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

1026 lines
37 KiB
Org Mode

#+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
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~.
#+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/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)
#+END_SRC
** 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.
#+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/src/backend/tests.lisp
(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)))
#+END_SRC
** 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.
#+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/src/backend/tests.lisp
(defun run-tests ()
"Run all backend tests."
(let ((result (run 'backend-suite)))
(fiveam:explain! result)
(uiop:quit 0)))
#+END_SRC
** 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.
#+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/src/backend/tests.lisp
(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)))
#+END_SRC
** 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".
#+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/src/backend/tests.lisp
(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")))
#+END_SRC
** 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.
#+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/src/backend/tests.lisp
(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"))))
#+END_SRC
** 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.
#+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/src/backend/tests.lisp
(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"))))
#+END_SRC
** 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.
#+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/src/backend/tests.lisp
(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")))
#+END_SRC
** 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.
#+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/src/backend/tests.lisp
(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")))
#+END_SRC
** 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.
#+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/src/backend/tests.lisp
(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)))
#+END_SRC
** 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.
#+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/src/backend/tests.lisp
(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)))
#+END_SRC
** 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.
#+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/src/backend/tests.lisp
(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)))
#+END_SRC
** 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.
#+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/src/backend/tests.lisp
(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")))
#+END_SRC
** 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.
#+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/src/backend/tests.lisp
(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")))
#+END_SRC
** 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).
#+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/src/backend/tests.lisp
(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))))
#+END_SRC
** 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.
#+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/src/backend/tests.lisp
(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
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.
#+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/src/backend/package.lisp
(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)
#+END_SRC
** 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.
#+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/src/backend/classes.lisp
(in-package :cl-tty.backend)
(defclass backend () ())
#+END_SRC
*** Initialize Backend
Sets up terminal raw mode and enables features. The default method
returns the backend instance unchanged — subclasses that need setup
override this.
#+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/src/backend/classes.lisp
(defgeneric initialize-backend (backend)
(:method ((b backend)) b))
#+END_SRC
*** 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.
#+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/src/backend/classes.lisp
(defgeneric shutdown-backend (backend)
(:method ((b backend)) (values)))
#+END_SRC
*** Backend Size
Returns terminal dimensions as two values: columns and lines.
The default of 80x24 is a safe fallback that works everywhere.
#+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/src/backend/classes.lisp
(defgeneric backend-size (backend)
(:method ((b backend))
(values 80 24)))
#+END_SRC
*** 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.
#+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/src/backend/classes.lisp
(defgeneric backend-write (backend string))
#+END_SRC
*** 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).
#+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/src/backend/classes.lisp
(defgeneric backend-clear (backend)
(:method ((b backend))
(backend-write b (format nil "~C[2J~C[H" #\Esc #\Esc))))
#+END_SRC
*** 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.
#+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/src/backend/classes.lisp
(defgeneric draw-text (backend x y string fg bg &key
bold italic underline reverse dim blink
&allow-other-keys))
#+END_SRC
*** 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.
#+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/src/backend/classes.lisp
(defgeneric draw-border (backend x y width height
&key style fg bg title title-align))
#+END_SRC
*** 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.
#+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/src/backend/classes.lisp
(defgeneric draw-rect (backend x y width height &key bg))
#+END_SRC
*** 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.
#+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/src/backend/classes.lisp
(defgeneric draw-link (backend x y string url &key fg bg))
#+END_SRC
*** 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.
#+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/src/backend/classes.lisp
(defgeneric draw-ellipsis (backend x y width &key fg bg))
#+END_SRC
*** Cursor Move
Moves the cursor to absolute position (x, y). The default method
is a no-op — backends that support cursor positioning override this.
#+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/src/backend/classes.lisp
(defgeneric cursor-move (backend x y)
(:method ((b backend) x y) (declare (ignore x y)) (values)))
#+END_SRC
*** Cursor Hide
Hides the terminal cursor. The default method is a no-op so that
backends that lack cursor control still work safely.
#+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/src/backend/classes.lisp
(defgeneric cursor-hide (backend)
(:method ((b backend)) (values)))
#+END_SRC
*** Cursor Show
Shows the terminal cursor after a hide. Always paired with
~cursor-hide~. Default is a no-op.
#+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/src/backend/classes.lisp
(defgeneric cursor-show (backend)
(:method ((b backend)) (values)))
#+END_SRC
*** 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.
#+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/src/backend/classes.lisp
(defgeneric cursor-style (backend shape &key blink)
(:method ((b backend) shape &key blink) (values)))
#+END_SRC
*** Begin Sync
Starts a synchronized update (DECICM). All subsequent output is
buffered by the terminal until ~end-sync~. Default is a no-op.
#+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/src/backend/classes.lisp
(defgeneric begin-sync (backend)
(:method ((b backend)) (values)))
#+END_SRC
*** End Sync
Flushes the synchronized update buffer so the entire frame appears
at once. Always paired with ~begin-sync~. Default is a no-op.
#+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/src/backend/classes.lisp
(defgeneric end-sync (backend)
(:method ((b backend)) (values)))
#+END_SRC
*** 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.
#+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/src/backend/classes.lisp
(defgeneric read-event (backend &key timeout)
(:method ((b backend) &key timeout) (values nil nil)))
#+END_SRC
*** 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.
#+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/src/backend/classes.lisp
(defgeneric enable-mouse (backend)
(:method ((b backend)) (values)))
#+END_SRC
*** Enable Bracketed Paste
Enables bracketed paste mode so the application can distinguish
pasted text from typed input. Default is a no-op.
#+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/src/backend/classes.lisp
(defgeneric enable-bracketed-paste (backend)
(:method ((b backend)) (values)))
#+END_SRC
*** 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.
#+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/src/backend/classes.lisp
(defgeneric capable-p (backend feature)
(:method ((b backend) feature)
(declare (ignore feature))
nil))
#+END_SRC
*** 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.
#+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/src/backend/classes.lisp
(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)))
#+END_SRC
*** 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.
#+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/src/backend/classes.lisp
(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)))))
#+END_SRC
** 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*~.
#+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/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)))
#+END_SRC
*** 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.
#+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/src/backend/simple.lisp
(defun make-simple-backend (&key output-stream)
(make-instance 'simple-backend
:output-stream (or output-stream *standard-output*)))
#+END_SRC
*** 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.
#+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/src/backend/simple.lisp
(defmethod initialize-backend ((b simple-backend))
b)
#+END_SRC
*** 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.
#+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/src/backend/simple.lisp
(defmethod shutdown-backend ((b simple-backend))
(values))
#+END_SRC
*** Suspend (simple-backend)
No-op — simple backend has no terminal state to save.
#+begin_src lisp :tangle ~/.local/share/cl-tty/src/backend/simple.lisp
(defmethod suspend-backend ((b simple-backend))
(values))
#+end_src
*** Resume (simple-backend)
No-op — simple backend has no terminal state to restore.
#+begin_src lisp :tangle ~/.local/share/cl-tty/src/backend/simple.lisp
(defmethod resume-backend ((b simple-backend))
(values))
#+end_src
*** 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.
#+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/src/backend/simple.lisp
(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))))))))
#+END_SRC
*** 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.
#+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/src/backend/simple.lisp
(defmethod backend-write ((b simple-backend) string)
(let ((stream (backend-output-stream b)))
(write-string string stream)
(length string)))
#+END_SRC
*** 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.
#+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/src/backend/simple.lisp
(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))
#+END_SRC
*** 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.
#+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/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
*** 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.
#+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/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 Rect (Simple)
Background fill is impossible without escape sequences. This method
is a no-op — it discards all arguments and returns ~(values)~.
#+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/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))
#+END_SRC
*** 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.
#+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/src/backend/simple.lisp
(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))
#+END_SRC
*** 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.
#+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/src/backend/simple.lisp
(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