detached 5610a95

feat(copy): add canonical selection ranges

commit
5610a954fc8756d2bee2e3bec70079ce5165abe1
author
brice · · 2026-07-15 06:35 UTC
committer
brice · · 2026-07-15 09:45 UTC
parent
52f91f6
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +294 -0
A internal/usecase/copy/selection.go +160 -0
@@ -0,0 +1,160 @@
1+package copy
2+
3+import "github.com/bnema/vev/pkg/renderer"
4+
5+// Granularity controls how a selection's endpoints become display-cell ranges.
6+type Granularity uint8
7+
8+const (
9+ Line Granularity = iota
10+ Character
11+ Word
12+)
13+
14+// Selection is an immutable-document selection. Anchor and Active are glyph
15+// head positions whenever they originate from a Document operation.
16+type Selection struct {
17+ Anchor Pos
18+ Active Pos
19+ Granularity Granularity
20+ Enabled bool
21+}
22+
23+// NewLineSelection starts a complete physical-line selection at at.
24+func NewLineSelection(at Pos) Selection {
25+ return Selection{Anchor: at, Active: at, Granularity: Line, Enabled: true}
26+}
27+
28+// NewWordSelection selects the complete word containing at. Separators cannot
29+// begin a word selection.
30+func NewWordSelection(doc *Document, at Pos) (Selection, bool) {
31+ if doc == nil {
32+ return Selection{}, false
33+ }
34+ start, end, ok := doc.WordBounds(at)
35+ if !ok {
36+ return Selection{}, false
37+ }
38+ return Selection{Anchor: start, Active: end, Granularity: Word, Enabled: true}, true
39+}
40+
41+// Ordered returns the selection endpoints in document order.
42+func (s Selection) Ordered() (Pos, Pos) {
43+ if before(s.Active, s.Anchor) {
44+ return s.Active, s.Anchor
45+ }
46+ return s.Anchor, s.Active
47+}
48+
49+// Ranges returns the canonical, inclusive per-physical-row display-cell
50+// ranges. It returns nil for disabled selections or invalid endpoints.
51+func (s Selection) Ranges(doc *Document) []CellRange {
52+ if !s.Enabled || doc == nil {
53+ return nil
54+ }
55+ anchor, ok := doc.Normalize(s.Anchor)
56+ if !ok {
57+ return nil
58+ }
59+ active, ok := doc.Normalize(s.Active)
60+ if !ok {
61+ return nil
62+ }
63+
64+ switch s.Granularity {
65+ case Line:
66+ start, end := order(anchor, active)
67+ return lineRanges(doc, start.Row, end.Row)
68+ case Character:
69+ start, end := order(anchor, active)
70+ return streamRanges(doc, start, end)
71+ case Word:
72+ anchorStart, anchorEnd, ok := doc.WordBounds(anchor)
73+ if !ok {
74+ return nil
75+ }
76+ activeStart, activeEnd, ok := doc.WordBounds(active)
77+ if !ok {
78+ return nil
79+ }
80+ start, _ := order(anchorStart, activeStart)
81+ _, end := order(anchorEnd, activeEnd)
82+ return streamRanges(doc, start, end)
83+ default:
84+ return nil
85+ }
86+}
87+
88+// Text extracts the selection using its range granularity's newline and
89+// trailing-space semantics.
90+func (s Selection) Text(doc *Document) string {
91+ if doc == nil {
92+ return ""
93+ }
94+ return doc.Extract(s.Ranges(doc), s.Granularity == Line)
95+}
96+
97+// Extend moves the active endpoint. Document normalization occurs when ranges
98+// are requested so callers may pass raw pointer coordinates.
99+func (s *Selection) Extend(at Pos) {
100+ s.Active = at
101+}
102+
103+// AsCharacter changes an active selection to stream selection without moving
104+// either endpoint.
105+func (s *Selection) AsCharacter() {
106+ s.Granularity = Character
107+}
108+
109+func before(left, right Pos) bool {
110+ return left.Row < right.Row || left.Row == right.Row && left.Col < right.Col
111+}
112+
113+func order(left, right Pos) (Pos, Pos) {
114+ if before(right, left) {
115+ return right, left
116+ }
117+ return left, right
118+}
119+
120+func lineRanges(doc *Document, startRow, endRow int) []CellRange {
121+ ranges := make([]CellRange, 0, endRow-startRow+1)
122+ for row := startRow; row <= endRow; row++ {
123+ cells := doc.Row(row)
124+ if len(cells) == 0 {
125+ ranges = append(ranges, CellRange{Row: row})
126+ continue
127+ }
128+ ranges = append(ranges, CellRange{Row: row, End: len(cells) - 1})
129+ }
130+ return ranges
131+}
132+
133+func streamRanges(doc *Document, start, end Pos) []CellRange {
134+ ranges := make([]CellRange, 0, end.Row-start.Row+1)
135+ for row := start.Row; row <= end.Row; row++ {
136+ cells := doc.Row(row)
137+ if len(cells) == 0 {
138+ ranges = append(ranges, CellRange{Row: row})
139+ continue
140+ }
141+
142+ first, last := 0, len(cells)-1
143+ if row == start.Row {
144+ first = start.Col
145+ }
146+ if row == end.Row {
147+ last = glyphEnd(cells, end.Col)
148+ }
149+ ranges = append(ranges, CellRange{Row: row, Start: first, End: last})
150+ }
151+ return ranges
152+}
153+
154+// glyphEnd expands a glyph-head endpoint over all of its continuation cells.
155+func glyphEnd(cells []renderer.Cell, col int) int {
156+ for col+1 < len(cells) && cells[col+1].Continuation {
157+ col++
158+ }
159+ return col
160+}
@@ -0,0 +1,160 @@
1+package copy
2+
3+import "github.com/bnema/vev/pkg/renderer"
4+
5+// Granularity controls how a selection's endpoints become display-cell ranges.
6+type Granularity uint8
7+
8+const (
9+ Line Granularity = iota
10+ Character
11+ Word
12+)
13+
14+// Selection is an immutable-document selection. Anchor and Active are glyph
15+// head positions whenever they originate from a Document operation.
16+type Selection struct {
17+ Anchor Pos
18+ Active Pos
19+ Granularity Granularity
20+ Enabled bool
21+}
22+
23+// NewLineSelection starts a complete physical-line selection at at.
24+func NewLineSelection(at Pos) Selection {
25+ return Selection{Anchor: at, Active: at, Granularity: Line, Enabled: true}
26+}
27+
28+// NewWordSelection selects the complete word containing at. Separators cannot
29+// begin a word selection.
30+func NewWordSelection(doc *Document, at Pos) (Selection, bool) {
31+ if doc == nil {
32+ return Selection{}, false
33+ }
34+ start, end, ok := doc.WordBounds(at)
35+ if !ok {
36+ return Selection{}, false
37+ }
38+ return Selection{Anchor: start, Active: end, Granularity: Word, Enabled: true}, true
39+}
40+
41+// Ordered returns the selection endpoints in document order.
42+func (s Selection) Ordered() (Pos, Pos) {
43+ if before(s.Active, s.Anchor) {
44+ return s.Active, s.Anchor
45+ }
46+ return s.Anchor, s.Active
47+}
48+
49+// Ranges returns the canonical, inclusive per-physical-row display-cell
50+// ranges. It returns nil for disabled selections or invalid endpoints.
51+func (s Selection) Ranges(doc *Document) []CellRange {
52+ if !s.Enabled || doc == nil {
53+ return nil
54+ }
55+ anchor, ok := doc.Normalize(s.Anchor)
56+ if !ok {
57+ return nil
58+ }
59+ active, ok := doc.Normalize(s.Active)
60+ if !ok {
61+ return nil
62+ }
63+
64+ switch s.Granularity {
65+ case Line:
66+ start, end := order(anchor, active)
67+ return lineRanges(doc, start.Row, end.Row)
68+ case Character:
69+ start, end := order(anchor, active)
70+ return streamRanges(doc, start, end)
71+ case Word:
72+ anchorStart, anchorEnd, ok := doc.WordBounds(anchor)
73+ if !ok {
74+ return nil
75+ }
76+ activeStart, activeEnd, ok := doc.WordBounds(active)
77+ if !ok {
78+ return nil
79+ }
80+ start, _ := order(anchorStart, activeStart)
81+ _, end := order(anchorEnd, activeEnd)
82+ return streamRanges(doc, start, end)
83+ default:
84+ return nil
85+ }
86+}
87+
88+// Text extracts the selection using its range granularity's newline and
89+// trailing-space semantics.
90+func (s Selection) Text(doc *Document) string {
91+ if doc == nil {
92+ return ""
93+ }
94+ return doc.Extract(s.Ranges(doc), s.Granularity == Line)
95+}
96+
97+// Extend moves the active endpoint. Document normalization occurs when ranges
98+// are requested so callers may pass raw pointer coordinates.
99+func (s *Selection) Extend(at Pos) {
100+ s.Active = at
101+}
102+
103+// AsCharacter changes an active selection to stream selection without moving
104+// either endpoint.
105+func (s *Selection) AsCharacter() {
106+ s.Granularity = Character
107+}
108+
109+func before(left, right Pos) bool {
110+ return left.Row < right.Row || left.Row == right.Row && left.Col < right.Col
111+}
112+
113+func order(left, right Pos) (Pos, Pos) {
114+ if before(right, left) {
115+ return right, left
116+ }
117+ return left, right
118+}
119+
120+func lineRanges(doc *Document, startRow, endRow int) []CellRange {
121+ ranges := make([]CellRange, 0, endRow-startRow+1)
122+ for row := startRow; row <= endRow; row++ {
123+ cells := doc.Row(row)
124+ if len(cells) == 0 {
125+ ranges = append(ranges, CellRange{Row: row})
126+ continue
127+ }
128+ ranges = append(ranges, CellRange{Row: row, End: len(cells) - 1})
129+ }
130+ return ranges
131+}
132+
133+func streamRanges(doc *Document, start, end Pos) []CellRange {
134+ ranges := make([]CellRange, 0, end.Row-start.Row+1)
135+ for row := start.Row; row <= end.Row; row++ {
136+ cells := doc.Row(row)
137+ if len(cells) == 0 {
138+ ranges = append(ranges, CellRange{Row: row})
139+ continue
140+ }
141+
142+ first, last := 0, len(cells)-1
143+ if row == start.Row {
144+ first = start.Col
145+ }
146+ if row == end.Row {
147+ last = glyphEnd(cells, end.Col)
148+ }
149+ ranges = append(ranges, CellRange{Row: row, Start: first, End: last})
150+ }
151+ return ranges
152+}
153+
154+// glyphEnd expands a glyph-head endpoint over all of its continuation cells.
155+func glyphEnd(cells []renderer.Cell, col int) int {
156+ for col+1 < len(cells) && cells[col+1].Continuation {
157+ col++
158+ }
159+ return col
160+}
A internal/usecase/copy/selection_test.go +134 -0
@@ -0,0 +1,134 @@
1+package copy
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/pkg/renderer"
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func TestSelectionRanges(t *testing.T) {
11+ doc := documentFromStrings([]string{"alpha", "bravo", "charlie"}, " -_@")
12+
13+ tests := []struct {
14+ name string
15+ selection Selection
16+ want []CellRange
17+ }{
18+ {
19+ name: "line forward",
20+ selection: Selection{Anchor: Pos{0, 2}, Active: Pos{1, 1}, Granularity: Line, Enabled: true},
21+ want: []CellRange{{Row: 0, Start: 0, End: 4}, {Row: 1, Start: 0, End: 4}},
22+ },
23+ {
24+ name: "line reverse",
25+ selection: Selection{Anchor: Pos{1, 1}, Active: Pos{0, 2}, Granularity: Line, Enabled: true},
26+ want: []CellRange{{Row: 0, Start: 0, End: 4}, {Row: 1, Start: 0, End: 4}},
27+ },
28+ {
29+ name: "character forward",
30+ selection: Selection{Anchor: Pos{0, 2}, Active: Pos{1, 1}, Granularity: Character, Enabled: true},
31+ want: []CellRange{{Row: 0, Start: 2, End: 4}, {Row: 1, Start: 0, End: 1}},
32+ },
33+ {
34+ name: "character reverse",
35+ selection: Selection{Anchor: Pos{1, 1}, Active: Pos{0, 2}, Granularity: Character, Enabled: true},
36+ want: []CellRange{{Row: 0, Start: 2, End: 4}, {Row: 1, Start: 0, End: 1}},
37+ },
38+ {
39+ name: "word forward",
40+ selection: Selection{Anchor: Pos{0, 2}, Active: Pos{1, 1}, Granularity: Word, Enabled: true},
41+ want: []CellRange{{Row: 0, Start: 0, End: 4}, {Row: 1, Start: 0, End: 4}},
42+ },
43+ {
44+ name: "word reverse",
45+ selection: Selection{Anchor: Pos{1, 1}, Active: Pos{0, 2}, Granularity: Word, Enabled: true},
46+ want: []CellRange{{Row: 0, Start: 0, End: 4}, {Row: 1, Start: 0, End: 4}},
47+ },
48+ {name: "disabled", selection: Selection{}, want: nil},
49+ }
50+ for _, tt := range tests {
51+ t.Run(tt.name, func(t *testing.T) {
52+ require.Equal(t, tt.want, tt.selection.Ranges(doc))
53+ })
54+ }
55+}
56+
57+func TestSelectionOrdered(t *testing.T) {
58+ tests := []struct {
59+ name string
60+ selection Selection
61+ start Pos
62+ end Pos
63+ }{
64+ {"forward", Selection{Anchor: Pos{0, 2}, Active: Pos{1, 1}}, Pos{0, 2}, Pos{1, 1}},
65+ {"reverse", Selection{Anchor: Pos{1, 1}, Active: Pos{0, 2}}, Pos{0, 2}, Pos{1, 1}},
66+ }
67+ for _, tt := range tests {
68+ t.Run(tt.name, func(t *testing.T) {
69+ start, end := tt.selection.Ordered()
70+ require.Equal(t, tt.start, start)
71+ require.Equal(t, tt.end, end)
72+ })
73+ }
74+}
75+
76+func TestSelectionRangesEmptyRowsAndWideGlyphs(t *testing.T) {
77+ wide := []renderer.Cell{{Rune: 'a'}, {Rune: '界'}, {Continuation: true}, {Rune: 'b'}}
78+ doc := NewDocument(NewSnapshotFromRows([][]renderer.Cell{{}, wide, {}}, 4, 3), " -_@")
79+
80+ tests := []struct {
81+ name string
82+ selection Selection
83+ want []CellRange
84+ }{
85+ {
86+ name: "line includes empty rows",
87+ selection: Selection{Anchor: Pos{0, 0}, Active: Pos{2, 0}, Granularity: Line, Enabled: true},
88+ want: []CellRange{{Row: 0, Start: 0, End: 0}, {Row: 1, Start: 0, End: 3}, {Row: 2, Start: 0, End: 0}},
89+ },
90+ {
91+ name: "character expands wide glyph continuation",
92+ selection: Selection{Anchor: Pos{1, 1}, Active: Pos{1, 2}, Granularity: Character, Enabled: true},
93+ want: []CellRange{{Row: 1, Start: 1, End: 2}},
94+ },
95+ {
96+ name: "character crosses empty row",
97+ selection: Selection{Anchor: Pos{0, 0}, Active: Pos{1, 1}, Granularity: Character, Enabled: true},
98+ want: []CellRange{{Row: 0, Start: 0, End: 0}, {Row: 1, Start: 0, End: 2}},
99+ },
100+ }
101+ for _, tt := range tests {
102+ t.Run(tt.name, func(t *testing.T) {
103+ require.Equal(t, tt.want, tt.selection.Ranges(doc))
104+ })
105+ }
106+}
107+
108+func TestWordSelectionRejectsSeparator(t *testing.T) {
109+ doc := documentFromStrings([]string{"alpha/beta"}, "/")
110+
111+ selection, ok := NewWordSelection(doc, Pos{0, 5})
112+ require.False(t, ok)
113+ require.False(t, selection.Enabled)
114+}
115+
116+func TestSelectionConstructorsAndMutators(t *testing.T) {
117+ doc := documentFromStrings([]string{"alpha", "bravo"}, " -_@")
118+
119+ selection := NewLineSelection(Pos{0, 2})
120+ require.True(t, selection.Enabled)
121+ require.Equal(t, Line, selection.Granularity)
122+ require.Equal(t, Pos{0, 2}, selection.Anchor)
123+
124+ selection.Extend(Pos{1, 1})
125+ selection.AsCharacter()
126+ require.Equal(t, Character, selection.Granularity)
127+ require.Equal(t, "pha\nbr", selection.Text(doc))
128+
129+ word, ok := NewWordSelection(doc, Pos{1, 2})
130+ require.True(t, ok)
131+ require.Equal(t, "bravo", word.Text(doc))
132+ word.Extend(Pos{0, 2})
133+ require.Equal(t, "alpha\nbravo", word.Text(doc))
134+}
@@ -0,0 +1,134 @@
1+package copy
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/pkg/renderer"
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func TestSelectionRanges(t *testing.T) {
11+ doc := documentFromStrings([]string{"alpha", "bravo", "charlie"}, " -_@")
12+
13+ tests := []struct {
14+ name string
15+ selection Selection
16+ want []CellRange
17+ }{
18+ {
19+ name: "line forward",
20+ selection: Selection{Anchor: Pos{0, 2}, Active: Pos{1, 1}, Granularity: Line, Enabled: true},
21+ want: []CellRange{{Row: 0, Start: 0, End: 4}, {Row: 1, Start: 0, End: 4}},
22+ },
23+ {
24+ name: "line reverse",
25+ selection: Selection{Anchor: Pos{1, 1}, Active: Pos{0, 2}, Granularity: Line, Enabled: true},
26+ want: []CellRange{{Row: 0, Start: 0, End: 4}, {Row: 1, Start: 0, End: 4}},
27+ },
28+ {
29+ name: "character forward",
30+ selection: Selection{Anchor: Pos{0, 2}, Active: Pos{1, 1}, Granularity: Character, Enabled: true},
31+ want: []CellRange{{Row: 0, Start: 2, End: 4}, {Row: 1, Start: 0, End: 1}},
32+ },
33+ {
34+ name: "character reverse",
35+ selection: Selection{Anchor: Pos{1, 1}, Active: Pos{0, 2}, Granularity: Character, Enabled: true},
36+ want: []CellRange{{Row: 0, Start: 2, End: 4}, {Row: 1, Start: 0, End: 1}},
37+ },
38+ {
39+ name: "word forward",
40+ selection: Selection{Anchor: Pos{0, 2}, Active: Pos{1, 1}, Granularity: Word, Enabled: true},
41+ want: []CellRange{{Row: 0, Start: 0, End: 4}, {Row: 1, Start: 0, End: 4}},
42+ },
43+ {
44+ name: "word reverse",
45+ selection: Selection{Anchor: Pos{1, 1}, Active: Pos{0, 2}, Granularity: Word, Enabled: true},
46+ want: []CellRange{{Row: 0, Start: 0, End: 4}, {Row: 1, Start: 0, End: 4}},
47+ },
48+ {name: "disabled", selection: Selection{}, want: nil},
49+ }
50+ for _, tt := range tests {
51+ t.Run(tt.name, func(t *testing.T) {
52+ require.Equal(t, tt.want, tt.selection.Ranges(doc))
53+ })
54+ }
55+}
56+
57+func TestSelectionOrdered(t *testing.T) {
58+ tests := []struct {
59+ name string
60+ selection Selection
61+ start Pos
62+ end Pos
63+ }{
64+ {"forward", Selection{Anchor: Pos{0, 2}, Active: Pos{1, 1}}, Pos{0, 2}, Pos{1, 1}},
65+ {"reverse", Selection{Anchor: Pos{1, 1}, Active: Pos{0, 2}}, Pos{0, 2}, Pos{1, 1}},
66+ }
67+ for _, tt := range tests {
68+ t.Run(tt.name, func(t *testing.T) {
69+ start, end := tt.selection.Ordered()
70+ require.Equal(t, tt.start, start)
71+ require.Equal(t, tt.end, end)
72+ })
73+ }
74+}
75+
76+func TestSelectionRangesEmptyRowsAndWideGlyphs(t *testing.T) {
77+ wide := []renderer.Cell{{Rune: 'a'}, {Rune: '界'}, {Continuation: true}, {Rune: 'b'}}
78+ doc := NewDocument(NewSnapshotFromRows([][]renderer.Cell{{}, wide, {}}, 4, 3), " -_@")
79+
80+ tests := []struct {
81+ name string
82+ selection Selection
83+ want []CellRange
84+ }{
85+ {
86+ name: "line includes empty rows",
87+ selection: Selection{Anchor: Pos{0, 0}, Active: Pos{2, 0}, Granularity: Line, Enabled: true},
88+ want: []CellRange{{Row: 0, Start: 0, End: 0}, {Row: 1, Start: 0, End: 3}, {Row: 2, Start: 0, End: 0}},
89+ },
90+ {
91+ name: "character expands wide glyph continuation",
92+ selection: Selection{Anchor: Pos{1, 1}, Active: Pos{1, 2}, Granularity: Character, Enabled: true},
93+ want: []CellRange{{Row: 1, Start: 1, End: 2}},
94+ },
95+ {
96+ name: "character crosses empty row",
97+ selection: Selection{Anchor: Pos{0, 0}, Active: Pos{1, 1}, Granularity: Character, Enabled: true},
98+ want: []CellRange{{Row: 0, Start: 0, End: 0}, {Row: 1, Start: 0, End: 2}},
99+ },
100+ }
101+ for _, tt := range tests {
102+ t.Run(tt.name, func(t *testing.T) {
103+ require.Equal(t, tt.want, tt.selection.Ranges(doc))
104+ })
105+ }
106+}
107+
108+func TestWordSelectionRejectsSeparator(t *testing.T) {
109+ doc := documentFromStrings([]string{"alpha/beta"}, "/")
110+
111+ selection, ok := NewWordSelection(doc, Pos{0, 5})
112+ require.False(t, ok)
113+ require.False(t, selection.Enabled)
114+}
115+
116+func TestSelectionConstructorsAndMutators(t *testing.T) {
117+ doc := documentFromStrings([]string{"alpha", "bravo"}, " -_@")
118+
119+ selection := NewLineSelection(Pos{0, 2})
120+ require.True(t, selection.Enabled)
121+ require.Equal(t, Line, selection.Granularity)
122+ require.Equal(t, Pos{0, 2}, selection.Anchor)
123+
124+ selection.Extend(Pos{1, 1})
125+ selection.AsCharacter()
126+ require.Equal(t, Character, selection.Granularity)
127+ require.Equal(t, "pha\nbr", selection.Text(doc))
128+
129+ word, ok := NewWordSelection(doc, Pos{1, 2})
130+ require.True(t, ok)
131+ require.Equal(t, "bravo", word.Text(doc))
132+ word.Extend(Pos{0, 2})
133+ require.Equal(t, "alpha\nbravo", word.Text(doc))
134+}

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