▌ntui

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 the element! macro. A Node is one of a host View (a flexbox box), Text (a run of styled text), Fragment, Provider (context injection), or a type-erased Box<dyn AnyComponent> for a nested component.
  • Component (component.rs) — the trait a component implements (normally generated by #[component]) to turn props + Hooks into an Element.
  • FiberTree (fiber.rs) — the retained tree the engine actually keeps between frames: a HashMap arena keyed by monotonically increasing, never-reused fiber ids. Each fiber owns its hook slots, its children, its computed layout Rect, and a rendered_once flag.

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
  1. Wake. Calling State::set/update sends a Wake::Dirty(fiber_id) on an unbounded channel — this is the only path that schedules work; there is no polling.

  2. Process wakes. AppCore::process_wakes drains 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. Cascading set calls made during a render enqueue more wakes, so this loop repeats up to MAX_UPDATE_PASSES times per frame — the same safety valve React’s renderer uses against infinite re-render loops. Effects (use_effect mount/cleanup) flush after all renders settle for the frame.

  3. Reconcile. reconciler::render_fiber invokes the component function (hook slots are mem::taken out of the fiber first, so user code can’t observe stale hook state via re-entrancy, then restored after — see CLAUDE.md’s “Invariants that bite” if you’re touching this). The returned Element tree is reconciled against the fiber’s existing children: matched by explicit key first, then by index + node type. props_eq lets 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, since Arc’s own PartialEq compares pointees — more expensive than the render it exists to skip. Shared<T> is an Arc whose PartialEq is Arc::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 that Shared::default() allocates, so a defaulted field compares unequal every render and defeats the very short-circuit it is there to enable.

  4. Layout. If reconciliation touched anything layout-affecting (layout_dirty), layout::compute_layout rebuilds a fresh taffy tree from the current fibers and writes a Rect back onto each one. Text nodes are measured by wrap_text under taffy’s measure callback. A no-op re-render (props-equal, no structural change) must not set layout_dirty — this is covered by a dedicated regression test.

  5. Paint. paint::paint walks the fiber tree depth-first into a Buffer — a grid of Cells (char + style). Document order is z-order: later siblings/deeper nodes paint over earlier ones within their Rect.

  6. Diff + flush. Buffer::diff against the previous frame’s buffer produces a minimal list of changed cells, which Backend::flush writes 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 via use_scrollback’s Scrollback::commit(element) handle. Outside inline mode, Scrollback commits 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.