From fa0ce3c70f8a5c591fbf3d9924f010444b57959b Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sun, 11 Oct 2026 18:09:56 +0200 Subject: [PATCH] Shortcut menus: command line, drawing and layout tabs, Layers and Properties palettes (fixes #512) (#518) Squash of PR #518 by @vangel76. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/chrome.rs | 10 +- crates/ui-egui/src/closing.rs | 6 + crates/ui-egui/src/cmdline.rs | 1 + crates/ui-egui/src/docking.rs | 7 +- crates/ui-egui/src/i18n/uk.tsv | 9 + crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/menus.rs | 5 + crates/ui-egui/src/palettes.rs | 4 +- crates/ui-egui/src/shortcut_menus.rs | 245 +++++++++++++++++++++++++ crates/ui-egui/tests/shortcut_menus.rs | 167 +++++++++++++++++ docs/ui-parity.md | 12 +- 11 files changed, 454 insertions(+), 13 deletions(-) create mode 100644 crates/ui-egui/src/shortcut_menus.rs create mode 100644 crates/ui-egui/tests/shortcut_menus.rs diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index e273e92b1..a11f37d01 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -192,7 +192,7 @@ pub fn file_tabs(app: &mut CadApp, ui: &mut egui::Ui) { app.ui.start_tab = false; } x += 24.0; - let mut tab = |ui: &mut egui::Ui, label: &str, active: bool, closable: bool, w: f32, id: egui::Id| -> (bool, bool) { + let mut tab = |ui: &mut egui::Ui, label: &str, active: bool, closable: bool, w: f32, id: egui::Id| -> (bool, bool, egui::Response) { let tr = Rect::from_min_size(pos2(x, r.top()), vec2(w, r.height())); let resp = ui.interact(tr, id, Sense::click()); ui.painter().rect_filled( @@ -224,9 +224,9 @@ pub fn file_tabs(app: &mut CadApp, ui: &mut egui::Ui) { icons::describe_control(ui, &resp, label, Some(active), true); ui.painter().vline(tr.right(), tr.y_range(), Stroke::new(1.0, t.border)); x += w; - (resp.clicked(), close) + (resp.clicked(), close, resp) }; - let (c, _) = tab(ui, crate::tl!("Start"), app.ui.start_tab || app.session.docs.is_empty(), false, 110.0, ui.id().with("tab_start")); + let (c, _, _) = tab(ui, crate::tl!("Start"), app.ui.start_tab || app.session.docs.is_empty(), false, 110.0, ui.id().with("tab_start")); if c { app.ui.start_tab = true; } @@ -240,7 +240,8 @@ pub fn file_tabs(app: &mut CadApp, ui: &mut egui::Ui) { .map(|(i, d)| (i, format!("{}{}", d.title, if d.is_dirty() { "*" } else { "" }), i == app.session.active)) .collect(); for (i, title, active) in titles { - let (c, x) = tab(ui, &title, active && !app.ui.start_tab, true, 170.0, ui.id().with(("tab", i))); + let (c, x, resp) = tab(ui, &title, active && !app.ui.start_tab, true, 170.0, ui.id().with(("tab", i))); + crate::shortcut_menus::attach(app, &resp, |app| crate::shortcut_menus::drawing_tab_entries(app, i)); if c { switch_to = Some(i); } @@ -311,6 +312,7 @@ pub fn status_bar(app: &mut CadApp, ui: &mut egui::Ui) { if resp.clicked() { switch = Some(name.clone()); } + crate::shortcut_menus::attach(app, &resp, |app| crate::shortcut_menus::layout_tab_entries(app, name)); x += w + 2.0; if i == 0 { let pr = Rect::from_min_size(pos2(x, r.top() + 5.0), vec2(16.0, 16.0)); diff --git a/crates/ui-egui/src/closing.rs b/crates/ui-egui/src/closing.rs index ab2fd7b19..210d91188 100644 --- a/crates/ui-egui/src/closing.rs +++ b/crates/ui-egui/src/closing.rs @@ -52,6 +52,12 @@ impl CadApp { } } + /// Close every drawing but the one at `keep`, asking about each one with unsaved changes. + pub fn request_close_others(&mut self, keep: usize) { + let uids = self.session.docs.iter().enumerate().filter(|(i, _)| *i != keep).map(|(_, d)| d.uid).collect(); + self.begin_close(uids); + } + /// Close every drawing as the user asked for it, asking about each one with unsaved changes. pub fn request_close_all(&mut self) { let uids = self.session.docs.iter().map(|d| d.uid).collect(); diff --git a/crates/ui-egui/src/cmdline.rs b/crates/ui-egui/src/cmdline.rs index 4f80352c4..0ad42187d 100644 --- a/crates/ui-egui/src/cmdline.rs +++ b/crates/ui-egui/src/cmdline.rs @@ -453,6 +453,7 @@ fn history(app: &mut CadApp, ui: &mut egui::Ui, canvas: Rect, bar: Rect) { if resp.clicked() && resp.interact_pointer_pos().is_some_and(on_toggle) { let _ = app.run("ui.toggle.history", serde_json::json!({})); } + crate::shortcut_menus::attach(app, &resp, crate::shortcut_menus::command_line_entries); if let Some(r) = zones(app, canvas, bar).window { history_window(app, ui, r); } diff --git a/crates/ui-egui/src/docking.rs b/crates/ui-egui/src/docking.rs index 166f36380..d4c5f80ae 100644 --- a/crates/ui-egui/src/docking.rs +++ b/crates/ui-egui/src/docking.rs @@ -345,10 +345,9 @@ pub fn show(app: &mut crate::CadApp, ui: &mut egui::Ui) { .show(ui, |ui| crate::palettes::layers_section(app, ui)); } Panel::Properties => { - egui::ScrollArea::vertical() - .id_salt("dock-properties") - .auto_shrink([false, false]) - .show(ui, |ui| crate::palettes::properties_section(app, ui)); + egui::ScrollArea::vertical().id_salt("dock-properties").auto_shrink([false, false]).show(ui, |ui| { + crate::shortcut_menus::palette(app, ui, crate::palettes::properties_section, crate::shortcut_menus::properties_entries) + }); } }, ); diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index de3ffaa62..126a25509 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -112,6 +112,7 @@ Clipboard Буфер обміну Close Закрити Close All Закрити всі + Close All Other Drawings Закрити всі інші креслення Closed Замкнений Coincident Збіг Collapse Tool Sets Згорнути набори інструментів @@ -122,6 +123,7 @@ Color Index Індекс кольору Color... Колір... Command History Історія команд + Clear Command History Очистити історію команд Command Line Командний рядок Command Line History Lines Рядки історії командного рядка Command Reference Довідник команд @@ -138,6 +140,8 @@ Contributors Учасники Control Vertices Керувальні вершини Copy Копіювати + Copy Full File Path Копіювати повний шлях до файлу + Copy History Копіювати історію Copy Layout Копіювати аркуш Copy Nested Objects Копіювати вкладені об'єкти Copy Selection Копіювати вибране @@ -302,6 +306,7 @@ Freeze/Thaw in all viewports Заморозити/розморозити в усіх видових екранах Freeze/Thaw in the current viewport Заморозити/розморозити в поточному видовому екрані From Від точки + From Template... З шаблону... General Загальні Geometric Center Геометричний центр Geometric Constraint Геометричне обмеження @@ -396,6 +401,7 @@ Layer is empty (double-click to make current) Шар порожній (двічі клацніть, щоб зробити поточним) Layers Шари Layers Palette Палітра шарів + Hide Palette Сховати палітру Layers displayed: {shown} of {total}. Current layer: {layer} Показано шарів: {shown} із {total}. Поточний шар: {layer} Layout Аркуш Layout list Список аркушів @@ -413,6 +419,7 @@ Lines +/− Рядки +/− Lines added Додані рядки Lines deleted Видалені рядки + Lines of Command History Рядки історії команд Linetype Тип лінії Linetype Scale Масштаб типу лінії Linetype scale Масштаб типу лінії @@ -948,6 +955,7 @@ Text override Перевизначення тексту Text style Текстовий стиль Thaw All Layers Розморозити всі шари + Thaw Layer Розморозити шар There is nothing here yet. Тут поки що нічого немає. Thickness Висота видавлювання Time Час @@ -964,6 +972,7 @@ True Color Колір RGB Turn All Layers On Увімкнути всі шари Turn Off Layer Вимкнути шар + Turn On Layer Увімкнути шар Turn Quick Properties off (QPMODE) Вимкнути швидкі властивості (QPMODE) Type Тип Type a command Введіть команду diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 4b5acb9bb..ce7e130ac 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -31,6 +31,7 @@ pub mod palettes; pub mod parametric; pub mod plotstyles; pub mod quick; +pub mod shortcut_menus; pub mod theme; pub mod viewcube; pub mod vpscale; diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 74d22dc26..22e6a2da7 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -52,6 +52,7 @@ pub const UI_COMMANDS: &[(&str, &str, &[&str], Option<&str>)] = &[ ("ui.dialog.commands", "Command Reference", &["Help", "CADCraft Help"], Some("F1")), ("ui.toggle.history", "Command History", &["Window", "Command History"], Some("F2")), ("ui.cmdline.lines", "Command Line History Lines", &[], None), + ("ui.cmdline.clear", "Clear Command History", &[], None), ("ui.dialog.language", "Interface Language", &["Edit", "Interface Language"], None), ("ui.language", "Language", &[], None), ("ui.noop", "", &[], None), @@ -263,6 +264,10 @@ fn run_ui_command_inner(app: &mut CadApp, id: &str, params: &Value) -> Option { + app.session.log.clear(); + Ok(Value::Null) + } "ui.dialog.language" | "ui.dialog.layers" | "ui.dialog.blocks" diff --git a/crates/ui-egui/src/palettes.rs b/crates/ui-egui/src/palettes.rs index 5346bd89f..02b1eabf3 100644 --- a/crates/ui-egui/src/palettes.rs +++ b/crates/ui-egui/src/palettes.rs @@ -421,9 +421,11 @@ pub(crate) fn layers_section(app: &mut CadApp, ui: &mut egui::Ui) { } let (sr, _) = ui.allocate_exact_size(vec2(12.0, 12.0), Sense::hover()); swatch(ui.painter(), sr, *color); - if ui.selectable_label(name.eq_ignore_ascii_case(&cur), name).clicked() { + let r = ui.selectable_label(name.eq_ignore_ascii_case(&cur), name); + if r.clicked() { set_current = Some(name.clone()); } + crate::shortcut_menus::attach(app, &r, |app| crate::shortcut_menus::layer_entries(app, name)); }); } } diff --git a/crates/ui-egui/src/shortcut_menus.rs b/crates/ui-egui/src/shortcut_menus.rs new file mode 100644 index 000000000..98dc52dc9 --- /dev/null +++ b/crates/ui-egui/src/shortcut_menus.rs @@ -0,0 +1,245 @@ +//! Right-click menus outside the drawing area, after AutoCAD's shortcut menus: the command line, +//! the drawing (file) tabs, the layout tabs, and the Layers and Properties palettes. (The +//! drawing area's menus are [`crate::context_menu`].) +//! +//! Every item goes through a command: an item id is a command id, started like a menu item, or +//! one of the forms [`choose`] reads: `run::`, `layout::`, +//! `drawing::` and `cmdline:`. Items for commands that aren't registered are +//! left out. + +use cadcraft_engine::{Input, find_command}; +use serde_json::{Value, json}; + +use crate::CadApp; +use crate::menus::{Entry, activate}; + +/// How many recent commands the command line menu lists. +const RECENT: usize = 8; +/// The command line history heights offered (lines). +const LINES: &[usize] = &[0, 1, 3, 5, 8, 12]; + +fn item(app: &CadApp, id: &str, label: Option<&str>) -> Option { + let spec = find_command(id).map(|s| (s.label, s.shortcut, (s.enabled)(&app.session).is_ok())); + let ui = crate::menus::UI_COMMANDS.iter().find(|c| c.0 == id).map(|c| (c.1, c.3, true)); + let (default, shortcut, enabled) = spec.or(ui)?; + Some(Entry::Item { label: label.unwrap_or(default).to_string(), id: id.to_string(), shortcut: shortcut.map(str::to_string), enabled }) +} + +/// An item that is not a command of its own (it runs one with parameters, or acts on a tab). +fn action(label: &str, id: String, enabled: bool) -> Entry { + Entry::Item { label: label.into(), id, shortcut: None, enabled } +} + +/// An item running `command` with `params`, when the command exists. +fn run(label: &str, command: &str, params: Value) -> Option { + let known = find_command(command).is_some() || crate::menus::is_ui_command(command); + known.then(|| action(label, format!("run:{command}:{params}"), true)) +} + +fn push(groups: &mut Vec>, group: Vec) { + if !group.is_empty() { + groups.push(group); + } +} + +/// The command line's menu: Recent Commands, Dynamic Input, the history window and its height, +/// Copy History and Clear. +pub fn command_line_entries(app: &CadApp) -> Vec> { + let mut groups = Vec::new(); + let recent: Vec = app + .session + .recent_commands(RECENT) + .iter() + .filter_map(|id| find_command(id)) + .map(|c| Entry::Item { label: c.id.to_ascii_uppercase(), id: c.id.to_string(), shortcut: None, enabled: (c.enabled)(&app.session).is_ok() }) + .collect(); + if !recent.is_empty() { + groups.push(vec![Entry::Sub { label: "Recent Commands".into(), children: recent }]); + } + push(&mut groups, ["dynmode", "ui.toggle.history"].iter().filter_map(|id| item(app, id, None)).collect()); + let lines: Vec = LINES.iter().filter_map(|n| run(&n.to_string(), "ui.cmdline.lines", json!({ "lines": n }))).collect(); + if !lines.is_empty() { + groups.push(vec![Entry::Sub { label: "Lines of Command History".into(), children: lines }]); + } + let mut edit = vec![action("Copy History", "cmdline:copy".into(), !app.session.log.is_empty())]; + edit.extend(item(app, "ui.cmdline.clear", None)); + groups.push(edit); + groups +} + +/// A drawing tab's menu (`index` into the open drawings): New, Open, Save, Save As, Save All, +/// Close, Close All Other Drawings and Copy Full File Path. Save and Save As act on that drawing. +pub fn drawing_tab_entries(app: &CadApp, index: usize) -> Vec> { + let path = app.session.docs.get(index).and_then(|d| d.path.clone()); + let others = app.session.docs.len() > 1; + let mut groups = Vec::new(); + push(&mut groups, ["new", "ui.open"].iter().filter_map(|id| item(app, id, None)).collect()); + let save: Vec = [("qsave", "Save"), ("ui.saveas", "Save As...")] + .iter() + .filter_map(|(id, label)| item(app, id, Some(label)).map(|_| action(label, format!("drawing:{id}:{index}"), true))) + .chain(item(app, "saveall", None)) + .collect(); + push(&mut groups, save); + let close = vec![ + action("Close", format!("drawing:close:{index}"), true), + action("Close All Other Drawings", format!("drawing:closeothers:{index}"), others), + ]; + push(&mut groups, close); + groups.push(vec![action("Copy Full File Path", format!("drawing:copypath:{index}"), path.is_some())]); + groups +} + +/// A layout tab's menu (`name` is the layout, "Model" the model tab): New Layout, From Template, +/// Delete, Rename and Copy (layouts only), Page Setup Manager and Print. Delete, Rename and Copy +/// go through LAYOUT's prompts, so Rename and Copy ask for the new name on the command line. +pub fn layout_tab_entries(app: &CadApp, name: &str) -> Vec> { + let model = name == "Model"; + let layout = |op: &str, label: &str| action(label, format!("layout:{op}:{name}"), true); + let mut groups = Vec::new(); + let mut new: Vec = item(app, "layout.new", None).map(|_| layout("new", "New Layout")).into_iter().collect(); + if find_command("layout").is_some() { + new.push(layout("template", "From Template...")); + if !model { + new.extend([layout("delete", "Delete Layout"), layout("rename", "Rename Layout"), layout("copy", "Copy Layout")]); + } + } + push(&mut groups, new); + let out: Vec = [("pagesetup", "Page Setup Manager..."), ("plot", "Print...")] + .iter() + .filter(|(id, _)| find_command(id).is_some()) + .map(|(id, label)| layout(id, label)) + .collect(); + push(&mut groups, out); + groups +} + +/// A layer's menu in the Layers palette: Set Current, On/Off, Freeze/Thaw, Lock/Unlock, Delete and +/// the Layer Properties Manager. +pub fn layer_entries(app: &CadApp, name: &str) -> Vec> { + let Ok(d) = app.session.doc() else { return Vec::new() }; + let Some(l) = d.layers.iter().find(|l| l.name.eq_ignore_ascii_case(name)) else { return Vec::new() }; + let set = |label: &str, key: &str, value: bool| run(label, "layer.set", json!({ "name": name, key: value })); + let mut groups = Vec::new(); + push(&mut groups, run("Set Current", "layer.current", json!({ "name": name })).into_iter().collect()); + push( + &mut groups, + [ + if l.on { set("Turn Off Layer", "on", false) } else { set("Turn On Layer", "on", true) }, + if l.frozen { set("Thaw Layer", "frozen", false) } else { set("Freeze Layer", "frozen", true) }, + if l.locked { set("Unlock Layer", "locked", false) } else { set("Lock Layer", "locked", true) }, + ] + .into_iter() + .flatten() + .collect(), + ); + push(&mut groups, run("Delete Layer", "layer.delete", json!({ "name": name })).into_iter().collect()); + push(&mut groups, item(app, "ui.dialog.layers", None).into_iter().collect()); + groups +} + +/// The Properties palette's menu: Quick Select, Select All, Deselect All, and hiding the palette. +pub fn properties_entries(app: &CadApp) -> Vec> { + let mut groups = Vec::new(); + push(&mut groups, ["qselect", "selectall", "ai_deselect"].iter().filter_map(|id| item(app, id, None)).collect()); + push(&mut groups, run("Hide Palette", "ui.dock", json!({ "operation": "close", "panel": "properties" })).into_iter().collect()); + groups +} + +/// Run a chosen item. `ctx` is for the items that copy text to the system clipboard. +pub fn choose(app: &mut CadApp, ctx: &egui::Context, id: &str) { + if let Some(rest) = id.strip_prefix("run:") { + let Some((command, params)) = rest.split_once(':') else { return }; + let params = serde_json::from_str(params).unwrap_or(Value::Null); + if let Err(e) = app.run(command, params) { + app.session.echo(e); + } + } else if id == "cmdline:copy" { + ctx.copy_text(app.session.log.join("\n")); + } else if let Some(rest) = id.strip_prefix("drawing:") { + let Some((op, index)) = rest.split_once(':') else { return }; + let Ok(index) = index.parse::() else { return }; + drawing_action(app, ctx, op, index); + } else if let Some(rest) = id.strip_prefix("layout:") { + let Some((op, name)) = rest.split_once(':') else { return }; + layout_action(app, op, name); + } else { + activate(app, id); + } +} + +fn drawing_action(app: &mut CadApp, ctx: &egui::Context, op: &str, index: usize) { + match op { + "close" => app.request_close(Some(index)), + "closeothers" => app.request_close_others(index), + "copypath" => { + if let Some(p) = app.session.docs.get(index).and_then(|d| d.path.clone()) { + ctx.copy_text(p); + } + } + // Save, Save As: on that drawing. + command => { + if index != app.session.active { + let _ = app.run("document.switch", json!({ "index": index })); + } + app.ui.start_tab = false; + activate(app, command); + } + } +} + +/// Give the running command a keyword, then type `text` (a name; the prompt takes it whole). +fn keyword_then(app: &mut CadApp, keyword: &str, text: Option<&str>) { + if let Err(e) = app.session.input(Input::Keyword(keyword.into())) { + app.session.echo(e.to_string()); + return; + } + if let Some(t) = text { + app.cmdline(t); + } +} + +fn layout_action(app: &mut CadApp, op: &str, name: &str) { + match op { + "new" => { + if let Ok(v) = app.run("layout.new", json!({})) + && let Some(n) = v.get("name").and_then(Value::as_str) + { + let _ = app.run("layout.set", json!({ "name": n })); + } + } + "pagesetup" | "plot" => { + let _ = app.run("layout.set", json!({ "name": name })); + activate(app, op); + } + // LAYOUT's options, as typed: Template asks for the file, Rename and Copy for the new name. + "template" | "delete" | "rename" | "copy" => { + activate(app, "layout"); + if app.session.running.as_ref().is_some_and(|r| r.id == "layout") { + let keyword = match op { + "template" => "Template", + "delete" => "Delete", + "rename" => "Rename", + _ => "Copy", + }; + keyword_then(app, keyword, (op != "template").then_some(name)); + } + } + _ => {} + } +} + +/// Show `groups` as the context menu of `resp` and run the chosen item. +pub fn attach(app: &mut CadApp, resp: &egui::Response, groups: impl FnOnce(&CadApp) -> Vec>) { + let mut clicked = None; + resp.context_menu(|ui| crate::context_menu::groups_ui(ui, &groups(app), &mut clicked)); + if let Some(id) = clicked { + choose(app, &resp.ctx, &id); + } +} + +/// A palette's content with a right-click menu on its background (rows with menus of their own +/// keep theirs). +pub fn palette(app: &mut CadApp, ui: &mut egui::Ui, content: fn(&mut CadApp, &mut egui::Ui), groups: fn(&CadApp) -> Vec>) { + let r = ui.scope_builder(egui::UiBuilder::new().sense(egui::Sense::click()), |ui| content(app, ui)); + attach(app, &r.response, groups); +} diff --git a/crates/ui-egui/tests/shortcut_menus.rs b/crates/ui-egui/tests/shortcut_menus.rs new file mode 100644 index 000000000..eb132b8c8 --- /dev/null +++ b/crates/ui-egui/tests/shortcut_menus.rs @@ -0,0 +1,167 @@ +//! Right-click menus outside the drawing area (#512): the command line, drawing tabs, layout +//! tabs, and the Layers and Properties palettes. + +use cadcraft_engine::Session; +use cadcraft_ui_egui::menus::Entry; +use cadcraft_ui_egui::{CadApp, Services, shortcut_menus as sm}; +use serde_json::json; + +/// (label, id, enabled) of the items, with submenu items prefixed by "Sub/". +fn flatten(groups: &[Vec]) -> Vec<(String, String, bool)> { + fn walk(prefix: &str, es: &[Entry], out: &mut Vec<(String, String, bool)>) { + for e in es { + match e { + Entry::Item { label, id, enabled, .. } => out.push((format!("{prefix}{label}"), id.clone(), *enabled)), + Entry::Sub { label, children } => walk(&format!("{prefix}{label}/"), children, out), + } + } + } + let mut out = Vec::new(); + for g in groups { + walk("", g, &mut out); + } + out +} + +/// The id of the item labelled `label`, and whether it is enabled. +fn find(groups: &[Vec], label: &str) -> Option<(String, bool)> { + flatten(groups).into_iter().find(|(l, _, _)| l == label).map(|(_, id, e)| (id, e)) +} + +fn choose(app: &mut CadApp, groups: &[Vec], label: &str) { + let (id, enabled) = find(groups, label).unwrap_or_else(|| panic!("{label} missing from {:?}", flatten(groups))); + assert!(enabled, "{label} is disabled"); + sm::choose(app, &egui::Context::default(), &id); +} + +fn app() -> CadApp { + CadApp::new(Session::new(), Services::default()) +} + +fn layout_names(app: &CadApp) -> Vec { + app.session.doc().unwrap().layouts.iter().map(|l| l.name.clone()).collect() +} + +#[test] +fn command_line_menu() { + let mut app = app(); + app.cmdline("LINE 0,0 10,0"); + app.cmdline(""); + app.cmdline("CIRCLE 0,0 2"); + let g = sm::command_line_entries(&app); + assert_eq!(find(&g, "Recent Commands/CIRCLE").map(|f| f.0).as_deref(), Some("circle")); + assert!(find(&g, "Recent Commands/LINE").is_some()); + assert!(find(&g, "Dynamic Input").is_some() && find(&g, "Command History").is_some()); + assert!(find(&g, "Copy History").is_some_and(|(_, e)| e)); + choose(&mut app, &g, "Lines of Command History/5"); + assert_eq!(app.ui.history_lines, 5); + choose(&mut app, &g, "Recent Commands/LINE"); + assert_eq!(app.session.running.as_ref().map(|r| r.id.as_str()), Some("line")); + app.session.cancel(); + choose(&mut app, &g, "Clear Command History"); + assert!(app.session.log.is_empty()); + assert!(find(&sm::command_line_entries(&app), "Copy History").is_some_and(|(_, e)| !e), "nothing to copy"); +} + +#[test] +fn drawing_tab_menu() { + let mut app = app(); + app.run("new", json!({})).unwrap(); + app.run("new", json!({})).unwrap(); + assert_eq!(app.session.docs.len(), 3); + let g = sm::drawing_tab_entries(&app, 1); + for l in ["New Drawing...", "Open...", "Save", "Save As...", "Close", "Close All Other Drawings"] { + assert!(find(&g, l).is_some_and(|(_, e)| e), "{l} missing or disabled in {:?}", flatten(&g)); + } + assert!(find(&g, "Copy Full File Path").is_some_and(|(_, e)| !e), "an unsaved drawing has no path"); + let keep = app.session.docs[1].uid; + choose(&mut app, &g, "Close All Other Drawings"); + assert_eq!(app.session.docs.iter().map(|d| d.uid).collect::>(), [keep]); + let g = sm::drawing_tab_entries(&app, 0); + assert!(find(&g, "Close All Other Drawings").is_some_and(|(_, e)| !e), "no other drawing"); + choose(&mut app, &g, "Close"); + assert!(app.session.docs.is_empty()); +} + +#[test] +fn layout_tab_menu() { + let mut app = app(); + let before = layout_names(&app); + assert!(before.len() >= 2, "{before:?}"); + let model = sm::layout_tab_entries(&app, "Model"); + assert!(find(&model, "New Layout").is_some() && find(&model, "From Template...").is_some()); + assert!(find(&model, "Delete Layout").is_none() && find(&model, "Rename Layout").is_none(), "the model tab stays"); + + // Rename asks for the new name on the command line. + let first = before[0].clone(); + let g = sm::layout_tab_entries(&app, &first); + choose(&mut app, &g, "Rename Layout"); + assert!(app.session.prompt_text().contains("new layout name"), "{}", app.session.prompt_text()); + app.cmdline("Sheet A"); + assert!(layout_names(&app).contains(&"Sheet A".to_string()), "{:?}", layout_names(&app)); + + let second = before[1].clone(); + let g = sm::layout_tab_entries(&app, &second); + choose(&mut app, &g, "Delete Layout"); + assert!(!layout_names(&app).contains(&second)); + + let n = layout_names(&app).len(); + choose(&mut app, &model, "New Layout"); + assert_eq!(layout_names(&app).len(), n + 1); + assert!(matches!(app.session.state().unwrap().space, cadcraft_doc::Space::Paper(_)), "the new layout is current"); + + // Page Setup acts on the right-clicked tab. + let g = sm::layout_tab_entries(&app, "Sheet A"); + assert!(find(&g, "Page Setup Manager...").is_some() && find(&g, "Print...").is_some()); +} + +#[test] +fn layer_and_properties_palette_menus() { + let mut app = app(); + app.run("layer.new", json!({ "name": "Walls" })).unwrap(); + let g = sm::layer_entries(&app, "Walls"); + choose(&mut app, &g, "Turn Off Layer"); + let walls = |app: &CadApp| app.session.doc().unwrap().layers.iter().find(|l| l.name == "Walls").cloned().unwrap(); + assert!(!walls(&app).on); + let g = sm::layer_entries(&app, "Walls"); + assert!(find(&g, "Turn On Layer").is_some() && find(&g, "Freeze Layer").is_some() && find(&g, "Lock Layer").is_some()); + choose(&mut app, &g, "Set Current"); + assert_eq!(app.session.doc().unwrap().header.str("CLAYER", "0"), "Walls"); + assert!(sm::layer_entries(&app, "NoSuchLayer").is_empty()); + + let g = sm::properties_entries(&app); + for l in ["Quick Select...", "Select All", "Deselect All", "Hide Palette"] { + assert!(find(&g, l).is_some(), "{l} missing from {:?}", flatten(&g)); + } +} + +#[test] +fn every_label_has_a_ukrainian_translation_and_every_id_a_command() { + let mut app = app(); + app.cmdline("LINE 0,0 10,0"); + app.cmdline(""); + let mut all = flatten(&sm::command_line_entries(&app)); + all.extend(flatten(&sm::drawing_tab_entries(&app, 0))); + all.extend(flatten(&sm::layout_tab_entries(&app, "Model"))); + all.extend(flatten(&sm::layout_tab_entries(&app, "Layout1"))); + all.extend(flatten(&sm::layer_entries(&app, "0"))); + all.extend(flatten(&sm::properties_entries(&app))); + let mut missing = Vec::new(); + for (label, id, _) in &all { + let segments: Vec<&str> = label.split('/').collect(); + // Command names (Recent Commands) and line counts aren't translated. + let n = segments.len() - usize::from(segments.len() > 1); + for s in &segments[..n] { + if cadcraft_ui_egui::i18n::tr("uk", s) == *s { + missing.push(s.to_string()); + } + } + let command = id.strip_prefix("run:").and_then(|r| r.split_once(':')).map_or(id.as_str(), |(c, _)| c); + let special = ["drawing:", "layout:", "cmdline:"].iter().any(|p| id.starts_with(p)); + assert!( + special || cadcraft_engine::find_command(command).is_some() || cadcraft_ui_egui::menus::is_ui_command(command), + "{label}: {id} isn't a command" + ); + } + assert!(missing.is_empty(), "untranslated menu labels: {missing:?}"); +} diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 7097f26cc..1008b6e16 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -188,10 +188,14 @@ down-arrow options menu (DYNPROMPT), and DYNDIM. shortcuts. AutoCAD for Mac's defaults are similar in number; F4 (3D osnap), F5 (isoplane), F6 (dynamic UCS) are missing. - Delete erases the selection on macOS (#64); Escape cancels. -- Right-click on the canvas is Enter while a command runs; otherwise it opens a shortcut menu - (Repeat, Clipboard ▸ Cut/Copy/Copy with Base Point/Paste/Paste as Block/Paste to Original - Coordinates, the selection's editing commands, Undo/Redo, Pan/Zoom; #325). Still missing: - AutoCAD's per-command menus and the menus of the command line, palettes and tabs. +- Right-click on the canvas is Enter while a command runs; holding it (250 ms, AutoCAD's + time-sensitive right-click) opens the command menu: Enter, Cancel, Recent Input, the prompt's + options, Snap Overrides, Pan/Zoom. Otherwise it opens a shortcut menu (Repeat, Recent Commands, + Clipboard ▸, Isolate ▸, the selection's editing commands, Draw Order ▸, Group, the selected + object type's edit commands, Select Similar, Deselect All, Undo/Redo, Pan/Zoom; #325, #512). + The command line, drawing tabs, layout tabs, Layers palette rows and the Properties palette + have menus too (#512). Still missing: in-place layout tab rename, Move or Copy, Select All + Layouts. ## Revision history