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

Draw the conversation on the phone

frq.app's chat screen, the same 1,168 lines the desktop renders: the message
list with its avatars and senders, the header, the compose bar. Live in
#test.

Getting there was all renderer, and `Length::Fill` is the whole of it. The
rule it settled at, after four wrong shapes:

A child that fills is Expanded, not a bigger mainAxisSize. The question is
recursive — a plain :vbox holding a :scroll fills too, and the chat screen is
three wrappers deep. A Row holding a filling column must stretch, or the
column is handed a loose height and its Expanded lands in unbounded space. And
a pane that fills a column takes the row's width as well: the message list
sits beside the people panel, and at its natural width it is as wide as its
longest URL.

Prose in a row is Flexible rather than Expanded, because Expanded hands out
equal shares and a header of four buttons then reads Ch/at/s.

:scroll no longer wraps itself, since its parent does — two ParentDataWidgets
on one RenderObject is "competing", and Flutter draws nothing.

And :width-request 0 means no request. Every number is truthy in Clojure, so
taking it at face value gave the message list a SizedBox of zero width.

frq.state's install map moved its three profile entries to frq.app, which is
the namespace that requires frq.profile; they only worked before because they
sat inside a fn body and resolved late.

Verified both ways: the TUI renders the conversation identically to a
same-prefs baseline in a throwaway worktree, and the phone shows #test with no
overflow.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nandi committed 2026-09-11T23:04:00-07:00 Browse files
c0121fe parent: 5485559
modified common/frq/actions.cljc +27 -0
@@ -112,3 +112,30 @@
112112 (defn start-edit! [& args] (call :start-edit! args))
113113 (defn toggle-reaction! [& args] (call :toggle-reaction! args))
114114 (defn unhover-reaction! [& args] (call :unhover-reaction! args))
115+
116+;; ------------------------------------------------------ the rest of a call
117+;;
118+;; The media plane's own state, which is not a cell here because it is not
119+;; state this app holds: it lives in `frq.av`, which wraps a MoQ session, the
120+;; codecs and the devices. A phone installs none of it.
121+
122+(defn local-call [] (call :local-call []))
123+(defn local-feed [] (call :local-feed []))
124+(defn media-error [] (call :media-error []))
125+(defn tiles [& args] (call :tiles args))
126+(defn tile-rows [& args] (call :tile-rows args))
127+(defn set-muted! [& args] (call :set-muted! args))
128+(defn set-speaker-muted! [& args] (call :set-speaker-muted! args))
129+(defn set-camera! [& args] (call :set-camera! args))
130+
131+;; --------------------------------------------------------------- platform
132+
133+(defn after! [& args] (call :after! args))
134+(defn open-url! [& args] (call :open-url! args))
135+
136+;; ---------------------------------------------------------------- profile
137+
138+(defn profile-hover! [& args] (call :profile-hover! args))
139+(defn profile-unhover! [& args] (call :profile-unhover! args))
140+(defn profile-open! [& args] (call :profile-open! args))
141+
@@ -112,3 +112,30 @@
112 (defn start-edit! [& args] (call :start-edit! args))112 (defn start-edit! [& args] (call :start-edit! args))
113 (defn toggle-reaction! [& args] (call :toggle-reaction! args))113 (defn toggle-reaction! [& args] (call :toggle-reaction! args))
114 (defn unhover-reaction! [& args] (call :unhover-reaction! args))114 (defn unhover-reaction! [& args] (call :unhover-reaction! args))
115+
116+;; ------------------------------------------------------ the rest of a call
117+;;
118+;; The media plane's own state, which is not a cell here because it is not
119+;; state this app holds: it lives in `frq.av`, which wraps a MoQ session, the
120+;; codecs and the devices. A phone installs none of it.
121+
122+(defn local-call [] (call :local-call []))
123+(defn local-feed [] (call :local-feed []))
124+(defn media-error [] (call :media-error []))
125+(defn tiles [& args] (call :tiles args))
126+(defn tile-rows [& args] (call :tile-rows args))
127+(defn set-muted! [& args] (call :set-muted! args))
128+(defn set-speaker-muted! [& args] (call :set-speaker-muted! args))
129+(defn set-camera! [& args] (call :set-camera! args))
130+
131+;; --------------------------------------------------------------- platform
132+
133+(defn after! [& args] (call :after! args))
134+(defn open-url! [& args] (call :open-url! args))
135+
136+;; ---------------------------------------------------------------- profile
137+
138+(defn profile-hover! [& args] (call :profile-hover! args))
139+(defn profile-unhover! [& args] (call :profile-unhover! args))
140+(defn profile-open! [& args] (call :profile-open! args))
141+
modified common/frq/screens/chat.cljc +18 -18
@@ -119,7 +119,7 @@
119119 deafened microphone still carries your voice, and one control for both would
120120 make the quieter of the two a surprise."
121121 []
122- (let [{:keys [muted? speaker-muted? camera? has-camera? has-mic? media]} @av/local-call]
122+ (let [{:keys [muted? speaker-muted? camera? has-camera? has-mic? media]} (actions/local-call)]
123123 [:vbox {:key :call-controls :spacing 6}
124124 [:hbox {:spacing 8}
125125 [:label {:label (case media
@@ -134,16 +134,16 @@
134134 [:dim-label {:label "· listening only"}])]
135135 [:hbox {:spacing 8}
136136 [:button {:label (if muted? "Unmute" "Mute")
137- :on-click #(av/set-muted! (not muted?))}]
137+ :on-click #(actions/set-muted! (not muted?))}]
138138 [:button {:label (if speaker-muted? "Undeafen" "Deafen")
139- :on-click #(av/set-speaker-muted! (not speaker-muted?))}]
139+ :on-click #(actions/set-speaker-muted! (not speaker-muted?))}]
140140 ;; Only offered when there is a camera to turn on. Nothing is more
141141 ;; annoying than a control that does nothing and does not say why.
142142 (when has-camera?
143143 [:button {:label (if camera? "Stop video" "Start video")
144- :on-click #(av/set-camera! (not camera?))}])
144+ :on-click #(actions/set-camera! (not camera?))}])
145145 [:button {:label "Leave" :on-click #(actions/leave-call!)}]]
146- (when-let [e @av/media-error]
146+ (when-let [e (actions/media-error)]
147147 [:dim-label {:label (str "" e)}])]))
148148
149149 (defn- call-tile
@@ -157,7 +157,7 @@
157157 over. Naming both keeps a portrait phone from making its tile tall enough to
158158 push the row off the screen the picture is fitted inside, never stretched."
159159 [width key]
160- (let [mine? (= av/local-feed key)]
160+ (let [mine? (= (actions/local-feed) key)]
161161 [:vbox {:key key :spacing 2}
162162 ;; `:upscale` because a tile is a slot the layout sized, not a picture
163163 ;; sitting at whatever the camera happened to send. Without it a 480-wide
@@ -175,12 +175,12 @@
175175 A call with no video is the normal case and should look like one a row of
176176 empty frames would suggest something had failed to load.
177177
178- Both cells this reads are what subscribe it: `av/tiles` for who is on
178+ Both cells this reads are what subscribe it: `actions/tiles` for who is on
179179 screen, and the window width so the tiles follow a window being dragged.
180180 Without either it would lay itself out once, on the first frame, and keep
181181 that shape for the rest of the call."
182182 []
183- (let [[width rows] (av/tile-rows)]
183+ (let [[width rows] (actions/tile-rows)]
184184 [:vbox {:key :call-wall :spacing 6}
185185 ;; A seq, not a vector: children splice, and a vector would be read as one
186186 ;; more hiccup element which an empty one is not.
@@ -220,7 +220,7 @@
220220 ;; We are in a call, but in a different room. Say which, since the
221221 ;; controls are not on this screen to be found by looking.
222222 (actions/in-call?)
223- [:dim-label {:label (str "In a call in " (:channel @av/local-call))}]
223+ [:dim-label {:label (str "In a call in " (:channel (actions/local-call)))}]
224224
225225 :else nil)])
226226
@@ -500,10 +500,10 @@
500500 (actions/open-channel! channel))
501501 (reset! cells/jump-to id)
502502 (reset! cells/highlight id)
503- (platform/after! settle (fn [] (reset! cells/jump-to nil)))
503+ (actions/after! settle (fn [] (reset! cells/jump-to nil)))
504504 ;; The highlight only clears itself: a later jump elsewhere owns the
505505 ;; highlight from then on.
506- (platform/after! linger (fn [] (when (= id @cells/highlight)
506+ (actions/after! linger (fn [] (when (= id @cells/highlight)
507507 (reset! cells/highlight nil)))))
508508
509509 (defn- summarise
@@ -563,7 +563,7 @@
563563 is still one plain label the row is only paid for where it is needed."
564564 [j [kind value] system?]
565565 (if (= :link kind)
566- [:link {:key j :label value :on-click #(platform/open-url! value)}]
566+ [:link {:key j :label value :on-click #(actions/open-url! value)}]
567567 (let [pieces (glyphs/runs (str/trim value))]
568568 (if (glyphs/emoji? pieces)
569569 ;; Runs alternate text and picture, so this gap only ever falls either
@@ -655,7 +655,7 @@
655655 ;; terminal can draw a picture there is a face after all, hung beside
656656 ;; the whole message rather than off its heading `message-row` has it.
657657 (when-not @terminal?
658- (let [src @(actions/avatar-path (:actor m))]
658+ (let [src (actions/avatar-path (:actor m))]
659659 ;; One profile, two gestures, and no card hung under the face: the
660660 ;; pointer opens the dialog and the press pins it. What makes that
661661 ;; work is the dialog being non-modal while the pointer is what is
@@ -663,10 +663,10 @@
663663 [:avatar (cond-> {:label (:from m)
664664 :src (or src "")
665665 :size face-size
666- :on-click #(profile/open! (:from m) (:actor m))}
666+ :on-click #(actions/profile-open! (:from m) (:actor m))}
667667 (actions/desktop?)
668- (assoc :on-hover #(profile/hover! (:from m) (:actor m))
669- :on-unhover #(profile/unhover! (:from m))))]))
668+ (assoc :on-hover #(actions/profile-hover! (:from m) (:actor m))
669+ :on-unhover #(actions/profile-unhover! (:from m))))]))
670670 ;; The name carries the row, so it is set at body size in the plain
671671 ;; text colour: dimmed caption made the one thing you scan a column
672672 ;; for the faintest thing on it.
@@ -725,7 +725,7 @@
725725 ;; wakes this row for its own pictures landing and not for everyone's.
726726 (doall
727727 (for [url (:images m)]
728- (when-let [path @(actions/image-path url)]
728+ (when-let [path (actions/image-path url)]
729729 [:image {:key url
730730 :src path
731731 :max-height (preview-height)
@@ -791,7 +791,7 @@
791791 (if (terminal-face?)
792792 [:hbox {:key :faced :spacing 8}
793793 [:vbox {:key :face :width-request face-size}
794- (when-let [path @(actions/avatar-path (:actor m))]
794+ (when-let [path (actions/avatar-path (:actor m))]
795795 [:image {:key :picture
796796 :src path
797797 :max-width face-size
@@ -119,7 +119,7 @@
119 deafened microphone still carries your voice, and one control for both would119 deafened microphone still carries your voice, and one control for both would
120 make the quieter of the two a surprise."120 make the quieter of the two a surprise."
121 []121 []
122- (let [{:keys [muted? speaker-muted? camera? has-camera? has-mic? media]} @av/local-call]122+ (let [{:keys [muted? speaker-muted? camera? has-camera? has-mic? media]} (actions/local-call)]
123 [:vbox {:key :call-controls :spacing 6}123 [:vbox {:key :call-controls :spacing 6}
124 [:hbox {:spacing 8}124 [:hbox {:spacing 8}
125 [:label {:label (case media125 [:label {:label (case media
@@ -134,16 +134,16 @@
134 [:dim-label {:label "· listening only"}])]134 [:dim-label {:label "· listening only"}])]
135 [:hbox {:spacing 8}135 [:hbox {:spacing 8}
136 [:button {:label (if muted? "Unmute" "Mute")136 [:button {:label (if muted? "Unmute" "Mute")
137- :on-click #(av/set-muted! (not muted?))}]137+ :on-click #(actions/set-muted! (not muted?))}]
138 [:button {:label (if speaker-muted? "Undeafen" "Deafen")138 [:button {:label (if speaker-muted? "Undeafen" "Deafen")
139- :on-click #(av/set-speaker-muted! (not speaker-muted?))}]139+ :on-click #(actions/set-speaker-muted! (not speaker-muted?))}]
140 ;; Only offered when there is a camera to turn on. Nothing is more140 ;; Only offered when there is a camera to turn on. Nothing is more
141 ;; annoying than a control that does nothing and does not say why.141 ;; annoying than a control that does nothing and does not say why.
142 (when has-camera?142 (when has-camera?
143 [:button {:label (if camera? "Stop video" "Start video")143 [:button {:label (if camera? "Stop video" "Start video")
144- :on-click #(av/set-camera! (not camera?))}])144+ :on-click #(actions/set-camera! (not camera?))}])
145 [:button {:label "Leave" :on-click #(actions/leave-call!)}]]145 [:button {:label "Leave" :on-click #(actions/leave-call!)}]]
146- (when-let [e @av/media-error]146+ (when-let [e (actions/media-error)]
147 [:dim-label {:label (str "" e)}])]))147 [:dim-label {:label (str "" e)}])]))
148 148
149 (defn- call-tile149 (defn- call-tile
@@ -157,7 +157,7 @@
157 over. Naming both keeps a portrait phone from making its tile tall enough to157 over. Naming both keeps a portrait phone from making its tile tall enough to
158 push the row off the screen the picture is fitted inside, never stretched."158 push the row off the screen the picture is fitted inside, never stretched."
159 [width key]159 [width key]
160- (let [mine? (= av/local-feed key)]160+ (let [mine? (= (actions/local-feed) key)]
161 [:vbox {:key key :spacing 2}161 [:vbox {:key key :spacing 2}
162 ;; `:upscale` because a tile is a slot the layout sized, not a picture162 ;; `:upscale` because a tile is a slot the layout sized, not a picture
163 ;; sitting at whatever the camera happened to send. Without it a 480-wide163 ;; sitting at whatever the camera happened to send. Without it a 480-wide
@@ -175,12 +175,12 @@
175 A call with no video is the normal case and should look like one a row of175 A call with no video is the normal case and should look like one a row of
176 empty frames would suggest something had failed to load.176 empty frames would suggest something had failed to load.
177 177
178- Both cells this reads are what subscribe it: `av/tiles` for who is on178+ Both cells this reads are what subscribe it: `actions/tiles` for who is on
179 screen, and the window width so the tiles follow a window being dragged.179 screen, and the window width so the tiles follow a window being dragged.
180 Without either it would lay itself out once, on the first frame, and keep180 Without either it would lay itself out once, on the first frame, and keep
181 that shape for the rest of the call."181 that shape for the rest of the call."
182 []182 []
183- (let [[width rows] (av/tile-rows)]183+ (let [[width rows] (actions/tile-rows)]
184 [:vbox {:key :call-wall :spacing 6}184 [:vbox {:key :call-wall :spacing 6}
185 ;; A seq, not a vector: children splice, and a vector would be read as one185 ;; A seq, not a vector: children splice, and a vector would be read as one
186 ;; more hiccup element which an empty one is not.186 ;; more hiccup element which an empty one is not.
@@ -220,7 +220,7 @@
220 ;; We are in a call, but in a different room. Say which, since the220 ;; We are in a call, but in a different room. Say which, since the
221 ;; controls are not on this screen to be found by looking.221 ;; controls are not on this screen to be found by looking.
222 (actions/in-call?)222 (actions/in-call?)
223- [:dim-label {:label (str "In a call in " (:channel @av/local-call))}]223+ [:dim-label {:label (str "In a call in " (:channel (actions/local-call)))}]
224 224
225 :else nil)])225 :else nil)])
226 226
@@ -500,10 +500,10 @@
500 (actions/open-channel! channel))500 (actions/open-channel! channel))
501 (reset! cells/jump-to id)501 (reset! cells/jump-to id)
502 (reset! cells/highlight id)502 (reset! cells/highlight id)
503- (platform/after! settle (fn [] (reset! cells/jump-to nil)))503+ (actions/after! settle (fn [] (reset! cells/jump-to nil)))
504 ;; The highlight only clears itself: a later jump elsewhere owns the504 ;; The highlight only clears itself: a later jump elsewhere owns the
505 ;; highlight from then on.505 ;; highlight from then on.
506- (platform/after! linger (fn [] (when (= id @cells/highlight)506+ (actions/after! linger (fn [] (when (= id @cells/highlight)
507 (reset! cells/highlight nil)))))507 (reset! cells/highlight nil)))))
508 508
509 (defn- summarise509 (defn- summarise
@@ -563,7 +563,7 @@
563 is still one plain label the row is only paid for where it is needed."563 is still one plain label the row is only paid for where it is needed."
564 [j [kind value] system?]564 [j [kind value] system?]
565 (if (= :link kind)565 (if (= :link kind)
566- [:link {:key j :label value :on-click #(platform/open-url! value)}]566+ [:link {:key j :label value :on-click #(actions/open-url! value)}]
567 (let [pieces (glyphs/runs (str/trim value))]567 (let [pieces (glyphs/runs (str/trim value))]
568 (if (glyphs/emoji? pieces)568 (if (glyphs/emoji? pieces)
569 ;; Runs alternate text and picture, so this gap only ever falls either569 ;; Runs alternate text and picture, so this gap only ever falls either
@@ -655,7 +655,7 @@
655 ;; terminal can draw a picture there is a face after all, hung beside655 ;; terminal can draw a picture there is a face after all, hung beside
656 ;; the whole message rather than off its heading `message-row` has it.656 ;; the whole message rather than off its heading `message-row` has it.
657 (when-not @terminal?657 (when-not @terminal?
658- (let [src @(actions/avatar-path (:actor m))]658+ (let [src (actions/avatar-path (:actor m))]
659 ;; One profile, two gestures, and no card hung under the face: the659 ;; One profile, two gestures, and no card hung under the face: the
660 ;; pointer opens the dialog and the press pins it. What makes that660 ;; pointer opens the dialog and the press pins it. What makes that
661 ;; work is the dialog being non-modal while the pointer is what is661 ;; work is the dialog being non-modal while the pointer is what is
@@ -663,10 +663,10 @@
663 [:avatar (cond-> {:label (:from m)663 [:avatar (cond-> {:label (:from m)
664 :src (or src "")664 :src (or src "")
665 :size face-size665 :size face-size
666- :on-click #(profile/open! (:from m) (:actor m))}666+ :on-click #(actions/profile-open! (:from m) (:actor m))}
667 (actions/desktop?)667 (actions/desktop?)
668- (assoc :on-hover #(profile/hover! (:from m) (:actor m))668+ (assoc :on-hover #(actions/profile-hover! (:from m) (:actor m))
669- :on-unhover #(profile/unhover! (:from m))))]))669+ :on-unhover #(actions/profile-unhover! (:from m))))]))
670 ;; The name carries the row, so it is set at body size in the plain670 ;; The name carries the row, so it is set at body size in the plain
671 ;; text colour: dimmed caption made the one thing you scan a column671 ;; text colour: dimmed caption made the one thing you scan a column
672 ;; for the faintest thing on it.672 ;; for the faintest thing on it.
@@ -725,7 +725,7 @@
725 ;; wakes this row for its own pictures landing and not for everyone's.725 ;; wakes this row for its own pictures landing and not for everyone's.
726 (doall726 (doall
727 (for [url (:images m)]727 (for [url (:images m)]
728- (when-let [path @(actions/image-path url)]728+ (when-let [path (actions/image-path url)]
729 [:image {:key url729 [:image {:key url
730 :src path730 :src path
731 :max-height (preview-height)731 :max-height (preview-height)
@@ -791,7 +791,7 @@
791 (if (terminal-face?)791 (if (terminal-face?)
792 [:hbox {:key :faced :spacing 8}792 [:hbox {:key :faced :spacing 8}
793 [:vbox {:key :face :width-request face-size}793 [:vbox {:key :face :width-request face-size}
794- (when-let [path @(actions/avatar-path (:actor m))]794+ (when-let [path (actions/avatar-path (:actor m))]
795 [:image {:key :picture795 [:image {:key :picture
796 :src path796 :src path
797 :max-width face-size797 :max-width face-size
modified flutter/README.md +21 -1
@@ -135,7 +135,27 @@ screen rather than the subtree that read it. Fine at this size.
135135 reads is `frq.cells` and what it calls is `frq.actions`, and each platform
136136 fills those in: `frq.state`'s reducers on the desktop, dart:io here.
137137
138-## Two screens shared, and where the renderer stops
138+## Three screens shared, all drawn on both
139+
140+`frq.screens.connect`, `frq.screens.chats` and `frq.screens.chat` are in
141+`common/`, with the cells under them in `frq.cells`, the derivations in
142+`frq.rooms`, the backend metrics in `frq.metrics` and everything a screen
143+cannot do itself behind `frq.actions`. `frq.app` is 621 lines and was 1,744.
144+
145+What `Length::Fill` means took four goes to get right, and the rule it ended
146+at is worth stating once: a child that fills is Flutter's `Expanded`, the
147+question is recursive — a plain `:vbox` holding a `:scroll` fills too — a Row
148+holding a filling column must `stretch` and be given a height, and a pane that
149+fills a column takes the row's width as well, or it is as wide as its longest
150+line. Prose in a row is `Flexible` rather than `Expanded`, because Expanded
151+hands out equal shares and a button label then wraps down the middle of a
152+word.
153+
154+And `:width-request 0` means no request. Every number is truthy in Clojure, so
155+taking it at face value gave the message list a `SizedBox` of zero width and
156+an empty screen.
157+
158+## The older note, kept because the lesson is general
139159
140160 `frq.screens.connect` and `frq.screens.chats` are in `common/` now, with the
141161 cells under them in `frq.cells`, the derivations in `frq.rooms`, the backend
@@ -135,7 +135,27 @@ screen rather than the subtree that read it. Fine at this size.
135 reads is `frq.cells` and what it calls is `frq.actions`, and each platform135 reads is `frq.cells` and what it calls is `frq.actions`, and each platform
136 fills those in: `frq.state`'s reducers on the desktop, dart:io here.136 fills those in: `frq.state`'s reducers on the desktop, dart:io here.
137 137
138-## Two screens shared, and where the renderer stops138+## Three screens shared, all drawn on both
139+
140+`frq.screens.connect`, `frq.screens.chats` and `frq.screens.chat` are in
141+`common/`, with the cells under them in `frq.cells`, the derivations in
142+`frq.rooms`, the backend metrics in `frq.metrics` and everything a screen
143+cannot do itself behind `frq.actions`. `frq.app` is 621 lines and was 1,744.
144+
145+What `Length::Fill` means took four goes to get right, and the rule it ended
146+at is worth stating once: a child that fills is Flutter's `Expanded`, the
147+question is recursive — a plain `:vbox` holding a `:scroll` fills too — a Row
148+holding a filling column must `stretch` and be given a height, and a pane that
149+fills a column takes the row's width as well, or it is as wide as its longest
150+line. Prose in a row is `Flexible` rather than `Expanded`, because Expanded
151+hands out equal shares and a button label then wraps down the middle of a
152+word.
153+
154+And `:width-request 0` means no request. Every number is truthy in Clojure, so
155+taking it at face value gave the message list a `SizedBox` of zero width and
156+an empty screen.
157+
158+## The older note, kept because the lesson is general
139 159
140 `frq.screens.connect` and `frq.screens.chats` are in `common/` now, with the160 `frq.screens.connect` and `frq.screens.chats` are in `common/` now, with the
141 cells under them in `frq.cells`, the derivations in `frq.rooms`, the backend161 cells under them in `frq.cells`, the derivations in `frq.rooms`, the backend
modified flutter/src/frq/hiccup.cljd +131 -24
@@ -31,6 +31,17 @@
3131 [frq.theme :as t]))
3232
3333
34+(defn- width-of
35+ "A `:width-request`, or nil when there is none to honour.
36+
37+ Zero means no request, not a width of nothing the chat screen writes
38+ `(if show-users? (messages-width) 0)` and every number is truthy in Clojure,
39+ so taking it at face value gave the message list a SizedBox of zero and an
40+ empty screen."
41+ [p]
42+ (let [w (:width-request p)]
43+ (when (and (number? w) (pos? w)) (double w))))
44+
3445 (defn- dbl [x default]
3546 (cond (number? x) (double x)
3647 :else default))
@@ -68,7 +79,7 @@
6879 (defn- body [node]
6980 (let [p (second node)] (if (map? p) (drop 2 node) (drop 1 node))))
7081
71-(declare render)
82+(declare render fills-row?)
7283
7384 (defn- fills-row?
7485 "Whether a node takes the width its row has left over.
@@ -80,18 +91,75 @@
8091 (and (vector? node)
8192 (= :entry (first node))
8293 (let [p (second node)]
83- (or (not (map? p)) (nil? (:width-request p)) (:hexpand p)))))
94+ (or (not (map? p)) (nil? (width-of p)) (:hexpand p)))))
95+
96+(declare body)
97+
98+(defn- prose?
99+ "Whether a subtree is prose — text that should wrap — rather than controls.
100+
101+ The distinction matters because of what Flexible does: it hands every such
102+ child an equal share of the row, and a child whose natural size is larger
103+ then shrinks into it. That is right for a message body, which wraps to fit,
104+ and wrong for a button, whose label then wraps down the middle of the word.
105+ A header of four buttons came out reading Ch/at/s."
106+ [node]
107+ (cond
108+ (seq? node) (boolean (some prose? node))
109+ (not (vector? node)) false
110+ :else
111+ (let [tag (first node)]
112+ (cond
113+ (contains? #{:button :entry :checkbutton :image :avatar :emoji :reaction} tag) false
114+ (contains? #{:label :dim-label :text :title :title-2 :link} tag) true
115+ (contains? #{:vbox :hbox :card} tag)
116+ (let [kids (let [p (second node)] (if (map? p) (drop 2 node) (drop 1 node)))]
117+ (and (some prose? kids)
118+ (not (some #(and (vector? %)
119+ (contains? #{:button :entry :checkbutton} (first %)))
120+ kids))))
121+ :else false))))
122+
123+(defn- flexes-in-row?
124+ "Whether a node should be given the width a row has left, loosely.
125+
126+ iced wraps a line of text at whatever width is available; Flutter's Text in
127+ an unbounded Row does not wrap at all, and the longest message then decides
128+ the width of the conversation and overflows off the right. So prose in a row
129+ is Flexible it takes what is there and wraps inside it, rather than
130+ insisting on it the way Expanded does."
131+ [node]
132+ (prose? node))
84133
85134 (defn- fills-column?
86135 "Whether a node takes the height its column has left over.
87136
88137 `:fill-height` is Length::Fill down the other axis Expanded, not a taller
89- mainAxisSize. `:scroll` is excluded: it returns an Expanded of its own."
138+ mainAxisSize. A `:scroll` fills by definition; it is what the space is left
139+ over *for*.
140+
141+ And it is recursive, which is the part that is easy to miss: a plain `:vbox`
142+ holding a `:scroll` fills too, because the scroll inside it needs a height
143+ and a column sized to its contents hands its children an unbounded one. The
144+ chat screen is three such wrappers deep, and each one has to pass the
145+ question up or the innermost Expanded lands in unbounded space
146+ \"RenderFlex children have non-zero flex but incoming height constraints are
147+ unbounded\", which paints nothing."
90148 [node]
91149 (and (vector? node)
92- (not= :scroll (first node))
93- (let [p (second node)]
94- (and (map? p) (:fill-height p)))))
150+ (let [tag (first node)
151+ p (second node)]
152+ (or (= :scroll tag)
153+ (and (map? p) (:fill-height p))
154+ ;; Containers pass it up; `:page` does not, because a page
155+ ;; scrolls and nothing inside a scroll can take what is left.
156+ ;;
157+ ;; `:hbox` is in the list for the chat screen: its message band is
158+ ;; a fill-height column sitting in a row beside the people panel,
159+ ;; so the row must be given a height before the column can take
160+ ;; what is left of it.
161+ (and (contains? #{:vbox :card :hbox} tag)
162+ (boolean (some fills-column? (body node))))))))
95163
96164 (defn- children
97165 "Flatten seqs, drop nils. `(for [...] ...)` in a component yields a seq in
@@ -116,6 +184,15 @@
116184 (cond (nil? n) acc
117185 (seq? n) (reduce conj! acc (flexed fills? n))
118186 (fills? n) (conj! acc (m/Expanded .child (render n)))
187+ ;; A pane that fills the column takes the row's width too:
188+ ;; the message list sits in a row beside the people panel,
189+ ;; and left to its natural width it is as wide as its
190+ ;; longest line which is one URL and a screen and a half
191+ ;; of overflow.
192+ (and (identical? fills? fills-row?) (fills-column? n))
193+ (conj! acc (m/Expanded .child (render n)))
194+ (and (identical? fills? fills-row?) (flexes-in-row? n))
195+ (conj! acc (m/Flexible .child (render n)))
119196 :else (conj! acc (render n))))
120197 (transient []) nodes)))
121198
@@ -174,10 +251,18 @@
174251 :context ctx
175252 (let [p (props node)
176253 kids (children (body node))
177- col (fn [sp cs] (m/Column .crossAxisAlignment m/CrossAxisAlignment.start
178- .mainAxisSize m/MainAxisSize.min
179- .spacing sp
180- .children cs))]
254+ ;; One column builder for every container, so the rule about what
255+ ;; fills is applied in one place: children that take what is left are
256+ ;; wrapped, and a column holding one asks for the whole height rather
257+ ;; than for its contents. Doing this only in `:vbox` left a `:card`
258+ ;; and a `:scroll` handing their children unbounded space.
259+ col (fn [sp nodes]
260+ (m/Column .crossAxisAlignment m/CrossAxisAlignment.start
261+ .mainAxisSize (if (some fills-column? nodes)
262+ m/MainAxisSize.max
263+ m/MainAxisSize.min)
264+ .spacing sp
265+ .children (flexed fills-column? nodes)))]
181266 (case tag
182267 :vbox
183268 ;; `:fill-height` is what pins a tab bar to the bottom: the band above
@@ -185,7 +270,10 @@
185270 ;; contents has no rest to give.
186271 (let [c (m/Column
187272 .crossAxisAlignment m/CrossAxisAlignment.start
188- .mainAxisSize (if (:fill-height p)
273+ ;; Max when something inside wants the space left over: a
274+ ;; column sized to its contents has none to give.
275+ .mainAxisSize (if (or (:fill-height p)
276+ (some fills-column? (body node)))
189277 m/MainAxisSize.max
190278 m/MainAxisSize.min)
191279 .spacing (dbl (:spacing p) 0.0)
@@ -193,8 +281,8 @@
193281 c (if-let [mg (:margin p)]
194282 (m/Padding .padding (m/EdgeInsets.all (dbl mg 0.0)) .child c)
195283 c)]
196- (if-let [w (:width-request p)]
197- (m/SizedBox .width (dbl w 0.0) .child c)
284+ (if-let [w (width-of p)]
285+ (m/SizedBox .width w .child c)
198286 c))
199287
200288 ;; `page` is jolt-cosmic's scrollable container, centred and capped.
@@ -214,18 +302,34 @@
214302 .constraints (m/BoxConstraints .maxWidth (dbl (:max-width p) 520.0))
215303 .child (m/Padding
216304 .padding (m/EdgeInsets.all t/space-s)
217- .child (col t/space-xs kids)))))
305+ .child (col t/space-xs (body node))))))
218306
307+ ;; `:wrap true` is a row that runs onto the next line how a message
308+ ;; body lays its words, its links and its emoji out, since a line of
309+ ;; text is a row of runs here rather than one string. Flutter calls it
310+ ;; Wrap. Without it the longest message decided the width of the
311+ ;; conversation and the rest overflowed off the right.
219312 :hbox
220- (m/Row
221- .crossAxisAlignment m/CrossAxisAlignment.center
313+ (if (true? (:wrap p))
314+ (m/Wrap
315+ .spacing (dbl (:spacing p) 0.0)
316+ .runSpacing (dbl (:spacing p) 0.0)
317+ .crossAxisAlignment m/WrapCrossAlignment.center
318+ .children kids)
319+ (m/Row
320+ ;; Stretch when a child fills vertically: a Row hands its children a
321+ ;; loose height by default, and a column that wants what is left of it
322+ ;; needs a tight one.
323+ .crossAxisAlignment (if (some fills-column? (body node))
324+ m/CrossAxisAlignment.stretch
325+ m/CrossAxisAlignment.center)
222326 ;; A row holding something that fills has to be given the width to
223327 ;; divide, so it is max rather than min whenever a child asks.
224328 .mainAxisSize (if (some fills-row? (body node))
225329 m/MainAxisSize.max
226330 m/MainAxisSize.min)
227- .spacing (dbl (:spacing p) 0.0)
228- .children (flexed fills-row? (body node)))
331+ .spacing (dbl (:spacing p) 0.0)
332+ .children (flexed fills-row? (body node))))
229333
230334 :label (txt ctx (:label p "") t/text-body t/on-bg)
231335 :dim-label (txt ctx (:label p "") t/text-caption t/dim)
@@ -251,7 +355,7 @@
251355 .decoration (m/BoxDecoration
252356 .color t/card
253357 .borderRadius (m/BorderRadius.circular t/radius-s))
254- .child (col (dbl (:spacing p) t/space-xxs) kids))
358+ .child (col (dbl (:spacing p) t/space-xxs) (body node)))
255359
256360 :separator (m/Divider .height 1.0 .thickness 1.0 .color t/divider)
257361
@@ -311,7 +415,7 @@
311415 (let [on-change (:on-change p)
312416 on-activate (:on-activate p)
313417 rows (:rows p)
314- w (:width-request p)
418+ w (width-of p)
315419 field (m/TextField
316420 .controller (controller-for (:key p) (:text p))
317421 .onChanged (when on-change #(on-change %))
@@ -345,7 +449,7 @@
345449 ;; Column hands its children bounded width and a row wants the
346450 ;; width-request the caller already writes.
347451 (if w
348- (m/SizedBox .width (dbl w 200.0) .child field)
452+ (m/SizedBox .width w .child field)
349453 field))
350454
351455 :emoji
@@ -386,10 +490,13 @@
386490 (m/InkWell .onTap #(on) .child img)
387491 img))
388492
493+ ;; No Expanded of its own: `fills-column?` says a scroll takes what is
494+ ;; left, so its parent column wraps it and wrapping here as well put
495+ ;; two ParentDataWidgets on one RenderObject, which Flutter calls
496+ ;; "competing" and then draws nothing.
389497 :scroll
390- (m/Expanded
391- .child (m/SingleChildScrollView
392- .child (col (dbl (:spacing p) 0.0) kids)))
498+ (m/SingleChildScrollView
499+ .child (col (dbl (:spacing p) 0.0) (body node)))
393500
394501 ;; A tag this backend has not grown yet still shows its children which
395502 ;; is what libvidya did and what jolt-cosmic kept. The marker is here so
@@ -31,6 +31,17 @@
31 [frq.theme :as t]))31 [frq.theme :as t]))
32 32
33 33
34+(defn- width-of
35+ "A `:width-request`, or nil when there is none to honour.
36+
37+ Zero means no request, not a width of nothing the chat screen writes
38+ `(if show-users? (messages-width) 0)` and every number is truthy in Clojure,
39+ so taking it at face value gave the message list a SizedBox of zero and an
40+ empty screen."
41+ [p]
42+ (let [w (:width-request p)]
43+ (when (and (number? w) (pos? w)) (double w))))
44+
34 (defn- dbl [x default]45 (defn- dbl [x default]
35 (cond (number? x) (double x)46 (cond (number? x) (double x)
36 :else default))47 :else default))
@@ -68,7 +79,7 @@
68 (defn- body [node]79 (defn- body [node]
69 (let [p (second node)] (if (map? p) (drop 2 node) (drop 1 node))))80 (let [p (second node)] (if (map? p) (drop 2 node) (drop 1 node))))
70 81
71-(declare render)82+(declare render fills-row?)
72 83
73 (defn- fills-row?84 (defn- fills-row?
74 "Whether a node takes the width its row has left over.85 "Whether a node takes the width its row has left over.
@@ -80,18 +91,75 @@
80 (and (vector? node)91 (and (vector? node)
81 (= :entry (first node))92 (= :entry (first node))
82 (let [p (second node)]93 (let [p (second node)]
83- (or (not (map? p)) (nil? (:width-request p)) (:hexpand p)))))94+ (or (not (map? p)) (nil? (width-of p)) (:hexpand p)))))
95+
96+(declare body)
97+
98+(defn- prose?
99+ "Whether a subtree is prose — text that should wrap — rather than controls.
100+
101+ The distinction matters because of what Flexible does: it hands every such
102+ child an equal share of the row, and a child whose natural size is larger
103+ then shrinks into it. That is right for a message body, which wraps to fit,
104+ and wrong for a button, whose label then wraps down the middle of the word.
105+ A header of four buttons came out reading Ch/at/s."
106+ [node]
107+ (cond
108+ (seq? node) (boolean (some prose? node))
109+ (not (vector? node)) false
110+ :else
111+ (let [tag (first node)]
112+ (cond
113+ (contains? #{:button :entry :checkbutton :image :avatar :emoji :reaction} tag) false
114+ (contains? #{:label :dim-label :text :title :title-2 :link} tag) true
115+ (contains? #{:vbox :hbox :card} tag)
116+ (let [kids (let [p (second node)] (if (map? p) (drop 2 node) (drop 1 node)))]
117+ (and (some prose? kids)
118+ (not (some #(and (vector? %)
119+ (contains? #{:button :entry :checkbutton} (first %)))
120+ kids))))
121+ :else false))))
122+
123+(defn- flexes-in-row?
124+ "Whether a node should be given the width a row has left, loosely.
125+
126+ iced wraps a line of text at whatever width is available; Flutter's Text in
127+ an unbounded Row does not wrap at all, and the longest message then decides
128+ the width of the conversation and overflows off the right. So prose in a row
129+ is Flexible it takes what is there and wraps inside it, rather than
130+ insisting on it the way Expanded does."
131+ [node]
132+ (prose? node))
84 133
85 (defn- fills-column?134 (defn- fills-column?
86 "Whether a node takes the height its column has left over.135 "Whether a node takes the height its column has left over.
87 136
88 `:fill-height` is Length::Fill down the other axis Expanded, not a taller137 `:fill-height` is Length::Fill down the other axis Expanded, not a taller
89- mainAxisSize. `:scroll` is excluded: it returns an Expanded of its own."138+ mainAxisSize. A `:scroll` fills by definition; it is what the space is left
139+ over *for*.
140+
141+ And it is recursive, which is the part that is easy to miss: a plain `:vbox`
142+ holding a `:scroll` fills too, because the scroll inside it needs a height
143+ and a column sized to its contents hands its children an unbounded one. The
144+ chat screen is three such wrappers deep, and each one has to pass the
145+ question up or the innermost Expanded lands in unbounded space
146+ \"RenderFlex children have non-zero flex but incoming height constraints are
147+ unbounded\", which paints nothing."
90 [node]148 [node]
91 (and (vector? node)149 (and (vector? node)
92- (not= :scroll (first node))150+ (let [tag (first node)
93- (let [p (second node)]151+ p (second node)]
94- (and (map? p) (:fill-height p)))))152+ (or (= :scroll tag)
153+ (and (map? p) (:fill-height p))
154+ ;; Containers pass it up; `:page` does not, because a page
155+ ;; scrolls and nothing inside a scroll can take what is left.
156+ ;;
157+ ;; `:hbox` is in the list for the chat screen: its message band is
158+ ;; a fill-height column sitting in a row beside the people panel,
159+ ;; so the row must be given a height before the column can take
160+ ;; what is left of it.
161+ (and (contains? #{:vbox :card :hbox} tag)
162+ (boolean (some fills-column? (body node))))))))
95 163
96 (defn- children164 (defn- children
97 "Flatten seqs, drop nils. `(for [...] ...)` in a component yields a seq in165 "Flatten seqs, drop nils. `(for [...] ...)` in a component yields a seq in
@@ -116,6 +184,15 @@
116 (cond (nil? n) acc184 (cond (nil? n) acc
117 (seq? n) (reduce conj! acc (flexed fills? n))185 (seq? n) (reduce conj! acc (flexed fills? n))
118 (fills? n) (conj! acc (m/Expanded .child (render n)))186 (fills? n) (conj! acc (m/Expanded .child (render n)))
187+ ;; A pane that fills the column takes the row's width too:
188+ ;; the message list sits in a row beside the people panel,
189+ ;; and left to its natural width it is as wide as its
190+ ;; longest line which is one URL and a screen and a half
191+ ;; of overflow.
192+ (and (identical? fills? fills-row?) (fills-column? n))
193+ (conj! acc (m/Expanded .child (render n)))
194+ (and (identical? fills? fills-row?) (flexes-in-row? n))
195+ (conj! acc (m/Flexible .child (render n)))
119 :else (conj! acc (render n))))196 :else (conj! acc (render n))))
120 (transient []) nodes)))197 (transient []) nodes)))
121 198
@@ -174,10 +251,18 @@
174 :context ctx251 :context ctx
175 (let [p (props node)252 (let [p (props node)
176 kids (children (body node))253 kids (children (body node))
177- col (fn [sp cs] (m/Column .crossAxisAlignment m/CrossAxisAlignment.start254+ ;; One column builder for every container, so the rule about what
178- .mainAxisSize m/MainAxisSize.min255+ ;; fills is applied in one place: children that take what is left are
179- .spacing sp256+ ;; wrapped, and a column holding one asks for the whole height rather
180- .children cs))]257+ ;; than for its contents. Doing this only in `:vbox` left a `:card`
258+ ;; and a `:scroll` handing their children unbounded space.
259+ col (fn [sp nodes]
260+ (m/Column .crossAxisAlignment m/CrossAxisAlignment.start
261+ .mainAxisSize (if (some fills-column? nodes)
262+ m/MainAxisSize.max
263+ m/MainAxisSize.min)
264+ .spacing sp
265+ .children (flexed fills-column? nodes)))]
181 (case tag266 (case tag
182 :vbox267 :vbox
183 ;; `:fill-height` is what pins a tab bar to the bottom: the band above268 ;; `:fill-height` is what pins a tab bar to the bottom: the band above
@@ -185,7 +270,10 @@
185 ;; contents has no rest to give.270 ;; contents has no rest to give.
186 (let [c (m/Column271 (let [c (m/Column
187 .crossAxisAlignment m/CrossAxisAlignment.start272 .crossAxisAlignment m/CrossAxisAlignment.start
188- .mainAxisSize (if (:fill-height p)273+ ;; Max when something inside wants the space left over: a
274+ ;; column sized to its contents has none to give.
275+ .mainAxisSize (if (or (:fill-height p)
276+ (some fills-column? (body node)))
189 m/MainAxisSize.max277 m/MainAxisSize.max
190 m/MainAxisSize.min)278 m/MainAxisSize.min)
191 .spacing (dbl (:spacing p) 0.0)279 .spacing (dbl (:spacing p) 0.0)
@@ -193,8 +281,8 @@
193 c (if-let [mg (:margin p)]281 c (if-let [mg (:margin p)]
194 (m/Padding .padding (m/EdgeInsets.all (dbl mg 0.0)) .child c)282 (m/Padding .padding (m/EdgeInsets.all (dbl mg 0.0)) .child c)
195 c)]283 c)]
196- (if-let [w (:width-request p)]284+ (if-let [w (width-of p)]
197- (m/SizedBox .width (dbl w 0.0) .child c)285+ (m/SizedBox .width w .child c)
198 c))286 c))
199 287
200 ;; `page` is jolt-cosmic's scrollable container, centred and capped.288 ;; `page` is jolt-cosmic's scrollable container, centred and capped.
@@ -214,18 +302,34 @@
214 .constraints (m/BoxConstraints .maxWidth (dbl (:max-width p) 520.0))302 .constraints (m/BoxConstraints .maxWidth (dbl (:max-width p) 520.0))
215 .child (m/Padding303 .child (m/Padding
216 .padding (m/EdgeInsets.all t/space-s)304 .padding (m/EdgeInsets.all t/space-s)
217- .child (col t/space-xs kids)))))305+ .child (col t/space-xs (body node))))))
218 306
307+ ;; `:wrap true` is a row that runs onto the next line how a message
308+ ;; body lays its words, its links and its emoji out, since a line of
309+ ;; text is a row of runs here rather than one string. Flutter calls it
310+ ;; Wrap. Without it the longest message decided the width of the
311+ ;; conversation and the rest overflowed off the right.
219 :hbox312 :hbox
220- (m/Row313+ (if (true? (:wrap p))
221- .crossAxisAlignment m/CrossAxisAlignment.center314+ (m/Wrap
315+ .spacing (dbl (:spacing p) 0.0)
316+ .runSpacing (dbl (:spacing p) 0.0)
317+ .crossAxisAlignment m/WrapCrossAlignment.center
318+ .children kids)
319+ (m/Row
320+ ;; Stretch when a child fills vertically: a Row hands its children a
321+ ;; loose height by default, and a column that wants what is left of it
322+ ;; needs a tight one.
323+ .crossAxisAlignment (if (some fills-column? (body node))
324+ m/CrossAxisAlignment.stretch
325+ m/CrossAxisAlignment.center)
222 ;; A row holding something that fills has to be given the width to326 ;; A row holding something that fills has to be given the width to
223 ;; divide, so it is max rather than min whenever a child asks.327 ;; divide, so it is max rather than min whenever a child asks.
224 .mainAxisSize (if (some fills-row? (body node))328 .mainAxisSize (if (some fills-row? (body node))
225 m/MainAxisSize.max329 m/MainAxisSize.max
226 m/MainAxisSize.min)330 m/MainAxisSize.min)
227- .spacing (dbl (:spacing p) 0.0)331+ .spacing (dbl (:spacing p) 0.0)
228- .children (flexed fills-row? (body node)))332+ .children (flexed fills-row? (body node))))
229 333
230 :label (txt ctx (:label p "") t/text-body t/on-bg)334 :label (txt ctx (:label p "") t/text-body t/on-bg)
231 :dim-label (txt ctx (:label p "") t/text-caption t/dim)335 :dim-label (txt ctx (:label p "") t/text-caption t/dim)
@@ -251,7 +355,7 @@
251 .decoration (m/BoxDecoration355 .decoration (m/BoxDecoration
252 .color t/card356 .color t/card
253 .borderRadius (m/BorderRadius.circular t/radius-s))357 .borderRadius (m/BorderRadius.circular t/radius-s))
254- .child (col (dbl (:spacing p) t/space-xxs) kids))358+ .child (col (dbl (:spacing p) t/space-xxs) (body node)))
255 359
256 :separator (m/Divider .height 1.0 .thickness 1.0 .color t/divider)360 :separator (m/Divider .height 1.0 .thickness 1.0 .color t/divider)
257 361
@@ -311,7 +415,7 @@
311 (let [on-change (:on-change p)415 (let [on-change (:on-change p)
312 on-activate (:on-activate p)416 on-activate (:on-activate p)
313 rows (:rows p)417 rows (:rows p)
314- w (:width-request p)418+ w (width-of p)
315 field (m/TextField419 field (m/TextField
316 .controller (controller-for (:key p) (:text p))420 .controller (controller-for (:key p) (:text p))
317 .onChanged (when on-change #(on-change %))421 .onChanged (when on-change #(on-change %))
@@ -345,7 +449,7 @@
345 ;; Column hands its children bounded width and a row wants the449 ;; Column hands its children bounded width and a row wants the
346 ;; width-request the caller already writes.450 ;; width-request the caller already writes.
347 (if w451 (if w
348- (m/SizedBox .width (dbl w 200.0) .child field)452+ (m/SizedBox .width w .child field)
349 field))453 field))
350 454
351 :emoji455 :emoji
@@ -386,10 +490,13 @@
386 (m/InkWell .onTap #(on) .child img)490 (m/InkWell .onTap #(on) .child img)
387 img))491 img))
388 492
493+ ;; No Expanded of its own: `fills-column?` says a scroll takes what is
494+ ;; left, so its parent column wraps it and wrapping here as well put
495+ ;; two ParentDataWidgets on one RenderObject, which Flutter calls
496+ ;; "competing" and then draws nothing.
389 :scroll497 :scroll
390- (m/Expanded498+ (m/SingleChildScrollView
391- .child (m/SingleChildScrollView499+ .child (col (dbl (:spacing p) 0.0) (body node)))
392- .child (col (dbl (:spacing p) 0.0) kids)))
393 500
394 ;; A tag this backend has not grown yet still shows its children which501 ;; A tag this backend has not grown yet still shows its children which
395 ;; is what libvidya did and what jolt-cosmic kept. The marker is here so502 ;; is what libvidya did and what jolt-cosmic kept. The marker is here so
modified flutter/src/frq/main.cljd +23 -44
@@ -31,6 +31,7 @@
3131 [frq.cells :as cells]
3232 [frq.screens.connect :as connect]
3333 [frq.screens.chats :as chats]
34+ [frq.screens.chat :as chat]
3435 [frq.rooms :as rooms]
3536 [frq.irc.parse :as irc]))
3637
@@ -226,54 +227,15 @@
226227 [:button {:label "Disconnect" :destructive true :on-click #(disconnect!)}]]
227228 [:dim-label {:label "The chats screen is next: it wants rooms, messages and avatars, none of which are ported yet."}]])
228229
229-(defonce ^:private draft
230- ;; Phone-local for now. `frq.state/draft` is the shared one this becomes
231- ;; when `frq.app`'s chat screen is ported — it carries replies, edits and an
232- ;; attachment with it, and none of that is here.
233- (atom ""))
234-
235230 (defn- send-draft! []
236- (let [text (str @draft)
231+ (let [text (str @cells/draft)
237232 room (str @cells/current)]
238233 (when (and (seq text) (seq room) @conn)
239234 (net/send-line! @conn (str "PRIVMSG " room " :" text))
240235 ;; Echoed locally: the server does not send our own PRIVMSG back.
241236 (swap! cells/channels update room
242237 #(update % :messages conj {:from @cells/form-nick :text text}))
243- (reset! draft ""))))
244-
245-(defn- chat-screen
246- "One conversation, until `frq.app`'s own is ported.
247-
248- That one is 230 lines over about thirty cells — replies, edits, reactions,
249- the emoji picker, avatars, the people panel and a call bar. This is the part
250- the list opens onto: who said what, and a box to answer in."
251- []
252- (let [room (str @cells/current)
253- buffer (get @cells/channels room)]
254- [:vbox {:fill-height true :margin 12 :spacing 8}
255- [:vbox {:key :head :spacing 8}
256- [:hbox {:spacing 8}
257- [:button {:label "← Chats" :on-click #(reset! cells/screen :chats)}]
258- [:title-2 {:label room}]]
259- [:separator {}]]
260- [:vbox {:key :list :fill-height true}
261- [:scroll {:spacing 8}
262- (if (empty? (:messages buffer))
263- [:dim-label {:label "No messages yet"}]
264- (for [[i m] (map-indexed vector (:messages buffer))]
265- [:vbox {:key i :spacing 2}
266- [:dim-label {:label (str (:from m))}]
267- [:label {:label (str (:text m))}]]))]]
268- [:vbox {:key :foot :spacing 8}
269- [:separator {}]
270- [:hbox {:spacing 8}
271- [:button {:label "Send" :kind :primary :on-click #(send-draft!)}]
272- [:entry {:key :draft
273- :text @draft
274- :placeholder "Message"
275- :on-change #(reset! draft %)
276- :on-activate #(send-draft!)}]]]]))
238+ (reset! cells/draft ""))))
277239
278240 (defn ^:async main []
279241 (m/WidgetsFlutterBinding.ensureInitialized)
@@ -300,6 +262,20 @@
300262 :leave-channel! (fn [name]
301263 (when-let [c @conn] (net/send-line! c (str "PART " name)))
302264 (swap! cells/channels dissoc name))
265+ :send-draft! send-draft!
266+ :wide? (fn [] false)
267+ :desktop? (fn [] false)
268+ :mine? (fn [from] (= (str from) (str @cells/form-nick)))
269+ :message-by-id (fn [room id]
270+ (->> (get-in @cells/channels [room :messages])
271+ (filter #(= id (:id %)))
272+ first))
273+ :member-count (fn [_] nil)
274+ :member-list (fn [_] [])
275+ :toggle-users! (fn [] (swap! cells/show-users? not))
276+ :toggle-overview! (fn [] (swap! cells/overview? not))
277+ :toggle-chat-list! (fn [] (swap! cells/hide-chat-list? not))
278+ :jump-to-present! (fn [] (reset! cells/at-present? true))
303279 :open-channel! (fn [name]
304280 (reset! cells/current name)
305281 ;; `frq.state/open-channel!` does this and more: it
@@ -338,10 +314,13 @@
338314 c-apppw cells/form-app-password
339315 c-broker cells/broker-token
340316 c-login cells/login-url
341- c-screen cells/screen]
317+ c-screen cells/screen
318+ c-draft cells/draft
319+ c-channels cells/channels
320+ c-current cells/current]
342321 :watch [st c-status er c-error cn c-connecting am c-mode
343322 fh c-handle nk c-nick hs c-host pt c-port tl c-tls
344- ap c-apppw bt c-broker lu c-login ls lines sc c-screen dr draft ch cells/channels cu cells/current]
323+ ap c-apppw bt c-broker lu c-login ls lines sc c-screen dr c-draft ch c-channels cu c-current]
345324 ;; No scroll view around the screen. A `:page` scrolls itself now, and a
346325 ;; `:vbox :fill-height` wants the bounded height the Scaffold gives it —
347326 ;; wrapping the tree took that away, and `:scroll`'s Expanded then sat
@@ -349,5 +328,5 @@
349328 (h/render (case @cells/screen
350329 :connect [connect/connect-screen]
351330 :chats [chats/chats-screen]
352- :chat [chat-screen]
331+ :chat [chat/chat-screen]
353332 [connected-screen]))))))
@@ -31,6 +31,7 @@
31 [frq.cells :as cells]31 [frq.cells :as cells]
32 [frq.screens.connect :as connect]32 [frq.screens.connect :as connect]
33 [frq.screens.chats :as chats]33 [frq.screens.chats :as chats]
34+ [frq.screens.chat :as chat]
34 [frq.rooms :as rooms]35 [frq.rooms :as rooms]
35 [frq.irc.parse :as irc]))36 [frq.irc.parse :as irc]))
36 37
@@ -226,54 +227,15 @@
226 [:button {:label "Disconnect" :destructive true :on-click #(disconnect!)}]]227 [:button {:label "Disconnect" :destructive true :on-click #(disconnect!)}]]
227 [:dim-label {:label "The chats screen is next: it wants rooms, messages and avatars, none of which are ported yet."}]])228 [:dim-label {:label "The chats screen is next: it wants rooms, messages and avatars, none of which are ported yet."}]])
228 229
229-(defonce ^:private draft
230- ;; Phone-local for now. `frq.state/draft` is the shared one this becomes
231- ;; when `frq.app`'s chat screen is ported — it carries replies, edits and an
232- ;; attachment with it, and none of that is here.
233- (atom ""))
234-
235 (defn- send-draft! []230 (defn- send-draft! []
236- (let [text (str @draft)231+ (let [text (str @cells/draft)
237 room (str @cells/current)]232 room (str @cells/current)]
238 (when (and (seq text) (seq room) @conn)233 (when (and (seq text) (seq room) @conn)
239 (net/send-line! @conn (str "PRIVMSG " room " :" text))234 (net/send-line! @conn (str "PRIVMSG " room " :" text))
240 ;; Echoed locally: the server does not send our own PRIVMSG back.235 ;; Echoed locally: the server does not send our own PRIVMSG back.
241 (swap! cells/channels update room236 (swap! cells/channels update room
242 #(update % :messages conj {:from @cells/form-nick :text text}))237 #(update % :messages conj {:from @cells/form-nick :text text}))
243- (reset! draft ""))))238+ (reset! cells/draft ""))))
244-
245-(defn- chat-screen
246- "One conversation, until `frq.app`'s own is ported.
247-
248- That one is 230 lines over about thirty cells — replies, edits, reactions,
249- the emoji picker, avatars, the people panel and a call bar. This is the part
250- the list opens onto: who said what, and a box to answer in."
251- []
252- (let [room (str @cells/current)
253- buffer (get @cells/channels room)]
254- [:vbox {:fill-height true :margin 12 :spacing 8}
255- [:vbox {:key :head :spacing 8}
256- [:hbox {:spacing 8}
257- [:button {:label "← Chats" :on-click #(reset! cells/screen :chats)}]
258- [:title-2 {:label room}]]
259- [:separator {}]]
260- [:vbox {:key :list :fill-height true}
261- [:scroll {:spacing 8}
262- (if (empty? (:messages buffer))
263- [:dim-label {:label "No messages yet"}]
264- (for [[i m] (map-indexed vector (:messages buffer))]
265- [:vbox {:key i :spacing 2}
266- [:dim-label {:label (str (:from m))}]
267- [:label {:label (str (:text m))}]]))]]
268- [:vbox {:key :foot :spacing 8}
269- [:separator {}]
270- [:hbox {:spacing 8}
271- [:button {:label "Send" :kind :primary :on-click #(send-draft!)}]
272- [:entry {:key :draft
273- :text @draft
274- :placeholder "Message"
275- :on-change #(reset! draft %)
276- :on-activate #(send-draft!)}]]]]))
277 239
278 (defn ^:async main []240 (defn ^:async main []
279 (m/WidgetsFlutterBinding.ensureInitialized)241 (m/WidgetsFlutterBinding.ensureInitialized)
@@ -300,6 +262,20 @@
300 :leave-channel! (fn [name]262 :leave-channel! (fn [name]
301 (when-let [c @conn] (net/send-line! c (str "PART " name)))263 (when-let [c @conn] (net/send-line! c (str "PART " name)))
302 (swap! cells/channels dissoc name))264 (swap! cells/channels dissoc name))
265+ :send-draft! send-draft!
266+ :wide? (fn [] false)
267+ :desktop? (fn [] false)
268+ :mine? (fn [from] (= (str from) (str @cells/form-nick)))
269+ :message-by-id (fn [room id]
270+ (->> (get-in @cells/channels [room :messages])
271+ (filter #(= id (:id %)))
272+ first))
273+ :member-count (fn [_] nil)
274+ :member-list (fn [_] [])
275+ :toggle-users! (fn [] (swap! cells/show-users? not))
276+ :toggle-overview! (fn [] (swap! cells/overview? not))
277+ :toggle-chat-list! (fn [] (swap! cells/hide-chat-list? not))
278+ :jump-to-present! (fn [] (reset! cells/at-present? true))
303 :open-channel! (fn [name]279 :open-channel! (fn [name]
304 (reset! cells/current name)280 (reset! cells/current name)
305 ;; `frq.state/open-channel!` does this and more: it281 ;; `frq.state/open-channel!` does this and more: it
@@ -338,10 +314,13 @@
338 c-apppw cells/form-app-password314 c-apppw cells/form-app-password
339 c-broker cells/broker-token315 c-broker cells/broker-token
340 c-login cells/login-url316 c-login cells/login-url
341- c-screen cells/screen]317+ c-screen cells/screen
318+ c-draft cells/draft
319+ c-channels cells/channels
320+ c-current cells/current]
342 :watch [st c-status er c-error cn c-connecting am c-mode321 :watch [st c-status er c-error cn c-connecting am c-mode
343 fh c-handle nk c-nick hs c-host pt c-port tl c-tls322 fh c-handle nk c-nick hs c-host pt c-port tl c-tls
344- ap c-apppw bt c-broker lu c-login ls lines sc c-screen dr draft ch cells/channels cu cells/current]323+ ap c-apppw bt c-broker lu c-login ls lines sc c-screen dr c-draft ch c-channels cu c-current]
345 ;; No scroll view around the screen. A `:page` scrolls itself now, and a324 ;; No scroll view around the screen. A `:page` scrolls itself now, and a
346 ;; `:vbox :fill-height` wants the bounded height the Scaffold gives it —325 ;; `:vbox :fill-height` wants the bounded height the Scaffold gives it —
347 ;; wrapping the tree took that away, and `:scroll`'s Expanded then sat326 ;; wrapping the tree took that away, and `:scroll`'s Expanded then sat
@@ -349,5 +328,5 @@
349 (h/render (case @cells/screen328 (h/render (case @cells/screen
350 :connect [connect/connect-screen]329 :connect [connect/connect-screen]
351 :chats [chats/chats-screen]330 :chats [chats/chats-screen]
352- :chat [chat-screen]331+ :chat [chat/chat-screen]
353 [connected-screen]))))))332 [connected-screen]))))))
modified src/frq/app.clj +13 -2
@@ -617,5 +617,16 @@
617617 ;; neither the reaction nor the cache. It installs nothing and the screens
618618 ;; draw what they draw before a face arrives.
619619 (actions/install!
620- {:avatar-path avatar-path
621- :image-path image-path})
620+ ;; The value rather than the cell: a platform without a cache answers nil,
621+ ;; and `@nil` is not a thing. Derefing the reaction here keeps the desktop's
622+ ;; per-row waking, because the deref still happens inside the render.
623+ {:avatar-path (fn [actor] @(avatar-path actor))
624+ :image-path (fn [url] @(image-path url))
625+ ;; The profile half lives here rather than in frq.state, which does not
626+ ;; require frq.profile — who is hovered and whose card is open is a question
627+ ;; about the screen, not about the connection.
628+ :hovering (fn [] @profile/hovering)
629+ :viewing (fn [] @profile/viewing)
630+ :profile-hover! profile/hover!
631+ :profile-unhover! profile/unhover!
632+ :profile-open! profile/open!})
@@ -617,5 +617,16 @@
617 ;; neither the reaction nor the cache. It installs nothing and the screens617 ;; neither the reaction nor the cache. It installs nothing and the screens
618 ;; draw what they draw before a face arrives.618 ;; draw what they draw before a face arrives.
619 (actions/install!619 (actions/install!
620- {:avatar-path avatar-path620+ ;; The value rather than the cell: a platform without a cache answers nil,
621- :image-path image-path})621+ ;; and `@nil` is not a thing. Derefing the reaction here keeps the desktop's
622+ ;; per-row waking, because the deref still happens inside the render.
623+ {:avatar-path (fn [actor] @(avatar-path actor))
624+ :image-path (fn [url] @(image-path url))
625+ ;; The profile half lives here rather than in frq.state, which does not
626+ ;; require frq.profile — who is hovered and whose card is open is a question
627+ ;; about the screen, not about the connection.
628+ :hovering (fn [] @profile/hovering)
629+ :viewing (fn [] @profile/viewing)
630+ :profile-hover! profile/hover!
631+ :profile-unhover! profile/unhover!
632+ :profile-open! profile/open!})
modified src/frq/state.clj +10 -2
@@ -1980,10 +1980,18 @@
19801980 :call-available? av/available?
19811981 :desktop? platform/desktop?
19821982 :quit! platform/quit!
1983- :hovering (fn [] @profile/hovering)
1984- :viewing (fn [] @profile/viewing)
19851983 :avatar-path nil
19861984 :image-path nil
1985+ :local-call (fn [] @av/local-call)
1986+ :local-feed (fn [] av/local-feed)
1987+ :media-error (fn [] @av/media-error)
1988+ :tiles av/tiles
1989+ :tile-rows av/tile-rows
1990+ :set-muted! av/set-muted!
1991+ :set-speaker-muted! av/set-speaker-muted!
1992+ :set-camera! av/set-camera!
1993+ :after! platform/after!
1994+ :open-url! platform/open-url!
19871995 :accept-policy! accept-policy!
19881996 :close-picker! close-picker!
19891997 :hover-reaction! hover-reaction!
@@ -1980,10 +1980,18 @@
1980 :call-available? av/available?1980 :call-available? av/available?
1981 :desktop? platform/desktop?1981 :desktop? platform/desktop?
1982 :quit! platform/quit!1982 :quit! platform/quit!
1983- :hovering (fn [] @profile/hovering)
1984- :viewing (fn [] @profile/viewing)
1985 :avatar-path nil1983 :avatar-path nil
1986 :image-path nil1984 :image-path nil
1985+ :local-call (fn [] @av/local-call)
1986+ :local-feed (fn [] av/local-feed)
1987+ :media-error (fn [] @av/media-error)
1988+ :tiles av/tiles
1989+ :tile-rows av/tile-rows
1990+ :set-muted! av/set-muted!
1991+ :set-speaker-muted! av/set-speaker-muted!
1992+ :set-camera! av/set-camera!
1993+ :after! platform/after!
1994+ :open-url! platform/open-url!
1987 :accept-policy! accept-policy!1995 :accept-policy! accept-policy!
1988 :close-picker! close-picker!1996 :close-picker! close-picker!
1989 :hover-reaction! hover-reaction!1997 :hover-reaction! hover-reaction!