detached 0461ab8

feat(vt): add owned screen snapshots

commit
0461ab84977fc8ed286bbd4707326d21e63803f4
author
brice · · 2026-07-28 14:06 UTC
committer
brice · · 2026-07-29 05:00 UTC
parent
43eb2f4
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +258 -0
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 +169 -0
@@ -0,0 +1,169 @@
1+package vt
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/require"
7+)
8+
9+func TestScreenSnapshotGeometryAndMetadata(t *testing.T) {
10+ tests := []struct {
11+ name string
12+ screen func() *Screen
13+ wantColumns int
14+ wantRows int
15+ wantTitle string
16+ wantCursor CursorSnapshot
17+ wantModes ModeSnapshot
18+ checkRows func(t *testing.T, snapshot ScreenSnapshot)
19+ }{
20+ {
21+ name: "nil receiver is the zero snapshot",
22+ screen: func() *Screen { return nil },
23+ wantCursor: CursorSnapshot{},
24+ wantModes: ModeSnapshot{},
25+ checkRows: func(t *testing.T, snapshot ScreenSnapshot) {
26+ require.Nil(t, snapshot.Row(0))
27+ require.Nil(t, snapshot.BorrowedRow(0))
28+ require.Equal(t, LineBound{}, snapshot.Bound(0))
29+ },
30+ },
31+ {
32+ name: "normal viewport captures cursor modes and title",
33+ screen: func() *Screen {
34+ screen := NewScreen(6, 3)
35+ 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"))
36+ return screen
37+ },
38+ wantColumns: 6,
39+ wantRows: 3,
40+ wantTitle: "editor",
41+ wantCursor: CursorSnapshot{Row: 1, Col: 2, Visible: false, Style: 2, StyleSet: true},
42+ wantModes: ModeSnapshot{
43+ BracketedPaste: true,
44+ SynchronizedUpdate: true,
45+ ColorSchemeMode: true,
46+ MouseTracking: 1002,
47+ MouseSGR: true,
48+ },
49+ checkRows: func(t *testing.T, snapshot ScreenSnapshot) {
50+ require.Len(t, snapshot.BorrowedRow(0), 6)
51+ require.Equal(t, LineBound{}, snapshot.Bound(0))
52+ },
53+ },
54+ {
55+ name: "zero by zero preserves screen metadata",
56+ screen: func() *Screen {
57+ screen := NewScreen(2, 2)
58+ screen.Write([]byte("\x1b]2;collapsed\x07\x1b[?2004h"))
59+ screen.Resize(0, 0)
60+ return screen
61+ },
62+ wantTitle: "collapsed",
63+ wantCursor: CursorSnapshot{Visible: true},
64+ wantModes: ModeSnapshot{BracketedPaste: true},
65+ checkRows: func(t *testing.T, snapshot ScreenSnapshot) {
66+ require.Nil(t, snapshot.Row(0))
67+ require.Equal(t, LineBound{}, snapshot.Bound(0))
68+ },
69+ },
70+ {
71+ name: "zero columns retains empty physical rows and bounds",
72+ screen: func() *Screen {
73+ screen := NewScreen(2, 2)
74+ screen.Write([]byte("\x1b]2;zero-columns\x07"))
75+ screen.Resize(0, 2)
76+ return screen
77+ },
78+ wantRows: 2,
79+ wantTitle: "zero-columns",
80+ wantCursor: CursorSnapshot{Visible: true},
81+ checkRows: func(t *testing.T, snapshot ScreenSnapshot) {
82+ require.NotNil(t, snapshot.BorrowedRow(0))
83+ require.Empty(t, snapshot.BorrowedRow(0))
84+ require.Nil(t, snapshot.Row(0))
85+ require.Empty(t, snapshot.Row(1))
86+ require.Equal(t, LineBound{}, snapshot.Bound(1))
87+ require.Nil(t, snapshot.Row(2))
88+ },
89+ },
90+ {
91+ name: "zero rows retains columns without rows",
92+ screen: func() *Screen {
93+ screen := NewScreen(2, 2)
94+ screen.Write([]byte("\x1b]2;zero-rows\x07"))
95+ screen.Resize(2, 0)
96+ return screen
97+ },
98+ wantColumns: 2,
99+ wantTitle: "zero-rows",
100+ wantCursor: CursorSnapshot{Visible: true},
101+ checkRows: func(t *testing.T, snapshot ScreenSnapshot) {
102+ require.Nil(t, snapshot.BorrowedRow(0))
103+ require.Equal(t, LineBound{}, snapshot.Bound(0))
104+ },
105+ },
106+ }
107+
108+ for _, test := range tests {
109+ t.Run(test.name, func(t *testing.T) {
110+ snapshot := test.screen().Snapshot()
111+ require.Equal(t, test.wantColumns, snapshot.Columns())
112+ require.Equal(t, test.wantRows, snapshot.Rows())
113+ require.Equal(t, test.wantTitle, snapshot.Title())
114+ require.Equal(t, test.wantCursor, snapshot.Cursor())
115+ require.Equal(t, test.wantModes, snapshot.Modes())
116+ test.checkRows(t, snapshot)
117+ })
118+ }
119+}
120+
121+func TestScreenSnapshotColorSchemeModeExcludesHostPreference(t *testing.T) {
122+ tests := []struct {
123+ name string
124+ run func(*Screen)
125+ want bool
126+ }{
127+ {
128+ name: "host preference alone is not DEC 2031 mode",
129+ run: func(screen *Screen) { screen.SetColorScheme(true) },
130+ want: false,
131+ },
132+ {
133+ name: "DEC 2031 mode is captured independently",
134+ run: func(screen *Screen) {
135+ screen.SetColorScheme(false)
136+ screen.Write([]byte("\x1b[?2031h"))
137+ },
138+ want: true,
139+ },
140+ }
141+
142+ for _, test := range tests {
143+ t.Run(test.name, func(t *testing.T) {
144+ screen := NewScreen(4, 2)
145+ test.run(screen)
146+ require.Equal(t, test.want, screen.Snapshot().Modes().ColorSchemeMode)
147+ })
148+ }
149+}
150+
151+func TestScreenSnapshotTitle(t *testing.T) {
152+ tests := []struct {
153+ name string
154+ feed string
155+ want string
156+ }{
157+ {name: "empty title", want: ""},
158+ {name: "latest title replaces the previous value", feed: "\x1b]2;first\x07\x1b]0;second\x07", want: "second"},
159+ {name: "UTF-8 title is preserved", feed: "\x1b]2;éditeur 界\x07", want: "éditeur 界"},
160+ }
161+
162+ for _, test := range tests {
163+ t.Run(test.name, func(t *testing.T) {
164+ screen := NewScreen(8, 2)
165+ screen.Write([]byte(test.feed))
166+ require.Equal(t, test.want, screen.Snapshot().Title())
167+ })
168+ }
169+}
@@ -0,0 +1,169 @@
1+package vt
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/require"
7+)
8+
9+func TestScreenSnapshotGeometryAndMetadata(t *testing.T) {
10+ tests := []struct {
11+ name string
12+ screen func() *Screen
13+ wantColumns int
14+ wantRows int
15+ wantTitle string
16+ wantCursor CursorSnapshot
17+ wantModes ModeSnapshot
18+ checkRows func(t *testing.T, snapshot ScreenSnapshot)
19+ }{
20+ {
21+ name: "nil receiver is the zero snapshot",
22+ screen: func() *Screen { return nil },
23+ wantCursor: CursorSnapshot{},
24+ wantModes: ModeSnapshot{},
25+ checkRows: func(t *testing.T, snapshot ScreenSnapshot) {
26+ require.Nil(t, snapshot.Row(0))
27+ require.Nil(t, snapshot.BorrowedRow(0))
28+ require.Equal(t, LineBound{}, snapshot.Bound(0))
29+ },
30+ },
31+ {
32+ name: "normal viewport captures cursor modes and title",
33+ screen: func() *Screen {
34+ screen := NewScreen(6, 3)
35+ 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"))
36+ return screen
37+ },
38+ wantColumns: 6,
39+ wantRows: 3,
40+ wantTitle: "editor",
41+ wantCursor: CursorSnapshot{Row: 1, Col: 2, Visible: false, Style: 2, StyleSet: true},
42+ wantModes: ModeSnapshot{
43+ BracketedPaste: true,
44+ SynchronizedUpdate: true,
45+ ColorSchemeMode: true,
46+ MouseTracking: 1002,
47+ MouseSGR: true,
48+ },
49+ checkRows: func(t *testing.T, snapshot ScreenSnapshot) {
50+ require.Len(t, snapshot.BorrowedRow(0), 6)
51+ require.Equal(t, LineBound{}, snapshot.Bound(0))
52+ },
53+ },
54+ {
55+ name: "zero by zero preserves screen metadata",
56+ screen: func() *Screen {
57+ screen := NewScreen(2, 2)
58+ screen.Write([]byte("\x1b]2;collapsed\x07\x1b[?2004h"))
59+ screen.Resize(0, 0)
60+ return screen
61+ },
62+ wantTitle: "collapsed",
63+ wantCursor: CursorSnapshot{Visible: true},
64+ wantModes: ModeSnapshot{BracketedPaste: true},
65+ checkRows: func(t *testing.T, snapshot ScreenSnapshot) {
66+ require.Nil(t, snapshot.Row(0))
67+ require.Equal(t, LineBound{}, snapshot.Bound(0))
68+ },
69+ },
70+ {
71+ name: "zero columns retains empty physical rows and bounds",
72+ screen: func() *Screen {
73+ screen := NewScreen(2, 2)
74+ screen.Write([]byte("\x1b]2;zero-columns\x07"))
75+ screen.Resize(0, 2)
76+ return screen
77+ },
78+ wantRows: 2,
79+ wantTitle: "zero-columns",
80+ wantCursor: CursorSnapshot{Visible: true},
81+ checkRows: func(t *testing.T, snapshot ScreenSnapshot) {
82+ require.NotNil(t, snapshot.BorrowedRow(0))
83+ require.Empty(t, snapshot.BorrowedRow(0))
84+ require.Nil(t, snapshot.Row(0))
85+ require.Empty(t, snapshot.Row(1))
86+ require.Equal(t, LineBound{}, snapshot.Bound(1))
87+ require.Nil(t, snapshot.Row(2))
88+ },
89+ },
90+ {
91+ name: "zero rows retains columns without rows",
92+ screen: func() *Screen {
93+ screen := NewScreen(2, 2)
94+ screen.Write([]byte("\x1b]2;zero-rows\x07"))
95+ screen.Resize(2, 0)
96+ return screen
97+ },
98+ wantColumns: 2,
99+ wantTitle: "zero-rows",
100+ wantCursor: CursorSnapshot{Visible: true},
101+ checkRows: func(t *testing.T, snapshot ScreenSnapshot) {
102+ require.Nil(t, snapshot.BorrowedRow(0))
103+ require.Equal(t, LineBound{}, snapshot.Bound(0))
104+ },
105+ },
106+ }
107+
108+ for _, test := range tests {
109+ t.Run(test.name, func(t *testing.T) {
110+ snapshot := test.screen().Snapshot()
111+ require.Equal(t, test.wantColumns, snapshot.Columns())
112+ require.Equal(t, test.wantRows, snapshot.Rows())
113+ require.Equal(t, test.wantTitle, snapshot.Title())
114+ require.Equal(t, test.wantCursor, snapshot.Cursor())
115+ require.Equal(t, test.wantModes, snapshot.Modes())
116+ test.checkRows(t, snapshot)
117+ })
118+ }
119+}
120+
121+func TestScreenSnapshotColorSchemeModeExcludesHostPreference(t *testing.T) {
122+ tests := []struct {
123+ name string
124+ run func(*Screen)
125+ want bool
126+ }{
127+ {
128+ name: "host preference alone is not DEC 2031 mode",
129+ run: func(screen *Screen) { screen.SetColorScheme(true) },
130+ want: false,
131+ },
132+ {
133+ name: "DEC 2031 mode is captured independently",
134+ run: func(screen *Screen) {
135+ screen.SetColorScheme(false)
136+ screen.Write([]byte("\x1b[?2031h"))
137+ },
138+ want: true,
139+ },
140+ }
141+
142+ for _, test := range tests {
143+ t.Run(test.name, func(t *testing.T) {
144+ screen := NewScreen(4, 2)
145+ test.run(screen)
146+ require.Equal(t, test.want, screen.Snapshot().Modes().ColorSchemeMode)
147+ })
148+ }
149+}
150+
151+func TestScreenSnapshotTitle(t *testing.T) {
152+ tests := []struct {
153+ name string
154+ feed string
155+ want string
156+ }{
157+ {name: "empty title", want: ""},
158+ {name: "latest title replaces the previous value", feed: "\x1b]2;first\x07\x1b]0;second\x07", want: "second"},
159+ {name: "UTF-8 title is preserved", feed: "\x1b]2;éditeur 界\x07", want: "éditeur 界"},
160+ }
161+
162+ for _, test := range tests {
163+ t.Run(test.name, func(t *testing.T) {
164+ screen := NewScreen(8, 2)
165+ screen.Write([]byte(test.feed))
166+ require.Equal(t, test.want, screen.Snapshot().Title())
167+ })
168+ }
169+}

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