detached 7ab375d

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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 {
379379 }
380380 }
381381 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)
383384 }
384385 }
385386 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) {
9292 require.Equal(t, Selection{Anchor: Pos{Row: 0}, Active: Pos{Row: 2, Col: 2}, Granularity: Character, Enabled: true}, m.Selection())
9393 }
9494
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+
95105 func TestCopyModeRenderKeepsPassiveCursorOutsidePartialSameRowSelection(t *testing.T) {
96106 cells := []renderer.Cell{{Rune: '界'}, {Continuation: true}, {Rune: 'a'}, {Rune: 'b'}}
97107 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
177177 pointer := rt.copyPointer
178178 if pointer.valid && pointer.epoch == snapshot.epoch && pointer.pane == mapped.pane && pointer.document == rt.copyDocument {
179179 if !pointer.dragging {
180- rt.copyMode.StartCharacterSelection(pointer.press)
180+ if !pointer.wordDrag {
181+ rt.copyMode.StartCharacterSelection(pointer.press)
182+ }
181183 pointer.dragging = true
182184 }
183185 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.copyPointer177 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 = true183 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 @@
11 package daemon
22
33 import (
4+ "strconv"
45 "strings"
56 "testing"
67 "time"
@@ -140,6 +141,63 @@ func TestCopyDoubleClick(t *testing.T) {
140141 })
141142 }
142143
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+
143201 func TestCopySearchReleaseInvalidatesPointerBeforeNextDrag(t *testing.T) {
144202 d, sess, ac, _ := mouseCopyHarness(t, "alpha bravo")
145203
@@ -1,6 +1,7 @@
1 package daemon1 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

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close