Skip to content

Kitty graphics: placing with an existing placement id replaces it - #162

Closed
tomlm wants to merge 1 commit into
mainfrom
fix/kitty-placement-id-replace
Closed

tomlm wants to merge 1 commit into
mainfrom
fix/kitty-placement-id-replace

Conversation

@tomlm

@tomlm tomlm commented Oct 6, 2026

Copy link
Copy Markdown
Owner

Problem

Running notcurses-demo in a host built on XTerm.NET, the intro's moving sprite leaves a copy of itself at every step, so one picture becomes a trail across the screen.

Notcurses moves a Kitty image by re-sending a=p with the same i= and p= at the new position. It never sends a delete. Per the Kitty graphics protocol, placing with a placement id the image already has replaces that placement.

PlaceKittyImage always added a new placement. The only callers of DropPlacements were the two delete paths.

Fix

In PlaceKittyImage, when the command carries a non-zero placement id, drop the existing placement for that (image, placement id) before placing. It reuses the predicate the d=i delete path already uses.

  • Placements with no id (p=0) are anonymous and still accumulate.
  • Placement ids stay scoped to their image, so two images that both use p=1 do not replace each other.
  • The drop comes after the crop check, so a command that places nothing does not remove the old appearance.

Tests

KittyPlacementReplaceTests covers: a move (both rows of the old position cleared), a 20-step walk leaving one picture, anonymous placements accumulating, a different placement id being a second appearance, the same placement id on another image being left alone, and the stored image surviving a replace.

Not run locally. The environment this was written in has no .NET SDK and could not download one, so the change has not been compiled or tested. CI is the first build of it.

Notes for review

  • Cost. DropPlacements walks every line of both buffers, scrollback included, on each id'd placement. This is not the print path, but a client animating at a high frame rate with a long scrollback pays it per frame. An index from (image, placement id) to rows would remove the walk if it shows up in a profile.
  • Related gap, not fixed here. Re-transmitting an image under an id that is already stored (a=t or a=T with the same i=) replaces the registry entry, but the placements of the previous image stay on screen, because TerminalImage.Id is a fresh serial per transmission. Kitty removes the old image's placements in that case. A client that re-sends pixels each frame with a=T would still leave a trail.

🤖 Generated with Claude Code

https://claude.ai/code/session_0146GFSWj55CpWULQWNetAgB


Generated by Claude Code

Placing an image with a placement id (p=) that the image already has
names the same appearance, so it must replace the one on screen. That
is the protocol's move operation: notcurses re-sends a=p with the same
i= and p= at every step of a moving sprite and never sends a delete.

PlaceKittyImage always added a placement, so each step left a copy
behind and one sprite became a trail of them (visible in the
notcurses-demo intro).

Drop the existing placement for (image, placement id) before placing.
Placements with no id are anonymous and still accumulate, and placement
ids stay scoped to their image.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146GFSWj55CpWULQWNetAgB
@github-actions

github-actions Bot commented Oct 6, 2026

Copy link
Copy Markdown
Contributor

Perf comparison — this change, against its base

3 run(s) of each side, alternating on one machine. Allocation is a count and is gated exactly. Time is a measurement, so its gate is derived from the spread this job just observed in itself rather than fixed in advance.

corpus bytes/char gen0/Mchar ns/char Δ time noise gate
scroll-ascii 0.00 → 0.00 0.00 → 0.00 3.76 → 3.76 +0.0% ±4% 11%
sgr-churn 0.00 → 0.00 0.00 → 0.00 8.87 → 9.03 +1.9% ±4% 11%
truecolor 0.00 → 0.00 0.00 → 0.00 8.08 → 8.18 +1.2% ±1% 4%
alt-redraw 0.00 → 0.00 0.00 → 0.00 11.40 → 11.31 -0.8% ±1% 4%
unicode 7.66 → 7.66 0.09 → 0.09 30.99 → 30.90 -0.3% ±3% 10%
flood 0.00 → 0.00 0.00 → 0.00 116.64 → 116.93 +0.3% ±3% 8%

Each corpus is gated at max(4%, 3 × its own noise). A wide noise column means this runner was busy and the timing half of the table should be read as advisory; the allocation half is exact either way.

assemblies measured
  • base: XTerm.NET 2.0.3.0 mvid:49f84c34-f27d-4cac-b5ec-43bdd387e050
  • head: XTerm.NET 2.0.3.0 mvid:88821629-7421-47d0-bb9f-836040866d9d

Perf comparison — cumulative, everything since 2.0.3

3 run(s) of each side, alternating on one machine. Allocation is a count and is gated exactly. Time is a measurement, so its gate is derived from the spread this job just observed in itself rather than fixed in advance.

corpus bytes/char gen0/Mchar ns/char Δ time noise gate
scroll-ascii 0.00 → 0.00 0.00 → 0.00 3.85 → 3.76 -2.3% ±5% 15%
sgr-churn 0.00 → 0.00 0.00 → 0.00 8.71 → 9.03 +3.7% ±3% 9%
truecolor 0.00 → 0.00 0.00 → 0.00 8.16 → 8.18 +0.2% ±1% 5%
alt-redraw 0.00 → 0.00 0.00 → 0.00 11.30 → 11.31 +0.0% ±1% 5%
unicode 7.66 → 7.66 0.09 → 0.09 30.60 → 30.90 +1.0% ±5% 14%
flood 0.00 → 0.00 0.00 → 0.00 116.44 → 116.93 +0.4% ±3% 8%

Each corpus is gated at max(5%, 3 × its own noise). A wide noise column means this runner was busy and the timing half of the table should be read as advisory; the allocation half is exact either way.

assemblies measured
  • base: XTerm.NET 2.0.3.0 mvid:9ba0ce98-0d1e-4424-8aa1-9ab012cdc07a
  • head: XTerm.NET 2.0.3.0 mvid:88821629-7421-47d0-bb9f-836040866d9d

tomlm commented Oct 6, 2026

Copy link
Copy Markdown
Owner Author

Closing as a duplicate of #163, which fixes the same bug in the same place.


Generated by Claude Code

@tomlm tomlm closed this Oct 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants