v0.0.1: Backend Protocol — abstraction layer + simple backend #2
60
backend/classes.lisp
Normal file
60
backend/classes.lisp
Normal file
@@ -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))
|
||||
22
backend/package.lisp
Normal file
22
backend/package.lisp
Normal file
@@ -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)
|
||||
62
backend/simple.lisp
Normal file
62
backend/simple.lisp
Normal file
@@ -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 "..."))
|
||||
102
backend/tests.lisp
Normal file
102
backend/tests.lisp
Normal file
@@ -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)))
|
||||
24
cl-tui.asd
Normal file
24
cl-tui.asd
Normal file
@@ -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!)))
|
||||
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)
|
||||
@@ -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
|
||||
|
||||
382
org/backend-protocol.org
Normal file
382
org/backend-protocol.org
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user