detached 3f3a435

feat(copy): invert selected cells and mark selected newlines

commit
3f3a435518c02857d82bd775dabd9dd5558e2355
author
brice · · 2026-10-05 07:17 UTC
parent
9d2dea9
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +110 -3
A internal/usecase/copy/content_render_test.go +87 -0
@@ -0,0 +1,87 @@
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, m.SelectedText(), copied.String(), "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 TestRenderRowsSelectionPreservesCellColors(t *testing.T) {
73+ styles := []vt.Style{
74+ {HasForegroundRGB: true, ForegroundRGB: vt.RGB{G: 255, B: 255}, Bold: true},
75+ {Inverse: true, HasBackgroundRGB: true, BackgroundRGB: vt.RGB{R: 20}, Italic: true},
76+ }
77+ cells := []vt.Cell{{Rune: 'a', Style: styles[0]}, {Rune: 'b', Style: styles[1]}}
78+ m := NewMode(NewDocument(NewSnapshotFromRows([][]vt.Cell{cells}, 2, 1), ""))
79+ require.True(t, m.StartCharacterSelection(Pos{0, 0}))
80+ require.True(t, m.ExtendCharacterSelection(Pos{0, 1}))
81+ f := m.Render(vt.DefaultStyle(), vt.Style{HasBackgroundRGB: true, BackgroundRGB: vt.RGB{R: 1}})
82+ for x, style := range styles {
83+ style.Inverse = !style.Inverse
84+ require.Equal(t, style, f.At(x, 0).Style)
85+ }
86+ require.Equal(t, cells, m.Document().Row(0))
87+}
@@ -0,0 +1,87 @@
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, m.SelectedText(), copied.String(), "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 TestRenderRowsSelectionPreservesCellColors(t *testing.T) {
73+ styles := []vt.Style{
74+ {HasForegroundRGB: true, ForegroundRGB: vt.RGB{G: 255, B: 255}, Bold: true},
75+ {Inverse: true, HasBackgroundRGB: true, BackgroundRGB: vt.RGB{R: 20}, Italic: true},
76+ }
77+ cells := []vt.Cell{{Rune: 'a', Style: styles[0]}, {Rune: 'b', Style: styles[1]}}
78+ m := NewMode(NewDocument(NewSnapshotFromRows([][]vt.Cell{cells}, 2, 1), ""))
79+ require.True(t, m.StartCharacterSelection(Pos{0, 0}))
80+ require.True(t, m.ExtendCharacterSelection(Pos{0, 1}))
81+ f := m.Render(vt.DefaultStyle(), vt.Style{HasBackgroundRGB: true, BackgroundRGB: vt.RGB{R: 1}})
82+ for x, style := range styles {
83+ style.Inverse = !style.Inverse
84+ require.Equal(t, style, f.At(x, 0).Style)
85+ }
86+ require.Equal(t, cells, m.Document().Row(0))
87+}
M internal/usecase/copy/mode.go +9 -1
@@ -606,7 +606,13 @@ func (m *Mode) RenderRowsRange(start, end int, paint func(int, []renderer.Cell),
606606 if hasSelectionBounds {
607607 if r, ok := selectionBounds.rangeForRow(d, src); ok {
608608 for x := max(r.Start, 0); x <= min(r.End, len(row)-1); x++ {
609- applySelectionStyle(&row[x].Style, selection, hasSelection)
609+ toggleInverse(&row[x].Style)
610+ }
611+ if col, ok := selectionBounds.newlineMarker(d, src); ok && col < len(row) {
612+ toggleInverse(&row[col].Style)
613+ if cursorValid && cursor.Row == src && cursor.Col == col {
614+ cursorCovered = true
615+ }
610616 }
611617 if cursorValid && r.Row == cursor.Row && cursor.Col >= r.Start && cursor.Col <= r.End {
612618 cursorCovered = true
@@ -639,6 +645,8 @@ func optionalStyle(styles []renderer.Style, idx int) (renderer.Style, bool) {
639645 }
640646 return styles[idx], true
641647 }
648+func toggleInverse(dst *renderer.Style) { dst.Inverse = !dst.Inverse }
649+
642650 func applySelectionStyle(dst *renderer.Style, style renderer.Style, ok bool) {
643651 if !ok || style.Equal(inverseStyle()) {
644652 dst.Inverse = true
@@ -606,7 +606,13 @@ func (m *Mode) RenderRowsRange(start, end int, paint func(int, []renderer.Cell),
606 if hasSelectionBounds {606 if hasSelectionBounds {
607 if r, ok := selectionBounds.rangeForRow(d, src); ok {607 if r, ok := selectionBounds.rangeForRow(d, src); ok {
608 for x := max(r.Start, 0); x <= min(r.End, len(row)-1); x++ {608 for x := max(r.Start, 0); x <= min(r.End, len(row)-1); x++ {
609- applySelectionStyle(&row[x].Style, selection, hasSelection)609+ toggleInverse(&row[x].Style)
610+ }
611+ if col, ok := selectionBounds.newlineMarker(d, src); ok && col < len(row) {
612+ toggleInverse(&row[col].Style)
613+ if cursorValid && cursor.Row == src && cursor.Col == col {
614+ cursorCovered = true
615+ }
610 }616 }
611 if cursorValid && r.Row == cursor.Row && cursor.Col >= r.Start && cursor.Col <= r.End {617 if cursorValid && r.Row == cursor.Row && cursor.Col >= r.Start && cursor.Col <= r.End {
612 cursorCovered = true618 cursorCovered = true
@@ -639,6 +645,8 @@ func optionalStyle(styles []renderer.Style, idx int) (renderer.Style, bool) {
639 }645 }
640 return styles[idx], true646 return styles[idx], true
641 }647 }
648+func toggleInverse(dst *renderer.Style) { dst.Inverse = !dst.Inverse }
649+
642 func applySelectionStyle(dst *renderer.Style, style renderer.Style, ok bool) {650 func applySelectionStyle(dst *renderer.Style, style renderer.Style, ok bool) {
643 if !ok || style.Equal(inverseStyle()) {651 if !ok || style.Equal(inverseStyle()) {
644 dst.Inverse = true652 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 +9 -0
@@ -145,6 +145,15 @@ func (b selectionBounds) rangeForRow(doc *Document, row int) (CellRange, bool) {
145145 return CellRange{Row: row, Start: start, End: end}, true
146146 }
147147
148+// newlineMarker reports the column marking a selected hard newline on row.
149+func (b selectionBounds) newlineMarker(doc *Document, row int) (int, bool) {
150+ if row < b.start.Row || row > b.end.Row || doc.snapshot.Bound(row).Soft || (!b.linewise && row == b.end.Row) {
151+ return 0, false
152+ }
153+ end := doc.contentEnd(row)
154+ return end, end > 0 && end < doc.Width()
155+}
156+
148157 // Text extracts the selection's ranges. The granularity is already encoded in
149158 // those ranges, so extraction applies the same padding and wrap rules to all.
150159 func (s Selection) Text(doc *Document) string {
@@ -145,6 +145,15 @@ func (b selectionBounds) rangeForRow(doc *Document, row int) (CellRange, bool) {
145 return CellRange{Row: row, Start: start, End: end}, true145 return CellRange{Row: row, Start: start, End: end}, true
146 }146 }
147 147
148+// newlineMarker reports the column marking a selected hard newline on row.
149+func (b selectionBounds) newlineMarker(doc *Document, row int) (int, bool) {
150+ if row < b.start.Row || row > b.end.Row || doc.snapshot.Bound(row).Soft || (!b.linewise && row == b.end.Row) {
151+ return 0, false
152+ }
153+ end := doc.contentEnd(row)
154+ return end, end > 0 && end < doc.Width()
155+}
156+
148 // Text extracts the selection's ranges. The granularity is already encoded in157 // Text extracts the selection's ranges. The granularity is already encoded in
149 // those ranges, so extraction applies the same padding and wrap rules to all.158 // those ranges, so extraction applies the same padding and wrap rules to all.
150 func (s Selection) Text(doc *Document) string {159 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