detached ab6686c

screen_graphics_test.go RawHistoryFolder

1package vt
2
3import (
4	"bytes"
5	"encoding/base64"
6	"image"
7	"image/png"
8	"math"
9	"runtime"
10	"strconv"
11	"strings"
12	"testing"
13
14	"github.com/bnema/vev-vt/graphics"
15	"github.com/stretchr/testify/require"
16)
17
18func screenKittyImageAPC(action string) []byte {
19	payload := base64.RawStdEncoding.EncodeToString([]byte{1, 2, 3, 4})
20	return []byte("\x1b_Ga=" + action + ",i=1,f=32,s=1,v=1,C=1;" + payload + "\x1b\\")
21}
22
23func screenKittyImageAPCWithID(action string, id uint64) []byte {
24	payload := base64.RawStdEncoding.EncodeToString([]byte{1, 2, 3, 4})
25	return []byte("\x1b_Ga=" + action + ",i=" + strconv.FormatUint(id, 10) + ",f=32,s=1,v=1,C=1;" + payload + "\x1b\\")
26}
27
28func screenKittyPutAPC(id uint64) []byte {
29	return []byte("\x1b_Ga=p,i=" + strconv.FormatUint(id, 10) + ";\x1b\\")
30}
31
32func TestScreenKittyGraphicsFragmentedAPCAndResponse(t *testing.T) {
33	screen := NewScreen(16, 3)
34	var responses []string
35	screen.OnResponse = func(response []byte) { responses = append(responses, string(response)) }
36
37	input := append([]byte("before"), screenKittyImageAPC("T")...)
38	input = append(input, []byte("after")...)
39	for _, b := range input {
40		screen.Write([]byte{b})
41	}
42
43	require.Equal(t, "beforeafter     ", rowText(screen.Snapshot().Row(0)))
44	require.Equal(t, []string{"\x1b_Gi=1;OK\x1b\\"}, responses)
45	snapshot := screen.GraphicsSnapshot()
46	require.NotNil(t, snapshot)
47	require.Equal(t, uint64(1), snapshot.Usage().Assets)
48	require.Equal(t, uint64(1), snapshot.Usage().Placements)
49}
50
51func TestScreenKittyGraphicsWithoutImageIDDoesNotRespond(t *testing.T) {
52	screen := NewScreen(16, 3)
53	var responses []string
54	screen.OnResponse = func(response []byte) { responses = append(responses, string(response)) }
55	payload := base64.RawStdEncoding.EncodeToString([]byte{1, 2, 3, 4})
56
57	screen.Write([]byte("\x1b_Ga=T,f=32,s=1,v=1,C=1;" + payload + "\x1b\\"))
58	screen.Write([]byte("\x1b_Ga=T,i=0,f=32,s=1,v=1,C=1;" + payload + "\x1b\\"))
59	screen.Write([]byte("\x1b_Ga=q,f=24,s=1,v=1;invalid\x1b\\"))
60
61	require.Empty(t, responses)
62	require.Equal(t, uint64(2), screen.GraphicsSnapshot().Usage().Placements)
63}
64
65func TestScreenKittyIcatDetectionResponses(t *testing.T) {
66	screen := NewScreen(80, 24)
67	var responses []string
68	screen.OnResponse = func(response []byte) { responses = append(responses, string(response)) }
69
70	screen.Write([]byte("\x1b_Ga=q,f=24,s=1,v=1,S=3,i=1;MTIz\x1b\\"))
71	screen.Write([]byte("\x1b_Ga=q,f=24,t=t,s=1,v=1,S=47,i=2;L2Rldi9zaG0va2l0dHktdHR5LWdyYXBoaWNzLXByb3RvY29sLTMzMTU3NTkxNjc\x1b\\"))
72	screen.Write([]byte("\x1b_Ga=q,f=24,t=s,s=1,v=1,S=18,i=3;aWNhdC1aQlJCWFdNQ0lIQ0ZD\x1b\\"))
73	screen.Write([]byte("\x1b[c"))
74
75	require.Equal(t, []string{
76		"\x1b_Gi=1;OK\x1b\\",
77		"\x1b_Gi=2;ENOTSUP\x1b\\",
78		"\x1b_Gi=3;ENOTSUP\x1b\\",
79		"\x1b[?62;22c",
80	}, responses)
81}
82
83func TestScreenKittyGraphicsPrimaryAndAlternateState(t *testing.T) {
84	screen := NewScreen(8, 3)
85	screen.Write(screenKittyImageAPC("T"))
86	primary := screen.GraphicsSnapshot()
87	require.NotNil(t, primary)
88	require.Equal(t, uint64(1), primary.Usage().Placements)
89
90	screen.Write([]byte("\x1b[?1049halt"))
91	require.True(t, screen.AltScreenActive())
92	require.Nil(t, screen.GraphicsSnapshot())
93	screen.Write(screenKittyImageAPCWithID("T", 2))
94	require.Equal(t, uint64(1), screen.GraphicsSnapshot().Usage().Placements)
95
96	screen.Write([]byte("\x1b[?1049l"))
97	require.False(t, screen.AltScreenActive())
98	require.Equal(t, primary.Usage(), screen.GraphicsSnapshot().Usage())
99}
100
101func TestScreenReenteringActiveAlternateClearsGraphicsState(t *testing.T) {
102	screen := NewScreen(8, 3)
103	screen.Write([]byte("\x1b[?1049h"))
104	screen.Write(screenKittyImageAPC("T"))
105	before := screen.GraphicsSnapshot()
106	require.NotNil(t, before)
107	require.Equal(t, uint64(1), before.Usage().Placements)
108
109	screen.Write([]byte("\x1b[?1049h"))
110	after := screen.GraphicsSnapshot()
111	require.Nil(t, after)
112	require.Equal(t, uint64(1), before.Usage().Placements)
113}
114
115func TestScreenFullClearAbortsPendingUploadAndClearsPlacements(t *testing.T) {
116	screen := NewScreen(8, 3)
117	screen.Write(screenKittyImageAPC("T"))
118	screen.Write([]byte("\x1b_Ga=T,i=2,f=32,s=1,v=1,m=1,C=1;AQ\x1b\\"))
119	require.Equal(t, uint64(1), screen.GraphicsSnapshot().Usage().Placements)
120
121	screen.Write([]byte("\x1b[2J"))
122	require.Equal(t, uint64(0), screen.GraphicsSnapshot().Usage().Placements)
123	screen.Write([]byte("\x1b_Gm=0;IDBA\x1b\\"))
124	graphicsSnapshot := screen.GraphicsSnapshot()
125	require.NotNil(t, graphicsSnapshot)
126	require.Equal(t, uint64(1), graphicsSnapshot.Usage().Assets)
127	require.Equal(t, uint64(0), graphicsSnapshot.Usage().Placements)
128}
129
130func TestScreenPendingTransmitDisplayFailsClosedAcrossErrorAndResize(t *testing.T) {
131	pending := []byte("\x1b_Ga=T,i=1,f=32,s=1,v=1,m=1,C=1;AQ\x1b\\")
132	continuation := []byte("\x1b_Gm=0;IDBA\x1b\\")
133
134	screen := NewScreen(4, 2)
135	screen.Write(pending)
136	screen.Write([]byte("\x1b_Ga=p,i=1;\x1b\\"))
137	screen.Write(continuation)
138	require.NotNil(t, screen.GraphicsSnapshot())
139	require.Equal(t, uint64(0), screen.GraphicsSnapshot().Usage().Placements)
140
141	screen.Write(pending)
142	screen.Resize(5, 2)
143	screen.Write(continuation)
144	require.NotNil(t, screen.GraphicsSnapshot())
145	require.Equal(t, uint64(0), screen.GraphicsSnapshot().Usage().Placements)
146}
147
148func TestScreenPendingTransmitDisplayIsScopedToScreenBuffer(t *testing.T) {
149	screen := NewScreen(4, 2)
150	screen.Write([]byte("\x1b_Ga=T,i=1,f=32,s=1,v=1,m=1,C=1;AQ\x1b\\"))
151	screen.Write([]byte("\x1b[?1049h"))
152	screen.Write([]byte("\x1b_Gm=0;IDBA\x1b\\"))
153	require.NotNil(t, screen.GraphicsSnapshot())
154	require.Equal(t, uint64(0), screen.GraphicsSnapshot().Usage().Placements)
155	screen.Write([]byte("\x1b[?1049l"))
156	screen.Write([]byte("\x1b_Gm=0;IDBA\x1b\\"))
157	require.Equal(t, uint64(1), screen.GraphicsSnapshot().Usage().Placements)
158}
159
160func TestScreenPartialPixelGeometryDoesNotInventPixelCoordinates(t *testing.T) {
161	screen := NewScreen(10, 4)
162	screen.SetGeometry(Geometry{Cols: 10, Rows: 4, PixelWidth: 100})
163	screen.Write([]byte("\x1b[2;3H"))
164	screen.Write(screenKittyImageAPC("T"))
165	placement := screen.GraphicsSnapshot().Placements()[0]
166	require.Equal(t, graphics.PixelRect{X: 0, Y: 0, Width: 1, Height: 1}, placement.Destination())
167}
168
169func TestScreenKittyPlacementSeparatesCursorOriginCropAndPixelOffset(t *testing.T) {
170	screen := NewScreen(10, 4)
171	screen.SetGeometry(Geometry{Cols: 10, Rows: 4, PixelWidth: 100, PixelHeight: 40})
172	screen.Write([]byte("\x1b[2;3H"))
173	payload := base64.RawStdEncoding.EncodeToString(make([]byte, 64))
174	screen.Write([]byte("\x1b_Ga=T,i=1,f=32,s=4,v=4,x=1,y=1,w=2,h=2,X=3,Y=4,C=1;" + payload + "\x1b\\"))
175
176	placement := screen.GraphicsSnapshot().Placements()[0]
177	require.Equal(t, graphics.PixelRect{X: 1, Y: 1, Width: 2, Height: 2}, placement.Source())
178	require.Equal(t, graphics.PixelRect{X: 23, Y: 14, Width: 2, Height: 2}, placement.Destination())
179}
180
181func TestScreenKittyGraphicsOrdinaryResetClearScrollAndResize(t *testing.T) {
182	screen := NewScreen(3, 2)
183	screen.Write(screenKittyImageAPC("T"))
184
185	// Printable text and a scroll do not guess at image anchoring. Existing
186	// placements remain available until a graphics-aware policy is supplied.
187	screen.Write([]byte("abcdefg"))
188	require.Equal(t, uint64(1), screen.GraphicsSnapshot().Usage().Placements)
189	screen.Resize(4, 3)
190	require.Equal(t, uint64(1), screen.GraphicsSnapshot().Usage().Placements)
191
192	// A full ordinary erase removes visible placements while retaining the
193	// protocol image mapping for a later explicit put.
194	screen.Write([]byte("\x1b[2J"))
195	graphicsSnapshot := screen.GraphicsSnapshot()
196	require.NotNil(t, graphicsSnapshot)
197	require.Equal(t, uint64(1), graphicsSnapshot.Usage().Assets)
198	require.Equal(t, uint64(0), graphicsSnapshot.Usage().Placements)
199
200	screen.Write(screenKittyPutAPC(1))
201	require.Equal(t, uint64(1), screen.GraphicsSnapshot().Usage().Placements)
202	screen.Write([]byte("\x1bc"))
203	require.Nil(t, screen.GraphicsSnapshot())
204}
205
206func TestScreenSnapshotCapturesOwnedActiveGraphicsSnapshot(t *testing.T) {
207	screen := NewScreen(4, 2)
208	screen.Write(screenKittyImageAPC("T"))
209	captured := screen.Snapshot()
210	require.NotNil(t, captured.Graphics())
211	require.Equal(t, uint64(1), captured.Graphics().Usage().Placements)
212	require.Equal(t, captured.Graphics().Usage(), screen.GraphicsSnapshot().Usage(), "screen graphics snapshot returns the immutable active-scene reference")
213
214	screen.Write([]byte("\x1b[2J"))
215	require.Equal(t, uint64(1), captured.Graphics().Usage().Placements)
216	require.Equal(t, uint64(0), screen.Snapshot().Graphics().Usage().Placements)
217
218	// Graphics snapshots are independent of the text frame and have no Cell or
219	// VTH3 representation to invalidate.
220	require.Equal(t, graphics.Usage{Assets: 1, Placements: 1, EncodedBytes: 4, DecodedPixels: 1}, captured.Graphics().Usage())
221}
222
223func TestScreenOrdinaryTextPathDoesNotAllocateGraphicsState(t *testing.T) {
224	screen := NewScreen(4, 2)
225	screen.Write([]byte("text"))
226	require.Nil(t, screen.GraphicsSnapshot())
227	require.Nil(t, screen.Snapshot().Graphics())
228	require.Equal(t, "text", rowText(screen.Snapshot().Row(0)))
229}
230
231func TestScreenKittyDiscardRetainsTerminatorFraming(t *testing.T) {
232	screen := NewScreen(16, 2)
233	screen.kittyDiscard = true
234	screen.Write([]byte("discarded body"))
235	screen.Write([]byte("\x1b"))
236	screen.Write([]byte("\\after"))
237	require.Equal(t, "after           ", rowText(screen.Snapshot().Row(0)))
238}
239
240func TestScreenKittyPlacementUsesCursorAndCControlsMovement(t *testing.T) {
241	screen := NewScreen(10, 4)
242	screen.SetGeometry(Geometry{Cols: 10, Rows: 4, PixelWidth: 100, PixelHeight: 40})
243	screen.Write([]byte("\x1b[2;3H"))
244	payload := base64.RawStdEncoding.EncodeToString(make([]byte, 16))
245	screen.Write([]byte("\x1b_Ga=T,i=1,f=32,s=2,v=2,c=2,r=1,C=1;" + payload + "\x1b\\"))
246
247	snapshot := screen.GraphicsSnapshot()
248	require.NotNil(t, snapshot)
249	placements := snapshot.Placements()
250	require.Len(t, placements, 1)
251	require.Equal(t, graphics.PixelRect{X: 20, Y: 10, Width: 2, Height: 2}, placements[0].Destination())
252	require.Equal(t, 1, screen.CursorRow())
253	require.Equal(t, 2, screen.CursorCol())
254
255	screen.Write([]byte("\x1b_Ga=T,i=2,f=32,s=2,v=2,C=0;" + payload + "\x1b\\"))
256	require.Equal(t, 1, screen.CursorRow())
257	require.Equal(t, 3, screen.CursorCol())
258
259	screen.Write([]byte("\x1b[3;4H\x1b_Ga=T,i=3,f=32,s=1,v=1,m=1,C=1;AQ\x1b\\"))
260	screen.Write([]byte("\x1b_Gm=0;IDBA\x1b\\"))
261	placements = screen.GraphicsSnapshot().Placements()
262	require.Len(t, placements, 3)
263	require.Equal(t, graphics.PixelRect{X: 30, Y: 20, Width: 1, Height: 1}, placements[2].Destination())
264	require.Equal(t, 2, screen.CursorRow())
265	require.Equal(t, 3, screen.CursorCol())
266}
267
268func TestScreenKittyPlacementNormalizesDeferredWrapCursor(t *testing.T) {
269	screen := NewScreen(3, 3)
270	screen.SetGeometry(Geometry{Cols: 3, Rows: 3, PixelWidth: 30, PixelHeight: 30})
271	screen.Write([]byte("abc"))
272	payload := base64.RawStdEncoding.EncodeToString(make([]byte, 4))
273
274	screen.Write([]byte("\x1b_Ga=T,i=1,f=32,s=1,v=1,C=1;" + payload + "\x1b\\"))
275
276	placement := screen.GraphicsSnapshot().Placements()[0]
277	require.Equal(t, graphics.PixelRect{X: 20, Width: 1, Height: 1}, placement.Destination())
278}
279
280func TestScreenKittyNaturalImageHeightMovesCursorBelowPlacement(t *testing.T) {
281	screen := NewScreen(10, 5)
282	screen.SetGeometry(Geometry{Cols: 10, Rows: 5, PixelWidth: 100, PixelHeight: 50})
283	payload := base64.RawStdEncoding.EncodeToString(make([]byte, 15*25*4))
284
285	screen.Write([]byte("\x1b_Ga=T,i=1,f=32,s=15,v=25;" + payload + "\x1b\\"))
286
287	// A 15x25 image occupies 2x3 cells. Kitty leaves the cursor immediately to
288	// the right of the placement and on its final occupied row.
289	require.Equal(t, 2, screen.CursorRow())
290	require.Equal(t, 2, screen.CursorCol())
291}
292
293func TestScreenKittyPlacementScrollsWithTallImageCursorAdvance(t *testing.T) {
294	screen := NewScreen(10, 5)
295	screen.SetGeometry(Geometry{Cols: 10, Rows: 5, PixelWidth: 100, PixelHeight: 50})
296	screen.Write([]byte("\x1b[2;1H"))
297	payload := base64.RawStdEncoding.EncodeToString(make([]byte, 15*45*4))
298
299	screen.Write([]byte("\x1b_Ga=T,i=1,f=32,s=15,v=45;" + payload + "\x1b\\"))
300
301	placement := screen.GraphicsSnapshot().Placements()[0]
302	require.Equal(t, graphics.PixelRect{X: 0, Y: 0, Width: 15, Height: 45}, placement.Destination(), "the placement must scroll with the text buffer")
303	require.Equal(t, 4, screen.CursorRow())
304	require.Equal(t, 2, screen.CursorCol())
305}
306
307func TestScreenKittyPlacementScrollsOnlyWhenContainedInRegion(t *testing.T) {
308	screen := NewScreen(10, 6)
309	screen.SetGeometry(Geometry{Cols: 10, Rows: 6, PixelWidth: 100, PixelHeight: 60})
310	screen.Write([]byte("\x1b[2;5r"))
311
312	insidePayload := base64.RawStdEncoding.EncodeToString(make([]byte, 1*10*4))
313	screen.Write([]byte("\x1b[3;1H\x1b_Ga=T,i=1,f=32,s=1,v=10,C=1;" + insidePayload + "\x1b\\"))
314	straddlingPayload := base64.RawStdEncoding.EncodeToString(make([]byte, 1*20*4))
315	screen.Write([]byte("\x1b[1;1H\x1b_Ga=T,i=2,f=32,s=1,v=20,Y=5,C=1;" + straddlingPayload + "\x1b\\"))
316
317	screen.Write([]byte("\x1b[S"))
318
319	placements := screen.GraphicsSnapshot().Placements()
320	require.Len(t, placements, 2)
321	require.Equal(t, graphics.PixelRect{X: 0, Y: 10, Width: 1, Height: 10}, placements[0].Destination(), "a placement contained in the margin must scroll")
322	require.Equal(t, graphics.PixelRect{X: 0, Y: 5, Width: 1, Height: 20}, placements[1].Destination(), "a placement crossing the margin must remain fixed")
323}
324
325func TestScreenKittyPlacementScrollSkipsBottomEdgeOverflow(t *testing.T) {
326	screen := NewScreen(1, 2)
327	screen.SetGeometry(Geometry{Cols: 1, Rows: 2, PixelWidth: 10, PixelHeight: 20})
328	payload := base64.RawStdEncoding.EncodeToString(make([]byte, 1*10*4))
329	screen.Write([]byte("\x1b_Ga=T,i=1,f=32,s=1,v=10,C=1;" + payload + "\x1b\\"))
330	placement := screen.GraphicsSnapshot().Placements()[0]
331	nearLimit := graphics.PixelRect{X: 0, Y: math.MaxInt64 - 11, Width: 1, Height: 10}
332	require.NoError(t, screen.graphics.scene.UpdatePlacement(placement.ID(), graphics.PlacementSpec{Destination: nearLimit}))
333
334	require.NotPanics(t, func() { screen.Write([]byte("\x1b[T")) })
335
336	placement = screen.GraphicsSnapshot().Placements()[0]
337	require.Equal(t, nearLimit, placement.Destination())
338}
339
340// Fragmented APCs reuse the Screen's escape buffer, so retained graphics state
341// (assets and pending chunked uploads) must not alias it.
342func TestScreenKittyFragmentedAPCsDoNotAliasEscapeBuffer(t *testing.T) {
343	screen := NewScreen(16, 3)
344	writeByteWise := func(data []byte) {
345		for i := range data {
346			screen.Write(data[i : i+1])
347		}
348	}
349	enc := func(b ...byte) string { return base64.StdEncoding.EncodeToString(b) }
350	writeByteWise([]byte("\x1b_Ga=t,i=1,f=32,s=1,v=1;" + enc(1, 2, 3, 4) + "\x1b\\"))
351	// Chunked upload interleaved with other escape sequences reusing the buffer.
352	writeByteWise([]byte("\x1b_Ga=t,i=2,f=32,s=1,v=1,m=1;" + enc(9, 9, 9, 9)[:4] + "\x1b\\"))
353	writeByteWise([]byte("\x1b[1mtext\x1b[0m"))
354	writeByteWise([]byte("\x1b_Gm=0;" + enc(9, 9, 9, 9)[4:] + "\x1b\\"))
355
356	snapshot := screen.GraphicsSnapshot()
357	require.NotNil(t, snapshot)
358	var got [][]byte
359	for _, asset := range snapshot.Assets() {
360		got = append(got, asset.Bytes())
361	}
362	require.ElementsMatch(t, [][]byte{{1, 2, 3, 4}, {9, 9, 9, 9}}, got)
363}
364
365func screenAssetBytes(t *testing.T, screen *Screen) [][]byte {
366	t.Helper()
367	snapshot := screen.GraphicsSnapshot()
368	require.NotNil(t, snapshot)
369	var got [][]byte
370	for _, asset := range snapshot.Assets() {
371		got = append(got, asset.Bytes())
372	}
373	return got
374}
375
376// A callback may call Write re-entrantly; the inner Write must not clobber the
377// reusable combine buffer the outer Write is still parsing.
378func TestScreenWriteReentrantFromCallbackKeepsOuterStreamIntact(t *testing.T) {
379	t.Run("split CSI then DA reply writes text", func(t *testing.T) {
380		screen := NewScreen(40, 3)
381		fired := 0
382		screen.OnResponse = func([]byte) {
383			fired++
384			// Leave a partial escape, then complete it: the second inner
385			// Write takes the combine path while the outer Write is mid-parse.
386			screen.Write([]byte("\x1b["))
387			screen.Write([]byte("1mZZZZZZZZZZ"))
388		}
389		screen.Write([]byte("\x1b[1"))
390		screen.Write([]byte("m\x1b[cBCDEFGH"))
391		require.Equal(t, 1, fired)
392		require.Equal(t, "ZZZZZZZZZZBCDEFGH", strings.TrimRight(rowText(screen.Snapshot().Row(0)), " "))
393	})
394	t.Run("split APC response writes upload", func(t *testing.T) {
395		screen := NewScreen(40, 3)
396		inner := false
397		screen.OnResponse = func([]byte) {
398			if inner {
399				return
400			}
401			inner = true
402			for _, b := range []byte("\x1b_Ga=t,f=32,s=1,v=1;CQkJCQ==\x1b\\") {
403				screen.Write([]byte{b})
404			}
405		}
406		screen.Write([]byte("\x1b_Ga=t,i=1,f=32,s=1,v=1;AQID"))
407		screen.Write([]byte("BA==\x1b\\XYZ"))
408		require.Equal(t, "XYZ", strings.TrimRight(rowText(screen.Snapshot().Row(0)), " "))
409		require.ElementsMatch(t, [][]byte{{1, 2, 3, 4}, {9, 9, 9, 9}}, screenAssetBytes(t, screen))
410	})
411}
412
413// Base64 payloads padded with CR/LF decode into a buffer sized for the raw
414// input. The scene must not retain that slack beyond its accounted bytes,
415// otherwise untrusted output could hold memory past MaxEncodedBytes.
416func TestScreenKittyNewlinePaddedPayloadRetainsOnlyAccountedBytes(t *testing.T) {
417	const uploads, padding = 16, 1 << 20
418	screen := NewScreen(16, 3)
419	pad := strings.Repeat("\n", padding)
420	var before, after runtime.MemStats
421	runtime.GC()
422	runtime.ReadMemStats(&before)
423	for i := range uploads {
424		screen.Write([]byte("\x1b_Ga=t,i=" + strconv.Itoa(i+1) + ",f=32,s=1,v=1;AQID" + pad + "BA==\x1b\\"))
425	}
426	runtime.GC()
427	runtime.ReadMemStats(&after)
428	require.Equal(t, uint64(4*uploads), screen.graphics.scene.Usage().EncodedBytes)
429	for _, asset := range screenAssetBytes(t, screen) {
430		require.Equal(t, []byte{1, 2, 3, 4}, asset)
431	}
432	// Untrimmed decode buffers would retain about 0.75 MiB per upload.
433	growth := int64(after.HeapAlloc) - int64(before.HeapAlloc)
434	require.Less(t, growth, int64(4<<20), "heap retained by %d padded uploads", uploads)
435	runtime.KeepAlive(screen)
436}
437
438// The Screen borrows the caller's Write buffer while parsing; nothing it
439// retains may alias that buffer once Write returns.
440func TestScreenKittyGraphicsDoesNotAliasCallerWriteBuffer(t *testing.T) {
441	scribble := func(b []byte) {
442		for i := range b {
443			b[i] = 0xff
444		}
445	}
446	t.Run("single-shot transmit and display", func(t *testing.T) {
447		screen := NewScreen(16, 3)
448		buf := []byte("\x1b_Ga=T,i=1,f=32,s=1,v=1;AQIDBA==\x1b\\")
449		screen.Write(buf)
450		scribble(buf)
451		require.Equal(t, [][]byte{{1, 2, 3, 4}}, screenAssetBytes(t, screen))
452	})
453	t.Run("first and final chunk in one write", func(t *testing.T) {
454		screen := NewScreen(16, 3)
455		buf := []byte("\x1b_Ga=t,i=1,f=32,s=1,v=1,m=1;AQID\x1b\\\x1b_Gm=0;BA==\x1b\\")
456		screen.Write(buf)
457		scribble(buf)
458		require.Equal(t, [][]byte{{1, 2, 3, 4}}, screenAssetBytes(t, screen))
459	})
460	t.Run("first chunk write then buffer reuse before final chunk", func(t *testing.T) {
461		screen := NewScreen(16, 3)
462		buf := []byte("\x1b_Ga=t,i=1,f=32,s=1,v=1,m=1;AQID\x1b\\")
463		screen.Write(buf)
464		scribble(buf)
465		screen.Write([]byte("\x1b_Gm=0;BA==\x1b\\"))
466		require.Equal(t, [][]byte{{1, 2, 3, 4}}, screenAssetBytes(t, screen))
467	})
468}
469
470func TestScreenKittySingleColumnOrRowPlacement(t *testing.T) {
471	var buf bytes.Buffer
472	require.NoError(t, png.Encode(&buf, image.NewRGBA(image.Rect(0, 0, 4, 4))))
473	payload := base64.StdEncoding.EncodeToString(buf.Bytes())
474	// 4x4 image. Unknown pixel size uses a 1:2 cell; the 10x20 cell geometry
475	// comes from SetGeometry. Without C=1 the cursor moves past the placement.
476	tests := []struct {
477		name             string
478		geometry         bool
479		commands         string
480		cols, rows       int64
481		wantCol, wantRow int
482	}{
483		{"columns only", false, "a=T,f=100,c=5;" + payload, 5, 3, 5, 2},
484		{"rows only", false, "a=T,f=100,r=3;" + payload, 6, 3, 6, 2},
485		{"zero rows is unset", false, "a=T,f=100,c=5,r=0;" + payload, 5, 3, 5, 2},
486		{"zero columns is unset", false, "a=T,f=100,c=0,r=3;" + payload, 6, 3, 6, 2},
487		{"known cell pixels", true, "a=T,f=100,c=4;" + payload, 4, 2, 4, 1},
488		{"put existing image", false, "a=t,i=7,f=100;" + payload + "\x1b\\\x1b_Ga=p,i=7,q=2,c=5", 5, 3, 5, 2},
489	}
490	for _, tt := range tests {
491		t.Run(tt.name, func(t *testing.T) {
492			screen := NewScreen(100, 30)
493			if tt.geometry {
494				screen.SetGeometry(Geometry{Cols: 100, Rows: 30, PixelWidth: 1000, PixelHeight: 600})
495			}
496			screen.Write([]byte("\x1b_G" + tt.commands + "\x1b\\"))
497			usage := screen.GraphicsSnapshot().Usage()
498			require.Equal(t, uint64(1), usage.Assets)
499			require.Equal(t, uint64(1), usage.Placements)
500			cells := screen.GraphicsSnapshot().Placements()[0].Cells()
501			require.Equal(t, tt.cols, cells.Width)
502			require.Equal(t, tt.rows, cells.Height)
503			require.Equal(t, tt.wantCol, screen.Col)
504			require.Equal(t, tt.wantRow, screen.Row)
505		})
506	}
507}

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