2 Commits

Author SHA1 Message Date
Hermes
6ba69f4610 review fixes: in-suite, version bump, default children method
Fixes from subagent review:
- render-tests.lisp: added (in-suite box-suite) — tests were registered
  to default suite, never executed by runner
- dirty-tests.lisp: same fix
- cl-tui.asd: version 0.2.0 → 0.3.0
- render.lisp: component-children default method (c t) nil for
  protocol completeness (component-parent already had this)
2026-05-11 15:16:59 +00:00
Hermes
b0e5c18257 v0.3.0: Rendering pipeline — render dispatch, tree walk, dirty propagation
- render generic function dispatches per component type
- render-screen entry point with sync wrapper
- render-node walks tree, computes layout, calls render
- component-layout-node generic (box/text methods)
- component-children/component-parent generics
- propagate-dirty marks component + ancestors dirty
- box and text now inherit from dirty-mixin
- 6 new tests: render dispatch, layout-node accessor, children,
  dirty propagation, available-width defaults
- 42 component tests, 100% GREEN
2026-05-11 15:12:38 +00:00
7 changed files with 128 additions and 6 deletions

View File

@@ -2,7 +2,7 @@
(asdf:defsystem :cl-tui (asdf:defsystem :cl-tui
:description "Reusable Common Lisp Terminal UI Framework" :description "Reusable Common Lisp Terminal UI Framework"
:author "Amr Gharbeia" :author "Amr Gharbeia"
:version "0.2.0" :version "0.3.0"
:license "TBD" :license "TBD"
:depends-on (:fiveam) :depends-on (:fiveam)
:components :components
@@ -20,7 +20,8 @@
((:file "package") ((:file "package")
(:file "dirty") (:file "dirty")
(:file "box" :depends-on ("package")) (:file "box" :depends-on ("package"))
(:file "text" :depends-on ("package" "box"))))) (:file "text" :depends-on ("package" "box"))
(:file "render" :depends-on ("package" "box" "text")))))
:in-order-to ((test-op (test-op :cl-tui-tests)))) :in-order-to ((test-op (test-op :cl-tui-tests))))
(asdf:defsystem :cl-tui-tests (asdf:defsystem :cl-tui-tests
@@ -36,6 +37,7 @@
(:module "src/components" (:module "src/components"
:components :components
((:file "box-tests") ((:file "box-tests")
(:file "dirty-tests")))) (:file "dirty-tests")
(:file "render-tests"))))
:perform (test-op (o c) :perform (test-op (o c)
(uiop:symbol-call :cl-tui-backend-test '#:run-tests))) (uiop:symbol-call :cl-tui-backend-test '#:run-tests)))

View File

@@ -1,6 +1,6 @@
(in-package :cl-tui.box) (in-package :cl-tui.box)
(defclass box () (defclass box (dirty-mixin)
((layout-node :initform (make-layout-node) :accessor box-layout-node ((layout-node :initform (make-layout-node) :accessor box-layout-node
:initarg :layout-node) :initarg :layout-node)
(border-style :initform :single :initarg :border-style (border-style :initform :single :initarg :border-style

View File

@@ -1,5 +1,6 @@
;; Dirty tracking tests are in box-tests.lisp (same test suite) ;; Dirty tracking tests are in box-tests.lisp (same test suite)
(in-package :cl-tui-box-test) (in-package :cl-tui-box-test)
(in-suite box-suite)
(test dirty-mixin-default-is-dirty (test dirty-mixin-default-is-dirty
"A dirty-mixin starts as dirty" "A dirty-mixin starts as dirty"

View File

@@ -19,5 +19,10 @@
;; 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
#:render #:render-screen #:render-node
#:component-layout-node #:component-children #:component-parent
#:available-width #:available-height
#:propagate-dirty))
(in-package :cl-tui.box) (in-package :cl-tui.box)

View File

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

View File

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

View File

@@ -18,7 +18,7 @@
:underline underline :reverse reverse :dim dim :underline underline :reverse reverse :dim dim
:fg fg :bg bg)) :fg fg :bg bg))
(defclass text () (defclass text (dirty-mixin)
((layout-node :initform (make-layout-node) :accessor text-layout-node ((layout-node :initform (make-layout-node) :accessor text-layout-node
:initarg :layout-node) :initarg :layout-node)
(content :initform "" :initarg :content :accessor text-content) (content :initform "" :initarg :content :accessor text-content)