404 lines
15 KiB
Org Mode
404 lines
15 KiB
Org Mode
#+TITLE: Render Dispatch and Pipeline
|
|
#+STARTUP: content
|
|
#+FILETAGS: :cl-tty:components:
|
|
|
|
* 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 pipeline** — ~render-screen~ ties layout computation to
|
|
rendering, using the backend's actual terminal dimensions rather
|
|
than hardcoded values. ~render-node~ walks the tree.
|
|
|
|
3. **Dirty propagation** — ~propagate-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.
|
|
|
|
#+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/src/components/render-tests.lisp
|
|
(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)))
|
|
#+END_SRC
|
|
|
|
** 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.
|
|
|
|
#+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/src/components/render-tests.lisp
|
|
(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"))))
|
|
#+END_SRC
|
|
|
|
** 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.
|
|
|
|
#+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/src/components/render-tests.lisp
|
|
(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"))))
|
|
#+END_SRC
|
|
|
|
** 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.
|
|
|
|
#+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/src/components/render-tests.lisp
|
|
(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))))
|
|
#+END_SRC
|
|
|
|
** 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.
|
|
|
|
#+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/src/components/render-tests.lisp
|
|
(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)))))
|
|
#+END_SRC
|
|
|
|
** 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.
|
|
|
|
#+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/src/components/render-tests.lisp
|
|
(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")))
|
|
#+END_SRC
|
|
|
|
** 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.
|
|
|
|
#+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/src/components/render-tests.lisp
|
|
(test available-width-defaults
|
|
"available-width returns 0 for components without explicit width"
|
|
(let ((c (make-box)))
|
|
(is (= (available-width c) 0))))
|
|
#+END_SRC
|
|
|
|
* 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.
|
|
|
|
#+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/src/components/render.lisp
|
|
(in-package :cl-tty.box)
|
|
|
|
;; ── Component Protocol ────────────────────────────────────────
|
|
|
|
(defgeneric component-layout-node (component)
|
|
(:documentation "Return the layout-node for COMPONENT."))
|
|
#+END_SRC
|
|
|
|
Each component type returns its internal layout node slot. This method
|
|
specializes on ~box~ and returns the ~box-layout-node~ slot value.
|
|
|
|
#+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/src/components/render.lisp
|
|
(defmethod component-layout-node ((bx box))
|
|
(box-layout-node bx))
|
|
#+END_SRC
|
|
|
|
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.
|
|
|
|
#+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/src/components/render.lisp
|
|
(defmethod component-layout-node ((tx text))
|
|
(text-layout-node tx))
|
|
#+END_SRC
|
|
|
|
*** 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.
|
|
|
|
#+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/src/components/render.lisp
|
|
(defgeneric component-children (component)
|
|
(:documentation "Return the children of COMPONENT, or nil.")
|
|
(:method ((c t)) nil))
|
|
#+END_SRC
|
|
|
|
*** 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.
|
|
|
|
#+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/src/components/render.lisp
|
|
(defgeneric component-parent (component)
|
|
(:documentation "Return the parent of COMPONENT, or nil.")
|
|
(:method ((c t)) nil))
|
|
#+END_SRC
|
|
|
|
** 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.
|
|
|
|
#+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/src/components/render.lisp
|
|
;; ── Rendering Pipeline ────────────────────────────────────────
|
|
|
|
(defgeneric render (component backend)
|
|
(:documentation "Render COMPONENT at its computed position using BACKEND.")
|
|
(:method ((c t) backend)
|
|
(declare (ignore backend))
|
|
(values)))
|
|
#+END_SRC
|
|
|
|
*** 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.
|
|
|
|
#+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/src/components/render.lisp
|
|
(defmethod render ((bx box) backend)
|
|
(render-box bx backend))
|
|
#+END_SRC
|
|
|
|
*** 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.
|
|
|
|
#+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/src/components/render.lisp
|
|
(defmethod render ((tx text) backend)
|
|
(render-text tx backend))
|
|
#+END_SRC
|
|
|
|
** 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.
|
|
|
|
#+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/src/components/render.lisp
|
|
(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)))
|
|
#+END_SRC
|
|
|
|
*** 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).
|
|
|
|
#+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/src/components/render.lisp
|
|
(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)))
|
|
#+END_SRC
|
|
|
|
** 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).
|
|
|
|
#+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/src/components/render.lisp
|
|
(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)))
|
|
#+END_SRC
|
|
|
|
*** 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.
|
|
|
|
#+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/src/components/render.lisp
|
|
(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)))
|
|
#+END_SRC
|
|
|
|
** 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).
|
|
|
|
#+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/src/components/render.lisp
|
|
;; ── 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))))
|
|
#+END_SRC
|