The renderer state mutex is unfair on all platforms (os_unfair_lock
on macOS, a futex based lock elsewhere). A thread that unlocks and
right away locks again wins over a sleeping waiter, because the
waiter first has to be woken up and scheduled. The termio parse
thread does exactly this under heavy pty output: it relocks the
mutex for every batch and never sleeps in between, so the renderer
can starve in updateFrame for as long as the output lasts.
Fix this by letting the parse thread stay off the lock until the
renderer had its turn. `renderer.State` gets two atomics: a waiter
count (`demand`) and a generation counter (`handoff_gen`). The renderer
takes the mutex through lockDemand/unlockDemand which update these,
and the parse thread calls yieldToDemand between batches. If a
waiter exists it sleeps on a futex until the renderer took and
released the lock, with a 1ms timeout so a lost wake can not stall
IO forever.
All the atomics are monotonic on purpose: they are only a hint for
scheduling, the mutex still protects the terminal state itself.
When the renderer is not waiting the cost for the parse loop is a
single relaxed atomic load per batch.
Determine if any links are active before building the string and
byte-to-cell map. Those buffers scale with viewport size, and this
function runs during frame updates, so avoid allocating them when the
current mouse/modifier state can't highlight any regex links.
This adds an additional `self.links` iteration, but that list is usually
small, the "active" check is cheap, and it breaks on the first hit.
This optimizes `RenderState.update`, the per-frame call that snapshots
terminal state for the renderer and is the main reason the renderer
thread holds the terminal lock.
Lock hold time is reduced ~2.7x to ~11x depending on the frame.
## The changes
1. iterate page chunks instead of rows in `update`
2. classify cells with masked vector compares.
3. split the update into `beginUpdate`/`endUpdate` phases. There's a
lot to be gained by accumulating data with the lock held and then
processing it out of the lock.
4. generalize the masked-compare scans into `page.Mask`. This is just
a really common pattern we're doing now and it yields a ton of great
value. Its error prone so lets make it a tested helper.
## Benchmarks
Measured with the new `ghostty-bench +screen-clone` modes (`render`,
`render-locked`, `render-clean`, `render-partial`), 120x80 terminal, M4
Max, macOS 26, ReleaseFast, hyperfine means of 10+ runs, per-update
times derived from fixed-count update loops with process startup
subtracted. "Lock held" is the time the terminal lock must be held per
update; "before" held the lock for the entire update.
| scenario | before (lock held) | after (lock held) | after (total) | lock change |
|----------|--------------------|-------------------|---------------|-------------|
| clean frame (nothing dirty) | 202 ns | 19 ns | 19 ns | 10.9x |
| partial frame (1 dirty row) | 290 ns | 54 ns | 54 ns | 5.4x |
| full rebuild, lightly styled | 6.9 µs | 2.5 µs | 3.0 µs | 2.7x |
| full rebuild, fully styled | 9.3 µs | 2.4 µs | 8.0 µs | 3.8x |
| full rebuild, fully styled, 250x150 | 49.9 µs | 9.4 µs | 31.6 µs | 5.3x |
| full rebuild, plain text | 1.9 µs | 1.9 µs | 1.9 µs | 1.0x (memcpy floor) |
The clean and partial cases are the steady-state frame costs (cursor
blink, mouse movement, typing). The full-rebuild cases are the contended
ones: colored scrolling output (build logs, htop, vim) moves the
viewport pin every frame, forcing a full rebuild exactly when the IO
thread is busiest, so that row of the table is where lock contention
actually hurts. Plain text was already at the memcpy floor and is
unchanged.
## LLM Notes
This work was driven by Fable 5: benchmarks, optimizations, the property
test, and the measurements above. I reviewed every line, simplified the
design in a few places (API naming, the Mask helper shape), and re-ran
the verifications myself.
Add a generation counter to the kitty graphics image storage. Every
content mutation (image transmit/replace, placement add, delete)
assigns the storage a fresh stamp, and every image is stamped when
it is added or replaced.
This solves two problems:
First, a retransmission of the same image ID with identical dimensions
was previously undetectable by anything comparing width, height, format,
and data length; the per-image stamp changes on every add/replace, so caches
keyed on it always see the change. Second, the dirty flag was the only
storage-wide signal, and it is also set by scrolling and resizing, which move
placements without changing contents. The generation is only bumped by content
mutations, so an unchanged value means the placement set and all
image data are identical and consumers can skip re-reading them,
recomputing only placement geometry.
The generation replaces Image.transmit_time entirely: newest-image
lookup by number, eviction ordering, and the renderer's texture
staleness checks all key on it now. A monotonic counter strictly
orders transmissions where Instant-based times could collide within
clock resolution (the renderer previously assumed equal timestamps
meant identical images), and this removes a syscall and an error
path per transmission.
Delete commands now only mark a mutation (dirty flag and
generation) when they actually remove something. A delete-all runs
on every screen clear, so previously every ESC [ 2 J dirtied the
image state even with no images stored. Eviction via setLimit also
now marks the state dirty, which it previously did not.
Both generations are exposed through libghostty-vt as
GHOSTTY_KITTY_GRAPHICS_DATA_GENERATION and
GHOSTTY_KITTY_IMAGE_DATA_GENERATION (uint64_t), and the headers now
document that stored image data is always post-inflate/post-decode:
COMPRESSION always reports NONE, FORMAT is never PNG, and DATA_PTR
is raw pixels ready for GPU upload.
uint64_t gen = 0;
ghostty_kitty_graphics_get(
graphics, GHOSTTY_KITTY_GRAPHICS_DATA_GENERATION, &gen);
if (gen == last_gen) return; // nothing changed, skip re-reads
renderCallback early-returns while !flags.visible to avoid the
cell rebuild for hidden surfaces (tab switch, minimize, etc.).
The .visible → true mailbox handler now runs updateFrame before
drawFrame so the first frame after re-show isn't stale.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Expose Placement.pixelSize() and Placement.gridSize() as new C API
functions ghostty_kitty_graphics_placement_pixel_size() and
ghostty_kitty_graphics_placement_grid_size(). Both take the placement
iterator, image handle, and terminal, returning their results via
out params.
Rename the internal Zig method from calculatedSize to pixelSize to
pair naturally with gridSize — one returns pixels, the other grid
cells. Updated all callers including the renderer.
Previously WindowPaddingBalance was defined inside Config.zig, which
meant tests for renderer sizing had to pull in the full config
dependency. Move the enum into renderer/size.zig as PaddingBalance
and re-export it from Config so the public API is unchanged. This
lets size.zig tests run without depending on Config.
Fixes#11705
Add bg_color and fg_color options to GhosttyRenderStateRowCellsData
that resolve the final RGB color for a cell, flattening the multiple
possible sources. For background, this handles content-tag bg_color_rgb,
content-tag bg_color_palette (looked up in the palette), and the
style bg_color. For foreground, this resolves palette indices through
the palette; bold color handling is not applied and is left to the
caller.
Both return GHOSTTY_INVALID_VALUE when no explicit color is set, in
which case the caller should fall back to whatever default color it
wants (e.g. the terminal background/foreground).
Size report escape sequences were previously formatted inline in
Termio.sizeReportLocked, and termio.Message carried a duplicate enum for
report styles. That made the encoding logic harder to reuse and kept
the style type scoped to termio.
Move the encoding into terminal.size_report and export it through
terminal.main. The encoder now takes renderer.Size directly and derives
grid and pixel dimensions from one source of truth. termio.Message now
aliases terminal.size_report.Style, and Termio writes reports via the
shared encoder.
Convert Coordinate in terminal/point.zig and CellSize, ScreenSize,
GridSize, and Padding in renderer/size.zig to extern structs. All
fields are already extern-compatible types, so this gives them a
guaranteed C ABI layout with no functional change.
Change `window-padding-balance` from `bool` to an enum with three
values:
- `false` - no balancing (default, unchanged)
- `true` - balance with vshift that caps top padding and shifts excess
to bottom (existing behavior, unchanged)
- `equal` - balance whitespace equally on all four sides
This gives users who prefer truly equal padding a way to opt in without
changing the default behavior.
The terminal.Stream next/nextSlice functions can now no longer fail.
All prior failure modes were fully isolated in the handler `vt`
callbacks. As such, vt callbacks are now required to not return an error
and handle their own errors somehow.
Allowing streams to be fallible before was an incorrect design. It
caused problematic scenarios like in `nextSlice` early terminating
processing due to handler errors. This should not be possible.
There is no safe way to bubble up vt errors through the stream because
if nextSlice is called and multiple errors are returned, we can't
coalesce them. We could modify that to return a partial result but its
just more work for stream that is unnecessary. The handler can do all of
this.
This work was discovered due to cleanups to prepare for more C APIs.
Less errors make C APIs easier to implement! And, it helps clean up our
Zig, too.
Specifically:
iCurrentCursorStyle
iPreviousCursorStyle
iCurrentCursorVisible
iPreviousCursorVisible
Visibility calculated and updated independently from the typical cursor
unifrom updates to preserve cursor style even when not in the viewport
or set to be hidden
Fixes#10680
The image state is used for drawing, so when we update it, we need to
acquire the draw mutex. All our other state updates already acquire the
draw mutex but Kitty images are odd in that they happen in the critical
area (due to their size).
Discussed in https://github.com/ghostty-org/ghostty/discussions/10739
## Summary
Remove the hardcoded opaque background (alpha=255) from IME preedit
cells so they respect `background-opacity` like all other cells.
When `background-opacity` is less than 1, preedit (composition) text was
rendered with a fully opaque background, causing the text to appear
highlighted and hard to read. This change removes the explicit per-cell
background from `addPreeditCell`, letting preedit cells fall through to
the global background. The underline indicator is preserved to mark the
preedit region.
---
`background-opacity` が 1
未満のとき、IME入力中(preedit)のセルが完全不透明な背景で描画され、ハイライトされたように見えて読みづらくなる問題を修正しました。
`addPreeditCell` のセル背景描画を削除し、グローバル背景に委ねることで通常セルと同じ透過表示になります。
preedit領域のアンダーラインは維持されます。
## Test plan
- Set `background-opacity` to a value less than 1 (e.g. 0.5)
- Type Japanese (or other IME input) to trigger preedit
- Verify preedit text no longer appears highlighted
- Verify the underline indicator is still drawn under preedit text
AI disclosure: I used Claude Code to investigate the source code and
generate code changes in this PR.
Fixes#10424
Replaces #10431
The issue is that when the row where preedit was wasn't dirty, we were
layering more preedit cells (identical ones) on top, so it'd appear to
get "thicker".
The draw timer should only be activated in case a custom shader
is configured and the option custom-shader-animation is either always
or true. If the timer is kept alive CPU usage spiked enourmously.
Fixes#10667
Fixes#10522
This also fixes possible runtime safety crashes. Whenever the underlying
size information doesn't match what our renderer or grid see, then we
should deinit and reinit.
This extracts all our image renderer state into a separate struct,
blandly named `renderer.image.State`. This structure owns all the
storage of images and placements and exposes a limited public API
to manage them.
One motivation was to limit state access by our Kitty graphics functions
within the generic renderer. Another was to limit our own generic
renderer from getting our image system into an incoherent state. This is
prevented now on both sides due to some encapsulation.
This currently only supports Kitty images, since that's the only image
protocol we support. But I intend to add additional image types to this,
namely the ability to add overlay images for debug information.
**There are no plans to add new image protocols to the terminal,** the
extraction is purely to support some internal features. But, it could be
used for other protocols one day.
Adds palette and color scheme uniforms to custom shaders, allowing
custom shaders to access terminal color information:
- iPalette[256]: Full 256-color terminal palette (RGB)
- iBackgroundColor, iForegroundColor: Terminal colors (RGB)
- iCursorColor, iCursorText: Cursor colors (RGB)
- iSelectionBackgroundColor, iSelectionForegroundColor: Selection
colors (RGB)
Colors are normalized to [0.0, 1.0] range and update when the palette
changes via OSC sequences or configuration changes. The palette_dirty
flag tracks when colors need to be refreshed, initialized to true to
ensure correct colors on new surfaces.