v0.0.1: backend protocol — abstraction layer + simple backend
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
This commit is contained in:
318
docs/ARCHITECTURE.org
Normal file
318
docs/ARCHITECTURE.org
Normal file
@@ -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)
|
||||
Reference in New Issue
Block a user