From a469e748fb2c879e9e975589521b3d103bff3a1f Mon Sep 17 00:00:00 2001 From: Dongcheng Lin Date: Sun, 30 Aug 2026 18:28:39 +0800 Subject: [PATCH] refactor(ui): measured Ribbon layout with priority-prefix overflow Split ribbon.rs into layout (width mathematics) and buttons (command widgets) modules ahead of the labelled-compact work, and make the layout engine measure instead of guess: - Group and tile widths come from real galley measurement through an injected measurer, replacing the per-character estimates that drift on CJK and long labels; the More reservation is measured the same way instead of a fixed 86 px. - The overflow fill now stops at the first group that does not fit, so the visible set is always a highest-priority prefix; previously a wide high-priority group could land in More while narrower low-priority groups stayed visible. - Every (tab, group) pair has an explicit left-to-right order, guarded by an exhaustive test over the placement tables; half the groups previously tied at u8::MAX and ordered by catalog iteration accident. - The screenshot harness gains an Op::RibbonTab scene op and captures every task tab at the in-between 900 px width, so Ribbon changes have visual coverage beyond the Analyze tab. Co-Authored-By: Claude Fable 5 --- crates/app/src/shot.rs | 21 +- crates/app/src/typography.rs | 4 + crates/app/src/ui/commands.rs | 168 ++-------- crates/app/src/ui/commands/roster.rs | 164 ++++++++++ crates/app/src/ui/ribbon.rs | 437 +++------------------------ crates/app/src/ui/ribbon/buttons.rs | 238 +++++++++++++++ crates/app/src/ui/ribbon/layout.rs | 262 ++++++++++++++++ 7 files changed, 744 insertions(+), 550 deletions(-) create mode 100644 crates/app/src/ui/commands/roster.rs create mode 100644 crates/app/src/ui/ribbon/buttons.rs create mode 100644 crates/app/src/ui/ribbon/layout.rs diff --git a/crates/app/src/shot.rs b/crates/app/src/shot.rs index edba5c9..b548500 100644 --- a/crates/app/src/shot.rs +++ b/crates/app/src/shot.rs @@ -24,7 +24,7 @@ use plotx_core::settings::Settings; use plotx_core::state::{ AnalysisSelection, AxisRange, DEFAULT_CANVAS_SIZE_MM, Dataset, FrameRef, LineShapeKind, Nmr2DDataset, NmrDataset, Peak2DOrigin, Peak2DPoint, Peak2DReview, PlotxApp, Region, RegionId, - Tool, XpsDataset, region_color, + Tool, WorkflowTab, XpsDataset, region_color, }; use plotx_io::xps::{ XpsEnergyKind, XpsExperiment, XpsMeasurement, XpsMeasurementId, XpsRegion, XpsRegionId, @@ -91,6 +91,10 @@ enum Op { XpsSetup, CraftSetup, XpsTab(plotx_core::state::XpsWorkbenchTab), + /// Show a Ribbon task tab. Sets the state directly (like [`Op::XpsTab`]) + /// so the capture shows the tab's command row without the side effects a + /// real tab click has (opening task cards or sidebar tool groups). + RibbonTab(plotx_core::state::WorkflowTab), Zoom(f32), Resize(f32, f32), } @@ -143,6 +147,20 @@ const SCENES: &[Scene] = &[ shot(10, "ribbon_720"), act(2, Op::Resize(900.0, 760.0)), shot(12, "ribbon_900"), + // Every task tab at the in-between width, where density and overflow do + // the most work. The state carries a fitted 1D spectrum, so contextual + // groups representative of the core workflow are present. + act(2, Op::RibbonTab(WorkflowTab::Data)), + shot(6, "ribbon_data_900"), + act(2, Op::RibbonTab(WorkflowTab::Process)), + shot(6, "ribbon_process_900"), + act(2, Op::RibbonTab(WorkflowTab::Figure)), + shot(6, "ribbon_figure_900"), + act(2, Op::RibbonTab(WorkflowTab::Arrange)), + shot(6, "ribbon_arrange_900"), + act(2, Op::RibbonTab(WorkflowTab::View)), + shot(6, "ribbon_view_900"), + act(2, Op::RibbonTab(WorkflowTab::Analyze)), act(2, Op::Resize(1440.0, 900.0)), shot(12, "ribbon_1440"), act(2, Op::RegionResult), @@ -361,6 +379,7 @@ fn run_op(op: Op, app: &mut PlotxApp, ctx: &egui::Context) -> Result<(), String> Op::XpsSetup => xps_setup(app, ctx)?, Op::CraftSetup => craft_shot::setup(app, ctx)?, Op::XpsTab(tab) => app.session.ui.xps_workbench_tab = tab, + Op::RibbonTab(tab) => app.session.ui.ribbon_tab = tab, Op::Zoom(factor) => ctx.set_zoom_factor(factor), Op::Resize(w, h) => { ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(egui::vec2(w, h))); diff --git a/crates/app/src/typography.rs b/crates/app/src/typography.rs index cf4685a..b809e05 100644 --- a/crates/app/src/typography.rs +++ b/crates/app/src/typography.rs @@ -104,6 +104,10 @@ pub(crate) fn caption(text: impl Into) -> RichText { RichText::new(text).text_style(named(CAPTION_STYLE)) } +pub(crate) fn caption_font() -> FontId { + FontId::new(CAPTION_PT, FontFamily::Proportional) +} + #[cfg(test)] pub(crate) fn test_context() -> egui::Context { let ctx = egui::Context::default(); diff --git a/crates/app/src/ui/commands.rs b/crates/app/src/ui/commands.rs index 2bb1266..cafbef9 100644 --- a/crates/app/src/ui/commands.rs +++ b/crates/app/src/ui/commands.rs @@ -13,10 +13,12 @@ use identity::command_identity; pub(crate) use identity::recent_entry_label; mod helpers; pub(super) use helpers::chart_plot_target; -use helpers::{has_active_plot, requires, selected_paths_unlocked, tool_commands}; +use helpers::{has_active_plot, requires, selected_paths_unlocked}; mod ribbon; +mod roster; use ribbon::ribbon_placement; pub use ribbon::{Applicability, RibbonPlacement}; +use roster::command_ids; pub(crate) const MANUAL_URL: &str = "https://docs.plotx.nmrtist.space/"; /// The public source repository, linked from About. pub(crate) const REPOSITORY_URL: &str = "https://github.com/nmrtist/plotx"; @@ -155,156 +157,8 @@ pub struct CommandDescriptor { } pub fn catalog(app: &PlotxApp) -> Vec { - let mut ids = vec![ - CommandId::NewProject, - CommandId::OpenProject, - CommandId::CloseProject, - CommandId::OpenFile, - CommandId::OpenFolder, - CommandId::RunBatchWorkflow, - CommandId::RunScientificScript, - CommandId::ClearRecentFiles, - CommandId::HelpManual, - CommandId::ImportTable, - CommandId::ImportImage, - CommandId::ImportImageFirstFrame, - CommandId::ImportImageWithoutMetadata, - CommandId::ImportTiffPages, - CommandId::PasteImage, - CommandId::CancelImageImport, - CommandId::ReplaceImage, - CommandId::PasteTable, - CommandId::SaveProject, - CommandId::NewTable, - CommandId::ExportData, - CommandId::CopyFigure, - CommandId::Quit, - CommandId::Undo, - CommandId::Redo, - CommandId::SelectAll, - CommandId::DeselectAll, - CommandId::Group, - CommandId::Ungroup, - CommandId::CreatePanel, - CommandId::ComposePanel, - CommandId::DissolvePanel, - CommandId::DeletePanel, - CommandId::DuplicatePanel, - CommandId::MergePanels, - CommandId::SplitPanel, - CommandId::ReorderPanelLabels, - CommandId::SetPanelLayout(plotx_core::state::PanelLayout::Free), - CommandId::SetPanelLayout(plotx_core::state::PanelLayout::VerticalStack), - CommandId::SetPanelLayout(plotx_core::state::PanelLayout::HorizontalStack), - CommandId::SetPanelLayout(plotx_core::state::PanelLayout::Grid { rows: 2, cols: 2 }), - CommandId::TogglePrimarySidebar, - CommandId::ToggleSecondarySidebar, - CommandId::ZoomToFit, - CommandId::ZoomToSelection, - CommandId::FitPlotY, - CommandId::FitPlotXY, - CommandId::UiScaleUp, - CommandId::UiScaleDown, - CommandId::UiScaleReset, - CommandId::Present, - CommandId::ToggleGrid, - CommandId::ToggleSnap, - CommandId::Preferences, - CommandId::CommandPalette, - CommandId::CheckUpdates, - CommandId::OperationHistory, - CommandId::About, - CommandId::SaveProcessingTemplate, - CommandId::ApplyProcessingTemplate, - CommandId::Craft, - CommandId::RunCraft, - CommandId::CraftComponentTable, - CommandId::SpectrumArithmetic, - CommandId::AlignSpectra, - CommandId::AlignTraces, - CommandId::StackData, - CommandId::ExtractMassSpectrum, - CommandId::SelectRange, - CommandId::ClearRange, - CommandId::Regions, - CommandId::SeriesTable, - CommandId::DetectPeaks, - CommandId::PeakList, - CommandId::LineFit, - CommandId::RunPeakFit, - CommandId::CurveFit, - CommandId::RunCurveFit, - CommandId::Statistics, - CommandId::ChartType, - CommandId::FigureTypography, - CommandId::Integrate, - CommandId::Multiplets, - CommandId::TidyBoard, - CommandId::CanvasSettings, - CommandId::SimplifyInnerAxes, - ]; - ids.extend((0..app.session.recent_files.len()).map(CommandId::OpenRecent)); - ids.extend( - plotx_core::templates::CanvasTemplate::all() - .iter() - .enumerate() - .map(|(i, _)| CommandId::NewCanvas(i)), - ); - ids.extend([SpacingMode::Frame, SpacingMode::Visual].map(CommandId::SetSpacingMode)); - ids.extend(GutterPreset::ALL.map(CommandId::SetGutterPreset)); - ids.extend( - [ - ExportFormat::Svg, - ExportFormat::Pdf, - ExportFormat::Png, - ExportFormat::Jpeg, - ExportFormat::Tiff, - ] + command_ids(app.session.recent_files.len()) .into_iter() - .map(CommandId::Export), - ); - ids.extend( - plotx_core::state::size_presets() - .iter() - .map(|preset| CommandId::SetCanvasSizePreset(preset.id)), - ); - ids.extend( - plotx_core::layout::GRID_PRESETS - .iter() - .map(|&(_, rows, cols)| CommandId::ArrangeGrid(rows, cols)), - ); - ids.extend([ - CommandId::Align(Align::Left), - CommandId::Align(Align::HCenter), - CommandId::Align(Align::Right), - CommandId::Align(Align::Top), - CommandId::Align(Align::VCenter), - CommandId::Align(Align::Bottom), - CommandId::Distribute(Distribute::Horizontal), - CommandId::Distribute(Distribute::Vertical), - CommandId::ZOrder(ZOrder::Front), - CommandId::ZOrder(ZOrder::Forward), - CommandId::ZOrder(ZOrder::Backward), - CommandId::ZOrder(ZOrder::Back), - ]); - ids.extend( - plotx_core::theme::Theme::all() - .into_iter() - .map(|theme| CommandId::ApplyTheme(theme.id)), - ); - // Every declared property group, and the step gesture. Both are derived - // from the property catalog: a group declared once appears here, and a - // property that declares itself steppable is driven by the existing - // binding without any new command. - ids.extend( - super::properties::GROUPS - .iter() - .map(|group| CommandId::PropertyGroup(group.section)), - ); - ids.extend([PropertyStep::Lower, PropertyStep::Raise].map(CommandId::StepProperty)); - ids.push(CommandId::CycleCursor); - ids.extend(tool_commands().into_iter().map(CommandId::Tool)); - ids.into_iter() .map(|id| { debug_assert!(!id.stable_id().is_empty()); describe(app, id) @@ -312,6 +166,20 @@ pub fn catalog(app: &PlotxApp) -> Vec { .collect() } +/// Every (tab, group) pair the Ribbon placement tables can produce, so layout +/// tests stay exhaustive as placements evolve. +#[cfg(test)] +pub(super) fn ribbon_group_pairs() -> Vec<(plotx_core::state::WorkflowTab, &'static str)> { + let mut pairs: Vec<_> = command_ids(0) + .into_iter() + .filter_map(ribbon_placement) + .map(|placement| (placement.tab, placement.group)) + .collect(); + pairs.sort_by_key(|&(tab, group)| (tab as u8, group)); + pairs.dedup(); + pairs +} + pub fn describe(app: &PlotxApp, id: CommandId) -> CommandDescriptor { let has_canvas = app.session.active_canvas.is_some(); let selected = app.session.ui.selection.objects().len(); diff --git a/crates/app/src/ui/commands/roster.rs b/crates/app/src/ui/commands/roster.rs new file mode 100644 index 0000000..b04e487 --- /dev/null +++ b/crates/app/src/ui/commands/roster.rs @@ -0,0 +1,164 @@ +//! The command roster: every `CommandId` the catalog describes, in one +//! place, so `catalog()` and the exhaustive placement tests iterate the same +//! list. + +use plotx_core::export::ExportFormat; +use plotx_core::layout::{Align, Distribute, GutterPreset, SpacingMode}; +use plotx_core::properties::PropertyStep; + +use super::helpers::tool_commands; +use super::{CommandId, ZOrder}; + +/// The full command roster; only the recent-files arm depends on app state. +pub(super) fn command_ids(recent_files: usize) -> Vec { + let mut ids = vec![ + CommandId::NewProject, + CommandId::OpenProject, + CommandId::CloseProject, + CommandId::OpenFile, + CommandId::OpenFolder, + CommandId::RunBatchWorkflow, + CommandId::RunScientificScript, + CommandId::ClearRecentFiles, + CommandId::HelpManual, + CommandId::ImportTable, + CommandId::ImportImage, + CommandId::ImportImageFirstFrame, + CommandId::ImportImageWithoutMetadata, + CommandId::ImportTiffPages, + CommandId::PasteImage, + CommandId::CancelImageImport, + CommandId::ReplaceImage, + CommandId::PasteTable, + CommandId::SaveProject, + CommandId::NewTable, + CommandId::ExportData, + CommandId::CopyFigure, + CommandId::Quit, + CommandId::Undo, + CommandId::Redo, + CommandId::SelectAll, + CommandId::DeselectAll, + CommandId::Group, + CommandId::Ungroup, + CommandId::CreatePanel, + CommandId::ComposePanel, + CommandId::DissolvePanel, + CommandId::DeletePanel, + CommandId::DuplicatePanel, + CommandId::MergePanels, + CommandId::SplitPanel, + CommandId::ReorderPanelLabels, + CommandId::SetPanelLayout(plotx_core::state::PanelLayout::Free), + CommandId::SetPanelLayout(plotx_core::state::PanelLayout::VerticalStack), + CommandId::SetPanelLayout(plotx_core::state::PanelLayout::HorizontalStack), + CommandId::SetPanelLayout(plotx_core::state::PanelLayout::Grid { rows: 2, cols: 2 }), + CommandId::TogglePrimarySidebar, + CommandId::ToggleSecondarySidebar, + CommandId::ZoomToFit, + CommandId::ZoomToSelection, + CommandId::FitPlotY, + CommandId::FitPlotXY, + CommandId::UiScaleUp, + CommandId::UiScaleDown, + CommandId::UiScaleReset, + CommandId::Present, + CommandId::ToggleGrid, + CommandId::ToggleSnap, + CommandId::Preferences, + CommandId::CommandPalette, + CommandId::CheckUpdates, + CommandId::OperationHistory, + CommandId::About, + CommandId::SaveProcessingTemplate, + CommandId::ApplyProcessingTemplate, + CommandId::Craft, + CommandId::RunCraft, + CommandId::CraftComponentTable, + CommandId::SpectrumArithmetic, + CommandId::AlignSpectra, + CommandId::AlignTraces, + CommandId::StackData, + CommandId::ExtractMassSpectrum, + CommandId::SelectRange, + CommandId::ClearRange, + CommandId::Regions, + CommandId::SeriesTable, + CommandId::DetectPeaks, + CommandId::PeakList, + CommandId::LineFit, + CommandId::RunPeakFit, + CommandId::CurveFit, + CommandId::RunCurveFit, + CommandId::Statistics, + CommandId::ChartType, + CommandId::FigureTypography, + CommandId::Integrate, + CommandId::Multiplets, + CommandId::TidyBoard, + CommandId::CanvasSettings, + CommandId::SimplifyInnerAxes, + ]; + ids.extend((0..recent_files).map(CommandId::OpenRecent)); + ids.extend( + plotx_core::templates::CanvasTemplate::all() + .iter() + .enumerate() + .map(|(i, _)| CommandId::NewCanvas(i)), + ); + ids.extend([SpacingMode::Frame, SpacingMode::Visual].map(CommandId::SetSpacingMode)); + ids.extend(GutterPreset::ALL.map(CommandId::SetGutterPreset)); + ids.extend( + [ + ExportFormat::Svg, + ExportFormat::Pdf, + ExportFormat::Png, + ExportFormat::Jpeg, + ExportFormat::Tiff, + ] + .into_iter() + .map(CommandId::Export), + ); + ids.extend( + plotx_core::state::size_presets() + .iter() + .map(|preset| CommandId::SetCanvasSizePreset(preset.id)), + ); + ids.extend( + plotx_core::layout::GRID_PRESETS + .iter() + .map(|&(_, rows, cols)| CommandId::ArrangeGrid(rows, cols)), + ); + ids.extend([ + CommandId::Align(Align::Left), + CommandId::Align(Align::HCenter), + CommandId::Align(Align::Right), + CommandId::Align(Align::Top), + CommandId::Align(Align::VCenter), + CommandId::Align(Align::Bottom), + CommandId::Distribute(Distribute::Horizontal), + CommandId::Distribute(Distribute::Vertical), + CommandId::ZOrder(ZOrder::Front), + CommandId::ZOrder(ZOrder::Forward), + CommandId::ZOrder(ZOrder::Backward), + CommandId::ZOrder(ZOrder::Back), + ]); + ids.extend( + plotx_core::theme::Theme::all() + .into_iter() + .map(|theme| CommandId::ApplyTheme(theme.id)), + ); + // Every declared property group, and the step gesture. Both are derived + // from the property catalog: a group declared once appears here, and a + // property that declares itself steppable is driven by the existing + // binding without any new command. + ids.extend( + crate::ui::properties::GROUPS + .iter() + .map(|group| CommandId::PropertyGroup(group.section)), + ); + ids.extend([PropertyStep::Lower, PropertyStep::Raise].map(CommandId::StepProperty)); + ids.push(CommandId::CycleCursor); + ids.extend(tool_commands().into_iter().map(CommandId::Tool)); + ids +} diff --git a/crates/app/src/ui/ribbon.rs b/crates/app/src/ui/ribbon.rs index 782a012..b972b74 100644 --- a/crates/app/src/ui/ribbon.rs +++ b/crates/app/src/ui/ribbon.rs @@ -2,24 +2,21 @@ //! to PlotX's existing light egui chrome; the task/group hierarchy is the only //! idea borrowed from the supplied Office reference. -use egui::text::LayoutJob; +mod buttons; +mod layout; + use egui::{ - Align, Align2, Button, Color32, FontId, Label, Layout, PointerButton, RichText, Sense, - TextFormat, TextWrapMode, Ui, UiBuilder, Vec2, vec2, + Align, Button, Label, Layout, PointerButton, RichText, Sense, TextWrapMode, Ui, UiBuilder, + Vec2, vec2, }; use egui_phosphor::regular as icon; -use plotx_core::actions::ZOrder; -use plotx_core::export::ExportFormat; -use plotx_core::state::{PlotxApp, Tool, ToolGroup, WorkflowTab}; +use plotx_core::state::{PlotxApp, ToolGroup, WorkflowTab}; use super::clipboard_table::ClipboardTablePaste; use super::commands::{self, CommandDescriptor, CommandId}; +use buttons::{overflow_item, ribbon_button}; +use layout::{ROW_HEIGHT, TILE_HEIGHT}; -const AUTO_COLLAPSE_WIDTH: f32 = 760.0; -/// One shared tile height (Full density) and row height (Compact) keeps every -/// command in a group visually equal-sized. -const TILE_HEIGHT: f32 = 46.0; -const ROW_HEIGHT: f32 = 26.0; /// The native metric includes a little more bottom breathing room than the /// tab highlight needs visually; trim it so the highlight has equal margins. const MACOS_TITLE_ROW_BOTTOM_TRIM: f32 = 2.0; @@ -43,9 +40,10 @@ pub(crate) fn render( // Measured before `task_row`, so a tab click adopts the new tab's density // one frame later — invisible in practice. let density = { + let measure = layout::text_measure(ui.ctx().clone()); let catalog = commands::catalog(app); - let groups = groups_for_tab(&catalog, app.session.ui.ribbon_tab); - density(width, app.session.ui.ribbon_expanded, &groups) + let groups = layout::groups_for_tab(&catalog, app.session.ui.ribbon_tab); + layout::density(width, app.session.ui.ribbon_expanded, &groups, &measure) }; task_row(app, clipboard, ui, density, chrome); if density != RibbonDensity::Collapsed { @@ -275,28 +273,24 @@ fn command_row( ) { let tab = app.session.ui.ribbon_tab; let catalog = commands::catalog(app); - let groups = groups_for_tab(&catalog, tab); + let groups = layout::groups_for_tab(&catalog, tab); if density == RibbonDensity::Collapsed { return; } - let mut ranked: Vec = groups.iter().enumerate().map(|(index, _)| index).collect(); - ranked.sort_by_key(|&index| groups[index].1); - let required = required_width(&groups, density); + let measure = layout::text_measure(ui.ctx().clone()); + let widths: Vec = groups + .iter() + .map(|(title, _, entries)| layout::group_width(title, entries, density, &measure) + 8.0) + .collect(); + let priorities: Vec = groups.iter().map(|(_, priority, _)| *priority).collect(); + let required: f32 = widths.iter().sum(); let available = ui.available_width(); let budget = if required <= available { available } else { - (available - 86.0).max(0.0) + (available - more_button_width(ui, &measure)).max(0.0) }; - let mut used = 0.0; - let mut shown = vec![false; groups.len()]; - for index in ranked { - let width = group_width(groups[index].0, &groups[index].2, density) + 8.0; - if used + width <= budget { - shown[index] = true; - used += width; - } - } + let shown = layout::shown_groups(&priorities, &widths, budget); let (visible, hidden): (Vec<_>, Vec<_>) = groups .into_iter() .enumerate() @@ -314,11 +308,11 @@ fn command_row( 3.0 }; for (_, (group, _, commands)) in visible { - ribbon_group(app, clipboard, ui, group, commands, density); + ribbon_group(app, clipboard, ui, group, commands, density, &measure); ui.separator(); } if !hidden.is_empty() { - ui.menu_button(format!("{} More", icon::DOTS_THREE), |ui| { + ui.menu_button(more_label(), |ui| { for (_, (group, _, entries)) in hidden { ui.label(crate::typography::headline(group)); for command in entries { @@ -333,6 +327,17 @@ fn command_row( }); } +fn more_label() -> String { + format!("{} More", icon::DOTS_THREE) +} + +/// Measured reservation for the More overflow button, so the budget tracks the +/// live fonts instead of a fixed guess. +fn more_button_width(ui: &Ui, measure: layout::Measure) -> f32 { + let font = egui::TextStyle::Button.resolve(ui.style()); + measure(&more_label(), font) + ui.spacing().button_padding.x * 2.0 + ui.spacing().item_spacing.x +} + fn ribbon_group( app: &mut PlotxApp, clipboard: &mut ClipboardTablePaste, @@ -340,9 +345,10 @@ fn ribbon_group( title: &str, entries: Vec<&CommandDescriptor>, density: RibbonDensity, + measure: layout::Measure, ) { - let width = group_width(title, &entries, density); - let tile = tile_width(&entries); + let width = layout::group_width(title, &entries, density, measure); + let tile = layout::tile_width(&entries, measure); ui.allocate_ui_with_layout( Vec2::new( width, @@ -361,7 +367,7 @@ fn ribbon_group( 2.0 }; for command in entries { - ribbon_button(app, clipboard, ui, command, density, tile); + ribbon_button(app, clipboard, ui, command, density, tile, measure); } }); ui.add_space(1.0); @@ -373,317 +379,6 @@ fn ribbon_group( ); } -/// Width the whole tab needs at `density`: every group plus its separator. -/// The same estimate drives the density choice and the overflow budget, so a -/// tab shown Full is guaranteed to fit without a More menu. -fn required_width( - groups: &[(&'static str, u8, Vec<&CommandDescriptor>)], - density: RibbonDensity, -) -> f32 { - groups - .iter() - .map(|(title, _, entries)| group_width(title, entries, density) + 8.0) - .sum() -} - -fn group_width(title: &str, entries: &[&CommandDescriptor], density: RibbonDensity) -> f32 { - let spacing = 4.0 * entries.len().saturating_sub(1) as f32; - let commands = if density == RibbonDensity::Full { - tile_width(entries) * entries.len() as f32 + spacing - } else { - entries - .iter() - .map(|command| button_width(command)) - .sum::() - + spacing - }; - commands.max(title.chars().count() as f32 * 5.8 + 8.0) -} - -/// All tiles in a group share the width of the widest short label, so a group -/// reads as one row of even targets instead of a ragged strip. -fn tile_width(entries: &[&CommandDescriptor]) -> f32 { - entries - .iter() - .map(|command| short_label(command).chars().count() as f32 * 5.8 + 18.0) - .fold(58.0, f32::max) - .min(112.0) -} - -fn button_width(command: &CommandDescriptor) -> f32 { - if command.icon.is_some() { - ROW_HEIGHT - } else { - (short_label(command).chars().count() as f32 * 6.2 + 16.0).clamp(40.0, 140.0) - } -} - -/// Ribbon buttons carry short verb labels; the full command name and shortcut -/// stay in the tooltip, menus and the command palette. -fn short_label(command: &CommandDescriptor) -> String { - match command.id { - CommandId::NewCanvas(index) => match index { - 0 => "Slides", - 1 => "1 Column", - 2 => "2 Columns", - 3 => "Poster", - _ => "Canvas", - } - .to_owned(), - CommandId::ChartType => "Chart".to_owned(), - CommandId::ApplyTheme(id) => match id { - "publication" => "Paper", - "presentation_dark" => "Dark", - "vibrant" => "Vibrant", - _ => "Theme", - } - .to_owned(), - CommandId::CopyFigure => "Copy".to_owned(), - CommandId::Export(format) => match format { - ExportFormat::Png => "PNG", - ExportFormat::Svg => "SVG", - _ => format.label(), - } - .to_owned(), - CommandId::ImportTable => "Import Table".to_owned(), - CommandId::ImportImage => "Add Images".to_owned(), - CommandId::ImportImageFirstFrame => "First Frame".to_owned(), - CommandId::PasteTable => "Paste Table".to_owned(), - CommandId::NewTable => "New Table".to_owned(), - CommandId::StackData => "Stack Data".to_owned(), - CommandId::SaveProcessingTemplate => "Save Template".to_owned(), - CommandId::ApplyProcessingTemplate => "Apply Template".to_owned(), - CommandId::SpectrumArithmetic => "Arithmetic".to_owned(), - CommandId::AlignSpectra => "Align Spectra".to_owned(), - CommandId::TidyBoard => "Tidy Frames".to_owned(), - CommandId::ToggleSnap => "Snapping".to_owned(), - CommandId::TogglePrimarySidebar => "Left Bar".to_owned(), - CommandId::ToggleSecondarySidebar => "Right Bar".to_owned(), - CommandId::ArrangeGrid(rows, cols) => format!("Plots {rows} × {cols}"), - CommandId::ZOrder(mode) => match mode { - ZOrder::Front => "To Front", - ZOrder::Forward => "Forward", - ZOrder::Backward => "Backward", - ZOrder::Back => "To Back", - } - .to_owned(), - CommandId::Align(_) => command.label.trim_start_matches("Align ").to_owned(), - CommandId::Distribute(_) => command.label.trim_start_matches("Distribute ").to_owned(), - // A Ribbon tile shows the group's own short name; the full "… settings" - // wording stays in the tooltip, the menus and the palette. - CommandId::PropertyGroup(section) => super::properties::discovery::group(section) - .map(|group| group.label.get().to_owned()) - .unwrap_or_else(|| "Settings".to_owned()), - CommandId::Tool(Tool::BrowseZoom) => "Zoom".to_owned(), - CommandId::Tool(_) => command.label.trim_start_matches("Tool: ").to_owned(), - _ => command.label.clone(), - } -} - -fn ribbon_button( - app: &mut PlotxApp, - clipboard: &mut ClipboardTablePaste, - ui: &mut Ui, - command: &CommandDescriptor, - density: RibbonDensity, - tile: f32, -) { - let label = short_label(command); - // Icons carry the accent colour; label text keeps the theme colour via the - // placeholder, which also inherits the correct disabled/selected colours. - let icon_color = if command.enabled && command.checked != Some(true) { - ui.visuals().hyperlink_color - } else { - Color32::PLACEHOLDER - }; - let mut job = LayoutJob::default(); - let response = if density == RibbonDensity::Full { - let icon_font = FontId::proportional(16.0); - let label_font = crate::typography::subheadline_font(); - let selected = command.checked == Some(true); - // Keep the command name in the button for accessibility, but paint the - // two visible rows ourselves so both share the tile's exact centre. - // LayoutJob's per-row offsets otherwise make differently sized glyphs - // appear alternately left- and right-aligned. - let button = Button::selectable( - selected, - RichText::new(&label).size(1.0).color(Color32::TRANSPARENT), - ) - .min_size(Vec2::new(tile, TILE_HEIGHT)); - let response = ui.add_enabled(command.enabled, button); - let text_color = ui - .style() - .button_style(response.widget_state(), selected) - .text_style - .color; - let center = response.rect.center(); - if let Some(icon) = command.icon { - ui.painter().text( - center - Vec2::new(0.0, 7.5), - Align2::CENTER_CENTER, - icon, - icon_font, - if command.enabled && !selected { - icon_color - } else { - text_color - }, - ); - ui.painter().text( - center + Vec2::new(0.0, 9.0), - Align2::CENTER_CENTER, - &label, - label_font, - text_color, - ); - } else { - ui.painter().text( - center, - Align2::CENTER_CENTER, - &label, - label_font, - text_color, - ); - } - response - } else { - if let Some(icon) = command.icon { - job.append( - icon, - 0.0, - TextFormat { - font_id: FontId::proportional(14.0), - color: icon_color, - ..Default::default() - }, - ); - } else { - job.append( - &label, - 0.0, - TextFormat { - font_id: crate::typography::callout_font(), - color: Color32::PLACEHOLDER, - ..Default::default() - }, - ); - } - let button = Button::selectable(command.checked == Some(true), job) - .min_size(Vec2::new(button_width(command), ROW_HEIGHT)); - ui.add_enabled(command.enabled, button) - }; - let tip = match &command.shortcut { - Some(shortcut) => format!("{} ({shortcut})", command.label), - None => command.label.clone(), - }; - let clicked = response.clicked(); - if command.enabled { - response.on_hover_text(tip); - } else { - let reason = command - .disabled_reason - .unwrap_or("Unavailable in the current context."); - response.on_disabled_hover_text(format!("{tip} · {reason}")); - } - if clicked { - commands::execute(command.id, app, clipboard, ui.ctx()); - } -} - -fn overflow_item( - app: &mut PlotxApp, - clipboard: &mut ClipboardTablePaste, - ui: &mut Ui, - id: CommandId, -) { - let command = commands::describe(app, id); - let mut button = Button::new(&command.label).selected(command.checked == Some(true)); - if let Some(shortcut) = &command.shortcut { - button = button.shortcut_text(shortcut); - } - let response = ui.add_enabled(command.enabled, button); - let clicked = response.clicked(); - if !command.enabled - && let Some(reason) = command.disabled_reason - { - response.on_disabled_hover_text(reason); - } - if clicked { - commands::execute(id, app, clipboard, ui.ctx()); - ui.close(); - } -} - -fn groups_for_tab( - catalog: &[CommandDescriptor], - tab: WorkflowTab, -) -> Vec<(&'static str, u8, Vec<&CommandDescriptor>)> { - let mut groups: Vec<(&'static str, u8, Vec<&CommandDescriptor>)> = Vec::new(); - for command in catalog { - let Some(placement) = command.ribbon.filter(|placement| placement.tab == tab) else { - continue; - }; - if let Some((_, priority, entries)) = groups - .iter_mut() - .find(|(group, _, _)| *group == placement.group) - { - *priority = (*priority).min(placement.priority); - entries.push(command); - } else { - groups.push((placement.group, placement.priority, vec![command])); - } - } - groups.sort_by_key(|(group, _, _)| group_order(tab, group)); - groups -} - -fn group_order(tab: WorkflowTab, group: &str) -> u8 { - match (tab, group) { - (WorkflowTab::View, "Navigate") - | (WorkflowTab::Data, "Import") - | (WorkflowTab::Process, "Correct") - | (WorkflowTab::Analyze, "Range") - | (WorkflowTab::Figure, "Create") - | (WorkflowTab::Arrange, "Layout") => 0, - (WorkflowTab::Analyze, "Regions") => 1, - (WorkflowTab::View, "Display") - | (WorkflowTab::Data, "Build") - | (WorkflowTab::Process, "Transform") - | (WorkflowTab::Figure, "Chart") - | (WorkflowTab::Arrange, "Align") => 1, - (WorkflowTab::Figure, "Style") => 2, - (WorkflowTab::Figure, "Output") => 3, - (WorkflowTab::Analyze, "Peaks") => 2, - (WorkflowTab::Data, "Inspect") - | (WorkflowTab::Process, "Recipes") - | (WorkflowTab::Arrange, "Distribute") => 2, - (WorkflowTab::Analyze, "Peak Fit") | (WorkflowTab::Arrange, "Order") => 3, - (WorkflowTab::Analyze, "Curve Fit") => 4, - (WorkflowTab::Analyze, "Interpret") => 5, - (WorkflowTab::Arrange, "Guides") => 4, - (WorkflowTab::Arrange, "Annotate") => 5, - _ => u8::MAX, - } -} - -/// The richest density whose content actually fits `width`: full icon-and-text -/// tiles whenever the active tab's groups all fit, otherwise the compact icon -/// row (whose own overflow moves whole groups into More). Below the absolute -/// floor even icon rows crowd, so the command area collapses to menus. -fn density( - width: f32, - expanded: bool, - groups: &[(&'static str, u8, Vec<&CommandDescriptor>)], -) -> RibbonDensity { - if !expanded || width < AUTO_COLLAPSE_WIDTH { - RibbonDensity::Collapsed - } else if required_width(groups, RibbonDensity::Full) <= width { - RibbonDensity::Full - } else { - RibbonDensity::Compact - } -} - fn update_button(app: &mut PlotxApp, ui: &mut Ui, compact: bool) { use plotx_core::update::UpdateStatus; match app.session.updates.status().clone() { @@ -717,59 +412,3 @@ fn update_button(app: &mut PlotxApp, ui: &mut Ui, compact: bool) { _ => {} } } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn density_follows_the_active_tabs_measured_content() { - let app = PlotxApp::new_with_settings(plotx_core::settings::Settings::default()); - let catalog = commands::catalog(&app); - let groups = groups_for_tab(&catalog, WorkflowTab::View); - let full_need = required_width(&groups, RibbonDensity::Full); - assert!( - full_need > AUTO_COLLAPSE_WIDTH, - "test premise: the View tab's full-density content ({full_need}) must exceed the collapse floor" - ); - - // Full the moment the tab's content fits — no fixed window breakpoint. - assert_eq!(density(full_need + 1.0, true, &groups), RibbonDensity::Full); - assert_eq!( - density(full_need - 1.0, true, &groups), - RibbonDensity::Compact - ); - assert_eq!(density(700.0, true, &groups), RibbonDensity::Collapsed); - assert_eq!( - density(full_need + 1.0, false, &groups), - RibbonDensity::Collapsed - ); - } - - #[test] - fn compact_groups_reserve_width_for_single_line_titles() { - assert!(group_width("Guides", &[], RibbonDensity::Compact) > ROW_HEIGHT); - assert!(group_width("Object", &[], RibbonDensity::Compact) > ROW_HEIGHT); - } - - #[test] - fn figure_tiles_use_short_labels() { - let app = PlotxApp::new_with_settings(plotx_core::settings::Settings::default()); - let cases = [ - (CommandId::NewCanvas(0), "Slides"), - (CommandId::NewCanvas(1), "1 Column"), - (CommandId::NewCanvas(2), "2 Columns"), - (CommandId::NewCanvas(3), "Poster"), - (CommandId::ChartType, "Chart"), - (CommandId::ApplyTheme("publication"), "Paper"), - (CommandId::ApplyTheme("presentation_dark"), "Dark"), - (CommandId::ApplyTheme("vibrant"), "Vibrant"), - (CommandId::CopyFigure, "Copy"), - (CommandId::Export(ExportFormat::Png), "PNG"), - (CommandId::Export(ExportFormat::Svg), "SVG"), - ]; - for (id, expected) in cases { - assert_eq!(short_label(&commands::describe(&app, id)), expected); - } - } -} diff --git a/crates/app/src/ui/ribbon/buttons.rs b/crates/app/src/ui/ribbon/buttons.rs new file mode 100644 index 0000000..08cb093 --- /dev/null +++ b/crates/app/src/ui/ribbon/buttons.rs @@ -0,0 +1,238 @@ +//! Individual Ribbon command widgets: the density-dependent buttons, their +//! short labels, and the overflow-menu rows. + +use egui::text::LayoutJob; +use egui::{Align2, Button, Color32, FontId, RichText, TextFormat, Ui, Vec2}; +use plotx_core::actions::ZOrder; +use plotx_core::export::ExportFormat; +use plotx_core::state::{PlotxApp, Tool}; + +use super::super::clipboard_table::ClipboardTablePaste; +use super::super::commands::{self, CommandDescriptor, CommandId}; +use super::RibbonDensity; +use super::layout::{Measure, ROW_HEIGHT, TILE_HEIGHT, button_width}; + +/// Ribbon buttons carry short verb labels; the full command name and shortcut +/// stay in the tooltip, menus and the command palette. +pub(super) fn short_label(command: &CommandDescriptor) -> String { + match command.id { + CommandId::NewCanvas(index) => match index { + 0 => "Slides", + 1 => "1 Column", + 2 => "2 Columns", + 3 => "Poster", + _ => "Canvas", + } + .to_owned(), + CommandId::ChartType => "Chart".to_owned(), + CommandId::ApplyTheme(id) => match id { + "publication" => "Paper", + "presentation_dark" => "Dark", + "vibrant" => "Vibrant", + _ => "Theme", + } + .to_owned(), + CommandId::CopyFigure => "Copy".to_owned(), + CommandId::Export(format) => match format { + ExportFormat::Png => "PNG", + ExportFormat::Svg => "SVG", + _ => format.label(), + } + .to_owned(), + CommandId::ImportTable => "Import Table".to_owned(), + CommandId::ImportImage => "Add Images".to_owned(), + CommandId::ImportImageFirstFrame => "First Frame".to_owned(), + CommandId::PasteTable => "Paste Table".to_owned(), + CommandId::NewTable => "New Table".to_owned(), + CommandId::StackData => "Stack Data".to_owned(), + CommandId::SaveProcessingTemplate => "Save Template".to_owned(), + CommandId::ApplyProcessingTemplate => "Apply Template".to_owned(), + CommandId::SpectrumArithmetic => "Arithmetic".to_owned(), + CommandId::AlignSpectra => "Align Spectra".to_owned(), + CommandId::TidyBoard => "Tidy Frames".to_owned(), + CommandId::ToggleSnap => "Snapping".to_owned(), + CommandId::TogglePrimarySidebar => "Left Bar".to_owned(), + CommandId::ToggleSecondarySidebar => "Right Bar".to_owned(), + CommandId::ArrangeGrid(rows, cols) => format!("Plots {rows} × {cols}"), + CommandId::ZOrder(mode) => match mode { + ZOrder::Front => "To Front", + ZOrder::Forward => "Forward", + ZOrder::Backward => "Backward", + ZOrder::Back => "To Back", + } + .to_owned(), + CommandId::Align(_) => command.label.trim_start_matches("Align ").to_owned(), + CommandId::Distribute(_) => command.label.trim_start_matches("Distribute ").to_owned(), + // A Ribbon tile shows the group's own short name; the full "… settings" + // wording stays in the tooltip, the menus and the palette. + CommandId::PropertyGroup(section) => super::super::properties::discovery::group(section) + .map(|group| group.label.get().to_owned()) + .unwrap_or_else(|| "Settings".to_owned()), + CommandId::Tool(Tool::BrowseZoom) => "Zoom".to_owned(), + CommandId::Tool(_) => command.label.trim_start_matches("Tool: ").to_owned(), + _ => command.label.clone(), + } +} + +pub(super) fn ribbon_button( + app: &mut PlotxApp, + clipboard: &mut ClipboardTablePaste, + ui: &mut Ui, + command: &CommandDescriptor, + density: RibbonDensity, + tile: f32, + measure: Measure, +) { + let label = short_label(command); + // Icons carry the accent colour; label text keeps the theme colour via the + // placeholder, which also inherits the correct disabled/selected colours. + let icon_color = if command.enabled && command.checked != Some(true) { + ui.visuals().hyperlink_color + } else { + Color32::PLACEHOLDER + }; + let mut job = LayoutJob::default(); + let response = if density == RibbonDensity::Full { + let icon_font = FontId::proportional(16.0); + let label_font = crate::typography::subheadline_font(); + let selected = command.checked == Some(true); + // Keep the command name in the button for accessibility, but paint the + // two visible rows ourselves so both share the tile's exact centre. + // LayoutJob's per-row offsets otherwise make differently sized glyphs + // appear alternately left- and right-aligned. + let button = Button::selectable( + selected, + RichText::new(&label).size(1.0).color(Color32::TRANSPARENT), + ) + .min_size(Vec2::new(tile, TILE_HEIGHT)); + let response = ui.add_enabled(command.enabled, button); + let text_color = ui + .style() + .button_style(response.widget_state(), selected) + .text_style + .color; + let center = response.rect.center(); + if let Some(icon) = command.icon { + ui.painter().text( + center - Vec2::new(0.0, 7.5), + Align2::CENTER_CENTER, + icon, + icon_font, + if command.enabled && !selected { + icon_color + } else { + text_color + }, + ); + ui.painter().text( + center + Vec2::new(0.0, 9.0), + Align2::CENTER_CENTER, + &label, + label_font, + text_color, + ); + } else { + ui.painter().text( + center, + Align2::CENTER_CENTER, + &label, + label_font, + text_color, + ); + } + response + } else { + if let Some(icon) = command.icon { + job.append( + icon, + 0.0, + TextFormat { + font_id: FontId::proportional(14.0), + color: icon_color, + ..Default::default() + }, + ); + } else { + job.append( + &label, + 0.0, + TextFormat { + font_id: crate::typography::callout_font(), + color: Color32::PLACEHOLDER, + ..Default::default() + }, + ); + } + let button = Button::selectable(command.checked == Some(true), job) + .min_size(Vec2::new(button_width(command, measure), ROW_HEIGHT)); + ui.add_enabled(command.enabled, button) + }; + let tip = match &command.shortcut { + Some(shortcut) => format!("{} ({shortcut})", command.label), + None => command.label.clone(), + }; + let clicked = response.clicked(); + if command.enabled { + response.on_hover_text(tip); + } else { + let reason = command + .disabled_reason + .unwrap_or("Unavailable in the current context."); + response.on_disabled_hover_text(format!("{tip} · {reason}")); + } + if clicked { + commands::execute(command.id, app, clipboard, ui.ctx()); + } +} + +pub(super) fn overflow_item( + app: &mut PlotxApp, + clipboard: &mut ClipboardTablePaste, + ui: &mut Ui, + id: CommandId, +) { + let command = commands::describe(app, id); + let mut button = Button::new(&command.label).selected(command.checked == Some(true)); + if let Some(shortcut) = &command.shortcut { + button = button.shortcut_text(shortcut); + } + let response = ui.add_enabled(command.enabled, button); + let clicked = response.clicked(); + if !command.enabled + && let Some(reason) = command.disabled_reason + { + response.on_disabled_hover_text(reason); + } + if clicked { + commands::execute(id, app, clipboard, ui.ctx()); + ui.close(); + } +} + +#[cfg(test)] +mod tests { + use super::super::super::commands; + use super::*; + use plotx_core::state::PlotxApp; + + #[test] + fn figure_tiles_use_short_labels() { + let app = PlotxApp::new_with_settings(plotx_core::settings::Settings::default()); + let cases = [ + (CommandId::NewCanvas(0), "Slides"), + (CommandId::NewCanvas(1), "1 Column"), + (CommandId::NewCanvas(2), "2 Columns"), + (CommandId::NewCanvas(3), "Poster"), + (CommandId::ChartType, "Chart"), + (CommandId::ApplyTheme("publication"), "Paper"), + (CommandId::ApplyTheme("presentation_dark"), "Dark"), + (CommandId::ApplyTheme("vibrant"), "Vibrant"), + (CommandId::CopyFigure, "Copy"), + (CommandId::Export(ExportFormat::Png), "PNG"), + (CommandId::Export(ExportFormat::Svg), "SVG"), + ]; + for (id, expected) in cases { + assert_eq!(short_label(&commands::describe(&app, id)), expected); + } + } +} diff --git a/crates/app/src/ui/ribbon/layout.rs b/crates/app/src/ui/ribbon/layout.rs new file mode 100644 index 0000000..2a8ec10 --- /dev/null +++ b/crates/app/src/ui/ribbon/layout.rs @@ -0,0 +1,262 @@ +//! Width mathematics for the Ribbon: density selection, group measurement, +//! ordering, and the overflow partition. All text is sized through an injected +//! [`Measure`] so the same arithmetic runs under the live font system and in +//! headless tests. + +use egui::FontId; +use plotx_core::state::WorkflowTab; + +use super::super::commands::CommandDescriptor; +use super::RibbonDensity; +use super::buttons::short_label; + +pub(super) const AUTO_COLLAPSE_WIDTH: f32 = 760.0; +/// One shared tile height (Full density) and row height (Compact) keeps every +/// command in a group visually equal-sized. +pub(super) const TILE_HEIGHT: f32 = 46.0; +pub(super) const ROW_HEIGHT: f32 = 26.0; + +/// Returns the width of `text` in `font`. Layout decisions and painting must +/// agree on glyph widths (a per-character estimate drifts on CJK and long +/// labels), so production injects the live font system via [`text_measure`] +/// and tests inject a deterministic stand-in. +pub(super) type Measure<'a> = &'a dyn Fn(&str, FontId) -> f32; + +/// The production [`Measure`]: galley layout through the context's fonts. +/// Owning a context clone keeps the closure free of `Ui` borrows, so callers +/// can keep mutating the `Ui` they are laying out. +pub(super) fn text_measure(ctx: egui::Context) -> impl Fn(&str, FontId) -> f32 { + move |text, font| { + ctx.fonts_mut(|fonts| { + fonts + .layout_no_wrap(text.to_owned(), font, egui::Color32::PLACEHOLDER) + .size() + .x + }) + } +} + +/// The richest density whose content actually fits `width`: full icon-and-text +/// tiles whenever the active tab's groups all fit, otherwise the compact icon +/// row (whose own overflow moves whole groups into More). Below the absolute +/// floor even icon rows crowd, so the command area collapses to menus. +pub(super) fn density( + width: f32, + expanded: bool, + groups: &[(&'static str, u8, Vec<&CommandDescriptor>)], + measure: Measure, +) -> RibbonDensity { + if !expanded || width < AUTO_COLLAPSE_WIDTH { + RibbonDensity::Collapsed + } else if required_width(groups, RibbonDensity::Full, measure) <= width { + RibbonDensity::Full + } else { + RibbonDensity::Compact + } +} + +/// Width the whole tab needs at `density`: every group plus its separator. +/// The same measurement drives the density choice and the overflow budget, so +/// a tab shown Full is guaranteed to fit without a More menu. +pub(super) fn required_width( + groups: &[(&'static str, u8, Vec<&CommandDescriptor>)], + density: RibbonDensity, + measure: Measure, +) -> f32 { + groups + .iter() + .map(|(title, _, entries)| group_width(title, entries, density, measure) + 8.0) + .sum() +} + +pub(super) fn group_width( + title: &str, + entries: &[&CommandDescriptor], + density: RibbonDensity, + measure: Measure, +) -> f32 { + let spacing = 4.0 * entries.len().saturating_sub(1) as f32; + let commands = if density == RibbonDensity::Full { + tile_width(entries, measure) * entries.len() as f32 + spacing + } else { + entries + .iter() + .map(|command| button_width(command, measure)) + .sum::() + + spacing + }; + commands.max(measure(title, crate::typography::caption_font()) + 8.0) +} + +/// All tiles in a group share the width of the widest short label, so a group +/// reads as one row of even targets instead of a ragged strip. +pub(super) fn tile_width(entries: &[&CommandDescriptor], measure: Measure) -> f32 { + entries + .iter() + .map(|command| measure(&short_label(command), crate::typography::subheadline_font()) + 18.0) + .fold(58.0, f32::max) + .min(112.0) +} + +pub(super) fn button_width(command: &CommandDescriptor, measure: Measure) -> f32 { + if command.icon.is_some() { + ROW_HEIGHT + } else { + (measure(&short_label(command), crate::typography::callout_font()) + 16.0) + .clamp(40.0, 140.0) + } +} + +/// Which groups stay on the Ribbon within `budget`. Groups are admitted in +/// priority order and admission stops at the first group that does not fit, so +/// the visible set is always a highest-priority prefix: nothing in the More +/// menu ever outranks a group that stayed visible. +pub(super) fn shown_groups(priorities: &[u8], widths: &[f32], budget: f32) -> Vec { + debug_assert_eq!(priorities.len(), widths.len()); + let mut ranked: Vec = (0..priorities.len()).collect(); + ranked.sort_by_key(|&index| priorities[index]); + let mut shown = vec![false; priorities.len()]; + let mut used = 0.0; + for index in ranked { + if used + widths[index] > budget { + break; + } + shown[index] = true; + used += widths[index]; + } + shown +} + +pub(super) fn groups_for_tab( + catalog: &[CommandDescriptor], + tab: WorkflowTab, +) -> Vec<(&'static str, u8, Vec<&CommandDescriptor>)> { + let mut groups: Vec<(&'static str, u8, Vec<&CommandDescriptor>)> = Vec::new(); + for command in catalog { + let Some(placement) = command.ribbon.filter(|placement| placement.tab == tab) else { + continue; + }; + if let Some((_, priority, entries)) = groups + .iter_mut() + .find(|(group, _, _)| *group == placement.group) + { + *priority = (*priority).min(placement.priority); + entries.push(command); + } else { + groups.push((placement.group, placement.priority, vec![command])); + } + } + groups.sort_by_key(|(group, _, _)| group_order(tab, group)); + groups +} + +/// Left-to-right order of every Ribbon group, tab by tab, following each +/// tab's workflow reading. Every (tab, group) pair the placement tables can +/// produce must appear here: an unlisted pair would fall back to catalog +/// iteration order, which is accidental. Guarded by +/// `every_ribbon_group_has_an_explicit_order`. +pub(super) fn group_order(tab: WorkflowTab, group: &str) -> u8 { + let order: &[&str] = match tab { + WorkflowTab::Data => &["Import", "Build", "Export"], + WorkflowTab::Process => &["Processing", "Correct", "Transform", "Recipes"], + WorkflowTab::Analyze => &[ + "Range", + "Extract", + "Regions", + "Peaks", + "Review", + "Align", + "Peak Fit", + "Curve Fit", + "Statistics", + "Interpret", + ], + WorkflowTab::Figure => &["Create", "Chart", "Data", "Style", "Canvas", "Output"], + WorkflowTab::Arrange => &[ + "Layout", + "Align", + "Distribute", + "Order", + "Guides", + "Annotate", + "Object", + "Canvas", + ], + WorkflowTab::View => &["Navigate", "Display"], + }; + order + .iter() + .position(|&name| name == group) + .map_or(u8::MAX, |index| index as u8) +} + +#[cfg(test)] +mod tests { + use super::super::super::commands; + use super::*; + use plotx_core::state::PlotxApp; + + /// Deterministic stand-in for the live font system: proportional to the + /// font size like real glyphs, close to the Latin average width. + fn estimate(text: &str, font: FontId) -> f32 { + text.chars().count() as f32 * font.size * 0.53 + } + + #[test] + fn density_follows_the_active_tabs_measured_content() { + let app = PlotxApp::new_with_settings(plotx_core::settings::Settings::default()); + let catalog = commands::catalog(&app); + let groups = groups_for_tab(&catalog, WorkflowTab::View); + let full_need = required_width(&groups, RibbonDensity::Full, &estimate); + assert!( + full_need > AUTO_COLLAPSE_WIDTH, + "test premise: the View tab's full-density content ({full_need}) must exceed the collapse floor" + ); + + // Full the moment the tab's content fits — no fixed window breakpoint. + assert_eq!( + density(full_need + 1.0, true, &groups, &estimate), + RibbonDensity::Full + ); + assert_eq!( + density(full_need - 1.0, true, &groups, &estimate), + RibbonDensity::Compact + ); + assert_eq!( + density(700.0, true, &groups, &estimate), + RibbonDensity::Collapsed + ); + assert_eq!( + density(full_need + 1.0, false, &groups, &estimate), + RibbonDensity::Collapsed + ); + } + + #[test] + fn compact_groups_reserve_width_for_single_line_titles() { + assert!(group_width("Guides", &[], RibbonDensity::Compact, &estimate) > ROW_HEIGHT); + assert!(group_width("Object", &[], RibbonDensity::Compact, &estimate) > ROW_HEIGHT); + } + + #[test] + fn overflow_keeps_the_highest_priority_prefix() { + let priorities = [2u8, 0, 1, 3]; + let widths = [40.0, 50.0, 30.0, 20.0]; + // 50 + 30 fit; the priority-2 group does not, and it must also block + // the smaller priority-3 group behind it — a lower-priority group must + // never appear while a higher-priority one sits in the More menu. + let shown = shown_groups(&priorities, &widths, 90.0); + assert_eq!(shown, vec![false, true, true, false]); + } + + #[test] + fn every_ribbon_group_has_an_explicit_order() { + for (tab, group) in commands::ribbon_group_pairs() { + assert_ne!( + group_order(tab, group), + u8::MAX, + "({tab:?}, {group:?}) has no explicit order; add it to group_order" + ); + } + } +}