Implement the backend protocol with two backends (modern planned, simple done). Includes package definitions, CLOS generic protocol, simple-backend with ASCII borders, and 9 FiveAM tests. RED: 9/9 tests failing (no implementation) GREEN: 9/9 tests passing - backend/package.lisp — defpackage, exports - backend/classes.lisp — backend base class, 18 generics - backend/simple.lisp — simple-backend implementation - backend/tests.lisp — 9 FiveAM test cases - org/backend-protocol.org — literate source
12 KiB
cl-tui Architecture
- 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
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 │ │
│ └─────────────┘ └─────────────────────────┘ │
└───────────────────────────────────────────────┘
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 featuressimple-backend— ASCII art, no color, universal compatibilitybackend— 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
;; ── 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)
Style structure
All drawing functions accept a style plist that is resolved through
the theme engine before reaching the backend:
(: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
The backend receives resolved hex colors, not semantic roles. Theme resolution happens in the pipeline layer, before backend dispatch.
Backend Selection
At startup:
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
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:
- Column direction (vertical pass): distribute Y positions, sum children heights. Width is inherited from parent (minus padding).
- 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
(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
Composable API
(vbox (:gap 1 :padding 1)
(header "Title")
(hbox (:grow 1)
(sidebar (:width 30) ...)
(content ...)))
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
(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
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
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
Input Handling
Input goes through a layered keybinding system:
- Terminal emits escape sequences → parser converts to events
- Events dispatched through layers:
:global→:local→:focused - Focused component's
handle-keyfires first - Unconsumed events bubble to
:localkeymap, then:global - 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
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
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:
- Backend — detect, initialize
- Theme — load default preset
- Layout — construct component tree
- Render — layout → commands → flush
- Input — event loop (blocks on read-event)