SIGN IN SIGN UP

fix(kitty): draw Unicode placeholders after a=t + a=p,U=1, track GPU uploads per image (#1893)

* fix(kitty): resolve placeholder cells with placement id 0 to any virtual placement

Per the kitty graphics protocol the placement id of a Unicode placeholder
cell is carried in the underline color and "if it's omitted or zero, the
terminal may choose any virtual placement of the given image". kitty
(graphics.c, "Find the first virtual image placement") and ghostty
(graphics_unicode.zig, placeholderTarget) both do that.

Rio only tried the exact key and then (image_id, 0), so a placement
registered with p=N was never found for cells without an underline color.
That is the common case: Neovim's TUI emits SGR 58 only for cells that
carry an underline attribute (tui.c, `has_any_underline`), and
snacks.image registers its placements with p=N and does not underline the
placeholder cells - the cells reserved space but nothing was drawn.

Add `resolve_virtual_placement`: a non-zero id must match exactly (as in
kitty); id 0 falls back to the placement with the lowest id for that
image, so the result does not depend on hash-map order and an explicit
p=0 placement still wins. Use it in both renderers.

* fix(kitty): upload pixels for virtual placements after a=t

`a=t` deliberately defers the GPU upload to the placement ("pixel data is
sent to GPU when a=p placement arrives"). The direct a=p path does that in
`place_kitty_overlay`, and the combined a=T,U=1 path does it explicitly -
with a comment explaining that otherwise "the placeholder cells render as
blank space". The plain a=p,U=1 path after a=t never did, which is exactly
the sequence snacks.image and yazi emit: the placement was registered, the
placeholder run resolved, and nothing was drawn.

Queue the stored pixels before the image's first virtual placement
(`upload_for_virtual_placement`); re-placing the same image, which clients
do on every relayout, does not resend them. Retransmitting an image that
has virtual placements now refreshes those too.

* fix(kitty): track GPU uploads per stored image

Follow-up to the virtual placement upload fix. Deciding whether an
image's pixels are on the GPU from "does it have a virtual placement"
breaks in a few cases:

- alt screen: image stores and placements are per screen but GPU
  textures are per window and keyed by image id, so an alt-screen
  image reusing an id overwrote the main screen's texture and the
  main screen showed the wrong pixels after a round trip
- a=d then re-place resent pixels the texture still held, as did a
  virtual placement following a direct one of the same image

StoredImage now carries a gpu_uploaded marker set by every upload
path (direct, virtual, transmit+display, retransmit). A new image
store resets it. swap_kitty_screen_state invalidates images whose id
was uploaded by the outgoing screen and re-queues those with live
placements (the texture cache keys on transmit_time, so the resend
refreshes it).

Also:
- eviction protection and the dangling-placement sweep now cover
  virtual placements (they were only looking at direct ones, so a
  placed image could be evicted and its placeholder cells kept
  pointing at a missing image)
- a retransmit whose new dimensions no longer contain a virtual
  placement's source rect falls back to the whole image instead of
  rendering nothing at the placeholder cells
- resolve_virtual_placement tries (image_id, 0) before scanning the
  map, so the common case stays O(1) per placeholder run
- tests assert on the upload marker rather than pre-calling the helper

* fix(kitty): record texture contents per window instead of per image

A per-StoredImage upload flag only knows what its own screen did.
The other screen can overwrite the shared texture and then lose the
evidence: retransmit (new store, flag reset), delete with data (store
gone), or eviction (store gone, texture freed for both screens). In
each case the returning screen kept its flag and drew the wrong
pixels, or nothing, with no path that re-uploads.

Graphics now keeps kitty_texture_contents, a window-wide map from
image id to the transmission_time of the pixels last queued for it,
which is not swapped with the screens. "Uploaded" means the active
store's transmission_time matches that record. Eviction of a kitty
image drops the record along with the texture, and both eviction and
the screen swap re-queue placed active images whose record no longer
matches.

Also count virtual placements in collect_active_graphic_ids so a=d
with d=C/P/X/Y/Z/Q plus delete_data does not delete a virtually
placed image's data out from under its placeholder cells.

* fix(kitty): free a texture on eviction only when it holds the evicted pixels

Evicting the inactive screen's copy of an id queued a texture removal
even when the texture held the active screen's pixels, and the
same-batch requeue was then undone by the frontend, which applied
uploads before removals. Now a kitty eviction frees the texture only
if kitty_texture_contents says it holds the evicted store, and the
frontend processes removals before uploads so a batch that frees and
resends a key keeps the new pixels.

Also:
- store_kitty_image guarantees a strictly increasing transmission_time
  per id (web_time on wasm has millisecond resolution) since it doubles
  as the texture generation for the record and the renderer dedup
- the direct placement path goes through queue_kitty_upload like the
  virtual one instead of cloning and resending the pixels per new
  placement; the renderer never read the per-placement display size
  from the uploaded copy, and the dead Instant::now fallback is gone

* fix(kitty): free textures on delete and reset, align d=a and d=r with kitty

Texture release now goes through one helper used by eviction, delete
with data, and full reset: the frontend texture for an id is freed
only when the record says it holds the deleted store's pixels, a
pending upload of those pixels is cancelled so it cannot land after
the removal, and the record entry is dropped. Deleting image data also
sweeps the placements that pointed at it.

The strictly increasing transmission_time guard now considers the
inactive screen's copy of the id as well.

Delete semantics checked against kitty (graphics.c
clear_filter_func_noncell skips virtual refs; id_range_filter_func
covers every ref of the image) and ghostty (graphics_storage.zig
delete .all skips virtual placements): d=a no longer clears virtual
placements or the images they keep alive, and d=r now removes virtual
placements in range. The old test pinned the opposite for d=a.

* fix(graphics): namespace image textures by terminal route

The window's image store and GPU texture cache were keyed by the bare
kitty image id or atlas graphic id. Kitty ids are chosen by each
terminal's client and atlas ids are a per-terminal counter, so two
tabs or splits in one window using the same id overwrote each
other's pixels, and the per-terminal texture record could not see it.

route_image_key packs the terminal's route id above the existing
33-bit key space (route 0 is the identity, so libsugarloaf and the
VT layer's route-less keys are unchanged). The frontend namespaces
uploads and removals with the UpdateGraphics route id and the
renderer looks overlays up with the panel's route. Closing a tab or
split now drops that terminal's images and textures from sugarloaf,
which the id overwrite used to do by accident.

* fix(graphics): drop image batches for routes that are already closed

The VT thread can queue an UpdateGraphics batch right before the user
closes that tab or split; the main thread then inserted its pixels
under a route nothing would ever release. Skip batches whose route is
gone from the window. Also mask the key in route_image_key so an
out-of-range id can never spill into the route bits.

---------

Co-authored-by: reminiscience <4295987+reminiscience@users.noreply.github.com>
R
Raphael Amorim committed
96698f8687ade4826cda42dcf3d888c4a452f9fc
Parent: 0a538f7
Committed by GitHub <noreply@github.com> on 8/24/2026, 9:45:38 PM