review fixes: word-wrap hard-break, title-align, ASDF fix, edge cases

Fixes from subagent review:
- Word-wrap now hard-breaks words exceeding max-width (was returning
  un-truncated overflow strings)
- Box zero-size guard now catches any zero/single dimension (was only
  catching both zero together)
- Title-align now respected (:left/:center/:right) with proper positioning
- render-text declares (ignore spans) to suppress unused warning
- ASDF test-op fixed: run! → run-tests (symbol didn't exist)
- New test: box-single-column (width=1 renders nothing)
- Tightened word-wrap test: verifies hard-break produces both chunks
- Simplified word-wrap with cond instead of nested if/progn (avoided
  recurring paren-balance issue)
This commit is contained in:
Hermes
2026-05-11 14:57:44 +00:00
parent a1b1352d10
commit 88c576a6b9
4 changed files with 45 additions and 31 deletions

View File

@@ -38,4 +38,4 @@
((:file "box-tests") ((:file "box-tests")
(:file "dirty-tests")))) (:file "dirty-tests"))))
:perform (test-op (o c) :perform (test-op (o c)
(uiop:symbol-call :cl-tui-backend-test '#:run!))) (uiop:symbol-call :cl-tui-backend-test '#:run-tests)))

View File

@@ -66,7 +66,7 @@
(is-false (search "┌" out) "no top-left corner"))))) (is-false (search "┌" out) "no top-left corner")))))
(test box-zero-size (test box-zero-size
"A zero-size box renders nothing" "A box with any zero dimension renders nothing"
(multiple-value-bind (b s) (make-capturing-backend) (multiple-value-bind (b s) (make-capturing-backend)
(let ((bx (make-box :border-style :single :width 0 :height 0))) (let ((bx (make-box :border-style :single :width 0 :height 0)))
(compute-layout (box-layout-node bx) 0 0) (compute-layout (box-layout-node bx) 0 0)
@@ -74,6 +74,15 @@
(is (string= (get-output-stream-string s) "") (is (string= (get-output-stream-string s) "")
"zero-size box produces no output")))) "zero-size box produces no output"))))
(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"))))
(test box-minimum-size (test box-minimum-size
"A box with minimum non-zero size still renders" "A box with minimum non-zero size still renders"
(multiple-value-bind (b s) (make-capturing-backend) (multiple-value-bind (b s) (make-capturing-backend)
@@ -131,13 +140,14 @@
(is (search "new" out) "third line"))))) (is (search "new" out) "third line")))))
(test text-word-wrap-single-word (test text-word-wrap-single-word
"Text wraps even a single word if it exceeds width" "A word longer than width is hard-broken at max-width"
(multiple-value-bind (b s) (make-capturing-backend) (multiple-value-bind (b s) (make-capturing-backend)
(let ((tx (make-text "Hello" :width 3 :height 3))) (let ((tx (make-text "Hello" :width 3 :height 3)))
(compute-layout (text-layout-node tx) 3 3) (compute-layout (text-layout-node tx) 3 3)
(render-text tx b) (render-text tx b)
(let ((out (get-output-stream-string s))) (let ((out (get-output-stream-string s)))
(is (search "Hel" out) "word truncated to width"))))) (is (search "Hel" out) "first chunk is Hel")
(is (search "lo" out) "second chunk is lo")))))
(test span-creates-with-attributes (test span-creates-with-attributes
"A span has text and optional style attributes" "A span has text and optional style attributes"

View File

@@ -36,15 +36,19 @@
(y (layout-node-y ln)) (y (layout-node-y ln))
(w (layout-node-width ln)) (w (layout-node-width ln))
(h (layout-node-height ln))) (h (layout-node-height ln)))
(when (and (zerop w) (zerop h)) (when (or (zerop w) (zerop h) (< w 2) (< h 2))
(return-from render-box (values))) (return-from render-box (values)))
(when bg (when bg
(draw-rect backend x y w h :bg bg)) (draw-rect backend x y w h :bg bg))
(when bs (when bs
(draw-border backend x y w h (draw-border backend x y w h :style bs :fg fg :bg bg))
:style bs :fg fg :bg bg))
(when title (when title
;; Render title below top border, left-aligned inside the box (let* ((content-w (- w 4))
(let ((tx (+ x 2)) (tx (+ x 2))
(ty (+ y 1))) (ty (+ y (if bs 1 0)))
(draw-text backend tx ty title fg bg)))))) (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))))))))

View File

@@ -44,6 +44,7 @@
(bg (text-bg text-object)) (bg (text-bg text-object))
(wrap (text-wrap-mode text-object)) (wrap (text-wrap-mode text-object))
(spans (text-spans text-object))) (spans (text-spans text-object)))
(declare (ignore spans))
(let ((x (layout-node-x ln)) (let ((x (layout-node-x ln))
(y (layout-node-y ln)) (y (layout-node-y ln))
(w (layout-node-width ln)) (w (layout-node-width ln))
@@ -51,10 +52,8 @@
(when (or (zerop (length content)) (zerop w) (zerop h)) (when (or (zerop (length content)) (zerop w) (zerop h))
(return-from render-text (values))) (return-from render-text (values)))
(if (eql wrap :none) (if (eql wrap :none)
;; No wrap — truncate to width
(let ((display (subseq content 0 (min (length content) w)))) (let ((display (subseq content 0 (min (length content) w))))
(draw-text backend x y display fg bg)) (draw-text backend x y display fg bg))
;; Word wrap
(let ((lines (word-wrap content w)) (let ((lines (word-wrap content w))
(max-lines h)) (max-lines h))
(loop for line in lines (loop for line in lines
@@ -62,27 +61,28 @@
do (draw-text backend x (+ y row) line fg bg))))))) do (draw-text backend x (+ y row) line fg bg)))))))
(defun word-wrap (text max-width) (defun word-wrap (text max-width)
"Split TEXT into lines, each no longer than MAX-WIDTH characters. "Split TEXT into lines, each <= MAX-WIDTH chars.
Breaks at word boundaries when possible." Breaks at word boundaries. Words exceeding MAX-WIDTH are hard-broken."
(if (or (zerop max-width) (zerop (length text))) (if (or (zerop max-width) (zerop (length text)))
(list "") (list "")
(let ((words (split-string text)) (let ((words (split-string text)) (lines nil) (current nil) (current-len 0))
(lines nil)
(current nil)
(current-len 0))
(dolist (word words) (dolist (word words)
(let ((word-len (length word))) (let ((wl (length word)))
(if (and current (<= (+ current-len 1 word-len) max-width)) (cond ((<= wl max-width)
;; Add to current line (if (and current (<= (+ current-len 1 wl) max-width))
(progn
(push word current) (push word current)
(incf current-len (1+ word-len)))
;; Start new line
(progn (progn
(when current (when current
(push (format nil "~{~A~^ ~}" (nreverse current)) lines)) (push (format nil "~{~A~^ ~}" (nreverse current)) lines))
(setf current (list word)) (setf current (list word))
(setf current-len word-len))))) (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 (when current
(push (format nil "~{~A~^ ~}" (nreverse current)) lines)) (push (format nil "~{~A~^ ~}" (nreverse current)) lines))
(or (nreverse lines) (list ""))))) (or (nreverse lines) (list "")))))