fix(daemon): capture copy source on drag entry
- commit
- 50dce664c2d658fffd9460243483229ca9276d5b
- author
- brice · · 2026-07-10 07:45 UTC
- parent
- a1d7783
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
4 files · +37 -3
M internal/usecase/daemon/copymode.go +5 -1
| @@ -437,7 +437,11 @@ func composeCopyClientFrame(mode *scopy.Mode, p *pane, target domain.Rect, frame | |||
| 437 | 437 | } | |
| 438 | 438 | } | |
| 439 | 439 | statusY := frame.Height - 1 | |
| 440 | - | clear(frame.Row(statusY)) | |
| 440 | + | statusRow := frame.Row(statusY) | |
| 441 | + | blank := renderer.BlankCell() | |
| 442 | + | for x := range statusRow { | |
| 443 | + | statusRow[x] = blank | |
| 444 | + | } | |
| 441 | 445 | copy(frame.Row(statusY), copyFrame.Row(copyFrame.Height - 1)[:min(frame.Width, copyFrame.Width)]) | |
| 442 | 446 | return frame, []renderer.Damage{renderer.FullRedraw()} | |
| 443 | 447 | } | |
| @@ -437,7 +437,11 @@ func composeCopyClientFrame(mode *scopy.Mode, p *pane, target domain.Rect, frame | |||||
| 437 | } | 437 | } | ||
| 438 | } | 438 | } | ||
| 439 | statusY := frame.Height - 1 | 439 | statusY := frame.Height - 1 | ||
| 440 | - | clear(frame.Row(statusY)) | 440 | + | statusRow := frame.Row(statusY) |
| 441 | + | blank := renderer.BlankCell() | |||
| 442 | + | for x := range statusRow { | |||
| 443 | + | statusRow[x] = blank | |||
| 444 | + | } | |||
| 441 | copy(frame.Row(statusY), copyFrame.Row(copyFrame.Height - 1)[:min(frame.Width, copyFrame.Width)]) | 445 | copy(frame.Row(statusY), copyFrame.Row(copyFrame.Height - 1)[:min(frame.Width, copyFrame.Width)]) | ||
| 442 | return frame, []renderer.Damage{renderer.FullRedraw()} | 446 | return frame, []renderer.Damage{renderer.FullRedraw()} | ||
| 443 | } | 447 | } | ||
M internal/usecase/daemon/copymode_test.go +27 -2
| @@ -752,12 +752,14 @@ func TestFloatingCopyModeMouseSelectsFloatingRows(t *testing.T) { | |||
| 752 | 752 | viewportTop := ac.overlays.copyMode.ViewportTop | |
| 753 | 753 | inner := calculateFloatingGeometry(domain.Rect{Width: 80, Height: 23}, d.currentFloatingConfig()).Inner | |
| 754 | 754 | ||
| 755 | - | press := fmt.Sprintf("\x1b[<0;%d;%dM", inner.X+3, inner.Y+2) | |
| 755 | + | // Copy body row k renders at wire row inner.Y+k+2 (top bar + content | |
| 756 | + | // offset); clicking a row must select exactly that row. | |
| 757 | + | press := fmt.Sprintf("\x1b[<0;%d;%dM", inner.X+3, inner.Y+3) | |
| 756 | 758 | d.handleInput(sess, ac, []byte(press)) | |
| 757 | 759 | awaitFrame(t, sends, ports.MsgOutput) | |
| 758 | 760 | require.Equal(t, viewportTop+1, ac.overlays.copyMode.Cursor) | |
| 759 | 761 | ||
| 760 | - | motion := fmt.Sprintf("\x1b[<32;%d;%dM", inner.X+3, inner.Y+3) | |
| 762 | + | motion := fmt.Sprintf("\x1b[<32;%d;%dM", inner.X+3, inner.Y+4) | |
| 761 | 763 | d.handleInput(sess, ac, []byte(motion)) | |
| 762 | 764 | awaitFrame(t, sends, ports.MsgOutput) | |
| 763 | 765 | lo, hi, ok := ac.overlays.copyMode.SelectedBounds() | |
| @@ -790,3 +792,26 @@ func TestFloatingExitClearsCopyModeBeforeRepaint(t *testing.T) { | |||
| 790 | 792 | require.NotContains(t, string(data), "[SCROLL]") | |
| 791 | 793 | require.NotContains(t, string(data), "flt-live") | |
| 792 | 794 | } | |
| 795 | + | ||
| 796 | + | func TestMouseDragCopyEntryCapturesSourceForYank(t *testing.T) { | |
| 797 | + | pty, _ := newBlockingPTY(t) | |
| 798 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, pty) | |
| 799 | + | pane := sess.tabs[0].focusedPane() | |
| 800 | + | copy(pane.screen.Frame.Row(0), testRow("alpha")) | |
| 801 | + | copy(pane.screen.Frame.Row(1), testRow("bravo")) | |
| 802 | + | ||
| 803 | + | d.handleInput(sess, ac, []byte("\x1b[<0;1;1M\x1b[<32;1;2M")) | |
| 804 | + | mustOutputData(t, sends) | |
| 805 | + | require.NotNil(t, ac.overlays.copyMode) | |
| 806 | + | ac.overlays.copyMu.Lock() | |
| 807 | + | captured := ac.overlays.copyPane | |
| 808 | + | ac.overlays.copyMu.Unlock() | |
| 809 | + | require.Same(t, pane, captured, "drag entry must capture the copy source pane") | |
| 810 | + | ||
| 811 | + | d.handleInput(sess, ac, []byte("y")) | |
| 812 | + | out := awaitFrame(t, sends, ports.MsgOutput) | |
| 813 | + | msg, err := ports.UnmarshalOutput(out.Payload) | |
| 814 | + | require.NoError(t, err) | |
| 815 | + | want := "\x1b]52;c;" + base64.StdEncoding.EncodeToString([]byte("alpha\nbravo")) + "\x07" | |
| 816 | + | require.Equal(t, want, string(msg.Data)) | |
| 817 | + | } | |
| @@ -752,12 +752,14 @@ func TestFloatingCopyModeMouseSelectsFloatingRows(t *testing.T) { | |||||
| 752 | viewportTop := ac.overlays.copyMode.ViewportTop | 752 | viewportTop := ac.overlays.copyMode.ViewportTop | ||
| 753 | inner := calculateFloatingGeometry(domain.Rect{Width: 80, Height: 23}, d.currentFloatingConfig()).Inner | 753 | inner := calculateFloatingGeometry(domain.Rect{Width: 80, Height: 23}, d.currentFloatingConfig()).Inner | ||
| 754 | 754 | ||||
| 755 | - | press := fmt.Sprintf("\x1b[<0;%d;%dM", inner.X+3, inner.Y+2) | 755 | + | // Copy body row k renders at wire row inner.Y+k+2 (top bar + content |
| 756 | + | // offset); clicking a row must select exactly that row. | |||
| 757 | + | press := fmt.Sprintf("\x1b[<0;%d;%dM", inner.X+3, inner.Y+3) | |||
| 756 | d.handleInput(sess, ac, []byte(press)) | 758 | d.handleInput(sess, ac, []byte(press)) | ||
| 757 | awaitFrame(t, sends, ports.MsgOutput) | 759 | awaitFrame(t, sends, ports.MsgOutput) | ||
| 758 | require.Equal(t, viewportTop+1, ac.overlays.copyMode.Cursor) | 760 | require.Equal(t, viewportTop+1, ac.overlays.copyMode.Cursor) | ||
| 759 | 761 | ||||
| 760 | - | motion := fmt.Sprintf("\x1b[<32;%d;%dM", inner.X+3, inner.Y+3) | 762 | + | motion := fmt.Sprintf("\x1b[<32;%d;%dM", inner.X+3, inner.Y+4) |
| 761 | d.handleInput(sess, ac, []byte(motion)) | 763 | d.handleInput(sess, ac, []byte(motion)) | ||
| 762 | awaitFrame(t, sends, ports.MsgOutput) | 764 | awaitFrame(t, sends, ports.MsgOutput) | ||
| 763 | lo, hi, ok := ac.overlays.copyMode.SelectedBounds() | 765 | lo, hi, ok := ac.overlays.copyMode.SelectedBounds() | ||
| @@ -790,3 +792,26 @@ func TestFloatingExitClearsCopyModeBeforeRepaint(t *testing.T) { | |||||
| 790 | require.NotContains(t, string(data), "[SCROLL]") | 792 | require.NotContains(t, string(data), "[SCROLL]") | ||
| 791 | require.NotContains(t, string(data), "flt-live") | 793 | require.NotContains(t, string(data), "flt-live") | ||
| 792 | } | 794 | } | ||
| 795 | + | ||||
| 796 | + | func TestMouseDragCopyEntryCapturesSourceForYank(t *testing.T) { | |||
| 797 | + | pty, _ := newBlockingPTY(t) | |||
| 798 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, pty) | |||
| 799 | + | pane := sess.tabs[0].focusedPane() | |||
| 800 | + | copy(pane.screen.Frame.Row(0), testRow("alpha")) | |||
| 801 | + | copy(pane.screen.Frame.Row(1), testRow("bravo")) | |||
| 802 | + | ||||
| 803 | + | d.handleInput(sess, ac, []byte("\x1b[<0;1;1M\x1b[<32;1;2M")) | |||
| 804 | + | mustOutputData(t, sends) | |||
| 805 | + | require.NotNil(t, ac.overlays.copyMode) | |||
| 806 | + | ac.overlays.copyMu.Lock() | |||
| 807 | + | captured := ac.overlays.copyPane | |||
| 808 | + | ac.overlays.copyMu.Unlock() | |||
| 809 | + | require.Same(t, pane, captured, "drag entry must capture the copy source pane") | |||
| 810 | + | ||||
| 811 | + | d.handleInput(sess, ac, []byte("y")) | |||
| 812 | + | out := awaitFrame(t, sends, ports.MsgOutput) | |||
| 813 | + | msg, err := ports.UnmarshalOutput(out.Payload) | |||
| 814 | + | require.NoError(t, err) | |||
| 815 | + | want := "\x1b]52;c;" + base64.StdEncoding.EncodeToString([]byte("alpha\nbravo")) + "\x07" | |||
| 816 | + | require.Equal(t, want, string(msg.Data)) | |||
| 817 | + | } | |||
M internal/usecase/daemon/input.go +4 -0
| @@ -84,6 +84,9 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) { | |||
| 84 | 84 | if !pointInRect(ev.Col, contentRow, geometry.Inner) { | |
| 85 | 85 | return | |
| 86 | 86 | } | |
| 87 | + | // Match the multi-pane translation: hand copyMouse a zero-based | |
| 88 | + | // popup row so a click selects exactly the row under the pointer. | |
| 89 | + | ev.Row = contentRow | |
| 87 | 90 | ev = translateMouseEvent(ev, geometry.Inner.X, geometry.Inner.Y) | |
| 88 | 91 | switch ev.Button { | |
| 89 | 92 | case mouse.Left: | |
| @@ -276,6 +279,7 @@ func (d *Daemon) handleTerminalMouse(sess *session, ac *attachedClient, p *pane, | |||
| 276 | 279 | mode.StartSelectionAt(snap, pressTop+pressRow) | |
| 277 | 280 | mode.ExtendTo(snap, len(snap.Rows)-snap.Height+ev.Row) | |
| 278 | 281 | rt.copyMode = mode | |
| 282 | + | rt.copyPane = p | |
| 279 | 283 | rt.copyPressRow = pressTop + pressRow | |
| 280 | 284 | rt.copyPressRowValid = true | |
| 281 | 285 | rt.copyDragging = true | |
| @@ -84,6 +84,9 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) { | |||||
| 84 | if !pointInRect(ev.Col, contentRow, geometry.Inner) { | 84 | if !pointInRect(ev.Col, contentRow, geometry.Inner) { | ||
| 85 | return | 85 | return | ||
| 86 | } | 86 | } | ||
| 87 | + | // Match the multi-pane translation: hand copyMouse a zero-based | |||
| 88 | + | // popup row so a click selects exactly the row under the pointer. | |||
| 89 | + | ev.Row = contentRow | |||
| 87 | ev = translateMouseEvent(ev, geometry.Inner.X, geometry.Inner.Y) | 90 | ev = translateMouseEvent(ev, geometry.Inner.X, geometry.Inner.Y) | ||
| 88 | switch ev.Button { | 91 | switch ev.Button { | ||
| 89 | case mouse.Left: | 92 | case mouse.Left: | ||
| @@ -276,6 +279,7 @@ func (d *Daemon) handleTerminalMouse(sess *session, ac *attachedClient, p *pane, | |||||
| 276 | mode.StartSelectionAt(snap, pressTop+pressRow) | 279 | mode.StartSelectionAt(snap, pressTop+pressRow) | ||
| 277 | mode.ExtendTo(snap, len(snap.Rows)-snap.Height+ev.Row) | 280 | mode.ExtendTo(snap, len(snap.Rows)-snap.Height+ev.Row) | ||
| 278 | rt.copyMode = mode | 281 | rt.copyMode = mode | ||
| 282 | + | rt.copyPane = p | |||
| 279 | rt.copyPressRow = pressTop + pressRow | 283 | rt.copyPressRow = pressTop + pressRow | ||
| 280 | rt.copyPressRowValid = true | 284 | rt.copyPressRowValid = true | ||
| 281 | rt.copyDragging = true | 285 | rt.copyDragging = true | ||
M internal/usecase/daemon/render_test.go +1 -0
| @@ -1447,6 +1447,7 @@ func TestComposeCopyClientFrameOverlaysBaseAtTarget(t *testing.T) { | |||
| 1447 | 1447 | status := rowText(frame.Row(7)) | |
| 1448 | 1448 | require.Contains(t, status, "[SCROLL]") | |
| 1449 | 1449 | require.NotContains(t, status, "#") | |
| 1450 | + | require.Equal(t, renderer.BlankCell(), frame.At(19, 7), "status filler must be blank cells, not zero cells") | |
| 1450 | 1451 | } | |
| 1451 | 1452 | ||
| 1452 | 1453 | func TestCopyTargetRectLocked(t *testing.T) { | |
| @@ -1447,6 +1447,7 @@ func TestComposeCopyClientFrameOverlaysBaseAtTarget(t *testing.T) { | |||||
| 1447 | status := rowText(frame.Row(7)) | 1447 | status := rowText(frame.Row(7)) | ||
| 1448 | require.Contains(t, status, "[SCROLL]") | 1448 | require.Contains(t, status, "[SCROLL]") | ||
| 1449 | require.NotContains(t, status, "#") | 1449 | require.NotContains(t, status, "#") | ||
| 1450 | + | require.Equal(t, renderer.BlankCell(), frame.At(19, 7), "status filler must be blank cells, not zero cells") | |||
| 1450 | } | 1451 | } | ||
| 1451 | 1452 | ||||
| 1452 | func TestCopyTargetRectLocked(t *testing.T) { | 1453 | func TestCopyTargetRectLocked(t *testing.T) { | ||