feat(snap): Shift+RMB one-shot snap override menu (#337)
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 <noreply@anthropic.com>
This commit is contained in:
parent
d2be0c9bea
commit
dd40e1f632
7 changed files with 166 additions and 0 deletions
|
|
@ -556,6 +556,9 @@ pub(super) struct OpenCADStudio {
|
||||||
text_inline: Option<text_inline::TextInlineState>,
|
text_inline: Option<text_inline::TextInlineState>,
|
||||||
/// Which layout tab has its context menu open (None = closed).
|
/// Which layout tab has its context menu open (None = closed).
|
||||||
layout_context_menu: Option<String>,
|
layout_context_menu: Option<String>,
|
||||||
|
/// Cursor-anchored one-shot snap override menu (Shift+RMB): the canvas
|
||||||
|
/// point it opened at, or `None` when closed (#337).
|
||||||
|
snap_override_popup: Option<iced::Point>,
|
||||||
/// Inline rename state: (original_name, current_edit_value).
|
/// Inline rename state: (original_name, current_edit_value).
|
||||||
layout_rename_state: Option<(String, String)>,
|
layout_rename_state: Option<(String, String)>,
|
||||||
/// Timestamp of the previous viewport left-click release (for double-click detection).
|
/// Timestamp of the previous viewport left-click release (for double-click detection).
|
||||||
|
|
@ -1262,6 +1265,11 @@ pub enum Message {
|
||||||
OpenPathPicked(Option<(PathBuf, u64)>),
|
OpenPathPicked(Option<(PathBuf, u64)>),
|
||||||
/// A file was dragged from the desktop and dropped on the window (#344).
|
/// A file was dragged from the desktop and dropped on the window (#344).
|
||||||
FileDropped(PathBuf),
|
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
|
/// Open a path from the Start tab's recent-documents list (skips the
|
||||||
/// file picker; the path is already known).
|
/// file picker; the path is already known).
|
||||||
OpenRecent(PathBuf),
|
OpenRecent(PathBuf),
|
||||||
|
|
@ -2338,6 +2346,7 @@ impl OpenCADStudio {
|
||||||
mtext_editor: None,
|
mtext_editor: None,
|
||||||
text_inline: None,
|
text_inline: None,
|
||||||
layout_context_menu: None,
|
layout_context_menu: None,
|
||||||
|
snap_override_popup: None,
|
||||||
layout_rename_state: None,
|
layout_rename_state: None,
|
||||||
last_vp_click_time: None,
|
last_vp_click_time: None,
|
||||||
last_vp_click_pos: None,
|
last_vp_click_pos: None,
|
||||||
|
|
|
||||||
|
|
@ -431,6 +431,10 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn on_command_escape(&mut self) -> Task<Message> {
|
pub(super) fn on_command_escape(&mut self) -> Task<Message> {
|
||||||
|
// 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).
|
// Open MText editor swallows Escape (cancel without committing).
|
||||||
if self.mtext_editor.is_some() {
|
if self.mtext_editor.is_some() {
|
||||||
self.mtext_cancel();
|
self.mtext_cancel();
|
||||||
|
|
|
||||||
|
|
@ -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) => {
|
Message::FileDropped(path) => {
|
||||||
// Desktop drag & drop (#344): accept the formats the Open
|
// Desktop drag & drop (#344): accept the formats the Open
|
||||||
// dialog accepts — a drop has no picker filter, so anything
|
// dialog accepts — a drop has no picker filter, so anything
|
||||||
|
|
@ -1367,6 +1385,15 @@ impl OpenCADStudio {
|
||||||
Message::ViewportRightPress => {
|
Message::ViewportRightPress => {
|
||||||
let i = self.active_tab;
|
let i = self.active_tab;
|
||||||
self.ribbon.close_dropdown();
|
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 mut sel = self.tabs[i].scene.selection.borrow_mut();
|
||||||
let Some(p) = sel.last_move_pos else {
|
let Some(p) = sel.last_move_pos else {
|
||||||
return Task::none();
|
return Task::none();
|
||||||
|
|
|
||||||
|
|
@ -2175,6 +2175,9 @@ pub(super) fn on_tick(&mut self, t: Instant) -> Task<Message> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
self.last_point = Some(world_pt);
|
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.dyn_user_reshaped = false;
|
||||||
self.sync_dyn_fields();
|
self.sync_dyn_fields();
|
||||||
self.reset_tracking_after_point();
|
self.reset_tracking_after_point();
|
||||||
|
|
|
||||||
|
|
@ -1511,6 +1511,13 @@ impl OpenCADStudio {
|
||||||
iced::widget::Space::new().width(0).height(0).into()
|
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 qselect_layer: Element<'_, Message> = if let Some(state) = &self.qselect {
|
||||||
let types = tab.scene.entity_type_names_in_layout();
|
let types = tab.scene.entity_type_names_in_layout();
|
||||||
let properties = tab.scene.qselect_properties(state.type_filter.as_deref());
|
let properties = tab.scene.qselect_properties(state.type_filter.as_deref());
|
||||||
|
|
@ -1538,6 +1545,7 @@ impl OpenCADStudio {
|
||||||
dropdown_layer,
|
dropdown_layer,
|
||||||
layout_ctx_layer,
|
layout_ctx_layer,
|
||||||
qselect_layer,
|
qselect_layer,
|
||||||
|
snap_override_layer,
|
||||||
open_progress_layer,
|
open_progress_layer,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1021,6 +1021,89 @@ pub(super) fn layout_context_menu_overlay(name: &str, win: (f32, f32)) -> Elemen
|
||||||
stack![catcher, positioned].into()
|
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::<Message>(
|
||||||
|
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 ─────────────────────────────────────────────────────
|
// ── Quick Select panel ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
const QSELECT_ANY_TYPE: &str = "(Any type)";
|
const QSELECT_ANY_TYPE: &str = "(Any type)";
|
||||||
|
|
|
||||||
32
src/snap.rs
32
src/snap.rs
|
|
@ -137,6 +137,12 @@ pub struct Snapper {
|
||||||
/// direction + point under the cursor, when it was first hovered, and
|
/// direction + point under the cursor, when it was first hovered, and
|
||||||
/// whether this dwell has already fired (so it acquires/toggles once).
|
/// whether this dwell has already fired (so it acquires/toggles once).
|
||||||
parallel_dwell: Option<(Vec3, Vec3, Instant, bool)>,
|
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<SnapType>, bool)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for Snapper {
|
impl Default for Snapper {
|
||||||
|
|
@ -164,6 +170,7 @@ impl Default for Snapper {
|
||||||
from_point: None,
|
from_point: None,
|
||||||
parallel_ref: None,
|
parallel_ref: None,
|
||||||
parallel_dwell: None,
|
parallel_dwell: None,
|
||||||
|
override_saved: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -178,6 +185,30 @@ impl Snapper {
|
||||||
self.enabled.contains(&t)
|
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
|
/// Whether temporary tracking points are being acquired and drawn: OTRACK
|
||||||
/// on, or the Extension object snap on. Extension tracks a segment's line
|
/// on, or the Extension object snap on. Extension tracks a segment's line
|
||||||
/// only from an acquired endpoint, and works independently of OTRACK's
|
/// only from an acquired endpoint, and works independently of OTRACK's
|
||||||
|
|
@ -748,6 +779,7 @@ impl Snapper {
|
||||||
from_point: None,
|
from_point: None,
|
||||||
parallel_ref: None,
|
parallel_ref: None,
|
||||||
parallel_dwell: None,
|
parallel_dwell: None,
|
||||||
|
override_saved: None,
|
||||||
};
|
};
|
||||||
// Tangent-only: Grid is disabled here, so the grid basis is irrelevant.
|
// Tangent-only: Grid is disabled here, so the grid basis is irrelevant.
|
||||||
tmp.snap(
|
tmp.snap(
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue