perf(ribbon): build only selected density after measuring

This commit is contained in:
Karim Jerbi committed 2026-10-18 11:46:11 +01:00
1 parent 140303217f
commit f0479a5fc6
6 files changed
+260 -63

No files matched your search

+4
View File
@@ -215,6 +215,10 @@ strip = true
name = "performance_benchmarks"
harness = false
[[bench]]
name = "ribbon_layout_benchmark"
harness = false
[[bench]]
name = "command_benchmarks"
harness = false
+80
View File
@@ -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(
<iced::Renderer as iced_test::core::renderer::Headless>::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
);
}
+141 -10
View File
@@ -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<Element<'a, Message>>; SLOTS],
build: Box<dyn Fn(Level) -> Element<'a, Message> + 'a>,
}
impl<'a> Panel<'a> {
pub(crate) fn deferred(
id: impl Into<String>,
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<Level> {
@@ -260,11 +288,16 @@ impl<'a> Widget<Message, Theme, Renderer> 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<Length> {
@@ -294,11 +327,17 @@ impl<'a> Widget<Message, Theme, Renderer> 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<Message, Theme, Renderer> 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<Message, Theme, Renderer> 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<Message, Theme, Renderer> 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<Message, Theme, Renderer> 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<Message, Theme, Renderer> 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<AtomicUsize>, open: Option<String>) -> 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(
<Renderer as iced_test::core::renderer::Headless>::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,
+24 -40
View File
@@ -632,16 +632,13 @@ impl Ribbon {
&'a self,
g: &'a RibbonGroup,
ts: widgets::ToggleState,
style_ctx: &StyleContext<'_>,
lead: &dyn Fn(bool) -> Vec<Element<'a, Message>>,
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<Panel<'_>> = 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();
+10 -13
View File
@@ -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<RibbonContext>) -> 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<RibbonContext>) -> 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),
})
}
+1
View File
@@ -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,