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.