fix(copy): preserve mouse routing across overlays
- commit
- 1f016aac4f7aa69e37052a8cf6fdd7b82344eb22
- author
- brice · · 2026-07-15 10:15 UTC
- committer
- brice · · 2026-07-15 10:19 UTC
- parent
- 7ab375d
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
3 files · +105 -25
M internal/usecase/daemon/copy_mouse_input.go +25 -25
| @@ -13,15 +13,21 @@ func (d *Daemon) handleCopyMouse(sess *session, ac *attachedClient, tb *tab, ev | |||
| 13 | 13 | if !rt.copyActive() { | |
| 14 | 14 | return false | |
| 15 | 15 | } | |
| 16 | + | if rt.copySearchActive() { | |
| 17 | + | // Search owns ordinary mouse input, but a release completes an | |
| 18 | + | // in-flight drag. Route it through the active mapper so its endpoint is | |
| 19 | + | // clamped against the press-owned geometry before clearing the pointer. | |
| 20 | + | if ev.Button == mouse.Left && ev.Type == mouse.Release { | |
| 21 | + | d.handleActiveCopyMouse(sess, ac, tb, ev) | |
| 22 | + | } | |
| 23 | + | return true | |
| 24 | + | } | |
| 16 | 25 | if ev.Button == mouse.Left && ev.Type == mouse.Release { | |
| 17 | 26 | rt.copyMu.Lock() | |
| 18 | 27 | rt.invalidateCopyPointerLocked(false) | |
| 19 | 28 | rt.copyMu.Unlock() | |
| 20 | 29 | return true | |
| 21 | 30 | } | |
| 22 | - | if rt.copySearchActive() { | |
| 23 | - | return true | |
| 24 | - | } | |
| 25 | 31 | switch ev.Button { | |
| 26 | 32 | case mouse.WheelUp: | |
| 27 | 33 | d.copyWheel(sess, ac, -3) | |
| @@ -251,7 +257,7 @@ func (d *Daemon) handleActiveCopyMouse(sess *session, ac *attachedClient, tb *ta | |||
| 251 | 257 | // later floating/layout change must not turn an in-flight drag into a hit | |
| 252 | 258 | // on a different pane. | |
| 253 | 259 | if ev.Type != mouse.Press && snapshot.pointer.valid && snapshot.pointer.pane == snapshot.pane && snapshot.pointer.document == snapshot.document { | |
| 254 | - | mapped, ok := mapCopyMouse(ev, snapshot.pointer.geometry, snapshot.viewportTop, snapshot.document, ev.Type == mouse.Motion) | |
| 260 | + | mapped, ok := mapCopyMouse(ev, snapshot.pointer.geometry, snapshot.viewportTop, snapshot.document, ev.Type == mouse.Motion || ev.Type == mouse.Release) | |
| 255 | 261 | if ev.Type == mouse.Release && !ok { | |
| 256 | 262 | invalidateSnapshotCopyPointer(rt, snapshot) | |
| 257 | 263 | return | |
| @@ -263,15 +269,24 @@ func (d *Daemon) handleActiveCopyMouse(sess *session, ac *attachedClient, tb *ta | |||
| 263 | 269 | } | |
| 264 | 270 | tb.mu.Lock() | |
| 265 | 271 | geometry, ok := copyMouseGeometryForPaneLocked(tb, cfg, snapshot.pane) | |
| 266 | - | if !ok && ev.Type == mouse.Press { | |
| 267 | - | // Preserve the established press-on-another-pane behavior: copy mode | |
| 268 | - | // exits rather than retargeting its immutable document. | |
| 269 | - | if hit, hitOK := hitTestCopyMouseGeometryLocked(tb, cfg, ev.Col, ev.Row); hitOK && hit.pane != snapshot.pane && tb.tree != nil { | |
| 270 | - | focusPlacementLocked(tb, hit.pane.id) | |
| 271 | - | d.applyLayoutLocked(tb) | |
| 272 | + | otherPane := false | |
| 273 | + | if ev.Type == mouse.Press { | |
| 274 | + | // A new press is allowed to change focus, but never the document owned | |
| 275 | + | // by the active copy mode. Do all tab work first; exitCopyMode takes | |
| 276 | + | // copyMu only after tb.mu is released. | |
| 277 | + | if hit, hitOK := hitTestCopyMouseGeometryLocked(tb, cfg, ev.Col, ev.Row); hitOK && hit.pane != snapshot.pane { | |
| 278 | + | otherPane = true | |
| 279 | + | if tb.tree != nil { | |
| 280 | + | focusPlacementLocked(tb, hit.pane.id) | |
| 281 | + | d.applyLayoutLocked(tb) | |
| 282 | + | } | |
| 272 | 283 | } | |
| 273 | 284 | } | |
| 274 | 285 | tb.mu.Unlock() | |
| 286 | + | if otherPane { | |
| 287 | + | d.exitCopyMode(ac) | |
| 288 | + | return | |
| 289 | + | } | |
| 275 | 290 | if !ok { | |
| 276 | 291 | if ev.Type == mouse.Release || ev.Type == mouse.Press { | |
| 277 | 292 | invalidateSnapshotCopyPointer(rt, snapshot) | |
| @@ -281,21 +296,6 @@ func (d *Daemon) handleActiveCopyMouse(sess *session, ac *attachedClient, tb *ta | |||
| 281 | 296 | clamp := ev.Type == mouse.Motion || ev.Type == mouse.Release | |
| 282 | 297 | mapped, mappedOK := mapCopyMouse(ev, geometry, snapshot.viewportTop, snapshot.document, clamp) | |
| 283 | 298 | if !mappedOK { | |
| 284 | - | if ev.Type == mouse.Press { | |
| 285 | - | otherPane := false | |
| 286 | - | tb.mu.Lock() | |
| 287 | - | if hit, hitOK := hitTestCopyMouseGeometryLocked(tb, cfg, ev.Col, ev.Row); hitOK && hit.pane != snapshot.pane { | |
| 288 | - | otherPane = true | |
| 289 | - | if tb.tree != nil { | |
| 290 | - | focusPlacementLocked(tb, hit.pane.id) | |
| 291 | - | d.applyLayoutLocked(tb) | |
| 292 | - | } | |
| 293 | - | } | |
| 294 | - | tb.mu.Unlock() | |
| 295 | - | if otherPane { | |
| 296 | - | d.exitCopyMode(ac) | |
| 297 | - | } | |
| 298 | - | } | |
| 299 | 299 | if ev.Type == mouse.Release || ev.Type == mouse.Press { | |
| 300 | 300 | invalidateSnapshotCopyPointer(rt, snapshot) | |
| 301 | 301 | } | |
| @@ -13,15 +13,21 @@ func (d *Daemon) handleCopyMouse(sess *session, ac *attachedClient, tb *tab, ev | |||||
| 13 | if !rt.copyActive() { | 13 | if !rt.copyActive() { | ||
| 14 | return false | 14 | return false | ||
| 15 | } | 15 | } | ||
| 16 | + | if rt.copySearchActive() { | |||
| 17 | + | // Search owns ordinary mouse input, but a release completes an | |||
| 18 | + | // in-flight drag. Route it through the active mapper so its endpoint is | |||
| 19 | + | // clamped against the press-owned geometry before clearing the pointer. | |||
| 20 | + | if ev.Button == mouse.Left && ev.Type == mouse.Release { | |||
| 21 | + | d.handleActiveCopyMouse(sess, ac, tb, ev) | |||
| 22 | + | } | |||
| 23 | + | return true | |||
| 24 | + | } | |||
| 16 | if ev.Button == mouse.Left && ev.Type == mouse.Release { | 25 | if ev.Button == mouse.Left && ev.Type == mouse.Release { | ||
| 17 | rt.copyMu.Lock() | 26 | rt.copyMu.Lock() | ||
| 18 | rt.invalidateCopyPointerLocked(false) | 27 | rt.invalidateCopyPointerLocked(false) | ||
| 19 | rt.copyMu.Unlock() | 28 | rt.copyMu.Unlock() | ||
| 20 | return true | 29 | return true | ||
| 21 | } | 30 | } | ||
| 22 | - | if rt.copySearchActive() { | |||
| 23 | - | return true | |||
| 24 | - | } | |||
| 25 | switch ev.Button { | 31 | switch ev.Button { | ||
| 26 | case mouse.WheelUp: | 32 | case mouse.WheelUp: | ||
| 27 | d.copyWheel(sess, ac, -3) | 33 | d.copyWheel(sess, ac, -3) | ||
| @@ -251,7 +257,7 @@ func (d *Daemon) handleActiveCopyMouse(sess *session, ac *attachedClient, tb *ta | |||||
| 251 | // later floating/layout change must not turn an in-flight drag into a hit | 257 | // later floating/layout change must not turn an in-flight drag into a hit | ||
| 252 | // on a different pane. | 258 | // on a different pane. | ||
| 253 | if ev.Type != mouse.Press && snapshot.pointer.valid && snapshot.pointer.pane == snapshot.pane && snapshot.pointer.document == snapshot.document { | 259 | if ev.Type != mouse.Press && snapshot.pointer.valid && snapshot.pointer.pane == snapshot.pane && snapshot.pointer.document == snapshot.document { | ||
| 254 | - | mapped, ok := mapCopyMouse(ev, snapshot.pointer.geometry, snapshot.viewportTop, snapshot.document, ev.Type == mouse.Motion) | 260 | + | mapped, ok := mapCopyMouse(ev, snapshot.pointer.geometry, snapshot.viewportTop, snapshot.document, ev.Type == mouse.Motion || ev.Type == mouse.Release) |
| 255 | if ev.Type == mouse.Release && !ok { | 261 | if ev.Type == mouse.Release && !ok { | ||
| 256 | invalidateSnapshotCopyPointer(rt, snapshot) | 262 | invalidateSnapshotCopyPointer(rt, snapshot) | ||
| 257 | return | 263 | return | ||
| @@ -263,15 +269,24 @@ func (d *Daemon) handleActiveCopyMouse(sess *session, ac *attachedClient, tb *ta | |||||
| 263 | } | 269 | } | ||
| 264 | tb.mu.Lock() | 270 | tb.mu.Lock() | ||
| 265 | geometry, ok := copyMouseGeometryForPaneLocked(tb, cfg, snapshot.pane) | 271 | geometry, ok := copyMouseGeometryForPaneLocked(tb, cfg, snapshot.pane) | ||
| 266 | - | if !ok && ev.Type == mouse.Press { | 272 | + | otherPane := false |
| 267 | - | // Preserve the established press-on-another-pane behavior: copy mode | 273 | + | if ev.Type == mouse.Press { |
| 268 | - | // exits rather than retargeting its immutable document. | 274 | + | // A new press is allowed to change focus, but never the document owned |
| 269 | - | if hit, hitOK := hitTestCopyMouseGeometryLocked(tb, cfg, ev.Col, ev.Row); hitOK && hit.pane != snapshot.pane && tb.tree != nil { | 275 | + | // by the active copy mode. Do all tab work first; exitCopyMode takes |
| 270 | - | focusPlacementLocked(tb, hit.pane.id) | 276 | + | // copyMu only after tb.mu is released. |
| 271 | - | d.applyLayoutLocked(tb) | 277 | + | if hit, hitOK := hitTestCopyMouseGeometryLocked(tb, cfg, ev.Col, ev.Row); hitOK && hit.pane != snapshot.pane { |
| 278 | + | otherPane = true | |||
| 279 | + | if tb.tree != nil { | |||
| 280 | + | focusPlacementLocked(tb, hit.pane.id) | |||
| 281 | + | d.applyLayoutLocked(tb) | |||
| 282 | + | } | |||
| 272 | } | 283 | } | ||
| 273 | } | 284 | } | ||
| 274 | tb.mu.Unlock() | 285 | tb.mu.Unlock() | ||
| 286 | + | if otherPane { | |||
| 287 | + | d.exitCopyMode(ac) | |||
| 288 | + | return | |||
| 289 | + | } | |||
| 275 | if !ok { | 290 | if !ok { | ||
| 276 | if ev.Type == mouse.Release || ev.Type == mouse.Press { | 291 | if ev.Type == mouse.Release || ev.Type == mouse.Press { | ||
| 277 | invalidateSnapshotCopyPointer(rt, snapshot) | 292 | invalidateSnapshotCopyPointer(rt, snapshot) | ||
| @@ -281,21 +296,6 @@ func (d *Daemon) handleActiveCopyMouse(sess *session, ac *attachedClient, tb *ta | |||||
| 281 | clamp := ev.Type == mouse.Motion || ev.Type == mouse.Release | 296 | clamp := ev.Type == mouse.Motion || ev.Type == mouse.Release | ||
| 282 | mapped, mappedOK := mapCopyMouse(ev, geometry, snapshot.viewportTop, snapshot.document, clamp) | 297 | mapped, mappedOK := mapCopyMouse(ev, geometry, snapshot.viewportTop, snapshot.document, clamp) | ||
| 283 | if !mappedOK { | 298 | if !mappedOK { | ||
| 284 | - | if ev.Type == mouse.Press { | |||
| 285 | - | otherPane := false | |||
| 286 | - | tb.mu.Lock() | |||
| 287 | - | if hit, hitOK := hitTestCopyMouseGeometryLocked(tb, cfg, ev.Col, ev.Row); hitOK && hit.pane != snapshot.pane { | |||
| 288 | - | otherPane = true | |||
| 289 | - | if tb.tree != nil { | |||
| 290 | - | focusPlacementLocked(tb, hit.pane.id) | |||
| 291 | - | d.applyLayoutLocked(tb) | |||
| 292 | - | } | |||
| 293 | - | } | |||
| 294 | - | tb.mu.Unlock() | |||
| 295 | - | if otherPane { | |||
| 296 | - | d.exitCopyMode(ac) | |||
| 297 | - | } | |||
| 298 | - | } | |||
| 299 | if ev.Type == mouse.Release || ev.Type == mouse.Press { | 299 | if ev.Type == mouse.Release || ev.Type == mouse.Press { | ||
| 300 | invalidateSnapshotCopyPointer(rt, snapshot) | 300 | invalidateSnapshotCopyPointer(rt, snapshot) | ||
| 301 | } | 301 | } | ||
M internal/usecase/daemon/copymode.go +8 -0
| @@ -193,6 +193,14 @@ func (d *Daemon) copyMouse(sess *session, ac *attachedClient, ev mouse.Event, ma | |||
| 193 | 193 | rt.copyPointer = pointer | |
| 194 | 194 | } | |
| 195 | 195 | case mouse.Release: | |
| 196 | + | pointer := rt.copyPointer | |
| 197 | + | if pointer.valid && pointer.epoch == snapshot.epoch && pointer.pane == mapped.pane && pointer.document == rt.copyDocument && pointer.dragging { | |
| 198 | + | if pointer.wordDrag { | |
| 199 | + | changed = rt.copyMode.ExtendWordSelection(mapped.pos) || changed | |
| 200 | + | } else { | |
| 201 | + | changed = rt.copyMode.ExtendCharacterSelection(mapped.pos) || changed | |
| 202 | + | } | |
| 203 | + | } | |
| 196 | 204 | rt.invalidateCopyPointerLocked(false) | |
| 197 | 205 | } | |
| 198 | 206 | rt.copyMu.Unlock() | |
| @@ -193,6 +193,14 @@ func (d *Daemon) copyMouse(sess *session, ac *attachedClient, ev mouse.Event, ma | |||||
| 193 | rt.copyPointer = pointer | 193 | rt.copyPointer = pointer | ||
| 194 | } | 194 | } | ||
| 195 | case mouse.Release: | 195 | case mouse.Release: | ||
| 196 | + | pointer := rt.copyPointer | |||
| 197 | + | if pointer.valid && pointer.epoch == snapshot.epoch && pointer.pane == mapped.pane && pointer.document == rt.copyDocument && pointer.dragging { | |||
| 198 | + | if pointer.wordDrag { | |||
| 199 | + | changed = rt.copyMode.ExtendWordSelection(mapped.pos) || changed | |||
| 200 | + | } else { | |||
| 201 | + | changed = rt.copyMode.ExtendCharacterSelection(mapped.pos) || changed | |||
| 202 | + | } | |||
| 203 | + | } | |||
| 196 | rt.invalidateCopyPointerLocked(false) | 204 | rt.invalidateCopyPointerLocked(false) | ||
| 197 | } | 205 | } | ||
| 198 | rt.copyMu.Unlock() | 206 | rt.copyMu.Unlock() | ||
M internal/usecase/daemon/input_test.go +72 -0
| @@ -1473,6 +1473,78 @@ func TestMouseCollapsedStackBarExpandsAndFocuses(t *testing.T) { | |||
| 1473 | 1473 | require.Equal(t, layout.PaneID("pane-2"), tb.tree.Root.Expanded) | |
| 1474 | 1474 | } | |
| 1475 | 1475 | ||
| 1476 | + | func TestActiveCopyPressOnOtherPaneFocusesAndExitsBeforeFutureInput(t *testing.T) { | |
| 1477 | + | p1, release := newBlockingPTY(t) | |
| 1478 | + | defer release() | |
| 1479 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, p1) | |
| 1480 | + | tb := sess.activeTab() | |
| 1481 | + | p2 := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 10}) | |
| 1482 | + | tb.mu.Lock() | |
| 1483 | + | tb.size = domain.Size{Cols: 41, Rows: 10} | |
| 1484 | + | tb.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}} | |
| 1485 | + | tb.panes["pane-2"] = p2 | |
| 1486 | + | tb.mu.Unlock() | |
| 1487 | + | ||
| 1488 | + | d.enterCopyMode(sess, ac) | |
| 1489 | + | require.True(t, ac.overlays.copyActive()) | |
| 1490 | + | d.handleInput(sess, ac, []byte("\x1b[<0;1;2M")) | |
| 1491 | + | ac.overlays.copyMu.Lock() | |
| 1492 | + | require.True(t, ac.overlays.copyPointer.valid) | |
| 1493 | + | require.True(t, ac.overlays.copyClick.valid) | |
| 1494 | + | ac.overlays.copyMu.Unlock() | |
| 1495 | + | ||
| 1496 | + | // A press in the right split must focus it, then leave the old pane's copy | |
| 1497 | + | // snapshot behind. The following press is therefore a fresh p2 candidate. | |
| 1498 | + | d.handleInput(sess, ac, []byte("\x1b[<0;22;2M")) | |
| 1499 | + | require.Equal(t, layout.PaneID("pane-2"), tb.tree.Focus) | |
| 1500 | + | require.False(t, ac.overlays.copyActive()) | |
| 1501 | + | ac.overlays.copyMu.Lock() | |
| 1502 | + | require.False(t, ac.overlays.copyPointer.valid) | |
| 1503 | + | require.False(t, ac.overlays.copyClick.valid) | |
| 1504 | + | ac.overlays.copyMu.Unlock() | |
| 1505 | + | ||
| 1506 | + | d.handleInput(sess, ac, []byte("\x1b[<0;22;2M")) | |
| 1507 | + | ac.overlays.copyMu.Lock() | |
| 1508 | + | pointer := ac.overlays.copyPointer | |
| 1509 | + | ac.overlays.copyMu.Unlock() | |
| 1510 | + | require.True(t, pointer.valid) | |
| 1511 | + | require.Same(t, p2, pointer.pane) | |
| 1512 | + | } | |
| 1513 | + | ||
| 1514 | + | func TestCopySearchReleaseMapsFinalEndpointAndInvalidatesPointer(t *testing.T) { | |
| 1515 | + | for _, tc := range []struct { | |
| 1516 | + | name string | |
| 1517 | + | release string | |
| 1518 | + | wantCol int | |
| 1519 | + | }{ | |
| 1520 | + | {name: "in pane", release: "\x1b[<0;8;2m", wantCol: 7}, | |
| 1521 | + | {name: "outside pane clamps", release: "\x1b[<0;99;2m", wantCol: 79}, | |
| 1522 | + | } { | |
| 1523 | + | t.Run(tc.name, func(t *testing.T) { | |
| 1524 | + | d, sess, ac, _ := mouseCopyHarness(t, "alpha bravo") | |
| 1525 | + | ||
| 1526 | + | copyMouseInput(d, sess, ac, "\x1b[<0;1;2M\x1b[<32;5;2M") | |
| 1527 | + | copyMouseInput(d, sess, ac, "/") | |
| 1528 | + | require.True(t, ac.overlays.copySearchActive()) | |
| 1529 | + | ||
| 1530 | + | // Search owns ordinary mouse input, but the release still completes | |
| 1531 | + | // the active drag against its press-owned geometry. | |
| 1532 | + | copyMouseInput(d, sess, ac, tc.release) | |
| 1533 | + | copyMouseInput(d, sess, ac, "\x03") | |
| 1534 | + | ||
| 1535 | + | ac.overlays.copyMu.Lock() | |
| 1536 | + | selection := ac.overlays.copyMode.Selection() | |
| 1537 | + | pointerValid := ac.overlays.copyPointer.valid | |
| 1538 | + | ac.overlays.copyMu.Unlock() | |
| 1539 | + | require.True(t, selection.Enabled) | |
| 1540 | + | require.Equal(t, scopy.Pos{Row: 0, Col: 0}, selection.Anchor) | |
| 1541 | + | require.Equal(t, scopy.Pos{Row: 0, Col: tc.wantCol}, selection.Active) | |
| 1542 | + | require.False(t, pointerValid) | |
| 1543 | + | require.False(t, ac.overlays.copySearchActive()) | |
| 1544 | + | }) | |
| 1545 | + | } | |
| 1546 | + | } | |
| 1547 | + | ||
| 1476 | 1548 | // TestActiveCopyMouseIgnoresStalePointerResets makes the mapping/reset race | |
| 1477 | 1549 | // deterministic. Each path captures a copy-input snapshot, yields while that | |
| 1478 | 1550 | // snapshot is outside copyMu, then starts a new pointer. The old event must | |
| @@ -1473,6 +1473,78 @@ func TestMouseCollapsedStackBarExpandsAndFocuses(t *testing.T) { | |||||
| 1473 | require.Equal(t, layout.PaneID("pane-2"), tb.tree.Root.Expanded) | 1473 | require.Equal(t, layout.PaneID("pane-2"), tb.tree.Root.Expanded) | ||
| 1474 | } | 1474 | } | ||
| 1475 | 1475 | ||||
| 1476 | + | func TestActiveCopyPressOnOtherPaneFocusesAndExitsBeforeFutureInput(t *testing.T) { | |||
| 1477 | + | p1, release := newBlockingPTY(t) | |||
| 1478 | + | defer release() | |||
| 1479 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, p1) | |||
| 1480 | + | tb := sess.activeTab() | |||
| 1481 | + | p2 := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 10}) | |||
| 1482 | + | tb.mu.Lock() | |||
| 1483 | + | tb.size = domain.Size{Cols: 41, Rows: 10} | |||
| 1484 | + | tb.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}} | |||
| 1485 | + | tb.panes["pane-2"] = p2 | |||
| 1486 | + | tb.mu.Unlock() | |||
| 1487 | + | ||||
| 1488 | + | d.enterCopyMode(sess, ac) | |||
| 1489 | + | require.True(t, ac.overlays.copyActive()) | |||
| 1490 | + | d.handleInput(sess, ac, []byte("\x1b[<0;1;2M")) | |||
| 1491 | + | ac.overlays.copyMu.Lock() | |||
| 1492 | + | require.True(t, ac.overlays.copyPointer.valid) | |||
| 1493 | + | require.True(t, ac.overlays.copyClick.valid) | |||
| 1494 | + | ac.overlays.copyMu.Unlock() | |||
| 1495 | + | ||||
| 1496 | + | // A press in the right split must focus it, then leave the old pane's copy | |||
| 1497 | + | // snapshot behind. The following press is therefore a fresh p2 candidate. | |||
| 1498 | + | d.handleInput(sess, ac, []byte("\x1b[<0;22;2M")) | |||
| 1499 | + | require.Equal(t, layout.PaneID("pane-2"), tb.tree.Focus) | |||
| 1500 | + | require.False(t, ac.overlays.copyActive()) | |||
| 1501 | + | ac.overlays.copyMu.Lock() | |||
| 1502 | + | require.False(t, ac.overlays.copyPointer.valid) | |||
| 1503 | + | require.False(t, ac.overlays.copyClick.valid) | |||
| 1504 | + | ac.overlays.copyMu.Unlock() | |||
| 1505 | + | ||||
| 1506 | + | d.handleInput(sess, ac, []byte("\x1b[<0;22;2M")) | |||
| 1507 | + | ac.overlays.copyMu.Lock() | |||
| 1508 | + | pointer := ac.overlays.copyPointer | |||
| 1509 | + | ac.overlays.copyMu.Unlock() | |||
| 1510 | + | require.True(t, pointer.valid) | |||
| 1511 | + | require.Same(t, p2, pointer.pane) | |||
| 1512 | + | } | |||
| 1513 | + | ||||
| 1514 | + | func TestCopySearchReleaseMapsFinalEndpointAndInvalidatesPointer(t *testing.T) { | |||
| 1515 | + | for _, tc := range []struct { | |||
| 1516 | + | name string | |||
| 1517 | + | release string | |||
| 1518 | + | wantCol int | |||
| 1519 | + | }{ | |||
| 1520 | + | {name: "in pane", release: "\x1b[<0;8;2m", wantCol: 7}, | |||
| 1521 | + | {name: "outside pane clamps", release: "\x1b[<0;99;2m", wantCol: 79}, | |||
| 1522 | + | } { | |||
| 1523 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 1524 | + | d, sess, ac, _ := mouseCopyHarness(t, "alpha bravo") | |||
| 1525 | + | ||||
| 1526 | + | copyMouseInput(d, sess, ac, "\x1b[<0;1;2M\x1b[<32;5;2M") | |||
| 1527 | + | copyMouseInput(d, sess, ac, "/") | |||
| 1528 | + | require.True(t, ac.overlays.copySearchActive()) | |||
| 1529 | + | ||||
| 1530 | + | // Search owns ordinary mouse input, but the release still completes | |||
| 1531 | + | // the active drag against its press-owned geometry. | |||
| 1532 | + | copyMouseInput(d, sess, ac, tc.release) | |||
| 1533 | + | copyMouseInput(d, sess, ac, "\x03") | |||
| 1534 | + | ||||
| 1535 | + | ac.overlays.copyMu.Lock() | |||
| 1536 | + | selection := ac.overlays.copyMode.Selection() | |||
| 1537 | + | pointerValid := ac.overlays.copyPointer.valid | |||
| 1538 | + | ac.overlays.copyMu.Unlock() | |||
| 1539 | + | require.True(t, selection.Enabled) | |||
| 1540 | + | require.Equal(t, scopy.Pos{Row: 0, Col: 0}, selection.Anchor) | |||
| 1541 | + | require.Equal(t, scopy.Pos{Row: 0, Col: tc.wantCol}, selection.Active) | |||
| 1542 | + | require.False(t, pointerValid) | |||
| 1543 | + | require.False(t, ac.overlays.copySearchActive()) | |||
| 1544 | + | }) | |||
| 1545 | + | } | |||
| 1546 | + | } | |||
| 1547 | + | ||||
| 1476 | // TestActiveCopyMouseIgnoresStalePointerResets makes the mapping/reset race | 1548 | // TestActiveCopyMouseIgnoresStalePointerResets makes the mapping/reset race | ||
| 1477 | // deterministic. Each path captures a copy-input snapshot, yields while that | 1549 | // deterministic. Each path captures a copy-input snapshot, yields while that | ||
| 1478 | // snapshot is outside copyMu, then starts a new pointer. The old event must | 1550 | // snapshot is outside copyMu, then starts a new pointer. The old event must | ||