#+TITLE: cl-tty — Terminal UI Framework for Common Lisp Pure CL terminal UI framework. No ncurses, no FFI, no external dependencies. #+BEGIN_SRC lisp (ql:quickload :cl-tty) #+END_SRC * Quick start The simplest possible cl-tty program — detect the terminal, draw some text, read a key, and shut down: #+BEGIN_SRC 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)))) #+END_SRC Or run the full interactive demo: #+BEGIN_SRC bash sbcl --script demo.lisp #+END_SRC * 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: #+BEGIN_SRC 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, 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 #+END_SRC ** Event loop pattern #+BEGIN_SRC 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 )) (when (eq event :eof) (setf running nil)))) (shutdown-backend be)) #+END_SRC ** Layout system Pure CL flexbox layout engine. No C dependencies, no Yoga FFI. #+BEGIN_SRC lisp ;; Macros build layout-trees: (vbox (:gap 1 :padding 1) (header "Title") (hbox (:grow 1) (sidebar (:width 30) ...) (content ...))) #+END_SRC 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: 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 #+BEGIN_SRC 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)) #+END_SRC * 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: #+BEGIN_SRC 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)) #+END_SRC *** Box Bordered container. Draws borders using Unicode box-drawing characters (modern) or ASCII ~+~/~-~/~|~ (simple). Supports background fill, titled borders. See ~org/box-renderable.org~. #+BEGIN_SRC lisp (make-box &key (border-style :single) title (title-align :left) fg bg width height) #+END_SRC *** Text Styled text with inline spans and word wrapping. Spans support per-run attributes (bold, italic, underline, fg, bg). See ~org/box-renderable.org~. #+BEGIN_SRC lisp (make-text content &key fg bg wrap-mode width height spans) ;; Span example: (span "hello" :bold t :fg :bright-yellow) #+END_SRC *** TextInput Single-line text editor with emacs-style keybindings. Supports placeholder, max-length, on-submit callback. See ~org/text-input.org~. #+BEGIN_SRC 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))) #+END_SRC *** TextArea Multi-line text editor. Supports undo/redo (Ctrl+Z/Y), cursor movement, line joining on backspace. See ~org/text-input.org~. #+BEGIN_SRC lisp (make-textarea &key value on-submit) #+END_SRC *** 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~. #+BEGIN_SRC lisp (make-scroll-box &key children scroll-y scroll-x sticky-scroll-p) (scroll-by sb dy dx) #+END_SRC *** TabBar Horizontal tab navigation. Renders tab labels, highlights active tab. Left/right arrows cycle through tabs. See ~org/tabbar.org~. #+BEGIN_SRC 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) #+END_SRC *** 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~. #+BEGIN_SRC lisp (make-select &key options filter on-select) ;; Options format: (:title "Name" :category "Group") or (:title "Name") #+END_SRC *** 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~. #+BEGIN_SRC lisp (render-markdown "# Hello\n\nThis is **bold**.") #+END_SRC *** 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~. #+BEGIN_SRC lisp (push-dialog (make-instance 'dialog :size :medium)) (alert-dialog "Notice" "Operation complete") (toast "Saved!" :variant :success) #+END_SRC *** 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~. #+BEGIN_SRC lisp (defclass my-panel (mouse-mixin) ...) (handle-mouse-event component mouse-event) (hit-test root x y) → deepest matching component #+END_SRC *** 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. #+BEGIN_SRC lisp (defslot :status-bar :order 0 (lambda (&rest args) (draw-text backend 0 0 "Ready" :text-muted nil))) (slot-render :status-bar) #+END_SRC * 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 #+BEGIN_SRC bash # 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 #+END_SRC 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: #+BEGIN_EXAMPLE 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) #+END_EXAMPLE * License GNU General Public License v3.0