854 lines
32 KiB
Org Mode
854 lines
32 KiB
Org Mode
#+TITLE: Rendering Pipeline — Framebuffer (v0.13.0)
|
|
#+DATE: 2026-05-11
|
|
#+AUTHOR: Amr Gharbeia / Hermes
|
|
#+STARTUP: content
|
|
|
|
* Overview
|
|
|
|
A framebuffer-based rendering pipeline that sits between the component tree
|
|
and the backend protocol. Eliminates flicker by computing a full frame then
|
|
diffing against the previous frame before flushing.
|
|
|
|
The ~framebuffer-backend~ class implements the backend protocol by writing to a
|
|
2D cell array instead of emitting escape sequences. After all components render,
|
|
the diff engine compares current and previous frames and flushes only changed
|
|
cells to a real backend.
|
|
|
|
Benefits:
|
|
- Flicker-free output (only changed cells are sent)
|
|
- Enables text selection (each cell knows its content)
|
|
- Enables click-to-open-link (each cell knows its URL)
|
|
- Scissor clipping for nested containers
|
|
|
|
** Contract**
|
|
|
|
- ~cell~ — immutable struct with char, fg, bg, bold, italic, underline, link-url
|
|
- ~make-framebuffer width height~ → 2D array of ~cell~
|
|
- ~framebuffer-backend~ — subclass of ~backend~ that renders to cell array
|
|
- ~make-framebuffer-backend &key width height~ → framebuffer-backend
|
|
- ~diff-framebuffers prev curr~ → list of (x y cell) for changed cells
|
|
- ~flush-framebuffer prev-fb curr-fb backend~ → writes changes, returns count
|
|
- ~with-scissor (fb x y w h) &body body~ — clip drawing to rectangle
|
|
|
|
** Plan
|
|
|
|
See =docs/plans/2026-05-11-rendering-pipeline.md= for full implementation plan.
|
|
|
|
1. Create org file with code blocks
|
|
2. Tangle → framebuffer.lisp
|
|
3. Add to ASDF
|
|
4. Write tests
|
|
5. Run, commit
|
|
|
|
* Tests (reference documentation, not tangled)
|
|
|
|
#+BEGIN_SRC lisp :tangle no
|
|
;; Tests for framebuffer pipeline — manually added to tests/framebuffer-tests.lisp
|
|
#+END_SRC
|
|
|
|
** Test package and suite setup
|
|
|
|
Setting up the test package with FiveAM, importing the rendering and backend
|
|
packages for use in all subsequent tests.
|
|
|
|
#+BEGIN_SRC lisp :tangle no
|
|
(defpackage :cl-tty-framebuffer-test
|
|
(:use :cl :fiveam :cl-tty.rendering :cl-tty.backend))
|
|
(in-package :cl-tty-framebuffer-test)
|
|
|
|
(def-suite framebuffer-suite :description "Framebuffer rendering pipeline tests")
|
|
(in-suite framebuffer-suite)
|
|
#+END_SRC
|
|
|
|
** Test: make-framebuffer creates correct size
|
|
|
|
Verify that the framebuffer constructor produces an array with the expected
|
|
dimensions. Height should match the first dimension (rows), width the second
|
|
dimension (columns).
|
|
|
|
#+BEGIN_SRC lisp :tangle no
|
|
(test make-framebuffer-creates-correct-size
|
|
(let ((fb (make-framebuffer 80 24)))
|
|
(is (= 24 (framebuffer-height fb)))
|
|
(is (= 80 (framebuffer-width fb)))))
|
|
#+END_SRC
|
|
|
|
** Test: cell defaults are space
|
|
|
|
Cells created via MAKE-CELL with no arguments should default to a space
|
|
character with nil foreground and background — a blank, unstyled cell.
|
|
|
|
#+BEGIN_SRC lisp :tangle no
|
|
(test cell-defaults-are-space
|
|
(let ((cell (aref (make-framebuffer 10 10) 0 0)))
|
|
(is (eql #\space (cell-char cell)))
|
|
(is (null (cell-fg cell)))
|
|
(is (null (cell-bg cell)))))
|
|
#+END_SRC
|
|
|
|
** Test: draw-text on framebuffer sets cells
|
|
|
|
Drawing a string into the framebuffer backend should set the character and
|
|
foreground color at each cell position. Characters should appear at the expected
|
|
(x, y) offsets.
|
|
|
|
#+BEGIN_SRC lisp :tangle no
|
|
(test draw-text-on-fb-sets-cells
|
|
(let ((fb (make-framebuffer-backend)))
|
|
(draw-text fb 2 3 "abc" :red nil)
|
|
(let ((cells (fb-framebuffer fb)))
|
|
(is (eql #\a (cell-char (aref cells 3 2))))
|
|
(is (eql #\b (cell-char (aref cells 3 3))))
|
|
(is (eql #\c (cell-char (aref cells 3 4))))
|
|
(is (eql :red (cell-fg (aref cells 3 2)))))))
|
|
#+END_SRC
|
|
|
|
** Test: draw-text clips at bounds
|
|
|
|
When drawing text that extends past the right edge of the framebuffer, cells
|
|
beyond the width should remain unchanged (space characters). This prevents
|
|
buffer overflow and undefined memory access.
|
|
|
|
#+BEGIN_SRC lisp :tangle no
|
|
(test draw-text-clips-at-bounds
|
|
(let ((fb (make-framebuffer-backend :width 10 :height 5)))
|
|
(draw-text fb 8 2 "hello" nil nil)
|
|
(let ((cells (fb-framebuffer fb)))
|
|
(is (eql #\h (cell-char (aref cells 2 8))))
|
|
(is (eql #\e (cell-char (aref cells 2 9))))
|
|
(is (eql #\space (cell-char (aref cells 2 0))) "out of bounds text is ignored"))))
|
|
#+END_SRC
|
|
|
|
** Test: diff of identical framebuffers returns empty
|
|
|
|
Two framebuffers with identical cells should produce no changes. The diff
|
|
engine must short-circuit when no cells differ.
|
|
|
|
#+BEGIN_SRC lisp :tangle no
|
|
(test diff-identical-fbs-returns-empty
|
|
(let ((fb1 (make-framebuffer 80 24))
|
|
(fb2 (make-framebuffer 80 24)))
|
|
(is (null (diff-framebuffers fb1 fb2)))))
|
|
#+END_SRC
|
|
|
|
** Test: diff of changed framebuffer returns changes
|
|
|
|
After modifying a single cell in one framebuffer, the diff engine should return
|
|
exactly one change with the correct coordinates and cell data.
|
|
|
|
#+BEGIN_SRC lisp :tangle no
|
|
(test diff-changed-fb-returns-changes
|
|
(let* ((fb1 (make-framebuffer 10 10))
|
|
(fb2 (make-framebuffer 10 10)))
|
|
(setf (aref fb2 5 5) (make-cell :char #\X :fg :red))
|
|
(let ((changes (diff-framebuffers fb1 fb2)))
|
|
(is (= 1 (length changes)))
|
|
(destructuring-bind (x y cell) (first changes)
|
|
(is (= 5 x))
|
|
(is (= 5 y))
|
|
(is (eql #\X (cell-char cell)))))))
|
|
#+END_SRC
|
|
|
|
** Test: with-scissor clips drawing
|
|
|
|
When a scissor rectangle is active, drawing operations outside the rectangle
|
|
should be clipped away. Operations inside the rectangle should proceed normally.
|
|
|
|
#+BEGIN_SRC lisp :tangle no
|
|
(test with-scissor-clips-drawing
|
|
(let ((fb (make-framebuffer-backend :width 20 :height 10)))
|
|
(with-scissor (fb 5 5 3 3)
|
|
(draw-text fb 6 6 "ABC" nil nil)
|
|
(draw-text fb 1 1 "OUTSIDE" nil nil))
|
|
(let ((cells (fb-framebuffer fb)))
|
|
(is (eql #\A (cell-char (aref cells 6 6))) "inside scissor draws")
|
|
(is (eql #\space (cell-char (aref cells 1 1))) "outside scissor is clipped"))))
|
|
#+END_SRC
|
|
|
|
** Test: flush-fb copies to backend
|
|
|
|
After drawing on a framebuffer backend and flushing to a real backend, at least
|
|
one cell change should be detected and forwarded to the output backend.
|
|
|
|
#+BEGIN_SRC lisp :tangle no
|
|
(test flush-fb-copies-to-backend
|
|
(let* ((real-be (make-simple-backend :output-stream (make-string-output-stream)))
|
|
(fb (make-framebuffer-backend)))
|
|
(draw-text fb 0 0 "X" :red nil)
|
|
(let ((changed (flush-framebuffer (make-framebuffer 80 24) (fb-framebuffer fb) real-be)))
|
|
(is (>= changed 1)))))
|
|
#+END_SRC
|
|
|
|
* Implementation
|
|
|
|
** Package definition
|
|
|
|
The ~cl-tty.rendering~ package exports all public symbols: the ~cell~ struct,
|
|
framebuffer backend class, constructor, diff/flush utilities, scissor macro,
|
|
and frame-inspection functions. It depends on ~:cl-tty.backend~ for the
|
|
~backend~ base class and protocol methods.
|
|
|
|
#+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/src/rendering/framebuffer.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 #:fb-framebuffer
|
|
#:framebuffer-width #:framebuffer-height
|
|
#:diff-framebuffers #:flush-framebuffer
|
|
#:with-scissor
|
|
#:extract-text #:fb-cell-link-url))
|
|
#+END_SRC
|
|
|
|
** Package switch
|
|
|
|
Switch to the ~cl-tty.rendering~ package for all subsequent definitions.
|
|
|
|
#+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/src/rendering/framebuffer.lisp
|
|
(in-package :cl-tty.rendering)
|
|
#+END_SRC
|
|
|
|
** Cell — immutable per-cell state
|
|
|
|
The ~cell~ struct represents a single terminal cell. By making it a struct
|
|
(rather than a class) we get value semantics: copying is cheap and cells are
|
|
compared by value during diffing. All fields have sensible defaults so that
|
|
~make-cell~ with no arguments produces a blank space cell. The ~link-url~
|
|
slot enables OSC-8 hyperlink support for clickable text.
|
|
|
|
#+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/src/rendering/framebuffer.lisp
|
|
(defstruct cell
|
|
"A single terminal cell — character, colors, and attributes."
|
|
(char #\space :type character)
|
|
(fg nil)
|
|
(bg nil)
|
|
(bold nil :type boolean)
|
|
(italic nil :type boolean)
|
|
(underline nil :type boolean)
|
|
(link-url nil))
|
|
#+END_SRC
|
|
|
|
** Framebuffer — 2D array of cells
|
|
|
|
*** make-framebuffer
|
|
|
|
Create a two-dimensional array of ~cell~ structs with HEIGHT rows and WIDTH
|
|
columns. Using ~:initial-element (make-cell)~ ensures every cell is a fresh
|
|
struct instance (not shared). The ~:element-type~ declaration is a hint for
|
|
potential optimizations.
|
|
|
|
#+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/src/rendering/framebuffer.lisp
|
|
(defun make-framebuffer (width height)
|
|
"Create a 2D array of CELL with dimensions HEIGHT x WIDTH."
|
|
(make-array (list height width)
|
|
:initial-element (make-cell)
|
|
:element-type 'cell))
|
|
#+END_SRC
|
|
|
|
*** framebuffer-width, framebuffer-height
|
|
|
|
Accessors that return the dimensions of a framebuffer array. These guard
|
|
against non-array values (returning 0) so that callers don't crash on nil or
|
|
uninitialized framebuffer slots.
|
|
|
|
#+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/src/rendering/framebuffer.lisp
|
|
(defun framebuffer-width (fb)
|
|
"Return the width (columns) of framebuffer FB."
|
|
(if (arrayp fb) (array-dimension fb 1) 0))
|
|
#+END_SRC
|
|
|
|
#+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/src/rendering/framebuffer.lisp
|
|
(defun framebuffer-height (fb)
|
|
"Return the height (rows) of framebuffer FB."
|
|
(if (arrayp fb) (array-dimension fb 0) 0))
|
|
#+END_SRC
|
|
|
|
** Framebuffer Backend — implements backend protocol
|
|
|
|
*** framebuffer-backend class
|
|
|
|
The ~framebuffer-backend~ class subclasses ~backend~ and stores a 2D cell array
|
|
plus scissor-clipping state. All drawing methods on this backend write to the
|
|
cell array instead of emitting escape sequences. The scissor coordinates are
|
|
used by ~%in-scissor-p~ to clip drawing during component rendering.
|
|
|
|
#+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/src/rendering/framebuffer.lisp
|
|
(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)))
|
|
#+END_SRC
|
|
|
|
*** make-framebuffer-backend
|
|
|
|
Constructor that creates a ~framebuffer-backend~ instance and initializes its
|
|
framebuffer array to the given dimensions (defaulting to 80x24, a common
|
|
terminal size).
|
|
|
|
#+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/src/rendering/framebuffer.lisp
|
|
(defun make-framebuffer-backend (&key (width 80) (height 24))
|
|
"Create a framebuffer-backend with a fresh framebuffer."
|
|
(let ((fb (make-instance 'framebuffer-backend)))
|
|
(setf (fb-framebuffer fb) (make-framebuffer width height))
|
|
fb))
|
|
#+END_SRC
|
|
|
|
** Drawing helpers
|
|
|
|
*** %in-scissor-p
|
|
|
|
Predicate that checks whether a cell at (CX, CY) falls within the active
|
|
scissor rectangle. If either scissor dimension is nil (meaning no scissor is
|
|
set), the corresponding axis check is skipped, effectively treating the entire
|
|
framebuffer as the drawable area.
|
|
|
|
#+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/src/rendering/framebuffer.lisp
|
|
(defun %in-scissor-p (fb cx cy)
|
|
"Check if (CX, CY) falls within the current scissor rectangle."
|
|
(let ((sx (fb-scissor-x fb)) (sy (fb-scissor-y fb))
|
|
(sw (fb-scissor-w fb)) (sh (fb-scissor-h fb)))
|
|
(and (or (null sw) (and (>= cx sx) (< cx (+ sx sw))))
|
|
(or (null sh) (and (>= cy sy) (< cy (+ sy sh)))))))
|
|
#+END_SRC
|
|
|
|
*** %set-cell
|
|
|
|
Low-level cell-writer that performs bounds checking and scissor clipping before
|
|
assigning a new cell. This is the single choke-point where all drawing
|
|
ultimately lands, ensuring consistent clipping behavior across all drawing
|
|
operations. Only cells within both the framebuffer dimensions and the active
|
|
scissor rectangle are written.
|
|
|
|
#+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/src/rendering/framebuffer.lisp
|
|
(defun %set-cell (fb x y char &key fg bg bold italic underline link-url)
|
|
"Set cell (X, Y) if within bounds and scissor."
|
|
(let ((cells (fb-framebuffer fb)))
|
|
(when (and (>= y 0) (< y (framebuffer-height cells))
|
|
(>= x 0) (< x (framebuffer-width cells))
|
|
(%in-scissor-p fb x y))
|
|
(setf (aref cells y x)
|
|
(make-cell :char char :fg fg :bg bg
|
|
:bold bold :italic italic :underline underline
|
|
:link-url link-url)))))
|
|
#+END_SRC
|
|
|
|
** Drawing methods
|
|
|
|
*** draw-text
|
|
|
|
Render a string of characters starting at position (X, Y), one cell per
|
|
character. Each cell is set via ~%set-cell~ so bounds checking and scissor
|
|
clipping apply automatically. The ~&allow-other-keys~ permits passing
|
|
style-related keyword arguments that other backends may use but the framebuffer
|
|
does not need (e.g., reverse, dim, blink).
|
|
|
|
#+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/src/rendering/framebuffer.lisp
|
|
(defmethod draw-text ((fb framebuffer-backend) x y string fg bg
|
|
&key bold italic underline reverse dim blink
|
|
(link-url nil link-url-p)
|
|
&allow-other-keys)
|
|
(declare (ignore reverse dim blink link-url-p))
|
|
(loop for i from 0 below (length string)
|
|
do (%set-cell fb (+ x i) y (char string i)
|
|
:fg fg :bg bg
|
|
:bold bold :italic italic :underline underline
|
|
:link-url link-url)))
|
|
#+END_SRC
|
|
|
|
*** draw-text (raw array)
|
|
|
|
Direct rendering onto a raw 2D framebuffer array (the type returned by
|
|
~make-framebuffer~). This lets application code call ~draw-text~ directly on a
|
|
framebuffer without wrapping it in a ~framebuffer-backend~.
|
|
|
|
#+begin_src lisp :tangle ~/.local/share/cl-tty/src/rendering/framebuffer.lisp
|
|
(defmethod draw-text ((fb array) x y string fg bg
|
|
&key bold italic underline reverse dim blink
|
|
&allow-other-keys)
|
|
(declare (ignore reverse dim blink))
|
|
(let ((h (array-dimension fb 0))
|
|
(w (array-dimension fb 1)))
|
|
(loop for i from 0 below (length string)
|
|
for cx from x
|
|
while (< cx w)
|
|
when (and (< y h) (>= cx 0) (>= y 0))
|
|
do (setf (aref fb y cx)
|
|
(make-cell :char (char string i)
|
|
:fg fg :bg bg
|
|
:bold bold :italic italic :underline underline)))))
|
|
#+end_src
|
|
|
|
*** backend-clear (raw array)
|
|
|
|
Allow clearing a raw 2D framebuffer array directly (same type as returned by
|
|
~make-framebuffer~). Resets all cells to blank defaults.
|
|
|
|
#+begin_src lisp :tangle ~/.local/share/cl-tty/src/rendering/framebuffer.lisp
|
|
(defmethod backend-clear ((fb array))
|
|
(dotimes (y (array-dimension fb 0))
|
|
(dotimes (x (array-dimension fb 1))
|
|
(setf (aref fb y x) (make-cell)))))
|
|
#+end_src
|
|
|
|
*** draw-rect
|
|
|
|
Fill a rectangular region with space characters and an optional background
|
|
color. This is used for clearing areas and rendering background fills for
|
|
panels and widgets. Iterates row by row, column by column, using ~%set-cell~ so
|
|
scissor clipping is respected.
|
|
|
|
#+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/src/rendering/framebuffer.lisp
|
|
(defmethod draw-rect ((fb framebuffer-backend) x y w h &key bg)
|
|
(dotimes (row h)
|
|
(dotimes (col w)
|
|
(%set-cell fb (+ x col) (+ y row) #\space :fg nil :bg bg))))
|
|
#+END_SRC
|
|
|
|
*** draw-border
|
|
|
|
Draws a border around a rectangular region, optionally rendering a title
|
|
string at the top edge. Supports three border styles: :single, :double, and
|
|
:rounded, each using different corner and line characters. The title is drawn
|
|
starting two cells from the left edge, overwriting top-edge characters.
|
|
|
|
#+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/src/rendering/framebuffer.lisp
|
|
(defmethod draw-border ((fb framebuffer-backend) x y w h &key (style :single) title title-align fg bg)
|
|
(let* ((chars (case style
|
|
(:single '(#\+ #\- #\|))
|
|
(:double '(#\+ #\= #\|))
|
|
(:rounded '(#\. #\- #\|))
|
|
(t '(#\+ #\- #\|))))
|
|
(tc (first chars)) (hc (second chars)) (vc (third chars)))
|
|
;; Top edge
|
|
(%set-cell fb x y tc :fg fg :bg bg)
|
|
(loop for i from 1 below (1- w) do (%set-cell fb (+ x i) y hc :fg fg :bg bg))
|
|
(%set-cell fb (1- (+ x w)) y tc :fg fg :bg bg)
|
|
;; Sides
|
|
(dotimes (row (- h 2))
|
|
(%set-cell fb x (+ y row 1) vc :fg fg :bg bg)
|
|
(%set-cell fb (1- (+ x w)) (+ y row 1) vc :fg fg :bg bg))
|
|
;; Bottom edge
|
|
(%set-cell fb x (+ y h -1) tc :fg fg :bg bg)
|
|
(loop for i from 1 below (1- w) do (%set-cell fb (+ x i) (+ y h -1) hc :fg fg :bg bg))
|
|
(%set-cell fb (1- (+ x w)) (+ y h -1) tc :fg fg :bg bg)
|
|
;; Title
|
|
(when title
|
|
(loop for i from 0 below (length title)
|
|
do (%set-cell fb (+ x 2 i) y (char title i) :fg fg :bg bg)))))
|
|
#+END_SRC
|
|
|
|
*** backend-clear
|
|
|
|
Clears every cell in the framebuffer to a fresh default cell (space, no style).
|
|
This is the ~backend-clear~ protocol method specialized on ~framebuffer-backend~,
|
|
providing a full-frame reset used between render passes.
|
|
|
|
#+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/src/rendering/framebuffer.lisp
|
|
(defmethod backend-clear ((fb framebuffer-backend))
|
|
(let ((cells (fb-framebuffer fb)))
|
|
(dotimes (y (framebuffer-height cells))
|
|
(dotimes (x (framebuffer-width cells))
|
|
(setf (aref cells y x) (make-cell))))))
|
|
#+END_SRC
|
|
|
|
** Link and ellipsis methods
|
|
|
|
*** draw-link
|
|
|
|
Draws text with an associated OSC-8 hyperlink URL. The framebuffer backend
|
|
stores the URL in the cell's ~link-url~ slot for later retrieval (e.g., on
|
|
mouse click). The actual OSC-8 escape sequence rendering is deferred to the
|
|
real backend during flush.
|
|
|
|
#+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/src/rendering/framebuffer.lisp
|
|
(defmethod draw-link ((fb framebuffer-backend) x y string url &key fg bg)
|
|
;; OSC 8 links are not rendered in framebuffer — store as text
|
|
(draw-text fb x y string fg bg :link-url url))
|
|
#+END_SRC
|
|
|
|
*** draw-ellipsis
|
|
|
|
Renders a horizontal ellipsis (up to 3 periods) starting at position (X, Y).
|
|
Width is capped at 3 characters to prevent overflow into adjacent cells.
|
|
|
|
#+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/src/rendering/framebuffer.lisp
|
|
(defmethod draw-ellipsis ((fb framebuffer-backend) x y width &key fg bg)
|
|
(dotimes (i (min 3 width))
|
|
(%set-cell fb (+ x i) y #\. :fg fg :bg bg)))
|
|
#+END_SRC
|
|
|
|
** Diff engine
|
|
|
|
*** cells-equal-p
|
|
|
|
Compares two ~cell~ structs field by field to determine if they represent the
|
|
same visual output. Uses ~eql~ for characters, symbols, and booleans, and
|
|
~equal~ for string comparison of ~link-url~. This predicate drives the diff
|
|
algorithm — only cells that differ are flushed.
|
|
|
|
#+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/src/rendering/framebuffer.lisp
|
|
(defun cells-equal-p (a b)
|
|
"Return T if two cells have identical content and style."
|
|
(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))))
|
|
#+END_SRC
|
|
|
|
*** diff-framebuffers
|
|
|
|
The core difference algorithm: iterate over the overlapping region of two
|
|
framebuffers and collect a list of (X Y CELL) triples for every cell that
|
|
changed. Using ~nreverse~ at the end ensures stable ordering (top-to-bottom,
|
|
left-to-right) without consing during accumulation.
|
|
|
|
#+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/src/rendering/framebuffer.lisp
|
|
(defun diff-framebuffers (prev curr)
|
|
"Compare PREV and CURR framebuffers. Return list of (X Y CELL) for changes."
|
|
(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 (cells-equal-p a b)
|
|
(push (list x y b) changes)))))
|
|
(nreverse changes)))
|
|
#+END_SRC
|
|
|
|
** Flush
|
|
|
|
*** flush-framebuffer
|
|
|
|
Orchestrates the full diff-and-flush cycle. Computes the difference between
|
|
previous and current framebuffers, then replays changes to a real backend using
|
|
minimal cursor movement (tracking the current row to avoid redundant cursor
|
|
positioning). Returns the count of changed cells so callers can monitor
|
|
rendering overhead.
|
|
|
|
#+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/src/rendering/framebuffer.lisp
|
|
(defun flush-framebuffer (prev-fb curr-fb backend)
|
|
"Diff PREV-FB and CURR-FB and flush changes to BACKEND.
|
|
Returns the number of changed cells."
|
|
(let* ((changes (diff-framebuffers prev-fb curr-fb))
|
|
(count (length changes))
|
|
(current-row -1))
|
|
(when (plusp count)
|
|
(begin-sync backend)
|
|
(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))))
|
|
(end-sync backend))
|
|
count))
|
|
#+END_SRC
|
|
|
|
** Frame inspection (for mouse selection / link clicking)
|
|
|
|
*** fb-cell-link-url
|
|
|
|
Retrieves the hyperlink URL stored at cell position (X, Y) in a framebuffer
|
|
array. Returns nil if the cell is out of bounds or has no link. This enables
|
|
click-to-open-link functionality in the TUI.
|
|
|
|
#+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/src/rendering/framebuffer.lisp
|
|
(defun fb-cell-link-url (fb x y)
|
|
"Return the link URL at (X Y) in framebuffer FB, or nil."
|
|
(when (and (arrayp fb) (>= y 0) (< y (array-dimension fb 0))
|
|
(>= x 0) (< x (array-dimension fb 1)))
|
|
(let ((c (aref fb y x)))
|
|
(cell-link-url c))))
|
|
#+END_SRC
|
|
|
|
*** extract-text
|
|
|
|
Extracts visible text from a rectangular region of the framebuffer, useful for
|
|
mouse selection and clipboard operations. Normalizes coordinate order (so the
|
|
user can drag in any direction) and appends newlines between rows for natural
|
|
multi-line text.
|
|
|
|
#+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/src/rendering/framebuffer.lisp
|
|
(defun extract-text (fb x1 y1 x2 y2)
|
|
"Extract visible text from the rectangle between (X1,Y1) and (X2,Y2)."
|
|
(let ((x-min (max 0 (min x1 x2))) (x-max (max 0 (max x1 x2)))
|
|
(y-min (max 0 (min y1 y2))) (y-max (max 0 (max y1 y2)))
|
|
(h (if (arrayp fb) (array-dimension fb 0) 0))
|
|
(w (if (arrayp fb) (array-dimension fb 1) 0)))
|
|
(with-output-to-string (s)
|
|
(loop for y from y-min to (min y-max (1- h))
|
|
do (loop for x from x-min to (min x-max (1- w))
|
|
do (let ((c (aref fb y x)))
|
|
(princ (cell-char c) s)))
|
|
(when (< y y-max) (princ #\Newline s))))))
|
|
#+END_SRC
|
|
|
|
** Scissor clipping
|
|
|
|
*** with-scissor
|
|
|
|
A macro that temporarily sets the scissor rectangle on a framebuffer backend
|
|
for the duration of BODY. Saves and restores previous scissor state via
|
|
~unwind-protect~ for proper cleanup even on non-local exits. Using gensyms for
|
|
the state variables ensures no variable capture issues.
|
|
|
|
#+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/src/rendering/framebuffer.lisp
|
|
(defmacro with-scissor ((fb x y w h) &body body)
|
|
"Clip all drawing on FB to 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)))))
|
|
#+END_SRC
|
|
|
|
* Tests
|
|
|
|
** Test package and suite setup
|
|
|
|
Setting up the test package with FiveAM, importing the rendering and backend
|
|
packages for use in all subsequent tests. This block tangles to the test file
|
|
that is loaded by the test runner.
|
|
|
|
#+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/tests/framebuffer-tests.lisp
|
|
(defpackage :cl-tty-framebuffer-test
|
|
(:use :cl :fiveam :cl-tty.rendering :cl-tty.backend))
|
|
(in-package :cl-tty-framebuffer-test)
|
|
|
|
(def-suite framebuffer-suite :description "Framebuffer rendering pipeline tests")
|
|
(in-suite framebuffer-suite)
|
|
#+END_SRC
|
|
|
|
** Test: make-framebuffer creates correct size
|
|
|
|
Verify that the framebuffer constructor produces an array with the expected
|
|
dimensions. Height should match the first dimension (rows), width the second
|
|
dimension (columns).
|
|
|
|
#+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/tests/framebuffer-tests.lisp
|
|
(test make-framebuffer-creates-correct-size
|
|
(let ((fb (make-framebuffer 80 24)))
|
|
(is (= 24 (framebuffer-height fb)))
|
|
(is (= 80 (framebuffer-width fb)))))
|
|
#+END_SRC
|
|
|
|
** Test: cell defaults are space
|
|
|
|
Cells created via MAKE-CELL with no arguments should default to a space
|
|
character with nil foreground and background — a blank, unstyled cell.
|
|
|
|
#+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/tests/framebuffer-tests.lisp
|
|
(test cell-defaults-are-space
|
|
(let ((cell (aref (make-framebuffer 10 10) 0 0)))
|
|
(is (eql #\space (cell-char cell)))
|
|
(is (null (cell-fg cell)))
|
|
(is (null (cell-bg cell)))))
|
|
#+END_SRC
|
|
|
|
** Test: draw-text on framebuffer sets cells
|
|
|
|
Drawing a string into the framebuffer backend should set the character and
|
|
foreground color at each cell position. Characters should appear at the expected
|
|
(x, y) offsets.
|
|
|
|
#+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/tests/framebuffer-tests.lisp
|
|
(test draw-text-on-fb-sets-cells
|
|
(let ((fb (make-framebuffer-backend)))
|
|
(draw-text fb 2 3 "abc" :red nil)
|
|
(let ((cells (fb-framebuffer fb)))
|
|
(is (eql #\a (cell-char (aref cells 3 2))))
|
|
(is (eql #\b (cell-char (aref cells 3 3))))
|
|
(is (eql #\c (cell-char (aref cells 3 4))))
|
|
(is (eql :red (cell-fg (aref cells 3 2)))))))
|
|
#+END_SRC
|
|
|
|
** Test: draw-text clips at bounds
|
|
|
|
When drawing text that extends past the right edge of the framebuffer, cells
|
|
beyond the width should remain unchanged (space characters). This prevents
|
|
buffer overflow and undefined memory access.
|
|
|
|
#+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/tests/framebuffer-tests.lisp
|
|
(test draw-text-clips-at-bounds
|
|
(let ((fb (make-framebuffer-backend :width 10 :height 5)))
|
|
(draw-text fb 8 2 "hello" nil nil)
|
|
(let ((cells (fb-framebuffer fb)))
|
|
(is (eql #\h (cell-char (aref cells 2 8))))
|
|
(is (eql #\e (cell-char (aref cells 2 9))))
|
|
(is (eql #\space (cell-char (aref cells 2 0))) "out of bounds text is ignored"))))
|
|
#+END_SRC
|
|
|
|
** Test: diff of identical framebuffers returns empty
|
|
|
|
Two framebuffers with identical cells should produce no changes. The diff
|
|
engine must short-circuit when no cells differ.
|
|
|
|
#+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/tests/framebuffer-tests.lisp
|
|
(test diff-identical-fbs-returns-empty
|
|
(let ((fb1 (make-framebuffer 80 24))
|
|
(fb2 (make-framebuffer 80 24)))
|
|
(is (null (diff-framebuffers fb1 fb2)))))
|
|
#+END_SRC
|
|
|
|
** Test: diff of changed framebuffer returns changes
|
|
|
|
After modifying a single cell in one framebuffer, the diff engine should return
|
|
exactly one change with the correct coordinates and cell data.
|
|
|
|
#+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/tests/framebuffer-tests.lisp
|
|
(test diff-changed-fb-returns-changes
|
|
(let* ((fb1 (make-framebuffer 10 10))
|
|
(fb2 (make-framebuffer 10 10)))
|
|
(setf (aref fb2 5 5) (make-cell :char #\X :fg :red))
|
|
(let ((changes (diff-framebuffers fb1 fb2)))
|
|
(is (= 1 (length changes)))
|
|
(destructuring-bind (x y cell) (first changes)
|
|
(is (= 5 x))
|
|
(is (= 5 y))
|
|
(is (eql #\X (cell-char cell)))))))
|
|
#+END_SRC
|
|
|
|
** Test: with-scissor clips drawing
|
|
|
|
When a scissor rectangle is active, drawing operations outside the rectangle
|
|
should be clipped away. Operations inside the rectangle should proceed normally.
|
|
|
|
#+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/tests/framebuffer-tests.lisp
|
|
(test with-scissor-clips-drawing
|
|
(let ((fb (make-framebuffer-backend :width 20 :height 10)))
|
|
(with-scissor (fb 5 5 3 3)
|
|
(draw-text fb 6 6 "ABC" nil nil)
|
|
(draw-text fb 1 1 "OUTSIDE" nil nil))
|
|
(let ((cells (fb-framebuffer fb)))
|
|
(is (eql #\A (cell-char (aref cells 6 6))) "inside scissor draws")
|
|
(is (eql #\space (cell-char (aref cells 1 1))) "outside scissor is clipped"))))
|
|
#+END_SRC
|
|
|
|
** Test: flush handles different-sized framebuffers
|
|
|
|
When comparing framebuffers of different sizes, only the overlapping region
|
|
should be diffed. This test verifies correct behavior at both the smaller and
|
|
larger end of the size mismatch — ensuring edge cells in the non-overlapping
|
|
region are ignored.
|
|
|
|
#+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/tests/framebuffer-tests.lisp
|
|
(test flush-different-sized-fbs-handles-edge-cells
|
|
(let* ((small-fb (make-framebuffer 5 5))
|
|
(large-fb (make-framebuffer 10 10))
|
|
(be (make-simple-backend :output-stream (make-string-output-stream))))
|
|
(setf (aref small-fb 0 0) (make-cell :char #\X :fg :red))
|
|
(let ((changes (diff-framebuffers small-fb large-fb)))
|
|
(is (= 1 (length changes)) "one cell changed in overlap region"))
|
|
(let ((changed (flush-framebuffer small-fb large-fb be)))
|
|
(is (= 1 changed) "flush reports 1 changed cell"))
|
|
(setf (aref large-fb 9 9) (make-cell :char #\Y :fg :blue))
|
|
(let ((changes2 (diff-framebuffers large-fb small-fb)))
|
|
(is (= 1 (length changes2)) "only overlapping region diffed"))
|
|
(let ((changed2 (flush-framebuffer large-fb small-fb be)))
|
|
(is (= 1 changed2) "flush with shrunk fb reports 1 changed cell"))))
|
|
#+END_SRC
|
|
|
|
** Test: flush-fb copies to backend
|
|
|
|
After drawing on a framebuffer backend and flushing to a real backend, at least
|
|
one cell change should be detected and forwarded to the output backend.
|
|
|
|
#+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/tests/framebuffer-tests.lisp
|
|
(test flush-fb-copies-to-backend
|
|
(let* ((real-be (make-simple-backend :output-stream (make-string-output-stream)))
|
|
(fb (make-framebuffer-backend)))
|
|
(draw-text fb 0 0 "X" :red nil)
|
|
(let ((changed (flush-framebuffer (make-framebuffer 80 24) (fb-framebuffer fb) real-be)))
|
|
(is (>= changed 1)))))
|
|
#+END_SRC
|
|
|
|
** Test: fb-cell-link-url returns nil for blank cell
|
|
|
|
A cell without a hyperlink should return nil from ~fb-cell-link-url~, ensuring
|
|
the default state is correct and no spurious URL is reported.
|
|
|
|
#+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/tests/framebuffer-tests.lisp
|
|
(test fb-cell-link-url-returns-nil-for-blank-cell
|
|
(let ((fb (make-framebuffer 10 10)))
|
|
(is (null (fb-cell-link-url fb 5 5)))))
|
|
#+END_SRC
|
|
|
|
** Test: fb-cell-link-url finds link-url
|
|
|
|
After drawing text with a link-url, the corresponding cell should return that
|
|
URL. Cells at other positions should still return nil. This validates that
|
|
link metadata is stored per-cell and correctly retrievable.
|
|
|
|
#+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/tests/framebuffer-tests.lisp
|
|
(test fb-cell-link-url-finds-link-url
|
|
(let ((fb (make-framebuffer-backend)))
|
|
(draw-text fb 0 0 "click" nil nil :link-url "https://example.com")
|
|
(is (equal "https://example.com" (fb-cell-link-url (fb-framebuffer fb) 0 0)))
|
|
(is (null (fb-cell-link-url (fb-framebuffer fb) 5 5)))))
|
|
#+END_SRC
|
|
|
|
** Test: fb-cell-link-url out of bounds returns nil
|
|
|
|
Querying a cell position outside the framebuffer dimensions should gracefully
|
|
return nil rather than erroring, which prevents crashes during mouse event
|
|
processing at the edges of the terminal.
|
|
|
|
#+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/tests/framebuffer-tests.lisp
|
|
(test fb-cell-link-url-out-of-bounds-returns-nil
|
|
(let ((fb (make-framebuffer 5 5)))
|
|
(is (null (fb-cell-link-url fb 10 10)))))
|
|
#+END_SRC
|
|
|
|
** Test: extract-text single row
|
|
|
|
Extracting text from a single row of the framebuffer should return the
|
|
characters in that row as a contiguous string, preserving order and including
|
|
only visible characters.
|
|
|
|
#+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/tests/framebuffer-tests.lisp
|
|
(test extract-text-single-row
|
|
(let ((fb (make-framebuffer-backend)))
|
|
(draw-text fb 0 0 "hello" nil nil)
|
|
(let ((cells (fb-framebuffer fb)))
|
|
(is (equal "hello" (extract-text cells 0 0 4 0))))))
|
|
#+END_SRC
|
|
|
|
** Test: extract-text multi-row
|
|
|
|
Extracting text from a rectangle spanning multiple rows should concatenate
|
|
rows with newline separators. This matches the expected behavior for clipboard
|
|
copy of rectangular selections in the TUI.
|
|
|
|
#+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/tests/framebuffer-tests.lisp
|
|
(test extract-text-multi-row
|
|
(let ((fb (make-framebuffer-backend)))
|
|
(draw-text fb 0 0 "abc" nil nil)
|
|
(draw-text fb 0 1 "def" nil nil)
|
|
(let* ((cells (fb-framebuffer fb))
|
|
(text (extract-text cells 0 0 2 1)))
|
|
(is (equal "abc
|
|
def" text)))))
|
|
#+END_SRC
|