Shortcut menus: command menu while a command runs, selection menu with object edits (part of #512) (#515)

Squash of PR #515 by @vangel76.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Valentin Klein
2026-10-11 11:48:12 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 836badce4c
commit 997aef85d5
8 changed files with 566 additions and 37 deletions
+1
View File
@@ -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;
+25
View File
@@ -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<String> {
let mut out: Vec<String> = 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
}
}
+20
View File
@@ -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"]);
}
+4 -3
View File
@@ -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.
+4
View File
@@ -16,6 +16,9 @@ pub struct CmdLine {
typed_since_selection: bool,
pub history: Vec<String>,
pub history_pos: Option<usize>,
/// What was typed at command prompts (not command names), oldest first: the in-command
/// shortcut menu's Recent Input.
pub recent_input: Vec<String>,
/// 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());
+262 -34
View File
@@ -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<Entry> {
let spec = find_command(id)?;
@@ -38,23 +63,171 @@ fn item(app: &CadApp, id: &str, label: Option<&str>) -> Option<Entry> {
})
}
/// The menu's groups of entries (separated by lines).
fn items(app: &CadApp, ids: &[&str]) -> Vec<Entry> {
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<Entry>) -> Option<Entry> {
(!children.is_empty()).then(|| Entry::Sub { label: label.into(), children })
}
/// Push a group unless it is empty.
fn push(groups: &mut Vec<Vec<Entry>>, group: Vec<Entry>) {
if !group.is_empty() {
groups.push(group);
}
}
/// The idle menu's groups of entries (separated by lines).
pub fn entries(app: &CadApp) -> Vec<Vec<Entry>> {
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<Entry> = 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<Entry> = 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<Entry> = 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<Entry> {
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<Vec<Vec<Entry>>> {
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<Entry> = 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<Entry>], clicked: &mut Option<String>) {
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>(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
}
+7
View File
@@ -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 Початок
1 # CADCraft Ukrainian interface catalog — original translations by @dmatviichuk.
213
214
215
216
217
218
219
225
226
227
228
229
230
231
593
594
595
596
597
598
599
626
627
628
629
630
631
632
633
769
770
771
772
773
774
775
904
905
906
907
908
909
910
+243
View File
@@ -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<Entry>]) -> 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<Entry>]) -> Vec<String> {
flatten(groups).into_iter().map(|(l, _, _)| l).collect()
}
fn ids(groups: &[Vec<Entry>]) -> Vec<String> {
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<egui::Event>, 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);
}