Skip to content
Open
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
38 changes: 23 additions & 15 deletions internal/app/app_input_mouse.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
42 changes: 42 additions & 0 deletions internal/app/app_input_mouse_helpers_test.go
Original file line number Diff line number Diff line change
@@ -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
}
129 changes: 68 additions & 61 deletions internal/app/app_input_mouse_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand All @@ -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) {
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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,
Expand All @@ -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)
}
}
Loading