forked from 1b-agent/opencadstudio-upstream
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 <[email protected]>
This commit is contained in:
1 parent
63de137106
commit
098585bb2d
10 files changed
+160
-56
No files matched your search
@@ -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 ") => {
|
||||
|
||||
+50
-35
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
+8
-1
@@ -103,6 +103,10 @@ pub struct UserSettings {
|
||||
pub plugin_repos: Vec<String>,
|
||||
/// 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::<Vec<_>>()
|
||||
.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),
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
+23
-10
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Message> {
|
||||
}
|
||||
|
||||
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<Message> {
|
||||
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;
|
||||
|
||||
+1
-1
@@ -1036,7 +1036,7 @@ impl OpenCADStudio {
|
||||
let isolation_active = tab.scene.is_isolation_active();
|
||||
let last_cmds: Vec<String> = self
|
||||
.command_line
|
||||
.cmd_recall
|
||||
.recent_commands
|
||||
.iter()
|
||||
.rev()
|
||||
.take(3)
|
||||
|
||||
@@ -24,6 +24,7 @@ pub struct SelectionState {
|
||||
pub left_dragging: bool,
|
||||
pub right_down: bool,
|
||||
pub right_press_pos: Option<Point>,
|
||||
pub right_press_time: Option<Instant>,
|
||||
pub right_dragging: bool,
|
||||
pub right_last_pos: Option<Point>,
|
||||
pub middle_down: bool,
|
||||
|
||||
+22
-1
@@ -25,8 +25,13 @@ const MAX_HISTORY: usize = 64;
|
||||
pub struct CommandLine {
|
||||
pub input: String,
|
||||
pub history: Vec<HistoryEntry>,
|
||||
/// 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<String>,
|
||||
/// 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<String>,
|
||||
/// Current position in `cmd_recall` while navigating (None = not navigating).
|
||||
recall_cursor: Option<usize>,
|
||||
/// 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() {
|
||||
|
||||
Reference in new issue
Block a user