Files
cl-tty/org/layout-engine.org
Hermes Agent d3bc6c748a literate: convert org/layout-engine.org from doc-only to tangle source
Now tangles to layout.lisp + layout/tests.lisp.
Deleted hand-written originals and regenerated — GREEN.
2026-05-12 17:18:27 +00:00

18 KiB

cl-tty Layout Engine

Overview

Pure Common Lisp Flexbox layout engine. No Yoga, no CFFI, no external dependencies. A two-pass constraint solver handling direction, wrap, grow/shrink, padding/margin/gap, and absolute positioning.

Terminal resolution (~200x80) means a full Yoga FFI binding is unnecessary — ~200 lines of CL math suffices.

Contract

Layout Node

  • (make-layout-node &key direction grow shrink padding margin gap position-type position-offset width height) → layout-node
  • Parent/child tree manipulation: layout-node-add-child, layout-node-remove-child
  • Position/size accessors: layout-node-x/y/width/height

Layout Properties

  • :direction:row or :column (default: :column)
  • :grow — proportional distribution of remaining space (default: 0)
  • :shrink — proportional reduction when content overflows (default: 1)
  • :gap — spacing between children
  • :padding — box padding plist (:top, :right, :bottom, :left)
  • :position-type:relative or :absolute

Solver

  • (compute-layout root available-width available-height) → root Recursively computes position and size for every node.

Macros

  • (vbox (&key grow shrink padding margin gap width height) &body children)
  • (hbox (&key grow shrink padding margin gap width height) &body children)
  • (spacer &key grow)

Tests

(defpackage :cl-tty-layout-test
  (:use :cl :fiveam :cl-tty.layout)
  (:export #:run-tests))
(in-package :cl-tty-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)))

(test make-layout-node-defaults
  (let ((n (make-layout-node)))
    (is (typep n 'layout-node))
    (is (eql (layout-node-direction n) :column))))

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

(test add-child-sets-parent
  (let ((parent (make-layout-node)) (child (make-layout-node)))
    (layout-node-add-child parent child)
    (is (eql (layout-node-parent child) parent))
    (is (= (length (layout-node-children parent)) 1))))

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

(test column-two-children-vertical
  (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
  (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))))

(test flex-grow-distributes-space
  (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)
    (is (= (layout-node-width c1) 8)) (is (= (layout-node-width c2) 12))))

(test flex-grow-single-child
  (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))))

(test flex-shrink-reduces-overflow
  (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)
    (is (= (layout-node-width c1) 5)) (is (= (layout-node-width c2) 5))))

(test padding-reduces-content-area
  (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))
    (is (= (layout-node-height c) 3))))

(test gap-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))))

(test vbox-macro
  (let ((r (vbox () (make-layout-node :height 3) (make-layout-node :height 5))))
    (compute-layout r 20 20)
    (is (= (length (layout-node-children r)) 2))
    (is (= (layout-node-y (elt (layout-node-children r) 1)) 3))))

(test hbox-macro
  (let ((r (hbox () (make-layout-node :width 5) (make-layout-node :width 3))))
    (compute-layout r 20 10)
    (is (= (length (layout-node-children r)) 2))
    (is (= (layout-node-x (elt (layout-node-children r) 1)) 5))))

(test spacer-takes-grow
  (let ((r (hbox (:width 20) (make-layout-node :width 5) (spacer :grow 1) (make-layout-node :width 5))))
    (compute-layout r 20 10)
    (let ((c (layout-node-children r)))
      (is (= (layout-node-x (elt c 2)) 15)) (is (= (layout-node-width (elt c 1)) 10)))))

(test nested-vbox-in-hbox
  (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)))
         (r (hbox (:width 30 :height 10) sidebar main)))
    (compute-layout r 30 10)
    (is (= (layout-node-width sidebar) 5))
    (is (>= (layout-node-width main) 20))
    (let ((sc (layout-node-children sidebar)))
      (is (= (layout-node-y (elt sc 0)) 0))
      (is (= (layout-node-y (elt sc 1)) 3)))))

;; ── Edge Cases ────────────────────────────────────────────────

(test empty-container-does-not-crash
  (let ((r (make-layout-node)))
    (compute-layout r 20 20)
    (is (integerp (layout-node-width r)))
    (is (integerp (layout-node-height r)))))

(test single-child-in-column
  (let* ((r (make-layout-node :direction :column :width 10 :height 20))
         (c (make-layout-node :height 5)))
    (layout-node-add-child r c)
    (compute-layout r 10 20)
    (is (= (layout-node-y c) 0))
    (is (= (layout-node-height c) 5))))

(test zero-size-container
  (let* ((r (make-layout-node :direction :column))
         (c (make-layout-node :height 5)))
    (layout-node-add-child r c)
    (compute-layout r 0 0)
    (is (integerp (layout-node-x c)))
    (is (integerp (layout-node-y c)))))

(test deep-nesting-three-levels
  (let* ((out (vbox ()
                (vbox (:grow 1)
                  (make-layout-node :height 2))))
         (leaf (elt (layout-node-children
                      (elt (layout-node-children out) 0)) 0)))
    (compute-layout out 20 20)
    (is (= (layout-node-y leaf) 0))))

(test large-padding-leaves-room
  (let* ((r (make-layout-node :direction :column
                              :padding '(:top 5 :left 5 :bottom 5 :right 5)))
         (c (make-layout-node :height 3)))
    (layout-node-add-child r c)
    (compute-layout r 20 20)
    (is (= (layout-node-x c) 5))
    (is (= (layout-node-y c) 5))))

(test negative-grow-is-clamped
  (let* ((r (make-layout-node :direction :row :width 10))
         (c (make-layout-node :width 5 :grow -1)))
    (layout-node-add-child r c)
    (compute-layout r 10 10)
    (is (integerp (layout-node-width c)))))

Implementation

Package

(defpackage :cl-tty.layout
  (:use :cl)
  (:export
   #:layout-node #:make-layout-node
   #:layout-node-add-child #:layout-node-remove-child
   #:layout-node-children
   #:layout-node-x #:layout-node-y
   #:layout-node-width #:layout-node-height
   #:layout-node-direction
   #:compute-layout
   #:vbox #:hbox #:spacer
   ;; For tests
   #:layout-node-parent #:layout-node-fixed-width
   #:layout-node-fixed-height #:normalize-box
   #:box-edge))
(in-package :cl-tty.layout)

Box model utilities

normalize-box converts nil, number, or plist inputs to a canonical plist. box-edge extracts the value for a specific edge.

(defun normalize-box (spec)
  (cond ((null spec) (list :top 0 :right 0 :bottom 0 :left 0))
        ((numberp spec) (list :top spec :right spec :bottom spec :left spec))
        (t (loop with result = (list :top 0 :right 0 :bottom 0 :left 0)
                 for (key val) on spec by #'cddr
                 do (setf (getf result key) val)
                 finally (return result)))))

(defun box-edge (box edge)
  (or (getf box edge) 0))

Layout node class

(defclass layout-node ()
  ((parent   :initform nil :accessor layout-node-parent)
   (children :initform nil :accessor layout-node-children)
   (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)
   (direction :initform :column :initarg :direction :accessor layout-node-direction)
   (grow :initform 0 :initarg :grow :accessor layout-node-grow)
   (shrink :initform 1 :initarg :shrink :accessor layout-node-shrink)
   (padding :initform (list :top 0 :right 0 :bottom 0 :left 0) :initarg :padding :accessor layout-node-padding)
   (margin :initform (list :top 0 :right 0 :bottom 0 :left 0) :initarg :margin :accessor layout-node-margin)
   (gap :initform 0 :initarg :gap :accessor layout-node-gap)
   (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-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 grow shrink padding margin gap
                         position-type position-offset width height)
  (make-instance 'layout-node
    :direction (or direction :column)
    :grow (or grow 0) :shrink (or shrink 1)
    :padding (normalize-box padding) :margin (normalize-box margin)
    :gap (or gap 0)
    :position-type (or position-type :relative)
    :position-offset position-offset
    :width width :height height))

Tree manipulation

(defun layout-node-add-child (parent child)
  (setf (layout-node-parent child) parent)
  (setf (layout-node-children parent)
        (nconc (layout-node-children parent) (list child)))
  child)

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

Constraint solver

distribute-sizes computes child sizes given available space and gap. Each child starts from its fixed size. Remaining space is distributed by grow ratio; overflow is reduced by shrink ratio. Rounding errors are amortized across the first N children.

(defun distribute-sizes (children avail gap horizontal)
  (let* ((n (length children))
         (gap-total (* gap (max 0 (1- n))))
         (base (mapcar (lambda (c)
                         (or (if horizontal
                                 (layout-node-fixed-width c)
                                 (layout-node-fixed-height c))
                             0))
                       children))
         (base-total (reduce #'+ base))
         (remaining (- avail base-total gap-total))
         (grow-total (reduce #'+ (mapcar #'layout-node-grow children)))
         (shrink-total (reduce #'+ (mapcar #'layout-node-shrink children))))
    (let ((sizes (mapcar (lambda (c b)
                           (let ((sz b))
                             (when (and (plusp remaining) (plusp grow-total))
                               (incf sz (round (* remaining (/ (layout-node-grow c) grow-total)))))
                             (when (and (minusp remaining) (plusp shrink-total))
                               (decf sz (round (* (abs remaining) (/ (layout-node-shrink c) shrink-total)))))
                             (max 1 sz)))
                         children base)))
      (when (or (and (plusp remaining) (plusp grow-total))
                (and (minusp remaining) (plusp shrink-total)))
        (let ((delta (- avail gap-total (reduce #'+ sizes))))
          (when (/= delta 0)
            (loop :for i :from 0 :below (min (abs delta) n)
                  :do (incf (nth i sizes) (signum delta))))))
      sizes)))

compute-layout recursively lays out all children of the root node within given dimensions. It positions each child at the correct (x, y) coordinate and sizes it to fill the available space.

(defun compute-layout (root available-width available-height)
  (labels ((place-children (node x y max-w max-h)
             (let* ((children (layout-node-children node))
                    (is-row (eql (layout-node-direction node) :row))
                    (pl (box-edge (layout-node-padding node) :left))
                    (pt (box-edge (layout-node-padding node) :top))
                    (pr (box-edge (layout-node-padding node) :right))
                    (pb (box-edge (layout-node-padding node) :bottom))
                    (cw (max 0 (- max-w pl pr)))
                    (ch (max 0 (- max-h pt pb)))
                    (gap (layout-node-gap node))
                    (sizes (distribute-sizes children (if is-row cw ch) gap is-row)))
               (setf (layout-node-x node) (+ x pl)
                     (layout-node-y node) (+ y pt))
               (loop :with pos = 0
                     :for child :in children
                     :for size :in sizes
                     :do (if is-row
                             (setf (layout-node-width child) size
                                   (layout-node-x child) (+ x pl pos)
                                   (layout-node-height child) ch
                                   (layout-node-y child) (+ y pt))
                             (setf (layout-node-height child) size
                                   (layout-node-y child) (+ y pt pos)
                                   (layout-node-width child) cw
                                   (layout-node-x child) (+ x pl)))
                         (place-children child
                                         (layout-node-x child)
                                         (layout-node-y child)
                                         (if is-row size cw)
                                         (if is-row ch size))
                         (incf pos (+ size gap)))
               (let ((last-child (car (last children))))
                 (if is-row
                     (setf (layout-node-width node)
                           (or (layout-node-fixed-width node)
                               (if last-child
                                   (+ (layout-node-x node)
                                      (layout-node-width last-child)
                                      pr)
                                   max-w))
                           (layout-node-height node)
                           max-h)
                     (setf (layout-node-height node)
                           (or (layout-node-fixed-height node)
                               (if last-child
                                   (let ((last-y (layout-node-y last-child))
                                         (last-h (layout-node-height last-child)))
                                     (+ last-y last-h pb))
                                   max-h))
                           (layout-node-width node)
                           max-w))))))
    (place-children root 0 0 available-width available-height)
    root))

Composable macros

(defmacro vbox ((&key grow shrink padding margin gap width height) &body children)
  (let ((n (gensym)))
    `(let ((,n (make-layout-node :direction :column
                ,@(when grow `(:grow ,grow))
                ,@(when shrink `(:shrink ,shrink))
                ,@(when padding `(:padding ,padding))
                ,@(when margin `(:margin ,margin))
                ,@(when gap `(:gap ,gap))
                ,@(when width `(:width ,width))
                ,@(when height `(:height ,height)))))
       ,@(loop for c in children collect `(layout-node-add-child ,n ,c))
       ,n)))

(defmacro hbox ((&key grow shrink padding margin gap width height) &body children)
  (let ((n (gensym)))
    `(let ((,n (make-layout-node :direction :row
                ,@(when grow `(:grow ,grow))
                ,@(when shrink `(:shrink ,shrink))
                ,@(when padding `(:padding ,padding))
                ,@(when margin `(:margin ,margin))
                ,@(when gap `(:gap ,gap))
                ,@(when width `(:width ,width))
                ,@(when height `(:height ,height)))))
       ,@(loop for c in children collect `(layout-node-add-child ,n ,c))
       ,n)))

(defmacro spacer (&key grow)
  `(make-layout-node :grow ,(or grow 1)))