detached 9d2dea9

feat(copy): bound selection ranges to row content

commit
9d2dea9e7f4bc85042e7bc8e2703c14a7028c452
author
brice · · 2026-10-05 07:14 UTC
parent
ce725b2
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +66 -1
A internal/usecase/copy/content_selection_test.go +53 -0
@@ -0,0 +1,53 @@
1+package copy
2+
3+import (
4+ "testing"
5+
6+ vt "github.com/bnema/vev-vt"
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func TestSelectionContentBoundedRanges(t *testing.T) {
11+ tests := []struct {
12+ name string
13+ rows [][]vt.Cell
14+ bounds []vt.LineBound
15+ selection Selection
16+ target int
17+ want CellRange
18+ text string
19+ }{
20+ {"line", [][]vt.Cell{padRow("ab", 8)}, nil, Selection{Anchor: Pos{0, 0}, Active: Pos{0, 0}, Granularity: Line, Enabled: true}, 0, CellRange{Row: 0, End: 1}, "ab"},
21+ {"character middle", [][]vt.Cell{padRow("x", 8), padRow("ab", 8), padRow("z", 8)}, nil, Selection{Anchor: Pos{0, 0}, Active: Pos{2, 0}, Granularity: Character, Enabled: true}, 1, CellRange{Row: 1, End: 1}, "x\nab\nz"},
22+ {"character end in padding", [][]vt.Cell{padRow("ab", 8)}, nil, Selection{Anchor: Pos{0, 0}, Active: Pos{0, 6}, Granularity: Character, Enabled: true}, 0, CellRange{End: 1}, "ab"},
23+ {"soft trailing spaces", [][]vt.Cell{padRow("abc ", 8), padRow("de", 8)}, []vt.LineBound{{End: 8, Soft: true}, {End: 2}}, Selection{Anchor: Pos{0, 0}, Active: Pos{1, 1}, Granularity: Character, Enabled: true}, 0, CellRange{End: 7}, "abc de"},
24+ {"soft partial bound", [][]vt.Cell{padRow("abc", 8), padRow("de", 8)}, []vt.LineBound{{End: 5, Soft: true}, {End: 2}}, Selection{Anchor: Pos{0, 0}, Active: Pos{1, 1}, Granularity: Character, Enabled: true}, 0, CellRange{End: 4}, "abc de"},
25+ {"blank middle", [][]vt.Cell{padRow("ab", 8), padRow("", 8), padRow("cd", 8)}, nil, Selection{Anchor: Pos{0, 0}, Active: Pos{2, 1}, Granularity: Character, Enabled: true}, 1, CellRange{Row: 1, End: -1}, "ab\n\ncd"},
26+ {"anchor in padding", [][]vt.Cell{padRow("ab", 8)}, nil, Selection{Anchor: Pos{0, 5}, Active: Pos{0, 6}, Granularity: Character, Enabled: true}, 0, CellRange{Start: 5, End: 4}, ""},
27+ }
28+ for _, tt := range tests {
29+ t.Run(tt.name, func(t *testing.T) {
30+ doc := NewDocument(NewSnapshotFromLines(tt.rows, tt.bounds, 8, len(tt.rows)), "")
31+ got, ok := tt.selection.RangeForRow(doc, tt.target)
32+ require.True(t, ok)
33+ require.Equal(t, tt.want, got)
34+ require.Contains(t, tt.selection.Ranges(doc), got)
35+ require.Equal(t, tt.text, tt.selection.Text(doc))
36+ })
37+ }
38+}
39+
40+func TestDocumentExtractSelectedEmptyRanges(t *testing.T) {
41+ doc := NewDocument(NewSnapshotFromRows([][]vt.Cell{padRow("ab", 8), padRow("", 8), padRow("cd", 8)}, 8, 3), "")
42+ tests := []struct {
43+ name string
44+ ranges []CellRange
45+ want string
46+ }{
47+ {"empty", []CellRange{{Row: 0, End: -1}}, ""},
48+ {"blank middle", []CellRange{{Row: 0, End: 1}, {Row: 1, End: -1}, {Row: 2, End: 1}}, "ab\n\ncd"},
49+ }
50+ for _, tt := range tests {
51+ t.Run(tt.name, func(t *testing.T) { require.Equal(t, tt.want, doc.Extract(tt.ranges)) })
52+ }
53+}
@@ -0,0 +1,53 @@
1+package copy
2+
3+import (
4+ "testing"
5+
6+ vt "github.com/bnema/vev-vt"
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func TestSelectionContentBoundedRanges(t *testing.T) {
11+ tests := []struct {
12+ name string
13+ rows [][]vt.Cell
14+ bounds []vt.LineBound
15+ selection Selection
16+ target int
17+ want CellRange
18+ text string
19+ }{
20+ {"line", [][]vt.Cell{padRow("ab", 8)}, nil, Selection{Anchor: Pos{0, 0}, Active: Pos{0, 0}, Granularity: Line, Enabled: true}, 0, CellRange{Row: 0, End: 1}, "ab"},
21+ {"character middle", [][]vt.Cell{padRow("x", 8), padRow("ab", 8), padRow("z", 8)}, nil, Selection{Anchor: Pos{0, 0}, Active: Pos{2, 0}, Granularity: Character, Enabled: true}, 1, CellRange{Row: 1, End: 1}, "x\nab\nz"},
22+ {"character end in padding", [][]vt.Cell{padRow("ab", 8)}, nil, Selection{Anchor: Pos{0, 0}, Active: Pos{0, 6}, Granularity: Character, Enabled: true}, 0, CellRange{End: 1}, "ab"},
23+ {"soft trailing spaces", [][]vt.Cell{padRow("abc ", 8), padRow("de", 8)}, []vt.LineBound{{End: 8, Soft: true}, {End: 2}}, Selection{Anchor: Pos{0, 0}, Active: Pos{1, 1}, Granularity: Character, Enabled: true}, 0, CellRange{End: 7}, "abc de"},
24+ {"soft partial bound", [][]vt.Cell{padRow("abc", 8), padRow("de", 8)}, []vt.LineBound{{End: 5, Soft: true}, {End: 2}}, Selection{Anchor: Pos{0, 0}, Active: Pos{1, 1}, Granularity: Character, Enabled: true}, 0, CellRange{End: 4}, "abc de"},
25+ {"blank middle", [][]vt.Cell{padRow("ab", 8), padRow("", 8), padRow("cd", 8)}, nil, Selection{Anchor: Pos{0, 0}, Active: Pos{2, 1}, Granularity: Character, Enabled: true}, 1, CellRange{Row: 1, End: -1}, "ab\n\ncd"},
26+ {"anchor in padding", [][]vt.Cell{padRow("ab", 8)}, nil, Selection{Anchor: Pos{0, 5}, Active: Pos{0, 6}, Granularity: Character, Enabled: true}, 0, CellRange{Start: 5, End: 4}, ""},
27+ }
28+ for _, tt := range tests {
29+ t.Run(tt.name, func(t *testing.T) {
30+ doc := NewDocument(NewSnapshotFromLines(tt.rows, tt.bounds, 8, len(tt.rows)), "")
31+ got, ok := tt.selection.RangeForRow(doc, tt.target)
32+ require.True(t, ok)
33+ require.Equal(t, tt.want, got)
34+ require.Contains(t, tt.selection.Ranges(doc), got)
35+ require.Equal(t, tt.text, tt.selection.Text(doc))
36+ })
37+ }
38+}
39+
40+func TestDocumentExtractSelectedEmptyRanges(t *testing.T) {
41+ doc := NewDocument(NewSnapshotFromRows([][]vt.Cell{padRow("ab", 8), padRow("", 8), padRow("cd", 8)}, 8, 3), "")
42+ tests := []struct {
43+ name string
44+ ranges []CellRange
45+ want string
46+ }{
47+ {"empty", []CellRange{{Row: 0, End: -1}}, ""},
48+ {"blank middle", []CellRange{{Row: 0, End: 1}, {Row: 1, End: -1}, {Row: 2, End: 1}}, "ab\n\ncd"},
49+ }
50+ for _, tt := range tests {
51+ t.Run(tt.name, func(t *testing.T) { require.Equal(t, tt.want, doc.Extract(tt.ranges)) })
52+ }
53+}
M internal/usecase/copy/document.go +4 -0
@@ -285,6 +285,10 @@ func (d *Document) rangeText(r CellRange, logicalEnd bool) (string, bool, bool)
285285 if len(row) == 0 {
286286 return "", false, r.Start == 0 && r.End == 0
287287 }
288+ if r.End < r.Start {
289+ bound := d.snapshot.Bound(r.Row)
290+ return "", bound.Soft && !logicalEnd, true
291+ }
288292 start, end := min(r.Start, r.End), max(r.Start, r.End)
289293 if end < 0 || start >= len(row) {
290294 return "", false, false
@@ -285,6 +285,10 @@ func (d *Document) rangeText(r CellRange, logicalEnd bool) (string, bool, bool)
285 if len(row) == 0 {285 if len(row) == 0 {
286 return "", false, r.Start == 0 && r.End == 0286 return "", false, r.Start == 0 && r.End == 0
287 }287 }
288+ if r.End < r.Start {
289+ bound := d.snapshot.Bound(r.Row)
290+ return "", bound.Soft && !logicalEnd, true
291+ }
288 start, end := min(r.Start, r.End), max(r.Start, r.End)292 start, end := min(r.Start, r.End), max(r.Start, r.End)
289 if end < 0 || start >= len(row) {293 if end < 0 || start >= len(row) {
290 return "", false, false294 return "", false, false
M internal/usecase/copy/selection.go +9 -1
@@ -123,8 +123,12 @@ func (b selectionBounds) rangeForRow(doc *Document, row int) (CellRange, bool) {
123123 if len(cells) == 0 {
124124 return CellRange{Row: row}, true
125125 }
126+ limit := doc.contentEnd(row)
127+ if limit == 0 {
128+ return CellRange{Row: row, End: -1}, true
129+ }
126130 if b.linewise {
127- return CellRange{Row: row, End: len(cells) - 1}, true
131+ return CellRange{Row: row, End: limit - 1}, true
128132 }
129133
130134 start, end := 0, len(cells)-1
@@ -134,6 +138,10 @@ func (b selectionBounds) rangeForRow(doc *Document, row int) (CellRange, bool) {
134138 if row == b.end.Row {
135139 end = glyphEnd(cells, b.end.Col)
136140 }
141+ end = min(end, limit-1)
142+ if start > end {
143+ end = start - 1
144+ }
137145 return CellRange{Row: row, Start: start, End: end}, true
138146 }
139147
@@ -123,8 +123,12 @@ func (b selectionBounds) rangeForRow(doc *Document, row int) (CellRange, bool) {
123 if len(cells) == 0 {123 if len(cells) == 0 {
124 return CellRange{Row: row}, true124 return CellRange{Row: row}, true
125 }125 }
126+ limit := doc.contentEnd(row)
127+ if limit == 0 {
128+ return CellRange{Row: row, End: -1}, true
129+ }
126 if b.linewise {130 if b.linewise {
127- return CellRange{Row: row, End: len(cells) - 1}, true131+ return CellRange{Row: row, End: limit - 1}, true
128 }132 }
129 133
130 start, end := 0, len(cells)-1134 start, end := 0, len(cells)-1
@@ -134,6 +138,10 @@ func (b selectionBounds) rangeForRow(doc *Document, row int) (CellRange, bool) {
134 if row == b.end.Row {138 if row == b.end.Row {
135 end = glyphEnd(cells, b.end.Col)139 end = glyphEnd(cells, b.end.Col)
136 }140 }
141+ end = min(end, limit-1)
142+ if start > end {
143+ end = start - 1
144+ }
137 return CellRange{Row: row, Start: start, End: end}, true145 return CellRange{Row: row, Start: start, End: end}, true
138 }146 }
139 147

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