Demo (demo.lisp):
- Full interactive demo with 3 tabs: Home, Widgets, Console
- Uses read-event/SGR mouse paths (exercises real terminal input)
- Demonstrates text-input, textarea, backend drawing, tab navigation
- Event log console shows keyboard and mouse events in real time
- Proper terminal cleanup via shutdown-backend + unwind-protect
README.org:
- Complete rewrite with getting-started guide, architecture overview
- API reference for all components with signatures and examples
- Event loop pattern, layout system, rendering pipeline docs
- Backend features table, development guide, project structure
Bug fixes:
- read-raw-byte (input.lisp:89-109): use sb-sys:with-pinned-objects +
vector-sap for proper sb-posix:read buffer handling (SBCL type error
with plain (unsigned-byte 8) arrays)
- input-package.lisp: export textarea-lines (was missing from package)
Version bump: v0.14.0 → v0.15.0
392 tests pass.
378 lines
12 KiB
Org Mode
378 lines
12 KiB
Org Mode
# cl-tty — Terminal UI Framework for Common Lisp
|
|
|
|
Pure CL terminal UI framework. No ncurses, no FFI, no external dependencies.
|
|
|
|
```lisp
|
|
(ql:quickload :cl-tty)
|
|
```
|
|
|
|
## Quick start
|
|
|
|
The simplest possible cl-tty program — detect the terminal, draw some text,
|
|
read a key, and shut down:
|
|
|
|
```lisp
|
|
(sb-posix:with-raw-terminal
|
|
(let* ((be (cl-tty.backend:detect-backend))
|
|
(w 80) (h 24))
|
|
(cl-tty.backend:initialize-backend be)
|
|
(unwind-protect
|
|
(progn
|
|
(cl-tty.backend:draw-text be 0 0 "Hello, terminal!" :green nil :bold t)
|
|
(cl-tty.backend:draw-border be 0 1 30 5 :style :single)
|
|
(finish-output)
|
|
;; Read one key (blocks)
|
|
(cl-tty.input:read-event be))
|
|
(cl-tty.backend:shutdown-backend be))))
|
|
```
|
|
|
|
Or run the full interactive demo:
|
|
|
|
```bash
|
|
sbcl --script demo.lisp
|
|
```
|
|
|
|
## Architecture
|
|
|
|
Two backends, one protocol:
|
|
|
|
- **modern-backend** — truecolor 24-bit, OSC 8 hyperlinks, DECICM sync,
|
|
SGR mouse, kitty keyboard, bold/italic/underline, box-drawing chars
|
|
- **simple-backend** — ASCII art, no color, universal compatibility (pipe-safe)
|
|
|
|
Everything is pure escape sequences (no curses, no terminfo, no FFI).
|
|
|
|
### Backend protocol
|
|
|
|
Every drawing operation is a CLOS generic function dispatched on the backend
|
|
class. Programs never call terminal codes directly:
|
|
|
|
```lisp
|
|
;; Lifecycle
|
|
(initialize-backend backend)
|
|
(shutdown-backend backend)
|
|
|
|
;; Drawing
|
|
(draw-text backend x y string fg bg &key bold italic underline reverse dim)
|
|
(draw-border backend x y width height &key style fg bg title)
|
|
(draw-rect backend x y width height &key bg)
|
|
(draw-link backend x y string url &key fg bg)
|
|
|
|
;; Input
|
|
(read-event backend &key timeout) → key-event or mouse-event
|
|
(backend-size backend) → (values columns lines)
|
|
|
|
;; Cursor
|
|
(cursor-move backend x y)
|
|
(cursor-hide backend)
|
|
(cursor-show backend)
|
|
(cursor-style backend shape &key blink) ;; :bar :block :underline
|
|
```
|
|
|
|
### Event loop pattern
|
|
|
|
```lisp
|
|
(let ((be (detect-backend)))
|
|
(initialize-backend be)
|
|
(loop with running = t
|
|
while running
|
|
do (backend-clear be)
|
|
;; ... draw frame ...
|
|
(finish-output *standard-output*)
|
|
(let ((event (read-event be)))
|
|
(typecase event
|
|
(key-event
|
|
(when (eql (key-event-key event) :escape)
|
|
(setf running nil)))
|
|
(mouse-event
|
|
;; handle mouse
|
|
))))
|
|
(shutdown-backend be))
|
|
```
|
|
|
|
### Layout system
|
|
|
|
Pure CL flexbox layout engine. No C dependencies, no Yoga FFI.
|
|
|
|
```lisp
|
|
;; Macros build layout-trees:
|
|
(vbox (:gap 1 :padding 1)
|
|
(header "Title")
|
|
(hbox (:grow 1)
|
|
(sidebar (:width 30) ...)
|
|
(content ...)))
|
|
```
|
|
|
|
Layout properties: `:direction` (`:row` / `:column`), `:grow`, `:shrink`,
|
|
`:basis`, `:gap`, `:padding`, `:margin`, `:width`, `:height`, `:wrap`.
|
|
|
|
See `layout/layout.lisp` or `org/layout-engine.org` for the full API.
|
|
|
|
### Rendering pipeline
|
|
|
|
Component trees render through a coordinated pipeline:
|
|
|
|
1. **Layout pass** — `compute-layout` traverses dirty branches, solves flex constraints
|
|
2. **Render dispatch** — `render` generic dispatches per component type
|
|
3. **Framebuffer** — (optional) `make-framebuffer-backend` captures to a cell array,
|
|
`diff-framebuffers` computes minimal changes, `flush-framebuffer` writes only
|
|
changed cells
|
|
|
|
```lisp
|
|
;; Full pipeline with framebuffer
|
|
(let* ((fb-be (make-framebuffer-backend :width 80 :height 24))
|
|
(fb (fb-framebuffer fb-be)))
|
|
(render my-component fb-be)
|
|
(flush-framebuffer prev-fb fb real-backend))
|
|
```
|
|
|
|
## Components
|
|
|
|
| Component | What it does | Status |
|
|
|-------------|------------------------------------------------------|--------|
|
|
| Box | Bordered container with background, title | stable |
|
|
| Text | Styled text with word-wrap, spans | stable |
|
|
| ScrollBox | Scrollable viewport with scrollbars | stable |
|
|
| TabBar | Horizontal tab navigation | stable |
|
|
| Select | Dropdown with fuzzy filter, category headers | stable |
|
|
| TextInput | Single-line text input with readline keybindings | stable |
|
|
| TextArea | Multi-line input with undo/redo, cursor movement | stable |
|
|
| Markdown | Renders markdown with syntax highlighting + diffs | stable |
|
|
| Dialog | Modal overlays with stack management | stable |
|
|
| Toast | Transient notifications (info/success/warning/error) | stable |
|
|
| Mouse | Event handlers, hit-testing, text selection | stable |
|
|
| Slot | Plugin system — named slots for extensible UI | stable |
|
|
|
|
Each component follows a consistent pattern:
|
|
|
|
```lisp
|
|
;; 1. Create — factory function returns instance
|
|
(let ((input (make-text-input :placeholder "Type here..."))
|
|
(box (make-box :border-style :single :title "My Box")))
|
|
|
|
;; 2. Layout — macros compose components
|
|
(vbox (:gap 1)
|
|
box
|
|
(hbox (:grow 1)
|
|
input
|
|
(make-select :options '((:title "Option A") (:title "Option B")))))
|
|
|
|
;; 3. Render — dispatches through the component protocol
|
|
(render my-component backend))
|
|
```
|
|
|
|
### Box
|
|
|
|
Bordered container. Draws borders using Unicode box-drawing characters
|
|
(modern) or ASCII `+`/`-`/`|` (simple). Supports background fill, titled
|
|
borders. See `org/box-renderable.org`.
|
|
|
|
```lisp
|
|
(make-box &key (border-style :single) title (title-align :left) fg bg width height)
|
|
```
|
|
|
|
### Text
|
|
|
|
Styled text with inline spans and word wrapping. Spans support per-run
|
|
attributes (bold, italic, underline, fg, bg). See `org/box-renderable.org`.
|
|
|
|
```lisp
|
|
(make-text content &key fg bg wrap-mode width height spans)
|
|
;; Span example:
|
|
(span "hello" :bold t :fg :bright-yellow)
|
|
```
|
|
|
|
### TextInput
|
|
|
|
Single-line text editor with emacs-style keybindings. Supports placeholder,
|
|
max-length, on-submit callback. See `org/text-input.org`.
|
|
|
|
```lisp
|
|
(make-text-input &key value cursor placeholder max-length on-submit)
|
|
;; Widget logic (input-level, no backend needed):
|
|
(handle-text-input input (make-key-event :key :a :code (char-code #\a)))
|
|
```
|
|
|
|
### TextArea
|
|
|
|
Multi-line text editor. Supports undo/redo (Ctrl+Z/Y), cursor movement,
|
|
line joining on backspace. See `org/text-input.org`.
|
|
|
|
```lisp
|
|
(make-textarea &key value on-submit)
|
|
```
|
|
|
|
### ScrollBox
|
|
|
|
Scrollable viewport with a list of children. Only renders children
|
|
intersecting the visible area (viewport culling). Scrollbars drawn
|
|
at the right/bottom edges. See `org/scrollbox-tabbar.org`.
|
|
|
|
```lisp
|
|
(make-scroll-box &key children scroll-y scroll-x sticky-scroll-p)
|
|
(scroll-by sb dy dx)
|
|
```
|
|
|
|
### TabBar
|
|
|
|
Horizontal tab navigation. Renders tab labels, highlights active tab.
|
|
Left/right arrows cycle through tabs. See `org/scrollbox-tabbar.org`.
|
|
|
|
```lisp
|
|
(make-tab-bar &key tabs active)
|
|
(tab-bar-add tb id title)
|
|
(tab-bar-next tb) / (tab-bar-prev tb)
|
|
(tab-bar-handle-key tb event)
|
|
```
|
|
|
|
### Select
|
|
|
|
Dropdown/filter widget. Options can have categories (rendered as
|
|
non-selectable headers). Fuzzy fallback: matching > 30% character
|
|
overlap. Arrow keys navigate, Enter selects. See `org/select.org`.
|
|
|
|
```lisp
|
|
(make-select &key options filter on-select)
|
|
;; Options format: (:title "Name" :category "Group") or (:title "Name")
|
|
```
|
|
|
|
### Markdown
|
|
|
|
Parsed markdown AST with rendering. Supports headings, paragraphs,
|
|
bold, italic, inline code, links, code blocks with syntax highlighting,
|
|
diff blocks, blockquotes, lists, thematic breaks. See
|
|
`org/markdown-renderer.org`.
|
|
|
|
```lisp
|
|
(render-markdown "# Hello\n\nThis is **bold**.")
|
|
```
|
|
|
|
### Dialog + Toast
|
|
|
|
Modal dialog stack. `alert-dialog`, `confirm-dialog`, `select-dialog`,
|
|
`prompt-dialog` are convenience constructors. Toasts are transient
|
|
notifications that auto-dismiss. See `org/dialog.org`.
|
|
|
|
```lisp
|
|
(push-dialog (make-instance 'dialog :size :medium))
|
|
(alert-dialog "Notice" "Operation complete")
|
|
(toast "Saved!" :variant :success)
|
|
```
|
|
|
|
### Mouse
|
|
|
|
Mixin class providing mouse event handler slots. `hit-test` finds the
|
|
deepest component at a coordinate. Text selection tracks drag gestures.
|
|
Scrollboxes integrate wheel events. See `org/mouse.org`.
|
|
|
|
```lisp
|
|
(defclass my-panel (mouse-mixin) ...)
|
|
(handle-mouse-event component mouse-event)
|
|
(hit-test root x y) → deepest matching component
|
|
```
|
|
|
|
### Slot system
|
|
|
|
Plugin system for extensible rendering slots. Register named rendering
|
|
functions, then render them by slot name. Useful for toolbars, status
|
|
bars, and plugin architectures.
|
|
|
|
```lisp
|
|
(defslot :status-bar :order 0
|
|
(lambda (&rest args)
|
|
(draw-text backend 0 0 "Ready" :text-muted nil)))
|
|
(slot-render :status-bar)
|
|
```
|
|
|
|
## Backend features
|
|
|
|
| Feature | modern | simple |
|
|
|-------------------|--------|--------|
|
|
| Truecolor (24-bit)| Yes | No |
|
|
| Bold/italic | Yes | No |
|
|
| OSC 8 hyperlinks | Yes | No |
|
|
| DECICM sync | Yes | No |
|
|
| SGR mouse | Yes | No |
|
|
| Kitty keyboard | Yes | No |
|
|
| Box drawing chars | Unicode| ASCII |
|
|
| Pipe-safe | No | Yes |
|
|
|
|
Backend selection happens automatically via `detect-backend`. It checks:
|
|
1. Is stdout a TTY? (if not → simple-backend)
|
|
2. Does `COLORTERM` contain "truecolor" or "24bit"?
|
|
3. Send DA1 query — does the terminal respond with modern feature codes?
|
|
|
|
Result is cached in `*detected-backend*`.
|
|
|
|
## Development
|
|
|
|
```bash
|
|
# Run all tests (392 checks, 12 suites)
|
|
sbcl --script run-all-tests.lisp
|
|
|
|
# Run interactive demo
|
|
sbcl --script demo.lisp
|
|
|
|
# Tangle org files (regenerate .lisp from .org sources)
|
|
for f in org/*.org; do
|
|
emacs --batch --eval "(progn (require 'org) (find-file \"$f\") (org-babel-tangle) (kill-buffer))" 2>&1
|
|
done
|
|
```
|
|
|
|
Literate programming: `.org` files in `org/` are the source of truth for
|
|
the input system, scrollbox/tabbar, dialog, mouse, select, slot,
|
|
framebuffer, and markdown modules. The backend (`modern.lisp`,
|
|
`simple.lisp`) and basic components (`box.lisp`, `text.lisp`, `render.lisp`,
|
|
`theme.lisp`, `dirty.lisp`) are written directly.
|
|
|
|
Project structure:
|
|
|
|
```
|
|
cl-tty/
|
|
├── cl-tty.asd # ASDF system definition
|
|
├── demo.lisp # Interactive demo
|
|
├── run-all-tests.lisp # Test runner
|
|
├── backend/ # Backend protocol + implementations
|
|
│ ├── package.lisp
|
|
│ ├── classes.lisp # Generic definitions
|
|
│ ├── simple.lisp # ASCII fallback backend
|
|
│ ├── modern.lisp # Truecolor escape backend
|
|
│ └── detection.lisp # Auto-detect backend from env
|
|
├── layout/ # Flexbox layout engine
|
|
│ └── layout.lisp
|
|
├── src/
|
|
│ ├── rendering/ # Framebuffer backend + diff + flush
|
|
│ │ └── framebuffer.lisp
|
|
│ └── components/ # Widgets
|
|
│ ├── box.lisp, text.lisp, render.lisp, theme.lisp
|
|
│ ├── dirty.lisp, input-package.lisp, input.lisp
|
|
│ ├── text-input.lisp, textarea.lisp, keybindings.lisp
|
|
│ ├── scrollbox.lisp, tabbar.lisp, container-package.lisp
|
|
│ ├── select.lisp, select-package.lisp
|
|
│ ├── markdown.lisp, markdown-package.lisp
|
|
│ ├── dialog.lisp, dialog-package.lisp
|
|
│ ├── mouse.lisp, mouse-package.lisp
|
|
│ └── slot.lisp, slot-package.lisp
|
|
├── tests/ # Test files
|
|
├── org/ # Literate source files
|
|
│ ├── text-input.org
|
|
│ ├── scrollbox-tabbar.org
|
|
│ ├── dialog.org
|
|
│ ├── mouse.org
|
|
│ ├── select.org
|
|
│ ├── slot.org
|
|
│ ├── framebuffer.org
|
|
│ ├── markdown-renderer.org
|
|
│ ├── detection.org
|
|
│ ├── modern-backend.org
|
|
│ ├── box-renderable.org
|
|
│ └── layout-engine.org
|
|
└── docs/
|
|
├── ROADMAP.org # Versioned roadmap
|
|
└── ARCHITECTURE.org # Design docs
|
|
```
|
|
|
|
## License
|
|
|
|
GNU General Public License v3.0
|