From 39917bec266e0ecd3becf1ac40068d47e9066c31 Mon Sep 17 00:00:00 2001 From: Emil Ernerfeldt Date: Thu, 28 Apr 2022 11:09:44 +0200 Subject: [PATCH] Collapsing header with custom header (#1538) * Returns openness in CollapsingResponse * Make CollapsingState a building block for custom collapsing headers * Add a demo of the custom collapsing header * Revert to much simpler tree demo * Add CollapsingState::is_open and CollapsingState::set_open --- CHANGELOG.md | 1 + egui/src/containers/collapsing_header.rs | 300 ++++++++++++++---- egui/src/containers/mod.rs | 2 +- egui/src/containers/window.rs | 32 +- egui/src/context.rs | 5 +- egui/src/style.rs | 24 +- .../src/apps/demo/misc_demo_window.rs | 103 +++--- 7 files changed, 323 insertions(+), 144 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f4541c6c..db9a60b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ NOTE: [`epaint`](epaint/CHANGELOG.md), [`eframe`](eframe/CHANGELOG.md), [`egui_w * Added `Ui::spinner()` shortcut method ([#1494](https://github.com/emilk/egui/pull/1494)). * Added `CursorIcon`s for resizing columns, rows, and the eight cardinal directions. * Added `Ui::toggle_value`. +* Added ability to add any widgets to the header of a collapsing region ([#1538](https://github.com/emilk/egui/pull/1538)). ### Changed 🔧 * `ClippedMesh` has been replaced with `ClippedPrimitive` ([#1351](https://github.com/emilk/egui/pull/1351)). diff --git a/egui/src/containers/collapsing_header.rs b/egui/src/containers/collapsing_header.rs index 1e98d22d..9a6933f4 100644 --- a/egui/src/containers/collapsing_header.rs +++ b/egui/src/containers/collapsing_header.rs @@ -3,74 +3,194 @@ use std::hash::Hash; use crate::*; use epaint::Shape; -#[derive(Clone, Copy, Debug, Default)] +#[derive(Clone, Copy, Debug)] #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] -#[cfg_attr(feature = "serde", serde(default))] -pub(crate) struct State { +pub(crate) struct InnerState { open: bool, /// Height of the region when open. Used for animations + #[cfg_attr(feature = "serde", serde(default))] open_height: Option, } -impl State { +/// This is a a building block for building collapsing regions. +/// +/// It is used by [`CollapsingHeader`] and [`Window`], but can also be used on its own. +/// +/// See [`CollapsingState::show_header`] for how to show a collapsing header with a custom header. +#[derive(Clone, Debug)] +pub struct CollapsingState { + id: Id, + state: InnerState, +} + +impl CollapsingState { pub fn load(ctx: &Context, id: Id) -> Option { - ctx.data().get_persisted(id) + ctx.data() + .get_persisted::(id) + .map(|state| Self { id, state }) } - pub fn store(self, ctx: &Context, id: Id) { - ctx.data().insert_persisted(id, self); + pub fn store(&self, ctx: &Context) { + ctx.data().insert_persisted(self.id, self.state); } - pub fn from_memory_with_default_open(ctx: &Context, id: Id, default_open: bool) -> Self { - Self::load(ctx, id).unwrap_or_else(|| State { - open: default_open, - ..Default::default() + pub fn id(&self) -> Id { + self.id + } + + pub fn load_with_default_open(ctx: &Context, id: Id, default_open: bool) -> Self { + Self::load(ctx, id).unwrap_or(CollapsingState { + id, + state: InnerState { + open: default_open, + open_height: None, + }, }) } - // Helper - pub fn is_open(ctx: &Context, id: Id) -> Option { - if ctx.memory().everything_is_visible() { - Some(true) - } else { - State::load(ctx, id).map(|state| state.open) - } + pub fn is_open(&self) -> bool { + self.state.open + } + + pub fn set_open(&mut self, open: bool) { + self.state.open = open; } pub fn toggle(&mut self, ui: &Ui) { - self.open = !self.open; + self.state.open = !self.state.open; ui.ctx().request_repaint(); } /// 0 for closed, 1 for open, with tweening - pub fn openness(&self, ctx: &Context, id: Id) -> f32 { + pub fn openness(&self, ctx: &Context) -> f32 { if ctx.memory().everything_is_visible() { 1.0 } else { - ctx.animate_bool(id, self.open) + ctx.animate_bool(self.id, self.state.open) } } - /// Show contents if we are open, with a nice animation between closed and open - pub fn add_contents( + /// Will toggle when clicked, etc. + pub(crate) fn show_default_button_with_size( &mut self, ui: &mut Ui, - id: Id, - add_contents: impl FnOnce(&mut Ui) -> R, + button_size: Vec2, + ) -> Response { + let (_id, rect) = ui.allocate_space(button_size); + let response = ui.interact(rect, self.id, Sense::click()); + if response.clicked() { + self.toggle(ui); + } + let openness = self.openness(ui.ctx()); + paint_default_icon(ui, openness, &response); + response + } + + /// Will toggle when clicked, etc. + fn show_default_button_indented(&mut self, ui: &mut Ui) -> Response { + let size = Vec2::new(ui.spacing().indent, ui.spacing().icon_width); + let (_id, rect) = ui.allocate_space(size); + let response = ui.interact(rect, self.id, Sense::click()); + if response.clicked() { + self.toggle(ui); + } + + let (mut icon_rect, _) = ui.spacing().icon_rectangles(response.rect); + icon_rect.set_center(pos2( + response.rect.left() + ui.spacing().indent / 2.0, + response.rect.center().y, + )); + let openness = self.openness(ui.ctx()); + let small_icon_response = Response { + rect: icon_rect, + ..response.clone() + }; + paint_default_icon(ui, openness, &small_icon_response); + response + } + + /// Shows header and body (if expanded). + /// + /// The header will start with the default button in a horizontal layout, followed by whatever you add. + /// + /// Will also store the state. + /// + /// Returns the response of the collapsing button, the custom header, and the custom body. + /// + /// ``` + /// # egui::__run_test_ui(|ui| { + /// let id = ui.make_persistent_id("my_collapsing_header"); + /// egui::collapsing_header::CollapsingState::load_with_default_open(ui.ctx(), id, false) + /// .show_header(ui, |ui| { + /// ui.label("Header"); // you can put checkboxes or whatever here + /// }) + /// .body(|ui| ui.label("Body")); + /// # }); + /// ``` + pub fn show_header( + mut self, + ui: &mut Ui, + add_header: impl FnOnce(&mut Ui) -> HeaderRet, + ) -> HeaderResponse<'_, HeaderRet> { + let header_response = ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 0.0; // the toggler button uses the full indent width + let collapser = self.show_default_button_indented(ui); + ui.spacing_mut().item_spacing.x = ui.spacing_mut().icon_spacing; // Restore spacing + (collapser, add_header(ui)) + }); + HeaderResponse { + state: self, + ui, + toggle_button_response: header_response.inner.0, + header_response: InnerResponse { + response: header_response.response, + inner: header_response.inner.1, + }, + } + } + + /// Show body if we are open, with a nice animation between closed and open. + /// Indent the body to show it belongs to the header. + /// + /// Will also store the state. + pub fn show_body_indented( + &mut self, + header_response: &Response, + ui: &mut Ui, + add_body: impl FnOnce(&mut Ui) -> R, ) -> Option> { - let openness = self.openness(ui.ctx(), id); + let id = self.id; + self.show_body_unindented(ui, |ui| { + ui.indent(id, |ui| { + // make as wide as the header: + ui.expand_to_include_x(header_response.rect.right()); + add_body(ui) + }) + .inner + }) + } + + /// Show body if we are open, with a nice animation between closed and open. + /// Will also store the state. + pub fn show_body_unindented( + &mut self, + ui: &mut Ui, + add_body: impl FnOnce(&mut Ui) -> R, + ) -> Option> { + let openness = self.openness(ui.ctx()); if openness <= 0.0 { + self.store(ui.ctx()); // we store any earlier toggling as promised in the docstring None } else if openness < 1.0 { Some(ui.scope(|child_ui| { - let max_height = if self.open && self.open_height.is_none() { + let max_height = if self.state.open && self.state.open_height.is_none() { // First frame of expansion. // We don't know full height yet, but we will next frame. // Just use a placeholder value that shows some movement: 10.0 } else { - let full_height = self.open_height.unwrap_or_default(); + let full_height = self.state.open_height.unwrap_or_default(); remap_clamp(openness, 0.0..=1.0, 0.0..=full_height) }; @@ -78,10 +198,11 @@ impl State { clip_rect.max.y = clip_rect.max.y.min(child_ui.max_rect().top() + max_height); child_ui.set_clip_rect(clip_rect); - let ret = add_contents(child_ui); + let ret = add_body(child_ui); let mut min_rect = child_ui.min_rect(); - self.open_height = Some(min_rect.height()); + self.state.open_height = Some(min_rect.height()); + self.store(child_ui.ctx()); // remember the height // Pretend children took up at most `max_height` space: min_rect.max.y = min_rect.max.y.at_most(min_rect.top() + max_height); @@ -89,16 +210,49 @@ impl State { ret })) } else { - let ret_response = ui.scope(add_contents); + let ret_response = ui.scope(add_body); let full_size = ret_response.response.rect.size(); - self.open_height = Some(full_size.y); + self.state.open_height = Some(full_size.y); + self.store(ui.ctx()); // remember the height Some(ret_response) } } } +/// From [`CollapsingState::show_header`]. +#[must_use = "Remember to show the body"] +pub struct HeaderResponse<'ui, HeaderRet> { + state: CollapsingState, + ui: &'ui mut Ui, + toggle_button_response: Response, + header_response: InnerResponse, +} + +impl<'ui, HeaderRet> HeaderResponse<'ui, HeaderRet> { + /// Returns the response of the collapsing button, the custom header, and the custom body. + pub fn body( + mut self, + add_body: impl FnOnce(&mut Ui) -> BodyRet, + ) -> ( + Response, + InnerResponse, + Option>, + ) { + let body_response = + self.state + .show_body_indented(&self.header_response.response, self.ui, add_body); + ( + self.toggle_button_response, + self.header_response, + body_response, + ) + } +} + +// ---------------------------------------------------------------------------- + /// Paint the arrow icon that indicated if the region is open or not -pub(crate) fn paint_default_icon(ui: &mut Ui, openness: f32, response: &Response) { +pub fn paint_default_icon(ui: &mut Ui, openness: f32, response: &Response) { let visuals = ui.style().interact(response); let stroke = visuals.fg_stroke; @@ -126,13 +280,15 @@ pub type IconPainter = Box; /// # egui::__run_test_ui(|ui| { /// egui::CollapsingHeader::new("Heading") /// .show(ui, |ui| { -/// ui.label("Contents"); +/// ui.label("Body"); /// }); /// /// // Short version: -/// ui.collapsing("Heading", |ui| { ui.label("Contents"); }); +/// ui.collapsing("Heading", |ui| { ui.label("Body"); }); /// # }); /// ``` +/// +/// If you want to customize the header contents, see [`CollapsingState::show_header`]. #[must_use = "You should call .show()"] pub struct CollapsingHeader { text: WidgetText, @@ -217,7 +373,7 @@ impl CollapsingHeader { /// let response = egui::CollapsingHeader::new("Select and open me") /// .selectable(true) /// .selected(selected) - /// .show(ui, |ui| ui.label("Content")); + /// .show(ui, |ui| ui.label("Body")); /// if response.header_response.clicked() { /// selected = true; /// } @@ -265,9 +421,9 @@ impl CollapsingHeader { } struct Prepared { - id: Id, header_response: Response, - state: State, + state: CollapsingState, + openness: f32, } impl CollapsingHeader { @@ -283,9 +439,9 @@ impl CollapsingHeader { open, id_source, enabled: _, - selectable: _, - selected: _, - show_background: _, + selectable, + selected, + show_background, } = self; // TODO: horizontal layout, with icon and text as labels. Insert background behind using Frame. @@ -315,9 +471,9 @@ impl CollapsingHeader { header_response.rect.center().y - text.size().y / 2.0, ); - let mut state = State::from_memory_with_default_open(ui.ctx(), id, default_open); + let mut state = CollapsingState::load_with_default_open(ui.ctx(), id, default_open); if let Some(open) = open { - if open != state.open { + if open != state.is_open() { state.toggle(ui); header_response.mark_changed(); } @@ -329,12 +485,12 @@ impl CollapsingHeader { header_response .widget_info(|| WidgetInfo::labeled(WidgetType::CollapsingHeader, text.text())); - if ui.is_rect_visible(rect) { - let visuals = ui - .style() - .interact_selectable(&header_response, self.selected); + let openness = state.openness(ui.ctx()); - if ui.visuals().collapsing_header_frame || self.show_background { + if ui.is_rect_visible(rect) { + let visuals = ui.style().interact_selectable(&header_response, selected); + + if ui.visuals().collapsing_header_frame || show_background { ui.painter().add(epaint::RectShape { rect: header_response.rect.expand(visuals.expansion), rounding: visuals.rounding, @@ -344,8 +500,7 @@ impl CollapsingHeader { }); } - if self.selected - || self.selectable && (header_response.hovered() || header_response.has_focus()) + if selected || selectable && (header_response.hovered() || header_response.has_focus()) { let rect = rect.expand(visuals.expansion); @@ -363,7 +518,6 @@ impl CollapsingHeader { rect: icon_rect, ..header_response.clone() }; - let openness = state.openness(ui.ctx(), id); if let Some(icon) = icon { icon(ui, openness, &icon_response); } else { @@ -375,9 +529,9 @@ impl CollapsingHeader { } Prepared { - id, header_response, state, + openness, } } @@ -385,48 +539,42 @@ impl CollapsingHeader { pub fn show( self, ui: &mut Ui, - add_contents: impl FnOnce(&mut Ui) -> R, + add_body: impl FnOnce(&mut Ui) -> R, ) -> CollapsingResponse { - self.show_dyn(ui, Box::new(add_contents)) + self.show_dyn(ui, Box::new(add_body)) } fn show_dyn<'c, R>( self, ui: &mut Ui, - add_contents: Box R + 'c>, + add_body: Box R + 'c>, ) -> CollapsingResponse { - // Make sure contents are bellow header, + // Make sure body is bellow header, // and make sure it is one unit (necessary for putting a [`CollapsingHeader`] in a grid). ui.vertical(|ui| { ui.set_enabled(self.enabled); let Prepared { - id, header_response, mut state, - } = self.begin(ui); + openness, + } = self.begin(ui); // show the header - let ret_response = state.add_contents(ui, id, |ui| { - ui.indent(id, |ui| { - // make as wide as the header: - ui.expand_to_include_x(header_response.rect.right()); - add_contents(ui) - }) - .inner - }); - state.store(ui.ctx(), id); + let ret_response = state.show_body_indented(&header_response, ui, add_body); if let Some(ret_response) = ret_response { CollapsingResponse { header_response, body_response: Some(ret_response.response), body_returned: Some(ret_response.inner), + openness, } } else { CollapsingResponse { header_response, body_response: None, body_returned: None, + openness, } } }) @@ -436,9 +584,27 @@ impl CollapsingHeader { /// The response from showing a [`CollapsingHeader`]. pub struct CollapsingResponse { + /// Response of the actual clickable header. pub header_response: Response, + /// None iff collapsed. pub body_response: Option, + /// None iff collapsed. pub body_returned: Option, + + /// 0.0 if fully closed, 1.0 if fully open, and something in-between while animating. + pub openness: f32, +} + +impl CollapsingResponse { + /// Was the [`CollapsingHeader`] fully closed (and not being animated)? + pub fn fully_closed(&self) -> bool { + self.openness <= 0.0 + } + + /// Was the [`CollapsingHeader`] fully open (and not being animated)? + pub fn fully_open(&self) -> bool { + self.openness >= 1.0 + } } diff --git a/egui/src/containers/mod.rs b/egui/src/containers/mod.rs index f151b8e9..0231c20e 100644 --- a/egui/src/containers/mod.rs +++ b/egui/src/containers/mod.rs @@ -3,7 +3,7 @@ //! For instance, a [`Frame`] adds a frame and background to some contained UI. pub(crate) mod area; -pub(crate) mod collapsing_header; +pub mod collapsing_header; mod combo_box; pub(crate) mod frame; pub mod panel; diff --git a/egui/src/containers/window.rs b/egui/src/containers/window.rs index 1d419665..858503d3 100644 --- a/egui/src/containers/window.rs +++ b/egui/src/containers/window.rs @@ -1,5 +1,6 @@ // WARNING: the code in here is horrible. It is a behemoth that needs breaking up into simpler parts. +use crate::collapsing_header::CollapsingState; use crate::{widget_text::WidgetTextGalley, *}; use epaint::*; @@ -269,10 +270,10 @@ impl<'open> Window<'open> { let area_id = area.id; let area_layer_id = area.layer(); let resize_id = area_id.with("resize"); - let collapsing_id = area_id.with("collapsing"); + let mut collapsing = + CollapsingState::load_with_default_open(ctx, area_id.with("collapsing"), true); - let is_collapsed = with_title_bar - && !collapsing_header::State::is_open(ctx, collapsing_id).unwrap_or_default(); + let is_collapsed = with_title_bar && !collapsing.is_open(); let possible = PossibleInteractions::new(&area, &resize, is_collapsed); let area = area.movable(false); // We move it manually, or the area will move the window when we want to resize it @@ -326,19 +327,12 @@ impl<'open> Window<'open> { let frame_stroke = frame.stroke; let mut frame = frame.begin(&mut area_content_ui); - let default_expanded = true; - let mut collapsing = collapsing_header::State::from_memory_with_default_open( - ctx, - collapsing_id, - default_expanded, - ); let show_close_button = open.is_some(); let title_bar = if with_title_bar { let title_bar = show_title_bar( &mut frame.content_ui, title, show_close_button, - collapsing_id, &mut collapsing, collapsible, ); @@ -349,7 +343,7 @@ impl<'open> Window<'open> { }; let (content_inner, content_response) = collapsing - .add_contents(&mut frame.content_ui, collapsing_id, |ui| { + .show_body_unindented(&mut frame.content_ui, |ui| { resize.show(ui, |ui| { if title_bar.is_some() { ui.add_space(title_content_spacing); @@ -380,7 +374,7 @@ impl<'open> Window<'open> { ); } - collapsing.store(ctx, collapsing_id); + collapsing.store(ctx); if let Some(interaction) = interaction { paint_frame_interaction( @@ -781,8 +775,7 @@ fn show_title_bar( ui: &mut Ui, title: WidgetText, show_close_button: bool, - collapsing_id: Id, - collapsing: &mut collapsing_header::State, + collapsing: &mut CollapsingState, collapsible: bool, ) -> TitleBar { let inner_response = ui.horizontal(|ui| { @@ -798,14 +791,7 @@ fn show_title_bar( if collapsible { ui.add_space(pad); - - let (_id, rect) = ui.allocate_space(button_size); - let collapse_button_response = ui.interact(rect, collapsing_id, Sense::click()); - if collapse_button_response.clicked() { - collapsing.toggle(ui); - } - let openness = collapsing.openness(ui.ctx(), collapsing_id); - collapsing_header::paint_default_icon(ui, openness, &collapse_button_response); + collapsing.show_default_button_with_size(ui, button_size); } let title_galley = title.into_galley(ui, Some(false), f32::INFINITY, TextStyle::Heading); @@ -854,7 +840,7 @@ impl TitleBar { outer_rect: Rect, content_response: &Option, open: Option<&mut bool>, - collapsing: &mut collapsing_header::State, + collapsing: &mut CollapsingState, collapsible: bool, ) { if let Some(content_response) = &content_response { diff --git a/egui/src/context.rs b/egui/src/context.rs index 1c3e50b8..55986216 100644 --- a/egui/src/context.rs +++ b/egui/src/context.rs @@ -1238,11 +1238,12 @@ impl Context { ui.horizontal(|ui| { ui.label(format!( "{} collapsing headers", - self.data().count::() + self.data() + .count::() )); if ui.button("Reset").clicked() { self.data() - .remove_by_type::(); + .remove_by_type::(); } }); diff --git a/egui/src/style.rs b/egui/src/style.rs index f196fd32..ee2e6996 100644 --- a/egui/src/style.rs +++ b/egui/src/style.rs @@ -267,9 +267,13 @@ pub struct Spacing { pub text_edit_width: f32, /// Checkboxes, radio button and collapsing headers have an icon at the start. - /// This is the width/height of this icon. + /// This is the width/height of the outer part of this icon (e.g. the BOX of the checkbox). pub icon_width: f32, + /// Checkboxes, radio button and collapsing headers have an icon at the start. + /// This is the width/height of the inner part of this icon (e.g. the check of the checkbox). + pub icon_width_inner: f32, + /// Checkboxes, radio button and collapsing headers have an icon at the start. /// This is the spacing between the icon and the text pub icon_spacing: f32, @@ -289,15 +293,14 @@ pub struct Spacing { impl Spacing { /// Returns small icon rectangle and big icon rectangle pub fn icon_rectangles(&self, rect: Rect) -> (Rect, Rect) { - let box_side = self.icon_width; + let icon_width = self.icon_width; let big_icon_rect = Rect::from_center_size( - pos2(rect.left() + box_side / 2.0, rect.center().y), - vec2(box_side, box_side), + pos2(rect.left() + icon_width / 2.0, rect.center().y), + vec2(icon_width, icon_width), ); - let small_rect_side = 8.0; // TODO: make a parameter let small_icon_rect = - Rect::from_center_size(big_icon_rect.center(), Vec2::splat(small_rect_side)); + Rect::from_center_size(big_icon_rect.center(), Vec2::splat(self.icon_width_inner)); (small_icon_rect, big_icon_rect) } @@ -634,6 +637,7 @@ impl Default for Spacing { slider_width: 100.0, text_edit_width: 280.0, icon_width: 14.0, + icon_width_inner: 8.0, icon_spacing: 4.0, tooltip_width: 600.0, combo_height: 200.0, @@ -909,6 +913,7 @@ impl Spacing { slider_width, text_edit_width, icon_width, + icon_width_inner, icon_spacing, tooltip_width, indent_ends_with_horizontal_line, @@ -972,7 +977,12 @@ impl Spacing { ui.label("Checkboxes etc:"); ui.add( DragValue::new(icon_width) - .prefix("width:") + .prefix("outer icon width:") + .clamp_range(0.0..=60.0), + ); + ui.add( + DragValue::new(icon_width_inner) + .prefix("inner icon width:") .clamp_range(0.0..=60.0), ); ui.add( diff --git a/egui_demo_lib/src/apps/demo/misc_demo_window.rs b/egui_demo_lib/src/apps/demo/misc_demo_window.rs index fc5ff9b0..85262e54 100644 --- a/egui_demo_lib/src/apps/demo/misc_demo_window.rs +++ b/egui_demo_lib/src/apps/demo/misc_demo_window.rs @@ -14,6 +14,7 @@ pub struct MiscDemoWindow { widgets: Widgets, colors: ColorWidgets, + custom_collapsing_header: CustomCollapsingHeader, tree: Tree, box_painting: BoxPainting, @@ -32,6 +33,7 @@ impl Default for MiscDemoWindow { widgets: Default::default(), colors: Default::default(), + custom_collapsing_header: Default::default(), tree: Tree::demo(), box_painting: Default::default(), @@ -82,6 +84,10 @@ impl View for MiscDemoWindow { self.colors.ui(ui); }); + CollapsingHeader::new("Custom Collapsing Header") + .default_open(false) + .show(ui, |ui| self.custom_collapsing_header.ui(ui)); + CollapsingHeader::new("Tree") .default_open(false) .show(ui, |ui| self.tree.ui(ui)); @@ -351,6 +357,44 @@ impl BoxPainting { // ---------------------------------------------------------------------------- +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +struct CustomCollapsingHeader { + selected: bool, + radio_value: bool, +} + +impl Default for CustomCollapsingHeader { + fn default() -> Self { + Self { + selected: true, + radio_value: false, + } + } +} + +impl CustomCollapsingHeader { + pub fn ui(&mut self, ui: &mut egui::Ui) { + ui.label("Example of a collapsing header with custom header:"); + + let id = ui.make_persistent_id("my_collapsing_header"); + egui::collapsing_header::CollapsingState::load_with_default_open(ui.ctx(), id, true) + .show_header(ui, |ui| { + ui.toggle_value(&mut self.selected, "Click to select/unselect"); + ui.radio_value(&mut self.radio_value, false, ""); + ui.radio_value(&mut self.radio_value, true, ""); + }) + .body(|ui| { + ui.label("The body is always custom"); + }); + + CollapsingHeader::new("Normal collapsing header for comparison").show(ui, |ui| { + ui.label("Nothing exciting here"); + }); + } +} + +// ---------------------------------------------------------------------------- + #[derive(Clone, Copy, PartialEq)] enum Action { Keep, @@ -359,53 +403,30 @@ enum Action { #[derive(Clone, Default)] #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] -struct Tree(String, SubTree); +struct Tree(Vec); impl Tree { pub fn demo() -> Self { - Self( - String::from("root"), - SubTree(vec![ - SubTree(vec![SubTree::default(); 4]), - SubTree(vec![SubTree(vec![SubTree::default(); 2]); 3]), - ]), - ) + Self(vec![ + Tree(vec![Tree::default(); 4]), + Tree(vec![Tree(vec![Tree::default(); 2]); 3]), + ]) } pub fn ui(&mut self, ui: &mut Ui) -> Action { - self.1.ui(ui, 0, "root", &mut self.0) + self.ui_impl(ui, 0, "root") } } -#[derive(Clone, Default)] -#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] -struct SubTree(Vec); - -impl SubTree { - pub fn ui( - &mut self, - ui: &mut Ui, - depth: usize, - name: &str, - selected_name: &mut String, - ) -> Action { - let response = CollapsingHeader::new(name) +impl Tree { + fn ui_impl(&mut self, ui: &mut Ui, depth: usize, name: &str) -> Action { + CollapsingHeader::new(name) .default_open(depth < 1) - .selectable(true) - .selected(selected_name.as_str() == name) - .show(ui, |ui| self.children_ui(ui, name, depth, selected_name)); - if response.header_response.clicked() { - *selected_name = name.to_string(); - } - response.body_returned.unwrap_or(Action::Keep) + .show(ui, |ui| self.children_ui(ui, depth)) + .body_returned + .unwrap_or(Action::Keep) } - fn children_ui( - &mut self, - ui: &mut Ui, - parent_name: &str, - depth: usize, - selected_name: &mut String, - ) -> Action { + fn children_ui(&mut self, ui: &mut Ui, depth: usize) -> Action { if depth > 0 && ui .button(RichText::new("delete").color(Color32::RED)) @@ -419,13 +440,7 @@ impl SubTree { .into_iter() .enumerate() .filter_map(|(i, mut tree)| { - if tree.ui( - ui, - depth + 1, - &format!("{}/{}", parent_name, i), - selected_name, - ) == Action::Keep - { + if tree.ui_impl(ui, depth + 1, &format!("child #{}", i)) == Action::Keep { Some(tree) } else { None @@ -434,7 +449,7 @@ impl SubTree { .collect(); if ui.button("+").clicked() { - self.0.push(SubTree::default()); + self.0.push(Tree::default()); } Action::Keep