From 56682d0cc2a89fd42a2b4e0b6b7a90f1725c1163 Mon Sep 17 00:00:00 2001 From: Amr Gharbeia Date: Mon, 11 May 2026 08:08:15 -0400 Subject: [PATCH] =?UTF-8?q?v0.1.0:=20Layout=20primitives=20+=20composable?= =?UTF-8?q?=20API=20=E2=80=94=2023=20new=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Layout primitives (org/layout-primitives.org, ~290 lines): - CLOS layout-node class wrapping YGNodeRef with tg:finalize for GC cleanup - 12 setter functions with keyword→integer enum translation - layout-calculate: runs Yoga layout, returns root - 14 tests: dimension, direction, flex-grow, align, justify, padding, margin, absolute position, wrap, gap, nested layout Composable API (org/layout-composable.org, ~200 lines): - vbox/hbox macros: declarative container creation with style props - overlay macro: absolute-positioned child over relative base - spacer function: flex-grow filler - make-props-list helper: extracts plist minus :children - 6 tests: vbox stacking, hbox layout, spacer flex, overlay position, align/justify via vbox, padding offset GREEN: 29/29 pass (59 assertions) - yoga-ffi: 9 tests (22 assertions) - layout-primitives: 14 tests (24 assertions) - layout-composable: 6 tests (13 assertions) --- cl-tui.asd | 6 +- docs/ROADMAP.org | 10 +- lisp/layout-composable.lisp | 194 ++++++++++++ lisp/layout-primitives.lisp | 511 ++++++++++++++++++++++++++++++ org/layout-composable.org | 248 +++++++++++++++ org/layout-primitives.org | 608 ++++++++++++++++++++++++++++++++++++ 6 files changed, 1573 insertions(+), 4 deletions(-) create mode 100644 lisp/layout-composable.lisp create mode 100644 lisp/layout-primitives.lisp create mode 100644 org/layout-composable.org create mode 100644 org/layout-primitives.org diff --git a/cl-tui.asd b/cl-tui.asd index 3484e26..cece1c7 100644 --- a/cl-tui.asd +++ b/cl-tui.asd @@ -4,6 +4,8 @@ :version "0.1.0" :license "AGPLv3" :description "Reusable Common Lisp Terminal UI Framework" - :depends-on (:cffi :croatoan) + :depends-on (:cffi :croatoan :trivial-garbage) :serial t - :components ((:file "lisp/yoga-ffi"))) + :components ((:file "lisp/yoga-ffi") + (:file "lisp/layout-primitives") + (:file "lisp/layout-composable"))) diff --git a/docs/ROADMAP.org b/docs/ROADMAP.org index c4687de..ecb9ebc 100644 --- a/docs/ROADMAP.org +++ b/docs/ROADMAP.org @@ -37,11 +37,14 @@ every component after v0.1.0 uses the layout engine for positioning. - Bind core functions: ~node-new~, ~node-free~, ~node-style-set-*~, ~node-layout-get-*~, ~calculate-layout~ - ~100 lines CFFI -*** TODO Layout primitives +*** DONE Layout primitives :PROPERTIES: :ID: id-v010-layout-primitives :CREATED: [2026-05-10 Sat] :END: +:LOGBOOK: +- State "DONE" from "TODO" [2026-05-11 Mon] +:END: - ~(make-layout-node)~ — wraps a ~YGNodeRef~ in a CLOS object - ~(layout-node-set-dimension node width height)~ — sets width/height in points @@ -59,11 +62,14 @@ every component after v0.1.0 uses the layout engine for positioning. - ~(layout-calculate root width height)~ — runs Yoga's calculateLayout, populates each node's computed x/y/w/h - ~200 lines CL -*** TODO Layout composable API +*** DONE Layout composable API :PROPERTIES: :ID: id-v010-layout-composable :CREATED: [2026-05-10 Sat] :END: +:LOGBOOK: +- State "DONE" from "TODO" [2026-05-11 Mon] +:END: Convenience macros to build layout trees from CL function calls: diff --git a/lisp/layout-composable.lisp b/lisp/layout-composable.lisp new file mode 100644 index 0000000..27faa8f --- /dev/null +++ b/lisp/layout-composable.lisp @@ -0,0 +1,194 @@ +(eval-when (:compile-toplevel :load-toplevel :execute) + (ql:quickload :cffi :silent t) + (ql:quickload :trivial-garbage :silent t)) + +(defpackage :cl-tui.layout-composable + (:use :cl :cl-tui.layout-primitives) + (:export + #:vbox + #:hbox + #:overlay + #:spacer)) + +(in-package :cl-tui.layout-composable) + +(defun apply-common-props (node &key width height flex-grow flex-shrink flex-basis + align justify gap padding margin + &allow-other-keys) + "Apply the shared style properties to a layout-node." + (when (or width height) + (layout-node-set-dimension node (or width 0) (or height 0))) + (when (or flex-grow flex-shrink flex-basis) + (layout-node-set-flex node :grow flex-grow :shrink flex-shrink :basis flex-basis)) + (when align + (apply #'layout-node-set-align node align)) + (when justify + (layout-node-set-justify node justify)) + (when gap + (apply #'layout-node-set-gap node gap)) + (when padding + (apply #'layout-node-set-padding node padding)) + (when margin + (apply #'layout-node-set-margin node margin))) + +(defun add-children (parent children) + "Add each child in CHILDREN to PARENT. Non-node values are skipped." + (dolist (child children) + (when (typep child 'layout-node) + (layout-node-add-child parent child)))) + +(defun make-props-list (args) + "Extract all properties except :children from ARGS plist." + (loop for (k v) on args by #'cddr + unless (eq k :children) + append (list k v))) + +(defmacro vbox (&rest args &key children &allow-other-keys) + "Create a column-direction container with CHILDREN stacked vertically." + (declare (ignore children)) + (let* ((node (gensym "VBOX")) + (props (make-props-list args))) + `(let ((,node (make-layout-node))) + (layout-node-set-direction ,node :column) + (apply #'cl-tui.layout-composable::apply-common-props ,node ',props) + (cl-tui.layout-composable::add-children ,node (list ,@children)) + ,node))) + +(defmacro hbox (&rest args &key children &allow-other-keys) + "Create a row-direction container with CHILDREN laid out horizontally." + (declare (ignore children)) + (let* ((node (gensym "HBOX")) + (props (make-props-list args))) + `(let ((,node (make-layout-node))) + (layout-node-set-direction ,node :row) + (apply #'cl-tui.layout-composable::apply-common-props ,node ',props) + (cl-tui.layout-composable::add-children ,node (list ,@children)) + ,node))) + +(defmacro overlay (base child &key top right bottom left) + "Create a container with BASE as the relative foundation and CHILD +positioned absolutely on top." + (let ((node (gensym "OVERLAY"))) + `(let ((,node (make-layout-node))) + (layout-node-set-position ,node :relative) + (layout-node-add-child ,node ,base) + (layout-node-set-position ,child :absolute + ,@(when top `(:top ,top)) + ,@(when right `(:right ,right)) + ,@(when bottom `(:bottom ,bottom)) + ,@(when left `(:left ,left))) + (layout-node-add-child ,node ,child) + ,node))) + +(defun spacer (&key (grow 0)) + "Create an empty spacer node that fills available space via flex-grow." + (let ((node (make-layout-node))) + (when (> grow 0) + (layout-node-set-flex node :grow grow)) + node)) + +(eval-when (:compile-toplevel :load-toplevel :execute) + (ql:quickload :fiveam :silent t)) + +(defpackage :cl-tui.layout-composable-tests + (:use :cl :fiveam) + (:import-from :cl-tui.layout-composable + #:vbox #:hbox #:overlay #:spacer) + (:import-from :cl-tui.layout-primitives + #:layout-node #:layout-node-ptr #:layout-calculate + #:make-layout-node #:layout-node-set-dimension) + (:import-from :cl-tui.yoga-ffi + #:yg-node-layout-get-left + #:yg-node-layout-get-top + #:yg-node-layout-get-width + #:yg-node-layout-get-height + #:yg-node-get-child)) + +(in-package :cl-tui.layout-composable-tests) + +(defun node-x (n) (yg-node-layout-get-left (layout-node-ptr n))) +(defun node-y (n) (yg-node-layout-get-top (layout-node-ptr n))) +(defun node-w (n) (yg-node-layout-get-width (layout-node-ptr n))) +(defun node-h (n) (yg-node-layout-get-height (layout-node-ptr n))) +(defun child-x (p) (yg-node-layout-get-left p)) +(defun child-y (p) (yg-node-layout-get-top p)) +(defun child-w (p) (yg-node-layout-get-width p)) +(defun child-h (p) (yg-node-layout-get-height p)) + +(defun nth-child (node n) + (yg-node-get-child (layout-node-ptr node) n)) + +(defun layout-dummy (w h) + (let ((n (make-layout-node))) + (layout-node-set-dimension n w h) + n)) + +(fiveam:def-suite layout-composable-suite + :description "Composable API macro verification") +(fiveam:in-suite layout-composable-suite) + +(fiveam:test test-vbox-stacks-children + "Contract: vbox stacks children vertically." + (let* ((root (vbox :width 100 :height 200 + :children ((layout-dummy 100 50) + (layout-dummy 100 50))))) + (layout-calculate root 100 200) + (let ((c1 (nth-child root 0)) + (c2 (nth-child root 1))) + (fiveam:is (= 0.0 (child-y c1))) + (fiveam:is (= 50.0 (child-y c2)))))) + +(fiveam:test test-hbox-lays-out-horizontally + "Contract: hbox places children horizontally." + (let* ((root (hbox :width 200 :height 100 + :children ((layout-dummy 80 50) + (layout-dummy 80 50))))) + (layout-calculate root 200 100) + (let ((c1 (nth-child root 0)) + (c2 (nth-child root 1))) + (fiveam:is (= 0.0 (child-x c1))) + (fiveam:is (= 80.0 (child-x c2)))))) + +(fiveam:test test-spacer-flex-grow + "Contract: spacer with flex-grow expands to fill space." + (let* ((root (hbox :width 200 :height 100 + :children ((layout-dummy 50 50) + (spacer :grow 1) + (layout-dummy 50 50))))) + (layout-calculate root 200 100) + (let ((c1 (nth-child root 0)) + (c2 (nth-child root 1)) + (c3 (nth-child root 2))) + (fiveam:is (= 0.0 (child-x c1))) + (fiveam:is (= 50.0 (child-w c1))) + (fiveam:is (= 100.0 (child-w c2)))))) + +(fiveam:test test-overlay-absolute-position + "Contract: overlay positions an absolute child over a relative base." + (let* ((base (layout-dummy 100 100)) + (child (layout-dummy 30 30)) + (root (overlay base child :top 10 :left 20))) + (layout-calculate root 200 200) + (fiveam:is (= 20.0 (node-x child))) + (fiveam:is (= 10.0 (node-y child))))) + +(fiveam:test test-vbox-align-justify + "Contract: vbox accepts align and justify keywords." + (let* ((root (vbox :width 200 :height 100 + :align (:items :center) + :justify :center + :children ((layout-dummy 50 50))))) + (layout-calculate root 200 100) + (let ((c (nth-child root 0))) + (fiveam:is (= 25.0 (child-y c))) + (fiveam:is (= 75.0 (child-x c)))))) + +(fiveam:test test-vbox-padding + "Contract: vbox padding offsets children." + (let* ((root (vbox :width 200 :height 100 + :padding (:all 10) + :children ((layout-dummy 180 80))))) + (layout-calculate root 200 100) + (let ((c (nth-child root 0))) + (fiveam:is (= 10.0 (child-x c))) + (fiveam:is (= 10.0 (child-y c)))))) diff --git a/lisp/layout-primitives.lisp b/lisp/layout-primitives.lisp new file mode 100644 index 0000000..c72f3b3 --- /dev/null +++ b/lisp/layout-primitives.lisp @@ -0,0 +1,511 @@ +(eval-when (:compile-toplevel :load-toplevel :execute) + (ql:quickload :cffi :silent t) + (ql:quickload :trivial-garbage :silent t)) + +(defpackage :cl-tui.layout-primitives + (:use :cl) + (:import-from :cl-tui.yoga-ffi + #:load-yoga + #:yg-node-new + #:yg-node-free + #:yg-node-insert-child + #:yg-node-remove-child + #:yg-node-get-child-count + #:yg-node-calculate-layout + #:yg-node-layout-get-left + #:yg-node-layout-get-top + #:yg-node-layout-get-width + #:yg-node-layout-get-height + #:yg-node-layout-get-right + #:yg-node-layout-get-bottom + #:yg-node-style-set-direction + #:yg-node-style-set-flex-direction + #:yg-node-style-set-justify-content + #:yg-node-style-set-align-items + #:yg-node-style-set-align-self + #:yg-node-style-set-align-content + #:yg-node-style-set-flex-wrap + #:yg-node-style-set-position-type + #:yg-node-style-set-flex-grow + #:yg-node-style-set-flex-shrink + #:yg-node-style-set-flex-basis + #:yg-node-style-set-flex-basis-auto + #:yg-node-style-set-overflow + #:yg-node-style-set-display + #:yg-node-style-set-width + #:yg-node-style-set-width-auto + #:yg-node-style-set-height + #:yg-node-style-set-height-auto + #:yg-node-style-set-min-width + #:yg-node-style-set-min-height + #:yg-node-style-set-max-width + #:yg-node-style-set-max-height + #:yg-node-style-set-aspect-ratio + #:yg-node-style-set-padding + #:yg-node-style-set-margin + #:yg-node-style-set-margin-auto + #:yg-node-style-set-border + #:yg-node-style-set-gap + #:yg-node-style-set-position + ;; enum constants + #:+yg-flex-direction-column+ + #:+yg-flex-direction-column-reverse+ + #:+yg-flex-direction-row+ + #:+yg-flex-direction-row-reverse+ + #:+yg-wrap-nowrap+ + #:+yg-wrap-wrap+ + #:+yg-wrap-wrap-reverse+ + #:+yg-justify-auto+ + #:+yg-justify-flex-start+ + #:+yg-justify-center+ + #:+yg-justify-flex-end+ + #:+yg-justify-space-between+ + #:+yg-justify-space-around+ + #:+yg-justify-space-evenly+ + #:+yg-align-auto+ + #:+yg-align-flex-start+ + #:+yg-align-center+ + #:+yg-align-flex-end+ + #:+yg-align-stretch+ + #:+yg-align-baseline+ + #:+yg-align-space-between+ + #:+yg-align-space-around+ + #:+yg-align-space-evenly+ + #:+yg-position-type-static+ + #:+yg-position-type-relative+ + #:+yg-position-type-absolute+ + #:+yg-overflow-visible+ + #:+yg-overflow-hidden+ + #:+yg-overflow-scroll+ + #:+yg-display-flex+ + #:+yg-display-none+ + #:+yg-edge-left+ + #:+yg-edge-top+ + #:+yg-edge-right+ + #:+yg-edge-bottom+ + #:+yg-edge-all+ + #:+yg-edge-start+ + #:+yg-edge-end+ + #:+yg-edge-horizontal+ + #:+yg-edge-vertical+ + #:+yg-gutter-column+ + #:+yg-gutter-row+ + #:+yg-gutter-all+ + #:+yg-direction-inherit+ + #:+yg-direction-ltr+ + #:+yg-direction-rtl+) + (:export + #:layout-node + #:layout-node-ptr + #:make-layout-node + #:layout-node-add-child + #:layout-node-set-dimension + #:layout-node-set-flex + #:layout-node-set-direction + #:layout-node-set-wrap + #:layout-node-set-align + #:layout-node-set-justify + #:layout-node-set-padding + #:layout-node-set-margin + #:layout-node-set-gap + #:layout-node-set-position + #:layout-node-set-border + #:layout-node-set-overflow + #:layout-node-set-display + #:layout-node-set-aspect-ratio + #:layout-calculate)) + +(in-package :cl-tui.layout-primitives) + +;; Keyword → integer translation tables. Used by setter functions +;; so callers use (:flex-start) instead of (+yg-justify-flex-start+). + +(defparameter *flex-direction-map* + '((:column . 0) (:column-reverse . 1) (:row . 2) (:row-reverse . 3))) + +(defparameter *wrap-map* + '((:nowrap . 0) (:wrap . 1) (:wrap-reverse . 2))) + +(defparameter *justify-map* + '((:auto . 0) (:flex-start . 1) (:center . 2) (:flex-end . 3) + (:space-between . 4) (:space-around . 5) (:space-evenly . 6))) + +(defparameter *align-map* + '((:auto . 0) (:flex-start . 1) (:center . 2) (:flex-end . 3) + (:stretch . 4) (:baseline . 5) (:space-between . 6) (:space-around . 7) + (:space-evenly . 8))) + +(defparameter *position-type-map* + '((:static . 0) (:relative . 1) (:absolute . 2))) + +(defparameter *overflow-map* + '((:visible . 0) (:hidden . 1) (:scroll . 2))) + +(defparameter *display-map* + '((:flex . 0) (:none . 1))) + +(defparameter *edge-map* + '((:left . 0) (:top . 1) (:right . 2) (:bottom . 3) + (:start . 4) (:end . 5) (:horizontal . 6) (:vertical . 7) (:all . 8))) + +(defparameter *direction-map* + '((:inherit . 0) (:ltr . 1) (:rtl . 2))) + +(defun resolve-enum (map keyword) + "Look up KEYWORD in MAP (an alist). Throws if not found." + (or (cdr (assoc keyword map)) + (error "Unknown enum keyword ~a" keyword))) + +(defclass layout-node () + ((ptr :initarg :ptr :reader layout-node-ptr + :documentation "Raw YGNodeRef pointer"))) + +(defmethod print-object ((node layout-node) stream) + (print-unreadable-object (node stream :type t) + (format stream "~a" (layout-node-ptr node)))) + +(defun make-layout-node () + "Allocate a new Yoga node and wrap it in a layout-node." + (let ((node (make-instance 'layout-node :ptr (yg-node-new)))) + (tg:finalize node (lambda () (yg-node-free (layout-node-ptr node)))) + node)) + +(defun layout-node-add-child (parent child) + "Insert CHILD at the end of PARENT's children list." + (let ((count (yg-node-get-child-count (layout-node-ptr parent)))) + (yg-node-insert-child (layout-node-ptr parent) (layout-node-ptr child) count))) + +(defun layout-node-set-dimension (node width height) + "Set fixed width and height in points." + (yg-node-style-set-width (layout-node-ptr node) (coerce width 'single-float)) + (yg-node-style-set-height (layout-node-ptr node) (coerce height 'single-float))) + +(defun layout-node-set-flex (node &key grow shrink basis) + "Set flex properties. Unspecified keys are left unchanged." + (let ((p (layout-node-ptr node))) + (when grow (yg-node-style-set-flex-grow p (coerce grow 'single-float))) + (when shrink (yg-node-style-set-flex-shrink p (coerce shrink 'single-float))) + (when basis (yg-node-style-set-flex-basis p (coerce basis 'single-float))))) + +(defun layout-node-set-aspect-ratio (node ratio) + "Set aspect ratio (width/height)." + (yg-node-style-set-aspect-ratio (layout-node-ptr node) (coerce ratio 'single-float))) + +(defun layout-node-set-direction (node direction) + "Set flex-direction. DIRECTION is :column, :column-reverse, :row, or :row-reverse." + (yg-node-style-set-flex-direction + (layout-node-ptr node) + (resolve-enum *flex-direction-map* direction))) + +(defun layout-node-set-wrap (node wrap) + "Set flex-wrap. WRAP is :nowrap, :wrap, or :wrap-reverse." + (yg-node-style-set-flex-wrap + (layout-node-ptr node) + (resolve-enum *wrap-map* wrap))) + +(defun layout-node-set-align (node &key items self content) + "Set align-items, align-self, align-content. Values are keywords like :flex-start." + (let ((p (layout-node-ptr node))) + (when items (yg-node-style-set-align-items p (resolve-enum *align-map* items))) + (when self (yg-node-style-set-align-self p (resolve-enum *align-map* self))) + (when content (yg-node-style-set-align-content p (resolve-enum *align-map* content))))) + +(defun layout-node-set-justify (node justify) + "Set justify-content. JUSTIFY is :flex-start, :center, :flex-end, :space-between, etc." + (yg-node-style-set-justify-content + (layout-node-ptr node) + (resolve-enum *justify-map* justify))) + +(defun layout-node-set-position (node type &key top right bottom left) + "Set position type and offsets. TYPE is :static, :relative, or :absolute." + (let ((p (layout-node-ptr node))) + (yg-node-style-set-position-type p (resolve-enum *position-type-map* type)) + (when left (yg-node-style-set-position p +yg-edge-left+ (coerce left 'single-float))) + (when top (yg-node-style-set-position p +yg-edge-top+ (coerce top 'single-float))) + (when right (yg-node-style-set-position p +yg-edge-right+ (coerce right 'single-float))) + (when bottom (yg-node-style-set-position p +yg-edge-bottom+ (coerce bottom 'single-float))))) + +(defun set-edges (p fn all top right bottom left x y) + "Helper: call FN on each specified edge. FN is (fn ptr edge value)." + (flet ((s (edge val) (funcall fn p edge (coerce val 'single-float)))) + (when all (dolist (e (list +yg-edge-left+ +yg-edge-top+ +yg-edge-right+ +yg-edge-bottom+)) + (s e all))) + (when top (s +yg-edge-top+ top)) + (when right (s +yg-edge-right+ right)) + (when bottom (s +yg-edge-bottom+ bottom)) + (when left (s +yg-edge-left+ left)) + (when x (s +yg-edge-horizontal+ x)) + (when y (s +yg-edge-vertical+ y)))) + +(defun layout-node-set-padding (node &key all top right bottom left x y) + "Set padding on specified edges in points." + (set-edges (layout-node-ptr node) #'yg-node-style-set-padding all top right bottom left x y)) + +(defun layout-node-set-margin (node &key all top right bottom left x y) + "Set margin on specified edges in points." + (set-edges (layout-node-ptr node) #'yg-node-style-set-margin all top right bottom left x y)) + +(defun layout-node-set-border (node width &key all top right bottom left x y) + "Set border width on specified edges." + (let ((p (layout-node-ptr node))) + (flet ((s (edge val) (yg-node-style-set-border p edge (coerce val 'single-float)))) + (when all (dolist (e (list +yg-edge-left+ +yg-edge-top+ +yg-edge-right+ +yg-edge-bottom+)) + (s e all))) + (when top (s +yg-edge-top+ top)) + (when right (s +yg-edge-right+ right)) + (when bottom (s +yg-edge-bottom+ bottom)) + (when left (s +yg-edge-left+ left)) + (when x (s +yg-edge-horizontal+ x)) + (when y (s +yg-edge-vertical+ y))))) + +(defun layout-node-set-gap (node &key row column) + "Set gap between children." + (let ((p (layout-node-ptr node))) + (when row (yg-node-style-set-gap p +yg-gutter-row+ (coerce row 'single-float))) + (when column (yg-node-style-set-gap p +yg-gutter-column+ (coerce column 'single-float))))) + +(defun layout-node-set-overflow (node overflow) + "Set overflow mode. OVERFLOW is :visible, :hidden, or :scroll." + (yg-node-style-set-overflow + (layout-node-ptr node) + (resolve-enum *overflow-map* overflow))) + +(defun layout-node-set-display (node display) + "Set display mode. DISPLAY is :flex or :none." + (yg-node-style-set-display + (layout-node-ptr node) + (resolve-enum *display-map* display))) + +(defun layout-calculate (root width height &optional (direction :ltr)) + "Run Yoga layout on the tree rooted at ROOT. +Returns ROOT (for chaining). Each node's computed position is available via +the raw FFI layout getter functions (yg-node-layout-get-left etc.)." + (yg-node-calculate-layout + (layout-node-ptr root) + (coerce width 'single-float) + (coerce height 'single-float) + (resolve-enum *direction-map* direction)) + root) + +(eval-when (:compile-toplevel :load-toplevel :execute) + (ql:quickload :fiveam :silent t)) + +(defpackage :cl-tui.layout-primitives-tests + (:use :cl :fiveam) + (:import-from :cl-tui.layout-primitives + #:make-layout-node + #:layout-node-add-child + #:layout-node-set-dimension + #:layout-node-set-flex + #:layout-node-set-direction + #:layout-node-set-wrap + #:layout-node-set-align + #:layout-node-set-justify + #:layout-node-set-padding + #:layout-node-set-margin + #:layout-node-set-gap + #:layout-node-set-position + #:layout-node-set-border + #:layout-node-set-overflow + #:layout-node-set-display + #:layout-node-set-aspect-ratio + #:layout-calculate + #:layout-node-ptr) + (:import-from :cl-tui.yoga-ffi + #:yg-node-layout-get-left + #:yg-node-layout-get-top + #:yg-node-layout-get-width + #:yg-node-layout-get-height)) + +(in-package :cl-tui.layout-primitives-tests) + +(fiveam:def-suite layout-primitives-suite + :description "Layout primitive CLOS wrappers verification") +(fiveam:in-suite layout-primitives-suite) + +(defun node-x (node) (yg-node-layout-get-left (layout-node-ptr node))) +(defun node-y (node) (yg-node-layout-get-top (layout-node-ptr node))) +(defun node-w (node) (yg-node-layout-get-width (layout-node-ptr node))) +(defun node-h (node) (yg-node-layout-get-height (layout-node-ptr node))) + +(fiveam:test test-make-layout-node + "Contract: make-layout-node returns a live node." + (let ((n (make-layout-node))) + (fiveam:is (not (cffi:null-pointer-p (layout-node-ptr n)))))) + +(fiveam:test test-layout-node-add-child + "Contract: adding a child makes it appear in the tree." + (let* ((parent (make-layout-node)) + (child (make-layout-node))) + (layout-node-add-child parent child) + (layout-node-set-dimension parent 100 100) + (layout-node-set-dimension child 50 50) + (layout-calculate parent 100 100) + (fiveam:is (= 50.0 (node-w child))) + (fiveam:is (= 50.0 (node-h child))))) + +(fiveam:test test-set-dimension + "Contract: layout-node-set-dimension sets width and height." + (let ((n (make-layout-node))) + (layout-node-set-dimension n 200 100) + (layout-calculate n 200 100) + (fiveam:is (= 200.0 (node-w n))) + (fiveam:is (= 100.0 (node-h n))))) + +(fiveam:test test-set-direction-column + "Contract: column direction stacks children vertically." + (let* ((root (make-layout-node)) + (a (make-layout-node)) + (b (make-layout-node))) + (layout-node-set-dimension root 100 200) + (layout-node-set-dimension a 100 50) + (layout-node-set-dimension b 100 50) + (layout-node-add-child root a) + (layout-node-add-child root b) + (layout-node-set-direction root :column) + (layout-calculate root 100 200) + (fiveam:is (= 0.0 (node-y a))) + (fiveam:is (= 50.0 (node-y b))))) + +(fiveam:test test-set-direction-row + "Contract: row direction places children horizontally." + (let* ((root (make-layout-node)) + (a (make-layout-node)) + (b (make-layout-node))) + (layout-node-set-dimension root 200 100) + (layout-node-set-dimension a 80 50) + (layout-node-set-dimension b 80 50) + (layout-node-add-child root a) + (layout-node-add-child root b) + (layout-node-set-direction root :row) + (layout-calculate root 200 100) + (fiveam:is (= 0.0 (node-x a))) + (fiveam:is (= 80.0 (node-x b))))) + +(fiveam:test test-set-flex-grow + "Contract: flex-grow distributes remaining space." + (let* ((root (make-layout-node)) + (a (make-layout-node)) + (b (make-layout-node))) + (layout-node-set-dimension root 200 100) + (layout-node-set-dimension a 0 100) + (layout-node-set-dimension b 0 100) + (layout-node-set-flex a :grow 1) + (layout-node-set-flex b :grow 2) + (layout-node-add-child root a) + (layout-node-add-child root b) + (layout-node-set-direction root :row) + (layout-calculate root 200 100) + (fiveam:is (< 0.0 (node-w a))) + (fiveam:is (< 0.0 (node-w b))) + (fiveam:is (= 200.0 (+ (node-w a) (node-w b)))))) + +(fiveam:test test-set-align-center + "Contract: align-items :center centers children on the cross axis." + (let* ((root (make-layout-node)) + (child (make-layout-node))) + (layout-node-set-dimension root 200 100) + (layout-node-set-dimension child 50 50) + (layout-node-set-direction root :row) + (layout-node-add-child root child) + (layout-node-set-align root :items :center) + (layout-calculate root 200 100) + (fiveam:is (= 25.0 (node-y child))))) + +(fiveam:test test-set-justify + "Contract: justify-content :center centers children on the main axis." + (let* ((root (make-layout-node)) + (child (make-layout-node))) + (layout-node-set-dimension root 200 100) + (layout-node-set-dimension child 50 50) + (layout-node-set-direction root :row) + (layout-node-add-child root child) + (layout-node-set-justify root :center) + (layout-calculate root 200 100) + (fiveam:is (= 75.0 (node-x child))))) + +(fiveam:test test-set-padding + "Contract: padding offsets children from the parent edges." + (let* ((root (make-layout-node)) + (child (make-layout-node))) + (layout-node-set-dimension root 200 100) + (layout-node-set-dimension child 100 50) + (layout-node-add-child root child) + (layout-node-set-padding root :all 10) + (layout-calculate root 200 100) + (fiveam:is (= 10.0 (node-x child))) + (fiveam:is (= 10.0 (node-y child))))) + +(fiveam:test test-set-margin + "Contract: margin offsets the child from its siblings/parent." + (let* ((root (make-layout-node)) + (child (make-layout-node))) + (layout-node-set-dimension root 200 100) + (layout-node-set-dimension child 80 50) + (layout-node-add-child root child) + (layout-node-set-margin child :left 20) + (layout-calculate root 200 100) + (fiveam:is (= 20.0 (node-x child))))) + +(fiveam:test test-set-position-absolute + "Contract: absolute positioning places a child at exact coordinates." + (let* ((root (make-layout-node)) + (child (make-layout-node))) + (layout-node-set-dimension root 300 300) + (layout-node-set-dimension child 50 50) + (layout-node-add-child root child) + (layout-node-set-position child :absolute :left 100 :top 50) + (layout-calculate root 300 300) + (fiveam:is (= 100.0 (node-x child))) + (fiveam:is (= 50.0 (node-y child))))) + +(fiveam:test test-set-wrap + "Contract: flex-wrap :wrap allows children to wrap to next line." + (let* ((root (make-layout-node)) + (a (make-layout-node)) + (b (make-layout-node)) + (c (make-layout-node))) + (layout-node-set-dimension root 100 200) + (layout-node-set-dimension a 60 50) + (layout-node-set-dimension b 60 50) + (layout-node-set-dimension c 60 50) + (layout-node-add-child root a) + (layout-node-add-child root b) + (layout-node-add-child root c) + (layout-node-set-direction root :row) + (layout-node-set-wrap root :wrap) + (layout-calculate root 100 200) + (fiveam:is (< 0 (node-h a))) + ;; Second child (b) should wrap to next row since 60+60 > 100 + (fiveam:is (> (node-y b) (node-y a))))) + +(fiveam:test test-set-gap + "Contract: gap adds spacing between children." + (let* ((root (make-layout-node)) + (a (make-layout-node)) + (b (make-layout-node))) + (layout-node-set-dimension root 200 100) + (layout-node-set-dimension a 50 50) + (layout-node-set-dimension b 50 50) + (layout-node-add-child root a) + (layout-node-add-child root b) + (layout-node-set-direction root :column) + (layout-node-set-gap root :row 20) + (layout-calculate root 200 100) + (fiveam:is (= 70.0 (node-y b))))) + +(fiveam:test test-nested-layout + "Contract: nested containers produce correct leaf positions." + (let* ((root (make-layout-node)) + (outer (make-layout-node)) + (inner (make-layout-node))) + (layout-node-set-dimension root 400 400) + (layout-node-set-dimension outer 400 200) + (layout-node-set-dimension inner 100 100) + (layout-node-add-child outer inner) + (layout-node-add-child root outer) + (layout-node-set-direction root :column) + (layout-calculate root 400 400) + (fiveam:is (= 0.0 (node-x inner))) + (fiveam:is (= 100.0 (node-w inner))) + (fiveam:is (= 100.0 (node-h inner))))) diff --git a/org/layout-composable.org b/org/layout-composable.org new file mode 100644 index 0000000..01bcb56 --- /dev/null +++ b/org/layout-composable.org @@ -0,0 +1,248 @@ +#+TITLE: Layout Composable API +#+STARTUP: content +#+FILETAGS: :cl-tui:layout-composable:v010: + +* Layout Composable API + +Convenience macros for building layout trees declaratively. Each macro +creates a =layout-node=, applies style properties, and inserts child nodes. + +Dependencies: =layout-primitives=. + +** Contract + +- =(vbox &key width height flex-grow flex-shrink flex-basis align justify gap + padding margin children ...)= → layout-node :: + Creates a column-direction container. All =children= are + layout-node instances, or plain non-node values are skipped. +- =(hbox &key width height flex-grow flex-shrink flex-basis align justify gap + padding margin children ...)= → layout-node :: + Creates a row-direction container. +- =(overlay base child &key top right bottom left)= → layout-node :: + Creates a container with a relative base and an absolute-positioned child + overlaid on top. +- =(spacer &key grow)= → layout-node :: + Creates an empty flexible spacer that fills available space. + +* Package + +#+begin_src lisp +(eval-when (:compile-toplevel :load-toplevel :execute) + (ql:quickload :cffi :silent t) + (ql:quickload :trivial-garbage :silent t)) + +(defpackage :cl-tui.layout-composable + (:use :cl :cl-tui.layout-primitives) + (:export + #:vbox + #:hbox + #:overlay + #:spacer)) + +(in-package :cl-tui.layout-composable) +#+end_src + +* Internal Helpers + +#+begin_src lisp +(defun apply-common-props (node &key width height flex-grow flex-shrink flex-basis + align justify gap padding margin + &allow-other-keys) + "Apply the shared style properties to a layout-node." + (when (or width height) + (layout-node-set-dimension node (or width 0) (or height 0))) + (when (or flex-grow flex-shrink flex-basis) + (layout-node-set-flex node :grow flex-grow :shrink flex-shrink :basis flex-basis)) + (when align + (apply #'layout-node-set-align node align)) + (when justify + (layout-node-set-justify node justify)) + (when gap + (apply #'layout-node-set-gap node gap)) + (when padding + (apply #'layout-node-set-padding node padding)) + (when margin + (apply #'layout-node-set-margin node margin))) + +(defun add-children (parent children) + "Add each child in CHILDREN to PARENT. Non-node values are skipped." + (dolist (child children) + (when (typep child 'layout-node) + (layout-node-add-child parent child)))) + +(defun make-props-list (args) + "Extract all properties except :children from ARGS plist." + (loop for (k v) on args by #'cddr + unless (eq k :children) + append (list k v))) +#+end_src + +* vbox — Vertical Box + +#+begin_src lisp +(defmacro vbox (&rest args &key children &allow-other-keys) + "Create a column-direction container with CHILDREN stacked vertically." + (declare (ignore children)) + (let* ((node (gensym "VBOX")) + (props (make-props-list args))) + `(let ((,node (make-layout-node))) + (layout-node-set-direction ,node :column) + (apply #'cl-tui.layout-composable::apply-common-props ,node ',props) + (cl-tui.layout-composable::add-children ,node (list ,@children)) + ,node))) +#+end_src + +* hbox — Horizontal Box + +#+begin_src lisp +(defmacro hbox (&rest args &key children &allow-other-keys) + "Create a row-direction container with CHILDREN laid out horizontally." + (declare (ignore children)) + (let* ((node (gensym "HBOX")) + (props (make-props-list args))) + `(let ((,node (make-layout-node))) + (layout-node-set-direction ,node :row) + (apply #'cl-tui.layout-composable::apply-common-props ,node ',props) + (cl-tui.layout-composable::add-children ,node (list ,@children)) + ,node))) +#+end_src + +* overlay — Absolute Overlay + +#+begin_src lisp +(defmacro overlay (base child &key top right bottom left) + "Create a container with BASE as the relative foundation and CHILD +positioned absolutely on top." + (let ((node (gensym "OVERLAY"))) + `(let ((,node (make-layout-node))) + (layout-node-set-position ,node :relative) + (layout-node-add-child ,node ,base) + (layout-node-set-position ,child :absolute + ,@(when top `(:top ,top)) + ,@(when right `(:right ,right)) + ,@(when bottom `(:bottom ,bottom)) + ,@(when left `(:left ,left))) + (layout-node-add-child ,node ,child) + ,node))) +#+end_src + +* spacer — Flex Spacer + +#+begin_src lisp +(defun spacer (&key (grow 0)) + "Create an empty spacer node that fills available space via flex-grow." + (let ((node (make-layout-node))) + (when (> grow 0) + (layout-node-set-flex node :grow grow)) + node)) +#+end_src + +* Test Suite + +#+begin_src lisp +(eval-when (:compile-toplevel :load-toplevel :execute) + (ql:quickload :fiveam :silent t)) + +(defpackage :cl-tui.layout-composable-tests + (:use :cl :fiveam) + (:import-from :cl-tui.layout-composable + #:vbox #:hbox #:overlay #:spacer) + (:import-from :cl-tui.layout-primitives + #:layout-node #:layout-node-ptr #:layout-calculate + #:make-layout-node #:layout-node-set-dimension) + (:import-from :cl-tui.yoga-ffi + #:yg-node-layout-get-left + #:yg-node-layout-get-top + #:yg-node-layout-get-width + #:yg-node-layout-get-height + #:yg-node-get-child)) + +(in-package :cl-tui.layout-composable-tests) + +(defun node-x (n) (yg-node-layout-get-left (layout-node-ptr n))) +(defun node-y (n) (yg-node-layout-get-top (layout-node-ptr n))) +(defun node-w (n) (yg-node-layout-get-width (layout-node-ptr n))) +(defun node-h (n) (yg-node-layout-get-height (layout-node-ptr n))) +(defun child-x (p) (yg-node-layout-get-left p)) +(defun child-y (p) (yg-node-layout-get-top p)) +(defun child-w (p) (yg-node-layout-get-width p)) +(defun child-h (p) (yg-node-layout-get-height p)) + +(defun nth-child (node n) + (yg-node-get-child (layout-node-ptr node) n)) + +(defun layout-dummy (w h) + (let ((n (make-layout-node))) + (layout-node-set-dimension n w h) + n)) + +(fiveam:def-suite layout-composable-suite + :description "Composable API macro verification") +(fiveam:in-suite layout-composable-suite) + +(fiveam:test test-vbox-stacks-children + "Contract: vbox stacks children vertically." + (let* ((root (vbox :width 100 :height 200 + :children ((layout-dummy 100 50) + (layout-dummy 100 50))))) + (layout-calculate root 100 200) + (let ((c1 (nth-child root 0)) + (c2 (nth-child root 1))) + (fiveam:is (= 0.0 (child-y c1))) + (fiveam:is (= 50.0 (child-y c2)))))) + +(fiveam:test test-hbox-lays-out-horizontally + "Contract: hbox places children horizontally." + (let* ((root (hbox :width 200 :height 100 + :children ((layout-dummy 80 50) + (layout-dummy 80 50))))) + (layout-calculate root 200 100) + (let ((c1 (nth-child root 0)) + (c2 (nth-child root 1))) + (fiveam:is (= 0.0 (child-x c1))) + (fiveam:is (= 80.0 (child-x c2)))))) + +(fiveam:test test-spacer-flex-grow + "Contract: spacer with flex-grow expands to fill space." + (let* ((root (hbox :width 200 :height 100 + :children ((layout-dummy 50 50) + (spacer :grow 1) + (layout-dummy 50 50))))) + (layout-calculate root 200 100) + (let ((c1 (nth-child root 0)) + (c2 (nth-child root 1)) + (c3 (nth-child root 2))) + (fiveam:is (= 0.0 (child-x c1))) + (fiveam:is (= 50.0 (child-w c1))) + (fiveam:is (= 100.0 (child-w c2)))))) + +(fiveam:test test-overlay-absolute-position + "Contract: overlay positions an absolute child over a relative base." + (let* ((base (layout-dummy 100 100)) + (child (layout-dummy 30 30)) + (root (overlay base child :top 10 :left 20))) + (layout-calculate root 200 200) + (fiveam:is (= 20.0 (node-x child))) + (fiveam:is (= 10.0 (node-y child))))) + +(fiveam:test test-vbox-align-justify + "Contract: vbox accepts align and justify keywords." + (let* ((root (vbox :width 200 :height 100 + :align (:items :center) + :justify :center + :children ((layout-dummy 50 50))))) + (layout-calculate root 200 100) + (let ((c (nth-child root 0))) + (fiveam:is (= 25.0 (child-y c))) + (fiveam:is (= 75.0 (child-x c)))))) + +(fiveam:test test-vbox-padding + "Contract: vbox padding offsets children." + (let* ((root (vbox :width 200 :height 100 + :padding (:all 10) + :children ((layout-dummy 180 80))))) + (layout-calculate root 200 100) + (let ((c (nth-child root 0))) + (fiveam:is (= 10.0 (child-x c))) + (fiveam:is (= 10.0 (child-y c)))))) +#+end_src diff --git a/org/layout-primitives.org b/org/layout-primitives.org new file mode 100644 index 0000000..ea8ed7d --- /dev/null +++ b/org/layout-primitives.org @@ -0,0 +1,608 @@ +#+TITLE: Layout Primitives +#+STARTUP: content +#+FILETAGS: :cl-tui:layout-primitives:v010: + +* Layout Primitives + +CLOS wrappers around the raw Yoga FFI bindings. Each =layout-node= wraps a +=YGNodeRef= with automatic finalization. Setter functions translate Lisp +keywords to Yoga enum integers, providing a safe, idiomatic Common Lisp API. + +This file depends on =yoga-ffi.lisp= (the CFFI bindings). The composable API +(=vbox=, =hbox=, =overlay=, =spacer=) is in =layout-composable.org=. + +** Contract + +- =(make-layout-node)= → layout-node :: allocates a new Yoga node, wraps it in + a CLOS instance. The YGNodeRef is freed when the layout-node is garbage + collected (via trivial-garbage). +- =(layout-node-ptr node)= → YGNodeRef :: returns the raw C pointer for use + with raw FFI functions. +- =(layout-node-add-child parent child)= :: inserts child at the end of + parent's children list. Throws if child is nil. +- =(layout-node-set-dimension node width height)= :: sets fixed width and + height in points. +- =(layout-node-set-flex node &key grow shrink basis)= :: sets flex-grow, + flex-shrink, flex-basis. Unspecified keys are left unchanged. +- =(layout-node-set-direction node direction)= :: sets flex-direction. + direction is one of: :column :column-reverse :row :row-reverse. +- =(layout-node-set-wrap node wrap)= :: sets flex-wrap. + wrap is one of: :nowrap :wrap :wrap-reverse. +- =(layout-node-set-align node &key items self content)= :: sets align-items, + align-self, align-content. Values from: :auto, :flex-start, :center, + :flex-end, :stretch, :baseline, :space-between, :space-around, :space-evenly. +- =(layout-node-set-justify node justify)= :: sets justify-content. Values + from: :auto :flex-start :center :flex-end :space-between :space-around + :space-evenly. +- =(layout-node-set-padding node &key all top right bottom left x y)= :: sets + padding in points on specified edges. :all sets all 4 edges. +- =(layout-node-set-margin node &key all top right bottom left x y)= :: sets + margin in points. +- =(layout-node-set-gap node &key row column)= :: sets gap between children. +- =(layout-node-set-position node type &key top right bottom left)= :: sets + position type (:static :relative :absolute) and offsets. +- =(layout-node-set-border node width &key top right bottom left all)= :: + sets border width on edges. +- =(layout-node-set-overflow node overflow)= :: sets overflow mode. Values: + :visible :hidden :scroll. +- =(layout-node-set-display node display)= :: sets display mode. Values: :flex + :none. +- =(layout-node-set-aspect-ratio node ratio)= :: sets aspect ratio. +- =(layout-calculate root width height &optional (direction :ltr))= :: runs + Yoga's calculateLayout, populating each node's computed x/y/w/h. + +* Package and Dependencies + +#+begin_src lisp +(eval-when (:compile-toplevel :load-toplevel :execute) + (ql:quickload :cffi :silent t) + (ql:quickload :trivial-garbage :silent t)) + +(defpackage :cl-tui.layout-primitives + (:use :cl) + (:import-from :cl-tui.yoga-ffi + #:load-yoga + #:yg-node-new + #:yg-node-free + #:yg-node-insert-child + #:yg-node-remove-child + #:yg-node-get-child-count + #:yg-node-calculate-layout + #:yg-node-layout-get-left + #:yg-node-layout-get-top + #:yg-node-layout-get-width + #:yg-node-layout-get-height + #:yg-node-layout-get-right + #:yg-node-layout-get-bottom + #:yg-node-style-set-direction + #:yg-node-style-set-flex-direction + #:yg-node-style-set-justify-content + #:yg-node-style-set-align-items + #:yg-node-style-set-align-self + #:yg-node-style-set-align-content + #:yg-node-style-set-flex-wrap + #:yg-node-style-set-position-type + #:yg-node-style-set-flex-grow + #:yg-node-style-set-flex-shrink + #:yg-node-style-set-flex-basis + #:yg-node-style-set-flex-basis-auto + #:yg-node-style-set-overflow + #:yg-node-style-set-display + #:yg-node-style-set-width + #:yg-node-style-set-width-auto + #:yg-node-style-set-height + #:yg-node-style-set-height-auto + #:yg-node-style-set-min-width + #:yg-node-style-set-min-height + #:yg-node-style-set-max-width + #:yg-node-style-set-max-height + #:yg-node-style-set-aspect-ratio + #:yg-node-style-set-padding + #:yg-node-style-set-margin + #:yg-node-style-set-margin-auto + #:yg-node-style-set-border + #:yg-node-style-set-gap + #:yg-node-style-set-position + ;; enum constants + #:+yg-flex-direction-column+ + #:+yg-flex-direction-column-reverse+ + #:+yg-flex-direction-row+ + #:+yg-flex-direction-row-reverse+ + #:+yg-wrap-nowrap+ + #:+yg-wrap-wrap+ + #:+yg-wrap-wrap-reverse+ + #:+yg-justify-auto+ + #:+yg-justify-flex-start+ + #:+yg-justify-center+ + #:+yg-justify-flex-end+ + #:+yg-justify-space-between+ + #:+yg-justify-space-around+ + #:+yg-justify-space-evenly+ + #:+yg-align-auto+ + #:+yg-align-flex-start+ + #:+yg-align-center+ + #:+yg-align-flex-end+ + #:+yg-align-stretch+ + #:+yg-align-baseline+ + #:+yg-align-space-between+ + #:+yg-align-space-around+ + #:+yg-align-space-evenly+ + #:+yg-position-type-static+ + #:+yg-position-type-relative+ + #:+yg-position-type-absolute+ + #:+yg-overflow-visible+ + #:+yg-overflow-hidden+ + #:+yg-overflow-scroll+ + #:+yg-display-flex+ + #:+yg-display-none+ + #:+yg-edge-left+ + #:+yg-edge-top+ + #:+yg-edge-right+ + #:+yg-edge-bottom+ + #:+yg-edge-all+ + #:+yg-edge-start+ + #:+yg-edge-end+ + #:+yg-edge-horizontal+ + #:+yg-edge-vertical+ + #:+yg-gutter-column+ + #:+yg-gutter-row+ + #:+yg-gutter-all+ + #:+yg-direction-inherit+ + #:+yg-direction-ltr+ + #:+yg-direction-rtl+) + (:export + #:layout-node + #:layout-node-ptr + #:make-layout-node + #:layout-node-add-child + #:layout-node-set-dimension + #:layout-node-set-flex + #:layout-node-set-direction + #:layout-node-set-wrap + #:layout-node-set-align + #:layout-node-set-justify + #:layout-node-set-padding + #:layout-node-set-margin + #:layout-node-set-gap + #:layout-node-set-position + #:layout-node-set-border + #:layout-node-set-overflow + #:layout-node-set-display + #:layout-node-set-aspect-ratio + #:layout-calculate)) + +(in-package :cl-tui.layout-primitives) +#+end_src + +* Enum Translation Tables + +#+begin_src lisp +;; Keyword → integer translation tables. Used by setter functions +;; so callers use (:flex-start) instead of (+yg-justify-flex-start+). + +(defparameter *flex-direction-map* + '((:column . 0) (:column-reverse . 1) (:row . 2) (:row-reverse . 3))) + +(defparameter *wrap-map* + '((:nowrap . 0) (:wrap . 1) (:wrap-reverse . 2))) + +(defparameter *justify-map* + '((:auto . 0) (:flex-start . 1) (:center . 2) (:flex-end . 3) + (:space-between . 4) (:space-around . 5) (:space-evenly . 6))) + +(defparameter *align-map* + '((:auto . 0) (:flex-start . 1) (:center . 2) (:flex-end . 3) + (:stretch . 4) (:baseline . 5) (:space-between . 6) (:space-around . 7) + (:space-evenly . 8))) + +(defparameter *position-type-map* + '((:static . 0) (:relative . 1) (:absolute . 2))) + +(defparameter *overflow-map* + '((:visible . 0) (:hidden . 1) (:scroll . 2))) + +(defparameter *display-map* + '((:flex . 0) (:none . 1))) + +(defparameter *edge-map* + '((:left . 0) (:top . 1) (:right . 2) (:bottom . 3) + (:start . 4) (:end . 5) (:horizontal . 6) (:vertical . 7) (:all . 8))) + +(defparameter *direction-map* + '((:inherit . 0) (:ltr . 1) (:rtl . 2))) + +(defun resolve-enum (map keyword) + "Look up KEYWORD in MAP (an alist). Throws if not found." + (or (cdr (assoc keyword map)) + (error "Unknown enum keyword ~a" keyword))) +#+end_src + +* Layout Node Class + +#+begin_src lisp +(defclass layout-node () + ((ptr :initarg :ptr :reader layout-node-ptr + :documentation "Raw YGNodeRef pointer"))) + +(defmethod print-object ((node layout-node) stream) + (print-unreadable-object (node stream :type t) + (format stream "~a" (layout-node-ptr node)))) + +(defun make-layout-node () + "Allocate a new Yoga node and wrap it in a layout-node." + (let ((node (make-instance 'layout-node :ptr (yg-node-new)))) + (tg:finalize node (lambda () (yg-node-free (layout-node-ptr node)))) + node)) + +(defun layout-node-add-child (parent child) + "Insert CHILD at the end of PARENT's children list." + (let ((count (yg-node-get-child-count (layout-node-ptr parent)))) + (yg-node-insert-child (layout-node-ptr parent) (layout-node-ptr child) count))) +#+end_src + +* Dimension Setters + +#+begin_src lisp +(defun layout-node-set-dimension (node width height) + "Set fixed width and height in points." + (yg-node-style-set-width (layout-node-ptr node) (coerce width 'single-float)) + (yg-node-style-set-height (layout-node-ptr node) (coerce height 'single-float))) + +(defun layout-node-set-flex (node &key grow shrink basis) + "Set flex properties. Unspecified keys are left unchanged." + (let ((p (layout-node-ptr node))) + (when grow (yg-node-style-set-flex-grow p (coerce grow 'single-float))) + (when shrink (yg-node-style-set-flex-shrink p (coerce shrink 'single-float))) + (when basis (yg-node-style-set-flex-basis p (coerce basis 'single-float))))) + +(defun layout-node-set-aspect-ratio (node ratio) + "Set aspect ratio (width/height)." + (yg-node-style-set-aspect-ratio (layout-node-ptr node) (coerce ratio 'single-float))) +#+end_src + +* Layout Direction and Wrapping + +#+begin_src lisp +(defun layout-node-set-direction (node direction) + "Set flex-direction. DIRECTION is :column, :column-reverse, :row, or :row-reverse." + (yg-node-style-set-flex-direction + (layout-node-ptr node) + (resolve-enum *flex-direction-map* direction))) + +(defun layout-node-set-wrap (node wrap) + "Set flex-wrap. WRAP is :nowrap, :wrap, or :wrap-reverse." + (yg-node-style-set-flex-wrap + (layout-node-ptr node) + (resolve-enum *wrap-map* wrap))) +#+end_src + +* Alignment and Justification + +#+begin_src lisp +(defun layout-node-set-align (node &key items self content) + "Set align-items, align-self, align-content. Values are keywords like :flex-start." + (let ((p (layout-node-ptr node))) + (when items (yg-node-style-set-align-items p (resolve-enum *align-map* items))) + (when self (yg-node-style-set-align-self p (resolve-enum *align-map* self))) + (when content (yg-node-style-set-align-content p (resolve-enum *align-map* content))))) + +(defun layout-node-set-justify (node justify) + "Set justify-content. JUSTIFY is :flex-start, :center, :flex-end, :space-between, etc." + (yg-node-style-set-justify-content + (layout-node-ptr node) + (resolve-enum *justify-map* justify))) +#+end_src + +* Position Type and Offsets + +#+begin_src lisp +(defun layout-node-set-position (node type &key top right bottom left) + "Set position type and offsets. TYPE is :static, :relative, or :absolute." + (let ((p (layout-node-ptr node))) + (yg-node-style-set-position-type p (resolve-enum *position-type-map* type)) + (when left (yg-node-style-set-position p +yg-edge-left+ (coerce left 'single-float))) + (when top (yg-node-style-set-position p +yg-edge-top+ (coerce top 'single-float))) + (when right (yg-node-style-set-position p +yg-edge-right+ (coerce right 'single-float))) + (when bottom (yg-node-style-set-position p +yg-edge-bottom+ (coerce bottom 'single-float))))) +#+end_src + +* Padding, Margin, Border, Gap + +#+begin_src lisp +(defun set-edges (p fn all top right bottom left x y) + "Helper: call FN on each specified edge. FN is (fn ptr edge value)." + (flet ((s (edge val) (funcall fn p edge (coerce val 'single-float)))) + (when all (dolist (e (list +yg-edge-left+ +yg-edge-top+ +yg-edge-right+ +yg-edge-bottom+)) + (s e all))) + (when top (s +yg-edge-top+ top)) + (when right (s +yg-edge-right+ right)) + (when bottom (s +yg-edge-bottom+ bottom)) + (when left (s +yg-edge-left+ left)) + (when x (s +yg-edge-horizontal+ x)) + (when y (s +yg-edge-vertical+ y)))) + +(defun layout-node-set-padding (node &key all top right bottom left x y) + "Set padding on specified edges in points." + (set-edges (layout-node-ptr node) #'yg-node-style-set-padding all top right bottom left x y)) + +(defun layout-node-set-margin (node &key all top right bottom left x y) + "Set margin on specified edges in points." + (set-edges (layout-node-ptr node) #'yg-node-style-set-margin all top right bottom left x y)) + +(defun layout-node-set-border (node width &key all top right bottom left x y) + "Set border width on specified edges." + (let ((p (layout-node-ptr node))) + (flet ((s (edge val) (yg-node-style-set-border p edge (coerce val 'single-float)))) + (when all (dolist (e (list +yg-edge-left+ +yg-edge-top+ +yg-edge-right+ +yg-edge-bottom+)) + (s e all))) + (when top (s +yg-edge-top+ top)) + (when right (s +yg-edge-right+ right)) + (when bottom (s +yg-edge-bottom+ bottom)) + (when left (s +yg-edge-left+ left)) + (when x (s +yg-edge-horizontal+ x)) + (when y (s +yg-edge-vertical+ y))))) + +(defun layout-node-set-gap (node &key row column) + "Set gap between children." + (let ((p (layout-node-ptr node))) + (when row (yg-node-style-set-gap p +yg-gutter-row+ (coerce row 'single-float))) + (when column (yg-node-style-set-gap p +yg-gutter-column+ (coerce column 'single-float))))) +#+end_src + +* Overflow and Display + +#+begin_src lisp +(defun layout-node-set-overflow (node overflow) + "Set overflow mode. OVERFLOW is :visible, :hidden, or :scroll." + (yg-node-style-set-overflow + (layout-node-ptr node) + (resolve-enum *overflow-map* overflow))) + +(defun layout-node-set-display (node display) + "Set display mode. DISPLAY is :flex or :none." + (yg-node-style-set-display + (layout-node-ptr node) + (resolve-enum *display-map* display))) +#+end_src + +* Layout Calculation + +#+begin_src lisp +(defun layout-calculate (root width height &optional (direction :ltr)) + "Run Yoga layout on the tree rooted at ROOT. +Returns ROOT (for chaining). Each node's computed position is available via +the raw FFI layout getter functions (yg-node-layout-get-left etc.)." + (yg-node-calculate-layout + (layout-node-ptr root) + (coerce width 'single-float) + (coerce height 'single-float) + (resolve-enum *direction-map* direction)) + root) +#+end_src + +* Test Suite + +#+begin_src lisp +(eval-when (:compile-toplevel :load-toplevel :execute) + (ql:quickload :fiveam :silent t)) + +(defpackage :cl-tui.layout-primitives-tests + (:use :cl :fiveam) + (:import-from :cl-tui.layout-primitives + #:make-layout-node + #:layout-node-add-child + #:layout-node-set-dimension + #:layout-node-set-flex + #:layout-node-set-direction + #:layout-node-set-wrap + #:layout-node-set-align + #:layout-node-set-justify + #:layout-node-set-padding + #:layout-node-set-margin + #:layout-node-set-gap + #:layout-node-set-position + #:layout-node-set-border + #:layout-node-set-overflow + #:layout-node-set-display + #:layout-node-set-aspect-ratio + #:layout-calculate + #:layout-node-ptr) + (:import-from :cl-tui.yoga-ffi + #:yg-node-layout-get-left + #:yg-node-layout-get-top + #:yg-node-layout-get-width + #:yg-node-layout-get-height)) + +(in-package :cl-tui.layout-primitives-tests) + +(fiveam:def-suite layout-primitives-suite + :description "Layout primitive CLOS wrappers verification") +(fiveam:in-suite layout-primitives-suite) + +(defun node-x (node) (yg-node-layout-get-left (layout-node-ptr node))) +(defun node-y (node) (yg-node-layout-get-top (layout-node-ptr node))) +(defun node-w (node) (yg-node-layout-get-width (layout-node-ptr node))) +(defun node-h (node) (yg-node-layout-get-height (layout-node-ptr node))) + +(fiveam:test test-make-layout-node + "Contract: make-layout-node returns a live node." + (let ((n (make-layout-node))) + (fiveam:is (not (cffi:null-pointer-p (layout-node-ptr n)))))) + +(fiveam:test test-layout-node-add-child + "Contract: adding a child makes it appear in the tree." + (let* ((parent (make-layout-node)) + (child (make-layout-node))) + (layout-node-add-child parent child) + (layout-node-set-dimension parent 100 100) + (layout-node-set-dimension child 50 50) + (layout-calculate parent 100 100) + (fiveam:is (= 50.0 (node-w child))) + (fiveam:is (= 50.0 (node-h child))))) + +(fiveam:test test-set-dimension + "Contract: layout-node-set-dimension sets width and height." + (let ((n (make-layout-node))) + (layout-node-set-dimension n 200 100) + (layout-calculate n 200 100) + (fiveam:is (= 200.0 (node-w n))) + (fiveam:is (= 100.0 (node-h n))))) + +(fiveam:test test-set-direction-column + "Contract: column direction stacks children vertically." + (let* ((root (make-layout-node)) + (a (make-layout-node)) + (b (make-layout-node))) + (layout-node-set-dimension root 100 200) + (layout-node-set-dimension a 100 50) + (layout-node-set-dimension b 100 50) + (layout-node-add-child root a) + (layout-node-add-child root b) + (layout-node-set-direction root :column) + (layout-calculate root 100 200) + (fiveam:is (= 0.0 (node-y a))) + (fiveam:is (= 50.0 (node-y b))))) + +(fiveam:test test-set-direction-row + "Contract: row direction places children horizontally." + (let* ((root (make-layout-node)) + (a (make-layout-node)) + (b (make-layout-node))) + (layout-node-set-dimension root 200 100) + (layout-node-set-dimension a 80 50) + (layout-node-set-dimension b 80 50) + (layout-node-add-child root a) + (layout-node-add-child root b) + (layout-node-set-direction root :row) + (layout-calculate root 200 100) + (fiveam:is (= 0.0 (node-x a))) + (fiveam:is (= 80.0 (node-x b))))) + +(fiveam:test test-set-flex-grow + "Contract: flex-grow distributes remaining space." + (let* ((root (make-layout-node)) + (a (make-layout-node)) + (b (make-layout-node))) + (layout-node-set-dimension root 200 100) + (layout-node-set-dimension a 0 100) + (layout-node-set-dimension b 0 100) + (layout-node-set-flex a :grow 1) + (layout-node-set-flex b :grow 2) + (layout-node-add-child root a) + (layout-node-add-child root b) + (layout-node-set-direction root :row) + (layout-calculate root 200 100) + (fiveam:is (< 0.0 (node-w a))) + (fiveam:is (< 0.0 (node-w b))) + (fiveam:is (= 200.0 (+ (node-w a) (node-w b)))))) + +(fiveam:test test-set-align-center + "Contract: align-items :center centers children on the cross axis." + (let* ((root (make-layout-node)) + (child (make-layout-node))) + (layout-node-set-dimension root 200 100) + (layout-node-set-dimension child 50 50) + (layout-node-set-direction root :row) + (layout-node-add-child root child) + (layout-node-set-align root :items :center) + (layout-calculate root 200 100) + (fiveam:is (= 25.0 (node-y child))))) + +(fiveam:test test-set-justify + "Contract: justify-content :center centers children on the main axis." + (let* ((root (make-layout-node)) + (child (make-layout-node))) + (layout-node-set-dimension root 200 100) + (layout-node-set-dimension child 50 50) + (layout-node-set-direction root :row) + (layout-node-add-child root child) + (layout-node-set-justify root :center) + (layout-calculate root 200 100) + (fiveam:is (= 75.0 (node-x child))))) + +(fiveam:test test-set-padding + "Contract: padding offsets children from the parent edges." + (let* ((root (make-layout-node)) + (child (make-layout-node))) + (layout-node-set-dimension root 200 100) + (layout-node-set-dimension child 100 50) + (layout-node-add-child root child) + (layout-node-set-padding root :all 10) + (layout-calculate root 200 100) + (fiveam:is (= 10.0 (node-x child))) + (fiveam:is (= 10.0 (node-y child))))) + +(fiveam:test test-set-margin + "Contract: margin offsets the child from its siblings/parent." + (let* ((root (make-layout-node)) + (child (make-layout-node))) + (layout-node-set-dimension root 200 100) + (layout-node-set-dimension child 80 50) + (layout-node-add-child root child) + (layout-node-set-margin child :left 20) + (layout-calculate root 200 100) + (fiveam:is (= 20.0 (node-x child))))) + +(fiveam:test test-set-position-absolute + "Contract: absolute positioning places a child at exact coordinates." + (let* ((root (make-layout-node)) + (child (make-layout-node))) + (layout-node-set-dimension root 300 300) + (layout-node-set-dimension child 50 50) + (layout-node-add-child root child) + (layout-node-set-position child :absolute :left 100 :top 50) + (layout-calculate root 300 300) + (fiveam:is (= 100.0 (node-x child))) + (fiveam:is (= 50.0 (node-y child))))) + +(fiveam:test test-set-wrap + "Contract: flex-wrap :wrap allows children to wrap to next line." + (let* ((root (make-layout-node)) + (a (make-layout-node)) + (b (make-layout-node)) + (c (make-layout-node))) + (layout-node-set-dimension root 100 200) + (layout-node-set-dimension a 60 50) + (layout-node-set-dimension b 60 50) + (layout-node-set-dimension c 60 50) + (layout-node-add-child root a) + (layout-node-add-child root b) + (layout-node-add-child root c) + (layout-node-set-direction root :row) + (layout-node-set-wrap root :wrap) + (layout-calculate root 100 200) + (fiveam:is (< 0 (node-h a))) + ;; Second child (b) should wrap to next row since 60+60 > 100 + (fiveam:is (> (node-y b) (node-y a))))) + +(fiveam:test test-set-gap + "Contract: gap adds spacing between children." + (let* ((root (make-layout-node)) + (a (make-layout-node)) + (b (make-layout-node))) + (layout-node-set-dimension root 200 100) + (layout-node-set-dimension a 50 50) + (layout-node-set-dimension b 50 50) + (layout-node-add-child root a) + (layout-node-add-child root b) + (layout-node-set-direction root :column) + (layout-node-set-gap root :row 20) + (layout-calculate root 200 100) + (fiveam:is (= 70.0 (node-y b))))) + +(fiveam:test test-nested-layout + "Contract: nested containers produce correct leaf positions." + (let* ((root (make-layout-node)) + (outer (make-layout-node)) + (inner (make-layout-node))) + (layout-node-set-dimension root 400 400) + (layout-node-set-dimension outer 400 200) + (layout-node-set-dimension inner 100 100) + (layout-node-add-child outer inner) + (layout-node-add-child root outer) + (layout-node-set-direction root :column) + (layout-calculate root 400 400) + (fiveam:is (= 0.0 (node-x inner))) + (fiveam:is (= 100.0 (node-w inner))) + (fiveam:is (= 100.0 (node-h inner))))) +#+end_src