core/architecture/component_tree_tui_architecture.md

Component Tree Architecture for Terminal User Interfaces

Language-agnostic reference for building DOM-like component trees in TUIs: event bubbling from leaf to root, z-index compositing with dirty tracking, active/focus routing, hit-testing with clipping, animation delegation, and the engineering trade-offs that make this pattern earn its keep versus flat widget lists.

A DOM-like component tree for terminal UIs - one base type, one event routing rule, one compositing pipeline. This document describes the architecture in language-agnostic terms, drawn from a production Nim implementation that powers a multi-widget, animated, streaming TUI application. It covers what the tree provides, when it earns its keep versus a flat widget list, and the invariants that must hold for the approach to remain correct.

When to use this guidance: You are building or refactoring a TUI that has more than two interactive regions (input box, status bar, popup menu, modal dialog, scrollable viewport, overlay), any of which need to capture keyboard input independently, and you want a single consistent answer to "who paints on top of whom" and "who receives this keystroke."


Decision Table: Component Tree or Flat Widgets

SignalUse a Component TreeFlat widgets are fine
Interactive regions4+ regions that can each receive keyboard focus1-2 regions, one always active
Overlapping paintA popup, dropdown, or tooltip must paint on top of other contentAll widgets occupy non-overlapping horizontal bands
Focus changes at runtimeA modal appears mid-session and must capture all input, then hand backOnly one widget ever owns input
z-order mattersWidgets are added/removed dynamically and must sort correctlyStatic layout, insertion order suffices
Animation clocksMultiple widgets animate independently (spinners, pulse dots, breathing)One global tick or no animation
Team size2+ engineers adding widgets over timeSolo project, complete widget set

If you answered "flat widgets are fine" to all rows, a component tree is overengineering. If you hit even one "Component Tree" row, the rest of this document applies.


Core Concepts

The Tree

Every visible and interactive element inherits from a single base type - Component. The tree has one root (whose parent is null) and an arbitrary number of children, each of which can have its own children. A component's position (bounds) is relative to its parent; the compositor resolves absolute screen positions by accumulating offsets down the tree.

                         ┌──────────────┐
                         │   AppRoot    │  ← parentRef = null
                         └──────┬───────┘     (last link in every bubble chain)
                                │ children
     ┌──────────┬──────────┬────┴─────┬──────────┬──────────┐
     ▼          ▼          ▼          ▼          ▼          ▼
  Banner    TaskList   StatusBar  SlashMenu  ModalPrompt  Viewport
                                                            │
                                                        InputBox
                                                   (active at startup)

Two Halves, One Tree

The tree serves two purposes that are conceptually separate but share the same structure:

  1. Event routing - dispatch walks from the currently active component
  2. up through parentRef until a handler consumes the event or the walk reaches the root. This is the live path on every keystroke.

  1. Rendering / compositing - a composite() pipeline collects every
  2. visible component's buffer, sorts by (zIndex, preorderIndex), and splices them into one frame. This may or may not be the live render path (see Deliberate Scoping Decisions), but its existence guarantees the architecture can support true z-order overlap when needed.

The Component Base Type

Every component carries these properties:

PropertyTypePurpose
boundsrectangle (x, y, w, h)Position relative to parent and size
zIndexintegerPaint order; higher draws on top
visiblebooleanWhether the component participates in compositing and hit-testing
activebooleanExactly one component in the tree is the keyboard focus target
hasActiveDescendantbooleanTrue on every ancestor of the active component
dirtybooleanNeeds re-rendering before the next compositor pass
overflowVisiblebooleanWhen true, content can paint outside parent bounds (popups)
bufferarray of linesThe component's own rendered output
childrenarray of ComponentSubtree
parentComponent or nullOne link up

No component knows about its siblings except through the parent's child list. No component knows about the rendering engine. The tree is pure layout, event routing, and buffer management - the terminal write itself happens outside it.


Event Routing: Leaf-First Bubbling

This is the single most important behavioral contract in the architecture. Given an active component and a key event:

  1. Start at the active component.
  2. Call its onEvent(event) handler.
  3. If it returns Handled, stop. The event is consumed.
  4. If it returns Ignored, move to parent.
  5. Repeat until a handler consumes the event or parent is null (the root
  6. was reached and also ignored it).

InputBox.onEvent(event)          - handles kChar, kBackspace, kDelete,
                                   kLeft, kRight, kHome, kEnd;
                                   returns Ignored for everything else
  ↓ (if Ignored)
AppRoot.onEvent(event)            - global shortcuts (Ctrl+C, Ctrl+L, etc.),
                                   the last link (parentRef == null)

Why leaf-first, not root-first? If global shortcuts run first, a modal that captures most keys but lets Ctrl+C through cannot be implemented without duplicating the Ctrl+C handler in both places. Leaf-first means the modal owns its keys by default and deliberately ignores only the ones it wants to bubble. This is the same reason the DOM works this way.

The kEnter Landmine

A key that both a leaf widget and the root need to handle (Enter, to submit text, but also to dispatch slash commands) creates a genuine conflict under leaf-first dispatch. If the leaf claims Enter unconditionally, the root never sees it and slash-command dispatch silently breaks.

The rule: only one handler in the bubble chain may claim a key. If two need it, the higher (closer-to-root) handler claims it and the leaf deliberately ignores it. The root's handler then replicates whatever the leaf would have done for the plain-text case, after checking all its own special cases first.

Root's Enter handler:
  1. If buffer starts with '/'  → dispatch as slash command
  2. If buffer starts with '!'  → dispatch as shell command
  3. If trailing backslash      → insert literal newline, do NOT submit
  4. Else                       → submit buffer as plain text (replicates
                                  what the leaf would have done)

The Modal Landmine

A modal that appears mid-session (permission prompt, error dialog) must become the tree's active component when it shows, and must hand activation back explicitly when dismissed. If it does not call requestActive() on itself, keystrokes meant to answer the modal (y/n/Escape) are silently swallowed by whatever was active before - the modal appears to ignore input.

Modal.show():
  1. Set own "visible" flag to true
  2. Call requestActive()          ← makes self the tree's active component
  3. Render

Answer handler:
  1. Process the answer
  2. Set own "visible" flag to false
  3. Call inputBox.requestActive() ← hand focus back explicitly, by name

There is no generic "focus history stack" in the base architecture. When only one modal can appear at a time and focus always returns to the same place, a named handoff is simpler and provably correct.


Active / Focus Routing

There is exactly one active component in the tree at any time. Setting it:

  1. Clear hasActiveDescendant on every ancestor of the old active component.
  2. Clear active on the old active component itself, mark it dirty.
  3. Set active on the new component, mark it dirty.
  4. Walk the new component's ancestor chain, setting hasActiveDescendant
  5. on each.

active is never set by a click (there is no mouse input in a pure-keyboard TUI). It is set only by explicit, intentional code: InputBox at startup, ModalPrompt.show(), and back to InputBox on dismiss.

The distinction between active (tree-wide focus concept) and a widget's own visible/open boolean (am I currently shown) is critical - they mean different things and must be tracked independently. A modal can be both visible and active simultaneously, but only one component can be active.


Rendering Pipeline

The Compositor

The generic compositing pipeline, whether wired into the live render path or held in reserve for overlapping widgets, consists of three steps:

Step 1 - renderDirty(root): Collect every visible component in preorder (depth-first, parent before children). An invisible component's entire subtree is skipped. For each component whose dirty flag is true, call its render() method, then clear the flag. Return the full preorder list - dirty and non-dirty alike, because even a non-dirty component's existing buffer still needs to be painted this frame.

Step 2 - paintOrder(root): Sort the preorder list by (zIndex, preorderIndex) ascending. Higher zIndex paints later (on top). Equal zIndex breaks by insertion order into the tree - deterministic and DOM-like. This is what lets a deep grandchild with a high zIndex draw on top of an entirely unrelated branch.

Step 3 - composite(root): Walk the paint-order list and, for each component, splice its buffer into a shared output array at its absoluteBounds() position, clipped to its effectiveClipRect(). The result is one flat array of lines sized to the root's height - exactly what any cell-diff rendering engine expects.

Dirty Propagation

markDirty() sets the dirty flag on a component and unconditionally walks up through every ancestor, setting dirty on each. No early-exit shortcut ("stop if already dirty") - the tree is shallow enough (2-4 levels) that unconditional propagation is cheaper to reason about and provably correct across addChild/removeChild reparenting.

Setters auto-dirty when values actually change:

  • zIndex= - marks self dirty.
  • bounds= - marks self dirty only when width or height changes (pure
  • position moves do not invalidate the rendered buffer).

  • visible= - marks the parent dirty (a newly hidden/shown child changes
  • what the parent must composite).

  • buffer= - the sanctioned way for a subclass render() to publish its
  • computed lines.

Clipping and Absolute Positioning

absoluteBounds(c) accumulates parent-relative offsets down the ancestor chain into one screen-absolute rectangle.

effectiveClipRect(c) is the rectangle that constrains c's drawing. It is computed recursively:

  • For the root: its own absoluteBounds().
  • For a child whose overflowVisible is false (default): the intersection
  • of what constrains the parent and the parent's own rectangle. Content outside the parent's bounds is clipped.

  • For a child whose overflowVisible is true: exactly what constrains the
  • parent, without additionally confining to the parent's own rectangle. This is what lets a popup positioned at a child's bottom-right corner escape the parent's clip window.

When NOT to Wire the Generic Compositor

Three concrete reasons to keep the compositor as a built, tested, but not-yet-live render path:

  1. Zero overlap today. If every component occupies its own
  2. non-overlapping horizontal band, full-width, stacked vertically, z-order compositing produces identical output to direct sequential rendering - at the cost of an extra sort and splice step.

  1. Uncomposable layout logic. A scrollbar glyph stamped onto every
  2. visible content row, regardless of which sub-widget produced that row, is a property of the whole viewport - it does not decompose cleanly onto per-component rects.

  1. Risk asymmetry. Flipping the entire render path to a new compositor
  2. is a large, high-blast-radius change. When the current direct-call path produces zero-idle-bytes and correct output for every existing layout, the switchover should happen as part of adding the first overlapping widget (popup, dropdown, tooltip) - not before.


Hit-Testing

Even in a keyboard-only TUI, hit-testing matters for architectural completeness and for the day mouse input is optionally supported:

hitTest(root, x, y) walks components in reverse paint order (topmost first), checks whether the point falls within absoluteBounds(c) AND effectiveClipRect(c), and returns the first match. A clipped child is unreachable outside its parent's bounds - the point falls through to whatever is underneath.

overflowVisible correctly escapes a parent's clip: a popup positioned to extend past its 5-row parent is still hittable at a point the parent alone would have clipped away.

Mouse capture is permanently disabled in a pure-keyboard TUI so that plain click-drag text selection in the terminal always works. Hit-testing exists, is correct, and is unit-testable with synthetic trees - it just has no live input path until a concrete feature needs it.


Tick and Animation Delegation

The base tick(deltaMillis) recurses into every child. Each widget that animates (spinner, pulse dots, breathing effect, streaming reveal) overrides tick to advance its own clock. The override replaces the recursive default entirely - fine as long as animating widgets are leaf nodes with no children of their own.

Ticking is gated: the main loop only calls root.tick(deltaMillis) while animations are active (e.g., content is streaming, a spinner is visible). When the application is idle, the tick loop stops and the terminal emits zero bytes - this is the single most important rendering invariant in the whole system.

Do not add manual per-widget tick calls outside the tree. The moment widgets are real children of the root, root.tick() already reaches them through the recursive default. A second manual call produces a double-tick bug (animations advance twice per frame, running visibly too fast).


The Compatibility-Shim Pattern

When migrating a flat widget system to a component tree without changing observable behavior, each old buildXLines(widget, width) -> seq[Line] function becomes a thin wrapper:

function buildWidgetLines(widget, width):
    desired = widget.preferredSize(width, 0)
    widget.bounds = rect(0, 0, width, desired.height)
    widget.render()              // reads bounds.width/height internally
    return widget.buffer

Callers are unaware anything changed - self.widget.buildWidgetLines(width) reads identically before and after. The shim exists only as long as the direct-call render path is live; it disappears when the compositor takes over.


Common Pitfalls and Anti-Patterns

Pitfall: Field Name Collisions Between Widget State and Base Type

If a widget declares a field with the same name as a base-type accessor (e.g., a widget's own active: bool meaning "am I visible" vs the tree's active meaning "am I the focus target"), the language resolves the widget-typed reference against the field first, silently shadowing the base accessor. The tree's focus concept is only reachable through generic/base-typed references - which is fine as long as no widget-typed code path tries to read the tree focus state through dot syntax.

Rule: audit every field name on every widget type for collisions with the base type's accessor names (active, buffer, visible, bounds, zIndex, children, parent, dirty, bounds). Rename the widget's field if the collision is real (different types), document it if both are the same type and the shadowing is intentional.

Pitfall: Claiming a Key That the Root Needs

Any key that a leaf widget claims unconditionally will never reach the root. If the root also needs that key (Enter for slash-command dispatch, Escape for a global dismiss), the leaf must deliberately ignore it. Audit the full key-set of every onEvent override against the root's global shortcuts before wiring dispatch.

Pitfall: Forgetting requestActive() on a New Modal

A modal that sets its visible flag but does not call requestActive() will paint correctly but ignore all input. Keystrokes go to whatever was active before. The symptom: a dialog appears but pressing y/n does nothing, and letters silently appear in the input box behind it.

Pitfall: Double-Ticking

If a widget is both a real tree child (reached by root.tick()'s recursive walk) AND ticked manually in the frame handler, its animation clock advances twice per frame. The fix: remove the manual call. The tree already covers it.

Anti-Pattern: Abstracting Too Early

A purely flat, non-overlapping, single-active-widget TUI does not benefit from a full component tree - the tree adds abstraction overhead (tree operations, dirty propagation, compositing sort) for zero behavioral gain. Build the flat version first. Introduce the tree the moment you add the first widget that needs to overlap, capture focus independently, or animate on its own clock.


Testing Strategies

Unit Tests for Tree Logic

The component tree is testable without a terminal, without a rendering engine, and without real keyboard input. Construct trees in memory, dispatch synthetic key events, and assert:

  • Dirty propagation: markDirty reaches every ancestor; addChild
  • marks the parent dirty; a size change marks dirty, a pure position move does not.

  • Compositor ordering: paintOrder sorts by (zIndex, preorderIndex); a
  • deep high-zIndex grandchild sorts above an unrelated shallow branch.

  • Active semantics: exactly one active component exists; ancestors get
  • hasActiveDescendant (never active themselves); switching active fully clears the old chain and sets the new one.

  • Key bubbling: first handler wins, bubbling stops there; unhandled event
  • reaches the root last; with no active component, dispatch falls back to the root.

  • Hit-testing: topmost-by-zIndex resolution; clipped children are
  • unreachable outside parent bounds; overflowVisible escapes clip.

  • Edge cases: a shared ancestor between old and new active nodes stays
  • hasActiveDescendant = true across the switch (never flickers to false and back); removing the active child from the tree is handled.

Integration Tests for Rendering

For the live render loop, use a headless terminal emulator (pty + screen buffer). Assert against emulated screen contents after key sequences:

  • Idle zero-bytes: with no input and no animation, the terminal receives
  • exactly 0 bytes over 5+ seconds. A keypress produces output immediately. The following idle window is again 0 bytes.

  • Layout alignment: border glyphs (╭╮╰╯│) align precisely across
  • different terminal widths; scrollbar thumb position and size are proportional and direction-correct.

  • Chronological transcript: tool output and LLM responses interleave in
  • the exact order events happened; streamed text appends to the correct entry, never teleports above earlier content.

  • Theme changes: switching themes invalidates the render cache and
  • repaints every entry with the new colors.

Testing the kEnter Contract

This deserves dedicated coverage because it is the most likely regression point:

  1. Plain text + Enter → submits to backend.
  2. /command + Enter → dispatches as command (never appears as submitted
  3. chat text).

  4. !shell + Enter → runs as shell command, streams output.
  5. Trailing backslash + Enter → inserts literal newline, buffer stays
  6. unsubmitted.

Each sub-case must be verified end-to-end against the actual rendered screen.


Performance, Security, and Scalability

Idle Zero-Bytes

The terminal must emit zero bytes while idle. This is what keeps terminal text selection and copying intact. Achieving it requires three things:

  1. A damage flag that is only set on real state changes (key dispatch,
  2. agent events, terminal resize, external editor return, startup).

  3. A cell-diff engine that compares consecutive frames and writes only
  4. changed cell runs.

  5. No unconditional painting or ticking - the tick loop stops when
  6. animations stop, and nothing repaints "just in case."

Scroll-Shift Optimization

When the next frame is a uniform upward shift of the current one (streaming appends, growing input box), detect it and scroll the terminal once via newline-at-bottom-row instead of rewriting every cell. Shift the in-memory canvas and repaint only the rows that truly differ. This makes streaming output chirurgical instead of a mass rewrite.

Rendering Engine Decoupling

The rendering engine (cell grid, differential flush) must require zero changes when the component tree is introduced or when the compositor is wired in. The compositor's output type is identical to what a hand-assembled frame produces: an array of lines, one per terminal row. This is the proof that the tree and the engine are correctly decoupled.


Edge Cases and Advanced Usage

The root's parentRef is null. The dispatch loop terminates when node becomes null - which happens one step after visiting the root. This structurally guarantees the root's handler is always the last link tried, never skipped, regardless of how deep the active component is.

Composite Key Ownership

When two widgets in the same bubble path both want to handle the same key (e.g., kUp/kDown for a slash menu AND for content scrolling), the conflict must be resolved at the caller level (the root's handler), not inside either widget. The root checks its own state (is the slash menu open?) and routes to the correct widget. Neither widget claims the key in its own onEvent - both return Ignored.

Adding a Floating Overlay

This is the concrete trigger for wiring the generic compositor into the live render path:

  1. Give the overlay real bounds (position AND size) and
  2. overflowVisible = true.

  3. Give it a zIndex high enough to paint above whatever it floats over.
  4. Wire composite()/paintOrder() into the render path - the direct-call
  5. shim pattern assumes non-overlapping bands, which a floating overlay deliberately violates.

  6. Write the hit-test/clip regression test before the widget.

When Not to Use / Alternatives

SituationComponent TreeAlternative
Single input + single output areaOverengineeredDirect draw functions, no tree
Two static panes (editor + preview)OverengineeredTwo render calls in fixed order
Pure display (no interactivity)UnnecessaryA single buffer builder
Embedded / severely memory-constrainedThe tree overhead (parent refs, children arrays per node) may matterFlat arrays of widget structs
Rapid prototype, throwawayOverengineeredHardcoded paint sequence

The component tree earns its keep at roughly 4+ interactive regions, at least one of which needs to change focus at runtime.



Glossary

  • Active component: The one component in the tree that currently owns
  • keyboard focus. Exactly one exists at any time (or zero, if nothing is active - dispatch falls back to the root).

  • Bubbling: Walking from the active component up through parentRef
  • until a handler consumes the event or the root is reached.

  • Compositing: Collecting every visible component's rendered buffer,
  • sorting by z-index, and splicing into one frame.

  • Damage / dirty: A flag indicating a component needs re-rendering.
  • Propagates upward unconditionally.

  • EventResult: A two-value enum - Handled (stop bubbling) or
  • Ignored (continue bubbling to parent).

  • Paint order: The sorted list of components by (zIndex, preorderIndex)
  • ascending - the order in which buffers are composited.

  • Preorder index: The position of a component in a depth-first traversal
  • of the tree. Recomputed every compositor pass. Acts as the tie-break when z-indices are equal.

  • Shim: A thin wrapper function that preserves the old public API
  • (buildXLines(width) -> seq[Line]) while routing through the new Component render()/buffer mechanism underneath.

  • zIndex: An integer controlling paint order. Higher values paint later
  • (on top). Ties break by preorder index.


Maintenance note: When updating this document, also update lastupdated and consider whether the related links in cleanarchitecture.md, structural.md, and behavioral.md should link back here.