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_selection_panel/src/defaults_ui.rs b/crates/re_selection_panel/src/defaults_ui.rs index 4381fec5a1d1..0b9466da750d 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( @@ -83,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/selection_panel.rs b/crates/re_selection_panel/src/selection_panel.rs index 60356e58f87b..21a127a05eaa 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, @@ -506,18 +509,13 @@ 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") - .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()); 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/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..74cbf3f57d61 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| { + 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(button) + .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( @@ -88,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; 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/examples/re_ui_example/main.rs b/crates/re_ui/examples/re_ui_example/main.rs index 3666ab7381e1..5d2299713f99 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/design_tokens.rs b/crates/re_ui/src/design_tokens.rs index dc3e247b89f6..b0ec8b09c557 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 [`crate::SectionCollapsingHeader`]. + pub fn section_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/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/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..f004bede9a7e 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::SectionCollapsingHeader`]. + 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/section_collapsing_header.rs b/crates/re_ui/src/section_collapsing_header.rs new file mode 100644 index 000000000000..25fe9f4d76ad --- /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::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. + 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 8c59569a6734..bb0c6facdb81 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::{ @@ -14,67 +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) { - 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.on_disabled_hover_text(disabled_hover_text); - } - }); - } -} - /// Rerun custom extensions to [`egui::Ui`]. pub trait UiExt { fn ui(&self) -> &egui::Ui; @@ -514,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 @@ -659,6 +576,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, @@ -1066,134 +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 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 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 - }); - - egui::CollapsingResponse { - header_response, - body_response: body_response.map(|r| r.response), - body_returned: None, - openness, - } -}