From 9f6add23dfcf008541b25ce1200718d995b6c23f Mon Sep 17 00:00:00 2001 From: Antoine Beyeler <49431240+abey79@users.noreply.github.com> Date: Thu, 2 May 2024 16:07:23 +0200 Subject: [PATCH] `ListItem` 2.0 (part 2): introduce `PropertyContent` for two-column, property-like list items (#6174) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### What This PR introduced the `PropertyContent`, an implementation of `ListItemContent` for two-column list item, with a label and flexible "values". Currently only demonstrated in `re_ui_example`. What the "value" displays is delegated to a user-provided closure. However, `PropertyContent` provides helper for a few basic types: bool, text, color (both read-only and editable). - Part of #6075 - Follow-up to #6161 https://github.com/rerun-io/rerun/assets/49431240/bf94871a-63d5-46fa-94fe-a9adf720cdb8 ### Limitations and todo - Columns are fixed size at 50%. They will be made smart in [the next PR.](https://github.com/rerun-io/rerun/pull/6182) - More helpers are needed for various kinds of values. - There can be only 0 or 1 action button. This should be extended by using a `…` button with some kind of popup with all available actions in a future PR. - Right gutter space is reserved for the action button even if no list item in scope use them. The `list_item_scope` could track this and skip reserving that space if it's never used (e.g. component list in entity path selection panel): https://github.com/rerun-io/rerun/issues/6179 ### Checklist * [x] I have read and agree to [Contributor Guide](https://github.com/rerun-io/rerun/blob/main/CONTRIBUTING.md) and the [Code of Conduct](https://github.com/rerun-io/rerun/blob/main/CODE_OF_CONDUCT.md) * [x] I've included a screenshot or gif (if applicable) * [x] I have tested the web demo (if applicable): * Using examples from latest `main` build: [rerun.io/viewer](https://rerun.io/viewer/pr/6174?manifest_url=https://app.rerun.io/version/main/examples_manifest.json) * Using full set of examples from `nightly` build: [rerun.io/viewer](https://rerun.io/viewer/pr/6174?manifest_url=https://app.rerun.io/version/nightly/examples_manifest.json) * [x] The PR title and labels are set such as to maximize their usefulness for the next release's CHANGELOG * [x] If applicable, add a new check to the [release checklist](https://github.com/rerun-io/rerun/blob/main/tests/python/release_checklist)! - [PR Build Summary](https://build.rerun.io/pr/6174) - [Recent benchmark results](https://build.rerun.io/graphs/crates.html) - [Wasm size tracking](https://build.rerun.io/graphs/sizes.html) To run all checks from `main`, comment on the PR with `@rerun-bot full-check`. --- crates/re_ui/examples/re_ui_example/main.rs | 3 +- .../examples/re_ui_example/right_panel.rs | 66 ++++- crates/re_ui/src/list_item2/list_item.rs | 12 +- crates/re_ui/src/list_item2/mod.rs | 3 + .../re_ui/src/list_item2/property_content.rs | 278 ++++++++++++++++++ crates/re_ui/src/list_item2/scope.rs | 2 + crates/re_ui/src/toggle_switch.rs | 22 +- 7 files changed, 363 insertions(+), 23 deletions(-) create mode 100644 crates/re_ui/src/list_item2/property_content.rs diff --git a/crates/re_ui/examples/re_ui_example/main.rs b/crates/re_ui/examples/re_ui_example/main.rs index 0b58b238312e..35d72ea00bf4 100644 --- a/crates/re_ui/examples/re_ui_example/main.rs +++ b/crates/re_ui/examples/re_ui_example/main.rs @@ -227,7 +227,7 @@ impl eframe::App for ExampleApp { .show(ui, |ui| { ui.horizontal(|ui| { ui.label("Toggle switch:"); - ui.add(re_ui::toggle_switch(&mut self.dummy_bool)); + ui.add(re_ui::toggle_switch(8.0, &mut self.dummy_bool)); }); ui.label(format!("Latest command: {}", self.latest_cmd)); @@ -310,6 +310,7 @@ impl eframe::App for ExampleApp { egui::SidePanel::right("right_panel") .frame(panel_frame) + .min_width(0.0) .show_animated(egui_ctx, self.show_right_panel, |ui| { // TODO(#6156): this is still needed for some full-span widgets ui.set_clip_rect(ui.max_rect()); 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 80d32651c4a6..cbc89d910eb7 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,11 @@ pub struct RightPanel { drag_and_drop: drag_and_drop::ExampleDragAndDrop, hierarchical_drag_and_drop: hierarchical_drag_and_drop::HierarchicalDragAndDrop, selected_list_item: Option, + + // dummy data + text: String, + color: [u8; 4], + boolean: bool, } impl Default for RightPanel { @@ -17,6 +22,10 @@ impl Default for RightPanel { hierarchical_drag_and_drop: hierarchical_drag_and_drop::HierarchicalDragAndDrop::default(), selected_list_item: None, + // dummy data + text: "Hello world".to_owned(), + color: [128, 0, 0, 255], + boolean: false, } } } @@ -32,7 +41,7 @@ impl RightPanel { re_ui.panel_content(ui, |re_ui, ui| { re_ui.panel_title_bar_with_buttons(ui, "Demo: drag-and-drop", None, |ui| { - ui.add(re_ui::toggle_switch(&mut self.show_hierarchical_demo)); + ui.add(re_ui::toggle_switch(8.0, &mut self.show_hierarchical_demo)); ui.label("Hierarchical:"); }); @@ -51,7 +60,7 @@ impl RightPanel { re_ui.panel_content(ui, |re_ui, ui| { re_ui.panel_title_bar(ui, "Demo: ListItem APIs", None); - Self::list_item_api_demo(re_ui, ui); + self.list_item_api_demo(re_ui, ui); }); ui.add_space(20.0); @@ -93,7 +102,7 @@ impl RightPanel { }); } - fn list_item_api_demo(re_ui: &ReUi, ui: &mut Ui) { + fn list_item_api_demo(&mut self, re_ui: &ReUi, ui: &mut Ui) { re_ui .list_item2() .show_hierarchical(ui, list_item2::LabelContent::new("Default")); @@ -170,6 +179,57 @@ impl RightPanel { }, ); + re_ui.list_item2().show_hierarchical_with_children( + ui, + "property content features", + true, + list_item2::PropertyContent::new("PropertyContent features:") + .value_text("bunch of properties"), + |re_ui, ui| { + re_ui.list_item2().show_hierarchical( + ui, + list_item2::PropertyContent::new("Bool").value_bool(self.boolean), + ); + + re_ui.list_item2().show_hierarchical( + ui, + list_item2::PropertyContent::new("Bool (editable)") + .value_bool_mut(&mut self.boolean), + ); + + re_ui.list_item2().show_hierarchical( + ui, + list_item2::PropertyContent::new("Text").value_text(&self.text), + ); + + re_ui.list_item2().show_hierarchical( + ui, + list_item2::PropertyContent::new("Text (editable)") + .value_text_mut(&mut self.text), + ); + + re_ui.list_item2().show_hierarchical( + ui, + list_item2::PropertyContent::new("Color") + .with_icon(&re_ui::icons::SPACE_VIEW_TEXT) + .action_button(&re_ui::icons::ADD, || { + re_log::warn!("Add button clicked"); + }) + .value_color(&self.color), + ); + + re_ui.list_item2().show_hierarchical( + ui, + list_item2::PropertyContent::new("Color (editable)") + .with_icon(&re_ui::icons::SPACE_VIEW_TEXT) + .action_button(&re_ui::icons::ADD, || { + re_log::warn!("Add button clicked"); + }) + .value_color_mut(&mut self.color), + ); + }, + ); + re_ui.list_item2().show_hierarchical_with_children( ui, "other features", diff --git a/crates/re_ui/src/list_item2/list_item.rs b/crates/re_ui/src/list_item2/list_item.rs index 7e5e9e8f8c5a..0d237b8ddcd9 100644 --- a/crates/re_ui/src/list_item2/list_item.rs +++ b/crates/re_ui/src/list_item2/list_item.rs @@ -106,7 +106,7 @@ impl<'a> ListItem<'a> { } /// Draw the item as part of a flat list. - pub fn show_flat(self, ui: &mut Ui, content: impl ListItemContent + 'static) -> Response { + pub fn show_flat(self, ui: &mut Ui, content: impl ListItemContent + 'a) -> Response { // Note: the purpose of the scope is to minimise interferences on subsequent items' id ui.scope(|ui| self.ui(ui, None, 0.0, Box::new(content))) .inner @@ -114,11 +114,7 @@ impl<'a> ListItem<'a> { } /// Draw the item as a leaf node from a hierarchical list. - pub fn show_hierarchical( - self, - ui: &mut Ui, - content: impl ListItemContent + 'static, - ) -> Response { + pub fn show_hierarchical(self, ui: &mut Ui, content: impl ListItemContent + 'a) -> Response { // Note: the purpose of the scope is to minimise interferences on subsequent items' id ui.scope(|ui| { self.ui( @@ -138,7 +134,7 @@ impl<'a> ListItem<'a> { ui: &mut Ui, id: impl Into, default_open: bool, - content: impl ListItemContent + 'static, + content: impl ListItemContent + 'a, add_childrens: impl FnOnce(&ReUi, &mut egui::Ui) -> R, ) -> ShowCollapsingResponse { let id = id.into(); @@ -185,7 +181,7 @@ impl<'a> ListItem<'a> { ui: &mut Ui, id: Option, extra_indent: f32, - content: Box, + content: Box, ) -> ListItemResponse { let Self { re_ui, diff --git a/crates/re_ui/src/list_item2/mod.rs b/crates/re_ui/src/list_item2/mod.rs index 3cb35f418c80..7a4ea2c94970 100644 --- a/crates/re_ui/src/list_item2/mod.rs +++ b/crates/re_ui/src/list_item2/mod.rs @@ -5,11 +5,13 @@ mod label_content; mod list_item; mod other_contents; +mod property_content; mod scope; pub use label_content::*; pub use list_item::*; pub use other_contents::*; +pub use property_content::*; pub use scope::*; /// Context provided to [`ListItemContent`] implementations @@ -62,6 +64,7 @@ pub trait ListItemContent { /// /// If the content has some interactive elements, it should return its response. In particular, /// if the response is hovered, the list item will show a dimmer background highlight. + //TODO(ab): could the return type be just a bool meaning "inner interactive widget was hovered"? fn ui( self: Box, re_ui: &crate::ReUi, diff --git a/crates/re_ui/src/list_item2/property_content.rs b/crates/re_ui/src/list_item2/property_content.rs new file mode 100644 index 000000000000..1ff14f0d0822 --- /dev/null +++ b/crates/re_ui/src/list_item2/property_content.rs @@ -0,0 +1,278 @@ +use crate::list_item2::{ContentContext, ListItemContent}; +use crate::{Icon, ReUi}; +use eframe::emath::{Align, Align2}; +use eframe::epaint::text::TextWrapping; +use egui::{NumExt, Response, Ui}; + +/// Closure to draw an icon left of the label. +type IconFn<'a> = dyn FnOnce(&ReUi, &mut egui::Ui, egui::Rect, egui::style::WidgetVisuals) + 'a; + +/// Closure to draw the right column of the property. +type PropertyValueFn<'a> = + dyn FnOnce(&ReUi, &mut egui::Ui, egui::style::WidgetVisuals) -> Option + 'a; + +struct PropertyActionButton<'a> { + icon: &'static crate::icons::Icon, + on_click: Box, +} + +/// [`ListItemContent`] to display property-like, two-column content, with the left column +/// containing a label (along with an optional icon) and the right column containing some custom +/// value (which may be editable). +pub struct PropertyContent<'a> { + label: egui::WidgetText, + icon_fn: Option>>, + summary_only: bool, + value_fn: Option>>, + //TODO(ab): in the future, that should be a `Vec`, with some auto expanding mini-toolbar + action_buttons: Option>, + /**/ + //TODO(ab): icon styling? link icon right of label? clickable label? +} + +impl<'a> PropertyContent<'a> { + pub fn new(label: impl Into) -> Self { + Self { + label: label.into(), + icon_fn: None, + summary_only: true, + value_fn: None, + action_buttons: None, + } + } + + /// Provide an [`Icon`] to be displayed on the left of the label. + #[inline] + pub fn with_icon(self, icon: &'a Icon) -> Self { + self.with_icon_fn(|_, ui, rect, visuals| { + let tint = visuals.fg_stroke.color; + icon.as_image().tint(tint).paint_at(ui, rect); + }) + } + + /// Provide a custom closure to draw an icon on the left of the item. + #[inline] + pub fn with_icon_fn(mut self, icon_fn: F) -> Self + where + F: FnOnce(&ReUi, &mut egui::Ui, egui::Rect, egui::style::WidgetVisuals) + 'a, + { + self.icon_fn = Some(Box::new(icon_fn)); + self + } + + /// 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. + #[inline] + pub fn action_button( + mut self, + icon: &'static crate::icons::Icon, + on_click: impl FnOnce() + 'a, + ) -> Self { + self.action_buttons = Some(PropertyActionButton { + icon, + on_click: Box::new(on_click), + }); + self + } + + /// Display value only for leaf or collapsed items. + /// + /// When enabled, the value for this item is not displayed for uncollapsed hierarchical items. + /// This is convenient when the value serves are a summary of the child content, which doesn't + /// need to be displayed when said content is visible. + /// + /// Enabled by default. + #[inline] + pub fn summary_only(mut self, summary_only: bool) -> Self { + self.summary_only = summary_only; + self + } + + /// Provide a closure to draw the content of the right column. + #[inline] + pub fn value_fn(mut self, value_fn: F) -> Self + where + F: FnOnce(&ReUi, &mut egui::Ui, egui::style::WidgetVisuals) -> Option + 'a, + { + self.value_fn = Some(Box::new(value_fn)); + self + } + + // + // Bunch of helpers with concrete implementation of value fn + // + + /// Show a read-only boolean in the value column. + #[inline] + pub fn value_bool(self, mut b: bool) -> Self { + self.value_fn(move |_, ui: &mut Ui, _| { + Some(ui.add_enabled(false, crate::toggle_switch(15.0, &mut b))) + }) + } + + /// Show an editable boolean in the value column. + #[inline] + pub fn value_bool_mut(self, b: &'a mut bool) -> Self { + self.value_fn(|_, ui: &mut Ui, _| { + ui.visuals_mut().widgets.hovered.expansion = 0.0; + ui.visuals_mut().widgets.active.expansion = 0.0; + + Some(ui.add(crate::toggle_switch(15.0, b))) + }) + } + + /// Show a static text in the value column. + #[inline] + pub fn value_text(self, text: impl Into + 'a) -> Self { + self.value_fn(move |_, ui, _| Some(ui.label(text.into()))) + } + + /// Show an editable text in the value column. + #[inline] + pub fn value_text_mut(self, text: &'a mut String) -> Self { + self.value_fn(|_, ui, _| Some(ui.text_edit_singleline(text))) + } + + /// Show a read-only color in the value column. + #[inline] + pub fn value_color(self, color: &'a [u8; 4]) -> Self { + self.value_fn(|_, ui, _| { + let [r, g, b, a] = color; + 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}")); + None + }) + } + + /// Show an editable color in the value column. + #[inline] + pub fn value_color_mut(self, color: &'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; + Some(ui.color_edit_button_srgba_unmultiplied(color)) + }) + } +} + +impl ListItemContent for PropertyContent<'_> { + fn ui( + self: Box, + re_ui: &ReUi, + ui: &mut Ui, + context: &ContentContext<'_>, + ) -> Option { + let Self { + label, + icon_fn, + summary_only, + value_fn, + action_buttons, + } = *self; + + // We always reserve space for the action button(s), even if there are none. + 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 content_width = + (context.rect.width() - action_button_rect.width() - ReUi::text_to_icon_padding()) + .at_least(0.0); + + //TODO(ab): adaptable columns + let column_width = ((content_width - ReUi::text_to_icon_padding()) / 2.).at_least(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(), + ); + + let mut label_rect = egui::Rect::from_min_size( + context.rect.left_top(), + egui::vec2(column_width, context.rect.height()), + ); + if icon_fn.is_some() { + label_rect.min.x += icon_rect.width() + ReUi::text_to_icon_padding(); + } + + let value_rect = egui::Rect::from_min_size( + context.rect.left_top() + egui::vec2(column_width + ReUi::text_to_icon_padding(), 0.0), + egui::vec2(column_width, context.rect.height()), + ); + + let visuals = ui + .style() + .interact_selectable(context.response, context.list_item.selected); + + // Draw icon + if let Some(icon_fn) = icon_fn { + icon_fn(re_ui, ui, icon_rect, visuals); + } + + let button_response = if let Some(action_button) = action_buttons { + let mut child_ui = ui.child_ui( + action_button_rect.expand(2.0), + egui::Layout::centered_and_justified(egui::Direction::LeftToRight), + ); + let button_response = re_ui.small_icon_button(&mut child_ui, action_button.icon); + if button_response.clicked() { + (action_button.on_click)(); + } + Some(button_response) + } else { + None + }; + + // Draw label + let mut layout_job = + label.into_layout_job(ui.style(), egui::FontSelection::Default, Align::LEFT); + layout_job.wrap = TextWrapping::truncate_at_width(label_rect.width()); + let galley = ui.fonts(|fonts| fonts.layout_job(layout_job)); + + // this happens here to avoid cloning the text + context.response.widget_info(|| { + egui::WidgetInfo::selected( + egui::WidgetType::SelectableLabel, + context.list_item.selected, + galley.text(), + ) + }); + + let text_pos = Align2::LEFT_CENTER + .align_size_within_rect(galley.size(), label_rect) + .min; + ui.painter().galley(text_pos, galley, visuals.text_color()); + + // Draw value + let should_show_value = context + .list_item + .collapse_openness + .map_or(true, |o| o == 0.0) + || !summary_only; + let value_response = if let Some(value_fn) = value_fn { + if should_show_value { + let mut child_ui = + ui.child_ui(value_rect, egui::Layout::left_to_right(egui::Align::Center)); + value_fn(re_ui, &mut child_ui, visuals) + } else { + None + } + } else { + None + }; + + // Make a union of all (possibly) interactive elements + match (value_response, button_response) { + (Some(a), Some(b)) => Some(a | b), + (Some(a), None) | (None, Some(a)) => Some(a), + (None, None) => None, + } + } +} diff --git a/crates/re_ui/src/list_item2/scope.rs b/crates/re_ui/src/list_item2/scope.rs index ea789d1b6e92..84b084056572 100644 --- a/crates/re_ui/src/list_item2/scope.rs +++ b/crates/re_ui/src/list_item2/scope.rs @@ -13,6 +13,8 @@ pub struct State { // be generalized to some `full_span_scope` mechanism to be used by all full-span widgets beyond // `ListItem`. pub(crate) background_x_range: egui::Rangef, + // TODO(ab): record the use of right action button in all PropertyContent such as to not reserve + // right gutter space if none have it. } impl Default for State { diff --git a/crates/re_ui/src/toggle_switch.rs b/crates/re_ui/src/toggle_switch.rs index 77d5e5c2d9f3..73592320ff9e 100644 --- a/crates/re_ui/src/toggle_switch.rs +++ b/crates/re_ui/src/toggle_switch.rs @@ -1,12 +1,12 @@ //! Adapted from `egui_demo_lib/src/demo/toggle_switch.rs` -fn toggle_switch_ui(ui: &mut egui::Ui, on: &mut bool) -> egui::Response { - let interactive_size = egui::vec2(12.0, ui.spacing().interact_size.y); - let (interact_rect, mut response) = - ui.allocate_exact_size(interactive_size, egui::Sense::click()); - let visual_size = egui::vec2(12.0, 8.0); // 12x7 in figma, but 12x8 looks _much_ better in epaint - let visual_rect = - egui::Align2::CENTER_CENTER.align_size_within_rect(visual_size, interact_rect); +fn toggle_switch_ui(ui: &mut egui::Ui, height: f32, on: &mut bool) -> egui::Response { + let width = (height / 2. * 3.).ceil(); + let size = egui::vec2(width, height); // 12x7 in figma, but 12x8 looks _much_ better in epaint + + let (interact_rect, mut response) = ui.allocate_exact_size(size, egui::Sense::click()); + + let visual_rect = egui::Align2::CENTER_CENTER.align_size_within_rect(size, interact_rect); if response.clicked() { *on = !*on; @@ -29,7 +29,7 @@ fn toggle_switch_ui(ui: &mut egui::Ui, on: &mut bool) -> egui::Response { ); let circle_center = egui::pos2(circle_x, expanded_rect.center().y); - let circle_radius = 2.5 * expanded_rect.height() / visual_size.y; + let circle_radius = 0.3 * expanded_rect.height(); ui.painter() .circle(circle_center, circle_radius, fg_fill, egui::Stroke::NONE); } @@ -42,9 +42,9 @@ fn toggle_switch_ui(ui: &mut egui::Ui, on: &mut bool) -> egui::Response { /// /// ## Example: /// ``` ignore -/// ui.add(toggle_switch(&mut my_bool)); +/// ui.add(toggle_switch(8.0, &mut my_bool)); /// ``` #[allow(clippy::needless_pass_by_ref_mut)] // False positive, toggle_switch_ui needs &mut -pub fn toggle_switch(on: &mut bool) -> impl egui::Widget + '_ { - move |ui: &mut egui::Ui| toggle_switch_ui(ui, on) +pub fn toggle_switch(height: f32, on: &mut bool) -> impl egui::Widget + '_ { + move |ui: &mut egui::Ui| toggle_switch_ui(ui, height, on) }