perf(ribbon): cache natural panel measurements

This commit is contained in:
Karim Jerbi committed 2026-10-18 11:25:39 +01:00
1 parent 42ba7f12a0
commit 140303217f
2 files changed
+352 -60

No files matched your search

+95 -38
View File
@@ -8,12 +8,12 @@
//! panel, so it shrinks as the panels do.
use std::cell::RefCell;
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use iced::advanced::layout::{self, Layout};
use iced::advanced::widget::{self, Widget};
use iced::advanced::{mouse, overlay, renderer, Renderer as _, Shell};
use iced::advanced::{Renderer as _, Shell, mouse, overlay, renderer};
use iced::{
Background, Border, Element, Event, Length, Point, Rectangle, Renderer, Shadow, Size, Theme,
Vector,
@@ -173,6 +173,23 @@ pub(crate) struct CollapsePanels<'a> {
/// User-chosen density. `Auto` runs the width-based degradation; the others
/// pin every panel to one level regardless of the window width.
mode: CollapseMode,
/// Fingerprint of content that can affect natural panel dimensions.
measure_key: u64,
}
#[derive(Default)]
struct MeasureCache {
key: Option<u64>,
widths: Vec<Widths>,
heights: Vec<[f32; 4]>,
}
impl MeasureCache {
fn matches(&self, key: u64, panel_count: usize) -> bool {
self.key == Some(key)
&& self.widths.len() == panel_count
&& self.heights.len() == panel_count
}
}
impl<'a> CollapsePanels<'a> {
@@ -186,9 +203,18 @@ impl<'a> CollapsePanels<'a> {
height_out: None,
tight_out: None,
mode: CollapseMode::Auto,
measure_key: 0,
}
}
/// Reuse natural measurements while the panel content and font metrics stay
/// unchanged. The available window width is intentionally not part of the
/// key: these measurements use unbounded natural limits.
pub fn measure_key(mut self, key: u64) -> Self {
self.measure_key = key;
self
}
/// Report the measured row height into `out` on every layout.
pub fn report_height(mut self, out: Arc<AtomicU32>) -> Self {
self.height_out = Some(out);
@@ -225,6 +251,14 @@ impl<'a> CollapsePanels<'a> {
}
impl<'a> Widget<Message, Theme, Renderer> for CollapsePanels<'a> {
fn tag(&self) -> widget::tree::Tag {
widget::tree::Tag::of::<MeasureCache>()
}
fn state(&self) -> widget::tree::State {
widget::tree::State::new(MeasureCache::default())
}
fn diff(&mut self, tree: &mut widget::Tree) {
let mut refs = Vec::with_capacity(self.panels.len() * SLOTS);
for p in &mut self.panels {
@@ -254,42 +288,52 @@ impl<'a> Widget<Message, Theme, Renderer> for CollapsePanels<'a> {
let mut heights = Vec::with_capacity(n);
let auto = self.mode == CollapseMode::Auto;
if auto {
for i in 0..n {
let f = self.panels[i].elements[Level::Full as usize]
.as_widget_mut()
.layout(&mut tree.children[slot(i, Level::Full)], renderer, &natural)
.size();
let c = self.panels[i].elements[Level::Compact as usize]
.as_widget_mut()
.layout(
&mut tree.children[slot(i, Level::Compact)],
renderer,
&natural,
)
.size();
let b = self.panels[i].elements[Level::Collapsed as usize]
.as_widget_mut()
.layout(
&mut tree.children[slot(i, Level::Collapsed)],
renderer,
&natural,
)
.size();
let t = self.panels[i].elements[Level::Tight as usize]
.as_widget_mut()
.layout(
&mut tree.children[slot(i, Level::Tight)],
renderer,
&natural,
)
.size();
widths.push(Widths {
full: f.width,
compact: c.width,
button: b.width,
tight: t.width,
});
heights.push([f.height, c.height, b.height, t.height]);
let cache = tree.state.downcast_ref::<MeasureCache>();
if cache.matches(self.measure_key, n) {
widths.clone_from(&cache.widths);
heights.clone_from(&cache.heights);
} else {
for i in 0..n {
let f = self.panels[i].elements[Level::Full as usize]
.as_widget_mut()
.layout(&mut tree.children[slot(i, Level::Full)], renderer, &natural)
.size();
let c = self.panels[i].elements[Level::Compact as usize]
.as_widget_mut()
.layout(
&mut tree.children[slot(i, Level::Compact)],
renderer,
&natural,
)
.size();
let b = self.panels[i].elements[Level::Collapsed as usize]
.as_widget_mut()
.layout(
&mut tree.children[slot(i, Level::Collapsed)],
renderer,
&natural,
)
.size();
let t = self.panels[i].elements[Level::Tight as usize]
.as_widget_mut()
.layout(
&mut tree.children[slot(i, Level::Tight)],
renderer,
&natural,
)
.size();
widths.push(Widths {
full: f.width,
compact: c.width,
button: b.width,
tight: t.width,
});
heights.push([f.height, c.height, b.height, t.height]);
}
let cache = tree.state.downcast_mut::<MeasureCache>();
cache.key = Some(self.measure_key);
cache.widths.clone_from(&widths);
cache.heights.clone_from(&heights);
}
}
let levels = if let Some(level) = self.mode.forced_level() {
@@ -696,6 +740,19 @@ impl overlay::Overlay<Message, Theme, Renderer> for FlyoutOverlay<'_, '_> {
mod tests {
use super::*;
#[test]
fn natural_measurements_are_reused_only_for_the_same_key_and_panel_count() {
let cache = MeasureCache {
key: Some(17),
widths: vec![Widths::default(); 2],
heights: vec![[0.0; 4]; 2],
};
assert!(cache.matches(17, 2));
assert!(!cache.matches(18, 2));
assert!(!cache.matches(17, 1));
}
fn w(full: f32, compact: f32, button: f32, tight: f32) -> Widths {
Widths {
full,
+257 -22
View File
@@ -6,8 +6,10 @@
//
// Dropdown items within a group are collected into columns of 3 rows.
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use rustc_hash::FxHashMap as HashMap;
@@ -16,9 +18,9 @@ use iced::widget::{button, column, container, mouse_area, row, scrollable, text}
use iced::{Background, Border, Color, Element, Fill, Length, Padding, Theme};
use crate::app::Message;
use crate::modules::{CadModule, IconKind, RibbonGroup, RibbonItem};
use crate::modules::{CadModule, IconKind, RibbonGroup, RibbonItem, ToolDef};
use crate::plugin::all_ribbon_modules;
use crate::ui::properties::{linetype_display_name, lw_options, LinetypeItem};
use crate::ui::properties::{LinetypeItem, linetype_display_name, lw_options};
mod draw_panel;
pub(crate) mod widgets;
@@ -27,9 +29,9 @@ mod color_dropdown;
mod context_tools;
mod modify_panel;
mod visibility_panel;
pub use context_tools::{pdf_underlay_tools, point_cloud_tools, xref_tools, UnderlayContext};
use widgets::{StyleContext, *};
pub use context_tools::{UnderlayContext, pdf_underlay_tools, point_cloud_tools, xref_tools};
pub(crate) use widgets::{REDO_HISTORY_ID, UNDO_HISTORY_ID};
use widgets::{StyleContext, *};
mod collapse;
mod keytips;
mod locate;
@@ -38,9 +40,8 @@ use crate::ui::wrap_bar::{PosReport, WrapBar, WrapFlow};
pub use collapse::CollapseMode;
use collapse::{CollapsePanels, Panel};
pub use keytips::{
active as keytips_active, key_pressed as keytip_key_pressed,
key_released as keytip_key_released, pointer_pressed as keytip_pointer_pressed, KeyTipInput,
KeyTipStep,
KeyTipInput, KeyTipStep, active as keytips_active, key_pressed as keytip_key_pressed,
key_released as keytip_key_released, pointer_pressed as keytip_pointer_pressed,
};
pub(crate) fn tooltip_content(text: String) -> Element<'static, Message> {
@@ -56,6 +57,144 @@ fn dropdown_header(id: &str) -> Option<&'static str> {
(id == "IMPORT_FILE").then_some("Import File — the face runs the last one chosen")
}
fn hash_tool_layout(tool: &ToolDef, hasher: &mut impl Hasher) {
tool.id.hash(hasher);
t!(tool.label).as_ref().hash(hasher);
}
fn hash_item_layout(item: &RibbonItem, hasher: &mut impl Hasher) {
match item {
RibbonItem::Tool(tool) => {
0u8.hash(hasher);
hash_tool_layout(tool, hasher);
}
RibbonItem::LabeledTool(tool) => {
1u8.hash(hasher);
hash_tool_layout(tool, hasher);
}
RibbonItem::LargeTool(tool) => {
2u8.hash(hasher);
hash_tool_layout(tool, hasher);
}
RibbonItem::Dropdown { id, items, .. } => {
3u8.hash(hasher);
id.hash(hasher);
items.len().hash(hasher);
for (command, label, _) in items {
command.hash(hasher);
t!(*label).as_ref().hash(hasher);
}
}
RibbonItem::LabeledDropdown {
id, label, items, ..
} => {
4u8.hash(hasher);
id.hash(hasher);
t!(*label).as_ref().hash(hasher);
items.len().hash(hasher);
for (command, item_label, _) in items {
command.hash(hasher);
t!(*item_label).as_ref().hash(hasher);
}
}
RibbonItem::LargeDropdown {
id, label, items, ..
} => {
5u8.hash(hasher);
id.hash(hasher);
t!(*label).as_ref().hash(hasher);
items.len().hash(hasher);
for (command, item_label, _) in items {
command.hash(hasher);
t!(*item_label).as_ref().hash(hasher);
}
}
RibbonItem::ToolGrid { columns } => {
6u8.hash(hasher);
columns.len().hash(hasher);
for column in columns {
column.len().hash(hasher);
for tool in column {
hash_tool_layout(tool, hasher);
}
}
}
RibbonItem::LayerComboGroup { row2, row3 } => {
7u8.hash(hasher);
row2.len().hash(hasher);
row3.len().hash(hasher);
for tool in row2.iter().chain(row3) {
hash_tool_layout(tool, hasher);
}
}
RibbonItem::PropertiesGroup { match_prop } => {
8u8.hash(hasher);
hash_tool_layout(match_prop, hasher);
}
RibbonItem::StyleComboGroup {
style_key,
combo_id,
rows,
..
} => {
9u8.hash(hasher);
format!("{style_key:?}").hash(hasher);
combo_id.hash(hasher);
for row in rows {
row.len().hash(hasher);
for tool in row {
hash_tool_layout(tool, hasher);
}
}
}
}
}
fn panel_measure_key(
groups: &[&RibbonGroup],
last_cmd: &HashMap<&'static str, &'static str>,
last_panel_tool: &HashMap<&'static str, &'static str>,
style_ctx: &StyleContext<'_>,
visibility: Option<&crate::ui::window::block_visibility::RibbonContext>,
) -> u64 {
let mut hasher = DefaultHasher::new();
crate::sys::ui_font::generation().hash(&mut hasher);
groups.len().hash(&mut hasher);
for group in groups {
group.title.hash(&mut hasher);
group.tools.len().hash(&mut hasher);
for item in &group.tools {
hash_item_layout(item, &mut hasher);
}
}
let mut commands: Vec<_> = last_cmd.iter().collect();
commands.sort_unstable_by_key(|(id, _)| **id);
for (id, command) in commands {
id.hash(&mut hasher);
command.hash(&mut hasher);
}
let mut representatives: Vec<_> = last_panel_tool.iter().collect();
representatives.sort_unstable_by_key(|(title, _)| **title);
for (title, tool) in representatives {
title.hash(&mut hasher);
tool.hash(&mut hasher);
}
for (names, active) in [
(style_ctx.text_style_names, style_ctx.active_text_style),
(style_ctx.dim_style_names, style_ctx.active_dim_style),
(
style_ctx.mleader_style_names,
style_ctx.active_mleader_style,
),
(style_ctx.table_style_names, style_ctx.active_table_style),
] {
names.hash(&mut hasher);
active.hash(&mut hasher);
}
format!("{visibility:?}").hash(&mut hasher);
hasher.finish()
}
// ── Ribbon state ───────────────────────────────────────────────────────────
pub struct Ribbon {
@@ -495,6 +634,8 @@ impl Ribbon {
ts: widgets::ToggleState,
style_ctx: &StyleContext<'_>,
lead: &dyn Fn(bool) -> Vec<Element<'a, Message>>,
mode: CollapseMode,
flyout_open: bool,
) -> Panel<'a> {
let group = |compact: bool| {
render_group(
@@ -530,9 +671,21 @@ impl Ribbon {
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: [group(false), group(true), button(false), button(true)],
elements,
}
}
@@ -773,12 +926,14 @@ impl Ribbon {
.padding([2, 8]);
let mode_dd = PosReport::new(COLLAPSE_MODE_ID, mode_btn);
let mut tab_row = row![container(
WrapBar::new(lead.into(), tabs.into())
.spacing(6.0)
.report_height(self.tab_bar_h.clone()),
)
.width(Length::Fill)]
let mut tab_row = row![
container(
WrapBar::new(lead.into(), tabs.into())
.spacing(6.0)
.report_height(self.tab_bar_h.clone()),
)
.width(Length::Fill)
]
.spacing(6.0)
.align_y(iced::Center);
if !self.collapse_tight.load(Ordering::Relaxed) {
@@ -809,7 +964,7 @@ impl Ribbon {
self.active
};
let tool_area: Element<'_, Message> = if self.modules.get(effective_active).is_some() {
let groups = self.groups_for(effective_active);
let groups: Vec<_> = self.groups_for(effective_active).collect();
let style_ctx = StyleContext {
text_style_names: &self.text_style_names,
active_text_style: &self.active_text_style,
@@ -826,18 +981,34 @@ 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 panels: Vec<Panel<'_>> = groups
.into_iter()
.map(|g| {
if self.modules[effective_active].id() == "block_editor"
&& g.title == "Visibility"
{
visibility_panel::panel(visibility.clone())
} else {
self.panel(g, ts, &style_ctx, &|_| Vec::new())
self.panel(
g,
ts,
&style_ctx,
&|_| Vec::new(),
self.collapse_mode,
self.collapsed_open.as_deref() == Some(g.title),
)
}
})
.collect();
CollapsePanels::new(panels, self.collapsed_open.clone(), TOOL_BAR_H)
.measure_key(measure_key)
.report_height(self.tool_bar_h.clone())
.report_tight(self.collapse_tight.clone())
.mode(self.collapse_mode)
@@ -1780,7 +1951,69 @@ impl Default for Ribbon {
#[cfg(test)]
mod tests {
use super::widgets::is_active_tool;
use super::Ribbon;
use super::{CollapseMode, Ribbon, StyleContext, panel_measure_key};
use crate::modules::{IconKind, ModuleEvent, RibbonGroup, RibbonItem, ToolDef};
use rustc_hash::FxHashMap;
fn measurement_key(tool_id: &'static str, selected_command: &'static str) -> u64 {
let groups = [RibbonGroup {
title: "Test",
tools: vec![RibbonItem::LargeTool(ToolDef {
id: tool_id,
label: "Test Tool",
icon: IconKind::Glyph("T"),
event: ModuleEvent::Command("TEST".to_string()),
})],
}];
let style_ctx = StyleContext {
text_style_names: &[],
active_text_style: "",
dim_style_names: &[],
active_dim_style: "",
mleader_style_names: &[],
active_mleader_style: "",
table_style_names: &[],
active_table_style: "",
};
let groups: Vec<_> = groups.iter().collect();
let mut last_cmd = FxHashMap::default();
last_cmd.insert("TEST_DROPDOWN", selected_command);
panel_measure_key(&groups, &last_cmd, &FxHashMap::default(), &style_ctx, None)
}
#[test]
fn panel_measure_fingerprint_is_stable_and_tracks_panel_structure() {
assert_eq!(
measurement_key("TEST", "TEST"),
measurement_key("TEST", "TEST")
);
assert_ne!(
measurement_key("TEST", "TEST"),
measurement_key("OTHER", "TEST")
);
assert_ne!(
measurement_key("TEST", "TEST"),
measurement_key("TEST", "OTHER")
);
}
#[test]
fn forced_density_views_build_and_keep_collapsed_flyout_content() {
let mut ribbon = Ribbon::default();
let first_panel = ribbon.groups_for(ribbon.active).next().unwrap().title;
for mode in [
CollapseMode::Full,
CollapseMode::Compact,
CollapseMode::Collapsed,
] {
ribbon.set_collapse_mode(mode);
ribbon.collapsed_open =
(mode == CollapseMode::Collapsed).then(|| first_panel.to_string());
let view = ribbon.view(false, false, 0, 0, false, None);
std::hint::black_box(view);
}
}
#[test]
fn block_test_close_panel_extends_draw_only_while_testing() {
@@ -1790,10 +2023,12 @@ mod tests {
.iter()
.position(|module| module.id() == "draw")
.unwrap();
assert!(!ribbon
.modules
.iter()
.any(|module| module.id() == "block_test"));
assert!(
!ribbon
.modules
.iter()
.any(|module| module.id() == "block_test")
);
let ordinary: Vec<_> = ribbon.groups_for(draw).map(|group| group.title).collect();
assert!(!ordinary.contains(&"Close"));
ribbon.set_block_editing(true);