From 098585bb2dd05ba1606163727eebff752db0751e Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Sat, 27 Jun 2026 16:16:08 +0300 Subject: [PATCH] feat(viewport): RMB=Enter option + timed right-click Adds a configurable right-mouse-button behavior without disturbing orbit or the context menu. - New persisted setting `rmb_enter` (command `RMBENTER [ON|OFF]`, default off): when on, a right-click while a command is active acts as Enter (commit/close); when idle it still opens the context menu. Right-drag always orbits, so nothing is lost. - Right-click vs orbit is now time-based: orbit engages only after the button is held >=150ms and the pointer moves, so a quick right-click is no longer swallowed by slight pointer jitter (was an 8px distance gate). - The right-click Repeat menu now lists commands from every source (command line, ribbon, context menu, shortcuts) via a unified recents list recorded at the dispatch choke point, instead of only typed commands. Closes #185 Co-Authored-By: Claude Opus 4.8 --- src/app/commands/display.rs | 21 ++++++++ src/app/commands/mod.rs | 85 ++++++++++++++++++------------- src/app/mod.rs | 4 ++ src/app/settings.rs | 9 +++- src/app/update/file.rs | 2 + src/app/update/mod.rs | 33 ++++++++---- src/app/update/viewport.rs | 36 ++++++++++--- src/app/view/mod.rs | 2 +- src/scene/pick/selection_state.rs | 1 + src/ui/command_line.rs | 23 ++++++++- 10 files changed, 160 insertions(+), 56 deletions(-) diff --git a/src/app/commands/display.rs b/src/app/commands/display.rs index 867a7b207..345e2b0c4 100644 --- a/src/app/commands/display.rs +++ b/src/app/commands/display.rs @@ -17,6 +17,27 @@ impl OpenCADStudio { .push_output("PAN: drag with the left mouse button. Press Esc to exit."); } + // RMBENTER [ON|OFF] — toggle whether a viewport right-click acts as + // Enter (commit / close) while a command is active. Idle right-click + // still opens the context menu and right-drag still orbits. The + // preference is persisted across runs. + cmd if cmd == "RMBENTER" || cmd.starts_with("RMBENTER ") => { + let arg = cmd + .split_once(' ') + .map(|(_, r)| r.trim().to_uppercase()) + .unwrap_or_default(); + self.rmb_enter = match arg.as_str() { + "ON" | "1" => true, + "OFF" | "0" => false, + _ => !self.rmb_enter, + }; + self.command_line.push_output(if self.rmb_enter { + "RMBENTER on: right-click acts as Enter while a command is active." + } else { + "RMBENTER off: right-click opens the context menu." + }); + } + // ── TABLE cell editing ───────────────────────────────────────────── // TABLE CELL — set text for a cell in the selected Table cmd if cmd.starts_with("TABLE ") => { diff --git a/src/app/commands/mod.rs b/src/app/commands/mod.rs index b4c949eaf..dc92913d2 100644 --- a/src/app/commands/mod.rs +++ b/src/app/commands/mod.rs @@ -99,46 +99,22 @@ impl OpenCADStudio { // tool was a one-shot and we must turn the ribbon highlight off here — // normally apply_cmd_result does that, but plugin dispatch can return // without producing a CmdResult. + self.command_line.record_recent(cmd); if self.tabs[i].active_cmd.is_none() { self.ribbon.deactivate_tool(); } return Task::none(); } - // Command families are dispatched in source order; the first family - // whose `match` arm matches `cmd` handles it. Each handler returns - // `Some(task)` when it handled the command (either an early-returning - // arm or a state-mutating arm that falls through to `finish_dispatch`), - // or `None` to fall through to the next family — exactly as a single - // sequential `match` over all arms would behave. - if let Some(t) = self.dispatch_fileops(cmd, i) { - return t; - } - if let Some(t) = self.dispatch_layers(cmd, i) { - return t; - } - if let Some(t) = self.dispatch_blocks(cmd, i) { - return t; - } - if let Some(t) = self.dispatch_draw(cmd, i) { - return t; - } - if let Some(t) = self.dispatch_dim(cmd, i) { - return t; - } - if let Some(t) = self.dispatch_inquiry(cmd, i) { - return t; - } - if let Some(t) = self.dispatch_view(cmd, i) { - return t; - } - if let Some(t) = self.dispatch_layerprops(cmd, i) { - return t; - } - if let Some(t) = self.dispatch_styleprops(cmd, i) { - return t; - } - if let Some(t) = self.dispatch_display(cmd, i) { + // Command families are dispatched in source order (see + // `dispatch_families`); the first whose `match` arm matches handles it. + if let Some(t) = self.dispatch_families(cmd, i) { + // A command resolved — record it for the right-click Repeat menu so + // commands from every source (command line, ribbon, context menu, + // shortcuts) appear there, not only typed ones. Recorded after + // resolution so a partial verb completed via the suggestion + // fallback (`BAC`) stores the real command (`BACKGROUND`). + self.command_line.record_recent(cmd); return t; } @@ -158,6 +134,45 @@ impl OpenCADStudio { self.finish_dispatch(cmd) } + /// Try each command family in source order, returning the first that + /// handles `cmd`, or `None` when none match. Each family returns + /// `Some(task)` for an arm it owns (early-returning or falling through to + /// `finish_dispatch`), or `None` to defer to the next — equivalent to one + /// sequential `match` over all arms. + fn dispatch_families(&mut self, cmd: &str, i: usize) -> Option> { + if let Some(t) = self.dispatch_fileops(cmd, i) { + return Some(t); + } + if let Some(t) = self.dispatch_layers(cmd, i) { + return Some(t); + } + if let Some(t) = self.dispatch_blocks(cmd, i) { + return Some(t); + } + if let Some(t) = self.dispatch_draw(cmd, i) { + return Some(t); + } + if let Some(t) = self.dispatch_dim(cmd, i) { + return Some(t); + } + if let Some(t) = self.dispatch_inquiry(cmd, i) { + return Some(t); + } + if let Some(t) = self.dispatch_view(cmd, i) { + return Some(t); + } + if let Some(t) = self.dispatch_layerprops(cmd, i) { + return Some(t); + } + if let Some(t) = self.dispatch_styleprops(cmd, i) { + return Some(t); + } + if let Some(t) = self.dispatch_display(cmd, i) { + return Some(t); + } + None + } + /// Shared tail run after a `dispatch_*` family handler whose matched arm /// did not early-return. Focuses the command line whenever a command just /// became active. @@ -193,7 +208,7 @@ inventory::submit!(crate::command::CommandRegistration { "P", "PAN", "PAGESETUP", "PERF", "PERSP", "PLOT", "PLOTSTYLE", "PLOTSTYLEEDITOR", "PLOTSTYLEPANEL", "PR", "PRINT", "PROPERTIES", "PROPS", "PSPACE", "PURGE", "QS", "QSAVE", "QSELECT", - "QUIT", "REDO", "REDRAW", "REDRWALL", "REGEN", "REGENALL", "RENAME", "REPORT", + "QUIT", "REDO", "REDRAW", "REDRWALL", "REGEN", "REGENALL", "RENAME", "REPORT", "RMBENTER", "SA", "SAVE", "SAVEAS", "SCALETEXT", "SELECTALL", "SELECTSIMILAR", "SELSIM", "SHEETSET", "SHORTCUTS", "SOLID", "SSM", "STEPOUT", "STLOUT", "STPOUT", "STYLE", "STYLESMANAGER", "TABLESTYLE", "TOOLPALETTES", "TP", "TS", "U", "UCS", "UCSICON", "UNDERLAY", diff --git a/src/app/mod.rs b/src/app/mod.rs index a137c0584..5a7ef3c85 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -247,6 +247,9 @@ pub(super) struct OpenCADStudio { dyn_input: bool, /// Controls whether the TEXTEDIT command repeats automatically (0 = Multiple, 1 = Single). pub texteditmode: bool, + /// When true, a viewport right-click acts as Enter while a command is active + /// (idle right-click still opens the context menu). Toggled by `RMBENTER`. + pub rmb_enter: bool, /// Persisted default viewport background, restored from settings and applied /// to every drawing tab (new and opened) so a chosen background survives /// restarts (#188). `None` = the built-in dark-grey / off-white defaults. @@ -1823,6 +1826,7 @@ impl OpenCADStudio { show_grid: false, dyn_input: true, texteditmode: false, + rmb_enter: false, default_bg_color: None, default_paper_bg_color: None, awaiting_vports: false, diff --git a/src/app/settings.rs b/src/app/settings.rs index e5f834fe2..d9ea5c2c6 100644 --- a/src/app/settings.rs +++ b/src/app/settings.rs @@ -103,6 +103,10 @@ pub struct UserSettings { pub plugin_repos: Vec, /// Controls whether the TEXTEDIT command repeats automatically (0 = Multiple, 1 = Single). pub texteditmode: bool, + /// When true, a right-click in the viewport acts as Enter (commit / close) + /// while a command is active; when idle it still opens the context menu. + /// Toggled by the `RMBENTER` command. Right-drag always orbits. + pub rmb_enter: bool, /// Persisted viewport background colours (0–255 RGB); `None` = app default /// (dark grey model / off-white paper). Applied to every drawing tab on /// launch and to tabs opened later, so a chosen background survives restarts @@ -133,6 +137,7 @@ impl Default for UserSettings { disabled_plugins: Vec::new(), plugin_repos: Vec::new(), texteditmode: false, + rmb_enter: false, bg_color: None, paper_bg_color: None, } @@ -170,6 +175,7 @@ impl UserSettings { "bg_color" => s.bg_color = parse_rgb(val), "paper_bg_color" => s.paper_bg_color = parse_rgb(val), "default_assoc_prompted" => s.default_assoc_prompted = val == "1", + "rmb_enter" => s.rmb_enter = val == "1", "texteditmode" => { if let Some(v) = crate::modules::annotate::textedit::parse_texteditmode(val) @@ -218,7 +224,7 @@ impl UserSettings { .collect::>() .join(","); let body = format!( - "dyn={}\northo={}\npolar={}\npolar_increment_deg={}\nosnap={}\notrack={}\ndefault_assoc_prompted={}\nsnap_modes={}\ndisabled_plugins={}\nplugin_repos={}\ntexteditmode={}\nbg_color={}\npaper_bg_color={}\n", + "dyn={}\northo={}\npolar={}\npolar_increment_deg={}\nosnap={}\notrack={}\ndefault_assoc_prompted={}\nsnap_modes={}\ndisabled_plugins={}\nplugin_repos={}\ntexteditmode={}\nrmb_enter={}\nbg_color={}\npaper_bg_color={}\n", b(self.dyn_input), b(self.ortho), b(self.polar), @@ -230,6 +236,7 @@ impl UserSettings { self.disabled_plugins.join(","), self.plugin_repos.join(","), self.texteditmode, + b(self.rmb_enter), rgb_to_str(self.bg_color), rgb_to_str(self.paper_bg_color), ); diff --git a/src/app/update/file.rs b/src/app/update/file.rs index a6d42d556..0c3f659fb 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -40,6 +40,7 @@ impl OpenCADStudio { }, plugin_repos: self.plugin_repos.clone(), texteditmode: self.texteditmode, + rmb_enter: self.rmb_enter, bg_color: self.default_bg_color.map(f4_to_u3), paper_bg_color: self.default_paper_bg_color.map(f4_to_u3), } @@ -58,6 +59,7 @@ impl OpenCADStudio { self.disabled_plugins = s.disabled_plugins.iter().cloned().collect(); self.plugin_repos = s.plugin_repos.clone(); self.texteditmode = s.texteditmode; + self.rmb_enter = s.rmb_enter; self.default_bg_color = s.bg_color.map(u3_to_f4); self.default_paper_bg_color = s.paper_bg_color.map(u3_to_f4); // Push the restored background onto every drawing tab that exists now diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index f448a40aa..2268394c7 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -1095,6 +1095,7 @@ impl OpenCADStudio { sel.context_menu = None; sel.right_down = true; sel.right_press_pos = Some(p); + sel.right_press_time = Some(iced::time::Instant::now()); sel.right_last_pos = Some(p); sel.right_dragging = false; Task::none() @@ -1103,19 +1104,31 @@ impl OpenCADStudio { Message::ViewportRightRelease => { let i = self.active_tab; let mut sel = self.tabs[i].scene.selection.borrow_mut(); - let Some(_p) = sel.last_move_pos else { + let Some(click_pos) = sel.last_move_pos else { return Task::none(); }; - if sel.right_down { - if !sel.right_dragging { - sel.context_menu = sel.last_move_pos; - sel.draworder_submenu = false; - } - sel.right_down = false; - sel.right_press_pos = None; - sel.right_last_pos = None; - sel.right_dragging = false; + if !sel.right_down { + return Task::none(); } + let was_click = !sel.right_dragging; + sel.right_down = false; + sel.right_press_pos = None; + sel.right_press_time = None; + sel.right_last_pos = None; + sel.right_dragging = false; + if !was_click { + return Task::none(); + } + // A right-click (no orbit). With "RMB = Enter" enabled, a click + // while a command is active fires Enter (commit/close) instead + // of the menu; when idle — or the option is off — it opens the + // context menu as before, so the menu is never lost. + if self.rmb_enter && self.tabs[i].active_cmd.is_some() { + drop(sel); + return self.update(Message::CommandFinalize); + } + sel.context_menu = Some(click_pos); + sel.draworder_submenu = false; Task::none() } diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index ca890e4a5..e854b8e7e 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -20,6 +20,10 @@ use acadrust::{EntityType as AcadEntityType, Handle}; use iced::time::Instant; use iced::{mouse, Point, Task}; +/// How long the right button must be held (ms) before a right-drag is treated +/// as an orbit. A press + release inside this window stays a click (context +/// menu / Enter), so quick right-clicks aren't lost to slight pointer jitter. +const RIGHT_HOLD_MS: u128 = 150; /// Pixel radius for grabbing a UCS-icon grip (origin dot or an axis tip). const UCS_GRIP_HIT_PX: f32 = 9.0; /// Pixel reach for hovering/clicking the icon body (origin, tips, or an arm). @@ -559,16 +563,31 @@ pub(super) fn on_tick(&mut self, t: Instant) -> Task { } if sel.right_down { - if let Some(press) = sel.right_press_pos { - let dx = p.x - press.x; - let dy = p.y - press.y; - // 8 px (64 squared) threshold so normal hand jitter - // between a right-button press and release doesn't - // promote a click-and-release to an orbit drag, which - // would suppress the context menu on release. - if !sel.right_dragging && (dx * dx + dy * dy) > 64.0 { + // Time-based click-vs-orbit split: a quick right-click + // (press + release within RIGHT_HOLD_MS) is always a click — + // it opens the context menu / fires Enter on release, even if + // the pointer drifted a little. Orbit only engages once the + // button has been *held* past the threshold and the pointer + // is actually moving, so hand jitter during a click never + // promotes it to an orbit drag. + if !sel.right_dragging { + let held = sel + .right_press_time + .map(|t| t.elapsed().as_millis() >= RIGHT_HOLD_MS) + .unwrap_or(false); + let moved = sel + .right_press_pos + .map(|press| { + let (dx, dy) = (p.x - press.x, p.y - press.y); + dx * dx + dy * dy > 4.0 + }) + .unwrap_or(false); + if held && moved { sel.right_dragging = true; sel.context_menu = None; + // Start the orbit from the current position so the + // view doesn't jump by the pre-threshold movement. + sel.right_last_pos = Some(p); } } if sel.right_dragging { @@ -1204,6 +1223,7 @@ pub(super) fn on_tick(&mut self, t: Instant) -> Task { sel.left_dragging = false; sel.right_down = false; sel.right_press_pos = None; + sel.right_press_time = None; sel.right_last_pos = None; sel.right_dragging = false; sel.middle_down = false; diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index 937fa67ef..518a50120 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -1036,7 +1036,7 @@ impl OpenCADStudio { let isolation_active = tab.scene.is_isolation_active(); let last_cmds: Vec = self .command_line - .cmd_recall + .recent_commands .iter() .rev() .take(3) diff --git a/src/scene/pick/selection_state.rs b/src/scene/pick/selection_state.rs index 9261e7c63..9c993b545 100644 --- a/src/scene/pick/selection_state.rs +++ b/src/scene/pick/selection_state.rs @@ -24,6 +24,7 @@ pub struct SelectionState { pub left_dragging: bool, pub right_down: bool, pub right_press_pos: Option, + pub right_press_time: Option, pub right_dragging: bool, pub right_last_pos: Option, pub middle_down: bool, diff --git a/src/ui/command_line.rs b/src/ui/command_line.rs index 5f59646f8..79578f989 100644 --- a/src/ui/command_line.rs +++ b/src/ui/command_line.rs @@ -25,8 +25,13 @@ const MAX_HISTORY: usize = 64; pub struct CommandLine { pub input: String, pub history: Vec, - /// Commands the user has typed (for ↑/↓ recall). + /// Commands the user has typed (for ↑/↓ recall). Holds the raw typed + /// strings so the line can be re-edited; distinct from `recent_commands`. pub cmd_recall: Vec, + /// Commands actually dispatched, from any source (command line, ribbon, + /// 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, /// Current position in `cmd_recall` while navigating (None = not navigating). recall_cursor: Option, /// Saved draft input before the user started navigating history. @@ -100,6 +105,22 @@ impl CommandLine { Some(cmd) } + /// Record a dispatched command for the right-click "Repeat" menu, skipping + /// a consecutive duplicate and capping the list. Called from the dispatch + /// choke point so commands from every source are captured. + pub fn record_recent(&mut self, cmd: &str) { + let cmd = cmd.trim(); + if cmd.is_empty() { + return; + } + if self.recent_commands.last().map(|s| s.as_str()) != Some(cmd) { + self.recent_commands.push(cmd.to_string()); + if self.recent_commands.len() > 50 { + self.recent_commands.remove(0); + } + } + } + /// Navigate to the previous command in recall history (↑). pub fn history_prev(&mut self) { if self.cmd_recall.is_empty() {