Files
cl-tty/org/backend-protocol.org
Hermes Agent 29f99a576d literate: restructure all 19 org files with per-function blocks and prose
Every function, defclass, defstruct, defgeneric, defmethod, defmacro,
defvar, and defparameter in every org file now has its own #+BEGIN_SRC
block with literate prose above it explaining the design reasoning.

Block counts before → after:
  package.org:           1 → 7
  container-package.org: 1 → 1 (prose expanded)
  dirty.org:             4 → 6
  render.org:           10 → 25
  theme.org:             6 → 19
  box-renderable.org:    9 → 29
  scrollbox.org:         8 → 26
  tabbar.org:            5 → 10
  backend-protocol.org:  8 → 66
  modern-backend.org:   17 → 53
  detection.org:         4 → 6
  layout-engine.org:     9 → 36
  framebuffer.org:       8 → 37
  markdown-renderer.org:13 → 38
  dialog.org:           17 → 23 (merged dual structure)
  mouse.org:             4 → 25
  select.org:           12 → 30
  slot.org:              4 → 12
  text-input.org:       11 → 53

Total: ~153 blocks → ~502 blocks

Bugs fixed during restructuring:
- render.org: stray π character typo (backenπd → backend)
- modern-backend.org: sgr-attr missing closing paren + #+END_SRC
- detection.org: invalid #\Esc character reference
- select.org: extra closing paren in select-visible-options

All 13 test suites pass at 100%.
2026-05-12 18:55:07 +00:00

30 KiB

cl-tty Backend Protocol — v0.0.1

Overview

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

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

Contract

Backend Lifecycle

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

Rendering Primitives

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

Cursor Operations

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

Synchronization

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

Input

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

Capability Queries

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

Backend Classes

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

Tests

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

Test Package and Suite

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

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

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

Capturing Backend Helper

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

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

Test Runner Entry Point

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

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

Simple Backend Lifecycle

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

(test simple-backend-lifecycle
  "simple-backend can be created and shut down"
  (let ((b (make-simple-backend)))
    (is (typep b 'simple-backend))
    (initialize-backend b)
    (is-false (capable-p b :truecolor) "simple backend has no truecolor")
    (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
   #: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)

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

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

Backend Size (Simple)

Returns hard-coded 80x24 dimensions. A real implementation would use ioctl or TIOCGWINSZ, but the simple backend avoids OS-specific calls for maximum portability.

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

Backend Write (Simple)

Writes a string to the backend's output stream, forces the stream to flush, and returns the length of the string. Uses finish-output to ensure the data is actually sent, which matters for pipe and network output.

(defmethod backend-write ((b simple-backend) string)
  (let ((stream (backend-output-stream b)))
    (write-string string stream)
    (finish-output 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)
  (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 "..."))