Browse / github.com/storytold/cadcraft / patch
fix(ui): deduplicate collapsed ribbon menu commands
fix(ui): deduplicate collapsed ribbon menu commands ## Change When Home > Layers collapses, its menu repeats Turn All Layers On, Unisolate and Thaw All Layers because those commands belong to both visible cells and the overflow list. Append overflow entries only when their command ID is not already present, preserving order and every distinct command. Focused commit: c2c5e62836c0eb44f8087b3c3413c7ec81965aa8 (two files, 36 insertions / 2 deletions). This isolates the source-verified finding in review comment 4386206898 on #2281. ## Dependencies and overlap - Requires ribbon foundation #1023 and Windows validation prerequisite #2198, integrat
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- smacdmac101 (imported from pull request #2288, open)
- Base
d864a08b013353143086bd8bb568c26cb6256796- Size
- +1662 −19 in 14 files
- Review
- pending
- Shared
- 2026-10-28
- Id
66a9a0f57eaeb007f020df25
How it went
No other operator has reported using it yet.
Diff
raw · apps/cadcraft/src/workspace.rs crates/ui-egui/src/chrome.rs crates/ui-egui/src/docking.rs crates/ui-egui/src/i18n.rs crates/ui-egui/src/i18n/fr.tsv crates/ui-egui/src/i18n/tests.rs crates/ui-egui/src/i18n/uk.tsv crates/ui-egui/src/lib.rs crates/ui-egui/src/menus.rs crates/ui-egui/src/ribbon.rs crates/ui-egui/tests/it/chrome_geometry.rs docs/ui-parity.md xtask/src/parity.rs xtask/src/stats.rs
@@ -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};@@ -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));@@ -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); }@@ -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)) }@@ -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)@@ -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);@@ -1371,3 +1371,6 @@ Check for Updates… Перевірити оновлення… CADCraft is up to date. Встановлено найновішу версію CADCraft. Could not check for updates. Не вдалося перевірити оновлення.+ Ribbon Стрічка+ Windows Layout (Ribbon) Розкладка Windows (стрічка)+ Mac Layout (Tool Sets) Розкладка Mac (набори інструментів)@@ -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| {@@ -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)new file mode 100644@@ -0,0 +1,1283 @@+//! The Ribbon: a Windows-style tabbed command bar, with tabs, panels and buttons where 2D+//! drafting users on Windows expect them (observed black-box). Every button runs a command (`app.start`); a command+//! CADCraft doesn't have yet shows greyed out in its place, so positions stay where users expect+//! them. Icons are CADCraft's own (`icons.rs`); a command without one shows its initial.+//!+//! Panels that don't fit the window collapse from the right into a single button that opens the+//! panel's commands as a menu, as Windows CAD ribbons do.++use egui::{Align2, Color32, FontId, Rect, Sense, Stroke, pos2, vec2};+use serde_json::json;++use crate::CadApp;+use crate::icons::{self, Icon};+use crate::theme::Tokens;++/// One ribbon command: id (as `app.start` takes it), label (English source, translated by the+/// interface catalog) and icon.+#[derive(Clone, Copy)]+pub struct Cmd {+ pub id: &'static str,+ pub label: &'static str,+ pub icon: Option<Icon>,+}++const fn c(id: &'static str, label: &'static str, icon: Icon) -> Cmd {+ Cmd { id, label, icon: Some(icon) }+}+const fn n(id: &'static str, label: &'static str) -> Cmd {+ Cmd { id, label, icon: None }+}++/// A current-value list in a panel (layer, colour, styles…).+#[derive(Clone, Copy, PartialEq, Eq, Debug)]+pub enum Widget {+ Layer,+ Color,+ Lineweight,+ Linetype,+ TextStyle,+ DimStyle,+ MleaderStyle,+ TableStyle,+ NamedView,+}++/// A column of a panel.+pub enum Cell {+ /// Large button (icon above, label below); `variants` open from its lower half (▾).+ Big(Cmd, &'static [Cmd]),+ /// Up to three small buttons (icon, label to the right), each with optional variants.+ Small(&'static [(Cmd, &'static [Cmd])]),+ /// Icon-only buttons, three per column.+ Icons(&'static [Cmd]),+ /// Current-value lists stacked in one column of the given width.+ Lists(&'static [Widget], f32),+ /// A list on the first row and icon buttons on the two rows under it (the Layers panel).+ ListIcons(Widget, f32, &'static [Cmd]),+}++pub struct PanelDef {+ pub title: &'static str,+ pub cells: &'static [Cell],+ /// Commands under the panel title's ▾ (the expanded panel).+ pub more: &'static [Cmd],+}++pub struct TabDef {+ pub id: &'static str,+ pub title: &'static str,+ pub panels: &'static [PanelDef],+}++// ------------------------------------------------------------------------------------------+// Tabs (the Windows 2D drafting layout; 3D tabs are left out on purpose)+// ------------------------------------------------------------------------------------------++const CIRCLES: &[Cmd] = &[+ c("circle", "Center, Radius", Icon::Circle),+ n("circle.cd", "Center, Diameter"),+ n("circle.2p", "2-Point"),+ n("circle.3p", "3-Point"),+ n("circle.ttr", "Tan, Tan, Radius"),+ n("circle.ttt", "Tan, Tan, Tan"),+];+const ARCS: &[Cmd] = &[+ c("arc", "3-Point", Icon::Arc),+ n("arc.sce", "Start, Center, End"),+ n("arc.sca", "Start, Center, Angle"),+ n("arc.scl", "Start, Center, Length"),+ n("arc.sea", "Start, End, Angle"),+ n("arc.sed", "Start, End, Direction"),+ n("arc.ser", "Start, End, Radius"),+ n("arc.cse", "Center, Start, End"),+ n("arc.csa", "Center, Start, Angle"),+ n("arc.csl", "Center, Start, Length"),+ n("arc.continue", "Continue"),+];+const RECTS: &[Cmd] = &[c("rectang", "Rectangle", Icon::Rectangle), c("polygon", "Polygon", Icon::Polygon)];+const ELLIPSES: &[Cmd] = &[c("ellipse", "Center", Icon::Ellipse), n("ellipse.axis", "Axis, End"), n("ellipse.arc", "Elliptical Arc")];+const HATCHES: &[Cmd] = &[c("hatch", "Hatch", Icon::Hatch), c("gradient", "Gradient", Icon::Gradient), c("boundary", "Boundary", Icon::Boundary)];+const TRIMS: &[Cmd] = &[c("trim", "Trim", Icon::Trim), c("extend", "Extend", Icon::Extend)];+const FILLETS: &[Cmd] = &[c("fillet", "Fillet", Icon::Fillet), c("chamfer", "Chamfer", Icon::Chamfer), n("blend", "Blend Curves")];+const ARRAYS: &[Cmd] =+ &[c("arrayrect", "Rectangular Array", Icon::ArrayRect), n("arraypath", "Path Array"), c("arraypolar", "Polar Array", Icon::ArrayPolar)];+const TEXTS: &[Cmd] = &[c("mtext", "Multiline Text", Icon::MText), c("text", "Single Line", Icon::Text)];+const DIMS: &[Cmd] = &[+ c("dimlinear", "Linear", Icon::DimLinear),+ c("dimaligned", "Aligned", Icon::DimAligned),+ c("dimangular", "Angular", Icon::DimAngular),+ c("dimarc", "Arc Length", Icon::DimArc),+ c("dimradius", "Radius", Icon::DimRadius),+ c("dimdiameter", "Diameter", Icon::DimDiameter),+ n("dimjogged", "Jogged"),+ c("dimordinate", "Ordinate", Icon::DimOrdinate),+];+const LEADERS: &[Cmd] = &[+ c("mleader", "Multileader", Icon::MLeader),+ c("mleaderedit.add", "Add Leader", Icon::AddLeader),+ c("mleaderedit.remove", "Remove Leader", Icon::RemoveLeader),+];+const INSERTS: &[Cmd] = &[c("insert", "Insert", Icon::Insert), c("ui.dialog.blocks", "Blocks Palette", Icon::Blocks)];+const ATT_EDITS: &[Cmd] = &[n("eattedit", "Single"), n("-attedit", "Multiple"), n("attsync", "Synchronize")];+const MEASURES: &[Cmd] = &[+ c("measuregeom", "Distance", Icon::Distance),+ n("measuregeom.radius", "Radius"),+ n("measuregeom.angle", "Angle"),+ c("area", "Area", Icon::Area),+];+const PASTES: &[Cmd] =+ &[n("pasteclip", "Paste"), n("pasteblock", "Paste as Block"), n("pasteorig", "Paste to Original Coordinates"), n("pastespec", "Paste Special")];+const ATT_DISPLAY: &[Cmd] =+ &[n("attdisp.normal", "Retain Attribute Display"), n("attdisp.on", "Display All Attributes"), n("attdisp.off", "Hide All Attributes")];+const BLOCK_CREATE: &[Cmd] = &[c("block", "Create Block", Icon::BlockCreate), n("wblock", "Write Block")];+const PDF_IMPORTS: &[Cmd] = &[n("pdfimport", "PDF Import"), n("import", "Import")];+const REVCLOUDS: &[Cmd] = &[+ c("revcloud", "Rectangular", Icon::RevCloud),+ n("revcloud.polygonal", "Polygonal"),+ n("revcloud.freehand", "Freehand"),+ n("revcloud.object", "Object"),+];+const VPORT_CONFIGS: &[Cmd] =+ &[n("vports.1", "Single"), n("vports.2", "Two"), n("vports.3", "Three"), n("vports.4", "Four"), n("vports", "Named...")];+const EXPORTS: &[Cmd] = &[n("exportpdf", "PDF"), n("exportdwf", "DWF"), n("exportdwfx", "DWFx"), n("export", "Other Formats")];+const CONTINUES: &[Cmd] = &[c("dimcontinue", "Continue", Icon::DimContinue), c("dimbaseline", "Baseline", Icon::DimBaseline)];++const NONE: &[Cmd] = &[];++pub const TABS: &[TabDef] = &[+ TabDef {+ id: "home",+ title: "Home",+ panels: &[+ PanelDef {+ title: "Draw",+ cells: &[+ Cell::Big(c("line", "Line", Icon::Line), NONE),+ Cell::Big(c("pline", "Polyline", Icon::Polyline), NONE),+ Cell::Big(c("circle", "Circle", Icon::Circle), CIRCLES),+ Cell::Big(c("arc", "Arc", Icon::Arc), ARCS),+ Cell::Small(&[+ (c("rectang", "Rectangle", Icon::Rectangle), RECTS),+ (c("ellipse", "Ellipse", Icon::Ellipse), ELLIPSES),+ (c("hatch", "Hatch", Icon::Hatch), HATCHES),+ ]),+ ],+ more: &[+ c("spline", "Spline Fit", Icon::Spline),+ c("xline", "Construction Line", Icon::XLine),+ c("ray", "Ray", Icon::Ray),+ c("point", "Multiple Points", Icon::Point),+ n("divide", "Divide"),+ n("measure", "Measure"),+ c("polygon", "Polygon", Icon::Polygon),+ c("donut", "Donut", Icon::Donut),+ c("region", "Region", Icon::Region),+ c("wipeout", "Wipeout", Icon::Wipeout),+ c("revcloud", "Revision Cloud", Icon::RevCloud),+ n("3dpoly", "3D Polyline"),+ ],+ },+ PanelDef {+ title: "Modify",+ cells: &[+ Cell::Small(&[+ (c("move", "Move", Icon::Move), NONE),+ (c("copy", "Copy", Icon::Copy), NONE),+ (c("stretch", "Stretch", Icon::Stretch), NONE),+ ]),+ Cell::Small(&[+ (c("rotate", "Rotate", Icon::Rotate), NONE),+ (c("mirror", "Mirror", Icon::Mirror), NONE),+ (c("scale", "Scale", Icon::Scale), NONE),+ ]),+ Cell::Small(&[+ (c("trim", "Trim", Icon::Trim), TRIMS),+ (c("fillet", "Fillet", Icon::Fillet), FILLETS),+ (c("arrayrect", "Array", Icon::ArrayRect), ARRAYS),+ ]),+ Cell::Icons(&[c("erase", "Erase", Icon::Erase), c("explode", "Explode", Icon::Explode), c("offset", "Offset", Icon::Offset)]),+ ],+ more: &[+ c("break", "Break", Icon::Break),+ n("breakatpoint", "Break at Point"),+ c("join", "Join", Icon::Join),+ c("lengthen", "Lengthen", Icon::Lengthen),+ c("chamfer", "Chamfer", Icon::Chamfer),+ n("align", "Align"),+ n("reverse", "Reverse"),+ n("pedit", "Edit Polyline"),+ n("splinedit", "Edit Spline"),+ n("hatchedit", "Edit Hatch"),+ n("setbylayer", "Set to ByLayer"),+ n("overkill", "Delete Duplicate Objects"),+ n("draworder", "Draw Order"),+ n("flatten", "Flatten"),+ ],+ },+ PanelDef {+ title: "Annotation",+ cells: &[+ Cell::Big(c("mtext", "Text", Icon::MText), TEXTS),+ Cell::Big(c("dim", "Dimension", Icon::DimLinear), NONE),+ Cell::Small(&[+ (c("dimlinear", "Linear", Icon::DimLinear), DIMS),+ (c("mleader", "Leader", Icon::MLeader), LEADERS),+ (c("table", "Table", Icon::Table), NONE),+ ]),+ ],+ more: &[+ n("style", "Text Style"),+ n("dimstyle", "Dimension Style"),+ n("mleaderstyle", "Multileader Style"),+ n("tablestyle", "Table Style"),+ ],+ },+ PanelDef {+ title: "Layers",+ cells: &[+ Cell::Big(c("ui.dialog.layers", "Layer Properties", Icon::LayerProps), NONE),+ Cell::ListIcons(+ Widget::Layer,+ 168.0,+ &[+ c("layoff", "Off", Icon::LayerOff),+ c("layiso", "Isolate", Icon::LayerIso),+ c("layfrz", "Freeze", Icon::LayerFreeze),+ c("laylck", "Lock", Icon::LayerLock),+ n("layon", "Turn All Layers On"),+ n("layuniso", "Unisolate"),+ n("laythw", "Thaw All Layers"),+ c("layulk", "Unlock", Icon::Unlock),+ c("layerp", "Previous", Icon::LayerPrev),+ ],+ ),+ Cell::Small(&[+ (c("laymcur", "Make Current", Icon::MakeCurrent), NONE),+ (c("laymch", "Match Layer", Icon::LayerMatch), NONE),+ (n("copytolayer", "Copy to Layer"), NONE),+ ]),+ ],+ more: &[+ n("layon", "Turn All Layers On"),+ n("laythw", "Thaw All Layers"),+ n("layuniso", "Unisolate"),+ n("laywalk", "Layer Walk"),+ n("laymrg", "Merge"),+ n("laydel", "Delete"),+ n("layvpi", "Isolate to Current Viewport"),+ n("layerstate", "Layer States"),+ ],+ },+ PanelDef {+ title: "Block",+ cells: &[+ Cell::Big(c("insert", "Insert", Icon::Insert), INSERTS),+ Cell::Small(&[+ (c("block", "Create", Icon::BlockCreate), NONE),+ (c("bedit", "Edit", Icon::BlockEdit), NONE),+ (n("eattedit", "Edit Attributes"), ATT_EDITS),+ ]),+ ],+ more: &[c("attdef", "Define Attributes", Icon::AttDef), n("battman", "Manage Attributes"), n("attsync", "Synchronize Attributes")],+ },+ PanelDef {+ title: "Properties",+ cells: &[+ Cell::Big(c("matchprop", "Match Properties", Icon::MatchProp), NONE),+ Cell::Lists(&[Widget::Color, Widget::Lineweight, Widget::Linetype], 140.0),+ ],+ more: &[n("transparency", "Transparency"), c("list", "List", Icon::List), n("setbylayer", "Set to ByLayer")],+ },+ PanelDef {+ title: "Groups",+ cells: &[+ Cell::Big(n("group", "Group"), NONE),+ Cell::Icons(&[n("ungroup", "Ungroup"), n("groupedit", "Group Edit"), n("pickstyle", "Group Selection On/Off")]),+ ],+ more: NONE,+ },+ PanelDef {+ title: "Utilities",+ cells: &[+ Cell::Big(c("measuregeom", "Measure", Icon::Measure), MEASURES),+ Cell::Icons(&[+ c("ui.dialog.qselect", "Quick Select", Icon::QuickSelect),+ n("ui.panel.quickcalc", "QuickCalc"),+ n("id", "ID Point"),+ ]),+ ],+ more: &[n("selectall", "Select All"), n("count", "Count")],+ },+ PanelDef {+ title: "Clipboard",+ cells: &[+ Cell::Big(n("pasteclip", "Paste"), PASTES),+ Cell::Icons(&[n("copyclip", "Copy Clip"), n("cutclip", "Cut"), n("copybase", "Copy with Base Point")]),+ ],+ more: NONE,+ },+ ],+ },+ TabDef {+ id: "insert",+ title: "Insert",+ panels: &[+ PanelDef {+ title: "Block",+ cells: &[+ Cell::Big(c("insert", "Insert", Icon::Insert), INSERTS),+ Cell::Small(&[(n("eattedit", "Edit Attribute"), ATT_EDITS), (n("attdisp", "Retain Attribute Display"), ATT_DISPLAY)]),+ ],+ more: NONE,+ },+ PanelDef {+ title: "Block Definition",+ cells: &[+ Cell::Big(c("block", "Create Block", Icon::BlockCreate), BLOCK_CREATE),+ Cell::Big(c("attdef", "Define Attributes", Icon::AttDef), NONE),+ Cell::Big(n("battman", "Manage Attributes"), NONE),+ Cell::Big(c("bedit", "Block Editor", Icon::BlockEdit), NONE),+ ],+ more: NONE,+ },+ PanelDef {+ title: "Reference",+ cells: &[+ Cell::Big(c("attach", "Attach", Icon::Attach), NONE),+ Cell::Big(n("clip", "Clip"), NONE),+ Cell::Big(n("adjust", "Adjust"), NONE),+ Cell::Small(&[+ (n("xref", "External References"), NONE),+ (n("frame", "Frames vary"), NONE),+ (n("uosnap", "Snap to Underlays"), NONE),+ ]),+ ],+ more: &[n("xclip", "Clip Xref"), n("xbind", "Bind"), n("imageattach", "Attach Image"), n("pdfattach", "Attach PDF")],+ },+ PanelDef {+ title: "Import",+ cells: &[+ Cell::Big(n("pdfimport", "PDF Import"), PDF_IMPORTS),+ Cell::Small(&[(n("pdfshxtext", "Recognize SHX Text"), NONE), (n("pdfshxtextoptions", "Recognition Settings"), NONE)]),+ Cell::Big(n("txt2mtxt", "Combine Text"), NONE),+ ],+ more: NONE,+ },+ PanelDef {+ title: "Data",+ cells: &[+ Cell::Big(n("field", "Field"), NONE),+ Cell::Small(&[+ (n("updatefield", "Update Fields"), NONE),+ (n("insertobj", "OLE Object"), NONE),+ (n("hyperlink", "Hyperlink"), NONE),+ ]),+ ],+ more: NONE,+ },+ PanelDef {+ title: "Linking & Extraction",+ cells: &[+ Cell::Big(n("datalink", "Data Link"), NONE),+ Cell::Icons(&[n("dataextraction", "Extract Data"), n("datalinkupdate", "Update Data Links")]),+ ],+ more: NONE,+ },+ PanelDef { title: "Location", cells: &[Cell::Big(n("geographiclocation", "Set Location"), NONE)], more: NONE },+ ],+ },+ TabDef {+ id: "annotate",+ title: "Annotate",+ panels: &[+ PanelDef {+ title: "Text",+ cells: &[+ Cell::Big(c("mtext", "Multiline Text", Icon::MText), TEXTS),+ Cell::Lists(&[Widget::TextStyle], 170.0),+ Cell::Icons(&[n("spell", "Check Spelling"), c("find", "Find Text", Icon::Search), n("scaletext", "Scale Text")]),+ ],+ more: &[n("justifytext", "Justify"), n("textalign", "Text Align"), n("style", "Text Style")],+ },+ PanelDef {+ title: "Dimensions",+ cells: &[+ Cell::Big(c("dim", "Dimension", Icon::DimLinear), NONE),+ Cell::Lists(&[Widget::DimStyle], 170.0),+ Cell::Small(&[+ (c("dimlinear", "Linear", Icon::DimLinear), DIMS),+ (c("qdim", "Quick", Icon::QDim), NONE),+ (c("dimcontinue", "Continue", Icon::DimContinue), CONTINUES),+ ]),+ Cell::Icons(&[+ n("dimbreak", "Break"),+ n("dimspace", "Adjust Space"),+ n("diminspect", "Inspect"),+ n("dimjogline", "Jog Line"),+ n("dimreassociate", "Reassociate"),+ n("dimtedit", "Text Angle"),+ ]),+ ],+ more: &[n("dimstyle", "Dimension Style"), n("dimoverride", "Override"), n("dimedit.oblique", "Oblique"), n("tolerance", "Tolerance")],+ },+ PanelDef {+ title: "Centerlines",+ cells: &[Cell::Big(n("centermark", "Center Mark"), NONE), Cell::Big(n("centerline", "Centerline"), NONE)],+ more: NONE,+ },+ PanelDef {+ title: "Leaders",+ cells: &[+ Cell::Big(c("mleader", "Multileader", Icon::MLeader), NONE),+ Cell::Lists(&[Widget::MleaderStyle], 160.0),+ Cell::Small(&[+ (c("mleaderedit.add", "Add Leader", Icon::AddLeader), NONE),+ (c("mleaderedit.remove", "Remove Leader", Icon::RemoveLeader), NONE),+ ]),+ Cell::Icons(&[c("mleaderalign", "Align", Icon::AlignLeaders), n("mleadercollect", "Collect")]),+ ],+ more: &[n("mleaderstyle", "Multileader Style")],+ },+ PanelDef {+ title: "Tables",+ cells: &[+ Cell::Big(c("table", "Table", Icon::Table), NONE),+ Cell::Lists(&[Widget::TableStyle], 150.0),+ Cell::Small(&[(n("datalink", "Link Data"), NONE), (n("dataextraction", "Extract Data"), NONE)]),+ ],+ more: &[n("tablestyle", "Table Style"), n("tableexport", "Export")],+ },+ PanelDef {+ title: "Markup",+ cells: &[+ Cell::Big(c("wipeout", "Wipeout", Icon::Wipeout), NONE),+ Cell::Big(c("revcloud", "Revision Cloud", Icon::RevCloud), REVCLOUDS),+ ],+ more: NONE,+ },+ PanelDef {+ title: "Annotation Scaling",+ cells: &[+ Cell::Big(c("objectscale", "Add Current Scale", Icon::Annotation), NONE),+ Cell::Small(&[(n("scalelistedit", "Scale List"), NONE), (n("annoupdate", "Sync Scale Positions"), NONE)]),+ ],+ more: NONE,+ },+ ],+ },+ TabDef {+ id: "parametric",+ title: "Parametric",+ panels: &[+ PanelDef {+ title: "Geometric",+ cells: &[+ Cell::Big(c("autoconstrain", "Auto Constrain", Icon::Constraint), NONE),+ Cell::Icons(&[+ c("gccoincident", "Coincident", Icon::Coincident),+ c("gcparallel", "Parallel", Icon::Parallel),+ c("gcperpendicular", "Perpendicular", Icon::Perpendicular),+ ]),+ Cell::Icons(&[+ c("gchorizontal", "Horizontal", Icon::Horizontal),+ c("gcvertical", "Vertical", Icon::Vertical),+ c("gctangent", "Tangent", Icon::Tangent),+ ]),+ Cell::Icons(&[+ c("gcconcentric", "Concentric", Icon::Concentric),+ c("gcequal", "Equal", Icon::Equal),+ c("gcfix", "Fix", Icon::Fix),+ ]),+ Cell::Small(&[+ (n("constraintbar", "Show/Hide"), NONE),+ (n("constraintbar.showall", "Show All"), NONE),+ (n("constraintbar.hideall", "Hide All"), NONE),+ ]),+ ],+ more: NONE,+ },+ PanelDef {+ title: "Dimensional",+ cells: &[+ Cell::Small(&[(n("dclinear", "Linear"), NONE), (n("dcaligned", "Aligned"), NONE), (n("dcangular", "Angular"), NONE)]),+ Cell::Small(&[(n("dcradius", "Radius"), NONE), (n("dcdiameter", "Diameter"), NONE), (n("dcconvert", "Convert"), NONE)]),+ Cell::Small(&[+ (n("dcdisplay", "Show/Hide"), NONE),+ (n("dcdisplay.showall", "Show All"), NONE),+ (n("dcdisplay.hideall", "Hide All"), NONE),+ ]),+ ],+ more: NONE,+ },+ PanelDef {+ title: "Manage",+ cells: &[Cell::Big(n("delconstraint", "Delete Constraints"), NONE), Cell::Big(n("ui.dialog.parameters", "Parameters Manager"), NONE)],+ more: NONE,+ },+ ],+ },+ TabDef {+ id: "view",+ title: "View",+ panels: &[+ PanelDef {+ title: "Viewport Tools",+ cells: &[+ Cell::Big(n("ui.toggle.ucsicon", "UCS Icon"), NONE),+ Cell::Big(n("ui.toggle.viewcube", "Navigation Cube"), NONE),+ Cell::Big(n("navbar", "Navigation Bar"), NONE),+ ],+ more: NONE,+ },+ PanelDef {+ title: "Named Views",+ cells: &[Cell::Lists(&[Widget::NamedView], 170.0), Cell::Small(&[(n("view", "New View"), NONE), (n("view", "View Manager"), NONE)])],+ more: NONE,+ },+ PanelDef {+ title: "Model Viewports",+ cells: &[+ Cell::Big(n("vports", "Viewport Configuration"), VPORT_CONFIGS),+ Cell::Small(&[(n("vports", "Named"), NONE), (n("-vports.join", "Join"), NONE), (n("vports.1", "Restore"), NONE)]),+ ],+ more: NONE,+ },+ PanelDef { title: "Review", cells: &[Cell::Big(n("compare", "DWG Compare"), NONE)], more: NONE },+ PanelDef { title: "History", cells: &[Cell::Big(n("activityinsights", "Activity Insights"), NONE)], more: NONE },+ PanelDef {+ title: "Palettes",+ cells: &[+ Cell::Big(n("toolpalettes", "Tool Palettes"), NONE),+ Cell::Big(c("ui.panel.properties", "Properties", Icon::Properties), NONE),+ Cell::Big(c("ui.dialog.blocks", "Blocks", Icon::Blocks), NONE),+ Cell::Big(n("count", "Count"), NONE),+ Cell::Big(n("sheetset", "Sheet Set Manager"), NONE),+ Cell::Icons(&[+ n("ui.toggle.cmdline", "Command Line"),+ c("ui.panel.layers", "Layers", Icon::Layers),+ n("ui.panel.quickcalc", "QuickCalc"),+ ]),+ Cell::Icons(&[n("xref", "External References"), n("ui.toggle.history", "Text Window"), n("markupimport", "Markup Import")]),+ ],+ more: NONE,+ },+ PanelDef {+ title: "Interface",+ cells: &[+ Cell::Big(c("ui.switcher", "Switch Windows", Icon::Switcher), NONE),+ Cell::Big(n("ui.toggle.filetabs", "File Tabs"), NONE),+ Cell::Big(n("ui.toggle.layouttabs", "Layout Tabs"), NONE),+ Cell::Small(&[+ (n("ui.tile.horizontal", "Tile Horizontally"), NONE),+ (n("ui.tile.vertical", "Tile Vertically"), NONE),+ (n("ui.cascade", "Cascade"), NONE),+ ]),+ ],+ more: NONE,+ },+ ],+ },+ TabDef {+ id: "manage",+ title: "Manage",+ panels: &[+ PanelDef {+ title: "Customization",+ cells: &[+ Cell::Big(n("cui", "User Interface"), NONE),+ Cell::Big(n("toolpalettes", "Tool Palettes"), NONE),+ Cell::Small(&[(n("cuiimport", "Import"), NONE), (n("cuiexport", "Export"), NONE), (n("aliasedit", "Edit Aliases"), NONE)]),+ ],+ more: NONE,+ },+ PanelDef {+ title: "Applications",+ cells: &[Cell::Big(n("appload", "Load Application"), NONE), Cell::Big(n("script", "Run Script"), NONE)],+ more: NONE,+ },+ PanelDef {+ title: "Cleanup",+ cells: &[+ Cell::Big(n("purge.find", "Find Non-Purgeable Items"), NONE),+ Cell::Small(&[(n("purge", "Purge"), NONE), (n("overkill", "Overkill"), NONE), (n("audit", "Audit"), NONE)]),+ ],+ more: NONE,+ },+ ],+ },+ TabDef {+ id: "output",+ title: "Output",+ panels: &[+ PanelDef {+ title: "Plot",+ cells: &[+ Cell::Big(c("plot", "Plot", Icon::Plot), NONE),+ Cell::Big(c("publish", "Batch Plot", Icon::Publish), NONE),+ Cell::Big(c("preview", "Preview", Icon::Preview), NONE),+ Cell::Small(&[+ (c("pagesetup", "Page Setup Manager", Icon::PageSetup), NONE),+ (n("viewplotdetails", "View Details"), NONE),+ (n("plottermanager", "Plotter Manager"), NONE),+ ]),+ ],+ more: NONE,+ },+ PanelDef { title: "Export to DWF/PDF", cells: &[Cell::Big(c("exportpdf", "Export", Icon::Export), EXPORTS)], more: NONE },+ ],+ },+ TabDef {+ id: "collaborate",+ title: "Collaborate",+ panels: &[+ PanelDef {+ title: "Share",+ cells: &[Cell::Big(c("share", "Share Drawing", Icon::Share), NONE), Cell::Big(n("sharedviews", "Shared Views"), NONE)],+ more: NONE,+ },+ PanelDef { title: "Compare", cells: &[Cell::Big(n("compare", "DWG Compare"), NONE)], more: NONE },+ ],+ },+];++// ------------------------------------------------------------------------------------------+// Layout+// ------------------------------------------------------------------------------------------++/// Height of the tab row.+pub const TAB_ROW: f32 = 24.0;+/// Height of the panel body (three small-button rows) and of the panel title row under it.+const BODY: f32 = 72.0;+const TITLE: f32 = 18.0;+/// Whole ribbon height when expanded.+pub const HEIGHT: f32 = TAB_ROW + BODY + TITLE + 4.0;++const ROW: f32 = BODY / 3.0;+const BIG_ICON: f32 = 30.0;+const SMALL_ICON: f32 = 16.0;+const PAD: f32 = 3.0;++/// Ribbon text is a little smaller than the palettes' body text.+fn font() -> FontId {+ FontId::proportional(11.0)+}++/// A tab's title: the catalog's `Tab: <title>` row when the tab is named differently from a+/// button with the same English word (Insert tab, Insert button), else the plain label.+fn tab_label(title: &str) -> String {+ let key = format!("Tab: {title}");+ let tab = crate::i18n::ribbon(&key);+ if tab == key { crate::i18n::ribbon(title).to_string() } else { tab.to_string() }+}++/// Whether the command exists (else its button is greyed out).+pub fn known(id: &str) -> bool {+ cadcraft_engine::find_command(id).is_some() || crate::menus::is_ui_command(id)+}++fn text_w(ui: &egui::Ui, s: &str) -> f32 {+ ui.painter().layout_no_wrap(crate::i18n::ribbon(s).to_string(), font(), Color32::WHITE).size().x+}++/// A big button's label in up to two lines (split at the space nearest the middle).+fn two_lines(label: &str) -> (String, Option<String>) {+ let s = crate::i18n::ribbon(label);+ if s.chars().count() <= 10 || !s.contains(' ') {+ return (s.to_string(), None);+ }+ let mid = s.len() / 2;+ let split = s.char_indices().filter(|(_, ch)| *ch == ' ').map(|(i, _)| i).min_by_key(|i| i.abs_diff(mid)).unwrap_or(mid);+ let (a, b) = s.split_at(split);+ (a.to_string(), Some(b.trim_start().to_string()))+}++fn cell_width(ui: &egui::Ui, cell: &Cell) -> f32 {+ match cell {+ Cell::Big(cmd, variants) => {+ let (a, b) = two_lines(cmd.label);+ let w = |s: &str| ui.painter().layout_no_wrap(s.to_string(), font(), Color32::WHITE).size().x;+ let lw = w(&a).max(b.as_deref().map_or(0.0, w)) + if variants.is_empty() { 0.0 } else { 10.0 };+ lw.max(BIG_ICON + 4.0) + 2.0 * PAD+ }+ Cell::Small(rows) => {+ rows.iter().map(|(cmd, v)| SMALL_ICON + 5.0 + text_w(ui, cmd.label) + if v.is_empty() { 2.0 } else { 13.0 }).fold(0.0, f32::max)+ + 2.0 * PAD+ }+ Cell::Icons(cmds) => (cmds.len().div_ceil(3) as f32) * (SMALL_ICON + 8.0) + PAD,+ Cell::Lists(_, w) | Cell::ListIcons(_, w, _) => *w + 2.0 * PAD,+ }+}++fn panel_width(ui: &egui::Ui, panel: &PanelDef) -> f32 {+ let cells: f32 = panel.cells.iter().map(|c| cell_width(ui, c)).sum();+ cells.max(text_w(ui, panel.title) + if panel.more.is_empty() { 12.0 } else { 24.0 }) + PAD+}++/// Width of a collapsed panel (a single button with the panel's title).+fn collapsed_width(ui: &egui::Ui, panel: &PanelDef) -> f32 {+ text_w(ui, panel.title).max(BIG_ICON) + 4.0 * PAD + 10.0+}++/// Which panels are shown in full: all of them while they fit, then the rightmost ones collapse+/// first, as Windows CAD ribbons do when the window narrows.+pub fn fit(full: &[f32], collapsed: &[f32], room: f32) -> Vec<bool> {+ let mut expanded = vec![true; full.len()];+ let total = |e: &[bool]| -> f32 {+ e.iter().enumerate().map(|(i, x)| if *x { full.get(i).copied().unwrap_or(0.0) } else { collapsed.get(i).copied().unwrap_or(0.0) }).sum()+ };+ let mut i = full.len();+ while total(&expanded) > room && i > 0 {+ i -= 1;+ if let Some(e) = expanded.get_mut(i) {+ *e = false;+ }+ }+ expanded+}++// ------------------------------------------------------------------------------------------+// Drawing+// ------------------------------------------------------------------------------------------++/// What a click in the ribbon asks for.+enum Action {+ Start(&'static str),+ Tab(&'static str),+ ToggleMinimized,+ Layer(String),+ LayerToggle(String, &'static str, bool),+}++fn hover_fill(ui: &egui::Ui, r: Rect, resp: &egui::Response) {+ if resp.hovered() {+ ui.painter().rect_filled(r, 3.0, Tokens::get().control_hover.gamma_multiply(0.6));+ }+}++fn paint_icon(p: &egui::Painter, r: Rect, cmd: &Cmd, dim: bool) {+ match cmd.icon {+ Some(icon) => icons::paint(p, r, icon, dim),+ None => {+ let t = Tokens::get();+ let col = if dim { t.text_faint } else { t.icon };+ p.rect_stroke(r.shrink(r.width() * 0.08), 2.0, Stroke::new(1.0, col), egui::StrokeKind::Inside);+ let initial: String = crate::i18n::ribbon(cmd.label).chars().next().map(|ch| ch.to_uppercase().collect()).unwrap_or_default();+ p.text(r.center(), Align2::CENTER_CENTER, initial, FontId::proportional(r.height() * 0.55), col);+ }+ }+}++fn tooltip(cmd: &Cmd, enabled: bool) -> String {+ let label = crate::i18n::ribbon(cmd.label);+ if enabled { format!("{label} ({})", cmd.id.to_uppercase()) } else { format!("{label} — {}", crate::tl!("not available yet")) }+}++/// A menu of command variants (or a collapsed panel's commands).+fn variant_menu(ui: &mut egui::Ui, items: &[Cmd], out: &mut Option<Action>) {+ for v in items {+ let enabled = known(v.id);+ let resp = ui.add_enabled(enabled, egui::Button::new(crate::i18n::ribbon(v.label)));+ crate::control::record_widget(ui, &resp, v.label, "button", None);+ if resp.clicked() {+ *out = Some(Action::Start(v.id));+ ui.close();+ }+ }+}++fn big(ui: &mut egui::Ui, r: Rect, cmd: &Cmd, variants: &'static [Cmd], out: &mut Option<Action>) {+ let t = Tokens::get();+ let enabled = known(cmd.id);+ let top = Rect::from_min_max(r.min, pos2(r.right(), r.top() + BIG_ICON + 8.0));+ let resp = ui.interact(if variants.is_empty() { r } else { top }, ui.scope_id().with(("rb_big", cmd.id, cmd.label)), Sense::click());+ hover_fill(ui, if variants.is_empty() { r } else { top }, &resp);+ paint_icon(ui.painter(), Rect::from_center_size(pos2(r.center().x, r.top() + 4.0 + BIG_ICON / 2.0), vec2(BIG_ICON, BIG_ICON)), cmd, !enabled);+ let (a, b) = two_lines(cmd.label);+ let col = if enabled { t.text } else { t.text_faint };+ let y = r.top() + BIG_ICON + 10.0;+ ui.painter().text(pos2(r.center().x, y), Align2::CENTER_TOP, &a, font(), col);+ if let Some(b) = &b {+ ui.painter().text(pos2(r.center().x, y + 13.0), Align2::CENTER_TOP, b, font(), col);+ }+ icons::describe_control(ui, &resp, cmd.label, None, false);+ if resp.on_hover_text(tooltip(cmd, enabled)).clicked() && enabled {+ *out = Some(Action::Start(cmd.id));+ }+ if !variants.is_empty() {+ let low = Rect::from_min_max(pos2(r.left(), top.bottom()), r.max);+ let vr = ui.interact(low, ui.scope_id().with(("rb_bigv", cmd.id, cmd.label)), Sense::click());+ hover_fill(ui, low, &vr);+ let line_y = y + if b.is_some() { 26.0 } else { 13.0 };+ icons::paint(ui.painter(), Rect::from_center_size(pos2(r.center().x, line_y + 4.0), vec2(9.0, 9.0)), Icon::ChevronDown, false);+ egui::Popup::menu(&vr).show(|ui| variant_menu(ui, variants, out));+ }+}++fn small(ui: &mut egui::Ui, r: Rect, cmd: &Cmd, variants: &'static [Cmd], out: &mut Option<Action>) {+ let t = Tokens::get();+ let enabled = known(cmd.id);+ let main = if variants.is_empty() { r } else { Rect::from_min_max(r.min, pos2(r.right() - 12.0, r.bottom())) };+ let resp = ui.interact(main, ui.scope_id().with(("rb_small", cmd.id, cmd.label, r.top() as i32)), Sense::click());+ hover_fill(ui, main, &resp);+ paint_icon(+ ui.painter(),+ Rect::from_center_size(pos2(r.left() + 2.0 + SMALL_ICON / 2.0, r.center().y), vec2(SMALL_ICON, SMALL_ICON)),+ cmd,+ !enabled,+ );+ ui.painter().text(+ pos2(r.left() + SMALL_ICON + 6.0, r.center().y),+ Align2::LEFT_CENTER,+ crate::i18n::ribbon(cmd.label),+ font(),+ if enabled { t.text } else { t.text_faint },+ );+ icons::describe_control(ui, &resp, cmd.label, None, false);+ if resp.on_hover_text(tooltip(cmd, enabled)).clicked() && enabled {+ *out = Some(Action::Start(cmd.id));+ }+ if !variants.is_empty() {+ let vr_rect = Rect::from_min_max(pos2(r.right() - 12.0, r.top()), r.max);+ let vr = ui.interact(vr_rect, ui.scope_id().with(("rb_smallv", cmd.id, cmd.label, r.top() as i32)), Sense::click());+ hover_fill(ui, vr_rect, &vr);+ icons::paint(ui.painter(), Rect::from_center_size(vr_rect.center(), vec2(8.0, 8.0)), Icon::ChevronDown, false);+ egui::Popup::menu(&vr).show(|ui| variant_menu(ui, variants, out));+ }+}++fn icon_button(ui: &mut egui::Ui, r: Rect, cmd: &Cmd, out: &mut Option<Action>) {+ let enabled = known(cmd.id);+ let resp = ui.interact(r, ui.scope_id().with(("rb_icon", cmd.id, cmd.label)), Sense::click());+ hover_fill(ui, r, &resp);+ paint_icon(ui.painter(), Rect::from_center_size(r.center(), vec2(SMALL_ICON, SMALL_ICON)), cmd, !enabled);+ icons::describe_control(ui, &resp, cmd.label, None, false);+ if resp.on_hover_text(tooltip(cmd, enabled)).clicked() && enabled {+ *out = Some(Action::Start(cmd.id));+ }+}++/// The current value a list shows, and the command its button opens.+fn widget_value(app: &CadApp, w: Widget) -> (String, &'static str) {+ let Ok(d) = app.session.doc() else { return (String::new(), "ui.noop") };+ let h = &d.header;+ let get = |name: &str, default: &str| {+ let v = h.str(name, default);+ if v.is_empty() { default.to_string() } else { v }+ };+ match w {+ Widget::Layer => (h.str("CLAYER", "0"), "ui.dialog.layers"),+ Widget::Color => (color_name(h.i64("CECOLOR", 256)), "color"),+ Widget::Lineweight => (lineweight_name(h.i64("CELWEIGHT", -1)), "lweight"),+ Widget::Linetype => (by_name(&get("CELTYPE", "ByLayer")), "linetype"),+ Widget::TextStyle => (get("TEXTSTYLE", "Standard"), "style"),+ Widget::DimStyle => (get("DIMSTYLE", "Standard"), "dimstyle"),+ Widget::MleaderStyle => (get("CMLEADERSTYLE", "Standard"), "mleaderstyle"),+ Widget::TableStyle => (get("CTABLESTYLE", "Standard"), "tablestyle"),+ Widget::NamedView => (crate::tl!("Unsaved View").to_string(), "view"),+ }+}++/// ByLayer/ByBlock in the interface language; other names as they are.+fn by_name(name: &str) -> String {+ if name.eq_ignore_ascii_case("ByLayer") {+ crate::tl!("ByLayer").to_string()+ } else if name.eq_ignore_ascii_case("ByBlock") {+ crate::tl!("ByBlock").to_string()+ } else {+ name.to_string()+ }+}++/// The usual CAD name for a colour index (CECOLOR).+pub fn color_name(aci: i64) -> String {+ match aci {+ 256 => crate::tl!("ByLayer").to_string(),+ 0 => crate::tl!("ByBlock").to_string(),+ 1 => crate::tl!("Red").to_string(),+ 2 => crate::tl!("Yellow").to_string(),+ 3 => crate::tl!("Green").to_string(),+ 4 => crate::tl!("Cyan").to_string(),+ 5 => crate::tl!("Blue").to_string(),+ 6 => crate::tl!("Magenta").to_string(),+ 7 => crate::tl!("White").to_string(),+ n => format!("{} {n}", crate::tl!("Color")),+ }+}++/// The usual CAD name for a lineweight value (CELWEIGHT, hundredths of a millimetre).+pub fn lineweight_name(v: i64) -> String {+ match v {+ -1 => crate::tl!("ByLayer").to_string(),+ -2 => crate::tl!("ByBlock").to_string(),+ -3 => crate::tl!("Default").to_string(),+ n if n >= 0 => format!("{:.2} mm", n as f64 / 100.0),+ _ => crate::tl!("ByLayer").to_string(),+ }+}++fn list(ui: &mut egui::Ui, app: &CadApp, r: Rect, w: Widget, out: &mut Option<Action>) {+ let t = Tokens::get();+ let (value, cmd) = widget_value(app, w);+ let resp = ui.interact(r, ui.scope_id().with(("rb_list", w as u8)), Sense::click());+ ui.painter().rect_filled(r, 2.0, if resp.hovered() { t.control_hover } else { t.control });+ let mut x = r.left() + 5.0;+ if w == Widget::Layer+ && let Ok(d) = app.session.doc()+ && let Some(l) = d.layers.iter().find(|l| l.name.eq_ignore_ascii_case(&value))+ {+ for icon in [+ if l.on { Icon::Bulb } else { Icon::BulbOff },+ if l.frozen { Icon::Snowflake } else { Icon::Sun },+ if l.locked { Icon::Lock } else { Icon::Unlock },+ ] {+ icons::paint(ui.painter(), Rect::from_min_size(pos2(x, r.center().y - 7.0), vec2(14.0, 14.0)), icon, false);+ x += 16.0;+ }+ crate::layers::swatch(ui.painter(), Rect::from_min_size(pos2(x + 1.0, r.center().y - 5.0), vec2(10.0, 10.0)), l.color);+ x += 16.0;+ }+ let galley = ui.painter().layout_no_wrap(value.clone(), font(), t.text);+ let room = (r.right() - 16.0 - x).max(0.0);+ let clip = Rect::from_min_max(pos2(x, r.top()), pos2(x + room, r.bottom()));+ ui.painter().with_clip_rect(clip).galley(pos2(x, r.center().y - galley.size().y / 2.0), galley, t.text);+ icons::paint(ui.painter(), Rect::from_center_size(pos2(r.right() - 8.0, r.center().y), vec2(9.0, 9.0)), Icon::ChevronDown, false);+ resp.clone().on_hover_text(&value);+ if w == Widget::Layer {+ let layers: Vec<(String, bool, bool, bool, cadcraft_color::Color)> =+ app.session.doc().map(|d| d.layers.iter().map(|l| (l.name.clone(), l.on, l.frozen, l.locked, l.color)).collect()).unwrap_or_default();+ egui::Popup::from_toggle_button_response(&resp)+ .width(r.width().max(220.0))+ .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside)+ .show(|ui| {+ egui::ScrollArea::vertical().max_height(420.0).show(ui, |ui| {+ for (name, on, frozen, locked, color) in &layers {+ ui.horizontal(|ui| {+ if icons::button(ui, if *on { Icon::Bulb } else { Icon::BulbOff }, 18.0, "On/Off", false).clicked() {+ *out = Some(Action::LayerToggle(name.clone(), "on", !*on));+ }+ if icons::button(ui, if *frozen { Icon::Snowflake } else { Icon::Sun }, 18.0, "Freeze/Thaw", false).clicked() {+ *out = Some(Action::LayerToggle(name.clone(), "frozen", !*frozen));+ }+ if icons::button(ui, if *locked { Icon::Lock } else { Icon::Unlock }, 18.0, "Lock/Unlock", false).clicked() {+ *out = Some(Action::LayerToggle(name.clone(), "locked", !*locked));+ }+ let (sr, _) = ui.allocate_exact_size(vec2(12.0, 12.0), Sense::hover());+ crate::layers::swatch(ui.painter(), sr, *color);+ if ui.selectable_label(name.eq_ignore_ascii_case(&value), name).clicked() {+ *out = Some(Action::Layer(name.clone()));+ ui.close();+ }+ });+ }+ });+ });+ } else if resp.clicked() && known(cmd) {+ *out = Some(Action::Start(cmd));+ }+}++fn draw_cell(ui: &mut egui::Ui, app: &CadApp, cell: &Cell, r: Rect, out: &mut Option<Action>) {+ match cell {+ Cell::Big(cmd, v) => big(ui, r.shrink2(vec2(PAD / 2.0, 0.0)), cmd, v, out),+ Cell::Small(rows) => {+ for (i, (cmd, v)) in rows.iter().enumerate() {+ let rr = Rect::from_min_size(pos2(r.left() + PAD, r.top() + i as f32 * ROW + 1.0), vec2(r.width() - 2.0 * PAD, ROW - 2.0));+ small(ui, rr, cmd, v, out);+ }+ }+ Cell::Icons(cmds) => {+ for (i, cmd) in cmds.iter().enumerate() {+ let (col, row) = (i / 3, i % 3);+ let rr = Rect::from_min_size(+ pos2(r.left() + col as f32 * (SMALL_ICON + 8.0), r.top() + row as f32 * ROW + 1.0),+ vec2(SMALL_ICON + 6.0, ROW - 2.0),+ );+ icon_button(ui, rr, cmd, out);+ }+ }+ Cell::Lists(ws, w) => {+ for (i, wd) in ws.iter().enumerate() {+ let rr = Rect::from_min_size(pos2(r.left() + PAD, r.top() + i as f32 * ROW + 2.0), vec2(*w, ROW - 4.0));+ list(ui, app, rr, *wd, out);+ }+ }+ Cell::ListIcons(wd, w, cmds) => {+ list(ui, app, Rect::from_min_size(pos2(r.left() + PAD, r.top() + 2.0), vec2(*w, ROW - 4.0)), *wd, out);+ let per_row = ((*w / (SMALL_ICON + 8.0)).floor() as usize).max(1);+ for (i, cmd) in cmds.iter().enumerate() {+ let (row, col) = (1 + i / per_row, i % per_row);+ let rr = Rect::from_min_size(+ pos2(r.left() + PAD + col as f32 * (SMALL_ICON + 8.0), r.top() + row as f32 * ROW + 1.0),+ vec2(SMALL_ICON + 6.0, ROW - 2.0),+ );+ icon_button(ui, rr, cmd, out);+ }+ }+ }+}++/// Every command of a panel, for its collapsed button's menu.+fn panel_commands(panel: &PanelDef) -> Vec<Cmd> {+ let mut v = Vec::new();+ for cell in panel.cells {+ match cell {+ Cell::Big(cmd, _) => v.push(*cmd),+ Cell::Small(rows) => v.extend(rows.iter().map(|(cmd, _)| *cmd)),+ Cell::Icons(cmds) => v.extend(cmds.iter().copied()),+ Cell::Lists(..) => {}+ Cell::ListIcons(_, _, cmds) => v.extend(cmds.iter().copied()),+ }+ }+ for cmd in panel.more {+ if !v.iter().any(|existing| existing.id == cmd.id) {+ v.push(*cmd);+ }+ }+ v+}++/// The ribbon (tab row and the active tab's panels).+pub fn ribbon(app: &mut CadApp, ui: &mut egui::Ui) {+ let t = Tokens::get();+ let h = if app.ui.ribbon_minimized { TAB_ROW + 2.0 } else { HEIGHT };+ let mut out: Option<Action> = None;+ egui::Panel::top("cc_ribbon").exact_size(h).frame(egui::Frame::NONE.fill(t.chrome)).show(ui, |ui| {+ let r = ui.max_rect();+ // Tabs.+ let mut x = r.left() + 8.0;+ let active = TABS.iter().find(|tab| tab.id == app.ui.ribbon_tab).map_or("home", |tab| tab.id);+ for tab in TABS {+ let label = tab_label(tab.title);+ let w = ui.painter().layout_no_wrap(label.clone(), crate::theme::body(), t.text).size().x + 18.0;+ let tr = Rect::from_min_size(pos2(x, r.top()), vec2(w, TAB_ROW));+ let resp = ui.interact(tr, ui.scope_id().with(("rb_tab", tab.id)), Sense::click());+ let is_active = tab.id == active;+ if is_active && !app.ui.ribbon_minimized {+ ui.painter().rect_filled(tr, egui::CornerRadius { nw: 3, ne: 3, sw: 0, se: 0 }, t.panel);+ } else if resp.hovered() {+ ui.painter().rect_filled(tr, 3.0, t.control_hover.gamma_multiply(0.5));+ }+ ui.painter().text(tr.center(), Align2::CENTER_CENTER, label, crate::theme::body(), if is_active { t.text } else { t.text_dim });+ icons::describe_control(ui, &resp, tab.title, Some(is_active), true);+ if resp.double_clicked() {+ out = Some(Action::ToggleMinimized);+ } else if resp.clicked() {+ out = Some(Action::Tab(tab.id));+ }+ x += w + 2.0;+ }+ if app.ui.ribbon_minimized {+ return;+ }+ // Body.+ let body = Rect::from_min_max(pos2(r.left(), r.top() + TAB_ROW), r.max);+ ui.painter().rect_filled(body, 0.0, t.panel);+ ui.painter().hline(body.x_range(), body.bottom() - 0.5, Stroke::new(1.0, t.border));+ let Some(tab) = TABS.iter().find(|tab| tab.id == active) else { return };+ let full: Vec<f32> = tab.panels.iter().map(|p| panel_width(ui, p)).collect();+ let collapsed: Vec<f32> = tab.panels.iter().map(|p| collapsed_width(ui, p)).collect();+ let expanded = fit(&full, &collapsed, body.width() - 12.0);+ let mut x = body.left() + 6.0;+ for (i, panel) in tab.panels.iter().enumerate() {+ let open = expanded.get(i).copied().unwrap_or(true);+ let w = if open { full.get(i).copied().unwrap_or(0.0) } else { collapsed.get(i).copied().unwrap_or(0.0) };+ let pr = Rect::from_min_size(pos2(x, body.top() + 2.0), vec2(w, BODY + TITLE));+ if open {+ let mut cx = pr.left() + PAD;+ for cell in panel.cells {+ let cw = cell_width(ui, cell);+ draw_cell(ui, app, cell, Rect::from_min_size(pos2(cx, pr.top()), vec2(cw, BODY)), &mut out);+ cx += cw;+ }+ // Title row (with the expander when the panel has more commands).+ let title_r = Rect::from_min_max(pos2(pr.left(), pr.bottom() - TITLE), pr.max);+ let label = crate::i18n::ribbon(panel.title);+ if panel.more.is_empty() {+ ui.painter().text(title_r.center(), Align2::CENTER_CENTER, label, font(), t.text_dim);+ } else {+ let resp = ui.interact(title_r, ui.scope_id().with(("rb_more", tab.id, panel.title)), Sense::click());+ hover_fill(ui, title_r, &resp);+ let tw = text_w(ui, panel.title);+ ui.painter().text(pos2(title_r.center().x - 6.0, title_r.center().y), Align2::CENTER_CENTER, label, font(), t.text_dim);+ icons::paint(+ ui.painter(),+ Rect::from_center_size(pos2(title_r.center().x + tw / 2.0 + 2.0, title_r.center().y), vec2(8.0, 8.0)),+ Icon::ChevronDown,+ false,+ );+ icons::describe_control(ui, &resp, panel.title, None, false);+ egui::Popup::menu(&resp).show(|ui| variant_menu(ui, panel.more, &mut out));+ }+ } else {+ let resp = ui.interact(pr, ui.scope_id().with(("rb_collapsed", tab.id, panel.title)), Sense::click());+ hover_fill(ui, pr, &resp);+ let first = panel_commands(panel).into_iter().next();+ if let Some(cmd) = &first {+ paint_icon(+ ui.painter(),+ Rect::from_center_size(pos2(pr.center().x, pr.top() + 6.0 + BIG_ICON / 2.0), vec2(BIG_ICON, BIG_ICON)),+ cmd,+ false,+ );+ }+ ui.painter().text(+ pos2(pr.center().x, pr.top() + BIG_ICON + 14.0),+ Align2::CENTER_TOP,+ crate::i18n::ribbon(panel.title),+ font(),+ t.text,+ );+ icons::paint(ui.painter(), Rect::from_center_size(pos2(pr.center().x, pr.bottom() - 10.0), vec2(9.0, 9.0)), Icon::ChevronDown, false);+ icons::describe_control(ui, &resp, panel.title, None, false);+ let cmds = panel_commands(panel);+ egui::Popup::menu(&resp).show(|ui| variant_menu(ui, &cmds, &mut out));+ }+ x += w;+ // Panel separator.+ ui.painter().vline(x, (pr.top() + 4.0)..=(pr.bottom() - 4.0), Stroke::new(1.0, t.border));+ x += 1.0;+ }+ });+ match out {+ Some(Action::Start(id)) => app.start(id),+ Some(Action::Tab(id)) => {+ app.ui.ribbon_tab = id.to_string();+ app.ui.ribbon_minimized = false;+ }+ Some(Action::ToggleMinimized) => app.ui.ribbon_minimized = !app.ui.ribbon_minimized,+ Some(Action::Layer(name)) => {+ let _ = app.run("layer.current", json!({ "name": name }));+ }+ Some(Action::LayerToggle(name, key, value)) => {+ let _ = app.run("layer.set", json!({ "name": name, key: value }));+ }+ None => {}+ }+}++/// Switch to the Windows layout: Ribbon, quick access tool bar in the title row,+/// Properties docked left, no Tool Sets or Layers palette.+pub fn apply_pc_layout(app: &mut CadApp) {+ let workspace = crate::docking::pc_workspace();+ crate::docking::publish_visibility(app, &workspace);+ app.ui.docking = workspace;+ app.ui.show_ribbon = true;+ app.ui.show_toolbar = false;+ app.ui.pc_layout_applied = true;+}++/// Back to CADCraft's Mac layout: Tool Bar, Tool Sets, Layers and Properties+/// palettes on the right, no Ribbon.+pub fn apply_mac_layout(app: &mut CadApp) {+ let workspace = crate::docking::default_workspace();+ crate::docking::publish_visibility(app, &workspace);+ app.ui.docking = workspace;+ app.ui.show_ribbon = false;+ app.ui.show_toolbar = true;+ app.ui.pc_layout_applied = true;+}++#[cfg(test)]+mod tests {+ use super::*;++ #[test]+ fn collapsed_layers_menu_keeps_each_command_once_in_display_order() {+ let layers = TABS.iter().find(|tab| tab.id == "home").unwrap().panels.iter().find(|panel| panel.title == "Layers").unwrap();+ let ids: Vec<_> = panel_commands(layers).iter().map(|cmd| cmd.id).collect();+ assert_eq!(+ ids,+ [+ "ui.dialog.layers",+ "layoff",+ "layiso",+ "layfrz",+ "laylck",+ "layon",+ "layuniso",+ "laythw",+ "layulk",+ "layerp",+ "laymcur",+ "laymch",+ "copytolayer",+ "laywalk",+ "laymrg",+ "laydel",+ "layvpi",+ "layerstate",+ ]+ );+ }++ #[test]+ fn panels_collapse_from_the_right() {+ let full = [100.0, 100.0, 100.0];+ let collapsed = [40.0, 40.0, 40.0];+ assert_eq!(fit(&full, &collapsed, 400.0), vec![true, true, true]);+ assert_eq!(fit(&full, &collapsed, 250.0), vec![true, true, false]);+ assert_eq!(fit(&full, &collapsed, 190.0), vec![true, false, false]);+ assert_eq!(fit(&full, &collapsed, 10.0), vec![false, false, false]);+ }++ #[test]+ fn tabs_match_the_windows_layout_order() {+ let titles: Vec<&str> = TABS.iter().map(|t| t.title).collect();+ assert_eq!(titles, ["Home", "Insert", "Annotate", "Parametric", "View", "Manage", "Output", "Collaborate"]);+ let home: Vec<&str> = TABS[0].panels.iter().map(|p| p.title).collect();+ assert_eq!(home, ["Draw", "Modify", "Annotation", "Layers", "Block", "Properties", "Groups", "Utilities", "Clipboard"]);+ }++ /// The everyday drafting commands of the Home tab are all real commands (not greyed out).+ #[test]+ fn home_tab_core_commands_exist() {+ for id in [+ "line",+ "pline",+ "circle",+ "arc",+ "rectang",+ "ellipse",+ "hatch",+ "move",+ "copy",+ "stretch",+ "rotate",+ "mirror",+ "scale",+ "trim",+ "fillet",+ "arrayrect",+ "erase",+ "explode",+ "offset",+ "mtext",+ "dim",+ "dimlinear",+ "mleader",+ "table",+ "insert",+ "block",+ "matchprop",+ "laymcur",+ "layoff",+ "layiso",+ "layfrz",+ "laylck",+ ] {+ assert!(known(id), "{id} is not a command");+ }+ for v in CIRCLES.iter().chain(ARCS).chain(ELLIPSES) {+ assert!(known(v.id), "{} is not a command", v.id);+ }+ }++ #[test]+ fn colour_and_lineweight_names() {+ assert_eq!(lineweight_name(30), "0.30 mm");+ assert_eq!(lineweight_name(-1), "ByLayer");+ assert_eq!(color_name(4), "Cyan");+ assert_eq!(color_name(42), "Color 42");+ }+}@@ -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());+}@@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (distinct polyline midpoint grips) · **Target:** Autodesk AutoCAD 2027 for Mac (26.0)+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-27 · **Change:** minor (collapsed ribbon menu duplicates) · **Target:** Autodesk AutoCAD 2027 for Mac (26.0) How CADCraft's interaction compares with AutoCAD's: point entry, object snaps, tracking, dynamic input, grips, selection, the command line, chrome and navigation. A drafter's speed in AutoCAD@@ -203,6 +203,7 @@ down-arrow options menu (DYNPROMPT), and DYNDIM. | Date | Change | Summary | |---|---|---|+| 2026-10-27 | minor | Collapsed ribbon panels omit overflow commands already present in their cells, preserving first-occurrence order and unique overflow actions | | 2026-10-22 | minor | Polyline segment midpoint grips use rectangles, distinct from square vertex grips, with matching hover and pick bounds (#1950) | | 2026-10-13 | minor | Filled-region highlight boundaries are traversed in deterministic coordinate order, fixing dash-phase jumps on CPU hover/selection redraws and GPU highlight rebuilds; CPU dash-position, pixel and GPU mesh regressions | | 2026-10-14 | minor | Selection cycling (#1143): the list and badge of SELECTIONCYCLING, Shift+Space through overlapping objects (#1145) |@@ -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");@@ -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(); }