From f0479a5fc6ca6401d9228820376c866cf6024caf Mon Sep 17 00:00:00 2001 From: Karim Jerbi Date: Sun, 18 Oct 2026 11:46:11 +0100 Subject: [PATCH] perf(ribbon): build only selected density after measuring --- Cargo.toml | 4 + benches/ribbon_layout_benchmark.rs | 80 +++++++++++++++ src/ui/ribbon/collapse.rs | 151 +++++++++++++++++++++++++++-- src/ui/ribbon/mod.rs | 64 +++++------- src/ui/ribbon/visibility_panel.rs | 23 ++--- src/ui/ribbon/widgets.rs | 1 + 6 files changed, 260 insertions(+), 63 deletions(-) create mode 100644 benches/ribbon_layout_benchmark.rs diff --git a/Cargo.toml b/Cargo.toml index af11add7f..de0972601 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -215,6 +215,10 @@ strip = true name = "performance_benchmarks" harness = false +[[bench]] +name = "ribbon_layout_benchmark" +harness = false + [[bench]] name = "command_benchmarks" harness = false diff --git a/benches/ribbon_layout_benchmark.rs b/benches/ribbon_layout_benchmark.rs new file mode 100644 index 000000000..1ead2220f --- /dev/null +++ b/benches/ribbon_layout_benchmark.rs @@ -0,0 +1,80 @@ +//! End-to-end ribbon view construction and layout benchmark. +//! +//! Unlike the broader performance bench's widget-construction-only metric, this +//! carries Iced's widget-tree cache across frames and runs layout at a fixed size. + +use std::hint::black_box; +use std::time::Instant; + +use OpenCADStudio::ui::ribbon::{LayerInfo, Ribbon}; +use iced::{Color, Size}; +use iced_test::runtime::user_interface::{Cache, UserInterface}; + +fn main() { + let mut ribbon = Ribbon::new(); + ribbon.set_styles( + vec!["Standard".into(), "Title".into(), "Annotative".into()], + "Standard", + vec!["Standard".into()], + "Standard", + vec!["Standard".into()], + "Standard", + vec!["Standard".into()], + "Standard", + ); + ribbon.set_layers( + ["0", "DRAWING", "DIMENSIONS", "ANNOTATIONS"] + .into_iter() + .map(|name| LayerInfo { + name: name.into(), + color: Color::TRANSPARENT, + visible: true, + frozen: false, + locked: false, + vp_frozen: None, + }) + .collect(), + "0", + ); + + let mut renderer = iced_test::futures::futures::executor::block_on( + ::new( + iced_test::core::renderer::Settings::default(), + None, + ), + ) + .expect("headless renderer"); + let mut cache = Cache::default(); + let bounds = Size::new(1600.0, 180.0); + + let frame = |cache, renderer: &mut iced::Renderer| { + let ui = UserInterface::build( + ribbon.view(false, false, 0, 0, false, None), + bounds, + cache, + renderer, + ); + black_box(ui.into_cache()) + }; + + for _ in 0..10 { + cache = frame(cache, &mut renderer); + } + + let iterations = 100; + let runs = 7; + let mut samples = Vec::with_capacity(runs); + for _ in 0..runs { + let start = Instant::now(); + for _ in 0..iterations { + cache = frame(cache, &mut renderer); + } + samples.push(start.elapsed().as_secs_f64() * 1_000_000.0 / iterations as f64); + } + samples.sort_by(f64::total_cmp); + println!( + "ribbon view + Iced layout (cached steady state): median {:.1} µs/frame; samples {:?}", + samples[runs / 2], + samples + ); +} diff --git a/src/ui/ribbon/collapse.rs b/src/ui/ribbon/collapse.rs index efb5a48ed..e6f0908c4 100644 --- a/src/ui/ribbon/collapse.rs +++ b/src/ui/ribbon/collapse.rs @@ -24,10 +24,25 @@ use crate::app::Message; /// One panel in its four density renderings. `tight` is the collapsed button /// with a small representative icon instead of the large one — the last step /// before the buttons are squeezed together. An open flyout draws the panel's -/// own `full` rendering; see `FlyoutOverlay`. `elements` is indexed by `Level`. +/// own `full` rendering; see `FlyoutOverlay`. Elements are built lazily and +/// indexed by `Level`. pub(crate) struct Panel<'a> { pub(crate) id: String, - pub(crate) elements: [Element<'a, Message>; 4], + elements: [Option>; SLOTS], + build: Box Element<'a, Message> + 'a>, +} + +impl<'a> Panel<'a> { + pub(crate) fn deferred( + id: impl Into, + build: impl Fn(Level) -> Element<'a, Message> + 'a, + ) -> Self { + Self { + id: id.into(), + elements: std::array::from_fn(|_| None), + build: Box::new(build), + } + } } // Number of tree slots per panel: [full, compact, button, tight]. @@ -236,11 +251,24 @@ impl<'a> CollapsePanels<'a> { /// The shown element of panel `i` at `level` — an index into its `elements` /// array, which the tree slots mirror (`slot(i, level)`). fn shown(&self, i: usize, level: Level) -> &Element<'a, Message> { - &self.panels[i].elements[level as usize] + self.panels[i].elements[level as usize] + .as_ref() + .expect("selected ribbon density is built during layout") } fn shown_mut(&mut self, i: usize, level: Level) -> &mut Element<'a, Message> { - &mut self.panels[i].elements[level as usize] + self.panels[i].elements[level as usize] + .as_mut() + .expect("selected ribbon density is built during layout") + } + + fn ensure_element(&mut self, tree: &mut widget::Tree, i: usize, level: Level) { + let index = level as usize; + if self.panels[i].elements[index].is_none() { + let mut element = (self.panels[i].build)(level); + tree.children[slot(i, level)].diff(element.as_widget_mut()); + self.panels[i].elements[index] = Some(element); + } } fn levels_snapshot(&self, n: usize) -> Vec { @@ -260,11 +288,16 @@ impl<'a> Widget for CollapsePanels<'a> { } fn diff(&mut self, tree: &mut widget::Tree) { - let mut refs = Vec::with_capacity(self.panels.len() * SLOTS); - for p in &mut self.panels { - refs.extend(p.elements.iter_mut()); + let child_count = self.panels.len() * SLOTS; + tree.children.truncate(child_count); + tree.children.resize_with(child_count, widget::Tree::empty); + for (panel_index, panel) in self.panels.iter_mut().enumerate() { + for level in [Level::Full, Level::Compact, Level::Collapsed, Level::Tight] { + if let Some(element) = panel.elements[level as usize].as_mut() { + tree.children[slot(panel_index, level)].diff(element.as_widget_mut()); + } + } } - tree.diff_children(&mut refs); } fn size(&self) -> Size { @@ -294,11 +327,17 @@ impl<'a> Widget for CollapsePanels<'a> { heights.clone_from(&cache.heights); } else { for i in 0..n { + self.ensure_element(tree, i, Level::Full); let f = self.panels[i].elements[Level::Full as usize] + .as_mut() + .unwrap() .as_widget_mut() .layout(&mut tree.children[slot(i, Level::Full)], renderer, &natural) .size(); + self.ensure_element(tree, i, Level::Compact); let c = self.panels[i].elements[Level::Compact as usize] + .as_mut() + .unwrap() .as_widget_mut() .layout( &mut tree.children[slot(i, Level::Compact)], @@ -306,7 +345,10 @@ impl<'a> Widget for CollapsePanels<'a> { &natural, ) .size(); + self.ensure_element(tree, i, Level::Collapsed); let b = self.panels[i].elements[Level::Collapsed as usize] + .as_mut() + .unwrap() .as_widget_mut() .layout( &mut tree.children[slot(i, Level::Collapsed)], @@ -314,7 +356,10 @@ impl<'a> Widget for CollapsePanels<'a> { &natural, ) .size(); + self.ensure_element(tree, i, Level::Tight); let t = self.panels[i].elements[Level::Tight as usize] + .as_mut() + .unwrap() .as_widget_mut() .layout( &mut tree.children[slot(i, Level::Tight)], @@ -342,6 +387,18 @@ impl<'a> Widget for CollapsePanels<'a> { decide_levels(self.mode, &widths, max_w) }; + // Measurements let Auto choose a density without building all variants + // again. On a cache hit only the selected element is built. If a + // collapsed panel is open, its Full element is also needed by the flyout. + for (i, &level) in levels.iter().enumerate() { + self.ensure_element(tree, i, level); + if matches!(level, Level::Collapsed | Level::Tight) + && self.open.as_deref() == Some(self.panels[i].id.as_str()) + { + self.ensure_element(tree, i, Level::Full); + } + } + // The row is "tight" once any panel has dropped to its small icon — the // last, most cramped state. The tab bar hides its mode selector then. if let Some(out) = &self.tight_out { @@ -572,7 +629,9 @@ impl<'a> Widget for CollapsePanels<'a> { // never shows slot 0 while the panel is button-form, so it // is free to double as the overlay content). return Some(overlay::Element::new(Box::new(FlyoutOverlay { - content: &mut self.panels[p].elements[Level::Full as usize], + content: self.panels[p].elements[Level::Full as usize] + .as_mut() + .expect("open collapsed flyout has full panel content"), tree: &mut tree.children[slot(p, Level::Full)], anchor, }))); @@ -595,7 +654,9 @@ impl<'a> Widget for CollapsePanels<'a> { let (chunk, rest) = tree_rest.split_at_mut(SLOTS); tree_rest = rest; let level = levels.get(i).copied().unwrap_or(Level::Full); - let child = &mut panel.elements[level as usize]; + let Some(child) = panel.elements[level as usize].as_mut() else { + continue; + }; if let Some(o) = child.as_widget_mut().overlay( &mut chunk[level as usize], child_layout, @@ -753,6 +814,76 @@ mod tests { assert!(!cache.matches(17, 1)); } + #[test] + fn auto_builds_all_densities_once_then_only_the_selected_density() { + use iced_test::runtime::user_interface::{Cache, UserInterface}; + use std::sync::atomic::{AtomicUsize, Ordering}; + + fn view(counter: Arc, open: Option) -> Element<'static, Message> { + let panel = Panel::deferred("test", move |level| { + counter.fetch_add(1, Ordering::Relaxed); + let (width, height) = match level { + Level::Full => (200.0, 80.0), + Level::Compact => (150.0, 70.0), + Level::Collapsed => (100.0, 50.0), + Level::Tight => (50.0, 30.0), + }; + iced::widget::container(iced::widget::text("panel")) + .width(Length::Fixed(width)) + .height(Length::Fixed(height)) + .into() + }); + CollapsePanels::new(vec![panel], open, 80.0) + .measure_key(17) + .into() + } + + let count = Arc::new(AtomicUsize::new(0)); + let mut renderer = iced_test::futures::futures::executor::block_on( + ::new( + iced_test::core::renderer::Settings::default(), + None, + ), + ) + .expect("headless renderer"); + + let ui = UserInterface::build( + view(count.clone(), None), + Size::new(1000.0, 100.0), + Cache::default(), + &mut renderer, + ); + assert_eq!(count.load(Ordering::Relaxed), 4); + + let ui = UserInterface::build( + view(count.clone(), None), + Size::new(1000.0, 100.0), + ui.into_cache(), + &mut renderer, + ); + assert_eq!(count.load(Ordering::Relaxed), 5, "only Full is rebuilt"); + + let ui = UserInterface::build( + view(count.clone(), None), + Size::new(170.0, 100.0), + ui.into_cache(), + &mut renderer, + ); + assert_eq!(count.load(Ordering::Relaxed), 6, "resize builds Compact"); + + let _ui = UserInterface::build( + view(count.clone(), Some("test".to_string())), + Size::new(20.0, 100.0), + ui.into_cache(), + &mut renderer, + ); + assert_eq!( + count.load(Ordering::Relaxed), + 8, + "tight selection builds Tight and Full for the open flyout" + ); + } + fn w(full: f32, compact: f32, button: f32, tight: f32) -> Widths { Widths { full, diff --git a/src/ui/ribbon/mod.rs b/src/ui/ribbon/mod.rs index 82c111438..b256e07b0 100644 --- a/src/ui/ribbon/mod.rs +++ b/src/ui/ribbon/mod.rs @@ -632,16 +632,13 @@ impl Ribbon { &'a self, g: &'a RibbonGroup, ts: widgets::ToggleState, - style_ctx: &StyleContext<'_>, - lead: &dyn Fn(bool) -> Vec>, - mode: CollapseMode, - flyout_open: bool, + style_ctx: StyleContext<'a>, ) -> Panel<'a> { - let group = |compact: bool| { + let group = move |compact: bool| { render_group( compact, g, - lead(compact), + Vec::new(), &self.active_tool, &self.open_dropdown, &self.last_cmd, @@ -651,10 +648,10 @@ impl Ribbon { self.active_color, &self.active_linetype, self.active_lineweight, - style_ctx, + &style_ctx, ) }; - let button = |tight: bool| { + let button = move |tight: bool| { collapse_button( g, self.last_panel_tool.get(g.title).copied(), @@ -667,26 +664,16 @@ impl Ribbon { self.active_color, &self.active_linetype, self.active_lineweight, - style_ctx, + &style_ctx, tight, ) }; - let empty = || text("").into(); - let elements = match mode { - CollapseMode::Auto => [group(false), group(true), button(false), button(true)], - CollapseMode::Full => [group(false), empty(), empty(), empty()], - CollapseMode::Compact => [empty(), group(true), empty(), empty()], - CollapseMode::Collapsed => [ - if flyout_open { group(false) } else { empty() }, - empty(), - button(false), - empty(), - ], - }; - Panel { - id: g.title.to_string(), - elements, - } + Panel::deferred(g.title, move |level| match level { + collapse::Level::Full => group(false), + collapse::Level::Compact => group(true), + collapse::Level::Collapsed => button(false), + collapse::Level::Tight => button(true), + }) } pub fn toggle_dropdown(&mut self, id: &str) { @@ -981,13 +968,17 @@ impl Ribbon { // shrink to compact icon columns, then it collapses to a ▾ flyout // button. See `CollapsePanels`. let ts = self.toggle_state(show_block_palette); - let measure_key = panel_measure_key( - &groups, - &self.last_cmd, - &self.last_panel_tool, - &style_ctx, - visibility.as_ref(), - ); + let measure_key = if self.collapse_mode == CollapseMode::Auto { + panel_measure_key( + &groups, + &self.last_cmd, + &self.last_panel_tool, + &style_ctx, + visibility.as_ref(), + ) + } else { + 0 + }; let panels: Vec> = groups .into_iter() .map(|g| { @@ -996,14 +987,7 @@ impl Ribbon { { visibility_panel::panel(visibility.clone()) } else { - self.panel( - g, - ts, - &style_ctx, - &|_| Vec::new(), - self.collapse_mode, - self.collapsed_open.as_deref() == Some(g.title), - ) + self.panel(g, ts, style_ctx) } }) .collect(); diff --git a/src/ui/ribbon/visibility_panel.rs b/src/ui/ribbon/visibility_panel.rs index 9353e2406..f98ceac94 100644 --- a/src/ui/ribbon/visibility_panel.rs +++ b/src/ui/ribbon/visibility_panel.rs @@ -2,12 +2,12 @@ use iced::widget::{button, column, container, pick_list, row, text, tooltip}; use iced::{Element, Fill, Theme}; -use super::collapse::Panel; -use super::widgets::{make_icon, tool_btn_style, LARGE_ICON, LARGE_W, SMALL_ICON, TOOL_BAR_H}; +use super::collapse::{Level, Panel}; +use super::widgets::{LARGE_ICON, LARGE_W, SMALL_ICON, TOOL_BAR_H, make_icon, tool_btn_style}; use crate::app::Message; use crate::modules::IconKind; -use crate::ui::window::block_visibility::{Action, RibbonContext}; use crate::ui::ROW_H; +use crate::ui::window::block_visibility::{Action, RibbonContext}; fn tool( id: &'static str, @@ -152,14 +152,11 @@ fn collapsed(tight: bool) -> Element<'static, Message> { .into() } -pub(super) fn panel(context: Option) -> Panel<'static> { - Panel { - id: "Visibility".into(), - elements: [ - view(context.clone(), false), - view(context, true), - collapsed(false), - collapsed(true), - ], - } +pub(super) fn panel<'a>(context: Option) -> Panel<'a> { + Panel::deferred("Visibility", move |level| match level { + Level::Full => view(context.clone(), false), + Level::Compact => view(context.clone(), true), + Level::Collapsed => collapsed(false), + Level::Tight => collapsed(true), + }) } diff --git a/src/ui/ribbon/widgets.rs b/src/ui/ribbon/widgets.rs index 34086b73c..01bbf515d 100644 --- a/src/ui/ribbon/widgets.rs +++ b/src/ui/ribbon/widgets.rs @@ -321,6 +321,7 @@ pub(super) const PROP_LW_ID: &str = "PROP_LW"; // ── Style context (passed from Ribbon to render_large) ──────────────────── +#[derive(Clone, Copy)] pub(super) struct StyleContext<'a> { pub text_style_names: &'a [String], pub active_text_style: &'a str,