v0.9.1 ece7682

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

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