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. //! panel, so it shrinks as the panels do.
use std::cell::RefCell; use std::cell::RefCell;
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use std::sync::Arc; use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use iced::advanced::layout::{self, Layout}; use iced::advanced::layout::{self, Layout};
use iced::advanced::widget::{self, Widget}; 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::{ use iced::{
Background, Border, Element, Event, Length, Point, Rectangle, Renderer, Shadow, Size, Theme, Background, Border, Element, Event, Length, Point, Rectangle, Renderer, Shadow, Size, Theme,
Vector, Vector,
@@ -173,6 +173,23 @@ pub(crate) struct CollapsePanels<'a> {
/// User-chosen density. `Auto` runs the width-based degradation; the others /// User-chosen density. `Auto` runs the width-based degradation; the others
/// pin every panel to one level regardless of the window width. /// pin every panel to one level regardless of the window width.
mode: CollapseMode, 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> { impl<'a> CollapsePanels<'a> {
@@ -186,9 +203,18 @@ impl<'a> CollapsePanels<'a> {
height_out: None, height_out: None,
tight_out: None, tight_out: None,
mode: CollapseMode::Auto, 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. /// Report the measured row height into `out` on every layout.
pub fn report_height(mut self, out: Arc<AtomicU32>) -> Self { pub fn report_height(mut self, out: Arc<AtomicU32>) -> Self {
self.height_out = Some(out); self.height_out = Some(out);
@@ -225,6 +251,14 @@ impl<'a> CollapsePanels<'a> {
} }
impl<'a> Widget<Message, Theme, Renderer> for 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) { fn diff(&mut self, tree: &mut widget::Tree) {
let mut refs = Vec::with_capacity(self.panels.len() * SLOTS); let mut refs = Vec::with_capacity(self.panels.len() * SLOTS);
for p in &mut self.panels { 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 mut heights = Vec::with_capacity(n);
let auto = self.mode == CollapseMode::Auto; let auto = self.mode == CollapseMode::Auto;
if auto { if auto {
for i in 0..n { let cache = tree.state.downcast_ref::<MeasureCache>();
let f = self.panels[i].elements[Level::Full as usize] if cache.matches(self.measure_key, n) {
.as_widget_mut() widths.clone_from(&cache.widths);
.layout(&mut tree.children[slot(i, Level::Full)], renderer, &natural) heights.clone_from(&cache.heights);
.size(); } else {
let c = self.panels[i].elements[Level::Compact as usize] for i in 0..n {
.as_widget_mut() let f = self.panels[i].elements[Level::Full as usize]
.layout( .as_widget_mut()
&mut tree.children[slot(i, Level::Compact)], .layout(&mut tree.children[slot(i, Level::Full)], renderer, &natural)
renderer, .size();
&natural, let c = self.panels[i].elements[Level::Compact as usize]
) .as_widget_mut()
.size(); .layout(
let b = self.panels[i].elements[Level::Collapsed as usize] &mut tree.children[slot(i, Level::Compact)],
.as_widget_mut() renderer,
.layout( &natural,
&mut tree.children[slot(i, Level::Collapsed)], )
renderer, .size();
&natural, let b = self.panels[i].elements[Level::Collapsed as usize]
) .as_widget_mut()
.size(); .layout(
let t = self.panels[i].elements[Level::Tight as usize] &mut tree.children[slot(i, Level::Collapsed)],
.as_widget_mut() renderer,
.layout( &natural,
&mut tree.children[slot(i, Level::Tight)], )
renderer, .size();
&natural, let t = self.panels[i].elements[Level::Tight as usize]
) .as_widget_mut()
.size(); .layout(
widths.push(Widths { &mut tree.children[slot(i, Level::Tight)],
full: f.width, renderer,
compact: c.width, &natural,
button: b.width, )
tight: t.width, .size();
}); widths.push(Widths {
heights.push([f.height, c.height, b.height, t.height]); 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() { let levels = if let Some(level) = self.mode.forced_level() {
@@ -696,6 +740,19 @@ impl overlay::Overlay<Message, Theme, Renderer> for FlyoutOverlay<'_, '_> {
mod tests { mod tests {
use super::*; 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 { fn w(full: f32, compact: f32, button: f32, tight: f32) -> Widths {
Widths { Widths {
full, full,
+257 -22
View File
@@ -6,8 +6,10 @@
// //
// Dropdown items within a group are collected into columns of 3 rows. // 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::Arc;
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use rustc_hash::FxHashMap as HashMap; 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 iced::{Background, Border, Color, Element, Fill, Length, Padding, Theme};
use crate::app::Message; 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::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; mod draw_panel;
pub(crate) mod widgets; pub(crate) mod widgets;
@@ -27,9 +29,9 @@ mod color_dropdown;
mod context_tools; mod context_tools;
mod modify_panel; mod modify_panel;
mod visibility_panel; mod visibility_panel;
pub use context_tools::{pdf_underlay_tools, point_cloud_tools, xref_tools, UnderlayContext}; pub use context_tools::{UnderlayContext, pdf_underlay_tools, point_cloud_tools, xref_tools};
use widgets::{StyleContext, *};
pub(crate) use widgets::{REDO_HISTORY_ID, UNDO_HISTORY_ID}; pub(crate) use widgets::{REDO_HISTORY_ID, UNDO_HISTORY_ID};
use widgets::{StyleContext, *};
mod collapse; mod collapse;
mod keytips; mod keytips;
mod locate; mod locate;
@@ -38,9 +40,8 @@ use crate::ui::wrap_bar::{PosReport, WrapBar, WrapFlow};
pub use collapse::CollapseMode; pub use collapse::CollapseMode;
use collapse::{CollapsePanels, Panel}; use collapse::{CollapsePanels, Panel};
pub use keytips::{ pub use keytips::{
active as keytips_active, key_pressed as keytip_key_pressed, KeyTipInput, KeyTipStep, active as keytips_active, key_pressed as keytip_key_pressed,
key_released as keytip_key_released, pointer_pressed as keytip_pointer_pressed, KeyTipInput, key_released as keytip_key_released, pointer_pressed as keytip_pointer_pressed,
KeyTipStep,
}; };
pub(crate) fn tooltip_content(text: String) -> Element<'static, Message> { 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") (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 ─────────────────────────────────────────────────────────── // ── Ribbon state ───────────────────────────────────────────────────────────
pub struct Ribbon { pub struct Ribbon {
@@ -495,6 +634,8 @@ impl Ribbon {
ts: widgets::ToggleState, ts: widgets::ToggleState,
style_ctx: &StyleContext<'_>, style_ctx: &StyleContext<'_>,
lead: &dyn Fn(bool) -> Vec<Element<'a, Message>>, lead: &dyn Fn(bool) -> Vec<Element<'a, Message>>,
mode: CollapseMode,
flyout_open: bool,
) -> Panel<'a> { ) -> Panel<'a> {
let group = |compact: bool| { let group = |compact: bool| {
render_group( render_group(
@@ -530,9 +671,21 @@ impl Ribbon {
tight, 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 { Panel {
id: g.title.to_string(), id: g.title.to_string(),
elements: [group(false), group(true), button(false), button(true)], elements,
} }
} }
@@ -773,12 +926,14 @@ impl Ribbon {
.padding([2, 8]); .padding([2, 8]);
let mode_dd = PosReport::new(COLLAPSE_MODE_ID, mode_btn); let mode_dd = PosReport::new(COLLAPSE_MODE_ID, mode_btn);
let mut tab_row = row![container( let mut tab_row = row![
WrapBar::new(lead.into(), tabs.into()) container(
.spacing(6.0) WrapBar::new(lead.into(), tabs.into())
.report_height(self.tab_bar_h.clone()), .spacing(6.0)
) .report_height(self.tab_bar_h.clone()),
.width(Length::Fill)] )
.width(Length::Fill)
]
.spacing(6.0) .spacing(6.0)
.align_y(iced::Center); .align_y(iced::Center);
if !self.collapse_tight.load(Ordering::Relaxed) { if !self.collapse_tight.load(Ordering::Relaxed) {
@@ -809,7 +964,7 @@ impl Ribbon {
self.active self.active
}; };
let tool_area: Element<'_, Message> = if self.modules.get(effective_active).is_some() { 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 { let style_ctx = StyleContext {
text_style_names: &self.text_style_names, text_style_names: &self.text_style_names,
active_text_style: &self.active_text_style, active_text_style: &self.active_text_style,
@@ -826,18 +981,34 @@ impl Ribbon {
// shrink to compact icon columns, then it collapses to a ▾ flyout // shrink to compact icon columns, then it collapses to a ▾ flyout
// button. See `CollapsePanels`. // button. See `CollapsePanels`.
let ts = self.toggle_state(show_block_palette); 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 let panels: Vec<Panel<'_>> = groups
.into_iter()
.map(|g| { .map(|g| {
if self.modules[effective_active].id() == "block_editor" if self.modules[effective_active].id() == "block_editor"
&& g.title == "Visibility" && g.title == "Visibility"
{ {
visibility_panel::panel(visibility.clone()) visibility_panel::panel(visibility.clone())
} else { } 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(); .collect();
CollapsePanels::new(panels, self.collapsed_open.clone(), TOOL_BAR_H) CollapsePanels::new(panels, self.collapsed_open.clone(), TOOL_BAR_H)
.measure_key(measure_key)
.report_height(self.tool_bar_h.clone()) .report_height(self.tool_bar_h.clone())
.report_tight(self.collapse_tight.clone()) .report_tight(self.collapse_tight.clone())
.mode(self.collapse_mode) .mode(self.collapse_mode)
@@ -1780,7 +1951,69 @@ impl Default for Ribbon {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::widgets::is_active_tool; 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] #[test]
fn block_test_close_panel_extends_draw_only_while_testing() { fn block_test_close_panel_extends_draw_only_while_testing() {
@@ -1790,10 +2023,12 @@ mod tests {
.iter() .iter()
.position(|module| module.id() == "draw") .position(|module| module.id() == "draw")
.unwrap(); .unwrap();
assert!(!ribbon assert!(
.modules !ribbon
.iter() .modules
.any(|module| module.id() == "block_test")); .iter()
.any(|module| module.id() == "block_test")
);
let ordinary: Vec<_> = ribbon.groups_for(draw).map(|group| group.title).collect(); let ordinary: Vec<_> = ribbon.groups_for(draw).map(|group| group.title).collect();
assert!(!ordinary.contains(&"Close")); assert!(!ordinary.contains(&"Close"));
ribbon.set_block_editing(true); ribbon.set_block_editing(true);