From eb68c804b5f0511fd19df554cf0f41b040649b03 Mon Sep 17 00:00:00 2001 From: shayne <79330+shayne@users.noreply.github.com> Date: Sun, 12 Jul 2026 22:47:46 -0400 Subject: [PATCH] tui: preserve chat divider gesture ordering --- .../2026-07-12-divider-drag-event-ordering.md | 307 ++++++++++++++++++ ...7-12-divider-drag-event-ordering-design.md | 63 ++++ pkg/derpssh/tui/app.go | 10 +- pkg/derpssh/tui/header_test.go | 2 +- pkg/derpssh/tui/mouse.go | 9 + pkg/derpssh/tui/mouse_test.go | 179 +++++++--- pkg/derpssh/tui/scene.go | 9 - pkg/derpssh/tui/scene_header_test.go | 6 +- 8 files changed, 511 insertions(+), 74 deletions(-) create mode 100644 docs/superpowers/plans/2026-07-12-divider-drag-event-ordering.md create mode 100644 docs/superpowers/specs/2026-07-12-divider-drag-event-ordering-design.md diff --git a/docs/superpowers/plans/2026-07-12-divider-drag-event-ordering.md b/docs/superpowers/plans/2026-07-12-divider-drag-event-ordering.md new file mode 100644 index 0000000..01c5004 --- /dev/null +++ b/docs/superpowers/plans/2026-07-12-divider-drag-event-ordering.md @@ -0,0 +1,307 @@ +# Reliable Chat Divider Dragging Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make exact-divider chat resizing reliable by processing press, motion, and release as one ordered Bubble Tea input stream. + +**Architecture:** Route raw `tea.MouseMsg` values synchronously through `App.Update`. Resolve the semantic scene target only when no pointer target is captured, then preserve the existing divider capture across motion and release. Remove the asynchronous `View.OnMouse` forwarding path so each physical event is handled exactly once and in arrival order. + +**Tech Stack:** Go, Bubble Tea v2.0.8, Lip Gloss v2.0.5 semantic layers, GitButler, Go tests, mise verification tasks. + +## Global Constraints + +- Keep the visible divider and its hit area exactly one terminal cell wide. +- Do not add invisible pointer padding around the divider. +- Treat a drag as the ordered state machine press, capture, motion, release. +- Keep interaction-state mutations in `Update`; `View` remains a rendering operation. +- Use the semantic Lip Gloss scene for uncaptured pointer hit-testing. +- Route captured motion and release to the captured semantic target regardless of their coordinates. +- Preserve existing capture cancellation when a modal opens or mouse interaction is disabled. +- Do not change layout calculations, sidebar width constraints, keyboard resizing, terminal SGR encoding, or unrelated Charm v2 APIs. +- Use GitButler for commits and history operations; do not include another session's changes. + +--- + +## File Structure + +- `pkg/derpssh/tui/app.go`: accept ordered raw mouse messages in the model update loop and stop installing asynchronous view callbacks. +- `pkg/derpssh/tui/mouse.go`: resolve uncaptured semantic targets and synchronously dispatch raw pointer events through the existing mouse state machine. +- `pkg/derpssh/tui/scene.go`: retain scene hit-testing while deleting the obsolete command-producing pointer adapter. +- `pkg/derpssh/tui/mouse_test.go`: prove synchronous exact-divider capture, capture outside the divider during motion, exact adjacent-cell behavior, and direct raw-event routing for the existing mouse suite. +- `pkg/derpssh/tui/scene_header_test.go`: replace the obsolete assertion that raw mouse messages are ignored with the ordered raw-header-action contract. + +### Task 1: Route Pointer Gestures Through the Ordered Update Stream + +**Files:** +- Modify: `pkg/derpssh/tui/app.go:229-241,362-382` +- Modify: `pkg/derpssh/tui/mouse.go:22-35` +- Modify: `pkg/derpssh/tui/scene.go:39-57` +- Modify: `pkg/derpssh/tui/mouse_test.go:304-390,606-618` +- Modify: `pkg/derpssh/tui/scene_header_test.go:65-74` +- Test: `pkg/derpssh/tui/mouse_test.go` +- Test: `pkg/derpssh/tui/scene_header_test.go` + +**Interfaces:** +- Consumes: `Scene.TargetAt(x int, y int) layerTarget`, `App.pointerCapture layerTarget`, `HandleMouse(app *App, pointer pointerMsg) tea.Cmd`, and ordered raw `tea.MouseMsg` values delivered to `App.Update`. +- Produces: `func (a *App) handleMouseMessage(msg tea.MouseMsg) tea.Cmd`, synchronous mouse routing in `App.handleInteractiveMessage`, and a `tea.View` with mouse mode enabled but `OnMouse == nil`. + +- [ ] **Step 1: Add failing regression tests for synchronous exact-divider capture** + +Add these tests near the existing divider-drag tests in `pkg/derpssh/tui/mouse_test.go`: + +```go +func TestRawMouseExactDividerPressCapturesSynchronously(t *testing.T) { + app := NewApp(Options{Terminal: &fakePane{view: "shell$"}}) + app.Update(tea.WindowSizeMsg{Width: 100, Height: 24}) + app.setSidebarOpen(true) + divider := app.layout.Divider + + app.Update(clickAt(divider.X, divider.Y+1, tea.MouseLeft)) + + if app.pointerCapture != targetDivider || !app.draggingDivider { + t.Fatalf("capture, dragging = %q, %v; want divider, true", app.pointerCapture, app.draggingDivider) + } +} + +func TestViewDoesNotAsynchronouslyForwardMouseEvents(t *testing.T) { + app := NewApp(Options{Terminal: &fakePane{view: "shell$"}}) + view := app.View() + + if view.OnMouse != nil { + t.Fatal("View().OnMouse is non-nil; raw mouse events must be ordered through Update") + } +} +``` + +- [ ] **Step 2: Run the new tests and verify the red state** + +Run: + +```bash +mise exec -- go test ./pkg/derpssh/tui -run 'TestRawMouseExactDividerPressCapturesSynchronously|TestViewDoesNotAsynchronouslyForwardMouseEvents' -count=1 +``` + +Expected: FAIL because the raw divider press is ignored and `View().OnMouse` is non-nil. + +- [ ] **Step 3: Add synchronous raw-event routing** + +Add this method beside `newPointerMsg` in `pkg/derpssh/tui/mouse.go`: + +```go +func (a *App) handleMouseMessage(msg tea.MouseMsg) tea.Cmd { + target := a.pointerCapture + if target == "" { + mouse := msg.Mouse() + target = a.buildScene().TargetAt(mouse.X, mouse.Y) + } + return HandleMouse(a, newPointerMsg(target, msg)) +} +``` + +This performs a scene hit-test for an uncaptured press or independent pointer event. Once the divider captures the pointer, motion and release skip coordinate hit-testing and remain routed to `targetDivider`. + +- [ ] **Step 4: Route raw mouse messages through the new method** + +Replace the ignored raw-mouse case in `App.handleInteractiveMessage` in `pkg/derpssh/tui/app.go`: + +```go +case tea.MouseMsg: + return a.handleMouseMessage(msg), true +``` + +Keep the existing `pointerMsg` case as the internal semantic-handler boundary used by focused mouse-handler tests. Production mouse input will reach that representation synchronously through `handleMouseMessage`, not through `View.OnMouse`. + +- [ ] **Step 5: Remove the asynchronous view callback and command adapter** + +In `App.configureView`, delete the captured `pointerCapture` value and the `view.OnMouse` callback: + +```go +func (a *App) configureView(view tea.View, scene Scene) tea.View { + view.AltScreen = true + view.MouseMode = tea.MouseModeCellMotion + if a.modalActive() { + a.clearPointerCapture() + } + if a.copyMode || a.inviteOpen { + a.clearPointerCapture() + view.MouseMode = tea.MouseModeNone + } + view.Cursor = scene.Cursor + view.KeyboardEnhancements = tea.KeyboardEnhancements{ + ReportAlternateKeys: true, + ReportAllKeysAsEscapeCodes: true, + ReportAssociatedText: true, + } + return view +} +``` + +Delete `Scene.PointerCmd` from `pkg/derpssh/tui/scene.go`. Keep `Scene.TargetAt` unchanged; it remains the single semantic hit-testing boundary. + +- [ ] **Step 6: Verify the minimal implementation turns the regression tests green** + +Run: + +```bash +mise exec -- go test ./pkg/derpssh/tui -run 'TestRawMouseExactDividerPressCapturesSynchronously|TestViewDoesNotAsynchronouslyForwardMouseEvents' -count=1 +``` + +Expected: PASS. + +- [ ] **Step 7: Move the existing mouse suite onto the real raw-event boundary** + +Rename `dispatchViewMouse` to `dispatchMouse` throughout `pkg/derpssh/tui/mouse_test.go` and `pkg/derpssh/tui/header_test.go`. Replace its implementation and delete `viewMouseCmd`: + +```go +func dispatchMouse(t *testing.T, app *App, msg tea.MouseMsg) { + t.Helper() + app.Update(msg) +} +``` + +Replace the two direct `viewMouseCmd` calls with raw messages: + +```go +_, cmd := app.Update(leftClick(0, 0)) +``` + +and: + +```go +_, repaint := app.Update(leftRelease(x, y)) +``` + +Rename `TestViewOnMouseUsesRenderedLayerAndCapturesDividerDrag` to `TestRawMouseUsesRenderedLayerAndCapturesDividerDrag`. + +Replace `TestRawMouseMessageDoesNotDispatchPointerAction` in `pkg/derpssh/tui/scene_header_test.go` with `TestRawMouseMessageDispatchesPointerAction`, send the existing raw chat-action click through `app.Update`, and assert that `app.sidebarOpen` is true. The old assertion encoded the superseded architecture in which raw mouse messages were deliberately discarded. + +- [ ] **Step 8: Add exact adjacent-cell and captured-motion coverage** + +Add the following test beside the synchronous capture regression: + +```go +func TestRawMouseDividerHitAreaRemainsExactlyOneCell(t *testing.T) { + for _, tc := range []struct { + name string + dx int + }{ + {name: "terminal neighbor", dx: -1}, + {name: "sidebar neighbor", dx: 1}, + } { + t.Run(tc.name, func(t *testing.T) { + app := NewApp(Options{Terminal: &fakePane{view: "shell$"}}) + app.Update(tea.WindowSizeMsg{Width: 100, Height: 24}) + app.setSidebarOpen(true) + divider := app.layout.Divider + + app.Update(clickAt(divider.X+tc.dx, divider.Y+1, tea.MouseLeft)) + + if app.pointerCapture == targetDivider || app.draggingDivider { + t.Fatalf("neighbor dx %d captured divider", tc.dx) + } + }) + } +} +``` + +Keep `TestRawMouseUsesRenderedLayerAndCapturesDividerDrag` asserting that motion eight cells outside the divider remains captured until release. This proves capture, rather than a widened hit zone, owns the gesture. + +- [ ] **Step 9: Run focused TUI tests** + +Run: + +```bash +mise exec -- go test ./pkg/derpssh/tui -count=1 +``` + +Expected: PASS with the entire existing semantic-target, modal, terminal-mouse, and divider suite now exercising raw ordered events. + +- [ ] **Step 10: Inspect and commit only the TUI fix** + +Run `but diff`, select only the IDs for: + +- `pkg/derpssh/tui/app.go` +- `pkg/derpssh/tui/mouse.go` +- `pkg/derpssh/tui/scene.go` +- `pkg/derpssh/tui/mouse_test.go` +- `pkg/derpssh/tui/header_test.go` +- `pkg/derpssh/tui/scene_header_test.go` + +Copy the six file IDs printed by `but diff` into one comma-separated `--changes` argument, then commit them to `codex/chat-divider-drag-ordering` with the message `tui: preserve mouse gesture ordering`. Do not hard-code IDs before reading the current diff because GitButler IDs can change as other sessions update the shared workspace. + +Expected: one implementation commit above the design and plan commits, with all unrelated HTTP-proxy and transport changes still outside this branch. + +### Task 2: Verify the Exact Branch in Isolation + +**Files:** +- Verify: `pkg/derpssh/tui/app.go` +- Verify: `pkg/derpssh/tui/mouse.go` +- Verify: `pkg/derpssh/tui/scene.go` +- Verify: `pkg/derpssh/tui/mouse_test.go` +- Verify: `pkg/derpssh/tui/header_test.go` +- Verify: `pkg/derpssh/tui/scene_header_test.go` + +**Interfaces:** +- Consumes: the committed `codex/chat-divider-drag-ordering` branch. +- Produces: clean focused, repository, and race verification evidence pinned to the exact implementation commit. + +- [ ] **Step 1: Run the broader derpssh test surface in the working repository** + +Run: + +```bash +mise exec -- go test ./pkg/derpssh/... ./cmd/derpssh/... -count=1 +``` + +Expected: PASS. If an unrelated applied branch prevents compilation, record the exact failure and continue with the clean-clone verification below rather than changing another session's work. + +- [ ] **Step 2: Create a clean temporary clone pinned to the branch commit** + +Run: + +```bash +verify_dir="$(mktemp -d /tmp/derphole-divider-verify.XXXXXX)" +git clone --quiet --no-local /Users/shayne/code/derphole "$verify_dir" +git -C "$verify_dir" checkout --quiet --detach "$(git rev-parse codex/chat-divider-drag-ordering)" +git -C "$verify_dir" rev-parse HEAD +``` + +Expected: the printed commit equals `git rev-parse codex/chat-divider-drag-ordering` in the working repository. + +- [ ] **Step 3: Run repository and race gates on the exact commit** + +Run: + +```bash +( + cd "$verify_dir" + mise run check + mise run race +) +``` + +Expected: both commands PASS. Any failure belongs to the exact branch and must be diagnosed with `superpowers:systematic-debugging` before completion. + +- [ ] **Step 4: Confirm the committed scope** + +Run: + +```bash +git diff --name-only main..codex/chat-divider-drag-ordering +git log --oneline --reverse main..codex/chat-divider-drag-ordering +``` + +Expected changed paths: + +```text +docs/superpowers/plans/2026-07-12-divider-drag-event-ordering.md +docs/superpowers/specs/2026-07-12-divider-drag-event-ordering-design.md +pkg/derpssh/tui/app.go +pkg/derpssh/tui/header_test.go +pkg/derpssh/tui/mouse.go +pkg/derpssh/tui/mouse_test.go +pkg/derpssh/tui/scene.go +pkg/derpssh/tui/scene_header_test.go +``` + +Expected history: the design commit, the implementation-plan commit, and one implementation commit. Do not squash or publish unless the user explicitly requests integration. diff --git a/docs/superpowers/specs/2026-07-12-divider-drag-event-ordering-design.md b/docs/superpowers/specs/2026-07-12-divider-drag-event-ordering-design.md new file mode 100644 index 0000000..4433a75 --- /dev/null +++ b/docs/superpowers/specs/2026-07-12-divider-drag-event-ordering-design.md @@ -0,0 +1,63 @@ +# Reliable Chat Divider Dragging + +## Problem + +The chat divider is visibly and semantically one terminal cell wide. That is the intended interaction area, but dragging it starts unreliably even when the initial press lands on the divider. + +The scene compositor currently resolves each mouse event through `View.OnMouse`, which returns a Bubble Tea command containing a semantic `pointerMsg`. Bubble Tea launches each returned command independently. Press, motion, and release messages can therefore reach the model out of order. The model also ignores the original ordered `tea.MouseMsg` stream, so divider capture may not be established before motion or release is handled. + +The defect is event ordering, not target geometry. Expanding the divider hit area would hide the symptom while taking mouse input away from adjacent terminal and chat cells. + +## Design Principles + +- A drag is an ordered state machine: press, capture, motion, release. +- Input ordering belongs in `Update`; asynchronous commands are for effects, not gesture sequencing. +- Hit-testing should use the semantic Lip Gloss scene that produced the visible interface. +- After capture, pointer coordinates no longer choose the recipient. Motion and release remain routed to the captured target. +- The visible divider and its hit area remain exactly one terminal cell wide. +- `View` remains a rendering operation and does not mutate interaction state. + +## Event Flow + +`App.Update` will handle supported raw `tea.MouseMsg` values directly instead of discarding them. + +For a pointer event with no active capture: + +1. Build the semantic scene from the current model state. +2. Ask the scene compositor for the topmost target at the event coordinates. +3. Wrap the raw event and target in the existing `pointerMsg` representation. +4. Dispatch it synchronously through the existing mouse handlers. + +For a pointer event with an active capture: + +1. Skip coordinate hit-testing. +2. Route the event to the captured semantic target. +3. Keep routing motion to that target even when the pointer crosses into the terminal or sidebar. +4. Clear capture on release through the existing release path. + +The view will continue enabling `tea.MouseModeCellMotion`, but it will no longer install an `OnMouse` callback that forwards a second, asynchronous copy of each event. + +## Divider Behavior + +An exact left-button press on the rendered divider starts a divider drag and captures `targetDivider`. Motion updates the sidebar width using the current pointer coordinate. Release ends the drag and clears capture. + +A press in the immediately adjacent terminal or sidebar cell retains that cell's existing behavior. No invisible padding is added around the divider. + +Existing cancellation rules remain in force: opening a modal, entering copy mode, or otherwise disabling mouse interaction clears pointer capture. + +## Testing + +Regression coverage will exercise the public model boundary rather than manually running commands returned by `View.OnMouse`: + +- An exact-divider raw press sent to `Update` synchronously establishes divider capture. +- A raw press, motion, and release sequence resizes the chat pane and clears capture. +- Motion outside the divider continues to route to the captured divider. +- An adjacent terminal or sidebar press does not capture the divider. +- The rendered divider remains one cell wide and retains its semantic divider target. +- Existing modal, copy-mode, terminal-mouse, and scene-target tests continue to pass. + +The focused TUI tests will run first, followed by the repository check and race suites before completion. + +## Scope + +This change is limited to local mouse-event routing and its tests. It does not alter divider rendering, layout calculations, sidebar width constraints, keyboard resizing, terminal mouse encoding, or unrelated Charm v2 APIs. diff --git a/pkg/derpssh/tui/app.go b/pkg/derpssh/tui/app.go index af46d5d..5c6e0f5 100644 --- a/pkg/derpssh/tui/app.go +++ b/pkg/derpssh/tui/app.go @@ -235,7 +235,7 @@ func (a *App) handleInteractiveMessage(msg tea.Msg) (tea.Cmd, bool) { case pointerMsg: return HandleMouse(a, msg), true case tea.MouseMsg: - return nil, true + return a.handleMouseMessage(msg), true default: return nil, false } @@ -362,11 +362,7 @@ func (a *App) buildScene() Scene { func (a *App) configureView(view tea.View, scene Scene) tea.View { view.AltScreen = true view.MouseMode = tea.MouseModeCellMotion - if a.modalActive() { - a.clearPointerCapture() - } if a.copyMode || a.inviteOpen { - a.clearPointerCapture() view.MouseMode = tea.MouseModeNone } view.Cursor = scene.Cursor @@ -375,10 +371,6 @@ func (a *App) configureView(view tea.View, scene Scene) tea.View { ReportAllKeysAsEscapeCodes: true, ReportAssociatedText: true, } - capture := a.pointerCapture - view.OnMouse = func(msg tea.MouseMsg) tea.Cmd { - return scene.PointerCmd(capture, msg) - } return view } diff --git a/pkg/derpssh/tui/header_test.go b/pkg/derpssh/tui/header_test.go index 53b8f88..a7bea1d 100644 --- a/pkg/derpssh/tui/header_test.go +++ b/pkg/derpssh/tui/header_test.go @@ -18,7 +18,7 @@ func TestHeaderPeerChipClickOpensPeerDialogForPeerID(t *testing.T) { _ = appContent(app) peer := topBarPeerRect(t, app, "guest-2") - dispatchViewMouse(t, app, leftClick(peer.X+peer.W/2, peer.Y)) + dispatchMouse(t, app, leftClick(peer.X+peer.W/2, peer.Y)) if !app.peerDialogOpen { t.Fatal("peer dialog did not open") diff --git a/pkg/derpssh/tui/mouse.go b/pkg/derpssh/tui/mouse.go index bca2068..9c4702a 100644 --- a/pkg/derpssh/tui/mouse.go +++ b/pkg/derpssh/tui/mouse.go @@ -31,6 +31,15 @@ func newPointerMsg(target layerTarget, msg tea.MouseMsg) pointerMsg { return pointerMsg{Target: target, Event: msg, Mouse: msg.Mouse()} } +func (a *App) handleMouseMessage(msg tea.MouseMsg) tea.Cmd { + target := a.pointerCapture + if target == "" { + mouse := msg.Mouse() + target = a.buildScene().TargetAt(mouse.X, mouse.Y) + } + return HandleMouse(a, newPointerMsg(target, msg)) +} + type pointerAction int const ( diff --git a/pkg/derpssh/tui/mouse_test.go b/pkg/derpssh/tui/mouse_test.go index cd670be..25bbdab 100644 --- a/pkg/derpssh/tui/mouse_test.go +++ b/pkg/derpssh/tui/mouse_test.go @@ -17,7 +17,7 @@ func TestMouseClickTopBarChatToggle(t *testing.T) { drainCommands(app) chat := topBarActionRect(t, app, ActionToggleChat) - dispatchViewMouse(t, app, leftClick(chat.X+chat.W/2, chat.Y)) + dispatchMouse(t, app, leftClick(chat.X+chat.W/2, chat.Y)) if !app.sidebarOpen { t.Fatalf("sidebarOpen = false, want true after top-bar chat click") @@ -37,7 +37,7 @@ func TestMouseClickTopBarChatToggle(t *testing.T) { t.Fatalf("resize command = %+v, want %+v", got, want) } - dispatchViewMouse(t, app, leftClick(chat.X+chat.W/2, chat.Y)) + dispatchMouse(t, app, leftClick(chat.X+chat.W/2, chat.Y)) if app.sidebarOpen { t.Fatalf("sidebarOpen = true, want false after second top-bar chat click") @@ -53,7 +53,7 @@ func TestMouseClickTopBarQuitOpensConfirmation(t *testing.T) { drainCommands(app) quit := topBarActionRect(t, app, ActionQuit) - dispatchViewMouse(t, app, leftClick(quit.X+quit.W/2, quit.Y)) + dispatchMouse(t, app, leftClick(quit.X+quit.W/2, quit.Y)) if !app.quitOpen { t.Fatalf("quitOpen = false, want true after top-bar X click") @@ -73,7 +73,7 @@ func TestMouseClickPeerTopBarOpensPeerDialog(t *testing.T) { drainCommands(app) peer := topBarPeerRect(t, app, "guest-2") - dispatchViewMouse(t, app, leftClick(peer.X+peer.W/2, peer.Y)) + dispatchMouse(t, app, leftClick(peer.X+peer.W/2, peer.Y)) if !app.peerDialogOpen { t.Fatal("peer dialog did not open after clicking peer chip") @@ -95,15 +95,15 @@ func TestMouseClickPeerDialogReadChangesClickedPeer(t *testing.T) { }}) drainCommands(app) peer := topBarPeerRect(t, app, "guest-2") - dispatchViewMouse(t, app, leftClick(peer.X+peer.W/2, peer.Y)) + dispatchMouse(t, app, leftClick(peer.X+peer.W/2, peer.Y)) read, _, _ := app.peerActionButtonRects() - dispatchViewMouse(t, app, leftClick(read.X+read.W/2, read.Y)) + dispatchMouse(t, app, leftClick(read.X+read.W/2, read.Y)) if cmd := readCommand(app); cmd != nil { t.Fatalf("peer dialog press emitted command %+v, want none until release", cmd) } - dispatchViewMouse(t, app, leftRelease(read.X+read.W/2, read.Y)) + dispatchMouse(t, app, leftRelease(read.X+read.W/2, read.Y)) got, ok := readCommand(app).(RoleChangeCommand) if !ok { @@ -122,12 +122,12 @@ func TestMouseClickQuitConfirmationButtons(t *testing.T) { app.openQuitConfirm() quit, _ := app.quitButtonRects() - dispatchViewMouse(t, app, leftClick(quit.X+quit.W/2, quit.Y)) + dispatchMouse(t, app, leftClick(quit.X+quit.W/2, quit.Y)) if cmd := readCommand(app); cmd != nil { t.Fatalf("quit confirmation press emitted command %+v, want none until release", cmd) } - dispatchViewMouse(t, app, leftRelease(quit.X+quit.W/2, quit.Y)) + dispatchMouse(t, app, leftRelease(quit.X+quit.W/2, quit.Y)) if _, ok := readCommand(app).(QuitCommand); !ok { t.Fatalf("quit confirmation click did not emit QuitCommand") @@ -141,7 +141,7 @@ func TestMouseQuitPressDoesNotSurviveKeyboardClose(t *testing.T) { app.openQuitConfirm() quit, _ := app.quitButtonRects() - dispatchViewMouse(t, app, leftClick(quit.X+quit.W/2, quit.Y)) + dispatchMouse(t, app, leftClick(quit.X+quit.W/2, quit.Y)) if cmd := readCommand(app); cmd != nil { t.Fatalf("quit confirmation press emitted command %+v, want none until release", cmd) } @@ -153,7 +153,7 @@ func TestMouseQuitPressDoesNotSurviveKeyboardClose(t *testing.T) { app.openQuitConfirm() quit, _ = app.quitButtonRects() - dispatchViewMouse(t, app, leftRelease(quit.X+quit.W/2, quit.Y)) + dispatchMouse(t, app, leftRelease(quit.X+quit.W/2, quit.Y)) if cmd := readCommand(app); cmd != nil { t.Fatalf("stale mouse release emitted command %+v, want none without fresh press", cmd) @@ -168,12 +168,12 @@ func TestMouseClickQuitConfirmationWorksInCopyMode(t *testing.T) { app.openQuitConfirm() quit, _ := app.quitButtonRects() - dispatchViewMouse(t, app, leftClick(quit.X+quit.W/2, quit.Y)) + dispatchMouse(t, app, leftClick(quit.X+quit.W/2, quit.Y)) if cmd := readCommand(app); cmd != nil { t.Fatalf("quit confirmation press in copy mode emitted command %+v, want none until release", cmd) } - dispatchViewMouse(t, app, leftRelease(quit.X+quit.W/2, quit.Y)) + dispatchMouse(t, app, leftRelease(quit.X+quit.W/2, quit.Y)) if _, ok := readCommand(app).(QuitCommand); !ok { t.Fatalf("quit confirmation click in copy mode did not emit QuitCommand") @@ -186,7 +186,7 @@ func TestSelectionModeClickOutsideTerminalRestoresMouse(t *testing.T) { drainCommands(app) app.copyMode = true - _, cmd := app.Update(viewMouseCmd(t, app, leftClick(0, 0))()) + _, cmd := app.Update(leftClick(0, 0)) if app.copyMode { t.Fatalf("copyMode = true, want false after click outside terminal") @@ -205,7 +205,7 @@ func TestSelectionModeTerminalClickDoesNotForwardMouse(t *testing.T) { drainCommands(app) app.copyMode = true - dispatchViewMouse(t, app, leftClick(app.layout.Terminal.X+1, app.layout.Terminal.Y+1)) + dispatchMouse(t, app, leftClick(app.layout.Terminal.X+1, app.layout.Terminal.Y+1)) if !app.copyMode { t.Fatalf("copyMode = false, want true after terminal-area selection click") @@ -258,12 +258,12 @@ func TestMouseClickShellExitQuitCommitsOnRelease(t *testing.T) { app.shellExitChoice = shellExitChoiceQuit _, quit := app.shellExitButtonRects() - dispatchViewMouse(t, app, leftClick(quit.X+quit.W/2, quit.Y)) + dispatchMouse(t, app, leftClick(quit.X+quit.W/2, quit.Y)) if cmd := readCommand(app); cmd != nil { t.Fatalf("shell-exit quit press emitted command %+v, want none until release", cmd) } - dispatchViewMouse(t, app, leftRelease(quit.X+quit.W/2, quit.Y)) + dispatchMouse(t, app, leftRelease(quit.X+quit.W/2, quit.Y)) if _, ok := readCommand(app).(QuitCommand); !ok { t.Fatalf("shell-exit quit release did not emit QuitCommand") @@ -276,7 +276,7 @@ func TestMouseMenuShowsHostInvite(t *testing.T) { drainCommands(app) menu := topBarActionRect(t, app, ActionShowMenu) - dispatchViewMouse(t, app, leftClick(menu.X+menu.W/2, menu.Y)) + dispatchMouse(t, app, leftClick(menu.X+menu.W/2, menu.Y)) if !strings.Contains(appContent(app), "Show Invite") || !strings.Contains(appContent(app), "Ctrl-X I") { t.Fatalf("menu missing invite action:\n%s", appContent(app)) @@ -290,12 +290,12 @@ func TestMouseClickFocusesTerminalAndChat(t *testing.T) { app.Update(textKey("s")) drainCommands(app) - dispatchViewMouse(t, app, leftClick(app.layout.Terminal.X+1, app.layout.Terminal.Y+1)) + dispatchMouse(t, app, leftClick(app.layout.Terminal.X+1, app.layout.Terminal.Y+1)) if app.focus != FocusTerminal { t.Fatalf("focus after terminal click = %v, want terminal", app.focus) } - dispatchViewMouse(t, app, leftClick(app.layout.Composer.X+1, app.layout.Composer.Y)) + dispatchMouse(t, app, leftClick(app.layout.Composer.X+1, app.layout.Composer.Y)) if app.focus != FocusChat { t.Fatalf("focus after composer click = %v, want chat", app.focus) } @@ -309,9 +309,9 @@ func TestMouseDragDividerResizesChat(t *testing.T) { drainCommands(app) start := app.layout.Divider.X - dispatchViewMouse(t, app, leftClick(start, app.layout.Divider.Y+2)) - dispatchViewMouse(t, app, tea.MouseMotionMsg{X: 70, Y: app.layout.Divider.Y + 2, Button: tea.MouseLeft}) - dispatchViewMouse(t, app, releaseAt(70, app.layout.Divider.Y+2, tea.MouseLeft)) + dispatchMouse(t, app, leftClick(start, app.layout.Divider.Y+2)) + dispatchMouse(t, app, tea.MouseMotionMsg{X: 70, Y: app.layout.Divider.Y + 2, Button: tea.MouseLeft}) + dispatchMouse(t, app, releaseAt(70, app.layout.Divider.Y+2, tea.MouseLeft)) if app.layout.Sidebar.W != 49 { t.Fatalf("Sidebar.W = %d, want 49 after dragging divider", app.layout.Sidebar.W) @@ -332,8 +332,8 @@ func TestMouseDragDividerRepaintsTerminalDuringMotion(t *testing.T) { _ = appContent(app) initialWidth := pane.lastViewWidth() - dispatchViewMouse(t, app, leftClick(start, app.layout.Divider.Y+2)) - dispatchViewMouse(t, app, tea.MouseMotionMsg{X: 70, Y: app.layout.Divider.Y + 2, Button: tea.MouseLeft}) + dispatchMouse(t, app, leftClick(start, app.layout.Divider.Y+2)) + dispatchMouse(t, app, tea.MouseMotionMsg{X: 70, Y: app.layout.Divider.Y + 2, Button: tea.MouseLeft}) _ = appContent(app) if got := pane.lastViewWidth(); got == initialWidth { @@ -344,18 +344,102 @@ func TestMouseDragDividerRepaintsTerminalDuringMotion(t *testing.T) { } } -func TestViewOnMouseUsesRenderedLayerAndCapturesDividerDrag(t *testing.T) { +func TestRawMouseExactDividerPressCapturesSynchronously(t *testing.T) { app := NewApp(Options{Terminal: &fakePane{view: "shell$"}}) app.Update(tea.WindowSizeMsg{Width: 100, Height: 24}) app.setSidebarOpen(true) divider := app.layout.Divider - dispatchViewMouse(t, app, clickAt(divider.X, divider.Y+1, tea.MouseLeft)) + app.Update(clickAt(divider.X, divider.Y+1, tea.MouseLeft)) + + if app.pointerCapture != targetDivider || !app.draggingDivider { + t.Fatalf("capture, dragging = %q, %v; want divider, true", app.pointerCapture, app.draggingDivider) + } +} + +func TestViewDoesNotAsynchronouslyForwardMouseEvents(t *testing.T) { + app := NewApp(Options{Terminal: &fakePane{view: "shell$"}}) + view := app.View() + + if view.OnMouse != nil { + t.Fatal("View().OnMouse is non-nil; raw mouse events must be ordered through Update") + } +} + +func TestViewDoesNotMutatePointerCapture(t *testing.T) { + for _, tc := range []struct { + name string + configure func(*App) + wantMouseMode tea.MouseMode + }{ + { + name: "copy mode", + configure: func(app *App) { + app.copyMode = true + }, + wantMouseMode: tea.MouseModeNone, + }, + { + name: "modal", + configure: func(app *App) { + app.quitOpen = true + }, + wantMouseMode: tea.MouseModeCellMotion, + }, + } { + t.Run(tc.name, func(t *testing.T) { + app := NewApp(Options{Terminal: &fakePane{view: "shell$"}}) + app.pointerCapture = targetDivider + app.draggingDivider = true + tc.configure(app) + + view := app.View() + + if view.MouseMode != tc.wantMouseMode { + t.Fatalf("MouseMode = %v, want %v", view.MouseMode, tc.wantMouseMode) + } + if app.pointerCapture != targetDivider || !app.draggingDivider { + t.Fatalf("capture, dragging after View = %q, %v; want divider, true", app.pointerCapture, app.draggingDivider) + } + }) + } +} + +func TestRawMouseDividerHitAreaRemainsExactlyOneCell(t *testing.T) { + for _, tc := range []struct { + name string + dx int + }{ + {name: "terminal neighbor", dx: -1}, + {name: "sidebar neighbor", dx: 1}, + } { + t.Run(tc.name, func(t *testing.T) { + app := NewApp(Options{Terminal: &fakePane{view: "shell$"}}) + app.Update(tea.WindowSizeMsg{Width: 100, Height: 24}) + app.setSidebarOpen(true) + divider := app.layout.Divider + + app.Update(clickAt(divider.X+tc.dx, divider.Y+1, tea.MouseLeft)) + + if app.pointerCapture == targetDivider || app.draggingDivider { + t.Fatalf("neighbor dx %d captured divider", tc.dx) + } + }) + } +} + +func TestRawMouseUsesRenderedLayerAndCapturesDividerDrag(t *testing.T) { + app := NewApp(Options{Terminal: &fakePane{view: "shell$"}}) + app.Update(tea.WindowSizeMsg{Width: 100, Height: 24}) + app.setSidebarOpen(true) + divider := app.layout.Divider + + dispatchMouse(t, app, clickAt(divider.X, divider.Y+1, tea.MouseLeft)) if app.pointerCapture != targetDivider { t.Fatalf("capture = %q, want divider", app.pointerCapture) } - dispatchViewMouse(t, app, tea.MouseMotionMsg{X: divider.X - 8, Y: divider.Y + 2, Button: tea.MouseLeft}) - dispatchViewMouse(t, app, releaseAt(divider.X-8, divider.Y+2, tea.MouseLeft)) + dispatchMouse(t, app, tea.MouseMotionMsg{X: divider.X - 8, Y: divider.Y + 2, Button: tea.MouseLeft}) + dispatchMouse(t, app, releaseAt(divider.X-8, divider.Y+2, tea.MouseLeft)) if app.pointerCapture != "" { t.Fatalf("capture after release = %q, want empty", app.pointerCapture) } @@ -366,7 +450,7 @@ func TestPointerCaptureClearsWhenModalOpens(t *testing.T) { app.Update(tea.WindowSizeMsg{Width: 100, Height: 24}) app.setSidebarOpen(true) divider := app.layout.Divider - dispatchViewMouse(t, app, clickAt(divider.X, divider.Y+1, tea.MouseLeft)) + dispatchMouse(t, app, clickAt(divider.X, divider.Y+1, tea.MouseLeft)) app.openQuitConfirm() @@ -380,7 +464,7 @@ func TestPointerCaptureClearsWhenMouseModeDisabled(t *testing.T) { app.Update(tea.WindowSizeMsg{Width: 100, Height: 24}) app.setSidebarOpen(true) divider := app.layout.Divider - dispatchViewMouse(t, app, clickAt(divider.X, divider.Y+1, tea.MouseLeft)) + dispatchMouse(t, app, clickAt(divider.X, divider.Y+1, tea.MouseLeft)) app.setCopyMode(true) @@ -407,12 +491,12 @@ func TestMouseClickApprovalButtons(t *testing.T) { read, write, deny := app.approvalButtonRects() button := tt.pick(read, write, deny) - dispatchViewMouse(t, app, leftClick(button.X+button.W/2, button.Y)) + dispatchMouse(t, app, leftClick(button.X+button.W/2, button.Y)) if cmd := readCommand(app); cmd != nil { t.Fatalf("approval press emitted command %+v, want none until release", cmd) } - dispatchViewMouse(t, app, leftRelease(button.X+button.W/2, button.Y)) + dispatchMouse(t, app, leftRelease(button.X+button.W/2, button.Y)) got, ok := readCommand(app).(ApprovalDecisionCommand) if !ok { @@ -432,7 +516,7 @@ func TestMouseDuringApprovalDoesNotReachTerminalOrChangeFocus(t *testing.T) { drainCommands(app) app.Update(ApprovalRequestMsg{Peer: "Alex"}) - dispatchViewMouse(t, app, leftClick(app.layout.Terminal.X+4, app.layout.Terminal.Y+2)) + dispatchMouse(t, app, leftClick(app.layout.Terminal.X+4, app.layout.Terminal.Y+2)) if cmd := readCommand(app); cmd != nil { t.Fatalf("approval terminal click emitted command %+v, want none", cmd) @@ -453,12 +537,12 @@ func TestHostApprovalClickAtDisplayedWriteButtonRendersDeclaratively(t *testing. x := write.X + write.W/2 y := write.Y - dispatchViewMouse(t, app, leftClick(x, y)) + dispatchMouse(t, app, leftClick(x, y)) if cmd := readCommand(app); cmd != nil { t.Fatalf("approval press emitted command %+v, want none until release", cmd) } - _, repaint := app.Update(viewMouseCmd(t, app, leftRelease(x, y))()) + _, repaint := app.Update(leftRelease(x, y)) if repaint != nil { t.Fatalf("approval release returned command %T, want declarative render", repaint()) } @@ -494,7 +578,7 @@ func TestPassiveModalMouseDoesNotReachTerminal(t *testing.T) { drainCommands(app) app.Update(RuntimeStateMsg{Transport: "direct", HostCols: 101, HostRows: 29, LocalRole: RolePending}) - dispatchViewMouse(t, app, leftClick(app.layout.Terminal.X+50, app.layout.Terminal.Y+16)) + dispatchMouse(t, app, leftClick(app.layout.Terminal.X+50, app.layout.Terminal.Y+16)) if cmd := readCommand(app); cmd != nil { t.Fatalf("passive modal mouse emitted command %+v, want none", cmd) @@ -507,12 +591,12 @@ func TestApprovalDecisionIncludesPeerIDForDuplicateNames(t *testing.T) { app.Update(ApprovalRequestMsg{PeerID: "guest-2", Peer: "Alex"}) read, _, _ := app.approvalButtonRects() - dispatchViewMouse(t, app, leftClick(read.X+read.W/2, read.Y)) + dispatchMouse(t, app, leftClick(read.X+read.W/2, read.Y)) if cmd := readCommand(app); cmd != nil { t.Fatalf("approval press emitted command %+v, want none until release", cmd) } - dispatchViewMouse(t, app, leftRelease(read.X+read.W/2, read.Y)) + dispatchMouse(t, app, leftRelease(read.X+read.W/2, read.Y)) got, ok := readCommand(app).(ApprovalDecisionCommand) if !ok { @@ -530,13 +614,13 @@ func TestTerminalMouseOnlyForwardsWhenEnabled(t *testing.T) { app.Update(tea.WindowSizeMsg{Width: 100, Height: 30}) drainCommands(app) - dispatchViewMouse(t, app, leftClick(app.layout.Terminal.X+4, app.layout.Terminal.Y+2)) + dispatchMouse(t, app, leftClick(app.layout.Terminal.X+4, app.layout.Terminal.Y+2)) if cmd := readCommand(app); cmd != nil { t.Fatalf("mouse with disabled terminal mode emitted %+v, want none", cmd) } pane.mouse = MouseMode{Enabled: true, SGR: true} - dispatchViewMouse(t, app, leftClick(app.layout.Terminal.X+4, app.layout.Terminal.Y+2)) + dispatchMouse(t, app, leftClick(app.layout.Terminal.X+4, app.layout.Terminal.Y+2)) cmd, ok := readCommand(app).(TerminalInputCommand) if !ok { t.Fatalf("command = %T, want TerminalInputCommand", cmd) @@ -603,18 +687,9 @@ func TestEncodeSGRMousePreservesShiftAndAltModifiers(t *testing.T) { } } -func dispatchViewMouse(t *testing.T, app *App, msg tea.MouseMsg) { +func dispatchMouse(t *testing.T, app *App, msg tea.MouseMsg) { t.Helper() - app.Update(viewMouseCmd(t, app, msg)()) -} - -func viewMouseCmd(t *testing.T, app *App, msg tea.MouseMsg) tea.Cmd { - t.Helper() - cmd := app.View().OnMouse(msg) - if cmd == nil { - t.Fatal("View().OnMouse returned nil command") - } - return cmd + app.Update(msg) } func leftClick(x int, y int) tea.MouseClickMsg { diff --git a/pkg/derpssh/tui/scene.go b/pkg/derpssh/tui/scene.go index 3f23219..0baf3f5 100644 --- a/pkg/derpssh/tui/scene.go +++ b/pkg/derpssh/tui/scene.go @@ -47,15 +47,6 @@ func (s Scene) TargetAt(x int, y int) layerTarget { return layerTarget(hit.ID()) } -func (s Scene) PointerCmd(capture layerTarget, msg tea.MouseMsg) tea.Cmd { - target := capture - if target == "" { - mouse := msg.Mouse() - target = s.TargetAt(mouse.X, mouse.Y) - } - return func() tea.Msg { return newPointerMsg(target, msg) } -} - func sceneLayer(id layerTarget, rect Rect, z int, content string) *lipgloss.Layer { return lipgloss.NewLayer(fitSceneContent(content, rect.W, rect.H)). ID(string(id)). diff --git a/pkg/derpssh/tui/scene_header_test.go b/pkg/derpssh/tui/scene_header_test.go index ea51914..1e8cd62 100644 --- a/pkg/derpssh/tui/scene_header_test.go +++ b/pkg/derpssh/tui/scene_header_test.go @@ -62,14 +62,14 @@ func TestPointerDispatchUsesPeerIDTarget(t *testing.T) { } } -func TestRawMouseMessageDoesNotDispatchPointerAction(t *testing.T) { +func TestRawMouseMessageDispatchesHeaderAction(t *testing.T) { app := NewApp(Options{Terminal: &fakePane{view: "ok"}}) app.Update(tea.WindowSizeMsg{Width: 100, Height: 24}) chat := topBarActionIDRect(t, app, ActionToggleChat) app.Update(clickAt(chat.X, chat.Y, tea.MouseLeft)) - if app.sidebarOpen { - t.Fatal("raw Bubble Tea mouse message dispatched a second action") + if !app.sidebarOpen { + t.Fatal("sidebarOpen = false, want true after raw toggle-chat click") } }