Files
cl-tty/org/framebuffer.org
Amr Gharbeia 07cea571ef fix: add backend-clear method for raw 2D arrays
Same pattern as the draw-text array fix. Application code may call
backend-clear with a framebuffer array instead of a backend instance.
The array method clears all cells to default blank state.
2026-05-13 16:29:50 -04:00

32 KiB

Rendering Pipeline — Framebuffer (v0.13.0)

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)

;; 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)))))

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.

(defmethod backend-clear ((fb array))
  (dotimes (y (array-dimension fb 0))
    (dotimes (x (array-dimension fb 1))
      (setf (aref fb y x) (make-cell)))))

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)))))