diff --git a/cl-tui.asd b/cl-tui.asd index dd940e8..3ccc036 100644 --- a/cl-tui.asd +++ b/cl-tui.asd @@ -2,7 +2,7 @@ (asdf:defsystem :cl-tui :description "Reusable Common Lisp Terminal UI Framework" :author "Amr Gharbeia" - :version "0.2.0" + :version "0.3.0" :license "TBD" :depends-on (:fiveam) :components @@ -20,7 +20,8 @@ ((:file "package") (:file "dirty") (: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)))) (asdf:defsystem :cl-tui-tests @@ -36,6 +37,7 @@ (:module "src/components" :components ((:file "box-tests") - (:file "dirty-tests")))) + (:file "dirty-tests") + (:file "render-tests")))) :perform (test-op (o c) (uiop:symbol-call :cl-tui-backend-test '#:run-tests))) diff --git a/src/components/box.lisp b/src/components/box.lisp index f85b20d..bfe5eb7 100644 --- a/src/components/box.lisp +++ b/src/components/box.lisp @@ -1,6 +1,6 @@ (in-package :cl-tui.box) -(defclass box () +(defclass box (dirty-mixin) ((layout-node :initform (make-layout-node) :accessor box-layout-node :initarg :layout-node) (border-style :initform :single :initarg :border-style diff --git a/src/components/dirty-tests.lisp b/src/components/dirty-tests.lisp index c6a4d56..89b6bac 100644 --- a/src/components/dirty-tests.lisp +++ b/src/components/dirty-tests.lisp @@ -1,5 +1,6 @@ ;; Dirty tracking tests are in box-tests.lisp (same test suite) (in-package :cl-tui-box-test) +(in-suite box-suite) (test dirty-mixin-default-is-dirty "A dirty-mixin starts as dirty" diff --git a/src/components/package.lisp b/src/components/package.lisp index e9b7ff9..34cdfd3 100644 --- a/src/components/package.lisp +++ b/src/components/package.lisp @@ -19,5 +19,10 @@ ;; Utilities (for tests) #:word-wrap #:split-string ;; 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) diff --git a/src/components/render-tests.lisp b/src/components/render-tests.lisp new file mode 100644 index 0000000..f0f552c --- /dev/null +++ b/src/components/render-tests.lisp @@ -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)))) diff --git a/src/components/render.lisp b/src/components/render.lisp new file mode 100644 index 0000000..85b17e7 --- /dev/null +++ b/src/components/render.lisp @@ -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)))) diff --git a/src/components/text.lisp b/src/components/text.lisp index 6678f67..9a74bbf 100644 --- a/src/components/text.lisp +++ b/src/components/text.lisp @@ -18,7 +18,7 @@ :underline underline :reverse reverse :dim dim :fg fg :bg bg)) -(defclass text () +(defclass text (dirty-mixin) ((layout-node :initform (make-layout-node) :accessor text-layout-node :initarg :layout-node) (content :initform "" :initarg :content :accessor text-content)