Merge pull request #1330 from mechpanos/feat/right-click-menus

feat(ui): right-click context menus with command keywords, PLINE/ARC keyword parity, grip menu
This commit is contained in:
Panos authored and GitHub committed 2026-09-17 16:05:31 +03:00
commit 8e05ed552c
58 files changed
+6437 -538

No files matched your search

+47
View File
@@ -176,6 +176,9 @@ impl OpenCADStudio {
pub(super) fn cancel_active_grip_edit(&mut self) -> bool {
let i = self.active_tab;
// Grip-menu toggles live only as long as the gesture.
self.tabs[i].grip_copy = false;
self.tabs[i].grip_base_pending = false;
let had_grip = self.tabs[i].active_grip.take().is_some()
|| self.grip_add_provisional.is_some()
|| !self.grip_preview_handles.is_empty()
@@ -783,6 +786,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 {
@@ -914,6 +920,9 @@ impl OpenCADStudio {
);
let task = self.apply_cmd_result_inner(result);
let i = self.active_tab;
// A transparent zoom that just finished hands control back to the
// command it interrupted before the "command ended" bookkeeping below.
self.resume_transparent_parent(i);
let preview_hidden = self.tabs[i]
.active_cmd
.as_ref()
@@ -1388,6 +1397,38 @@ impl OpenCADStudio {
Task::none()
}
/// Bring back the command a transparent zoom parked, once the zoom is
/// over (no command active any more). No-op otherwise.
pub(in crate::app) fn resume_transparent_parent(&mut self, i: usize) {
if !self.tabs[i].transparent_resume {
return;
}
if self.tabs[i].active_cmd.is_some() {
// The transparent prompt is still up (or the parent was already
// restored by a Cancel).
if self.tabs[i].suspended_cmd.is_none() {
self.tabs[i].transparent_resume = false;
}
return;
}
self.tabs[i].transparent_resume = false;
let Some(parent) = self.tabs[i].suspended_cmd.take() else {
return;
};
self.tabs[i].active_cmd = Some(parent);
let prompt = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt());
if let Some(p) = prompt {
self.command_line.push_info(&p);
}
let opts = self.tabs[i]
.active_cmd
.as_ref()
.map(|c| c.options())
.unwrap_or_default();
self.command_line.set_step_options(opts);
self.refresh_active_cmd_preview(i);
}
pub(in crate::app) fn start_mtp_modifier(&mut self, i: usize) {
let parent = self.tabs[i].active_cmd.take();
self.tabs[i].suspended_cmd = parent;
@@ -6596,6 +6637,12 @@ impl OpenCADStudio {
}
}
}
{
let tab = &mut self.tabs[i];
if let Some(command) = tab.active_cmd.as_mut() {
command.on_document_undone(&tab.scene.document);
}
}
let prompt = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt());
if let Some(p) = prompt {
self.command_line.push_info(&p);
+4 -4
View File
@@ -311,8 +311,8 @@ impl OpenCADStudio {
}
"ARC" => {
use crate::modules::draw::draw::arc::Arc3PCommand;
let new_cmd = Arc3PCommand::new();
use crate::modules::draw::draw::arc::ArcCommand;
let new_cmd = ArcCommand::new();
self.command_line.push_info(&new_cmd.prompt());
self.tabs[i].active_cmd = Some(Box::new(new_cmd));
}
@@ -323,8 +323,8 @@ impl OpenCADStudio {
self.tabs[i].active_cmd = Some(Box::new(new_cmd));
}
"ARC_CSE" => {
use crate::modules::draw::draw::arc::ArcCommand;
let new_cmd = ArcCommand::new();
use crate::modules::draw::draw::arc::ArcCSECommand;
let new_cmd = ArcCSECommand::new();
self.command_line.push_info(&new_cmd.prompt());
self.tabs[i].active_cmd = Some(Box::new(new_cmd));
}
+42 -5
View File
@@ -63,6 +63,12 @@ impl OpenCADStudio {
// routing, so every path below (Start-tab gate, plugins, all dispatch
// families, the Repeat menu) sees the canonical command. Arguments after
// the first space are left untouched. A non-alias passes through as-is.
// The transparent prefix of commercial solutions: `'PAN` / `'ZOOM` run in the middle
// of another command and hand control back to it afterwards.
let (cmd, quoted) = match cmd.trim().strip_prefix('\'') {
Some(rest) => (rest.trim(), true),
None => (cmd, false),
};
let resolved = self.resolve_alias(cmd);
let cmd = resolved.as_deref().unwrap_or(cmd);
if is_spacemouse_command(cmd) {
@@ -72,11 +78,22 @@ impl OpenCADStudio {
// already running: pressing F8 partway through a LINE means "constrain
// the rest of this line", not "abandon it". Everything below tears the
// running command down, so a transparent one skips straight past it.
// (#677)
if is_transparent(cmd) {
return self
.dispatch_families(cmd, i)
.unwrap_or_else(Task::none);
// (#677) A quoted navigation command does the same; an interactive
// one (ZOOM Window) parks the running command and resumes it when the
// zoom ends, as does a ZOOM the zoom prompt itself hands off to.
let transparent = is_transparent(cmd)
|| (quoted && is_transparent_capable(cmd))
|| (self.tabs[i].transparent_resume && cmd.starts_with("ZOOM"));
if transparent {
if zoom_prompts(cmd) && self.tabs[i].active_cmd.is_some() {
self.tabs[i].suspended_cmd = self.tabs[i].active_cmd.take();
self.tabs[i].transparent_resume = true;
self.tabs[i].scene.clear_preview_wire();
}
let task = self.dispatch_families(cmd, i).unwrap_or_else(Task::none);
// A one-shot zoom is over already — resume straight away.
self.resume_transparent_parent(i);
return task;
}
// A new command abandons any grip edit and its numeric input.
let had_pending_grip_input = self.grip_pending.take().is_some();
@@ -115,6 +132,9 @@ impl OpenCADStudio {
// template-property override too (#239).
self.restore_add_selected_defaults();
}
// A command parked behind a transparent zoom / MTP goes with it.
self.tabs[i].suspended_cmd = None;
self.tabs[i].transparent_resume = false;
// Starting any command leaves interactive navigation modes (their own
// command arms below re-enable the selected one).
self.tabs[i].pan_mode = false;
@@ -275,6 +295,21 @@ impl OpenCADStudio {
/// point, so F8 during a MOVE both ended the move and made the dragged ghost
/// vanish. Nothing here starts a command, opens a document or reads geometry,
/// so there is nothing for the teardown to protect. (#677)
/// Commands that may run transparently (`'PAN`, `'ZOOM …`): the navigation
/// set, which never edits the drawing.
fn is_transparent_capable(cmd: &str) -> bool {
cmd == "PAN" || cmd == "ZOOM" || cmd.starts_with("ZOOM ") || matches!(cmd, "ZW" | "ZE" | "ZA" | "ZP" | "ZI" | "ZO" | "ZD" | "ZEA" | "ZOBJ")
}
/// ZOOM forms that install an interactive prompt (window corners, object
/// pick) and so need the running command parked while they last.
fn zoom_prompts(cmd: &str) -> bool {
matches!(
cmd,
"ZOOM" | "ZOOM WINDOW" | "ZOOM W" | "ZW" | "ZOOM OBJECT" | "ZOOM O" | "ZOBJ"
)
}
pub fn is_transparent(cmd: &str) -> bool {
is_spacemouse_command(cmd)
|| matches!(
@@ -503,6 +538,8 @@ inventory::submit!(crate::command::CommandRegistration {
"MIRRTEXT",
"ZOOMWHEEL",
"ZOOMFACTOR",
"SHORTCUTMENU",
"SHORTCUTMENUDURATION",
"CURSORSIZE",
"PICKBOX",
"CURSORTYPE",
+50 -1
View File
@@ -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))
}
},
// Bit code of commercial solutions: 0 = no shortcut menus (right-click
// is Enter), 16 = time-sensitive; everything else is the
// regular shortcut menu. Reported as the defaults of commercial solutions
// (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::<i32>() {
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::<i32>() {
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::<i32>() {
Ok(size) if (1..=100).contains(&size) => {
@@ -2347,6 +2394,8 @@ impl OpenCADStudio {
name.as_str(),
"ZOOMWHEEL"
| "ZOOMFACTOR"
| "SHORTCUTMENU"
| "SHORTCUTMENUDURATION"
| "CURSORSIZE"
| "PICKBOX"
| "CURSORTYPE"
+6 -1
View File
@@ -267,7 +267,8 @@ impl OpenCADStudio {
"mtext_cancel" => Message::MTextCancel,
"text_input" => Message::TextInlineInput(string(req, "value")?.into()),
"text_commit" => Message::TextInlineOk,
"pointer_move" | "pointer_press" | "pointer_release" => {
"pointer_move" | "pointer_press" | "pointer_release" | "pointer_right_press"
| "pointer_right_release" => {
let x = req["x"]
.as_f64()
.filter(|v| v.is_finite())
@@ -289,6 +290,10 @@ impl OpenCADStudio {
let event = match name {
"pointer_press" => self.update(Message::ViewportLeftPress),
"pointer_release" => self.update(Message::ViewportLeftRelease),
// Right button: the context menu / Enter behaviour chosen in
// Options (see `Message::ViewportRightRelease`).
"pointer_right_press" => self.update(Message::ViewportRightPress),
"pointer_right_release" => self.update(Message::ViewportRightRelease),
_ => Task::none(),
};
return Ok(Task::batch([move_task, event]));
+12
View File
@@ -146,6 +146,12 @@ pub(super) struct DocumentTab {
pub(super) selected_grip_handles: Vec<Handle>,
/// 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<Handle>,
/// Dynamic-block visibility grip for the current single selection.
pub(super) visibility_grip: Option<super::visibility::VisibilityGrip>,
@@ -225,6 +231,9 @@ pub(super) struct DocumentTab {
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
pub(super) plugin_state: HashMap<&'static str, Box<dyn Any + Send + Sync>>,
pub(super) suspended_cmd: Option<Box<dyn CadCommand>>,
/// `suspended_cmd` was parked by a transparent command (`'ZOOM`) and is
/// restored as soon as the transparent one ends.
pub(super) transparent_resume: bool,
}
impl DocumentTab {
@@ -601,6 +610,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,
@@ -634,6 +645,7 @@ impl DocumentTab {
zoom_dynamic_mode: false,
plugin_state: HashMap::new(),
suspended_cmd: None,
transparent_resume: false,
}
}
+32 -3
View File
@@ -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,
@@ -2027,6 +2042,8 @@ pub enum Message {
/// Mid Between 2 Points from the snap menu: modal 2-pick modifier over
/// the active point prompt.
SnapOverrideMtp,
/// Snap Overrides ▸ None: the next pick ignores object snaps.
SnapOverrideNone,
/// Close the one-shot snap override menu without picking.
SnapOverrideClose,
/// Open a path from the Start tab's recent-documents list (skips the
@@ -2158,6 +2175,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).
@@ -3291,9 +3312,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),
@@ -3808,6 +3835,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,
+44
View File
@@ -132,6 +132,43 @@ impl CursorType {
}
}
/// What a right-click in the drawing area does (SHORTCUTMENU in commercial solutions /
/// "Right-click Customization").
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum RightClickMode {
/// The default of commercial solutions: 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,
/// The "time-sensitive right-click" of commercial solutions: 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,
@@ -583,6 +625,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,
+16
View File
@@ -727,6 +727,16 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
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
@@ -932,6 +942,12 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
if grip_dyn_locked {
return self.update(Message::CommandSubmit);
}
// Enter while a grip is hot places it where it is (or keeps
// it hot when it has not moved) — it must not fall through to
// "repeat the last command", which would discard the edit.
if self.tabs[i].active_grip.is_some() && self.tabs[i].active_cmd.is_none() {
return self.commit_active_grip_edit();
}
// Normal command Dynamic Input commit.
if let Some(task) = self.try_dyn_commit() {
+943
View File
@@ -0,0 +1,943 @@
//! 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::command::StepInput;
use crate::scene::pick::grip::GripEditMode;
use crate::ui::popup::context_menu::{
build_context_menu, ContextMenu, GripMenuCmd, GripMenuContext, MenuAction, MenuContext,
SubmenuId,
};
use acadrust::Handle;
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<Message> {
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<Message> {
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<Message> {
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 if self.tabs[i].active_grip.is_some() => {
self.commit_active_grip_edit()
}
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::SnapOverride(t) => self.update(Message::SnapOverridePick(t)),
MenuAction::SnapOverrideNone => self.update(Message::SnapOverrideNone),
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<Message> {
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 commercial solutions).
pub(in crate::app) fn intercept_context_menu_key(&mut self, msg: &Message) -> Option<Task<Message>> {
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<char>) -> Option<Task<Message>> {
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 (the grip menu of commercial solutions): the grip stays hot
/// for Stretch / Base Point / Copy / Undo; Move / Rotate / Scale / Mirror
/// hand the selection to the matching command with the grip as its base
/// point; Exit drops the edit.
pub(in crate::app) fn on_grip_context_pick(&mut self, cmd: GripMenuCmd) -> Task<Message> {
let i = self.active_tab;
let Some(grip) = self.tabs[i].active_grip.clone() else {
return Task::none();
};
match cmd {
GripMenuCmd::Exit => {
self.cancel_active_grip_edit();
self.tabs[i].grip_copy = false;
self.tabs[i].grip_base_pending = false;
Task::none()
}
GripMenuCmd::Stretch => {
if let Some(active) = self.tabs[i].active_grip.as_mut() {
active.mode = GripEditMode::Stretch;
active.axis = None;
}
Task::none()
}
GripMenuCmd::Move | GripMenuCmd::Rotate | GripMenuCmd::Scale | GripMenuCmd::Mirror => {
let base = grip.origin_world;
let handles: Vec<Handle> = grip.targets.iter().map(|t| t.handle).collect();
self.cancel_active_grip_edit();
self.tabs[i].grip_copy = false;
self.tabs[i].grip_base_pending = false;
// The command edits the current selection; make sure the
// gripped objects are in it.
if self.tabs[i].scene.selected.is_empty() {
self.tabs[i].scene.select_entities(&handles);
}
let name = match cmd {
GripMenuCmd::Move => "MOVE",
GripMenuCmd::Rotate => "ROTATE",
GripMenuCmd::Scale => "SCALE",
_ => "MIRROR",
};
let start = self.dispatch_command(name);
if self.tabs[i].active_cmd.is_none() {
return start;
}
// The grip is the base point, so the command opens directly
// at its second prompt.
self.last_point = Some(base);
let fed = self.feed_command(StepInput::Point(base));
Task::batch(vec![start, fed])
}
GripMenuCmd::BasePoint => {
self.tabs[i].grip_base_pending = true;
Task::none()
}
GripMenuCmd::CopyToggle => {
self.tabs[i].grip_copy = !self.tabs[i].grip_copy;
Task::none()
}
GripMenuCmd::Undo => {
// Put the shapes back where the gesture started and keep the
// grip hot there (the Copy toggle survives).
let copy = self.tabs[i].grip_copy;
self.cancel_active_grip_edit();
self.tabs[i].grip_copy = copy;
let mut again = grip;
let delta = again.last_world - again.origin_world;
again.last_world = again.origin_world;
for target in &mut again.targets {
target.last_world -= delta;
}
self.tabs[i].active_grip = Some(again);
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<MenuAction> {
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::ToggleSubmenu(SubmenuId::SnapOverrides)));
assert!(acts.contains(&MenuAction::Command("'PAN".into())));
});
}
#[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);
});
}
// ── Grip menu ─────────────────────────────────────────────────────
/// A drawing with one line, selected, its start grip hot at the origin.
fn grip_app() -> (OpenCADStudio, Handle) {
use acadrust::{entities::Line, types::Vector3, EntityType};
let mut app = app();
app.snapper.snap_enabled = false;
app.snapper.grid_snap_on = false;
app.snapper.otrack_enabled = false;
app.ortho_mode = false;
app.polar_mode = false;
let handle = app.tabs[0].scene.add_entity(EntityType::Line(Line::from_points(
Vector3::new(0.0, 0.0, 0.0),
Vector3::new(10.0, 0.0, 0.0),
)));
app.tabs[0].scene.selected.insert(handle);
app.refresh_selected_grips();
app.tabs[0].active_grip = Some(crate::scene::pick::grip::GripEdit::single(
handle,
0,
false,
DVec3::ZERO,
));
(app, handle)
}
fn move_cursor_to(app: &mut OpenCADStudio, world: DVec3) {
let cursor = app.tabs[0]
.scene
.camera
.borrow()
.project(world, iced::Rectangle::with_size(iced::Size::new(800.0, 600.0)))
.unwrap();
let _ = app.on_viewport_move(Point::new(cursor.x, cursor.y));
}
fn line_start(app: &OpenCADStudio, handle: Handle) -> (f64, f64) {
match app.tabs[0].scene.document.get_entity(handle) {
Some(acadrust::EntityType::Line(l)) => (l.start.x, l.start.y),
other => panic!("{other:?}"),
}
}
#[test]
fn rmb_on_hot_grip_opens_grip_menu() {
with_stack(|| {
let (mut app, _) = grip_app();
right_click(&mut app);
assert!(menu_open(&app));
let acts = menu_actions(&app);
assert_eq!(acts[0], MenuAction::Enter);
assert!(acts.contains(&MenuAction::Grip(GripMenuCmd::Move)));
assert!(acts.contains(&MenuAction::Grip(GripMenuCmd::Exit)));
assert!(app.tabs[0].active_grip.is_some(), "the grip stays hot under the menu");
});
}
#[test]
fn grip_menu_move_dispatches_move_with_grip_base() {
with_stack(|| {
let (mut app, _) = grip_app();
right_click(&mut app);
let _ = app.update(Message::ContextMenuPick(MenuAction::Grip(GripMenuCmd::Move)));
assert!(!menu_open(&app));
assert!(app.tabs[0].active_grip.is_none());
assert_eq!(active(&app), Some("MOVE"));
let prompt = app.tabs[0].active_cmd.as_ref().map(|c| c.prompt()).unwrap_or_default();
let base_prompt = crate::tr!("command-move", "base", count = 1i64);
assert_ne!(prompt, base_prompt, "base already consumed");
assert_eq!(app.last_point, Some(DVec3::ZERO));
});
}
#[test]
fn grip_menu_exit_restores_original_geometry() {
with_stack(|| {
let (mut app, handle) = grip_app();
move_cursor_to(&mut app, DVec3::new(3.0, 4.0, 0.0));
let (x, y) = line_start(&app, handle);
assert!((x - 3.0).abs() < 1e-3 && (y - 4.0).abs() < 1e-3, "drag applied");
right_click(&mut app);
let _ = app.update(Message::ContextMenuPick(MenuAction::Grip(GripMenuCmd::Exit)));
assert!(app.tabs[0].active_grip.is_none());
let (x, y) = line_start(&app, handle);
assert!(x.abs() < 1e-9 && y.abs() < 1e-9, "Exit restores the original");
});
}
#[test]
fn grip_menu_undo_snaps_back_but_stays_hot() {
with_stack(|| {
let (mut app, handle) = grip_app();
move_cursor_to(&mut app, DVec3::new(3.0, 4.0, 0.0));
right_click(&mut app);
let _ = app.update(Message::ContextMenuPick(MenuAction::Grip(GripMenuCmd::Undo)));
let (x, y) = line_start(&app, handle);
assert!(x.abs() < 1e-9 && y.abs() < 1e-9);
let grip = app.tabs[0].active_grip.as_ref().expect("grip still hot");
assert_eq!(grip.last_world, grip.origin_world);
});
}
#[test]
fn grip_menu_copy_leaves_original_and_adds_copy() {
with_stack(|| {
let (mut app, handle) = grip_app();
right_click(&mut app);
let _ = app.update(Message::ContextMenuPick(MenuAction::Grip(GripMenuCmd::CopyToggle)));
assert!(app.tabs[0].grip_copy);
move_cursor_to(&mut app, DVec3::new(3.0, 4.0, 0.0));
let _ = app.update(Message::CommandFinalize);
let lines: Vec<(f64, f64)> = app.tabs[0]
.scene
.document
.entities()
.filter_map(|e| match e {
acadrust::EntityType::Line(l) => Some((l.start.x, l.start.y)),
_ => None,
})
.collect();
assert_eq!(lines.len(), 2, "{lines:?}");
let (x, y) = line_start(&app, handle);
assert!(x.abs() < 1e-9 && y.abs() < 1e-9, "original untouched");
assert!(lines.iter().any(|&(x, y)| (x - 3.0).abs() < 1e-3 && (y - 4.0).abs() < 1e-3));
assert!(app.tabs[0].active_grip.is_some(), "grip re-armed for the next copy");
// One undo step removes the copy again.
app.undo_steps(1);
let count = app.tabs[0]
.scene
.document
.entities()
.filter(|e| matches!(e, acadrust::EntityType::Line(_)))
.count();
assert_eq!(count, 1);
});
}
#[test]
fn grip_menu_base_point_rebases_without_placing() {
with_stack(|| {
let (mut app, handle) = grip_app();
right_click(&mut app);
let _ = app.update(Message::ContextMenuPick(MenuAction::Grip(GripMenuCmd::BasePoint)));
assert!(app.tabs[0].grip_base_pending);
move_cursor_to(&mut app, DVec3::new(1.0, 1.0, 0.0));
let _ = app.update(Message::ViewportLeftPress);
let _ = app.update(Message::ViewportLeftRelease);
let grip = app.tabs[0].active_grip.as_ref().expect("grip still hot");
assert_eq!(grip.origin_world, grip.last_world);
assert!(!app.tabs[0].grip_base_pending);
let (x, y) = line_start(&app, handle);
assert!((x - 1.0).abs() < 1e-3 && (y - 1.0).abs() < 1e-3);
});
}
}
#[cfg(test)]
mod transparent_tests {
use super::*;
use crate::command::StepInput;
use glam::DVec3;
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 line_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.dispatch_command("LINE");
let _ = app.feed_command(StepInput::Point(DVec3::ZERO));
app
}
fn active(app: &OpenCADStudio) -> Option<&'static str> {
app.tabs[0].active_cmd.as_ref().map(|c| c.name())
}
#[test]
fn transparent_pan_keeps_the_command_running() {
with_stack(|| {
let mut app = line_app();
let _ = app.update(Message::ContextMenuPick(MenuAction::Command("'PAN".into())));
assert!(app.tabs[0].pan_mode);
assert_eq!(active(&app), Some("LINE"));
// Esc leaves pan mode; the line is still being drawn.
let _ = app.update(Message::CommandEscape);
assert!(!app.tabs[0].pan_mode);
assert_eq!(active(&app), Some("LINE"));
});
}
#[test]
fn transparent_zoom_window_resumes_the_command() {
with_stack(|| {
let mut app = line_app();
let _ = app.dispatch_command("'ZOOM W");
assert_eq!(active(&app), Some("ZOOM WINDOW"));
assert!(app.tabs[0].transparent_resume);
let _ = app.feed_command(StepInput::Point(DVec3::new(-5.0, -5.0, 0.0)));
let _ = app.feed_command(StepInput::Point(DVec3::new(5.0, 5.0, 0.0)));
assert_eq!(active(&app), Some("LINE"), "LINE resumes after the zoom");
assert!(!app.tabs[0].transparent_resume);
assert!(app.tabs[0].suspended_cmd.is_none());
// The line still has its first point: the next pick draws a segment.
let _ = app.feed_command(StepInput::Point(DVec3::new(10.0, 0.0, 0.0)));
let lines = app.tabs[0]
.scene
.document
.entities()
.filter(|e| matches!(e, acadrust::EntityType::Line(_)))
.count();
assert_eq!(lines, 1);
});
}
#[test]
fn zoom_prompt_handoff_and_escape_both_resume() {
with_stack(|| {
let mut app = line_app();
let _ = app.dispatch_command("'ZOOM");
let _ = app.feed_active_cmd("E"); // → ZOOM EXTENTS, one-shot
assert_eq!(active(&app), Some("LINE"));
let _ = app.dispatch_command("'ZOOM");
assert_eq!(active(&app), Some("ZOOM WINDOW"));
let _ = app.update(Message::CommandEscape);
assert_eq!(active(&app), Some("LINE"));
assert!(!app.tabs[0].transparent_resume);
});
}
#[test]
fn new_command_drops_a_parked_one() {
with_stack(|| {
let mut app = line_app();
let _ = app.dispatch_command("'ZOOM");
let _ = app.dispatch_command("CIRCLE");
assert_eq!(active(&app), Some("CIRCLE"));
assert!(app.tabs[0].suspended_cmd.is_none());
let _ = app.update(Message::CommandEscape);
assert_eq!(active(&app), None, "no stale LINE comes back");
});
}
#[test]
fn snap_override_none_is_one_shot() {
with_stack(|| {
let mut app = line_app();
app.snapper.snap_enabled = true;
let _ = app.update(Message::ContextMenuPick(MenuAction::SnapOverrideNone));
assert!(!app.snapper.snap_enabled);
app.snapper.clear_override();
assert!(app.snapper.snap_enabled);
});
}
}
+4
View File
@@ -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,
@@ -561,6 +563,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;
+68 -16
View File
@@ -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 (the behaviour of commercial solutions).
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();
@@ -745,6 +754,14 @@ impl OpenCADStudio {
Task::none()
}
Message::SnapOverrideNone => {
self.snap_override_popup = None;
self.snapper.set_override_none();
self.command_line
.push_info(crate::t!("Snap override: None (next pick only).").as_ref());
Task::none()
}
Message::SnapOverrideMtp => {
self.snap_override_popup = None;
self.tabs[self.active_tab]
@@ -3536,6 +3553,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 +3581,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, SHORTCUTMENU in commercial solutions):
// • 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 the 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(),
@@ -5336,12 +5379,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;
@@ -7236,6 +7276,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();
+168 -91
View File
@@ -3451,102 +3451,21 @@ impl OpenCADStudio {
let mut sel = self.tabs[i].scene.selection.borrow_mut();
sel.clear_left_selection_gesture();
}
// Grip-menu Base Point: this click re-bases the gesture at the
// cursor instead of placing anything.
if self.tabs[i].grip_base_pending {
self.tabs[i].grip_base_pending = false;
if let Some(active) = self.tabs[i].active_grip.as_mut() {
active.origin_world = active.last_world;
}
return Task::none();
}
let moved = grip.last_world != grip.origin_world;
if is_click && !moved {
// Engaging click — stay hot, wait for the placement click.
return Task::none();
}
if matches!(
grip.mode,
GripEditMode::Lengthen
| GripEditMode::Radius
| GripEditMode::ArcLength
| GripEditMode::RectangleWidth
| GripEditMode::RectangleHeight
| GripEditMode::MoveParallel
) {
self.grip_pending = None;
self.command_line.input.clear();
}
let added_vertex_focus = grip.targets.iter().find_map(|target| {
let original = self
.grip_originals
.iter()
.find(|(handle, _)| *handle == target.handle)
.map(|(_, entity)| entity)?;
let current = self.tabs[i].scene.document.get_entity(target.handle)?;
is_added_polyline_vertex(original, current, target.grip_id)
.then_some(target.grip_id)
});
// Keep originals available until every history shape has a valid
// final display. A rejected rebuild cancels the entire gesture.
let history_handles: Vec<_> = self
.grip_preview_handles
.iter()
.copied()
.filter(|handle| {
self.tabs[i]
.scene
.document
.solid_history_operation(*handle)
.is_some()
})
.collect();
for handle in history_handles {
if !self.tabs[i].scene.finalize_solid_history(handle) {
self.cancel_active_grip_edit();
self.command_line.push_error(crate::t!("The edited shape could not be displayed; the original geometry was restored.").as_ref());
self.refresh_properties();
return Task::none();
}
}
self.tabs[i].active_grip = None;
// Commit the grip drag as one undoable group, then put every
// edited entity back into the resident tessellation.
let handles = std::mem::take(&mut self.grip_preview_handles);
let originals = std::mem::take(&mut self.grip_originals);
let history_originals = std::mem::take(&mut self.grip_history_originals);
let dirty_before = self.grip_dirty_before.take().unwrap_or(self.tabs[i].dirty);
if !handles.is_empty() {
if !originals.is_empty() {
self.push_entity_group_history(
i,
"GRIP",
originals
.into_iter()
.map(|(handle, entity)| (handle, std::sync::Arc::new(entity)))
.collect(),
history_originals
.into_iter()
.flat_map(|(_, objects)| objects)
.collect(),
dirty_before,
);
self.tabs[i].dirty = true;
}
self.grip_reference_wires.clear();
self.grip_text_verts = Vec::new();
self.grip_text_slide = false;
for &handle in &handles {
self.tabs[i].scene.preview_hidden.remove(&handle);
}
self.tabs[i].scene.clear_preview_wire();
let changes: Vec<_> = handles
.into_iter()
.map(|handle| (handle, crate::scene::ChangeKind::Modified))
.collect();
self.tabs[i].scene.bump_entities_after_parametric_solve(&changes);
}
// Placement confirmed — keep the just-added leader.
self.grip_add_provisional = None;
self.tabs[i].snap_result = None;
if let Some(vertex_id) = added_vertex_focus {
self.tabs[i].properties.prop_vertex = vertex_id;
self.tabs[i].properties.prop_vertex_indicator_active = true;
crate::scene::view::dispatch::set_prop_current_vertex(vertex_id);
}
self.refresh_properties();
return Task::none();
return self.commit_active_grip_edit();
}
// Map the release point into the active Model tile so the
@@ -6219,6 +6138,164 @@ properties={:.1}ms picked={}",
}
Task::none()
}
/// Commit the active grip edit at its current (already applied) position:
/// one undoable group per gesture, every edited entity back into the
/// resident tessellation. With the grip-menu Copy toggle on, the
/// originals are put back and the moved shapes are added as new
/// entities, and the grip stays hot for the next copy.
pub(in crate::app) fn commit_active_grip_edit(&mut self) -> Task<Message> {
let i = self.active_tab;
let Some(grip) = self.tabs[i].active_grip.clone() else {
return Task::none();
};
if grip.last_world == grip.origin_world {
// Nothing placed yet — the grip simply stays hot.
return Task::none();
}
if self.tabs[i].grip_copy {
return self.commit_active_grip_edit_as_copy(grip);
}
self.tabs[i].grip_base_pending = false;
if matches!(
grip.mode,
GripEditMode::Lengthen
| GripEditMode::Radius
| GripEditMode::ArcLength
| GripEditMode::RectangleWidth
| GripEditMode::RectangleHeight
| GripEditMode::MoveParallel
) {
self.grip_pending = None;
self.command_line.input.clear();
}
let added_vertex_focus = grip.targets.iter().find_map(|target| {
let original = self
.grip_originals
.iter()
.find(|(handle, _)| *handle == target.handle)
.map(|(_, entity)| entity)?;
let current = self.tabs[i].scene.document.get_entity(target.handle)?;
is_added_polyline_vertex(original, current, target.grip_id)
.then_some(target.grip_id)
});
// Keep originals available until every history shape has a valid
// final display. A rejected rebuild cancels the entire gesture.
let history_handles: Vec<_> = self
.grip_preview_handles
.iter()
.copied()
.filter(|handle| {
self.tabs[i]
.scene
.document
.solid_history_operation(*handle)
.is_some()
})
.collect();
for handle in history_handles {
if !self.tabs[i].scene.finalize_solid_history(handle) {
self.cancel_active_grip_edit();
self.command_line.push_error(crate::t!("The edited shape could not be displayed; the original geometry was restored.").as_ref());
self.refresh_properties();
return Task::none();
}
}
self.tabs[i].active_grip = None;
// Commit the grip drag as one undoable group, then put every
// edited entity back into the resident tessellation.
let handles = std::mem::take(&mut self.grip_preview_handles);
let originals = std::mem::take(&mut self.grip_originals);
let history_originals = std::mem::take(&mut self.grip_history_originals);
let dirty_before = self.grip_dirty_before.take().unwrap_or(self.tabs[i].dirty);
if !handles.is_empty() {
if !originals.is_empty() {
self.push_entity_group_history(
i,
"GRIP",
originals
.into_iter()
.map(|(handle, entity)| (handle, std::sync::Arc::new(entity)))
.collect(),
history_originals
.into_iter()
.flat_map(|(_, objects)| objects)
.collect(),
dirty_before,
);
self.tabs[i].dirty = true;
}
self.grip_reference_wires.clear();
self.grip_text_verts = Vec::new();
self.grip_text_slide = false;
for &handle in &handles {
self.tabs[i].scene.preview_hidden.remove(&handle);
}
self.tabs[i].scene.clear_preview_wire();
let changes: Vec<_> = handles
.into_iter()
.map(|handle| (handle, crate::scene::ChangeKind::Modified))
.collect();
self.tabs[i].scene.bump_entities_after_parametric_solve(&changes);
}
// Placement confirmed — keep the just-added leader.
self.grip_add_provisional = None;
self.tabs[i].snap_result = None;
if let Some(vertex_id) = added_vertex_focus {
self.tabs[i].properties.prop_vertex = vertex_id;
self.tabs[i].properties.prop_vertex_indicator_active = true;
crate::scene::view::dispatch::set_prop_current_vertex(vertex_id);
}
self.refresh_properties();
Task::none()
}
/// Grip-menu Copy: leave the originals where they were and add the
/// edited shapes as new entities, then re-arm the same grip at its
/// origin so the next placement makes another copy (grip Copy, as in commercial solutions).
fn commit_active_grip_edit_as_copy(&mut self, grip: GripEdit) -> Task<Message> {
let i = self.active_tab;
let handles = std::mem::take(&mut self.grip_preview_handles);
let originals = std::mem::take(&mut self.grip_originals);
let _ = std::mem::take(&mut self.grip_history_originals);
self.grip_dirty_before = None;
self.grip_add_provisional = None;
self.grip_reference_wires.clear();
self.grip_text_verts = Vec::new();
self.grip_text_slide = false;
for &handle in &handles {
self.tabs[i].scene.preview_hidden.remove(&handle);
}
self.tabs[i].scene.clear_preview_wire();
if !originals.is_empty() {
self.push_undo_snapshot(i, "GRIP");
let mut restored = Vec::with_capacity(originals.len());
for (handle, original) in &originals {
let Some(moved) = self.tabs[i].scene.document.get_entity(*handle).cloned() else {
continue;
};
if self.tabs[i].scene.update_entity(original.clone()) {
restored.push((*handle, crate::scene::ChangeKind::Modified));
}
self.tabs[i].scene.add_entity_clone(moved);
}
self.tabs[i].scene.bump_entities_after_parametric_solve(&restored);
self.finish_pending_history(i);
self.tabs[i].dirty = true;
}
self.tabs[i].snap_result = None;
// Re-arm: the grip sits at its origin again over the restored shape.
let mut again = grip;
let delta = again.last_world - again.origin_world;
again.last_world = again.origin_world;
for target in &mut again.targets {
target.last_world -= delta;
}
self.tabs[i].active_grip = Some(again);
self.refresh_properties();
Task::none()
}
}
#[cfg(test)]
+6 -26
View File
@@ -1617,7 +1617,7 @@ bg={bg_ms:.1}ms n={view_count}"
crate::ui::command_line::history_max_height(self.win_size.1),
) + 72.0
} else {
34.0
34.0 + self.command_line.overlay_lines_height()
};
// Quick Properties: stay near the selection cursor, flipping around
// it as needed to remain inside the visible drawing area.
@@ -1766,37 +1766,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<String> = 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,
));
}
}
+2
View File
@@ -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(),
+198 -153
View File
@@ -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, MenuIcon, 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<Message, Theme, iced::Renderer> for ClampedPin<'_> {
@@ -983,9 +994,10 @@ impl Widget<Message, Theme, iced::Renderer> for ClampedPin<'_> {
&layout::Limits::new(Size::ZERO, safe),
);
let content = node.size();
let bottom_inset = self.bottom_inset;
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)
@@ -995,9 +1007,8 @@ impl Widget<Message, Theme, iced::Renderer> for ClampedPin<'_> {
max_x
};
let max_y =
(max.height - self.bottom_inset - MARGIN - content.height).max(MARGIN);
let below = self.anchor.y + self.gap;
let max_y = (max.height - bottom_inset - MARGIN - content.height).max(MARGIN);
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,44 @@ impl Widget<Message, Theme, iced::Renderer> 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;
/// Width of the icon gutter every row reserves so labels line up whether or
/// not the row carries a glyph (object snaps, Pan / Zoom).
const MENU_GUTTER_W: f32 = 18.0;
const MENU_ICON_SIZE: f32 = 14.0;
/// The gutter cell: the row's glyph, or empty space of the same width.
fn context_menu_gutter(icon: Option<MenuIcon>) -> Element<'static, Message> {
let bytes = icon.map(|icon| match icon {
MenuIcon::Snap(t) => crate::ui::icons::osnap(t),
MenuIcon::Mtp => crate::ui::icons::mtp_icon(),
MenuIcon::Pan => crate::ui::icons::pan_icon(),
MenuIcon::Zoom => crate::ui::icons::zoom_icon(),
});
let cell: Element<'static, Message> = match bytes {
Some(bytes) => crate::ui::icons::themed::<Message>(bytes, MENU_ICON_SIZE),
None => iced::widget::Space::new().width(MENU_ICON_SIZE).height(MENU_ICON_SIZE).into(),
};
container(cell)
.width(Length::Fixed(MENU_GUTTER_W))
.align_x(iced::Center)
.align_y(iced::Center)
.into()
}
/// 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 (commercial solutions anchor their 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<crate::scene::parametric_constraints::ConstraintId>,
isolation_active: bool,
last_cmds: Vec<String>,
draworder_open: bool,
has_point_step: bool,
menu: &ContextMenu,
highlighted: Option<usize>,
) -> 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 +1172,67 @@ 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<Element<'static, Message>> = 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![
context_menu_gutter(None),
text(label.clone()).size(12),
iced::widget::Space::new().width(Fill),
caret,
]
.spacing(4)
.align_y(iced::Center);
let enabled = !children.is_empty();
let mut btn = button(content)
.padding(iced::Padding {
top: 3.0,
right: 12.0,
bottom: 3.0,
left: 6.0,
})
.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 +1241,103 @@ 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![context_menu_gutter(item.icon), label_text]
.spacing(4)
.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: 3.0,
right: 12.0,
bottom: 3.0,
left: 6.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