nandi/frqpublic Fork 0
10a1bd0
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.

Show the list and the conversation at once on a wide window

The app is laid out for a phone: the list and the chat take turns, and a
desktop window gets the same single column with margins either side. Past
900pt there is room for both, so the chats screen becomes two panes — the
list at 320, the conversation in what is left — and opening a channel
there is a change of `current` rather than a change of screen. Discover
and settings stay whole screens: they are somewhere else, not the other
half of here.

Nothing reported a window's size, so the backend writes it onto the
window node the way an entry writes back its text, and a timer polls it
into a cell. A poll rather than an event: only what a component derefs
re-renders, so the layout follows a drag of the window's edge without
every frame touching the tree.

Two things the panes needed from vidya. `:fill-height`, because a column
in a row is otherwise as tall as the row — one button, at the moment it
is placed — and both the list and the backlog size themselves against the
height they are handed. And `:wrap false`, because an `:hbox` wraps: the
conversation asking for a few points more than were left was painted on a
row below the list, off the bottom of the window, which reads exactly
like a conversation that has gone missing. So only the list is given a
width now, and the conversation takes the remainder rather than the
window's width minus a guess at the list's.

The glimmer pin moves for the other half of that bug: replacing a native
node with one of another tag left every watcher under it subscribed, so
swapping the layouts shredded the chat — one component's props painted
onto another's widget.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nandi committed 2026-08-30T17:22:38-07:00 Browse files
10a1bd0 parent: 3c98eb0
modified deps.edn +6 -2
@@ -2,12 +2,16 @@
22
33 ;; glimmer owns the reactive half (ratom, components, reconciler);
44 ;; glimmer-vidya paints it as Vidya/egui through libvidya's retained-tree ABI.
5- ;; Our fork of glimmer, until the reconciler fix in it lands upstream: a
5+ ;; Our fork of glimmer, until the reconciler fixes in it land upstream. A
66 ;; component unmounted between a cell firing and the queued render running
77 ;; used to render anyway, into widgets the backend had already freed and
88 ;; handed out again — which took the message list apart in a busy channel.
9+ ;; And replacing a native node with one of another tag left every watcher
10+ ;; under it subscribed, so swapping the phone layout for the split one and
11+ ;; back shredded the chat: one component's props painted onto another's
12+ ;; widget.
913 :deps {jolt-lang/glimmer {:git/url "https://gitlab.com/nandithebull/glimmer"
10- :git/sha "4e733aa26343cef0478d8dfd3f2b76c0113b0969"}
14+ :git/sha "399df371c790d690fb6e4560c3d4d7f838502857"}
1115 nandi/glimmer-vidya {:local/root "../vidya/glimmer"}}
1216
1317 ;; libvidya, the Rust/egui build (the C/raylib one does not export the tree
@@ -2,12 +2,16 @@
2 2
3 ;; glimmer owns the reactive half (ratom, components, reconciler);3 ;; glimmer owns the reactive half (ratom, components, reconciler);
4 ;; glimmer-vidya paints it as Vidya/egui through libvidya's retained-tree ABI.4 ;; glimmer-vidya paints it as Vidya/egui through libvidya's retained-tree ABI.
5- ;; Our fork of glimmer, until the reconciler fix in it lands upstream: a5+ ;; Our fork of glimmer, until the reconciler fixes in it land upstream. A
6 ;; component unmounted between a cell firing and the queued render running6 ;; component unmounted between a cell firing and the queued render running
7 ;; used to render anyway, into widgets the backend had already freed and7 ;; used to render anyway, into widgets the backend had already freed and
8 ;; handed out again — which took the message list apart in a busy channel.8 ;; handed out again — which took the message list apart in a busy channel.
9+ ;; And replacing a native node with one of another tag left every watcher
10+ ;; under it subscribed, so swapping the phone layout for the split one and
11+ ;; back shredded the chat: one component's props painted onto another's
12+ ;; widget.
9 :deps {jolt-lang/glimmer {:git/url "https://gitlab.com/nandithebull/glimmer"13 :deps {jolt-lang/glimmer {:git/url "https://gitlab.com/nandithebull/glimmer"
10- :git/sha "4e733aa26343cef0478d8dfd3f2b76c0113b0969"}14+ :git/sha "399df371c790d690fb6e4560c3d4d7f838502857"}
11 nandi/glimmer-vidya {:local/root "../vidya/glimmer"}}15 nandi/glimmer-vidya {:local/root "../vidya/glimmer"}}
12 16
13 ;; libvidya, the Rust/egui build (the C/raylib one does not export the tree17 ;; libvidya, the Rust/egui build (the C/raylib one does not export the tree
modified src/frq/app.jolt +67 -9
@@ -529,7 +529,13 @@
529529 ;; bottom of a screen wants is above it, not beneath it.
530530 [:vbox {:spacing 8 :margin 12 :margin-bottom 4}
531531 [:hbox {:spacing 8}
532- [:button {:label "← Chats" :on-click #(reset! s/screen :chats)}]
532+ ;; The way back to the list, on a window with room for one thing at a
533+ ;; time. Beside the list there is nothing to go back to, so the button
534+ ;; goes in a wrapper of its own, since a child that comes and goes
535+ ;; would otherwise renumber the row for the reconciler.
536+ [:vbox {:key :back}
537+ (when-not (s/wide?)
538+ [:button {:label "← Chats" :on-click #(reset! s/screen :chats)}])]
533539 [:title {:label (or name "Chat")}]]
534540 [error-note]
535541 ;; :reserve leaves room for everything below: the jump button's row, the
@@ -602,6 +608,46 @@
602608 :on-activate s/send-draft!}]
603609 [:button {:label "Send" :kind :primary :on-click s/send-draft!}]]]))
604610
611+;; ---------------------------------------------------------------- split
612+
613+(def ^:private sidebar-width 320)
614+
615+(defn- no-chat-pane
616+ "What fills the second pane before a conversation has been picked. The pane
617+ is there either way a list that widened into two columns and back again as
618+ channels were opened would be a worse answer than an empty half."
619+ []
620+ [:vbox {:spacing 8 :margin 12}
621+ [:title {:label "frq"}]
622+ [:card {} [:dim-label {:label "Pick a conversation on the left."}]]])
623+
624+(defn split-screen
625+ "The chats list and the conversation side by side, for a window wide enough
626+ to hold both.
627+
628+ Same components as the narrow layout, in a row instead of one at a time: the
629+ list keeps its own scroll and its own tab bar, and the conversation keeps the
630+ compose bar pinned under a backlog that scrolls on its own.
631+
632+ Both panes take `:fill-height`: a column in a row is otherwise as tall as the
633+ row, which at the moment it is placed is one button and the list and the
634+ message backlog both size themselves against the height they are handed.
635+
636+ Only the list is given a width. The conversation takes what is left, rather
637+ than the window's width minus the list's: the list costs a little more than
638+ its 320 (its page has padding of its own), and a second column asking for
639+ more than remains is wrapped onto a row below painting the whole
640+ conversation off the bottom of the window, which reads exactly like a
641+ conversation that has gone missing."
642+ []
643+ [:hbox {:spacing 0 :wrap false}
644+ [:vbox {:key :list :width-request sidebar-width :fill-height true}
645+ [chats-screen]]
646+ [:vbox {:key :chat :fill-height true}
647+ (if @s/current
648+ [chat-screen]
649+ [no-chat-pane])]])
650+
605651 ;; ---------------------------------------------------------------- discover
606652
607653 (defn discover-screen []
@@ -658,14 +704,19 @@
658704 ;; ---------------------------------------------------------------- shell
659705
660706 (defn app []
661- (if @s/lightbox
662- [lightbox-screen]
663- (case @s/screen
664- :connect [connect-screen]
665- :chat [chat-screen]
666- :discover [discover-screen]
667- :settings [settings-screen]
668- [chats-screen])))
707+ (cond
708+ @s/lightbox [lightbox-screen]
709+ ;; Wide enough for both, and on one of the two screens that are halves of
710+ ;; the same thing: the list and the conversation it opens. Discover and
711+ ;; settings stay whole screens they are somewhere else, not the other
712+ ;; half of here.
713+ (and (s/wide?) (contains? #{:chats :chat} @s/screen)) [split-screen]
714+ :else (case @s/screen
715+ :connect [connect-screen]
716+ :chat [chat-screen]
717+ :discover [discover-screen]
718+ :settings [settings-screen]
719+ [chats-screen])))
669720
670721 (defn -main [& _]
671722 ;; Before the window: the rooms this client has been in, and a saved sign-in
@@ -679,4 +730,11 @@
679730 ;; user should be looking at while this happens, and if it fails, the
680731 ;; error lands somewhere visible.
681732 (vidya/after! 150 s/connect!))
733+ ;; The window's width, into a ratom, a few times a second. Polled rather than
734+ ;; delivered: the backend reports a size by writing it onto the window node,
735+ ;; and only what a component derefs re-renders so the layout follows a drag
736+ ;; of the window's edge without every frame touching the tree.
737+ (vidya/every! 200 #(let [w (vidya/window-width)]
738+ (when (not= w @s/window-width)
739+ (reset! s/window-width w))))
682740 (ui/run app :title "frq" :width 520 :height 860))
@@ -529,7 +529,13 @@
529 ;; bottom of a screen wants is above it, not beneath it.529 ;; bottom of a screen wants is above it, not beneath it.
530 [:vbox {:spacing 8 :margin 12 :margin-bottom 4}530 [:vbox {:spacing 8 :margin 12 :margin-bottom 4}
531 [:hbox {:spacing 8}531 [:hbox {:spacing 8}
532- [:button {:label "← Chats" :on-click #(reset! s/screen :chats)}]532+ ;; The way back to the list, on a window with room for one thing at a
533+ ;; time. Beside the list there is nothing to go back to, so the button
534+ ;; goes in a wrapper of its own, since a child that comes and goes
535+ ;; would otherwise renumber the row for the reconciler.
536+ [:vbox {:key :back}
537+ (when-not (s/wide?)
538+ [:button {:label "← Chats" :on-click #(reset! s/screen :chats)}])]
533 [:title {:label (or name "Chat")}]]539 [:title {:label (or name "Chat")}]]
534 [error-note]540 [error-note]
535 ;; :reserve leaves room for everything below: the jump button's row, the541 ;; :reserve leaves room for everything below: the jump button's row, the
@@ -602,6 +608,46 @@
602 :on-activate s/send-draft!}]608 :on-activate s/send-draft!}]
603 [:button {:label "Send" :kind :primary :on-click s/send-draft!}]]]))609 [:button {:label "Send" :kind :primary :on-click s/send-draft!}]]]))
604 610
611+;; ---------------------------------------------------------------- split
612+
613+(def ^:private sidebar-width 320)
614+
615+(defn- no-chat-pane
616+ "What fills the second pane before a conversation has been picked. The pane
617+ is there either way a list that widened into two columns and back again as
618+ channels were opened would be a worse answer than an empty half."
619+ []
620+ [:vbox {:spacing 8 :margin 12}
621+ [:title {:label "frq"}]
622+ [:card {} [:dim-label {:label "Pick a conversation on the left."}]]])
623+
624+(defn split-screen
625+ "The chats list and the conversation side by side, for a window wide enough
626+ to hold both.
627+
628+ Same components as the narrow layout, in a row instead of one at a time: the
629+ list keeps its own scroll and its own tab bar, and the conversation keeps the
630+ compose bar pinned under a backlog that scrolls on its own.
631+
632+ Both panes take `:fill-height`: a column in a row is otherwise as tall as the
633+ row, which at the moment it is placed is one button and the list and the
634+ message backlog both size themselves against the height they are handed.
635+
636+ Only the list is given a width. The conversation takes what is left, rather
637+ than the window's width minus the list's: the list costs a little more than
638+ its 320 (its page has padding of its own), and a second column asking for
639+ more than remains is wrapped onto a row below painting the whole
640+ conversation off the bottom of the window, which reads exactly like a
641+ conversation that has gone missing."
642+ []
643+ [:hbox {:spacing 0 :wrap false}
644+ [:vbox {:key :list :width-request sidebar-width :fill-height true}
645+ [chats-screen]]
646+ [:vbox {:key :chat :fill-height true}
647+ (if @s/current
648+ [chat-screen]
649+ [no-chat-pane])]])
650+
605 ;; ---------------------------------------------------------------- discover651 ;; ---------------------------------------------------------------- discover
606 652
607 (defn discover-screen []653 (defn discover-screen []
@@ -658,14 +704,19 @@
658 ;; ---------------------------------------------------------------- shell704 ;; ---------------------------------------------------------------- shell
659 705
660 (defn app []706 (defn app []
661- (if @s/lightbox707+ (cond
662- [lightbox-screen]708+ @s/lightbox [lightbox-screen]
663- (case @s/screen709+ ;; Wide enough for both, and on one of the two screens that are halves of
664- :connect [connect-screen]710+ ;; the same thing: the list and the conversation it opens. Discover and
665- :chat [chat-screen]711+ ;; settings stay whole screens they are somewhere else, not the other
666- :discover [discover-screen]712+ ;; half of here.
667- :settings [settings-screen]713+ (and (s/wide?) (contains? #{:chats :chat} @s/screen)) [split-screen]
668- [chats-screen])))714+ :else (case @s/screen
715+ :connect [connect-screen]
716+ :chat [chat-screen]
717+ :discover [discover-screen]
718+ :settings [settings-screen]
719+ [chats-screen])))
669 720
670 (defn -main [& _]721 (defn -main [& _]
671 ;; Before the window: the rooms this client has been in, and a saved sign-in722 ;; Before the window: the rooms this client has been in, and a saved sign-in
@@ -679,4 +730,11 @@
679 ;; user should be looking at while this happens, and if it fails, the730 ;; user should be looking at while this happens, and if it fails, the
680 ;; error lands somewhere visible.731 ;; error lands somewhere visible.
681 (vidya/after! 150 s/connect!))732 (vidya/after! 150 s/connect!))
733+ ;; The window's width, into a ratom, a few times a second. Polled rather than
734+ ;; delivered: the backend reports a size by writing it onto the window node,
735+ ;; and only what a component derefs re-renders so the layout follows a drag
736+ ;; of the window's edge without every frame touching the tree.
737+ (vidya/every! 200 #(let [w (vidya/window-width)]
738+ (when (not= w @s/window-width)
739+ (reset! s/window-width w))))
682 (ui/run app :title "frq" :width 520 :height 860))740 (ui/run app :title "frq" :width 520 :height 860))
modified src/frq/state.jolt +31 -2
@@ -88,6 +88,32 @@
8888 (defonce lightbox (atom nil)) ; {:path :url}
8989 (defonce join-input (atom ""))
9090
91+;; The window's content width in points, polled from the backend a few times a
92+;; second. The app is laid out for a phone-width window, and this is what lets
93+;; a wide one be more than a phone with margins: past `wide-width` the channel
94+;; list and the conversation are both on screen instead of taking turns.
95+(defonce window-width (atom 0))
96+
97+;; Where the second pane starts paying for itself. Below this a 300pt list
98+;; beside a conversation leaves the messages narrower than the phone layout
99+;; they were written for.
100+(def wide-width 900)
101+
102+(defn wide?
103+ "True while the window has room for the list and a conversation at once."
104+ []
105+ (>= @window-width wide-width))
106+
107+(defn chat-visible?
108+ "Whether the conversation in `current` is on screen.
109+
110+ On a narrow window that is the chat screen alone. On a wide one the chats
111+ screen shows it too, in the pane beside the list — so this, and not the
112+ screen, is what decides whether an arriving line counts as unread."
113+ []
114+ (or (= :chat @screen)
115+ (and (wide?) (= :chats @screen))))
116+
91117 ;; Whether the chat view is showing the newest line, and a counter the view
92118 ;; watches to be told to go back to it. A counter rather than a flag: a flag
93119 ;; would need clearing, and there is no frame in which to clear it.
@@ -150,7 +176,7 @@
150176 (swap! channels
151177 (fn [m]
152178 (let [m (ensure-channel m channel)
153- viewing? (and (= :chat @screen) (= channel @current))]
179+ viewing? (and (chat-visible?) (= channel @current))]
154180 (-> m
155181 (update-in [channel :messages] conj
156182 {:from from :text text :system? (= "*" from)
@@ -170,7 +196,10 @@
170196 buffer stays), and opening one is a request to be in it."
171197 [name]
172198 (reset! current name)
173- (reset! screen :chat)
199+ ;; On a wide window the conversation lives in the chats screen's second
200+ ;; pane, beside the list; :chat is the narrow window's way of showing it
201+ ;; instead of the list, and there is nothing there to trade it for.
202+ (reset! screen (if (wide?) :chats :chat))
174203 ;; A picker belongs to the message it was opened on; carrying it into another
175204 ;; buffer would offer to react to something that is no longer on screen.
176205 (reset! reacting nil)
@@ -88,6 +88,32 @@
88 (defonce lightbox (atom nil)) ; {:path :url}88 (defonce lightbox (atom nil)) ; {:path :url}
89 (defonce join-input (atom ""))89 (defonce join-input (atom ""))
90 90
91+;; The window's content width in points, polled from the backend a few times a
92+;; second. The app is laid out for a phone-width window, and this is what lets
93+;; a wide one be more than a phone with margins: past `wide-width` the channel
94+;; list and the conversation are both on screen instead of taking turns.
95+(defonce window-width (atom 0))
96+
97+;; Where the second pane starts paying for itself. Below this a 300pt list
98+;; beside a conversation leaves the messages narrower than the phone layout
99+;; they were written for.
100+(def wide-width 900)
101+
102+(defn wide?
103+ "True while the window has room for the list and a conversation at once."
104+ []
105+ (>= @window-width wide-width))
106+
107+(defn chat-visible?
108+ "Whether the conversation in `current` is on screen.
109+
110+ On a narrow window that is the chat screen alone. On a wide one the chats
111+ screen shows it too, in the pane beside the list — so this, and not the
112+ screen, is what decides whether an arriving line counts as unread."
113+ []
114+ (or (= :chat @screen)
115+ (and (wide?) (= :chats @screen))))
116+
91 ;; Whether the chat view is showing the newest line, and a counter the view117 ;; Whether the chat view is showing the newest line, and a counter the view
92 ;; watches to be told to go back to it. A counter rather than a flag: a flag118 ;; watches to be told to go back to it. A counter rather than a flag: a flag
93 ;; would need clearing, and there is no frame in which to clear it.119 ;; would need clearing, and there is no frame in which to clear it.
@@ -150,7 +176,7 @@
150 (swap! channels176 (swap! channels
151 (fn [m]177 (fn [m]
152 (let [m (ensure-channel m channel)178 (let [m (ensure-channel m channel)
153- viewing? (and (= :chat @screen) (= channel @current))]179+ viewing? (and (chat-visible?) (= channel @current))]
154 (-> m180 (-> m
155 (update-in [channel :messages] conj181 (update-in [channel :messages] conj
156 {:from from :text text :system? (= "*" from)182 {:from from :text text :system? (= "*" from)
@@ -170,7 +196,10 @@
170 buffer stays), and opening one is a request to be in it."196 buffer stays), and opening one is a request to be in it."
171 [name]197 [name]
172 (reset! current name)198 (reset! current name)
173- (reset! screen :chat)199+ ;; On a wide window the conversation lives in the chats screen's second
200+ ;; pane, beside the list; :chat is the narrow window's way of showing it
201+ ;; instead of the list, and there is nothing there to trade it for.
202+ (reset! screen (if (wide?) :chats :chat))
174 ;; A picker belongs to the message it was opened on; carrying it into another203 ;; A picker belongs to the message it was opened on; carrying it into another
175 ;; buffer would offer to react to something that is no longer on screen.204 ;; buffer would offer to react to something that is no longer on screen.
176 (reset! reacting nil)205 (reset! reacting nil)