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.
This commit is contained in:
253
docs/plans/2026-05-11-rendering-pipeline.md
Normal file
253
docs/plans/2026-05-11-rendering-pipeline.md
Normal file
@@ -0,0 +1,253 @@
|
||||
# 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:**
|
||||
|
||||
```lisp
|
||||
(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:**
|
||||
|
||||
```lisp
|
||||
(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:**
|
||||
```lisp
|
||||
(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:**
|
||||
```lisp
|
||||
(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
|
||||
|
||||
```lisp
|
||||
(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:
|
||||
```lisp
|
||||
(: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
|
||||
Reference in New Issue
Block a user