v1.0.0 — Stable release + TUI support #8
@@ -95,6 +95,18 @@ class. Application code never calls terminal escape sequences directly.
|
|||||||
|
|
||||||
* Tests
|
* Tests
|
||||||
|
|
||||||
|
The test suite is organized around the backend protocol contract.
|
||||||
|
Each rendering primitive and lifecycle operation has a dedicated
|
||||||
|
test case. Tests use a capturing backend (a simple-backend wired to
|
||||||
|
a string output stream) so assertions check actual output strings
|
||||||
|
rather than terminal behavior.
|
||||||
|
|
||||||
|
** Test Package and Suite
|
||||||
|
|
||||||
|
FiveAM requires a test package with :use of :fiveam and the system
|
||||||
|
under test. The suite name ~backend-suite~ is referenced by the
|
||||||
|
multi-suite runner in ~run-all-tests.lisp~.
|
||||||
|
|
||||||
#+BEGIN_SRC lisp :tangle ../src/backend/tests.lisp
|
#+BEGIN_SRC lisp :tangle ../src/backend/tests.lisp
|
||||||
(defpackage :cl-tty-backend-test
|
(defpackage :cl-tty-backend-test
|
||||||
(:use :cl :fiveam :cl-tty.backend)
|
(:use :cl :fiveam :cl-tty.backend)
|
||||||
@@ -103,23 +115,45 @@ class. Application code never calls terminal escape sequences directly.
|
|||||||
|
|
||||||
(def-suite backend-suite :description "Backend protocol tests")
|
(def-suite backend-suite :description "Backend protocol tests")
|
||||||
(in-suite backend-suite)
|
(in-suite backend-suite)
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
;; ── Helpers ─────────────────────────────────────────────────────
|
** Capturing Backend Helper
|
||||||
|
|
||||||
|
Tests need to inspect what the backend actually writes. This helper
|
||||||
|
creates a simple-backend pointed at a string output stream and
|
||||||
|
returns both the backend and the stream. The test can then call
|
||||||
|
~get-output-stream-string~ after the operation.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/backend/tests.lisp
|
||||||
(defun make-capturing-backend ()
|
(defun make-capturing-backend ()
|
||||||
"Create a simple-backend that writes to a string stream."
|
"Create a simple-backend that writes to a string stream."
|
||||||
(let* ((s (make-string-output-stream))
|
(let* ((s (make-string-output-stream))
|
||||||
(b (make-simple-backend :output-stream s)))
|
(b (make-simple-backend :output-stream s)))
|
||||||
(values b s)))
|
(values b s)))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
;; ── Simple Backend ──────────────────────────────────────────────
|
** Test Runner Entry Point
|
||||||
|
|
||||||
|
The ~run-tests~ function is an alternative entry point for
|
||||||
|
interactive use or for downstream scripts that want to run only the
|
||||||
|
backend suite. It prints results with FiveAM's explainer.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/backend/tests.lisp
|
||||||
(defun run-tests ()
|
(defun run-tests ()
|
||||||
"Run all backend tests."
|
"Run all backend tests."
|
||||||
(let ((result (run 'backend-suite)))
|
(let ((result (run 'backend-suite)))
|
||||||
(fiveam:explain! result)
|
(fiveam:explain! result)
|
||||||
(uiop:quit 0)))
|
(uiop:quit 0)))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** Simple Backend Lifecycle
|
||||||
|
|
||||||
|
Verifies that a simple-backend can be constructed, initialized, and
|
||||||
|
shut down without errors. Also confirms that the capability query
|
||||||
|
returns nil for truecolor — the defining characteristic of the
|
||||||
|
simple backend.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/backend/tests.lisp
|
||||||
(test simple-backend-lifecycle
|
(test simple-backend-lifecycle
|
||||||
"simple-backend can be created and shut down"
|
"simple-backend can be created and shut down"
|
||||||
(let ((b (make-simple-backend)))
|
(let ((b (make-simple-backend)))
|
||||||
@@ -127,7 +161,16 @@ class. Application code never calls terminal escape sequences directly.
|
|||||||
(initialize-backend b)
|
(initialize-backend b)
|
||||||
(is-false (capable-p b :truecolor) "simple backend has no truecolor")
|
(is-false (capable-p b :truecolor) "simple backend has no truecolor")
|
||||||
(shutdown-backend b)))
|
(shutdown-backend b)))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** Simple Backend Draw Text
|
||||||
|
|
||||||
|
The simple backend ignores style attributes (bold, italic, color)
|
||||||
|
and position. It merely appends the text string to the output stream.
|
||||||
|
This test confirms that passing style keywords does not change the
|
||||||
|
output — the captured stream should contain exactly the string "hello".
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/backend/tests.lisp
|
||||||
(test simple-backend-draw-text
|
(test simple-backend-draw-text
|
||||||
"simple-backend renders text at position, ignoring style"
|
"simple-backend renders text at position, ignoring style"
|
||||||
(multiple-value-bind (b s) (make-capturing-backend)
|
(multiple-value-bind (b s) (make-capturing-backend)
|
||||||
@@ -136,7 +179,16 @@ class. Application code never calls terminal escape sequences directly.
|
|||||||
(shutdown-backend b)
|
(shutdown-backend b)
|
||||||
(is (string= (get-output-stream-string s) "hello")
|
(is (string= (get-output-stream-string s) "hello")
|
||||||
"draw-text should output the string ignoring style")))
|
"draw-text should output the string ignoring style")))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** Simple Backend Draw Border
|
||||||
|
|
||||||
|
Border rendering on the simple backend uses ASCII characters:
|
||||||
|
~+~ for corners, ~-~ for horizontal edges, ~|~ for vertical edges.
|
||||||
|
This test checks that the top edge contains "+---+" and a middle
|
||||||
|
row shows "| |" with pipe-separated empty space.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/backend/tests.lisp
|
||||||
(test simple-backend-draw-border
|
(test simple-backend-draw-border
|
||||||
"simple-backend draws ASCII border with +-| characters"
|
"simple-backend draws ASCII border with +-| characters"
|
||||||
(multiple-value-bind (b s) (make-capturing-backend)
|
(multiple-value-bind (b s) (make-capturing-backend)
|
||||||
@@ -144,9 +196,18 @@ class. Application code never calls terminal escape sequences directly.
|
|||||||
(draw-border b 0 0 5 3 :style :single)
|
(draw-border b 0 0 5 3 :style :single)
|
||||||
(shutdown-backend b)
|
(shutdown-backend b)
|
||||||
(let ((out (get-output-stream-string s)))
|
(let ((out (get-output-stream-string s)))
|
||||||
(is (search "+---+" out) "top edge should have +---+")
|
(is (search "+---+" out) "top edge should have +---+\"")
|
||||||
(is (search "| |" out) "middle row should have pipe sides"))))
|
(is (search "| |" out) "middle row should have pipe sides"))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** Simple Backend Draw Rounded Border
|
||||||
|
|
||||||
|
The simple backend does not support rounded corners — every style
|
||||||
|
falls back to the same ASCII characters. This test verifies that
|
||||||
|
requesting ~:rounded~ produces the same output as ~:single~,
|
||||||
|
confirming the graceful fallback.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/backend/tests.lisp
|
||||||
(test simple-backend-draw-rounded
|
(test simple-backend-draw-rounded
|
||||||
"simple-backend falls back to straight edges for rounded style"
|
"simple-backend falls back to straight edges for rounded style"
|
||||||
(multiple-value-bind (b s) (make-capturing-backend)
|
(multiple-value-bind (b s) (make-capturing-backend)
|
||||||
@@ -154,9 +215,17 @@ class. Application code never calls terminal escape sequences directly.
|
|||||||
(draw-border b 0 0 5 3 :style :rounded)
|
(draw-border b 0 0 5 3 :style :rounded)
|
||||||
(shutdown-backend b)
|
(shutdown-backend b)
|
||||||
(let ((out (get-output-stream-string s)))
|
(let ((out (get-output-stream-string s)))
|
||||||
;; Rounded falls back to ASCII — identical output to single
|
;; Rounded falls back to ASCII -- identical output to single
|
||||||
(is (search "+---+" out) "rounded style produces same dashes as single"))))
|
(is (search "+---+" out) "rounded style produces same dashes as single"))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** Simple Backend Draw Link
|
||||||
|
|
||||||
|
Hyperlinks are meaningless on a simple terminal output. The simple
|
||||||
|
backend's ~draw-link~ should output only the visible text and
|
||||||
|
completely ignore the URL parameter.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/backend/tests.lisp
|
||||||
(test simple-backend-draw-link
|
(test simple-backend-draw-link
|
||||||
"simple-backend renders link as plain text"
|
"simple-backend renders link as plain text"
|
||||||
(multiple-value-bind (b s) (make-capturing-backend)
|
(multiple-value-bind (b s) (make-capturing-backend)
|
||||||
@@ -165,7 +234,15 @@ class. Application code never calls terminal escape sequences directly.
|
|||||||
(shutdown-backend b)
|
(shutdown-backend b)
|
||||||
(is (string= (get-output-stream-string s) "click me")
|
(is (string= (get-output-stream-string s) "click me")
|
||||||
"simple-backend ignores URL, outputs text only")))
|
"simple-backend ignores URL, outputs text only")))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** Simple Backend Draw Ellipsis
|
||||||
|
|
||||||
|
Truncation markers are rendered as three literal dots on the simple
|
||||||
|
backend. This test checks that ~draw-ellipsis~ outputs exactly "..."
|
||||||
|
at the specified position.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/backend/tests.lisp
|
||||||
(test simple-backend-draw-ellipsis
|
(test simple-backend-draw-ellipsis
|
||||||
"simple-backend renders ... for ellipsis"
|
"simple-backend renders ... for ellipsis"
|
||||||
(multiple-value-bind (b s) (make-capturing-backend)
|
(multiple-value-bind (b s) (make-capturing-backend)
|
||||||
@@ -174,9 +251,16 @@ class. Application code never calls terminal escape sequences directly.
|
|||||||
(shutdown-backend b)
|
(shutdown-backend b)
|
||||||
(is (string= (get-output-stream-string s) "...")
|
(is (string= (get-output-stream-string s) "...")
|
||||||
"ellipsis should output 3 dots")))
|
"ellipsis should output 3 dots")))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
;; ── Backend Capabilities ───────────────────────────────────────
|
** Capability Query: Known Features
|
||||||
|
|
||||||
|
All known terminal features should report ~nil~ on the simple
|
||||||
|
backend. This comprehensive check iterates every feature keyword
|
||||||
|
to ensure the simple backend makes no false claims about its
|
||||||
|
capabilities.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/backend/tests.lisp
|
||||||
(test capable-p-known-features
|
(test capable-p-known-features
|
||||||
"capable-p returns nil for all features on simple-backend"
|
"capable-p returns nil for all features on simple-backend"
|
||||||
(let ((b (make-simple-backend)))
|
(let ((b (make-simple-backend)))
|
||||||
@@ -186,9 +270,16 @@ class. Application code never calls terminal escape sequences directly.
|
|||||||
(is-false (capable-p b f)
|
(is-false (capable-p b f)
|
||||||
(format nil "~s should not be supported on simple-backend" f)))
|
(format nil "~s should not be supported on simple-backend" f)))
|
||||||
(shutdown-backend b)))
|
(shutdown-backend b)))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
;; ── Backend Size ───────────────────────────────────────────────
|
** Backend Size Returns Integers
|
||||||
|
|
||||||
|
The ~backend-size~ function must return two integer values
|
||||||
|
representing columns and lines. This test verifies the return types
|
||||||
|
and a minimum size threshold (10 columns, 3 lines) for any
|
||||||
|
terminal-like environment.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/backend/tests.lisp
|
||||||
(test backend-size-returns-integers
|
(test backend-size-returns-integers
|
||||||
"backend-size returns two integer values"
|
"backend-size returns two integer values"
|
||||||
(let ((b (make-simple-backend)))
|
(let ((b (make-simple-backend)))
|
||||||
@@ -199,9 +290,17 @@ class. Application code never calls terminal escape sequences directly.
|
|||||||
(is (>= cols 10))
|
(is (>= cols 10))
|
||||||
(is (>= lines 3)))
|
(is (>= lines 3)))
|
||||||
(shutdown-backend b)))
|
(shutdown-backend b)))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
;; ── Backend Protocol: Defaults and No-ops ──────────────────────
|
** Default Methods Are No-Ops
|
||||||
|
|
||||||
|
All cursor operations and sync operations on the default backend
|
||||||
|
should return ~nil~ (or ~(values)~) without signaling errors. This
|
||||||
|
test calls ~cursor-hide~, ~cursor-show~, ~cursor-style~,
|
||||||
|
~begin-sync~, and ~end-sync~ and confirms none of them produce
|
||||||
|
multiple return values.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/backend/tests.lisp
|
||||||
(test default-methods-are-no-ops
|
(test default-methods-are-no-ops
|
||||||
"Default backend methods don't error"
|
"Default backend methods don't error"
|
||||||
(let ((b (make-simple-backend)))
|
(let ((b (make-simple-backend)))
|
||||||
@@ -212,7 +311,16 @@ class. Application code never calls terminal escape sequences directly.
|
|||||||
(is (null (multiple-value-list (begin-sync b))))
|
(is (null (multiple-value-list (begin-sync b))))
|
||||||
(is (null (multiple-value-list (end-sync b))))
|
(is (null (multiple-value-list (end-sync b))))
|
||||||
(shutdown-backend b)))
|
(shutdown-backend b)))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** Sync Is No-Op on Simple
|
||||||
|
|
||||||
|
Synchronized updates (DECICM) have no meaning on a simple terminal
|
||||||
|
output. This test verifies that wrapping a draw-text call between
|
||||||
|
~begin-sync~ and ~end-sync~ produces exactly the same output as
|
||||||
|
draw-text alone — no extra escape sequences are emitted.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/backend/tests.lisp
|
||||||
(test sync-is-noop-on-simple
|
(test sync-is-noop-on-simple
|
||||||
"begin-sync and end-sync produce no output on simple-backend"
|
"begin-sync and end-sync produce no output on simple-backend"
|
||||||
(multiple-value-bind (b s) (make-capturing-backend)
|
(multiple-value-bind (b s) (make-capturing-backend)
|
||||||
@@ -223,9 +331,16 @@ class. Application code never calls terminal escape sequences directly.
|
|||||||
(shutdown-backend b)
|
(shutdown-backend b)
|
||||||
(is (string= (get-output-stream-string s) "in sync")
|
(is (string= (get-output-stream-string s) "in sync")
|
||||||
"no sync escape sequences should appear")))
|
"no sync escape sequences should appear")))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
;; ── Draw-rect ──────────────────────────────────────────────────
|
** Draw Rect Is No-Op on Simple
|
||||||
|
|
||||||
|
Background fill operations require escape sequences to change cell
|
||||||
|
colors. Since the simple backend emits no escape sequences,
|
||||||
|
~draw-rect~ should produce zero output regardless of the fill
|
||||||
|
color requested.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/backend/tests.lisp
|
||||||
(test draw-rect-fills-area-correctly
|
(test draw-rect-fills-area-correctly
|
||||||
"draw-rect with background writes nothing to output (simple-backend no-op)"
|
"draw-rect with background writes nothing to output (simple-backend no-op)"
|
||||||
(multiple-value-bind (b s) (make-capturing-backend)
|
(multiple-value-bind (b s) (make-capturing-backend)
|
||||||
@@ -234,14 +349,29 @@ class. Application code never calls terminal escape sequences directly.
|
|||||||
(shutdown-backend b)
|
(shutdown-backend b)
|
||||||
(is (string= (get-output-stream-string s) "")
|
(is (string= (get-output-stream-string s) "")
|
||||||
"draw-rect is a no-op on simple-backend")))
|
"draw-rect is a no-op on simple-backend")))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
;; ── Detection ──────────────────────────────────────────────────
|
** Backend Detection Returns Instance
|
||||||
|
|
||||||
|
The ~detect-backend~ function must return a backend instance
|
||||||
|
suitable for the current environment. This test verifies that the
|
||||||
|
returned value is of type ~backend~ (satisfying the protocol).
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/backend/tests.lisp
|
||||||
(test detection-returns-backend-instance
|
(test detection-returns-backend-instance
|
||||||
"detect-backend returns a valid backend instance"
|
"detect-backend returns a valid backend instance"
|
||||||
(let ((be (cl-tty.backend:detect-backend)))
|
(let ((be (cl-tty.backend:detect-backend)))
|
||||||
(is (typep be 'cl-tty.backend:backend))))
|
(is (typep be 'cl-tty.backend:backend))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** Backend Detection Caches Result
|
||||||
|
|
||||||
|
~detect-backend~ caches its result in ~*detected-backend*~ so that
|
||||||
|
subsequent calls are cheap. This test clears the cache, calls
|
||||||
|
detect-backend, and verifies that the special variable is no longer
|
||||||
|
nil.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/backend/tests.lisp
|
||||||
(test detection-caches-result
|
(test detection-caches-result
|
||||||
"detect-backend caches the result in *detected-backend*"
|
"detect-backend caches the result in *detected-backend*"
|
||||||
(let ((*detected-backend* nil))
|
(let ((*detected-backend* nil))
|
||||||
@@ -251,10 +381,17 @@ class. Application code never calls terminal escape sequences directly.
|
|||||||
|
|
||||||
* Implementation
|
* Implementation
|
||||||
|
|
||||||
|
This section defines the base backend protocol and the simple
|
||||||
|
backend implementation. Each function, generic function, and method
|
||||||
|
is documented individually with its design rationale.
|
||||||
|
|
||||||
** Package
|
** Package
|
||||||
|
|
||||||
The ~cl-tty.backend~ package exports all the generic function names
|
The ~cl-tty.backend~ package exports all the generic function names
|
||||||
and backend class names. It uses only ~:cl~ — no external dependencies.
|
and backend class names. It uses only ~:cl~ — no external dependencies.
|
||||||
|
The package also exports internal symbols (~sgr-fg~, ~hex-to-24bit~,
|
||||||
|
etc.) for testing. These let the test suite verify escape sequence
|
||||||
|
construction without actually rendering to a terminal.
|
||||||
|
|
||||||
#+BEGIN_SRC lisp :tangle ../src/backend/package.lisp
|
#+BEGIN_SRC lisp :tangle ../src/backend/package.lisp
|
||||||
(defpackage :cl-tty.backend
|
(defpackage :cl-tty.backend
|
||||||
@@ -292,10 +429,6 @@ and backend class names. It uses only ~:cl~ — no external dependencies.
|
|||||||
(in-package :cl-tty.backend)
|
(in-package :cl-tty.backend)
|
||||||
#+END_SRC
|
#+END_SRC
|
||||||
|
|
||||||
The package also exports internal symbols (~sgr-fg~, ~hex-to-24bit~,
|
|
||||||
etc.) for testing. These let the test suite verify escape sequence
|
|
||||||
construction without actually rendering to a terminal.
|
|
||||||
|
|
||||||
** Backend Base Class
|
** Backend Base Class
|
||||||
|
|
||||||
The ~backend~ class itself is empty — it's a base for method dispatch.
|
The ~backend~ class itself is empty — it's a base for method dispatch.
|
||||||
@@ -303,84 +436,248 @@ Every generic function on ~backend~ has a default method so that new
|
|||||||
backend implementations only need to override the functions they
|
backend implementations only need to override the functions they
|
||||||
actually support.
|
actually support.
|
||||||
|
|
||||||
|
*** Backend Class Definition
|
||||||
|
|
||||||
|
An empty base class. There are no slots because backends manage
|
||||||
|
their own state (e.g., output streams) directly.
|
||||||
|
|
||||||
#+BEGIN_SRC lisp :tangle ../src/backend/classes.lisp
|
#+BEGIN_SRC lisp :tangle ../src/backend/classes.lisp
|
||||||
(in-package :cl-tty.backend)
|
(in-package :cl-tty.backend)
|
||||||
|
|
||||||
(defclass backend () ())
|
(defclass backend () ())
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
*** Initialize Backend
|
||||||
|
|
||||||
|
Sets up terminal raw mode and enables features. The default method
|
||||||
|
returns the backend instance unchanged — subclasses that need setup
|
||||||
|
override this.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/backend/classes.lisp
|
||||||
(defgeneric initialize-backend (backend)
|
(defgeneric initialize-backend (backend)
|
||||||
(:method ((b backend)) b))
|
(:method ((b backend)) b))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
*** Shutdown Backend
|
||||||
|
|
||||||
|
Restores terminal to cooked mode, resets colors, shows cursor.
|
||||||
|
Must be called on exit. The default method is a no-op returning
|
||||||
|
multiple values; subclasses with terminal state override this.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/backend/classes.lisp
|
||||||
(defgeneric shutdown-backend (backend)
|
(defgeneric shutdown-backend (backend)
|
||||||
(:method ((b backend)) (values)))
|
(:method ((b backend)) (values)))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
*** Backend Size
|
||||||
|
|
||||||
|
Returns terminal dimensions as two values: columns and lines.
|
||||||
|
The default of 80x24 is a safe fallback that works everywhere.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/backend/classes.lisp
|
||||||
(defgeneric backend-size (backend)
|
(defgeneric backend-size (backend)
|
||||||
(:method ((b backend))
|
(:method ((b backend))
|
||||||
(values 80 24)))
|
(values 80 24)))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
*** Backend Write
|
||||||
|
|
||||||
|
Writes a raw string to the terminal output. Has no default method
|
||||||
|
because every backend must provide its own output mechanism — there
|
||||||
|
is no reasonable universal behavior.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/backend/classes.lisp
|
||||||
(defgeneric backend-write (backend string))
|
(defgeneric backend-write (backend string))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
*** Backend Clear
|
||||||
|
|
||||||
|
Clears the entire screen and resets the cursor to (0,0). The default
|
||||||
|
method sends the ANSI escape sequence ~ESC[2J~ (clear entire screen)
|
||||||
|
followed by ~ESC[H~ (cursor home).
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/backend/classes.lisp
|
||||||
(defgeneric backend-clear (backend)
|
(defgeneric backend-clear (backend)
|
||||||
(:method ((b backend))
|
(:method ((b backend))
|
||||||
(backend-write b (format nil "~C[2J~C[H" #\Esc #\Esc))))
|
(backend-write b (format nil "~C[2J~C[H" #\Esc #\Esc))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
*** Draw Text
|
||||||
|
|
||||||
|
Renders text at position (x, y) with foreground and background
|
||||||
|
colors and style attributes. The ~&allow-other-keys~ is important:
|
||||||
|
it lets individual backend methods accept keyword arguments they
|
||||||
|
don't use without signaling an error. The simple backend ignores
|
||||||
|
styles; the modern backend processes them.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/backend/classes.lisp
|
||||||
(defgeneric draw-text (backend x y string fg bg &key
|
(defgeneric draw-text (backend x y string fg bg &key
|
||||||
bold italic underline reverse dim blink
|
bold italic underline reverse dim blink
|
||||||
&allow-other-keys))
|
&allow-other-keys))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
*** Draw Border
|
||||||
|
|
||||||
|
Draws a border rectangle with optional title. Style is one of
|
||||||
|
~:single~, ~:double~, or ~:rounded~. The default method has no
|
||||||
|
implementation — each backend provides its own border rendering.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/backend/classes.lisp
|
||||||
(defgeneric draw-border (backend x y width height
|
(defgeneric draw-border (backend x y width height
|
||||||
&key style fg bg title title-align))
|
&key style fg bg title title-align))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
*** Draw Rectangle
|
||||||
|
|
||||||
|
Fills a rectangular area with a background color. On the simple
|
||||||
|
backend this is a no-op; on the modern backend it writes space
|
||||||
|
characters with the appropriate SGR background color.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/backend/classes.lisp
|
||||||
(defgeneric draw-rect (backend x y width height &key bg))
|
(defgeneric draw-rect (backend x y width height &key bg))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
*** Draw Link
|
||||||
|
|
||||||
|
Renders a clickable hyperlink using OSC 8 escape sequences. The
|
||||||
|
default is a protocol declaration only — modern-backend implements
|
||||||
|
the actual escape sequences, simple-backend falls back to plain text.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/backend/classes.lisp
|
||||||
(defgeneric draw-link (backend x y string url &key fg bg))
|
(defgeneric draw-link (backend x y string url &key fg bg))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
*** Draw Ellipsis
|
||||||
|
|
||||||
|
Renders a "..." truncation marker at position (x, y). This is used
|
||||||
|
when text exceeds the available width. Each backend positions the
|
||||||
|
marker according to its own coordinate system.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/backend/classes.lisp
|
||||||
(defgeneric draw-ellipsis (backend x y width &key fg bg))
|
(defgeneric draw-ellipsis (backend x y width &key fg bg))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
*** Cursor Move
|
||||||
|
|
||||||
|
Moves the cursor to absolute position (x, y). The default method
|
||||||
|
is a no-op — backends that support cursor positioning override this.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/backend/classes.lisp
|
||||||
(defgeneric cursor-move (backend x y)
|
(defgeneric cursor-move (backend x y)
|
||||||
(:method ((b backend) x y) (declare (ignore x y)) (values)))
|
(:method ((b backend) x y) (declare (ignore x y)) (values)))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
*** Cursor Hide
|
||||||
|
|
||||||
|
Hides the terminal cursor. The default method is a no-op so that
|
||||||
|
backends that lack cursor control still work safely.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/backend/classes.lisp
|
||||||
(defgeneric cursor-hide (backend)
|
(defgeneric cursor-hide (backend)
|
||||||
(:method ((b backend)) (values)))
|
(:method ((b backend)) (values)))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
*** Cursor Show
|
||||||
|
|
||||||
|
Shows the terminal cursor after a hide. Always paired with
|
||||||
|
~cursor-hide~. Default is a no-op.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/backend/classes.lisp
|
||||||
(defgeneric cursor-show (backend)
|
(defgeneric cursor-show (backend)
|
||||||
(:method ((b backend)) (values)))
|
(:method ((b backend)) (values)))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
*** Cursor Style
|
||||||
|
|
||||||
|
Sets the cursor shape and blink behavior. Shape is ~:block~,
|
||||||
|
~:bar~, or ~:underline~. Default is a no-op for backends that
|
||||||
|
don't support cursor styling.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/backend/classes.lisp
|
||||||
(defgeneric cursor-style (backend shape &key blink)
|
(defgeneric cursor-style (backend shape &key blink)
|
||||||
(:method ((b backend) shape &key blink) (values)))
|
(:method ((b backend) shape &key blink) (values)))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
*** Begin Sync
|
||||||
|
|
||||||
|
Starts a synchronized update (DECICM). All subsequent output is
|
||||||
|
buffered by the terminal until ~end-sync~. Default is a no-op.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/backend/classes.lisp
|
||||||
(defgeneric begin-sync (backend)
|
(defgeneric begin-sync (backend)
|
||||||
(:method ((b backend)) (values)))
|
(:method ((b backend)) (values)))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
*** End Sync
|
||||||
|
|
||||||
|
Flushes the synchronized update buffer so the entire frame appears
|
||||||
|
at once. Always paired with ~begin-sync~. Default is a no-op.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/backend/classes.lisp
|
||||||
(defgeneric end-sync (backend)
|
(defgeneric end-sync (backend)
|
||||||
(:method ((b backend)) (values)))
|
(:method ((b backend)) (values)))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
*** Read Event
|
||||||
|
|
||||||
|
Reads the next input event from the terminal. Blocks until an event
|
||||||
|
arrives or the timeout expires. Returns (values keyword event-data).
|
||||||
|
The default method returns ~(values nil nil)~ — no events available.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/backend/classes.lisp
|
||||||
(defgeneric read-event (backend &key timeout)
|
(defgeneric read-event (backend &key timeout)
|
||||||
(:method ((b backend) &key timeout) (values nil nil)))
|
(:method ((b backend) &key timeout) (values nil nil)))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
*** Enable Mouse
|
||||||
|
|
||||||
|
Enables SGR mouse tracking so mouse click and motion events are
|
||||||
|
reported as input. Default is a no-op on backends that don't
|
||||||
|
support mouse input.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/backend/classes.lisp
|
||||||
(defgeneric enable-mouse (backend)
|
(defgeneric enable-mouse (backend)
|
||||||
(:method ((b backend)) (values)))
|
(:method ((b backend)) (values)))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
*** Enable Bracketed Paste
|
||||||
|
|
||||||
|
Enables bracketed paste mode so the application can distinguish
|
||||||
|
pasted text from typed input. Default is a no-op.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/backend/classes.lisp
|
||||||
(defgeneric enable-bracketed-paste (backend)
|
(defgeneric enable-bracketed-paste (backend)
|
||||||
(:method ((b backend)) (values)))
|
(:method ((b backend)) (values)))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
*** Capable-P Feature Query
|
||||||
|
|
||||||
|
Queries whether the backend supports a named feature. Feature
|
||||||
|
keywords include ~:truecolor~, ~:osc8~, ~:sync~, ~:mouse~,
|
||||||
|
~:bracketed-paste~, ~:kitty-keyboard~, ~:sixel~, and
|
||||||
|
~:cursor-style~. The default method returns ~nil~ for all features.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/backend/classes.lisp
|
||||||
(defgeneric capable-p (backend feature)
|
(defgeneric capable-p (backend feature)
|
||||||
(:method ((b backend) feature)
|
(:method ((b backend) feature)
|
||||||
(declare (ignore feature))
|
(declare (ignore feature))
|
||||||
nil))
|
nil))
|
||||||
#+END_SRC
|
#+END_SRC
|
||||||
|
|
||||||
The ~&allow-other-keys~ on ~draw-text~ is important: it lets
|
|
||||||
individual backend methods accept keyword arguments they don't use
|
|
||||||
without signaling an error. The simple backend ignores styles; the
|
|
||||||
modern backend processes them.
|
|
||||||
|
|
||||||
** Simple Backend
|
** Simple Backend
|
||||||
|
|
||||||
~simple-backend~ inherits from ~backend~ and implements every
|
~simple-backend~ inherits from ~backend~ and implements every
|
||||||
operation in pure ASCII. No escape sequences, no color, no modern
|
operation in pure ASCII. No escape sequences, no color, no modern
|
||||||
features. Works in any terminal, pipe, or serial connection.
|
features. Works in any terminal, pipe, or serial connection.
|
||||||
|
|
||||||
|
*** Simple Backend Class
|
||||||
|
|
||||||
|
The ~simple-backend~ class has a single slot: ~output-stream~.
|
||||||
|
This defaults to ~*standard-output*~ but can be overridden via
|
||||||
|
the ~:output-stream~ initarg — the key extensibility point. Tests
|
||||||
|
use ~make-string-output-stream~ to capture output, while production
|
||||||
|
uses ~*standard-output*~.
|
||||||
|
|
||||||
#+BEGIN_SRC lisp :tangle ../src/backend/simple.lisp
|
#+BEGIN_SRC lisp :tangle ../src/backend/simple.lisp
|
||||||
(in-package :cl-tty.backend)
|
(in-package :cl-tty.backend)
|
||||||
|
|
||||||
@@ -388,44 +685,89 @@ features. Works in any terminal, pipe, or serial connection.
|
|||||||
((output-stream :initform *standard-output*
|
((output-stream :initform *standard-output*
|
||||||
:initarg :output-stream
|
:initarg :output-stream
|
||||||
:accessor backend-output-stream)))
|
:accessor backend-output-stream)))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
*** Make Simple Backend
|
||||||
|
|
||||||
|
Constructor function that creates a ~simple-backend~ instance. Uses
|
||||||
|
~make-instance~ with the provided output stream or falls back to
|
||||||
|
~*standard-output*~. This function is exported rather than exposing
|
||||||
|
~make-instance~ directly to provide a stable API surface.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/backend/simple.lisp
|
||||||
(defun make-simple-backend (&key output-stream)
|
(defun make-simple-backend (&key output-stream)
|
||||||
(make-instance 'simple-backend
|
(make-instance 'simple-backend
|
||||||
:output-stream (or output-stream *standard-output*)))
|
:output-stream (or output-stream *standard-output*)))
|
||||||
#+END_SRC
|
#+END_SRC
|
||||||
|
|
||||||
The ~output-stream~ initarg is the key extensibility point: tests use
|
*** Initialize Backend (Simple)
|
||||||
~make-string-output-stream~ to capture output, while production uses
|
|
||||||
~*standard-output*~.
|
Simple backend initialization is a no-op — there is no terminal
|
||||||
|
state to configure. Returns the backend instance to satisfy the
|
||||||
|
protocol contract.
|
||||||
|
|
||||||
#+BEGIN_SRC lisp :tangle ../src/backend/simple.lisp
|
#+BEGIN_SRC lisp :tangle ../src/backend/simple.lisp
|
||||||
(defmethod initialize-backend ((b simple-backend))
|
(defmethod initialize-backend ((b simple-backend))
|
||||||
b)
|
b)
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
*** Shutdown Backend (Simple)
|
||||||
|
|
||||||
|
Simple backend shutdown is a no-op — there is no terminal state to
|
||||||
|
restore. Returns multiple values to satisfy the protocol contract.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/backend/simple.lisp
|
||||||
(defmethod shutdown-backend ((b simple-backend))
|
(defmethod shutdown-backend ((b simple-backend))
|
||||||
(values))
|
(values))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
*** Backend Size (Simple)
|
||||||
|
|
||||||
|
Returns hard-coded 80x24 dimensions. A real implementation would use
|
||||||
|
ioctl or TIOCGWINSZ, but the simple backend avoids OS-specific calls
|
||||||
|
for maximum portability.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/backend/simple.lisp
|
||||||
(defmethod backend-size ((b simple-backend))
|
(defmethod backend-size ((b simple-backend))
|
||||||
;; Try ioctl, fall back to 80x24
|
;; Try ioctl, fall back to 80x24
|
||||||
(values 80 24))
|
(values 80 24))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
*** Backend Write (Simple)
|
||||||
|
|
||||||
|
Writes a string to the backend's output stream, forces the stream to
|
||||||
|
flush, and returns the length of the string. Uses ~finish-output~ to
|
||||||
|
ensure the data is actually sent, which matters for pipe and network
|
||||||
|
output.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/backend/simple.lisp
|
||||||
(defmethod backend-write ((b simple-backend) string)
|
(defmethod backend-write ((b simple-backend) string)
|
||||||
(let ((stream (backend-output-stream b)))
|
(let ((stream (backend-output-stream b)))
|
||||||
(write-string string stream)
|
(write-string string stream)
|
||||||
(finish-output stream)
|
(finish-output stream)
|
||||||
(length string)))
|
(length string)))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
*** Draw Text (Simple)
|
||||||
|
|
||||||
|
The simple backend's ~draw-text~ ignores position, color, and style
|
||||||
|
completely. It appends only the string content to the output stream.
|
||||||
|
This means simple backends are always a "scroll and dump" mode —
|
||||||
|
no cursor positioning, no escape sequences.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/backend/simple.lisp
|
||||||
(defmethod draw-text ((b simple-backend) x y string fg bg
|
(defmethod draw-text ((b simple-backend) x y string fg bg
|
||||||
&key bold italic underline reverse dim blink)
|
&key bold italic underline reverse dim blink)
|
||||||
(declare (ignore x y fg bg bold italic underline reverse dim blink))
|
(declare (ignore x y fg bg bold italic underline reverse dim blink))
|
||||||
(backend-write b string))
|
(backend-write b string))
|
||||||
#+END_SRC
|
#+END_SRC
|
||||||
|
|
||||||
~draw-text~ on simple-backend ignores position and style completely.
|
*** Simple Border Character Helper
|
||||||
It just appends the string to the output stream. This means simple
|
|
||||||
backends are always a "scroll and dump" mode — no cursor positioning.
|
|
||||||
|
|
||||||
** Border drawing
|
Returns the ASCII character for a given border position. All four
|
||||||
|
corners use ~#\+~, horizontal edges use ~#\-~, and vertical edges
|
||||||
|
use ~#\|~. No style distinction — single, double, and rounded are
|
||||||
|
identical in ASCII output.
|
||||||
|
|
||||||
#+BEGIN_SRC lisp :tangle ../src/backend/simple.lisp
|
#+BEGIN_SRC lisp :tangle ../src/backend/simple.lisp
|
||||||
(defun %simple-border-char (pos)
|
(defun %simple-border-char (pos)
|
||||||
@@ -438,8 +780,13 @@ POS is :top-left, :top-right, :bottom-left, :bottom-right,
|
|||||||
(:vertical #\|)))
|
(:vertical #\|)))
|
||||||
#+END_SRC
|
#+END_SRC
|
||||||
|
|
||||||
All four corners use ~#\+~, edges use ~#\-~ and ~#\|~. No style
|
*** Draw Border (Simple)
|
||||||
distinction — single, double, and rounded are identical in ASCII.
|
|
||||||
|
Draws a border using only newlines and spaces for positioning —
|
||||||
|
no escape sequences. This makes it compatible with pipe output.
|
||||||
|
The title rendering supports ~:left~ and ~:center~ alignment,
|
||||||
|
placing the title inside the top border line with horizontal
|
||||||
|
dashes filling the remaining space.
|
||||||
|
|
||||||
#+BEGIN_SRC lisp :tangle ../src/backend/simple.lisp
|
#+BEGIN_SRC lisp :tangle ../src/backend/simple.lisp
|
||||||
(defmethod draw-border ((b simple-backend) x y width height
|
(defmethod draw-border ((b simple-backend) x y width height
|
||||||
@@ -492,12 +839,10 @@ distinction — single, double, and rounded are identical in ASCII.
|
|||||||
(backend-write b (string br))))
|
(backend-write b (string br))))
|
||||||
#+END_SRC
|
#+END_SRC
|
||||||
|
|
||||||
~draw-border~ on the simple backend uses newlines and spaces for
|
*** Draw Rect (Simple)
|
||||||
positioning instead of ~cursor-move~ escape sequences. This makes it
|
|
||||||
compatible with pipe output. The title rendering supports ~:left~ and
|
|
||||||
~:center~ alignment, placing the title inside the top border line.
|
|
||||||
|
|
||||||
** Remaining primitives
|
Background fill is impossible without escape sequences. This method
|
||||||
|
is a no-op — it discards all arguments and returns ~(values)~.
|
||||||
|
|
||||||
#+BEGIN_SRC lisp :tangle ../src/backend/simple.lisp
|
#+BEGIN_SRC lisp :tangle ../src/backend/simple.lisp
|
||||||
(defmethod draw-rect ((b simple-backend) x y width height
|
(defmethod draw-rect ((b simple-backend) x y width height
|
||||||
@@ -505,12 +850,28 @@ compatible with pipe output. The title rendering supports ~:left~ and
|
|||||||
(declare (ignore x y width height bg))
|
(declare (ignore x y width height bg))
|
||||||
;; On simple backend, background fill is a no-op
|
;; On simple backend, background fill is a no-op
|
||||||
(values))
|
(values))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
*** Draw Link (Simple)
|
||||||
|
|
||||||
|
Hyperlinks fall back to plain text on the simple backend. The URL
|
||||||
|
parameter is discarded entirely; the visible text is rendered via
|
||||||
|
~draw-text~ with no styling.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/backend/simple.lisp
|
||||||
(defmethod draw-link ((b simple-backend) x y string url
|
(defmethod draw-link ((b simple-backend) x y string url
|
||||||
&key fg bg)
|
&key fg bg)
|
||||||
(declare (ignore url fg bg))
|
(declare (ignore url fg bg))
|
||||||
(draw-text b x y string nil nil))
|
(draw-text b x y string nil nil))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
*** Draw Ellipsis (Simple)
|
||||||
|
|
||||||
|
Renders "..." using the simple backend's positioning pattern:
|
||||||
|
newlines to reach the target row, spaces to reach the target column,
|
||||||
|
then the literal three dots. No escape sequences are used.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/backend/simple.lisp
|
||||||
(defmethod draw-ellipsis ((b simple-backend) x y width
|
(defmethod draw-ellipsis ((b simple-backend) x y width
|
||||||
&key fg bg)
|
&key fg bg)
|
||||||
(declare (ignore width fg bg))
|
(declare (ignore width fg bg))
|
||||||
@@ -519,7 +880,3 @@ compatible with pipe output. The title rendering supports ~:left~ and
|
|||||||
(backend-write b (make-string x :initial-element #\Space))
|
(backend-write b (make-string x :initial-element #\Space))
|
||||||
(backend-write b "..."))
|
(backend-write b "..."))
|
||||||
#+END_SRC
|
#+END_SRC
|
||||||
|
|
||||||
~draw-rect~ is a no-op on simple-backend (no background fill possible
|
|
||||||
without escape sequences). ~draw-link~ falls back to plain text.
|
|
||||||
~draw-ellipsis~ positions and writes "...".
|
|
||||||
|
|||||||
@@ -37,6 +37,12 @@ carry a ~layout-node~ for position/size computed by the layout engine.
|
|||||||
|
|
||||||
* Tests
|
* Tests
|
||||||
|
|
||||||
|
** Package and test suite setup
|
||||||
|
|
||||||
|
The test package exports ~run-tests~ so it can be invoked from the
|
||||||
|
top-level test runner. ~fiveam~ imports directly for declarative
|
||||||
|
~test~ and ~is~ forms. The ~box-suite~ collects all box/text tests.
|
||||||
|
|
||||||
#+BEGIN_SRC lisp :tangle ../src/components/box-tests.lisp
|
#+BEGIN_SRC lisp :tangle ../src/components/box-tests.lisp
|
||||||
(defpackage :cl-tty-box-test
|
(defpackage :cl-tty-box-test
|
||||||
(:use :cl :fiveam :cl-tty.backend :cl-tty.layout :cl-tty.box)
|
(:use :cl :fiveam :cl-tty.backend :cl-tty.layout :cl-tty.box)
|
||||||
@@ -45,25 +51,54 @@ carry a ~layout-node~ for position/size computed by the layout engine.
|
|||||||
|
|
||||||
(def-suite box-suite :description "Box renderable tests")
|
(def-suite box-suite :description "Box renderable tests")
|
||||||
(in-suite box-suite)
|
(in-suite box-suite)
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** Test runner entry point
|
||||||
|
|
||||||
|
~run-tests~ is the entry point called from the top-level
|
||||||
|
~run-all-tests.lisp~. It runs the ~box-suite~, explains results to
|
||||||
|
stdout, and exits cleanly with ~uiop:quit~.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/components/box-tests.lisp
|
||||||
(defun run-tests ()
|
(defun run-tests ()
|
||||||
(let ((result (run 'box-suite)))
|
(let ((result (run 'box-suite)))
|
||||||
(fiveam:explain! result)
|
(fiveam:explain! result)
|
||||||
(uiop:quit 0)))
|
(uiop:quit 0)))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** Capturing backend helper
|
||||||
|
|
||||||
|
~make-capturing-backend~ creates a backend that writes to a
|
||||||
|
~string-output-stream~ so tests can inspect rendered output without
|
||||||
|
actual terminal I/O. Returns the backend and stream as multiple
|
||||||
|
values.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/components/box-tests.lisp
|
||||||
(defun make-capturing-backend ()
|
(defun make-capturing-backend ()
|
||||||
(let* ((s (make-string-output-stream))
|
(let* ((s (make-string-output-stream))
|
||||||
(b (make-modern-backend :output-stream s)))
|
(b (make-modern-backend :output-stream s)))
|
||||||
(values b s)))
|
(values b s)))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
;; ── Box Tests ─────────────────────────────────────────────────
|
** Test: box-creates-with-defaults
|
||||||
|
|
||||||
|
Verify that a bare ~make-box~ returns a ~box~ instance and
|
||||||
|
automatically creates a ~layout-node~ through inheritance.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/components/box-tests.lisp
|
||||||
(test box-creates-with-defaults
|
(test box-creates-with-defaults
|
||||||
"A box created with no arguments has reasonable defaults"
|
"A box created with no arguments has reasonable defaults"
|
||||||
(let ((b (make-box)))
|
(let ((b (make-box)))
|
||||||
(is (typep b 'box))
|
(is (typep b 'box))
|
||||||
(is (typep (box-layout-node b) 'layout-node))))
|
(is (typep (box-layout-node b) 'layout-node))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** Test: box-renders-border
|
||||||
|
|
||||||
|
Verify that a box with ~:border-style :single~ draws the four corner
|
||||||
|
characters (┌ ┐ └ ┘) in the output stream.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/components/box-tests.lisp
|
||||||
(test box-renders-border
|
(test box-renders-border
|
||||||
"A box with border draws border characters"
|
"A box with border draws border characters"
|
||||||
(multiple-value-bind (b s) (make-capturing-backend)
|
(multiple-value-bind (b s) (make-capturing-backend)
|
||||||
@@ -75,7 +110,14 @@ carry a ~layout-node~ for position/size computed by the layout engine.
|
|||||||
(is (search "┐" out) "top-right corner")
|
(is (search "┐" out) "top-right corner")
|
||||||
(is (search "└" out) "bottom-left corner")
|
(is (search "└" out) "bottom-left corner")
|
||||||
(is (search "┘" out) "bottom-right corner")))))
|
(is (search "┘" out) "bottom-right corner")))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** Test: box-renders-background
|
||||||
|
|
||||||
|
Verify that a box with ~:bg :red~ emits SGR background color codes
|
||||||
|
(41m) in the output stream.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/components/box-tests.lisp
|
||||||
(test box-renders-background
|
(test box-renders-background
|
||||||
"A box with background color fills interior"
|
"A box with background color fills interior"
|
||||||
(multiple-value-bind (b s) (make-capturing-backend)
|
(multiple-value-bind (b s) (make-capturing-backend)
|
||||||
@@ -85,7 +127,14 @@ carry a ~layout-node~ for position/size computed by the layout engine.
|
|||||||
(let ((out (get-output-stream-string s)))
|
(let ((out (get-output-stream-string s)))
|
||||||
(is (search "┌" out) "border with background")
|
(is (search "┌" out) "border with background")
|
||||||
(is (search "41m" out) "SGR background for red")))))
|
(is (search "41m" out) "SGR background for red")))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** Test: box-renders-title
|
||||||
|
|
||||||
|
Verify that a title string appears in the rendered output stream
|
||||||
|
when ~:title~ is provided.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/components/box-tests.lisp
|
||||||
(test box-renders-title
|
(test box-renders-title
|
||||||
"A box with title renders the title text"
|
"A box with title renders the title text"
|
||||||
(multiple-value-bind (b s) (make-capturing-backend)
|
(multiple-value-bind (b s) (make-capturing-backend)
|
||||||
@@ -94,7 +143,14 @@ carry a ~layout-node~ for position/size computed by the layout engine.
|
|||||||
(render-box bx b)
|
(render-box bx b)
|
||||||
(let ((out (get-output-stream-string s)))
|
(let ((out (get-output-stream-string s)))
|
||||||
(is (search "Hello" out) "title text should appear")))))
|
(is (search "Hello" out) "title text should appear")))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** Test: box-without-border
|
||||||
|
|
||||||
|
Verify that ~:border-style nil~ suppresses corner characters but
|
||||||
|
background fill rendering continues to work.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/components/box-tests.lisp
|
||||||
(test box-without-border
|
(test box-without-border
|
||||||
"A box with border-style nil draws no border"
|
"A box with border-style nil draws no border"
|
||||||
(multiple-value-bind (b s) (make-capturing-backend)
|
(multiple-value-bind (b s) (make-capturing-backend)
|
||||||
@@ -104,7 +160,14 @@ carry a ~layout-node~ for position/size computed by the layout engine.
|
|||||||
(let ((out (get-output-stream-string s)))
|
(let ((out (get-output-stream-string s)))
|
||||||
(is (search "41m" out) "background still renders")
|
(is (search "41m" out) "background still renders")
|
||||||
(is-false (search "┌" out) "no top-left corner")))))
|
(is-false (search "┌" out) "no top-left corner")))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** Test: box-zero-size
|
||||||
|
|
||||||
|
Verify that a box with zero width and height produces no output
|
||||||
|
(triggers the early-return guard in ~render-box~).
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/components/box-tests.lisp
|
||||||
(test box-zero-size
|
(test box-zero-size
|
||||||
"A box with any zero dimension renders nothing"
|
"A box with any zero dimension renders nothing"
|
||||||
(multiple-value-bind (b s) (make-capturing-backend)
|
(multiple-value-bind (b s) (make-capturing-backend)
|
||||||
@@ -113,7 +176,14 @@ carry a ~layout-node~ for position/size computed by the layout engine.
|
|||||||
(render-box bx b)
|
(render-box bx b)
|
||||||
(is (string= (get-output-stream-string s) "")
|
(is (string= (get-output-stream-string s) "")
|
||||||
"zero-size box produces no output"))))
|
"zero-size box produces no output"))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** Test: box-single-column
|
||||||
|
|
||||||
|
Verify that a box with width 1 produces no output — ~draw-border~
|
||||||
|
requires at least 2 columns to draw corner and edge characters.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/components/box-tests.lisp
|
||||||
(test box-single-column
|
(test box-single-column
|
||||||
"A box with width 1 renders nothing (needs min 2 for border)"
|
"A box with width 1 renders nothing (needs min 2 for border)"
|
||||||
(multiple-value-bind (b s) (make-capturing-backend)
|
(multiple-value-bind (b s) (make-capturing-backend)
|
||||||
@@ -122,7 +192,14 @@ carry a ~layout-node~ for position/size computed by the layout engine.
|
|||||||
(render-box bx b)
|
(render-box bx b)
|
||||||
(is (string= (get-output-stream-string s) "")
|
(is (string= (get-output-stream-string s) "")
|
||||||
"width=1 box renders nothing"))))
|
"width=1 box renders nothing"))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** Test: box-minimum-size
|
||||||
|
|
||||||
|
Verify that a 2x2 box (the minimum viable size for border rendering)
|
||||||
|
still produces corner characters in the output.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/components/box-tests.lisp
|
||||||
(test box-minimum-size
|
(test box-minimum-size
|
||||||
"A box with minimum non-zero size still renders"
|
"A box with minimum non-zero size still renders"
|
||||||
(multiple-value-bind (b s) (make-capturing-backend)
|
(multiple-value-bind (b s) (make-capturing-backend)
|
||||||
@@ -131,15 +208,27 @@ carry a ~layout-node~ for position/size computed by the layout engine.
|
|||||||
(render-box bx b)
|
(render-box bx b)
|
||||||
(let ((out (get-output-stream-string s)))
|
(let ((out (get-output-stream-string s)))
|
||||||
(is (search "┌" out) "2x2 box still has borders")))))
|
(is (search "┌" out) "2x2 box still has borders")))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
;; ── Text and Span Tests ───────────────────────────────────────
|
** Test: text-creates-with-defaults
|
||||||
|
|
||||||
|
Verify that ~make-text~ with an empty string returns a ~text~
|
||||||
|
instance and creates a ~layout-node~.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/components/box-tests.lisp
|
||||||
(test text-creates-with-defaults
|
(test text-creates-with-defaults
|
||||||
"A text created with no arguments has reasonable defaults"
|
"A text created with no arguments has reasonable defaults"
|
||||||
(let ((txt (make-text "")))
|
(let ((txt (make-text "")))
|
||||||
(is (typep txt 'text))
|
(is (typep txt 'text))
|
||||||
(is (typep (text-layout-node txt) 'layout-node))))
|
(is (typep (text-layout-node txt) 'layout-node))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** Test: text-renders-content
|
||||||
|
|
||||||
|
Verify that text content appears in the captured output stream after
|
||||||
|
rendering.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/components/box-tests.lisp
|
||||||
(test text-renders-content
|
(test text-renders-content
|
||||||
"A text renders its content at position"
|
"A text renders its content at position"
|
||||||
(multiple-value-bind (b s) (make-capturing-backend)
|
(multiple-value-bind (b s) (make-capturing-backend)
|
||||||
@@ -148,7 +237,14 @@ carry a ~layout-node~ for position/size computed by the layout engine.
|
|||||||
(render-text tx b)
|
(render-text tx b)
|
||||||
(let ((out (get-output-stream-string s)))
|
(let ((out (get-output-stream-string s)))
|
||||||
(is (search "Hello" out) "content should appear")))))
|
(is (search "Hello" out) "content should appear")))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** Test: text-empty-string
|
||||||
|
|
||||||
|
Verify that an empty string produces no output (triggers the
|
||||||
|
early-return guard in ~render-text~).
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/components/box-tests.lisp
|
||||||
(test text-empty-string
|
(test text-empty-string
|
||||||
"Empty text produces no output"
|
"Empty text produces no output"
|
||||||
(multiple-value-bind (b s) (make-capturing-backend)
|
(multiple-value-bind (b s) (make-capturing-backend)
|
||||||
@@ -157,7 +253,14 @@ carry a ~layout-node~ for position/size computed by the layout engine.
|
|||||||
(render-text tx b)
|
(render-text tx b)
|
||||||
(is (string= (get-output-stream-string s) "")
|
(is (string= (get-output-stream-string s) "")
|
||||||
"empty string produces no output"))))
|
"empty string produces no output"))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** Test: text-truncates-when-no-wrap
|
||||||
|
|
||||||
|
Verify that ~:wrap-mode :none~ truncates the content string to fit
|
||||||
|
within the available width, producing only the first N characters.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/components/box-tests.lisp
|
||||||
(test text-truncates-when-no-wrap
|
(test text-truncates-when-no-wrap
|
||||||
"Text with wrap-mode :none truncates at width"
|
"Text with wrap-mode :none truncates at width"
|
||||||
(multiple-value-bind (b s) (make-capturing-backend)
|
(multiple-value-bind (b s) (make-capturing-backend)
|
||||||
@@ -167,7 +270,14 @@ carry a ~layout-node~ for position/size computed by the layout engine.
|
|||||||
(render-text tx b)
|
(render-text tx b)
|
||||||
(let ((out (get-output-stream-string s)))
|
(let ((out (get-output-stream-string s)))
|
||||||
(is (search "Hello" out) "truncated to first 5 chars")))))
|
(is (search "Hello" out) "truncated to first 5 chars")))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** Test: text-word-wraps
|
||||||
|
|
||||||
|
Verify that ~:wrap-mode :word~ breaks lines at word boundaries,
|
||||||
|
distributing words across successive rows.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/components/box-tests.lisp
|
||||||
(test text-word-wraps
|
(test text-word-wraps
|
||||||
"Text with wrap-mode :word wraps at word boundaries"
|
"Text with wrap-mode :word wraps at word boundaries"
|
||||||
(multiple-value-bind (b s) (make-capturing-backend)
|
(multiple-value-bind (b s) (make-capturing-backend)
|
||||||
@@ -178,7 +288,14 @@ carry a ~layout-node~ for position/size computed by the layout engine.
|
|||||||
(is (search "Hello" out) "first line")
|
(is (search "Hello" out) "first line")
|
||||||
(is (search "brave" out) "second line")
|
(is (search "brave" out) "second line")
|
||||||
(is (search "new" out) "third line")))))
|
(is (search "new" out) "third line")))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** Test: text-word-wrap-single-word
|
||||||
|
|
||||||
|
Verify that a single word longer than the available width is
|
||||||
|
hard-broken at character boundaries into ~max-width~-sized chunks.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/components/box-tests.lisp
|
||||||
(test text-word-wrap-single-word
|
(test text-word-wrap-single-word
|
||||||
"A word longer than width is hard-broken at max-width"
|
"A word longer than width is hard-broken at max-width"
|
||||||
(multiple-value-bind (b s) (make-capturing-backend)
|
(multiple-value-bind (b s) (make-capturing-backend)
|
||||||
@@ -188,14 +305,28 @@ carry a ~layout-node~ for position/size computed by the layout engine.
|
|||||||
(let ((out (get-output-stream-string s)))
|
(let ((out (get-output-stream-string s)))
|
||||||
(is (search "Hel" out) "first chunk is Hel")
|
(is (search "Hel" out) "first chunk is Hel")
|
||||||
(is (search "lo" out) "second chunk is lo")))))
|
(is (search "lo" out) "second chunk is lo")))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** Test: span-creates-with-attributes
|
||||||
|
|
||||||
|
Verify that ~span~ stores its text content and style attributes
|
||||||
|
correctly, with unset attributes defaulting to ~nil~.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/components/box-tests.lisp
|
||||||
(test span-creates-with-attributes
|
(test span-creates-with-attributes
|
||||||
"A span has text and optional style attributes"
|
"A span has text and optional style attributes"
|
||||||
(let ((s (span "bold text" :bold t)))
|
(let ((s (span "bold text" :bold t)))
|
||||||
(is (string= (span-text s) "bold text"))
|
(is (string= (span-text s) "bold text"))
|
||||||
(is-true (span-bold s))
|
(is-true (span-bold s))
|
||||||
(is-false (span-italic s))))
|
(is-false (span-italic s))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** Test: make-text-with-spans
|
||||||
|
|
||||||
|
Verify that ~make-text~ with ~:spans~ stores the provided span
|
||||||
|
objects and they are accessible via ~text-spans~.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/components/box-tests.lisp
|
||||||
(test make-text-with-spans
|
(test make-text-with-spans
|
||||||
"Text with spans stores span objects"
|
"Text with spans stores span objects"
|
||||||
(let* ((sp (list (span "Hello" :bold t)
|
(let* ((sp (list (span "Hello" :bold t)
|
||||||
@@ -212,7 +343,8 @@ carry a ~layout-node~ for position/size computed by the layout engine.
|
|||||||
|
|
||||||
~box~ inherits from ~dirty-mixin~ so changes (resize, title update,
|
~box~ inherits from ~dirty-mixin~ so changes (resize, title update,
|
||||||
color change) trigger incremental re-render. The ~layout-node~ slot
|
color change) trigger incremental re-render. The ~layout-node~ slot
|
||||||
holds the computed position and size from the layout engine.
|
holds the computed position and size from the layout engine. Border
|
||||||
|
style, title, alignment, and colors are all configurable slots.
|
||||||
|
|
||||||
#+BEGIN_SRC lisp :tangle ../src/components/box.lisp
|
#+BEGIN_SRC lisp :tangle ../src/components/box.lisp
|
||||||
(in-package :cl-tty.box)
|
(in-package :cl-tty.box)
|
||||||
@@ -229,8 +361,11 @@ holds the computed position and size from the layout engine.
|
|||||||
(bg :initform nil :initarg :bg :accessor box-bg)))
|
(bg :initform nil :initarg :bg :accessor box-bg)))
|
||||||
#+END_SRC
|
#+END_SRC
|
||||||
|
|
||||||
|
** make-box constructor
|
||||||
|
|
||||||
The constructor wraps ~make-instance~ and passes layout parameters
|
The constructor wraps ~make-instance~ and passes layout parameters
|
||||||
through to the layout node:
|
through to the layout node. Width and height are optional; when
|
||||||
|
omitted the layout engine will compute them from parent constraints.
|
||||||
|
|
||||||
#+BEGIN_SRC lisp :tangle ../src/components/box.lisp
|
#+BEGIN_SRC lisp :tangle ../src/components/box.lisp
|
||||||
(defun make-box (&key (border-style :single) title
|
(defun make-box (&key (border-style :single) title
|
||||||
@@ -248,9 +383,15 @@ through to the layout node:
|
|||||||
:direction :column)))
|
:direction :column)))
|
||||||
#+END_SRC
|
#+END_SRC
|
||||||
|
|
||||||
|
** render-box function
|
||||||
|
|
||||||
~render-box~ draws the border at the component's layout position.
|
~render-box~ draws the border at the component's layout position.
|
||||||
It handles zero-size (returns immediately) and optional background
|
It handles zero-size (returns immediately) and optional background
|
||||||
fill.
|
fill. The early return for ~(< w 2)~ is important: ~draw-border~
|
||||||
|
requires at least 2 columns of width to draw corner characters.
|
||||||
|
Title rendering supports ~:left~, ~:center~, and ~:right~ alignment
|
||||||
|
with automatic truncation when the title is wider than available
|
||||||
|
content area (width-4 when border is present).
|
||||||
|
|
||||||
#+BEGIN_SRC lisp :tangle ../src/components/box.lisp
|
#+BEGIN_SRC lisp :tangle ../src/components/box.lisp
|
||||||
(defun render-box (box backend)
|
(defun render-box (box backend)
|
||||||
@@ -282,20 +423,16 @@ fill.
|
|||||||
(t (draw-text backend tx ty display fg bg))))))))
|
(t (draw-text backend tx ty display fg bg))))))))
|
||||||
#+END_SRC
|
#+END_SRC
|
||||||
|
|
||||||
The early return for ~(< w 2)~ is important: ~draw-border~ requires
|
|
||||||
at least 2 columns of width to draw corner characters.
|
|
||||||
|
|
||||||
** Span class
|
** Span class
|
||||||
|
|
||||||
~span~ represents an inline styled segment within a Text component.
|
~span~ represents an inline styled segment within a Text component.
|
||||||
Multiple spans let a single Text contain bold, colored, or italicized
|
Multiple spans let a single Text contain bold, colored, or italicized
|
||||||
runs.
|
runs. Each style attribute is a separate slot so consumers can
|
||||||
|
inspect and apply them individually.
|
||||||
|
|
||||||
#+BEGIN_SRC lisp :tangle ../src/components/text.lisp
|
#+BEGIN_SRC lisp :tangle ../src/components/text.lisp
|
||||||
(in-package :cl-tty.box)
|
(in-package :cl-tty.box)
|
||||||
|
|
||||||
;; ── Text Renderable ────────────────────────────────────────────
|
|
||||||
|
|
||||||
(defclass span ()
|
(defclass span ()
|
||||||
((text :initarg :text :accessor span-text)
|
((text :initarg :text :accessor span-text)
|
||||||
(bold :initform nil :initarg :bold :accessor span-bold)
|
(bold :initform nil :initarg :bold :accessor span-bold)
|
||||||
@@ -305,7 +442,15 @@ runs.
|
|||||||
(dim :initform nil :initarg :dim :accessor span-dim)
|
(dim :initform nil :initarg :dim :accessor span-dim)
|
||||||
(fg :initform nil :initarg :fg :accessor span-fg)
|
(fg :initform nil :initarg :fg :accessor span-fg)
|
||||||
(bg :initform nil :initarg :bg :accessor span-bg)))
|
(bg :initform nil :initarg :bg :accessor span-bg)))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** span constructor
|
||||||
|
|
||||||
|
~span~ is a convenience function for creating ~span~ instances with
|
||||||
|
keyword arguments for all style attributes. A ~nil~ default means
|
||||||
|
"inherit/no-change" when merged with parent styling context.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/components/text.lisp
|
||||||
(defun span (text &key bold italic underline reverse dim fg bg)
|
(defun span (text &key bold italic underline reverse dim fg bg)
|
||||||
(make-instance 'span
|
(make-instance 'span
|
||||||
:text text :bold bold :italic italic
|
:text text :bold bold :italic italic
|
||||||
@@ -316,8 +461,9 @@ runs.
|
|||||||
** Text class
|
** Text class
|
||||||
|
|
||||||
~text~ renders a string at a layout position with word-wrapping.
|
~text~ renders a string at a layout position with word-wrapping.
|
||||||
Spans are stored but not yet rendered with per-run styling in the
|
Spans are stored for future per-run styling but the current
|
||||||
current implementation.
|
implementation renders all content as plain text. It inherits from
|
||||||
|
~dirty-mixin~ so content, color, or size changes trigger re-render.
|
||||||
|
|
||||||
#+BEGIN_SRC lisp :tangle ../src/components/text.lisp
|
#+BEGIN_SRC lisp :tangle ../src/components/text.lisp
|
||||||
(defclass text (dirty-mixin)
|
(defclass text (dirty-mixin)
|
||||||
@@ -328,7 +474,16 @@ current implementation.
|
|||||||
(fg :initform nil :initarg :fg :accessor text-fg)
|
(fg :initform nil :initarg :fg :accessor text-fg)
|
||||||
(bg :initform nil :initarg :bg :accessor text-bg)
|
(bg :initform nil :initarg :bg :accessor text-bg)
|
||||||
(wrap-mode :initform :word :initarg :wrap-mode :accessor text-wrap-mode)))
|
(wrap-mode :initform :word :initarg :wrap-mode :accessor text-wrap-mode)))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** make-text constructor
|
||||||
|
|
||||||
|
~make-text~ is a convenience constructor that accepts layout
|
||||||
|
dimensions and content parameters. It defaults ~wrap-mode~ to ~:word~
|
||||||
|
so text wraps by default, and creates a ~:column~-oriented layout
|
||||||
|
node.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/components/text.lisp
|
||||||
(defun make-text (content &key fg bg wrap-mode width height spans)
|
(defun make-text (content &key fg bg wrap-mode width height spans)
|
||||||
(make-instance 'text
|
(make-instance 'text
|
||||||
:content content
|
:content content
|
||||||
@@ -339,9 +494,13 @@ current implementation.
|
|||||||
:width width :height height)))
|
:width width :height height)))
|
||||||
#+END_SRC
|
#+END_SRC
|
||||||
|
|
||||||
|
** render-text function
|
||||||
|
|
||||||
~render-text~ handles both wrap modes. For ~:word~, it calls
|
~render-text~ handles both wrap modes. For ~:word~, it calls
|
||||||
~word-wrap~ to break the content into lines, then renders each line
|
~word-wrap~ to break the content into lines, then renders each line
|
||||||
at successive row positions.
|
at successive row positions. For ~:none~, it truncates the content to
|
||||||
|
fit the width in a single line. Empty content or zero dimensions
|
||||||
|
triggers an early return.
|
||||||
|
|
||||||
#+BEGIN_SRC lisp :tangle ../src/components/text.lisp
|
#+BEGIN_SRC lisp :tangle ../src/components/text.lisp
|
||||||
(defun render-text (text-object backend)
|
(defun render-text (text-object backend)
|
||||||
@@ -373,7 +532,8 @@ at successive row positions.
|
|||||||
|
|
||||||
~word-wrap~ implements the line-breaking algorithm. It splits the
|
~word-wrap~ implements the line-breaking algorithm. It splits the
|
||||||
input into words, then packs them into lines respecting ~max-width~.
|
input into words, then packs them into lines respecting ~max-width~.
|
||||||
Words that exceed ~max-width~ are hard-broken at character boundaries.
|
Words that exceed ~max-width~ are hard-broken at character boundaries
|
||||||
|
in chunks of ~max-width~ to ensure no line exceeds the limit.
|
||||||
|
|
||||||
#+BEGIN_SRC lisp :tangle ../src/components/text.lisp
|
#+BEGIN_SRC lisp :tangle ../src/components/text.lisp
|
||||||
(defun word-wrap (text max-width)
|
(defun word-wrap (text max-width)
|
||||||
@@ -405,7 +565,12 @@ Words that exceed ~max-width~ are hard-broken at character boundaries.
|
|||||||
(or (nreverse lines) (list "")))))
|
(or (nreverse lines) (list "")))))
|
||||||
#+END_SRC
|
#+END_SRC
|
||||||
|
|
||||||
~split-string~ tokenizes on whitespace (space, tab, newline):
|
** split-string utility
|
||||||
|
|
||||||
|
~split-string~ tokenizes on whitespace characters (space, tab,
|
||||||
|
newline). It uses ~position-if~ to find delimiters and builds the
|
||||||
|
word list iteratively. Consecutive delimiters are collapsed
|
||||||
|
(only one advance per delimiter character).
|
||||||
|
|
||||||
#+BEGIN_SRC lisp :tangle ../src/components/text.lisp
|
#+BEGIN_SRC lisp :tangle ../src/components/text.lisp
|
||||||
(defun split-string (string)
|
(defun split-string (string)
|
||||||
|
|||||||
@@ -11,6 +11,100 @@ ScrollBox and TabBar. It uses ~cl-tty.backend~, ~cl-tty.box~,
|
|||||||
The package exports both ScrollBox and TabBar classes, constructors,
|
The package exports both ScrollBox and TabBar classes, constructors,
|
||||||
accessors, and navigation functions.
|
accessors, and navigation functions.
|
||||||
|
|
||||||
|
* Why a Separate Package?
|
||||||
|
|
||||||
|
The base ~cl-tty.box~ package was designed for the fundamental
|
||||||
|
renderable types — box, text, spans, dirty-tracking, the render
|
||||||
|
pipeline, and the theme engine. These are the building blocks that
|
||||||
|
virtually every component depends on. Container components —
|
||||||
|
ScrollBox and TabBar — are higher-level composite widgets with
|
||||||
|
specific behavioral contracts (viewport scrolling, tab navigation,
|
||||||
|
keyboard dispatch) that are not needed by every component user.
|
||||||
|
|
||||||
|
Separating them into ~cl-tty.container~ achieves two things:
|
||||||
|
|
||||||
|
1. It keeps ~cl-tty.box~ lean. Users who only need basic
|
||||||
|
renderables (boxes, text) do not pull in scroll-logic or
|
||||||
|
tab-navigation code. This is especially important for the
|
||||||
|
test suite — container tests have their own setup, backend
|
||||||
|
capture, and assertion patterns that are unrelated to the
|
||||||
|
base component tests.
|
||||||
|
|
||||||
|
2. It establishes a clean dependency boundary. ~cl-tty.box~
|
||||||
|
depends only on ~cl-tty.backend~ and ~cl-tty.layout~.
|
||||||
|
Container components additionally depend on ~cl-tty.input~,
|
||||||
|
because TabBar handles key events. By putting container
|
||||||
|
code in its own package, we avoid creating a circular or
|
||||||
|
incidental dependency between the input system and the
|
||||||
|
base component layer.
|
||||||
|
|
||||||
|
* What the Container Package Provides
|
||||||
|
|
||||||
|
The package exports two full component families:
|
||||||
|
|
||||||
|
- **ScrollBox**: A viewport-based container that holds a list of
|
||||||
|
child components and provides vertical/horizontal scrolling with
|
||||||
|
viewport culling (only visible children are rendered), scrollbar
|
||||||
|
display, sticky-scroll (auto-scroll to bottom on new content),
|
||||||
|
and scroll-offset clamping. ScrollBox inherits ~dirty-mixin~,
|
||||||
|
implements the component protocol (~render~, ~component-children~,
|
||||||
|
~component-layout-node~), and integrates with the layout engine.
|
||||||
|
Its constructor ~make-scroll-box~ accepts ~:children~,
|
||||||
|
~:scroll-y~, ~:scroll-x~, and ~:sticky-scroll-p~ keyword args.
|
||||||
|
|
||||||
|
- **TabBar**: A horizontal tab-navigation widget that manages a
|
||||||
|
list of named tabs, tracks the active tab, and dispatches
|
||||||
|
keyboard events (Left/Right for prev/next). TabBar also inherits
|
||||||
|
~dirty-mixin~ and implements ~render~ and ~component-layout-node~.
|
||||||
|
It provides ~tab-bar-add~ for dynamic tab creation, ~tab-bar-next~
|
||||||
|
/ ~tab-bar-prev~ for cycling, ~tab-bar-select~ for direct
|
||||||
|
activation, and ~tab-bar-handle-key~ for keyboard integration.
|
||||||
|
|
||||||
|
Both components export the generic ~render~ method, allowing the
|
||||||
|
rendering pipeline to dispatch ~(render instance backend)~ uniformly.
|
||||||
|
|
||||||
|
* Design Decisions: ScrollBox and TabBar in One Package
|
||||||
|
|
||||||
|
ScrollBox and TabBar are very different widgets — one manages a
|
||||||
|
scrollable viewport, the other renders a row of selectable labels.
|
||||||
|
They are kept in the same package rather than split into
|
||||||
|
~cl-tty.scroll-box~ and ~cl-tty.tab-bar~ for several reasons:
|
||||||
|
|
||||||
|
1. **Shared dependencies**: Both components :use the same four
|
||||||
|
packages (~cl-tty.backend~, ~cl-tty.box~, ~cl-tty.layout~,
|
||||||
|
~cl-tty.input~). They both inherit from ~dirty-mixin~ and
|
||||||
|
implement the component protocol. A shared package avoids
|
||||||
|
duplicating the ~:use~ and ~:export~ boilerplate.
|
||||||
|
|
||||||
|
2. **Co-located tests**: The test suite
|
||||||
|
(~tests/scrollbox-tabbar-tests.lisp~) tests both components
|
||||||
|
in one file and one FiveAM suite. They share test helpers,
|
||||||
|
backend-capture patterns, and the same package dependency.
|
||||||
|
Keeping them in one source package means the test defpackage
|
||||||
|
only needs one ~:use~ clause for the container, and symbols
|
||||||
|
from both components are visible together.
|
||||||
|
|
||||||
|
3. **Common contract**: Both components are "containers" in the
|
||||||
|
architectural sense — they manage a collection of sub-items
|
||||||
|
(children or tabs) and provide navigation over them. A
|
||||||
|
TabBar is conceptually a horizontal container of selectable
|
||||||
|
entries; a ScrollBox is a vertical container with scroll.
|
||||||
|
Placing them under the same ~:cl-tty.container~ namespace
|
||||||
|
signals to users that these are the composite widget types,
|
||||||
|
as opposed to the atomic renderables in ~:cl-tty.box~.
|
||||||
|
|
||||||
|
4. **Practical usage patterns**: In typical TUI applications, a
|
||||||
|
TabBar switches between views and a ScrollBox displays the
|
||||||
|
content of each view. They are often used together in the
|
||||||
|
same composition. Having them in one package eliminates
|
||||||
|
cross-package qualification or redundant ~:import-from~
|
||||||
|
declarations when building combined layouts.
|
||||||
|
|
||||||
|
If either component grows substantial internal logic in the future
|
||||||
|
(say, ScrollBox develops virtual scrolling, infinite loading, or
|
||||||
|
its own input model), it could be split into its own package at
|
||||||
|
that point. The current scope favors simplicity and co-location.
|
||||||
|
|
||||||
* Package Definition
|
* Package Definition
|
||||||
|
|
||||||
#+BEGIN_SRC lisp :tangle ../src/components/container-package.lisp
|
#+BEGIN_SRC lisp :tangle ../src/components/container-package.lisp
|
||||||
|
|||||||
@@ -36,6 +36,9 @@ If detection can't determine modern capability, it falls back to
|
|||||||
- ~*detected-backend*~ — variable
|
- ~*detected-backend*~ — variable
|
||||||
Cache for detection result. ~nil~ = not yet detected.
|
Cache for detection result. ~nil~ = not yet detected.
|
||||||
|
|
||||||
|
- ~query-terminal~ — function
|
||||||
|
Low-level escape sequence query helper shared by probes.
|
||||||
|
|
||||||
* Plan
|
* Plan
|
||||||
|
|
||||||
See =docs/plans/2026-05-11-terminal-detection.md= for implementation tasks.
|
See =docs/plans/2026-05-11-terminal-detection.md= for implementation tasks.
|
||||||
@@ -66,20 +69,36 @@ See =docs/plans/2026-05-11-terminal-detection.md= for implementation tasks.
|
|||||||
Detection functions are added to the existing ~cl-tty.backend~ package.
|
Detection functions are added to the existing ~cl-tty.backend~ package.
|
||||||
No new package definition needed.
|
No new package definition needed.
|
||||||
|
|
||||||
** Environment probe
|
** Detection cache
|
||||||
|
|
||||||
Check ~COLORTERM~ first — it's the simplest and most reliable signal.
|
The ~*detected-backend*~ special variable holds the cached backend instance
|
||||||
|
after the first successful detection. Initializing it to ~nil~ gives downstream
|
||||||
|
code a simple truthiness check — ~(or *detected-backend* ...)~ — so that
|
||||||
|
~detect-backend~ returns immediately on re-entry without re-running probes.
|
||||||
|
|
||||||
|
Using a global variable rather than a closure or class slot keeps the detection
|
||||||
|
path stateless and trivially resettable for testing: binding ~*detected-backend*~
|
||||||
|
to ~nil~ forces a fresh detection run.
|
||||||
|
|
||||||
#+BEGIN_SRC lisp :tangle ../src/backend/detection.lisp
|
#+BEGIN_SRC lisp :tangle ../src/backend/detection.lisp
|
||||||
(in-package :cl-tty.backend)
|
(in-package :cl-tty.backend)
|
||||||
|
|
||||||
;;; ─── Detection cache ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
(defvar *detected-backend* nil
|
(defvar *detected-backend* nil
|
||||||
"Cached backend instance from detect-backend. Nil = not yet detected.")
|
"Cached backend instance from detect-backend. Nil = not yet detected.")
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
;;; ─── Environment probe ──────────────────────────────────────────────────────
|
** Environment probe
|
||||||
|
|
||||||
|
The ~COLORTERM~ environment variable is the single most reliable signal for
|
||||||
|
truecolor support. It is set by modern terminal emulators (kitty, Alacritty,
|
||||||
|
GNOME Terminal, iTerm2, Windows Terminal) and has near-zero false-positive
|
||||||
|
rate. Checking it first avoids the I/O costs and race conditions of escape
|
||||||
|
sequence queries.
|
||||||
|
|
||||||
|
Case-insensitive matching via ~char-equal~ handles variances across platforms
|
||||||
|
(GNOME Terminal uses ~truecolor~, some Windows builds use ~24bit~).
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/backend/detection.lisp
|
||||||
(defun detect-backend-by-env ()
|
(defun detect-backend-by-env ()
|
||||||
"Check COLORTERM environment variable for modern terminal support.
|
"Check COLORTERM environment variable for modern terminal support.
|
||||||
Returns :modern if COLORTERM contains 'truecolor' or '24bit', nil otherwise."
|
Returns :modern if COLORTERM contains 'truecolor' or '24bit', nil otherwise."
|
||||||
@@ -92,36 +111,36 @@ Returns :modern if COLORTERM contains 'truecolor' or '24bit', nil otherwise."
|
|||||||
|
|
||||||
** TTY probe
|
** TTY probe
|
||||||
|
|
||||||
Check if stdout is connected to a terminal (not a pipe or file).
|
The ~interactive-stream-p~ function from the CL standard reliably distinguishes
|
||||||
|
real terminals from pipes and redirected files. If stdout is not a terminal,
|
||||||
|
escape sequence queries will hang or produce garbage, so this check gates all
|
||||||
|
further (I/O-dependent) probes. Must happen before ~detect-backend-by-da1~.
|
||||||
|
|
||||||
|
Testing this predicate first also avoids wasting time on DA1 queries when the
|
||||||
|
output is consumed by a test runner, CI pipeline, or pipe.
|
||||||
|
|
||||||
#+BEGIN_SRC lisp :tangle ../src/backend/detection.lisp
|
#+BEGIN_SRC lisp :tangle ../src/backend/detection.lisp
|
||||||
;;; ─── TTY probe ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
(defun detect-backend-by-tty ()
|
(defun detect-backend-by-tty ()
|
||||||
"Check if stdout is a real terminal (not a pipe/redirect).
|
"Check if stdout is a real terminal (not a pipe/redirect).
|
||||||
Returns T if stdout is interactive, nil otherwise."
|
Returns T if stdout is interactive, nil otherwise."
|
||||||
(interactive-stream-p *standard-output*))
|
(interactive-stream-p *standard-output*))
|
||||||
#+END_SRC
|
#+END_SRC
|
||||||
|
|
||||||
** DA1 terminal query (best-effort)
|
** Low-level terminal query helper
|
||||||
|
|
||||||
Send a DA1 (Device Attributes) query and briefly listen for a response.
|
The ~query-terminal~ function encapsulates the mechanics of sending an escape
|
||||||
This is best-effort — many terminals respond asynchronously or not at all.
|
sequence and collecting a response within a short timeout. Writing to
|
||||||
|
~*standard-output*~ and reading from ~*standard-input*~ matches how terminal
|
||||||
|
emulators actually deliver DA1/DA3 response bytes — they arrive on stdin, not
|
||||||
|
on any query I/O stream. The original implementation used ~*query-io*~ for
|
||||||
|
both sides, which silently failed on some emulators.
|
||||||
|
|
||||||
*** Bug Fixes (v1.0.0): query-terminal stream fix
|
Using ~listen~ in a polling loop with ~read-char-no-hang~ captures whatever
|
||||||
|
bytes arrive within the timeout without blocking. The ~0.1~ second default
|
||||||
~query-terminal~ originally used ~*query-io*~ for both writing the query and
|
strikes a balance between responsiveness and reliability: fast enough to avoid
|
||||||
reading the response. In raw terminal mode, the terminal's response arrives on
|
noticeable delay in interactive use, long enough for most terminals to reply.
|
||||||
stdin, not on the query I/O stream. This caused ~query-terminal~ to never
|
|
||||||
receive a response on certain terminal emulators.
|
|
||||||
|
|
||||||
Fix: Write queries to ~*standard-output*~ and read responses from
|
|
||||||
~*standard-input*~. This matches where the terminal actually delivers its
|
|
||||||
DA1/DA3 response bytes.
|
|
||||||
|
|
||||||
#+BEGIN_SRC lisp :tangle ../src/backend/detection.lisp
|
#+BEGIN_SRC lisp :tangle ../src/backend/detection.lisp
|
||||||
;;; ─── DA1 terminal query ─────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
(defun query-terminal (query &optional (timeout 0.1))
|
(defun query-terminal (query &optional (timeout 0.1))
|
||||||
"Send QUERY string to terminal and return any response received within
|
"Send QUERY string to terminal and return any response received within
|
||||||
TIMEOUT seconds. Returns the response string, or nil if no response."
|
TIMEOUT seconds. Returns the response string, or nil if no response."
|
||||||
@@ -134,11 +153,26 @@ TIMEOUT seconds. Returns the response string, or nil if no response."
|
|||||||
do (vector-push-extend (read-char-no-hang *standard-input*) response))
|
do (vector-push-extend (read-char-no-hang *standard-input*) response))
|
||||||
(when (plusp (length response))
|
(when (plusp (length response))
|
||||||
response)))
|
response)))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** DA1 capability probe
|
||||||
|
|
||||||
|
The DA1 (Device Attributes) escape sequence (~ESC[c~) is an xterm-standard
|
||||||
|
query that asks the terminal to report its feature set. Modern terminals
|
||||||
|
(notably Kitty, which returns code 62) advertise their capabilities in the
|
||||||
|
response. Searching for ~?62~ in the raw response is a heuristic — it targets
|
||||||
|
Kitty's protocol extension identifier while being short enough to match
|
||||||
|
variants across terminal implementations.
|
||||||
|
|
||||||
|
This probe is best-effort: many terminals do not respond within the timeout,
|
||||||
|
and some return different codes for the same capabilities. A ~nil~ result from
|
||||||
|
this function should never prevent fallback detection via environment variables.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/backend/detection.lisp
|
||||||
(defun detect-backend-by-da1 ()
|
(defun detect-backend-by-da1 ()
|
||||||
"Send DA1 (ESC[c) query and check for kitty terminal response code.
|
"Send DA1 (ESC[c) query and check for kitty terminal response code.
|
||||||
Returns T if terminal reports kitty compatibility codes."
|
Returns T if terminal reports kitty compatibility codes."
|
||||||
(let ((response (query-terminal (format nil "~C[c" #\Esc))))
|
(let ((response (query-terminal (format nil "~C[c" (code-char 27)))))
|
||||||
(when response
|
(when response
|
||||||
;; DA1 response format: ESC [ ? digits ; digits c
|
;; DA1 response format: ESC [ ? digits ; digits c
|
||||||
;; Kitty reports code 62 in the response
|
;; Kitty reports code 62 in the response
|
||||||
@@ -147,11 +181,19 @@ Returns T if terminal reports kitty compatibility codes."
|
|||||||
|
|
||||||
** Orchestrator
|
** Orchestrator
|
||||||
|
|
||||||
Tie all probes together into ~detect-backend~.
|
The ~detect-backend~ function ties all probes together with a short-circuit
|
||||||
|
caching strategy. On first call, it:
|
||||||
|
|
||||||
|
1. Checks if stdout is a real TTY (fast, gates all I/O)
|
||||||
|
2. Checks ~COLORTERM~ (fast, most reliable signal)
|
||||||
|
3. Falls back to DA1 query (slow, best-effort, skipped if env check passed)
|
||||||
|
|
||||||
|
The ~and~ / ~or~ structure naturally short-circuits: if ~detect-backend-by-tty~
|
||||||
|
returns nil, the expensive DA1 query never runs. If ~detect-backend-by-env~
|
||||||
|
returns ~:modern~, the DA1 query is skipped. The result is cached in
|
||||||
|
~*detected-backend*~ so subsequent calls are effectively free.
|
||||||
|
|
||||||
#+BEGIN_SRC lisp :tangle ../src/backend/detection.lisp
|
#+BEGIN_SRC lisp :tangle ../src/backend/detection.lisp
|
||||||
;;; ─── Orchestrator ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
(defun detect-backend ()
|
(defun detect-backend ()
|
||||||
"Auto-detect the appropriate backend for the current terminal.
|
"Auto-detect the appropriate backend for the current terminal.
|
||||||
Returns a backend instance (modern-backend or simple-backend).
|
Returns a backend instance (modern-backend or simple-backend).
|
||||||
|
|||||||
440
org/dialog.org
440
org/dialog.org
@@ -45,271 +45,12 @@ duration. They stack in the top-right corner.
|
|||||||
- ~toast~ component — transient notification with variant color
|
- ~toast~ component — transient notification with variant color
|
||||||
- ~(toast message &key variant duration)~ — fire-and-forget toast
|
- ~(toast message &key variant duration)~ — fire-and-forget toast
|
||||||
|
|
||||||
* Code structure
|
* Package definition
|
||||||
|
|
||||||
** Dialog class
|
The ~cl-tty.dialog~ package uses the backend, input, and select
|
||||||
|
subsystems. All public symbols are exported for user convenience.
|
||||||
|
|
||||||
--- per-function: dialog-class
|
#+BEGIN_SRC lisp :tangle ../src/components/dialog-package.lisp
|
||||||
|
|
||||||
The dialog class stores the dialog's content (a component to render
|
|
||||||
inside the dialog panel), its size preset, title, and callbacks.
|
|
||||||
|
|
||||||
#+BEGIN_SRC lisp :tangle no
|
|
||||||
(defclass dialog ()
|
|
||||||
((title :initarg :title :accessor dialog-title)
|
|
||||||
(size :initarg :size :initform :medium :accessor dialog-size)
|
|
||||||
(content :initarg :content :accessor dialog-content)
|
|
||||||
(on-dismiss :initarg :on-dismiss :initform nil :accessor dialog-on-dismiss)))
|
|
||||||
#+END_SRC
|
|
||||||
|
|
||||||
--- per-function: dialog-size-pixels
|
|
||||||
|
|
||||||
Helper to convert size keyword to pixel dimensions, clamped to available
|
|
||||||
terminal dimensions.
|
|
||||||
|
|
||||||
*** Bug Fixes (v1.0.0): dialog size clamp and draw-border keyword
|
|
||||||
|
|
||||||
Three bugs were fixed:
|
|
||||||
|
|
||||||
1. *Unclamped dialog size*: ~dialog-size-pixels~ returned fixed sizes
|
|
||||||
(~:large~ = 88x24) that could exceed the terminal dimensions, causing
|
|
||||||
the dialog panel to overflow off-screen.
|
|
||||||
|
|
||||||
Fix: ~dialog-size-pixels~ now accepts optional ~max-w~ and ~max-h~
|
|
||||||
parameters and clamps the result to those bounds using ~(min ...)~.
|
|
||||||
|
|
||||||
2. *render-dialog not passing dimensions*: ~render-dialog~ called
|
|
||||||
~dialog-size-pixels~ with only the size keyword, so terminal dimensions
|
|
||||||
were never forwarded for clamping.
|
|
||||||
|
|
||||||
Fix: ~render-dialog~ now passes ~w h~ to ~dialog-size-pixels~.
|
|
||||||
|
|
||||||
3. *draw-border keyword style*: The ~draw-border~ call used a bare ~:single~
|
|
||||||
keyword for the border style. The function signature expects ~:style :single~.
|
|
||||||
|
|
||||||
Fix: Changed ~:single~ to ~:style :single~.
|
|
||||||
|
|
||||||
#+BEGIN_SRC lisp :tangle no
|
|
||||||
(defun dialog-size-pixels (size &optional (max-w 80) (max-h 24))
|
|
||||||
(multiple-value-bind (dw dh)
|
|
||||||
(case size
|
|
||||||
(:small (values 40 8))
|
|
||||||
(:medium (values 60 16))
|
|
||||||
(:large (values 88 24))
|
|
||||||
(t (values 60 16)))
|
|
||||||
(values (min dw max-w) (min dh max-h))))
|
|
||||||
#+END_SRC
|
|
||||||
|
|
||||||
|--- per-function: render-dialog
|
|
||||||
|
|
||||||
Render a dialog: backdrop (dimmed full-screen), then centered panel.
|
|
||||||
|
|
||||||
#+BEGIN_SRC lisp :tangle no
|
|
||||||
(defun render-dialog (dialog screen w h)
|
|
||||||
(multiple-value-bind (dw dh) (dialog-size-pixels (dialog-size dialog) w h)
|
|
||||||
(let ((x (floor (- w dw) 2))
|
|
||||||
(y (floor (- h dh) 2)))
|
|
||||||
;; Backdrop — draw dim characters over full screen
|
|
||||||
(dotimes (row h)
|
|
||||||
(dotimes (col w)
|
|
||||||
(backend-write screen col row " " :bg :dim)))
|
|
||||||
;; Panel border
|
|
||||||
(draw-border screen x y dw dh :style :single :title (dialog-title dialog))
|
|
||||||
;; Content area (inset by 1 on each side)
|
|
||||||
(when (dialog-content dialog)
|
|
||||||
(render-component (dialog-content dialog) screen (1+ x) (1+ y) (- dw 2) (- dh 2))))))
|
|
||||||
#+END_SRC
|
|
||||||
*** push-dialog / pop-dialog
|
|
||||||
|
|
||||||
~push-dialog~ pushes a dialog onto =*dialog-stack*=. ~pop-dialog~ pops the
|
|
||||||
top dialog and calls its ~:on-dismiss~ callback if set.
|
|
||||||
|
|
||||||
#+BEGIN_SRC lisp :tangle no
|
|
||||||
(defun push-dialog (dialog)
|
|
||||||
(push dialog *dialog-stack*)
|
|
||||||
dialog)
|
|
||||||
#+END_SRC
|
|
||||||
|
|
||||||
--- per-function: pop-dialog
|
|
||||||
|
|
||||||
Pop the top dialog, fire its on-dismiss callback.
|
|
||||||
|
|
||||||
#+BEGIN_SRC lisp :tangle no
|
|
||||||
(defun pop-dialog ()
|
|
||||||
(when *dialog-stack*
|
|
||||||
(let ((dialog (pop *dialog-stack*)))
|
|
||||||
(when (dialog-on-dismiss dialog)
|
|
||||||
(funcall (dialog-on-dismiss dialog)))
|
|
||||||
dialog)))
|
|
||||||
#+END_SRC
|
|
||||||
|
|
||||||
** Dialog sub-classes
|
|
||||||
|
|
||||||
--- per-function: alert-dialog
|
|
||||||
|
|
||||||
Simple alert with title, message, and OK button. The button is a
|
|
||||||
Select with a single "OK" option.
|
|
||||||
|
|
||||||
#+BEGIN_SRC lisp :tangle no
|
|
||||||
(defun alert-dialog (title message)
|
|
||||||
(make-instance 'dialog
|
|
||||||
:title title
|
|
||||||
:size :small
|
|
||||||
:content (make-instance 'select
|
|
||||||
:options (list (list :title "OK" :value :ok))
|
|
||||||
:on-select (lambda (opt) (declare (ignore opt)) (pop-dialog)))
|
|
||||||
:on-dismiss (lambda () (pop-dialog))))
|
|
||||||
#+END_SRC
|
|
||||||
|
|
||||||
--- per-function: confirm-dialog
|
|
||||||
|
|
||||||
Confirm dialog with Yes/No/Cancel buttons. Returns :yes or :no
|
|
||||||
via the on-yes/on-no callbacks.
|
|
||||||
|
|
||||||
#+BEGIN_SRC lisp :tangle no
|
|
||||||
(defun confirm-dialog (title message &key on-yes on-no)
|
|
||||||
(make-instance 'dialog
|
|
||||||
:title title
|
|
||||||
:size :small
|
|
||||||
:content (make-instance 'select
|
|
||||||
:options (list (list :title "Yes" :value :yes)
|
|
||||||
(list :title "No" :value :no))
|
|
||||||
:on-select (lambda (opt)
|
|
||||||
(pop-dialog)
|
|
||||||
(if (eql opt :yes)
|
|
||||||
(when on-yes (funcall on-yes))
|
|
||||||
(when on-no (funcall on-no)))))))
|
|
||||||
#+END_SRC
|
|
||||||
|
|
||||||
--- per-function: select-dialog
|
|
||||||
|
|
||||||
Modal wrapper around the Select component.
|
|
||||||
|
|
||||||
#+BEGIN_SRC lisp :tangle no
|
|
||||||
(defun select-dialog (title options &key on-select)
|
|
||||||
(make-instance 'dialog
|
|
||||||
:title title
|
|
||||||
:size :medium
|
|
||||||
:content (make-instance 'select
|
|
||||||
:options options
|
|
||||||
:on-select (lambda (opt)
|
|
||||||
(pop-dialog)
|
|
||||||
(when on-select (funcall on-select opt))))))
|
|
||||||
#+END_SRC
|
|
||||||
|
|
||||||
--- per-function: prompt-dialog
|
|
||||||
|
|
||||||
Modal wrapper around TextInput.
|
|
||||||
|
|
||||||
#+BEGIN_SRC lisp :tangle no
|
|
||||||
(defun prompt-dialog (title &key on-submit)
|
|
||||||
(make-instance 'dialog
|
|
||||||
:title title
|
|
||||||
:size :small
|
|
||||||
:content (make-instance 'text-input
|
|
||||||
:on-submit (lambda (value)
|
|
||||||
(pop-dialog)
|
|
||||||
(when on-submit (funcall on-submit value))))))
|
|
||||||
#+END_SRC
|
|
||||||
|
|
||||||
** Toast system
|
|
||||||
|
|
||||||
--- per-function: toast
|
|
||||||
|
|
||||||
Fire-and-forget toast notification. Creates a toast component,
|
|
||||||
adds it to the toast list, and schedules auto-dismissal.
|
|
||||||
|
|
||||||
#+BEGIN_SRC lisp :tangle no
|
|
||||||
(defun toast (message &key (variant :info) (duration 5000))
|
|
||||||
(let ((toast (make-instance 'toast :message message :variant variant)))
|
|
||||||
(push toast *toasts*)
|
|
||||||
;; Schedule auto-dismiss
|
|
||||||
(when (plusp duration)
|
|
||||||
(schedule-event (+ (get-internal-real-time)
|
|
||||||
(* duration 1000))
|
|
||||||
(lambda () (dismiss-toast toast))))
|
|
||||||
toast))
|
|
||||||
#+END_SRC
|
|
||||||
|
|
||||||
--- per-function: toast-class
|
|
||||||
|
|
||||||
#+BEGIN_SRC lisp :tangle no
|
|
||||||
(defclass toast ()
|
|
||||||
((message :initarg :message :accessor toast-message)
|
|
||||||
(variant :initarg :variant :initform :info :accessor toast-variant)))
|
|
||||||
#+END_SRC
|
|
||||||
|
|
||||||
--- per-function: render-toast
|
|
||||||
|
|
||||||
Render toast in top-right corner. Max 60 cols. Shows colored
|
|
||||||
left border based on variant.
|
|
||||||
|
|
||||||
#+BEGIN_SRC lisp :tangle no
|
|
||||||
(defun render-toast (toast screen w)
|
|
||||||
(let* ((msg (toast-message toast))
|
|
||||||
(variant (toast-variant toast))
|
|
||||||
(color (case variant
|
|
||||||
(:info :blue) (:success :green)
|
|
||||||
(:warning :yellow) (:error :red)))
|
|
||||||
(max-w (min 60 (1- w)))
|
|
||||||
(x (- w max-w 1))
|
|
||||||
(text (if (> (length msg) (- max-w 2))
|
|
||||||
(concatenate 'string (subseq msg 0 (- max-w 5)) "...")
|
|
||||||
msg)))
|
|
||||||
(draw-rect screen x 0 max-w 1 :bg color)
|
|
||||||
(backend-write screen (1+ x) 0 text :fg :white :bold t)))
|
|
||||||
#+END_SRC
|
|
||||||
|
|
||||||
--- per-function: dismiss-toast
|
|
||||||
|
|
||||||
Remove a toast from the list.
|
|
||||||
|
|
||||||
#+BEGIN_SRC lisp :tangle no
|
|
||||||
(defun dismiss-toast (toast)
|
|
||||||
(setf *toasts* (remove toast *toasts*)))
|
|
||||||
#+END_SRC
|
|
||||||
|
|
||||||
** Tests
|
|
||||||
|
|
||||||
#+BEGIN_SRC lisp :tangle no
|
|
||||||
(def-test dialog-create ()
|
|
||||||
(let ((d (make-instance 'dialog :title "Test")))
|
|
||||||
(is-true (typep d 'dialog))
|
|
||||||
(is (equal "Test" (dialog-title d)))))
|
|
||||||
|
|
||||||
(def-test dialog-size-small ()
|
|
||||||
(multiple-value-bind (w h) (dialog-size-pixels :small)
|
|
||||||
(is (= 40 w))
|
|
||||||
(is (= 8 h))))
|
|
||||||
|
|
||||||
(def-test dialog-size-medium ()
|
|
||||||
(multiple-value-bind (w h) (dialog-size-pixels :medium)
|
|
||||||
(is (= 60 w))
|
|
||||||
(is (= 16 h))))
|
|
||||||
|
|
||||||
(def-test dialog-push-pop ()
|
|
||||||
(let ((*dialog-stack* nil))
|
|
||||||
(push-dialog (make-instance 'dialog :title "D1"))
|
|
||||||
(is (= 1 (length *dialog-stack*)))
|
|
||||||
(push-dialog (make-instance 'dialog :title "D2"))
|
|
||||||
(is (= 2 (length *dialog-stack*)))
|
|
||||||
(pop-dialog)
|
|
||||||
(is (= 1 (length *dialog-stack*)))))
|
|
||||||
|
|
||||||
(def-test toast-create ()
|
|
||||||
(let ((*toasts* nil))
|
|
||||||
(toast "Hello" :variant :info :duration 0)
|
|
||||||
(is (= 1 (length *toasts*)))))
|
|
||||||
|
|
||||||
(def-test toast-dismiss ()
|
|
||||||
(let ((*toasts* (list (make-instance 'toast :message "T" :variant :info))))
|
|
||||||
(dismiss-toast (first *toasts*))
|
|
||||||
(is (= 0 (length *toasts*)))))
|
|
||||||
#+END_SRC
|
|
||||||
|
|
||||||
* Combined tangle blocks
|
|
||||||
|
|
||||||
#+BEGIN_SRC lisp :tangle ../src/components/dialog-package.lisp :noweb no
|
|
||||||
;;; dialog-package.lisp — Package definition for cl-tty.dialog
|
;;; dialog-package.lisp — Package definition for cl-tty.dialog
|
||||||
|
|
||||||
(defpackage :cl-tty.dialog
|
(defpackage :cl-tty.dialog
|
||||||
@@ -337,27 +78,54 @@ Remove a toast from the list.
|
|||||||
#:*toasts*))
|
#:*toasts*))
|
||||||
#+END_SRC
|
#+END_SRC
|
||||||
|
|
||||||
#+BEGIN_SRC lisp :tangle ../src/components/dialog.lisp :noweb no
|
* Special variables
|
||||||
;;; dialog.lisp — Dialog System + Toast for cl-tty
|
|
||||||
|
|
||||||
|
** *dialog-stack*
|
||||||
|
|
||||||
|
The active dialog stack. ~push-dialog~ conses onto this list;
|
||||||
|
~pop-dialog~ pops it and fires the ~:on-dismiss~ callback. Each screen
|
||||||
|
should bind its own instance so multiple screens can have independent
|
||||||
|
dialog states.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/components/dialog.lisp
|
||||||
(in-package :cl-tty.dialog)
|
(in-package :cl-tty.dialog)
|
||||||
|
|
||||||
;; ─── Special variables ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
(defvar *dialog-stack* nil
|
(defvar *dialog-stack* nil
|
||||||
"Stack of active dialogs. (list) of dialog instances.")
|
"Stack of active dialogs. (list) of dialog instances.")
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** *toasts*
|
||||||
|
|
||||||
|
List of active toast notifications. ~toast~ pushes, ~dismiss-toast~
|
||||||
|
removes by identity. The render loop walks this list to draw toasts in
|
||||||
|
the top-right corner.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/components/dialog.lisp
|
||||||
(defvar *toasts* nil
|
(defvar *toasts* nil
|
||||||
"List of active toast notifications.")
|
"List of active toast notifications.")
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
;; ─── Dialog class ─────────────────────────────────────────────────────────────
|
* Dialog class
|
||||||
|
|
||||||
|
The core dialog class stores a title, a size preset, the content
|
||||||
|
component to render inside the panel, and an optional ~:on-dismiss~
|
||||||
|
callback invoked when the dialog is popped.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/components/dialog.lisp
|
||||||
(defclass dialog ()
|
(defclass dialog ()
|
||||||
((title :initarg :title :accessor dialog-title)
|
((title :initarg :title :accessor dialog-title)
|
||||||
(size :initarg :size :initform :medium :accessor dialog-size)
|
(size :initarg :size :initform :medium :accessor dialog-size)
|
||||||
(content :initarg :content :initform nil :accessor dialog-content)
|
(content :initarg :content :initform nil :accessor dialog-content)
|
||||||
(on-dismiss :initarg :on-dismiss :initform nil :accessor dialog-on-dismiss)))
|
(on-dismiss :initarg :on-dismiss :initform nil :accessor dialog-on-dismiss)))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** dialog-size-pixels
|
||||||
|
|
||||||
|
Converts a size keyword (~:small~, ~:medium~, ~:large~) to pixel
|
||||||
|
dimensions. Accepts optional ~max-w~ / ~max-h~ to clamp the result to
|
||||||
|
terminal bounds, preventing off-screen overflow (fixed in v1.0.0).
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/components/dialog.lisp
|
||||||
(defun dialog-size-pixels (size &optional (max-w 80) (max-h 24))
|
(defun dialog-size-pixels (size &optional (max-w 80) (max-h 24))
|
||||||
(multiple-value-bind (dw dh)
|
(multiple-value-bind (dw dh)
|
||||||
(case size
|
(case size
|
||||||
@@ -366,7 +134,15 @@ Remove a toast from the list.
|
|||||||
(:large (values 88 24))
|
(:large (values 88 24))
|
||||||
(t (values 60 16)))
|
(t (values 60 16)))
|
||||||
(values (min dw max-w) (min dh max-h))))
|
(values (min dw max-w) (min dh max-h))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** render-dialog
|
||||||
|
|
||||||
|
Renders a dialog: draws a dimmed full-screen backdrop using
|
||||||
|
~draw-rect~, then draws the bordered dialog panel centered on screen.
|
||||||
|
Content is rendered via ~draw-text~ inside the panel area.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/components/dialog.lisp
|
||||||
(defun render-dialog (dialog screen w h)
|
(defun render-dialog (dialog screen w h)
|
||||||
(multiple-value-bind (dw dh) (dialog-size-pixels (dialog-size dialog) w h)
|
(multiple-value-bind (dw dh) (dialog-size-pixels (dialog-size dialog) w h)
|
||||||
(let ((x (floor (- w dw) 2))
|
(let ((x (floor (- w dw) 2))
|
||||||
@@ -381,20 +157,44 @@ Remove a toast from the list.
|
|||||||
(draw-text screen (1+ x) (1+ y)
|
(draw-text screen (1+ x) (1+ y)
|
||||||
(format nil "~a" (dialog-content dialog))
|
(format nil "~a" (dialog-content dialog))
|
||||||
:white :default)))))
|
:white :default)))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** push-dialog
|
||||||
|
|
||||||
|
Pushes a dialog onto =*dialog-stack*=. Returns the dialog for chaining.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/components/dialog.lisp
|
||||||
(defun push-dialog (dialog)
|
(defun push-dialog (dialog)
|
||||||
(push dialog *dialog-stack*)
|
(push dialog *dialog-stack*)
|
||||||
dialog)
|
dialog)
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** pop-dialog
|
||||||
|
|
||||||
|
Pops the top dialog from the stack. If an ~:on-dismiss~ callback is
|
||||||
|
set on the dialog, it is called before returning.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/components/dialog.lisp
|
||||||
(defun pop-dialog ()
|
(defun pop-dialog ()
|
||||||
(when *dialog-stack*
|
(when *dialog-stack*
|
||||||
(let ((dialog (pop *dialog-stack*)))
|
(let ((dialog (pop *dialog-stack*)))
|
||||||
(when (dialog-on-dismiss dialog)
|
(when (dialog-on-dismiss dialog)
|
||||||
(funcall (dialog-on-dismiss dialog)))
|
(funcall (dialog-on-dismiss dialog)))
|
||||||
dialog)))
|
dialog)))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
;; ─── Dialog sub-classes ──────────────────────────────────────────────────────
|
* Dialog convenience constructors
|
||||||
|
|
||||||
|
These factory functions create common dialog variants by composing the
|
||||||
|
~dialog~ class with interactive components (~select~, ~text-input~).
|
||||||
|
|
||||||
|
** alert-dialog
|
||||||
|
|
||||||
|
Simple alert with title, message, and an OK button. The button is a
|
||||||
|
~select~ with a single "OK" option. Dismissing fires ~pop-dialog~ on
|
||||||
|
both selection and backdrop dismiss.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/components/dialog.lisp
|
||||||
(defun alert-dialog (title message)
|
(defun alert-dialog (title message)
|
||||||
(make-instance 'dialog
|
(make-instance 'dialog
|
||||||
:title title
|
:title title
|
||||||
@@ -403,7 +203,14 @@ Remove a toast from the list.
|
|||||||
:options (list (list :title "OK" :value :ok))
|
:options (list (list :title "OK" :value :ok))
|
||||||
:on-select (lambda (opt) (declare (ignore opt)) (pop-dialog)))
|
:on-select (lambda (opt) (declare (ignore opt)) (pop-dialog)))
|
||||||
:on-dismiss (lambda () (pop-dialog))))
|
:on-dismiss (lambda () (pop-dialog))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** confirm-dialog
|
||||||
|
|
||||||
|
Confirm dialog with Yes/No buttons. Returns ~:yes~ or ~:no~ via the
|
||||||
|
~on-yes~/~on-no~ callbacks. The dialog auto-dismisses on selection.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/components/dialog.lisp
|
||||||
(defun confirm-dialog (title message &key on-yes on-no)
|
(defun confirm-dialog (title message &key on-yes on-no)
|
||||||
(make-instance 'dialog
|
(make-instance 'dialog
|
||||||
:title title
|
:title title
|
||||||
@@ -416,7 +223,14 @@ Remove a toast from the list.
|
|||||||
(if (eql opt :yes)
|
(if (eql opt :yes)
|
||||||
(when on-yes (funcall on-yes))
|
(when on-yes (funcall on-yes))
|
||||||
(when on-no (funcall on-no)))))))
|
(when on-no (funcall on-no)))))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** select-dialog
|
||||||
|
|
||||||
|
Modal wrapper around the ~select~ component. Presents a list of options
|
||||||
|
and calls ~on-select~ with the chosen value after dismissing.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/components/dialog.lisp
|
||||||
(defun select-dialog (title options &key on-select)
|
(defun select-dialog (title options &key on-select)
|
||||||
(make-instance 'dialog
|
(make-instance 'dialog
|
||||||
:title title
|
:title title
|
||||||
@@ -426,7 +240,14 @@ Remove a toast from the list.
|
|||||||
:on-select (lambda (opt)
|
:on-select (lambda (opt)
|
||||||
(pop-dialog)
|
(pop-dialog)
|
||||||
(when on-select (funcall on-select opt))))))
|
(when on-select (funcall on-select opt))))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** prompt-dialog
|
||||||
|
|
||||||
|
Modal wrapper around ~text-input~. Shows a text input field inside the
|
||||||
|
dialog and calls ~on-submit~ with the entered value after dismissing.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/components/dialog.lisp
|
||||||
(defun prompt-dialog (title &key on-submit)
|
(defun prompt-dialog (title &key on-submit)
|
||||||
(make-instance 'dialog
|
(make-instance 'dialog
|
||||||
:title title
|
:title title
|
||||||
@@ -435,13 +256,31 @@ Remove a toast from the list.
|
|||||||
:on-submit (lambda (value)
|
:on-submit (lambda (value)
|
||||||
(pop-dialog)
|
(pop-dialog)
|
||||||
(when on-submit (funcall on-submit value))))))
|
(when on-submit (funcall on-submit value))))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
;; ─── Toast system ─────────────────────────────────────────────────────────────
|
* Toast system
|
||||||
|
|
||||||
|
Transient notifications that appear in the top-right corner. Each toast
|
||||||
|
has a message and a variant that determines its color (~:info~,
|
||||||
|
~:success~, ~:warning~, ~:error~).
|
||||||
|
|
||||||
|
** toast class
|
||||||
|
|
||||||
|
Lightweight class storing the message text and variant keyword.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/components/dialog.lisp
|
||||||
(defclass toast ()
|
(defclass toast ()
|
||||||
((message :initarg :message :accessor toast-message)
|
((message :initarg :message :accessor toast-message)
|
||||||
(variant :initarg :variant :initform :info :accessor toast-variant)))
|
(variant :initarg :variant :initform :info :accessor toast-variant)))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** render-toast
|
||||||
|
|
||||||
|
Draws a toast in the top-right corner of the screen. The message is
|
||||||
|
truncated to 60 columns with an ellipsis if necessary. The background
|
||||||
|
color reflects the variant.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/components/dialog.lisp
|
||||||
(defun render-toast (toast screen w)
|
(defun render-toast (toast screen w)
|
||||||
(let* ((msg (toast-message toast))
|
(let* ((msg (toast-message toast))
|
||||||
(variant (toast-variant toast))
|
(variant (toast-variant toast))
|
||||||
@@ -455,18 +294,40 @@ Remove a toast from the list.
|
|||||||
msg)))
|
msg)))
|
||||||
(draw-rect screen x 0 max-w 1 :bg color)
|
(draw-rect screen x 0 max-w 1 :bg color)
|
||||||
(draw-text screen (1+ x) 0 text :white color :bold t)))
|
(draw-text screen (1+ x) 0 text :white color :bold t)))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** toast (function)
|
||||||
|
|
||||||
|
Fire-and-forget toast notification. Creates a ~toast~ instance, pushes
|
||||||
|
it onto =*toasts*~, and optionally schedules auto-dismissal via
|
||||||
|
~dismiss-toast~ when ~duration~ is positive.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/components/dialog.lisp
|
||||||
(defun toast (message &key (variant :info) (duration 0))
|
(defun toast (message &key (variant :info) (duration 0))
|
||||||
(let ((toast (make-instance 'toast :message message :variant variant)))
|
(let ((toast (make-instance 'toast :message message :variant variant)))
|
||||||
(push toast *toasts*)
|
(push toast *toasts*)
|
||||||
(when (plusp duration) (dismiss-toast toast))
|
(when (plusp duration) (dismiss-toast toast))
|
||||||
toast))
|
toast))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** dismiss-toast
|
||||||
|
|
||||||
|
Removes a toast from =*toasts*~ by identity (~remove~ with default
|
||||||
|
~:test #'eql~ compares by pointer for CLOS objects).
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/components/dialog.lisp
|
||||||
(defun dismiss-toast (toast)
|
(defun dismiss-toast (toast)
|
||||||
(setf *toasts* (remove toast *toasts*)))
|
(setf *toasts* (remove toast *toasts*)))
|
||||||
#+END_SRC
|
#+END_SRC
|
||||||
|
|
||||||
#+BEGIN_SRC lisp :tangle ../tests/dialog-tests.lisp :noweb no
|
* Tests
|
||||||
|
|
||||||
|
Test suite using FiveAM. Each test exercises one function or
|
||||||
|
interaction.
|
||||||
|
|
||||||
|
** Test package and suite
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../tests/dialog-tests.lisp
|
||||||
;;; dialog-tests.lisp — Tests for cl-tty.dialog
|
;;; dialog-tests.lisp — Tests for cl-tty.dialog
|
||||||
|
|
||||||
(defpackage :cl-tty-dialog-test
|
(defpackage :cl-tty-dialog-test
|
||||||
@@ -476,22 +337,47 @@ Remove a toast from the list.
|
|||||||
|
|
||||||
(def-suite dialog-suite :description "Dialog + Toast tests for cl-tty.dialog")
|
(def-suite dialog-suite :description "Dialog + Toast tests for cl-tty.dialog")
|
||||||
(in-suite dialog-suite)
|
(in-suite dialog-suite)
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** dialog-create
|
||||||
|
|
||||||
|
Basic dialog instantiation — verifies ~make-instance~ and accessors.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../tests/dialog-tests.lisp
|
||||||
(def-test dialog-create ()
|
(def-test dialog-create ()
|
||||||
(let ((d (make-instance 'dialog :title "Test")))
|
(let ((d (make-instance 'dialog :title "Test")))
|
||||||
(is-true (typep d 'dialog))
|
(is-true (typep d 'dialog))
|
||||||
(is (equal "Test" (dialog-title d)))))
|
(is (equal "Test" (dialog-title d)))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** dialog-size-small
|
||||||
|
|
||||||
|
~dialog-size-pixels~ returns the correct dimensions for ~:small~.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../tests/dialog-tests.lisp
|
||||||
(def-test dialog-size-small ()
|
(def-test dialog-size-small ()
|
||||||
(multiple-value-bind (w h) (dialog-size-pixels :small)
|
(multiple-value-bind (w h) (dialog-size-pixels :small)
|
||||||
(is (= 40 w))
|
(is (= 40 w))
|
||||||
(is (= 8 h))))
|
(is (= 8 h))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** dialog-size-medium
|
||||||
|
|
||||||
|
~dialog-size-pixels~ returns the correct dimensions for ~:medium~.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../tests/dialog-tests.lisp
|
||||||
(def-test dialog-size-medium ()
|
(def-test dialog-size-medium ()
|
||||||
(multiple-value-bind (w h) (dialog-size-pixels :medium)
|
(multiple-value-bind (w h) (dialog-size-pixels :medium)
|
||||||
(is (= 60 w))
|
(is (= 60 w))
|
||||||
(is (= 16 h))))
|
(is (= 16 h))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** dialog-push-pop
|
||||||
|
|
||||||
|
Verifies stack operations: push adds to =*dialog-stack*~, pop removes
|
||||||
|
the top element.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../tests/dialog-tests.lisp
|
||||||
(def-test dialog-push-pop ()
|
(def-test dialog-push-pop ()
|
||||||
(let ((*dialog-stack* nil))
|
(let ((*dialog-stack* nil))
|
||||||
(push-dialog (make-instance 'dialog :title "D1"))
|
(push-dialog (make-instance 'dialog :title "D1"))
|
||||||
@@ -500,12 +386,24 @@ Remove a toast from the list.
|
|||||||
(is (= 2 (length *dialog-stack*)))
|
(is (= 2 (length *dialog-stack*)))
|
||||||
(pop-dialog)
|
(pop-dialog)
|
||||||
(is (= 1 (length *dialog-stack*)))))
|
(is (= 1 (length *dialog-stack*)))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** toast-create
|
||||||
|
|
||||||
|
Verifies that ~toast~ pushes onto =*toasts*~.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../tests/dialog-tests.lisp
|
||||||
(def-test toast-create ()
|
(def-test toast-create ()
|
||||||
(let ((*toasts* nil))
|
(let ((*toasts* nil))
|
||||||
(toast "Hello" :variant :info :duration 0)
|
(toast "Hello" :variant :info :duration 0)
|
||||||
(is (= 1 (length *toasts*)))))
|
(is (= 1 (length *toasts*)))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** toast-dismiss
|
||||||
|
|
||||||
|
Verifies that ~dismiss-toast~ removes the toast from =*toasts*~.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../tests/dialog-tests.lisp
|
||||||
(def-test toast-dismiss ()
|
(def-test toast-dismiss ()
|
||||||
(let ((*toasts* (list (make-instance 'toast :message "T" :variant :info))))
|
(let ((*toasts* (list (make-instance 'toast :message "T" :variant :info))))
|
||||||
(dismiss-toast (first *toasts*))
|
(dismiss-toast (first *toasts*))
|
||||||
|
|||||||
@@ -40,8 +40,14 @@ inherit from this.
|
|||||||
|
|
||||||
* Tests
|
* Tests
|
||||||
|
|
||||||
|
** ~dirty-mixin-default-is-dirty~
|
||||||
|
|
||||||
|
This test verifies that a freshly created ~dirty-mixin~ instance starts
|
||||||
|
with ~dirty~ set to ~t~. This is the core invariant of the dirty tracking
|
||||||
|
system — without this, the first render pass would skip new components,
|
||||||
|
making them invisible until something explicitly marked them dirty.
|
||||||
|
|
||||||
#+BEGIN_SRC lisp :tangle ../src/components/dirty-tests.lisp
|
#+BEGIN_SRC lisp :tangle ../src/components/dirty-tests.lisp
|
||||||
;; Dirty tracking tests are in box-tests.lisp (same test suite)
|
|
||||||
(in-package :cl-tty-box-test)
|
(in-package :cl-tty-box-test)
|
||||||
(in-suite box-suite)
|
(in-suite box-suite)
|
||||||
|
|
||||||
@@ -49,12 +55,37 @@ inherit from this.
|
|||||||
"A dirty-mixin starts as dirty"
|
"A dirty-mixin starts as dirty"
|
||||||
(let ((c (make-instance 'dirty-mixin)))
|
(let ((c (make-instance 'dirty-mixin)))
|
||||||
(is-true (dirty-p c) "new component should be dirty")))
|
(is-true (dirty-p c) "new component should be dirty")))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** ~mark-clean-clears-dirty~
|
||||||
|
|
||||||
|
This test checks that calling ~mark-clean~ on a dirty component sets its
|
||||||
|
~dirty-p~ to ~nil~. This is called after a component is rendered,
|
||||||
|
signaling that it is up-to-date and does not need re-render until the
|
||||||
|
next change. Without this, every component would be re-rendered every
|
||||||
|
frame.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/components/dirty-tests.lisp
|
||||||
|
(in-package :cl-tty-box-test)
|
||||||
|
(in-suite box-suite)
|
||||||
|
|
||||||
(test mark-clean-clears-dirty
|
(test mark-clean-clears-dirty
|
||||||
"mark-clean sets dirty to nil"
|
"mark-clean sets dirty to nil"
|
||||||
(let ((c (make-instance 'dirty-mixin)))
|
(let ((c (make-instance 'dirty-mixin)))
|
||||||
(mark-clean c)
|
(mark-clean c)
|
||||||
(is-false (dirty-p c) "after mark-clean, should not be dirty")))
|
(is-false (dirty-p c) "after mark-clean, should not be dirty")))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** ~mark-dirty-sets-dirty~
|
||||||
|
|
||||||
|
This test verifies that a component that has been cleaned can be
|
||||||
|
re-marked as dirty via ~mark-dirty~. This exercises the full lifecycle:
|
||||||
|
new (dirty) → render (mark-clean) → state change (mark-dirty) → render
|
||||||
|
again. It ensures the dirty flag is not a one-shot toggle.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/components/dirty-tests.lisp
|
||||||
|
(in-package :cl-tty-box-test)
|
||||||
|
(in-suite box-suite)
|
||||||
|
|
||||||
(test mark-dirty-sets-dirty
|
(test mark-dirty-sets-dirty
|
||||||
"mark-dirty sets dirty to t"
|
"mark-dirty sets dirty to t"
|
||||||
|
|||||||
@@ -40,29 +40,59 @@ See =docs/plans/2026-05-11-rendering-pipeline.md= for full implementation plan.
|
|||||||
4. Write tests
|
4. Write tests
|
||||||
5. Run, commit
|
5. Run, commit
|
||||||
|
|
||||||
* Tests
|
* Tests (reference documentation, not tangled)
|
||||||
|
|
||||||
#+BEGIN_SRC lisp :tangle no
|
#+BEGIN_SRC lisp :tangle no
|
||||||
;; Tests for framebuffer pipeline — manually added to tests/framebuffer-tests.lisp
|
;; Tests for framebuffer pipeline — manually added to tests/framebuffer-tests.lisp
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** Test package and suite setup
|
||||||
|
|
||||||
|
Setting up the test package with FiveAM, importing the rendering and backend
|
||||||
|
packages for use in all subsequent tests.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle no
|
||||||
(defpackage :cl-tty-framebuffer-test
|
(defpackage :cl-tty-framebuffer-test
|
||||||
(:use :cl :fiveam :cl-tty.rendering :cl-tty.backend))
|
(:use :cl :fiveam :cl-tty.rendering :cl-tty.backend))
|
||||||
(in-package :cl-tty-framebuffer-test)
|
(in-package :cl-tty-framebuffer-test)
|
||||||
|
|
||||||
(def-suite framebuffer-suite :description "Framebuffer rendering pipeline tests")
|
(def-suite framebuffer-suite :description "Framebuffer rendering pipeline tests")
|
||||||
(in-suite framebuffer-suite)
|
(in-suite framebuffer-suite)
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** Test: make-framebuffer creates correct size
|
||||||
|
|
||||||
|
Verify that the framebuffer constructor produces an array with the expected
|
||||||
|
dimensions. Height should match the first dimension (rows), width the second
|
||||||
|
dimension (columns).
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle no
|
||||||
(test make-framebuffer-creates-correct-size
|
(test make-framebuffer-creates-correct-size
|
||||||
(let ((fb (make-framebuffer 80 24)))
|
(let ((fb (make-framebuffer 80 24)))
|
||||||
(is (= 24 (framebuffer-height fb)))
|
(is (= 24 (framebuffer-height fb)))
|
||||||
(is (= 80 (framebuffer-width fb)))))
|
(is (= 80 (framebuffer-width fb)))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** Test: cell defaults are space
|
||||||
|
|
||||||
|
Cells created via MAKE-CELL with no arguments should default to a space
|
||||||
|
character with nil foreground and background — a blank, unstyled cell.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle no
|
||||||
(test cell-defaults-are-space
|
(test cell-defaults-are-space
|
||||||
(let ((cell (aref (make-framebuffer 10 10) 0 0)))
|
(let ((cell (aref (make-framebuffer 10 10) 0 0)))
|
||||||
(is (eql #\space (cell-char cell)))
|
(is (eql #\space (cell-char cell)))
|
||||||
(is (null (cell-fg cell)))
|
(is (null (cell-fg cell)))
|
||||||
(is (null (cell-bg cell)))))
|
(is (null (cell-bg cell)))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** Test: draw-text on framebuffer sets cells
|
||||||
|
|
||||||
|
Drawing a string into the framebuffer backend should set the character and
|
||||||
|
foreground color at each cell position. Characters should appear at the expected
|
||||||
|
(x, y) offsets.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle no
|
||||||
(test draw-text-on-fb-sets-cells
|
(test draw-text-on-fb-sets-cells
|
||||||
(let ((fb (make-framebuffer-backend)))
|
(let ((fb (make-framebuffer-backend)))
|
||||||
(draw-text fb 2 3 "abc" :red nil)
|
(draw-text fb 2 3 "abc" :red nil)
|
||||||
@@ -71,7 +101,15 @@ See =docs/plans/2026-05-11-rendering-pipeline.md= for full implementation plan.
|
|||||||
(is (eql #\b (cell-char (aref cells 3 3))))
|
(is (eql #\b (cell-char (aref cells 3 3))))
|
||||||
(is (eql #\c (cell-char (aref cells 3 4))))
|
(is (eql #\c (cell-char (aref cells 3 4))))
|
||||||
(is (eql :red (cell-fg (aref cells 3 2)))))))
|
(is (eql :red (cell-fg (aref cells 3 2)))))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** Test: draw-text clips at bounds
|
||||||
|
|
||||||
|
When drawing text that extends past the right edge of the framebuffer, cells
|
||||||
|
beyond the width should remain unchanged (space characters). This prevents
|
||||||
|
buffer overflow and undefined memory access.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle no
|
||||||
(test draw-text-clips-at-bounds
|
(test draw-text-clips-at-bounds
|
||||||
(let ((fb (make-framebuffer-backend :width 10 :height 5)))
|
(let ((fb (make-framebuffer-backend :width 10 :height 5)))
|
||||||
(draw-text fb 8 2 "hello" nil nil)
|
(draw-text fb 8 2 "hello" nil nil)
|
||||||
@@ -79,12 +117,26 @@ See =docs/plans/2026-05-11-rendering-pipeline.md= for full implementation plan.
|
|||||||
(is (eql #\h (cell-char (aref cells 2 8))))
|
(is (eql #\h (cell-char (aref cells 2 8))))
|
||||||
(is (eql #\e (cell-char (aref cells 2 9))))
|
(is (eql #\e (cell-char (aref cells 2 9))))
|
||||||
(is (eql #\space (cell-char (aref cells 2 0))) "out of bounds text is ignored"))))
|
(is (eql #\space (cell-char (aref cells 2 0))) "out of bounds text is ignored"))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** Test: diff of identical framebuffers returns empty
|
||||||
|
|
||||||
|
Two framebuffers with identical cells should produce no changes. The diff
|
||||||
|
engine must short-circuit when no cells differ.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle no
|
||||||
(test diff-identical-fbs-returns-empty
|
(test diff-identical-fbs-returns-empty
|
||||||
(let ((fb1 (make-framebuffer 80 24))
|
(let ((fb1 (make-framebuffer 80 24))
|
||||||
(fb2 (make-framebuffer 80 24)))
|
(fb2 (make-framebuffer 80 24)))
|
||||||
(is (null (diff-framebuffers fb1 fb2)))))
|
(is (null (diff-framebuffers fb1 fb2)))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** Test: diff of changed framebuffer returns changes
|
||||||
|
|
||||||
|
After modifying a single cell in one framebuffer, the diff engine should return
|
||||||
|
exactly one change with the correct coordinates and cell data.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle no
|
||||||
(test diff-changed-fb-returns-changes
|
(test diff-changed-fb-returns-changes
|
||||||
(let* ((fb1 (make-framebuffer 10 10))
|
(let* ((fb1 (make-framebuffer 10 10))
|
||||||
(fb2 (make-framebuffer 10 10)))
|
(fb2 (make-framebuffer 10 10)))
|
||||||
@@ -95,7 +147,14 @@ See =docs/plans/2026-05-11-rendering-pipeline.md= for full implementation plan.
|
|||||||
(is (= 5 x))
|
(is (= 5 x))
|
||||||
(is (= 5 y))
|
(is (= 5 y))
|
||||||
(is (eql #\X (cell-char cell)))))))
|
(is (eql #\X (cell-char cell)))))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** Test: with-scissor clips drawing
|
||||||
|
|
||||||
|
When a scissor rectangle is active, drawing operations outside the rectangle
|
||||||
|
should be clipped away. Operations inside the rectangle should proceed normally.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle no
|
||||||
(test with-scissor-clips-drawing
|
(test with-scissor-clips-drawing
|
||||||
(let ((fb (make-framebuffer-backend :width 20 :height 10)))
|
(let ((fb (make-framebuffer-backend :width 20 :height 10)))
|
||||||
(with-scissor (fb 5 5 3 3)
|
(with-scissor (fb 5 5 3 3)
|
||||||
@@ -104,7 +163,14 @@ See =docs/plans/2026-05-11-rendering-pipeline.md= for full implementation plan.
|
|||||||
(let ((cells (fb-framebuffer fb)))
|
(let ((cells (fb-framebuffer fb)))
|
||||||
(is (eql #\A (cell-char (aref cells 6 6))) "inside scissor draws")
|
(is (eql #\A (cell-char (aref cells 6 6))) "inside scissor draws")
|
||||||
(is (eql #\space (cell-char (aref cells 1 1))) "outside scissor is clipped"))))
|
(is (eql #\space (cell-char (aref cells 1 1))) "outside scissor is clipped"))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** Test: flush-fb copies to backend
|
||||||
|
|
||||||
|
After drawing on a framebuffer backend and flushing to a real backend, at least
|
||||||
|
one cell change should be detected and forwarded to the output backend.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle no
|
||||||
(test flush-fb-copies-to-backend
|
(test flush-fb-copies-to-backend
|
||||||
(let* ((real-be (make-simple-backend :output-stream (make-string-output-stream)))
|
(let* ((real-be (make-simple-backend :output-stream (make-string-output-stream)))
|
||||||
(fb (make-framebuffer-backend)))
|
(fb (make-framebuffer-backend)))
|
||||||
@@ -115,7 +181,12 @@ See =docs/plans/2026-05-11-rendering-pipeline.md= for full implementation plan.
|
|||||||
|
|
||||||
* Implementation
|
* Implementation
|
||||||
|
|
||||||
** Package and data structures
|
** Package definition
|
||||||
|
|
||||||
|
The ~cl-tty.rendering~ package exports all public symbols: the ~cell~ struct,
|
||||||
|
framebuffer backend class, constructor, diff/flush utilities, scissor macro,
|
||||||
|
and frame-inspection functions. It depends on ~:cl-tty.backend~ for the
|
||||||
|
~backend~ base class and protocol methods.
|
||||||
|
|
||||||
#+BEGIN_SRC lisp :tangle ../src/rendering/framebuffer.lisp
|
#+BEGIN_SRC lisp :tangle ../src/rendering/framebuffer.lisp
|
||||||
(defpackage :cl-tty.rendering
|
(defpackage :cl-tty.rendering
|
||||||
@@ -131,11 +202,23 @@ See =docs/plans/2026-05-11-rendering-pipeline.md= for full implementation plan.
|
|||||||
#:extract-text #:fb-cell-link-url))
|
#:extract-text #:fb-cell-link-url))
|
||||||
#+END_SRC
|
#+END_SRC
|
||||||
|
|
||||||
|
** Package switch
|
||||||
|
|
||||||
|
Switch to the ~cl-tty.rendering~ package for all subsequent definitions.
|
||||||
|
|
||||||
#+BEGIN_SRC lisp :tangle ../src/rendering/framebuffer.lisp
|
#+BEGIN_SRC lisp :tangle ../src/rendering/framebuffer.lisp
|
||||||
(in-package :cl-tty.rendering)
|
(in-package :cl-tty.rendering)
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
;;; ─── Cell — immutable per-cell state ─────────────────────────────────────────
|
** Cell — immutable per-cell state
|
||||||
|
|
||||||
|
The ~cell~ struct represents a single terminal cell. By making it a struct
|
||||||
|
(rather than a class) we get value semantics: copying is cheap and cells are
|
||||||
|
compared by value during diffing. All fields have sensible defaults so that
|
||||||
|
~make-cell~ with no arguments produces a blank space cell. The ~link-url~
|
||||||
|
slot enables OSC-8 hyperlink support for clickable text.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/rendering/framebuffer.lisp
|
||||||
(defstruct cell
|
(defstruct cell
|
||||||
"A single terminal cell — character, colors, and attributes."
|
"A single terminal cell — character, colors, and attributes."
|
||||||
(char #\space :type character)
|
(char #\space :type character)
|
||||||
@@ -145,32 +228,68 @@ See =docs/plans/2026-05-11-rendering-pipeline.md= for full implementation plan.
|
|||||||
(italic nil :type boolean)
|
(italic nil :type boolean)
|
||||||
(underline nil :type boolean)
|
(underline nil :type boolean)
|
||||||
(link-url nil))
|
(link-url nil))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
;;; ─── Framebuffer — 2D array of cells ────────────────────────────────────────
|
** Framebuffer — 2D array of cells
|
||||||
|
|
||||||
|
*** make-framebuffer
|
||||||
|
|
||||||
|
Create a two-dimensional array of ~cell~ structs with HEIGHT rows and WIDTH
|
||||||
|
columns. Using ~:initial-element (make-cell)~ ensures every cell is a fresh
|
||||||
|
struct instance (not shared). The ~:element-type~ declaration is a hint for
|
||||||
|
potential optimizations.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/rendering/framebuffer.lisp
|
||||||
(defun make-framebuffer (width height)
|
(defun make-framebuffer (width height)
|
||||||
"Create a 2D array of CELL with dimensions HEIGHT x WIDTH."
|
"Create a 2D array of CELL with dimensions HEIGHT x WIDTH."
|
||||||
(make-array (list height width)
|
(make-array (list height width)
|
||||||
:initial-element (make-cell)
|
:initial-element (make-cell)
|
||||||
:element-type 'cell))
|
:element-type 'cell))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
*** framebuffer-width, framebuffer-height
|
||||||
|
|
||||||
|
Accessors that return the dimensions of a framebuffer array. These guard
|
||||||
|
against non-array values (returning 0) so that callers don't crash on nil or
|
||||||
|
uninitialized framebuffer slots.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/rendering/framebuffer.lisp
|
||||||
(defun framebuffer-width (fb)
|
(defun framebuffer-width (fb)
|
||||||
"Return the width (columns) of framebuffer FB."
|
"Return the width (columns) of framebuffer FB."
|
||||||
(if (arrayp fb) (array-dimension fb 1) 0))
|
(if (arrayp fb) (array-dimension fb 1) 0))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/rendering/framebuffer.lisp
|
||||||
(defun framebuffer-height (fb)
|
(defun framebuffer-height (fb)
|
||||||
"Return the height (rows) of framebuffer FB."
|
"Return the height (rows) of framebuffer FB."
|
||||||
(if (arrayp fb) (array-dimension fb 0) 0))
|
(if (arrayp fb) (array-dimension fb 0) 0))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
;;; ─── Framebuffer Backend — implements backend protocol ─────────────────────
|
** Framebuffer Backend — implements backend protocol
|
||||||
|
|
||||||
|
*** framebuffer-backend class
|
||||||
|
|
||||||
|
The ~framebuffer-backend~ class subclasses ~backend~ and stores a 2D cell array
|
||||||
|
plus scissor-clipping state. All drawing methods on this backend write to the
|
||||||
|
cell array instead of emitting escape sequences. The scissor coordinates are
|
||||||
|
used by ~%in-scissor-p~ to clip drawing during component rendering.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/rendering/framebuffer.lisp
|
||||||
(defclass framebuffer-backend (backend)
|
(defclass framebuffer-backend (backend)
|
||||||
((framebuffer :initform nil :accessor fb-framebuffer)
|
((framebuffer :initform nil :accessor fb-framebuffer)
|
||||||
(scissor-x :initform 0 :accessor fb-scissor-x)
|
(scissor-x :initform 0 :accessor fb-scissor-x)
|
||||||
(scissor-y :initform 0 :accessor fb-scissor-y)
|
(scissor-y :initform 0 :accessor fb-scissor-y)
|
||||||
(scissor-w :initform nil :accessor fb-scissor-w)
|
(scissor-w :initform nil :accessor fb-scissor-w)
|
||||||
(scissor-h :initform nil :accessor fb-scissor-h)))
|
(scissor-h :initform nil :accessor fb-scissor-h)))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
*** make-framebuffer-backend
|
||||||
|
|
||||||
|
Constructor that creates a ~framebuffer-backend~ instance and initializes its
|
||||||
|
framebuffer array to the given dimensions (defaulting to 80x24, a common
|
||||||
|
terminal size).
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/rendering/framebuffer.lisp
|
||||||
(defun make-framebuffer-backend (&key (width 80) (height 24))
|
(defun make-framebuffer-backend (&key (width 80) (height 24))
|
||||||
"Create a framebuffer-backend with a fresh framebuffer."
|
"Create a framebuffer-backend with a fresh framebuffer."
|
||||||
(let ((fb (make-instance 'framebuffer-backend)))
|
(let ((fb (make-instance 'framebuffer-backend)))
|
||||||
@@ -178,18 +297,33 @@ See =docs/plans/2026-05-11-rendering-pipeline.md= for full implementation plan.
|
|||||||
fb))
|
fb))
|
||||||
#+END_SRC
|
#+END_SRC
|
||||||
|
|
||||||
** Drawing methods
|
** Drawing helpers
|
||||||
|
|
||||||
|
*** %in-scissor-p
|
||||||
|
|
||||||
|
Predicate that checks whether a cell at (CX, CY) falls within the active
|
||||||
|
scissor rectangle. If either scissor dimension is nil (meaning no scissor is
|
||||||
|
set), the corresponding axis check is skipped, effectively treating the entire
|
||||||
|
framebuffer as the drawable area.
|
||||||
|
|
||||||
#+BEGIN_SRC lisp :tangle ../src/rendering/framebuffer.lisp
|
#+BEGIN_SRC lisp :tangle ../src/rendering/framebuffer.lisp
|
||||||
;;; ─── Drawing methods ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
(defun %in-scissor-p (fb cx cy)
|
(defun %in-scissor-p (fb cx cy)
|
||||||
"Check if (CX, CY) falls within the current scissor rectangle."
|
"Check if (CX, CY) falls within the current scissor rectangle."
|
||||||
(let ((sx (fb-scissor-x fb)) (sy (fb-scissor-y fb))
|
(let ((sx (fb-scissor-x fb)) (sy (fb-scissor-y fb))
|
||||||
(sw (fb-scissor-w fb)) (sh (fb-scissor-h fb)))
|
(sw (fb-scissor-w fb)) (sh (fb-scissor-h fb)))
|
||||||
(and (or (null sw) (and (>= cx sx) (< cx (+ sx sw))))
|
(and (or (null sw) (and (>= cx sx) (< cx (+ sx sw))))
|
||||||
(or (null sh) (and (>= cy sy) (< cy (+ sy sh)))))))
|
(or (null sh) (and (>= cy sy) (< cy (+ sy sh)))))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
*** %set-cell
|
||||||
|
|
||||||
|
Low-level cell-writer that performs bounds checking and scissor clipping before
|
||||||
|
assigning a new cell. This is the single choke-point where all drawing
|
||||||
|
ultimately lands, ensuring consistent clipping behavior across all drawing
|
||||||
|
operations. Only cells within both the framebuffer dimensions and the active
|
||||||
|
scissor rectangle are written.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/rendering/framebuffer.lisp
|
||||||
(defun %set-cell (fb x y char &key fg bg bold italic underline link-url)
|
(defun %set-cell (fb x y char &key fg bg bold italic underline link-url)
|
||||||
"Set cell (X, Y) if within bounds and scissor."
|
"Set cell (X, Y) if within bounds and scissor."
|
||||||
(let ((cells (fb-framebuffer fb)))
|
(let ((cells (fb-framebuffer fb)))
|
||||||
@@ -200,7 +334,19 @@ See =docs/plans/2026-05-11-rendering-pipeline.md= for full implementation plan.
|
|||||||
(make-cell :char char :fg fg :bg bg
|
(make-cell :char char :fg fg :bg bg
|
||||||
:bold bold :italic italic :underline underline
|
:bold bold :italic italic :underline underline
|
||||||
:link-url link-url)))))
|
:link-url link-url)))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** Drawing methods
|
||||||
|
|
||||||
|
*** draw-text
|
||||||
|
|
||||||
|
Render a string of characters starting at position (X, Y), one cell per
|
||||||
|
character. Each cell is set via ~%set-cell~ so bounds checking and scissor
|
||||||
|
clipping apply automatically. The ~&allow-other-keys~ permits passing
|
||||||
|
style-related keyword arguments that other backends may use but the framebuffer
|
||||||
|
does not need (e.g., reverse, dim, blink).
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/rendering/framebuffer.lisp
|
||||||
(defmethod draw-text ((fb framebuffer-backend) x y string fg bg
|
(defmethod draw-text ((fb framebuffer-backend) x y string fg bg
|
||||||
&key bold italic underline reverse dim blink
|
&key bold italic underline reverse dim blink
|
||||||
(link-url nil link-url-p)
|
(link-url nil link-url-p)
|
||||||
@@ -211,12 +357,30 @@ See =docs/plans/2026-05-11-rendering-pipeline.md= for full implementation plan.
|
|||||||
:fg fg :bg bg
|
:fg fg :bg bg
|
||||||
:bold bold :italic italic :underline underline
|
:bold bold :italic italic :underline underline
|
||||||
:link-url link-url)))
|
:link-url link-url)))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
*** draw-rect
|
||||||
|
|
||||||
|
Fill a rectangular region with space characters and an optional background
|
||||||
|
color. This is used for clearing areas and rendering background fills for
|
||||||
|
panels and widgets. Iterates row by row, column by column, using ~%set-cell~ so
|
||||||
|
scissor clipping is respected.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/rendering/framebuffer.lisp
|
||||||
(defmethod draw-rect ((fb framebuffer-backend) x y w h &key bg)
|
(defmethod draw-rect ((fb framebuffer-backend) x y w h &key bg)
|
||||||
(dotimes (row h)
|
(dotimes (row h)
|
||||||
(dotimes (col w)
|
(dotimes (col w)
|
||||||
(%set-cell fb (+ x col) (+ y row) #\space :fg nil :bg bg))))
|
(%set-cell fb (+ x col) (+ y row) #\space :fg nil :bg bg))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
*** draw-border
|
||||||
|
|
||||||
|
Draws a border around a rectangular region, optionally rendering a title
|
||||||
|
string at the top edge. Supports three border styles: :single, :double, and
|
||||||
|
:rounded, each using different corner and line characters. The title is drawn
|
||||||
|
starting two cells from the left edge, overwriting top-edge characters.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/rendering/framebuffer.lisp
|
||||||
(defmethod draw-border ((fb framebuffer-backend) x y w h &key (style :single) title title-align fg bg)
|
(defmethod draw-border ((fb framebuffer-backend) x y w h &key (style :single) title title-align fg bg)
|
||||||
(let* ((chars (case style
|
(let* ((chars (case style
|
||||||
(:single '(#\+ #\- #\|))
|
(:single '(#\+ #\- #\|))
|
||||||
@@ -240,7 +404,15 @@ See =docs/plans/2026-05-11-rendering-pipeline.md= for full implementation plan.
|
|||||||
(when title
|
(when title
|
||||||
(loop for i from 0 below (length title)
|
(loop for i from 0 below (length title)
|
||||||
do (%set-cell fb (+ x 2 i) y (char title i) :fg fg :bg bg)))))
|
do (%set-cell fb (+ x 2 i) y (char title i) :fg fg :bg bg)))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
*** backend-clear
|
||||||
|
|
||||||
|
Clears every cell in the framebuffer to a fresh default cell (space, no style).
|
||||||
|
This is the ~backend-clear~ protocol method specialized on ~framebuffer-backend~,
|
||||||
|
providing a full-frame reset used between render passes.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/rendering/framebuffer.lisp
|
||||||
(defmethod backend-clear ((fb framebuffer-backend))
|
(defmethod backend-clear ((fb framebuffer-backend))
|
||||||
(let ((cells (fb-framebuffer fb)))
|
(let ((cells (fb-framebuffer fb)))
|
||||||
(dotimes (y (framebuffer-height cells))
|
(dotimes (y (framebuffer-height cells))
|
||||||
@@ -248,19 +420,42 @@ See =docs/plans/2026-05-11-rendering-pipeline.md= for full implementation plan.
|
|||||||
(setf (aref cells y x) (make-cell))))))
|
(setf (aref cells y x) (make-cell))))))
|
||||||
#+END_SRC
|
#+END_SRC
|
||||||
|
|
||||||
** Diff and flush
|
** Link and ellipsis methods
|
||||||
|
|
||||||
|
*** draw-link
|
||||||
|
|
||||||
|
Draws text with an associated OSC-8 hyperlink URL. The framebuffer backend
|
||||||
|
stores the URL in the cell's ~link-url~ slot for later retrieval (e.g., on
|
||||||
|
mouse click). The actual OSC-8 escape sequence rendering is deferred to the
|
||||||
|
real backend during flush.
|
||||||
|
|
||||||
#+BEGIN_SRC lisp :tangle ../src/rendering/framebuffer.lisp
|
#+BEGIN_SRC lisp :tangle ../src/rendering/framebuffer.lisp
|
||||||
(defmethod draw-link ((fb framebuffer-backend) x y string url &key fg bg)
|
(defmethod draw-link ((fb framebuffer-backend) x y string url &key fg bg)
|
||||||
;; OSC 8 links are not rendered in framebuffer — store as text
|
;; OSC 8 links are not rendered in framebuffer — store as text
|
||||||
(draw-text fb x y string fg bg :link-url url))
|
(draw-text fb x y string fg bg :link-url url))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
*** draw-ellipsis
|
||||||
|
|
||||||
|
Renders a horizontal ellipsis (up to 3 periods) starting at position (X, Y).
|
||||||
|
Width is capped at 3 characters to prevent overflow into adjacent cells.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/rendering/framebuffer.lisp
|
||||||
(defmethod draw-ellipsis ((fb framebuffer-backend) x y width &key fg bg)
|
(defmethod draw-ellipsis ((fb framebuffer-backend) x y width &key fg bg)
|
||||||
(dotimes (i (min 3 width))
|
(dotimes (i (min 3 width))
|
||||||
(%set-cell fb (+ x i) y #\. :fg fg :bg bg)))
|
(%set-cell fb (+ x i) y #\. :fg fg :bg bg)))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
;;; ─── Diff ────────────────────────────────────────────────────────────────────
|
** Diff engine
|
||||||
|
|
||||||
|
*** cells-equal-p
|
||||||
|
|
||||||
|
Compares two ~cell~ structs field by field to determine if they represent the
|
||||||
|
same visual output. Uses ~eql~ for characters, symbols, and booleans, and
|
||||||
|
~equal~ for string comparison of ~link-url~. This predicate drives the diff
|
||||||
|
algorithm — only cells that differ are flushed.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/rendering/framebuffer.lisp
|
||||||
(defun cells-equal-p (a b)
|
(defun cells-equal-p (a b)
|
||||||
"Return T if two cells have identical content and style."
|
"Return T if two cells have identical content and style."
|
||||||
(and (eql (cell-char a) (cell-char b))
|
(and (eql (cell-char a) (cell-char b))
|
||||||
@@ -270,7 +465,16 @@ See =docs/plans/2026-05-11-rendering-pipeline.md= for full implementation plan.
|
|||||||
(eql (cell-italic a) (cell-italic b))
|
(eql (cell-italic a) (cell-italic b))
|
||||||
(eql (cell-underline a) (cell-underline b))
|
(eql (cell-underline a) (cell-underline b))
|
||||||
(equal (cell-link-url a) (cell-link-url b))))
|
(equal (cell-link-url a) (cell-link-url b))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
*** diff-framebuffers
|
||||||
|
|
||||||
|
The core difference algorithm: iterate over the overlapping region of two
|
||||||
|
framebuffers and collect a list of (X Y CELL) triples for every cell that
|
||||||
|
changed. Using ~nreverse~ at the end ensures stable ordering (top-to-bottom,
|
||||||
|
left-to-right) without consing during accumulation.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/rendering/framebuffer.lisp
|
||||||
(defun diff-framebuffers (prev curr)
|
(defun diff-framebuffers (prev curr)
|
||||||
"Compare PREV and CURR framebuffers. Return list of (X Y CELL) for changes."
|
"Compare PREV and CURR framebuffers. Return list of (X Y CELL) for changes."
|
||||||
(let ((changes nil)
|
(let ((changes nil)
|
||||||
@@ -282,9 +486,19 @@ See =docs/plans/2026-05-11-rendering-pipeline.md= for full implementation plan.
|
|||||||
(unless (cells-equal-p a b)
|
(unless (cells-equal-p a b)
|
||||||
(push (list x y b) changes)))))
|
(push (list x y b) changes)))))
|
||||||
(nreverse changes)))
|
(nreverse changes)))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
;;; ─── Flush ───────────────────────────────────────────────────────────────────
|
** Flush
|
||||||
|
|
||||||
|
*** flush-framebuffer
|
||||||
|
|
||||||
|
Orchestrates the full diff-and-flush cycle. Computes the difference between
|
||||||
|
previous and current framebuffers, then replays changes to a real backend using
|
||||||
|
minimal cursor movement (tracking the current row to avoid redundant cursor
|
||||||
|
positioning). Returns the count of changed cells so callers can monitor
|
||||||
|
rendering overhead.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/rendering/framebuffer.lisp
|
||||||
(defun flush-framebuffer (prev-fb curr-fb backend)
|
(defun flush-framebuffer (prev-fb curr-fb backend)
|
||||||
"Diff PREV-FB and CURR-FB and flush changes to BACKEND.
|
"Diff PREV-FB and CURR-FB and flush changes to BACKEND.
|
||||||
Returns the number of changed cells."
|
Returns the number of changed cells."
|
||||||
@@ -309,16 +523,29 @@ Returns the number of changed cells."
|
|||||||
|
|
||||||
** Frame inspection (for mouse selection / link clicking)
|
** Frame inspection (for mouse selection / link clicking)
|
||||||
|
|
||||||
#+BEGIN_SRC lisp :tangle ../src/rendering/framebuffer.lisp
|
*** fb-cell-link-url
|
||||||
;;; --- Frame inspection ---------------------------------------------------
|
|
||||||
|
|
||||||
|
Retrieves the hyperlink URL stored at cell position (X, Y) in a framebuffer
|
||||||
|
array. Returns nil if the cell is out of bounds or has no link. This enables
|
||||||
|
click-to-open-link functionality in the TUI.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/rendering/framebuffer.lisp
|
||||||
(defun fb-cell-link-url (fb x y)
|
(defun fb-cell-link-url (fb x y)
|
||||||
"Return the link URL at (X Y) in framebuffer FB, or nil."
|
"Return the link URL at (X Y) in framebuffer FB, or nil."
|
||||||
(when (and (arrayp fb) (>= y 0) (< y (array-dimension fb 0))
|
(when (and (arrayp fb) (>= y 0) (< y (array-dimension fb 0))
|
||||||
(>= x 0) (< x (array-dimension fb 1)))
|
(>= x 0) (< x (array-dimension fb 1)))
|
||||||
(let ((c (aref fb y x)))
|
(let ((c (aref fb y x)))
|
||||||
(cell-link-url c))))
|
(cell-link-url c))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
*** extract-text
|
||||||
|
|
||||||
|
Extracts visible text from a rectangular region of the framebuffer, useful for
|
||||||
|
mouse selection and clipboard operations. Normalizes coordinate order (so the
|
||||||
|
user can drag in any direction) and appends newlines between rows for natural
|
||||||
|
multi-line text.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/rendering/framebuffer.lisp
|
||||||
(defun extract-text (fb x1 y1 x2 y2)
|
(defun extract-text (fb x1 y1 x2 y2)
|
||||||
"Extract visible text from the rectangle between (X1,Y1) and (X2,Y2)."
|
"Extract visible text from the rectangle between (X1,Y1) and (X2,Y2)."
|
||||||
(let ((x-min (max 0 (min x1 x2))) (x-max (max 0 (max x1 x2)))
|
(let ((x-min (max 0 (min x1 x2))) (x-max (max 0 (max x1 x2)))
|
||||||
@@ -335,9 +562,14 @@ Returns the number of changed cells."
|
|||||||
|
|
||||||
** Scissor clipping
|
** Scissor clipping
|
||||||
|
|
||||||
#+BEGIN_SRC lisp :tangle ../src/rendering/framebuffer.lisp
|
*** with-scissor
|
||||||
;;; ─── Scissor clipping ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
A macro that temporarily sets the scissor rectangle on a framebuffer backend
|
||||||
|
for the duration of BODY. Saves and restores previous scissor state via
|
||||||
|
~unwind-protect~ for proper cleanup even on non-local exits. Using gensyms for
|
||||||
|
the state variables ensures no variable capture issues.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/rendering/framebuffer.lisp
|
||||||
(defmacro with-scissor ((fb x y w h) &body body)
|
(defmacro with-scissor ((fb x y w h) &body body)
|
||||||
"Clip all drawing on FB to rectangle (X Y W H)."
|
"Clip all drawing on FB to rectangle (X Y W H)."
|
||||||
(let ((old-x (gensym)) (old-y (gensym))
|
(let ((old-x (gensym)) (old-y (gensym))
|
||||||
@@ -357,7 +589,13 @@ Returns the number of changed cells."
|
|||||||
(fb-scissor-h ,fb) ,old-h)))))
|
(fb-scissor-h ,fb) ,old-h)))))
|
||||||
#+END_SRC
|
#+END_SRC
|
||||||
|
|
||||||
** Tests
|
* Tests
|
||||||
|
|
||||||
|
** Test package and suite setup
|
||||||
|
|
||||||
|
Setting up the test package with FiveAM, importing the rendering and backend
|
||||||
|
packages for use in all subsequent tests. This block tangles to the test file
|
||||||
|
that is loaded by the test runner.
|
||||||
|
|
||||||
#+BEGIN_SRC lisp :tangle ../tests/framebuffer-tests.lisp
|
#+BEGIN_SRC lisp :tangle ../tests/framebuffer-tests.lisp
|
||||||
(defpackage :cl-tty-framebuffer-test
|
(defpackage :cl-tty-framebuffer-test
|
||||||
@@ -366,18 +604,41 @@ Returns the number of changed cells."
|
|||||||
|
|
||||||
(def-suite framebuffer-suite :description "Framebuffer rendering pipeline tests")
|
(def-suite framebuffer-suite :description "Framebuffer rendering pipeline tests")
|
||||||
(in-suite framebuffer-suite)
|
(in-suite framebuffer-suite)
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** Test: make-framebuffer creates correct size
|
||||||
|
|
||||||
|
Verify that the framebuffer constructor produces an array with the expected
|
||||||
|
dimensions. Height should match the first dimension (rows), width the second
|
||||||
|
dimension (columns).
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../tests/framebuffer-tests.lisp
|
||||||
(test make-framebuffer-creates-correct-size
|
(test make-framebuffer-creates-correct-size
|
||||||
(let ((fb (make-framebuffer 80 24)))
|
(let ((fb (make-framebuffer 80 24)))
|
||||||
(is (= 24 (framebuffer-height fb)))
|
(is (= 24 (framebuffer-height fb)))
|
||||||
(is (= 80 (framebuffer-width fb)))))
|
(is (= 80 (framebuffer-width fb)))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** Test: cell defaults are space
|
||||||
|
|
||||||
|
Cells created via MAKE-CELL with no arguments should default to a space
|
||||||
|
character with nil foreground and background — a blank, unstyled cell.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../tests/framebuffer-tests.lisp
|
||||||
(test cell-defaults-are-space
|
(test cell-defaults-are-space
|
||||||
(let ((cell (aref (make-framebuffer 10 10) 0 0)))
|
(let ((cell (aref (make-framebuffer 10 10) 0 0)))
|
||||||
(is (eql #\space (cell-char cell)))
|
(is (eql #\space (cell-char cell)))
|
||||||
(is (null (cell-fg cell)))
|
(is (null (cell-fg cell)))
|
||||||
(is (null (cell-bg cell)))))
|
(is (null (cell-bg cell)))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** Test: draw-text on framebuffer sets cells
|
||||||
|
|
||||||
|
Drawing a string into the framebuffer backend should set the character and
|
||||||
|
foreground color at each cell position. Characters should appear at the expected
|
||||||
|
(x, y) offsets.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../tests/framebuffer-tests.lisp
|
||||||
(test draw-text-on-fb-sets-cells
|
(test draw-text-on-fb-sets-cells
|
||||||
(let ((fb (make-framebuffer-backend)))
|
(let ((fb (make-framebuffer-backend)))
|
||||||
(draw-text fb 2 3 "abc" :red nil)
|
(draw-text fb 2 3 "abc" :red nil)
|
||||||
@@ -386,7 +647,15 @@ Returns the number of changed cells."
|
|||||||
(is (eql #\b (cell-char (aref cells 3 3))))
|
(is (eql #\b (cell-char (aref cells 3 3))))
|
||||||
(is (eql #\c (cell-char (aref cells 3 4))))
|
(is (eql #\c (cell-char (aref cells 3 4))))
|
||||||
(is (eql :red (cell-fg (aref cells 3 2)))))))
|
(is (eql :red (cell-fg (aref cells 3 2)))))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** Test: draw-text clips at bounds
|
||||||
|
|
||||||
|
When drawing text that extends past the right edge of the framebuffer, cells
|
||||||
|
beyond the width should remain unchanged (space characters). This prevents
|
||||||
|
buffer overflow and undefined memory access.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../tests/framebuffer-tests.lisp
|
||||||
(test draw-text-clips-at-bounds
|
(test draw-text-clips-at-bounds
|
||||||
(let ((fb (make-framebuffer-backend :width 10 :height 5)))
|
(let ((fb (make-framebuffer-backend :width 10 :height 5)))
|
||||||
(draw-text fb 8 2 "hello" nil nil)
|
(draw-text fb 8 2 "hello" nil nil)
|
||||||
@@ -394,12 +663,26 @@ Returns the number of changed cells."
|
|||||||
(is (eql #\h (cell-char (aref cells 2 8))))
|
(is (eql #\h (cell-char (aref cells 2 8))))
|
||||||
(is (eql #\e (cell-char (aref cells 2 9))))
|
(is (eql #\e (cell-char (aref cells 2 9))))
|
||||||
(is (eql #\space (cell-char (aref cells 2 0))) "out of bounds text is ignored"))))
|
(is (eql #\space (cell-char (aref cells 2 0))) "out of bounds text is ignored"))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** Test: diff of identical framebuffers returns empty
|
||||||
|
|
||||||
|
Two framebuffers with identical cells should produce no changes. The diff
|
||||||
|
engine must short-circuit when no cells differ.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../tests/framebuffer-tests.lisp
|
||||||
(test diff-identical-fbs-returns-empty
|
(test diff-identical-fbs-returns-empty
|
||||||
(let ((fb1 (make-framebuffer 80 24))
|
(let ((fb1 (make-framebuffer 80 24))
|
||||||
(fb2 (make-framebuffer 80 24)))
|
(fb2 (make-framebuffer 80 24)))
|
||||||
(is (null (diff-framebuffers fb1 fb2)))))
|
(is (null (diff-framebuffers fb1 fb2)))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** Test: diff of changed framebuffer returns changes
|
||||||
|
|
||||||
|
After modifying a single cell in one framebuffer, the diff engine should return
|
||||||
|
exactly one change with the correct coordinates and cell data.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../tests/framebuffer-tests.lisp
|
||||||
(test diff-changed-fb-returns-changes
|
(test diff-changed-fb-returns-changes
|
||||||
(let* ((fb1 (make-framebuffer 10 10))
|
(let* ((fb1 (make-framebuffer 10 10))
|
||||||
(fb2 (make-framebuffer 10 10)))
|
(fb2 (make-framebuffer 10 10)))
|
||||||
@@ -410,7 +693,14 @@ Returns the number of changed cells."
|
|||||||
(is (= 5 x))
|
(is (= 5 x))
|
||||||
(is (= 5 y))
|
(is (= 5 y))
|
||||||
(is (eql #\X (cell-char cell)))))))
|
(is (eql #\X (cell-char cell)))))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** Test: with-scissor clips drawing
|
||||||
|
|
||||||
|
When a scissor rectangle is active, drawing operations outside the rectangle
|
||||||
|
should be clipped away. Operations inside the rectangle should proceed normally.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../tests/framebuffer-tests.lisp
|
||||||
(test with-scissor-clips-drawing
|
(test with-scissor-clips-drawing
|
||||||
(let ((fb (make-framebuffer-backend :width 20 :height 10)))
|
(let ((fb (make-framebuffer-backend :width 20 :height 10)))
|
||||||
(with-scissor (fb 5 5 3 3)
|
(with-scissor (fb 5 5 3 3)
|
||||||
@@ -419,7 +709,16 @@ Returns the number of changed cells."
|
|||||||
(let ((cells (fb-framebuffer fb)))
|
(let ((cells (fb-framebuffer fb)))
|
||||||
(is (eql #\A (cell-char (aref cells 6 6))) "inside scissor draws")
|
(is (eql #\A (cell-char (aref cells 6 6))) "inside scissor draws")
|
||||||
(is (eql #\space (cell-char (aref cells 1 1))) "outside scissor is clipped"))))
|
(is (eql #\space (cell-char (aref cells 1 1))) "outside scissor is clipped"))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** Test: flush handles different-sized framebuffers
|
||||||
|
|
||||||
|
When comparing framebuffers of different sizes, only the overlapping region
|
||||||
|
should be diffed. This test verifies correct behavior at both the smaller and
|
||||||
|
larger end of the size mismatch — ensuring edge cells in the non-overlapping
|
||||||
|
region are ignored.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../tests/framebuffer-tests.lisp
|
||||||
(test flush-different-sized-fbs-handles-edge-cells
|
(test flush-different-sized-fbs-handles-edge-cells
|
||||||
(let* ((small-fb (make-framebuffer 5 5))
|
(let* ((small-fb (make-framebuffer 5 5))
|
||||||
(large-fb (make-framebuffer 10 10))
|
(large-fb (make-framebuffer 10 10))
|
||||||
@@ -434,34 +733,80 @@ Returns the number of changed cells."
|
|||||||
(is (= 1 (length changes2)) "only overlapping region diffed"))
|
(is (= 1 (length changes2)) "only overlapping region diffed"))
|
||||||
(let ((changed2 (flush-framebuffer large-fb small-fb be)))
|
(let ((changed2 (flush-framebuffer large-fb small-fb be)))
|
||||||
(is (= 1 changed2) "flush with shrunk fb reports 1 changed cell"))))
|
(is (= 1 changed2) "flush with shrunk fb reports 1 changed cell"))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** Test: flush-fb copies to backend
|
||||||
|
|
||||||
|
After drawing on a framebuffer backend and flushing to a real backend, at least
|
||||||
|
one cell change should be detected and forwarded to the output backend.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../tests/framebuffer-tests.lisp
|
||||||
(test flush-fb-copies-to-backend
|
(test flush-fb-copies-to-backend
|
||||||
(let* ((real-be (make-simple-backend :output-stream (make-string-output-stream)))
|
(let* ((real-be (make-simple-backend :output-stream (make-string-output-stream)))
|
||||||
(fb (make-framebuffer-backend)))
|
(fb (make-framebuffer-backend)))
|
||||||
(draw-text fb 0 0 "X" :red nil)
|
(draw-text fb 0 0 "X" :red nil)
|
||||||
(let ((changed (flush-framebuffer (make-framebuffer 80 24) (fb-framebuffer fb) real-be)))
|
(let ((changed (flush-framebuffer (make-framebuffer 80 24) (fb-framebuffer fb) real-be)))
|
||||||
(is (>= changed 1)))))
|
(is (>= changed 1)))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** Test: fb-cell-link-url returns nil for blank cell
|
||||||
|
|
||||||
|
A cell without a hyperlink should return nil from ~fb-cell-link-url~, ensuring
|
||||||
|
the default state is correct and no spurious URL is reported.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../tests/framebuffer-tests.lisp
|
||||||
(test fb-cell-link-url-returns-nil-for-blank-cell
|
(test fb-cell-link-url-returns-nil-for-blank-cell
|
||||||
(let ((fb (make-framebuffer 10 10)))
|
(let ((fb (make-framebuffer 10 10)))
|
||||||
(is (null (fb-cell-link-url fb 5 5)))))
|
(is (null (fb-cell-link-url fb 5 5)))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** Test: fb-cell-link-url finds link-url
|
||||||
|
|
||||||
|
After drawing text with a link-url, the corresponding cell should return that
|
||||||
|
URL. Cells at other positions should still return nil. This validates that
|
||||||
|
link metadata is stored per-cell and correctly retrievable.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../tests/framebuffer-tests.lisp
|
||||||
(test fb-cell-link-url-finds-link-url
|
(test fb-cell-link-url-finds-link-url
|
||||||
(let ((fb (make-framebuffer-backend)))
|
(let ((fb (make-framebuffer-backend)))
|
||||||
(draw-text fb 0 0 "click" nil nil :link-url "https://example.com")
|
(draw-text fb 0 0 "click" nil nil :link-url "https://example.com")
|
||||||
(is (equal "https://example.com" (fb-cell-link-url (fb-framebuffer fb) 0 0)))
|
(is (equal "https://example.com" (fb-cell-link-url (fb-framebuffer fb) 0 0)))
|
||||||
(is (null (fb-cell-link-url (fb-framebuffer fb) 5 5)))))
|
(is (null (fb-cell-link-url (fb-framebuffer fb) 5 5)))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** Test: fb-cell-link-url out of bounds returns nil
|
||||||
|
|
||||||
|
Querying a cell position outside the framebuffer dimensions should gracefully
|
||||||
|
return nil rather than erroring, which prevents crashes during mouse event
|
||||||
|
processing at the edges of the terminal.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../tests/framebuffer-tests.lisp
|
||||||
(test fb-cell-link-url-out-of-bounds-returns-nil
|
(test fb-cell-link-url-out-of-bounds-returns-nil
|
||||||
(let ((fb (make-framebuffer 5 5)))
|
(let ((fb (make-framebuffer 5 5)))
|
||||||
(is (null (fb-cell-link-url fb 10 10)))))
|
(is (null (fb-cell-link-url fb 10 10)))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** Test: extract-text single row
|
||||||
|
|
||||||
|
Extracting text from a single row of the framebuffer should return the
|
||||||
|
characters in that row as a contiguous string, preserving order and including
|
||||||
|
only visible characters.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../tests/framebuffer-tests.lisp
|
||||||
(test extract-text-single-row
|
(test extract-text-single-row
|
||||||
(let ((fb (make-framebuffer-backend)))
|
(let ((fb (make-framebuffer-backend)))
|
||||||
(draw-text fb 0 0 "hello" nil nil)
|
(draw-text fb 0 0 "hello" nil nil)
|
||||||
(let ((cells (fb-framebuffer fb)))
|
(let ((cells (fb-framebuffer fb)))
|
||||||
(is (equal "hello" (extract-text cells 0 0 4 0))))))
|
(is (equal "hello" (extract-text cells 0 0 4 0))))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** Test: extract-text multi-row
|
||||||
|
|
||||||
|
Extracting text from a rectangle spanning multiple rows should concatenate
|
||||||
|
rows with newline separators. This matches the expected behavior for clipboard
|
||||||
|
copy of rectangular selections in the TUI.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../tests/framebuffer-tests.lisp
|
||||||
(test extract-text-multi-row
|
(test extract-text-multi-row
|
||||||
(let ((fb (make-framebuffer-backend)))
|
(let ((fb (make-framebuffer-backend)))
|
||||||
(draw-text fb 0 0 "abc" nil nil)
|
(draw-text fb 0 0 "abc" nil nil)
|
||||||
|
|||||||
@@ -42,42 +42,96 @@ unnecessary — ~200 lines of CL math suffices.
|
|||||||
|
|
||||||
* Tests
|
* Tests
|
||||||
|
|
||||||
|
** Test package definition
|
||||||
|
|
||||||
|
The test package uses ~:fiveam~ for the test framework and imports
|
||||||
|
all exported symbols from ~cl-tty.layout~.
|
||||||
|
|
||||||
#+BEGIN_SRC lisp :tangle ../src/layout/tests.lisp
|
#+BEGIN_SRC lisp :tangle ../src/layout/tests.lisp
|
||||||
(defpackage :cl-tty-layout-test
|
(defpackage :cl-tty-layout-test
|
||||||
(:use :cl :fiveam :cl-tty.layout)
|
(:use :cl :fiveam :cl-tty.layout)
|
||||||
(:export #:run-tests))
|
(:export #:run-tests))
|
||||||
(in-package :cl-tty-layout-test)
|
(in-package :cl-tty-layout-test)
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** Test suite
|
||||||
|
|
||||||
|
~fiveam~ suites collect related tests under a descriptive name for
|
||||||
|
batch execution.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/layout/tests.lisp
|
||||||
(def-suite layout-suite :description "Layout engine tests")
|
(def-suite layout-suite :description "Layout engine tests")
|
||||||
(in-suite layout-suite)
|
(in-suite layout-suite)
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** Test runner
|
||||||
|
|
||||||
|
~run-tests~ provides a convenient entry point that prints results and
|
||||||
|
exits cleanly for CI or batch runs.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/layout/tests.lisp
|
||||||
(defun run-tests ()
|
(defun run-tests ()
|
||||||
(let ((result (run 'layout-suite)))
|
(let ((result (run 'layout-suite)))
|
||||||
(fiveam:explain! result)
|
(fiveam:explain! result)
|
||||||
(uiop:quit 0)))
|
(uiop:quit 0)))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** Test: make-layout-node defaults
|
||||||
|
|
||||||
|
Verify that a node created with no arguments has the correct default
|
||||||
|
direction ~:column~ and is of type ~layout-node~.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/layout/tests.lisp
|
||||||
(test make-layout-node-defaults
|
(test make-layout-node-defaults
|
||||||
(let ((n (make-layout-node)))
|
(let ((n (make-layout-node)))
|
||||||
(is (typep n 'layout-node))
|
(is (typep n 'layout-node))
|
||||||
(is (eql (layout-node-direction n) :column))))
|
(is (eql (layout-node-direction n) :column))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** Test: make-layout-node with ~:row~
|
||||||
|
|
||||||
|
Verify that passing ~:direction :row~ produces a node whose direction
|
||||||
|
slot reflects that choice.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/layout/tests.lisp
|
||||||
(test make-layout-node-row
|
(test make-layout-node-row
|
||||||
(let ((n (make-layout-node :direction :row)))
|
(let ((n (make-layout-node :direction :row)))
|
||||||
(is (eql (layout-node-direction n) :row))))
|
(is (eql (layout-node-direction n) :row))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** Test: add-child sets parent
|
||||||
|
|
||||||
|
Children must have their ~parent~ back-pointer set when added, and
|
||||||
|
the parent's ~children~ list must contain the child.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/layout/tests.lisp
|
||||||
(test add-child-sets-parent
|
(test add-child-sets-parent
|
||||||
(let ((parent (make-layout-node)) (child (make-layout-node)))
|
(let ((parent (make-layout-node)) (child (make-layout-node)))
|
||||||
(layout-node-add-child parent child)
|
(layout-node-add-child parent child)
|
||||||
(is (eql (layout-node-parent child) parent))
|
(is (eql (layout-node-parent child) parent))
|
||||||
(is (= (length (layout-node-children parent)) 1))))
|
(is (= (length (layout-node-children parent)) 1))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** Test: remove-child clears parent
|
||||||
|
|
||||||
|
Removing a child should clear its parent reference and remove it
|
||||||
|
from the parent's ~children~ list.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/layout/tests.lisp
|
||||||
(test remove-child-clears-parent
|
(test remove-child-clears-parent
|
||||||
(let ((parent (make-layout-node)) (child (make-layout-node)))
|
(let ((parent (make-layout-node)) (child (make-layout-node)))
|
||||||
(layout-node-add-child parent child)
|
(layout-node-add-child parent child)
|
||||||
(layout-node-remove-child parent child)
|
(layout-node-remove-child parent child)
|
||||||
(is (null (layout-node-parent child)))
|
(is (null (layout-node-parent child)))
|
||||||
(is (= (length (layout-node-children parent)) 0))))
|
(is (= (length (layout-node-children parent)) 0))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** Test: column lays out two children vertically
|
||||||
|
|
||||||
|
In a column layout, children stack top-to-bottom. The first child
|
||||||
|
starts at y=0; the second starts below the first.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/layout/tests.lisp
|
||||||
(test column-two-children-vertical
|
(test column-two-children-vertical
|
||||||
(let* ((root (make-layout-node :direction :column))
|
(let* ((root (make-layout-node :direction :column))
|
||||||
(c1 (make-layout-node :height 3))
|
(c1 (make-layout-node :height 3))
|
||||||
@@ -86,7 +140,14 @@ unnecessary — ~200 lines of CL math suffices.
|
|||||||
(compute-layout root 20 20)
|
(compute-layout root 20 20)
|
||||||
(is (= (layout-node-y c1) 0)) (is (= (layout-node-height c1) 3))
|
(is (= (layout-node-y c1) 0)) (is (= (layout-node-height c1) 3))
|
||||||
(is (= (layout-node-y c2) 3)) (is (= (layout-node-height c2) 5))))
|
(is (= (layout-node-y c2) 3)) (is (= (layout-node-height c2) 5))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** Test: row lays out two children horizontally
|
||||||
|
|
||||||
|
In a row layout, children stack left-to-right. The first child starts
|
||||||
|
at x=0; the second starts to the right of the first.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/layout/tests.lisp
|
||||||
(test row-two-children-horizontal
|
(test row-two-children-horizontal
|
||||||
(let* ((root (make-layout-node :direction :row))
|
(let* ((root (make-layout-node :direction :row))
|
||||||
(c1 (make-layout-node :width 10))
|
(c1 (make-layout-node :width 10))
|
||||||
@@ -95,7 +156,15 @@ unnecessary — ~200 lines of CL math suffices.
|
|||||||
(compute-layout root 20 10)
|
(compute-layout root 20 10)
|
||||||
(is (= (layout-node-x c1) 0)) (is (= (layout-node-width c1) 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))))
|
(is (= (layout-node-x c2) 10)) (is (= (layout-node-width c2) 5))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** Test: flex-grow distributes remaining space proportionally
|
||||||
|
|
||||||
|
When children have different ~grow~ values, remaining space is
|
||||||
|
divided in proportion to those values. A child with grow=2 gets
|
||||||
|
twice as much extra space as a child with grow=1.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/layout/tests.lisp
|
||||||
(test flex-grow-distributes-space
|
(test flex-grow-distributes-space
|
||||||
(let* ((root (make-layout-node :direction :row :width 20))
|
(let* ((root (make-layout-node :direction :row :width 20))
|
||||||
(c1 (make-layout-node :width 4 :grow 1))
|
(c1 (make-layout-node :width 4 :grow 1))
|
||||||
@@ -103,14 +172,28 @@ unnecessary — ~200 lines of CL math suffices.
|
|||||||
(layout-node-add-child root c1) (layout-node-add-child root c2)
|
(layout-node-add-child root c1) (layout-node-add-child root c2)
|
||||||
(compute-layout root 20 10)
|
(compute-layout root 20 10)
|
||||||
(is (= (layout-node-width c1) 8)) (is (= (layout-node-width c2) 12))))
|
(is (= (layout-node-width c1) 8)) (is (= (layout-node-width c2) 12))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** Test: flex-grow single child fills container
|
||||||
|
|
||||||
|
A single flexible child with ~grow~ set should expand to fill all
|
||||||
|
available space in the container.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/layout/tests.lisp
|
||||||
(test flex-grow-single-child
|
(test flex-grow-single-child
|
||||||
(let* ((root (make-layout-node :direction :row :width 20))
|
(let* ((root (make-layout-node :direction :row :width 20))
|
||||||
(c (make-layout-node :width 5 :grow 1)))
|
(c (make-layout-node :width 5 :grow 1)))
|
||||||
(layout-node-add-child root c)
|
(layout-node-add-child root c)
|
||||||
(compute-layout root 20 10)
|
(compute-layout root 20 10)
|
||||||
(is (= (layout-node-width c) 20))))
|
(is (= (layout-node-width c) 20))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** Test: flex-shrink reduces overflow proportionally
|
||||||
|
|
||||||
|
When children exceed the container size, each child shrinks in
|
||||||
|
proportion to its ~shrink~ value.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/layout/tests.lisp
|
||||||
(test flex-shrink-reduces-overflow
|
(test flex-shrink-reduces-overflow
|
||||||
(let* ((root (make-layout-node :direction :row :width 10))
|
(let* ((root (make-layout-node :direction :row :width 10))
|
||||||
(c1 (make-layout-node :width 8 :shrink 1))
|
(c1 (make-layout-node :width 8 :shrink 1))
|
||||||
@@ -118,7 +201,14 @@ unnecessary — ~200 lines of CL math suffices.
|
|||||||
(layout-node-add-child root c1) (layout-node-add-child root c2)
|
(layout-node-add-child root c1) (layout-node-add-child root c2)
|
||||||
(compute-layout root 10 10)
|
(compute-layout root 10 10)
|
||||||
(is (= (layout-node-width c1) 5)) (is (= (layout-node-width c2) 5))))
|
(is (= (layout-node-width c1) 5)) (is (= (layout-node-width c2) 5))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** Test: padding reduces content area
|
||||||
|
|
||||||
|
Padding insets the child rendering area. Children are offset by the
|
||||||
|
padding values and sized to the remaining space.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/layout/tests.lisp
|
||||||
(test padding-reduces-content-area
|
(test padding-reduces-content-area
|
||||||
(let* ((root (make-layout-node :direction :column :padding '(:top 1 :left 1 :bottom 1 :right 1)))
|
(let* ((root (make-layout-node :direction :column :padding '(:top 1 :left 1 :bottom 1 :right 1)))
|
||||||
(c (make-layout-node :height 3)))
|
(c (make-layout-node :height 3)))
|
||||||
@@ -126,7 +216,14 @@ unnecessary — ~200 lines of CL math suffices.
|
|||||||
(compute-layout root 20 10)
|
(compute-layout root 20 10)
|
||||||
(is (= (layout-node-x c) 1)) (is (= (layout-node-y c) 1))
|
(is (= (layout-node-x c) 1)) (is (= (layout-node-y c) 1))
|
||||||
(is (= (layout-node-height c) 3))))
|
(is (= (layout-node-height c) 3))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** Test: gap between children
|
||||||
|
|
||||||
|
The ~gap~ property inserts spacing between consecutive children
|
||||||
|
without adding space before the first or after the last.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/layout/tests.lisp
|
||||||
(test gap-between-children
|
(test gap-between-children
|
||||||
(let* ((root (make-layout-node :direction :column :gap 2))
|
(let* ((root (make-layout-node :direction :column :gap 2))
|
||||||
(c1 (make-layout-node :height 3))
|
(c1 (make-layout-node :height 3))
|
||||||
@@ -134,25 +231,55 @@ unnecessary — ~200 lines of CL math suffices.
|
|||||||
(layout-node-add-child root c1) (layout-node-add-child root c2)
|
(layout-node-add-child root c1) (layout-node-add-child root c2)
|
||||||
(compute-layout root 20 20)
|
(compute-layout root 20 20)
|
||||||
(is (= (layout-node-y c1) 0)) (is (= (layout-node-y c2) 5))))
|
(is (= (layout-node-y c1) 0)) (is (= (layout-node-y c2) 5))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** Test: vbox macro
|
||||||
|
|
||||||
|
The ~vbox~ macro creates a column-direction container and adds
|
||||||
|
children in one expression. The second child's y-offset should be
|
||||||
|
the sum of the first child's height plus gap.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/layout/tests.lisp
|
||||||
(test vbox-macro
|
(test vbox-macro
|
||||||
(let ((r (vbox () (make-layout-node :height 3) (make-layout-node :height 5))))
|
(let ((r (vbox () (make-layout-node :height 3) (make-layout-node :height 5))))
|
||||||
(compute-layout r 20 20)
|
(compute-layout r 20 20)
|
||||||
(is (= (length (layout-node-children r)) 2))
|
(is (= (length (layout-node-children r)) 2))
|
||||||
(is (= (layout-node-y (elt (layout-node-children r) 1)) 3))))
|
(is (= (layout-node-y (elt (layout-node-children r) 1)) 3))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** Test: hbox macro
|
||||||
|
|
||||||
|
The ~hbox~ macro creates a row-direction container. The second
|
||||||
|
child's x-offset should equal the first child's width.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/layout/tests.lisp
|
||||||
(test hbox-macro
|
(test hbox-macro
|
||||||
(let ((r (hbox () (make-layout-node :width 5) (make-layout-node :width 3))))
|
(let ((r (hbox () (make-layout-node :width 5) (make-layout-node :width 3))))
|
||||||
(compute-layout r 20 10)
|
(compute-layout r 20 10)
|
||||||
(is (= (length (layout-node-children r)) 2))
|
(is (= (length (layout-node-children r)) 2))
|
||||||
(is (= (layout-node-x (elt (layout-node-children r) 1)) 5))))
|
(is (= (layout-node-x (elt (layout-node-children r) 1)) 5))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** Test: spacer takes grow
|
||||||
|
|
||||||
|
The ~spacer~ macro creates a flexible node that pushes siblings
|
||||||
|
apart. With two fixed-width children and a spacer between them, the
|
||||||
|
spacer absorbs all remaining width.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/layout/tests.lisp
|
||||||
(test spacer-takes-grow
|
(test spacer-takes-grow
|
||||||
(let ((r (hbox (:width 20) (make-layout-node :width 5) (spacer :grow 1) (make-layout-node :width 5))))
|
(let ((r (hbox (:width 20) (make-layout-node :width 5) (spacer :grow 1) (make-layout-node :width 5))))
|
||||||
(compute-layout r 20 10)
|
(compute-layout r 20 10)
|
||||||
(let ((c (layout-node-children r)))
|
(let ((c (layout-node-children r)))
|
||||||
(is (= (layout-node-x (elt c 2)) 15)) (is (= (layout-node-width (elt c 1)) 10)))))
|
(is (= (layout-node-x (elt c 2)) 15)) (is (= (layout-node-width (elt c 1)) 10)))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** Test: nested vbox in hbox
|
||||||
|
|
||||||
|
Nesting a column layout inside a row layout exercises the recursive
|
||||||
|
solver. Sidebar gets fixed width; main content stretches.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/layout/tests.lisp
|
||||||
(test nested-vbox-in-hbox
|
(test nested-vbox-in-hbox
|
||||||
(let* ((sidebar (vbox (:width 5 :height 10) (make-layout-node :height 3) (make-layout-node :height 7)))
|
(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)))
|
(main (vbox (:grow 1 :height 10) (make-layout-node :height 2) (make-layout-node :grow 1)))
|
||||||
@@ -163,15 +290,27 @@ unnecessary — ~200 lines of CL math suffices.
|
|||||||
(let ((sc (layout-node-children sidebar)))
|
(let ((sc (layout-node-children sidebar)))
|
||||||
(is (= (layout-node-y (elt sc 0)) 0))
|
(is (= (layout-node-y (elt sc 0)) 0))
|
||||||
(is (= (layout-node-y (elt sc 1)) 3)))))
|
(is (= (layout-node-y (elt sc 1)) 3)))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
;; ── Edge Cases ────────────────────────────────────────────────
|
** Test: empty container does not crash
|
||||||
|
|
||||||
|
Layout must gracefully handle containers with no children, returning
|
||||||
|
valid integer dimensions.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/layout/tests.lisp
|
||||||
(test empty-container-does-not-crash
|
(test empty-container-does-not-crash
|
||||||
(let ((r (make-layout-node)))
|
(let ((r (make-layout-node)))
|
||||||
(compute-layout r 20 20)
|
(compute-layout r 20 20)
|
||||||
(is (integerp (layout-node-width r)))
|
(is (integerp (layout-node-width r)))
|
||||||
(is (integerp (layout-node-height r)))))
|
(is (integerp (layout-node-height r)))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** Test: single child in column
|
||||||
|
|
||||||
|
A column with one child positions it at the origin and sizes it to
|
||||||
|
its requested height. Width is inherited from the container.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/layout/tests.lisp
|
||||||
(test single-child-in-column
|
(test single-child-in-column
|
||||||
(let* ((r (make-layout-node :direction :column :width 10 :height 20))
|
(let* ((r (make-layout-node :direction :column :width 10 :height 20))
|
||||||
(c (make-layout-node :height 5)))
|
(c (make-layout-node :height 5)))
|
||||||
@@ -179,7 +318,14 @@ unnecessary — ~200 lines of CL math suffices.
|
|||||||
(compute-layout r 10 20)
|
(compute-layout r 10 20)
|
||||||
(is (= (layout-node-y c) 0))
|
(is (= (layout-node-y c) 0))
|
||||||
(is (= (layout-node-height c) 5))))
|
(is (= (layout-node-height c) 5))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** Test: zero-size container
|
||||||
|
|
||||||
|
When available space is zero, the solver must still produce valid
|
||||||
|
integer coordinates without crashing or producing NaN/infinite values.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/layout/tests.lisp
|
||||||
(test zero-size-container
|
(test zero-size-container
|
||||||
(let* ((r (make-layout-node :direction :column))
|
(let* ((r (make-layout-node :direction :column))
|
||||||
(c (make-layout-node :height 5)))
|
(c (make-layout-node :height 5)))
|
||||||
@@ -187,7 +333,14 @@ unnecessary — ~200 lines of CL math suffices.
|
|||||||
(compute-layout r 0 0)
|
(compute-layout r 0 0)
|
||||||
(is (integerp (layout-node-x c)))
|
(is (integerp (layout-node-x c)))
|
||||||
(is (integerp (layout-node-y c)))))
|
(is (integerp (layout-node-y c)))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** Test: deep nesting three levels
|
||||||
|
|
||||||
|
Three levels of nested vboxes ensure that layout is computed
|
||||||
|
correctly for deeply nested subtrees.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/layout/tests.lisp
|
||||||
(test deep-nesting-three-levels
|
(test deep-nesting-three-levels
|
||||||
(let* ((out (vbox ()
|
(let* ((out (vbox ()
|
||||||
(vbox (:grow 1)
|
(vbox (:grow 1)
|
||||||
@@ -196,7 +349,14 @@ unnecessary — ~200 lines of CL math suffices.
|
|||||||
(elt (layout-node-children out) 0)) 0)))
|
(elt (layout-node-children out) 0)) 0)))
|
||||||
(compute-layout out 20 20)
|
(compute-layout out 20 20)
|
||||||
(is (= (layout-node-y leaf) 0))))
|
(is (= (layout-node-y leaf) 0))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** Test: large padding leaves room
|
||||||
|
|
||||||
|
Substantial padding on all sides should offset children inward by the
|
||||||
|
full padding amount.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/layout/tests.lisp
|
||||||
(test large-padding-leaves-room
|
(test large-padding-leaves-room
|
||||||
(let* ((r (make-layout-node :direction :column
|
(let* ((r (make-layout-node :direction :column
|
||||||
:padding '(:top 5 :left 5 :bottom 5 :right 5)))
|
:padding '(:top 5 :left 5 :bottom 5 :right 5)))
|
||||||
@@ -205,7 +365,14 @@ unnecessary — ~200 lines of CL math suffices.
|
|||||||
(compute-layout r 20 20)
|
(compute-layout r 20 20)
|
||||||
(is (= (layout-node-x c) 5))
|
(is (= (layout-node-x c) 5))
|
||||||
(is (= (layout-node-y c) 5))))
|
(is (= (layout-node-y c) 5))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** Test: negative grow is clamped
|
||||||
|
|
||||||
|
A negative ~grow~ value should not cause layout errors. The solver
|
||||||
|
treats it as zero for distribution purposes and produces valid output.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/layout/tests.lisp
|
||||||
(test negative-grow-is-clamped
|
(test negative-grow-is-clamped
|
||||||
(let* ((r (make-layout-node :direction :row :width 10))
|
(let* ((r (make-layout-node :direction :row :width 10))
|
||||||
(c (make-layout-node :width 5 :grow -1)))
|
(c (make-layout-node :width 5 :grow -1)))
|
||||||
@@ -218,6 +385,11 @@ unnecessary — ~200 lines of CL math suffices.
|
|||||||
|
|
||||||
** Package
|
** Package
|
||||||
|
|
||||||
|
The ~cl-tty.layout~ package exports all public symbols for creating
|
||||||
|
and manipulating layout trees. Internal accessors like
|
||||||
|
~layout-node-parent~ and helpers like ~normalize-box~ are also
|
||||||
|
exported for testing.
|
||||||
|
|
||||||
#+BEGIN_SRC lisp :tangle ../src/layout/layout.lisp
|
#+BEGIN_SRC lisp :tangle ../src/layout/layout.lisp
|
||||||
(defpackage :cl-tty.layout
|
(defpackage :cl-tty.layout
|
||||||
(:use :cl)
|
(:use :cl)
|
||||||
@@ -239,8 +411,11 @@ unnecessary — ~200 lines of CL math suffices.
|
|||||||
|
|
||||||
** Box model utilities
|
** Box model utilities
|
||||||
|
|
||||||
|
*** normalize-box
|
||||||
|
|
||||||
~normalize-box~ converts nil, number, or plist inputs to a canonical
|
~normalize-box~ converts nil, number, or plist inputs to a canonical
|
||||||
plist. ~box-edge~ extracts the value for a specific edge.
|
plist. This normalisation layer means users can pass ~:padding 2~ or
|
||||||
|
~:padding '(:top 1 :left 2)~ interchangeably throughout the API.
|
||||||
|
|
||||||
#+BEGIN_SRC lisp :tangle ../src/layout/layout.lisp
|
#+BEGIN_SRC lisp :tangle ../src/layout/layout.lisp
|
||||||
(defun normalize-box (spec)
|
(defun normalize-box (spec)
|
||||||
@@ -250,13 +425,27 @@ plist. ~box-edge~ extracts the value for a specific edge.
|
|||||||
for (key val) on spec by #'cddr
|
for (key val) on spec by #'cddr
|
||||||
do (setf (getf result key) val)
|
do (setf (getf result key) val)
|
||||||
finally (return result)))))
|
finally (return result)))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
*** box-edge
|
||||||
|
|
||||||
|
~box-edge~ extracts the value for a specific edge keyword from a
|
||||||
|
canonical box plist, defaulting to zero if the key is not present.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/layout/layout.lisp
|
||||||
(defun box-edge (box edge)
|
(defun box-edge (box edge)
|
||||||
(or (getf box edge) 0))
|
(or (getf box edge) 0))
|
||||||
#+END_SRC
|
#+END_SRC
|
||||||
|
|
||||||
** Layout node class
|
** Layout node class
|
||||||
|
|
||||||
|
The ~layout-node~ class holds all properties needed by the flexbox
|
||||||
|
layout algorithm. Slots are split between tree structure (~parent~,
|
||||||
|
~children~), computed layout results (~x~, ~y~, ~width~, ~height~),
|
||||||
|
and input constraints (~direction~, ~grow~, ~shrink~, ~padding~,
|
||||||
|
~margin~, ~gap~, ~position-type~, ~position-offset~, ~fixed-width~,
|
||||||
|
~fixed-height~).
|
||||||
|
|
||||||
#+BEGIN_SRC lisp :tangle ../src/layout/layout.lisp
|
#+BEGIN_SRC lisp :tangle ../src/layout/layout.lisp
|
||||||
(defclass layout-node ()
|
(defclass layout-node ()
|
||||||
((parent :initform nil :accessor layout-node-parent)
|
((parent :initform nil :accessor layout-node-parent)
|
||||||
@@ -279,6 +468,10 @@ plist. ~box-edge~ extracts the value for a specific edge.
|
|||||||
|
|
||||||
** Constructor
|
** Constructor
|
||||||
|
|
||||||
|
~make-layout-node~ is the primary constructor. It normalises all
|
||||||
|
keyword arguments through ~normalize-box~ for padding/margin, fills
|
||||||
|
defaults for missing values, and delegates to ~make-instance~.
|
||||||
|
|
||||||
#+BEGIN_SRC lisp :tangle ../src/layout/layout.lisp
|
#+BEGIN_SRC lisp :tangle ../src/layout/layout.lisp
|
||||||
(defun make-layout-node (&key direction grow shrink padding margin gap
|
(defun make-layout-node (&key direction grow shrink padding margin gap
|
||||||
position-type position-offset width height)
|
position-type position-offset width height)
|
||||||
@@ -294,13 +487,27 @@ plist. ~box-edge~ extracts the value for a specific edge.
|
|||||||
|
|
||||||
** Tree manipulation
|
** Tree manipulation
|
||||||
|
|
||||||
|
*** layout-node-add-child
|
||||||
|
|
||||||
|
~layout-node-add-child~ attaches a child to a parent by setting the
|
||||||
|
child's parent back-pointer and appending to the parent's children
|
||||||
|
list. Returns the child for convenience in chaining or ~let~ forms.
|
||||||
|
|
||||||
#+BEGIN_SRC lisp :tangle ../src/layout/layout.lisp
|
#+BEGIN_SRC lisp :tangle ../src/layout/layout.lisp
|
||||||
(defun layout-node-add-child (parent child)
|
(defun layout-node-add-child (parent child)
|
||||||
(setf (layout-node-parent child) parent)
|
(setf (layout-node-parent child) parent)
|
||||||
(setf (layout-node-children parent)
|
(setf (layout-node-children parent)
|
||||||
(nconc (layout-node-children parent) (list child)))
|
(nconc (layout-node-children parent) (list child)))
|
||||||
child)
|
child)
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
*** layout-node-remove-child
|
||||||
|
|
||||||
|
~layout-node-remove-child~ detaches a child by clearing its parent
|
||||||
|
back-pointer and removing it from the parent's children list.
|
||||||
|
Returns the child.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/layout/layout.lisp
|
||||||
(defun layout-node-remove-child (parent child)
|
(defun layout-node-remove-child (parent child)
|
||||||
(setf (layout-node-parent child) nil)
|
(setf (layout-node-parent child) nil)
|
||||||
(setf (layout-node-children parent)
|
(setf (layout-node-children parent)
|
||||||
@@ -310,10 +517,12 @@ plist. ~box-edge~ extracts the value for a specific edge.
|
|||||||
|
|
||||||
** Constraint solver
|
** Constraint solver
|
||||||
|
|
||||||
~distribute-sizes~ computes child sizes given available space and gap.
|
*** distribute-sizes
|
||||||
Each child starts from its fixed size. Remaining space is distributed
|
|
||||||
by grow ratio; overflow is reduced by shrink ratio. Rounding errors
|
~distribute-sizes~ computes child sizes given available space and
|
||||||
are amortized across the first N children.
|
gap. Each child starts from its fixed size. Remaining space is
|
||||||
|
distributed by grow ratio; overflow is reduced by shrink ratio.
|
||||||
|
Rounding errors are amortized across the first N children.
|
||||||
|
|
||||||
#+BEGIN_SRC lisp :tangle ../src/layout/layout.lisp
|
#+BEGIN_SRC lisp :tangle ../src/layout/layout.lisp
|
||||||
(defun distribute-sizes (children avail gap horizontal)
|
(defun distribute-sizes (children avail gap horizontal)
|
||||||
@@ -346,9 +555,13 @@ are amortized across the first N children.
|
|||||||
sizes)))
|
sizes)))
|
||||||
#+END_SRC
|
#+END_SRC
|
||||||
|
|
||||||
|
*** compute-layout
|
||||||
|
|
||||||
~compute-layout~ recursively lays out all children of the root node
|
~compute-layout~ recursively lays out all children of the root node
|
||||||
within given dimensions. It positions each child at the correct
|
within given dimensions. It positions each child at the correct
|
||||||
(x, y) coordinate and sizes it to fill the available space.
|
(x, y) coordinate and sizes it to fill the available space. The
|
||||||
|
inner ~labels~ form ~place-children~ handles the recursive descent,
|
||||||
|
adjusting for padding and direction at each level.
|
||||||
|
|
||||||
#+BEGIN_SRC lisp :tangle ../src/layout/layout.lisp
|
#+BEGIN_SRC lisp :tangle ../src/layout/layout.lisp
|
||||||
(defun compute-layout (root available-width available-height)
|
(defun compute-layout (root available-width available-height)
|
||||||
@@ -409,6 +622,12 @@ within given dimensions. It positions each child at the correct
|
|||||||
|
|
||||||
** Composable macros
|
** Composable macros
|
||||||
|
|
||||||
|
*** vbox
|
||||||
|
|
||||||
|
~vbox~ creates a column-direction container with optional layout
|
||||||
|
properties and adds all children via ~layout-node-add-child~. The
|
||||||
|
~gensym~ ensures no variable capture in the expansion.
|
||||||
|
|
||||||
#+BEGIN_SRC lisp :tangle ../src/layout/layout.lisp
|
#+BEGIN_SRC lisp :tangle ../src/layout/layout.lisp
|
||||||
(defmacro vbox ((&key grow shrink padding margin gap width height) &body children)
|
(defmacro vbox ((&key grow shrink padding margin gap width height) &body children)
|
||||||
(let ((n (gensym)))
|
(let ((n (gensym)))
|
||||||
@@ -422,7 +641,14 @@ within given dimensions. It positions each child at the correct
|
|||||||
,@(when height `(:height ,height)))))
|
,@(when height `(:height ,height)))))
|
||||||
,@(loop for c in children collect `(layout-node-add-child ,n ,c))
|
,@(loop for c in children collect `(layout-node-add-child ,n ,c))
|
||||||
,n)))
|
,n)))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
*** hbox
|
||||||
|
|
||||||
|
~hbox~ creates a row-direction container, structurally identical to
|
||||||
|
~vbox~ except the ~:direction~ is ~:row~.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/layout/layout.lisp
|
||||||
(defmacro hbox ((&key grow shrink padding margin gap width height) &body children)
|
(defmacro hbox ((&key grow shrink padding margin gap width height) &body children)
|
||||||
(let ((n (gensym)))
|
(let ((n (gensym)))
|
||||||
`(let ((,n (make-layout-node :direction :row
|
`(let ((,n (make-layout-node :direction :row
|
||||||
@@ -435,7 +661,14 @@ within given dimensions. It positions each child at the correct
|
|||||||
,@(when height `(:height ,height)))))
|
,@(when height `(:height ,height)))))
|
||||||
,@(loop for c in children collect `(layout-node-add-child ,n ,c))
|
,@(loop for c in children collect `(layout-node-add-child ,n ,c))
|
||||||
,n)))
|
,n)))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
*** spacer
|
||||||
|
|
||||||
|
~spacer~ creates a minimal flex-grow node that fills remaining space,
|
||||||
|
defaulting to ~grow 1~ when no keyword is given.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/layout/layout.lisp
|
||||||
(defmacro spacer (&key grow)
|
(defmacro spacer (&key grow)
|
||||||
`(make-layout-node :grow ,(or grow 1)))
|
`(make-layout-node :grow ,(or grow 1)))
|
||||||
#+END_SRC
|
#+END_SRC
|
||||||
|
|||||||
@@ -25,13 +25,33 @@ and diff rendering. Self-contained in ~cl-tty.markdown~ package.
|
|||||||
|
|
||||||
** Main module
|
** Main module
|
||||||
|
|
||||||
|
The main module file header includes the package declaration and a
|
||||||
|
comment indicating the file's purpose. This block is the first to
|
||||||
|
target ~markdown.lisp~ and thus overwrites any previous content;
|
||||||
|
all subsequent blocks append.
|
||||||
|
|
||||||
#+BEGIN_SRC lisp :tangle ../src/components/markdown.lisp
|
#+BEGIN_SRC lisp :tangle ../src/components/markdown.lisp
|
||||||
;;; markdown.lisp — Markdown + Code + Diff rendering for cl-tty
|
;;; markdown.lisp — Markdown + Code + Diff rendering for cl-tty
|
||||||
|
|
||||||
(in-package :cl-tty.markdown)
|
(in-package :cl-tty.markdown)
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
;; ─── Node constructors ────────────────────────────────────────────────────────
|
*** Node constructors
|
||||||
|
|
||||||
|
Node constructors provide a uniform way to build the AST for parsed
|
||||||
|
Markdown. Using plists (property lists) with a ~:type~ key gives us
|
||||||
|
flexibility — we can attach arbitrary metadata without a rigid class
|
||||||
|
hierarchy, which keeps the parser simple and the data easy to
|
||||||
|
introspect from the REPL.
|
||||||
|
|
||||||
|
**** make-md-node
|
||||||
|
|
||||||
|
~make-md-node~ is the primary constructor. It accepts a required ~type~
|
||||||
|
symbol and optional keyword arguments for ~children~, ~properties~,
|
||||||
|
~content~, and ~url~. Only non-nil slots are stored, keeping the
|
||||||
|
plist compact.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/components/markdown.lisp
|
||||||
(defun make-md-node (type &key children properties content url)
|
(defun make-md-node (type &key children properties content url)
|
||||||
(let ((node (list :type type)))
|
(let ((node (list :type type)))
|
||||||
(when children (setf (getf node :children) children))
|
(when children (setf (getf node :children) children))
|
||||||
@@ -39,10 +59,28 @@ and diff rendering. Self-contained in ~cl-tty.markdown~ package.
|
|||||||
(when content (setf (getf node :content) content))
|
(when content (setf (getf node :content) content))
|
||||||
(when url (setf (getf node :url) url))
|
(when url (setf (getf node :url) url))
|
||||||
node))
|
node))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
**** md-node-p
|
||||||
|
|
||||||
|
Predicate that checks whether a value is an AST node by verifying it
|
||||||
|
is a list and has a ~:type~ property. This uses plist access which
|
||||||
|
bypasses the need for ~typep~ or class-based dispatch.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/components/markdown.lisp
|
||||||
(defun md-node-p (thing)
|
(defun md-node-p (thing)
|
||||||
(and (listp thing) (getf thing :type)))
|
(and (listp thing) (getf thing :type)))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
**** md-node-text
|
||||||
|
|
||||||
|
~md-node-text~ recursively extracts the plain-text representation of a
|
||||||
|
node tree. The ~:link~ type formats as ~text (url)~; ~:text~ and
|
||||||
|
~:inline-code~ return their content directly; other container types
|
||||||
|
concatenate their children's text. This is useful for summarisation
|
||||||
|
and testing.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/components/markdown.lisp
|
||||||
(defun md-node-text (node)
|
(defun md-node-text (node)
|
||||||
(let ((type (getf node :type)))
|
(let ((type (getf node :type)))
|
||||||
(cond ((eql type :text) (or (getf node :content) ""))
|
(cond ((eql type :text) (or (getf node :content) ""))
|
||||||
@@ -55,9 +93,21 @@ and diff rendering. Self-contained in ~cl-tty.markdown~ package.
|
|||||||
(apply #'concatenate 'string
|
(apply #'concatenate 'string
|
||||||
(mapcar #'md-node-text (getf node :children))))
|
(mapcar #'md-node-text (getf node :children))))
|
||||||
(t ""))))
|
(t ""))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
;; ─── Block-level parser ───────────────────────────────────────────────────────
|
*** Block-level parser
|
||||||
|
|
||||||
|
The block parser splits raw text into lines and classifies each line
|
||||||
|
to determine what kind of block structure it begins. Helper functions
|
||||||
|
keep the main ~parse-blocks~ dispatch manageable.
|
||||||
|
|
||||||
|
**** split-string-into-lines
|
||||||
|
|
||||||
|
Handles ~CRLF~, ~LF~, and missing trailing newline uniformly.
|
||||||
|
Returns a ~vector~ for fast indexed access by line number during
|
||||||
|
parsing. Returns an empty vector for ~nil~ input.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/components/markdown.lisp
|
||||||
(defun split-string-into-lines (string)
|
(defun split-string-into-lines (string)
|
||||||
(unless string (return-from split-string-into-lines (coerce nil 'vector)))
|
(unless string (return-from split-string-into-lines (coerce nil 'vector)))
|
||||||
(let ((result nil) (start 0))
|
(let ((result nil) (start 0))
|
||||||
@@ -72,6 +122,14 @@ and diff rendering. Self-contained in ~cl-tty.markdown~ package.
|
|||||||
(coerce (nreverse result) 'vector))))
|
(coerce (nreverse result) 'vector))))
|
||||||
#+END_SRC
|
#+END_SRC
|
||||||
|
|
||||||
|
**** classify-line
|
||||||
|
|
||||||
|
The core line classification function. It checks line prefixes in
|
||||||
|
priority order — blank lines, thematic breaks, ATX headings, blockquote
|
||||||
|
markers, unordered/ordered list items, diff headers, diff lines, and
|
||||||
|
fenced code-block starts — and returns a ~(cons type data)~ pair.
|
||||||
|
Everything else is treated as a paragraph continuation line.
|
||||||
|
|
||||||
#+BEGIN_SRC lisp :tangle ../src/components/markdown.lisp
|
#+BEGIN_SRC lisp :tangle ../src/components/markdown.lisp
|
||||||
(defun classify-line (line)
|
(defun classify-line (line)
|
||||||
(cond
|
(cond
|
||||||
@@ -122,7 +180,15 @@ and diff rendering. Self-contained in ~cl-tty.markdown~ package.
|
|||||||
(subseq line fence-len))))
|
(subseq line fence-len))))
|
||||||
(cons :code-start rest))))))
|
(cons :code-start rest))))))
|
||||||
(t (cons :paragraph line))))
|
(t (cons :paragraph line))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
**** find-closing-marker
|
||||||
|
|
||||||
|
Scans for a literal marker string starting from position ~start~,
|
||||||
|
escaping backslash-escaped markers. This is shared by inline
|
||||||
|
emphasis, code span, and link parsing. Returns the position or ~nil~.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/components/markdown.lisp
|
||||||
(defun find-closing-marker (text start marker)
|
(defun find-closing-marker (text start marker)
|
||||||
(let ((marker-len (length marker)) (len (length text)))
|
(let ((marker-len (length marker)) (len (length text)))
|
||||||
(loop for j from start to (- len marker-len)
|
(loop for j from start to (- len marker-len)
|
||||||
@@ -133,6 +199,13 @@ and diff rendering. Self-contained in ~cl-tty.markdown~ package.
|
|||||||
finally (return nil))))
|
finally (return nil))))
|
||||||
#+END_SRC
|
#+END_SRC
|
||||||
|
|
||||||
|
**** parse-paragraph
|
||||||
|
|
||||||
|
Collects consecutive paragraph lines (lines classified as ~:paragraph~)
|
||||||
|
into a single ~:paragraph~ node. Stops at a blank line or any
|
||||||
|
non-paragraph classification. Lines are joined with spaces before
|
||||||
|
inline parsing.
|
||||||
|
|
||||||
#+BEGIN_SRC lisp :tangle ../src/components/markdown.lisp
|
#+BEGIN_SRC lisp :tangle ../src/components/markdown.lisp
|
||||||
(defun parse-paragraph (lines start)
|
(defun parse-paragraph (lines start)
|
||||||
(let ((text-parts nil) (i start))
|
(let ((text-parts nil) (i start))
|
||||||
@@ -152,7 +225,15 @@ and diff rendering. Self-contained in ~cl-tty.markdown~ package.
|
|||||||
do (unless first (write-char #\Space s))
|
do (unless first (write-char #\Space s))
|
||||||
(princ part s)))))
|
(princ part s)))))
|
||||||
i)))
|
i)))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
**** parse-blockquote
|
||||||
|
|
||||||
|
Like ~parse-paragraph~ but collects ~:blockquote~ lines and strips the
|
||||||
|
leading ~>~ marker. The collected text is then inline-parsed to
|
||||||
|
support bold, italic, code, and links inside quotes.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/components/markdown.lisp
|
||||||
(defun parse-blockquote (lines start)
|
(defun parse-blockquote (lines start)
|
||||||
(let ((text-parts nil) (i start))
|
(let ((text-parts nil) (i start))
|
||||||
(loop while (< i (length lines))
|
(loop while (< i (length lines))
|
||||||
@@ -173,6 +254,14 @@ and diff rendering. Self-contained in ~cl-tty.markdown~ package.
|
|||||||
i)))
|
i)))
|
||||||
#+END_SRC
|
#+END_SRC
|
||||||
|
|
||||||
|
**** parse-list
|
||||||
|
|
||||||
|
Handles both unordered (~:list-item~) and ordered (~:ordered-item~)
|
||||||
|
list items. Adjacent blank lines between items are allowed (creating
|
||||||
|
loose lists), but a blank line followed by a non-list line terminates
|
||||||
|
the list. Returns multiple nodes because each top-level list item
|
||||||
|
becomes its own ~:list-item~ or ~:ordered-item~ node.
|
||||||
|
|
||||||
#+BEGIN_SRC lisp :tangle ../src/components/markdown.lisp
|
#+BEGIN_SRC lisp :tangle ../src/components/markdown.lisp
|
||||||
(defun parse-list (lines start)
|
(defun parse-list (lines start)
|
||||||
(let ((items nil) (i start))
|
(let ((items nil) (i start))
|
||||||
@@ -200,6 +289,14 @@ and diff rendering. Self-contained in ~cl-tty.markdown~ package.
|
|||||||
(values (nreverse nodes) i))))
|
(values (nreverse nodes) i))))
|
||||||
#+END_SRC
|
#+END_SRC
|
||||||
|
|
||||||
|
**** parse-code-block
|
||||||
|
|
||||||
|
Parses a fenced code block starting at ~start~. The fence character
|
||||||
|
and length are detected from the opening line; the closing fence must
|
||||||
|
match in character and be at least as long. The language (if any) is
|
||||||
|
taken from the info string on the opening fence. Produces a single
|
||||||
|
~:code-block~ node.
|
||||||
|
|
||||||
#+BEGIN_SRC lisp :tangle ../src/components/markdown.lisp
|
#+BEGIN_SRC lisp :tangle ../src/components/markdown.lisp
|
||||||
(defun parse-code-block (lines start lang)
|
(defun parse-code-block (lines start lang)
|
||||||
(let ((code-lines nil)
|
(let ((code-lines nil)
|
||||||
@@ -227,7 +324,16 @@ and diff rendering. Self-contained in ~cl-tty.markdown~ package.
|
|||||||
for first = t then nil
|
for first = t then nil
|
||||||
do (unless first (terpri s)) (princ cl s))))
|
do (unless first (terpri s)) (princ cl s))))
|
||||||
i)))
|
i)))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
**** parse-diff-block
|
||||||
|
|
||||||
|
Collects consecutive diff lines (~:diff-header~, ~:diff-line~) into a
|
||||||
|
single ~:diff-block~ node. The raw lines are preserved in a ~:lines~
|
||||||
|
property for coloured rendering later. Diff blocks are delimited by
|
||||||
|
blank lines.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/components/markdown.lisp
|
||||||
(defun parse-diff-block (lines start)
|
(defun parse-diff-block (lines start)
|
||||||
(let ((diff-lines nil) (i start))
|
(let ((diff-lines nil) (i start))
|
||||||
(loop while (< i (length lines))
|
(loop while (< i (length lines))
|
||||||
@@ -249,6 +355,14 @@ and diff rendering. Self-contained in ~cl-tty.markdown~ package.
|
|||||||
i))))
|
i))))
|
||||||
#+END_SRC
|
#+END_SRC
|
||||||
|
|
||||||
|
**** parse-blocks
|
||||||
|
|
||||||
|
Top-level block parser. Dispatches on the ~classify-line~ result to
|
||||||
|
call the appropriate sub-parser, accumulating nodes into a list.
|
||||||
|
Handles blank lines, thematic breaks, headings, paragraphs,
|
||||||
|
blockquotes, lists, code blocks, and diff blocks. Returns ~nil~ for
|
||||||
|
~nil~ input.
|
||||||
|
|
||||||
#+BEGIN_SRC lisp :tangle ../src/components/markdown.lisp
|
#+BEGIN_SRC lisp :tangle ../src/components/markdown.lisp
|
||||||
(defun parse-blocks (text)
|
(defun parse-blocks (text)
|
||||||
(unless text (return-from parse-blocks nil))
|
(unless text (return-from parse-blocks nil))
|
||||||
@@ -289,9 +403,20 @@ and diff rendering. Self-contained in ~cl-tty.markdown~ package.
|
|||||||
(nreverse nodes)))
|
(nreverse nodes)))
|
||||||
#+END_SRC
|
#+END_SRC
|
||||||
|
|
||||||
#+BEGIN_SRC lisp :tangle ../src/components/markdown.lisp
|
*** Inline parser
|
||||||
;; ─── Inline parser ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
The inline parser handles character-level formatting inside block
|
||||||
|
content: emphasis, code spans, and links.
|
||||||
|
|
||||||
|
**** parse-inline
|
||||||
|
|
||||||
|
Main inline dispatcher. Walks the text character by character.
|
||||||
|
~*~ triggers star emphasis; ~_~ triggers underscore emphasis; ~`~
|
||||||
|
triggers inline code; ~[~ triggers links; everything else is
|
||||||
|
accumulated as plain ~:text~ nodes. Consecutive plain text is merged
|
||||||
|
into single nodes for efficiency.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/components/markdown.lisp
|
||||||
(defun parse-inline (text)
|
(defun parse-inline (text)
|
||||||
(unless (and text (> (length text) 0)) (return-from parse-inline nil))
|
(unless (and text (> (length text) 0)) (return-from parse-inline nil))
|
||||||
(let ((nodes nil) (i 0) (len (length text)))
|
(let ((nodes nil) (i 0) (len (length text)))
|
||||||
@@ -327,7 +452,17 @@ and diff rendering. Self-contained in ~cl-tty.markdown~ package.
|
|||||||
(incf i)))))
|
(incf i)))))
|
||||||
(push (make-md-node :text :content (subseq text start i)) nodes))))))
|
(push (make-md-node :text :content (subseq text start i)) nodes))))))
|
||||||
(nreverse nodes)))
|
(nreverse nodes)))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
**** parse-star-emphasis
|
||||||
|
|
||||||
|
Handles ~*italic*~ and ~**bold**~ using star markers. A double star
|
||||||
|
is tried first; if the closing ~**~ is found it produces a ~:bold~
|
||||||
|
node, otherwise it falls back to single-star ~:italic~. If neither
|
||||||
|
closes, returns ~nil~ to let the caller treat the character as literal
|
||||||
|
text.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/components/markdown.lisp
|
||||||
(defun parse-star-emphasis (text i len)
|
(defun parse-star-emphasis (text i len)
|
||||||
(when (>= i len) (return-from parse-star-emphasis (values nil i)))
|
(when (>= i len) (return-from parse-star-emphasis (values nil i)))
|
||||||
(if (and (< (1+ i) len) (char= (char text (1+ i)) #\*))
|
(if (and (< (1+ i) len) (char= (char text (1+ i)) #\*))
|
||||||
@@ -341,7 +476,17 @@ and diff rendering. Self-contained in ~cl-tty.markdown~ package.
|
|||||||
(values (make-md-node :italic :children (parse-inline (subseq text (1+ i) close)))
|
(values (make-md-node :italic :children (parse-inline (subseq text (1+ i) close)))
|
||||||
(1+ close))
|
(1+ close))
|
||||||
(values nil i)))))
|
(values nil i)))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
**** parse-underscore-emphasis
|
||||||
|
|
||||||
|
Handles ~_italic_~ and ~__bold__~ using underscore markers.
|
||||||
|
Underscore emphasis is more restrictive than star emphasis: it only
|
||||||
|
opens after whitespace or at the start of text, and single-underscore
|
||||||
|
italic only closes before whitespace or punctuation. This avoids false
|
||||||
|
positives in identifiers like ~foo_bar~.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/components/markdown.lisp
|
||||||
(defun parse-underscore-emphasis (text i len)
|
(defun parse-underscore-emphasis (text i len)
|
||||||
(when (>= i len) (return-from parse-underscore-emphasis (values nil i)))
|
(when (>= i len) (return-from parse-underscore-emphasis (values nil i)))
|
||||||
(when (and (> i 0) (not (find (char text (1- i)) " \t\n\r")))
|
(when (and (> i 0) (not (find (char text (1- i)) " \t\n\r")))
|
||||||
@@ -359,7 +504,15 @@ and diff rendering. Self-contained in ~cl-tty.markdown~ package.
|
|||||||
(values (make-md-node :italic :children (parse-inline (subseq text (1+ i) close)))
|
(values (make-md-node :italic :children (parse-inline (subseq text (1+ i) close)))
|
||||||
(1+ close))
|
(1+ close))
|
||||||
(values nil i)))))
|
(values nil i)))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
**** parse-inline-code
|
||||||
|
|
||||||
|
Parses backtick-delimited inline code spans. Supports up to three
|
||||||
|
backticks as delimiters (so single backticks inside double-backtick
|
||||||
|
spans work). The matched pair's backtick count must be equal.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/components/markdown.lisp
|
||||||
(defun parse-inline-code (text i len)
|
(defun parse-inline-code (text i len)
|
||||||
(when (or (>= i len) (not (char= (char text i) #\`)))
|
(when (or (>= i len) (not (char= (char text i) #\`)))
|
||||||
(return-from parse-inline-code (values nil i)))
|
(return-from parse-inline-code (values nil i)))
|
||||||
@@ -372,7 +525,16 @@ and diff rendering. Self-contained in ~cl-tty.markdown~ package.
|
|||||||
:content (subseq text (+ i bt-count) close))
|
:content (subseq text (+ i bt-count) close))
|
||||||
(+ close bt-count))
|
(+ close bt-count))
|
||||||
(values nil i)))))
|
(values nil i)))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
**** parse-link
|
||||||
|
|
||||||
|
Parses Markdown links in the form ~[text](url)~. Uses nested bracket
|
||||||
|
matching via ~find-closing-marker~. The text portion is inline-parsed
|
||||||
|
to support formatting inside link text. Returns ~nil~ if the syntax
|
||||||
|
is incomplete, letting the caller render the ~[~ as literal text.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/components/markdown.lisp
|
||||||
(defun parse-link (text i len)
|
(defun parse-link (text i len)
|
||||||
(when (or (>= i len) (not (char= (char text i) #\[)))
|
(when (or (>= i len) (not (char= (char text i) #\[)))
|
||||||
(return-from parse-link (values nil i)))
|
(return-from parse-link (values nil i)))
|
||||||
@@ -389,9 +551,24 @@ and diff rendering. Self-contained in ~cl-tty.markdown~ package.
|
|||||||
(1+ close-paren)))))
|
(1+ close-paren)))))
|
||||||
#+END_SRC
|
#+END_SRC
|
||||||
|
|
||||||
#+BEGIN_SRC lisp :tangle ../src/components/markdown.lisp
|
*** Syntax highlighting
|
||||||
;; ─── Syntax highlighting ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
Syntax highlighting tokenises source code into (token . category) pairs
|
||||||
|
that the renderer colours with ANSI escape codes. Each supported
|
||||||
|
language has a definition of comment, string, keyword, and builtin
|
||||||
|
patterns.
|
||||||
|
|
||||||
|
**** get-highlighter
|
||||||
|
|
||||||
|
Returns a plist of highlighting rules for a given language name.
|
||||||
|
The rules define ~:comment~, ~:string~, ~:keyword~, and ~:builtin~
|
||||||
|
patterns. Supported languages: lisp, common-lisp, python,
|
||||||
|
javascript, bash, shell. Unknown languages return ~nil~, which tells
|
||||||
|
the caller to fall back to plain rendering. The assoc list uses
|
||||||
|
~string=~ for matching on the language tag, and each entry uses a
|
||||||
|
dotted-pair format ~(\"language\" . plist)~.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/components/markdown.lisp
|
||||||
(defun get-highlighter (lang)
|
(defun get-highlighter (lang)
|
||||||
(cdr (assoc lang
|
(cdr (assoc lang
|
||||||
'(("lisp" . (:comment (";" "#|" ";;") :string ("\"")
|
'(("lisp" . (:comment (";" "#|" ";;") :string ("\"")
|
||||||
@@ -479,6 +656,15 @@ and diff rendering. Self-contained in ~cl-tty.markdown~ package.
|
|||||||
:test #'string=)))
|
:test #'string=)))
|
||||||
#+END_SRC
|
#+END_SRC
|
||||||
|
|
||||||
|
**** tokenize-line
|
||||||
|
|
||||||
|
Tokenises a single line of source code into ~(token . category)~
|
||||||
|
pairs. Categories are ~:plain~, ~:comment~, ~:string~, ~:number~,
|
||||||
|
~:keyword~, ~:builtin~, and ~:function~. The highlighter plist
|
||||||
|
provides the patterns for comment delimiters, string delimiters,
|
||||||
|
keywords, and builtins. Words immediately followed by ~(~ are
|
||||||
|
classified as ~:function~ calls.
|
||||||
|
|
||||||
#+BEGIN_SRC lisp :tangle ../src/components/markdown.lisp
|
#+BEGIN_SRC lisp :tangle ../src/components/markdown.lisp
|
||||||
(defun tokenize-line (line highlighter)
|
(defun tokenize-line (line highlighter)
|
||||||
(let ((tokens nil) (i 0) (len (length line))
|
(let ((tokens nil) (i 0) (len (length line))
|
||||||
@@ -546,7 +732,17 @@ and diff rendering. Self-contained in ~cl-tty.markdown~ package.
|
|||||||
(push (cons token :plain) tokens)))))))
|
(push (cons token :plain) tokens)))))))
|
||||||
(t (push (cons (string c) :plain) tokens) (incf i)))))
|
(t (push (cons (string c) :plain) tokens) (incf i)))))
|
||||||
(nreverse tokens)))
|
(nreverse tokens)))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
**** highlight-code
|
||||||
|
|
||||||
|
Applies syntax highlighting to a whole code string. Splits the code
|
||||||
|
into lines, tokenises each line with the language's highlighter, and
|
||||||
|
returns a flat list of ~(token . category)~ pairs with newline
|
||||||
|
separators between lines. Returns ~nil~ for empty input or a single
|
||||||
|
~:plain~ pair if no highlighter is found for the language.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/components/markdown.lisp
|
||||||
(defun highlight-code (code language)
|
(defun highlight-code (code language)
|
||||||
(unless code (return-from highlight-code nil))
|
(unless code (return-from highlight-code nil))
|
||||||
(let ((highlighter (get-highlighter (and language (string-downcase language)))))
|
(let ((highlighter (get-highlighter (and language (string-downcase language)))))
|
||||||
@@ -558,25 +754,59 @@ and diff rendering. Self-contained in ~cl-tty.markdown~ package.
|
|||||||
(when tokens (push (cons (string #\Newline) :plain) tokens))
|
(when tokens (push (cons (string #\Newline) :plain) tokens))
|
||||||
(setf tokens (nconc (nreverse line-tokens) tokens)))))
|
(setf tokens (nconc (nreverse line-tokens) tokens)))))
|
||||||
(nreverse tokens))))
|
(nreverse tokens))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
**** apply-highlight-token
|
||||||
|
|
||||||
|
Wraps a single token in an ANSI escape code based on its highlight
|
||||||
|
category. Keywords get colour 33 (yellow), builtins 36 (cyan),
|
||||||
|
functions 34 (blue), comments 2 (dim), strings 32 (green), numbers
|
||||||
|
35 (magenta). Unrecognised categories render as plain text.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/components/markdown.lisp
|
||||||
(defun apply-highlight-token (token category)
|
(defun apply-highlight-token (token category)
|
||||||
(let ((code (case category
|
(let ((code (case category
|
||||||
(:keyword "33") (:builtin "36")
|
(:keyword "33") (:builtin "36")
|
||||||
(:function "34") (:comment "2") (:string "32") (:number "35")
|
(:function "34") (:comment "2") (:string "32") (:number "35")
|
||||||
(t nil))))
|
(t nil))))
|
||||||
(if code (format nil "~c[~am~a~c[0m" #\Esc code token #\Esc) token)))
|
(if code (format nil "~c[~am~a~c[0m" #\Esc code token #\Esc) token)))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
**** apply-highlight-style
|
||||||
|
|
||||||
|
Coerces an adjustable character vector (accumulated during line
|
||||||
|
rendering) back into a string. This is a thin wrapper that exists
|
||||||
|
for potential future customisation of style application.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/components/markdown.lisp
|
||||||
(defun apply-highlight-style (char-vector)
|
(defun apply-highlight-style (char-vector)
|
||||||
(coerce char-vector 'string))
|
(coerce char-vector 'string))
|
||||||
#+END_SRC
|
#+END_SRC
|
||||||
|
|
||||||
#+BEGIN_SRC lisp :tangle ../src/components/markdown.lisp
|
*** Diff rendering
|
||||||
;; ─── Diff rendering ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
The diff rendering utilities classify diff lines and produce
|
||||||
|
colourised output.
|
||||||
|
|
||||||
|
**** string-prefix-p
|
||||||
|
|
||||||
|
Utility predicate that checks whether ~string~ starts with ~prefix~.
|
||||||
|
Avoids reimplementing this inline in multiple diff classifiers.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/components/markdown.lisp
|
||||||
(defun string-prefix-p (prefix string)
|
(defun string-prefix-p (prefix string)
|
||||||
(and (>= (length string) (length prefix))
|
(and (>= (length string) (length prefix))
|
||||||
(string= prefix (subseq string 0 (length prefix)))))
|
(string= prefix (subseq string 0 (length prefix)))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
**** classify-diff-line
|
||||||
|
|
||||||
|
Classifies a single diff line into a semantic category: ~:file-header~
|
||||||
|
(for ~+++~ and ~---~ lines), ~:hunk-header~ (for ~@@~ lines), ~:added~
|
||||||
|
(for ~+~ lines), ~:removed~ (for ~-~ lines), or ~:context~ (for
|
||||||
|
everything else). This powers colourised diff rendering.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/components/markdown.lisp
|
||||||
(defun classify-diff-line (line)
|
(defun classify-diff-line (line)
|
||||||
(cond ((string-prefix-p "+++ " line) :file-header)
|
(cond ((string-prefix-p "+++ " line) :file-header)
|
||||||
((string-prefix-p "--- " line) :file-header)
|
((string-prefix-p "--- " line) :file-header)
|
||||||
@@ -584,9 +814,23 @@ and diff rendering. Self-contained in ~cl-tty.markdown~ package.
|
|||||||
((string-prefix-p "+" line) :added)
|
((string-prefix-p "+" line) :added)
|
||||||
((string-prefix-p "-" line) :removed)
|
((string-prefix-p "-" line) :removed)
|
||||||
(t :context)))
|
(t :context)))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
;; ─── Rendering ────────────────────────────────────────────────────────────────
|
*** Rendering
|
||||||
|
|
||||||
|
The rendering layer converts parsed AST nodes into styled terminal
|
||||||
|
output strings. Each node type has its own renderer, and
|
||||||
|
~render-md-node~ dispatches to the correct one.
|
||||||
|
|
||||||
|
**** apply-style
|
||||||
|
|
||||||
|
Wraps ~text~ in ANSI escape codes for a given ~style~ keyword or
|
||||||
|
string. Supports both keyword (e.g. ~:bold~) and string (e.g.
|
||||||
|
~\"bold\"~) style designators for flexibility. Common styles include
|
||||||
|
bold, italic, dim, code, link, underline, and the full set of 16
|
||||||
|
terminal colours. Unrecognised styles return the text unchanged.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/components/markdown.lisp
|
||||||
(defun apply-style (style text)
|
(defun apply-style (style text)
|
||||||
(let ((code (cond
|
(let ((code (cond
|
||||||
((eql style :bold) "1") ((eql style :italic) "3")
|
((eql style :bold) "1") ((eql style :italic) "3")
|
||||||
@@ -619,6 +863,13 @@ and diff rendering. Self-contained in ~cl-tty.markdown~ package.
|
|||||||
(if code (format nil "~c[~am~a~c[0m" #\Esc code text #\Esc) text)))
|
(if code (format nil "~c[~am~a~c[0m" #\Esc code text #\Esc) text)))
|
||||||
#+END_SRC
|
#+END_SRC
|
||||||
|
|
||||||
|
**** render-inline
|
||||||
|
|
||||||
|
Renders a list of inline child nodes into a single string. Handles
|
||||||
|
~:text~ (plain), ~:bold~, ~:italic~, ~:inline-code~, and ~:link~
|
||||||
|
types. Links render the text styled as link followed by the URL in
|
||||||
|
parentheses styled as url.
|
||||||
|
|
||||||
#+BEGIN_SRC lisp :tangle ../src/components/markdown.lisp
|
#+BEGIN_SRC lisp :tangle ../src/components/markdown.lisp
|
||||||
(defun render-inline (children)
|
(defun render-inline (children)
|
||||||
(if (null children) ""
|
(if (null children) ""
|
||||||
@@ -637,7 +888,16 @@ and diff rendering. Self-contained in ~cl-tty.markdown~ package.
|
|||||||
(princ " " s)
|
(princ " " s)
|
||||||
(princ (apply-style :url (format nil "(~a)" url)) s))))
|
(princ (apply-style :url (format nil "(~a)" url)) s))))
|
||||||
(t (princ (or (getf child :content) "") s))))))))
|
(t (princ (or (getf child :content) "") s))))))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
**** render-heading
|
||||||
|
|
||||||
|
Renders a heading node as a coloured ~# Title~ line. The heading
|
||||||
|
level determines the number of ~#~ characters (capped at 6) and the
|
||||||
|
colour: level 1 uses bright-cyan, level 2 uses bright-yellow, and
|
||||||
|
deeper levels use bright-white.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/components/markdown.lisp
|
||||||
(defun render-heading (node)
|
(defun render-heading (node)
|
||||||
(let* ((level (or (getf (getf node :properties) :level) 1))
|
(let* ((level (or (getf (getf node :properties) :level) 1))
|
||||||
(prefix (make-string (min level 6) :initial-element #\#))
|
(prefix (make-string (min level 6) :initial-element #\#))
|
||||||
@@ -645,15 +905,36 @@ and diff rendering. Self-contained in ~cl-tty.markdown~ package.
|
|||||||
(color (cond ((= level 1) :bright-cyan) ((= level 2) :bright-yellow)
|
(color (cond ((= level 1) :bright-cyan) ((= level 2) :bright-yellow)
|
||||||
(t :bright-white))))
|
(t :bright-white))))
|
||||||
(list (apply-style color (concatenate 'string prefix " " text)))))
|
(list (apply-style color (concatenate 'string prefix " " text)))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
**** render-paragraph
|
||||||
|
|
||||||
|
Renders a paragraph node by inline-rendering its children. The
|
||||||
|
result is a single-element list containing the rendered text.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/components/markdown.lisp
|
||||||
(defun render-paragraph (node)
|
(defun render-paragraph (node)
|
||||||
(list (render-inline (getf node :children))))
|
(list (render-inline (getf node :children))))
|
||||||
#+END_SRC
|
#+END_SRC
|
||||||
|
|
||||||
|
**** render-blockquote
|
||||||
|
|
||||||
|
Renders a blockquote node with a dimmed ~> ~ prefix before the
|
||||||
|
inline-rendered content.
|
||||||
|
|
||||||
#+BEGIN_SRC lisp :tangle ../src/components/markdown.lisp
|
#+BEGIN_SRC lisp :tangle ../src/components/markdown.lisp
|
||||||
(defun render-blockquote (node)
|
(defun render-blockquote (node)
|
||||||
(list (apply-style :dim (concatenate 'string "> " (render-inline (getf node :children))))))
|
(list (apply-style :dim (concatenate 'string "> " (render-inline (getf node :children))))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
**** render-code-block
|
||||||
|
|
||||||
|
Renders a fenced code block. If the block has a language tag and the
|
||||||
|
highlighter supports it, the code is syntax-highlighted with ANSI
|
||||||
|
colours. Otherwise it is rendered in plain ~:code~ style. A dimmed
|
||||||
|
language header line is shown when a language is present.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/components/markdown.lisp
|
||||||
(defun render-code-block (node)
|
(defun render-code-block (node)
|
||||||
(let* ((language (or (getf (getf node :properties) :language) ""))
|
(let* ((language (or (getf (getf node :properties) :language) ""))
|
||||||
(content (or (getf node :content) ""))
|
(content (or (getf node :content) ""))
|
||||||
@@ -681,7 +962,16 @@ and diff rendering. Self-contained in ~cl-tty.markdown~ package.
|
|||||||
(loop for line = (read-line s nil nil) while line
|
(loop for line = (read-line s nil nil) while line
|
||||||
do (push (apply-style :code line) lines))))
|
do (push (apply-style :code line) lines))))
|
||||||
(nreverse lines)))
|
(nreverse lines)))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
**** render-diff-block
|
||||||
|
|
||||||
|
Renders a diff block by classifying each line and applying
|
||||||
|
colour: added lines in green (32), removed in red (31), hunk headers
|
||||||
|
in cyan (36), file headers in bold-cyan (1;36), and context lines
|
||||||
|
unstyled.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/components/markdown.lisp
|
||||||
(defun render-diff-block (node)
|
(defun render-diff-block (node)
|
||||||
(let* ((lines (getf (getf node :properties) :lines)) (result nil))
|
(let* ((lines (getf (getf node :properties) :lines)) (result nil))
|
||||||
(dolist (line (or lines
|
(dolist (line (or lines
|
||||||
@@ -696,16 +986,38 @@ and diff rendering. Self-contained in ~cl-tty.markdown~ package.
|
|||||||
(push (format nil "~c[~am~a~c[0m" #\Esc color line #\Esc) result)
|
(push (format nil "~c[~am~a~c[0m" #\Esc color line #\Esc) result)
|
||||||
(push line result))))
|
(push line result))))
|
||||||
(nreverse result)))
|
(nreverse result)))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
**** render-thematic-break
|
||||||
|
|
||||||
|
Renders a thematic break as a dimmed horizontal rule using
|
||||||
|
Unicode box-drawing characters.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/components/markdown.lisp
|
||||||
(defun render-thematic-break (node)
|
(defun render-thematic-break (node)
|
||||||
(declare (ignore node))
|
(declare (ignore node))
|
||||||
(list (apply-style :dim "──────────────────────────────────────────────")))
|
(list (apply-style :dim "──────────────────────────────────────────────")))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
**** render-list-item
|
||||||
|
|
||||||
|
Renders a list item node. Ordered items get ~ 1.~ prefix,
|
||||||
|
unordered items get ~ * ~ prefix. The content is inline-rendered.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/components/markdown.lisp
|
||||||
(defun render-list-item (node)
|
(defun render-list-item (node)
|
||||||
(list (concatenate 'string
|
(list (concatenate 'string
|
||||||
(if (eql (getf node :type) :ordered-item) " 1." " * ")
|
(if (eql (getf node :type) :ordered-item) " 1." " * ")
|
||||||
(render-inline (getf node :children)))))
|
(render-inline (getf node :children)))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
**** render-md-node
|
||||||
|
|
||||||
|
Dispatcher function that routes a single AST node to the correct
|
||||||
|
renderer based on its ~:type~. Each type-specific renderer returns a
|
||||||
|
list of strings (multiple lines), which ~render-md~ concatenates.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/components/markdown.lisp
|
||||||
(defun render-md-node (node)
|
(defun render-md-node (node)
|
||||||
(let ((type (getf node :type)))
|
(let ((type (getf node :type)))
|
||||||
(case type
|
(case type
|
||||||
@@ -718,12 +1030,28 @@ and diff rendering. Self-contained in ~cl-tty.markdown~ package.
|
|||||||
(:list-item (render-list-item node))
|
(:list-item (render-list-item node))
|
||||||
(:ordered-item (render-list-item node))
|
(:ordered-item (render-list-item node))
|
||||||
(t (list "")))))
|
(t (list "")))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
**** render-md
|
||||||
|
|
||||||
|
Renders a list of AST nodes (the output of ~parse-blocks~) into a
|
||||||
|
flat list of output lines by calling ~render-md-node~ on each node
|
||||||
|
and concatenating the results.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/components/markdown.lisp
|
||||||
(defun render-md (nodes)
|
(defun render-md (nodes)
|
||||||
(let ((lines nil))
|
(let ((lines nil))
|
||||||
(dolist (node nodes) (setf lines (nconc lines (render-md-node node))))
|
(dolist (node nodes) (setf lines (nconc lines (render-md-node node))))
|
||||||
lines))
|
lines))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
**** render-markdown
|
||||||
|
|
||||||
|
Top-level convenience function that parses a Markdown string and
|
||||||
|
renders it to a single output string with newline-separated lines.
|
||||||
|
Returns an empty string for ~nil~ input.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/components/markdown.lisp
|
||||||
(defun render-markdown (text)
|
(defun render-markdown (text)
|
||||||
(unless text (return-from render-markdown ""))
|
(unless text (return-from render-markdown ""))
|
||||||
(let ((nodes (parse-blocks text)) (parts nil))
|
(let ((nodes (parse-blocks text)) (parts nil))
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ escape sequences. It supports truecolor 24-bit color, OSC 8 hyperlinks,
|
|||||||
DECICM synchronized updates, SGR mouse tracking, kitty keyboard protocol,
|
DECICM synchronized updates, SGR mouse tracking, kitty keyboard protocol,
|
||||||
and Unicode box-drawing characters (single, double, rounded).
|
and Unicode box-drawing characters (single, double, rounded).
|
||||||
|
|
||||||
All rendering functions produce CSI/OSC escape sequences directly — no
|
All rendering functions produce CSI/OSC escape sequences directly --- no
|
||||||
ncurses, no terminfo, no FFI. Color resolution handles named colors
|
ncurses, no terminfo, no FFI. Color resolution handles named colors
|
||||||
(~:red~, ~:blue~, etc.), hex strings (~"#FFD700"~), and semantic theme
|
(~:red~, ~:blue~, etc.), hex strings (~"#FFD700"~), and semantic theme
|
||||||
roles (~:accent~, ~:error~) via the ~*theme-colors*~ hash table.
|
roles (~:accent~, ~:error~) via the ~*theme-colors*~ hash table.
|
||||||
@@ -18,166 +18,281 @@ roles (~:accent~, ~:error~) via the ~*theme-colors*~ hash table.
|
|||||||
|
|
||||||
** Color and attribute helpers
|
** Color and attribute helpers
|
||||||
|
|
||||||
- ~(hex-to-rgb hex)~ → (values r g b) — parse "#RRGGBB" or "#RGB"
|
- ~(hex-to-rgb hex)~ (r g b) --- parse "#RRGGBB" or "#RGB"
|
||||||
- ~(sgr-fg color)~ → escape string — foreground color escape
|
- ~(sgr-fg color)~ escape string --- foreground color escape
|
||||||
- ~(sgr-bg color)~ → escape string — background color escape
|
- ~(sgr-bg color)~ escape string --- background color escape
|
||||||
- ~(sgr-attr attr)~ → escape string — attribute escape (bold, italic, etc.)
|
- ~(sgr-attr attr)~ escape string --- attribute escape (bold, italic, etc.)
|
||||||
|
|
||||||
** Cursor helpers
|
** Cursor helpers
|
||||||
|
|
||||||
- ~(cursor-move-escape x y)~ → escape string — CSI cursor position
|
- ~(cursor-move-escape x y)~ escape string --- CSI cursor position
|
||||||
- ~(cursor-style-escape shape blink)~ → escape string — DECSTR cursor shape
|
- ~(cursor-style-escape shape blink)~ escape string --- DECSTR cursor shape
|
||||||
|
|
||||||
** Sync and link helpers
|
** Sync and link helpers
|
||||||
|
|
||||||
- ~(decicm-begin)~ → escape string — enable synchronized updates
|
- ~(decicm-begin)~ escape string --- enable synchronized updates
|
||||||
- ~(decicm-end)~ → escape string — disable synchronized updates
|
- ~(decicm-end)~ escape string --- disable synchronized updates
|
||||||
- ~(osc8-link url text)~ → escape string — OSC 8 hyperlink wrapper
|
- ~(osc8-link url text)~ escape string --- OSC 8 hyperlink wrapper
|
||||||
|
|
||||||
** Border helpers
|
** Border helpers
|
||||||
|
|
||||||
- ~(border-char style pos)~ → string — Unicode box-drawing character
|
- ~(border-char style pos)~ string --- Unicode box-drawing character
|
||||||
|
|
||||||
** Modern backend class
|
** Modern backend class
|
||||||
|
|
||||||
- ~(make-modern-backend &key output-stream)~ → modern-backend
|
- ~(make-modern-backend &key output-stream)~ modern-backend
|
||||||
- Implements all ~backend~ protocol methods with escape sequences
|
- Implements all ~backend~ protocol methods with escape sequences
|
||||||
|
|
||||||
* Tests
|
* Tests
|
||||||
|
|
||||||
|
The test suite lives in =modern-tests.lisp= and uses FiveAM. Each test
|
||||||
|
covers one logical behavior.
|
||||||
|
|
||||||
|
** Package and setup
|
||||||
|
|
||||||
|
The test package uses =cl-tty.backend= to access internal symbols for
|
||||||
|
white-box testing of escape generation.
|
||||||
|
|
||||||
#+BEGIN_SRC lisp :tangle ../src/backend/modern-tests.lisp
|
#+BEGIN_SRC lisp :tangle ../src/backend/modern-tests.lisp
|
||||||
(defpackage :cl-tty-modern-backend-test
|
(defpackage :cl-tty-modern-backend-test
|
||||||
(:use :cl :fiveam :cl-tty.backend)
|
(:use :cl :fiveam :cl-tty.backend)
|
||||||
(:export #:run-tests))
|
(:export #:run-tests))
|
||||||
(in-package :cl-tty-modern-backend-test)
|
(in-package :cl-tty-modern-backend-test)
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** Suite definition
|
||||||
|
|
||||||
|
A single suite groups all modern backend tests.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/backend/modern-tests.lisp
|
||||||
(def-suite modern-backend-suite :description "Modern backend tests")
|
(def-suite modern-backend-suite :description "Modern backend tests")
|
||||||
(in-suite modern-backend-suite)
|
(in-suite modern-backend-suite)
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** Test runner
|
||||||
|
|
||||||
|
The =run-tests= entry point is called by the CI test harness.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/backend/modern-tests.lisp
|
||||||
(defun run-tests ()
|
(defun run-tests ()
|
||||||
(let ((result (run 'modern-backend-suite)))
|
(let ((result (run 'modern-backend-suite)))
|
||||||
(fiveam:explain! result)
|
(fiveam:explain! result)
|
||||||
(uiop:quit 0)))
|
(uiop:quit 0)))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
;; ── Constructor ────────────────────────────────────────────────
|
** Constructor test
|
||||||
|
|
||||||
|
Verifies that =make-modern-backend= returns an instance of the correct
|
||||||
|
class. This is the most basic smoke test for the backend factory.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/backend/modern-tests.lisp
|
||||||
(test make-modern-backend-creates
|
(test make-modern-backend-creates
|
||||||
"make-modern-backend returns a modern-backend instance"
|
"make-modern-backend returns a modern-backend instance"
|
||||||
(let ((b (make-modern-backend)))
|
(let ((b (make-modern-backend)))
|
||||||
(is (typep b 'cl-tty.backend::modern-backend))))
|
(is (typep b 'cl-tty.backend::modern-backend))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
;; ── Escape Generation ──────────────────────────────────────────
|
** SGR truecolor foreground escape
|
||||||
|
|
||||||
|
Ensures a 6-digit hex string produces the correct 24-bit foreground
|
||||||
|
escape sequence with red, green, and blue components in the right order.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/backend/modern-tests.lisp
|
||||||
(test sgr-truecolor-foreground
|
(test sgr-truecolor-foreground
|
||||||
"SGR truecolor foreground escape is correct"
|
"SGR truecolor foreground escape is correct"
|
||||||
(is (equal (cl-tty.backend::sgr-fg "#FFD700")
|
(is (equal (cl-tty.backend::sgr-fg "#FFD700")
|
||||||
(format nil "~C[38;2;255;215;0m" #\Esc))))
|
(format nil "~C[38;2;255;215;0m" #\Esc))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** SGR truecolor background escape
|
||||||
|
|
||||||
|
Same as foreground but uses the =48= background prefix instead of =38=.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/backend/modern-tests.lisp
|
||||||
(test sgr-truecolor-background
|
(test sgr-truecolor-background
|
||||||
"SGR truecolor background escape is correct"
|
"SGR truecolor background escape is correct"
|
||||||
(is (equal (cl-tty.backend::sgr-bg "#1a1b26")
|
(is (equal (cl-tty.backend::sgr-bg "#1a1b26")
|
||||||
(format nil "~C[48;2;26;27;38m" #\Esc))))
|
(format nil "~C[48;2;26;27;38m" #\Esc))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** SGR named color resolution
|
||||||
|
|
||||||
|
Verifies that keyword symbols like =:red= and =:blue= resolve to the
|
||||||
|
standard 8-color SGR codes (=31= foreground, =44= background).
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/backend/modern-tests.lisp
|
||||||
(test sgr-named-colors
|
(test sgr-named-colors
|
||||||
"SGR named colors resolve to 8-color codes"
|
"SGR named colors resolve to 8-color codes"
|
||||||
(is (equal (cl-tty.backend::sgr-fg :red)
|
(is (equal (cl-tty.backend::sgr-fg :red)
|
||||||
(format nil "~C[31m" #\Esc)))
|
(format nil "~C[31m" #\Esc)))
|
||||||
(is (equal (cl-tty.backend::sgr-bg :blue)
|
(is (equal (cl-tty.backend::sgr-bg :blue)
|
||||||
(format nil "~C[44m" #\Esc))))
|
(format nil "~C[44m" #\Esc))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** SGR attribute escapes
|
||||||
|
|
||||||
|
Each attribute keyword (=:bold=, =:italic=, =:underline=, =:reset=)
|
||||||
|
should map to the correct SGR number.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/backend/modern-tests.lisp
|
||||||
(test sgr-bold-italic
|
(test sgr-bold-italic
|
||||||
"SGR attribute escapes are correct"
|
"SGR attribute escapes are correct"
|
||||||
(is (equal (cl-tty.backend::sgr-attr :bold) (format nil "~C[1m" #\Esc)))
|
(is (equal (cl-tty.backend::sgr-attr :bold) (format nil "~C[1m" #\Esc)))
|
||||||
(is (equal (cl-tty.backend::sgr-attr :italic) (format nil "~C[3m" #\Esc)))
|
(is (equal (cl-tty.backend::sgr-attr :italic) (format nil "~C[3m" #\Esc)))
|
||||||
(is (equal (cl-tty.backend::sgr-attr :underline) (format nil "~C[4m" #\Esc)))
|
(is (equal (cl-tty.backend::sgr-attr :underline) (format nil "~C[4m" #\Esc)))
|
||||||
(is (equal (cl-tty.backend::sgr-attr :reset) (format nil "~C[0m" #\Esc))))
|
(is (equal (cl-tty.backend::sgr-attr :reset) (format nil "~C[0m" #\Esc))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
;; ── Cursor ─────────────────────────────────────────────────────
|
** Cursor move escape
|
||||||
|
|
||||||
|
Verifies that =cursor-move-escape= produces a CSI =H= sequence with
|
||||||
|
1-indexed row and column.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/backend/modern-tests.lisp
|
||||||
(test cursor-move-escape
|
(test cursor-move-escape
|
||||||
"cursor-move generates correct CSI escape"
|
"cursor-move generates correct CSI escape"
|
||||||
(let ((b (make-modern-backend)))
|
(let ((b (make-modern-backend)))
|
||||||
(is (equal (cl-tty.backend::cursor-move-escape 5 10)
|
(is (equal (cl-tty.backend::cursor-move-escape 5 10)
|
||||||
(format nil "~C[11;6H" #\Esc)))))
|
(format nil "~C[11;6H" #\Esc)))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** Cursor style block
|
||||||
|
|
||||||
|
Verifies the DECSTR escape for a block cursor without blinking (code 2).
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/backend/modern-tests.lisp
|
||||||
(test cursor-style-block
|
(test cursor-style-block
|
||||||
"cursor-style :block generate correct escape"
|
"cursor-style :block generate correct escape"
|
||||||
(let ((b (make-modern-backend)))
|
(let ((b (make-modern-backend)))
|
||||||
(is (equal (cl-tty.backend::cursor-style-escape :block nil)
|
(is (equal (cl-tty.backend::cursor-style-escape :block nil)
|
||||||
(format nil "~C[2 q" #\Esc)))))
|
(format nil "~C[2 q" #\Esc)))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** Cursor style bar
|
||||||
|
|
||||||
|
Verifies the DECSTR escape for a bar cursor without blinking (code 6).
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/backend/modern-tests.lisp
|
||||||
(test cursor-style-bar
|
(test cursor-style-bar
|
||||||
"cursor-style :bar generate correct escape"
|
"cursor-style :bar generate correct escape"
|
||||||
(let ((b (make-modern-backend)))
|
(let ((b (make-modern-backend)))
|
||||||
(is (equal (cl-tty.backend::cursor-style-escape :bar nil)
|
(is (equal (cl-tty.backend::cursor-style-escape :bar nil)
|
||||||
(format nil "~C[6 q" #\Esc)))))
|
(format nil "~C[6 q" #\Esc)))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** Cursor style underline with blink
|
||||||
|
|
||||||
|
Verifies that =:underline= with =blink=t= produces code 5 (underline
|
||||||
|
blinking), which is base 4 + blink offset 1.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/backend/modern-tests.lisp
|
||||||
(test cursor-style-underline-blink
|
(test cursor-style-underline-blink
|
||||||
"cursor-style :underline with blink"
|
"cursor-style :underline with blink"
|
||||||
(let ((b (make-modern-backend)))
|
(let ((b (make-modern-backend)))
|
||||||
(is (equal (cl-tty.backend::cursor-style-escape :underline t)
|
(is (equal (cl-tty.backend::cursor-style-escape :underline t)
|
||||||
(format nil "~C[5 q" #\Esc)))))
|
(format nil "~C[5 q" #\Esc)))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
;; ── Synchronization ────────────────────────────────────────────
|
** DECICM synchronized update escapes
|
||||||
|
|
||||||
|
Confirms that =decicm-begin= and =decicm-end= produce =?2026h= and
|
||||||
|
=?2026l= respectively.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/backend/modern-tests.lisp
|
||||||
(test decicm-escapes
|
(test decicm-escapes
|
||||||
"DECICM synchronized update escapes"
|
"DECICM synchronized update escapes"
|
||||||
(is (equal (cl-tty.backend::decicm-begin) (format nil "~C[?2026h" #\Esc)))
|
(is (equal (cl-tty.backend::decicm-begin) (format nil "~C[?2026h" #\Esc)))
|
||||||
(is (equal (cl-tty.backend::decicm-end) (format nil "~C[?2026l" #\Esc))))
|
(is (equal (cl-tty.backend::decicm-end) (format nil "~C[?2026l" #\Esc))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
;; ── OSC 8 Hyperlinks ──────────────────────────────────────────
|
** OSC 8 hyperlink escape
|
||||||
|
|
||||||
|
Verifies the full OSC 8 wrapping: opening sequence with URL, the text,
|
||||||
|
and the closing sequence. The FORMAT string uses ~~ for literal tilde
|
||||||
|
and ~\\ for literal backslash.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/backend/modern-tests.lisp
|
||||||
(test osc8-escape
|
(test osc8-escape
|
||||||
"OSC 8 hyperlink escape wraps text"
|
"OSC 8 hyperlink escape wraps text"
|
||||||
(is (equal (cl-tty.backend::osc8-link "http://example.com" "click here")
|
(is (equal (cl-tty.backend::osc8-link "http://example.com" "click here")
|
||||||
(format nil "~C]8;;http://example.com~C\\\\click here~C]8;;~C\\\\"
|
(format nil "~C]8;;http://example.com~C\\click here~C]8;;~C\"
|
||||||
#\Esc #\Esc #\Esc #\Esc))))
|
#\Esc #\Esc #\Esc #\Esc))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
;; ── Hex Parsing ────────────────────────────────────────────────
|
** Hex color parsing (gold)
|
||||||
|
|
||||||
|
Verifies that ="#FFD700"= parses to (255, 215, 0).
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/backend/modern-tests.lisp
|
||||||
(test hex-color-parsing
|
(test hex-color-parsing
|
||||||
"hex-to-rgb parses valid hex colors"
|
"hex-to-rgb parses valid hex colors"
|
||||||
(multiple-value-bind (r g b) (cl-tty.backend::hex-to-rgb "#FFD700")
|
(multiple-value-bind (r g b) (cl-tty.backend::hex-to-rgb "#FFD700")
|
||||||
(is (= r 255))
|
(is (= r 255))
|
||||||
(is (= g 215))
|
(is (= g 215))
|
||||||
(is (= b 0))))
|
(is (= b 0))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** Hex color parsing (black)
|
||||||
|
|
||||||
|
Verifies all-zero parsing.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/backend/modern-tests.lisp
|
||||||
(test hex-color-black
|
(test hex-color-black
|
||||||
"hex-to-rgb parses black"
|
"hex-to-rgb parses black"
|
||||||
(multiple-value-bind (r g b) (cl-tty.backend::hex-to-rgb "#000000")
|
(multiple-value-bind (r g b) (cl-tty.backend::hex-to-rgb "#000000")
|
||||||
(is (= r 0))
|
(is (= r 0))
|
||||||
(is (= g 0))
|
(is (= g 0))
|
||||||
(is (= b 0))))
|
(is (= b 0))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** Hex color parsing (3-digit short form)
|
||||||
|
|
||||||
|
Verifies that ="#F00"= expands to ="#FF0000"= = (255, 0, 0).
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/backend/modern-tests.lisp
|
||||||
(test hex-color-short-form
|
(test hex-color-short-form
|
||||||
"hex-to-rgb parses 3-digit hex"
|
"hex-to-rgb parses 3-digit hex"
|
||||||
(multiple-value-bind (r g b) (cl-tty.backend::hex-to-rgb "#F00")
|
(multiple-value-bind (r g b) (cl-tty.backend::hex-to-rgb "#F00")
|
||||||
(is (= r 255))
|
(is (= r 255))
|
||||||
(is (= g 0))
|
(is (= g 0))
|
||||||
(is (= b 0))))
|
(is (= b 0))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
;; ── Border Characters ──────────────────────────────────────────
|
** Border characters --- rounded style
|
||||||
|
|
||||||
|
Confirms that =:rounded= style maps to the Unicode box-drawing
|
||||||
|
characters for the four corners and edges.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/backend/modern-tests.lisp
|
||||||
(test border-char-rounded
|
(test border-char-rounded
|
||||||
"modern-border-char returns Unicode box-drawing for rounded style"
|
"modern-border-char returns Unicode box-drawing for rounded style"
|
||||||
(is (equal (cl-tty.backend::border-char :rounded :top-left) "╭"))
|
(is (equal (cl-tty.backend::border-char :rounded :top-left) "╭"))
|
||||||
(is (equal (cl-tty.backend::border-char :rounded :horizontal) "─"))
|
(is (equal (cl-tty.backend::border-char :rounded :horizontal) "─"))
|
||||||
(is (equal (cl-tty.backend::border-char :rounded :vertical) "│"))
|
(is (equal (cl-tty.backend::border-char :rounded :vertical) "│"))
|
||||||
(is (equal (cl-tty.backend::border-char :rounded :bottom-right) "╯")))
|
(is (equal (cl-tty.backend::border-char :rounded :bottom-right) "╯"))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** Border characters --- double style
|
||||||
|
|
||||||
|
Confirms that =:double= style maps to double-line box-drawing characters.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/backend/modern-tests.lisp
|
||||||
(test border-char-double
|
(test border-char-double
|
||||||
"modern-border-char returns double-line chars"
|
"modern-border-char returns double-line chars"
|
||||||
(is (equal (cl-tty.backend::border-char :double :top-left) "╔"))
|
(is (equal (cl-tty.backend::border-char :double :top-left) "╔"))
|
||||||
(is (equal (cl-tty.backend::border-char :double :horizontal) "═"))
|
(is (equal (cl-tty.backend::border-char :double :horizontal) "═"))
|
||||||
(is (equal (cl-tty.backend::border-char :double :vertical) "║")))
|
(is (equal (cl-tty.backend::border-char :double :vertical) "║"))
|
||||||
#+END_SRC
|
#+END_SRC
|
||||||
|
|
||||||
* Implementation
|
* Implementation
|
||||||
|
|
||||||
** Color and attribute helpers
|
** Color and attribute helpers
|
||||||
|
|
||||||
|
*** hex-to-rgb
|
||||||
|
|
||||||
~hex-to-rgb~ parses hex color strings into (r g b) triplets. Handles
|
~hex-to-rgb~ parses hex color strings into (r g b) triplets. Handles
|
||||||
both 6-digit (fully specified) and 3-digit (shorthand) formats.
|
both 6-digit (fully specified) and 3-digit (shorthand) formats. The
|
||||||
|
3-digit form expands each hexit by duplicating it (=#F00= => =#FF0000=).
|
||||||
|
|
||||||
#+BEGIN_SRC lisp :tangle ../src/backend/modern.lisp
|
#+BEGIN_SRC lisp :tangle ../src/backend/modern.lisp
|
||||||
(in-package :cl-tty.backend)
|
(in-package :cl-tty.backend)
|
||||||
@@ -197,21 +312,37 @@ both 6-digit (fully specified) and 3-digit (shorthand) formats.
|
|||||||
(parse-integer (subseq clean 4 6) :radix 16 :junk-allowed t)))))
|
(parse-integer (subseq clean 4 6) :radix 16 :junk-allowed t)))))
|
||||||
#+END_SRC
|
#+END_SRC
|
||||||
|
|
||||||
Named color mapping and theme color store:
|
*** *named-colors*
|
||||||
|
|
||||||
|
Maps keyword color names to 8-color SGR index values. Used as the
|
||||||
|
primary lookup in =sgr-fg= and =sgr-bg= before falling back to the
|
||||||
|
theme colors hash table.
|
||||||
|
|
||||||
#+BEGIN_SRC lisp :tangle ../src/backend/modern.lisp
|
#+BEGIN_SRC lisp :tangle ../src/backend/modern.lisp
|
||||||
(defparameter *named-colors*
|
(defparameter *named-colors*
|
||||||
'((:black . 0) (:red . 1) (:green . 2) (:yellow . 3)
|
'((:black . 0) (:red . 1) (:green . 2) (:yellow . 3)
|
||||||
(:blue . 4) (:magenta . 5) (:cyan . 6) (:white . 7)))
|
(:blue . 4) (:magenta . 5) (:cyan . 6) (:white . 7)))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
*** *theme-colors*
|
||||||
|
|
||||||
|
Hash table mapping semantic theme role keywords to hex color strings.
|
||||||
|
Populated by the theme system's =load-preset=. When a keyword is not in
|
||||||
|
=*named-colors*=, =sgr-fg= and =sgr-bg= consult this table as a
|
||||||
|
fallback, enabling user themes to define custom color roles.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/backend/modern.lisp
|
||||||
(defvar *theme-colors* (make-hash-table :test 'eq)
|
(defvar *theme-colors* (make-hash-table :test 'eq)
|
||||||
"Hash table mapping theme keywords to hex color strings.
|
"Hash table mapping theme keywords to hex color strings.
|
||||||
Populated by the theme system's load-preset. Checked by sgr-fg/sgr-bg
|
Populated by the theme system's load-preset. Checked by sgr-fg/sgr-bg
|
||||||
as a fallback when a keyword is not in *named-colors*.")
|
as a fallback when a keyword is not in *named-colors*.")
|
||||||
#+END_SRC
|
#+END_SRC
|
||||||
|
|
||||||
~sgr-fg~ and ~sgr-bg~ produce the actual escape sequences. The
|
*** sgr-fg
|
||||||
resolution chain is: hex → named color → theme semantic role → empty.
|
|
||||||
|
~sgr-fg~ produces the SGR foreground escape sequence. Resolution chain:
|
||||||
|
hex string => named color => semantic theme role => empty string if
|
||||||
|
unresolved. Truecolor uses =38;2;R;G;B=, named colors use =3n=.
|
||||||
|
|
||||||
#+BEGIN_SRC lisp :tangle ../src/backend/modern.lisp
|
#+BEGIN_SRC lisp :tangle ../src/backend/modern.lisp
|
||||||
(defun sgr-fg (color)
|
(defun sgr-fg (color)
|
||||||
@@ -232,6 +363,11 @@ resolution chain is: hex → named color → theme semantic role → empty.
|
|||||||
(t ""))))
|
(t ""))))
|
||||||
#+END_SRC
|
#+END_SRC
|
||||||
|
|
||||||
|
*** sgr-bg
|
||||||
|
|
||||||
|
~sgr-bg~ produces the SGR background escape. Same resolution chain as
|
||||||
|
=sgr-fg= but uses =48;2;R;G;B= for truecolor and =4n= for named colors.
|
||||||
|
|
||||||
#+BEGIN_SRC lisp :tangle ../src/backend/modern.lisp
|
#+BEGIN_SRC lisp :tangle ../src/backend/modern.lisp
|
||||||
(defun sgr-bg (color)
|
(defun sgr-bg (color)
|
||||||
"Return SGR background escape for COLOR."
|
"Return SGR background escape for COLOR."
|
||||||
@@ -251,13 +387,23 @@ resolution chain is: hex → named color → theme semantic role → empty.
|
|||||||
(t ""))))
|
(t ""))))
|
||||||
#+END_SRC
|
#+END_SRC
|
||||||
|
|
||||||
Attribute codes map keywords to SGR numbers:
|
*** *sgr-attr-codes*
|
||||||
|
|
||||||
|
Maps attribute keywords to SGR parameter numbers. Covers bold, dim,
|
||||||
|
italic, underline, blink, reverse video, and reset.
|
||||||
|
|
||||||
#+BEGIN_SRC lisp :tangle ../src/backend/modern.lisp
|
#+BEGIN_SRC lisp :tangle ../src/backend/modern.lisp
|
||||||
(defparameter *sgr-attr-codes*
|
(defparameter *sgr-attr-codes*
|
||||||
'((:bold . 1) (:dim . 2) (:italic . 3) (:underline . 4)
|
'((:bold . 1) (:dim . 2) (:italic . 3) (:underline . 4)
|
||||||
(:blink . 5) (:reverse . 7) (:reset . 0)))
|
(:blink . 5) (:reverse . 7) (:reset . 0)))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
*** sgr-attr
|
||||||
|
|
||||||
|
~sgr-attr~ looks up the keyword in =*sgr-attr-codes*= and produces the
|
||||||
|
matching SGR escape.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/backend/modern.lisp
|
||||||
(defun sgr-attr (attr)
|
(defun sgr-attr (attr)
|
||||||
"Return SGR attribute escape for ATTR keyword."
|
"Return SGR attribute escape for ATTR keyword."
|
||||||
(let ((code (cdr (assoc attr *sgr-attr-codes*))))
|
(let ((code (cdr (assoc attr *sgr-attr-codes*))))
|
||||||
@@ -268,11 +414,24 @@ Attribute codes map keywords to SGR numbers:
|
|||||||
|
|
||||||
** Cursor escapes
|
** Cursor escapes
|
||||||
|
|
||||||
|
*** cursor-move-escape
|
||||||
|
|
||||||
|
Produces a CSI =H= (CUP) sequence to position the cursor. Coordinates
|
||||||
|
are 1-indexed: =cursor-move-escape 0 0= moves to row 1, column 1.
|
||||||
|
|
||||||
#+BEGIN_SRC lisp :tangle ../src/backend/modern.lisp
|
#+BEGIN_SRC lisp :tangle ../src/backend/modern.lisp
|
||||||
(defun cursor-move-escape (x y)
|
(defun cursor-move-escape (x y)
|
||||||
"Return CSI escape to move cursor to (x, y), 1-indexed."
|
"Return CSI escape to move cursor to (x, y), 1-indexed."
|
||||||
(format nil "~C[~d;~dH" #\Esc (1+ y) (1+ x)))
|
(format nil "~C[~d;~dH" #\Esc (1+ y) (1+ x)))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
*** cursor-style-escape
|
||||||
|
|
||||||
|
Produces a DECSTR sequence (=CSI Ps q=) to set the cursor shape.
|
||||||
|
Base codes: block=2, underline=4, bar=6. When =blink= is true the code
|
||||||
|
is incremented by 1 (e.g. blinking block = code 3).
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/backend/modern.lisp
|
||||||
(defun cursor-style-escape (shape blink)
|
(defun cursor-style-escape (shape blink)
|
||||||
"Return DECSTR escape for cursor shape."
|
"Return DECSTR escape for cursor shape."
|
||||||
(let* ((base (case shape
|
(let* ((base (case shape
|
||||||
@@ -284,23 +443,50 @@ Attribute codes map keywords to SGR numbers:
|
|||||||
|
|
||||||
** Sync and link escapes
|
** Sync and link escapes
|
||||||
|
|
||||||
|
*** decicm-begin
|
||||||
|
|
||||||
|
Enables DEC private mode 2026 (synchronized updates). All output
|
||||||
|
between =begin= and =end= is buffered by the terminal and rendered
|
||||||
|
atomically.
|
||||||
|
|
||||||
#+BEGIN_SRC lisp :tangle ../src/backend/modern.lisp
|
#+BEGIN_SRC lisp :tangle ../src/backend/modern.lisp
|
||||||
(defun decicm-begin ()
|
(defun decicm-begin ()
|
||||||
"Return escape to enable synchronized updates."
|
"Return escape to enable synchronized updates."
|
||||||
(format nil "~C[?2026h" #\Esc))
|
(format nil "~C[?2026h" #\Esc))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
*** decicm-end
|
||||||
|
|
||||||
|
Disables DEC private mode 2026, flushing the buffered frame to the
|
||||||
|
display.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/backend/modern.lisp
|
||||||
(defun decicm-end ()
|
(defun decicm-end ()
|
||||||
"Return escape to disable synchronized updates."
|
"Return escape to disable synchronized updates."
|
||||||
(format nil "~C[?2026l" #\Esc))
|
(format nil "~C[?2026l" #\Esc))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
*** osc8-link
|
||||||
|
|
||||||
|
Wraps text in an OSC 8 hyperlink. The opening sequence carries the URL,
|
||||||
|
the closing sequence (=ESC]8;;ESC\)=) terminates the link. This
|
||||||
|
allows clickable text in terminals that support the protocol.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/backend/modern.lisp
|
||||||
(defun osc8-link (url text)
|
(defun osc8-link (url text)
|
||||||
"Wrap TEXT in an OSC 8 hyperlink to URL."
|
"Wrap TEXT in an OSC 8 hyperlink to URL."
|
||||||
(format nil "~C]8;;~A~C\\\\~A~C]8;;~C\\\\"
|
(format nil "~C]8;;~A~C\\~A~C]8;;~C\\"
|
||||||
#\Esc url #\Esc text #\Esc #\Esc))
|
#\Esc url #\Esc text #\Esc #\Esc))
|
||||||
#+END_SRC
|
#+END_SRC
|
||||||
|
|
||||||
** Border characters
|
** Border characters
|
||||||
|
|
||||||
|
*** *border-chars*
|
||||||
|
|
||||||
|
Lookup alist mapping =(style position)= pairs to Unicode box-drawing
|
||||||
|
characters. Covers single, double, and rounded styles with all four
|
||||||
|
corners plus horizontal and vertical connectors.
|
||||||
|
|
||||||
#+BEGIN_SRC lisp :tangle ../src/backend/modern.lisp
|
#+BEGIN_SRC lisp :tangle ../src/backend/modern.lisp
|
||||||
(defparameter *border-chars*
|
(defparameter *border-chars*
|
||||||
'(((:single :top-left) . "┌") ((:single :top-right) . "┐")
|
'(((:single :top-left) . "┌") ((:single :top-right) . "┐")
|
||||||
@@ -312,7 +498,16 @@ Attribute codes map keywords to SGR numbers:
|
|||||||
((:rounded :top-left) . "╭") ((:rounded :top-right) . "╮")
|
((:rounded :top-left) . "╭") ((:rounded :top-right) . "╮")
|
||||||
((:rounded :bottom-left) . "╰") ((:rounded :bottom-right) . "╯")
|
((:rounded :bottom-left) . "╰") ((:rounded :bottom-right) . "╯")
|
||||||
((:rounded :horizontal) . "─") ((:rounded :vertical) . "│")))
|
((:rounded :horizontal) . "─") ((:rounded :vertical) . "│")))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
*** border-char
|
||||||
|
|
||||||
|
Looks up a border character by style and position. Falls back to
|
||||||
|
horizontal/vertical lines (=U+2500=, =U+2502=) if the style is unknown
|
||||||
|
for edge positions, or =+= for corners --- ensuring the UI never shows
|
||||||
|
a blank gap.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/backend/modern.lisp
|
||||||
(defun border-char (style pos)
|
(defun border-char (style pos)
|
||||||
"Return the Unicode box-drawing character for STYLE at POS."
|
"Return the Unicode box-drawing character for STYLE at POS."
|
||||||
(let ((char (cdr (assoc (list style pos) *border-chars* :test #'equal))))
|
(let ((char (cdr (assoc (list style pos) *border-chars* :test #'equal))))
|
||||||
@@ -323,13 +518,28 @@ Attribute codes map keywords to SGR numbers:
|
|||||||
|
|
||||||
** Modern backend class
|
** Modern backend class
|
||||||
|
|
||||||
|
*** modern-backend (class)
|
||||||
|
|
||||||
|
Subclasses the abstract =backend= class. =output-stream= is where escape
|
||||||
|
sequences are written; =in-sync-p= tracks whether we are inside a
|
||||||
|
DECICM synchronized update block.
|
||||||
|
|
||||||
#+BEGIN_SRC lisp :tangle ../src/backend/modern.lisp
|
#+BEGIN_SRC lisp :tangle ../src/backend/modern.lisp
|
||||||
(defclass modern-backend (backend)
|
(defclass modern-backend (backend)
|
||||||
((output-stream :initform *standard-output*
|
((output-stream :initform *standard-output*
|
||||||
:initarg :output-stream
|
:initarg :output-stream
|
||||||
:accessor backend-output-stream)
|
:accessor backend-output-stream)
|
||||||
(in-sync-p :initform nil :accessor in-sync-p)))
|
(in-sync-p :initform nil :accessor in-sync-p)))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
*** make-modern-backend
|
||||||
|
|
||||||
|
Factory function that creates a =modern-backend= instance. Accepts an
|
||||||
|
optional =output-stream=; defaults to =*standard-output*=. The
|
||||||
|
=color-palette= argument is ignored in favor of the dynamic
|
||||||
|
=*theme-colors*= hash table.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/backend/modern.lisp
|
||||||
(defun make-modern-backend (&key color-palette output-stream)
|
(defun make-modern-backend (&key color-palette output-stream)
|
||||||
(declare (ignore color-palette))
|
(declare (ignore color-palette))
|
||||||
(make-instance 'modern-backend :output-stream (or output-stream *standard-output*)))
|
(make-instance 'modern-backend :output-stream (or output-stream *standard-output*)))
|
||||||
@@ -337,9 +547,12 @@ Attribute codes map keywords to SGR numbers:
|
|||||||
|
|
||||||
** Lifecycle
|
** Lifecycle
|
||||||
|
|
||||||
~initialize-backend~ enters the alt screen, enables mouse tracking,
|
*** initialize-backend
|
||||||
bracketed paste, and kitty keyboard protocol. ~shutdown-backend~
|
|
||||||
restores everything.
|
Enters the alternate screen buffer, enables mouse tracking (basic +
|
||||||
|
drag + SGR), bracketed paste mode, and the Kitty keyboard protocol.
|
||||||
|
Hides the cursor and flushes the stream. Returns the backend instance
|
||||||
|
for chaining.
|
||||||
|
|
||||||
#+BEGIN_SRC lisp :tangle ../src/backend/modern.lisp
|
#+BEGIN_SRC lisp :tangle ../src/backend/modern.lisp
|
||||||
(defmethod initialize-backend ((b modern-backend))
|
(defmethod initialize-backend ((b modern-backend))
|
||||||
@@ -352,7 +565,15 @@ restores everything.
|
|||||||
(cursor-hide b)
|
(cursor-hide b)
|
||||||
(finish-output (backend-output-stream b))
|
(finish-output (backend-output-stream b))
|
||||||
b)
|
b)
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
*** shutdown-backend
|
||||||
|
|
||||||
|
Restores the terminal: shows the cursor, disables the Kitty keyboard
|
||||||
|
protocol, bracketed paste, SGR mouse, drag, basic mouse, and finally
|
||||||
|
leaves the alternate screen. Returns =nil= (via =(values)=).
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/backend/modern.lisp
|
||||||
(defmethod shutdown-backend ((b modern-backend))
|
(defmethod shutdown-backend ((b modern-backend))
|
||||||
(cursor-show b)
|
(cursor-show b)
|
||||||
(backend-write b (format nil "~C[?u" #\Esc))
|
(backend-write b (format nil "~C[?u" #\Esc))
|
||||||
@@ -367,8 +588,11 @@ restores everything.
|
|||||||
|
|
||||||
** Backend-size via ioctl
|
** Backend-size via ioctl
|
||||||
|
|
||||||
Uses TIOCGWINSZ to query actual terminal dimensions. The alien-sap
|
*** backend-size
|
||||||
wrapper ensures compatibility across SBCL versions.
|
|
||||||
|
Uses TIOCGWINSZ (=0x5413= = 21523) to query actual terminal dimensions
|
||||||
|
from the kernel via =ioctl=. The =alien-sap= wrapper ensures
|
||||||
|
compatibility across SBCL versions. Returns (values cols rows).
|
||||||
|
|
||||||
#+BEGIN_SRC lisp :tangle ../src/backend/modern.lisp
|
#+BEGIN_SRC lisp :tangle ../src/backend/modern.lisp
|
||||||
(defmethod backend-size ((b modern-backend))
|
(defmethod backend-size ((b modern-backend))
|
||||||
@@ -386,13 +610,27 @@ wrapper ensures compatibility across SBCL versions.
|
|||||||
|
|
||||||
** Capability query and write
|
** Capability query and write
|
||||||
|
|
||||||
|
*** backend-write
|
||||||
|
|
||||||
|
Writes a string to the backend's output stream, flushing after each
|
||||||
|
write to ensure the terminal receives the escape sequence immediately.
|
||||||
|
Returns the string length for protocol compatibility.
|
||||||
|
|
||||||
#+BEGIN_SRC lisp :tangle ../src/backend/modern.lisp
|
#+BEGIN_SRC lisp :tangle ../src/backend/modern.lisp
|
||||||
(defmethod backend-write ((b modern-backend) string)
|
(defmethod backend-write ((b modern-backend) string)
|
||||||
(let ((stream (backend-output-stream b)))
|
(let ((stream (backend-output-stream b)))
|
||||||
(write-string string stream)
|
(write-string string stream)
|
||||||
(finish-output stream)
|
(finish-output stream)
|
||||||
(length string)))
|
(length string)))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
*** capable-p
|
||||||
|
|
||||||
|
Advertises which features this backend supports. =modern-backend=
|
||||||
|
supports truecolor, OSC 8 hyperlinks, DECICM sync, SGR mouse,
|
||||||
|
bracketed paste, cursor style control, and the Kitty keyboard protocol.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/backend/modern.lisp
|
||||||
(defmethod capable-p ((b modern-backend) feature)
|
(defmethod capable-p ((b modern-backend) feature)
|
||||||
(member feature '(:truecolor :osc8 :sync :mouse
|
(member feature '(:truecolor :osc8 :sync :mouse
|
||||||
:bracketed-paste :cursor-style
|
:bracketed-paste :cursor-style
|
||||||
@@ -401,9 +639,12 @@ wrapper ensures compatibility across SBCL versions.
|
|||||||
|
|
||||||
** Drawing
|
** Drawing
|
||||||
|
|
||||||
~draw-text~ combines cursor positioning, SGR colors, attributes, the
|
*** draw-text
|
||||||
text itself, and a reset into a single string. This minimizes ioctl
|
|
||||||
calls — one write per draw operation.
|
Combines cursor positioning, SGR colors, optional attributes, the text
|
||||||
|
itself, and a reset into a single concatenated string. Minimizes output
|
||||||
|
calls --- one =backend-write= per draw operation --- by packing everything
|
||||||
|
into one buffer.
|
||||||
|
|
||||||
#+BEGIN_SRC lisp :tangle ../src/backend/modern.lisp
|
#+BEGIN_SRC lisp :tangle ../src/backend/modern.lisp
|
||||||
(defmethod draw-text ((b modern-backend) x y string fg bg
|
(defmethod draw-text ((b modern-backend) x y string fg bg
|
||||||
@@ -421,9 +662,12 @@ calls — one write per draw operation.
|
|||||||
(backend-write b (apply #'concatenate 'string parts))))
|
(backend-write b (apply #'concatenate 'string parts))))
|
||||||
#+END_SRC
|
#+END_SRC
|
||||||
|
|
||||||
~draw-border~ builds the full border as three string parts (top with
|
*** draw-border
|
||||||
optional title, mid with sides, bottom) and writes them with minimal
|
|
||||||
output calls.
|
Builds the full border as three distinct string parts (top with optional
|
||||||
|
title, repeated mid sections, bottom) and writes them with minimal
|
||||||
|
output calls. The title can be left-aligned or centered within the top
|
||||||
|
border line. Uses the border character lookup for the chosen style.
|
||||||
|
|
||||||
#+BEGIN_SRC lisp :tangle ../src/backend/modern.lisp
|
#+BEGIN_SRC lisp :tangle ../src/backend/modern.lisp
|
||||||
(defmethod draw-border ((b modern-backend) x y width height
|
(defmethod draw-border ((b modern-backend) x y width height
|
||||||
@@ -480,6 +724,13 @@ output calls.
|
|||||||
(backend-write b bot)))
|
(backend-write b bot)))
|
||||||
#+END_SRC
|
#+END_SRC
|
||||||
|
|
||||||
|
*** draw-rect
|
||||||
|
|
||||||
|
Fills a rectangular area with a background color. For each row, moves
|
||||||
|
the cursor and writes a filled line. This is simpler than =draw-border=
|
||||||
|
because it has no border characters --- just spaces with a background
|
||||||
|
color.
|
||||||
|
|
||||||
#+BEGIN_SRC lisp :tangle ../src/backend/modern.lisp
|
#+BEGIN_SRC lisp :tangle ../src/backend/modern.lisp
|
||||||
(defmethod draw-rect ((b modern-backend) x y width height &key bg)
|
(defmethod draw-rect ((b modern-backend) x y width height &key bg)
|
||||||
(let* ((bg-esc (sgr-bg bg))
|
(let* ((bg-esc (sgr-bg bg))
|
||||||
@@ -491,7 +742,16 @@ output calls.
|
|||||||
(loop :for row :from 0 :below height :do
|
(loop :for row :from 0 :below height :do
|
||||||
(backend-write b (cursor-move-escape x (+ y row)))
|
(backend-write b (cursor-move-escape x (+ y row)))
|
||||||
(backend-write b line))))
|
(backend-write b line))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
*** draw-link
|
||||||
|
|
||||||
|
Draws a hyperlinked text at position (x, y). Combines cursor
|
||||||
|
positioning, optional fg/bg colors, the OSC 8 link wrapper around the
|
||||||
|
text, and a reset. This lets the user click the text to open the URL
|
||||||
|
in terminals that support OSC 8.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/backend/modern.lisp
|
||||||
(defmethod draw-link ((b modern-backend) x y string url
|
(defmethod draw-link ((b modern-backend) x y string url
|
||||||
&key fg bg)
|
&key fg bg)
|
||||||
(let ((parts (list (cursor-move-escape x y)
|
(let ((parts (list (cursor-move-escape x y)
|
||||||
@@ -499,7 +759,15 @@ output calls.
|
|||||||
(osc8-link url string)
|
(osc8-link url string)
|
||||||
(sgr-attr :reset))))
|
(sgr-attr :reset))))
|
||||||
(backend-write b (apply #'concatenate 'string parts))))
|
(backend-write b (apply #'concatenate 'string parts))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
*** draw-ellipsis
|
||||||
|
|
||||||
|
Draws a three-dot ellipsis at the given position. The =width= parameter
|
||||||
|
is ignored since dots have a fixed visual length; delegates to
|
||||||
|
=draw-text= for uniform rendering.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/backend/modern.lisp
|
||||||
(defmethod draw-ellipsis ((b modern-backend) x y width
|
(defmethod draw-ellipsis ((b modern-backend) x y width
|
||||||
&key fg bg)
|
&key fg bg)
|
||||||
(declare (ignore width))
|
(declare (ignore width))
|
||||||
@@ -509,33 +777,87 @@ output calls.
|
|||||||
|
|
||||||
** Cursor and input methods
|
** Cursor and input methods
|
||||||
|
|
||||||
|
*** cursor-move
|
||||||
|
|
||||||
|
Delegates to =cursor-move-escape= and writes the resulting CSI sequence
|
||||||
|
to the output stream.
|
||||||
|
|
||||||
#+BEGIN_SRC lisp :tangle ../src/backend/modern.lisp
|
#+BEGIN_SRC lisp :tangle ../src/backend/modern.lisp
|
||||||
(defmethod cursor-move ((b modern-backend) x y)
|
(defmethod cursor-move ((b modern-backend) x y)
|
||||||
(backend-write b (cursor-move-escape x y)))
|
(backend-write b (cursor-move-escape x y)))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
*** cursor-hide
|
||||||
|
|
||||||
|
Sends the DECTCEM private mode =?25l= to hide the cursor.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/backend/modern.lisp
|
||||||
(defmethod cursor-hide ((b modern-backend))
|
(defmethod cursor-hide ((b modern-backend))
|
||||||
(backend-write b (format nil "~C[?25l" #\Esc)))
|
(backend-write b (format nil "~C[?25l" #\Esc)))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
*** cursor-show
|
||||||
|
|
||||||
|
Sends =?25h= to restore the cursor visibility.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/backend/modern.lisp
|
||||||
(defmethod cursor-show ((b modern-backend))
|
(defmethod cursor-show ((b modern-backend))
|
||||||
(backend-write b (format nil "~C[?25h" #\Esc)))
|
(backend-write b (format nil "~C[?25h" #\Esc)))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
*** cursor-style
|
||||||
|
|
||||||
|
Sets the cursor shape (block/underline/bar, optionally blinking) by
|
||||||
|
delegating to =cursor-style-escape=.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/backend/modern.lisp
|
||||||
(defmethod cursor-style ((b modern-backend) shape &key blink)
|
(defmethod cursor-style ((b modern-backend) shape &key blink)
|
||||||
(backend-write b (cursor-style-escape shape blink)))
|
(backend-write b (cursor-style-escape shape blink)))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
*** enable-mouse
|
||||||
|
|
||||||
|
Enables basic mouse tracking, button-event tracking (drag), and SGR
|
||||||
|
extended mouse mode. These three modes together give full mouse
|
||||||
|
support while staying compatible with modern terminal emulators.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/backend/modern.lisp
|
||||||
(defmethod enable-mouse ((b modern-backend))
|
(defmethod enable-mouse ((b modern-backend))
|
||||||
(backend-write b (format nil "~C[?1000h" #\Esc))
|
(backend-write b (format nil "~C[?1000h" #\Esc))
|
||||||
(backend-write b (format nil "~C[?1002h" #\Esc))
|
(backend-write b (format nil "~C[?1002h" #\Esc))
|
||||||
(backend-write b (format nil "~C[?1006h" #\Esc))
|
(backend-write b (format nil "~C[?1006h" #\Esc))
|
||||||
(finish-output (backend-output-stream b)))
|
(finish-output (backend-output-stream b)))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
*** enable-bracketed-paste
|
||||||
|
|
||||||
|
Enables bracketed paste mode, where the terminal wraps pasted text in
|
||||||
|
=ESC[200~= and =ESC[201~= delimiters. This allows the application to
|
||||||
|
distinguish user input from pasted content.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/backend/modern.lisp
|
||||||
(defmethod enable-bracketed-paste ((b modern-backend))
|
(defmethod enable-bracketed-paste ((b modern-backend))
|
||||||
(backend-write b (format nil "~C[?2004h" #\Esc))
|
(backend-write b (format nil "~C[?2004h" #\Esc))
|
||||||
(finish-output (backend-output-stream b)))
|
(finish-output (backend-output-stream b)))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
*** begin-sync
|
||||||
|
|
||||||
|
Begins a synchronized update frame using DECICM. Sets the =in-sync-p=
|
||||||
|
slot so other methods can check whether we are inside a sync block.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/backend/modern.lisp
|
||||||
(defmethod begin-sync ((b modern-backend))
|
(defmethod begin-sync ((b modern-backend))
|
||||||
(setf (in-sync-p b) t)
|
(setf (in-sync-p b) t)
|
||||||
(backend-write b (decicm-begin)))
|
(backend-write b (decicm-begin)))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
*** end-sync
|
||||||
|
|
||||||
|
Ends the synchronized update frame and flushes the output, causing the
|
||||||
|
terminal to render the buffered changes atomically.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/backend/modern.lisp
|
||||||
(defmethod end-sync ((b modern-backend))
|
(defmethod end-sync ((b modern-backend))
|
||||||
(setf (in-sync-p b) nil)
|
(setf (in-sync-p b) nil)
|
||||||
(backend-write b (decicm-end))
|
(backend-write b (decicm-end))
|
||||||
|
|||||||
216
org/mouse.org
216
org/mouse.org
@@ -25,6 +25,13 @@ module adds:
|
|||||||
|
|
||||||
** Code
|
** Code
|
||||||
|
|
||||||
|
*** Package definition
|
||||||
|
|
||||||
|
The package lives in its own file so it can be loaded before the
|
||||||
|
implementation. It re-exports the public API symbols that consumers
|
||||||
|
(~cl-tty.core~, user applications) rely on without pulling in
|
||||||
|
implementation details.
|
||||||
|
|
||||||
#+BEGIN_SRC lisp :tangle ../src/components/mouse-package.lisp :noweb no
|
#+BEGIN_SRC lisp :tangle ../src/components/mouse-package.lisp :noweb no
|
||||||
(defpackage :cl-tty.mouse
|
(defpackage :cl-tty.mouse
|
||||||
(:use :cl :cl-tty.layout :cl-tty.input :cl-tty.box :cl-tty.rendering)
|
(:use :cl :cl-tty.layout :cl-tty.input :cl-tty.box :cl-tty.rendering)
|
||||||
@@ -40,15 +47,39 @@ module adds:
|
|||||||
#:cell-link-at #:open-link-at))
|
#:cell-link-at #:open-link-at))
|
||||||
#+END_SRC
|
#+END_SRC
|
||||||
|
|
||||||
|
*** Package entry form
|
||||||
|
|
||||||
|
Standard boilerplate to enter the package defined above.
|
||||||
|
|
||||||
#+BEGIN_SRC lisp :tangle ../src/components/mouse.lisp :noweb no
|
#+BEGIN_SRC lisp :tangle ../src/components/mouse.lisp :noweb no
|
||||||
(in-package :cl-tty.mouse)
|
(in-package :cl-tty.mouse)
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
*** ~mouse-mixin~ — mixin class for mouse event handler slots
|
||||||
|
|
||||||
|
Using a mixin (rather than adding slots to every component class)
|
||||||
|
keeps the mouse concern orthogonal to layout or rendering. Components
|
||||||
|
that want mouse support simply inherit from ~mouse-mixin~ alongside
|
||||||
|
their primary superclass. Each slot stores a closure invoked when the
|
||||||
|
corresponding event fires; ~nil~ means "no handler."
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/components/mouse.lisp :noweb no
|
||||||
(defclass mouse-mixin ()
|
(defclass mouse-mixin ()
|
||||||
((on-mouse-down :initarg :on-mouse-down :initform nil :accessor on-mouse-down)
|
((on-mouse-down :initarg :on-mouse-down :initform nil :accessor on-mouse-down)
|
||||||
(on-mouse-up :initarg :on-mouse-up :initform nil :accessor on-mouse-up)
|
(on-mouse-up :initarg :on-mouse-up :initform nil :accessor on-mouse-up)
|
||||||
(on-mouse-move :initarg :on-mouse-move :initform nil :accessor on-mouse-move)
|
(on-mouse-move :initarg :on-mouse-move :initform nil :accessor on-mouse-move)
|
||||||
(on-mouse-scroll :initarg :on-mouse-scroll :initform nil :accessor on-mouse-scroll)))
|
(on-mouse-scroll :initarg :on-mouse-scroll :initform nil :accessor on-mouse-scroll)))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
*** ~handle-mouse-event~ — dispatch mouse events to the right slot handler
|
||||||
|
|
||||||
|
Maps from the low-level ~mouse-event-type~ keyword to the
|
||||||
|
corresponding mixin slot. Using ~case~ here is simpler than a generic
|
||||||
|
function dispatch because the mapping is one-to-one and never needs
|
||||||
|
CLOS multiple-dispatch. Returns ~nil~ when no handler is bound (the
|
||||||
|
caller can decide whether to bubble the event up).
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/components/mouse.lisp :noweb no
|
||||||
(defun handle-mouse-event (component event)
|
(defun handle-mouse-event (component event)
|
||||||
(let* ((type (mouse-event-type event))
|
(let* ((type (mouse-event-type event))
|
||||||
(handler (case type
|
(handler (case type
|
||||||
@@ -57,7 +88,17 @@ module adds:
|
|||||||
(:drag (on-mouse-move component))
|
(:drag (on-mouse-move component))
|
||||||
(t nil))))
|
(t nil))))
|
||||||
(when handler (funcall handler event))))
|
(when handler (funcall handler event))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
*** ~hit-test~ — find the deepest component at a given (x, y)
|
||||||
|
|
||||||
|
Recursive coordinate lookup. Children are checked first so that the
|
||||||
|
innermost matching component wins (front-most in rendering order).
|
||||||
|
~ignore-errors~ guards against components that haven't been laid out
|
||||||
|
yet (no ~layout-node~ bound). This makes hit-testing safe to call
|
||||||
|
mid-render when the tree is partially constructed.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/components/mouse.lisp :noweb no
|
||||||
(defun hit-test (root x y)
|
(defun hit-test (root x y)
|
||||||
"Find the deepest component at (X, Y) by testing layout-node bounds.
|
"Find the deepest component at (X, Y) by testing layout-node bounds.
|
||||||
Recurses into component-children to find the innermost match.
|
Recurses into component-children to find the innermost match.
|
||||||
@@ -81,24 +122,50 @@ Components without a layout-node or position return nil."
|
|||||||
(>= y ny) (< y (+ ny nh)))
|
(>= y ny) (< y (+ ny nh)))
|
||||||
node)))))))
|
node)))))))
|
||||||
(recurse root)))
|
(recurse root)))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
;; Selection
|
*** ~*selection*~ — global variable holding the current selection
|
||||||
|
|
||||||
|
A single global makes the selection accessible from anywhere in the
|
||||||
|
process without threading it through the entire component tree. This
|
||||||
|
keeps the API simple for now; a future refactor could store the
|
||||||
|
selection on a per-frame or per-window basis if needed.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/components/mouse.lisp :noweb no
|
||||||
(defvar *selection* nil)
|
(defvar *selection* nil)
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
*** ~selection~ struct — data representation of a highlighted region
|
||||||
|
|
||||||
|
Stores the bounding box (start and end coordinates) plus the extracted
|
||||||
|
text. The ~:conc-name sel-~ prefix keeps accessors short while
|
||||||
|
avoiding name collisions. Using a struct (vs. a class) gives inline
|
||||||
|
accessors and no CLOS overhead, which matters when the selection is
|
||||||
|
read on every render frame.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/components/mouse.lisp :noweb no
|
||||||
(defstruct (selection (:conc-name sel-))
|
(defstruct (selection (:conc-name sel-))
|
||||||
(start-x 0) (start-y 0) (end-x 0) (end-y 0) (text ""))
|
(start-x 0) (start-y 0) (end-x 0) (end-y 0) (text ""))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
*** ~get-selection~ — read the selected text
|
||||||
|
|
||||||
|
Simple accessor that returns nil when nothing is selected (rather than
|
||||||
|
an empty string), making it easy for callers to test with ~when~.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/components/mouse.lisp :noweb no
|
||||||
(defun get-selection ()
|
(defun get-selection ()
|
||||||
(when *selection* (sel-text *selection*)))
|
(when *selection* (sel-text *selection*)))
|
||||||
#+END_SRC
|
#+END_SRC
|
||||||
|
|
||||||
*** Bug Fixes (v1.0.0): Wayland clipboard support
|
*** ~copy-to-clipboard~ — platform-aware clipboard writing
|
||||||
|
|
||||||
~copy-to-clipboard~ only called ~xclip~, which fails silently on Wayland
|
The original implementation only called ~xclip~, which fails silently
|
||||||
sessions (where ~xclip~ is often unavailable or requires XWayland).
|
on Wayland sessions. This version checks ~WAYLAND_DISPLAY~ at runtime
|
||||||
|
— if set, it uses ~wl-copy~; otherwise it falls back to ~xclip~.
|
||||||
Fix: Check the ~WAYLAND_DISPLAY~ environment variable. If set, use
|
Darwin uses ~pbcopy~. The approach avoids build-time feature detection
|
||||||
~wl-copy~ instead of ~xclip~. Fall back to ~xclip~ for traditional X11
|
(~#+wayland~) in favor of runtime environment checks, which handles
|
||||||
|
the common case of a single SBCL binary used across X11 and Wayland
|
||||||
sessions.
|
sessions.
|
||||||
|
|
||||||
#+BEGIN_SRC lisp :tangle ../src/components/mouse.lisp :noweb no
|
#+BEGIN_SRC lisp :tangle ../src/components/mouse.lisp :noweb no
|
||||||
@@ -111,32 +178,89 @@ sessions.
|
|||||||
(sb-ext:run-program "xclip" (list "-selection" "clipboard")
|
(sb-ext:run-program "xclip" (list "-selection" "clipboard")
|
||||||
:input text :wait nil)))
|
:input text :wait nil)))
|
||||||
#+darwin (sb-ext:run-program "pbcopy" nil :input text :wait nil))
|
#+darwin (sb-ext:run-program "pbcopy" nil :input text :wait nil))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
;;; --- Selection tracking (mouse drag) ---------------------------------------
|
*** ~*selection-active*~ — flag indicating an in-progress drag selection
|
||||||
|
|
||||||
|
Setting this to ~T~ during a mouse drag lets the renderer know it
|
||||||
|
should draw a highlight overlay. A global flag (rather than threading
|
||||||
|
the drag state through event handlers) mirrors the simplicity of
|
||||||
|
~*selection*~ and makes it trivial to check in rendering code.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/components/mouse.lisp :noweb no
|
||||||
(defvar *selection-active* nil
|
(defvar *selection-active* nil
|
||||||
"T when a drag selection is in progress.")
|
"T when a drag selection is in progress.")
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
*** ~*selection-start*~ — drag origin coordinates
|
||||||
|
|
||||||
|
Stored as a cons cell ~(X . Y)~ of the mouse-down position. Using a
|
||||||
|
cons (vs. a struct) keeps the imperative mutation simple — ~setf~ with
|
||||||
|
~cons~ is a single expression.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/components/mouse.lisp :noweb no
|
||||||
(defvar *selection-start* nil
|
(defvar *selection-start* nil
|
||||||
"Cons (X . Y) of mouse-down position during drag.")
|
"Cons (X . Y) of mouse-down position during drag.")
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
*** ~*selection-end*~ — current drag extent coordinates
|
||||||
|
|
||||||
|
Updated on every mouse-move during a drag so the rendering loop can
|
||||||
|
draw the live highlight rectangle between ~*selection-start*~ and
|
||||||
|
~*selection-end*~.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/components/mouse.lisp :noweb no
|
||||||
(defvar *selection-end* nil
|
(defvar *selection-end* nil
|
||||||
"Cons (X . Y) of current mouse position during drag.")
|
"Cons (X . Y) of current mouse position during drag.")
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
*** ~start-selection~ — begin a drag selection
|
||||||
|
|
||||||
|
Initializes all three drag state variables in one call. Both start and
|
||||||
|
end are set to the same position so that before the first mouse-move
|
||||||
|
the "selection" is a zero-width region (which renders as nothing).
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/components/mouse.lisp :noweb no
|
||||||
(defun start-selection (x y)
|
(defun start-selection (x y)
|
||||||
"Begin a drag selection at (X Y)."
|
"Begin a drag selection at (X Y)."
|
||||||
(setf *selection-start* (cons x y)
|
(setf *selection-start* (cons x y)
|
||||||
*selection-end* (cons x y)
|
*selection-end* (cons x y)
|
||||||
*selection-active* t))
|
*selection-active* t))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
*** ~update-selection~ — update the drag extent during mouse-move
|
||||||
|
|
||||||
|
Called on every mouse-move event while dragging. Only updates the end
|
||||||
|
position; the start remains fixed from the original mouse-down. The
|
||||||
|
rendering loop reads both globals to draw the highlight rectangle.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/components/mouse.lisp :noweb no
|
||||||
(defun update-selection (x y)
|
(defun update-selection (x y)
|
||||||
"Update the drag selection end position to (X Y)."
|
"Update the drag selection end position to (X Y)."
|
||||||
(setf *selection-end* (cons x y)))
|
(setf *selection-end* (cons x y)))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
*** ~selection-active-p~ — predicate for drag state
|
||||||
|
|
||||||
|
Encapsulates the global flag behind a function so that callers don't
|
||||||
|
need to know the variable name. Returning ~*selection-active*~
|
||||||
|
directly works because it is always ~nil~ or ~T~.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/components/mouse.lisp :noweb no
|
||||||
(defun selection-active-p ()
|
(defun selection-active-p ()
|
||||||
"Return T if a drag selection is in progress."
|
"Return T if a drag selection is in progress."
|
||||||
*selection-active*)
|
*selection-active*)
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
*** ~finalize-selection~ — complete the drag and extract text
|
||||||
|
|
||||||
|
Clears the active flag, normalizes coordinates (the user may have
|
||||||
|
dragged right-to-left or bottom-to-top), extracts the text from the
|
||||||
|
framebuffer via ~cl-tty.rendering:extract-text~, stores the result in
|
||||||
|
~*selection*~, and returns the extracted string. The ~fb~ parameter
|
||||||
|
must be the current framebuffer at the time of release.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/components/mouse.lisp :noweb no
|
||||||
(defun finalize-selection (fb)
|
(defun finalize-selection (fb)
|
||||||
"End the drag selection and extract text from the framebuffer."
|
"End the drag selection and extract text from the framebuffer."
|
||||||
(setf *selection-active* nil)
|
(setf *selection-active* nil)
|
||||||
@@ -151,13 +275,28 @@ sessions.
|
|||||||
:text text))
|
:text text))
|
||||||
(setf *selection-start* nil *selection-end* nil)
|
(setf *selection-start* nil *selection-end* nil)
|
||||||
text)))
|
text)))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
;;; --- Link clicking ---------------------------------------------------------
|
*** ~cell-link-at~ — read a link URL from the framebuffer at (x, y)
|
||||||
|
|
||||||
|
Delegates to the rendering layer's ~fb-cell-link-url~ to look up the
|
||||||
|
cell metadata. This indirection keeps mouse code independent of the
|
||||||
|
framebuffer's internal storage format.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/components/mouse.lisp :noweb no
|
||||||
(defun cell-link-at (fb x y)
|
(defun cell-link-at (fb x y)
|
||||||
"Return the link URL at (X Y) in framebuffer FB, or nil."
|
"Return the link URL at (X Y) in framebuffer FB, or nil."
|
||||||
(cl-tty.rendering:fb-cell-link-url fb x y))
|
(cl-tty.rendering:fb-cell-link-url fb x y))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
*** ~open-link-at~ — navigate to a URL embedded at a screen position
|
||||||
|
|
||||||
|
If ~cell-link-at~ finds a URL, open it with the OS default handler
|
||||||
|
(~xdg-open~ on Linux, ~open~ on Darwin). Returns the URL (or nil) so
|
||||||
|
the caller can log or react to the result. The ~:wait nil~ avoids
|
||||||
|
blocking the TTY UI while the browser launches.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/components/mouse.lisp :noweb no
|
||||||
(defun open-link-at (fb x y)
|
(defun open-link-at (fb x y)
|
||||||
"If there is a link URL at (X Y) in FB, open it via xdg-open."
|
"If there is a link URL at (X Y) in FB, open it via xdg-open."
|
||||||
(let ((url (cell-link-at fb x y)))
|
(let ((url (cell-link-at fb x y)))
|
||||||
@@ -167,29 +306,68 @@ sessions.
|
|||||||
url))
|
url))
|
||||||
#+END_SRC
|
#+END_SRC
|
||||||
|
|
||||||
|
*** Tests
|
||||||
|
|
||||||
|
**** Test package and suite definition
|
||||||
|
|
||||||
|
Isolates test symbols in their own package to avoid polluting the
|
||||||
|
production namespace. FiveAM's ~def-suite~ groups all mouse tests
|
||||||
|
under a single name for convenient batch execution.
|
||||||
|
|
||||||
#+BEGIN_SRC lisp :tangle ../tests/mouse-tests.lisp :noweb no
|
#+BEGIN_SRC lisp :tangle ../tests/mouse-tests.lisp :noweb no
|
||||||
(defpackage :cl-tty-mouse-test (:use :cl :cl-tty.mouse :fiveam))
|
(defpackage :cl-tty-mouse-test (:use :cl :cl-tty.mouse :fiveam))
|
||||||
(in-package :cl-tty-mouse-test)
|
(in-package :cl-tty-mouse-test)
|
||||||
|
|
||||||
(def-suite mouse-suite :description "Mouse tests")
|
(def-suite mouse-suite :description "Mouse tests")
|
||||||
(in-suite mouse-suite)
|
(in-suite mouse-suite)
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
**** Test: ~mouse-mixin-create~
|
||||||
|
|
||||||
|
Verifies that the mixin class can be instantiated and passes a basic
|
||||||
|
typep check. This guards against missing ~:initform~ values or
|
||||||
|
superclass chain issues.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../tests/mouse-tests.lisp :noweb no
|
||||||
(def-test mouse-mixin-create ()
|
(def-test mouse-mixin-create ()
|
||||||
(let ((m (make-instance 'mouse-mixin)))
|
(let ((m (make-instance 'mouse-mixin)))
|
||||||
(is-true (typep m 'mouse-mixin))))
|
(is-true (typep m 'mouse-mixin))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
**** Test: ~mouse-hit-test-point~
|
||||||
|
|
||||||
|
~hit-test~ on a bare ~mouse-mixin~ (no layout-node) should return nil
|
||||||
|
for any coordinates. This tests the ~ignore-errors~ guard path in the
|
||||||
|
hit-testing logic.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../tests/mouse-tests.lisp :noweb no
|
||||||
(def-test mouse-hit-test-point ()
|
(def-test mouse-hit-test-point ()
|
||||||
"hit-test returns nil when no component has position slots bound"
|
"hit-test returns nil when no component has position slots bound"
|
||||||
(let ((obj (make-instance 'mouse-mixin)))
|
(let ((obj (make-instance 'mouse-mixin)))
|
||||||
(is-false (hit-test obj 0 0))
|
(is-false (hit-test obj 0 0))
|
||||||
(is-false (hit-test obj 100 100))))
|
(is-false (hit-test obj 100 100))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
**** Test: ~selection-set-and-get~
|
||||||
|
|
||||||
|
Sets ~*selection*~ directly (simulating a completed drag) and checks
|
||||||
|
that ~get-selection~ returns the expected text. This validates the
|
||||||
|
~selection~ struct accessor chain end-to-end.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../tests/mouse-tests.lisp :noweb no
|
||||||
(def-test selection-set-and-get ()
|
(def-test selection-set-and-get ()
|
||||||
(setf cl-tty.mouse::*selection* (make-selection :text "hello"))
|
(setf cl-tty.mouse::*selection* (make-selection :text "hello"))
|
||||||
(is (equal "hello" (get-selection))))
|
(is (equal "hello" (get-selection))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
;; ── Selection tracking ──────────────────────────────────────
|
**** Test: ~start-selection-initializes-state~
|
||||||
|
|
||||||
|
~start-selection~ must set ~*selection-start*~, ~*selection-end*~, and
|
||||||
|
~*selection-active*~ to their expected initial values. The teardown
|
||||||
|
resets globals to avoid cross-test contamination (FiveAM does not
|
||||||
|
automatically reset special variables between tests).
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../tests/mouse-tests.lisp :noweb no
|
||||||
(def-test start-selection-initializes-state ()
|
(def-test start-selection-initializes-state ()
|
||||||
(start-selection 5 10)
|
(start-selection 5 10)
|
||||||
(is-true (selection-active-p))
|
(is-true (selection-active-p))
|
||||||
@@ -198,7 +376,15 @@ sessions.
|
|||||||
(setf cl-tty.mouse::*selection-active* nil
|
(setf cl-tty.mouse::*selection-active* nil
|
||||||
cl-tty.mouse::*selection-start* nil
|
cl-tty.mouse::*selection-start* nil
|
||||||
cl-tty.mouse::*selection-end* nil))
|
cl-tty.mouse::*selection-end* nil))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
**** Test: ~update-selection-moves-end~
|
||||||
|
|
||||||
|
After ~start-selection~, calling ~update-selection~ must update
|
||||||
|
~*selection-end*~ while leaving ~*selection-start*~ unchanged. This
|
||||||
|
validates the drag-tracking update path.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../tests/mouse-tests.lisp :noweb no
|
||||||
(def-test update-selection-moves-end ()
|
(def-test update-selection-moves-end ()
|
||||||
(start-selection 0 0)
|
(start-selection 0 0)
|
||||||
(update-selection 3 7)
|
(update-selection 3 7)
|
||||||
@@ -206,7 +392,16 @@ sessions.
|
|||||||
(setf cl-tty.mouse::*selection-active* nil
|
(setf cl-tty.mouse::*selection-active* nil
|
||||||
cl-tty.mouse::*selection-start* nil
|
cl-tty.mouse::*selection-start* nil
|
||||||
cl-tty.mouse::*selection-end* nil))
|
cl-tty.mouse::*selection-end* nil))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
**** Test: ~finalize-selection-extracts-text~
|
||||||
|
|
||||||
|
End-to-end integration test: draws text into a real framebuffer,
|
||||||
|
simulates a drag selection, and verifies that ~finalize-selection~
|
||||||
|
extracts the correct multi-line string. This exercises the full chain
|
||||||
|
from framebuffer cell storage through coordinate normalization.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../tests/mouse-tests.lisp :noweb no
|
||||||
(def-test finalize-selection-extracts-text ()
|
(def-test finalize-selection-extracts-text ()
|
||||||
(let* ((fb-be (cl-tty.rendering:make-framebuffer-backend))
|
(let* ((fb-be (cl-tty.rendering:make-framebuffer-backend))
|
||||||
(fb (cl-tty.rendering:fb-framebuffer fb-be)))
|
(fb (cl-tty.rendering:fb-framebuffer fb-be)))
|
||||||
@@ -217,5 +412,4 @@ sessions.
|
|||||||
(let ((text (finalize-selection fb)))
|
(let ((text (finalize-selection fb)))
|
||||||
(is (equal "hello
|
(is (equal "hello
|
||||||
world" text)))))
|
world" text)))))
|
||||||
|
|
||||||
#+END_SRC
|
#+END_SRC
|
||||||
111
org/package.org
111
org/package.org
@@ -38,6 +38,21 @@ etc., and ~cl-tty.layout~ for ~layout-node~, ~compute-layout~, and the
|
|||||||
The only direct dependencies are these two packages — no other
|
The only direct dependencies are these two packages — no other
|
||||||
application code is needed to define components.
|
application code is needed to define components.
|
||||||
|
|
||||||
|
** Box exports
|
||||||
|
|
||||||
|
The ~box~ class is the primary rectangular container: it renders a
|
||||||
|
bordered region with optional title and background color. The accessor
|
||||||
|
family (~box-border-style~, ~box-title~, ~box-title-align~,
|
||||||
|
~box-fg~, ~box-bg~) follows a consistent naming convention so that
|
||||||
|
users can infer slot names from the class name. ~render-box~ is the
|
||||||
|
specialized method that draws the border and fills the interior.
|
||||||
|
|
||||||
|
The ~box-layout-node~ accessor connects the box to its layout tree
|
||||||
|
node, which is essential for the render pipeline's coordinate
|
||||||
|
computation. We export it separately from the rendering symbols
|
||||||
|
because it is also needed by code that walks the component tree
|
||||||
|
without triggering a full render.
|
||||||
|
|
||||||
#+BEGIN_SRC lisp :tangle ../src/components/package.lisp
|
#+BEGIN_SRC lisp :tangle ../src/components/package.lisp
|
||||||
(defpackage :cl-tty.box
|
(defpackage :cl-tty.box
|
||||||
(:use :cl :cl-tty.backend :cl-tty.layout)
|
(:use :cl :cl-tty.backend :cl-tty.layout)
|
||||||
@@ -48,30 +63,118 @@ application code is needed to define components.
|
|||||||
#:box-border-style #:box-title #:box-title-align
|
#:box-border-style #:box-title #:box-title-align
|
||||||
#:box-fg #:box-bg
|
#:box-fg #:box-bg
|
||||||
#:render-box
|
#:render-box
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** Span exports
|
||||||
|
|
||||||
|
Spans are lightweight inline-style records — not full classes with
|
||||||
|
layout. Each span stores a substring of the parent text along with
|
||||||
|
its visual attributes. The reader-named accessors (~span-text~,
|
||||||
|
~span-bold~, ~span-italic~, etc.) let rendering code inspect span
|
||||||
|
properties without pulling in the internal representation. We keep
|
||||||
|
the accessor list flat (no grouping macro) to make the package
|
||||||
|
surface easy to grep and to keep the API browser-friendly.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/components/package.lisp
|
||||||
;; Span
|
;; Span
|
||||||
#:span
|
#:span
|
||||||
#:span-text #:span-bold #:span-italic #:span-underline
|
#:span-text #:span-bold #:span-italic #:span-underline
|
||||||
#:span-reverse #:span-dim #:span-fg #:span-bg
|
#:span-reverse #:span-dim #:span-fg #:span-bg
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** Text exports
|
||||||
|
|
||||||
|
~text~ and ~make-text~ are the construction interface for the text
|
||||||
|
renderable. The ~text-layout-node~ accessor follows the same pattern
|
||||||
|
as ~box-layout-node~, bridging the component and layout layers.
|
||||||
|
~text-content~ and ~text-spans~ expose the raw data for rendering;
|
||||||
|
~text-fg~, ~text-bg~, and ~text-wrap-mode~ control global text
|
||||||
|
appearance. ~render-text~ is the CLOS method that walks the span list
|
||||||
|
and calls ~draw-text~ from the backend.
|
||||||
|
|
||||||
|
These symbols live in the ~cl-tty.box~ package rather than a
|
||||||
|
separate ~cl-tty.text~ package to keep inter-component references
|
||||||
|
trivial — boxes can hold text children, and text can be nested inside
|
||||||
|
other components, all without cross-package imports.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/components/package.lisp
|
||||||
;; Text
|
;; Text
|
||||||
#:text #:make-text
|
#:text #:make-text
|
||||||
#:text-layout-node #:text-content #:text-spans
|
#:text-layout-node #:text-content #:text-spans
|
||||||
#:text-fg #:text-bg #:text-wrap-mode
|
#:text-fg #:text-bg #:text-wrap-mode
|
||||||
#:render-text
|
#:render-text
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** Utility exports (for tests)
|
||||||
|
|
||||||
|
~word-wrap~ and ~split-string~ are internal text-processing utilities
|
||||||
|
used by the text renderer to break lines and tokenize input. They are
|
||||||
|
exported specifically so the test suite can unit-test them in
|
||||||
|
isolation. They are not part of the public component API and should
|
||||||
|
not be relied upon by application code outside of tests.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/components/package.lisp
|
||||||
;; Utilities (for tests)
|
;; Utilities (for tests)
|
||||||
#:word-wrap #:split-string
|
#:word-wrap #:split-string
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** Dirty tracking
|
||||||
|
|
||||||
|
The dirty-mixin protocol lets any component class participate in the
|
||||||
|
change-propagation system. ~dirty-mixin~ is the mixin class, and
|
||||||
|
~dirty-p~, ~mark-clean~, ~mark-dirty~ are the three operations that
|
||||||
|
the render pipeline calls to decide whether a subtree needs
|
||||||
|
re-rendering.
|
||||||
|
|
||||||
|
Having these as generic functions (rather than a single ~(setf
|
||||||
|
dirty-p)~) makes it easy for subclasses to add side effects on dirty
|
||||||
|
transitions — for example, invalidating a cached bitmap or
|
||||||
|
recomputing string metrics.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/components/package.lisp
|
||||||
;; Dirty tracking
|
;; Dirty tracking
|
||||||
#:dirty-mixin #:dirty-p #:mark-clean #:mark-dirty
|
#:dirty-mixin #:dirty-p #:mark-clean #:mark-dirty
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** Rendering pipeline
|
||||||
|
|
||||||
|
~render~, ~render-screen~, and ~render-node~ are the three entry
|
||||||
|
points into the rendering dispatch. ~component-layout-node~,
|
||||||
|
~component-children~, and ~component-parent~ form the tree-navigation
|
||||||
|
interface that ~render-node~ uses to walk the component hierarchy.
|
||||||
|
~available-width~ and ~available-height~ are passed down the tree to
|
||||||
|
constrain layout. ~propagate-dirty~ walks upward from a changed
|
||||||
|
component to mark ancestors as dirty, ensuring the screen is
|
||||||
|
re-drawn from the correct root.
|
||||||
|
|
||||||
|
Collecting these under a single "Rendering pipeline" group signals to
|
||||||
|
readers that they form a coherent subsystem — if you override one,
|
||||||
|
you likely need to understand all of them.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/components/package.lisp
|
||||||
;; Rendering pipeline
|
;; Rendering pipeline
|
||||||
#:render #:render-screen #:render-node
|
#:render #:render-screen #:render-node
|
||||||
#:component-layout-node #:component-children #:component-parent
|
#:component-layout-node #:component-children #:component-parent
|
||||||
#:available-width #:available-height
|
#:available-width #:available-height
|
||||||
#:propagate-dirty
|
#:propagate-dirty
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** Theme engine
|
||||||
|
|
||||||
|
~theme~ and ~make-theme~ are the constructor and class for theme
|
||||||
|
objects. ~theme-mode~ selects the active color mode (light/dark).
|
||||||
|
~theme-color~ looks up a named color in the current theme.
|
||||||
|
~load-preset~ loads a theme from a file, and ~define-preset~ registers
|
||||||
|
a preset at compile time.
|
||||||
|
|
||||||
|
The theme engine is isolated from the rest of the component layer —
|
||||||
|
boxes and text reference theme colors by name at render time, and the
|
||||||
|
theme object is passed in from the application level. This separation
|
||||||
|
means themes can be swapped without touching component instances.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/components/package.lisp
|
||||||
;; Theme engine
|
;; Theme engine
|
||||||
#:theme #:make-theme #:theme-mode
|
#:theme #:make-theme #:theme-mode
|
||||||
#:theme-color #:load-preset #:define-preset))
|
#:theme-color #:load-preset #:define-preset))
|
||||||
(in-package :cl-tty.box)
|
(in-package :cl-tty.box)
|
||||||
#+END_SRC
|
#+END_SRC
|
||||||
|
|
||||||
The ~#:word-wrap~ and ~#:split-string~ exports are for tests only —
|
|
||||||
they're utility functions used internally by ~text~ rendering but
|
|
||||||
exposed so the test suite can unit-test them directly.
|
|
||||||
|
|||||||
231
org/render.org
231
org/render.org
@@ -65,6 +65,13 @@ Mark ~component~ and every ancestor dirty. Walks up via
|
|||||||
|
|
||||||
* Tests
|
* Tests
|
||||||
|
|
||||||
|
** Test helper: make-capturing-backend
|
||||||
|
|
||||||
|
Before any render test can run, we need a backend that captures output
|
||||||
|
to a string stream instead of writing to the real terminal. This helper
|
||||||
|
creates a ~modern-backend~ with a ~string-output-stream~ and returns
|
||||||
|
both, so tests can inspect what was rendered.
|
||||||
|
|
||||||
#+BEGIN_SRC lisp :tangle ../src/components/render-tests.lisp
|
#+BEGIN_SRC lisp :tangle ../src/components/render-tests.lisp
|
||||||
(in-package :cl-tty-box-test)
|
(in-package :cl-tty-box-test)
|
||||||
(in-suite box-suite)
|
(in-suite box-suite)
|
||||||
@@ -73,7 +80,17 @@ Mark ~component~ and every ancestor dirty. Walks up via
|
|||||||
(let* ((s (make-string-output-stream))
|
(let* ((s (make-string-output-stream))
|
||||||
(b (make-modern-backend :output-stream s)))
|
(b (make-modern-backend :output-stream s)))
|
||||||
(values b s)))
|
(values b s)))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** Test: render dispatches to box method
|
||||||
|
|
||||||
|
Verifies that calling ~render~ on a ~box~ instance invokes the box
|
||||||
|
rendering path, which draws border characters (e.g. ┌). This confirms
|
||||||
|
generic dispatch works for the box type and that the border rendering
|
||||||
|
pipeline is intact. A regression here would mean ~render-box~ is not
|
||||||
|
being called or produces no output.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/components/render-tests.lisp
|
||||||
(test render-generic-dispatches-box
|
(test render-generic-dispatches-box
|
||||||
"render dispatches to render-box for box instances"
|
"render dispatches to render-box for box instances"
|
||||||
(multiple-value-bind (b s) (make-capturing-backend)
|
(multiple-value-bind (b s) (make-capturing-backend)
|
||||||
@@ -81,7 +98,17 @@ Mark ~component~ and every ancestor dirty. Walks up via
|
|||||||
(compute-layout (box-layout-node bx) 10 5)
|
(compute-layout (box-layout-node bx) 10 5)
|
||||||
(render bx b)
|
(render bx b)
|
||||||
(is (search "┌" (get-output-stream-string s)) "box renders border"))))
|
(is (search "┌" (get-output-stream-string s)) "box renders border"))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** Test: render dispatches to text method
|
||||||
|
|
||||||
|
Verifies that calling ~render~ on a ~text~ instance invokes the text
|
||||||
|
rendering path, which outputs the string content. This confirms generic
|
||||||
|
dispatch works for the text type and that text content is correctly
|
||||||
|
emitted to the backend. A regression would mean ~render-text~ is not
|
||||||
|
being called.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/components/render-tests.lisp
|
||||||
(test render-generic-dispatches-text
|
(test render-generic-dispatches-text
|
||||||
"render dispatches to render-text for text instances"
|
"render dispatches to render-text for text instances"
|
||||||
(multiple-value-bind (b s) (make-capturing-backend)
|
(multiple-value-bind (b s) (make-capturing-backend)
|
||||||
@@ -89,19 +116,51 @@ Mark ~component~ and every ancestor dirty. Walks up via
|
|||||||
(compute-layout (text-layout-node tx) 10 1)
|
(compute-layout (text-layout-node tx) 10 1)
|
||||||
(render tx b)
|
(render tx b)
|
||||||
(is (search "Hello" (get-output-stream-string s)) "text renders content"))))
|
(is (search "Hello" (get-output-stream-string s)) "text renders content"))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** Test: component-layout-node returns layout-node
|
||||||
|
|
||||||
|
The ~component-layout-node~ generic is the bridge between the component
|
||||||
|
layer and the layout layer. Every renderable component must have an
|
||||||
|
associated layout node. This test confirms that both ~box~ and ~text~
|
||||||
|
return a ~layout-node~ instance from their ~component-layout-node~
|
||||||
|
method. A failure here means a component type is missing its method or
|
||||||
|
the slot accessor is wrong.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/components/render-tests.lisp
|
||||||
(test component-layout-node-works
|
(test component-layout-node-works
|
||||||
"component-layout-node returns the right slot for each type"
|
"component-layout-node returns the right slot for each type"
|
||||||
(let ((bx (make-box)) (tx (make-text "")))
|
(let ((bx (make-box)) (tx (make-text "")))
|
||||||
(is (typep (component-layout-node bx) 'layout-node))
|
(is (typep (component-layout-node bx) 'layout-node))
|
||||||
(is (typep (component-layout-node tx) 'layout-node))))
|
(is (typep (component-layout-node tx) 'layout-node))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** Test: component-children returns nil for leaves
|
||||||
|
|
||||||
|
Leaf components (~box~, ~text~) have no children by definition. The
|
||||||
|
default method on ~t~ returns ~nil~. This test ensures that neither box
|
||||||
|
nor text accidentally inherits or defines a method that returns
|
||||||
|
non-nil, which would break the tree-walk in ~render-node~ by causing
|
||||||
|
infinite recursion or rendering phantom children.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/components/render-tests.lisp
|
||||||
(test component-children-returns-nil
|
(test component-children-returns-nil
|
||||||
"Leaf components have no children"
|
"Leaf components have no children"
|
||||||
(let ((bx (make-box)) (tx (make-text "")))
|
(let ((bx (make-box)) (tx (make-text "")))
|
||||||
(is (null (component-children bx)))
|
(is (null (component-children bx)))
|
||||||
(is (null (component-children tx)))))
|
(is (null (component-children tx)))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** Test: propagate-dirty marks component dirty
|
||||||
|
|
||||||
|
~propagate-dirty~ is the entry point for the incremental rendering
|
||||||
|
pipeline. When a component changes (e.g. a keystroke in a text input),
|
||||||
|
it calls ~propagate-dirty~ to ensure the frame is re-rendered. This
|
||||||
|
test verifies that calling ~propagate-dirty~ on a clean component sets
|
||||||
|
it dirty. Without this, components that mutate would never trigger a
|
||||||
|
re-render and the display would become stale.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/components/render-tests.lisp
|
||||||
(test propagate-dirty-marks-component
|
(test propagate-dirty-marks-component
|
||||||
"propagate-dirty marks the component dirty"
|
"propagate-dirty marks the component dirty"
|
||||||
(let ((c (make-box)))
|
(let ((c (make-box)))
|
||||||
@@ -109,7 +168,19 @@ Mark ~component~ and every ancestor dirty. Walks up via
|
|||||||
(is-false (dirty-p c) "should be clean after mark-clean")
|
(is-false (dirty-p c) "should be clean after mark-clean")
|
||||||
(propagate-dirty c)
|
(propagate-dirty c)
|
||||||
(is-true (dirty-p c) "should be dirty after propagate-dirty")))
|
(is-true (dirty-p c) "should be dirty after propagate-dirty")))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** Test: available-width defaults
|
||||||
|
|
||||||
|
~available-width~ reads the computed width from the component's layout
|
||||||
|
node. When a component hasn't been laid out (no explicit width set),
|
||||||
|
the layout node's width defaults to 0. This test verifies that
|
||||||
|
~available-width~ returns 0 for a freshly created box without layout
|
||||||
|
computation. This matters because container components use
|
||||||
|
~available-width~ to position children — getting a sensible default
|
||||||
|
prevents division-by-zero or garbled layouts during initialization.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/components/render-tests.lisp
|
||||||
(test available-width-defaults
|
(test available-width-defaults
|
||||||
"available-width returns 0 for components without explicit width"
|
"available-width returns 0 for components without explicit width"
|
||||||
(let ((c (make-box)))
|
(let ((c (make-box)))
|
||||||
@@ -124,22 +195,46 @@ These three generic functions form the tree navigation API. They're
|
|||||||
separated from ~render~ because layout and dirty propagation also
|
separated from ~render~ because layout and dirty propagation also
|
||||||
need to traverse the tree.
|
need to traverse the tree.
|
||||||
|
|
||||||
|
*** component-layout-node
|
||||||
|
|
||||||
|
The ~component-layout-node~ generic returns the ~layout-node~ instance
|
||||||
|
for a given component. Every component that participates in layout and
|
||||||
|
rendering must have a layout node — it stores the computed position and
|
||||||
|
size after layout passes. The generic is defined with two specific
|
||||||
|
methods for the built-in component types.
|
||||||
|
|
||||||
#+BEGIN_SRC lisp :tangle ../src/components/render.lisp
|
#+BEGIN_SRC lisp :tangle ../src/components/render.lisp
|
||||||
(in-package :cl-tty.box)
|
(in-package :cl-tty.box)
|
||||||
|
|
||||||
;; ── Component Protocol ────────────────────────────────────────
|
;; ── Component Protocol ────────────────────────────────────────
|
||||||
|
|
||||||
(defgeneric component-layout-node (component)
|
(defgeneric component-layout-node (component)
|
||||||
(:documentation "Return the layout-node for COMPONENT.")
|
(:documentation "Return the layout-node for COMPONENT."))
|
||||||
(:method ((bx box)) (box-layout-node bx))
|
|
||||||
(:method ((tx text)) (text-layout-node tx)))
|
|
||||||
#+END_SRC
|
#+END_SRC
|
||||||
|
|
||||||
Each component type defines its own ~component-layout-node~ method
|
Each component type returns its internal layout node slot. This method
|
||||||
that returns its internal layout node. The default method (on ~t~)
|
specializes on ~box~ and returns the ~box-layout-node~ slot value.
|
||||||
would return ~nil~, but since every component in cl-tty has a layout
|
|
||||||
node, we don't provide one — new component types must add their own
|
#+BEGIN_SRC lisp :tangle ../src/components/render.lisp
|
||||||
method.
|
(defmethod component-layout-node ((bx box))
|
||||||
|
(box-layout-node bx))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
The ~text~ component stores its layout node in the ~text-layout-node~
|
||||||
|
slot. Both methods return the same type (~layout-node~), so the layout
|
||||||
|
engine can operate uniformly regardless of component type.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/components/render.lisp
|
||||||
|
(defmethod component-layout-node ((tx text))
|
||||||
|
(text-layout-node tx))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
*** component-children
|
||||||
|
|
||||||
|
Leaf components (~box~, ~text~) have no children. Container components
|
||||||
|
(~scrollbox~, ~tabbar~) override this to return their child list. The
|
||||||
|
default method on ~t~ returns ~nil~, so new component types are
|
||||||
|
automatically treated as leaves unless they explicitly override.
|
||||||
|
|
||||||
#+BEGIN_SRC lisp :tangle ../src/components/render.lisp
|
#+BEGIN_SRC lisp :tangle ../src/components/render.lisp
|
||||||
(defgeneric component-children (component)
|
(defgeneric component-children (component)
|
||||||
@@ -147,8 +242,13 @@ method.
|
|||||||
(:method ((c t)) nil))
|
(:method ((c t)) nil))
|
||||||
#+END_SRC
|
#+END_SRC
|
||||||
|
|
||||||
Leaf components (~box~, ~text~) have no children. Container components
|
*** component-parent
|
||||||
(~scrollbox~, ~tabbar~) override this to return their child list.
|
|
||||||
|
Parent links are set by the container when adding children. They're
|
||||||
|
used by ~propagate-dirty~ to walk up the tree. The default method on
|
||||||
|
~t~ returns ~nil~, which acts as the termination condition for the
|
||||||
|
recursive dirty walk — when ~component-parent~ returns ~nil~, we've
|
||||||
|
reached the root.
|
||||||
|
|
||||||
#+BEGIN_SRC lisp :tangle ../src/components/render.lisp
|
#+BEGIN_SRC lisp :tangle ../src/components/render.lisp
|
||||||
(defgeneric component-parent (component)
|
(defgeneric component-parent (component)
|
||||||
@@ -156,11 +256,16 @@ Leaf components (~box~, ~text~) have no children. Container components
|
|||||||
(:method ((c t)) nil))
|
(:method ((c t)) nil))
|
||||||
#+END_SRC
|
#+END_SRC
|
||||||
|
|
||||||
Parent links are set by the container when adding children. They're
|
|
||||||
used by ~propagate-dirty~ to walk up the tree.
|
|
||||||
|
|
||||||
** Render dispatch
|
** Render dispatch
|
||||||
|
|
||||||
|
*** render generic
|
||||||
|
|
||||||
|
The ~render~ generic is the central dispatch point for the rendering
|
||||||
|
pipeline. Every component type that can be drawn defines a method on
|
||||||
|
~render~. The default method on ~t~ is a no-op so that non-renderable
|
||||||
|
objects (or components still under development) don't cause errors
|
||||||
|
when the tree walk reaches them.
|
||||||
|
|
||||||
#+BEGIN_SRC lisp :tangle ../src/components/render.lisp
|
#+BEGIN_SRC lisp :tangle ../src/components/render.lisp
|
||||||
;; ── Rendering Pipeline ────────────────────────────────────────
|
;; ── Rendering Pipeline ────────────────────────────────────────
|
||||||
|
|
||||||
@@ -171,25 +276,43 @@ used by ~propagate-dirty~ to walk up the tree.
|
|||||||
(values)))
|
(values)))
|
||||||
#+END_SRC
|
#+END_SRC
|
||||||
|
|
||||||
The ~render~ generic is the central dispatch point. Every component
|
*** render method for box
|
||||||
type that can be drawn defines a method on ~render~. The default
|
|
||||||
method is a no-op so that non-renderable objects (or components still
|
Boxes are rendered with border characters. The ~render~ method
|
||||||
under development) don't cause errors.
|
delegates to the ~render-box~ function defined in ~box.lisp~, which
|
||||||
|
handles the actual drawing of border lines and corners.
|
||||||
|
|
||||||
#+BEGIN_SRC lisp :tangle ../src/components/render.lisp
|
#+BEGIN_SRC lisp :tangle ../src/components/render.lisp
|
||||||
(defmethod render ((bx box) backend)
|
(defmethod render ((bx box) backend)
|
||||||
(render-box bx backend))
|
(render-box bx backend))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
*** render method for text
|
||||||
|
|
||||||
|
Text components render their content string at the computed position.
|
||||||
|
The ~render~ method delegates to ~render-text~ from ~text.lisp~, which
|
||||||
|
writes the string with appropriate escape sequences for positioning.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/components/render.lisp
|
||||||
(defmethod render ((tx text) backend)
|
(defmethod render ((tx text) backend)
|
||||||
(render-text tx backend))
|
(render-text tx backend))
|
||||||
#+END_SRC
|
#+END_SRC
|
||||||
|
|
||||||
Box and text are the two built-in renderable types. Their ~render~
|
|
||||||
methods delegate to the specific rendering functions defined in
|
|
||||||
~box.lisp~ and ~text.lisp~.
|
|
||||||
|
|
||||||
** Screen-level orchestration
|
** Screen-level orchestration
|
||||||
|
|
||||||
|
*** render-screen
|
||||||
|
|
||||||
|
~render-screen~ is the entry point for rendering a full frame. It
|
||||||
|
queries the terminal size at render time (not at startup), so the
|
||||||
|
layout adapts to window resizes automatically. The DECICM sync pair
|
||||||
|
(~begin-sync~/~end-sync~) wraps the entire frame in a synchronized
|
||||||
|
update: the terminal buffers all escape sequences and flushes them
|
||||||
|
atomically, preventing partial-frame flicker.
|
||||||
|
|
||||||
|
The pipeline is: (1) query backend pixel/dimension size, (2) begin
|
||||||
|
sync, (3) compute layout at the root, (4) walk the tree rendering each
|
||||||
|
node, (5) end sync.
|
||||||
|
|
||||||
#+BEGIN_SRC lisp :tangle ../src/components/render.lisp
|
#+BEGIN_SRC lisp :tangle ../src/components/render.lisp
|
||||||
(defun render-screen (root backend)
|
(defun render-screen (root backend)
|
||||||
"Render the component tree ROOT using BACKEND.
|
"Render the component tree ROOT using BACKEND.
|
||||||
@@ -203,14 +326,13 @@ methods delegate to the specific rendering functions defined in
|
|||||||
(end-sync backend)))
|
(end-sync backend)))
|
||||||
#+END_SRC
|
#+END_SRC
|
||||||
|
|
||||||
~render-screen~ is the entry point for rendering a full frame. It
|
*** render-node
|
||||||
queries the terminal size at render time (not at startup), so the
|
|
||||||
layout adapts to window resizes automatically.
|
|
||||||
|
|
||||||
The DECICM sync pair (~begin-sync~/~end-sync~) wraps the entire
|
Tree walk: render this node, then recurse into children. The layout was
|
||||||
frame in a synchronized update: the terminal buffers all escape
|
already computed by ~render-screen~, so each node's position and size
|
||||||
sequences and flushes them atomically. This prevents partial-frame
|
are available from its ~layout-node~. The recursion is depth-first:
|
||||||
flicker.
|
parents are drawn before children, which matters for z-ordering (the
|
||||||
|
parent's background is drawn first, children overlay on top).
|
||||||
|
|
||||||
#+BEGIN_SRC lisp :tangle ../src/components/render.lisp
|
#+BEGIN_SRC lisp :tangle ../src/components/render.lisp
|
||||||
(defun render-node (node backend)
|
(defun render-node (node backend)
|
||||||
@@ -222,34 +344,53 @@ flicker.
|
|||||||
(render-node child backend)))
|
(render-node child backend)))
|
||||||
#+END_SRC
|
#+END_SRC
|
||||||
|
|
||||||
Tree walk: render this node, then recurse into children. The layout
|
|
||||||
was already computed by ~render-screen~, so each node's position and
|
|
||||||
size are available from its ~layout-node~.
|
|
||||||
|
|
||||||
** Utility accessors
|
** Utility accessors
|
||||||
|
|
||||||
|
*** available-width
|
||||||
|
|
||||||
|
Returns the computed width from the component's layout node. The layout
|
||||||
|
node's width is set by ~compute-layout~ during ~render-screen~, so this
|
||||||
|
reflects the actual allocated space — not the requested width. The
|
||||||
|
fallback of 80 matches the default terminal width when no layout node
|
||||||
|
exists (during initialization or testing without a backend).
|
||||||
|
|
||||||
#+BEGIN_SRC lisp :tangle ../src/components/render.lisp
|
#+BEGIN_SRC lisp :tangle ../src/components/render.lisp
|
||||||
(defun available-width (component)
|
(defun available-width (component)
|
||||||
"Return the available width for COMPONENT (or 80 as default)."
|
"Return the available width for COMPONENT (or 80 as default)."
|
||||||
(let ((ln (component-layout-node component)))
|
(let ((ln (component-layout-node component)))
|
||||||
(if ln (layout-node-width ln) 80)))
|
(if ln (layout-node-width ln) 80)))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
*** available-height
|
||||||
|
|
||||||
|
Returns the computed height from the component's layout node. Like
|
||||||
|
~available-width~, this reflects post-layout allocated space. The
|
||||||
|
fallback of 24 matches the default terminal height. These accessors
|
||||||
|
provide a clean API for components that need to know their allocated
|
||||||
|
space during rendering, avoiding direct access to layout nodes.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/components/render.lisp
|
||||||
(defun available-height (component)
|
(defun available-height (component)
|
||||||
"Return the available height for COMPONENT (or 24 as default)."
|
"Return the available height for COMPONENT (or 24 as default)."
|
||||||
(let ((ln (component-layout-node component)))
|
(let ((ln (component-layout-node component)))
|
||||||
(if ln (layout-node-height ln) 24)))
|
(if ln (layout-node-height ln) 24)))
|
||||||
#+END_SRC
|
#+END_SRC
|
||||||
|
|
||||||
These accessors provide a clean API for components that need to know
|
|
||||||
their allocated space. They return the computed dimensions from the
|
|
||||||
layout node, which was set by ~compute-layout~ during ~render-screen~.
|
|
||||||
|
|
||||||
The fallback values (80x24) match the terminal default when no layout
|
|
||||||
node exists — typically during initialization or testing without a
|
|
||||||
backenπd.
|
|
||||||
|
|
||||||
** Dirty propagation
|
** Dirty propagation
|
||||||
|
|
||||||
|
*** propagate-dirty
|
||||||
|
|
||||||
|
Recursive walk up the parent chain. When a text input receives a
|
||||||
|
keystroke, it marks itself dirty, then its parent scrollbox, then the
|
||||||
|
containing box, then the root — triggering recomputation and
|
||||||
|
re-rendering of everything that might have changed.
|
||||||
|
|
||||||
|
This is the key to incremental rendering: only dirty branches are
|
||||||
|
re-processed. The ~render~ methods check ~dirty-p~ early and return
|
||||||
|
immediately for clean components (handled in each component's render,
|
||||||
|
not here). The recursion terminates when ~component-parent~ returns
|
||||||
|
~nil~ (the root component has no parent).
|
||||||
|
|
||||||
#+BEGIN_SRC lisp :tangle ../src/components/render.lisp
|
#+BEGIN_SRC lisp :tangle ../src/components/render.lisp
|
||||||
;; ── Dirty Propagation ─────────────────────────────────────────
|
;; ── Dirty Propagation ─────────────────────────────────────────
|
||||||
|
|
||||||
@@ -260,13 +401,3 @@ backenπd.
|
|||||||
(when parent
|
(when parent
|
||||||
(propagate-dirty parent))))
|
(propagate-dirty parent))))
|
||||||
#+END_SRC
|
#+END_SRC
|
||||||
|
|
||||||
Recursive walk up the parent chain. When a text input receives a
|
|
||||||
keystroke, it marks itself dirty, then its parent scrollbox, then the
|
|
||||||
containing box, then the root — triggering recomputation and
|
|
||||||
re-rendering of everything that might have changed.
|
|
||||||
|
|
||||||
This is the key to incremental rendering: only dirty branches are
|
|
||||||
re-processed. The ~render~ methods check ~dirty-p~ early and return
|
|
||||||
immediately for clean components (handled in each component's render,
|
|
||||||
not here).
|
|
||||||
|
|||||||
@@ -41,8 +41,9 @@ list of child components and two scroll offset slots (~scroll-y~ and
|
|||||||
~scroll-x~). The ~sticky-scroll-p~ flag, when true, keeps the scroll
|
~scroll-x~). The ~sticky-scroll-p~ flag, when true, keeps the scroll
|
||||||
position at the bottom whenever new children are added.
|
position at the bottom whenever new children are added.
|
||||||
|
|
||||||
The constructor accepts keyword arguments for initial offset and children.
|
Defining this as a class (rather than a struct) lets us integrate with
|
||||||
~children~ defaults to an empty list.
|
the CLOS-based component protocol — ~render~ dispatches on the class,
|
||||||
|
and dirty-mixin provides the marking machinery used by the refresh loop.
|
||||||
|
|
||||||
#+BEGIN_SRC lisp :tangle ../src/components/scrollbox.lisp
|
#+BEGIN_SRC lisp :tangle ../src/components/scrollbox.lisp
|
||||||
(in-package #:cl-tty.container)
|
(in-package #:cl-tty.container)
|
||||||
@@ -57,7 +58,18 @@ The constructor accepts keyword arguments for initial offset and children.
|
|||||||
(sticky-scroll-p :initform t :initarg :sticky-scroll-p
|
(sticky-scroll-p :initform t :initarg :sticky-scroll-p
|
||||||
:accessor sticky-scroll-p :type boolean)
|
:accessor sticky-scroll-p :type boolean)
|
||||||
(layout-node :initform (make-layout-node) :accessor scroll-box-layout-node)))
|
(layout-node :initform (make-layout-node) :accessor scroll-box-layout-node)))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** make-scroll-box constructor
|
||||||
|
|
||||||
|
A dedicated constructor function provides keyword argument defaults and
|
||||||
|
ensures ~sticky-scroll-p~ defaults to T even when the caller omits it
|
||||||
|
(the :initform on the slot handles default-initialization, but a nil
|
||||||
|
value explicitly passed as ~:sticky-scroll-p nil~ needs to be
|
||||||
|
preserved). Using a function instead of making the user call
|
||||||
|
~make-instance~ directly keeps the API ergonomic and hides CLOS plumbing.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/components/scrollbox.lisp
|
||||||
(defun make-scroll-box (&key (children nil) (scroll-y 0) (scroll-x 0)
|
(defun make-scroll-box (&key (children nil) (scroll-y 0) (scroll-x 0)
|
||||||
sticky-scroll-p)
|
sticky-scroll-p)
|
||||||
(make-instance 'scroll-box
|
(make-instance 'scroll-box
|
||||||
@@ -67,29 +79,39 @@ The constructor accepts keyword arguments for initial offset and children.
|
|||||||
:sticky-scroll-p (if (null sticky-scroll-p) t sticky-scroll-p)))
|
:sticky-scroll-p (if (null sticky-scroll-p) t sticky-scroll-p)))
|
||||||
#+END_SRC
|
#+END_SRC
|
||||||
|
|
||||||
** ScrollBox: component protocol
|
** component-children method
|
||||||
|
|
||||||
~component-children~ returns the child list for the rendering pipeline
|
~component-children~ is part of the component protocol. The rendering
|
||||||
to traverse. ~component-layout-node~ returns the layout node so the
|
pipeline calls this to discover the tree of children to render. By
|
||||||
layout engine can position the ScrollBox itself.
|
delegating to the ~scroll-box-children~ accessor, we keep the protocol
|
||||||
|
implementation thin — just an indirection that makes ~scroll-box~
|
||||||
|
participate polymorphically alongside other container types.
|
||||||
|
|
||||||
#+BEGIN_SRC lisp :tangle ../src/components/scrollbox.lisp
|
#+BEGIN_SRC lisp :tangle ../src/components/scrollbox.lisp
|
||||||
(defmethod component-children ((sb scroll-box))
|
(defmethod component-children ((sb scroll-box))
|
||||||
(scroll-box-children sb))
|
(scroll-box-children sb))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** component-layout-node method
|
||||||
|
|
||||||
|
~component-layout-node~ returns the layout node that the layout engine
|
||||||
|
uses to position the ScrollBox itself within its parent. Each ScrollBox
|
||||||
|
creates its own layout node at construction time via ~make-layout-node~,
|
||||||
|
so this method simply returns that stored node.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/components/scrollbox.lisp
|
||||||
(defmethod component-layout-node ((sb scroll-box))
|
(defmethod component-layout-node ((sb scroll-box))
|
||||||
(scroll-box-layout-node sb))
|
(scroll-box-layout-node sb))
|
||||||
#+END_SRC
|
#+END_SRC
|
||||||
|
|
||||||
** ScrollBox: scroll-by
|
** clamp-scroll helper
|
||||||
|
|
||||||
~scroll-by~ adjusts the scroll offset by delta rows and columns. It
|
~clamp-scroll~ recalculates valid scroll bounds after content or viewport
|
||||||
clamps the offset so it doesn't go below 0 (no scroll before start)
|
changes — called automatically when children change or the layout node
|
||||||
or beyond the content size minus the viewport size.
|
resizes. It reads the viewport dimensions from the layout node and the
|
||||||
|
content dimensions from the content-size helpers, then clamps both
|
||||||
~clamp-scroll~ recalculates valid bounds after content or viewport
|
scroll offsets with ~max~/~min~ to ensure they never go below 0 or
|
||||||
changes — called automatically when children change or the layout
|
beyond the scrollable range.
|
||||||
node resizes.
|
|
||||||
|
|
||||||
#+BEGIN_SRC lisp :tangle ../src/components/scrollbox.lisp
|
#+BEGIN_SRC lisp :tangle ../src/components/scrollbox.lisp
|
||||||
(defun clamp-scroll (sb)
|
(defun clamp-scroll (sb)
|
||||||
@@ -105,7 +127,17 @@ node resizes.
|
|||||||
(setf (scroll-box-scroll-x sb)
|
(setf (scroll-box-scroll-x sb)
|
||||||
(max 0 (min (scroll-box-scroll-x sb)
|
(max 0 (min (scroll-box-scroll-x sb)
|
||||||
(- content-width viewport-width))))))
|
(- content-width viewport-width))))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** scroll-by method
|
||||||
|
|
||||||
|
~scroll-by~ adjusts the scroll offset by delta rows and columns. It
|
||||||
|
increments the current offset, clamps via ~clamp-scroll~, then marks
|
||||||
|
the component dirty so the render loop picks up the change. This is
|
||||||
|
the primary API entry point for programmatic scrolling (from keyboard
|
||||||
|
input or mouse wheel events).
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/components/scrollbox.lisp
|
||||||
(defun scroll-by (sb dy dx)
|
(defun scroll-by (sb dy dx)
|
||||||
"Scroll by DY rows and DX columns. Clamps to valid range."
|
"Scroll by DY rows and DX columns. Clamps to valid range."
|
||||||
(incf (scroll-box-scroll-y sb) dy)
|
(incf (scroll-box-scroll-y sb) dy)
|
||||||
@@ -114,14 +146,13 @@ node resizes.
|
|||||||
(mark-dirty sb))
|
(mark-dirty sb))
|
||||||
#+END_SRC
|
#+END_SRC
|
||||||
|
|
||||||
** ScrollBox: content size estimation
|
** scroll-box-content-height
|
||||||
|
|
||||||
~scroll-box-content-height~ and ~scroll-box-content-width~ calculate
|
~scroll-box-content-height~ calculates the total content height by
|
||||||
the total content size by summing child layout node dimensions. This
|
summing all child heights. Each child reports its height through its
|
||||||
is used by ~clamp-scroll~ and scrollbar rendering.
|
layout node, with a minimum of 1 row (even zero-height children get a
|
||||||
|
floor so they don't collapse the layout). This is used by
|
||||||
For height: sum of all child heights (vertical layout).
|
~clamp-scroll~, scrollbar rendering, and sticky-scroll logic.
|
||||||
For width: max of all child widths (horizontal scroll).
|
|
||||||
|
|
||||||
#+BEGIN_SRC lisp :tangle ../src/components/scrollbox.lisp
|
#+BEGIN_SRC lisp :tangle ../src/components/scrollbox.lisp
|
||||||
(defun scroll-box-content-height (sb)
|
(defun scroll-box-content-height (sb)
|
||||||
@@ -131,7 +162,16 @@ For width: max of all child widths (horizontal scroll).
|
|||||||
(let ((ln (component-layout-node c)))
|
(let ((ln (component-layout-node c)))
|
||||||
(if ln (max 1 (layout-node-height ln)) 1)))
|
(if ln (max 1 (layout-node-height ln)) 1)))
|
||||||
:initial-value 0))
|
:initial-value 0))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** scroll-box-content-width
|
||||||
|
|
||||||
|
~scroll-box-content-width~ calculates the maximum width among children,
|
||||||
|
since horizontal scrolling follows the widest child rather than summing
|
||||||
|
widths. Like the height counterpart, it floors child widths at 1 so
|
||||||
|
empty children don't zero out the measurement.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/components/scrollbox.lisp
|
||||||
(defun scroll-box-content-width (sb)
|
(defun scroll-box-content-width (sb)
|
||||||
"Maximum width among children."
|
"Maximum width among children."
|
||||||
(reduce #'max (scroll-box-children sb)
|
(reduce #'max (scroll-box-children sb)
|
||||||
@@ -141,7 +181,7 @@ For width: max of all child widths (horizontal scroll).
|
|||||||
:initial-value 0))
|
:initial-value 0))
|
||||||
#+END_SRC
|
#+END_SRC
|
||||||
|
|
||||||
** ScrollBox: rendering with viewport culling
|
** Render method with viewport culling
|
||||||
|
|
||||||
~render~ iterates children, computes each child's position within
|
~render~ iterates children, computes each child's position within
|
||||||
the viewport (adjusted for scroll offset), and only renders children
|
the viewport (adjusted for scroll offset), and only renders children
|
||||||
@@ -149,9 +189,14 @@ whose visible area intersects the viewport. This is the core
|
|||||||
optimization — for a terminal with 200 children, only the ~24
|
optimization — for a terminal with 200 children, only the ~24
|
||||||
visible ones are actually drawn.
|
visible ones are actually drawn.
|
||||||
|
|
||||||
~sticky-scroll~ when enabled and the view is at the bottom, keeps
|
The method temporarily offsets each child's layout node by the scroll
|
||||||
it at the bottom after content changes. The flag resets to false
|
amount during rendering, then restores the original position via
|
||||||
when the user manually scrolls up.
|
~unwind-protect~. This avoids mutating the permanent layout state while
|
||||||
|
still making each child's ~render~ method draw at the correct scrolled
|
||||||
|
position.
|
||||||
|
|
||||||
|
After child rendering, it delegates to ~draw-scrollbars~ for the
|
||||||
|
scrollbar overlay.
|
||||||
|
|
||||||
#+BEGIN_SRC lisp :tangle ../src/components/scrollbox.lisp
|
#+BEGIN_SRC lisp :tangle ../src/components/scrollbox.lisp
|
||||||
(defmethod render ((sb scroll-box) backend)
|
(defmethod render ((sb scroll-box) backend)
|
||||||
@@ -187,11 +232,14 @@ the viewport are clipped out."
|
|||||||
(draw-scrollbars sb backend vw vh)))
|
(draw-scrollbars sb backend vw vh)))
|
||||||
#+END_SRC
|
#+END_SRC
|
||||||
|
|
||||||
** ScrollBox: sticky scroll
|
** update-sticky-scroll
|
||||||
|
|
||||||
~sticky-scroll~ checks whether the view is at the bottom. If so,
|
~update-sticky-scroll~ checks whether the view is at the bottom and, if
|
||||||
auto-scrolls to keep the bottommost content visible. The user
|
the ~sticky-scroll-p~ flag is set, auto-scrolls to keep the bottommost
|
||||||
calling ~scroll-by~ with a negative DY resets the sticky flag.
|
content visible. The comparison uses a 1-row tolerance (~(- content-h
|
||||||
|
viewport-h 1)~) so minor content changes don't cause jitter. The sticky
|
||||||
|
flag is reset to nil when the user manually scrolls up (handled by
|
||||||
|
callers of ~scroll-by~).
|
||||||
|
|
||||||
#+BEGIN_SRC lisp :tangle ../src/components/scrollbox.lisp
|
#+BEGIN_SRC lisp :tangle ../src/components/scrollbox.lisp
|
||||||
(defun update-sticky-scroll (sb)
|
(defun update-sticky-scroll (sb)
|
||||||
@@ -205,15 +253,14 @@ calling ~scroll-by~ with a negative DY resets the sticky flag.
|
|||||||
(max 0 (- content-h viewport-h)))))))
|
(max 0 (- content-h viewport-h)))))))
|
||||||
#+END_SRC
|
#+END_SRC
|
||||||
|
|
||||||
** ScrollBox: scrollbar rendering
|
** scrollbar-thumb helper
|
||||||
|
|
||||||
~draw-scrollbars~ renders vertical and horizontal scrollbars as
|
~scrollbar-thumb~ converts a raw scroll position (in lines) into a
|
||||||
single-character-wide bars on the right and bottom edges of the
|
normalized 0.0-to-1.0 ratio representing where the thumb should appear
|
||||||
viewport. The scrollbar thumb position and size reflect the current
|
on the scrollbar track. When content fits entirely within the viewport,
|
||||||
scroll position relative to content size.
|
it returns 0.0 (no scrolling possible). This normalized value is used
|
||||||
|
by ~draw-scrollbars~ to compute the pixel/character position of the
|
||||||
Vertical scrollbar: blocks (~#\\Full~ ~#\\Up~ ~#\\Mid~ ~#\\Down~).
|
thumb.
|
||||||
Horizontal scrollbar: block characters along the bottom.
|
|
||||||
|
|
||||||
#+BEGIN_SRC lisp :tangle ../src/components/scrollbox.lisp
|
#+BEGIN_SRC lisp :tangle ../src/components/scrollbox.lisp
|
||||||
(defun scrollbar-thumb (scroll-pos viewport-size content-size)
|
(defun scrollbar-thumb (scroll-pos viewport-size content-size)
|
||||||
@@ -221,7 +268,22 @@ Horizontal scrollbar: block characters along the bottom.
|
|||||||
(if (> content-size viewport-size)
|
(if (> content-size viewport-size)
|
||||||
(/ (float scroll-pos) (- content-size viewport-size))
|
(/ (float scroll-pos) (- content-size viewport-size))
|
||||||
0.0))
|
0.0))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** draw-scrollbars
|
||||||
|
|
||||||
|
~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.
|
||||||
|
|
||||||
|
The vertical scrollbar uses a filled block (█) for the thumb and a
|
||||||
|
background fill for the track. The horizontal scrollbar is drawn along
|
||||||
|
the bottom edge. Both account for the scrollbox's own position within
|
||||||
|
the layout tree (~ox~, ~oy~) so nested scrollboxes render scrollbars at
|
||||||
|
the correct screen coordinates.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/components/scrollbox.lisp
|
||||||
(defun draw-scrollbars (sb backend viewport-w viewport-h)
|
(defun draw-scrollbars (sb backend viewport-w viewport-h)
|
||||||
"Draw scrollbars if content exceeds viewport."
|
"Draw scrollbars if content exceeds viewport."
|
||||||
(let* ((content-h (scroll-box-content-height sb))
|
(let* ((content-h (scroll-box-content-height sb))
|
||||||
@@ -269,6 +331,17 @@ Two bugs were fixed in the ScrollBox render pipeline:
|
|||||||
|
|
||||||
Test suite for both ScrollBox and TabBar.
|
Test suite for both ScrollBox and TabBar.
|
||||||
|
|
||||||
|
** Package and test infrastructure
|
||||||
|
|
||||||
|
The tests use FiveAM, the Common Lisp testing framework. The package
|
||||||
|
setup pulls in all the systems under test (~cl-tty.backend~,
|
||||||
|
~cl-tty.box~, ~cl-tty.layout~, ~cl-tty.input~, ~cl-tty.container~)
|
||||||
|
along with the base ~:cl~ language and ~:fiveam~ itself.
|
||||||
|
|
||||||
|
~run-tests~ is exported so the test runner script can call it
|
||||||
|
unconditionally; it runs the ~scrollbox-suite~ and prints results via
|
||||||
|
~fiveam:explain!~ before exiting.
|
||||||
|
|
||||||
#+BEGIN_SRC lisp :tangle ../tests/scrollbox-tabbar-tests.lisp
|
#+BEGIN_SRC lisp :tangle ../tests/scrollbox-tabbar-tests.lisp
|
||||||
(defpackage :cl-tty-scrollbox-test
|
(defpackage :cl-tty-scrollbox-test
|
||||||
(:use :cl :fiveam :cl-tty.backend :cl-tty.box :cl-tty.layout :cl-tty.input :cl-tty.container)
|
(:use :cl :fiveam :cl-tty.backend :cl-tty.box :cl-tty.layout :cl-tty.input :cl-tty.container)
|
||||||
@@ -282,9 +355,15 @@ Test suite for both ScrollBox and TabBar.
|
|||||||
(let ((result (run 'scrollbox-suite)))
|
(let ((result (run 'scrollbox-suite)))
|
||||||
(fiveam:explain! result)
|
(fiveam:explain! result)
|
||||||
(uiop:quit 0)))
|
(uiop:quit 0)))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
;; ── ScrollBox Tests ─────────────────────────────────────────────
|
** ScrollBox constructor test
|
||||||
|
|
||||||
|
Confirms a bare ~make-scroll-box~ returns a ~scroll-box~ instance with
|
||||||
|
default scroll offsets of 0 and no children. This establishes that the
|
||||||
|
class definition and constructor are wired up correctly.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../tests/scrollbox-tabbar-tests.lisp
|
||||||
(test scrollbox-creates
|
(test scrollbox-creates
|
||||||
"A ScrollBox can be created with defaults."
|
"A ScrollBox can be created with defaults."
|
||||||
(let ((sb (make-scroll-box)))
|
(let ((sb (make-scroll-box)))
|
||||||
@@ -292,24 +371,59 @@ Test suite for both ScrollBox and TabBar.
|
|||||||
(is (= (scroll-box-scroll-y sb) 0))
|
(is (= (scroll-box-scroll-y sb) 0))
|
||||||
(is (= (scroll-box-scroll-x sb) 0))
|
(is (= (scroll-box-scroll-x sb) 0))
|
||||||
(is-false (scroll-box-children sb))))
|
(is-false (scroll-box-children sb))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** ScrollBox with children test
|
||||||
|
|
||||||
|
Verifies that the ~:children~ initarg is accepted and that
|
||||||
|
~scroll-box-children~ returns the list. A ScrollBox with one child
|
||||||
|
should report length 1.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../tests/scrollbox-tabbar-tests.lisp
|
||||||
(test scrollbox-with-children
|
(test scrollbox-with-children
|
||||||
"A ScrollBox can have children."
|
"A ScrollBox can have children."
|
||||||
(let ((sb (make-scroll-box :children (list (make-text "hello")))))
|
(let ((sb (make-scroll-box :children (list (make-text "hello")))))
|
||||||
(is (= (length (scroll-box-children sb)) 1))))
|
(is (= (length (scroll-box-children sb)) 1))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** ScrollBox scroll-by test
|
||||||
|
|
||||||
|
Exercises ~scroll-by~ with a positive DY offset and asserts the
|
||||||
|
scroll-y is non-negative after the operation. Combined with
|
||||||
|
~scrollbox-scroll-clamp~ below, this covers both the normal and
|
||||||
|
boundary behavior of the scroll mechanic.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../tests/scrollbox-tabbar-tests.lisp
|
||||||
(test scrollbox-scroll-by
|
(test scrollbox-scroll-by
|
||||||
"ScrollBy adjusts offset clamped to valid range."
|
"ScrollBy adjusts offset clamped to valid range."
|
||||||
(let ((sb (make-scroll-box :scroll-y 0)))
|
(let ((sb (make-scroll-box :scroll-y 0)))
|
||||||
(scroll-by sb 5 0)
|
(scroll-by sb 5 0)
|
||||||
(is (>= (scroll-box-scroll-y sb) 0))))
|
(is (>= (scroll-box-scroll-y sb) 0))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** ScrollBox component-children test
|
||||||
|
|
||||||
|
Confirms the component protocol method ~component-children~ returns the
|
||||||
|
same child list that ~scroll-box-children~ does. This ensures the
|
||||||
|
protocol indirection works and that the rendering pipeline will see the
|
||||||
|
correct children.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../tests/scrollbox-tabbar-tests.lisp
|
||||||
(test scrollbox-component-children
|
(test scrollbox-component-children
|
||||||
"Component protocol: children are accessible."
|
"Component protocol: children are accessible."
|
||||||
(let* ((child (make-text "hello"))
|
(let* ((child (make-text "hello"))
|
||||||
(sb (make-scroll-box :children (list child))))
|
(sb (make-scroll-box :children (list child))))
|
||||||
(is (eql (first (component-children sb)) child))))
|
(is (eql (first (component-children sb)) child))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** ScrollBox render no-op test
|
||||||
|
|
||||||
|
Renders a ScrollBox with no children to a string-output-stream backend.
|
||||||
|
The test passes if no errors are signaled — this guards against nil
|
||||||
|
layout nodes or unbound slots causing problems during the render
|
||||||
|
pipeline's initial traversal.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../tests/scrollbox-tabbar-tests.lisp
|
||||||
(test scrollbox-render-noop
|
(test scrollbox-render-noop
|
||||||
"Rendering a ScrollBox with no children does not error."
|
"Rendering a ScrollBox with no children does not error."
|
||||||
(let* ((stream (make-string-output-stream))
|
(let* ((stream (make-string-output-stream))
|
||||||
@@ -317,16 +431,30 @@ Test suite for both ScrollBox and TabBar.
|
|||||||
(sb (make-scroll-box)))
|
(sb (make-scroll-box)))
|
||||||
(render sb backend)
|
(render sb backend)
|
||||||
(is-true t)))
|
(is-true t)))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
;; ── TabBar Tests ────────────────────────────────────────────────
|
** TabBar constructor test
|
||||||
|
|
||||||
|
Confirms a bare ~make-tab-bar~ returns a ~tab-bar~ instance with no
|
||||||
|
active tab and no tabs. This validates the TabBar class definition and
|
||||||
|
constructor.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../tests/scrollbox-tabbar-tests.lisp
|
||||||
(test tabbar-creates
|
(test tabbar-creates
|
||||||
"A TabBar can be created with defaults."
|
"A TabBar can be created with defaults."
|
||||||
(let ((tb (make-tab-bar)))
|
(let ((tb (make-tab-bar)))
|
||||||
(is (typep tb 'tab-bar))
|
(is (typep tb 'tab-bar))
|
||||||
(is-false (tab-bar-active tb))
|
(is-false (tab-bar-active tb))
|
||||||
(is-false (tab-bar-tabs tb))))
|
(is-false (tab-bar-tabs tb))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** TabBar add-tab test
|
||||||
|
|
||||||
|
Tests that ~tab-bar-add~ returns the supplied ID, adds a tab to the
|
||||||
|
internal list, and stores the title correctly. Each tab is stored as a
|
||||||
|
plist, so the test checks both list length and the ~:title~ property.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../tests/scrollbox-tabbar-tests.lisp
|
||||||
(test tabbar-add-tab
|
(test tabbar-add-tab
|
||||||
"Adding a tab returns the id and updates tabs."
|
"Adding a tab returns the id and updates tabs."
|
||||||
(let ((tb (make-tab-bar)))
|
(let ((tb (make-tab-bar)))
|
||||||
@@ -334,7 +462,14 @@ Test suite for both ScrollBox and TabBar.
|
|||||||
(is (eql id :tab1))
|
(is (eql id :tab1))
|
||||||
(is (= (length (tab-bar-tabs tb)) 1))
|
(is (= (length (tab-bar-tabs tb)) 1))
|
||||||
(is (string= (getf (first (tab-bar-tabs tb)) :title) "Tab One")))))
|
(is (string= (getf (first (tab-bar-tabs tb)) :title) "Tab One")))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** TabBar active tab test
|
||||||
|
|
||||||
|
Verifies that ~(setf tab-bar-active)~ correctly selects a tab by ID and
|
||||||
|
that ~tab-bar-active~ returns that ID afterward.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../tests/scrollbox-tabbar-tests.lisp
|
||||||
(test tabbar-active-tab
|
(test tabbar-active-tab
|
||||||
"Setting active tab works."
|
"Setting active tab works."
|
||||||
(let ((tb (make-tab-bar)))
|
(let ((tb (make-tab-bar)))
|
||||||
@@ -342,7 +477,16 @@ Test suite for both ScrollBox and TabBar.
|
|||||||
(tab-bar-add tb :tab2 "Two")
|
(tab-bar-add tb :tab2 "Two")
|
||||||
(setf (tab-bar-active tb) :tab2)
|
(setf (tab-bar-active tb) :tab2)
|
||||||
(is (eql (tab-bar-active tb) :tab2))))
|
(is (eql (tab-bar-active tb) :tab2))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** TabBar render no-op test
|
||||||
|
|
||||||
|
Renders a fully configured TabBar (with tabs and an active selection) to
|
||||||
|
a string-output-stream backend to confirm the render method doesn't
|
||||||
|
error. A TabBar must draw its tab strip without crashing even when
|
||||||
|
disconnected from a real terminal.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../tests/scrollbox-tabbar-tests.lisp
|
||||||
(test tabbar-render-noop
|
(test tabbar-render-noop
|
||||||
"Rendering a TabBar does not error."
|
"Rendering a TabBar does not error."
|
||||||
(let* ((stream (make-string-output-stream))
|
(let* ((stream (make-string-output-stream))
|
||||||
@@ -353,7 +497,17 @@ Test suite for both ScrollBox and TabBar.
|
|||||||
(setf (tab-bar-active tb) :tab1)
|
(setf (tab-bar-active tb) :tab1)
|
||||||
(render tb backend)
|
(render tb backend)
|
||||||
(is-true t)))
|
(is-true t)))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** TabBar next/prev navigation test
|
||||||
|
|
||||||
|
Exercises the full navigation cycle: ~tab-bar-next~ advances through
|
||||||
|
three tabs, wrapping around past the last; ~tab-bar-prev~ goes backward,
|
||||||
|
wrapping around past the first. This is the core keyboard interaction
|
||||||
|
for tabbed UIs and must handle edge cases (empty bar, single tab, etc.)
|
||||||
|
gracefully.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../tests/scrollbox-tabbar-tests.lisp
|
||||||
(test tabbar-next-prev
|
(test tabbar-next-prev
|
||||||
"TabBar next/prev wraps around through tabs."
|
"TabBar next/prev wraps around through tabs."
|
||||||
(let ((tb (make-tab-bar)))
|
(let ((tb (make-tab-bar)))
|
||||||
@@ -369,7 +523,15 @@ Test suite for both ScrollBox and TabBar.
|
|||||||
(is (eql (tab-bar-active tb) :tab1) "wrap around past last")
|
(is (eql (tab-bar-active tb) :tab1) "wrap around past last")
|
||||||
(tab-bar-prev tb)
|
(tab-bar-prev tb)
|
||||||
(is (eql (tab-bar-active tb) :tab3) "wrap around past first")))
|
(is (eql (tab-bar-active tb) :tab3) "wrap around past first")))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** TabBar select test
|
||||||
|
|
||||||
|
~tab-bar-select~ activates a named tab directly (as opposed to relative
|
||||||
|
next/prev navigation). This test verifies that selecting ~:tab2~ from a
|
||||||
|
three-tab bar correctly sets the active tab.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../tests/scrollbox-tabbar-tests.lisp
|
||||||
(test tabbar-select
|
(test tabbar-select
|
||||||
"TabBar select activates the specified tab."
|
"TabBar select activates the specified tab."
|
||||||
(let ((tb (make-tab-bar)))
|
(let ((tb (make-tab-bar)))
|
||||||
@@ -377,7 +539,16 @@ Test suite for both ScrollBox and TabBar.
|
|||||||
(tab-bar-add tb :tab2 "Two")
|
(tab-bar-add tb :tab2 "Two")
|
||||||
(tab-bar-select tb :tab2)
|
(tab-bar-select tb :tab2)
|
||||||
(is (eql (tab-bar-active tb) :tab2))))
|
(is (eql (tab-bar-active tb) :tab2))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** TabBar key handling test
|
||||||
|
|
||||||
|
~tab-bar-handle-key~ maps keyboard events to navigation actions. A
|
||||||
|
~:right~ key event should advance; a ~:left~ key event should retreat.
|
||||||
|
This tests the bridge between the input event system and the TabBar
|
||||||
|
navigation API.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../tests/scrollbox-tabbar-tests.lisp
|
||||||
(test tabbar-handle-key
|
(test tabbar-handle-key
|
||||||
"TabBar handle-key dispatches left/right."
|
"TabBar handle-key dispatches left/right."
|
||||||
(let ((tb (make-tab-bar)))
|
(let ((tb (make-tab-bar)))
|
||||||
@@ -388,7 +559,16 @@ Test suite for both ScrollBox and TabBar.
|
|||||||
(is (eql (tab-bar-active tb) :tab2))
|
(is (eql (tab-bar-active tb) :tab2))
|
||||||
(tab-bar-handle-key tb (make-key-event :key :left))
|
(tab-bar-handle-key tb (make-key-event :key :left))
|
||||||
(is (eql (tab-bar-active tb) :tab1))))
|
(is (eql (tab-bar-active tb) :tab1))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** ScrollBox clamp boundary test
|
||||||
|
|
||||||
|
Directly tests ~clamp-scroll~ by setting scroll offsets to invalid
|
||||||
|
values (negative and extremely large) and confirming they get clamped
|
||||||
|
back to 0. With no children, content size is 0 so the max scroll is
|
||||||
|
also 0 — this exercises the degenerate case.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../tests/scrollbox-tabbar-tests.lisp
|
||||||
(test scrollbox-scroll-clamp
|
(test scrollbox-scroll-clamp
|
||||||
"ScrollBox clamp prevents scrolling past bounds."
|
"ScrollBox clamp prevents scrolling past bounds."
|
||||||
(let ((sb (make-scroll-box :scroll-y 5 :scroll-x 3)))
|
(let ((sb (make-scroll-box :scroll-y 5 :scroll-x 3)))
|
||||||
|
|||||||
371
org/select.org
371
org/select.org
@@ -40,20 +40,39 @@ fallback, and category grouping with dimmed headers.
|
|||||||
|
|
||||||
** Tests
|
** Tests
|
||||||
|
|
||||||
|
*** Test package and suite setup
|
||||||
|
|
||||||
|
The test file uses FiveAM. The ~defpackage~ pulls in all the dependencies needed
|
||||||
|
by the select widget tests — FiveAM itself, the backend/box/layout/input infrastructure,
|
||||||
|
and the ~cl-tty.select~ package under test. ~run-tests~ is the entry point for
|
||||||
|
CI and interactive use.
|
||||||
|
|
||||||
#+BEGIN_SRC lisp :tangle ../tests/select-tests.lisp
|
#+BEGIN_SRC lisp :tangle ../tests/select-tests.lisp
|
||||||
(defpackage :cl-tty-select-test
|
(defpackage :cl-tty-select-test
|
||||||
(:use :cl :fiveam :cl-tty.backend :cl-tty.box :cl-tty.layout :cl-tty.input :cl-tty.select)
|
(:use :cl :fiveam :cl-tty.backend :cl-tty.box :cl-tty.layout :cl-tty.input :cl-tty.select)
|
||||||
(:export #:run-tests))
|
(:export #:run-tests))
|
||||||
(in-package #:cl-tty-select-test)
|
(in-package #:cl-tty-select-test)
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../tests/select-tests.lisp
|
||||||
(def-suite select-suite :description "Select widget tests")
|
(def-suite select-suite :description "Select widget tests")
|
||||||
(in-suite select-suite)
|
(in-suite select-suite)
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../tests/select-tests.lisp
|
||||||
(defun run-tests ()
|
(defun run-tests ()
|
||||||
(let ((result (run 'select-suite)))
|
(let ((result (run 'select-suite)))
|
||||||
(fiveam:explain! result)
|
(fiveam:explain! result)
|
||||||
(uiop:quit 0)))
|
(uiop:quit 0)))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
*** test select-creates
|
||||||
|
|
||||||
|
Verifies that a select widget can be constructed with default values. The
|
||||||
|
~selected-index~ should start at 0, and both ~options~ and ~filter~ should
|
||||||
|
be nil. This establishes the baseline contract for the default constructor.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../tests/select-tests.lisp
|
||||||
(test select-creates
|
(test select-creates
|
||||||
"A Select can be created with defaults."
|
"A Select can be created with defaults."
|
||||||
(let ((sel (make-select)))
|
(let ((sel (make-select)))
|
||||||
@@ -61,13 +80,29 @@ fallback, and category grouping with dimmed headers.
|
|||||||
(is-false (select-options sel))
|
(is-false (select-options sel))
|
||||||
(is-false (select-filter sel))
|
(is-false (select-filter sel))
|
||||||
(is (= (select-selected-index sel) 0))))
|
(is (= (select-selected-index sel) 0))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
*** test select-with-options
|
||||||
|
|
||||||
|
Ensures that passing ~:options~ to ~make-select~ stores them correctly. The
|
||||||
|
length check is the simplest invariant — two options in, two options out.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../tests/select-tests.lisp
|
||||||
(test select-with-options
|
(test select-with-options
|
||||||
"A Select stores options."
|
"A Select stores options."
|
||||||
(let ((sel (make-select :options '((:title "Red" :value :red)
|
(let ((sel (make-select :options '((:title "Red" :value :red)
|
||||||
(:title "Blue" :value :blue)))))
|
(:title "Blue" :value :blue)))))
|
||||||
(is (= (length (select-options sel)) 2))))
|
(is (= (length (select-options sel)) 2))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
*** test select-filtered-exact
|
||||||
|
|
||||||
|
Tests case-insensitive substring filtering: setting filter to ~\"bl\"~ should
|
||||||
|
match \"Blue\" but not \"Red\" or \"Green\". The return value is an alist of
|
||||||
|
~(display-index original-index option)~, so we dig into the third element
|
||||||
|
to check the ~:value~.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../tests/select-tests.lisp
|
||||||
(test select-filtered-exact
|
(test select-filtered-exact
|
||||||
"Filter returns case-insensitive substring matches."
|
"Filter returns case-insensitive substring matches."
|
||||||
(let ((sel (make-select
|
(let ((sel (make-select
|
||||||
@@ -78,7 +113,15 @@ fallback, and category grouping with dimmed headers.
|
|||||||
(let ((filtered (select-filtered-options sel)))
|
(let ((filtered (select-filtered-options sel)))
|
||||||
(is (= (length filtered) 1))
|
(is (= (length filtered) 1))
|
||||||
(is (eql (getf (third (first filtered)) :value) :blue)))))
|
(is (eql (getf (third (first filtered)) :value) :blue)))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
*** test select-filtered-all
|
||||||
|
|
||||||
|
When the filter is nil ~select-filtered-options~ must return every option
|
||||||
|
unchanged. This is the unfiltered/identity case and the most common state
|
||||||
|
when the user hasn't typed anything.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../tests/select-tests.lisp
|
||||||
(test select-filtered-all
|
(test select-filtered-all
|
||||||
"Nil filter returns all options."
|
"Nil filter returns all options."
|
||||||
(let ((sel (make-select
|
(let ((sel (make-select
|
||||||
@@ -86,7 +129,15 @@ fallback, and category grouping with dimmed headers.
|
|||||||
(:title "Blue" :value :blue)))))
|
(:title "Blue" :value :blue)))))
|
||||||
(let ((filtered (select-filtered-options sel)))
|
(let ((filtered (select-filtered-options sel)))
|
||||||
(is (= (length filtered) 2)))))
|
(is (= (length filtered) 2)))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
*** test select-navigation
|
||||||
|
|
||||||
|
Exercises ~select-next~ and ~select-prev~ through a three-item list,
|
||||||
|
confirming that forward and backward movement works and that both directions
|
||||||
|
wrap around at list boundaries.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../tests/select-tests.lisp
|
||||||
(test select-navigation
|
(test select-navigation
|
||||||
"Select-next and select-prev navigate through options."
|
"Select-next and select-prev navigate through options."
|
||||||
(let ((sel (make-select
|
(let ((sel (make-select
|
||||||
@@ -102,7 +153,16 @@ fallback, and category grouping with dimmed headers.
|
|||||||
(is (= (select-selected-index sel) 0) "wraps forward")
|
(is (= (select-selected-index sel) 0) "wraps forward")
|
||||||
(select-prev sel)
|
(select-prev sel)
|
||||||
(is (= (select-selected-index sel) 2) "wraps backward")))
|
(is (= (select-selected-index sel) 2) "wraps backward")))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
*** test select-navigation-skips-categories
|
||||||
|
|
||||||
|
Category headers (options with ~:category t~) should be invisible to
|
||||||
|
navigation — ~select-next~ and ~select-prev~ skip over them. This test
|
||||||
|
sets up a list with two category headers interleaved and verifies they
|
||||||
|
are transparent to movement.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../tests/select-tests.lisp
|
||||||
(test select-navigation-skips-categories
|
(test select-navigation-skips-categories
|
||||||
"Navigation skips category header options."
|
"Navigation skips category header options."
|
||||||
(let ((sel (make-select
|
(let ((sel (make-select
|
||||||
@@ -118,7 +178,15 @@ fallback, and category grouping with dimmed headers.
|
|||||||
(is (= (select-selected-index sel) 2))
|
(is (= (select-selected-index sel) 2))
|
||||||
(select-next sel)
|
(select-next sel)
|
||||||
(is (= (select-selected-index sel) 4) "skipped category header at 3")))
|
(is (= (select-selected-index sel) 4) "skipped category header at 3")))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
*** test select-handle-key
|
||||||
|
|
||||||
|
Validates that ~select-handle-key~ dispatches correctly: Down moves forward,
|
||||||
|
Up moves backward, and Enter invokes the ~on-select~ callback with the
|
||||||
|
currently highlighted option's plist.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../tests/select-tests.lisp
|
||||||
(test select-handle-key
|
(test select-handle-key
|
||||||
"Select handle-key dispatches navigation and selection."
|
"Select handle-key dispatches navigation and selection."
|
||||||
(let* ((result (list nil))
|
(let* ((result (list nil))
|
||||||
@@ -131,7 +199,15 @@ fallback, and category grouping with dimmed headers.
|
|||||||
(is (= (select-selected-index sel) 0))
|
(is (= (select-selected-index sel) 0))
|
||||||
(select-handle-key sel (make-key-event :key :enter))
|
(select-handle-key sel (make-key-event :key :enter))
|
||||||
(is (eql (car result) :a))))
|
(is (eql (car result) :a))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
*** test select-handle-key-ctrl
|
||||||
|
|
||||||
|
Ctrl+N and Ctrl+P are Emacs-compatible alternatives to Down/Up. They must
|
||||||
|
produce identical navigation behavior. This test confirms the control-key
|
||||||
|
dispatch paths.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../tests/select-tests.lisp
|
||||||
(test select-handle-key-ctrl
|
(test select-handle-key-ctrl
|
||||||
"Ctrl+N and Ctrl+P navigate like down/up."
|
"Ctrl+N and Ctrl+P navigate like down/up."
|
||||||
(let ((sel (make-select
|
(let ((sel (make-select
|
||||||
@@ -140,7 +216,15 @@ fallback, and category grouping with dimmed headers.
|
|||||||
(is (= (select-selected-index sel) 1))
|
(is (= (select-selected-index sel) 1))
|
||||||
(select-handle-key sel (make-key-event :key :p :ctrl t))
|
(select-handle-key sel (make-key-event :key :p :ctrl t))
|
||||||
(is (= (select-selected-index sel) 0))))
|
(is (= (select-selected-index sel) 0))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
*** test select-visible-count
|
||||||
|
|
||||||
|
~select-visible-options~ should never return more items than the viewport
|
||||||
|
height. This test creates 20 options, sets the layout height to 5, and
|
||||||
|
asserts the visible subset fits within that constraint.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../tests/select-tests.lisp
|
||||||
(test select-visible-count
|
(test select-visible-count
|
||||||
"Visible options respects viewport height."
|
"Visible options respects viewport height."
|
||||||
(let* ((ln (make-layout-node))
|
(let* ((ln (make-layout-node))
|
||||||
@@ -150,7 +234,15 @@ fallback, and category grouping with dimmed headers.
|
|||||||
(setf (layout-node-height ln) 5)
|
(setf (layout-node-height ln) 5)
|
||||||
(let ((visible (select-visible-options sel)))
|
(let ((visible (select-visible-options sel)))
|
||||||
(is (<= (length visible) 5)))))
|
(is (<= (length visible) 5)))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
*** test select-fuzzy-fallback
|
||||||
|
|
||||||
|
When exact substring matching fails, the filter falls back to character-set
|
||||||
|
Jaccard similarity. ~\"nrd\"~ should match ~\"Nord\"~ because the character
|
||||||
|
overlap (n, o, r, d → 3 of 4) exceeds the 0.3 threshold.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../tests/select-tests.lisp
|
||||||
(test select-fuzzy-fallback
|
(test select-fuzzy-fallback
|
||||||
"Fuzzy filter catches near-misses."
|
"Fuzzy filter catches near-misses."
|
||||||
(let ((sel (make-select
|
(let ((sel (make-select
|
||||||
@@ -167,7 +259,13 @@ fallback, and category grouping with dimmed headers.
|
|||||||
|
|
||||||
** Package
|
** Package
|
||||||
|
|
||||||
#+BEGIN_SRC lisp
|
The ~cl-tty.select~ package depends on the backend, box model, layout,
|
||||||
|
and input subsystems. The exported symbols cover the public API: the
|
||||||
|
~select~ class, constructor, accessors, filtering, navigation, key
|
||||||
|
handling, rendering, and the fuzzy matching predicate (exposed for
|
||||||
|
testing and extensibility).
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/components/select-package.lisp
|
||||||
(defpackage :cl-tty.select
|
(defpackage :cl-tty.select
|
||||||
(:use :cl :cl-tty.backend :cl-tty.box :cl-tty.layout :cl-tty.input)
|
(:use :cl :cl-tty.backend :cl-tty.box :cl-tty.layout :cl-tty.input)
|
||||||
(:export
|
(:export
|
||||||
@@ -185,12 +283,16 @@ fallback, and category grouping with dimmed headers.
|
|||||||
|
|
||||||
** Select class
|
** Select class
|
||||||
|
|
||||||
~select~ inherits from ~dirty-mixin~. Options are stored as a list of
|
*** defclass select
|
||||||
plists. ~selected-index~ tracks the currently highlighted option.
|
|
||||||
~filter~ is a string (or nil for unfiltered). ~on-select~ is a callback
|
|
||||||
receiving the selected option plist.
|
|
||||||
|
|
||||||
#+BEGIN_SRC lisp
|
~select~ inherits from ~dirty-mixin~ so the rendering layer knows when
|
||||||
|
the widget state has changed (after navigation, filter updates, etc.).
|
||||||
|
Options are stored as a list of plists. ~selected-index~ tracks the
|
||||||
|
currently highlighted option. ~filter~ is a string (or nil for
|
||||||
|
unfiltered). ~on-select~ is a callback receiving the selected option
|
||||||
|
plist. ~layout-node~ positions the widget in the window.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/components/select.lisp
|
||||||
(in-package #:cl-tty.select)
|
(in-package #:cl-tty.select)
|
||||||
|
|
||||||
(defclass select (dirty-mixin)
|
(defclass select (dirty-mixin)
|
||||||
@@ -204,7 +306,15 @@ receiving the selected option plist.
|
|||||||
:accessor select-on-select)
|
:accessor select-on-select)
|
||||||
(layout-node :initform (make-layout-node) :initarg :layout-node
|
(layout-node :initform (make-layout-node) :initarg :layout-node
|
||||||
:accessor select-layout-node)))
|
:accessor select-layout-node)))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
*** defun make-select
|
||||||
|
|
||||||
|
A convenience constructor that wraps ~make-instance~ with keyword
|
||||||
|
arguments. Defaults to nil for all optional parameters, matching the
|
||||||
|
~defclass~ initforms.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/components/select.lisp
|
||||||
(defun make-select (&key options filter on-select)
|
(defun make-select (&key options filter on-select)
|
||||||
(make-instance 'select
|
(make-instance 'select
|
||||||
:options (or options nil)
|
:options (or options nil)
|
||||||
@@ -214,16 +324,21 @@ receiving the selected option plist.
|
|||||||
|
|
||||||
** Component protocol
|
** Component protocol
|
||||||
|
|
||||||
~component-layout-node~ returns the layout node so the layout engine
|
*** defmethod component-layout-node
|
||||||
can position the select widget.
|
|
||||||
|
|
||||||
#+BEGIN_SRC lisp
|
The layout engine needs a uniform way to access a component's position.
|
||||||
|
~component-layout-node~ is part of the component protocol; this method
|
||||||
|
for ~select~ simply delegates to the ~select-layout-node~ accessor.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/components/select.lisp
|
||||||
(defmethod component-layout-node ((sel select))
|
(defmethod component-layout-node ((sel select))
|
||||||
(select-layout-node sel))
|
(select-layout-node sel))
|
||||||
#+END_SRC
|
#+END_SRC
|
||||||
|
|
||||||
** Option filtering: substring match
|
** Option filtering: substring match
|
||||||
|
|
||||||
|
*** defun select-filtered-options
|
||||||
|
|
||||||
~select-filtered-options~ returns options whose ~:title~ contains the
|
~select-filtered-options~ returns options whose ~:title~ contains the
|
||||||
filter string (case-insensitive). When ~filter~ is nil, returns all
|
filter string (case-insensitive). When ~filter~ is nil, returns all
|
||||||
options. Category headers are NOT filtered out — they remain in the
|
options. Category headers are NOT filtered out — they remain in the
|
||||||
@@ -232,7 +347,12 @@ list so the user can see category context.
|
|||||||
The function returns an alist of ~(filtered-index original-index option)~
|
The function returns an alist of ~(filtered-index original-index option)~
|
||||||
to preserve the original index for selection tracking.
|
to preserve the original index for selection tracking.
|
||||||
|
|
||||||
#+BEGIN_SRC lisp
|
Internally, the filter first checks for exact substring containment via
|
||||||
|
~search~. If no option matches that way, it falls through to the
|
||||||
|
character-set ~fuzzy-match-p~ predicate. Category headers short-circuit
|
||||||
|
so they always pass through the filter.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/components/select.lisp
|
||||||
(defun select-filtered-options (sel)
|
(defun select-filtered-options (sel)
|
||||||
"Return list of options matching the current filter, in display order.
|
"Return list of options matching the current filter, in display order.
|
||||||
Each item: (display-index original-index option-plist)."
|
Each item: (display-index original-index option-plist)."
|
||||||
@@ -243,27 +363,29 @@ to preserve the original index for selection tracking.
|
|||||||
(let ((lower (string-downcase filter)))
|
(let ((lower (string-downcase filter)))
|
||||||
(remove-if-not
|
(remove-if-not
|
||||||
(lambda (opt)
|
(lambda (opt)
|
||||||
(when (getf opt :category)
|
(or (getf opt :category)
|
||||||
(return-from select-filtered-options all-options))
|
|
||||||
(let ((title (string-downcase (getf opt :title))))
|
(let ((title (string-downcase (getf opt :title))))
|
||||||
(or (search lower title)
|
(or (search lower title)
|
||||||
(fuzzy-match-p lower title))))
|
(fuzzy-match-p lower title)))))
|
||||||
all-options)))))
|
all-options)))))
|
||||||
(loop for opt in filtered
|
(loop for opt in filtered
|
||||||
for i from 0
|
for i from 0
|
||||||
collect (list i (position opt all-options) opt))))
|
collect (list i (position opt all-options) opt))))
|
||||||
#+END_SRC
|
#+END_SRC
|
||||||
|
|
||||||
** Fuzzy matching: trigram Jaccard similarity
|
** Fuzzy matching: character-set Jaccard similarity
|
||||||
|
|
||||||
~trigram-score~ converts a string into a set of 3-character sliding
|
*** defun string-trigrams
|
||||||
window n-grams. ~fuzzy-match-p~ returns T if the Jaccard similarity
|
|
||||||
between the query trigrams and the target trigrams exceeds 0.3.
|
|
||||||
|
|
||||||
Trigrams capture character-level similarity without requiring exact
|
Converts a string into a set of 3-character sliding window n-grams.
|
||||||
substring matches. "nrd" matches "Nord" because both contain ~nor~,
|
Short strings (fewer than 3 characters) return the whole string as a
|
||||||
~ord~ and ~nrd~ contributes ~nrd~ — the overlap is enough to exceed
|
single trigram. Duplicates are removed so the set can be used for
|
||||||
the threshold.
|
Jaccard intersection/union calculations.
|
||||||
|
|
||||||
|
Note: the running tangle does not call this function directly — the
|
||||||
|
simpler character-set ~fuzzy-match-p~ is used instead. Trigram
|
||||||
|
matching is retained here as a documented alternative for future
|
||||||
|
experimentation.
|
||||||
|
|
||||||
#+BEGIN_SRC lisp
|
#+BEGIN_SRC lisp
|
||||||
(defun string-trigrams (str)
|
(defun string-trigrams (str)
|
||||||
@@ -275,7 +397,17 @@ the threshold.
|
|||||||
(loop for i from 0 to (- (length s) 3)
|
(loop for i from 0 to (- (length s) 3)
|
||||||
do (push (subseq s i (+ i 3)) result))
|
do (push (subseq s i (+ i 3)) result))
|
||||||
(delete-duplicates result :test #'string=)))
|
(delete-duplicates result :test #'string=)))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
*** defun trigram-score
|
||||||
|
|
||||||
|
Jaccard similarity of two trigram sets: the size of the intersection
|
||||||
|
divided by the size of the union. A score of 1.0 means identical sets;
|
||||||
|
0.0 means no overlap. This is used by ~fuzzy-match-p~ if trigram mode
|
||||||
|
is enabled (currently unused in the default filter path — see
|
||||||
|
~string-trigrams~).
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp
|
||||||
(defun trigram-score (query target)
|
(defun trigram-score (query target)
|
||||||
"Jaccard similarity of trigram sets: |intersection| / |union|."
|
"Jaccard similarity of trigram sets: |intersection| / |union|."
|
||||||
(let* ((q-trigrams (string-trigrams query))
|
(let* ((q-trigrams (string-trigrams query))
|
||||||
@@ -283,7 +415,16 @@ the threshold.
|
|||||||
(intersection (length (intersection q-trigrams t-trigrams :test #'string=)))
|
(intersection (length (intersection q-trigrams t-trigrams :test #'string=)))
|
||||||
(union (length (union q-trigrams t-trigrams :test #'string=))))
|
(union (length (union q-trigrams t-trigrams :test #'string=))))
|
||||||
(if (zerop union) 0.0 (/ (float intersection) union))))
|
(if (zerop union) 0.0 (/ (float intersection) union))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
*** defun fuzzy-match-p
|
||||||
|
|
||||||
|
Returns T if the Jaccard similarity between the character sets of the
|
||||||
|
query and target exceeds 0.3. The character-set approach is simpler
|
||||||
|
and cheaper than trigrams while still catching common typos and
|
||||||
|
near-misses like ~\"nrd\"~ for ~\"Nord\"~.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/components/select.lisp
|
||||||
(defun fuzzy-match-p (query target)
|
(defun fuzzy-match-p (query target)
|
||||||
"T if character-set Jaccard similarity exceeds threshold (0.3)."
|
"T if character-set Jaccard similarity exceeds threshold (0.3)."
|
||||||
(let* ((q-chars (remove-duplicates (coerce (string-downcase query) 'list)))
|
(let* ((q-chars (remove-duplicates (coerce (string-downcase query) 'list)))
|
||||||
@@ -295,12 +436,14 @@ the threshold.
|
|||||||
|
|
||||||
** Navigation
|
** Navigation
|
||||||
|
|
||||||
~select-next~ and ~select-prev~ move the selection forward/backward
|
*** defun select-clamp-index
|
||||||
through the filtered options list. They skip category headers (options
|
|
||||||
with ~:category t~). The selection wraps at list boundaries.
|
|
||||||
~select-clamp-index~ ensures the index is valid after filtering changes.
|
|
||||||
|
|
||||||
#+BEGIN_SRC lisp
|
After the filter changes (user types or clears input), the selected
|
||||||
|
index may point beyond the filtered list. ~select-clamp-index~ ensures
|
||||||
|
the index stays within valid bounds. If the list is empty the index
|
||||||
|
resets to 0.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/components/select.lisp
|
||||||
(defun select-clamp-index (sel)
|
(defun select-clamp-index (sel)
|
||||||
"Ensure selected-index is valid. Wraps if empty."
|
"Ensure selected-index is valid. Wraps if empty."
|
||||||
(let* ((filtered (select-filtered-options sel))
|
(let* ((filtered (select-filtered-options sel))
|
||||||
@@ -309,7 +452,16 @@ with ~:category t~). The selection wraps at list boundaries.
|
|||||||
(setf (select-selected-index sel) 0)
|
(setf (select-selected-index sel) 0)
|
||||||
(setf (select-selected-index sel)
|
(setf (select-selected-index sel)
|
||||||
(max 0 (min (select-selected-index sel) (1- count)))))))
|
(max 0 (min (select-selected-index sel) (1- count)))))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
*** defun select-next
|
||||||
|
|
||||||
|
Moves the selection forward to the next non-category option. Iterates
|
||||||
|
through the filtered list starting from the current index, wrapping
|
||||||
|
around at the end. Each candidate is checked for ~:category t~ and
|
||||||
|
skipped. Marks the widget dirty so the render pass picks up the change.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/components/select.lisp
|
||||||
(defun select-next (sel)
|
(defun select-next (sel)
|
||||||
"Move selection to next non-category option. Wraps at end."
|
"Move selection to next non-category option. Wraps at end."
|
||||||
(let* ((filtered (select-filtered-options sel))
|
(let* ((filtered (select-filtered-options sel))
|
||||||
@@ -323,7 +475,15 @@ with ~:category t~). The selection wraps at list boundaries.
|
|||||||
do (setf (select-selected-index sel) idx)
|
do (setf (select-selected-index sel) idx)
|
||||||
(mark-dirty sel)
|
(mark-dirty sel)
|
||||||
(return)))))
|
(return)))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
*** defun select-prev
|
||||||
|
|
||||||
|
Moves the selection backward to the previous non-category option.
|
||||||
|
Mirrors ~select-next~ but decrements the index (with modular arithmetic
|
||||||
|
for wrap-around). Category headers are skipped identically.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/components/select.lisp
|
||||||
(defun select-prev (sel)
|
(defun select-prev (sel)
|
||||||
"Move selection to previous non-category option. Wraps at start."
|
"Move selection to previous non-category option. Wraps at start."
|
||||||
(let* ((filtered (select-filtered-options sel))
|
(let* ((filtered (select-filtered-options sel))
|
||||||
@@ -341,15 +501,18 @@ with ~:category t~). The selection wraps at list boundaries.
|
|||||||
|
|
||||||
** Key event handler
|
** Key event handler
|
||||||
|
|
||||||
~select-handle-key~ dispatches keyboard events:
|
*** defun select-handle-key
|
||||||
- Down, Ctrl+N → select-next
|
|
||||||
- Up, Ctrl+P → select-prev
|
|
||||||
- Enter → on-select callback with the selected option
|
|
||||||
- Esc → return NIL (caller can dismiss)
|
|
||||||
|
|
||||||
Returns T if the key was handled, NIL otherwise.
|
Dispatches keyboard events:
|
||||||
|
- Down, Ctrl+N → ~select-next~
|
||||||
|
- Up, Ctrl+P → ~select-prev~
|
||||||
|
- Enter → ~on-select~ callback with the selected option
|
||||||
|
- Esc → return NIL (caller can dismiss the widget)
|
||||||
|
|
||||||
#+BEGIN_SRC lisp
|
Returns T if the key was handled (consumed), NIL otherwise so the
|
||||||
|
caller knows not to propagate the event further.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/components/select.lisp
|
||||||
(defun select-handle-key (sel event)
|
(defun select-handle-key (sel event)
|
||||||
"Handle a key-event. Returns T if handled."
|
"Handle a key-event. Returns T if handled."
|
||||||
(let ((key (key-event-key event))
|
(let ((key (key-event-key event))
|
||||||
@@ -374,11 +537,15 @@ Returns T if the key was handled, NIL otherwise.
|
|||||||
|
|
||||||
** Visible options (viewport culling)
|
** Visible options (viewport culling)
|
||||||
|
|
||||||
~select-visible-options~ returns only the filtered options that fit
|
*** defun select-visible-options
|
||||||
within the widget's available height. Each option occupies 1 row.
|
|
||||||
This prevents rendering hundreds of items when the viewport shows 10.
|
|
||||||
|
|
||||||
#+BEGIN_SRC lisp
|
Returns only the filtered options that fit within the widget's
|
||||||
|
available height. Each option occupies 1 row. This prevents rendering
|
||||||
|
hundreds of items when the viewport shows only 10. The window is
|
||||||
|
centered around the currently selected index so the user always sees
|
||||||
|
context around their cursor.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/components/select.lisp
|
||||||
(defun select-visible-options (sel)
|
(defun select-visible-options (sel)
|
||||||
"Return filtered options that fit within the viewport."
|
"Return filtered options that fit within the viewport."
|
||||||
(let* ((ln (select-layout-node sel))
|
(let* ((ln (select-layout-node sel))
|
||||||
@@ -394,12 +561,15 @@ This prevents rendering hundreds of items when the viewport shows 10.
|
|||||||
|
|
||||||
** Rendering
|
** Rendering
|
||||||
|
|
||||||
~render~ draws each visible option on its own line. The selected
|
*** defmethod render
|
||||||
option is highlighted with ~:accent~ foreground and ~:background-element~
|
|
||||||
background. Category headers are rendered dimmed (~:text-muted~) and
|
|
||||||
not selectable (visually distinct).
|
|
||||||
|
|
||||||
#+BEGIN_SRC lisp
|
Draws each visible option on its own line. The selected option is
|
||||||
|
highlighted with ~:accent~ foreground and ~:background-element~
|
||||||
|
background. Category headers are rendered dimmed (~:text-muted~) and
|
||||||
|
visually distinct from selectable items. Long titles are truncated with
|
||||||
|
an ellipsis character to fit the viewport width.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/components/select.lisp
|
||||||
(defmethod render ((sel select) backend)
|
(defmethod render ((sel select) backend)
|
||||||
(let* ((ln (select-layout-node sel))
|
(let* ((ln (select-layout-node sel))
|
||||||
(x (if ln (layout-node-x ln) 0))
|
(x (if ln (layout-node-x ln) 0))
|
||||||
@@ -427,120 +597,3 @@ not selectable (visually distinct).
|
|||||||
(incf y 1)))
|
(incf y 1)))
|
||||||
(values)))
|
(values)))
|
||||||
#+END_SRC
|
#+END_SRC
|
||||||
|
|
||||||
** Combined tangle block
|
|
||||||
|
|
||||||
#+BEGIN_SRC lisp :tangle ../src/components/select.lisp
|
|
||||||
(in-package #:cl-tty.select)
|
|
||||||
|
|
||||||
(defclass select (dirty-mixin)
|
|
||||||
((options :initform nil :initarg :options :accessor select-options :type list)
|
|
||||||
(filter :initform nil :initarg :filter :accessor select-filter :type (or string null))
|
|
||||||
(selected-index :initform 0 :initarg :selected-index :accessor select-selected-index :type fixnum)
|
|
||||||
(on-select :initform nil :initarg :on-select :accessor select-on-select)
|
|
||||||
(layout-node :initform (make-layout-node) :initarg :layout-node :accessor select-layout-node)))
|
|
||||||
|
|
||||||
(defun make-select (&key options filter on-select)
|
|
||||||
(make-instance 'select :options (or options nil) :filter filter :on-select on-select))
|
|
||||||
|
|
||||||
(defmethod component-layout-node ((sel select)) (select-layout-node sel))
|
|
||||||
|
|
||||||
(defun select-filtered-options (sel)
|
|
||||||
(let* ((filter (select-filter sel)) (all-options (select-options sel))
|
|
||||||
(filtered (if (null filter) all-options
|
|
||||||
(let ((lower (string-downcase filter)))
|
|
||||||
(remove-if-not
|
|
||||||
(lambda (opt)
|
|
||||||
(or (getf opt :category)
|
|
||||||
(let ((title (string-downcase (getf opt :title))))
|
|
||||||
(or (search lower title) (fuzzy-match-p lower title)))))
|
|
||||||
all-options)))))
|
|
||||||
(loop for opt in filtered for i from 0
|
|
||||||
collect (list i (position opt all-options) opt))))
|
|
||||||
|
|
||||||
(defun fuzzy-match-p (query target)
|
|
||||||
(let* ((q (remove-duplicates (coerce (string-downcase query) 'list)))
|
|
||||||
(tg (remove-duplicates (coerce (string-downcase target) 'list)))
|
|
||||||
(intersection (length (intersection q tg)))
|
|
||||||
(union (length (union q tg))))
|
|
||||||
(if (zerop union) nil (> (/ (float intersection) union) 0.3))))
|
|
||||||
|
|
||||||
(defun select-clamp-index (sel)
|
|
||||||
(let* ((filtered (select-filtered-options sel)) (count (length filtered)))
|
|
||||||
(if (zerop count) (setf (select-selected-index sel) 0)
|
|
||||||
(setf (select-selected-index sel) (max 0 (min (select-selected-index sel) (1- count)))))))
|
|
||||||
|
|
||||||
(defun select-next (sel)
|
|
||||||
(let* ((filtered (select-filtered-options sel)) (count (length filtered))
|
|
||||||
(current (select-selected-index sel)))
|
|
||||||
(when (plusp count)
|
|
||||||
(loop for i from 1 below count
|
|
||||||
for idx = (mod (+ current i) count)
|
|
||||||
for opt = (third (nth idx filtered))
|
|
||||||
when (not (getf opt :category))
|
|
||||||
do (setf (select-selected-index sel) idx) (mark-dirty sel) (return)))))
|
|
||||||
|
|
||||||
(defun select-prev (sel)
|
|
||||||
(let* ((filtered (select-filtered-options sel)) (count (length filtered))
|
|
||||||
(current (select-selected-index sel)))
|
|
||||||
(when (plusp count)
|
|
||||||
(loop for i from 1 below count
|
|
||||||
for idx = (mod (- current i) count)
|
|
||||||
for opt = (third (nth idx filtered))
|
|
||||||
when (not (getf opt :category))
|
|
||||||
do (setf (select-selected-index sel) idx) (mark-dirty sel) (return)))))
|
|
||||||
|
|
||||||
(defun select-handle-key (sel event)
|
|
||||||
(let ((key (key-event-key event)) (ctrl (key-event-ctrl event)))
|
|
||||||
(cond
|
|
||||||
((or (eql key :down) (and ctrl (eql key :n))) (select-next sel) t)
|
|
||||||
((or (eql key :up) (and ctrl (eql key :p))) (select-prev sel) t)
|
|
||||||
((eql key :enter)
|
|
||||||
(let* ((filtered (select-filtered-options sel)) (idx (select-selected-index sel))
|
|
||||||
(item (when (< idx (length filtered)) (third (nth idx filtered)))))
|
|
||||||
(when item (let ((cb (select-on-select sel))) (when cb (funcall cb item)))) t))
|
|
||||||
((eql key :escape) nil) (t nil))))
|
|
||||||
|
|
||||||
(defun select-visible-options (sel)
|
|
||||||
(let* ((ln (select-layout-node sel)) (height (if ln (layout-node-height ln) 80))
|
|
||||||
(filtered (select-filtered-options sel)) (sel-idx (select-selected-index sel))
|
|
||||||
(half (floor (1- height) 2)) (start (max 0 (- sel-idx half)))
|
|
||||||
(end (min (length filtered) (+ start height))))
|
|
||||||
(subseq filtered start end)))
|
|
||||||
|
|
||||||
(defmethod render ((sel select) backend)
|
|
||||||
(let* ((ln (select-layout-node sel))
|
|
||||||
(x (if ln (layout-node-x ln) 0))
|
|
||||||
(y (if ln (layout-node-y ln) 0))
|
|
||||||
(w (if ln (layout-node-width ln) 80))
|
|
||||||
(visible (select-visible-options sel)) (sel-idx (select-selected-index sel)))
|
|
||||||
(dolist (item visible)
|
|
||||||
(let* ((display-idx (first item)) (option (third item))
|
|
||||||
(title (getf option :title)) (cat (getf option :category))
|
|
||||||
(selected (eql display-idx sel-idx))
|
|
||||||
(display (if (> (length title) (1- w))
|
|
||||||
(concatenate 'string (subseq title 0 (1- w)) "…") title)))
|
|
||||||
(cond (cat (draw-text backend x y display :text-muted nil))
|
|
||||||
(selected
|
|
||||||
(draw-rect backend x y w 1 :bg :accent)
|
|
||||||
(draw-text backend x y display :background :accent))
|
|
||||||
(t (draw-text backend x y display nil nil)))
|
|
||||||
(incf y 1)))
|
|
||||||
(values)))
|
|
||||||
#+END_SRC
|
|
||||||
|
|
||||||
#+BEGIN_SRC lisp :tangle ../src/components/select-package.lisp
|
|
||||||
(defpackage :cl-tty.select
|
|
||||||
(:use :cl :cl-tty.backend :cl-tty.box :cl-tty.layout :cl-tty.input)
|
|
||||||
(:export
|
|
||||||
#:select #:make-select
|
|
||||||
#:select-options #:select-filter
|
|
||||||
#:select-selected-index #:select-on-select
|
|
||||||
#:select-layout-node
|
|
||||||
#:select-filtered-options
|
|
||||||
#:select-next #:select-prev
|
|
||||||
#:select-visible-options
|
|
||||||
#:select-handle-key
|
|
||||||
#:render
|
|
||||||
#:fuzzy-match-p))
|
|
||||||
#+END_SRC
|
|
||||||
|
|||||||
82
org/slot.org
82
org/slot.org
@@ -25,6 +25,9 @@ Slot modes:
|
|||||||
|
|
||||||
** Implementation
|
** Implementation
|
||||||
|
|
||||||
|
The package provides the public API and exports all slot system symbols.
|
||||||
|
Clients :use this package or refer to symbols qualified.
|
||||||
|
|
||||||
#+BEGIN_SRC lisp :tangle ../src/components/slot-package.lisp :noweb no
|
#+BEGIN_SRC lisp :tangle ../src/components/slot-package.lisp :noweb no
|
||||||
(defpackage :cl-tty.slot
|
(defpackage :cl-tty.slot
|
||||||
(:use :cl)
|
(:use :cl)
|
||||||
@@ -37,12 +40,30 @@ Slot modes:
|
|||||||
#:*slots*))
|
#:*slots*))
|
||||||
#+END_SRC
|
#+END_SRC
|
||||||
|
|
||||||
|
*** Slot Storage: *slots*
|
||||||
|
|
||||||
|
The central registry is a hash table keyed by slot name (strings, for
|
||||||
|
case-insensitive lookup via ~equal~). Each value is a list of
|
||||||
|
~(order . render-fn)~ cons cells, sorted by order on insertion. The
|
||||||
|
~:test #'equal~ ensures that ~:sidebar~ and ~\"sidebar\"~ map to the
|
||||||
|
same key.
|
||||||
|
|
||||||
#+BEGIN_SRC lisp :tangle ../src/components/slot.lisp :noweb no
|
#+BEGIN_SRC lisp :tangle ../src/components/slot.lisp :noweb no
|
||||||
(in-package :cl-tty.slot)
|
(in-package :cl-tty.slot)
|
||||||
|
|
||||||
(defvar *slots* (make-hash-table :test #'equal)
|
(defvar *slots* (make-hash-table :test #'equal)
|
||||||
"Hash table mapping slot name (string) -> list of (order . render-fn) pairs.")
|
"Hash table mapping slot name (string) -> list of (order . render-fn) pairs.")
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
*** defslot: Register a Render Function
|
||||||
|
|
||||||
|
~defslot~ inserts a new ~(order . render-fn)~ entry into the slot's
|
||||||
|
entry list. If the slot has no previous entries a fresh list is
|
||||||
|
created; otherwise the new entry is consed onto the existing list and
|
||||||
|
the whole list is sorted by ~order~ ascending. The ~render-fn~ itself
|
||||||
|
is returned so callers can use it inline or store it.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/components/slot.lisp :noweb no
|
||||||
(defun defslot (name &key (order 0) render-fn)
|
(defun defslot (name &key (order 0) render-fn)
|
||||||
(let* ((key (string name))
|
(let* ((key (string name))
|
||||||
(entries (gethash key *slots*)))
|
(entries (gethash key *slots*)))
|
||||||
@@ -53,15 +74,16 @@ Slot modes:
|
|||||||
render-fn)
|
render-fn)
|
||||||
#+END_SRC
|
#+END_SRC
|
||||||
|
|
||||||
*** Bug Fixes (v1.0.0): nil handler guard in slot-render
|
*** slot-render: Invoke All Render Functions
|
||||||
|
|
||||||
~slot-render~ called ~(apply (cdr entry) args)~ unconditionally, but
|
Iterates over the slot's registered entries and calls each non-nil
|
||||||
~defslot~ stores ~(order . render-fn)~ pairs where ~render-fn~ can be
|
render function with the supplied ~args~. Entries with a nil handler
|
||||||
~nil~ (if called without ~:render-fn~). This caused a type error when
|
are silently skipped — this is important because ~defslot~ accepts an
|
||||||
~apply~ received ~nil~ as the function argument.
|
optional ~:render-fn~ keyword that defaults to ~nil~, and we must
|
||||||
|
guard against calling ~apply~ on nil (a type error in Common Lisp).
|
||||||
|
|
||||||
Fix: Check ~(when fn)~ before calling ~apply~. Entries with a nil
|
Returns a list of results, one per non-nil render function. Returns
|
||||||
handler are silently skipped.
|
~nil~ (via ~when~) if the slot has no registrations at all.
|
||||||
|
|
||||||
#+BEGIN_SRC lisp :tangle ../src/components/slot.lisp :noweb no
|
#+BEGIN_SRC lisp :tangle ../src/components/slot.lisp :noweb no
|
||||||
(defun slot-render (slot-name &rest args)
|
(defun slot-render (slot-name &rest args)
|
||||||
@@ -71,39 +93,85 @@ handler are silently skipped.
|
|||||||
(let ((fn (cdr entry)))
|
(let ((fn (cdr entry)))
|
||||||
(when fn (apply fn args))))
|
(when fn (apply fn args))))
|
||||||
entries))))
|
entries))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
*** slot-p: Check Slot Existence
|
||||||
|
|
||||||
|
Uses ~nth-value 1~ of ~gethash~ which returns ~t~ if the key is
|
||||||
|
present (even if the value is ~nil~) or ~nil~ if absent. This is the
|
||||||
|
canonical Common Lisp idiom for testing hash-table membership.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/components/slot.lisp :noweb no
|
||||||
(defun slot-p (slot-name)
|
(defun slot-p (slot-name)
|
||||||
(nth-value 1 (gethash (string slot-name) *slots*)))
|
(nth-value 1 (gethash (string slot-name) *slots*)))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
*** clear-slot: Remove All Registrations
|
||||||
|
|
||||||
|
Calls ~remhash~ to delete the slot's entry from the hash table
|
||||||
|
entirely. After this call ~slot-p~ returns false and ~slot-render~
|
||||||
|
returns nil for the given slot name.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/components/slot.lisp :noweb no
|
||||||
(defun clear-slot (slot-name)
|
(defun clear-slot (slot-name)
|
||||||
(remhash (string slot-name) *slots*))
|
(remhash (string slot-name) *slots*))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
*** list-slots: Enumerate Registered Slots
|
||||||
|
|
||||||
|
Iterates over all hash keys in ~*slots*~ and returns them as a list.
|
||||||
|
Only slots that have been registered (i.e. have at least one entry)
|
||||||
|
appear in the result.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/components/slot.lisp :noweb no
|
||||||
(defun list-slots ()
|
(defun list-slots ()
|
||||||
(loop for key being the hash-keys of *slots* collect key))
|
(loop for key being the hash-keys of *slots* collect key))
|
||||||
#+END_SRC
|
#+END_SRC
|
||||||
|
|
||||||
|
*** Tests
|
||||||
|
|
||||||
|
The test suite uses FiveAM and exercises each public function.
|
||||||
|
|
||||||
|
**** Test Package and Suite
|
||||||
|
|
||||||
#+BEGIN_SRC lisp :tangle ../tests/slot-tests.lisp :noweb no
|
#+BEGIN_SRC lisp :tangle ../tests/slot-tests.lisp :noweb no
|
||||||
(defpackage :cl-tty-slot-test (:use :cl :cl-tty.slot :fiveam))
|
(defpackage :cl-tty-slot-test (:use :cl :cl-tty.slot :fiveam))
|
||||||
(in-package :cl-tty-slot-test)
|
(in-package :cl-tty-slot-test)
|
||||||
|
|
||||||
(def-suite slot-suite :description "Slot system tests")
|
(def-suite slot-suite :description "Slot system tests")
|
||||||
(in-suite slot-suite)
|
(in-suite slot-suite)
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
**** defslot-register: Registering a slot makes it visible
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../tests/slot-tests.lisp :noweb no
|
||||||
(def-test defslot-register ()
|
(def-test defslot-register ()
|
||||||
(clear-slot :test-slot)
|
(clear-slot :test-slot)
|
||||||
(defslot :test-slot :order 1 :render-fn (lambda () "hello"))
|
(defslot :test-slot :order 1 :render-fn (lambda () "hello"))
|
||||||
(is-true (slot-p :test-slot)))
|
(is-true (slot-p :test-slot)))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
**** slot-render-calls: Registered functions are called in order
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../tests/slot-tests.lisp :noweb no
|
||||||
(def-test slot-render-calls ()
|
(def-test slot-render-calls ()
|
||||||
(clear-slot :test-slot)
|
(clear-slot :test-slot)
|
||||||
(defslot :test-slot :order 1 :render-fn (lambda () "a"))
|
(defslot :test-slot :order 1 :render-fn (lambda () "a"))
|
||||||
(defslot :test-slot :order 2 :render-fn (lambda () "b"))
|
(defslot :test-slot :order 2 :render-fn (lambda () "b"))
|
||||||
(is (equal '("a" "b") (slot-render :test-slot))))
|
(is (equal '("a" "b") (slot-render :test-slot))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
**** slot-render-empty: Unregistered slot returns nil
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../tests/slot-tests.lisp :noweb no
|
||||||
(def-test slot-render-empty ()
|
(def-test slot-render-empty ()
|
||||||
(clear-slot :ghost)
|
(clear-slot :ghost)
|
||||||
(is-false (slot-render :ghost)))
|
(is-false (slot-render :ghost)))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
**** clear-slot-removes: Clearing a slot makes it absent
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../tests/slot-tests.lisp :noweb no
|
||||||
(def-test clear-slot-removes ()
|
(def-test clear-slot-removes ()
|
||||||
(clear-slot :test-slot)
|
(clear-slot :test-slot)
|
||||||
(defslot :test-slot :order 1 :render-fn (lambda () "x"))
|
(defslot :test-slot :order 1 :render-fn (lambda () "x"))
|
||||||
|
|||||||
104
org/tabbar.org
104
org/tabbar.org
@@ -25,15 +25,30 @@ pipeline and layout engine.
|
|||||||
|
|
||||||
* Implementation
|
* Implementation
|
||||||
|
|
||||||
** TabBar class
|
** Package declaration
|
||||||
|
|
||||||
~tab-bar~ stores a list of tab plists ~((:id :tab1 :title "One") ...)~
|
All TabBar code lives in the ~cl-tty.container~ package alongside the
|
||||||
and the currently active tab id. ~tab-bar-add~ creates a new tab with
|
other container components (scrollbox, box, slot, etc.). This keeps
|
||||||
the given id and title, returns the id.
|
the symbol namespace clean and avoids accidental collisions with
|
||||||
|
user-level code.
|
||||||
|
|
||||||
#+BEGIN_SRC lisp :tangle ../src/components/tabbar.lisp
|
#+BEGIN_SRC lisp :tangle ../src/components/tabbar.lisp
|
||||||
(in-package #:cl-tty.container)
|
(in-package #:cl-tty.container)
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** TabBar class
|
||||||
|
|
||||||
|
~tab-bar~ stores a list of tab plists ~((:id :tab1 :title "One") ...)~
|
||||||
|
and the currently active tab id. It inherits from ~dirty-mixin~ so that
|
||||||
|
any mutation (adding a tab, switching tabs) automatically marks the
|
||||||
|
component for re-render. A layout node holds its geometry; the
|
||||||
|
~focusable~ slot allows the keyboard navigation system to discover it.
|
||||||
|
|
||||||
|
The ~tabs~ slot is a simple plist list rather than a hash table or
|
||||||
|
alist because the total number of tabs in a UI is typically small
|
||||||
|
(< 20) and we need ordered iteration for rendering.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/components/tabbar.lisp
|
||||||
(defclass tab-bar (dirty-mixin)
|
(defclass tab-bar (dirty-mixin)
|
||||||
((tabs :initform nil :initarg :tabs
|
((tabs :initform nil :initarg :tabs
|
||||||
:accessor tab-bar-tabs :type list)
|
:accessor tab-bar-tabs :type list)
|
||||||
@@ -41,10 +56,30 @@ the given id and title, returns the id.
|
|||||||
:accessor tab-bar-active)
|
:accessor tab-bar-active)
|
||||||
(layout-node :initform (make-layout-node) :accessor tab-bar-layout-node)
|
(layout-node :initform (make-layout-node) :accessor tab-bar-layout-node)
|
||||||
(focusable :initform t :accessor tab-bar-focusable)))
|
(focusable :initform t :accessor tab-bar-focusable)))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** make-tab-bar constructor
|
||||||
|
|
||||||
|
Convenience constructor that forwards keyword arguments to
|
||||||
|
~make-instance~. Using a dedicated function instead of inlining
|
||||||
|
~make-instance~ everywhere gives us a single place to add
|
||||||
|
defaulting, validation, or initialization hooks in the future.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/components/tabbar.lisp
|
||||||
(defun make-tab-bar (&key tabs active)
|
(defun make-tab-bar (&key tabs active)
|
||||||
(make-instance 'tab-bar :tabs (or tabs nil) :active active))
|
(make-instance 'tab-bar :tabs (or tabs nil) :active active))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** tab-bar-add: adding tabs
|
||||||
|
|
||||||
|
~tab-bar-add~ appends a new tab plist to the end of the tab list.
|
||||||
|
The callers supply both an ~id~ (for programmatic selection) and a
|
||||||
|
~title~ (for display). If no tab is currently active, the newly added
|
||||||
|
tab becomes active automatically — this ensures there is always a
|
||||||
|
sensible default when the first tab is created. Returns the ~id~ so
|
||||||
|
callers can chain or store it.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/components/tabbar.lisp
|
||||||
(defun tab-bar-add (tb id title)
|
(defun tab-bar-add (tb id title)
|
||||||
"Add a tab with ID and TITLE. Sets as active if first tab."
|
"Add a tab with ID and TITLE. Sets as active if first tab."
|
||||||
(setf (tab-bar-tabs tb)
|
(setf (tab-bar-tabs tb)
|
||||||
@@ -54,18 +89,26 @@ the given id and title, returns the id.
|
|||||||
id)
|
id)
|
||||||
#+END_SRC
|
#+END_SRC
|
||||||
|
|
||||||
** TabBar: component protocol
|
** component-layout-node protocol
|
||||||
|
|
||||||
|
Returns the layout node so the layout engine can position and size
|
||||||
|
the tab bar within its parent. Every component that participates in
|
||||||
|
automatic layout must implement this method.
|
||||||
|
|
||||||
#+BEGIN_SRC lisp :tangle ../src/components/tabbar.lisp
|
#+BEGIN_SRC lisp :tangle ../src/components/tabbar.lisp
|
||||||
(defmethod component-layout-node ((tb tab-bar))
|
(defmethod component-layout-node ((tb tab-bar))
|
||||||
(tab-bar-layout-node tb))
|
(tab-bar-layout-node tb))
|
||||||
#+END_SRC
|
#+END_SRC
|
||||||
|
|
||||||
** TabBar: navigation
|
** tab-bar-next: cycling forward
|
||||||
|
|
||||||
~tab-bar-next~ and ~tab-bar-prev~ cycle through tabs. ~tab-bar-select~
|
~tab-bar-next~ moves the active cursor to the next tab in the list,
|
||||||
activates a tab by id. ~tab-bar-handle-key~ dispatches key events
|
wrapping around from the last tab to the first (~mod~ arithmetic).
|
||||||
(Left/Right to navigate, optional Enter to select).
|
It calls ~mark-dirty~ so the rendering pass picks up the change.
|
||||||
|
|
||||||
|
The lookup strategy — mapcar ids, position, mod — is O(n) but
|
||||||
|
acceptable since tab lists are small. A hash-based index would be
|
||||||
|
premature optimization at this scale.
|
||||||
|
|
||||||
#+BEGIN_SRC lisp :tangle ../src/components/tabbar.lisp
|
#+BEGIN_SRC lisp :tangle ../src/components/tabbar.lisp
|
||||||
(defun tab-bar-next (tb)
|
(defun tab-bar-next (tb)
|
||||||
@@ -78,7 +121,16 @@ activates a tab by id. ~tab-bar-handle-key~ dispatches key events
|
|||||||
(let ((next (nth (mod (1+ pos) (length ids)) ids)))
|
(let ((next (nth (mod (1+ pos) (length ids)) ids)))
|
||||||
(setf (tab-bar-active tb) next)
|
(setf (tab-bar-active tb) next)
|
||||||
(mark-dirty tb)))))
|
(mark-dirty tb)))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** tab-bar-prev: cycling backward
|
||||||
|
|
||||||
|
Mirror of ~tab-bar-next~; decrements the position index instead of
|
||||||
|
incrementing it. ~mod~ handles negative wrap-around correctly in
|
||||||
|
Common Lisp (returns a non-negative remainder), so ~(mod (1- 0) 3)~
|
||||||
|
produces 2 rather than −1.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/components/tabbar.lisp
|
||||||
(defun tab-bar-prev (tb)
|
(defun tab-bar-prev (tb)
|
||||||
"Move to previous tab."
|
"Move to previous tab."
|
||||||
(let* ((tabs (tab-bar-tabs tb))
|
(let* ((tabs (tab-bar-tabs tb))
|
||||||
@@ -89,18 +141,29 @@ activates a tab by id. ~tab-bar-handle-key~ dispatches key events
|
|||||||
(let ((prev (nth (mod (1- pos) (length ids)) ids)))
|
(let ((prev (nth (mod (1- pos) (length ids)) ids)))
|
||||||
(setf (tab-bar-active tb) prev)
|
(setf (tab-bar-active tb) prev)
|
||||||
(mark-dirty tb)))))
|
(mark-dirty tb)))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** tab-bar-select: direct tab selection
|
||||||
|
|
||||||
|
~tab-bar-select~ sets the active tab directly by id, bypassing the
|
||||||
|
cyclic navigation. This is used when a user clicks a tab (via mouse
|
||||||
|
binding), when a programmatic action needs to switch views, or when
|
||||||
|
activating a tab from outside the keyboard flow. Always marks dirty.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/components/tabbar.lisp
|
||||||
(defun tab-bar-select (tb id)
|
(defun tab-bar-select (tb id)
|
||||||
"Select a tab by ID."
|
"Select a tab by ID."
|
||||||
(setf (tab-bar-active tb) id)
|
(setf (tab-bar-active tb) id)
|
||||||
(mark-dirty tb))
|
(mark-dirty tb))
|
||||||
#+END_SRC
|
#+END_SRC
|
||||||
|
|
||||||
** TabBar: keyboard handler
|
** tab-bar-handle-key: keyboard dispatch
|
||||||
|
|
||||||
~tab-bar-handle-key~ dispatches Left → previous tab, Right → next tab.
|
Dispatches key events for tab navigation. Left arrow goes to the
|
||||||
Returns T if the key was handled, NIL otherwise (for composability with
|
previous tab, right arrow to the next. Returns ~t~ when the key was
|
||||||
the keybinding system).
|
consumed and ~nil~ otherwise, which lets the keybinding system fall
|
||||||
|
through to other handlers — important for composable UIs where a tab
|
||||||
|
bar lives alongside other focusable elements.
|
||||||
|
|
||||||
#+BEGIN_SRC lisp :tangle ../src/components/tabbar.lisp
|
#+BEGIN_SRC lisp :tangle ../src/components/tabbar.lisp
|
||||||
(defun tab-bar-handle-key (tb event)
|
(defun tab-bar-handle-key (tb event)
|
||||||
@@ -111,14 +174,17 @@ the keybinding system).
|
|||||||
(t nil)))
|
(t nil)))
|
||||||
#+END_SRC
|
#+END_SRC
|
||||||
|
|
||||||
** TabBar: rendering
|
** render: drawing the tab row
|
||||||
|
|
||||||
~render~ iterates tabs, drawing each as ~[ Title ]~ with the active
|
~render~ iterates the tab list and draws each one as ~[ Title ]~.
|
||||||
tab highlighted (bold, accent color) and inactive tabs dimmed. Tabs
|
The active tab uses the ~:accent~ foreground color and
|
||||||
are separated by two spaces.
|
~:background-element~ background for visual prominence; inactive tabs
|
||||||
|
are rendered in ~:text-muted~. Tabs are separated by two spaces.
|
||||||
|
|
||||||
The available width comes from the layout node. If tabs overflow,
|
Available width comes from the layout node. If the total tab width
|
||||||
they are truncated with an ellipsis.
|
exceeds the available space, tabs are truncated and an ellipsis
|
||||||
|
~...~ is drawn at the overflow point. This prevents the tab bar from
|
||||||
|
breaking the layout on narrow terminals.
|
||||||
|
|
||||||
#+BEGIN_SRC lisp :tangle ../src/components/tabbar.lisp
|
#+BEGIN_SRC lisp :tangle ../src/components/tabbar.lisp
|
||||||
(defmethod render ((tb tab-bar) backend)
|
(defmethod render ((tb tab-bar) backend)
|
||||||
|
|||||||
1924
org/text-input.org
1924
org/text-input.org
File diff suppressed because it is too large
Load Diff
165
org/theme.org
165
org/theme.org
@@ -45,32 +45,75 @@ and the backend's ~*theme-colors*~ for SGR resolution.
|
|||||||
|
|
||||||
* Tests
|
* Tests
|
||||||
|
|
||||||
|
** Test header
|
||||||
|
|
||||||
|
Package declaration and test suite registration.
|
||||||
|
|
||||||
#+BEGIN_SRC lisp :tangle ../src/components/theme-tests.lisp
|
#+BEGIN_SRC lisp :tangle ../src/components/theme-tests.lisp
|
||||||
(in-package :cl-tty-box-test)
|
(in-package :cl-tty-box-test)
|
||||||
(in-suite box-suite)
|
(in-suite box-suite)
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** Test: theme-create-default
|
||||||
|
|
||||||
|
Verifies basic construction of a theme with default ~:dark~ mode. The
|
||||||
|
~make-theme~ constructor should return an instance of the ~theme~
|
||||||
|
class with ~:dark~ as the initial mode.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/components/theme-tests.lisp
|
||||||
(test theme-create-default
|
(test theme-create-default
|
||||||
"A theme can be created with default mode"
|
"A theme can be created with default mode"
|
||||||
(let ((th (make-theme)))
|
(let ((th (make-theme)))
|
||||||
(is (typep th 'theme))
|
(is (typep th 'theme))
|
||||||
(is (eql (theme-mode th) :dark))))
|
(is (eql (theme-mode th) :dark))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** Test: theme-create-light
|
||||||
|
|
||||||
|
Verifies explicit ~:light~ mode works. Both modes must produce themes
|
||||||
|
ready to accept color role assignments.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/components/theme-tests.lisp
|
||||||
(test theme-create-light
|
(test theme-create-light
|
||||||
"A theme can be created in light mode"
|
"A theme can be created in light mode"
|
||||||
(let ((th (make-theme :mode :light)))
|
(let ((th (make-theme :mode :light)))
|
||||||
(is (eql (theme-mode th) :light))))
|
(is (eql (theme-mode th) :light))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** Test: theme-color-set-and-get
|
||||||
|
|
||||||
|
Confirms ~setf~ on ~theme-color~ stores a value and that reading it
|
||||||
|
back returns the same string. This is the core read/write contract
|
||||||
|
for the theme's role map.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/components/theme-tests.lisp
|
||||||
(test theme-color-set-and-get
|
(test theme-color-set-and-get
|
||||||
"theme-color setf/get works"
|
"theme-color setf/get works"
|
||||||
(let ((th (make-theme)))
|
(let ((th (make-theme)))
|
||||||
(setf (theme-color th :primary) "#FFD700")
|
(setf (theme-color th :primary) "#FFD700")
|
||||||
(is (string= (theme-color th :primary) "#FFD700"))))
|
(is (string= (theme-color th :primary) "#FFD700"))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** Test: theme-color-unknown-returns-nil
|
||||||
|
|
||||||
|
Unassigned roles must return ~nil~ rather than signaling an error.
|
||||||
|
This allows components to degrade gracefully when a theme doesn't
|
||||||
|
define every possible role.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/components/theme-tests.lisp
|
||||||
(test theme-color-unknown-returns-nil
|
(test theme-color-unknown-returns-nil
|
||||||
"Unknown roles return nil"
|
"Unknown roles return nil"
|
||||||
(let ((th (make-theme)))
|
(let ((th (make-theme)))
|
||||||
(is (null (theme-color th :nonexistent)))))
|
(is (null (theme-color th :nonexistent)))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** Test: load-default-dark-preset
|
||||||
|
|
||||||
|
Loading the ~:default~ preset in ~:dark~ mode must populate a set of
|
||||||
|
expected roles with their documented hex values. We spot-check
|
||||||
|
~:primary~, ~:background~, and ~:error~.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/components/theme-tests.lisp
|
||||||
(test load-default-dark-preset
|
(test load-default-dark-preset
|
||||||
"Loading the default dark preset populates roles"
|
"Loading the default dark preset populates roles"
|
||||||
(let ((th (make-theme :mode :dark)))
|
(let ((th (make-theme :mode :dark)))
|
||||||
@@ -78,27 +121,59 @@ and the backend's ~*theme-colors*~ for SGR resolution.
|
|||||||
(is (string= (theme-color th :primary) "#FFD700"))
|
(is (string= (theme-color th :primary) "#FFD700"))
|
||||||
(is (string= (theme-color th :background) "#1A1A2E"))
|
(is (string= (theme-color th :background) "#1A1A2E"))
|
||||||
(is (string= (theme-color th :error) "#FF4444"))))
|
(is (string= (theme-color th :error) "#FF4444"))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** Test: load-default-light-preset
|
||||||
|
|
||||||
|
The light variant of ~:default~ must produce different values (warm
|
||||||
|
tones on near-white). This validates the mode dispatch inside
|
||||||
|
~load-preset~.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/components/theme-tests.lisp
|
||||||
(test load-default-light-preset
|
(test load-default-light-preset
|
||||||
"Light variant has different colors"
|
"Light variant has different colors"
|
||||||
(let ((th (make-theme :mode :light)))
|
(let ((th (make-theme :mode :light)))
|
||||||
(load-preset th :default)
|
(load-preset th :default)
|
||||||
(is (string= (theme-color th :primary) "#B8860B"))
|
(is (string= (theme-color th :primary) "#B8860B"))
|
||||||
(is (string= (theme-color th :background) "#F8F9FA"))))
|
(is (string= (theme-color th :background) "#F8F9FA"))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** Test: load-nord-preset
|
||||||
|
|
||||||
|
The ~:nord~ preset must produce a distinct cool-blue palette,
|
||||||
|
different from the ~:default~ gold scheme. This validates independent
|
||||||
|
preset data.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/components/theme-tests.lisp
|
||||||
(test load-nord-preset
|
(test load-nord-preset
|
||||||
"Nord preset has different colors than default"
|
"Nord preset has different colors than default"
|
||||||
(let ((th (make-theme :mode :dark)))
|
(let ((th (make-theme :mode :dark)))
|
||||||
(load-preset th :nord)
|
(load-preset th :nord)
|
||||||
(is (string= (theme-color th :primary) "#88C0D0"))
|
(is (string= (theme-color th :primary) "#88C0D0"))
|
||||||
(is (string= (theme-color th :background) "#2E3440"))))
|
(is (string= (theme-color th :background) "#2E3440"))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** Test: load-preset-unknown-warns
|
||||||
|
|
||||||
|
An unknown preset name must signal a ~warning~ (not an ~error~) and
|
||||||
|
leave the theme's roles unpopulated. This ensures graceful degradation
|
||||||
|
when a preset is missing.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/components/theme-tests.lisp
|
||||||
(test load-preset-unknown-warns
|
(test load-preset-unknown-warns
|
||||||
"Unknown preset warns but doesn't error"
|
"Unknown preset warns but doesn't error"
|
||||||
(let ((th (make-theme)))
|
(let ((th (make-theme)))
|
||||||
(signals warning (load-preset th :nonexistent))
|
(signals warning (load-preset th :nonexistent))
|
||||||
(is (null (theme-color th :primary)))))
|
(is (null (theme-color th :primary)))))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** Test: preset-switch-mode
|
||||||
|
|
||||||
|
Switching the mode at runtime and re-loading the same preset must
|
||||||
|
produce the other variant's colors. This validates that ~load-preset~
|
||||||
|
reads the current ~theme-mode~ each time, not a cached value.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/components/theme-tests.lisp
|
||||||
(test preset-switch-mode
|
(test preset-switch-mode
|
||||||
"Switching mode and reloading changes colors"
|
"Switching mode and reloading changes colors"
|
||||||
(let ((th (make-theme :mode :dark)))
|
(let ((th (make-theme :mode :dark)))
|
||||||
@@ -117,47 +192,84 @@ The ~theme~ class holds a mode flag (~:dark~/~:light~) and a hash
|
|||||||
table of role→hex mappings. The hash table gives O(1) lookups for
|
table of role→hex mappings. The hash table gives O(1) lookups for
|
||||||
~theme-color~ and clean iteration for ~load-preset~.
|
~theme-color~ and clean iteration for ~load-preset~.
|
||||||
|
|
||||||
|
*** defclass theme
|
||||||
|
|
||||||
|
The class has two slots: ~mode~ (defaulting to ~:dark~, with an
|
||||||
|
~:initarg~ and ~accessor~ for reads and writes) and ~roles~ (a hash
|
||||||
|
table storing role→hex mappings, lazily initialized to an empty
|
||||||
|
hash table). Using ~make-hash-table~ as the ~:initform~ ensures each
|
||||||
|
instance gets its own table instead of sharing one.
|
||||||
|
|
||||||
#+BEGIN_SRC lisp :tangle ../src/components/theme.lisp
|
#+BEGIN_SRC lisp :tangle ../src/components/theme.lisp
|
||||||
(in-package :cl-tty.box)
|
(in-package :cl-tty.box)
|
||||||
|
|
||||||
;; ── Theme Engine ──────────────────────────────────────────────
|
|
||||||
|
|
||||||
(defclass theme ()
|
(defclass theme ()
|
||||||
((mode :initform :dark :initarg :mode :accessor theme-mode)
|
((mode :initform :dark :initarg :mode :accessor theme-mode)
|
||||||
(roles :initform (make-hash-table) :accessor theme-roles)))
|
(roles :initform (make-hash-table) :accessor theme-roles)))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
*** defun make-theme
|
||||||
|
|
||||||
|
A convenience constructor that delegates to ~make-instance~. Wrapping
|
||||||
|
this in a function lets us change the constructor signature without
|
||||||
|
breaking callers. Mode defaults to ~:dark~, suitable for dark-background
|
||||||
|
terminals; callers pass ~:mode :light~ for light backgrounds.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/components/theme.lisp
|
||||||
(defun make-theme (&key (mode :dark))
|
(defun make-theme (&key (mode :dark))
|
||||||
(make-instance 'theme :mode mode))
|
(make-instance 'theme :mode mode))
|
||||||
#+END_SRC
|
#+END_SRC
|
||||||
|
|
||||||
The mode defaults to ~:dark~. Applications can initialize with
|
|
||||||
~:light~ for terminals with light backgrounds. The mode controls
|
|
||||||
which variant ~load-preset~ selects.
|
|
||||||
|
|
||||||
** Color resolution
|
** Color resolution
|
||||||
|
|
||||||
|
*** defun theme-color
|
||||||
|
|
||||||
|
Reads a semantic role from the theme's roles hash table. Uses
|
||||||
|
~gethash~ which returns ~nil~ for unknown roles — so missing roles
|
||||||
|
degrade gracefully rather than crashing. The backend treats ~nil~ as
|
||||||
|
"use default."
|
||||||
|
|
||||||
#+BEGIN_SRC lisp :tangle ../src/components/theme.lisp
|
#+BEGIN_SRC lisp :tangle ../src/components/theme.lisp
|
||||||
(defun theme-color (theme role)
|
(defun theme-color (theme role)
|
||||||
"Resolve a semantic ROLE to a hex color string in THEME."
|
"Resolve a semantic ROLE to a hex color string in THEME."
|
||||||
(gethash role (theme-roles theme)))
|
(gethash role (theme-roles theme)))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
*** defun (setf theme-color)
|
||||||
|
|
||||||
|
The setter companion to ~theme-color~. Storing via ~setf~ writes
|
||||||
|
directly into the roles hash table. Uses ~setf~ on ~gethash~ which
|
||||||
|
creates the entry if it doesn't exist.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/components/theme.lisp
|
||||||
(defun (setf theme-color) (hex theme role)
|
(defun (setf theme-color) (hex theme role)
|
||||||
"Set the hex color for a semantic ROLE in THEME."
|
"Set the hex color for a semantic ROLE in THEME."
|
||||||
(setf (gethash role (theme-roles theme)) hex))
|
(setf (gethash role (theme-roles theme)) hex))
|
||||||
#+END_SRC
|
#+END_SRC
|
||||||
|
|
||||||
Uses ~gethash~ for both getter and setter. Unknown roles return ~nil~,
|
** Global preset registry
|
||||||
which the backend treats as "use default" — so missing roles degrade
|
|
||||||
gracefully rather than crashing.
|
|
||||||
|
|
||||||
** Preset system
|
A hash table (keyed by ~eq~-comparable keywords) stores all registered
|
||||||
|
presets. Using ~#\\'~ (quoted list) instead of an alist or nested hash
|
||||||
|
table keeps preset data inline and readable.
|
||||||
|
|
||||||
Presets are stored in a global hash table keyed by keyword name. The
|
*** defparameter *presets*
|
||||||
~define-preset~ macro registers a preset at macro-expansion time.
|
|
||||||
|
Global storage for preset definitions. The ~eq~ test matches keyword
|
||||||
|
identity, which is the fastest hash test for keywords.
|
||||||
|
|
||||||
#+BEGIN_SRC lisp :tangle ../src/components/theme.lisp
|
#+BEGIN_SRC lisp :tangle ../src/components/theme.lisp
|
||||||
(defparameter *presets* (make-hash-table :test #'eq))
|
(defparameter *presets* (make-hash-table :test #'eq))
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
*** defmacro define-preset
|
||||||
|
|
||||||
|
Registers a preset by name (~keyword~) at macro-expansion time. The
|
||||||
|
~check-type~ enforces that names are keywords. The macro expands to a
|
||||||
|
~setf~ of ~gethash~, storing a plist of ~:dark~ and ~:light~ variants.
|
||||||
|
Using a quoted list (not an alist or hash) keeps the data compact.
|
||||||
|
|
||||||
|
#+BEGIN_SRC lisp :tangle ../src/components/theme.lisp
|
||||||
(defmacro define-preset (name &key dark light)
|
(defmacro define-preset (name &key dark light)
|
||||||
"Define a theme preset with DARK and LIGHT variants.
|
"Define a theme preset with DARK and LIGHT variants.
|
||||||
NAME should be a keyword (e.g., :default, :nord)."
|
NAME should be a keyword (e.g., :default, :nord)."
|
||||||
@@ -165,9 +277,20 @@ NAME should be a keyword (e.g., :default, :nord)."
|
|||||||
`(setf (gethash ,name *presets*) '(:dark ,dark :light ,light)))
|
`(setf (gethash ,name *presets*) '(:dark ,dark :light ,light)))
|
||||||
#+END_SRC
|
#+END_SRC
|
||||||
|
|
||||||
Using ~#\'~ (quoted list) instead of an alist or hash table keeps the
|
** Loading presets
|
||||||
preset data inline and easy to read. The ~eq~ hash table test matches
|
|
||||||
keyword identity.
|
*** defun load-preset
|
||||||
|
|
||||||
|
The central function that applies a named preset to a theme. Does
|
||||||
|
double duty: populates the theme's role map and the backend's
|
||||||
|
~*theme-colors*~. This second step is what makes semantic colors work
|
||||||
|
at the SGR level — when the backend renders ~:accent~, it looks up
|
||||||
|
~*theme-colors*~ to get the hex, then generates the escape sequence.
|
||||||
|
|
||||||
|
The ~loop for (role hex) on colors by #'cddr~ iterates the plist in
|
||||||
|
pairs, setting both the theme entry and the backend entry. If the
|
||||||
|
preset doesn't exist, ~warn~ is called instead of ~error~ — a missing
|
||||||
|
preset shouldn't crash the application.
|
||||||
|
|
||||||
#+BEGIN_SRC lisp :tangle ../src/components/theme.lisp
|
#+BEGIN_SRC lisp :tangle ../src/components/theme.lisp
|
||||||
(defun load-preset (theme preset-name)
|
(defun load-preset (theme preset-name)
|
||||||
@@ -188,18 +311,6 @@ color roles resolve to hex at SGR generation time."
|
|||||||
(warn "Unknown preset: ~S" preset-name))))
|
(warn "Unknown preset: ~S" preset-name))))
|
||||||
#+END_SRC
|
#+END_SRC
|
||||||
|
|
||||||
~load-preset~ does double duty: it populates the theme's role map and
|
|
||||||
the backend's ~*theme-colors*~. This second step is what makes
|
|
||||||
semantic colors work at the SGR level — when the backend renders
|
|
||||||
~:accent~, it looks up ~*theme-colors*~ to get the hex, then
|
|
||||||
generates the escape sequence.
|
|
||||||
|
|
||||||
The ~loop for (role hex) on colors by #'cddr~ iterates the plist in
|
|
||||||
pairs, setting both the theme entry and the backend entry.
|
|
||||||
|
|
||||||
If the preset doesn't exist, ~warn~ is called instead of ~error~ — a
|
|
||||||
missing preset shouldn't crash the application.
|
|
||||||
|
|
||||||
** Built-in presets
|
** Built-in presets
|
||||||
|
|
||||||
Two presets are built in:
|
Two presets are built in:
|
||||||
|
|||||||
@@ -1,12 +1,8 @@
|
|||||||
(in-package :cl-tty.backend)
|
(in-package :cl-tty.backend)
|
||||||
|
|
||||||
;;; ─── Detection cache ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
(defvar *detected-backend* nil
|
(defvar *detected-backend* nil
|
||||||
"Cached backend instance from detect-backend. Nil = not yet detected.")
|
"Cached backend instance from detect-backend. Nil = not yet detected.")
|
||||||
|
|
||||||
;;; ─── Environment probe ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
(defun detect-backend-by-env ()
|
(defun detect-backend-by-env ()
|
||||||
"Check COLORTERM environment variable for modern terminal support.
|
"Check COLORTERM environment variable for modern terminal support.
|
||||||
Returns :modern if COLORTERM contains 'truecolor' or '24bit', nil otherwise."
|
Returns :modern if COLORTERM contains 'truecolor' or '24bit', nil otherwise."
|
||||||
@@ -16,15 +12,11 @@ Returns :modern if COLORTERM contains 'truecolor' or '24bit', nil otherwise."
|
|||||||
(search "24bit" colorterm :test #'char-equal)))
|
(search "24bit" colorterm :test #'char-equal)))
|
||||||
:modern)))
|
:modern)))
|
||||||
|
|
||||||
;;; ─── TTY probe ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
(defun detect-backend-by-tty ()
|
(defun detect-backend-by-tty ()
|
||||||
"Check if stdout is a real terminal (not a pipe/redirect).
|
"Check if stdout is a real terminal (not a pipe/redirect).
|
||||||
Returns T if stdout is interactive, nil otherwise."
|
Returns T if stdout is interactive, nil otherwise."
|
||||||
(interactive-stream-p *standard-output*))
|
(interactive-stream-p *standard-output*))
|
||||||
|
|
||||||
;;; ─── DA1 terminal query ─────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
(defun query-terminal (query &optional (timeout 0.1))
|
(defun query-terminal (query &optional (timeout 0.1))
|
||||||
"Send QUERY string to terminal and return any response received within
|
"Send QUERY string to terminal and return any response received within
|
||||||
TIMEOUT seconds. Returns the response string, or nil if no response."
|
TIMEOUT seconds. Returns the response string, or nil if no response."
|
||||||
@@ -41,14 +33,12 @@ TIMEOUT seconds. Returns the response string, or nil if no response."
|
|||||||
(defun detect-backend-by-da1 ()
|
(defun detect-backend-by-da1 ()
|
||||||
"Send DA1 (ESC[c) query and check for kitty terminal response code.
|
"Send DA1 (ESC[c) query and check for kitty terminal response code.
|
||||||
Returns T if terminal reports kitty compatibility codes."
|
Returns T if terminal reports kitty compatibility codes."
|
||||||
(let ((response (query-terminal (format nil "~C[c" #\Esc))))
|
(let ((response (query-terminal (format nil "~C[c" (code-char 27)))))
|
||||||
(when response
|
(when response
|
||||||
;; DA1 response format: ESC [ ? digits ; digits c
|
;; DA1 response format: ESC [ ? digits ; digits c
|
||||||
;; Kitty reports code 62 in the response
|
;; Kitty reports code 62 in the response
|
||||||
(search "?62" response))))
|
(search "?62" response))))
|
||||||
|
|
||||||
;;; ─── Orchestrator ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
(defun detect-backend ()
|
(defun detect-backend ()
|
||||||
"Auto-detect the appropriate backend for the current terminal.
|
"Auto-detect the appropriate backend for the current terminal.
|
||||||
Returns a backend instance (modern-backend or simple-backend).
|
Returns a backend instance (modern-backend or simple-backend).
|
||||||
|
|||||||
@@ -11,15 +11,11 @@
|
|||||||
(fiveam:explain! result)
|
(fiveam:explain! result)
|
||||||
(uiop:quit 0)))
|
(uiop:quit 0)))
|
||||||
|
|
||||||
;; ── Constructor ────────────────────────────────────────────────
|
|
||||||
|
|
||||||
(test make-modern-backend-creates
|
(test make-modern-backend-creates
|
||||||
"make-modern-backend returns a modern-backend instance"
|
"make-modern-backend returns a modern-backend instance"
|
||||||
(let ((b (make-modern-backend)))
|
(let ((b (make-modern-backend)))
|
||||||
(is (typep b 'cl-tty.backend::modern-backend))))
|
(is (typep b 'cl-tty.backend::modern-backend))))
|
||||||
|
|
||||||
;; ── Escape Generation ──────────────────────────────────────────
|
|
||||||
|
|
||||||
(test sgr-truecolor-foreground
|
(test sgr-truecolor-foreground
|
||||||
"SGR truecolor foreground escape is correct"
|
"SGR truecolor foreground escape is correct"
|
||||||
(is (equal (cl-tty.backend::sgr-fg "#FFD700")
|
(is (equal (cl-tty.backend::sgr-fg "#FFD700")
|
||||||
@@ -44,8 +40,6 @@
|
|||||||
(is (equal (cl-tty.backend::sgr-attr :underline) (format nil "~C[4m" #\Esc)))
|
(is (equal (cl-tty.backend::sgr-attr :underline) (format nil "~C[4m" #\Esc)))
|
||||||
(is (equal (cl-tty.backend::sgr-attr :reset) (format nil "~C[0m" #\Esc))))
|
(is (equal (cl-tty.backend::sgr-attr :reset) (format nil "~C[0m" #\Esc))))
|
||||||
|
|
||||||
;; ── Cursor ─────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
(test cursor-move-escape
|
(test cursor-move-escape
|
||||||
"cursor-move generates correct CSI escape"
|
"cursor-move generates correct CSI escape"
|
||||||
(let ((b (make-modern-backend)))
|
(let ((b (make-modern-backend)))
|
||||||
@@ -70,23 +64,17 @@
|
|||||||
(is (equal (cl-tty.backend::cursor-style-escape :underline t)
|
(is (equal (cl-tty.backend::cursor-style-escape :underline t)
|
||||||
(format nil "~C[5 q" #\Esc)))))
|
(format nil "~C[5 q" #\Esc)))))
|
||||||
|
|
||||||
;; ── Synchronization ────────────────────────────────────────────
|
|
||||||
|
|
||||||
(test decicm-escapes
|
(test decicm-escapes
|
||||||
"DECICM synchronized update escapes"
|
"DECICM synchronized update escapes"
|
||||||
(is (equal (cl-tty.backend::decicm-begin) (format nil "~C[?2026h" #\Esc)))
|
(is (equal (cl-tty.backend::decicm-begin) (format nil "~C[?2026h" #\Esc)))
|
||||||
(is (equal (cl-tty.backend::decicm-end) (format nil "~C[?2026l" #\Esc))))
|
(is (equal (cl-tty.backend::decicm-end) (format nil "~C[?2026l" #\Esc))))
|
||||||
|
|
||||||
;; ── OSC 8 Hyperlinks ──────────────────────────────────────────
|
|
||||||
|
|
||||||
(test osc8-escape
|
(test osc8-escape
|
||||||
"OSC 8 hyperlink escape wraps text"
|
"OSC 8 hyperlink escape wraps text"
|
||||||
(is (equal (cl-tty.backend::osc8-link "http://example.com" "click here")
|
(is (equal (cl-tty.backend::osc8-link "http://example.com" "click here")
|
||||||
(format nil "~C]8;;http://example.com~C\\\\click here~C]8;;~C\\\\"
|
(format nil "~C]8;;http://example.com~C\\click here~C]8;;~C\"
|
||||||
#\Esc #\Esc #\Esc #\Esc))))
|
#\Esc #\Esc #\Esc #\Esc))))
|
||||||
|
|
||||||
;; ── Hex Parsing ────────────────────────────────────────────────
|
|
||||||
|
|
||||||
(test hex-color-parsing
|
(test hex-color-parsing
|
||||||
"hex-to-rgb parses valid hex colors"
|
"hex-to-rgb parses valid hex colors"
|
||||||
(multiple-value-bind (r g b) (cl-tty.backend::hex-to-rgb "#FFD700")
|
(multiple-value-bind (r g b) (cl-tty.backend::hex-to-rgb "#FFD700")
|
||||||
@@ -108,17 +96,15 @@
|
|||||||
(is (= g 0))
|
(is (= g 0))
|
||||||
(is (= b 0))))
|
(is (= b 0))))
|
||||||
|
|
||||||
;; ── Border Characters ──────────────────────────────────────────
|
|
||||||
|
|
||||||
(test border-char-rounded
|
(test border-char-rounded
|
||||||
"modern-border-char returns Unicode box-drawing for rounded style"
|
"modern-border-char returns Unicode box-drawing for rounded style"
|
||||||
(is (equal (cl-tty.backend::border-char :rounded :top-left) "╭"))
|
(is (equal (cl-tty.backend::border-char :rounded :top-left) "╭"))
|
||||||
(is (equal (cl-tty.backend::border-char :rounded :horizontal) "─"))
|
(is (equal (cl-tty.backend::border-char :rounded :horizontal) "─"))
|
||||||
(is (equal (cl-tty.backend::border-char :rounded :vertical) "│"))
|
(is (equal (cl-tty.backend::border-char :rounded :vertical) "│"))
|
||||||
(is (equal (cl-tty.backend::border-char :rounded :bottom-right) "╯")))
|
(is (equal (cl-tty.backend::border-char :rounded :bottom-right) "╯"))
|
||||||
|
|
||||||
(test border-char-double
|
(test border-char-double
|
||||||
"modern-border-char returns double-line chars"
|
"modern-border-char returns double-line chars"
|
||||||
(is (equal (cl-tty.backend::border-char :double :top-left) "╔"))
|
(is (equal (cl-tty.backend::border-char :double :top-left) "╔"))
|
||||||
(is (equal (cl-tty.backend::border-char :double :horizontal) "═"))
|
(is (equal (cl-tty.backend::border-char :double :horizontal) "═"))
|
||||||
(is (equal (cl-tty.backend::border-char :double :vertical) "║")))
|
(is (equal (cl-tty.backend::border-char :double :vertical) "║"))
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ as a fallback when a keyword is not in *named-colors*.")
|
|||||||
|
|
||||||
(defun osc8-link (url text)
|
(defun osc8-link (url text)
|
||||||
"Wrap TEXT in an OSC 8 hyperlink to URL."
|
"Wrap TEXT in an OSC 8 hyperlink to URL."
|
||||||
(format nil "~C]8;;~A~C\\\\~A~C]8;;~C\\\\"
|
(format nil "~C]8;;~A~C\\~A~C]8;;~C\\"
|
||||||
#\Esc url #\Esc text #\Esc #\Esc))
|
#\Esc url #\Esc text #\Esc #\Esc))
|
||||||
|
|
||||||
(defparameter *border-chars*
|
(defparameter *border-chars*
|
||||||
|
|||||||
@@ -6,16 +6,12 @@
|
|||||||
(def-suite backend-suite :description "Backend protocol tests")
|
(def-suite backend-suite :description "Backend protocol tests")
|
||||||
(in-suite backend-suite)
|
(in-suite backend-suite)
|
||||||
|
|
||||||
;; ── Helpers ─────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
(defun make-capturing-backend ()
|
(defun make-capturing-backend ()
|
||||||
"Create a simple-backend that writes to a string stream."
|
"Create a simple-backend that writes to a string stream."
|
||||||
(let* ((s (make-string-output-stream))
|
(let* ((s (make-string-output-stream))
|
||||||
(b (make-simple-backend :output-stream s)))
|
(b (make-simple-backend :output-stream s)))
|
||||||
(values b s)))
|
(values b s)))
|
||||||
|
|
||||||
;; ── Simple Backend ──────────────────────────────────────────────
|
|
||||||
|
|
||||||
(defun run-tests ()
|
(defun run-tests ()
|
||||||
"Run all backend tests."
|
"Run all backend tests."
|
||||||
(let ((result (run 'backend-suite)))
|
(let ((result (run 'backend-suite)))
|
||||||
@@ -46,7 +42,7 @@
|
|||||||
(draw-border b 0 0 5 3 :style :single)
|
(draw-border b 0 0 5 3 :style :single)
|
||||||
(shutdown-backend b)
|
(shutdown-backend b)
|
||||||
(let ((out (get-output-stream-string s)))
|
(let ((out (get-output-stream-string s)))
|
||||||
(is (search "+---+" out) "top edge should have +---+")
|
(is (search "+---+" out) "top edge should have +---+\"")
|
||||||
(is (search "| |" out) "middle row should have pipe sides"))))
|
(is (search "| |" out) "middle row should have pipe sides"))))
|
||||||
|
|
||||||
(test simple-backend-draw-rounded
|
(test simple-backend-draw-rounded
|
||||||
@@ -56,7 +52,7 @@
|
|||||||
(draw-border b 0 0 5 3 :style :rounded)
|
(draw-border b 0 0 5 3 :style :rounded)
|
||||||
(shutdown-backend b)
|
(shutdown-backend b)
|
||||||
(let ((out (get-output-stream-string s)))
|
(let ((out (get-output-stream-string s)))
|
||||||
;; Rounded falls back to ASCII — identical output to single
|
;; Rounded falls back to ASCII -- identical output to single
|
||||||
(is (search "+---+" out) "rounded style produces same dashes as single"))))
|
(is (search "+---+" out) "rounded style produces same dashes as single"))))
|
||||||
|
|
||||||
(test simple-backend-draw-link
|
(test simple-backend-draw-link
|
||||||
@@ -77,8 +73,6 @@
|
|||||||
(is (string= (get-output-stream-string s) "...")
|
(is (string= (get-output-stream-string s) "...")
|
||||||
"ellipsis should output 3 dots")))
|
"ellipsis should output 3 dots")))
|
||||||
|
|
||||||
;; ── Backend Capabilities ───────────────────────────────────────
|
|
||||||
|
|
||||||
(test capable-p-known-features
|
(test capable-p-known-features
|
||||||
"capable-p returns nil for all features on simple-backend"
|
"capable-p returns nil for all features on simple-backend"
|
||||||
(let ((b (make-simple-backend)))
|
(let ((b (make-simple-backend)))
|
||||||
@@ -89,8 +83,6 @@
|
|||||||
(format nil "~s should not be supported on simple-backend" f)))
|
(format nil "~s should not be supported on simple-backend" f)))
|
||||||
(shutdown-backend b)))
|
(shutdown-backend b)))
|
||||||
|
|
||||||
;; ── Backend Size ───────────────────────────────────────────────
|
|
||||||
|
|
||||||
(test backend-size-returns-integers
|
(test backend-size-returns-integers
|
||||||
"backend-size returns two integer values"
|
"backend-size returns two integer values"
|
||||||
(let ((b (make-simple-backend)))
|
(let ((b (make-simple-backend)))
|
||||||
@@ -102,8 +94,6 @@
|
|||||||
(is (>= lines 3)))
|
(is (>= lines 3)))
|
||||||
(shutdown-backend b)))
|
(shutdown-backend b)))
|
||||||
|
|
||||||
;; ── Backend Protocol: Defaults and No-ops ──────────────────────
|
|
||||||
|
|
||||||
(test default-methods-are-no-ops
|
(test default-methods-are-no-ops
|
||||||
"Default backend methods don't error"
|
"Default backend methods don't error"
|
||||||
(let ((b (make-simple-backend)))
|
(let ((b (make-simple-backend)))
|
||||||
@@ -126,8 +116,6 @@
|
|||||||
(is (string= (get-output-stream-string s) "in sync")
|
(is (string= (get-output-stream-string s) "in sync")
|
||||||
"no sync escape sequences should appear")))
|
"no sync escape sequences should appear")))
|
||||||
|
|
||||||
;; ── Draw-rect ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
(test draw-rect-fills-area-correctly
|
(test draw-rect-fills-area-correctly
|
||||||
"draw-rect with background writes nothing to output (simple-backend no-op)"
|
"draw-rect with background writes nothing to output (simple-backend no-op)"
|
||||||
(multiple-value-bind (b s) (make-capturing-backend)
|
(multiple-value-bind (b s) (make-capturing-backend)
|
||||||
@@ -137,8 +125,6 @@
|
|||||||
(is (string= (get-output-stream-string s) "")
|
(is (string= (get-output-stream-string s) "")
|
||||||
"draw-rect is a no-op on simple-backend")))
|
"draw-rect is a no-op on simple-backend")))
|
||||||
|
|
||||||
;; ── Detection ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
(test detection-returns-backend-instance
|
(test detection-returns-backend-instance
|
||||||
"detect-backend returns a valid backend instance"
|
"detect-backend returns a valid backend instance"
|
||||||
(let ((be (cl-tty.backend:detect-backend)))
|
(let ((be (cl-tty.backend:detect-backend)))
|
||||||
|
|||||||
@@ -16,8 +16,6 @@
|
|||||||
(b (make-modern-backend :output-stream s)))
|
(b (make-modern-backend :output-stream s)))
|
||||||
(values b s)))
|
(values b s)))
|
||||||
|
|
||||||
;; ── Box Tests ─────────────────────────────────────────────────
|
|
||||||
|
|
||||||
(test box-creates-with-defaults
|
(test box-creates-with-defaults
|
||||||
"A box created with no arguments has reasonable defaults"
|
"A box created with no arguments has reasonable defaults"
|
||||||
(let ((b (make-box)))
|
(let ((b (make-box)))
|
||||||
@@ -92,8 +90,6 @@
|
|||||||
(let ((out (get-output-stream-string s)))
|
(let ((out (get-output-stream-string s)))
|
||||||
(is (search "┌" out) "2x2 box still has borders")))))
|
(is (search "┌" out) "2x2 box still has borders")))))
|
||||||
|
|
||||||
;; ── Text and Span Tests ───────────────────────────────────────
|
|
||||||
|
|
||||||
(test text-creates-with-defaults
|
(test text-creates-with-defaults
|
||||||
"A text created with no arguments has reasonable defaults"
|
"A text created with no arguments has reasonable defaults"
|
||||||
(let ((txt (make-text "")))
|
(let ((txt (make-text "")))
|
||||||
|
|||||||
@@ -1,17 +1,11 @@
|
|||||||
;;; dialog.lisp — Dialog System + Toast for cl-tty
|
|
||||||
|
|
||||||
(in-package :cl-tty.dialog)
|
(in-package :cl-tty.dialog)
|
||||||
|
|
||||||
;; ─── Special variables ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
(defvar *dialog-stack* nil
|
(defvar *dialog-stack* nil
|
||||||
"Stack of active dialogs. (list) of dialog instances.")
|
"Stack of active dialogs. (list) of dialog instances.")
|
||||||
|
|
||||||
(defvar *toasts* nil
|
(defvar *toasts* nil
|
||||||
"List of active toast notifications.")
|
"List of active toast notifications.")
|
||||||
|
|
||||||
;; ─── Dialog class ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
(defclass dialog ()
|
(defclass dialog ()
|
||||||
((title :initarg :title :accessor dialog-title)
|
((title :initarg :title :accessor dialog-title)
|
||||||
(size :initarg :size :initform :medium :accessor dialog-size)
|
(size :initarg :size :initform :medium :accessor dialog-size)
|
||||||
@@ -53,8 +47,6 @@
|
|||||||
(funcall (dialog-on-dismiss dialog)))
|
(funcall (dialog-on-dismiss dialog)))
|
||||||
dialog)))
|
dialog)))
|
||||||
|
|
||||||
;; ─── Dialog sub-classes ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
(defun alert-dialog (title message)
|
(defun alert-dialog (title message)
|
||||||
(make-instance 'dialog
|
(make-instance 'dialog
|
||||||
:title title
|
:title title
|
||||||
@@ -96,8 +88,6 @@
|
|||||||
(pop-dialog)
|
(pop-dialog)
|
||||||
(when on-submit (funcall on-submit value))))))
|
(when on-submit (funcall on-submit value))))))
|
||||||
|
|
||||||
;; ─── Toast system ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
(defclass toast ()
|
(defclass toast ()
|
||||||
((message :initarg :message :accessor toast-message)
|
((message :initarg :message :accessor toast-message)
|
||||||
(variant :initarg :variant :initform :info :accessor toast-variant)))
|
(variant :initarg :variant :initform :info :accessor toast-variant)))
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
;; Dirty tracking tests are in box-tests.lisp (same test suite)
|
|
||||||
(in-package :cl-tty-box-test)
|
(in-package :cl-tty-box-test)
|
||||||
(in-suite box-suite)
|
(in-suite box-suite)
|
||||||
|
|
||||||
@@ -7,12 +6,18 @@
|
|||||||
(let ((c (make-instance 'dirty-mixin)))
|
(let ((c (make-instance 'dirty-mixin)))
|
||||||
(is-true (dirty-p c) "new component should be dirty")))
|
(is-true (dirty-p c) "new component should be dirty")))
|
||||||
|
|
||||||
|
(in-package :cl-tty-box-test)
|
||||||
|
(in-suite box-suite)
|
||||||
|
|
||||||
(test mark-clean-clears-dirty
|
(test mark-clean-clears-dirty
|
||||||
"mark-clean sets dirty to nil"
|
"mark-clean sets dirty to nil"
|
||||||
(let ((c (make-instance 'dirty-mixin)))
|
(let ((c (make-instance 'dirty-mixin)))
|
||||||
(mark-clean c)
|
(mark-clean c)
|
||||||
(is-false (dirty-p c) "after mark-clean, should not be dirty")))
|
(is-false (dirty-p c) "after mark-clean, should not be dirty")))
|
||||||
|
|
||||||
|
(in-package :cl-tty-box-test)
|
||||||
|
(in-suite box-suite)
|
||||||
|
|
||||||
(test mark-dirty-sets-dirty
|
(test mark-dirty-sets-dirty
|
||||||
"mark-dirty sets dirty to t"
|
"mark-dirty sets dirty to t"
|
||||||
(let ((c (make-instance 'dirty-mixin)))
|
(let ((c (make-instance 'dirty-mixin)))
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
;; This file is deprecated. Tests moved to tests/input-tests.lisp.
|
;; This file is deprecated. Tests moved to tests/input-tests.lisp.
|
||||||
;; Kept as placeholder to prevent confusion with stale copies.
|
;; Kept as placeholder to prevent confusion with stale copies.
|
||||||
|
(defpackage :cl-tty-input-test
|
||||||
|
(:use :cl :fiveam :cl-tty.backend :cl-tty.box :cl-tty.layout :cl-tty.input)
|
||||||
|
(:export #:run-tests))
|
||||||
(in-package :cl-tty-input-test)
|
(in-package :cl-tty-input-test)
|
||||||
|
|
||||||
(defun run-tests ()
|
(defun run-tests ()
|
||||||
|
|||||||
@@ -1,12 +1,19 @@
|
|||||||
(in-package #:cl-tty.input)
|
(in-package #:cl-tty.input)
|
||||||
|
|
||||||
(defun %split-string (string separator)
|
(defun %split-string (string separator)
|
||||||
|
"Split STRING at each occurrence of SEPARATOR. Returns list of strings."
|
||||||
(loop with start = 0
|
(loop with start = 0
|
||||||
for pos = (position separator string :start start)
|
for pos = (position separator string :start start)
|
||||||
collect (subseq string start pos)
|
collect (subseq string start pos)
|
||||||
while pos
|
while pos
|
||||||
do (setf start (1+ pos))))
|
do (setf start (1+ pos))))
|
||||||
|
|
||||||
|
(defvar *current-backend* nil
|
||||||
|
"The active backend used for rendering.")
|
||||||
|
|
||||||
|
(defvar *current-theme* nil
|
||||||
|
"The active theme used for semantic color resolution.")
|
||||||
|
|
||||||
(defstruct key-event
|
(defstruct key-event
|
||||||
(key nil :type (or keyword null))
|
(key nil :type (or keyword null))
|
||||||
(ctrl nil :type boolean)
|
(ctrl nil :type boolean)
|
||||||
|
|||||||
@@ -1,22 +1,14 @@
|
|||||||
(in-package #:cl-tty.input)
|
(in-package #:cl-tty.input)
|
||||||
|
|
||||||
;;; ---------------------------------------------------------------------------
|
|
||||||
;;; Key map struct
|
|
||||||
;;; ---------------------------------------------------------------------------
|
|
||||||
(defstruct keymap
|
(defstruct keymap
|
||||||
(name nil :type (or keyword null))
|
(name nil :type (or keyword null))
|
||||||
(bindings nil :type list)
|
(bindings nil :type list)
|
||||||
(parent nil :type (or keymap null)))
|
(parent nil :type (or keymap null)))
|
||||||
|
|
||||||
;;; ---------------------------------------------------------------------------
|
|
||||||
;;; Global keymap registry
|
|
||||||
;;; ---------------------------------------------------------------------------
|
|
||||||
(defparameter *keymaps* (make-hash-table :test #'equal))
|
(defparameter *keymaps* (make-hash-table :test #'equal))
|
||||||
|
|
||||||
(defparameter *chord-timeout* 0.5)
|
(defparameter *chord-timeout* 0.5)
|
||||||
|
|
||||||
;;; ---------------------------------------------------------------------------
|
|
||||||
;;; Key spec matching
|
|
||||||
;;; ---------------------------------------------------------------------------
|
|
||||||
(defun key-match-p (spec event)
|
(defun key-match-p (spec event)
|
||||||
"T if SPEC matches EVENT. Spec is :ctrl+p (modifier+key keyword)
|
"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."
|
or (:ctrl+p) for single-spec in a list, or (:ctrl+x :ctrl+s) for chords."
|
||||||
@@ -26,7 +18,7 @@
|
|||||||
(let* ((name (string spec))
|
(let* ((name (string spec))
|
||||||
(plus (position #\+ name)))
|
(plus (position #\+ name)))
|
||||||
(if plus
|
(if plus
|
||||||
;; Modified key: :ctrl+p → mod-str="CTRL", key-str="P"
|
;; Modified key: :ctrl+p -> mod-str="CTRL", key-str="P"
|
||||||
(let ((mod-str (subseq name 0 plus))
|
(let ((mod-str (subseq name 0 plus))
|
||||||
(key-str (subseq name (1+ plus))))
|
(key-str (subseq name (1+ plus))))
|
||||||
(and (eql (intern key-str :keyword)
|
(and (eql (intern key-str :keyword)
|
||||||
@@ -43,24 +35,6 @@
|
|||||||
(when spec
|
(when spec
|
||||||
(key-match-p (first spec) event)))))
|
(key-match-p (first spec) event)))))
|
||||||
|
|
||||||
;;; ---------------------------------------------------------------------------
|
|
||||||
;;; Dispatch
|
|
||||||
;;; ---------------------------------------------------------------------------
|
|
||||||
;;; dispatch-key-event — main entry point for keymap-based dispatch.
|
|
||||||
;;;
|
|
||||||
;;; IMPORTANT: This function is NOT called by the demo's event loop
|
|
||||||
;;; or by any built-in widget event handlers. Users who want to use
|
|
||||||
;;; the keymap system MUST call dispatch-key-event explicitly in their
|
|
||||||
;;; own event loops, e.g.:
|
|
||||||
;;;
|
|
||||||
;;; (defun handle-event (event)
|
|
||||||
;;; (or (dispatch-key-event event)
|
|
||||||
;;; (handle-text-input my-input event)
|
|
||||||
;;; ...))
|
|
||||||
;;;
|
|
||||||
;;; Chords ((:ctrl+x :ctrl+s)) are not yet supported; only single
|
|
||||||
;;; key specs work. The *chord-timeout* and list-of-lists syntax
|
|
||||||
;;; are reserved for future implementation.
|
|
||||||
(defun dispatch-key-event (event &key component)
|
(defun dispatch-key-event (event &key component)
|
||||||
(labels ((try-keymap (km)
|
(labels ((try-keymap (km)
|
||||||
(when km
|
(when km
|
||||||
@@ -76,9 +50,6 @@
|
|||||||
(try-keymap (find-keymap :local))
|
(try-keymap (find-keymap :local))
|
||||||
(try-keymap (find-keymap :global)))))
|
(try-keymap (find-keymap :global)))))
|
||||||
|
|
||||||
;;; ---------------------------------------------------------------------------
|
|
||||||
;;; defkeymap macro
|
|
||||||
;;; ---------------------------------------------------------------------------
|
|
||||||
(defmacro defkeymap (name &body bindings)
|
(defmacro defkeymap (name &body bindings)
|
||||||
`(setf (gethash ',name *keymaps*)
|
`(setf (gethash ',name *keymaps*)
|
||||||
(make-keymap :name ',name
|
(make-keymap :name ',name
|
||||||
|
|||||||
@@ -2,8 +2,6 @@
|
|||||||
|
|
||||||
(in-package :cl-tty.markdown)
|
(in-package :cl-tty.markdown)
|
||||||
|
|
||||||
;; ─── Node constructors ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
(defun make-md-node (type &key children properties content url)
|
(defun make-md-node (type &key children properties content url)
|
||||||
(let ((node (list :type type)))
|
(let ((node (list :type type)))
|
||||||
(when children (setf (getf node :children) children))
|
(when children (setf (getf node :children) children))
|
||||||
@@ -28,8 +26,6 @@
|
|||||||
(mapcar #'md-node-text (getf node :children))))
|
(mapcar #'md-node-text (getf node :children))))
|
||||||
(t ""))))
|
(t ""))))
|
||||||
|
|
||||||
;; ─── Block-level parser ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
(defun split-string-into-lines (string)
|
(defun split-string-into-lines (string)
|
||||||
(unless string (return-from split-string-into-lines (coerce nil 'vector)))
|
(unless string (return-from split-string-into-lines (coerce nil 'vector)))
|
||||||
(let ((result nil) (start 0))
|
(let ((result nil) (start 0))
|
||||||
@@ -250,8 +246,6 @@
|
|||||||
(t (incf i)))))
|
(t (incf i)))))
|
||||||
(nreverse nodes)))
|
(nreverse nodes)))
|
||||||
|
|
||||||
;; ─── Inline parser ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
(defun parse-inline (text)
|
(defun parse-inline (text)
|
||||||
(unless (and text (> (length text) 0)) (return-from parse-inline nil))
|
(unless (and text (> (length text) 0)) (return-from parse-inline nil))
|
||||||
(let ((nodes nil) (i 0) (len (length text)))
|
(let ((nodes nil) (i 0) (len (length text)))
|
||||||
@@ -348,8 +342,6 @@
|
|||||||
:url (subseq text (+ close-bracket 2) close-paren))
|
:url (subseq text (+ close-bracket 2) close-paren))
|
||||||
(1+ close-paren)))))
|
(1+ close-paren)))))
|
||||||
|
|
||||||
;; ─── Syntax highlighting ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
(defun get-highlighter (lang)
|
(defun get-highlighter (lang)
|
||||||
(cdr (assoc lang
|
(cdr (assoc lang
|
||||||
'(("lisp" . (:comment (";" "#|" ";;") :string ("\"")
|
'(("lisp" . (:comment (";" "#|" ";;") :string ("\"")
|
||||||
@@ -525,8 +517,6 @@
|
|||||||
(defun apply-highlight-style (char-vector)
|
(defun apply-highlight-style (char-vector)
|
||||||
(coerce char-vector 'string))
|
(coerce char-vector 'string))
|
||||||
|
|
||||||
;; ─── Diff rendering ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
(defun string-prefix-p (prefix string)
|
(defun string-prefix-p (prefix string)
|
||||||
(and (>= (length string) (length prefix))
|
(and (>= (length string) (length prefix))
|
||||||
(string= prefix (subseq string 0 (length prefix)))))
|
(string= prefix (subseq string 0 (length prefix)))))
|
||||||
@@ -539,8 +529,6 @@
|
|||||||
((string-prefix-p "-" line) :removed)
|
((string-prefix-p "-" line) :removed)
|
||||||
(t :context)))
|
(t :context)))
|
||||||
|
|
||||||
;; ─── Rendering ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
(defun apply-style (style text)
|
(defun apply-style (style text)
|
||||||
(let ((code (cond
|
(let ((code (cond
|
||||||
((eql style :bold) "1") ((eql style :italic) "3")
|
((eql style :bold) "1") ((eql style :italic) "3")
|
||||||
|
|||||||
@@ -39,7 +39,6 @@ Components without a layout-node or position return nil."
|
|||||||
node)))))))
|
node)))))))
|
||||||
(recurse root)))
|
(recurse root)))
|
||||||
|
|
||||||
;; Selection
|
|
||||||
(defvar *selection* nil)
|
(defvar *selection* nil)
|
||||||
|
|
||||||
(defstruct (selection (:conc-name sel-))
|
(defstruct (selection (:conc-name sel-))
|
||||||
@@ -58,8 +57,6 @@ Components without a layout-node or position return nil."
|
|||||||
:input text :wait nil)))
|
:input text :wait nil)))
|
||||||
#+darwin (sb-ext:run-program "pbcopy" nil :input text :wait nil))
|
#+darwin (sb-ext:run-program "pbcopy" nil :input text :wait nil))
|
||||||
|
|
||||||
;;; --- Selection tracking (mouse drag) ---------------------------------------
|
|
||||||
|
|
||||||
(defvar *selection-active* nil
|
(defvar *selection-active* nil
|
||||||
"T when a drag selection is in progress.")
|
"T when a drag selection is in progress.")
|
||||||
|
|
||||||
@@ -98,8 +95,6 @@ Components without a layout-node or position return nil."
|
|||||||
(setf *selection-start* nil *selection-end* nil)
|
(setf *selection-start* nil *selection-end* nil)
|
||||||
text)))
|
text)))
|
||||||
|
|
||||||
;;; --- Link clicking ---------------------------------------------------------
|
|
||||||
|
|
||||||
(defun cell-link-at (fb x y)
|
(defun cell-link-at (fb x y)
|
||||||
"Return the link URL at (X Y) in framebuffer FB, or nil."
|
"Return the link URL at (X Y) in framebuffer FB, or nil."
|
||||||
(cl-tty.rendering:fb-cell-link-url fb x y))
|
(cl-tty.rendering:fb-cell-link-url fb x y))
|
||||||
|
|||||||
@@ -7,24 +7,30 @@
|
|||||||
#:box-border-style #:box-title #:box-title-align
|
#:box-border-style #:box-title #:box-title-align
|
||||||
#:box-fg #:box-bg
|
#:box-fg #:box-bg
|
||||||
#:render-box
|
#:render-box
|
||||||
|
|
||||||
;; Span
|
;; Span
|
||||||
#:span
|
#:span
|
||||||
#:span-text #:span-bold #:span-italic #:span-underline
|
#:span-text #:span-bold #:span-italic #:span-underline
|
||||||
#:span-reverse #:span-dim #:span-fg #:span-bg
|
#:span-reverse #:span-dim #:span-fg #:span-bg
|
||||||
|
|
||||||
;; Text
|
;; Text
|
||||||
#:text #:make-text
|
#:text #:make-text
|
||||||
#:text-layout-node #:text-content #:text-spans
|
#:text-layout-node #:text-content #:text-spans
|
||||||
#:text-fg #:text-bg #:text-wrap-mode
|
#:text-fg #:text-bg #:text-wrap-mode
|
||||||
#:render-text
|
#:render-text
|
||||||
|
|
||||||
;; Utilities (for tests)
|
;; Utilities (for tests)
|
||||||
#:word-wrap #:split-string
|
#:word-wrap #:split-string
|
||||||
|
|
||||||
;; Dirty tracking
|
;; Dirty tracking
|
||||||
#:dirty-mixin #:dirty-p #:mark-clean #:mark-dirty
|
#:dirty-mixin #:dirty-p #:mark-clean #:mark-dirty
|
||||||
|
|
||||||
;; Rendering pipeline
|
;; Rendering pipeline
|
||||||
#:render #:render-screen #:render-node
|
#:render #:render-screen #:render-node
|
||||||
#:component-layout-node #:component-children #:component-parent
|
#:component-layout-node #:component-children #:component-parent
|
||||||
#:available-width #:available-height
|
#:available-width #:available-height
|
||||||
#:propagate-dirty
|
#:propagate-dirty
|
||||||
|
|
||||||
;; Theme engine
|
;; Theme engine
|
||||||
#:theme #:make-theme #:theme-mode
|
#:theme #:make-theme #:theme-mode
|
||||||
#:theme-color #:load-preset #:define-preset))
|
#:theme-color #:load-preset #:define-preset))
|
||||||
|
|||||||
@@ -3,9 +3,13 @@
|
|||||||
;; ── Component Protocol ────────────────────────────────────────
|
;; ── Component Protocol ────────────────────────────────────────
|
||||||
|
|
||||||
(defgeneric component-layout-node (component)
|
(defgeneric component-layout-node (component)
|
||||||
(:documentation "Return the layout-node for COMPONENT.")
|
(:documentation "Return the layout-node for COMPONENT."))
|
||||||
(:method ((bx box)) (box-layout-node bx))
|
|
||||||
(:method ((tx text)) (text-layout-node tx)))
|
(defmethod component-layout-node ((bx box))
|
||||||
|
(box-layout-node bx))
|
||||||
|
|
||||||
|
(defmethod component-layout-node ((tx text))
|
||||||
|
(text-layout-node tx))
|
||||||
|
|
||||||
(defgeneric component-children (component)
|
(defgeneric component-children (component)
|
||||||
(:documentation "Return the children of COMPONENT, or nil.")
|
(:documentation "Return the children of COMPONENT, or nil.")
|
||||||
|
|||||||
@@ -1,77 +1,120 @@
|
|||||||
(in-package #:cl-tty.select)
|
(in-package #:cl-tty.select)
|
||||||
|
|
||||||
(defclass select (dirty-mixin)
|
(defclass select (dirty-mixin)
|
||||||
((options :initform nil :initarg :options :accessor select-options :type list)
|
((options :initform nil :initarg :options
|
||||||
(filter :initform nil :initarg :filter :accessor select-filter :type (or string null))
|
:accessor select-options :type list)
|
||||||
(selected-index :initform 0 :initarg :selected-index :accessor select-selected-index :type fixnum)
|
(filter :initform nil :initarg :filter
|
||||||
(on-select :initform nil :initarg :on-select :accessor select-on-select)
|
:accessor select-filter :type (or string null))
|
||||||
(layout-node :initform (make-layout-node) :initarg :layout-node :accessor select-layout-node)))
|
(selected-index :initform 0 :initarg :selected-index
|
||||||
|
:accessor select-selected-index :type fixnum)
|
||||||
|
(on-select :initform nil :initarg :on-select
|
||||||
|
:accessor select-on-select)
|
||||||
|
(layout-node :initform (make-layout-node) :initarg :layout-node
|
||||||
|
:accessor select-layout-node)))
|
||||||
|
|
||||||
(defun make-select (&key options filter on-select)
|
(defun make-select (&key options filter on-select)
|
||||||
(make-instance 'select :options (or options nil) :filter filter :on-select on-select))
|
(make-instance 'select
|
||||||
|
:options (or options nil)
|
||||||
|
:filter filter
|
||||||
|
:on-select on-select))
|
||||||
|
|
||||||
(defmethod component-layout-node ((sel select)) (select-layout-node sel))
|
(defmethod component-layout-node ((sel select))
|
||||||
|
(select-layout-node sel))
|
||||||
|
|
||||||
(defun select-filtered-options (sel)
|
(defun select-filtered-options (sel)
|
||||||
(let* ((filter (select-filter sel)) (all-options (select-options sel))
|
"Return list of options matching the current filter, in display order.
|
||||||
(filtered (if (null filter) all-options
|
Each item: (display-index original-index option-plist)."
|
||||||
|
(let* ((filter (select-filter sel))
|
||||||
|
(all-options (select-options sel))
|
||||||
|
(filtered (if (null filter)
|
||||||
|
all-options
|
||||||
(let ((lower (string-downcase filter)))
|
(let ((lower (string-downcase filter)))
|
||||||
(remove-if-not
|
(remove-if-not
|
||||||
(lambda (opt)
|
(lambda (opt)
|
||||||
(or (getf opt :category)
|
(or (getf opt :category)
|
||||||
(let ((title (string-downcase (getf opt :title))))
|
(let ((title (string-downcase (getf opt :title))))
|
||||||
(or (search lower title) (fuzzy-match-p lower title)))))
|
(or (search lower title)
|
||||||
|
(fuzzy-match-p lower title)))))
|
||||||
all-options)))))
|
all-options)))))
|
||||||
(loop for opt in filtered for i from 0
|
(loop for opt in filtered
|
||||||
|
for i from 0
|
||||||
collect (list i (position opt all-options) opt))))
|
collect (list i (position opt all-options) opt))))
|
||||||
|
|
||||||
(defun fuzzy-match-p (query target)
|
(defun fuzzy-match-p (query target)
|
||||||
(let* ((q (remove-duplicates (coerce (string-downcase query) 'list)))
|
"T if character-set Jaccard similarity exceeds threshold (0.3)."
|
||||||
(tg (remove-duplicates (coerce (string-downcase target) 'list)))
|
(let* ((q-chars (remove-duplicates (coerce (string-downcase query) 'list)))
|
||||||
(intersection (length (intersection q tg)))
|
(t-chars (remove-duplicates (coerce (string-downcase target) 'list)))
|
||||||
(union (length (union q tg))))
|
(intersection (length (intersection q-chars t-chars)))
|
||||||
|
(union (length (union q-chars t-chars))))
|
||||||
(if (zerop union) nil (> (/ (float intersection) union) 0.3))))
|
(if (zerop union) nil (> (/ (float intersection) union) 0.3))))
|
||||||
|
|
||||||
(defun select-clamp-index (sel)
|
(defun select-clamp-index (sel)
|
||||||
(let* ((filtered (select-filtered-options sel)) (count (length filtered)))
|
"Ensure selected-index is valid. Wraps if empty."
|
||||||
(if (zerop count) (setf (select-selected-index sel) 0)
|
(let* ((filtered (select-filtered-options sel))
|
||||||
(setf (select-selected-index sel) (max 0 (min (select-selected-index sel) (1- count)))))))
|
(count (length filtered)))
|
||||||
|
(if (zerop count)
|
||||||
|
(setf (select-selected-index sel) 0)
|
||||||
|
(setf (select-selected-index sel)
|
||||||
|
(max 0 (min (select-selected-index sel) (1- count)))))))
|
||||||
|
|
||||||
(defun select-next (sel)
|
(defun select-next (sel)
|
||||||
(let* ((filtered (select-filtered-options sel)) (count (length filtered))
|
"Move selection to next non-category option. Wraps at end."
|
||||||
|
(let* ((filtered (select-filtered-options sel))
|
||||||
|
(count (length filtered))
|
||||||
(current (select-selected-index sel)))
|
(current (select-selected-index sel)))
|
||||||
(when (plusp count)
|
(when (plusp count)
|
||||||
(loop for i from 1 below count
|
(loop for i from 1 below count
|
||||||
for idx = (mod (+ current i) count)
|
for idx = (mod (+ current i) count)
|
||||||
for opt = (third (nth idx filtered))
|
for opt = (third (nth idx filtered))
|
||||||
when (not (getf opt :category))
|
when (not (getf opt :category))
|
||||||
do (setf (select-selected-index sel) idx) (mark-dirty sel) (return)))))
|
do (setf (select-selected-index sel) idx)
|
||||||
|
(mark-dirty sel)
|
||||||
|
(return)))))
|
||||||
|
|
||||||
(defun select-prev (sel)
|
(defun select-prev (sel)
|
||||||
(let* ((filtered (select-filtered-options sel)) (count (length filtered))
|
"Move selection to previous non-category option. Wraps at start."
|
||||||
|
(let* ((filtered (select-filtered-options sel))
|
||||||
|
(count (length filtered))
|
||||||
(current (select-selected-index sel)))
|
(current (select-selected-index sel)))
|
||||||
(when (plusp count)
|
(when (plusp count)
|
||||||
(loop for i from 1 below count
|
(loop for i from 1 below count
|
||||||
for idx = (mod (- current i) count)
|
for idx = (mod (- current i) count)
|
||||||
for opt = (third (nth idx filtered))
|
for opt = (third (nth idx filtered))
|
||||||
when (not (getf opt :category))
|
when (not (getf opt :category))
|
||||||
do (setf (select-selected-index sel) idx) (mark-dirty sel) (return)))))
|
do (setf (select-selected-index sel) idx)
|
||||||
|
(mark-dirty sel)
|
||||||
|
(return)))))
|
||||||
|
|
||||||
(defun select-handle-key (sel event)
|
(defun select-handle-key (sel event)
|
||||||
(let ((key (key-event-key event)) (ctrl (key-event-ctrl event)))
|
"Handle a key-event. Returns T if handled."
|
||||||
|
(let ((key (key-event-key event))
|
||||||
|
(ctrl (key-event-ctrl event)))
|
||||||
(cond
|
(cond
|
||||||
((or (eql key :down) (and ctrl (eql key :n))) (select-next sel) t)
|
((or (eql key :down) (and ctrl (eql key :n)))
|
||||||
((or (eql key :up) (and ctrl (eql key :p))) (select-prev sel) t)
|
(select-next sel) t)
|
||||||
|
((or (eql key :up) (and ctrl (eql key :p)))
|
||||||
|
(select-prev sel) t)
|
||||||
((eql key :enter)
|
((eql key :enter)
|
||||||
(let* ((filtered (select-filtered-options sel)) (idx (select-selected-index sel))
|
(let* ((filtered (select-filtered-options sel))
|
||||||
(item (when (< idx (length filtered)) (third (nth idx filtered)))))
|
(idx (select-selected-index sel))
|
||||||
(when item (let ((cb (select-on-select sel))) (when cb (funcall cb item)))) t))
|
(item (when (< idx (length filtered))
|
||||||
((eql key :escape) nil) (t nil))))
|
(third (nth idx filtered)))))
|
||||||
|
(when item
|
||||||
|
(let ((cb (select-on-select sel)))
|
||||||
|
(when cb (funcall cb item))))
|
||||||
|
t))
|
||||||
|
((eql key :escape) nil)
|
||||||
|
(t nil))))
|
||||||
|
|
||||||
(defun select-visible-options (sel)
|
(defun select-visible-options (sel)
|
||||||
(let* ((ln (select-layout-node sel)) (height (if ln (layout-node-height ln) 80))
|
"Return filtered options that fit within the viewport."
|
||||||
(filtered (select-filtered-options sel)) (sel-idx (select-selected-index sel))
|
(let* ((ln (select-layout-node sel))
|
||||||
(half (floor (1- height) 2)) (start (max 0 (- sel-idx half)))
|
(height (if ln (layout-node-height ln) 80))
|
||||||
|
(filtered (select-filtered-options sel))
|
||||||
|
(sel-idx (select-selected-index sel))
|
||||||
|
;; Show items around the selection
|
||||||
|
(half (floor (1- height) 2))
|
||||||
|
(start (max 0 (- sel-idx half)))
|
||||||
(end (min (length filtered) (+ start height))))
|
(end (min (length filtered) (+ start height))))
|
||||||
(subseq filtered start end)))
|
(subseq filtered start end)))
|
||||||
|
|
||||||
@@ -80,17 +123,24 @@
|
|||||||
(x (if ln (layout-node-x ln) 0))
|
(x (if ln (layout-node-x ln) 0))
|
||||||
(y (if ln (layout-node-y ln) 0))
|
(y (if ln (layout-node-y ln) 0))
|
||||||
(w (if ln (layout-node-width ln) 80))
|
(w (if ln (layout-node-width ln) 80))
|
||||||
(visible (select-visible-options sel)) (sel-idx (select-selected-index sel)))
|
(visible (select-visible-options sel))
|
||||||
|
(sel-idx (select-selected-index sel)))
|
||||||
(dolist (item visible)
|
(dolist (item visible)
|
||||||
(let* ((display-idx (first item)) (option (third item))
|
(let* ((display-idx (first item))
|
||||||
(title (getf option :title)) (cat (getf option :category))
|
(option (third item))
|
||||||
(selected (eql display-idx sel-idx))
|
(title (getf option :title))
|
||||||
|
(is-category (getf option :category))
|
||||||
|
(is-selected (eql display-idx sel-idx))
|
||||||
(display (if (> (length title) (1- w))
|
(display (if (> (length title) (1- w))
|
||||||
(concatenate 'string (subseq title 0 (1- w)) "…") title)))
|
(concatenate 'string (subseq title 0 (1- w)) "…")
|
||||||
(cond (cat (draw-text backend x y display :text-muted nil))
|
title)))
|
||||||
(selected
|
(cond
|
||||||
|
(is-category
|
||||||
|
(draw-text backend x y display :text-muted nil))
|
||||||
|
(is-selected
|
||||||
(draw-rect backend x y w 1 :bg :accent)
|
(draw-rect backend x y w 1 :bg :accent)
|
||||||
(draw-text backend x y display :background :accent))
|
(draw-text backend x y display :background :accent))
|
||||||
(t (draw-text backend x y display nil nil)))
|
(t
|
||||||
|
(draw-text backend x y display nil nil)))
|
||||||
(incf y 1)))
|
(incf y 1)))
|
||||||
(values)))
|
(values)))
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
(in-package :cl-tty.box)
|
(in-package :cl-tty.box)
|
||||||
|
|
||||||
;; ── Text Renderable ────────────────────────────────────────────
|
|
||||||
|
|
||||||
(defclass span ()
|
(defclass span ()
|
||||||
((text :initarg :text :accessor span-text)
|
((text :initarg :text :accessor span-text)
|
||||||
(bold :initform nil :initarg :bold :accessor span-bold)
|
(bold :initform nil :initarg :bold :accessor span-bold)
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
(in-package #:cl-tty.input)
|
(in-package #:cl-tty.input)
|
||||||
|
|
||||||
;;; ---------------------------------------------------------------------------
|
|
||||||
;;; Textarea class
|
|
||||||
;;; ---------------------------------------------------------------------------
|
|
||||||
(defclass textarea (dirty-mixin)
|
(defclass textarea (dirty-mixin)
|
||||||
((value :initform "" :initarg :value :accessor textarea-value :type string)
|
((value :initform "" :initarg :value :accessor textarea-value :type string)
|
||||||
(cursor-row :initform 0 :accessor textarea-cursor-row :type fixnum)
|
(cursor-row :initform 0 :accessor textarea-cursor-row :type fixnum)
|
||||||
@@ -21,9 +18,6 @@
|
|||||||
:value (or value "")
|
:value (or value "")
|
||||||
:on-submit on-submit))
|
:on-submit on-submit))
|
||||||
|
|
||||||
;;; ---------------------------------------------------------------------------
|
|
||||||
;;; Line helpers
|
|
||||||
;;; ---------------------------------------------------------------------------
|
|
||||||
(defun textarea-lines (ta)
|
(defun textarea-lines (ta)
|
||||||
"Split value into lines."
|
"Split value into lines."
|
||||||
(%split-string (textarea-value ta) #\Newline))
|
(%split-string (textarea-value ta) #\Newline))
|
||||||
@@ -42,9 +36,6 @@
|
|||||||
(max 0 (min (textarea-cursor-col ta) line-len)))))
|
(max 0 (min (textarea-cursor-col ta) line-len)))))
|
||||||
(mark-dirty ta))
|
(mark-dirty ta))
|
||||||
|
|
||||||
;;; ---------------------------------------------------------------------------
|
|
||||||
;;; Utility: join strings with newline
|
|
||||||
;;; ---------------------------------------------------------------------------
|
|
||||||
(defun %join-lines (lines)
|
(defun %join-lines (lines)
|
||||||
"Join a sequence of strings with newlines."
|
"Join a sequence of strings with newlines."
|
||||||
(with-output-to-string (s)
|
(with-output-to-string (s)
|
||||||
@@ -53,9 +44,6 @@
|
|||||||
do (unless first (write-char #\Newline s))
|
do (unless first (write-char #\Newline s))
|
||||||
(write-string line s))))
|
(write-string line s))))
|
||||||
|
|
||||||
;;; ---------------------------------------------------------------------------
|
|
||||||
;;; Text manipulation
|
|
||||||
;;; ---------------------------------------------------------------------------
|
|
||||||
(defun textarea-insert-char (ta char)
|
(defun textarea-insert-char (ta char)
|
||||||
"Insert CHAR at the cursor position."
|
"Insert CHAR at the cursor position."
|
||||||
(textarea-push-undo ta)
|
(textarea-push-undo ta)
|
||||||
@@ -141,9 +129,6 @@
|
|||||||
(decf (textarea-cursor-col ta))
|
(decf (textarea-cursor-col ta))
|
||||||
(mark-dirty ta))))))
|
(mark-dirty ta))))))
|
||||||
|
|
||||||
;;; ---------------------------------------------------------------------------
|
|
||||||
;;; Cursor movement
|
|
||||||
;;; ---------------------------------------------------------------------------
|
|
||||||
(defun textarea-move-up (ta)
|
(defun textarea-move-up (ta)
|
||||||
(decf (textarea-cursor-row ta))
|
(decf (textarea-cursor-row ta))
|
||||||
(textarea-ensure-cursor ta))
|
(textarea-ensure-cursor ta))
|
||||||
@@ -152,9 +137,6 @@
|
|||||||
(incf (textarea-cursor-row ta))
|
(incf (textarea-cursor-row ta))
|
||||||
(textarea-ensure-cursor ta))
|
(textarea-ensure-cursor ta))
|
||||||
|
|
||||||
;;; ---------------------------------------------------------------------------
|
|
||||||
;;; Undo/redo
|
|
||||||
;;; ---------------------------------------------------------------------------
|
|
||||||
(defun textarea-push-undo (ta)
|
(defun textarea-push-undo (ta)
|
||||||
"Save current value on undo stack."
|
"Save current value on undo stack."
|
||||||
(let ((stack (textarea-undo-stack ta)))
|
(let ((stack (textarea-undo-stack ta)))
|
||||||
@@ -183,9 +165,6 @@
|
|||||||
(textarea-ensure-cursor ta)
|
(textarea-ensure-cursor ta)
|
||||||
(mark-dirty ta)))))
|
(mark-dirty ta)))))
|
||||||
|
|
||||||
;;; ---------------------------------------------------------------------------
|
|
||||||
;;; Key event handler
|
|
||||||
;;; ---------------------------------------------------------------------------
|
|
||||||
(defun handle-textarea-input (ta event)
|
(defun handle-textarea-input (ta event)
|
||||||
"Process a key-event on a textarea widget."
|
"Process a key-event on a textarea widget."
|
||||||
(cond
|
(cond
|
||||||
@@ -239,9 +218,6 @@
|
|||||||
(when (and ch (graphic-char-p ch))
|
(when (and ch (graphic-char-p ch))
|
||||||
(textarea-insert-char ta ch))))))))
|
(textarea-insert-char ta ch))))))))
|
||||||
|
|
||||||
;;; ---------------------------------------------------------------------------
|
|
||||||
;;; Rendering
|
|
||||||
;;; ---------------------------------------------------------------------------
|
|
||||||
(defmethod render ((ta textarea) (backend t))
|
(defmethod render ((ta textarea) (backend t))
|
||||||
"Render textarea lines at layout position."
|
"Render textarea lines at layout position."
|
||||||
(let* ((ln (textarea-layout-node ta))
|
(let* ((ln (textarea-layout-node ta))
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
(in-package :cl-tty.box)
|
(in-package :cl-tty.box)
|
||||||
|
|
||||||
;; ── Theme Engine ──────────────────────────────────────────────
|
|
||||||
|
|
||||||
(defclass theme ()
|
(defclass theme ()
|
||||||
((mode :initform :dark :initarg :mode :accessor theme-mode)
|
((mode :initform :dark :initarg :mode :accessor theme-mode)
|
||||||
(roles :initform (make-hash-table) :accessor theme-roles)))
|
(roles :initform (make-hash-table) :accessor theme-roles)))
|
||||||
|
|||||||
@@ -119,8 +119,6 @@
|
|||||||
(is (= (layout-node-y (elt sc 0)) 0))
|
(is (= (layout-node-y (elt sc 0)) 0))
|
||||||
(is (= (layout-node-y (elt sc 1)) 3)))))
|
(is (= (layout-node-y (elt sc 1)) 3)))))
|
||||||
|
|
||||||
;; ── Edge Cases ────────────────────────────────────────────────
|
|
||||||
|
|
||||||
(test empty-container-does-not-crash
|
(test empty-container-does-not-crash
|
||||||
(let ((r (make-layout-node)))
|
(let ((r (make-layout-node)))
|
||||||
(compute-layout r 20 20)
|
(compute-layout r 20 20)
|
||||||
|
|||||||
@@ -12,8 +12,6 @@
|
|||||||
|
|
||||||
(in-package :cl-tty.rendering)
|
(in-package :cl-tty.rendering)
|
||||||
|
|
||||||
;;; ─── Cell — immutable per-cell state ─────────────────────────────────────────
|
|
||||||
|
|
||||||
(defstruct cell
|
(defstruct cell
|
||||||
"A single terminal cell — character, colors, and attributes."
|
"A single terminal cell — character, colors, and attributes."
|
||||||
(char #\space :type character)
|
(char #\space :type character)
|
||||||
@@ -24,8 +22,6 @@
|
|||||||
(underline nil :type boolean)
|
(underline nil :type boolean)
|
||||||
(link-url nil))
|
(link-url nil))
|
||||||
|
|
||||||
;;; ─── Framebuffer — 2D array of cells ────────────────────────────────────────
|
|
||||||
|
|
||||||
(defun make-framebuffer (width height)
|
(defun make-framebuffer (width height)
|
||||||
"Create a 2D array of CELL with dimensions HEIGHT x WIDTH."
|
"Create a 2D array of CELL with dimensions HEIGHT x WIDTH."
|
||||||
(make-array (list height width)
|
(make-array (list height width)
|
||||||
@@ -40,8 +36,6 @@
|
|||||||
"Return the height (rows) of framebuffer FB."
|
"Return the height (rows) of framebuffer FB."
|
||||||
(if (arrayp fb) (array-dimension fb 0) 0))
|
(if (arrayp fb) (array-dimension fb 0) 0))
|
||||||
|
|
||||||
;;; ─── Framebuffer Backend — implements backend protocol ─────────────────────
|
|
||||||
|
|
||||||
(defclass framebuffer-backend (backend)
|
(defclass framebuffer-backend (backend)
|
||||||
((framebuffer :initform nil :accessor fb-framebuffer)
|
((framebuffer :initform nil :accessor fb-framebuffer)
|
||||||
(scissor-x :initform 0 :accessor fb-scissor-x)
|
(scissor-x :initform 0 :accessor fb-scissor-x)
|
||||||
@@ -55,8 +49,6 @@
|
|||||||
(setf (fb-framebuffer fb) (make-framebuffer width height))
|
(setf (fb-framebuffer fb) (make-framebuffer width height))
|
||||||
fb))
|
fb))
|
||||||
|
|
||||||
;;; ─── Drawing methods ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
(defun %in-scissor-p (fb cx cy)
|
(defun %in-scissor-p (fb cx cy)
|
||||||
"Check if (CX, CY) falls within the current scissor rectangle."
|
"Check if (CX, CY) falls within the current scissor rectangle."
|
||||||
(let ((sx (fb-scissor-x fb)) (sy (fb-scissor-y fb))
|
(let ((sx (fb-scissor-x fb)) (sy (fb-scissor-y fb))
|
||||||
@@ -129,8 +121,6 @@
|
|||||||
(dotimes (i (min 3 width))
|
(dotimes (i (min 3 width))
|
||||||
(%set-cell fb (+ x i) y #\. :fg fg :bg bg)))
|
(%set-cell fb (+ x i) y #\. :fg fg :bg bg)))
|
||||||
|
|
||||||
;;; ─── Diff ────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
(defun cells-equal-p (a b)
|
(defun cells-equal-p (a b)
|
||||||
"Return T if two cells have identical content and style."
|
"Return T if two cells have identical content and style."
|
||||||
(and (eql (cell-char a) (cell-char b))
|
(and (eql (cell-char a) (cell-char b))
|
||||||
@@ -153,8 +143,6 @@
|
|||||||
(push (list x y b) changes)))))
|
(push (list x y b) changes)))))
|
||||||
(nreverse changes)))
|
(nreverse changes)))
|
||||||
|
|
||||||
;;; ─── Flush ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
(defun flush-framebuffer (prev-fb curr-fb backend)
|
(defun flush-framebuffer (prev-fb curr-fb backend)
|
||||||
"Diff PREV-FB and CURR-FB and flush changes to BACKEND.
|
"Diff PREV-FB and CURR-FB and flush changes to BACKEND.
|
||||||
Returns the number of changed cells."
|
Returns the number of changed cells."
|
||||||
@@ -176,8 +164,6 @@ Returns the number of changed cells."
|
|||||||
(end-sync backend))
|
(end-sync backend))
|
||||||
count))
|
count))
|
||||||
|
|
||||||
;;; --- Frame inspection ---------------------------------------------------
|
|
||||||
|
|
||||||
(defun fb-cell-link-url (fb x y)
|
(defun fb-cell-link-url (fb x y)
|
||||||
"Return the link URL at (X Y) in framebuffer FB, or nil."
|
"Return the link URL at (X Y) in framebuffer FB, or nil."
|
||||||
(when (and (arrayp fb) (>= y 0) (< y (array-dimension fb 0))
|
(when (and (arrayp fb) (>= y 0) (< y (array-dimension fb 0))
|
||||||
@@ -198,8 +184,6 @@ Returns the number of changed cells."
|
|||||||
(princ (cell-char c) s)))
|
(princ (cell-char c) s)))
|
||||||
(when (< y y-max) (princ #\Newline s))))))
|
(when (< y y-max) (princ #\Newline s))))))
|
||||||
|
|
||||||
;;; ─── Scissor clipping ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
(defmacro with-scissor ((fb x y w h) &body body)
|
(defmacro with-scissor ((fb x y w h) &body body)
|
||||||
"Clip all drawing on FB to rectangle (X Y W H)."
|
"Clip all drawing on FB to rectangle (X Y W H)."
|
||||||
(let ((old-x (gensym)) (old-y (gensym))
|
(let ((old-x (gensym)) (old-y (gensym))
|
||||||
|
|||||||
@@ -190,14 +190,11 @@
|
|||||||
(handle-textarea-input a (make-key-event :key :a :code (char-code #\a)))
|
(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 :enter))
|
||||||
(handle-textarea-input a (make-key-event :key :b :code (char-code #\b)))
|
(handle-textarea-input a (make-key-event :key :b :code (char-code #\b)))
|
||||||
(is (string= (textarea-value a) "a
|
(is (string= (textarea-value a) (format nil "a~Cb" #\Newline)))))
|
||||||
b"))))
|
|
||||||
|
|
||||||
(test textarea-cursor-up-down
|
(test textarea-cursor-up-down
|
||||||
"Cursor moves between lines maintaining column position."
|
"Cursor moves between lines maintaining column position."
|
||||||
(let ((a (make-textarea :value "abc
|
(let ((a (make-textarea :value (format nil "abc~Cde~Cfghi" #\Newline #\Newline))))
|
||||||
de
|
|
||||||
fghi")))
|
|
||||||
(setf (textarea-cursor-row a) 1)
|
(setf (textarea-cursor-row a) 1)
|
||||||
(setf (textarea-cursor-col a) 1)
|
(setf (textarea-cursor-col a) 1)
|
||||||
(handle-textarea-input a (make-key-event :key :up))
|
(handle-textarea-input a (make-key-event :key :up))
|
||||||
@@ -209,8 +206,7 @@ fghi")))
|
|||||||
|
|
||||||
(test textarea-cursor-up-down-bounds
|
(test textarea-cursor-up-down-bounds
|
||||||
"Cursor cannot move past first or last line."
|
"Cursor cannot move past first or last line."
|
||||||
(let ((a (make-textarea :value "a
|
(let ((a (make-textarea :value (format nil "a~Cb" #\Newline))))
|
||||||
b")))
|
|
||||||
(handle-textarea-input a (make-key-event :key :up))
|
(handle-textarea-input a (make-key-event :key :up))
|
||||||
(is (= (textarea-cursor-row a) 0))
|
(is (= (textarea-cursor-row a) 0))
|
||||||
(setf (textarea-cursor-row a) 1)
|
(setf (textarea-cursor-row a) 1)
|
||||||
@@ -219,8 +215,7 @@ b")))
|
|||||||
|
|
||||||
(test textarea-backspace-joins-lines
|
(test textarea-backspace-joins-lines
|
||||||
"Backspace at start of a line joins with previous."
|
"Backspace at start of a line joins with previous."
|
||||||
(let ((a (make-textarea :value "hello
|
(let ((a (make-textarea :value (format nil "hello~Cworld" #\Newline))))
|
||||||
world")))
|
|
||||||
(setf (textarea-cursor-row a) 1)
|
(setf (textarea-cursor-row a) 1)
|
||||||
(setf (textarea-cursor-col a) 0)
|
(setf (textarea-cursor-col a) 0)
|
||||||
(handle-textarea-input a (make-key-event :key :backspace))
|
(handle-textarea-input a (make-key-event :key :backspace))
|
||||||
|
|||||||
@@ -18,8 +18,6 @@
|
|||||||
(setf cl-tty.mouse::*selection* (make-selection :text "hello"))
|
(setf cl-tty.mouse::*selection* (make-selection :text "hello"))
|
||||||
(is (equal "hello" (get-selection))))
|
(is (equal "hello" (get-selection))))
|
||||||
|
|
||||||
;; ── Selection tracking ──────────────────────────────────────
|
|
||||||
|
|
||||||
(def-test start-selection-initializes-state ()
|
(def-test start-selection-initializes-state ()
|
||||||
(start-selection 5 10)
|
(start-selection 5 10)
|
||||||
(is-true (selection-active-p))
|
(is-true (selection-active-p))
|
||||||
|
|||||||
@@ -11,8 +11,6 @@
|
|||||||
(fiveam:explain! result)
|
(fiveam:explain! result)
|
||||||
(uiop:quit 0)))
|
(uiop:quit 0)))
|
||||||
|
|
||||||
;; ── ScrollBox Tests ─────────────────────────────────────────────
|
|
||||||
|
|
||||||
(test scrollbox-creates
|
(test scrollbox-creates
|
||||||
"A ScrollBox can be created with defaults."
|
"A ScrollBox can be created with defaults."
|
||||||
(let ((sb (make-scroll-box)))
|
(let ((sb (make-scroll-box)))
|
||||||
@@ -46,8 +44,6 @@
|
|||||||
(render sb backend)
|
(render sb backend)
|
||||||
(is-true t)))
|
(is-true t)))
|
||||||
|
|
||||||
;; ── TabBar Tests ────────────────────────────────────────────────
|
|
||||||
|
|
||||||
(test tabbar-creates
|
(test tabbar-creates
|
||||||
"A TabBar can be created with defaults."
|
"A TabBar can be created with defaults."
|
||||||
(let ((tb (make-tab-bar)))
|
(let ((tb (make-tab-bar)))
|
||||||
|
|||||||
Reference in New Issue
Block a user