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 <noreply@anthropic.com>
This commit is contained in:
Hakan Seven 2026-06-27 16:16:08 +03:00
commit 098585bb2d
10 changed files with 157 additions and 53 deletions

View file

@ -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 <row> <col> <text> — set text for a cell in the selected Table
cmd if cmd.starts_with("TABLE ") => {

View file

@ -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<Task<Message>> {
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",