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
| Signal | Use a Component Tree | Flat widgets are fine |
|---|---|---|
| Interactive regions | 4+ regions that can each receive keyboard focus | 1-2 regions, one always active |
| Overlapping paint | A popup, dropdown, or tooltip must paint on top of other content | All widgets occupy non-overlapping horizontal bands |
| Focus changes at runtime | A modal appears mid-session and must capture all input, then hand back | Only one widget ever owns input |
| z-order matters | Widgets are added/removed dynamically and must sort correctly | Static layout, insertion order suffices |
| Animation clocks | Multiple widgets animate independently (spinners, pulse dots, breathing) | One global tick or no animation |
| Team size | 2+ engineers adding widgets over time | Solo 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:
- Event routing - dispatch walks from the currently active component
up through parentRef until a handler consumes the event or the walk reaches the root. This is the live path on every keystroke.
- Rendering / compositing - a
composite()pipeline collects every
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:
| Property | Type | Purpose |
|---|---|---|
bounds | rectangle (x, y, w, h) | Position relative to parent and size |
zIndex | integer | Paint order; higher draws on top |
visible | boolean | Whether the component participates in compositing and hit-testing |
active | boolean | Exactly one component in the tree is the keyboard focus target |
hasActiveDescendant | boolean | True on every ancestor of the active component |
dirty | boolean | Needs re-rendering before the next compositor pass |
overflowVisible | boolean | When true, content can paint outside parent bounds (popups) |
buffer | array of lines | The component's own rendered output |
children | array of Component | Subtree |
parent | Component or null | One 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:
- Start at the active component.
- Call its
onEvent(event)handler. - If it returns
Handled, stop. The event is consumed. - If it returns
Ignored, move toparent. - Repeat until a handler consumes the event or
parentis null (the root
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:
- Clear
hasActiveDescendanton every ancestor of the old active component. - Clear
activeon the old active component itself, mark it dirty. - Set
activeon the new component, mark it dirty. - Walk the new component's ancestor chain, setting
hasActiveDescendant
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 (purevisible=- marks the parent dirty (a newly hidden/shown child changesbuffer=- the sanctioned way for a subclassrender()to publish its
position moves do not invalidate the rendered buffer).
what the parent must composite).
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
overflowVisibleis false (default): the intersection - For a child whose
overflowVisibleis true: exactly what constrains the
of what constrains the parent and the parent's own rectangle. Content outside the parent's bounds is clipped.
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:
- Zero overlap today. If every component occupies its own
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.
- Uncomposable layout logic. A scrollbar glyph stamped onto every
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.
- Risk asymmetry. Flipping the entire render path to a new compositor
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:
markDirtyreaches every ancestor;addChild - Compositor ordering:
paintOrdersorts by(zIndex, preorderIndex); a - Active semantics: exactly one active component exists; ancestors get
- Key bubbling: first handler wins, bubbling stops there; unhandled event
- Hit-testing: topmost-by-zIndex resolution; clipped children are
- Edge cases: a shared ancestor between old and new active nodes stays
marks the parent dirty; a size change marks dirty, a pure position move does not.
deep high-zIndex grandchild sorts above an unrelated shallow branch.
hasActiveDescendant (never active themselves); switching active fully clears the old chain and sets the new one.
reaches the root last; with no active component, dispatch falls back to the root.
unreachable outside parent bounds; overflowVisible escapes clip.
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
- Layout alignment: border glyphs (
╭╮╰╯│) align precisely across - Chronological transcript: tool output and LLM responses interleave in
- Theme changes: switching themes invalidates the render cache and
exactly 0 bytes over 5+ seconds. A keypress produces output immediately. The following idle window is again 0 bytes.
different terminal widths; scrollbar thumb position and size are proportional and direction-correct.
the exact order events happened; streamed text appends to the correct entry, never teleports above earlier content.
repaints every entry with the new colors.
Testing the kEnter Contract
This deserves dedicated coverage because it is the most likely regression point:
- Plain text + Enter → submits to backend.
/command+ Enter → dispatches as command (never appears as submitted!shell+ Enter → runs as shell command, streams output.- Trailing backslash + Enter → inserts literal newline, buffer stays
chat text).
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:
- A damage flag that is only set on real state changes (key dispatch,
- A cell-diff engine that compares consecutive frames and writes only
- No unconditional painting or ticking - the tick loop stops when
agent events, terminal resize, external editor return, startup).
changed cell runs.
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 as Last Bubble Link
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:
- Give the overlay real
bounds(position AND size) and - Give it a
zIndexhigh enough to paint above whatever it floats over. - Wire
composite()/paintOrder()into the render path - the direct-call - Write the hit-test/clip regression test before the widget.
overflowVisible = true.
shim pattern assumes non-overlapping bands, which a floating overlay deliberately violates.
When Not to Use / Alternatives
| Situation | Component Tree | Alternative |
|---|---|---|
| Single input + single output area | Overengineered | Direct draw functions, no tree |
| Two static panes (editor + preview) | Overengineered | Two render calls in fixed order |
| Pure display (no interactivity) | Unnecessary | A single buffer builder |
| Embedded / severely memory-constrained | The tree overhead (parent refs, children arrays per node) may matter | Flat arrays of widget structs |
| Rapid prototype, throwaway | Overengineered | Hardcoded paint sequence |
The component tree earns its keep at roughly 4+ interactive regions, at least one of which needs to change focus at runtime.
Related Documentation
- Clean Architecture: Layers, the Dependency Rule -
- Hexagonal Architecture - ports and adapters mirror how the
- Structural Design Patterns - Composite
- Behavioral Design Patterns - Chain of
- SOLID Principles - Dependency Inversion keeps the
- DRY, KISS, YAGNI - why the compositor
the same dependency-inversion thinking applied to application architecture
component tree decouples widgets from the rendering engine
pattern is the theoretical basis for this tree
Responsibility is the pattern behind event bubbling
rendering engine untouched by tree changes
stays built-but-not-wired until the first overlapping widget needs it
Glossary
- Active component: The one component in the tree that currently owns
- Bubbling: Walking from the active component up through
parentRef - Compositing: Collecting every visible component's rendered buffer,
- Damage / dirty: A flag indicating a component needs re-rendering.
- EventResult: A two-value enum -
Handled(stop bubbling) or - Paint order: The sorted list of components by
(zIndex, preorderIndex) - Preorder index: The position of a component in a depth-first traversal
- Shim: A thin wrapper function that preserves the old public API
- zIndex: An integer controlling paint order. Higher values paint later
keyboard focus. Exactly one exists at any time (or zero, if nothing is active - dispatch falls back to the root).
until a handler consumes the event or the root is reached.
sorting by z-index, and splicing into one frame.
Propagates upward unconditionally.
Ignored (continue bubbling to parent).
ascending - the order in which buffers are composited.
of the tree. Recomputed every compositor pass. Acts as the tie-break when z-indices are equal.
(buildXLines(width) -> seq[Line]) while routing through the new Component render()/buffer mechanism underneath.
(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.