collective

Browse / github.com/storytold/cadcraft / patch

fix(ui): apply ribbon layer choices to selected objects

fix(ui): apply ribbon layer choices to selected objects ## Change CC-039: focused extraction of the historical selection-aware ribbon layer change. The optional ribbon layer picker changes the current layer even when objects are selected. Show the common selected-object layer, or a blank value for mixed layers, and apply choices through properties.set. With no selection, retain layer.current. Disable reassignment of locked-layer selections and freezing of the current layer. ## Dependencies and overlap - Requires original ribbon #1023 (a3644ea) and the original Windows validation prerequisite #2198 (a2dd814), retained through 1850cd7. Or

Status
open
Score
0.00
Adopters
0 other operators
Author
smacdmac101 (imported from pull request #2271, open)
Base
d864a08b013353143086bd8bb568c26cb6256796
Size
+1780 −19 in 16 files
Review
pending
Shared
2026-10-28
Id
4aa254915d3d6785781370fe

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 crates/ui-egui/tests/it/main.rs crates/ui-egui/tests/it/ribbon_layers.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,7 @@ 	Check for Updates…	Перевірити оновлення… 	CADCraft is up to date.	Встановлено найновішу версію CADCraft. 	Could not check for updates.	Не вдалося перевірити оновлення.+	Ribbon	Стрічка+	Windows Layout (Ribbon)	Розкладка Windows (стрічка)+	Mac Layout (Tool Sets)	Розкладка Mac (набори інструментів)+	Ribbon Layer	Шар стрічки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,1290 @@+//! 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 => {+            let selection = app.session.selection();+            let value = if selection.is_empty() {+                h.str("CLAYER", "0")+            } else {+                let mut names = selection.iter().filter_map(|handle| d.entity(*handle).map(|entity| entity.common.layer.as_str()));+                names.next().filter(|first| names.all(|name| name.eq_ignore_ascii_case(first))).unwrap_or_default().to_string()+            };+            (value, "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());+    if w == Widget::Layer {+        resp.widget_info(|| {+            let mut info = egui::WidgetInfo::labeled(egui::Role::ComboBox, ui.is_enabled(), crate::tl!("Ribbon Layer"));+            info.current_text_value = Some(value.clone());+            info+        });+    }+    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 {+        egui::Popup::from_toggle_button_response(&resp)+            .width(r.width().max(220.0))+            .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside)+            .show(|ui| {+                let selection = app.session.selection();+                let editable = app.session.doc().is_ok_and(|doc| {+                    selection+                        .iter()+                        .all(|handle| doc.entity(*handle).is_some_and(|entity| doc.layer(&entity.common.layer).is_none_or(|layer| !layer.locked)))+                });+                let current = app.session.doc().map(|doc| doc.header.str("CLAYER", "0")).unwrap_or_default();+                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::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 ui+                                .add_enabled_ui(!name.eq_ignore_ascii_case(&current), |ui| {+                                    icons::button(ui, if *frozen { Icon::Snowflake } else { Icon::Sun }, 18.0, "Freeze/Thaw", false)+                                })+                                .inner+                                .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.add_enabled_ui(editable, |ui| ui.selectable_label(name.eq_ignore_ascii_case(&value), name)).inner.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()),+        }+    }+    v.extend(panel.more.iter().copied());+    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 selection = app.session.selection();+            let result = if selection.is_empty() {+                app.run("layer.current", json!({ "name": name }))+            } else {+                app.run("properties.set", json!({ "handles": selection.iter().map(|handle| handle.hex()).collect::<Vec<_>>(), "layer": name }))+            };+            if let Err(error) = result {+                app.set_status(error.to_string());+            }+        }+        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 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/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs--- a/crates/ui-egui/tests/it/main.rs+++ b/crates/ui-egui/tests/it/main.rs@@ -24,6 +24,7 @@ mod parameters_rename; mod pickdrag; mod properties_units; mod rapid_clicks;+mod ribbon_layers; mod selection_cycling; mod shared_controls; mod shortcut_menus;diff --git a/crates/ui-egui/tests/it/ribbon_layers.rs b/crates/ui-egui/tests/it/ribbon_layers.rsnew file mode 100644--- /dev/null+++ b/crates/ui-egui/tests/it/ribbon_layers.rs@@ -0,0 +1,108 @@+//! Reference workflow: current/selected/mixed layer control with undo and command isolation.+use cadcraft_engine::{Input, Session, doc::Handle, geom::Vec2};+use cadcraft_ui_egui::{CadApp, Services};+use egui::vec2;+use egui_kittest::{+    Harness,+    kittest::{NodeT, Queryable},+};+use serde_json::json;++fn harness() -> Harness<'static, CadApp> {+    let mut app = CadApp::new(Session::default(), Services::default());+    app.run("new", json!({})).unwrap();+    app.run("ui.layout.pc", json!({})).unwrap();+    app.ui.start_tab = false;+    app.run("layer.new", json!({"name":"CC_A","color":1})).unwrap();+    app.run("layer.new", json!({"name":"CC_B","color":3})).unwrap();+    let mut h = Harness::builder().with_size(vec2(1800.0, 1000.0)).build_ui_state(+        |ui, app: &mut CadApp| {+            app.logic(ui.ctx());+            app.ui(ui);+        },+        app,+    );+    h.run_steps(4);+    h+}+fn pick(h: &mut Harness<'_, CadApp>, name: &str) {+    h.get_by_label("Ribbon Layer").click();+    h.run_steps(4);+    h.get_by_label(name).click();+    h.run_steps(4);+}+fn line(h: &mut Harness<'_, CadApp>, y: i32) -> Handle {+    let v = h.state_mut().run("line", json!({"points":[[0,y],[100,y]]})).unwrap();+    Handle::parse_hex(v["handles"][0].as_str().unwrap()).unwrap()+}+fn current(h: &Harness<'_, CadApp>) -> String {+    h.state().session.doc().unwrap().header.str("CLAYER", "0")+}+#[test]+fn current_and_selected_layer_changes_are_distinct_and_undoable() {+    let mut h = harness();+    pick(&mut h, "CC_A");+    assert_eq!(current(&h), "CC_A");+    assert_eq!(h.get_by_label("Ribbon Layer").accesskit_node().value().as_deref(), Some("CC_A"));+    let a = line(&mut h, 0);+    pick(&mut h, "CC_B");+    let b = line(&mut h, 40);+    pick(&mut h, "0");+    h.state_mut().session.set_selection(vec![a, b]);+    h.run_steps(4);+    // Mixed layers have no selected row; choosing one edits both objects, not CLAYER.+    h.get_by_label("Ribbon Layer").click();+    h.run_steps(4);+    assert_eq!(h.get_by_label("Ribbon Layer").accesskit_node().value().unwrap_or_default(), "");+    h.get_by_label("CC_A").click();+    h.run_steps(4);+    assert_eq!(current(&h), "0");+    for handle in [a, b] {+        assert_eq!(h.state().session.doc().unwrap().entity(handle).unwrap().common.layer, "CC_A");+    }+    assert_eq!(h.state().session.selection(), vec![a, b]);+    h.state_mut().run("undo", json!({})).unwrap();+    assert_eq!(h.state().session.doc().unwrap().entity(b).unwrap().common.layer, "CC_B");+    assert_eq!(h.state().session.doc().unwrap().entity(a).unwrap().common.layer, "CC_A");+    assert_eq!(current(&h), "0");+    h.state_mut().run("redo", json!({})).unwrap();+    assert_eq!(h.state().session.doc().unwrap().entity(b).unwrap().common.layer, "CC_A");+}+#[test]+fn layer_picker_during_line_does_not_cancel_or_supply_a_point() {+    let mut h = harness();+    h.state_mut().session.start("line").unwrap();+    h.state_mut().session.input(Input::Point(Vec2::new(0.0, 0.0))).unwrap();+    h.run_steps(3);+    pick(&mut h, "CC_B");+    assert_eq!(h.state().session.running.as_ref().unwrap().id, "line");+    assert_eq!(current(&h), "CC_B");+    h.state_mut().session.input(Input::Point(Vec2::new(100.0, 0.0))).unwrap();+    let doc = h.state().session.doc().unwrap();+    let entities = doc.space(&cadcraft_engine::doc::Space::Model).unwrap();+    assert_eq!(entities.len(), 1);+    assert_eq!(entities.iter().next().unwrap().common.layer, "CC_B");+}+#[test]+fn layer_state_buttons_preserve_current_layer_and_locked_objects_are_not_reassigned() {+    let mut h = harness();+    pick(&mut h, "CC_A");+    let a = line(&mut h, 0);+    pick(&mut h, "0");+    h.state_mut().run("layer.set", json!({"name":"CC_A","locked":true})).unwrap();+    h.state_mut().session.set_selection(vec![a]);+    h.run_steps(4);+    assert_eq!(h.get_by_label("Ribbon Layer").accesskit_node().value().as_deref(), Some("CC_A"));+    h.get_by_label("Ribbon Layer").click();+    h.run_steps(4);+    assert!(h.get_by_label("CC_B").accesskit_node().is_disabled());+    assert!(h.get_all_by_label("Freeze/Thaw").next().unwrap().accesskit_node().is_disabled());+    let row_y = h.get_all_by_label("CC_A").find(|node| node.accesskit_node().is_disabled()).unwrap().rect().center().y;+    h.get_all_by_label("Lock/Unlock").find(|node| (node.rect().center().y - row_y).abs() < 2.0).unwrap().click();+    h.run_steps(4);+    assert!(!h.state().session.doc().unwrap().layer("CC_A").unwrap().locked);+    h.get_by_label("CC_B").click();+    h.run_steps(4);+    assert_eq!(h.state().session.doc().unwrap().entity(a).unwrap().common.layer, "CC_B");+    assert_eq!(current(&h), "0");+}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-26 · **Change:** minor (selection-aware ribbon layer control) · **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@@ -161,6 +161,7 @@ down-arrow options menu (DYNPROMPT), and DYNDIM. | Tool Sets palette (Drafting, Modeling, … with flyouts) | yes | yes for 2D groups | | Properties Inspector, Quick Properties | yes | yes (every control acts or is visibly unavailable, #182); block references show read-only Position X/Y/Z from their stored insertion point; coordinate editing is still missing | | Layer Properties Manager (filters, states, VP overrides) | yes | yes |+| Ribbon layer selection | Windows ribbon: current or selected-object layers | The optional Windows ribbon shows the current layer with no selection, a shared selected-object layer, or a blank mixed value. Choosing a layer edits selected objects through Properties without changing the current layer. Locked-layer selections must be unlocked before reassignment. | | Blocks palette, Content | yes | Blocks window (click to insert, #28) | | Status bar toggles | ~25 | most toggles; Annotation Visibility and Workspace do nothing | | ViewCube | interactive 3D | clickable for 2D (Top/Home → extents); dragging does nothing (#7) |@@ -203,6 +204,7 @@ down-arrow options menu (DYNPROMPT), and DYNDIM.  | Date | Change | Summary | |---|---|---|+| 2026-10-26 | minor | Selection-aware ribbon layer display and edits reuse layer/property commands and Undo. No layout or drawing schema change. | | 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();     }