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