Conversation
Three rendering fixes carried on top of the Ghostty 1.3 upgrade, plus a regression test: - Scroll boundary: floor viewportY once and use that single integer for both the scrollback/screen row comparison and the offset/screen-row math (render loop and hyperlink-hover scan). During smooth scroll viewportY is fractional; comparing rows against the raw value while indexing with the floored value read one row past the scrollback (returning null, leaving stale pixels) and dropped the top screen row, duplicating a line near the top of the viewport. Covered by renderer-viewport-boundary.test.ts. - Block elements: snap every partial-block edge to the device-pixel grid and add the quadrant blocks (U+2596-U+259F) as device-snapped rectangles. Quadrants previously fell through to font-glyph rendering, which doesn't fill the cell and left gaps in block/quadrant art (e.g. the Claude Code mascot), especially at line-height > 1. - Selection color: selected text keeps its original foreground instead of being forced to theme.selectionForeground, so editor theme colors survive selection (helper customization). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Reserve a SCROLLBAR_GUTTER (15px) on the right of the canvas so the scrollback scrollbar draws beside the text instead of painting over the last columns. Text still lays out across cols*metrics.width; the canvas is widened by the gutter and the needsResize check is updated to match. Route the scrollbar thumb and track colors through the theme (scrollbarThumb / scrollbarTrack), applying fade and idle dim via globalAlpha. The track defaults to empty so the gutter shows the terminal background, like VS Code's scrollbars, and is only drawn when a theme provides a color. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…roll blit Streaming output was spending almost all of its time re-reading the same cells out of WASM. Measured in Chromium against a real terminal: grid before after speedup 80x24 15.6 ms 1.96 ms 8.0x 120x40 56.3 ms 4.70 ms 12.0x 200x50 129.6 ms 9.37 ms 13.8x Three changes, in order of how much they matter. 1. Fetch the viewport once per frame. getLine() walks every cell of every row to return one row, so calling it per rendered row cost O(rows^2 * cols) WASM crossings a frame. Profiling a 120x40 grid: 40 getLine calls came to 58.7 ms, one bulk read came to 1.5 ms. That accounts for most of the speedup above, and for why the old path degraded quadratically as the terminal grew. Adds GhosttyTerminal.getViewportLines(), an optional member of IRenderable, and routes the renderer's row access through a lazy per-frame cache. Rows served from scrollback already used a direct per-row grid walk and are unchanged. 2. Glyph atlas (lib/glyph-atlas.ts). Rasterises each distinct glyph once and composites it with drawImage, keyed by text, colour and the bold/italic flags. Slots are sized from the glyph's ink box rather than the cell box so italics, combining marks and full-height powerline glyphs are not clipped. Shelf packed, repacks when full, and disables itself after four repacks so a working set that never fits falls back to fillText instead of thrashing. The lookup runs per painted cell, so it uses two-level buckets with the outer one memoised across a run. A single composite string key allocated thousands of strings a frame and made the atlas a net loss. 3. Scroll blit. A scroll relocates every row, which is why the buffer reports FULL dirty and the old path repainted the whole viewport. The pixels are still correct though, just in the wrong place, so move them with one copy and repaint only what the copy could not fill: 40 frames of scrolling went from 800 row paints to 80. The shift is derived, since nothing reports it. Row y always shows absolute line scrollbackLength - viewportY + y, so holding a line fixed across two frames gives: shift = (viewportY - lastViewportY) - (scrollbackLength - lastLength) Scrolling into history and streaming at the bottom both fall out of that. The result is treated as a hypothesis: each retained row is checked against two independent 32-bit hashes of what is actually on the canvas before its pixels are reused, so alternate-screen redraws and scrolling regions, which move content without growing scrollback, are caught and repainted. More than half the viewport failing verification falls back to a full repaint. An opaque background moves pixels in a single self-copy. Translucent themes stage through a scratch bitmap, because there the moved band has to replace the destination and clearing first would destroy the overlapping source. Verified by tests/e2e/10-renderer-optimisations.spec.ts, which diffs bitmaps rendered with each optimisation on and off. The blit is pixel exact. The atlas differs by at most 2/255 on 5.4% of pixels, spread evenly across rows, which is antialiasing rounding from rasterising at device scale in an unscaled context rather than CSS scale in a scaled one. Both paths sit behind the glyphAtlas and scrollBlit renderer options, defaulting on, so either can be switched off to isolate an artefact. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Follow-up to 96698f9, from grepping the remaining getLine() callers. Both of these walked the whole viewport to read a single row. renderCursor fetched the cell under a block cursor itself, which cost a full viewport walk on every frame that drew a cursor. It runs inside render(), which already has the frame's rows cached, so the row is passed in now. Viewport walks per frame drop from 2 to 1 and a 120x40 streaming frame goes from 4.70 ms to 3.18 ms, or 17.7x against the original renderer. The OSC8 link provider's backward scan called getLine() once per column while looking for the start of a link, on a row that does not change inside the loop. Scanning a long link was quadratic as a result. Hoisted out, which is how the forward scan a few lines below was already written. One more caller is left: SelectionManager.getSelection() fetches per row inside its loop over the selected range, so copying a selection costs one walk per selected screen row. It is a one-shot user action rather than a per-frame cost, and fixing it means threading a bulk read through the scrollback and screen split, so it is left for its own change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Completes the plumbing for the two renderer optimisations added in 96698f9, which until now could only be reached by constructing a CanvasRenderer directly. Embedders using Terminal got the defaults with no way to turn either one off. Adds both to ITerminalOptions, defaults them to true in TerminalCore, and forwards them when Terminal builds its renderer. handleOptionChange also handles them at runtime via the renderer's setGlyphAtlas/setScrollBlit, forcing one full repaint so the frame that follows starts from a canvas the blit can reason about. The setters landed in 72d3ac4 by accident, as they were written in a concurrent session while that commit was being staged. Committing the rest here rather than rewriting history, so the two halves are at least adjacent. Verified in Chromium: defaults reach the renderer, values passed at construction are respected, and toggling either at runtime leaves the canvas differing by at most 1/255 per channel, which is the antialiasing rounding between atlas and fillText rasterisation and nothing more. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.



We embed ghostty-web in a VS Code extension as the terminal for coding CLIs.
Streaming output felt slow enough to be distracting, so we profiled it instead
of guessing. This PR is the renderer half of what came out.
Fetching the viewport once per frame
getLine(y)on the WASM terminal callsgetViewport(), which walks every cellof every row and then throws away all but one. The renderer called it once per
visible row, so a single frame did around forty full viewport walks. That is
O(rows squared) crossings, and it degrades quadratically as the terminal gets
taller:
getViewportLines()walks once and slices, and the renderer caches it perframe. On a 120x40 streaming frame that single change is about 11 of the 12x in
this PR. The atlas and the blit are the remaining 11%.
Also worth reporting:
dirtyRowsaveraged 0.19 per frame while 36.58 rows werebeing painted. Ghostty reports
needsFullRedraw()on every scroll, becauserelocating every row's content cannot be expressed as a row set, so the per-row
dirty flags were doing nothing during streaming.
Glyph atlas
Rasterises each distinct glyph once, keyed by text, colour, bold and italic.
Slots are sized from
actualBoundingBox*ink extents rather than the cell box,so italics, combining marks and full-height powerline glyphs do not clip.
Engines without those metrics fall back to a padded cell box. Shelf-packed, and
after four repacks it disables itself and reverts to
fillTextrather thanthrashing.
One thing worth knowing before you review it. A first version built a
${style}|${color}|${text}cache key per cell, allocated thousands of strings aframe, and was a net loss at 0.96x. Two-level buckets with the outer one
memoised across a run turned it into a win. Chromium's Skia already caches glyph
masks internally, so a userland atlas only pays off if the lookup is cheaper
than
fillText, which is a narrower margin than it sounds.Scroll blit
The shift is derived rather than reported. Row y always shows absolute line
scrollbackLength - viewportY + y, so holding a line fixed across two framesgives
shift = (viewportY - lastViewportY) - (scrollbackLength - lastLength).Scrolling into history and streaming at the bottom both fall out of that one
formula.
It is treated as a hypothesis, not a fact. Every retained row is checked against
two independent 32-bit hashes of what is actually on the canvas before its
pixels are reused, and mismatches get repainted. Alternate-screen programs and
scrolling regions move content without growing the scrollback, and the hash
catches them. If more than half the viewport fails verification it falls back to
a full repaint.
Two mistakes fixed after measuring, in case they read as odd:
The blit was gated on
!forceAll, which meantneedsFullRedraw()disabled iton exactly the frames it existed for. A caller-requested repaint still blocks
it, but a full-dirty buffer does not, because the hashes are better informed
than a viewport-wide flag.
Every blit was staged through a scratch canvas, copying the full bitmap twice
per frame, which made it a net loss at 5.78 ms against 5.30. An opaque
background now moves pixels in one self-copy. The scratch is only used for
translucent themes, where clearing first would destroy the overlapping source.
Options
glyphAtlasandscrollBlitland onITerminalOptions, defaulting on, withlive toggling. Mainly so the two can be measured independently, which is how the
numbers above were separated.
Verification
tests/e2e/10-renderer-optimisations.spec.tsrenders identical content with eachoptimisation on and off and diffs the bitmaps.
The atlas delta is antialiasing rounding from rasterising at device scale in an
unscaled context instead of CSS scale in a scaled one. It is spread evenly
across rows. A positioning bug would show max delta 255 in clusters.
438 unit tests, the e2e suite,
tsc, biome and prettier all clean.Stacked on #28.