fix(ctx-menu): anchor right-click menu under cursor, share overlay infra
The right-click context menu was being placed via window-relative coordinates while the cursor position it anchored to was canvas-relative, so the menu drifted away from the cursor by the size of the surrounding ribbon / sidebar / status bar. Move the menu inside `viewport_stack` so its anchor resolves in the same coordinate space the cursor was captured in. Introduce `position_canvas_overlay(anchor, panel)` — wraps `panel` in a column+row of fixed-width spacers and `iced::widget::opaque(panel)` so events on the menu itself don't fall through to the viewport mouse area underneath. The multi-functional grip popup is rewritten to use the same helper. Outside-click dismiss now mirrors the grip popup: `ViewportLeftPress` checks `selection.context_menu.take()` at the top and returns early when it was set, so a press that reaches the viewport mouse area (i.e. outside the opaque panel) closes the menu without running the rest of the left-click handler. The redundant `context_menu = None` later in the handler and the now-unused `Message::ViewportContextMenuClose` are removed. Two related issues fixed in the same pass: * The right-drag threshold for orbit was 3 px squared, small enough that normal hand jitter between a right-button press and release was promoting a click to a drag and suppressing the menu on release. Bumped to 8 px squared. * `Message::ViewportExit` was clearing `context_menu`. With the menu now living above the viewport mouse area, the opaque panel capturing the cursor fires the underlying area's exit handler — which closed the menu the instant it opened. Stop clearing the menu there; outside-click dismiss is enough. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
0954d6d123
commit
cf925be861
3 changed files with 77 additions and 60 deletions
|
|
@ -627,8 +627,6 @@ pub enum Message {
|
||||||
// ── About window ────────────────────────────────────────────────────
|
// ── About window ────────────────────────────────────────────────────
|
||||||
AboutOpen,
|
AboutOpen,
|
||||||
AboutCopyInfo,
|
AboutCopyInfo,
|
||||||
/// Close the viewport right-click context menu without performing any action.
|
|
||||||
ViewportContextMenuClose,
|
|
||||||
/// The user clicked the title-bar ✕ (fires before the window closes).
|
/// The user clicked the title-bar ✕ (fires before the window closes).
|
||||||
WindowCloseRequested(window::Id),
|
WindowCloseRequested(window::Id),
|
||||||
/// A window was fully closed (fires after `window::close()` is called).
|
/// A window was fully closed (fires after `window::close()` is called).
|
||||||
|
|
|
||||||
|
|
@ -1888,7 +1888,11 @@ impl OpenCADStudio {
|
||||||
if let Some(press) = sel.right_press_pos {
|
if let Some(press) = sel.right_press_pos {
|
||||||
let dx = p.x - press.x;
|
let dx = p.x - press.x;
|
||||||
let dy = p.y - press.y;
|
let dy = p.y - press.y;
|
||||||
if !sel.right_dragging && (dx * dx + dy * dy) > 9.0 {
|
// 8 px (64 squared) threshold so normal hand jitter
|
||||||
|
// between a right-button press and release doesn't
|
||||||
|
// promote a click-and-release to an orbit drag, which
|
||||||
|
// would suppress the context menu on release.
|
||||||
|
if !sel.right_dragging && (dx * dx + dy * dy) > 64.0 {
|
||||||
sel.right_dragging = true;
|
sel.right_dragging = true;
|
||||||
sel.context_menu = None;
|
sel.context_menu = None;
|
||||||
}
|
}
|
||||||
|
|
@ -2238,7 +2242,11 @@ impl OpenCADStudio {
|
||||||
sel.poly_active = false;
|
sel.poly_active = false;
|
||||||
sel.poly_points.clear();
|
sel.poly_points.clear();
|
||||||
sel.poly_crossing = false;
|
sel.poly_crossing = false;
|
||||||
sel.context_menu = None;
|
// Don't touch `context_menu` here. ViewportExit also fires
|
||||||
|
// when an upper overlay (the right-click menu panel) takes
|
||||||
|
// the cursor, so clearing the menu state on every exit
|
||||||
|
// would close the menu the moment it opens. Outside-click
|
||||||
|
// dismiss is handled in `ViewportLeftPress`.
|
||||||
Task::none()
|
Task::none()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2252,6 +2260,15 @@ impl OpenCADStudio {
|
||||||
self.grip_hover = None;
|
self.grip_hover = None;
|
||||||
return Task::none();
|
return Task::none();
|
||||||
}
|
}
|
||||||
|
// Same dismiss-on-outside-click for the right-click
|
||||||
|
// context menu: its panel is opaque, so a press that
|
||||||
|
// reaches here is outside the menu.
|
||||||
|
{
|
||||||
|
let mut sel = self.tabs[i].scene.selection.borrow_mut();
|
||||||
|
if sel.context_menu.take().is_some() {
|
||||||
|
return Task::none();
|
||||||
|
}
|
||||||
|
}
|
||||||
let (p, vp_size) = {
|
let (p, vp_size) = {
|
||||||
let sel = self.tabs[i].scene.selection.borrow();
|
let sel = self.tabs[i].scene.selection.borrow();
|
||||||
let p = match sel.last_move_pos {
|
let p = match sel.last_move_pos {
|
||||||
|
|
@ -2368,7 +2385,6 @@ impl OpenCADStudio {
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut sel = self.tabs[i].scene.selection.borrow_mut();
|
let mut sel = self.tabs[i].scene.selection.borrow_mut();
|
||||||
sel.context_menu = None;
|
|
||||||
sel.left_down = true;
|
sel.left_down = true;
|
||||||
// Stored in full-canvas space (like ViewportMove's cursor and
|
// Stored in full-canvas space (like ViewportMove's cursor and
|
||||||
// the overlay box / lasso drawing); release maps it into the
|
// the overlay box / lasso drawing); release maps it into the
|
||||||
|
|
@ -4150,12 +4166,6 @@ impl OpenCADStudio {
|
||||||
iced::clipboard::write(info)
|
iced::clipboard::write(info)
|
||||||
}
|
}
|
||||||
|
|
||||||
Message::ViewportContextMenuClose => {
|
|
||||||
let i = self.active_tab;
|
|
||||||
self.tabs[i].scene.selection.borrow_mut().context_menu = None;
|
|
||||||
Task::none()
|
|
||||||
}
|
|
||||||
|
|
||||||
Message::EnterViewport(handle) => {
|
Message::EnterViewport(handle) => {
|
||||||
let i = self.active_tab;
|
let i = self.active_tab;
|
||||||
// Clear paper-space selection before entering model space.
|
// Clear paper-space selection before entering model space.
|
||||||
|
|
|
||||||
107
src/app/view.rs
107
src/app/view.rs
|
|
@ -689,8 +689,6 @@ impl OpenCADStudio {
|
||||||
// as a single widget instead of stacked tiles.
|
// as a single widget instead of stacked tiles.
|
||||||
if let Some(popup) = self.grip_popup.as_ref() {
|
if let Some(popup) = self.grip_popup.as_ref() {
|
||||||
if !tab.is_start {
|
if !tab.is_start {
|
||||||
let anchor_x = (popup.anchor.x + 12.0).max(0.0);
|
|
||||||
let anchor_y = (popup.anchor.y + 12.0).max(0.0);
|
|
||||||
// Size the row to the widest label so the selection
|
// Size the row to the widest label so the selection
|
||||||
// highlight fills the whole row instead of just the
|
// highlight fills the whole row instead of just the
|
||||||
// text glyphs. ~7 px per character at size 12 + the
|
// text glyphs. ~7 px per character at size 12 + the
|
||||||
|
|
@ -759,16 +757,32 @@ impl OpenCADStudio {
|
||||||
},
|
},
|
||||||
..Default::default()
|
..Default::default()
|
||||||
});
|
});
|
||||||
let popup_layer = iced::widget::column![
|
// Offset the menu by 12 px so the cursor doesn't land on
|
||||||
Space::new().height(iced::Length::Fixed(anchor_y)),
|
// the first item immediately, matching the right-click
|
||||||
iced::widget::row![
|
// context menu's "panel below the click point" feel.
|
||||||
Space::new().width(iced::Length::Fixed(anchor_x)),
|
let anchor = iced::Point::new(popup.anchor.x + 12.0, popup.anchor.y + 12.0);
|
||||||
iced::widget::opaque(menu_panel),
|
viewport_stack = viewport_stack.push(position_canvas_overlay(anchor, menu_panel.into()));
|
||||||
],
|
}
|
||||||
]
|
}
|
||||||
.width(Fill)
|
|
||||||
.height(Fill);
|
// Right-click context menu. Lives inside the viewport stack so
|
||||||
viewport_stack = viewport_stack.push(popup_layer);
|
// 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 = tab.scene.selection.borrow().context_menu;
|
||||||
|
if let Some(p) = ctx_pos {
|
||||||
|
let has_cmd = tab.active_cmd.is_some();
|
||||||
|
let has_selection = !tab.scene.selected.is_empty();
|
||||||
|
let last_cmds: Vec<String> = self
|
||||||
|
.command_line
|
||||||
|
.cmd_recall
|
||||||
|
.iter()
|
||||||
|
.rev()
|
||||||
|
.take(3)
|
||||||
|
.cloned()
|
||||||
|
.collect();
|
||||||
|
viewport_stack = viewport_stack
|
||||||
|
.push(viewport_context_menu_overlay(p, has_cmd, has_selection, last_cmds));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -896,26 +910,6 @@ impl OpenCADStudio {
|
||||||
iced::widget::Space::new().width(0).height(0).into()
|
iced::widget::Space::new().width(0).height(0).into()
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Viewport right-click context menu ─────────────────────────────
|
|
||||||
let viewport_ctx_layer: Element<'_, Message> = {
|
|
||||||
let ctx_pos = tab.scene.selection.borrow().context_menu;
|
|
||||||
if let Some(p) = ctx_pos {
|
|
||||||
let has_cmd = tab.active_cmd.is_some();
|
|
||||||
let has_selection = !tab.scene.selected.is_empty();
|
|
||||||
let last_cmds: Vec<String> = self
|
|
||||||
.command_line
|
|
||||||
.cmd_recall
|
|
||||||
.iter()
|
|
||||||
.rev()
|
|
||||||
.take(3)
|
|
||||||
.cloned()
|
|
||||||
.collect();
|
|
||||||
viewport_context_menu_overlay(p, has_cmd, has_selection, last_cmds)
|
|
||||||
} else {
|
|
||||||
iced::widget::Space::new().width(0).height(0).into()
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let open_progress_layer: Element<'_, Message> = if let Some(p) = &self.opening {
|
let open_progress_layer: Element<'_, Message> = if let Some(p) = &self.opening {
|
||||||
crate::ui::open_progress::view(p, iced::time::Instant::now())
|
crate::ui::open_progress::view(p, iced::time::Instant::now())
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -929,7 +923,6 @@ impl OpenCADStudio {
|
||||||
scale_layer,
|
scale_layer,
|
||||||
dropdown_layer,
|
dropdown_layer,
|
||||||
layout_ctx_layer,
|
layout_ctx_layer,
|
||||||
viewport_ctx_layer,
|
|
||||||
open_progress_layer,
|
open_progress_layer,
|
||||||
]
|
]
|
||||||
.into()
|
.into()
|
||||||
|
|
@ -1263,6 +1256,34 @@ pub(super) fn doc_tab_bar<'a>(tabs: &'a [DocumentTab], active_tab: usize) -> Ele
|
||||||
|
|
||||||
// ── Layout context-menu overlay ────────────────────────────────────────────
|
// ── Layout context-menu overlay ────────────────────────────────────────────
|
||||||
|
|
||||||
|
// ── Canvas-relative overlay positioning ────────────────────────────────────
|
||||||
|
|
||||||
|
/// Wraps `panel` in a column+row of `Space` widgets so it sits at
|
||||||
|
/// canvas-relative coordinates `(anchor.x, anchor.y)`. `panel` is wrapped
|
||||||
|
/// in `iced::widget::opaque` so mouse events on the panel itself do not
|
||||||
|
/// fall through to the viewport mouse area underneath; outside-click
|
||||||
|
/// dismissal is the caller's responsibility (handled via `ViewportLeftPress`
|
||||||
|
/// in `update.rs`, identical to how the multi-functional grip popup is
|
||||||
|
/// dismissed). Pushed into `viewport_stack` so the anchor is interpreted
|
||||||
|
/// in canvas-relative space, not window-relative.
|
||||||
|
fn position_canvas_overlay<'a>(
|
||||||
|
anchor: iced::Point,
|
||||||
|
panel: Element<'a, Message>,
|
||||||
|
) -> Element<'a, Message> {
|
||||||
|
let ax = anchor.x.max(0.0);
|
||||||
|
let ay = anchor.y.max(0.0);
|
||||||
|
column![
|
||||||
|
Space::new().height(iced::Length::Fixed(ay)),
|
||||||
|
row![
|
||||||
|
Space::new().width(iced::Length::Fixed(ax)),
|
||||||
|
iced::widget::opaque(panel),
|
||||||
|
],
|
||||||
|
]
|
||||||
|
.width(Fill)
|
||||||
|
.height(Fill)
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
// ── Viewport right-click context menu ──────────────────────────────────────
|
// ── Viewport right-click context menu ──────────────────────────────────────
|
||||||
|
|
||||||
fn viewport_context_menu_overlay(
|
fn viewport_context_menu_overlay(
|
||||||
|
|
@ -1374,7 +1395,7 @@ fn viewport_context_menu_overlay(
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let menu_col = column(items).spacing(0).width(180);
|
let menu_col = column(items).spacing(0).width(iced::Length::Fixed(180.0));
|
||||||
|
|
||||||
let menu = container(menu_col)
|
let menu = container(menu_col)
|
||||||
.style(move |_: &Theme| container::Style {
|
.style(move |_: &Theme| container::Style {
|
||||||
|
|
@ -1386,22 +1407,10 @@ fn viewport_context_menu_overlay(
|
||||||
},
|
},
|
||||||
..Default::default()
|
..Default::default()
|
||||||
})
|
})
|
||||||
.padding([4, 0]);
|
.padding([4, 0])
|
||||||
|
.width(iced::Length::Fixed(180.0));
|
||||||
|
|
||||||
// Click-catcher to close the menu when clicking outside.
|
position_canvas_overlay(pos, menu.into())
|
||||||
let catcher = mouse_area(container(Space::new()).width(Fill).height(Fill))
|
|
||||||
.on_press(Message::ViewportContextMenuClose)
|
|
||||||
.on_right_press(Message::ViewportContextMenuClose);
|
|
||||||
|
|
||||||
// Position using top/left spacing.
|
|
||||||
let positioned = column![
|
|
||||||
Space::new().height(pos.y),
|
|
||||||
row![Space::new().width(pos.x), menu],
|
|
||||||
]
|
|
||||||
.width(Fill)
|
|
||||||
.height(Fill);
|
|
||||||
|
|
||||||
stack![catcher, positioned].into()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A small right-click context menu rendered above the status bar.
|
/// A small right-click context menu rendered above the status bar.
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue