v0.7.0 4c16dd8

graphics_output.go RawHistoryFolder

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

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