detached b3cac20

feat(copy): add display-cell document primitives

commit
b3cac20002dd3dc3237a3c42adfbe5d2bac31ea6
author
brice · · 2026-07-15 06:31 UTC
committer
brice · · 2026-07-15 09:45 UTC
parent
bb3050a
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +553 -0
A internal/usecase/copy/document.go +343 -0
@@ -0,0 +1,343 @@
1+package copy
2+
3+import (
4+ "strings"
5+ "unicode"
6+
7+ "github.com/bnema/vev/pkg/renderer"
8+)
9+
10+// Pos identifies a terminal display cell in a document. Valid positions are
11+// always glyph heads; Normalize converts continuation cells to their head.
12+type Pos struct {
13+ Row int
14+ Col int
15+}
16+
17+// CellRange is an inclusive display-cell range on one physical document row.
18+type CellRange struct {
19+ Row int
20+ Start int
21+ End int
22+}
23+
24+// Document provides immutable display-cell and text operations over a Snapshot.
25+type Document struct {
26+ snapshot Snapshot
27+ wordSeparators map[rune]struct{}
28+}
29+
30+func NewDocument(snapshot Snapshot, separators string) *Document {
31+ set := make(map[rune]struct{}, len([]rune(separators)))
32+ for _, r := range separators {
33+ set[r] = struct{}{}
34+ }
35+ return &Document{snapshot: snapshot, wordSeparators: set}
36+}
37+
38+func (d *Document) Snapshot() Snapshot { return d.snapshot }
39+func (d *Document) Len() int { return d.snapshot.Len() }
40+func (d *Document) Width() int { return d.snapshot.Width }
41+func (d *Document) Height() int { return d.snapshot.Height }
42+func (d *Document) Row(row int) []renderer.Cell { return d.snapshot.Row(row) }
43+
44+// Normalize returns pos as a valid glyph head. Empty physical rows have the
45+// stable logical position {row, 0}, which is useful to vertical navigation.
46+func (d *Document) Normalize(pos Pos) (Pos, bool) {
47+ if pos.Row < 0 || pos.Row >= d.Len() || pos.Col < 0 {
48+ return Pos{}, false
49+ }
50+ row := d.Row(pos.Row)
51+ if len(row) == 0 {
52+ if pos.Col == 0 {
53+ return pos, true
54+ }
55+ return Pos{}, false
56+ }
57+ if pos.Col >= len(row) {
58+ return Pos{}, false
59+ }
60+ for pos.Col > 0 && row[pos.Col].Continuation {
61+ pos.Col--
62+ }
63+ if row[pos.Col].Continuation {
64+ return Pos{}, false
65+ }
66+ return pos, true
67+}
68+
69+// PrevGlyph returns the previous glyph head on pos's physical row.
70+func (d *Document) PrevGlyph(pos Pos) (Pos, bool) {
71+ pos, ok := d.Normalize(pos)
72+ if !ok || len(d.Row(pos.Row)) == 0 {
73+ return Pos{}, false
74+ }
75+ for col := pos.Col - 1; col >= 0; col-- {
76+ if !d.Row(pos.Row)[col].Continuation {
77+ return Pos{Row: pos.Row, Col: col}, true
78+ }
79+ }
80+ return Pos{}, false
81+}
82+
83+// NextGlyph returns the next glyph head on pos's physical row.
84+func (d *Document) NextGlyph(pos Pos) (Pos, bool) {
85+ pos, ok := d.Normalize(pos)
86+ if !ok {
87+ return Pos{}, false
88+ }
89+ row := d.Row(pos.Row)
90+ for col := pos.Col + 1; col < len(row); col++ {
91+ if !row[col].Continuation {
92+ return Pos{Row: pos.Row, Col: col}, true
93+ }
94+ }
95+ return Pos{}, false
96+}
97+
98+// WordBounds returns the inclusive bounds of the word containing pos. Unicode
99+// whitespace and configured separators do not belong to words.
100+func (d *Document) WordBounds(pos Pos) (Pos, Pos, bool) {
101+ pos, ok := d.Normalize(pos)
102+ if !ok || !d.isGlyph(pos) || d.isSeparator(d.runeAt(pos)) {
103+ return Pos{}, Pos{}, false
104+ }
105+ start, end := pos, pos
106+ for {
107+ prev, ok := d.PrevGlyph(start)
108+ if !ok || d.isSeparator(d.runeAt(prev)) {
109+ break
110+ }
111+ start = prev
112+ }
113+ for {
114+ next, ok := d.NextGlyph(end)
115+ if !ok || d.isSeparator(d.runeAt(next)) {
116+ break
117+ }
118+ end = next
119+ }
120+ return start, end, true
121+}
122+
123+// NextWordStart returns the beginning of the next word after pos, crossing
124+// physical rows as necessary.
125+func (d *Document) NextWordStart(pos Pos) (Pos, bool) {
126+ pos, ok := d.Normalize(pos)
127+ if !ok {
128+ return Pos{}, false
129+ }
130+ glyphs := d.glyphs()
131+ i := d.glyphIndex(glyphs, pos)
132+ if i < 0 {
133+ return d.firstWordAfterRow(glyphs, pos.Row)
134+ }
135+ if !d.isSeparator(d.runeAt(glyphs[i])) {
136+ for i < len(glyphs) && d.sameWord(glyphs[i], pos) {
137+ i++
138+ }
139+ }
140+ for i < len(glyphs) && d.isSeparator(d.runeAt(glyphs[i])) {
141+ i++
142+ }
143+ if i < len(glyphs) {
144+ return glyphs[i], true
145+ }
146+ return Pos{}, false
147+}
148+
149+// PreviousWordStart returns the beginning of the current word, or of the
150+// preceding word when pos is already at a word beginning.
151+func (d *Document) PreviousWordStart(pos Pos) (Pos, bool) {
152+ pos, ok := d.Normalize(pos)
153+ if !ok {
154+ return Pos{}, false
155+ }
156+ glyphs := d.glyphs()
157+ i := d.glyphIndex(glyphs, pos)
158+ if i < 0 {
159+ return d.lastWordBeforeRow(glyphs, pos.Row)
160+ }
161+ if !d.isSeparator(d.runeAt(glyphs[i])) && i > 0 && d.sameWord(glyphs[i-1], glyphs[i]) {
162+ for i > 0 && d.sameWord(glyphs[i-1], glyphs[i]) {
163+ i--
164+ }
165+ return glyphs[i], true
166+ }
167+ i--
168+ for i >= 0 && d.isSeparator(d.runeAt(glyphs[i])) {
169+ i--
170+ }
171+ if i < 0 {
172+ return Pos{}, false
173+ }
174+ for i > 0 && d.sameWord(glyphs[i-1], glyphs[i]) {
175+ i--
176+ }
177+ return glyphs[i], true
178+}
179+
180+// NextWordEnd returns the inclusive end of the current word, or of the next
181+// word when pos is on a separator.
182+func (d *Document) NextWordEnd(pos Pos) (Pos, bool) {
183+ pos, ok := d.Normalize(pos)
184+ if !ok {
185+ return Pos{}, false
186+ }
187+ glyphs := d.glyphs()
188+ i := d.glyphIndex(glyphs, pos)
189+ if i < 0 {
190+ return Pos{}, false
191+ }
192+ for i < len(glyphs) && d.isSeparator(d.runeAt(glyphs[i])) {
193+ i++
194+ }
195+ if i == len(glyphs) {
196+ return Pos{}, false
197+ }
198+ end := glyphs[i]
199+ for i+1 < len(glyphs) && d.sameWord(glyphs[i+1], glyphs[i]) {
200+ i++
201+ end = glyphs[i]
202+ }
203+ return end, true
204+}
205+
206+// LineText returns row text without duplicated continuation cells and retains
207+// the established copy-mode behavior of trimming trailing blank spaces.
208+func (d *Document) LineText(row int) string {
209+ return strings.TrimRight(d.cellsText(d.Row(row), 0, len(d.Row(row))-1), " ")
210+}
211+
212+// Extract returns inclusive ranges joined by physical-row newlines. Line-wise
213+// extraction trims trailing blank spaces; stream extraction preserves them.
214+func (d *Document) Extract(ranges []CellRange, linewise bool) string {
215+ if len(ranges) == 0 {
216+ return ""
217+ }
218+ lines := make([]string, 0, len(ranges))
219+ for _, r := range ranges {
220+ text, ok := d.rangeText(r)
221+ if !ok {
222+ continue
223+ }
224+ if linewise {
225+ text = strings.TrimRight(text, " ")
226+ }
227+ lines = append(lines, text)
228+ }
229+ return strings.Join(lines, "\n")
230+}
231+
232+func (d *Document) rangeText(r CellRange) (string, bool) {
233+ if r.Row < 0 || r.Row >= d.Len() {
234+ return "", false
235+ }
236+ row := d.Row(r.Row)
237+ if len(row) == 0 {
238+ return "", r.Start == 0 && r.End == 0
239+ }
240+ start, end := min(r.Start, r.End), max(r.Start, r.End)
241+ if end < 0 || start >= len(row) {
242+ return "", false
243+ }
244+ start = max(start, 0)
245+ end = min(end, len(row)-1)
246+ headStart, ok := d.Normalize(Pos{Row: r.Row, Col: start})
247+ if !ok {
248+ return "", false
249+ }
250+ headEnd, ok := d.Normalize(Pos{Row: r.Row, Col: end})
251+ if !ok {
252+ return "", false
253+ }
254+ return d.cellsText(row, headStart.Col, headEnd.Col), true
255+}
256+
257+func (d *Document) cellsText(row []renderer.Cell, start, end int) string {
258+ if start < 0 || end < start || start >= len(row) {
259+ return ""
260+ }
261+ end = min(end, len(row)-1)
262+ var text strings.Builder
263+ for col := start; col <= end; col++ {
264+ cell := row[col]
265+ if cell.Continuation {
266+ continue
267+ }
268+ if cell.Rune == 0 {
269+ text.WriteByte(' ')
270+ continue
271+ }
272+ text.WriteRune(cell.Rune)
273+ }
274+ return text.String()
275+}
276+
277+func (d *Document) isSeparator(r rune) bool {
278+ if unicode.IsSpace(r) {
279+ return true
280+ }
281+ _, ok := d.wordSeparators[r]
282+ return ok
283+}
284+
285+func (d *Document) isGlyph(pos Pos) bool {
286+ row := d.Row(pos.Row)
287+ return pos.Col >= 0 && pos.Col < len(row) && !row[pos.Col].Continuation
288+}
289+
290+func (d *Document) runeAt(pos Pos) rune {
291+ cell := d.Row(pos.Row)[pos.Col]
292+ if cell.Rune == 0 {
293+ return ' '
294+ }
295+ return cell.Rune
296+}
297+
298+func (d *Document) glyphs() []Pos {
299+ glyphs := make([]Pos, 0)
300+ for row := 0; row < d.Len(); row++ {
301+ for col, cell := range d.Row(row) {
302+ if !cell.Continuation {
303+ glyphs = append(glyphs, Pos{Row: row, Col: col})
304+ }
305+ }
306+ }
307+ return glyphs
308+}
309+
310+func (d *Document) glyphIndex(glyphs []Pos, pos Pos) int {
311+ for i, glyph := range glyphs {
312+ if glyph == pos {
313+ return i
314+ }
315+ }
316+ return -1
317+}
318+
319+func (d *Document) sameWord(left, right Pos) bool {
320+ return left.Row == right.Row && !d.isSeparator(d.runeAt(left)) && !d.isSeparator(d.runeAt(right))
321+}
322+
323+func (d *Document) firstWordAfterRow(glyphs []Pos, row int) (Pos, bool) {
324+ for _, glyph := range glyphs {
325+ if glyph.Row > row && !d.isSeparator(d.runeAt(glyph)) {
326+ return glyph, true
327+ }
328+ }
329+ return Pos{}, false
330+}
331+
332+func (d *Document) lastWordBeforeRow(glyphs []Pos, row int) (Pos, bool) {
333+ for i := len(glyphs) - 1; i >= 0; i-- {
334+ if glyphs[i].Row >= row || d.isSeparator(d.runeAt(glyphs[i])) {
335+ continue
336+ }
337+ for i > 0 && d.sameWord(glyphs[i-1], glyphs[i]) {
338+ i--
339+ }
340+ return glyphs[i], true
341+ }
342+ return Pos{}, false
343+}
@@ -0,0 +1,343 @@
1+package copy
2+
3+import (
4+ "strings"
5+ "unicode"
6+
7+ "github.com/bnema/vev/pkg/renderer"
8+)
9+
10+// Pos identifies a terminal display cell in a document. Valid positions are
11+// always glyph heads; Normalize converts continuation cells to their head.
12+type Pos struct {
13+ Row int
14+ Col int
15+}
16+
17+// CellRange is an inclusive display-cell range on one physical document row.
18+type CellRange struct {
19+ Row int
20+ Start int
21+ End int
22+}
23+
24+// Document provides immutable display-cell and text operations over a Snapshot.
25+type Document struct {
26+ snapshot Snapshot
27+ wordSeparators map[rune]struct{}
28+}
29+
30+func NewDocument(snapshot Snapshot, separators string) *Document {
31+ set := make(map[rune]struct{}, len([]rune(separators)))
32+ for _, r := range separators {
33+ set[r] = struct{}{}
34+ }
35+ return &Document{snapshot: snapshot, wordSeparators: set}
36+}
37+
38+func (d *Document) Snapshot() Snapshot { return d.snapshot }
39+func (d *Document) Len() int { return d.snapshot.Len() }
40+func (d *Document) Width() int { return d.snapshot.Width }
41+func (d *Document) Height() int { return d.snapshot.Height }
42+func (d *Document) Row(row int) []renderer.Cell { return d.snapshot.Row(row) }
43+
44+// Normalize returns pos as a valid glyph head. Empty physical rows have the
45+// stable logical position {row, 0}, which is useful to vertical navigation.
46+func (d *Document) Normalize(pos Pos) (Pos, bool) {
47+ if pos.Row < 0 || pos.Row >= d.Len() || pos.Col < 0 {
48+ return Pos{}, false
49+ }
50+ row := d.Row(pos.Row)
51+ if len(row) == 0 {
52+ if pos.Col == 0 {
53+ return pos, true
54+ }
55+ return Pos{}, false
56+ }
57+ if pos.Col >= len(row) {
58+ return Pos{}, false
59+ }
60+ for pos.Col > 0 && row[pos.Col].Continuation {
61+ pos.Col--
62+ }
63+ if row[pos.Col].Continuation {
64+ return Pos{}, false
65+ }
66+ return pos, true
67+}
68+
69+// PrevGlyph returns the previous glyph head on pos's physical row.
70+func (d *Document) PrevGlyph(pos Pos) (Pos, bool) {
71+ pos, ok := d.Normalize(pos)
72+ if !ok || len(d.Row(pos.Row)) == 0 {
73+ return Pos{}, false
74+ }
75+ for col := pos.Col - 1; col >= 0; col-- {
76+ if !d.Row(pos.Row)[col].Continuation {
77+ return Pos{Row: pos.Row, Col: col}, true
78+ }
79+ }
80+ return Pos{}, false
81+}
82+
83+// NextGlyph returns the next glyph head on pos's physical row.
84+func (d *Document) NextGlyph(pos Pos) (Pos, bool) {
85+ pos, ok := d.Normalize(pos)
86+ if !ok {
87+ return Pos{}, false
88+ }
89+ row := d.Row(pos.Row)
90+ for col := pos.Col + 1; col < len(row); col++ {
91+ if !row[col].Continuation {
92+ return Pos{Row: pos.Row, Col: col}, true
93+ }
94+ }
95+ return Pos{}, false
96+}
97+
98+// WordBounds returns the inclusive bounds of the word containing pos. Unicode
99+// whitespace and configured separators do not belong to words.
100+func (d *Document) WordBounds(pos Pos) (Pos, Pos, bool) {
101+ pos, ok := d.Normalize(pos)
102+ if !ok || !d.isGlyph(pos) || d.isSeparator(d.runeAt(pos)) {
103+ return Pos{}, Pos{}, false
104+ }
105+ start, end := pos, pos
106+ for {
107+ prev, ok := d.PrevGlyph(start)
108+ if !ok || d.isSeparator(d.runeAt(prev)) {
109+ break
110+ }
111+ start = prev
112+ }
113+ for {
114+ next, ok := d.NextGlyph(end)
115+ if !ok || d.isSeparator(d.runeAt(next)) {
116+ break
117+ }
118+ end = next
119+ }
120+ return start, end, true
121+}
122+
123+// NextWordStart returns the beginning of the next word after pos, crossing
124+// physical rows as necessary.
125+func (d *Document) NextWordStart(pos Pos) (Pos, bool) {
126+ pos, ok := d.Normalize(pos)
127+ if !ok {
128+ return Pos{}, false
129+ }
130+ glyphs := d.glyphs()
131+ i := d.glyphIndex(glyphs, pos)
132+ if i < 0 {
133+ return d.firstWordAfterRow(glyphs, pos.Row)
134+ }
135+ if !d.isSeparator(d.runeAt(glyphs[i])) {
136+ for i < len(glyphs) && d.sameWord(glyphs[i], pos) {
137+ i++
138+ }
139+ }
140+ for i < len(glyphs) && d.isSeparator(d.runeAt(glyphs[i])) {
141+ i++
142+ }
143+ if i < len(glyphs) {
144+ return glyphs[i], true
145+ }
146+ return Pos{}, false
147+}
148+
149+// PreviousWordStart returns the beginning of the current word, or of the
150+// preceding word when pos is already at a word beginning.
151+func (d *Document) PreviousWordStart(pos Pos) (Pos, bool) {
152+ pos, ok := d.Normalize(pos)
153+ if !ok {
154+ return Pos{}, false
155+ }
156+ glyphs := d.glyphs()
157+ i := d.glyphIndex(glyphs, pos)
158+ if i < 0 {
159+ return d.lastWordBeforeRow(glyphs, pos.Row)
160+ }
161+ if !d.isSeparator(d.runeAt(glyphs[i])) && i > 0 && d.sameWord(glyphs[i-1], glyphs[i]) {
162+ for i > 0 && d.sameWord(glyphs[i-1], glyphs[i]) {
163+ i--
164+ }
165+ return glyphs[i], true
166+ }
167+ i--
168+ for i >= 0 && d.isSeparator(d.runeAt(glyphs[i])) {
169+ i--
170+ }
171+ if i < 0 {
172+ return Pos{}, false
173+ }
174+ for i > 0 && d.sameWord(glyphs[i-1], glyphs[i]) {
175+ i--
176+ }
177+ return glyphs[i], true
178+}
179+
180+// NextWordEnd returns the inclusive end of the current word, or of the next
181+// word when pos is on a separator.
182+func (d *Document) NextWordEnd(pos Pos) (Pos, bool) {
183+ pos, ok := d.Normalize(pos)
184+ if !ok {
185+ return Pos{}, false
186+ }
187+ glyphs := d.glyphs()
188+ i := d.glyphIndex(glyphs, pos)
189+ if i < 0 {
190+ return Pos{}, false
191+ }
192+ for i < len(glyphs) && d.isSeparator(d.runeAt(glyphs[i])) {
193+ i++
194+ }
195+ if i == len(glyphs) {
196+ return Pos{}, false
197+ }
198+ end := glyphs[i]
199+ for i+1 < len(glyphs) && d.sameWord(glyphs[i+1], glyphs[i]) {
200+ i++
201+ end = glyphs[i]
202+ }
203+ return end, true
204+}
205+
206+// LineText returns row text without duplicated continuation cells and retains
207+// the established copy-mode behavior of trimming trailing blank spaces.
208+func (d *Document) LineText(row int) string {
209+ return strings.TrimRight(d.cellsText(d.Row(row), 0, len(d.Row(row))-1), " ")
210+}
211+
212+// Extract returns inclusive ranges joined by physical-row newlines. Line-wise
213+// extraction trims trailing blank spaces; stream extraction preserves them.
214+func (d *Document) Extract(ranges []CellRange, linewise bool) string {
215+ if len(ranges) == 0 {
216+ return ""
217+ }
218+ lines := make([]string, 0, len(ranges))
219+ for _, r := range ranges {
220+ text, ok := d.rangeText(r)
221+ if !ok {
222+ continue
223+ }
224+ if linewise {
225+ text = strings.TrimRight(text, " ")
226+ }
227+ lines = append(lines, text)
228+ }
229+ return strings.Join(lines, "\n")
230+}
231+
232+func (d *Document) rangeText(r CellRange) (string, bool) {
233+ if r.Row < 0 || r.Row >= d.Len() {
234+ return "", false
235+ }
236+ row := d.Row(r.Row)
237+ if len(row) == 0 {
238+ return "", r.Start == 0 && r.End == 0
239+ }
240+ start, end := min(r.Start, r.End), max(r.Start, r.End)
241+ if end < 0 || start >= len(row) {
242+ return "", false
243+ }
244+ start = max(start, 0)
245+ end = min(end, len(row)-1)
246+ headStart, ok := d.Normalize(Pos{Row: r.Row, Col: start})
247+ if !ok {
248+ return "", false
249+ }
250+ headEnd, ok := d.Normalize(Pos{Row: r.Row, Col: end})
251+ if !ok {
252+ return "", false
253+ }
254+ return d.cellsText(row, headStart.Col, headEnd.Col), true
255+}
256+
257+func (d *Document) cellsText(row []renderer.Cell, start, end int) string {
258+ if start < 0 || end < start || start >= len(row) {
259+ return ""
260+ }
261+ end = min(end, len(row)-1)
262+ var text strings.Builder
263+ for col := start; col <= end; col++ {
264+ cell := row[col]
265+ if cell.Continuation {
266+ continue
267+ }
268+ if cell.Rune == 0 {
269+ text.WriteByte(' ')
270+ continue
271+ }
272+ text.WriteRune(cell.Rune)
273+ }
274+ return text.String()
275+}
276+
277+func (d *Document) isSeparator(r rune) bool {
278+ if unicode.IsSpace(r) {
279+ return true
280+ }
281+ _, ok := d.wordSeparators[r]
282+ return ok
283+}
284+
285+func (d *Document) isGlyph(pos Pos) bool {
286+ row := d.Row(pos.Row)
287+ return pos.Col >= 0 && pos.Col < len(row) && !row[pos.Col].Continuation
288+}
289+
290+func (d *Document) runeAt(pos Pos) rune {
291+ cell := d.Row(pos.Row)[pos.Col]
292+ if cell.Rune == 0 {
293+ return ' '
294+ }
295+ return cell.Rune
296+}
297+
298+func (d *Document) glyphs() []Pos {
299+ glyphs := make([]Pos, 0)
300+ for row := 0; row < d.Len(); row++ {
301+ for col, cell := range d.Row(row) {
302+ if !cell.Continuation {
303+ glyphs = append(glyphs, Pos{Row: row, Col: col})
304+ }
305+ }
306+ }
307+ return glyphs
308+}
309+
310+func (d *Document) glyphIndex(glyphs []Pos, pos Pos) int {
311+ for i, glyph := range glyphs {
312+ if glyph == pos {
313+ return i
314+ }
315+ }
316+ return -1
317+}
318+
319+func (d *Document) sameWord(left, right Pos) bool {
320+ return left.Row == right.Row && !d.isSeparator(d.runeAt(left)) && !d.isSeparator(d.runeAt(right))
321+}
322+
323+func (d *Document) firstWordAfterRow(glyphs []Pos, row int) (Pos, bool) {
324+ for _, glyph := range glyphs {
325+ if glyph.Row > row && !d.isSeparator(d.runeAt(glyph)) {
326+ return glyph, true
327+ }
328+ }
329+ return Pos{}, false
330+}
331+
332+func (d *Document) lastWordBeforeRow(glyphs []Pos, row int) (Pos, bool) {
333+ for i := len(glyphs) - 1; i >= 0; i-- {
334+ if glyphs[i].Row >= row || d.isSeparator(d.runeAt(glyphs[i])) {
335+ continue
336+ }
337+ for i > 0 && d.sameWord(glyphs[i-1], glyphs[i]) {
338+ i--
339+ }
340+ return glyphs[i], true
341+ }
342+ return Pos{}, false
343+}
A internal/usecase/copy/document_test.go +210 -0
@@ -0,0 +1,210 @@
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 documentCells(text string) []renderer.Cell {
11+ cells := make([]renderer.Cell, 0, len([]rune(text)))
12+ for _, r := range text {
13+ cells = append(cells, renderer.Cell{Rune: r})
14+ }
15+ return cells
16+}
17+
18+func documentFromStrings(lines []string, separators string) *Document {
19+ rows := make([][]renderer.Cell, len(lines))
20+ for i, line := range lines {
21+ rows[i] = documentCells(line)
22+ }
23+ return NewDocument(NewSnapshotFromRows(rows, 32, 4), separators)
24+}
25+
26+func mustNormalize(t *testing.T, doc *Document, pos Pos) Pos {
27+ t.Helper()
28+ got, ok := doc.Normalize(pos)
29+ require.True(t, ok)
30+ return got
31+}
32+
33+func mustPrev(t *testing.T, doc *Document, pos Pos) Pos {
34+ t.Helper()
35+ got, ok := doc.PrevGlyph(pos)
36+ require.True(t, ok)
37+ return got
38+}
39+
40+func mustNext(t *testing.T, doc *Document, pos Pos) Pos {
41+ t.Helper()
42+ got, ok := doc.NextGlyph(pos)
43+ require.True(t, ok)
44+ return got
45+}
46+
47+func mustWordStart(t *testing.T, doc *Document, pos Pos) Pos {
48+ t.Helper()
49+ got, _, ok := doc.WordBounds(pos)
50+ require.True(t, ok)
51+ return got
52+}
53+
54+func mustWordEnd(t *testing.T, doc *Document, pos Pos) Pos {
55+ t.Helper()
56+ _, got, ok := doc.WordBounds(pos)
57+ require.True(t, ok)
58+ return got
59+}
60+
61+func TestDocumentNormalize(t *testing.T) {
62+ wide := []renderer.Cell{{Rune: 'a'}, {Rune: '界'}, {Continuation: true}, {Rune: ' '}, {Rune: 'b'}}
63+ doc := NewDocument(NewSnapshotFromRows([][]renderer.Cell{wide, {}}, 5, 1), " -_@")
64+
65+ tests := []struct {
66+ name string
67+ in Pos
68+ want Pos
69+ ok bool
70+ }{
71+ {"glyph head", Pos{0, 1}, Pos{0, 1}, true},
72+ {"wide continuation", Pos{0, 2}, Pos{0, 1}, true},
73+ {"empty row stable position", Pos{1, 0}, Pos{1, 0}, true},
74+ {"negative row", Pos{-1, 0}, Pos{}, false},
75+ {"past row", Pos{2, 0}, Pos{}, false},
76+ {"negative column", Pos{0, -1}, Pos{}, false},
77+ {"past column", Pos{0, 5}, Pos{}, false},
78+ {"empty row past column", Pos{1, 1}, Pos{}, false},
79+ }
80+ for _, tt := range tests {
81+ t.Run(tt.name, func(t *testing.T) {
82+ got, ok := doc.Normalize(tt.in)
83+ require.Equal(t, tt.ok, ok)
84+ if ok {
85+ require.Equal(t, tt.want, got)
86+ }
87+ })
88+ }
89+}
90+
91+func TestDocumentGlyphNavigation(t *testing.T) {
92+ wide := []renderer.Cell{{Rune: 'a'}, {Rune: '界'}, {Continuation: true}, {Rune: ' '}, {Rune: 'b'}}
93+ doc := NewDocument(NewSnapshotFromRows([][]renderer.Cell{wide}, 5, 1), " -_@")
94+
95+ tests := []struct {
96+ name string
97+ fn func(Pos) (Pos, bool)
98+ in Pos
99+ want Pos
100+ ok bool
101+ }{
102+ {"next narrow to wide", doc.NextGlyph, Pos{0, 0}, Pos{0, 1}, true},
103+ {"next skips continuation", doc.NextGlyph, Pos{0, 1}, Pos{0, 3}, true},
104+ {"next at end", doc.NextGlyph, Pos{0, 4}, Pos{}, false},
105+ {"previous skips continuation", doc.PrevGlyph, Pos{0, 3}, Pos{0, 1}, true},
106+ {"previous continuation normalizes", doc.PrevGlyph, Pos{0, 2}, Pos{0, 0}, true},
107+ {"previous at start", doc.PrevGlyph, Pos{0, 0}, Pos{}, false},
108+ }
109+ for _, tt := range tests {
110+ t.Run(tt.name, func(t *testing.T) {
111+ got, ok := tt.fn(tt.in)
112+ require.Equal(t, tt.ok, ok)
113+ if ok {
114+ require.Equal(t, tt.want, got)
115+ }
116+ })
117+ }
118+}
119+
120+func TestDocumentWordBounds(t *testing.T) {
121+ doc := documentFromStrings([]string{"alpha/beta gamma", "été\u2003東京", ""}, "/")
122+
123+ tests := []struct {
124+ name string
125+ in Pos
126+ start Pos
127+ end Pos
128+ ok bool
129+ }{
130+ {"configured separator", Pos{0, 5}, Pos{}, Pos{}, false},
131+ {"second word", Pos{0, 8}, Pos{0, 6}, Pos{0, 9}, true},
132+ {"unicode whitespace", Pos{1, 3}, Pos{}, Pos{}, false},
133+ {"unicode word", Pos{1, 5}, Pos{1, 4}, Pos{1, 5}, true},
134+ {"empty row", Pos{2, 0}, Pos{}, Pos{}, false},
135+ {"out of range", Pos{4, 0}, Pos{}, Pos{}, false},
136+ }
137+ for _, tt := range tests {
138+ t.Run(tt.name, func(t *testing.T) {
139+ start, end, ok := doc.WordBounds(tt.in)
140+ require.Equal(t, tt.ok, ok)
141+ if ok {
142+ require.Equal(t, tt.start, start)
143+ require.Equal(t, tt.end, end)
144+ }
145+ })
146+ }
147+}
148+
149+func TestDocumentWordMovement(t *testing.T) {
150+ doc := documentFromStrings([]string{"alpha/beta", " gamma", "delta"}, "/")
151+
152+ tests := []struct {
153+ name string
154+ fn func(Pos) (Pos, bool)
155+ in Pos
156+ want Pos
157+ }{
158+ {"next skips current word and separator", doc.NextWordStart, Pos{0, 2}, Pos{0, 6}},
159+ {"next crosses rows", doc.NextWordStart, Pos{0, 7}, Pos{1, 1}},
160+ {"previous returns current word start", doc.PreviousWordStart, Pos{1, 3}, Pos{1, 1}},
161+ {"previous crosses rows", doc.PreviousWordStart, Pos{1, 1}, Pos{0, 6}},
162+ {"next end returns current word end", doc.NextWordEnd, Pos{0, 2}, Pos{0, 4}},
163+ {"next end skips separators", doc.NextWordEnd, Pos{0, 5}, Pos{0, 9}},
164+ }
165+ for _, tt := range tests {
166+ t.Run(tt.name, func(t *testing.T) {
167+ got, ok := tt.fn(tt.in)
168+ require.True(t, ok)
169+ require.Equal(t, tt.want, got)
170+ })
171+ }
172+}
173+
174+func TestDocumentExtractRanges(t *testing.T) {
175+ wide := []renderer.Cell{{Rune: 'a'}, {Rune: '界'}, {Continuation: true}, {Rune: ' '}, {Rune: 'b'}}
176+ doc := NewDocument(NewSnapshotFromRows([][]renderer.Cell{documentCells("alpha "), documentCells("beta"), wide, {}}, 7, 4), "")
177+
178+ tests := []struct {
179+ name string
180+ ranges []CellRange
181+ linewise bool
182+ want string
183+ }{
184+ {"inclusive partial multiline preserves spaces", []CellRange{{Row: 0, Start: 2, End: 6}, {Row: 1, Start: 0, End: 1}}, false, "pha \nbe"},
185+ {"linewise trims trailing spaces", []CellRange{{Row: 0, Start: 0, End: 6}}, true, "alpha"},
186+ {"wide glyph emitted once", []CellRange{{Row: 2, Start: 1, End: 2}}, false, "界"},
187+ {"continuation endpoint normalizes to head", []CellRange{{Row: 2, Start: 2, End: 2}}, false, "界"},
188+ {"empty row", []CellRange{{Row: 3, Start: 0, End: 0}}, false, ""},
189+ {"out of range ignored", []CellRange{{Row: 8, Start: 0, End: 1}}, false, ""},
190+ {"no ranges", nil, false, ""},
191+ }
192+ for _, tt := range tests {
193+ t.Run(tt.name, func(t *testing.T) {
194+ require.Equal(t, tt.want, doc.Extract(tt.ranges, tt.linewise))
195+ })
196+ }
197+}
198+
199+func TestDocumentAccessorsExposeImmutableSnapshot(t *testing.T) {
200+ rows := [][]renderer.Cell{documentCells("one")}
201+ doc := NewDocument(NewSnapshotFromRows(rows, 9, 2), "_")
202+ rows[0][0].Rune = 'x'
203+
204+ require.Equal(t, 1, doc.Len())
205+ require.Equal(t, 9, doc.Width())
206+ require.Equal(t, 2, doc.Height())
207+ require.Equal(t, "one", doc.LineText(0))
208+ require.Equal(t, doc.Snapshot().Row(0), doc.Row(0))
209+ require.Nil(t, doc.Row(1))
210+}
@@ -0,0 +1,210 @@
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 documentCells(text string) []renderer.Cell {
11+ cells := make([]renderer.Cell, 0, len([]rune(text)))
12+ for _, r := range text {
13+ cells = append(cells, renderer.Cell{Rune: r})
14+ }
15+ return cells
16+}
17+
18+func documentFromStrings(lines []string, separators string) *Document {
19+ rows := make([][]renderer.Cell, len(lines))
20+ for i, line := range lines {
21+ rows[i] = documentCells(line)
22+ }
23+ return NewDocument(NewSnapshotFromRows(rows, 32, 4), separators)
24+}
25+
26+func mustNormalize(t *testing.T, doc *Document, pos Pos) Pos {
27+ t.Helper()
28+ got, ok := doc.Normalize(pos)
29+ require.True(t, ok)
30+ return got
31+}
32+
33+func mustPrev(t *testing.T, doc *Document, pos Pos) Pos {
34+ t.Helper()
35+ got, ok := doc.PrevGlyph(pos)
36+ require.True(t, ok)
37+ return got
38+}
39+
40+func mustNext(t *testing.T, doc *Document, pos Pos) Pos {
41+ t.Helper()
42+ got, ok := doc.NextGlyph(pos)
43+ require.True(t, ok)
44+ return got
45+}
46+
47+func mustWordStart(t *testing.T, doc *Document, pos Pos) Pos {
48+ t.Helper()
49+ got, _, ok := doc.WordBounds(pos)
50+ require.True(t, ok)
51+ return got
52+}
53+
54+func mustWordEnd(t *testing.T, doc *Document, pos Pos) Pos {
55+ t.Helper()
56+ _, got, ok := doc.WordBounds(pos)
57+ require.True(t, ok)
58+ return got
59+}
60+
61+func TestDocumentNormalize(t *testing.T) {
62+ wide := []renderer.Cell{{Rune: 'a'}, {Rune: '界'}, {Continuation: true}, {Rune: ' '}, {Rune: 'b'}}
63+ doc := NewDocument(NewSnapshotFromRows([][]renderer.Cell{wide, {}}, 5, 1), " -_@")
64+
65+ tests := []struct {
66+ name string
67+ in Pos
68+ want Pos
69+ ok bool
70+ }{
71+ {"glyph head", Pos{0, 1}, Pos{0, 1}, true},
72+ {"wide continuation", Pos{0, 2}, Pos{0, 1}, true},
73+ {"empty row stable position", Pos{1, 0}, Pos{1, 0}, true},
74+ {"negative row", Pos{-1, 0}, Pos{}, false},
75+ {"past row", Pos{2, 0}, Pos{}, false},
76+ {"negative column", Pos{0, -1}, Pos{}, false},
77+ {"past column", Pos{0, 5}, Pos{}, false},
78+ {"empty row past column", Pos{1, 1}, Pos{}, false},
79+ }
80+ for _, tt := range tests {
81+ t.Run(tt.name, func(t *testing.T) {
82+ got, ok := doc.Normalize(tt.in)
83+ require.Equal(t, tt.ok, ok)
84+ if ok {
85+ require.Equal(t, tt.want, got)
86+ }
87+ })
88+ }
89+}
90+
91+func TestDocumentGlyphNavigation(t *testing.T) {
92+ wide := []renderer.Cell{{Rune: 'a'}, {Rune: '界'}, {Continuation: true}, {Rune: ' '}, {Rune: 'b'}}
93+ doc := NewDocument(NewSnapshotFromRows([][]renderer.Cell{wide}, 5, 1), " -_@")
94+
95+ tests := []struct {
96+ name string
97+ fn func(Pos) (Pos, bool)
98+ in Pos
99+ want Pos
100+ ok bool
101+ }{
102+ {"next narrow to wide", doc.NextGlyph, Pos{0, 0}, Pos{0, 1}, true},
103+ {"next skips continuation", doc.NextGlyph, Pos{0, 1}, Pos{0, 3}, true},
104+ {"next at end", doc.NextGlyph, Pos{0, 4}, Pos{}, false},
105+ {"previous skips continuation", doc.PrevGlyph, Pos{0, 3}, Pos{0, 1}, true},
106+ {"previous continuation normalizes", doc.PrevGlyph, Pos{0, 2}, Pos{0, 0}, true},
107+ {"previous at start", doc.PrevGlyph, Pos{0, 0}, Pos{}, false},
108+ }
109+ for _, tt := range tests {
110+ t.Run(tt.name, func(t *testing.T) {
111+ got, ok := tt.fn(tt.in)
112+ require.Equal(t, tt.ok, ok)
113+ if ok {
114+ require.Equal(t, tt.want, got)
115+ }
116+ })
117+ }
118+}
119+
120+func TestDocumentWordBounds(t *testing.T) {
121+ doc := documentFromStrings([]string{"alpha/beta gamma", "été\u2003東京", ""}, "/")
122+
123+ tests := []struct {
124+ name string
125+ in Pos
126+ start Pos
127+ end Pos
128+ ok bool
129+ }{
130+ {"configured separator", Pos{0, 5}, Pos{}, Pos{}, false},
131+ {"second word", Pos{0, 8}, Pos{0, 6}, Pos{0, 9}, true},
132+ {"unicode whitespace", Pos{1, 3}, Pos{}, Pos{}, false},
133+ {"unicode word", Pos{1, 5}, Pos{1, 4}, Pos{1, 5}, true},
134+ {"empty row", Pos{2, 0}, Pos{}, Pos{}, false},
135+ {"out of range", Pos{4, 0}, Pos{}, Pos{}, false},
136+ }
137+ for _, tt := range tests {
138+ t.Run(tt.name, func(t *testing.T) {
139+ start, end, ok := doc.WordBounds(tt.in)
140+ require.Equal(t, tt.ok, ok)
141+ if ok {
142+ require.Equal(t, tt.start, start)
143+ require.Equal(t, tt.end, end)
144+ }
145+ })
146+ }
147+}
148+
149+func TestDocumentWordMovement(t *testing.T) {
150+ doc := documentFromStrings([]string{"alpha/beta", " gamma", "delta"}, "/")
151+
152+ tests := []struct {
153+ name string
154+ fn func(Pos) (Pos, bool)
155+ in Pos
156+ want Pos
157+ }{
158+ {"next skips current word and separator", doc.NextWordStart, Pos{0, 2}, Pos{0, 6}},
159+ {"next crosses rows", doc.NextWordStart, Pos{0, 7}, Pos{1, 1}},
160+ {"previous returns current word start", doc.PreviousWordStart, Pos{1, 3}, Pos{1, 1}},
161+ {"previous crosses rows", doc.PreviousWordStart, Pos{1, 1}, Pos{0, 6}},
162+ {"next end returns current word end", doc.NextWordEnd, Pos{0, 2}, Pos{0, 4}},
163+ {"next end skips separators", doc.NextWordEnd, Pos{0, 5}, Pos{0, 9}},
164+ }
165+ for _, tt := range tests {
166+ t.Run(tt.name, func(t *testing.T) {
167+ got, ok := tt.fn(tt.in)
168+ require.True(t, ok)
169+ require.Equal(t, tt.want, got)
170+ })
171+ }
172+}
173+
174+func TestDocumentExtractRanges(t *testing.T) {
175+ wide := []renderer.Cell{{Rune: 'a'}, {Rune: '界'}, {Continuation: true}, {Rune: ' '}, {Rune: 'b'}}
176+ doc := NewDocument(NewSnapshotFromRows([][]renderer.Cell{documentCells("alpha "), documentCells("beta"), wide, {}}, 7, 4), "")
177+
178+ tests := []struct {
179+ name string
180+ ranges []CellRange
181+ linewise bool
182+ want string
183+ }{
184+ {"inclusive partial multiline preserves spaces", []CellRange{{Row: 0, Start: 2, End: 6}, {Row: 1, Start: 0, End: 1}}, false, "pha \nbe"},
185+ {"linewise trims trailing spaces", []CellRange{{Row: 0, Start: 0, End: 6}}, true, "alpha"},
186+ {"wide glyph emitted once", []CellRange{{Row: 2, Start: 1, End: 2}}, false, "界"},
187+ {"continuation endpoint normalizes to head", []CellRange{{Row: 2, Start: 2, End: 2}}, false, "界"},
188+ {"empty row", []CellRange{{Row: 3, Start: 0, End: 0}}, false, ""},
189+ {"out of range ignored", []CellRange{{Row: 8, Start: 0, End: 1}}, false, ""},
190+ {"no ranges", nil, false, ""},
191+ }
192+ for _, tt := range tests {
193+ t.Run(tt.name, func(t *testing.T) {
194+ require.Equal(t, tt.want, doc.Extract(tt.ranges, tt.linewise))
195+ })
196+ }
197+}
198+
199+func TestDocumentAccessorsExposeImmutableSnapshot(t *testing.T) {
200+ rows := [][]renderer.Cell{documentCells("one")}
201+ doc := NewDocument(NewSnapshotFromRows(rows, 9, 2), "_")
202+ rows[0][0].Rune = 'x'
203+
204+ require.Equal(t, 1, doc.Len())
205+ require.Equal(t, 9, doc.Width())
206+ require.Equal(t, 2, doc.Height())
207+ require.Equal(t, "one", doc.LineText(0))
208+ require.Equal(t, doc.Snapshot().Row(0), doc.Row(0))
209+ require.Nil(t, doc.Row(1))
210+}

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