Architecture
This is the condensed, kept-current map of how ntui turns component
functions into terminal cells. For the original rationale behind these
choices, see
superpowers/specs/2026-07-02-ntui-design.md.
The tree types
Element/Node(element.rs) — the tree your component returns each render, usually built with theelement!macro. ANodeis one of a hostView(a flexbox box),Text(a run of styled text),Fragment,Provider(context injection), or a type-erasedBox<dyn AnyComponent>for a nested component.Component(component.rs) — the trait a component implements (normally generated by#[component]) to turn props +Hooksinto anElement.FiberTree(fiber.rs) — the retained tree the engine actually keeps between frames: aHashMaparena keyed by monotonically increasing, never-reused fiber ids. Each fiber owns its hook slots, its children, its computed layoutRect, and arendered_onceflag.
The distinction matters: Element is a fresh, cheap description built every
render; FiberTree is the durable state (hook slots, DOM-like identity) that
persists across renders and is reconciled against the new Element tree.
One frame, end to end
State::set ──► Wake::Dirty on unbounded channel
runtime::AppCore::process_wakes drains the channel, dedups, sorts dirty fibers
shallowest-first, re-renders each (bounded by
MAX_UPDATE_PASSES ≈ React's max update depth),
then FiberTree::flush_effects
reconciler::render_fiber runs the component fn, then reconcile_children:
match by key, else index+type; props_eq
short-circuits whole subtrees
layout::compute_layout rebuilds a fresh TaffyTree from the fiber tree
(only when tree.layout_dirty), text measured via
wrap_text, writes a Rect per fiber
paint::paint DFS over fibers → Buffer (a grid of Cells);
document order = z-order
Buffer::diff cell-level diff vs. the previous frame
Backend::flush minimal updates out
-
Wake. Calling
State::set/updatesends aWake::Dirty(fiber_id)on an unbounded channel — this is the only path that schedules work; there is no polling. -
Process wakes.
AppCore::process_wakesdrains and dedups pending wakes, sorts them shallowest-fiber-first (so a parent re-render that remounts a child doesn’t waste a redundant re-render of the old child), and re-renders each dirty fiber. Cascadingsetcalls made during a render enqueue more wakes, so this loop repeats up toMAX_UPDATE_PASSEStimes per frame — the same safety valve React’s renderer uses against infinite re-render loops. Effects (use_effectmount/cleanup) flush after all renders settle for the frame. -
Reconcile.
reconciler::render_fiberinvokes the component function (hook slots aremem::taken out of the fiber first, so user code can’t observe stale hook state via re-entrancy, then restored after — seeCLAUDE.md’s “Invariants that bite” if you’re touching this). The returnedElementtree is reconciled against the fiber’s existing children: matched by explicitkeyfirst, then by index + node type.props_eqlets a whole matched subtree skip re-rendering when its props didn’t change — this is where big trees stay cheap.That comparison is by value, which inverts for a large payload: passing a list of several hundred items down as
Arc<Vec<Item>>deep-compares the whole list every frame, sinceArc’s ownPartialEqcompares pointees — more expensive than the render it exists to skip.Shared<T>is anArcwhosePartialEqisArc::ptr_eq, which is both cheaper and, for the usual producer pattern, more accurate: code that rebuilds its payload each tick hands down a fresh allocation, so identity already answers “did this change?”. Note thatShared::default()allocates, so a defaulted field compares unequal every render and defeats the very short-circuit it is there to enable. -
Layout. If reconciliation touched anything layout-affecting (
layout_dirty),layout::compute_layoutrebuilds a freshtaffytree from the current fibers and writes aRectback onto each one. Text nodes are measured bywrap_textunder taffy’s measure callback. A no-op re-render (props-equal, no structural change) must not setlayout_dirty— this is covered by a dedicated regression test. -
Paint.
paint::paintwalks the fiber tree depth-first into aBuffer— a grid ofCells (char + style). Document order is z-order: later siblings/deeper nodes paint over earlier ones within theirRect. -
Diff + flush.
Buffer::diffagainst the previous frame’s buffer produces a minimal list of changed cells, whichBackend::flushwrites out. Only what changed hits the terminal.
Two rendering modes
Both modes share every stage above; they differ only in the Backend and
in what “flush” means.
Fullscreen — render()
backend/fullscreen.rs’s FullscreenBackend switches to the alternate
screen and enables raw mode on enter(), and must leave the screen cleared
(the first frame is diffed against an all-blank buffer — see the Backend
trait contract in backend/mod.rs). Each frame’s cell diff is written via
flush(&[CellUpdate]). This is the mode used by counter, spinner,
list, demo, and claude_code.
Inline — render_inline()
backend/inline.rs’s InlineBackend (driven by run_inline_loop in
runtime.rs) does not use the alternate screen. Instead, each frame
splits into two parts:
- Committed rows — printed permanently via
InlineSink::commit, which erases the current live region, prints the given rows (they scroll into real, mouse-scrollable terminal history), then leaves the cursor ready for the live region again. A component pushes finished, static content here viause_scrollback’sScrollback::commit(element)handle. Outside inline mode,Scrollbackcommits are queued but never drawn. - The live region — redrawn in place below the committed content via
InlineSink::present, for anything still changing (an input line, a streaming reply, a spinner).
backend/ansi.rs holds the cell→ANSI row writer shared by both backends, so
the two only differ in when and where they write, not in how a row of
cells becomes terminal bytes.
Inline mode assumes a small live region (an input line plus a few lines of
in-progress output); a live region taller than the screen can glitch on
redraw. See inline_chat.rs for the
intended shape.
hooks/scroll.rs’s use_scroll (scroll position within an
Overflow::Scroll view) is unrelated to Scrollback and works identically
in both modes.
Testing surface
testing::TestTerminal drives an AppCore by hand — mount, send a key,
inspect the frame — without any backend or TTY at all. This is how nearly
all of ntui’s own integration-style tests work, and it’s the recommended
way to test application components too (see
getting-started.md).
Public surface
Engine internals (fiber, reconciler, runtime, layout, paint, text)
are pub(crate) — nothing outside the crate touches them directly. The
public API is the curated re-export list at the top of lib.rs: Element,
Component, Hooks, the element!/#[component] macros, render /
render_inline, the Backend trait and its implementations, testing, and
the hook return types (State, Scroll, Scrollback, AppHandle, …).
Everything re-exported there is semver surface.