detached bbd5a05

Merge pull request #145 from bnema/feat/v01-reusable-terminal-snapshot

feat(vt): add reusable screen snapshots
commit
bbd5a05eb8b1c9e54b8fc8f55d1b7b3ea323ed22
author
bnema · · 2026-07-29 05:36 UTC
committer
GitHub · · 2026-07-29 05:36 UTC
merge of
2fea442 + 8a9dd95 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
4 files · +541 -6
M internal/app/daemon_integration_test.go +10 -6
@@ -1220,10 +1220,14 @@ func TestIntegration_KillAllShutsDownDaemon(t *testing.T) {
12201220
12211221 _, err = killTr.Recv()
12221222 require.ErrorIs(t, err, io.EOF)
1223- select {
1224- case err := <-served:
1225- require.NoError(t, err)
1226- case <-time.After(5 * time.Second):
1227- t.Fatal("daemon did not shut down after kill all")
1228- }
1223+ var serveErr error
1224+ require.Eventually(t, func() bool {
1225+ select {
1226+ case serveErr = <-served:
1227+ return true
1228+ default:
1229+ return false
1230+ }
1231+ }, 15*time.Second, 10*time.Millisecond, "daemon did not shut down after kill all")
1232+ require.NoError(t, serveErr)
12291233 }
@@ -1220,10 +1220,14 @@ func TestIntegration_KillAllShutsDownDaemon(t *testing.T) {
1220 1220
1221 _, err = killTr.Recv()1221 _, err = killTr.Recv()
1222 require.ErrorIs(t, err, io.EOF)1222 require.ErrorIs(t, err, io.EOF)
1223- select {1223+ var serveErr error
1224- case err := <-served:1224+ require.Eventually(t, func() bool {
1225- require.NoError(t, err)1225+ select {
1226- case <-time.After(5 * time.Second):1226+ case serveErr = <-served:
1227- t.Fatal("daemon did not shut down after kill all")1227+ return true
1228- }1228+ default:
1229+ return false
1230+ }
1231+ }, 15*time.Second, 10*time.Millisecond, "daemon did not shut down after kill all")
1232+ require.NoError(t, serveErr)
1229 }1233 }
M pkg/vt/doc.go +5 -0
@@ -5,4 +5,9 @@
55 // sequences. Wide runes that fit are stored as a head cell plus a continuation
66 // cell, and resize or edit operations repair row-boundary splits instead of
77 // leaving orphaned halves.
8+//
9+// Screen is single-owner. Snapshot returns an owned capture of the active
10+// visible viewport and renderer-relevant metadata; callers must serialize it
11+// with Write, Resize, and host mutations. Terminal history remains available
12+// through HistoryView and HistorySnapshotView rather than ScreenSnapshot.
813 package vt
@@ -5,4 +5,9 @@
5 // sequences. Wide runes that fit are stored as a head cell plus a continuation5 // sequences. Wide runes that fit are stored as a head cell plus a continuation
6 // cell, and resize or edit operations repair row-boundary splits instead of6 // cell, and resize or edit operations repair row-boundary splits instead of
7 // leaving orphaned halves.7 // leaving orphaned halves.
8+//
9+// Screen is single-owner. Snapshot returns an owned capture of the active
10+// visible viewport and renderer-relevant metadata; callers must serialize it
11+// with Write, Resize, and host mutations. Terminal history remains available
12+// through HistoryView and HistorySnapshotView rather than ScreenSnapshot.
8 package vt13 package vt
A pkg/vt/screen_snapshot.go +89 -0
@@ -0,0 +1,89 @@
1+package vt
2+
3+import "github.com/bnema/vev/pkg/renderer"
4+
5+// CursorSnapshot is the cursor state captured with a visible screen snapshot.
6+type CursorSnapshot struct {
7+ Row int
8+ Col int
9+ Visible bool
10+ Style int
11+ StyleSet bool
12+}
13+
14+// ModeSnapshot is the renderer-relevant VT mode state captured with a screen.
15+type ModeSnapshot struct {
16+ AlternateScreen bool
17+ BracketedPaste bool
18+ SynchronizedUpdate bool
19+ ColorSchemeMode bool
20+ MouseTracking int
21+ MouseSGR bool
22+}
23+
24+// ScreenSnapshot is an owned immutable-by-convention capture of the active
25+// terminal viewport. Row returns caller-owned storage; BorrowedRow must not be
26+// mutated and remains valid while the snapshot is retained.
27+type ScreenSnapshot struct {
28+ frame renderer.Frame
29+ bounds []LineBound
30+ cursor CursorSnapshot
31+ modes ModeSnapshot
32+ title string
33+}
34+
35+// Snapshot captures the active visible viewport without mutating Screen,
36+// history, or pending damage. Screen remains single-owner; callers must
37+// serialize Snapshot with Write, Resize, and host mutations.
38+func (s *Screen) Snapshot() ScreenSnapshot {
39+ if s == nil {
40+ return ScreenSnapshot{}
41+ }
42+ cursorStyle, cursorStyleSet := s.CursorStyle()
43+ mouseTracking, mouseSGR := s.MouseMode()
44+ return ScreenSnapshot{
45+ frame: s.Frame.Clone(),
46+ bounds: s.LineBounds(),
47+ cursor: CursorSnapshot{
48+ Row: s.CursorRow(),
49+ Col: s.CursorCol(),
50+ Visible: s.CursorVisible(),
51+ Style: cursorStyle,
52+ StyleSet: cursorStyleSet,
53+ },
54+ modes: ModeSnapshot{
55+ AlternateScreen: s.AltScreenActive(),
56+ BracketedPaste: s.BracketedPasteMode(),
57+ SynchronizedUpdate: s.SyncUpdateActive(),
58+ ColorSchemeMode: s.ColorSchemeMode(),
59+ MouseTracking: mouseTracking,
60+ MouseSGR: mouseSGR,
61+ },
62+ title: s.TerminalTitle(),
63+ }
64+}
65+
66+func (s ScreenSnapshot) Columns() int { return s.frame.Width }
67+func (s ScreenSnapshot) Rows() int { return s.frame.Height }
68+
69+func (s ScreenSnapshot) Row(y int) []renderer.Cell {
70+ return append([]renderer.Cell(nil), s.BorrowedRow(y)...)
71+}
72+
73+func (s ScreenSnapshot) BorrowedRow(y int) []renderer.Cell {
74+ if y < 0 || y >= s.frame.Height {
75+ return nil
76+ }
77+ return s.frame.Row(y)
78+}
79+
80+func (s ScreenSnapshot) Bound(y int) LineBound {
81+ if y < 0 || y >= len(s.bounds) {
82+ return LineBound{}
83+ }
84+ return s.bounds[y]
85+}
86+
87+func (s ScreenSnapshot) Cursor() CursorSnapshot { return s.cursor }
88+func (s ScreenSnapshot) Modes() ModeSnapshot { return s.modes }
89+func (s ScreenSnapshot) Title() string { return s.title }
@@ -0,0 +1,89 @@
1+package vt
2+
3+import "github.com/bnema/vev/pkg/renderer"
4+
5+// CursorSnapshot is the cursor state captured with a visible screen snapshot.
6+type CursorSnapshot struct {
7+ Row int
8+ Col int
9+ Visible bool
10+ Style int
11+ StyleSet bool
12+}
13+
14+// ModeSnapshot is the renderer-relevant VT mode state captured with a screen.
15+type ModeSnapshot struct {
16+ AlternateScreen bool
17+ BracketedPaste bool
18+ SynchronizedUpdate bool
19+ ColorSchemeMode bool
20+ MouseTracking int
21+ MouseSGR bool
22+}
23+
24+// ScreenSnapshot is an owned immutable-by-convention capture of the active
25+// terminal viewport. Row returns caller-owned storage; BorrowedRow must not be
26+// mutated and remains valid while the snapshot is retained.
27+type ScreenSnapshot struct {
28+ frame renderer.Frame
29+ bounds []LineBound
30+ cursor CursorSnapshot
31+ modes ModeSnapshot
32+ title string
33+}
34+
35+// Snapshot captures the active visible viewport without mutating Screen,
36+// history, or pending damage. Screen remains single-owner; callers must
37+// serialize Snapshot with Write, Resize, and host mutations.
38+func (s *Screen) Snapshot() ScreenSnapshot {
39+ if s == nil {
40+ return ScreenSnapshot{}
41+ }
42+ cursorStyle, cursorStyleSet := s.CursorStyle()
43+ mouseTracking, mouseSGR := s.MouseMode()
44+ return ScreenSnapshot{
45+ frame: s.Frame.Clone(),
46+ bounds: s.LineBounds(),
47+ cursor: CursorSnapshot{
48+ Row: s.CursorRow(),
49+ Col: s.CursorCol(),
50+ Visible: s.CursorVisible(),
51+ Style: cursorStyle,
52+ StyleSet: cursorStyleSet,
53+ },
54+ modes: ModeSnapshot{
55+ AlternateScreen: s.AltScreenActive(),
56+ BracketedPaste: s.BracketedPasteMode(),
57+ SynchronizedUpdate: s.SyncUpdateActive(),
58+ ColorSchemeMode: s.ColorSchemeMode(),
59+ MouseTracking: mouseTracking,
60+ MouseSGR: mouseSGR,
61+ },
62+ title: s.TerminalTitle(),
63+ }
64+}
65+
66+func (s ScreenSnapshot) Columns() int { return s.frame.Width }
67+func (s ScreenSnapshot) Rows() int { return s.frame.Height }
68+
69+func (s ScreenSnapshot) Row(y int) []renderer.Cell {
70+ return append([]renderer.Cell(nil), s.BorrowedRow(y)...)
71+}
72+
73+func (s ScreenSnapshot) BorrowedRow(y int) []renderer.Cell {
74+ if y < 0 || y >= s.frame.Height {
75+ return nil
76+ }
77+ return s.frame.Row(y)
78+}
79+
80+func (s ScreenSnapshot) Bound(y int) LineBound {
81+ if y < 0 || y >= len(s.bounds) {
82+ return LineBound{}
83+ }
84+ return s.bounds[y]
85+}
86+
87+func (s ScreenSnapshot) Cursor() CursorSnapshot { return s.cursor }
88+func (s ScreenSnapshot) Modes() ModeSnapshot { return s.modes }
89+func (s ScreenSnapshot) Title() string { return s.title }
A pkg/vt/screen_snapshot_test.go +437 -0
@@ -0,0 +1,437 @@
1+package vt
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/pkg/renderer"
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func TestScreenSnapshotGeometryAndMetadata(t *testing.T) {
11+ tests := []struct {
12+ name string
13+ screen func() *Screen
14+ wantColumns int
15+ wantRows int
16+ wantTitle string
17+ wantCursor CursorSnapshot
18+ wantModes ModeSnapshot
19+ checkRows func(t *testing.T, snapshot ScreenSnapshot)
20+ }{
21+ {
22+ name: "nil receiver is the zero snapshot",
23+ screen: func() *Screen { return nil },
24+ wantCursor: CursorSnapshot{},
25+ wantModes: ModeSnapshot{},
26+ checkRows: func(t *testing.T, snapshot ScreenSnapshot) {
27+ require.Nil(t, snapshot.Row(0))
28+ require.Nil(t, snapshot.BorrowedRow(0))
29+ require.Equal(t, LineBound{}, snapshot.Bound(0))
30+ },
31+ },
32+ {
33+ name: "normal viewport captures cursor modes and title",
34+ screen: func() *Screen {
35+ screen := NewScreen(6, 3)
36+ screen.Write([]byte("\x1b]2;editor\x07\x1b[2;3H\x1b[?25l\x1b[2 q\x1b[?2004h\x1b[?2026h\x1b[?2031h\x1b[?1002h\x1b[?1006h"))
37+ return screen
38+ },
39+ wantColumns: 6,
40+ wantRows: 3,
41+ wantTitle: "editor",
42+ wantCursor: CursorSnapshot{Row: 1, Col: 2, Visible: false, Style: 2, StyleSet: true},
43+ wantModes: ModeSnapshot{
44+ BracketedPaste: true,
45+ SynchronizedUpdate: true,
46+ ColorSchemeMode: true,
47+ MouseTracking: 1002,
48+ MouseSGR: true,
49+ },
50+ checkRows: func(t *testing.T, snapshot ScreenSnapshot) {
51+ require.Len(t, snapshot.BorrowedRow(0), 6)
52+ require.Equal(t, LineBound{}, snapshot.Bound(0))
53+ },
54+ },
55+ {
56+ name: "zero by zero preserves screen metadata",
57+ screen: func() *Screen {
58+ screen := NewScreen(2, 2)
59+ screen.Write([]byte("\x1b]2;collapsed\x07\x1b[?2004h"))
60+ screen.Resize(0, 0)
61+ return screen
62+ },
63+ wantTitle: "collapsed",
64+ wantCursor: CursorSnapshot{Visible: true},
65+ wantModes: ModeSnapshot{BracketedPaste: true},
66+ checkRows: func(t *testing.T, snapshot ScreenSnapshot) {
67+ require.Nil(t, snapshot.Row(0))
68+ require.Equal(t, LineBound{}, snapshot.Bound(0))
69+ },
70+ },
71+ {
72+ name: "zero columns retains empty physical rows and bounds",
73+ screen: func() *Screen {
74+ screen := NewScreen(2, 2)
75+ screen.Write([]byte("\x1b]2;zero-columns\x07"))
76+ screen.Resize(0, 2)
77+ return screen
78+ },
79+ wantRows: 2,
80+ wantTitle: "zero-columns",
81+ wantCursor: CursorSnapshot{Visible: true},
82+ checkRows: func(t *testing.T, snapshot ScreenSnapshot) {
83+ require.NotNil(t, snapshot.BorrowedRow(0))
84+ require.Empty(t, snapshot.BorrowedRow(0))
85+ require.Nil(t, snapshot.Row(0))
86+ require.Empty(t, snapshot.Row(1))
87+ require.Equal(t, LineBound{}, snapshot.Bound(1))
88+ require.Nil(t, snapshot.Row(2))
89+ },
90+ },
91+ {
92+ name: "zero rows retains columns without rows",
93+ screen: func() *Screen {
94+ screen := NewScreen(2, 2)
95+ screen.Write([]byte("\x1b]2;zero-rows\x07"))
96+ screen.Resize(2, 0)
97+ return screen
98+ },
99+ wantColumns: 2,
100+ wantTitle: "zero-rows",
101+ wantCursor: CursorSnapshot{Visible: true},
102+ checkRows: func(t *testing.T, snapshot ScreenSnapshot) {
103+ require.Nil(t, snapshot.BorrowedRow(0))
104+ require.Equal(t, LineBound{}, snapshot.Bound(0))
105+ },
106+ },
107+ }
108+
109+ for _, test := range tests {
110+ t.Run(test.name, func(t *testing.T) {
111+ snapshot := test.screen().Snapshot()
112+ require.Equal(t, test.wantColumns, snapshot.Columns())
113+ require.Equal(t, test.wantRows, snapshot.Rows())
114+ require.Equal(t, test.wantTitle, snapshot.Title())
115+ require.Equal(t, test.wantCursor, snapshot.Cursor())
116+ require.Equal(t, test.wantModes, snapshot.Modes())
117+ test.checkRows(t, snapshot)
118+ })
119+ }
120+}
121+
122+func TestScreenSnapshotColorSchemeModeExcludesHostPreference(t *testing.T) {
123+ tests := []struct {
124+ name string
125+ run func(*Screen)
126+ want bool
127+ }{
128+ {
129+ name: "host preference alone is not DEC 2031 mode",
130+ run: func(screen *Screen) { screen.SetColorScheme(true) },
131+ want: false,
132+ },
133+ {
134+ name: "DEC 2031 mode is captured independently",
135+ run: func(screen *Screen) {
136+ screen.SetColorScheme(false)
137+ screen.Write([]byte("\x1b[?2031h"))
138+ },
139+ want: true,
140+ },
141+ }
142+
143+ for _, test := range tests {
144+ t.Run(test.name, func(t *testing.T) {
145+ screen := NewScreen(4, 2)
146+ test.run(screen)
147+ require.Equal(t, test.want, screen.Snapshot().Modes().ColorSchemeMode)
148+ })
149+ }
150+}
151+
152+func TestScreenSnapshotTitle(t *testing.T) {
153+ tests := []struct {
154+ name string
155+ feed string
156+ want string
157+ }{
158+ {name: "empty title", want: ""},
159+ {name: "latest title replaces the previous value", feed: "\x1b]2;first\x07\x1b]0;second\x07", want: "second"},
160+ {name: "UTF-8 title is preserved", feed: "\x1b]2;éditeur 界\x07", want: "éditeur 界"},
161+ }
162+
163+ for _, test := range tests {
164+ t.Run(test.name, func(t *testing.T) {
165+ screen := NewScreen(8, 2)
166+ screen.Write([]byte(test.feed))
167+ require.Equal(t, test.want, screen.Snapshot().Title())
168+ })
169+ }
170+}
171+
172+func TestScreenSnapshotFidelity(t *testing.T) {
173+ tests := []struct {
174+ name string
175+ make func() (*Screen, []renderer.Cell, []LineBound)
176+ wantModes ModeSnapshot
177+ }{
178+ {
179+ name: "styles wide continuation and bounds are exact",
180+ make: func() (*Screen, []renderer.Cell, []LineBound) {
181+ screen := NewScreen(4, 2)
182+ style := renderer.Style{
183+ Bold: true, Italic: true, Inverse: true,
184+ Attrs: renderer.AttrDim | renderer.AttrUnderline | renderer.AttrBlink | renderer.AttrStrikethrough,
185+ HasForegroundRGB: true, ForegroundRGB: renderer.RGB{R: 1, G: 2, B: 3},
186+ Background: 17,
187+ UnderlineStyle: renderer.UnderlineCurly,
188+ HasUnderlineColorRGB: true, UnderlineColorRGB: renderer.RGB{R: 4, G: 5, B: 6},
189+ }
190+ cells := []renderer.Cell{
191+ {Rune: '界', Style: style},
192+ {Continuation: true, Style: style},
193+ {Rune: 'x', Style: renderer.DefaultStyle()},
194+ renderer.BlankCell(),
195+ }
196+ copy(screen.Frame.Row(0), cells)
197+ screen.buffer.boundaries[0] = LineBound{End: 3, Soft: true}
198+ return screen, cells, []LineBound{{End: 3, Soft: true}, {}}
199+ },
200+ },
201+ {
202+ name: "active alternate screen is captured",
203+ make: func() (*Screen, []renderer.Cell, []LineBound) {
204+ screen := NewScreen(3, 1)
205+ screen.Write([]byte("primary\x1b[?1049halt"))
206+ cells := append([]renderer.Cell(nil), screen.Frame.Row(0)...)
207+ return screen, cells, screen.LineBounds()
208+ },
209+ wantModes: ModeSnapshot{AlternateScreen: true},
210+ },
211+ }
212+
213+ for _, test := range tests {
214+ t.Run(test.name, func(t *testing.T) {
215+ screen, wantCells, wantBounds := test.make()
216+ snapshot := screen.Snapshot()
217+ require.Equal(t, screen.Frame.Width, snapshot.Columns())
218+ require.Equal(t, screen.Frame.Height, snapshot.Rows())
219+ require.Equal(t, wantCells, snapshot.Row(0))
220+ require.Equal(t, test.wantModes, snapshot.Modes())
221+ for row, want := range wantBounds {
222+ require.Equal(t, want, snapshot.Bound(row))
223+ }
224+ })
225+ }
226+}
227+
228+func TestScreenSnapshotPreservesLogicalRowsAfterScroll(t *testing.T) {
229+ screen := NewScreen(3, 2)
230+ screen.Write([]byte("ABC"))
231+ screen.Write([]byte("DEF"))
232+ screen.Write([]byte("G"))
233+
234+ snapshot := screen.Snapshot()
235+ for y, want := range []string{"DEF", "G "} {
236+ require.Equal(t, want, rowText(snapshot.Row(y)))
237+ require.Equal(t, screen.Frame.Row(y), snapshot.Row(y))
238+ require.Equal(t, screen.LineBounds()[y], snapshot.Bound(y))
239+ }
240+}
241+
242+func TestScreenSnapshotOwnership(t *testing.T) {
243+ tests := []struct {
244+ name string
245+ mutate func(*Screen)
246+ }{
247+ {name: "later write", mutate: func(screen *Screen) { screen.Write([]byte("later")) }},
248+ {name: "later scroll", mutate: func(screen *Screen) { screen.Write([]byte("1111222233334444")) }},
249+ {name: "later resize", mutate: func(screen *Screen) { screen.Resize(2, 4) }},
250+ {name: "later alternate entry", mutate: func(screen *Screen) { screen.Write([]byte("\x1b[?1049halt")) }},
251+ {name: "later alternate entry and exit", mutate: func(screen *Screen) { screen.Write([]byte("\x1b[?1049halt\x1b[?1049l")) }},
252+ }
253+
254+ for _, test := range tests {
255+ t.Run(test.name, func(t *testing.T) {
256+ screen := NewScreen(4, 2)
257+ screen.Write([]byte("abcdEF"))
258+ screen.buffer.boundaries[0] = LineBound{End: 4, Soft: true}
259+ snapshot := screen.Snapshot()
260+ wantRows := [][]renderer.Cell{snapshot.Row(0), snapshot.Row(1)}
261+ wantBounds := []LineBound{snapshot.Bound(0), snapshot.Bound(1)}
262+
263+ test.mutate(screen)
264+
265+ require.Equal(t, wantRows[0], snapshot.Row(0))
266+ require.Equal(t, wantRows[1], snapshot.Row(1))
267+ require.Equal(t, wantBounds[0], snapshot.Bound(0))
268+ require.Equal(t, wantBounds[1], snapshot.Bound(1))
269+
270+ liveBeforeCallerMutation := append([]renderer.Cell(nil), screen.Frame.Row(0)...)
271+ secondSnapshot := screen.Snapshot()
272+ secondSnapshotBefore := secondSnapshot.Row(0)
273+
274+ callerCopy := snapshot.Row(0)
275+ callerCopy[0] = renderer.Cell{Rune: 'z'}
276+
277+ require.Equal(t, wantRows[0], snapshot.BorrowedRow(0))
278+ require.Equal(t, liveBeforeCallerMutation, screen.Frame.Row(0), "mutating Row copy must not change live Screen")
279+ require.Equal(t, secondSnapshotBefore, secondSnapshot.Row(0), "mutating Row copy must not change another snapshot")
280+ })
281+ }
282+}
283+
284+func TestScreenSnapshotBoundsAreIndependent(t *testing.T) {
285+ tests := []struct {
286+ name string
287+ mutate func(*Screen)
288+ }{
289+ {
290+ name: "mutating a single boundary entry after the snapshot",
291+ mutate: func(screen *Screen) {
292+ screen.buffer.boundaries[0] = LineBound{End: 1, Soft: false}
293+ },
294+ },
295+ {
296+ name: "replacing the whole boundaries slice after the snapshot",
297+ mutate: func(screen *Screen) {
298+ screen.buffer.boundaries = []LineBound{{End: 1, Soft: false}, {End: 1, Soft: false}}
299+ },
300+ },
301+ }
302+
303+ for _, test := range tests {
304+ t.Run(test.name, func(t *testing.T) {
305+ screen := NewScreen(4, 2)
306+ screen.Write([]byte("abcdEF"))
307+ screen.buffer.boundaries[0] = LineBound{End: 4, Soft: true}
308+ snapshot := screen.Snapshot()
309+ want := snapshot.Bound(0)
310+
311+ test.mutate(screen)
312+
313+ require.Equal(t, want, snapshot.Bound(0), "snapshot bounds must not alias Screen.buffer.boundaries")
314+ })
315+ }
316+}
317+
318+func TestScreenSnapshotDoesNotMutateHistoryOrDamage(t *testing.T) {
319+ tests := []struct {
320+ name string
321+ make func() *Screen
322+ }{
323+ {
324+ name: "screen without history",
325+ make: func() *Screen {
326+ screen := NewScreen(4, 2)
327+ screen.ClearDamage()
328+ screen.Write([]byte("abcdef"))
329+ return screen
330+ },
331+ },
332+ {
333+ name: "screen with sealed chunks and mutable tail",
334+ make: func() *Screen {
335+ screen := NewScreenWithHistory(4, 2, HistoryConfig{MaxRows: 16, MaxCells: 64, ChunkRows: 2})
336+ screen.Write([]byte("111122223333444455"))
337+ return screen
338+ },
339+ },
340+ }
341+
342+ for _, test := range tests {
343+ t.Run(test.name, func(t *testing.T) {
344+ screen := test.make()
345+ beforeDamage := screen.CaptureDamage()
346+ var beforeHistory HistorySnapshotView
347+ var beforeRows [][]renderer.Cell
348+ var beforeBounds []LineBound
349+ beforeCap, beforeCellCap := 0, 0
350+ if screen.History() != nil {
351+ beforeHistory = screen.History().SnapshotView()
352+ beforeView := screen.History().View()
353+ beforeRows = make([][]renderer.Cell, beforeView.Len())
354+ beforeBounds = make([]LineBound, beforeView.Len())
355+ for i := range beforeRows {
356+ beforeRows[i] = beforeView.Row(i)
357+ beforeBounds[i] = beforeView.Bound(i)
358+ }
359+ beforeCap = screen.History().Cap()
360+ beforeCellCap = screen.History().CellCap()
361+ }
362+
363+ _ = screen.Snapshot()
364+
365+ afterDamage := screen.CaptureDamage()
366+ require.Equal(t, beforeDamage, afterDamage)
367+ if screen.History() != nil {
368+ afterHistory := screen.History().SnapshotView()
369+ afterView := screen.History().View()
370+ afterRows := make([][]renderer.Cell, afterView.Len())
371+ afterBounds := make([]LineBound, afterView.Len())
372+ for i := range afterRows {
373+ afterRows[i] = afterView.Row(i)
374+ afterBounds[i] = afterView.Bound(i)
375+ }
376+ require.Equal(t, beforeHistory.ChunkCount(), afterHistory.ChunkCount())
377+ require.Equal(t, beforeHistory.Len(), afterHistory.Len())
378+ require.Equal(t, beforeHistory.Cells(), afterHistory.Cells())
379+ require.Equal(t, beforeHistory.Tail().Len(), afterHistory.Tail().Len())
380+ require.Equal(t, beforeRows, afterRows)
381+ require.Equal(t, beforeBounds, afterBounds)
382+ require.Equal(t, beforeCap, screen.History().Cap())
383+ require.Equal(t, beforeCellCap, screen.History().CellCap())
384+ }
385+ })
386+ }
387+}
388+
389+func TestScreenSnapshotPreservesStaleDamageAcknowledge(t *testing.T) {
390+ tests := []struct {
391+ name string
392+ mutate func(*Screen)
393+ }{
394+ {name: "write after capture", mutate: func(screen *Screen) { screen.Write([]byte("b")) }},
395+ {name: "resize after capture", mutate: func(screen *Screen) { screen.Resize(6, 2) }},
396+ }
397+
398+ for _, test := range tests {
399+ t.Run(test.name, func(t *testing.T) {
400+ screen := NewScreen(4, 2)
401+ screen.ClearDamage()
402+ screen.Write([]byte("a"))
403+ captured := screen.CaptureDamage()
404+
405+ _ = screen.Snapshot()
406+ test.mutate(screen)
407+
408+ require.False(t, screen.AcknowledgeDamage(captured.Generation))
409+ require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, screen.Damage())
410+
411+ screen.record(renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 0, Width: 1, Height: 1, Count: 1})
412+ require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, screen.Damage(), "full redraw stays sticky until exact acknowledgement")
413+ })
414+ }
415+}
416+
417+func TestScreenSnapshotPreservesExactDamageAcknowledge(t *testing.T) {
418+ tests := []struct {
419+ name string
420+ }{
421+ {name: "snapshot does not stale an exact damage capture"},
422+ }
423+
424+ for _, test := range tests {
425+ t.Run(test.name, func(t *testing.T) {
426+ screen := NewScreen(4, 2)
427+ screen.ClearDamage()
428+ screen.Write([]byte("a"))
429+ captured := screen.CaptureDamage()
430+
431+ _ = screen.Snapshot()
432+
433+ require.True(t, screen.AcknowledgeDamage(captured.Generation))
434+ require.Empty(t, screen.Damage())
435+ })
436+ }
437+}
@@ -0,0 +1,437 @@
1+package vt
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/pkg/renderer"
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func TestScreenSnapshotGeometryAndMetadata(t *testing.T) {
11+ tests := []struct {
12+ name string
13+ screen func() *Screen
14+ wantColumns int
15+ wantRows int
16+ wantTitle string
17+ wantCursor CursorSnapshot
18+ wantModes ModeSnapshot
19+ checkRows func(t *testing.T, snapshot ScreenSnapshot)
20+ }{
21+ {
22+ name: "nil receiver is the zero snapshot",
23+ screen: func() *Screen { return nil },
24+ wantCursor: CursorSnapshot{},
25+ wantModes: ModeSnapshot{},
26+ checkRows: func(t *testing.T, snapshot ScreenSnapshot) {
27+ require.Nil(t, snapshot.Row(0))
28+ require.Nil(t, snapshot.BorrowedRow(0))
29+ require.Equal(t, LineBound{}, snapshot.Bound(0))
30+ },
31+ },
32+ {
33+ name: "normal viewport captures cursor modes and title",
34+ screen: func() *Screen {
35+ screen := NewScreen(6, 3)
36+ screen.Write([]byte("\x1b]2;editor\x07\x1b[2;3H\x1b[?25l\x1b[2 q\x1b[?2004h\x1b[?2026h\x1b[?2031h\x1b[?1002h\x1b[?1006h"))
37+ return screen
38+ },
39+ wantColumns: 6,
40+ wantRows: 3,
41+ wantTitle: "editor",
42+ wantCursor: CursorSnapshot{Row: 1, Col: 2, Visible: false, Style: 2, StyleSet: true},
43+ wantModes: ModeSnapshot{
44+ BracketedPaste: true,
45+ SynchronizedUpdate: true,
46+ ColorSchemeMode: true,
47+ MouseTracking: 1002,
48+ MouseSGR: true,
49+ },
50+ checkRows: func(t *testing.T, snapshot ScreenSnapshot) {
51+ require.Len(t, snapshot.BorrowedRow(0), 6)
52+ require.Equal(t, LineBound{}, snapshot.Bound(0))
53+ },
54+ },
55+ {
56+ name: "zero by zero preserves screen metadata",
57+ screen: func() *Screen {
58+ screen := NewScreen(2, 2)
59+ screen.Write([]byte("\x1b]2;collapsed\x07\x1b[?2004h"))
60+ screen.Resize(0, 0)
61+ return screen
62+ },
63+ wantTitle: "collapsed",
64+ wantCursor: CursorSnapshot{Visible: true},
65+ wantModes: ModeSnapshot{BracketedPaste: true},
66+ checkRows: func(t *testing.T, snapshot ScreenSnapshot) {
67+ require.Nil(t, snapshot.Row(0))
68+ require.Equal(t, LineBound{}, snapshot.Bound(0))
69+ },
70+ },
71+ {
72+ name: "zero columns retains empty physical rows and bounds",
73+ screen: func() *Screen {
74+ screen := NewScreen(2, 2)
75+ screen.Write([]byte("\x1b]2;zero-columns\x07"))
76+ screen.Resize(0, 2)
77+ return screen
78+ },
79+ wantRows: 2,
80+ wantTitle: "zero-columns",
81+ wantCursor: CursorSnapshot{Visible: true},
82+ checkRows: func(t *testing.T, snapshot ScreenSnapshot) {
83+ require.NotNil(t, snapshot.BorrowedRow(0))
84+ require.Empty(t, snapshot.BorrowedRow(0))
85+ require.Nil(t, snapshot.Row(0))
86+ require.Empty(t, snapshot.Row(1))
87+ require.Equal(t, LineBound{}, snapshot.Bound(1))
88+ require.Nil(t, snapshot.Row(2))
89+ },
90+ },
91+ {
92+ name: "zero rows retains columns without rows",
93+ screen: func() *Screen {
94+ screen := NewScreen(2, 2)
95+ screen.Write([]byte("\x1b]2;zero-rows\x07"))
96+ screen.Resize(2, 0)
97+ return screen
98+ },
99+ wantColumns: 2,
100+ wantTitle: "zero-rows",
101+ wantCursor: CursorSnapshot{Visible: true},
102+ checkRows: func(t *testing.T, snapshot ScreenSnapshot) {
103+ require.Nil(t, snapshot.BorrowedRow(0))
104+ require.Equal(t, LineBound{}, snapshot.Bound(0))
105+ },
106+ },
107+ }
108+
109+ for _, test := range tests {
110+ t.Run(test.name, func(t *testing.T) {
111+ snapshot := test.screen().Snapshot()
112+ require.Equal(t, test.wantColumns, snapshot.Columns())
113+ require.Equal(t, test.wantRows, snapshot.Rows())
114+ require.Equal(t, test.wantTitle, snapshot.Title())
115+ require.Equal(t, test.wantCursor, snapshot.Cursor())
116+ require.Equal(t, test.wantModes, snapshot.Modes())
117+ test.checkRows(t, snapshot)
118+ })
119+ }
120+}
121+
122+func TestScreenSnapshotColorSchemeModeExcludesHostPreference(t *testing.T) {
123+ tests := []struct {
124+ name string
125+ run func(*Screen)
126+ want bool
127+ }{
128+ {
129+ name: "host preference alone is not DEC 2031 mode",
130+ run: func(screen *Screen) { screen.SetColorScheme(true) },
131+ want: false,
132+ },
133+ {
134+ name: "DEC 2031 mode is captured independently",
135+ run: func(screen *Screen) {
136+ screen.SetColorScheme(false)
137+ screen.Write([]byte("\x1b[?2031h"))
138+ },
139+ want: true,
140+ },
141+ }
142+
143+ for _, test := range tests {
144+ t.Run(test.name, func(t *testing.T) {
145+ screen := NewScreen(4, 2)
146+ test.run(screen)
147+ require.Equal(t, test.want, screen.Snapshot().Modes().ColorSchemeMode)
148+ })
149+ }
150+}
151+
152+func TestScreenSnapshotTitle(t *testing.T) {
153+ tests := []struct {
154+ name string
155+ feed string
156+ want string
157+ }{
158+ {name: "empty title", want: ""},
159+ {name: "latest title replaces the previous value", feed: "\x1b]2;first\x07\x1b]0;second\x07", want: "second"},
160+ {name: "UTF-8 title is preserved", feed: "\x1b]2;éditeur 界\x07", want: "éditeur 界"},
161+ }
162+
163+ for _, test := range tests {
164+ t.Run(test.name, func(t *testing.T) {
165+ screen := NewScreen(8, 2)
166+ screen.Write([]byte(test.feed))
167+ require.Equal(t, test.want, screen.Snapshot().Title())
168+ })
169+ }
170+}
171+
172+func TestScreenSnapshotFidelity(t *testing.T) {
173+ tests := []struct {
174+ name string
175+ make func() (*Screen, []renderer.Cell, []LineBound)
176+ wantModes ModeSnapshot
177+ }{
178+ {
179+ name: "styles wide continuation and bounds are exact",
180+ make: func() (*Screen, []renderer.Cell, []LineBound) {
181+ screen := NewScreen(4, 2)
182+ style := renderer.Style{
183+ Bold: true, Italic: true, Inverse: true,
184+ Attrs: renderer.AttrDim | renderer.AttrUnderline | renderer.AttrBlink | renderer.AttrStrikethrough,
185+ HasForegroundRGB: true, ForegroundRGB: renderer.RGB{R: 1, G: 2, B: 3},
186+ Background: 17,
187+ UnderlineStyle: renderer.UnderlineCurly,
188+ HasUnderlineColorRGB: true, UnderlineColorRGB: renderer.RGB{R: 4, G: 5, B: 6},
189+ }
190+ cells := []renderer.Cell{
191+ {Rune: '界', Style: style},
192+ {Continuation: true, Style: style},
193+ {Rune: 'x', Style: renderer.DefaultStyle()},
194+ renderer.BlankCell(),
195+ }
196+ copy(screen.Frame.Row(0), cells)
197+ screen.buffer.boundaries[0] = LineBound{End: 3, Soft: true}
198+ return screen, cells, []LineBound{{End: 3, Soft: true}, {}}
199+ },
200+ },
201+ {
202+ name: "active alternate screen is captured",
203+ make: func() (*Screen, []renderer.Cell, []LineBound) {
204+ screen := NewScreen(3, 1)
205+ screen.Write([]byte("primary\x1b[?1049halt"))
206+ cells := append([]renderer.Cell(nil), screen.Frame.Row(0)...)
207+ return screen, cells, screen.LineBounds()
208+ },
209+ wantModes: ModeSnapshot{AlternateScreen: true},
210+ },
211+ }
212+
213+ for _, test := range tests {
214+ t.Run(test.name, func(t *testing.T) {
215+ screen, wantCells, wantBounds := test.make()
216+ snapshot := screen.Snapshot()
217+ require.Equal(t, screen.Frame.Width, snapshot.Columns())
218+ require.Equal(t, screen.Frame.Height, snapshot.Rows())
219+ require.Equal(t, wantCells, snapshot.Row(0))
220+ require.Equal(t, test.wantModes, snapshot.Modes())
221+ for row, want := range wantBounds {
222+ require.Equal(t, want, snapshot.Bound(row))
223+ }
224+ })
225+ }
226+}
227+
228+func TestScreenSnapshotPreservesLogicalRowsAfterScroll(t *testing.T) {
229+ screen := NewScreen(3, 2)
230+ screen.Write([]byte("ABC"))
231+ screen.Write([]byte("DEF"))
232+ screen.Write([]byte("G"))
233+
234+ snapshot := screen.Snapshot()
235+ for y, want := range []string{"DEF", "G "} {
236+ require.Equal(t, want, rowText(snapshot.Row(y)))
237+ require.Equal(t, screen.Frame.Row(y), snapshot.Row(y))
238+ require.Equal(t, screen.LineBounds()[y], snapshot.Bound(y))
239+ }
240+}
241+
242+func TestScreenSnapshotOwnership(t *testing.T) {
243+ tests := []struct {
244+ name string
245+ mutate func(*Screen)
246+ }{
247+ {name: "later write", mutate: func(screen *Screen) { screen.Write([]byte("later")) }},
248+ {name: "later scroll", mutate: func(screen *Screen) { screen.Write([]byte("1111222233334444")) }},
249+ {name: "later resize", mutate: func(screen *Screen) { screen.Resize(2, 4) }},
250+ {name: "later alternate entry", mutate: func(screen *Screen) { screen.Write([]byte("\x1b[?1049halt")) }},
251+ {name: "later alternate entry and exit", mutate: func(screen *Screen) { screen.Write([]byte("\x1b[?1049halt\x1b[?1049l")) }},
252+ }
253+
254+ for _, test := range tests {
255+ t.Run(test.name, func(t *testing.T) {
256+ screen := NewScreen(4, 2)
257+ screen.Write([]byte("abcdEF"))
258+ screen.buffer.boundaries[0] = LineBound{End: 4, Soft: true}
259+ snapshot := screen.Snapshot()
260+ wantRows := [][]renderer.Cell{snapshot.Row(0), snapshot.Row(1)}
261+ wantBounds := []LineBound{snapshot.Bound(0), snapshot.Bound(1)}
262+
263+ test.mutate(screen)
264+
265+ require.Equal(t, wantRows[0], snapshot.Row(0))
266+ require.Equal(t, wantRows[1], snapshot.Row(1))
267+ require.Equal(t, wantBounds[0], snapshot.Bound(0))
268+ require.Equal(t, wantBounds[1], snapshot.Bound(1))
269+
270+ liveBeforeCallerMutation := append([]renderer.Cell(nil), screen.Frame.Row(0)...)
271+ secondSnapshot := screen.Snapshot()
272+ secondSnapshotBefore := secondSnapshot.Row(0)
273+
274+ callerCopy := snapshot.Row(0)
275+ callerCopy[0] = renderer.Cell{Rune: 'z'}
276+
277+ require.Equal(t, wantRows[0], snapshot.BorrowedRow(0))
278+ require.Equal(t, liveBeforeCallerMutation, screen.Frame.Row(0), "mutating Row copy must not change live Screen")
279+ require.Equal(t, secondSnapshotBefore, secondSnapshot.Row(0), "mutating Row copy must not change another snapshot")
280+ })
281+ }
282+}
283+
284+func TestScreenSnapshotBoundsAreIndependent(t *testing.T) {
285+ tests := []struct {
286+ name string
287+ mutate func(*Screen)
288+ }{
289+ {
290+ name: "mutating a single boundary entry after the snapshot",
291+ mutate: func(screen *Screen) {
292+ screen.buffer.boundaries[0] = LineBound{End: 1, Soft: false}
293+ },
294+ },
295+ {
296+ name: "replacing the whole boundaries slice after the snapshot",
297+ mutate: func(screen *Screen) {
298+ screen.buffer.boundaries = []LineBound{{End: 1, Soft: false}, {End: 1, Soft: false}}
299+ },
300+ },
301+ }
302+
303+ for _, test := range tests {
304+ t.Run(test.name, func(t *testing.T) {
305+ screen := NewScreen(4, 2)
306+ screen.Write([]byte("abcdEF"))
307+ screen.buffer.boundaries[0] = LineBound{End: 4, Soft: true}
308+ snapshot := screen.Snapshot()
309+ want := snapshot.Bound(0)
310+
311+ test.mutate(screen)
312+
313+ require.Equal(t, want, snapshot.Bound(0), "snapshot bounds must not alias Screen.buffer.boundaries")
314+ })
315+ }
316+}
317+
318+func TestScreenSnapshotDoesNotMutateHistoryOrDamage(t *testing.T) {
319+ tests := []struct {
320+ name string
321+ make func() *Screen
322+ }{
323+ {
324+ name: "screen without history",
325+ make: func() *Screen {
326+ screen := NewScreen(4, 2)
327+ screen.ClearDamage()
328+ screen.Write([]byte("abcdef"))
329+ return screen
330+ },
331+ },
332+ {
333+ name: "screen with sealed chunks and mutable tail",
334+ make: func() *Screen {
335+ screen := NewScreenWithHistory(4, 2, HistoryConfig{MaxRows: 16, MaxCells: 64, ChunkRows: 2})
336+ screen.Write([]byte("111122223333444455"))
337+ return screen
338+ },
339+ },
340+ }
341+
342+ for _, test := range tests {
343+ t.Run(test.name, func(t *testing.T) {
344+ screen := test.make()
345+ beforeDamage := screen.CaptureDamage()
346+ var beforeHistory HistorySnapshotView
347+ var beforeRows [][]renderer.Cell
348+ var beforeBounds []LineBound
349+ beforeCap, beforeCellCap := 0, 0
350+ if screen.History() != nil {
351+ beforeHistory = screen.History().SnapshotView()
352+ beforeView := screen.History().View()
353+ beforeRows = make([][]renderer.Cell, beforeView.Len())
354+ beforeBounds = make([]LineBound, beforeView.Len())
355+ for i := range beforeRows {
356+ beforeRows[i] = beforeView.Row(i)
357+ beforeBounds[i] = beforeView.Bound(i)
358+ }
359+ beforeCap = screen.History().Cap()
360+ beforeCellCap = screen.History().CellCap()
361+ }
362+
363+ _ = screen.Snapshot()
364+
365+ afterDamage := screen.CaptureDamage()
366+ require.Equal(t, beforeDamage, afterDamage)
367+ if screen.History() != nil {
368+ afterHistory := screen.History().SnapshotView()
369+ afterView := screen.History().View()
370+ afterRows := make([][]renderer.Cell, afterView.Len())
371+ afterBounds := make([]LineBound, afterView.Len())
372+ for i := range afterRows {
373+ afterRows[i] = afterView.Row(i)
374+ afterBounds[i] = afterView.Bound(i)
375+ }
376+ require.Equal(t, beforeHistory.ChunkCount(), afterHistory.ChunkCount())
377+ require.Equal(t, beforeHistory.Len(), afterHistory.Len())
378+ require.Equal(t, beforeHistory.Cells(), afterHistory.Cells())
379+ require.Equal(t, beforeHistory.Tail().Len(), afterHistory.Tail().Len())
380+ require.Equal(t, beforeRows, afterRows)
381+ require.Equal(t, beforeBounds, afterBounds)
382+ require.Equal(t, beforeCap, screen.History().Cap())
383+ require.Equal(t, beforeCellCap, screen.History().CellCap())
384+ }
385+ })
386+ }
387+}
388+
389+func TestScreenSnapshotPreservesStaleDamageAcknowledge(t *testing.T) {
390+ tests := []struct {
391+ name string
392+ mutate func(*Screen)
393+ }{
394+ {name: "write after capture", mutate: func(screen *Screen) { screen.Write([]byte("b")) }},
395+ {name: "resize after capture", mutate: func(screen *Screen) { screen.Resize(6, 2) }},
396+ }
397+
398+ for _, test := range tests {
399+ t.Run(test.name, func(t *testing.T) {
400+ screen := NewScreen(4, 2)
401+ screen.ClearDamage()
402+ screen.Write([]byte("a"))
403+ captured := screen.CaptureDamage()
404+
405+ _ = screen.Snapshot()
406+ test.mutate(screen)
407+
408+ require.False(t, screen.AcknowledgeDamage(captured.Generation))
409+ require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, screen.Damage())
410+
411+ screen.record(renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 0, Width: 1, Height: 1, Count: 1})
412+ require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, screen.Damage(), "full redraw stays sticky until exact acknowledgement")
413+ })
414+ }
415+}
416+
417+func TestScreenSnapshotPreservesExactDamageAcknowledge(t *testing.T) {
418+ tests := []struct {
419+ name string
420+ }{
421+ {name: "snapshot does not stale an exact damage capture"},
422+ }
423+
424+ for _, test := range tests {
425+ t.Run(test.name, func(t *testing.T) {
426+ screen := NewScreen(4, 2)
427+ screen.ClearDamage()
428+ screen.Write([]byte("a"))
429+ captured := screen.CaptureDamage()
430+
431+ _ = screen.Snapshot()
432+
433+ require.True(t, screen.AcknowledgeDamage(captured.Generation))
434+ require.Empty(t, screen.Damage())
435+ })
436+ }
437+}

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