nandi/jolt-nativepublic Fork 0
7d907d0
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.

Fill the window's height, and find a glyph the UI font has not got

Two things a screenshot showed.

THE GLYPHS. A UI font covers Latin and stops, and frq's chrome is icons
while its messages carry emoji — every one of them was the missing-glyph
box. SDL_ttf keeps a list of fallback faces and asks each in turn, so
Noto Color Emoji and Noto Sans Symbols are added behind the main face and
text the main face already has costs nothing. Per FACE and not per
family, so each size opens its own and holds them: SDL_ttf keeps the
pointer rather than copying the font.

THE HEIGHT, which took three wrong answers to get right and each one is
worth keeping.

A row is only as tall as what is in it, and frq marks the PANES with
:fill-height rather than the row holding them — egui hands a horizontal
layout the available height and the panes fill that, so there is nothing
there to mark. Here the row has to be told, and its own children are what
know: a row holding something that wants the height wants the height.

Then the viewport ASKED for the height instead of filling it — a minimum
of the whole column — and the column had nothing left for the separator
and the compose bar, which went off the bottom of the window. It reports
nothing now and takes what is left. An explicit :height is still a
demand, which is what the viewport test was already saying.

And its :expand was being overridden by the container default, :cross. A
viewport that fills only the width asks its column for no height, is
given none, and shows nothing at all — an empty backlog with a scrollbar
beside it, which is exactly what the window looked like. It is the one
container that always fills both ways, so it says so itself.

Every one of those was found by tracing the box chain rather than by
reading it: the numbers say which box stopped passing the slack on, and
all three times it was not the one I would have guessed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nandi committed 2026-09-10T06:59:08-07:00 Browse files
7d907d0 parent: 5fcbb42
modified glimmer-backends/glimmer-jvui/src/glimmer_jvui/core.clj +29 -6
@@ -70,9 +70,27 @@
7070
7171 (defn- num [v default] (if (number? v) (double v) default))
7272
73+(defn- fills-height?
74+ "Does any child of `n` ask to fill the height?
75+
76+ A row is only as tall as what is in it, and frq marks the PANES with
77+ :fill-height rather than the row that holds them — egui gives a
78+ horizontal layout the available height and the panes fill that, so
79+ there is nothing there to mark. Here the row has to be told, and its
80+ own children are what know: a row holding something that wants the
81+ height wants the height.
82+
83+ Asked of the tree rather than inferred from the layout, because the
84+ layout answers a frame too late — a row that learns it should be tall
85+ from what happened last frame is a row that is the wrong height on
86+ the frame anybody looks at."
87+ [n]
88+ (boolean (some #(:fill-height (:props (deref %))) (:children (deref n)))))
89+
7390 (defn- box-opts
7491 "The container options shared by every container tag."
75- [props key]
92+ ([props key] (box-opts props key false))
93+ ([props key fill-height?]
7694 (cond-> {:key key
7795 :dir (if (= :horizontal (:orientation props)) :horizontal :vertical)
7896 ;; A CONTAINER fills its parent's cross axis by default. Without
@@ -97,7 +115,7 @@
97115 ;; row it does not fill the width of is not what anyone means by it.
98116 ;; The panes that do NOT ask for it stay :cross and keep their own
99117 ;; size, which is what leaves the slack to be taken.
100- (:fill-height props) (assoc :expand :both)
118+ (or (:fill-height props) fill-height?) (assoc :expand :both)
101119 ;; A minimum, not a size: the messages column asks for one only while
102120 ;; the people panel is beside it.
103121 (:width-request props)
@@ -113,7 +131,7 @@
113131 (:align props) (assoc :gravity (case (:align props)
114132 (:center "center") [0.0 0.5]
115133 (:end "end") [0.0 1.0]
116- [0.0 0.0]))))
134+ [0.0 0.0])))))
117135
118136 (defn- fire! [n k & args]
119137 (when-let [f (get (:props @n) k)] (apply f args)))
@@ -158,7 +176,11 @@
158176 ;; :height is a prop frq writes and this used to drop on the floor —
159177 ;; the chat did not follow new messages, and switching channels
160178 ;; carried the previous one's scroll across.
161- :scroll (w/scroll* (cond-> (box-opts props key)
179+ ;; :expand is forced rather than left to box-opts, whose default is
180+ ;; :cross — and a viewport that fills only the width asks its column
181+ ;; for no height, is given none, and shows nothing at all. It is the
182+ ;; one container that always fills both ways.
183+ :scroll (w/scroll* (cond-> (assoc (box-opts props key) :expand :both)
162184 (:height props)
163185 (assoc :height (num (:height props) 200.0))
164186 (:reserve props)
@@ -185,10 +207,11 @@
185207 (fire! n :on-change (if at-end? "end" "away")))))
186208 (emit-children! n))
187209
188- :hbox (c/box* (assoc (box-opts props key) :dir :horizontal)
210+ :hbox (c/box* (assoc (box-opts props key (fills-height? n)) :dir :horizontal)
189211 (emit-children! n))
190212
191- (:vbox :box) (c/box* (box-opts props key) (emit-children! n))
213+ (:vbox :box) (c/box* (box-opts props key (fills-height? n))
214+ (emit-children! n))
192215
193216 ;; ONE tag for both kinds of picture: `:feed` is live pixels pushed
194217 ;; in under a name and re-uploaded as they arrive, `:src` is a file
@@ -70,9 +70,27 @@
70 70
71 (defn- num [v default] (if (number? v) (double v) default))71 (defn- num [v default] (if (number? v) (double v) default))
72 72
73+(defn- fills-height?
74+ "Does any child of `n` ask to fill the height?
75+
76+ A row is only as tall as what is in it, and frq marks the PANES with
77+ :fill-height rather than the row that holds them — egui gives a
78+ horizontal layout the available height and the panes fill that, so
79+ there is nothing there to mark. Here the row has to be told, and its
80+ own children are what know: a row holding something that wants the
81+ height wants the height.
82+
83+ Asked of the tree rather than inferred from the layout, because the
84+ layout answers a frame too late — a row that learns it should be tall
85+ from what happened last frame is a row that is the wrong height on
86+ the frame anybody looks at."
87+ [n]
88+ (boolean (some #(:fill-height (:props (deref %))) (:children (deref n)))))
89+
73 (defn- box-opts90 (defn- box-opts
74 "The container options shared by every container tag."91 "The container options shared by every container tag."
75- [props key]92+ ([props key] (box-opts props key false))
93+ ([props key fill-height?]
76 (cond-> {:key key94 (cond-> {:key key
77 :dir (if (= :horizontal (:orientation props)) :horizontal :vertical)95 :dir (if (= :horizontal (:orientation props)) :horizontal :vertical)
78 ;; A CONTAINER fills its parent's cross axis by default. Without96 ;; A CONTAINER fills its parent's cross axis by default. Without
@@ -97,7 +115,7 @@
97 ;; row it does not fill the width of is not what anyone means by it.115 ;; row it does not fill the width of is not what anyone means by it.
98 ;; The panes that do NOT ask for it stay :cross and keep their own116 ;; The panes that do NOT ask for it stay :cross and keep their own
99 ;; size, which is what leaves the slack to be taken.117 ;; size, which is what leaves the slack to be taken.
100- (:fill-height props) (assoc :expand :both)118+ (or (:fill-height props) fill-height?) (assoc :expand :both)
101 ;; A minimum, not a size: the messages column asks for one only while119 ;; A minimum, not a size: the messages column asks for one only while
102 ;; the people panel is beside it.120 ;; the people panel is beside it.
103 (:width-request props)121 (:width-request props)
@@ -113,7 +131,7 @@
113 (:align props) (assoc :gravity (case (:align props)131 (:align props) (assoc :gravity (case (:align props)
114 (:center "center") [0.0 0.5]132 (:center "center") [0.0 0.5]
115 (:end "end") [0.0 1.0]133 (:end "end") [0.0 1.0]
116- [0.0 0.0]))))134+ [0.0 0.0])))))
117 135
118 (defn- fire! [n k & args]136 (defn- fire! [n k & args]
119 (when-let [f (get (:props @n) k)] (apply f args)))137 (when-let [f (get (:props @n) k)] (apply f args)))
@@ -158,7 +176,11 @@
158 ;; :height is a prop frq writes and this used to drop on the floor —176 ;; :height is a prop frq writes and this used to drop on the floor —
159 ;; the chat did not follow new messages, and switching channels177 ;; the chat did not follow new messages, and switching channels
160 ;; carried the previous one's scroll across.178 ;; carried the previous one's scroll across.
161- :scroll (w/scroll* (cond-> (box-opts props key)179+ ;; :expand is forced rather than left to box-opts, whose default is
180+ ;; :cross — and a viewport that fills only the width asks its column
181+ ;; for no height, is given none, and shows nothing at all. It is the
182+ ;; one container that always fills both ways.
183+ :scroll (w/scroll* (cond-> (assoc (box-opts props key) :expand :both)
162 (:height props)184 (:height props)
163 (assoc :height (num (:height props) 200.0))185 (assoc :height (num (:height props) 200.0))
164 (:reserve props)186 (:reserve props)
@@ -185,10 +207,11 @@
185 (fire! n :on-change (if at-end? "end" "away")))))207 (fire! n :on-change (if at-end? "end" "away")))))
186 (emit-children! n))208 (emit-children! n))
187 209
188- :hbox (c/box* (assoc (box-opts props key) :dir :horizontal)210+ :hbox (c/box* (assoc (box-opts props key (fills-height? n)) :dir :horizontal)
189 (emit-children! n))211 (emit-children! n))
190 212
191- (:vbox :box) (c/box* (box-opts props key) (emit-children! n))213+ (:vbox :box) (c/box* (box-opts props key (fills-height? n))
214+ (emit-children! n))
192 215
193 ;; ONE tag for both kinds of picture: `:feed` is live pixels pushed216 ;; ONE tag for both kinds of picture: `:feed` is live pixels pushed
194 ;; in under a name and re-uploaded as they arrive, `:src` is a file217 ;; in under a name and re-uploaded as they arrive, `:src` is a file
modified jvui/src/jvui/font.clj +38 -1
@@ -13,7 +13,8 @@
1313 with a colour-mod at draw time, so one cached texture serves a label in
1414 every colour it is ever drawn in, and a static page uploads nothing at all
1515 after its first frame."
16- (:require [jvui.sdl :as sdl]))
16+ (:require [clojure.string]
17+ [jvui.sdl :as sdl]))
1718
1819 (def ^:private candidates
1920 ["/usr/share/fonts/noto/NotoSans-Regular.ttf"
@@ -35,6 +36,31 @@
3536
3637 (defn- ffi-null? [p] (or (nil? p) (and (number? p) (zero? p))))
3738
39+(def ^:private fallbacks
40+ "Faces to try for what the UI font has no glyph for.
41+
42+ A UI font covers Latin and stops. frq's chrome is icons and its
43+ messages carry emoji, and every one of those was coming out as the
44+ missing-glyph box — which is what the reader sees as \"the glyphs are
45+ broken\". SDL_ttf keeps a list of fallback faces and asks each in turn,
46+ so this costs nothing for text that the main face already has.
47+
48+ Emoji first: a face that has both a symbol and its emoji presentation
49+ should give the emoji one, which is what a message means by it.
50+ JVUI_FALLBACK_FONTS overrides, colon-separated, for a machine whose
51+ fonts live somewhere else."
52+ ["/usr/share/fonts/noto/NotoColorEmoji.ttf"
53+ "/usr/share/fonts/truetype/noto/NotoColorEmoji.ttf"
54+ "/run/current-system/sw/share/X11/fonts/NotoColorEmoji.ttf"
55+ "/usr/share/fonts/noto/NotoSansSymbols2-Regular.ttf"
56+ "/usr/share/fonts/noto/NotoSansSymbols-Regular.ttf"
57+ "/usr/share/fonts/truetype/noto/NotoSansSymbols2-Regular.ttf"])
58+
59+(defn- fallback-paths []
60+ (if-let [env (System/getenv "JVUI_FALLBACK_FONTS")]
61+ (remove clojure.string/blank? (clojure.string/split env #":"))
62+ (filter #(.exists (java.io.File. %)) fallbacks)))
63+
3864 (defn open
3965 "A font cache over `path`. Sizes are opened lazily and kept."
4066 [path]
@@ -47,6 +73,17 @@
4773 (let [f (sdl/open-font (:path @cache) (float size))]
4874 (when (or (nil? f) (and (number? f) (zero? f)))
4975 (throw (ex-info (str "TTF_OpenFont " (:path @cache) ": " (sdl/error)) {})))
76+ ;; Fallbacks are per FACE, not per family, so each size opens its
77+ ;; own and they are kept for as long as the face is — SDL_ttf
78+ ;; holds the pointer and does not copy the font.
79+ (let [fbs (into []
80+ (keep (fn [path]
81+ (let [g (sdl/open-font path (float size))]
82+ (when-not (ffi-null? g)
83+ (sdl/add-fallback-font! f g)
84+ g))))
85+ (fallback-paths))]
86+ (swap! cache update :fallbacks (fnil into []) fbs))
5087 (swap! cache assoc-in [:faces k] f)
5188 f))))
5289
@@ -13,7 +13,8 @@
13 with a colour-mod at draw time, so one cached texture serves a label in13 with a colour-mod at draw time, so one cached texture serves a label in
14 every colour it is ever drawn in, and a static page uploads nothing at all14 every colour it is ever drawn in, and a static page uploads nothing at all
15 after its first frame."15 after its first frame."
16- (:require [jvui.sdl :as sdl]))16+ (:require [clojure.string]
17+ [jvui.sdl :as sdl]))
17 18
18 (def ^:private candidates19 (def ^:private candidates
19 ["/usr/share/fonts/noto/NotoSans-Regular.ttf"20 ["/usr/share/fonts/noto/NotoSans-Regular.ttf"
@@ -35,6 +36,31 @@
35 36
36 (defn- ffi-null? [p] (or (nil? p) (and (number? p) (zero? p))))37 (defn- ffi-null? [p] (or (nil? p) (and (number? p) (zero? p))))
37 38
39+(def ^:private fallbacks
40+ "Faces to try for what the UI font has no glyph for.
41+
42+ A UI font covers Latin and stops. frq's chrome is icons and its
43+ messages carry emoji, and every one of those was coming out as the
44+ missing-glyph box — which is what the reader sees as \"the glyphs are
45+ broken\". SDL_ttf keeps a list of fallback faces and asks each in turn,
46+ so this costs nothing for text that the main face already has.
47+
48+ Emoji first: a face that has both a symbol and its emoji presentation
49+ should give the emoji one, which is what a message means by it.
50+ JVUI_FALLBACK_FONTS overrides, colon-separated, for a machine whose
51+ fonts live somewhere else."
52+ ["/usr/share/fonts/noto/NotoColorEmoji.ttf"
53+ "/usr/share/fonts/truetype/noto/NotoColorEmoji.ttf"
54+ "/run/current-system/sw/share/X11/fonts/NotoColorEmoji.ttf"
55+ "/usr/share/fonts/noto/NotoSansSymbols2-Regular.ttf"
56+ "/usr/share/fonts/noto/NotoSansSymbols-Regular.ttf"
57+ "/usr/share/fonts/truetype/noto/NotoSansSymbols2-Regular.ttf"])
58+
59+(defn- fallback-paths []
60+ (if-let [env (System/getenv "JVUI_FALLBACK_FONTS")]
61+ (remove clojure.string/blank? (clojure.string/split env #":"))
62+ (filter #(.exists (java.io.File. %)) fallbacks)))
63+
38 (defn open64 (defn open
39 "A font cache over `path`. Sizes are opened lazily and kept."65 "A font cache over `path`. Sizes are opened lazily and kept."
40 [path]66 [path]
@@ -47,6 +73,17 @@
47 (let [f (sdl/open-font (:path @cache) (float size))]73 (let [f (sdl/open-font (:path @cache) (float size))]
48 (when (or (nil? f) (and (number? f) (zero? f)))74 (when (or (nil? f) (and (number? f) (zero? f)))
49 (throw (ex-info (str "TTF_OpenFont " (:path @cache) ": " (sdl/error)) {})))75 (throw (ex-info (str "TTF_OpenFont " (:path @cache) ": " (sdl/error)) {})))
76+ ;; Fallbacks are per FACE, not per family, so each size opens its
77+ ;; own and they are kept for as long as the face is — SDL_ttf
78+ ;; holds the pointer and does not copy the font.
79+ (let [fbs (into []
80+ (keep (fn [path]
81+ (let [g (sdl/open-font path (float size))]
82+ (when-not (ffi-null? g)
83+ (sdl/add-fallback-font! f g)
84+ g))))
85+ (fallback-paths))]
86+ (swap! cache update :fallbacks (fnil into []) fbs))
50 (swap! cache assoc-in [:faces k] f)87 (swap! cache assoc-in [:faces k] f)
51 f))))88 f))))
52 89
modified jvui/src/jvui/sdl.clj +3 -0
@@ -67,6 +67,9 @@
6767 (ffi/defcfn open-font "TTF_OpenFont" [:string :float] :pointer)
6868 (ffi/defcfn close-font! "TTF_CloseFont" [:pointer] :void)
6969 (ffi/defcfn font-height "TTF_GetFontHeight" [:pointer] :int)
70+;; A face to try for characters the main one has no glyph for. SDL_ttf keeps
71+;; a list of them and asks each in turn.
72+(ffi/defcfn add-fallback-font! "TTF_AddFallbackFont" [:pointer :pointer] :bool)
7073 (ffi/defcfn ^:private raw-string-size "TTF_GetStringSize"
7174 [:pointer :string :ulong :pointer :pointer] :bool)
7275 (ffi/defcfn ^:private raw-render-blended "TTF_RenderText_Blended"
@@ -67,6 +67,9 @@
67 (ffi/defcfn open-font "TTF_OpenFont" [:string :float] :pointer)67 (ffi/defcfn open-font "TTF_OpenFont" [:string :float] :pointer)
68 (ffi/defcfn close-font! "TTF_CloseFont" [:pointer] :void)68 (ffi/defcfn close-font! "TTF_CloseFont" [:pointer] :void)
69 (ffi/defcfn font-height "TTF_GetFontHeight" [:pointer] :int)69 (ffi/defcfn font-height "TTF_GetFontHeight" [:pointer] :int)
70+;; A face to try for characters the main one has no glyph for. SDL_ttf keeps
71+;; a list of them and asks each in turn.
72+(ffi/defcfn add-fallback-font! "TTF_AddFallbackFont" [:pointer :pointer] :bool)
70 (ffi/defcfn ^:private raw-string-size "TTF_GetStringSize"73 (ffi/defcfn ^:private raw-string-size "TTF_GetStringSize"
71 [:pointer :string :ulong :pointer :pointer] :bool)74 [:pointer :string :ulong :pointer :pointer] :bool)
72 (ffi/defcfn ^:private raw-render-blended "TTF_RenderText_Blended"75 (ffi/defcfn ^:private raw-render-blended "TTF_RenderText_Blended"
modified jvui/src/jvui/widgets.clj +22 -13
@@ -426,20 +426,24 @@
426426 leaves the end"
427427 [opts body]
428428 (let [{:keys [scroll-key reserve stick-to-bottom scroll-to-bottom on-at-end]} opts
429- avail (c/avail-height)
430- h (double (cond
431- (:height opts) (:height opts)
432- ;; With no height of its own a viewport takes what the
433- ;; column has left, less whatever must stay behind for
434- ;; the compose bar under it. Two hundred is only the
435- ;; answer before anything has a size — on the first
436- ;; frame, when the column does not know its own height
437- ;; yet.
438- (pos? avail) (max 0.0 (- avail (double (or reserve 0.0))))
439- :else 200.0))
440429 id (c/next-id (:key opts))
441430 area (or scroll-key id)
442431 prev (c/data id)
432+ ;; A viewport with no height of its own FILLS what is left rather
433+ ;; than ASKING for it. Asking is what it did first — a minimum of
434+ ;; the whole column — and then the column had nothing left for
435+ ;; the separator and compose bar under it, which went off the
436+ ;; bottom of the window.
437+ ;;
438+ ;; So the height is the one it was GIVEN last frame, and it
439+ ;; reports a minimum of nothing. Two hundred is the answer on the
440+ ;; first frame only, before it has been given anything.
441+ given (nth (:rect prev) 3 nil)
442+ h (double (cond
443+ (:height opts) (:height opts)
444+ (and given (pos? (double given)))
445+ (max 0.0 (- (double given) (double (or reserve 0.0))))
446+ :else 200.0))
443447 content-h (second (or (:content-min prev) [0.0 0.0]))
444448 view (or (:rect prev) [0.0 0.0 0.0 0.0])
445449 maxoff (max 0.0 (- content-h h))
@@ -470,8 +474,13 @@
470474 stick? maxoff
471475 :else wheeled)
472476 r (c/box* (merge {:dir :vertical
473- :expand :horizontal
474- :min-size [0.0 h]
477+ :expand :both
478+ ;; An explicit :height IS a demand and is
479+ ;; reported as one. Without it the viewport
480+ ;; fills what is left and asks for nothing —
481+ ;; asking is what left the compose bar off the
482+ ;; bottom of the window.
483+ :min-size [0.0 (if (:height opts) h 0.0)]
475484 :fixed true
476485 :clip? true
477486 :spacing (c/th :spacing)
@@ -426,20 +426,24 @@
426 leaves the end"426 leaves the end"
427 [opts body]427 [opts body]
428 (let [{:keys [scroll-key reserve stick-to-bottom scroll-to-bottom on-at-end]} opts428 (let [{:keys [scroll-key reserve stick-to-bottom scroll-to-bottom on-at-end]} opts
429- avail (c/avail-height)
430- h (double (cond
431- (:height opts) (:height opts)
432- ;; With no height of its own a viewport takes what the
433- ;; column has left, less whatever must stay behind for
434- ;; the compose bar under it. Two hundred is only the
435- ;; answer before anything has a size — on the first
436- ;; frame, when the column does not know its own height
437- ;; yet.
438- (pos? avail) (max 0.0 (- avail (double (or reserve 0.0))))
439- :else 200.0))
440 id (c/next-id (:key opts))429 id (c/next-id (:key opts))
441 area (or scroll-key id)430 area (or scroll-key id)
442 prev (c/data id)431 prev (c/data id)
432+ ;; A viewport with no height of its own FILLS what is left rather
433+ ;; than ASKING for it. Asking is what it did first — a minimum of
434+ ;; the whole column — and then the column had nothing left for
435+ ;; the separator and compose bar under it, which went off the
436+ ;; bottom of the window.
437+ ;;
438+ ;; So the height is the one it was GIVEN last frame, and it
439+ ;; reports a minimum of nothing. Two hundred is the answer on the
440+ ;; first frame only, before it has been given anything.
441+ given (nth (:rect prev) 3 nil)
442+ h (double (cond
443+ (:height opts) (:height opts)
444+ (and given (pos? (double given)))
445+ (max 0.0 (- (double given) (double (or reserve 0.0))))
446+ :else 200.0))
443 content-h (second (or (:content-min prev) [0.0 0.0]))447 content-h (second (or (:content-min prev) [0.0 0.0]))
444 view (or (:rect prev) [0.0 0.0 0.0 0.0])448 view (or (:rect prev) [0.0 0.0 0.0 0.0])
445 maxoff (max 0.0 (- content-h h))449 maxoff (max 0.0 (- content-h h))
@@ -470,8 +474,13 @@
470 stick? maxoff474 stick? maxoff
471 :else wheeled)475 :else wheeled)
472 r (c/box* (merge {:dir :vertical476 r (c/box* (merge {:dir :vertical
473- :expand :horizontal477+ :expand :both
474- :min-size [0.0 h]478+ ;; An explicit :height IS a demand and is
479+ ;; reported as one. Without it the viewport
480+ ;; fills what is left and asks for nothing —
481+ ;; asking is what left the compose bar off the
482+ ;; bottom of the window.
483+ :min-size [0.0 (if (:height opts) h 0.0)]
475 :fixed true484 :fixed true
476 :clip? true485 :clip? true
477 :spacing (c/th :spacing)486 :spacing (c/th :spacing)