detached 71f5218

perf(copy): bound selection rendering to viewport

commit
71f5218ca8647e61959cbb153e0c2568286e6434
author
brice · · 2026-07-15 07:33 UTC
committer
brice · · 2026-07-15 09:45 UTC
parent
661ba07
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +190 -54
M internal/usecase/copy/mode.go +9 -11
@@ -352,11 +352,7 @@ func (m *Mode) Render(styles ...renderer.Style) renderer.Frame {
352352 }
353353 d := m.document
354354 frame := renderer.NewFrame(d.Width(), d.Height()+1)
355- ranges := m.selection.Ranges(d)
356- selected := make(map[int][]CellRange, len(ranges))
357- for _, r := range ranges {
358- selected[r.Row] = append(selected[r.Row], r)
359- }
355+ selectionBounds, hasSelectionBounds := m.selection.bounds(d)
360356 selection, hasSelection := optionalStyle(styles, 1)
361357 cursor, cursorValid := d.Normalize(m.navigator.Pos)
362358 for y := range d.Height() {
@@ -372,12 +368,14 @@ func (m *Mode) Render(styles ...renderer.Style) renderer.Frame {
372368 }
373369 }
374370 cursorCovered := false
375- for _, r := range selected[src] {
376- for x := max(r.Start, 0); x <= min(r.End, len(row)-1); x++ {
377- applySelectionStyle(&row[x].Style, selection, hasSelection)
378- }
379- if cursorValid && r.Row == cursor.Row && cursor.Col >= r.Start && cursor.Col <= r.End {
380- cursorCovered = true
371+ if hasSelectionBounds {
372+ if r, ok := selectionBounds.rangeForRow(d, src); ok {
373+ for x := max(r.Start, 0); x <= min(r.End, len(row)-1); x++ {
374+ applySelectionStyle(&row[x].Style, selection, hasSelection)
375+ }
376+ if cursorValid && r.Row == cursor.Row && cursor.Col >= r.Start && cursor.Col <= r.End {
377+ cursorCovered = true
378+ }
381379 }
382380 }
383381 if src == cursor.Row && cursorValid && !cursorCovered && len(row) > 0 {
@@ -352,11 +352,7 @@ func (m *Mode) Render(styles ...renderer.Style) renderer.Frame {
352 }352 }
353 d := m.document353 d := m.document
354 frame := renderer.NewFrame(d.Width(), d.Height()+1)354 frame := renderer.NewFrame(d.Width(), d.Height()+1)
355- ranges := m.selection.Ranges(d)355+ selectionBounds, hasSelectionBounds := m.selection.bounds(d)
356- selected := make(map[int][]CellRange, len(ranges))
357- for _, r := range ranges {
358- selected[r.Row] = append(selected[r.Row], r)
359- }
360 selection, hasSelection := optionalStyle(styles, 1)356 selection, hasSelection := optionalStyle(styles, 1)
361 cursor, cursorValid := d.Normalize(m.navigator.Pos)357 cursor, cursorValid := d.Normalize(m.navigator.Pos)
362 for y := range d.Height() {358 for y := range d.Height() {
@@ -372,12 +368,14 @@ func (m *Mode) Render(styles ...renderer.Style) renderer.Frame {
372 }368 }
373 }369 }
374 cursorCovered := false370 cursorCovered := false
375- for _, r := range selected[src] {371+ if hasSelectionBounds {
376- for x := max(r.Start, 0); x <= min(r.End, len(row)-1); x++ {372+ if r, ok := selectionBounds.rangeForRow(d, src); ok {
377- applySelectionStyle(&row[x].Style, selection, hasSelection)373+ for x := max(r.Start, 0); x <= min(r.End, len(row)-1); x++ {
378- }374+ applySelectionStyle(&row[x].Style, selection, hasSelection)
379- if cursorValid && r.Row == cursor.Row && cursor.Col >= r.Start && cursor.Col <= r.End {375+ }
380- cursorCovered = true376+ if cursorValid && r.Row == cursor.Row && cursor.Col >= r.Start && cursor.Col <= r.End {
377+ cursorCovered = true
378+ }
381 }379 }
382 }380 }
383 if src == cursor.Row && cursorValid && !cursorCovered && len(row) > 0 {381 if src == cursor.Row && cursorValid && !cursorCovered && len(row) > 0 {
M internal/usecase/copy/mode_test.go +93 -0
@@ -8,6 +8,7 @@ import (
88
99 "github.com/bnema/vev/internal/domain"
1010 "github.com/bnema/vev/pkg/renderer"
11+ "github.com/bnema/vev/pkg/vt"
1112 "github.com/stretchr/testify/require"
1213 )
1314
@@ -109,6 +110,98 @@ func TestFindMatchesUsesExclusiveDisplayCellOffsets(t *testing.T) {
109110 require.Equal(t, []SearchMatch{{Row: 0, Start: 2, End: 7, Text: "界alpha"}}, FindMatches(doc, "alpha"))
110111 }
111112
113+func TestFindMatchesRepeatedUnicodeDisplayCells(t *testing.T) {
114+ cells := []renderer.Cell{
115+ {Rune: 'Ä'}, {Rune: '界'}, {Continuation: true},
116+ {Rune: 'ä'}, {Rune: '界'}, {Continuation: true},
117+ {Rune: 'Ä'}, {Rune: '界'}, {Continuation: true},
118+ }
119+ doc := NewDocument(NewSnapshotFromRows([][]renderer.Cell{row("aaaa"), cells}, 9, 2), "")
120+
121+ for _, tt := range []struct {
122+ name, query string
123+ want []SearchMatch
124+ }{
125+ {
126+ name: "repeated non-overlapping",
127+ query: "aa",
128+ want: []SearchMatch{
129+ {Row: 0, Start: 0, End: 2, Text: "aaaa"},
130+ {Row: 0, Start: 2, End: 4, Text: "aaaa"},
131+ },
132+ },
133+ {
134+ name: "unicode display cells",
135+ query: "Ä界",
136+ want: []SearchMatch{
137+ {Row: 1, Start: 0, End: 3, Text: "Ä界ä界Ä界"},
138+ {Row: 1, Start: 3, End: 6, Text: "Ä界ä界Ä界"},
139+ {Row: 1, Start: 6, End: 9, Text: "Ä界ä界Ä界"},
140+ },
141+ },
142+ } {
143+ t.Run(tt.name, func(t *testing.T) {
144+ require.Equal(t, tt.want, FindMatches(doc, tt.query))
145+ })
146+ }
147+}
148+
149+func TestFindMatchesUsesSealedScrollbackWithoutGlobalCopy(t *testing.T) {
150+ const rows = 10_000
151+ history := vt.NewHistory(vt.HistoryConfig{MaxRows: rows + 1, ChunkRows: 256})
152+ for range rows {
153+ history.Append(row("unmatched"))
154+ }
155+ history.Append(row("target"))
156+ snapshot := NewSnapshot(history, renderer.NewFrame(16, 1))
157+ view := history.SealAndView()
158+ require.Same(t, view.Chunk(0), snapshot.history.Chunk(0))
159+
160+ doc := NewDocument(snapshot, "")
161+ require.Equal(t, []SearchMatch{{Row: rows, Start: 0, End: 6, Text: "target"}}, FindMatches(doc, "target"))
162+}
163+
164+func TestCopyModeRenderSelectionAllocationsBoundedToViewport(t *testing.T) {
165+ const (
166+ rows = 10_000
167+ height = 4
168+ maxAllocs = 20
169+ )
170+ lines := make([][]renderer.Cell, rows)
171+ for i := range lines {
172+ lines[i] = row("selection")
173+ }
174+ m := NewMode(NewDocument(NewSnapshotFromRows(lines, 16, height), ""))
175+ require.True(t, m.StartCharacterSelection(Pos{}))
176+ require.True(t, m.ExtendCharacterSelection(Pos{Row: rows - 1, Col: 8}))
177+
178+ allocs := testing.AllocsPerRun(10, func() {
179+ _ = m.Render()
180+ })
181+ require.LessOrEqual(t, allocs, float64(maxAllocs), "rendering a %d-line selection must only allocate for its %d-line viewport", rows, height)
182+}
183+
184+func BenchmarkCopyModeRenderLargeSelection(b *testing.B) {
185+ const (
186+ rows = 10_000
187+ height = 4
188+ )
189+ lines := make([][]renderer.Cell, rows)
190+ for i := range lines {
191+ lines[i] = row("selection")
192+ }
193+ m := NewMode(NewDocument(NewSnapshotFromRows(lines, 16, height), ""))
194+ if !m.StartCharacterSelection(Pos{}) || !m.ExtendCharacterSelection(Pos{Row: rows - 1, Col: 8}) {
195+ b.Fatal("could not create large selection")
196+ }
197+
198+ b.ReportAllocs()
199+ b.ResetTimer()
200+ for range b.N {
201+ _ = m.Render()
202+ }
203+}
204+
112205 func TestCopyModeOSC52SelectionMatrix(t *testing.T) {
113206 cases := []struct {
114207 name string
@@ -8,6 +8,7 @@ import (
8 8
9 "github.com/bnema/vev/internal/domain"9 "github.com/bnema/vev/internal/domain"
10 "github.com/bnema/vev/pkg/renderer"10 "github.com/bnema/vev/pkg/renderer"
11+ "github.com/bnema/vev/pkg/vt"
11 "github.com/stretchr/testify/require"12 "github.com/stretchr/testify/require"
12 )13 )
13 14
@@ -109,6 +110,98 @@ func TestFindMatchesUsesExclusiveDisplayCellOffsets(t *testing.T) {
109 require.Equal(t, []SearchMatch{{Row: 0, Start: 2, End: 7, Text: "界alpha"}}, FindMatches(doc, "alpha"))110 require.Equal(t, []SearchMatch{{Row: 0, Start: 2, End: 7, Text: "界alpha"}}, FindMatches(doc, "alpha"))
110 }111 }
111 112
113+func TestFindMatchesRepeatedUnicodeDisplayCells(t *testing.T) {
114+ cells := []renderer.Cell{
115+ {Rune: 'Ä'}, {Rune: '界'}, {Continuation: true},
116+ {Rune: 'ä'}, {Rune: '界'}, {Continuation: true},
117+ {Rune: 'Ä'}, {Rune: '界'}, {Continuation: true},
118+ }
119+ doc := NewDocument(NewSnapshotFromRows([][]renderer.Cell{row("aaaa"), cells}, 9, 2), "")
120+
121+ for _, tt := range []struct {
122+ name, query string
123+ want []SearchMatch
124+ }{
125+ {
126+ name: "repeated non-overlapping",
127+ query: "aa",
128+ want: []SearchMatch{
129+ {Row: 0, Start: 0, End: 2, Text: "aaaa"},
130+ {Row: 0, Start: 2, End: 4, Text: "aaaa"},
131+ },
132+ },
133+ {
134+ name: "unicode display cells",
135+ query: "Ä界",
136+ want: []SearchMatch{
137+ {Row: 1, Start: 0, End: 3, Text: "Ä界ä界Ä界"},
138+ {Row: 1, Start: 3, End: 6, Text: "Ä界ä界Ä界"},
139+ {Row: 1, Start: 6, End: 9, Text: "Ä界ä界Ä界"},
140+ },
141+ },
142+ } {
143+ t.Run(tt.name, func(t *testing.T) {
144+ require.Equal(t, tt.want, FindMatches(doc, tt.query))
145+ })
146+ }
147+}
148+
149+func TestFindMatchesUsesSealedScrollbackWithoutGlobalCopy(t *testing.T) {
150+ const rows = 10_000
151+ history := vt.NewHistory(vt.HistoryConfig{MaxRows: rows + 1, ChunkRows: 256})
152+ for range rows {
153+ history.Append(row("unmatched"))
154+ }
155+ history.Append(row("target"))
156+ snapshot := NewSnapshot(history, renderer.NewFrame(16, 1))
157+ view := history.SealAndView()
158+ require.Same(t, view.Chunk(0), snapshot.history.Chunk(0))
159+
160+ doc := NewDocument(snapshot, "")
161+ require.Equal(t, []SearchMatch{{Row: rows, Start: 0, End: 6, Text: "target"}}, FindMatches(doc, "target"))
162+}
163+
164+func TestCopyModeRenderSelectionAllocationsBoundedToViewport(t *testing.T) {
165+ const (
166+ rows = 10_000
167+ height = 4
168+ maxAllocs = 20
169+ )
170+ lines := make([][]renderer.Cell, rows)
171+ for i := range lines {
172+ lines[i] = row("selection")
173+ }
174+ m := NewMode(NewDocument(NewSnapshotFromRows(lines, 16, height), ""))
175+ require.True(t, m.StartCharacterSelection(Pos{}))
176+ require.True(t, m.ExtendCharacterSelection(Pos{Row: rows - 1, Col: 8}))
177+
178+ allocs := testing.AllocsPerRun(10, func() {
179+ _ = m.Render()
180+ })
181+ require.LessOrEqual(t, allocs, float64(maxAllocs), "rendering a %d-line selection must only allocate for its %d-line viewport", rows, height)
182+}
183+
184+func BenchmarkCopyModeRenderLargeSelection(b *testing.B) {
185+ const (
186+ rows = 10_000
187+ height = 4
188+ )
189+ lines := make([][]renderer.Cell, rows)
190+ for i := range lines {
191+ lines[i] = row("selection")
192+ }
193+ m := NewMode(NewDocument(NewSnapshotFromRows(lines, 16, height), ""))
194+ if !m.StartCharacterSelection(Pos{}) || !m.ExtendCharacterSelection(Pos{Row: rows - 1, Col: 8}) {
195+ b.Fatal("could not create large selection")
196+ }
197+
198+ b.ReportAllocs()
199+ b.ResetTimer()
200+ for range b.N {
201+ _ = m.Render()
202+ }
203+}
204+
112 func TestCopyModeOSC52SelectionMatrix(t *testing.T) {205 func TestCopyModeOSC52SelectionMatrix(t *testing.T) {
113 cases := []struct {206 cases := []struct {
114 name string207 name string
M internal/usecase/copy/selection.go +61 -43
@@ -49,42 +49,94 @@ func (s Selection) Ordered() (Pos, Pos) {
4949 // Ranges returns the canonical, inclusive per-physical-row display-cell
5050 // ranges. It returns nil for disabled selections or invalid endpoints.
5151 func (s Selection) Ranges(doc *Document) []CellRange {
52- if !s.Enabled || doc == nil {
52+ bounds, ok := s.bounds(doc)
53+ if !ok {
5354 return nil
5455 }
56+ ranges := make([]CellRange, 0, bounds.end.Row-bounds.start.Row+1)
57+ for row := bounds.start.Row; row <= bounds.end.Row; row++ {
58+ r, ok := bounds.rangeForRow(doc, row)
59+ if ok {
60+ ranges = append(ranges, r)
61+ }
62+ }
63+ return ranges
64+}
65+
66+// RangeForRow returns the canonical inclusive range for one document row. It
67+// lets viewport renderers avoid materializing ranges for off-screen rows.
68+func (s Selection) RangeForRow(doc *Document, row int) (CellRange, bool) {
69+ bounds, ok := s.bounds(doc)
70+ if !ok {
71+ return CellRange{}, false
72+ }
73+ return bounds.rangeForRow(doc, row)
74+}
75+
76+type selectionBounds struct {
77+ start, end Pos
78+ linewise bool
79+}
80+
81+func (s Selection) bounds(doc *Document) (selectionBounds, bool) {
82+ if !s.Enabled || doc == nil {
83+ return selectionBounds{}, false
84+ }
5585 anchor, ok := doc.Normalize(s.Anchor)
5686 if !ok {
57- return nil
87+ return selectionBounds{}, false
5888 }
5989 active, ok := doc.Normalize(s.Active)
6090 if !ok {
61- return nil
91+ return selectionBounds{}, false
6292 }
6393
6494 switch s.Granularity {
6595 case Line:
6696 start, end := order(anchor, active)
67- return lineRanges(doc, start.Row, end.Row)
97+ return selectionBounds{start: start, end: end, linewise: true}, true
6898 case Character:
6999 start, end := order(anchor, active)
70- return streamRanges(doc, start, end)
100+ return selectionBounds{start: start, end: end}, true
71101 case Word:
72102 anchorStart, anchorEnd, ok := doc.WordBounds(anchor)
73103 if !ok {
74- return nil
104+ return selectionBounds{}, false
75105 }
76106 activeStart, activeEnd, ok := doc.WordBounds(active)
77107 if !ok {
78- return nil
108+ return selectionBounds{}, false
79109 }
80110 start, _ := order(anchorStart, activeStart)
81111 _, end := order(anchorEnd, activeEnd)
82- return streamRanges(doc, start, end)
112+ return selectionBounds{start: start, end: end}, true
83113 default:
84- return nil
114+ return selectionBounds{}, false
85115 }
86116 }
87117
118+func (b selectionBounds) rangeForRow(doc *Document, row int) (CellRange, bool) {
119+ if row < b.start.Row || row > b.end.Row {
120+ return CellRange{}, false
121+ }
122+ cells := doc.Row(row)
123+ if len(cells) == 0 {
124+ return CellRange{Row: row}, true
125+ }
126+ if b.linewise {
127+ return CellRange{Row: row, End: len(cells) - 1}, true
128+ }
129+
130+ start, end := 0, len(cells)-1
131+ if row == b.start.Row {
132+ start = b.start.Col
133+ }
134+ if row == b.end.Row {
135+ end = glyphEnd(cells, b.end.Col)
136+ }
137+ return CellRange{Row: row, Start: start, End: end}, true
138+}
139+
88140 // Text extracts the selection using its range granularity's newline and
89141 // trailing-space semantics.
90142 func (s Selection) Text(doc *Document) string {
@@ -117,40 +169,6 @@ func order(left, right Pos) (Pos, Pos) {
117169 return left, right
118170 }
119171
120-func lineRanges(doc *Document, startRow, endRow int) []CellRange {
121- ranges := make([]CellRange, 0, endRow-startRow+1)
122- for row := startRow; row <= endRow; row++ {
123- cells := doc.Row(row)
124- if len(cells) == 0 {
125- ranges = append(ranges, CellRange{Row: row})
126- continue
127- }
128- ranges = append(ranges, CellRange{Row: row, End: len(cells) - 1})
129- }
130- return ranges
131-}
132-
133-func streamRanges(doc *Document, start, end Pos) []CellRange {
134- ranges := make([]CellRange, 0, end.Row-start.Row+1)
135- for row := start.Row; row <= end.Row; row++ {
136- cells := doc.Row(row)
137- if len(cells) == 0 {
138- ranges = append(ranges, CellRange{Row: row})
139- continue
140- }
141-
142- first, last := 0, len(cells)-1
143- if row == start.Row {
144- first = start.Col
145- }
146- if row == end.Row {
147- last = glyphEnd(cells, end.Col)
148- }
149- ranges = append(ranges, CellRange{Row: row, Start: first, End: last})
150- }
151- return ranges
152-}
153-
154172 // glyphEnd expands a glyph-head endpoint over all of its continuation cells.
155173 func glyphEnd(cells []renderer.Cell, col int) int {
156174 for col+1 < len(cells) && cells[col+1].Continuation {
@@ -49,42 +49,94 @@ func (s Selection) Ordered() (Pos, Pos) {
49 // Ranges returns the canonical, inclusive per-physical-row display-cell49 // Ranges returns the canonical, inclusive per-physical-row display-cell
50 // ranges. It returns nil for disabled selections or invalid endpoints.50 // ranges. It returns nil for disabled selections or invalid endpoints.
51 func (s Selection) Ranges(doc *Document) []CellRange {51 func (s Selection) Ranges(doc *Document) []CellRange {
52- if !s.Enabled || doc == nil {52+ bounds, ok := s.bounds(doc)
53+ if !ok {
53 return nil54 return nil
54 }55 }
56+ ranges := make([]CellRange, 0, bounds.end.Row-bounds.start.Row+1)
57+ for row := bounds.start.Row; row <= bounds.end.Row; row++ {
58+ r, ok := bounds.rangeForRow(doc, row)
59+ if ok {
60+ ranges = append(ranges, r)
61+ }
62+ }
63+ return ranges
64+}
65+
66+// RangeForRow returns the canonical inclusive range for one document row. It
67+// lets viewport renderers avoid materializing ranges for off-screen rows.
68+func (s Selection) RangeForRow(doc *Document, row int) (CellRange, bool) {
69+ bounds, ok := s.bounds(doc)
70+ if !ok {
71+ return CellRange{}, false
72+ }
73+ return bounds.rangeForRow(doc, row)
74+}
75+
76+type selectionBounds struct {
77+ start, end Pos
78+ linewise bool
79+}
80+
81+func (s Selection) bounds(doc *Document) (selectionBounds, bool) {
82+ if !s.Enabled || doc == nil {
83+ return selectionBounds{}, false
84+ }
55 anchor, ok := doc.Normalize(s.Anchor)85 anchor, ok := doc.Normalize(s.Anchor)
56 if !ok {86 if !ok {
57- return nil87+ return selectionBounds{}, false
58 }88 }
59 active, ok := doc.Normalize(s.Active)89 active, ok := doc.Normalize(s.Active)
60 if !ok {90 if !ok {
61- return nil91+ return selectionBounds{}, false
62 }92 }
63 93
64 switch s.Granularity {94 switch s.Granularity {
65 case Line:95 case Line:
66 start, end := order(anchor, active)96 start, end := order(anchor, active)
67- return lineRanges(doc, start.Row, end.Row)97+ return selectionBounds{start: start, end: end, linewise: true}, true
68 case Character:98 case Character:
69 start, end := order(anchor, active)99 start, end := order(anchor, active)
70- return streamRanges(doc, start, end)100+ return selectionBounds{start: start, end: end}, true
71 case Word:101 case Word:
72 anchorStart, anchorEnd, ok := doc.WordBounds(anchor)102 anchorStart, anchorEnd, ok := doc.WordBounds(anchor)
73 if !ok {103 if !ok {
74- return nil104+ return selectionBounds{}, false
75 }105 }
76 activeStart, activeEnd, ok := doc.WordBounds(active)106 activeStart, activeEnd, ok := doc.WordBounds(active)
77 if !ok {107 if !ok {
78- return nil108+ return selectionBounds{}, false
79 }109 }
80 start, _ := order(anchorStart, activeStart)110 start, _ := order(anchorStart, activeStart)
81 _, end := order(anchorEnd, activeEnd)111 _, end := order(anchorEnd, activeEnd)
82- return streamRanges(doc, start, end)112+ return selectionBounds{start: start, end: end}, true
83 default:113 default:
84- return nil114+ return selectionBounds{}, false
85 }115 }
86 }116 }
87 117
118+func (b selectionBounds) rangeForRow(doc *Document, row int) (CellRange, bool) {
119+ if row < b.start.Row || row > b.end.Row {
120+ return CellRange{}, false
121+ }
122+ cells := doc.Row(row)
123+ if len(cells) == 0 {
124+ return CellRange{Row: row}, true
125+ }
126+ if b.linewise {
127+ return CellRange{Row: row, End: len(cells) - 1}, true
128+ }
129+
130+ start, end := 0, len(cells)-1
131+ if row == b.start.Row {
132+ start = b.start.Col
133+ }
134+ if row == b.end.Row {
135+ end = glyphEnd(cells, b.end.Col)
136+ }
137+ return CellRange{Row: row, Start: start, End: end}, true
138+}
139+
88 // Text extracts the selection using its range granularity's newline and140 // Text extracts the selection using its range granularity's newline and
89 // trailing-space semantics.141 // trailing-space semantics.
90 func (s Selection) Text(doc *Document) string {142 func (s Selection) Text(doc *Document) string {
@@ -117,40 +169,6 @@ func order(left, right Pos) (Pos, Pos) {
117 return left, right169 return left, right
118 }170 }
119 171
120-func lineRanges(doc *Document, startRow, endRow int) []CellRange {
121- ranges := make([]CellRange, 0, endRow-startRow+1)
122- for row := startRow; row <= endRow; row++ {
123- cells := doc.Row(row)
124- if len(cells) == 0 {
125- ranges = append(ranges, CellRange{Row: row})
126- continue
127- }
128- ranges = append(ranges, CellRange{Row: row, End: len(cells) - 1})
129- }
130- return ranges
131-}
132-
133-func streamRanges(doc *Document, start, end Pos) []CellRange {
134- ranges := make([]CellRange, 0, end.Row-start.Row+1)
135- for row := start.Row; row <= end.Row; row++ {
136- cells := doc.Row(row)
137- if len(cells) == 0 {
138- ranges = append(ranges, CellRange{Row: row})
139- continue
140- }
141-
142- first, last := 0, len(cells)-1
143- if row == start.Row {
144- first = start.Col
145- }
146- if row == end.Row {
147- last = glyphEnd(cells, end.Col)
148- }
149- ranges = append(ranges, CellRange{Row: row, Start: first, End: last})
150- }
151- return ranges
152-}
153-
154 // glyphEnd expands a glyph-head endpoint over all of its continuation cells.172 // glyphEnd expands a glyph-head endpoint over all of its continuation cells.
155 func glyphEnd(cells []renderer.Cell, col int) int {173 func glyphEnd(cells []renderer.Cell, col int) int {
156 for col+1 < len(cells) && cells[col+1].Continuation {174 for col+1 < len(cells) && cells[col+1].Continuation {
M internal/usecase/copy/selection_test.go +27 -0
@@ -54,6 +54,33 @@ func TestSelectionRanges(t *testing.T) {
5454 }
5555 }
5656
57+func TestSelectionRangeForRowMatchesCanonicalRanges(t *testing.T) {
58+ wide := []renderer.Cell{{Rune: 'a'}, {Rune: '界'}, {Continuation: true}, {Rune: 'b'}}
59+ doc := NewDocument(NewSnapshotFromRows([][]renderer.Cell{row("alpha"), wide, row("bravo")}, 5, 3), " -_@")
60+ tests := []struct {
61+ name string
62+ selection Selection
63+ row int
64+ want CellRange
65+ ok bool
66+ }{
67+ {"line reverse", Selection{Anchor: Pos{2, 1}, Active: Pos{0, 2}, Granularity: Line, Enabled: true}, 1, CellRange{Row: 1, End: 3}, true},
68+ {"character wide", Selection{Anchor: Pos{0, 2}, Active: Pos{1, 1}, Granularity: Character, Enabled: true}, 1, CellRange{Row: 1, End: 2}, true},
69+ {"word", Selection{Anchor: Pos{0, 2}, Active: Pos{2, 1}, Granularity: Word, Enabled: true}, 1, CellRange{Row: 1, End: 3}, true},
70+ {"outside selection", Selection{Anchor: Pos{0, 2}, Active: Pos{1, 1}, Granularity: Character, Enabled: true}, 2, CellRange{}, false},
71+ }
72+ for _, tt := range tests {
73+ t.Run(tt.name, func(t *testing.T) {
74+ got, ok := tt.selection.RangeForRow(doc, tt.row)
75+ require.Equal(t, tt.ok, ok)
76+ if ok {
77+ require.Equal(t, tt.want, got)
78+ require.Contains(t, tt.selection.Ranges(doc), got)
79+ }
80+ })
81+ }
82+}
83+
5784 func TestSelectionOrdered(t *testing.T) {
5885 tests := []struct {
5986 name string
@@ -54,6 +54,33 @@ func TestSelectionRanges(t *testing.T) {
54 }54 }
55 }55 }
56 56
57+func TestSelectionRangeForRowMatchesCanonicalRanges(t *testing.T) {
58+ wide := []renderer.Cell{{Rune: 'a'}, {Rune: '界'}, {Continuation: true}, {Rune: 'b'}}
59+ doc := NewDocument(NewSnapshotFromRows([][]renderer.Cell{row("alpha"), wide, row("bravo")}, 5, 3), " -_@")
60+ tests := []struct {
61+ name string
62+ selection Selection
63+ row int
64+ want CellRange
65+ ok bool
66+ }{
67+ {"line reverse", Selection{Anchor: Pos{2, 1}, Active: Pos{0, 2}, Granularity: Line, Enabled: true}, 1, CellRange{Row: 1, End: 3}, true},
68+ {"character wide", Selection{Anchor: Pos{0, 2}, Active: Pos{1, 1}, Granularity: Character, Enabled: true}, 1, CellRange{Row: 1, End: 2}, true},
69+ {"word", Selection{Anchor: Pos{0, 2}, Active: Pos{2, 1}, Granularity: Word, Enabled: true}, 1, CellRange{Row: 1, End: 3}, true},
70+ {"outside selection", Selection{Anchor: Pos{0, 2}, Active: Pos{1, 1}, Granularity: Character, Enabled: true}, 2, CellRange{}, false},
71+ }
72+ for _, tt := range tests {
73+ t.Run(tt.name, func(t *testing.T) {
74+ got, ok := tt.selection.RangeForRow(doc, tt.row)
75+ require.Equal(t, tt.ok, ok)
76+ if ok {
77+ require.Equal(t, tt.want, got)
78+ require.Contains(t, tt.selection.Ranges(doc), got)
79+ }
80+ })
81+ }
82+}
83+
57 func TestSelectionOrdered(t *testing.T) {84 func TestSelectionOrdered(t *testing.T) {
58 tests := []struct {85 tests := []struct {
59 name string86 name 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