From 93d13e02a875b09b22e1dfa9c66ab87808c5a90d Mon Sep 17 00:00:00 2001 From: Antoine Beyeler Date: Wed, 26 Jun 2024 17:22:16 +0200 Subject: [PATCH 1/6] - Added force_background to ListItem - Added show_hierarchical_with_children_unindented to ListItem - Re-implemented LargeCollapsingHeader using ListItem --- .../re_blueprint_tree/src/blueprint_tree.rs | 2 + crates/re_time_panel/src/lib.rs | 1 + crates/re_ui/src/design_tokens.rs | 6 + crates/re_ui/src/list_item/label_content.rs | 4 +- crates/re_ui/src/list_item/list_item.rs | 111 ++++++++++--- crates/re_ui/src/ui_ext.rs | 151 ++++-------------- 6 files changed, 127 insertions(+), 148 deletions(-) diff --git a/crates/re_blueprint_tree/src/blueprint_tree.rs b/crates/re_blueprint_tree/src/blueprint_tree.rs index 2ecfa50794b4..e94e1c2bc466 100644 --- a/crates/re_blueprint_tree/src/blueprint_tree.rs +++ b/crates/re_blueprint_tree/src/blueprint_tree.rs @@ -277,6 +277,7 @@ impl BlueprintTree { let list_item::ShowCollapsingResponse { item_response: response, body_response, + .. } = ui .list_item() .selected(ctx.selection().contains_item(&item)) @@ -363,6 +364,7 @@ impl BlueprintTree { let list_item::ShowCollapsingResponse { item_response: mut response, body_response, + .. } = ui .list_item() .selected(ctx.selection().contains_item(&item)) diff --git a/crates/re_time_panel/src/lib.rs b/crates/re_time_panel/src/lib.rs index 8939826d6764..68b045087bcf 100644 --- a/crates/re_time_panel/src/lib.rs +++ b/crates/re_time_panel/src/lib.rs @@ -613,6 +613,7 @@ impl TimePanel { let list_item::ShowCollapsingResponse { item_response: response, body_response, + .. } = ui .list_item() .selected(is_selected) diff --git a/crates/re_ui/src/design_tokens.rs b/crates/re_ui/src/design_tokens.rs index dc3e247b89f6..0e741c587a59 100644 --- a/crates/re_ui/src/design_tokens.rs +++ b/crates/re_ui/src/design_tokens.rs @@ -378,6 +378,12 @@ impl DesignTokens { Self::small_icon_size() } + // The color for the background of the large collapsing headers + pub fn large_collapsing_header_color() -> egui::Color32 { + // same as visuals.widgets.inactive.bg_fill + egui::Color32::from_gray(50) + } + /// The color we use to mean "loop this selection" pub fn loop_selection_color() -> egui::Color32 { egui::Color32::from_rgb(1, 37, 105) // from figma 2023-02-09 diff --git a/crates/re_ui/src/list_item/label_content.rs b/crates/re_ui/src/list_item/label_content.rs index 92956eefb7bb..8516adfa4495 100644 --- a/crates/re_ui/src/list_item/label_content.rs +++ b/crates/re_ui/src/list_item/label_content.rs @@ -14,7 +14,7 @@ pub struct LabelContent<'a> { italics: bool, label_style: LabelStyle, - icon_fn: Option>, + icon_fn: Option>, buttons_fn: Option egui::Response + 'a>>, always_show_buttons: bool, @@ -115,7 +115,7 @@ impl<'a> LabelContent<'a> { #[inline] pub fn with_icon_fn( mut self, - icon_fn: impl FnOnce(&egui::Ui, egui::Rect, egui::style::WidgetVisuals) + 'a, + icon_fn: impl FnOnce(&mut egui::Ui, egui::Rect, egui::style::WidgetVisuals) + 'a, ) -> Self { self.icon_fn = Some(Box::new(icon_fn)); self diff --git a/crates/re_ui/src/list_item/list_item.rs b/crates/re_ui/src/list_item/list_item.rs index ade991488258..54a763a6fb06 100644 --- a/crates/re_ui/src/list_item/list_item.rs +++ b/crates/re_ui/src/list_item/list_item.rs @@ -23,6 +23,9 @@ pub struct ShowCollapsingResponse { /// Response from the body, if it was displayed. pub body_response: Option>, + + /// 0.0 if fully closed, 1.0 if fully open, and something in-between while animating. + pub openness: f32, } /// Content-generic list item. @@ -45,6 +48,7 @@ pub struct ListItem { pub draggable: bool, pub drag_target: bool, pub force_hovered: bool, + force_background: Option, pub collapse_openness: Option, height: f32, } @@ -57,6 +61,7 @@ impl Default for ListItem { draggable: false, drag_target: false, force_hovered: false, + force_background: None, collapse_openness: None, height: DesignTokens::list_item_height(), } @@ -117,6 +122,16 @@ impl ListItem { self } + /// Override the background color for the item. + /// + /// If set, this takes precedence over [`Self::force_hovered`] and any kind of selection/ + /// interaction-driven background handling. + #[inline] + pub fn force_background(mut self, force_background: egui::Color32) -> Self { + self.force_background = Some(force_background); + self + } + /// Set the item height. /// /// The default is provided by [`DesignTokens::list_item_height`] and is suitable for hierarchical @@ -156,15 +171,49 @@ impl ListItem { /// Draw the item as a non-leaf node from a hierarchical list. /// - /// The `id` should be globally unique! - /// You can use `ui.make_persistent_id(…)` for that. + /// The `id` should be globally unique! You can use `ui.make_persistent_id(…)` for that. The + /// children content is indented. /// /// *Important*: must be called while nested in a [`super::list_item_scope`]. pub fn show_hierarchical_with_children( + self, + ui: &mut Ui, + id: egui::Id, + default_open: bool, + content: impl ListItemContent, + add_children: impl FnOnce(&mut egui::Ui) -> R, + ) -> ShowCollapsingResponse { + self.show_hierarchical_with_children_impl(ui, id, default_open, true, content, add_children) + } + + /// Draw the item with unindented child content. + /// + /// This is similar to [`Self::show_hierarchical_with_children`] but without indent. This is + /// only for special cases such as [`crate::UiExt::large_collapsing_header`]. + pub fn show_hierarchical_with_children_unindented( + self, + ui: &mut Ui, + id: egui::Id, + default_open: bool, + content: impl ListItemContent, + add_children: impl FnOnce(&mut egui::Ui) -> R, + ) -> ShowCollapsingResponse { + self.show_hierarchical_with_children_impl( + ui, + id, + default_open, + false, + content, + add_children, + ) + } + + fn show_hierarchical_with_children_impl( mut self, ui: &mut Ui, id: egui::Id, default_open: bool, + indented: bool, content: impl ListItemContent, add_children: impl FnOnce(&mut egui::Ui) -> R, ) -> ShowCollapsingResponse { @@ -175,7 +224,8 @@ impl ListItem { ); // enable collapsing arrow - self.collapse_openness = Some(state.openness(ui.ctx())); + let openness = state.openness(ui.ctx()); + self.collapse_openness = Some(openness); // Note: the purpose of the scope is to minimise interferences on subsequent items' id let response = ui @@ -193,15 +243,20 @@ impl ListItem { let body_response = ui .scope(|ui| { - ui.spacing_mut().indent = - DesignTokens::small_icon_size().x + DesignTokens::text_to_icon_padding(); - state.show_body_indented(&response.response, ui, |ui| add_children(ui)) + if indented { + ui.spacing_mut().indent = + DesignTokens::small_icon_size().x + DesignTokens::text_to_icon_padding(); + state.show_body_indented(&response.response, ui, |ui| add_children(ui)) + } else { + state.show_body_unindented(ui, |ui| add_children(ui)) + } }) .inner; ShowCollapsingResponse { item_response: response.response, body_response, + openness, } } @@ -218,6 +273,7 @@ impl ListItem { draggable, drag_target, force_hovered, + force_background, collapse_openness, height, } = self; @@ -330,33 +386,38 @@ impl ListItem { // fractional pixels. let bg_rect_to_paint = ui.painter().round_rect_to_pixels(bg_rect); - // Draw background on interaction. if drag_target { ui.painter().set( background_frame, Shape::rect_stroke(bg_rect_to_paint, 0.0, ui.ctx().hover_stroke()), ); - } else if interactive { - let bg_fill = if !response.hovered() && ui.rect_contains_pointer(bg_rect) { - // if some part of the content is active and hovered, our background should - // become dimmer - Some(visuals.bg_fill) - } else if selected - || style_response.hovered() - || style_response.highlighted() - || style_response.has_focus() - { - Some(visuals.weak_bg_fill) + } + + let bg_fill = force_background.or_else(|| { + if !drag_target && interactive { + if !response.hovered() && ui.rect_contains_pointer(bg_rect) { + // if some part of the content is active and hovered, our background should + // become dimmer + Some(visuals.bg_fill) + } else if selected + || style_response.hovered() + || style_response.highlighted() + || style_response.has_focus() + { + Some(visuals.weak_bg_fill) + } else { + None + } } else { None - }; - - if let Some(bg_fill) = bg_fill { - ui.painter().set( - background_frame, - Shape::rect_filled(bg_rect_to_paint, 0.0, bg_fill), - ); } + }); + + if let Some(bg_fill) = bg_fill { + ui.painter().set( + background_frame, + Shape::rect_filled(bg_rect_to_paint, 0.0, bg_fill), + ); } } diff --git a/crates/re_ui/src/ui_ext.rs b/crates/re_ui/src/ui_ext.rs index 8c59569a6734..7b6d36cde88c 100644 --- a/crates/re_ui/src/ui_ext.rs +++ b/crates/re_ui/src/ui_ext.rs @@ -1,4 +1,3 @@ -use eframe::epaint::text::TextWrapping; use std::hash::Hash; use egui::{ @@ -55,8 +54,12 @@ impl<'a> HeaderMenuButton<'a> { self } - fn show(self, ui: &mut egui::Ui) { + fn show(self, ui: &mut egui::Ui) -> egui::Response { ui.add_enabled_ui(self.enabled, |ui| { + if !self.enabled { + ui.disable() + } + ui.spacing_mut().item_spacing = egui::Vec2::ZERO; let mut response = egui::menu::menu_image_button( @@ -69,9 +72,12 @@ impl<'a> HeaderMenuButton<'a> { response = response.on_hover_text(hover_text); } if let Some(disabled_hover_text) = self.disabled_hover_text { - response.on_disabled_hover_text(disabled_hover_text); + response = response.on_disabled_hover_text(disabled_hover_text); } - }); + + response + }) + .inner } } @@ -1074,126 +1080,29 @@ fn large_collapsing_header_impl( add_body: impl FnOnce(&mut egui::Ui), button: Option>, ) -> egui::CollapsingResponse<()> { - let mut state = egui::collapsing_header::CollapsingState::load_with_default_open( - ui.ctx(), - ui.make_persistent_id(label), - default_open, - ); - - let openness = state.openness(ui.ctx()); - - let height = DesignTokens::list_item_height(); - - // In some cases, the available width is not even, which cause some instability with the nested - // left-to-right in right-to-left UIs. Thus the `floor()`. - let header_size = egui::vec2(ui.available_width().floor(), height); - - let header_response = ui - .scope(|ui| { - ui.spacing_mut().item_spacing = egui::vec2(0.0, 0.0); - - ui.allocate_ui_with_layout( - header_size, - egui::Layout::right_to_left(egui::Align::Center), - |ui| { - ui.visuals_mut().widgets.hovered.expansion = 0.0; - ui.visuals_mut().widgets.active.expansion = 0.0; - ui.visuals_mut().widgets.open.expansion = 0.0; - - let background_frame = ui.painter().add(egui::Shape::Noop); - - // draw button if any, and extract its width - let button_width = if let Some(button) = button { - button.show(ui); - ui.min_rect().width() + ui.spacing().icon_spacing - } else { - 0.0 - }; - - let header_size_without_button = - egui::vec2((header_size.x - button_width).floor(), header_size.y); - - ui.allocate_ui_with_layout( - header_size_without_button, - egui::Layout::left_to_right(egui::Align::Center), - |ui| { - let space_before_icon = 0.0; - let icon_width = ui.spacing().icon_width_inner; - let space_after_icon = ui.spacing().icon_spacing; - - let mut layout_job = egui::WidgetText::from(label).into_layout_job( - ui.style(), - egui::FontSelection::Default, - egui::Align::LEFT, - ); - layout_job.wrap = TextWrapping::truncate_at_width( - header_size_without_button.x - - (space_before_icon + icon_width + space_after_icon), - ); - let galley = ui.fonts(|fonts| fonts.layout_job(layout_job)); - - let header_response = ui.allocate_response( - header_size_without_button, - egui::Sense::click(), - ); - let rect = header_response.rect; - - let icon_rect = egui::Rect::from_center_size( - header_response.rect.left_center() - + egui::vec2(space_before_icon + icon_width / 2.0, 0.0), - egui::Vec2::splat(icon_width), - ); - let icon_response = header_response.clone().with_new_rect(icon_rect); - ui.paint_collapsing_triangle( - openness, - icon_rect.center(), - ui.style().interact(&icon_response), - ); - - let visuals = ui.style().interact(&header_response); - - let optical_vertical_alignment = 0.5; // improves perceived vertical alignment - let text_pos = icon_response.rect.right_center() - + egui::vec2( - space_after_icon, - -0.5 * galley.size().y + optical_vertical_alignment, - ); - - ui.painter().galley(text_pos, galley, visuals.text_color()); - - // Let the rect cover the full panel width: - let bg_rect = - egui::Rect::from_x_y_ranges(ui.full_span(), rect.y_range()); - - ui.painter().set( - background_frame, - Shape::rect_filled(bg_rect, 0.0, visuals.bg_fill), - ); - - if header_response.clicked() { - state.toggle(ui); - } - - header_response - }, - ) - }, - ) - }) - .inner - .inner - .inner; + let id = ui.make_persistent_id(label); - let body_response = state.show_body_unindented(ui, |ui| { - ui.add_space(4.0); // Add space only if there is a body to make minimized headers stick together. - add_body(ui); - ui.add_space(4.0); // Same here - }); + let mut content = list_item::LabelContent::new(label); + if let Some(button) = button { + content = content + .with_buttons(|ui| button.show(ui)) + .always_show_buttons(true); + } + + let resp = list_item::ListItem::new() + .interactive(true) + .force_background(DesignTokens::large_collapsing_header_color()) + .show_hierarchical_with_children_unindented(ui, id, default_open, content, |ui| { + //TODO(ab): this space is not desirable when the content actually is list items + ui.add_space(4.0); // Add space only if there is a body to make minimized headers stick together. + add_body(ui); + ui.add_space(4.0); // Same here + }); egui::CollapsingResponse { - header_response, - body_response: body_response.map(|r| r.response), + header_response: resp.item_response, + body_response: resp.body_response.map(|r| r.response), body_returned: None, - openness, + openness: resp.openness, } } From ecba5b4e9a6ebc26a31a54ceaffd19d5b280c7b5 Mon Sep 17 00:00:00 2001 From: Antoine Beyeler Date: Wed, 26 Jun 2024 18:30:26 +0200 Subject: [PATCH 2/6] - Refactor into SectionCollapsingHeader with builder pattern and support for help tooltip --- crates/re_selection_panel/src/defaults_ui.rs | 4 +- .../re_selection_panel/src/selection_panel.rs | 144 +++++++------- .../src/visible_time_range_ui.rs | 7 +- .../re_selection_panel/src/visualizer_ui.rs | 35 ++-- crates/re_ui/examples/re_ui_example/main.rs | 17 +- crates/re_ui/src/lib.rs | 4 +- crates/re_ui/src/section_collapsing_header.rs | 172 ++++++++++++++++ crates/re_ui/src/ui_ext.rs | 183 ++++++------------ 8 files changed, 340 insertions(+), 226 deletions(-) create mode 100644 crates/re_ui/src/section_collapsing_header.rs diff --git a/crates/re_selection_panel/src/defaults_ui.rs b/crates/re_selection_panel/src/defaults_ui.rs index 4381fec5a1d1..0d8fe092a189 100644 --- a/crates/re_selection_panel/src/defaults_ui.rs +++ b/crates/re_selection_panel/src/defaults_ui.rs @@ -56,7 +56,9 @@ pub fn view_components_defaults_section_ui( db, ); }; - ui.large_collapsing_header_with_button("Component defaults", true, body, add_button); + ui.section_collapsing_header("Component defaults") + .button(add_button) + .show(ui, body); } fn active_default_ui( diff --git a/crates/re_selection_panel/src/selection_panel.rs b/crates/re_selection_panel/src/selection_panel.rs index 60356e58f87b..67565fa37cff 100644 --- a/crates/re_selection_panel/src/selection_panel.rs +++ b/crates/re_selection_panel/src/selection_panel.rs @@ -275,7 +275,7 @@ impl SelectionPanel { } if let Some(data_ui_item) = data_section_ui(item) { - ui.large_collapsing_header("Data", true, |ui| { + ui.section_collapsing_header("Data").show(ui, |ui| { // TODO(#6075): Because `list_item_scope` changes it. Temporary until everything is `ListItem`. ui.spacing_mut().item_spacing.y = ui.ctx().style().spacing.item_spacing.y; @@ -319,74 +319,6 @@ impl SelectionPanel { view_id: &SpaceViewId, view_states: &mut ViewStates, ) { - clone_space_view_button_ui(ctx, ui, blueprint, *view_id); - - if let Some(view) = blueprint.view(view_id) { - ui.large_collapsing_header("Entity path filter", true, |ui| { - // TODO(#6075): Because `list_item_scope` changes it. Temporary until everything is `ListItem`. - ui.spacing_mut().item_spacing.y = ui.ctx().style().spacing.item_spacing.y; - - if let Some(new_entity_path_filter) = self.entity_path_filter_ui( - ctx, - ui, - *view_id, - &view.contents.entity_path_filter, - &view.space_origin, - ) { - view.contents - .set_entity_path_filter(ctx, &new_entity_path_filter); - } - }) - .header_response - .on_hover_text( - "The entity path query consists of a list of include/exclude rules \ - that determines what entities are part of this view", - ); - } - - if let Some(view) = blueprint.view(view_id) { - let view_class = view.class(ctx.space_view_class_registry); - let view_state = view_states.get_mut_or_create(view.id, view_class); - - ui.large_collapsing_header("View properties", true, |ui| { - // TODO(#6075): Because `list_item_scope` changes it. Temporary until everything is `ListItem`. - ui.spacing_mut().item_spacing.y = ui.ctx().style().spacing.item_spacing.y; - - let cursor = ui.cursor(); - - if let Err(err) = - view_class.selection_ui(ctx, ui, view_state, &view.space_origin, view.id) - { - re_log::error_once!( - "Error in view selection UI (class: {}, display name: {}): {err}", - view.class_identifier(), - view_class.display_name(), - ); - } - - if cursor == ui.cursor() { - ui.weak("(none)"); - } - }); - - let view_ctx = view.bundle_context_with_state(ctx, view_state); - view_components_defaults_section_ui(&view_ctx, ui, view); - } - - if let Some(view) = blueprint.view(view_id) { - visible_time_range_ui_for_view(ctx, ui, view); - } - } - - /// Returns a new filter when the editing is done, and there has been a change. - fn entity_path_filter_ui( - &mut self, - ctx: &ViewerContext<'_>, - ui: &mut egui::Ui, - view_id: SpaceViewId, - filter: &EntityPathFilter, - origin: &EntityPath, - ) -> Option { fn entity_path_filter_help_ui(ui: &mut egui::Ui) { let markdown = r#" # Entity path query syntax @@ -428,6 +360,77 @@ The last rule matching `/world/house` is `+ /world/**`, so it is included. ui.markdown_ui(egui::Id::new("entity_path_filter_help_ui"), markdown); } + clone_space_view_button_ui(ctx, ui, blueprint, *view_id); + + if let Some(view) = blueprint.view(view_id) { + ui.section_collapsing_header("Entity path filter") + .help_ui(entity_path_filter_help_ui) + .show(ui, |ui| { + // TODO(#6075): Because `list_item_scope` changes it. Temporary until everything is `ListItem`. + ui.spacing_mut().item_spacing.y = ui.ctx().style().spacing.item_spacing.y; + + if let Some(new_entity_path_filter) = self.entity_path_filter_ui( + ctx, + ui, + *view_id, + &view.contents.entity_path_filter, + &view.space_origin, + ) { + view.contents + .set_entity_path_filter(ctx, &new_entity_path_filter); + } + }) + .header_response + .on_hover_text( + "The entity path query consists of a list of include/exclude rules \ + that determines what entities are part of this view", + ); + } + + if let Some(view) = blueprint.view(view_id) { + let view_class = view.class(ctx.space_view_class_registry); + let view_state = view_states.get_mut_or_create(view.id, view_class); + + ui.section_collapsing_header("View properties") + .show(ui, |ui| { + // TODO(#6075): Because `list_item_scope` changes it. Temporary until everything is `ListItem`. + ui.spacing_mut().item_spacing.y = ui.ctx().style().spacing.item_spacing.y; + + let cursor = ui.cursor(); + + if let Err(err) = + view_class.selection_ui(ctx, ui, view_state, &view.space_origin, view.id) + { + re_log::error_once!( + "Error in view selection UI (class: {}, display name: {}): {err}", + view.class_identifier(), + view_class.display_name(), + ); + } + + if cursor == ui.cursor() { + ui.weak("(none)"); + } + }); + + let view_ctx = view.bundle_context_with_state(ctx, view_state); + view_components_defaults_section_ui(&view_ctx, ui, view); + } + + if let Some(view) = blueprint.view(view_id) { + visible_time_range_ui_for_view(ctx, ui, view); + } + } + + /// Returns a new filter when the editing is done, and there has been a change. + fn entity_path_filter_ui( + &mut self, + ctx: &ViewerContext<'_>, + ui: &mut egui::Ui, + view_id: SpaceViewId, + filter: &EntityPathFilter, + origin: &EntityPath, + ) -> Option { fn syntax_highlight_entity_path_filter( style: &egui::Style, mut string: &str, @@ -507,9 +510,6 @@ The last rule matching `/world/house` is `+ /world/**`, so it is included. } ui.horizontal(|ui| { - ui.help_hover_button() - .on_hover_ui(entity_path_filter_help_ui); - if ui .button("Edit") .on_hover_text("Modify the entity query using the editor") diff --git a/crates/re_selection_panel/src/visible_time_range_ui.rs b/crates/re_selection_panel/src/visible_time_range_ui.rs index 7aa8e23d479a..7ec06697cb0d 100644 --- a/crates/re_selection_panel/src/visible_time_range_ui.rs +++ b/crates/re_selection_panel/src/visible_time_range_ui.rs @@ -174,9 +174,10 @@ fn query_range_ui( let mut interacting_with_controls = false; - let default_open = false; - let collapsing_response = - ui.large_collapsing_header("Visible time range", default_open, |ui| { + let collapsing_response = ui + .section_collapsing_header("Visible time range") + .default_open(false) + .show(ui, |ui| { ui.horizontal(|ui| { ui.re_radio_value(has_individual_time_range, false, "Default") .on_hover_text(if is_space_view { diff --git a/crates/re_selection_panel/src/visualizer_ui.rs b/crates/re_selection_panel/src/visualizer_ui.rs index 05caf2ceba11..f6ef906e59ca 100644 --- a/crates/re_selection_panel/src/visualizer_ui.rs +++ b/crates/re_selection_panel/src/visualizer_ui.rs @@ -37,25 +37,24 @@ pub fn visualizer_ui( &active_visualizers, ); - ui.large_collapsing_header_with_button( - "Visualizers", - true, - |ui| { + ui.section_collapsing_header("Visualizers") + .button( + re_ui::HeaderMenuButton::new(&re_ui::icons::ADD, |ui| { + menu_add_new_visualizer( + ctx, + ui, + &data_result, + &active_visualizers, + &available_inactive_visualizers, + ); + }) + .enabled(!available_inactive_visualizers.is_empty()) + .hover_text("Add additional visualizers") + .disabled_hover_text("No additional visualizers available"), + ) + .show(ui, |ui| { visualizer_ui_impl(ctx, ui, &data_result, &active_visualizers); - }, - re_ui::HeaderMenuButton::new(&re_ui::icons::ADD, |ui| { - menu_add_new_visualizer( - ctx, - ui, - &data_result, - &active_visualizers, - &available_inactive_visualizers, - ); - }) - .enabled(!available_inactive_visualizers.is_empty()) - .hover_text("Add additional visualizers") - .disabled_hover_text("No additional visualizers available"), - ); + }); } pub fn visualizer_ui_impl( diff --git a/crates/re_ui/examples/re_ui_example/main.rs b/crates/re_ui/examples/re_ui_example/main.rs index ed26993983a3..33950aaed14c 100644 --- a/crates/re_ui/examples/re_ui_example/main.rs +++ b/crates/re_ui/examples/re_ui_example/main.rs @@ -243,17 +243,14 @@ impl eframe::App for ExampleApp { // --- - ui.large_collapsing_header_with_button( - "Data", - true, - |ui| { - ui.label("Some data here"); - }, - re_ui::HeaderMenuButton::new(&re_ui::icons::ADD, |ui| { + ui.section_collapsing_header("Data") + .button(re_ui::HeaderMenuButton::new(&re_ui::icons::ADD, |ui| { ui.weak("empty"); - }), - ); - ui.large_collapsing_header("Blueprint", true, |ui| { + })) + .show(ui, |ui| { + ui.label("Some data here"); + }); + ui.section_collapsing_header("Blueprint").show(ui, |ui| { ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Extend); ui.label("Some blueprint stuff here, that might be wide."); ui.re_checkbox(&mut self.dummy_bool, "Checkbox"); diff --git a/crates/re_ui/src/lib.rs b/crates/re_ui/src/lib.rs index 8c90eee358d7..bdcdcccae588 100644 --- a/crates/re_ui/src/lib.rs +++ b/crates/re_ui/src/lib.rs @@ -11,6 +11,7 @@ pub mod drag_and_drop; pub mod icons; pub mod list_item; pub mod modal; +mod section_collapsing_header; pub mod toasts; mod ui_ext; @@ -21,8 +22,9 @@ pub use self::{ design_tokens::DesignTokens, icons::Icon, layout_job_builder::LayoutJobBuilder, + section_collapsing_header::{HeaderMenuButton, SectionCollapsingHeader}, syntax_highlighting::SyntaxHighlighting, - ui_ext::{HeaderMenuButton, UiExt}, + ui_ext::UiExt, }; // --------------------------------------------------------------------------- diff --git a/crates/re_ui/src/section_collapsing_header.rs b/crates/re_ui/src/section_collapsing_header.rs new file mode 100644 index 000000000000..443212a52978 --- /dev/null +++ b/crates/re_ui/src/section_collapsing_header.rs @@ -0,0 +1,172 @@ +use crate::{list_item, DesignTokens, Icon, UiExt as _}; + +/// Icon button to be used in the header of a panel. +pub struct HeaderMenuButton<'a> { + pub icon: &'static Icon, + pub add_contents: Box, + pub enabled: bool, + pub hover_text: Option, + pub disabled_hover_text: Option, +} + +impl<'a> HeaderMenuButton<'a> { + pub fn new(icon: &'static Icon, add_contents: impl FnOnce(&mut egui::Ui) + 'a) -> Self { + Self { + icon, + add_contents: Box::new(add_contents), + enabled: true, + hover_text: None, + disabled_hover_text: None, + } + } + + /// Sets enable/disable state of the button. + #[inline] + pub fn enabled(mut self, enabled: bool) -> Self { + self.enabled = enabled; + self + } + + /// Sets text shown when the button hovered. + #[inline] + pub fn hover_text(mut self, hover_text: impl Into) -> Self { + self.hover_text = Some(hover_text.into()); + self + } + + /// Sets text shown when the button is disabled and hovered. + #[inline] + pub fn disabled_hover_text(mut self, hover_text: impl Into) -> Self { + self.disabled_hover_text = Some(hover_text.into()); + self + } + + fn show(self, ui: &mut egui::Ui) -> egui::Response { + ui.add_enabled_ui(self.enabled, |ui| { + ui.spacing_mut().item_spacing = egui::Vec2::ZERO; + + let mut response = egui::menu::menu_image_button( + ui, + ui.small_icon_button_widget(self.icon), + self.add_contents, + ) + .response; + if let Some(hover_text) = self.hover_text { + response = response.on_hover_text(hover_text); + } + if let Some(disabled_hover_text) = self.disabled_hover_text { + response = response.on_disabled_hover_text(disabled_hover_text); + } + + response + }) + .inner + } +} + +/// A collapsible section header, with support for optional help tooltip and button. +#[allow(clippy::type_complexity)] +pub struct SectionCollapsingHeader<'a> { + label: egui::WidgetText, + default_open: bool, + button: Option>, + help: Option>, +} + +impl<'a> SectionCollapsingHeader<'a> { + /// Create a new [`Self`]. + /// + /// See also [`crate::UiExt::section_collapsing_header`] + pub fn new(label: impl Into) -> Self { + Self { + label: label.into(), + default_open: true, + button: None, + help: None, + } + } + + /// Set the default open state of the section header. + /// + /// Defaults to `true`. + #[inline] + pub fn default_open(mut self, default_open: bool) -> Self { + self.default_open = default_open; + self + } + + /// Set the button to be shown in the header. + #[inline] + pub fn button(mut self, button: HeaderMenuButton<'a>) -> Self { + self.button = Some(button); + self + } + + /// Set the help text tooltip to be shown in the header. + #[inline] + pub fn help_text(mut self, help: impl Into) -> Self { + let help = help.into(); + self.help = Some(Box::new(move |ui| { + ui.label(help); + })); + self + } + + /// Set the help UI closure to be shown in the header. + #[inline] + pub fn help_ui(mut self, help: impl FnOnce(&mut egui::Ui) + 'a) -> Self { + self.help = Some(Box::new(help)); + self + } + + /// Display the header. + pub fn show( + self, + ui: &mut egui::Ui, + add_body: impl FnOnce(&mut egui::Ui), + ) -> egui::CollapsingResponse<()> { + let Self { + label, + default_open, + button, + help, + } = self; + + let id = ui.make_persistent_id(label.text()); + + let mut content = list_item::LabelContent::new(label); + if button.is_some() || help.is_some() { + content = content + .with_buttons(|ui| { + let button_response = button.map(|button| button.show(ui)); + let help_response = help.map(|help| ui.help_hover_button().on_hover_ui(help)); + + match (button_response, help_response) { + (Some(button_response), Some(help_response)) => { + button_response | help_response + } + (Some(response), None) | (None, Some(response)) => response, + (None, None) => unreachable!("at least one of button or help is set"), + } + }) + .always_show_buttons(true); + } + + let resp = list_item::ListItem::new() + .interactive(true) + .force_background(DesignTokens::large_collapsing_header_color()) + .show_hierarchical_with_children_unindented(ui, id, default_open, content, |ui| { + //TODO(ab): this space is not desirable when the content actually is list items + ui.add_space(4.0); // Add space only if there is a body to make minimized headers stick together. + add_body(ui); + ui.add_space(4.0); // Same here + }); + + egui::CollapsingResponse { + header_response: resp.item_response, + body_response: resp.body_response.map(|r| r.response), + body_returned: None, + openness: resp.openness, + } + } +} diff --git a/crates/re_ui/src/ui_ext.rs b/crates/re_ui/src/ui_ext.rs index 7b6d36cde88c..0423bf3e9c8b 100644 --- a/crates/re_ui/src/ui_ext.rs +++ b/crates/re_ui/src/ui_ext.rs @@ -13,74 +13,6 @@ use crate::{ static FULL_SPAN_TAG: &str = "rerun_full_span"; -/// Icon button to be used in the header of a panel. -pub struct HeaderMenuButton<'a> { - pub icon: &'static Icon, - pub add_contents: Box, - pub enabled: bool, - pub hover_text: Option, - pub disabled_hover_text: Option, -} - -impl<'a> HeaderMenuButton<'a> { - pub fn new(icon: &'static Icon, add_contents: impl FnOnce(&mut egui::Ui) + 'a) -> Self { - Self { - icon, - add_contents: Box::new(add_contents), - enabled: true, - hover_text: None, - disabled_hover_text: None, - } - } - - /// Sets enable/disable state of the button. - #[inline] - pub fn enabled(mut self, enabled: bool) -> Self { - self.enabled = enabled; - self - } - - /// Sets text shown when the button hovered. - #[inline] - pub fn hover_text(mut self, hover_text: impl Into) -> Self { - self.hover_text = Some(hover_text.into()); - self - } - - /// Sets text shown when the button is disabled and hovered. - #[inline] - pub fn disabled_hover_text(mut self, hover_text: impl Into) -> Self { - self.disabled_hover_text = Some(hover_text.into()); - self - } - - fn show(self, ui: &mut egui::Ui) -> egui::Response { - ui.add_enabled_ui(self.enabled, |ui| { - if !self.enabled { - ui.disable() - } - - ui.spacing_mut().item_spacing = egui::Vec2::ZERO; - - let mut response = egui::menu::menu_image_button( - ui, - ui.small_icon_button_widget(self.icon), - self.add_contents, - ) - .response; - if let Some(hover_text) = self.hover_text { - response = response.on_hover_text(hover_text); - } - if let Some(disabled_hover_text) = self.disabled_hover_text { - response = response.on_disabled_hover_text(disabled_hover_text); - } - - response - }) - .inner - } -} - /// Rerun custom extensions to [`egui::Ui`]. pub trait UiExt { fn ui(&self) -> &egui::Ui; @@ -520,26 +452,26 @@ pub trait UiExt { } } - /// Show a prominent collapsing header to be used as section delimitation in side panels. - fn large_collapsing_header( - &mut self, - label: &str, - default_open: bool, - add_body: impl FnOnce(&mut egui::Ui), - ) -> egui::CollapsingResponse<()> { - large_collapsing_header_impl(self.ui_mut(), label, default_open, add_body, None) - } - - /// Show a prominent collapsing header to be used as section delimitation in side panels with an image button. - fn large_collapsing_header_with_button( - &mut self, - label: &str, - default_open: bool, - add_body: impl FnOnce(&mut egui::Ui), - button: HeaderMenuButton<'_>, - ) -> egui::CollapsingResponse<()> { - large_collapsing_header_impl(self.ui_mut(), label, default_open, add_body, Some(button)) - } + // /// Show a prominent collapsing header to be used as section delimitation in side panels. + // fn large_collapsing_header( + // &mut self, + // label: &str, + // default_open: bool, + // add_body: impl FnOnce(&mut egui::Ui), + // ) -> egui::CollapsingResponse<()> { + // large_collapsing_header_impl(self.ui_mut(), label, default_open, add_body, None) + // } + // + // /// Show a prominent collapsing header to be used as section delimitation in side panels with an image button. + // fn large_collapsing_header_with_button( + // &mut self, + // label: &str, + // default_open: bool, + // add_body: impl FnOnce(&mut egui::Ui), + // button: HeaderMenuButton<'_>, + // ) -> egui::CollapsingResponse<()> { + // large_collapsing_header_impl(self.ui_mut(), label, default_open, add_body, Some(button)) + // } /// Paint a collapsing triangle with rounded corners. /// @@ -665,6 +597,15 @@ pub trait UiExt { .show_flat(self.ui_mut(), content) } + /// Convenience function to create a [`crate::SectionCollapsingHeader`]. + #[allow(clippy:unused_self)] + fn section_collapsing_header<'a>( + &self, + label: impl Into, + ) -> crate::SectionCollapsingHeader<'a> { + crate::SectionCollapsingHeader::new(label) + } + fn selectable_label_with_icon( &mut self, @@ -1073,36 +1014,36 @@ impl UiExt for egui::Ui { } } -fn large_collapsing_header_impl( - ui: &mut egui::Ui, - label: &str, - default_open: bool, - add_body: impl FnOnce(&mut egui::Ui), - button: Option>, -) -> egui::CollapsingResponse<()> { - let id = ui.make_persistent_id(label); - - let mut content = list_item::LabelContent::new(label); - if let Some(button) = button { - content = content - .with_buttons(|ui| button.show(ui)) - .always_show_buttons(true); - } - - let resp = list_item::ListItem::new() - .interactive(true) - .force_background(DesignTokens::large_collapsing_header_color()) - .show_hierarchical_with_children_unindented(ui, id, default_open, content, |ui| { - //TODO(ab): this space is not desirable when the content actually is list items - ui.add_space(4.0); // Add space only if there is a body to make minimized headers stick together. - add_body(ui); - ui.add_space(4.0); // Same here - }); - - egui::CollapsingResponse { - header_response: resp.item_response, - body_response: resp.body_response.map(|r| r.response), - body_returned: None, - openness: resp.openness, - } -} +// fn large_collapsing_header_impl( +// ui: &mut egui::Ui, +// label: &str, +// default_open: bool, +// add_body: impl FnOnce(&mut egui::Ui), +// button: Option>, +// ) -> egui::CollapsingResponse<()> { +// let id = ui.make_persistent_id(label); +// +// let mut content = list_item::LabelContent::new(label); +// if let Some(button) = button { +// content = content +// .with_buttons(|ui| button.show(ui)) +// .always_show_buttons(true); +// } +// +// let resp = list_item::ListItem::new() +// .interactive(true) +// .force_background(DesignTokens::large_collapsing_header_color()) +// .show_hierarchical_with_children_unindented(ui, id, default_open, content, |ui| { +// //TODO(ab): this space is not desirable when the content actually is list items +// ui.add_space(4.0); // Add space only if there is a body to make minimized headers stick together. +// add_body(ui); +// ui.add_space(4.0); // Same here +// }); +// +// egui::CollapsingResponse { +// header_response: resp.item_response, +// body_response: resp.body_response.map(|r| r.response), +// body_returned: None, +// openness: resp.openness, +// } +// } From 7fafa07b279530749ee30be064421e73ff99bed7 Mon Sep 17 00:00:00 2001 From: Antoine Beyeler Date: Wed, 26 Jun 2024 18:43:42 +0200 Subject: [PATCH 3/6] Remove useless ui.horizontal --- crates/re_selection_panel/src/selection_panel.rs | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/crates/re_selection_panel/src/selection_panel.rs b/crates/re_selection_panel/src/selection_panel.rs index 67565fa37cff..21a127a05eaa 100644 --- a/crates/re_selection_panel/src/selection_panel.rs +++ b/crates/re_selection_panel/src/selection_panel.rs @@ -509,15 +509,13 @@ The last rule matching `/world/house` is `+ /world/**`, so it is included. )); } - ui.horizontal(|ui| { - if ui - .button("Edit") - .on_hover_text("Modify the entity query using the editor") - .clicked() - { - self.space_view_entity_modal.open(view_id); - } - }); + if ui + .button("Edit") + .on_hover_text("Modify the entity query using the editor") + .clicked() + { + self.space_view_entity_modal.open(view_id); + } // Apply the edit. let new_filter = EntityPathFilter::parse_forgiving(&filter_string, &Default::default()); From edf548c8e9674bc812e2939bfbe7af79a0039927 Mon Sep 17 00:00:00 2001 From: Antoine Beyeler Date: Wed, 26 Jun 2024 18:47:41 +0200 Subject: [PATCH 4/6] dead code + cleanup --- .../re_selection_panel/src/visualizer_ui.rs | 28 +++++----- crates/re_ui/src/ui_ext.rs | 55 ------------------- 2 files changed, 14 insertions(+), 69 deletions(-) diff --git a/crates/re_selection_panel/src/visualizer_ui.rs b/crates/re_selection_panel/src/visualizer_ui.rs index f6ef906e59ca..1d44f3396ae7 100644 --- a/crates/re_selection_panel/src/visualizer_ui.rs +++ b/crates/re_selection_panel/src/visualizer_ui.rs @@ -37,21 +37,21 @@ pub fn visualizer_ui( &active_visualizers, ); + let button = re_ui::HeaderMenuButton::new(&re_ui::icons::ADD, |ui| { + menu_add_new_visualizer( + ctx, + ui, + &data_result, + &active_visualizers, + &available_inactive_visualizers, + ); + }) + .enabled(!available_inactive_visualizers.is_empty()) + .hover_text("Add additional visualizers") + .disabled_hover_text("No additional visualizers available"); + ui.section_collapsing_header("Visualizers") - .button( - re_ui::HeaderMenuButton::new(&re_ui::icons::ADD, |ui| { - menu_add_new_visualizer( - ctx, - ui, - &data_result, - &active_visualizers, - &available_inactive_visualizers, - ); - }) - .enabled(!available_inactive_visualizers.is_empty()) - .hover_text("Add additional visualizers") - .disabled_hover_text("No additional visualizers available"), - ) + .button(button) .show(ui, |ui| { visualizer_ui_impl(ctx, ui, &data_result, &active_visualizers); }); diff --git a/crates/re_ui/src/ui_ext.rs b/crates/re_ui/src/ui_ext.rs index 0423bf3e9c8b..bb0c6facdb81 100644 --- a/crates/re_ui/src/ui_ext.rs +++ b/crates/re_ui/src/ui_ext.rs @@ -452,27 +452,6 @@ pub trait UiExt { } } - // /// Show a prominent collapsing header to be used as section delimitation in side panels. - // fn large_collapsing_header( - // &mut self, - // label: &str, - // default_open: bool, - // add_body: impl FnOnce(&mut egui::Ui), - // ) -> egui::CollapsingResponse<()> { - // large_collapsing_header_impl(self.ui_mut(), label, default_open, add_body, None) - // } - // - // /// Show a prominent collapsing header to be used as section delimitation in side panels with an image button. - // fn large_collapsing_header_with_button( - // &mut self, - // label: &str, - // default_open: bool, - // add_body: impl FnOnce(&mut egui::Ui), - // button: HeaderMenuButton<'_>, - // ) -> egui::CollapsingResponse<()> { - // large_collapsing_header_impl(self.ui_mut(), label, default_open, add_body, Some(button)) - // } - /// Paint a collapsing triangle with rounded corners. /// /// Alternative to [`egui::collapsing_header::paint_default_icon`]. Note that the triangle is @@ -1013,37 +992,3 @@ impl UiExt for egui::Ui { self } } - -// fn large_collapsing_header_impl( -// ui: &mut egui::Ui, -// label: &str, -// default_open: bool, -// add_body: impl FnOnce(&mut egui::Ui), -// button: Option>, -// ) -> egui::CollapsingResponse<()> { -// let id = ui.make_persistent_id(label); -// -// let mut content = list_item::LabelContent::new(label); -// if let Some(button) = button { -// content = content -// .with_buttons(|ui| button.show(ui)) -// .always_show_buttons(true); -// } -// -// let resp = list_item::ListItem::new() -// .interactive(true) -// .force_background(DesignTokens::large_collapsing_header_color()) -// .show_hierarchical_with_children_unindented(ui, id, default_open, content, |ui| { -// //TODO(ab): this space is not desirable when the content actually is list items -// ui.add_space(4.0); // Add space only if there is a body to make minimized headers stick together. -// add_body(ui); -// ui.add_space(4.0); // Same here -// }); -// -// egui::CollapsingResponse { -// header_response: resp.item_response, -// body_response: resp.body_response.map(|r| r.response), -// body_returned: None, -// openness: resp.openness, -// } -// } From 40e52d78249af60bedc998cfbcee76578bf132d0 Mon Sep 17 00:00:00 2001 From: Antoine Beyeler Date: Wed, 26 Jun 2024 21:47:10 +0200 Subject: [PATCH 5/6] minor fixes --- crates/re_selection_panel/src/defaults_ui.rs | 2 +- .../re_selection_panel/src/space_view_space_origin_ui.rs | 3 +-- crates/re_selection_panel/src/visualizer_ui.rs | 8 +++++++- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/crates/re_selection_panel/src/defaults_ui.rs b/crates/re_selection_panel/src/defaults_ui.rs index 0d8fe092a189..0b9466da750d 100644 --- a/crates/re_selection_panel/src/defaults_ui.rs +++ b/crates/re_selection_panel/src/defaults_ui.rs @@ -85,7 +85,7 @@ fn active_default_ui( ui.spacing_mut().item_spacing.y = 0.0; if sorted_overrides.is_empty() { - ui.list_item_flat_noninteractive(LabelContent::new("(none)").weak(true)); + ui.list_item_flat_noninteractive(LabelContent::new("none").weak(true).italics(true)); } for component_name in sorted_overrides { diff --git a/crates/re_selection_panel/src/space_view_space_origin_ui.rs b/crates/re_selection_panel/src/space_view_space_origin_ui.rs index 4b072a7027c8..fc1ed68d27a8 100644 --- a/crates/re_selection_panel/src/space_view_space_origin_ui.rs +++ b/crates/re_selection_panel/src/space_view_space_origin_ui.rs @@ -206,8 +206,7 @@ fn space_view_space_origin_widget_editing_ui( let excluded_count = space_view_suggestions.len() - filtered_space_view_suggestions.len(); if excluded_count > 0 { - ui.list_item().interactive(false).show_flat( - ui, + ui.list_item_flat_noninteractive( list_item::LabelContent::new(format!("{excluded_count} hidden suggestions")) .weak(true) .italics(true), diff --git a/crates/re_selection_panel/src/visualizer_ui.rs b/crates/re_selection_panel/src/visualizer_ui.rs index 1d44f3396ae7..74cbf3f57d61 100644 --- a/crates/re_selection_panel/src/visualizer_ui.rs +++ b/crates/re_selection_panel/src/visualizer_ui.rs @@ -87,7 +87,13 @@ pub fn visualizer_ui_impl( }; list_item::list_item_scope(ui, "visualizers", |ui| { - ui.spacing_mut().item_spacing.y = 0.0; + if active_visualizers.is_empty() { + ui.list_item_flat_noninteractive( + list_item::LabelContent::new("none") + .weak(true) + .italics(true), + ); + } for &visualizer_id in active_visualizers { let default_open = true; From aa83f81fe78f34b1c9396681c58da77b8a64ccd5 Mon Sep 17 00:00:00 2001 From: Antoine Beyeler Date: Thu, 27 Jun 2024 08:14:40 +0200 Subject: [PATCH 6/6] fix references to `large_collapsing_header` --- crates/re_ui/src/design_tokens.rs | 4 ++-- crates/re_ui/src/list_item/list_item.rs | 2 +- crates/re_ui/src/section_collapsing_header.rs | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/re_ui/src/design_tokens.rs b/crates/re_ui/src/design_tokens.rs index 0e741c587a59..b0ec8b09c557 100644 --- a/crates/re_ui/src/design_tokens.rs +++ b/crates/re_ui/src/design_tokens.rs @@ -378,8 +378,8 @@ impl DesignTokens { Self::small_icon_size() } - // The color for the background of the large collapsing headers - pub fn large_collapsing_header_color() -> egui::Color32 { + /// The color for the background of [`crate::SectionCollapsingHeader`]. + pub fn section_collapsing_header_color() -> egui::Color32 { // same as visuals.widgets.inactive.bg_fill egui::Color32::from_gray(50) } diff --git a/crates/re_ui/src/list_item/list_item.rs b/crates/re_ui/src/list_item/list_item.rs index 54a763a6fb06..f004bede9a7e 100644 --- a/crates/re_ui/src/list_item/list_item.rs +++ b/crates/re_ui/src/list_item/list_item.rs @@ -189,7 +189,7 @@ impl ListItem { /// Draw the item with unindented child content. /// /// This is similar to [`Self::show_hierarchical_with_children`] but without indent. This is - /// only for special cases such as [`crate::UiExt::large_collapsing_header`]. + /// only for special cases such as [`crate::SectionCollapsingHeader`]. pub fn show_hierarchical_with_children_unindented( self, ui: &mut Ui, diff --git a/crates/re_ui/src/section_collapsing_header.rs b/crates/re_ui/src/section_collapsing_header.rs index 443212a52978..25fe9f4d76ad 100644 --- a/crates/re_ui/src/section_collapsing_header.rs +++ b/crates/re_ui/src/section_collapsing_header.rs @@ -154,7 +154,7 @@ impl<'a> SectionCollapsingHeader<'a> { let resp = list_item::ListItem::new() .interactive(true) - .force_background(DesignTokens::large_collapsing_header_color()) + .force_background(DesignTokens::section_collapsing_header_color()) .show_hierarchical_with_children_unindented(ui, id, default_open, content, |ui| { //TODO(ab): this space is not desirable when the content actually is list items ui.add_space(4.0); // Add space only if there is a body to make minimized headers stick together.