Files
cl-tty/org/backend-protocol.org

37 KiB

cl-tty Backend Protocol — v0.0.1

Overview

The backend protocol is the rendering abstraction layer. Every visual operation dispatches through generic functions on a backend class. Two implementations exist: modern-backend (raw escape sequences, truecolor, modern terminal features) and simple-backend (ASCII art, universal compatibility).

All drawing operations are generic functions dispatched on the backend class. Application code never calls terminal escape sequences directly.

Contract

Backend Lifecycle

  • (initialize-backend backend) → backend Initialize the terminal, set raw mode, enable features. Returns the backend instance.
  • (shutdown-backend backend) → nil Restore terminal to cooked mode, reset colors, show cursor. Must be called on exit regardless of how the image stops.
  • (backend-size backend) → (values columns lines) Return terminal dimensions. First value = columns, second = lines.
  • (backend-write backend string) → integer Write raw string to terminal output. Returns number of bytes written.
  • (backend-clear backend) → nil Clear the entire screen and reset cursor to (0,0).

Rendering Primitives

  • (draw-text backend x y string fg bg &key bold italic underline reverse dim blink) → nil Render text at position (x, y). fg and bg are hex color strings (e.g. "#FFD700") or nil for default. Attributes are booleans.
  • (draw-border backend x y width height &key style fg bg title title-align) → nil Draw a border rectangle. Style is :single, :double, or :rounded.
  • (draw-rect backend x y width height &key bg) → nil Fill a rectangle with background color.
  • (draw-link backend x y string url &key fg bg) → nil Render clickable hyperlink (OSC 8 escape sequence).
  • (draw-ellipsis backend x y width &key fg bg) → nil Render "…" truncated text marker at position.

Cursor Operations

  • (cursor-move backend x y) → nil
  • (cursor-hide backend) → nil
  • (cursor-show backend) → nil
  • (cursor-style backend shape &key blink) → nil Shape is :block, :bar, or :underline.

Synchronization

  • (begin-sync backend) → nil Start synchronized update (DECICM). All subsequent output is buffered by the terminal until end-sync.
  • (end-sync backend) → nil Flush synchronized update buffer. The entire frame appears at once.

Input

  • (read-event backend &key timeout) → (values keyword list) Read next input event. Blocks until event or timeout.
  • (enable-mouse backend) → nil Enable SGR mouse tracking.
  • (enable-bracketed-paste backend) → nil Enable bracketed paste mode.

Capability Queries

  • (capable-p backend feature) → boolean Feature is :truecolor, :osc8, :sync, :mouse, :bracketed-paste, :kitty-keyboard, :sixel, :cursor-style.

Backend Classes

  • (make-simple-backend &key output-stream) → simple-backend Minimal backend. ASCII borders, no color, no modern features.
  • (make-modern-backend &key output-stream) → modern-backend Full-featured backend. Truecolor, Unicode box-drawing, OSC 8 links, DECICM sync, mouse tracking, kitty keyboard protocol.

Tests

The test suite is organized around the backend protocol contract. Each rendering primitive and lifecycle operation has a dedicated test case. Tests use a capturing backend (a simple-backend wired to a string output stream) so assertions check actual output strings rather than terminal behavior.

Test Package and Suite

FiveAM requires a test package with :use of :fiveam and the system under test. The suite name backend-suite is referenced by the multi-suite runner in run-all-tests.lisp.

(defpackage :cl-tty-backend-test
  (:use :cl :fiveam :cl-tty.backend)
  (:export #:run-tests))
(in-package :cl-tty-backend-test)

(def-suite backend-suite :description "Backend protocol tests")
(in-suite backend-suite)

Capturing Backend Helper

Tests need to inspect what the backend actually writes. This helper creates a simple-backend pointed at a string output stream and returns both the backend and the stream. The test can then call get-output-stream-string after the operation.

(defun make-capturing-backend ()
  "Create a simple-backend that writes to a string stream."
  (let* ((s (make-string-output-stream))
         (b (make-simple-backend :output-stream s)))
    (values b s)))

Test Runner Entry Point

The run-tests function is an alternative entry point for interactive use or for downstream scripts that want to run only the backend suite. It prints results with FiveAM's explainer.

(defun run-tests ()
  "Run all backend tests."
  (let ((result (run 'backend-suite)))
    (fiveam:explain! result)
    (uiop:quit 0)))

Simple Backend Lifecycle

Verifies that a simple-backend can be constructed, initialized, and shut down without errors. Also confirms that the capability query returns nil for truecolor — the defining characteristic of the simple backend.

(test simple-backend-lifecycle
  "simple-backend can be created and shut down"
  (let ((b (make-simple-backend)))
    (is (typep b 'simple-backend))
    (initialize-backend b)
    (is-false (capable-p b :truecolor) "simple backend has no truecolor")
    (is (null (multiple-value-list (suspend-backend b))))
    (is (null (multiple-value-list (resume-backend b))))
    (shutdown-backend b)))

Simple Backend Draw Text

The simple backend ignores style attributes (bold, italic, color) and position. It merely appends the text string to the output stream. This test confirms that passing style keywords does not change the output — the captured stream should contain exactly the string "hello".

(test simple-backend-draw-text
  "simple-backend renders text at position, ignoring style"
  (multiple-value-bind (b s) (make-capturing-backend)
    (initialize-backend b)
    (draw-text b 0 0 "hello" :red nil :bold t :italic t)
    (shutdown-backend b)
    (is (string= (get-output-stream-string s) "hello")
        "draw-text should output the string ignoring style")))

Simple Backend Draw Border

Border rendering on the simple backend uses ASCII characters: + for corners, - for horizontal edges, | for vertical edges. This test checks that the top edge contains "" and a middle row shows "| |" with pipe-separated empty space.

(test simple-backend-draw-border
  "simple-backend draws ASCII border with +-| characters"
  (multiple-value-bind (b s) (make-capturing-backend)
    (initialize-backend b)
    (draw-border b 0 0 5 3 :style :single)
    (shutdown-backend b)
    (let ((out (get-output-stream-string s)))
      (is (search "+---+" out) "top edge should have +---+\"")
      (is (search "|   |" out) "middle row should have pipe sides"))))

Simple Backend Draw Rounded Border

The simple backend does not support rounded corners — every style falls back to the same ASCII characters. This test verifies that requesting :rounded produces the same output as :single, confirming the graceful fallback.

(test simple-backend-draw-rounded
  "simple-backend falls back to straight edges for rounded style"
  (multiple-value-bind (b s) (make-capturing-backend)
    (initialize-backend b)
    (draw-border b 0 0 5 3 :style :rounded)
    (shutdown-backend b)
    (let ((out (get-output-stream-string s)))
      ;; Rounded falls back to ASCII -- identical output to single
      (is (search "+---+" out) "rounded style produces same dashes as single"))))

Simple Backend Draw Link

Hyperlinks are meaningless on a simple terminal output. The simple backend's draw-link should output only the visible text and completely ignore the URL parameter.

(test simple-backend-draw-link
  "simple-backend renders link as plain text"
  (multiple-value-bind (b s) (make-capturing-backend)
    (initialize-backend b)
    (draw-link b 0 0 "click me" "http://example.com")
    (shutdown-backend b)
    (is (string= (get-output-stream-string s) "click me")
        "simple-backend ignores URL, outputs text only")))

Simple Backend Draw Ellipsis

Truncation markers are rendered as three literal dots on the simple backend. This test checks that draw-ellipsis outputs exactly "…" at the specified position.

(test simple-backend-draw-ellipsis
  "simple-backend renders ... for ellipsis"
  (multiple-value-bind (b s) (make-capturing-backend)
    (initialize-backend b)
    (draw-ellipsis b 0 0 5)
    (shutdown-backend b)
    (is (string= (get-output-stream-string s) "...")
        "ellipsis should output 3 dots")))

Capability Query: Known Features

All known terminal features should report nil on the simple backend. This comprehensive check iterates every feature keyword to ensure the simple backend makes no false claims about its capabilities.

(test capable-p-known-features
  "capable-p returns nil for all features on simple-backend"
  (let ((b (make-simple-backend)))
    (initialize-backend b)
    (dolist (f '(:truecolor :osc8 :sync :mouse :bracketed-paste
                 :kitty-keyboard :sixel :cursor-style))
      (is-false (capable-p b f)
                (format nil "~s should not be supported on simple-backend" f)))
    (shutdown-backend b)))

Backend Size Returns Integers

The backend-size function must return two integer values representing columns and lines. This test verifies the return types and a minimum size threshold (10 columns, 3 lines) for any terminal-like environment.

(test backend-size-returns-integers
  "backend-size returns two integer values"
  (let ((b (make-simple-backend)))
    (initialize-backend b)
    (multiple-value-bind (cols lines) (backend-size b)
      (is (integerp cols))
      (is (integerp lines))
      (is (>= cols 10))
      (is (>= lines 3)))
    (shutdown-backend b)))

Default Methods Are No-Ops

All cursor operations and sync operations on the default backend should return nil (or (values)) without signaling errors. This test calls cursor-hide, cursor-show, cursor-style, begin-sync, and end-sync and confirms none of them produce multiple return values.

(test default-methods-are-no-ops
  "Default backend methods don't error"
  (let ((b (make-simple-backend)))
    (initialize-backend b)
    (is (null (multiple-value-list (cursor-hide b))))
    (is (null (multiple-value-list (cursor-show b))))
    (is (null (multiple-value-list (cursor-style b :block))))
    (is (null (multiple-value-list (begin-sync b))))
    (is (null (multiple-value-list (end-sync b))))
    (shutdown-backend b)))

Sync Is No-Op on Simple

Synchronized updates (DECICM) have no meaning on a simple terminal output. This test verifies that wrapping a draw-text call between begin-sync and end-sync produces exactly the same output as draw-text alone — no extra escape sequences are emitted.

(test sync-is-noop-on-simple
  "begin-sync and end-sync produce no output on simple-backend"
  (multiple-value-bind (b s) (make-capturing-backend)
    (initialize-backend b)
    (begin-sync b)
    (draw-text b 0 0 "in sync" nil nil)
    (end-sync b)
    (shutdown-backend b)
    (is (string= (get-output-stream-string s) "in sync")
        "no sync escape sequences should appear")))

Draw Rect Is No-Op on Simple

Background fill operations require escape sequences to change cell colors. Since the simple backend emits no escape sequences, draw-rect should produce zero output regardless of the fill color requested.

(test draw-rect-fills-area-correctly
  "draw-rect with background writes nothing to output (simple-backend no-op)"
  (multiple-value-bind (b s) (make-capturing-backend)
    (initialize-backend b)
    (draw-rect b 0 0 5 3 :bg :red)
    (shutdown-backend b)
    (is (string= (get-output-stream-string s) "")
        "draw-rect is a no-op on simple-backend")))

Backend Detection Returns Instance

The detect-backend function must return a backend instance suitable for the current environment. This test verifies that the returned value is of type backend (satisfying the protocol).

(test detection-returns-backend-instance
  "detect-backend returns a valid backend instance"
  (let ((be (cl-tty.backend:detect-backend)))
    (is (typep be 'cl-tty.backend:backend))))

Backend Detection Caches Result

detect-backend caches its result in *detected-backend* so that subsequent calls are cheap. This test clears the cache, calls detect-backend, and verifies that the special variable is no longer nil.

(test detection-caches-result
  "detect-backend caches the result in *detected-backend*"
  (let ((*detected-backend* nil))
    (cl-tty.backend:detect-backend)
    (is-true (not (null cl-tty.backend::*detected-backend*)))))

Implementation

This section defines the base backend protocol and the simple backend implementation. Each function, generic function, and method is documented individually with its design rationale.

Package

The cl-tty.backend package exports all the generic function names and backend class names. It uses only :cl — no external dependencies. The package also exports internal symbols (sgr-fg, hex-to-24bit, etc.) for testing. These let the test suite verify escape sequence construction without actually rendering to a terminal.

(defpackage :cl-tty.backend
  (:use :cl)
  (:export
   ;; Backend classes
   #:backend #:simple-backend
   ;; Lifecycle
   #:initialize-backend #:shutdown-backend
   #:suspend-backend #:resume-backend
   #:backend-size #:backend-write #:backend-clear
   ;; Drawing
   #:draw-text #:draw-border #:draw-rect
   #:draw-link #:draw-ellipsis
   ;; Cursor
   #:cursor-move #:cursor-hide #:cursor-show #:cursor-style
   ;; Sync
   #:begin-sync #:end-sync
   ;; Input
   #:read-event #:enable-mouse #:enable-bracketed-paste
   ;; Queries
   #:capable-p
   ;; Constructors
    #:make-simple-backend
    #:with-terminal
    ;; Modern backend
   #:modern-backend #:make-modern-backend
   ;; Detection
   #:detect-backend #:*detected-backend*
   ;; Theme color resolution (populated by theme system)
   #:*theme-colors*
   ;; Internal (for testing)
   #:sgr-fg #:sgr-bg #:sgr-attr
   #:cursor-move-escape #:cursor-style-escape
   #:decicm-begin #:decicm-end #:osc8-link
   #:hex-to-rgb #:border-char))
(in-package :cl-tty.backend)

Backend Base Class

The backend class itself is empty — it's a base for method dispatch. Every generic function on backend has a default method so that new backend implementations only need to override the functions they actually support.

Backend Class Definition

An empty base class. There are no slots because backends manage their own state (e.g., output streams) directly.

(in-package :cl-tty.backend)

(defclass backend () ())

Initialize Backend

Sets up terminal raw mode and enables features. The default method returns the backend instance unchanged — subclasses that need setup override this.

(defgeneric initialize-backend (backend)
  (:method ((b backend)) b))

Shutdown Backend

Restores terminal to cooked mode, resets colors, shows cursor. Must be called on exit. The default method is a no-op returning multiple values; subclasses with terminal state override this.

(defgeneric shutdown-backend (backend)
  (:method ((b backend)) (values)))

Backend Size

Returns terminal dimensions as two values: columns and lines. The default of 80x24 is a safe fallback that works everywhere.

(defgeneric backend-size (backend)
  (:method ((b backend))
    (values 80 24)))

Backend Write

Writes a raw string to the terminal output. Has no default method because every backend must provide its own output mechanism — there is no reasonable universal behavior.

(defgeneric backend-write (backend string))

Backend Clear

Clears the entire screen and resets the cursor to (0,0). The default method sends the ANSI escape sequence ESC[2J (clear entire screen) followed by ESC[H (cursor home).

(defgeneric backend-clear (backend)
  (:method ((b backend))
    (backend-write b (format nil "~C[2J~C[H" #\Esc #\Esc))))

Draw Text

Renders text at position (x, y) with foreground and background colors and style attributes. The &allow-other-keys is important: it lets individual backend methods accept keyword arguments they don't use without signaling an error. The simple backend ignores styles; the modern backend processes them.

(defgeneric draw-text (backend x y string fg bg &key
                       bold italic underline reverse dim blink
                       &allow-other-keys))

Draw Border

Draws a border rectangle with optional title. Style is one of :single, :double, or :rounded. The default method has no implementation — each backend provides its own border rendering.

(defgeneric draw-border (backend x y width height
                         &key style fg bg title title-align))

Draw Rectangle

Fills a rectangular area with a background color. On the simple backend this is a no-op; on the modern backend it writes space characters with the appropriate SGR background color.

(defgeneric draw-rect (backend x y width height &key bg))

Draw Link

Renders a clickable hyperlink using OSC 8 escape sequences. The default is a protocol declaration only — modern-backend implements the actual escape sequences, simple-backend falls back to plain text.

(defgeneric draw-link (backend x y string url &key fg bg))

Draw Ellipsis

Renders a "…" truncation marker at position (x, y). This is used when text exceeds the available width. Each backend positions the marker according to its own coordinate system.

(defgeneric draw-ellipsis (backend x y width &key fg bg))

Cursor Move

Moves the cursor to absolute position (x, y). The default method is a no-op — backends that support cursor positioning override this.

(defgeneric cursor-move (backend x y)
  (:method ((b backend) x y) (declare (ignore x y)) (values)))

Cursor Hide

Hides the terminal cursor. The default method is a no-op so that backends that lack cursor control still work safely.

(defgeneric cursor-hide (backend)
  (:method ((b backend)) (values)))

Cursor Show

Shows the terminal cursor after a hide. Always paired with cursor-hide. Default is a no-op.

(defgeneric cursor-show (backend)
  (:method ((b backend)) (values)))

Cursor Style

Sets the cursor shape and blink behavior. Shape is :block, :bar, or :underline. Default is a no-op for backends that don't support cursor styling.

(defgeneric cursor-style (backend shape &key blink)
  (:method ((b backend) shape &key blink) (values)))

Begin Sync

Starts a synchronized update (DECICM). All subsequent output is buffered by the terminal until end-sync. Default is a no-op.

(defgeneric begin-sync (backend)
  (:method ((b backend)) (values)))

End Sync

Flushes the synchronized update buffer so the entire frame appears at once. Always paired with begin-sync. Default is a no-op.

(defgeneric end-sync (backend)
  (:method ((b backend)) (values)))

Read Event

Reads the next input event from the terminal. Blocks until an event arrives or the timeout expires. Returns (values keyword event-data). The default method returns (values nil nil) — no events available.

(defgeneric read-event (backend &key timeout)
  (:method ((b backend) &key timeout) (values nil nil)))

Enable Mouse

Enables SGR mouse tracking so mouse click and motion events are reported as input. Default is a no-op on backends that don't support mouse input.

(defgeneric enable-mouse (backend)
  (:method ((b backend)) (values)))

Enable Bracketed Paste

Enables bracketed paste mode so the application can distinguish pasted text from typed input. Default is a no-op.

(defgeneric enable-bracketed-paste (backend)
  (:method ((b backend)) (values)))

Capable-P Feature Query

Queries whether the backend supports a named feature. Feature keywords include :truecolor, :osc8, :sync, :mouse, :bracketed-paste, :kitty-keyboard, :sixel, and :cursor-style. The default method returns nil for all features.

(defgeneric capable-p (backend feature)
  (:method ((b backend) feature)
    (declare (ignore feature))
    nil))

Suspend and Resume

Temporary terminal suspension and re-initialization. Used when the application receives SIGTSTP (suspend) or SIGCONT (resume) signals. The default methods are no-ops; backends with terminal state override these to restore cooked mode on suspend and raw mode on resume.

(in-package :cl-tty.backend)

(defgeneric suspend-backend (backend)
  (:documentation "Temporarily suspend the backend, restoring terminal to normal state.
Called before SIGTSTP or similar suspension. Application should redraw after resume.")
  (:method ((b backend)) (values)))

(defgeneric resume-backend (backend)
  (:documentation "Re-initialize the backend after suspension.
Called after SIGCONT or similar resume. Re-enables raw mode and backend features.")
  (:method ((b backend)) (values)))

With Terminal

A convenience macro that initializes a terminal backend, executes body, and guarantees cleanup on exit via unwind-protect.

The macro detects a suitable backend, initializes it, captures the terminal dimensions, binds them to the provided variables, executes the body, and always calls shutdown-backend when the body exits (whether normally or by a non-local control transfer).

Arguments:

  • backend-var — bound to the detected backend instance.
  • cols-var, rows-var (optional) — bound to terminal columns and lines captured after initialization.
  • &body body — executed with the above bindings.
(in-package :cl-tty.backend)

(defmacro with-terminal ((backend-var &optional cols-var rows-var)
                         &body body)
  "Execute BODY with a fully initialized terminal backend.

DETECT-BACKEND, INITIALIZE-BACKEND, and SHUTDOWN-BACKEND are called
automatically.  The backend instance is bound to BACKEND-VAR.  If
COLS-VAR and ROWS-VAR are provided, they are bound to the terminal
dimensions at startup.

The caller should wrap this in SB-POSIX:WITH-RAW-TERMINAL (or
equivalent) if raw-mode input handling is needed.

Example:
  (with-terminal (be cols rows)
    (loop for ev = (read-event be :timeout 0.1)
          while ev
          do (format t \"~A~%\" ev))))"
  (let ((be-sym (gensym "BE"))
        (c-sym (gensym "COLS"))
        (r-sym (gensym "ROWS")))
    `(let* ((,be-sym (detect-backend))
            ,@(when cols-var `((,c-sym (nth-value 0 (backend-size ,be-sym)))))
            ,@(when rows-var `((,r-sym (nth-value 1 (backend-size ,be-sym))))))
       (initialize-backend ,be-sym)
       (unwind-protect
            (let ((,backend-var ,be-sym)
                  ,@(when cols-var `((,cols-var ,c-sym)))
                  ,@(when rows-var `((,rows-var ,r-sym))))
              ,@body)
         (shutdown-backend ,be-sym)))))

Simple Backend

simple-backend inherits from backend and implements every operation in pure ASCII. No escape sequences, no color, no modern features. Works in any terminal, pipe, or serial connection.

Simple Backend Class

The simple-backend class has a single slot: output-stream. This defaults to *standard-output* but can be overridden via the :output-stream initarg — the key extensibility point. Tests use make-string-output-stream to capture output, while production uses *standard-output*.

(in-package :cl-tty.backend)

(defclass simple-backend (backend)
  ((output-stream :initform *standard-output*
                  :initarg :output-stream
                  :accessor backend-output-stream)))

Make Simple Backend

Constructor function that creates a simple-backend instance. Uses make-instance with the provided output stream or falls back to *standard-output*. This function is exported rather than exposing make-instance directly to provide a stable API surface.

(defun make-simple-backend (&key output-stream)
  (make-instance 'simple-backend
    :output-stream (or output-stream *standard-output*)))

Initialize Backend (Simple)

Simple backend initialization is a no-op — there is no terminal state to configure. Returns the backend instance to satisfy the protocol contract.

(defmethod initialize-backend ((b simple-backend))
  b)

Shutdown Backend (Simple)

Simple backend shutdown is a no-op — there is no terminal state to restore. Returns multiple values to satisfy the protocol contract.

(defmethod shutdown-backend ((b simple-backend))
  (values))

Suspend (simple-backend)

No-op — simple backend has no terminal state to save.

(defmethod suspend-backend ((b simple-backend))
  (values))

Resume (simple-backend)

No-op — simple backend has no terminal state to restore.

(defmethod resume-backend ((b simple-backend))
  (values))

Backend Size (Simple)

Queries actual terminal dimensions through a fallback chain, with a hard-coded 80x24 at the end:

  1. ioctl on fd 0 (stdin) — the parent's real terminal fd.
  2. ioctl on stdout — fast and correct after SIGWINCH at runtime.
  3. ioctl on /dev/tty — fallback when stdin/stdout are pipes.
  4. (values 80 24) — last resort.
(defmethod backend-size ((b simple-backend))
  ;; Try ioctl on fd 0 (stdin), then stdout, then /dev/tty, then 80x24.
  ;; Use multiple-value-bind/values to preserve both cols and rows
  ;; (or discards secondary values).
  (multiple-value-bind (cols rows)
      (ignore-errors
        (let ((winsize (sb-alien:make-alien sb-alien:unsigned-short 4)))
          (unwind-protect
               (let ((ok (sb-unix:unix-ioctl 0 21523
                                             (sb-alien:alien-sap winsize))))
                 (when ok
                   (let ((c (sb-alien:deref winsize 1))
                         (r (sb-alien:deref winsize 0)))
                     (when (and c r (> c 0) (> r 0))
                       (values c r)))))
            (sb-alien:free-alien winsize))))
    (if (and cols rows (> cols 0) (> rows 0))
        (values cols rows)
        ;; ioctl on stdout fd
        (multiple-value-bind (cols rows)
            (ignore-errors
              (let* ((winsize (sb-alien:make-alien sb-alien:unsigned-short 4)))
                (unwind-protect
                     (let ((ok (sb-unix:unix-ioctl
                                 (sb-sys:fd-stream-fd (backend-output-stream b))
                                 21523 (sb-alien:alien-sap winsize))))
                       (when ok
                         (values (sb-alien:deref winsize 1)
                                 (sb-alien:deref winsize 0))))
                  (sb-alien:free-alien winsize))))
          (if (and cols rows (> cols 0) (> rows 0))
              (values cols rows)
              ;; Direct ioctl on /dev/tty
              (multiple-value-bind (cols rows)
                  (ignore-errors
                    (let ((tty-fd (sb-unix:unix-open "/dev/tty" 0 0)))
                      (when (and tty-fd (numberp tty-fd) (> tty-fd 0))
                        (unwind-protect
                             (let* ((winsize (sb-alien:make-alien sb-alien:unsigned-short 4)))
                               (let ((ok (sb-unix:unix-ioctl tty-fd 21523
                                                             (sb-alien:alien-sap winsize))))
                                 (when ok
                                   (values (sb-alien:deref winsize 1)
                                           (sb-alien:deref winsize 0))))
                               (sb-alien:free-alien winsize))
                          (sb-unix:unix-close tty-fd)))))
                (if (and cols rows (> cols 0) (> rows 0))
                    (values cols rows)
                    (values 80 24))))))))

Backend Write (Simple)

Writes a string to the backend's output stream and returns its length. Does NOT flush — explicit sync points (initialize-backend, end-sync, etc.) call finish-output as needed.

(defmethod backend-write ((b simple-backend) string)
  (let ((stream (backend-output-stream b)))
    (write-string string stream)
    (length string)))

Draw Text (Simple)

The simple backend's draw-text ignores position, color, and style completely. It appends only the string content to the output stream. This means simple backends are always a "scroll and dump" mode — no cursor positioning, no escape sequences.

 (defmethod draw-text ((b simple-backend) x y string fg bg
                       &key bold italic underline reverse dim blink
                       &allow-other-keys)
  (declare (ignore x y fg bg bold italic underline reverse dim blink))
  (backend-write b string))

Simple Border Character Helper

Returns the ASCII character for a given border position. All four corners use #\+, horizontal edges use #\-, and vertical edges use #\|. No style distinction — single, double, and rounded are identical in ASCII output.

(defun %simple-border-char (pos)
  "Return ASCII border character at POS.
POS is :top-left, :top-right, :bottom-left, :bottom-right,
:horizontal, or :vertical."
  (case pos
    ((:top-left :top-right :bottom-left :bottom-right) #\+)
    (:horizontal #\-)
    (:vertical #\|)))

Draw Border (Simple)

Draws a border using only newlines and spaces for positioning — no escape sequences. This makes it compatible with pipe output. The title rendering supports :left and :center alignment, placing the title inside the top border line with horizontal dashes filling the remaining space.

(defmethod draw-border ((b simple-backend) x y width height
                        &key style fg bg title title-align)
  (declare (ignore style fg bg))
  (let ((h (%simple-border-char :horizontal))
        (v (%simple-border-char :vertical))
        (tl (%simple-border-char :top-left))
        (tr (%simple-border-char :top-right))
        (bl (%simple-border-char :bottom-left))
        (br (%simple-border-char :bottom-right)))
    ;; Position cursor with newlines and spaces (no escape sequences)
    (dotimes (row y) (backend-write b (string #\Newline)))
    ;; Top edge with optional title
    (backend-write b (make-string x :initial-element #\space))
    (backend-write b (string tl))
    (if (and title (plusp (length title)))
        (let* ((align (or title-align :left))
               (inner-width (- width 2))
               (max-tlen (- inner-width 2))
               (tlen (min (length title) max-tlen))
               (trunc-title (subseq title 0 tlen)))
          (ecase align
            (:left
             (backend-write b (string #\Space))
             (backend-write b trunc-title)
             (backend-write b (string #\Space))
             (backend-write b (make-string (- inner-width tlen 2) :initial-element h)))
            (:center
             (let* ((total-pad (- inner-width tlen))
                    (left-pad (floor total-pad 2))
                    (right-pad (- total-pad left-pad)))
               (backend-write b (make-string left-pad :initial-element h))
               (backend-write b trunc-title)
               (backend-write b (make-string right-pad :initial-element h))))))
        (backend-write b (make-string (- width 2) :initial-element h)))
    (backend-write b (string tr))
    ;; Sides
    (loop for i from 1 below (1- height)
          do (backend-write b (string #\Newline))
             (backend-write b (make-string x :initial-element #\space))
             (backend-write b (string v))
             (backend-write b (make-string (- width 2) :initial-element #\space))
             (backend-write b (string v)))
    ;; Bottom edge
    (backend-write b (string #\Newline))
    (backend-write b (make-string x :initial-element #\space))
    (backend-write b (string bl))
    (backend-write b (make-string (- width 2) :initial-element h))
    (backend-write b (string br))))

Draw Rect (Simple)

Background fill is impossible without escape sequences. This method is a no-op — it discards all arguments and returns (values).

(defmethod draw-rect ((b simple-backend) x y width height
                      &key bg)
  (declare (ignore x y width height bg))
  ;; On simple backend, background fill is a no-op
  (values))

Draw Link (Simple)

Hyperlinks fall back to plain text on the simple backend. The URL parameter is discarded entirely; the visible text is rendered via draw-text with no styling.

(defmethod draw-link ((b simple-backend) x y string url
                      &key fg bg)
  (declare (ignore url fg bg))
  (draw-text b x y string nil nil))

Draw Ellipsis (Simple)

Renders "…" using the simple backend's positioning pattern: newlines to reach the target row, spaces to reach the target column, then the literal three dots. No escape sequences are used.

(defmethod draw-ellipsis ((b simple-backend) x y width
                          &key fg bg)
  (declare (ignore width fg bg))
  ;; Position using newlines+spaces (simple-backend pattern)
  (dotimes (row y) (backend-write b (string #\Newline)))
  (backend-write b (make-string x :initial-element #\Space))
  (backend-write b "..."))