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 17:48:12 +02:00 committed by GitHub
parent 836badce4c
commit 997aef85d5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 566 additions and 37 deletions

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;

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
}
}

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"]);
}

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.

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());

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
}

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 Початок

Can't render this file because it has a wrong number of fields in line 7.

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);
}