#+TITLE: cl-tty Backend Protocol — v0.0.1 #+STARTUP: content #+FILETAGS: :cl-tty:backend:v0.0.1: #+OPTIONS: ^:nil * 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 #+BEGIN_SRC lisp :tangle ../src/backend/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) ;; ── 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*))))) #+END_SRC * Implementation ** Package The ~cl-tty.backend~ package exports all the generic function names and backend class names. It uses only ~:cl~ — no external dependencies. #+BEGIN_SRC lisp :tangle ../src/backend/package.lisp (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) #+END_SRC 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. #+BEGIN_SRC lisp :tangle ../src/backend/classes.lisp (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)) #+END_SRC 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. #+BEGIN_SRC lisp :tangle ../src/backend/simple.lisp (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*))) #+END_SRC The ~output-stream~ initarg is the key extensibility point: tests use ~make-string-output-stream~ to capture output, while production uses ~*standard-output*~. #+BEGIN_SRC lisp :tangle ../src/backend/simple.lisp (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)) #+END_SRC ~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 #+BEGIN_SRC lisp :tangle ../src/backend/simple.lisp (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 #\|))) #+END_SRC All four corners use ~#\+~, edges use ~#\-~ and ~#\|~. No style distinction — single, double, and rounded are identical in ASCII. #+BEGIN_SRC lisp :tangle ../src/backend/simple.lisp (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)))) #+END_SRC ~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 #+BEGIN_SRC lisp :tangle ../src/backend/simple.lisp (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 "...")) #+END_SRC ~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 "...".