Report Enter from a field, and take its width request
Two of the props a diff of frq's call sites against what this backend reads turned up. Fifty-four of frq's five hundred and twenty-eight prop uses go unread; these are the ones that break function rather than looks. :on-activate is Enter, and Enter is the one key a text field must not swallow as input — frq sends its message on it. A compose box that accepted every character and never reported Enter takes a message and has no way to say it is finished: typing works, sending does not, and nothing looks broken. Recorded as widget state rather than returned, because text-entry already answers its text and a second return value would change every existing call. :width-request is a minimum width, and :hexpand whether to take the rest of the row. Eight fields in frq ask for a width and were all getting the default. Still unread, and listed so the next pass has somewhere to start: scroll's stick-to-bottom, scroll-to-bottom, scroll-key, reserve, on-scroll and on-change; hbox's wrap and align; vbox's fill-height, width-request, reserve and margins; entry's rows and on-paste-empty; image's upscale. The scroll ones matter most — a chat that does not follow new messages is the next thing anyone will notice. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
c03a752 parent: c131fc6 modified
glimmer-backends/glimmer-jvui/src/glimmer_jvui/core.clj +14 -2 | @@ -219,9 +219,21 @@ | ||
| 219 | 219 | (:entry :text-entry) |
| 220 | 220 | (let [was (str (or (:value props) (:text props) "")) |
| 221 | 221 | id (c/next-id key) |
| 222 | - now (w/text-entry was {:key key :placeholder (:placeholder props)})] | |
| 222 | + now (w/text-entry was {:key key | |
| 223 | + :placeholder (:placeholder props) | |
| 224 | + ;; :width-request is what frq and | |
| 225 | + ;; libvidya call a minimum width; | |
| 226 | + ;; :hexpand says take the rest of the | |
| 227 | + ;; row, which is this widget's default. | |
| 228 | + :min-width (:width-request props) | |
| 229 | + :expand (if (false? (:hexpand props)) | |
| 230 | + :none :horizontal)})] | |
| 223 | 231 | (record! n id) |
| 224 | - (when (not= now was) (fire! n :on-change now))) | |
| 232 | + (when (not= now was) (fire! n :on-change now)) | |
| 233 | + ;; Enter, which a field must not swallow as input: frq sends its | |
| 234 | + ;; message on it, and without this the compose box accepted text | |
| 235 | + ;; and had no way to say it was finished. | |
| 236 | + (when (w/entry-activated? id) (fire! n :on-activate now))) | |
| 225 | 237 | |
| 226 | 238 | :progress (w/progress (num (:value props) 0.0)) |
| 227 | 239 | :separator (w/separator) |
| @@ -219,9 +219,21 @@ | |||
| 219 | (:entry :text-entry) | 219 | (:entry :text-entry) |
| 220 | (let [was (str (or (:value props) (:text props) "")) | 220 | (let [was (str (or (:value props) (:text props) "")) |
| 221 | id (c/next-id key) | 221 | id (c/next-id key) |
| 222 | - now (w/text-entry was {:key key :placeholder (:placeholder props)})] | 222 | + now (w/text-entry was {:key key |
| 223 | + :placeholder (:placeholder props) | ||
| 224 | + ;; :width-request is what frq and | ||
| 225 | + ;; libvidya call a minimum width; | ||
| 226 | + ;; :hexpand says take the rest of the | ||
| 227 | + ;; row, which is this widget's default. | ||
| 228 | + :min-width (:width-request props) | ||
| 229 | + :expand (if (false? (:hexpand props)) | ||
| 230 | + :none :horizontal)})] | ||
| 223 | (record! n id) | 231 | (record! n id) |
| 224 | - (when (not= now was) (fire! n :on-change now))) | 232 | + (when (not= now was) (fire! n :on-change now)) |
| 233 | + ;; Enter, which a field must not swallow as input: frq sends its | ||
| 234 | + ;; message on it, and without this the compose box accepted text | ||
| 235 | + ;; and had no way to say it was finished. | ||
| 236 | + (when (w/entry-activated? id) (fire! n :on-activate now))) | ||
| 225 | 237 | ||
| 226 | :progress (w/progress (num (:value props) 0.0)) | 238 | :progress (w/progress (num (:value props) 0.0)) |
| 227 | :separator (w/separator) | 239 | :separator (w/separator) |
modified
glimmer-backends/glimmer-jvui/test/glimmer_jvui/tests.clj +20 -0 | @@ -149,6 +149,25 @@ | ||
| 149 | 149 | (check! (= "hi" (ra/deref text)) |
| 150 | 150 | (str "typed text reached :on-change: " (pr-str (ra/deref text)))))) |
| 151 | 151 | |
| 152 | +(defn- check-enter-sends! [] | |
| 153 | + ;; Enter is the one key a field must not swallow. frq sends its message | |
| 154 | + ;; on it, so a compose box that accepted text and never reported Enter | |
| 155 | + ;; would take a message and have no way to say it was finished — typing | |
| 156 | + ;; works, sending does not, and nothing looks broken. | |
| 157 | + (let [root (jv/root-node) cx (ctx) | |
| 158 | + text (ra/atom "hello") | |
| 159 | + sent (ra/atom nil)] | |
| 160 | + (gui/mount root :page | |
| 161 | + [(fn [] [:entry {:value (ra/deref text) | |
| 162 | + :on-change #(ra/reset! text %) | |
| 163 | + :on-activate #(ra/reset! sent %)}])]) | |
| 164 | + (jv/render-once root cx) | |
| 165 | + (let [[ex ey] (centre (tagged root :entry))] | |
| 166 | + (jv/render-once root cx (click-at ex ey))) ; take focus | |
| 167 | + (jv/render-once root cx [{:kind :key-down :key :return}]) | |
| 168 | + (check! (= "hello" (ra/deref sent)) | |
| 169 | + (str "Enter reached :on-activate: " (pr-str (ra/deref sent)))))) | |
| 170 | + | |
| 152 | 171 | (defn- check-checkbox-round-trips! [] |
| 153 | 172 | (let [root (jv/root-node) cx (ctx)] |
| 154 | 173 | (ra/reset! on? false) |
| @@ -169,6 +188,7 @@ | ||
| 169 | 188 | ["a press alone does not" check-press-alone-is-not-a-click!] |
| 170 | 189 | ["a reorder keeps identity" check-reorder-keeps-identity!] |
| 171 | 190 | ["an unknown tag is a container" check-unknown-tag-is-a-container!] |
| 191 | + ["Enter sends" check-enter-sends!] | |
| 172 | 192 | ["an entry round-trips" check-entry-round-trips!] |
| 173 | 193 | ["a checkbox round-trips" check-checkbox-round-trips!]]) |
| 174 | 194 | |
| @@ -149,6 +149,25 @@ | |||
| 149 | (check! (= "hi" (ra/deref text)) | 149 | (check! (= "hi" (ra/deref text)) |
| 150 | (str "typed text reached :on-change: " (pr-str (ra/deref text)))))) | 150 | (str "typed text reached :on-change: " (pr-str (ra/deref text)))))) |
| 151 | 151 | ||
| 152 | +(defn- check-enter-sends! [] | ||
| 153 | + ;; Enter is the one key a field must not swallow. frq sends its message | ||
| 154 | + ;; on it, so a compose box that accepted text and never reported Enter | ||
| 155 | + ;; would take a message and have no way to say it was finished — typing | ||
| 156 | + ;; works, sending does not, and nothing looks broken. | ||
| 157 | + (let [root (jv/root-node) cx (ctx) | ||
| 158 | + text (ra/atom "hello") | ||
| 159 | + sent (ra/atom nil)] | ||
| 160 | + (gui/mount root :page | ||
| 161 | + [(fn [] [:entry {:value (ra/deref text) | ||
| 162 | + :on-change #(ra/reset! text %) | ||
| 163 | + :on-activate #(ra/reset! sent %)}])]) | ||
| 164 | + (jv/render-once root cx) | ||
| 165 | + (let [[ex ey] (centre (tagged root :entry))] | ||
| 166 | + (jv/render-once root cx (click-at ex ey))) ; take focus | ||
| 167 | + (jv/render-once root cx [{:kind :key-down :key :return}]) | ||
| 168 | + (check! (= "hello" (ra/deref sent)) | ||
| 169 | + (str "Enter reached :on-activate: " (pr-str (ra/deref sent)))))) | ||
| 170 | + | ||
| 152 | (defn- check-checkbox-round-trips! [] | 171 | (defn- check-checkbox-round-trips! [] |
| 153 | (let [root (jv/root-node) cx (ctx)] | 172 | (let [root (jv/root-node) cx (ctx)] |
| 154 | (ra/reset! on? false) | 173 | (ra/reset! on? false) |
| @@ -169,6 +188,7 @@ | |||
| 169 | ["a press alone does not" check-press-alone-is-not-a-click!] | 188 | ["a press alone does not" check-press-alone-is-not-a-click!] |
| 170 | ["a reorder keeps identity" check-reorder-keeps-identity!] | 189 | ["a reorder keeps identity" check-reorder-keeps-identity!] |
| 171 | ["an unknown tag is a container" check-unknown-tag-is-a-container!] | 190 | ["an unknown tag is a container" check-unknown-tag-is-a-container!] |
| 191 | + ["Enter sends" check-enter-sends!] | ||
| 172 | ["an entry round-trips" check-entry-round-trips!] | 192 | ["an entry round-trips" check-entry-round-trips!] |
| 173 | ["a checkbox round-trips" check-checkbox-round-trips!]]) | 193 | ["a checkbox round-trips" check-checkbox-round-trips!]]) |
| 174 | 194 | ||
modified
jvui/src/jvui/widgets.clj +19 -2 | @@ -277,18 +277,28 @@ | ||
| 277 | 277 | |
| 278 | 278 | (defn- clamp [v lo hi] (max lo (min hi v))) |
| 279 | 279 | |
| 280 | +(defn entry-activated? | |
| 281 | + "Did the field under `id` see Enter on the frame just walked? | |
| 282 | + | |
| 283 | + Asked after `text-entry`, which records it. Enter is the one key a text | |
| 284 | + field must NOT treat as input — the client sends on it — and a widget | |
| 285 | + that answered only its text gave a caller no way to know." | |
| 286 | + [id] | |
| 287 | + (boolean (c/state id :activated false))) | |
| 288 | + | |
| 280 | 289 | (defn text-entry |
| 281 | 290 | "A single-line editable string. Answers the text after this frame. |
| 282 | 291 | |
| 283 | 292 | The caret is an index into the string kept under the widget's id, which is |
| 284 | 293 | the one piece of state a text field cannot recompute from its value." |
| 285 | 294 | ([value] (text-entry value {})) |
| 286 | - ([value {:keys [key expand placeholder] :or {expand :horizontal}}] | |
| 295 | + ([value {:keys [key expand placeholder min-width] | |
| 296 | + :or {expand :horizontal}}] | |
| 287 | 297 | (let [size (c/th :font-size) |
| 288 | 298 | pad (c/th :padding) |
| 289 | 299 | h (double (c/th :control-height)) |
| 290 | 300 | id (c/next-id key) |
| 291 | - rect (c/leaf [160.0 h] expand [0.0 0.5]) | |
| 301 | + rect (c/leaf [(double (or min-width 160.0)) h] expand [0.0 0.5]) | |
| 292 | 302 | {:keys [hover? focused?]} (c/interact! id rect) |
| 293 | 303 | [rx ry rw rh] rect |
| 294 | 304 | s (str value) |
| @@ -319,6 +329,13 @@ | ||
| 319 | 329 | [s caret0] (or evs [])) |
| 320 | 330 | caret' (clamp caret' 0 (count s'))] |
| 321 | 331 | (c/state! id :caret caret') |
| 332 | + ;; Enter is not an edit and must not be swallowed as one: a client | |
| 333 | + ;; sends its message on it. Recorded as state rather than returned, | |
| 334 | + ;; because `text-entry` already answers the text and a second return | |
| 335 | + ;; value would change every existing call. | |
| 336 | + (c/state! id :activated | |
| 337 | + (boolean (some #(and (= :key-down (:kind %)) (= :return (:key %))) | |
| 338 | + (or evs [])))) | |
| 322 | 339 | (c/fill! rect (c/th :surface) (c/th :radius) |
| 323 | 340 | (if focused? (c/th :focus) (c/th :border)) |
| 324 | 341 | (if focused? 2.0 (c/th :border-width))) |
| @@ -277,18 +277,28 @@ | |||
| 277 | 277 | ||
| 278 | (defn- clamp [v lo hi] (max lo (min hi v))) | 278 | (defn- clamp [v lo hi] (max lo (min hi v))) |
| 279 | 279 | ||
| 280 | +(defn entry-activated? | ||
| 281 | + "Did the field under `id` see Enter on the frame just walked? | ||
| 282 | + | ||
| 283 | + Asked after `text-entry`, which records it. Enter is the one key a text | ||
| 284 | + field must NOT treat as input — the client sends on it — and a widget | ||
| 285 | + that answered only its text gave a caller no way to know." | ||
| 286 | + [id] | ||
| 287 | + (boolean (c/state id :activated false))) | ||
| 288 | + | ||
| 280 | (defn text-entry | 289 | (defn text-entry |
| 281 | "A single-line editable string. Answers the text after this frame. | 290 | "A single-line editable string. Answers the text after this frame. |
| 282 | 291 | ||
| 283 | The caret is an index into the string kept under the widget's id, which is | 292 | The caret is an index into the string kept under the widget's id, which is |
| 284 | the one piece of state a text field cannot recompute from its value." | 293 | the one piece of state a text field cannot recompute from its value." |
| 285 | ([value] (text-entry value {})) | 294 | ([value] (text-entry value {})) |
| 286 | - ([value {:keys [key expand placeholder] :or {expand :horizontal}}] | 295 | + ([value {:keys [key expand placeholder min-width] |
| 296 | + :or {expand :horizontal}}] | ||
| 287 | (let [size (c/th :font-size) | 297 | (let [size (c/th :font-size) |
| 288 | pad (c/th :padding) | 298 | pad (c/th :padding) |
| 289 | h (double (c/th :control-height)) | 299 | h (double (c/th :control-height)) |
| 290 | id (c/next-id key) | 300 | id (c/next-id key) |
| 291 | - rect (c/leaf [160.0 h] expand [0.0 0.5]) | 301 | + rect (c/leaf [(double (or min-width 160.0)) h] expand [0.0 0.5]) |
| 292 | {:keys [hover? focused?]} (c/interact! id rect) | 302 | {:keys [hover? focused?]} (c/interact! id rect) |
| 293 | [rx ry rw rh] rect | 303 | [rx ry rw rh] rect |
| 294 | s (str value) | 304 | s (str value) |
| @@ -319,6 +329,13 @@ | |||
| 319 | [s caret0] (or evs [])) | 329 | [s caret0] (or evs [])) |
| 320 | caret' (clamp caret' 0 (count s'))] | 330 | caret' (clamp caret' 0 (count s'))] |
| 321 | (c/state! id :caret caret') | 331 | (c/state! id :caret caret') |
| 332 | + ;; Enter is not an edit and must not be swallowed as one: a client | ||
| 333 | + ;; sends its message on it. Recorded as state rather than returned, | ||
| 334 | + ;; because `text-entry` already answers the text and a second return | ||
| 335 | + ;; value would change every existing call. | ||
| 336 | + (c/state! id :activated | ||
| 337 | + (boolean (some #(and (= :key-down (:kind %)) (= :return (:key %))) | ||
| 338 | + (or evs [])))) | ||
| 322 | (c/fill! rect (c/th :surface) (c/th :radius) | 339 | (c/fill! rect (c/th :surface) (c/th :radius) |
| 323 | (if focused? (c/th :focus) (c/th :border)) | 340 | (if focused? (c/th :focus) (c/th :border)) |
| 324 | (if focused? 2.0 (c/th :border-width))) | 341 | (if focused? 2.0 (c/th :border-width))) |