Wrap a label, and bound it by the window rather than its box
frq's sign-in screen, painted here for the first time, hung off BOTH edges of the window. One long sentence made its container wider than the window, and a page that centres its content then centres something too big — so it overhangs left and right equally, which is what the screenshot showed. Labels wrap now, greedily and on spaces, measured through the same `c/measure` the drawing uses so a line that is said to fit does fit. A single word longer than the line is left long: a URL cut in half is harder to read than one that overflows, and the viewport clips either way. The interesting half is what to wrap AGAINST. Asking the box is useless — a box's width comes from what its children asked for last frame, so an unconstrained one is exactly as wide as its widest child, and answers "as much as you took" to a label wondering whether to wrap. The feedback loop never breaks. So `avail-width` is bounded by the WINDOW, which is the one width in the tree no child can talk into growing. Zero still means "not yet" and not "no room", the way `rect-for` already documents for the first frame. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
58ad86a parent: 99fabc1 modified
jvui/src/jvui/core.clj +25 -0 | @@ -256,6 +256,31 @@ | ||
| 256 | 256 | (tally! w mw mh grow-along?) |
| 257 | 257 | [x y w h])))) |
| 258 | 258 | |
| 259 | +(defn avail-width | |
| 260 | + "How much width the current box can still give a child. | |
| 261 | + | |
| 262 | + Along a row that is what is left after the children already placed; down | |
| 263 | + a column it is the whole width, because each child starts at the left. | |
| 264 | + | |
| 265 | + Answers 0 before a box has a width — a container's size comes from what | |
| 266 | + its children asked for LAST frame, so on the first one there is nothing | |
| 267 | + to divide. A caller must read 0 as \"no constraint known yet\" and not as | |
| 268 | + \"no room\": treating it as no room pins the child at zero and the loop | |
| 269 | + never recovers, which is the same trap `rect-for` documents above." | |
| 270 | + [] | |
| 271 | + (let [{:keys [dir rect] :as f} (top) | |
| 272 | + [rx _ rw _] rect | |
| 273 | + [sw _] (:size (ui)) | |
| 274 | + box (if (= dir :vertical) rw (- rw (ctr f CURSOR))) | |
| 275 | + ;; Bounded by the WINDOW, not only by the box. A box's width comes | |
| 276 | + ;; from what its children asked for last frame, so an unconstrained | |
| 277 | + ;; one is exactly as wide as its widest child — ask it how much room | |
| 278 | + ;; there is and it answers "as much as you took", and a label that | |
| 279 | + ;; wants to wrap never does. The window is the one width in the tree | |
| 280 | + ;; that no child can talk into growing. | |
| 281 | + room (- (double (or sw 0)) rx)] | |
| 282 | + (max 0.0 (if (pos? room) (min box room) box)))) | |
| 283 | + | |
| 259 | 284 | (defn next-id |
| 260 | 285 | "Claim the next id under the current box." |
| 261 | 286 | ([] (next-id nil)) |
| @@ -256,6 +256,31 @@ | |||
| 256 | (tally! w mw mh grow-along?) | 256 | (tally! w mw mh grow-along?) |
| 257 | [x y w h])))) | 257 | [x y w h])))) |
| 258 | 258 | ||
| 259 | +(defn avail-width | ||
| 260 | + "How much width the current box can still give a child. | ||
| 261 | + | ||
| 262 | + Along a row that is what is left after the children already placed; down | ||
| 263 | + a column it is the whole width, because each child starts at the left. | ||
| 264 | + | ||
| 265 | + Answers 0 before a box has a width — a container's size comes from what | ||
| 266 | + its children asked for LAST frame, so on the first one there is nothing | ||
| 267 | + to divide. A caller must read 0 as \"no constraint known yet\" and not as | ||
| 268 | + \"no room\": treating it as no room pins the child at zero and the loop | ||
| 269 | + never recovers, which is the same trap `rect-for` documents above." | ||
| 270 | + [] | ||
| 271 | + (let [{:keys [dir rect] :as f} (top) | ||
| 272 | + [rx _ rw _] rect | ||
| 273 | + [sw _] (:size (ui)) | ||
| 274 | + box (if (= dir :vertical) rw (- rw (ctr f CURSOR))) | ||
| 275 | + ;; Bounded by the WINDOW, not only by the box. A box's width comes | ||
| 276 | + ;; from what its children asked for last frame, so an unconstrained | ||
| 277 | + ;; one is exactly as wide as its widest child — ask it how much room | ||
| 278 | + ;; there is and it answers "as much as you took", and a label that | ||
| 279 | + ;; wants to wrap never does. The window is the one width in the tree | ||
| 280 | + ;; that no child can talk into growing. | ||
| 281 | + room (- (double (or sw 0)) rx)] | ||
| 282 | + (max 0.0 (if (pos? room) (min box room) box)))) | ||
| 283 | + | ||
| 259 | (defn next-id | 284 | (defn next-id |
| 260 | "Claim the next id under the current box." | 285 | "Claim the next id under the current box." |
| 261 | ([] (next-id nil)) | 286 | ([] (next-id nil)) |
modified
jvui/src/jvui/widgets.clj +55 -11 | @@ -66,22 +66,66 @@ | ||
| 66 | 66 | |
| 67 | 67 | ;; -------------------------------------------------------------------- text |
| 68 | 68 | |
| 69 | +(defn- wrap-lines | |
| 70 | + "Break `s` into lines that each fit `width`, on spaces. | |
| 71 | + | |
| 72 | + Greedy and word-wise, which is what a paragraph of UI text wants. A | |
| 73 | + single word longer than the line is left long rather than broken | |
| 74 | + mid-word: a URL cut in half is harder to read than one that overflows, | |
| 75 | + and the viewport clips it either way. | |
| 76 | + | |
| 77 | + Measured through the same `c/measure` the drawing uses, so a line that | |
| 78 | + is said to fit does fit — a wrap computed against a different metric | |
| 79 | + than the renderer's is off by a word at the worst moments." | |
| 80 | + [s size width] | |
| 81 | + (let [words (str/split (str s) #" ")] | |
| 82 | + (loop [[w & more] words line nil out []] | |
| 83 | + (cond | |
| 84 | + (nil? w) (if line (conj out line) out) | |
| 85 | + (nil? line) (recur more w out) | |
| 86 | + :else | |
| 87 | + (let [try* (str line " " w)] | |
| 88 | + (if (<= (first (c/measure try* size)) width) | |
| 89 | + (recur more try* out) | |
| 90 | + (recur more w (conj out line)))))))) | |
| 91 | + | |
| 69 | 92 | (defn label |
| 70 | - "A line of text. Answers its rectangle." | |
| 93 | + "A line of text, wrapped to the space it was given. Answers its rectangle. | |
| 94 | + | |
| 95 | + Wrapping is on when the text does not fit and the box has told us how | |
| 96 | + much room there is. That is not a style choice: without it one long | |
| 97 | + sentence makes its container wider than the window, and a page that | |
| 98 | + centres its content then hangs off BOTH edges — which is exactly what | |
| 99 | + frq's sign-in screen did the first time it was painted here. | |
| 100 | + | |
| 101 | + `:wrap false` turns it off for a caller that would rather overflow, and | |
| 102 | + a width of zero — the first frame, before any box knows its size — is | |
| 103 | + read as \"not yet\" rather than \"no room\"." | |
| 71 | 104 | ([s] (label s {})) |
| 72 | - ([s {:keys [size colour expand gravity align] | |
| 73 | - :or {expand :horizontal gravity [0.0 0.5] align :left}}] | |
| 105 | + ([s {:keys [size colour expand gravity align wrap] | |
| 106 | + :or {expand :horizontal gravity [0.0 0.5] align :left wrap true}}] | |
| 74 | 107 | (let [size (or size (c/th :font-size)) |
| 75 | 108 | colour (or colour (c/th :text)) |
| 76 | - [tw th*] (c/measure s size) | |
| 77 | - rect (c/leaf [(double tw) (double (max th* (c/line-height size)))] | |
| 109 | + [tw0 th0] (c/measure s size) | |
| 110 | + avail (c/avail-width) | |
| 111 | + lines (if (and wrap (pos? avail) (> tw0 avail)) | |
| 112 | + (wrap-lines s size avail) | |
| 113 | + [(str s)]) | |
| 114 | + many? (> (count lines) 1) | |
| 115 | + widths (mapv #(first (c/measure % size)) lines) | |
| 116 | + tw (if many? (reduce max 0.0 widths) tw0) | |
| 117 | + lh (max th0 (c/line-height size)) | |
| 118 | + rect (c/leaf [(double tw) (double (* lh (count lines)))] | |
| 78 | 119 | expand gravity) |
| 79 | - [x y w h] rect | |
| 80 | - tx (case align | |
| 81 | - :center (+ x (/ (- w tw) 2.0)) | |
| 82 | - :right (+ x (- w tw)) | |
| 83 | - x)] | |
| 84 | - (c/draw-text! s tx (+ y (/ (- h th*) 2.0)) size colour) | |
| 120 | + [x y w h] rect] | |
| 121 | + (dotimes [i (count lines)] | |
| 122 | + (let [ln (nth lines i) | |
| 123 | + lw (nth widths i) | |
| 124 | + tx (case align | |
| 125 | + :center (+ x (/ (- w lw) 2.0)) | |
| 126 | + :right (+ x (- w lw)) | |
| 127 | + x)] | |
| 128 | + (c/draw-text! ln tx (+ y (* i lh) (/ (- lh th0) 2.0)) size colour))) | |
| 85 | 129 | rect))) |
| 86 | 130 | |
| 87 | 131 | (defn title [s & [opts]] |
| @@ -66,22 +66,66 @@ | |||
| 66 | 66 | ||
| 67 | ;; -------------------------------------------------------------------- text | 67 | ;; -------------------------------------------------------------------- text |
| 68 | 68 | ||
| 69 | +(defn- wrap-lines | ||
| 70 | + "Break `s` into lines that each fit `width`, on spaces. | ||
| 71 | + | ||
| 72 | + Greedy and word-wise, which is what a paragraph of UI text wants. A | ||
| 73 | + single word longer than the line is left long rather than broken | ||
| 74 | + mid-word: a URL cut in half is harder to read than one that overflows, | ||
| 75 | + and the viewport clips it either way. | ||
| 76 | + | ||
| 77 | + Measured through the same `c/measure` the drawing uses, so a line that | ||
| 78 | + is said to fit does fit — a wrap computed against a different metric | ||
| 79 | + than the renderer's is off by a word at the worst moments." | ||
| 80 | + [s size width] | ||
| 81 | + (let [words (str/split (str s) #" ")] | ||
| 82 | + (loop [[w & more] words line nil out []] | ||
| 83 | + (cond | ||
| 84 | + (nil? w) (if line (conj out line) out) | ||
| 85 | + (nil? line) (recur more w out) | ||
| 86 | + :else | ||
| 87 | + (let [try* (str line " " w)] | ||
| 88 | + (if (<= (first (c/measure try* size)) width) | ||
| 89 | + (recur more try* out) | ||
| 90 | + (recur more w (conj out line)))))))) | ||
| 91 | + | ||
| 69 | (defn label | 92 | (defn label |
| 70 | - "A line of text. Answers its rectangle." | 93 | + "A line of text, wrapped to the space it was given. Answers its rectangle. |
| 94 | + | ||
| 95 | + Wrapping is on when the text does not fit and the box has told us how | ||
| 96 | + much room there is. That is not a style choice: without it one long | ||
| 97 | + sentence makes its container wider than the window, and a page that | ||
| 98 | + centres its content then hangs off BOTH edges — which is exactly what | ||
| 99 | + frq's sign-in screen did the first time it was painted here. | ||
| 100 | + | ||
| 101 | + `:wrap false` turns it off for a caller that would rather overflow, and | ||
| 102 | + a width of zero — the first frame, before any box knows its size — is | ||
| 103 | + read as \"not yet\" rather than \"no room\"." | ||
| 71 | ([s] (label s {})) | 104 | ([s] (label s {})) |
| 72 | - ([s {:keys [size colour expand gravity align] | 105 | + ([s {:keys [size colour expand gravity align wrap] |
| 73 | - :or {expand :horizontal gravity [0.0 0.5] align :left}}] | 106 | + :or {expand :horizontal gravity [0.0 0.5] align :left wrap true}}] |
| 74 | (let [size (or size (c/th :font-size)) | 107 | (let [size (or size (c/th :font-size)) |
| 75 | colour (or colour (c/th :text)) | 108 | colour (or colour (c/th :text)) |
| 76 | - [tw th*] (c/measure s size) | 109 | + [tw0 th0] (c/measure s size) |
| 77 | - rect (c/leaf [(double tw) (double (max th* (c/line-height size)))] | 110 | + avail (c/avail-width) |
| 111 | + lines (if (and wrap (pos? avail) (> tw0 avail)) | ||
| 112 | + (wrap-lines s size avail) | ||
| 113 | + [(str s)]) | ||
| 114 | + many? (> (count lines) 1) | ||
| 115 | + widths (mapv #(first (c/measure % size)) lines) | ||
| 116 | + tw (if many? (reduce max 0.0 widths) tw0) | ||
| 117 | + lh (max th0 (c/line-height size)) | ||
| 118 | + rect (c/leaf [(double tw) (double (* lh (count lines)))] | ||
| 78 | expand gravity) | 119 | expand gravity) |
| 79 | - [x y w h] rect | 120 | + [x y w h] rect] |
| 80 | - tx (case align | 121 | + (dotimes [i (count lines)] |
| 81 | - :center (+ x (/ (- w tw) 2.0)) | 122 | + (let [ln (nth lines i) |
| 82 | - :right (+ x (- w tw)) | 123 | + lw (nth widths i) |
| 83 | - x)] | 124 | + tx (case align |
| 84 | - (c/draw-text! s tx (+ y (/ (- h th*) 2.0)) size colour) | 125 | + :center (+ x (/ (- w lw) 2.0)) |
| 126 | + :right (+ x (- w lw)) | ||
| 127 | + x)] | ||
| 128 | + (c/draw-text! ln tx (+ y (* i lh) (/ (- lh th0) 2.0)) size colour))) | ||
| 85 | rect))) | 129 | rect))) |
| 86 | 130 | ||
| 87 | (defn title [s & [opts]] | 131 | (defn title [s & [opts]] |
modified
jvui/test/jvui/tests.clj +22 -0 | @@ -268,12 +268,34 @@ | ||
| 268 | 268 | (check! (= [127 127 127 255] (theme/mix [0 0 0 255] [254 254 254 255] 0.5)) |
| 269 | 269 | "and halfway")) |
| 270 | 270 | |
| 271 | +(defn- check-label-wraps! [] | |
| 272 | + ;; A sentence longer than its box must come back TALLER and no wider, not | |
| 273 | + ;; wider and on one line. Without this a single long label makes its | |
| 274 | + ;; container wider than the window, and a page that centres its content | |
| 275 | + ;; then hangs off BOTH edges — which is what frq's sign-in screen did the | |
| 276 | + ;; first time jvui painted it. | |
| 277 | + ;; | |
| 278 | + ;; The stub font is eight pixels a character, so the numbers below are | |
| 279 | + ;; the box width and not a property of any real face. | |
| 280 | + (let [text (apply str (repeat 12 "wordy ")) ; 72 chars ≈ 576px unwrapped | |
| 281 | + cx (ctx) ; a 400-wide context | |
| 282 | + seen (atom nil)] | |
| 283 | + (dotimes [_ 2] | |
| 284 | + ;; Twice, because a box's width comes from what its children asked | |
| 285 | + ;; for LAST frame — the first has nothing to wrap against. | |
| 286 | + (frame! cx (fn [] (c/box* {:dir :vertical} | |
| 287 | + (fn [_ _] (reset! seen (w/label text))))))) | |
| 288 | + (let [[_ _ w h] @seen] | |
| 289 | + (check! (<= w 400.5) "a long label wraps inside its box") | |
| 290 | + (check! (> h 16.5) "and takes more than one line to do it")))) | |
| 291 | + | |
| 271 | 292 | ;; --- runner ------------------------------------------------------------------ |
| 272 | 293 | |
| 273 | 294 | (def ^:private checks |
| 274 | 295 | [["a leaf is placed" check-leaf!] |
| 275 | 296 | ["a column stacks" check-vbox-stacks!] |
| 276 | 297 | ["a row runs" check-hbox-runs!] |
| 298 | + ["a long label wraps" check-label-wraps!] | |
| 277 | 299 | ["expand shares the slack" check-expand-shares!] |
| 278 | 300 | ["gravity centres" check-gravity-centres!] |
| 279 | 301 | ["layout settles unseen" check-layout-settles!] |
| @@ -268,12 +268,34 @@ | |||
| 268 | (check! (= [127 127 127 255] (theme/mix [0 0 0 255] [254 254 254 255] 0.5)) | 268 | (check! (= [127 127 127 255] (theme/mix [0 0 0 255] [254 254 254 255] 0.5)) |
| 269 | "and halfway")) | 269 | "and halfway")) |
| 270 | 270 | ||
| 271 | +(defn- check-label-wraps! [] | ||
| 272 | + ;; A sentence longer than its box must come back TALLER and no wider, not | ||
| 273 | + ;; wider and on one line. Without this a single long label makes its | ||
| 274 | + ;; container wider than the window, and a page that centres its content | ||
| 275 | + ;; then hangs off BOTH edges — which is what frq's sign-in screen did the | ||
| 276 | + ;; first time jvui painted it. | ||
| 277 | + ;; | ||
| 278 | + ;; The stub font is eight pixels a character, so the numbers below are | ||
| 279 | + ;; the box width and not a property of any real face. | ||
| 280 | + (let [text (apply str (repeat 12 "wordy ")) ; 72 chars ≈ 576px unwrapped | ||
| 281 | + cx (ctx) ; a 400-wide context | ||
| 282 | + seen (atom nil)] | ||
| 283 | + (dotimes [_ 2] | ||
| 284 | + ;; Twice, because a box's width comes from what its children asked | ||
| 285 | + ;; for LAST frame — the first has nothing to wrap against. | ||
| 286 | + (frame! cx (fn [] (c/box* {:dir :vertical} | ||
| 287 | + (fn [_ _] (reset! seen (w/label text))))))) | ||
| 288 | + (let [[_ _ w h] @seen] | ||
| 289 | + (check! (<= w 400.5) "a long label wraps inside its box") | ||
| 290 | + (check! (> h 16.5) "and takes more than one line to do it")))) | ||
| 291 | + | ||
| 271 | ;; --- runner ------------------------------------------------------------------ | 292 | ;; --- runner ------------------------------------------------------------------ |
| 272 | 293 | ||
| 273 | (def ^:private checks | 294 | (def ^:private checks |
| 274 | [["a leaf is placed" check-leaf!] | 295 | [["a leaf is placed" check-leaf!] |
| 275 | ["a column stacks" check-vbox-stacks!] | 296 | ["a column stacks" check-vbox-stacks!] |
| 276 | ["a row runs" check-hbox-runs!] | 297 | ["a row runs" check-hbox-runs!] |
| 298 | + ["a long label wraps" check-label-wraps!] | ||
| 277 | ["expand shares the slack" check-expand-shares!] | 299 | ["expand shares the slack" check-expand-shares!] |
| 278 | ["gravity centres" check-gravity-centres!] | 300 | ["gravity centres" check-gravity-centres!] |
| 279 | ["layout settles unseen" check-layout-settles!] | 301 | ["layout settles unseen" check-layout-settles!] |