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