diff --git a/Cargo.lock b/Cargo.lock index f9c2e4d12d23..2e105d003189 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4957,7 +4957,6 @@ dependencies = [ "egui_commonmark", "egui_extras", "egui_tiles", - "once_cell", "parking_lot", "rand", "re_entity_db", diff --git a/crates/re_ui/Cargo.toml b/crates/re_ui/Cargo.toml index 66ac3a540f03..9c13f93771f1 100644 --- a/crates/re_ui/Cargo.toml +++ b/crates/re_ui/Cargo.toml @@ -34,16 +34,15 @@ re_format.workspace = true re_log.workspace = true re_log_types.workspace = true # syntax-highlighting for EntityPath +egui.workspace = true egui_commonmark = { workspace = true, features = ["pulldown_cmark"] } egui_extras.workspace = true -egui.workspace = true parking_lot.workspace = true serde = { workspace = true, features = ["derive"] } serde_json.workspace = true -strum_macros.workspace = true strum.workspace = true +strum_macros.workspace = true sublime_fuzzy.workspace = true -once_cell.workspace = true eframe = { workspace = true, default-features = false, features = ["wgpu"] } diff --git a/crates/re_ui/examples/re_ui_example/right_panel.rs b/crates/re_ui/examples/re_ui_example/right_panel.rs index ce77be79e4d3..f2ad8366f8c1 100644 --- a/crates/re_ui/examples/re_ui_example/right_panel.rs +++ b/crates/re_ui/examples/re_ui_example/right_panel.rs @@ -7,6 +7,7 @@ pub struct RightPanel { drag_and_drop: drag_and_drop::ExampleDragAndDrop, hierarchical_drag_and_drop: hierarchical_drag_and_drop::HierarchicalDragAndDrop, selected_list_item: Option, + use_action_button: bool, // dummy data text: String, @@ -22,6 +23,7 @@ impl Default for RightPanel { hierarchical_drag_and_drop: hierarchical_drag_and_drop::HierarchicalDragAndDrop::default(), selected_list_item: None, + use_action_button: false, // dummy data text: "Hello world".to_owned(), color: [128, 0, 0, 255], @@ -236,21 +238,53 @@ impl RightPanel { re_ui.list_item2().show_hierarchical_with_children( ui, - "other features", + "property content right button reserve", true, - list_item2::LabelContent::new("Other contents:"), + list_item2::PropertyContent::new("PropertyContent action button:") + .value_text("demo of right gutter"), |re_ui, ui| { - re_ui.list_item2().show_hierarchical( - ui, - list_item2::LabelContent::new("next line is a EmptyContent:") - .subdued(true) - .italics(true), - ); + // By using an inner scope, we allow the nested properties to not align themselves + // to the parent property, which in this particular case looks better. + list_item2::list_item_scope(ui, "inner_scope", None, |ui| { + fn demo_item(re_ui: &ReUi, ui: &mut egui::Ui) { + re_ui.list_item2().show_hierarchical( + ui, + list_item2::PropertyContent::new("Some item:").value_fn(|_, ui, _| { + ui.ctx().debug_painter().debug_rect( + ui.max_rect(), + egui::Color32::LIGHT_BLUE, + "space for value", + ); + }), + ); + } - re_ui - .list_item2() - .show_hierarchical(ui, list_item2::EmptyContent); + for _ in 0..3 { + demo_item(re_ui, ui); + } + let mut content = list_item2::PropertyContent::new("Use action button"); + if self.use_action_button { + content = content.action_button(&re_ui::icons::ADD, || { + re_log::warn!("Add button clicked"); + }); + } + content = content.value_bool_mut(&mut self.use_action_button); + re_ui.list_item2().show_hierarchical(ui, content); + + for _ in 0..3 { + demo_item(re_ui, ui); + } + }); + }, + ); + + re_ui.list_item2().show_hierarchical_with_children( + ui, + "other features", + true, + list_item2::LabelContent::new("Other contents:"), + |re_ui, ui| { re_ui.list_item2().show_hierarchical( ui, list_item2::DebugContent::default() diff --git a/crates/re_ui/src/drag_and_drop.rs b/crates/re_ui/src/drag_and_drop.rs index c5afaea9dbe7..c4e4fdad07c4 100644 --- a/crates/re_ui/src/drag_and_drop.rs +++ b/crates/re_ui/src/drag_and_drop.rs @@ -1,6 +1,6 @@ //! Helpers for drag and drop support for reordering hierarchical lists. //! -//! Works well in combination with [`crate::ListItem`]. +//! Works well in combination with [`crate::list_item2::ListItem`]. pub enum ItemKind { /// Root container item. diff --git a/crates/re_ui/src/list_item2/list_item.rs b/crates/re_ui/src/list_item2/list_item.rs index b5627c6951ee..211ac8938cc8 100644 --- a/crates/re_ui/src/list_item2/list_item.rs +++ b/crates/re_ui/src/list_item2/list_item.rs @@ -2,7 +2,7 @@ use egui::{NumExt, Response, Shape, Ui}; -use crate::list_item2::{ContentContext, DesiredWidth, ListItemContent, StateStack}; +use crate::list_item2::{ContentContext, DesiredWidth, LayoutInfoStack, ListItemContent}; use crate::ReUi; struct ListItemResponse { @@ -22,7 +22,19 @@ pub struct ShowCollapsingResponse { pub body_response: Option>, } -/// Generic list item that delegates its content to a [`ListItemContent`] implementation. +/// Content-generic list item. +/// +/// The following features are supported: +/// - Flat or collapsible hierarchical lists. +/// - Full-span background highlighting via [`super::list_item_scope`]. TODO(#6156): fix reference +/// - Interactive or not. +/// - Support for drag and drop with [`crate::drag_and_drop`]. +/// +/// Besides these core features, [`ListItem`] delegates all content to the [`ListItemContent`] +/// implementations, such as [`super::LabelContent`] and [`super::PropertyContent`]. +/// +/// Usage example can be found in `re_ui_example`. + #[derive(Debug, Clone)] pub struct ListItem<'a> { re_ui: &'a ReUi, @@ -226,10 +238,10 @@ impl<'a> ListItem<'a> { // We use the state set by ListItemContainer to determine how far the background should // extend. - let state = StateStack::top(ui.ctx()); + let layout_info = LayoutInfoStack::top(ui.ctx()); let mut bg_rect = rect; - bg_rect.set_left(state.background_x_range.min); - bg_rect.set_right(state.background_x_range.max); + bg_rect.set_left(layout_info.background_x_range.min); + bg_rect.set_right(layout_info.background_x_range.max); // We want to be able to select/hover the item across its full span, so we interact over the // entire background rect. But… @@ -283,6 +295,7 @@ impl<'a> ListItem<'a> { bg_rect, response: &style_response, list_item: &self, + layout_info, }; content.ui(re_ui, ui, &content_ctx); diff --git a/crates/re_ui/src/list_item2/mod.rs b/crates/re_ui/src/list_item2/mod.rs index e48be5d155b7..058eacb9b051 100644 --- a/crates/re_ui/src/list_item2/mod.rs +++ b/crates/re_ui/src/list_item2/mod.rs @@ -21,6 +21,9 @@ pub struct ContentContext<'a> { pub rect: egui::Rect, /// Background area + /// + /// This is the area covered by the full-span highlighting. Useful for testing if the cursor is + /// over the item. pub bg_rect: egui::Rect, /// List item response. @@ -31,6 +34,9 @@ pub struct ContentContext<'a> { /// The current list item. pub list_item: &'a ListItem<'a>, + + /// Layout information to use for rendering. + pub layout_info: LayoutInfo, } #[derive(Debug, Clone, Copy)] diff --git a/crates/re_ui/src/list_item2/other_contents.rs b/crates/re_ui/src/list_item2/other_contents.rs index be3f14bd4588..d8aaa45e9fbf 100644 --- a/crates/re_ui/src/list_item2/other_contents.rs +++ b/crates/re_ui/src/list_item2/other_contents.rs @@ -2,28 +2,25 @@ use crate::list_item2::{ContentContext, DesiredWidth, ListItemContent}; use crate::ReUi; use egui::Ui; -/// Empty list item content. -pub struct EmptyContent; - -impl ListItemContent for EmptyContent { - fn ui( - self: Box, - _re_ui: &crate::ReUi, - _ui: &mut egui::Ui, - _context: &ContentContext<'_>, - ) { - } -} - /// [`ListItemContent`] that delegates to a closure. #[allow(clippy::type_complexity)] pub struct CustomContent<'a> { ui: Box) + 'a>, + desired_width: DesiredWidth, } impl<'a> CustomContent<'a> { pub fn new(ui: impl FnOnce(&crate::ReUi, &mut egui::Ui, &ContentContext<'_>) + 'a) -> Self { - Self { ui: Box::new(ui) } + Self { + ui: Box::new(ui), + desired_width: Default::default(), + } + } + + #[inline] + pub fn with_desired_width(mut self, desired_width: DesiredWidth) -> Self { + self.desired_width = desired_width; + self } } @@ -31,6 +28,10 @@ impl ListItemContent for CustomContent<'_> { fn ui(self: Box, re_ui: &crate::ReUi, ui: &mut egui::Ui, context: &ContentContext<'_>) { (self.ui)(re_ui, ui, context); } + + fn desired_width(&self, _re_ui: &ReUi, _ui: &Ui) -> DesiredWidth { + self.desired_width + } } /// [`ListItemContent`] that displays the content rect. diff --git a/crates/re_ui/src/list_item2/property_content.rs b/crates/re_ui/src/list_item2/property_content.rs index 38d270130063..211e36f7ba3b 100644 --- a/crates/re_ui/src/list_item2/property_content.rs +++ b/crates/re_ui/src/list_item2/property_content.rs @@ -21,7 +21,7 @@ struct PropertyActionButton<'a> { pub struct PropertyContent<'a> { label: egui::WidgetText, icon_fn: Option>>, - summary_only: bool, + show_only_when_collapsed: bool, value_fn: Option>>, //TODO(ab): in the future, that should be a `Vec`, with some auto expanding mini-toolbar action_buttons: Option>, @@ -30,13 +30,14 @@ pub struct PropertyContent<'a> { } impl<'a> PropertyContent<'a> { + /// Spacing used between the two main columns const COLUMN_SPACING: f32 = 12.0; pub fn new(label: impl Into) -> Self { Self { label: label.into(), icon_fn: None, - summary_only: true, + show_only_when_collapsed: true, value_fn: None, action_buttons: None, } @@ -64,16 +65,18 @@ impl<'a> PropertyContent<'a> { /// Right aligned action button. /// /// Note: for aesthetics, space is always reserved for the action button. - // TODO(ab): accept multiple calls for this function for multiple actions. In that case, a `…´ - // button should be displayed that turns into a mini-popup with all available actions - // TODO(ab): if ALL item in a scope have no button active, then we could skip reserving the - // space in the right margin. + // TODO(#6191): accept multiple calls for this function for multiple actions. #[inline] pub fn action_button( mut self, icon: &'static crate::icons::Icon, on_click: impl FnOnce() + 'a, ) -> Self { + // TODO(#6191): support multiple action buttons + assert!( + self.action_buttons.is_none(), + "Only one action button supported right now" + ); self.action_buttons = Some(PropertyActionButton { icon, on_click: Box::new(on_click), @@ -89,8 +92,8 @@ impl<'a> PropertyContent<'a> { /// /// Enabled by default. #[inline] - pub fn summary_only(mut self, summary_only: bool) -> Self { - self.summary_only = summary_only; + pub fn show_only_when_collapsed(mut self, show_only_when_collapsed: bool) -> Self { + self.show_only_when_collapsed = show_only_when_collapsed; self } @@ -145,9 +148,9 @@ impl<'a> PropertyContent<'a> { /// Show a read-only color in the value column. #[inline] - pub fn value_color(self, color: &'a [u8; 4]) -> Self { + pub fn value_color(self, rgba: &'a [u8; 4]) -> Self { self.value_fn(|_, ui, _| { - let [r, g, b, a] = color; + let [r, g, b, a] = rgba; let color = egui::Color32::from_rgba_unmultiplied(*r, *g, *b, *a); let response = egui::color_picker::show_color(ui, color, ui.spacing().interact_size); response.on_hover_text(format!("Color #{r:02x}{g:02x}{b:02x}{a:02x}")); @@ -156,11 +159,11 @@ impl<'a> PropertyContent<'a> { /// Show an editable color in the value column. #[inline] - pub fn value_color_mut(self, color: &'a mut [u8; 4]) -> Self { + pub fn value_color_mut(self, rgba: &'a mut [u8; 4]) -> Self { self.value_fn(|_, ui: &mut egui::Ui, _| { ui.visuals_mut().widgets.hovered.expansion = 0.0; ui.visuals_mut().widgets.active.expansion = 0.0; - ui.color_edit_button_srgba_unmultiplied(color); + ui.color_edit_button_srgba_unmultiplied(rgba); }) } } @@ -170,17 +173,17 @@ impl ListItemContent for PropertyContent<'_> { let Self { label, icon_fn, - summary_only, + show_only_when_collapsed, value_fn, action_buttons, } = *self; // │ │ - // │◀─────────────────────────────background_x_range─────────────────────────────▶│ + // │◀─────────────────────layout_info.background_x_range─────────────────────────▶│ // │ │ - // │ ◀───────────state.left_column_width────────────▶│┌──COLUMN_SPACING │ + // │ ◀────────layout_info.left_column_width─────────▶│┌──COLUMN_SPACING │ // │ ▼ │ - // │ ◀──────────────CONTENT────┼──────────────────────────▶ │ + // │ ◀─────────────────────────┼────────context.rect──────▶ │ // │ ┌ ─ ─ ─ ─ ┬ ─ ─ ─ ─ ┬ ┬────────┬─┬─────────────┬─┬─────────────┬─┬─────────┐ │ // │ │ │ │ │││ │ │ │ │ // │ │ │ │ │ │ │ │ │ │ │ │ │ @@ -189,21 +192,17 @@ impl ListItemContent for PropertyContent<'_> { // │ │ │ │ │││ │ │ │ │ // │ └ ─ ─ ─ ─ ┴ ─ ─ ─ ─ ┴ ┴────────┴─┴─────────────┴─┴─────────────┴─┴─────────┘ │ // │ ▲ ▲ ▲ │ ▲ │ - // │ └──state.left_x │ └───────────────────────────────┤ │ + // │ └──layout_info.left │ └───────────────────────────────┤ │ // │ │ ▲ │ │ - // │ content_left_x──┘ mid_point_x───┘ text_to_icon_padding │ + // │ content_left_x──┘ mid_point_x───┘ text_to_icon_padding() │ // │ │ - // - // content_indent = content_left_x - state.left_x - // left_column_width = content_indent + icon_extra + label_width + COLUMN_SPACING/2 - - let state = super::StateStack::top(ui.ctx()); let content_left_x = context.rect.left(); // Total indent left of the content rect. This is part of the left column width. - let content_indent = content_left_x - state.left_x; - let mid_point_x = state.left_x - + state + let content_indent = content_left_x - context.layout_info.left_x; + let mid_point_x = context.layout_info.left_x + + context + .layout_info .left_column_width .unwrap_or_else(|| content_indent + (context.rect.width() / 2.).at_least(0.0)); @@ -213,19 +212,16 @@ impl ListItemContent for PropertyContent<'_> { 0.0 }; - let icon_rect = egui::Rect::from_center_size( - context.rect.left_center() + egui::vec2(ReUi::small_icon_size().x / 2., 0.0), - ReUi::small_icon_size(), - ); - - // TODO(#6179): don't reserve space for action button if none are ever used in the current - // scope. - let action_button_rect = egui::Rect::from_center_size( - context.rect.right_center() - egui::vec2(ReUi::small_icon_size().x / 2., 0.0), - ReUi::small_icon_size() + egui::vec2(1.0, 1.0), // padding is needed for the buttons - ); - - let action_button_extra = action_button_rect.width() + ReUi::text_to_icon_padding(); + // Based on egui::ImageButton::ui() + let action_button_dimension = + ReUi::small_icon_size().x + 2.0 * ui.spacing().button_padding.x; + let reserve_action_button_space = + action_buttons.is_some() || context.layout_info.reserve_action_button_space; + let action_button_extra = if reserve_action_button_space { + action_button_dimension + ReUi::text_to_icon_padding() + } else { + 0.0 + }; let label_rect = egui::Rect::from_x_y_ranges( (content_left_x + icon_extra)..=(mid_point_x - Self::COLUMN_SPACING / 2.0), @@ -244,6 +240,11 @@ impl ListItemContent for PropertyContent<'_> { // Draw icon if let Some(icon_fn) = icon_fn { + let icon_rect = egui::Rect::from_center_size( + context.rect.left_center() + egui::vec2(ReUi::small_icon_size().x / 2., 0.0), + ReUi::small_icon_size(), + ); + icon_fn(re_ui, ui, icon_rect, visuals); } @@ -256,9 +257,12 @@ impl ListItemContent for PropertyContent<'_> { (content_indent + icon_extra + desired_galley.size().x + Self::COLUMN_SPACING / 2.0) .ceil(); - super::StateStack::top_mut(ui.ctx(), |state| { - state.register_desired_left_column_width(desired_width); - }); + context + .layout_info + .register_desired_left_column_width(ui.ctx(), desired_width); + context + .layout_info + .reserve_action_button_space(ui.ctx(), action_buttons.is_some()); let galley = if desired_galley.size().x <= label_rect.width() { desired_galley @@ -283,11 +287,15 @@ impl ListItemContent for PropertyContent<'_> { ui.painter().galley(text_pos, galley, visuals.text_color()); // Draw value - let should_show_value = context + let is_completely_collapsed = context .list_item .collapse_openness - .map_or(true, |o| o == 0.0) - || !summary_only; + .map_or(true, |o| o == 0.0); + let should_show_value = if show_only_when_collapsed { + is_completely_collapsed + } else { + true + }; if let Some(value_fn) = value_fn { if should_show_value { let mut child_ui = @@ -298,9 +306,16 @@ impl ListItemContent for PropertyContent<'_> { // Draw action button if let Some(action_button) = action_buttons { + let action_button_rect = egui::Rect::from_center_size( + context.rect.right_center() - egui::vec2(action_button_dimension / 2.0, 0.0), + egui::Vec2::splat(action_button_dimension), + ); + + // the right to left layout is used to mimic LabelContent's buttons behavior and get a + // better alignment let mut child_ui = ui.child_ui( - action_button_rect.expand(2.0), - egui::Layout::centered_and_justified(egui::Direction::LeftToRight), + action_button_rect, + egui::Layout::right_to_left(egui::Align::Center), ); let button_response = re_ui.small_icon_button(&mut child_ui, action_button.icon); if button_response.clicked() { diff --git a/crates/re_ui/src/list_item2/scope.rs b/crates/re_ui/src/list_item2/scope.rs index 9619928b5371..a963f6fc77c9 100644 --- a/crates/re_ui/src/list_item2/scope.rs +++ b/crates/re_ui/src/list_item2/scope.rs @@ -1,13 +1,72 @@ use egui::NumExt; -use once_cell::sync::Lazy; -#[derive(Debug, Clone, PartialEq)] -pub struct State { - /// X coordinate span to use for hover/selection highlight. +/// Layout statistics accumulated during the frame that are used for next frame's layout. +/// +/// On frame `n`, statistics are gathered by the [`super::ListItemContent`] implementations and +/// stored in this structure (via [`LayoutInfo`] methods). Then, it is saved in egui temporary memory +/// against the scope id. On frame `n+1`, the accumulated values are used by [`list_item_scope`] to +/// set up the [`LayoutInfo`] and the accumulator is reset to restart the process. +#[derive(Debug, Clone)] +struct LayoutStatistics { + /// Maximum desired column width. + /// + /// The semantics are exactly the same as [`LayoutInfo`]'s `left_column_width`. + max_desired_left_column_width: f32, + + /// Track whether any item uses the action button. + /// + /// If so, space for a right-aligned gutter should be reserved. + is_action_button_used: bool, +} + +impl Default for LayoutStatistics { + fn default() -> Self { + // set values suitable to initialize the stat accumulator + Self { + max_desired_left_column_width: f32::NEG_INFINITY, + is_action_button_used: false, + } + } +} + +impl LayoutStatistics { + /// Reset the layout statistics to the default. + /// + /// Should be called at the beginning of the frame. + fn reset(ctx: &egui::Context, scope_id: egui::Id) { + ctx.data_mut(|writer| { + writer.insert_temp(scope_id, LayoutStatistics::default()); + }); + } + + /// Read the saved accumulated value. + fn read(ctx: &egui::Context, scope_id: egui::Id) -> LayoutStatistics { + ctx.data(|reader| reader.get_temp(scope_id).unwrap_or_default()) + } + + /// Update the accumulator. /// - /// Note: this field is not, strictly speaking, part of the state, as it's overwritten with each - /// call of `list_item_scope`. Still, it's convenient to have it here to pass it from the scope - /// to the inner `ListItem`. + /// Used by [`LayoutInfo`]'s methods. + fn update(ctx: &egui::Context, scope_id: egui::Id, update: impl FnOnce(&mut LayoutStatistics)) { + ctx.data_mut(|writer| { + let stats: &mut LayoutStatistics = writer.get_temp_mut_or_default(scope_id); + update(stats); + }); + } +} + +/// Layout information prepared by [`list_item_scope`] to be used by [`super::ListItemContent`]. +/// +/// This structure has two purposes: +/// - Provide read-only layout information to be used when rendering the list item. +/// - Provide an API to register needs (such as left column width). These needs are then accumulated +/// and used to set up the next frame's layout information. +/// +/// [`super::ListItemContent`] implementations have access to this structure via +/// [`super::ContentContext`]. +#[derive(Debug, Clone)] +pub struct LayoutInfo { + /// X coordinate span to use for hover/selection highlight. // TODO(#6156): this being here is a (temporary) hack (see docstring). In the future, this will // be generalized to some `full_span_scope` mechanism to be used by all full-span widgets beyond // `ListItem`. @@ -19,7 +78,7 @@ pub struct State { /// based on `ui.max_rect()`. pub(crate) left_x: f32, - /// Column width to be used this frame. + /// Column width to be read this frame. /// /// The column width has `left_x` as reference, so it includes: /// - All the indentation on the left side of the list item. @@ -28,71 +87,89 @@ pub struct State { /// /// The effective left column width for a given [`super::ListItemContent`] implementation can be /// calculated as `left_column_width - (context.rect.left() - left_x)`. + /// + /// This value is set to `None` during the first frame, when [`list_item_scope`] isn't able to + /// determine a suitable value. In that case, implementations should devise a suitable default + /// value. pub(crate) left_column_width: Option, - /// Maximum desired column width, to be updated this frame. - /// - /// The semantics are exactly the same as for `left_column_width`. - max_desired_left_column_width: f32, - /**/ - // TODO(#6179): record the use of right action button in all PropertyContent such as to not - // unnecessarily reserve right gutter space if none have it. + /// If true, right-aligned space should be reserved for the action button, even if not used. + pub(crate) reserve_action_button_space: bool, + + /// Scope id, used to retrieve the corresponding [`LayoutStatistics`]. + scope_id: egui::Id, } -impl Default for State { +impl Default for LayoutInfo { fn default() -> Self { Self { background_x_range: egui::Rangef::NOTHING, left_x: f32::NEG_INFINITY, left_column_width: None, - max_desired_left_column_width: f32::NEG_INFINITY, + reserve_action_button_space: true, + scope_id: egui::Id::NULL, } } } -impl State { +impl LayoutInfo { /// Register the desired width of the left column. /// /// All [`super::ListItemContent`] implementation that attempt to align on the two-column system should /// call this function once in their [`super::ListItemContent::ui`] method. - pub(crate) fn register_desired_left_column_width(&mut self, desired_width: f32) { - self.max_desired_left_column_width = self.max_desired_left_column_width.max(desired_width); + pub(crate) fn register_desired_left_column_width( + &self, + ctx: &egui::Context, + desired_width: f32, + ) { + LayoutStatistics::update(ctx, self.scope_id, |stats| { + stats.max_desired_left_column_width = + stats.max_desired_left_column_width.max(desired_width); + }); + } + + /// Indicate whether right-aligned space should be reserved for the action button. + pub(crate) fn reserve_action_button_space(&self, ctx: &egui::Context, reserve: bool) { + LayoutStatistics::update(ctx, self.scope_id, |stats| { + stats.is_action_button_used |= reserve; + }); } } -/// Stack of [`State`]s. +/// Stack of [`LayoutInfo`]s. /// /// The stack is stored in `egui`'s memory and its API directly wraps the relevant calls. -/// Calls to [`list_item_scope`] push new states to the stack so that [`super::ListItem`]s can -/// always access the correct state from the top of the stack. +/// Calls to [`list_item_scope`] push new [`LayoutInfo`] to the stack so that [`super::ListItem`]s +/// can always access the correct state from the top of the stack. +/// +/// [`super::ListItemContent`] implementations should *not* access the stack directly but instead +/// use the [`LayoutInfo`] provided by [`super::ContentContext`]. #[derive(Debug, Clone, Default)] -pub(crate) struct StateStack(Vec); - -static STATE_STACK_ID: Lazy = Lazy::new(|| egui::Id::new("re_ui_list_item_state_stack")); +pub(crate) struct LayoutInfoStack(Vec); -impl StateStack { - fn push(ctx: &egui::Context, state: State) { +impl LayoutInfoStack { + fn push(ctx: &egui::Context, state: LayoutInfo) { ctx.data_mut(|writer| { - let stack: &mut StateStack = writer.get_temp_mut_or_default(*STATE_STACK_ID); + let stack: &mut LayoutInfoStack = writer.get_temp_mut_or_default(egui::Id::NULL); stack.0.push(state); }); } - fn pop(ctx: &egui::Context) -> Option { + fn pop(ctx: &egui::Context) -> Option { ctx.data_mut(|writer| { - let stack: &mut StateStack = writer.get_temp_mut_or_default(*STATE_STACK_ID); + let stack: &mut LayoutInfoStack = writer.get_temp_mut_or_default(egui::Id::NULL); stack.0.pop() }) } - /// Returns the current [`State`] to be used by [`super::ListItemContent`] implementation. + /// Returns the current [`LayoutInfo`] to be used by [`super::ListItemContent`] implementation. /// /// For ergonomic reasons, this function will fail by returning a default state if the stack is /// empty. This is an error condition that should be addressed by wrapping `ListItem` code in a /// [`super::list_item_scope`]. - pub(crate) fn top(ctx: &egui::Context) -> State { + pub(crate) fn top(ctx: &egui::Context) -> LayoutInfo { ctx.data_mut(|writer| { - let stack: &mut StateStack = writer.get_temp_mut_or_default(*STATE_STACK_ID); + let stack: &mut LayoutInfoStack = writer.get_temp_mut_or_default(egui::Id::NULL); let state = stack.0.last(); if state.is_none() { re_log::warn_once!( @@ -104,28 +181,9 @@ impl StateStack { }) } - /// Provides mutable access to the current [`State`]. - /// - /// The closure is called with a mutable reference to the current state, if any. If the stack is - /// empty, the closure is not called and a warning is logged. - pub(crate) fn top_mut(ctx: &egui::Context, state_writer: impl FnOnce(&mut State)) { + fn peek(ctx: &egui::Context) -> Option { ctx.data_mut(|writer| { - let stack: &mut StateStack = writer.get_temp_mut_or_default(*STATE_STACK_ID); - let state = stack.0.last_mut(); - if let Some(state) = state { - state_writer(state); - } else { - re_log::warn_once!( - "Failed to mutable access empty ListItem state stack. Wrap in a \ - `list_item_scope`." - ); - } - }); - } - - fn peek(ctx: &egui::Context) -> Option { - ctx.data_mut(|writer| { - let stack: &mut StateStack = writer.get_temp_mut_or_default(*STATE_STACK_ID); + let stack: &mut LayoutInfoStack = writer.get_temp_mut_or_default(egui::Id::NULL); stack.0.last().cloned() }) } @@ -133,79 +191,64 @@ impl StateStack { /// Create a scope in which `[ListItem]`s can be created. /// -/// This scope serves two purposes: -/// - Manage the state that is saved across frame (e.g. for tracking column boundary position). -/// - Manage the range of X coordinates defining the boundaries of the hover/selection highlight. +/// This scope provides the infrastructure to gather layout statistics from nested list items, +/// compute corresponding layout information, and provide this information to nested list items. /// -/// State is loaded against the provided `id`, and pushed to a global stack, such that calls to this +/// State is loaded against the scope id, and pushed to a global stack, such that calls to this /// function may be nested. `ListItem` code will always use the top of the stack as current state. /// -/// The hover/selection highlight coordinate range is determined with the following heuristics: -/// - Value passed as argument if not `None`. -/// - Value from the parent scope if the scope is nested. -/// - Clip rectangle's `x_range` (legacy behavior). -/// -/// Given the above, `list_item_scope` can be used for two potentially distinct use-cases: -/// 1) Store a suitable `background_x_range` value for use by nested `ListItem`s. This happens, -/// e.g., close to the top of the `egui::SidePanel::show` closure, where the panel size -/// information is readily available (e.g. `ui.max_rect().x_range()`). -/// 2) Limit state sharing for a subgroup of `ListItem`s. This makes it possible to independently -/// align the columns of two `ListItem`s subgroups, for which a single, global alignment would -/// be detrimental. This may happen in deeply nested UI code. +/// Layout statistics are accumulated during the frame and stored in egui's memory against the scope +/// id. Layout information is pushed to a global stack, which is also stored in egui's memory. This +/// enables nesting [`list_item_scope`]s. /// +/// *Note*: the scope id is derived from the provided `id_source` and combined with the +/// [`egui::Ui`]'s id, such that `id_source` only needs to be unique within the scope of the parent +/// ui. pub fn list_item_scope( ui: &mut egui::Ui, - id: impl Into, + id_source: impl Into, background_x_range: Option, content: impl FnOnce(&mut egui::Ui) -> R, ) -> R { - /* - data contains two set of things: - - some per container state - - a global state stack that is read by actual list items - */ - - let id = id.into(); + let scope_id = ui.id().with(id_source.into()); - // read the state for this container, if any - let state: Option = ui.data(|reader| reader.get_temp(id)); - let mut state = state.unwrap_or_default(); + // read last frame layout statistics and reset for the new frame + let layout_stats = LayoutStatistics::read(ui.ctx(), scope_id); + LayoutStatistics::reset(ui.ctx(), scope_id); - // determine the background x range to use + // prepare the layout infos // TODO(#6156): the background X range stuff is to be split off and generalised for all full-span // widgets. - state.background_x_range = if let Some(background_x_range) = background_x_range { + let background_x_range = if let Some(background_x_range) = background_x_range { background_x_range - } else if let Some(parent_state) = StateStack::peek(ui.ctx()) { + } else if let Some(parent_state) = LayoutInfoStack::peek(ui.ctx()) { parent_state.background_x_range } else { ui.clip_rect().x_range() }; - - // Set up the state for this scope. - state.left_x = ui.max_rect().left(); - state.left_column_width = if state.max_desired_left_column_width > 0.0 { + let left_column_width = if layout_stats.max_desired_left_column_width > 0.0 { Some( // TODO(ab): this heuristics can certainly be improved, to be done with more hindsight // from real-world usage. - state + layout_stats .max_desired_left_column_width .at_most(0.7 * ui.max_rect().width()), ) } else { None }; - state.max_desired_left_column_width = f32::NEG_INFINITY; + let state = LayoutInfo { + background_x_range, + left_x: ui.max_rect().left(), + left_column_width, + reserve_action_button_space: layout_stats.is_action_button_used, + scope_id, + }; // push, run, pop - StateStack::push(ui.ctx(), state.clone()); + LayoutInfoStack::push(ui.ctx(), state); let result = content(ui); - let state = StateStack::pop(ui.ctx()); - - // save the state for this container - if let Some(state) = state { - ui.data_mut(|writer| writer.insert_temp(id, state)); - } + LayoutInfoStack::pop(ui.ctx()); result }