Files
cl-tty/org/backend-protocol.org
Hermes Agent dfd828c914 literate: convert org/backend-protocol.org from doc-only to tangle source
Now tangles to: package.lisp, classes.lisp, simple.lisp, tests.lisp
All 4 .lisp files deleted and regenerated from org alone — verified GREEN
2026-05-12 17:08:54 +00:00

18 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

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

;; ── Helpers ─────────────────────────────────────────────────────

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

;; ── Simple Backend ──────────────────────────────────────────────

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

(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")
    (shutdown-backend b)))

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

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

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

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

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

;; ── Backend 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 ───────────────────────────────────────────────

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

;; ── Backend Protocol: Defaults and No-ops ──────────────────────

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

(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 ──────────────────────────────────────────────────

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

;; ── Detection ──────────────────────────────────────────────────

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

(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

Package

The cl-tty.backend package exports all the generic function names and backend class names. It uses only :cl — no external dependencies.

(defpackage :cl-tty.backend
  (:use :cl)
  (:export
   ;; Backend classes
   #:backend #:simple-backend
   ;; Lifecycle
   #:initialize-backend #:shutdown-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
   ;; 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)

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.

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.

(in-package :cl-tty.backend)

(defclass backend () ())

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

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

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

(defgeneric backend-write (backend string))

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

The &allow-other-keys on draw-text 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.

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.

(in-package :cl-tty.backend)

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

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

The output-stream initarg is the key extensibility point: tests use make-string-output-stream to capture output, while production uses *standard-output*.

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

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

(defmethod backend-size ((b simple-backend))
  ;; Try ioctl, fall back to 80x24
  (values 80 24))

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

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

draw-text on simple-backend ignores position and style completely. It just appends the string to the output stream. This means simple backends are always a "scroll and dump" mode — no cursor positioning.

Border drawing

(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 #\|)))

All four corners use #\+, edges use #\- and #\|. No style distinction — single, double, and rounded are identical in ASCII.

(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-border on the simple backend uses newlines and spaces for positioning instead of cursor-move 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.

Remaining primitives

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

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

(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 "..."))

draw-rect is a no-op on simple-backend (no background fill possible without escape sequences). draw-link falls back to plain text. draw-ellipsis positions and writes "…".