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
@@ -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,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 Шар стрічки@@ -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,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(¤t), |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");+ }+}@@ -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());+}@@ -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;new file mode 100644@@ -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");+}@@ -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) |@@ -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(); }