fix(copy): clear pointer on focused title press
- commit
- d57a9657cdc7737363410f5f888b0d4fb909302a
- author
- brice · · 2026-07-15 08:17 UTC
- committer
- brice · · 2026-07-15 09:45 UTC
- parent
- 9938807
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
2 files · +46 -0
M internal/usecase/daemon/input.go +4 -0
| @@ -95,6 +95,10 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) { | |||
| 95 | 95 | focusPlacementLocked(tb, pl.ID) | |
| 96 | 96 | d.applyLayoutLocked(tb) | |
| 97 | 97 | tb.mu.Unlock() | |
| 98 | + | // A title bar never routes to terminal content. Clear any pre-existing | |
| 99 | + | // left-button candidate before handling the focus result, including when | |
| 100 | + | // this press leaves the same pane focused. | |
| 101 | + | invalidateRejectedLeftPointer(rt, ev) | |
| 98 | 102 | if pl.ID != oldFocus { | |
| 99 | 103 | d.exitCopyMode(ac) | |
| 100 | 104 | d.refreshPaneTitleOnFocus(sess, pl.ID) | |
| @@ -95,6 +95,10 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) { | |||||
| 95 | focusPlacementLocked(tb, pl.ID) | 95 | focusPlacementLocked(tb, pl.ID) | ||
| 96 | d.applyLayoutLocked(tb) | 96 | d.applyLayoutLocked(tb) | ||
| 97 | tb.mu.Unlock() | 97 | tb.mu.Unlock() | ||
| 98 | + | // A title bar never routes to terminal content. Clear any pre-existing | |||
| 99 | + | // left-button candidate before handling the focus result, including when | |||
| 100 | + | // this press leaves the same pane focused. | |||
| 101 | + | invalidateRejectedLeftPointer(rt, ev) | |||
| 98 | if pl.ID != oldFocus { | 102 | if pl.ID != oldFocus { | ||
| 99 | d.exitCopyMode(ac) | 103 | d.exitCopyMode(ac) | ||
| 100 | d.refreshPaneTitleOnFocus(sess, pl.ID) | 104 | d.refreshPaneTitleOnFocus(sess, pl.ID) | ||
M internal/usecase/daemon/input_test.go +42 -0
| @@ -1316,6 +1316,48 @@ func TestRejectedLeftPressAndInactiveReleaseInvalidateStalePointer(t *testing.T) | |||
| 1316 | 1316 | ac.overlays.copyMu.Unlock() | |
| 1317 | 1317 | } | |
| 1318 | 1318 | ||
| 1319 | + | func TestFocusedSplitTitleBarPressInvalidatesFreshPointerBeforeMotion(t *testing.T) { | |
| 1320 | + | p, release := newBlockingPTY(t) | |
| 1321 | + | defer release() | |
| 1322 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, p) | |
| 1323 | + | tb := sess.activeTab() | |
| 1324 | + | p2 := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 8}) | |
| 1325 | + | p3 := newPane("pane-3", nil, domain.Size{Cols: 20, Rows: 1}) | |
| 1326 | + | tb.mu.Lock() | |
| 1327 | + | tb.size = domain.Size{Cols: 41, Rows: 10} | |
| 1328 | + | tb.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{ | |
| 1329 | + | layout.NewLeaf("pane-1"), | |
| 1330 | + | {Kind: layout.Stack, Children: []*layout.Node{layout.NewLeaf("pane-2"), layout.NewLeaf("pane-3")}, Expanded: "pane-2"}, | |
| 1331 | + | }} | |
| 1332 | + | tb.tree.Focus = "pane-2" | |
| 1333 | + | tb.panes["pane-2"] = p2 | |
| 1334 | + | tb.panes["pane-3"] = p3 | |
| 1335 | + | tb.mu.Unlock() | |
| 1336 | + | ||
| 1337 | + | // A content press gives pane-2 a fresh candidate. Pressing that already | |
| 1338 | + | // focused pane's title bar must make the following motion a no-op rather | |
| 1339 | + | // than reusing this pointer to publish copy mode. | |
| 1340 | + | d.handleInput(sess, ac, []byte("\x1b[<0;22;3M")) | |
| 1341 | + | ac.overlays.copyMu.Lock() | |
| 1342 | + | require.True(t, ac.overlays.copyPointer.valid) | |
| 1343 | + | ac.overlays.copyClick = copyClickCandidate{valid: true, pane: ac.overlays.copyPointer.pane} | |
| 1344 | + | epoch := ac.overlays.copyPointerEpoch | |
| 1345 | + | ac.overlays.copyMu.Unlock() | |
| 1346 | + | ||
| 1347 | + | d.handleInput(sess, ac, []byte("\x1b[<0;22;2M")) | |
| 1348 | + | ac.overlays.copyMu.Lock() | |
| 1349 | + | require.False(t, ac.overlays.copyPointer.valid) | |
| 1350 | + | require.False(t, ac.overlays.copyClick.valid) | |
| 1351 | + | require.Greater(t, ac.overlays.copyPointerEpoch, epoch) | |
| 1352 | + | ac.overlays.copyMu.Unlock() | |
| 1353 | + | require.Equal(t, layout.PaneID("pane-2"), tb.tree.Focus, "title press on focused pane must preserve focus") | |
| 1354 | + | ||
| 1355 | + | d.handleInput(sess, ac, []byte("\x1b[<32;22;4M")) | |
| 1356 | + | ac.overlays.copyMu.Lock() | |
| 1357 | + | require.Nil(t, ac.overlays.copyMode, "motion after title-bar press must not publish stale pointer state") | |
| 1358 | + | ac.overlays.copyMu.Unlock() | |
| 1359 | + | } | |
| 1360 | + | ||
| 1319 | 1361 | func TestMouseDividerAndTitleBarDoNotForwardBogusCoordinates(t *testing.T) { | |
| 1320 | 1362 | cases := []struct { | |
| 1321 | 1363 | name string | |
| @@ -1316,6 +1316,48 @@ func TestRejectedLeftPressAndInactiveReleaseInvalidateStalePointer(t *testing.T) | |||||
| 1316 | ac.overlays.copyMu.Unlock() | 1316 | ac.overlays.copyMu.Unlock() | ||
| 1317 | } | 1317 | } | ||
| 1318 | 1318 | ||||
| 1319 | + | func TestFocusedSplitTitleBarPressInvalidatesFreshPointerBeforeMotion(t *testing.T) { | |||
| 1320 | + | p, release := newBlockingPTY(t) | |||
| 1321 | + | defer release() | |||
| 1322 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, p) | |||
| 1323 | + | tb := sess.activeTab() | |||
| 1324 | + | p2 := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 8}) | |||
| 1325 | + | p3 := newPane("pane-3", nil, domain.Size{Cols: 20, Rows: 1}) | |||
| 1326 | + | tb.mu.Lock() | |||
| 1327 | + | tb.size = domain.Size{Cols: 41, Rows: 10} | |||
| 1328 | + | tb.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{ | |||
| 1329 | + | layout.NewLeaf("pane-1"), | |||
| 1330 | + | {Kind: layout.Stack, Children: []*layout.Node{layout.NewLeaf("pane-2"), layout.NewLeaf("pane-3")}, Expanded: "pane-2"}, | |||
| 1331 | + | }} | |||
| 1332 | + | tb.tree.Focus = "pane-2" | |||
| 1333 | + | tb.panes["pane-2"] = p2 | |||
| 1334 | + | tb.panes["pane-3"] = p3 | |||
| 1335 | + | tb.mu.Unlock() | |||
| 1336 | + | ||||
| 1337 | + | // A content press gives pane-2 a fresh candidate. Pressing that already | |||
| 1338 | + | // focused pane's title bar must make the following motion a no-op rather | |||
| 1339 | + | // than reusing this pointer to publish copy mode. | |||
| 1340 | + | d.handleInput(sess, ac, []byte("\x1b[<0;22;3M")) | |||
| 1341 | + | ac.overlays.copyMu.Lock() | |||
| 1342 | + | require.True(t, ac.overlays.copyPointer.valid) | |||
| 1343 | + | ac.overlays.copyClick = copyClickCandidate{valid: true, pane: ac.overlays.copyPointer.pane} | |||
| 1344 | + | epoch := ac.overlays.copyPointerEpoch | |||
| 1345 | + | ac.overlays.copyMu.Unlock() | |||
| 1346 | + | ||||
| 1347 | + | d.handleInput(sess, ac, []byte("\x1b[<0;22;2M")) | |||
| 1348 | + | ac.overlays.copyMu.Lock() | |||
| 1349 | + | require.False(t, ac.overlays.copyPointer.valid) | |||
| 1350 | + | require.False(t, ac.overlays.copyClick.valid) | |||
| 1351 | + | require.Greater(t, ac.overlays.copyPointerEpoch, epoch) | |||
| 1352 | + | ac.overlays.copyMu.Unlock() | |||
| 1353 | + | require.Equal(t, layout.PaneID("pane-2"), tb.tree.Focus, "title press on focused pane must preserve focus") | |||
| 1354 | + | ||||
| 1355 | + | d.handleInput(sess, ac, []byte("\x1b[<32;22;4M")) | |||
| 1356 | + | ac.overlays.copyMu.Lock() | |||
| 1357 | + | require.Nil(t, ac.overlays.copyMode, "motion after title-bar press must not publish stale pointer state") | |||
| 1358 | + | ac.overlays.copyMu.Unlock() | |||
| 1359 | + | } | |||
| 1360 | + | ||||
| 1319 | func TestMouseDividerAndTitleBarDoNotForwardBogusCoordinates(t *testing.T) { | 1361 | func TestMouseDividerAndTitleBarDoNotForwardBogusCoordinates(t *testing.T) { | ||
| 1320 | cases := []struct { | 1362 | cases := []struct { | ||
| 1321 | name string | 1363 | name string | ||