fix(copy): preserve bounded word drag state
- commit
- 7ab375db2309fcf34e75e0ddce7bae86c9ddccf8
- author
- brice · · 2026-07-15 10:12 UTC
- committer
- brice · · 2026-07-15 10:19 UTC
- parent
- 61e7927
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
4 files · +73 -2
M internal/usecase/copy/mode.go +2 -1
| @@ -379,7 +379,8 @@ func (m *Mode) Render(styles ...renderer.Style) renderer.Frame { | |||
| 379 | 379 | } | |
| 380 | 380 | } | |
| 381 | 381 | if src == cursor.Row && cursorValid && !cursorCovered && len(row) > 0 { | |
| 382 | - | applySelectionStyle(&row[cursor.Col].Style, selection, hasSelection) | |
| 382 | + | cursorCol := min(cursor.Col, len(row)-1) | |
| 383 | + | applySelectionStyle(&row[cursorCol].Style, selection, hasSelection) | |
| 383 | 384 | } | |
| 384 | 385 | } | |
| 385 | 386 | status := inverseStyle() | |
| @@ -379,7 +379,8 @@ func (m *Mode) Render(styles ...renderer.Style) renderer.Frame { | |||||
| 379 | } | 379 | } | ||
| 380 | } | 380 | } | ||
| 381 | if src == cursor.Row && cursorValid && !cursorCovered && len(row) > 0 { | 381 | if src == cursor.Row && cursorValid && !cursorCovered && len(row) > 0 { | ||
| 382 | - | applySelectionStyle(&row[cursor.Col].Style, selection, hasSelection) | 382 | + | cursorCol := min(cursor.Col, len(row)-1) |
| 383 | + | applySelectionStyle(&row[cursorCol].Style, selection, hasSelection) | |||
| 383 | } | 384 | } | ||
| 384 | } | 385 | } | ||
| 385 | status := inverseStyle() | 386 | status := inverseStyle() | ||
M internal/usecase/copy/mode_test.go +10 -0
| @@ -92,6 +92,16 @@ func TestCopyModeSearchNavigationExtendsActiveSelection(t *testing.T) { | |||
| 92 | 92 | require.Equal(t, Selection{Anchor: Pos{Row: 0}, Active: Pos{Row: 2, Col: 2}, Granularity: Character, Enabled: true}, m.Selection()) | |
| 93 | 93 | } | |
| 94 | 94 | ||
| 95 | + | func TestCopyModeRenderBoundsCursorToRenderedRow(t *testing.T) { | |
| 96 | + | m := NewMode(NewDocument(NewSnapshotFromRows([][]renderer.Cell{row("alpha")}, 2, 1), "")) | |
| 97 | + | require.True(t, m.SetPosition(Pos{Row: 0, Col: 4}), "cursor remains valid in the document despite a narrow render frame") | |
| 98 | + | ||
| 99 | + | selection := renderer.Style{HasBackgroundRGB: true, BackgroundRGB: renderer.RGB{R: 1}} | |
| 100 | + | require.NotPanics(t, func() { _ = m.Render(renderer.DefaultStyle(), selection) }) | |
| 101 | + | frame := m.Render(renderer.DefaultStyle(), selection) | |
| 102 | + | require.True(t, frame.At(1, 0).Style.HasBackgroundRGB, "a valid off-frame cursor is rendered at the visible edge") | |
| 103 | + | } | |
| 104 | + | ||
| 95 | 105 | func TestCopyModeRenderKeepsPassiveCursorOutsidePartialSameRowSelection(t *testing.T) { | |
| 96 | 106 | cells := []renderer.Cell{{Rune: '界'}, {Continuation: true}, {Rune: 'a'}, {Rune: 'b'}} | |
| 97 | 107 | m := NewMode(NewDocument(NewSnapshotFromRows([][]renderer.Cell{cells}, 4, 1), "")) | |
| @@ -92,6 +92,16 @@ func TestCopyModeSearchNavigationExtendsActiveSelection(t *testing.T) { | |||||
| 92 | require.Equal(t, Selection{Anchor: Pos{Row: 0}, Active: Pos{Row: 2, Col: 2}, Granularity: Character, Enabled: true}, m.Selection()) | 92 | require.Equal(t, Selection{Anchor: Pos{Row: 0}, Active: Pos{Row: 2, Col: 2}, Granularity: Character, Enabled: true}, m.Selection()) | ||
| 93 | } | 93 | } | ||
| 94 | 94 | ||||
| 95 | + | func TestCopyModeRenderBoundsCursorToRenderedRow(t *testing.T) { | |||
| 96 | + | m := NewMode(NewDocument(NewSnapshotFromRows([][]renderer.Cell{row("alpha")}, 2, 1), "")) | |||
| 97 | + | require.True(t, m.SetPosition(Pos{Row: 0, Col: 4}), "cursor remains valid in the document despite a narrow render frame") | |||
| 98 | + | ||||
| 99 | + | selection := renderer.Style{HasBackgroundRGB: true, BackgroundRGB: renderer.RGB{R: 1}} | |||
| 100 | + | require.NotPanics(t, func() { _ = m.Render(renderer.DefaultStyle(), selection) }) | |||
| 101 | + | frame := m.Render(renderer.DefaultStyle(), selection) | |||
| 102 | + | require.True(t, frame.At(1, 0).Style.HasBackgroundRGB, "a valid off-frame cursor is rendered at the visible edge") | |||
| 103 | + | } | |||
| 104 | + | ||||
| 95 | func TestCopyModeRenderKeepsPassiveCursorOutsidePartialSameRowSelection(t *testing.T) { | 105 | func TestCopyModeRenderKeepsPassiveCursorOutsidePartialSameRowSelection(t *testing.T) { | ||
| 96 | cells := []renderer.Cell{{Rune: '界'}, {Continuation: true}, {Rune: 'a'}, {Rune: 'b'}} | 106 | cells := []renderer.Cell{{Rune: '界'}, {Continuation: true}, {Rune: 'a'}, {Rune: 'b'}} | ||
| 97 | m := NewMode(NewDocument(NewSnapshotFromRows([][]renderer.Cell{cells}, 4, 1), "")) | 107 | m := NewMode(NewDocument(NewSnapshotFromRows([][]renderer.Cell{cells}, 4, 1), "")) | ||
M internal/usecase/daemon/copymode.go +3 -1
| @@ -177,7 +177,9 @@ func (d *Daemon) copyMouse(sess *session, ac *attachedClient, ev mouse.Event, ma | |||
| 177 | 177 | pointer := rt.copyPointer | |
| 178 | 178 | if pointer.valid && pointer.epoch == snapshot.epoch && pointer.pane == mapped.pane && pointer.document == rt.copyDocument { | |
| 179 | 179 | if !pointer.dragging { | |
| 180 | - | rt.copyMode.StartCharacterSelection(pointer.press) | |
| 180 | + | if !pointer.wordDrag { | |
| 181 | + | rt.copyMode.StartCharacterSelection(pointer.press) | |
| 182 | + | } | |
| 181 | 183 | pointer.dragging = true | |
| 182 | 184 | } | |
| 183 | 185 | if rt.copyClick.valid && rt.copyClick.pane == pointer.pane { | |
| @@ -177,7 +177,9 @@ func (d *Daemon) copyMouse(sess *session, ac *attachedClient, ev mouse.Event, ma | |||||
| 177 | pointer := rt.copyPointer | 177 | pointer := rt.copyPointer | ||
| 178 | if pointer.valid && pointer.epoch == snapshot.epoch && pointer.pane == mapped.pane && pointer.document == rt.copyDocument { | 178 | if pointer.valid && pointer.epoch == snapshot.epoch && pointer.pane == mapped.pane && pointer.document == rt.copyDocument { | ||
| 179 | if !pointer.dragging { | 179 | if !pointer.dragging { | ||
| 180 | - | rt.copyMode.StartCharacterSelection(pointer.press) | 180 | + | if !pointer.wordDrag { |
| 181 | + | rt.copyMode.StartCharacterSelection(pointer.press) | |||
| 182 | + | } | |||
| 181 | pointer.dragging = true | 183 | pointer.dragging = true | ||
| 182 | } | 184 | } | ||
| 183 | if rt.copyClick.valid && rt.copyClick.pane == pointer.pane { | 185 | if rt.copyClick.valid && rt.copyClick.pane == pointer.pane { | ||
M internal/usecase/daemon/copymode_mouse_test.go +58 -0
| @@ -1,6 +1,7 @@ | |||
| 1 | 1 | package daemon | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "strconv" | |
| 4 | 5 | "strings" | |
| 5 | 6 | "testing" | |
| 6 | 7 | "time" | |
| @@ -140,6 +141,63 @@ func TestCopyDoubleClick(t *testing.T) { | |||
| 140 | 141 | }) | |
| 141 | 142 | } | |
| 142 | 143 | ||
| 144 | + | func TestCopyWordDragKeepsWordAnchorOnFirstMotion(t *testing.T) { | |
| 145 | + | for _, tc := range []struct { | |
| 146 | + | name string | |
| 147 | + | pressCol, motionCol int | |
| 148 | + | wantAnchor, wantActive scopy.Pos | |
| 149 | + | }{ | |
| 150 | + | { | |
| 151 | + | name: "forward from inside first word", | |
| 152 | + | pressCol: 3, | |
| 153 | + | motionCol: 8, | |
| 154 | + | wantAnchor: scopy.Pos{Row: 0}, | |
| 155 | + | wantActive: scopy.Pos{Row: 0, Col: 9}, | |
| 156 | + | }, | |
| 157 | + | { | |
| 158 | + | name: "reverse from inside second word", | |
| 159 | + | pressCol: 8, | |
| 160 | + | motionCol: 3, | |
| 161 | + | wantAnchor: scopy.Pos{Row: 0, Col: 6}, | |
| 162 | + | wantActive: scopy.Pos{Row: 0, Col: 4}, | |
| 163 | + | }, | |
| 164 | + | } { | |
| 165 | + | t.Run(tc.name, func(t *testing.T) { | |
| 166 | + | d, sess, ac, clock := mouseCopyHarness(t, "alpha beta") | |
| 167 | + | copyMouseInput(d, sess, ac, "\x1b[<0;"+strconv.Itoa(tc.pressCol)+";2M\x1b[<0;"+strconv.Itoa(tc.pressCol)+";2m") | |
| 168 | + | clock.Advance(time.Millisecond) | |
| 169 | + | copyMouseInput(d, sess, ac, "\x1b[<0;"+strconv.Itoa(tc.pressCol)+";2M\x1b[<32;"+strconv.Itoa(tc.motionCol)+";2M") | |
| 170 | + | ||
| 171 | + | ac.overlays.copyMu.Lock() | |
| 172 | + | selection := ac.overlays.copyMode.Selection() | |
| 173 | + | text := ac.overlays.copyMode.SelectedText() | |
| 174 | + | pointer := ac.overlays.copyPointer | |
| 175 | + | ac.overlays.copyMu.Unlock() | |
| 176 | + | require.Equal(t, scopy.Word, selection.Granularity) | |
| 177 | + | require.Equal(t, tc.wantAnchor, selection.Anchor) | |
| 178 | + | require.Equal(t, tc.wantActive, selection.Active) | |
| 179 | + | require.Equal(t, "alpha beta", text) | |
| 180 | + | require.True(t, pointer.dragging) | |
| 181 | + | require.True(t, pointer.wordDrag) | |
| 182 | + | }) | |
| 183 | + | } | |
| 184 | + | } | |
| 185 | + | ||
| 186 | + | func TestCopyCharacterDragStartsAtPress(t *testing.T) { | |
| 187 | + | d, sess, ac, _ := mouseCopyHarness(t, "alpha beta") | |
| 188 | + | copyMouseInput(d, sess, ac, "\x1b[<0;3;2M\x1b[<32;8;2M") | |
| 189 | + | ||
| 190 | + | ac.overlays.copyMu.Lock() | |
| 191 | + | selection := ac.overlays.copyMode.Selection() | |
| 192 | + | pointer := ac.overlays.copyPointer | |
| 193 | + | ac.overlays.copyMu.Unlock() | |
| 194 | + | require.Equal(t, scopy.Character, selection.Granularity) | |
| 195 | + | require.Equal(t, scopy.Pos{Row: 0, Col: 2}, selection.Anchor) | |
| 196 | + | require.Equal(t, scopy.Pos{Row: 0, Col: 7}, selection.Active) | |
| 197 | + | require.True(t, pointer.dragging) | |
| 198 | + | require.False(t, pointer.wordDrag) | |
| 199 | + | } | |
| 200 | + | ||
| 143 | 201 | func TestCopySearchReleaseInvalidatesPointerBeforeNextDrag(t *testing.T) { | |
| 144 | 202 | d, sess, ac, _ := mouseCopyHarness(t, "alpha bravo") | |
| 145 | 203 | ||
| @@ -1,6 +1,7 @@ | |||||
| 1 | package daemon | 1 | package daemon | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "strconv" | |||
| 4 | "strings" | 5 | "strings" | ||
| 5 | "testing" | 6 | "testing" | ||
| 6 | "time" | 7 | "time" | ||
| @@ -140,6 +141,63 @@ func TestCopyDoubleClick(t *testing.T) { | |||||
| 140 | }) | 141 | }) | ||
| 141 | } | 142 | } | ||
| 142 | 143 | ||||
| 144 | + | func TestCopyWordDragKeepsWordAnchorOnFirstMotion(t *testing.T) { | |||
| 145 | + | for _, tc := range []struct { | |||
| 146 | + | name string | |||
| 147 | + | pressCol, motionCol int | |||
| 148 | + | wantAnchor, wantActive scopy.Pos | |||
| 149 | + | }{ | |||
| 150 | + | { | |||
| 151 | + | name: "forward from inside first word", | |||
| 152 | + | pressCol: 3, | |||
| 153 | + | motionCol: 8, | |||
| 154 | + | wantAnchor: scopy.Pos{Row: 0}, | |||
| 155 | + | wantActive: scopy.Pos{Row: 0, Col: 9}, | |||
| 156 | + | }, | |||
| 157 | + | { | |||
| 158 | + | name: "reverse from inside second word", | |||
| 159 | + | pressCol: 8, | |||
| 160 | + | motionCol: 3, | |||
| 161 | + | wantAnchor: scopy.Pos{Row: 0, Col: 6}, | |||
| 162 | + | wantActive: scopy.Pos{Row: 0, Col: 4}, | |||
| 163 | + | }, | |||
| 164 | + | } { | |||
| 165 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 166 | + | d, sess, ac, clock := mouseCopyHarness(t, "alpha beta") | |||
| 167 | + | copyMouseInput(d, sess, ac, "\x1b[<0;"+strconv.Itoa(tc.pressCol)+";2M\x1b[<0;"+strconv.Itoa(tc.pressCol)+";2m") | |||
| 168 | + | clock.Advance(time.Millisecond) | |||
| 169 | + | copyMouseInput(d, sess, ac, "\x1b[<0;"+strconv.Itoa(tc.pressCol)+";2M\x1b[<32;"+strconv.Itoa(tc.motionCol)+";2M") | |||
| 170 | + | ||||
| 171 | + | ac.overlays.copyMu.Lock() | |||
| 172 | + | selection := ac.overlays.copyMode.Selection() | |||
| 173 | + | text := ac.overlays.copyMode.SelectedText() | |||
| 174 | + | pointer := ac.overlays.copyPointer | |||
| 175 | + | ac.overlays.copyMu.Unlock() | |||
| 176 | + | require.Equal(t, scopy.Word, selection.Granularity) | |||
| 177 | + | require.Equal(t, tc.wantAnchor, selection.Anchor) | |||
| 178 | + | require.Equal(t, tc.wantActive, selection.Active) | |||
| 179 | + | require.Equal(t, "alpha beta", text) | |||
| 180 | + | require.True(t, pointer.dragging) | |||
| 181 | + | require.True(t, pointer.wordDrag) | |||
| 182 | + | }) | |||
| 183 | + | } | |||
| 184 | + | } | |||
| 185 | + | ||||
| 186 | + | func TestCopyCharacterDragStartsAtPress(t *testing.T) { | |||
| 187 | + | d, sess, ac, _ := mouseCopyHarness(t, "alpha beta") | |||
| 188 | + | copyMouseInput(d, sess, ac, "\x1b[<0;3;2M\x1b[<32;8;2M") | |||
| 189 | + | ||||
| 190 | + | ac.overlays.copyMu.Lock() | |||
| 191 | + | selection := ac.overlays.copyMode.Selection() | |||
| 192 | + | pointer := ac.overlays.copyPointer | |||
| 193 | + | ac.overlays.copyMu.Unlock() | |||
| 194 | + | require.Equal(t, scopy.Character, selection.Granularity) | |||
| 195 | + | require.Equal(t, scopy.Pos{Row: 0, Col: 2}, selection.Anchor) | |||
| 196 | + | require.Equal(t, scopy.Pos{Row: 0, Col: 7}, selection.Active) | |||
| 197 | + | require.True(t, pointer.dragging) | |||
| 198 | + | require.False(t, pointer.wordDrag) | |||
| 199 | + | } | |||
| 200 | + | ||||
| 143 | func TestCopySearchReleaseInvalidatesPointerBeforeNextDrag(t *testing.T) { | 201 | func TestCopySearchReleaseInvalidatesPointerBeforeNextDrag(t *testing.T) { | ||
| 144 | d, sess, ac, _ := mouseCopyHarness(t, "alpha bravo") | 202 | d, sess, ac, _ := mouseCopyHarness(t, "alpha bravo") | ||
| 145 | 203 | ||||