Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions docs/ceilings.md
Original file line number Diff line number Diff line change
Expand Up @@ -812,6 +812,13 @@ work when nobody knows it and that the call site never shows. The mechanism and
cell's background as well, the same as one below the backgrounds. Images live in the window's terminal, not in
the replay ring: a terminal rebuilt from the ring gets back the images whose bytes are still in it, which a
program that repaints (a TUI) recovers from and an image printed once at a shell prompt does not.
- **A PNG is decoded with the terminal locked** (`native/gioterm/kitty.go`): libghostty calls the decoder from
inside `ghostty_terminal_vt_write`, so `goDecodePng` runs under `Terminal.mu`, and the frame waits for that lock
in `Update`. One 1080p PNG holds it about 20 ms, a dropped frame for the whole window. Releasing the lock inside
the callback would let the frame into libghostty mid-write. The two ways out, if a slow window is ever reported:
decode ahead in `Write` (parse the Kitty APC chunks, `o=z` included, and hand the callback a cached result), or
let the frame skip a locked terminal and redraw its last snapshot, which every frame-side call that takes
`Terminal.mu` would need too.
- **One socket carries every session's output** (`internal/terminal/writequeue.go`): the per-session outbox
decouples the *producers*, never the wire. A window that stops reading stalls the connection's single writer,
so after `wsWriteTimeout` (5s) every session's output switches to the `/events` bridge at once. That is a
Expand Down
3 changes: 2 additions & 1 deletion native/cmd/lich-native/terminals.go
Original file line number Diff line number Diff line change
Expand Up @@ -380,7 +380,7 @@ func (ts *terminals) drop(id string) {

// onScreen records the sessions this frame draws a terminal for, and tells
// the backend about each that came on or went off screen. A terminal that
// went off screen lets go of its renderer's pixels: kept, every terminal ever
// went off screen lets go of its renderer's pixels and its images: kept, every terminal ever
// shown held a screenful of row images for good, the pages that ended in swap.
// It runs in the frame, the one goroutine that draws with a renderer.
func (ts *terminals) onScreen(ids ...string) {
Expand All @@ -391,6 +391,7 @@ func (ts *terminals) onScreen(ids ...string) {
hidden = append(hidden, id)
if t := ts.byID[id]; t != nil {
t.r.Release()
t.vt.ReleaseImages()
}
}
}
Expand Down
23 changes: 22 additions & 1 deletion native/gioterm/kitty.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,13 @@ static GhosttyKittyGraphics graphics_of(GhosttyTerminal t) {
return g;
}

static uint64_t graphics_generation(GhosttyTerminal t) {
GhosttyKittyGraphics g = graphics_of(t);
uint64_t gen = 0;
if (g != NULL) ghostty_kitty_graphics_get(g, GHOSTTY_KITTY_GRAPHICS_DATA_GENERATION, &gen);
return gen;
}

// Copies the placements on screen into out, at most max of them, and returns
// how many; -1 when the iterator cannot be read. A storage never written to
// returns 0 before touching the iterator, the cost every plain terminal pays.
Expand Down Expand Up @@ -190,7 +197,6 @@ func (k *kittyImages) init(t C.GhosttyTerminal) C.GhosttyResult {
if r := C.set_image_limit(t, imageStorageLimit); r != C.GHOSTTY_SUCCESS {
return r
}
k.raw = make([]C.placed_t, maxPlacements)
return C.ghostty_kitty_graphics_placement_iterator_new(nil, &k.it)
}

Expand All @@ -204,6 +210,13 @@ func (k *kittyImages) free() {
// libghostty lends is only valid until the next write.
func (k *kittyImages) update(t C.GhosttyTerminal, cellW, cellH int) error {
k.below, k.above = k.below[:0], k.above[:0]
if k.raw == nil {
// Most terminals never get an image: the placement buffer waits for one.
if C.graphics_generation(t) == 0 {
return nil
}
k.raw = make([]C.placed_t, maxPlacements)
}
n := int(C.collect_placements(t, k.it, &k.raw[0], C.int(len(k.raw))))
if n < 0 {
return fmt.Errorf("kitty graphics: placement iterator")
Expand Down Expand Up @@ -242,6 +255,14 @@ func (k *kittyImages) update(t C.GhosttyTerminal, cellW, cellH int) error {
return nil
}

// ReleaseImages lets go of the textures of a terminal off screen; the next
// Update makes again those it draws.
func (v *Terminal) ReleaseImages() {
v.mu.Lock()
defer v.mu.Unlock()
v.img.textures, v.img.below, v.img.above = nil, nil, nil
}

// texture is image id ready to draw, made again only when libghostty's
// generation for it moves (a retransmission under the same id).
func (k *kittyImages) texture(t C.GhosttyTerminal, id uint32) *texture {
Expand Down
39 changes: 39 additions & 0 deletions native/gioterm/kitty_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -214,3 +214,42 @@ func TestRowsClearUnderImages(t *testing.T) {
t.Error("a colored cell over an image is not opaque")
}
}

// A terminal no program sent an image to holds no placement buffer.
func TestKittyPlacementBufferOnFirstImage(t *testing.T) {
v := newKittyTerm(t)
if _, err := v.Update(); err != nil {
t.Fatal(err)
}
if v.img.raw != nil {
t.Fatalf("plain terminal holds %d placement slots", len(v.img.raw))
}
v.Write([]byte(kittyAPC("a=T,f=100,i=5,q=2", pngOf(t, 4, 4, color.NRGBA{A: 255}))))
s, err := v.Update()
if err != nil {
t.Fatal(err)
}
if len(s.above) != 1 {
t.Fatalf("placements above = %d, want 1", len(s.above))
}
}

// A hidden terminal lets go of its textures and makes them again when shown.
func TestKittyReleaseImagesDropsTextures(t *testing.T) {
v := newKittyTerm(t)
v.Write([]byte(kittyAPC("a=T,f=100,i=6,q=2", pngOf(t, 4, 4, color.NRGBA{A: 255}))))
if _, err := v.Update(); err != nil {
t.Fatal(err)
}
v.ReleaseImages()
if len(v.img.textures) != 0 || len(v.img.above) != 0 {
t.Fatalf("after release: %d textures, %d placements", len(v.img.textures), len(v.img.above))
}
s, err := v.Update()
if err != nil {
t.Fatal(err)
}
if len(s.above) != 1 || len(v.img.textures) != 1 {
t.Fatalf("shown again: %d placements, %d textures, want 1/1", len(s.above), len(v.img.textures))
}
}
Loading