From dd40e1f632a76c91e417bda8fb6a0300ddeb854d Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Wed, 22 Jul 2026 12:27:27 +0300 Subject: [PATCH] feat(snap): Shift+RMB one-shot snap override menu (#337) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shift+right-click in the viewport opens a cursor-anchored grid of snap icons (names as hover tooltips). Picking one engages a one-shot override: only that snap applies — even with running osnap off — until the next point pick commits, which restores the saved configuration. Esc drops an unconsumed override; an outside click closes the menu. The status-bar snap toggles are untouched — this is a separate, temporary mechanism. Covers items 1 and 2 of #337 (mid-between-two-points still pending). Co-Authored-By: Claude Fable 5 --- src/app/mod.rs | 9 +++++ src/app/update/command.rs | 4 ++ src/app/update/mod.rs | 27 +++++++++++++ src/app/update/viewport.rs | 3 ++ src/app/view/mod.rs | 8 ++++ src/app/view/overlay.rs | 83 ++++++++++++++++++++++++++++++++++++++ src/snap.rs | 32 +++++++++++++++ 7 files changed, 166 insertions(+) diff --git a/src/app/mod.rs b/src/app/mod.rs index 16c09ee5..e3e7cba3 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -556,6 +556,9 @@ pub(super) struct OpenCADStudio { text_inline: Option, /// Which layout tab has its context menu open (None = closed). layout_context_menu: Option, + /// Cursor-anchored one-shot snap override menu (Shift+RMB): the canvas + /// point it opened at, or `None` when closed (#337). + snap_override_popup: Option, /// Inline rename state: (original_name, current_edit_value). layout_rename_state: Option<(String, String)>, /// Timestamp of the previous viewport left-click release (for double-click detection). @@ -1262,6 +1265,11 @@ pub enum Message { OpenPathPicked(Option<(PathBuf, u64)>), /// A file was dragged from the desktop and dropped on the window (#344). FileDropped(PathBuf), + /// Pick from the one-shot snap override menu (Shift+RMB): only this snap + /// applies to the next point pick, then the configuration restores (#337). + SnapOverridePick(crate::snap::SnapType), + /// Close the one-shot snap override menu without picking. + SnapOverrideClose, /// Open a path from the Start tab's recent-documents list (skips the /// file picker; the path is already known). OpenRecent(PathBuf), @@ -2338,6 +2346,7 @@ impl OpenCADStudio { mtext_editor: None, text_inline: None, layout_context_menu: None, + snap_override_popup: None, layout_rename_state: None, last_vp_click_time: None, last_vp_click_pos: None, diff --git a/src/app/update/command.rs b/src/app/update/command.rs index 1a6222da..80ae451d 100644 --- a/src/app/update/command.rs +++ b/src/app/update/command.rs @@ -431,6 +431,10 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { } pub(super) fn on_command_escape(&mut self) -> Task { + // Esc drops an unconsumed one-shot snap override and closes + // its menu (#337). Falls through — Esc keeps its usual effect. + self.snap_override_popup = None; + self.snapper.clear_override(); // Open MText editor swallows Escape (cancel without committing). if self.mtext_editor.is_some() { self.mtext_cancel(); diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 8614024f..4fd089b6 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -292,6 +292,24 @@ impl OpenCADStudio { } } + Message::SnapOverridePick(t) => { + self.snap_override_popup = None; + self.snapper.set_override(t); + let label = crate::snap::ALL_SNAP_MODES + .iter() + .find(|(m, _, _)| *m == t) + .map(|(_, _, l)| *l) + .unwrap_or("Snap"); + self.command_line + .push_info(&format!("Snap override: {label} (next pick only).")); + Task::none() + } + + Message::SnapOverrideClose => { + self.snap_override_popup = None; + Task::none() + } + Message::FileDropped(path) => { // Desktop drag & drop (#344): accept the formats the Open // dialog accepts — a drop has no picker filter, so anything @@ -1367,6 +1385,15 @@ impl OpenCADStudio { Message::ViewportRightPress => { let i = self.active_tab; self.ribbon.close_dropdown(); + // Shift+RMB: the one-shot snap override menu at the cursor — + // pick a snap for just the next point, then it expires (#337). + if self.shift_down { + let pos = self.tabs[i].scene.selection.borrow().last_move_pos; + if let Some(p) = pos { + self.snap_override_popup = Some(p); + } + return Task::none(); + } let mut sel = self.tabs[i].scene.selection.borrow_mut(); let Some(p) = sel.last_move_pos else { return Task::none(); diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index 1846a088..684d8e7c 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -2175,6 +2175,9 @@ pub(super) fn on_tick(&mut self, t: Instant) -> Task { } } self.last_point = Some(world_pt); + // The one-shot snap override is spent by this pick — + // restore the running osnap configuration (#337). + self.snapper.clear_override(); self.dyn_user_reshaped = false; self.sync_dyn_fields(); self.reset_tracking_after_point(); diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index da2effcb..608f57b4 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -1511,6 +1511,13 @@ impl OpenCADStudio { iced::widget::Space::new().width(0).height(0).into() }; + let snap_override_layer: Element<'_, Message> = + if let Some(pos) = self.snap_override_popup { + overlay::snap_override_overlay(pos) + } else { + iced::widget::Space::new().width(0).height(0).into() + }; + let qselect_layer: Element<'_, Message> = if let Some(state) = &self.qselect { let types = tab.scene.entity_type_names_in_layout(); let properties = tab.scene.qselect_properties(state.type_filter.as_deref()); @@ -1538,6 +1545,7 @@ impl OpenCADStudio { dropdown_layer, layout_ctx_layer, qselect_layer, + snap_override_layer, open_progress_layer, ]; diff --git a/src/app/view/overlay.rs b/src/app/view/overlay.rs index c5452d13..aa2631bf 100644 --- a/src/app/view/overlay.rs +++ b/src/app/view/overlay.rs @@ -1021,6 +1021,89 @@ pub(super) fn layout_context_menu_overlay(name: &str, win: (f32, f32)) -> Elemen stack![catcher, positioned].into() } +/// One-shot snap override menu (Shift+RMB, #337): a cursor-anchored grid of +/// snap ICONS only — the names show as hover tooltips. Picking one applies +/// that snap to just the next point pick. +pub(super) fn snap_override_overlay(pos: iced::Point) -> Element<'static, Message> { + const PANEL_BG: Color = Color { r: 0.16, g: 0.16, b: 0.16, a: 0.98 }; + const PANEL_BORDER: Color = Color { r: 0.35, g: 0.35, b: 0.35, a: 1.0 }; + const ICON_COLOR: Color = Color { r: 0.85, g: 0.85, b: 0.85, a: 1.0 }; + const HOVER: Color = Color { r: 0.25, g: 0.45, b: 0.70, a: 1.0 }; + const TIP_BG: Color = Color { r: 0.10, g: 0.10, b: 0.10, a: 0.98 }; + const COLS: usize = 4; + + let cell = |snap_type: crate::snap::SnapType, label: &'static str| -> Element<'static, Message> { + let icon = container(crate::ui::icons::tinted::( + crate::ui::icons::osnap(snap_type), + 16.0, + ICON_COLOR, + )) + .width(26) + .height(26) + .align_x(iced::Center) + .align_y(iced::Center); + let btn = button(icon) + .on_press(Message::SnapOverridePick(snap_type)) + .style(|_: &Theme, status| button::Style { + background: Some(Background::Color(match status { + button::Status::Hovered | button::Status::Pressed => HOVER, + _ => Color::TRANSPARENT, + })), + border: Border::default(), + ..Default::default() + }) + .padding(2); + iced::widget::tooltip( + btn, + container(text(label).size(11).color(Color::WHITE)) + .style(|_: &Theme| container::Style { + background: Some(Background::Color(TIP_BG)), + border: Border { + color: PANEL_BORDER, + width: 1.0, + radius: 2.0.into(), + }, + ..Default::default() + }) + .padding([2, 6]), + iced::widget::tooltip::Position::Bottom, + ) + .into() + }; + + let mut grid = column![].spacing(2); + for chunk in crate::snap::ALL_SNAP_MODES.chunks(COLS) { + let mut r = row![].spacing(2); + for &(snap_type, _glyph, label) in chunk { + r = r.push(cell(snap_type, label)); + } + grid = grid.push(r); + } + + let panel = container(grid) + .style(|_: &Theme| container::Style { + background: Some(Background::Color(PANEL_BG)), + border: Border { + color: PANEL_BORDER, + width: 1.0, + radius: 4.0.into(), + }, + ..Default::default() + }) + .padding(4); + + // Full-screen click-catcher closes on an outside click. + let catcher = mouse_area( + container(iced::widget::Space::new().width(Fill).height(Fill)) + .width(Fill) + .height(Fill), + ) + .on_press(Message::SnapOverrideClose) + .on_right_press(Message::SnapOverrideClose); + + stack![catcher, position_canvas_overlay(pos, panel.into())].into() +} + // ── Quick Select panel ───────────────────────────────────────────────────── const QSELECT_ANY_TYPE: &str = "(Any type)"; diff --git a/src/snap.rs b/src/snap.rs index bd4d4be9..40d87483 100644 --- a/src/snap.rs +++ b/src/snap.rs @@ -137,6 +137,12 @@ pub struct Snapper { /// direction + point under the cursor, when it was first hovered, and /// whether this dwell has already fired (so it acquires/toggles once). parallel_dwell: Option<(Vec3, Vec3, Instant, bool)>, + /// One-shot snap override (Shift+RMB menu): the (enabled set, snap on) + /// pair saved when the override engaged, restored when it is consumed by + /// the next point pick or cancelled. While `Some`, `enabled` holds only + /// the override mode and `snap_enabled` is forced on — the override works + /// even with running osnap off (#337). + override_saved: Option<(HashSet, bool)>, } impl Default for Snapper { @@ -164,6 +170,7 @@ impl Default for Snapper { from_point: None, parallel_ref: None, parallel_dwell: None, + override_saved: None, } } } @@ -178,6 +185,30 @@ impl Snapper { self.enabled.contains(&t) } + /// Engage a one-shot snap override (Shift+RMB menu): only `t` snaps, even + /// with running osnap off, until `clear_override` restores the saved + /// configuration. Re-picking while active replaces the mode but keeps the + /// original saved state. + pub fn set_override(&mut self, t: SnapType) { + if self.override_saved.is_none() { + self.override_saved = Some((self.enabled.clone(), self.snap_enabled)); + } + self.enabled = std::iter::once(t).collect(); + self.snap_enabled = true; + } + + /// Restore the pre-override snap configuration. No-op when inactive. + pub fn clear_override(&mut self) { + if let Some((enabled, on)) = self.override_saved.take() { + self.enabled = enabled; + self.snap_enabled = on; + } + } + + pub fn override_active(&self) -> bool { + self.override_saved.is_some() + } + /// Whether temporary tracking points are being acquired and drawn: OTRACK /// on, or the Extension object snap on. Extension tracks a segment's line /// only from an acquired endpoint, and works independently of OTRACK's @@ -748,6 +779,7 @@ impl Snapper { from_point: None, parallel_ref: None, parallel_dwell: None, + override_saved: None, }; // Tangent-only: Grid is disabled here, so the grid basis is irrelevant. tmp.snap(