From 025adc66ae25c032e53b1f112409480cbb5858f3 Mon Sep 17 00:00:00 2001 From: Andrew Lee Date: Sun, 12 Apr 2026 19:37:27 -0700 Subject: [PATCH] Fix mouse wheel overlay routing --- internal/app/app_input_mouse.go | 38 +++--- internal/app/app_input_mouse_helpers_test.go | 42 ++++++ internal/app/app_input_mouse_test.go | 129 ++++++++++--------- 3 files changed, 133 insertions(+), 76 deletions(-) create mode 100644 internal/app/app_input_mouse_helpers_test.go diff --git a/internal/app/app_input_mouse.go b/internal/app/app_input_mouse.go index 2e27ef14..6e65d92c 100644 --- a/internal/app/app_input_mouse.go +++ b/internal/app/app_input_mouse.go @@ -97,23 +97,31 @@ func (a *App) routeMouseWheel(msg tea.MouseWheelMsg) tea.Cmd { return nil } + if (a.dialog != nil && a.dialog.Visible()) || + (a.filePicker != nil && a.filePicker.Visible()) || + (a.settingsDialog != nil && a.settingsDialog.Visible()) || + a.err != nil || + a.toastCoversPoint(msg.X, msg.Y) { + // Modal, error, and toast overlays should block background scrolling. + return nil + } + targetPane := a.focusedPane - // Modal overlays and toast overlays do not consume wheel today, so preserve - // focused-pane routing instead of hit-testing obscured panes beneath them. - if !a.overlayVisible() && !a.toastCoversPoint(msg.X, msg.Y) { - // Route wheel input by pointer target when possible so hovered panes - // scroll without requiring a prior click. Fall back to keyboard focus - // when the pointer is outside interactive pane geometry. - hoverPane, hasTarget := a.paneForPoint(msg.X, msg.Y) - if hasTarget { - // Dashboard wheel handling activates rows, so do not retarget passive - // hover wheel input into it from another pane. - if hoverPane != messages.PaneDashboard || a.focusedPane == messages.PaneDashboard { - if a.canRetargetWheelToPane(hoverPane) { - targetPane = hoverPane - } - } + // Route wheel input by pointer target when possible so hovered panes scroll + // without requiring a prior click. If the pointer is over another pane that + // cannot handle wheel input, consume the event instead of scrolling the + // previously focused pane behind it. + hoverPane, hasTarget := a.paneForPoint(msg.X, msg.Y) + if hasTarget && hoverPane != a.focusedPane { + // Dashboard wheel handling activates rows, so do not retarget passive + // hover wheel input into it from another pane. + if hoverPane == messages.PaneDashboard { + return nil + } + if !a.canRetargetWheelToPane(hoverPane) { + return nil } + targetPane = hoverPane } var focusCmd tea.Cmd diff --git a/internal/app/app_input_mouse_helpers_test.go b/internal/app/app_input_mouse_helpers_test.go new file mode 100644 index 00000000..4f00feaa --- /dev/null +++ b/internal/app/app_input_mouse_helpers_test.go @@ -0,0 +1,42 @@ +package app + +import ( + "fmt" + "testing" + + "github.com/andyrewlee/amux/internal/messages" + "github.com/andyrewlee/amux/internal/ui/center" +) + +func newScrollableCenterWheelHarness(t *testing.T) (*App, *center.Tab) { + t.Helper() + + h, err := NewHarness(HarnessOptions{ + Mode: HarnessCenter, + Tabs: 1, + Width: 140, + Height: 40, + HotTabs: 0, + }) + if err != nil { + t.Fatalf("harness init: %v", err) + } + if h.app == nil || len(h.tabs) != 1 || h.tabs[0] == nil || h.tabs[0].Terminal == nil { + t.Fatal("expected harness center tab terminal") + } + + tab := h.tabs[0] + for i := 0; i < 80; i++ { + tab.WriteToTerminal([]byte(fmt.Sprintf("line %d\n", i))) + } + tab.Terminal.ScrollView(12) + offset, _ := tab.Terminal.GetScrollInfo() + if offset == 0 { + t.Fatal("expected center terminal to start scrolled into history") + } + + if cmd := h.app.focusPane(messages.PaneCenter); cmd != nil { + t.Fatal("expected scroll harness focus to be synchronous") + } + return h.app, tab +} diff --git a/internal/app/app_input_mouse_test.go b/internal/app/app_input_mouse_test.go index 56093310..14380d1f 100644 --- a/internal/app/app_input_mouse_test.go +++ b/internal/app/app_input_mouse_test.go @@ -181,6 +181,29 @@ func TestRouteMouseWheel_PrefixPaletteConsumesWheel(t *testing.T) { } } +func TestRouteMouseWheel_PrefixModeOutsidePaletteStillScrollsFocusedPane(t *testing.T) { + app, tab := newScrollableCenterWheelHarness(t) + app.prefixActive = true + + l := app.layout + x := l.LeftGutter() + l.DashboardWidth() + l.GapX() + 3 + y := l.TopGutter() + 2 + if app.prefixPaletteContainsPoint(x, y) { + t.Fatal("expected wheel point to be outside prefix palette") + } + + before, _ := tab.Terminal.GetScrollInfo() + app.routeMouseWheel(tea.MouseWheelMsg{ + Button: tea.MouseWheelDown, + X: x, + Y: y, + }) + after, _ := tab.Terminal.GetScrollInfo() + if after >= before { + t.Fatalf("expected prefix mode wheel outside palette to scroll focused center pane, before=%d after=%d", before, after) + } +} + func TestRouteMouseWheel_FocusesHoveredSidebarAndScrollsChanges(t *testing.T) { l := layout.NewManager() l.Resize(140, 40) @@ -263,6 +286,25 @@ func TestRouteMouseWheel_HoverSidebarTerminalSkipsFocusSideEffects(t *testing.T) } } +func TestRouteMouseWheel_HoverSidebarTerminalDoesNotScrollFocusedCenter(t *testing.T) { + app, tab := newScrollableCenterWheelHarness(t) + + l := app.layout + sidebarStartX := l.LeftGutter() + l.DashboardWidth() + l.GapX() + l.CenterWidth() + l.GapX() + topPaneHeight, _ := sidebarPaneHeights(l.Height()) + + before, _ := tab.Terminal.GetScrollInfo() + app.routeMouseWheel(tea.MouseWheelMsg{ + Button: tea.MouseWheelDown, + X: sidebarStartX + 3, + Y: l.TopGutter() + topPaneHeight + 2, + }) + after, _ := tab.Terminal.GetScrollInfo() + if after != before { + t.Fatalf("expected hovered empty sidebar terminal to leave center scroll unchanged, before=%d after=%d", before, after) + } +} + func TestRouteMouseWheel_HoverCenterPreservesDetachedReattach(t *testing.T) { l := layout.NewManager() l.Resize(140, 40) @@ -310,18 +352,10 @@ func TestRouteMouseWheel_HoverCenterPreservesDetachedReattach(t *testing.T) { } func TestRouteMouseWheel_HoverDashboardDoesNotRetargetFromFocusedPane(t *testing.T) { - l := layout.NewManager() - l.Resize(140, 40) + app, tab := newScrollableCenterWheelHarness(t) + l := app.layout - app := &App{ - layout: l, - dashboard: dashboard.New(), - center: center.New(&config.Config{}), - sidebar: sidebar.NewTabbedSidebar(), - sidebarTerminal: sidebar.NewTerminalModel(), - } - app.updateLayout() - app.focusPane(messages.PaneCenter) + before, _ := tab.Terminal.GetScrollInfo() cmd := app.routeMouseWheel(tea.MouseWheelMsg{ Button: tea.MouseWheelDown, @@ -334,6 +368,10 @@ func TestRouteMouseWheel_HoverDashboardDoesNotRetargetFromFocusedPane(t *testing if app.focusedPane != messages.PaneCenter { t.Fatalf("expected focus to remain center, got %v", app.focusedPane) } + after, _ := tab.Terminal.GetScrollInfo() + if after != before { + t.Fatalf("expected dashboard hover wheel to leave center scroll unchanged, before=%d after=%d", before, after) + } } func TestRouteMouseWheel_HoverEmptyCenterDoesNotStealFocus(t *testing.T) { @@ -362,66 +400,30 @@ func TestRouteMouseWheel_HoverEmptyCenterDoesNotStealFocus(t *testing.T) { } func TestRouteMouseWheel_DialogOverlayPreventsRetarget(t *testing.T) { - l := layout.NewManager() - l.Resize(140, 40) - - app := &App{ - layout: l, - dashboard: dashboard.New(), - center: center.New(&config.Config{}), - sidebar: sidebar.NewTabbedSidebar(), - sidebarTerminal: sidebar.NewTerminalModel(), - dialog: common.NewConfirmDialog("quit", "Quit", "Confirm?"), - } + app, tab := newScrollableCenterWheelHarness(t) + l := app.layout + app.dialog = common.NewConfirmDialog("quit", "Quit", "Confirm?") app.dialog.Show() - app.updateLayout() - app.focusPane(messages.PaneDashboard) centerStartX := l.LeftGutter() + l.DashboardWidth() + l.GapX() + before, _ := tab.Terminal.GetScrollInfo() _ = app.routeMouseWheel(tea.MouseWheelMsg{ Button: tea.MouseWheelDown, X: centerStartX + 3, Y: l.TopGutter() + 2, }) - if app.focusedPane != messages.PaneDashboard { - t.Fatalf("expected dialog overlay to preserve dashboard focus, got %v", app.focusedPane) + if app.focusedPane != messages.PaneCenter { + t.Fatalf("expected dialog overlay to preserve center focus, got %v", app.focusedPane) + } + after, _ := tab.Terminal.GetScrollInfo() + if after != before { + t.Fatalf("expected dialog overlay to block background scrolling, before=%d after=%d", before, after) } } func TestRouteMouseWheel_ToastOverlayPreventsRetarget(t *testing.T) { - l := layout.NewManager() - l.Resize(140, 40) - - cfg := &config.Config{ - Assistants: map[string]config.AssistantConfig{ - "codex": {Command: "codex"}, - }, - } - centerModel := center.New(cfg) - app := &App{ - width: 140, - height: 40, - layout: l, - dashboard: dashboard.New(), - center: centerModel, - sidebar: sidebar.NewTabbedSidebar(), - sidebarTerminal: sidebar.NewTerminalModel(), - toast: common.NewToastModel(), - } - app.updateLayout() - - ws := data.NewWorkspace("feature", "feature", "main", "/tmp/repo", "/tmp/repo/feature") - ws.OpenTabs = []data.TabInfo{{ - Assistant: "codex", - Name: "Codex", - SessionName: "amux-test-toast-detached", - Status: "detached", - }} - centerModel.SetWorkspace(ws) - if cmd := centerModel.RestoreTabsFromWorkspace(ws); cmd != nil { - t.Fatal("expected detached tab restore to be synchronous") - } - app.focusPane(messages.PaneDashboard) + app, tab := newScrollableCenterWheelHarness(t) + l := app.layout _ = app.toast.ShowInfo(strings.Repeat("toast ", 12)) toastView := app.toast.View() @@ -452,6 +454,7 @@ func TestRouteMouseWheel_ToastOverlayPreventsRetarget(t *testing.T) { t.Fatal("expected toast height to be positive") } + before, _ := tab.Terminal.GetScrollInfo() cmd := app.routeMouseWheel(tea.MouseWheelMsg{ Button: tea.MouseWheelDown, X: x, @@ -460,7 +463,11 @@ func TestRouteMouseWheel_ToastOverlayPreventsRetarget(t *testing.T) { if cmd != nil { t.Fatal("expected toast-covered wheel input to avoid retarget side effects") } - if app.focusedPane != messages.PaneDashboard { - t.Fatalf("expected toast overlay to preserve dashboard focus, got %v", app.focusedPane) + if app.focusedPane != messages.PaneCenter { + t.Fatalf("expected toast overlay to preserve center focus, got %v", app.focusedPane) + } + after, _ := tab.Terminal.GetScrollInfo() + if after != before { + t.Fatalf("expected toast overlay to block background scrolling, before=%d after=%d", before, after) } }