forked from 1b-agent/opencadstudio-upstream
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:
1 parent
65c0fe5494
commit
e623c053ab
19 files changed
+2019
-199
No files matched your search
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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,3 +1,4 @@
|
||||
pub mod context_menu;
|
||||
pub mod cycle_popup;
|
||||
pub mod isolate_popup;
|
||||
pub mod polar_popup;
|
||||
|
||||
@@ -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![
|
||||
|
||||
Reference in new issue
Block a user