From b0e5c182579a65335cf4b2cdf68f1b29d4548468 Mon Sep 17 00:00:00 2001 From: Hermes Date: Mon, 11 May 2026 15:12:38 +0000 Subject: [PATCH] =?UTF-8?q?v0.3.0:=20Rendering=20pipeline=20=E2=80=94=20re?= =?UTF-8?q?nder=20dispatch,=20tree=20walk,=20dirty=20propagation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- cl-tui.asd | 6 ++- src/components/box.lisp | 2 +- src/components/package.lisp | 7 +++- src/components/render-tests.lisp | 47 ++++++++++++++++++++++ src/components/render.lisp | 67 ++++++++++++++++++++++++++++++++ src/components/text.lisp | 2 +- 6 files changed, 126 insertions(+), 5 deletions(-) create mode 100644 src/components/render-tests.lisp create mode 100644 src/components/render.lisp diff --git a/cl-tui.asd b/cl-tui.asd index dd940e8..2bc9f16 100644 --- a/cl-tui.asd +++ b/cl-tui.asd @@ -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/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..52779ac --- /dev/null +++ b/src/components/render-tests.lisp @@ -0,0 +1,47 @@ +(in-package :cl-tui-box-test) + +(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..d5aadc2 --- /dev/null +++ b/src/components/render.lisp @@ -0,0 +1,67 @@ +(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 ((bx box)) nil) + (:method ((tx text)) 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)