- Move backend/ and layout/ directories into src/ - Update all path references in ASD, scripts, docs - Convert README.org from Markdown syntax to proper Org-mode - Fix demo.lisp use-package conflict (both backend and input export #:read-event) - Fix modern-backend TIOCGWINSZ ioctl alien type (alien-sap wrapper) - Add v0.15.0 section to ROADMAP, update line count to 5760 - Add known gaps (suspend/resume-backend, slot modes) to v1.0.0 checklist - Remove docs/plans/, debug-layout.lisp, system-index.txt, ci-watchdog.sh - Move tangle.py to Hermes skill (org-babel-tangle) - Add .gitignore for fasl files
12 KiB
cl-tty — Terminal UI Framework for Common Lisp
Pure CL terminal UI framework. No ncurses, no FFI, no external dependencies.
(ql:quickload :cl-tty)
Quick start
The simplest possible cl-tty program — detect the terminal, draw some text, read a key, and shut down:
(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:
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:
;; 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, mouse-event, :eof, or nil
(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
(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
))
(when (eq event :eof) (setf running nil))))
(shutdown-backend be))
Layout system
Pure CL flexbox layout engine. No C dependencies, no Yoga FFI.
;; 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 src/layout/layout.lisp or org/layout-engine.org for the full API.
Rendering pipeline
Component trees render through a coordinated pipeline:
- Layout pass —
compute-layouttraverses dirty branches, solves flex constraints - Render dispatch —
rendergeneric dispatches per component type - Framebuffer — (optional)
make-framebuffer-backendcaptures to a cell array,diff-framebufferscomputes minimal changes,flush-framebufferwrites only changed cells
;; 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:
;; 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.
(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.
(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.
(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.
(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.
(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.
(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.
(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.
(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.
(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.
(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.
(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:
- Is stdout a TTY? (if not → simple-backend)
- Does
COLORTERMcontain "truecolor" or "24bit"? - Send DA1 query — does the terminal respond with modern feature codes?
Result is cached in *detected-backend*.
Development
# Run all tests (483 checks, 13 suites)
sbcl --script run-all-tests.lisp
# Run interactive demo
sbcl --script demo.lisp
# Tangle org files (regenerate .lisp from .org sources)
python3 ~/.hermes/skills/software-development/org-babel-tangle/scripts/tangle.py org/*.org
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
├── src/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
├── src/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