collective

Browse / github.com/storytold/cadcraft / patch

fix(ui): deduplicate collapsed ribbon menu commands

fix(ui): deduplicate collapsed ribbon menu commands ## Change When Home > Layers collapses, its menu repeats Turn All Layers On, Unisolate and Thaw All Layers because those commands belong to both visible cells and the overflow list. Append overflow entries only when their command ID is not already present, preserving order and every distinct command. Focused commit: c2c5e62836c0eb44f8087b3c3413c7ec81965aa8 (two files, 36 insertions / 2 deletions). This isolates the source-verified finding in review comment 4386206898 on #2281. ## Dependencies and overlap - Requires ribbon foundation #1023 and Windows validation prerequisite #2198, integrat

Status
open
Score
0.00
Adopters
0 other operators
Author
smacdmac101 (imported from pull request #2288, open)
Base
d864a08b013353143086bd8bb568c26cb6256796
Size
+1662 −19 in 14 files
Review
pending
Shared
2026-10-28
Id
66a9a0f57eaeb007f020df25

How it went

No other operator has reported using it yet.

Diff

raw · apps/cadcraft/src/workspace.rs crates/ui-egui/src/chrome.rs crates/ui-egui/src/docking.rs crates/ui-egui/src/i18n.rs crates/ui-egui/src/i18n/fr.tsv crates/ui-egui/src/i18n/tests.rs crates/ui-egui/src/i18n/uk.tsv crates/ui-egui/src/lib.rs crates/ui-egui/src/menus.rs crates/ui-egui/src/ribbon.rs crates/ui-egui/tests/it/chrome_geometry.rs docs/ui-parity.md xtask/src/parity.rs xtask/src/stats.rs

diff --git a/apps/cadcraft/src/workspace.rs b/apps/cadcraft/src/workspace.rs--- a/apps/cadcraft/src/workspace.rs+++ b/apps/cadcraft/src/workspace.rs@@ -47,12 +47,19 @@ fn snapshot(ui: &UiState) -> Result<Value, String> {     if ui.history_lines > 12 {         return Err("historyLines must be between 0 and 12".into());     }+    if ui.ribbon_tab.len() > 64 {+        return Err("ribbonTab exceeds the workspace size limit".into());+    }     Ok(json!({         "docking": ui.docking,         "dockingVersion": cadcraft_ui_egui::docking::LAYOUT_VERSION,         "showToolsets": ui.show_toolsets,         "showPalettes": ui.show_palettes,         "showToolbar": ui.show_toolbar,+        "showRibbon": ui.show_ribbon,+        "ribbonTab": ui.ribbon_tab,+        "ribbonMinimized": ui.ribbon_minimized,+        "pcLayoutApplied": ui.pc_layout_applied,         "showFileTabs": ui.show_file_tabs,         "showStatusBar": ui.show_status_bar,         "showCommandLine": ui.show_command_line,@@ -255,6 +262,21 @@ mod tests {         fs::remove_dir_all(directory).unwrap();     } +    #[test]+    fn the_ribbon_survives_native_workspace_file_reload() {+        let directory = temp_dir();+        let path = directory.join("workspace.json");+        let (mut first, _, _) = Workspace::at(Some(path.clone()));+        let ui = UiState { show_ribbon: true, ribbon_tab: "annotate".into(), ribbon_minimized: true, pc_layout_applied: true, ..Default::default() };+        first.save_if_changed(&ui, Instant::now()).unwrap();+        let (_, restored, error) = Workspace::at(Some(path.clone()));+        assert!(error.is_none(), "{error:?}");+        let restored = restored.unwrap();+        assert!(restored.show_ribbon && restored.ribbon_minimized && restored.pc_layout_applied);+        assert_eq!(restored.ribbon_tab, "annotate");+        fs::remove_dir_all(directory).unwrap();+    }+     #[test]     fn dock_columns_floating_and_hidden_panels_survive_native_workspace_file_reload() {         use cadcraft_ui_egui::docking::{LAYOUT_VERSION, Panel, permissions, reveal_panel};diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs--- a/crates/ui-egui/src/chrome.rs+++ b/crates/ui-egui/src/chrome.rs@@ -61,13 +61,60 @@ fn title_left(left: f32, width: f32, text_w: f32) -> f32 {     (left + (width - text_w) / 2.0).max(left + MAC_CONTENT_LEFT) } +/// The Windows layout's quick access tool bar (title row, with the Ribbon).+const QAT: &[(Icon, &str, &str, Option<&str>)] = &[+    (Icon::New, "new", "New Drawing", Some("Cmd+N")),+    (Icon::Open, "open", "Open", Some("Cmd+O")),+    (Icon::Save, "qsave", "Save", Some("Cmd+S")),+    (Icon::SaveAs, "saveas", "Save As", None),+    (Icon::Plot, "plot", "Plot", Some("Cmd+P")),+    (Icon::Undo, "u", "Undo", Some("Cmd+Z")),+    (Icon::Redo, "redo", "Redo", Some("Cmd+Shift+Z")),+];+const QAT_SIZE: f32 = 22.0;++/// Draw the quick access tool bar from `x` on the row centred at `cy`; returns its right edge.+fn quick_access(ui: &mut egui::Ui, x: f32, cy: f32, clicked: &mut Option<&'static str>) -> f32 {+    let t = Tokens::get();+    let mac = ui.ctx().os().is_mac();+    let mut x = x;+    for (i, (icon, cmd, label, shortcut)) in QAT.iter().enumerate() {+        if i == 5 {+            x += 8.0;+        }+        let br = Rect::from_min_size(pos2(x, cy - QAT_SIZE / 2.0), vec2(QAT_SIZE, QAT_SIZE));+        let resp = ui.interact(br, ui.scope_id().with(("qat", *cmd)), Sense::click());+        if resp.hovered() {+            ui.painter().rect_filled(br, 3.0, t.control_hover.gamma_multiply(0.6));+        }+        icons::paint(ui.painter(), br.shrink(3.0), *icon, false);+        let tip = match shortcut {+            Some(sc) => format!("{} ({})", crate::i18n::t(label), crate::menus::shortcut_label(sc, mac)),+            None => crate::i18n::t(label).to_string(),+        };+        icons::describe_control(ui, &resp, &tip, None, false);+        if resp.on_hover_text(tip).clicked() {+            *clicked = Some(cmd);+        }+        x += QAT_SIZE + 4.0;+    }+    x+}+ /// Title row (with the integrated macOS title bar) and the Tool Bar. pub fn title_and_toolbar(app: &mut CadApp, ui: &mut egui::Ui) {     let t = Tokens::get();     let title_h = if app.integrated_titlebar { MAC_TITLE_BAR } else { 0.0 };+    // With the Ribbon and no integrated title bar, the quick access tool bar gets its own row.+    let qat_h = if app.ui.show_ribbon && !app.integrated_titlebar { 28.0 } else { 0.0 };     let tb_h = if app.ui.show_toolbar { 34.0 } else { 0.0 };-    egui::Panel::top("cc_title_toolbar").exact_size(title_h + tb_h).frame(egui::Frame::NONE.fill(t.chrome)).show(ui, |ui| {+    egui::Panel::top("cc_title_toolbar").exact_size(title_h + qat_h + tb_h).frame(egui::Frame::NONE.fill(t.chrome)).show(ui, |ui| {         let r = ui.max_rect();+        let mut qat_clicked = None;+        if qat_h > 0.0 {+            quick_access(ui, r.left() + 10.0, r.top() + qat_h / 2.0, &mut qat_clicked);+        }+        let r = Rect::from_min_max(pos2(r.left(), r.top() + qat_h), r.max);         if title_h > 0.0 {             // The empty row moves the window; it goes first so later widgets take their own clicks.             craft_window::title_bar::empty_area(ui, Rect::from_min_size(r.min, vec2(r.width(), title_h)));@@ -78,8 +125,16 @@ pub fn title_and_toolbar(app: &mut CadApp, ui: &mut egui::Ui) {             // Centred on the traffic lights' centre line (the row's middle): with the system font             // the capitals' middle is the text box's middle, so no optical nudge.             let galley = ui.painter().layout_no_wrap(title, egui::FontId::proportional(13.0), t.text_dim);-            let x = title_left(r.left(), r.width(), galley.size().x);+            let qat_room = if app.ui.show_ribbon { QAT.len() as f32 * (QAT_SIZE + 4.0) + 24.0 } else { 0.0 };+            let x = title_left(r.left(), r.width(), galley.size().x).max(r.left() + MAC_CONTENT_LEFT + qat_room);             ui.painter().galley(pos2(x, r.top() + (title_h - galley.size().y) / 2.0), galley, t.text_dim);+            // The Windows layout keeps its quick access tool bar in the title row.+            if app.ui.show_ribbon {+                quick_access(ui, r.left() + MAC_CONTENT_LEFT + 4.0, r.top() + title_h / 2.0, &mut qat_clicked);+            }+        }+        if let Some(c) = qat_clicked {+            app.start(c);         }         if tb_h > 0.0 {             let row = Rect::from_min_size(pos2(r.left(), r.top() + title_h), vec2(r.width(), tb_h));diff --git a/crates/ui-egui/src/docking.rs b/crates/ui-egui/src/docking.rs--- a/crates/ui-egui/src/docking.rs+++ b/crates/ui-egui/src/docking.rs@@ -109,6 +109,21 @@ pub fn default_workspace() -> Workspace {     }) } +/// The Windows layout: the Properties palette docked on the left of the drawing, no Tool Sets+/// (the Ribbon holds the tools) and no Layers palette (the Ribbon's Layers panel holds the layer+/// list).+pub fn pc_workspace() -> Workspace {+    Workspace::new(Layout {+        root: Some(split(+            SplitAxis::Horizontal,+            SplitSize::FixedFirst(PALETTES_WIDTH),+            dock(Side::Start, PALETTES_WIDTH, &[Panel::Properties]),+            tabs(Panel::Canvas),+        )),+        floating: Vec::new(),+    })+}+ /// The dock a panel belongs in: Tool Sets on the left, the palettes on the right. fn home_side(panel: Panel) -> (Side, f32) {     if panel == Panel::ToolSets { (Side::Start, TOOL_SETS_WIDTH) } else { (Side::End, PALETTES_WIDTH) }@@ -288,7 +303,7 @@ fn sync(workspace: &mut Workspace, app: &crate::CadApp) -> Result<(), String> {     Ok(()) } -fn publish_visibility(app: &mut crate::CadApp, workspace: &Workspace) {+pub(crate) fn publish_visibility(app: &mut crate::CadApp, workspace: &Workspace) {     app.ui.show_toolsets = workspace.layout.contains(&Panel::ToolSets);     app.ui.show_palettes = workspace.layout.contains(&Panel::Layers) || workspace.layout.contains(&Panel::Properties); }diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs--- a/crates/ui-egui/src/i18n.rs+++ b/crates/ui-egui/src/i18n.rs@@ -290,6 +290,12 @@ pub fn tr<'a>(language: &str, source: &'a str) -> &'a str {     catalog(language).and_then(|c| c.get(&("", source))).copied().unwrap_or(source) } +/// A Ribbon label: the catalog's `ribbon` row (the wording Windows CAD ribbons use in that+/// language), else the ordinary translation.+pub fn ribbon(source: &str) -> &str {+    CURRENT.with(|l| catalog(l.get()).and_then(|c| c.get(&("ribbon", source))).copied().unwrap_or_else(|| tr(l.get(), source)))+}+ pub fn t(source: &str) -> &str {     CURRENT.with(|l| tr(l.get(), source)) }diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv--- a/crates/ui-egui/src/i18n/fr.tsv+++ b/crates/ui-egui/src/i18n/fr.tsv@@ -1015,3 +1015,177 @@ 	Check for Updates…	Rechercher des mises à jour… 	CADCraft is up to date.	CADCraft est à jour. 	Could not check for updates.	Impossible de rechercher les mises à jour.+# Ribbon labels (context `ribbon`): the French wording drafting users know from Windows CAD ribbons (observed black-box, 2026-10-13).+ribbon	Home	Début+ribbon	Tab: Insert	Insertion+ribbon	Insert	Insérer+ribbon	Annotate	Annoter+ribbon	Parametric	Paramétrique+ribbon	View	Vue+ribbon	Manage	Gérer+ribbon	Output	Sortie+ribbon	Collaborate	Collaborer+ribbon	Draw	Dessin+ribbon	Modify	Modification+ribbon	Annotation	Annotation+ribbon	Layers	Calques+ribbon	Block	Bloc+ribbon	Properties	Propriétés+ribbon	Groups	Groupes+ribbon	Utilities	Utilitaires+ribbon	Clipboard	Presse-papiers+ribbon	Block Definition	Définition de bloc+ribbon	Reference	Référence+ribbon	Import	Importer+ribbon	Data	Données+ribbon	Linking & Extraction	Liaison et extraction+ribbon	Location	Position+ribbon	Text	Texte+ribbon	Dimensions	Cotes+ribbon	Centerlines	Traits d'axe+ribbon	Leaders	Lignes de repère+ribbon	Tables	Tableaux+ribbon	Markup	Annotation+ribbon	Annotation Scaling	Echelle d'annotation+ribbon	Geometric	Géométrique+ribbon	Dimensional	Dimensionnelle+ribbon	Viewport Tools	Outils de fenêtre+ribbon	Named Views	Vues existantes+ribbon	Model Viewports	Fenêtres d'espace objet+ribbon	Review	Vérifier+ribbon	History	Historique+ribbon	Palettes	Palettes+ribbon	Interface	Interface+ribbon	Customization	Personnalisation+ribbon	Applications	Applications+ribbon	Cleanup	Nettoyer+ribbon	Plot	Traceur+ribbon	Export to DWF/PDF	Exporter au format DWF/PDF+ribbon	Share	Partager+ribbon	Compare	Comparer+ribbon	Line	Ligne+ribbon	Polyline	Polyligne+ribbon	Circle	Cercle+ribbon	Arc	Arc+ribbon	Rectangle	Rectangle+ribbon	Ellipse	Ellipse+ribbon	Hatch	Hachures+ribbon	Move	Déplacer+ribbon	Copy	Copier+ribbon	Stretch	Etirer+ribbon	Rotate	Rotation+ribbon	Mirror	Miroir+ribbon	Scale	Echelle+ribbon	Trim	Ajuster+ribbon	Fillet	Raccord+ribbon	Array	Réseau+ribbon	Erase	Effacer+ribbon	Explode	Décomposer+ribbon	Offset	Décaler+ribbon	Dimension	Cote+ribbon	Linear	Linéaire+ribbon	Leader	Ligne de repère+ribbon	Table	Tableau+ribbon	Layer Properties	Propriétés des calques+ribbon	Make Current	Rendre courant+ribbon	Match Layer	Faire correspondre le calque+ribbon	Copy to Layer	Copier le calque+ribbon	Create	Créer+ribbon	Edit	Modifier+ribbon	Edit Attributes	Modifier attributs+ribbon	Match Properties	Copier les propriétés+ribbon	Group	Grouper+ribbon	Measure	Mesurer+ribbon	Paste	Coller+ribbon	Edit Attribute	Editer les attributs+ribbon	Retain Attribute Display	Conserver l'affichage des attributs+ribbon	Create Block	Créer un bloc+ribbon	Define Attributes	Définir des attributs+ribbon	Manage Attributes	Gérer les attributs+ribbon	Block Editor	Editeur de blocs+ribbon	Attach	Attacher+ribbon	Clip	Délimiter+ribbon	Adjust	Ajuster+ribbon	External References	Références externes+ribbon	Frames vary	Variation de cadres+ribbon	Snap to Underlays	Accrochage aux fonds de plan+ribbon	PDF Import	Importer en PDF+ribbon	Recognize SHX Text	Reconnaître le texte SHX+ribbon	Recognition Settings	Paramètres de reconnaissance+ribbon	Combine Text	Combiner le texte+ribbon	Field	Champ+ribbon	Update Fields	Mettre à jour les champs+ribbon	OLE Object	Objet OLE+ribbon	Hyperlink	Hyperlien+ribbon	Data Link	Liaison de données+ribbon	Set Location	Définir l'emplacement+ribbon	Multiline Text	Texte multiligne+ribbon	Quick	Rapide+ribbon	Continue	Continuer+ribbon	Center Mark	Marque centrale+ribbon	Centerline	Trait d'axe+ribbon	Multileader	Ligne de repère multiple+ribbon	Add Leader	Ajouter une ligne de repère+ribbon	Remove Leader	Supprimer une ligne de repère+ribbon	Link Data	Liaison de données+ribbon	Wipeout	Masque+ribbon	Revision Cloud	Nuage de révision+ribbon	Add Current Scale	Ajouter échelle courante+ribbon	Scale List	Liste d'échelles+ribbon	Auto Constrain	Contrainte auto+ribbon	Show/Hide	Afficher/Masquer+ribbon	Show All	Tout afficher+ribbon	Hide All	Tout masquer+ribbon	Delete Constraints	Supprimer les contraintes+ribbon	Parameters Manager	Gestionnaire des paramètres+ribbon	UCS Icon	Icône du SCU+ribbon	Navigation Cube	Cube de navigation+ribbon	Navigation Bar	Barre de navigation+ribbon	New View	Nouvelle vue+ribbon	View Manager	Gestionnaire des vues+ribbon	Viewport Configuration	Configuration de la fenêtre+ribbon	Named	Nommé+ribbon	Join	Joindre+ribbon	Restore	Restaurer+ribbon	DWG Compare	Comparaison de fichiers DWG+ribbon	Activity Insights	Informations sur l'activité+ribbon	Tool Palettes	Palettes d'outils+ribbon	Blocks	Blocs+ribbon	Count	Quantité+ribbon	Sheet Set Manager	Gestionnaire du jeu de feuilles+ribbon	Switch Windows	Basculer les fenêtres+ribbon	File Tabs	Onglets Fichier+ribbon	Layout Tabs	Onglets de présentation+ribbon	Tile Horizontally	Mosaïque horizontale+ribbon	Tile Vertically	Mosaïque verticale+ribbon	Cascade	Cascade+ribbon	User Interface	Interface utilisateur+ribbon	Export	Exporter+ribbon	Edit Aliases	Modifier les alias+ribbon	Load Application	Charger une application+ribbon	Run Script	Exécuter le script+ribbon	Find Non-Purgeable Items	Rechercher des éléments ne pouvant pas être purgés+ribbon	Purge	Purger+ribbon	Overkill	Epurer+ribbon	Audit	Contrôler+ribbon	Batch Plot	Tracé par lot+ribbon	Preview	Aperçu+ribbon	Page Setup Manager	Gestionnaire des mises en page+ribbon	View Details	Afficher les détails+ribbon	Plotter Manager	Gestionnaire de traçage+ribbon	Share Drawing	Partager le dessin+ribbon	Shared Views	Vues partagées+ribbon	Off	Désactiver+ribbon	Isolate	Isoler+ribbon	Freeze	Geler+ribbon	Lock	Verrouiller+ribbon	Unlock	Déverrouiller+ribbon	Previous	Précédent+ribbon	Turn All Layers On	Activer tous les calques+ribbon	Unisolate	Annuler l'isolement+ribbon	Thaw All Layers	Libérer tous les calques+ribbon	Quick Select	Sélection rapide+ribbon	Command Line	Ligne de commande+	Ribbon	Ruban+	Windows Layout (Ribbon)	Disposition Windows (ruban)+	Mac Layout (Tool Sets)	Disposition Mac (palettes)diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs--- a/crates/ui-egui/src/i18n/tests.rs+++ b/crates/ui-egui/src/i18n/tests.rs@@ -20,7 +20,7 @@ fn catalog_is_valid_and_preserves_placeholders_and_ellipses() {         let (catalog, errors) = parse(language.source);         assert!(errors.is_empty(), "{}: {errors:?}", language.code);         for ((context, key), value) in catalog {-            assert!(["", "menu", "@plural"].contains(&context), "unknown context: {context}");+            assert!(["", "menu", "ribbon", "@plural"].contains(&context), "unknown context: {context}");             let forms: Vec<_> = if context == "@plural" { value.split('|').collect() } else { vec![value] };             if context == "@plural" {                 assert_eq!(forms.len(), language.plural_forms, "{}: {key}", language.code);diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv--- a/crates/ui-egui/src/i18n/uk.tsv+++ b/crates/ui-egui/src/i18n/uk.tsv@@ -1371,3 +1371,6 @@ 	Check for Updates…	Перевірити оновлення… 	CADCraft is up to date.	Встановлено найновішу версію CADCraft. 	Could not check for updates.	Не вдалося перевірити оновлення.+	Ribbon	Стрічка+	Windows Layout (Ribbon)	Розкладка Windows (стрічка)+	Mac Layout (Tool Sets)	Розкладка Mac (набори інструментів)diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs--- a/crates/ui-egui/src/lib.rs+++ b/crates/ui-egui/src/lib.rs@@ -46,6 +46,7 @@ pub mod palettes; pub mod parametric; pub mod plotstyles; pub mod quick;+pub mod ribbon; pub mod shortcut_menus; pub mod theme; #[cfg(not(target_arch = "wasm32"))]@@ -97,6 +98,15 @@ pub struct UiState {     /// menu puts them back.     pub hidden_tools: Vec<String>,     pub properties_all: bool,+    /// The Ribbon above the drawing (the Windows layout).+    pub show_ribbon: bool,+    /// The Ribbon tab shown (`ribbon::TABS` ids).+    pub ribbon_tab: String,+    /// Ribbon reduced to its tab row (double-click a tab).+    pub ribbon_minimized: bool,+    /// The desktop app has set up the Windows layout once (Ribbon, Properties docked+    /// left, no Tool Sets); later choices of layout are the user's (`ui.layout.pc` / `.mac`).+    pub pc_layout_applied: bool,     /// Show an in-window menu bar (platforms without a native menu bar, or on request).     pub in_window_menu: bool,     pub start_tab: bool,@@ -178,6 +188,10 @@ impl Default for UiState {             collapsed_groups: Vec::new(),             hidden_tools: Vec::new(),             properties_all: true,+            show_ribbon: false,+            ribbon_tab: "home".into(),+            ribbon_minimized: false,+            pc_layout_applied: false,             in_window_menu: !cfg!(target_os = "macos"),             start_tab: false,             dialog: None,@@ -817,9 +831,13 @@ impl CadApp {         control::begin_frame(ui.ctx());         let t = theme::Tokens::get();         chrome::title_and_toolbar(self, ui);+        // Top panels stack in call order: the menu bar sits above the Ribbon, as on Windows.         if self.ui.in_window_menu {             menus::menu_bar(self, ui);         }+        if self.ui.show_ribbon {+            ribbon::ribbon(self, ui);+        }         if self.ui.show_status_bar {             chrome::status_bar(self, ui);         }@@ -827,7 +845,8 @@ impl CadApp {             chrome::file_tabs(self, ui);         }         let has_doc = !self.session.docs.is_empty() && !self.ui.start_tab;-        if has_doc && !self.ui.show_toolsets {+        // With the Ribbon the tools are in it: no collapsed Tool Sets strip.+        if has_doc && !self.ui.show_toolsets && !self.ui.show_ribbon {             palettes::toolsets_strip(self, ui);         }         egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.canvas)).show(ui, |ui| {diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs--- a/crates/ui-egui/src/menus.rs+++ b/crates/ui-egui/src/menus.rs@@ -35,6 +35,9 @@ pub const UI_COMMANDS: &[(&str, &str, &[&str], Option<&str>)] = &[     ("ui.animations.off", "Off", &["View", "Accessibility", "Animation Effects", "Off"], None),     ("ui.animations", "Animation Effects", &[], None),     ("ui.toggle.toolbar", "Tool Bar", &["Window", "Tool Bar"], None),+    ("ui.toggle.ribbon", "Ribbon", &["Window", "Ribbon"], None),+    ("ui.layout.pc", "Windows Layout (Ribbon)", &["Window", "Windows Layout (Ribbon)"], None),+    ("ui.layout.mac", "Mac Layout (Tool Sets)", &["Window", "Mac Layout (Tool Sets)"], None),     ("ui.toggle.filetabs", "File Tab", &["Window", "File Tab"], None),     ("ui.toggle.statusbar", "Status Bar", &["Window", "Status Bar"], None),     ("ui.toggle.cmdline", "Command Line", &["Window", "Command Line"], Some("Cmd+9")),@@ -243,6 +246,18 @@ fn run_ui_command_inner(app: &mut CadApp, id: &str, params: &Value) -> Option<Re             toggle(&mut app.ui.show_toolbar, params);             Ok(Value::Null)         }+        "ui.toggle.ribbon" => {+            toggle(&mut app.ui.show_ribbon, params);+            Ok(Value::Null)+        }+        "ui.layout.pc" => {+            crate::ribbon::apply_pc_layout(app);+            Ok(Value::Null)+        }+        "ui.layout.mac" => {+            crate::ribbon::apply_mac_layout(app);+            Ok(Value::Null)+        }         "ui.toggle.filetabs" => {             toggle(&mut app.ui.show_file_tabs, params);             Ok(Value::Null)diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rsnew file mode 100644--- /dev/null+++ b/crates/ui-egui/src/ribbon.rs@@ -0,0 +1,1283 @@+//! The Ribbon: a Windows-style tabbed command bar, with tabs, panels and buttons where 2D+//! drafting users on Windows expect them (observed black-box). Every button runs a command (`app.start`); a command+//! CADCraft doesn't have yet shows greyed out in its place, so positions stay where users expect+//! them. Icons are CADCraft's own (`icons.rs`); a command without one shows its initial.+//!+//! Panels that don't fit the window collapse from the right into a single button that opens the+//! panel's commands as a menu, as Windows CAD ribbons do.++use egui::{Align2, Color32, FontId, Rect, Sense, Stroke, pos2, vec2};+use serde_json::json;++use crate::CadApp;+use crate::icons::{self, Icon};+use crate::theme::Tokens;++/// One ribbon command: id (as `app.start` takes it), label (English source, translated by the+/// interface catalog) and icon.+#[derive(Clone, Copy)]+pub struct Cmd {+    pub id: &'static str,+    pub label: &'static str,+    pub icon: Option<Icon>,+}++const fn c(id: &'static str, label: &'static str, icon: Icon) -> Cmd {+    Cmd { id, label, icon: Some(icon) }+}+const fn n(id: &'static str, label: &'static str) -> Cmd {+    Cmd { id, label, icon: None }+}++/// A current-value list in a panel (layer, colour, styles…).+#[derive(Clone, Copy, PartialEq, Eq, Debug)]+pub enum Widget {+    Layer,+    Color,+    Lineweight,+    Linetype,+    TextStyle,+    DimStyle,+    MleaderStyle,+    TableStyle,+    NamedView,+}++/// A column of a panel.+pub enum Cell {+    /// Large button (icon above, label below); `variants` open from its lower half (▾).+    Big(Cmd, &'static [Cmd]),+    /// Up to three small buttons (icon, label to the right), each with optional variants.+    Small(&'static [(Cmd, &'static [Cmd])]),+    /// Icon-only buttons, three per column.+    Icons(&'static [Cmd]),+    /// Current-value lists stacked in one column of the given width.+    Lists(&'static [Widget], f32),+    /// A list on the first row and icon buttons on the two rows under it (the Layers panel).+    ListIcons(Widget, f32, &'static [Cmd]),+}++pub struct PanelDef {+    pub title: &'static str,+    pub cells: &'static [Cell],+    /// Commands under the panel title's ▾ (the expanded panel).+    pub more: &'static [Cmd],+}++pub struct TabDef {+    pub id: &'static str,+    pub title: &'static str,+    pub panels: &'static [PanelDef],+}++// ------------------------------------------------------------------------------------------+// Tabs (the Windows 2D drafting layout; 3D tabs are left out on purpose)+// ------------------------------------------------------------------------------------------++const CIRCLES: &[Cmd] = &[+    c("circle", "Center, Radius", Icon::Circle),+    n("circle.cd", "Center, Diameter"),+    n("circle.2p", "2-Point"),+    n("circle.3p", "3-Point"),+    n("circle.ttr", "Tan, Tan, Radius"),+    n("circle.ttt", "Tan, Tan, Tan"),+];+const ARCS: &[Cmd] = &[+    c("arc", "3-Point", Icon::Arc),+    n("arc.sce", "Start, Center, End"),+    n("arc.sca", "Start, Center, Angle"),+    n("arc.scl", "Start, Center, Length"),+    n("arc.sea", "Start, End, Angle"),+    n("arc.sed", "Start, End, Direction"),+    n("arc.ser", "Start, End, Radius"),+    n("arc.cse", "Center, Start, End"),+    n("arc.csa", "Center, Start, Angle"),+    n("arc.csl", "Center, Start, Length"),+    n("arc.continue", "Continue"),+];+const RECTS: &[Cmd] = &[c("rectang", "Rectangle", Icon::Rectangle), c("polygon", "Polygon", Icon::Polygon)];+const ELLIPSES: &[Cmd] = &[c("ellipse", "Center", Icon::Ellipse), n("ellipse.axis", "Axis, End"), n("ellipse.arc", "Elliptical Arc")];+const HATCHES: &[Cmd] = &[c("hatch", "Hatch", Icon::Hatch), c("gradient", "Gradient", Icon::Gradient), c("boundary", "Boundary", Icon::Boundary)];+const TRIMS: &[Cmd] = &[c("trim", "Trim", Icon::Trim), c("extend", "Extend", Icon::Extend)];+const FILLETS: &[Cmd] = &[c("fillet", "Fillet", Icon::Fillet), c("chamfer", "Chamfer", Icon::Chamfer), n("blend", "Blend Curves")];+const ARRAYS: &[Cmd] =+    &[c("arrayrect", "Rectangular Array", Icon::ArrayRect), n("arraypath", "Path Array"), c("arraypolar", "Polar Array", Icon::ArrayPolar)];+const TEXTS: &[Cmd] = &[c("mtext", "Multiline Text", Icon::MText), c("text", "Single Line", Icon::Text)];+const DIMS: &[Cmd] = &[+    c("dimlinear", "Linear", Icon::DimLinear),+    c("dimaligned", "Aligned", Icon::DimAligned),+    c("dimangular", "Angular", Icon::DimAngular),+    c("dimarc", "Arc Length", Icon::DimArc),+    c("dimradius", "Radius", Icon::DimRadius),+    c("dimdiameter", "Diameter", Icon::DimDiameter),+    n("dimjogged", "Jogged"),+    c("dimordinate", "Ordinate", Icon::DimOrdinate),+];+const LEADERS: &[Cmd] = &[+    c("mleader", "Multileader", Icon::MLeader),+    c("mleaderedit.add", "Add Leader", Icon::AddLeader),+    c("mleaderedit.remove", "Remove Leader", Icon::RemoveLeader),+];+const INSERTS: &[Cmd] = &[c("insert", "Insert", Icon::Insert), c("ui.dialog.blocks", "Blocks Palette", Icon::Blocks)];+const ATT_EDITS: &[Cmd] = &[n("eattedit", "Single"), n("-attedit", "Multiple"), n("attsync", "Synchronize")];+const MEASURES: &[Cmd] = &[+    c("measuregeom", "Distance", Icon::Distance),+    n("measuregeom.radius", "Radius"),+    n("measuregeom.angle", "Angle"),+    c("area", "Area", Icon::Area),+];+const PASTES: &[Cmd] =+    &[n("pasteclip", "Paste"), n("pasteblock", "Paste as Block"), n("pasteorig", "Paste to Original Coordinates"), n("pastespec", "Paste Special")];+const ATT_DISPLAY: &[Cmd] =+    &[n("attdisp.normal", "Retain Attribute Display"), n("attdisp.on", "Display All Attributes"), n("attdisp.off", "Hide All Attributes")];+const BLOCK_CREATE: &[Cmd] = &[c("block", "Create Block", Icon::BlockCreate), n("wblock", "Write Block")];+const PDF_IMPORTS: &[Cmd] = &[n("pdfimport", "PDF Import"), n("import", "Import")];+const REVCLOUDS: &[Cmd] = &[+    c("revcloud", "Rectangular", Icon::RevCloud),+    n("revcloud.polygonal", "Polygonal"),+    n("revcloud.freehand", "Freehand"),+    n("revcloud.object", "Object"),+];+const VPORT_CONFIGS: &[Cmd] =+    &[n("vports.1", "Single"), n("vports.2", "Two"), n("vports.3", "Three"), n("vports.4", "Four"), n("vports", "Named...")];+const EXPORTS: &[Cmd] = &[n("exportpdf", "PDF"), n("exportdwf", "DWF"), n("exportdwfx", "DWFx"), n("export", "Other Formats")];+const CONTINUES: &[Cmd] = &[c("dimcontinue", "Continue", Icon::DimContinue), c("dimbaseline", "Baseline", Icon::DimBaseline)];++const NONE: &[Cmd] = &[];++pub const TABS: &[TabDef] = &[+    TabDef {+        id: "home",+        title: "Home",+        panels: &[+            PanelDef {+                title: "Draw",+                cells: &[+                    Cell::Big(c("line", "Line", Icon::Line), NONE),+                    Cell::Big(c("pline", "Polyline", Icon::Polyline), NONE),+                    Cell::Big(c("circle", "Circle", Icon::Circle), CIRCLES),+                    Cell::Big(c("arc", "Arc", Icon::Arc), ARCS),+                    Cell::Small(&[+                        (c("rectang", "Rectangle", Icon::Rectangle), RECTS),+                        (c("ellipse", "Ellipse", Icon::Ellipse), ELLIPSES),+                        (c("hatch", "Hatch", Icon::Hatch), HATCHES),+                    ]),+                ],+                more: &[+                    c("spline", "Spline Fit", Icon::Spline),+                    c("xline", "Construction Line", Icon::XLine),+                    c("ray", "Ray", Icon::Ray),+                    c("point", "Multiple Points", Icon::Point),+                    n("divide", "Divide"),+                    n("measure", "Measure"),+                    c("polygon", "Polygon", Icon::Polygon),+                    c("donut", "Donut", Icon::Donut),+                    c("region", "Region", Icon::Region),+                    c("wipeout", "Wipeout", Icon::Wipeout),+                    c("revcloud", "Revision Cloud", Icon::RevCloud),+                    n("3dpoly", "3D Polyline"),+                ],+            },+            PanelDef {+                title: "Modify",+                cells: &[+                    Cell::Small(&[+                        (c("move", "Move", Icon::Move), NONE),+                        (c("copy", "Copy", Icon::Copy), NONE),+                        (c("stretch", "Stretch", Icon::Stretch), NONE),+                    ]),+                    Cell::Small(&[+                        (c("rotate", "Rotate", Icon::Rotate), NONE),+                        (c("mirror", "Mirror", Icon::Mirror), NONE),+                        (c("scale", "Scale", Icon::Scale), NONE),+                    ]),+                    Cell::Small(&[+                        (c("trim", "Trim", Icon::Trim), TRIMS),+                        (c("fillet", "Fillet", Icon::Fillet), FILLETS),+                        (c("arrayrect", "Array", Icon::ArrayRect), ARRAYS),+                    ]),+                    Cell::Icons(&[c("erase", "Erase", Icon::Erase), c("explode", "Explode", Icon::Explode), c("offset", "Offset", Icon::Offset)]),+                ],+                more: &[+                    c("break", "Break", Icon::Break),+                    n("breakatpoint", "Break at Point"),+                    c("join", "Join", Icon::Join),+                    c("lengthen", "Lengthen", Icon::Lengthen),+                    c("chamfer", "Chamfer", Icon::Chamfer),+                    n("align", "Align"),+                    n("reverse", "Reverse"),+                    n("pedit", "Edit Polyline"),+                    n("splinedit", "Edit Spline"),+                    n("hatchedit", "Edit Hatch"),+                    n("setbylayer", "Set to ByLayer"),+                    n("overkill", "Delete Duplicate Objects"),+                    n("draworder", "Draw Order"),+                    n("flatten", "Flatten"),+                ],+            },+            PanelDef {+                title: "Annotation",+                cells: &[+                    Cell::Big(c("mtext", "Text", Icon::MText), TEXTS),+                    Cell::Big(c("dim", "Dimension", Icon::DimLinear), NONE),+                    Cell::Small(&[+                        (c("dimlinear", "Linear", Icon::DimLinear), DIMS),+                        (c("mleader", "Leader", Icon::MLeader), LEADERS),+                        (c("table", "Table", Icon::Table), NONE),+                    ]),+                ],+                more: &[+                    n("style", "Text Style"),+                    n("dimstyle", "Dimension Style"),+                    n("mleaderstyle", "Multileader Style"),+                    n("tablestyle", "Table Style"),+                ],+            },+            PanelDef {+                title: "Layers",+                cells: &[+                    Cell::Big(c("ui.dialog.layers", "Layer Properties", Icon::LayerProps), NONE),+                    Cell::ListIcons(+                        Widget::Layer,+                        168.0,+                        &[+                            c("layoff", "Off", Icon::LayerOff),+                            c("layiso", "Isolate", Icon::LayerIso),+                            c("layfrz", "Freeze", Icon::LayerFreeze),+                            c("laylck", "Lock", Icon::LayerLock),+                            n("layon", "Turn All Layers On"),+                            n("layuniso", "Unisolate"),+                            n("laythw", "Thaw All Layers"),+                            c("layulk", "Unlock", Icon::Unlock),+                            c("layerp", "Previous", Icon::LayerPrev),+                        ],+                    ),+                    Cell::Small(&[+                        (c("laymcur", "Make Current", Icon::MakeCurrent), NONE),+                        (c("laymch", "Match Layer", Icon::LayerMatch), NONE),+                        (n("copytolayer", "Copy to Layer"), NONE),+                    ]),+                ],+                more: &[+                    n("layon", "Turn All Layers On"),+                    n("laythw", "Thaw All Layers"),+                    n("layuniso", "Unisolate"),+                    n("laywalk", "Layer Walk"),+                    n("laymrg", "Merge"),+                    n("laydel", "Delete"),+                    n("layvpi", "Isolate to Current Viewport"),+                    n("layerstate", "Layer States"),+                ],+            },+            PanelDef {+                title: "Block",+                cells: &[+                    Cell::Big(c("insert", "Insert", Icon::Insert), INSERTS),+                    Cell::Small(&[+                        (c("block", "Create", Icon::BlockCreate), NONE),+                        (c("bedit", "Edit", Icon::BlockEdit), NONE),+                        (n("eattedit", "Edit Attributes"), ATT_EDITS),+                    ]),+                ],+                more: &[c("attdef", "Define Attributes", Icon::AttDef), n("battman", "Manage Attributes"), n("attsync", "Synchronize Attributes")],+            },+            PanelDef {+                title: "Properties",+                cells: &[+                    Cell::Big(c("matchprop", "Match Properties", Icon::MatchProp), NONE),+                    Cell::Lists(&[Widget::Color, Widget::Lineweight, Widget::Linetype], 140.0),+                ],+                more: &[n("transparency", "Transparency"), c("list", "List", Icon::List), n("setbylayer", "Set to ByLayer")],+            },+            PanelDef {+                title: "Groups",+                cells: &[+                    Cell::Big(n("group", "Group"), NONE),+                    Cell::Icons(&[n("ungroup", "Ungroup"), n("groupedit", "Group Edit"), n("pickstyle", "Group Selection On/Off")]),+                ],+                more: NONE,+            },+            PanelDef {+                title: "Utilities",+                cells: &[+                    Cell::Big(c("measuregeom", "Measure", Icon::Measure), MEASURES),+                    Cell::Icons(&[+                        c("ui.dialog.qselect", "Quick Select", Icon::QuickSelect),+                        n("ui.panel.quickcalc", "QuickCalc"),+                        n("id", "ID Point"),+                    ]),+                ],+                more: &[n("selectall", "Select All"), n("count", "Count")],+            },+            PanelDef {+                title: "Clipboard",+                cells: &[+                    Cell::Big(n("pasteclip", "Paste"), PASTES),+                    Cell::Icons(&[n("copyclip", "Copy Clip"), n("cutclip", "Cut"), n("copybase", "Copy with Base Point")]),+                ],+                more: NONE,+            },+        ],+    },+    TabDef {+        id: "insert",+        title: "Insert",+        panels: &[+            PanelDef {+                title: "Block",+                cells: &[+                    Cell::Big(c("insert", "Insert", Icon::Insert), INSERTS),+                    Cell::Small(&[(n("eattedit", "Edit Attribute"), ATT_EDITS), (n("attdisp", "Retain Attribute Display"), ATT_DISPLAY)]),+                ],+                more: NONE,+            },+            PanelDef {+                title: "Block Definition",+                cells: &[+                    Cell::Big(c("block", "Create Block", Icon::BlockCreate), BLOCK_CREATE),+                    Cell::Big(c("attdef", "Define Attributes", Icon::AttDef), NONE),+                    Cell::Big(n("battman", "Manage Attributes"), NONE),+                    Cell::Big(c("bedit", "Block Editor", Icon::BlockEdit), NONE),+                ],+                more: NONE,+            },+            PanelDef {+                title: "Reference",+                cells: &[+                    Cell::Big(c("attach", "Attach", Icon::Attach), NONE),+                    Cell::Big(n("clip", "Clip"), NONE),+                    Cell::Big(n("adjust", "Adjust"), NONE),+                    Cell::Small(&[+                        (n("xref", "External References"), NONE),+                        (n("frame", "Frames vary"), NONE),+                        (n("uosnap", "Snap to Underlays"), NONE),+                    ]),+                ],+                more: &[n("xclip", "Clip Xref"), n("xbind", "Bind"), n("imageattach", "Attach Image"), n("pdfattach", "Attach PDF")],+            },+            PanelDef {+                title: "Import",+                cells: &[+                    Cell::Big(n("pdfimport", "PDF Import"), PDF_IMPORTS),+                    Cell::Small(&[(n("pdfshxtext", "Recognize SHX Text"), NONE), (n("pdfshxtextoptions", "Recognition Settings"), NONE)]),+                    Cell::Big(n("txt2mtxt", "Combine Text"), NONE),+                ],+                more: NONE,+            },+            PanelDef {+                title: "Data",+                cells: &[+                    Cell::Big(n("field", "Field"), NONE),+                    Cell::Small(&[+                        (n("updatefield", "Update Fields"), NONE),+                        (n("insertobj", "OLE Object"), NONE),+                        (n("hyperlink", "Hyperlink"), NONE),+                    ]),+                ],+                more: NONE,+            },+            PanelDef {+                title: "Linking & Extraction",+                cells: &[+                    Cell::Big(n("datalink", "Data Link"), NONE),+                    Cell::Icons(&[n("dataextraction", "Extract Data"), n("datalinkupdate", "Update Data Links")]),+                ],+                more: NONE,+            },+            PanelDef { title: "Location", cells: &[Cell::Big(n("geographiclocation", "Set Location"), NONE)], more: NONE },+        ],+    },+    TabDef {+        id: "annotate",+        title: "Annotate",+        panels: &[+            PanelDef {+                title: "Text",+                cells: &[+                    Cell::Big(c("mtext", "Multiline Text", Icon::MText), TEXTS),+                    Cell::Lists(&[Widget::TextStyle], 170.0),+                    Cell::Icons(&[n("spell", "Check Spelling"), c("find", "Find Text", Icon::Search), n("scaletext", "Scale Text")]),+                ],+                more: &[n("justifytext", "Justify"), n("textalign", "Text Align"), n("style", "Text Style")],+            },+            PanelDef {+                title: "Dimensions",+                cells: &[+                    Cell::Big(c("dim", "Dimension", Icon::DimLinear), NONE),+                    Cell::Lists(&[Widget::DimStyle], 170.0),+                    Cell::Small(&[+                        (c("dimlinear", "Linear", Icon::DimLinear), DIMS),+                        (c("qdim", "Quick", Icon::QDim), NONE),+                        (c("dimcontinue", "Continue", Icon::DimContinue), CONTINUES),+                    ]),+                    Cell::Icons(&[+                        n("dimbreak", "Break"),+                        n("dimspace", "Adjust Space"),+                        n("diminspect", "Inspect"),+                        n("dimjogline", "Jog Line"),+                        n("dimreassociate", "Reassociate"),+                        n("dimtedit", "Text Angle"),+                    ]),+                ],+                more: &[n("dimstyle", "Dimension Style"), n("dimoverride", "Override"), n("dimedit.oblique", "Oblique"), n("tolerance", "Tolerance")],+            },+            PanelDef {+                title: "Centerlines",+                cells: &[Cell::Big(n("centermark", "Center Mark"), NONE), Cell::Big(n("centerline", "Centerline"), NONE)],+                more: NONE,+            },+            PanelDef {+                title: "Leaders",+                cells: &[+                    Cell::Big(c("mleader", "Multileader", Icon::MLeader), NONE),+                    Cell::Lists(&[Widget::MleaderStyle], 160.0),+                    Cell::Small(&[+                        (c("mleaderedit.add", "Add Leader", Icon::AddLeader), NONE),+                        (c("mleaderedit.remove", "Remove Leader", Icon::RemoveLeader), NONE),+                    ]),+                    Cell::Icons(&[c("mleaderalign", "Align", Icon::AlignLeaders), n("mleadercollect", "Collect")]),+                ],+                more: &[n("mleaderstyle", "Multileader Style")],+            },+            PanelDef {+                title: "Tables",+                cells: &[+                    Cell::Big(c("table", "Table", Icon::Table), NONE),+                    Cell::Lists(&[Widget::TableStyle], 150.0),+                    Cell::Small(&[(n("datalink", "Link Data"), NONE), (n("dataextraction", "Extract Data"), NONE)]),+                ],+                more: &[n("tablestyle", "Table Style"), n("tableexport", "Export")],+            },+            PanelDef {+                title: "Markup",+                cells: &[+                    Cell::Big(c("wipeout", "Wipeout", Icon::Wipeout), NONE),+                    Cell::Big(c("revcloud", "Revision Cloud", Icon::RevCloud), REVCLOUDS),+                ],+                more: NONE,+            },+            PanelDef {+                title: "Annotation Scaling",+                cells: &[+                    Cell::Big(c("objectscale", "Add Current Scale", Icon::Annotation), NONE),+                    Cell::Small(&[(n("scalelistedit", "Scale List"), NONE), (n("annoupdate", "Sync Scale Positions"), NONE)]),+                ],+                more: NONE,+            },+        ],+    },+    TabDef {+        id: "parametric",+        title: "Parametric",+        panels: &[+            PanelDef {+                title: "Geometric",+                cells: &[+                    Cell::Big(c("autoconstrain", "Auto Constrain", Icon::Constraint), NONE),+                    Cell::Icons(&[+                        c("gccoincident", "Coincident", Icon::Coincident),+                        c("gcparallel", "Parallel", Icon::Parallel),+                        c("gcperpendicular", "Perpendicular", Icon::Perpendicular),+                    ]),+                    Cell::Icons(&[+                        c("gchorizontal", "Horizontal", Icon::Horizontal),+                        c("gcvertical", "Vertical", Icon::Vertical),+                        c("gctangent", "Tangent", Icon::Tangent),+                    ]),+                    Cell::Icons(&[+                        c("gcconcentric", "Concentric", Icon::Concentric),+                        c("gcequal", "Equal", Icon::Equal),+                        c("gcfix", "Fix", Icon::Fix),+                    ]),+                    Cell::Small(&[+                        (n("constraintbar", "Show/Hide"), NONE),+                        (n("constraintbar.showall", "Show All"), NONE),+                        (n("constraintbar.hideall", "Hide All"), NONE),+                    ]),+                ],+                more: NONE,+            },+            PanelDef {+                title: "Dimensional",+                cells: &[+                    Cell::Small(&[(n("dclinear", "Linear"), NONE), (n("dcaligned", "Aligned"), NONE), (n("dcangular", "Angular"), NONE)]),+                    Cell::Small(&[(n("dcradius", "Radius"), NONE), (n("dcdiameter", "Diameter"), NONE), (n("dcconvert", "Convert"), NONE)]),+                    Cell::Small(&[+                        (n("dcdisplay", "Show/Hide"), NONE),+                        (n("dcdisplay.showall", "Show All"), NONE),+                        (n("dcdisplay.hideall", "Hide All"), NONE),+                    ]),+                ],+                more: NONE,+            },+            PanelDef {+                title: "Manage",+                cells: &[Cell::Big(n("delconstraint", "Delete Constraints"), NONE), Cell::Big(n("ui.dialog.parameters", "Parameters Manager"), NONE)],+                more: NONE,+            },+        ],+    },+    TabDef {+        id: "view",+        title: "View",+        panels: &[+            PanelDef {+                title: "Viewport Tools",+                cells: &[+                    Cell::Big(n("ui.toggle.ucsicon", "UCS Icon"), NONE),+                    Cell::Big(n("ui.toggle.viewcube", "Navigation Cube"), NONE),+                    Cell::Big(n("navbar", "Navigation Bar"), NONE),+                ],+                more: NONE,+            },+            PanelDef {+                title: "Named Views",+                cells: &[Cell::Lists(&[Widget::NamedView], 170.0), Cell::Small(&[(n("view", "New View"), NONE), (n("view", "View Manager"), NONE)])],+                more: NONE,+            },+            PanelDef {+                title: "Model Viewports",+                cells: &[+                    Cell::Big(n("vports", "Viewport Configuration"), VPORT_CONFIGS),+                    Cell::Small(&[(n("vports", "Named"), NONE), (n("-vports.join", "Join"), NONE), (n("vports.1", "Restore"), NONE)]),+                ],+                more: NONE,+            },+            PanelDef { title: "Review", cells: &[Cell::Big(n("compare", "DWG Compare"), NONE)], more: NONE },+            PanelDef { title: "History", cells: &[Cell::Big(n("activityinsights", "Activity Insights"), NONE)], more: NONE },+            PanelDef {+                title: "Palettes",+                cells: &[+                    Cell::Big(n("toolpalettes", "Tool Palettes"), NONE),+                    Cell::Big(c("ui.panel.properties", "Properties", Icon::Properties), NONE),+                    Cell::Big(c("ui.dialog.blocks", "Blocks", Icon::Blocks), NONE),+                    Cell::Big(n("count", "Count"), NONE),+                    Cell::Big(n("sheetset", "Sheet Set Manager"), NONE),+                    Cell::Icons(&[+                        n("ui.toggle.cmdline", "Command Line"),+                        c("ui.panel.layers", "Layers", Icon::Layers),+                        n("ui.panel.quickcalc", "QuickCalc"),+                    ]),+                    Cell::Icons(&[n("xref", "External References"), n("ui.toggle.history", "Text Window"), n("markupimport", "Markup Import")]),+                ],+                more: NONE,+            },+            PanelDef {+                title: "Interface",+                cells: &[+                    Cell::Big(c("ui.switcher", "Switch Windows", Icon::Switcher), NONE),+                    Cell::Big(n("ui.toggle.filetabs", "File Tabs"), NONE),+                    Cell::Big(n("ui.toggle.layouttabs", "Layout Tabs"), NONE),+                    Cell::Small(&[+                        (n("ui.tile.horizontal", "Tile Horizontally"), NONE),+                        (n("ui.tile.vertical", "Tile Vertically"), NONE),+                        (n("ui.cascade", "Cascade"), NONE),+                    ]),+                ],+                more: NONE,+            },+        ],+    },+    TabDef {+        id: "manage",+        title: "Manage",+        panels: &[+            PanelDef {+                title: "Customization",+                cells: &[+                    Cell::Big(n("cui", "User Interface"), NONE),+                    Cell::Big(n("toolpalettes", "Tool Palettes"), NONE),+                    Cell::Small(&[(n("cuiimport", "Import"), NONE), (n("cuiexport", "Export"), NONE), (n("aliasedit", "Edit Aliases"), NONE)]),+                ],+                more: NONE,+            },+            PanelDef {+                title: "Applications",+                cells: &[Cell::Big(n("appload", "Load Application"), NONE), Cell::Big(n("script", "Run Script"), NONE)],+                more: NONE,+            },+            PanelDef {+                title: "Cleanup",+                cells: &[+                    Cell::Big(n("purge.find", "Find Non-Purgeable Items"), NONE),+                    Cell::Small(&[(n("purge", "Purge"), NONE), (n("overkill", "Overkill"), NONE), (n("audit", "Audit"), NONE)]),+                ],+                more: NONE,+            },+        ],+    },+    TabDef {+        id: "output",+        title: "Output",+        panels: &[+            PanelDef {+                title: "Plot",+                cells: &[+                    Cell::Big(c("plot", "Plot", Icon::Plot), NONE),+                    Cell::Big(c("publish", "Batch Plot", Icon::Publish), NONE),+                    Cell::Big(c("preview", "Preview", Icon::Preview), NONE),+                    Cell::Small(&[+                        (c("pagesetup", "Page Setup Manager", Icon::PageSetup), NONE),+                        (n("viewplotdetails", "View Details"), NONE),+                        (n("plottermanager", "Plotter Manager"), NONE),+                    ]),+                ],+                more: NONE,+            },+            PanelDef { title: "Export to DWF/PDF", cells: &[Cell::Big(c("exportpdf", "Export", Icon::Export), EXPORTS)], more: NONE },+        ],+    },+    TabDef {+        id: "collaborate",+        title: "Collaborate",+        panels: &[+            PanelDef {+                title: "Share",+                cells: &[Cell::Big(c("share", "Share Drawing", Icon::Share), NONE), Cell::Big(n("sharedviews", "Shared Views"), NONE)],+                more: NONE,+            },+            PanelDef { title: "Compare", cells: &[Cell::Big(n("compare", "DWG Compare"), NONE)], more: NONE },+        ],+    },+];++// ------------------------------------------------------------------------------------------+// Layout+// ------------------------------------------------------------------------------------------++/// Height of the tab row.+pub const TAB_ROW: f32 = 24.0;+/// Height of the panel body (three small-button rows) and of the panel title row under it.+const BODY: f32 = 72.0;+const TITLE: f32 = 18.0;+/// Whole ribbon height when expanded.+pub const HEIGHT: f32 = TAB_ROW + BODY + TITLE + 4.0;++const ROW: f32 = BODY / 3.0;+const BIG_ICON: f32 = 30.0;+const SMALL_ICON: f32 = 16.0;+const PAD: f32 = 3.0;++/// Ribbon text is a little smaller than the palettes' body text.+fn font() -> FontId {+    FontId::proportional(11.0)+}++/// A tab's title: the catalog's `Tab: <title>` row when the tab is named differently from a+/// button with the same English word (Insert tab, Insert button), else the plain label.+fn tab_label(title: &str) -> String {+    let key = format!("Tab: {title}");+    let tab = crate::i18n::ribbon(&key);+    if tab == key { crate::i18n::ribbon(title).to_string() } else { tab.to_string() }+}++/// Whether the command exists (else its button is greyed out).+pub fn known(id: &str) -> bool {+    cadcraft_engine::find_command(id).is_some() || crate::menus::is_ui_command(id)+}++fn text_w(ui: &egui::Ui, s: &str) -> f32 {+    ui.painter().layout_no_wrap(crate::i18n::ribbon(s).to_string(), font(), Color32::WHITE).size().x+}++/// A big button's label in up to two lines (split at the space nearest the middle).+fn two_lines(label: &str) -> (String, Option<String>) {+    let s = crate::i18n::ribbon(label);+    if s.chars().count() <= 10 || !s.contains(' ') {+        return (s.to_string(), None);+    }+    let mid = s.len() / 2;+    let split = s.char_indices().filter(|(_, ch)| *ch == ' ').map(|(i, _)| i).min_by_key(|i| i.abs_diff(mid)).unwrap_or(mid);+    let (a, b) = s.split_at(split);+    (a.to_string(), Some(b.trim_start().to_string()))+}++fn cell_width(ui: &egui::Ui, cell: &Cell) -> f32 {+    match cell {+        Cell::Big(cmd, variants) => {+            let (a, b) = two_lines(cmd.label);+            let w = |s: &str| ui.painter().layout_no_wrap(s.to_string(), font(), Color32::WHITE).size().x;+            let lw = w(&a).max(b.as_deref().map_or(0.0, w)) + if variants.is_empty() { 0.0 } else { 10.0 };+            lw.max(BIG_ICON + 4.0) + 2.0 * PAD+        }+        Cell::Small(rows) => {+            rows.iter().map(|(cmd, v)| SMALL_ICON + 5.0 + text_w(ui, cmd.label) + if v.is_empty() { 2.0 } else { 13.0 }).fold(0.0, f32::max)+                + 2.0 * PAD+        }+        Cell::Icons(cmds) => (cmds.len().div_ceil(3) as f32) * (SMALL_ICON + 8.0) + PAD,+        Cell::Lists(_, w) | Cell::ListIcons(_, w, _) => *w + 2.0 * PAD,+    }+}++fn panel_width(ui: &egui::Ui, panel: &PanelDef) -> f32 {+    let cells: f32 = panel.cells.iter().map(|c| cell_width(ui, c)).sum();+    cells.max(text_w(ui, panel.title) + if panel.more.is_empty() { 12.0 } else { 24.0 }) + PAD+}++/// Width of a collapsed panel (a single button with the panel's title).+fn collapsed_width(ui: &egui::Ui, panel: &PanelDef) -> f32 {+    text_w(ui, panel.title).max(BIG_ICON) + 4.0 * PAD + 10.0+}++/// Which panels are shown in full: all of them while they fit, then the rightmost ones collapse+/// first, as Windows CAD ribbons do when the window narrows.+pub fn fit(full: &[f32], collapsed: &[f32], room: f32) -> Vec<bool> {+    let mut expanded = vec![true; full.len()];+    let total = |e: &[bool]| -> f32 {+        e.iter().enumerate().map(|(i, x)| if *x { full.get(i).copied().unwrap_or(0.0) } else { collapsed.get(i).copied().unwrap_or(0.0) }).sum()+    };+    let mut i = full.len();+    while total(&expanded) > room && i > 0 {+        i -= 1;+        if let Some(e) = expanded.get_mut(i) {+            *e = false;+        }+    }+    expanded+}++// ------------------------------------------------------------------------------------------+// Drawing+// ------------------------------------------------------------------------------------------++/// What a click in the ribbon asks for.+enum Action {+    Start(&'static str),+    Tab(&'static str),+    ToggleMinimized,+    Layer(String),+    LayerToggle(String, &'static str, bool),+}++fn hover_fill(ui: &egui::Ui, r: Rect, resp: &egui::Response) {+    if resp.hovered() {+        ui.painter().rect_filled(r, 3.0, Tokens::get().control_hover.gamma_multiply(0.6));+    }+}++fn paint_icon(p: &egui::Painter, r: Rect, cmd: &Cmd, dim: bool) {+    match cmd.icon {+        Some(icon) => icons::paint(p, r, icon, dim),+        None => {+            let t = Tokens::get();+            let col = if dim { t.text_faint } else { t.icon };+            p.rect_stroke(r.shrink(r.width() * 0.08), 2.0, Stroke::new(1.0, col), egui::StrokeKind::Inside);+            let initial: String = crate::i18n::ribbon(cmd.label).chars().next().map(|ch| ch.to_uppercase().collect()).unwrap_or_default();+            p.text(r.center(), Align2::CENTER_CENTER, initial, FontId::proportional(r.height() * 0.55), col);+        }+    }+}++fn tooltip(cmd: &Cmd, enabled: bool) -> String {+    let label = crate::i18n::ribbon(cmd.label);+    if enabled { format!("{label} ({})", cmd.id.to_uppercase()) } else { format!("{label} — {}", crate::tl!("not available yet")) }+}++/// A menu of command variants (or a collapsed panel's commands).+fn variant_menu(ui: &mut egui::Ui, items: &[Cmd], out: &mut Option<Action>) {+    for v in items {+        let enabled = known(v.id);+        let resp = ui.add_enabled(enabled, egui::Button::new(crate::i18n::ribbon(v.label)));+        crate::control::record_widget(ui, &resp, v.label, "button", None);+        if resp.clicked() {+            *out = Some(Action::Start(v.id));+            ui.close();+        }+    }+}++fn big(ui: &mut egui::Ui, r: Rect, cmd: &Cmd, variants: &'static [Cmd], out: &mut Option<Action>) {+    let t = Tokens::get();+    let enabled = known(cmd.id);+    let top = Rect::from_min_max(r.min, pos2(r.right(), r.top() + BIG_ICON + 8.0));+    let resp = ui.interact(if variants.is_empty() { r } else { top }, ui.scope_id().with(("rb_big", cmd.id, cmd.label)), Sense::click());+    hover_fill(ui, if variants.is_empty() { r } else { top }, &resp);+    paint_icon(ui.painter(), Rect::from_center_size(pos2(r.center().x, r.top() + 4.0 + BIG_ICON / 2.0), vec2(BIG_ICON, BIG_ICON)), cmd, !enabled);+    let (a, b) = two_lines(cmd.label);+    let col = if enabled { t.text } else { t.text_faint };+    let y = r.top() + BIG_ICON + 10.0;+    ui.painter().text(pos2(r.center().x, y), Align2::CENTER_TOP, &a, font(), col);+    if let Some(b) = &b {+        ui.painter().text(pos2(r.center().x, y + 13.0), Align2::CENTER_TOP, b, font(), col);+    }+    icons::describe_control(ui, &resp, cmd.label, None, false);+    if resp.on_hover_text(tooltip(cmd, enabled)).clicked() && enabled {+        *out = Some(Action::Start(cmd.id));+    }+    if !variants.is_empty() {+        let low = Rect::from_min_max(pos2(r.left(), top.bottom()), r.max);+        let vr = ui.interact(low, ui.scope_id().with(("rb_bigv", cmd.id, cmd.label)), Sense::click());+        hover_fill(ui, low, &vr);+        let line_y = y + if b.is_some() { 26.0 } else { 13.0 };+        icons::paint(ui.painter(), Rect::from_center_size(pos2(r.center().x, line_y + 4.0), vec2(9.0, 9.0)), Icon::ChevronDown, false);+        egui::Popup::menu(&vr).show(|ui| variant_menu(ui, variants, out));+    }+}++fn small(ui: &mut egui::Ui, r: Rect, cmd: &Cmd, variants: &'static [Cmd], out: &mut Option<Action>) {+    let t = Tokens::get();+    let enabled = known(cmd.id);+    let main = if variants.is_empty() { r } else { Rect::from_min_max(r.min, pos2(r.right() - 12.0, r.bottom())) };+    let resp = ui.interact(main, ui.scope_id().with(("rb_small", cmd.id, cmd.label, r.top() as i32)), Sense::click());+    hover_fill(ui, main, &resp);+    paint_icon(+        ui.painter(),+        Rect::from_center_size(pos2(r.left() + 2.0 + SMALL_ICON / 2.0, r.center().y), vec2(SMALL_ICON, SMALL_ICON)),+        cmd,+        !enabled,+    );+    ui.painter().text(+        pos2(r.left() + SMALL_ICON + 6.0, r.center().y),+        Align2::LEFT_CENTER,+        crate::i18n::ribbon(cmd.label),+        font(),+        if enabled { t.text } else { t.text_faint },+    );+    icons::describe_control(ui, &resp, cmd.label, None, false);+    if resp.on_hover_text(tooltip(cmd, enabled)).clicked() && enabled {+        *out = Some(Action::Start(cmd.id));+    }+    if !variants.is_empty() {+        let vr_rect = Rect::from_min_max(pos2(r.right() - 12.0, r.top()), r.max);+        let vr = ui.interact(vr_rect, ui.scope_id().with(("rb_smallv", cmd.id, cmd.label, r.top() as i32)), Sense::click());+        hover_fill(ui, vr_rect, &vr);+        icons::paint(ui.painter(), Rect::from_center_size(vr_rect.center(), vec2(8.0, 8.0)), Icon::ChevronDown, false);+        egui::Popup::menu(&vr).show(|ui| variant_menu(ui, variants, out));+    }+}++fn icon_button(ui: &mut egui::Ui, r: Rect, cmd: &Cmd, out: &mut Option<Action>) {+    let enabled = known(cmd.id);+    let resp = ui.interact(r, ui.scope_id().with(("rb_icon", cmd.id, cmd.label)), Sense::click());+    hover_fill(ui, r, &resp);+    paint_icon(ui.painter(), Rect::from_center_size(r.center(), vec2(SMALL_ICON, SMALL_ICON)), cmd, !enabled);+    icons::describe_control(ui, &resp, cmd.label, None, false);+    if resp.on_hover_text(tooltip(cmd, enabled)).clicked() && enabled {+        *out = Some(Action::Start(cmd.id));+    }+}++/// The current value a list shows, and the command its button opens.+fn widget_value(app: &CadApp, w: Widget) -> (String, &'static str) {+    let Ok(d) = app.session.doc() else { return (String::new(), "ui.noop") };+    let h = &d.header;+    let get = |name: &str, default: &str| {+        let v = h.str(name, default);+        if v.is_empty() { default.to_string() } else { v }+    };+    match w {+        Widget::Layer => (h.str("CLAYER", "0"), "ui.dialog.layers"),+        Widget::Color => (color_name(h.i64("CECOLOR", 256)), "color"),+        Widget::Lineweight => (lineweight_name(h.i64("CELWEIGHT", -1)), "lweight"),+        Widget::Linetype => (by_name(&get("CELTYPE", "ByLayer")), "linetype"),+        Widget::TextStyle => (get("TEXTSTYLE", "Standard"), "style"),+        Widget::DimStyle => (get("DIMSTYLE", "Standard"), "dimstyle"),+        Widget::MleaderStyle => (get("CMLEADERSTYLE", "Standard"), "mleaderstyle"),+        Widget::TableStyle => (get("CTABLESTYLE", "Standard"), "tablestyle"),+        Widget::NamedView => (crate::tl!("Unsaved View").to_string(), "view"),+    }+}++/// ByLayer/ByBlock in the interface language; other names as they are.+fn by_name(name: &str) -> String {+    if name.eq_ignore_ascii_case("ByLayer") {+        crate::tl!("ByLayer").to_string()+    } else if name.eq_ignore_ascii_case("ByBlock") {+        crate::tl!("ByBlock").to_string()+    } else {+        name.to_string()+    }+}++/// The usual CAD name for a colour index (CECOLOR).+pub fn color_name(aci: i64) -> String {+    match aci {+        256 => crate::tl!("ByLayer").to_string(),+        0 => crate::tl!("ByBlock").to_string(),+        1 => crate::tl!("Red").to_string(),+        2 => crate::tl!("Yellow").to_string(),+        3 => crate::tl!("Green").to_string(),+        4 => crate::tl!("Cyan").to_string(),+        5 => crate::tl!("Blue").to_string(),+        6 => crate::tl!("Magenta").to_string(),+        7 => crate::tl!("White").to_string(),+        n => format!("{} {n}", crate::tl!("Color")),+    }+}++/// The usual CAD name for a lineweight value (CELWEIGHT, hundredths of a millimetre).+pub fn lineweight_name(v: i64) -> String {+    match v {+        -1 => crate::tl!("ByLayer").to_string(),+        -2 => crate::tl!("ByBlock").to_string(),+        -3 => crate::tl!("Default").to_string(),+        n if n >= 0 => format!("{:.2} mm", n as f64 / 100.0),+        _ => crate::tl!("ByLayer").to_string(),+    }+}++fn list(ui: &mut egui::Ui, app: &CadApp, r: Rect, w: Widget, out: &mut Option<Action>) {+    let t = Tokens::get();+    let (value, cmd) = widget_value(app, w);+    let resp = ui.interact(r, ui.scope_id().with(("rb_list", w as u8)), Sense::click());+    ui.painter().rect_filled(r, 2.0, if resp.hovered() { t.control_hover } else { t.control });+    let mut x = r.left() + 5.0;+    if w == Widget::Layer+        && let Ok(d) = app.session.doc()+        && let Some(l) = d.layers.iter().find(|l| l.name.eq_ignore_ascii_case(&value))+    {+        for icon in [+            if l.on { Icon::Bulb } else { Icon::BulbOff },+            if l.frozen { Icon::Snowflake } else { Icon::Sun },+            if l.locked { Icon::Lock } else { Icon::Unlock },+        ] {+            icons::paint(ui.painter(), Rect::from_min_size(pos2(x, r.center().y - 7.0), vec2(14.0, 14.0)), icon, false);+            x += 16.0;+        }+        crate::layers::swatch(ui.painter(), Rect::from_min_size(pos2(x + 1.0, r.center().y - 5.0), vec2(10.0, 10.0)), l.color);+        x += 16.0;+    }+    let galley = ui.painter().layout_no_wrap(value.clone(), font(), t.text);+    let room = (r.right() - 16.0 - x).max(0.0);+    let clip = Rect::from_min_max(pos2(x, r.top()), pos2(x + room, r.bottom()));+    ui.painter().with_clip_rect(clip).galley(pos2(x, r.center().y - galley.size().y / 2.0), galley, t.text);+    icons::paint(ui.painter(), Rect::from_center_size(pos2(r.right() - 8.0, r.center().y), vec2(9.0, 9.0)), Icon::ChevronDown, false);+    resp.clone().on_hover_text(&value);+    if w == Widget::Layer {+        let layers: Vec<(String, bool, bool, bool, cadcraft_color::Color)> =+            app.session.doc().map(|d| d.layers.iter().map(|l| (l.name.clone(), l.on, l.frozen, l.locked, l.color)).collect()).unwrap_or_default();+        egui::Popup::from_toggle_button_response(&resp)+            .width(r.width().max(220.0))+            .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside)+            .show(|ui| {+                egui::ScrollArea::vertical().max_height(420.0).show(ui, |ui| {+                    for (name, on, frozen, locked, color) in &layers {+                        ui.horizontal(|ui| {+                            if icons::button(ui, if *on { Icon::Bulb } else { Icon::BulbOff }, 18.0, "On/Off", false).clicked() {+                                *out = Some(Action::LayerToggle(name.clone(), "on", !*on));+                            }+                            if icons::button(ui, if *frozen { Icon::Snowflake } else { Icon::Sun }, 18.0, "Freeze/Thaw", false).clicked() {+                                *out = Some(Action::LayerToggle(name.clone(), "frozen", !*frozen));+                            }+                            if icons::button(ui, if *locked { Icon::Lock } else { Icon::Unlock }, 18.0, "Lock/Unlock", false).clicked() {+                                *out = Some(Action::LayerToggle(name.clone(), "locked", !*locked));+                            }+                            let (sr, _) = ui.allocate_exact_size(vec2(12.0, 12.0), Sense::hover());+                            crate::layers::swatch(ui.painter(), sr, *color);+                            if ui.selectable_label(name.eq_ignore_ascii_case(&value), name).clicked() {+                                *out = Some(Action::Layer(name.clone()));+                                ui.close();+                            }+                        });+                    }+                });+            });+    } else if resp.clicked() && known(cmd) {+        *out = Some(Action::Start(cmd));+    }+}++fn draw_cell(ui: &mut egui::Ui, app: &CadApp, cell: &Cell, r: Rect, out: &mut Option<Action>) {+    match cell {+        Cell::Big(cmd, v) => big(ui, r.shrink2(vec2(PAD / 2.0, 0.0)), cmd, v, out),+        Cell::Small(rows) => {+            for (i, (cmd, v)) in rows.iter().enumerate() {+                let rr = Rect::from_min_size(pos2(r.left() + PAD, r.top() + i as f32 * ROW + 1.0), vec2(r.width() - 2.0 * PAD, ROW - 2.0));+                small(ui, rr, cmd, v, out);+            }+        }+        Cell::Icons(cmds) => {+            for (i, cmd) in cmds.iter().enumerate() {+                let (col, row) = (i / 3, i % 3);+                let rr = Rect::from_min_size(+                    pos2(r.left() + col as f32 * (SMALL_ICON + 8.0), r.top() + row as f32 * ROW + 1.0),+                    vec2(SMALL_ICON + 6.0, ROW - 2.0),+                );+                icon_button(ui, rr, cmd, out);+            }+        }+        Cell::Lists(ws, w) => {+            for (i, wd) in ws.iter().enumerate() {+                let rr = Rect::from_min_size(pos2(r.left() + PAD, r.top() + i as f32 * ROW + 2.0), vec2(*w, ROW - 4.0));+                list(ui, app, rr, *wd, out);+            }+        }+        Cell::ListIcons(wd, w, cmds) => {+            list(ui, app, Rect::from_min_size(pos2(r.left() + PAD, r.top() + 2.0), vec2(*w, ROW - 4.0)), *wd, out);+            let per_row = ((*w / (SMALL_ICON + 8.0)).floor() as usize).max(1);+            for (i, cmd) in cmds.iter().enumerate() {+                let (row, col) = (1 + i / per_row, i % per_row);+                let rr = Rect::from_min_size(+                    pos2(r.left() + PAD + col as f32 * (SMALL_ICON + 8.0), r.top() + row as f32 * ROW + 1.0),+                    vec2(SMALL_ICON + 6.0, ROW - 2.0),+                );+                icon_button(ui, rr, cmd, out);+            }+        }+    }+}++/// Every command of a panel, for its collapsed button's menu.+fn panel_commands(panel: &PanelDef) -> Vec<Cmd> {+    let mut v = Vec::new();+    for cell in panel.cells {+        match cell {+            Cell::Big(cmd, _) => v.push(*cmd),+            Cell::Small(rows) => v.extend(rows.iter().map(|(cmd, _)| *cmd)),+            Cell::Icons(cmds) => v.extend(cmds.iter().copied()),+            Cell::Lists(..) => {}+            Cell::ListIcons(_, _, cmds) => v.extend(cmds.iter().copied()),+        }+    }+    for cmd in panel.more {+        if !v.iter().any(|existing| existing.id == cmd.id) {+            v.push(*cmd);+        }+    }+    v+}++/// The ribbon (tab row and the active tab's panels).+pub fn ribbon(app: &mut CadApp, ui: &mut egui::Ui) {+    let t = Tokens::get();+    let h = if app.ui.ribbon_minimized { TAB_ROW + 2.0 } else { HEIGHT };+    let mut out: Option<Action> = None;+    egui::Panel::top("cc_ribbon").exact_size(h).frame(egui::Frame::NONE.fill(t.chrome)).show(ui, |ui| {+        let r = ui.max_rect();+        // Tabs.+        let mut x = r.left() + 8.0;+        let active = TABS.iter().find(|tab| tab.id == app.ui.ribbon_tab).map_or("home", |tab| tab.id);+        for tab in TABS {+            let label = tab_label(tab.title);+            let w = ui.painter().layout_no_wrap(label.clone(), crate::theme::body(), t.text).size().x + 18.0;+            let tr = Rect::from_min_size(pos2(x, r.top()), vec2(w, TAB_ROW));+            let resp = ui.interact(tr, ui.scope_id().with(("rb_tab", tab.id)), Sense::click());+            let is_active = tab.id == active;+            if is_active && !app.ui.ribbon_minimized {+                ui.painter().rect_filled(tr, egui::CornerRadius { nw: 3, ne: 3, sw: 0, se: 0 }, t.panel);+            } else if resp.hovered() {+                ui.painter().rect_filled(tr, 3.0, t.control_hover.gamma_multiply(0.5));+            }+            ui.painter().text(tr.center(), Align2::CENTER_CENTER, label, crate::theme::body(), if is_active { t.text } else { t.text_dim });+            icons::describe_control(ui, &resp, tab.title, Some(is_active), true);+            if resp.double_clicked() {+                out = Some(Action::ToggleMinimized);+            } else if resp.clicked() {+                out = Some(Action::Tab(tab.id));+            }+            x += w + 2.0;+        }+        if app.ui.ribbon_minimized {+            return;+        }+        // Body.+        let body = Rect::from_min_max(pos2(r.left(), r.top() + TAB_ROW), r.max);+        ui.painter().rect_filled(body, 0.0, t.panel);+        ui.painter().hline(body.x_range(), body.bottom() - 0.5, Stroke::new(1.0, t.border));+        let Some(tab) = TABS.iter().find(|tab| tab.id == active) else { return };+        let full: Vec<f32> = tab.panels.iter().map(|p| panel_width(ui, p)).collect();+        let collapsed: Vec<f32> = tab.panels.iter().map(|p| collapsed_width(ui, p)).collect();+        let expanded = fit(&full, &collapsed, body.width() - 12.0);+        let mut x = body.left() + 6.0;+        for (i, panel) in tab.panels.iter().enumerate() {+            let open = expanded.get(i).copied().unwrap_or(true);+            let w = if open { full.get(i).copied().unwrap_or(0.0) } else { collapsed.get(i).copied().unwrap_or(0.0) };+            let pr = Rect::from_min_size(pos2(x, body.top() + 2.0), vec2(w, BODY + TITLE));+            if open {+                let mut cx = pr.left() + PAD;+                for cell in panel.cells {+                    let cw = cell_width(ui, cell);+                    draw_cell(ui, app, cell, Rect::from_min_size(pos2(cx, pr.top()), vec2(cw, BODY)), &mut out);+                    cx += cw;+                }+                // Title row (with the expander when the panel has more commands).+                let title_r = Rect::from_min_max(pos2(pr.left(), pr.bottom() - TITLE), pr.max);+                let label = crate::i18n::ribbon(panel.title);+                if panel.more.is_empty() {+                    ui.painter().text(title_r.center(), Align2::CENTER_CENTER, label, font(), t.text_dim);+                } else {+                    let resp = ui.interact(title_r, ui.scope_id().with(("rb_more", tab.id, panel.title)), Sense::click());+                    hover_fill(ui, title_r, &resp);+                    let tw = text_w(ui, panel.title);+                    ui.painter().text(pos2(title_r.center().x - 6.0, title_r.center().y), Align2::CENTER_CENTER, label, font(), t.text_dim);+                    icons::paint(+                        ui.painter(),+                        Rect::from_center_size(pos2(title_r.center().x + tw / 2.0 + 2.0, title_r.center().y), vec2(8.0, 8.0)),+                        Icon::ChevronDown,+                        false,+                    );+                    icons::describe_control(ui, &resp, panel.title, None, false);+                    egui::Popup::menu(&resp).show(|ui| variant_menu(ui, panel.more, &mut out));+                }+            } else {+                let resp = ui.interact(pr, ui.scope_id().with(("rb_collapsed", tab.id, panel.title)), Sense::click());+                hover_fill(ui, pr, &resp);+                let first = panel_commands(panel).into_iter().next();+                if let Some(cmd) = &first {+                    paint_icon(+                        ui.painter(),+                        Rect::from_center_size(pos2(pr.center().x, pr.top() + 6.0 + BIG_ICON / 2.0), vec2(BIG_ICON, BIG_ICON)),+                        cmd,+                        false,+                    );+                }+                ui.painter().text(+                    pos2(pr.center().x, pr.top() + BIG_ICON + 14.0),+                    Align2::CENTER_TOP,+                    crate::i18n::ribbon(panel.title),+                    font(),+                    t.text,+                );+                icons::paint(ui.painter(), Rect::from_center_size(pos2(pr.center().x, pr.bottom() - 10.0), vec2(9.0, 9.0)), Icon::ChevronDown, false);+                icons::describe_control(ui, &resp, panel.title, None, false);+                let cmds = panel_commands(panel);+                egui::Popup::menu(&resp).show(|ui| variant_menu(ui, &cmds, &mut out));+            }+            x += w;+            // Panel separator.+            ui.painter().vline(x, (pr.top() + 4.0)..=(pr.bottom() - 4.0), Stroke::new(1.0, t.border));+            x += 1.0;+        }+    });+    match out {+        Some(Action::Start(id)) => app.start(id),+        Some(Action::Tab(id)) => {+            app.ui.ribbon_tab = id.to_string();+            app.ui.ribbon_minimized = false;+        }+        Some(Action::ToggleMinimized) => app.ui.ribbon_minimized = !app.ui.ribbon_minimized,+        Some(Action::Layer(name)) => {+            let _ = app.run("layer.current", json!({ "name": name }));+        }+        Some(Action::LayerToggle(name, key, value)) => {+            let _ = app.run("layer.set", json!({ "name": name, key: value }));+        }+        None => {}+    }+}++/// Switch to the Windows layout: Ribbon, quick access tool bar in the title row,+/// Properties docked left, no Tool Sets or Layers palette.+pub fn apply_pc_layout(app: &mut CadApp) {+    let workspace = crate::docking::pc_workspace();+    crate::docking::publish_visibility(app, &workspace);+    app.ui.docking = workspace;+    app.ui.show_ribbon = true;+    app.ui.show_toolbar = false;+    app.ui.pc_layout_applied = true;+}++/// Back to CADCraft's Mac layout: Tool Bar, Tool Sets, Layers and Properties+/// palettes on the right, no Ribbon.+pub fn apply_mac_layout(app: &mut CadApp) {+    let workspace = crate::docking::default_workspace();+    crate::docking::publish_visibility(app, &workspace);+    app.ui.docking = workspace;+    app.ui.show_ribbon = false;+    app.ui.show_toolbar = true;+    app.ui.pc_layout_applied = true;+}++#[cfg(test)]+mod tests {+    use super::*;++    #[test]+    fn collapsed_layers_menu_keeps_each_command_once_in_display_order() {+        let layers = TABS.iter().find(|tab| tab.id == "home").unwrap().panels.iter().find(|panel| panel.title == "Layers").unwrap();+        let ids: Vec<_> = panel_commands(layers).iter().map(|cmd| cmd.id).collect();+        assert_eq!(+            ids,+            [+                "ui.dialog.layers",+                "layoff",+                "layiso",+                "layfrz",+                "laylck",+                "layon",+                "layuniso",+                "laythw",+                "layulk",+                "layerp",+                "laymcur",+                "laymch",+                "copytolayer",+                "laywalk",+                "laymrg",+                "laydel",+                "layvpi",+                "layerstate",+            ]+        );+    }++    #[test]+    fn panels_collapse_from_the_right() {+        let full = [100.0, 100.0, 100.0];+        let collapsed = [40.0, 40.0, 40.0];+        assert_eq!(fit(&full, &collapsed, 400.0), vec![true, true, true]);+        assert_eq!(fit(&full, &collapsed, 250.0), vec![true, true, false]);+        assert_eq!(fit(&full, &collapsed, 190.0), vec![true, false, false]);+        assert_eq!(fit(&full, &collapsed, 10.0), vec![false, false, false]);+    }++    #[test]+    fn tabs_match_the_windows_layout_order() {+        let titles: Vec<&str> = TABS.iter().map(|t| t.title).collect();+        assert_eq!(titles, ["Home", "Insert", "Annotate", "Parametric", "View", "Manage", "Output", "Collaborate"]);+        let home: Vec<&str> = TABS[0].panels.iter().map(|p| p.title).collect();+        assert_eq!(home, ["Draw", "Modify", "Annotation", "Layers", "Block", "Properties", "Groups", "Utilities", "Clipboard"]);+    }++    /// The everyday drafting commands of the Home tab are all real commands (not greyed out).+    #[test]+    fn home_tab_core_commands_exist() {+        for id in [+            "line",+            "pline",+            "circle",+            "arc",+            "rectang",+            "ellipse",+            "hatch",+            "move",+            "copy",+            "stretch",+            "rotate",+            "mirror",+            "scale",+            "trim",+            "fillet",+            "arrayrect",+            "erase",+            "explode",+            "offset",+            "mtext",+            "dim",+            "dimlinear",+            "mleader",+            "table",+            "insert",+            "block",+            "matchprop",+            "laymcur",+            "layoff",+            "layiso",+            "layfrz",+            "laylck",+        ] {+            assert!(known(id), "{id} is not a command");+        }+        for v in CIRCLES.iter().chain(ARCS).chain(ELLIPSES) {+            assert!(known(v.id), "{} is not a command", v.id);+        }+    }++    #[test]+    fn colour_and_lineweight_names() {+        assert_eq!(lineweight_name(30), "0.30 mm");+        assert_eq!(lineweight_name(-1), "ByLayer");+        assert_eq!(color_name(4), "Cyan");+        assert_eq!(color_name(42), "Color 42");+    }+}diff --git a/crates/ui-egui/tests/it/chrome_geometry.rs b/crates/ui-egui/tests/it/chrome_geometry.rs--- a/crates/ui-egui/tests/it/chrome_geometry.rs+++ b/crates/ui-egui/tests/it/chrome_geometry.rs@@ -8,9 +8,11 @@ use egui::{Rect, Shape, vec2}; use egui_kittest::Harness; use egui_kittest::kittest::Queryable; -fn harness(width: f32, height: f32, scale: f32, message: bool) -> Harness<'static, CadApp> {+/// `pc`: the Windows layout (Ribbon); else the Mac layout (Tool Bar).+fn harness(width: f32, height: f32, scale: f32, message: bool, pc: bool) -> Harness<'static, CadApp> {     let mut app = CadApp::new(Session::default(), Services::default());     app.run("new", serde_json::json!({})).unwrap();+    app.run(if pc { "ui.layout.pc" } else { "ui.layout.mac" }, serde_json::json!({})).unwrap();     app.ui.start_tab = false;     app.ui.in_window_menu = false;     app.integrated_titlebar = true;@@ -46,11 +48,11 @@ fn visible_text(shape: &Shape, clip: Rect, result: &mut Vec<(String, Rect)>) {     } } -fn check_status_labels_case(width: f32, height: f32, scale: f32, message: bool, overlaps: &mut Vec<String>) {-    let mut h = harness(width, height, scale, message);+fn check_status_labels_case(width: f32, height: f32, scale: f32, message: bool, pc: bool, overlaps: &mut Vec<String>) {+    let mut h = harness(width, height, scale, message, pc);     if let Ok(directory) = std::env::var("CRAFT_UI_VISUAL_DIR") {         std::fs::create_dir_all(&directory).unwrap();-        h.render().unwrap().save(format!("{directory}/cad-{width}-{scale}-{message}.png")).unwrap();+        h.render().unwrap().save(format!("{directory}/cad-{width}-{scale}-{message}-{}.png", if pc { "pc" } else { "mac" })).unwrap();     }     let mut texts = Vec::new();     for shape in &h.output().shapes {@@ -76,8 +78,8 @@ fn status_labels_never_overlap_at_standard_and_compact_sizes() {     let mut overlaps = Vec::new();     for (width, height) in [(1200.0, 760.0), (900.0, 560.0)] {         for scale in [1.0, 2.0] {-            for message in [false, true] {-                check_status_labels_case(width, height, scale, message, &mut overlaps);+            for (message, pc) in [(false, false), (true, false), (false, true), (true, true)] {+                check_status_labels_case(width, height, scale, message, pc, &mut overlaps);             }         }     }@@ -87,7 +89,7 @@ fn status_labels_never_overlap_at_standard_and_compact_sizes() { #[test] fn layout_tabs_stay_anchored_when_toolsets_toggle_resize_or_move() {     for scale in [1.0, 2.0] {-        let mut h = harness(1200.0, 760.0, scale, false);+        let mut h = harness(1200.0, 760.0, scale, false, false);         let tab_rects =             |h: &Harness<'_, CadApp>| ["Model", "Layout1", "Layout2"].map(|name| h.get_by_role_and_label(egui::accesskit::Role::Tab, name).rect());         let original = tab_rects(&h);@@ -114,7 +116,7 @@ fn layout_tabs_stay_anchored_when_toolsets_toggle_resize_or_move() { #[test] fn layout_tabs_and_add_button_work_with_toolsets_shown_or_hidden() {     for visible in [true, false] {-        let mut h = harness(1400.0, 760.0, 1.0, false);+        let mut h = harness(1400.0, 760.0, 1.0, false, false);         h.state_mut().run("ui.toggle.toolsets", serde_json::json!({"on":visible})).unwrap();         h.run_steps(5);         h.get_by_role_and_label(egui::accesskit::Role::Tab, "Layout1").click();@@ -137,7 +139,7 @@ fn layout_tabs_and_add_button_work_with_toolsets_shown_or_hidden() {  #[test] fn compact_toolbar_overflow_keeps_help_reachable() {-    let mut h = harness(900.0, 560.0, 1.0, false);+    let mut h = harness(900.0, 560.0, 1.0, false, false);     h.get_by_label("More toolbar commands").click();     h.run_steps(3);     h.get_by_label("Help").click();@@ -147,8 +149,54 @@ fn compact_toolbar_overflow_keeps_help_reachable() {  #[test] fn toolbar_plot_preview_opens_dialog() {-    let mut h = harness(1200.0, 760.0, 1.0, false);+    let mut h = harness(1200.0, 760.0, 1.0, false, false);     h.get_by_label("Plot Preview").click();     h.run_steps(3);     assert_eq!(h.state().ui.dialog.as_deref(), Some("preview")); }++#[test]+fn ribbon_output_tab_preview_opens_dialog() {+    let mut h = harness(1200.0, 760.0, 1.0, false, true);+    h.get_by_label("Output").click();+    h.run_steps(3);+    assert_eq!(h.state().ui.ribbon_tab, "output");+    h.get_by_label("Preview").click();+    h.run_steps(3);+    assert_eq!(h.state().ui.dialog.as_deref(), Some("preview"));+}++#[test]+fn ribbon_home_tab_starts_line() {+    let mut h = harness(1200.0, 760.0, 1.0, false, true);+    h.get_by_label("Line").click();+    h.run_steps(3);+    assert!(h.state().session.running.is_some(), "LINE should be prompting");+}++#[test]+fn the_in_window_menu_bar_sits_above_the_ribbon() {+    let mut h = harness(1200.0, 760.0, 1.0, false, true);+    // Windows and Linux draw the menu bar in the window (macOS uses the system one).+    h.state_mut().ui.in_window_menu = true;+    h.run_steps(5);+    let mut texts = Vec::new();+    for shape in &h.output().shapes {+        visible_text(&shape.shape, shape.clip_rect, &mut texts);+    }+    let top = |label: &str| texts.iter().find(|(text, _)| text == label).map(|(_, rect)| rect.top()).unwrap_or_else(|| panic!("no {label:?} drawn"));+    // The menu bar's File, then the Ribbon's Home tab under it.+    assert!(top("File") < top("Home"), "File at {}, Home at {}", top("File"), top("Home"));+}++#[test]+fn the_windows_layout_has_no_collapsed_tool_sets_strip() {+    // The Ribbon holds the tools: no 22 px Tool Sets strip beside the Properties palette.+    let h = harness(1200.0, 760.0, 1.0, false, true);+    assert!(h.query_by_label("Expand Tool Sets").is_none());+    // The Mac layout with Tool Sets hidden keeps its strip to bring them back.+    let mut h = harness(1200.0, 760.0, 1.0, false, false);+    h.state_mut().ui.show_toolsets = false;+    h.run_steps(3);+    assert!(h.query_by_label("Expand Tool Sets").is_some());+}diff --git a/docs/ui-parity.md b/docs/ui-parity.md--- a/docs/ui-parity.md+++ b/docs/ui-parity.md@@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (distinct polyline midpoint grips) · **Target:** Autodesk AutoCAD 2027 for Mac (26.0)+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-27 · **Change:** minor (collapsed ribbon menu duplicates) · **Target:** Autodesk AutoCAD 2027 for Mac (26.0)  How CADCraft's interaction compares with AutoCAD's: point entry, object snaps, tracking, dynamic input, grips, selection, the command line, chrome and navigation. A drafter's speed in AutoCAD@@ -203,6 +203,7 @@ down-arrow options menu (DYNPROMPT), and DYNDIM.  | Date | Change | Summary | |---|---|---|+| 2026-10-27 | minor | Collapsed ribbon panels omit overflow commands already present in their cells, preserving first-occurrence order and unique overflow actions | | 2026-10-22 | minor | Polyline segment midpoint grips use rectangles, distinct from square vertex grips, with matching hover and pick bounds (#1950) | | 2026-10-13 | minor | Filled-region highlight boundaries are traversed in deterministic coordinate order, fixing dash-phase jumps on CPU hover/selection redraws and GPU highlight rebuilds; CPU dash-position, pixel and GPU mesh regressions | | 2026-10-14 | minor | Selection cycling (#1143): the list and badge of SELECTIONCYCLING, Shift+Space through overlapping objects (#1145) |diff --git a/xtask/src/parity.rs b/xtask/src/parity.rs--- a/xtask/src/parity.rs+++ b/xtask/src/parity.rs@@ -136,7 +136,9 @@ mod tests {         assert_eq!(day(20_736), "2026-10-10");         assert_eq!(date(std::time::UNIX_EPOCH - std::time::Duration::from_secs(86_400)), "1970-01-01");         // Past chrono's range keeps the proleptic Gregorian date, not the epoch (old `civil_from_days`).-        assert_eq!(day(115_740_740), "318857-05-20");+        // Checked on the day count: Windows' SystemTime ends in the year 30828, so building that+        // SystemTime panics there.+        assert_eq!(civil_from_days(115_740_740), (318_857, 5, 20));         // Windows' SystemTime can't hold u64::MAX / 2 seconds, so it is only checked where it exists.         if let Some(far) = std::time::UNIX_EPOCH.checked_add(std::time::Duration::from_secs(u64::MAX / 2)) {             assert_eq!(date(far), "292277026596-12-04");diff --git a/xtask/src/stats.rs b/xtask/src/stats.rs--- a/xtask/src/stats.rs+++ b/xtask/src/stats.rs@@ -134,7 +134,7 @@ pub fn run(_root: &Path, exact: bool) -> Result<(), String> {  #[cfg(test)] mod tests {-    use super::{Counts, count_tests, walk};+    use super::{Counts, count_tests};      #[test]     fn counts_attributes_and_proptest_blocks() {@@ -155,7 +155,7 @@ mod tests {         // A directory symlink pointing back at `real` would be re-scanned if followed.         symlink(&real, base.join("alias")).unwrap();         let mut c = Counts::default();-        walk(&base, &mut c).unwrap();+        super::walk(&base, &mut c).unwrap();         assert_eq!(c.files, 1, "the aliased directory was followed and counted twice");         std::fs::remove_dir_all(&base).ok();     }