Skip to content

feat(render): GPU grid with decorations from font metrics [2] - #203

Draft
AbysmalBiscuit wants to merge 71 commits into
mathix420:masterfrom
AbysmalBiscuit:feat/decoration-metrics
Draft

feat(render): GPU grid with decorations from font metrics [2]#203
AbysmalBiscuit wants to merge 71 commits into
mathix420:masterfrom
AbysmalBiscuit:feat/decoration-metrics

Conversation

@AbysmalBiscuit

@AbysmalBiscuit AbysmalBiscuit commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

TL;DR (human written)

This PR adds a new rendering approach for the terminal that is faster than the existing one. The speedup varies from 5% up to 25%.
It also adds support for all terminal styles of underlines, and handles double-width characters (e.g., CJK).

examples of all underlines

This PR almost guarantees that rendering won't be a bottle neck again, so future performance improvements can just focus on app logic instead.

To use this new renderer, you need to set gpu_grid = true in your config:

[ui]
gpu_grid = true

To customize the decorations, you can use the new [ui.decorations] table (inspired by how kitty does it):

[ui.decorations]
underline_position = "1px"
underline_thickness = "150%"
strikeout_position = "-0.5pt"
strikeout_thickness = "125%"

I am planning to support some more features for it later (e.g., colored underlines), so for now I'm leaving the old rendering path.

Closes AbysmalBiscuit#9
Closes AbysmalBiscuit#12
Closes AbysmalBiscuit#41

Claude summary follows:


Stacked on #202. The base is master, so the diff below also contains that PR's commits. Review #202 first.

Two changes, both behind config options that default to off:

  • the terminal grid draws through an OpenGL callback instead of egui shapes
  • underlines and strikeouts take their position and weight from the font's own metric tables, with four knobs to correct a face that reports bad ones

Part 1: drawing the grid on the GPU

Drawing the terminal grid costs about 212 ns per cell, and nearly all of that goes into building a list of shapes and then flattening it back into triangles. You can reproduce the number on master today:

cargo test -p alacritree --release -- --ignored --nocapture report_paint_cost

Per character the painter does a GlyphCache lookup, clones an Arc<Galley>, and pushes a TextShape onto egui's shape list. epaint's tessellator then walks each single-glyph galley to emit four vertices. The split is close to even: roughly 105 ns building shapes, roughly 102 ns turning them into triangles. On a 318x83 grid (a 2560x1440-point window at the default font size) that is 26,394 cells and about 105,000 vertices, written by the CPU every frame so the GPU can read them straight back.

I call that the mesh path below. It is what draws the grid today, and this branch leaves it as the default.

Damage tracking would only recover the first half of that. egui re-tessellates every frame whatever happens, and turned down caching it: context.rs says comparing last frame's shapes costs about half what tessellating them costs. So the ceiling inside egui's Shape model is around 2x, for a lot of restructuring.

What this does

egui lets a caller hand epaint an opaque draw callback instead of shapes. The grid takes that route through egui::PaintCallback, so epaint sees a single shape carrying no geometry and everything below it runs on our own GL context.

  • One twelve-byte record per cell, in a flat array indexed row * cols + col (grid_instances.rs). The vertex shader derives the quad from that record, so no vertices cross the bus. Only rows the terminal reported damaged get rewritten.
  • Three instanced draws over that one buffer (grid_gl.rs): backgrounds, glyphs, decorations, in that order. Decorations go last because alacritty draws its rects over the text in display::draw, so a descender crossing an underline has to come out the same way here.
  • No new glyph atlas. egui_glow::Painter::texture hands over the atlas epaint already packed its glyphs into, so the shader samples the same artwork.
  • Underlines and strikeouts become a sprite strip (decoration_sprites.rs) sampled per cell, rather than one shape per run for epaint to tessellate every frame.

Emoji and box-drawing glyphs stay on egui's painter. They carry their own textures or their own geometry, and there are few enough of them per screen to leave alone.

Both options default to off

[ui] gpu_grid turns the path on. An unmodified config keeps the mesh path. A GL context too old for instanced arrays logs once, loses the frame it was found on, and falls back for the rest of the session.

[debug] gpu_timing wraps the upload and each draw in timer queries and logs a median line per window. It exists so a bug report about this path can carry numbers rather than adjectives. Results are read a few frames late, because asking for one on the frame that issued it would block until the GPU caught up.

The wide-glyph bug this exposed

Underlines are new on this path, so every style got a look. Under CJK the decoration covered the left half of each character and stopped there.

The cause sits in GridSnapshot::capture, not in the new renderer, so the mesh path had it too.

A wide character occupies two cells: a lead holding the character and Flags::WIDE_CHAR, then a spacer holding a space and Flags::WIDE_CHAR_SPACER. alacritty_terminal writes both from the same cursor template (term/mod.rs, write_at_cursor), so the spacer carries identical colours and identical SGR flags and differs only in which of those two bits is set. capture groups cells into runs of one style, and Style::from_cell compared raw flags, so that single bit read as a style change. Every wide character ended its run early and its spacer was dropped from the frame entirely.

The fix masks both bits out of the style comparison and stops discarding spacers. kitty reaches the same place from the other end: screen.c copies the lead cell's GPU record into the spacer at parse time, and its renderer then has no width logic anywhere. alacritty does the opposite, dropping spacers from its renderable iterator and adding the column back when it draws a line (renderer/rects.rs). I tried alacritty's shape first, reconstructing the missing column with width arithmetic in the loop that writes cell records. It worked and cost about 4% of that loop, measured against a captured snapshot replayed in one process. Masking two bits costs nothing, because the grid already holds what the arithmetic was recomputing.

One pleasant side effect: 你好 now captures as a single run spanning four columns instead of two runs of one column each, so a screen full of CJK produces about half as many runs.

Measurements

termbench (forked from the original termbench) drives the terminal with several output workloads and reports wall time for each, plus a total.

Every run here is paired. Two builds launch a minute apart and the ratio is taken within the pair, so machine drift divides out rather than landing on whichever build met it. Which build goes first alternates every pair. Four grid sizes, one machine. Below 1 means this branch was faster.

grid cells total pairs kept
78x18 1,404 0.975 5 of 5
198x47 9,306 0.955 5 of 6
198x46 9,108 0.957 5 of 6
352x80 28,160 0.944 6 of 6

I threw out three pairs because a load check caught a CPU spike inside one of the two builds, which pairing cannot divide away.

The advantage grows with cell count, which is what a per-cell cost predicts and a fixed overhead would not. 352x80 is the row to trust: every pair inside 2% on the load check, all six pointing the same way. The two 198x4x rows are the same experiment run twice, landing within 0.002 of each other, which is what makes the gap up to 78x18 readable as signal rather than noise.

Per workload, at three of those grid sizes:

workload 1,404 cells 9,108 cells 28,160 cells
sc.noscroll 1.003 0.877 0.744
sg.plain 0.977 0.850 0.778
tb.LongLine 0.944 0.920 0.879
sc.scroll 0.942 0.928 0.875

sc.noscroll fills the screen without scrolling it, and it is the clearest case: no gain at all on a tiny grid, 25.6% faster on a full-screen one. That is a per-cell submit cost showing up only once there are enough cells to pay for it.

What these numbers do not settle

The faster build also carried the PTY packet-storm fix from #195, which was unmerged at the time and changes how quickly the reader drains bytes while the paint lock is contended. termbench measures almost exactly that. So 0.944 is an upper bound on what this renderer contributes, not an isolated figure for it. The direction and the growth with cell count still hold. A clean re-run against current master, now that #195 has landed, would replace the bound with a number, and I have not done it.

termbench also measures throughput rather than frame time. It asks how fast the terminal swallows a stream, which is mostly parser and grid-update bound, so a paint saving shows up as UI-thread headroom sitting behind a bottleneck somewhere else.

One regression, and it is by design

Three SGR-heavy workloads move the other way, and unlike the wins they do not scale:

workload 1,404 cells 9,108 cells 28,160 cells
sg.percellbg 1.001 1.046 1.021
sg.underline 0.985 1.030 1.018
sg.percell 0.979 1.006 1.013

Every cell gets an instance in all three passes. A screen where each cell carries its own background colour and its own decoration therefore pays every pass at full width, where the mesh path emitted shapes only for the runs that differed. That trade is what buys the flat per-cell cost in the table above. On the workload built to stress SGR it costs 1 to 4%, and it is worth naming rather than burying.


Part 2: decorations from the font's own metrics

Underlines and strikeouts were placed by three constants scaled off the cell height:

thickness: (cell_h * ppp / 14.0).round().max(1.0),
underline_y: (cell_h - 1.5) * ppp,
strikeout_y: cell_h * 0.5 * ppp,

The font's own numbers went unread, so a face that asks for a fine stroke and a low underline got neither. The post table carries underlinePosition and underlineThickness; OS/2 carries yStrikeoutPosition and yStrikeoutSize. Upstream alacritty reads all four plus the descent through crossfont::Metrics and places its lines from them in renderer/rects.rs, so this was a divergence rather than a shared limitation.

ttf-parser was already in the lockfile. Face::underline_metrics() and Face::strikeout_metrics() read those two tables and apply variable-font metric variations, so a variable face reports metrics for its instantiated weight rather than its default master. The primary face is parsed once at startup.

Finding the baseline

Geometry needs the baseline's position inside the cell, and deriving it from the face drifts from where glyphs actually sit. FontImpl::new quantizes the ascent it stores: epaint divides the scaled ascent by pixels_per_point and calls round_ui, which snaps to a multiple of 1/32 of a logical point. The face's ascent and the one epaint lays out against are different numbers.

Glyph.font_ascent on a laid-out galley is the quantized one, so that is what the geometry uses. The grid's own GlyphCache already holds a galley for M at the paint size, so reading the ascent is a map lookup rather than a fresh layout per frame.

That also yields the em size without depending on epaint's scaling internals: px_per_em = font_ascent_px / metrics.ascender.

The descent area

baseline and descent are new on Geometry, and together they give the band from baseline to baseline + descent. Two styles anchor there rather than on underline_y, which is what alacritty does:

  • double: two stems, centred at baseline + 0.25 * descent and baseline + 0.75 * descent
  • curly: a sine wave filling the band, centre baseline + descent / 2, amplitude (descent - underline_thickness) / 2

Straight, dashed and dotted keep underline_y and the font's thickness. This matters because once thickness comes from the font, deriving a curl's height from it gives a face with a fine stroke a curl too shallow to read, and a double underline whose stems merge.

alacritty reaches the same band from the cell's bottom edge, which works there because its cell height is the font's. Here cell_h is a floored row height plus font.offset.y, so the cell bottom and baseline + descent are two different places and only the baseline tracks where glyphs sit.

The single thickness field splits in two, because the font reports separate weights for the two lines and nothing justified picking one of them for both.

A face can ship a table with a zero in it, which kitty and ghostty both guard against. Same guards here: a missing underline thickness falls back to 0.05 em, a missing position to -0.1 em, a missing strikeout thickness to the resolved underline thickness, and a missing strikeout position to 0.35 x ascender. A face that will not parse at all yields every default and one log::warn!.

The knobs

Four optional string fields, for correcting a face whose metrics are wrong:

[ui.decorations]
underline_position = "-2px"
underline_thickness = "150%"
strikeout_position = "2pt"
strikeout_thickness = "200%"
suffix means
px physical pixels, added
pt or none points, added after scaling by pixels_per_point
% multiplies the resolved pixel value

Positive moves the line down, matching kitty and ghostty, so a value copied from either behaves the same way. kitty spells points as a bare number; pt is accepted as well so a config can say what it means, and everything kitty accepts still parses.

"0" adds nothing and "100%" multiplies by one, so both units express the same no-op and an unmodified config follows the face. A percentage carries no sign: kitty takes the absolute value of a negative one, which is a silent surprise, so this rejects it and logs. Any value that will not parse logs a warning naming the field and the offending text, then behaves as "0". Nothing here panics.

Scaling happens before rounding, because rounding first would quantize the value a percentage then scales, leaving a 50% request against a 1px line with nothing to halve.

One caveat worth stating for anyone porting a kitty config: because double and curly anchor on the descent, underline_position does not move them. kitty derives both from underline_position instead (decorations.c, add_double_underline and add_curl_underline). This follows alacritty.

The mesh path is unchanged

The old renderer keeps its constants and its single straight rule. It is slated for removal once the GL path is stable, and the comment at the construction site now records that the two paths differ deliberately.


Tests

cargo test -p alacritree runs 1,116 unit tests plus 7 in tests/cli_isolation.rs and 2 in tests/config_schema.rs.

One test fails on my machine right now: session::tests::a_pane_runs_its_child_without_a_console_host_handshake asserts cmd /c echo ready exits within 2 seconds, and under load it does not. That test and its assertion are byte-identical to master's, and nothing in this branch touches it.

The GL path runs headless through a GpuGrid whose callback is emitted and never invoked, which covers everything on the CPU side (capture, run building, record writing) and the fallback when the context will not build. Two tests cover the wide-glyph fix: one asserts a spacer keeps its background and its decoration, the other that a run holds one character for every column it spans.

The decoration work is covered headless with no GL context: the suffix parser over the values that should and should not parse, FaceMetrics read from the bundled alacritree-symbols.ttf with each value asserted normalized to the em, each fallback from a synthetic FaceMetrics with one field zeroed, and the geometry resolved with zero adjustments. The assertion that catches an anchor read off the cell bottom instead of the baseline is on rasterized output: on a Geometry whose descent area sits clear of the cell edges, the curly tile's ink stays inside [baseline, baseline + descent] and the double tile's two stems straddle its midpoint.


Written by Claude Opus 5 in Claude Code.

AbysmalBiscuit and others added 9 commits August 31, 2026 14:57
A build saturating every core starves the shell the user is typing
into: the line editor redrawing its prompt waits behind sixteen
compilers at the same scheduling class, and the keystroke round trip
stretches into seconds.

Raising the shell alone does not reach that, because a Windows priority
class does not spread to the processes a raised process starts. A job
object does: a process joins the job it is created in and comes up at
the job's class, at any depth and with no scanning. The session takes
the job at spawn rather than on focus, since anything started before
the job exists escapes it for good.

Only the session on screen, and only while the window has focus, is
raised; the GUI follows so it does not lose to the tree it draws.
Behind `[ui] focus_priority_boost`, off by default, and Windows only.
A Unix nice value is already inherited and cannot be lowered back
without privilege, so the platform module there is a no-op.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A job's priority class is a ceiling, not a setting.  Releasing the boost
named normal but left the limit standing, so every process an unfocused
session held was capped there and could not raise itself — a build or an
agent running under a background tab lost the class it asked for, and
kept losing it until that tab was focused again.

Release now names normal to lower the members already running, then
clears the limit so they own their class again.  Drop is the same two
steps, so it needs nothing beyond releasing the boost.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The console reaps only the programs attached to it, so anything that
leaves it — an editor's background search, anything started detached —
outlives the terminal that asked for it and piles up until the machine
is rebooted.  Nothing else holds those processes: the job object is the
only thing that has every descendant at every depth.

`[ui] reap_descendants_on_close`, off by default, gives the job
kill-on-close, so the kernel ends its members when the last handle goes.
That covers a killed or crashed alacritree as well as a closed tab,
since no cleanup path has to run.  Breakaway rides with it: a process
that means to outlive the terminal asks with `CREATE_BREAKAWAY_FROM_JOB`
and is let go, which is a request the kernel refuses without the flag.

The lifetime limits travel with the priority one on every set, because
`SetInformationJobObject` replaces `LimitFlags` whole and a change of
focus would otherwise drop them.  One job serves both options, so it is
created when either wants it.

The tests tear down real pseudoconsoles and check what outlived them,
against an unjobbed baseline that pins any failure on the harness rather
than the feature.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The reaping option shipped without a test that the console misses
anything, so nothing said whether the job was fixing a real leak.

The escaping child is now a process started with `DETACHED_PROCESS`,
which has no console and so is nobody's console client — the shape of a
completion helper an editor spawns.  Only a process already inside the
session can start one, so the session runs this binary again and an
ignored test does the spawning; that also keeps the desktop clear, where
a new console would have opened a window.

Two arms make the claim falsifiable: unjobbed, the child outlives the
teardown, and a failure there would mean there is no leak to fix; jobbed,
it does not.  The session is jobbed before its tree is waited for, as a
real session does it, because a process joins a job when it is created
and anything already running stays outside it for good.

Members carry their name and their kernel-reported job membership, so a
survivor says on its own whether the model or the flag is at fault.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The cmd.exe subjects and the taskkill sweep carried no CREATE_NO_WINDOW,
so each one flashed a console window during a test run, and the subjects
holding a `ping` open kept theirs for a minute. command_ext exists for
this; the tests pipe their stdio and assert on job membership and
priority, neither of which the flag touches.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A test binary has no `main`, so hardening was left to each test that
opened a pseudoconsole. The focus-priority tests added one that does not,
and once any PTY opens first the module it loaded answers every later
`LoadLibraryW("conpty.dll")` in the process. That is the three-second
console-host stall the handshake test exists to catch, and it started
failing on CI for tests that never touched it.

Harden immediately before each `tty::new` instead, guarded by a `Once` so
the repeat costs nothing and `main` keeps its startup call. The per-test
calls go away with the convention they were upholding.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The pid came from a subject the test had already killed and reaped, so
Windows was free to hand that number to anything.  Under a parallel run
it handed it to another test's freshly spawned subject, which then read
ABOVE_NORMAL where it expected NORMAL.

Use a number the kernel never allocates, and assert that it names no
process rather than trusting the arithmetic.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every subject was born at whatever Windows chose, and the boost tests
then asserted that choice was normal.  Windows hands a new process its
creator's job class when the creator is in a job, and half of these
tests build jobs, so under a parallel run a subject sometimes came up
above normal and the first assertion failed before the boost was even
applied.

Pass the class in the creation flags.  The one test whose subject exists
to report Windows' choice keeps making none, and says what to suspect
if it ever comes up raised.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`tree_of` walked the process table by parent pid alone.  Windows frees a
pid for reuse the moment the last handle to it closes, so an orphan goes
on naming the number its dead parent had and whoever is given that number
next inherits it as a child.  The teardown arms taskkill every survivor
they find, which turned a wrong answer here into an unrelated process on
the machine being killed: a build's stray vctip.exe was reaped as part of
a session tree it never belonged to.

No child predates its parent, so a start time earlier than the parent's
rules the candidate out.  A process the snapshot cannot open reports no
start time at all, and those keep their place rather than being read as
born at the epoch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@AbysmalBiscuit
AbysmalBiscuit force-pushed the feat/decoration-metrics branch from 7e7131d to e2541de Compare August 31, 2026 21:57
Font fixtures went into the shared system temporary directory under fixed
names.  Unique within one binary, but two binaries running at once write
the same paths, and a fixture one of them has mapped cannot be rewritten
by the other: Windows fails the write with ERROR_USER_MAPPED_FILE.  Five
fonts tests failed together in one run here, which is what a second
binary mapping its whole fixture set looks like.

Hang the fixtures off a directory named for the process instead, and
sweep the ones whose process has gone.  The names lose the prefix that
was disambiguating them inside the shared directory.

Closes #46

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
AbysmalBiscuit and others added 3 commits September 2, 2026 16:33
The probe for a foreign console host asserted that a pane's child
exited inside two seconds, then ten. Both bounds are wall-clock, so a
Windows runner that descheduled the polling thread failed the test with
nothing wrong: once on each bound, both passing on re-run.

What the probe is there to catch is `LoadLibraryW("conpty.dll")`
resolving out of PATH, and that is observable directly. A file named
conpty.dll that is not a module tells the two loader outcomes apart: a
loader that reaches PATH finds it and reports ERROR_BAD_EXE_FORMAT, a
hardened one reports ERROR_MOD_NOT_FOUND. Both arms run as child
processes because SetDefaultDllDirectories is process-wide and cannot
be undone, so the parent cannot host the unhardened arm.

The control arm pins the unhardened code as well, so a plant the loader
never reaches fails the test rather than passing it by default.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`the_probe_hands_the_scan_to_the_refresher` asserted the call returned
within 5 ms. A bound that tight fails whenever the runner deschedules
the thread, and it cannot distinguish that from the failure it is
aiming at, since a probe that scanned inline would blow it for the same
reason a busy machine does.

`probe` reads the published map and registers interest; it never
scans. The assertion above it already pins the answer to the default,
which only an unscanned probe can return, and the cost the bound stood
for is what `report_process_probe_cost` reports.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`each_phase_measures_only_its_own_span` slept 20 ms to give the first
phase a span, then asserted the second one came in under 10 ms. The
bound is what fails when a runner deschedules the thread between two
adjacent statements, and 10 ms is close enough to be reachable.

Back-dating `since` gives the first phase five seconds without waiting
for them, and the claim is stated against it: a second phase still
dating from the frame's start would be at least as long as the first,
so `second < first` is the property, not an absolute ceiling. The test
also stops costing 20 ms of every run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@AbysmalBiscuit
AbysmalBiscuit force-pushed the feat/decoration-metrics branch from fb1a7a1 to 07652b1 Compare September 2, 2026 14:43
AbysmalBiscuit and others added 12 commits September 2, 2026 19:26
The diff-pane title tests spawned a real `cmd /c exit` through ConPTY
purely to obtain a `Session`, then polled up to ten seconds for the
child to exit so its startup title could not race the injected one.  A
loaded runner reaches that bound with nothing wrong, which is what broke
CI.

The session under test needs no PTY: the tests inject their sequence
straight into the terminal and drain it.  Building it as a struct
literal removes the child, the wait and the racing title together.

Co-Authored-By: Claude Opus 5 (1M Context) <noreply@anthropic.com>
Issue #17 asked where SIMD could help the paint path.  Answering it
needs the frame broken into pieces that can be timed on their own, and
a prototype of the alternative to time against.

Four reporting harnesses, none of them gates:

- report_build_breakdown splits the build phase into the grid read, the
  galley lookup, and the run walk.
- report_lookup_shapes compares the hashed galley cache against a table
  indexed by character.
- report_fill_variants writes the same vertices four ways, down to
  unchecked stores, against a plain memcpy of the finished buffer.
- report_mesh_frame paints the whole grid as one Shape::Mesh built from
  a character-indexed table, with geometry following `show` so its
  numbers compare directly against report_paint_cost.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Handing epaint a mesh still costs a full copy of every vertex: the CPU
writes 105k vertices for a 2560x1440 screen and epaint copies all of
them into a fresh buffer before the GPU sees anything.  Almost all of
that work is position arithmetic a vertex shader does for free.

`[ui] gpu_grid` routes the grid to a paint callback instead.  One
twelve-byte record per cell replaces four twenty-byte vertices, the
vertex shader derives each quad, and backgrounds become a single
triangle over the viewport indexing a cell-sized texture, so neither
draw's geometry grows with the grid.  epaint sees one shape carrying no
vertices.

Nothing here owns a glyph atlas: `egui_glow::Painter::texture` hands
over the texture epaint already packed, so the shader samples the same
artwork, and the fragment shader repeats epaint's gamma-space multiply
so glyph weight matches the rest of the UI.

Records sit at a fixed column stride, so a frame that rewrote three
rows uploads three rows rather than the grid.

Off by default.  It needs a GL 3 context for instanced arrays and
bypasses the renderer every other panel goes through; a context too old
logs once and draws nothing rather than falling over.  Emoji,
box-drawing glyphs, underlines and the cursor stay on egui's painter,
emitted after the callback.

Measured with the harnesses in terminal_view, 318x83 cells, median of
three passes:

    today          4.55 ms   (build 2.29 + tessellate 2.16)
    Shape::Mesh    1.01 ms   (build 0.31 + tessellate 0.63)
    gpu_grid       0.18 ms   (build 0.18 + tessellate 0.001)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both error paths in `link` abandoned the program and every shader they
had created.  On its own that would be a one-shot leak, but the callback
left the resource slot empty after a failure and so rebuilt on the next
frame, and the next: a driver that rejects the shaders leaked a program
plus two shader objects at the repaint rate, with nothing on screen to
say why memory was climbing.

Release what a failed link made, and latch the failure so the build is
attempted once.  A context that cannot compile the shaders now logs one
line and draws nothing, which is what the config flag being off already
does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every frame re-walked the whole grid and rewrote every cell record,
which is why a full screen cost 179us of CPU before the GPU saw
anything.  Almost none of that work was needed: a terminal frame
usually changes one row.

The snapshot now stores each viewport row separately, so re-walking one
row never moves another's bytes, and `collect_damage` decides which rows
to walk.  It mirrors `Display::update_damage` upstream: take
`Term::damage`, then add what that method documents as the caller's to
track.  The selection and the link highlight restyle cells the terminal
never wrote, so both edges of a change to either are re-walked; a
resize, a scroll, a palette change and the first capture all come back
as the full range.

One snapshot serves every session, so a change of session invalidates
every row too — otherwise a switch would show whatever the previous
terminal left in the rows the new one did not touch.

The GPU path builds run views and writes records for that span alone,
and marks exactly it dirty, so the partial upload the fixed row stride
was built for finally gets a narrow range to send.

Measured with the harnesses in terminal_view, 318x83 cells, repeat
frames where only the cursor row is damaged:

    build            179us -> 16.6us
    capture           89us ->  5.3us
    frame            181us -> 17.4us

A frame that genuinely changes everything still pays the old price;
this buys the common case, not the worst one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The GPU path walked every run on screen twice per frame, once to build
the record views and once to re-emit underlines, then discarded all but
the damaged row. That walk alone cost more than everything else in the
build put together.

Narrow the record walk to the damaged span, and give each row a flag for
whether it carries an underline or a strikeout so the decoration pass
skips rows that have neither.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Everything outside ASCII resolved through a HashMap with the default
hasher, so a CJK or Nerd-Font screen paid a cryptographic hash for a key
no attacker chooses. Pack the face and the character into one integer
and hash it with a multiply-shift, which drops the lookup from about
9.6 ns per cell to 1.4.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Everything outside ASCII resolved through a HashMap, so a CJK or
Nerd-Font screen paid a hash and a probe per cell where Latin text paid
an array index. Replace both paths with one two-level table: the low
byte of the character picks an entry within a page, the rest picks the
page, and pages live in an arena so an untouched one costs no
allocation and the lookup has nothing to branch on.

Non-ASCII drops from about 9.6 ns per cell to 0.7; ASCII gives up 0.1 ns
for the second load.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Backgrounds travelled as their own RGBA8 texture, so every frame paid
two uploads for one grid and a cell's colours sat in two places. The
record also carried cell coordinates it did not need: records sit at a
fixed row stride, so the cell is the instance index.

Drop the coordinates, put the background in the space they freed, and
draw backgrounds as a quad instanced from the same buffer. A full screen
now sends 309 KiB in one upload where it sent 309 KiB plus a 103 KiB
texture in two, and a blank row clears with a fill.

The strip past the last whole cell is covered by clearing inside the
callback, which egui has already scissored to the grid.

Padding the record to sixteen bytes for cache-line alignment was
measured and rejected: dead padding costs 1.24 to 1.40 ns per cell for
nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
egui-winit raises Event::Copy alongside the key press for ctrl+c, and a
program whose only exit is the interrupt cannot be stopped if the pair
resolves to nothing. Assert the whole event stream yields ETX rather
than the key event alone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every timing harness here drives dense_screen, which is written once and
then never changes, so from the second frame on the terminal reports no
damage.  A renderer that skips clean rows measures as doing almost
nothing, and the numbers come out nearly independent of grid size.

termbench_frame transcribes FGPerChar and FGBGPerChar from termbench.cpp:
a truecolour SGR on every cell, colours derived from the frame index so
consecutive frames share no cell.  Damage is full every frame, which is
what streaming output actually produces.  Parsing runs off the clock and
both paths see byte-identical frames.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The frame harness timed `ctx.run` end to end, so every reading of it had
to guess which part of the paint the number belonged to.  A per-cell
figure derived from that total was attributed to whatever the reader
already suspected, and one such guess put the blame on a run vector
without evidence.

Split the frame with `phase!`, which compiles to its body alone outside
a test build, and count allocations through the counter `steady_state`
already provides.  Give the workload a stride knob so run count moves
independently of cell count, and step both painters through the same
frames in the same round rather than one after the other — read in
sequence, a quiet minute and a busy one land on different paths and
read as a difference between the paths.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
AbysmalBiscuit and others added 29 commits September 2, 2026 19:26
The pass ran whenever the decoration atlas existed, which says only that
a font has been loaded.  It draws one instance per cell and lets the
vertex shader collapse the undecorated ones, so a screen carrying no
underline still issued a full-grid instanced draw every frame.

Track which rows were written a decorated run and skip the pass when
none were.  The flag is set once per run, not once per cell, and is
conservative: a decorated run whose cells all landed past the last
column still counts, since drawing a pass that paints nothing costs a
frame where dropping one loses an underline.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two builds launched in turn cannot resolve a few microseconds of GPU
time on a loaded machine: the round-to-round spread swamps the effect,
and every workload in a pair drifts together, which no code change can
cause.

Under [debug] gpu_deco_ab the timers flip the gate between report
windows and name the arm on each line, so two adjacent lines compare the
arms against one driver, one grid and one minute.  Three things the
comparison needs come with it:

  - a per-frame total, since the gated arm issues no decoration query
    and the arms otherwise share no figure;
  - a count of frames that skipped the pass, without which a stage
    median cannot say whether the gate fired once or every frame;
  - a settling window after each flip, because a query result arrives
    DEPTH frames after the draw that earned it and would otherwise
    credit one arm with the other's work.

On the CPU side write_rows_nocount is the shipped writer without the
decorated-row bookkeeping, so report_writer_variants prices that
bookkeeping against the whole-grid write it rides along with.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The callback clears the whole grid rect to the default background and
then draws an opaque quad over every cell, so a screen where most cells
carry that background repaints it once per cell for no visible change.
On an idle grid that is nearly the entire pass.

Collapse such a cell to a point in the vertex shader, the way the
decoration pass already collapses an undecorated one. Alacritty reaches
the same end from the other side, giving the cell zero alpha and
discarding it in the fragment shader.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`gpu_deco_ab` could only price the decoration gate. Replace it with
`gpu_ab`, naming the skip under test, so the background collapse gets
the same paired comparison inside one process.

The baseline arm draws every background quad by comparing against a
colour no normalized byte can hold, which no cell matches. Both arms
then take the same code path and differ only in a uniform, where a
branch around the draw would have differed in more than the skip.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Negative result. Do not merge; the branch is the record.

The glyph shader ran egui's general conversion, recovering coverage from
the colour channels with three pow() calls per fragment. That work is
redundant here: epaint writes every font texel as
`from_rgba_premultiplied(a, a, a, a)`, colour glyphs never reach this
pass, and alpha survives an sRGB atlas untouched. The decoration pass
already reads its mask that way.

Removing all three pow() changes nothing the instrument can see. Over
148 pairs the glyphs stage came out at ratio 1.010, p 0.242, against a
null control of exactly 1.000. The pass is bound by writing blended
fragments, not by what it computes for each one, which is also why
removing background quads won so much and this wins nothing.

It is not free either: the sRGB round-trip it skips moves 1.5% of pixels
by one 255th. A change that costs output and buys no time should not
ship.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The four per-stage brackets were the only GPU measurement and `total`
was their sum, which is not the frame. The clear belongs to the frame
but to no stage, so nothing counted it. And a bracket that ends at
bottom-of-pipe charges its stage for a drain the next stage would
otherwise have overlapped, which no null control can detect: an empty
bracket has nothing in flight and reads near zero while every real one
stays inflated.

GL_TIME_ELAPSED cannot nest, so the whole-callback bracket alternates
with the per-stage queries frame by frame instead of wrapping them. One
report window carries both, and the gap between them bounds how much of
a stage median is the pass rather than the instrument.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A microsecond figure from the paint callback cannot be judged without
knowing which device drew it, and this machine has two.  Log the GL
vendor, renderer and version once per context, and put the grid
dimensions and each median's sample count on the report line so an
availability miss stops hiding inside a bare median.

The comment beside the total accumulation claimed a frame short of a
stage keeps no total.  Only an unavailable result does that; a gated
decoration pass leaves a complete three-stage frame.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A glyph's atlas rectangle is a box around the ink, and most of that box
is transparent.  Those fragments run a premultiplied blend whose result
equals the destination they overwrite, and the pass has never been
measured without them.  `gpu_ab = "fill"` links a second glyph program
that discards exactly-zero coverage and alternates it against the
shipped one.

`gpu_ab = "blend"` does the same for the background pass, whose colours
all come from `Color32::from_rgb` and so are opaque: premultiplied
blending there computes the source it already had.

The per-instance colours also become `flat`.  Every vertex of a quad
carries the same value, so interpolating it computes a constant;
alacritty qualifies the same values the same way in
`res/glsl3/text.{v,f}.glsl`.  It rides in both arms of every experiment
here, so it cannot bias one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`paint_grid_gpu` sent every character through the monochrome atlas, so
an emoji came out as whatever silhouette egui's font chain had for it,
multiplied by the cell's foreground.  Box-drawing characters lost their
built-in shapes the same way.  Only the mesh path consulted
`ColorGlyphCache` and `BuiltinGlyphCache`, and `[ui] gpu_grid = true`
never reaches it.

Cells those caches own now record an overlay instead of a slot: the
grid leaves the cell blank and the painter draws the glyph over the
callback, where it lands above the grid the way the mesh path draws it
above its own backgrounds.  Overlays are kept per row and rebuilt with
the row, so a frame that damages three rows rescans three rows.

ASCII short-circuits before either cache, because it is most of what a
terminal holds and none of it is box drawing or emoji.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The whole-callback bracket exceeded the four stage brackets by about
88us, and nothing measured where that went. The clear was the suspect
because it sits outside every stage, but a suspect is not a number.

Add it as a fifth timed stage. It reads 100us of a 359us callback, and
the residual drops to -12us, which prices the instrument's own bracket
tax at roughly 3us per bracket.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The clear costs 100us of a 363us callback, which is far too slow for a
constant-colour write and points at the scissor egui leaves enabled
keeping it off the fast path.

Add three A/B arms against it: lifting the scissor, painting the rect
with a full-rect quad, and skipping the grid's clear on the grounds that
eframe already cleared the framebuffer to the same colour. The quad and
the skip each save about 95us; lifting the scissor saves 38us and
relocates the rest into the background pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two things were wrong with the colour behind the grid.

It came from the configured palette while cell colours resolved
against the terminal's live one, so OSC 11 left a border of the old
colour and stopped the background collapse from ever matching -- every
cell drew a quad it did not need. The capture now records the resolved
background and both paint paths read it from there.

The alpha was not premultiplied. egui_glow hands the clear colour to
glClearColor untouched and the compositor reads the framebuffer as
premultiplied, so a translucent window added the background at full
strength instead of scaling it, and no opacity setting could fade it
out. Alacritty writes (rgb * alpha, alpha) in renderer::clear.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
eframe clears the whole framebuffer to the terminal's background before
the callback runs, so the callback cleared a rect that already held the
colour it was painting. Under egui's scissor that second clear missed
the driver's fast path and cost 100us of a 363us callback, and because
glClear ignores blend state it also stamped alpha 1.0 over the grid,
leaving the terminal opaque on a translucent window while the sidebars
were not.

A static screen renders identically without it, 0 pixels of 1,764,636.

The A/B arms that priced the alternatives -- lifting the scissor,
painting a full-rect quad, skipping the clear -- go with it, along with
the timing stage that bracketed it. Their measurements are written up
in the bench harness.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The compositor reads the framebuffer as premultiplied, so scaling the
background by the opacity is what alacritty's renderer::clear does and
is the correct encoding. Applying it here would darken every
[window] opacity already tuned against the unscaled colour, which is a
visible change to a setting nobody asked to have changed.

Keep the current encoding and record why in a comment, so the
divergence from upstream is a decision rather than an oversight.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A wide character owns two cells, and the terminal writes both from one
cursor template: the spacer carries the same colours and the same SGR
flags as the character it belongs to, differing only in holding
WIDE_CHAR_SPACER where the lead holds WIDE_CHAR.

Capture read that one bit as style and dropped the spacer, so a run
ended between the halves of a single character and the right half of
every CJK glyph came out with no background and no decoration.  Both
painters take a run's extent from its character count, so both showed
it.

Keeping the spacer as a character of its run and masking the two bits
out of the style comparison leaves every cell carrying its own record,
which is how kitty holds a wide glyph: it copies the lead cell's GPU
record into the spacer when the character is parsed and never asks how
wide anything is while rendering.  alacritty takes the other route,
dropping spacers from its renderable cells and adding the column back
when it draws a line.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The renderer was chosen by comparing candidates: three record layouts,
four vertex-fill loops, three galley-lookup shapes, a whole-grid
`Shape::Mesh` against per-glyph `TextShape`, and two alternative writer
bodies run against one captured snapshot.  Each of those comparisons
decided something that is now in the shipped code, and none of them can
fail, so they cost review attention and give nothing back.

The harnesses that measure the shipped path stay, as they did before
this work.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`paint_phases` split a frame into capture, glyph lookup and record write
so a ranking could say which of them dominated.  The answer shaped the
renderer and nothing reads the counters now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The timers carried a second job: alternating one of the callback's skips
between report windows so both arms met the same driver and the same
grid.  That priced the decoration gate, the background quad, the glyph
shader's coverage read and the two blend skips, and every one of those
questions is settled.  Its baseline arms drew the frame wrong on
purpose, which is not something a release binary should be able to do.

`[debug] gpu_timing` and its per-stage report stay.  `[debug] gpu_ab`
goes with the arms it selected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The doc comment on both `gpu_timing` fields held the same paragraph
twice, and schemars concatenates a field's doc into the hover text the
published schema carries, so an editor showed it twice too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The DOUBLE arm split the descent area between its two stems without a
floor, so a stroke reaching a quarter of the descent closed the gap and
the pair rendered as the single rule the style exists to differ from. A
thickness knob turned up reaches that on an ordinary face.

Clamp the stems' spacing to twice the stroke, leaving a stroke's worth of
blank between them whatever the face reports.

The percentage-scaling test could not fail for its own bug either: the
default face at a 16pt ascent resolves to a whole-pixel stroke, where
rounding before and after a scale agree. Anchor it on an ascent that
makes the stroke fractional.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`resolve_fallbacks` substituted a default only when a metric was zero
or non-finite. A face reporting a non-negative descender or a
non-positive ascender slipped past that check, and each inverts a
downstream calculation: `descent = -metrics.descender * px_per_em`
goes negative and collapses the curl underline to its amplitude floor
while pushing both double-underline stems above the baseline, and a
non-positive ascender flips `px_per_em` outright.

Add `correctly_signed`, a sibling to `nonzero`, and use it for these
two fields only — the other four are legitimately signed (a negative
`underline_position` is correct) and stay on the zero-only check.
The ascender rejection also has to feed the resolved (possibly
default) ascender into the strikeout-position fallback rather than
the rejected raw value, since that fallback already reads the local
`ascender` variable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`underline_position` claimed to move "the underline", but the double
and curly styles anchor entirely on baseline and descent in
`decoration_sprites.rs` and never read it — that placement mirrors
upstream alacritty's `renderer/rects.rs` and is intentional. Document
which three styles the knob actually reaches instead of implying all
five. `underline_thickness`, confirmed by reading `draw_underline` and
`curl`, does reach every style, so its doc gets a matching note for
symmetry.

`Adjust`'s type doc claimed kitty parity outright. kitty derives its
double and curly underline positions from the face's underline
position, so a value that behaves identically in kitty is a no-op for
those two styles here. Qualify the claim so it is true.

Regenerated schema/alacritree-config.json via
ALACRITREE_UPDATE_SCHEMA=1 cargo test -p alacritree --test config_schema.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Geometry::resolve wires four knobs by hand, and the strikeout pair had
no test: a copy-paste swap onto the underline knobs would compile and
pass the whole suite. Mirror the existing underline knob tests for
strikeout_position and strikeout_thickness, each also asserting the
underline field stays put so a swap in either direction fails here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The DOUBLE arm pulled the lower stem up when it would leave the cell
but left `upper` unclamped, so a descent band much larger than the
cell room drove its whole span below row 0. `rect_x` then drew nothing
for it, leaving a single rule where the style promises a pair. Clamp
`upper` to stay at or below `t / 2.0` from the top, matching kitty,
which clamps both ends the same way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The ascent probe built a String and a LayoutJob every frame before
reaching egui's galley cache, and it ran in show() unconditionally, so
the mesh path paid for it too. GlyphCache already holds the same galley
at the same size, so the probe becomes a map lookup.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@AbysmalBiscuit
AbysmalBiscuit force-pushed the feat/decoration-metrics branch from 07652b1 to 69f46e9 Compare September 2, 2026 17:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant