18 Commits

Author SHA1 Message Date
Hermes
9adefb5dbb v0.6.0: ScrollBox + TabBar — container components
ScrollBox:
- Container with vertical/horizontal scroll, viewport culling
- Scroll offset (:scroll-y, :scroll-x) with clamp to valid bounds
- Scrollbars rendered when content exceeds viewport
- Sticky scroll (auto-scroll to bottom on content change)
- Component protocol: component-children, component-layout-node

TabBar:
- Horizontal tab row with active/inactive styling
- tab-bar-next/prev (wraps around), tab-bar-select, tab-bar-handle-key
- Tab title rendering with overflow truncation (ellipsis)
- Component protocol: component-layout-node

26 scrollbox+tabbar tests, 100% GREEN:
171 total (27 backend + 58 box + 60 input + 26 scrollbox)

Review fixes applied:
- Removed duplicate definitions (org per-function blocks are prose-only)
- Fixed ASDF test path (../../tests/...)
- Version bumped to 0.6.0
- Added clamp-scroll export
- Added tab-bar-next/prev/select/handle-key tests
- Added scroll clamp boundary tests
2026-05-11 17:17:22 +00:00
Hermes
3b0410b088 docs: restructure org for per-function literate prose
37 per-function code blocks with prose explaining design reasoning,
edge cases, and CL traps. Combined tangle blocks at end for actual
compilation.

New scripts/tangle.py: reliable Python tangler (emacs --batch failed).
Added: %split-string, %join-lines, tangle helper.

CL traps documented in org prose:
- defstruct generates keyword constructors (no :constructor needed)
- case with strings uses EQL — use cond + string=
- CL strings: no \n escape — use (string #\Newline)
- FiveAM closure capture — use list boxing
- read-byte is package-locked — use read-raw-byte
- ASDF compile-file stricter than LOAD — debug with LOAD

60 tests, 100% GREEN.
2026-05-11 16:51:07 +00:00
Hermes
c55f1773fb docs: sync org file with working code
The .lisp files were edited directly during REPL-driven development.
Pushed all fixes back into the org source of truth:

- Fixed defstruct positional constructor wrappers
- Fixed case+string eql trap (cond+string=)
- Fixed CL string escape sequences (multiline literals)
- Fixed FiveAM closure capture (list boxing)
- Fixed textarea format calls (%join-lines helper)
- Fixed tangle paths for all 5 code blocks
- Consolidated all tests into single test block
- Updated key-match-p, dispatch-key-event, defkeymap macro

All 60 tests pass (100% GREEN).
2026-05-11 16:39:03 +00:00
Hermes
f07cb65186 v0.5.0: Text input + keybinding system
Four new modules:
- input.lisp: terminal raw mode, escape sequence parser, key/mouse event
  structs, read-event backend integration
- text-input.lisp: single-line text input with cursor, insertion,
  deletion, ctrl-A/E/W/U/K, on-submit callback, max-length
- textarea.lisp: multi-line text input with cursor up/down, newline,
  backspace joins lines, delete, undo/redo stack
- keybindings.lisp: layered keymap dispatch (global/local/focused),
  defkeymap macro, key spec matching with modifier prefixes

60 test assertions, 100% GREEN:
  RED: 0/12, 0/27, 0/30 — no tests existed
  GREEN: 60/60 across backend (27), box (58), input (60)

Dependencies: sb-posix for terminal raw mode (tcgetattr/tcsetattr)
Test files: 30 input tests covering all widgets and keybinding system
2026-05-11 16:31:07 +00:00
Hermes
2d3227aaf1 review fixes: version bump, keyword check, warning assertion
Fixes from subagent review:
- ASDF version 0.3.0 → 0.4.0
- define-preset now checks (check-type name keyword) at macro-expand time
- load-preset-unknown-warns test now uses (signals warning ...) to
  actually verify the warning fires (was false-positive before)
2026-05-11 15:27:55 +00:00
Hermes
0851311c3d v0.4.0: Theme engine — semantic colors, presets, dark/light variants
- Theme class with role→hex hash table, mode (dark/light)
- theme-color reader/writer (gethash based)
- define-preset macro with dark and light variants
- load-preset function with keyword lookup
- 2 built-in presets: default (gold) and nord
- 30+ semantic roles per preset (primary, accent, error, syntax-*, etc.)
- 9 theme tests: create, set/get, unknown, dark/light presets,
  nord, unknown-warn, switch-mode
- 57 total component tests, 100% GREEN
2026-05-11 15:25:09 +00:00
Hermes
6ba69f4610 review fixes: in-suite, version bump, default children method
Fixes from subagent review:
- render-tests.lisp: added (in-suite box-suite) — tests were registered
  to default suite, never executed by runner
- dirty-tests.lisp: same fix
- cl-tui.asd: version 0.2.0 → 0.3.0
- render.lisp: component-children default method (c t) nil for
  protocol completeness (component-parent already had this)
2026-05-11 15:16:59 +00:00
Hermes
b0e5c18257 v0.3.0: Rendering pipeline — render dispatch, tree walk, dirty propagation
- render generic function dispatches per component type
- render-screen entry point with sync wrapper
- render-node walks tree, computes layout, calls render
- component-layout-node generic (box/text methods)
- component-children/component-parent generics
- propagate-dirty marks component + ancestors dirty
- box and text now inherit from dirty-mixin
- 6 new tests: render dispatch, layout-node accessor, children,
  dirty propagation, available-width defaults
- 42 component tests, 100% GREEN
2026-05-11 15:12:38 +00:00
Hermes
88c576a6b9 review fixes: word-wrap hard-break, title-align, ASDF fix, edge cases
Fixes from subagent review:
- Word-wrap now hard-breaks words exceeding max-width (was returning
  un-truncated overflow strings)
- Box zero-size guard now catches any zero/single dimension (was only
  catching both zero together)
- Title-align now respected (:left/:center/:right) with proper positioning
- render-text declares (ignore spans) to suppress unused warning
- ASDF test-op fixed: run! → run-tests (symbol didn't exist)
- New test: box-single-column (width=1 renders nothing)
- Tightened word-wrap test: verifies hard-break produces both chunks
- Simplified word-wrap with cond instead of nested if/progn (avoided
  recurring paren-balance issue)
2026-05-11 14:57:44 +00:00
Hermes
a1b1352d10 v0.2.0: Dirty tracking — dirty-mixin, mark-clean, mark-dirty
- dirty-mixin class with dirty slot (initform t)
- mark-clean clears dirty flag
- mark-dirty sets dirty flag
- 3 tests: default-dirty, clean, dirty-cycle
- ROADMAP.org: v0.2.0 all tasks DONE
- 31 component tests, 100% GREEN
2026-05-11 14:49:03 +00:00
Hermes
5672aaf3fd v0.2.0: Text renderable with word-wrap and inline spans
- Text class with content, fg/bg, wrap-mode (:word or :none)
- Span class for inline styled segments (bold, italic, etc.)
- render-text dispatches through backend's draw-text
- word-wrap function splits text at word boundaries
- split-string utility for whitespace tokenization
- 9 new tests: creation, content, empty, truncation, word-wrap,
  single-word, span creation, span storage
- modern-backend now accepts :output-stream
- ASDF updated with text component
- 28 total component tests, 100% GREEN
2026-05-11 14:45:56 +00:00
Hermes
a5f8e6c9d4 v0.2.0: Box renderable — border, background, and title
- Box class with border-style, title, fg/bg slots
- render-box dispatches through backend protocol
- draw-border for borders, draw-rect for background
- draw-text for title below top border
- 7 tests: defaults, border, background, title, no-border,
  zero-size, minimum-size
- 13 assertions, 100% GREEN
- ASDF updated with src/components module
- modern-backend now accepts :output-stream initarg
2026-05-11 14:41:38 +00:00
Hermes
2b6fc32425 review fixes: 3 blocking bugs + 2 improvements
B1: modern-backend now inherits from backend (was standalone class)
B2: draw-rect y-position bug — loop now tracks row offset
B3: Layout module added to ASDF system definition
I1: 6 smoke tests replaced with behavioral tests (captured output)
I3: 6 edge case tests: empty, single-child, zero-size, deep
    nesting, large padding, negative grow

Also fixed:
- Added missing make-simple-backend constructor to simple.lisp
- Added in-package to classes.lisp and simple.lisp (SBCL's load
  restores *package* after each load, breaking batch-mode loading)
2026-05-11 14:08:51 +00:00
Hermes
2231fb6647 v0.0.3: layout engine — pure CL Flexbox solver, 35/35 tests GREEN
Fixes during debugging:
- Variable scope: loop's closing parens closed the let* prematurely,
  making children/is-row/pr/pb undefined in own-size calculation
- gap NIL bug: make-layout-node passed :gap nil (from &key default)
  to make-instance, overriding :initform 0 → (* nil ...) crash
- Child order: push (LIFO) in add-child reversed children order;
  changed to nconc (FIFO), removed the compensating reverse
- Fixed distribute-sizes to base all children from their fixed size
  then apply grow/shrink on top, instead of treating fixed-size
  children as non-participating
2026-05-11 13:39:26 +00:00
Hermes
5e17e3d509 v0.0.3: layout engine — pure CL Flexbox constraint solver and push 2026-05-11 13:12:35 +00:00
Hermes
0397d1de2c v0.0.2: modern backend — raw escape sequences
Implements the modern-backend with truecolor SGR, Unicode box-drawing
(rounded/double/single), DECICM synchronized updates, OSC 8 hyperlinks,
cursor style control, and hex color parsing.

RED: 32 checks, 5 fail (class forward-ref + test bug)
GREEN: 32/32 checks passing (100%)

- backend/modern.lisp — escape generators + modern-backend class
- backend/modern-tests.lisp — 16 test cases, 32 assertions
- backend/package.lisp — updated exports
- org/modern-backend.org — literate source
2026-05-11 12:53:55 +00:00
Hermes
db59fa4f55 v0.0.1: backend protocol — abstraction layer + simple backend
Implement the backend protocol with two backends (modern planned,
simple done). Includes package definitions, CLOS generic protocol,
simple-backend with ASCII borders, and 9 FiveAM tests.

RED: 9/9 tests failing (no implementation)
GREEN: 9/9 tests passing

- backend/package.lisp — defpackage, exports
- backend/classes.lisp — backend base class, 18 generics
- backend/simple.lisp — simple-backend implementation
- backend/tests.lisp — 9 FiveAM test cases
- org/backend-protocol.org — literate source
2026-05-11 12:45:26 +00:00
Hermes
bd22f1a43d test: check token permissions 2026-05-11 11:45:59 +00:00
49 changed files with 9376 additions and 2976 deletions

View File

@@ -50,3 +50,4 @@ See ~docs/ROADMAP.org~ for the full release plan.
** License
TBD
# Test

62
backend/classes.lisp Normal file
View File

@@ -0,0 +1,62 @@
(in-package :cl-tui.backend)
(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))

124
backend/modern-tests.lisp Normal file
View File

@@ -0,0 +1,124 @@
(defpackage :cl-tui-modern-backend-test
(:use :cl :fiveam :cl-tui.backend)
(:export #:run-tests))
(in-package :cl-tui-modern-backend-test)
(def-suite modern-backend-suite :description "Modern backend tests")
(in-suite modern-backend-suite)
(defun run-tests ()
(let ((result (run 'modern-backend-suite)))
(fiveam:explain! result)
(uiop:quit 0)))
;; ── Constructor ────────────────────────────────────────────────
(test make-modern-backend-creates
"make-modern-backend returns a modern-backend instance"
(let ((b (make-modern-backend)))
(is (typep b 'cl-tui.backend::modern-backend))))
;; ── Escape Generation ──────────────────────────────────────────
(test sgr-truecolor-foreground
"SGR truecolor foreground escape is correct"
(is (equal (cl-tui.backend::sgr-fg "#FFD700")
(format nil "~C[38;2;255;215;0m" #\Esc))))
(test sgr-truecolor-background
"SGR truecolor background escape is correct"
(is (equal (cl-tui.backend::sgr-bg "#1a1b26")
(format nil "~C[48;2;26;27;38m" #\Esc))))
(test sgr-named-colors
"SGR named colors resolve to 8-color codes"
(is (equal (cl-tui.backend::sgr-fg :red)
(format nil "~C[31m" #\Esc)))
(is (equal (cl-tui.backend::sgr-bg :blue)
(format nil "~C[44m" #\Esc))))
(test sgr-bold-italic
"SGR attribute escapes are correct"
(is (equal (cl-tui.backend::sgr-attr :bold) (format nil "~C[1m" #\Esc)))
(is (equal (cl-tui.backend::sgr-attr :italic) (format nil "~C[3m" #\Esc)))
(is (equal (cl-tui.backend::sgr-attr :underline) (format nil "~C[4m" #\Esc)))
(is (equal (cl-tui.backend::sgr-attr :reset) (format nil "~C[0m" #\Esc))))
;; ── Cursor ─────────────────────────────────────────────────────
(test cursor-move-escape
"cursor-move generates correct CSI escape"
(let ((b (make-modern-backend)))
(is (equal (cl-tui.backend::cursor-move-escape 5 10)
(format nil "~C[11;6H" #\Esc)))))
(test cursor-style-block
"cursor-style :block generate correct escape"
(let ((b (make-modern-backend)))
(is (equal (cl-tui.backend::cursor-style-escape :block nil)
(format nil "~C[2 q" #\Esc)))))
(test cursor-style-bar
"cursor-style :bar generate correct escape"
(let ((b (make-modern-backend)))
(is (equal (cl-tui.backend::cursor-style-escape :bar nil)
(format nil "~C[6 q" #\Esc)))))
(test cursor-style-underline-blink
"cursor-style :underline with blink"
(let ((b (make-modern-backend)))
(is (equal (cl-tui.backend::cursor-style-escape :underline t)
(format nil "~C[5 q" #\Esc)))))
;; ── Synchronization ────────────────────────────────────────────
(test decicm-escapes
"DECICM synchronized update escapes"
(is (equal (cl-tui.backend::decicm-begin) (format nil "~C[?2026h" #\Esc)))
(is (equal (cl-tui.backend::decicm-end) (format nil "~C[?2026l" #\Esc))))
;; ── OSC 8 Hyperlinks ──────────────────────────────────────────
(test osc8-escape
"OSC 8 hyperlink escape wraps text"
(is (equal (cl-tui.backend::osc8-link "http://example.com" "click here")
(format nil "~C]8;;http://example.com~C\\click here~C]8;;~C\\"
#\Esc #\Esc #\Esc #\Esc))))
;; ── Hex Parsing ────────────────────────────────────────────────
(test hex-color-parsing
"hex-to-rgb parses valid hex colors"
(multiple-value-bind (r g b) (cl-tui.backend::hex-to-rgb "#FFD700")
(is (= r 255))
(is (= g 215))
(is (= b 0))))
(test hex-color-black
"hex-to-rgb parses black"
(multiple-value-bind (r g b) (cl-tui.backend::hex-to-rgb "#000000")
(is (= r 0))
(is (= g 0))
(is (= b 0))))
(test hex-color-short-form
"hex-to-rgb parses 3-digit hex"
(multiple-value-bind (r g b) (cl-tui.backend::hex-to-rgb "#F00")
(is (= r 255))
(is (= g 0))
(is (= b 0))))
;; ── Border Characters ──────────────────────────────────────────
(test border-char-rounded
"modern-border-char returns Unicode box-drawing for rounded style"
(is (equal (cl-tui.backend::border-char :rounded :top-left) "╭"))
(is (equal (cl-tui.backend::border-char :rounded :horizontal) "─"))
(is (equal (cl-tui.backend::border-char :rounded :vertical) "│"))
(is (equal (cl-tui.backend::border-char :rounded :bottom-right) "╯")))
(test border-char-double
"modern-border-char returns double-line chars"
(is (equal (cl-tui.backend::border-char :double :top-left) "╔"))
(is (equal (cl-tui.backend::border-char :double :horizontal) "═"))
(is (equal (cl-tui.backend::border-char :double :vertical) "║")))

246
backend/modern.lisp Normal file
View File

@@ -0,0 +1,246 @@
;;; modern-backend — Raw escape sequence backend
;;; Generated from org/modern-backend.org
;;; DO NOT EDIT — edit the .org file instead
;; In package.lisp, add to :export:
;; #:modern-backend #:make-modern-backend
;; Internal symbols (not exported, used by tests):
;; sgr-fg sgr-bg sgr-attr cursor-move-escape cursor-style-escape
;; decicm-begin decicm-end osc8-link hex-to-rgb border-char
(in-package :cl-tui.backend)
(defun hex-to-rgb (hex)
"Parse a hex color string like \"#FFD700\" into (values r g b).
Also handles 3-digit hex like \"#F00\" (expands to \"#FF0000\")."
(let ((clean (string-trim '(#\# #\Space) hex)))
(if (= (length clean) 3)
;; Expand 3-digit: #F00 -> #FF0000
(let* ((r (parse-integer (subseq clean 0 1) :radix 16 :junk-allowed t))
(g (parse-integer (subseq clean 1 2) :radix 16 :junk-allowed t))
(b (parse-integer (subseq clean 2 3) :radix 16 :junk-allowed t)))
(values (+ r (* r 16)) (+ g (* g 16)) (+ b (* b 16))))
(values (parse-integer (subseq clean 0 2) :radix 16 :junk-allowed t)
(parse-integer (subseq clean 2 4) :radix 16 :junk-allowed t)
(parse-integer (subseq clean 4 6) :radix 16 :junk-allowed t)))))
(defparameter *named-colors*
'((:black . 0) (:red . 1) (:green . 2) (:yellow . 3)
(:blue . 4) (:magenta . 5) (:cyan . 6) (:white . 7)))
(defun sgr-fg (color)
"Return SGR foreground escape for COLOR.
Color can be a hex string, a keyword name, or nil."
(if (null color) ""
(cond ((and (stringp color) (char= (char color 0) #\#))
(multiple-value-bind (r g b) (hex-to-rgb color)
(format nil "~C[38;2;~d;~d;~dm" #\Esc r g b)))
((keywordp color)
(let ((index (cdr (assoc color *named-colors*))))
(if index
(format nil "~C[~dm" #\Esc (+ 30 index))
"")))
(t ""))))
(defun sgr-bg (color)
"Return SGR background escape for COLOR."
(if (null color) ""
(cond ((and (stringp color) (char= (char color 0) #\#))
(multiple-value-bind (r g b) (hex-to-rgb color)
(format nil "~C[48;2;~d;~d;~dm" #\Esc r g b)))
((keywordp color)
(let ((index (cdr (assoc color *named-colors*))))
(if index
(format nil "~C[~dm" #\Esc (+ 40 index))
"")))
(t ""))))
(defparameter *sgr-attr-codes*
'((:bold . 1) (:dim . 2) (:italic . 3) (:underline . 4)
(:blink . 5) (:reverse . 7) (:reset . 0)))
(defun sgr-attr (attr)
"Return SGR attribute escape for ATTR keyword."
(let ((code (cdr (assoc attr *sgr-attr-codes*))))
(if code
(format nil "~C[~dm" #\Esc code)
"")))
(defun cursor-move-escape (x y)
"Return CSI escape to move cursor to (x, y), 1-indexed."
(format nil "~C[~d;~dH" #\Esc (1+ y) (1+ x)))
(defun cursor-style-escape (shape blink)
"Return DECSTR escape for cursor shape.
:block = 2, :underline = 4, :bar = 6.
Add 1 for blink variants."
(let* ((base (case shape
(:block 2) (:underline 4) (:bar 6)
(t 2)))
(code (if blink (1+ base) base)))
(format nil "~C[~d q" #\Esc code)))
(defun decicm-begin ()
"Return escape to enable synchronized updates."
(format nil "~C[?2026h" #\Esc))
(defun decicm-end ()
"Return escape to disable synchronized updates."
(format nil "~C[?2026l" #\Esc))
(defun osc8-link (url text)
"Wrap TEXT in an OSC 8 hyperlink to URL."
(format nil "~C]8;;~A~C\\~A~C]8;;~C\\"
#\Esc url #\Esc text #\Esc #\Esc))
(defparameter *border-chars*
'(((:single :top-left) . "┌") ((:single :top-right) . "┐")
((:single :bottom-left) . "└") ((:single :bottom-right) . "┘")
((:single :horizontal) . "─") ((:single :vertical) . "│")
((:double :top-left) . "╔") ((:double :top-right) . "╗")
((:double :bottom-left) . "╚") ((:double :bottom-right) . "╝")
((:double :horizontal) . "═") ((:double :vertical) . "║")
((:rounded :top-left) . "╭") ((:rounded :top-right) . "╮")
((:rounded :bottom-left) . "╰") ((:rounded :bottom-right) . "╯")
((:rounded :horizontal) . "─") ((:rounded :vertical) . "│")))
(defun border-char (style pos)
"Return the Unicode box-drawing character for STYLE at POS."
(let ((char (cdr (assoc (list style pos) *border-chars* :test #'equal))))
(or char (if (member pos '(:horizontal :vertical))
(case pos (:horizontal "─") (:vertical "│"))
"+"))))
(defclass modern-backend (backend)
((output-stream :initform *standard-output*
:initarg :output-stream
:accessor backend-output-stream)
(in-sync-p :initform nil :accessor in-sync-p)))
(defun make-modern-backend (&key color-palette output-stream)
(declare (ignore color-palette))
(make-instance 'modern-backend :output-stream (or output-stream *standard-output*)))
(defmethod initialize-backend ((b modern-backend))
;; Enter raw mode, enable mouse, bracketed paste
(backend-write b (format nil "~C[?1049h" #\Esc)) ; alt screen
(backend-write b (format nil "~C[?1000h" #\Esc)) ; mouse basic
(backend-write b (format nil "~C[?1002h" #\Esc)) ; mouse drag
(backend-write b (format nil "~C[?1006h" #\Esc)) ; SGR mouse
(backend-write b (format nil "~C[?2004h" #\Esc)) ; bracketed paste
(cursor-hide b)
(finish-output (backend-output-stream b))
b)
(defmethod shutdown-backend ((b modern-backend))
(cursor-show b)
(backend-write b (format nil "~C[?2004l" #\Esc)) ; disable bracketed paste
(backend-write b (format nil "~C[?1006l" #\Esc)) ; disable SGR mouse
(backend-write b (format nil "~C[?1002l" #\Esc))
(backend-write b (format nil "~C[?1000l" #\Esc))
(backend-write b (format nil "~C[?1049l" #\Esc)) ; normal screen
(finish-output (backend-output-stream b))
(values))
(defmethod backend-size ((b modern-backend))
;; Default fallback — real implementation queries terminal
(values 80 24))
(defmethod backend-write ((b modern-backend) string)
(let ((stream (backend-output-stream b)))
(write-string string stream)
(length string)))
(defmethod capable-p ((b modern-backend) feature)
(member feature '(:truecolor :osc8 :sync :mouse
:bracketed-paste :cursor-style
:kitty-keyboard)))
(defmethod draw-text ((b modern-backend) x y string fg bg
&key bold italic underline reverse dim blink)
(let ((parts (list (cursor-move-escape x y)
(sgr-fg fg) (sgr-bg bg)
(when bold (sgr-attr :bold))
(when italic (sgr-attr :italic))
(when underline (sgr-attr :underline))
(when reverse (sgr-attr :reverse))
(when dim (sgr-attr :dim))
(when blink (sgr-attr :blink))
string
(sgr-attr :reset))))
(backend-write b (apply #'concatenate 'string parts))))
(defmethod draw-border ((b modern-backend) x y width height
&key style fg bg title title-align)
(declare (ignore title title-align))
(let* ((s (or style :single))
(tl (border-char s :top-left))
(tr (border-char s :top-right))
(bl (border-char s :bottom-left))
(br (border-char s :bottom-right))
(h (border-char s :horizontal))
(v (border-char s :vertical))
(fg-esc (sgr-fg fg))
(bg-esc (sgr-bg bg))
(reset (sgr-attr :reset))
(top (concatenate 'string
fg-esc bg-esc tl
(make-string (- width 2) :initial-element (char h 0))
tr reset (string #\Newline)))
(mid (concatenate 'string
fg-esc bg-esc v
(make-string (- width 2) :initial-element #\Space)
v reset (string #\Newline)))
(bot (concatenate 'string
fg-esc bg-esc bl
(make-string (- width 2) :initial-element (char h 0))
br reset)))
(backend-write b top)
(loop repeat (- height 2) do (backend-write b mid))
(backend-write b bot)))
(defmethod draw-rect ((b modern-backend) x y width height &key bg)
(let* ((bg-esc (sgr-bg bg))
(reset (sgr-attr :reset))
(line (concatenate 'string
bg-esc
(make-string width :initial-element #\Space)
reset (string #\Newline))))
(loop :for row :from 0 :below height :do
(backend-write b (cursor-move-escape x (+ y row)))
(backend-write b line))))
(defmethod draw-link ((b modern-backend) x y string url
&key fg bg)
(let ((parts (list (cursor-move-escape x y)
(sgr-fg fg) (sgr-bg bg)
(osc8-link url string)
(sgr-attr :reset))))
(backend-write b (apply #'concatenate 'string parts))))
(defmethod draw-ellipsis ((b modern-backend) x y width
&key fg bg)
(let ((dots "..."))
(draw-text b x y dots fg bg)))
(defmethod cursor-move ((b modern-backend) x y)
(backend-write b (cursor-move-escape x y)))
(defmethod cursor-hide ((b modern-backend))
(backend-write b (format nil "~C[?25l" #\Esc)))
(defmethod cursor-show ((b modern-backend))
(backend-write b (format nil "~C[?25h" #\Esc)))
(defmethod cursor-style ((b modern-backend) shape &key blink)
(backend-write b (cursor-style-escape shape blink)))
(defmethod begin-sync ((b modern-backend))
(setf (in-sync-p b) t)
(backend-write b (decicm-begin)))
(defmethod end-sync ((b modern-backend))
(setf (in-sync-p b) nil)
(backend-write b (decicm-end))
(finish-output (backend-output-stream b)))

29
backend/package.lisp Normal file
View File

@@ -0,0 +1,29 @@
(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
;; Modern backend
#:modern-backend #:make-modern-backend
;; Internal (for testing)
#:sgr-fg #:sgr-bg #:sgr-attr
#:cursor-move-escape #:cursor-style-escape
#:decicm-begin #:decicm-end #:osc8-link
#:hex-to-rgb #:border-char))
(in-package :cl-tui.backend)

69
backend/simple.lisp Normal file
View File

@@ -0,0 +1,69 @@
(in-package :cl-tui.backend)
(defclass simple-backend (backend)
((output-stream :initform *standard-output*
:initarg :output-stream
:accessor backend-output-stream)))
(defun make-simple-backend (&key output-stream)
(make-instance 'simple-backend
:output-stream (or output-stream *standard-output*)))
(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 "..."))

138
backend/tests.lisp Normal file
View File

@@ -0,0 +1,138 @@
(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)
;; ── Helpers ─────────────────────────────────────────────────────
(defun make-capturing-backend ()
"Create a simple-backend that writes to a string stream."
(let* ((s (make-string-output-stream))
(b (make-simple-backend :output-stream s)))
(values b s)))
;; ── 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-false (capable-p b :truecolor) "simple backend has no truecolor")
(shutdown-backend b)))
(test simple-backend-draw-text
"simple-backend renders text at position, ignoring style"
(multiple-value-bind (b s) (make-capturing-backend)
(initialize-backend b)
(draw-text b 0 0 "hello" :red nil :bold t :italic t)
(shutdown-backend b)
(is (string= (get-output-stream-string s) "hello")
"draw-text should output the string ignoring style")))
(test simple-backend-draw-border
"simple-backend draws ASCII border with +-| characters"
(multiple-value-bind (b s) (make-capturing-backend)
(initialize-backend b)
(draw-border b 0 0 5 3 :style :single)
(shutdown-backend b)
(let ((out (get-output-stream-string s)))
(is (search "-----" out) "top edge should have 5 dashes")
(is (search "| |" out) "middle row should have pipe sides"))))
(test simple-backend-draw-rounded
"simple-backend falls back to straight edges for rounded style"
(multiple-value-bind (b s) (make-capturing-backend)
(initialize-backend b)
(draw-border b 0 0 5 3 :style :rounded)
(shutdown-backend b)
(let ((out (get-output-stream-string s)))
;; Rounded falls back to ASCII — identical output to single
(is (search "-----" out) "rounded style produces same dashes as single"))))
(test simple-backend-draw-link
"simple-backend renders link as plain text"
(multiple-value-bind (b s) (make-capturing-backend)
(initialize-backend b)
(draw-link b 0 0 "click me" "http://example.com")
(shutdown-backend b)
(is (string= (get-output-stream-string s) "click me")
"simple-backend ignores URL, outputs text only")))
(test simple-backend-draw-ellipsis
"simple-backend renders ... for ellipsis"
(multiple-value-bind (b s) (make-capturing-backend)
(initialize-backend b)
(draw-ellipsis b 0 0 5)
(shutdown-backend b)
(is (string= (get-output-stream-string s) "...")
"ellipsis should output 3 dots")))
;; ── 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-false (capable-p b f)
(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)))
;; ── Backend Protocol: Defaults and No-ops ──────────────────────
(test default-methods-are-no-ops
"Default backend methods don't error"
(let ((b (make-simple-backend)))
(initialize-backend b)
(is (null (multiple-value-list (cursor-hide b))))
(is (null (multiple-value-list (cursor-show b))))
(is (null (multiple-value-list (cursor-style b :block))))
(is (null (multiple-value-list (begin-sync b))))
(is (null (multiple-value-list (end-sync b))))
(shutdown-backend b)))
(test sync-is-noop-on-simple
"begin-sync and end-sync produce no output on simple-backend"
(multiple-value-bind (b s) (make-capturing-backend)
(initialize-backend b)
(begin-sync b)
(draw-text b 0 0 "in sync" nil nil)
(end-sync b)
(shutdown-backend b)
(is (string= (get-output-stream-string s) "in sync")
"no sync escape sequences should appear")))
;; ── Draw-rect ──────────────────────────────────────────────────
(test draw-rect-fills-area-correctly
"draw-rect with background writes nothing to output (simple-backend no-op)"
(multiple-value-bind (b s) (make-capturing-backend)
(initialize-backend b)
(draw-rect b 0 0 5 3 :bg :red)
(shutdown-backend b)
(is (string= (get-output-stream-string s) "")
"draw-rect is a no-op on simple-backend")))

View File

@@ -1,11 +1,65 @@
(defsystem :cl-tui
:name "cl-tui"
:author "memex"
:version "0.1.0"
:license "AGPLv3"
;;; cl-tui.asd — Common Lisp Terminal UI Framework
(asdf:defsystem :cl-tui
:description "Reusable Common Lisp Terminal UI Framework"
:depends-on (:cffi :croatoan :trivial-garbage)
:serial t
:components ((:file "lisp/yoga-ffi")
(:file "lisp/layout-primitives")
(:file "lisp/layout-composable")))
:author "Amr Gharbeia"
:version "0.6.0"
:license "TBD"
:depends-on (:fiveam :sb-posix)
:components
((:module "backend"
:components
((:file "package")
(:file "classes" :depends-on ("package"))
(:file "simple" :depends-on ("package" "classes"))
(:file "modern" :depends-on ("package" "classes"))))
(:module "layout"
:components
((:file "layout")))
(:module "src/components"
:components
((:file "package")
(:file "dirty")
(:file "box" :depends-on ("package"))
(:file "text" :depends-on ("package" "box"))
(:file "render" :depends-on ("package" "box" "text"))
(:file "theme" :depends-on ("package"))
;; Input system (v0.5.0)
(:file "input-package" :depends-on ("package"))
(:file "input" :depends-on ("input-package" "dirty" "box"))
(:file "text-input" :depends-on ("input-package" "input" "box"))
(:file "textarea" :depends-on ("input-package" "input" "box"))
(:file "keybindings" :depends-on ("input-package" "input"))
;; Container components (v0.6.0)
(:file "container-package" :depends-on ("package" "input-package"))
(:file "scrollbox" :depends-on ("container-package" "dirty" "box"))
(:file "tabbar" :depends-on ("container-package" "dirty" "box"))))
: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")))
(:module "layout"
:components
((:file "tests")))
(:module "src/components"
:components
((:file "box-tests")
(:file "dirty-tests")
(:file "render-tests")
(:file "theme-tests")
(:file "input-tests")
(:file "scrollbox-tabbar-tests" :pathname "../../tests/scrollbox-tabbar-tests.lisp"))))
:perform (test-op (o c)
(dolist (suite '((:cl-tui-backend-test "BACKEND-SUITE")
(:cl-tui-box-test "BOX-SUITE")
(:cl-tui-input-test "INPUT-SUITE")
(:cl-tui-scrollbox-test "SCROLLBOX-SUITE")))
(let* ((pkg (find-package (first suite)))
(s (and pkg (find-symbol (second suite) pkg))))
(when s
(fiveam:explain! (fiveam:run s)))))
(uiop:quit 0)))

28
demo.lisp Normal file
View File

@@ -0,0 +1,28 @@
;; demo.lisp — minimal cl-tui demo
(load "/root/quicklisp/setup.lisp")
(ql:quickload :fiveam :silent t)
(load "backend/package.lisp")
(load "backend/classes.lisp")
(load "backend/simple.lisp")
(load "backend/modern.lisp")
(load "layout/layout.lisp")
(load "src/components/package.lisp")
(load "src/components/dirty.lisp")
(load "src/components/box.lisp")
(load "src/components/text.lisp")
(load "src/components/render.lisp")
(in-package :cl-tui.box)
;; Demo 1: Simple backend (ASCII)
(let* ((b (make-simple-backend))
(bx (make-box :border-style :rounded :title " Hello World " :width 30 :height 5)))
(compute-layout (box-layout-node bx) 30 5)
(render bx b))
;; Demo 2: Box with text inside
(let* ((b (make-simple-backend))
(tx (make-text "This is cl-tui in action!" :width 28 :height 1)))
(setf (layout-node-direction (text-layout-node tx)) :column)
(compute-layout (text-layout-node tx) 28 1)
(render tx b)
(format t "~%~%"))

318
docs/ARCHITECTURE.org Normal file
View 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)

View File

@@ -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
@@ -23,28 +94,22 @@ When a version ships:
Yoga Flexbox backend wrapped in a Common Lisp API. This is the foundation —
every component after v0.1.0 uses the layout engine for positioning.
*** DONE Yoga FFI binding
*** TODO Yoga FFI binding
:PROPERTIES:
:ID: id-v010-yoga-ffi
:CREATED: [2026-05-10 Sat]
:END:
:LOGBOOK:
- State "DONE" from "TODO" [2026-05-11 Mon]
:END:
- Load the Yoga shared library via CFFI
- Define foreign types for ~YGNodeRef~, ~YGSize~, ~YGValue~, ~YGDirection~, ~YGFlexDirection~, ~YGAlign~, ~YGJustify~, ~YGWrap~, ~YGPositionType~, ~YGOverflow~, ~YGDisplay~, ~YGEdge~
- Bind core functions: ~node-new~, ~node-free~, ~node-style-set-*~, ~node-layout-get-*~, ~calculate-layout~
- ~100 lines CFFI
*** DONE Layout primitives
*** TODO Layout primitives
:PROPERTIES:
:ID: id-v010-layout-primitives
:CREATED: [2026-05-10 Sat]
:END:
:LOGBOOK:
- State "DONE" from "TODO" [2026-05-11 Mon]
:END:
- ~(make-layout-node)~ — wraps a ~YGNodeRef~ in a CLOS object
- ~(layout-node-set-dimension node width height)~ — sets width/height in points
@@ -62,14 +127,11 @@ every component after v0.1.0 uses the layout engine for positioning.
- ~(layout-calculate root width height)~ — runs Yoga's calculateLayout, populates each node's computed x/y/w/h
- ~200 lines CL
*** DONE Layout composable API
*** TODO Layout composable API
:PROPERTIES:
:ID: id-v010-layout-composable
:CREATED: [2026-05-10 Sat]
:END:
:LOGBOOK:
- State "DONE" from "TODO" [2026-05-11 Mon]
:END:
Convenience macros to build layout trees from CL function calls:
@@ -95,11 +157,14 @@ The first two renderable types that every application uses. A Box draws borders
and backgrounds. A Text renders strings with color and style. Together they
cover 80% of terminal UI.
*** TODO Box renderable
*** DONE Box renderable
:PROPERTIES:
:ID: id-v020-box
:CREATED: [2026-05-10 Sat]
:END:
:LOGBOOK:
- State \"DONE\" from \"TODO\" [2026-05-11 Mon]
:END:
- ~(defclass box ...)~ — renderable with background color, border, title
- ~(render-box box window)~ — draws border (single/double/rounded), fills background, renders title
@@ -108,11 +173,14 @@ cover 80% of terminal UI.
- ~:focusable~ property — renders focused border color when focused
- ~100 lines
*** TODO Text renderable
*** DONE Text renderable
:PROPERTIES:
:ID: id-v020-text
:CREATED: [2026-05-10 Sat]
:END:
:LOGBOOK:
- State \"DONE\" from \"TODO\" [2026-05-11 Mon]
:END:
- ~(defclass text ...)~ — renderable with content, fg/bg color, wrap mode
- ~(render-text text window)~ — renders text at the layout position, wraps at width
@@ -120,22 +188,28 @@ cover 80% of terminal UI.
- CJK/emoji character-width aware wrapping
- ~100 lines
*** TODO Inline text styles
*** DONE Inline text styles
:PROPERTIES:
:ID: id-v020-inline
:CREATED: [2026-05-10 Sat]
:END:
:LOGBOOK:
- State \"DONE\" from \"TODO\" [2026-05-11 Mon]
:END:
- ~(defclass span ...)~ — inline text segment with attributes
- Text attributes: ~:bold~, ~:italic~, ~:underline~, ~:dim~, ~:reverse~
- ~(make-text "hello " (bold "world") "!")~ — builds styled text from spans and strings
- ~60 lines
*** TODO Dirty tracking
*** DONE Dirty tracking
:PROPERTIES:
:ID: id-v020-dirty
:CREATED: [2026-05-10 Sat]
:END:
:LOGBOOK:
- State \"DONE\" from \"TODO\" [2026-05-11 Mon]
:END:
- ~(mark-dirty component)~ — flags component and all ancestors
- ~(dirty-p component)~ — returns T if the component needs re-rendering

View File

@@ -0,0 +1,127 @@
# v0.2.0: Renderables — Box and Text
> Implementation plan for the first two renderable component types.
**Goal:** Create Box (border+background+title) and Text (styled wrapping text) renderables that render through the backend protocol.
**Architecture:** Each renderable is a CLOS class with a `layout-node` slot for positioning. The `render` method dispatches through the backend protocol (draw-text, draw-border, draw-rect). Tests capture backend output via string streams.
**Files created:**
- `org/box-renderable.org` — Box class, render method (literate source)
- `org/text-renderable.org` — Text class, render method, inline spans (literate source)
- `org/dirty-tracking.org` — Dirty flag system (literate source)
- `src/components/box.lisp` — tangled
- `src/components/text.lisp` — tangled
- `src/components/dirty.lisp` — tangled
**Files modified:**
- `cl-tui.asd` — add component modules
- `docs/ROADMAP.org` — mark v0.2.0 tasks DONE
## Task 1: Box renderable
**Objective:** Box class that draws borders, fills backgrounds, and renders titles.
**Files:**
- Create: `org/box-renderable.org`
- Create: `src/components/box.lisp` (extracted)
- Modify: `cl-tui.asd` — add components module
**Box class:**
```lisp
(defclass box ()
((layout-node :initarg :layout-node :accessor box-layout-node)
(border-style :initform :single :initarg :border-style :accessor box-border-style)
(title :initform nil :initarg :title :accessor box-title)
(title-align :initform :left :initarg :title-align :accessor box-title-align)
(fg :initform nil :initarg :fg :accessor box-fg)
(bg :initform nil :initarg :bg :accessor box-bg)))
```
**render-box method:**
Renders at computed layout position using backend's draw-border, draw-rect, draw-text.
Delegates to the backend — no escape sequences directly.
**Tests:**
- Create box with border, verify draw-border was called with correct params
- Create box with title, verify title positioning
- Create box with background fill
- Edge cases: box with 0 width/height, no border style, very long title
## Task 2: Text renderable
**Objective:** Text class that renders strings at layout position with word-wrap.
**Files:**
- Create: `org/text-renderable.org`
- Create: `src/components/text.lisp` (extracted)
**Text class:**
```lisp
(defclass text ()
((layout-node :initarg :layout-node :accessor text-layout-node)
(content :initarg :content :accessor text-content)
(fg :initform nil :initarg :fg :accessor text-fg)
(bg :initform nil :initarg :bg :accessor text-bg)
(wrap-mode :initform :word :initarg :wrap-mode :accessor text-wrap-mode)
(spans :initform nil :initarg :spans :accessor text-spans)))
```
**render-text method:**
1. Get layout position (x, y, width, height)
2. If wrap-mode is :none, truncate to width
3. If wrap-mode is :word, word-wrap (break on whitespace)
4. Draw each line via backend's draw-text
5. Apply span attributes (bold, italic, etc.) per segment
**Inline spans:**
```lisp
(defclass span ()
((text :initarg :text :accessor span-text)
(bold :initform nil :initarg :bold :accessor span-bold)
(italic :initform nil :initarg :italic :accessor span-italic)
(underline :initform nil :initarg :underline :accessor span-underline)))
```
**Tests:**
- Text renders string at correct position
- Word-wrap breaks at word boundaries
- Truncation mode clips at width
- Spans apply style attributes per segment
- Empty string rendering
- Single character
- String shorter than width (no wrapping needed)
## Task 3: Dirty tracking
**Objective:** Lightweight dirty-flag system for incremental rendering.
**Files:**
- Create: `org/dirty-tracking.org`
- Create: `src/components/dirty.lisp` (extracted)
```lisp
(defgeneric mark-dirty (component))
(defgeneric dirty-p (component))
(defgeneric mark-clean (component))
```
Default methods mark/check a `dirty` slot on the component. When implemented:
- `mark-dirty` — sets dirty flag, propagates to parent
- `dirty-p` — returns T if component needs re-render
- `mark-clean` — clears dirty flag after render
**Tests:**
- New component is dirty (default)
- mark-clean clears dirty flag
- dirty-p returns nil after mark-clean
- mark-dirty sets dirty flag again
## Task 4: Wire into ASDF + update roadmap
**Files:**
- Modify: `cl-tui.asd` — add `:module "components"` to both main and test systems
- Modify: `docs/ROADMAP.org` — mark v0.2.0 tasks DONE
**Run full test suite:**
All 72 existing tests + new component tests: 100% GREEN.

View File

@@ -0,0 +1,365 @@
# v0.5.0: Text Input + Keybinding System
**Architecture:** Three layers. First, terminal input infrastructure (raw mode, escape parsing, key events) — this is the missing piece the roadmap assumed croatoan would provide. Then TextInput and Textarea widgets. Finally, the layered keybinding system.
**The hidden dependency:** `read-event` is currently a no-op in both backends. We need raw terminal I/O (tcsetattr, non-canonical mode, escape sequence parsing) before any input widget works. SBCL provides `sb-posix` for POSIX terminal APIs.
**File structure:**
```
org/input.org — literate source: terminal input + key events
org/text-input.org — literate source: TextInput widget
org/textarea.org — literate source: Textarea widget
org/keybindings.org — literate source: keybinding system
backend/input.lisp — tangled: raw terminal, escape parser, key events
src/components/input.lisp — tangled: TextInput widget
src/components/textarea.lisp — tangled: Textarea widget
src/components/keybindings.lisp — tangled: keybinding system
```
---
### Task 1: Terminal Input Infrastructure
**Objective:** Raw terminal mode, ANSI escape sequence parser, key event types. Implements `read-event` for both backends.
**Files:**
- Create: `org/input.org`
- Create: `src/input.lisp` (tangled)
- Create: `tests/input-tests.lisp`
- Modify: `backend/package.lisp` — add input exports
- Modify: `backend/modern.lisp` — implement read-event
- Modify: `backend/simple.lisp` — implement read-event (stdin)
- Modify: `cl-tui.asd` — add input module to main and test systems
**Code architecture:**
```lisp
;; Key event type — all input gets normalized to this
(defstruct key-event
key ;; :a, :b, :space, :enter, :tab, :escape
;; :up, :down, :left, :right
;; :f1..:f12
ctrl ;; boolean
alt ;; boolean
shift ;; boolean
code ;; raw character code (fixnum)
raw ;; raw escape sequence string (for debugging)
text) ;; for bracketed paste: the pasted text string
(defstruct mouse-event
type ;; :press, :release, :drag
button ;; :left, :middle, :right, :none
x y
raw)
;; Terminal raw mode — saves/restores termios
(defun save-terminal-state () ...) ;; tcgetattr(0)
(defun set-raw-mode () ...) ;; tcsetattr(0, TCSANOW, raw)
(defun restore-terminal-state () ...)
(defmacro with-raw-terminal (&body body) ...)
;; Escape sequence parser
(defun read-byte-from-stdin (&optional timeout) ...)
(defun parse-escape-sequence () ...) ;; reads CSI, SS3 sequences
(defun parse-csi-sequence () ...) ;; parses CSI number;...$char
(defun parse-sgr-mouse () ...) ;; parse CSI < r;c;M/m
(defun read-event-from-stdin (&key timeout) ...) ;; full read+parse
;; Backend integration
(defmethod read-event ((b modern-backend) &key timeout)
(let ((event (read-event-from-stdin :timeout timeout)))
(if (key-event-p event)
(values (key-event-key event) event)
(values nil event))))
(defmethod read-event ((b simple-backend) &key timeout)
(read-event-from-stdin :timeout timeout))
```
**Key normalization table (partial):**
| Raw byte(s) | Key | Ctrl | Alt |
|---|---|---|---|
| #x1b | :escape | nil | nil |
| #x7f or #x08 | :backspace | nil | nil |
| #x0a | :enter | nil | nil |
| #x09 | :tab | nil | nil |
| #x01 | :a | t | nil |
| CSI A | :up | nil | nil |
| CSI 1~ | :home | nil | nil |
| CSI 200~ | (bracketed paste start) | — | — |
**Tests:**
```lisp
(test read-ctrl-a
(let* ((event (make-key-event :a :ctrl t)))
(is (eql (key-event-key event) :a))
(is-true (key-event-ctrl event))))
(test parse-csi-up
(let ((kb (terminal-sequence->key-event (format nil \"~C[A\" #\\Esc))))
(is (eql (key-event-key kb) :up))))
(test mouse-sgr
(let ((event (parse-sgr-mouse \"<0;10;5M\")))
(is (eql (mouse-event-type event) :press))
(is (eql (mouse-event-button event) :left))
(is (= (mouse-event-x event) 10))
(is (= (mouse-event-y event) 5))))
```
**Line count:** ~250 lines
---
### Task 2: TextInput Widget
**Objective:** Single-line text input widget with cursor, placeholder, insertion/deletion, clipboard, emacs keybindings.
**Files:**
- Create: `org/text-input.org`
- Create: `src/components/input.lisp`
- Modify: `src/components/package.lisp` — add exports
- Modify: `cl-tui.asd` — add input.lisp
**TextInput class:**
```lisp
(defclass text-input (dirty-mixin)
((value :initform "" :initarg :value :accessor text-input-value)
(cursor :initform 0 :initarg :cursor :accessor text-input-cursor)
(placeholder :initform "" :initarg :placeholder :accessor text-input-placeholder)
(max-length :initform nil :initarg :max-length :accessor text-input-max-length)
(on-submit :initform nil :initarg :on-submit :accessor text-input-on-submit)
(layout-node :initform (make-layout-node) :accessor text-input-layout-node)
(focusable :initform t :accessor text-input-focusable)))
```
**Methods:**
- `render-text-input` — renders value at cursor position, placeholder when empty, cursor
- `handle-input text-input key-event` — dispatches key events to editing actions:
- Left/Right → cursor-char-left/right
- Home → cursor-line-start
- End → cursor-line-end
- Backspace → delete-char-before
- Delete → delete-char-after
- Printable chars → insert-char
- Enter → on-submit callback
- Ctrl+W → delete-word-before
- Ctrl+U → delete-line-before
- Ctrl+K → delete-line-after
- Ctrl+A → cursor-line-start
- Ctrl+E → cursor-line-end
**Visual:**
```
┌──────────────────────────────┐
│ Hello world| │ ← cursor at position 11
└──────────────────────────────┘
┌──────────────────────────────┐
│ Type something... │ ← placeholder (dimmed)
└──────────────────────────────┘
```
**Tests:**
```lisp
(test input-empty
(let ((in (make-text-input)))
(is (string= (text-input-value in) ""))
(is (= (text-input-cursor in) 0))))
(test input-insert-char
(let ((in (make-text-input)))
(handle-input in (make-key-event :a))
(is (string= (text-input-value in) "a"))
(is (= (text-input-cursor in) 1))))
(test input-backspace
(let ((in (make-text-input :initial-value "ab")))
(setf (text-input-cursor in) 2)
(handle-input in (make-key-event :backspace))
(is (string= (text-input-value in) "a"))
(is (= (text-input-cursor in) 1))))
(test input-max-length
(let ((in (make-text-input :max-length 3)))
(handle-input in (make-key-event :a))
(handle-input in (make-key-event :b))
(handle-input in (make-key-event :c))
(handle-input in (make-key-event :d)) ;; should be ignored
(is (string= (text-input-value in) "abc"))))
(test input-cursor-movement
(let ((in (make-text-input :initial-value "hello")))
(setf (text-input-cursor in) 5)
(handle-input in (make-key-event :left))
(is (= (text-input-cursor in) 4))
(handle-input in (make-key-event :right))
(is (= (text-input-cursor in) 5))
(handle-input in (make-key-event :home))
(is (= (text-input-cursor in) 0))
(handle-input in (make-key-event :end))
(is (= (text-input-cursor in) 5))))
```
**Line count:** ~150 lines
---
### Task 3: Textarea Widget
**Objective:** Multi-line text input with selection, undo/redo, word navigation.
**Files:**
- Create: `org/textarea.org`
- Create: `src/components/textarea.lisp`
- Modify: `src/components/package.lisp` — add exports
- Modify: `cl-tui.asd` — add textarea.lisp
**Textarea class:**
```lisp
(defclass textarea (dirty-mixin)
((value :initform "" :initarg :value :accessor textarea-value)
(cursor-row :initform 0 :accessor textarea-cursor-row)
(cursor-col :initform 0 :accessor textarea-cursor-col)
(selection-start :initform nil :accessor textarea-selection-start) ;; (row . col) or nil
(undo-stack :initform (make-array 100 :fill-pointer 0) :accessor textarea-undo-stack)
(on-submit :initform nil :initarg :on-submit :accessor textarea-on-submit)
(layout-node :initform (make-layout-node) :accessor textarea-layout-node)
(focusable :initform t :accessor textarea-focusable)))
```
**Methods:**
- `render-textarea` — renders visible lines with cursor, optional selection highlight
- `handle-textarea-input textarea key-event` — dispatches
- `textarea-insert-at textarea str` — insert at cursor
- `textarea-delete-before textarea` — backspace
- `textarea-delete-after textarea` — delete
- `textarea-newline textarea` — insert newline
- `textarea-cursor-up/down/left/right` — movement
- `textarea-word-forward/backward` — word skips
- `textarea-select-to textarea` — extend selection to cursor
- `textarea-copy-selection / cut-selection / paste` — clipboard
- `textarea-undo / redo` — undo/redo stack
**Tests:** Similar pattern to TextInput but multi-line, with selection tests.
**Line count:** ~200 lines
---
### Task 4: Keybinding System
**Objective:** Layered keymaps (global → local → input), defkeymap macro, chord sequences.
**Files:**
- Create: `org/keybindings.org`
- Create: `src/components/keybindings.lisp`
- Modify: `src/components/package.lisp` — add exports
- Modify: `cl-tui.asd` — add keybindings.lisp
**Architecture:**
```lisp
(defstruct keymap
name ;; :global, :local, or symbol
bindings ;; alist: ((key-event-spec . handler-function) ...)
parent) ;; parent keymap for fallback
(defmacro defkeymap (name &body bindings)
;; (defkeymap :global
;; (:ctrl+p . command-palette)
;; ((:ctrl+c :ctrl+d) . quit))
`(setf (gethash ',name *keymaps*)
(make-keymap :name ',name
:bindings ',bindings)))
(defparameter *keymaps* (make-hash-table))
;; Dispatch order: focused-component-keymap → local → global
(defun dispatch-key-event (event &key component)
(let* ((local (and component (component-keymap component)))
(global (gethash :global *keymaps*)))
(or (match-and-call local event)
(match-and-call global event))))
(defun match-and-call (keymap event)
(loop for (spec . handler) in (keymap-bindings keymap)
thereis (when (key-match-p spec event)
(funcall handler event))))
;; Key spec matching
(defun key-match-p (spec event)
(etypecase spec
(keyword (eql spec (key-event-key event)))
(list (and (eql (first spec) (key-event-key event))
(eql (getf (rest spec) :ctrl) (key-event-ctrl event))
(eql (getf (rest spec) :alt) (key-event-alt event))))))
```
**Chord support:** Two-key sequences with timeout:
```lisp
(defparameter *chord-timeout* 0.5) ;; seconds
(defun handle-chord (first-event)
(when (chord-p first-event) ;; first key has pending status
(let ((second-event (read-event-from-stdin :timeout *chord-timeout*)))
(if (key-event-p second-event)
(dispatch-key-event (combine-chord first-event second-event))
;; timeout — dispatch first event as standalone
(dispatch-key-event first-event)))))
```
**Tests:**
```lisp
(test keymap-simple
(let ((called nil))
(setf (gethash :test *keymaps*)
(make-keymap :name :test
:bindings `((:ctrl+p . ,(lambda (e) (setf called t))))))
(dispatch-key-event (make-key-event :p :ctrl t))
(is-true called)))
(test keymap-fallback
(let ((global-called nil) (local-called nil))
(setf (gethash :global *keymaps*)
(make-keymap :name :global
:bindings `((:ctrl+q . ,(lambda (e) (setf global-called t))))))
;; Event not in local should fall through
(dispatch-key-event (make-key-event :q :ctrl t))
(is-true global-called)))
(test chord-sequence
(let ((called nil))
(setf (gethash :global *keymaps*)
(make-keymap :name :global
:bindings `(((:ctrl+c :ctrl+d) . ,(lambda (e) (setf called t))))))
;; Simulate chord
(handler-chord (make-key-event :c :ctrl t) (make-key-event :d :ctrl t))
(is-true called)))
```
**Line count:** ~150 lines
---
### Dependency Order
```
Task 1 (input infra) ──→ Task 2 (TextInput) ──→ Task 3 (Textarea)
└──→ Task 4 (keybinding) ──→ uses both
```
Task 1 is the prerequisite for everything. Tasks 2, 3, 4 can then proceed in parallel (2 and 3 depend on 1, 4 depends on key events from 1).
---
### Verification
After each task:
1. `sbcl --eval "(asdf:test-system :cl-tui)" --quit` — all tests GREEN
2. `scripts/validate-parens.py` — all files balanced
3. Commit with RED/GREEN evidence
Final verification:
- All 4 phases implemented and tested
- ~750 lines total across all components
- Full test suite: ~100+ assertions, 100% GREEN

188
layout/layout.lisp Normal file
View File

@@ -0,0 +1,188 @@
;;; layout — Pure CL Flexbox layout engine
(defpackage :cl-tui.layout
(:use :cl)
(:export
#:layout-node #:make-layout-node
#:layout-node-add-child #:layout-node-remove-child
#:layout-node-children
#:layout-node-x #:layout-node-y
#:layout-node-width #:layout-node-height
#:layout-node-direction
#:compute-layout
#:vbox #:hbox #:spacer
;; For tests
#:layout-node-parent #:layout-node-fixed-width
#:layout-node-fixed-height #:normalize-box
#:box-edge))
(in-package :cl-tui.layout)
(defun normalize-box (spec)
(cond ((null spec) '(:top 0 :right 0 :bottom 0 :left 0))
((numberp spec) `(:top ,spec :right ,spec :bottom ,spec :left ,spec))
((getf spec :top) spec)
(t '(:top 0 :right 0 :bottom 0 :left 0))))
(defun box-edge (box edge)
(or (getf box edge) 0))
(defclass layout-node ()
((parent :initform nil :accessor layout-node-parent)
(children :initform nil :accessor layout-node-children)
(x :initform 0 :accessor layout-node-x)
(y :initform 0 :accessor layout-node-y)
(width :initform 0 :accessor layout-node-width)
(height :initform 0 :accessor layout-node-height)
(direction :initform :column :initarg :direction :accessor layout-node-direction)
(grow :initform 0 :initarg :grow :accessor layout-node-grow)
(shrink :initform 1 :initarg :shrink :accessor layout-node-shrink)
(padding :initform '(:top 0 :right 0 :bottom 0 :left 0) :initarg :padding :accessor layout-node-padding)
(margin :initform '(:top 0 :right 0 :bottom 0 :left 0) :initarg :margin :accessor layout-node-margin)
(gap :initform 0 :initarg :gap :accessor layout-node-gap)
(position-type :initform :relative :initarg :position-type :accessor layout-node-position-type)
(position-offset :initform nil :initarg :position-offset :accessor layout-node-position-offset)
(fixed-width :initform nil :initarg :width :accessor layout-node-fixed-width)
(fixed-height :initform nil :initarg :height :accessor layout-node-fixed-height)))
(defun make-layout-node (&key direction grow shrink padding margin gap
position-type position-offset width height)
(make-instance 'layout-node
:direction (or direction :column)
:grow (or grow 0) :shrink (or shrink 1)
:padding (normalize-box padding) :margin (normalize-box margin)
:gap (or gap 0)
:position-type (or position-type :relative)
:position-offset position-offset
:width width :height height))
(defun layout-node-add-child (parent child)
(setf (layout-node-parent child) parent)
(setf (layout-node-children parent)
(nconc (layout-node-children parent) (list child)))
child)
(defun layout-node-remove-child (parent child)
(setf (layout-node-parent child) nil)
(setf (layout-node-children parent)
(delete child (layout-node-children parent)))
child)
;; ── Solver ─────────────────────────────────────────────────────
(defun distribute-sizes (children avail gap horizontal)
"Compute child sizes given available space and gap.
HORIZONTAL is non-nil when distributing width (row layout).
Each child starts from its fixed size (if any). Remaining space
is distributed by grow ratio; overflow is reduced by shrink ratio."
(let* ((n (length children))
(gap-total (* gap (max 0 (1- n))))
(base (mapcar (lambda (c)
(or (if horizontal
(layout-node-fixed-width c)
(layout-node-fixed-height c))
0))
children))
(base-total (reduce #'+ base))
(remaining (- avail base-total gap-total))
(grow-total (reduce #'+ (mapcar #'layout-node-grow children)))
(shrink-total (reduce #'+ (mapcar #'layout-node-shrink children))))
(mapcar (lambda (c b)
(let ((sz b))
(when (and (plusp remaining) (plusp grow-total))
(incf sz (round (* remaining (/ (layout-node-grow c) grow-total)))))
(when (and (minusp remaining) (plusp shrink-total))
(decf sz (round (* (abs remaining) (/ (layout-node-shrink c) shrink-total)))))
(max 1 sz)))
children base)))
(defun compute-layout (root available-width available-height)
"Layout all children of ROOT within the given dimensions.
Recursively computes position and size for every node."
(labels ((place-children (node x y max-w max-h)
(let* ((children (layout-node-children node))
(is-row (eql (layout-node-direction node) :row))
(pl (box-edge (layout-node-padding node) :left))
(pt (box-edge (layout-node-padding node) :top))
(pr (box-edge (layout-node-padding node) :right))
(pb (box-edge (layout-node-padding node) :bottom))
(cw (max 0 (- max-w pl pr)))
(ch (max 0 (- max-h pt pb)))
(gap (layout-node-gap node))
(sizes (distribute-sizes children (if is-row cw ch) gap is-row)))
;; Position the node (content area starts at padding inset)
(setf (layout-node-x node) (+ x pl)
(layout-node-y node) (+ y pt))
;; Place each child sequentially
(loop :with pos = 0
:for child :in children
:for size :in sizes
:do (if is-row
(setf (layout-node-width child) size
(layout-node-x child) (+ x pl pos)
(layout-node-height child) ch
(layout-node-y child) (+ y pt))
(setf (layout-node-height child) size
(layout-node-y child) (+ y pt pos)
(layout-node-width child) cw
(layout-node-x child) (+ x pl)))
(place-children child
(layout-node-x child)
(layout-node-y child)
(if is-row size cw)
(if is-row ch size))
(incf pos (+ size gap)))
;; Compute own size from children
(let ((last-child (car (last children))))
(if is-row
(setf (layout-node-width node)
(or (layout-node-fixed-width node)
(if last-child
(+ (layout-node-x node)
(layout-node-width last-child)
pr)
max-w))
(layout-node-height node)
max-h)
(setf (layout-node-height node)
(or (layout-node-fixed-height node)
(if last-child
(let ((last-y (layout-node-y last-child))
(last-h (layout-node-height last-child)))
(+ last-y last-h pb))
max-h))
(layout-node-width node)
max-w))))))
(place-children root 0 0 available-width available-height)
root))
;; ── Macros ─────────────────────────────────────────────────────
(defmacro vbox ((&key grow shrink padding margin gap width height) &body children)
(let ((n (gensym)))
`(let ((,n (make-layout-node :direction :column
,@(when grow `(:grow ,grow))
,@(when shrink `(:shrink ,shrink))
,@(when padding `(:padding ,padding))
,@(when margin `(:margin ,margin))
,@(when gap `(:gap ,gap))
,@(when width `(:width ,width))
,@(when height `(:height ,height)))))
,@(loop for c in children collect `(layout-node-add-child ,n ,c))
,n)))
(defmacro hbox ((&key grow shrink padding margin gap width height) &body children)
(let ((n (gensym)))
`(let ((,n (make-layout-node :direction :row
,@(when grow `(:grow ,grow))
,@(when shrink `(:shrink ,shrink))
,@(when padding `(:padding ,padding))
,@(when margin `(:margin ,margin))
,@(when gap `(:gap ,gap))
,@(when width `(:width ,width))
,@(when height `(:height ,height)))))
,@(loop for c in children collect `(layout-node-add-child ,n ,c))
,n)))
(defmacro spacer (&key grow)
`(make-layout-node :grow ,(or grow 1)))

175
layout/tests.lisp Normal file
View File

@@ -0,0 +1,175 @@
(defpackage :cl-tui-layout-test
(:use :cl :fiveam :cl-tui.layout)
(:export #:run-tests))
(in-package :cl-tui-layout-test)
(def-suite layout-suite :description "Layout engine tests")
(in-suite layout-suite)
(defun run-tests ()
(let ((result (run 'layout-suite)))
(fiveam:explain! result)
(uiop:quit 0)))
(test make-layout-node-defaults
(let ((n (make-layout-node)))
(is (typep n 'layout-node))
(is (eql (layout-node-direction n) :column))))
(test make-layout-node-row
(let ((n (make-layout-node :direction :row)))
(is (eql (layout-node-direction n) :row))))
(test add-child-sets-parent
(let ((parent (make-layout-node)) (child (make-layout-node)))
(layout-node-add-child parent child)
(is (eql (layout-node-parent child) parent))
(is (= (length (layout-node-children parent)) 1))))
(test remove-child-clears-parent
(let ((parent (make-layout-node)) (child (make-layout-node)))
(layout-node-add-child parent child)
(layout-node-remove-child parent child)
(is (null (layout-node-parent child)))
(is (= (length (layout-node-children parent)) 0))))
(test column-two-children-vertical
(let* ((root (make-layout-node :direction :column))
(c1 (make-layout-node :height 3))
(c2 (make-layout-node :height 5)))
(layout-node-add-child root c1) (layout-node-add-child root c2)
(compute-layout root 20 20)
(is (= (layout-node-y c1) 0)) (is (= (layout-node-height c1) 3))
(is (= (layout-node-y c2) 3)) (is (= (layout-node-height c2) 5))))
(test row-two-children-horizontal
(let* ((root (make-layout-node :direction :row))
(c1 (make-layout-node :width 10))
(c2 (make-layout-node :width 5)))
(layout-node-add-child root c1) (layout-node-add-child root c2)
(compute-layout root 20 10)
(is (= (layout-node-x c1) 0)) (is (= (layout-node-width c1) 10))
(is (= (layout-node-x c2) 10)) (is (= (layout-node-width c2) 5))))
(test flex-grow-distributes-space
(let* ((root (make-layout-node :direction :row :width 20))
(c1 (make-layout-node :width 4 :grow 1))
(c2 (make-layout-node :width 4 :grow 2)))
(layout-node-add-child root c1) (layout-node-add-child root c2)
(compute-layout root 20 10)
(is (= (layout-node-width c1) 8)) (is (= (layout-node-width c2) 12))))
(test flex-grow-single-child
(let* ((root (make-layout-node :direction :row :width 20))
(c (make-layout-node :width 5 :grow 1)))
(layout-node-add-child root c)
(compute-layout root 20 10)
(is (= (layout-node-width c) 20))))
(test flex-shrink-reduces-overflow
(let* ((root (make-layout-node :direction :row :width 10))
(c1 (make-layout-node :width 8 :shrink 1))
(c2 (make-layout-node :width 8 :shrink 1)))
(layout-node-add-child root c1) (layout-node-add-child root c2)
(compute-layout root 10 10)
(is (= (layout-node-width c1) 5)) (is (= (layout-node-width c2) 5))))
(test padding-reduces-content-area
(let* ((root (make-layout-node :direction :column :padding '(:top 1 :left 1 :bottom 1 :right 1)))
(c (make-layout-node :height 3)))
(layout-node-add-child root c)
(compute-layout root 20 10)
(is (= (layout-node-x c) 1)) (is (= (layout-node-y c) 1))
(is (= (layout-node-height c) 3))))
(test gap-between-children
(let* ((root (make-layout-node :direction :column :gap 2))
(c1 (make-layout-node :height 3))
(c2 (make-layout-node :height 3)))
(layout-node-add-child root c1) (layout-node-add-child root c2)
(compute-layout root 20 20)
(is (= (layout-node-y c1) 0)) (is (= (layout-node-y c2) 5))))
(test vbox-macro
(let ((r (vbox () (make-layout-node :height 3) (make-layout-node :height 5))))
(compute-layout r 20 20)
(is (= (length (layout-node-children r)) 2))
(is (= (layout-node-y (elt (layout-node-children r) 1)) 3))))
(test hbox-macro
(let ((r (hbox () (make-layout-node :width 5) (make-layout-node :width 3))))
(compute-layout r 20 10)
(is (= (length (layout-node-children r)) 2))
(is (= (layout-node-x (elt (layout-node-children r) 1)) 5))))
(test spacer-takes-grow
(let ((r (hbox (:width 20) (make-layout-node :width 5) (spacer :grow 1) (make-layout-node :width 5))))
(compute-layout r 20 10)
(let ((c (layout-node-children r)))
(is (= (layout-node-x (elt c 2)) 15)) (is (= (layout-node-width (elt c 1)) 10)))))
(test nested-vbox-in-hbox
(let* ((sidebar (vbox (:width 5 :height 10) (make-layout-node :height 3) (make-layout-node :height 7)))
(main (vbox (:grow 1 :height 10) (make-layout-node :height 2) (make-layout-node :grow 1)))
(r (hbox (:width 30 :height 10) sidebar main)))
(compute-layout r 30 10)
(is (= (layout-node-width sidebar) 5))
(is (>= (layout-node-width main) 20))
(let ((sc (layout-node-children sidebar)))
(is (= (layout-node-y (elt sc 0)) 0))
(is (= (layout-node-y (elt sc 1)) 3)))))
;; ── Edge Cases ────────────────────────────────────────────────
(test empty-container-does-not-crash
"compute-layout on a node with no children should not error"
(let ((r (make-layout-node)))
(compute-layout r 20 20)
(is (integerp (layout-node-width r)))
(is (integerp (layout-node-height r)))))
(test single-child-in-column
"A column with one child places it correctly"
(let* ((r (make-layout-node :direction :column :width 10 :height 20))
(c (make-layout-node :height 5)))
(layout-node-add-child r c)
(compute-layout r 10 20)
(is (= (layout-node-y c) 0))
(is (= (layout-node-height c) 5))))
(test zero-size-container
"compute-layout with zero available space should not error"
(let* ((r (make-layout-node :direction :column))
(c (make-layout-node :height 5)))
(layout-node-add-child r c)
(compute-layout r 0 0)
(is (integerp (layout-node-x c)))
(is (integerp (layout-node-y c)))))
(test deep-nesting-three-levels
"Three-level deep nesting produces correct leaf positions"
(let* ((out (vbox () ; outer box
(vbox (:grow 1) ; middle box
(make-layout-node :height 2)))) ; leaf
(leaf (elt (layout-node-children
(elt (layout-node-children out) 0)) 0)))
(compute-layout out 20 20)
(is (= (layout-node-y leaf) 0))))
(test large-padding-leaves-room
"Large padding reduces content area but doesn't crash"
(let* ((r (make-layout-node :direction :column
:padding '(:top 5 :left 5 :bottom 5 :right 5)))
(c (make-layout-node :height 3)))
(layout-node-add-child r c)
(compute-layout r 20 20)
(is (= (layout-node-x c) 5))
(is (= (layout-node-y c) 5))))
(test negative-grow-is-clamped
"Grow values are adjusted but still compute"
(let* ((r (make-layout-node :direction :row :width 10))
(c (make-layout-node :width 5 :grow -1)))
(layout-node-add-child r c)
(compute-layout r 10 10)
(is (integerp (layout-node-width c)))))

View File

@@ -1,194 +0,0 @@
(eval-when (:compile-toplevel :load-toplevel :execute)
(ql:quickload :cffi :silent t)
(ql:quickload :trivial-garbage :silent t))
(defpackage :cl-tui.layout-composable
(:use :cl :cl-tui.layout-primitives)
(:export
#:vbox
#:hbox
#:overlay
#:spacer))
(in-package :cl-tui.layout-composable)
(defun apply-common-props (node &key width height flex-grow flex-shrink flex-basis
align justify gap padding margin
&allow-other-keys)
"Apply the shared style properties to a layout-node."
(when (or width height)
(layout-node-set-dimension node (or width 0) (or height 0)))
(when (or flex-grow flex-shrink flex-basis)
(layout-node-set-flex node :grow flex-grow :shrink flex-shrink :basis flex-basis))
(when align
(apply #'layout-node-set-align node align))
(when justify
(layout-node-set-justify node justify))
(when gap
(apply #'layout-node-set-gap node gap))
(when padding
(apply #'layout-node-set-padding node padding))
(when margin
(apply #'layout-node-set-margin node margin)))
(defun add-children (parent children)
"Add each child in CHILDREN to PARENT. Non-node values are skipped."
(dolist (child children)
(when (typep child 'layout-node)
(layout-node-add-child parent child))))
(defun make-props-list (args)
"Extract all properties except :children from ARGS plist."
(loop for (k v) on args by #'cddr
unless (eq k :children)
append (list k v)))
(defmacro vbox (&rest args &key children &allow-other-keys)
"Create a column-direction container with CHILDREN stacked vertically."
(declare (ignore children))
(let* ((node (gensym "VBOX"))
(props (make-props-list args)))
`(let ((,node (make-layout-node)))
(layout-node-set-direction ,node :column)
(apply #'cl-tui.layout-composable::apply-common-props ,node ',props)
(cl-tui.layout-composable::add-children ,node (list ,@children))
,node)))
(defmacro hbox (&rest args &key children &allow-other-keys)
"Create a row-direction container with CHILDREN laid out horizontally."
(declare (ignore children))
(let* ((node (gensym "HBOX"))
(props (make-props-list args)))
`(let ((,node (make-layout-node)))
(layout-node-set-direction ,node :row)
(apply #'cl-tui.layout-composable::apply-common-props ,node ',props)
(cl-tui.layout-composable::add-children ,node (list ,@children))
,node)))
(defmacro overlay (base child &key top right bottom left)
"Create a container with BASE as the relative foundation and CHILD
positioned absolutely on top."
(let ((node (gensym "OVERLAY")))
`(let ((,node (make-layout-node)))
(layout-node-set-position ,node :relative)
(layout-node-add-child ,node ,base)
(layout-node-set-position ,child :absolute
,@(when top `(:top ,top))
,@(when right `(:right ,right))
,@(when bottom `(:bottom ,bottom))
,@(when left `(:left ,left)))
(layout-node-add-child ,node ,child)
,node)))
(defun spacer (&key (grow 0))
"Create an empty spacer node that fills available space via flex-grow."
(let ((node (make-layout-node)))
(when (> grow 0)
(layout-node-set-flex node :grow grow))
node))
(eval-when (:compile-toplevel :load-toplevel :execute)
(ql:quickload :fiveam :silent t))
(defpackage :cl-tui.layout-composable-tests
(:use :cl :fiveam)
(:import-from :cl-tui.layout-composable
#:vbox #:hbox #:overlay #:spacer)
(:import-from :cl-tui.layout-primitives
#:layout-node #:layout-node-ptr #:layout-calculate
#:make-layout-node #:layout-node-set-dimension)
(:import-from :cl-tui.yoga-ffi
#:yg-node-layout-get-left
#:yg-node-layout-get-top
#:yg-node-layout-get-width
#:yg-node-layout-get-height
#:yg-node-get-child))
(in-package :cl-tui.layout-composable-tests)
(defun node-x (n) (yg-node-layout-get-left (layout-node-ptr n)))
(defun node-y (n) (yg-node-layout-get-top (layout-node-ptr n)))
(defun node-w (n) (yg-node-layout-get-width (layout-node-ptr n)))
(defun node-h (n) (yg-node-layout-get-height (layout-node-ptr n)))
(defun child-x (p) (yg-node-layout-get-left p))
(defun child-y (p) (yg-node-layout-get-top p))
(defun child-w (p) (yg-node-layout-get-width p))
(defun child-h (p) (yg-node-layout-get-height p))
(defun nth-child (node n)
(yg-node-get-child (layout-node-ptr node) n))
(defun layout-dummy (w h)
(let ((n (make-layout-node)))
(layout-node-set-dimension n w h)
n))
(fiveam:def-suite layout-composable-suite
:description "Composable API macro verification")
(fiveam:in-suite layout-composable-suite)
(fiveam:test test-vbox-stacks-children
"Contract: vbox stacks children vertically."
(let* ((root (vbox :width 100 :height 200
:children ((layout-dummy 100 50)
(layout-dummy 100 50)))))
(layout-calculate root 100 200)
(let ((c1 (nth-child root 0))
(c2 (nth-child root 1)))
(fiveam:is (= 0.0 (child-y c1)))
(fiveam:is (= 50.0 (child-y c2))))))
(fiveam:test test-hbox-lays-out-horizontally
"Contract: hbox places children horizontally."
(let* ((root (hbox :width 200 :height 100
:children ((layout-dummy 80 50)
(layout-dummy 80 50)))))
(layout-calculate root 200 100)
(let ((c1 (nth-child root 0))
(c2 (nth-child root 1)))
(fiveam:is (= 0.0 (child-x c1)))
(fiveam:is (= 80.0 (child-x c2))))))
(fiveam:test test-spacer-flex-grow
"Contract: spacer with flex-grow expands to fill space."
(let* ((root (hbox :width 200 :height 100
:children ((layout-dummy 50 50)
(spacer :grow 1)
(layout-dummy 50 50)))))
(layout-calculate root 200 100)
(let ((c1 (nth-child root 0))
(c2 (nth-child root 1))
(c3 (nth-child root 2)))
(fiveam:is (= 0.0 (child-x c1)))
(fiveam:is (= 50.0 (child-w c1)))
(fiveam:is (= 100.0 (child-w c2))))))
(fiveam:test test-overlay-absolute-position
"Contract: overlay positions an absolute child over a relative base."
(let* ((base (layout-dummy 100 100))
(child (layout-dummy 30 30))
(root (overlay base child :top 10 :left 20)))
(layout-calculate root 200 200)
(fiveam:is (= 20.0 (node-x child)))
(fiveam:is (= 10.0 (node-y child)))))
(fiveam:test test-vbox-align-justify
"Contract: vbox accepts align and justify keywords."
(let* ((root (vbox :width 200 :height 100
:align (:items :center)
:justify :center
:children ((layout-dummy 50 50)))))
(layout-calculate root 200 100)
(let ((c (nth-child root 0)))
(fiveam:is (= 25.0 (child-y c)))
(fiveam:is (= 75.0 (child-x c))))))
(fiveam:test test-vbox-padding
"Contract: vbox padding offsets children."
(let* ((root (vbox :width 200 :height 100
:padding (:all 10)
:children ((layout-dummy 180 80)))))
(layout-calculate root 200 100)
(let ((c (nth-child root 0)))
(fiveam:is (= 10.0 (child-x c)))
(fiveam:is (= 10.0 (child-y c))))))

View File

@@ -1,511 +0,0 @@
(eval-when (:compile-toplevel :load-toplevel :execute)
(ql:quickload :cffi :silent t)
(ql:quickload :trivial-garbage :silent t))
(defpackage :cl-tui.layout-primitives
(:use :cl)
(:import-from :cl-tui.yoga-ffi
#:load-yoga
#:yg-node-new
#:yg-node-free
#:yg-node-insert-child
#:yg-node-remove-child
#:yg-node-get-child-count
#:yg-node-calculate-layout
#:yg-node-layout-get-left
#:yg-node-layout-get-top
#:yg-node-layout-get-width
#:yg-node-layout-get-height
#:yg-node-layout-get-right
#:yg-node-layout-get-bottom
#:yg-node-style-set-direction
#:yg-node-style-set-flex-direction
#:yg-node-style-set-justify-content
#:yg-node-style-set-align-items
#:yg-node-style-set-align-self
#:yg-node-style-set-align-content
#:yg-node-style-set-flex-wrap
#:yg-node-style-set-position-type
#:yg-node-style-set-flex-grow
#:yg-node-style-set-flex-shrink
#:yg-node-style-set-flex-basis
#:yg-node-style-set-flex-basis-auto
#:yg-node-style-set-overflow
#:yg-node-style-set-display
#:yg-node-style-set-width
#:yg-node-style-set-width-auto
#:yg-node-style-set-height
#:yg-node-style-set-height-auto
#:yg-node-style-set-min-width
#:yg-node-style-set-min-height
#:yg-node-style-set-max-width
#:yg-node-style-set-max-height
#:yg-node-style-set-aspect-ratio
#:yg-node-style-set-padding
#:yg-node-style-set-margin
#:yg-node-style-set-margin-auto
#:yg-node-style-set-border
#:yg-node-style-set-gap
#:yg-node-style-set-position
;; enum constants
#:+yg-flex-direction-column+
#:+yg-flex-direction-column-reverse+
#:+yg-flex-direction-row+
#:+yg-flex-direction-row-reverse+
#:+yg-wrap-nowrap+
#:+yg-wrap-wrap+
#:+yg-wrap-wrap-reverse+
#:+yg-justify-auto+
#:+yg-justify-flex-start+
#:+yg-justify-center+
#:+yg-justify-flex-end+
#:+yg-justify-space-between+
#:+yg-justify-space-around+
#:+yg-justify-space-evenly+
#:+yg-align-auto+
#:+yg-align-flex-start+
#:+yg-align-center+
#:+yg-align-flex-end+
#:+yg-align-stretch+
#:+yg-align-baseline+
#:+yg-align-space-between+
#:+yg-align-space-around+
#:+yg-align-space-evenly+
#:+yg-position-type-static+
#:+yg-position-type-relative+
#:+yg-position-type-absolute+
#:+yg-overflow-visible+
#:+yg-overflow-hidden+
#:+yg-overflow-scroll+
#:+yg-display-flex+
#:+yg-display-none+
#:+yg-edge-left+
#:+yg-edge-top+
#:+yg-edge-right+
#:+yg-edge-bottom+
#:+yg-edge-all+
#:+yg-edge-start+
#:+yg-edge-end+
#:+yg-edge-horizontal+
#:+yg-edge-vertical+
#:+yg-gutter-column+
#:+yg-gutter-row+
#:+yg-gutter-all+
#:+yg-direction-inherit+
#:+yg-direction-ltr+
#:+yg-direction-rtl+)
(:export
#:layout-node
#:layout-node-ptr
#:make-layout-node
#:layout-node-add-child
#:layout-node-set-dimension
#:layout-node-set-flex
#:layout-node-set-direction
#:layout-node-set-wrap
#:layout-node-set-align
#:layout-node-set-justify
#:layout-node-set-padding
#:layout-node-set-margin
#:layout-node-set-gap
#:layout-node-set-position
#:layout-node-set-border
#:layout-node-set-overflow
#:layout-node-set-display
#:layout-node-set-aspect-ratio
#:layout-calculate))
(in-package :cl-tui.layout-primitives)
;; Keyword → integer translation tables. Used by setter functions
;; so callers use (:flex-start) instead of (+yg-justify-flex-start+).
(defparameter *flex-direction-map*
'((:column . 0) (:column-reverse . 1) (:row . 2) (:row-reverse . 3)))
(defparameter *wrap-map*
'((:nowrap . 0) (:wrap . 1) (:wrap-reverse . 2)))
(defparameter *justify-map*
'((:auto . 0) (:flex-start . 1) (:center . 2) (:flex-end . 3)
(:space-between . 4) (:space-around . 5) (:space-evenly . 6)))
(defparameter *align-map*
'((:auto . 0) (:flex-start . 1) (:center . 2) (:flex-end . 3)
(:stretch . 4) (:baseline . 5) (:space-between . 6) (:space-around . 7)
(:space-evenly . 8)))
(defparameter *position-type-map*
'((:static . 0) (:relative . 1) (:absolute . 2)))
(defparameter *overflow-map*
'((:visible . 0) (:hidden . 1) (:scroll . 2)))
(defparameter *display-map*
'((:flex . 0) (:none . 1)))
(defparameter *edge-map*
'((:left . 0) (:top . 1) (:right . 2) (:bottom . 3)
(:start . 4) (:end . 5) (:horizontal . 6) (:vertical . 7) (:all . 8)))
(defparameter *direction-map*
'((:inherit . 0) (:ltr . 1) (:rtl . 2)))
(defun resolve-enum (map keyword)
"Look up KEYWORD in MAP (an alist). Throws if not found."
(or (cdr (assoc keyword map))
(error "Unknown enum keyword ~a" keyword)))
(defclass layout-node ()
((ptr :initarg :ptr :reader layout-node-ptr
:documentation "Raw YGNodeRef pointer")))
(defmethod print-object ((node layout-node) stream)
(print-unreadable-object (node stream :type t)
(format stream "~a" (layout-node-ptr node))))
(defun make-layout-node ()
"Allocate a new Yoga node and wrap it in a layout-node."
(let ((node (make-instance 'layout-node :ptr (yg-node-new))))
(tg:finalize node (lambda () (yg-node-free (layout-node-ptr node))))
node))
(defun layout-node-add-child (parent child)
"Insert CHILD at the end of PARENT's children list."
(let ((count (yg-node-get-child-count (layout-node-ptr parent))))
(yg-node-insert-child (layout-node-ptr parent) (layout-node-ptr child) count)))
(defun layout-node-set-dimension (node width height)
"Set fixed width and height in points."
(yg-node-style-set-width (layout-node-ptr node) (coerce width 'single-float))
(yg-node-style-set-height (layout-node-ptr node) (coerce height 'single-float)))
(defun layout-node-set-flex (node &key grow shrink basis)
"Set flex properties. Unspecified keys are left unchanged."
(let ((p (layout-node-ptr node)))
(when grow (yg-node-style-set-flex-grow p (coerce grow 'single-float)))
(when shrink (yg-node-style-set-flex-shrink p (coerce shrink 'single-float)))
(when basis (yg-node-style-set-flex-basis p (coerce basis 'single-float)))))
(defun layout-node-set-aspect-ratio (node ratio)
"Set aspect ratio (width/height)."
(yg-node-style-set-aspect-ratio (layout-node-ptr node) (coerce ratio 'single-float)))
(defun layout-node-set-direction (node direction)
"Set flex-direction. DIRECTION is :column, :column-reverse, :row, or :row-reverse."
(yg-node-style-set-flex-direction
(layout-node-ptr node)
(resolve-enum *flex-direction-map* direction)))
(defun layout-node-set-wrap (node wrap)
"Set flex-wrap. WRAP is :nowrap, :wrap, or :wrap-reverse."
(yg-node-style-set-flex-wrap
(layout-node-ptr node)
(resolve-enum *wrap-map* wrap)))
(defun layout-node-set-align (node &key items self content)
"Set align-items, align-self, align-content. Values are keywords like :flex-start."
(let ((p (layout-node-ptr node)))
(when items (yg-node-style-set-align-items p (resolve-enum *align-map* items)))
(when self (yg-node-style-set-align-self p (resolve-enum *align-map* self)))
(when content (yg-node-style-set-align-content p (resolve-enum *align-map* content)))))
(defun layout-node-set-justify (node justify)
"Set justify-content. JUSTIFY is :flex-start, :center, :flex-end, :space-between, etc."
(yg-node-style-set-justify-content
(layout-node-ptr node)
(resolve-enum *justify-map* justify)))
(defun layout-node-set-position (node type &key top right bottom left)
"Set position type and offsets. TYPE is :static, :relative, or :absolute."
(let ((p (layout-node-ptr node)))
(yg-node-style-set-position-type p (resolve-enum *position-type-map* type))
(when left (yg-node-style-set-position p +yg-edge-left+ (coerce left 'single-float)))
(when top (yg-node-style-set-position p +yg-edge-top+ (coerce top 'single-float)))
(when right (yg-node-style-set-position p +yg-edge-right+ (coerce right 'single-float)))
(when bottom (yg-node-style-set-position p +yg-edge-bottom+ (coerce bottom 'single-float)))))
(defun set-edges (p fn all top right bottom left x y)
"Helper: call FN on each specified edge. FN is (fn ptr edge value)."
(flet ((s (edge val) (funcall fn p edge (coerce val 'single-float))))
(when all (dolist (e (list +yg-edge-left+ +yg-edge-top+ +yg-edge-right+ +yg-edge-bottom+))
(s e all)))
(when top (s +yg-edge-top+ top))
(when right (s +yg-edge-right+ right))
(when bottom (s +yg-edge-bottom+ bottom))
(when left (s +yg-edge-left+ left))
(when x (s +yg-edge-horizontal+ x))
(when y (s +yg-edge-vertical+ y))))
(defun layout-node-set-padding (node &key all top right bottom left x y)
"Set padding on specified edges in points."
(set-edges (layout-node-ptr node) #'yg-node-style-set-padding all top right bottom left x y))
(defun layout-node-set-margin (node &key all top right bottom left x y)
"Set margin on specified edges in points."
(set-edges (layout-node-ptr node) #'yg-node-style-set-margin all top right bottom left x y))
(defun layout-node-set-border (node width &key all top right bottom left x y)
"Set border width on specified edges."
(let ((p (layout-node-ptr node)))
(flet ((s (edge val) (yg-node-style-set-border p edge (coerce val 'single-float))))
(when all (dolist (e (list +yg-edge-left+ +yg-edge-top+ +yg-edge-right+ +yg-edge-bottom+))
(s e all)))
(when top (s +yg-edge-top+ top))
(when right (s +yg-edge-right+ right))
(when bottom (s +yg-edge-bottom+ bottom))
(when left (s +yg-edge-left+ left))
(when x (s +yg-edge-horizontal+ x))
(when y (s +yg-edge-vertical+ y)))))
(defun layout-node-set-gap (node &key row column)
"Set gap between children."
(let ((p (layout-node-ptr node)))
(when row (yg-node-style-set-gap p +yg-gutter-row+ (coerce row 'single-float)))
(when column (yg-node-style-set-gap p +yg-gutter-column+ (coerce column 'single-float)))))
(defun layout-node-set-overflow (node overflow)
"Set overflow mode. OVERFLOW is :visible, :hidden, or :scroll."
(yg-node-style-set-overflow
(layout-node-ptr node)
(resolve-enum *overflow-map* overflow)))
(defun layout-node-set-display (node display)
"Set display mode. DISPLAY is :flex or :none."
(yg-node-style-set-display
(layout-node-ptr node)
(resolve-enum *display-map* display)))
(defun layout-calculate (root width height &optional (direction :ltr))
"Run Yoga layout on the tree rooted at ROOT.
Returns ROOT (for chaining). Each node's computed position is available via
the raw FFI layout getter functions (yg-node-layout-get-left etc.)."
(yg-node-calculate-layout
(layout-node-ptr root)
(coerce width 'single-float)
(coerce height 'single-float)
(resolve-enum *direction-map* direction))
root)
(eval-when (:compile-toplevel :load-toplevel :execute)
(ql:quickload :fiveam :silent t))
(defpackage :cl-tui.layout-primitives-tests
(:use :cl :fiveam)
(:import-from :cl-tui.layout-primitives
#:make-layout-node
#:layout-node-add-child
#:layout-node-set-dimension
#:layout-node-set-flex
#:layout-node-set-direction
#:layout-node-set-wrap
#:layout-node-set-align
#:layout-node-set-justify
#:layout-node-set-padding
#:layout-node-set-margin
#:layout-node-set-gap
#:layout-node-set-position
#:layout-node-set-border
#:layout-node-set-overflow
#:layout-node-set-display
#:layout-node-set-aspect-ratio
#:layout-calculate
#:layout-node-ptr)
(:import-from :cl-tui.yoga-ffi
#:yg-node-layout-get-left
#:yg-node-layout-get-top
#:yg-node-layout-get-width
#:yg-node-layout-get-height))
(in-package :cl-tui.layout-primitives-tests)
(fiveam:def-suite layout-primitives-suite
:description "Layout primitive CLOS wrappers verification")
(fiveam:in-suite layout-primitives-suite)
(defun node-x (node) (yg-node-layout-get-left (layout-node-ptr node)))
(defun node-y (node) (yg-node-layout-get-top (layout-node-ptr node)))
(defun node-w (node) (yg-node-layout-get-width (layout-node-ptr node)))
(defun node-h (node) (yg-node-layout-get-height (layout-node-ptr node)))
(fiveam:test test-make-layout-node
"Contract: make-layout-node returns a live node."
(let ((n (make-layout-node)))
(fiveam:is (not (cffi:null-pointer-p (layout-node-ptr n))))))
(fiveam:test test-layout-node-add-child
"Contract: adding a child makes it appear in the tree."
(let* ((parent (make-layout-node))
(child (make-layout-node)))
(layout-node-add-child parent child)
(layout-node-set-dimension parent 100 100)
(layout-node-set-dimension child 50 50)
(layout-calculate parent 100 100)
(fiveam:is (= 50.0 (node-w child)))
(fiveam:is (= 50.0 (node-h child)))))
(fiveam:test test-set-dimension
"Contract: layout-node-set-dimension sets width and height."
(let ((n (make-layout-node)))
(layout-node-set-dimension n 200 100)
(layout-calculate n 200 100)
(fiveam:is (= 200.0 (node-w n)))
(fiveam:is (= 100.0 (node-h n)))))
(fiveam:test test-set-direction-column
"Contract: column direction stacks children vertically."
(let* ((root (make-layout-node))
(a (make-layout-node))
(b (make-layout-node)))
(layout-node-set-dimension root 100 200)
(layout-node-set-dimension a 100 50)
(layout-node-set-dimension b 100 50)
(layout-node-add-child root a)
(layout-node-add-child root b)
(layout-node-set-direction root :column)
(layout-calculate root 100 200)
(fiveam:is (= 0.0 (node-y a)))
(fiveam:is (= 50.0 (node-y b)))))
(fiveam:test test-set-direction-row
"Contract: row direction places children horizontally."
(let* ((root (make-layout-node))
(a (make-layout-node))
(b (make-layout-node)))
(layout-node-set-dimension root 200 100)
(layout-node-set-dimension a 80 50)
(layout-node-set-dimension b 80 50)
(layout-node-add-child root a)
(layout-node-add-child root b)
(layout-node-set-direction root :row)
(layout-calculate root 200 100)
(fiveam:is (= 0.0 (node-x a)))
(fiveam:is (= 80.0 (node-x b)))))
(fiveam:test test-set-flex-grow
"Contract: flex-grow distributes remaining space."
(let* ((root (make-layout-node))
(a (make-layout-node))
(b (make-layout-node)))
(layout-node-set-dimension root 200 100)
(layout-node-set-dimension a 0 100)
(layout-node-set-dimension b 0 100)
(layout-node-set-flex a :grow 1)
(layout-node-set-flex b :grow 2)
(layout-node-add-child root a)
(layout-node-add-child root b)
(layout-node-set-direction root :row)
(layout-calculate root 200 100)
(fiveam:is (< 0.0 (node-w a)))
(fiveam:is (< 0.0 (node-w b)))
(fiveam:is (= 200.0 (+ (node-w a) (node-w b))))))
(fiveam:test test-set-align-center
"Contract: align-items :center centers children on the cross axis."
(let* ((root (make-layout-node))
(child (make-layout-node)))
(layout-node-set-dimension root 200 100)
(layout-node-set-dimension child 50 50)
(layout-node-set-direction root :row)
(layout-node-add-child root child)
(layout-node-set-align root :items :center)
(layout-calculate root 200 100)
(fiveam:is (= 25.0 (node-y child)))))
(fiveam:test test-set-justify
"Contract: justify-content :center centers children on the main axis."
(let* ((root (make-layout-node))
(child (make-layout-node)))
(layout-node-set-dimension root 200 100)
(layout-node-set-dimension child 50 50)
(layout-node-set-direction root :row)
(layout-node-add-child root child)
(layout-node-set-justify root :center)
(layout-calculate root 200 100)
(fiveam:is (= 75.0 (node-x child)))))
(fiveam:test test-set-padding
"Contract: padding offsets children from the parent edges."
(let* ((root (make-layout-node))
(child (make-layout-node)))
(layout-node-set-dimension root 200 100)
(layout-node-set-dimension child 100 50)
(layout-node-add-child root child)
(layout-node-set-padding root :all 10)
(layout-calculate root 200 100)
(fiveam:is (= 10.0 (node-x child)))
(fiveam:is (= 10.0 (node-y child)))))
(fiveam:test test-set-margin
"Contract: margin offsets the child from its siblings/parent."
(let* ((root (make-layout-node))
(child (make-layout-node)))
(layout-node-set-dimension root 200 100)
(layout-node-set-dimension child 80 50)
(layout-node-add-child root child)
(layout-node-set-margin child :left 20)
(layout-calculate root 200 100)
(fiveam:is (= 20.0 (node-x child)))))
(fiveam:test test-set-position-absolute
"Contract: absolute positioning places a child at exact coordinates."
(let* ((root (make-layout-node))
(child (make-layout-node)))
(layout-node-set-dimension root 300 300)
(layout-node-set-dimension child 50 50)
(layout-node-add-child root child)
(layout-node-set-position child :absolute :left 100 :top 50)
(layout-calculate root 300 300)
(fiveam:is (= 100.0 (node-x child)))
(fiveam:is (= 50.0 (node-y child)))))
(fiveam:test test-set-wrap
"Contract: flex-wrap :wrap allows children to wrap to next line."
(let* ((root (make-layout-node))
(a (make-layout-node))
(b (make-layout-node))
(c (make-layout-node)))
(layout-node-set-dimension root 100 200)
(layout-node-set-dimension a 60 50)
(layout-node-set-dimension b 60 50)
(layout-node-set-dimension c 60 50)
(layout-node-add-child root a)
(layout-node-add-child root b)
(layout-node-add-child root c)
(layout-node-set-direction root :row)
(layout-node-set-wrap root :wrap)
(layout-calculate root 100 200)
(fiveam:is (< 0 (node-h a)))
;; Second child (b) should wrap to next row since 60+60 > 100
(fiveam:is (> (node-y b) (node-y a)))))
(fiveam:test test-set-gap
"Contract: gap adds spacing between children."
(let* ((root (make-layout-node))
(a (make-layout-node))
(b (make-layout-node)))
(layout-node-set-dimension root 200 100)
(layout-node-set-dimension a 50 50)
(layout-node-set-dimension b 50 50)
(layout-node-add-child root a)
(layout-node-add-child root b)
(layout-node-set-direction root :column)
(layout-node-set-gap root :row 20)
(layout-calculate root 200 100)
(fiveam:is (= 70.0 (node-y b)))))
(fiveam:test test-nested-layout
"Contract: nested containers produce correct leaf positions."
(let* ((root (make-layout-node))
(outer (make-layout-node))
(inner (make-layout-node)))
(layout-node-set-dimension root 400 400)
(layout-node-set-dimension outer 400 200)
(layout-node-set-dimension inner 100 100)
(layout-node-add-child outer inner)
(layout-node-add-child root outer)
(layout-node-set-direction root :column)
(layout-calculate root 400 400)
(fiveam:is (= 0.0 (node-x inner)))
(fiveam:is (= 100.0 (node-w inner)))
(fiveam:is (= 100.0 (node-h inner)))))

View File

@@ -1,631 +0,0 @@
(eval-when (:compile-toplevel :load-toplevel :execute)
(ql:quickload :cffi :silent t))
(defpackage :cl-tui.yoga-ffi
(:use :cl)
(:export
;; library
#:*libyoga*
#:load-yoga
;; constants
#:+yg-direction-inherit+
#:+yg-direction-ltr+
#:+yg-direction-rtl+
#:+yg-flex-direction-column+
#:+yg-flex-direction-column-reverse+
#:+yg-flex-direction-row+
#:+yg-flex-direction-row-reverse+
#:+yg-justify-auto+
#:+yg-justify-flex-start+
#:+yg-justify-center+
#:+yg-justify-flex-end+
#:+yg-justify-space-between+
#:+yg-justify-space-around+
#:+yg-justify-space-evenly+
#:+yg-justify-stretch+
#:+yg-align-auto+
#:+yg-align-flex-start+
#:+yg-align-center+
#:+yg-align-flex-end+
#:+yg-align-stretch+
#:+yg-align-baseline+
#:+yg-align-space-between+
#:+yg-align-space-around+
#:+yg-align-space-evenly+
#:+yg-wrap-nowrap+
#:+yg-wrap-wrap+
#:+yg-wrap-wrap-reverse+
#:+yg-position-type-static+
#:+yg-position-type-relative+
#:+yg-position-type-absolute+
#:+yg-overflow-visible+
#:+yg-overflow-hidden+
#:+yg-overflow-scroll+
#:+yg-display-flex+
#:+yg-display-none+
#:+yg-display-contents+
#:+yg-edge-left+
#:+yg-edge-top+
#:+yg-edge-right+
#:+yg-edge-bottom+
#:+yg-edge-start+
#:+yg-edge-end+
#:+yg-edge-horizontal+
#:+yg-edge-vertical+
#:+yg-edge-all+
#:+yg-gutter-column+
#:+yg-gutter-row+
#:+yg-gutter-all+
#:+yg-unit-undefined+
#:+yg-unit-point+
#:+yg-unit-percent+
#:+yg-unit-auto+
;; types
#:yg-node-ref
#:yg-node-const-ref
;; node management
#:yg-node-new
#:yg-node-free
#:yg-node-free-recursive
#:yg-node-insert-child
#:yg-node-remove-child
#:yg-node-remove-all-children
#:yg-node-get-child-count
#:yg-node-get-child
;; layout
#:yg-node-calculate-layout
#:yg-node-layout-get-left
#:yg-node-layout-get-top
#:yg-node-layout-get-width
#:yg-node-layout-get-height
#:yg-node-layout-get-right
#:yg-node-layout-get-bottom
#:yg-node-is-dirty
#:yg-node-mark-dirty
;; style direction & flex
#:yg-node-style-set-direction
#:yg-node-style-set-flex-direction
#:yg-node-style-set-justify-content
#:yg-node-style-set-align-items
#:yg-node-style-set-align-self
#:yg-node-style-set-align-content
#:yg-node-style-set-flex-wrap
#:yg-node-style-set-position-type
#:yg-node-style-set-flex-grow
#:yg-node-style-set-flex-shrink
#:yg-node-style-set-flex-basis
#:yg-node-style-set-flex-basis-auto
#:yg-node-style-set-flex-basis-percent
#:yg-node-style-set-overflow
#:yg-node-style-set-display
;; style dimensions
#:yg-node-style-set-width
#:yg-node-style-set-width-percent
#:yg-node-style-set-width-auto
#:yg-node-style-set-height
#:yg-node-style-set-height-percent
#:yg-node-style-set-height-auto
#:yg-node-style-set-min-width
#:yg-node-style-set-min-width-percent
#:yg-node-style-set-min-height
#:yg-node-style-set-min-height-percent
#:yg-node-style-set-max-width
#:yg-node-style-set-max-height
#:yg-node-style-set-aspect-ratio
;; style padding/margin/border/gap/position
#:yg-node-style-set-padding
#:yg-node-style-set-padding-percent
#:yg-node-style-set-margin
#:yg-node-style-set-margin-percent
#:yg-node-style-set-margin-auto
#:yg-node-style-set-border
#:yg-node-style-set-gap
#:yg-node-style-set-position
#:yg-node-style-set-position-percent
;; style getters
#:yg-node-style-get-width
#:yg-node-style-get-height
#:yg-node-style-get-flex-direction
#:yg-node-style-get-align-items
#:yg-node-style-get-justify-content))
(in-package :cl-tui.yoga-ffi)
(defparameter *libyoga* nil "Handle for the loaded Yoga shared library.")
(defun load-yoga ()
"Load the Yoga shared library via CFFI."
(setf *libyoga* (cffi:load-foreign-library "/usr/local/lib/libyoga.so"))
(sb-int:set-floating-point-modes :traps '())
*libyoga*)
(load-yoga)
(eval-when (:compile-toplevel :load-toplevel :execute)
;; YGDirection
(defconstant +yg-direction-inherit+ 0)
(defconstant +yg-direction-ltr+ 1)
(defconstant +yg-direction-rtl+ 2)
;; YGFlexDirection
(defconstant +yg-flex-direction-column+ 0)
(defconstant +yg-flex-direction-column-reverse+ 1)
(defconstant +yg-flex-direction-row+ 2)
(defconstant +yg-flex-direction-row-reverse+ 3)
;; YGJustify
(defconstant +yg-justify-auto+ 0)
(defconstant +yg-justify-flex-start+ 1)
(defconstant +yg-justify-center+ 2)
(defconstant +yg-justify-flex-end+ 3)
(defconstant +yg-justify-space-between+ 4)
(defconstant +yg-justify-space-around+ 5)
(defconstant +yg-justify-space-evenly+ 6)
(defconstant +yg-justify-stretch+ 7)
;; YGAlign
(defconstant +yg-align-auto+ 0)
(defconstant +yg-align-flex-start+ 1)
(defconstant +yg-align-center+ 2)
(defconstant +yg-align-flex-end+ 3)
(defconstant +yg-align-stretch+ 4)
(defconstant +yg-align-baseline+ 5)
(defconstant +yg-align-space-between+ 6)
(defconstant +yg-align-space-around+ 7)
(defconstant +yg-align-space-evenly+ 8)
;; YGWrap
(defconstant +yg-wrap-nowrap+ 0)
(defconstant +yg-wrap-wrap+ 1)
(defconstant +yg-wrap-wrap-reverse+ 2)
;; YGPositionType
(defconstant +yg-position-type-static+ 0)
(defconstant +yg-position-type-relative+ 1)
(defconstant +yg-position-type-absolute+ 2)
;; YGOverflow
(defconstant +yg-overflow-visible+ 0)
(defconstant +yg-overflow-hidden+ 1)
(defconstant +yg-overflow-scroll+ 2)
;; YGDisplay
(defconstant +yg-display-flex+ 0)
(defconstant +yg-display-none+ 1)
(defconstant +yg-display-contents+ 2)
;; YGEdge
(defconstant +yg-edge-left+ 0)
(defconstant +yg-edge-top+ 1)
(defconstant +yg-edge-right+ 2)
(defconstant +yg-edge-bottom+ 3)
(defconstant +yg-edge-start+ 4)
(defconstant +yg-edge-end+ 5)
(defconstant +yg-edge-horizontal+ 6)
(defconstant +yg-edge-vertical+ 7)
(defconstant +yg-edge-all+ 8)
;; YGGutter
(defconstant +yg-gutter-column+ 0)
(defconstant +yg-gutter-row+ 1)
(defconstant +yg-gutter-all+ 2)
;; YGUnit
(defconstant +yg-unit-undefined+ 0)
(defconstant +yg-unit-point+ 1)
(defconstant +yg-unit-percent+ 2)
(defconstant +yg-unit-auto+ 3))
(cffi:defctype yg-node-ref :pointer)
(cffi:defctype yg-node-const-ref :pointer)
(cffi:defcstruct yg-size
(width :float)
(height :float))
(cffi:defcstruct yg-value
(value :float)
(unit :int))
(cffi:defcfun ("YGNodeNew" yg-node-new) yg-node-ref)
(cffi:defcfun ("YGNodeFree" yg-node-free) :void
(node yg-node-ref))
(cffi:defcfun ("YGNodeFreeRecursive" yg-node-free-recursive) :void
(node yg-node-ref))
(cffi:defcfun ("YGNodeInsertChild" yg-node-insert-child) :void
(node yg-node-ref)
(child yg-node-ref)
(index :unsigned-int))
(cffi:defcfun ("YGNodeRemoveChild" yg-node-remove-child) :void
(node yg-node-ref)
(child yg-node-ref))
(cffi:defcfun ("YGNodeRemoveAllChildren" yg-node-remove-all-children) :void
(node yg-node-ref))
(cffi:defcfun ("YGNodeGetChildCount" yg-node-get-child-count) :unsigned-int
(node yg-node-const-ref))
(cffi:defcfun ("YGNodeGetChild" yg-node-get-child) yg-node-ref
(node yg-node-ref)
(index :unsigned-int))
(cffi:defcfun ("YGNodeCalculateLayout" yg-node-calculate-layout) :void
(node yg-node-ref)
(available-width :float)
(available-height :float)
(owner-direction :int))
(cffi:defcfun ("YGNodeLayoutGetLeft" yg-node-layout-get-left) :float
(node yg-node-const-ref))
(cffi:defcfun ("YGNodeLayoutGetTop" yg-node-layout-get-top) :float
(node yg-node-const-ref))
(cffi:defcfun ("YGNodeLayoutGetWidth" yg-node-layout-get-width) :float
(node yg-node-const-ref))
(cffi:defcfun ("YGNodeLayoutGetHeight" yg-node-layout-get-height) :float
(node yg-node-const-ref))
(cffi:defcfun ("YGNodeLayoutGetRight" yg-node-layout-get-right) :float
(node yg-node-const-ref))
(cffi:defcfun ("YGNodeLayoutGetBottom" yg-node-layout-get-bottom) :float
(node yg-node-const-ref))
(cffi:defcfun ("YGNodeIsDirty" yg-node-is-dirty) :boolean
(node yg-node-const-ref))
(cffi:defcfun ("YGNodeMarkDirty" yg-node-mark-dirty) :void
(node yg-node-ref))
(cffi:defcfun ("YGNodeStyleSetDirection" yg-node-style-set-direction) :void
(node yg-node-ref)
(direction :int))
(cffi:defcfun ("YGNodeStyleSetFlexDirection" yg-node-style-set-flex-direction) :void
(node yg-node-ref)
(flex-direction :int))
(cffi:defcfun ("YGNodeStyleSetJustifyContent" yg-node-style-set-justify-content) :void
(node yg-node-ref)
(justify :int))
(cffi:defcfun ("YGNodeStyleSetAlignItems" yg-node-style-set-align-items) :void
(node yg-node-ref)
(align :int))
(cffi:defcfun ("YGNodeStyleSetAlignSelf" yg-node-style-set-align-self) :void
(node yg-node-ref)
(align :int))
(cffi:defcfun ("YGNodeStyleSetAlignContent" yg-node-style-set-align-content) :void
(node yg-node-ref)
(align :int))
(cffi:defcfun ("YGNodeStyleSetFlexWrap" yg-node-style-set-flex-wrap) :void
(node yg-node-ref)
(wrap :int))
(cffi:defcfun ("YGNodeStyleSetPositionType" yg-node-style-set-position-type) :void
(node yg-node-ref)
(position-type :int))
(cffi:defcfun ("YGNodeStyleSetFlexGrow" yg-node-style-set-flex-grow) :void
(node yg-node-ref)
(flex-grow :float))
(cffi:defcfun ("YGNodeStyleSetFlexShrink" yg-node-style-set-flex-shrink) :void
(node yg-node-ref)
(flex-shrink :float))
(cffi:defcfun ("YGNodeStyleSetFlexBasis" yg-node-style-set-flex-basis) :void
(node yg-node-ref)
(flex-basis :float))
(cffi:defcfun ("YGNodeStyleSetFlexBasisAuto" yg-node-style-set-flex-basis-auto) :void
(node yg-node-ref))
(cffi:defcfun ("YGNodeStyleSetFlexBasisPercent" yg-node-style-set-flex-basis-percent) :void
(node yg-node-ref)
(flex-basis :float))
(cffi:defcfun ("YGNodeStyleSetOverflow" yg-node-style-set-overflow) :void
(node yg-node-ref)
(overflow :int))
(cffi:defcfun ("YGNodeStyleSetDisplay" yg-node-style-set-display) :void
(node yg-node-ref)
(display :int))
(cffi:defcfun ("YGNodeStyleSetWidth" yg-node-style-set-width) :void
(node yg-node-ref)
(width :float))
(cffi:defcfun ("YGNodeStyleSetWidthPercent" yg-node-style-set-width-percent) :void
(node yg-node-ref)
(width :float))
(cffi:defcfun ("YGNodeStyleSetWidthAuto" yg-node-style-set-width-auto) :void
(node yg-node-ref))
(cffi:defcfun ("YGNodeStyleSetHeight" yg-node-style-set-height) :void
(node yg-node-ref)
(height :float))
(cffi:defcfun ("YGNodeStyleSetHeightPercent" yg-node-style-set-height-percent) :void
(node yg-node-ref)
(height :float))
(cffi:defcfun ("YGNodeStyleSetHeightAuto" yg-node-style-set-height-auto) :void
(node yg-node-ref))
(cffi:defcfun ("YGNodeStyleSetMinWidth" yg-node-style-set-min-width) :void
(node yg-node-ref)
(min-width :float))
(cffi:defcfun ("YGNodeStyleSetMinWidthPercent" yg-node-style-set-min-width-percent) :void
(node yg-node-ref)
(min-width :float))
(cffi:defcfun ("YGNodeStyleSetMinHeight" yg-node-style-set-min-height) :void
(node yg-node-ref)
(min-height :float))
(cffi:defcfun ("YGNodeStyleSetMinHeightPercent" yg-node-style-set-min-height-percent) :void
(node yg-node-ref)
(min-height :float))
(cffi:defcfun ("YGNodeStyleSetMaxWidth" yg-node-style-set-max-width) :void
(node yg-node-ref)
(max-width :float))
(cffi:defcfun ("YGNodeStyleSetMaxHeight" yg-node-style-set-max-height) :void
(node yg-node-ref)
(max-height :float))
(cffi:defcfun ("YGNodeStyleSetAspectRatio" yg-node-style-set-aspect-ratio) :void
(node yg-node-ref)
(aspect-ratio :float))
(cffi:defcfun ("YGNodeStyleSetPadding" yg-node-style-set-padding) :void
(node yg-node-ref)
(edge :int)
(padding :float))
(cffi:defcfun ("YGNodeStyleSetPaddingPercent" yg-node-style-set-padding-percent) :void
(node yg-node-ref)
(edge :int)
(padding :float))
(cffi:defcfun ("YGNodeStyleSetMargin" yg-node-style-set-margin) :void
(node yg-node-ref)
(edge :int)
(margin :float))
(cffi:defcfun ("YGNodeStyleSetMarginPercent" yg-node-style-set-margin-percent) :void
(node yg-node-ref)
(edge :int)
(margin :float))
(cffi:defcfun ("YGNodeStyleSetMarginAuto" yg-node-style-set-margin-auto) :void
(node yg-node-ref)
(edge :int))
(cffi:defcfun ("YGNodeStyleSetBorder" yg-node-style-set-border) :void
(node yg-node-ref)
(edge :int)
(border :float))
(cffi:defcfun ("YGNodeStyleSetGap" yg-node-style-set-gap) :void
(node yg-node-ref)
(gutter :int)
(gap :float))
(cffi:defcfun ("YGNodeStyleSetPosition" yg-node-style-set-position) :void
(node yg-node-ref)
(edge :int)
(position :float))
(cffi:defcfun ("YGNodeStyleSetPositionPercent" yg-node-style-set-position-percent) :void
(node yg-node-ref)
(edge :int)
(position :float))
(cffi:defcfun ("YGNodeStyleGetWidth" yg-node-style-get-width) yg-value
(node yg-node-const-ref))
(cffi:defcfun ("YGNodeStyleGetHeight" yg-node-style-get-height) yg-value
(node yg-node-const-ref))
(cffi:defcfun ("YGNodeStyleGetFlexDirection" yg-node-style-get-flex-direction) :int
(node yg-node-const-ref))
(cffi:defcfun ("YGNodeStyleGetAlignItems" yg-node-style-get-align-items) :int
(node yg-node-const-ref))
(cffi:defcfun ("YGNodeStyleGetJustifyContent" yg-node-style-get-justify-content) :int
(node yg-node-const-ref))
(eval-when (:compile-toplevel :load-toplevel :execute)
(ql:quickload :fiveam :silent t))
(defpackage :cl-tui.yoga-ffi-tests
(:use :cl :fiveam)
(:import-from :cl-tui.yoga-ffi
#:load-yoga
#:yg-node-new
#:yg-node-free
#:yg-node-free-recursive
#:yg-node-insert-child
#:yg-node-get-child-count
#:yg-node-calculate-layout
#:yg-node-layout-get-left
#:yg-node-layout-get-top
#:yg-node-layout-get-width
#:yg-node-layout-get-height
#:yg-node-style-set-flex-direction
#:yg-node-style-set-justify-content
#:yg-node-style-set-align-items
#:yg-node-style-set-width
#:yg-node-style-set-height
#:yg-node-style-set-padding
#:yg-node-style-set-margin
#:yg-node-style-set-flex-grow
#:yg-node-style-set-position-type
#:yg-node-style-set-position
#:+yg-flex-direction-column+
#:+yg-flex-direction-row+
#:+yg-justify-center+
#:+yg-justify-flex-start+
#:+yg-align-stretch+
#:+yg-align-center+
#:+yg-edge-all+
#:+yg-edge-left+
#:+yg-edge-top+
#:+yg-edge-right+
#:+yg-edge-bottom+
#:+yg-position-type-relative+
#:+yg-position-type-absolute+
;; YGDirection constants (different from YGFlexDirection!)
#:+yg-direction-inherit+
#:+yg-direction-ltr+
#:+yg-direction-rtl+))
(in-package :cl-tui.yoga-ffi-tests)
(fiveam:def-suite yoga-ffi-suite
:description "Yoga FFI binding verification")
(fiveam:in-suite yoga-ffi-suite)
(fiveam:test test-node-create-free
"Contract: yg-node-new returns a non-null pointer; yg-node-free doesn't crash."
(let ((node (yg-node-new)))
(fiveam:is (not (cffi:null-pointer-p node)))
(yg-node-free node)
(fiveam:pass)))
(fiveam:test test-node-child-count
"Contract: yg-node-get-child-count returns 0 for a new node."
(let ((node (yg-node-new)))
(fiveam:is (= 0 (yg-node-get-child-count node)))
(yg-node-free node)))
(fiveam:test test-node-insert-child
"Contract: inserting a child increments the child count."
(let ((parent (yg-node-new))
(child (yg-node-new)))
(yg-node-insert-child parent child 0)
(fiveam:is (= 1 (yg-node-get-child-count parent)))
(yg-node-free-recursive parent)))
(fiveam:test test-layout-basic-column
"Contract: a column with two fixed-height children positions them vertically."
(let* ((root (yg-node-new)))
(yg-node-style-set-width root 100.0)
(yg-node-style-set-height root 200.0)
(yg-node-style-set-flex-direction root +yg-flex-direction-column+)
(let ((child1 (yg-node-new))
(child2 (yg-node-new)))
(yg-node-style-set-width child1 100.0)
(yg-node-style-set-height child1 50.0)
(yg-node-style-set-width child2 100.0)
(yg-node-style-set-height child2 50.0)
(yg-node-insert-child root child1 0)
(yg-node-insert-child root child2 1)
(yg-node-calculate-layout root 100.0 200.0 +yg-direction-ltr+)
(fiveam:is (= 0.0 (yg-node-layout-get-left child1)))
(fiveam:is (= 0.0 (yg-node-layout-get-top child1)))
(fiveam:is (= 50.0 (yg-node-layout-get-top child2)))
(yg-node-free-recursive root))))
(fiveam:test test-layout-basic-row
"Contract: a row with two fixed-width children positions them horizontally."
(let* ((root (yg-node-new)))
(yg-node-style-set-width root 200.0)
(yg-node-style-set-height root 100.0)
(yg-node-style-set-flex-direction root +yg-flex-direction-row+)
(let ((child1 (yg-node-new))
(child2 (yg-node-new)))
(yg-node-style-set-width child1 80.0)
(yg-node-style-set-height child1 50.0)
(yg-node-style-set-width child2 80.0)
(yg-node-style-set-height child2 50.0)
(yg-node-insert-child root child1 0)
(yg-node-insert-child root child2 1)
(yg-node-calculate-layout root 200.0 100.0 +yg-direction-ltr+)
(fiveam:is (= 0.0 (yg-node-layout-get-left child1)))
(fiveam:is (= 0.0 (yg-node-layout-get-top child1)))
(fiveam:is (= 80.0 (yg-node-layout-get-left child2)))
(yg-node-free-recursive root))))
(fiveam:test test-layout-flex-grow
"Contract: flex-grow distributes remaining space proportionally."
(let* ((root (yg-node-new)))
(yg-node-style-set-width root 200.0)
(yg-node-style-set-height root 100.0)
(yg-node-style-set-flex-direction root +yg-flex-direction-row+)
(let ((child1 (yg-node-new))
(child2 (yg-node-new)))
(yg-node-style-set-height child1 100.0)
(yg-node-style-set-flex-grow child1 1.0)
(yg-node-style-set-height child2 100.0)
(yg-node-style-set-flex-grow child2 2.0)
(yg-node-insert-child root child1 0)
(yg-node-insert-child root child2 1)
(yg-node-calculate-layout root 200.0 100.0 +yg-direction-ltr+)
(fiveam:is (< 0.0 (yg-node-layout-get-width child1)))
(fiveam:is (< 0.0 (yg-node-layout-get-width child2)))
(fiveam:is (= 200.0 (+ (yg-node-layout-get-width child1)
(yg-node-layout-get-width child2))))
(yg-node-free-recursive root))))
(fiveam:test test-layout-absolute-position
"Contract: an absolute child positions relative to its parent."
(let* ((root (yg-node-new)))
(yg-node-style-set-width root 300.0)
(yg-node-style-set-height root 300.0)
(let ((child (yg-node-new)))
(yg-node-style-set-width child 50.0)
(yg-node-style-set-height child 50.0)
(yg-node-style-set-position-type child +yg-position-type-absolute+)
(yg-node-style-set-position child +yg-edge-left+ 100.0)
(yg-node-style-set-position child +yg-edge-top+ 50.0)
(yg-node-insert-child root child 0)
(yg-node-calculate-layout root 300.0 300.0 +yg-direction-ltr+)
(fiveam:is (= 100.0 (yg-node-layout-get-left child)))
(fiveam:is (= 50.0 (yg-node-layout-get-top child)))
(fiveam:is (= 50.0 (yg-node-layout-get-width child)))
(fiveam:is (= 50.0 (yg-node-layout-get-height child)))
(yg-node-free-recursive root))))
(fiveam:test test-layout-padding
"Contract: padding reduces the available space for children."
(let* ((root (yg-node-new)))
(yg-node-style-set-width root 200.0)
(yg-node-style-set-height root 100.0)
(yg-node-style-set-padding root +yg-edge-all+ 10.0)
(let ((child (yg-node-new)))
(yg-node-style-set-width child 180.0)
(yg-node-style-set-height child 80.0)
(yg-node-insert-child root child 0)
(yg-node-calculate-layout root 200.0 100.0 +yg-direction-ltr+)
(fiveam:is (= 10.0 (yg-node-layout-get-left child)))
(fiveam:is (= 10.0 (yg-node-layout-get-top child)))
(yg-node-free-recursive root))))
(fiveam:test test-layout-nested
"Contract: nested containers produce correct leaf positions."
(let* ((root (yg-node-new)))
(yg-node-style-set-width root 400.0)
(yg-node-style-set-height root 400.0)
(yg-node-style-set-flex-direction root +yg-flex-direction-column+)
(let ((outer (yg-node-new)))
(yg-node-style-set-width outer 400.0)
(yg-node-style-set-height outer 200.0)
(yg-node-style-set-flex-direction outer +yg-flex-direction-row+)
(let ((inner (yg-node-new)))
(yg-node-style-set-width inner 100.0)
(yg-node-style-set-height inner 100.0)
(yg-node-insert-child outer inner 0)
(yg-node-insert-child root outer 0)
(yg-node-calculate-layout root 400.0 400.0 +yg-direction-ltr+)
(fiveam:is (= 0.0 (yg-node-layout-get-left inner)))
(fiveam:is (= 100.0 (yg-node-layout-get-width inner)))
(fiveam:is (= 100.0 (yg-node-layout-get-height inner)))
(yg-node-free-recursive root)))))

382
org/backend-protocol.org Normal file
View 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

169
org/box-renderable.org Normal file
View File

@@ -0,0 +1,169 @@
#+TITLE: cl-tui Box Renderable — v0.2.0
#+STARTUP: content
#+FILETAGS: :cl-tui:components:v0.2.0:
#+OPTIONS: ^:nil
* Box Renderable
The Box renderable draws a bordered rectangle with optional title and background
fill. It is the first renderable type and the foundation for all container
components (dialog, panel, group).
A Box has a =layout-node= slot for positioning via the layout engine. Its
=render-box= method dispatches through the backend protocol.
** Contract
- =(make-box &key border-style title title-align fg bg)= → box
Create a Box with optional border style, title, and colors.
- =(render-box box backend)= → nil
Render the box at its computed layout position. Draws background fill,
border, and title if configured.
- =(box-layout-node box)= → layout-node
Access the underlying layout-node for positioning.
** Tests
#+BEGIN_SRC lisp
(defpackage :cl-tui-box-test
(:use :cl :fiveam :cl-tui.backend :cl-tui.layout)
(:export #:run-tests))
(in-package :cl-tui-box-test)
(def-suite box-suite :description "Box renderable tests")
(in-suite box-suite)
(defun run-tests ()
(let ((result (run 'box-suite)))
(fiveam:explain! result)
(uiop:quit 0)))
(defun make-capturing-backend ()
(let* ((s (make-string-output-stream))
(b (make-modern-backend :output-stream s)))
(values b s)))
(test box-creates-with-defaults
"A box created with no arguments has reasonable defaults"
(let ((b (make-box)))
(is (typep b 'box))
(is (typep (box-layout-node b) 'layout-node))))
(test box-renders-border
"A box with border draws border characters"
(multiple-value-bind (b s) (make-capturing-backend)
(let ((bx (make-box :border-style :single :width 10 :height 5)))
(compute-layout (box-layout-node bx) 10 5)
(render-box bx b)
(let ((out (get-output-stream-string s)))
(is (search "┌" out) "top-left corner")
(is (search "┐" out) "top-right corner")
(is (search "└" out) "bottom-left corner")
(is (search "┘" out) "bottom-right corner")))))
(test box-renders-background
"A box with background color fills interior"
(multiple-value-bind (b s) (make-capturing-backend)
(let ((bx (make-box :bg :red :width 5 :height 3)))
(compute-layout (box-layout-node bx) 5 3)
(render-box bx b)
(let ((out (get-output-stream-string s)))
;; Should contain SGR background escape for red
(is (search "48;2;255;0;0" out) "SGR background should be red")
(is (search "┌" out) "border with background")))))
(test box-renders-title
"A box with title renders the title text"
(multiple-value-bind (b s) (make-capturing-backend)
(let ((bx (make-box :title "Hello" :width 12 :height 3)))
(compute-layout (box-layout-node bx) 12 3)
(render-box bx b)
(let ((out (get-output-stream-string s)))
(is (search "Hello" out) "title text should appear")))))
(test box-without-border
"A box with border-style nil draws no border"
(multiple-value-bind (b s) (make-capturing-backend)
(let ((bx (make-box :border-style nil :bg :red :width 5 :height 3)))
(compute-layout (box-layout-node bx) 5 3)
(render-box bx b)
(let ((out (get-output-stream-string s)))
(is (search "48;2;255;0;0" out) "background still renders")
;; No border chars
(is-false (search "┌" out) "no top-left corner")))))
(test box-zero-size
"A zero-size box renders nothing"
(multiple-value-bind (b s) (make-capturing-backend)
(let ((bx (make-box :border-style :single :width 0 :height 0)))
(compute-layout (box-layout-node bx) 0 0)
(render-box bx b)
(is (string= (get-output-stream-string s) "")
"zero-size box produces no output"))))
(test box-minimum-size
"A box with minimum non-zero size still renders"
(multiple-value-bind (b s) (make-capturing-backend)
(let ((bx (make-box :border-style :single :width 2 :height 2)))
(compute-layout (box-layout-node bx) 2 2)
(render-box bx b)
(let ((out (get-output-stream-string s)))
(is (search "┌" out) "2x2 box still has borders")))))
#+END_SRC
** Implementation
#+BEGIN_SRC lisp
(in-package :cl-tui.box)
(defclass box ()
((layout-node :initform (make-layout-node) :accessor box-layout-node
:initarg :layout-node)
(border-style :initform :single :initarg :border-style
:accessor box-border-style)
(title :initform nil :initarg :title :accessor box-title)
(title-align :initform :left :initarg :title-align
:accessor box-title-align)
(fg :initform nil :initarg :fg :accessor box-fg)
(bg :initform nil :initarg :bg :accessor box-bg)))
(defun make-box (&key (border-style :single) title
(title-align :left) fg bg
width height)
(make-instance 'box
:border-style border-style
:title title
:title-align title-align
:fg fg
:bg bg
:layout-node (make-layout-node
:width width
:height height
:direction :column)))
(defun render-box (box backend)
"Render BOX at its computed layout position using BACKEND."
(let ((ln (box-layout-node box))
(bs (box-border-style box))
(title (box-title box))
(fg (box-fg box))
(bg (box-bg box)))
(let ((x (layout-node-x ln))
(y (layout-node-y ln))
(w (layout-node-width ln))
(h (layout-node-height ln)))
(when (and (zerop w) (zerop h))
(return-from render-box (values)))
(when bg
(draw-rect backend x y w h :bg bg))
(when bs
(draw-border backend x y w h
:style bs :fg fg :bg bg
:title title
:title-align (box-title-align box)))
(when (and title bs)
;; Title is rendered by draw-border — nothing extra needed
(values)))))
#+END_SRC

View File

@@ -1,248 +0,0 @@
#+TITLE: Layout Composable API
#+STARTUP: content
#+FILETAGS: :cl-tui:layout-composable:v010:
* Layout Composable API
Convenience macros for building layout trees declaratively. Each macro
creates a =layout-node=, applies style properties, and inserts child nodes.
Dependencies: =layout-primitives=.
** Contract
- =(vbox &key width height flex-grow flex-shrink flex-basis align justify gap
padding margin children ...)= → layout-node ::
Creates a column-direction container. All =children= are
layout-node instances, or plain non-node values are skipped.
- =(hbox &key width height flex-grow flex-shrink flex-basis align justify gap
padding margin children ...)= → layout-node ::
Creates a row-direction container.
- =(overlay base child &key top right bottom left)= → layout-node ::
Creates a container with a relative base and an absolute-positioned child
overlaid on top.
- =(spacer &key grow)= → layout-node ::
Creates an empty flexible spacer that fills available space.
* Package
#+begin_src lisp
(eval-when (:compile-toplevel :load-toplevel :execute)
(ql:quickload :cffi :silent t)
(ql:quickload :trivial-garbage :silent t))
(defpackage :cl-tui.layout-composable
(:use :cl :cl-tui.layout-primitives)
(:export
#:vbox
#:hbox
#:overlay
#:spacer))
(in-package :cl-tui.layout-composable)
#+end_src
* Internal Helpers
#+begin_src lisp
(defun apply-common-props (node &key width height flex-grow flex-shrink flex-basis
align justify gap padding margin
&allow-other-keys)
"Apply the shared style properties to a layout-node."
(when (or width height)
(layout-node-set-dimension node (or width 0) (or height 0)))
(when (or flex-grow flex-shrink flex-basis)
(layout-node-set-flex node :grow flex-grow :shrink flex-shrink :basis flex-basis))
(when align
(apply #'layout-node-set-align node align))
(when justify
(layout-node-set-justify node justify))
(when gap
(apply #'layout-node-set-gap node gap))
(when padding
(apply #'layout-node-set-padding node padding))
(when margin
(apply #'layout-node-set-margin node margin)))
(defun add-children (parent children)
"Add each child in CHILDREN to PARENT. Non-node values are skipped."
(dolist (child children)
(when (typep child 'layout-node)
(layout-node-add-child parent child))))
(defun make-props-list (args)
"Extract all properties except :children from ARGS plist."
(loop for (k v) on args by #'cddr
unless (eq k :children)
append (list k v)))
#+end_src
* vbox — Vertical Box
#+begin_src lisp
(defmacro vbox (&rest args &key children &allow-other-keys)
"Create a column-direction container with CHILDREN stacked vertically."
(declare (ignore children))
(let* ((node (gensym "VBOX"))
(props (make-props-list args)))
`(let ((,node (make-layout-node)))
(layout-node-set-direction ,node :column)
(apply #'cl-tui.layout-composable::apply-common-props ,node ',props)
(cl-tui.layout-composable::add-children ,node (list ,@children))
,node)))
#+end_src
* hbox — Horizontal Box
#+begin_src lisp
(defmacro hbox (&rest args &key children &allow-other-keys)
"Create a row-direction container with CHILDREN laid out horizontally."
(declare (ignore children))
(let* ((node (gensym "HBOX"))
(props (make-props-list args)))
`(let ((,node (make-layout-node)))
(layout-node-set-direction ,node :row)
(apply #'cl-tui.layout-composable::apply-common-props ,node ',props)
(cl-tui.layout-composable::add-children ,node (list ,@children))
,node)))
#+end_src
* overlay — Absolute Overlay
#+begin_src lisp
(defmacro overlay (base child &key top right bottom left)
"Create a container with BASE as the relative foundation and CHILD
positioned absolutely on top."
(let ((node (gensym "OVERLAY")))
`(let ((,node (make-layout-node)))
(layout-node-set-position ,node :relative)
(layout-node-add-child ,node ,base)
(layout-node-set-position ,child :absolute
,@(when top `(:top ,top))
,@(when right `(:right ,right))
,@(when bottom `(:bottom ,bottom))
,@(when left `(:left ,left)))
(layout-node-add-child ,node ,child)
,node)))
#+end_src
* spacer — Flex Spacer
#+begin_src lisp
(defun spacer (&key (grow 0))
"Create an empty spacer node that fills available space via flex-grow."
(let ((node (make-layout-node)))
(when (> grow 0)
(layout-node-set-flex node :grow grow))
node))
#+end_src
* Test Suite
#+begin_src lisp
(eval-when (:compile-toplevel :load-toplevel :execute)
(ql:quickload :fiveam :silent t))
(defpackage :cl-tui.layout-composable-tests
(:use :cl :fiveam)
(:import-from :cl-tui.layout-composable
#:vbox #:hbox #:overlay #:spacer)
(:import-from :cl-tui.layout-primitives
#:layout-node #:layout-node-ptr #:layout-calculate
#:make-layout-node #:layout-node-set-dimension)
(:import-from :cl-tui.yoga-ffi
#:yg-node-layout-get-left
#:yg-node-layout-get-top
#:yg-node-layout-get-width
#:yg-node-layout-get-height
#:yg-node-get-child))
(in-package :cl-tui.layout-composable-tests)
(defun node-x (n) (yg-node-layout-get-left (layout-node-ptr n)))
(defun node-y (n) (yg-node-layout-get-top (layout-node-ptr n)))
(defun node-w (n) (yg-node-layout-get-width (layout-node-ptr n)))
(defun node-h (n) (yg-node-layout-get-height (layout-node-ptr n)))
(defun child-x (p) (yg-node-layout-get-left p))
(defun child-y (p) (yg-node-layout-get-top p))
(defun child-w (p) (yg-node-layout-get-width p))
(defun child-h (p) (yg-node-layout-get-height p))
(defun nth-child (node n)
(yg-node-get-child (layout-node-ptr node) n))
(defun layout-dummy (w h)
(let ((n (make-layout-node)))
(layout-node-set-dimension n w h)
n))
(fiveam:def-suite layout-composable-suite
:description "Composable API macro verification")
(fiveam:in-suite layout-composable-suite)
(fiveam:test test-vbox-stacks-children
"Contract: vbox stacks children vertically."
(let* ((root (vbox :width 100 :height 200
:children ((layout-dummy 100 50)
(layout-dummy 100 50)))))
(layout-calculate root 100 200)
(let ((c1 (nth-child root 0))
(c2 (nth-child root 1)))
(fiveam:is (= 0.0 (child-y c1)))
(fiveam:is (= 50.0 (child-y c2))))))
(fiveam:test test-hbox-lays-out-horizontally
"Contract: hbox places children horizontally."
(let* ((root (hbox :width 200 :height 100
:children ((layout-dummy 80 50)
(layout-dummy 80 50)))))
(layout-calculate root 200 100)
(let ((c1 (nth-child root 0))
(c2 (nth-child root 1)))
(fiveam:is (= 0.0 (child-x c1)))
(fiveam:is (= 80.0 (child-x c2))))))
(fiveam:test test-spacer-flex-grow
"Contract: spacer with flex-grow expands to fill space."
(let* ((root (hbox :width 200 :height 100
:children ((layout-dummy 50 50)
(spacer :grow 1)
(layout-dummy 50 50)))))
(layout-calculate root 200 100)
(let ((c1 (nth-child root 0))
(c2 (nth-child root 1))
(c3 (nth-child root 2)))
(fiveam:is (= 0.0 (child-x c1)))
(fiveam:is (= 50.0 (child-w c1)))
(fiveam:is (= 100.0 (child-w c2))))))
(fiveam:test test-overlay-absolute-position
"Contract: overlay positions an absolute child over a relative base."
(let* ((base (layout-dummy 100 100))
(child (layout-dummy 30 30))
(root (overlay base child :top 10 :left 20)))
(layout-calculate root 200 200)
(fiveam:is (= 20.0 (node-x child)))
(fiveam:is (= 10.0 (node-y child)))))
(fiveam:test test-vbox-align-justify
"Contract: vbox accepts align and justify keywords."
(let* ((root (vbox :width 200 :height 100
:align (:items :center)
:justify :center
:children ((layout-dummy 50 50)))))
(layout-calculate root 200 100)
(let ((c (nth-child root 0)))
(fiveam:is (= 25.0 (child-y c)))
(fiveam:is (= 75.0 (child-x c))))))
(fiveam:test test-vbox-padding
"Contract: vbox padding offsets children."
(let* ((root (vbox :width 200 :height 100
:padding (:all 10)
:children ((layout-dummy 180 80)))))
(layout-calculate root 200 100)
(let ((c (nth-child root 0)))
(fiveam:is (= 10.0 (child-x c)))
(fiveam:is (= 10.0 (child-y c))))))
#+end_src

591
org/layout-engine.org Normal file
View File

@@ -0,0 +1,591 @@
#+TITLE: cl-tui Layout Engine — v0.0.3
#+STARTUP: content
#+FILETAGS: :cl-tui:layout:v0.0.3:
#+OPTIONS: ^:nil
* Layout Engine
Pure Common Lisp Flexbox layout engine. No Yoga, no CFFI, no external
dependencies. A two-pass constraint solver that handles direction, wrap,
grow/shrink, and absolute positioning. Terminal resolution (~200x80)
means a full Yoga FFI binding is unnecessary — ~200 lines of CL math.
** Contract
*** Layout Node
- =(make-layout-node &key direction wrap grow shrink basis
align-items justify-content padding margin border gap
position-type position-offset width height)= → layout-node
Create a layout node with the given properties.
Properties:
- =:direction==:row=, =:column=, =:row-reverse=, =:column-reverse=
- =:wrap==:nowrap=, =:wrap=, =:wrap-reverse=
- =:grow= — flex grow factor (0 = no grow)
- =:shrink= — flex shrink factor (1 = default)
- =:basis= — flex basis (:auto or integer)
- =:align-items==:flex-start=, =:center=, =:flex-end=, =:stretch=
- =:justify-content==:flex-start=, =:center=, =:flex-end=,
=:space-between=, =:space-around=, =:space-evenly=
- =:padding=, =:margin=, =:border= — plist with =:top=, =:right=,
=:bottom=, =:left=, =:x=, =:y=
- =:gap= — plist with =:row= and =:column=, or single integer
- =:position-type==:relative= or =:absolute=
- =:position-offset= — plist with =:top=, =:right=, =:bottom=, =:left=
- =:width=, =:height= — fixed dimensions (nil = auto)
*** Node Manipulation
- =(layout-node-add-child parent child)= → child
Add CHILD as the last child of PARENT. Sets child's parent.
- =(layout-node-remove-child parent child)= → child
Remove CHILD from PARENT's children list.
- =(layout-node-children node)= → list
Return list of child nodes.
*** Layout Calculation
- =(compute-layout root available-width available-height)= → root
Run the layout algorithm on the entire tree. Populates each node's
computed =:x=, =:y=, =:width=, =:height= slots.
Algorithm:
1. Resolve styles (inherit, defaults)
2. First pass (column direction): distribute Y positions
3. Second pass (row direction): distribute X positions
4. Resolve absolute-positioned children
5. Handle wrap (overflow → new row/column)
*** Composed Value Access
- =(layout-node-x node)= → integer
- =(layout-node-y node)= → integer
- =(layout-node-width node)= → integer
- =(layout-node-height node)= → integer
*** Composable Macros
- =(vbox (&key grow shrink basis align-items justify-content
padding margin border gap width height)
&body children)= → layout-node
Create a vertical column container.
- =(hbox (&key grow shrink basis align-items justify-content
padding margin border gap width height)
&body children)= → layout-node
Create a horizontal row container.
- =(spacer &key grow)= → layout-node
Create an empty flex spacer.
** Test Suite
#+BEGIN_SRC lisp
(defpackage :cl-tui-layout-test
(:use :cl :fiveam :cl-tui.layout)
(:export #:run-tests))
(in-package :cl-tui-layout-test)
(def-suite layout-suite :description "Layout engine tests")
(in-suite layout-suite)
(defun run-tests ()
(let ((result (run 'layout-suite)))
(fiveam:explain! result)
(uiop:quit 0)))
;; ── Node Creation ──────────────────────────────────────────────
(test make-layout-node-defaults
"make-layout-node creates a node with default values"
(let ((n (make-layout-node)))
(is (typep n 'layout-node))
(is (eql (layout-node-direction n) :column))))
(test make-layout-node-row
"make-layout-node with :row direction"
(let ((n (make-layout-node :direction :row)))
(is (eql (layout-node-direction n) :row))))
;; ── Tree Building ──────────────────────────────────────────────
(test add-child-sets-parent
"layout-node-add-child sets parent on child"
(let ((parent (make-layout-node))
(child (make-layout-node)))
(layout-node-add-child parent child)
(is (eql (slot-value child 'parent) parent))
(is (= (length (slot-value parent 'children)) 1))))
(test remove-child-clears-parent
"layout-node-remove-child clears parent slot"
(let ((parent (make-layout-node))
(child (make-layout-node)))
(layout-node-add-child parent child)
(layout-node-remove-child parent child)
(is (null (slot-value child 'parent)))
(is (= (length (slot-value parent 'children)) 0))))
;; ── Simple Layout — Column ─────────────────────────────────────
(test column-two-children-vertical
"column stacks children vertically"
(let* ((root (make-layout-node :direction :column))
(c1 (make-layout-node :height 3))
(c2 (make-layout-node :height 5)))
(layout-node-add-child root c1)
(layout-node-add-child root c2)
(compute-layout root 20 20)
(is (= (layout-node-y c1) 0))
(is (= (layout-node-height c1) 3))
(is (= (layout-node-y c2) 3))
(is (= (layout-node-height c2) 5))))
(test row-two-children-horizontal
"row places children side by side"
(let* ((root (make-layout-node :direction :row))
(c1 (make-layout-node :width 10))
(c2 (make-layout-node :width 5)))
(layout-node-add-child root c1)
(layout-node-add-child root c2)
(compute-layout root 20 10)
(is (= (layout-node-x c1) 0))
(is (= (layout-node-width c1) 10))
(is (= (layout-node-x c2) 10))
(is (= (layout-node-width c2) 5))))
;; ── Flex Grow ──────────────────────────────────────────────────
(test flex-grow-distributes-space
"flex-grow distributes remaining space proportionally"
(let* ((root (make-layout-node :direction :row :width 20))
(c1 (make-layout-node :width 4 :grow 1))
(c2 (make-layout-node :width 4 :grow 2)))
(layout-node-add-child root c1)
(layout-node-add-child root c2)
(compute-layout root 20 10)
;; total fixed = 8, available = 12, c1 gets 4, c2 gets 8
(is (= (layout-node-width c1) 8))
(is (= (layout-node-width c2) 12))))
(test flex-grow-single-child
"single child with flex-grow fills remaining space"
(let* ((root (make-layout-node :direction :row :width 20))
(c (make-layout-node :width 5 :grow 1)))
(layout-node-add-child root c)
(compute-layout root 20 10)
(is (= (layout-node-width c) 20))))
;; ── Flex Shrink ────────────────────────────────────────────────
(test flex-shrink-reduces-overflow
"flex-shrink reduces children when content overflows"
(let* ((root (make-layout-node :direction :row :width 10))
(c1 (make-layout-node :width 8 :shrink 1))
(c2 (make-layout-node :width 8 :shrink 1)))
(layout-node-add-child root c1)
(layout-node-add-child root c2)
(compute-layout root 10 10)
;; Total = 16, available = 10, overflow = 6, each shrinks by 3
(is (= (layout-node-width c1) 5))
(is (= (layout-node-width c2) 5))))
;; ── Absolute Positioning ───────────────────────────────────────
(test absolute-positioned-child
"absolute child positions relative to parent"
(let* ((root (make-layout-node :width 20 :height 20))
(c (make-layout-node :position-type :absolute
:position-offset '(:top 2 :left 3)
:width 5 :height 5)))
(layout-node-add-child root c)
(compute-layout root 20 20)
(is (= (layout-node-x c) 3))
(is (= (layout-node-y c) 2))))
;; ── Padding ────────────────────────────────────────────────────
(test padding-reduces-content-area
"padding reduces available area for children"
(let* ((root (make-layout-node :direction :column
:padding '(:top 1 :left 1 :bottom 1 :right 1)))
(c (make-layout-node :height 3)))
(layout-node-add-child root c)
(compute-layout root 20 10)
(is (= (layout-node-x c) 1))
(is (= (layout-node-y c) 1))
;; content height = 10 - 2 = 8, child height = 3
(is (= (layout-node-height c) 3))))
;; ── Gap ────────────────────────────────────────────────────────
(test gap-between-children
"gap adds spacing between children"
(let* ((root (make-layout-node :direction :column :gap 2))
(c1 (make-layout-node :height 3))
(c2 (make-layout-node :height 3)))
(layout-node-add-child root c1)
(layout-node-add-child root c2)
(compute-layout root 20 20)
(is (= (layout-node-y c1) 0))
(is (= (layout-node-y c2) 5)))) ; 3 + 2 gap
;; ── Composable Macros ──────────────────────────────────────────
(test vbox-macro
"vbox creates a column container with children"
(let* ((root (vbox () (make-layout-node :height 3) (make-layout-node :height 5))))
(compute-layout root 20 20)
(is (= (length (layout-node-children root)) 2))
(is (= (layout-node-y (elt (layout-node-children root) 1)) 3))))
(test hbox-macro
"hbox creates a row container with children"
(let* ((root (hbox () (make-layout-node :width 5) (make-layout-node :width 3))))
(compute-layout root 20 10)
(is (= (length (layout-node-children root)) 2))
(is (= (layout-node-x (elt (layout-node-children root) 1)) 5))))
(test spacer-takes-grow
"spacer with grow fills remaining space"
(let* ((root (hbox (:width 20)
(make-layout-node :width 5)
(spacer :grow 1)
(make-layout-node :width 5))))
(compute-layout root 20 10)
(let ((children (layout-node-children root)))
(is (= (layout-node-x (elt children 2)) 15))
(is (= (layout-node-width (elt children 1)) 10)))))
;; ── Nested Layout ──────────────────────────────────────────────
(test nested-vbox-in-hbox
"nested vbox in hbox produces correct leaf positions"
(let* ((sidebar (vbox (:width 5 :height 10)
(make-layout-node :height 3)
(make-layout-node :height 7)))
(main (vbox (:grow 1 :height 10)
(make-layout-node :height 2)
(make-layout-node :grow 1)))
(root (hbox (:width 30 :height 10)
sidebar main)))
(compute-layout root 30 10)
;; sidebar takes 5 cols, main takes 25 cols (grows)
(is (= (layout-node-width sidebar) 5))
(is (>= (layout-node-width main) 20))
;; sidebar children positioned correctly
(let ((sidebar-children (layout-node-children sidebar)))
(is (= (layout-node-y (elt sidebar-children 0)) 0))
(is (= (layout-node-y (elt sidebar-children 1)) 3)))))
#+END_SRC
** Implementation
*** Package
#+BEGIN_SRC lisp
(defpackage :cl-tui.layout
(:use :cl)
(:export
;; Classes
#:layout-node
;; Construction
#:make-layout-node
;; Tree manipulation
#:layout-node-add-child #:layout-node-remove-child
#:layout-node-children
;; Computed values
#:layout-node-x #:layout-node-y
#:layout-node-width #:layout-node-height
#:layout-node-direction
;; Layout
#:compute-layout
;; Macros
#:vbox #:hbox #:spacer))
(in-package :cl-tui.layout)
#+END_SRC
*** Layout Node Class
#+BEGIN_SRC lisp
(defclass layout-node ()
;; Tree structure
((parent :initform nil :accessor layout-node-parent)
(children :initform '() :accessor layout-node-children)
;; Computed layout (populated by compute-layout)
(x :initform 0 :accessor layout-node-x)
(y :initform 0 :accessor layout-node-y)
(width :initform 0 :accessor layout-node-width)
(height :initform 0 :accessor layout-node-height)
;; Flex properties
(direction :initform :column
:initarg :direction :accessor layout-node-direction)
(wrap :initform :nowrap
:initarg :wrap :accessor layout-node-wrap)
(grow :initform 0 :initarg :grow
:accessor layout-node-grow)
(shrink :initform 1 :initarg :shrink
:accessor layout-node-shrink)
(basis :initform :auto :initarg :basis
:accessor layout-node-basis)
(align-items :initform :stretch :initarg :align-items
:accessor layout-node-align-items)
(justify-content :initform :flex-start :initarg :justify-content
:accessor layout-node-justify-content)
;; Box model
(padding :initform '(:top 0 :right 0 :bottom 0 :left 0)
:initarg :padding :accessor layout-node-padding)
(margin :initform '(:top 0 :right 0 :bottom 0 :left 0)
:initarg :margin :accessor layout-node-margin)
(border :initform '(:top 0 :right 0 :bottom 0 :left 0)
:initarg :border :accessor layout-node-border)
(gap :initform 0 :initarg :gap :accessor layout-node-gap)
;; Position
(position-type :initform :relative :initarg :position-type
:accessor layout-node-position-type)
(position-offset :initform nil :initarg :position-offset
:accessor layout-node-position-offset)
;; Fixed dimensions (nil = auto)
(fixed-width :initform nil :initarg :width
:accessor layout-node-fixed-width)
(fixed-height :initform nil :initarg :height
:accessor layout-node-fixed-height)))
#+END_SRC
*** Constructor
#+BEGIN_SRC lisp
(defun make-layout-node (&key direction wrap grow shrink basis
align-items justify-content
padding margin border gap
position-type position-offset
width height)
(make-instance 'layout-node
:direction (or direction :column)
:wrap (or wrap :nowrap)
:grow (or grow 0)
:shrink (or shrink 1)
:basis (or basis :auto)
:align-items (or align-items :stretch)
:justify-content (or justify-content :flex-start)
:padding (normalize-box padding)
:margin (normalize-box margin)
:border (normalize-box border)
:gap gap
:position-type (or position-type :relative)
:position-offset position-offset
:width width
:height height))
(defun normalize-box (spec)
"Convert a box property spec to ( :top N :right N :bottom N :left N )."
(cond ((null spec) '(:top 0 :right 0 :bottom 0 :left 0))
((numberp spec) `(:top ,spec :right ,spec :bottom ,spec :left ,spec))
((getf spec :top) spec)
(t `(:top 0 :right 0 :bottom 0 :left 0))))
#+END_SRC
*** Tree Manipulation
#+BEGIN_SRC lisp
(defun layout-node-add-child (parent child)
(setf (slot-value child 'parent) parent)
(push child (slot-value parent 'children))
child)
(defun layout-node-remove-child (parent child)
(setf (slot-value child 'parent) nil)
(setf (slot-value parent 'children)
(delete child (slot-value parent 'children)))
child)
(defun box-edge (box edge)
"Get a specific edge value from a box plist."
(or (getf box edge) 0))
#+END_SRC
*** Constraint Solver
#+BEGIN_SRC lisp
(defun compute-layout (root available-width available-height)
"Run the layout algorithm on the entire tree."
(labels
((resolve-main-size (node)
;; Get the main-axis size from fixed dimension or basis
(if (eql (layout-node-direction node) :row)
(layout-node-fixed-width node)
(layout-node-fixed-height node)))
(resolve-cross-size (node)
(if (eql (layout-node-direction node) :row)
(layout-node-fixed-height node)
(layout-node-fixed-width node)))
(compute-node (node x-offset y-offset max-w max-h)
(let* ((dir (layout-node-direction node))
(pad-top (box-edge (layout-node-padding node) :top))
(pad-right (box-edge (layout-node-padding node) :right))
(pad-bottom (box-edge (layout-node-padding node) :bottom))
(pad-left (box-edge (layout-node-padding node) :left))
(pad-x (+ pad-left pad-right))
(pad-y (+ pad-top pad-bottom))
(margin-top (box-edge (layout-node-margin node) :top))
(margin-left (box-edge (layout-node-margin node) :left))
(gap (layout-node-gap node))
;; Content area (minus padding)
(content-w (max 0 (- max-w pad-x)))
(content-h (max 0 (- max-h pad-y)))
(children (reverse (layout-node-children node)))
(is-row (eql dir :row))
(main-axis (if is-row :width :height))
(cross-axis (if is-row :height :width))
;; First pass: measure children
(child-count (length children)))
;; Set own position
(setf (layout-node-x node) (+ x-offset margin-left pad-left)
(layout-node-y node) (+ y-offset margin-top pad-top))
(when (plusp child-count)
;; Calculate main-axis sizes
(let* ((fixed-sizes (mapcar (lambda (c)
(or (resolve-main-size c)
(if is-row
(or (layout-node-fixed-width c)
(round content-w child-count))
(or (layout-node-fixed-height c)
(round content-h child-count)))))
children))
(total-fixed (reduce #'+ fixed-sizes))
(total-grow (reduce #'+ (mapcar #'layout-node-grow children)))
(total-shrink (reduce #'+ (mapcar #'layout-node-shrink children)))
(remaining (- (if is-row content-w content-h) total-fixed))
(available-without-gap (if is-row content-w content-h))
(gap-total (* gap (max 0 (1- child-count))))
;; Account for gap in available space
(available (- available-without-gap gap-total))
(overflow (- total-fixed available))
;; Distribute grow/shrink
(final-sizes
(mapcar (lambda (child fixed)
(let* ((g (layout-node-grow child))
(s (layout-node-shrink child))
(size fixed))
(when (and (plusp remaining) (plusp total-grow))
(incf size (round (* remaining (/ g total-grow)))))
(when (and (plusp overflow) (plusp total-shrink))
(decf size (round (* overflow (/ s total-shrink)))))
(max 0 size)))
children fixed-sizes)))
;; Second pass: position children
(let ((pos 0))
(mapc (lambda (child size)
(if is-row
(progn
(setf (layout-node-width child) size
(layout-node-x child) (+ pad-left x-offset pos)
(layout-node-height child) content-h
(layout-node-y child) (+ pad-top y-offset))
(compute-node child
(layout-node-x child)
(layout-node-y child)
size content-h))
(progn
(setf (layout-node-height child) size
(layout-node-y child) (+ pad-top y-offset pos)
(layout-node-width child) content-w
(layout-node-x child) (+ pad-left x-offset))
(compute-node child
(layout-node-x child)
(layout-node-y child)
content-w size)))
(incf pos (+ size gap)))
children final-sizes))))
;; Set own size to content size
(let ((last-child (first (last children))))
(if is-row
(progn
(setf (layout-node-width node)
(if (layout-node-fixed-width node)
(layout-node-fixed-width node)
(if last-child
(+ (layout-node-x last-child)
(layout-node-width last-child)
pad-right margin-left)
max-w)))
(setf (layout-node-height node) max-h))
(progn
(setf (layout-node-height node)
(if (layout-node-fixed-height node)
(layout-node-fixed-height node)
(if last-child
(+ (layout-node-y last-child)
(layout-node-height last-child)
pad-bottom margin-top)
max-h)))
(setf (layout-node-width node) max-w))))
node))
(compute-node root 0 0 available-width available-height)
root))
#+END_SRC
*** Composable Macros
#+BEGIN_SRC lisp
(defmacro vbox ((&key grow shrink basis align-items justify-content
padding margin border gap width height)
&body children)
"Create a vertical column container."
(let ((node (gensym)))
`(let ((,node (make-layout-node
:direction :column
,@(when grow `(:grow ,grow))
,@(when shrink `(:shrink ,shrink))
,@(when basis `(:basis ,basis))
,@(when align-items `(:align-items ,align-items))
,@(when justify-content `(:justify-content ,justify-content))
,@(when padding `(:padding ,padding))
,@(when margin `(:margin ,margin))
,@(when border `(:border ,border))
,@(when gap `(:gap ,gap))
,@(when width `(:width ,width))
,@(when height `(:height ,height)))))
,@(loop for child in children collect
`(layout-node-add-child ,node ,child))
,node)))
(defmacro hbox ((&key grow shrink basis align-items justify-content
padding margin border gap width height)
&body children)
"Create a horizontal row container."
(let ((node (gensym)))
`(let ((,node (make-layout-node
:direction :row
,@(when grow `(:grow ,grow))
,@(when shrink `(:shrink ,shrink))
,@(when basis `(:basis ,basis))
,@(when align-items `(:align-items ,align-items))
,@(when justify-content `(:justify-content ,justify-content))
,@(when padding `(:padding ,padding))
,@(when margin `(:margin ,margin))
,@(when border `(:border ,border))
,@(when gap `(:gap ,gap))
,@(when width `(:width ,width))
,@(when height `(:height ,height)))))
,@(loop for child in children collect
`(layout-node-add-child ,node ,child))
,node)))
(defmacro spacer (&key grow)
"Create an empty flex spacer."
`(make-layout-node :grow ,(or grow 1)))
#+END_SRC

View File

@@ -1,608 +0,0 @@
#+TITLE: Layout Primitives
#+STARTUP: content
#+FILETAGS: :cl-tui:layout-primitives:v010:
* Layout Primitives
CLOS wrappers around the raw Yoga FFI bindings. Each =layout-node= wraps a
=YGNodeRef= with automatic finalization. Setter functions translate Lisp
keywords to Yoga enum integers, providing a safe, idiomatic Common Lisp API.
This file depends on =yoga-ffi.lisp= (the CFFI bindings). The composable API
(=vbox=, =hbox=, =overlay=, =spacer=) is in =layout-composable.org=.
** Contract
- =(make-layout-node)= → layout-node :: allocates a new Yoga node, wraps it in
a CLOS instance. The YGNodeRef is freed when the layout-node is garbage
collected (via trivial-garbage).
- =(layout-node-ptr node)= → YGNodeRef :: returns the raw C pointer for use
with raw FFI functions.
- =(layout-node-add-child parent child)= :: inserts child at the end of
parent's children list. Throws if child is nil.
- =(layout-node-set-dimension node width height)= :: sets fixed width and
height in points.
- =(layout-node-set-flex node &key grow shrink basis)= :: sets flex-grow,
flex-shrink, flex-basis. Unspecified keys are left unchanged.
- =(layout-node-set-direction node direction)= :: sets flex-direction.
direction is one of: :column :column-reverse :row :row-reverse.
- =(layout-node-set-wrap node wrap)= :: sets flex-wrap.
wrap is one of: :nowrap :wrap :wrap-reverse.
- =(layout-node-set-align node &key items self content)= :: sets align-items,
align-self, align-content. Values from: :auto, :flex-start, :center,
:flex-end, :stretch, :baseline, :space-between, :space-around, :space-evenly.
- =(layout-node-set-justify node justify)= :: sets justify-content. Values
from: :auto :flex-start :center :flex-end :space-between :space-around
:space-evenly.
- =(layout-node-set-padding node &key all top right bottom left x y)= :: sets
padding in points on specified edges. :all sets all 4 edges.
- =(layout-node-set-margin node &key all top right bottom left x y)= :: sets
margin in points.
- =(layout-node-set-gap node &key row column)= :: sets gap between children.
- =(layout-node-set-position node type &key top right bottom left)= :: sets
position type (:static :relative :absolute) and offsets.
- =(layout-node-set-border node width &key top right bottom left all)= ::
sets border width on edges.
- =(layout-node-set-overflow node overflow)= :: sets overflow mode. Values:
:visible :hidden :scroll.
- =(layout-node-set-display node display)= :: sets display mode. Values: :flex
:none.
- =(layout-node-set-aspect-ratio node ratio)= :: sets aspect ratio.
- =(layout-calculate root width height &optional (direction :ltr))= :: runs
Yoga's calculateLayout, populating each node's computed x/y/w/h.
* Package and Dependencies
#+begin_src lisp
(eval-when (:compile-toplevel :load-toplevel :execute)
(ql:quickload :cffi :silent t)
(ql:quickload :trivial-garbage :silent t))
(defpackage :cl-tui.layout-primitives
(:use :cl)
(:import-from :cl-tui.yoga-ffi
#:load-yoga
#:yg-node-new
#:yg-node-free
#:yg-node-insert-child
#:yg-node-remove-child
#:yg-node-get-child-count
#:yg-node-calculate-layout
#:yg-node-layout-get-left
#:yg-node-layout-get-top
#:yg-node-layout-get-width
#:yg-node-layout-get-height
#:yg-node-layout-get-right
#:yg-node-layout-get-bottom
#:yg-node-style-set-direction
#:yg-node-style-set-flex-direction
#:yg-node-style-set-justify-content
#:yg-node-style-set-align-items
#:yg-node-style-set-align-self
#:yg-node-style-set-align-content
#:yg-node-style-set-flex-wrap
#:yg-node-style-set-position-type
#:yg-node-style-set-flex-grow
#:yg-node-style-set-flex-shrink
#:yg-node-style-set-flex-basis
#:yg-node-style-set-flex-basis-auto
#:yg-node-style-set-overflow
#:yg-node-style-set-display
#:yg-node-style-set-width
#:yg-node-style-set-width-auto
#:yg-node-style-set-height
#:yg-node-style-set-height-auto
#:yg-node-style-set-min-width
#:yg-node-style-set-min-height
#:yg-node-style-set-max-width
#:yg-node-style-set-max-height
#:yg-node-style-set-aspect-ratio
#:yg-node-style-set-padding
#:yg-node-style-set-margin
#:yg-node-style-set-margin-auto
#:yg-node-style-set-border
#:yg-node-style-set-gap
#:yg-node-style-set-position
;; enum constants
#:+yg-flex-direction-column+
#:+yg-flex-direction-column-reverse+
#:+yg-flex-direction-row+
#:+yg-flex-direction-row-reverse+
#:+yg-wrap-nowrap+
#:+yg-wrap-wrap+
#:+yg-wrap-wrap-reverse+
#:+yg-justify-auto+
#:+yg-justify-flex-start+
#:+yg-justify-center+
#:+yg-justify-flex-end+
#:+yg-justify-space-between+
#:+yg-justify-space-around+
#:+yg-justify-space-evenly+
#:+yg-align-auto+
#:+yg-align-flex-start+
#:+yg-align-center+
#:+yg-align-flex-end+
#:+yg-align-stretch+
#:+yg-align-baseline+
#:+yg-align-space-between+
#:+yg-align-space-around+
#:+yg-align-space-evenly+
#:+yg-position-type-static+
#:+yg-position-type-relative+
#:+yg-position-type-absolute+
#:+yg-overflow-visible+
#:+yg-overflow-hidden+
#:+yg-overflow-scroll+
#:+yg-display-flex+
#:+yg-display-none+
#:+yg-edge-left+
#:+yg-edge-top+
#:+yg-edge-right+
#:+yg-edge-bottom+
#:+yg-edge-all+
#:+yg-edge-start+
#:+yg-edge-end+
#:+yg-edge-horizontal+
#:+yg-edge-vertical+
#:+yg-gutter-column+
#:+yg-gutter-row+
#:+yg-gutter-all+
#:+yg-direction-inherit+
#:+yg-direction-ltr+
#:+yg-direction-rtl+)
(:export
#:layout-node
#:layout-node-ptr
#:make-layout-node
#:layout-node-add-child
#:layout-node-set-dimension
#:layout-node-set-flex
#:layout-node-set-direction
#:layout-node-set-wrap
#:layout-node-set-align
#:layout-node-set-justify
#:layout-node-set-padding
#:layout-node-set-margin
#:layout-node-set-gap
#:layout-node-set-position
#:layout-node-set-border
#:layout-node-set-overflow
#:layout-node-set-display
#:layout-node-set-aspect-ratio
#:layout-calculate))
(in-package :cl-tui.layout-primitives)
#+end_src
* Enum Translation Tables
#+begin_src lisp
;; Keyword → integer translation tables. Used by setter functions
;; so callers use (:flex-start) instead of (+yg-justify-flex-start+).
(defparameter *flex-direction-map*
'((:column . 0) (:column-reverse . 1) (:row . 2) (:row-reverse . 3)))
(defparameter *wrap-map*
'((:nowrap . 0) (:wrap . 1) (:wrap-reverse . 2)))
(defparameter *justify-map*
'((:auto . 0) (:flex-start . 1) (:center . 2) (:flex-end . 3)
(:space-between . 4) (:space-around . 5) (:space-evenly . 6)))
(defparameter *align-map*
'((:auto . 0) (:flex-start . 1) (:center . 2) (:flex-end . 3)
(:stretch . 4) (:baseline . 5) (:space-between . 6) (:space-around . 7)
(:space-evenly . 8)))
(defparameter *position-type-map*
'((:static . 0) (:relative . 1) (:absolute . 2)))
(defparameter *overflow-map*
'((:visible . 0) (:hidden . 1) (:scroll . 2)))
(defparameter *display-map*
'((:flex . 0) (:none . 1)))
(defparameter *edge-map*
'((:left . 0) (:top . 1) (:right . 2) (:bottom . 3)
(:start . 4) (:end . 5) (:horizontal . 6) (:vertical . 7) (:all . 8)))
(defparameter *direction-map*
'((:inherit . 0) (:ltr . 1) (:rtl . 2)))
(defun resolve-enum (map keyword)
"Look up KEYWORD in MAP (an alist). Throws if not found."
(or (cdr (assoc keyword map))
(error "Unknown enum keyword ~a" keyword)))
#+end_src
* Layout Node Class
#+begin_src lisp
(defclass layout-node ()
((ptr :initarg :ptr :reader layout-node-ptr
:documentation "Raw YGNodeRef pointer")))
(defmethod print-object ((node layout-node) stream)
(print-unreadable-object (node stream :type t)
(format stream "~a" (layout-node-ptr node))))
(defun make-layout-node ()
"Allocate a new Yoga node and wrap it in a layout-node."
(let ((node (make-instance 'layout-node :ptr (yg-node-new))))
(tg:finalize node (lambda () (yg-node-free (layout-node-ptr node))))
node))
(defun layout-node-add-child (parent child)
"Insert CHILD at the end of PARENT's children list."
(let ((count (yg-node-get-child-count (layout-node-ptr parent))))
(yg-node-insert-child (layout-node-ptr parent) (layout-node-ptr child) count)))
#+end_src
* Dimension Setters
#+begin_src lisp
(defun layout-node-set-dimension (node width height)
"Set fixed width and height in points."
(yg-node-style-set-width (layout-node-ptr node) (coerce width 'single-float))
(yg-node-style-set-height (layout-node-ptr node) (coerce height 'single-float)))
(defun layout-node-set-flex (node &key grow shrink basis)
"Set flex properties. Unspecified keys are left unchanged."
(let ((p (layout-node-ptr node)))
(when grow (yg-node-style-set-flex-grow p (coerce grow 'single-float)))
(when shrink (yg-node-style-set-flex-shrink p (coerce shrink 'single-float)))
(when basis (yg-node-style-set-flex-basis p (coerce basis 'single-float)))))
(defun layout-node-set-aspect-ratio (node ratio)
"Set aspect ratio (width/height)."
(yg-node-style-set-aspect-ratio (layout-node-ptr node) (coerce ratio 'single-float)))
#+end_src
* Layout Direction and Wrapping
#+begin_src lisp
(defun layout-node-set-direction (node direction)
"Set flex-direction. DIRECTION is :column, :column-reverse, :row, or :row-reverse."
(yg-node-style-set-flex-direction
(layout-node-ptr node)
(resolve-enum *flex-direction-map* direction)))
(defun layout-node-set-wrap (node wrap)
"Set flex-wrap. WRAP is :nowrap, :wrap, or :wrap-reverse."
(yg-node-style-set-flex-wrap
(layout-node-ptr node)
(resolve-enum *wrap-map* wrap)))
#+end_src
* Alignment and Justification
#+begin_src lisp
(defun layout-node-set-align (node &key items self content)
"Set align-items, align-self, align-content. Values are keywords like :flex-start."
(let ((p (layout-node-ptr node)))
(when items (yg-node-style-set-align-items p (resolve-enum *align-map* items)))
(when self (yg-node-style-set-align-self p (resolve-enum *align-map* self)))
(when content (yg-node-style-set-align-content p (resolve-enum *align-map* content)))))
(defun layout-node-set-justify (node justify)
"Set justify-content. JUSTIFY is :flex-start, :center, :flex-end, :space-between, etc."
(yg-node-style-set-justify-content
(layout-node-ptr node)
(resolve-enum *justify-map* justify)))
#+end_src
* Position Type and Offsets
#+begin_src lisp
(defun layout-node-set-position (node type &key top right bottom left)
"Set position type and offsets. TYPE is :static, :relative, or :absolute."
(let ((p (layout-node-ptr node)))
(yg-node-style-set-position-type p (resolve-enum *position-type-map* type))
(when left (yg-node-style-set-position p +yg-edge-left+ (coerce left 'single-float)))
(when top (yg-node-style-set-position p +yg-edge-top+ (coerce top 'single-float)))
(when right (yg-node-style-set-position p +yg-edge-right+ (coerce right 'single-float)))
(when bottom (yg-node-style-set-position p +yg-edge-bottom+ (coerce bottom 'single-float)))))
#+end_src
* Padding, Margin, Border, Gap
#+begin_src lisp
(defun set-edges (p fn all top right bottom left x y)
"Helper: call FN on each specified edge. FN is (fn ptr edge value)."
(flet ((s (edge val) (funcall fn p edge (coerce val 'single-float))))
(when all (dolist (e (list +yg-edge-left+ +yg-edge-top+ +yg-edge-right+ +yg-edge-bottom+))
(s e all)))
(when top (s +yg-edge-top+ top))
(when right (s +yg-edge-right+ right))
(when bottom (s +yg-edge-bottom+ bottom))
(when left (s +yg-edge-left+ left))
(when x (s +yg-edge-horizontal+ x))
(when y (s +yg-edge-vertical+ y))))
(defun layout-node-set-padding (node &key all top right bottom left x y)
"Set padding on specified edges in points."
(set-edges (layout-node-ptr node) #'yg-node-style-set-padding all top right bottom left x y))
(defun layout-node-set-margin (node &key all top right bottom left x y)
"Set margin on specified edges in points."
(set-edges (layout-node-ptr node) #'yg-node-style-set-margin all top right bottom left x y))
(defun layout-node-set-border (node width &key all top right bottom left x y)
"Set border width on specified edges."
(let ((p (layout-node-ptr node)))
(flet ((s (edge val) (yg-node-style-set-border p edge (coerce val 'single-float))))
(when all (dolist (e (list +yg-edge-left+ +yg-edge-top+ +yg-edge-right+ +yg-edge-bottom+))
(s e all)))
(when top (s +yg-edge-top+ top))
(when right (s +yg-edge-right+ right))
(when bottom (s +yg-edge-bottom+ bottom))
(when left (s +yg-edge-left+ left))
(when x (s +yg-edge-horizontal+ x))
(when y (s +yg-edge-vertical+ y)))))
(defun layout-node-set-gap (node &key row column)
"Set gap between children."
(let ((p (layout-node-ptr node)))
(when row (yg-node-style-set-gap p +yg-gutter-row+ (coerce row 'single-float)))
(when column (yg-node-style-set-gap p +yg-gutter-column+ (coerce column 'single-float)))))
#+end_src
* Overflow and Display
#+begin_src lisp
(defun layout-node-set-overflow (node overflow)
"Set overflow mode. OVERFLOW is :visible, :hidden, or :scroll."
(yg-node-style-set-overflow
(layout-node-ptr node)
(resolve-enum *overflow-map* overflow)))
(defun layout-node-set-display (node display)
"Set display mode. DISPLAY is :flex or :none."
(yg-node-style-set-display
(layout-node-ptr node)
(resolve-enum *display-map* display)))
#+end_src
* Layout Calculation
#+begin_src lisp
(defun layout-calculate (root width height &optional (direction :ltr))
"Run Yoga layout on the tree rooted at ROOT.
Returns ROOT (for chaining). Each node's computed position is available via
the raw FFI layout getter functions (yg-node-layout-get-left etc.)."
(yg-node-calculate-layout
(layout-node-ptr root)
(coerce width 'single-float)
(coerce height 'single-float)
(resolve-enum *direction-map* direction))
root)
#+end_src
* Test Suite
#+begin_src lisp
(eval-when (:compile-toplevel :load-toplevel :execute)
(ql:quickload :fiveam :silent t))
(defpackage :cl-tui.layout-primitives-tests
(:use :cl :fiveam)
(:import-from :cl-tui.layout-primitives
#:make-layout-node
#:layout-node-add-child
#:layout-node-set-dimension
#:layout-node-set-flex
#:layout-node-set-direction
#:layout-node-set-wrap
#:layout-node-set-align
#:layout-node-set-justify
#:layout-node-set-padding
#:layout-node-set-margin
#:layout-node-set-gap
#:layout-node-set-position
#:layout-node-set-border
#:layout-node-set-overflow
#:layout-node-set-display
#:layout-node-set-aspect-ratio
#:layout-calculate
#:layout-node-ptr)
(:import-from :cl-tui.yoga-ffi
#:yg-node-layout-get-left
#:yg-node-layout-get-top
#:yg-node-layout-get-width
#:yg-node-layout-get-height))
(in-package :cl-tui.layout-primitives-tests)
(fiveam:def-suite layout-primitives-suite
:description "Layout primitive CLOS wrappers verification")
(fiveam:in-suite layout-primitives-suite)
(defun node-x (node) (yg-node-layout-get-left (layout-node-ptr node)))
(defun node-y (node) (yg-node-layout-get-top (layout-node-ptr node)))
(defun node-w (node) (yg-node-layout-get-width (layout-node-ptr node)))
(defun node-h (node) (yg-node-layout-get-height (layout-node-ptr node)))
(fiveam:test test-make-layout-node
"Contract: make-layout-node returns a live node."
(let ((n (make-layout-node)))
(fiveam:is (not (cffi:null-pointer-p (layout-node-ptr n))))))
(fiveam:test test-layout-node-add-child
"Contract: adding a child makes it appear in the tree."
(let* ((parent (make-layout-node))
(child (make-layout-node)))
(layout-node-add-child parent child)
(layout-node-set-dimension parent 100 100)
(layout-node-set-dimension child 50 50)
(layout-calculate parent 100 100)
(fiveam:is (= 50.0 (node-w child)))
(fiveam:is (= 50.0 (node-h child)))))
(fiveam:test test-set-dimension
"Contract: layout-node-set-dimension sets width and height."
(let ((n (make-layout-node)))
(layout-node-set-dimension n 200 100)
(layout-calculate n 200 100)
(fiveam:is (= 200.0 (node-w n)))
(fiveam:is (= 100.0 (node-h n)))))
(fiveam:test test-set-direction-column
"Contract: column direction stacks children vertically."
(let* ((root (make-layout-node))
(a (make-layout-node))
(b (make-layout-node)))
(layout-node-set-dimension root 100 200)
(layout-node-set-dimension a 100 50)
(layout-node-set-dimension b 100 50)
(layout-node-add-child root a)
(layout-node-add-child root b)
(layout-node-set-direction root :column)
(layout-calculate root 100 200)
(fiveam:is (= 0.0 (node-y a)))
(fiveam:is (= 50.0 (node-y b)))))
(fiveam:test test-set-direction-row
"Contract: row direction places children horizontally."
(let* ((root (make-layout-node))
(a (make-layout-node))
(b (make-layout-node)))
(layout-node-set-dimension root 200 100)
(layout-node-set-dimension a 80 50)
(layout-node-set-dimension b 80 50)
(layout-node-add-child root a)
(layout-node-add-child root b)
(layout-node-set-direction root :row)
(layout-calculate root 200 100)
(fiveam:is (= 0.0 (node-x a)))
(fiveam:is (= 80.0 (node-x b)))))
(fiveam:test test-set-flex-grow
"Contract: flex-grow distributes remaining space."
(let* ((root (make-layout-node))
(a (make-layout-node))
(b (make-layout-node)))
(layout-node-set-dimension root 200 100)
(layout-node-set-dimension a 0 100)
(layout-node-set-dimension b 0 100)
(layout-node-set-flex a :grow 1)
(layout-node-set-flex b :grow 2)
(layout-node-add-child root a)
(layout-node-add-child root b)
(layout-node-set-direction root :row)
(layout-calculate root 200 100)
(fiveam:is (< 0.0 (node-w a)))
(fiveam:is (< 0.0 (node-w b)))
(fiveam:is (= 200.0 (+ (node-w a) (node-w b))))))
(fiveam:test test-set-align-center
"Contract: align-items :center centers children on the cross axis."
(let* ((root (make-layout-node))
(child (make-layout-node)))
(layout-node-set-dimension root 200 100)
(layout-node-set-dimension child 50 50)
(layout-node-set-direction root :row)
(layout-node-add-child root child)
(layout-node-set-align root :items :center)
(layout-calculate root 200 100)
(fiveam:is (= 25.0 (node-y child)))))
(fiveam:test test-set-justify
"Contract: justify-content :center centers children on the main axis."
(let* ((root (make-layout-node))
(child (make-layout-node)))
(layout-node-set-dimension root 200 100)
(layout-node-set-dimension child 50 50)
(layout-node-set-direction root :row)
(layout-node-add-child root child)
(layout-node-set-justify root :center)
(layout-calculate root 200 100)
(fiveam:is (= 75.0 (node-x child)))))
(fiveam:test test-set-padding
"Contract: padding offsets children from the parent edges."
(let* ((root (make-layout-node))
(child (make-layout-node)))
(layout-node-set-dimension root 200 100)
(layout-node-set-dimension child 100 50)
(layout-node-add-child root child)
(layout-node-set-padding root :all 10)
(layout-calculate root 200 100)
(fiveam:is (= 10.0 (node-x child)))
(fiveam:is (= 10.0 (node-y child)))))
(fiveam:test test-set-margin
"Contract: margin offsets the child from its siblings/parent."
(let* ((root (make-layout-node))
(child (make-layout-node)))
(layout-node-set-dimension root 200 100)
(layout-node-set-dimension child 80 50)
(layout-node-add-child root child)
(layout-node-set-margin child :left 20)
(layout-calculate root 200 100)
(fiveam:is (= 20.0 (node-x child)))))
(fiveam:test test-set-position-absolute
"Contract: absolute positioning places a child at exact coordinates."
(let* ((root (make-layout-node))
(child (make-layout-node)))
(layout-node-set-dimension root 300 300)
(layout-node-set-dimension child 50 50)
(layout-node-add-child root child)
(layout-node-set-position child :absolute :left 100 :top 50)
(layout-calculate root 300 300)
(fiveam:is (= 100.0 (node-x child)))
(fiveam:is (= 50.0 (node-y child)))))
(fiveam:test test-set-wrap
"Contract: flex-wrap :wrap allows children to wrap to next line."
(let* ((root (make-layout-node))
(a (make-layout-node))
(b (make-layout-node))
(c (make-layout-node)))
(layout-node-set-dimension root 100 200)
(layout-node-set-dimension a 60 50)
(layout-node-set-dimension b 60 50)
(layout-node-set-dimension c 60 50)
(layout-node-add-child root a)
(layout-node-add-child root b)
(layout-node-add-child root c)
(layout-node-set-direction root :row)
(layout-node-set-wrap root :wrap)
(layout-calculate root 100 200)
(fiveam:is (< 0 (node-h a)))
;; Second child (b) should wrap to next row since 60+60 > 100
(fiveam:is (> (node-y b) (node-y a)))))
(fiveam:test test-set-gap
"Contract: gap adds spacing between children."
(let* ((root (make-layout-node))
(a (make-layout-node))
(b (make-layout-node)))
(layout-node-set-dimension root 200 100)
(layout-node-set-dimension a 50 50)
(layout-node-set-dimension b 50 50)
(layout-node-add-child root a)
(layout-node-add-child root b)
(layout-node-set-direction root :column)
(layout-node-set-gap root :row 20)
(layout-calculate root 200 100)
(fiveam:is (= 70.0 (node-y b)))))
(fiveam:test test-nested-layout
"Contract: nested containers produce correct leaf positions."
(let* ((root (make-layout-node))
(outer (make-layout-node))
(inner (make-layout-node)))
(layout-node-set-dimension root 400 400)
(layout-node-set-dimension outer 400 200)
(layout-node-set-dimension inner 100 100)
(layout-node-add-child outer inner)
(layout-node-add-child root outer)
(layout-node-set-direction root :column)
(layout-calculate root 400 400)
(fiveam:is (= 0.0 (node-x inner)))
(fiveam:is (= 100.0 (node-w inner)))
(fiveam:is (= 100.0 (node-h inner)))))
#+end_src

438
org/modern-backend.org Normal file
View File

@@ -0,0 +1,438 @@
#+TITLE: cl-tui Modern Backend — v0.0.2
#+STARTUP: content
#+FILETAGS: :cl-tui:backend:v0.0.2:
#+OPTIONS: ^:nil
* Modern Backend
The =modern-backend= renders through raw ANSI/XTerm escape sequences.
No ncurses, no CFFI, no external dependencies — pure CL string
construction. Supports truecolor, Unicode box-drawing, OSC 8 hyperlinks,
DECICM synchronized updates, SGR mouse, and the kitty keyboard protocol.
** Contract
*** Constructor
- =(make-modern-backend &key color-palette)= → modern-backend
Create a modern backend. color-palette modifies theme color mappings.
*** Escape Sequence Generation
All escape sequences follow ECMA-48 / ANSI X3.64 conventions:
| Escape | Meaning |
|--------+--------------------------|
| ~ESC[~ | Control Sequence Introducer (CSI) |
| ~ESC]~ | Operating System Command (OSC) |
| ~ESC ~ | Single-character sequence |
*** Style Resolution
Colors are resolved through a palette before emission:
- =(resolve-color backend hex-or-name)= → color-index
Convert hex string or semantic name to an SGR color code.
Hex ("#FFD700") → 48;2;R;G;B or 38;2;R;G;B.
Named colors (:black :red :green :yellow :blue :magenta :cyan :white)
→ 8-color SGR codes.
** Test Suite
#+BEGIN_SRC lisp
(defpackage :cl-tui-modern-backend-test
(:use :cl :fiveam :cl-tui.backend)
(:export #:run-tests))
(in-package :cl-tui-modern-backend-test)
(def-suite modern-backend-suite :description "Modern backend tests")
(in-suite modern-backend-suite)
(defun run-tests ()
(let ((result (run 'modern-backend-suite)))
(fiveam:explain! result)
(uiop:quit 0)))
;; ── Constructor ────────────────────────────────────────────────
(test make-modern-backend-creates
"make-modern-backend returns a modern-backend instance"
(let ((b (make-modern-backend)))
(is (typep b 'cl-tui.backend::modern-backend))))
;; ── Escape Generation ──────────────────────────────────────────
(test sgr-truecolor-foreground
"SGR truecolor foreground escape is correct"
(is (equal (cl-tui.backend::sgr-fg "#FFD700")
(format nil "~C[38;2;255;215;0m" #\Esc))))
(test sgr-truecolor-background
"SGR truecolor background escape is correct"
(is (equal (cl-tui.backend::sgr-bg "#1a1b26")
(format nil "~C[48;2;26;27;38m" #\Esc))))
(test sgr-named-colors
"SGR named colors resolve to 8-color codes"
(is (equal (cl-tui.backend::sgr-fg :red)
(format nil "~C[31m" #\Esc)))
(is (equal (cl-tui.backend::sgr-bg :blue)
(format nil "~C[44m" #\Esc))))
(test sgr-bold-italic
"SGR attribute escapes are correct"
(is (equal (cl-tui.backend::sgr-attr :bold) (format nil "~C[1m" #\Esc)))
(is (equal (cl-tui.backend::sgr-attr :italic) (format nil "~C[3m" #\Esc)))
(is (equal (cl-tui.backend::sgr-attr :underline) (format nil "~C[4m" #\Esc)))
(is (equal (cl-tui.backend::sgr-attr :reset) (format nil "~C[0m" #\Esc))))
;; ── Cursor ─────────────────────────────────────────────────────
(test cursor-move-escape
"cursor-move generates correct CSI escape"
(let ((b (make-modern-backend)))
(is (equal (cl-tui.backend::cursor-move-escape 5 10)
(format nil "~C[6;11H" #\Esc)))))
(test cursor-style-block
"cursor-style :block generate correct escape"
(let ((b (make-modern-backend)))
(is (equal (cl-tui.backend::cursor-style-escape :block nil)
(format nil "~C[2 q" #\Esc)))))
(test cursor-style-bar
"cursor-style :bar generate correct escape"
(let ((b (make-modern-backend)))
(is (equal (cl-tui.backend::cursor-style-escape :bar nil)
(format nil "~C[6 q" #\Esc)))))
(test cursor-style-underline-blink
"cursor-style :underline with blink"
(let ((b (make-modern-backend)))
(is (equal (cl-tui.backend::cursor-style-escape :underline t)
(format nil "~C[5 q" #\Esc)))))
;; ── Synchronization ────────────────────────────────────────────
(test decicm-escapes
"DECICM synchronized update escapes"
(is (equal (cl-tui.backend::decicm-begin) (format nil "~C[?2026h" #\Esc)))
(is (equal (cl-tui.backend::decicm-end) (format nil "~C[?2026l" #\Esc))))
;; ── OSC 8 Hyperlinks ──────────────────────────────────────────
(test osc8-escape
"OSC 8 hyperlink escape wraps text"
(is (equal (cl-tui.backend::osc8-link "http://example.com" "click here")
(format nil "~C]8;;http://example.com~C\\click here~C]8;;~C\\"
#\Esc #\Esc #\Esc #\Esc))))
;; ── Hex Parsing ────────────────────────────────────────────────
(test hex-color-parsing
"hex-to-rgb parses valid hex colors"
(multiple-value-bind (r g b) (cl-tui.backend::hex-to-rgb "#FFD700")
(is (= r 255))
(is (= g 215))
(is (= b 0))))
(test hex-color-black
"hex-to-rgb parses black"
(multiple-value-bind (r g b) (cl-tui.backend::hex-to-rgb "#000000")
(is (= r 0))
(is (= g 0))
(is (= b 0))))
(test hex-color-short-form
"hex-to-rgb parses 3-digit hex"
(multiple-value-bind (r g b) (cl-tui.backend::hex-to-rgb "#F00")
(is (= r 255))
(is (= g 0))
(is (= b 0))))
;; ── Border Characters ──────────────────────────────────────────
(test border-char-rounded
"modern-border-char returns Unicode box-drawing for rounded style"
(is (equal (cl-tui.backend::border-char :rounded :top-left) "╭"))
(is (equal (cl-tui.backend::border-char :rounded :horizontal) "─"))
(is (equal (cl-tui.backend::border-char :rounded :vertical) "│"))
(is (equal (cl-tui.backend::border-char :rounded :bottom-right) "╯")))
(test border-char-double
"modern-border-char returns double-line chars"
(is (equal (cl-tui.backend::border-char :double :top-left) "╔"))
(is (equal (cl-tui.backend::border-char :double :horizontal) "═"))
(is (equal (cl-tui.backend::border-char :double :vertical) "║")))
#+END_SRC
** Implementation
*** Package
Add to =cl-tui.backend= package:
#+BEGIN_SRC lisp
;; In package.lisp, add to :export:
;; #:modern-backend #:make-modern-backend
;; Internal symbols (not exported, used by tests):
;; sgr-fg sgr-bg sgr-attr cursor-move-escape cursor-style-escape
;; decicm-begin decicm-end osc8-link hex-to-rgb border-char
(in-package :cl-tui.backend)
#+END_SRC
*** Color Resolution
#+BEGIN_SRC lisp
(defun hex-to-rgb (hex)
"Parse a hex color string like \"#FFD700\" into (values r g b).
Also handles 3-digit hex like \"#F00\"."
(let ((clean (string-trim '(#\# #\Space) hex)))
(if (= (length clean) 3)
(values (parse-integer (subseq clean 0 1) :radix 16 :junk-allowed t)
(parse-integer (subseq clean 1 2) :radix 16 :junk-allowed t)
(parse-integer (subseq clean 2 3) :radix 16 :junk-allowed t))
(values (parse-integer (subseq clean 0 2) :radix 16 :junk-allowed t)
(parse-integer (subseq clean 2 4) :radix 16 :junk-allowed t)
(parse-integer (subseq clean 4 6) :radix 16 :junk-allowed t)))))
(defparameter *named-colors*
'((:black . 0) (:red . 1) (:green . 2) (:yellow . 3)
(:blue . 4) (:magenta . 5) (:cyan . 6) (:white . 7)))
(defun sgr-fg (color)
"Return SGR foreground escape for COLOR.
Color can be a hex string, a keyword name, or nil."
(if (null color) ""
(cond ((and (stringp color) (char= (char color 0) #\#))
(multiple-value-bind (r g b) (hex-to-rgb color)
(format nil "~C[38;2;~d;~d;~dm" #\Esc r g b)))
((keywordp color)
(let ((index (cdr (assoc color *named-colors*))))
(if index
(format nil "~C[~dm" #\Esc (+ 30 index))
"")))
(t ""))))
(defun sgr-bg (color)
"Return SGR background escape for COLOR."
(if (null color) ""
(cond ((and (stringp color) (char= (char color 0) #\#))
(multiple-value-bind (r g b) (hex-to-rgb color)
(format nil "~C[48;2;~d;~d;~dm" #\Esc r g b)))
((keywordp color)
(let ((index (cdr (assoc color *named-colors*))))
(if index
(format nil "~C[~dm" #\Esc (+ 40 index))
"")))
(t ""))))
(defparameter *sgr-attr-codes*
'((:bold . 1) (:dim . 2) (:italic . 3) (:underline . 4)
(:blink . 5) (:reverse . 7) (:reset . 0)))
(defun sgr-attr (attr)
"Return SGR attribute escape for ATTR keyword."
(let ((code (cdr (assoc attr *sgr-attr-codes*))))
(if code
(format nil "~C[~dm" #\Esc code)
"")))
#+END_SRC
*** Cursor Escapes
#+BEGIN_SRC lisp
(defun cursor-move-escape (x y)
"Return CSI escape to move cursor to (x, y), 1-indexed."
(format nil "~C[~d;~dH" #\Esc (1+ y) (1+ x)))
(defun cursor-style-escape (shape blink)
"Return DECSTR escape for cursor shape.
:block = 2, :underline = 4, :bar = 6.
Add 1 for blink variants."
(let* ((base (case shape
(:block 2) (:underline 4) (:bar 6)
(t 2)))
(code (if blink (1+ base) base)))
(format nil "~C[~d q" #\Esc code)))
#+END_SRC
*** Synchronization (DECICM)
#+BEGIN_SRC lisp
(defun decicm-begin ()
"Return escape to enable synchronized updates."
(format nil "~C[?2026h" #\Esc))
(defun decicm-end ()
"Return escape to disable synchronized updates."
(format nil "~C[?2026l" #\Esc))
#+END_SRC
*** OSC 8 Hyperlinks
#+BEGIN_SRC lisp
(defun osc8-link (url text)
"Wrap TEXT in an OSC 8 hyperlink to URL."
(format nil "~C]8;;~A~C\\~A~C]8;;~C\\"
#\Esc url #\Esc text #\Esc #\Esc))
#+END_SRC
*** Border Characters
#+BEGIN_SRC lisp
(defparameter *border-chars*
'((:single :top-left . "┌") (:single :top-right . "┐")
(:single :bottom-left . "└") (:single :bottom-right . "┘")
(:single :horizontal . "─") (:single :vertical . "│")
(:double :top-left . "╔") (:double :top-right . "╗")
(:double :bottom-left . "╚") (:double :bottom-right . "╝")
(:double :horizontal . "═") (:double :vertical . "║")
(:rounded :top-left . "╭") (:rounded :top-right . "╮")
(:rounded :bottom-left . "╰") (:rounded :bottom-right . "╯")
(:rounded :horizontal . "─") (:rounded :vertical . "│")))
(defun border-char (style pos)
"Return the Unicode box-drawing character for STYLE at POS."
(let ((char (cdr (assoc (cons style pos) *border-chars* :test #'equal))))
(or char (if (member pos '(:horizontal :vertical))
(case pos (:horizontal "─") (:vertical "│"))
"+"))))
#+END_SRC
*** Modern Backend Class
#+BEGIN_SRC lisp
(defclass modern-backend (backend)
((output-stream :initform *standard-output*
:accessor backend-output-stream)
(in-sync-p :initform nil :accessor in-sync-p)))
(defun make-modern-backend (&key color-palette)
(declare (ignore color-palette))
(make-instance 'modern-backend))
(defmethod initialize-backend ((b modern-backend))
;; Enter raw mode, enable mouse, bracketed paste
(backend-write b (format nil "~C[?1049h" #\Esc)) ; alt screen
(backend-write b (format nil "~C[?1000h" #\Esc)) ; mouse basic
(backend-write b (format nil "~C[?1002h" #\Esc)) ; mouse drag
(backend-write b (format nil "~C[?1006h" #\Esc)) ; SGR mouse
(backend-write b (format nil "~C[?2004h" #\Esc)) ; bracketed paste
(cursor-hide b)
(finish-output (backend-output-stream b))
b)
(defmethod shutdown-backend ((b modern-backend))
(cursor-show b)
(backend-write b (format nil "~C[?2004l" #\Esc)) ; disable bracketed paste
(backend-write b (format nil "~C[?1006l" #\Esc)) ; disable SGR mouse
(backend-write b (format nil "~C[?1002l" #\Esc))
(backend-write b (format nil "~C[?1000l" #\Esc))
(backend-write b (format nil "~C[?1049l" #\Esc)) ; normal screen
(finish-output (backend-output-stream b))
(values))
(defmethod backend-size ((b modern-backend))
;; Default fallback — real implementation queries terminal
(values 80 24))
(defmethod backend-write ((b modern-backend) string)
(let ((stream (backend-output-stream b)))
(write-string string stream)
(length string)))
(defmethod capable-p ((b modern-backend) feature)
(member feature '(:truecolor :osc8 :sync :mouse
:bracketed-paste :cursor-style
:kitty-keyboard)))
(defmethod draw-text ((b modern-backend) x y string fg bg
&key bold italic underline reverse dim blink)
(let ((parts (list (cursor-move-escape x y)
(sgr-fg fg) (sgr-bg bg)
(when bold (sgr-attr :bold))
(when italic (sgr-attr :italic))
(when underline (sgr-attr :underline))
(when reverse (sgr-attr :reverse))
(when dim (sgr-attr :dim))
(when blink (sgr-attr :blink))
string
(sgr-attr :reset))))
(backend-write b (apply #'concatenate 'string parts))))
(defmethod draw-border ((b modern-backend) x y width height
&key style fg bg title title-align)
(declare (ignore title title-align))
(let* ((s (or style :single))
(tl (border-char s :top-left))
(tr (border-char s :top-right))
(bl (border-char s :bottom-left))
(br (border-char s :bottom-right))
(h (border-char s :horizontal))
(v (border-char s :vertical))
(fg-esc (sgr-fg fg))
(bg-esc (sgr-bg bg))
(reset (sgr-attr :reset))
(top (concatenate 'string
fg-esc bg-esc tl
(make-string (- width 2) :initial-element (char h 0))
tr reset (string #\Newline)))
(mid (concatenate 'string
fg-esc bg-esc v
(make-string (- width 2) :initial-element #\Space)
v reset (string #\Newline)))
(bot (concatenate 'string
fg-esc bg-esc bl
(make-string (- width 2) :initial-element (char h 0))
br reset)))
(backend-write b top)
(loop repeat (- height 2) do (backend-write b mid))
(backend-write b bot)))
(defmethod draw-rect ((b modern-backend) x y width height &key bg)
(let ((bg-esc (sgr-bg bg))
(reset (sgr-attr :reset))
(line (concatenate 'string
bg-esc
(make-string width :initial-element #\Space)
reset (string #\Newline))))
(loop repeat height do
(backend-write b (cursor-move-escape x y))
(backend-write b line))))
(defmethod draw-link ((b modern-backend) x y string url
&key fg bg)
(let ((parts (list (cursor-move-escape x y)
(sgr-fg fg) (sgr-bg bg)
(osc8-link url string)
(sgr-attr :reset))))
(backend-write b (apply #'concatenate 'string parts))))
(defmethod draw-ellipsis ((b modern-backend) x y width
&key fg bg)
(let ((dots "..."))
(draw-text b x y dots fg bg)))
(defmethod cursor-move ((b modern-backend) x y)
(backend-write b (cursor-move-escape x y)))
(defmethod cursor-hide ((b modern-backend))
(backend-write b (format nil "~C[?25l" #\Esc)))
(defmethod cursor-show ((b modern-backend))
(backend-write b (format nil "~C[?25h" #\Esc)))
(defmethod cursor-style ((b modern-backend) shape &key blink)
(backend-write b (cursor-style-escape shape blink)))
(defmethod begin-sync ((b modern-backend))
(setf (in-sync-p b) t)
(backend-write b (decicm-begin)))
(defmethod end-sync ((b modern-backend))
(setf (in-sync-p b) nil)
(backend-write b (decicm-end))
(finish-output (backend-output-stream b)))
#+END_SRC

686
org/scrollbox-tabbar.org Normal file
View File

@@ -0,0 +1,686 @@
#+TITLE: cl-tui v0.6.0 — ScrollBox + TabBar
#+STARTUP: content
* ScrollBox and TabBar
Container components. ScrollBox handles content larger than the viewport,
providing scroll offsets, viewport culling, and scrollbars. TabBar
handles horizontal tab navigation with keyboard support.
Both components inherit ~dirty-mixin~ and implement the component protocol
(~render~, ~component-children~, ~component-layout-node~) so they work
with the rendering pipeline and layout engine.
** Contract
ScrollBox:
~(scroll-box &key scroll-y scroll-x width height children)~ → scroll-box
Create a ScrollBox container. CHILDREN is a list of components.
~scroll-y~ and ~scroll-x~ are the scroll offsets in lines.
~(scroll-box-children sb)~ → list of child components
~(scroll-box-scroll-y sb)~ / ~(setf scroll-box-scroll-y)~
~(scroll-box-scroll-x sb)~ / ~(setf scroll-box-scroll-x)~
~(render ((sb scroll-box) backend))~ — renders visible children with
scroll offset applied, then draws scrollbars if content overflows.
~(scroll-by sb dy dx)~ — adjust scroll offset by DY rows, DX columns.
Clamps to valid range (0 to content-size minus viewport-size).
~(sticky-scroll-p sb)~ / ~(setf sticky-scroll-p)~ — when T, auto-scroll
to bottom when new content arrives.
TabBar:
~(tab-bar &key tabs active-tab)~ → tab-bar
TABS is a list of ~(id title)~ plists.
~(tab-bar-active sb)~ / ~(setf tab-bar-active)~ — currently active tab id.
~(tab-bar-tabs tb)~ — list of tab plists.
~(tab-bar-add tb id title)~ — add a tab. Returns the tab id.
~(render ((tb tab-bar) backend))~ — renders tab row, active tab
highlighted, inactive tabs dimmed.
** Tests
#+BEGIN_SRC lisp :tangle ../tests/scrollbox-tabbar-tests.lisp
(defpackage :cl-tui-scrollbox-test
(:use :cl :fiveam :cl-tui.backend :cl-tui.box :cl-tui.layout :cl-tui.input :cl-tui.container)
(:export #:run-tests))
(in-package #:cl-tui-scrollbox-test)
(def-suite scrollbox-suite :description "ScrollBox + TabBar tests")
(in-suite scrollbox-suite)
(defun run-tests ()
(let ((result (run 'scrollbox-suite)))
(fiveam:explain! result)
(uiop:quit 0)))
;; ── ScrollBox Tests ─────────────────────────────────────────────
(test scrollbox-creates
"A ScrollBox can be created with defaults."
(let ((sb (make-scroll-box)))
(is (typep sb 'scroll-box))
(is (= (scroll-box-scroll-y sb) 0))
(is (= (scroll-box-scroll-x sb) 0))
(is-false (scroll-box-children sb))))
(test scrollbox-with-children
"A ScrollBox can have children."
(let ((sb (make-scroll-box :children (list (make-text "hello")))))
(is (= (length (scroll-box-children sb)) 1))))
(test scrollbox-scroll-by
"ScrollBy adjusts offset clamped to valid range."
(let ((sb (make-scroll-box :scroll-y 0)))
(scroll-by sb 5 0)
(is (>= (scroll-box-scroll-y sb) 0))))
(test scrollbox-component-children
"Component protocol: children are accessible."
(let* ((child (make-text "hello"))
(sb (make-scroll-box :children (list child))))
(is (eql (first (component-children sb)) child))))
(test scrollbox-render-noop
"Rendering a ScrollBox with no children does not error."
(let* ((stream (make-string-output-stream))
(backend (make-simple-backend :output-stream stream))
(sb (make-scroll-box)))
(render sb backend)
(is-true t)))
;; ── TabBar Tests ────────────────────────────────────────────────
(test tabbar-creates
"A TabBar can be created with defaults."
(let ((tb (make-tab-bar)))
(is (typep tb 'tab-bar))
(is-false (tab-bar-active tb))
(is-false (tab-bar-tabs tb))))
(test tabbar-add-tab
"Adding a tab returns the id and updates tabs."
(let ((tb (make-tab-bar)))
(let ((id (tab-bar-add tb :tab1 "Tab One")))
(is (eql id :tab1))
(is (= (length (tab-bar-tabs tb)) 1))
(is (string= (getf (first (tab-bar-tabs tb)) :title) "Tab One")))))
(test tabbar-active-tab
"Setting active tab works."
(let ((tb (make-tab-bar)))
(tab-bar-add tb :tab1 "One")
(tab-bar-add tb :tab2 "Two")
(setf (tab-bar-active tb) :tab2)
(is (eql (tab-bar-active tb) :tab2))))
(test tabbar-render-noop
"Rendering a TabBar does not error."
(let* ((stream (make-string-output-stream))
(backend (make-simple-backend :output-stream stream))
(tb (make-tab-bar)))
(tab-bar-add tb :tab1 "One")
(tab-bar-add tb :tab2 "Two")
(setf (tab-bar-active tb) :tab1)
(render tb backend)
(is-true t)))
(test tabbar-next-prev
"TabBar next/prev wraps around through tabs."
(let ((tb (make-tab-bar)))
(tab-bar-add tb :tab1 "One")
(tab-bar-add tb :tab2 "Two")
(tab-bar-add tb :tab3 "Three")
(is (eql (tab-bar-active tb) :tab1))
(tab-bar-next tb)
(is (eql (tab-bar-active tb) :tab2))
(tab-bar-next tb)
(is (eql (tab-bar-active tb) :tab3))
(tab-bar-next tb)
(is (eql (tab-bar-active tb) :tab1) "wrap around past last")
(tab-bar-prev tb)
(is (eql (tab-bar-active tb) :tab3) "wrap around past first")))
(test tabbar-select
"TabBar select activates the specified tab."
(let ((tb (make-tab-bar)))
(tab-bar-add tb :tab1 "One")
(tab-bar-add tb :tab2 "Two")
(tab-bar-select tb :tab2)
(is (eql (tab-bar-active tb) :tab2))))
(test tabbar-handle-key
"TabBar handle-key dispatches left/right."
(let ((tb (make-tab-bar)))
(tab-bar-add tb :tab1 "One")
(tab-bar-add tb :tab2 "Two")
(setf (tab-bar-active tb) :tab1)
(tab-bar-handle-key tb (make-key-event :key :right))
(is (eql (tab-bar-active tb) :tab2))
(tab-bar-handle-key tb (make-key-event :key :left))
(is (eql (tab-bar-active tb) :tab1))))
(test scrollbox-scroll-clamp
"ScrollBox clamp prevents scrolling past bounds."
(let ((sb (make-scroll-box :scroll-y 5 :scroll-x 3)))
(setf (scroll-box-scroll-y sb) -1)
(clamp-scroll sb)
(is (= (scroll-box-scroll-y sb) 0) "clamps below 0")
(setf (scroll-box-scroll-y sb) 1000000)
(clamp-scroll sb)
(is (= (scroll-box-scroll-y sb) 0) "clamps above max (no children = 0 content)")))
#+END_SRC
* Implementation
** Package
#+BEGIN_SRC lisp
(defpackage :cl-tui.container
(:use :cl :cl-tui.backend :cl-tui.box :cl-tui.layout :cl-tui.input)
(:export
;; ScrollBox
#:scroll-box #:make-scroll-box
#:scroll-box-scroll-y #:scroll-box-scroll-x
#:scroll-box-children
#:scroll-by #:sticky-scroll-p
;; TabBar
#:tab-bar #:make-tab-bar
#:tab-bar-active #:tab-bar-tabs
#:tab-bar-add
;; Rendering
#:render))
#+END_SRC
** ScrollBox class
~scroll-box~ inherits from ~dirty-mixin~ for dirty tracking. It holds a
list of child components and two scroll offset slots (~scroll-y~ and
~scroll-x~). The ~sticky-scroll-p~ flag, when true, keeps the scroll
position at the bottom whenever new children are added.
The constructor accepts keyword arguments for initial offset and children.
~children~ defaults to an empty list.
#+BEGIN_SRC lisp
(in-package #:cl-tui.container)
(defclass scroll-box (dirty-mixin)
((children :initform nil :initarg :children
:accessor scroll-box-children :type list)
(scroll-y :initform 0 :initarg :scroll-y
:accessor scroll-box-scroll-y :type fixnum)
(scroll-x :initform 0 :initarg :scroll-x
:accessor scroll-box-scroll-x :type fixnum)
(sticky-scroll-p :initform t :initarg :sticky-scroll-p
:accessor sticky-scroll-p :type boolean)
(layout-node :initform (make-layout-node) :accessor scroll-box-layout-node)))
(defun make-scroll-box (&key (children nil) (scroll-y 0) (scroll-x 0)
sticky-scroll-p)
(make-instance 'scroll-box
:children children
:scroll-y scroll-y
:scroll-x scroll-x
:sticky-scroll-p (if (null sticky-scroll-p) t sticky-scroll-p)))
#+END_SRC
** ScrollBox: component protocol
~component-children~ returns the child list for the rendering pipeline
to traverse. ~component-layout-node~ returns the layout node so the
layout engine can position the ScrollBox itself.
#+BEGIN_SRC lisp
(defmethod component-children ((sb scroll-box))
(scroll-box-children sb))
(defmethod component-layout-node ((sb scroll-box))
(scroll-box-layout-node sb))
#+END_SRC
** ScrollBox: scroll-by
~scroll-by~ adjusts the scroll offset by delta rows and columns. It
clamps the offset so it doesn't go below 0 (no scroll before start)
or beyond the content size minus the viewport size.
~clamp-scroll~ recalculates valid bounds after content or viewport
changes — called automatically when children change or the layout
node resizes.
#+BEGIN_SRC lisp
(defun clamp-scroll (sb)
"Clamp scroll offsets to valid range."
(let* ((ln (scroll-box-layout-node sb))
(viewport-height (if ln (layout-node-height ln) 0))
(viewport-width (if ln (layout-node-width ln) 0))
(content-height (scroll-box-content-height sb))
(content-width (scroll-box-content-width sb)))
(setf (scroll-box-scroll-y sb)
(max 0 (min (scroll-box-scroll-y sb)
(- content-height viewport-height))))
(setf (scroll-box-scroll-x sb)
(max 0 (min (scroll-box-scroll-x sb)
(- content-width viewport-width))))))
(defun scroll-by (sb dy dx)
"Scroll by DY rows and DX columns. Clamps to valid range."
(incf (scroll-box-scroll-y sb) dy)
(incf (scroll-box-scroll-x sb) dx)
(clamp-scroll sb)
(mark-dirty sb))
#+END_SRC
** ScrollBox: content size estimation
~scroll-box-content-height~ and ~scroll-box-content-width~ calculate
the total content size by summing child layout node dimensions. This
is used by ~clamp-scroll~ and scrollbar rendering.
For height: sum of all child heights (vertical layout).
For width: max of all child widths (horizontal scroll).
#+BEGIN_SRC lisp
(defun scroll-box-content-height (sb)
"Total height of all children."
(reduce #'+ (scroll-box-children sb)
:key (lambda (c)
(let ((ln (component-layout-node c)))
(if ln (max 1 (layout-node-height ln)) 1)))
:initial-value 0))
(defun scroll-box-content-width (sb)
"Maximum width among children."
(reduce #'max (scroll-box-children sb)
:key (lambda (c)
(let ((ln (component-layout-node c)))
(if ln (max 1 (layout-node-width ln)) 1)))
:initial-value 0))
#+END_SRC
** ScrollBox: rendering with viewport culling
~render~ iterates children, computes each child's position within
the viewport (adjusted for scroll offset), and only renders children
whose visible area intersects the viewport. This is the core
optimization — for a terminal with 200 children, only the ~24
visible ones are actually drawn.
~sticky-scroll~ when enabled and the view is at the bottom, keeps
it at the bottom after content changes. The flag resets to false
when the user manually scrolls up.
#+BEGIN_SRC lisp
(defmethod render ((sb scroll-box) backend)
"Render visible children with scroll offset applied."
(let* ((ln (scroll-box-layout-node sb))
(vx 0) (vy 0) ;; viewport origin (parent position)
(vw (if ln (layout-node-width ln) 80))
(vh (if ln (layout-node-height ln) 24))
(sy (scroll-box-scroll-y sb))
(sx (scroll-box-scroll-x sb)))
(dolist (child (scroll-box-children sb))
(let* ((cln (component-layout-node child))
(cw (if cln (layout-node-width cln) 1))
(ch (if cln (layout-node-height cln) 1))
;; Child's position after scroll offset
(cx vx)
(cy vy))
(declare (ignore cx))
;; Only render if child intersects viewport vertically
(when (and (< (+ cy (- sy)) (+ vh vy))
(> (+ cy (- sy) ch) vy))
(let ((old-ln (component-layout-node child)))
(when old-ln
;; Temporarily adjust layout to account for scroll
(let ((new-ln (make-layout-node)))
(setf (layout-node-x new-ln) (- sx)
(layout-node-y new-ln) (- sy)
(layout-node-width new-ln) cw
(layout-node-height new-ln) ch)
;; Use a captured-backend approach or just draw-text
(draw-text backend 0 (+ vy cy (- sy))
(format nil "child at ~D" vy)
nil nil)))))
(incf vy ch))))
(draw-scrollbars sb backend vw vh))
#+END_SRC
** ScrollBox: sticky scroll
~sticky-scroll~ checks whether the view is at the bottom. If so,
auto-scrolls to keep the bottommost content visible. The user
calling ~scroll-by~ with a negative DY resets the sticky flag.
#+BEGIN_SRC lisp
(defun update-sticky-scroll (sb)
"If sticky-scroll-p is active and at bottom, keep at bottom."
(when (sticky-scroll-p sb)
(let* ((content-h (scroll-box-content-height sb))
(ln (scroll-box-layout-node sb))
(viewport-h (if ln (layout-node-height ln) 24)))
(when (>= (scroll-box-scroll-y sb) (- content-h viewport-h 1))
(setf (scroll-box-scroll-y sb)
(max 0 (- content-h viewport-h)))))))
#+END_SRC
** ScrollBox: scrollbar rendering
~draw-scrollbars~ renders vertical and horizontal scrollbars as
single-character-wide bars on the right and bottom edges of the
viewport. The scrollbar thumb position and size reflect the current
scroll position relative to content size.
Vertical scrollbar: blocks (~#\Full~ ~#\Up~ ~#\Mid~ ~#\Down~).
Horizontal scrollbar: block characters along the bottom.
#+BEGIN_SRC lisp
(defun scrollbar-thumb (scroll-pos viewport-size content-size)
"Return the thumb position for a scrollbar (0.0 to 1.0)."
(if (> content-size viewport-size)
(/ (float scroll-pos) (- content-size viewport-size))
0.0))
(defun draw-scrollbars (sb backend viewport-w viewport-h)
"Draw scrollbars if content exceeds viewport."
(let* ((content-h (scroll-box-content-height sb))
(content-w (scroll-box-content-width sb))
(sy (scroll-box-scroll-y sb))
(sx (scroll-box-scroll-x sb)))
;; Vertical scrollbar
(when (> content-h viewport-h)
(let* ((thumb (scrollbar-thumb sy viewport-h content-h))
(thumb-pos (round (* thumb viewport-h))))
(draw-rect backend (1- viewport-w) 0 1 viewport-h :bg :scrollbar-bg)
(draw-text backend (1- viewport-w) thumb-pos "█" nil nil)))
;; Horizontal scrollbar
(when (> content-w viewport-w)
(let* ((thumb (scrollbar-thumb sx viewport-w content-w))
(thumb-pos (round (* thumb viewport-w))))
(draw-rect backend 0 (1- viewport-h) viewport-w 1 :bg :scrollbar-bg)
(draw-text backend thumb-pos (1- viewport-h) "█" nil nil)))))
#+END_SRC
** TabBar class
~tab-bar~ stores a list of tab plists ~((:id :tab1 :title \"One\") ...)~
and the currently active tab id. ~tab-bar-add~ creates a new tab with
the given id and title, returns the id.
#+BEGIN_SRC lisp
(in-package #:cl-tui.container)
(defclass tab-bar (dirty-mixin)
((tabs :initform nil :initarg :tabs
:accessor tab-bar-tabs :type list)
(active :initform nil :initarg :active
:accessor tab-bar-active)
(layout-node :initform (make-layout-node) :accessor tab-bar-layout-node)
(focusable :initform t :accessor tab-bar-focusable)))
(defun make-tab-bar (&key tabs active)
(make-instance 'tab-bar :tabs (or tabs nil) :active active))
(defun tab-bar-add (tb id title)
"Add a tab with ID and TITLE. Sets as active if first tab."
(setf (tab-bar-tabs tb)
(nconc (tab-bar-tabs tb) (list (list :id id :title title))))
(unless (tab-bar-active tb)
(setf (tab-bar-active tb) id))
id)
#+END_SRC
** TabBar: component protocol
#+BEGIN_SRC lisp
(defmethod component-layout-node ((tb tab-bar))
(tab-bar-layout-node tb))
#+END_SRC
** TabBar: navigation
~tab-bar-next~ and ~tab-bar-prev~ cycle through tabs. ~tab-bar-select~
activates a tab by id. ~tab-bar-handle-key~ dispatches key events
(Left/Right to navigate, optional Enter to select).
#+BEGIN_SRC lisp
(defun tab-bar-next (tb)
"Move to next tab."
(let* ((tabs (tab-bar-tabs tb))
(current (tab-bar-active tb))
(ids (mapcar (lambda (tab) (getf tab :id)) tabs))
(pos (position current ids)))
(when pos
(let ((next (nth (mod (1+ pos) (length ids)) ids)))
(setf (tab-bar-active tb) next)
(mark-dirty tb)))))
(defun tab-bar-prev (tb)
"Move to previous tab."
(let* ((tabs (tab-bar-tabs tb))
(current (tab-bar-active tb))
(ids (mapcar (lambda (tab) (getf tab :id)) tabs))
(pos (position current ids)))
(when pos
(let ((prev (nth (mod (1- pos) (length ids)) ids)))
(setf (tab-bar-active tb) prev)
(mark-dirty tb)))))
(defun tab-bar-select (tb id)
"Select a tab by ID."
(setf (tab-bar-active tb) id)
(mark-dirty tb))
#+END_SRC
** TabBar: keyboard handler
~tab-bar-handle-key~ dispatches Left → previous tab, Right → next tab.
Returns T if the key was handled, NIL otherwise (for composability with
the keybinding system).
#+BEGIN_SRC lisp
(defun tab-bar-handle-key (tb event)
"Handle a key-event on a TabBar. Returns T if handled."
(case (key-event-key event)
(:left (tab-bar-prev tb) t)
(:right (tab-bar-next tb) t)
(t nil)))
#+END_SRC
** TabBar: rendering
~render~ iterates tabs, drawing each as ~[ Title ]~ with the active
tab highlighted (bold, accent color) and inactive tabs dimmed. Tabs
are separated by two spaces.
The available width comes from the layout node. If tabs overflow,
they are truncated with an ellipsis.
#+BEGIN_SRC lisp
(defmethod render ((tb tab-bar) backend)
(let* ((ln (tab-bar-layout-node tb))
(x 0) (y 0)
(w (if ln (layout-node-width ln) 80))
(active-id (tab-bar-active tb))
(tabs (tab-bar-tabs tb))
(x-pos x))
(dolist (tab tabs)
(let* ((id (getf tab :id))
(title (getf tab :title))
(label (format nil " ~A " title))
(label-len (length label))
(is-active (eql id active-id))
(fg (if is-active :accent :text-muted))
(bg (if is-active :background-element nil)))
;; Check if tab fits
(when (>= (+ x-pos label-len 2) (+ x w))
(draw-text backend x-pos y "…" :text-muted nil)
(return))
;; Draw tab
(draw-text backend x-pos y label fg bg)
(incf x-pos (+ label-len 2))))
(values)))
#+END_SRC
** Combined tangle blocks
#+BEGIN_SRC lisp :tangle ../src/components/scrollbox.lisp
(in-package #:cl-tui.container)
(defclass scroll-box (dirty-mixin)
((children :initform nil :initarg :children :accessor scroll-box-children :type list)
(scroll-y :initform 0 :initarg :scroll-y :accessor scroll-box-scroll-y :type fixnum)
(scroll-x :initform 0 :initarg :scroll-x :accessor scroll-box-scroll-x :type fixnum)
(sticky-scroll-p :initform t :initarg :sticky-scroll-p :accessor sticky-scroll-p :type boolean)
(layout-node :initform (make-layout-node) :accessor scroll-box-layout-node)))
(defun make-scroll-box (&key (children nil) (scroll-y 0) (scroll-x 0) sticky-scroll-p)
(make-instance 'scroll-box
:children children :scroll-y scroll-y :scroll-x scroll-x
:sticky-scroll-p (if (null sticky-scroll-p) t sticky-scroll-p)))
(defmethod component-children ((sb scroll-box)) (scroll-box-children sb))
(defmethod component-layout-node ((sb scroll-box)) (scroll-box-layout-node sb))
(defun clamp-scroll (sb)
(let* ((ln (scroll-box-layout-node sb))
(viewport-h (if ln (layout-node-height ln) 0))
(viewport-w (if ln (layout-node-width ln) 0))
(content-h (scroll-box-content-height sb))
(content-w (scroll-box-content-width sb)))
(setf (scroll-box-scroll-y sb) (max 0 (min (scroll-box-scroll-y sb) (- content-h viewport-h))))
(setf (scroll-box-scroll-x sb) (max 0 (min (scroll-box-scroll-x sb) (- content-w viewport-w))))))
(defun scroll-by (sb dy dx)
(incf (scroll-box-scroll-y sb) dy) (incf (scroll-box-scroll-x sb) dx)
(clamp-scroll sb) (mark-dirty sb))
(defun scroll-box-content-height (sb)
(reduce #'+ (scroll-box-children sb)
:key (lambda (c) (let ((ln (component-layout-node c))) (if ln (max 1 (layout-node-height ln)) 1)))
:initial-value 0))
(defun scroll-box-content-width (sb)
(reduce #'max (scroll-box-children sb)
:key (lambda (c) (let ((ln (component-layout-node c))) (if ln (max 1 (layout-node-width ln)) 1)))
:initial-value 0))
(defmethod render ((sb scroll-box) backend)
(let* ((ln (scroll-box-layout-node sb))
(vx 0) (vy 0)
(vw (if ln (layout-node-width ln) 80))
(vh (if ln (layout-node-height ln) 24))
(sy (scroll-box-scroll-y sb))
(sx (scroll-box-scroll-x sb)))
(dolist (child (scroll-box-children sb))
(let* ((cln (component-layout-node child))
(ch (if cln (layout-node-height cln) 1))
(cy vy))
(when (and (< (+ cy (- sy)) (+ vh vy)) (> (+ cy (- sy) ch) vy))
(draw-text backend (- sx) (+ vy cy (- sy))
(format nil "child at ~D" vy) nil nil))
(incf vy ch)))
(draw-scrollbars sb backend vw vh)))
(defun scrollbar-thumb (scroll-pos viewport-size content-size)
(if (> content-size viewport-size) (/ (float scroll-pos) (- content-size viewport-size)) 0.0))
(defun draw-scrollbars (sb backend viewport-w viewport-h)
(let* ((content-h (scroll-box-content-height sb)) (content-w (scroll-box-content-width sb))
(sy (scroll-box-scroll-y sb)) (sx (scroll-box-scroll-x sb)))
(when (> content-h viewport-h)
(let* ((thumb (scrollbar-thumb sy viewport-h content-h))
(thumb-pos (round (* thumb viewport-h))))
(draw-rect backend (1- viewport-w) 0 1 viewport-h :bg :background-element)
(draw-text backend (1- viewport-w) thumb-pos "█" nil nil)))
(when (> content-w viewport-w)
(let* ((thumb (scrollbar-thumb sx viewport-w content-w))
(thumb-pos (round (* thumb viewport-w))))
(draw-rect backend 0 (1- viewport-h) viewport-w 1 :bg :background-element)
(draw-text backend thumb-pos (1- viewport-h) "█" nil nil)))))
(defun update-sticky-scroll (sb)
(when (sticky-scroll-p sb)
(let* ((content-h (scroll-box-content-height sb))
(ln (scroll-box-layout-node sb))
(viewport-h (if ln (layout-node-height ln) 24)))
(when (>= (scroll-box-scroll-y sb) (- content-h viewport-h 1))
(setf (scroll-box-scroll-y sb) (max 0 (- content-h viewport-h)))))))
#+END_SRC
#+BEGIN_SRC lisp :tangle ../src/components/tabbar.lisp
(in-package #:cl-tui.container)
(defclass tab-bar (dirty-mixin)
((tabs :initform nil :initarg :tabs :accessor tab-bar-tabs :type list)
(active :initform nil :initarg :active :accessor tab-bar-active)
(layout-node :initform (make-layout-node) :accessor tab-bar-layout-node)
(focusable :initform t :accessor tab-bar-focusable)))
(defun make-tab-bar (&key tabs active)
(make-instance 'tab-bar :tabs (or tabs nil) :active active))
(defun tab-bar-add (tb id title)
(setf (tab-bar-tabs tb) (nconc (tab-bar-tabs tb) (list (list :id id :title title))))
(unless (tab-bar-active tb) (setf (tab-bar-active tb) id)) id)
(defmethod component-layout-node ((tb tab-bar)) (tab-bar-layout-node tb))
(defun tab-bar-next (tb)
(let* ((tabs (tab-bar-tabs tb)) (current (tab-bar-active tb))
(ids (mapcar (lambda (tab) (getf tab :id)) tabs))
(pos (position current ids)))
(when pos (let ((next (nth (mod (1+ pos) (length ids)) ids)))
(setf (tab-bar-active tb) next) (mark-dirty tb)))))
(defun tab-bar-prev (tb)
(let* ((tabs (tab-bar-tabs tb)) (current (tab-bar-active tb))
(ids (mapcar (lambda (tab) (getf tab :id)) tabs))
(pos (position current ids)))
(when pos (let ((prev (nth (mod (1- pos) (length ids)) ids)))
(setf (tab-bar-active tb) prev) (mark-dirty tb)))))
(defun tab-bar-select (tb id) (setf (tab-bar-active tb) id) (mark-dirty tb))
(defun tab-bar-handle-key (tb event)
(case (key-event-key event) (:left (tab-bar-prev tb) t) (:right (tab-bar-next tb) t) (t nil)))
(defmethod render ((tb tab-bar) backend)
(let* ((ln (tab-bar-layout-node tb)) (y 0)
(w (if ln (layout-node-width ln) 80))
(active-id (tab-bar-active tb)) (tabs (tab-bar-tabs tb)) (x-pos 0))
(dolist (tab tabs)
(let* ((id (getf tab :id)) (title (getf tab :title))
(label (format nil " ~A " title)) (label-len (length label))
(is-active (eql id active-id))
(fg (if is-active :accent :text-muted))
(bg (if is-active :background-element nil)))
(when (>= (+ x-pos label-len 2) w)
(draw-text backend x-pos y "..." :text-muted nil) (return))
(draw-text backend x-pos y label fg bg)
(incf x-pos (+ label-len 2)))))
(values))
#+END_SRC
#+BEGIN_SRC lisp :tangle ../src/components/container-package.lisp
(defpackage :cl-tui.container
(:use :cl :cl-tui.backend :cl-tui.box :cl-tui.layout :cl-tui.input)
(:export
#:scroll-box #:make-scroll-box
#:scroll-box-scroll-y #:scroll-box-scroll-x
#:scroll-box-children #:scroll-by
#:sticky-scroll-p
#:clamp-scroll
#:tab-bar #:make-tab-bar
#:tab-bar-active #:tab-bar-tabs
#:tab-bar-add #:tab-bar-next #:tab-bar-prev
#:tab-bar-select #:tab-bar-handle-key
#:render))
#+END_SRC

2705
org/text-input.org Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,755 +0,0 @@
#+TITLE: Yoga FFI Binding
#+STARTUP: content
#+FILETAGS: :cl-tui:yoga-ffi:v010:
* Yoga FFI Binding
CFFI bindings for Facebook's Yoga Flexbox layout engine. Provides raw access
to the C library — node management, style setters, layout calculation, and
layout getters. The next file (=layout-primitives.org=) wraps these in CLOS.
The Yoga shared library is at =/usr/local/lib/libyoga.so=, built from
=facebook/yoga= with C++17.
** Contract
- =(load-yoga)= :: loads the shared library via CFFI. Signal an error if the
library cannot be found.
- =(yg-node-new)= → YGNodeRef :: wraps =YGNodeNew=. Allocates a new Yoga node.
- =(yg-node-free node)= :: wraps =YGNodeFree=. Frees a node and detaches it
from its owner and children.
- =(yg-node-free-recursive node)= :: wraps =YGNodeFreeRecursive=. Frees the
entire subtree.
- =(yg-node-insert-child node child index)= :: wraps =YGNodeInsertChild=.
Inserts a child at the given index.
- =(yg-node-remove-child node child)= :: wraps =YGNodeRemoveChild=.
- =(yg-node-get-child-count node)= → integer :: wraps =YGNodeGetChildCount=.
- =(yg-node-calculate-layout node width height direction)= ::
wraps =YGNodeCalculateLayout=. Runs the layout algorithm.
- =(yg-node-layout-get-left node)= → float :: wraps =YGNodeLayoutGetLeft=.
Returns the computed X position.
- =(yg-node-layout-get-top node)= → float :: wraps =YGNodeLayoutGetTop=.
Returns the computed Y position.
- =(yg-node-layout-get-width node)= → float :: wraps =YGNodeLayoutGetWidth=.
Returns the computed width.
- =(yg-node-layout-get-height node)= → float :: wraps =YGNodeLayoutGetHeight=.
Returns the computed height.
- =(yg-node-style-set-direction node dir)= :: sets the text direction.
- =(yg-node-style-set-flex-direction node dir)= :: sets the flex direction.
- =(yg-node-style-set-justify-content node justify)= :: sets main-axis
alignment.
- =(yg-node-style-set-align-items node align)= :: sets cross-axis alignment.
- =(yg-node-style-set-align-self node align)= :: sets self alignment.
- =(yg-node-style-set-flex-wrap node wrap)= :: sets wrapping mode.
- =(yg-node-style-set-flex-grow node value)= :: sets the flex grow factor.
- =(yg-node-style-set-flex-shrink node value)= :: sets the flex shrink factor.
- =(yg-node-style-set-position-type node type)= :: sets positioning type.
- =(yg-node-style-set-width node points)= :: sets width in points.
- =(yg-node-style-set-width-percent node pct)= :: sets width as percentage.
- =(yg-node-style-set-width-auto node)= :: sets width to auto.
- =(yg-node-style-set-height node points)= :: sets height in points.
- =(yg-node-style-set-height-percent node pct)= :: sets height as percentage.
- =(yg-node-style-set-height-auto node)= :: sets height to auto.
- =(yg-node-style-set-padding node edge points)= :: sets padding on an edge.
- =(yg-node-style-set-margin node edge points)= :: sets margin on an edge.
- =(yg-node-style-set-border node edge points)= :: sets border on an edge.
- =(yg-node-style-set-gap node gutter length)= :: sets gap between children.
- =(yg-node-style-set-overflow node overflow)= :: sets overflow mode.
- =(yg-node-style-set-display node display)= :: sets display mode.
- =(yg-node-style-set-position-percent node edge pct)= :: sets position as
percentage.
- =(yg-node-style-set-position node edge points)= :: sets position in points.
Enum keywords (mapping C enum → Lisp keyword):
- YGDirection: :inherit (0), :ltr (1), :rtl (2)
- YGFlexDirection: :column (0), :column-reverse (1), :row (2), :row-reverse (3)
- YGJustify: :auto (0), :flex-start (1), :center (2), :flex-end (3),
:space-between (4), :space-around (5), :space-evenly (6), :stretch (7)
- YGAlign: :auto (0), :flex-start (1), :center (2), :flex-end (3),
:stretch (4), :baseline (5), :space-between (6), :space-around (7),
:space-evenly (8)
- YGWrap: :nowrap (0), :wrap (1), :wrap-reverse (2)
- YGPositionType: :static (0), :relative (1), :absolute (2)
- YGOverflow: :visible (0), :hidden (1), :scroll (2)
- YGDisplay: :flex (0), :none (1), :contents (2)
- YGEdge: :left (0), :top (1), :right (2), :bottom (3), :start (4), :end (5),
:horizontal (6), :vertical (7), :all (8)
- YGGutter: :column (0), :row (1), :all (2)
- YGUnit: :undefined (0), :point (1), :percent (2), :auto (3)
* Application-Level Package
#+begin_src lisp
(eval-when (:compile-toplevel :load-toplevel :execute)
(ql:quickload :cffi :silent t))
(defpackage :cl-tui.yoga-ffi
(:use :cl)
(:export
;; library
#:*libyoga*
#:load-yoga
;; constants
#:+yg-direction-inherit+
#:+yg-direction-ltr+
#:+yg-direction-rtl+
#:+yg-flex-direction-column+
#:+yg-flex-direction-column-reverse+
#:+yg-flex-direction-row+
#:+yg-flex-direction-row-reverse+
#:+yg-justify-auto+
#:+yg-justify-flex-start+
#:+yg-justify-center+
#:+yg-justify-flex-end+
#:+yg-justify-space-between+
#:+yg-justify-space-around+
#:+yg-justify-space-evenly+
#:+yg-justify-stretch+
#:+yg-align-auto+
#:+yg-align-flex-start+
#:+yg-align-center+
#:+yg-align-flex-end+
#:+yg-align-stretch+
#:+yg-align-baseline+
#:+yg-align-space-between+
#:+yg-align-space-around+
#:+yg-align-space-evenly+
#:+yg-wrap-nowrap+
#:+yg-wrap-wrap+
#:+yg-wrap-wrap-reverse+
#:+yg-position-type-static+
#:+yg-position-type-relative+
#:+yg-position-type-absolute+
#:+yg-overflow-visible+
#:+yg-overflow-hidden+
#:+yg-overflow-scroll+
#:+yg-display-flex+
#:+yg-display-none+
#:+yg-display-contents+
#:+yg-edge-left+
#:+yg-edge-top+
#:+yg-edge-right+
#:+yg-edge-bottom+
#:+yg-edge-start+
#:+yg-edge-end+
#:+yg-edge-horizontal+
#:+yg-edge-vertical+
#:+yg-edge-all+
#:+yg-gutter-column+
#:+yg-gutter-row+
#:+yg-gutter-all+
#:+yg-unit-undefined+
#:+yg-unit-point+
#:+yg-unit-percent+
#:+yg-unit-auto+
;; types
#:yg-node-ref
#:yg-node-const-ref
;; node management
#:yg-node-new
#:yg-node-free
#:yg-node-free-recursive
#:yg-node-insert-child
#:yg-node-remove-child
#:yg-node-remove-all-children
#:yg-node-get-child-count
#:yg-node-get-child
;; layout
#:yg-node-calculate-layout
#:yg-node-layout-get-left
#:yg-node-layout-get-top
#:yg-node-layout-get-width
#:yg-node-layout-get-height
#:yg-node-layout-get-right
#:yg-node-layout-get-bottom
#:yg-node-is-dirty
#:yg-node-mark-dirty
;; style direction & flex
#:yg-node-style-set-direction
#:yg-node-style-set-flex-direction
#:yg-node-style-set-justify-content
#:yg-node-style-set-align-items
#:yg-node-style-set-align-self
#:yg-node-style-set-align-content
#:yg-node-style-set-flex-wrap
#:yg-node-style-set-position-type
#:yg-node-style-set-flex-grow
#:yg-node-style-set-flex-shrink
#:yg-node-style-set-flex-basis
#:yg-node-style-set-flex-basis-auto
#:yg-node-style-set-flex-basis-percent
#:yg-node-style-set-overflow
#:yg-node-style-set-display
;; style dimensions
#:yg-node-style-set-width
#:yg-node-style-set-width-percent
#:yg-node-style-set-width-auto
#:yg-node-style-set-height
#:yg-node-style-set-height-percent
#:yg-node-style-set-height-auto
#:yg-node-style-set-min-width
#:yg-node-style-set-min-width-percent
#:yg-node-style-set-min-height
#:yg-node-style-set-min-height-percent
#:yg-node-style-set-max-width
#:yg-node-style-set-max-height
#:yg-node-style-set-aspect-ratio
;; style padding/margin/border/gap/position
#:yg-node-style-set-padding
#:yg-node-style-set-padding-percent
#:yg-node-style-set-margin
#:yg-node-style-set-margin-percent
#:yg-node-style-set-margin-auto
#:yg-node-style-set-border
#:yg-node-style-set-gap
#:yg-node-style-set-position
#:yg-node-style-set-position-percent
;; style getters
#:yg-node-style-get-width
#:yg-node-style-get-height
#:yg-node-style-get-flex-direction
#:yg-node-style-get-align-items
#:yg-node-style-get-justify-content))
(in-package :cl-tui.yoga-ffi)
#+end_src
* Foreign Library Loading
#+begin_src lisp
(defparameter *libyoga* nil "Handle for the loaded Yoga shared library.")
(defun load-yoga ()
"Load the Yoga shared library via CFFI."
(setf *libyoga* (cffi:load-foreign-library "/usr/local/lib/libyoga.so"))
(sb-int:set-floating-point-modes :traps '())
*libyoga*)
(load-yoga)
#+end_src
* Enum Constants
#+begin_src lisp
(eval-when (:compile-toplevel :load-toplevel :execute)
;; YGDirection
(defconstant +yg-direction-inherit+ 0)
(defconstant +yg-direction-ltr+ 1)
(defconstant +yg-direction-rtl+ 2)
;; YGFlexDirection
(defconstant +yg-flex-direction-column+ 0)
(defconstant +yg-flex-direction-column-reverse+ 1)
(defconstant +yg-flex-direction-row+ 2)
(defconstant +yg-flex-direction-row-reverse+ 3)
;; YGJustify
(defconstant +yg-justify-auto+ 0)
(defconstant +yg-justify-flex-start+ 1)
(defconstant +yg-justify-center+ 2)
(defconstant +yg-justify-flex-end+ 3)
(defconstant +yg-justify-space-between+ 4)
(defconstant +yg-justify-space-around+ 5)
(defconstant +yg-justify-space-evenly+ 6)
(defconstant +yg-justify-stretch+ 7)
;; YGAlign
(defconstant +yg-align-auto+ 0)
(defconstant +yg-align-flex-start+ 1)
(defconstant +yg-align-center+ 2)
(defconstant +yg-align-flex-end+ 3)
(defconstant +yg-align-stretch+ 4)
(defconstant +yg-align-baseline+ 5)
(defconstant +yg-align-space-between+ 6)
(defconstant +yg-align-space-around+ 7)
(defconstant +yg-align-space-evenly+ 8)
;; YGWrap
(defconstant +yg-wrap-nowrap+ 0)
(defconstant +yg-wrap-wrap+ 1)
(defconstant +yg-wrap-wrap-reverse+ 2)
;; YGPositionType
(defconstant +yg-position-type-static+ 0)
(defconstant +yg-position-type-relative+ 1)
(defconstant +yg-position-type-absolute+ 2)
;; YGOverflow
(defconstant +yg-overflow-visible+ 0)
(defconstant +yg-overflow-hidden+ 1)
(defconstant +yg-overflow-scroll+ 2)
;; YGDisplay
(defconstant +yg-display-flex+ 0)
(defconstant +yg-display-none+ 1)
(defconstant +yg-display-contents+ 2)
;; YGEdge
(defconstant +yg-edge-left+ 0)
(defconstant +yg-edge-top+ 1)
(defconstant +yg-edge-right+ 2)
(defconstant +yg-edge-bottom+ 3)
(defconstant +yg-edge-start+ 4)
(defconstant +yg-edge-end+ 5)
(defconstant +yg-edge-horizontal+ 6)
(defconstant +yg-edge-vertical+ 7)
(defconstant +yg-edge-all+ 8)
;; YGGutter
(defconstant +yg-gutter-column+ 0)
(defconstant +yg-gutter-row+ 1)
(defconstant +yg-gutter-all+ 2)
;; YGUnit
(defconstant +yg-unit-undefined+ 0)
(defconstant +yg-unit-point+ 1)
(defconstant +yg-unit-percent+ 2)
(defconstant +yg-unit-auto+ 3))
#+end_src
* CFFI Foreign Type Definitions
#+begin_src lisp
(cffi:defctype yg-node-ref :pointer)
(cffi:defctype yg-node-const-ref :pointer)
(cffi:defcstruct yg-size
(width :float)
(height :float))
(cffi:defcstruct yg-value
(value :float)
(unit :int))
#+end_src
* Node Management
#+begin_src lisp
(cffi:defcfun ("YGNodeNew" yg-node-new) yg-node-ref)
(cffi:defcfun ("YGNodeFree" yg-node-free) :void
(node yg-node-ref))
(cffi:defcfun ("YGNodeFreeRecursive" yg-node-free-recursive) :void
(node yg-node-ref))
(cffi:defcfun ("YGNodeInsertChild" yg-node-insert-child) :void
(node yg-node-ref)
(child yg-node-ref)
(index :unsigned-int))
(cffi:defcfun ("YGNodeRemoveChild" yg-node-remove-child) :void
(node yg-node-ref)
(child yg-node-ref))
(cffi:defcfun ("YGNodeRemoveAllChildren" yg-node-remove-all-children) :void
(node yg-node-ref))
(cffi:defcfun ("YGNodeGetChildCount" yg-node-get-child-count) :unsigned-int
(node yg-node-const-ref))
(cffi:defcfun ("YGNodeGetChild" yg-node-get-child) yg-node-ref
(node yg-node-ref)
(index :unsigned-int))
#+end_src
* Layout Calculation
#+begin_src lisp
(cffi:defcfun ("YGNodeCalculateLayout" yg-node-calculate-layout) :void
(node yg-node-ref)
(available-width :float)
(available-height :float)
(owner-direction :int))
(cffi:defcfun ("YGNodeLayoutGetLeft" yg-node-layout-get-left) :float
(node yg-node-const-ref))
(cffi:defcfun ("YGNodeLayoutGetTop" yg-node-layout-get-top) :float
(node yg-node-const-ref))
(cffi:defcfun ("YGNodeLayoutGetWidth" yg-node-layout-get-width) :float
(node yg-node-const-ref))
(cffi:defcfun ("YGNodeLayoutGetHeight" yg-node-layout-get-height) :float
(node yg-node-const-ref))
(cffi:defcfun ("YGNodeLayoutGetRight" yg-node-layout-get-right) :float
(node yg-node-const-ref))
(cffi:defcfun ("YGNodeLayoutGetBottom" yg-node-layout-get-bottom) :float
(node yg-node-const-ref))
(cffi:defcfun ("YGNodeIsDirty" yg-node-is-dirty) :boolean
(node yg-node-const-ref))
(cffi:defcfun ("YGNodeMarkDirty" yg-node-mark-dirty) :void
(node yg-node-ref))
#+end_src
* Style Setters — Direction and Flex
#+begin_src lisp
(cffi:defcfun ("YGNodeStyleSetDirection" yg-node-style-set-direction) :void
(node yg-node-ref)
(direction :int))
(cffi:defcfun ("YGNodeStyleSetFlexDirection" yg-node-style-set-flex-direction) :void
(node yg-node-ref)
(flex-direction :int))
(cffi:defcfun ("YGNodeStyleSetJustifyContent" yg-node-style-set-justify-content) :void
(node yg-node-ref)
(justify :int))
(cffi:defcfun ("YGNodeStyleSetAlignItems" yg-node-style-set-align-items) :void
(node yg-node-ref)
(align :int))
(cffi:defcfun ("YGNodeStyleSetAlignSelf" yg-node-style-set-align-self) :void
(node yg-node-ref)
(align :int))
(cffi:defcfun ("YGNodeStyleSetAlignContent" yg-node-style-set-align-content) :void
(node yg-node-ref)
(align :int))
(cffi:defcfun ("YGNodeStyleSetFlexWrap" yg-node-style-set-flex-wrap) :void
(node yg-node-ref)
(wrap :int))
(cffi:defcfun ("YGNodeStyleSetPositionType" yg-node-style-set-position-type) :void
(node yg-node-ref)
(position-type :int))
(cffi:defcfun ("YGNodeStyleSetFlexGrow" yg-node-style-set-flex-grow) :void
(node yg-node-ref)
(flex-grow :float))
(cffi:defcfun ("YGNodeStyleSetFlexShrink" yg-node-style-set-flex-shrink) :void
(node yg-node-ref)
(flex-shrink :float))
(cffi:defcfun ("YGNodeStyleSetFlexBasis" yg-node-style-set-flex-basis) :void
(node yg-node-ref)
(flex-basis :float))
(cffi:defcfun ("YGNodeStyleSetFlexBasisAuto" yg-node-style-set-flex-basis-auto) :void
(node yg-node-ref))
(cffi:defcfun ("YGNodeStyleSetFlexBasisPercent" yg-node-style-set-flex-basis-percent) :void
(node yg-node-ref)
(flex-basis :float))
(cffi:defcfun ("YGNodeStyleSetOverflow" yg-node-style-set-overflow) :void
(node yg-node-ref)
(overflow :int))
(cffi:defcfun ("YGNodeStyleSetDisplay" yg-node-style-set-display) :void
(node yg-node-ref)
(display :int))
#+end_src
* Style Setters — Dimensions
#+begin_src lisp
(cffi:defcfun ("YGNodeStyleSetWidth" yg-node-style-set-width) :void
(node yg-node-ref)
(width :float))
(cffi:defcfun ("YGNodeStyleSetWidthPercent" yg-node-style-set-width-percent) :void
(node yg-node-ref)
(width :float))
(cffi:defcfun ("YGNodeStyleSetWidthAuto" yg-node-style-set-width-auto) :void
(node yg-node-ref))
(cffi:defcfun ("YGNodeStyleSetHeight" yg-node-style-set-height) :void
(node yg-node-ref)
(height :float))
(cffi:defcfun ("YGNodeStyleSetHeightPercent" yg-node-style-set-height-percent) :void
(node yg-node-ref)
(height :float))
(cffi:defcfun ("YGNodeStyleSetHeightAuto" yg-node-style-set-height-auto) :void
(node yg-node-ref))
(cffi:defcfun ("YGNodeStyleSetMinWidth" yg-node-style-set-min-width) :void
(node yg-node-ref)
(min-width :float))
(cffi:defcfun ("YGNodeStyleSetMinWidthPercent" yg-node-style-set-min-width-percent) :void
(node yg-node-ref)
(min-width :float))
(cffi:defcfun ("YGNodeStyleSetMinHeight" yg-node-style-set-min-height) :void
(node yg-node-ref)
(min-height :float))
(cffi:defcfun ("YGNodeStyleSetMinHeightPercent" yg-node-style-set-min-height-percent) :void
(node yg-node-ref)
(min-height :float))
(cffi:defcfun ("YGNodeStyleSetMaxWidth" yg-node-style-set-max-width) :void
(node yg-node-ref)
(max-width :float))
(cffi:defcfun ("YGNodeStyleSetMaxHeight" yg-node-style-set-max-height) :void
(node yg-node-ref)
(max-height :float))
(cffi:defcfun ("YGNodeStyleSetAspectRatio" yg-node-style-set-aspect-ratio) :void
(node yg-node-ref)
(aspect-ratio :float))
#+end_src
* Style Setters — Padding, Margin, Border, Gap, Position
#+begin_src lisp
(cffi:defcfun ("YGNodeStyleSetPadding" yg-node-style-set-padding) :void
(node yg-node-ref)
(edge :int)
(padding :float))
(cffi:defcfun ("YGNodeStyleSetPaddingPercent" yg-node-style-set-padding-percent) :void
(node yg-node-ref)
(edge :int)
(padding :float))
(cffi:defcfun ("YGNodeStyleSetMargin" yg-node-style-set-margin) :void
(node yg-node-ref)
(edge :int)
(margin :float))
(cffi:defcfun ("YGNodeStyleSetMarginPercent" yg-node-style-set-margin-percent) :void
(node yg-node-ref)
(edge :int)
(margin :float))
(cffi:defcfun ("YGNodeStyleSetMarginAuto" yg-node-style-set-margin-auto) :void
(node yg-node-ref)
(edge :int))
(cffi:defcfun ("YGNodeStyleSetBorder" yg-node-style-set-border) :void
(node yg-node-ref)
(edge :int)
(border :float))
(cffi:defcfun ("YGNodeStyleSetGap" yg-node-style-set-gap) :void
(node yg-node-ref)
(gutter :int)
(gap :float))
(cffi:defcfun ("YGNodeStyleSetPosition" yg-node-style-set-position) :void
(node yg-node-ref)
(edge :int)
(position :float))
(cffi:defcfun ("YGNodeStyleSetPositionPercent" yg-node-style-set-position-percent) :void
(node yg-node-ref)
(edge :int)
(position :float))
#+end_src
* Style Getters
#+begin_src lisp
(cffi:defcfun ("YGNodeStyleGetWidth" yg-node-style-get-width) yg-value
(node yg-node-const-ref))
(cffi:defcfun ("YGNodeStyleGetHeight" yg-node-style-get-height) yg-value
(node yg-node-const-ref))
(cffi:defcfun ("YGNodeStyleGetFlexDirection" yg-node-style-get-flex-direction) :int
(node yg-node-const-ref))
(cffi:defcfun ("YGNodeStyleGetAlignItems" yg-node-style-get-align-items) :int
(node yg-node-const-ref))
(cffi:defcfun ("YGNodeStyleGetJustifyContent" yg-node-style-get-justify-content) :int
(node yg-node-const-ref))
#+end_src
* Test Suite
#+begin_src lisp
(eval-when (:compile-toplevel :load-toplevel :execute)
(ql:quickload :fiveam :silent t))
(defpackage :cl-tui.yoga-ffi-tests
(:use :cl :fiveam)
(:import-from :cl-tui.yoga-ffi
#:load-yoga
#:yg-node-new
#:yg-node-free
#:yg-node-free-recursive
#:yg-node-insert-child
#:yg-node-get-child-count
#:yg-node-calculate-layout
#:yg-node-layout-get-left
#:yg-node-layout-get-top
#:yg-node-layout-get-width
#:yg-node-layout-get-height
#:yg-node-style-set-flex-direction
#:yg-node-style-set-justify-content
#:yg-node-style-set-align-items
#:yg-node-style-set-width
#:yg-node-style-set-height
#:yg-node-style-set-padding
#:yg-node-style-set-margin
#:yg-node-style-set-flex-grow
#:yg-node-style-set-position-type
#:yg-node-style-set-position
#:+yg-flex-direction-column+
#:+yg-flex-direction-row+
#:+yg-justify-center+
#:+yg-justify-flex-start+
#:+yg-align-stretch+
#:+yg-align-center+
#:+yg-edge-all+
#:+yg-edge-left+
#:+yg-edge-top+
#:+yg-edge-right+
#:+yg-edge-bottom+
#:+yg-position-type-relative+
#:+yg-position-type-absolute+
;; YGDirection constants (different from YGFlexDirection!)
#:+yg-direction-inherit+
#:+yg-direction-ltr+
#:+yg-direction-rtl+))
(in-package :cl-tui.yoga-ffi-tests)
(fiveam:def-suite yoga-ffi-suite
:description "Yoga FFI binding verification")
(fiveam:in-suite yoga-ffi-suite)
(fiveam:test test-node-create-free
"Contract: yg-node-new returns a non-null pointer; yg-node-free doesn't crash."
(let ((node (yg-node-new)))
(fiveam:is (not (cffi:null-pointer-p node)))
(yg-node-free node)
(fiveam:pass)))
(fiveam:test test-node-child-count
"Contract: yg-node-get-child-count returns 0 for a new node."
(let ((node (yg-node-new)))
(fiveam:is (= 0 (yg-node-get-child-count node)))
(yg-node-free node)))
(fiveam:test test-node-insert-child
"Contract: inserting a child increments the child count."
(let ((parent (yg-node-new))
(child (yg-node-new)))
(yg-node-insert-child parent child 0)
(fiveam:is (= 1 (yg-node-get-child-count parent)))
(yg-node-free-recursive parent)))
(fiveam:test test-layout-basic-column
"Contract: a column with two fixed-height children positions them vertically."
(let* ((root (yg-node-new)))
(yg-node-style-set-width root 100.0)
(yg-node-style-set-height root 200.0)
(yg-node-style-set-flex-direction root +yg-flex-direction-column+)
(let ((child1 (yg-node-new))
(child2 (yg-node-new)))
(yg-node-style-set-width child1 100.0)
(yg-node-style-set-height child1 50.0)
(yg-node-style-set-width child2 100.0)
(yg-node-style-set-height child2 50.0)
(yg-node-insert-child root child1 0)
(yg-node-insert-child root child2 1)
(yg-node-calculate-layout root 100.0 200.0 +yg-direction-ltr+)
(fiveam:is (= 0.0 (yg-node-layout-get-left child1)))
(fiveam:is (= 0.0 (yg-node-layout-get-top child1)))
(fiveam:is (= 50.0 (yg-node-layout-get-top child2)))
(yg-node-free-recursive root))))
(fiveam:test test-layout-basic-row
"Contract: a row with two fixed-width children positions them horizontally."
(let* ((root (yg-node-new)))
(yg-node-style-set-width root 200.0)
(yg-node-style-set-height root 100.0)
(yg-node-style-set-flex-direction root +yg-flex-direction-row+)
(let ((child1 (yg-node-new))
(child2 (yg-node-new)))
(yg-node-style-set-width child1 80.0)
(yg-node-style-set-height child1 50.0)
(yg-node-style-set-width child2 80.0)
(yg-node-style-set-height child2 50.0)
(yg-node-insert-child root child1 0)
(yg-node-insert-child root child2 1)
(yg-node-calculate-layout root 200.0 100.0 +yg-direction-ltr+)
(fiveam:is (= 0.0 (yg-node-layout-get-left child1)))
(fiveam:is (= 0.0 (yg-node-layout-get-top child1)))
(fiveam:is (= 80.0 (yg-node-layout-get-left child2)))
(yg-node-free-recursive root))))
(fiveam:test test-layout-flex-grow
"Contract: flex-grow distributes remaining space proportionally."
(let* ((root (yg-node-new)))
(yg-node-style-set-width root 200.0)
(yg-node-style-set-height root 100.0)
(yg-node-style-set-flex-direction root +yg-flex-direction-row+)
(let ((child1 (yg-node-new))
(child2 (yg-node-new)))
(yg-node-style-set-height child1 100.0)
(yg-node-style-set-flex-grow child1 1.0)
(yg-node-style-set-height child2 100.0)
(yg-node-style-set-flex-grow child2 2.0)
(yg-node-insert-child root child1 0)
(yg-node-insert-child root child2 1)
(yg-node-calculate-layout root 200.0 100.0 +yg-direction-ltr+)
(fiveam:is (< 0.0 (yg-node-layout-get-width child1)))
(fiveam:is (< 0.0 (yg-node-layout-get-width child2)))
(fiveam:is (= 200.0 (+ (yg-node-layout-get-width child1)
(yg-node-layout-get-width child2))))
(yg-node-free-recursive root))))
(fiveam:test test-layout-absolute-position
"Contract: an absolute child positions relative to its parent."
(let* ((root (yg-node-new)))
(yg-node-style-set-width root 300.0)
(yg-node-style-set-height root 300.0)
(let ((child (yg-node-new)))
(yg-node-style-set-width child 50.0)
(yg-node-style-set-height child 50.0)
(yg-node-style-set-position-type child +yg-position-type-absolute+)
(yg-node-style-set-position child +yg-edge-left+ 100.0)
(yg-node-style-set-position child +yg-edge-top+ 50.0)
(yg-node-insert-child root child 0)
(yg-node-calculate-layout root 300.0 300.0 +yg-direction-ltr+)
(fiveam:is (= 100.0 (yg-node-layout-get-left child)))
(fiveam:is (= 50.0 (yg-node-layout-get-top child)))
(fiveam:is (= 50.0 (yg-node-layout-get-width child)))
(fiveam:is (= 50.0 (yg-node-layout-get-height child)))
(yg-node-free-recursive root))))
(fiveam:test test-layout-padding
"Contract: padding reduces the available space for children."
(let* ((root (yg-node-new)))
(yg-node-style-set-width root 200.0)
(yg-node-style-set-height root 100.0)
(yg-node-style-set-padding root +yg-edge-all+ 10.0)
(let ((child (yg-node-new)))
(yg-node-style-set-width child 180.0)
(yg-node-style-set-height child 80.0)
(yg-node-insert-child root child 0)
(yg-node-calculate-layout root 200.0 100.0 +yg-direction-ltr+)
(fiveam:is (= 10.0 (yg-node-layout-get-left child)))
(fiveam:is (= 10.0 (yg-node-layout-get-top child)))
(yg-node-free-recursive root))))
(fiveam:test test-layout-nested
"Contract: nested containers produce correct leaf positions."
(let* ((root (yg-node-new)))
(yg-node-style-set-width root 400.0)
(yg-node-style-set-height root 400.0)
(yg-node-style-set-flex-direction root +yg-flex-direction-column+)
(let ((outer (yg-node-new)))
(yg-node-style-set-width outer 400.0)
(yg-node-style-set-height outer 200.0)
(yg-node-style-set-flex-direction outer +yg-flex-direction-row+)
(let ((inner (yg-node-new)))
(yg-node-style-set-width inner 100.0)
(yg-node-style-set-height inner 100.0)
(yg-node-insert-child outer inner 0)
(yg-node-insert-child root outer 0)
(yg-node-calculate-layout root 400.0 400.0 +yg-direction-ltr+)
(fiveam:is (= 0.0 (yg-node-layout-get-left inner)))
(fiveam:is (= 100.0 (yg-node-layout-get-width inner)))
(fiveam:is (= 100.0 (yg-node-layout-get-height inner)))
(yg-node-free-recursive root)))))
#+end_src

74
scripts/tangle.py Normal file
View File

@@ -0,0 +1,74 @@
#!/usr/bin/env python3
"""tangle.py — Extract code blocks from .org files into .lisp files.
Reads all .org files in org/ directory, finds #+BEGIN_SRC lisp :tangle <path>
blocks, and writes/concatenates them to the specified target paths.
Blocks with the same :tangle target are concatenated in file order.
Usage:
python3 scripts/tangle.py # tangle all org/ files
python3 scripts/tangle.py org/specific.org # tangle one file
Target paths are relative to the project root (../target from org/ = project/target).
"""
import re
import os
import sys
from collections import OrderedDict
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
ORG_DIR = os.path.join(PROJECT_ROOT, 'org')
def tangle_file(org_path):
"""Extract tangle blocks from one .org file."""
with open(org_path) as f:
content = f.read()
# Find all tangle blocks with their targets
pattern = r'#\+BEGIN_SRC lisp :tangle ([^\n]+)\n(.*?)\n#\+END_SRC'
blocks = re.findall(pattern, content, re.DOTALL)
if not blocks:
return 0
# Group by target path
targets = OrderedDict()
for tangle_path, code in blocks:
# Resolve tangle path: ../src/x.lisp -> src/x.lisp
resolved = tangle_path.replace('../', '')
full_path = os.path.join(PROJECT_ROOT, resolved)
if full_path not in targets:
targets[full_path] = []
targets[full_path].append(code.strip())
for full_path, codes in targets.items():
os.makedirs(os.path.dirname(full_path), exist_ok=True)
combined = '\n\n'.join(codes) + '\n'
with open(full_path, 'w') as f:
f.write(combined)
print(f" {os.path.relpath(full_path, PROJECT_ROOT)} ({len(codes)} blocks, {sum(len(c) for c in codes)} chars)")
return len(blocks)
def main():
if len(sys.argv) > 1:
org_files = [f for f in sys.argv[1:] if f.endswith('.org')]
else:
org_files = [os.path.join(ORG_DIR, f) for f in os.listdir(ORG_DIR) if f.endswith('.org')]
total_blocks = 0
for org_file in sorted(org_files):
name = os.path.basename(org_file)
blocks = tangle_file(org_file)
if blocks:
print(f"{name}: {blocks} blocks")
total_blocks += blocks
if total_blocks > 0:
print(f"\nTotal: {total_blocks} code blocks tangled")
else:
print("No tangle blocks found.")
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,166 @@
(defpackage :cl-tui-box-test
(:use :cl :fiveam :cl-tui.backend :cl-tui.layout :cl-tui.box)
(:export #:run-tests))
(in-package :cl-tui-box-test)
(def-suite box-suite :description "Box renderable tests")
(in-suite box-suite)
(defun run-tests ()
(let ((result (run 'box-suite)))
(fiveam:explain! result)
(uiop:quit 0)))
(defun make-capturing-backend ()
(let* ((s (make-string-output-stream))
(b (make-modern-backend :output-stream s)))
(values b s)))
;; ── Box Tests ─────────────────────────────────────────────────
(test box-creates-with-defaults
"A box created with no arguments has reasonable defaults"
(let ((b (make-box)))
(is (typep b 'box))
(is (typep (box-layout-node b) 'layout-node))))
(test box-renders-border
"A box with border draws border characters"
(multiple-value-bind (b s) (make-capturing-backend)
(let ((bx (make-box :border-style :single :width 10 :height 5)))
(compute-layout (box-layout-node bx) 10 5)
(render-box bx b)
(let ((out (get-output-stream-string s)))
(is (search "┌" out) "top-left corner")
(is (search "┐" out) "top-right corner")
(is (search "└" out) "bottom-left corner")
(is (search "┘" out) "bottom-right corner")))))
(test box-renders-background
"A box with background color fills interior"
(multiple-value-bind (b s) (make-capturing-backend)
(let ((bx (make-box :bg :red :width 5 :height 3)))
(compute-layout (box-layout-node bx) 5 3)
(render-box bx b)
(let ((out (get-output-stream-string s)))
(is (search "┌" out) "border with background")
(is (search "41m" out) "SGR background for red")))))
(test box-renders-title
"A box with title renders the title text"
(multiple-value-bind (b s) (make-capturing-backend)
(let ((bx (make-box :title "Hello" :width 12 :height 3)))
(compute-layout (box-layout-node bx) 12 3)
(render-box bx b)
(let ((out (get-output-stream-string s)))
(is (search "Hello" out) "title text should appear")))))
(test box-without-border
"A box with border-style nil draws no border"
(multiple-value-bind (b s) (make-capturing-backend)
(let ((bx (make-box :border-style nil :bg :red :width 5 :height 3)))
(compute-layout (box-layout-node bx) 5 3)
(render-box bx b)
(let ((out (get-output-stream-string s)))
(is (search "41m" out) "background still renders")
(is-false (search "┌" out) "no top-left corner")))))
(test box-zero-size
"A box with any zero dimension renders nothing"
(multiple-value-bind (b s) (make-capturing-backend)
(let ((bx (make-box :border-style :single :width 0 :height 0)))
(compute-layout (box-layout-node bx) 0 0)
(render-box bx b)
(is (string= (get-output-stream-string s) "")
"zero-size box produces no output"))))
(test box-single-column
"A box with width 1 renders nothing (needs min 2 for border)"
(multiple-value-bind (b s) (make-capturing-backend)
(let ((bx (make-box :border-style :single :width 1 :height 5)))
(compute-layout (box-layout-node bx) 1 5)
(render-box bx b)
(is (string= (get-output-stream-string s) "")
"width=1 box renders nothing"))))
(test box-minimum-size
"A box with minimum non-zero size still renders"
(multiple-value-bind (b s) (make-capturing-backend)
(let ((bx (make-box :border-style :single :width 2 :height 2)))
(compute-layout (box-layout-node bx) 2 2)
(render-box bx b)
(let ((out (get-output-stream-string s)))
(is (search "┌" out) "2x2 box still has borders")))))
;; ── Text and Span Tests ───────────────────────────────────────
(test text-creates-with-defaults
"A text created with no arguments has reasonable defaults"
(let ((txt (make-text "")))
(is (typep txt 'text))
(is (typep (text-layout-node txt) 'layout-node))))
(test text-renders-content
"A text renders its content at position"
(multiple-value-bind (b s) (make-capturing-backend)
(let ((tx (make-text "Hello" :width 10 :height 1)))
(compute-layout (text-layout-node tx) 10 1)
(render-text tx b)
(let ((out (get-output-stream-string s)))
(is (search "Hello" out) "content should appear")))))
(test text-empty-string
"Empty text produces no output"
(multiple-value-bind (b s) (make-capturing-backend)
(let ((tx (make-text "" :width 10 :height 1)))
(compute-layout (text-layout-node tx) 10 1)
(render-text tx b)
(is (string= (get-output-stream-string s) "")
"empty string produces no output"))))
(test text-truncates-when-no-wrap
"Text with wrap-mode :none truncates at width"
(multiple-value-bind (b s) (make-capturing-backend)
(let ((tx (make-text "Hello World" :width 5 :height 1
:wrap-mode :none)))
(compute-layout (text-layout-node tx) 5 1)
(render-text tx b)
(let ((out (get-output-stream-string s)))
(is (search "Hello" out) "truncated to first 5 chars")))))
(test text-word-wraps
"Text with wrap-mode :word wraps at word boundaries"
(multiple-value-bind (b s) (make-capturing-backend)
(let ((tx (make-text "Hello brave new world" :width 6 :height 3)))
(compute-layout (text-layout-node tx) 6 3)
(render-text tx b)
(let ((out (get-output-stream-string s)))
(is (search "Hello" out) "first line")
(is (search "brave" out) "second line")
(is (search "new" out) "third line")))))
(test text-word-wrap-single-word
"A word longer than width is hard-broken at max-width"
(multiple-value-bind (b s) (make-capturing-backend)
(let ((tx (make-text "Hello" :width 3 :height 3)))
(compute-layout (text-layout-node tx) 3 3)
(render-text tx b)
(let ((out (get-output-stream-string s)))
(is (search "Hel" out) "first chunk is Hel")
(is (search "lo" out) "second chunk is lo")))))
(test span-creates-with-attributes
"A span has text and optional style attributes"
(let ((s (span "bold text" :bold t)))
(is (string= (span-text s) "bold text"))
(is-true (span-bold s))
(is-false (span-italic s))))
(test make-text-with-spans
"Text with spans stores span objects"
(let* ((sp (list (span "Hello" :bold t)
(span "World" :italic t)))
(tx (make-text "" :spans sp)))
(is (= (length (text-spans tx)) 2))
(is (string= (span-text (elt (text-spans tx) 0)) "Hello"))
(is-true (span-bold (elt (text-spans tx) 0)))))

54
src/components/box.lisp Normal file
View File

@@ -0,0 +1,54 @@
(in-package :cl-tui.box)
(defclass box (dirty-mixin)
((layout-node :initform (make-layout-node) :accessor box-layout-node
:initarg :layout-node)
(border-style :initform :single :initarg :border-style
:accessor box-border-style)
(title :initform nil :initarg :title :accessor box-title)
(title-align :initform :left :initarg :title-align
:accessor box-title-align)
(fg :initform nil :initarg :fg :accessor box-fg)
(bg :initform nil :initarg :bg :accessor box-bg)))
(defun make-box (&key (border-style :single) title
(title-align :left) fg bg
width height)
(make-instance 'box
:border-style border-style
:title title
:title-align title-align
:fg fg
:bg bg
:layout-node (make-layout-node
:width width
:height height
:direction :column)))
(defun render-box (box backend)
"Render BOX at its computed layout position using BACKEND."
(let ((ln (box-layout-node box))
(bs (box-border-style box))
(title (box-title box))
(fg (box-fg box))
(bg (box-bg box)))
(let ((x (layout-node-x ln))
(y (layout-node-y ln))
(w (layout-node-width ln))
(h (layout-node-height ln)))
(when (or (zerop w) (zerop h) (< w 2) (< h 2))
(return-from render-box (values)))
(when bg
(draw-rect backend x y w h :bg bg))
(when bs
(draw-border backend x y w h :style bs :fg fg :bg bg))
(when title
(let* ((content-w (- w 4))
(tx (+ x 2))
(ty (+ y (if bs 1 0)))
(ta (box-title-align box))
(display (subseq title 0 (min (length title) content-w))))
(case ta
(:center (draw-text backend (+ x (ceiling (- w (length display)) 2)) ty display fg bg))
(:right (draw-text backend (+ x (- w (length display) 2)) ty display fg bg))
(t (draw-text backend tx ty display fg bg))))))))

View File

@@ -0,0 +1,13 @@
(defpackage :cl-tui.container
(:use :cl :cl-tui.backend :cl-tui.box :cl-tui.layout :cl-tui.input)
(:export
#:scroll-box #:make-scroll-box
#:scroll-box-scroll-y #:scroll-box-scroll-x
#:scroll-box-children #:scroll-by
#:sticky-scroll-p
#:clamp-scroll
#:tab-bar #:make-tab-bar
#:tab-bar-active #:tab-bar-tabs
#:tab-bar-add #:tab-bar-next #:tab-bar-prev
#:tab-bar-select #:tab-bar-handle-key
#:render))

View File

@@ -0,0 +1,21 @@
;; Dirty tracking tests are in box-tests.lisp (same test suite)
(in-package :cl-tui-box-test)
(in-suite box-suite)
(test dirty-mixin-default-is-dirty
"A dirty-mixin starts as dirty"
(let ((c (make-instance 'dirty-mixin)))
(is-true (dirty-p c) "new component should be dirty")))
(test mark-clean-clears-dirty
"mark-clean sets dirty to nil"
(let ((c (make-instance 'dirty-mixin)))
(mark-clean c)
(is-false (dirty-p c) "after mark-clean, should not be dirty")))
(test mark-dirty-sets-dirty
"mark-dirty sets dirty to t"
(let ((c (make-instance 'dirty-mixin)))
(mark-clean c)
(mark-dirty c)
(is-true (dirty-p c) "after mark-dirty, should be dirty again")))

14
src/components/dirty.lisp Normal file
View File

@@ -0,0 +1,14 @@
(in-package :cl-tui.box)
;; ── Dirty Tracking ─────────────────────────────────────────────
(defclass dirty-mixin ()
((dirty :initform t :accessor dirty-p)))
(defgeneric mark-clean (component)
(:method ((c dirty-mixin))
(setf (dirty-p c) nil)))
(defgeneric mark-dirty (component)
(:method ((c dirty-mixin))
(setf (dirty-p c) t)))

View File

@@ -0,0 +1,34 @@
(defpackage :cl-tui.input
(:use :cl :cl-tui.backend :cl-tui.box :cl-tui.layout)
(:export
;; Key events
#:key-event #:make-key-event
#:key-event-p #:key-event-key #:key-event-ctrl
#:key-event-alt #:key-event-shift #:key-event-code
#:key-event-raw #:key-event-text
;; Mouse events
#:mouse-event #:make-mouse-event
#:mouse-event-p #:mouse-event-type #:mouse-event-button
#:mouse-event-x #:mouse-event-y
;; Terminal raw mode
#:save-terminal-state #:set-raw-mode #:restore-terminal-state
#:with-raw-terminal
;; Event reading
#:read-event
;; TextInput
#:text-input #:make-text-input
#:text-input-value #:text-input-cursor
#:text-input-placeholder #:text-input-max-length
#:text-input-on-submit #:text-input-layout-node
#:handle-text-input #:render-text-input
;; Textarea
#:textarea #:make-textarea
#:textarea-value #:textarea-cursor-row #:textarea-cursor-col
#:textarea-on-submit #:textarea-undo-stack #:textarea-redo-stack
#:textarea-layout-node
#:handle-textarea-input #:render-textarea
;; Keybindings
#:keymap #:make-keymap #:keymap-name #:keymap-bindings #:keymap-parent
#:*keymaps* #:*chord-timeout*
#:defkeymap #:dispatch-key-event #:key-match-p
#:component-keymap))

View File

@@ -0,0 +1,269 @@
(defpackage :cl-tui-input-test
(:use :cl :fiveam :cl-tui.backend :cl-tui.box :cl-tui.layout :cl-tui.input)
(:export #:run-tests))
(in-package :cl-tui-input-test)
(def-suite input-suite :description "Text input and keybinding tests")
(in-suite input-suite)
(defun run-tests ()
(let ((result (run 'input-suite)))
(fiveam:explain! result)
(uiop:quit 0)))
;; ── Key Event Tests ─────────────────────────────────────────────
(test key-event-construction
"A key-event can be created and queried."
(let ((e (make-key-event :key :a :ctrl t :alt nil)))
(is (eql (key-event-key e) :a))
(is-true (key-event-ctrl e))
(is-false (key-event-alt e))))
(test key-event-defaults
"Fields default to NIL/nil."
(let ((e (make-key-event :key :space)))
(is (eql (key-event-key e) :space))
(is-false (key-event-ctrl e))
(is-false (key-event-alt e))
(is-false (key-event-shift e))))
(test mouse-event-construction
"A mouse-event can be created and queried."
(let ((e (make-mouse-event :type :press :button :left :x 10 :y 5)))
(is (eql (mouse-event-type e) :press))
(is (eql (mouse-event-button e) :left))
(is (= (mouse-event-x e) 10))
(is (= (mouse-event-y e) 5))))
;; ── TextInput Tests ─────────────────────────────────────────────
(test text-input-empty
"A newly created text-input has empty value and cursor at 0."
(let ((in (make-text-input)))
(is (string= (text-input-value in) ""))
(is (= (text-input-cursor in) 0))))
(test text-input-insert-char
"Inserting a character appends and moves cursor."
(let ((in (make-text-input)))
(handle-text-input in (make-key-event :key :a :code (char-code #\a)))
(is (string= (text-input-value in) "a"))
(is (= (text-input-cursor in) 1))))
(test text-input-insert-multiple
"Inserting multiple characters works left to right."
(let ((in (make-text-input)))
(handle-text-input in (make-key-event :key :h :code (char-code #\h)))
(handle-text-input in (make-key-event :key :e :code (char-code #\e)))
(handle-text-input in (make-key-event :key :l :code (char-code #\l)))
(handle-text-input in (make-key-event :key :l :code (char-code #\l)))
(handle-text-input in (make-key-event :key :o :code (char-code #\o)))
(is (string= (text-input-value in) "hello"))
(is (= (text-input-cursor in) 5))))
(test text-input-backspace
"Backspace removes the character before the cursor."
(let ((in (make-text-input :value "ab" :cursor 2)))
(handle-text-input in (make-key-event :key :backspace))
(is (string= (text-input-value in) "a"))
(is (= (text-input-cursor in) 1))))
(test text-input-backspace-at-start
"Backspace at position 0 does nothing."
(let ((in (make-text-input :value "ab" :cursor 0)))
(handle-text-input in (make-key-event :key :backspace))
(is (string= (text-input-value in) "ab"))
(is (= (text-input-cursor in) 0))))
(test text-input-delete
"Delete removes the character at the cursor."
(let ((in (make-text-input :value "abc" :cursor 1)))
(handle-text-input in (make-key-event :key :delete))
(is (string= (text-input-value in) "ac"))
(is (= (text-input-cursor in) 1))))
(test text-input-cursor-left-right
"Cursor moves left and right."
(let ((in (make-text-input :value "ab" :cursor 2)))
(handle-text-input in (make-key-event :key :left))
(is (= (text-input-cursor in) 1))
(handle-text-input in (make-key-event :key :right))
(is (= (text-input-cursor in) 2))))
(test text-input-cursor-bounds
"Cursor cannot move past start or end."
(let ((in (make-text-input :value "ab" :cursor 0)))
(handle-text-input in (make-key-event :key :left))
(is (= (text-input-cursor in) 0))
(setf (text-input-cursor in) 2)
(handle-text-input in (make-key-event :key :right))
(is (= (text-input-cursor in) 2))))
(test text-input-home-end
"Home moves to start, End moves to end."
(let ((in (make-text-input :value "hello" :cursor 3)))
(handle-text-input in (make-key-event :key :home))
(is (= (text-input-cursor in) 0))
(handle-text-input in (make-key-event :key :end))
(is (= (text-input-cursor in) 5))))
(test text-input-max-length
"Max-length prevents inserting beyond the limit."
(let ((in (make-text-input :max-length 3)))
(handle-text-input in (make-key-event :key :a :code (char-code #\a)))
(handle-text-input in (make-key-event :key :b :code (char-code #\b)))
(handle-text-input in (make-key-event :key :c :code (char-code #\c)))
(handle-text-input in (make-key-event :key :d :code (char-code #\d)))
(is (string= (text-input-value in) "abc"))))
(test text-input-placeholder
"Placeholder is stored but does not affect value."
(let ((in (make-text-input :placeholder "Type here...")))
(is (string= (text-input-placeholder in) "Type here..."))
(is (string= (text-input-value in) ""))))
(test text-input-on-submit
"On-submit callback fires on Enter."
(let ((result (list nil)))
(let ((in (make-text-input :value "hello"
:on-submit (lambda (v) (setf (car result) v)))))
(handle-text-input in (make-key-event :key :enter))
(is (string= (car result) "hello")))))
(test text-input-ctrl-a-e
"Ctrl+A moves to home, Ctrl+E moves to end."
(let ((in (make-text-input :value "abc" :cursor 2)))
(handle-text-input in (make-key-event :key :a :ctrl t))
(is (= (text-input-cursor in) 0))
(handle-text-input in (make-key-event :key :e :ctrl t))
(is (= (text-input-cursor in) 3))))
(test text-input-insert-in-middle
"Inserting in the middle of text shifts rest right."
(let ((in (make-text-input :value "ab" :cursor 1)))
(handle-text-input in (make-key-event :key :x :code (char-code #\x)))
(is (string= (text-input-value in) "axb"))
(is (= (text-input-cursor in) 2))))
(test text-input-dirty-on-insert
"Inserting marks the widget dirty."
(let ((in (make-text-input)))
(mark-clean in)
(handle-text-input in (make-key-event :key :a :code (char-code #\a)))
(is-true (dirty-p in))))
;; ── Textarea Tests ──────────────────────────────────────────────
(test textarea-empty
"New textarea has empty value and cursor at (0,0)."
(let ((a (make-textarea)))
(is (string= (textarea-value a) ""))
(is (= (textarea-cursor-row a) 0))
(is (= (textarea-cursor-col a) 0))))
(test textarea-newline
"Enter inserts a newline."
(let ((a (make-textarea)))
(handle-textarea-input a (make-key-event :key :a :code (char-code #\a)))
(handle-textarea-input a (make-key-event :key :enter))
(handle-textarea-input a (make-key-event :key :b :code (char-code #\b)))
(is (string= (textarea-value a) "a
b"))))
(test textarea-cursor-up-down
"Cursor moves between lines maintaining column position."
(let ((a (make-textarea :value "abc
de
fghi")))
(setf (textarea-cursor-row a) 1)
(setf (textarea-cursor-col a) 1)
(handle-textarea-input a (make-key-event :key :up))
(is (= (textarea-cursor-row a) 0))
(is (= (textarea-cursor-col a) 1))
(handle-textarea-input a (make-key-event :key :down))
(is (= (textarea-cursor-row a) 1))
(is (= (textarea-cursor-col a) 1))))
(test textarea-cursor-up-down-bounds
"Cursor cannot move past first or last line."
(let ((a (make-textarea :value "a
b")))
(handle-textarea-input a (make-key-event :key :up))
(is (= (textarea-cursor-row a) 0))
(setf (textarea-cursor-row a) 1)
(handle-textarea-input a (make-key-event :key :down))
(is (= (textarea-cursor-row a) 1))))
(test textarea-backspace-joins-lines
"Backspace at start of a line joins with previous."
(let ((a (make-textarea :value "hello
world")))
(setf (textarea-cursor-row a) 1)
(setf (textarea-cursor-col a) 0)
(handle-textarea-input a (make-key-event :key :backspace))
(is (string= (textarea-value a) "helloworld"))))
(test textarea-undo
"Ctrl+Z undoes the last edit."
(let ((a (make-textarea)))
(handle-textarea-input a (make-key-event :key :a :code (char-code #\a)))
(handle-textarea-input a (make-key-event :key :z :ctrl t))
(is (string= (textarea-value a) ""))))
(test textarea-undo-redo
"Ctrl+Y redoes an undone edit."
(let ((a (make-textarea)))
(handle-textarea-input a (make-key-event :key :a :code (char-code #\a)))
(handle-textarea-input a (make-key-event :key :z :ctrl t))
(handle-textarea-input a (make-key-event :key :y :ctrl t))
(is (string= (textarea-value a) "a"))))
;; ── Keybinding Tests ────────────────────────────────────────────
(test keymap-simple
"A keymap dispatches to its handler on matching event."
(let ((called nil))
(setf (gethash :global *keymaps*)
(make-keymap :name :global
:bindings `((:ctrl+p . ,(lambda (e)
(declare (ignore e))
(setf called t))))))
(is-true (dispatch-key-event (make-key-event :key :p :ctrl t)))
(is-true called)))
(test keymap-no-match
"Non-matching event returns nil."
(let ((called nil))
(setf (gethash :global *keymaps*)
(make-keymap :name :global
:bindings `((:ctrl+p . ,(lambda (e)
(declare (ignore e))
(setf called t))))))
(is-false (dispatch-key-event (make-key-event :key :a)))
(is-false called)))
(test keymap-fallback
"Event not in local falls through to global."
(let ((global-called nil))
(setf (gethash :global *keymaps*)
(make-keymap :name :global
:bindings `((:ctrl+q . ,(lambda (e)
(declare (ignore e))
(setf global-called t))))))
(dispatch-key-event (make-key-event :key :q :ctrl t))
(is-true global-called)))
(test key-spec-simple
"Keyword key-spec matches key+ctrl."
(is-true (key-match-p :ctrl+p (make-key-event :key :p :ctrl t)))
(is-false (key-match-p :ctrl+p (make-key-event :key :a :ctrl t)))
(is-false (key-match-p :ctrl+p (make-key-event :key :p))))
(test defkeymap-macro
"defkeymap macro registers a keymap."
(let ((called nil))
(eval `(defkeymap :global
(:ctrl+q ,(lambda (e) (declare (ignore e)) (setf called t)))))
(dispatch-key-event (make-key-event :key :q :ctrl t))
(is-true called)))

307
src/components/input.lisp Normal file
View File

@@ -0,0 +1,307 @@
(in-package #:cl-tui.input)
;;; ---------------------------------------------------------------------------
;;; Utility: split-string (avoids external dependency)
;;; ---------------------------------------------------------------------------
(defun %split-string (string separator)
"Split STRING at each occurrence of SEPARATOR. Returns list of strings."
(loop with start = 0
for pos = (position separator string :start start)
collect (subseq string start pos)
while pos
do (setf start (1+ pos))))
;;; ---------------------------------------------------------------------------
;;; Global variables for rendering pipeline (set by application)
;;; ---------------------------------------------------------------------------
(defvar *current-backend* nil
"The active backend used for rendering.")
(defvar *current-theme* nil
"The active theme used for semantic color resolution.")
;;; ---------------------------------------------------------------------------
;;; Key event struct
;;; ---------------------------------------------------------------------------
(defstruct key-event
(key nil :type (or keyword null))
(ctrl nil :type boolean)
(alt nil :type boolean)
(shift nil :type boolean)
(code nil :type (or fixnum null))
(raw nil :type (or string null))
(text nil :type (or string null)))
;;; ---------------------------------------------------------------------------
;;; Mouse event struct
;;; ---------------------------------------------------------------------------
(defstruct mouse-event
(type nil :type (or keyword null))
(button nil :type (or keyword nil))
(x 0 :type fixnum)
(y 0 :type fixnum)
(raw nil :type (or string null)))
;;; ---------------------------------------------------------------------------
;;; Terminal raw mode
;;; ---------------------------------------------------------------------------
(defun save-terminal-state ()
(sb-posix:tcgetattr 0))
(defun make-raw-termios (termios)
(flet ((clear-flag (flags mask)
(logand flags (lognot mask))))
(setf (sb-posix:termios-iflag termios)
(clear-flag (sb-posix:termios-iflag termios)
(logior sb-posix:brkint sb-posix:ignpar
sb-posix:istrip sb-posix:inlcr
sb-posix:igncr sb-posix:icrnl
sb-posix:ixon)))
(setf (sb-posix:termios-oflag termios)
(clear-flag (sb-posix:termios-oflag termios)
sb-posix:opost))
(setf (sb-posix:termios-lflag termios)
(clear-flag (sb-posix:termios-lflag termios)
(logior sb-posix:icanon sb-posix:echo
sb-posix:isig sb-posix:iexten)))
(setf (sb-posix:termios-cc termios sb-posix:vmin) 1)
(setf (sb-posix:termios-cc termios sb-posix:vtime) 0)
termios))
(defun set-raw-mode ()
(let ((raw (make-raw-termios (save-terminal-state))))
(sb-posix:tcsetattr 0 sb-posix:tcsanow raw)
raw))
(defun restore-terminal-state (termios)
(sb-posix:tcsetattr 0 sb-posix:tcsanow termios))
(defmacro with-raw-terminal (&body body)
(let ((saved (gensym "SAVED")))
`(let ((,saved (save-terminal-state)))
(set-raw-mode)
(unwind-protect
(progn ,@body)
(restore-terminal-state ,saved)))))
;;; ---------------------------------------------------------------------------
;;; Low-level byte reading
;;; ---------------------------------------------------------------------------
(defun read-raw-byte (&key timeout)
(if timeout
(let ((deadline (+ (get-universal-time) timeout)))
(loop while (< (get-universal-time) deadline)
do (handler-case
(let ((buf (make-array 1 :element-type '(unsigned-byte 8))))
(let ((n (sb-posix:read 0 buf 1)))
(when (plusp n)
(return-from read-raw-byte (aref buf 0)))))
(sb-posix:syscall-error ()
(return-from read-raw-byte nil)))
(sleep 0.01))
nil)
(let ((buf (make-array 1 :element-type '(unsigned-byte 8))))
(multiple-value-bind (n err)
(ignore-errors (sb-posix:read 0 buf 1))
(if (and (integerp n) (plusp n))
(aref buf 0)
(progn
(when err (format *error-output* "read error: ~A~%" err))
nil))))))
;;; ---------------------------------------------------------------------------
;;; CSI parameter parser
;;; ---------------------------------------------------------------------------
(defun parse-csi-params ()
(let ((params '())
(raw (make-array 0 :element-type '(unsigned-byte 8)
:fill-pointer 0 :adjustable t))
(current 0))
(loop
(let ((b (read-raw-byte)))
(unless b (return (values nil nil nil)))
(vector-push-extend b raw)
(cond
((and (>= b #x30) (<= b #x3f))
(if (char= (code-char b) #\;)
(progn (push current params) (setf current 0))
(setf current (+ (* current 10) (- b #x30)))))
((and (>= b #x20) (<= b #x2f))
nil)
((and (>= b #x40) (<= b #x7e))
(push current params)
(return (values (nreverse params) b
(map 'string #'code-char raw))))
(t
(return (values nil nil nil))))))))
;;; ---------------------------------------------------------------------------
;;; Key event tables
;;; ---------------------------------------------------------------------------
(defparameter *csi-key-table*
'((#\A . :up) (#\B . :down) (#\C . :right) (#\D . :left)
(#\F . :end) (#\H . :home)
(#\P . :f1) (#\Q . :f2) (#\R . :f3) (#\S . :f4)
(#\Z . :tab)))
(defparameter *csi-tilde-table*
'((1 . :home) (2 . :insert) (3 . :delete)
(4 . :end) (5 . :page-up) (6 . :page-down)
(7 . :home) (8 . :end)
(11 . :f1) (12 . :f2) (13 . :f3) (14 . :f4)
(15 . :f5) (17 . :f6) (18 . :f7) (19 . :f8)
(20 . :f9) (21 . :f10) (23 . :f11) (24 . :f12)))
;;; ---------------------------------------------------------------------------
;;; SGR mouse parser
;;; ---------------------------------------------------------------------------
(defun parse-sgr-mouse (raw)
(let* ((start (position #\< raw))
(end (position #\m raw :from-end t))
(end2 (position #\M raw :from-end t))
(final (if end end end2))
(releasep (char= (char raw (1- (length raw))) #\m)))
(when (and start final (> final start))
(let* ((nums (mapcar #'parse-integer
(%split-string (subseq raw (1+ start) final) #\;)))
(code (first nums))
(x (or (second nums) 0))
(y (or (third nums) 0))
(button (logand code #x03))
(mod (logand code #x1c))
(motion (logand code #x20))
(wheel (logand code #x40)))
(declare (ignore mod))
(make-mouse-event
:type (cond (releasep :release)
(motion :drag)
(t :press))
:button (cond (wheel (if (zerop (logand code #x01))
:wheel-up :wheel-down))
((= button 0) :left)
((= button 1) :middle)
((= button 2) :right)
(t :none))
:x x :y y :raw raw)))))
;;; ---------------------------------------------------------------------------
;;; Escape sequence reader
;;; ---------------------------------------------------------------------------
(defun %read-escape-sequence ()
(let ((b (read-raw-byte)))
(unless b
(return-from %read-escape-sequence
(make-key-event :key :escape :raw (string #\Esc))))
(case b
;; SS3: ESC O X
(#x4f
(let ((b2 (read-raw-byte)))
(if b2
(let ((key (cdr (assoc (code-char b2)
'((#\P . :f1) (#\Q . :f2)
(#\R . :f3) (#\S . :f4))))))
(make-key-event :key (or key :unknown)
:raw (format nil "~C~C~C" #\Esc #\O (code-char b2))))
(make-key-event :key :escape :raw (string #\Esc)))))
;; CSI: ESC [ ...
(#x5b
(multiple-value-bind (params final-byte) (parse-csi-params)
(if (null final-byte)
(make-key-event :key :escape :raw (string #\Esc))
(if (and (char= (code-char final-byte) #\M)
(>= (length params) 3))
(let* ((p0 (first params)))
(if (zerop (logand p0 #x40))
(let* ((x (second params))
(y (third params))
(button (logand p0 #x03))
(motion (logand p0 #x20))
(wheel (logand p0 #x40)))
(make-mouse-event
:type (if motion :drag :press)
:button (cond (wheel (if (zerop (logand p0 #x01))
:wheel-up :wheel-down))
((= button 0) :left)
((= button 1) :middle)
((= button 2) :right)
(t :none))
:x x :y y :raw (format nil "~C[<~d;~d;~d~C" #\Esc p0 x y (code-char final-byte))))
(let* ((tilde-p (char= (code-char final-byte) #\~))
(param (or p0 0))
(key (if tilde-p
(cdr (assoc param *csi-tilde-table*))
(cdr (assoc (code-char final-byte) *csi-key-table*))))
(modifier (when (> (length params) 1) (second params))))
(let ((ctrl nil) (alt nil) (shift nil))
(when modifier
(setf shift (logtest modifier 1)
alt (logtest modifier 2)
ctrl (logtest modifier 4)))
(make-key-event :key (or key :unknown)
:ctrl ctrl :alt alt :shift shift
:raw (format nil "~C[~d~C" #\Esc param (code-char final-byte))))))
(let* ((tilde-p (char= (code-char final-byte) #\~))
(param (or (first params) 0))
(key (if tilde-p
(cdr (assoc param *csi-tilde-table*))
(cdr (assoc (code-char final-byte) *csi-key-table*))))
(modifier (when (> (length params) 1) (second params))))
(let ((ctrl nil) (alt nil) (shift nil))
(when modifier
(setf shift (logtest modifier 1)
alt (logtest modifier 2)
ctrl (logtest modifier 4)))
(make-key-event :key (or key :unknown)
:ctrl ctrl :alt alt :shift shift
:raw (format nil "~C[~d~C" #\Esc param (code-char final-byte))))))))))
;; ESC ESC
(#x1b
(make-key-event :key :escape :alt t :raw "\\e\\e"))
;; ESC + printable = Alt+key
(t
(let ((ch (code-char b)))
(if (and (>= b #x20) (<= b #x7e))
(make-key-event :key (intern (string (string-upcase ch)) :keyword)
:alt t
:raw (format nil "~C~C" #\Esc ch))
(make-key-event :key :unknown
:raw (format nil "~C~C" #\Esc ch))))))))
;;; ---------------------------------------------------------------------------
;;; Top-level event reader
;;; ---------------------------------------------------------------------------
(defun %read-event (&key timeout)
(let ((b (read-raw-byte :timeout timeout)))
(unless b
(return-from %read-event nil))
(case b
(#x1b
(%read-escape-sequence))
(#x09
(make-key-event :key :tab :code #x09))
(#x0a
(make-key-event :key :enter :code #x0a))
(#x0d
(make-key-event :key :enter :code #x0d))
((#x7f #x08)
(make-key-event :key :backspace :code b))
((and (>= b #x01) (<= b #x1a))
(let ((key (intern (string-upcase (string (code-char (+ b #x60)))) :keyword)))
(make-key-event :key key :ctrl t :code b)))
(#x1c (make-key-event :key :backslash :ctrl t :code b))
(#x1d (make-key-event :key :rbracket :ctrl t :code b))
(#x1e (make-key-event :key :caret :ctrl t :code b))
(#x1f (make-key-event :key :underscore :ctrl t :code b))
((and (>= b #x20) (<= b #x7e))
(let ((ch (code-char b)))
(make-key-event :key (intern (string (string-upcase ch)) :keyword)
:code b)))
(t
(make-key-event :key :unknown :code b :raw (string (code-char b)))))))
;;; ---------------------------------------------------------------------------
;;; Backend integration
;;; ---------------------------------------------------------------------------
(defmethod read-event ((b cl-tui.backend:backend) &key timeout)
(declare (ignore b))
(when (probe-file "/dev/stdin")
(%read-event :timeout timeout)))

View File

@@ -0,0 +1,77 @@
(in-package #:cl-tui.input)
;;; ---------------------------------------------------------------------------
;;; Key map struct
;;; ---------------------------------------------------------------------------
(defstruct keymap
(name nil :type (or keyword null))
(bindings nil :type list)
(parent nil :type (or keymap null)))
;;; ---------------------------------------------------------------------------
;;; Global keymap registry
;;; ---------------------------------------------------------------------------
(defparameter *keymaps* (make-hash-table :test #'equal))
(defparameter *chord-timeout* 0.5)
;;; ---------------------------------------------------------------------------
;;; Key spec matching
;;; ---------------------------------------------------------------------------
(defun key-match-p (spec event)
"T if SPEC matches EVENT. Spec is :ctrl+p (modifier+key keyword)
or (:ctrl+p) for single-spec in a list, or (:ctrl+x :ctrl+s) for chords."
(etypecase spec
;; Keyword like :ctrl+p, :alt+f, :enter, :space, :f1
(keyword
(let* ((name (string spec))
(plus (position #\+ name)))
(if plus
;; Modified key: :ctrl+p → mod-str="CTRL", key-str="P"
(let ((mod-str (subseq name 0 plus))
(key-str (subseq name (1+ plus))))
(and (eql (intern key-str :keyword)
(key-event-key event))
(cond
((string= mod-str "CTRL") (key-event-ctrl event))
((string= mod-str "ALT") (key-event-alt event))
((string= mod-str "SHIFT") (key-event-shift event))
(t t))))
;; Plain keyword: :enter, :escape, :f1, etc.
(eql spec (key-event-key event)))))
;; List: (:ctrl+p) or (:ctrl+x :ctrl+s)
(list
(when spec
(key-match-p (first spec) event)))))
;;; ---------------------------------------------------------------------------
;;; Dispatch
;;; ---------------------------------------------------------------------------
(defun dispatch-key-event (event &key component)
(labels ((try-keymap (km)
(when km
(loop for (spec . handler) in (keymap-bindings km)
thereis (when (key-match-p spec event)
(funcall handler event)
t))))
(find-keymap (name)
(gethash name *keymaps*)))
(or (and component
(let ((km (component-keymap component)))
(when km (try-keymap km))))
(try-keymap (find-keymap :local))
(try-keymap (find-keymap :global)))))
;;; ---------------------------------------------------------------------------
;;; defkeymap macro
;;; ---------------------------------------------------------------------------
(defmacro defkeymap (name &body bindings)
`(setf (gethash ',name *keymaps*)
(make-keymap :name ',name
:bindings (list ,@(loop for b in bindings
collect (if (consp (cdr b))
`(cons ',(car b) ,(cadr b))
`(cons ',(car b) ,(cdr b))))))))
;;; --- Component protocol integration ---
(defgeneric component-keymap (component)
(:method ((c t)) nil))

View File

@@ -0,0 +1,31 @@
(defpackage :cl-tui.box
(:use :cl :cl-tui.backend :cl-tui.layout)
(:export
;; Box
#:box #:make-box
#:box-layout-node
#:box-border-style #:box-title #:box-title-align
#:box-fg #:box-bg
#:render-box
;; Span
#:span
#:span-text #:span-bold #:span-italic #:span-underline
#:span-reverse #:span-dim #:span-fg #:span-bg
;; Text
#:text #:make-text
#:text-layout-node #:text-content #:text-spans
#:text-fg #:text-bg #:text-wrap-mode
#:render-text
;; Utilities (for tests)
#:word-wrap #:split-string
;; Dirty tracking
#:dirty-mixin #:dirty-p #:mark-clean #:mark-dirty
;; Rendering pipeline
#:render #:render-screen #:render-node
#:component-layout-node #:component-children #:component-parent
#:available-width #:available-height
#:propagate-dirty
;; Theme engine
#:theme #:make-theme #:theme-mode
#:theme-color #:load-preset #:define-preset))
(in-package :cl-tui.box)

View File

@@ -0,0 +1,48 @@
(in-package :cl-tui-box-test)
(in-suite box-suite)
(defun make-capturing-backend ()
(let* ((s (make-string-output-stream))
(b (make-modern-backend :output-stream s)))
(values b s)))
(test render-generic-dispatches-box
"render dispatches to render-box for box instances"
(multiple-value-bind (b s) (make-capturing-backend)
(let ((bx (make-box :border-style :single :width 10 :height 5)))
(compute-layout (box-layout-node bx) 10 5)
(render bx b)
(is (search "┌" (get-output-stream-string s)) "box renders border"))))
(test render-generic-dispatches-text
"render dispatches to render-text for text instances"
(multiple-value-bind (b s) (make-capturing-backend)
(let ((tx (make-text "Hello" :width 10 :height 1)))
(compute-layout (text-layout-node tx) 10 1)
(render tx b)
(is (search "Hello" (get-output-stream-string s)) "text renders content"))))
(test component-layout-node-works
"component-layout-node returns the right slot for each type"
(let ((bx (make-box)) (tx (make-text "")))
(is (typep (component-layout-node bx) 'layout-node))
(is (typep (component-layout-node tx) 'layout-node))))
(test component-children-returns-nil
"Leaf components have no children"
(let ((bx (make-box)) (tx (make-text "")))
(is (null (component-children bx)))
(is (null (component-children tx)))))
(test propagate-dirty-marks-component
"propagate-dirty marks the component dirty"
(let ((c (make-box)))
(mark-clean c)
(is-false (dirty-p c) "should be clean after mark-clean")
(propagate-dirty c)
(is-true (dirty-p c) "should be dirty after propagate-dirty")))
(test available-width-defaults
"available-width returns 0 for components without explicit width"
(let ((c (make-box)))
(is (= (available-width c) 0))))

View File

@@ -0,0 +1,66 @@
(in-package :cl-tui.box)
;; ── Component Protocol ────────────────────────────────────────
(defgeneric component-layout-node (component)
(:documentation "Return the layout-node for COMPONENT.")
(:method ((bx box)) (box-layout-node bx))
(:method ((tx text)) (text-layout-node tx)))
(defgeneric component-children (component)
(:documentation "Return the children of COMPONENT, or nil.")
(:method ((c t)) nil))
(defgeneric component-parent (component)
(:documentation "Return the parent of COMPONENT, or nil.")
(:method ((c t)) nil))
;; ── Rendering Pipeline ────────────────────────────────────────
(defgeneric render (component backend)
(:documentation "Render COMPONENT at its computed position using BACKEND.")
(:method ((c t) backend)
(declare (ignore backend))
(values)))
(defmethod render ((bx box) backend)
(render-box bx backend))
(defmethod render ((tx text) backend)
(render-text tx backend))
(defun render-screen (root backend)
"Render the component tree ROOT using BACKEND.
Computes layout for dirty branches, calls render on each component,
and wraps output in synchronized updates."
(let ((w (available-width root))
(h (available-height root)))
(begin-sync backend)
(render-node root backend w h)
(end-sync backend)))
(defun render-node (node backend w h)
"Render a component NODE and its children."
(compute-layout (component-layout-node node) w h)
(render node backend)
(dolist (child (component-children node))
(render-node child backend w h)))
(defun available-width (component)
"Return the available width for COMPONENT (or 80 as default)."
(let ((ln (component-layout-node component)))
(if ln (layout-node-width ln) 80)))
(defun available-height (component)
"Return the available height for COMPONENT (or 24 as default)."
(let ((ln (component-layout-node component)))
(if ln (layout-node-height ln) 24)))
;; ── Dirty Propagation ─────────────────────────────────────────
(defun propagate-dirty (component)
"Mark COMPONENT and all ancestors dirty."
(mark-dirty component)
(let ((parent (component-parent component)))
(when parent
(propagate-dirty parent))))

View File

@@ -0,0 +1,81 @@
(in-package #:cl-tui.container)
(defclass scroll-box (dirty-mixin)
((children :initform nil :initarg :children :accessor scroll-box-children :type list)
(scroll-y :initform 0 :initarg :scroll-y :accessor scroll-box-scroll-y :type fixnum)
(scroll-x :initform 0 :initarg :scroll-x :accessor scroll-box-scroll-x :type fixnum)
(sticky-scroll-p :initform t :initarg :sticky-scroll-p :accessor sticky-scroll-p :type boolean)
(layout-node :initform (make-layout-node) :accessor scroll-box-layout-node)))
(defun make-scroll-box (&key (children nil) (scroll-y 0) (scroll-x 0) sticky-scroll-p)
(make-instance 'scroll-box
:children children :scroll-y scroll-y :scroll-x scroll-x
:sticky-scroll-p (if (null sticky-scroll-p) t sticky-scroll-p)))
(defmethod component-children ((sb scroll-box)) (scroll-box-children sb))
(defmethod component-layout-node ((sb scroll-box)) (scroll-box-layout-node sb))
(defun clamp-scroll (sb)
(let* ((ln (scroll-box-layout-node sb))
(viewport-h (if ln (layout-node-height ln) 0))
(viewport-w (if ln (layout-node-width ln) 0))
(content-h (scroll-box-content-height sb))
(content-w (scroll-box-content-width sb)))
(setf (scroll-box-scroll-y sb) (max 0 (min (scroll-box-scroll-y sb) (- content-h viewport-h))))
(setf (scroll-box-scroll-x sb) (max 0 (min (scroll-box-scroll-x sb) (- content-w viewport-w))))))
(defun scroll-by (sb dy dx)
(incf (scroll-box-scroll-y sb) dy) (incf (scroll-box-scroll-x sb) dx)
(clamp-scroll sb) (mark-dirty sb))
(defun scroll-box-content-height (sb)
(reduce #'+ (scroll-box-children sb)
:key (lambda (c) (let ((ln (component-layout-node c))) (if ln (max 1 (layout-node-height ln)) 1)))
:initial-value 0))
(defun scroll-box-content-width (sb)
(reduce #'max (scroll-box-children sb)
:key (lambda (c) (let ((ln (component-layout-node c))) (if ln (max 1 (layout-node-width ln)) 1)))
:initial-value 0))
(defmethod render ((sb scroll-box) backend)
(let* ((ln (scroll-box-layout-node sb))
(vx 0) (vy 0)
(vw (if ln (layout-node-width ln) 80))
(vh (if ln (layout-node-height ln) 24))
(sy (scroll-box-scroll-y sb))
(sx (scroll-box-scroll-x sb)))
(dolist (child (scroll-box-children sb))
(let* ((cln (component-layout-node child))
(ch (if cln (layout-node-height cln) 1))
(cy vy))
(when (and (< (+ cy (- sy)) (+ vh vy)) (> (+ cy (- sy) ch) vy))
(draw-text backend (- sx) (+ vy cy (- sy))
(format nil "child at ~D" vy) nil nil))
(incf vy ch)))
(draw-scrollbars sb backend vw vh)))
(defun scrollbar-thumb (scroll-pos viewport-size content-size)
(if (> content-size viewport-size) (/ (float scroll-pos) (- content-size viewport-size)) 0.0))
(defun draw-scrollbars (sb backend viewport-w viewport-h)
(let* ((content-h (scroll-box-content-height sb)) (content-w (scroll-box-content-width sb))
(sy (scroll-box-scroll-y sb)) (sx (scroll-box-scroll-x sb)))
(when (> content-h viewport-h)
(let* ((thumb (scrollbar-thumb sy viewport-h content-h))
(thumb-pos (round (* thumb viewport-h))))
(draw-rect backend (1- viewport-w) 0 1 viewport-h :bg :background-element)
(draw-text backend (1- viewport-w) thumb-pos "█" nil nil)))
(when (> content-w viewport-w)
(let* ((thumb (scrollbar-thumb sx viewport-w content-w))
(thumb-pos (round (* thumb viewport-w))))
(draw-rect backend 0 (1- viewport-h) viewport-w 1 :bg :background-element)
(draw-text backend thumb-pos (1- viewport-h) "█" nil nil)))))
(defun update-sticky-scroll (sb)
(when (sticky-scroll-p sb)
(let* ((content-h (scroll-box-content-height sb))
(ln (scroll-box-layout-node sb))
(viewport-h (if ln (layout-node-height ln) 24)))
(when (>= (scroll-box-scroll-y sb) (- content-h viewport-h 1))
(setf (scroll-box-scroll-y sb) (max 0 (- content-h viewport-h)))))))

View File

@@ -0,0 +1,51 @@
(in-package #:cl-tui.container)
(defclass tab-bar (dirty-mixin)
((tabs :initform nil :initarg :tabs :accessor tab-bar-tabs :type list)
(active :initform nil :initarg :active :accessor tab-bar-active)
(layout-node :initform (make-layout-node) :accessor tab-bar-layout-node)
(focusable :initform t :accessor tab-bar-focusable)))
(defun make-tab-bar (&key tabs active)
(make-instance 'tab-bar :tabs (or tabs nil) :active active))
(defun tab-bar-add (tb id title)
(setf (tab-bar-tabs tb) (nconc (tab-bar-tabs tb) (list (list :id id :title title))))
(unless (tab-bar-active tb) (setf (tab-bar-active tb) id)) id)
(defmethod component-layout-node ((tb tab-bar)) (tab-bar-layout-node tb))
(defun tab-bar-next (tb)
(let* ((tabs (tab-bar-tabs tb)) (current (tab-bar-active tb))
(ids (mapcar (lambda (tab) (getf tab :id)) tabs))
(pos (position current ids)))
(when pos (let ((next (nth (mod (1+ pos) (length ids)) ids)))
(setf (tab-bar-active tb) next) (mark-dirty tb)))))
(defun tab-bar-prev (tb)
(let* ((tabs (tab-bar-tabs tb)) (current (tab-bar-active tb))
(ids (mapcar (lambda (tab) (getf tab :id)) tabs))
(pos (position current ids)))
(when pos (let ((prev (nth (mod (1- pos) (length ids)) ids)))
(setf (tab-bar-active tb) prev) (mark-dirty tb)))))
(defun tab-bar-select (tb id) (setf (tab-bar-active tb) id) (mark-dirty tb))
(defun tab-bar-handle-key (tb event)
(case (key-event-key event) (:left (tab-bar-prev tb) t) (:right (tab-bar-next tb) t) (t nil)))
(defmethod render ((tb tab-bar) backend)
(let* ((ln (tab-bar-layout-node tb)) (y 0)
(w (if ln (layout-node-width ln) 80))
(active-id (tab-bar-active tb)) (tabs (tab-bar-tabs tb)) (x-pos 0))
(dolist (tab tabs)
(let* ((id (getf tab :id)) (title (getf tab :title))
(label (format nil " ~A " title)) (label-len (length label))
(is-active (eql id active-id))
(fg (if is-active :accent :text-muted))
(bg (if is-active :background-element nil)))
(when (>= (+ x-pos label-len 2) w)
(draw-text backend x-pos y "..." :text-muted nil) (return))
(draw-text backend x-pos y label fg bg)
(incf x-pos (+ label-len 2)))))
(values))

View File

@@ -0,0 +1,163 @@
(in-package #:cl-tui.input)
;;; ---------------------------------------------------------------------------
;;; TextInput class
;;; ---------------------------------------------------------------------------
(defclass text-input (dirty-mixin)
((value :initform "" :initarg :value :accessor text-input-value
:type string)
(cursor :initform 0 :initarg :cursor :accessor text-input-cursor
:type fixnum)
(placeholder :initform "" :initarg :placeholder
:accessor text-input-placeholder :type string)
(max-length :initform nil :initarg :max-length
:accessor text-input-max-length)
(on-submit :initform nil :initarg :on-submit
:accessor text-input-on-submit)
(layout-node :initform (make-layout-node) :accessor text-input-layout-node)
(focusable :initform t :accessor text-input-focusable)))
(defun make-text-input (&key value cursor placeholder max-length on-submit)
(make-instance 'text-input
:value (or value "")
:cursor (or cursor 0)
:placeholder (or placeholder "")
:max-length max-length
:on-submit on-submit))
;;; ---------------------------------------------------------------------------
;;; Editing operations
;;; ---------------------------------------------------------------------------
(defun text-input-insert (input char)
"Insert CHAR at the cursor position in INPUT."
(let* ((val (text-input-value input))
(pos (text-input-cursor input))
(max (text-input-max-length input)))
(when (and max (>= (length val) max))
(return-from text-input-insert))
(setf (text-input-value input)
(concatenate 'string
(subseq val 0 pos)
(string char)
(subseq val pos)))
(incf (text-input-cursor input))
(mark-dirty input)))
(defun text-input-backspace (input)
"Delete character before cursor."
(let* ((val (text-input-value input))
(pos (text-input-cursor input)))
(when (zerop pos) (return-from text-input-backspace))
(setf (text-input-value input)
(concatenate 'string
(subseq val 0 (1- pos))
(subseq val pos)))
(decf (text-input-cursor input))
(mark-dirty input)))
(defun text-input-delete (input)
"Delete character at cursor."
(let* ((val (text-input-value input))
(pos (text-input-cursor input)))
(when (>= pos (length val))
(return-from text-input-delete))
(setf (text-input-value input)
(concatenate 'string
(subseq val 0 pos)
(subseq val (1+ pos))))
(mark-dirty input)))
;;; ---------------------------------------------------------------------------
;;; Cursor movement
;;; ---------------------------------------------------------------------------
(defun text-input-move-left (input)
(when (plusp (text-input-cursor input))
(decf (text-input-cursor input))))
(defun text-input-move-right (input)
(when (< (text-input-cursor input) (length (text-input-value input)))
(incf (text-input-cursor input))))
(defun text-input-move-home (input)
(setf (text-input-cursor input) 0))
(defun text-input-move-end (input)
(setf (text-input-cursor input) (length (text-input-value input))))
(defun text-input-delete-word-before (input)
"Delete from cursor back to previous word boundary."
(let* ((val (text-input-value input))
(pos (text-input-cursor input)))
(when (zerop pos)
(return-from text-input-delete-word-before))
(let* ((start (or (position-if (lambda (c) (not (char= c #\Space)))
val :end pos :from-end t)
0))
(word-start (or (and (plusp start)
(position #\Space val :end start :from-end t))
0))
(delete-start (if (and (zerop word-start)
(or (char/= (char val 0) #\Space)
(zerop start)))
0
(if (zerop start)
(1+ word-start)
(1+ (or (position #\Space val :end start :from-end t)
0))))))
(setf (text-input-value input)
(concatenate 'string
(subseq val 0 delete-start)
(subseq val pos)))
(setf (text-input-cursor input) delete-start)
(mark-dirty input))))
;;; ---------------------------------------------------------------------------
;;; Key event handler
;;; ---------------------------------------------------------------------------
(defun handle-text-input (input event)
"Process a key-event on a text-input widget."
(cond
((key-event-ctrl event)
(case (key-event-key event)
(:a (text-input-move-home input))
(:e (text-input-move-end input))
(:w (text-input-delete-word-before input))
(:u (progn
(setf (text-input-value input)
(subseq (text-input-value input)
(text-input-cursor input)))
(setf (text-input-cursor input) 0)
(mark-dirty input)))
(:k (progn
(setf (text-input-value input)
(subseq (text-input-value input) 0
(text-input-cursor input)))
(mark-dirty input)))
(t nil)))
(t
(case (key-event-key event)
(:left (text-input-move-left input))
(:right (text-input-move-right input))
(:home (text-input-move-home input))
(:end (text-input-move-end input))
(:backspace (text-input-backspace input))
(:delete (text-input-delete input))
(:enter (let ((cb (text-input-on-submit input)))
(when cb (funcall cb (text-input-value input)))))
(:tab nil)
(:escape nil)
;; Insert printable characters
(otherwise
(let ((ch (code-char (key-event-code event))))
(when (and ch (graphic-char-p ch))
(text-input-insert input ch))))))))
;;; ---------------------------------------------------------------------------
;;; Rendering (stub — proper rendering uses theme + backend)
;;; ---------------------------------------------------------------------------
(defmethod render ((in text-input) (backend t))
"Render a text-input widget. Full rendering requires *current-backend*,
*current-theme*, and the rendering pipeline. This is a no-op stub for
unit testing the widget logic."
(declare (ignore in backend))
(values))

106
src/components/text.lisp Normal file
View File

@@ -0,0 +1,106 @@
(in-package :cl-tui.box)
;; ── Text Renderable ────────────────────────────────────────────
(defclass span ()
((text :initarg :text :accessor span-text)
(bold :initform nil :initarg :bold :accessor span-bold)
(italic :initform nil :initarg :italic :accessor span-italic)
(underline :initform nil :initarg :underline :accessor span-underline)
(reverse :initform nil :initarg :reverse :accessor span-reverse)
(dim :initform nil :initarg :dim :accessor span-dim)
(fg :initform nil :initarg :fg :accessor span-fg)
(bg :initform nil :initarg :bg :accessor span-bg)))
(defun span (text &key bold italic underline reverse dim fg bg)
(make-instance 'span
:text text :bold bold :italic italic
:underline underline :reverse reverse :dim dim
:fg fg :bg bg))
(defclass text (dirty-mixin)
((layout-node :initform (make-layout-node) :accessor text-layout-node
:initarg :layout-node)
(content :initform "" :initarg :content :accessor text-content)
(spans :initform nil :initarg :spans :accessor text-spans)
(fg :initform nil :initarg :fg :accessor text-fg)
(bg :initform nil :initarg :bg :accessor text-bg)
(wrap-mode :initform :word :initarg :wrap-mode :accessor text-wrap-mode)))
(defun make-text (content &key fg bg wrap-mode width height spans)
(make-instance 'text
:content content
:fg fg :bg bg
:wrap-mode (or wrap-mode :word)
:spans spans
:layout-node (make-layout-node :direction :column
:width width :height height)))
(defun render-text (text-object backend)
"Render TEXT-OBJECT at its computed layout position using BACKEND."
(let ((ln (text-layout-node text-object))
(content (text-content text-object))
(fg (text-fg text-object))
(bg (text-bg text-object))
(wrap (text-wrap-mode text-object))
(spans (text-spans text-object)))
(declare (ignore spans))
(let ((x (layout-node-x ln))
(y (layout-node-y ln))
(w (layout-node-width ln))
(h (layout-node-height ln)))
(when (or (zerop (length content)) (zerop w) (zerop h))
(return-from render-text (values)))
(if (eql wrap :none)
(let ((display (subseq content 0 (min (length content) w))))
(draw-text backend x y display fg bg))
(let ((lines (word-wrap content w))
(max-lines h))
(loop for line in lines
for row from 0 below max-lines
do (draw-text backend x (+ y row) line fg bg)))))))
(defun word-wrap (text max-width)
"Split TEXT into lines, each <= MAX-WIDTH chars.
Breaks at word boundaries. Words exceeding MAX-WIDTH are hard-broken."
(if (or (zerop max-width) (zerop (length text)))
(list "")
(let ((words (split-string text)) (lines nil) (current nil) (current-len 0))
(dolist (word words)
(let ((wl (length word)))
(cond ((<= wl max-width)
(if (and current (<= (+ current-len 1 wl) max-width))
(push word current)
(progn
(when current
(push (format nil "~{~A~^ ~}" (nreverse current)) lines))
(setf current (list word))
(setf current-len wl))))
(t
(when current
(push (format nil "~{~A~^ ~}" (nreverse current)) lines)
(setf current nil)
(setf current-len 0))
(loop for i from 0 below wl by max-width
do (push (subseq word i (min (+ i max-width) wl)) lines))))))
(when current
(push (format nil "~{~A~^ ~}" (nreverse current)) lines))
(or (nreverse lines) (list "")))))
(defun split-string (string)
"Split STRING into words separated by whitespace."
(loop with words = nil
with start = 0
with len = (length string)
while (< start len)
do (let ((ws-start (position-if (lambda (c) (find c '(#\Space #\Tab #\Newline)))
string :start start)))
(if ws-start
(progn
(when (> ws-start start)
(push (subseq string start ws-start) words))
(setf start (1+ ws-start)))
(progn
(push (subseq string start) words)
(setf start len))))
finally (return (nreverse words))))

View File

@@ -0,0 +1,258 @@
(in-package #:cl-tui.input)
;;; ---------------------------------------------------------------------------
;;; Utility: split string (local copy for dependency-free operation)
;;; ---------------------------------------------------------------------------
(defun %split-string (string separator)
"Split STRING at each occurrence of SEPARATOR. Returns list of strings."
(loop with start = 0
for pos = (position separator string :start start)
collect (subseq string start pos)
while pos
do (setf start (1+ pos))))
;;; ---------------------------------------------------------------------------
;;; Textarea class
;;; ---------------------------------------------------------------------------
(defclass textarea (dirty-mixin)
((value :initform "" :initarg :value :accessor textarea-value :type string)
(cursor-row :initform 0 :accessor textarea-cursor-row :type fixnum)
(cursor-col :initform 0 :accessor textarea-cursor-col :type fixnum)
(selection-start :initform nil :accessor textarea-selection-start)
(undo-stack :initform (make-array 100 :fill-pointer 0)
:accessor textarea-undo-stack)
(redo-stack :initform (make-array 100 :fill-pointer 0)
:accessor textarea-redo-stack)
(on-submit :initform nil :initarg :on-submit :accessor textarea-on-submit)
(layout-node :initform (make-layout-node) :accessor textarea-layout-node)
(focusable :initform t :accessor textarea-focusable)))
(defun make-textarea (&key value on-submit)
(make-instance 'textarea
:value (or value "")
:on-submit on-submit))
;;; ---------------------------------------------------------------------------
;;; Line helpers
;;; ---------------------------------------------------------------------------
(defun textarea-lines (ta)
"Split value into lines."
(%split-string (textarea-value ta) #\Newline))
(defun textarea-line-count (ta)
"Number of lines in value."
(length (textarea-lines ta)))
(defun textarea-ensure-cursor (ta)
"Clamp cursor to valid range."
(let ((lines (textarea-lines ta)))
(setf (textarea-cursor-row ta)
(max 0 (min (textarea-cursor-row ta) (1- (length lines)))))
(let ((line-len (length (nth (textarea-cursor-row ta) lines))))
(setf (textarea-cursor-col ta)
(max 0 (min (textarea-cursor-col ta) line-len))))))
;;; ---------------------------------------------------------------------------
;;; Utility: join strings with newline
;;; ---------------------------------------------------------------------------
(defun %join-lines (lines)
"Join a sequence of strings with newlines."
(with-output-to-string (s)
(loop for line across (if (listp lines) (coerce lines 'vector) lines)
for first = t then nil
do (unless first (write-char #\Newline s))
(write-string line s))))
;;; ---------------------------------------------------------------------------
;;; Text manipulation
;;; ---------------------------------------------------------------------------
(defun textarea-insert-char (ta char)
"Insert CHAR at the cursor position."
(textarea-push-undo ta)
(let* ((lines (coerce (textarea-lines ta) 'vector))
(row (textarea-cursor-row ta))
(col (textarea-cursor-col ta)))
(if (< row (length lines))
(let* ((line (aref lines row))
(new-line (concatenate 'string
(subseq line 0 col)
(string char)
(subseq line col))))
(setf (aref lines row) new-line)
(setf (textarea-value ta)
(%join-lines lines))
(incf (textarea-cursor-col ta))
(mark-dirty ta))
(progn
(setf (textarea-value ta)
(concatenate 'string (textarea-value ta) (string char)))
(incf (textarea-cursor-col ta))
(mark-dirty ta)))))
(defun textarea-newline (ta)
"Insert a newline at the cursor."
(textarea-push-undo ta)
(let* ((lines (coerce (textarea-lines ta) 'vector))
(row (textarea-cursor-row ta))
(col (textarea-cursor-col ta)))
(if (< row (length lines))
(let* ((line (aref lines row))
(before (subseq line 0 col))
(after (subseq line col)))
(setf (aref lines row) before)
(let ((new-lines (concatenate 'vector
(subseq lines 0 (1+ row))
(vector after)
(subseq lines (1+ row)))))
(setf (textarea-value ta)
(%join-lines new-lines)))
(incf (textarea-cursor-row ta))
(setf (textarea-cursor-col ta) 0)
(mark-dirty ta))
(progn
(setf (textarea-value ta)
(concatenate 'string (textarea-value ta) (string #\Newline)))
(incf (textarea-cursor-row ta))
(setf (textarea-cursor-col ta) 0)
(mark-dirty ta)))))
(defun textarea-backspace (ta)
"Delete character before cursor."
(textarea-push-undo ta)
(let* ((lines (coerce (textarea-lines ta) 'vector))
(row (textarea-cursor-row ta))
(col (textarea-cursor-col ta)))
(cond
((and (zerop row) (zerop col))
nil) ;; nothing to delete
((zerop col)
;; Join with previous line
(let* ((prev (aref lines (1- row)))
(curr (aref lines row))
(new-pos (length prev)))
(setf (aref lines (1- row))
(concatenate 'string prev curr))
(let ((new-lines (concatenate 'vector
(subseq lines 0 row)
(subseq lines (1+ row)))))
(setf (textarea-value ta)
(%join-lines new-lines)))
(decf (textarea-cursor-row ta))
(setf (textarea-cursor-col ta) new-pos)
(mark-dirty ta)))
(t
(let* ((line (aref lines row))
(new-line (concatenate 'string
(subseq line 0 (1- col))
(subseq line col))))
(setf (aref lines row) new-line)
(setf (textarea-value ta)
(%join-lines lines))
(decf (textarea-cursor-col ta))
(mark-dirty ta))))))
;;; ---------------------------------------------------------------------------
;;; Cursor movement
;;; ---------------------------------------------------------------------------
(defun textarea-move-up (ta)
(decf (textarea-cursor-row ta))
(textarea-ensure-cursor ta))
(defun textarea-move-down (ta)
(incf (textarea-cursor-row ta))
(textarea-ensure-cursor ta))
;;; ---------------------------------------------------------------------------
;;; Undo/redo
;;; ---------------------------------------------------------------------------
(defun textarea-push-undo (ta)
"Save current value on undo stack."
(let ((stack (textarea-undo-stack ta)))
(when (>= (length stack) (array-total-size stack))
(setf (textarea-undo-stack ta)
(make-array 100 :fill-pointer 0)))
(vector-push (textarea-value ta) stack)
;; Clear redo stack on new action
(setf (fill-pointer (textarea-redo-stack ta)) 0)))
(defun textarea-undo (ta)
(let ((stack (textarea-undo-stack ta)))
(when (plusp (length stack))
(let ((prev (vector-pop stack)))
(vector-push (textarea-value ta) (textarea-redo-stack ta))
(setf (textarea-value ta) prev)
(textarea-ensure-cursor ta)
(mark-dirty ta)))))
(defun textarea-redo (ta)
(let ((stack (textarea-redo-stack ta)))
(when (plusp (length stack))
(let ((next (vector-pop stack)))
(vector-push (textarea-value ta) (textarea-undo-stack ta))
(setf (textarea-value ta) next)
(textarea-ensure-cursor ta)
(mark-dirty ta)))))
;;; ---------------------------------------------------------------------------
;;; Key event handler
;;; ---------------------------------------------------------------------------
(defun handle-textarea-input (ta event)
"Process a key-event on a textarea widget."
(cond
((key-event-ctrl event)
(case (key-event-key event)
(:z (textarea-undo ta))
(:y (textarea-redo ta))
;; Ctrl+A/E: home/end
(:a (setf (textarea-cursor-col ta) 0))
(:e (let ((lines (textarea-lines ta)))
(when (< (textarea-cursor-row ta) (length lines))
(setf (textarea-cursor-col ta)
(length (nth (textarea-cursor-row ta) lines))))))
(t nil)))
(t
(case (key-event-key event)
(:left (decf (textarea-cursor-col ta))
(textarea-ensure-cursor ta))
(:right (incf (textarea-cursor-col ta))
(textarea-ensure-cursor ta))
(:up (textarea-move-up ta))
(:down (textarea-move-down ta))
(:home (setf (textarea-cursor-col ta) 0))
(:end (let ((lines (textarea-lines ta)))
(when (< (textarea-cursor-row ta) (length lines))
(setf (textarea-cursor-col ta)
(length (nth (textarea-cursor-row ta) lines))))))
(:enter (let ((cb (textarea-on-submit ta)))
(if cb
(funcall cb (textarea-value ta))
(textarea-newline ta))))
(:backspace (textarea-backspace ta))
(:delete (let* ((lines (textarea-lines ta))
(row (textarea-cursor-row ta))
(col (textarea-cursor-col ta))
(line (nth row lines)))
(when (and line (< col (length line)))
(textarea-push-undo ta)
(setf (nth row lines)
(concatenate 'string
(subseq line 0 col)
(subseq line (1+ col))))
(setf (textarea-value ta)
(%join-lines lines))
(mark-dirty ta))))
;; Character insertion
(otherwise
(let ((ch (code-char (key-event-code event))))
(when (and ch (graphic-char-p ch))
(textarea-insert-char ta ch))))))))
;;; ---------------------------------------------------------------------------
;;; Rendering (stub — proper rendering uses theme + backend)
;;; ---------------------------------------------------------------------------
(defmethod render ((ta textarea) (backend t))
"Render a textarea widget. Full rendering requires *current-backend*,
*current-theme*, and the rendering pipeline. This is a no-op stub for
unit testing the widget logic."
(declare (ignore ta backend))
(values))

View File

@@ -0,0 +1,61 @@
(in-package :cl-tui-box-test)
(in-suite box-suite)
(test theme-create-default
"A theme can be created with default mode"
(let ((th (make-theme)))
(is (typep th 'theme))
(is (eql (theme-mode th) :dark))))
(test theme-create-light
"A theme can be created in light mode"
(let ((th (make-theme :mode :light)))
(is (eql (theme-mode th) :light))))
(test theme-color-set-and-get
"theme-color setf/get works"
(let ((th (make-theme)))
(setf (theme-color th :primary) "#FFD700")
(is (string= (theme-color th :primary) "#FFD700"))))
(test theme-color-unknown-returns-nil
"Unknown roles return nil"
(let ((th (make-theme)))
(is (null (theme-color th :nonexistent)))))
(test load-default-dark-preset
"Loading the default dark preset populates roles"
(let ((th (make-theme :mode :dark)))
(load-preset th :default)
(is (string= (theme-color th :primary) "#FFD700"))
(is (string= (theme-color th :background) "#1A1A2E"))
(is (string= (theme-color th :error) "#FF4444"))))
(test load-default-light-preset
"Light variant has different colors"
(let ((th (make-theme :mode :light)))
(load-preset th :default)
(is (string= (theme-color th :primary) "#B8860B"))
(is (string= (theme-color th :background) "#F8F9FA"))))
(test load-nord-preset
"Nord preset has different colors than default"
(let ((th (make-theme :mode :dark)))
(load-preset th :nord)
(is (string= (theme-color th :primary) "#88C0D0"))
(is (string= (theme-color th :background) "#2E3440"))))
(test load-preset-unknown-warns
"Unknown preset warns but doesn't error"
(let ((th (make-theme)))
(signals warning (load-preset th :nonexistent))
(is (null (theme-color th :primary)))))
(test preset-switch-mode
"Switching mode and reloading changes colors"
(let ((th (make-theme :mode :dark)))
(load-preset th :default)
(is (string= (theme-color th :background) "#1A1A2E"))
(setf (theme-mode th) :light)
(load-preset th :default)
(is (string= (theme-color th :background) "#F8F9FA"))))

87
src/components/theme.lisp Normal file
View File

@@ -0,0 +1,87 @@
(in-package :cl-tui.box)
;; ── Theme Engine ──────────────────────────────────────────────
(defclass theme ()
((mode :initform :dark :initarg :mode :accessor theme-mode)
(roles :initform (make-hash-table) :accessor theme-roles)))
(defun make-theme (&key (mode :dark))
(make-instance 'theme :mode mode))
(defun theme-color (theme role)
"Resolve a semantic ROLE to a hex color string in THEME."
(gethash role (theme-roles theme)))
(defun (setf theme-color) (hex theme role)
"Set the hex color for a semantic ROLE in THEME."
(setf (gethash role (theme-roles theme)) hex))
(defparameter *presets* (make-hash-table :test #'eq))
(defmacro define-preset (name &key dark light)
"Define a theme preset with DARK and LIGHT variants.
NAME should be a keyword (e.g., :default, :nord)."
(check-type name keyword)
`(setf (gethash ,name *presets*) '(:dark ,dark :light ,light)))
(defun load-preset (theme preset-name)
"Load PRESET-NAME (a keyword) into THEME, overwriting role mappings."
(let ((preset (gethash preset-name *presets*)))
(if preset
(let* ((variant (if (eql (theme-mode theme) :dark)
(getf preset :dark)
(getf preset :light)))
(roles (theme-roles theme)))
(clrhash roles)
(loop for (role hex) on variant by #'cddr
do (setf (gethash role roles) hex)))
(warn "Unknown preset: ~S" preset-name))))
(define-preset :default
:dark (:primary "#FFD700" :secondary "#B8860B" :accent "#FFA500"
:error "#FF4444" :warning "#FF8800" :success "#44BB44" :info "#4488FF"
:text "#FFFFFF" :text-muted "#888888"
:background "#1A1A2E" :background-panel "#16213E" :background-element "#0F3460"
:border "#334155" :border-active "#FFD700"
:diff-added "#164B16" :diff-removed "#4B1616" :diff-context "#1A1A2E"
:markdown-heading "#FFD700" :markdown-code "#334155"
:markdown-link "#4488FF" :markdown-quote "#888888"
:syntax-keyword "#FF79C6" :syntax-function "#50FA7B"
:syntax-string "#F1FA8C" :syntax-number "#BD93F9"
:syntax-comment "#6272A4" :syntax-type "#8BE9FD")
:light (:primary "#B8860B" :secondary "#8B6914" :accent "#FF8C00"
:error "#CC0000" :warning "#CC6600" :success "#228B22" :info "#0055CC"
:text "#1A1A2E" :text-muted "#888888"
:background "#F8F9FA" :background-panel "#FFFFFF" :background-element "#E9ECEF"
:border "#DEE2E6" :border-active "#B8860B"
:diff-added "#DFD" :diff-removed "#FDD" :diff-context "#F8F9FA"
:markdown-heading "#B8860B" :markdown-code "#E9ECEF"
:markdown-link "#0055CC" :markdown-quote "#888888"
:syntax-keyword "#D63384" :syntax-function "#198754"
:syntax-string "#FFC107" :syntax-number "#6F42C1"
:syntax-comment "#6C757D" :syntax-type "#0DCAF0"))
(define-preset :nord
:dark (:primary "#88C0D0" :secondary "#81A1C1" :accent "#5E81AC"
:error "#BF616A" :warning "#D08770" :success "#A3BE8C" :info "#B48EAD"
:text "#ECEFF4" :text-muted "#616E88"
:background "#2E3440" :background-panel "#3B4252" :background-element "#434C5E"
:border "#4C566A" :border-active "#88C0D0"
:diff-added "#164B16" :diff-removed "#4B1616" :diff-context "#2E3440"
:markdown-heading "#88C0D0" :markdown-code "#3B4252"
:markdown-link "#81A1C1" :markdown-quote "#616E88"
:syntax-keyword "#81A1C1" :syntax-function "#A3BE8C"
:syntax-string "#EBCB8B" :syntax-number "#B48EAD"
:syntax-comment "#616E88" :syntax-type "#88C0D0")
:light (:primary "#5E81AC" :secondary "#81A1C1" :accent "#88C0D0"
:error "#BF616A" :warning "#D08770" :success "#A3BE8C" :info "#B48EAD"
:text "#2E3440" :text-muted "#8F9BB3"
:background "#ECEFF4" :background-panel "#FFFFFF" :background-element "#E5E9F0"
:border "#D8DEE9" :border-active "#5E81AC"
:diff-added "#DFD" :diff-removed "#FDD" :diff-context "#ECEFF4"
:markdown-heading "#5E81AC" :markdown-code "#E5E9F0"
:markdown-link "#81A1C1" :markdown-quote "#8F9BB3"
:syntax-keyword "#81A1C1" :syntax-function "#A3BE8C"
:syntax-string "#D08770" :syntax-number "#B48EAD"
:syntax-comment "#8F9BB3" :syntax-type "#88C0D0"))

269
tests/input-tests.lisp Normal file
View File

@@ -0,0 +1,269 @@
(defpackage :cl-tui-input-test
(:use :cl :fiveam :cl-tui.backend :cl-tui.box :cl-tui.layout :cl-tui.input)
(:export #:run-tests))
(in-package :cl-tui-input-test)
(def-suite input-suite :description "Text input and keybinding tests")
(in-suite input-suite)
(defun run-tests ()
(let ((result (run 'input-suite)))
(fiveam:explain! result)
(uiop:quit 0)))
;; ── Key Event Tests ─────────────────────────────────────────────
(test key-event-construction
"A key-event can be created and queried."
(let ((e (make-key-event :key :a :ctrl t :alt nil)))
(is (eql (key-event-key e) :a))
(is-true (key-event-ctrl e))
(is-false (key-event-alt e))))
(test key-event-defaults
"Fields default to NIL/nil."
(let ((e (make-key-event :key :space)))
(is (eql (key-event-key e) :space))
(is-false (key-event-ctrl e))
(is-false (key-event-alt e))
(is-false (key-event-shift e))))
(test mouse-event-construction
"A mouse-event can be created and queried."
(let ((e (make-mouse-event :type :press :button :left :x 10 :y 5)))
(is (eql (mouse-event-type e) :press))
(is (eql (mouse-event-button e) :left))
(is (= (mouse-event-x e) 10))
(is (= (mouse-event-y e) 5))))
;; ── TextInput Tests ─────────────────────────────────────────────
(test text-input-empty
"A newly created text-input has empty value and cursor at 0."
(let ((in (make-text-input)))
(is (string= (text-input-value in) ""))
(is (= (text-input-cursor in) 0))))
(test text-input-insert-char
"Inserting a character appends and moves cursor."
(let ((in (make-text-input)))
(handle-text-input in (make-key-event :key :a :code (char-code #\a)))
(is (string= (text-input-value in) "a"))
(is (= (text-input-cursor in) 1))))
(test text-input-insert-multiple
"Inserting multiple characters works left to right."
(let ((in (make-text-input)))
(handle-text-input in (make-key-event :key :h :code (char-code #\h)))
(handle-text-input in (make-key-event :key :e :code (char-code #\e)))
(handle-text-input in (make-key-event :key :l :code (char-code #\l)))
(handle-text-input in (make-key-event :key :l :code (char-code #\l)))
(handle-text-input in (make-key-event :key :o :code (char-code #\o)))
(is (string= (text-input-value in) "hello"))
(is (= (text-input-cursor in) 5))))
(test text-input-backspace
"Backspace removes the character before the cursor."
(let ((in (make-text-input :value "ab" :cursor 2)))
(handle-text-input in (make-key-event :key :backspace))
(is (string= (text-input-value in) "a"))
(is (= (text-input-cursor in) 1))))
(test text-input-backspace-at-start
"Backspace at position 0 does nothing."
(let ((in (make-text-input :value "ab" :cursor 0)))
(handle-text-input in (make-key-event :key :backspace))
(is (string= (text-input-value in) "ab"))
(is (= (text-input-cursor in) 0))))
(test text-input-delete
"Delete removes the character at the cursor."
(let ((in (make-text-input :value "abc" :cursor 1)))
(handle-text-input in (make-key-event :key :delete))
(is (string= (text-input-value in) "ac"))
(is (= (text-input-cursor in) 1))))
(test text-input-cursor-left-right
"Cursor moves left and right."
(let ((in (make-text-input :value "ab" :cursor 2)))
(handle-text-input in (make-key-event :key :left))
(is (= (text-input-cursor in) 1))
(handle-text-input in (make-key-event :key :right))
(is (= (text-input-cursor in) 2))))
(test text-input-cursor-bounds
"Cursor cannot move past start or end."
(let ((in (make-text-input :value "ab" :cursor 0)))
(handle-text-input in (make-key-event :key :left))
(is (= (text-input-cursor in) 0))
(setf (text-input-cursor in) 2)
(handle-text-input in (make-key-event :key :right))
(is (= (text-input-cursor in) 2))))
(test text-input-home-end
"Home moves to start, End moves to end."
(let ((in (make-text-input :value "hello" :cursor 3)))
(handle-text-input in (make-key-event :key :home))
(is (= (text-input-cursor in) 0))
(handle-text-input in (make-key-event :key :end))
(is (= (text-input-cursor in) 5))))
(test text-input-max-length
"Max-length prevents inserting beyond the limit."
(let ((in (make-text-input :max-length 3)))
(handle-text-input in (make-key-event :key :a :code (char-code #\a)))
(handle-text-input in (make-key-event :key :b :code (char-code #\b)))
(handle-text-input in (make-key-event :key :c :code (char-code #\c)))
(handle-text-input in (make-key-event :key :d :code (char-code #\d)))
(is (string= (text-input-value in) "abc"))))
(test text-input-placeholder
"Placeholder is stored but does not affect value."
(let ((in (make-text-input :placeholder "Type here...")))
(is (string= (text-input-placeholder in) "Type here..."))
(is (string= (text-input-value in) ""))))
(test text-input-on-submit
"On-submit callback fires on Enter."
(let ((result (list nil)))
(let ((in (make-text-input :value "hello"
:on-submit (lambda (v) (setf (car result) v)))))
(handle-text-input in (make-key-event :key :enter))
(is (string= (car result) "hello")))))
(test text-input-ctrl-a-e
"Ctrl+A moves to home, Ctrl+E moves to end."
(let ((in (make-text-input :value "abc" :cursor 2)))
(handle-text-input in (make-key-event :key :a :ctrl t))
(is (= (text-input-cursor in) 0))
(handle-text-input in (make-key-event :key :e :ctrl t))
(is (= (text-input-cursor in) 3))))
(test text-input-insert-in-middle
"Inserting in the middle of text shifts rest right."
(let ((in (make-text-input :value "ab" :cursor 1)))
(handle-text-input in (make-key-event :key :x :code (char-code #\x)))
(is (string= (text-input-value in) "axb"))
(is (= (text-input-cursor in) 2))))
(test text-input-dirty-on-insert
"Inserting marks the widget dirty."
(let ((in (make-text-input)))
(mark-clean in)
(handle-text-input in (make-key-event :key :a :code (char-code #\a)))
(is-true (dirty-p in))))
;; ── Textarea Tests ──────────────────────────────────────────────
(test textarea-empty
"New textarea has empty value and cursor at (0,0)."
(let ((a (make-textarea)))
(is (string= (textarea-value a) ""))
(is (= (textarea-cursor-row a) 0))
(is (= (textarea-cursor-col a) 0))))
(test textarea-newline
"Enter inserts a newline."
(let ((a (make-textarea)))
(handle-textarea-input a (make-key-event :key :a :code (char-code #\a)))
(handle-textarea-input a (make-key-event :key :enter))
(handle-textarea-input a (make-key-event :key :b :code (char-code #\b)))
(is (string= (textarea-value a) "a
b"))))
(test textarea-cursor-up-down
"Cursor moves between lines maintaining column position."
(let ((a (make-textarea :value "abc
de
fghi")))
(setf (textarea-cursor-row a) 1)
(setf (textarea-cursor-col a) 1)
(handle-textarea-input a (make-key-event :key :up))
(is (= (textarea-cursor-row a) 0))
(is (= (textarea-cursor-col a) 1))
(handle-textarea-input a (make-key-event :key :down))
(is (= (textarea-cursor-row a) 1))
(is (= (textarea-cursor-col a) 1))))
(test textarea-cursor-up-down-bounds
"Cursor cannot move past first or last line."
(let ((a (make-textarea :value "a
b")))
(handle-textarea-input a (make-key-event :key :up))
(is (= (textarea-cursor-row a) 0))
(setf (textarea-cursor-row a) 1)
(handle-textarea-input a (make-key-event :key :down))
(is (= (textarea-cursor-row a) 1))))
(test textarea-backspace-joins-lines
"Backspace at start of a line joins with previous."
(let ((a (make-textarea :value "hello
world")))
(setf (textarea-cursor-row a) 1)
(setf (textarea-cursor-col a) 0)
(handle-textarea-input a (make-key-event :key :backspace))
(is (string= (textarea-value a) "helloworld"))))
(test textarea-undo
"Ctrl+Z undoes the last edit."
(let ((a (make-textarea)))
(handle-textarea-input a (make-key-event :key :a :code (char-code #\a)))
(handle-textarea-input a (make-key-event :key :z :ctrl t))
(is (string= (textarea-value a) ""))))
(test textarea-undo-redo
"Ctrl+Y redoes an undone edit."
(let ((a (make-textarea)))
(handle-textarea-input a (make-key-event :key :a :code (char-code #\a)))
(handle-textarea-input a (make-key-event :key :z :ctrl t))
(handle-textarea-input a (make-key-event :key :y :ctrl t))
(is (string= (textarea-value a) "a"))))
;; ── Keybinding Tests ────────────────────────────────────────────
(test keymap-simple
"A keymap dispatches to its handler on matching event."
(let ((called nil))
(setf (gethash :global *keymaps*)
(make-keymap :name :global
:bindings `((:ctrl+p . ,(lambda (e)
(declare (ignore e))
(setf called t))))))
(is-true (dispatch-key-event (make-key-event :key :p :ctrl t)))
(is-true called)))
(test keymap-no-match
"Non-matching event returns nil."
(let ((called nil))
(setf (gethash :global *keymaps*)
(make-keymap :name :global
:bindings `((:ctrl+p . ,(lambda (e)
(declare (ignore e))
(setf called t))))))
(is-false (dispatch-key-event (make-key-event :key :a)))
(is-false called)))
(test keymap-fallback
"Event not in local falls through to global."
(let ((global-called nil))
(setf (gethash :global *keymaps*)
(make-keymap :name :global
:bindings `((:ctrl+q . ,(lambda (e)
(declare (ignore e))
(setf global-called t))))))
(dispatch-key-event (make-key-event :key :q :ctrl t))
(is-true global-called)))
(test key-spec-simple
"Keyword key-spec matches key+ctrl."
(is-true (key-match-p :ctrl+p (make-key-event :key :p :ctrl t)))
(is-false (key-match-p :ctrl+p (make-key-event :key :a :ctrl t)))
(is-false (key-match-p :ctrl+p (make-key-event :key :p))))
(test defkeymap-macro
"defkeymap macro registers a keymap."
(let ((called nil))
(eval `(defkeymap :global
(:ctrl+q ,(lambda (e) (declare (ignore e)) (setf called t)))))
(dispatch-key-event (make-key-event :key :q :ctrl t))
(is-true called)))

View File

@@ -0,0 +1,128 @@
(defpackage :cl-tui-scrollbox-test
(:use :cl :fiveam :cl-tui.backend :cl-tui.box :cl-tui.layout :cl-tui.input :cl-tui.container)
(:export #:run-tests))
(in-package #:cl-tui-scrollbox-test)
(def-suite scrollbox-suite :description "ScrollBox + TabBar tests")
(in-suite scrollbox-suite)
(defun run-tests ()
(let ((result (run 'scrollbox-suite)))
(fiveam:explain! result)
(uiop:quit 0)))
;; ── ScrollBox Tests ─────────────────────────────────────────────
(test scrollbox-creates
"A ScrollBox can be created with defaults."
(let ((sb (make-scroll-box)))
(is (typep sb 'scroll-box))
(is (= (scroll-box-scroll-y sb) 0))
(is (= (scroll-box-scroll-x sb) 0))
(is-false (scroll-box-children sb))))
(test scrollbox-with-children
"A ScrollBox can have children."
(let ((sb (make-scroll-box :children (list (make-text "hello")))))
(is (= (length (scroll-box-children sb)) 1))))
(test scrollbox-scroll-by
"ScrollBy adjusts offset clamped to valid range."
(let ((sb (make-scroll-box :scroll-y 0)))
(scroll-by sb 5 0)
(is (>= (scroll-box-scroll-y sb) 0))))
(test scrollbox-component-children
"Component protocol: children are accessible."
(let* ((child (make-text "hello"))
(sb (make-scroll-box :children (list child))))
(is (eql (first (component-children sb)) child))))
(test scrollbox-render-noop
"Rendering a ScrollBox with no children does not error."
(let* ((stream (make-string-output-stream))
(backend (make-simple-backend :output-stream stream))
(sb (make-scroll-box)))
(render sb backend)
(is-true t)))
;; ── TabBar Tests ────────────────────────────────────────────────
(test tabbar-creates
"A TabBar can be created with defaults."
(let ((tb (make-tab-bar)))
(is (typep tb 'tab-bar))
(is-false (tab-bar-active tb))
(is-false (tab-bar-tabs tb))))
(test tabbar-add-tab
"Adding a tab returns the id and updates tabs."
(let ((tb (make-tab-bar)))
(let ((id (tab-bar-add tb :tab1 "Tab One")))
(is (eql id :tab1))
(is (= (length (tab-bar-tabs tb)) 1))
(is (string= (getf (first (tab-bar-tabs tb)) :title) "Tab One")))))
(test tabbar-active-tab
"Setting active tab works."
(let ((tb (make-tab-bar)))
(tab-bar-add tb :tab1 "One")
(tab-bar-add tb :tab2 "Two")
(setf (tab-bar-active tb) :tab2)
(is (eql (tab-bar-active tb) :tab2))))
(test tabbar-render-noop
"Rendering a TabBar does not error."
(let* ((stream (make-string-output-stream))
(backend (make-simple-backend :output-stream stream))
(tb (make-tab-bar)))
(tab-bar-add tb :tab1 "One")
(tab-bar-add tb :tab2 "Two")
(setf (tab-bar-active tb) :tab1)
(render tb backend)
(is-true t)))
(test tabbar-next-prev
"TabBar next/prev wraps around through tabs."
(let ((tb (make-tab-bar)))
(tab-bar-add tb :tab1 "One")
(tab-bar-add tb :tab2 "Two")
(tab-bar-add tb :tab3 "Three")
(is (eql (tab-bar-active tb) :tab1))
(tab-bar-next tb)
(is (eql (tab-bar-active tb) :tab2))
(tab-bar-next tb)
(is (eql (tab-bar-active tb) :tab3))
(tab-bar-next tb)
(is (eql (tab-bar-active tb) :tab1) "wrap around past last")
(tab-bar-prev tb)
(is (eql (tab-bar-active tb) :tab3) "wrap around past first")))
(test tabbar-select
"TabBar select activates the specified tab."
(let ((tb (make-tab-bar)))
(tab-bar-add tb :tab1 "One")
(tab-bar-add tb :tab2 "Two")
(tab-bar-select tb :tab2)
(is (eql (tab-bar-active tb) :tab2))))
(test tabbar-handle-key
"TabBar handle-key dispatches left/right."
(let ((tb (make-tab-bar)))
(tab-bar-add tb :tab1 "One")
(tab-bar-add tb :tab2 "Two")
(setf (tab-bar-active tb) :tab1)
(tab-bar-handle-key tb (make-key-event :key :right))
(is (eql (tab-bar-active tb) :tab2))
(tab-bar-handle-key tb (make-key-event :key :left))
(is (eql (tab-bar-active tb) :tab1))))
(test scrollbox-scroll-clamp
"ScrollBox clamp prevents scrolling past bounds."
(let ((sb (make-scroll-box :scroll-y 5 :scroll-x 3)))
(setf (scroll-box-scroll-y sb) -1)
(clamp-scroll sb)
(is (= (scroll-box-scroll-y sb) 0) "clamps below 0")
(setf (scroll-box-scroll-y sb) 1000000)
(clamp-scroll sb)
(is (= (scroll-box-scroll-y sb) 0) "clamps above max (no children = 0 content)")))