Share the root, and with it the last of the screens
`frq.screens.app` is the decision about which screen is showing, the split view that shows two at once, and the three dialogs that sit over whichever it is. The phone renders `[screens/app]` now and nothing else — it had been switching screens by hand, because the thing that decides had not moved yet. frq.app is 226 lines. It was 1,744. What is left is what cannot move: `derived` and the two asset lookups, which are a glimmer reaction over a fetch-and-cache; the metrics aliases frq.tui writes; and `start!`. The renderer needed one more thing, and it is the last of the fill-height family. The root wraps every screen in a plain `:vbox`, and the child is `[chat-screen]` — a function, not a tag. `fills-column?` reads tags, so it could not see that the screen inside wanted the whole height, and the conversation came back unbounded. Components are expanded before the question is asked now, once, and both asked and rendered from the same expansion. The picker, the lightbox and the profile card come with the root and lean on things the phone has not got: a directory listing, a picture cache, an HTTPS profile fetch. All three go through `frq.actions`, so a platform that installs none of them draws the shape they take when the answer is nil rather than being special-cased in the screen. Verified: the TUI is identical to a same-prefs baseline, the split view still draws the list beside the conversation, and the phone walks connect → chats → open → conversation entirely through the shared root. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
433c3c8 parent: 49fb82f modified
common/frq/actions.cljc +26 -0 | @@ -139,3 +139,29 @@ | ||
| 139 | 139 | (defn profile-unhover! [& args] (call :profile-unhover! args)) |
| 140 | 140 | (defn profile-open! [& args] (call :profile-open! args)) |
| 141 | 141 | (defn toggle-hide-join-part! [] (call :toggle-hide-join-part! [])) |
| 142 | + | |
| 143 | +;; --------------------------------------------------- the picker and a face | |
| 144 | +;; | |
| 145 | +;; Browsing the filesystem for a picture, and the profile behind a nick. Both | |
| 146 | +;; are the host's: one is a directory listing and the other is an HTTPS fetch | |
| 147 | +;; with a cache, and a platform that has neither answers nil. | |
| 148 | + | |
| 149 | +(defn browse! [& args] (call :browse! args)) | |
| 150 | +(defn close-image-picker! [& args] (call :close-image-picker! args)) | |
| 151 | +(defn pick-image! [& args] (call :pick-image! args)) | |
| 152 | +(defn parent-dir [& args] (call :parent-dir args)) | |
| 153 | +(defn picker-entries [& args] (call :picker-entries args)) | |
| 154 | +(defn picker-roots [& args] (call :picker-roots args)) | |
| 155 | +(defn media-tick [] (call :media-tick [])) | |
| 156 | +(defn avatar-ready [& args] (call :avatar-ready args)) | |
| 157 | + | |
| 158 | +(defn profile-close! [& args] (call :profile-close! args)) | |
| 159 | +(defn profile-dismiss! [& args] (call :profile-dismiss! args)) | |
| 160 | +(defn profile-enter-dialog! [& args] (call :profile-enter-dialog! args)) | |
| 161 | +(defn profile-entry [& args] (call :profile-entry args)) | |
| 162 | +(defn profile-leave-dialog! [& args] (call :profile-leave-dialog! args)) | |
| 163 | +(defn profile-stats-line [& args] (call :profile-stats-line args)) | |
| 164 | +(defn profile-tick [] (call :profile-tick [])) | |
| 165 | +(defn profile-truncate [& args] (call :profile-truncate args)) | |
| 166 | +(defn profile-web-url [& args] (call :profile-web-url args)) | |
| 167 | + | |
| @@ -139,3 +139,29 @@ | |||
| 139 | (defn profile-unhover! [& args] (call :profile-unhover! args)) | 139 | (defn profile-unhover! [& args] (call :profile-unhover! args)) |
| 140 | (defn profile-open! [& args] (call :profile-open! args)) | 140 | (defn profile-open! [& args] (call :profile-open! args)) |
| 141 | (defn toggle-hide-join-part! [] (call :toggle-hide-join-part! [])) | 141 | (defn toggle-hide-join-part! [] (call :toggle-hide-join-part! [])) |
| 142 | + | ||
| 143 | +;; --------------------------------------------------- the picker and a face | ||
| 144 | +;; | ||
| 145 | +;; Browsing the filesystem for a picture, and the profile behind a nick. Both | ||
| 146 | +;; are the host's: one is a directory listing and the other is an HTTPS fetch | ||
| 147 | +;; with a cache, and a platform that has neither answers nil. | ||
| 148 | + | ||
| 149 | +(defn browse! [& args] (call :browse! args)) | ||
| 150 | +(defn close-image-picker! [& args] (call :close-image-picker! args)) | ||
| 151 | +(defn pick-image! [& args] (call :pick-image! args)) | ||
| 152 | +(defn parent-dir [& args] (call :parent-dir args)) | ||
| 153 | +(defn picker-entries [& args] (call :picker-entries args)) | ||
| 154 | +(defn picker-roots [& args] (call :picker-roots args)) | ||
| 155 | +(defn media-tick [] (call :media-tick [])) | ||
| 156 | +(defn avatar-ready [& args] (call :avatar-ready args)) | ||
| 157 | + | ||
| 158 | +(defn profile-close! [& args] (call :profile-close! args)) | ||
| 159 | +(defn profile-dismiss! [& args] (call :profile-dismiss! args)) | ||
| 160 | +(defn profile-enter-dialog! [& args] (call :profile-enter-dialog! args)) | ||
| 161 | +(defn profile-entry [& args] (call :profile-entry args)) | ||
| 162 | +(defn profile-leave-dialog! [& args] (call :profile-leave-dialog! args)) | ||
| 163 | +(defn profile-stats-line [& args] (call :profile-stats-line args)) | ||
| 164 | +(defn profile-tick [] (call :profile-tick [])) | ||
| 165 | +(defn profile-truncate [& args] (call :profile-truncate args)) | ||
| 166 | +(defn profile-web-url [& args] (call :profile-web-url args)) | ||
| 167 | + | ||
modified
common/frq/cells.cljc +4 -0 | @@ -208,3 +208,7 @@ | ||
| 208 | 208 | |
| 209 | 209 | |
| 210 | 210 | ;; joined as soon as the server sends 001 |
| 211 | + | |
| 212 | +;; Where the picker is looking, or nil when it is closed. A path, so the | |
| 213 | +;; browsing is just this cell moving. | |
| 214 | +(defonce image-picker (atom nil)) | |
| @@ -208,3 +208,7 @@ | |||
| 208 | 208 | ||
| 209 | 209 | ||
| 210 | ;; joined as soon as the server sends 001 | 210 | ;; joined as soon as the server sends 001 |
| 211 | + | ||
| 212 | +;; Where the picker is looking, or nil when it is closed. A path, so the | ||
| 213 | +;; browsing is just this cell moving. | ||
| 214 | +(defonce image-picker (atom nil)) | ||
added
common/frq/screens/app.cljc +378 -0 | new file mode 100644 | ||
| @@ -0,0 +1,378 @@ | ||
| 1 | +(ns frq.screens.app | |
| 2 | + "The screens as one app: which one is showing, the split view that shows two | |
| 3 | + at once, and the three that sit over the top of whichever it is. | |
| 4 | + | |
| 5 | + The last of `frq.app` to move, and the piece that makes the rest of it | |
| 6 | + reachable — a phone was switching screens by hand before this, because the | |
| 7 | + thing that decides had not been shared yet. | |
| 8 | + | |
| 9 | + `profile-screen`, `lightbox-screen` and `image-picker-screen` come with it | |
| 10 | + because `app` is what shows them. All three lean on things a phone does not | |
| 11 | + have yet — a picture cache, a profile fetch, a filesystem to browse — and | |
| 12 | + all three ask `frq.actions` for them, so what a phone gets is the shape they | |
| 13 | + take when the answer is nil." | |
| 14 | + (:require [clojure.string :as str] | |
| 15 | + [frq.actions :as actions] | |
| 16 | + [frq.cells :as cells] | |
| 17 | + [frq.metrics :refer [terminal?]] | |
| 18 | + [frq.rooms :as rooms] | |
| 19 | + [frq.screens.chat :refer [chat-screen emoji-picker reactor-dialog | |
| 20 | + sidebar-width]] | |
| 21 | + [frq.screens.chats :refer [chats-screen conversation-row tab-bar]] | |
| 22 | + [frq.screens.connect :refer [connect-screen error-note]] | |
| 23 | + [frq.screens.settings :refer [discover-screen settings-screen | |
| 24 | + tab-screen]])) | |
| 25 | + | |
| 26 | +(defn image-picker-screen | |
| 27 | + "The pictures on this device, to send one of. | |
| 28 | + | |
| 29 | + A screen rather than a panel over the compose bar: choosing a file is | |
| 30 | + browsing, and browsing wants the window — the backend paints in one layer | |
| 31 | + anyway, so there is no overlay to put it in. | |
| 32 | + | |
| 33 | + A directory at a time, PNG only. Which directories there are to start from is | |
| 34 | + the platform's answer, not this screen's: a desktop opens on ~/Pictures, and | |
| 35 | + a phone on what the app can read without a permission it has no code to ask | |
| 36 | + for, which may be very little. Either way the list says what it found." | |
| 37 | + [] | |
| 38 | + (let [dir @cells/image-picker | |
| 39 | + {:keys [dirs files]} (actions/picker-entries dir) | |
| 40 | + up (actions/parent-dir dir)] | |
| 41 | + [:vbox {:spacing 8 :margin 12} | |
| 42 | + [:hbox {:spacing 8} | |
| 43 | + [:button {:label "← Back" :on-click actions/close-image-picker!}] | |
| 44 | + [:title {:label "Send a picture"}]] | |
| 45 | + [:dim-label {:label (str dir)}] | |
| 46 | + [error-note] | |
| 47 | + ;; The places worth starting from, always in reach: browsing into a corner | |
| 48 | + ;; of the filesystem should not cost the way back to the pictures folder. | |
| 49 | + [:hbox {:key :roots :spacing 6} | |
| 50 | + (for [root (actions/picker-roots)] | |
| 51 | + [:button {:key root | |
| 52 | + :label (or (last (str/split root #"/")) root) | |
| 53 | + :kind (if (= root dir) :primary :default) | |
| 54 | + :on-click #(actions/browse! root)}])] | |
| 55 | + [:separator {}] | |
| 56 | + [:scroll {:scroll-key "image-picker" | |
| 57 | + :orientation :vertical | |
| 58 | + :reserve 40} | |
| 59 | + [:vbox {:spacing 6} | |
| 60 | + [:vbox {:key :up} | |
| 61 | + (when up | |
| 62 | + [:button {:label "⬆ Up" :on-click #(actions/browse! up)}])] | |
| 63 | + [:vbox {:key :dirs :spacing 4} | |
| 64 | + (for [d dirs] | |
| 65 | + [:button {:key d | |
| 66 | + :label (str "📁 " (last (str/split d #"/"))) | |
| 67 | + :on-click #(actions/browse! d)}])] | |
| 68 | + ;; The picture itself is the button: a filename is not what anyone is | |
| 69 | + ;; choosing between, and a thumbnail answers "is this the one" in a way | |
| 70 | + ;; no name does. | |
| 71 | + [:vbox {:key :files :spacing 6} | |
| 72 | + (for [f files] | |
| 73 | + [:hbox {:key f :spacing 8} | |
| 74 | + [:image {:src f :max-height 72 :on-click #(actions/pick-image! f)}] | |
| 75 | + [:button {:label (last (str/split f #"/")) | |
| 76 | + :on-click #(actions/pick-image! f)}]])] | |
| 77 | + [:vbox {:key :empty} | |
| 78 | + (when (and (empty? dirs) (empty? files)) | |
| 79 | + [:dim-label {:label "No pictures here that this app can read."}])]]]])) | |
| 80 | + | |
| 81 | +;; What the people panel asks for, and what the conversation beside it has to | |
| 82 | +;; be told to leave. A column with `:fill-height` and no width takes the whole | |
| 83 | +;; row — so the panel is only ever on screen if the message list is given a | |
| 84 | +;; width that stops short of it. | |
| 85 | + | |
| 86 | +(defn lightbox-screen | |
| 87 | + "One picture, as big as the window will paint it. | |
| 88 | + | |
| 89 | + A screen rather than an overlay: the tree backend paints in one layer and has | |
| 90 | + no z-order to put something on top of everything else with. So the picture | |
| 91 | + takes the window — `:fit` gives it every point below the one row that is not | |
| 92 | + it, centred and in proportion, scaled up as readily as down. | |
| 93 | + | |
| 94 | + That row is the way back. Clicking the picture closes it too — the same | |
| 95 | + gesture that opened it — but a way out you have to guess at is not one, and | |
| 96 | + the row costs the picture a line." | |
| 97 | + [] | |
| 98 | + (let [{:keys [path]} @cells/lightbox] | |
| 99 | + [:vbox {:spacing 4 :margin 4} | |
| 100 | + [:hbox {:spacing 8} | |
| 101 | + [:button {:label "← Back" :on-click #(reset! cells/lightbox nil)}]] | |
| 102 | + [:image {:src path :fit true :on-click #(reset! cells/lightbox nil)}]])) | |
| 103 | + | |
| 104 | +(defn- profile-dialog | |
| 105 | + "Who someone is, as libcosmic's own dialog: centred over the window, with | |
| 106 | + what you were reading dimmed behind it rather than replaced. | |
| 107 | + | |
| 108 | + The window can do this and the terminal cannot, which is the whole reason | |
| 109 | + there are two of these. `profile-screen` below is the terminal's, and says | |
| 110 | + why it is a screen. | |
| 111 | + | |
| 112 | + Both buttons are always here, the Bluesky one insensitive until there is a | |
| 113 | + profile to open: a dialog whose second button appears a moment after it | |
| 114 | + opens is a dialog that moves under the pointer, and the fetch lands whenever | |
| 115 | + it lands. | |
| 116 | + | |
| 117 | + Who it is about comes from either of two places, and that — with the | |
| 118 | + modality below — is the whole of what hovering and pressing a face do | |
| 119 | + differently. Resting on one sets `hovering`, which the pointer takes away | |
| 120 | + again when it leaves; pressing one sets `viewing`, which nothing takes away | |
| 121 | + but Close. `viewing` is read first, so a pinned profile is not swapped out | |
| 122 | + from under the reader by a face the pointer crosses on the way to it. | |
| 123 | + | |
| 124 | + And a dialog the pointer is holding open is not modal. A modal one makes | |
| 125 | + the window underneath it deaf — libcosmic wraps the app in a popover that | |
| 126 | + hands its content an `Unavailable` cursor while a popup is up — so the face | |
| 127 | + that opened it never hears the pointer leave, and what a hover opened could | |
| 128 | + never close itself. Non-modal, the face keeps hearing, and moving away shuts | |
| 129 | + it. A pinned one is modal, which is what being pinned means: it is the thing | |
| 130 | + on the screen until it is dismissed." | |
| 131 | + [] | |
| 132 | + (let [pinned? (some? (actions/viewing)) | |
| 133 | + {:keys [nick actor]} (or (actions/viewing) (actions/hovering)) | |
| 134 | + _ (actions/profile-tick) | |
| 135 | + _ @actions/media-tick | |
| 136 | + pr (actions/profile-entry actor) | |
| 137 | + ready? (= :ready (:status pr)) | |
| 138 | + display (or (:display-name pr) nick) | |
| 139 | + url (when ready? (actions/profile-web-url pr))] | |
| 140 | + [:dialog {:label display :max-width 520 :modal pinned? | |
| 141 | + :on-hover actions/profile-enter-dialog! | |
| 142 | + :on-unhover actions/profile-leave-dialog!} | |
| 143 | + ;; The body is the screen's card without its heading: the dialog's own | |
| 144 | + ;; title is the name now, so repeating it under the picture is a line | |
| 145 | + ;; that says nothing. | |
| 146 | + [:vbox {:key :body :spacing 8} | |
| 147 | + [:hbox {:spacing 12} | |
| 148 | + [:avatar {:label nick | |
| 149 | + :src (or (actions/avatar-ready actor) "") | |
| 150 | + :size 72}] | |
| 151 | + [:vbox {:spacing 2} | |
| 152 | + [:vbox {:key :nick} | |
| 153 | + (when (not= display nick) [:dim-label {:label nick}])] | |
| 154 | + [:vbox {:key :handle} | |
| 155 | + (when-let [h (and ready? (not-empty (or (:handle pr) "")))] | |
| 156 | + [:label {:label (str "@" h)}])] | |
| 157 | + [:vbox {:key :did} | |
| 158 | + (when-let [did (:did pr)] [:dim-label {:label did}])]]] | |
| 159 | + [:vbox {:key :bio :spacing 2} | |
| 160 | + (when-let [bio (and ready? (:description pr))] | |
| 161 | + (for [[i line] (map-indexed vector (str/split-lines (actions/profile-truncate bio 600)))] | |
| 162 | + [:label {:key i :label line}]))] | |
| 163 | + [:vbox {:key :stats} | |
| 164 | + (when-let [line (and ready? (actions/profile-stats-line pr))] | |
| 165 | + [:dim-label {:label line}])] | |
| 166 | + [:vbox {:key :status :spacing 4} | |
| 167 | + (cond | |
| 168 | + (nil? actor) | |
| 169 | + [:dim-label {:label "Guest — no Bluesky / AT Protocol identity"}] | |
| 170 | + | |
| 171 | + (= :loading (:status pr)) | |
| 172 | + [:hbox {:spacing 8} | |
| 173 | + [:spinner {}] | |
| 174 | + [:dim-label {:label "Loading Bluesky profile…"}]] | |
| 175 | + | |
| 176 | + (= :failed (:status pr)) | |
| 177 | + [:dim-label {:label "No Bluesky profile found"}])] | |
| 178 | +] | |
| 179 | + ;; `slot` is where libcosmic puts a button: the two actions go to the | |
| 180 | + ;; foot of the dialog, and anything else here would be another control | |
| 181 | + ;; stacked in the body. | |
| 182 | + ;; | |
| 183 | + ;; Both are here whether the profile is pinned or only hovered. They were | |
| 184 | + ;; hidden while hovering, back when a hover could not be walked into: the | |
| 185 | + ;; dialog reports its own pointer now, so moving towards a button in it | |
| 186 | + ;; keeps it open instead of closing it, and a button you can reach is a | |
| 187 | + ;; button worth drawing. | |
| 188 | + [:button {:key :close :slot "primary" | |
| 189 | + :label (if pinned? "Close" "Dismiss") | |
| 190 | + :on-click actions/profile-dismiss!}] | |
| 191 | + [:button {:key :web :slot "secondary" :label "Bluesky ↗" | |
| 192 | + :sensitive (boolean url) | |
| 193 | + :on-click #(when url (actions/open-url! url))}]])) | |
| 194 | + | |
| 195 | +(defn profile-screen | |
| 196 | + "Who someone is: sleek's peer profile modal, as a screen. | |
| 197 | + | |
| 198 | + A screen rather than a layer for the same reason the lightbox is one — the | |
| 199 | + tree backend paints in a single layer, with no z-order to hang a modal from. | |
| 200 | + So the back row does what sleek's ✕ and backdrop did. | |
| 201 | + | |
| 202 | + The picture is the same cached file the chat column draws, at a size worth | |
| 203 | + looking at; the bio is split into a label a line, so a bio that was written | |
| 204 | + as several lines is still several lines here." | |
| 205 | + [] | |
| 206 | + (let [{:keys [nick actor]} (actions/viewing) | |
| 207 | + ;; Reading both ticks subscribes this screen to the two fetches it is | |
| 208 | + ;; waiting on: the profile itself, and the picture on it. | |
| 209 | + _ (actions/profile-tick) | |
| 210 | + _ @actions/media-tick | |
| 211 | + pr (actions/profile-entry actor) | |
| 212 | + ready? (= :ready (:status pr)) | |
| 213 | + display (or (:display-name pr) nick) | |
| 214 | + url (when ready? (actions/profile-web-url pr))] | |
| 215 | + [:page {:max-width 520} | |
| 216 | + [:hbox {:spacing 8} | |
| 217 | + [:button {:label "← Back" :on-click actions/profile-close!}]] | |
| 218 | + [:card {} | |
| 219 | + [:hbox {:spacing 12} | |
| 220 | + [:avatar {:label nick | |
| 221 | + :src (or (actions/avatar-ready actor) "") | |
| 222 | + :size 72}] | |
| 223 | + [:vbox {:spacing 2} | |
| 224 | + [:title-2 {:label display}] | |
| 225 | + ;; The nick under the display name only when they differ — repeating | |
| 226 | + ;; it is a line that says nothing. | |
| 227 | + [:vbox {:key :nick} | |
| 228 | + (when (not= display nick) [:dim-label {:label nick}])] | |
| 229 | + [:vbox {:key :handle} | |
| 230 | + (when-let [h (and ready? (not-empty (or (:handle pr) "")))] | |
| 231 | + [:label {:label (str "@" h)}])]]] | |
| 232 | + ;; The DID is the identity itself, and outlasts both the nick and the | |
| 233 | + ;; handle — so it is on the screen, in full, rather than implied. | |
| 234 | + [:vbox {:key :did :spacing 2} | |
| 235 | + (when-let [did (:did pr)] [:dim-label {:label did}])] | |
| 236 | + [:vbox {:key :bio :spacing 2} | |
| 237 | + (when-let [bio (and ready? (:description pr))] | |
| 238 | + (for [[i line] (map-indexed vector (str/split-lines (actions/profile-truncate bio 600)))] | |
| 239 | + [:label {:key i :label line}]))] | |
| 240 | + [:vbox {:key :stats} | |
| 241 | + (when-let [line (and ready? (actions/profile-stats-line pr))] | |
| 242 | + [:dim-label {:label line}])] | |
| 243 | + ;; What is happening, or why nothing is: a guest has no identity to look | |
| 244 | + ;; up, and saying so is a better answer than a spinner that never lands. | |
| 245 | + [:vbox {:key :status :spacing 4} | |
| 246 | + (cond | |
| 247 | + (nil? actor) | |
| 248 | + [:dim-label {:label "Guest — no Bluesky / AT Protocol identity"}] | |
| 249 | + | |
| 250 | + (= :loading (:status pr)) | |
| 251 | + [:hbox {:spacing 8} | |
| 252 | + [:spinner {}] | |
| 253 | + [:dim-label {:label "Loading Bluesky profile…"}]] | |
| 254 | + | |
| 255 | + (= :failed (:status pr)) | |
| 256 | + [:dim-label {:label "No Bluesky profile found"}])] | |
| 257 | + [:vbox {:key :actions} | |
| 258 | + (when url | |
| 259 | + [:button {:label "Bluesky ↗" | |
| 260 | + :kind :primary | |
| 261 | + :on-click #(actions/open-url! url)}])]]])) | |
| 262 | + | |
| 263 | +(defn- no-chat-pane | |
| 264 | + "What fills the second pane before a conversation has been picked. The pane | |
| 265 | + is there either way — a list that widened into two columns and back again as | |
| 266 | + channels were opened would be a worse answer than an empty half." | |
| 267 | + [] | |
| 268 | + [:vbox {:spacing 8 :margin 12} | |
| 269 | + [:title {:label "frq"}] | |
| 270 | + ;; With the list folded away there is nothing on the left to pick from, and | |
| 271 | + ;; no conversation here carrying the switch that would bring it back — so | |
| 272 | + ;; this pane carries it instead. Without that the reader who hid the list | |
| 273 | + ;; and then closed the last room has put the app away, not the list. | |
| 274 | + (if @cells/hide-chat-list? | |
| 275 | + [:card {} | |
| 276 | + [:dim-label {:label "The chats list is hidden."}] | |
| 277 | + [:button {:label "☰ Chats" :kind :primary | |
| 278 | + :on-click actions/toggle-chat-list!}]] | |
| 279 | + [:card {} [:dim-label {:label "Pick a conversation on the left."}]])]) | |
| 280 | + | |
| 281 | +(defn split-screen | |
| 282 | + "The chats list and the conversation side by side, for a window wide enough | |
| 283 | + to hold both. | |
| 284 | + | |
| 285 | + Same components as the narrow layout, in a row instead of one at a time: the | |
| 286 | + list keeps its own scroll and its own tab bar, and the conversation keeps the | |
| 287 | + compose bar pinned under a backlog that scrolls on its own. | |
| 288 | + | |
| 289 | + Both panes take `:fill-height`: a column in a row is otherwise as tall as the | |
| 290 | + row, which at the moment it is placed is one button — and the list and the | |
| 291 | + message backlog both size themselves against the height they are handed. | |
| 292 | + | |
| 293 | + Only the list is given a width. The conversation takes what is left, rather | |
| 294 | + than the window's width minus the list's: the list costs a little more than | |
| 295 | + its 320 (its page has padding of its own), and a second column asking for | |
| 296 | + more than remains is wrapped onto a row below — painting the whole | |
| 297 | + conversation off the bottom of the window, which reads exactly like a | |
| 298 | + conversation that has gone missing." | |
| 299 | + [] | |
| 300 | + ;; And the row itself takes the window, for the reason each narrow root | |
| 301 | + ;; gives: a backend that sizes a box by what is in it hands two panes that | |
| 302 | + ;; both asked for the rest of the screen the height of the taller one's | |
| 303 | + ;; contents. Marking the children alone is not enough — what they fill is | |
| 304 | + ;; whatever the row got. | |
| 305 | + [:hbox {:spacing 0 :wrap false :fill-height true} | |
| 306 | + ;; :expand :cross so the width-request is the width. Both panes fill the | |
| 307 | + ;; height, and a pane that fills the height is also told to take a share | |
| 308 | + ;; of the row's spare WIDTH — which two panes split between them, so the | |
| 309 | + ;; list came out half the window with its cards clipped at the fold and | |
| 310 | + ;; the conversation squeezed beside it. Down a row, :cross is the vertical | |
| 311 | + ;; axis alone: the height without the share. The conversation keeps | |
| 312 | + ;; :fill-height and so keeps the slack, which is what the note above says | |
| 313 | + ;; it takes. | |
| 314 | + ;; The list pane, when the reader has not put it away. Hidden, the wrapper | |
| 315 | + ;; stays and empties: a child that vanished would renumber the row for the | |
| 316 | + ;; reconciler and take the conversation's scroll position with it every | |
| 317 | + ;; time the list was toggled — the same trick the people panel plays. | |
| 318 | + ;; Empty, it asks for nothing: no width and no `:fill-height`, because a | |
| 319 | + ;; column that fills the height is also a column that is there, and a | |
| 320 | + ;; 320-point hole where the list was is not hiding it. | |
| 321 | + (if @cells/hide-chat-list? | |
| 322 | + [:vbox {:key :list}] | |
| 323 | + [:vbox {:key :list :width-request sidebar-width :fill-height true | |
| 324 | + :expand :cross} | |
| 325 | + [chats-screen]]) | |
| 326 | + [:vbox {:key :chat :fill-height true} | |
| 327 | + (if @cells/current | |
| 328 | + [chat-screen] | |
| 329 | + [no-chat-pane])]]) | |
| 330 | + | |
| 331 | +(defn app [] | |
| 332 | + ;; Every branch in its own keyed wrapper, and every key different. | |
| 333 | + ;; | |
| 334 | + ;; Without them the screens are all one position in the tree, and moving | |
| 335 | + ;; between two of them is a diff of one screen's children against another's: | |
| 336 | + ;; the reconciler matches what it can by position and patches the rest, which | |
| 337 | + ;; leaves nodes from the screen you left standing in the one you arrived at — | |
| 338 | + ;; the chats screen's join box turning up under its tab bar, an error box | |
| 339 | + ;; from the conversation you were in a moment ago. A key that changes with | |
| 340 | + ;; the screen makes the swap a swap: the old tree comes out whole and the new | |
| 341 | + ;; one goes in whole. | |
| 342 | + ;; And the dialog beside them all rather than instead of one of them. A | |
| 343 | + ;; `dialog` node is not painted where it stands — the window backend hands | |
| 344 | + ;; it to libcosmic, which puts it over the middle of the window with what is | |
| 345 | + ;; behind it dimmed — so the screen under it keeps its place in the tree, | |
| 346 | + ;; and its scroll position with it. The wrapper is always here and only its | |
| 347 | + ;; child comes and goes, for the reason every other wrapper in this file | |
| 348 | + ;; gives: a child that appeared and vanished would renumber the root. | |
| 349 | + ;; | |
| 350 | + ;; The terminal has no such thing, and a `dialog` tag it has not grown would | |
| 351 | + ;; paint its contents inline at the bottom of the screen. So there it stays | |
| 352 | + ;; a screen you go to and come back from, which is `profile-screen`. | |
| 353 | + [:vbox {:key :root :fill-height true} | |
| 354 | + ;; One dialog at a time, and a pinned profile outranks both pointers: it is | |
| 355 | + ;; the only one of the three that was asked for by a press rather than by | |
| 356 | + ;; where the pointer happens to be resting. | |
| 357 | + [:vbox {:key :dialog} | |
| 358 | + (when-not @terminal? | |
| 359 | + (cond | |
| 360 | + (or (actions/viewing) (actions/hovering)) [profile-dialog] | |
| 361 | + @cells/reaction-hover [reactor-dialog]))] | |
| 362 | + (cond | |
| 363 | + @cells/lightbox [:vbox {:key :screen-lightbox} [lightbox-screen]] | |
| 364 | + (and @terminal? (actions/viewing)) | |
| 365 | + [:vbox {:key :screen-profile} [profile-screen]] | |
| 366 | + @cells/image-picker [:vbox {:key :screen-picker} [image-picker-screen]] | |
| 367 | + ;; Wide enough for both, and on one of the two screens that are halves of | |
| 368 | + ;; the same thing: the list and the conversation it opens. Discover and | |
| 369 | + ;; settings stay whole screens — they are somewhere else, not the other | |
| 370 | + ;; half of here. | |
| 371 | + (and (actions/wide?) (contains? #{:chats :chat} @cells/screen)) | |
| 372 | + [:vbox {:key :screen-split} [split-screen]] | |
| 373 | + :else (case @cells/screen | |
| 374 | + :connect [:vbox {:key :screen-connect} [connect-screen]] | |
| 375 | + :chat [:vbox {:key :screen-chat} [chat-screen]] | |
| 376 | + :discover [:vbox {:key :screen-discover} [discover-screen]] | |
| 377 | + :settings [:vbox {:key :screen-settings} [settings-screen]] | |
| 378 | + [:vbox {:key :screen-chats} [chats-screen]]))]) | |
| new file mode 100644 | |||
| @@ -0,0 +1,378 @@ | |||
| 1 | +(ns frq.screens.app | ||
| 2 | + "The screens as one app: which one is showing, the split view that shows two | ||
| 3 | + at once, and the three that sit over the top of whichever it is. | ||
| 4 | + | ||
| 5 | + The last of `frq.app` to move, and the piece that makes the rest of it | ||
| 6 | + reachable — a phone was switching screens by hand before this, because the | ||
| 7 | + thing that decides had not been shared yet. | ||
| 8 | + | ||
| 9 | + `profile-screen`, `lightbox-screen` and `image-picker-screen` come with it | ||
| 10 | + because `app` is what shows them. All three lean on things a phone does not | ||
| 11 | + have yet — a picture cache, a profile fetch, a filesystem to browse — and | ||
| 12 | + all three ask `frq.actions` for them, so what a phone gets is the shape they | ||
| 13 | + take when the answer is nil." | ||
| 14 | + (:require [clojure.string :as str] | ||
| 15 | + [frq.actions :as actions] | ||
| 16 | + [frq.cells :as cells] | ||
| 17 | + [frq.metrics :refer [terminal?]] | ||
| 18 | + [frq.rooms :as rooms] | ||
| 19 | + [frq.screens.chat :refer [chat-screen emoji-picker reactor-dialog | ||
| 20 | + sidebar-width]] | ||
| 21 | + [frq.screens.chats :refer [chats-screen conversation-row tab-bar]] | ||
| 22 | + [frq.screens.connect :refer [connect-screen error-note]] | ||
| 23 | + [frq.screens.settings :refer [discover-screen settings-screen | ||
| 24 | + tab-screen]])) | ||
| 25 | + | ||
| 26 | +(defn image-picker-screen | ||
| 27 | + "The pictures on this device, to send one of. | ||
| 28 | + | ||
| 29 | + A screen rather than a panel over the compose bar: choosing a file is | ||
| 30 | + browsing, and browsing wants the window — the backend paints in one layer | ||
| 31 | + anyway, so there is no overlay to put it in. | ||
| 32 | + | ||
| 33 | + A directory at a time, PNG only. Which directories there are to start from is | ||
| 34 | + the platform's answer, not this screen's: a desktop opens on ~/Pictures, and | ||
| 35 | + a phone on what the app can read without a permission it has no code to ask | ||
| 36 | + for, which may be very little. Either way the list says what it found." | ||
| 37 | + [] | ||
| 38 | + (let [dir @cells/image-picker | ||
| 39 | + {:keys [dirs files]} (actions/picker-entries dir) | ||
| 40 | + up (actions/parent-dir dir)] | ||
| 41 | + [:vbox {:spacing 8 :margin 12} | ||
| 42 | + [:hbox {:spacing 8} | ||
| 43 | + [:button {:label "← Back" :on-click actions/close-image-picker!}] | ||
| 44 | + [:title {:label "Send a picture"}]] | ||
| 45 | + [:dim-label {:label (str dir)}] | ||
| 46 | + [error-note] | ||
| 47 | + ;; The places worth starting from, always in reach: browsing into a corner | ||
| 48 | + ;; of the filesystem should not cost the way back to the pictures folder. | ||
| 49 | + [:hbox {:key :roots :spacing 6} | ||
| 50 | + (for [root (actions/picker-roots)] | ||
| 51 | + [:button {:key root | ||
| 52 | + :label (or (last (str/split root #"/")) root) | ||
| 53 | + :kind (if (= root dir) :primary :default) | ||
| 54 | + :on-click #(actions/browse! root)}])] | ||
| 55 | + [:separator {}] | ||
| 56 | + [:scroll {:scroll-key "image-picker" | ||
| 57 | + :orientation :vertical | ||
| 58 | + :reserve 40} | ||
| 59 | + [:vbox {:spacing 6} | ||
| 60 | + [:vbox {:key :up} | ||
| 61 | + (when up | ||
| 62 | + [:button {:label "⬆ Up" :on-click #(actions/browse! up)}])] | ||
| 63 | + [:vbox {:key :dirs :spacing 4} | ||
| 64 | + (for [d dirs] | ||
| 65 | + [:button {:key d | ||
| 66 | + :label (str "📁 " (last (str/split d #"/"))) | ||
| 67 | + :on-click #(actions/browse! d)}])] | ||
| 68 | + ;; The picture itself is the button: a filename is not what anyone is | ||
| 69 | + ;; choosing between, and a thumbnail answers "is this the one" in a way | ||
| 70 | + ;; no name does. | ||
| 71 | + [:vbox {:key :files :spacing 6} | ||
| 72 | + (for [f files] | ||
| 73 | + [:hbox {:key f :spacing 8} | ||
| 74 | + [:image {:src f :max-height 72 :on-click #(actions/pick-image! f)}] | ||
| 75 | + [:button {:label (last (str/split f #"/")) | ||
| 76 | + :on-click #(actions/pick-image! f)}]])] | ||
| 77 | + [:vbox {:key :empty} | ||
| 78 | + (when (and (empty? dirs) (empty? files)) | ||
| 79 | + [:dim-label {:label "No pictures here that this app can read."}])]]]])) | ||
| 80 | + | ||
| 81 | +;; What the people panel asks for, and what the conversation beside it has to | ||
| 82 | +;; be told to leave. A column with `:fill-height` and no width takes the whole | ||
| 83 | +;; row — so the panel is only ever on screen if the message list is given a | ||
| 84 | +;; width that stops short of it. | ||
| 85 | + | ||
| 86 | +(defn lightbox-screen | ||
| 87 | + "One picture, as big as the window will paint it. | ||
| 88 | + | ||
| 89 | + A screen rather than an overlay: the tree backend paints in one layer and has | ||
| 90 | + no z-order to put something on top of everything else with. So the picture | ||
| 91 | + takes the window — `:fit` gives it every point below the one row that is not | ||
| 92 | + it, centred and in proportion, scaled up as readily as down. | ||
| 93 | + | ||
| 94 | + That row is the way back. Clicking the picture closes it too — the same | ||
| 95 | + gesture that opened it — but a way out you have to guess at is not one, and | ||
| 96 | + the row costs the picture a line." | ||
| 97 | + [] | ||
| 98 | + (let [{:keys [path]} @cells/lightbox] | ||
| 99 | + [:vbox {:spacing 4 :margin 4} | ||
| 100 | + [:hbox {:spacing 8} | ||
| 101 | + [:button {:label "← Back" :on-click #(reset! cells/lightbox nil)}]] | ||
| 102 | + [:image {:src path :fit true :on-click #(reset! cells/lightbox nil)}]])) | ||
| 103 | + | ||
| 104 | +(defn- profile-dialog | ||
| 105 | + "Who someone is, as libcosmic's own dialog: centred over the window, with | ||
| 106 | + what you were reading dimmed behind it rather than replaced. | ||
| 107 | + | ||
| 108 | + The window can do this and the terminal cannot, which is the whole reason | ||
| 109 | + there are two of these. `profile-screen` below is the terminal's, and says | ||
| 110 | + why it is a screen. | ||
| 111 | + | ||
| 112 | + Both buttons are always here, the Bluesky one insensitive until there is a | ||
| 113 | + profile to open: a dialog whose second button appears a moment after it | ||
| 114 | + opens is a dialog that moves under the pointer, and the fetch lands whenever | ||
| 115 | + it lands. | ||
| 116 | + | ||
| 117 | + Who it is about comes from either of two places, and that — with the | ||
| 118 | + modality below — is the whole of what hovering and pressing a face do | ||
| 119 | + differently. Resting on one sets `hovering`, which the pointer takes away | ||
| 120 | + again when it leaves; pressing one sets `viewing`, which nothing takes away | ||
| 121 | + but Close. `viewing` is read first, so a pinned profile is not swapped out | ||
| 122 | + from under the reader by a face the pointer crosses on the way to it. | ||
| 123 | + | ||
| 124 | + And a dialog the pointer is holding open is not modal. A modal one makes | ||
| 125 | + the window underneath it deaf — libcosmic wraps the app in a popover that | ||
| 126 | + hands its content an `Unavailable` cursor while a popup is up — so the face | ||
| 127 | + that opened it never hears the pointer leave, and what a hover opened could | ||
| 128 | + never close itself. Non-modal, the face keeps hearing, and moving away shuts | ||
| 129 | + it. A pinned one is modal, which is what being pinned means: it is the thing | ||
| 130 | + on the screen until it is dismissed." | ||
| 131 | + [] | ||
| 132 | + (let [pinned? (some? (actions/viewing)) | ||
| 133 | + {:keys [nick actor]} (or (actions/viewing) (actions/hovering)) | ||
| 134 | + _ (actions/profile-tick) | ||
| 135 | + _ @actions/media-tick | ||
| 136 | + pr (actions/profile-entry actor) | ||
| 137 | + ready? (= :ready (:status pr)) | ||
| 138 | + display (or (:display-name pr) nick) | ||
| 139 | + url (when ready? (actions/profile-web-url pr))] | ||
| 140 | + [:dialog {:label display :max-width 520 :modal pinned? | ||
| 141 | + :on-hover actions/profile-enter-dialog! | ||
| 142 | + :on-unhover actions/profile-leave-dialog!} | ||
| 143 | + ;; The body is the screen's card without its heading: the dialog's own | ||
| 144 | + ;; title is the name now, so repeating it under the picture is a line | ||
| 145 | + ;; that says nothing. | ||
| 146 | + [:vbox {:key :body :spacing 8} | ||
| 147 | + [:hbox {:spacing 12} | ||
| 148 | + [:avatar {:label nick | ||
| 149 | + :src (or (actions/avatar-ready actor) "") | ||
| 150 | + :size 72}] | ||
| 151 | + [:vbox {:spacing 2} | ||
| 152 | + [:vbox {:key :nick} | ||
| 153 | + (when (not= display nick) [:dim-label {:label nick}])] | ||
| 154 | + [:vbox {:key :handle} | ||
| 155 | + (when-let [h (and ready? (not-empty (or (:handle pr) "")))] | ||
| 156 | + [:label {:label (str "@" h)}])] | ||
| 157 | + [:vbox {:key :did} | ||
| 158 | + (when-let [did (:did pr)] [:dim-label {:label did}])]]] | ||
| 159 | + [:vbox {:key :bio :spacing 2} | ||
| 160 | + (when-let [bio (and ready? (:description pr))] | ||
| 161 | + (for [[i line] (map-indexed vector (str/split-lines (actions/profile-truncate bio 600)))] | ||
| 162 | + [:label {:key i :label line}]))] | ||
| 163 | + [:vbox {:key :stats} | ||
| 164 | + (when-let [line (and ready? (actions/profile-stats-line pr))] | ||
| 165 | + [:dim-label {:label line}])] | ||
| 166 | + [:vbox {:key :status :spacing 4} | ||
| 167 | + (cond | ||
| 168 | + (nil? actor) | ||
| 169 | + [:dim-label {:label "Guest — no Bluesky / AT Protocol identity"}] | ||
| 170 | + | ||
| 171 | + (= :loading (:status pr)) | ||
| 172 | + [:hbox {:spacing 8} | ||
| 173 | + [:spinner {}] | ||
| 174 | + [:dim-label {:label "Loading Bluesky profile…"}]] | ||
| 175 | + | ||
| 176 | + (= :failed (:status pr)) | ||
| 177 | + [:dim-label {:label "No Bluesky profile found"}])] | ||
| 178 | +] | ||
| 179 | + ;; `slot` is where libcosmic puts a button: the two actions go to the | ||
| 180 | + ;; foot of the dialog, and anything else here would be another control | ||
| 181 | + ;; stacked in the body. | ||
| 182 | + ;; | ||
| 183 | + ;; Both are here whether the profile is pinned or only hovered. They were | ||
| 184 | + ;; hidden while hovering, back when a hover could not be walked into: the | ||
| 185 | + ;; dialog reports its own pointer now, so moving towards a button in it | ||
| 186 | + ;; keeps it open instead of closing it, and a button you can reach is a | ||
| 187 | + ;; button worth drawing. | ||
| 188 | + [:button {:key :close :slot "primary" | ||
| 189 | + :label (if pinned? "Close" "Dismiss") | ||
| 190 | + :on-click actions/profile-dismiss!}] | ||
| 191 | + [:button {:key :web :slot "secondary" :label "Bluesky ↗" | ||
| 192 | + :sensitive (boolean url) | ||
| 193 | + :on-click #(when url (actions/open-url! url))}]])) | ||
| 194 | + | ||
| 195 | +(defn profile-screen | ||
| 196 | + "Who someone is: sleek's peer profile modal, as a screen. | ||
| 197 | + | ||
| 198 | + A screen rather than a layer for the same reason the lightbox is one — the | ||
| 199 | + tree backend paints in a single layer, with no z-order to hang a modal from. | ||
| 200 | + So the back row does what sleek's ✕ and backdrop did. | ||
| 201 | + | ||
| 202 | + The picture is the same cached file the chat column draws, at a size worth | ||
| 203 | + looking at; the bio is split into a label a line, so a bio that was written | ||
| 204 | + as several lines is still several lines here." | ||
| 205 | + [] | ||
| 206 | + (let [{:keys [nick actor]} (actions/viewing) | ||
| 207 | + ;; Reading both ticks subscribes this screen to the two fetches it is | ||
| 208 | + ;; waiting on: the profile itself, and the picture on it. | ||
| 209 | + _ (actions/profile-tick) | ||
| 210 | + _ @actions/media-tick | ||
| 211 | + pr (actions/profile-entry actor) | ||
| 212 | + ready? (= :ready (:status pr)) | ||
| 213 | + display (or (:display-name pr) nick) | ||
| 214 | + url (when ready? (actions/profile-web-url pr))] | ||
| 215 | + [:page {:max-width 520} | ||
| 216 | + [:hbox {:spacing 8} | ||
| 217 | + [:button {:label "← Back" :on-click actions/profile-close!}]] | ||
| 218 | + [:card {} | ||
| 219 | + [:hbox {:spacing 12} | ||
| 220 | + [:avatar {:label nick | ||
| 221 | + :src (or (actions/avatar-ready actor) "") | ||
| 222 | + :size 72}] | ||
| 223 | + [:vbox {:spacing 2} | ||
| 224 | + [:title-2 {:label display}] | ||
| 225 | + ;; The nick under the display name only when they differ — repeating | ||
| 226 | + ;; it is a line that says nothing. | ||
| 227 | + [:vbox {:key :nick} | ||
| 228 | + (when (not= display nick) [:dim-label {:label nick}])] | ||
| 229 | + [:vbox {:key :handle} | ||
| 230 | + (when-let [h (and ready? (not-empty (or (:handle pr) "")))] | ||
| 231 | + [:label {:label (str "@" h)}])]]] | ||
| 232 | + ;; The DID is the identity itself, and outlasts both the nick and the | ||
| 233 | + ;; handle — so it is on the screen, in full, rather than implied. | ||
| 234 | + [:vbox {:key :did :spacing 2} | ||
| 235 | + (when-let [did (:did pr)] [:dim-label {:label did}])] | ||
| 236 | + [:vbox {:key :bio :spacing 2} | ||
| 237 | + (when-let [bio (and ready? (:description pr))] | ||
| 238 | + (for [[i line] (map-indexed vector (str/split-lines (actions/profile-truncate bio 600)))] | ||
| 239 | + [:label {:key i :label line}]))] | ||
| 240 | + [:vbox {:key :stats} | ||
| 241 | + (when-let [line (and ready? (actions/profile-stats-line pr))] | ||
| 242 | + [:dim-label {:label line}])] | ||
| 243 | + ;; What is happening, or why nothing is: a guest has no identity to look | ||
| 244 | + ;; up, and saying so is a better answer than a spinner that never lands. | ||
| 245 | + [:vbox {:key :status :spacing 4} | ||
| 246 | + (cond | ||
| 247 | + (nil? actor) | ||
| 248 | + [:dim-label {:label "Guest — no Bluesky / AT Protocol identity"}] | ||
| 249 | + | ||
| 250 | + (= :loading (:status pr)) | ||
| 251 | + [:hbox {:spacing 8} | ||
| 252 | + [:spinner {}] | ||
| 253 | + [:dim-label {:label "Loading Bluesky profile…"}]] | ||
| 254 | + | ||
| 255 | + (= :failed (:status pr)) | ||
| 256 | + [:dim-label {:label "No Bluesky profile found"}])] | ||
| 257 | + [:vbox {:key :actions} | ||
| 258 | + (when url | ||
| 259 | + [:button {:label "Bluesky ↗" | ||
| 260 | + :kind :primary | ||
| 261 | + :on-click #(actions/open-url! url)}])]]])) | ||
| 262 | + | ||
| 263 | +(defn- no-chat-pane | ||
| 264 | + "What fills the second pane before a conversation has been picked. The pane | ||
| 265 | + is there either way — a list that widened into two columns and back again as | ||
| 266 | + channels were opened would be a worse answer than an empty half." | ||
| 267 | + [] | ||
| 268 | + [:vbox {:spacing 8 :margin 12} | ||
| 269 | + [:title {:label "frq"}] | ||
| 270 | + ;; With the list folded away there is nothing on the left to pick from, and | ||
| 271 | + ;; no conversation here carrying the switch that would bring it back — so | ||
| 272 | + ;; this pane carries it instead. Without that the reader who hid the list | ||
| 273 | + ;; and then closed the last room has put the app away, not the list. | ||
| 274 | + (if @cells/hide-chat-list? | ||
| 275 | + [:card {} | ||
| 276 | + [:dim-label {:label "The chats list is hidden."}] | ||
| 277 | + [:button {:label "☰ Chats" :kind :primary | ||
| 278 | + :on-click actions/toggle-chat-list!}]] | ||
| 279 | + [:card {} [:dim-label {:label "Pick a conversation on the left."}]])]) | ||
| 280 | + | ||
| 281 | +(defn split-screen | ||
| 282 | + "The chats list and the conversation side by side, for a window wide enough | ||
| 283 | + to hold both. | ||
| 284 | + | ||
| 285 | + Same components as the narrow layout, in a row instead of one at a time: the | ||
| 286 | + list keeps its own scroll and its own tab bar, and the conversation keeps the | ||
| 287 | + compose bar pinned under a backlog that scrolls on its own. | ||
| 288 | + | ||
| 289 | + Both panes take `:fill-height`: a column in a row is otherwise as tall as the | ||
| 290 | + row, which at the moment it is placed is one button — and the list and the | ||
| 291 | + message backlog both size themselves against the height they are handed. | ||
| 292 | + | ||
| 293 | + Only the list is given a width. The conversation takes what is left, rather | ||
| 294 | + than the window's width minus the list's: the list costs a little more than | ||
| 295 | + its 320 (its page has padding of its own), and a second column asking for | ||
| 296 | + more than remains is wrapped onto a row below — painting the whole | ||
| 297 | + conversation off the bottom of the window, which reads exactly like a | ||
| 298 | + conversation that has gone missing." | ||
| 299 | + [] | ||
| 300 | + ;; And the row itself takes the window, for the reason each narrow root | ||
| 301 | + ;; gives: a backend that sizes a box by what is in it hands two panes that | ||
| 302 | + ;; both asked for the rest of the screen the height of the taller one's | ||
| 303 | + ;; contents. Marking the children alone is not enough — what they fill is | ||
| 304 | + ;; whatever the row got. | ||
| 305 | + [:hbox {:spacing 0 :wrap false :fill-height true} | ||
| 306 | + ;; :expand :cross so the width-request is the width. Both panes fill the | ||
| 307 | + ;; height, and a pane that fills the height is also told to take a share | ||
| 308 | + ;; of the row's spare WIDTH — which two panes split between them, so the | ||
| 309 | + ;; list came out half the window with its cards clipped at the fold and | ||
| 310 | + ;; the conversation squeezed beside it. Down a row, :cross is the vertical | ||
| 311 | + ;; axis alone: the height without the share. The conversation keeps | ||
| 312 | + ;; :fill-height and so keeps the slack, which is what the note above says | ||
| 313 | + ;; it takes. | ||
| 314 | + ;; The list pane, when the reader has not put it away. Hidden, the wrapper | ||
| 315 | + ;; stays and empties: a child that vanished would renumber the row for the | ||
| 316 | + ;; reconciler and take the conversation's scroll position with it every | ||
| 317 | + ;; time the list was toggled — the same trick the people panel plays. | ||
| 318 | + ;; Empty, it asks for nothing: no width and no `:fill-height`, because a | ||
| 319 | + ;; column that fills the height is also a column that is there, and a | ||
| 320 | + ;; 320-point hole where the list was is not hiding it. | ||
| 321 | + (if @cells/hide-chat-list? | ||
| 322 | + [:vbox {:key :list}] | ||
| 323 | + [:vbox {:key :list :width-request sidebar-width :fill-height true | ||
| 324 | + :expand :cross} | ||
| 325 | + [chats-screen]]) | ||
| 326 | + [:vbox {:key :chat :fill-height true} | ||
| 327 | + (if @cells/current | ||
| 328 | + [chat-screen] | ||
| 329 | + [no-chat-pane])]]) | ||
| 330 | + | ||
| 331 | +(defn app [] | ||
| 332 | + ;; Every branch in its own keyed wrapper, and every key different. | ||
| 333 | + ;; | ||
| 334 | + ;; Without them the screens are all one position in the tree, and moving | ||
| 335 | + ;; between two of them is a diff of one screen's children against another's: | ||
| 336 | + ;; the reconciler matches what it can by position and patches the rest, which | ||
| 337 | + ;; leaves nodes from the screen you left standing in the one you arrived at — | ||
| 338 | + ;; the chats screen's join box turning up under its tab bar, an error box | ||
| 339 | + ;; from the conversation you were in a moment ago. A key that changes with | ||
| 340 | + ;; the screen makes the swap a swap: the old tree comes out whole and the new | ||
| 341 | + ;; one goes in whole. | ||
| 342 | + ;; And the dialog beside them all rather than instead of one of them. A | ||
| 343 | + ;; `dialog` node is not painted where it stands — the window backend hands | ||
| 344 | + ;; it to libcosmic, which puts it over the middle of the window with what is | ||
| 345 | + ;; behind it dimmed — so the screen under it keeps its place in the tree, | ||
| 346 | + ;; and its scroll position with it. The wrapper is always here and only its | ||
| 347 | + ;; child comes and goes, for the reason every other wrapper in this file | ||
| 348 | + ;; gives: a child that appeared and vanished would renumber the root. | ||
| 349 | + ;; | ||
| 350 | + ;; The terminal has no such thing, and a `dialog` tag it has not grown would | ||
| 351 | + ;; paint its contents inline at the bottom of the screen. So there it stays | ||
| 352 | + ;; a screen you go to and come back from, which is `profile-screen`. | ||
| 353 | + [:vbox {:key :root :fill-height true} | ||
| 354 | + ;; One dialog at a time, and a pinned profile outranks both pointers: it is | ||
| 355 | + ;; the only one of the three that was asked for by a press rather than by | ||
| 356 | + ;; where the pointer happens to be resting. | ||
| 357 | + [:vbox {:key :dialog} | ||
| 358 | + (when-not @terminal? | ||
| 359 | + (cond | ||
| 360 | + (or (actions/viewing) (actions/hovering)) [profile-dialog] | ||
| 361 | + @cells/reaction-hover [reactor-dialog]))] | ||
| 362 | + (cond | ||
| 363 | + @cells/lightbox [:vbox {:key :screen-lightbox} [lightbox-screen]] | ||
| 364 | + (and @terminal? (actions/viewing)) | ||
| 365 | + [:vbox {:key :screen-profile} [profile-screen]] | ||
| 366 | + @cells/image-picker [:vbox {:key :screen-picker} [image-picker-screen]] | ||
| 367 | + ;; Wide enough for both, and on one of the two screens that are halves of | ||
| 368 | + ;; the same thing: the list and the conversation it opens. Discover and | ||
| 369 | + ;; settings stay whole screens — they are somewhere else, not the other | ||
| 370 | + ;; half of here. | ||
| 371 | + (and (actions/wide?) (contains? #{:chats :chat} @cells/screen)) | ||
| 372 | + [:vbox {:key :screen-split} [split-screen]] | ||
| 373 | + :else (case @cells/screen | ||
| 374 | + :connect [:vbox {:key :screen-connect} [connect-screen]] | ||
| 375 | + :chat [:vbox {:key :screen-chat} [chat-screen]] | ||
| 376 | + :discover [:vbox {:key :screen-discover} [discover-screen]] | ||
| 377 | + :settings [:vbox {:key :screen-settings} [settings-screen]] | ||
| 378 | + [:vbox {:key :screen-chats} [chats-screen]]))]) | ||
modified
flutter/README.md +9 -5 | @@ -135,13 +135,17 @@ screen rather than the subtree that read it. Fine at this size. | ||
| 135 | 135 | reads is `frq.cells` and what it calls is `frq.actions`, and each platform |
| 136 | 136 | fills those in: `frq.state`'s reducers on the desktop, dart:io here. |
| 137 | 137 | |
| 138 | -## Five screens shared, all drawn on both | |
| 138 | +## Every screen shared, and the root that picks between them | |
| 139 | 139 | |
| 140 | -`frq.screens.connect`, `frq.screens.chats`, `frq.screens.chat` and | |
| 141 | -`frq.screens.settings` — the last carrying Discover, Settings and the frame the | |
| 142 | -tab bar sits in — are in `common/`, with the cells under them in `frq.cells`, the derivations in | |
| 140 | +All of them are in `common/frq/screens/` now — `connect`, `chats`, `chat`, | |
| 141 | +`settings` (with Discover and the tab frame) and `app`, which carries the | |
| 142 | +split view, the three dialogs and the decision about which screen is showing. | |
| 143 | +The phone renders `[screens/app]` and nothing else; it was switching by hand | |
| 144 | +until that moved. With the cells under them in `frq.cells`, with the cells under them in `frq.cells`, the derivations in | |
| 143 | 145 | `frq.rooms`, the backend metrics in `frq.metrics` and everything a screen |
| 144 | -cannot do itself behind `frq.actions`. `frq.app` is 565 lines and was 1,744. | |
| 146 | +cannot do itself behind `frq.actions`. `frq.app` is 226 lines and was 1,744. What is left in it is the part that | |
| 147 | +cannot move: `derived` and the two asset lookups, which are a glimmer reaction | |
| 148 | +over a fetch-and-cache, the metrics aliases `frq.tui` writes, and `start!`. | |
| 145 | 149 | |
| 146 | 150 | What `Length::Fill` means took four goes to get right, and the rule it ended |
| 147 | 151 | at is worth stating once: a child that fills is Flutter's `Expanded`, the |
| @@ -135,13 +135,17 @@ 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 platform | 135 | 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 | -## Five screens shared, all drawn on both | 138 | +## Every screen shared, and the root that picks between them |
| 139 | 139 | ||
| 140 | -`frq.screens.connect`, `frq.screens.chats`, `frq.screens.chat` and | 140 | +All of them are in `common/frq/screens/` now — `connect`, `chats`, `chat`, |
| 141 | -`frq.screens.settings` — the last carrying Discover, Settings and the frame the | 141 | +`settings` (with Discover and the tab frame) and `app`, which carries the |
| 142 | -tab bar sits in — are in `common/`, with the cells under them in `frq.cells`, the derivations in | 142 | +split view, the three dialogs and the decision about which screen is showing. |
| 143 | +The phone renders `[screens/app]` and nothing else; it was switching by hand | ||
| 144 | +until that moved. With the cells under them in `frq.cells`, with the cells under them in `frq.cells`, the derivations in | ||
| 143 | `frq.rooms`, the backend metrics in `frq.metrics` and everything a screen | 145 | `frq.rooms`, the backend metrics in `frq.metrics` and everything a screen |
| 144 | -cannot do itself behind `frq.actions`. `frq.app` is 565 lines and was 1,744. | 146 | +cannot do itself behind `frq.actions`. `frq.app` is 226 lines and was 1,744. What is left in it is the part that |
| 147 | +cannot move: `derived` and the two asset lookups, which are a glimmer reaction | ||
| 148 | +over a fetch-and-cache, the metrics aliases `frq.tui` writes, and `start!`. | ||
| 145 | 149 | ||
| 146 | What `Length::Fill` means took four goes to get right, and the rule it ended | 150 | What `Length::Fill` means took four goes to get right, and the rule it ended |
| 147 | at is worth stating once: a child that fills is Flutter's `Expanded`, the | 151 | at is worth stating once: a child that fills is Flutter's `Expanded`, the |
modified
flutter/src/frq/hiccup.cljd +20 -7 | @@ -81,6 +81,18 @@ | ||
| 81 | 81 | |
| 82 | 82 | (declare render fills-row?) |
| 83 | 83 | |
| 84 | +(defn- expand | |
| 85 | + "A component vector reduced to the tags it produces. | |
| 86 | + | |
| 87 | + `[chat-screen]` is a function, and asking whether it fills means calling it — | |
| 88 | + the root wraps every screen in a plain `:vbox`, and without this the wrapper | |
| 89 | + could not see that the screen inside it wants the whole height. Expanded | |
| 90 | + once and both asked and rendered, so the component runs once either way." | |
| 91 | + [node] | |
| 92 | + (if (and (vector? node) (seq node) (not (keyword? (first node)))) | |
| 93 | + (expand (apply (first node) (rest node))) | |
| 94 | + node)) | |
| 95 | + | |
| 84 | 96 | (defn- fills-row? |
| 85 | 97 | "Whether a node takes the width its row has left over. |
| 86 | 98 | |
| @@ -159,7 +171,7 @@ | ||
| 159 | 171 | ;; so the row must be given a height before the column can take |
| 160 | 172 | ;; what is left of it. |
| 161 | 173 | (and (contains? #{:vbox :card :hbox} tag) |
| 162 | - (boolean (some fills-column? (body node)))))))) | |
| 174 | + (boolean (some #(fills-column? (expand %)) (body node)))))))) | |
| 163 | 175 | |
| 164 | 176 | (defn- children |
| 165 | 177 | "Flatten seqs, drop nils. `(for [...] ...)` in a component yields a seq in |
| @@ -180,7 +192,8 @@ | ||
| 180 | 192 | `fills?` says which, so a Row asks about width and a Column about height." |
| 181 | 193 | [fills? nodes] |
| 182 | 194 | (persistent! |
| 183 | - (reduce (fn [acc n] | |
| 195 | + (reduce (fn [acc n0] | |
| 196 | + (let [n (expand n0)] | |
| 184 | 197 | (cond (nil? n) acc |
| 185 | 198 | (seq? n) (reduce conj! acc (flexed fills? n)) |
| 186 | 199 | (fills? n) (conj! acc (m/Expanded .child (render n))) |
| @@ -193,7 +206,7 @@ | ||
| 193 | 206 | (conj! acc (m/Expanded .child (render n))) |
| 194 | 207 | (and (identical? fills? fills-row?) (flexes-in-row? n)) |
| 195 | 208 | (conj! acc (m/Flexible .child (render n))) |
| 196 | - :else (conj! acc (render n)))) | |
| 209 | + :else (conj! acc (render n))))) | |
| 197 | 210 | (transient []) nodes))) |
| 198 | 211 | |
| 199 | 212 | (defn- txt |
| @@ -258,7 +271,7 @@ | ||
| 258 | 271 | ;; and a `:scroll` handing their children unbounded space. |
| 259 | 272 | col (fn [sp nodes] |
| 260 | 273 | (m/Column .crossAxisAlignment m/CrossAxisAlignment.start |
| 261 | - .mainAxisSize (if (some fills-column? nodes) | |
| 274 | + .mainAxisSize (if (some #(fills-column? (expand %)) nodes) | |
| 262 | 275 | m/MainAxisSize.max |
| 263 | 276 | m/MainAxisSize.min) |
| 264 | 277 | .spacing sp |
| @@ -273,7 +286,7 @@ | ||
| 273 | 286 | ;; Max when something inside wants the space left over: a |
| 274 | 287 | ;; column sized to its contents has none to give. |
| 275 | 288 | .mainAxisSize (if (or (:fill-height p) |
| 276 | - (some fills-column? (body node))) | |
| 289 | + (some #(fills-column? (expand %)) (body node))) | |
| 277 | 290 | m/MainAxisSize.max |
| 278 | 291 | m/MainAxisSize.min) |
| 279 | 292 | .spacing (dbl (:spacing p) 0.0) |
| @@ -320,12 +333,12 @@ | ||
| 320 | 333 | ;; Stretch when a child fills vertically: a Row hands its children a |
| 321 | 334 | ;; loose height by default, and a column that wants what is left of it |
| 322 | 335 | ;; needs a tight one. |
| 323 | - .crossAxisAlignment (if (some fills-column? (body node)) | |
| 336 | + .crossAxisAlignment (if (some #(fills-column? (expand %)) (body node)) | |
| 324 | 337 | m/CrossAxisAlignment.stretch |
| 325 | 338 | m/CrossAxisAlignment.center) |
| 326 | 339 | ;; A row holding something that fills has to be given the width to |
| 327 | 340 | ;; divide, so it is max rather than min whenever a child asks. |
| 328 | - .mainAxisSize (if (some fills-row? (body node)) | |
| 341 | + .mainAxisSize (if (some #(fills-row? (expand %)) (body node)) | |
| 329 | 342 | m/MainAxisSize.max |
| 330 | 343 | m/MainAxisSize.min) |
| 331 | 344 | .spacing (dbl (:spacing p) 0.0) |
| @@ -81,6 +81,18 @@ | |||
| 81 | 81 | ||
| 82 | (declare render fills-row?) | 82 | (declare render fills-row?) |
| 83 | 83 | ||
| 84 | +(defn- expand | ||
| 85 | + "A component vector reduced to the tags it produces. | ||
| 86 | + | ||
| 87 | + `[chat-screen]` is a function, and asking whether it fills means calling it — | ||
| 88 | + the root wraps every screen in a plain `:vbox`, and without this the wrapper | ||
| 89 | + could not see that the screen inside it wants the whole height. Expanded | ||
| 90 | + once and both asked and rendered, so the component runs once either way." | ||
| 91 | + [node] | ||
| 92 | + (if (and (vector? node) (seq node) (not (keyword? (first node)))) | ||
| 93 | + (expand (apply (first node) (rest node))) | ||
| 94 | + node)) | ||
| 95 | + | ||
| 84 | (defn- fills-row? | 96 | (defn- fills-row? |
| 85 | "Whether a node takes the width its row has left over. | 97 | "Whether a node takes the width its row has left over. |
| 86 | 98 | ||
| @@ -159,7 +171,7 @@ | |||
| 159 | ;; so the row must be given a height before the column can take | 171 | ;; so the row must be given a height before the column can take |
| 160 | ;; what is left of it. | 172 | ;; what is left of it. |
| 161 | (and (contains? #{:vbox :card :hbox} tag) | 173 | (and (contains? #{:vbox :card :hbox} tag) |
| 162 | - (boolean (some fills-column? (body node)))))))) | 174 | + (boolean (some #(fills-column? (expand %)) (body node)))))))) |
| 163 | 175 | ||
| 164 | (defn- children | 176 | (defn- children |
| 165 | "Flatten seqs, drop nils. `(for [...] ...)` in a component yields a seq in | 177 | "Flatten seqs, drop nils. `(for [...] ...)` in a component yields a seq in |
| @@ -180,7 +192,8 @@ | |||
| 180 | `fills?` says which, so a Row asks about width and a Column about height." | 192 | `fills?` says which, so a Row asks about width and a Column about height." |
| 181 | [fills? nodes] | 193 | [fills? nodes] |
| 182 | (persistent! | 194 | (persistent! |
| 183 | - (reduce (fn [acc n] | 195 | + (reduce (fn [acc n0] |
| 196 | + (let [n (expand n0)] | ||
| 184 | (cond (nil? n) acc | 197 | (cond (nil? n) acc |
| 185 | (seq? n) (reduce conj! acc (flexed fills? n)) | 198 | (seq? n) (reduce conj! acc (flexed fills? n)) |
| 186 | (fills? n) (conj! acc (m/Expanded .child (render n))) | 199 | (fills? n) (conj! acc (m/Expanded .child (render n))) |
| @@ -193,7 +206,7 @@ | |||
| 193 | (conj! acc (m/Expanded .child (render n))) | 206 | (conj! acc (m/Expanded .child (render n))) |
| 194 | (and (identical? fills? fills-row?) (flexes-in-row? n)) | 207 | (and (identical? fills? fills-row?) (flexes-in-row? n)) |
| 195 | (conj! acc (m/Flexible .child (render n))) | 208 | (conj! acc (m/Flexible .child (render n))) |
| 196 | - :else (conj! acc (render n)))) | 209 | + :else (conj! acc (render n))))) |
| 197 | (transient []) nodes))) | 210 | (transient []) nodes))) |
| 198 | 211 | ||
| 199 | (defn- txt | 212 | (defn- txt |
| @@ -258,7 +271,7 @@ | |||
| 258 | ;; and a `:scroll` handing their children unbounded space. | 271 | ;; and a `:scroll` handing their children unbounded space. |
| 259 | col (fn [sp nodes] | 272 | col (fn [sp nodes] |
| 260 | (m/Column .crossAxisAlignment m/CrossAxisAlignment.start | 273 | (m/Column .crossAxisAlignment m/CrossAxisAlignment.start |
| 261 | - .mainAxisSize (if (some fills-column? nodes) | 274 | + .mainAxisSize (if (some #(fills-column? (expand %)) nodes) |
| 262 | m/MainAxisSize.max | 275 | m/MainAxisSize.max |
| 263 | m/MainAxisSize.min) | 276 | m/MainAxisSize.min) |
| 264 | .spacing sp | 277 | .spacing sp |
| @@ -273,7 +286,7 @@ | |||
| 273 | ;; Max when something inside wants the space left over: a | 286 | ;; Max when something inside wants the space left over: a |
| 274 | ;; column sized to its contents has none to give. | 287 | ;; column sized to its contents has none to give. |
| 275 | .mainAxisSize (if (or (:fill-height p) | 288 | .mainAxisSize (if (or (:fill-height p) |
| 276 | - (some fills-column? (body node))) | 289 | + (some #(fills-column? (expand %)) (body node))) |
| 277 | m/MainAxisSize.max | 290 | m/MainAxisSize.max |
| 278 | m/MainAxisSize.min) | 291 | m/MainAxisSize.min) |
| 279 | .spacing (dbl (:spacing p) 0.0) | 292 | .spacing (dbl (:spacing p) 0.0) |
| @@ -320,12 +333,12 @@ | |||
| 320 | ;; Stretch when a child fills vertically: a Row hands its children a | 333 | ;; 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 | 334 | ;; loose height by default, and a column that wants what is left of it |
| 322 | ;; needs a tight one. | 335 | ;; needs a tight one. |
| 323 | - .crossAxisAlignment (if (some fills-column? (body node)) | 336 | + .crossAxisAlignment (if (some #(fills-column? (expand %)) (body node)) |
| 324 | m/CrossAxisAlignment.stretch | 337 | m/CrossAxisAlignment.stretch |
| 325 | m/CrossAxisAlignment.center) | 338 | m/CrossAxisAlignment.center) |
| 326 | ;; A row holding something that fills has to be given the width to | 339 | ;; A row holding something that fills has to be given the width to |
| 327 | ;; divide, so it is max rather than min whenever a child asks. | 340 | ;; divide, so it is max rather than min whenever a child asks. |
| 328 | - .mainAxisSize (if (some fills-row? (body node)) | 341 | + .mainAxisSize (if (some #(fills-row? (expand %)) (body node)) |
| 329 | m/MainAxisSize.max | 342 | m/MainAxisSize.max |
| 330 | m/MainAxisSize.min) | 343 | m/MainAxisSize.min) |
| 331 | .spacing (dbl (:spacing p) 0.0) | 344 | .spacing (dbl (:spacing p) 0.0) |
modified
flutter/src/frq/main.cljd +5 -7 | @@ -33,6 +33,7 @@ | ||
| 33 | 33 | [frq.screens.chats :as chats] |
| 34 | 34 | [frq.screens.chat :as chat] |
| 35 | 35 | [frq.screens.settings :as settings] |
| 36 | + [frq.screens.app :as screens] | |
| 36 | 37 | [frq.rooms :as rooms] |
| 37 | 38 | [frq.irc.parse :as irc])) |
| 38 | 39 | |
| @@ -331,10 +332,7 @@ | ||
| 331 | 332 | ;; `:vbox :fill-height` wants the bounded height the Scaffold gives it — |
| 332 | 333 | ;; wrapping the tree took that away, and `:scroll`'s Expanded then sat |
| 333 | 334 | ;; in an unbounded column. |
| 334 | - (h/render (case @cells/screen | |
| 335 | - :connect [connect/connect-screen] | |
| 336 | - :chats [chats/chats-screen] | |
| 337 | - :chat [chat/chat-screen] | |
| 338 | - :discover [settings/discover-screen] | |
| 339 | - :settings [settings/settings-screen] | |
| 340 | - [connected-screen])))))) | |
| 335 | + (h/render | |
| 336 | + ;; `frq.screens.app` decides which screen shows, the same way it does | |
| 337 | + ;; on the desktop. The phone was switching by hand until this moved. | |
| 338 | + [screens/app]))))) | |
| @@ -33,6 +33,7 @@ | |||
| 33 | [frq.screens.chats :as chats] | 33 | [frq.screens.chats :as chats] |
| 34 | [frq.screens.chat :as chat] | 34 | [frq.screens.chat :as chat] |
| 35 | [frq.screens.settings :as settings] | 35 | [frq.screens.settings :as settings] |
| 36 | + [frq.screens.app :as screens] | ||
| 36 | [frq.rooms :as rooms] | 37 | [frq.rooms :as rooms] |
| 37 | [frq.irc.parse :as irc])) | 38 | [frq.irc.parse :as irc])) |
| 38 | 39 | ||
| @@ -331,10 +332,7 @@ | |||
| 331 | ;; `:vbox :fill-height` wants the bounded height the Scaffold gives it — | 332 | ;; `:vbox :fill-height` wants the bounded height the Scaffold gives it — |
| 332 | ;; wrapping the tree took that away, and `:scroll`'s Expanded then sat | 333 | ;; wrapping the tree took that away, and `:scroll`'s Expanded then sat |
| 333 | ;; in an unbounded column. | 334 | ;; in an unbounded column. |
| 334 | - (h/render (case @cells/screen | 335 | + (h/render |
| 335 | - :connect [connect/connect-screen] | 336 | + ;; `frq.screens.app` decides which screen shows, the same way it does |
| 336 | - :chats [chats/chats-screen] | 337 | + ;; on the desktop. The phone was switching by hand until this moved. |
| 337 | - :chat [chat/chat-screen] | 338 | + [screens/app]))))) |
| 338 | - :discover [settings/discover-screen] | ||
| 339 | - :settings [settings/settings-screen] | ||
| 340 | - [connected-screen])))))) | ||
modified
src/frq/app.clj +15 -354 | @@ -37,6 +37,7 @@ | ||
| 37 | 37 | ;; phone renders this very file. |
| 38 | 38 | [frq.screens.chats :refer [below-list chats-screen conversation-row |
| 39 | 39 | preview-line tab-bar]] |
| 40 | + [frq.screens.app :as screens] | |
| 40 | 41 | [frq.screens.settings :refer [discover-screen settings-screen |
| 41 | 42 | tab-screen]] |
| 42 | 43 | [frq.screens.chat :refer [chat-screen emoji-picker message-row |
| @@ -112,367 +113,14 @@ | ||
| 112 | 113 | |
| 113 | 114 | ;; ---------------------------------------------------------------- chat |
| 114 | 115 | |
| 115 | -(defn- profile-dialog | |
| 116 | - "Who someone is, as libcosmic's own dialog: centred over the window, with | |
| 117 | - what you were reading dimmed behind it rather than replaced. | |
| 118 | - | |
| 119 | - The window can do this and the terminal cannot, which is the whole reason | |
| 120 | - there are two of these. `profile-screen` below is the terminal's, and says | |
| 121 | - why it is a screen. | |
| 122 | - | |
| 123 | - Both buttons are always here, the Bluesky one insensitive until there is a | |
| 124 | - profile to open: a dialog whose second button appears a moment after it | |
| 125 | - opens is a dialog that moves under the pointer, and the fetch lands whenever | |
| 126 | - it lands. | |
| 127 | - | |
| 128 | - Who it is about comes from either of two places, and that — with the | |
| 129 | - modality below — is the whole of what hovering and pressing a face do | |
| 130 | - differently. Resting on one sets `hovering`, which the pointer takes away | |
| 131 | - again when it leaves; pressing one sets `viewing`, which nothing takes away | |
| 132 | - but Close. `viewing` is read first, so a pinned profile is not swapped out | |
| 133 | - from under the reader by a face the pointer crosses on the way to it. | |
| 134 | - | |
| 135 | - And a dialog the pointer is holding open is not modal. A modal one makes | |
| 136 | - the window underneath it deaf — libcosmic wraps the app in a popover that | |
| 137 | - hands its content an `Unavailable` cursor while a popup is up — so the face | |
| 138 | - that opened it never hears the pointer leave, and what a hover opened could | |
| 139 | - never close itself. Non-modal, the face keeps hearing, and moving away shuts | |
| 140 | - it. A pinned one is modal, which is what being pinned means: it is the thing | |
| 141 | - on the screen until it is dismissed." | |
| 142 | - [] | |
| 143 | - (let [pinned? (some? @profile/viewing) | |
| 144 | - {:keys [nick actor]} (or @profile/viewing @profile/hovering) | |
| 145 | - _ @profile/tick | |
| 146 | - _ @s/media-tick | |
| 147 | - pr (profile/entry actor) | |
| 148 | - ready? (= :ready (:status pr)) | |
| 149 | - display (or (:display-name pr) nick) | |
| 150 | - url (when ready? (profile/web-url pr))] | |
| 151 | - [:dialog {:label display :max-width 520 :modal pinned? | |
| 152 | - :on-hover profile/enter-dialog! | |
| 153 | - :on-unhover profile/leave-dialog!} | |
| 154 | - ;; The body is the screen's card without its heading: the dialog's own | |
| 155 | - ;; title is the name now, so repeating it under the picture is a line | |
| 156 | - ;; that says nothing. | |
| 157 | - [:vbox {:key :body :spacing 8} | |
| 158 | - [:hbox {:spacing 12} | |
| 159 | - [:avatar {:label nick | |
| 160 | - :src (or (avatars/path-when-ready actor) "") | |
| 161 | - :size 72}] | |
| 162 | - [:vbox {:spacing 2} | |
| 163 | - [:vbox {:key :nick} | |
| 164 | - (when (not= display nick) [:dim-label {:label nick}])] | |
| 165 | - [:vbox {:key :handle} | |
| 166 | - (when-let [h (and ready? (not-empty (or (:handle pr) "")))] | |
| 167 | - [:label {:label (str "@" h)}])] | |
| 168 | - [:vbox {:key :did} | |
| 169 | - (when-let [did (:did pr)] [:dim-label {:label did}])]]] | |
| 170 | - [:vbox {:key :bio :spacing 2} | |
| 171 | - (when-let [bio (and ready? (:description pr))] | |
| 172 | - (for [[i line] (map-indexed vector (str/split-lines (profile/truncate bio 600)))] | |
| 173 | - [:label {:key i :label line}]))] | |
| 174 | - [:vbox {:key :stats} | |
| 175 | - (when-let [line (and ready? (profile/stats-line pr))] | |
| 176 | - [:dim-label {:label line}])] | |
| 177 | - [:vbox {:key :status :spacing 4} | |
| 178 | - (cond | |
| 179 | - (nil? actor) | |
| 180 | - [:dim-label {:label "Guest — no Bluesky / AT Protocol identity"}] | |
| 181 | - | |
| 182 | - (= :loading (:status pr)) | |
| 183 | - [:hbox {:spacing 8} | |
| 184 | - [:spinner {}] | |
| 185 | - [:dim-label {:label "Loading Bluesky profile…"}]] | |
| 186 | - | |
| 187 | - (= :failed (:status pr)) | |
| 188 | - [:dim-label {:label "No Bluesky profile found"}])] | |
| 189 | -] | |
| 190 | - ;; `slot` is where libcosmic puts a button: the two actions go to the | |
| 191 | - ;; foot of the dialog, and anything else here would be another control | |
| 192 | - ;; stacked in the body. | |
| 193 | - ;; | |
| 194 | - ;; Both are here whether the profile is pinned or only hovered. They were | |
| 195 | - ;; hidden while hovering, back when a hover could not be walked into: the | |
| 196 | - ;; dialog reports its own pointer now, so moving towards a button in it | |
| 197 | - ;; keeps it open instead of closing it, and a button you can reach is a | |
| 198 | - ;; button worth drawing. | |
| 199 | - [:button {:key :close :slot "primary" | |
| 200 | - :label (if pinned? "Close" "Dismiss") | |
| 201 | - :on-click profile/dismiss!}] | |
| 202 | - [:button {:key :web :slot "secondary" :label "Bluesky ↗" | |
| 203 | - :sensitive (boolean url) | |
| 204 | - :on-click #(when url (platform/open-url! url))}]])) | |
| 205 | - | |
| 206 | -(defn profile-screen | |
| 207 | - "Who someone is: sleek's peer profile modal, as a screen. | |
| 208 | - | |
| 209 | - A screen rather than a layer for the same reason the lightbox is one — the | |
| 210 | - tree backend paints in a single layer, with no z-order to hang a modal from. | |
| 211 | - So the back row does what sleek's ✕ and backdrop did. | |
| 212 | - | |
| 213 | - The picture is the same cached file the chat column draws, at a size worth | |
| 214 | - looking at; the bio is split into a label a line, so a bio that was written | |
| 215 | - as several lines is still several lines here." | |
| 216 | - [] | |
| 217 | - (let [{:keys [nick actor]} @profile/viewing | |
| 218 | - ;; Reading both ticks subscribes this screen to the two fetches it is | |
| 219 | - ;; waiting on: the profile itself, and the picture on it. | |
| 220 | - _ @profile/tick | |
| 221 | - _ @s/media-tick | |
| 222 | - pr (profile/entry actor) | |
| 223 | - ready? (= :ready (:status pr)) | |
| 224 | - display (or (:display-name pr) nick) | |
| 225 | - url (when ready? (profile/web-url pr))] | |
| 226 | - [:page {:max-width 520} | |
| 227 | - [:hbox {:spacing 8} | |
| 228 | - [:button {:label "← Back" :on-click profile/close!}]] | |
| 229 | - [:card {} | |
| 230 | - [:hbox {:spacing 12} | |
| 231 | - [:avatar {:label nick | |
| 232 | - :src (or (avatars/path-when-ready actor) "") | |
| 233 | - :size 72}] | |
| 234 | - [:vbox {:spacing 2} | |
| 235 | - [:title-2 {:label display}] | |
| 236 | - ;; The nick under the display name only when they differ — repeating | |
| 237 | - ;; it is a line that says nothing. | |
| 238 | - [:vbox {:key :nick} | |
| 239 | - (when (not= display nick) [:dim-label {:label nick}])] | |
| 240 | - [:vbox {:key :handle} | |
| 241 | - (when-let [h (and ready? (not-empty (or (:handle pr) "")))] | |
| 242 | - [:label {:label (str "@" h)}])]]] | |
| 243 | - ;; The DID is the identity itself, and outlasts both the nick and the | |
| 244 | - ;; handle — so it is on the screen, in full, rather than implied. | |
| 245 | - [:vbox {:key :did :spacing 2} | |
| 246 | - (when-let [did (:did pr)] [:dim-label {:label did}])] | |
| 247 | - [:vbox {:key :bio :spacing 2} | |
| 248 | - (when-let [bio (and ready? (:description pr))] | |
| 249 | - (for [[i line] (map-indexed vector (str/split-lines (profile/truncate bio 600)))] | |
| 250 | - [:label {:key i :label line}]))] | |
| 251 | - [:vbox {:key :stats} | |
| 252 | - (when-let [line (and ready? (profile/stats-line pr))] | |
| 253 | - [:dim-label {:label line}])] | |
| 254 | - ;; What is happening, or why nothing is: a guest has no identity to look | |
| 255 | - ;; up, and saying so is a better answer than a spinner that never lands. | |
| 256 | - [:vbox {:key :status :spacing 4} | |
| 257 | - (cond | |
| 258 | - (nil? actor) | |
| 259 | - [:dim-label {:label "Guest — no Bluesky / AT Protocol identity"}] | |
| 260 | - | |
| 261 | - (= :loading (:status pr)) | |
| 262 | - [:hbox {:spacing 8} | |
| 263 | - [:spinner {}] | |
| 264 | - [:dim-label {:label "Loading Bluesky profile…"}]] | |
| 265 | - | |
| 266 | - (= :failed (:status pr)) | |
| 267 | - [:dim-label {:label "No Bluesky profile found"}])] | |
| 268 | - [:vbox {:key :actions} | |
| 269 | - (when url | |
| 270 | - [:button {:label "Bluesky ↗" | |
| 271 | - :kind :primary | |
| 272 | - :on-click #(platform/open-url! url)}])]]])) | |
| 273 | - | |
| 274 | -(defn lightbox-screen | |
| 275 | - "One picture, as big as the window will paint it. | |
| 276 | - | |
| 277 | - A screen rather than an overlay: the tree backend paints in one layer and has | |
| 278 | - no z-order to put something on top of everything else with. So the picture | |
| 279 | - takes the window — `:fit` gives it every point below the one row that is not | |
| 280 | - it, centred and in proportion, scaled up as readily as down. | |
| 281 | - | |
| 282 | - That row is the way back. Clicking the picture closes it too — the same | |
| 283 | - gesture that opened it — but a way out you have to guess at is not one, and | |
| 284 | - the row costs the picture a line." | |
| 285 | - [] | |
| 286 | - (let [{:keys [path]} @s/lightbox] | |
| 287 | - [:vbox {:spacing 4 :margin 4} | |
| 288 | - [:hbox {:spacing 8} | |
| 289 | - [:button {:label "← Back" :on-click #(reset! s/lightbox nil)}]] | |
| 290 | - [:image {:src path :fit true :on-click #(reset! s/lightbox nil)}]])) | |
| 291 | - | |
| 292 | -(defn image-picker-screen | |
| 293 | - "The pictures on this device, to send one of. | |
| 294 | - | |
| 295 | - A screen rather than a panel over the compose bar: choosing a file is | |
| 296 | - browsing, and browsing wants the window — the backend paints in one layer | |
| 297 | - anyway, so there is no overlay to put it in. | |
| 298 | - | |
| 299 | - A directory at a time, PNG only. Which directories there are to start from is | |
| 300 | - the platform's answer, not this screen's: a desktop opens on ~/Pictures, and | |
| 301 | - a phone on what the app can read without a permission it has no code to ask | |
| 302 | - for, which may be very little. Either way the list says what it found." | |
| 303 | - [] | |
| 304 | - (let [dir @s/image-picker | |
| 305 | - {:keys [dirs files]} (s/picker-entries dir) | |
| 306 | - up (s/parent-dir dir)] | |
| 307 | - [:vbox {:spacing 8 :margin 12} | |
| 308 | - [:hbox {:spacing 8} | |
| 309 | - [:button {:label "← Back" :on-click s/close-image-picker!}] | |
| 310 | - [:title {:label "Send a picture"}]] | |
| 311 | - [:dim-label {:label (str dir)}] | |
| 312 | - [error-note] | |
| 313 | - ;; The places worth starting from, always in reach: browsing into a corner | |
| 314 | - ;; of the filesystem should not cost the way back to the pictures folder. | |
| 315 | - [:hbox {:key :roots :spacing 6} | |
| 316 | - (for [root (s/picker-roots)] | |
| 317 | - [:button {:key root | |
| 318 | - :label (or (last (str/split root #"/")) root) | |
| 319 | - :kind (if (= root dir) :primary :default) | |
| 320 | - :on-click #(s/browse! root)}])] | |
| 321 | - [:separator {}] | |
| 322 | - [:scroll {:scroll-key "image-picker" | |
| 323 | - :orientation :vertical | |
| 324 | - :reserve 40} | |
| 325 | - [:vbox {:spacing 6} | |
| 326 | - [:vbox {:key :up} | |
| 327 | - (when up | |
| 328 | - [:button {:label "⬆ Up" :on-click #(s/browse! up)}])] | |
| 329 | - [:vbox {:key :dirs :spacing 4} | |
| 330 | - (for [d dirs] | |
| 331 | - [:button {:key d | |
| 332 | - :label (str "📁 " (last (str/split d #"/"))) | |
| 333 | - :on-click #(s/browse! d)}])] | |
| 334 | - ;; The picture itself is the button: a filename is not what anyone is | |
| 335 | - ;; choosing between, and a thumbnail answers "is this the one" in a way | |
| 336 | - ;; no name does. | |
| 337 | - [:vbox {:key :files :spacing 6} | |
| 338 | - (for [f files] | |
| 339 | - [:hbox {:key f :spacing 8} | |
| 340 | - [:image {:src f :max-height 72 :on-click #(s/pick-image! f)}] | |
| 341 | - [:button {:label (last (str/split f #"/")) | |
| 342 | - :on-click #(s/pick-image! f)}]])] | |
| 343 | - [:vbox {:key :empty} | |
| 344 | - (when (and (empty? dirs) (empty? files)) | |
| 345 | - [:dim-label {:label "No pictures here that this app can read."}])]]]])) | |
| 346 | - | |
| 347 | -;; What the people panel asks for, and what the conversation beside it has to | |
| 348 | -;; be told to leave. A column with `:fill-height` and no width takes the whole | |
| 349 | -;; row — so the panel is only ever on screen if the message list is given a | |
| 350 | -;; width that stops short of it. | |
| 351 | 116 | ;; ---------------------------------------------------------------- split |
| 352 | 117 | |
| 353 | -(defn- no-chat-pane | |
| 354 | - "What fills the second pane before a conversation has been picked. The pane | |
| 355 | - is there either way — a list that widened into two columns and back again as | |
| 356 | - channels were opened would be a worse answer than an empty half." | |
| 357 | - [] | |
| 358 | - [:vbox {:spacing 8 :margin 12} | |
| 359 | - [:title {:label "frq"}] | |
| 360 | - ;; With the list folded away there is nothing on the left to pick from, and | |
| 361 | - ;; no conversation here carrying the switch that would bring it back — so | |
| 362 | - ;; this pane carries it instead. Without that the reader who hid the list | |
| 363 | - ;; and then closed the last room has put the app away, not the list. | |
| 364 | - (if @s/hide-chat-list? | |
| 365 | - [:card {} | |
| 366 | - [:dim-label {:label "The chats list is hidden."}] | |
| 367 | - [:button {:label "☰ Chats" :kind :primary | |
| 368 | - :on-click s/toggle-chat-list!}]] | |
| 369 | - [:card {} [:dim-label {:label "Pick a conversation on the left."}]])]) | |
| 370 | - | |
| 371 | -(defn split-screen | |
| 372 | - "The chats list and the conversation side by side, for a window wide enough | |
| 373 | - to hold both. | |
| 374 | - | |
| 375 | - Same components as the narrow layout, in a row instead of one at a time: the | |
| 376 | - list keeps its own scroll and its own tab bar, and the conversation keeps the | |
| 377 | - compose bar pinned under a backlog that scrolls on its own. | |
| 378 | - | |
| 379 | - Both panes take `:fill-height`: a column in a row is otherwise as tall as the | |
| 380 | - row, which at the moment it is placed is one button — and the list and the | |
| 381 | - message backlog both size themselves against the height they are handed. | |
| 382 | - | |
| 383 | - Only the list is given a width. The conversation takes what is left, rather | |
| 384 | - than the window's width minus the list's: the list costs a little more than | |
| 385 | - its 320 (its page has padding of its own), and a second column asking for | |
| 386 | - more than remains is wrapped onto a row below — painting the whole | |
| 387 | - conversation off the bottom of the window, which reads exactly like a | |
| 388 | - conversation that has gone missing." | |
| 389 | - [] | |
| 390 | - ;; And the row itself takes the window, for the reason each narrow root | |
| 391 | - ;; gives: a backend that sizes a box by what is in it hands two panes that | |
| 392 | - ;; both asked for the rest of the screen the height of the taller one's | |
| 393 | - ;; contents. Marking the children alone is not enough — what they fill is | |
| 394 | - ;; whatever the row got. | |
| 395 | - [:hbox {:spacing 0 :wrap false :fill-height true} | |
| 396 | - ;; :expand :cross so the width-request is the width. Both panes fill the | |
| 397 | - ;; height, and a pane that fills the height is also told to take a share | |
| 398 | - ;; of the row's spare WIDTH — which two panes split between them, so the | |
| 399 | - ;; list came out half the window with its cards clipped at the fold and | |
| 400 | - ;; the conversation squeezed beside it. Down a row, :cross is the vertical | |
| 401 | - ;; axis alone: the height without the share. The conversation keeps | |
| 402 | - ;; :fill-height and so keeps the slack, which is what the note above says | |
| 403 | - ;; it takes. | |
| 404 | - ;; The list pane, when the reader has not put it away. Hidden, the wrapper | |
| 405 | - ;; stays and empties: a child that vanished would renumber the row for the | |
| 406 | - ;; reconciler and take the conversation's scroll position with it every | |
| 407 | - ;; time the list was toggled — the same trick the people panel plays. | |
| 408 | - ;; Empty, it asks for nothing: no width and no `:fill-height`, because a | |
| 409 | - ;; column that fills the height is also a column that is there, and a | |
| 410 | - ;; 320-point hole where the list was is not hiding it. | |
| 411 | - (if @s/hide-chat-list? | |
| 412 | - [:vbox {:key :list}] | |
| 413 | - [:vbox {:key :list :width-request sidebar-width :fill-height true | |
| 414 | - :expand :cross} | |
| 415 | - [chats-screen]]) | |
| 416 | - [:vbox {:key :chat :fill-height true} | |
| 417 | - (if @s/current | |
| 418 | - [chat-screen] | |
| 419 | - [no-chat-pane])]]) | |
| 420 | - | |
| 421 | 118 | ;; ---------------------------------------------------------------- discover |
| 422 | 119 | |
| 423 | 120 | ;; ---------------------------------------------------------------- settings |
| 424 | 121 | |
| 425 | 122 | ;; ---------------------------------------------------------------- shell |
| 426 | 123 | |
| 427 | -(defn app [] | |
| 428 | - ;; Every branch in its own keyed wrapper, and every key different. | |
| 429 | - ;; | |
| 430 | - ;; Without them the screens are all one position in the tree, and moving | |
| 431 | - ;; between two of them is a diff of one screen's children against another's: | |
| 432 | - ;; the reconciler matches what it can by position and patches the rest, which | |
| 433 | - ;; leaves nodes from the screen you left standing in the one you arrived at — | |
| 434 | - ;; the chats screen's join box turning up under its tab bar, an error box | |
| 435 | - ;; from the conversation you were in a moment ago. A key that changes with | |
| 436 | - ;; the screen makes the swap a swap: the old tree comes out whole and the new | |
| 437 | - ;; one goes in whole. | |
| 438 | - ;; And the dialog beside them all rather than instead of one of them. A | |
| 439 | - ;; `dialog` node is not painted where it stands — the window backend hands | |
| 440 | - ;; it to libcosmic, which puts it over the middle of the window with what is | |
| 441 | - ;; behind it dimmed — so the screen under it keeps its place in the tree, | |
| 442 | - ;; and its scroll position with it. The wrapper is always here and only its | |
| 443 | - ;; child comes and goes, for the reason every other wrapper in this file | |
| 444 | - ;; gives: a child that appeared and vanished would renumber the root. | |
| 445 | - ;; | |
| 446 | - ;; The terminal has no such thing, and a `dialog` tag it has not grown would | |
| 447 | - ;; paint its contents inline at the bottom of the screen. So there it stays | |
| 448 | - ;; a screen you go to and come back from, which is `profile-screen`. | |
| 449 | - [:vbox {:key :root :fill-height true} | |
| 450 | - ;; One dialog at a time, and a pinned profile outranks both pointers: it is | |
| 451 | - ;; the only one of the three that was asked for by a press rather than by | |
| 452 | - ;; where the pointer happens to be resting. | |
| 453 | - [:vbox {:key :dialog} | |
| 454 | - (when-not @terminal? | |
| 455 | - (cond | |
| 456 | - (or @profile/viewing @profile/hovering) [profile-dialog] | |
| 457 | - @s/reaction-hover [reactor-dialog]))] | |
| 458 | - (cond | |
| 459 | - @s/lightbox [:vbox {:key :screen-lightbox} [lightbox-screen]] | |
| 460 | - (and @terminal? @profile/viewing) | |
| 461 | - [:vbox {:key :screen-profile} [profile-screen]] | |
| 462 | - @s/image-picker [:vbox {:key :screen-picker} [image-picker-screen]] | |
| 463 | - ;; Wide enough for both, and on one of the two screens that are halves of | |
| 464 | - ;; the same thing: the list and the conversation it opens. Discover and | |
| 465 | - ;; settings stay whole screens — they are somewhere else, not the other | |
| 466 | - ;; half of here. | |
| 467 | - (and (s/wide?) (contains? #{:chats :chat} @s/screen)) | |
| 468 | - [:vbox {:key :screen-split} [split-screen]] | |
| 469 | - :else (case @s/screen | |
| 470 | - :connect [:vbox {:key :screen-connect} [connect-screen]] | |
| 471 | - :chat [:vbox {:key :screen-chat} [chat-screen]] | |
| 472 | - :discover [:vbox {:key :screen-discover} [discover-screen]] | |
| 473 | - :settings [:vbox {:key :screen-settings} [settings-screen]] | |
| 474 | - [:vbox {:key :screen-chats} [chats-screen]]))]) | |
| 475 | - | |
| 476 | 124 | (defn start! |
| 477 | 125 | "Everything a launch does before the loop starts, for whichever backend is |
| 478 | 126 | about to run it. |
| @@ -549,6 +197,10 @@ | ||
| 549 | 197 | ;; place in this namespace that uses glimmer's own API — and a phone has |
| 550 | 198 | ;; neither the reaction nor the cache. It installs nothing and the screens |
| 551 | 199 | ;; draw what they draw before a face arrives. |
| 200 | +;; The root, re-defined here: `frq.tui` and `frq.cosmic` both say `app/app` | |
| 201 | +;; when they mount, and the namespace they say it to is this one. | |
| 202 | +(def app screens/app) | |
| 203 | + | |
| 552 | 204 | (actions/install! |
| 553 | 205 | ;; The value rather than the cell: a platform without a cache answers nil, |
| 554 | 206 | ;; and `@nil` is not a thing. Derefing the reaction here keeps the desktop's |
| @@ -562,4 +214,13 @@ | ||
| 562 | 214 | :viewing (fn [] @profile/viewing) |
| 563 | 215 | :profile-hover! profile/hover! |
| 564 | 216 | :profile-unhover! profile/unhover! |
| 565 | - :profile-open! profile/open!}) | |
| 217 | + :profile-open! profile/open! | |
| 218 | + :profile-close! profile/close! | |
| 219 | + :profile-dismiss! profile/dismiss! | |
| 220 | + :profile-enter-dialog! profile/enter-dialog! | |
| 221 | + :profile-entry profile/entry | |
| 222 | + :profile-leave-dialog! profile/leave-dialog! | |
| 223 | + :profile-stats-line profile/stats-line | |
| 224 | + :profile-tick (fn [] @profile/tick) | |
| 225 | + :profile-truncate profile/truncate | |
| 226 | + :profile-web-url profile/web-url}) | |
| @@ -37,6 +37,7 @@ | |||
| 37 | ;; phone renders this very file. | 37 | ;; phone renders this very file. |
| 38 | [frq.screens.chats :refer [below-list chats-screen conversation-row | 38 | [frq.screens.chats :refer [below-list chats-screen conversation-row |
| 39 | preview-line tab-bar]] | 39 | preview-line tab-bar]] |
| 40 | + [frq.screens.app :as screens] | ||
| 40 | [frq.screens.settings :refer [discover-screen settings-screen | 41 | [frq.screens.settings :refer [discover-screen settings-screen |
| 41 | tab-screen]] | 42 | tab-screen]] |
| 42 | [frq.screens.chat :refer [chat-screen emoji-picker message-row | 43 | [frq.screens.chat :refer [chat-screen emoji-picker message-row |
| @@ -112,367 +113,14 @@ | |||
| 112 | 113 | ||
| 113 | ;; ---------------------------------------------------------------- chat | 114 | ;; ---------------------------------------------------------------- chat |
| 114 | 115 | ||
| 115 | -(defn- profile-dialog | ||
| 116 | - "Who someone is, as libcosmic's own dialog: centred over the window, with | ||
| 117 | - what you were reading dimmed behind it rather than replaced. | ||
| 118 | - | ||
| 119 | - The window can do this and the terminal cannot, which is the whole reason | ||
| 120 | - there are two of these. `profile-screen` below is the terminal's, and says | ||
| 121 | - why it is a screen. | ||
| 122 | - | ||
| 123 | - Both buttons are always here, the Bluesky one insensitive until there is a | ||
| 124 | - profile to open: a dialog whose second button appears a moment after it | ||
| 125 | - opens is a dialog that moves under the pointer, and the fetch lands whenever | ||
| 126 | - it lands. | ||
| 127 | - | ||
| 128 | - Who it is about comes from either of two places, and that — with the | ||
| 129 | - modality below — is the whole of what hovering and pressing a face do | ||
| 130 | - differently. Resting on one sets `hovering`, which the pointer takes away | ||
| 131 | - again when it leaves; pressing one sets `viewing`, which nothing takes away | ||
| 132 | - but Close. `viewing` is read first, so a pinned profile is not swapped out | ||
| 133 | - from under the reader by a face the pointer crosses on the way to it. | ||
| 134 | - | ||
| 135 | - And a dialog the pointer is holding open is not modal. A modal one makes | ||
| 136 | - the window underneath it deaf — libcosmic wraps the app in a popover that | ||
| 137 | - hands its content an `Unavailable` cursor while a popup is up — so the face | ||
| 138 | - that opened it never hears the pointer leave, and what a hover opened could | ||
| 139 | - never close itself. Non-modal, the face keeps hearing, and moving away shuts | ||
| 140 | - it. A pinned one is modal, which is what being pinned means: it is the thing | ||
| 141 | - on the screen until it is dismissed." | ||
| 142 | - [] | ||
| 143 | - (let [pinned? (some? @profile/viewing) | ||
| 144 | - {:keys [nick actor]} (or @profile/viewing @profile/hovering) | ||
| 145 | - _ @profile/tick | ||
| 146 | - _ @s/media-tick | ||
| 147 | - pr (profile/entry actor) | ||
| 148 | - ready? (= :ready (:status pr)) | ||
| 149 | - display (or (:display-name pr) nick) | ||
| 150 | - url (when ready? (profile/web-url pr))] | ||
| 151 | - [:dialog {:label display :max-width 520 :modal pinned? | ||
| 152 | - :on-hover profile/enter-dialog! | ||
| 153 | - :on-unhover profile/leave-dialog!} | ||
| 154 | - ;; The body is the screen's card without its heading: the dialog's own | ||
| 155 | - ;; title is the name now, so repeating it under the picture is a line | ||
| 156 | - ;; that says nothing. | ||
| 157 | - [:vbox {:key :body :spacing 8} | ||
| 158 | - [:hbox {:spacing 12} | ||
| 159 | - [:avatar {:label nick | ||
| 160 | - :src (or (avatars/path-when-ready actor) "") | ||
| 161 | - :size 72}] | ||
| 162 | - [:vbox {:spacing 2} | ||
| 163 | - [:vbox {:key :nick} | ||
| 164 | - (when (not= display nick) [:dim-label {:label nick}])] | ||
| 165 | - [:vbox {:key :handle} | ||
| 166 | - (when-let [h (and ready? (not-empty (or (:handle pr) "")))] | ||
| 167 | - [:label {:label (str "@" h)}])] | ||
| 168 | - [:vbox {:key :did} | ||
| 169 | - (when-let [did (:did pr)] [:dim-label {:label did}])]]] | ||
| 170 | - [:vbox {:key :bio :spacing 2} | ||
| 171 | - (when-let [bio (and ready? (:description pr))] | ||
| 172 | - (for [[i line] (map-indexed vector (str/split-lines (profile/truncate bio 600)))] | ||
| 173 | - [:label {:key i :label line}]))] | ||
| 174 | - [:vbox {:key :stats} | ||
| 175 | - (when-let [line (and ready? (profile/stats-line pr))] | ||
| 176 | - [:dim-label {:label line}])] | ||
| 177 | - [:vbox {:key :status :spacing 4} | ||
| 178 | - (cond | ||
| 179 | - (nil? actor) | ||
| 180 | - [:dim-label {:label "Guest — no Bluesky / AT Protocol identity"}] | ||
| 181 | - | ||
| 182 | - (= :loading (:status pr)) | ||
| 183 | - [:hbox {:spacing 8} | ||
| 184 | - [:spinner {}] | ||
| 185 | - [:dim-label {:label "Loading Bluesky profile…"}]] | ||
| 186 | - | ||
| 187 | - (= :failed (:status pr)) | ||
| 188 | - [:dim-label {:label "No Bluesky profile found"}])] | ||
| 189 | -] | ||
| 190 | - ;; `slot` is where libcosmic puts a button: the two actions go to the | ||
| 191 | - ;; foot of the dialog, and anything else here would be another control | ||
| 192 | - ;; stacked in the body. | ||
| 193 | - ;; | ||
| 194 | - ;; Both are here whether the profile is pinned or only hovered. They were | ||
| 195 | - ;; hidden while hovering, back when a hover could not be walked into: the | ||
| 196 | - ;; dialog reports its own pointer now, so moving towards a button in it | ||
| 197 | - ;; keeps it open instead of closing it, and a button you can reach is a | ||
| 198 | - ;; button worth drawing. | ||
| 199 | - [:button {:key :close :slot "primary" | ||
| 200 | - :label (if pinned? "Close" "Dismiss") | ||
| 201 | - :on-click profile/dismiss!}] | ||
| 202 | - [:button {:key :web :slot "secondary" :label "Bluesky ↗" | ||
| 203 | - :sensitive (boolean url) | ||
| 204 | - :on-click #(when url (platform/open-url! url))}]])) | ||
| 205 | - | ||
| 206 | -(defn profile-screen | ||
| 207 | - "Who someone is: sleek's peer profile modal, as a screen. | ||
| 208 | - | ||
| 209 | - A screen rather than a layer for the same reason the lightbox is one — the | ||
| 210 | - tree backend paints in a single layer, with no z-order to hang a modal from. | ||
| 211 | - So the back row does what sleek's ✕ and backdrop did. | ||
| 212 | - | ||
| 213 | - The picture is the same cached file the chat column draws, at a size worth | ||
| 214 | - looking at; the bio is split into a label a line, so a bio that was written | ||
| 215 | - as several lines is still several lines here." | ||
| 216 | - [] | ||
| 217 | - (let [{:keys [nick actor]} @profile/viewing | ||
| 218 | - ;; Reading both ticks subscribes this screen to the two fetches it is | ||
| 219 | - ;; waiting on: the profile itself, and the picture on it. | ||
| 220 | - _ @profile/tick | ||
| 221 | - _ @s/media-tick | ||
| 222 | - pr (profile/entry actor) | ||
| 223 | - ready? (= :ready (:status pr)) | ||
| 224 | - display (or (:display-name pr) nick) | ||
| 225 | - url (when ready? (profile/web-url pr))] | ||
| 226 | - [:page {:max-width 520} | ||
| 227 | - [:hbox {:spacing 8} | ||
| 228 | - [:button {:label "← Back" :on-click profile/close!}]] | ||
| 229 | - [:card {} | ||
| 230 | - [:hbox {:spacing 12} | ||
| 231 | - [:avatar {:label nick | ||
| 232 | - :src (or (avatars/path-when-ready actor) "") | ||
| 233 | - :size 72}] | ||
| 234 | - [:vbox {:spacing 2} | ||
| 235 | - [:title-2 {:label display}] | ||
| 236 | - ;; The nick under the display name only when they differ — repeating | ||
| 237 | - ;; it is a line that says nothing. | ||
| 238 | - [:vbox {:key :nick} | ||
| 239 | - (when (not= display nick) [:dim-label {:label nick}])] | ||
| 240 | - [:vbox {:key :handle} | ||
| 241 | - (when-let [h (and ready? (not-empty (or (:handle pr) "")))] | ||
| 242 | - [:label {:label (str "@" h)}])]]] | ||
| 243 | - ;; The DID is the identity itself, and outlasts both the nick and the | ||
| 244 | - ;; handle — so it is on the screen, in full, rather than implied. | ||
| 245 | - [:vbox {:key :did :spacing 2} | ||
| 246 | - (when-let [did (:did pr)] [:dim-label {:label did}])] | ||
| 247 | - [:vbox {:key :bio :spacing 2} | ||
| 248 | - (when-let [bio (and ready? (:description pr))] | ||
| 249 | - (for [[i line] (map-indexed vector (str/split-lines (profile/truncate bio 600)))] | ||
| 250 | - [:label {:key i :label line}]))] | ||
| 251 | - [:vbox {:key :stats} | ||
| 252 | - (when-let [line (and ready? (profile/stats-line pr))] | ||
| 253 | - [:dim-label {:label line}])] | ||
| 254 | - ;; What is happening, or why nothing is: a guest has no identity to look | ||
| 255 | - ;; up, and saying so is a better answer than a spinner that never lands. | ||
| 256 | - [:vbox {:key :status :spacing 4} | ||
| 257 | - (cond | ||
| 258 | - (nil? actor) | ||
| 259 | - [:dim-label {:label "Guest — no Bluesky / AT Protocol identity"}] | ||
| 260 | - | ||
| 261 | - (= :loading (:status pr)) | ||
| 262 | - [:hbox {:spacing 8} | ||
| 263 | - [:spinner {}] | ||
| 264 | - [:dim-label {:label "Loading Bluesky profile…"}]] | ||
| 265 | - | ||
| 266 | - (= :failed (:status pr)) | ||
| 267 | - [:dim-label {:label "No Bluesky profile found"}])] | ||
| 268 | - [:vbox {:key :actions} | ||
| 269 | - (when url | ||
| 270 | - [:button {:label "Bluesky ↗" | ||
| 271 | - :kind :primary | ||
| 272 | - :on-click #(platform/open-url! url)}])]]])) | ||
| 273 | - | ||
| 274 | -(defn lightbox-screen | ||
| 275 | - "One picture, as big as the window will paint it. | ||
| 276 | - | ||
| 277 | - A screen rather than an overlay: the tree backend paints in one layer and has | ||
| 278 | - no z-order to put something on top of everything else with. So the picture | ||
| 279 | - takes the window — `:fit` gives it every point below the one row that is not | ||
| 280 | - it, centred and in proportion, scaled up as readily as down. | ||
| 281 | - | ||
| 282 | - That row is the way back. Clicking the picture closes it too — the same | ||
| 283 | - gesture that opened it — but a way out you have to guess at is not one, and | ||
| 284 | - the row costs the picture a line." | ||
| 285 | - [] | ||
| 286 | - (let [{:keys [path]} @s/lightbox] | ||
| 287 | - [:vbox {:spacing 4 :margin 4} | ||
| 288 | - [:hbox {:spacing 8} | ||
| 289 | - [:button {:label "← Back" :on-click #(reset! s/lightbox nil)}]] | ||
| 290 | - [:image {:src path :fit true :on-click #(reset! s/lightbox nil)}]])) | ||
| 291 | - | ||
| 292 | -(defn image-picker-screen | ||
| 293 | - "The pictures on this device, to send one of. | ||
| 294 | - | ||
| 295 | - A screen rather than a panel over the compose bar: choosing a file is | ||
| 296 | - browsing, and browsing wants the window — the backend paints in one layer | ||
| 297 | - anyway, so there is no overlay to put it in. | ||
| 298 | - | ||
| 299 | - A directory at a time, PNG only. Which directories there are to start from is | ||
| 300 | - the platform's answer, not this screen's: a desktop opens on ~/Pictures, and | ||
| 301 | - a phone on what the app can read without a permission it has no code to ask | ||
| 302 | - for, which may be very little. Either way the list says what it found." | ||
| 303 | - [] | ||
| 304 | - (let [dir @s/image-picker | ||
| 305 | - {:keys [dirs files]} (s/picker-entries dir) | ||
| 306 | - up (s/parent-dir dir)] | ||
| 307 | - [:vbox {:spacing 8 :margin 12} | ||
| 308 | - [:hbox {:spacing 8} | ||
| 309 | - [:button {:label "← Back" :on-click s/close-image-picker!}] | ||
| 310 | - [:title {:label "Send a picture"}]] | ||
| 311 | - [:dim-label {:label (str dir)}] | ||
| 312 | - [error-note] | ||
| 313 | - ;; The places worth starting from, always in reach: browsing into a corner | ||
| 314 | - ;; of the filesystem should not cost the way back to the pictures folder. | ||
| 315 | - [:hbox {:key :roots :spacing 6} | ||
| 316 | - (for [root (s/picker-roots)] | ||
| 317 | - [:button {:key root | ||
| 318 | - :label (or (last (str/split root #"/")) root) | ||
| 319 | - :kind (if (= root dir) :primary :default) | ||
| 320 | - :on-click #(s/browse! root)}])] | ||
| 321 | - [:separator {}] | ||
| 322 | - [:scroll {:scroll-key "image-picker" | ||
| 323 | - :orientation :vertical | ||
| 324 | - :reserve 40} | ||
| 325 | - [:vbox {:spacing 6} | ||
| 326 | - [:vbox {:key :up} | ||
| 327 | - (when up | ||
| 328 | - [:button {:label "⬆ Up" :on-click #(s/browse! up)}])] | ||
| 329 | - [:vbox {:key :dirs :spacing 4} | ||
| 330 | - (for [d dirs] | ||
| 331 | - [:button {:key d | ||
| 332 | - :label (str "📁 " (last (str/split d #"/"))) | ||
| 333 | - :on-click #(s/browse! d)}])] | ||
| 334 | - ;; The picture itself is the button: a filename is not what anyone is | ||
| 335 | - ;; choosing between, and a thumbnail answers "is this the one" in a way | ||
| 336 | - ;; no name does. | ||
| 337 | - [:vbox {:key :files :spacing 6} | ||
| 338 | - (for [f files] | ||
| 339 | - [:hbox {:key f :spacing 8} | ||
| 340 | - [:image {:src f :max-height 72 :on-click #(s/pick-image! f)}] | ||
| 341 | - [:button {:label (last (str/split f #"/")) | ||
| 342 | - :on-click #(s/pick-image! f)}]])] | ||
| 343 | - [:vbox {:key :empty} | ||
| 344 | - (when (and (empty? dirs) (empty? files)) | ||
| 345 | - [:dim-label {:label "No pictures here that this app can read."}])]]]])) | ||
| 346 | - | ||
| 347 | -;; What the people panel asks for, and what the conversation beside it has to | ||
| 348 | -;; be told to leave. A column with `:fill-height` and no width takes the whole | ||
| 349 | -;; row — so the panel is only ever on screen if the message list is given a | ||
| 350 | -;; width that stops short of it. | ||
| 351 | ;; ---------------------------------------------------------------- split | 116 | ;; ---------------------------------------------------------------- split |
| 352 | 117 | ||
| 353 | -(defn- no-chat-pane | ||
| 354 | - "What fills the second pane before a conversation has been picked. The pane | ||
| 355 | - is there either way — a list that widened into two columns and back again as | ||
| 356 | - channels were opened would be a worse answer than an empty half." | ||
| 357 | - [] | ||
| 358 | - [:vbox {:spacing 8 :margin 12} | ||
| 359 | - [:title {:label "frq"}] | ||
| 360 | - ;; With the list folded away there is nothing on the left to pick from, and | ||
| 361 | - ;; no conversation here carrying the switch that would bring it back — so | ||
| 362 | - ;; this pane carries it instead. Without that the reader who hid the list | ||
| 363 | - ;; and then closed the last room has put the app away, not the list. | ||
| 364 | - (if @s/hide-chat-list? | ||
| 365 | - [:card {} | ||
| 366 | - [:dim-label {:label "The chats list is hidden."}] | ||
| 367 | - [:button {:label "☰ Chats" :kind :primary | ||
| 368 | - :on-click s/toggle-chat-list!}]] | ||
| 369 | - [:card {} [:dim-label {:label "Pick a conversation on the left."}]])]) | ||
| 370 | - | ||
| 371 | -(defn split-screen | ||
| 372 | - "The chats list and the conversation side by side, for a window wide enough | ||
| 373 | - to hold both. | ||
| 374 | - | ||
| 375 | - Same components as the narrow layout, in a row instead of one at a time: the | ||
| 376 | - list keeps its own scroll and its own tab bar, and the conversation keeps the | ||
| 377 | - compose bar pinned under a backlog that scrolls on its own. | ||
| 378 | - | ||
| 379 | - Both panes take `:fill-height`: a column in a row is otherwise as tall as the | ||
| 380 | - row, which at the moment it is placed is one button — and the list and the | ||
| 381 | - message backlog both size themselves against the height they are handed. | ||
| 382 | - | ||
| 383 | - Only the list is given a width. The conversation takes what is left, rather | ||
| 384 | - than the window's width minus the list's: the list costs a little more than | ||
| 385 | - its 320 (its page has padding of its own), and a second column asking for | ||
| 386 | - more than remains is wrapped onto a row below — painting the whole | ||
| 387 | - conversation off the bottom of the window, which reads exactly like a | ||
| 388 | - conversation that has gone missing." | ||
| 389 | - [] | ||
| 390 | - ;; And the row itself takes the window, for the reason each narrow root | ||
| 391 | - ;; gives: a backend that sizes a box by what is in it hands two panes that | ||
| 392 | - ;; both asked for the rest of the screen the height of the taller one's | ||
| 393 | - ;; contents. Marking the children alone is not enough — what they fill is | ||
| 394 | - ;; whatever the row got. | ||
| 395 | - [:hbox {:spacing 0 :wrap false :fill-height true} | ||
| 396 | - ;; :expand :cross so the width-request is the width. Both panes fill the | ||
| 397 | - ;; height, and a pane that fills the height is also told to take a share | ||
| 398 | - ;; of the row's spare WIDTH — which two panes split between them, so the | ||
| 399 | - ;; list came out half the window with its cards clipped at the fold and | ||
| 400 | - ;; the conversation squeezed beside it. Down a row, :cross is the vertical | ||
| 401 | - ;; axis alone: the height without the share. The conversation keeps | ||
| 402 | - ;; :fill-height and so keeps the slack, which is what the note above says | ||
| 403 | - ;; it takes. | ||
| 404 | - ;; The list pane, when the reader has not put it away. Hidden, the wrapper | ||
| 405 | - ;; stays and empties: a child that vanished would renumber the row for the | ||
| 406 | - ;; reconciler and take the conversation's scroll position with it every | ||
| 407 | - ;; time the list was toggled — the same trick the people panel plays. | ||
| 408 | - ;; Empty, it asks for nothing: no width and no `:fill-height`, because a | ||
| 409 | - ;; column that fills the height is also a column that is there, and a | ||
| 410 | - ;; 320-point hole where the list was is not hiding it. | ||
| 411 | - (if @s/hide-chat-list? | ||
| 412 | - [:vbox {:key :list}] | ||
| 413 | - [:vbox {:key :list :width-request sidebar-width :fill-height true | ||
| 414 | - :expand :cross} | ||
| 415 | - [chats-screen]]) | ||
| 416 | - [:vbox {:key :chat :fill-height true} | ||
| 417 | - (if @s/current | ||
| 418 | - [chat-screen] | ||
| 419 | - [no-chat-pane])]]) | ||
| 420 | - | ||
| 421 | ;; ---------------------------------------------------------------- discover | 118 | ;; ---------------------------------------------------------------- discover |
| 422 | 119 | ||
| 423 | ;; ---------------------------------------------------------------- settings | 120 | ;; ---------------------------------------------------------------- settings |
| 424 | 121 | ||
| 425 | ;; ---------------------------------------------------------------- shell | 122 | ;; ---------------------------------------------------------------- shell |
| 426 | 123 | ||
| 427 | -(defn app [] | ||
| 428 | - ;; Every branch in its own keyed wrapper, and every key different. | ||
| 429 | - ;; | ||
| 430 | - ;; Without them the screens are all one position in the tree, and moving | ||
| 431 | - ;; between two of them is a diff of one screen's children against another's: | ||
| 432 | - ;; the reconciler matches what it can by position and patches the rest, which | ||
| 433 | - ;; leaves nodes from the screen you left standing in the one you arrived at — | ||
| 434 | - ;; the chats screen's join box turning up under its tab bar, an error box | ||
| 435 | - ;; from the conversation you were in a moment ago. A key that changes with | ||
| 436 | - ;; the screen makes the swap a swap: the old tree comes out whole and the new | ||
| 437 | - ;; one goes in whole. | ||
| 438 | - ;; And the dialog beside them all rather than instead of one of them. A | ||
| 439 | - ;; `dialog` node is not painted where it stands — the window backend hands | ||
| 440 | - ;; it to libcosmic, which puts it over the middle of the window with what is | ||
| 441 | - ;; behind it dimmed — so the screen under it keeps its place in the tree, | ||
| 442 | - ;; and its scroll position with it. The wrapper is always here and only its | ||
| 443 | - ;; child comes and goes, for the reason every other wrapper in this file | ||
| 444 | - ;; gives: a child that appeared and vanished would renumber the root. | ||
| 445 | - ;; | ||
| 446 | - ;; The terminal has no such thing, and a `dialog` tag it has not grown would | ||
| 447 | - ;; paint its contents inline at the bottom of the screen. So there it stays | ||
| 448 | - ;; a screen you go to and come back from, which is `profile-screen`. | ||
| 449 | - [:vbox {:key :root :fill-height true} | ||
| 450 | - ;; One dialog at a time, and a pinned profile outranks both pointers: it is | ||
| 451 | - ;; the only one of the three that was asked for by a press rather than by | ||
| 452 | - ;; where the pointer happens to be resting. | ||
| 453 | - [:vbox {:key :dialog} | ||
| 454 | - (when-not @terminal? | ||
| 455 | - (cond | ||
| 456 | - (or @profile/viewing @profile/hovering) [profile-dialog] | ||
| 457 | - @s/reaction-hover [reactor-dialog]))] | ||
| 458 | - (cond | ||
| 459 | - @s/lightbox [:vbox {:key :screen-lightbox} [lightbox-screen]] | ||
| 460 | - (and @terminal? @profile/viewing) | ||
| 461 | - [:vbox {:key :screen-profile} [profile-screen]] | ||
| 462 | - @s/image-picker [:vbox {:key :screen-picker} [image-picker-screen]] | ||
| 463 | - ;; Wide enough for both, and on one of the two screens that are halves of | ||
| 464 | - ;; the same thing: the list and the conversation it opens. Discover and | ||
| 465 | - ;; settings stay whole screens — they are somewhere else, not the other | ||
| 466 | - ;; half of here. | ||
| 467 | - (and (s/wide?) (contains? #{:chats :chat} @s/screen)) | ||
| 468 | - [:vbox {:key :screen-split} [split-screen]] | ||
| 469 | - :else (case @s/screen | ||
| 470 | - :connect [:vbox {:key :screen-connect} [connect-screen]] | ||
| 471 | - :chat [:vbox {:key :screen-chat} [chat-screen]] | ||
| 472 | - :discover [:vbox {:key :screen-discover} [discover-screen]] | ||
| 473 | - :settings [:vbox {:key :screen-settings} [settings-screen]] | ||
| 474 | - [:vbox {:key :screen-chats} [chats-screen]]))]) | ||
| 475 | - | ||
| 476 | (defn start! | 124 | (defn start! |
| 477 | "Everything a launch does before the loop starts, for whichever backend is | 125 | "Everything a launch does before the loop starts, for whichever backend is |
| 478 | about to run it. | 126 | about to run it. |
| @@ -549,6 +197,10 @@ | |||
| 549 | ;; place in this namespace that uses glimmer's own API — and a phone has | 197 | ;; place in this namespace that uses glimmer's own API — and a phone has |
| 550 | ;; neither the reaction nor the cache. It installs nothing and the screens | 198 | ;; neither the reaction nor the cache. It installs nothing and the screens |
| 551 | ;; draw what they draw before a face arrives. | 199 | ;; draw what they draw before a face arrives. |
| 200 | +;; The root, re-defined here: `frq.tui` and `frq.cosmic` both say `app/app` | ||
| 201 | +;; when they mount, and the namespace they say it to is this one. | ||
| 202 | +(def app screens/app) | ||
| 203 | + | ||
| 552 | (actions/install! | 204 | (actions/install! |
| 553 | ;; The value rather than the cell: a platform without a cache answers nil, | 205 | ;; The value rather than the cell: a platform without a cache answers nil, |
| 554 | ;; and `@nil` is not a thing. Derefing the reaction here keeps the desktop's | 206 | ;; and `@nil` is not a thing. Derefing the reaction here keeps the desktop's |
| @@ -562,4 +214,13 @@ | |||
| 562 | :viewing (fn [] @profile/viewing) | 214 | :viewing (fn [] @profile/viewing) |
| 563 | :profile-hover! profile/hover! | 215 | :profile-hover! profile/hover! |
| 564 | :profile-unhover! profile/unhover! | 216 | :profile-unhover! profile/unhover! |
| 565 | - :profile-open! profile/open!}) | 217 | + :profile-open! profile/open! |
| 218 | + :profile-close! profile/close! | ||
| 219 | + :profile-dismiss! profile/dismiss! | ||
| 220 | + :profile-enter-dialog! profile/enter-dialog! | ||
| 221 | + :profile-entry profile/entry | ||
| 222 | + :profile-leave-dialog! profile/leave-dialog! | ||
| 223 | + :profile-stats-line profile/stats-line | ||
| 224 | + :profile-tick (fn [] @profile/tick) | ||
| 225 | + :profile-truncate profile/truncate | ||
| 226 | + :profile-web-url profile/web-url}) | ||
modified
src/frq/state.clj +9 -3 | @@ -1360,9 +1360,7 @@ | ||
| 1360 | 1360 | |
| 1361 | 1361 | ;; ------------------------------------------------------------------ picking |
| 1362 | 1362 | |
| 1363 | -;; Where the picker is looking, or nil when it is closed. A path, so the | |
| 1364 | -;; browsing is just this cell moving. | |
| 1365 | -(defonce image-picker (atom nil)) | |
| 1363 | +(def image-picker cells/image-picker) | |
| 1366 | 1364 | |
| 1367 | 1365 | (defn- readable-dir? [path] |
| 1368 | 1366 | (try (and (host/file-exists? path) (host/directory? path)) |
| @@ -1950,6 +1948,14 @@ | ||
| 1950 | 1948 | :open-channel! open-channel! |
| 1951 | 1949 | :leave-channel! leave-channel! |
| 1952 | 1950 | :toggle-hide-join-part! toggle-hide-join-part! |
| 1951 | + :browse! browse! | |
| 1952 | + :close-image-picker! close-image-picker! | |
| 1953 | + :pick-image! pick-image! | |
| 1954 | + :parent-dir parent-dir | |
| 1955 | + :picker-entries picker-entries | |
| 1956 | + :picker-roots picker-roots | |
| 1957 | + :media-tick (fn [] @media-tick) | |
| 1958 | + :avatar-ready (fn [actor] (avatars/path-when-ready actor)) | |
| 1953 | 1959 | :send-draft! send-draft! |
| 1954 | 1960 | :cancel-edit! cancel-edit! |
| 1955 | 1961 | :cancel-reply! cancel-reply! |
| @@ -1360,9 +1360,7 @@ | |||
| 1360 | 1360 | ||
| 1361 | ;; ------------------------------------------------------------------ picking | 1361 | ;; ------------------------------------------------------------------ picking |
| 1362 | 1362 | ||
| 1363 | -;; Where the picker is looking, or nil when it is closed. A path, so the | 1363 | +(def image-picker cells/image-picker) |
| 1364 | -;; browsing is just this cell moving. | ||
| 1365 | -(defonce image-picker (atom nil)) | ||
| 1366 | 1364 | ||
| 1367 | (defn- readable-dir? [path] | 1365 | (defn- readable-dir? [path] |
| 1368 | (try (and (host/file-exists? path) (host/directory? path)) | 1366 | (try (and (host/file-exists? path) (host/directory? path)) |
| @@ -1950,6 +1948,14 @@ | |||
| 1950 | :open-channel! open-channel! | 1948 | :open-channel! open-channel! |
| 1951 | :leave-channel! leave-channel! | 1949 | :leave-channel! leave-channel! |
| 1952 | :toggle-hide-join-part! toggle-hide-join-part! | 1950 | :toggle-hide-join-part! toggle-hide-join-part! |
| 1951 | + :browse! browse! | ||
| 1952 | + :close-image-picker! close-image-picker! | ||
| 1953 | + :pick-image! pick-image! | ||
| 1954 | + :parent-dir parent-dir | ||
| 1955 | + :picker-entries picker-entries | ||
| 1956 | + :picker-roots picker-roots | ||
| 1957 | + :media-tick (fn [] @media-tick) | ||
| 1958 | + :avatar-ready (fn [actor] (avatars/path-when-ready actor)) | ||
| 1953 | :send-draft! send-draft! | 1959 | :send-draft! send-draft! |
| 1954 | :cancel-edit! cancel-edit! | 1960 | :cancel-edit! cancel-edit! |
| 1955 | :cancel-reply! cancel-reply! | 1961 | :cancel-reply! cancel-reply! |