Skip to content

libvaxis — the seam is a negotiation, and the negotiated answer rides in the cell

Category: cell-only with sub-cell escape hatches. Last reviewed: August 23, 2026. Pinned at c060d314.

libvaxis has no backend type. There is exactly one target — a terminal — and the variation between terminals, which every other cell library absorbs with a terminfo lookup or a backend trait, is absorbed instead by a runtime-negotiated Capabilities struct and by per-cell escape hatches that let a single cell carry a pixel offset, a fractional scale factor, or an image placement. That makes it the survey's second sub-cell datapoint after Notcurses, and — this is the finding — it does not reach for Notcurses' fidelity ladder. It publishes the conversion factor instead.

FieldValue
LanguageZig (minimum_zig_version = "0.16.0"build.zig.zon)
LicenseMIT (LICENSE, © 2023 Tim Culverhouse)
Repositoryrockorager/libvaxis
Documentationrockorager.github.io/libvaxis, README.md
Categorycell-only with sub-cell escape hatches
Pinned revisionc060d314930c5552b99a89278a6a695baf0352da (version = "0.6.0")
Target rangeterminals only — but stratified by protocol, from wcwidth+ASCII to Kitty graphics and OSC 66 text sizing
Backends shippednone. One renderer, Vaxis.render, parameterised by a Capabilities value
The low seamScreen — a []Cell grid; Window is a clipping view onto it
The high seamvxfw.Widget — three function pointers; draw returns a Surface
Dependencieszigimg (image decode), uucode (Unicode tables, lazy = true) — no libc, no terminfo, no curses

NOTE

A breadth-first survey of libvaxis as a TUI library — event loop, widget catalogue, allocator discipline, Zig-to-D translation notes — already exists at docs/research/tui-libraries/libvaxis.md. This page reads the same tree for one thing only: what its renderer seam decides, and what that means for isCanvas. Where the two disagree on a version number, this page is pinned and that one is not.

Overview

What it solves

A terminal application must reach modern protocol features — true colour, Kitty graphics, the Kitty keyboard protocol, synchronized output, Unicode Core (mode 2027), OSC 66 text sizing — on terminals that support an arbitrary subset of them, without a static capability database that describes none of it.

Design philosophy

Two sentences at the top of README.md state the whole position:

Libvaxis does not use terminfo. Support for vt features is detected through terminal queries.

That is a seam decision, not a portability detail. Terminfo is a declared capability model consulted before drawing; a query is a negotiated one, whose answer arrives asynchronously, mid-run, on the same input stream as the user's keystrokes. Everything downstream — the width oracle, whether an image may be sent at all, whether a grapheme is written raw or wrapped in OSC 66 — is a read of the negotiated result.

How it works

The reified thing is a grid of Cell, exactly as in Ratatui, and the render pass is a diff of two grids. What differs is that the two grids are different types. Screen (src/Screen.zig) is the writable one:

zig
width: u16 = 0,
height: u16 = 0,
width_pix: u16 = 0,
height_pix: u16 = 0,
buf: []Cell = &.{},
cursor: Cursor = .{},
width_method: Method = .wcwidth,

InternalScreen (src/InternalScreen.zig) is the retained one, and its InternalCell replaces every borrowed slice with an arena-backed std.ArrayList(u8); InternalScreen.writeCell copies the grapheme, the URI and the URI params in. See Q7.

Window (src/Window.zig) is not a painter — it is a value: an offset, a size and a pointer to the screen.

zig
x_off: i17,
y_off: i17,
/// relative horizontal offset, from parent window. This only accumulates if it is negative so that
/// we can clip the window correctly
parent_x_off: i17,
parent_y_off: i17,
width: u16,
height: u16,
screen: *Screen,

Window.child clamps the child's size into the parent and keeps only the negative part of the relative offset, so writeCell can reject a cell that falls outside any ancestor with six comparisons and no clip stack. Clipping is arithmetic on a value, not state on a painter.

Vaxis.render (src/Vaxis.zig) walks the screen buffer once, skipping any cell where last.eql(cell) and nothing forces a repaint, tracking cursor: Style and link: Hyperlink as running registers and emitting SGR only on change. The whole frame is bracketed in ctlseqs.sync_set / sync_reset (mode 2026), and errdefer guarantees the reset even on a write failure.

The negotiation is the other half. Vaxis.queryTerminalSend writes one concatenated batch of probes — decrqm_sgr_pixels, decrqm_unicode, decrqm_color_scheme, in_band_resize_set, the two cursor-position tricks described below, xtversion, csi_u_query, kitty_graphics_query, primary_device_attrs — and queryTerminal then blocks on a futex until the DA1 reply arrives or the caller's timeout expires. Replies land as ordinary events in Loop.zig, which sets the corresponding field:

zig
.cap_kitty_graphics => {
    if (!vx.caps.kitty_graphics) {
        log.info("kitty graphics capability detected", .{});
        vx.caps.kitty_graphics = true;
    }
},

Two capabilities have no query of their own and are detected by measuring the terminal's own cursor: queryTerminalSend homes the cursor, emits explicit_width_query (OSC 66;w=1 around a space), and asks for a cursor position report. A terminal that honoured the width parameter has moved one column; the reply parses as a shift-F3 keypress, and Loop reads that as caps.explicit_width = true. The scaled-text probe is the same trick with s=2, arriving as alt-F3. Capability detection here is not a lookup and not even a protocol reply — it is an observation of a side effect.

Q1 — measurement units, and who answers

The unit is one: u16 cells, everywhere. The oracle, however, is capability-dependent, and libvaxis ships three of them plus two overrides plus a wire-level assertion. This is the richest Q1 answer in the survey and it does not simplify into the earlier ones.

gwidth (src/gwidth.zig) is a free function over a Method:

zig
pub const Method = enum { unicode, wcwidth, no_zwj };
pub fn gwidth(str: []const u8, method: Method) u16

.unicode iterates real grapheme clusters and applies emoji rules — a variation-selector-16 or an emoji-presentation codepoint forces width 2, two regional indicators force 2, U+FE0E pins text presentation to 1. .wcwidth walks bare codepoints. .no_zwj splits on U+200D first and sums. The library's own tests pin the disagreement: gwidth("👩‍🚀", …) is 2 under .unicode and 4 under both .wcwidth and .no_zwj.

The choice of method is a capability, not a configuration: Capabilities declares unicode: gwidth.Method = .wcwidth, Vaxis.resize copies it into screen.width_method, and Loop's .cap_unicode handler flips it to .unicode when the terminal answers mode 2027. Window.gwidth(str) is a one-line forward to gw.gwidth(str, self.screen.width_method).

Two escape hatches sit under that. First, a per-cell override, documented verbatim in src/Cell.zig:

zig
pub const Character = struct {
    grapheme: []const u8 = " ",
    /// width should only be provided when the application is sure the terminal
    /// will measure the same width. This can be ensure by using the gwidth method
    /// included in libvaxis. If width is 0, libvaxis will measure the glyph at
    /// render time
    width: u8 = 1,
};

width = 0 means "I decline to answer; measure it for me later", and Vaxis.render does exactly that, falling back to @max(1, gwidth(…)). Second, when caps.explicit_width is set and the width exceeds one, the renderer stops guessing and tells the terminal, via ctlseqs.explicit_width = OSC 66;w={d};{s}. The measurement disagreement that friction §1 describes is treated here as a protocol problem with a protocol answer.

IMPORTANT

There is also a measurement path that runs through the painter, which complicates F1. Window.PrintOptions carries a commit flag, documented "when true, print will write to the screen for rendering. When false, nothing is written. The return value describes the size of the wrapped text", and print returns PrintResult { col, row, overflow }. The library's own print: grapheme tests drive it with .commit = false purely to assert wrap geometry. Word wrapping cannot be answered by a width function alone — it needs the target rect — so the dry run is the honest place to put it. F1's "not the painter's job" holds for advance; it does not hold for wrapped extent.

At the framework level the oracle moves onto the draw context rather than either the painter or the caller: vxfw.DrawContext.stringWidth (src/vxfw/vxfw.zig) forwards to gwidth with a var width_method held on the type and initialised once by App from vx.screen.width_method. Widgets measure; they never see the screen.

Q2 — is the contract stated in one place?

Yes, and it is a value rather than a type. The whole negotiable surface is one struct in src/Vaxis.zig:

zig
pub const Capabilities = struct {
    kitty_keyboard: bool = false,
    kitty_graphics: bool = false,
    no_color: bool = false,
    rgb: bool = false,
    unicode: gwidth.Method = .wcwidth,
    sgr_pixels: bool = false,
    color_scheme_updates: bool = false,
    explicit_width: bool = false,
    scaled_text: bool = false,
    multi_cursor: bool = false,
};

Ten fields, every default the pessimistic one, and the field set is the contract. This is the Qt PaintEngineFeature model — a declared capability set — with the declaration moved from the backend author to the target itself, discovered at runtime. sparkles:ui has neither: isCanvas names five methods, OpKind has eight members, and the other four — rule, scrollbar, pushClip and popClip — are probed with __traits(compiles, …) at each call site in the immediate interpreter, which is friction §2.

Three properties are worth taking separately.

Refusal exists, but only for the expensive half. Every image entry point opens with if (!self.caps.kitty_graphics) return error.NoGraphicsCapability;transmitLocalImagePath, transmitPreEncodedImage, transmitImage, loadImage. That is F5's refusable degrade, obtained from the return type as in Ratatui, without a NODEGRADE flag. Text fidelity gets no such treatment: the renderer silently takes the lesser branch when caps.explicit_width is false, and the caller is never told. So even here, refusal is granted where the fallback would be expensive and withheld where it would be merely wrong — which is precisely the asymmetry a golden test trips over.

Negotiation has an override channel. enableDetectedFeatures reads NO_COLOR, TERMUX_VERSION, VHS_RECORD, TERM_PROGRAM == "vscode", VAXIS_FORCE_LEGACY_SGR, VAXIS_FORCE_WCWIDTH and VAXIS_FORCE_UNICODE, and several of those downgrade an already-detected capability (VHS_RECORD clears kitty_keyboard and forces .wcwidth). No other subject surveyed lets an operator pin the negotiated result. For a repository that ships golden GUI and PTY oracles, that is the more interesting half of F5: a capability model needs a pin, not only a refusal.

Negotiation costs a blocking wait. queryTerminal blocks on std.Io.futexWaitTimeout until DA1 lands. A declared contract has no such startup cost; that is what buying runtime truth costs.

Q3 — semantic operations, or primitives?

Neither, at either level. The low seam has no operations at all, only writeCell/readCell/fill/print, so a scrollbar reaches it as cells. The whole low-level scrollbar is 33 lines (src/widgets/Scrollbar.zig), and its degradation vocabulary is one field:

zig
/// character to use for the scrollbar
character: vaxis.Cell.Character = .{ .grapheme = "▐", .width = 1 },
style: vaxis.Style = .{},
top: usize = 0,
total: usize,
view_size: usize,

The rail geometry — bar_height, bar_top — is derived once, in the widget, from win.height. Compare the fourteen fields of DrawOp's Scrollbar payload, which travel so that each backend can lower the same rail for its own target. The sum keeps that cost on the arm that needs it — a PopClip carries no fields at all — and the lowering is shared rather than reinvented: scrollbarThumb in sparkles.ui.state is the one formula every backend renders through, with scrollbarCellCount, scrollbarCell and ruleEndpoints re-exported from canvas.d. What libvaxis shows is that the derivation can finish a layer earlier, so that nothing about a rail crosses the seam at all.

The framework's ScrollBars (src/vxfw/ScrollBars.zig) makes the point sharper by going further in the same direction: it exposes six configurable thumb cells — vertical and horizontal × idle, hover and drag — each a whole vaxis.Cell with its own style, plus estimated_content_height/_width for thumb sizing. Interaction state (is_hovering_vertical_thumb, is_dragging_horizontal_thumb, mouse_offset_into_thumb) lives in the widget, never in the seam. The Scrollbar payload's expandPercent and trackLit — read back through the DrawOp.expandPercent and DrawOp.barTrackLit accessors — are that same interaction state, pushed down a layer.

This is the second independent falsification of the necessity argument behind friction §3 (Ratatui was the first): a cell target degrades a scrollbar perfectly well without the drawing layer knowing what one is, because the glyph set is a widget parameter. It does not falsify Slint's converse claim — Slint's draw_box_shadow genuinely needs backend knowledge — but it does show that a scrollbar is not that kind of operation.

Q4 — command shape

No commands. As in Ratatui, the reified thing is a cell array, and Cell is a tag-free product type — so the illegal-combination problem that sparkles.input.events rejects by construction cannot arise here either. DrawOp reaches the same place from the other side: it is a closed SumType!(FillRect, TextRun, Glyph, Line, Rule, Scrollbar, PushClip, PopClip), so a combination that no primitive paints from has no arm to live in.

But libvaxis is the one cell subject where the tag-free shape still carries dead fields, and it is instructive that it does:

zig
char: Character = .{},
style: Style = .{},
link: Hyperlink = .{},
image: ?Image.Placement = null,
default: bool = false,
wrapped: bool = false,
scale: Scale = .{},

image is null for essentially every cell on screen; scale is the identity for essentially every cell. They are cheap — Scale is a packed struct that eql bitcasts to a u13 — but they are the same trade the seam's size budget makes, and they land here for the same reason: the escape hatches of Q5 have to be reachable from wherever content is expressed, and content is expressed per cell. static assert(DrawOp.sizeof <= 64) sizes every operation to the widest payload, TextRun, so a PopClip that carries nothing costs what a text run costs (friction §4). Optional per-element payloads are what a sub-cell escape hatch costs, in any shape. The lesson for DrawOp is that a sum type removes the illegal combinations, not the rare ones — it relocates the rare case into an arm nobody has to read, which is cheaper than a dead field but is not the same thing as eliminating it.

Q5 — sub-unit placement

The reason this subject is on the list, and the answer is not Notcurses'. libvaxis never enumerates positions and never enumerates resolutions. It exposes the conversion factor and defers sub-cell placement to a protocol, three different ways.

1. The cell's pixel size is a first-class layout input. Screen carries width_pix/height_pix from the Winsize ioctl beside width/height, and vxfw.DrawContext hands every widget:

zig
// Size of a single cell, in pixels
cell_size: Size,

computed in App.doLayout as vx.screen.width_pix / vx.screen.width. A cell toolkit whose layout pass knows how many device pixels a cell is worth is not the same thing as a toolkit with no unit below a cell. That is the cheapest available answer to friction §5, and it requires no new vocabulary at all.

2. Images place at pixel resolution inside a cell. Image.DrawOptions (src/Image.zig) carries, with the constraint stated in the doc comment:

zig
/// an offset into the top left cell, in pixels, with where to place the
/// origin of the image. These must be less than the pixel size of a single
/// cell
pixel_offset: ?struct { x: u16, y: u16 } = null,
z_index: ?i32 = null,
clip_region: ?struct { x: ?u16 = null, y: ?u16 = null, width: ?u16 = null, height: ?u16 = null } = null,
scale: enum { none, fill, fit, contain } = .none,

The placement is stored on a cell (win.writeCell(0, 0, .{ .image = p })) and serialised in render as Kitty graphics a=p with X=/Y= sub-cell pixel offsets, x/y/w/h clip parameters and a z= index. Sub-cell and z-ordered, inside a cell grid.

3. Text can exceed and subdivide the cell. Cell.Scale is the OSC 66 text-sizing protocol as a packed struct:

zig
pub const Scale = packed struct {
    scale: u3 = 1,
    // The spec allows up to 15, but we limit to 7
    numerator: u4 = 1,
    denominator: u4 = 1,
    vertical_alignment: enum(u2) { top = 0, bottom = 1, center = 2 } = .top,
};

Gated on caps.scaled_text, render emits either OSC 66;s={d}:w={d} or the fractional form s:w:n:d:v, and marks the covered cells skip = true in the retained screen so the diff does not paint over the glyph. A fraction with a vertical alignment is a strictly more expressive spelling of "where in the cell" than RuleEdge's six compass points, and it costs 13 bits rather than an enumerator per new case.

Input is symmetric: Vaxis.translateMouse divides the SGR-pixel mouse coordinates by the derived cell pitch and keeps the remainder as Mouse.xoffset/yoffset (src/Mouse.zig) — sub-cell resolution on the way in as well as on the way out, gated on caps.sgr_pixels.

IMPORTANT

This is the survey's second cell-native sub-cell subject and it does not generalise Notcurses' fidelity ladder — which was the explicit hypothesis behind putting it on the list. Notcurses answers "how finely may I draw here?" with a named blitter; libvaxis answers "how many pixels is a cell?" with a number, and then routes anything finer through a negotiated protocol (Kitty graphics, OSC 66) that carries real device units. F6 names both halves — a named fidelity and a queried device unit — and libvaxis is the survey's evidence for the second: publish the conversion factor and let a capability decide whether the fine path is available at all. That half requires no new toolkit vocabulary, which for a seam whose entire sub-cell spelling is RuleEdge's six compass points is the cheaper place to start.

Q6 — resolved or semantic styling

Resolved — but with one deliberate notch of deferral, and the re-resolver is the target. Cell.Style is concrete: fg, bg, ul, ul_style plus seven SGR booleans. Color, however, is a sum type whose first two cases are names, not values:

zig
pub const Color = union(enum) {
    default,
    index: u8,
    rgb: [3]u8,
};

.default and .index are resolved by the terminal, against the user's palette, after the seam. libvaxis carries a semantic role and a resolved value in one four-byte union, and never pays for both, because the wire format already has a vocabulary for the role.

The no_color capability then gates emission wholesale: every colour branch in render is guarded by if (!self.caps.no_color and …), so NO_COLOR=1 produces a frame with structure and no colour, decided at the writer rather than at every call site.

The bearing on friction §6 is direct. Each DrawOp payload stores the resolved fields its own primitive paints from — an Ink for the four content primitives, colour fields plus a const(BoxChrome)* for FillRect — and six of the eight store a Slot beside them, because the HTML interpreter re-resolves the role into class names. DrawOp.visual reconstructs a Visual from the stored halves through visualOf rather than keeping one, which makes the hedge cheaper without making it a decision. libvaxis shows the cheap version of that hedge: make the resolved type itself able to hold an unresolved name, so the op carries one field, and let the backend that can re-resolve read the name out of it. Slot is richer than a palette index, so this does not transfer unchanged — but "one field that can be either" is a different design point from "a resolved half and a role, always both", and per F9 it is the one nobody in the survey has paid for twice.

Q7 — payload ownership

Two cell types: one that borrows for the frame, one that owns across frames.Cell.Character.grapheme and Cell.Hyperlink.uri are []const u8 borrowed from the caller — the same bargain DrawOp.text strikes, and the subject of friction §7. What makes it safe is that the retained screen is a different type: InternalScreen.InternalCell holds char, uri and uri_id as std.ArrayList(u8) allocated from an arena owned by the screen, and InternalScreen.writeCell does clearRetainingCapacity + appendSlice on each. The frame-local borrow is legal precisely because the only thing that outlives the frame is a copy in a type that owns.

GraphemeCache (src/GraphemeCache.zig) plays the same role for key text: Loop runs mut_key.text = cache.put(text) before enqueueing a key event, because a Key's text is borrowed from the parser's scratch buffer and must survive the queue.

This is the third distinct answer to §7 in the survey, alongside reference counting (egui, Qt) and a backend-owned cache (Slint), and it is the one closest to what a @nogc toolkit can afford: no atomics, one copy at the retain boundary, and "can this outlive the frame?" answered by the type system rather than by a doc comment.

sparkles:ui already pays libvaxis's copy. DrawOp.text is a const(char)[] borrowed from a frame arena rather than from the caller, because CmdBuffer.textRun interns the run into sparkles.ui.arena's FrameArena — a bump allocator over never-moving pureMalloc chunks — before the operation is built, which is exactly what makes a scope source safe; RecordingCanvas interns on the collected heap, so its operations outlive the call that drew them. That places it inside F8 rather than outside it: the payload is arena-allocated, not shared across a frame boundary.

What the toolkit does not have is libvaxis's type distinction. DrawOp is one type in both regimes, and the rule that separates them — an operation is valid while the buffer that built it is alive and unreset — is stated on sparkles.ui.cmd_buffer and backed by the buffer being move-only, so a copy cannot hand out a second set of live pointers. It is enforceable, but it is enforced by the buffer rather than by the operation's type, and the compiler is told about the borrow through the launder cast that stops dip1000 confining the slice to the operation's lifetime instead of the arena's (friction §4 and §7). libvaxis's split is the argument that the retain boundary belongs in the type, where a walker cannot be handed the wrong kind of operation at all. That argument is taken up, and priced, under "Bearing on the proposal".

Q8 — extent query

Answered at the framework level, and answered in the cheapest possible way: the paint call returns the extent. vxfw.Widget.draw is fn (userdata, ctx: DrawContext) Allocator.Error!Surface, and Surface opens with its own size:

zig
pub const Surface = struct {
    size: Size,
    widget: Widget,
    cursor: ?CursorState = null,
    /// Contents of this surface. Must be len == 0 or  len == size.width * size.height
    buffer: []vaxis.Cell,
    children: []SubSurface,
};

App.render then sizes the root window from the returned value — win.child(.{ .width = surface.size.width, .height = surface.size.height }) — and Surface.render walks the child tree, sorting children by z-index and making a child Window per subsurface. Surface.trimHeight re-slices a surface to a smaller extent without redrawing.

So a scene here is self-describing about its extent, per node, and it costs nothing extra because the number is the draw call's return value rather than a separate query. That is F7's three questions kept apart and its axis answered at one end: surface extent is declared by the device (Screen carries width/height/width_pix/height_pix, as Qt's does), while the content extent a caller needs in order to size an offscreen target is maintained at construction rather than derived by a scan. sparkles:ui sits at the other end of that axis — nothing on CmdBuffer, the display list or the arena reports how much a built stream covers, and a caller that wants painted bounds folds op.rect over the operations itself (friction §8). libvaxis's answer is not a new query on the display list. It is making paint return a size, which the view → layout → buildDisplayList → paint pipeline is already in a position to do: buildDisplayList knows every rect it emits at the moment it emits it, and CmdBuffer already exposes length and measure.

Strengths

  • The contract is one value, and the target fills it in. Ten fields, all pessimistic by default, negotiated at startup — a declared model whose declaration comes from the party that actually knows.
  • Sub-cell without new toolkit vocabulary. cell_size in pixels, plus protocol-carried offsets/fractions, cover more ground than six compass points and add no enumerator.
  • Ownership is a type distinction, not a convention. Cell borrows, InternalCell owns; the retain boundary is where the copy happens and the compiler knows it.
  • Refusal by return type for the expensive capability (error.NoGraphicsCapability).
  • An override channel over the negotiated result — the missing half of a capability model for anyone running golden tests.
  • Clipping is a value, not painter state: Window's four offsets make out-of-bounds a comparison, with no clip stack to push, pop or mismatch.

Weaknesses

  • Refusal is asymmetric. Images refuse; text fidelity degrades silently. A caller cannot ask for explicit width and be told no — the same gap friction §2 records for rule.
  • Capability is global state. vxfw.DrawContext.width_method is a var on the type, set once by App.init; two Vaxis instances with different terminals in one process share one width oracle.
  • The negotiated answer can arrive mid-frame. Capabilities flip from the event loop thread while the app is drawing, so early frames may be measured by one oracle and later ones by another. queryTerminal's futex wait exists to bound that, at the cost of a blocking startup.
  • Two width oracles reachable from one program (.unicode vs .wcwidth, differing by 2 on a ZWJ sequence), with Character.width as a third, caller-supplied answer that the library explicitly declines to verify.
  • Detection by side effect is fragile: the explicit-width and scaled-text probes are read out of a cursor position report reinterpreted as an F3 keypress, which the code itself guards with a queries_done flag so it does not corrupt real F3 input.
  • No in-memory conforming target. There is no TestBackend equivalent; tests assert against a Screen directly. Which is, per Ratatui, arguably the right assertion target anyway.

Key design decisions and trade-offs

DecisionRationaleTrade-off
No terminfo; capabilities by runtime queryterminfo describes none of Kitty keyboard/graphics/OSC 66, and lies over SSH and in containersblocking startup wait; capabilities mutate mid-run; two probes read as reinterpreted keypresses
Capabilities as one struct with pessimistic defaultsthe negotiable surface is legible in one placeevery renderer branch reads it — capability checks are scattered through render by construction
Width method is a capability, not a constantmode 2027 changes what the terminal will do with a ZWJ sequencetwo oracles disagree by 2 on real strings; a third (Character.width) is unverified
Sub-cell via protocol payloads on the cell (image, scale)reuses the terminal's own device units instead of inventing toolkit onestwo mostly-dead optional fields on every cell; the fine path vanishes without the capability
Two cell types, borrowing and owningframe-local borrows stay cheap; retention is explicit and localiseda copy per changed cell per frame, plus an arena per screen
Color as default | index | rgbthe role/value hedge costs one union, and the terminal does the re-resolutiononly works because the wire format already names roles; a richer Slot would not fit
Widget draw returns a Surface carrying its own sizeextent is a result, not a queryevery draw allocates a buffer (from an arena discarded each frame)
No backend abstraction at allthere is one target class; variation is data, not a typea non-terminal target is inexpressible — the model cannot reach a GPU

Bearing on the proposal

  1. Take the capability struct, and let the backend fill it in (friction §2, F5). A single CanvasCaps value — hairline, clip, subCell, proportionalText — is legible where a __traits(compiles, …) probe repeated at every call site in the immediate interpreter is not, and it costs nothing that the DbI probe does not already cost: a backend can still derive it at compile time from what it implements. libvaxis shows the negotiated version; ours would be the declared version, and the shape is the same.

  2. Add the override channel, not just the refusal. F5 asks for a refusable degrade. libvaxis has VAXIS_FORCE_WCWIDTH/VAXIS_FORCE_UNICODE as well, and for a repository whose golden oracles are PTY and GUI recordings, pinning a capability is the more valuable of the two. Refusal tells a test it cannot have fidelity; a pin makes two machines produce the same bytes.

  3. Publish the conversion factor, not only a fidelity name (friction §5). F6 concludes that continuous coordinates relocate the sub-unit problem rather than dissolving it, and that the answer is a named fidelity plus a queried device unit. libvaxis is the evidence for the second half, arrived at independently of Notcurses: hand layout the device size of a cell (cell_size in DrawContext) and let anything finer travel as real device units under a capability gate. For sparkles:ui that is the smaller of the two halves to build — GridCanvas reports 1×1, SkiaCanvas and RaylibCanvas report their real cell pitch — and on its own it dissolves the "two-pixel focus ring" case §5 records as unspellable.

  4. Let the stored appearance hold an unresolved name (friction §6). Color's default | index | rgb union is the pattern: a resolved type that can also hold a role. Six payloads store a Slot beside the resolved fields their primitive paints from; whether Slot fits inside the Ink those payloads already carry is an open question, but "one sum-typed field" is strictly cheaper than a resolved half and a role on the same payload, and no subject surveyed pays for both.

  5. Make the retain boundary a second type, not a property of the arena the buffer was built over (friction §7). sparkles:ui draws the line between a frame-local operation and a retained one at the arena — CmdBuffer is CmdBufferT!(FrameArena!()), GcCmdBuffer is CmdBufferT!GcArena — while DrawOp is the same type on both sides of it, so a walker's signature says nothing about which one it was handed. libvaxis draws the same line as two types: Cell.Character.grapheme is a borrowed []const u8, InternalCell.char is a std.ArrayList(u8) from the screen's own arena, and InternalScreen.writeCell is the single place the copy happens — which makes "can this outlive the frame?" a question the compiler answers.

    The price is specific and it is not small. A DrawOp and a retained twin are two closed sums of eight arms each: visualOf, translate and every one of the seventeen accessors from kind through barThumbGlyph either gains a second instantiation or becomes a template over the text payload's type, and the final switch exhaustiveness that turns a ninth arm into a compile error has to hold in both. RecordingCanvas compares operations pairwise as plain values, which is how the op stream serves as the parity oracle; across two types that comparison needs a conversion or it stops being an equality. And the 64-byte budget is per type — an owning text payload is wider than the slice it replaces, and TextRun is already the arm that sets the budget.

    The claim narrows accordingly. The frame arena is not an interner in libvaxis's sense: it copies per run and does not dedupe, so the objection to interning does not land here. What survives, in its strongest form, is the retain-boundary question — whether an operation that outlives its frame should be a different type from one that does not — and UI-O4 is open on exactly that.

  6. Make paint return an extent (friction §8). F7 separates surface, layout and ink extent and puts the axis at maintained-at-construction versus derived-by-scan; vxfw.Surface.size is the maintained end at zero marginal cost, because the draw call already knows how much it covered and simply says so. skia-canvas-render.d's op-scan disappears without a new query being added anywhere.

  7. Do not conclude from Notcurses that a fidelity ladder is the cell answer. Two cell libraries, two different sub-cell designs, neither of them a position enum — the shared finding is only the negative one: RuleEdge is the outlier and integer-only geometry is what produced it.

  8. A scrollbar is a widget parameter, not a seam operation (friction §3). Second independent confirmation after Ratatui: libvaxis's low-level scrollbar is 33 lines with a single character field, and its framework version carries six configurable thumb cells — all above the seam. F4 draws the line at derived geometry rather than at semantic operations, and the Scrollbar payload's content, viewport and offset are on the wrong side of it: they travel so a rail can be derived below the seam, from a formula (scrollbarThumb) that already lives above it in sparkles.ui.state.

Sources

All paths verified to exist at c060d314930c5552b99a89278a6a695baf0352da; the revision was resolved with gh api repos/rockorager/libvaxis/commits/main --jq .sha and each file was read from raw.githubusercontent.com at that SHA.