diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 0c32037..21b3a46 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -17,6 +17,7 @@ pub mod grips; mod guard; pub mod pointmod; pub mod prompt; +mod recent; pub mod sample; pub mod select; pub mod snap; diff --git a/crates/engine/src/recent.rs b/crates/engine/src/recent.rs new file mode 100644 index 0000000..d6af734 --- /dev/null +++ b/crates/engine/src/recent.rs @@ -0,0 +1,25 @@ +//! What the user did recently, read from the command log: the shortcut menus' Recent Commands. + +use crate::{Session, find_command}; + +/// How a started command appears in the log ([`Session::start`]). +const STARTED: &str = "Command: "; + +impl Session { + /// The commands started most recently (typed, from a menu or repeated), newest first, each + /// once, at most `max`. Ids are registered command ids. + pub fn recent_commands(&self, max: usize) -> Vec { + let mut out: Vec = Vec::new(); + for line in self.log.iter().rev() { + if out.len() >= max { + break; + } + let Some(name) = line.strip_prefix(STARTED) else { continue }; + let Some(spec) = find_command(name.trim()) else { continue }; + if !out.iter().any(|c| c == spec.id) { + out.push(spec.id.to_string()); + } + } + out + } +} diff --git a/crates/engine/tests/recent_commands.rs b/crates/engine/tests/recent_commands.rs new file mode 100644 index 0000000..eb65b1e --- /dev/null +++ b/crates/engine/tests/recent_commands.rs @@ -0,0 +1,20 @@ +//! Recent Commands for the shortcut menus (#512): the commands started most recently, newest +//! first, each once. + +use cadcraft_engine::Session; + +#[test] +fn recent_commands_newest_first_without_repeats() { + let mut s = Session::new(); + assert!(s.recent_commands(5).is_empty()); + for line in ["LINE 0,0 10,0", "", "CIRCLE 0,0 5", "L 0,0 0,10", "", "ZOOM E"] { + s.cmdline(line).unwrap(); + } + assert_eq!(s.recent_commands(5), ["zoom", "line", "circle"]); + assert_eq!(s.recent_commands(2), ["zoom", "line"]); + assert!(s.recent_commands(0).is_empty()); + // Unknown names never get in. + assert!(s.cmdline("NOSUCHCOMMAND").is_err()); + s.echo("Command: NOSUCHCOMMAND"); + assert_eq!(s.recent_commands(5), ["zoom", "line", "circle"]); +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 6fcd19f..8fdc391 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -757,13 +757,14 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) { } } } - // Right-click acts as Enter while a command runs (the classic CAD default); otherwise it opens - // the shortcut menu (Repeat, Clipboard, …). Shift/Ctrl+right-click opens the object snap menu. + // Right-click acts as Enter while a command runs (the classic CAD default; held, it opens the + // command menu); otherwise it opens the shortcut menu (Repeat, Clipboard, …). + // Shift/Ctrl+right-click opens the object snap menu. let idle = app.session.running.is_none(); if inside && pressed_secondary { app.canvas.snap_menu = mods.shift || mods.ctrl; } - if inside && pressed_secondary && !idle && !app.canvas.snap_menu { + if crate::context_menu::in_command(app, ui, inside, pressed_secondary) { let _ = app.session.input(Input::Enter); } // Right-click on a grip with options (polyline vertex, line end…) opens the grip menu. diff --git a/crates/ui-egui/src/cmdline.rs b/crates/ui-egui/src/cmdline.rs index aa24bfa..4f80352 100644 --- a/crates/ui-egui/src/cmdline.rs +++ b/crates/ui-egui/src/cmdline.rs @@ -16,6 +16,9 @@ pub struct CmdLine { typed_since_selection: bool, pub history: Vec, pub history_pos: Option, + /// What was typed at command prompts (not command names), oldest first: the in-command + /// shortcut menu's Recent Input. + pub recent_input: Vec, /// The expanded history window is open (F2, the chevron, or a click on the history lines). pub expanded: bool, /// Dragging the history's top edge: (pointer y, line count) when the drag started. @@ -221,6 +224,7 @@ pub fn submit(app: &mut CadApp) { app.cmd.history.remove(0); } } + crate::context_menu::note_input(app, &text); if text.is_empty() { if let Err(e) = app.session.input(Input::Enter) { app.session.echo(e.to_string()); diff --git a/crates/ui-egui/src/context_menu.rs b/crates/ui-egui/src/context_menu.rs index 4dceb52..1bb980b 100644 --- a/crates/ui-egui/src/context_menu.rs +++ b/crates/ui-egui/src/context_menu.rs @@ -1,32 +1,57 @@ -//! The canvas's right-click menu while no command runs, after AutoCAD's default and edit-mode -//! shortcut menus: Repeat the last command, the Clipboard commands, editing commands for a -//! selection, Undo/Redo, Pan/Zoom, Quick Select and Find. (While a command runs, a right-click -//! is Enter.) Every item starts a command, like choosing it from the menu bar. +//! The canvas's right-click menus, after AutoCAD's shortcut menus: //! -//! Shift+right-click or Ctrl+right-click opens the object snap menu instead ([`snap_entries`]): -//! its items type the point modifiers and snap overrides (`_from`, `_endp`, `_non`…) at the -//! current point prompt, exactly as if typed on the command line. +//! - **No command running** ([`entries`]): Repeat the last command, Recent Commands, the +//! Clipboard and Isolate; with a selection the editing commands, Draw Order, Group, the edit +//! commands of the selected object type, Select Similar and Deselect All; then Undo/Redo, +//! Pan/Zoom, Quick Select, Find, Properties and Options. +//! - **While a command runs** ([`command_entries`]): a quick right-click is Enter; holding the +//! button for [`HOLD_SECS`] opens Enter, Cancel, Recent Input, the prompt's options, Snap +//! Overrides and Pan/Zoom instead (AutoCAD's time-sensitive right-click). +//! - **Shift/Ctrl+right-click** ([`snap_entries`]): the object snap menu. Its items type the point +//! modifiers and snap overrides (`_from`, `_endp`, `_non`…) at the current point prompt. +//! +//! An item for a command appears only when that command is registered, so the menus pick up +//! commands as they land. Choosing an item ([`choose`]) starts its command like the menu bar, or +//! gives the running command the same input as typing it on the command line. -use cadcraft_engine::find_command; +use cadcraft_doc::EntityKind; use cadcraft_engine::snap::mode; +use cadcraft_engine::{Input, find_command}; use crate::CadApp; use crate::menus::{Entry, activate, entry_ui}; /// Clipboard ▸ (AutoCAD's order). const CLIPBOARD: &[&str] = &["cutclip", "copyclip", "copybase", "pasteclip", "pasteblock", "pasteorig"]; +/// Isolate ▸ (object isolation commands). +const ISOLATE: &[&str] = &["isolateobjects", "hideobjects", "unisolateobjects"]; /// With a selection: commands that act on it, and its label when it differs from the command's. -const SELECTION: &[(&str, Option<&str>)] = &[ - ("erase", None), - ("move", None), - ("copy", Some("Copy Selection")), - ("scale", None), - ("rotate", None), - ("ai_deselect", None), - ("properties", None), -]; +const EDIT: &[(&str, Option<&str>)] = &[("erase", None), ("move", None), ("copy", Some("Copy Selection")), ("scale", None), ("rotate", None)]; +/// Draw Order ▸. +const DRAW_ORDER: &[&str] = &["draworder.front", "draworder.back", "draworder.above", "draworder.under"]; +const GROUP: &[&str] = &["group", "ungroup"]; +/// With a selection: selection tools. +const SELECT: &[&str] = &["addselected", "selectsimilar", "ai_deselect"]; const VIEW: &[&str] = &["u", "redo", "pan", "zoom"]; const TOOLS: &[&str] = &["qselect", "find"]; +/// Last in the menu: Properties with a selection, Options always. +const SETTINGS: &[&str] = &["options"]; + +/// How many recent commands and inputs the menus list. +pub const RECENT: usize = 8; +/// How long the right button must be held while a command runs to open the command menu instead +/// of acting as Enter (AutoCAD's time-sensitive right-click; 250 ms there by default). +pub const HOLD_SECS: f64 = 0.25; + +/// Item ids that are not commands: keys, typed text and snap menu items. +pub const ENTER: &str = "key:enter"; +pub const CANCEL: &str = "key:cancel"; +/// Types the rest at the prompt (Recent Input, Pan/Zoom). +pub const TYPE: &str = "type:"; +/// Gives the running command the keyword that follows. +pub const KEYWORD: &str = "keyword:"; +/// An object snap menu item ([`choose_snap`]). +pub const SNAP: &str = "snap:"; fn item(app: &CadApp, id: &str, label: Option<&str>) -> Option { let spec = find_command(id)?; @@ -38,23 +63,171 @@ fn item(app: &CadApp, id: &str, label: Option<&str>) -> Option { }) } -/// The menu's groups of entries (separated by lines). +fn items(app: &CadApp, ids: &[&str]) -> Vec { + ids.iter().filter_map(|id| item(app, id, None)).collect() +} + +/// A submenu, left out when none of its commands exist. +fn sub(label: &str, children: Vec) -> Option { + (!children.is_empty()).then(|| Entry::Sub { label: label.into(), children }) +} + +/// Push a group unless it is empty. +fn push(groups: &mut Vec>, group: Vec) { + if !group.is_empty() { + groups.push(group); + } +} + +/// The idle menu's groups of entries (separated by lines). pub fn entries(app: &CadApp) -> Vec> { let mut groups = Vec::new(); + let mut first = Vec::new(); if let Some(last) = app.session.last_command.as_deref().and_then(find_command) { let label = crate::tf!("Repeat {command}", command = last.id.to_ascii_uppercase()); - groups.push(vec![Entry::Item { label, id: last.id.to_string(), shortcut: None, enabled: (last.enabled)(&app.session).is_ok() }]); + first.push(Entry::Item { label, id: last.id.to_string(), shortcut: None, enabled: (last.enabled)(&app.session).is_ok() }); } - let clip = CLIPBOARD.iter().filter_map(|id| item(app, id, None)).collect(); - groups.push(vec![Entry::Sub { label: "Clipboard".into(), children: clip }]); - if !app.session.selection().is_empty() { - groups.push(SELECTION.iter().filter_map(|(id, label)| item(app, id, *label)).collect()); + 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(); + first.extend(sub("Recent Commands", recent)); + push(&mut groups, first); + let mut clip: Vec = sub("Clipboard", items(app, CLIPBOARD)).into_iter().collect(); + clip.extend(sub("Isolate", items(app, ISOLATE))); + push(&mut groups, clip); + let selected = !app.session.selection().is_empty(); + if selected { + let mut edit: Vec = EDIT.iter().filter_map(|(id, label)| item(app, id, *label)).collect(); + edit.extend(sub("Draw Order", items(app, DRAW_ORDER))); + edit.extend(items(app, GROUP)); + push(&mut groups, edit); + push(&mut groups, object_entries(app)); + push(&mut groups, items(app, SELECT)); } - groups.push(VIEW.iter().filter_map(|id| item(app, id, None)).collect()); - groups.push(TOOLS.iter().filter_map(|id| item(app, id, None)).collect()); + push(&mut groups, items(app, VIEW)); + push(&mut groups, items(app, TOOLS)); + let mut last = if selected { items(app, &["properties"]) } else { Vec::new() }; + last.extend(items(app, SETTINGS)); + push(&mut groups, last); groups } +/// The edit commands of the selected objects' type (when they are all of one type), with their +/// labels: Edit Text for text, Block Editor and Edit Attributes for block references, Hatch Edit, +/// Polyline Edit, Spline Edit… +fn object_commands(app: &CadApp) -> Vec<(&'static str, Option<&'static str>)> { + let Ok(d) = app.session.doc() else { return Vec::new() }; + let sel = app.session.selection(); + let ents: Vec<_> = sel.iter().filter_map(|h| d.entity(*h)).collect(); + let Some(first) = ents.first() else { return Vec::new() }; + let ty = first.kind.type_name(); + if ents.iter().any(|e| e.kind.type_name() != ty) { + return Vec::new(); + } + match &first.kind { + EntityKind::Text(_) | EntityKind::MText(_) | EntityKind::AttDef(_) => vec![("textedit", None)], + EntityKind::Insert(_) => { + let mut v = vec![("bedit", None), ("refedit", None)]; + if ents.iter().any(|e| matches!(&e.kind, EntityKind::Insert(i) if !i.attribs.is_empty())) { + v.push(("attedit", Some("Edit Attributes..."))); + } + v + } + EntityKind::Hatch(_) => vec![("hatchedit", None)], + EntityKind::LwPolyline(_) | EntityKind::Polyline3d(_) => vec![("pedit", Some("Polyline Edit"))], + EntityKind::Spline(_) => vec![("splinedit", Some("Spline Edit"))], + EntityKind::Dimension(_) => vec![("dimtedit", None)], + EntityKind::MLeader(_) => vec![("mleaderedit", None)], + EntityKind::Viewport(_) => vec![("vpclip", None)], + EntityKind::Image(_) => vec![("imageclip", None)], + _ => Vec::new(), + } +} + +/// [`object_commands`] that are registered, as menu items. +pub fn object_entries(app: &CadApp) -> Vec { + object_commands(app).into_iter().filter_map(|(id, label)| item(app, id, label)).collect() +} + +/// The command menu while a command runs (`None` when none does): Enter and Cancel, Recent Input, +/// the prompt's options, Snap Overrides at point prompts, Pan and Zoom. +pub fn command_entries(app: &CadApp) -> Option>> { + let prompt = app.session.current_prompt()?; + let typed = |label: &str, id: String| Entry::Item { label: label.into(), id, shortcut: None, enabled: true }; + let mut groups = vec![vec![typed("Enter", ENTER.into()), typed("Cancel", CANCEL.into())]]; + let recent: Vec = app.cmd.recent_input.iter().rev().take(RECENT).map(|t| typed(t, format!("{TYPE}{t}"))).collect(); + push(&mut groups, sub("Recent Input", recent).into_iter().collect()); + push(&mut groups, prompt.keywords.iter().map(|k| typed(k, format!("{KEYWORD}{k}"))).collect()); + if cadcraft_engine::pointmod::accepts(&prompt) { + let mut snaps = Vec::new(); + for g in snap_entries(app) { + for e in g { + snaps.push(prefix_ids(e, SNAP)); + } + } + push(&mut groups, sub("Snap Overrides", snaps).into_iter().collect()); + } + // Typed transparently ('zoom.extents), so the command keeps running. + let view = |label: &str, ids: &[&str]| { + let children = ids.iter().filter_map(|id| item(app, id, None)).map(|e| prefix_ids(e, &format!("{TYPE}'"))).collect(); + sub(label, children) + }; + let pan = view("Pan", &["pan.left", "pan.right", "pan.up", "pan.down"]); + let zoom = view("Zoom", &["zoom.extents", "zoom.all", "zoom.previous", "zoom.in", "zoom.out"]); + push(&mut groups, pan.into_iter().chain(zoom).collect()); + Some(groups) +} + +fn prefix_ids(e: Entry, prefix: &str) -> Entry { + match e { + Entry::Item { label, id, shortcut, enabled } => Entry::Item { label, id: format!("{prefix}{id}"), shortcut, enabled }, + Entry::Sub { label, children } => Entry::Sub { label, children: children.into_iter().map(|c| prefix_ids(c, prefix)).collect() }, + } +} + +/// Run a chosen item of any of the canvas menus. +pub fn choose(app: &mut CadApp, id: &str) { + if id == ENTER { + app.cmd.buffer.clear(); + app.cmdline(""); + } else if id == CANCEL { + // Escape. + app.cmd.buffer.clear(); + app.session.cancel(); + } else if let Some(text) = id.strip_prefix(TYPE) { + app.cmd.buffer.clear(); + app.cmdline(text); + } else if let Some(k) = id.strip_prefix(KEYWORD) { + app.cmd.buffer.clear(); + if let Err(e) = app.session.input(Input::Keyword(k.to_string())) { + app.session.echo(e.to_string()); + } + } else if let Some(s) = id.strip_prefix(SNAP) { + choose_snap(app, s); + } else { + activate(app, id); + } +} + +/// Remember a line typed at the command line for Recent Input (only lines typed at a command's +/// prompts; command names are the history's). +pub fn note_input(app: &mut CadApp, text: &str) { + let t = text.trim(); + if app.session.running.is_none() || t.is_empty() { + return; + } + let recent = &mut app.cmd.recent_input; + recent.retain(|r| r != t); + recent.push(t.to_string()); + if recent.len() > RECENT { + recent.remove(0); + } +} + /// The object snap menu's groups: (label, what it types) per item; the label of a snap mode is /// its name in [`mode::ALL`]. const SNAP_GROUPS: &[&[(u32, &str)]] = &[ @@ -95,6 +268,18 @@ pub fn choose_snap(app: &mut CadApp, id: &str) { } } +/// Lay out menu groups, separated by lines; the chosen item's id lands in `clicked`. +pub fn groups_ui(ui: &mut egui::Ui, groups: &[Vec], clicked: &mut Option) { + for (i, group) in groups.iter().enumerate() { + if i > 0 { + ui.separator(); + } + for e in group { + entry_ui(ui, e, clicked); + } + } +} + /// Attach the menu to the canvas response: it opens on a right-click, as the object snap menu /// when that click held Shift or Ctrl (`CanvasState::snap_menu`). pub fn show(app: &mut CadApp, resp: &egui::Response) { @@ -102,18 +287,61 @@ pub fn show(app: &mut CadApp, resp: &egui::Response) { let snap = app.canvas.snap_menu; resp.context_menu(|ui| { let groups = if snap { snap_entries(app) } else { entries(app) }; - for (i, group) in groups.iter().enumerate() { - if i > 0 { - ui.separator(); - } - for e in group { - entry_ui(ui, e, &mut clicked); - } - } + groups_ui(ui, &groups, &mut clicked); }); match clicked { Some(id) if snap => choose_snap(app, &id), - Some(id) => activate(app, &id), + Some(id) => choose(app, &id), None => {} } } + +/// When the right button went down on the canvas: (time, position). +#[derive(Clone, Copy, Default)] +struct Press(f64, egui::Pos2); + +/// The canvas's right button while a command runs (Shift/Ctrl+right-click is the snap menu's): a +/// quick click is Enter — returns `true` when the caller should send it — and a click held for +/// [`HOLD_SECS`] opens the command menu ([`command_entries`]) at the pointer. Call it every frame +/// with whether the pointer is on the canvas and whether a right-click ended there; it also shows +/// the menu while it is open. +pub fn in_command(app: &mut CadApp, ui: &egui::Ui, inside: bool, right_clicked: bool) -> bool { + let ctx = ui.ctx().clone(); + let press_id = egui::Id::new("cc_command_menu_press"); + let popup_id = egui::Id::new("cc_command_menu"); + let secondary = egui::PointerButton::Secondary; + let (pressed, released, now, pos) = + ctx.input(|i| (i.pointer.button_pressed(secondary), i.pointer.button_released(secondary), i.time, i.pointer.interact_pos())); + if pressed + && inside + && let Some(p) = pos + { + ctx.data_mut(|d| d.insert_temp(press_id, Press(now, p))); + } + let running = app.session.running.is_some() && !app.canvas.snap_menu; + let mut open = false; + if released && let Some(Press(t0, p0)) = ctx.data_mut(|d| d.remove_temp::(press_id)) { + // A drag isn't a click; a click held long enough asks for the menu. + let still = pos.is_some_and(|p| p.distance(p0) <= 6.0); + open = running && still && now - t0 >= HOLD_SECS; + } + let enter = running && inside && right_clicked && !open; + if !running { + return false; + } + let mut clicked = None; + let popup = egui::Popup::new(popup_id, ctx, egui::PopupAnchor::PointerFixed, ui.layer_id()) + .kind(egui::PopupKind::Menu) + .layout(egui::Layout::top_down_justified(egui::Align::Min)) + .style(egui::containers::menu::menu_style) + .open_memory(open.then_some(egui::SetOpenCommand::Bool(true))); + popup.show(|ui| { + if let Some(groups) = command_entries(app) { + groups_ui(ui, &groups, &mut clicked); + } + }); + if let Some(id) = clicked { + choose(app, &id); + } + enter +} diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index d372cdf..69afb60 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -213,6 +213,7 @@ Dynamic Input (F12) Динамічне введення (F12) E Сх Edit Редагування + Edit Attributes... Редагувати атрибути... Edit Text... Редагувати текст... Elevation Відмітка Ellipse Еліпс @@ -224,6 +225,7 @@ End Y Кінець Y End angle Кінцевий кут Endpoint Кінцева точка + Enter Ввід Enter Top, Zero or Bottom. Введіть Top, Zero або Bottom. Enter a fit data option Введіть параметр апроксимації Enter a pattern name Введіть назву візерунка @@ -591,6 +593,7 @@ Polygonal Багатокутний Polygonal Viewport Багатокутний видовий екран Polyline Полілінія + Polyline Edit Редагувати полілінію Position X Позиція X Position Y Позиція Y Position Z Позиція Z @@ -623,6 +626,8 @@ Realtime У реальному часі Reassociate Dimensions Відновити асоціативність розмірів Recent Нещодавні + Recent Commands Нещодавні команди + Recent Input Нещодавнє введення Recover... Відновити... Rectangle Прямокутник Rectangular Прямокутний @@ -764,6 +769,7 @@ Snap Mode Режим крокової прив'язки Snap Mode (F9) Режим крокової прив'язки (F9) Snap On (F9) Увімкнути крокову прив'язку (F9) + Snap Overrides Перевизначення прив'язок Snap X spacing Крок прив'язки X Snap and Grid Крокова прив'язка та сітка Snap type Тип крокової прив'язки @@ -898,6 +904,7 @@ Specify width for rectangles Укажіть ширину прямокутників Sphere Сфера Spline Сплайн + Spline Edit Редагувати сплайн Stack (type 1/2 then select) Дріб (введіть 1/2, потім виділіть) Standard Стандартний Start Початок diff --git a/crates/ui-egui/tests/context_menus.rs b/crates/ui-egui/tests/context_menus.rs new file mode 100644 index 0000000..109a421 --- /dev/null +++ b/crates/ui-egui/tests/context_menus.rs @@ -0,0 +1,243 @@ +//! The canvas's shortcut menus (#512): the command menu while a command runs (Enter, Cancel, +//! Recent Input, the prompt's options, Snap Overrides, Pan/Zoom), the selection menu with the +//! selected object type's edit commands, Recent Commands, and time-sensitive right-click. + +use cadcraft_engine::Session; +use cadcraft_ui_egui::menus::Entry; +use cadcraft_ui_egui::{CadApp, Services, cmdline, context_menu}; +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 +} + +fn labels(groups: &[Vec]) -> Vec { + flatten(groups).into_iter().map(|(l, _, _)| l).collect() +} + +fn ids(groups: &[Vec]) -> Vec { + flatten(groups).into_iter().map(|(_, id, _)| id).collect() +} + +fn app() -> CadApp { + CadApp::new(Session::new(), Services::default()) +} + +/// Select everything in model space. +fn select_all(app: &mut CadApp) { + let all = app.session.doc().unwrap().model.handles(); + app.session.set_selection(all); +} + +fn type_line(app: &mut CadApp, text: &str) { + app.cmd.buffer = text.into(); + cmdline::submit(app); +} + +#[test] +fn command_menu_offers_enter_cancel_options_snaps_and_views() { + let mut app = app(); + // Typed as is (Dynamic Input would make the second point relative). + app.session.settings.dynmode = false; + assert!(context_menu::command_entries(&app).is_none(), "no command, no command menu"); + type_line(&mut app, "LINE"); + type_line(&mut app, "0,0"); + type_line(&mut app, "10,0"); + let groups = context_menu::command_entries(&app).unwrap(); + let l = labels(&groups); + for want in + ["Enter", "Cancel", "Recent Input/10,0", "Recent Input/0,0", "Undo", "Snap Overrides/Endpoint", "Snap Overrides/From", "Zoom/Zoom Extents"] + { + assert!(l.iter().any(|x| x == want), "{want} missing from {l:?}"); + } + let recent: Vec<_> = l.iter().filter(|x| x.starts_with("Recent Input/")).collect(); + assert_eq!(recent, ["Recent Input/10,0", "Recent Input/0,0"], "newest first"); + assert!(!l.iter().any(|x| x == "Recent Input/LINE"), "command names aren't input"); + + // The prompt's option is the same input as typing it: LINE's Undo removes the segment. + context_menu::choose(&mut app, "keyword:Undo"); + assert!(app.session.prompt_text().contains("next point"), "{}", app.session.prompt_text()); + // Recent Input types the value again: the segment comes back. + context_menu::choose(&mut app, "type:10,0"); + // Zoom is transparent: the command keeps running. + context_menu::choose(&mut app, "type:'zoom.extents"); + assert_eq!(app.session.running.as_ref().map(|r| r.id.as_str()), Some("line")); + // Enter ends LINE with its one segment. + context_menu::choose(&mut app, context_menu::ENTER); + assert!(app.session.running.is_none()); + assert_eq!(app.session.doc().unwrap().model.handles().len(), 1); + + // A snap override is typed at the point prompt; Cancel is Escape. + type_line(&mut app, "CIRCLE"); + context_menu::choose(&mut app, "snap:_endp"); + assert_eq!(app.session.snap_override(), Some(cadcraft_engine::snap::mode::END)); + context_menu::choose(&mut app, context_menu::CANCEL); + assert!(app.session.running.is_none()); +} + +#[test] +fn snap_overrides_only_at_point_prompts() { + let mut app = app(); + app.run("line", json!({ "points": [[0, 0], [10, 0]] })).unwrap(); + type_line(&mut app, "ERASE"); + let l = labels(&context_menu::command_entries(&app).unwrap()); + assert!(l.iter().any(|x| x == "Enter")); + assert!(!l.iter().any(|x| x.starts_with("Snap Overrides/")), "Select objects isn't a point prompt: {l:?}"); +} + +#[test] +fn idle_menu_lists_recent_commands_and_selection_commands() { + let mut app = app(); + let l = labels(&context_menu::entries(&app)); + assert!(!l.iter().any(|x| x.starts_with("Recent Commands/")), "nothing ran yet"); + assert!(!ids(&context_menu::entries(&app)).iter().any(|i| i == "erase"), "no selection"); + app.cmdline("LINE 0,0 10,0"); + app.cmdline(""); + app.cmdline("CIRCLE 5,5 2"); + let g = context_menu::entries(&app); + let l = labels(&g); + let recent: Vec<_> = l.iter().filter(|x| x.starts_with("Recent Commands/")).collect(); + assert_eq!(recent, ["Recent Commands/CIRCLE", "Recent Commands/LINE"]); + assert_eq!(l.first().map(String::as_str), Some("Repeat CIRCLE")); + + select_all(&mut app); + let g = context_menu::entries(&app); + let ids = ids(&g); + for id in ["erase", "move", "copy", "scale", "rotate", "draworder.front", "draworder.back", "group", "ai_deselect", "properties", "qselect"] { + assert!(ids.iter().any(|x| x == id), "{id} missing from {ids:?}"); + } + // Items for commands that don't exist are left out, not stubbed. + for id in ids.iter().filter(|i| !i.contains(':')) { + assert!(cadcraft_engine::find_command(id).is_some() || cadcraft_ui_egui::menus::is_ui_command(id), "{id} isn't a command"); + } + // A line and a circle: no object-specific commands for a mixed selection. + assert!(context_menu::object_entries(&app).is_empty()); + // Choosing an item starts the command, acting on the selection. + context_menu::choose(&mut app, "erase"); + assert!(app.session.doc().unwrap().model.handles().is_empty()); +} + +#[test] +fn selection_menu_has_the_object_types_edit_commands() { + let cases = [ + ("text", json!({ "at": [0, 0], "text": "hi" }), "textedit"), + ("pline", json!({ "vertices": [[0, 0], [10, 0], [10, 5]] }), "pedit"), + ("spline", json!({ "fit": [[0, 0], [5, 5], [10, 0]] }), "splinedit"), + ]; + for (cmd, params, edit) in cases { + let mut app = app(); + app.run(cmd, params).unwrap(); + select_all(&mut app); + let g = vec![context_menu::object_entries(&app)]; + assert!(ids(&g).iter().any(|i| i == edit), "{cmd}: {edit} missing from {:?}", labels(&g)); + assert!(ids(&context_menu::entries(&app)).iter().any(|i| i == edit)); + } + // Polyline Edit, not the command's bare "Polyline" label. + let mut app = app(); + app.run("pline", json!({ "vertices": [[0, 0], [10, 0]] })).unwrap(); + select_all(&mut app); + assert_eq!(labels(&[context_menu::object_entries(&app)]), ["Polyline Edit"]); +} + +#[test] +fn every_menu_label_has_a_ukrainian_translation() { + let mut app = app(); + app.cmdline("LINE 0,0 10,0"); + app.cmdline(""); + app.run("pline", json!({ "vertices": [[0, 0], [10, 0]] })).unwrap(); + select_all(&mut app); + let mut all = flatten(&context_menu::entries(&app)); + let line = app.session.doc().unwrap().model.handles()[0]; + app.session.set_selection(app.session.doc().unwrap().model.handles().into_iter().filter(|h| *h != line).collect()); + all.extend(flatten(&context_menu::entries(&app))); + all.extend(flatten(&context_menu::snap_entries(&app))); + type_line(&mut app, "LINE"); + type_line(&mut app, "0,0"); + all.extend(flatten(&context_menu::command_entries(&app).unwrap())); + let mut missing = Vec::new(); + for (label, id, _) in &all { + let segments: Vec<&str> = label.split('/').collect(); + // The item itself is user text when it types a value, or a command name (Recent Commands). + let user_text = (id.starts_with(context_menu::TYPE) && !id.contains('\'')) || segments.first() == Some(&"Recent Commands"); + let n = segments.len() - usize::from(user_text); + for s in &segments[..n] { + // Point filters (.X) and "Repeat LINE" (a template) aren't catalog keys. + if !s.starts_with('.') && !s.starts_with("Repeat ") && cadcraft_ui_egui::i18n::tr("uk", s) == *s { + missing.push(s.to_string()); + } + } + } + assert!(missing.is_empty(), "untranslated menu labels: {missing:?}"); +} + +/// The text painted in one frame at `time` (seconds). +fn painted(app: &mut CadApp, ctx: &egui::Context, events: Vec, time: f64) -> String { + fn collect(shape: &egui::Shape, text: &mut String) { + match shape { + egui::Shape::Text(s) => { + text.push_str(s.galley.text()); + text.push('\n'); + } + egui::Shape::Vec(shapes) => shapes.iter().for_each(|s| collect(s, text)), + _ => {} + } + } + let input = egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1600.0, 1000.0))), + time: Some(time), + events, + ..Default::default() + }; + let mut output = ctx.run_ui(input, |ui| app.ui(ui)); + output.textures_delta.clear(); + let mut text = String::new(); + for s in &output.shapes { + collect(&s.shape, &mut text); + } + text +} + +/// A quick right-click during a command is Enter; holding the button opens the command menu. +#[test] +fn time_sensitive_right_click() { + let mut app = app(); + let ctx = egui::Context::default(); + app.logic(&ctx); + painted(&mut app, &ctx, Vec::new(), 0.0); + let at = egui::pos2(800.0, 500.0); + let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Secondary, pressed, modifiers: egui::Modifiers::NONE }; + painted(&mut app, &ctx, vec![egui::Event::PointerMoved(at)], 0.1); + + // Held: the menu opens and the command keeps running. + app.cmdline("LINE 0,0 10,0"); + painted(&mut app, &ctx, vec![button(true)], 1.0); + painted(&mut app, &ctx, Vec::new(), 1.2); + painted(&mut app, &ctx, vec![button(false)], 1.0 + context_menu::HOLD_SECS + 0.1); + let text = painted(&mut app, &ctx, Vec::new(), 1.5); + for l in ["Enter", "Cancel", "Undo", "Snap Overrides", "Zoom"] { + assert!(text.contains(l), "{l} missing from the painted menu:\n{text}"); + } + assert!(app.session.prompt_text().contains("next point"), "no Enter: {}", app.session.prompt_text()); + // A quick click elsewhere closes the menu and is Enter. + let away = egui::pos2(400.0, 300.0); + let quick = |pressed| egui::Event::PointerButton { pos: away, button: egui::PointerButton::Secondary, pressed, modifiers: egui::Modifiers::NONE }; + painted(&mut app, &ctx, vec![egui::Event::PointerMoved(away)], 2.0); + painted(&mut app, &ctx, vec![quick(true)], 3.0); + painted(&mut app, &ctx, vec![quick(false)], 3.05); + painted(&mut app, &ctx, Vec::new(), 3.1); + assert!(app.session.running.is_none(), "a quick right-click is Enter: {}", app.session.prompt_text()); + assert_eq!(app.session.doc().unwrap().model.handles().len(), 1); +}