diff --git a/backend/classes.lisp b/backend/classes.lisp new file mode 100644 index 0000000..8214110 --- /dev/null +++ b/backend/classes.lisp @@ -0,0 +1,60 @@ +(defclass backend () ()) + +(defgeneric initialize-backend (backend) + (:method ((b backend)) b)) + +(defgeneric shutdown-backend (backend) + (:method ((b backend)) (values))) + +(defgeneric backend-size (backend) + (:method ((b backend)) + (values 80 24))) + +(defgeneric backend-write (backend string)) + +(defgeneric backend-clear (backend) + (:method ((b backend)) + (backend-write b (format nil "~C[2J~C[H" #\Esc #\Esc)))) + +(defgeneric draw-text (backend x y string fg bg &key + bold italic underline reverse dim blink)) + +(defgeneric draw-border (backend x y width height + &key style fg bg title title-align)) + +(defgeneric draw-rect (backend x y width height &key bg)) + +(defgeneric draw-link (backend x y string url &key fg bg)) + +(defgeneric draw-ellipsis (backend x y width &key fg bg)) + +(defgeneric cursor-move (backend x y)) + +(defgeneric cursor-hide (backend) + (:method ((b backend)) (values))) + +(defgeneric cursor-show (backend) + (:method ((b backend)) (values))) + +(defgeneric cursor-style (backend shape &key blink) + (:method ((b backend) shape &key blink) (values))) + +(defgeneric begin-sync (backend) + (:method ((b backend)) (values))) + +(defgeneric end-sync (backend) + (:method ((b backend)) (values))) + +(defgeneric read-event (backend &key timeout) + (:method ((b backend) &key timeout) (values nil nil))) + +(defgeneric enable-mouse (backend) + (:method ((b backend)) (values))) + +(defgeneric enable-bracketed-paste (backend) + (:method ((b backend)) (values))) + +(defgeneric capable-p (backend feature) + (:method ((b backend) feature) + (declare (ignore feature)) + nil)) diff --git a/backend/package.lisp b/backend/package.lisp new file mode 100644 index 0000000..1a4c1da --- /dev/null +++ b/backend/package.lisp @@ -0,0 +1,22 @@ +(defpackage :cl-tui.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)) +(in-package :cl-tui.backend) diff --git a/backend/simple.lisp b/backend/simple.lisp new file mode 100644 index 0000000..5e482b8 --- /dev/null +++ b/backend/simple.lisp @@ -0,0 +1,62 @@ +(defclass simple-backend (backend) + ((output-stream :initform *standard-output* + :accessor backend-output-stream))) + +(defmethod initialize-backend ((b simple-backend)) + b) + +(defmethod shutdown-backend ((b simple-backend)) + (values)) + +(defmethod backend-size ((b simple-backend)) + ;; Try ioctl, fall back to 80x24 + (values 80 24)) + +(defmethod backend-write ((b simple-backend) string) + (let ((stream (backend-output-stream b))) + (write-string string stream) + (finish-output stream) + (length string))) + +(defmethod draw-text ((b simple-backend) x y string fg bg + &key bold italic underline reverse dim blink) + (declare (ignore x y fg bg bold italic underline reverse dim blink)) + (backend-write b string)) + +(defun %simple-border-char (edge-style pos) + "Return ASCII border character for EDGE-STYLE 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 #\|))) + +(defmethod draw-border ((b simple-backend) x y width height + &key style fg bg title title-align) + (declare (ignore style fg bg title title-align)) + (let ((h (%simple-border-char nil :horizontal)) + (v (%simple-border-char nil :vertical))) + ;; Top edge + (backend-write b (format nil "~%~v@{~a~:*~}" width h)) + ;; Sides + (loop for i from 1 below (1- height) + do (backend-write b (format nil "~%|~v@{~a~:*~}|" (- width 2) #\space))) + ;; Bottom edge + (backend-write b (format nil "~%~v@{~a~:*~}" width h)))) + +(defmethod draw-rect ((b simple-backend) x y width height + &key bg) + (declare (ignore x y width height bg)) + ;; On simple backend, background fill is a no-op + (values)) + +(defmethod draw-link ((b simple-backend) x y string url + &key fg bg) + (declare (ignore url fg bg)) + (draw-text b x y string nil nil)) + +(defmethod draw-ellipsis ((b simple-backend) x y width + &key fg bg) + (declare (ignore x y width fg bg)) + (backend-write b "...")) diff --git a/backend/tests.lisp b/backend/tests.lisp new file mode 100644 index 0000000..fd9b7f9 --- /dev/null +++ b/backend/tests.lisp @@ -0,0 +1,102 @@ +(defpackage :cl-tui-backend-test + (:use :cl :fiveam :cl-tui.backend) + (:export #:run-tests)) +(in-package :cl-tui-backend-test) + +(def-suite backend-suite :description "Backend protocol tests") +(in-suite backend-suite) + +;; ── Simple Backend ────────────────────────────────────────────── + +(defun run-tests () + "Run all backend tests." + (let ((result (run 'backend-suite))) + (fiveam:explain! result) + (uiop:quit 0))) + +(test simple-backend-lifecycle + "simple-backend can be created and shut down" + (let ((b (make-simple-backend))) + (is (typep b 'simple-backend)) + (initialize-backend b) + (is (capable-p b :truecolor) nil "simple backend has no truecolor") + (shutdown-backend b))) + +(test simple-backend-draw-text + "simple-backend renders text at position, ignoring style" + (let ((b (make-simple-backend))) + (initialize-backend b) + (draw-text b 0 0 "hello" nil nil) + (shutdown-backend b) + (is-t t))) + +(test simple-backend-border-single + "simple-backend draws ASCII single border" + (let ((b (make-simple-backend))) + (initialize-backend b) + (draw-border b 0 0 10 5 :style :single) + (shutdown-backend b) + (is-t t))) + +(test simple-backend-border-rounded + "simple-backend falls back to straight edges for rounded" + (let ((b (make-simple-backend))) + (initialize-backend b) + (draw-border b 0 0 10 5 :style :rounded) + (shutdown-backend b) + (is-t t))) + +;; ── Backend Capabilities ─────────────────────────────────────── + +(test capable-p-known-features + "capable-p returns nil for all features on simple-backend" + (let ((b (make-simple-backend))) + (initialize-backend b) + (dolist (f '(:truecolor :osc8 :sync :mouse :bracketed-paste + :kitty-keyboard :sixel :cursor-style)) + (is (capable-p b f) nil + (format nil "~s should not be supported on simple-backend" f))) + (shutdown-backend b))) + +;; ── Backend Size ─────────────────────────────────────────────── + +(test backend-size-returns-integers + "backend-size returns two integer values" + (let ((b (make-simple-backend))) + (initialize-backend b) + (multiple-value-bind (cols lines) (backend-size b) + (is (integerp cols)) + (is (integerp lines)) + (is (>= cols 10)) + (is (>= lines 3))) + (shutdown-backend b))) + +;; ── Drawing Primitives ───────────────────────────────────────── + +(test draw-rect-fills-area + "draw-rect fills a rectangular area with background" + (let ((b (make-simple-backend))) + (initialize-backend b) + (draw-rect b 0 0 5 3 :bg nil) + (shutdown-backend b) + (is-t t))) + +(test draw-text-multi-line + "draw-text handles strings with newlines" + (let ((b (make-simple-backend))) + (initialize-backend b) + (draw-text b 0 0 "line1~%line2" nil nil) + (shutdown-backend b) + (is-t t))) + +;; ── Synchronization ──────────────────────────────────────────── + +(test sync-is-noop-on-simple + "begin-sync and end-sync are no-ops on simple-backend" + (let ((b (make-simple-backend))) + (initialize-backend b) + (begin-sync b) + (draw-text b 0 0 "in sync" nil nil) + (end-sync b) + (shutdown-backend b) + (is-t t))) diff --git a/cl-tui.asd b/cl-tui.asd new file mode 100644 index 0000000..83034d1 --- /dev/null +++ b/cl-tui.asd @@ -0,0 +1,24 @@ +;;; cl-tui.asd — Common Lisp Terminal UI Framework +(asdf:defsystem :cl-tui + :description "Reusable Common Lisp Terminal UI Framework" + :author "Amr Gharbeia" + :version "0.0.1" + :license "TBD" + :depends-on (:fiveam) + :components + ((:module "backend" + :components + ((:file "package") + (:file "classes" :depends-on ("package")) + (:file "simple" :depends-on ("package" "classes"))))) + :in-order-to ((test-op (test-op :cl-tui-tests)))) + +(asdf:defsystem :cl-tui-tests + :description "Test suite for cl-tui" + :depends-on (:cl-tui :fiveam) + :components + ((:module "backend" + :components + ((:file "tests")))) + :perform (test-op (o c) + (uiop:symbol-call :cl-tui-backend-test '#:run!))) diff --git a/docs/ARCHITECTURE.org b/docs/ARCHITECTURE.org new file mode 100644 index 0000000..7234f63 --- /dev/null +++ b/docs/ARCHITECTURE.org @@ -0,0 +1,318 @@ +#+TITLE: cl-tui Architecture +#+STARTUP: content +#+FILETAGS: :project:cl-tui:architecture: + +* Architecture + +cl-tui is a layered framework. Each layer has a single responsibility +and communicates with adjacent layers through a well-defined protocol. + +** Layer Diagram + +#+BEGIN_SRC + Application Code (user's CL project) + ┌───────────────────────────────────────────────┐ + │ Component Tree │ + │ (user constructs via macros: vbox, hbox, │ + │ text, box, select, markdown, etc.) │ + └──────────────┬────────────────────────────────┘ + │ defgeneric render (component backend) + │ defgeneric handle-key (component event) + │ defgeneric handle-mouse (component event) + ▼ + ┌───────────────────────────────────────────────┐ + │ Rendering Pipeline │ + │ 1. Layout pass (constraint solve) │ + │ 2. Dirty walk (only changed branches) │ + │ 3. Render commands (component → cmds) │ + │ 4. Framebuffer diff (changed cells only) │ + └──────────────┬────────────────────────────────┘ + │ Render commands: + │ (:box x y w h style) + │ (:text x y str fg bg attrs) + │ (:rect x y w h ch) + ▼ + ┌───────────────────────────────────────────────┐ + │ Backend Protocol │ + │ ┌─────────────┐ ┌─────────────────────────┐ │ + │ │ modern │ │ simple │ │ + │ │ truecolor │ │ ASCII borders │ │ + │ │ rounded │ │ no color │ │ + │ │ OSC 8 links │ │ universal compatibility │ │ + │ │ DECICM sync │ │ SSH-safe │ │ + │ │ kitty proto │ │ pipe-safe │ │ + │ └─────────────┘ └─────────────────────────┘ │ + └───────────────────────────────────────────────┘ +#+END_SRC + +** The Backend Protocol + +The backend protocol is the central abstraction. Every rendering +operation is a generic function dispatched on the backend class. + +*** Backend Classes + +- =modern-backend= — raw escape sequences, truecolor, modern features +- =simple-backend= — ASCII art, no color, universal compatibility +- =backend= — abstract base (both inherit from this) + +Backend selection happens once at startup, via terminal capability +detection. The same component tree renders correctly on both. + +*** Backend Generic Functions + +#+BEGIN_SRC + ;; ── Lifecycle ── + (initialize-backend backend) → setup terminal, enable features + (shutdown-backend backend) → restore terminal, cleanup + (suspend-backend backend) → temporary suspend (SIGTSTP) + (resume-backend backend) → re-initialize after resume + + ;; ── Output ── + (backend-size backend) → (values columns lines) + (backend-write backend string) → raw output to terminal + (begin-sync backend) → start synchronized update + (end-sync backend) → flush synchronized update + (backend-clear backend) → clear entire screen + + ;; ── Drawing primitives ── + (draw-rect backend x y w h ch style) → fill rectangle + (draw-text backend x y str fg bg attrs) → render text at position + (draw-border backend x y w h style attrs) → draw border rectangle + (draw-ellipsis backend x y w) → truncated text marker + (draw-link backend x y str url fg bg attrs) → OSC 8 hyperlink + + ;; ── Cursor ── + (cursor-move backend x y) → position cursor + (cursor-hide backend) → hide cursor + (cursor-show backend) → show cursor + (cursor-style backend :bar|:block|:underline &optional blink) + + ;; ── Input ── + (read-event backend) → (values event-type event-data) + (enable-mouse backend) → enable SGR mouse reporting + (enable-bracketed-paste backend) → enable paste detection + (set-keyboard-mode backend :kitty|:default) + + ;; ── Capability queries ── + (capable-p backend :truecolor|:osc8|:kitty-keyboard|:sync|:sixel|:mouse) +#+END_SRC + +*** Style structure + +All drawing functions accept a =style= plist that is resolved through +the theme engine before reaching the backend: + +#+BEGIN_SRC + (:fg :error ; semantic role from theme + :bg :background-panel ; resolved to hex by theme + :bold t + :italic nil + :underline nil + :blink nil + :reverse nil + :dim nil + :hyperlink-url nil) ; OSC 8 URL if set +#+END_SRC + +The backend receives resolved hex colors, not semantic roles. Theme +resolution happens in the pipeline layer, before backend dispatch. + +*** Backend Selection + +At startup: + +#+BEGIN_SRC + 1. Check if stdout is a TTY (if not → simple-backend) + 2. Send DA1 query: ESC [ c + - No response within 100ms → simple-backend + - Response parsed → check for modern features + 3. Try DA3 (secondary device attributes): + - Kitty reports "Kitty" + protocol version + - WezTerm reports "WezTerm" + - iTerm2 reports specific codes + 4. Query DECRPM for DECICM sync: + - ESC [?2026$p + - Response ESC [?2026;1$y = supported + 5. If sync + truecolor + kitty keyboard → modern-backend + Otherwise → simple-backend +#+END_SRC + +** Layout Engine + +The layout engine is pure Common Lisp — no Yoga FFI, no C dependencies. + +*** Constraint Model + +A terminal has ~200x80 cells. The constraint solver only needs to +handle one-dimensional layout in two passes: + +1. **Column direction (vertical pass):** distribute Y positions, sum + children heights. Width is inherited from parent (minus padding). +2. **Row direction (horizontal pass):** distribute X positions, sum + children widths. Height is inherited from parent. + +Flex properties: +- =:grow= — proportional distribution of remaining space +- =:shrink= — proportional reduction when content overflows +- =:basis= — initial size before grow/shrink +- =:wrap= — overflow moves to next row/column +- =:gap= — spacing between children + +Position properties: +- =:relative= — normal flow (default) +- =:absolute= — positioned relative to parent's content box +- =:top=, =:right=, =:bottom=, =:left= — offset for absolute + +This is a subset of CSS Flexbox. Enough for every TUI layout pattern +(sidebar + content, toolbar + main + status, dialog overlay, tab +navigation, split panes). ~200 lines. + +*** Layout Node + +#+BEGIN_SRC + (defclass layout-node () + ;; Computed by solver + (x y width height ; computed position + size + children ; list of child layout-nodes + parent ; parent layout-node (or nil for root) + ;; Style input + direction ; :row | :column | :row-reverse | :column-reverse + wrap ; :nowrap | :wrap | :wrap-reverse + grow shrink basis ; flex sizing + align-self align-items ; cross-axis alignment + justify-content ; main-axis distribution + padding margin border ; box model + gap ; spacing between children + position-type ; :relative | :absolute + position-offset)) ; top/left for absolute +#+END_SRC + +*** Composable API + +#+BEGIN_SRC + (vbox (:gap 1 :padding 1) + (header "Title") + (hbox (:grow 1) + (sidebar (:width 30) ...) + (content ...))) +#+END_SRC + +Macros expand to layout-node construction + child wiring. + +** Component Tree + +Components are CLOS objects. Every component has a =layout-node= +slot that drives positioning. Components define =render= methods. + +*** Base Component Class + +#+BEGIN_SRC + (defclass component () + (layout-node ; layout-node for this component + parent ; parent component (or nil for root) + children ; list of child components + dirty ; t/nil — needs re-render + theme ; theme reference + visible)) ; t/nil +#+END_SRC + +*** Generic Functions + +- =(render component backend)= — returns list of render commands +- =(handle-key component event)= — returns t if consumed +- =(handle-mouse component event)= — returns t if consumed +- =(measure component max-width max-height)= — returns (values w h) +- =(children component)= — returns list of child components +- =(find-focused component)= — returns the focused child (or nil) + +*** Rendering Pipeline + +#+BEGIN_SRC + 1. (propagate-dirty root) → mark ancestors dirty + 2. (compute-layout root w h) → pure CL constraint solve + 3. (collect-commands root) → walk dirty branches, call render + 4. (diff-framebuffer prev curr) → emit only changed cells + 5. (begin-sync backend) → DECICM start + 6. (flush-commands backend) → write escape sequences + 7. (end-sync backend) → DECICM end + 8. (clear-dirty root) → mark all clean +#+END_SRC + +** Input Handling + +Input goes through a layered keybinding system: + +1. Terminal emits escape sequences → parser converts to events +2. Events dispatched through layers: =:global= → =:local= → =:focused= +3. Focused component's =handle-key= fires first +4. Unconsumed events bubble to =:local= keymap, then =:global= +5. Modal layers (dialog) intercept before global + +Mouse events follow the same path, with hit-testing routing to the +deepest component containing the click coordinates. + +** Theme Engine + +Semantic tokens → hex colors → backend color pairs. No code references +hex values directly. =:accent= resolves to gold in default theme, blue +in nord, green in gruvbox, depending on which preset is active. + +Presets define both =:dark= and =:light= variants. Auto-detection +reads terminal background color at startup. + +** File Structure + +#+BEGIN_SRC + cl-tui/ + ├── cl-tui.asd + ├── cl-tui-tests.asd + ├── README.org + ├── LICENSE + ├── docs/ + │ ├── ROADMAP.org + │ └── ARCHITECTURE.org ← this file + ├── src/ + │ ├── package.lisp + │ ├── backend/ + │ │ ├── protocol.lisp + │ │ ├── detection.lisp + │ │ ├── simple.lisp + │ │ └── modern.lisp + │ ├── layout/ + │ │ ├── nodes.lisp + │ │ ├── solver.lisp + │ │ └── api.lisp + │ ├── components/ + │ │ ├── base.lisp + │ │ ├── box.lisp + │ │ └── text.lisp + │ ├── rendering/ + │ │ ├── pipeline.lisp + │ │ ├── dirty.lisp + │ │ └── diff.lisp + │ └── theme/ + │ ├── tokens.lisp + │ └── presets.lisp + └── tests/ + ├── package.lisp + ├── backend.lisp + ├── layout.lisp + └── components.lisp +#+END_SRC + +** Dependency Graph + + backend/ (no deps) + layout/ (no deps — pure math) + theme/ (backend for color resolution) + components/ (layout, theme, rendering) + rendering/ (layout, components, backend, theme) + input/ (backend for raw events) + +Init order: + 1. Backend — detect, initialize + 2. Theme — load default preset + 3. Layout — construct component tree + 4. Render — layout → commands → flush + 5. Input — event loop (blocks on read-event) diff --git a/docs/ROADMAP.org b/docs/ROADMAP.org index 1d1f0e5..c9d2bf0 100644 --- a/docs/ROADMAP.org +++ b/docs/ROADMAP.org @@ -5,10 +5,81 @@ * The Roadmap Each phase is one minor release. Phases ship in dependency order — each depends on -the components from prior phases. The layout engine ships first because everything -else builds on it. +the components from prior phases. The backend protocol ships first because +everything else builds on it. -Feature releases increment the minor version (v0.X.0). Bugfix releases increment +** v0.0.1: Foundation — Backend Protocol + +The abstraction layer that makes everything portable. Two backends: +=modern= (raw escape sequences, truecolor, modern features) and =simple= +(ASCII art, universal compatibility). The component tree never touches +the terminal directly — it dispatches through the protocol. + +*** TODO Backend protocol definition +:PROPERTIES: +:ID: id-v000-protocol +:CREATED: [2026-05-10 Sat] +:END: + +- Define =backend= abstract class with generic functions: + - =initialize-backend=, =shutdown-backend=, =suspend-backend=, =resume-backend= + - =backend-size=, =backend-write=, =backend-clear= + - =begin-sync=, =end-sync= — DECICM synchronized updates + - =draw-rect=, =draw-text=, =draw-border=, =draw-ellipsis=, =draw-link= + - =cursor-move=, =cursor-hide=, =cursor-show=, =cursor-style= + - =read-event=, =enable-mouse=, =enable-bracketed-paste=, =set-keyboard-mode= + - =capable-p= — query feature support +- Style plist structure: ~(:fg :error :bg :background-panel :bold t :italic nil ...)~ +- ~100 lines + +*** TODO Simple backend +:PROPERTIES: +:ID: id-v000-simple +:CREATED: [2026-05-10 Sat] +:END: + +- =simple-backend= class — inherits =backend= +- Borders: ASCII (~+-|~), no rounded corners +- No color, no bold/italic — plain characters only +- No OSC 8 links, no mouse, no synchronized updates +- Works on any terminal, any SSH connection, piped output +- ~100 lines + +*** TODO Modern backend +:PROPERTIES: +:ID: id-v000-modern +:CREATED: [2026-05-10 Sat] +:END: + +- =modern-backend= class — inherits =backend= +- Truecolor 24-bit foreground/background +- Rounded, single, double border styles via Unicode box-drawing +- OSC 8 hyperlinks (clickable URLs) +- DECICM synchronized updates (flicker-free) +- SGR mouse tracking + kitty keyboard protocol +- Bracketed paste detection +- Bold, italic, underline, dim, blink, reverse, strikethrough +- Cursor style: =:bar=, =:block=, =:underline=, with blink option +- ~250 lines + +*** TODO Terminal capability detection +:PROPERTIES: +:ID: id-v000-detection +:CREATED: [2026-05-10 Sat] +:END: + +- =detect-backend= → returns =modern-backend= or =simple-backend= +- Check if stdout is a TTY (if not → =simple-backend=) +- Send DA1 (~ESC[c~) query, 100ms timeout +- Send DA3 (~ESC[?c~) for kitty/wezterm identification +- Query DECRPM (~ESC[?2026$p~) for DECICM sync support +- Query truecolor support via =COLORTERM= env var + DA response +- Cache detection result so subsequent calls are instant +- ~100 lines + +~550 lines total. Dependencies: None (pure CL, no FFI, no external libs). + +** v0.0.2: Layout Engine the patch version (v0.X.Y). ** File Update Checklist diff --git a/org/backend-protocol.org b/org/backend-protocol.org new file mode 100644 index 0000000..f1830fe --- /dev/null +++ b/org/backend-protocol.org @@ -0,0 +1,382 @@ +#+TITLE: cl-tui Backend Protocol — v0.0.1 +#+STARTUP: content +#+FILETAGS: :cl-tui:backend:v0.0.1: +#+OPTIONS: ^:nil + +* Backend Protocol + +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). + +** 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 integer integer) + 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 + Move cursor to position (x, y). Origin is top-left (0,0). + +- =(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. + Returns event type keyword and event data plist. + +- =(enable-mouse backend)= → nil + Enable SGR mouse tracking (press, release, drag, scroll). + +- =(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 + +*** Simple Backend + +=(make-simple-backend)= → simple-backend + +The minimal backend. ASCII borders, no color, no modern features. +Works everywhere — SSH, serial, pipes, ancient terminals. + +Borders: +- Single: + - | +- Double: + = | +- Rounded: + - | (same as single — no rounded chars) + +No color, no bold, no italic, no links, no mouse, no sync. + +*** Modern Backend + +=(make-modern-backend)= → modern-backend + +Full-featured backend. Truecolor, Unicode box-drawing, OSC 8 links, +DECICM sync, mouse tracking, kitty keyboard protocol. + +Borders: +- Single: ┌ ─ ┐ │ └ ┘ +- Double: ╔ ═ ╗ ║ ╚ ╝ +- Rounded: ╭ ─ ╮ │ ╰ ╯ + +** Test Suite + +#+BEGIN_SRC lisp +(defpackage :cl-tui-backend-test + (:use :cl :fiveam) + (:export #:run!)) +(in-package :cl-tui-backend-test) + +(def-suite backend-suite :description "Backend protocol tests") +(in-suite backend-suite) + +;; ── 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 (capable-p b :truecolor) nil "simple backend has no truecolor") + (shutdown-backend b))) + +(test simple-backend-draw-text + "simple-backend renders text at position, ignoring style" + (let ((b (make-simple-backend))) + (initialize-backend b) + (draw-text b 0 0 "hello" nil nil) + ;; No crash = pass (simple backend writes to *standard-output*) + (shutdown-backend b) + (is-t t))) + +(test simple-backend-border-single + "simple-backend draws ASCII single border" + (let ((b (make-simple-backend))) + (initialize-backend b) + (draw-border b 0 0 10 5 :style :single) + (shutdown-backend b) + (is-t t))) + +(test simple-backend-border-rounded + "simple-backend falls back to straight edges for rounded" + (let ((b (make-simple-backend))) + (initialize-backend b) + (draw-border b 0 0 10 5 :style :rounded) + ;; No error — rounded falls back to single on simple + (shutdown-backend b) + (is-t t))) + +;; ── Backend Capabilities ─────────────────────────────────────── + +(test capable-p-known-features + "capable-p returns nil for all features on simple-backend" + (let ((b (make-simple-backend))) + (initialize-backend b) + (dolist (f '(:truecolor :osc8 :sync :mouse :bracketed-paste + :kitty-keyboard :sixel :cursor-style)) + (is (capable-p b f) nil + (format nil "~s should not be supported on simple-backend" f))) + (shutdown-backend b))) + +;; ── Backend Size ─────────────────────────────────────────────── + +(test backend-size-returns-integers + "backend-size returns two integer values" + (let ((b (make-simple-backend))) + (initialize-backend b) + (multiple-value-bind (cols lines) (backend-size b) + (is (integerp cols)) + (is (integerp lines)) + (is (>= cols 10)) + (is (>= lines 3))) + (shutdown-backend b))) + +;; ── Drawing Primitives ───────────────────────────────────────── + +(test draw-rect-fills-area + "draw-rect fills a rectangular area with background" + (let ((b (make-simple-backend))) + (initialize-backend b) + (draw-rect b 0 0 5 3 :bg nil) + (shutdown-backend b) + (is-t t))) + +(test draw-text-multi-line + "draw-text handles strings with newlines" + (let ((b (make-simple-backend))) + (initialize-backend b) + (draw-text b 0 0 "line1~%line2" nil nil) + (shutdown-backend b) + (is-t t))) + +;; ── Synchronization ──────────────────────────────────────────── + +(test sync-is-noop-on-simple + "begin-sync and end-sync are no-ops on simple-backend" + (let ((b (make-simple-backend))) + (initialize-backend b) + (begin-sync b) + (draw-text b 0 0 "in sync" nil nil) + (end-sync b) + (shutdown-backend b) + (is-t t))) +#+END_SRC + +** Implementation + +*** Package + +#+BEGIN_SRC lisp +(defpackage :cl-tui.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)) +(in-package :cl-tui.backend) +#+END_SRC + +*** Backend Base Class + +#+BEGIN_SRC lisp +(defclass backend () ()) + +(defgeneric initialize-backend (backend) + (:method ((b backend)) b)) + +(defgeneric shutdown-backend (backend) + (:method ((b backend)) (values))) + +(defgeneric backend-size (backend) + (:method ((b backend)) + (values 80 24))) + +(defgeneric backend-write (backend string)) + +(defgeneric backend-clear (backend) + (:method ((b backend)) + (backend-write b (string #\escape) "[2J") + (cursor-move b 0 0))) + +(defgeneric draw-text (backend x y string fg bg &key + bold italic underline reverse dim blink)) + +(defgeneric draw-border (backend x y width height + &key style fg bg title title-align)) + +(defgeneric draw-rect (backend x y width height &key bg)) + +(defgeneric draw-link (backend x y string url &key fg bg)) + +(defgeneric draw-ellipsis (backend x y width &key fg bg)) + +(defgeneric cursor-move (backend x y)) + +(defgeneric cursor-hide (backend) + (:method ((b backend)) (values))) + +(defgeneric cursor-show (backend) + (:method ((b backend)) (values))) + +(defgeneric cursor-style (backend shape &key blink) + (:method ((b backend) shape &key blink) (values))) + +(defgeneric begin-sync (backend) + (:method ((b backend)) (values))) + +(defgeneric end-sync (backend) + (:method ((b backend)) (values))) + +(defgeneric read-event (backend &key timeout) + (:method ((b backend) &key timeout) (values nil nil))) + +(defgeneric enable-mouse (backend) + (:method ((b backend)) (values))) + +(defgeneric enable-bracketed-paste (backend) + (:method ((b backend)) (values))) + +(defgeneric capable-p (backend feature) + (:method ((b backend) feature) + (declare (ignore feature)) + nil)) +#+END_SRC + +*** Simple Backend + +#+BEGIN_SRC lisp +(defclass simple-backend (backend) + ((output-stream :initform *standard-output* + :accessor backend-output-stream))) + +(defmethod initialize-backend ((b simple-backend)) + b) + +(defmethod shutdown-backend ((b simple-backend)) + (values)) + +(defmethod backend-size ((b simple-backend)) + ;; Try ioctl, fall back to 80x24 + (values 80 24)) + +(defmethod backend-write ((b simple-backend) string) + (let ((stream (backend-output-stream b))) + (write-string string stream) + (finish-output stream) + (length string))) + +(defmethod draw-text ((b simple-backend) x y string fg bg + &key bold italic underline reverse dim blink) + (declare (ignore x y fg bg bold italic underline reverse dim blink)) + (backend-write b string)) + +(defun %simple-border-char (edge-style pos) + "Return ASCII border character for EDGE-STYLE 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 #\|))) + +(defmethod draw-border ((b simple-backend) x y width height + &key style fg bg title title-align) + (declare (ignore style fg bg title title-align)) + (let ((h (%simple-border-char nil :horizontal)) + (v (%simple-border-char nil :vertical))) + ;; Top edge + (backend-write b (format nil "~%~v@{~a~:*~}" width h)) + ;; Sides + (loop for i from 1 below (1- height) + do (backend-write b (format nil "~%|~v@{~a~:*~}|" (- width 2) #\space))) + ;; Bottom edge + (backend-write b (format nil "~%~v@{~a~:*~}" width h)))) + +(defmethod draw-rect ((b simple-backend) x y width height + &key bg) + (declare (ignore x y width height bg)) + ;; On simple backend, background fill is a no-op + (values)) + +(defmethod draw-link ((b simple-backend) x y string url + &key fg bg) + (declare (ignore url fg bg)) + (draw-text b x y string nil nil)) + +(defmethod draw-ellipsis ((b simple-backend) x y width + &key fg bg) + (declare (ignore x y width fg bg)) + (backend-write b "...")) +#+END_SRC