15 KiB
Render Dispatch and Pipeline
Overview
The render module provides the generic function dispatch that connects
the component tree to the backend. Every component type defines its own
render method; this module defines the common protocol and the
top-level orchestration functions.
Three responsibilities live here:
- Component protocol — generic functions for navigating the
component tree (
component-children,component-parent,component-layout-node) - Render pipeline —
render-screenties layout computation to rendering, using the backend's actual terminal dimensions rather than hardcoded values.render-nodewalks the tree. - Dirty propagation —
propagate-dirtymarks a component and all its ancestors for re-render. This is what makes the incremental pipeline efficient: only changed branches get re-processed.
Contract
component-layout-node component → layout-node or nil
Return the layout node associated with component. Specialized per
component type (box, text).
component-children component → list or nil
Return child components. Default method returns nil (leaf components).
component-parent component → component or nil
Return the parent component. Default method returns nil.
render component backend
Render component at its computed position using backend. Default
method is a no-op. Specialized per component type.
render-screen root backend
Full render pipeline: query backend size, compute layout, render tree,
wrapped in DECICM sync (begin-sync~/~end-sync).
render-node node backend
Render node and all descendants recursively. render-screen calls
this once layout is computed.
available-width / available-height component → integer
Return the computed width/height from the component's layout node, or 80/24 as fallback.
propagate-dirty component
Mark component and every ancestor dirty. Walks up via
component-parent.
Tests
Test helper: make-capturing-backend
Before any render test can run, we need a backend that captures output
to a string stream instead of writing to the real terminal. This helper
creates a modern-backend with a string-output-stream and returns
both, so tests can inspect what was rendered.
(in-package :cl-tty-box-test)
(in-suite box-suite)
(defun make-capturing-backend ()
(let* ((s (make-string-output-stream))
(b (make-modern-backend :output-stream s)))
(values b s)))
Test: render dispatches to box method
Verifies that calling render on a box instance invokes the box
rendering path, which draws border characters (e.g. ┌). This confirms
generic dispatch works for the box type and that the border rendering
pipeline is intact. A regression here would mean render-box is not
being called or produces no output.
(test render-generic-dispatches-box
"render dispatches to render-box for box instances"
(multiple-value-bind (b s) (make-capturing-backend)
(let ((bx (make-box :border-style :single :width 10 :height 5)))
(compute-layout (box-layout-node bx) 10 5)
(render bx b)
(is (search "┌" (get-output-stream-string s)) "box renders border"))))
Test: render dispatches to text method
Verifies that calling render on a text instance invokes the text
rendering path, which outputs the string content. This confirms generic
dispatch works for the text type and that text content is correctly
emitted to the backend. A regression would mean render-text is not
being called.
(test render-generic-dispatches-text
"render dispatches to render-text for text instances"
(multiple-value-bind (b s) (make-capturing-backend)
(let ((tx (make-text "Hello" :width 10 :height 1)))
(compute-layout (text-layout-node tx) 10 1)
(render tx b)
(is (search "Hello" (get-output-stream-string s)) "text renders content"))))
Test: component-layout-node returns layout-node
The component-layout-node generic is the bridge between the component
layer and the layout layer. Every renderable component must have an
associated layout node. This test confirms that both box and text
return a layout-node instance from their component-layout-node
method. A failure here means a component type is missing its method or
the slot accessor is wrong.
(test component-layout-node-works
"component-layout-node returns the right slot for each type"
(let ((bx (make-box)) (tx (make-text "")))
(is (typep (component-layout-node bx) 'layout-node))
(is (typep (component-layout-node tx) 'layout-node))))
Test: component-children returns nil for leaves
Leaf components (box, text) have no children by definition. The
default method on t returns nil. This test ensures that neither box
nor text accidentally inherits or defines a method that returns
non-nil, which would break the tree-walk in render-node by causing
infinite recursion or rendering phantom children.
(test component-children-returns-nil
"Leaf components have no children"
(let ((bx (make-box)) (tx (make-text "")))
(is (null (component-children bx)))
(is (null (component-children tx)))))
Test: propagate-dirty marks component dirty
propagate-dirty is the entry point for the incremental rendering
pipeline. When a component changes (e.g. a keystroke in a text input),
it calls propagate-dirty to ensure the frame is re-rendered. This
test verifies that calling propagate-dirty on a clean component sets
it dirty. Without this, components that mutate would never trigger a
re-render and the display would become stale.
(test propagate-dirty-marks-component
"propagate-dirty marks the component dirty"
(let ((c (make-box)))
(mark-clean c)
(is-false (dirty-p c) "should be clean after mark-clean")
(propagate-dirty c)
(is-true (dirty-p c) "should be dirty after propagate-dirty")))
Test: available-width defaults
available-width reads the computed width from the component's layout
node. When a component hasn't been laid out (no explicit width set),
the layout node's width defaults to 0. This test verifies that
available-width returns 0 for a freshly created box without layout
computation. This matters because container components use
available-width to position children — getting a sensible default
prevents division-by-zero or garbled layouts during initialization.
(test available-width-defaults
"available-width returns 0 for components without explicit width"
(let ((c (make-box)))
(is (= (available-width c) 0))))
Implementation
Component protocol
These three generic functions form the tree navigation API. They're
separated from render because layout and dirty propagation also
need to traverse the tree.
component-layout-node
The component-layout-node generic returns the layout-node instance
for a given component. Every component that participates in layout and
rendering must have a layout node — it stores the computed position and
size after layout passes. The generic is defined with two specific
methods for the built-in component types.
(in-package :cl-tty.box)
;; ── Component Protocol ────────────────────────────────────────
(defgeneric component-layout-node (component)
(:documentation "Return the layout-node for COMPONENT."))
Each component type returns its internal layout node slot. This method
specializes on box and returns the box-layout-node slot value.
(defmethod component-layout-node ((bx box))
(box-layout-node bx))
The text component stores its layout node in the text-layout-node
slot. Both methods return the same type (layout-node), so the layout
engine can operate uniformly regardless of component type.
(defmethod component-layout-node ((tx text))
(text-layout-node tx))
component-children
Leaf components (box, text) have no children. Container components
(scrollbox, tabbar) override this to return their child list. The
default method on t returns nil, so new component types are
automatically treated as leaves unless they explicitly override.
(defgeneric component-children (component)
(:documentation "Return the children of COMPONENT, or nil.")
(:method ((c t)) nil))
component-parent
Parent links are set by the container when adding children. They're
used by propagate-dirty to walk up the tree. The default method on
t returns nil, which acts as the termination condition for the
recursive dirty walk — when component-parent returns nil, we've
reached the root.
(defgeneric component-parent (component)
(:documentation "Return the parent of COMPONENT, or nil.")
(:method ((c t)) nil))
Render dispatch
render generic
The render generic is the central dispatch point for the rendering
pipeline. Every component type that can be drawn defines a method on
render. The default method on t is a no-op so that non-renderable
objects (or components still under development) don't cause errors
when the tree walk reaches them.
;; ── Rendering Pipeline ────────────────────────────────────────
(defgeneric render (component backend)
(:documentation "Render COMPONENT at its computed position using BACKEND.")
(:method ((c t) backend)
(declare (ignore backend))
(values)))
render method for box
Boxes are rendered with border characters. The render method
delegates to the render-box function defined in box.lisp, which
handles the actual drawing of border lines and corners.
(defmethod render ((bx box) backend)
(render-box bx backend))
render method for text
Text components render their content string at the computed position.
The render method delegates to render-text from text.lisp, which
writes the string with appropriate escape sequences for positioning.
(defmethod render ((tx text) backend)
(render-text tx backend))
Screen-level orchestration
render-screen
render-screen is the entry point for rendering a full frame. It
queries the terminal size at render time (not at startup), so the
layout adapts to window resizes automatically. The DECICM sync pair
(begin-sync~/~end-sync) wraps the entire frame in a synchronized
update: the terminal buffers all escape sequences and flushes them
atomically, preventing partial-frame flicker.
The pipeline is: (1) query backend pixel/dimension size, (2) begin sync, (3) compute layout at the root, (4) walk the tree rendering each node, (5) end sync.
(defun render-screen (root backend)
"Render the component tree ROOT using BACKEND.
Computes layout at the root level, then traverses children
rendering each at their pre-computed positions. Uses the actual
terminal dimensions from BACKEND rather than hardcoded defaults."
(multiple-value-bind (w h) (backend-size backend)
(begin-sync backend)
(compute-layout (component-layout-node root) w h)
(render-node root backend)
(end-sync backend)))
render-node
Tree walk: render this node, then recurse into children. The layout was
already computed by render-screen, so each node's position and size
are available from its layout-node. The recursion is depth-first:
parents are drawn before children, which matters for z-ordering (the
parent's background is drawn first, children overlay on top).
(defun render-node (node backend)
"Render a component NODE and its children.
Layout is computed once at the root by render-screen, so children
just render at their pre-computed positions."
(render node backend)
(dolist (child (component-children node))
(render-node child backend)))
Utility accessors
available-width
Returns the computed width from the component's layout node. The layout
node's width is set by compute-layout during render-screen, so this
reflects the actual allocated space — not the requested width. The
fallback of 80 matches the default terminal width when no layout node
exists (during initialization or testing without a backend).
(defun available-width (component)
"Return the available width for COMPONENT (or 80 as default)."
(let ((ln (component-layout-node component)))
(if ln (layout-node-width ln) 80)))
available-height
Returns the computed height from the component's layout node. Like
available-width, this reflects post-layout allocated space. The
fallback of 24 matches the default terminal height. These accessors
provide a clean API for components that need to know their allocated
space during rendering, avoiding direct access to layout nodes.
(defun available-height (component)
"Return the available height for COMPONENT (or 24 as default)."
(let ((ln (component-layout-node component)))
(if ln (layout-node-height ln) 24)))
Dirty propagation
propagate-dirty
Recursive walk up the parent chain. When a text input receives a keystroke, it marks itself dirty, then its parent scrollbox, then the containing box, then the root — triggering recomputation and re-rendering of everything that might have changed.
This is the key to incremental rendering: only dirty branches are
re-processed. The render methods check dirty-p early and return
immediately for clean components (handled in each component's render,
not here). The recursion terminates when component-parent returns
nil (the root component has no parent).
;; ── Dirty Propagation ─────────────────────────────────────────
(defun propagate-dirty (component)
"Mark COMPONENT and all ancestors dirty."
(mark-dirty component)
(let ((parent (component-parent component)))
(when parent
(propagate-dirty parent))))