feat(ui): compatible right-click context menu driven by command options

The viewport context menu is now built from a pure model
(ui::popup::context_menu) rendered by the overlay: while a command runs it
offers Enter / Cancel / every keyword of the command's options() with the
keyword shown as a hint, a Recent Input submenu and M2P; when idle it offers
Repeat, Recent Input, Clipboard, Undo/Redo with history labels, the edit
block (Erase/Move/Copy/Scale/Rotate/Mirror/Draw Order), Isolate, Pan/Zoom,
selection tools, Properties and Options. The default row is anchored under
the pointer and the menu is keyboard-navigable (arrows, Enter, mnemonic
letters; any other key closes it and types into the command line).

Right-click behaviour is a user preference (Options → User Preferences,
SHORTCUTMENU / SHORTCUTMENUDURATION): shortcut menu (commercial solutions default),
time-sensitive, or the previous Enter-first cycle.

LINE gains options() (Close/Undo/eXit).

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
PanosandClaude Opus 5 committed 2026-09-17 12:05:14 +03:00
1 parent 65c0fe5494
commit e623c053ab
19 files changed
+2019 -199

No files matched your search

+24
View File
@@ -14,6 +14,9 @@ use std::ops::Range;
use crate::ui::style::common::accessible_accent_threshold;
/// Maximum entries kept for the context menu's "Recent Input" list.
pub const RECENT_INPUT_CAP: usize = 10;
pub const CMD_INPUT_ID: &str = "cmd_input";
pub const HISTORY_SCROLL_ID: &str = "command_history_scroll";
@@ -120,6 +123,10 @@ pub struct CommandLine {
/// context menu, shortcuts), newest last. Drives the right-click "Repeat"
/// menu so it reflects every command source, not only typed ones.
pub recent_commands: Vec<String>,
/// Tokens typed into a running command (points, distances, keywords),
/// newest first, de-duplicated. Drives the context menu's "Recent Input"
/// submenu so a value can be re-fed with one click. Not persisted.
pub recent_inputs: Vec<String>,
/// Current position in `cmd_recall` while navigating (None = not navigating).
recall_cursor: Option<usize>,
/// Saved draft input before the user started navigating history.
@@ -168,6 +175,7 @@ impl Default for CommandLine {
last_error: None,
cmd_recall: Vec::new(),
recent_commands: Vec::new(),
recent_inputs: Vec::new(),
recall_cursor: None,
recall_draft: String::new(),
history_open: false,
@@ -309,6 +317,22 @@ impl CommandLine {
self.cancel_history_navigation();
}
/// Remember a token fed to a running command for the "Recent Input"
/// submenu: newest first, case-insensitive move-to-front de-duplication,
/// capped at `RECENT_INPUT_CAP`. Empty, overly long and entity-handle
/// tokens are skipped (a handle is meaningless outside the pick it
/// answered).
pub fn record_recent_input(&mut self, token: &str) {
let token = token.trim();
if token.is_empty() || token.chars().count() > 40 {
return;
}
let upper = token.to_uppercase();
self.recent_inputs.retain(|t| t.to_uppercase() != upper);
self.recent_inputs.insert(0, token.to_string());
self.recent_inputs.truncate(RECENT_INPUT_CAP);
}
pub fn history_navigation_active(&self) -> bool {
self.recall_cursor.is_some()
}
+909
View File
@@ -0,0 +1,909 @@
//! Viewport right-click context menu — the *model*.
//!
//! `build_context_menu` is a pure function from the app's current state
//! (`MenuContext`) to the rows the menu shows (`ContextMenu`). It knows nothing
//! about iced: the view renders the rows, and the keyboard handler in `update`
//! walks the same rows to resolve Up/Down/Enter/mnemonic picks, so what the
//! user sees and what a keypress selects can never diverge.
//!
//! The layout mirrors AutoCAD's shortcut menus, which the user base already
//! knows: while a command runs the menu offers Enter / Cancel / the command's
//! own keyword options (with the keyword shown as a hint, so the menu also
//! teaches the typed shortcut); when idle it offers Repeat / Recent Input /
//! Clipboard / Undo-Redo / Isolate / Pan-Zoom / selection tools; during a grip
//! edit it offers the grip modes.
use crate::command::CmdOption;
use crate::scene::parametric_constraints::ConstraintId;
use crate::scene::pick::grip::GripEditMode;
use crate::t;
/// Fixed row height used by the renderer, so `ContextMenu::default_row_y`
/// (which anchors the default row under the cursor) agrees with the layout.
pub const MENU_ROW_H: f32 = 24.0;
/// Height of a separator row (1 px line plus its vertical padding).
pub const MENU_SEP_H: f32 = 7.0;
/// Vertical padding inside the menu panel above the first row.
pub const MENU_PAD_TOP: f32 = 4.0;
/// Column width without keyword hints (the width the menu always had).
pub const MENU_WIDTH: f32 = 180.0;
/// Column width when any row carries a right-aligned keyword hint.
pub const MENU_WIDTH_WITH_HINTS: f32 = 220.0;
/// How many recent entries the Recent Input / Repeat lists show.
pub const RECENT_LIMIT: usize = 10;
/// Identifies an inline-expanding (accordion) submenu. Only one is open at a
/// time; the open one is tracked in `SelectionState::context_menu_ui`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SubmenuId {
RecentInput,
Clipboard,
DrawOrder,
Isolate,
}
/// What a grip-menu row does (AutoCAD grip-mode shortcut menu).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum GripMenuCmd {
Stretch,
Move,
Rotate,
Scale,
Mirror,
BasePoint,
CopyToggle,
Undo,
Exit,
}
/// Everything a menu row can do. Translated into the app's existing messages
/// by `OpenCADStudio::on_context_menu_pick`, so a row does exactly what the
/// equivalent typed input / shortcut does.
#[derive(Clone, Debug, PartialEq)]
pub enum MenuAction {
/// Enter — finish / accept the current step (`CommandFinalize`).
Enter,
/// Escape — cancel the running command (`CommandEscape`).
Cancel,
/// Feed a command option keyword (`CommandOptionPick`). Empty = Enter.
Option(String),
/// Re-feed a recent typed token (point, distance, keyword) to the
/// running command.
FeedInput(String),
/// Run a command line string (`Message::Command`).
Command(String),
/// One-shot "Mid Between 2 Points" snap override.
Mtp,
DeleteSelected,
SelectSimilar,
InvertSelection,
DeselectAll,
QuickSelect,
Properties,
Options,
Undo,
Redo,
DrawOrderPickRef(bool),
ConstraintDelete(ConstraintId),
Grip(GripMenuCmd),
/// Expand / collapse an accordion submenu (never "picked" as a command).
ToggleSubmenu(SubmenuId),
}
/// One selectable row.
#[derive(Clone, Debug, PartialEq)]
pub struct MenuItem {
pub label: String,
/// Right-aligned keyword hint, e.g. `"C"` for Close, `"⏎"` for the Enter
/// row. Teaches the typed shortcut without cluttering the label.
pub hint: Option<String>,
/// Uppercase letter that picks this row from the keyboard while the menu
/// is open. Derived from the option keyword's first character.
pub mnemonic: Option<char>,
pub action: MenuAction,
/// The row Enter picks when nothing is highlighted; rendered bold and
/// anchored under the cursor when the menu opens.
pub default: bool,
pub enabled: bool,
/// Rendered with a leading check mark (toggle / current-state rows).
pub checked: bool,
}
impl MenuItem {
fn new(label: impl Into<String>, action: MenuAction) -> Self {
Self {
label: label.into(),
hint: None,
mnemonic: None,
action,
default: false,
enabled: true,
checked: false,
}
}
fn hint(mut self, hint: impl Into<String>) -> Self {
self.hint = Some(hint.into());
self
}
fn mnemonic(mut self, ch: Option<char>) -> Self {
self.mnemonic = ch;
self
}
fn default(mut self) -> Self {
self.default = true;
self
}
fn enabled(mut self, enabled: bool) -> Self {
self.enabled = enabled;
self
}
fn checked(mut self, checked: bool) -> Self {
self.checked = checked;
self
}
}
#[derive(Clone, Debug, PartialEq)]
pub enum MenuRow {
Item(MenuItem),
Separator,
Submenu {
id: SubmenuId,
label: String,
items: Vec<MenuItem>,
open: bool,
},
}
/// A keyboard-navigable row in display order: either an item or a submenu
/// header. Separators and the children of collapsed submenus are skipped.
#[derive(Clone, Debug, PartialEq)]
pub struct Selectable {
pub action: MenuAction,
pub mnemonic: Option<char>,
pub enabled: bool,
/// `Some(id)` for a submenu header row.
pub header: Option<SubmenuId>,
}
#[derive(Clone, Debug, Default, PartialEq)]
pub struct ContextMenu {
pub rows: Vec<MenuRow>,
pub width: f32,
}
impl ContextMenu {
/// Keyboard-navigable rows in display order. The index into this list is
/// the highlight index kept in `ContextMenuUi::highlighted`.
pub fn selectable(&self) -> Vec<Selectable> {
let mut out = Vec::new();
for row in &self.rows {
match row {
MenuRow::Item(item) => out.push(Selectable {
action: item.action.clone(),
mnemonic: item.mnemonic,
enabled: item.enabled,
header: None,
}),
MenuRow::Separator => {}
MenuRow::Submenu { id, items, open, .. } => {
out.push(Selectable {
action: MenuAction::ToggleSubmenu(*id),
mnemonic: None,
enabled: !items.is_empty(),
header: Some(*id),
});
if *open {
for item in items {
out.push(Selectable {
action: item.action.clone(),
mnemonic: item.mnemonic,
enabled: item.enabled,
header: None,
});
}
}
}
}
}
out
}
/// Index (into `selectable()`) of the default row, else the first row.
pub fn default_index(&self) -> usize {
let mut idx = 0;
for row in &self.rows {
match row {
MenuRow::Item(item) => {
if item.default {
return idx;
}
idx += 1;
}
MenuRow::Separator => {}
MenuRow::Submenu { items, open, .. } => {
idx += 1;
if *open {
for item in items {
if item.default {
return idx;
}
idx += 1;
}
}
}
}
}
0
}
/// The enabled row whose mnemonic is `ch`, searching forward from (and
/// excluding) `from` and wrapping, so repeated presses cycle through rows
/// sharing a letter (PLINE's CEnter / CLose both answer to `C`).
pub fn find_mnemonic(&self, ch: char, from: Option<usize>) -> Option<usize> {
let ch = ch.to_ascii_uppercase();
let rows = self.selectable();
if rows.is_empty() {
return None;
}
let start = from.map_or(0, |f| (f + 1) % rows.len());
(0..rows.len())
.map(|k| (start + k) % rows.len())
.find(|&i| rows[i].enabled && rows[i].mnemonic == Some(ch))
}
/// Vertical offset from the panel's top edge to the top of the default
/// row, using the renderer's fixed row metrics.
pub fn default_row_y(&self) -> f32 {
let mut y = MENU_PAD_TOP;
for row in &self.rows {
match row {
MenuRow::Item(item) => {
if item.default {
return y;
}
y += MENU_ROW_H;
}
MenuRow::Separator => y += MENU_SEP_H,
MenuRow::Submenu { items, open, .. } => {
y += MENU_ROW_H;
if *open {
for item in items {
if item.default {
return y;
}
y += MENU_ROW_H;
}
}
}
}
}
MENU_PAD_TOP
}
/// Total panel height (rows plus the panel's vertical padding).
pub fn height(&self) -> f32 {
let mut h = MENU_PAD_TOP * 2.0;
for row in &self.rows {
match row {
MenuRow::Item(_) => h += MENU_ROW_H,
MenuRow::Separator => h += MENU_SEP_H,
MenuRow::Submenu { items, open, .. } => {
h += MENU_ROW_H;
if *open {
h += items.len() as f32 * MENU_ROW_H;
}
}
}
}
h
}
fn has_hints(&self) -> bool {
self.rows.iter().any(|row| match row {
MenuRow::Item(item) => item.hint.is_some(),
MenuRow::Separator => false,
MenuRow::Submenu { items, .. } => items.iter().any(|item| item.hint.is_some()),
})
}
}
/// State of a grip edit the menu was opened over.
#[derive(Clone, Debug, PartialEq)]
pub struct GripMenuContext {
pub mode: GripEditMode,
pub copy_on: bool,
/// The grip has been dragged away from its origin (Undo snaps it back).
pub moved: bool,
}
/// Inputs the builder needs, gathered by `OpenCADStudio::context_menu_context`.
#[derive(Clone, Debug)]
pub enum MenuContext {
/// A command is running: its current step's options.
Command {
options: Vec<CmdOption>,
/// The step takes a point pick (the M2P override applies).
has_point_step: bool,
/// Recently typed tokens (newest first) for the Recent Input submenu.
recent_inputs: Vec<String>,
},
/// Nothing running.
Idle {
has_selection: bool,
selected_constraint: Option<ConstraintId>,
isolation_active: bool,
/// Recently run commands, newest first.
recent_cmds: Vec<String>,
clipboard_nonempty: bool,
undo_label: Option<String>,
redo_label: Option<String>,
props_open: bool,
},
/// A grip is hot / being dragged.
Grip(GripMenuContext),
}
/// Build the menu rows for `ctx`, with `open_submenu` expanded.
pub fn build_context_menu(ctx: &MenuContext, open_submenu: Option<SubmenuId>) -> ContextMenu {
let rows = match ctx {
MenuContext::Command {
options,
has_point_step,
recent_inputs,
} => command_rows(options, *has_point_step, recent_inputs, open_submenu),
MenuContext::Idle {
has_selection,
selected_constraint,
isolation_active,
recent_cmds,
clipboard_nonempty,
undo_label,
redo_label,
props_open,
} => idle_rows(
*has_selection,
*selected_constraint,
*isolation_active,
recent_cmds,
*clipboard_nonempty,
undo_label.as_deref(),
redo_label.as_deref(),
*props_open,
open_submenu,
),
MenuContext::Grip(grip) => grip_rows(grip),
};
let mut menu = ContextMenu { rows, width: MENU_WIDTH };
if menu.has_hints() {
menu.width = MENU_WIDTH_WITH_HINTS;
}
menu
}
/// Keyboard letter for an option keyword: its first ASCII alphanumeric.
fn mnemonic_for(keyword: &str) -> Option<char> {
keyword
.chars()
.find(|c| c.is_ascii_alphanumeric())
.map(|c| c.to_ascii_uppercase())
}
fn command_rows(
options: &[CmdOption],
has_point_step: bool,
recent_inputs: &[String],
open_submenu: Option<SubmenuId>,
) -> Vec<MenuRow> {
let mut rows = Vec::new();
// A command may name its own finish action (PLINE "Done", OFFSET's
// "<0.5000>" default). That row *is* Enter, so it replaces the generic
// Enter row at the top instead of appearing twice.
let finish = options.iter().find(|o| o.keyword.is_empty());
let enter_label = finish.map_or_else(|| t!("Enter").into_owned(), |o| o.label.clone());
rows.push(MenuRow::Item(
MenuItem::new(enter_label, MenuAction::Enter).hint("⏎").default(),
));
rows.push(MenuRow::Item(
MenuItem::new(t!("Cancel").into_owned(), MenuAction::Cancel).hint("Esc"),
));
let keyword_opts: Vec<&CmdOption> = options.iter().filter(|o| !o.keyword.is_empty()).collect();
if !keyword_opts.is_empty() {
rows.push(MenuRow::Separator);
for opt in keyword_opts {
rows.push(MenuRow::Item(
MenuItem::new(opt.label.clone(), MenuAction::Option(opt.keyword.clone()))
.hint(opt.keyword.clone())
.mnemonic(mnemonic_for(&opt.keyword)),
));
}
}
if !recent_inputs.is_empty() {
rows.push(MenuRow::Separator);
rows.push(MenuRow::Submenu {
id: SubmenuId::RecentInput,
label: t!("Recent Input").into_owned(),
items: recent_inputs
.iter()
.take(RECENT_LIMIT)
.map(|tok| MenuItem::new(tok.clone(), MenuAction::FeedInput(tok.clone())))
.collect(),
open: open_submenu == Some(SubmenuId::RecentInput),
});
}
if has_point_step {
rows.push(MenuRow::Separator);
rows.push(MenuRow::Item(MenuItem::new(
t!("Mid Between 2 Points (M2P)").into_owned(),
MenuAction::Mtp,
)));
}
rows
}
#[allow(clippy::too_many_arguments)]
fn idle_rows(
has_selection: bool,
selected_constraint: Option<ConstraintId>,
isolation_active: bool,
recent_cmds: &[String],
clipboard_nonempty: bool,
undo_label: Option<&str>,
redo_label: Option<&str>,
props_open: bool,
open_submenu: Option<SubmenuId>,
) -> Vec<MenuRow> {
let cmd = |label: String, name: &str| MenuItem::new(label, MenuAction::Command(name.to_string()));
let mut rows = Vec::new();
// ── Repeat / Recent Input ──────────────────────────────────────────
if let Some(last) = recent_cmds.first() {
rows.push(MenuRow::Item(
MenuItem::new(
t!("Repeat %{last}", last = last.clone()).into_owned(),
MenuAction::Command(last.to_uppercase()),
)
.default(),
));
rows.push(MenuRow::Submenu {
id: SubmenuId::RecentInput,
label: t!("Recent Input").into_owned(),
items: recent_cmds
.iter()
.take(RECENT_LIMIT)
.map(|c| MenuItem::new(c.clone(), MenuAction::Command(c.to_uppercase())))
.collect(),
open: open_submenu == Some(SubmenuId::RecentInput),
});
rows.push(MenuRow::Separator);
}
// ── Clipboard ──────────────────────────────────────────────────────
rows.push(MenuRow::Submenu {
id: SubmenuId::Clipboard,
label: t!("Clipboard").into_owned(),
items: vec![
cmd(t!("Cut").into_owned(), "CUTCLIP").enabled(has_selection),
cmd(t!("Copy").into_owned(), "COPYCLIP").enabled(has_selection),
cmd(t!("Copy with Base Point").into_owned(), "COPYBASE").enabled(has_selection),
cmd(t!("Paste").into_owned(), "PASTECLIP").enabled(clipboard_nonempty),
cmd(t!("Paste as Block").into_owned(), "PASTEBLOCK").enabled(clipboard_nonempty),
cmd(t!("Paste to Original Coordinates").into_owned(), "PASTEORIG")
.enabled(clipboard_nonempty),
],
open: open_submenu == Some(SubmenuId::Clipboard),
});
rows.push(MenuRow::Separator);
// ── Undo / Redo ────────────────────────────────────────────────────
let undo = match undo_label {
Some(label) => t!("Undo %{label}", label = label.to_string()).into_owned(),
None => t!("Undo").into_owned(),
};
let redo = match redo_label {
Some(label) => t!("Redo %{label}", label = label.to_string()).into_owned(),
None => t!("Redo").into_owned(),
};
rows.push(MenuRow::Item(
MenuItem::new(undo, MenuAction::Undo).hint("Ctrl+Z").enabled(undo_label.is_some()),
));
rows.push(MenuRow::Item(
MenuItem::new(redo, MenuAction::Redo).hint("Ctrl+Y").enabled(redo_label.is_some()),
));
rows.push(MenuRow::Separator);
// ── Selection edit block (AutoCAD "Edit" shortcut menu) ────────────
if has_selection {
rows.push(MenuRow::Item(MenuItem::new(
t!("Erase").into_owned(),
MenuAction::DeleteSelected,
)));
rows.push(MenuRow::Item(cmd(t!("Move").into_owned(), "MOVE")));
rows.push(MenuRow::Item(cmd(t!("Copy Selection").into_owned(), "COPY")));
rows.push(MenuRow::Item(cmd(t!("Scale").into_owned(), "SCALE")));
rows.push(MenuRow::Item(cmd(t!("Rotate").into_owned(), "ROTATE")));
rows.push(MenuRow::Item(cmd(t!("Mirror").into_owned(), "MIRROR")));
rows.push(MenuRow::Submenu {
id: SubmenuId::DrawOrder,
label: t!("Draw Order").into_owned(),
items: vec![
cmd(t!("Bring to Front").into_owned(), "DRAWORDER F"),
cmd(t!("Send to Back").into_owned(), "DRAWORDER B"),
MenuItem::new(
t!("Bring Above Object").into_owned(),
MenuAction::DrawOrderPickRef(true),
),
MenuItem::new(
t!("Send Under Object").into_owned(),
MenuAction::DrawOrderPickRef(false),
),
],
open: open_submenu == Some(SubmenuId::DrawOrder),
});
rows.push(MenuRow::Separator);
} else if let Some(id) = selected_constraint {
// A selected constraint-glyph pill gets a minimal edit block — none
// of the entity actions above apply to it.
rows.push(MenuRow::Item(MenuItem::new(
t!("Delete").into_owned(),
MenuAction::ConstraintDelete(id),
)));
rows.push(MenuRow::Separator);
}
// ── Isolate ────────────────────────────────────────────────────────
rows.push(MenuRow::Submenu {
id: SubmenuId::Isolate,
label: t!("Isolate").into_owned(),
items: vec![
cmd(t!("Isolate Objects").into_owned(), "ISOLATEOBJECTS").enabled(has_selection),
cmd(t!("Hide Objects").into_owned(), "HIDEOBJECTS").enabled(has_selection),
cmd(t!("End Object Isolation").into_owned(), "UNISOLATEOBJECTS")
.enabled(isolation_active),
],
open: open_submenu == Some(SubmenuId::Isolate),
});
rows.push(MenuRow::Separator);
// ── Navigation ─────────────────────────────────────────────────────
rows.push(MenuRow::Item(cmd(t!("Pan").into_owned(), "PAN")));
rows.push(MenuRow::Item(cmd(t!("Zoom").into_owned(), "ZOOM")));
rows.push(MenuRow::Item(cmd(t!("Zoom Extents").into_owned(), "ZOOM EXTENTS")));
rows.push(MenuRow::Separator);
// ── Selection tools ────────────────────────────────────────────────
if has_selection {
rows.push(MenuRow::Item(MenuItem::new(
t!("Select Similar").into_owned(),
MenuAction::SelectSimilar,
)));
rows.push(MenuRow::Item(MenuItem::new(
t!("Invert Selection").into_owned(),
MenuAction::InvertSelection,
)));
rows.push(MenuRow::Item(MenuItem::new(
t!("Deselect All").into_owned(),
MenuAction::DeselectAll,
)));
}
rows.push(MenuRow::Item(cmd(t!("Select All").into_owned(), "SELECTALL")));
rows.push(MenuRow::Item(MenuItem::new(
t!("Quick Select...").into_owned(),
MenuAction::QuickSelect,
)));
rows.push(MenuRow::Separator);
// ── Panels ─────────────────────────────────────────────────────────
rows.push(MenuRow::Item(
MenuItem::new(t!("Properties").into_owned(), MenuAction::Properties).checked(props_open),
));
rows.push(MenuRow::Item(MenuItem::new(
t!("Options...").into_owned(),
MenuAction::Options,
)));
rows
}
fn grip_rows(grip: &GripMenuContext) -> Vec<MenuRow> {
let g = |label: String, cmd: GripMenuCmd| MenuItem::new(label, MenuAction::Grip(cmd));
vec![
MenuRow::Item(MenuItem::new(t!("Enter").into_owned(), MenuAction::Enter).hint("⏎").default()),
MenuRow::Separator,
MenuRow::Item(
g(t!("Stretch").into_owned(), GripMenuCmd::Stretch)
.checked(matches!(grip.mode, GripEditMode::Stretch)),
),
MenuRow::Item(g(t!("Move").into_owned(), GripMenuCmd::Move)),
MenuRow::Item(g(t!("Rotate").into_owned(), GripMenuCmd::Rotate)),
MenuRow::Item(g(t!("Scale").into_owned(), GripMenuCmd::Scale)),
MenuRow::Item(g(t!("Mirror").into_owned(), GripMenuCmd::Mirror)),
MenuRow::Separator,
MenuRow::Item(g(t!("Base Point").into_owned(), GripMenuCmd::BasePoint)),
MenuRow::Item(g(t!("Copy").into_owned(), GripMenuCmd::CopyToggle).checked(grip.copy_on)),
MenuRow::Item(g(t!("Undo").into_owned(), GripMenuCmd::Undo).enabled(grip.moved)),
MenuRow::Separator,
MenuRow::Item(g(t!("Exit").into_owned(), GripMenuCmd::Exit).hint("Esc")),
]
}
#[cfg(test)]
mod tests {
use super::*;
/// Labels are translated at build time, so expectations go through the
/// same catalog (tests run under whatever UI language the host has).
fn tl(source: &str) -> String {
t!(source).into_owned()
}
fn labels(menu: &ContextMenu) -> Vec<String> {
let mut out = Vec::new();
for row in &menu.rows {
match row {
MenuRow::Item(item) => out.push(item.label.clone()),
MenuRow::Separator => out.push("---".into()),
MenuRow::Submenu { label, items, open, .. } => {
out.push(format!("{label} >"));
if *open {
out.extend(items.iter().map(|i| format!(" {}", i.label)));
}
}
}
}
out
}
fn actions(menu: &ContextMenu) -> Vec<MenuAction> {
menu.selectable().into_iter().map(|s| s.action).collect()
}
fn item_at(menu: &ContextMenu, idx: usize) -> &MenuItem {
match &menu.rows[idx] {
MenuRow::Item(item) => item,
other => panic!("expected item at {idx}, got {other:?}"),
}
}
fn line_ctx() -> MenuContext {
MenuContext::Command {
options: vec![CmdOption::new("Close", "C"), CmdOption::new("Undo", "U")],
has_point_step: true,
recent_inputs: Vec::new(),
}
}
#[test]
fn command_menu_lists_options_with_hints() {
let menu = build_context_menu(&line_ctx(), None);
assert_eq!(
labels(&menu),
vec![
tl("Enter"),
tl("Cancel"),
"---".into(),
tl("Close"),
tl("Undo"),
"---".into(),
tl("Mid Between 2 Points (M2P)"),
]
);
let close = item_at(&menu, 3);
assert_eq!(close.hint.as_deref(), Some("C"));
assert_eq!(close.mnemonic, Some('C'));
assert_eq!(close.action, MenuAction::Option("C".into()));
assert!(item_at(&menu, 0).default);
assert_eq!(menu.default_index(), 0);
assert_eq!(menu.width, MENU_WIDTH_WITH_HINTS);
}
#[test]
fn enter_option_becomes_default_row() {
let ctx = MenuContext::Command {
options: vec![CmdOption::new("Arc", "A"), CmdOption::enter("Done")],
has_point_step: false,
recent_inputs: Vec::new(),
};
let menu = build_context_menu(&ctx, None);
assert_eq!(labels(&menu), vec![tl("Done"), tl("Cancel"), "---".into(), tl("Arc")]);
let done = item_at(&menu, 0);
assert!(done.default);
assert_eq!(done.action, MenuAction::Enter);
assert_eq!(
actions(&menu),
vec![MenuAction::Enter, MenuAction::Cancel, MenuAction::Option("A".into())]
);
}
#[test]
fn command_menu_without_options_has_only_enter_cancel() {
let ctx = MenuContext::Command {
options: Vec::new(),
has_point_step: false,
recent_inputs: Vec::new(),
};
let menu = build_context_menu(&ctx, None);
assert_eq!(actions(&menu), vec![MenuAction::Enter, MenuAction::Cancel]);
}
#[test]
fn recent_input_submenu_lists_tokens_when_open() {
let ctx = MenuContext::Command {
options: Vec::new(),
has_point_step: false,
recent_inputs: vec!["10,20".into(), "C".into()],
};
let closed = build_context_menu(&ctx, None);
assert_eq!(
actions(&closed),
vec![
MenuAction::Enter,
MenuAction::Cancel,
MenuAction::ToggleSubmenu(SubmenuId::RecentInput)
]
);
let open = build_context_menu(&ctx, Some(SubmenuId::RecentInput));
assert_eq!(
actions(&open),
vec![
MenuAction::Enter,
MenuAction::Cancel,
MenuAction::ToggleSubmenu(SubmenuId::RecentInput),
MenuAction::FeedInput("10,20".into()),
MenuAction::FeedInput("C".into()),
]
);
}
fn idle_ctx(has_selection: bool) -> MenuContext {
MenuContext::Idle {
has_selection,
selected_constraint: None,
isolation_active: false,
recent_cmds: vec!["LINE".into(), "CIRCLE".into()],
clipboard_nonempty: false,
undo_label: Some("LINE".into()),
redo_label: None,
props_open: false,
}
}
#[test]
fn idle_menu_without_selection_has_repeat_default() {
let menu = build_context_menu(&idle_ctx(false), None);
let first = item_at(&menu, 0);
assert_eq!(first.action, MenuAction::Command("LINE".into()));
assert!(first.default);
assert_eq!(menu.default_index(), 0);
let acts = actions(&menu);
assert!(acts.contains(&MenuAction::Command("PAN".into())));
assert!(acts.contains(&MenuAction::Options));
assert!(!acts.contains(&MenuAction::DeleteSelected));
assert!(!acts.contains(&MenuAction::DeselectAll));
}
#[test]
fn selection_menu_lists_modify_actions() {
let menu = build_context_menu(&idle_ctx(true), Some(SubmenuId::DrawOrder));
let acts = actions(&menu);
for want in [
MenuAction::DeleteSelected,
MenuAction::Command("MOVE".into()),
MenuAction::Command("COPY".into()),
MenuAction::Command("SCALE".into()),
MenuAction::Command("ROTATE".into()),
MenuAction::Command("MIRROR".into()),
MenuAction::ToggleSubmenu(SubmenuId::DrawOrder),
MenuAction::Command("DRAWORDER F".into()),
MenuAction::DrawOrderPickRef(false),
MenuAction::SelectSimilar,
MenuAction::DeselectAll,
] {
assert!(acts.contains(&want), "missing {want:?} in {acts:?}");
}
}
#[test]
fn clipboard_rows_disabled_when_empty() {
let menu = build_context_menu(&idle_ctx(false), Some(SubmenuId::Clipboard));
let sel = menu.selectable();
let paste = sel
.iter()
.find(|s| s.action == MenuAction::Command("PASTECLIP".into()))
.expect("paste row");
assert!(!paste.enabled);
let cut = sel
.iter()
.find(|s| s.action == MenuAction::Command("CUTCLIP".into()))
.expect("cut row");
assert!(!cut.enabled);
}
#[test]
fn undo_row_uses_history_label() {
let menu = build_context_menu(&idle_ctx(false), None);
let rows = labels(&menu);
let undo = t!("Undo %{label}", label = "LINE").into_owned();
assert!(rows.contains(&undo), "{rows:?}");
let sel = menu.selectable();
let redo = sel.iter().find(|s| s.action == MenuAction::Redo).expect("redo row");
assert!(!redo.enabled);
let undo = sel.iter().find(|s| s.action == MenuAction::Undo).expect("undo row");
assert!(undo.enabled);
}
#[test]
fn selectable_skips_separators_and_closed_submenus() {
let menu = build_context_menu(&idle_ctx(false), None);
let sel = menu.selectable();
assert!(sel.iter().all(|s| s.action != MenuAction::Command("PASTECLIP".into())));
assert!(sel.iter().any(|s| s.header == Some(SubmenuId::Clipboard)));
let open = build_context_menu(&idle_ctx(false), Some(SubmenuId::Clipboard));
assert!(open
.selectable()
.iter()
.any(|s| s.action == MenuAction::Command("PASTECLIP".into())));
}
#[test]
fn find_mnemonic_cycles_duplicates() {
let ctx = MenuContext::Command {
options: vec![
CmdOption::new("Angle", "A"),
CmdOption::new("CEnter", "CE"),
CmdOption::new("CLose", "CL"),
],
has_point_step: false,
recent_inputs: Vec::new(),
};
let menu = build_context_menu(&ctx, None);
// rows: Enter(0) Cancel(1) Angle(2) CEnter(3) CLose(4)
assert_eq!(menu.find_mnemonic('c', None), Some(3));
assert_eq!(menu.find_mnemonic('C', Some(3)), Some(4));
assert_eq!(menu.find_mnemonic('C', Some(4)), Some(3));
assert_eq!(menu.find_mnemonic('Z', None), None);
}
#[test]
fn default_row_y_accounts_for_separators() {
let menu = build_context_menu(&line_ctx(), None);
assert_eq!(menu.default_row_y(), MENU_PAD_TOP);
let menu = build_context_menu(&idle_ctx(false), None);
assert_eq!(menu.default_row_y(), MENU_PAD_TOP);
// 10 items + 3 submenu headers + 6 separators, plus panel padding.
let expected = MENU_PAD_TOP * 2.0 + 13.0 * MENU_ROW_H + 6.0 * MENU_SEP_H;
assert_eq!(menu.height(), expected);
}
#[test]
fn grip_menu_marks_current_mode() {
let menu = build_context_menu(
&MenuContext::Grip(GripMenuContext {
mode: GripEditMode::Stretch,
copy_on: true,
moved: false,
}),
None,
);
assert_eq!(item_at(&menu, 0).action, MenuAction::Enter);
assert!(item_at(&menu, 2).checked);
let sel = menu.selectable();
let stretch = sel
.iter()
.position(|s| s.action == MenuAction::Grip(GripMenuCmd::Stretch))
.unwrap();
assert_eq!(stretch, 1);
let undo = sel
.iter()
.find(|s| s.action == MenuAction::Grip(GripMenuCmd::Undo))
.unwrap();
assert!(!undo.enabled);
}
}
+1
View File
@@ -1,3 +1,4 @@
pub mod context_menu;
pub mod cycle_popup;
pub mod isolate_popup;
pub mod polar_popup;
+54 -1
View File
@@ -1,6 +1,6 @@
pub(crate) mod spacemouse;
use crate::app::config::UiThemeConfig;
use crate::app::settings::CursorType;
use crate::app::settings::{CursorType, RightClickMode};
use crate::app::Message;
use iced::widget::{
button, column, container, row, scrollable, slider, text, text_input, Space,
@@ -73,6 +73,10 @@ pub struct AppPrefs {
pub show_ucs_icon: bool,
/// UCSICON ORigin: draw it at the origin rather than the corner.
pub ucs_icon_at_origin: bool,
/// SHORTCUTMENU: what a right-click in the drawing area does.
pub right_click_mode: RightClickMode,
/// SHORTCUTMENUDURATION: time-sensitive hold threshold, ms.
pub right_click_hold_ms: i32,
}
/// The fixed locations the Files page lists.
@@ -218,6 +222,18 @@ pub fn view_window<'a>(
.find(|choice| choice.value == cursor_type)
.cloned();
let right_click_options = RightClickMode::ALL
.into_iter()
.map(|value: RightClickMode| Labelled {
label: crate::t!(value.label()).into_owned(),
value,
})
.collect::<Vec<_>>();
let selected_right_click = right_click_options
.iter()
.find(|choice| choice.value == prefs.right_click_mode)
.cloned();
let palette = ui_theme.palette.to_iced();
let colors = [
(crate::tr!("options", "color-background"), palette.background),
@@ -1221,6 +1237,43 @@ pub fn view_window<'a>(
.size(11)
.width(sizing.width),
Space::new().height(24),
text(crate::t!("Right-click Customization")).size(15),
Space::new().height(10),
row![
text(crate::t!("Right-click in drawing area")).size(12).width(150),
iced::widget::pick_list(
selected_right_click,
right_click_options,
|choice| choice.label.clone(),
)
.on_select(|choice| Message::RightClickModeChanged(choice.value))
.width(Fill),
]
.spacing(10)
.align_y(iced::Center),
Space::new().height(12),
row![
text(crate::t!("Hold duration")).size(12).width(150),
slider(
100..=1000,
prefs.right_click_hold_ms.clamp(100, 1000),
Message::RightClickHoldMsChanged
)
.step(50)
.width(Fill),
text(format!("{} ms", prefs.right_click_hold_ms.clamp(100, 1000)))
.size(11)
.width(52),
]
.spacing(10)
.align_y(iced::Center),
Space::new().height(6),
text(crate::t!(
"Shortcut menu: right-click always opens the menu (AutoCAD default). Time-sensitive: a quick click is Enter, holding longer opens the menu (SHORTCUTMENUDURATION)."
))
.size(11)
.width(sizing.width),
Space::new().height(24),
text(crate::t!("Block Edit")).size(15),
Space::new().height(10),
row![