Files
cl-tty/org/layout-engine.org

24 KiB

cl-tui Layout Engine — v0.0.3

Layout Engine

Pure Common Lisp Flexbox layout engine. No Yoga, no CFFI, no external dependencies. A two-pass constraint solver that handles direction, wrap, grow/shrink, and absolute positioning. Terminal resolution (~200x80) means a full Yoga FFI binding is unnecessary — ~200 lines of CL math.

Contract

Layout Node

  • =(make-layout-node &key direction wrap grow shrink basis align-items justify-content padding margin border gap position-type position-offset width height)= → layout-node

    Create a layout node with the given properties.

    Properties:

    • :direction:row, :column, :row-reverse, :column-reverse
    • :wrap:nowrap, :wrap, :wrap-reverse
    • :grow — flex grow factor (0 = no grow)
    • :shrink — flex shrink factor (1 = default)
    • :basis — flex basis (:auto or integer)
    • :align-items:flex-start, :center, :flex-end, :stretch
    • :justify-content:flex-start, :center, :flex-end, :space-between, :space-around, :space-evenly
    • :padding, :margin, :border — plist with :top, :right, :bottom, :left, :x, :y
    • :gap — plist with :row and :column, or single integer
    • :position-type:relative or :absolute
    • :position-offset — plist with :top, :right, :bottom, :left
    • :width, :height — fixed dimensions (nil = auto)

Node Manipulation

  • (layout-node-add-child parent child) → child Add CHILD as the last child of PARENT. Sets child's parent.
  • (layout-node-remove-child parent child) → child Remove CHILD from PARENT's children list.
  • (layout-node-children node) → list Return list of child nodes.

Layout Calculation

  • (compute-layout root available-width available-height) → root Run the layout algorithm on the entire tree. Populates each node's computed :x, :y, :width, :height slots.

    Algorithm:

    1. Resolve styles (inherit, defaults)
    2. First pass (column direction): distribute Y positions
    3. Second pass (row direction): distribute X positions
    4. Resolve absolute-positioned children
    5. Handle wrap (overflow → new row/column)

Composed Value Access

  • (layout-node-x node) → integer
  • (layout-node-y node) → integer
  • (layout-node-width node) → integer
  • (layout-node-height node) → integer

Composable Macros

  • =(vbox (&key grow shrink basis align-items justify-content padding margin border gap width height) &body children)= → layout-node Create a vertical column container.
  • =(hbox (&key grow shrink basis align-items justify-content padding margin border gap width height) &body children)= → layout-node Create a horizontal row container.
  • (spacer &key grow) → layout-node Create an empty flex spacer.

Test Suite

(defpackage :cl-tui-layout-test
  (:use :cl :fiveam :cl-tui.layout)
  (:export #:run-tests))
(in-package :cl-tui-layout-test)

(def-suite layout-suite :description "Layout engine tests")
(in-suite layout-suite)

(defun run-tests ()
  (let ((result (run 'layout-suite)))
    (fiveam:explain! result)
    (uiop:quit 0)))

;; ── Node Creation ──────────────────────────────────────────────

(test make-layout-node-defaults
  "make-layout-node creates a node with default values"
  (let ((n (make-layout-node)))
    (is (typep n 'layout-node))
    (is (eql (layout-node-direction n) :column))))

(test make-layout-node-row
  "make-layout-node with :row direction"
  (let ((n (make-layout-node :direction :row)))
    (is (eql (layout-node-direction n) :row))))

;; ── Tree Building ──────────────────────────────────────────────

(test add-child-sets-parent
  "layout-node-add-child sets parent on child"
  (let ((parent (make-layout-node))
        (child (make-layout-node)))
    (layout-node-add-child parent child)
    (is (eql (slot-value child 'parent) parent))
    (is (= (length (slot-value parent 'children)) 1))))

(test remove-child-clears-parent
  "layout-node-remove-child clears parent slot"
  (let ((parent (make-layout-node))
        (child (make-layout-node)))
    (layout-node-add-child parent child)
    (layout-node-remove-child parent child)
    (is (null (slot-value child 'parent)))
    (is (= (length (slot-value parent 'children)) 0))))

;; ── Simple Layout — Column ─────────────────────────────────────

(test column-two-children-vertical
  "column stacks children vertically"
  (let* ((root (make-layout-node :direction :column))
         (c1 (make-layout-node :height 3))
         (c2 (make-layout-node :height 5)))
    (layout-node-add-child root c1)
    (layout-node-add-child root c2)
    (compute-layout root 20 20)
    (is (= (layout-node-y c1) 0))
    (is (= (layout-node-height c1) 3))
    (is (= (layout-node-y c2) 3))
    (is (= (layout-node-height c2) 5))))

(test row-two-children-horizontal
  "row places children side by side"
  (let* ((root (make-layout-node :direction :row))
         (c1 (make-layout-node :width 10))
         (c2 (make-layout-node :width 5)))
    (layout-node-add-child root c1)
    (layout-node-add-child root c2)
    (compute-layout root 20 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))))

;; ── Flex Grow ──────────────────────────────────────────────────

(test flex-grow-distributes-space
  "flex-grow distributes remaining space proportionally"
  (let* ((root (make-layout-node :direction :row :width 20))
         (c1 (make-layout-node :width 4 :grow 1))
         (c2 (make-layout-node :width 4 :grow 2)))
    (layout-node-add-child root c1)
    (layout-node-add-child root c2)
    (compute-layout root 20 10)
    ;; total fixed = 8, available = 12, c1 gets 4, c2 gets 8
    (is (= (layout-node-width c1) 8))
    (is (= (layout-node-width c2) 12))))

(test flex-grow-single-child
  "single child with flex-grow fills remaining space"
  (let* ((root (make-layout-node :direction :row :width 20))
         (c (make-layout-node :width 5 :grow 1)))
    (layout-node-add-child root c)
    (compute-layout root 20 10)
    (is (= (layout-node-width c) 20))))

;; ── Flex Shrink ────────────────────────────────────────────────

(test flex-shrink-reduces-overflow
  "flex-shrink reduces children when content overflows"
  (let* ((root (make-layout-node :direction :row :width 10))
         (c1 (make-layout-node :width 8 :shrink 1))
         (c2 (make-layout-node :width 8 :shrink 1)))
    (layout-node-add-child root c1)
    (layout-node-add-child root c2)
    (compute-layout root 10 10)
    ;; Total = 16, available = 10, overflow = 6, each shrinks by 3
    (is (= (layout-node-width c1) 5))
    (is (= (layout-node-width c2) 5))))

;; ── Absolute Positioning ───────────────────────────────────────

(test absolute-positioned-child
  "absolute child positions relative to parent"
  (let* ((root (make-layout-node :width 20 :height 20))
         (c (make-layout-node :position-type :absolute
                              :position-offset '(:top 2 :left 3)
                              :width 5 :height 5)))
    (layout-node-add-child root c)
    (compute-layout root 20 20)
    (is (= (layout-node-x c) 3))
    (is (= (layout-node-y c) 2))))

;; ── Padding ────────────────────────────────────────────────────

(test padding-reduces-content-area
  "padding reduces available area for children"
  (let* ((root (make-layout-node :direction :column
                                 :padding '(:top 1 :left 1 :bottom 1 :right 1)))
         (c (make-layout-node :height 3)))
    (layout-node-add-child root c)
    (compute-layout root 20 10)
    (is (= (layout-node-x c) 1))
    (is (= (layout-node-y c) 1))
    ;; content height = 10 - 2 = 8, child height = 3
    (is (= (layout-node-height c) 3))))

;; ── Gap ────────────────────────────────────────────────────────

(test gap-between-children
  "gap adds spacing between children"
  (let* ((root (make-layout-node :direction :column :gap 2))
         (c1 (make-layout-node :height 3))
         (c2 (make-layout-node :height 3)))
    (layout-node-add-child root c1)
    (layout-node-add-child root c2)
    (compute-layout root 20 20)
    (is (= (layout-node-y c1) 0))
    (is (= (layout-node-y c2) 5))))  ; 3 + 2 gap

;; ── Composable Macros ──────────────────────────────────────────

(test vbox-macro
  "vbox creates a column container with children"
  (let* ((root (vbox () (make-layout-node :height 3) (make-layout-node :height 5))))
    (compute-layout root 20 20)
    (is (= (length (layout-node-children root)) 2))
    (is (= (layout-node-y (elt (layout-node-children root) 1)) 3))))

(test hbox-macro
  "hbox creates a row container with children"
  (let* ((root (hbox () (make-layout-node :width 5) (make-layout-node :width 3))))
    (compute-layout root 20 10)
    (is (= (length (layout-node-children root)) 2))
    (is (= (layout-node-x (elt (layout-node-children root) 1)) 5))))

(test spacer-takes-grow
  "spacer with grow fills remaining space"
  (let* ((root (hbox (:width 20)
                (make-layout-node :width 5)
                (spacer :grow 1)
                (make-layout-node :width 5))))
    (compute-layout root 20 10)
    (let ((children (layout-node-children root)))
      (is (= (layout-node-x (elt children 2)) 15))
      (is (= (layout-node-width (elt children 1)) 10)))))

;; ── Nested Layout ──────────────────────────────────────────────

(test nested-vbox-in-hbox
  "nested vbox in hbox produces correct leaf positions"
  (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)))
         (root (hbox (:width 30 :height 10)
                sidebar main)))
    (compute-layout root 30 10)
    ;; sidebar takes 5 cols, main takes 25 cols (grows)
    (is (= (layout-node-width sidebar) 5))
    (is (>= (layout-node-width main) 20))
    ;; sidebar children positioned correctly
    (let ((sidebar-children (layout-node-children sidebar)))
      (is (= (layout-node-y (elt sidebar-children 0)) 0))
      (is (= (layout-node-y (elt sidebar-children 1)) 3)))))

Implementation

Package

(defpackage :cl-tui.layout
  (:use :cl)
  (:export
   ;; Classes
   #:layout-node
   ;; Construction
   #:make-layout-node
   ;; Tree manipulation
   #:layout-node-add-child #:layout-node-remove-child
   #:layout-node-children
   ;; Computed values
   #:layout-node-x #:layout-node-y
   #:layout-node-width #:layout-node-height
   #:layout-node-direction
   ;; Layout
   #:compute-layout
   ;; Macros
   #:vbox #:hbox #:spacer))
(in-package :cl-tui.layout)

Layout Node Class

(defclass layout-node ()
  ;; Tree structure
  ((parent   :initform nil :accessor layout-node-parent)
   (children :initform '() :accessor layout-node-children)
   ;; Computed layout (populated by compute-layout)
   (x        :initform 0 :accessor layout-node-x)
   (y        :initform 0 :accessor layout-node-y)
   (width    :initform 0 :accessor layout-node-width)
   (height   :initform 0 :accessor layout-node-height)
   ;; Flex properties
   (direction       :initform :column
                    :initarg :direction :accessor layout-node-direction)
   (wrap            :initform :nowrap
                    :initarg :wrap :accessor layout-node-wrap)
   (grow            :initform 0 :initarg :grow
                    :accessor layout-node-grow)
   (shrink          :initform 1 :initarg :shrink
                    :accessor layout-node-shrink)
   (basis           :initform :auto :initarg :basis
                    :accessor layout-node-basis)
   (align-items     :initform :stretch :initarg :align-items
                    :accessor layout-node-align-items)
   (justify-content :initform :flex-start :initarg :justify-content
                    :accessor layout-node-justify-content)
   ;; Box model
   (padding  :initform '(:top 0 :right 0 :bottom 0 :left 0)
             :initarg :padding :accessor layout-node-padding)
   (margin   :initform '(:top 0 :right 0 :bottom 0 :left 0)
             :initarg :margin :accessor layout-node-margin)
   (border   :initform '(:top 0 :right 0 :bottom 0 :left 0)
             :initarg :border :accessor layout-node-border)
   (gap      :initform 0 :initarg :gap :accessor layout-node-gap)
   ;; Position
   (position-type :initform :relative :initarg :position-type
                  :accessor layout-node-position-type)
   (position-offset :initform nil :initarg :position-offset
                    :accessor layout-node-position-offset)
   ;; Fixed dimensions (nil = auto)
   (fixed-width  :initform nil :initarg :width
                 :accessor layout-node-fixed-width)
   (fixed-height :initform nil :initarg :height
                 :accessor layout-node-fixed-height)))

Constructor

(defun make-layout-node (&key direction wrap grow shrink basis
                         align-items justify-content
                         padding margin border gap
                         position-type position-offset
                         width height)
  (make-instance 'layout-node
    :direction (or direction :column)
    :wrap (or wrap :nowrap)
    :grow (or grow 0)
    :shrink (or shrink 1)
    :basis (or basis :auto)
    :align-items (or align-items :stretch)
    :justify-content (or justify-content :flex-start)
    :padding (normalize-box padding)
    :margin (normalize-box margin)
    :border (normalize-box border)
    :gap gap
    :position-type (or position-type :relative)
    :position-offset position-offset
    :width width
    :height height))

(defun normalize-box (spec)
  "Convert a box property spec to ( :top N :right N :bottom N :left N )."
  (cond ((null spec)        '(:top 0 :right 0 :bottom 0 :left 0))
        ((numberp spec)     `(:top ,spec :right ,spec :bottom ,spec :left ,spec))
        ((getf spec :top)   spec)
        (t                  `(:top 0 :right 0 :bottom 0 :left 0))))

Tree Manipulation

(defun layout-node-add-child (parent child)
  (setf (slot-value child 'parent) parent)
  (push child (slot-value parent 'children))
  child)

(defun layout-node-remove-child (parent child)
  (setf (slot-value child 'parent) nil)
  (setf (slot-value parent 'children)
        (delete child (slot-value parent 'children)))
  child)

(defun box-edge (box edge)
  "Get a specific edge value from a box plist."
  (or (getf box edge) 0))

Constraint Solver

(defun compute-layout (root available-width available-height)
  "Run the layout algorithm on the entire tree."
  (labels

    ((resolve-main-size (node)
       ;; Get the main-axis size from fixed dimension or basis
       (if (eql (layout-node-direction node) :row)
           (layout-node-fixed-width node)
           (layout-node-fixed-height node)))

     (resolve-cross-size (node)
       (if (eql (layout-node-direction node) :row)
           (layout-node-fixed-height node)
           (layout-node-fixed-width node)))

     (compute-node (node x-offset y-offset max-w max-h)
       (let* ((dir (layout-node-direction node))
              (pad-top (box-edge (layout-node-padding node) :top))
              (pad-right (box-edge (layout-node-padding node) :right))
              (pad-bottom (box-edge (layout-node-padding node) :bottom))
              (pad-left (box-edge (layout-node-padding node) :left))
              (pad-x (+ pad-left pad-right))
              (pad-y (+ pad-top pad-bottom))
              (margin-top (box-edge (layout-node-margin node) :top))
              (margin-left (box-edge (layout-node-margin node) :left))
              (gap (layout-node-gap node))
              ;; Content area (minus padding)
              (content-w (max 0 (- max-w pad-x)))
              (content-h (max 0 (- max-h pad-y)))
              (children (reverse (layout-node-children node)))
              (is-row (eql dir :row))
              (main-axis (if is-row :width :height))
              (cross-axis (if is-row :height :width))
              ;; First pass: measure children
              (child-count (length children)))

         ;; Set own position
         (setf (layout-node-x node) (+ x-offset margin-left pad-left)
               (layout-node-y node) (+ y-offset margin-top pad-top))

         (when (plusp child-count)
           ;; Calculate main-axis sizes
           (let* ((fixed-sizes (mapcar (lambda (c)
                                         (or (resolve-main-size c)
                                             (if is-row
                                                 (or (layout-node-fixed-width c)
                                                     (round content-w child-count))
                                                 (or (layout-node-fixed-height c)
                                                     (round content-h child-count)))))
                                       children))
                  (total-fixed (reduce #'+ fixed-sizes))
                  (total-grow (reduce #'+ (mapcar #'layout-node-grow children)))
                  (total-shrink (reduce #'+ (mapcar #'layout-node-shrink children)))
                  (remaining (- (if is-row content-w content-h) total-fixed))
                  (available-without-gap (if is-row content-w content-h))
                  (gap-total (* gap (max 0 (1- child-count))))
                  ;; Account for gap in available space
                  (available (- available-without-gap gap-total))
                  (overflow (- total-fixed available))
                  ;; Distribute grow/shrink
                  (final-sizes
                    (mapcar (lambda (child fixed)
                              (let* ((g (layout-node-grow child))
                                     (s (layout-node-shrink child))
                                     (size fixed))
                                (when (and (plusp remaining) (plusp total-grow))
                                  (incf size (round (* remaining (/ g total-grow)))))
                                (when (and (plusp overflow) (plusp total-shrink))
                                  (decf size (round (* overflow (/ s total-shrink)))))
                                (max 0 size)))
                            children fixed-sizes)))

             ;; Second pass: position children
             (let ((pos 0))
               (mapc (lambda (child size)
                       (if is-row
                           (progn
                             (setf (layout-node-width child) size
                                   (layout-node-x child) (+ pad-left x-offset pos)
                                   (layout-node-height child) content-h
                                   (layout-node-y child) (+ pad-top y-offset))
                             (compute-node child
                               (layout-node-x child)
                               (layout-node-y child)
                               size content-h))
                           (progn
                             (setf (layout-node-height child) size
                                   (layout-node-y child) (+ pad-top y-offset pos)
                                   (layout-node-width child) content-w
                                   (layout-node-x child) (+ pad-left x-offset))
                             (compute-node child
                               (layout-node-x child)
                               (layout-node-y child)
                               content-w size)))
                       (incf pos (+ size gap)))
                     children final-sizes))))

         ;; Set own size to content size
         (let ((last-child (first (last children))))
           (if is-row
               (progn
                 (setf (layout-node-width node)
                       (if (layout-node-fixed-width node)
                           (layout-node-fixed-width node)
                           (if last-child
                               (+ (layout-node-x last-child)
                                  (layout-node-width last-child)
                                  pad-right margin-left)
                               max-w)))
                 (setf (layout-node-height node) max-h))
               (progn
                 (setf (layout-node-height node)
                       (if (layout-node-fixed-height node)
                           (layout-node-fixed-height node)
                           (if last-child
                               (+ (layout-node-y last-child)
                                  (layout-node-height last-child)
                                  pad-bottom margin-top)
                               max-h)))
                 (setf (layout-node-width node) max-w))))

         node))

    (compute-node root 0 0 available-width available-height)
    root))

Composable Macros

(defmacro vbox ((&key grow shrink basis align-items justify-content
                 padding margin border gap width height)
                &body children)
  "Create a vertical column container."
  (let ((node (gensym)))
    `(let ((,node (make-layout-node
                   :direction :column
                   ,@(when grow `(:grow ,grow))
                   ,@(when shrink `(:shrink ,shrink))
                   ,@(when basis `(:basis ,basis))
                   ,@(when align-items `(:align-items ,align-items))
                   ,@(when justify-content `(:justify-content ,justify-content))
                   ,@(when padding `(:padding ,padding))
                   ,@(when margin `(:margin ,margin))
                   ,@(when border `(:border ,border))
                   ,@(when gap `(:gap ,gap))
                   ,@(when width `(:width ,width))
                   ,@(when height `(:height ,height)))))
       ,@(loop for child in children collect
               `(layout-node-add-child ,node ,child))
       ,node)))

(defmacro hbox ((&key grow shrink basis align-items justify-content
                 padding margin border gap width height)
                &body children)
  "Create a horizontal row container."
  (let ((node (gensym)))
    `(let ((,node (make-layout-node
                   :direction :row
                   ,@(when grow `(:grow ,grow))
                   ,@(when shrink `(:shrink ,shrink))
                   ,@(when basis `(:basis ,basis))
                   ,@(when align-items `(:align-items ,align-items))
                   ,@(when justify-content `(:justify-content ,justify-content))
                   ,@(when padding `(:padding ,padding))
                   ,@(when margin `(:margin ,margin))
                   ,@(when border `(:border ,border))
                   ,@(when gap `(:gap ,gap))
                   ,@(when width `(:width ,width))
                   ,@(when height `(:height ,height)))))
       ,@(loop for child in children collect
               `(layout-node-add-child ,node ,child))
       ,node)))

(defmacro spacer (&key grow)
  "Create an empty flex spacer."
  `(make-layout-node :grow ,(or grow 1)))