detached 93f7e39

Merge pull request 'feat(copy): bound selection to content and preserve colors' (#5) from feat/copy-selection-content-bounds into main

Reviewed-on: https://git.bnema.dev/bnema/vev/pulls/5
commit
93f7e3993ab004b3240eaeef044e2a9d36e33432
author
bnema · · 2026-10-05 08:16 UTC
merge of
668edcc + 1b6fda7 · diff against the first parent
signature
unsigned
refs
tag: v0.6.0
7 files · +294 -34
A internal/usecase/copy/content_render_test.go +106 -0
@@ -0,0 +1,106 @@
1+package copy
2+
3+import (
4+ "strings"
5+ "testing"
6+
7+ vt "github.com/bnema/vev-vt"
8+ "github.com/stretchr/testify/require"
9+)
10+
11+func TestRenderRowsSelectionHighlightsContentOnly(t *testing.T) {
12+ wide := padRow("", 8)
13+ wide[0] = vt.Cell{Rune: 'a'}
14+ wide[1] = vt.Cell{Rune: '界'}
15+ wide[2] = vt.Cell{Continuation: true}
16+ tests := []struct {
17+ name string
18+ rows [][]vt.Cell
19+ bounds []vt.LineBound
20+ end Pos
21+ granularity Granularity
22+ want [][]int
23+ }{
24+ {"hard rows", [][]vt.Cell{padRow("ab", 8), padRow("cd", 8)}, nil, Pos{1, 1}, Character, [][]int{{0, 1, 2}, {0, 1}}},
25+ {"word rows", [][]vt.Cell{padRow("ab", 8), padRow("cd", 8)}, nil, Pos{1, 1}, Word, [][]int{{0, 1, 2}, {0, 1}}},
26+ {"line mode", [][]vt.Cell{padRow("ab", 8), padRow("", 8), padRow("cd", 8)}, nil, Pos{2, 0}, Line, [][]int{{0, 1, 2}, {}, {0, 1, 2}}},
27+ {"soft spaces", [][]vt.Cell{padRow("abc ", 8), padRow("de", 8)}, []vt.LineBound{{End: 8, Soft: true}, {End: 2}}, Pos{1, 1}, Character, [][]int{{0, 1, 2, 3, 4, 5, 6, 7}, {0, 1}}},
28+ {"soft partial", [][]vt.Cell{padRow("abc", 8), padRow("de", 8)}, []vt.LineBound{{End: 5, Soft: true}, {End: 2}}, Pos{1, 1}, Character, [][]int{{0, 1, 2, 3, 4}, {0, 1}}},
29+ {"full hard row", [][]vt.Cell{padRow("abcdefgh", 8), padRow("ij", 8)}, nil, Pos{1, 0}, Character, [][]int{{0, 1, 2, 3, 4, 5, 6, 7}, {0}}},
30+ {"wide glyph", [][]vt.Cell{wide}, nil, Pos{0, 0}, Line, [][]int{{0, 1, 2, 3}}},
31+ {"end in padding", [][]vt.Cell{padRow("ab", 8)}, nil, Pos{0, 6}, Character, [][]int{{0, 1}}},
32+ }
33+ for _, tt := range tests {
34+ t.Run(tt.name, func(t *testing.T) {
35+ doc := NewDocument(NewSnapshotFromLines(tt.rows, tt.bounds, 8, len(tt.rows)), "")
36+ m := NewMode(doc)
37+ m.selection = Selection{Anchor: Pos{0, 0}, Active: tt.end, Granularity: tt.granularity, Enabled: true}
38+ m.navigator.Pos = Pos{0, 0}
39+ f := m.Render(vt.DefaultStyle(), vt.Style{HasBackgroundRGB: true, BackgroundRGB: vt.RGB{R: 1}})
40+ var copied strings.Builder
41+ bounds, _ := m.selection.bounds(doc)
42+ for y, source := range tt.rows {
43+ cols := []int{}
44+ marker, hasMarker := bounds.newlineMarker(doc, y)
45+ for x, cell := range source {
46+ if f.At(x, y).Style.Inverse == cell.Style.Inverse {
47+ continue
48+ }
49+ cols = append(cols, x)
50+ if (hasMarker && x == marker) || cell.Continuation {
51+ continue
52+ }
53+ if cell.Rune == 0 {
54+ copied.WriteByte(' ')
55+ } else {
56+ copied.WriteRune(cell.Rune)
57+ }
58+ }
59+ require.Equal(t, tt.want[y], cols)
60+ if y < len(tt.rows)-1 && !doc.snapshot.Bound(y).Soft {
61+ copied.WriteByte('\n')
62+ }
63+ }
64+ require.Equal(t, copied.String(), m.SelectedText(), "highlighted glyphs match copied text")
65+ for y, source := range tt.rows {
66+ require.Equal(t, source, doc.Row(y))
67+ }
68+ })
69+ }
70+}
71+
72+func TestRenderRowsSelectionAfterHistoryResize(t *testing.T) {
73+ screen := vt.NewScreenWithHistory(12, 2, vt.HistoryConfig{MaxRows: 64})
74+ screen.Write([]byte("abc xyz\r\nq\r\nr\r\ns"))
75+ screen.Resize(8, 2)
76+ doc := NewDocument(NewSnapshot(screen.History(), screen, screen.LineBounds(), nil), "")
77+ m := NewMode(doc)
78+ m.navigator.Pos = Pos{0, 0}
79+ m.ViewportTop = 0
80+ m.selection = Selection{Anchor: Pos{0, 0}, Active: Pos{1, 0}, Granularity: Character, Enabled: true}
81+ require.Equal(t, "abc xyz\nq", m.SelectedText())
82+ for _, col := range []int{0, 10} {
83+ m.navigator.Pos = Pos{0, col}
84+ f := m.Render(vt.DefaultStyle(), vt.Style{HasBackgroundRGB: true})
85+ for x := 0; x < 8; x++ {
86+ require.True(t, f.At(x, 0).Style.Inverse, "cursor %d, visible content column %d", col, x)
87+ }
88+ }
89+}
90+
91+func TestRenderRowsSelectionPreservesCellColors(t *testing.T) {
92+ styles := []vt.Style{
93+ {HasForegroundRGB: true, ForegroundRGB: vt.RGB{G: 255, B: 255}, Bold: true},
94+ {Inverse: true, HasBackgroundRGB: true, BackgroundRGB: vt.RGB{R: 20}, Italic: true},
95+ }
96+ cells := []vt.Cell{{Rune: 'a', Style: styles[0]}, {Rune: 'b', Style: styles[1]}}
97+ m := NewMode(NewDocument(NewSnapshotFromRows([][]vt.Cell{cells}, 2, 1), ""))
98+ require.True(t, m.StartCharacterSelection(Pos{0, 0}))
99+ require.True(t, m.ExtendCharacterSelection(Pos{0, 1}))
100+ f := m.Render(vt.DefaultStyle(), vt.Style{HasBackgroundRGB: true, BackgroundRGB: vt.RGB{R: 1}})
101+ for x, style := range styles {
102+ style.Inverse = !style.Inverse
103+ require.Equal(t, style, f.At(x, 0).Style)
104+ }
105+ require.Equal(t, cells, m.Document().Row(0))
106+}
@@ -0,0 +1,106 @@
1+package copy
2+
3+import (
4+ "strings"
5+ "testing"
6+
7+ vt "github.com/bnema/vev-vt"
8+ "github.com/stretchr/testify/require"
9+)
10+
11+func TestRenderRowsSelectionHighlightsContentOnly(t *testing.T) {
12+ wide := padRow("", 8)
13+ wide[0] = vt.Cell{Rune: 'a'}
14+ wide[1] = vt.Cell{Rune: '界'}
15+ wide[2] = vt.Cell{Continuation: true}
16+ tests := []struct {
17+ name string
18+ rows [][]vt.Cell
19+ bounds []vt.LineBound
20+ end Pos
21+ granularity Granularity
22+ want [][]int
23+ }{
24+ {"hard rows", [][]vt.Cell{padRow("ab", 8), padRow("cd", 8)}, nil, Pos{1, 1}, Character, [][]int{{0, 1, 2}, {0, 1}}},
25+ {"word rows", [][]vt.Cell{padRow("ab", 8), padRow("cd", 8)}, nil, Pos{1, 1}, Word, [][]int{{0, 1, 2}, {0, 1}}},
26+ {"line mode", [][]vt.Cell{padRow("ab", 8), padRow("", 8), padRow("cd", 8)}, nil, Pos{2, 0}, Line, [][]int{{0, 1, 2}, {}, {0, 1, 2}}},
27+ {"soft spaces", [][]vt.Cell{padRow("abc ", 8), padRow("de", 8)}, []vt.LineBound{{End: 8, Soft: true}, {End: 2}}, Pos{1, 1}, Character, [][]int{{0, 1, 2, 3, 4, 5, 6, 7}, {0, 1}}},
28+ {"soft partial", [][]vt.Cell{padRow("abc", 8), padRow("de", 8)}, []vt.LineBound{{End: 5, Soft: true}, {End: 2}}, Pos{1, 1}, Character, [][]int{{0, 1, 2, 3, 4}, {0, 1}}},
29+ {"full hard row", [][]vt.Cell{padRow("abcdefgh", 8), padRow("ij", 8)}, nil, Pos{1, 0}, Character, [][]int{{0, 1, 2, 3, 4, 5, 6, 7}, {0}}},
30+ {"wide glyph", [][]vt.Cell{wide}, nil, Pos{0, 0}, Line, [][]int{{0, 1, 2, 3}}},
31+ {"end in padding", [][]vt.Cell{padRow("ab", 8)}, nil, Pos{0, 6}, Character, [][]int{{0, 1}}},
32+ }
33+ for _, tt := range tests {
34+ t.Run(tt.name, func(t *testing.T) {
35+ doc := NewDocument(NewSnapshotFromLines(tt.rows, tt.bounds, 8, len(tt.rows)), "")
36+ m := NewMode(doc)
37+ m.selection = Selection{Anchor: Pos{0, 0}, Active: tt.end, Granularity: tt.granularity, Enabled: true}
38+ m.navigator.Pos = Pos{0, 0}
39+ f := m.Render(vt.DefaultStyle(), vt.Style{HasBackgroundRGB: true, BackgroundRGB: vt.RGB{R: 1}})
40+ var copied strings.Builder
41+ bounds, _ := m.selection.bounds(doc)
42+ for y, source := range tt.rows {
43+ cols := []int{}
44+ marker, hasMarker := bounds.newlineMarker(doc, y)
45+ for x, cell := range source {
46+ if f.At(x, y).Style.Inverse == cell.Style.Inverse {
47+ continue
48+ }
49+ cols = append(cols, x)
50+ if (hasMarker && x == marker) || cell.Continuation {
51+ continue
52+ }
53+ if cell.Rune == 0 {
54+ copied.WriteByte(' ')
55+ } else {
56+ copied.WriteRune(cell.Rune)
57+ }
58+ }
59+ require.Equal(t, tt.want[y], cols)
60+ if y < len(tt.rows)-1 && !doc.snapshot.Bound(y).Soft {
61+ copied.WriteByte('\n')
62+ }
63+ }
64+ require.Equal(t, copied.String(), m.SelectedText(), "highlighted glyphs match copied text")
65+ for y, source := range tt.rows {
66+ require.Equal(t, source, doc.Row(y))
67+ }
68+ })
69+ }
70+}
71+
72+func TestRenderRowsSelectionAfterHistoryResize(t *testing.T) {
73+ screen := vt.NewScreenWithHistory(12, 2, vt.HistoryConfig{MaxRows: 64})
74+ screen.Write([]byte("abc xyz\r\nq\r\nr\r\ns"))
75+ screen.Resize(8, 2)
76+ doc := NewDocument(NewSnapshot(screen.History(), screen, screen.LineBounds(), nil), "")
77+ m := NewMode(doc)
78+ m.navigator.Pos = Pos{0, 0}
79+ m.ViewportTop = 0
80+ m.selection = Selection{Anchor: Pos{0, 0}, Active: Pos{1, 0}, Granularity: Character, Enabled: true}
81+ require.Equal(t, "abc xyz\nq", m.SelectedText())
82+ for _, col := range []int{0, 10} {
83+ m.navigator.Pos = Pos{0, col}
84+ f := m.Render(vt.DefaultStyle(), vt.Style{HasBackgroundRGB: true})
85+ for x := 0; x < 8; x++ {
86+ require.True(t, f.At(x, 0).Style.Inverse, "cursor %d, visible content column %d", col, x)
87+ }
88+ }
89+}
90+
91+func TestRenderRowsSelectionPreservesCellColors(t *testing.T) {
92+ styles := []vt.Style{
93+ {HasForegroundRGB: true, ForegroundRGB: vt.RGB{G: 255, B: 255}, Bold: true},
94+ {Inverse: true, HasBackgroundRGB: true, BackgroundRGB: vt.RGB{R: 20}, Italic: true},
95+ }
96+ cells := []vt.Cell{{Rune: 'a', Style: styles[0]}, {Rune: 'b', Style: styles[1]}}
97+ m := NewMode(NewDocument(NewSnapshotFromRows([][]vt.Cell{cells}, 2, 1), ""))
98+ require.True(t, m.StartCharacterSelection(Pos{0, 0}))
99+ require.True(t, m.ExtendCharacterSelection(Pos{0, 1}))
100+ f := m.Render(vt.DefaultStyle(), vt.Style{HasBackgroundRGB: true, BackgroundRGB: vt.RGB{R: 1}})
101+ for x, style := range styles {
102+ style.Inverse = !style.Inverse
103+ require.Equal(t, style, f.At(x, 0).Style)
104+ }
105+ require.Equal(t, cells, m.Document().Row(0))
106+}
A internal/usecase/copy/content_selection_test.go +56 -0
@@ -0,0 +1,56 @@
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+ {"reversed", []CellRange{{Row: 2, Start: 7, End: 0}}, "cd"},
49+ {"past end", []CellRange{{Row: 0, Start: 99, End: 50}, {Row: 2, End: 1}}, "cd"},
50+ {"negative", []CellRange{{Row: 0, Start: -3, End: -9}, {Row: 2, End: 1}}, "cd"},
51+ {"blank middle", []CellRange{{Row: 0, End: 1}, {Row: 1, End: -1}, {Row: 2, End: 1}}, "ab\n\ncd"},
52+ }
53+ for _, tt := range tests {
54+ t.Run(tt.name, func(t *testing.T) { require.Equal(t, tt.want, doc.Extract(tt.ranges)) })
55+ }
56+}
@@ -0,0 +1,56 @@
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+ {"reversed", []CellRange{{Row: 2, Start: 7, End: 0}}, "cd"},
49+ {"past end", []CellRange{{Row: 0, Start: 99, End: 50}, {Row: 2, End: 1}}, "cd"},
50+ {"negative", []CellRange{{Row: 0, Start: -3, End: -9}, {Row: 2, End: 1}}, "cd"},
51+ {"blank middle", []CellRange{{Row: 0, End: 1}, {Row: 1, End: -1}, {Row: 2, End: 1}}, "ab\n\ncd"},
52+ }
53+ for _, tt := range tests {
54+ t.Run(tt.name, func(t *testing.T) { require.Equal(t, tt.want, doc.Extract(tt.ranges)) })
55+ }
56+}
M internal/usecase/copy/document.go +29 -2
@@ -16,6 +16,8 @@ type Pos struct {
1616 }
1717
1818 // CellRange is an inclusive display-cell range on one physical document row.
19+// End == Start-1 with an in-bounds Start denotes a selected row with no cells;
20+// this adjacent pair is never interpreted as a reversed range.
1921 type CellRange struct {
2022 Row int
2123 Start int
@@ -285,6 +287,10 @@ func (d *Document) rangeText(r CellRange, logicalEnd bool) (string, bool, bool)
285287 if len(row) == 0 {
286288 return "", false, r.Start == 0 && r.End == 0
287289 }
290+ if r.End == r.Start-1 && r.Start >= 0 && r.Start <= len(row) {
291+ bound := d.snapshot.Bound(r.Row)
292+ return "", bound.Soft && !logicalEnd, true
293+ }
288294 start, end := min(r.Start, r.End), max(r.Start, r.End)
289295 if end < 0 || start >= len(row) {
290296 return "", false, false
@@ -299,9 +305,10 @@ func (d *Document) rangeText(r CellRange, logicalEnd bool) (string, bool, bool)
299305 start = head.Col
300306
301307 bound := d.snapshot.Bound(r.Row)
302- soft := bound.Soft && !logicalEnd && end >= len(row)-1
308+ limit := contentEndOf(row, bound)
309+ soft := bound.Soft && !logicalEnd && end >= limit-1
303310 if soft {
304- end = min(end, min(max(bound.End, 0), len(row))-1)
311+ end = min(end, limit-1)
305312 } else {
306313 end = lastContentCol(row, start, end)
307314 }
@@ -316,6 +323,26 @@ func (d *Document) rangeText(r CellRange, logicalEnd bool) (string, bool, bool)
316323 return d.cellsText(row, start, tail.Col), soft, true
317324 }
318325
326+// contentEnd returns the exclusive display-cell end of row's copyable content.
327+// Soft rows end at their logical bound; hard rows drop blank padding.
328+func (d *Document) contentEnd(row int) int {
329+ return contentEndOf(d.Row(row), d.snapshot.Bound(row))
330+}
331+
332+func contentEndOf(cells []renderer.Cell, bound vt.LineBound) int {
333+ if len(cells) == 0 {
334+ return 0
335+ }
336+ if bound.Soft {
337+ return min(max(bound.End, 0), len(cells))
338+ }
339+ last := lastContentCol(cells, 0, len(cells)-1)
340+ if last < 0 {
341+ return 0
342+ }
343+ return glyphEnd(cells, last) + 1
344+}
345+
319346 // lastContentCol returns the rightmost column in [start, end] holding content,
320347 // or start-1 when the span is blank. Continuation cells belong to the glyph on
321348 // their left, so they never count on their own.
@@ -16,6 +16,8 @@ type Pos struct {
16 }16 }
17 17
18 // CellRange is an inclusive display-cell range on one physical document row.18 // CellRange is an inclusive display-cell range on one physical document row.
19+// End == Start-1 with an in-bounds Start denotes a selected row with no cells;
20+// this adjacent pair is never interpreted as a reversed range.
19 type CellRange struct {21 type CellRange struct {
20 Row int22 Row int
21 Start int23 Start int
@@ -285,6 +287,10 @@ func (d *Document) rangeText(r CellRange, logicalEnd bool) (string, bool, bool)
285 if len(row) == 0 {287 if len(row) == 0 {
286 return "", false, r.Start == 0 && r.End == 0288 return "", false, r.Start == 0 && r.End == 0
287 }289 }
290+ if r.End == r.Start-1 && r.Start >= 0 && r.Start <= len(row) {
291+ bound := d.snapshot.Bound(r.Row)
292+ return "", bound.Soft && !logicalEnd, true
293+ }
288 start, end := min(r.Start, r.End), max(r.Start, r.End)294 start, end := min(r.Start, r.End), max(r.Start, r.End)
289 if end < 0 || start >= len(row) {295 if end < 0 || start >= len(row) {
290 return "", false, false296 return "", false, false
@@ -299,9 +305,10 @@ func (d *Document) rangeText(r CellRange, logicalEnd bool) (string, bool, bool)
299 start = head.Col305 start = head.Col
300 306
301 bound := d.snapshot.Bound(r.Row)307 bound := d.snapshot.Bound(r.Row)
302- soft := bound.Soft && !logicalEnd && end >= len(row)-1308+ limit := contentEndOf(row, bound)
309+ soft := bound.Soft && !logicalEnd && end >= limit-1
303 if soft {310 if soft {
304- end = min(end, min(max(bound.End, 0), len(row))-1)311+ end = min(end, limit-1)
305 } else {312 } else {
306 end = lastContentCol(row, start, end)313 end = lastContentCol(row, start, end)
307 }314 }
@@ -316,6 +323,26 @@ func (d *Document) rangeText(r CellRange, logicalEnd bool) (string, bool, bool)
316 return d.cellsText(row, start, tail.Col), soft, true323 return d.cellsText(row, start, tail.Col), soft, true
317 }324 }
318 325
326+// contentEnd returns the exclusive display-cell end of row's copyable content.
327+// Soft rows end at their logical bound; hard rows drop blank padding.
328+func (d *Document) contentEnd(row int) int {
329+ return contentEndOf(d.Row(row), d.snapshot.Bound(row))
330+}
331+
332+func contentEndOf(cells []renderer.Cell, bound vt.LineBound) int {
333+ if len(cells) == 0 {
334+ return 0
335+ }
336+ if bound.Soft {
337+ return min(max(bound.End, 0), len(cells))
338+ }
339+ last := lastContentCol(cells, 0, len(cells)-1)
340+ if last < 0 {
341+ return 0
342+ }
343+ return glyphEnd(cells, last) + 1
344+}
345+
319 // lastContentCol returns the rightmost column in [start, end] holding content,346 // lastContentCol returns the rightmost column in [start, end] holding content,
320 // or start-1 when the span is blank. Continuation cells belong to the glyph on347 // or start-1 when the span is blank. Continuation cells belong to the glyph on
321 // their left, so they never count on their own.348 // their left, so they never count on their own.
M internal/usecase/copy/document_test.go +48 -24
@@ -236,21 +236,45 @@ func TestDocumentExtractRanges(t *testing.T) {
236236 }
237237 }
238238
239-func TestDocumentExtractTrimsPaddingAndJoinsWrappedRows(t *testing.T) {
240- // pad renders s into a width-w row, blank-filled like a real terminal grid.
241- pad := func(s string, w int) []renderer.Cell {
242- cells := make([]renderer.Cell, w)
243- for i := range cells {
244- cells[i] = renderer.BlankCell()
245- }
246- for i, r := range []rune(s) {
247- if i >= w {
248- break
249- }
250- cells[i] = renderer.Cell{Rune: r}
239+// padRow blank-fills a terminal row before adding text.
240+func padRow(s string, w int) []renderer.Cell {
241+ cells := make([]renderer.Cell, w)
242+ for i := range cells {
243+ cells[i] = renderer.BlankCell()
244+ }
245+ for i, r := range []rune(s) {
246+ if i >= w {
247+ break
251248 }
252- return cells
249+ cells[i] = renderer.Cell{Rune: r}
253250 }
251+ return cells
252+}
253+
254+func TestDocumentContentEnd(t *testing.T) {
255+ tests := []struct {
256+ name string
257+ cells []renderer.Cell
258+ bounds []vt.LineBound
259+ want int
260+ }{
261+ {"hard short", padRow("ab", 8), []vt.LineBound{{End: 2}}, 2},
262+ {"inner spaces", padRow("a b", 8), []vt.LineBound{{End: 3}}, 3},
263+ {"blank", padRow("", 8), []vt.LineBound{{}}, 0},
264+ {"soft trailing spaces", padRow("abc ", 8), []vt.LineBound{{End: 8, Soft: true}}, 8},
265+ {"soft partial bound", padRow("abc", 8), []vt.LineBound{{End: 5, Soft: true}}, 5},
266+ {"wide glyph", []renderer.Cell{{Rune: 'a'}, {Rune: '界'}, {Continuation: true}, renderer.BlankCell(), renderer.BlankCell(), renderer.BlankCell()}, []vt.LineBound{{End: 3}}, 3},
267+ {"nil bounds", padRow("ab", 8), nil, 2},
268+ }
269+ for _, tt := range tests {
270+ t.Run(tt.name, func(t *testing.T) {
271+ doc := NewDocument(NewSnapshotFromLines([][]renderer.Cell{tt.cells}, tt.bounds, 8, 1), "")
272+ require.Equal(t, tt.want, doc.contentEnd(0))
273+ })
274+ }
275+}
276+
277+func TestDocumentExtractTrimsPaddingAndJoinsWrappedRows(t *testing.T) {
254278
255279 tests := []struct {
256280 name string
@@ -261,28 +285,28 @@ func TestDocumentExtractTrimsPaddingAndJoinsWrappedRows(t *testing.T) {
261285 }{
262286 {
263287 name: "hard row drops its padding",
264- rows: [][]renderer.Cell{pad("ab", 8)},
288+ rows: [][]renderer.Cell{padRow("ab", 8)},
265289 bounds: []vt.LineBound{{End: 2}},
266290 ranges: []CellRange{{Row: 0, Start: 0, End: 7}},
267291 want: "ab",
268292 },
269293 {
270294 name: "soft row joins without a newline",
271- rows: [][]renderer.Cell{pad("abcdefgh", 8), pad("ij", 8)},
295+ rows: [][]renderer.Cell{padRow("abcdefgh", 8), padRow("ij", 8)},
272296 bounds: []vt.LineBound{{End: 8, Soft: true}, {End: 2}},
273297 ranges: []CellRange{{Row: 0, Start: 0, End: 7}, {Row: 1, Start: 0, End: 7}},
274298 want: "abcdefghij",
275299 },
276300 {
277301 name: "soft row keeps a real trailing space",
278- rows: [][]renderer.Cell{pad("abc ", 8), pad("de", 8)},
302+ rows: [][]renderer.Cell{padRow("abc ", 8), padRow("de", 8)},
279303 bounds: []vt.LineBound{{End: 8, Soft: true}, {End: 2}},
280304 ranges: []CellRange{{Row: 0, Start: 0, End: 7}, {Row: 1, Start: 0, End: 7}},
281305 want: "abc de",
282306 },
283307 {
284308 name: "three consecutive soft rows",
285- rows: [][]renderer.Cell{pad("aaaa", 4), pad("bbbb", 4), pad("cccc", 4), pad("dd", 4)},
309+ rows: [][]renderer.Cell{padRow("aaaa", 4), padRow("bbbb", 4), padRow("cccc", 4), padRow("dd", 4)},
286310 bounds: []vt.LineBound{
287311 {End: 4, Soft: true}, {End: 4, Soft: true}, {End: 4, Soft: true}, {End: 2},
288312 },
@@ -294,35 +318,35 @@ func TestDocumentExtractTrimsPaddingAndJoinsWrappedRows(t *testing.T) {
294318 },
295319 {
296320 name: "hard rows keep their newline",
297- rows: [][]renderer.Cell{pad("ab", 8), pad("cd", 8)},
321+ rows: [][]renderer.Cell{padRow("ab", 8), padRow("cd", 8)},
298322 bounds: []vt.LineBound{{End: 2}, {End: 2}},
299323 ranges: []CellRange{{Row: 0, Start: 0, End: 7}, {Row: 1, Start: 0, End: 7}},
300324 want: "ab\ncd",
301325 },
302326 {
303327 name: "sparse ranges do not join across a row gap",
304- rows: [][]renderer.Cell{pad("ab", 8), pad("ignored", 8), pad("cd", 8)},
328+ rows: [][]renderer.Cell{padRow("ab", 8), padRow("ignored", 8), padRow("cd", 8)},
305329 bounds: []vt.LineBound{{End: 8, Soft: true}, {End: 7}, {End: 2}},
306330 ranges: []CellRange{{Row: 0, Start: 0, End: 7}, {Row: 2, Start: 0, End: 7}},
307331 want: "ab\ncd",
308332 },
309333 {
310334 name: "invalid trailing range does not prevent final emitted row trimming",
311- rows: [][]renderer.Cell{pad("ab", 8)},
335+ rows: [][]renderer.Cell{padRow("ab", 8)},
312336 bounds: []vt.LineBound{{End: 8, Soft: true}},
313337 ranges: []CellRange{{Row: 0, Start: 0, End: 7}, {Row: 99, Start: 0, End: 7}},
314338 want: "ab",
315339 },
316340 {
317341 name: "blank row survives as an empty line",
318- rows: [][]renderer.Cell{pad("ab", 8), pad("", 8), pad("cd", 8)},
342+ rows: [][]renderer.Cell{padRow("ab", 8), padRow("", 8), padRow("cd", 8)},
319343 bounds: []vt.LineBound{{End: 2}, {End: 0}, {End: 2}},
320344 ranges: []CellRange{{Row: 0, Start: 0, End: 7}, {Row: 1, Start: 0, End: 7}, {Row: 2, Start: 0, End: 7}},
321345 want: "ab\n\ncd",
322346 },
323347 {
324348 name: "the last row is trimmed even when soft",
325- rows: [][]renderer.Cell{pad("ab", 8), pad("cd ", 8)},
349+ rows: [][]renderer.Cell{padRow("ab", 8), padRow("cd ", 8)},
326350 bounds: []vt.LineBound{{End: 2}, {End: 8, Soft: true}},
327351 ranges: []CellRange{{Row: 0, Start: 0, End: 7}, {Row: 1, Start: 0, End: 7}},
328352 want: "ab\ncd",
@@ -333,7 +357,7 @@ func TestDocumentExtractTrimsPaddingAndJoinsWrappedRows(t *testing.T) {
333357 // so End stops at 3 and column 3 is abandoned padding.
334358 rows: [][]renderer.Cell{
335359 {{Rune: 'a'}, {Rune: '界'}, {Continuation: true}, renderer.BlankCell()},
336- pad("b", 4),
360+ padRow("b", 4),
337361 },
338362 bounds: []vt.LineBound{{End: 3, Soft: true}, {End: 1}},
339363 ranges: []CellRange{{Row: 0, Start: 0, End: 3}, {Row: 1, Start: 0, End: 3}},
@@ -341,7 +365,7 @@ func TestDocumentExtractTrimsPaddingAndJoinsWrappedRows(t *testing.T) {
341365 },
342366 {
343367 name: "selection stopping mid soft row",
344- rows: [][]renderer.Cell{pad("abcdefgh", 8), pad("ij", 8)},
368+ rows: [][]renderer.Cell{padRow("abcdefgh", 8), padRow("ij", 8)},
345369 bounds: []vt.LineBound{{End: 8, Soft: true}, {End: 2}},
346370 ranges: []CellRange{{Row: 0, Start: 2, End: 4}},
347371 want: "cde",
@@ -236,21 +236,45 @@ func TestDocumentExtractRanges(t *testing.T) {
236 }236 }
237 }237 }
238 238
239-func TestDocumentExtractTrimsPaddingAndJoinsWrappedRows(t *testing.T) {239+// padRow blank-fills a terminal row before adding text.
240- // pad renders s into a width-w row, blank-filled like a real terminal grid.240+func padRow(s string, w int) []renderer.Cell {
241- pad := func(s string, w int) []renderer.Cell {241+ cells := make([]renderer.Cell, w)
242- cells := make([]renderer.Cell, w)242+ for i := range cells {
243- for i := range cells {243+ cells[i] = renderer.BlankCell()
244- cells[i] = renderer.BlankCell()244+ }
245- }245+ for i, r := range []rune(s) {
246- for i, r := range []rune(s) {246+ if i >= w {
247- if i >= w {247+ break
248- break
249- }
250- cells[i] = renderer.Cell{Rune: r}
251 }248 }
252- return cells249+ cells[i] = renderer.Cell{Rune: r}
253 }250 }
251+ return cells
252+}
253+
254+func TestDocumentContentEnd(t *testing.T) {
255+ tests := []struct {
256+ name string
257+ cells []renderer.Cell
258+ bounds []vt.LineBound
259+ want int
260+ }{
261+ {"hard short", padRow("ab", 8), []vt.LineBound{{End: 2}}, 2},
262+ {"inner spaces", padRow("a b", 8), []vt.LineBound{{End: 3}}, 3},
263+ {"blank", padRow("", 8), []vt.LineBound{{}}, 0},
264+ {"soft trailing spaces", padRow("abc ", 8), []vt.LineBound{{End: 8, Soft: true}}, 8},
265+ {"soft partial bound", padRow("abc", 8), []vt.LineBound{{End: 5, Soft: true}}, 5},
266+ {"wide glyph", []renderer.Cell{{Rune: 'a'}, {Rune: '界'}, {Continuation: true}, renderer.BlankCell(), renderer.BlankCell(), renderer.BlankCell()}, []vt.LineBound{{End: 3}}, 3},
267+ {"nil bounds", padRow("ab", 8), nil, 2},
268+ }
269+ for _, tt := range tests {
270+ t.Run(tt.name, func(t *testing.T) {
271+ doc := NewDocument(NewSnapshotFromLines([][]renderer.Cell{tt.cells}, tt.bounds, 8, 1), "")
272+ require.Equal(t, tt.want, doc.contentEnd(0))
273+ })
274+ }
275+}
276+
277+func TestDocumentExtractTrimsPaddingAndJoinsWrappedRows(t *testing.T) {
254 278
255 tests := []struct {279 tests := []struct {
256 name string280 name string
@@ -261,28 +285,28 @@ func TestDocumentExtractTrimsPaddingAndJoinsWrappedRows(t *testing.T) {
261 }{285 }{
262 {286 {
263 name: "hard row drops its padding",287 name: "hard row drops its padding",
264- rows: [][]renderer.Cell{pad("ab", 8)},288+ rows: [][]renderer.Cell{padRow("ab", 8)},
265 bounds: []vt.LineBound{{End: 2}},289 bounds: []vt.LineBound{{End: 2}},
266 ranges: []CellRange{{Row: 0, Start: 0, End: 7}},290 ranges: []CellRange{{Row: 0, Start: 0, End: 7}},
267 want: "ab",291 want: "ab",
268 },292 },
269 {293 {
270 name: "soft row joins without a newline",294 name: "soft row joins without a newline",
271- rows: [][]renderer.Cell{pad("abcdefgh", 8), pad("ij", 8)},295+ rows: [][]renderer.Cell{padRow("abcdefgh", 8), padRow("ij", 8)},
272 bounds: []vt.LineBound{{End: 8, Soft: true}, {End: 2}},296 bounds: []vt.LineBound{{End: 8, Soft: true}, {End: 2}},
273 ranges: []CellRange{{Row: 0, Start: 0, End: 7}, {Row: 1, Start: 0, End: 7}},297 ranges: []CellRange{{Row: 0, Start: 0, End: 7}, {Row: 1, Start: 0, End: 7}},
274 want: "abcdefghij",298 want: "abcdefghij",
275 },299 },
276 {300 {
277 name: "soft row keeps a real trailing space",301 name: "soft row keeps a real trailing space",
278- rows: [][]renderer.Cell{pad("abc ", 8), pad("de", 8)},302+ rows: [][]renderer.Cell{padRow("abc ", 8), padRow("de", 8)},
279 bounds: []vt.LineBound{{End: 8, Soft: true}, {End: 2}},303 bounds: []vt.LineBound{{End: 8, Soft: true}, {End: 2}},
280 ranges: []CellRange{{Row: 0, Start: 0, End: 7}, {Row: 1, Start: 0, End: 7}},304 ranges: []CellRange{{Row: 0, Start: 0, End: 7}, {Row: 1, Start: 0, End: 7}},
281 want: "abc de",305 want: "abc de",
282 },306 },
283 {307 {
284 name: "three consecutive soft rows",308 name: "three consecutive soft rows",
285- rows: [][]renderer.Cell{pad("aaaa", 4), pad("bbbb", 4), pad("cccc", 4), pad("dd", 4)},309+ rows: [][]renderer.Cell{padRow("aaaa", 4), padRow("bbbb", 4), padRow("cccc", 4), padRow("dd", 4)},
286 bounds: []vt.LineBound{310 bounds: []vt.LineBound{
287 {End: 4, Soft: true}, {End: 4, Soft: true}, {End: 4, Soft: true}, {End: 2},311 {End: 4, Soft: true}, {End: 4, Soft: true}, {End: 4, Soft: true}, {End: 2},
288 },312 },
@@ -294,35 +318,35 @@ func TestDocumentExtractTrimsPaddingAndJoinsWrappedRows(t *testing.T) {
294 },318 },
295 {319 {
296 name: "hard rows keep their newline",320 name: "hard rows keep their newline",
297- rows: [][]renderer.Cell{pad("ab", 8), pad("cd", 8)},321+ rows: [][]renderer.Cell{padRow("ab", 8), padRow("cd", 8)},
298 bounds: []vt.LineBound{{End: 2}, {End: 2}},322 bounds: []vt.LineBound{{End: 2}, {End: 2}},
299 ranges: []CellRange{{Row: 0, Start: 0, End: 7}, {Row: 1, Start: 0, End: 7}},323 ranges: []CellRange{{Row: 0, Start: 0, End: 7}, {Row: 1, Start: 0, End: 7}},
300 want: "ab\ncd",324 want: "ab\ncd",
301 },325 },
302 {326 {
303 name: "sparse ranges do not join across a row gap",327 name: "sparse ranges do not join across a row gap",
304- rows: [][]renderer.Cell{pad("ab", 8), pad("ignored", 8), pad("cd", 8)},328+ rows: [][]renderer.Cell{padRow("ab", 8), padRow("ignored", 8), padRow("cd", 8)},
305 bounds: []vt.LineBound{{End: 8, Soft: true}, {End: 7}, {End: 2}},329 bounds: []vt.LineBound{{End: 8, Soft: true}, {End: 7}, {End: 2}},
306 ranges: []CellRange{{Row: 0, Start: 0, End: 7}, {Row: 2, Start: 0, End: 7}},330 ranges: []CellRange{{Row: 0, Start: 0, End: 7}, {Row: 2, Start: 0, End: 7}},
307 want: "ab\ncd",331 want: "ab\ncd",
308 },332 },
309 {333 {
310 name: "invalid trailing range does not prevent final emitted row trimming",334 name: "invalid trailing range does not prevent final emitted row trimming",
311- rows: [][]renderer.Cell{pad("ab", 8)},335+ rows: [][]renderer.Cell{padRow("ab", 8)},
312 bounds: []vt.LineBound{{End: 8, Soft: true}},336 bounds: []vt.LineBound{{End: 8, Soft: true}},
313 ranges: []CellRange{{Row: 0, Start: 0, End: 7}, {Row: 99, Start: 0, End: 7}},337 ranges: []CellRange{{Row: 0, Start: 0, End: 7}, {Row: 99, Start: 0, End: 7}},
314 want: "ab",338 want: "ab",
315 },339 },
316 {340 {
317 name: "blank row survives as an empty line",341 name: "blank row survives as an empty line",
318- rows: [][]renderer.Cell{pad("ab", 8), pad("", 8), pad("cd", 8)},342+ rows: [][]renderer.Cell{padRow("ab", 8), padRow("", 8), padRow("cd", 8)},
319 bounds: []vt.LineBound{{End: 2}, {End: 0}, {End: 2}},343 bounds: []vt.LineBound{{End: 2}, {End: 0}, {End: 2}},
320 ranges: []CellRange{{Row: 0, Start: 0, End: 7}, {Row: 1, Start: 0, End: 7}, {Row: 2, Start: 0, End: 7}},344 ranges: []CellRange{{Row: 0, Start: 0, End: 7}, {Row: 1, Start: 0, End: 7}, {Row: 2, Start: 0, End: 7}},
321 want: "ab\n\ncd",345 want: "ab\n\ncd",
322 },346 },
323 {347 {
324 name: "the last row is trimmed even when soft",348 name: "the last row is trimmed even when soft",
325- rows: [][]renderer.Cell{pad("ab", 8), pad("cd ", 8)},349+ rows: [][]renderer.Cell{padRow("ab", 8), padRow("cd ", 8)},
326 bounds: []vt.LineBound{{End: 2}, {End: 8, Soft: true}},350 bounds: []vt.LineBound{{End: 2}, {End: 8, Soft: true}},
327 ranges: []CellRange{{Row: 0, Start: 0, End: 7}, {Row: 1, Start: 0, End: 7}},351 ranges: []CellRange{{Row: 0, Start: 0, End: 7}, {Row: 1, Start: 0, End: 7}},
328 want: "ab\ncd",352 want: "ab\ncd",
@@ -333,7 +357,7 @@ func TestDocumentExtractTrimsPaddingAndJoinsWrappedRows(t *testing.T) {
333 // so End stops at 3 and column 3 is abandoned padding.357 // so End stops at 3 and column 3 is abandoned padding.
334 rows: [][]renderer.Cell{358 rows: [][]renderer.Cell{
335 {{Rune: 'a'}, {Rune: '界'}, {Continuation: true}, renderer.BlankCell()},359 {{Rune: 'a'}, {Rune: '界'}, {Continuation: true}, renderer.BlankCell()},
336- pad("b", 4),360+ padRow("b", 4),
337 },361 },
338 bounds: []vt.LineBound{{End: 3, Soft: true}, {End: 1}},362 bounds: []vt.LineBound{{End: 3, Soft: true}, {End: 1}},
339 ranges: []CellRange{{Row: 0, Start: 0, End: 3}, {Row: 1, Start: 0, End: 3}},363 ranges: []CellRange{{Row: 0, Start: 0, End: 3}, {Row: 1, Start: 0, End: 3}},
@@ -341,7 +365,7 @@ func TestDocumentExtractTrimsPaddingAndJoinsWrappedRows(t *testing.T) {
341 },365 },
342 {366 {
343 name: "selection stopping mid soft row",367 name: "selection stopping mid soft row",
344- rows: [][]renderer.Cell{pad("abcdefgh", 8), pad("ij", 8)},368+ rows: [][]renderer.Cell{padRow("abcdefgh", 8), padRow("ij", 8)},
345 bounds: []vt.LineBound{{End: 8, Soft: true}, {End: 2}},369 bounds: []vt.LineBound{{End: 8, Soft: true}, {End: 2}},
346 ranges: []CellRange{{Row: 0, Start: 2, End: 4}},370 ranges: []CellRange{{Row: 0, Start: 2, End: 4}},
347 want: "cde",371 want: "cde",
M internal/usecase/copy/mode.go +20 -3
@@ -596,7 +596,7 @@ func (m *Mode) RenderRowsRange(start, end int, paint func(int, []renderer.Cell),
596596 paint(y, row)
597597 continue
598598 }
599- d.snapshot.CopyRow(src, row)
599+ n := d.snapshot.CopyRow(src, row)
600600 if match, ok := m.currentSearchMatchForRow(src); ok {
601601 for x := max(match.Start, 0); x < min(match.End, len(row)); x++ {
602602 applySelectionStyle(&row[x].Style, selection, hasSelection)
@@ -604,9 +604,24 @@ func (m *Mode) RenderRowsRange(start, end int, paint func(int, []renderer.Cell),
604604 }
605605 cursorCovered := false
606606 if hasSelectionBounds {
607- if r, ok := selectionBounds.rangeForRow(d, src); ok {
607+ bound := d.snapshot.Bound(src)
608+ width := d.RowWidth(src)
609+ cells, rangeCells := row[:n], row
610+ if n < width {
611+ cells = d.Row(src)
612+ rangeCells = cells
613+ }
614+ limit := contentEndOf(cells, bound)
615+ r, ok := selectionBounds.rangeForCells(src, rangeCells, limit)
616+ if ok {
608617 for x := max(r.Start, 0); x <= min(r.End, len(row)-1); x++ {
609- applySelectionStyle(&row[x].Style, selection, hasSelection)
618+ toggleInverse(&row[x].Style)
619+ }
620+ if col, ok := selectionBounds.newlineMarkerAt(src, limit, width, bound.Soft); ok && col < len(row) {
621+ toggleInverse(&row[col].Style)
622+ if cursorValid && cursor.Row == src && cursor.Col == col {
623+ cursorCovered = true
624+ }
610625 }
611626 if cursorValid && r.Row == cursor.Row && cursor.Col >= r.Start && cursor.Col <= r.End {
612627 cursorCovered = true
@@ -639,6 +654,8 @@ func optionalStyle(styles []renderer.Style, idx int) (renderer.Style, bool) {
639654 }
640655 return styles[idx], true
641656 }
657+func toggleInverse(dst *renderer.Style) { dst.Inverse = !dst.Inverse }
658+
642659 func applySelectionStyle(dst *renderer.Style, style renderer.Style, ok bool) {
643660 if !ok || style.Equal(inverseStyle()) {
644661 dst.Inverse = true
@@ -596,7 +596,7 @@ func (m *Mode) RenderRowsRange(start, end int, paint func(int, []renderer.Cell),
596 paint(y, row)596 paint(y, row)
597 continue597 continue
598 }598 }
599- d.snapshot.CopyRow(src, row)599+ n := d.snapshot.CopyRow(src, row)
600 if match, ok := m.currentSearchMatchForRow(src); ok {600 if match, ok := m.currentSearchMatchForRow(src); ok {
601 for x := max(match.Start, 0); x < min(match.End, len(row)); x++ {601 for x := max(match.Start, 0); x < min(match.End, len(row)); x++ {
602 applySelectionStyle(&row[x].Style, selection, hasSelection)602 applySelectionStyle(&row[x].Style, selection, hasSelection)
@@ -604,9 +604,24 @@ func (m *Mode) RenderRowsRange(start, end int, paint func(int, []renderer.Cell),
604 }604 }
605 cursorCovered := false605 cursorCovered := false
606 if hasSelectionBounds {606 if hasSelectionBounds {
607- if r, ok := selectionBounds.rangeForRow(d, src); ok {607+ bound := d.snapshot.Bound(src)
608+ width := d.RowWidth(src)
609+ cells, rangeCells := row[:n], row
610+ if n < width {
611+ cells = d.Row(src)
612+ rangeCells = cells
613+ }
614+ limit := contentEndOf(cells, bound)
615+ r, ok := selectionBounds.rangeForCells(src, rangeCells, limit)
616+ if ok {
608 for x := max(r.Start, 0); x <= min(r.End, len(row)-1); x++ {617 for x := max(r.Start, 0); x <= min(r.End, len(row)-1); x++ {
609- applySelectionStyle(&row[x].Style, selection, hasSelection)618+ toggleInverse(&row[x].Style)
619+ }
620+ if col, ok := selectionBounds.newlineMarkerAt(src, limit, width, bound.Soft); ok && col < len(row) {
621+ toggleInverse(&row[col].Style)
622+ if cursorValid && cursor.Row == src && cursor.Col == col {
623+ cursorCovered = true
624+ }
610 }625 }
611 if cursorValid && r.Row == cursor.Row && cursor.Col >= r.Start && cursor.Col <= r.End {626 if cursorValid && r.Row == cursor.Row && cursor.Col >= r.Start && cursor.Col <= r.End {
612 cursorCovered = true627 cursorCovered = true
@@ -639,6 +654,8 @@ func optionalStyle(styles []renderer.Style, idx int) (renderer.Style, bool) {
639 }654 }
640 return styles[idx], true655 return styles[idx], true
641 }656 }
657+func toggleInverse(dst *renderer.Style) { dst.Inverse = !dst.Inverse }
658+
642 func applySelectionStyle(dst *renderer.Style, style renderer.Style, ok bool) {659 func applySelectionStyle(dst *renderer.Style, style renderer.Style, ok bool) {
643 if !ok || style.Equal(inverseStyle()) {660 if !ok || style.Equal(inverseStyle()) {
644 dst.Inverse = true661 dst.Inverse = true
M internal/usecase/copy/mode_test.go +5 -2
@@ -143,7 +143,7 @@ func TestCopyModePreferredColumnAndPartialSpaces(t *testing.T) {
143143 require.Equal(t, "bcde", m.SelectedText())
144144 }
145145
146-func TestCopyModeRenderSelectionWinsSearchAndWideGlyph(t *testing.T) {
146+func TestCopyModeRenderSelectionInvertsSearchAndWideGlyph(t *testing.T) {
147147 cells := []renderer.Cell{{Rune: '界'}, {Continuation: true}, {Rune: 'a'}}
148148 m := NewMode(NewDocument(NewSnapshotFromRows([][]renderer.Cell{cells}, 3, 1), ""))
149149 require.True(t, m.SetSearchMatches("界", []SearchMatch{{Row: 0, Start: 0, End: 2}}, 0))
@@ -151,6 +151,8 @@ func TestCopyModeRenderSelectionWinsSearchAndWideGlyph(t *testing.T) {
151151 f := m.Render(renderer.DefaultStyle(), renderer.Style{HasBackgroundRGB: true, BackgroundRGB: renderer.RGB{R: 1}})
152152 require.True(t, f.At(0, 0).Style.HasBackgroundRGB)
153153 require.True(t, f.At(1, 0).Style.HasBackgroundRGB)
154+ require.True(t, f.At(0, 0).Style.Inverse)
155+ require.True(t, f.At(1, 0).Style.Inverse)
154156 }
155157
156158 func TestCopyModeSearchNavigationExtendsActiveSelection(t *testing.T) {
@@ -202,7 +204,8 @@ func TestCopyModeRenderKeepsPassiveCursorOutsidePartialSameRowSelection(t *testi
202204 selection := renderer.Style{HasBackgroundRGB: true, BackgroundRGB: renderer.RGB{R: 1}}
203205 f := m.Render(renderer.DefaultStyle(), selection)
204206 require.True(t, f.At(0, 0).Style.HasBackgroundRGB)
205- require.True(t, f.At(2, 0).Style.HasBackgroundRGB)
207+ require.True(t, f.At(2, 0).Style.Inverse)
208+ require.False(t, f.At(2, 0).Style.HasBackgroundRGB)
206209 }
207210
208211 func TestFindMatchesUsesExclusiveDisplayCellOffsets(t *testing.T) {
@@ -143,7 +143,7 @@ func TestCopyModePreferredColumnAndPartialSpaces(t *testing.T) {
143 require.Equal(t, "bcde", m.SelectedText())143 require.Equal(t, "bcde", m.SelectedText())
144 }144 }
145 145
146-func TestCopyModeRenderSelectionWinsSearchAndWideGlyph(t *testing.T) {146+func TestCopyModeRenderSelectionInvertsSearchAndWideGlyph(t *testing.T) {
147 cells := []renderer.Cell{{Rune: '界'}, {Continuation: true}, {Rune: 'a'}}147 cells := []renderer.Cell{{Rune: '界'}, {Continuation: true}, {Rune: 'a'}}
148 m := NewMode(NewDocument(NewSnapshotFromRows([][]renderer.Cell{cells}, 3, 1), ""))148 m := NewMode(NewDocument(NewSnapshotFromRows([][]renderer.Cell{cells}, 3, 1), ""))
149 require.True(t, m.SetSearchMatches("界", []SearchMatch{{Row: 0, Start: 0, End: 2}}, 0))149 require.True(t, m.SetSearchMatches("界", []SearchMatch{{Row: 0, Start: 0, End: 2}}, 0))
@@ -151,6 +151,8 @@ func TestCopyModeRenderSelectionWinsSearchAndWideGlyph(t *testing.T) {
151 f := m.Render(renderer.DefaultStyle(), renderer.Style{HasBackgroundRGB: true, BackgroundRGB: renderer.RGB{R: 1}})151 f := m.Render(renderer.DefaultStyle(), renderer.Style{HasBackgroundRGB: true, BackgroundRGB: renderer.RGB{R: 1}})
152 require.True(t, f.At(0, 0).Style.HasBackgroundRGB)152 require.True(t, f.At(0, 0).Style.HasBackgroundRGB)
153 require.True(t, f.At(1, 0).Style.HasBackgroundRGB)153 require.True(t, f.At(1, 0).Style.HasBackgroundRGB)
154+ require.True(t, f.At(0, 0).Style.Inverse)
155+ require.True(t, f.At(1, 0).Style.Inverse)
154 }156 }
155 157
156 func TestCopyModeSearchNavigationExtendsActiveSelection(t *testing.T) {158 func TestCopyModeSearchNavigationExtendsActiveSelection(t *testing.T) {
@@ -202,7 +204,8 @@ func TestCopyModeRenderKeepsPassiveCursorOutsidePartialSameRowSelection(t *testi
202 selection := renderer.Style{HasBackgroundRGB: true, BackgroundRGB: renderer.RGB{R: 1}}204 selection := renderer.Style{HasBackgroundRGB: true, BackgroundRGB: renderer.RGB{R: 1}}
203 f := m.Render(renderer.DefaultStyle(), selection)205 f := m.Render(renderer.DefaultStyle(), selection)
204 require.True(t, f.At(0, 0).Style.HasBackgroundRGB)206 require.True(t, f.At(0, 0).Style.HasBackgroundRGB)
205- require.True(t, f.At(2, 0).Style.HasBackgroundRGB)207+ require.True(t, f.At(2, 0).Style.Inverse)
208+ require.False(t, f.At(2, 0).Style.HasBackgroundRGB)
206 }209 }
207 210
208 func TestFindMatchesUsesExclusiveDisplayCellOffsets(t *testing.T) {211 func TestFindMatchesUsesExclusiveDisplayCellOffsets(t *testing.T) {
M internal/usecase/copy/selection.go +30 -3
@@ -63,8 +63,9 @@ func (s Selection) Ranges(doc *Document) []CellRange {
6363 return ranges
6464 }
6565
66-// RangeForRow returns the canonical inclusive range for one document row. It
67-// lets viewport renderers avoid materializing ranges for off-screen rows.
66+// RangeForRow returns the canonical inclusive range for one document row.
67+// End == Start-1 denotes a selected row with no cells. It lets viewport
68+// renderers avoid materializing ranges for off-screen rows.
6869 func (s Selection) RangeForRow(doc *Document, row int) (CellRange, bool) {
6970 bounds, ok := s.bounds(doc)
7071 if !ok {
@@ -120,11 +121,21 @@ func (b selectionBounds) rangeForRow(doc *Document, row int) (CellRange, bool) {
120121 return CellRange{}, false
121122 }
122123 cells := doc.Row(row)
124+ return b.rangeForCells(row, cells, contentEndOf(cells, doc.snapshot.Bound(row)))
125+}
126+
127+func (b selectionBounds) rangeForCells(row int, cells []renderer.Cell, limit int) (CellRange, bool) {
128+ if row < b.start.Row || row > b.end.Row {
129+ return CellRange{}, false
130+ }
123131 if len(cells) == 0 {
124132 return CellRange{Row: row}, true
125133 }
134+ if limit == 0 {
135+ return CellRange{Row: row, End: -1}, true
136+ }
126137 if b.linewise {
127- return CellRange{Row: row, End: len(cells) - 1}, true
138+ return CellRange{Row: row, End: limit - 1}, true
128139 }
129140
130141 start, end := 0, len(cells)-1
@@ -134,9 +145,25 @@ func (b selectionBounds) rangeForRow(doc *Document, row int) (CellRange, bool) {
134145 if row == b.end.Row {
135146 end = glyphEnd(cells, b.end.Col)
136147 }
148+ end = min(end, limit-1)
149+ if start > end {
150+ end = start - 1
151+ }
137152 return CellRange{Row: row, Start: start, End: end}, true
138153 }
139154
155+// newlineMarker reports the column marking a selected hard newline on row.
156+func (b selectionBounds) newlineMarker(doc *Document, row int) (int, bool) {
157+ return b.newlineMarkerAt(row, doc.contentEnd(row), doc.Width(), doc.snapshot.Bound(row).Soft)
158+}
159+
160+func (b selectionBounds) newlineMarkerAt(row, end, width int, soft bool) (int, bool) {
161+ if row < b.start.Row || row > b.end.Row || soft || (!b.linewise && row == b.end.Row) {
162+ return 0, false
163+ }
164+ return end, end > 0 && end < width
165+}
166+
140167 // Text extracts the selection's ranges. The granularity is already encoded in
141168 // those ranges, so extraction applies the same padding and wrap rules to all.
142169 func (s Selection) Text(doc *Document) string {
@@ -63,8 +63,9 @@ func (s Selection) Ranges(doc *Document) []CellRange {
63 return ranges63 return ranges
64 }64 }
65 65
66-// RangeForRow returns the canonical inclusive range for one document row. It66+// RangeForRow returns the canonical inclusive range for one document row.
67-// lets viewport renderers avoid materializing ranges for off-screen rows.67+// End == Start-1 denotes a selected row with no cells. It lets viewport
68+// renderers avoid materializing ranges for off-screen rows.
68 func (s Selection) RangeForRow(doc *Document, row int) (CellRange, bool) {69 func (s Selection) RangeForRow(doc *Document, row int) (CellRange, bool) {
69 bounds, ok := s.bounds(doc)70 bounds, ok := s.bounds(doc)
70 if !ok {71 if !ok {
@@ -120,11 +121,21 @@ func (b selectionBounds) rangeForRow(doc *Document, row int) (CellRange, bool) {
120 return CellRange{}, false121 return CellRange{}, false
121 }122 }
122 cells := doc.Row(row)123 cells := doc.Row(row)
124+ return b.rangeForCells(row, cells, contentEndOf(cells, doc.snapshot.Bound(row)))
125+}
126+
127+func (b selectionBounds) rangeForCells(row int, cells []renderer.Cell, limit int) (CellRange, bool) {
128+ if row < b.start.Row || row > b.end.Row {
129+ return CellRange{}, false
130+ }
123 if len(cells) == 0 {131 if len(cells) == 0 {
124 return CellRange{Row: row}, true132 return CellRange{Row: row}, true
125 }133 }
134+ if limit == 0 {
135+ return CellRange{Row: row, End: -1}, true
136+ }
126 if b.linewise {137 if b.linewise {
127- return CellRange{Row: row, End: len(cells) - 1}, true138+ return CellRange{Row: row, End: limit - 1}, true
128 }139 }
129 140
130 start, end := 0, len(cells)-1141 start, end := 0, len(cells)-1
@@ -134,9 +145,25 @@ func (b selectionBounds) rangeForRow(doc *Document, row int) (CellRange, bool) {
134 if row == b.end.Row {145 if row == b.end.Row {
135 end = glyphEnd(cells, b.end.Col)146 end = glyphEnd(cells, b.end.Col)
136 }147 }
148+ end = min(end, limit-1)
149+ if start > end {
150+ end = start - 1
151+ }
137 return CellRange{Row: row, Start: start, End: end}, true152 return CellRange{Row: row, Start: start, End: end}, true
138 }153 }
139 154
155+// newlineMarker reports the column marking a selected hard newline on row.
156+func (b selectionBounds) newlineMarker(doc *Document, row int) (int, bool) {
157+ return b.newlineMarkerAt(row, doc.contentEnd(row), doc.Width(), doc.snapshot.Bound(row).Soft)
158+}
159+
160+func (b selectionBounds) newlineMarkerAt(row, end, width int, soft bool) (int, bool) {
161+ if row < b.start.Row || row > b.end.Row || soft || (!b.linewise && row == b.end.Row) {
162+ return 0, false
163+ }
164+ return end, end > 0 && end < width
165+}
166+
140 // Text extracts the selection's ranges. The granularity is already encoded in167 // Text extracts the selection's ranges. The granularity is already encoded in
141 // those ranges, so extraction applies the same padding and wrap rules to all.168 // those ranges, so extraction applies the same padding and wrap rules to all.
142 func (s Selection) Text(doc *Document) string {169 func (s Selection) Text(doc *Document) string {

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