nandi/frqpublic Fork 0
07da0eb
Commits
Clone
git clone https://git.rickub.com/nandi/frq.git
git clone ssh://git@rickub.com/nandi/frq.git

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

Open the picker under the message it is for

It was a panel at the bottom of the window, which left the message it was
about somewhere else on the screen — sometimes several lines up, sometimes
scrolled off. Choosing a reaction is something done *to* a message, so the
picker now opens in the list itself, directly under the line whose ☺ was
clicked, and the conversation carries on below it.

The grid is no longer a scroll of its own. A scroll inside the message list
takes the wheel away from the list it is standing in, and — nested in one —
egui gave it whatever height was going rather than the height it asked for, so
the bottom row came out sliced in half. It lays out whole instead, bounded by
how much it will show: twelve rows, with "and N more" and the search box for
what is past that.

The chat screen goes back to what it was — no reserve for a panel that is no
longer there, and the way back to the present keeps its row.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nandi committed 2026-08-30T12:50:48-07:00 Browse files
07da0eb parent: ad3a77d
modified src/frq/app.jolt +74 -96
@@ -285,6 +285,71 @@
285285 :mine (s/my-reaction? m emoji)
286286 :on-click #(s/toggle-reaction! channel m emoji)}])]))
287287
288+(def ^:private picker-columns
289+ "Emoji to a row, at the picker's own size. Narrow enough that the grid fits a
290+ phone-width window, which is the width this app is laid out for."
291+ 9)
292+
293+(def ^:private picker-size 20)
294+
295+;; A row of chips, and the most the grid may take before it scrolls.
296+
297+
298+(defn emoji-picker
299+ "The whole set to choose from, under the message it is for.
300+
301+ It opens in the list itself, directly under the line it was opened on:
302+ choosing a reaction is something done *to* a message, and the message has to
303+ stay in front of the reader while it is chosen — which is not something a
304+ panel at the bottom of the window, or a screen of its own, can promise.
305+
306+ It opens on the emoji people actually react with; the groups and the search
307+ box are for the rest."
308+ []
309+ (let [shown (s/picker-emoji)
310+ over (max 0 (- (count shown) s/picker-limit))
311+ rows (partition-all picker-columns (take s/picker-limit shown))
312+ searching? (seq (str/trim @s/emoji-search))]
313+ [:vbox {:key :picker :spacing 4}
314+ [:hbox {:spacing 6}
315+ [:entry {:text @s/emoji-search
316+ :width-request 240
317+ :placeholder "Search emoji"
318+ :on-change #(reset! s/emoji-search %)}]
319+ [:button {:label "" :on-click s/close-picker!}]]
320+ ;; The groups are what the search box is not: a way in for someone who has
321+ ;; no word for what they want. Their first word is enough to tell them
322+ ;; apart, and is what keeps them to two rows. They give way to the
323+ ;; search's own answer while something is typed.
324+ [:vbox {:key :groups :spacing 4}
325+ (when-not searching?
326+ (for [[i row] (map-indexed vector (partition-all 5 (cons nil s/emoji-groups)))]
327+ [:hbox {:key i :spacing 4}
328+ (for [g row]
329+ [:button {:key (or g "popular")
330+ :label (if g (first (str/split g #" ")) "Popular")
331+ :kind (if (= g @s/emoji-group) :primary :normal)
332+ :on-click #(reset! s/emoji-group g)}])]))]
333+ ;; The grid itself, not a scroll around it: the picker sits inside the
334+ ;; message list, and a scroll within a scroll takes the wheel away from
335+ ;; the conversation it is standing in. What it shows is bounded instead,
336+ ;; and the search box is how you reach past that.
337+ [:vbox {:key :grid :spacing 2}
338+ (if (seq rows)
339+ (for [[i row] (map-indexed vector rows)]
340+ [:hbox {:key i :spacing 2}
341+ (for [[glyph] row]
342+ [:reaction {:key glyph
343+ :emoji glyph
344+ :size picker-size
345+ :count 0
346+ :on-click #(s/react-from-picker! glyph)}])])
347+ [:dim-label {:label "No emoji by that name."}])]
348+ ;; What was left out, said rather than silently dropped.
349+ [:vbox {:key :more}
350+ (when (pos? over)
351+ [:dim-label {:label (str "and " over " more — keep typing to narrow it")}])]]))
352+
288353 (defn message-row
289354 "One message. `prev` is the message above it, which decides whether this one
290355 repeats the sender.
@@ -365,6 +430,11 @@
365430 [:vbox {:key :reactions-row :margin-top 6 :margin-bottom 2}
366431 (when (and (:id m) (not (:system? m)) (seq (:reactions m)))
367432 [reaction-row @s/current m])]
433+ ;; And the picker, when this is the message it was opened on: under the
434+ ;; line it is about, where the reader is already looking.
435+ [:vbox {:key :picker :margin-bottom 4}
436+ (when (= (:id m) (:id @s/reacting))
437+ [emoji-picker])]
368438 ;; Pictures under the line that linked them. The link stays: it is what a
369439 ;; failed fetch, an unsupported format, or a phone with no TLS leaves you.
370440 [:vbox {:key :images :spacing 4}
@@ -419,86 +489,6 @@
419489 ;; Clicking the picture closes it too — the same gesture that opened it.
420490 [:image {:src path :max-height 20000 :on-click #(reset! s/lightbox nil)}]]]))
421491
422-(def ^:private picker-columns
423- "Emoji to a row, at the picker's own size. Narrow enough that the grid fits a
424- phone-width window, which is the width this app is laid out for."
425- 9)
426-
427-(def ^:private picker-size 20)
428-
429-(def ^:private picker-row-height 48)
430-(def ^:private picker-grid-max 170)
431-
432-(defn- picker-reserve
433- "How much room the picker panel wants under the message list.
434-
435- Measured rather than fixed: a panel is as tall as the grid it is showing, and
436- a reserve that assumed the tallest would leave a hole above a short one the
437- popular row is two rows deep, a group is as deep as the grid allows."
438- []
439- (let [n (min s/picker-limit (count (s/picker-emoji)))
440- rows (quot (+ n picker-columns -1) picker-columns)
441- searching? (seq (str/trim @s/emoji-search))]
442- (+ 82 ; the separator and compose bar
443- 44 ; the search box
444- (if searching? 0 88) ; the two rows of groups
445- (min picker-grid-max (* picker-row-height rows))
446- ;; The spacing between the panel's own rows, which is small and adds up
447- ;; — a reserve that came up short would put the compose bar under the
448- ;; bottom of the window.
449- 60)))
450-
451-(defn emoji-picker
452- "The whole set to choose from, over the compose bar, for one message.
453-
454- A panel under the conversation rather than a screen of its own: choosing a
455- reaction is something done *to* a message, and a picker that hid the message
456- would be asking about something no longer on screen. It takes the bottom of
457- the window; the list above it gives up that much room and keeps the rest.
458-
459- It opens on the emoji people actually react with; the groups and the search
460- box are for the rest."
461- []
462- (let [shown (s/picker-emoji)
463- over (max 0 (- (count shown) s/picker-limit))
464- rows (partition-all picker-columns (take s/picker-limit shown))
465- searching? (seq (str/trim @s/emoji-search))]
466- [:vbox {:key :picker :spacing 4}
467- [:hbox {:spacing 6}
468- [:entry {:text @s/emoji-search
469- :width-request 240
470- :placeholder "Search emoji"
471- :on-change #(reset! s/emoji-search %)}]
472- [:button {:label "" :on-click s/close-picker!}]]
473- ;; The groups are what the search box is not: a way in for someone who has
474- ;; no word for what they want. Their first word is enough to tell them
475- ;; apart, and is what keeps them to two rows. They give way to the
476- ;; search's own answer while something is typed.
477- [:vbox {:key :groups :spacing 4}
478- (when-not searching?
479- (for [[i row] (map-indexed vector (partition-all 5 (cons nil s/emoji-groups)))]
480- [:hbox {:key i :spacing 4}
481- (for [g row]
482- [:button {:key (or g "popular")
483- :label (if g (first (str/split g #" ")) "Popular")
484- :kind (if (= g @s/emoji-group) :primary :normal)
485- :on-click #(reset! s/emoji-group g)}])]))]
486- [:scroll {:orientation :vertical :max-height picker-grid-max}
487- (if (seq rows)
488- (for [[i row] (map-indexed vector rows)]
489- [:hbox {:key i :spacing 2}
490- (for [[glyph] row]
491- [:reaction {:key glyph
492- :emoji glyph
493- :size picker-size
494- :count 0
495- :on-click #(s/react-from-picker! glyph)}])])
496- [:dim-label {:label "No emoji by that name."}])]
497- ;; What was left out, said rather than silently dropped.
498- [:vbox {:key :more}
499- (when (pos? over)
500- [:dim-label {:label (str "and " over " more keep typing to narrow it")}])]]))
501-
502492 (defn chat-screen []
503493 (let [name @s/current
504494 buffer (get @s/channels name)]
@@ -522,12 +512,7 @@
522512 ;; move the compose bar, and should: it appears because the
523513 ;; reader asked to answer something, unlike the jump button,
524514 ;; which appears on its own and must not shift what is under it.
525- ;; And the picker's, which takes the bottom of the window
526- ;; while it is open — the list gives up that room rather than
527- ;; being painted under it.
528- :reserve (cond @s/reacting (picker-reserve)
529- @s/replying-to 150
530- :else 116)
515+ :reserve (if @s/replying-to 150 116)
531516 :stick-to-bottom true
532517 :scroll-to-bottom @s/jump-tick
533518 :on-change #(reset! s/at-present? (= "end" %))}
@@ -538,17 +523,10 @@
538523 ;; back to the present belongs next to the thing that puts you there.
539524 ;; The row keeps its height whether or not the button is in it, so the
540525 ;; compose bar below stays where the reader last saw it.
541- ;; Choosing an emoji is the only thing being done while the picker is
542- ;; open, so it takes the row the way back to the present would have had.
543- [:vbox {:key :picker-panel}
544- (when @s/reacting [emoji-picker])]
545526 [:vbox {:key :jump}
546- (cond
547- ;; Not even the spacer while the picker is open: the panel is what is
548- ;; holding the compose bar's place, and a gap under it is just a gap.
549- @s/reacting nil
550- @s/at-present? [:spacer {:size 34}]
551- :else [:button {:label " Jump to present" :on-click s/jump-to-present!}])]
527+ (if @s/at-present?
528+ [:spacer {:size 34}]
529+ [:button {:label " Jump to present" :on-click s/jump-to-present!}])]
552530 [:separator {}]
553531 ;; What the draft is answering, directly above where it is being typed.
554532 [:vbox {:key :replying}
@@ -285,6 +285,71 @@
285 :mine (s/my-reaction? m emoji)285 :mine (s/my-reaction? m emoji)
286 :on-click #(s/toggle-reaction! channel m emoji)}])]))286 :on-click #(s/toggle-reaction! channel m emoji)}])]))
287 287
288+(def ^:private picker-columns
289+ "Emoji to a row, at the picker's own size. Narrow enough that the grid fits a
290+ phone-width window, which is the width this app is laid out for."
291+ 9)
292+
293+(def ^:private picker-size 20)
294+
295+;; A row of chips, and the most the grid may take before it scrolls.
296+
297+
298+(defn emoji-picker
299+ "The whole set to choose from, under the message it is for.
300+
301+ It opens in the list itself, directly under the line it was opened on:
302+ choosing a reaction is something done *to* a message, and the message has to
303+ stay in front of the reader while it is chosen — which is not something a
304+ panel at the bottom of the window, or a screen of its own, can promise.
305+
306+ It opens on the emoji people actually react with; the groups and the search
307+ box are for the rest."
308+ []
309+ (let [shown (s/picker-emoji)
310+ over (max 0 (- (count shown) s/picker-limit))
311+ rows (partition-all picker-columns (take s/picker-limit shown))
312+ searching? (seq (str/trim @s/emoji-search))]
313+ [:vbox {:key :picker :spacing 4}
314+ [:hbox {:spacing 6}
315+ [:entry {:text @s/emoji-search
316+ :width-request 240
317+ :placeholder "Search emoji"
318+ :on-change #(reset! s/emoji-search %)}]
319+ [:button {:label "" :on-click s/close-picker!}]]
320+ ;; The groups are what the search box is not: a way in for someone who has
321+ ;; no word for what they want. Their first word is enough to tell them
322+ ;; apart, and is what keeps them to two rows. They give way to the
323+ ;; search's own answer while something is typed.
324+ [:vbox {:key :groups :spacing 4}
325+ (when-not searching?
326+ (for [[i row] (map-indexed vector (partition-all 5 (cons nil s/emoji-groups)))]
327+ [:hbox {:key i :spacing 4}
328+ (for [g row]
329+ [:button {:key (or g "popular")
330+ :label (if g (first (str/split g #" ")) "Popular")
331+ :kind (if (= g @s/emoji-group) :primary :normal)
332+ :on-click #(reset! s/emoji-group g)}])]))]
333+ ;; The grid itself, not a scroll around it: the picker sits inside the
334+ ;; message list, and a scroll within a scroll takes the wheel away from
335+ ;; the conversation it is standing in. What it shows is bounded instead,
336+ ;; and the search box is how you reach past that.
337+ [:vbox {:key :grid :spacing 2}
338+ (if (seq rows)
339+ (for [[i row] (map-indexed vector rows)]
340+ [:hbox {:key i :spacing 2}
341+ (for [[glyph] row]
342+ [:reaction {:key glyph
343+ :emoji glyph
344+ :size picker-size
345+ :count 0
346+ :on-click #(s/react-from-picker! glyph)}])])
347+ [:dim-label {:label "No emoji by that name."}])]
348+ ;; What was left out, said rather than silently dropped.
349+ [:vbox {:key :more}
350+ (when (pos? over)
351+ [:dim-label {:label (str "and " over " more — keep typing to narrow it")}])]]))
352+
288 (defn message-row353 (defn message-row
289 "One message. `prev` is the message above it, which decides whether this one354 "One message. `prev` is the message above it, which decides whether this one
290 repeats the sender.355 repeats the sender.
@@ -365,6 +430,11 @@
365 [:vbox {:key :reactions-row :margin-top 6 :margin-bottom 2}430 [:vbox {:key :reactions-row :margin-top 6 :margin-bottom 2}
366 (when (and (:id m) (not (:system? m)) (seq (:reactions m)))431 (when (and (:id m) (not (:system? m)) (seq (:reactions m)))
367 [reaction-row @s/current m])]432 [reaction-row @s/current m])]
433+ ;; And the picker, when this is the message it was opened on: under the
434+ ;; line it is about, where the reader is already looking.
435+ [:vbox {:key :picker :margin-bottom 4}
436+ (when (= (:id m) (:id @s/reacting))
437+ [emoji-picker])]
368 ;; Pictures under the line that linked them. The link stays: it is what a438 ;; Pictures under the line that linked them. The link stays: it is what a
369 ;; failed fetch, an unsupported format, or a phone with no TLS leaves you.439 ;; failed fetch, an unsupported format, or a phone with no TLS leaves you.
370 [:vbox {:key :images :spacing 4}440 [:vbox {:key :images :spacing 4}
@@ -419,86 +489,6 @@
419 ;; Clicking the picture closes it too — the same gesture that opened it.489 ;; Clicking the picture closes it too — the same gesture that opened it.
420 [:image {:src path :max-height 20000 :on-click #(reset! s/lightbox nil)}]]]))490 [:image {:src path :max-height 20000 :on-click #(reset! s/lightbox nil)}]]]))
421 491
422-(def ^:private picker-columns
423- "Emoji to a row, at the picker's own size. Narrow enough that the grid fits a
424- phone-width window, which is the width this app is laid out for."
425- 9)
426-
427-(def ^:private picker-size 20)
428-
429-(def ^:private picker-row-height 48)
430-(def ^:private picker-grid-max 170)
431-
432-(defn- picker-reserve
433- "How much room the picker panel wants under the message list.
434-
435- Measured rather than fixed: a panel is as tall as the grid it is showing, and
436- a reserve that assumed the tallest would leave a hole above a short one the
437- popular row is two rows deep, a group is as deep as the grid allows."
438- []
439- (let [n (min s/picker-limit (count (s/picker-emoji)))
440- rows (quot (+ n picker-columns -1) picker-columns)
441- searching? (seq (str/trim @s/emoji-search))]
442- (+ 82 ; the separator and compose bar
443- 44 ; the search box
444- (if searching? 0 88) ; the two rows of groups
445- (min picker-grid-max (* picker-row-height rows))
446- ;; The spacing between the panel's own rows, which is small and adds up
447- ;; — a reserve that came up short would put the compose bar under the
448- ;; bottom of the window.
449- 60)))
450-
451-(defn emoji-picker
452- "The whole set to choose from, over the compose bar, for one message.
453-
454- A panel under the conversation rather than a screen of its own: choosing a
455- reaction is something done *to* a message, and a picker that hid the message
456- would be asking about something no longer on screen. It takes the bottom of
457- the window; the list above it gives up that much room and keeps the rest.
458-
459- It opens on the emoji people actually react with; the groups and the search
460- box are for the rest."
461- []
462- (let [shown (s/picker-emoji)
463- over (max 0 (- (count shown) s/picker-limit))
464- rows (partition-all picker-columns (take s/picker-limit shown))
465- searching? (seq (str/trim @s/emoji-search))]
466- [:vbox {:key :picker :spacing 4}
467- [:hbox {:spacing 6}
468- [:entry {:text @s/emoji-search
469- :width-request 240
470- :placeholder "Search emoji"
471- :on-change #(reset! s/emoji-search %)}]
472- [:button {:label "" :on-click s/close-picker!}]]
473- ;; The groups are what the search box is not: a way in for someone who has
474- ;; no word for what they want. Their first word is enough to tell them
475- ;; apart, and is what keeps them to two rows. They give way to the
476- ;; search's own answer while something is typed.
477- [:vbox {:key :groups :spacing 4}
478- (when-not searching?
479- (for [[i row] (map-indexed vector (partition-all 5 (cons nil s/emoji-groups)))]
480- [:hbox {:key i :spacing 4}
481- (for [g row]
482- [:button {:key (or g "popular")
483- :label (if g (first (str/split g #" ")) "Popular")
484- :kind (if (= g @s/emoji-group) :primary :normal)
485- :on-click #(reset! s/emoji-group g)}])]))]
486- [:scroll {:orientation :vertical :max-height picker-grid-max}
487- (if (seq rows)
488- (for [[i row] (map-indexed vector rows)]
489- [:hbox {:key i :spacing 2}
490- (for [[glyph] row]
491- [:reaction {:key glyph
492- :emoji glyph
493- :size picker-size
494- :count 0
495- :on-click #(s/react-from-picker! glyph)}])])
496- [:dim-label {:label "No emoji by that name."}])]
497- ;; What was left out, said rather than silently dropped.
498- [:vbox {:key :more}
499- (when (pos? over)
500- [:dim-label {:label (str "and " over " more keep typing to narrow it")}])]]))
501-
502 (defn chat-screen []492 (defn chat-screen []
503 (let [name @s/current493 (let [name @s/current
504 buffer (get @s/channels name)]494 buffer (get @s/channels name)]
@@ -522,12 +512,7 @@
522 ;; move the compose bar, and should: it appears because the512 ;; move the compose bar, and should: it appears because the
523 ;; reader asked to answer something, unlike the jump button,513 ;; reader asked to answer something, unlike the jump button,
524 ;; which appears on its own and must not shift what is under it.514 ;; which appears on its own and must not shift what is under it.
525- ;; And the picker's, which takes the bottom of the window515+ :reserve (if @s/replying-to 150 116)
526- ;; while it is open — the list gives up that room rather than
527- ;; being painted under it.
528- :reserve (cond @s/reacting (picker-reserve)
529- @s/replying-to 150
530- :else 116)
531 :stick-to-bottom true516 :stick-to-bottom true
532 :scroll-to-bottom @s/jump-tick517 :scroll-to-bottom @s/jump-tick
533 :on-change #(reset! s/at-present? (= "end" %))}518 :on-change #(reset! s/at-present? (= "end" %))}
@@ -538,17 +523,10 @@
538 ;; back to the present belongs next to the thing that puts you there.523 ;; back to the present belongs next to the thing that puts you there.
539 ;; The row keeps its height whether or not the button is in it, so the524 ;; The row keeps its height whether or not the button is in it, so the
540 ;; compose bar below stays where the reader last saw it.525 ;; compose bar below stays where the reader last saw it.
541- ;; Choosing an emoji is the only thing being done while the picker is
542- ;; open, so it takes the row the way back to the present would have had.
543- [:vbox {:key :picker-panel}
544- (when @s/reacting [emoji-picker])]
545 [:vbox {:key :jump}526 [:vbox {:key :jump}
546- (cond527+ (if @s/at-present?
547- ;; Not even the spacer while the picker is open: the panel is what is528+ [:spacer {:size 34}]
548- ;; holding the compose bar's place, and a gap under it is just a gap.529+ [:button {:label " Jump to present" :on-click s/jump-to-present!}])]
549- @s/reacting nil
550- @s/at-present? [:spacer {:size 34}]
551- :else [:button {:label " Jump to present" :on-click s/jump-to-present!}])]
552 [:separator {}]530 [:separator {}]
553 ;; What the draft is answering, directly above where it is being typed.531 ;; What the draft is answering, directly above where it is being typed.
554 [:vbox {:key :replying}532 [:vbox {:key :replying}
modified src/frq/state.jolt +5 -3
@@ -542,9 +542,11 @@
542542 emoji/groups)
543543
544544 (def picker-limit
545- "How many glyphs one screen of the picker will show. A search that matches
546- more says so rather than laying out hundreds of pictures nobody scrolled to."
547- 240)
545+ "How many glyphs the picker will lay out at once — twelve rows of the nine it
546+ fits across. It stands inside the message list, so what it shows pushes the
547+ conversation down; a group that has more says so, and the search box is how
548+ you reach the rest."
549+ 108)
548550
549551 (defn picker-emoji
550552 "What the picker is showing right now: the popular row, one group, or
@@ -542,9 +542,11 @@
542 emoji/groups)542 emoji/groups)
543 543
544 (def picker-limit544 (def picker-limit
545- "How many glyphs one screen of the picker will show. A search that matches545+ "How many glyphs the picker will lay out at once — twelve rows of the nine it
546- more says so rather than laying out hundreds of pictures nobody scrolled to."546+ fits across. It stands inside the message list, so what it shows pushes the
547- 240)547+ conversation down; a group that has more says so, and the search box is how
548+ you reach the rest."
549+ 108)
548 550
549 (defn picker-emoji551 (defn picker-emoji
550 "What the picker is showing right now: the popular row, one group, or552 "What the picker is showing right now: the popular row, one group, or