The OR pattern inside backend-size used (or (multiple-value-bind ...) ...), but multiple-value-bind only returns the primary value of its body. When the env-var shortcut was removed, both calls to backend-size (the cols nth-value 0 and rows nth-value 1) returned the same primary value, making rows always nil. Restructure with nested multiple-value-bind/values chains so both return values propagate correctly through all fallback stages. Also remove MY_TERM_COLS/ROWS env-var pre-check — it returned stale startup dimensions after terminal resize.
36 KiB
cl-tty Backend Protocol — v0.0.1
- Overview
- Contract
- Tests
- Test Package and Suite
- Capturing Backend Helper
- Test Runner Entry Point
- Simple Backend Lifecycle
- Simple Backend Draw Text
- Simple Backend Draw Border
- Simple Backend Draw Rounded Border
- Simple Backend Draw Link
- Simple Backend Draw Ellipsis
- Capability Query: Known Features
- Backend Size Returns Integers
- Default Methods Are No-Ops
- Sync Is No-Op on Simple
- Draw Rect Is No-Op on Simple
- Backend Detection Returns Instance
- Backend Detection Caches Result
- Implementation
- Package
- Backend Base Class
- Backend Class Definition
- Initialize Backend
- Shutdown Backend
- Backend Size
- Backend Write
- Backend Clear
- Draw Text
- Draw Border
- Draw Rectangle
- Draw Link
- Draw Ellipsis
- Cursor Move
- Cursor Hide
- Cursor Show
- Cursor Style
- Begin Sync
- End Sync
- Read Event
- Enable Mouse
- Enable Bracketed Paste
- Capable-P Feature Query
- Suspend and Resume
- With Terminal
- Simple Backend
- Simple Backend Class
- Make Simple Backend
- Initialize Backend (Simple)
- Shutdown Backend (Simple)
- Suspend (simple-backend)
- Resume (simple-backend)
- Backend Size (Simple)
- Backend Write (Simple)
- Draw Text (Simple)
- Simple Border Character Helper
- Draw Border (Simple)
- Draw Rect (Simple)
- Draw Link (Simple)
- Draw Ellipsis (Simple)
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 untilend-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:
- ioctl on fd 0 (stdin) — the parent's real terminal fd.
- ioctl on stdout — fast and correct after SIGWINCH at runtime.
- ioctl on
/dev/tty— fallback when stdin/stdout are pipes. (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 "..."))