diff --git a/src/ui/ribbon/collapse.rs b/src/ui/ribbon/collapse.rs index 6115e8a77..efb5a48ed 100644 --- a/src/ui/ribbon/collapse.rs +++ b/src/ui/ribbon/collapse.rs @@ -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, + widths: Vec, + 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) -> Self { self.height_out = Some(out); @@ -225,6 +251,14 @@ impl<'a> CollapsePanels<'a> { } impl<'a> Widget for CollapsePanels<'a> { + fn tag(&self) -> widget::tree::Tag { + widget::tree::Tag::of::() + } + + 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 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::(); + 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::(); + 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 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, diff --git a/src/ui/ribbon/mod.rs b/src/ui/ribbon/mod.rs index 2f61c9933..82c111438 100644 --- a/src/ui/ribbon/mod.rs +++ b/src/ui/ribbon/mod.rs @@ -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>, + 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> = 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);