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