Files
cl-tty/docs/plans/2026-05-11-rendering-pipeline.md
Hermes ddd3950e49 v0.13.0: Rendering pipeline with framebuffer backend
New module: src/rendering/framebuffer.lisp (tangled from org/framebuffer.org)

- framebuffer-backend class: implements backend protocol by writing to
  2D cell array instead of emitting escape sequences
- cell struct: per-cell state (char, fg, bg, bold, italic, underline, link-url)
- make-framebuffer / framebuffer-width / framebuffer-height
- draw-text, draw-rect, draw-border, draw-link, draw-ellipsis methods
- diff-framebuffers: compares two framebuffers, returns changed cells
- flush-framebuffer: diff + output changes to real backend
- with-scissor macro: clip drawing operations to rectangle
- cursor-move: added default no-op method for all backends
- 20 new tests, all passing (372 total)

Version bumped from 0.11.0 to 0.13.0.
License field set to GPL-3.0 in ASDF.
2026-05-11 22:34:58 +00:00

7.9 KiB

Rendering Pipeline — Implementation Plan

For Hermes: Implement this plan task-by-task.

Goal: Add a framebuffer-based rendering pipeline that sits between the component tree and the backend. Eliminates flicker via incremental diff output. Enables future features (mouse text selection, click-to-open-link).

Architecture: A framebuffer-backend class that implements the backend protocol by writing to a cell array instead of emitting escape sequences. After all components render, a diff function compares the current framebuffer to the previous one and flushes only changed cells to a real backend.

Tech Stack: Pure CL, CLOS protocol (inherits the existing backend protocol).


Task 1: Create framebuffer.org

Objective: Write the literate source file with design, contract, tests, and implementation.

Files:

  • Create: org/framebuffer.org

Structure:

#+TITLE: Rendering Pipeline (v0.13.0)

* Overview
  - Why framebuffer: flicker-free, incremental output, enables selection
  - Architecture: framebuffer-backend → diff → flush

** Contract
  - cell struct — char, fg, bg, bold, italic, underline, link-url
  - make-framebuffer (width height) → 2D array of cells
  - framebuffer-backend class — backend subclass that writes to cell array
  - render-to-framebuffer (backend fb) → writes backend commands to fb
  - diff-framebuffers (prev curr) → list of changed (x y cell) triples
  - flush-framebuffer (prev curr real-backend) → diff + output
  - with-scissor (fb x y w h) &body body — clip drawing to rect

** Tests (tangle to tests/...)

** Implementation
  - cell struct
  - framebuffer-backend class (inherits backend)
  - draw-text, draw-rect, draw-border etc on framebuffer-backend
  - diff-framebuffers
  - flush-framebuffer
  - with-scissor macro

Task 2: Implement cell struct and framebuffer

Files:

  • Create: src/rendering/framebuffer.lisp

Code:

(defpackage :cl-tty.rendering
  (:use :cl :cl-tty.backend)
  (:export
   #:cell #:make-cell #:cell-char #:cell-fg #:cell-bg
   #:cell-bold #:cell-italic #:cell-underline #:cell-link-url
   #:framebuffer-backend #:make-framebuffer-backend
   #:make-framebuffer #:framebuffer-cells
   #:framebuffer-width #:framebuffer-height
   #:diff-framebuffers #:flush-framebuffer
   #:with-scissor))

(in-package :cl-tty.rendering)

(defstruct cell
  (char #\space :type character)
  (fg nil)
  (bg nil)
  (bold nil :type boolean)
  (italic nil :type boolean)
  (underline nil :type boolean)
  (link-url nil))

(defclass framebuffer-backend (backend)
  ((framebuffer :initform nil :accessor fb-framebuffer)
   (scissor-x :initform 0 :accessor fb-scissor-x)
   (scissor-y :initform 0 :accessor fb-scissor-y)
   (scissor-w :initform nil :accessor fb-scissor-w)
   (scissor-h :initform nil :accessor fb-scissor-h)))

(defun make-framebuffer (width height)
  (make-array (list height width)
              :initial-element (make-cell)
              :element-type 'cell))

(defun make-framebuffer-backend (&key (width 80) (height 24))
  (make-instance 'framebuffer-backend
    :framebuffer (make-framebuffer width height)))

(defun framebuffer-width (fb)
  (if (arrayp fb) (array-dimension fb 1) 0))

(defun framebuffer-height (fb)
  (if (arrayp fb) (array-dimension fb 0) 0))

TDD: Write tests that:

  • Create a framebuffer of specific dimensions
  • Verify cell defaults
  • Create framebuffer-backend and verify it has a framebuffer

Task 3: Implement framebuffer draw methods

Objective: Implement the backend protocol on framebuffer-backend.

Files:

  • Modify: src/rendering/framebuffer.lisp

Key method — draw-text:

(defmethod draw-text ((fb framebuffer-backend) x y string fg bg &rest attrs)
  (let ((cells (fb-framebuffer fb))
        (sx (fb-scissor-x fb)) (sy (fb-scissor-y fb))
        (sw (fb-scissor-w fb)) (sh (fb-scissor-h fb)))
    (loop for i from 0 below (length string)
          for cx = (+ x i)
          for cy = y
          when (and (or (null sw) (and (>= cx sx) (< cx (+ sx sw))))
                    (or (null sh) (and (>= cy sy) (< cy (+ sy sh))))
                    (< cy (framebuffer-height cells))
                    (< cx (framebuffer-width cells)))
          do (setf (aref cells cy cx)
                   (make-cell :char (char string i)
                              :fg fg :bg bg
                              :bold (getf attrs :bold)
                              :italic (getf attrs :italic)
                              :underline (getf attrs :underline)
                              :link-url (getf attrs :link-url))))))

Similar methods for draw-rect, draw-border, backend-clear.


Task 4: Implement diff and flush

Files:

  • Modify: src/rendering/framebuffer.lisp

diff-framebuffers:

(defun diff-framebuffers (prev curr)
  "Return list of (x y cell) triples for changed cells."
  (let ((changes nil)
        (h (min (framebuffer-height prev) (framebuffer-height curr)))
        (w (min (framebuffer-width prev) (framebuffer-width curr))))
    (dotimes (y h)
      (dotimes (x w)
        (let ((a (aref prev y x)) (b (aref curr y x)))
          (unless (and (eql (cell-char a) (cell-char b))
                       (eql (cell-fg a) (cell-fg b))
                       (eql (cell-bg a) (cell-bg b))
                       (eql (cell-bold a) (cell-bold b))
                       (eql (cell-italic a) (cell-italic b))
                       (eql (cell-underline a) (cell-underline b))
                       (equal (cell-link-url a) (cell-link-url b)))
            (push (list x y b) changes)))))
    (nreverse changes)))

flush-framebuffer:

(defun flush-framebuffer (prev-fb curr-fb backend)
  "Diff prev and curr, flush changes to BACKEND.
Returns count of changed cells."
  (let ((changes (diff-framebuffers prev-fb curr-fb))
        (current-row -1))
    (dolist (change changes)
      (destructuring-bind (x y cell) change
        (unless (= y current-row)
          (cursor-move backend x y)
          (setf current-row y))
        (draw-text backend x y (string (cell-char cell))
                   (cell-fg cell) (cell-bg cell)
                   :bold (cell-bold cell)
                   :italic (cell-italic cell)
                   :underline (cell-underline cell))))
    (length changes)))

Task 5: Implement with-scissor

(defmacro with-scissor ((fb x y w h) &body body)
  "Clip all drawing operations to the rectangle (x y w h)."
  (let ((old-x (gensym)) (old-y (gensym))
        (old-w (gensym)) (old-h (gensym)))
    `(let ((,old-x (fb-scissor-x ,fb))
           (,old-y (fb-scissor-y ,fb))
           (,old-w (fb-scissor-w ,fb))
           (,old-h (fb-scissor-h ,fb)))
       (setf (fb-scissor-x ,fb) ,x
             (fb-scissor-y ,fb) ,y
             (fb-scissor-w ,fb) ,w
             (fb-scissor-h ,fb) ,h)
       (unwind-protect (progn ,@body)
         (setf (fb-scissor-x ,fb) ,old-x
               (fb-scissor-y ,fb) ,old-y
               (fb-scissor-w ,fb) ,old-w
               (fb-scissor-h ,fb) ,old-h)))))

Task 6: Wire into ASDF

Files:

  • Create: src/rendering/ directory
  • Modify: cl-tty.asd

Add rendering module to ASDF:

(:module "src/rendering"
  :components
  ((:file "framebuffer")))

Task 7: Write tests

Files:

  • Create: tests/framebuffer-tests.lisp

Tests to write:

  1. make-framebuffer-creates-correct-size — verify dimensions
  2. cell-defaults-are-space — default cell has #\space char
  3. draw-text-on-fb-sets-cells — verify text lands in right cells
  4. draw-text-clips-at-bounds — text beyond width is ignored
  5. diff-identical-fbs-returns-empty — no changes detected
  6. diff-changed-fb-returns-changes — changed cells detected
  7. with-scissor-clips-drawing — drawing outside scissor is ignored
  8. flush-fb-copies-to-backend — verify flush outputs to a simple-backend

Task 8: Tangle, test, commit

  1. Tangle all org files
  2. Run full test suite (verify ~368 tests pass)
  3. Commit with message