From cf925be86152d64ac98f0b5908409155e520e5e9 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Fri, 29 May 2026 15:58:23 +0300 Subject: [PATCH] fix(ctx-menu): anchor right-click menu under cursor, share overlay infra MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/app/mod.rs | 2 - src/app/update.rs | 28 ++++++++---- src/app/view.rs | 107 +++++++++++++++++++++++++--------------------- 3 files changed, 77 insertions(+), 60 deletions(-) diff --git a/src/app/mod.rs b/src/app/mod.rs index 602b5ecd0..d5890aa21 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -627,8 +627,6 @@ pub enum Message { // ── About window ──────────────────────────────────────────────────── AboutOpen, AboutCopyInfo, - /// Close the viewport right-click context menu without performing any action. - ViewportContextMenuClose, /// The user clicked the title-bar ✕ (fires before the window closes). WindowCloseRequested(window::Id), /// A window was fully closed (fires after `window::close()` is called). diff --git a/src/app/update.rs b/src/app/update.rs index ea3a2f42d..00aac5f03 100644 --- a/src/app/update.rs +++ b/src/app/update.rs @@ -1888,7 +1888,11 @@ impl OpenCADStudio { if let Some(press) = sel.right_press_pos { let dx = p.x - press.x; 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.context_menu = None; } @@ -2238,7 +2242,11 @@ impl OpenCADStudio { sel.poly_active = false; sel.poly_points.clear(); 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() } @@ -2252,6 +2260,15 @@ impl OpenCADStudio { self.grip_hover = 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 sel = self.tabs[i].scene.selection.borrow(); let p = match sel.last_move_pos { @@ -2368,7 +2385,6 @@ impl OpenCADStudio { } let mut sel = self.tabs[i].scene.selection.borrow_mut(); - sel.context_menu = None; sel.left_down = true; // Stored in full-canvas space (like ViewportMove's cursor and // the overlay box / lasso drawing); release maps it into the @@ -4150,12 +4166,6 @@ impl OpenCADStudio { 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) => { let i = self.active_tab; // Clear paper-space selection before entering model space. diff --git a/src/app/view.rs b/src/app/view.rs index a8ab45057..49f8bc62e 100644 --- a/src/app/view.rs +++ b/src/app/view.rs @@ -689,8 +689,6 @@ impl OpenCADStudio { // as a single widget instead of stacked tiles. if let Some(popup) = self.grip_popup.as_ref() { 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 // highlight fills the whole row instead of just the // text glyphs. ~7 px per character at size 12 + the @@ -759,16 +757,32 @@ impl OpenCADStudio { }, ..Default::default() }); - let popup_layer = iced::widget::column![ - Space::new().height(iced::Length::Fixed(anchor_y)), - iced::widget::row![ - Space::new().width(iced::Length::Fixed(anchor_x)), - iced::widget::opaque(menu_panel), - ], - ] - .width(Fill) - .height(Fill); - viewport_stack = viewport_stack.push(popup_layer); + // Offset the menu by 12 px so the cursor doesn't land on + // the first item immediately, matching the right-click + // context menu's "panel below the click point" feel. + let anchor = iced::Point::new(popup.anchor.x + 12.0, popup.anchor.y + 12.0); + viewport_stack = viewport_stack.push(position_canvas_overlay(anchor, menu_panel.into())); + } + } + + // Right-click context menu. Lives inside the viewport stack so + // 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 = 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() }; - // ── 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 = 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 { crate::ui::open_progress::view(p, iced::time::Instant::now()) } else { @@ -929,7 +923,6 @@ impl OpenCADStudio { scale_layer, dropdown_layer, layout_ctx_layer, - viewport_ctx_layer, open_progress_layer, ] .into() @@ -1263,6 +1256,34 @@ pub(super) fn doc_tab_bar<'a>(tabs: &'a [DocumentTab], active_tab: usize) -> Ele // ── 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 ────────────────────────────────────── 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) .style(move |_: &Theme| container::Style { @@ -1386,22 +1407,10 @@ fn viewport_context_menu_overlay( }, ..Default::default() }) - .padding([4, 0]); + .padding([4, 0]) + .width(iced::Length::Fixed(180.0)); - // Click-catcher to close the menu when clicking outside. - 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() + position_canvas_overlay(pos, menu.into()) } /// A small right-click context menu rendered above the status bar.