#+TITLE: Box and Text Renderables #+STARTUP: content #+FILETAGS: :cl-tty:components: * Overview Box and Text are the two fundamental renderable component types. Box provides a bordered container with optional background fill and title. Text renders strings with word-wrap, color, and inline style spans. Both inherit from ~dirty-mixin~ for incremental rendering support and carry a ~layout-node~ for position/size computed by the layout engine. * Contract ** Box - ~(make-box &key border-style title title-align fg bg width height)~ → box - ~(render-box box backend)~ — draw the box at its layout position - Border styles: ~:single~, ~:double~, ~:rounded~, ~nil~ (no border) ** Span - ~(span text &key bold italic underline reverse dim fg bg)~ → span - Inline text segment with per-run style attributes. ** Text - ~(make-text content &key fg bg wrap-mode width height spans)~ → text - ~(render-text text-object backend)~ — render text at layout position - Wrap modes: ~:word~ (break at word boundaries), ~:none~ (truncate) ** Utilities - ~(word-wrap text max-width)~ → list of strings - ~(split-string string)~ → list of words * Tests ** Package and test suite setup The test package exports ~run-tests~ so it can be invoked from the top-level test runner. ~fiveam~ imports directly for declarative ~test~ and ~is~ forms. The ~box-suite~ collects all box/text tests. #+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/src/components/box-tests.lisp (defpackage :cl-tty-box-test (:use :cl :fiveam :cl-tty.backend :cl-tty.layout :cl-tty.box) (:export #:run-tests)) (in-package :cl-tty-box-test) (def-suite box-suite :description "Box renderable tests") (in-suite box-suite) #+END_SRC ** Test runner entry point ~run-tests~ is the entry point called from the top-level ~run-all-tests.lisp~. It runs the ~box-suite~, explains results to stdout, and exits cleanly with ~uiop:quit~. #+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/src/components/box-tests.lisp (defun run-tests () (let ((result (run 'box-suite))) (fiveam:explain! result) (uiop:quit 0))) #+END_SRC ** Capturing backend helper ~make-capturing-backend~ creates a backend that writes to a ~string-output-stream~ so tests can inspect rendered output without actual terminal I/O. Returns the backend and stream as multiple values. #+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/src/components/box-tests.lisp (defun make-capturing-backend () (let* ((s (make-string-output-stream)) (b (make-modern-backend :output-stream s))) (values b s))) #+END_SRC ** Test: box-creates-with-defaults Verify that a bare ~make-box~ returns a ~box~ instance and automatically creates a ~layout-node~ through inheritance. #+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/src/components/box-tests.lisp (test box-creates-with-defaults "A box created with no arguments has reasonable defaults" (let ((b (make-box))) (is (typep b 'box)) (is (typep (box-layout-node b) 'layout-node)))) #+END_SRC ** Test: box-renders-border Verify that a box with ~:border-style :single~ draws the four corner characters (┌ ┐ └ ┘) in the output stream. #+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/src/components/box-tests.lisp (test box-renders-border "A box with border draws border characters" (multiple-value-bind (b s) (make-capturing-backend) (let ((bx (make-box :border-style :single :width 10 :height 5))) (compute-layout (box-layout-node bx) 10 5) (render-box bx b) (let ((out (get-output-stream-string s))) (is (search "┌" out) "top-left corner") (is (search "┐" out) "top-right corner") (is (search "└" out) "bottom-left corner") (is (search "┘" out) "bottom-right corner"))))) #+END_SRC ** Test: box-renders-background Verify that a box with ~:bg :red~ emits SGR background color codes (41m) in the output stream. #+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/src/components/box-tests.lisp (test box-renders-background "A box with background color fills interior" (multiple-value-bind (b s) (make-capturing-backend) (let ((bx (make-box :bg :red :width 5 :height 3))) (compute-layout (box-layout-node bx) 5 3) (render-box bx b) (let ((out (get-output-stream-string s))) (is (search "┌" out) "border with background") (is (search "41m" out) "SGR background for red"))))) #+END_SRC ** Test: box-renders-title Verify that a title string appears in the rendered output stream when ~:title~ is provided. #+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/src/components/box-tests.lisp (test box-renders-title "A box with title renders the title text" (multiple-value-bind (b s) (make-capturing-backend) (let ((bx (make-box :title "Hello" :width 12 :height 3))) (compute-layout (box-layout-node bx) 12 3) (render-box bx b) (let ((out (get-output-stream-string s))) (is (search "Hello" out) "title text should appear"))))) #+END_SRC ** Test: box-without-border Verify that ~:border-style nil~ suppresses corner characters but background fill rendering continues to work. #+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/src/components/box-tests.lisp (test box-without-border "A box with border-style nil draws no border" (multiple-value-bind (b s) (make-capturing-backend) (let ((bx (make-box :border-style nil :bg :red :width 5 :height 3))) (compute-layout (box-layout-node bx) 5 3) (render-box bx b) (let ((out (get-output-stream-string s))) (is (search "41m" out) "background still renders") (is-false (search "┌" out) "no top-left corner"))))) #+END_SRC ** Test: box-zero-size Verify that a box with zero width and height produces no output (triggers the early-return guard in ~render-box~). #+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/src/components/box-tests.lisp (test box-zero-size "A box with any zero dimension renders nothing" (multiple-value-bind (b s) (make-capturing-backend) (let ((bx (make-box :border-style :single :width 0 :height 0))) (compute-layout (box-layout-node bx) 0 0) (render-box bx b) (is (string= (get-output-stream-string s) "") "zero-size box produces no output")))) #+END_SRC ** Test: box-single-column Verify that a box with width 1 produces no output — ~draw-border~ requires at least 2 columns to draw corner and edge characters. #+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/src/components/box-tests.lisp (test box-single-column "A box with width 1 renders nothing (needs min 2 for border)" (multiple-value-bind (b s) (make-capturing-backend) (let ((bx (make-box :border-style :single :width 1 :height 5))) (compute-layout (box-layout-node bx) 1 5) (render-box bx b) (is (string= (get-output-stream-string s) "") "width=1 box renders nothing")))) #+END_SRC ** Test: box-minimum-size Verify that a 2x2 box (the minimum viable size for border rendering) still produces corner characters in the output. #+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/src/components/box-tests.lisp (test box-minimum-size "A box with minimum non-zero size still renders" (multiple-value-bind (b s) (make-capturing-backend) (let ((bx (make-box :border-style :single :width 2 :height 2))) (compute-layout (box-layout-node bx) 2 2) (render-box bx b) (let ((out (get-output-stream-string s))) (is (search "┌" out) "2x2 box still has borders"))))) #+END_SRC ** Test: text-creates-with-defaults Verify that ~make-text~ with an empty string returns a ~text~ instance and creates a ~layout-node~. #+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/src/components/box-tests.lisp (test text-creates-with-defaults "A text created with no arguments has reasonable defaults" (let ((txt (make-text ""))) (is (typep txt 'text)) (is (typep (text-layout-node txt) 'layout-node)))) #+END_SRC ** Test: text-renders-content Verify that text content appears in the captured output stream after rendering. #+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/src/components/box-tests.lisp (test text-renders-content "A text renders its content at position" (multiple-value-bind (b s) (make-capturing-backend) (let ((tx (make-text "Hello" :width 10 :height 1))) (compute-layout (text-layout-node tx) 10 1) (render-text tx b) (let ((out (get-output-stream-string s))) (is (search "Hello" out) "content should appear"))))) #+END_SRC ** Test: text-empty-string Verify that an empty string produces no output (triggers the early-return guard in ~render-text~). #+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/src/components/box-tests.lisp (test text-empty-string "Empty text produces no output" (multiple-value-bind (b s) (make-capturing-backend) (let ((tx (make-text "" :width 10 :height 1))) (compute-layout (text-layout-node tx) 10 1) (render-text tx b) (is (string= (get-output-stream-string s) "") "empty string produces no output")))) #+END_SRC ** Test: text-truncates-when-no-wrap Verify that ~:wrap-mode :none~ truncates the content string to fit within the available width, producing only the first N characters. #+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/src/components/box-tests.lisp (test text-truncates-when-no-wrap "Text with wrap-mode :none truncates at width" (multiple-value-bind (b s) (make-capturing-backend) (let ((tx (make-text "Hello World" :width 5 :height 1 :wrap-mode :none))) (compute-layout (text-layout-node tx) 5 1) (render-text tx b) (let ((out (get-output-stream-string s))) (is (search "Hello" out) "truncated to first 5 chars"))))) #+END_SRC ** Test: text-word-wraps Verify that ~:wrap-mode :word~ breaks lines at word boundaries, distributing words across successive rows. #+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/src/components/box-tests.lisp (test text-word-wraps "Text with wrap-mode :word wraps at word boundaries" (multiple-value-bind (b s) (make-capturing-backend) (let ((tx (make-text "Hello brave new world" :width 6 :height 3))) (compute-layout (text-layout-node tx) 6 3) (render-text tx b) (let ((out (get-output-stream-string s))) (is (search "Hello" out) "first line") (is (search "brave" out) "second line") (is (search "new" out) "third line"))))) #+END_SRC ** Test: text-word-wrap-single-word Verify that a single word longer than the available width is hard-broken at character boundaries into ~max-width~-sized chunks. #+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/src/components/box-tests.lisp (test text-word-wrap-single-word "A word longer than width is hard-broken at max-width" (multiple-value-bind (b s) (make-capturing-backend) (let ((tx (make-text "Hello" :width 3 :height 3))) (compute-layout (text-layout-node tx) 3 3) (render-text tx b) (let ((out (get-output-stream-string s))) (is (search "Hel" out) "first chunk is Hel") (is (search "lo" out) "second chunk is lo"))))) #+END_SRC ** Test: span-creates-with-attributes Verify that ~span~ stores its text content and style attributes correctly, with unset attributes defaulting to ~nil~. #+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/src/components/box-tests.lisp (test span-creates-with-attributes "A span has text and optional style attributes" (let ((s (span "bold text" :bold t))) (is (string= (span-text s) "bold text")) (is-true (span-bold s)) (is-false (span-italic s)))) #+END_SRC ** Test: make-text-with-spans Verify that ~make-text~ with ~:spans~ stores the provided span objects and they are accessible via ~text-spans~. #+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/src/components/box-tests.lisp (test make-text-with-spans "Text with spans stores span objects" (let* ((sp (list (span "Hello" :bold t) (span "World" :italic t))) (tx (make-text "" :spans sp))) (is (= (length (text-spans tx)) 2)) (is (string= (span-text (elt (text-spans tx) 0)) "Hello")) (is-true (span-bold (elt (text-spans tx) 0))))) #+END_SRC * Implementation ** Box class ~box~ inherits from ~dirty-mixin~ so changes (resize, title update, color change) trigger incremental re-render. The ~layout-node~ slot holds the computed position and size from the layout engine. Border style, title, alignment, and colors are all configurable slots. #+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/src/components/box.lisp (in-package :cl-tty.box) (defclass box (dirty-mixin) ((layout-node :initform (make-layout-node) :accessor box-layout-node :initarg :layout-node) (border-style :initform :single :initarg :border-style :accessor box-border-style) (title :initform nil :initarg :title :accessor box-title) (title-align :initform :left :initarg :title-align :accessor box-title-align) (fg :initform nil :initarg :fg :accessor box-fg) (bg :initform nil :initarg :bg :accessor box-bg))) #+END_SRC ** make-box constructor The constructor wraps ~make-instance~ and passes layout parameters through to the layout node. Width and height are optional; when omitted the layout engine will compute them from parent constraints. #+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/src/components/box.lisp (defun make-box (&key (border-style :single) title (title-align :left) fg bg width height) (make-instance 'box :border-style border-style :title title :title-align title-align :fg fg :bg bg :layout-node (make-layout-node :width width :height height :direction :column))) #+END_SRC ** render-box function ~render-box~ draws the border at the component's layout position. It handles zero-size (returns immediately) and optional background fill. The early return for ~(< w 2)~ is important: ~draw-border~ requires at least 2 columns of width to draw corner characters. Title rendering supports ~:left~, ~:center~, and ~:right~ alignment with automatic truncation when the title is wider than available content area (width-4 when border is present). #+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/src/components/box.lisp (defun render-box (box backend) "Render BOX at its computed layout position using BACKEND." (let ((ln (box-layout-node box)) (bs (box-border-style box)) (title (box-title box)) (fg (box-fg box)) (bg (box-bg box))) (let ((x (layout-node-x ln)) (y (layout-node-y ln)) (w (layout-node-width ln)) (h (layout-node-height ln))) (when (or (zerop w) (zerop h) (< w 2) (< h 2)) (return-from render-box (values))) (when bg (draw-rect backend x y w h :bg bg)) (when bs (draw-border backend x y w h :style bs :fg fg :bg bg)) (when title (let* ((content-w (- w 4)) (tx (+ x 2)) (ty (+ y (if bs 1 0))) (ta (box-title-align box)) (display (subseq title 0 (min (length title) content-w)))) (case ta (:center (draw-text backend (+ x (ceiling (- w (length display)) 2)) ty display fg bg)) (:right (draw-text backend (+ x (- w (length display) 2)) ty display fg bg)) (t (draw-text backend tx ty display fg bg)))))))) #+END_SRC ** Span class ~span~ represents an inline styled segment within a Text component. Multiple spans let a single Text contain bold, colored, or italicized runs. Each style attribute is a separate slot so consumers can inspect and apply them individually. #+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/src/components/text.lisp (in-package :cl-tty.box) (defclass span () ((text :initarg :text :accessor span-text) (bold :initform nil :initarg :bold :accessor span-bold) (italic :initform nil :initarg :italic :accessor span-italic) (underline :initform nil :initarg :underline :accessor span-underline) (reverse :initform nil :initarg :reverse :accessor span-reverse) (dim :initform nil :initarg :dim :accessor span-dim) (fg :initform nil :initarg :fg :accessor span-fg) (bg :initform nil :initarg :bg :accessor span-bg))) #+END_SRC ** span constructor ~span~ is a convenience function for creating ~span~ instances with keyword arguments for all style attributes. A ~nil~ default means "inherit/no-change" when merged with parent styling context. #+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/src/components/text.lisp (defun span (text &key bold italic underline reverse dim fg bg) (make-instance 'span :text text :bold bold :italic italic :underline underline :reverse reverse :dim dim :fg fg :bg bg)) #+END_SRC ** Text class ~text~ renders a string at a layout position with word-wrapping. Spans are stored for future per-run styling but the current implementation renders all content as plain text. It inherits from ~dirty-mixin~ so content, color, or size changes trigger re-render. #+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/src/components/text.lisp (defclass text (dirty-mixin) ((layout-node :initform (make-layout-node) :accessor text-layout-node :initarg :layout-node) (content :initform "" :initarg :content :accessor text-content) (spans :initform nil :initarg :spans :accessor text-spans) (fg :initform nil :initarg :fg :accessor text-fg) (bg :initform nil :initarg :bg :accessor text-bg) (wrap-mode :initform :word :initarg :wrap-mode :accessor text-wrap-mode))) #+END_SRC ** make-text constructor ~make-text~ is a convenience constructor that accepts layout dimensions and content parameters. It defaults ~wrap-mode~ to ~:word~ so text wraps by default, and creates a ~:column~-oriented layout node. #+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/src/components/text.lisp (defun make-text (content &key fg bg wrap-mode width height spans) (make-instance 'text :content content :fg fg :bg bg :wrap-mode (or wrap-mode :word) :spans spans :layout-node (make-layout-node :direction :column :width width :height height))) #+END_SRC ** render-text function ~render-text~ handles both wrap modes. For ~:word~, it calls ~word-wrap~ to break the content into lines, then renders each line at successive row positions. For ~:none~, it truncates the content to fit the width in a single line. Empty content or zero dimensions triggers an early return. #+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/src/components/text.lisp (defun render-text (text-object backend) "Render TEXT-OBJECT at its computed layout position using BACKEND." (let ((ln (text-layout-node text-object)) (content (text-content text-object)) (fg (text-fg text-object)) (bg (text-bg text-object)) (wrap (text-wrap-mode text-object)) (spans (text-spans text-object))) (declare (ignore spans)) (let ((x (layout-node-x ln)) (y (layout-node-y ln)) (w (layout-node-width ln)) (h (layout-node-height ln))) (when (or (zerop (length content)) (zerop w) (zerop h)) (return-from render-text (values))) (if (eql wrap :none) (let ((display (subseq content 0 (min (length content) w)))) (draw-text backend x y display fg bg)) (let ((lines (word-wrap content w)) (max-lines h)) (loop for line in lines for row from 0 below max-lines do (draw-text backend x (+ y row) line fg bg))))))) #+END_SRC ** Word wrapping utility ~word-wrap~ implements the line-breaking algorithm. It splits the input into words, then packs them into lines respecting ~max-width~. Words that exceed ~max-width~ are hard-broken at character boundaries in chunks of ~max-width~ to ensure no line exceeds the limit. #+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/src/components/text.lisp (defun word-wrap (text max-width) "Split TEXT into lines, each <= MAX-WIDTH chars." (if (or (zerop max-width) (zerop (length text))) (list "") (let ((words (split-string text)) (lines nil) (current nil) (current-len 0)) (dolist (word words) (let ((wl (length word))) (cond ((<= wl max-width) (if (and current (<= (+ current-len 1 wl) max-width)) (progn (push word current) (incf current-len (1+ wl))) (progn (when current (push (format nil "~{~A~^ ~}" (nreverse current)) lines)) (setf current (list word)) (setf current-len wl)))) (t (when current (push (format nil "~{~A~^ ~}" (nreverse current)) lines) (setf current nil) (setf current-len 0)) (loop for i from 0 below wl by max-width do (push (subseq word i (min (+ i max-width) wl)) lines)))))) (when current (push (format nil "~{~A~^ ~}" (nreverse current)) lines)) (or (nreverse lines) (list ""))))) #+END_SRC ** split-string utility ~split-string~ tokenizes on whitespace characters (space, tab, newline). It uses ~position-if~ to find delimiters and builds the word list iteratively. Consecutive delimiters are collapsed (only one advance per delimiter character). #+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/src/components/text.lisp (defun split-string (string) "Split STRING into words separated by whitespace." (loop with words = nil with start = 0 with len = (length string) while (< start len) do (let ((ws-start (position-if (lambda (c) (find c '(#\Space #\Tab #\Newline))) string :start start))) (if ws-start (progn (when (> ws-start start) (push (subseq string start ws-start) words)) (setf start (1+ ws-start))) (progn (push (subseq string start) words) (setf start len)))) finally (return (nreverse words)))) #+END_SRC ** char-width utility ~char-width~ returns the terminal column width of a character. ASCII < 128 = 1. CJK, fullwidth, emoji = 2. Combining marks = 0. Tab = 8. Used by layout calculations that need to handle variable-width characters. #+BEGIN_SRC lisp :tangle ~/.local/share/cl-tty/src/components/text.lisp (defun char-width (ch) "Returns the terminal column width of character CH." (let ((code (char-code ch))) (cond ((= code 9) 8) ((< code 32) 0) ((<= code 127) 1) ((<= #x4E00 code #x9FFF) 2) ((<= #x3400 code #x4DBF) 2) ((<= #x3040 code #x309F) 2) ((<= #x30A0 code #x30FF) 2) ((<= #xAC00 code #xD7AF) 2) ((<= #xFF01 code #xFF60) 2) ((<= #xFFE0 code #xFFE6) 2) ((<= #x1F300 code #x1F9FF) 2) ((<= #x2600 code #x27BF) 2) ((<= #x0300 code #x036F) 0) ((<= #x20D0 code #x20FF) 0) ((<= #xFE00 code #xFE0F) 0) (t 1)))) #+END_SRC