nandi/jolt-nativepublic Fork 0
58ad86a
Commits
Clone
git clone https://git.rickub.com/nandi/jolt-native.git
git clone ssh://git@rickub.com/nandi/jolt-native.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

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>
nandi committed 2026-09-10T06:13:57-07:00 Browse files
58ad86a parent: 99fabc1
modified jvui/src/jvui/core.clj +25 -0
@@ -256,6 +256,31 @@
256256 (tally! w mw mh grow-along?)
257257 [x y w h]))))
258258
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+
259284 (defn next-id
260285 "Claim the next id under the current box."
261286 ([] (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-id284 (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 @@
6666
6767 ;; -------------------------------------------------------------------- text
6868
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+
6992 (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\"."
71104 ([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}}]
74107 (let [size (or size (c/th :font-size))
75108 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)))]
78119 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)))
85129 rect)))
86130
87131 (defn title [s & [opts]]
@@ -66,22 +66,66 @@
66 66
67 ;; -------------------------------------------------------------------- text67 ;; -------------------------------------------------------------------- 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 label92 (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] rect120+ [x y w h] rect]
80- tx (case align121+ (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 @@
268268 (check! (= [127 127 127 255] (theme/mix [0 0 0 255] [254 254 254 255] 0.5))
269269 "and halfway"))
270270
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+
271292 ;; --- runner ------------------------------------------------------------------
272293
273294 (def ^:private checks
274295 [["a leaf is placed" check-leaf!]
275296 ["a column stacks" check-vbox-stacks!]
276297 ["a row runs" check-hbox-runs!]
298+ ["a long label wraps" check-label-wraps!]
277299 ["expand shares the slack" check-expand-shares!]
278300 ["gravity centres" check-gravity-centres!]
279301 ["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 checks294 (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!]