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:
parent
836badce4c
commit
997aef85d5
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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"]);
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
|
|
@ -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);
|
||||
}
|
||||
Loading…
Reference in New Issue