Follows the literate programming workflow: Overview → Contract → Tests → Implement → Tangle → Test (GREEN) render.org covers render.lisp + render-tests.lisp (component protocol, render dispatch, dirty propagation) theme.org covers theme.lisp + theme-tests.lisp (theme class, presets, color resolution) package.org covers package.lisp (cl-tty.box defpackage)
9.5 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
(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-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-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-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
"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
"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 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.
(in-package :cl-tty.box)
;; ── Component Protocol ────────────────────────────────────────
(defgeneric component-layout-node (component)
(:documentation "Return the layout-node for COMPONENT.")
(:method ((bx box)) (box-layout-node bx))
(:method ((tx text)) (text-layout-node tx)))
Each component type defines its own component-layout-node method
that returns its internal layout node. The default method (on t)
would return nil, but since every component in cl-tty has a layout
node, we don't provide one — new component types must add their own
method.
(defgeneric component-children (component)
(:documentation "Return the children of COMPONENT, or nil.")
(:method ((c t)) nil))
Leaf components (box, text) have no children. Container components
(scrollbox, tabbar) override this to return their child list.
(defgeneric component-parent (component)
(:documentation "Return the parent of COMPONENT, or nil.")
(:method ((c t)) nil))
Parent links are set by the container when adding children. They're
used by propagate-dirty to walk up the tree.
Render dispatch
;; ── Rendering Pipeline ────────────────────────────────────────
(defgeneric render (component backend)
(:documentation "Render COMPONENT at its computed position using BACKEND.")
(:method ((c t) backend)
(declare (ignore backend))
(values)))
The render generic is the central dispatch point. Every component
type that can be drawn defines a method on render. The default
method is a no-op so that non-renderable objects (or components still
under development) don't cause errors.
(defmethod render ((bx box) backend)
(render-box bx backend))
(defmethod render ((tx text) backend)
(render-text tx backend))
Box and text are the two built-in renderable types. Their render
methods delegate to the specific rendering functions defined in
box.lisp and text.lisp.
Screen-level orchestration
(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-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. This prevents partial-frame
flicker.
(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)))
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.
Utility accessors
(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)))
(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)))
These accessors provide a clean API for components that need to know
their allocated space. They return the computed dimensions from the
layout node, which was set by compute-layout during render-screen.
The fallback values (80x24) match the terminal default when no layout node exists — typically during initialization or testing without a backenπd.
Dirty propagation
;; ── 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))))
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).