1package copy
2
3import (
4 "bytes"
5 "encoding/base64"
6 "strings"
7 "testing"
8
9 vt "github.com/bnema/vev-vt"
10 renderer "github.com/bnema/vev-vt/ansi"
11 "github.com/bnema/vev/internal/domain"
12 "github.com/stretchr/testify/require"
13)
14
15func row(text string) []renderer.Cell {
16 cells := make([]renderer.Cell, 0, len([]rune(text)))
17 for _, r := range text {
18 cells = append(cells, renderer.Cell{Rune: r})
19 }
20 return cells
21}
22func modeFor(lines []string, height int) *Mode {
23 rows := make([][]renderer.Cell, len(lines))
24 for i := range lines {
25 rows[i] = row(lines[i])
26 }
27 return NewMode(NewDocument(NewSnapshotFromRows(rows, 16, height), domain.DefaultWordSeparators))
28}
29
30func TestNewSnapshotFromRowsPreservesWideRows(t *testing.T) {
31 wide := make([]renderer.Cell, 321)
32 for i := range wide {
33 wide[i] = renderer.Cell{Rune: rune('a' + i%26)}
34 }
35 rows := [][]renderer.Cell{wide, row("tail")}
36
37 snapshot := NewSnapshotFromRows(rows, len(wide), len(rows))
38
39 require.Equal(t, len(rows), snapshot.history.Len())
40 for i, want := range rows {
41 require.Equal(t, want, snapshot.history.Row(i))
42 }
43}
44
45func TestSnapshotCarriesImmutableRowIDsAndDocumentsResolveBookmarks(t *testing.T) {
46 history := vt.NewHistory(vt.HistoryConfig{MaxRows: 8})
47 require.NoError(t, history.AppendWithID(row("hist"), vt.LineBound{End: 4}, vt.RowID(11)))
48 require.NoError(t, history.AppendWithID(row("tail"), vt.LineBound{End: 4}, vt.RowID(12)))
49 screen := renderer.NewFrame(4, 2)
50 screen.WriteRow(0, 0, row("live"))
51 screen.WriteRow(1, 0, row("more"))
52 rowIDs := []vt.RowID{21, 22}
53
54 snapshot := NewSnapshot(history, screen, []vt.LineBound{{End: 4}, {End: 4}}, rowIDs)
55 rowIDs[0] = 99
56 doc := NewDocument(snapshot, "")
57
58 require.Equal(t, []vt.RowID{11, 12, 21, 22}, snapshot.RowIDs())
59 ids := snapshot.RowIDs()
60 ids[0] = 100
61 require.Equal(t, vt.RowID(11), snapshot.RowID(0))
62 require.Equal(t, vt.RowID(21), doc.RowID(2))
63 require.Equal(t, 2, doc.FindRowID(vt.RowID(21)))
64 require.Equal(t, 3, doc.FindRowID(vt.RowID(22)))
65 require.Equal(t, -1, doc.FindRowID(vt.RowID(99)))
66}
67
68func TestSnapshotBoundDispatchesLikeRow(t *testing.T) {
69 row := func(s string) []renderer.Cell {
70 cells := make([]renderer.Cell, 0, len(s))
71 for _, r := range s {
72 cells = append(cells, renderer.Cell{Rune: r})
73 }
74 return cells
75 }
76
77 history := vt.NewHistory(vt.HistoryConfig{MaxRows: 8})
78 if err := history.Append(row("hist"), vt.LineBound{End: 4, Soft: true}); err != nil {
79 t.Fatalf("append: %v", err)
80 }
81 screen := renderer.NewFrame(4, 2)
82 screen.WriteRow(0, 0, row("live"))
83
84 snap := NewSnapshot(history, screen, []vt.LineBound{{End: 4}, {End: 0, Soft: true}}, nil)
85
86 tests := []struct {
87 name string
88 i int
89 want vt.LineBound
90 }{
91 {name: "history row", i: 0, want: vt.LineBound{End: 4, Soft: true}},
92 {name: "first screen row", i: 1, want: vt.LineBound{End: 4}},
93 {name: "second screen row", i: 2, want: vt.LineBound{End: 0, Soft: true}},
94 {name: "past the end", i: 3, want: vt.LineBound{}},
95 {name: "negative", i: -1, want: vt.LineBound{}},
96 }
97 for _, tc := range tests {
98 t.Run(tc.name, func(t *testing.T) {
99 if got := snap.Bound(tc.i); got != tc.want {
100 t.Errorf("Bound(%d) = %+v, want %+v", tc.i, got, tc.want)
101 }
102 })
103 }
104}
105
106func TestCopyModeKeyboardSelection(t *testing.T) {
107 m := modeFor([]string{"alpha", "xy", "bravo", "charlie"}, 2)
108 require.Equal(t, Pos{Row: 3}, m.Cursor())
109 m.Top()
110 m.ToggleLineSelection()
111 m.Down()
112 require.Equal(t, Line, m.Selection().Granularity)
113 require.Equal(t, "alpha\nxy", m.SelectedText())
114 m.Right()
115 require.Equal(t, Character, m.Selection().Granularity)
116 require.Equal(t, Pos{Row: 0}, m.Selection().Anchor)
117 require.Equal(t, Pos{Row: 1, Col: 1}, m.Selection().Active)
118 require.True(t, m.Left())
119 require.Equal(t, Pos{Row: 1}, m.Cursor())
120 require.True(t, m.Right())
121 require.True(t, m.Up())
122 require.Equal(t, Pos{Row: 0, Col: 1}, m.Cursor())
123 require.True(t, m.Down())
124 require.Equal(t, Pos{Row: 1, Col: 1}, m.Cursor())
125 require.True(t, m.WordNext())
126 require.Equal(t, Pos{Row: 2}, m.Cursor())
127 require.True(t, m.WordEnd())
128 require.Equal(t, Pos{Row: 2, Col: 4}, m.Cursor())
129 require.True(t, m.WordBackward())
130 require.Equal(t, Pos{Row: 2}, m.Cursor())
131}
132
133func TestCopyModePreferredColumnAndPartialSpaces(t *testing.T) {
134 m := modeFor([]string{"abcdef", "xy", "abcdef"}, 2)
135 require.True(t, m.SetPosition(Pos{Row: 0, Col: 5}))
136 m.Down()
137 require.Equal(t, Pos{Row: 1, Col: 1}, m.Cursor())
138 m.Down()
139 require.Equal(t, Pos{Row: 2, Col: 5}, m.Cursor())
140 m.SetPosition(Pos{Row: 0, Col: 1})
141 require.True(t, m.StartCharacterSelection(Pos{Row: 0, Col: 1}))
142 require.True(t, m.ExtendCharacterSelection(Pos{Row: 0, Col: 4}))
143 require.Equal(t, "bcde", m.SelectedText())
144}
145
146func TestCopyModeRenderSelectionWinsSearchAndWideGlyph(t *testing.T) {
147 cells := []renderer.Cell{{Rune: '界'}, {Continuation: true}, {Rune: 'a'}}
148 m := NewMode(NewDocument(NewSnapshotFromRows([][]renderer.Cell{cells}, 3, 1), ""))
149 require.True(t, m.SetSearchMatches("界", []SearchMatch{{Row: 0, Start: 0, End: 2}}, 0))
150 require.True(t, m.StartCharacterSelection(Pos{Row: 0, Col: 0}))
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)
153 require.True(t, f.At(1, 0).Style.HasBackgroundRGB)
154}
155
156func TestCopyModeSearchNavigationExtendsActiveSelection(t *testing.T) {
157 matches := []SearchMatch{{Row: 1, Start: 1, End: 2}, {Row: 2, Start: 2, End: 3}}
158 m := modeFor([]string{"zero", "one", "two"}, 3)
159
160 m.SetPosition(Pos{Row: 0})
161 m.ToggleLineSelection()
162 require.True(t, m.SetSearchMatches("match", matches, 0))
163 require.Equal(t, Selection{Anchor: Pos{Row: 0}, Active: Pos{Row: 1, Col: 1}, Granularity: Line, Enabled: true}, m.Selection())
164
165 require.True(t, m.Right())
166 require.Equal(t, Character, m.Selection().Granularity)
167 require.True(t, m.NextSearchMatch(1))
168 require.Equal(t, Selection{Anchor: Pos{Row: 0}, Active: Pos{Row: 2, Col: 2}, Granularity: Character, Enabled: true}, m.Selection())
169}
170
171func TestCopyModeSearchExtendsSelectionWhenMatchIsAlreadyAtCursor(t *testing.T) {
172 m := modeFor([]string{"zero", "one"}, 2)
173 require.True(t, m.StartCharacterSelection(Pos{Row: 0}))
174 require.True(t, m.ExtendCharacterSelection(Pos{Row: 1, Col: 1}))
175 require.True(t, m.SetPosition(Pos{Row: 0}))
176
177 require.True(t, m.SetSearchMatches("zero", []SearchMatch{{Row: 0, Start: 0, End: 4}}, 0))
178 require.Equal(t, Pos{Row: 0}, m.Selection().Active)
179}
180
181func TestCopyModeSearchNilMode(t *testing.T) {
182 var mode *Mode
183 require.False(t, mode.Search("anything"))
184}
185
186func TestCopyModeRenderBoundsCursorToRenderedRow(t *testing.T) {
187 m := NewMode(NewDocument(NewSnapshotFromRows([][]renderer.Cell{row("alpha")}, 2, 1), ""))
188 require.True(t, m.SetPosition(Pos{Row: 0, Col: 4}), "cursor remains valid in the document despite a narrow render frame")
189
190 selection := renderer.Style{HasBackgroundRGB: true, BackgroundRGB: renderer.RGB{R: 1}}
191 require.NotPanics(t, func() { _ = m.Render(renderer.DefaultStyle(), selection) })
192 frame := m.Render(renderer.DefaultStyle(), selection)
193 require.True(t, frame.At(1, 0).Style.HasBackgroundRGB, "a valid off-frame cursor is rendered at the visible edge")
194}
195
196func TestCopyModeRenderKeepsPassiveCursorOutsidePartialSameRowSelection(t *testing.T) {
197 cells := []renderer.Cell{{Rune: '界'}, {Continuation: true}, {Rune: 'a'}, {Rune: 'b'}}
198 m := NewMode(NewDocument(NewSnapshotFromRows([][]renderer.Cell{cells}, 4, 1), ""))
199 require.True(t, m.StartCharacterSelection(Pos{Row: 0, Col: 2}))
200 require.True(t, m.SetPosition(Pos{Row: 0, Col: 1})) // Normalize the wide-glyph continuation to its head.
201
202 selection := renderer.Style{HasBackgroundRGB: true, BackgroundRGB: renderer.RGB{R: 1}}
203 f := m.Render(renderer.DefaultStyle(), selection)
204 require.True(t, f.At(0, 0).Style.HasBackgroundRGB)
205 require.True(t, f.At(2, 0).Style.HasBackgroundRGB)
206}
207
208func TestFindMatchesUsesExclusiveDisplayCellOffsets(t *testing.T) {
209 cells := []renderer.Cell{{Rune: '界'}, {Continuation: true}, {Rune: 'a'}, {Rune: 'l'}, {Rune: 'p'}, {Rune: 'h'}, {Rune: 'a'}}
210 doc := NewDocument(NewSnapshotFromRows([][]renderer.Cell{cells}, 7, 1), "")
211 require.Equal(t, []SearchMatch{{Row: 0, Start: 2, End: 7, Text: "界alpha"}}, FindMatches(doc, "alpha"))
212}
213
214func TestFindMatchesRepeatedUnicodeDisplayCells(t *testing.T) {
215 cells := []renderer.Cell{
216 {Rune: 'Ä'}, {Rune: '界'}, {Continuation: true},
217 {Rune: 'ä'}, {Rune: '界'}, {Continuation: true},
218 {Rune: 'Ä'}, {Rune: '界'}, {Continuation: true},
219 }
220 doc := NewDocument(NewSnapshotFromRows([][]renderer.Cell{row("aaaa"), cells}, 9, 2), "")
221
222 for _, tt := range []struct {
223 name, query string
224 want []SearchMatch
225 }{
226 {
227 name: "repeated non-overlapping",
228 query: "aa",
229 want: []SearchMatch{
230 {Row: 0, Start: 0, End: 2, Text: "aaaa"},
231 {Row: 0, Start: 2, End: 4, Text: "aaaa"},
232 },
233 },
234 {
235 name: "unicode display cells",
236 query: "Ä界",
237 want: []SearchMatch{
238 {Row: 1, Start: 0, End: 3, Text: "Ä界ä界Ä界"},
239 {Row: 1, Start: 3, End: 6, Text: "Ä界ä界Ä界"},
240 {Row: 1, Start: 6, End: 9, Text: "Ä界ä界Ä界"},
241 },
242 },
243 } {
244 t.Run(tt.name, func(t *testing.T) {
245 require.Equal(t, tt.want, FindMatches(doc, tt.query))
246 })
247 }
248}
249
250func TestFindMatchesUsesSealedScrollbackWithoutGlobalCopy(t *testing.T) {
251 const rows = 10_000
252 history := vt.NewHistory(vt.HistoryConfig{MaxRows: rows + 1, ChunkRows: 256})
253 unmatched, target := row("unmatched"), row("target")
254 for range rows {
255 require.NoError(t, history.Append(unmatched, vt.LineBound{End: len(unmatched)}))
256 }
257 require.NoError(t, history.Append(target, vt.LineBound{End: len(target)}))
258 snapshot := NewSnapshot(history, renderer.NewFrame(16, 1), nil, nil)
259 view := history.SealAndView()
260 require.Same(t, view.Chunk(0), snapshot.history.Chunk(0))
261
262 doc := NewDocument(snapshot, "")
263 require.Equal(t, []SearchMatch{{Row: rows, Start: 0, End: 6, Text: "target"}}, FindMatches(doc, "target"))
264}
265
266func TestCopyModeRenderSelectionAllocationsBoundedToViewport(t *testing.T) {
267 const (
268 rows = 10_000
269 height = 4
270 maxAllocs = 20
271 )
272 lines := make([][]renderer.Cell, rows)
273 for i := range lines {
274 lines[i] = row("selection")
275 }
276 m := NewMode(NewDocument(NewSnapshotFromRows(lines, 16, height), ""))
277 require.True(t, m.StartCharacterSelection(Pos{}))
278 require.True(t, m.ExtendCharacterSelection(Pos{Row: rows - 1, Col: 8}))
279
280 allocs := testing.AllocsPerRun(10, func() {
281 _ = m.Render()
282 })
283 require.LessOrEqual(t, allocs, float64(maxAllocs), "rendering a %d-line selection must only allocate for its %d-line viewport", rows, height)
284}
285
286func BenchmarkCopyModeRenderLargeSelection(b *testing.B) {
287 const (
288 rows = 10_000
289 height = 4
290 )
291 lines := make([][]renderer.Cell, rows)
292 for i := range lines {
293 lines[i] = row("selection")
294 }
295 m := NewMode(NewDocument(NewSnapshotFromRows(lines, 16, height), ""))
296 if !m.StartCharacterSelection(Pos{}) || !m.ExtendCharacterSelection(Pos{Row: rows - 1, Col: 8}) {
297 b.Fatal("could not create large selection")
298 }
299
300 b.ReportAllocs()
301 b.ResetTimer()
302 for range b.N {
303 _ = m.Render()
304 }
305}
306
307func TestCopyModeOSC52SelectionMatrix(t *testing.T) {
308 cases := []struct {
309 name string
310 selectFn func(*Mode)
311 want string
312 }{
313 {"line", func(m *Mode) { m.SetPosition(Pos{}); m.ToggleLineSelection(); m.Down() }, "alpha\nbravo"},
314 {"horizontal", func(m *Mode) { m.StartCharacterSelection(Pos{Col: 1}); m.ExtendCharacterSelection(Pos{Col: 3}) }, "lph"},
315 {"multiline", func(m *Mode) { m.StartCharacterSelection(Pos{Col: 2}); m.ExtendCharacterSelection(Pos{Row: 1, Col: 1}) }, "pha\nbr"},
316 {"reverse", func(m *Mode) { m.StartCharacterSelection(Pos{Row: 1, Col: 1}); m.ExtendCharacterSelection(Pos{Col: 2}) }, "pha\nbr"},
317 {"word", func(m *Mode) { m.SelectWordAt(Pos{Row: 1, Col: 2}) }, "bravo"},
318 }
319 for _, tc := range cases {
320 t.Run(tc.name, func(t *testing.T) {
321 m := modeFor([]string{"alpha", "bravo"}, 2)
322 tc.selectFn(m)
323 got := OSC52(m.SelectedText())
324 require.Len(t, got, 1)
325 want := []byte("\x1b]52;c;" + base64.StdEncoding.EncodeToString([]byte(tc.want)) + "\x07")
326 require.True(t, bytes.Equal(want, got[0]))
327 })
328 }
329}
330
331func TestOSC52LimitsAndBase64(t *testing.T) {
332 require.Nil(t, OSC52(strings.Repeat("x", OSC52MaxPayloadBytes+1)))
333 encoded := base64.StdEncoding.EncodeToString([]byte("hello"))
334 require.Equal(t, []byte("\x1b]52;c;"+encoded+"\x07"), OSC52FromBase64(encoded))
335}
336
337func TestCopyModeRenderStylesStatusFiller(t *testing.T) {
338 m := modeFor([]string{"alpha"}, 5)
339 status := renderer.Style{Foreground: 1, Background: 2}
340 selection := renderer.Style{Foreground: 3, Background: 4}
341 frame := m.Render(status, selection)
342
343 require.True(t, frame.At(4, frame.Height-1).Style.Equal(status), "status filler keeps cached status surface")
344}
345
346func TestSelectedTextJoinsAcrossTheHistoryScreenBoundary(t *testing.T) {
347 row := func(s string, w int) []renderer.Cell {
348 cells := make([]renderer.Cell, w)
349 for i := range cells {
350 cells[i] = renderer.BlankCell()
351 }
352 for i, r := range []rune(s) {
353 if i >= w {
354 break
355 }
356 cells[i] = renderer.Cell{Rune: r}
357 }
358 return cells
359 }
360
361 const width = 4
362 history := vt.NewHistory(vt.HistoryConfig{MaxRows: 8})
363 // The last history row wrapped into what is now the first live screen row.
364 if err := history.Append(row("abcd", width), vt.LineBound{End: width, Soft: true}); err != nil {
365 t.Fatalf("append: %v", err)
366 }
367 screen := renderer.NewFrame(width, 2)
368 screen.WriteRow(0, 0, row("ef", width))
369 screen.WriteRow(1, 0, row("gh", width))
370
371 snapshot := NewSnapshot(history, screen, []vt.LineBound{{End: 2}, {End: 2}}, nil)
372 doc := NewDocument(snapshot, "")
373
374 ranges := []CellRange{
375 {Row: 0, Start: 0, End: width - 1},
376 {Row: 1, Start: 0, End: width - 1},
377 {Row: 2, Start: 0, End: width - 1},
378 }
379 const want = "abcdef\ngh"
380 if got := doc.Extract(ranges); got != want {
381 t.Errorf("Extract() = %q, want %q", got, want)
382 }
383}
384
385// TestSelectedTextJoinsRowsWrappedByTheVT drives the whole chain with no
386// hand-written bounds: a real vt.Screen wraps one logical line itself, its
387// LineBound travels through eviction into history or stays on the live grid,
388// and Extract must give the line back exactly as the application printed it.
389func TestSelectedTextJoinsRowsWrappedByTheVT(t *testing.T) {
390 const (
391 width = 8
392 height = 4
393 line = "abcdefghij"
394 )
395 cases := []struct {
396 name string
397 // input is written verbatim to the VT; the wrap and every scroll it
398 // implies are the emulator's own decisions.
399 input string
400 // wantHistoryLen pins where the history/screen seam falls, so a change
401 // in scroll behavior fails loudly instead of silently retargeting the
402 // case onto rows the seam no longer separates.
403 wantHistoryLen int
404 // wrapRow is the first snapshot row of the wrapped logical line.
405 wrapRow int
406 }{
407 {
408 name: "wrapped line straddles the history and screen seam",
409 input: line + "\r\nx\r\ny\r\n",
410 wantHistoryLen: 1,
411 wrapRow: 0,
412 },
413 {
414 name: "wrapped line sits entirely in sealed history",
415 input: line + "\r\nx\r\ny\r\nz\r\n",
416 wantHistoryLen: 2,
417 wrapRow: 0,
418 },
419 {
420 name: "wrapped line sits entirely on the live screen",
421 input: "1\r\n2\r\n3\r\n4\r\n5\r\n\x1b[2;1H" + line,
422 wantHistoryLen: 2,
423 wrapRow: 3,
424 },
425 {
426 // The common case: the pane is full, so the cursor is on the last
427 // row and the wrap scrolls the screen as it happens. This is what a
428 // long line printed at a shell prompt actually does.
429 name: "wrapped line wraps out of the bottom row of a full pane",
430 input: "1\r\n2\r\n3\r\n4\r\n5\r\n" + line,
431 wantHistoryLen: 3,
432 wrapRow: 5,
433 },
434 }
435 for _, tc := range cases {
436 t.Run(tc.name, func(t *testing.T) {
437 screen := vt.NewScreenWithHistory(width, height, vt.HistoryConfig{MaxRows: 64})
438 screen.Write([]byte(tc.input))
439
440 snapshot := NewSnapshot(screen.History(), screen, screen.LineBounds(), nil)
441 require.Equal(t, tc.wantHistoryLen, snapshot.history.Len(), "history/screen seam moved")
442 doc := NewDocument(snapshot, domain.DefaultWordSeparators)
443
444 got := doc.Extract([]CellRange{
445 {Row: tc.wrapRow, Start: 0, End: width - 1},
446 {Row: tc.wrapRow + 1, Start: 0, End: width - 1},
447 })
448 require.Equal(t, line, got, "the two physical rows must rejoin without a newline or grid padding")
449 })
450 }
451}