graphics_output.go RawHistoryFolder

1package daemon
2
3import (
4	"bytes"
5	"compress/zlib"
6	"crypto/sha256"
7	"encoding/base64"
8	"errors"
9	"fmt"
10	"sort"
11	"strconv"
12	"sync/atomic"
13
14	renderer "github.com/bnema/vev-vt"
15	"github.com/bnema/vev-vt/graphics"
16	"github.com/bnema/vev/internal/domain"
17	"github.com/bnema/vev/internal/domain/layout"
18	"github.com/bnema/vev/internal/protocol"
19	themeui "github.com/bnema/vev/internal/usecase/theme"
20)
21
22// Graphics output is deliberately a small attachment-local backend. It emits
23// direct uploads and ordinary placements from immutable pane snapshots. The
24// attachment-local state is also the ownership boundary for Kitty IDs: raw
25// scene IDs are never sent to the outer terminal.
26const (
27	maxGraphicsRecordBytes   = protocol.MaxOutputDataLen
28	maxGraphicsOutputBytes   = protocol.MaxOutputDataLen
29	graphicsUploadChunkBytes = 128 << 10
30)
31
32var (
33	errGraphicsOutputTooLarge = errors.New("kitty graphics output exceeds bound")
34	errGraphicsIDExhausted    = errors.New("kitty graphics output ID exhausted")
35)
36
37type graphicsOutputAsset struct {
38	id uint64
39}
40
41type graphicsOutputPlacement struct {
42	id     uint64
43	asset  string
44	source graphics.PixelRect
45	dest   graphics.PixelRect
46	cells  graphics.CellRect
47	layer  int64
48	// transform is set for composed multi-pane placements. Keeping it on the
49	// placement (rather than on graphicsOutputState) lets panes with distinct
50	// pixel geometries share one attachment-local output state.
51	transform    graphicsOutputTransform
52	transformSet bool
53}
54
55type graphicsOutputState struct {
56	assets        map[string]graphicsOutputAsset
57	placements    map[string]graphicsOutputPlacement
58	pendingImages map[uint64]struct{}
59	pendingPlaces map[uint64]struct{}
60	// nextID is a per-attachment cursor in Kitty's terminal-global 32-bit ID
61	// namespace. Image and placement IDs share the cursor so neither kind can
62	// collide within an attachment. namespaceBase is reserved by the daemon for
63	// the attachment/session until cleanup has retired its outer objects.
64	nextID         uint64
65	namespaceBase  uint64
66	namespaceFence uint64
67	// mayHaveEmitted is sticky once a Kitty record reaches the transport
68	// boundary. Output side effects are not terminal-ACKed, so neither a
69	// successful socket send nor an empty committed scene can make this
70	// namespace safe for another attachment.
71	mayHaveEmitted bool
72	transform      graphicsOutputTransform
73	transformSet   bool
74}
75
76type preparedGraphicsOutput struct {
77	owner         *graphicsOutputState
78	state         graphicsOutputState
79	data          []byte
80	maxBytes      int
81	valid         bool
82	sendAttempted bool
83}
84
85type graphicsOutputTransform struct {
86	originX, originY int
87	sourceGeometry   domain.Geometry
88}
89
90const (
91	maxKittyGraphicsID = uint64(^uint32(0))
92	maxGraphicsIDs     = uint64(1024 + 65536)
93	// A namespace is deliberately larger than the maximum bounded scene so an
94	// attachment can replace/delete objects without sharing IDs with another
95	// attachment. The high bits identify the attachment/session namespace and
96	// the low bits are its monotonically allocated object IDs. IDs start at one,
97	// so reserve only complete blocks and leave the short tail of Kitty's
98	// uint32 ID space unused.
99	graphicsIDNamespaceSize  = uint64(1 << 17)
100	graphicsIDNamespaceCount = maxKittyGraphicsID / graphicsIDNamespaceSize
101)
102
103var (
104	standaloneGraphicsNamespace atomic.Uint64
105	graphicsNamespaceFence      atomic.Uint64
106)
107
108func nextGraphicsNamespaceFence() uint64 {
109	fence := graphicsNamespaceFence.Add(1)
110	if fence == 0 {
111		fence = graphicsNamespaceFence.Add(1)
112	}
113	return fence
114}
115
116func newGraphicsOutputState() *graphicsOutputState {
117	block := (standaloneGraphicsNamespace.Add(1) - 1) % graphicsIDNamespaceCount
118	base := block*graphicsIDNamespaceSize + 1
119	return newGraphicsOutputStateWithLease(base, nextGraphicsNamespaceFence())
120}
121
122func newGraphicsOutputStateWithBase(base uint64) *graphicsOutputState {
123	return newGraphicsOutputStateWithLease(base, nextGraphicsNamespaceFence())
124}
125
126func newGraphicsOutputStateWithLease(base, fence uint64) *graphicsOutputState {
127	maxBase := (graphicsIDNamespaceCount-1)*graphicsIDNamespaceSize + 1
128	if base == 0 || (base-1)%graphicsIDNamespaceSize != 0 || base > maxBase {
129		base = 1
130	}
131	if fence == 0 {
132		fence = nextGraphicsNamespaceFence()
133	}
134	return &graphicsOutputState{
135		assets:         make(map[string]graphicsOutputAsset),
136		placements:     make(map[string]graphicsOutputPlacement),
137		pendingImages:  make(map[uint64]struct{}),
138		pendingPlaces:  make(map[uint64]struct{}),
139		nextID:         base,
140		namespaceBase:  base,
141		namespaceFence: fence,
142	}
143}
144
145func graphicsNamespaceKey(sess *session, clientID [16]byte) string {
146	if sess == nil {
147		return fmt.Sprintf("attachment:%x", clientID)
148	}
149	return fmt.Sprintf("session:%s:%x:%x", sess.id, sess.incarnation, clientID)
150}
151
152// reserveGraphicsNamespaceLocked chooses a deterministic preferred block and
153// linearly probes the bounded namespace table for a free block. Caller holds
154// d.mu; probing makes equal session/client identities collision-safe too.
155func (d *Daemon) reserveGraphicsNamespaceLocked(key string) uint64 {
156	base, _ := d.reserveGraphicsNamespaceLeaseLocked(key)
157	return base
158}
159
160func (d *Daemon) reserveGraphicsNamespaceLeaseLocked(key string) (uint64, uint64) {
161	if d == nil {
162		return 0, 0
163	}
164	return d.graphics.reserveLease(key)
165}
166
167func (d *Daemon) releaseGraphicsNamespace(base uint64) {
168	if d == nil || base == 0 || base%graphicsIDNamespaceSize != 1 {
169		return
170	}
171	d.mu.Lock()
172	d.releaseGraphicsNamespaceLocked(base)
173	d.mu.Unlock()
174}
175
176// releaseGraphicsNamespaceLocked is used by resume publication while d.mu is
177// already held. A quarantined block is intentionally not released by this
178// legacy base-only helper; only its fenced cleanup lifecycle may retire it.
179func (d *Daemon) releaseGraphicsNamespaceLocked(base uint64) {
180	if d == nil {
181		return
182	}
183	d.graphics.releaseBase(base)
184}
185
186func (d *Daemon) releaseGraphicsNamespaceLeaseLocked(state *graphicsOutputState) {
187	if d == nil {
188		return
189	}
190	d.graphics.releaseLease(state)
191}
192
193// quarantineGraphicsNamespaceLocked fences one exact namespace instance for
194// the daemon lifetime. Output side effects have no terminal ACK, so transport
195// completion must never release a block that may have reached an outer terminal.
196// The caller holds d.mu.
197func (d *Daemon) quarantineGraphicsNamespaceLocked(state *graphicsOutputState) *graphicsNamespaceQuarantine {
198	if d == nil {
199		return nil
200	}
201	return d.graphics.quarantine(state)
202}
203
204func cloneGraphicsOutputState(in *graphicsOutputState) graphicsOutputState {
205	out := graphicsOutputState{
206		assets:        make(map[string]graphicsOutputAsset),
207		placements:    make(map[string]graphicsOutputPlacement),
208		pendingImages: make(map[uint64]struct{}),
209		pendingPlaces: make(map[uint64]struct{}),
210		nextID:        1,
211	}
212	if in == nil {
213		return out
214	}
215	out.nextID, out.namespaceBase = in.nextID, in.namespaceBase
216	out.namespaceFence = in.namespaceFence
217	out.mayHaveEmitted = in.mayHaveEmitted
218	out.transform, out.transformSet = in.transform, in.transformSet
219	for key, asset := range in.assets {
220		out.assets[key] = asset
221	}
222	for key, placement := range in.placements {
223		out.placements[key] = placement
224	}
225	for id := range in.pendingImages {
226		out.pendingImages[id] = struct{}{}
227	}
228	for id := range in.pendingPlaces {
229		out.pendingPlaces[id] = struct{}{}
230	}
231	return out
232}
233
234// prepare speculatively computes the records needed to make an outer Kitty
235// terminal match snapshot. The maps are copied and become visible only when
236// commit is called after the enclosing Output record has been sent.
237func (s *graphicsOutputState) prepare(snapshot *graphics.Snapshot, reset bool) (*preparedGraphicsOutput, error) {
238	return s.prepareWithTransform(snapshot, reset, graphicsOutputTransform{})
239}
240
241func (s *graphicsOutputState) prepareWithTransform(snapshot *graphics.Snapshot, reset bool, transform graphicsOutputTransform, viewports ...graphics.Viewport) (*preparedGraphicsOutput, error) {
242	assets, placements := graphicsSnapshotRecords(snapshot, viewports...)
243	return s.prepareWithRecords(assets, placements, reset, transform)
244}
245
246// prepareWithRecords is the same speculative state transition as the Phase 5
247// single-scene path, but accepts a composed set of immutable pane records. It
248// intentionally does not write to any graphics.Scene.
249func (s *graphicsOutputState) prepareWithRecords(assets map[string]graphics.AssetView, placements map[string]graphicsOutputPlacement, reset bool, transform graphicsOutputTransform) (*preparedGraphicsOutput, error) {
250	return s.prepareWithRecordsLimit(assets, placements, reset, transform, maxGraphicsOutputBytes)
251}
252
253func (s *graphicsOutputState) prepareWithRecordsLimit(assets map[string]graphics.AssetView, placements map[string]graphicsOutputPlacement, reset bool, transform graphicsOutputTransform, maxBytes int) (*preparedGraphicsOutput, error) {
254	maxBytes = max(maxBytes, 0)
255	prepared, err := s.prepareWithRecordsOnce(assets, placements, reset, transform, false, maxBytes)
256	if !errors.Is(err, errGraphicsIDExhausted) {
257		return prepared, err
258	}
259	// A bounded attachment can safely recycle its own IDs only after deleting
260	// the objects it owns. Keep this retry attachment-local: it never advances
261	// into the next reserved namespace block.
262	return s.prepareWithRecordsOnce(assets, placements, true, transform, true, maxBytes)
263}
264
265func (s *graphicsOutputState) prepareWithRecordsOnce(assets map[string]graphics.AssetView, placements map[string]graphicsOutputPlacement, reset bool, transform graphicsOutputTransform, resetIDs bool, maxBytes int) (*preparedGraphicsOutput, error) {
266	if s == nil {
267		return nil, nil
268	}
269	candidate := cloneGraphicsOutputState(s)
270	transformChanged := !s.transformSet || s.transform != transform
271	replay := reset || transformChanged
272	candidate.transform, candidate.transformSet = transform, true
273	out := &preparedGraphicsOutput{owner: s, state: candidate, maxBytes: maxBytes, valid: true}
274	if replay {
275		for _, id := range sortedUint64Keys(s.pendingPlaces) {
276			if err := out.record(deletePlacementRecord(id)); err != nil {
277				return nil, err
278			}
279		}
280		for _, id := range sortedUint64Keys(s.pendingImages) {
281			if err := out.record(deleteImageRecord(id)); err != nil {
282				return nil, err
283			}
284		}
285		for _, placement := range sortedOutputPlacements(s.placements) {
286			if err := out.record(deletePlacementRecord(placement.id)); err != nil {
287				return nil, err
288			}
289		}
290		for _, asset := range sortedOutputAssets(s.assets) {
291			if err := out.record(deleteImageRecord(asset.id)); err != nil {
292				return nil, err
293			}
294		}
295		// Keep attachment-owned IDs stable across a rebase. The old remote
296		// objects are deleted first, then the same IDs are uploaded and placed
297		// again in the replay records below.
298		candidate.pendingPlaces = make(map[uint64]struct{})
299		candidate.pendingImages = make(map[uint64]struct{})
300		if resetIDs {
301			candidate.assets = make(map[string]graphicsOutputAsset)
302			candidate.placements = make(map[string]graphicsOutputPlacement)
303			candidate.nextID = candidate.namespaceBase
304		}
305	}
306
307	for key, placement := range candidate.placements {
308		if _, ok := placements[key]; ok {
309			continue
310		}
311		if err := out.record(deletePlacementRecord(placement.id)); err != nil {
312			return nil, err
313		}
314		delete(candidate.placements, key)
315	}
316	for key, asset := range candidate.assets {
317		if _, ok := assets[key]; ok {
318			continue
319		}
320		if err := out.record(deleteImageRecord(asset.id)); err != nil {
321			return nil, err
322		}
323		delete(candidate.assets, key)
324	}
325
326	assetKeys := sortedAssetKeys(assets)
327	for _, key := range assetKeys {
328		if _, ok := candidate.assets[key]; ok && !replay {
329			continue
330		}
331		asset, exists := candidate.assets[key]
332		id := asset.id
333		if !exists {
334			var ok bool
335			id, ok = takeGraphicsIDInNamespace(&candidate.nextID, candidate.namespaceBase)
336			if !ok {
337				return nil, errGraphicsIDExhausted
338			}
339			candidate.assets[key] = graphicsOutputAsset{id: id}
340		}
341		if err := out.record(uploadRecord(id, assets[key])); err != nil {
342			return nil, err
343		}
344	}
345
346	placementKeys := sortedPlacementKeys(placements)
347	for _, key := range placementKeys {
348		want := placements[key]
349		got, exists := candidate.placements[key]
350		if exists && equalGraphicsPlacement(got, want) && !replay {
351			continue
352		}
353		if exists {
354			if !replay {
355				if err := out.record(deletePlacementRecord(got.id)); err != nil {
356					return nil, err
357				}
358			}
359			want.id = got.id
360		} else {
361			id, ok := takeGraphicsIDInNamespace(&candidate.nextID, candidate.namespaceBase)
362			if !ok {
363				return nil, errGraphicsIDExhausted
364			}
365			want.id = id
366		}
367		asset, ok := candidate.assets[want.asset]
368		if !ok {
369			// A malformed scene cannot make the outer terminal emit a dangling
370			// placement. The VT graphics adapter normally prevents this state.
371			delete(candidate.placements, key)
372			continue
373		}
374		candidate.placements[key] = want
375		placementTransform := transform
376		if want.transformSet {
377			placementTransform = want.transform
378		}
379		if err := out.record(placementRecordWithTransform(asset.id, want, placementTransform)); err != nil {
380			return nil, err
381		}
382	}
383	// candidate.nextID and any maps replaced during namespace recovery changed
384	// after out was initialized; publish the complete speculative value before
385	// commit copies it back to the attachment owner.
386	out.state = candidate
387	if len(out.data) == 0 && !reset {
388		out.data = nil
389	}
390	return out, nil
391}
392
393func (p *preparedGraphicsOutput) record(record []byte) error {
394	if len(record) > maxGraphicsRecordBytes || len(record) > p.maxBytes || len(p.data) > p.maxBytes-len(record) {
395		return errGraphicsOutputTooLarge
396	}
397	p.data = append(p.data, record...)
398	return nil
399}
400
401func (p *preparedGraphicsOutput) commit() {
402	if p == nil || !p.valid || p.owner == nil {
403		return
404	}
405	*p.owner = p.state
406	p.valid = false
407}
408
409func (p *preparedGraphicsOutput) markSendAttempted() {
410	if p != nil && p.valid && p.owner != nil && len(p.data) > 0 {
411		p.sendAttempted = true
412		p.owner.mayHaveEmitted = true
413		p.state.mayHaveEmitted = true
414	}
415}
416
417func (p *preparedGraphicsOutput) abort() {
418	if p == nil || !p.valid || p.owner == nil {
419		return
420	}
421	// A preparation can be abandoned before the enclosing Output frame ever
422	// reaches the transport (for example when ANSI preparation fails). Such IDs
423	// were never emitted and must not become cleanup records. Once the transport
424	// was actually called, its result is ambiguous: the terminal may have
425	// consumed a prefix, so retain those speculative IDs for the next reset.
426	if p.sendAttempted {
427		for key, asset := range p.state.assets {
428			if committed, exists := p.owner.assets[key]; !exists || committed.id != asset.id {
429				p.owner.pendingImages[asset.id] = struct{}{}
430			}
431		}
432		for key, placement := range p.state.placements {
433			if committed, exists := p.owner.placements[key]; !exists || committed.id != placement.id {
434				p.owner.pendingPlaces[placement.id] = struct{}{}
435			}
436		}
437	}
438	p.valid = false
439}
440
441func graphicsSnapshotRecords(snapshot *graphics.Snapshot, viewports ...graphics.Viewport) (map[string]graphics.AssetView, map[string]graphicsOutputPlacement) {
442	return graphicsSnapshotRecordsForSource(snapshot, "", viewports...)
443}
444
445// graphicsSnapshotRecordsForSource projects one immutable pane scene into
446// attachment-local records. source is part of every key because a child VT
447// scene is allowed to issue the same raw a1/p1 IDs as every other pane.
448// viewports are a deterministic union of visible regions; this is used to
449// subtract a floating pane from tiled content without mutating the scene.
450func graphicsSnapshotRecordsForSource(snapshot *graphics.Snapshot, source string, viewports ...graphics.Viewport) (map[string]graphics.AssetView, map[string]graphicsOutputPlacement) {
451	return graphicsSnapshotRecordsForSourceOrdered(snapshot, source, 0, viewports...)
452}
453
454func graphicsSnapshotRecordsForSourceOrdered(snapshot *graphics.Snapshot, source string, paneOrder int, viewports ...graphics.Viewport) (map[string]graphics.AssetView, map[string]graphicsOutputPlacement) {
455	assets := make(map[string]graphics.AssetView)
456	placements := make(map[string]graphicsOutputPlacement)
457	if snapshot == nil {
458		return assets, placements
459	}
460	assetKeys := make(map[string]string)
461	generation := snapshot.Generation()
462	for _, asset := range snapshot.Assets() {
463		key := graphicsAssetKeyForSource(source, generation, asset)
464		assets[key] = asset
465		assetKeys[asset.ID().String()] = key
466	}
467
468	placementViews := snapshot.Placements()
469	if len(viewports) == 0 {
470		for order, placement := range placementViews {
471			addGraphicsOutputPlacementForSourceOrdered(placements, source, paneOrder, order, generation, assetKeys, placement.ID().String(), placement.AssetID().String(), placement.Source(), placement.Destination(), placement.Cells(), placement.Layer())
472		}
473		return assets, placements
474	}
475
476	byPlacementID := make(map[string]graphics.PlacementView, len(placementViews))
477	placementOrders := make(map[string]int, len(placementViews))
478	for order, placement := range placementViews {
479		id := placement.ID().String()
480		byPlacementID[id] = placement
481		placementOrders[id] = order
482	}
483	for _, viewport := range viewports {
484		cellFragments := make(map[string]graphics.VisibleFragment)
485		if viewport.Cells.Valid() && !viewport.Cells.Empty() {
486			for _, fragment := range snapshot.VisibleFragments(viewport) {
487				cellFragments[fragment.PlacementID.String()] = fragment
488			}
489		}
490		if viewport.Pixels.Valid() && !viewport.Pixels.Empty() {
491			for _, fragment := range snapshot.VisiblePixelFragments(viewport.Pixels) {
492				placement, ok := byPlacementID[fragment.PlacementID.String()]
493				if !ok {
494					continue
495				}
496				if placement.Cells().Valid() && !placement.Cells().Empty() {
497					fragment, ok = cellFragments[fragment.PlacementID.String()]
498					if !ok {
499						continue
500					}
501				}
502				order := placementOrders[fragment.PlacementID.String()]
503				addGraphicsOutputPlacementForSourceOrdered(placements, source, paneOrder, order, generation, assetKeys, fragment.PlacementID.String(), fragment.AssetID.String(), fragment.Source, fragment.Destination, fragment.Cells, placement.Layer())
504			}
505			continue
506		}
507		// A cell-only viewport is useful to callers that do not know pixel
508		// geometry. Cell-aware placements already carry enough information for
509		// the graphics package to map the clipped destination back to pixels.
510		for _, fragment := range cellFragments {
511			placement, ok := byPlacementID[fragment.PlacementID.String()]
512			if !ok {
513				continue
514			}
515			order := placementOrders[fragment.PlacementID.String()]
516			addGraphicsOutputPlacementForSourceOrdered(placements, source, paneOrder, order, generation, assetKeys, fragment.PlacementID.String(), fragment.AssetID.String(), fragment.Source, fragment.Destination, fragment.Cells, placement.Layer())
517		}
518	}
519	// A viewport is an attachment-local visibility boundary. Do not upload
520	// assets that have no visible placement in that boundary: a pane can retain
521	// decoded Kitty images after its last placement was erased, but those image
522	// records must not leak through a covered or collapsed projection.
523	if len(viewports) != 0 {
524		usedAssets := make(map[string]struct{}, len(placements))
525		for _, placement := range placements {
526			usedAssets[placement.asset] = struct{}{}
527		}
528		for key := range assets {
529			if _, ok := usedAssets[key]; !ok {
530				delete(assets, key)
531			}
532		}
533	}
534	return assets, placements
535}
536
537func addGraphicsOutputPlacement(dst map[string]graphicsOutputPlacement, generation uint64, assetKeys map[string]string, placementID, assetID string, source, destination graphics.PixelRect, cells graphics.CellRect, layer int64) {
538	addGraphicsOutputPlacementForSource(dst, "", 0, generation, assetKeys, placementID, assetID, source, destination, cells, layer)
539}
540
541func addGraphicsOutputPlacementForSource(dst map[string]graphicsOutputPlacement, source string, order int, generation uint64, assetKeys map[string]string, placementID, assetID string, sourceRect, destination graphics.PixelRect, cells graphics.CellRect, layer int64) {
542	addGraphicsOutputPlacementForSourceOrdered(dst, source, 0, order, generation, assetKeys, placementID, assetID, sourceRect, destination, cells, layer)
543}
544
545func addGraphicsOutputPlacementForSourceOrdered(dst map[string]graphicsOutputPlacement, source string, paneOrder, order int, generation uint64, assetKeys map[string]string, placementID, assetID string, sourceRect, destination graphics.PixelRect, cells graphics.CellRect, layer int64) {
546	assetKey := assetKeys[assetID]
547	if assetKey == "" {
548		return
549	}
550	key := graphicsPlacementKeyPartsForSourceOrdered(source, paneOrder, order, generation, placementID, assetKey, sourceRect, destination, cells, layer)
551	dst[key] = graphicsOutputPlacement{asset: assetKey, source: sourceRect, dest: destination, cells: cells, layer: layer}
552}
553
554func graphicsAssetKey(generation uint64, asset graphics.AssetView) string {
555	return graphicsAssetKeyForSource("", generation, asset)
556}
557
558func graphicsAssetKeyForSource(source string, generation uint64, asset graphics.AssetView) string {
559	digest := sha256.Sum256(asset.Encoded())
560	if source == "" {
561		return fmt.Sprintf("g%d:%s:%d:%d:%x", generation, asset.ID().String(), asset.Width(), asset.Height(), digest)
562	}
563	return fmt.Sprintf("s:%s:g%d:%s:%d:%d:%x", source, generation, asset.ID().String(), asset.Width(), asset.Height(), digest)
564}
565
566func graphicsPlacementKey(generation uint64, placement graphics.PlacementView, assetKey string) string {
567	return graphicsPlacementKeyParts(generation, placement.ID().String(), assetKey, placement.Source(), placement.Destination(), placement.Cells(), placement.Layer())
568}
569
570func graphicsPlacementKeyParts(generation uint64, placementID, assetKey string, source, dest graphics.PixelRect, cells graphics.CellRect, layer int64) string {
571	return graphicsPlacementKeyPartsForSource("", 0, generation, placementID, assetKey, source, dest, cells, layer)
572}
573
574func graphicsPlacementKeyPartsForSource(source string, order int, generation uint64, placementID, assetKey string, sourceRect, dest graphics.PixelRect, cells graphics.CellRect, layer int64) string {
575	return graphicsPlacementKeyPartsForSourceOrdered(source, 0, order, generation, placementID, assetKey, sourceRect, dest, cells, layer)
576}
577
578func graphicsPlacementKeyPartsForSourceOrdered(source string, paneOrder, order int, generation uint64, placementID, assetKey string, sourceRect, dest graphics.PixelRect, cells graphics.CellRect, layer int64) string {
579	if source == "" {
580		return fmt.Sprintf("g%d:%s:%s:%v:%v:%v:%d", generation, placementID, assetKey, sourceRect, dest, cells, layer)
581	}
582	// Put the fixed-width pane/scene order first so map iteration is irrelevant:
583	// placements are replayed in layout/scene order, while the remaining fields
584	// still distinguish clipped fragments and scene generations. The source
585	// identity itself deliberately excludes order so moving a pane can retain
586	// its attachment-local uploaded asset and only replace its placement.
587	return fmt.Sprintf("o%08d:%08d:s:%s:g%d:%s:%s:%v:%v:%v:%d", paneOrder, order, source, generation, placementID, assetKey, sourceRect, dest, cells, layer)
588}
589
590func sortedOutputAssets(values map[string]graphicsOutputAsset) []graphicsOutputAsset {
591	out := make([]graphicsOutputAsset, 0, len(values))
592	for _, value := range values {
593		out = append(out, value)
594	}
595	sort.Slice(out, func(i, j int) bool { return out[i].id < out[j].id })
596	return out
597}
598
599func sortedOutputPlacements(values map[string]graphicsOutputPlacement) []graphicsOutputPlacement {
600	out := make([]graphicsOutputPlacement, 0, len(values))
601	for _, value := range values {
602		out = append(out, value)
603	}
604	sort.Slice(out, func(i, j int) bool { return out[i].id < out[j].id })
605	return out
606}
607
608func sortedAssetKeys(values map[string]graphics.AssetView) []string {
609	keys := make([]string, 0, len(values))
610	for key := range values {
611		keys = append(keys, key)
612	}
613	sort.Strings(keys)
614	return keys
615}
616
617func sortedPlacementKeys(values map[string]graphicsOutputPlacement) []string {
618	keys := make([]string, 0, len(values))
619	for key := range values {
620		keys = append(keys, key)
621	}
622	sort.Strings(keys)
623	return keys
624}
625
626func sortedUint64Keys(values map[uint64]struct{}) []uint64 {
627	keys := make([]uint64, 0, len(values))
628	for key := range values {
629		keys = append(keys, key)
630	}
631	sort.Slice(keys, func(i, j int) bool { return keys[i] < keys[j] })
632	return keys
633}
634
635func takeGraphicsID(next *uint64) (uint64, bool) {
636	if next == nil || *next == 0 || *next > maxKittyGraphicsID {
637		return 0, false
638	}
639	base := ((*next - 1) / graphicsIDNamespaceSize) * graphicsIDNamespaceSize
640	return takeGraphicsIDInNamespace(next, base+1)
641}
642
643func takeGraphicsIDInNamespace(next *uint64, namespaceBase uint64) (uint64, bool) {
644	if next == nil || namespaceBase == 0 || (namespaceBase-1)%graphicsIDNamespaceSize != 0 {
645		return 0, false
646	}
647	// IDs are derived from the attachment's reserved block, never from the
648	// global Kitty ceiling. Exhaustion marks the cursor unusable; it must never
649	// wrap or consume the first ID reserved for another attachment.
650	limit := namespaceBase + graphicsIDNamespaceSize
651	if limit < namespaceBase || limit > maxKittyGraphicsID+1 || *next < namespaceBase || *next >= limit {
652		return 0, false
653	}
654	id := *next
655	if id+1 >= limit {
656		*next = 0
657	} else {
658		(*next)++
659	}
660	return id, true
661}
662
663func equalGraphicsPlacement(a, b graphicsOutputPlacement) bool {
664	return a.asset == b.asset && a.source == b.source && a.dest == b.dest && a.cells == b.cells && a.layer == b.layer && a.transformSet == b.transformSet && (!a.transformSet || a.transform == b.transform)
665}
666
667func kittyRecord(header string, payload []byte) []byte {
668	// q=2 asks Kitty to suppress protocol responses. Outer terminal replies
669	// would otherwise be interpreted as pane input or pollute the attachment's
670	// byte-only transport.
671	if header != "" {
672		header += ",q=2"
673	}
674	out := make([]byte, 0, len(header)+len(payload)+8)
675	out = append(out, "\x1b_G"...)
676	out = append(out, header...)
677	out = append(out, ';')
678	out = append(out, payload...)
679	out = append(out, "\x1b\\"...)
680	return out
681}
682
683func deletePlacementRecord(id uint64) []byte {
684	return kittyRecord("a=d,d=p,p="+strconv.FormatUint(id, 10), nil)
685}
686
687func deleteImageRecord(id uint64) []byte {
688	return kittyRecord("a=d,d=i,i="+strconv.FormatUint(id, 10), nil)
689}
690
691func uploadRecord(id uint64, asset graphics.AssetView) []byte {
692	data := asset.Encoded()
693	format := "32"
694	switch asset.Format() {
695	case graphics.AssetFormatRGB:
696		format = "24"
697	case graphics.AssetFormatPNG:
698		format = "100"
699	case graphics.AssetFormatRGBA, graphics.AssetFormatUnknown:
700		format = "32"
701	}
702	header := "a=t,i=" + strconv.FormatUint(id, 10) + ",f=" + format +
703		",s=" + strconv.FormatInt(asset.Width(), 10) + ",v=" + strconv.FormatInt(asset.Height(), 10)
704	if format != "100" && len(data) > 2048 {
705		var compressed bytes.Buffer
706		zw := zlib.NewWriter(&compressed)
707		_, writeErr := zw.Write(data)
708		closeErr := zw.Close()
709		if writeErr == nil && closeErr == nil && compressed.Len() < len(data) {
710			data = compressed.Bytes()
711			header += ",o=z"
712		}
713	}
714	payload := base64.RawStdEncoding.EncodeToString(data)
715	if len(payload) <= graphicsUploadChunkBytes {
716		return kittyRecord(header, []byte(payload))
717	}
718	out := make([]byte, 0, len(payload)+len(payload)/graphicsUploadChunkBytes*32)
719	first := true
720	for len(payload) != 0 {
721		n := min(len(payload), graphicsUploadChunkBytes)
722		chunk := payload[:n]
723		payload = payload[n:]
724		chunkHeader := "a=t"
725		if first {
726			chunkHeader = header
727			first = false
728		}
729		if len(payload) != 0 {
730			chunkHeader += ",m=1"
731		} else {
732			chunkHeader += ",m=0"
733		}
734		out = append(out, kittyRecord(chunkHeader, []byte(chunk))...)
735	}
736	return out
737}
738
739func placementRecord(assetID uint64, placement graphicsOutputPlacement) []byte {
740	return placementRecordWithTransform(assetID, placement, graphicsOutputTransform{})
741}
742
743func placementRecordWithTransform(assetID uint64, placement graphicsOutputPlacement, transform graphicsOutputTransform) []byte {
744	column, row, offsetX, offsetY, cells := transformedGraphicsPlacement(placement, transform)
745	header := "a=p,i=" + strconv.FormatUint(assetID, 10) + ",p=" + strconv.FormatUint(placement.id, 10)
746	if placement.source.X != 0 {
747		header += ",x=" + strconv.FormatInt(placement.source.X, 10)
748	}
749	if placement.source.Y != 0 {
750		header += ",y=" + strconv.FormatInt(placement.source.Y, 10)
751	}
752	header += ",w=" + strconv.FormatInt(placement.source.Width, 10) + ",h=" + strconv.FormatInt(placement.source.Height, 10)
753	if offsetX != 0 {
754		header += ",X=" + strconv.FormatInt(offsetX, 10)
755	}
756	if offsetY != 0 {
757		header += ",Y=" + strconv.FormatInt(offsetY, 10)
758	}
759	if cells.Valid() && !cells.Empty() {
760		header += ",c=" + strconv.FormatInt(cells.Width, 10) + ",r=" + strconv.FormatInt(cells.Height, 10)
761		if cells.X != 0 {
762			header += ",H=" + strconv.FormatInt(cells.X, 10)
763		}
764		if cells.Y != 0 {
765			header += ",V=" + strconv.FormatInt(cells.Y, 10)
766		}
767	}
768	if placement.layer != 0 {
769		header += ",z=" + strconv.FormatInt(placement.layer, 10)
770	}
771	// The relayed placement must not advance or scroll the outer text cursor.
772	header += ",C=1"
773	record := kittyRecord(header, nil)
774	if column < 0 || row < 0 {
775		return record
776	}
777	out := make([]byte, 0, len(record)+32)
778	out = append(out, "\x1b7\x1b["...)
779	out = strconv.AppendInt(out, row+1, 10)
780	out = append(out, ';')
781	out = strconv.AppendInt(out, column+1, 10)
782	out = append(out, 'H')
783	out = append(out, record...)
784	out = append(out, "\x1b8"...)
785	return out
786}
787
788// transformedGraphicsPlacement converts pane-local destination pixels into an
789// outer cursor cell plus Kitty X/Y sub-cell pixel offsets. Lowercase x/y are
790// source-crop controls and are therefore handled separately by the encoder.
791func transformedGraphicsPlacement(placement graphicsOutputPlacement, transform graphicsOutputTransform) (column, row, offsetX, offsetY int64, cells graphics.CellRect) {
792	column, row = placement.dest.X, placement.dest.Y
793	cells = placement.cells
794	if transform.sourceGeometry.PixelsKnown() && transform.sourceGeometry.Cols > 0 && transform.sourceGeometry.Rows > 0 {
795		cellWidth := transform.sourceGeometry.PixelWidth / transform.sourceGeometry.Cols
796		cellHeight := transform.sourceGeometry.PixelHeight / transform.sourceGeometry.Rows
797		if cellWidth > 0 && cellHeight > 0 && column >= 0 && row >= 0 {
798			offsetX = column % int64(cellWidth)
799			offsetY = row % int64(cellHeight)
800			column /= int64(cellWidth)
801			row /= int64(cellHeight)
802		}
803	}
804	if column >= 0 {
805		column += int64(transform.originX)
806	}
807	if row >= 0 {
808		row += int64(transform.originY)
809	}
810	return column, row, offsetX, offsetY, cells
811}
812
813func (s *graphicsOutputState) cleanup() {
814	if s == nil {
815		return
816	}
817	s.assets = make(map[string]graphicsOutputAsset)
818	s.placements = make(map[string]graphicsOutputPlacement)
819	s.pendingImages = make(map[uint64]struct{})
820	s.pendingPlaces = make(map[uint64]struct{})
821	s.transform = graphicsOutputTransform{}
822	s.transformSet = false
823}
824
825func graphicsOutputViewport(content domain.Rect, geometry domain.Geometry) (graphics.Viewport, bool) {
826	return graphicsOutputViewportRect(domain.Rect{Width: content.Width, Height: content.Height}, geometry)
827}
828
829func graphicsOutputViewportRect(content domain.Rect, geometry domain.Geometry) (graphics.Viewport, bool) {
830	if content.Width <= 0 || content.Height <= 0 || content.X < 0 || content.Y < 0 {
831		return graphics.Viewport{}, false
832	}
833	cells := graphics.CellRect{X: int64(content.X), Y: int64(content.Y), Width: int64(content.Width), Height: int64(content.Height)}
834	if !geometry.PixelsKnown() || geometry.Cols <= 0 || geometry.Rows <= 0 {
835		// Cell dimensions are not pixel dimensions. When the controlling
836		// terminal did not report pixels, keep clipping in cell space so a
837		// pixel-space placement without cell metadata is suppressed by the
838		// graphics scene rather than clipped to a made-up pixel rectangle.
839		return graphics.Viewport{Cells: cells}, true
840	}
841	cellWidth := geometry.PixelWidth / geometry.Cols
842	cellHeight := geometry.PixelHeight / geometry.Rows
843	if cellWidth <= 0 || cellHeight <= 0 {
844		return graphics.Viewport{Cells: cells}, true
845	}
846	return graphics.Viewport{
847		Pixels: graphics.PixelRect{
848			X:      int64(content.X) * int64(cellWidth),
849			Y:      int64(content.Y) * int64(cellHeight),
850			Width:  int64(content.Width) * int64(cellWidth),
851			Height: int64(content.Height) * int64(cellHeight),
852		},
853		Cells: cells,
854	}, true
855}
856
857func graphicsOutputNamespaceMustQuarantine(state *graphicsOutputState) bool {
858	return state != nil && (state.mayHaveEmitted || len(state.assets) != 0 || len(state.placements) != 0 || len(state.pendingImages) != 0 || len(state.pendingPlaces) != 0)
859}
860
861func (d *Daemon) discardAttachmentOutput(ac *attachedClient) {
862	if d == nil || ac == nil || ac.output == nil || ac.output.graphicsOutput == nil {
863		return
864	}
865	d.mu.Lock()
866	d.retireGraphicsOutputLocked(ac, ac.output.graphicsOutput)
867	d.mu.Unlock()
868}
869
870func (d *Daemon) retireGraphicsOutput(ac *attachedClient, state *graphicsOutputState) {
871	if d == nil || state == nil {
872		return
873	}
874	d.mu.Lock()
875	d.retireGraphicsOutputLocked(ac, state)
876	d.mu.Unlock()
877}
878
879// retireGraphicsOutputLocked fences exactly state, not whatever graphics state
880// a later attachment may have installed on the same client object. Any lease
881// that may have crossed the transport boundary stays quarantined for the daemon
882// lifetime; only a provably unused reservation is released. The caller holds
883// d.mu.
884func (d *Daemon) retireGraphicsOutputLocked(ac *attachedClient, state *graphicsOutputState) *graphicsNamespaceQuarantine {
885	if d == nil || state == nil {
886		return nil
887	}
888	if ac != nil && ac.output != nil && ac.output.graphicsOutput == state {
889		ac.output.graphicsOutput = nil
890	}
891	quarantine := graphicsOutputNamespaceMustQuarantine(state)
892	state.cleanup()
893	if !quarantine {
894		d.releaseGraphicsNamespaceLeaseLocked(state)
895		return nil
896	}
897	return d.quarantineGraphicsNamespaceLocked(state)
898}
899
900// discardAttachmentOutputLocked is used by lifecycle publication paths that
901// already hold d.mu. Since those paths cannot establish terminal receipt, any
902// namespace that may have emitted remains quarantined.
903func (d *Daemon) discardAttachmentOutputLocked(ac *attachedClient) {
904	if d == nil || ac == nil || ac.output == nil || ac.output.graphicsOutput == nil {
905		return
906	}
907	d.retireGraphicsOutputLocked(ac, ac.output.graphicsOutput)
908}
909
910// cleanupAttachmentOutput removes terminal objects owned by a closing
911// attachment before its transport is closed. Parked resumable attachments
912// intentionally do not call this: their state remains attached to the parked
913// link and the next epoch deletes and replays it as needed. Even a successful
914// cleanup socket send cannot release an exposed namespace because side-effect
915// Output frames are not terminal-ACKed.
916func (d *Daemon) cleanupAttachmentOutput(ac *attachedClient) error {
917	if d == nil || ac == nil || ac.output == nil || ac.output.graphicsOutput == nil {
918		return nil
919	}
920	// Keep preparation, the side-effect frame, and retirement under one
921	// attachment send fence. A concurrent paint must not commit a newer scene
922	// between the delete preparation and the handoff control frame.
923	ac.sendMu.Lock()
924	defer ac.sendMu.Unlock()
925	state := ac.output.graphicsOutput
926	if state == nil {
927		return nil
928	}
929	prepared, err := state.prepare(nil, true)
930	if err != nil || prepared == nil {
931		d.retireGraphicsOutput(ac, state)
932		return err
933	}
934	if len(prepared.data) == 0 {
935		prepared.commit()
936		d.retireGraphicsOutput(ac, state)
937		return nil
938	}
939	expected := ac.transportSnapshot()
940	if expected.transport == nil {
941		prepared.markSendAttempted()
942		prepared.abort()
943		d.retireGraphicsOutput(ac, state)
944		return errors.New("client transport is nil")
945	}
946	ac.output.lockView()
947	output, outputErr := ac.output.sideEffectLocked(prepared.data, ac.echoAck.Load())
948	if outputErr != nil {
949		ac.output.unlockView()
950		prepared.abort()
951		d.retireGraphicsOutput(ac, state)
952		return outputErr
953	}
954	prepared.markSendAttempted()
955	var sendErr error
956	if expected.transport.Capabilities().OwnedSynchronousSend {
957		sendErr = expected.transport.SendOutputSynchronous(output)
958	} else {
959		_, sendErr = d.boundedSendWith(expected.transport, func() error {
960			if !ac.transportSnapshotCurrent(expected) {
961				return errTransportReplaced
962			}
963			return expected.transport.SendOutput(output)
964		})
965		if errors.Is(sendErr, errSendTimedOut) {
966			_ = ac.closeCapturedTransport(expected.transport)
967		}
968	}
969	ac.output.unlockView()
970	if sendErr == nil {
971		prepared.commit()
972	} else {
973		prepared.abort()
974	}
975	d.retireGraphicsOutput(ac, state)
976	return sendErr
977}
978
979func (d *Daemon) disableGraphicsOutput(ac *attachedClient) {
980	if ac == nil || ac.output == nil || ac.output.graphicsOutput == nil {
981		return
982	}
983	ac.terminalCapabilities.KittyGraphics = false
984	state := ac.output.graphicsOutput
985	if d == nil {
986		ac.output.graphicsOutput = nil
987		state.cleanup()
988		return
989	}
990	d.retireGraphicsOutput(ac, state)
991}
992
993func graphicsOutputData(state *capturedRenderState, ac *attachedClient, reset bool) (*preparedGraphicsOutput, error) {
994	return graphicsOutputDataWithDaemon(nil, state, ac, reset)
995}
996
997// composedGraphicsRecords is the pane-layout projection boundary. It reads
998// only immutable captured snapshots and produces attachment-local keys and
999// placements. In particular, it never joins or mutates the generic VT scenes.
1000func composedGraphicsRecords(state *capturedRenderState) (map[string]graphics.AssetView, map[string]graphicsOutputPlacement) {
1001	assets := make(map[string]graphics.AssetView)
1002	placements := make(map[string]graphicsOutputPlacement)
1003	if state == nil {
1004		return assets, placements
1005	}
1006	// Kitty placements are emitted after the ANSI frame. Any opaque overlay
1007	// would therefore be painted over by an old image unless its covered
1008	// fragments are temporarily removed. Modal overlays suppress the complete
1009	// scene; compact notices and floating panes contribute exact coverage
1010	// rectangles without mutating their source panes.
1011	if state.overlays.active() {
1012		return assets, placements
1013	}
1014	coverage := graphicsOpaqueCoverage(state)
1015	outerContent, hasOuterContent := graphicsOuterContentRect(state)
1016
1017	for index, pane := range state.panes {
1018		if pane.graphics == nil || pane.placement.Collapsed || pane.placement.Content.Width <= 0 || pane.placement.Content.Height <= 0 {
1019			continue
1020		}
1021		var viewports []graphics.Viewport
1022		if hasOuterContent {
1023			viewports = paneGraphicsViewports(pane.placement.Content, pane.graphicsGeometry, coverage, outerContent)
1024		} else {
1025			viewports = paneGraphicsViewports(pane.placement.Content, pane.graphicsGeometry, coverage)
1026		}
1027		if len(viewports) == 0 {
1028			continue
1029		}
1030		transform := graphicsOutputTransform{originX: pane.placement.Content.X, originY: pane.placement.Content.Y + 1, sourceGeometry: pane.graphicsGeometry}
1031		source := graphicsSourceKey(state, pane.id, pane.stableID, false)
1032		paneAssets, panePlacements := graphicsSnapshotRecordsForSourceOrdered(pane.graphics, source, index, viewports...)
1033		mergeGraphicsRecords(assets, placements, paneAssets, panePlacements, transform)
1034	}
1035
1036	if state.floating.visible && state.floating.pane.graphics != nil && state.floating.geometry.Inner.Width > 0 && state.floating.geometry.Inner.Height > 0 {
1037		inner := state.floating.geometry.Inner
1038		var viewports []graphics.Viewport
1039		if hasOuterContent {
1040			viewports = paneGraphicsViewports(inner, state.floating.pane.graphicsGeometry, nil, outerContent)
1041		} else {
1042			viewports = paneGraphicsViewports(inner, state.floating.pane.graphicsGeometry, nil)
1043		}
1044		if len(viewports) != 0 {
1045			transform := graphicsOutputTransform{originX: inner.X, originY: inner.Y + 1, sourceGeometry: state.floating.pane.graphicsGeometry}
1046			source := graphicsSourceKey(state, state.floating.pane.id, state.floating.pane.stableID, true)
1047			paneAssets, panePlacements := graphicsSnapshotRecordsForSourceOrdered(state.floating.pane.graphics, source, len(state.panes), viewports...)
1048			mergeGraphicsRecords(assets, placements, paneAssets, panePlacements, transform)
1049		}
1050	}
1051	return assets, placements
1052}
1053
1054func graphicsSourceKey(state *capturedRenderState, paneID layout.PaneID, stableID domain.PaneStableID, floating bool) string {
1055	kind := "pane"
1056	if floating {
1057		kind = "floating"
1058	}
1059	if stableID == "" {
1060		stableID = domain.PaneStableID(paneID)
1061	}
1062	return fmt.Sprintf("%s:%q:%x:%q:%q:%q", kind, string(state.sessionID), state.incarnation, string(state.view.tabID), string(stableID), string(paneID))
1063}
1064
1065func mergeGraphicsRecords(dstAssets map[string]graphics.AssetView, dstPlacements map[string]graphicsOutputPlacement, assets map[string]graphics.AssetView, placements map[string]graphicsOutputPlacement, transform graphicsOutputTransform) {
1066	for key, asset := range assets {
1067		dstAssets[key] = asset
1068	}
1069	for key, placement := range placements {
1070		placement.transform = transform
1071		placement.transformSet = true
1072		dstPlacements[key] = placement
1073	}
1074}
1075
1076// paneGraphicsViewports returns pane content minus each opaque coverage
1077// rectangle. Each subtraction is ordered top, bottom, left, right, so a union
1078// of clipped fragments remains deterministic.
1079func graphicsOpaqueCoverage(state *capturedRenderState) []domain.Rect {
1080	if state == nil {
1081		return nil
1082	}
1083	coverage := make([]domain.Rect, 0, 1+len(state.overlays.notices))
1084	if state.floating.visible && state.floating.geometry.Bounds.Width > 0 && state.floating.geometry.Bounds.Height > 0 {
1085		coverage = append(coverage, state.floating.geometry.Bounds)
1086	}
1087	if len(state.overlays.notices) == 0 && state.overlays.noticeOverflow == 0 {
1088		return coverage
1089	}
1090	width, rows := state.layout.area.Width, state.layout.area.Height
1091	if state.window.Valid() {
1092		window := contentSize(state.window)
1093		width, rows = window.Cols, window.Rows
1094	}
1095	height := rows + tabChromeRows
1096	if width <= 0 || height <= 0 {
1097		for _, pane := range state.panes {
1098			if pane.placement.Content.X+pane.placement.Content.Width > width {
1099				width = pane.placement.Content.X + pane.placement.Content.Width
1100			}
1101			if pane.placement.Content.Y+pane.placement.Content.Height+tabChromeRows > height {
1102				height = pane.placement.Content.Y + pane.placement.Content.Height + tabChromeRows
1103			}
1104		}
1105	}
1106	if width <= 0 || height <= 0 {
1107		return coverage
1108	}
1109	styles := state.styles
1110	if styles == (themeui.Styles{}) {
1111		styles = fallbackChromeStyles
1112	}
1113	frame := renderer.NewFrame(width, height)
1114	for _, footprint := range composeCapturedNotices(state.overlays, frame, styles) {
1115		// Notices are laid out in complete-frame coordinates. The graphics
1116		// content origin is one row below that frame's top bar.
1117		footprint.Y--
1118		coverage = append(coverage, footprint)
1119	}
1120	return coverage
1121}
1122
1123func graphicsOuterContentRect(state *capturedRenderState) (domain.Rect, bool) {
1124	if state == nil {
1125		return domain.Rect{}, false
1126	}
1127	if state.window.Valid() {
1128		content := contentSize(state.window)
1129		return domain.Rect{Width: content.Cols, Height: content.Rows}, content.Valid()
1130	}
1131	if state.layout.area.Width > 0 && state.layout.area.Height > 0 {
1132		return domain.Rect{Width: state.layout.area.Width, Height: state.layout.area.Height}, true
1133	}
1134	return domain.Rect{}, false
1135}
1136
1137func paneGraphicsViewports(content domain.Rect, geometry domain.Geometry, coverage []domain.Rect, outerContent ...domain.Rect) []graphics.Viewport {
1138	full := domain.Rect{Width: content.Width, Height: content.Height}
1139	if content.Width <= 0 || content.Height <= 0 {
1140		return nil
1141	}
1142	regions := []domain.Rect{full}
1143	if len(outerContent) > 0 {
1144		visible, ok := intersectGraphicsDomainRect(content, outerContent[0])
1145		if !ok {
1146			return nil
1147		}
1148		visible.X -= content.X
1149		visible.Y -= content.Y
1150		regions[0] = visible
1151	}
1152	for _, opaque := range coverage {
1153		cover, ok := intersectGraphicsDomainRect(content, opaque)
1154		if !ok {
1155			continue
1156		}
1157		cover.X -= content.X
1158		cover.Y -= content.Y
1159		next := make([]domain.Rect, 0, len(regions)*4)
1160		for _, region := range regions {
1161			localCover, intersects := intersectGraphicsDomainRect(region, cover)
1162			if !intersects {
1163				next = append(next, region)
1164				continue
1165			}
1166			next = append(next, subtractGraphicsDomainRect(region, localCover)...)
1167		}
1168		regions = next
1169		if len(regions) == 0 {
1170			break
1171		}
1172	}
1173	viewports := make([]graphics.Viewport, 0, len(regions))
1174	for _, region := range regions {
1175		if region.Width <= 0 || region.Height <= 0 {
1176			continue
1177		}
1178		viewport, valid := graphicsOutputViewportRect(region, geometry)
1179		if valid {
1180			viewports = append(viewports, viewport)
1181		}
1182	}
1183	return viewports
1184}
1185
1186func subtractGraphicsDomainRect(region, cover domain.Rect) []domain.Rect {
1187	return []domain.Rect{
1188		{X: region.X, Y: region.Y, Width: region.Width, Height: cover.Y - region.Y},
1189		{X: region.X, Y: cover.Y + cover.Height, Width: region.Width, Height: region.Y + region.Height - (cover.Y + cover.Height)},
1190		{X: region.X, Y: cover.Y, Width: cover.X - region.X, Height: cover.Height},
1191		{X: cover.X + cover.Width, Y: cover.Y, Width: region.X + region.Width - (cover.X + cover.Width), Height: cover.Height},
1192	}
1193}
1194
1195func intersectGraphicsDomainRect(a, b domain.Rect) (domain.Rect, bool) {
1196	left, top := a.X, a.Y
1197	if b.X > left {
1198		left = b.X
1199	}
1200	if b.Y > top {
1201		top = b.Y
1202	}
1203	right, bottom := a.X+a.Width, a.Y+a.Height
1204	if b.X+b.Width < right {
1205		right = b.X + b.Width
1206	}
1207	if b.Y+b.Height < bottom {
1208		bottom = b.Y + b.Height
1209	}
1210	if right <= left || bottom <= top {
1211		return domain.Rect{}, false
1212	}
1213	return domain.Rect{X: left, Y: top, Width: right - left, Height: bottom - top}, true
1214}
1215
1216func graphicsOutputDataWithDaemon(d *Daemon, state *capturedRenderState, ac *attachedClient, reset bool) (*preparedGraphicsOutput, error) {
1217	return graphicsOutputDataWithDaemonLimit(d, state, ac, reset, maxGraphicsOutputBytes)
1218}
1219
1220func graphicsOutputDataWithDaemonLimit(d *Daemon, state *capturedRenderState, ac *attachedClient, reset bool, maxBytes int) (*preparedGraphicsOutput, error) {
1221	if ac == nil || ac.output == nil || ac.output.graphicsOutput == nil || state == nil || !ac.terminalCapabilities.SupportsKittyGraphics() {
1222		return nil, nil
1223	}
1224	assets, placements := composedGraphicsRecords(state)
1225	prepared, err := ac.output.graphicsOutput.prepareWithRecordsLimit(assets, placements, reset, graphicsOutputTransform{}, maxBytes)
1226	if errors.Is(err, errGraphicsIDExhausted) {
1227		d.disableGraphicsOutput(ac)
1228		return nil, nil
1229	}
1230	if !errors.Is(err, errGraphicsOutputTooLarge) {
1231		return prepared, err
1232	}
1233	// Graphics are optional decoration. If an upload exceeds the bounded output
1234	// budget, suppress it and still return the bounded cleanup for objects that
1235	// were already proven to be owned by this attachment. ANSI composition is
1236	// prepared and emitted by the caller independently.
1237	prepared, err = ac.output.graphicsOutput.prepareWithRecordsLimit(nil, nil, reset, graphicsOutputTransform{}, maxBytes)
1238	if errors.Is(err, errGraphicsIDExhausted) {
1239		d.disableGraphicsOutput(ac)
1240		return nil, nil
1241	}
1242	if errors.Is(err, errGraphicsOutputTooLarge) {
1243		// Preparation is speculative, so the attachment still owns every object
1244		// that needs deletion. Leave that state intact for a later frame with more
1245		// graphics budget instead of withholding the already-prepared ANSI frame.
1246		return nil, nil
1247	}
1248	return prepared, err
1249}
1250
1251func graphicsOutputError(err error) error {
1252	if err == nil {
1253		return nil
1254	}
1255	return fmt.Errorf("kitty graphics output: %w", err)
1256}

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