diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index 178b41a0a..b046f9d91 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -783,6 +783,9 @@ impl OpenCADStudio { } return Task::none(); } + // Keywords, distances and points fed here (option buttons, the + // context menu, scripts) join the Recent Input list like typed ones. + self.command_line.record_recent_input(token); let is_mtp = token.trim_start_matches('_').eq_ignore_ascii_case("MTP") || token.trim_start_matches('_').eq_ignore_ascii_case("M2P"); if is_mtp { diff --git a/src/app/commands/mod.rs b/src/app/commands/mod.rs index be8281535..ab5e9cc6d 100644 --- a/src/app/commands/mod.rs +++ b/src/app/commands/mod.rs @@ -503,6 +503,8 @@ inventory::submit!(crate::command::CommandRegistration { "MIRRTEXT", "ZOOMWHEEL", "ZOOMFACTOR", + "SHORTCUTMENU", + "SHORTCUTMENUDURATION", "CURSORSIZE", "PICKBOX", "CURSORTYPE", diff --git a/src/app/commands/styleprops.rs b/src/app/commands/styleprops.rs index 751d2e5d2..6d49a721a 100644 --- a/src/app/commands/styleprops.rs +++ b/src/app/commands/styleprops.rs @@ -956,6 +956,8 @@ impl OpenCADStudio { "MIRRTEXT" | "ZOOMWHEEL" | "ZOOMFACTOR" + | "SHORTCUTMENU" + | "SHORTCUTMENUDURATION" | "CURSORSIZE" | "PICKBOX" | "CURSORTYPE" @@ -1071,7 +1073,7 @@ impl OpenCADStudio { let value = it.next().map(|s| s.trim().to_string()); if name.is_empty() || name == "?" { self.command_line.push_info( - crate::t!("SETVAR: CETRANSPARENCY LTSCALE CELTSCALE PDMODE PDSIZE TEXTSIZE ORTHOMODE FILLMODE MIRRTEXT FRAME IMAGEFRAME PDFFRAME WIPEOUTFRAME XCLIPFRAME POINTCLOUDCLIPFRAME ZOOMWHEEL ZOOMFACTOR CURSORSIZE PICKBOX CURSORTYPE SNAPANG TEXTFILL CLIPROMPTLINES COMMANDLINEFADETIME ATTREQ ATTDIA DIMASSOC DIMCONTINUEMODE CONSTRAINTSOLVEMODE CONSTRAINTINFER CONSTRAINTBARDISPLAY ANGBASE ANGDIR SKETCHINC SKPOLY SKTOLERANCE DONUTID DONUTOD CENTEREXE CENTERLAYER CENTERLTYPE CENTERLTSCALE CENTERLTYPEFILE CENTERCROSSSIZE CENTERCROSSGAP CENTERMARKEXE COLORTHEME SELECTIONAREA SELECTIONAREAOPACITY SELECTIONEFFECT SELECTIONEFFECTCOLOR WINDOWSAREACOLOR CROSSINGAREACOLOR SELECTIONPREVIEW GRIPSIZE GRIPCOLOR GRIPHOT GRIPHOVER GRIPOBJLIMIT | CLAYER CELTYPE TEXTSTYLE (read-only)").as_ref(), + crate::t!("SETVAR: CETRANSPARENCY LTSCALE CELTSCALE PDMODE PDSIZE TEXTSIZE ORTHOMODE FILLMODE MIRRTEXT FRAME IMAGEFRAME PDFFRAME WIPEOUTFRAME XCLIPFRAME POINTCLOUDCLIPFRAME ZOOMWHEEL ZOOMFACTOR SHORTCUTMENU SHORTCUTMENUDURATION CURSORSIZE PICKBOX CURSORTYPE SNAPANG TEXTFILL CLIPROMPTLINES COMMANDLINEFADETIME ATTREQ ATTDIA DIMASSOC DIMCONTINUEMODE CONSTRAINTSOLVEMODE CONSTRAINTINFER CONSTRAINTBARDISPLAY ANGBASE ANGDIR SKETCHINC SKPOLY SKTOLERANCE DONUTID DONUTOD CENTEREXE CENTERLAYER CENTERLTYPE CENTERLTSCALE CENTERLTYPEFILE CENTERCROSSSIZE CENTERCROSSGAP CENTERMARKEXE COLORTHEME SELECTIONAREA SELECTIONAREAOPACITY SELECTIONEFFECT SELECTIONEFFECTCOLOR WINDOWSAREACOLOR CROSSINGAREACOLOR SELECTIONPREVIEW GRIPSIZE GRIPCOLOR GRIPHOT GRIPHOVER GRIPOBJLIMIT | CLAYER CELTYPE TEXTSTYLE (read-only)").as_ref(), ); } else { if name == "CETRANSPARENCY" { @@ -1490,6 +1492,51 @@ impl OpenCADStudio { Ok((format!("ZOOMFACTOR = {}", self.zoom_factor), false)) } }, + // AutoCAD bit code: 0 = no shortcut menus (right-click + // is Enter), 16 = time-sensitive; everything else is the + // regular shortcut menu. Reported as AutoCAD's defaults + // (11 = default+edit+command menus, +16 when + // time-sensitive). + "SHORTCUTMENU" => { + use crate::app::settings::RightClickMode; + let code = |mode: RightClickMode| match mode { + RightClickMode::EnterFirst => 0, + RightClickMode::ShortcutMenu => 11, + RightClickMode::TimeSensitive => 27, + }; + match &value { + Some(v) => match v.parse::() { + Ok(bits) if (0..=31).contains(&bits) => { + self.right_click_mode = if bits == 0 { + RightClickMode::EnterFirst + } else if bits & 16 != 0 { + RightClickMode::TimeSensitive + } else { + RightClickMode::ShortcutMenu + }; + Ok((format!("SHORTCUTMENU = {}", code(self.right_click_mode)), true)) + } + _ => Err("SETVAR: integer from 0 to 31 required.".into()), + }, + None => Ok(( + format!("SHORTCUTMENU = {}", code(self.right_click_mode)), + false, + )), + } + } + "SHORTCUTMENUDURATION" => match &value { + Some(v) => match v.parse::() { + Ok(ms) if (100..=1000).contains(&ms) => { + self.right_click_hold_ms = ms; + Ok((format!("SHORTCUTMENUDURATION = {ms}"), true)) + } + _ => Err("SETVAR: integer from 100 to 1000 required.".into()), + }, + None => Ok(( + format!("SHORTCUTMENUDURATION = {}", self.right_click_hold_ms), + false, + )), + }, "CURSORSIZE" => match &value { Some(v) => match v.parse::() { Ok(size) if (1..=100).contains(&size) => { @@ -2347,6 +2394,8 @@ impl OpenCADStudio { name.as_str(), "ZOOMWHEEL" | "ZOOMFACTOR" + | "SHORTCUTMENU" + | "SHORTCUTMENUDURATION" | "CURSORSIZE" | "PICKBOX" | "CURSORTYPE" diff --git a/src/app/document.rs b/src/app/document.rs index 24e43f196..4950b7486 100644 --- a/src/app/document.rs +++ b/src/app/document.rs @@ -146,6 +146,12 @@ pub(super) struct DocumentTab { pub(super) selected_grip_handles: Vec, /// Shift-selected grips, keyed by entity and object-local grip id. pub(super) hot_grips: rustc_hash::FxHashSet<(Handle, usize)>, + /// Grip-mode "Copy" toggle (context menu): each grip placement leaves the + /// original in place and adds a modified copy, until Enter / Esc. + pub(super) grip_copy: bool, + /// Grip-mode "Base Point" (context menu): the next left-click re-bases + /// the active grip edit instead of committing it. + pub(super) grip_base_pending: bool, pub(super) selected_handle: Option, /// Dynamic-block visibility grip for the current single selection. pub(super) visibility_grip: Option, @@ -601,6 +607,8 @@ impl DocumentTab { selected_grips: vec![], selected_grip_handles: vec![], hot_grips: rustc_hash::FxHashSet::default(), + grip_copy: false, + grip_base_pending: false, selected_handle: None, visibility_grip: None, wireframe: false, diff --git a/src/app/mod.rs b/src/app/mod.rs index fab5a9ecb..f6dfd5600 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -89,6 +89,17 @@ pub const HOVER_DWELL_MS: u128 = 500; /// threshold gates that extra redraw-avoidance behavior. pub const HOVER_DWELL_DENSE_WIRES: usize = 50_000; +/// Keyboard navigation inside the open right-click context menu. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ContextMenuNav { + Up, + Down, + /// Pick the highlighted row (or the default row when none is highlighted). + Enter, + /// Pick the next row whose mnemonic letter matches. + Mnemonic(char), +} + /// Open multi-functional-grip popup state. #[derive(Clone, Debug)] pub struct GripPopup { @@ -518,6 +529,10 @@ pub(super) struct OpenCADStudio { double_click_block_refedit: bool, /// Open ATTEDIT when double-clicking a block with attributes. double_click_block_attedit: bool, + /// What a right-click in the drawing area does (SHORTCUTMENU). + right_click_mode: settings::RightClickMode, + /// Time-sensitive right-click hold threshold, ms (SHORTCUTMENUDURATION). + right_click_hold_ms: i32, /// Selected-object count past which grips stop being generated /// (GRIPOBJLIMIT, 0..=32767; 0 = no limit). grip_object_limit: i32, @@ -2149,6 +2164,10 @@ pub enum Message { ZoomWheelReversedChanged(bool), /// Change how far one wheel notch zooms (ZOOMFACTOR, 3..=100). ZoomFactorChanged(i32), + /// Options → User Preferences: right-click behaviour (SHORTCUTMENU). + RightClickModeChanged(settings::RightClickMode), + /// Options → User Preferences: time-sensitive hold threshold, ms. + RightClickHoldMsChanged(i32), /// Toggle TEXTEDIT ending after one object (TEXTEDITMODE). TextEditModeChanged(bool), /// Toggle continued dimensions inheriting the base style (DIMCONTINUEMODE). @@ -3277,9 +3296,15 @@ pub enum Message { TextInlineInput(String), /// Commit the editor: create or update the TEXT entity. TextInlineOk, - // ── Draw Order context menu ───────────────────────────────────────── - /// Toggle the Draw Order sub-items in the viewport context menu. - DrawOrderSubmenuToggle, + // ── Viewport right-click context menu ─────────────────────────────── + /// A context-menu row was picked (mouse or keyboard). Closes the menu and + /// runs the row's action through the same message the equivalent typed + /// input / shortcut would have produced. + ContextMenuPick(crate::ui::popup::context_menu::MenuAction), + /// Expand / collapse an accordion submenu of the open context menu. + ContextMenuSubmenuToggle(crate::ui::popup::context_menu::SubmenuId), + /// Keyboard navigation inside the open context menu. + ContextMenuNavigate(ContextMenuNav), /// Begin an interactive reference-object pick to move the current /// selection above (`true`) or below (`false`) the picked object. DrawOrderPickRef(bool), @@ -3794,6 +3819,8 @@ impl OpenCADStudio { pick_box: 3, double_click_block_refedit: false, double_click_block_attedit: true, + right_click_mode: settings::RightClickMode::ShortcutMenu, + right_click_hold_ms: 250, grip_object_limit: settings::DEFAULT_GRIP_OBJECT_LIMIT, ncopy_bind: false, cursor_type: settings::CursorType::Crosshair, diff --git a/src/app/settings.rs b/src/app/settings.rs index 0c1670994..edf1a3253 100644 --- a/src/app/settings.rs +++ b/src/app/settings.rs @@ -132,6 +132,43 @@ impl CursorType { } } +/// What a right-click in the drawing area does (AutoCAD SHORTCUTMENU / +/// "Right-click Customization"). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum RightClickMode { + /// AutoCAD default: a right-click always opens the shortcut menu (Enter / + /// Cancel / the command's options while a command runs; Repeat / edit + /// tools when idle). + #[default] + ShortcutMenu, + /// AutoCAD "time-sensitive right-click": a quick click is Enter (or + /// repeats the last command when idle); holding the button longer than + /// `right_click_hold_ms` opens the shortcut menu. + TimeSensitive, + /// Original Open CAD Studio behaviour: while a command runs the first + /// right-click is Enter and a second consecutive one opens the menu; + /// when idle a right-click opens the menu. + EnterFirst, +} + +impl RightClickMode { + pub const ALL: [Self; 3] = [Self::ShortcutMenu, Self::TimeSensitive, Self::EnterFirst]; + + pub fn label(self) -> &'static str { + match self { + Self::ShortcutMenu => "Shortcut menu", + Self::TimeSensitive => "Time-sensitive (quick click = Enter)", + Self::EnterFirst => "Enter first, second click = menu", + } + } +} + +/// SHORTCUTMENUDURATION bounds: below 100 ms every click reads as a hold, +/// above 1000 ms the menu becomes unreachable in practice. +pub fn clamp_right_click_hold_ms(v: i32) -> i32 { + v.clamp(100, 1000) +} + /// Active pair of axes while isometric drafting is enabled. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] pub enum IsoPlane { @@ -312,6 +349,11 @@ pub struct UserSettings { pub double_click_block_refedit: bool, /// When true, double-clicking a block with attributes opens ATTEDIT. pub double_click_block_attedit: bool, + /// What a right-click in the drawing area does (SHORTCUTMENU). + pub right_click_mode: RightClickMode, + /// Hold duration that turns a time-sensitive right-click into the menu, + /// in milliseconds (SHORTCUTMENUDURATION, 100..=1000). + pub right_click_hold_ms: i32, /// GRIPOBJLIMIT: past this many selected objects, no grips are drawn at /// all. 0 means no limit. The drawing header carries no slot for it. pub grip_object_limit: i32, @@ -556,6 +598,8 @@ impl Default for UserSettings { selection_cycling: false, double_click_block_refedit: false, double_click_block_attedit: true, + right_click_mode: RightClickMode::ShortcutMenu, + right_click_hold_ms: 250, grip_object_limit: DEFAULT_GRIP_OBJECT_LIMIT, ncopy_bind: false, cursor_type: CursorType::Crosshair, diff --git a/src/app/update/command.rs b/src/app/update/command.rs index a01824f79..b2871fd4e 100644 --- a/src/app/update/command.rs +++ b/src/app/update/command.rs @@ -727,6 +727,16 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { crate::app::expr_eval::eval_to_string(&raw) }; self.command_line.input.clear(); + // Remember the token for the context menu's Recent Input + // list (prose steps excluded: a table cell or text body + // is not a reusable value). + let is_prose = self.tabs[i] + .active_cmd + .as_ref() + .is_some_and(|c| c.input_kind().is_free_text()); + if !is_prose { + self.command_line.record_recent_input(&text); + } // Offer the typed text to the command's option handler // first (keywords like PLINE's A/L/C, a radius, …). If it diff --git a/src/app/update/context_menu.rs b/src/app/update/context_menu.rs new file mode 100644 index 000000000..f1ebbce3a --- /dev/null +++ b/src/app/update/context_menu.rs @@ -0,0 +1,601 @@ +//! Viewport right-click context menu: gathering the menu's inputs from app +//! state, running a picked row, and keyboard navigation while it is open. +//! +//! The rows themselves are built by `ui::popup::context_menu` (a pure model); +//! this module is the glue between that model and the app's messages. + +use crate::app::{ArrowKey, ContextMenuNav, Message, OpenCADStudio}; +use crate::ui::popup::context_menu::{ + build_context_menu, ContextMenu, GripMenuContext, MenuAction, MenuContext, SubmenuId, +}; +use iced::Task; + +impl OpenCADStudio { + /// True while the viewport context menu is open on the active tab. + pub(in crate::app) fn context_menu_open(&self) -> bool { + self.tabs + .get(self.active_tab) + .is_some_and(|tab| tab.scene.selection.borrow().context_menu.is_some()) + } + + /// Everything the menu builder needs, read from the active tab. Called by + /// the view (to render) and by the keyboard handler (to resolve picks), so + /// both always see the same rows. + pub(in crate::app) fn context_menu_context(&self) -> MenuContext { + let tab = &self.tabs[self.active_tab]; + if let Some(grip) = tab.active_grip.as_ref() { + return MenuContext::Grip(GripMenuContext { + mode: grip.mode, + copy_on: tab.grip_copy, + moved: grip.last_world != grip.origin_world, + }); + } + if let Some(cmd) = tab.active_cmd.as_ref() { + // Same guard as typed MTP/M2P and SnapOverrideMtp: the step must + // take a point pick (possibly alongside keywords), not an entity. + let has_point_step = (!cmd.input_kind().wants_text() + || cmd.point_step_accepts_keywords()) + && !cmd.needs_entity_pick(); + return MenuContext::Command { + options: cmd.options(), + has_point_step, + recent_inputs: self.command_line.recent_inputs.clone(), + }; + } + MenuContext::Idle { + has_selection: !tab.scene.selected.is_empty(), + selected_constraint: tab.scene.selected_constraint, + isolation_active: tab.scene.is_isolation_active(), + recent_cmds: self + .command_line + .recent_commands + .iter() + .rev() + .take(crate::ui::popup::context_menu::RECENT_LIMIT) + .cloned() + .collect(), + clipboard_nonempty: !self.clipboard.is_empty(), + undo_label: tab.history.undo_stack.last().map(|s| s.label().to_string()), + redo_label: tab.history.redo_stack.last().map(|s| s.label().to_string()), + props_open: self.show_properties, + } + } + + /// The menu as currently shown (rows + expanded submenu). + pub(in crate::app) fn current_context_menu(&self) -> ContextMenu { + let open = self.tabs[self.active_tab] + .scene + .selection + .borrow() + .context_menu_ui + .open_submenu; + build_context_menu(&self.context_menu_context(), open) + } + + /// Close the menu and give the keyboard back to the command line. + pub(in crate::app) fn close_context_menu(&mut self) -> Task { + let i = self.active_tab; + let mut sel = self.tabs[i].scene.selection.borrow_mut(); + if sel.context_menu.is_none() { + return Task::none(); + } + sel.close_context_menu(); + drop(sel); + self.focus_cmd_input() + } + + pub(in crate::app) fn on_context_menu_submenu_toggle(&mut self, id: SubmenuId) -> Task { + let i = self.active_tab; + let mut sel = self.tabs[i].scene.selection.borrow_mut(); + let ui = &mut sel.context_menu_ui; + ui.open_submenu = if ui.open_submenu == Some(id) { None } else { Some(id) }; + drop(sel); + // Keep the keyboard highlight on the header that was toggled. + let menu = self.current_context_menu(); + let header = menu + .selectable() + .iter() + .position(|s| s.header == Some(id)); + let mut sel = self.tabs[i].scene.selection.borrow_mut(); + if sel.context_menu_ui.highlighted.is_some() { + sel.context_menu_ui.highlighted = header; + } + Task::none() + } + + /// Run a picked row. The menu closes first so the action sees the same + /// state a typed keyword / shortcut would (no menu open, command line + /// focused), then the action is routed through the existing message for + /// that behaviour. + pub(in crate::app) fn on_context_menu_pick(&mut self, action: MenuAction) -> Task { + if let MenuAction::ToggleSubmenu(id) = action { + return self.on_context_menu_submenu_toggle(id); + } + let i = self.active_tab; + { + let mut sel = self.tabs[i].scene.selection.borrow_mut(); + sel.close_context_menu(); + // A pick counts as "another interaction" for the Enter-first + // right-click cycle: the next right-click acts as Enter again. + sel.right_click_entered = false; + } + let focus = self.focus_cmd_input(); + let task = match action { + MenuAction::Enter => self.update(Message::CommandFinalize), + MenuAction::Cancel => self.update(Message::CommandEscape), + MenuAction::Option(kw) => self.update(Message::CommandOptionPick(kw)), + MenuAction::FeedInput(token) => { + if self.tabs[i].active_cmd.is_some() { + self.feed_active_cmd(&token) + } else { + self.run_command_line(&token) + } + } + MenuAction::Command(cmd) => self.update(Message::Command(cmd)), + MenuAction::Mtp => self.update(Message::SnapOverrideMtp), + MenuAction::DeleteSelected => self.update(Message::DeleteSelected), + MenuAction::SelectSimilar => self.update(Message::SelectSimilar), + MenuAction::InvertSelection => self.update(Message::InvertSelection), + MenuAction::DeselectAll => { + self.tabs[i].scene.deselect_all(); + self.refresh_properties(); + Task::none() + } + MenuAction::QuickSelect => self.update(Message::QSelectOpen), + MenuAction::Properties => self.update(Message::ToggleProperties), + MenuAction::Options => self.update(Message::OptionsOpen), + MenuAction::Undo => self.update(Message::Undo), + MenuAction::Redo => self.update(Message::Redo), + MenuAction::DrawOrderPickRef(above) => self.update(Message::DrawOrderPickRef(above)), + MenuAction::ConstraintDelete(id) => self.update(Message::PropConstraintDelete(id)), + MenuAction::Grip(cmd) => self.on_grip_context_pick(cmd), + MenuAction::ToggleSubmenu(_) => Task::none(), + }; + Task::batch(vec![focus, task]) + } + + /// Keyboard navigation while the menu is open. + pub(in crate::app) fn on_context_menu_navigate(&mut self, nav: ContextMenuNav) -> Task { + if !self.context_menu_open() { + return Task::none(); + } + let i = self.active_tab; + let menu = self.current_context_menu(); + let rows = menu.selectable(); + if rows.is_empty() { + return Task::none(); + } + let current = self.tabs[i].scene.selection.borrow().context_menu_ui.highlighted; + let next_enabled = |from: usize, step: isize| -> usize { + let n = rows.len() as isize; + let mut idx = from as isize; + for _ in 0..n { + idx = (idx + step).rem_euclid(n); + if rows[idx as usize].enabled { + return idx as usize; + } + } + from + }; + match nav { + ContextMenuNav::Down => { + let idx = match current { + // First press lands on the default row itself. + None => menu.default_index(), + Some(cur) => next_enabled(cur, 1), + }; + self.tabs[i].scene.selection.borrow_mut().context_menu_ui.highlighted = Some(idx); + Task::none() + } + ContextMenuNav::Up => { + let idx = match current { + None => menu.default_index(), + Some(cur) => next_enabled(cur, -1), + }; + self.tabs[i].scene.selection.borrow_mut().context_menu_ui.highlighted = Some(idx); + Task::none() + } + ContextMenuNav::Enter => { + let idx = current.unwrap_or_else(|| menu.default_index()); + let row = &rows[idx]; + if !row.enabled { + return Task::none(); + } + self.on_context_menu_pick(row.action.clone()) + } + ContextMenuNav::Mnemonic(ch) => match menu.find_mnemonic(ch, current) { + Some(idx) => { + // A letter shared by several rows (PLINE's CEnter / CLose) + // cycles the highlight; a unique one picks at once. + let unique = menu.find_mnemonic(ch, Some(idx)) == Some(idx); + if unique { + self.on_context_menu_pick(rows[idx].action.clone()) + } else { + self.tabs[i].scene.selection.borrow_mut().context_menu_ui.highlighted = + Some(idx); + Task::none() + } + } + None => Task::none(), + }, + } + } + + /// Route keyboard-derived messages to the open menu. Returns `Some(task)` + /// when the message was consumed; `None` lets it fall through to the + /// normal handlers (after closing the menu when the key is not a menu key, + /// so typing a command name or a coordinate just works — the menu gets + /// out of the way exactly as it does in AutoCAD). + pub(in crate::app) fn intercept_context_menu_key(&mut self, msg: &Message) -> Option> { + match msg { + Message::ArrowKeyPressed { direction, .. } + | Message::CommandLineArrowProbe { direction, .. } => match direction { + ArrowKey::Up => Some(self.on_context_menu_navigate(ContextMenuNav::Up)), + ArrowKey::Down => Some(self.on_context_menu_navigate(ContextMenuNav::Down)), + ArrowKey::Left | ArrowKey::Right => { + // On a submenu header, Right expands and Left collapses. + let i = self.active_tab; + let ui = self.tabs[i].scene.selection.borrow().context_menu_ui.clone(); + let menu = self.current_context_menu(); + let rows = menu.selectable(); + let header = ui.highlighted.and_then(|c| rows.get(c)).and_then(|r| r.header); + let want_open = matches!(direction, ArrowKey::Right); + match header { + Some(id) if (ui.open_submenu == Some(id)) != want_open => { + Some(self.on_context_menu_submenu_toggle(id)) + } + _ => Some(Task::none()), + } + } + }, + Message::DynTabNext => Some(self.on_context_menu_navigate(ContextMenuNav::Down)), + Message::CommandFinalize | Message::CommandSpace => { + if self.command_line.input.trim().is_empty() { + Some(self.on_context_menu_navigate(ContextMenuNav::Enter)) + } else { + // Typed text pending: Enter runs it, the menu just closes. + let close = self.close_context_menu(); + let run = self.update(Message::CommandFinalize); + Some(Task::batch(vec![close, run])) + } + } + Message::CommandSubmit => { + if self.command_line.input.trim().is_empty() { + Some(self.on_context_menu_navigate(ContextMenuNav::Enter)) + } else { + let _ = self.close_context_menu(); + None + } + } + Message::CommandAppendChar(s) => self.context_menu_typed(s.chars().next()), + Message::CommandInput(buf) => { + // The focused command-line field delivers the whole buffer; + // only a single appended character is a candidate mnemonic. + let appended = buf + .strip_prefix(self.command_line.input.as_str()) + .filter(|rest| rest.chars().count() == 1) + .and_then(|rest| rest.chars().next()); + match appended { + Some(ch) => self.context_menu_typed(Some(ch)), + None => { + let _ = self.close_context_menu(); + None + } + } + } + _ => None, + } + } + + /// A printable key while the menu is open: a mnemonic picks its row; + /// anything else closes the menu and is typed into the command line. + fn context_menu_typed(&mut self, ch: Option) -> Option> { + let ch = ch?; + let menu = self.current_context_menu(); + if ch.is_ascii_alphanumeric() && menu.find_mnemonic(ch, None).is_some() { + return Some(self.on_context_menu_navigate(ContextMenuNav::Mnemonic(ch))); + } + let _ = self.close_context_menu(); + None + } + + /// Grip-mode shortcut menu actions (Stage 7). + pub(in crate::app) fn on_grip_context_pick(&mut self, cmd: crate::ui::popup::context_menu::GripMenuCmd) -> Task { + let _ = cmd; + Task::none() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::app::settings::RightClickMode; + use crate::command::{CadCommand, StepInput}; + use crate::ui::popup::context_menu::MenuAction; + use glam::DVec3; + use iced::Point; + + /// `update` recursion (right-click → CommandFinalize → CommandSubmit → + /// apply) is deeper than the default test-thread stack; production + /// threads are sized for it (see `.cargo/config.toml`), so tests get an + /// equivalent stack here. + fn with_stack(f: impl FnOnce() + Send + 'static) { + std::thread::Builder::new() + .stack_size(32 * 1024 * 1024) + .spawn(f) + .expect("spawn test thread") + .join() + .unwrap_or_else(|e| std::panic::resume_unwind(e)); + } + + fn app() -> OpenCADStudio { + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + app.tabs[0].scene.selection.borrow_mut().vp_size = (800.0, 600.0); + let _ = app.update(Message::ViewportMove(Point::new(200.0, 150.0))); + app + } + + fn start(app: &mut OpenCADStudio, cmd: &str, points: &[(f64, f64)]) { + let _ = app.dispatch_command(cmd); + for &(x, y) in points { + let _ = app.feed_command(StepInput::Point(DVec3::new(x, y, 0.0))); + } + } + + fn right_click(app: &mut OpenCADStudio) { + let _ = app.update(Message::ViewportRightPress); + let _ = app.update(Message::ViewportRightRelease); + } + + fn menu_open(app: &OpenCADStudio) -> bool { + app.tabs[0].scene.selection.borrow().context_menu.is_some() + } + + fn active(app: &OpenCADStudio) -> Option<&'static str> { + app.tabs[0].active_cmd.as_ref().map(|c| c.name()) + } + + fn menu_actions(app: &OpenCADStudio) -> Vec { + app.current_context_menu() + .selectable() + .into_iter() + .map(|s| s.action) + .collect() + } + + #[test] + fn shortcut_menu_mode_single_rmb_opens_menu_during_command() { + with_stack(|| { + let mut app = app(); + assert_eq!(app.right_click_mode, RightClickMode::ShortcutMenu); + start(&mut app, "LINE", &[(0.0, 0.0), (10.0, 0.0)]); + right_click(&mut app); + assert!(menu_open(&app)); + assert_eq!(active(&app), Some("LINE")); + let acts = menu_actions(&app); + assert_eq!(acts[0], MenuAction::Enter); + assert_eq!(acts[1], MenuAction::Cancel); + assert!(acts.contains(&MenuAction::Option("C".into()))); + assert!(acts.contains(&MenuAction::Option("U".into()))); + assert!(acts.contains(&MenuAction::Mtp)); + }); + } + + #[test] + fn enter_first_mode_first_rmb_is_enter_second_opens_menu() { + with_stack(|| { + let mut app = app(); + app.right_click_mode = RightClickMode::EnterFirst; + start(&mut app, "LINE", &[(0.0, 0.0), (10.0, 0.0)]); + right_click(&mut app); + // Enter ends LINE. + assert!(!menu_open(&app)); + assert_eq!(active(&app), None); + // Idle: the next right-click opens the menu. + right_click(&mut app); + assert!(menu_open(&app)); + let acts = menu_actions(&app); + assert_eq!(acts[0], MenuAction::Command("LINE".into()), "Repeat LINE first"); + }); + } + + #[test] + fn time_sensitive_quick_click_is_enter_and_hold_opens_menu() { + with_stack(|| { + let mut app = app(); + app.right_click_mode = RightClickMode::TimeSensitive; + app.right_click_hold_ms = 250; + start(&mut app, "PLINE", &[(0.0, 0.0), (10.0, 0.0), (10.0, 10.0)]); + // Held: opens the menu, PLINE keeps running. + let _ = app.update(Message::ViewportRightPress); + { + let mut sel = app.tabs[0].scene.selection.borrow_mut(); + sel.right_press_time = iced::time::Instant::now() + .checked_sub(std::time::Duration::from_millis(400)); + } + let _ = app.update(Message::ViewportRightRelease); + assert!(menu_open(&app)); + assert_eq!(active(&app), Some("PLINE")); + // Dismiss, then a quick click finishes the polyline. + let _ = app.update(Message::CommandEscape); + assert!(!menu_open(&app)); + assert_eq!(active(&app), Some("PLINE")); + right_click(&mut app); + assert!(!menu_open(&app)); + assert_eq!(active(&app), None); + }); + } + + #[test] + fn pending_text_runs_as_enter_in_all_modes() { + with_stack(|| { + for mode in RightClickMode::ALL { + let mut app = app(); + app.right_click_mode = mode; + start(&mut app, "LINE", &[(0.0, 0.0), (10.0, 0.0), (10.0, 10.0)]); + app.command_line.input = "C".into(); + right_click(&mut app); + assert!(!menu_open(&app), "{mode:?}"); + assert_eq!(active(&app), None, "{mode:?}: Close should end LINE"); + let lines = app.tabs[0] + .scene + .document + .entities() + .filter(|e| matches!(e, acadrust::EntityType::Line(_))) + .count(); + assert_eq!(lines, 3, "{mode:?}"); + } + }); + } + + #[test] + fn context_menu_pick_option_feeds_keyword() { + with_stack(|| { + let mut app = app(); + start(&mut app, "PLINE", &[(0.0, 0.0), (10.0, 0.0), (10.0, 10.0)]); + right_click(&mut app); + assert!(menu_open(&app)); + let _ = app.update(Message::ContextMenuPick(MenuAction::Option("C".into()))); + assert!(!menu_open(&app)); + assert_eq!(active(&app), None); + let closed = app.tabs[0].scene.document.entities().any(|e| { + matches!(e, acadrust::EntityType::LwPolyline(p) if p.is_closed && p.vertices.len() == 3) + }); + assert!(closed, "PLINE should have been closed by the menu pick"); + }); + } + + #[test] + fn context_menu_pick_closes_menu_and_resets_enter_cycle() { + with_stack(|| { + let mut app = app(); + app.right_click_mode = RightClickMode::EnterFirst; + start(&mut app, "LINE", &[(0.0, 0.0), (10.0, 0.0)]); + right_click(&mut app); // Enter → LINE ends + right_click(&mut app); // idle → menu + assert!(menu_open(&app)); + let _ = app.update(Message::ContextMenuPick(MenuAction::Command("LINE".into()))); + assert!(!menu_open(&app)); + assert_eq!(active(&app), Some("LINE")); + assert!(!app.tabs[0].scene.selection.borrow().right_click_entered); + }); + } + + #[test] + fn menu_enter_with_no_highlight_picks_default() { + with_stack(|| { + let mut app = app(); + start(&mut app, "PLINE", &[(0.0, 0.0), (10.0, 0.0)]); + right_click(&mut app); + let _ = app.update(Message::CommandFinalize); + assert!(!menu_open(&app)); + assert_eq!(active(&app), None, "default row (Done) finishes PLINE"); + }); + } + + #[test] + fn menu_arrow_down_then_enter_picks_cancel() { + with_stack(|| { + let mut app = app(); + start(&mut app, "LINE", &[(0.0, 0.0), (10.0, 0.0)]); + right_click(&mut app); + let _ = app.update(Message::ContextMenuNavigate(ContextMenuNav::Down)); // Enter row + assert_eq!( + app.tabs[0].scene.selection.borrow().context_menu_ui.highlighted, + Some(0) + ); + let _ = app.update(Message::ContextMenuNavigate(ContextMenuNav::Down)); // Cancel + let _ = app.update(Message::ContextMenuNavigate(ContextMenuNav::Enter)); + assert!(!menu_open(&app)); + assert_eq!(active(&app), None); + }); + } + + #[test] + fn menu_mnemonic_c_closes_pline() { + with_stack(|| { + let mut app = app(); + start(&mut app, "PLINE", &[(0.0, 0.0), (10.0, 0.0), (10.0, 10.0)]); + right_click(&mut app); + let _ = app.update(Message::CommandAppendChar("c".into())); + assert!(!menu_open(&app)); + assert_eq!(active(&app), None); + let closed = app.tabs[0].scene.document.entities().any( + |e| matches!(e, acadrust::EntityType::LwPolyline(p) if p.is_closed), + ); + assert!(closed); + }); + } + + #[test] + fn menu_unmatched_char_closes_menu_and_types() { + with_stack(|| { + let mut app = app(); + app.dyn_input = false; + start(&mut app, "LINE", &[(0.0, 0.0)]); + right_click(&mut app); + assert!(menu_open(&app)); + let _ = app.update(Message::CommandAppendChar("7".into())); + assert!(!menu_open(&app)); + assert_eq!(active(&app), Some("LINE")); + assert!(app.command_line.input.contains('7'), "{:?}", app.command_line.input); + }); + } + + #[test] + fn recent_input_pick_refeeds_token() { + with_stack(|| { + let mut app = app(); + start(&mut app, "LINE", &[(0.0, 0.0)]); + let _ = app.feed_active_cmd("10,10"); + assert_eq!(app.command_line.recent_inputs, vec!["10,10".to_string()]); + let _ = app.feed_active_cmd("20,0"); + right_click(&mut app); + let _ = app.update(Message::ContextMenuSubmenuToggle(SubmenuId::RecentInput)); + let acts = menu_actions(&app); + assert!(acts.contains(&MenuAction::FeedInput("10,10".into()))); + let _ = app.update(Message::ContextMenuPick(MenuAction::FeedInput("10,10".into()))); + assert!(!menu_open(&app)); + let lines = app.tabs[0] + .scene + .document + .entities() + .filter(|e| matches!(e, acadrust::EntityType::Line(_))) + .count(); + assert_eq!(lines, 3); + }); + } + + #[test] + fn idle_selection_menu_erase_deletes_selection() { + with_stack(|| { + let mut app = app(); + start(&mut app, "LINE", &[(0.0, 0.0), (10.0, 0.0)]); + let _ = app.update(Message::CommandEscape); + let handle = app.tabs[0] + .scene + .document + .entities() + .find_map(|e| match e { + acadrust::EntityType::Line(l) => Some(l.common.handle), + _ => None, + }) + .expect("a line"); + app.tabs[0].scene.select_entities(&[handle]); + right_click(&mut app); + let acts = menu_actions(&app); + assert!(acts.contains(&MenuAction::DeleteSelected)); + assert!(acts.contains(&MenuAction::Command("ROTATE".into()))); + let _ = app.update(Message::ContextMenuPick(MenuAction::DeleteSelected)); + assert!(!menu_open(&app)); + let lines = app.tabs[0] + .scene + .document + .entities() + .filter(|e| matches!(e, acadrust::EntityType::Line(_))) + .count(); + assert_eq!(lines, 0); + }); + } +} diff --git a/src/app/update/file.rs b/src/app/update/file.rs index c4edecfa0..da1ac14a3 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -481,6 +481,8 @@ impl OpenCADStudio { selection_cycling: self.selection_cycling, double_click_block_refedit: self.double_click_block_refedit, double_click_block_attedit: self.double_click_block_attedit, + right_click_mode: self.right_click_mode, + right_click_hold_ms: self.right_click_hold_ms, grip_object_limit: self.grip_object_limit, ncopy_bind: self.ncopy_bind, cursor_type: self.cursor_type, @@ -556,6 +558,8 @@ impl OpenCADStudio { self.selection_cycling = s.selection_cycling; self.double_click_block_refedit = s.double_click_block_refedit; self.double_click_block_attedit = s.double_click_block_attedit; + self.right_click_mode = s.right_click_mode; + self.right_click_hold_ms = super::super::settings::clamp_right_click_hold_ms(s.right_click_hold_ms); self.grip_object_limit = s.grip_object_limit.clamp(0, 32767); self.ncopy_bind = s.ncopy_bind; self.cursor_type = s.cursor_type; diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index c88d9ffdf..c2bccca35 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -90,6 +90,7 @@ fn reorder_insertion_index(from: usize, to: usize, after: bool, len: usize) -> O } mod command; +mod context_menu; mod dialog; mod dynamic; mod file; @@ -301,6 +302,14 @@ impl OpenCADStudio { return Task::none(); } } + // The open right-click context menu owns the keyboard the same way: + // arrows / Enter / mnemonic letters drive it, any other key closes it + // and falls through to the command line (AutoCAD behaviour). + if self.context_menu_open() { + if let Some(task) = self.intercept_context_menu_key(&msg) { + return task; + } + } let task = self.update_inner(msg); self.refresh_gpu_status(); self.show_next_startup_modal(); @@ -3536,6 +3545,10 @@ impl OpenCADStudio { return Task::none(); } let was_click = !sel.right_dragging; + // How long the button was held, for the time-sensitive mode. + let held_ms = sel + .right_press_time + .map_or(0, |t| t.elapsed().as_millis() as i32); sel.right_down = false; sel.right_press_pos = None; sel.right_press_time = None; @@ -3560,21 +3573,43 @@ impl OpenCADStudio { drop(sel); return self.update(Message::CommandFinalize); } - // A right-click (no orbit). While a command is active the first - // right-click acts as Enter (commit / close); a second - // consecutive right-click opens the context menu instead. When - // idle it always opens the menu. (Right-drag, handled above, - // always orbits.) Any other interaction — a left-click pick or a - // new command — resets the cycle so the next right-click is Enter. - if self.tabs[i].active_cmd.is_some() && !sel.right_click_entered { + // A right-click. What it does is the user's choice (Options → + // User Preferences, AutoCAD's SHORTCUTMENU): + // • Shortcut menu — always open the context menu, whose + // default row (Enter / Repeat) sits under the pointer. + // • Time-sensitive — a quick click is Enter while a command + // runs (repeat the last command when idle); a held click + // opens the menu. + // • Enter first — while a command is active the first + // right-click acts as Enter and a second consecutive one + // opens the menu; idle always opens the menu. Any other + // interaction — a left-click pick or a new command — resets + // that cycle so the next right-click is Enter again. + let has_cmd = self.tabs[i].active_cmd.is_some(); + let open_menu = match self.right_click_mode { + super::settings::RightClickMode::ShortcutMenu => true, + super::settings::RightClickMode::TimeSensitive => { + held_ms >= self.right_click_hold_ms + } + super::settings::RightClickMode::EnterFirst => { + !(has_cmd && !sel.right_click_entered) + } + }; + if !open_menu { sel.right_click_entered = true; drop(sel); + // CommandFinalize is Enter during a command and "repeat + // the last command" when idle — exactly AutoCAD's quick + // right-click. return self.update(Message::CommandFinalize); } sel.right_click_entered = false; - sel.context_menu = Some(click_pos); - sel.draworder_submenu = false; - Task::none() + sel.open_context_menu(click_pos); + drop(sel); + // Take the keyboard away from the command-line field so keys + // reach the menu through the global subscription; the field + // is re-focused when the menu closes. + self.unfocus_widgets() } Message::ViewportMiddlePress => self.on_viewport_middle_press(), @@ -5306,12 +5341,9 @@ impl OpenCADStudio { self.post_editor_closed(committed) } - Message::DrawOrderSubmenuToggle => { - let i = self.active_tab; - let mut sel = self.tabs[i].scene.selection.borrow_mut(); - sel.draworder_submenu = !sel.draworder_submenu; - Task::none() - } + Message::ContextMenuPick(action) => self.on_context_menu_pick(action), + Message::ContextMenuSubmenuToggle(id) => self.on_context_menu_submenu_toggle(id), + Message::ContextMenuNavigate(nav) => self.on_context_menu_navigate(nav), Message::DrawOrderPickRef(above) => { let i = self.active_tab; @@ -7206,6 +7238,18 @@ impl OpenCADStudio { Task::none() } + Message::RightClickModeChanged(mode) => { + self.right_click_mode = mode; + self.persist_settings_if_changed(); + Task::none() + } + + Message::RightClickHoldMsChanged(ms) => { + self.right_click_hold_ms = super::settings::clamp_right_click_hold_ms(ms); + self.persist_settings_if_changed(); + Task::none() + } + Message::TextEditModeChanged(single) => { self.texteditmode = single; self.persist_settings_if_changed(); diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index 06cbe15d2..b5712158f 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -1752,37 +1752,17 @@ bg={bg_ms:.1}ms n={view_count}" // the cursor position (canvas-relative) anchors the menu under // the cursor instead of drifting into window-relative space. if !tab.is_start { - let (ctx_pos, draworder_open) = { + let (ctx_pos, highlighted) = { let sel = tab.scene.selection.borrow(); - (sel.context_menu, sel.draworder_submenu) + (sel.context_menu, sel.context_menu_ui.highlighted) }; if let Some(p) = ctx_pos { - let has_cmd = tab.active_cmd.is_some(); - // Same guard as typed MTP/M2P and SnapOverrideMtp. - let has_point_step = tab.active_cmd.as_ref().is_some_and(|c| { - (!c.input_kind().wants_text() || c.point_step_accepts_keywords()) - && !c.needs_entity_pick() - }); - let has_selection = !tab.scene.selected.is_empty(); - let isolation_active = tab.scene.is_isolation_active(); - let last_cmds: Vec = self - .command_line - .recent_commands - .iter() - .rev() - .take(3) - .cloned() - .collect(); + let menu = self.current_context_menu(); viewport_stack = viewport_stack.push(viewport_context_menu_overlay( p, command_line_inset, - has_cmd, - has_selection, - tab.scene.selected_constraint, - isolation_active, - last_cmds, - draworder_open, - has_point_step, + &menu, + highlighted, )); } } diff --git a/src/app/view/modal.rs b/src/app/view/modal.rs index 157334d76..04fe270ec 100644 --- a/src/app/view/modal.rs +++ b/src/app/view/modal.rs @@ -297,6 +297,8 @@ impl OpenCADStudio { show_viewcube: self.show_viewcube, show_ucs_icon: self.show_ucs_icon, ucs_icon_at_origin: self.ucs_icon_at_origin, + right_click_mode: self.right_click_mode, + right_click_hold_ms: self.right_click_hold_ms, }, crate::ui::window::options::spacemouse::view( self.spacemouse_preferences, self.spacemouse.status(), diff --git a/src/app/view/overlay.rs b/src/app/view/overlay.rs index 77c57b46c..37b28d79a 100644 --- a/src/app/view/overlay.rs +++ b/src/app/view/overlay.rs @@ -11,6 +11,7 @@ use iced::widget::{ }; use iced::{Background, Border, Color, Element, Event, Fill, Length, Rectangle, Size, Theme, Vector}; use crate::t; +use crate::ui::popup::context_menu::{ContextMenu, MenuItem, MenuRow, MENU_PAD_TOP, MENU_ROW_H}; pub(super) fn position_canvas_overlay<'a>( anchor: iced::Point, @@ -912,9 +913,15 @@ fn mtext_editor_content<'a>( /// panel is laid out first, so edge flipping uses its actual translated size /// instead of estimates. `bottom_inset` reserves overlaid controls such as the /// command line. +/// +/// `offset` shifts the preferred placement from the anchor before the fit / +/// flip logic runs (e.g. to put a specific row of a menu under the pointer). +/// When the shifted panel does not fit it flips to the other side of the +/// *original* anchor, so the pointer never ends up over an unintended row. fn position_canvas_overlay_clamped<'a>( anchor: iced::Point, bottom_inset: f32, + offset: Vector, panel: Element<'a, Message>, ) -> Element<'a, Message> { Element::new(ClampedPin { @@ -922,6 +929,7 @@ fn position_canvas_overlay_clamped<'a>( anchor, bottom_inset: bottom_inset.max(0.0), gap: 0.0, + offset, }) } @@ -937,6 +945,7 @@ pub(super) fn position_canvas_overlay_near_cursor<'a>( anchor: cursor, bottom_inset: bottom_inset.max(0.0), gap: 12.0, + offset: Vector::ZERO, }) } @@ -945,6 +954,8 @@ struct ClampedPin<'a> { anchor: iced::Point, bottom_inset: f32, gap: f32, + /// Preferred placement shift from the anchor (applied before fitting). + offset: Vector, } impl Widget for ClampedPin<'_> { @@ -985,7 +996,7 @@ impl Widget for ClampedPin<'_> { let content = node.size(); let max_x = (max.width - MARGIN - content.width).max(MARGIN); - let right = self.anchor.x + self.gap; + let right = self.anchor.x + self.gap + self.offset.x; let left = self.anchor.x - self.gap - content.width; let x = if right <= max_x { right.max(MARGIN) @@ -997,7 +1008,7 @@ impl Widget for ClampedPin<'_> { let max_y = (max.height - self.bottom_inset - MARGIN - content.height).max(MARGIN); - let below = self.anchor.y + self.gap; + let below = self.anchor.y + self.gap + self.offset.y; let above = self.anchor.y - self.gap - content.height; let y = if below <= max_y { below.max(MARGIN) @@ -1109,26 +1120,21 @@ impl Widget for ClampedPin<'_> { // ── Viewport right-click context menu ────────────────────────────────────── +/// Horizontal inset so the pointer rests inside the default row, not on the +/// panel's border, when the menu opens. +const MENU_CURSOR_INSET_X: f32 = 24.0; + +/// Render the right-click context menu (rows from +/// `ui::popup::context_menu::build_context_menu`). The panel is placed so the +/// default row sits under the pointer: right-click then left-click in place +/// completes the step without moving the mouse (AutoCAD anchors its shortcut +/// menu the same way). `highlighted` is the keyboard highlight, if any. pub(super) fn viewport_context_menu_overlay( pos: iced::Point, bottom_inset: f32, - has_cmd: bool, - has_selection: bool, - selected_constraint: Option, - isolation_active: bool, - last_cmds: Vec, - draworder_open: bool, - has_point_step: bool, + menu: &ContextMenu, + highlighted: Option, ) -> Element<'static, Message> { - let item = |label: String, msg: Message| -> Element<'static, Message> { - button(text(label).size(12)) - .on_press(msg) - .style(button::subtle) - .padding([4, 12]) - .width(Fill) - .into() - }; - let sep = || -> Element<'static, Message> { container(iced::widget::Space::new().width(Fill).height(1)) .style(|theme: &Theme| container::Style { @@ -1143,142 +1149,60 @@ pub(super) fn viewport_context_menu_overlay( .into() }; - // Indented variant for sub-menu rows (e.g. Draw Order children). - let subitem = |label: String, msg: Message| -> Element<'static, Message> { - button(text(label).size(12)) - .on_press(msg) - .style(button::subtle) - .padding(iced::Padding { - top: 4.0, - right: 12.0, - bottom: 4.0, - left: 26.0, - }) - .width(Fill) - .into() - }; - + // Index into `menu.selectable()` of the row being rendered, so the + // keyboard highlight lands on the same row the model counts. + let mut sel_idx = 0usize; let mut items: Vec> = Vec::new(); - if has_cmd { - items.push(item(t!("Cancel").into_owned(), Message::CommandEscape)); - items.push(item(t!("Enter").into_owned(), Message::CommandFinalize)); - // MTP (`_M2P`) goes last, after Parallel: two picks, so not a - // `SnapType`, but same icon-only cell with hover tooltip. - if has_point_step { - items.push(sep()); - items.push(item( - t!("Mid Between 2 Points (M2P)").into_owned(), - Message::SnapOverrideMtp, - )); - } - } else { - if !last_cmds.is_empty() { - let last = last_cmds[0].clone(); - items.push(item( - t!("Repeat %{last}", last = last).into_owned(), - Message::Command(last.to_uppercase()), - )); - if last_cmds.len() > 1 { - for cmd in last_cmds.iter().skip(1) { - let c = cmd.clone(); - items.push(item(c.clone(), Message::Command(c.to_uppercase()))); + for row in &menu.rows { + match row { + MenuRow::Item(item) => { + let is_hl = highlighted == Some(sel_idx); + items.push(context_menu_row(item, 0.0, is_hl)); + sel_idx += 1; + } + MenuRow::Separator => items.push(sep()), + MenuRow::Submenu { + id, + label, + items: children, + open, + } => { + let is_hl = highlighted == Some(sel_idx); + let caret = if *open { + crate::ui::icons::themed_arrow_down(9.0) + } else { + crate::ui::icons::themed_arrow_right(9.0) + }; + let content = row![ + text(label.clone()).size(12), + iced::widget::Space::new().width(Fill), + caret, + ] + .align_y(iced::Center); + let enabled = !children.is_empty(); + let mut btn = button(content) + .padding([4, 12]) + .width(Fill) + .height(Length::Fixed(MENU_ROW_H)) + .style(move |theme: &Theme, status| context_menu_row_style(theme, status, is_hl)); + if enabled { + btn = btn.on_press(Message::ContextMenuSubmenuToggle(*id)); + } + items.push(btn.into()); + sel_idx += 1; + if *open { + for child in children { + let is_hl = highlighted == Some(sel_idx); + items.push(context_menu_row(child, 14.0, is_hl)); + sel_idx += 1; + } } } - items.push(sep()); } - if has_selection { - items.push(item(t!("Delete").into_owned(), Message::DeleteSelected)); - items.push(item( - t!("Move").into_owned(), - Message::Command("MOVE".to_string()), - )); - items.push(item( - t!("Copy").into_owned(), - Message::Command("COPY".to_string()), - )); - items.push(sep()); - let do_caret = if draworder_open { - crate::ui::icons::themed_arrow_down(9.0) - } else { - crate::ui::icons::themed_arrow_right(9.0) - }; - items.push( - button( - row![ - text(t!("Draw Order").into_owned()).size(12), - iced::widget::Space::new().width(Fill), - do_caret, - ] - .align_y(iced::Center), - ) - .on_press(Message::DrawOrderSubmenuToggle) - .style(button::subtle) - .padding([4, 12]) - .width(Fill) - .into(), - ); - if draworder_open { - items.push(subitem( - t!("Bring to Front").into_owned(), - Message::Command("DRAWORDER F".to_string()), - )); - items.push(subitem( - t!("Send to Back").into_owned(), - Message::Command("DRAWORDER B".to_string()), - )); - items.push(subitem( - t!("Bring Above Object").into_owned(), - Message::DrawOrderPickRef(true), - )); - items.push(subitem( - t!("Send Under Object").into_owned(), - Message::DrawOrderPickRef(false), - )); - } - items.push(sep()); - items.push(item( - t!("Isolate Objects").into_owned(), - Message::Command("ISOLATEOBJECTS".to_string()), - )); - items.push(item( - t!("Hide Objects").into_owned(), - Message::Command("HIDEOBJECTS".to_string()), - )); - items.push(sep()); - items.push(item(t!("Select Similar").into_owned(), Message::SelectSimilar)); - items.push(item( - t!("Invert Selection").into_owned(), - Message::InvertSelection, - )); - } else if let Some(id) = selected_constraint { - // A selected constraint-glyph pill gets its own minimal menu — - // none of the entity-selection actions above (Move/Copy/Isolate/ - // Select Similar) apply to it. - items.push(item( - t!("Delete").into_owned(), - Message::PropConstraintDelete(id), - )); - items.push(sep()); - } - if isolation_active { - items.push(item( - t!("End Object Isolation").into_owned(), - Message::Command("UNISOLATEOBJECTS".to_string()), - )); - } - items.push(item( - t!("Select All").into_owned(), - Message::Command("SELECTALL".to_string()), - )); - items.push(item(t!("Quick Select...").into_owned(), Message::QSelectOpen)); - items.push(item( - t!("Zoom Extents").into_owned(), - Message::Command("ZOOM EXTENTS".to_string()), - )); } - let menu_col = column(items).spacing(0).width(Length::Fixed(180.0)); + let menu_col = column(items).spacing(0).width(Length::Fixed(menu.width)); let menu_col = scrollable(menu_col) .height(Length::Shrink) .direction(scrollable::Direction::Vertical( @@ -1287,12 +1211,101 @@ pub(super) fn viewport_context_menu_overlay( .scroller_width(6), )); - let menu = container(menu_col) + let panel = container(menu_col) .style(container::bordered_box) - .padding([4, 0]) - .width(Length::Fixed(180.0)); + .padding([MENU_PAD_TOP as u16, 0]) + .width(Length::Fixed(menu.width)); - position_canvas_overlay_clamped(pos, bottom_inset, menu.into()) + // Shift the panel so the default row's centre line is under the pointer. + let offset = Vector::new( + -MENU_CURSOR_INSET_X, + -(menu.default_row_y() + MENU_ROW_H * 0.5), + ); + position_canvas_overlay_clamped(pos, bottom_inset, offset, panel.into()) +} + +/// One menu row: label (bold for the default row, "✓ "-prefixed when +/// checked), and the keyword hint right-aligned and dimmed. Disabled rows +/// render without a press handler and with faded text. +fn context_menu_row(item: &MenuItem, indent: f32, highlighted: bool) -> Element<'static, Message> { + let label = if item.checked { + format!("✓ {}", item.label) + } else { + item.label.clone() + }; + let mut label_text = text(label).size(12); + if item.default { + label_text = label_text.font(iced::Font { + weight: iced::font::Weight::Bold, + ..iced::Font::DEFAULT + }); + } + let enabled = item.enabled; + let mut content = row![label_text].spacing(8).align_y(iced::Center); + if let Some(hint) = item.hint.as_ref() { + content = content + .push(iced::widget::Space::new().width(Fill)) + .push( + text(hint.clone()) + .size(11) + .style(move |theme: &Theme| { + let palette = theme.palette(); + let base = if highlighted { + palette.primary.strong.text + } else { + palette.background.base.text + }; + iced::widget::text::Style { + color: Some(base.scale_alpha(if enabled { 0.6 } else { 0.35 })), + } + }), + ); + } + let mut btn = button(content) + .padding(iced::Padding { + top: 4.0, + right: 12.0, + bottom: 4.0, + left: 12.0 + indent, + }) + .width(Fill) + .height(Length::Fixed(MENU_ROW_H)) + .style(move |theme: &Theme, status| context_menu_row_style(theme, status, highlighted)); + if enabled { + btn = btn.on_press(Message::ContextMenuPick(item.action.clone())); + } + btn.into() +} + +/// Row colours: keyboard highlight uses the accent (as the grip popup does), +/// hover the subtle background tint, disabled rows faded text. +fn context_menu_row_style(theme: &Theme, status: button::Status, highlighted: bool) -> button::Style { + let palette = theme.palette(); + let (background, text_color) = if highlighted { + ( + Some(Background::Color(palette.primary.strong.color)), + palette.primary.strong.text, + ) + } else { + match status { + button::Status::Hovered | button::Status::Pressed => ( + Some(Background::Color(palette.background.weak.color)), + palette.background.weak.text, + ), + button::Status::Disabled => (None, palette.background.base.text.scale_alpha(0.42)), + button::Status::Active => (None, palette.background.base.text), + } + }; + button::Style { + background, + text_color, + border: Border { + color: Color::TRANSPARENT, + width: 0.0, + radius: 0.0.into(), + }, + ..Default::default() + } } /// One-shot snap override menu (Shift+RMB, #337): a cursor-anchored grid of diff --git a/src/modules/draw/draw/line.rs b/src/modules/draw/draw/line.rs index 09615df52..453627b87 100644 --- a/src/modules/draw/draw/line.rs +++ b/src/modules/draw/draw/line.rs @@ -71,6 +71,21 @@ impl CadCommand for LineCommand { } } + fn options(&self) -> Vec { + use crate::command::CmdOption; + // Mirrors AutoCAD's LINE prompt: Undo once a point is placed, Close + // once a closing segment is possible, eXit always. + match self.points.len() { + 0 => Vec::new(), + 1 => vec![CmdOption::new("Undo", "U"), CmdOption::new("eXit", "X")], + _ => vec![ + CmdOption::new("Close", "C"), + CmdOption::new("Undo", "U"), + CmdOption::new("eXit", "X"), + ], + } + } + fn on_point(&mut self, pt: DVec3) -> CmdResult { if let Some(&last) = self.points.last() { let line = Self::line_between(last, pt); @@ -185,6 +200,9 @@ impl CadCommand for LineCommand { Some(CmdResult::NeedPoint) } } + // eXit: end the command keeping every segment drawn so far + // (AutoCAD LINE's third keyword). + "X" | "EXIT" => Some(CmdResult::Cancel), "U" | "UNDO" => { if self.points.len() >= 2 { // Drop the last vertex and revert its committed segment. diff --git a/src/scene/pick/selection_state.rs b/src/scene/pick/selection_state.rs index 067a9c2c2..22faca9a5 100644 --- a/src/scene/pick/selection_state.rs +++ b/src/scene/pick/selection_state.rs @@ -30,9 +30,13 @@ pub struct SelectionState { pub poly_points: Vec, pub poly_crossing: bool, pub poly_last_crossing: bool, + /// Canvas position the right-click context menu is anchored at while it + /// is open. `None` = closed. Its rows are rebuilt from app state every + /// frame (`ui::popup::context_menu::build_context_menu`). pub context_menu: Option, - /// True while the context menu's Draw Order sub-items are expanded. - pub draworder_submenu: bool, + /// Transient UI state of the open context menu (expanded submenu, + /// keyboard highlight). Reset every time the menu opens. + pub context_menu_ui: ContextMenuUi, pub last_move_pos: Option, pub left_down: bool, pub left_press_pos: Option, @@ -57,7 +61,31 @@ pub struct SelectionState { pub middle_last_press_time: Option, } +/// Transient state of the open right-click context menu. +#[derive(Clone, Default)] +pub struct ContextMenuUi { + /// The accordion submenu currently expanded (at most one). + pub open_submenu: Option, + /// Keyboard highlight as an index into `ContextMenu::selectable()`. + /// `None` until an arrow key / mnemonic is pressed, so a mouse user is + /// never shown a highlighted row that is not under the pointer. + pub highlighted: Option, +} + impl SelectionState { + /// Open the context menu at `at`, discarding the previous menu's + /// expanded-submenu / highlight state. + pub fn open_context_menu(&mut self, at: Point) { + self.context_menu = Some(at); + self.context_menu_ui = ContextMenuUi::default(); + } + + /// Close the context menu (no-op when it is not open). + pub fn close_context_menu(&mut self) { + self.context_menu = None; + self.context_menu_ui = ContextMenuUi::default(); + } + /// End every left-button selection gesture without disturbing the previous /// completed-window record or command-owned preview marquee. Grip editing /// owns the left button while engaged and calls this before/after placement diff --git a/src/ui/command_line.rs b/src/ui/command_line.rs index da54abc54..163e736bc 100644 --- a/src/ui/command_line.rs +++ b/src/ui/command_line.rs @@ -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, + /// 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, /// Current position in `cmd_recall` while navigating (None = not navigating). recall_cursor: Option, /// 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() } diff --git a/src/ui/popup/context_menu.rs b/src/ui/popup/context_menu.rs new file mode 100644 index 000000000..6d5905389 --- /dev/null +++ b/src/ui/popup/context_menu.rs @@ -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, + /// 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, + 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, 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) -> Self { + self.hint = Some(hint.into()); + self + } + + fn mnemonic(mut self, ch: Option) -> 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, + 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, + pub enabled: bool, + /// `Some(id)` for a submenu header row. + pub header: Option, +} + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct ContextMenu { + pub rows: Vec, + 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 { + 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) -> Option { + 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, + /// 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, + }, + /// Nothing running. + Idle { + has_selection: bool, + selected_constraint: Option, + isolation_active: bool, + /// Recently run commands, newest first. + recent_cmds: Vec, + clipboard_nonempty: bool, + undo_label: Option, + redo_label: Option, + 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) -> 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 { + 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, +) -> Vec { + 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, + isolation_active: bool, + recent_cmds: &[String], + clipboard_nonempty: bool, + undo_label: Option<&str>, + redo_label: Option<&str>, + props_open: bool, + open_submenu: Option, +) -> Vec { + 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 { + 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 { + 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 { + 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); + } +} diff --git a/src/ui/popup/mod.rs b/src/ui/popup/mod.rs index e5ff95d10..b541650d7 100644 --- a/src/ui/popup/mod.rs +++ b/src/ui/popup/mod.rs @@ -1,3 +1,4 @@ +pub mod context_menu; pub mod cycle_popup; pub mod isolate_popup; pub mod polar_popup; diff --git a/src/ui/window/options.rs b/src/ui/window/options.rs index c3e1c0d8e..bef3fb30c 100644 --- a/src/ui/window/options.rs +++ b/src/ui/window/options.rs @@ -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::>(); + 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![