The text-input widget now renders multi-line word-wrapped text using cl-tty.box:word-wrap instead of single-line truncation. The cursor position is computed from the wrapped lines using the same algorithm as position-cursor but now lives in the library where it belongs. This is the critical step that enables passepartout to replace its ad-hoc view-input + position-cursor with a simple (render input be) call. Placeholder text is shown when value is empty, drawn with :dim style. Block cursor (█) at the correct word-wrapped position. All tests pass at 100% including integration tests.
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.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/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
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
# Verify syntax of all tangled files
for f in src/**/*.lisp tests/*.lisp; do
sbcl --eval "(with-open-file (s \"$f\") (loop for e = (read s nil s) until (eq e s)))" \
--eval "(format t \"~a: OK~%\" \"$f\")" --quit 2>/dev/null
done
Literate programming: every .lisp file in src/ and tests/ is a generated
artifact from an .org file in org/. The org files are the source of truth.
Each function has its own code block with prose explaining the design reasoning.
Delete every .lisp file and they can all be regenerated by tangling the org files.
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 │ │ ├── simple.lisp, modern.lisp │ │ └── detection.lisp │ ├── layout/ # Flexbox layout engine │ │ └── layout.lisp │ ├── rendering/ # Framebuffer diffing pipeline │ │ └── framebuffer.lisp │ └── components/ # Widget library │ ├── package.lisp, dirty.lisp, render.lisp, theme.lisp │ ├── box.lisp, text.lisp │ ├── input-package.lisp, input.lisp │ ├── text-input.lisp, textarea.lisp, keybindings.lisp │ ├── container-package.lisp, scrollbox.lisp, tabbar.lisp │ ├── select-package.lisp, select.lisp │ ├── markdown-package.lisp, markdown.lisp │ ├── dialog-package.lisp, dialog.lisp │ ├── mouse-package.lisp, mouse.lisp │ └── slot-package.lisp, slot.lisp ├── tests/ # FiveAM test files │ ├── input-tests.lisp, scrollbox-tabbar-tests.lisp │ ├── select-tests.lisp, markdown-tests.lisp │ ├── dialog-tests.lisp, mouse-tests.lisp, slot-tests.lisp │ ├── framebuffer-tests.lisp, integration-tests.lisp │ ├── box-tests.lisp, dirty-tests.lisp, render-tests.lisp │ └── theme-tests.lisp ├── org/ # Literate source (all .lisp files come from here) │ ├── package.org, dirty.org, render.org, theme.org │ ├── box-renderable.org │ ├── text-input.org │ ├── scrollbox.org, tabbar.org, container-package.org │ ├── select.org │ ├── markdown-renderer.org │ ├── dialog.org │ ├── mouse.org │ ├── slot.org │ ├── backend-protocol.org, modern-backend.org, detection.org │ ├── layout-engine.org │ ├── framebuffer.org │ └── integration-tests.org ├── docs/ │ ├── ROADMAP.org │ └── ARCHITECTURE.org └── demo/ # Demo assets (optional)
License
GNU General Public License v3.0