feat(render): GPU grid with decorations from font metrics [2] - #203
Draft
AbysmalBiscuit wants to merge 71 commits into
Draft
feat(render): GPU grid with decorations from font metrics [2]#203AbysmalBiscuit wants to merge 71 commits into
AbysmalBiscuit wants to merge 71 commits into
Conversation
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
force-pushed
the
feat/decoration-metrics
branch
from
August 31, 2026 21:57
7e7131d to
e2541de
Compare
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
force-pushed
the
feat/decoration-metrics
branch
from
August 31, 2026 22:27
e2541de to
fb1a7a1
Compare
This was referenced Sep 1, 2026
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
force-pushed
the
feat/decoration-metrics
branch
from
September 2, 2026 14:43
fb1a7a1 to
07652b1
Compare
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>
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
force-pushed
the
feat/decoration-metrics
branch
from
September 2, 2026 17:29
07652b1 to
69f46e9
Compare
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.
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).
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 = truein your config:To customize the decorations, you can use the new
[ui.decorations]table (inspired by howkittydoes it):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:
Two changes, both behind config options that default to off:
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
mastertoday:cargo test -p alacritree --release -- --ignored --nocapture report_paint_costPer character the painter does a
GlyphCachelookup, clones anArc<Galley>, and pushes aTextShapeonto 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.rssays comparing last frame's shapes costs about half what tessellating them costs. So the ceiling inside egui'sShapemodel 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.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.grid_gl.rs): backgrounds, glyphs, decorations, in that order. Decorations go last because alacritty draws its rects over the text indisplay::draw, so a descender crossing an underline has to come out the same way here.egui_glow::Painter::texturehands over the atlas epaint already packed its glyphs into, so the shader samples the same artwork.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_gridturns 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_timingwraps 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 andFlags::WIDE_CHAR_SPACER.alacritty_terminalwrites 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.capturegroups cells into runs of one style, andStyle::from_cellcompared 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.ccopies 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.
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:
sc.noscrollsg.plaintb.LongLinesc.scrollsc.noscrollfills 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:
sg.percellbgsg.underlinesg.percellEvery 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:
The font's own numbers went unread, so a face that asks for a fine stroke and a low underline got neither. The
posttable carriesunderlinePositionandunderlineThickness; OS/2 carriesyStrikeoutPositionandyStrikeoutSize. Upstream alacritty reads all four plus the descent throughcrossfont::Metricsand places its lines from them inrenderer/rects.rs, so this was a divergence rather than a shared limitation.ttf-parserwas already in the lockfile.Face::underline_metrics()andFace::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
Geometryneeds the baseline's position inside the cell, and deriving it from the face drifts from where glyphs actually sit.FontImpl::newquantizes the ascent it stores: epaint divides the scaled ascent bypixels_per_pointand callsround_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_ascenton a laid-out galley is the quantized one, so that is what the geometry uses. The grid's ownGlyphCachealready holds a galley forMat 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
baselineanddescentare new onGeometry, and together they give the band frombaselinetobaseline + descent. Two styles anchor there rather than onunderline_y, which is what alacritty does:baseline + 0.25 * descentandbaseline + 0.75 * descentbaseline + descent / 2, amplitude(descent - underline_thickness) / 2Straight, dashed and dotted keep
underline_yand 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_his a floored row height plusfont.offset.y, so the cell bottom andbaseline + descentare two different places and only the baseline tracks where glyphs sit.The single
thicknessfield 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:
pxptor nonepixels_per_point%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;
ptis 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_positiondoes not move them. kitty derives both fromunderline_positioninstead (decorations.c,add_double_underlineandadd_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 alacritreeruns 1,116 unit tests plus 7 intests/cli_isolation.rsand 2 intests/config_schema.rs.One test fails on my machine right now:
session::tests::a_pane_runs_its_child_without_a_console_host_handshakeassertscmd /c echo readyexits within 2 seconds, and under load it does not. That test and its assertion are byte-identical tomaster's, and nothing in this branch touches it.The GL path runs headless through a
GpuGridwhose 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,
FaceMetricsread from the bundledalacritree-symbols.ttfwith each value asserted normalized to the em, each fallback from a syntheticFaceMetricswith 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 aGeometrywhose 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.