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