Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 34 additions & 14 deletions src/tui_main/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<quadraui::Point> = 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)
{
Expand Down Expand Up @@ -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)
{
Expand All @@ -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;
Expand Down Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions src/tui_main/mouse.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
35 changes: 35 additions & 0 deletions tests/context_menu.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 ───────────────────────────────────────────────
Expand Down Expand Up @@ -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"
);
}