detached 35da577

feat(vt): integrate kitty graphics state

commit
35da57720019a7dfde10f73325e5547f03fb1b46
author
brice · · 2026-08-20 14:55 UTC
parent
383f6ac
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +272 -4
A graphics_state.go +86 -0
@@ -0,0 +1,86 @@
1+package vt
2+
3+import (
4+ "github.com/bnema/vev-vt/graphics"
5+ "github.com/bnema/vev-vt/protocol/kittygraphics"
6+)
7+
8+// screenGraphicsState owns the protocol adapter and scene for one VT screen
9+// buffer. It is allocated only after the first Kitty graphics APC reaches the
10+// screen, so ordinary text writes do not pay for graphics state.
11+type screenGraphicsState struct {
12+ scene *graphics.Scene
13+ kitty *kittygraphics.Session
14+}
15+
16+func newScreenGraphicsState() *screenGraphicsState {
17+ scene := graphics.NewScene(graphics.Limits{})
18+ return &screenGraphicsState{
19+ scene: scene,
20+ kitty: kittygraphics.NewSession(scene),
21+ }
22+}
23+
24+func (g *screenGraphicsState) snapshot() *graphics.Snapshot {
25+ if g == nil || g.scene == nil {
26+ return nil
27+ }
28+ return g.scene.Snapshot()
29+}
30+
31+// clearPlacements applies the ordinary full-screen erase policy without
32+// discarding uploaded image mappings. The protocol session owns those
33+// mappings, so the operation goes through its delete-all-placements command
34+// rather than mutating the scene directly.
35+func (g *screenGraphicsState) clearPlacements() {
36+ if g == nil || g.kitty == nil {
37+ return
38+ }
39+ _, _ = g.kitty.Process(kittygraphics.Command{Controls: kittygraphics.Controls{
40+ Action: kittygraphics.ActionDelete,
41+ HasAction: true,
42+ Delete: kittygraphics.DeleteAllPlacements,
43+ HasDelete: true,
44+ Quiet: kittygraphics.QuietAll,
45+ HasQuiet: true,
46+ }})
47+}
48+
49+func (s *Screen) graphicsClearPlacements() {
50+ if s != nil && s.graphics != nil {
51+ s.graphics.clearPlacements()
52+ }
53+}
54+
55+// GraphicsScene returns the active screen buffer's graphics scene, or nil
56+// before graphics has been used. Calling it never allocates graphics state.
57+func (s *Screen) GraphicsScene() *graphics.Scene {
58+ if s == nil || s.graphics == nil {
59+ return nil
60+ }
61+ return s.graphics.scene
62+}
63+
64+// Graphics is a concise alias for GraphicsScene.
65+func (s *Screen) Graphics() *graphics.Scene { return s.GraphicsScene() }
66+
67+// GraphicsSession returns the active screen buffer's Kitty graphics adapter,
68+// or nil before graphics has been used.
69+func (s *Screen) GraphicsSession() *kittygraphics.Session {
70+ if s == nil || s.graphics == nil {
71+ return nil
72+ }
73+ return s.graphics.kitty
74+}
75+
76+// KittyGraphics is an adapter-named alias for GraphicsSession.
77+func (s *Screen) KittyGraphics() *kittygraphics.Session { return s.GraphicsSession() }
78+
79+// GraphicsSnapshot returns an immutable snapshot of the active screen
80+// buffer's graphics scene, or nil before graphics has been used.
81+func (s *Screen) GraphicsSnapshot() *graphics.Snapshot {
82+ if s == nil || s.graphics == nil {
83+ return nil
84+ }
85+ return s.graphics.snapshot()
86+}
@@ -0,0 +1,86 @@
1+package vt
2+
3+import (
4+ "github.com/bnema/vev-vt/graphics"
5+ "github.com/bnema/vev-vt/protocol/kittygraphics"
6+)
7+
8+// screenGraphicsState owns the protocol adapter and scene for one VT screen
9+// buffer. It is allocated only after the first Kitty graphics APC reaches the
10+// screen, so ordinary text writes do not pay for graphics state.
11+type screenGraphicsState struct {
12+ scene *graphics.Scene
13+ kitty *kittygraphics.Session
14+}
15+
16+func newScreenGraphicsState() *screenGraphicsState {
17+ scene := graphics.NewScene(graphics.Limits{})
18+ return &screenGraphicsState{
19+ scene: scene,
20+ kitty: kittygraphics.NewSession(scene),
21+ }
22+}
23+
24+func (g *screenGraphicsState) snapshot() *graphics.Snapshot {
25+ if g == nil || g.scene == nil {
26+ return nil
27+ }
28+ return g.scene.Snapshot()
29+}
30+
31+// clearPlacements applies the ordinary full-screen erase policy without
32+// discarding uploaded image mappings. The protocol session owns those
33+// mappings, so the operation goes through its delete-all-placements command
34+// rather than mutating the scene directly.
35+func (g *screenGraphicsState) clearPlacements() {
36+ if g == nil || g.kitty == nil {
37+ return
38+ }
39+ _, _ = g.kitty.Process(kittygraphics.Command{Controls: kittygraphics.Controls{
40+ Action: kittygraphics.ActionDelete,
41+ HasAction: true,
42+ Delete: kittygraphics.DeleteAllPlacements,
43+ HasDelete: true,
44+ Quiet: kittygraphics.QuietAll,
45+ HasQuiet: true,
46+ }})
47+}
48+
49+func (s *Screen) graphicsClearPlacements() {
50+ if s != nil && s.graphics != nil {
51+ s.graphics.clearPlacements()
52+ }
53+}
54+
55+// GraphicsScene returns the active screen buffer's graphics scene, or nil
56+// before graphics has been used. Calling it never allocates graphics state.
57+func (s *Screen) GraphicsScene() *graphics.Scene {
58+ if s == nil || s.graphics == nil {
59+ return nil
60+ }
61+ return s.graphics.scene
62+}
63+
64+// Graphics is a concise alias for GraphicsScene.
65+func (s *Screen) Graphics() *graphics.Scene { return s.GraphicsScene() }
66+
67+// GraphicsSession returns the active screen buffer's Kitty graphics adapter,
68+// or nil before graphics has been used.
69+func (s *Screen) GraphicsSession() *kittygraphics.Session {
70+ if s == nil || s.graphics == nil {
71+ return nil
72+ }
73+ return s.graphics.kitty
74+}
75+
76+// KittyGraphics is an adapter-named alias for GraphicsSession.
77+func (s *Screen) KittyGraphics() *kittygraphics.Session { return s.GraphicsSession() }
78+
79+// GraphicsSnapshot returns an immutable snapshot of the active screen
80+// buffer's graphics scene, or nil before graphics has been used.
81+func (s *Screen) GraphicsSnapshot() *graphics.Snapshot {
82+ if s == nil || s.graphics == nil {
83+ return nil
84+ }
85+ return s.graphics.snapshot()
86+}
M screen.go +57 -2
@@ -4,6 +4,7 @@ import (
44 "unicode/utf8"
55
66 renderer "github.com/bnema/vev-vt/core"
7+ "github.com/bnema/vev-vt/protocol/kittygraphics"
78 )
89
910 // maxEscapeBufferLen must stay large enough for OSC 52 clipboard payloads
@@ -12,6 +13,11 @@ import (
1213 // so keep this value in sync if that payload cap changes.
1314 const maxEscapeBufferLen = 128 * 1024
1415
16+// Kitty graphics APCs have their own protocol limit. Keep this separate from
17+// the ordinary escape-string limit so a fragmented image does not get fed
18+// through the text path or truncated at the OSC clipboard bound.
19+const maxKittyEscapeBufferLen = int(kittygraphics.DefaultMaxAPCBytes) + 5
20+
1521 const (
1622 // ColorSchemeReportDark is the DEC 2031 dark-scheme report.
1723 ColorSchemeReportDark = "\x1b[?997;1n"
@@ -73,6 +79,7 @@ type Screen struct {
7379 scrollBottom int
7480 savedCursor cursorState
7581 alternate *screenState
82+ graphics *screenGraphicsState
7683 syncUpdateActive bool
7784 progressState int
7885 cursorVisible bool
@@ -140,7 +147,11 @@ func (s *Screen) Write(data []byte) {
140147 continue
141148 }
142149 if partial {
143- if len(data) <= maxEscapeBufferLen {
150+ limit := maxEscapeBufferLen
151+ if isKittyGraphicsAPC(data) {
152+ limit = maxKittyEscapeBufferLen
153+ }
154+ if len(data) <= limit {
144155 s.escapeBuf = append(s.escapeBuf[:0], data...)
145156 }
146157 return
@@ -165,7 +176,12 @@ func (s *Screen) consumeEscape(data []byte) (consumed int, partial bool) {
165176 return s.consumeOSC(data)
166177 case 'P':
167178 return consumeSTString(data)
168- case '_', '^', 'X':
179+ case '_':
180+ if len(data) >= 3 && data[2] == 'G' {
181+ return s.consumeKittyGraphics(data)
182+ }
183+ return consumeSTString(data)
184+ case '^', 'X':
169185 return consumeSTString(data)
170186 case '[':
171187 return s.consumeCSI(data)
@@ -215,3 +231,42 @@ func consumeSTString(data []byte) (consumed int, partial bool) {
215231 }
216232 return 0, true
217233 }
234+
235+func isKittyGraphicsAPC(data []byte) bool {
236+ return len(data) >= 3 && data[0] == 0x1b && data[1] == '_' && data[2] == 'G'
237+}
238+
239+// consumeKittyGraphics frames one exact ESC _ G APC and dispatches only its
240+// complete bytes to the protocol adapter. The ordinary parser never sees the
241+// APC body as text, and the adapter is allocated only on the first complete
242+// graphics command.
243+func (s *Screen) consumeKittyGraphics(data []byte) (consumed int, partial bool) {
244+ for i := 3; i < len(data); i++ {
245+ switch data[i] {
246+ case 0x9c: // C1 string terminator.
247+ s.dispatchKittyGraphics(data[:i+1])
248+ return i + 1, false
249+ case 0x1b:
250+ if i+1 < len(data) && data[i+1] == '\\' {
251+ s.dispatchKittyGraphics(data[:i+2])
252+ return i + 2, false
253+ }
254+ }
255+ }
256+ return 0, true
257+}
258+
259+func (s *Screen) dispatchKittyGraphics(apc []byte) {
260+ if s.graphics == nil {
261+ s.graphics = newScreenGraphicsState()
262+ }
263+ result, _ := s.graphics.kitty.Feed(apc)
264+ for _, response := range result.Responses {
265+ s.respond(response)
266+ }
267+ if len(result.Mutations) != 0 {
268+ // Graphics are rendered independently from Frame. A graphics mutation
269+ // still needs to wake consumers which only redraw on screen damage.
270+ s.fullRedraw()
271+ }
272+}
@@ -4,6 +4,7 @@ import (
4 "unicode/utf8"4 "unicode/utf8"
5 5
6 renderer "github.com/bnema/vev-vt/core"6 renderer "github.com/bnema/vev-vt/core"
7+ "github.com/bnema/vev-vt/protocol/kittygraphics"
7 )8 )
8 9
9 // maxEscapeBufferLen must stay large enough for OSC 52 clipboard payloads10 // maxEscapeBufferLen must stay large enough for OSC 52 clipboard payloads
@@ -12,6 +13,11 @@ import (
12 // so keep this value in sync if that payload cap changes.13 // so keep this value in sync if that payload cap changes.
13 const maxEscapeBufferLen = 128 * 102414 const maxEscapeBufferLen = 128 * 1024
14 15
16+// Kitty graphics APCs have their own protocol limit. Keep this separate from
17+// the ordinary escape-string limit so a fragmented image does not get fed
18+// through the text path or truncated at the OSC clipboard bound.
19+const maxKittyEscapeBufferLen = int(kittygraphics.DefaultMaxAPCBytes) + 5
20+
15 const (21 const (
16 // ColorSchemeReportDark is the DEC 2031 dark-scheme report.22 // ColorSchemeReportDark is the DEC 2031 dark-scheme report.
17 ColorSchemeReportDark = "\x1b[?997;1n"23 ColorSchemeReportDark = "\x1b[?997;1n"
@@ -73,6 +79,7 @@ type Screen struct {
73 scrollBottom int79 scrollBottom int
74 savedCursor cursorState80 savedCursor cursorState
75 alternate *screenState81 alternate *screenState
82+ graphics *screenGraphicsState
76 syncUpdateActive bool83 syncUpdateActive bool
77 progressState int84 progressState int
78 cursorVisible bool85 cursorVisible bool
@@ -140,7 +147,11 @@ func (s *Screen) Write(data []byte) {
140 continue147 continue
141 }148 }
142 if partial {149 if partial {
143- if len(data) <= maxEscapeBufferLen {150+ limit := maxEscapeBufferLen
151+ if isKittyGraphicsAPC(data) {
152+ limit = maxKittyEscapeBufferLen
153+ }
154+ if len(data) <= limit {
144 s.escapeBuf = append(s.escapeBuf[:0], data...)155 s.escapeBuf = append(s.escapeBuf[:0], data...)
145 }156 }
146 return157 return
@@ -165,7 +176,12 @@ func (s *Screen) consumeEscape(data []byte) (consumed int, partial bool) {
165 return s.consumeOSC(data)176 return s.consumeOSC(data)
166 case 'P':177 case 'P':
167 return consumeSTString(data)178 return consumeSTString(data)
168- case '_', '^', 'X':179+ case '_':
180+ if len(data) >= 3 && data[2] == 'G' {
181+ return s.consumeKittyGraphics(data)
182+ }
183+ return consumeSTString(data)
184+ case '^', 'X':
169 return consumeSTString(data)185 return consumeSTString(data)
170 case '[':186 case '[':
171 return s.consumeCSI(data)187 return s.consumeCSI(data)
@@ -215,3 +231,42 @@ func consumeSTString(data []byte) (consumed int, partial bool) {
215 }231 }
216 return 0, true232 return 0, true
217 }233 }
234+
235+func isKittyGraphicsAPC(data []byte) bool {
236+ return len(data) >= 3 && data[0] == 0x1b && data[1] == '_' && data[2] == 'G'
237+}
238+
239+// consumeKittyGraphics frames one exact ESC _ G APC and dispatches only its
240+// complete bytes to the protocol adapter. The ordinary parser never sees the
241+// APC body as text, and the adapter is allocated only on the first complete
242+// graphics command.
243+func (s *Screen) consumeKittyGraphics(data []byte) (consumed int, partial bool) {
244+ for i := 3; i < len(data); i++ {
245+ switch data[i] {
246+ case 0x9c: // C1 string terminator.
247+ s.dispatchKittyGraphics(data[:i+1])
248+ return i + 1, false
249+ case 0x1b:
250+ if i+1 < len(data) && data[i+1] == '\\' {
251+ s.dispatchKittyGraphics(data[:i+2])
252+ return i + 2, false
253+ }
254+ }
255+ }
256+ return 0, true
257+}
258+
259+func (s *Screen) dispatchKittyGraphics(apc []byte) {
260+ if s.graphics == nil {
261+ s.graphics = newScreenGraphicsState()
262+ }
263+ result, _ := s.graphics.kitty.Feed(apc)
264+ for _, response := range result.Responses {
265+ s.respond(response)
266+ }
267+ if len(result.Mutations) != 0 {
268+ // Graphics are rendered independently from Frame. A graphics mutation
269+ // still needs to wake consumers which only redraw on screen damage.
270+ s.fullRedraw()
271+ }
272+}
M screen_csi.go +1 -0
@@ -418,6 +418,7 @@ func (s *Screen) clearScreenMode(mode int) {
418418 s.buffer.boundaries[y] = LineBound{Soft: s.buffer.boundaries[y].Soft}
419419 s.buffer.rowIDs[y] = s.nextRowIDValue()
420420 }
421+ s.graphicsClearPlacements()
421422 s.record(renderer.Damage{Kind: renderer.DamageClear, X: 0, Y: 0, Width: s.Frame.Width, Height: s.Frame.Height, Count: 1})
422423 default:
423424 for y := s.Row; y < s.Frame.Height; y++ {
@@ -418,6 +418,7 @@ func (s *Screen) clearScreenMode(mode int) {
418 s.buffer.boundaries[y] = LineBound{Soft: s.buffer.boundaries[y].Soft}418 s.buffer.boundaries[y] = LineBound{Soft: s.buffer.boundaries[y].Soft}
419 s.buffer.rowIDs[y] = s.nextRowIDValue()419 s.buffer.rowIDs[y] = s.nextRowIDValue()
420 }420 }
421+ s.graphicsClearPlacements()
421 s.record(renderer.Damage{Kind: renderer.DamageClear, X: 0, Y: 0, Width: s.Frame.Width, Height: s.Frame.Height, Count: 1})422 s.record(renderer.Damage{Kind: renderer.DamageClear, X: 0, Y: 0, Width: s.Frame.Width, Height: s.Frame.Height, Count: 1})
422 default:423 default:
423 for y := s.Row; y < s.Frame.Height; y++ {424 for y := s.Row; y < s.Frame.Height; y++ {
A screen_graphics_test.go +109 -0
@@ -0,0 +1,109 @@
1+package vt
2+
3+import (
4+ "encoding/base64"
5+ "testing"
6+
7+ "github.com/bnema/vev-vt/graphics"
8+ "github.com/stretchr/testify/require"
9+)
10+
11+func screenKittyImageAPC(action string) []byte {
12+ payload := base64.RawStdEncoding.EncodeToString([]byte{1, 2, 3, 4})
13+ return []byte("\x1b_Ga=" + action + ",i=1,f=32,s=1,v=1;" + payload + "\x1b\\")
14+}
15+
16+func screenKittyImageAPCWithID(action string, id byte) []byte {
17+ payload := base64.RawStdEncoding.EncodeToString([]byte{1, 2, 3, 4})
18+ return []byte("\x1b_Ga=" + action + ",i=" + string(id) + ",f=32,s=1,v=1;" + payload + "\x1b\\")
19+}
20+
21+func screenKittyPutAPC(id byte) []byte {
22+ return []byte("\x1b_Ga=p,i=" + string(id) + ";\x1b\\")
23+}
24+
25+func TestScreenKittyGraphicsFragmentedAPCAndResponse(t *testing.T) {
26+ screen := NewScreen(16, 3)
27+ var responses []string
28+ screen.OnResponse = func(response []byte) { responses = append(responses, string(response)) }
29+
30+ input := append([]byte("before"), screenKittyImageAPC("T")...)
31+ input = append(input, []byte("after")...)
32+ for _, b := range input {
33+ screen.Write([]byte{b})
34+ }
35+
36+ require.Equal(t, "beforeafter ", rowText(screen.Snapshot().Row(0)))
37+ require.Equal(t, []string{"\x1b_Gi=1;OK\x1b\\"}, responses)
38+ snapshot := screen.GraphicsSnapshot()
39+ require.NotNil(t, snapshot)
40+ require.Equal(t, uint64(1), snapshot.Usage().Assets)
41+ require.Equal(t, uint64(1), snapshot.Usage().Placements)
42+}
43+
44+func TestScreenKittyGraphicsPrimaryAndAlternateState(t *testing.T) {
45+ screen := NewScreen(8, 3)
46+ screen.Write(screenKittyImageAPC("T"))
47+ primary := screen.GraphicsSnapshot()
48+ require.NotNil(t, primary)
49+ require.Equal(t, uint64(1), primary.Usage().Placements)
50+
51+ screen.Write([]byte("\x1b[?1049halt"))
52+ require.True(t, screen.AltScreenActive())
53+ require.Nil(t, screen.GraphicsSnapshot())
54+ screen.Write(screenKittyImageAPCWithID("T", '2'))
55+ require.Equal(t, uint64(1), screen.GraphicsSnapshot().Usage().Placements)
56+
57+ screen.Write([]byte("\x1b[?1049l"))
58+ require.False(t, screen.AltScreenActive())
59+ require.Equal(t, primary.Usage(), screen.GraphicsSnapshot().Usage())
60+}
61+
62+func TestScreenKittyGraphicsOrdinaryResetClearScrollAndResize(t *testing.T) {
63+ screen := NewScreen(3, 2)
64+ screen.Write(screenKittyImageAPC("T"))
65+
66+ // Printable text and a scroll do not guess at image anchoring. Existing
67+ // placements remain available until a graphics-aware policy is supplied.
68+ screen.Write([]byte("abcdefg"))
69+ require.Equal(t, uint64(1), screen.GraphicsSnapshot().Usage().Placements)
70+ screen.Resize(4, 3)
71+ require.Equal(t, uint64(1), screen.GraphicsSnapshot().Usage().Placements)
72+
73+ // A full ordinary erase removes visible placements while retaining the
74+ // protocol image mapping for a later explicit put.
75+ screen.Write([]byte("\x1b[2J"))
76+ graphicsSnapshot := screen.GraphicsSnapshot()
77+ require.NotNil(t, graphicsSnapshot)
78+ require.Equal(t, uint64(1), graphicsSnapshot.Usage().Assets)
79+ require.Equal(t, uint64(0), graphicsSnapshot.Usage().Placements)
80+
81+ screen.Write(screenKittyPutAPC('1'))
82+ require.Equal(t, uint64(1), screen.GraphicsSnapshot().Usage().Placements)
83+ screen.Write([]byte("\x1bc"))
84+ require.Nil(t, screen.GraphicsSnapshot())
85+}
86+
87+func TestScreenSnapshotCapturesOwnedActiveGraphicsSnapshot(t *testing.T) {
88+ screen := NewScreen(4, 2)
89+ screen.Write(screenKittyImageAPC("T"))
90+ captured := screen.Snapshot()
91+ require.NotNil(t, captured.Graphics())
92+ require.Equal(t, uint64(1), captured.Graphics().Usage().Placements)
93+
94+ screen.Write([]byte("\x1b[2J"))
95+ require.Equal(t, uint64(1), captured.Graphics().Usage().Placements)
96+ require.Equal(t, uint64(0), screen.Snapshot().Graphics().Usage().Placements)
97+
98+ // Graphics snapshots are independent of the text frame and have no Cell or
99+ // VTH3 representation to invalidate.
100+ require.Equal(t, graphics.Usage{Assets: 1, Placements: 1, EncodedBytes: 4, DecodedPixels: 1}, captured.Graphics().Usage())
101+}
102+
103+func TestScreenOrdinaryTextPathDoesNotAllocateGraphicsState(t *testing.T) {
104+ screen := NewScreen(4, 2)
105+ screen.Write([]byte("text"))
106+ require.Nil(t, screen.GraphicsScene())
107+ require.Nil(t, screen.Snapshot().GraphicsSnapshot())
108+ require.Equal(t, "text", rowText(screen.Snapshot().Row(0)))
109+}
@@ -0,0 +1,109 @@
1+package vt
2+
3+import (
4+ "encoding/base64"
5+ "testing"
6+
7+ "github.com/bnema/vev-vt/graphics"
8+ "github.com/stretchr/testify/require"
9+)
10+
11+func screenKittyImageAPC(action string) []byte {
12+ payload := base64.RawStdEncoding.EncodeToString([]byte{1, 2, 3, 4})
13+ return []byte("\x1b_Ga=" + action + ",i=1,f=32,s=1,v=1;" + payload + "\x1b\\")
14+}
15+
16+func screenKittyImageAPCWithID(action string, id byte) []byte {
17+ payload := base64.RawStdEncoding.EncodeToString([]byte{1, 2, 3, 4})
18+ return []byte("\x1b_Ga=" + action + ",i=" + string(id) + ",f=32,s=1,v=1;" + payload + "\x1b\\")
19+}
20+
21+func screenKittyPutAPC(id byte) []byte {
22+ return []byte("\x1b_Ga=p,i=" + string(id) + ";\x1b\\")
23+}
24+
25+func TestScreenKittyGraphicsFragmentedAPCAndResponse(t *testing.T) {
26+ screen := NewScreen(16, 3)
27+ var responses []string
28+ screen.OnResponse = func(response []byte) { responses = append(responses, string(response)) }
29+
30+ input := append([]byte("before"), screenKittyImageAPC("T")...)
31+ input = append(input, []byte("after")...)
32+ for _, b := range input {
33+ screen.Write([]byte{b})
34+ }
35+
36+ require.Equal(t, "beforeafter ", rowText(screen.Snapshot().Row(0)))
37+ require.Equal(t, []string{"\x1b_Gi=1;OK\x1b\\"}, responses)
38+ snapshot := screen.GraphicsSnapshot()
39+ require.NotNil(t, snapshot)
40+ require.Equal(t, uint64(1), snapshot.Usage().Assets)
41+ require.Equal(t, uint64(1), snapshot.Usage().Placements)
42+}
43+
44+func TestScreenKittyGraphicsPrimaryAndAlternateState(t *testing.T) {
45+ screen := NewScreen(8, 3)
46+ screen.Write(screenKittyImageAPC("T"))
47+ primary := screen.GraphicsSnapshot()
48+ require.NotNil(t, primary)
49+ require.Equal(t, uint64(1), primary.Usage().Placements)
50+
51+ screen.Write([]byte("\x1b[?1049halt"))
52+ require.True(t, screen.AltScreenActive())
53+ require.Nil(t, screen.GraphicsSnapshot())
54+ screen.Write(screenKittyImageAPCWithID("T", '2'))
55+ require.Equal(t, uint64(1), screen.GraphicsSnapshot().Usage().Placements)
56+
57+ screen.Write([]byte("\x1b[?1049l"))
58+ require.False(t, screen.AltScreenActive())
59+ require.Equal(t, primary.Usage(), screen.GraphicsSnapshot().Usage())
60+}
61+
62+func TestScreenKittyGraphicsOrdinaryResetClearScrollAndResize(t *testing.T) {
63+ screen := NewScreen(3, 2)
64+ screen.Write(screenKittyImageAPC("T"))
65+
66+ // Printable text and a scroll do not guess at image anchoring. Existing
67+ // placements remain available until a graphics-aware policy is supplied.
68+ screen.Write([]byte("abcdefg"))
69+ require.Equal(t, uint64(1), screen.GraphicsSnapshot().Usage().Placements)
70+ screen.Resize(4, 3)
71+ require.Equal(t, uint64(1), screen.GraphicsSnapshot().Usage().Placements)
72+
73+ // A full ordinary erase removes visible placements while retaining the
74+ // protocol image mapping for a later explicit put.
75+ screen.Write([]byte("\x1b[2J"))
76+ graphicsSnapshot := screen.GraphicsSnapshot()
77+ require.NotNil(t, graphicsSnapshot)
78+ require.Equal(t, uint64(1), graphicsSnapshot.Usage().Assets)
79+ require.Equal(t, uint64(0), graphicsSnapshot.Usage().Placements)
80+
81+ screen.Write(screenKittyPutAPC('1'))
82+ require.Equal(t, uint64(1), screen.GraphicsSnapshot().Usage().Placements)
83+ screen.Write([]byte("\x1bc"))
84+ require.Nil(t, screen.GraphicsSnapshot())
85+}
86+
87+func TestScreenSnapshotCapturesOwnedActiveGraphicsSnapshot(t *testing.T) {
88+ screen := NewScreen(4, 2)
89+ screen.Write(screenKittyImageAPC("T"))
90+ captured := screen.Snapshot()
91+ require.NotNil(t, captured.Graphics())
92+ require.Equal(t, uint64(1), captured.Graphics().Usage().Placements)
93+
94+ screen.Write([]byte("\x1b[2J"))
95+ require.Equal(t, uint64(1), captured.Graphics().Usage().Placements)
96+ require.Equal(t, uint64(0), screen.Snapshot().Graphics().Usage().Placements)
97+
98+ // Graphics snapshots are independent of the text frame and have no Cell or
99+ // VTH3 representation to invalidate.
100+ require.Equal(t, graphics.Usage{Assets: 1, Placements: 1, EncodedBytes: 4, DecodedPixels: 1}, captured.Graphics().Usage())
101+}
102+
103+func TestScreenOrdinaryTextPathDoesNotAllocateGraphicsState(t *testing.T) {
104+ screen := NewScreen(4, 2)
105+ screen.Write([]byte("text"))
106+ require.Nil(t, screen.GraphicsScene())
107+ require.Nil(t, screen.Snapshot().GraphicsSnapshot())
108+ require.Equal(t, "text", rowText(screen.Snapshot().Row(0)))
109+}
M screen_modes.go +5 -0
@@ -11,6 +11,7 @@ type screenState struct {
1111 scrollTop int
1212 scrollBottom int
1313 savedCursor cursorState
14+ graphics *screenGraphicsState
1415 originMode bool
1516 insertMode bool
1617 }
@@ -94,6 +95,7 @@ func (s *Screen) reset() {
9495 s.escapeBuf = s.escapeBuf[:0]
9596 s.savedCursor = cursorState{}
9697 s.alternate = nil
98+ s.graphics = nil
9799 s.originMode = false
98100 s.insertMode = false
99101 s.cursorVisible = true
@@ -199,10 +201,12 @@ func (s *Screen) enterAlternateScreen() {
199201 scrollTop: s.scrollTop,
200202 scrollBottom: s.scrollBottom,
201203 savedCursor: s.savedCursor,
204+ graphics: s.graphics,
202205 originMode: s.originMode,
203206 insertMode: s.insertMode,
204207 }
205208 }
209+ s.graphics = nil
206210 s.buffer = s.newBuffer(s.Frame.Width, s.Frame.Height)
207211 s.Frame = s.buffer.frame
208212 s.Row, s.Col = 0, 0
@@ -229,6 +233,7 @@ func (s *Screen) exitAlternateScreen() {
229233 s.scrollTop = state.scrollTop
230234 s.scrollBottom = state.scrollBottom
231235 s.savedCursor = state.savedCursor
236+ s.graphics = state.graphics
232237 s.originMode = state.originMode
233238 s.insertMode = state.insertMode
234239 s.alternate = nil
@@ -11,6 +11,7 @@ type screenState struct {
11 scrollTop int11 scrollTop int
12 scrollBottom int12 scrollBottom int
13 savedCursor cursorState13 savedCursor cursorState
14+ graphics *screenGraphicsState
14 originMode bool15 originMode bool
15 insertMode bool16 insertMode bool
16 }17 }
@@ -94,6 +95,7 @@ func (s *Screen) reset() {
94 s.escapeBuf = s.escapeBuf[:0]95 s.escapeBuf = s.escapeBuf[:0]
95 s.savedCursor = cursorState{}96 s.savedCursor = cursorState{}
96 s.alternate = nil97 s.alternate = nil
98+ s.graphics = nil
97 s.originMode = false99 s.originMode = false
98 s.insertMode = false100 s.insertMode = false
99 s.cursorVisible = true101 s.cursorVisible = true
@@ -199,10 +201,12 @@ func (s *Screen) enterAlternateScreen() {
199 scrollTop: s.scrollTop,201 scrollTop: s.scrollTop,
200 scrollBottom: s.scrollBottom,202 scrollBottom: s.scrollBottom,
201 savedCursor: s.savedCursor,203 savedCursor: s.savedCursor,
204+ graphics: s.graphics,
202 originMode: s.originMode,205 originMode: s.originMode,
203 insertMode: s.insertMode,206 insertMode: s.insertMode,
204 }207 }
205 }208 }
209+ s.graphics = nil
206 s.buffer = s.newBuffer(s.Frame.Width, s.Frame.Height)210 s.buffer = s.newBuffer(s.Frame.Width, s.Frame.Height)
207 s.Frame = s.buffer.frame211 s.Frame = s.buffer.frame
208 s.Row, s.Col = 0, 0212 s.Row, s.Col = 0, 0
@@ -229,6 +233,7 @@ func (s *Screen) exitAlternateScreen() {
229 s.scrollTop = state.scrollTop233 s.scrollTop = state.scrollTop
230 s.scrollBottom = state.scrollBottom234 s.scrollBottom = state.scrollBottom
231 s.savedCursor = state.savedCursor235 s.savedCursor = state.savedCursor
236+ s.graphics = state.graphics
232 s.originMode = state.originMode237 s.originMode = state.originMode
233 s.insertMode = state.insertMode238 s.insertMode = state.insertMode
234 s.alternate = nil239 s.alternate = nil
M screen_snapshot.go +14 -2
@@ -1,6 +1,9 @@
11 package vt
22
3-import renderer "github.com/bnema/vev-vt/core"
3+import (
4+ renderer "github.com/bnema/vev-vt/core"
5+ "github.com/bnema/vev-vt/graphics"
6+)
47
58 // CursorSnapshot is the cursor state captured with a visible screen snapshot.
69 type CursorSnapshot struct {
@@ -31,6 +34,7 @@ type ScreenSnapshot struct {
3134 cursor CursorSnapshot
3235 modes ModeSnapshot
3336 title string
37+ graphics *graphics.Snapshot
3438 nextRowID RowID
3539 }
3640
@@ -67,7 +71,8 @@ func (s *Screen) Snapshot() ScreenSnapshot {
6771 MouseTracking: mouseTracking,
6872 MouseSGR: mouseSGR,
6973 },
70- title: s.TerminalTitle(),
74+ title: s.TerminalTitle(),
75+ graphics: s.GraphicsSnapshot(),
7176 }
7277 }
7378
@@ -114,3 +119,10 @@ func (s ScreenSnapshot) NextRowID() RowID {
114119 func (s ScreenSnapshot) Cursor() CursorSnapshot { return s.cursor }
115120 func (s ScreenSnapshot) Modes() ModeSnapshot { return s.modes }
116121 func (s ScreenSnapshot) Title() string { return s.title }
122+
123+// Graphics returns the immutable graphics scene snapshot for the active
124+// screen buffer, or nil when that buffer has not used Kitty graphics.
125+func (s ScreenSnapshot) Graphics() *graphics.Snapshot { return s.graphics }
126+
127+// GraphicsSnapshot is an explicit alias for Graphics.
128+func (s ScreenSnapshot) GraphicsSnapshot() *graphics.Snapshot { return s.graphics }
@@ -1,6 +1,9 @@
1 package vt1 package vt
2 2
3-import renderer "github.com/bnema/vev-vt/core"3+import (
4+ renderer "github.com/bnema/vev-vt/core"
5+ "github.com/bnema/vev-vt/graphics"
6+)
4 7
5 // CursorSnapshot is the cursor state captured with a visible screen snapshot.8 // CursorSnapshot is the cursor state captured with a visible screen snapshot.
6 type CursorSnapshot struct {9 type CursorSnapshot struct {
@@ -31,6 +34,7 @@ type ScreenSnapshot struct {
31 cursor CursorSnapshot34 cursor CursorSnapshot
32 modes ModeSnapshot35 modes ModeSnapshot
33 title string36 title string
37+ graphics *graphics.Snapshot
34 nextRowID RowID38 nextRowID RowID
35 }39 }
36 40
@@ -67,7 +71,8 @@ func (s *Screen) Snapshot() ScreenSnapshot {
67 MouseTracking: mouseTracking,71 MouseTracking: mouseTracking,
68 MouseSGR: mouseSGR,72 MouseSGR: mouseSGR,
69 },73 },
70- title: s.TerminalTitle(),74+ title: s.TerminalTitle(),
75+ graphics: s.GraphicsSnapshot(),
71 }76 }
72 }77 }
73 78
@@ -114,3 +119,10 @@ func (s ScreenSnapshot) NextRowID() RowID {
114 func (s ScreenSnapshot) Cursor() CursorSnapshot { return s.cursor }119 func (s ScreenSnapshot) Cursor() CursorSnapshot { return s.cursor }
115 func (s ScreenSnapshot) Modes() ModeSnapshot { return s.modes }120 func (s ScreenSnapshot) Modes() ModeSnapshot { return s.modes }
116 func (s ScreenSnapshot) Title() string { return s.title }121 func (s ScreenSnapshot) Title() string { return s.title }
122+
123+// Graphics returns the immutable graphics scene snapshot for the active
124+// screen buffer, or nil when that buffer has not used Kitty graphics.
125+func (s ScreenSnapshot) Graphics() *graphics.Snapshot { return s.graphics }
126+
127+// GraphicsSnapshot is an explicit alias for Graphics.
128+func (s ScreenSnapshot) GraphicsSnapshot() *graphics.Snapshot { return s.graphics }

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 2

Tags 13

↑↓ moveenter openesc close