--- title: "Component Tree Architecture for Terminal User Interfaces" description: "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." language: null framework: null category: software_architecture tags: - tui - terminal-ui - component-tree - event-bubbling - compositing - z-index - focus-management - hit-testing - clipping - rendering-pipeline - architecture - best-practices keywords: - component tree DOM-like terminal UI - event bubbling leaf first root last - z-index compositing dirty tracking TUI - active focus routing keyboard terminal - hit testing clipping terminal UI - animation tick delegation TUI - terminal rendering pipeline double buffering - markDirty propagation component tree - compositor paint order preorder sort - when to use component tree vs flat widgets TUI - zero idle bytes rendering invariant last_updated: "2026-07-08" difficulty: advanced version: "Architecture-level guidance; language-agnostic" related: - clean_architecture.md - hexagonal.md - ../design_patterns/structural.md - ../design_patterns/behavioral.md - ../principles/solid.md - ../principles/clean_code.md - ../principles/dry_kiss_yagni.md - ../testing/unit_testing_principles.md - ../testing/integration_testing.md search_priority: high status: published --- # Component Tree Architecture for Terminal User Interfaces 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: 1. **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. 2. **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](#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: 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 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` 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 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. 2. **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. 3. **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**: `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 chat text). 3. `!shell` + Enter → runs as shell command, streams output. 4. Trailing backslash + Enter → inserts literal newline, buffer stays 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, agent events, terminal resize, external editor return, startup). 2. A cell-diff engine that compares consecutive frames and writes only changed cell runs. 3. No unconditional painting or ticking - the tick loop stops when 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: 1. Give the overlay real `bounds` (position AND size) and `overflowVisible = true`. 2. Give it a `zIndex` high enough to paint above whatever it floats over. 3. Wire `composite()`/`paintOrder()` into the render path - the direct-call shim pattern assumes non-overlapping bands, which a floating overlay deliberately violates. 4. Write the hit-test/clip regression test before the widget. --- ## 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](clean_architecture.md) - the same dependency-inversion thinking applied to application architecture - [Hexagonal Architecture](hexagonal.md) - ports and adapters mirror how the component tree decouples widgets from the rendering engine - [Structural Design Patterns](../design_patterns/structural.md) - Composite pattern is the theoretical basis for this tree - [Behavioral Design Patterns](../design_patterns/behavioral.md) - Chain of Responsibility is the pattern behind event bubbling - [SOLID Principles](../principles/solid.md) - Dependency Inversion keeps the rendering engine untouched by tree changes - [DRY, KISS, YAGNI](../principles/dry_kiss_yagni.md) - why the compositor stays built-but-not-wired until the first overlapping widget needs it --- ## 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 `last_updated` and consider whether the `related` links in [clean_architecture.md](clean_architecture.md), [structural.md](../design_patterns/structural.md), and [behavioral.md](../design_patterns/behavioral.md) should link back here.