#+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 ../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 ../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 ../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 ../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 ../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 ../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 ../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 ../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 ../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 ../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 ../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 ../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 ../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 ../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 ../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 ../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 ../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 ../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 ../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 ../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 ../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 ../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 ../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 ../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 ../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 ../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 ../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 ../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 ../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 ../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 ../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 ../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 ../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 ../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 ../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 ../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 ../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 ../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 ../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