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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 { | |||
| 352 | 352 | } | |
| 353 | 353 | d := m.document | |
| 354 | 354 | 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) | |
| 360 | 356 | selection, hasSelection := optionalStyle(styles, 1) | |
| 361 | 357 | cursor, cursorValid := d.Normalize(m.navigator.Pos) | |
| 362 | 358 | for y := range d.Height() { | |
| @@ -372,12 +368,14 @@ func (m *Mode) Render(styles ...renderer.Style) renderer.Frame { | |||
| 372 | 368 | } | |
| 373 | 369 | } | |
| 374 | 370 | 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 | + | } | |
| 381 | 379 | } | |
| 382 | 380 | } | |
| 383 | 381 | 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.document | 353 | 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 := false | 370 | 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 = true | 376 | + | 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 ( | |||
| 8 | 8 | ||
| 9 | 9 | "github.com/bnema/vev/internal/domain" | |
| 10 | 10 | "github.com/bnema/vev/pkg/renderer" | |
| 11 | + | "github.com/bnema/vev/pkg/vt" | |
| 11 | 12 | "github.com/stretchr/testify/require" | |
| 12 | 13 | ) | |
| 13 | 14 | ||
| @@ -109,6 +110,98 @@ func TestFindMatchesUsesExclusiveDisplayCellOffsets(t *testing.T) { | |||
| 109 | 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 | 205 | func TestCopyModeOSC52SelectionMatrix(t *testing.T) { | |
| 113 | 206 | cases := []struct { | |
| 114 | 207 | 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 string | 207 | name string | ||
M internal/usecase/copy/selection.go +61 -43
| @@ -49,42 +49,94 @@ func (s Selection) Ordered() (Pos, Pos) { | |||
| 49 | 49 | // Ranges returns the canonical, inclusive per-physical-row display-cell | |
| 50 | 50 | // ranges. It returns nil for disabled selections or invalid endpoints. | |
| 51 | 51 | func (s Selection) Ranges(doc *Document) []CellRange { | |
| 52 | - | if !s.Enabled || doc == nil { | |
| 52 | + | bounds, ok := s.bounds(doc) | |
| 53 | + | if !ok { | |
| 53 | 54 | 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 | 85 | anchor, ok := doc.Normalize(s.Anchor) | |
| 56 | 86 | if !ok { | |
| 57 | - | return nil | |
| 87 | + | return selectionBounds{}, false | |
| 58 | 88 | } | |
| 59 | 89 | active, ok := doc.Normalize(s.Active) | |
| 60 | 90 | if !ok { | |
| 61 | - | return nil | |
| 91 | + | return selectionBounds{}, false | |
| 62 | 92 | } | |
| 63 | 93 | ||
| 64 | 94 | switch s.Granularity { | |
| 65 | 95 | case Line: | |
| 66 | 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 | 98 | case Character: | |
| 69 | 99 | start, end := order(anchor, active) | |
| 70 | - | return streamRanges(doc, start, end) | |
| 100 | + | return selectionBounds{start: start, end: end}, true | |
| 71 | 101 | case Word: | |
| 72 | 102 | anchorStart, anchorEnd, ok := doc.WordBounds(anchor) | |
| 73 | 103 | if !ok { | |
| 74 | - | return nil | |
| 104 | + | return selectionBounds{}, false | |
| 75 | 105 | } | |
| 76 | 106 | activeStart, activeEnd, ok := doc.WordBounds(active) | |
| 77 | 107 | if !ok { | |
| 78 | - | return nil | |
| 108 | + | return selectionBounds{}, false | |
| 79 | 109 | } | |
| 80 | 110 | start, _ := order(anchorStart, activeStart) | |
| 81 | 111 | _, end := order(anchorEnd, activeEnd) | |
| 82 | - | return streamRanges(doc, start, end) | |
| 112 | + | return selectionBounds{start: start, end: end}, true | |
| 83 | 113 | default: | |
| 84 | - | return nil | |
| 114 | + | 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 | 140 | // Text extracts the selection using its range granularity's newline and | |
| 89 | 141 | // trailing-space semantics. | |
| 90 | 142 | func (s Selection) Text(doc *Document) string { | |
| @@ -117,40 +169,6 @@ func order(left, right Pos) (Pos, Pos) { | |||
| 117 | 169 | 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 | 172 | // glyphEnd expands a glyph-head endpoint over all of its continuation cells. | |
| 155 | 173 | func glyphEnd(cells []renderer.Cell, col int) int { | |
| 156 | 174 | 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-cell | 49 | // 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 nil | 54 | 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 nil | 87 | + | 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 nil | 91 | + | 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 nil | 104 | + | 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 nil | 108 | + | 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 nil | 114 | + | 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 and | 140 | // 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, right | 169 | 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) { | |||
| 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 | 84 | func TestSelectionOrdered(t *testing.T) { | |
| 58 | 85 | tests := []struct { | |
| 59 | 86 | 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 string | 86 | name string | ||