Application code (passepartout TUI) calls draw-text with a framebuffer (2D array) as the first argument, but draw-text only had methods for framebuffer-backend CLOS instances. Added a method on array that sets cells directly on the framebuffer array, matching make-framebuffer's return type.
31 KiB
Rendering Pipeline — Framebuffer (v0.13.0)
- Overview
- Tests (reference documentation, not tangled)
- Test package and suite setup
- Test: make-framebuffer creates correct size
- Test: cell defaults are space
- Test: draw-text on framebuffer sets cells
- Test: draw-text clips at bounds
- Test: diff of identical framebuffers returns empty
- Test: diff of changed framebuffer returns changes
- Test: with-scissor clips drawing
- Test: flush-fb copies to backend
- Implementation
- Tests
- Test package and suite setup
- Test: make-framebuffer creates correct size
- Test: cell defaults are space
- Test: draw-text on framebuffer sets cells
- Test: draw-text clips at bounds
- Test: diff of identical framebuffers returns empty
- Test: diff of changed framebuffer returns changes
- Test: with-scissor clips drawing
- Test: flush handles different-sized framebuffers
- Test: flush-fb copies to backend
- Test: fb-cell-link-url returns nil for blank cell
- Test: fb-cell-link-url finds link-url
- Test: fb-cell-link-url out of bounds returns nil
- Test: extract-text single row
- Test: extract-text multi-row
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-urlmake-framebuffer width height→ 2D array ofcellframebuffer-backend— subclass ofbackendthat renders to cell arraymake-framebuffer-backend &key width height→ framebuffer-backenddiff-framebuffers prev curr→ list of (x y cell) for changed cellsflush-framebuffer prev-fb curr-fb backend→ writes changes, returns countwith-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.
- Create org file with code blocks
- Tangle → framebuffer.lisp
- Add to ASDF
- Write tests
- Run, commit
Tests (reference documentation, not tangled)
;; Tests for framebuffer pipeline — manually added to tests/framebuffer-tests.lisp
Test package and suite setup
Setting up the test package with FiveAM, importing the rendering and backend packages for use in all subsequent tests.
(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)
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).
(test make-framebuffer-creates-correct-size
(let ((fb (make-framebuffer 80 24)))
(is (= 24 (framebuffer-height fb)))
(is (= 80 (framebuffer-width fb)))))
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.
(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)))))
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.
(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)))))))
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.
(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"))))
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.
(test diff-identical-fbs-returns-empty
(let ((fb1 (make-framebuffer 80 24))
(fb2 (make-framebuffer 80 24)))
(is (null (diff-framebuffers fb1 fb2)))))
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.
(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)))))))
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.
(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"))))
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.
(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)))))
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.
(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))
Package switch
Switch to the cl-tty.rendering package for all subsequent definitions.
(in-package :cl-tty.rendering)
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.
(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))
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.
(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))
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.
(defun framebuffer-width (fb)
"Return the width (columns) of framebuffer FB."
(if (arrayp fb) (array-dimension fb 1) 0))
(defun framebuffer-height (fb)
"Return the height (rows) of framebuffer FB."
(if (arrayp fb) (array-dimension fb 0) 0))
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.
(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)))
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).
(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))
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.
(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)))))))
%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.
(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)))))
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).
(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)))
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.
(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)))))
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.
(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))))
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.
(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)))))
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.
(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))))))
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.
(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))
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.
(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)))
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.
(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))))
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.
(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)))
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.
(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))
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.
(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))))
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.
(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))))))
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.
(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)))))
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.
(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)
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).
(test make-framebuffer-creates-correct-size
(let ((fb (make-framebuffer 80 24)))
(is (= 24 (framebuffer-height fb)))
(is (= 80 (framebuffer-width fb)))))
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.
(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)))))
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.
(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)))))))
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.
(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"))))
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.
(test diff-identical-fbs-returns-empty
(let ((fb1 (make-framebuffer 80 24))
(fb2 (make-framebuffer 80 24)))
(is (null (diff-framebuffers fb1 fb2)))))
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.
(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)))))))
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.
(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"))))
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.
(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"))))
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.
(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)))))
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.
(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)))))
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.
(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)))))
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.
(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)))))
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.
(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))))))
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.
(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)))))