graphics_output_test.go RawHistoryFolder

1package daemon
2
3import (
4	"bytes"
5	"io"
6	"os"
7	"strconv"
8	"strings"
9	"sync"
10	"testing"
11	"time"
12
13	vt "github.com/bnema/vev-vt"
14	"github.com/bnema/vev-vt/graphics"
15	"github.com/bnema/vev/internal/domain"
16	"github.com/bnema/vev/internal/domain/terminalcap"
17	"github.com/bnema/vev/internal/protocol"
18	"github.com/bnema/vev/internal/protocol/wire"
19	"github.com/bnema/vev/internal/usecase/layout"
20	"github.com/stretchr/testify/require"
21)
22
23func attachmentOutputWithGraphics(state *graphicsOutputState) *attachmentOutput {
24	output := newOutputStateStream()
25	output.graphicsOutput = state
26	return output
27}
28
29func kittenGraphicsSnapshot(t *testing.T) *graphics.Snapshot {
30	t.Helper()
31	data, err := os.ReadFile("testdata/kitten-icat-stream-chunk.bin")
32	require.NoError(t, err)
33	screen := vt.NewScreen(80, 24)
34	for len(data) > 0 {
35		n := 791
36		if n > len(data) {
37			n = len(data)
38		}
39		screen.Write(data[:n])
40		data = data[n:]
41	}
42	snapshot := screen.GraphicsSnapshot()
43	require.NotNil(t, snapshot)
44	require.Equal(t, uint64(1), snapshot.Usage().Assets)
45	require.Equal(t, uint64(1), snapshot.Usage().Placements)
46	return snapshot
47}
48
49func recordIndex(data []byte, record string) int {
50	return bytes.Index(data, []byte(record))
51}
52
53type lateGraphicsCleanupTransport struct {
54	started     chan struct{}
55	release     chan struct{}
56	finished    chan struct{}
57	sends       chan wire.Envelope
58	startedOnce sync.Once
59	finishOnce  sync.Once
60}
61
62func newLateGraphicsCleanupTransport() *lateGraphicsCleanupTransport {
63	return &lateGraphicsCleanupTransport{
64		started:  make(chan struct{}),
65		release:  make(chan struct{}),
66		finished: make(chan struct{}),
67		sends:    make(chan wire.Envelope, 1),
68	}
69}
70
71func (t *lateGraphicsCleanupTransport) Send(frame wire.Envelope) error {
72	t.startedOnce.Do(func() { close(t.started) })
73	<-t.release
74	t.sends <- frame
75	t.finishOnce.Do(func() { close(t.finished) })
76	return nil
77}
78
79func (*lateGraphicsCleanupTransport) Recv() (wire.Envelope, error) { return wire.Envelope{}, io.EOF }
80func (*lateGraphicsCleanupTransport) Close() error                 { return nil }
81
82func TestGraphicsOutputReplaysStaticKittenIcatFixture(t *testing.T) {
83	state := newGraphicsOutputState()
84	snapshot := kittenGraphicsSnapshot(t)
85
86	first, err := state.prepare(snapshot, true)
87	require.NoError(t, err)
88	require.NotNil(t, first)
89	require.Contains(t, string(first.data), "\x1b_Ga=t,i=")
90	require.Contains(t, string(first.data), ",f=100")
91	require.Contains(t, string(first.data), "\x1b_Ga=p,i=")
92	require.Contains(t, string(first.data), ",q=2")
93	require.Less(t, recordIndex(first.data, "\x1b_Ga=t,"), recordIndex(first.data, "\x1b_Ga=p,"), "upload must precede placement")
94	outer := vt.NewScreen(80, 24)
95	outer.Write(first.data)
96	require.Equal(t, uint64(1), outer.GraphicsSnapshot().Usage().Assets)
97	require.Equal(t, uint64(1), outer.GraphicsSnapshot().Usage().Placements)
98	first.commit()
99
100	unchanged, err := state.prepare(snapshot, false)
101	require.NoError(t, err)
102	require.Empty(t, unchanged.data, "unchanged graphics must not enter ordinary text output")
103	unchanged.commit()
104
105	replay, err := state.prepare(snapshot, true)
106	require.NoError(t, err)
107	text := string(replay.data)
108	require.Less(t, strings.Index(text, "a=d,d=p,p="), strings.Index(text, "a=d,d=i,i="), "placement cleanup must precede image cleanup")
109	require.Less(t, strings.Index(text, "a=d,d=i,i="), strings.Index(text, "a=t,i="), "delete must precede replay upload")
110	require.Less(t, strings.Index(text, "a=t,i="), strings.Index(text, "a=p,i="), "upload must precede replay placement")
111}
112
113func TestKittyAttachmentPaintCarriesGraphicsInTheOutputStateRecord(t *testing.T) {
114	p, releasePTY := newBlockingPTY(t)
115	defer releasePTY()
116	d, sess, ac, sends := newManualSessionWithPTYs(t, p)
117	ac.terminalCapabilities.KittyGraphics = true
118	ac.output.graphicsOutput = newGraphicsOutputState()
119	fixture, err := os.ReadFile("testdata/kitten-icat-stream-chunk.bin")
120	require.NoError(t, err)
121	pane := sess.tabs[0].focusedPane()
122	// This fixture contains pixel-space Kitty output without cell metadata;
123	// provide the terminal pixel geometry explicitly so attachment clipping can
124	// map its placement safely.
125	geometry := domain.Geometry{Size: domain.Size{Cols: 80, Rows: 23}, PixelWidth: 800, PixelHeight: 460}
126	pane.geometry = geometry
127	setScreenGeometry(pane.screen, geometry)
128	pane.screen.Write(fixture)
129
130	d.paint(sess, ac, true, nil)
131	frame := awaitFrame(t, sends, "Output")
132	output := unmarshalTestOutput(t, frame.Payload)
133	require.Contains(t, string(output.Data), "\x1b_Ga=t,i=")
134	require.Contains(t, string(output.Data), ",f=100")
135	require.Contains(t, string(output.Data), "\x1b_Ga=p,i=")
136	require.Contains(t, string(output.Data), ",q=2")
137	require.Positive(t, output.New, "graphics must ride the ordinary state-bearing output")
138
139	outer := vt.NewScreen(output.Size.Cols, output.Size.Rows)
140	outer.Write(output.Data)
141	require.Equal(t, uint64(1), outer.GraphicsSnapshot().Usage().Assets)
142	require.Equal(t, uint64(1), outer.GraphicsSnapshot().Usage().Placements)
143}
144
145func TestFreshCapabilityDowngradePreservesForeignTerminalGraphics(t *testing.T) {
146	p, releasePTY := newBlockingPTY(t)
147	defer releasePTY()
148	d, sess, ac, sends := newManualSessionWithPTYs(t, p)
149	ac.terminalCapabilities.KittyGraphics = true
150	ac.output.graphicsOutput = newGraphicsOutputState()
151	ac.output.graphicsUnsupportedWarned.Store(true)
152	d.mu.Lock()
153	d.reconfigureAttachmentOutput(sess, ac, protocol.Hello{KittyDirectGraphics: false})
154	d.mu.Unlock()
155	require.False(t, ac.terminalCapabilities.SupportsKittyGraphics())
156	require.Nil(t, ac.output.graphicsOutput)
157	require.False(t, ac.output.graphicsUnsupportedWarned.Load(), "replacement terminal must receive its own unsupported-graphics warning")
158
159	d.paint(sess, ac, true, nil)
160	frame := awaitFrame(t, sends, "Output")
161	output := unmarshalTestOutput(t, frame.Payload)
162	require.Positive(t, output.New)
163	require.NotContains(t, string(output.Data), "\x1b_Ga=d,d=A", "vev must never globally delete terminal images")
164
165	outer := vt.NewScreen(80, 24)
166	foreign := newGraphicsOutputState()
167	foreignOutput, err := foreign.prepare(kittenGraphicsSnapshot(t), true)
168	require.NoError(t, err)
169	outer.Write(foreignOutput.data)
170	require.Equal(t, uint64(1), outer.GraphicsSnapshot().Usage().Placements)
171	outer.Write(output.Data)
172	require.Equal(t, uint64(1), outer.GraphicsSnapshot().Usage().Assets)
173	require.Equal(t, uint64(1), outer.GraphicsSnapshot().Usage().Placements)
174}
175
176func TestReplacementKittyTerminalGetsFreshGraphicsState(t *testing.T) {
177	d := newTestDaemon(t, nil, stubClock{})
178	sess := &session{sessionCore: sessionCore{id: "work"}}
179	ac := &attachedClient{clientID: [16]byte{1}, output: newOutputStateStream()}
180
181	d.mu.Lock()
182	oldBase, oldFence := d.reserveGraphicsNamespaceLeaseLocked(graphicsNamespaceKey(sess, ac.clientID))
183	oldState := newGraphicsOutputStateWithLease(oldBase, oldFence)
184	oldState.mayHaveEmitted = true
185	ac.output.graphicsOutput = oldState
186	ac.output.graphicsUnsupportedWarned.Store(true)
187	d.reconfigureAttachmentOutput(sess, ac, protocol.Hello{KittyDirectGraphics: true})
188	d.mu.Unlock()
189
190	require.True(t, ac.terminalCapabilities.SupportsKittyGraphics())
191	require.NotNil(t, ac.output.graphicsOutput)
192	require.NotSame(t, oldState, ac.output.graphicsOutput)
193	require.NotEqual(t, oldBase, ac.output.graphicsOutput.namespaceBase, "replacement terminal must not reuse terminal-global Kitty IDs")
194	require.False(t, ac.output.graphicsUnsupportedWarned.Load())
195}
196
197func TestPaneGraphicsCoordinateGeometrySurvivesAttachmentResize(t *testing.T) {
198	fixture, err := os.ReadFile("testdata/kitten-icat-stream-chunk.bin")
199	require.NoError(t, err)
200	p := newPane("pane", nil, domain.Size{Cols: 80, Rows: 24})
201	oldGeometry := domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}, PixelWidth: 800, PixelHeight: 480}
202	newGeometry := domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}, PixelWidth: 1600, PixelHeight: 960}
203
204	p.mu.Lock()
205	setScreenGeometry(p.screen, oldGeometry)
206	writePaneScreenLocked(p, fixture)
207	first := capturePaneRenderStateLockedInto(p, domain.Rect{Width: 80, Height: 24}, capturedPaneRenderState{})
208	setScreenGeometry(p.screen, newGeometry)
209	second := capturePaneRenderStateLockedInto(p, domain.Rect{Width: 80, Height: 24}, capturedPaneRenderState{})
210	p.mu.Unlock()
211
212	require.Equal(t, oldGeometry, first.graphicsGeometry)
213	require.Equal(t, oldGeometry, second.graphicsGeometry, "retained scene pixels must not be reinterpreted as the resized cell units")
214}
215
216func TestGraphicsOutputChunksLargeRGBUpload(t *testing.T) {
217	const width, height = 300, 300
218	raw := make([]byte, width*height*3)
219	state := uint32(1)
220	for i := range raw {
221		state = state*1664525 + 1013904223
222		raw[i] = byte(state >> 24)
223	}
224	scene := graphics.NewScene(graphics.Limits{})
225	asset, err := scene.AddAsset(graphics.AssetBlob{Encoded: raw, Format: graphics.AssetFormatRGB, Width: width, Height: height})
226	require.NoError(t, err)
227	_, err = scene.PlaceAsset(asset, graphics.PixelRect{Width: width, Height: height})
228	require.NoError(t, err)
229
230	prepared, err := newGraphicsOutputState().prepare(scene.Snapshot(), true)
231	require.NoError(t, err)
232	var uploadHeaders []string
233	for data := prepared.data; ; {
234		start := bytes.Index(data, []byte("\x1b_G"))
235		if start < 0 {
236			break
237		}
238		end := bytes.Index(data[start+3:], []byte("\x1b\\"))
239		require.NotEqual(t, -1, end)
240		body := data[start+3 : start+3+end]
241		header, payload, _ := bytes.Cut(body, []byte(";"))
242		if bytes.Contains(header, []byte("a=t")) {
243			uploadHeaders = append(uploadHeaders, string(header))
244			require.LessOrEqual(t, len(payload), graphicsUploadChunkBytes)
245		}
246		data = data[start+3+end+2:]
247	}
248	require.Greater(t, len(uploadHeaders), 1, "large RGB data must use Kitty continuation chunks")
249	require.Contains(t, uploadHeaders[0], "f=24")
250	require.Contains(t, uploadHeaders[0], "m=1")
251	require.Contains(t, uploadHeaders[len(uploadHeaders)-1], "m=0")
252}
253
254func TestGraphicsOutputCompressesLargeRGBUpload(t *testing.T) {
255	const width, height = 100, 100
256	raw := bytes.Repeat([]byte{0x7f}, width*height*3)
257	scene := graphics.NewScene(graphics.Limits{})
258	asset, err := scene.AddAsset(graphics.AssetBlob{Encoded: raw, Format: graphics.AssetFormatRGB, Width: width, Height: height})
259	require.NoError(t, err)
260	_, err = scene.PlaceAsset(asset, graphics.PixelRect{Width: width, Height: height})
261	require.NoError(t, err)
262
263	prepared, err := newGraphicsOutputState().prepare(scene.Snapshot(), true)
264	require.NoError(t, err)
265	require.Contains(t, string(prepared.data), ",f=24,s=100,v=100,o=z")
266}
267
268func TestGraphicsOutputRelaysLargeKittenIcatRGBWithinWireBudget(t *testing.T) {
269	const width, height = 2048, 1200
270	raw := make([]byte, width*height*3)
271	state := uint32(1)
272	for i := range raw {
273		state = state*1664525 + 1013904223
274		raw[i] = byte(state >> 24)
275	}
276	scene := graphics.NewScene(graphics.Limits{})
277	asset, err := scene.AddAsset(graphics.AssetBlob{Encoded: raw, Format: graphics.AssetFormatRGB, Width: width, Height: height})
278	require.NoError(t, err)
279	_, err = scene.PlaceAsset(asset, graphics.PixelRect{Width: width, Height: height})
280	require.NoError(t, err)
281
282	prepared, err := newGraphicsOutputState().prepare(scene.Snapshot(), true)
283	require.NoError(t, err)
284	require.Greater(t, len(prepared.data), 8<<20, "valid icat RGB replay must not be suppressed by the old graphics-only cap")
285	context := publicationTestContext()
286	context.Publication = 1
287	_, err = marshalOutputState(prepared.data, 1, 0, 1, 0, 0, domain.Size{Cols: 80, Rows: 24}, true, &context)
288	require.NoError(t, err, "the relayed graphics stream must fit the real Output wire budget")
289}
290
291func TestGraphicsOutputDefersCleanupThatDoesNotFitFrameBudget(t *testing.T) {
292	graphicsState := newGraphicsOutputState()
293	seed, err := graphicsState.prepare(kittenGraphicsSnapshot(t), true)
294	require.NoError(t, err)
295	seed.commit()
296	ac := &attachedClient{
297		output:               attachmentOutputWithGraphics(graphicsState),
298		terminalCapabilities: terminalcap.Capabilities{KittyGraphics: true},
299	}
300
301	deferred, err := graphicsOutputDataWithDaemonLimit(nil, &capturedRenderState{}, ac, false, 0)
302	require.NoError(t, err, "graphics cleanup must not abort an ANSI frame when no graphics budget remains")
303	require.Nil(t, deferred)
304	require.NotEmpty(t, graphicsState.assets, "required image deletes must remain pending for a later frame")
305	require.NotEmpty(t, graphicsState.placements, "required placement deletes must remain pending for a later frame")
306
307	cleanup, err := graphicsOutputDataWithDaemonLimit(nil, &capturedRenderState{}, ac, false, maxGraphicsOutputBytes)
308	require.NoError(t, err)
309	require.NotNil(t, cleanup)
310	require.Contains(t, string(cleanup.data), "a=d,d=p,p=")
311	require.Contains(t, string(cleanup.data), "a=d,d=i,i=")
312	cleanup.commit()
313	require.Empty(t, graphicsState.assets)
314	require.Empty(t, graphicsState.placements)
315}
316
317func TestGraphicsOutputIDsAreIsolatedAcrossAttachments(t *testing.T) {
318	snapshot := kittenGraphicsSnapshot(t)
319	left, right := newGraphicsOutputState(), newGraphicsOutputState()
320	leftPrepared, err := left.prepare(snapshot, true)
321	require.NoError(t, err)
322	rightPrepared, err := right.prepare(snapshot, true)
323	require.NoError(t, err)
324	var leftID, rightID uint64
325	for _, asset := range leftPrepared.state.assets {
326		leftID = asset.id
327		break
328	}
329	for _, asset := range rightPrepared.state.assets {
330		rightID = asset.id
331		break
332	}
333	require.NotZero(t, leftID)
334	require.NotZero(t, rightID)
335	require.NotEqual(t, leftID, rightID, "terminal-global image IDs must not be reused by another attachment")
336}
337
338func TestUnsupportedGraphicsWarningIsBoundedForLateSceneCreation(t *testing.T) {
339	pty, releasePTY := newBlockingPTY(t)
340	defer releasePTY()
341	d, sess, ac, _ := newManualSessionWithPTYs(t, pty)
342	ac.terminalCapabilities.KittyGraphics = false
343	fixture, err := os.ReadFile("testdata/kitten-icat-stream-chunk.bin")
344	require.NoError(t, err)
345	sess.tabs[0].focusedPane().screen.Write(fixture)
346
347	require.True(t, d.warnUnsupportedGraphics(ac), "late graphics creation should explain suppression")
348	require.False(t, d.warnUnsupportedGraphics(ac), "suppression warning must be bounded per attachment")
349	toasts, _ := visibleToasts(ac)
350	require.Len(t, toasts, 1)
351}
352
353func TestGraphicsNamespacesAreDeterministicAndCollisionSafe(t *testing.T) {
354	d := &Daemon{}
355	d.mu.Lock()
356	first := d.reserveGraphicsNamespaceLocked("session:work:attachment")
357	second := d.reserveGraphicsNamespaceLocked("session:work:attachment")
358	d.mu.Unlock()
359	require.NotZero(t, first)
360	require.NotZero(t, second)
361	require.NotEqual(t, first, second, "equal attachment/session keys still need a collision-safe namespace")
362	d.releaseGraphicsNamespace(first)
363	d.mu.Lock()
364	reused := d.reserveGraphicsNamespaceLocked("session:work:attachment")
365	d.mu.Unlock()
366	require.Equal(t, first, reused, "released namespaces should deterministically return to their preferred block")
367}
368
369func TestGraphicsOutputExhaustionResetsInsideItsNamespace(t *testing.T) {
370	base := graphicsIDNamespaceSize + 1
371	state := newGraphicsOutputStateWithBase(base)
372	state.nextID = base + graphicsIDNamespaceSize - 1
373	snapshot := kittenGraphicsSnapshot(t)
374	prepared, err := state.prepare(snapshot, false)
375	require.NoError(t, err, "namespace exhaustion must recover through an attachment-local replay")
376	require.NotNil(t, prepared)
377	limit := base + graphicsIDNamespaceSize - 1
378	for _, asset := range prepared.state.assets {
379		require.GreaterOrEqual(t, asset.id, base)
380		require.LessOrEqual(t, asset.id, limit)
381	}
382	for _, placement := range prepared.state.placements {
383		require.GreaterOrEqual(t, placement.id, base)
384		require.LessOrEqual(t, placement.id, limit)
385	}
386	require.Equal(t, base+2, prepared.state.nextID, "the replay should restart at the reserved block base")
387}
388
389func TestGraphicsNamespaceQuarantinesOnParkExpiryAndFailedCleanup(t *testing.T) {
390	d := newTestDaemon(t, nil, stubClock{})
391	base := graphicsIDNamespaceSize + 1
392	block := (base - 1) / graphicsIDNamespaceSize
393
394	parkedState := newGraphicsOutputStateWithBase(base)
395	parked := &parkedAttachment{
396		sess: &session{sessionCore: sessionCore{name: "parked"}},
397		ac:   &attachedClient{output: attachmentOutputWithGraphics(parkedState)},
398		done: make(chan struct{}),
399	}
400	d.mu.Lock()
401	d.graphicsNamespaces[block] = struct{}{}
402	d.resume.parked[1] = parked
403	d.mu.Unlock()
404	d.expireParked(1, parked)
405	require.Nil(t, parked.ac.output.graphicsOutput)
406	d.mu.Lock()
407	_, reserved := d.graphicsNamespaces[block]
408	d.mu.Unlock()
409	require.False(t, reserved, "park expiry with no outer objects may release the attachment namespace")
410
411	failedState := newGraphicsOutputStateWithBase(base)
412	seed, err := failedState.prepare(kittenGraphicsSnapshot(t), true)
413	require.NoError(t, err)
414	seed.commit()
415	failed := &attachedClient{
416		tr:     failingOutputTransport{},
417		output: attachmentOutputWithGraphics(failedState),
418	}
419	d.mu.Lock()
420	d.graphicsNamespaces[block] = struct{}{}
421	d.mu.Unlock()
422	require.Error(t, d.cleanupAttachmentOutput(failed), "failing transport must surface the cleanup send error")
423	require.Nil(t, failed.output.graphicsOutput)
424	d.mu.Lock()
425	_, reserved = d.graphicsNamespaces[block]
426	d.mu.Unlock()
427	require.True(t, reserved, "failed terminal cleanup must quarantine the namespace")
428}
429
430func TestGraphicsNamespaceStaysQuarantinedAfterSocketSendSuccessWithoutTerminalFlush(t *testing.T) {
431	d := newTestDaemon(t, nil, stubClock{})
432	key := "session:unflushed:attachment"
433	d.mu.Lock()
434	oldBase, oldFence := d.reserveGraphicsNamespaceLeaseLocked(key)
435	d.mu.Unlock()
436
437	state := newGraphicsOutputStateWithLease(oldBase, oldFence)
438	seed, err := state.prepare(kittenGraphicsSnapshot(t), true)
439	require.NoError(t, err)
440	seed.markSendAttempted()
441	seed.commit()
442
443	// This transport models only successful delivery to the client socket. It
444	// deliberately provides no evidence that the client flushed the side effect
445	// to its outer terminal before being lost.
446	transport := &closeTrackingTransport{}
447	ac := &attachedClient{tr: transport, output: attachmentOutputWithGraphics(state)}
448	require.NoError(t, d.cleanupAttachmentOutput(ac))
449	require.Nil(t, ac.output.graphicsOutput)
450	sends := transport.Sends()
451	require.Len(t, sends, 1)
452	cleanup := unmarshalTestOutput(t, sends[0].Payload)
453	require.Zero(t, cleanup.New, "graphics cleanup is an unacknowledged side-effect frame")
454
455	d.mu.Lock()
456	_, quarantined := d.graphicsNamespaceQuarantines[(oldBase-1)/graphicsIDNamespaceSize]
457	newBase, _ := d.reserveGraphicsNamespaceLeaseLocked(key)
458	d.mu.Unlock()
459	require.True(t, quarantined, "socket-send success cannot prove terminal cleanup")
460	require.NotEqual(t, oldBase, newBase, "an unflushed cleanup must not expose the old IDs to another attachment")
461}
462
463func TestGraphicsNamespaceStaysQuarantinedAfterFinalDeleteAndParkExpiry(t *testing.T) {
464	d := newTestDaemon(t, nil, stubClock{})
465	key := "session:empty-park:attachment"
466	d.mu.Lock()
467	oldBase, oldFence := d.reserveGraphicsNamespaceLeaseLocked(key)
468	d.mu.Unlock()
469
470	state := newGraphicsOutputStateWithLease(oldBase, oldFence)
471	seed, err := state.prepare(kittenGraphicsSnapshot(t), true)
472	require.NoError(t, err)
473	seed.markSendAttempted()
474	seed.commit()
475	finalDelete, err := state.prepare(nil, false)
476	require.NoError(t, err)
477	require.NotEmpty(t, finalDelete.data)
478	finalDelete.markSendAttempted()
479	finalDelete.commit()
480	require.Empty(t, state.assets)
481	require.Empty(t, state.placements)
482
483	parked := &parkedAttachment{
484		sess: &session{sessionCore: sessionCore{name: "parked"}},
485		ac:   &attachedClient{output: attachmentOutputWithGraphics(state)},
486		done: make(chan struct{}),
487	}
488	d.mu.Lock()
489	d.resume.parked[1] = parked
490	d.mu.Unlock()
491	d.expireParked(1, parked)
492
493	d.mu.Lock()
494	_, quarantined := d.graphicsNamespaceQuarantines[(oldBase-1)/graphicsIDNamespaceSize]
495	newBase, _ := d.reserveGraphicsNamespaceLeaseLocked(key)
496	d.mu.Unlock()
497	require.True(t, quarantined, "an apparently empty parked state still has unacknowledged terminal history")
498	require.NotEqual(t, oldBase, newBase, "park expiry must not recycle IDs after the final delete")
499}
500
501func TestGraphicsNamespacePoolExhaustionFallsBackToText(t *testing.T) {
502	d := newTestDaemon(t, nil, stubClock{})
503	d.mu.Lock()
504	for block := uint64(0); block < graphicsIDNamespaceCount; block++ {
505		d.graphicsNamespaces[block] = struct{}{}
506	}
507	ac := d.prepareAttachedClientLocked(
508		&session{sessionCore: sessionCore{id: "work"}},
509		&closeTrackingTransport{},
510		domain.Geometry{Size: defaultSize},
511		attachClientOptions{
512			capabilitiesSet:      true,
513			terminalCapabilities: terminalcap.Capabilities{KittyGraphics: true, Color: terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor}},
514		},
515	)
516	d.mu.Unlock()
517
518	require.Nil(t, ac.output.graphicsOutput)
519	require.False(t, ac.terminalCapabilities.SupportsKittyGraphics())
520	require.NotNil(t, ac.output, "namespace exhaustion must preserve the ordinary text renderer")
521}
522
523func TestGraphicsNamespaceQuarantineFencesTimedOutLateDelete(t *testing.T) {
524	clock := &signalClock{timers: make(chan *signalTimer, 1)}
525	d := newTestDaemon(t, nil, clock)
526	key := "session:stale:attachment"
527	d.mu.Lock()
528	oldBase, oldFence := d.reserveGraphicsNamespaceLeaseLocked(key)
529	d.mu.Unlock()
530	oldState := newGraphicsOutputStateWithLease(oldBase, oldFence)
531	seed, err := oldState.prepare(kittenGraphicsSnapshot(t), true)
532	require.NoError(t, err)
533	seed.commit()
534	var oldID uint64
535	for _, asset := range oldState.assets {
536		oldID = asset.id
537		break
538	}
539	require.NotZero(t, oldID)
540
541	transport := newLateGraphicsCleanupTransport()
542	ac := &attachedClient{tr: transport, output: attachmentOutputWithGraphics(oldState)}
543	cleanupDone := make(chan struct{})
544	go func() {
545		_ = d.cleanupAttachmentOutput(ac)
546		close(cleanupDone)
547	}()
548	awaitTestCompletion(t, transport.started, "timed-out cleanup did not reach the stale transport")
549	timer := awaitTestValue(t, clock.timers, "cleanup watchdog timer was not armed")
550	timer.ch <- time.Time{}
551	awaitTestCompletion(t, cleanupDone, "timed-out cleanup did not retire the attachment")
552
553	d.mu.Lock()
554	newBase, newFence := d.reserveGraphicsNamespaceLeaseLocked(key)
555	_, quarantined := d.graphicsNamespaceQuarantines[(oldBase-1)/graphicsIDNamespaceSize]
556	d.mu.Unlock()
557	require.True(t, quarantined, "timed-out cleanup must keep its namespace quarantined")
558	require.NotEqual(t, oldBase, newBase, "new allocation must skip the quarantined namespace")
559
560	newState := newGraphicsOutputStateWithLease(newBase, newFence)
561	fresh, err := newState.prepare(kittenGraphicsSnapshot(t), true)
562	require.NoError(t, err)
563	var newID uint64
564	for _, asset := range fresh.state.assets {
565		newID = asset.id
566		break
567	}
568	require.NotZero(t, newID)
569	require.NotEqual(t, oldID, newID, "fresh attachment IDs must not share the quarantined block")
570
571	close(transport.release)
572	staleFrame := awaitTestValue(t, transport.sends, "late stale cleanup did not execute")
573	awaitTestCompletion(t, transport.finished, "late stale cleanup did not return")
574	staleOutput := unmarshalTestOutput(t, staleFrame.Payload)
575	require.Contains(t, string(staleOutput.Data), "i="+strconv.FormatUint(oldID, 10))
576	require.NotContains(t, string(staleOutput.Data), "i="+strconv.FormatUint(newID, 10), "late cleanup must never target the fresh attachment")
577	d.mu.Lock()
578	_, retained := d.graphicsNamespaceQuarantines[(oldBase-1)/graphicsIDNamespaceSize]
579	d.mu.Unlock()
580	require.True(t, retained, "late transport success is not a terminal ACK and must not release the namespace")
581}
582
583func TestGraphicsOutputKeysIncludeSourceSceneContent(t *testing.T) {
584	makeSnapshot := func(data []byte) *graphics.Snapshot {
585		scene := graphics.NewScene(graphics.Limits{})
586		asset, err := scene.AddAsset(graphics.AssetBlob{Encoded: data, Width: 1, Height: 1})
587		require.NoError(t, err)
588		_, err = scene.PlaceAsset(asset, graphics.PixelRect{Width: 1, Height: 1})
589		require.NoError(t, err)
590		return scene.Snapshot()
591	}
592	state := newGraphicsOutputState()
593	first, err := state.prepare(makeSnapshot([]byte("first")), true)
594	require.NoError(t, err)
595	first.commit()
596	second, err := state.prepare(makeSnapshot([]byte("second")), false)
597	require.NoError(t, err)
598	require.Contains(t, string(second.data), "a=t,i=", "a new a1/p1 scene must not alias the stale outer image")
599}
600
601func TestGraphicsOutputPreSendAbortDoesNotCreateCleanupRecords(t *testing.T) {
602	state := newGraphicsOutputState()
603	prepared, err := state.prepare(kittenGraphicsSnapshot(t), true)
604	require.NoError(t, err)
605	prepared.abort()
606
607	cleanup, err := state.prepare(nil, true)
608	require.NoError(t, err)
609	require.Empty(t, cleanup.data, "IDs allocated by a preparation that never reached the transport are not owned by the terminal")
610}
611
612func TestGraphicsOutputOversizeSuppressesGraphicsWithoutCompositionError(t *testing.T) {
613	scene := graphics.NewScene(graphics.Limits{})
614	asset, err := scene.AddAsset(graphics.AssetBlob{Encoded: bytes.Repeat([]byte{'x'}, maxGraphicsOutputBytes), Width: 1, Height: 1})
615	require.NoError(t, err)
616	_, err = scene.PlaceAsset(asset, graphics.PixelRect{Width: 1, Height: 1})
617	require.NoError(t, err)
618	state := &capturedRenderState{panes: []capturedPaneRenderState{{
619		graphics:  scene.Snapshot(),
620		placement: layout.Placement{Content: domain.Rect{Width: 1, Height: 1}},
621	}}}
622	ac := &attachedClient{
623		output:               attachmentOutputWithGraphics(newGraphicsOutputState()),
624		terminalCapabilities: terminalcap.Capabilities{KittyGraphics: true},
625	}
626	prepared, err := graphicsOutputData(state, ac, true)
627	require.NoError(t, err, "ANSI composition must continue when optional graphics exceed the bound")
628	require.Empty(t, prepared.data, "oversized graphics are suppressed")
629}
630
631func TestGraphicsOutputClipsPlacementToPaneContent(t *testing.T) {
632	scene := graphics.NewScene(graphics.Limits{})
633	asset, err := scene.AddAsset(graphics.AssetBlob{Encoded: []byte("asset"), Width: 10, Height: 10})
634	require.NoError(t, err)
635	_, err = scene.PlaceAsset(asset, graphics.PixelRect{X: -2, Y: -1, Width: 10, Height: 10})
636	require.NoError(t, err)
637	state := &capturedRenderState{panes: []capturedPaneRenderState{{
638		graphics:         scene.Snapshot(),
639		graphicsGeometry: domain.Geometry{Size: domain.Size{Cols: 4, Rows: 3}, PixelWidth: 4, PixelHeight: 3},
640		placement:        layout.Placement{Content: domain.Rect{X: 3, Y: 4, Width: 4, Height: 3}},
641	}}}
642	ac := &attachedClient{
643		output:               attachmentOutputWithGraphics(newGraphicsOutputState()),
644		terminalCapabilities: terminalcap.Capabilities{KittyGraphics: true},
645	}
646	prepared, err := graphicsOutputData(state, ac, true)
647	require.NoError(t, err)
648	text := string(prepared.data)
649	require.Contains(t, text, "\x1b[6;4H", "placement must move to the clipped pane-local destination")
650	require.Contains(t, text, ",x=2,y=1,w=4,h=3", "lowercase coordinates must preserve the source crop")
651	require.NotContains(t, text, ",X=2,Y=1", "source coordinates must not become destination offsets")
652}
653
654func TestGraphicsOutputClipsEachAttachmentToOuterContentFrame(t *testing.T) {
655	scene := graphics.NewScene(graphics.Limits{})
656	asset, err := scene.AddAsset(graphics.AssetBlob{Encoded: []byte("attachment-frame"), Width: 80, Height: 24})
657	require.NoError(t, err)
658	_, err = scene.Place(graphics.PlacementSpec{
659		Asset:       asset,
660		Destination: graphics.PixelRect{Width: 800, Height: 480},
661		Cells:       graphics.CellRect{Width: 80, Height: 24},
662	})
663	require.NoError(t, err)
664
665	makeState := func(window domain.Size) *capturedRenderState {
666		return &capturedRenderState{
667			window: window,
668			layout: capturedTabLayout{area: domain.Rect{Width: 80, Height: 24}, valid: true},
669			panes: []capturedPaneRenderState{{
670				id:               "pane",
671				graphics:         scene.Snapshot(),
672				graphicsGeometry: domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}, PixelWidth: 800, PixelHeight: 480},
673				placement:        layout.Placement{ID: "pane", Content: domain.Rect{Width: 80, Height: 24}},
674			}},
675		}
676	}
677
678	small, err := graphicsOutputData(makeState(domain.Size{Cols: 40, Rows: 10}), graphicsPaneTestClient(), true)
679	require.NoError(t, err)
680	require.Len(t, small.state.placements, 1)
681	for _, placement := range small.state.placements {
682		require.Equal(t, graphics.PixelRect{Width: 400, Height: 160}, placement.dest, "small attachment must clip pixels to its content frame")
683		require.Equal(t, graphics.CellRect{Width: 40, Height: 8}, placement.cells, "status bar row must stay outside the graphics cells")
684	}
685	require.Contains(t, string(small.data), ",c=40,r=8")
686	require.NotContains(t, string(small.data), ",c=80,r=24")
687
688	large, err := graphicsOutputData(makeState(domain.Size{Cols: 100, Rows: 30}), graphicsPaneTestClient(), true)
689	require.NoError(t, err)
690	require.Len(t, large.state.placements, 1)
691	for _, placement := range large.state.placements {
692		require.Equal(t, graphics.PixelRect{Width: 800, Height: 480}, placement.dest, "a larger attachment must retain the pane portion it can display")
693	}
694	require.Contains(t, string(large.data), ",c=80,r=24")
695}
696
697func TestGraphicsOutputCellOnlyViewportPreservesSourceMapping(t *testing.T) {
698	scene := graphics.NewScene(graphics.Limits{})
699	asset, err := scene.AddAsset(graphics.AssetBlob{Encoded: []byte("cell-only"), Width: 100, Height: 50})
700	require.NoError(t, err)
701	_, err = scene.Place(graphics.PlacementSpec{
702		Asset:       asset,
703		Destination: graphics.PixelRect{X: 10, Y: 20, Width: 200, Height: 100},
704		Cells:       graphics.CellRect{X: 2, Y: 3, Width: 20, Height: 10},
705	})
706	require.NoError(t, err)
707
708	viewport, ok := graphicsOutputViewportRect(domain.Rect{X: 7, Y: 5, Width: 5, Height: 5}, domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}})
709	require.True(t, ok)
710	require.Equal(t, graphics.PixelRect{}, viewport.Pixels, "cell-only geometry must not become a pixel viewport")
711	require.Equal(t, graphics.CellRect{X: 7, Y: 5, Width: 5, Height: 5}, viewport.Cells)
712
713	_, placements := graphicsSnapshotRecords(scene.Snapshot(), viewport)
714	require.Len(t, placements, 1)
715	for _, placement := range placements {
716		require.Equal(t, graphics.PixelRect{X: 25, Y: 10, Width: 25, Height: 25}, placement.source)
717		require.Equal(t, graphics.PixelRect{X: 60, Y: 40, Width: 50, Height: 50}, placement.dest)
718	}
719
720	pixelOnly := graphics.NewScene(graphics.Limits{})
721	pixelAsset, err := pixelOnly.AddAsset(graphics.AssetBlob{Encoded: []byte("pixel-only"), Width: 10, Height: 10})
722	require.NoError(t, err)
723	_, err = pixelOnly.PlaceAsset(pixelAsset, graphics.PixelRect{Width: 10, Height: 10})
724	require.NoError(t, err)
725	_, unsuitable := graphicsSnapshotRecords(pixelOnly.Snapshot(), viewport)
726	require.Empty(t, unsuitable, "pixel-space fragments without cell metadata must be suppressed")
727}
728
729func TestGraphicsOutputComposesPaneOriginAndPixelGeometry(t *testing.T) {
730	placement := graphicsOutputPlacement{
731		id:     1,
732		source: graphics.PixelRect{Width: 20, Height: 10},
733		dest:   graphics.PixelRect{X: 103, Y: 50, Width: 20, Height: 10},
734		cells:  graphics.CellRect{Width: 2, Height: 1},
735	}
736	record := placementRecordWithTransform(1, placement, graphicsOutputTransform{
737		originX:        3,
738		originY:        2,
739		sourceGeometry: domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}, PixelWidth: 800, PixelHeight: 480},
740	})
741	text := string(record)
742	require.Contains(t, text, "\x1b7\x1b[5;14H", "destination cells must be expressed through cursor position")
743	require.Contains(t, text, ",X=3,Y=10", "destination pixel remainder must use uppercase offsets")
744	require.NotContains(t, text, ",x=13,y=4", "destination cells must never become source crop controls")
745	require.Contains(t, text, ",C=1", "placement must not move or scroll the text cursor")
746	require.True(t, strings.HasSuffix(text, "\x1b8"), "placement must restore the outer text cursor")
747}
748
749func TestGraphicsOutputPreservesKittenIcatCenteredPlacement(t *testing.T) {
750	placement := graphicsOutputPlacement{
751		id:     1,
752		source: graphics.PixelRect{Width: 103, Height: 128},
753		dest:   graphics.PixelRect{X: 343, Width: 103, Height: 128},
754	}
755	record := placementRecordWithTransform(1, placement, graphicsOutputTransform{
756		originY:        1,
757		sourceGeometry: domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}, PixelWidth: 800, PixelHeight: 480},
758	})
759	text := string(record)
760	require.Contains(t, text, "\x1b[2;35H", "icat's centered cursor column must survive composition")
761	require.Contains(t, text, ",w=103,h=128", "natural image dimensions must be preserved")
762	require.Contains(t, text, ",X=3", "sub-cell destination offset must be preserved")
763	require.NotContains(t, text, ",x=34", "center position must not crop the image source")
764}
765
766func TestGraphicsOutputFailedSendRetainsSpeculativeIDsForCleanup(t *testing.T) {
767	state := newGraphicsOutputState()
768	first, err := state.prepare(kittenGraphicsSnapshot(t), true)
769	require.NoError(t, err)
770	first.markSendAttempted()
771	first.abort()
772
773	retry, err := state.prepare(kittenGraphicsSnapshot(t), true)
774	require.NoError(t, err)
775	text := string(retry.data)
776	require.Less(t, strings.Index(text, "a=d,d=p,p="), strings.Index(text, "a=d,d=i,i="))
777	require.Less(t, strings.Index(text, "a=d,d=i,i="), strings.Index(text, "a=t,i="))
778	require.Contains(t, text, "a=p,i=")
779}
780
781func TestGraphicsOutputCleanupRemovesPlacementsBeforeImages(t *testing.T) {
782	state := newGraphicsOutputState()
783	snapshot := kittenGraphicsSnapshot(t)
784	prepared, err := state.prepare(snapshot, true)
785	require.NoError(t, err)
786	prepared.commit()
787
788	// A nil snapshot models the attachment losing its visible graphics scene.
789	cleared, err := state.prepare(nil, false)
790	require.NoError(t, err)
791	require.Less(t, strings.Index(string(cleared.data), "a=d,d=p,p="), strings.Index(string(cleared.data), "a=d,d=i,i="))
792	cleared.commit()
793	require.Empty(t, state.assets)
794	require.Empty(t, state.placements)
795}
796
797func graphicsPaneTestScene(t *testing.T, data string, destination graphics.PixelRect, layer int64) *graphics.Snapshot {
798	t.Helper()
799	scene := graphics.NewScene(graphics.Limits{})
800	asset, err := scene.AddAsset(graphics.AssetBlob{Encoded: []byte(data), Width: 20, Height: 10})
801	require.NoError(t, err)
802	_, err = scene.Place(graphics.PlacementSpec{Asset: asset, Destination: destination, Layer: layer, HasLayer: true})
803	require.NoError(t, err)
804	return scene.Snapshot()
805}
806
807func graphicsPaneTestState(tab domain.TabStableID, panes ...capturedPaneRenderState) *capturedRenderState {
808	for i := range panes {
809		if panes[i].graphicsGeometry.PixelsKnown() || panes[i].placement.Content.Width <= 0 || panes[i].placement.Content.Height <= 0 {
810			continue
811		}
812		content := panes[i].placement.Content
813		panes[i].graphicsGeometry = domain.Geometry{
814			Size:        domain.Size{Cols: content.Width, Rows: content.Height},
815			PixelWidth:  content.Width,
816			PixelHeight: content.Height,
817		}
818	}
819	return &capturedRenderState{view: attachmentView{tabID: tab}, panes: panes}
820}
821
822func graphicsPaneTestClient() *attachedClient {
823	return &attachedClient{
824		output:               attachmentOutputWithGraphics(newGraphicsOutputState()),
825		terminalCapabilities: terminalcap.Capabilities{KittyGraphics: true},
826	}
827}
828
829func TestGraphicsOutputComposesSplitPanesWithStableOrderAndChildIDIsolation(t *testing.T) {
830	left := graphicsPaneTestScene(t, "left", graphics.PixelRect{X: -2, Y: 0, Width: 8, Height: 4}, 2)
831	right := graphicsPaneTestScene(t, "right", graphics.PixelRect{X: 1, Y: 0, Width: 8, Height: 4}, -1)
832	state := graphicsPaneTestState("tab-a",
833		capturedPaneRenderState{id: "left", graphics: left, placement: layout.Placement{ID: "left", Content: domain.Rect{X: 0, Width: 20, Height: 4}}},
834		capturedPaneRenderState{id: "right", graphics: right, placement: layout.Placement{ID: "right", Content: domain.Rect{X: 21, Width: 20, Height: 4}}},
835	)
836	ac := graphicsPaneTestClient()
837	prepared, err := graphicsOutputData(state, ac, true)
838	require.NoError(t, err)
839	require.Len(t, prepared.state.assets, 2, "raw a1 IDs from separate child scenes must not alias")
840	require.Len(t, prepared.state.placements, 2)
841	text := string(prepared.data)
842	require.Less(t, strings.Index(text, "\x1b[2;1H"), strings.Index(text, "\x1b[2;23H"), "pane traversal order must be deterministic")
843	var ids []uint64
844	for _, asset := range prepared.state.assets {
845		ids = append(ids, asset.id)
846	}
847	require.NotEqual(t, ids[0], ids[1])
848}
849
850func TestGraphicsOutputSplitResizeReplacesTranslatedPlacementWithoutSceneMutation(t *testing.T) {
851	snapshot := graphicsPaneTestScene(t, "resize", graphics.PixelRect{Width: 8, Height: 4}, 0)
852	ac := graphicsPaneTestClient()
853	firstState := graphicsPaneTestState("tab-a", capturedPaneRenderState{id: "left", graphics: snapshot, placement: layout.Placement{ID: "left", Content: domain.Rect{Width: 20, Height: 4}}})
854	first, err := graphicsOutputData(firstState, ac, true)
855	require.NoError(t, err)
856	first.commit()
857
858	resized := graphicsPaneTestState("tab-a", capturedPaneRenderState{id: "left", graphics: snapshot, placement: layout.Placement{ID: "left", Content: domain.Rect{X: 7, Width: 20, Height: 4}}})
859	second, err := graphicsOutputData(resized, ac, false)
860	require.NoError(t, err)
861	require.Contains(t, string(second.data), "a=d,d=p,p=")
862	require.Contains(t, string(second.data), "a=p,i=")
863	require.NotContains(t, string(second.data), "a=t,i=", "layout-only recomputation must retain the immutable asset")
864}
865
866func TestGraphicsOutputPaneReorderRetainsAssetsButReplaysPlacementsInLayoutOrder(t *testing.T) {
867	left := graphicsPaneTestScene(t, "left-reorder", graphics.PixelRect{Width: 8, Height: 4}, 0)
868	right := graphicsPaneTestScene(t, "right-reorder", graphics.PixelRect{Width: 8, Height: 4}, 0)
869	ac := graphicsPaneTestClient()
870	firstState := graphicsPaneTestState("tab-a",
871		capturedPaneRenderState{id: "left", graphics: left, placement: layout.Placement{ID: "left", Content: domain.Rect{Width: 20, Height: 4}}},
872		capturedPaneRenderState{id: "right", graphics: right, placement: layout.Placement{ID: "right", Content: domain.Rect{X: 21, Width: 20, Height: 4}}},
873	)
874	first, err := graphicsOutputData(firstState, ac, true)
875	require.NoError(t, err)
876	first.commit()
877
878	reordered := graphicsPaneTestState("tab-a",
879		capturedPaneRenderState{id: "right", graphics: right, placement: layout.Placement{ID: "right", Content: domain.Rect{Width: 20, Height: 4}}},
880		capturedPaneRenderState{id: "left", graphics: left, placement: layout.Placement{ID: "left", Content: domain.Rect{X: 21, Width: 20, Height: 4}}},
881	)
882	second, err := graphicsOutputData(reordered, ac, false)
883	require.NoError(t, err)
884	require.NotContains(t, string(second.data), "a=t,i=", "moving panes must not re-upload immutable assets")
885	require.Contains(t, string(second.data), "a=d,d=p,p=")
886	require.Contains(t, string(second.data), "a=p,i=")
887	require.Less(t, strings.Index(string(second.data), "\x1b[2;1H"), strings.Index(string(second.data), "\x1b[2;22H"))
888}
889
890func TestGraphicsOutputTabSwitchReplaysSameRawChildIDsInNewSourceNamespace(t *testing.T) {
891	snapshot := graphicsPaneTestScene(t, "tab", graphics.PixelRect{Width: 8, Height: 4}, 0)
892	ac := graphicsPaneTestClient()
893	first, err := graphicsOutputData(graphicsPaneTestState("tab-a", capturedPaneRenderState{id: "pane", graphics: snapshot, placement: layout.Placement{ID: "pane", Content: domain.Rect{Width: 20, Height: 4}}}), ac, true)
894	require.NoError(t, err)
895	first.commit()
896	second, err := graphicsOutputData(graphicsPaneTestState("tab-b", capturedPaneRenderState{id: "pane", graphics: snapshot, placement: layout.Placement{ID: "pane", Content: domain.Rect{Width: 20, Height: 4}}}), ac, false)
897	require.NoError(t, err)
898	require.Contains(t, string(second.data), "a=d,d=p,p=")
899	require.Contains(t, string(second.data), "a=d,d=i,i=")
900	require.Contains(t, string(second.data), "a=t,i=")
901}
902
903func TestGraphicsOutputFloatingPaneCoversTiledGraphicsAndRetainsItsOwnScene(t *testing.T) {
904	tiled := graphicsPaneTestScene(t, "tiled", graphics.PixelRect{Width: 20, Height: 6}, 0)
905	floating := graphicsPaneTestScene(t, "floating", graphics.PixelRect{Width: 3, Height: 2}, 3)
906	state := graphicsPaneTestState("tab-a", capturedPaneRenderState{id: "tiled", graphics: tiled, placement: layout.Placement{ID: "tiled", Content: domain.Rect{Width: 20, Height: 6}}})
907	state.floating = capturedFloatingRenderState{
908		visible: true,
909		pane: capturedPaneRenderState{id: "floating", graphics: floating, graphicsGeometry: domain.Geometry{
910			Size: domain.Size{Cols: 3, Rows: 2}, PixelWidth: 3, PixelHeight: 2,
911		}},
912		geometry: floatingGeometry{Bounds: domain.Rect{X: 5, Y: 1, Width: 6, Height: 4}, Inner: domain.Rect{X: 6, Y: 2, Width: 3, Height: 2}},
913	}
914	ac := graphicsPaneTestClient()
915	prepared, err := graphicsOutputData(state, ac, true)
916	require.NoError(t, err)
917	require.Len(t, prepared.state.assets, 2)
918	require.GreaterOrEqual(t, len(prepared.state.placements), 2, "floating and uncovered tiled fragments both remain visible")
919	for _, placement := range prepared.state.placements {
920		if strings.Contains(placement.asset, "floating") {
921			continue
922		}
923		cover := graphics.PixelRect{X: 5, Y: 1, Width: 6, Height: 4}
924		_, overlaps := placement.dest.Intersect(cover)
925		require.False(t, overlaps, "tiled placement must not survive under floating coverage")
926	}
927}
928
929func TestGraphicsOutputOverlayDismissalHidesAndRestoresImmutableSnapshots(t *testing.T) {
930	snapshot := graphicsPaneTestScene(t, "overlay", graphics.PixelRect{Width: 8, Height: 4}, 0)
931	ac := graphicsPaneTestClient()
932	state := graphicsPaneTestState("tab-a", capturedPaneRenderState{id: "pane", graphics: snapshot, placement: layout.Placement{ID: "pane", Content: domain.Rect{Width: 20, Height: 4}}})
933	first, err := graphicsOutputData(state, ac, true)
934	require.NoError(t, err)
935	first.commit()
936
937	state.overlays.copyActive = true
938	hidden, err := graphicsOutputData(state, ac, true)
939	require.NoError(t, err)
940	require.Contains(t, string(hidden.data), "a=d,d=p,p=")
941	hidden.commit()
942	state.overlays.copyActive = false
943	restored, err := graphicsOutputData(state, ac, false)
944	require.NoError(t, err)
945	require.Contains(t, string(restored.data), "a=t,i=")
946	require.Equal(t, uint64(1), snapshot.Usage().Assets, "composition must not mutate the child scene")
947}
948
949func TestGraphicsOutputMixedTextAndGraphicsKeepsANSIBeforeGraphics(t *testing.T) {
950	snapshot := graphicsPaneTestScene(t, "mixed", graphics.PixelRect{Width: 8, Height: 4}, 0)
951	ac := graphicsPaneTestClient()
952	state := graphicsPaneTestState("tab-a", capturedPaneRenderState{id: "pane", graphics: snapshot, placement: layout.Placement{ID: "pane", Content: domain.Rect{Width: 20, Height: 4}}})
953	prepared, err := graphicsOutputData(state, ac, true)
954	require.NoError(t, err)
955	ansi := []byte("text-before-graphics")
956	combined := append(append([]byte(nil), ansi...), prepared.data...)
957	require.Less(t, bytes.Index(combined, ansi), bytes.Index(combined, []byte("\x1b_Ga=t,")))
958}
959
960func TestGraphicsOutputCollapsedAndHiddenPanesDoNotUploadRetainedAssets(t *testing.T) {
961	snapshot := graphicsPaneTestScene(t, "collapsed", graphics.PixelRect{Width: 8, Height: 4}, 0)
962	ac := graphicsPaneTestClient()
963	state := graphicsPaneTestState("tab-a", capturedPaneRenderState{
964		id:        "pane",
965		graphics:  snapshot,
966		placement: layout.Placement{ID: "pane", Content: domain.Rect{Width: 20, Height: 4}},
967	})
968	first, err := graphicsOutputData(state, ac, true)
969	require.NoError(t, err)
970	first.commit()
971
972	state.panes[0].placement.Collapsed = true
973	collapsed, err := graphicsOutputData(state, ac, false)
974	require.NoError(t, err)
975	require.Empty(t, collapsed.state.assets)
976	require.Empty(t, collapsed.state.placements)
977	require.Contains(t, string(collapsed.data), "a=d,d=p,p=")
978	collapsed.commit()
979
980	state.panes = nil
981	hidden, err := graphicsOutputData(state, ac, false)
982	require.NoError(t, err)
983	require.Empty(t, hidden.data, "a collapsed scene was already retired")
984	hidden.commit()
985}
986
987func TestGraphicsOutputViewportProjectionDropsUnplacedAssets(t *testing.T) {
988	scene := graphics.NewScene(graphics.Limits{})
989	placed, err := scene.AddAsset(graphics.AssetBlob{Encoded: []byte("placed"), Width: 4, Height: 2})
990	require.NoError(t, err)
991	_, err = scene.AddAsset(graphics.AssetBlob{Encoded: []byte("retained"), Width: 4, Height: 2})
992	require.NoError(t, err)
993	_, err = scene.PlaceAsset(placed, graphics.PixelRect{Width: 4, Height: 2})
994	require.NoError(t, err)
995
996	state := graphicsPaneTestState("tab-a", capturedPaneRenderState{
997		id:        "pane",
998		graphics:  scene.Snapshot(),
999		placement: layout.Placement{ID: "pane", Content: domain.Rect{Width: 8, Height: 4}},
1000	})
1001	ac := graphicsPaneTestClient()
1002	prepared, err := graphicsOutputData(state, ac, true)
1003	require.NoError(t, err)
1004	require.Len(t, prepared.state.assets, 1, "viewport projection must not upload an unplaced retained image")
1005	require.Len(t, prepared.state.placements, 1)
1006}

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