Files
cl-tty/org/render.org

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:

  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

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))))