X11 rendering review¶
Review of src/vt_widget.c drawing against upstream xterm
(upstream/xterm-snapshots) and, for contrast, ghostty/ghostling.
Goal: no flashing, blanking, or stale bands. xterm-level performance is
sufficient; frame batching is explicitly not wanted.
Revision 2 corrected overclaims about xterm found in review (xterm does
clear in some paths; Xft painting is ordered, not atomic; xterm renders
available input before returning to select, not per line) and adds the
cursor-only and Expose invariants.
Revision 3 records the correctness-first rewrite. All seven recommended changes are implemented; the visual and performance acceptance items called out below still require deliberate runtime coverage.
Revision 4 records two distinct resize failures found while repeatedly
reflowing a large scrollback. Grid dimensions changed while the last-painted
frame stayed valid, allowing Expose handling to paint an old-grid snapshot
with new-grid geometry. Resize now invalidates that snapshot before callbacks
or repaint. The separate wrapped Bash prompt failure is an identified Readline
8.3 regression. After widening, stale per-screen-line invisible-prompt
metadata moves the cursor left by the final invisible run's byte length. Bash
development commit 1e9f5e10b2 fixes it by refreshing expanded prompt
metadata after every SIGWINCH; the frame-cache fix and libghostty reflow are
not involved. See the
source diagnosis.
Revision 5 records a rapid-update TUI failure. One observed application refresh larger than 4095 bytes was split by the Linux PTY, then painted once after the erase-heavy prefix and again after the replacement content. The input callback now drains a bounded burst of currently available data into terminal state and paints once before returning to Xt.
Summary¶
Revenant and xterm both draw directly to the window, immediately, as PTY data arrives. The pre-rewrite Revenant renderer flashed and smeared because it broke several disciplines xterm follows. The byte-sniffing scroll fast path and content-diff scroll detector added to compensate were themselves a source of wrong output and have been removed.
Recommendation: keep direct, immediate drawing (the xterm model), adopt xterm's disciplines, and delete the heuristics. Do not move to a pixmap/DBE frame buffer as the primary fix; that is the batched-frame behaviour we want to avoid. Buffering remains the only way to get truly atomic presentation, so the target is ordered, low-flicker painting, not atomicity.
What xterm does (with references)¶
- Clears only known background regions, or immediately repaints
after a broader clear. Row painting is background + glyphs per run:
XDrawImageString(util.c:4276) on the core path — a single request — orXftDrawRectfollowed by the glyph draw (util.c:4691) on Xft, which is two requests but ordered and clipped to the cell box withXftDrawSetClipRectangles(util.c:4048). Whole-window clears do exist (xtermRepaint,util.c:3167;xtermClear2usesXClearArea,util.c:2887) but are followed at once byScrnRefresh; separately, the no-copy scroll path clears only cells that are meant to stay blank (ClearUndrawn,util.c:777), which need no repaint. - Scroll-copy is done safely. GCs set
graphics_exposures = True(cachedGCs.c:352);copy_arearecords the copied rectangle (util.c:2663);CopyWaitconsumesGraphicsExpose/NoExpose(util.c:2573);HandleExposuretranslates Expose events arriving mid-copy (util.c:2790). - Scroll amounts come from the emulator (
xtermScroll,util.c:940; jump-scroll viascroll_amt/FlushScroll). No content diffing, no guessing from bytes. - Expose repaints only the damaged rectangle, from the screen
buffer (
handle_translated_exposure,util.c:2922). - Colours and GCs are cached (
getXftColor,cachedGCs.c); no server round trips while drawing. - Timing:
in_put(charproc.c:6778) reads whatever is available, parses all of it, paints,XFlush, thenselect. Rendering boundaries are available-input bursts, not newlines or individual kernel read fragments. No frame timer. DBE (buffered, 40 fps) exists but is off unless built with--enable-double-buffer(main.h:184).
Defects found in the pre-rewrite renderer¶
| Symptom | Cause | Revision 3 status |
|---|---|---|
| Whole-window flash on full repaint | Whole-window clear, then rows painted in two passes | Removed; rows paint background and glyphs per run |
| Stale/garbage bands when partly obscured or off-screen | Unsafe window→window copy without exposure repair | All window copies removed |
| Wrong colours/attributes and DECSTBM behavior | Raw PTY-byte scroll path bypassed the emulator | Raw-byte path deleted |
| Spurious scrolling and O(rows²·cols) work | Content-diff scroll detector | Detector deleted |
| Piecemeal, staggered painting | XQueryColor on every text draw |
Xft colors cached with pixels |
| Pixel crumbs, especially bold | Synthetic Xft bold with no cell clip | Real Xft bold faces and run clips |
| Double flash on font change/redraw | Clear-generated Expose followed by another clear | Redraw calls the cache/full repaint directly |
| Full repaint on every Expose | Redisplay ignored the damage region |
Cached rows/columns intersecting damage are repainted |
| No-newline output delayed | Incomplete lines held on an 8 ms timer | Every PTY read is fed and rendered immediately |
| Rapid-update TUI flashes between erased and replaced content | One logical refresh crossed a PTY read boundary and each fragment was painted | Drain currently available input, up to a fairness budget, and paint once |
| Dim text has normal intensity | The adapter preserved SGR 2 faint, but the widget discarded it |
Scale foreground RGB to two-thirds, matching xterm's default faintIsRelative: false policy |
| Bold ANSI headings retain muted base colors | Bold font selection did not apply xterm's default boldColors palette rule |
Promote foreground palette colors 0–7 to 8–15; expose -/+pc and boldColors to control it |
| Stale rows painted with old geometry after resize | Grid changed while the old frame_cells snapshot remained valid |
Resize invalidates the frame before callbacks and Expose |
| Wrapped Bash prompt leaves cursor inside the prompt after shrink/grow | Readline 8.3 retains stale local_prompt_invis_chars[] metadata when widening |
Fixed in Bash development commit 1e9f5e10b2; keep the fixture as external-component coverage and do not work around it in Revenant |
The unused XtpVtUpdateScrolled, raw-byte helpers, content detector, and scroll
branch of RenderBegin were deleted together.
Invariants the rewrite must keep¶
- Cursor-only frames. libghostty can report no cell damage while
the cursor moves.
XtpTerminalRenderalready calls begin/end in that case andRenderEndperforms the cursor-only repaint (RenderEnd). Cursor, scrollbar thumb, viewport offset, and focus state must be refreshed independently of dirty rows. The self-test covers a cursor move with zero damaged cells. - Expose is not terminal damage. X damage is repainted from the
widget's last-painted
frame_cellssnapshot, clipped to the exposed rectangle — never by asking libghostty for dirty rows. Cursor intersection and wide/grapheme cells straddling the exposed edge must be handled (repaint the whole cell group). - Frame geometry must match widget geometry. A cached frame is repaintable
only while its rows, columns, and cell geometry describe the current widget.
ResizeWidgetinvalidatesframe_cellsbefore publishing new grid dimensions or invoking resize callbacks. The next terminal render or Expose rebuilds the snapshot from libghostty's reflowed state; it must never map an old snapshot onto a new grid. - Resize is one ordered transaction. Update the kernel PTY size first, then
resize/reflow terminal state before returning to the Xt event loop. Child
SIGWINCHoutput cannot be read and fed between those operations. This matches Ghostty'sTermio.resizeordering and ensures any subsequent shell redraw is parsed against the new grid. - Emulator is the only source of truth for content. Nothing is painted from raw PTY bytes.
- Kernel read fragments are not presentation frames. Drain currently available PTY data into the emulator and paint once before returning to Xt. Keep the drain bounded so a continuous producer cannot starve other events.
Applied changes¶
- Done: paint rows in place. Each run draws its own background
(
XDrawImageStringon the bitmap path;XftDrawRectimmediately followed by the clipped glyph draw on Xft). Clear only regions known to be undrawn (e.g. beyond the grid after a resize), with the repaint ordered directly behind the clear. Remove the whole-windowXClearWindowfrom the render path; full repaint = walk rows. - Done: drop window→window
XCopyArea(xtermno_copy_areamode) as the correctness-first step. If scroll-copy is wanted later, portCopyWaitand the Expose translation together withgraphics_exposures = True. - Done: delete
XtpVtPredictSimpleScroll,XtpVtDrawSimpleScrolledLine,DetectVerticalScroll, and the dead scroll path. - Done: feed each PTY fragment to libghostty immediately, drain up to 256 KiB of currently available input, and render once at the end of that burst. This removes both newline splitting and the 8 ms incomplete-line hold without exposing an erase-heavy prefix as an intermediate frame. Dirty state decides what to paint, not the byte stream. The budget preserves fairness for other Xt events under a continuous producer.
- Done: cache
XftColoralongsidePixelin the colour cache. - Done for Xft: load a real bold face (
FC_WEIGHT) instead of double-striking; clip each run to its cells. - Done:
Redisplayrepaints only rows/columns intersecting the exposed region fromframe_cells. The extraXClearAreacalls are removed from redraw and font switching. - Partial: grid resize invalidates the last-painted frame before the grid
is changed, and the resize callback updates PTY geometry before terminal
reflow.
xtp-resize-loopprovides repeatable narrow/wide X11 coverage. The wrapped-prompt fixture also distinguishes released Readline 8.3 from the upstream-fixed build while confirming cached-frame geometry remains correct. - Done: preserve SGR 2 through visual-cell conversion by reducing each
foreground RGB component to two-thirds. This matches xterm's default
absolute faint calculation; relative-to-background faint remains tied to
the unsupported
faintIsRelativeresource. - Done: implement
boldColorswith xterm's default value of true. Bold foreground palette colors 0–7 use configured colors 8–15;+pcorboldColors: falseretains the base color while still selecting the bold font face.
The bitmap path retains its existing clipped synthetic-bold fallback until a
separate xterm boldFont resource is wired.
Acceptance criteria¶
- During
DIRTY_FULLrepaints, no request clears the whole grid or a multi-row band ahead of its repaint; every background fill is immediately followed by the glyph request for the same run (verify request ordering with xtrace — a screen recording cannot prove the absence of intermediate frames). - Partially obscured / off-screen window scrolls without stale bands.
- Persistent SGR, DECSTBM, and alt-screen output render identically to a full repaint (regression tests for each).
- Cursor-only movement repaints without cell damage.
- Expose of a sub-rectangle repaints only that rectangle, correctly, including wide cells at its edge.
- Prompt and cursor-control output without a trailing newline appears without the 8 ms hold.
- An erase-then-replace refresh split across multiple PTY reads produces one replacement render, never a visible intermediate erased frame.
- SGR 2 text is visibly dimmer than normal text, while SGR 1 continues to use the selected bold face independently.
- With default
boldColors, bold ANSI colors 0–7 paint through their bright counterparts 8–15. With+pc, the same cells retain their base colors and only their font weight changes. - Repeated narrow/wide resize of large scrollback never paints prompt
fragments or stale rows from a previous grid. After returning to the live
bottom, editable input and its cursor remain intact. Exercise this with
xtp-resize-loopand a live Readline buffer.
Cost caveat¶
libghostty-vt exposes a snapshot with per-row dirty bits and a FULL
flag, not an edit stream, and marks FULL on every scroll. After change
1 each scrolled line costs an in-place repaint of every row. This is
not equivalent to xterm's no-copy mode, which knows the scroll region
and clears/repaints only undrawn cells; it is strictly more work.
Measure after implementing changes 1–5 (cells painted and X requests
per scrolled line under yes/cat of a large file) before deciding
whether scroll-copy with proper exposure handling needs to come back.