diff --git a/src/tui_main/mod.rs b/src/tui_main/mod.rs index 8e64562d..f35dea64 100644 --- a/src/tui_main/mod.rs +++ b/src/tui_main/mod.rs @@ -1365,12 +1365,33 @@ fn event_loop( } } + // #459: Hit-test the modal stack to check whether this event lands + // inside a floating modal (e.g. a context menu). The reconcile + // happens in mouse.rs at the top of handle_mouse; by the time a + // quadraui::UiEvent reaches here the stack reflects the state from + // the most recent crossterm mouse event. When the event position + // falls inside a modal the panel intercepts below must yield — + // the same priority rule that native z-ordering gives GTK for free. + let ctx_blocks_event = { + let event_pos: Option = match &ui_event { + quadraui::UiEvent::Scroll { position, .. } + | quadraui::UiEvent::MouseDown { position, .. } + | quadraui::UiEvent::MouseUp { position, .. } + | quadraui::UiEvent::MouseMoved { position, .. } + | quadraui::UiEvent::DoubleClick { position, .. } => Some(*position), + _ => None, + }; + let (_, modal_stack) = backend.drag_and_modal_mut(); + event_pos.is_some_and(|p| modal_stack.hit_test(p).is_some()) + }; + // ── SidebarSystem intercept for mouse/scroll in debug sidebar ── - // #456: skip when a context menu is open — the menu floats above + // #459: skip when the modal stack reports this event lands inside a + // floating modal (e.g. an open context menu) — the modal floats above // any panel and must intercept clicks before the panel below sees // them. The legacy mouse handler in `mouse.rs` has the matching // ctx-menu intercept at line ~1542. - if engine.context_menu.is_none() + if !ctx_blocks_event && engine.app_shell.sidebar_visible() && engine.active_panel_is(PANEL_DEBUG) { @@ -1402,8 +1423,8 @@ fn event_loop( } // ── SidebarSystem intercept for mouse/scroll in extensions sidebar ── - // #456: same priority rule as the debug sidebar above. - if engine.context_menu.is_none() + // #459: same priority rule as the debug sidebar above. + if !ctx_blocks_event && engine.app_shell.sidebar_visible() && engine.active_panel_is(PANEL_EXTENSIONS) { @@ -1429,11 +1450,9 @@ fn event_loop( } // ── Debug toolbar hover/press via ToolbarLayout hit-test (#510) ── - // Skip when a context menu is open. - if engine.context_menu.is_none() - && engine.debug_toolbar_visible - && debug_toolbar_rect.width > 0.0 - { + // #459: skip when the modal stack reports this event lands inside a + // floating modal (e.g. an open context menu). + if !ctx_blocks_event && engine.debug_toolbar_visible && debug_toolbar_rect.width > 0.0 { match &ui_event { quadraui::UiEvent::MouseDown { position, .. } => { let p = *position; @@ -1481,16 +1500,17 @@ fn event_loop( // MouseDown/DoubleClick for row selection; MouseMoved (left held) // and MouseUp for scrollbar drag lifecycle. // - // #456: skip the tree intercept entirely when an explorer context - // menu is open. The menu floats above the tree; clicks on a menu - // item must reach the legacy ctx-menu intercept in `mouse.rs`, - // not get consumed as a tree row activation underneath. + // #459: skip the tree intercept entirely when the modal stack + // reports this event lands inside a floating modal (e.g. an open + // explorer context menu). The menu floats above the tree; clicks on + // a menu item must reach the legacy ctx-menu intercept in + // `mouse.rs`, not get consumed as a tree row activation underneath. { let is_explorer_event = match &ui_event { quadraui::UiEvent::MouseDown { position, .. } | quadraui::UiEvent::DoubleClick { position, .. } => { let rect = engine.explorer_tree_rect.get(); - engine.context_menu.is_none() + !ctx_blocks_event && engine.app_shell.sidebar_visible() && engine.active_panel_is(PANEL_EXPLORER) && rect.width > 0.0 diff --git a/src/tui_main/mouse.rs b/src/tui_main/mouse.rs index 64bddd26..55734334 100644 --- a/src/tui_main/mouse.rs +++ b/src/tui_main/mouse.rs @@ -251,6 +251,20 @@ pub(super) fn handle_mouse( } } + // Reconcile context menu with modal stack (#459). + // Push the menu's outer bounds whenever a context menu is open so + // panel intercepts in mod.rs can call modal_stack.hit_test() instead + // of the per-backend engine.context_menu.is_none() gate. + { + let ctx_menu_id = quadraui::WidgetId::new("context_menu"); + match context_menu_layout { + Some(layout) => modal_stack.push(ctx_menu_id, layout.bounds), + None => { + modal_stack.pop(&ctx_menu_id); + } + } + } + // Reconcile stale picker modal: if the picker closed (keyboard // Escape / confirm) without a backdrop-dismiss click, the "picker" // entry lingers on the stack and swallows all dispatch_scroll events. diff --git a/tests/context_menu.rs b/tests/context_menu.rs index cac56a7d..3fd2756d 100644 --- a/tests/context_menu.rs +++ b/tests/context_menu.rs @@ -3,6 +3,7 @@ mod common; use common::*; use vimcode_core::core::engine::ContextMenuTarget; use vimcode_core::core::window::GroupId; +use vimcode_core::quadraui; use vimcode_core::Engine; // ── Helper: open N files as tabs ─────────────────────────────────────────────── @@ -1382,3 +1383,37 @@ fn test_editor_context_menu_first_enabled_selected() { assert_eq!(cm.selected, 6, "first enabled item should be paste"); assert_eq!(cm.items[cm.selected].action, "paste"); } + +// ── ModalStack integration (#459) ───────────────────────────────────────────── + +/// Regression test: when a context menu is open and its bounds are pushed onto +/// the ModalStack, hit_test() at the menu centre returns Some(menu_id) and +/// hit_test() outside the menu returns None. This is the invariant that the +/// ctx_blocks_event gate in mod.rs relies on. +#[test] +fn context_menu_modal_stack_hit_test() { + let mut modal_stack = quadraui::ModalStack::new(); + let menu_id = quadraui::WidgetId::new("context_menu"); // matches step 2's naming + let bounds = quadraui::Rect { + x: 5.0, + y: 3.0, + width: 20.0, + height: 10.0, + }; + + assert!(modal_stack.is_empty()); + + modal_stack.push(menu_id.clone(), bounds); + + let centre = quadraui::Point { x: 15.0, y: 8.0 }; + assert_eq!(modal_stack.hit_test(centre), Some(&menu_id)); + + let outside = quadraui::Point { x: 0.0, y: 0.0 }; + assert!(modal_stack.hit_test(outside).is_none()); + + modal_stack.pop(&menu_id); + assert!( + modal_stack.hit_test(centre).is_none(), + "after close, centre of former menu bounds should no longer hit" + ); +}