Files
cl-tty/org/render.org
Hermes Agent ce7e9fbab0 literate: create org/render.org, org/theme.org, org/package.org
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)
2026-05-12 17:05:47 +00:00

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:

  1. Component protocol — generic functions for navigating the component tree (component-children, component-parent, component-layout-node)
  2. Render pipelinerender-screen ties layout computation to rendering, using the backend's actual terminal dimensions rather than hardcoded values. render-node walks the tree.
  3. Dirty propagationpropagate-dirty marks 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).