Raise the profile card under the pointer on flutter-desktop
The profile dialog was a bordered sheet at the top of the tree on Flutter, above the conversation it was opened from, and only ever a press could open it. That was right when this half was only ever an APK. It has not been for a while: `just flutter-desktop` is a window with a mouse in it, and the two things libcosmic does with one — resting on a face to raise the card, and the card floating over the screen rather than taking a place in it — were both missing rather than impossible. `render-root` is the floating. A `:dialog` node cannot lift itself out of the column it is in, so this takes it out of the tree before rendering and stacks it over what is left: screens, scrim, card. The screen under it keeps its place, and its scroll position with it. The `:dialog` arm grew a second shape to go in that stack — capped at `:max-width`, buttons gathered out of the body into a foot by their `:slot`, and the body scrolling inside a capped height so a long bio is a card with a scroll in it rather than a card taller than the window. The phone keeps the sheet. `hovered` is the resting. `:avatar` and `:reaction` take a MouseRegion where there is a pointer, which is what `:on-hover` and `:on-unhover` have been asking for and being ignored about. The machine behind all that moved to common/. Hovering, the grace period for crossing from the face to the card, the card reporting its own pointer — it lived in `frq.profile.pointer` on the reading that a pointer meant libcosmic, and it is a few atoms and a timer with nothing host-shaped in it. The timer was the one part that was, so `after!` is new on the `frq.io` seam with both implementations behind it. What is left in `pointer` is what genuinely differs by host — the blocking fetch, and the avatar download — and it re-exports the rest, so jolt's wiring did not move. `:desktop?` on the Flutter host was hardcoded false and is now asked. Two places were reading it as "is this libcosmic" rather than "is there a pointer": the image-picker tile is a file read out of jolt's working directory, so it is `#?(:jolt ...)` now and flutter-desktop keeps the glyph; and Quit, which is real there, so `:quit!` exits instead of doing nothing. Reaction-pill hover comes along with it, since the chat screen gates that the same way — `reaction-hover` is wired, and `reactor-dialog` works there too. Verified by compiling both halves and by running the desktop bundle with a forced hover and a forced pin: no exception, no overflow, no layout assertion. Not verified by eye — the window opens on a session I can only capture whole, and headless X had no GL for Impeller. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
5b89e5a parent: 3fb7fd0 modified
common/frq/io.cljc +12 -0 | @@ -124,3 +124,15 @@ | ||
| 124 | 124 | backlog read in November carries messages from August." |
| 125 | 125 | [epoch-secs] |
| 126 | 126 | (call :local-offset-seconds [epoch-secs])) |
| 127 | + | |
| 128 | +(defn after! | |
| 129 | + "Run `f` in about `ms` milliseconds, wherever this host runs UI work. | |
| 130 | + | |
| 131 | + Named for when rather than for how, like the rest of this seam. The window | |
| 132 | + lends the toolkit's own timer — a callback off the UI thread repaints from | |
| 133 | + the wrong one — and the phone has an event loop already and needs no | |
| 134 | + lending. What hangs on it is the grace period for crossing from a face to | |
| 135 | + the card it raised: see `frq.profile/unhover!`." | |
| 136 | + [ms f] | |
| 137 | + (call :after! [ms f]) | |
| 138 | + nil) | |
| @@ -124,3 +124,15 @@ | |||
| 124 | backlog read in November carries messages from August." | 124 | backlog read in November carries messages from August." |
| 125 | [epoch-secs] | 125 | [epoch-secs] |
| 126 | (call :local-offset-seconds [epoch-secs])) | 126 | (call :local-offset-seconds [epoch-secs])) |
| 127 | + | ||
| 128 | +(defn after! | ||
| 129 | + "Run `f` in about `ms` milliseconds, wherever this host runs UI work. | ||
| 130 | + | ||
| 131 | + Named for when rather than for how, like the rest of this seam. The window | ||
| 132 | + lends the toolkit's own timer — a callback off the UI thread repaints from | ||
| 133 | + the wrong one — and the phone has an event loop already and needs no | ||
| 134 | + lending. What hangs on it is the grace period for crossing from a face to | ||
| 135 | + the card it raised: see `frq.profile/unhover!`." | ||
| 136 | + [ms f] | ||
| 137 | + (call :after! [ms f]) | ||
| 138 | + nil) | ||
modified
common/frq/profile.cljc +89 -5 | @@ -11,17 +11,22 @@ | ||
| 11 | 11 | them the screen is the nick and a line saying so, which is the honest answer |
| 12 | 12 | rather than a spinner that never lands. |
| 13 | 13 | |
| 14 | - What a profile *is* is here. What a *pointer* does about one is not: hovering | |
| 15 | - a face, the grace period for crossing to the card, and the card reporting its | |
| 16 | - own pointer are all the desktop's, and stay in `src/frq/profile.clj`. A | |
| 17 | - finger is either on a name or not on it. | |
| 14 | + What a pointer does about one is here too, at the foot of this file: hovering | |
| 15 | + a face, the grace period for crossing from the face to the card, and the card | |
| 16 | + reporting its own pointer. That used to be jolt's alone, on the reading that | |
| 17 | + a pointer meant libcosmic — but `just flutter-desktop` is a window with a | |
| 18 | + mouse in it as much as `just run` is, and the machine is a few atoms and a | |
| 19 | + timer with nothing host-shaped in it. The timer is the one thing that was, | |
| 20 | + and `frq.io/after!` is where that went. Whether there is a pointer at all is | |
| 21 | + still the host's answer: `actions/desktop?`. | |
| 18 | 22 | |
| 19 | 23 | The fetch is a seam, because it is the one part that differs — a future and a |
| 20 | 24 | blocking request on one side, an awaited one on the other. Nothing is fetched |
| 21 | 25 | until a backend installs one, and `entry` simply answers nil." |
| 22 | 26 | (:require [clojure.string :as str] |
| 23 | 27 | [frq.atproto.core :as atproto] |
| 24 | - [frq.cells :as cells])) | |
| 28 | + [frq.cells :as cells] | |
| 29 | + [frq.io :as io])) | |
| 25 | 30 | |
| 26 | 31 | (def directory-host atproto/directory-host) |
| 27 | 32 | |
| @@ -150,6 +155,85 @@ | ||
| 150 | 155 | |
| 151 | 156 | (defn close! [] (reset! viewing nil)) |
| 152 | 157 | |
| 158 | +;; ------------------------------------------------------- what a pointer does | |
| 159 | + | |
| 160 | +;; Whether the pointer is on the dialog the hover put up. | |
| 161 | +;; | |
| 162 | +;; This is what makes a hovered profile something you can move into and read | |
| 163 | +;; rather than something you can only glance at: the dialog reports its own | |
| 164 | +;; pointer, so leaving the face is not the end of the hover if the pointer | |
| 165 | +;; turned up here instead. | |
| 166 | +(defonce ^:private over-dialog? (atom false)) | |
| 167 | + | |
| 168 | +(defn dismiss! | |
| 169 | + "Put the profile away, however it was opened. | |
| 170 | + | |
| 171 | + The dialog is shown for `viewing` or for `hovering`, so a Close that cleared | |
| 172 | + only the first left one the pointer had opened on screen with its own button | |
| 173 | + doing nothing to it." | |
| 174 | + [] | |
| 175 | + (reset! viewing nil) | |
| 176 | + (reset! hovering nil) | |
| 177 | + ;; And the pointer's claim on it. Close takes the dialog out from under the | |
| 178 | + ;; pointer, so there is no leaving edge coming to say so — left set, it | |
| 179 | + ;; would hold the next hover open for good. | |
| 180 | + (reset! over-dialog? false)) | |
| 181 | + | |
| 182 | +;; How long the pointer may be on neither the face nor the dialog before the | |
| 183 | +;; dialog goes. | |
| 184 | +;; | |
| 185 | +;; There is a gap between the two — the dialog is centred and the face is | |
| 186 | +;; wherever the message is — and a hover that ended the instant the pointer | |
| 187 | +;; left the face would close it halfway across every time. Long enough to | |
| 188 | +;; cross, short enough that a pointer moving somewhere else entirely does not | |
| 189 | +;; drag it along. | |
| 190 | +(def ^:private grace-ms 400) | |
| 191 | + | |
| 192 | +(defn- release! | |
| 193 | + "Let `nick`'s hover go, unless something has taken it up again. | |
| 194 | + | |
| 195 | + Three things can have happened in the grace period: the pointer arrived on | |
| 196 | + the dialog, it went back to the face, or it landed on someone else's. In all | |
| 197 | + three there is a hover to keep, and it is not this one's to end — which is | |
| 198 | + what the nick guard says." | |
| 199 | + [nick] | |
| 200 | + (when-not @over-dialog? | |
| 201 | + (swap! hovering #(when-not (= nick (:nick %)) %)))) | |
| 202 | + | |
| 203 | +(defn hover! | |
| 204 | + "The pointer has come to rest on someone's face. Starts the fetch opening | |
| 205 | + them would, so the card has something on it by the time it is read. | |
| 206 | + | |
| 207 | + Their picture is not asked for here. It is the one part of this that needs | |
| 208 | + the host — the desktop downloads a file and the phone hands the CDN URL | |
| 209 | + straight to the widget — so each backend asks for it at the seam, beside the | |
| 210 | + call to this." | |
| 211 | + [nick actor] | |
| 212 | + (reset! hovering {:nick nick :actor actor}) | |
| 213 | + (fetch! actor)) | |
| 214 | + | |
| 215 | +(defn unhover! | |
| 216 | + "The pointer has left `nick`'s face — which is not yet the end of it. | |
| 217 | + | |
| 218 | + Guarded by who is being left, so the leaving of one face cannot take down | |
| 219 | + the card of the next one: both edges arrive in the same frame when the | |
| 220 | + pointer crosses straight over." | |
| 221 | + [nick] | |
| 222 | + (io/after! grace-ms #(release! nick))) | |
| 223 | + | |
| 224 | +(defn enter-dialog! | |
| 225 | + "The pointer is on the dialog. Whatever hover put it there is now this." | |
| 226 | + [] | |
| 227 | + (reset! over-dialog? true)) | |
| 228 | + | |
| 229 | +(defn leave-dialog! | |
| 230 | + "The pointer has left the dialog, and with it the last thing holding the | |
| 231 | + profile open — unless it went back to the face it came from." | |
| 232 | + [] | |
| 233 | + (reset! over-dialog? false) | |
| 234 | + (let [nick (:nick @hovering)] | |
| 235 | + (io/after! grace-ms #(release! nick)))) | |
| 236 | + | |
| 153 | 237 | (defn web-url |
| 154 | 238 | "Their profile on the web, by handle where there is one and DID otherwise." |
| 155 | 239 | [{:keys [handle did]}] |
| @@ -11,17 +11,22 @@ | |||
| 11 | them the screen is the nick and a line saying so, which is the honest answer | 11 | them the screen is the nick and a line saying so, which is the honest answer |
| 12 | rather than a spinner that never lands. | 12 | rather than a spinner that never lands. |
| 13 | 13 | ||
| 14 | - What a profile *is* is here. What a *pointer* does about one is not: hovering | 14 | + What a pointer does about one is here too, at the foot of this file: hovering |
| 15 | - a face, the grace period for crossing to the card, and the card reporting its | 15 | + a face, the grace period for crossing from the face to the card, and the card |
| 16 | - own pointer are all the desktop's, and stay in `src/frq/profile.clj`. A | 16 | + reporting its own pointer. That used to be jolt's alone, on the reading that |
| 17 | - finger is either on a name or not on it. | 17 | + a pointer meant libcosmic — but `just flutter-desktop` is a window with a |
| 18 | + mouse in it as much as `just run` is, and the machine is a few atoms and a | ||
| 19 | + timer with nothing host-shaped in it. The timer is the one thing that was, | ||
| 20 | + and `frq.io/after!` is where that went. Whether there is a pointer at all is | ||
| 21 | + still the host's answer: `actions/desktop?`. | ||
| 18 | 22 | ||
| 19 | The fetch is a seam, because it is the one part that differs — a future and a | 23 | The fetch is a seam, because it is the one part that differs — a future and a |
| 20 | blocking request on one side, an awaited one on the other. Nothing is fetched | 24 | blocking request on one side, an awaited one on the other. Nothing is fetched |
| 21 | until a backend installs one, and `entry` simply answers nil." | 25 | until a backend installs one, and `entry` simply answers nil." |
| 22 | (:require [clojure.string :as str] | 26 | (:require [clojure.string :as str] |
| 23 | [frq.atproto.core :as atproto] | 27 | [frq.atproto.core :as atproto] |
| 24 | - [frq.cells :as cells])) | 28 | + [frq.cells :as cells] |
| 29 | + [frq.io :as io])) | ||
| 25 | 30 | ||
| 26 | (def directory-host atproto/directory-host) | 31 | (def directory-host atproto/directory-host) |
| 27 | 32 | ||
| @@ -150,6 +155,85 @@ | |||
| 150 | 155 | ||
| 151 | (defn close! [] (reset! viewing nil)) | 156 | (defn close! [] (reset! viewing nil)) |
| 152 | 157 | ||
| 158 | +;; ------------------------------------------------------- what a pointer does | ||
| 159 | + | ||
| 160 | +;; Whether the pointer is on the dialog the hover put up. | ||
| 161 | +;; | ||
| 162 | +;; This is what makes a hovered profile something you can move into and read | ||
| 163 | +;; rather than something you can only glance at: the dialog reports its own | ||
| 164 | +;; pointer, so leaving the face is not the end of the hover if the pointer | ||
| 165 | +;; turned up here instead. | ||
| 166 | +(defonce ^:private over-dialog? (atom false)) | ||
| 167 | + | ||
| 168 | +(defn dismiss! | ||
| 169 | + "Put the profile away, however it was opened. | ||
| 170 | + | ||
| 171 | + The dialog is shown for `viewing` or for `hovering`, so a Close that cleared | ||
| 172 | + only the first left one the pointer had opened on screen with its own button | ||
| 173 | + doing nothing to it." | ||
| 174 | + [] | ||
| 175 | + (reset! viewing nil) | ||
| 176 | + (reset! hovering nil) | ||
| 177 | + ;; And the pointer's claim on it. Close takes the dialog out from under the | ||
| 178 | + ;; pointer, so there is no leaving edge coming to say so — left set, it | ||
| 179 | + ;; would hold the next hover open for good. | ||
| 180 | + (reset! over-dialog? false)) | ||
| 181 | + | ||
| 182 | +;; How long the pointer may be on neither the face nor the dialog before the | ||
| 183 | +;; dialog goes. | ||
| 184 | +;; | ||
| 185 | +;; There is a gap between the two — the dialog is centred and the face is | ||
| 186 | +;; wherever the message is — and a hover that ended the instant the pointer | ||
| 187 | +;; left the face would close it halfway across every time. Long enough to | ||
| 188 | +;; cross, short enough that a pointer moving somewhere else entirely does not | ||
| 189 | +;; drag it along. | ||
| 190 | +(def ^:private grace-ms 400) | ||
| 191 | + | ||
| 192 | +(defn- release! | ||
| 193 | + "Let `nick`'s hover go, unless something has taken it up again. | ||
| 194 | + | ||
| 195 | + Three things can have happened in the grace period: the pointer arrived on | ||
| 196 | + the dialog, it went back to the face, or it landed on someone else's. In all | ||
| 197 | + three there is a hover to keep, and it is not this one's to end — which is | ||
| 198 | + what the nick guard says." | ||
| 199 | + [nick] | ||
| 200 | + (when-not @over-dialog? | ||
| 201 | + (swap! hovering #(when-not (= nick (:nick %)) %)))) | ||
| 202 | + | ||
| 203 | +(defn hover! | ||
| 204 | + "The pointer has come to rest on someone's face. Starts the fetch opening | ||
| 205 | + them would, so the card has something on it by the time it is read. | ||
| 206 | + | ||
| 207 | + Their picture is not asked for here. It is the one part of this that needs | ||
| 208 | + the host — the desktop downloads a file and the phone hands the CDN URL | ||
| 209 | + straight to the widget — so each backend asks for it at the seam, beside the | ||
| 210 | + call to this." | ||
| 211 | + [nick actor] | ||
| 212 | + (reset! hovering {:nick nick :actor actor}) | ||
| 213 | + (fetch! actor)) | ||
| 214 | + | ||
| 215 | +(defn unhover! | ||
| 216 | + "The pointer has left `nick`'s face — which is not yet the end of it. | ||
| 217 | + | ||
| 218 | + Guarded by who is being left, so the leaving of one face cannot take down | ||
| 219 | + the card of the next one: both edges arrive in the same frame when the | ||
| 220 | + pointer crosses straight over." | ||
| 221 | + [nick] | ||
| 222 | + (io/after! grace-ms #(release! nick))) | ||
| 223 | + | ||
| 224 | +(defn enter-dialog! | ||
| 225 | + "The pointer is on the dialog. Whatever hover put it there is now this." | ||
| 226 | + [] | ||
| 227 | + (reset! over-dialog? true)) | ||
| 228 | + | ||
| 229 | +(defn leave-dialog! | ||
| 230 | + "The pointer has left the dialog, and with it the last thing holding the | ||
| 231 | + profile open — unless it went back to the face it came from." | ||
| 232 | + [] | ||
| 233 | + (reset! over-dialog? false) | ||
| 234 | + (let [nick (:nick @hovering)] | ||
| 235 | + (io/after! grace-ms #(release! nick)))) | ||
| 236 | + | ||
| 153 | (defn web-url | 237 | (defn web-url |
| 154 | "Their profile on the web, by handle where there is one and DID otherwise." | 238 | "Their profile on the web, by handle where there is one and DID otherwise." |
| 155 | [{:keys [handle did]}] | 239 | [{:keys [handle did]}] |
modified
common/frq/screens/app.cljc +22 -17 | @@ -102,12 +102,15 @@ | ||
| 102 | 102 | [:image {:src path :fit true :on-click #(reset! cells/lightbox nil)}]])) |
| 103 | 103 | |
| 104 | 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. | |
| 105 | + "Who someone is, as a dialog: centred over the window, with what you were | |
| 106 | + reading dimmed behind it rather than replaced. | |
| 107 | 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. | |
| 108 | + A window can do this and the terminal cannot, which is the whole reason there | |
| 109 | + are two of these. `profile-screen` below is the terminal's, and says why it is | |
| 110 | + a screen. Both windows draw this one — libcosmic is handed the `:dialog` node | |
| 111 | + and floats it itself, and `frq.hiccup/render-root` lifts it out of the tree | |
| 112 | + and stacks it — and on a phone the same node is a sheet where it stands, | |
| 113 | + which is what a screen the width of a dialog wants. | |
| 111 | 114 | |
| 112 | 115 | Both buttons are always here, the Bluesky one insensitive until there is a |
| 113 | 116 | profile to open: a dialog whose second button appears a moment after it |
| @@ -123,11 +126,12 @@ | ||
| 123 | 126 | |
| 124 | 127 | And a dialog the pointer is holding open is not modal. A modal one makes |
| 125 | 128 | 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." | |
| 129 | + hands its content an `Unavailable` cursor while a popup is up, and Flutter's | |
| 130 | + side of this puts a scrim over the screens that absorbs what lands on it — so | |
| 131 | + the face that opened it never hears the pointer leave, and what a hover opened | |
| 132 | + could never close itself. Non-modal, the face keeps hearing, and moving away | |
| 133 | + shuts it. A pinned one is modal, which is what being pinned means: it is the | |
| 134 | + thing on the screen until it is dismissed." | |
| 131 | 135 | [] |
| 132 | 136 | (let [pinned? (some? (actions/viewing)) |
| 133 | 137 | {:keys [nick actor]} (or (actions/viewing) (actions/hovering)) |
| @@ -339,13 +343,14 @@ | ||
| 339 | 343 | ;; from the conversation you were in a moment ago. A key that changes with |
| 340 | 344 | ;; the screen makes the swap a swap: the old tree comes out whole and the new |
| 341 | 345 | ;; 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. | |
| 346 | + ;; And the dialog beside them all rather than instead of one of them. In a | |
| 347 | + ;; window a `dialog` node is not painted where it stands — it is put over the | |
| 348 | + ;; middle of the window with what is behind it dimmed, by libcosmic on one | |
| 349 | + ;; side and by `frq.hiccup/render-root` on the other — so the screen under it | |
| 350 | + ;; keeps its place in the tree, and its scroll position with it. The wrapper | |
| 351 | + ;; is always here and only its child comes and goes, for the reason every | |
| 352 | + ;; other wrapper in this file gives: a child that appeared and vanished would | |
| 353 | + ;; renumber the root. | |
| 349 | 354 | ;; |
| 350 | 355 | ;; The terminal has no such thing, and a `dialog` tag it has not grown would |
| 351 | 356 | ;; paint its contents inline at the bottom of the screen. So there it stays |
| @@ -102,12 +102,15 @@ | |||
| 102 | [:image {:src path :fit true :on-click #(reset! cells/lightbox nil)}]])) | 102 | [:image {:src path :fit true :on-click #(reset! cells/lightbox nil)}]])) |
| 103 | 103 | ||
| 104 | (defn- profile-dialog | 104 | (defn- profile-dialog |
| 105 | - "Who someone is, as libcosmic's own dialog: centred over the window, with | 105 | + "Who someone is, as a dialog: centred over the window, with what you were |
| 106 | - what you were reading dimmed behind it rather than replaced. | 106 | + reading dimmed behind it rather than replaced. |
| 107 | 107 | ||
| 108 | - The window can do this and the terminal cannot, which is the whole reason | 108 | + A window can do this and the terminal cannot, which is the whole reason there |
| 109 | - there are two of these. `profile-screen` below is the terminal's, and says | 109 | + are two of these. `profile-screen` below is the terminal's, and says why it is |
| 110 | - why it is a screen. | 110 | + a screen. Both windows draw this one — libcosmic is handed the `:dialog` node |
| 111 | + and floats it itself, and `frq.hiccup/render-root` lifts it out of the tree | ||
| 112 | + and stacks it — and on a phone the same node is a sheet where it stands, | ||
| 113 | + which is what a screen the width of a dialog wants. | ||
| 111 | 114 | ||
| 112 | Both buttons are always here, the Bluesky one insensitive until there is a | 115 | 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 | 116 | profile to open: a dialog whose second button appears a moment after it |
| @@ -123,11 +126,12 @@ | |||
| 123 | 126 | ||
| 124 | And a dialog the pointer is holding open is not modal. A modal one makes | 127 | 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 | 128 | 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 | 129 | + hands its content an `Unavailable` cursor while a popup is up, and Flutter's |
| 127 | - that opened it never hears the pointer leave, and what a hover opened could | 130 | + side of this puts a scrim over the screens that absorbs what lands on it — so |
| 128 | - never close itself. Non-modal, the face keeps hearing, and moving away shuts | 131 | + the face that opened it never hears the pointer leave, and what a hover opened |
| 129 | - it. A pinned one is modal, which is what being pinned means: it is the thing | 132 | + could never close itself. Non-modal, the face keeps hearing, and moving away |
| 130 | - on the screen until it is dismissed." | 133 | + shuts it. A pinned one is modal, which is what being pinned means: it is the |
| 134 | + thing on the screen until it is dismissed." | ||
| 131 | [] | 135 | [] |
| 132 | (let [pinned? (some? (actions/viewing)) | 136 | (let [pinned? (some? (actions/viewing)) |
| 133 | {:keys [nick actor]} (or (actions/viewing) (actions/hovering)) | 137 | {:keys [nick actor]} (or (actions/viewing) (actions/hovering)) |
| @@ -339,13 +343,14 @@ | |||
| 339 | ;; from the conversation you were in a moment ago. A key that changes with | 343 | ;; 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 | 344 | ;; the screen makes the swap a swap: the old tree comes out whole and the new |
| 341 | ;; one goes in whole. | 345 | ;; one goes in whole. |
| 342 | - ;; And the dialog beside them all rather than instead of one of them. A | 346 | + ;; And the dialog beside them all rather than instead of one of them. In a |
| 343 | - ;; `dialog` node is not painted where it stands — the window backend hands | 347 | + ;; window a `dialog` node is not painted where it stands — it is put over the |
| 344 | - ;; it to libcosmic, which puts it over the middle of the window with what is | 348 | + ;; middle of the window with what is behind it dimmed, by libcosmic on one |
| 345 | - ;; behind it dimmed — so the screen under it keeps its place in the tree, | 349 | + ;; side and by `frq.hiccup/render-root` on the other — so the screen under it |
| 346 | - ;; and its scroll position with it. The wrapper is always here and only its | 350 | + ;; keeps its place in the tree, and its scroll position with it. The wrapper |
| 347 | - ;; child comes and goes, for the reason every other wrapper in this file | 351 | + ;; is always here and only its child comes and goes, for the reason every |
| 348 | - ;; gives: a child that appeared and vanished would renumber the root. | 352 | + ;; other wrapper in this file gives: a child that appeared and vanished would |
| 353 | + ;; renumber the root. | ||
| 349 | ;; | 354 | ;; |
| 350 | ;; The terminal has no such thing, and a `dialog` tag it has not grown would | 355 | ;; 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 | 356 | ;; paint its contents inline at the bottom of the screen. So there it stays |
modified
common/frq/screens/chat.cljc +6 -1 | @@ -1350,7 +1350,12 @@ | ||
| 1350 | 1350 | ;; which is the tree under `just run` and `just cosmic` and the store |
| 1351 | 1351 | ;; copy under the flake's launcher. A terminal has no pixels to put |
| 1352 | 1352 | ;; it in, and an APK carries no src/ to read it from — both keep the glyph. |
| 1353 | - (if (and (actions/desktop?) (not @terminal?)) | |
| 1353 | + ;; `:jolt` and not `desktop?`: the tile is a file read out of the working | |
| 1354 | + ;; directory, which is the source tree jolt runs from. Flutter's desktop | |
| 1355 | + ;; target is a window with a pointer like libcosmic's — `desktop?` is | |
| 1356 | + ;; true there now — but it is launched from `flutter/` and bundles no | |
| 1357 | + ;; src/, so it keeps the glyph the APK keeps. | |
| 1358 | + (if #?(:jolt (and (actions/desktop?) (not @terminal?)) :cljd false) | |
| 1354 | 1359 | [:image {:src "src/frq/icons/insert-image.png" |
| 1355 | 1360 | :size [36 36] |
| 1356 | 1361 | ;; On the middle of the field rather than the top of it: the |
| @@ -1350,7 +1350,12 @@ | |||
| 1350 | ;; which is the tree under `just run` and `just cosmic` and the store | 1350 | ;; which is the tree under `just run` and `just cosmic` and the store |
| 1351 | ;; copy under the flake's launcher. A terminal has no pixels to put | 1351 | ;; copy under the flake's launcher. A terminal has no pixels to put |
| 1352 | ;; it in, and an APK carries no src/ to read it from — both keep the glyph. | 1352 | ;; it in, and an APK carries no src/ to read it from — both keep the glyph. |
| 1353 | - (if (and (actions/desktop?) (not @terminal?)) | 1353 | + ;; `:jolt` and not `desktop?`: the tile is a file read out of the working |
| 1354 | + ;; directory, which is the source tree jolt runs from. Flutter's desktop | ||
| 1355 | + ;; target is a window with a pointer like libcosmic's — `desktop?` is | ||
| 1356 | + ;; true there now — but it is launched from `flutter/` and bundles no | ||
| 1357 | + ;; src/, so it keeps the glyph the APK keeps. | ||
| 1358 | + (if #?(:jolt (and (actions/desktop?) (not @terminal?)) :cljd false) | ||
| 1354 | [:image {:src "src/frq/icons/insert-image.png" | 1359 | [:image {:src "src/frq/icons/insert-image.png" |
| 1355 | :size [36 36] | 1360 | :size [36 36] |
| 1356 | ;; On the middle of the field rather than the top of it: the | 1361 | ;; On the middle of the field rather than the top of it: the |
modified
flutter/src/frq/hiccup.cljd +250 -47 | @@ -42,6 +42,36 @@ | ||
| 42 | 42 | (let [w (:width-request p)] |
| 43 | 43 | (when (and (number? w) (pos? w)) (double w)))) |
| 44 | 44 | |
| 45 | +(defn pointer? | |
| 46 | + "Whether there is a mouse here rather than a finger. | |
| 47 | + | |
| 48 | + Both Flutter targets compile this file — `just apk` and | |
| 49 | + `just flutter-desktop` — so the phone is no longer a safe assumption to bury | |
| 50 | + in an arm below. What hangs on it is everything a pointer can do that a | |
| 51 | + finger cannot: resting on a thing to ask about it, and the card that answers | |
| 52 | + floating over the screen instead of taking a place in it. | |
| 53 | + | |
| 54 | + `frq.screens.*` asks the host the same question as `actions/desktop?`, and | |
| 55 | + gets its answer from the same place; this is the renderer's side of it, for | |
| 56 | + the parts of a hover that never reach the shared screens at all." | |
| 57 | + [] | |
| 58 | + (not (.-isAndroid io/Platform))) | |
| 59 | + | |
| 60 | +(defn- hovered | |
| 61 | + "`w`, told when the pointer arrives and when it leaves. | |
| 62 | + | |
| 63 | + Plain `w` where there is nothing to tell — `MouseRegion` is cheap but not | |
| 64 | + free, and on a phone the callbacks could never fire anyway." | |
| 65 | + [p w] | |
| 66 | + (let [in (:on-hover p) | |
| 67 | + out (:on-unhover p)] | |
| 68 | + (if (and (pointer?) (or in out)) | |
| 69 | + (m/MouseRegion | |
| 70 | + .onEnter (when in (fn [_] (in))) | |
| 71 | + .onExit (when out (fn [_] (out))) | |
| 72 | + .child w) | |
| 73 | + w))) | |
| 74 | + | |
| 45 | 75 | (defn- dbl [x default] |
| 46 | 76 | (cond (number? x) (double x) |
| 47 | 77 | :else default)) |
| @@ -642,51 +672,131 @@ | ||
| 642 | 672 | n (:count p) |
| 643 | 673 | mine (boolean (:mine p)) |
| 644 | 674 | pad (max 2.0 (* 0.25 size))] |
| 645 | - (m/InkWell | |
| 646 | - .onTap (when-let [on (:on-click p)] #(on)) | |
| 647 | - .borderRadius (m/BorderRadius.circular t/radius-s) | |
| 648 | - .child | |
| 649 | - (m/Container | |
| 650 | - .padding (m/EdgeInsets.symmetric .horizontal pad .vertical (* 0.5 pad)) | |
| 651 | - .decoration (m/BoxDecoration | |
| 652 | - .color (if mine t/accent t/card-component) | |
| 653 | - .borderRadius (m/BorderRadius.circular t/radius-s)) | |
| 675 | + ;; And, where there is a pointer, resting on a pill says who put the | |
| 676 | + ;; reaction there — the card `reactor-dialog` raises out of | |
| 677 | + ;; `reaction-hover`. The pill's only job is to say where the pointer | |
| 678 | + ;; is; see the `:avatar` arm, which answers the same way about a face. | |
| 679 | + (hovered | |
| 680 | + p | |
| 681 | + (m/InkWell | |
| 682 | + .onTap (when-let [on (:on-click p)] #(on)) | |
| 683 | + .borderRadius (m/BorderRadius.circular t/radius-s) | |
| 654 | 684 | .child |
| 655 | - (m/Row | |
| 656 | - .mainAxisSize m/MainAxisSize.min | |
| 657 | - .children | |
| 658 | - (into [(m/Text (str (:emoji p "")) | |
| 659 | - .style (m/TextStyle .fontSize size))] | |
| 660 | - (when (and (number? n) (pos? n)) | |
| 661 | - [(m/SizedBox .width pad) | |
| 662 | - (m/Text (str n) | |
| 663 | - .style (m/TextStyle | |
| 664 | - .fontSize (* 0.85 size) | |
| 665 | - .color (if mine t/on-accent t/on-card)))])))))) | |
| 685 | + (m/Container | |
| 686 | + .padding (m/EdgeInsets.symmetric .horizontal pad .vertical (* 0.5 pad)) | |
| 687 | + .decoration (m/BoxDecoration | |
| 688 | + .color (if mine t/accent t/card-component) | |
| 689 | + .borderRadius (m/BorderRadius.circular t/radius-s)) | |
| 690 | + .child | |
| 691 | + (m/Row | |
| 692 | + .mainAxisSize m/MainAxisSize.min | |
| 693 | + .children | |
| 694 | + (into [(m/Text (str (:emoji p "")) | |
| 695 | + .style (m/TextStyle .fontSize size))] | |
| 696 | + (when (and (number? n) (pos? n)) | |
| 697 | + [(m/SizedBox .width pad) | |
| 698 | + (m/Text (str n) | |
| 699 | + .style (m/TextStyle | |
| 700 | + .fontSize (* 0.85 size) | |
| 701 | + .color (if mine t/on-accent t/on-card)))]))))))) | |
| 666 | 702 | |
| 667 | 703 | ;; libcosmic's dialog is centred over the window with what you were |
| 668 | - ;; reading dimmed behind it. There is no behind on a phone worth | |
| 669 | - ;; keeping: the screen is the width of the dialog already, so this is a | |
| 670 | - ;; sheet — the title, a rule, and the body — drawn where the tree puts | |
| 671 | - ;; it, above the conversation it was opened from. | |
| 704 | + ;; reading dimmed behind it, and there are two answers to that here | |
| 705 | + ;; because there are two Flutter targets. | |
| 672 | 706 | ;; |
| 673 | - ;; `:modal` and the hover pair are read and ignored. They are what a | |
| 674 | - ;; pointer needs to tell a card it opened by resting from one it opened | |
| 675 | - ;; by pressing, and a finger only ever presses. | |
| 707 | + ;; On a phone there is no behind worth keeping — the screen is the width | |
| 708 | + ;; of the dialog already — so this is a sheet: the title, a rule, and | |
| 709 | + ;; the body, drawn where the tree puts it, above the conversation it was | |
| 710 | + ;; opened from. `:modal` and the hover pair are read and ignored, since | |
| 711 | + ;; they are what a pointer needs to tell a card it opened by resting | |
| 712 | + ;; from one it opened by pressing, and a finger only ever presses. | |
| 713 | + ;; | |
| 714 | + ;; In a window it is what libcosmic's is: capped at `:max-width`, | |
| 715 | + ;; centred over the screen, the buttons gathered into a foot, and the | |
| 716 | + ;; hover pair heard — so a card a pointer raised can be moved into and | |
| 717 | + ;; read rather than only glanced at. Nothing about the floating is here, | |
| 718 | + ;; though; a widget cannot lift itself out of the column it is in. That | |
| 719 | + ;; is `render-root`, which takes the dialog out of the tree and stacks | |
| 720 | + ;; it over what is left. This arm only has to draw the card. | |
| 676 | 721 | :dialog |
| 677 | - (m/Container | |
| 678 | - .width double/infinity | |
| 679 | - .margin (m/EdgeInsets.only .bottom t/space-xs) | |
| 680 | - .padding (m/EdgeInsets.all t/space-xs) | |
| 681 | - .decoration (m/BoxDecoration | |
| 682 | - .color t/card | |
| 683 | - .border (m/Border.all .color t/divider .width 1.0) | |
| 684 | - .borderRadius (m/BorderRadius.circular t/radius-m)) | |
| 685 | - .child | |
| 686 | - (col t/space-xxs | |
| 687 | - (into [[:title-2 {:label (:label p "")}] | |
| 688 | - [:separator {}]] | |
| 689 | - (body node)))) | |
| 722 | + (if-not (pointer?) | |
| 723 | + (m/Container | |
| 724 | + .width double/infinity | |
| 725 | + .margin (m/EdgeInsets.only .bottom t/space-xs) | |
| 726 | + .padding (m/EdgeInsets.all t/space-xs) | |
| 727 | + .decoration (m/BoxDecoration | |
| 728 | + .color t/card | |
| 729 | + .border (m/Border.all .color t/divider .width 1.0) | |
| 730 | + .borderRadius (m/BorderRadius.circular t/radius-m)) | |
| 731 | + .child | |
| 732 | + (col t/space-xxs | |
| 733 | + (into [[:title-2 {:label (:label p "")}] | |
| 734 | + [:separator {}]] | |
| 735 | + (body node)))) | |
| 736 | + ;; `:slot` is libcosmic's word for "this belongs in the foot rather | |
| 737 | + ;; than in the body". Read here for the same reason: stacked into the | |
| 738 | + ;; body instead, Close and Bluesky are two full-width bars under the | |
| 739 | + ;; bio rather than a row of buttons under a rule. | |
| 740 | + (let [kids (remove nil? (map expand (body node))) | |
| 741 | + slotted (fn [n] (let [q (props n)] (not-empty (str (:slot q ""))))) | |
| 742 | + foot (filter slotted kids) | |
| 743 | + ;; Secondary first, so the button that closes the card is the | |
| 744 | + ;; one nearest the corner the pointer leaves by. | |
| 745 | + foot (concat (remove #(= "primary" (:slot (props %))) foot) | |
| 746 | + (filter #(= "primary" (:slot (props %))) foot))] | |
| 747 | + (hovered | |
| 748 | + p | |
| 749 | + (m/ConstrainedBox | |
| 750 | + ;; Capped both ways. The width is what libcosmic is asked for; the | |
| 751 | + ;; height is so that a long bio is a card with a scroll in it | |
| 752 | + ;; rather than a card taller than the window. The body is the part | |
| 753 | + ;; that gives — the title and the buttons are why it is a dialog. | |
| 754 | + .constraints (m/BoxConstraints | |
| 755 | + .maxWidth (dbl (:max-width p) 520.0) | |
| 756 | + .maxHeight (* 0.8 (.-height (.-size (m/MediaQuery.of ctx))))) | |
| 757 | + .child | |
| 758 | + (m/Container | |
| 759 | + ;; Infinity under a maxWidth of 520 is 520, and under a narrower | |
| 760 | + ;; window it is the window: the card is as wide as it is allowed | |
| 761 | + ;; to be, never as wide as its longest line. Without it the | |
| 762 | + ;; Column is sized to its children and the rules inside it ask | |
| 763 | + ;; for an unbounded width, which is a layout error rather than a | |
| 764 | + ;; narrow dialog. | |
| 765 | + .width double/infinity | |
| 766 | + .padding (m/EdgeInsets.all t/space-s) | |
| 767 | + .decoration (m/BoxDecoration | |
| 768 | + .color t/card | |
| 769 | + .border (m/Border.all .color t/divider .width 1.0) | |
| 770 | + .borderRadius (m/BorderRadius.circular t/radius-m)) | |
| 771 | + .child | |
| 772 | + ;; Built out rather than handed to `col`, because the foot is a | |
| 773 | + ;; Row aligned to the end and `:hbox` has no word for that. One | |
| 774 | + ;; place that wants it and no prop invented for it. | |
| 775 | + (m/Column | |
| 776 | + .crossAxisAlignment m/CrossAxisAlignment.start | |
| 777 | + .mainAxisSize m/MainAxisSize.min | |
| 778 | + .spacing t/space-xxs | |
| 779 | + .children | |
| 780 | + (concat [(render [:title-2 {:label (:label p "")}]) | |
| 781 | + (render [:separator {}])] | |
| 782 | + ;; Flexible and loose: the body takes what it needs up | |
| 783 | + ;; to what the cap leaves, and scrolls inside that. A | |
| 784 | + ;; short profile is still a short card. | |
| 785 | + [(m/Flexible | |
| 786 | + .child | |
| 787 | + (m/SingleChildScrollView | |
| 788 | + .child (m/Column | |
| 789 | + .crossAxisAlignment m/CrossAxisAlignment.start | |
| 790 | + .mainAxisSize m/MainAxisSize.min | |
| 791 | + .spacing t/space-xxs | |
| 792 | + .children (children (remove slotted kids)))))] | |
| 793 | + (when (seq foot) | |
| 794 | + [(render [:separator {}]) | |
| 795 | + (m/Row | |
| 796 | + .mainAxisSize m/MainAxisSize.max | |
| 797 | + .mainAxisAlignment m/MainAxisAlignment.end | |
| 798 | + .spacing t/space-xxs | |
| 799 | + .children (children foot))])))))))) | |
| 690 | 800 | |
| 691 | 801 | :separator (m/Divider .height 1.0 .thickness 1.0 .color t/divider) |
| 692 | 802 | |
| @@ -789,8 +899,12 @@ | ||
| 789 | 899 | ;; A face is a way in to who someone is, so it takes the press that |
| 790 | 900 | ;; opens their profile. It was drawn without one and the chat screen's |
| 791 | 901 | ;; `:on-click` went nowhere — the one gesture a phone has for this. |
| 792 | - ;; `:on-hover` and `:on-unhover` come with it on the desktop and are | |
| 793 | - ;; nothing here. | |
| 902 | + ;; | |
| 903 | + ;; And, in a window, the gesture that comes before the press: resting on | |
| 904 | + ;; a face raises the card without pinning it. `hovered` is what hears | |
| 905 | + ;; that; the card it raises is the `:dialog` arm above, floated by | |
| 906 | + ;; `render-root`. On the phone the screens do not even hang a hover on a | |
| 907 | + ;; face — they ask `desktop?` first — and `hovered` is a no-op besides. | |
| 794 | 908 | :avatar |
| 795 | 909 | (let [s (dbl (:size p) 32.0) |
| 796 | 910 | src (:src p) |
| @@ -820,11 +934,13 @@ | ||
| 820 | 934 | (.toUpperCase (subs l 0 1)) |
| 821 | 935 | "?")) |
| 822 | 936 | t/text-body t/on-bg)))] |
| 823 | - (if-let [on (:on-click p)] | |
| 824 | - (m/InkWell .onTap #(on) | |
| 825 | - .customBorder (m/CircleBorder) | |
| 826 | - .child face) | |
| 827 | - face)) | |
| 937 | + (hovered | |
| 938 | + p | |
| 939 | + (if-let [on (:on-click p)] | |
| 940 | + (m/InkWell .onTap #(on) | |
| 941 | + .customBorder (m/CircleBorder) | |
| 942 | + .child face) | |
| 943 | + face))) | |
| 828 | 944 | |
| 829 | 945 | :image |
| 830 | 946 | (let [src (or (:src p) (:path p)) |
| @@ -929,3 +1045,90 @@ | ||
| 929 | 1045 | .mainAxisSize m/MainAxisSize.min |
| 930 | 1046 | .children (children node)) |
| 931 | 1047 | :else (m/Text (str node)))) |
| 1048 | + | |
| 1049 | +;; ------------------------------------------------------------------- root | |
| 1050 | + | |
| 1051 | +(defn- dialog-node? | |
| 1052 | + [node] | |
| 1053 | + (and (vector? node) (= :dialog (first node)))) | |
| 1054 | + | |
| 1055 | +(defn- lift-dialog | |
| 1056 | + "`[tree-without-its-dialog dialog-or-nil]`. | |
| 1057 | + | |
| 1058 | + `frq.screens.app` puts the dialog beside the screens rather than instead of | |
| 1059 | + one, in a wrapper that is always there so that a child coming and going | |
| 1060 | + cannot renumber the root — see the comment on `app`. libcosmic takes that | |
| 1061 | + node and floats it itself. Flutter has no such hand-off: the node is a widget | |
| 1062 | + in a Column, and a widget cannot lift itself out of the column it is in. So | |
| 1063 | + this does the lifting, and `render-root` does the floating. | |
| 1064 | + | |
| 1065 | + Only `:vbox` is descended into, which is every wrapper between the root and | |
| 1066 | + the dialog and nothing else — there is no reason to walk a conversation | |
| 1067 | + looking for one. And the node is replaced by its expansion on the way past, | |
| 1068 | + so the component behind it runs once rather than once here and once again | |
| 1069 | + when the tree it came out of is rendered." | |
| 1070 | + [node] | |
| 1071 | + (let [n (expand node)] | |
| 1072 | + (cond | |
| 1073 | + (dialog-node? n) [nil n] | |
| 1074 | + (and (vector? n) (= :vbox (first n))) | |
| 1075 | + (let [p (props n) | |
| 1076 | + head (if (map? (second n)) [(first n) p] [(first n)])] | |
| 1077 | + (reduce (fn [[acc found] kid] | |
| 1078 | + (if found | |
| 1079 | + [(conj acc kid) found] | |
| 1080 | + (let [[kid' d] (lift-dialog kid)] | |
| 1081 | + [(conj acc kid') (or d found)]))) | |
| 1082 | + [head nil] | |
| 1083 | + (body n))) | |
| 1084 | + :else [n nil]))) | |
| 1085 | + | |
| 1086 | +(defn render-root | |
| 1087 | + "The whole app as one widget: the screens, and the dialog floated over them. | |
| 1088 | + | |
| 1089 | + On a phone this is `render` and nothing more. The dialog there is a sheet | |
| 1090 | + drawn where the tree puts it, which is what a screen the width of a dialog | |
| 1091 | + wants. | |
| 1092 | + | |
| 1093 | + In a window it is libcosmic's arrangement, built out of a Stack: the screens | |
| 1094 | + at their full size, a scrim, and the card centred over both. The screen keeps | |
| 1095 | + its place in the tree either way — and so its scroll position — because the | |
| 1096 | + dialog was never in its subtree to begin with. | |
| 1097 | + | |
| 1098 | + Two things about the scrim, and they are the same two things `profile-dialog` | |
| 1099 | + says about modality. A card that was *pressed* open dims what is behind it | |
| 1100 | + and swallows the presses meant for it: it is the thing on the screen until it | |
| 1101 | + is dismissed. A card the pointer is merely *holding* open does neither — it | |
| 1102 | + paints no dim, and `IgnorePointer` lets every event through to the face | |
| 1103 | + underneath, because the face has to keep hearing the pointer or nothing would | |
| 1104 | + ever tell the card to go." | |
| 1105 | + [node] | |
| 1106 | + (if-not (pointer?) | |
| 1107 | + (render node) | |
| 1108 | + (let [[tree dialog] (lift-dialog node)] | |
| 1109 | + (if (nil? dialog) | |
| 1110 | + (render tree) | |
| 1111 | + (let [modal (boolean (:modal (props dialog)))] | |
| 1112 | + (m/Stack | |
| 1113 | + .children | |
| 1114 | + [(render tree) | |
| 1115 | + (m/Positioned.fill | |
| 1116 | + .child | |
| 1117 | + (let [scrim (m/ColoredBox | |
| 1118 | + .color (if modal | |
| 1119 | + (m/Color.fromRGBO 0 0 0 0.45) | |
| 1120 | + (m/Color.fromRGBO 0 0 0 0.0)))] | |
| 1121 | + (if modal | |
| 1122 | + ;; Absorbing and not merely painting: a press on the dimmed | |
| 1123 | + ;; half of the window is a press meant for the card. | |
| 1124 | + (m/GestureDetector .onTap (fn [] nil) .child scrim) | |
| 1125 | + (m/IgnorePointer .child scrim)))) | |
| 1126 | + ;; `Center` and not a scroll view over the screen: a Center hands | |
| 1127 | + ;; the hit test to its child's bounds and nothing more, while a | |
| 1128 | + ;; viewport absorbs every event inside its own — which here is the | |
| 1129 | + ;; whole window, faces included. The card does its own scrolling. | |
| 1130 | + (m/Positioned.fill | |
| 1131 | + .child | |
| 1132 | + (m/Padding | |
| 1133 | + .padding (m/EdgeInsets.all t/space-s) | |
| 1134 | + .child (m/Center .child (render dialog))))])))))) | |
| @@ -42,6 +42,36 @@ | |||
| 42 | (let [w (:width-request p)] | 42 | (let [w (:width-request p)] |
| 43 | (when (and (number? w) (pos? w)) (double w)))) | 43 | (when (and (number? w) (pos? w)) (double w)))) |
| 44 | 44 | ||
| 45 | +(defn pointer? | ||
| 46 | + "Whether there is a mouse here rather than a finger. | ||
| 47 | + | ||
| 48 | + Both Flutter targets compile this file — `just apk` and | ||
| 49 | + `just flutter-desktop` — so the phone is no longer a safe assumption to bury | ||
| 50 | + in an arm below. What hangs on it is everything a pointer can do that a | ||
| 51 | + finger cannot: resting on a thing to ask about it, and the card that answers | ||
| 52 | + floating over the screen instead of taking a place in it. | ||
| 53 | + | ||
| 54 | + `frq.screens.*` asks the host the same question as `actions/desktop?`, and | ||
| 55 | + gets its answer from the same place; this is the renderer's side of it, for | ||
| 56 | + the parts of a hover that never reach the shared screens at all." | ||
| 57 | + [] | ||
| 58 | + (not (.-isAndroid io/Platform))) | ||
| 59 | + | ||
| 60 | +(defn- hovered | ||
| 61 | + "`w`, told when the pointer arrives and when it leaves. | ||
| 62 | + | ||
| 63 | + Plain `w` where there is nothing to tell — `MouseRegion` is cheap but not | ||
| 64 | + free, and on a phone the callbacks could never fire anyway." | ||
| 65 | + [p w] | ||
| 66 | + (let [in (:on-hover p) | ||
| 67 | + out (:on-unhover p)] | ||
| 68 | + (if (and (pointer?) (or in out)) | ||
| 69 | + (m/MouseRegion | ||
| 70 | + .onEnter (when in (fn [_] (in))) | ||
| 71 | + .onExit (when out (fn [_] (out))) | ||
| 72 | + .child w) | ||
| 73 | + w))) | ||
| 74 | + | ||
| 45 | (defn- dbl [x default] | 75 | (defn- dbl [x default] |
| 46 | (cond (number? x) (double x) | 76 | (cond (number? x) (double x) |
| 47 | :else default)) | 77 | :else default)) |
| @@ -642,51 +672,131 @@ | |||
| 642 | n (:count p) | 672 | n (:count p) |
| 643 | mine (boolean (:mine p)) | 673 | mine (boolean (:mine p)) |
| 644 | pad (max 2.0 (* 0.25 size))] | 674 | pad (max 2.0 (* 0.25 size))] |
| 645 | - (m/InkWell | 675 | + ;; And, where there is a pointer, resting on a pill says who put the |
| 646 | - .onTap (when-let [on (:on-click p)] #(on)) | 676 | + ;; reaction there — the card `reactor-dialog` raises out of |
| 647 | - .borderRadius (m/BorderRadius.circular t/radius-s) | 677 | + ;; `reaction-hover`. The pill's only job is to say where the pointer |
| 648 | - .child | 678 | + ;; is; see the `:avatar` arm, which answers the same way about a face. |
| 649 | - (m/Container | 679 | + (hovered |
| 650 | - .padding (m/EdgeInsets.symmetric .horizontal pad .vertical (* 0.5 pad)) | 680 | + p |
| 651 | - .decoration (m/BoxDecoration | 681 | + (m/InkWell |
| 652 | - .color (if mine t/accent t/card-component) | 682 | + .onTap (when-let [on (:on-click p)] #(on)) |
| 653 | - .borderRadius (m/BorderRadius.circular t/radius-s)) | 683 | + .borderRadius (m/BorderRadius.circular t/radius-s) |
| 654 | .child | 684 | .child |
| 655 | - (m/Row | 685 | + (m/Container |
| 656 | - .mainAxisSize m/MainAxisSize.min | 686 | + .padding (m/EdgeInsets.symmetric .horizontal pad .vertical (* 0.5 pad)) |
| 657 | - .children | 687 | + .decoration (m/BoxDecoration |
| 658 | - (into [(m/Text (str (:emoji p "")) | 688 | + .color (if mine t/accent t/card-component) |
| 659 | - .style (m/TextStyle .fontSize size))] | 689 | + .borderRadius (m/BorderRadius.circular t/radius-s)) |
| 660 | - (when (and (number? n) (pos? n)) | 690 | + .child |
| 661 | - [(m/SizedBox .width pad) | 691 | + (m/Row |
| 662 | - (m/Text (str n) | 692 | + .mainAxisSize m/MainAxisSize.min |
| 663 | - .style (m/TextStyle | 693 | + .children |
| 664 | - .fontSize (* 0.85 size) | 694 | + (into [(m/Text (str (:emoji p "")) |
| 665 | - .color (if mine t/on-accent t/on-card)))])))))) | 695 | + .style (m/TextStyle .fontSize size))] |
| 696 | + (when (and (number? n) (pos? n)) | ||
| 697 | + [(m/SizedBox .width pad) | ||
| 698 | + (m/Text (str n) | ||
| 699 | + .style (m/TextStyle | ||
| 700 | + .fontSize (* 0.85 size) | ||
| 701 | + .color (if mine t/on-accent t/on-card)))]))))))) | ||
| 666 | 702 | ||
| 667 | ;; libcosmic's dialog is centred over the window with what you were | 703 | ;; libcosmic's dialog is centred over the window with what you were |
| 668 | - ;; reading dimmed behind it. There is no behind on a phone worth | 704 | + ;; reading dimmed behind it, and there are two answers to that here |
| 669 | - ;; keeping: the screen is the width of the dialog already, so this is a | 705 | + ;; because there are two Flutter targets. |
| 670 | - ;; sheet — the title, a rule, and the body — drawn where the tree puts | ||
| 671 | - ;; it, above the conversation it was opened from. | ||
| 672 | ;; | 706 | ;; |
| 673 | - ;; `:modal` and the hover pair are read and ignored. They are what a | 707 | + ;; On a phone there is no behind worth keeping — the screen is the width |
| 674 | - ;; pointer needs to tell a card it opened by resting from one it opened | 708 | + ;; of the dialog already — so this is a sheet: the title, a rule, and |
| 675 | - ;; by pressing, and a finger only ever presses. | 709 | + ;; the body, drawn where the tree puts it, above the conversation it was |
| 710 | + ;; opened from. `:modal` and the hover pair are read and ignored, since | ||
| 711 | + ;; they are what a pointer needs to tell a card it opened by resting | ||
| 712 | + ;; from one it opened by pressing, and a finger only ever presses. | ||
| 713 | + ;; | ||
| 714 | + ;; In a window it is what libcosmic's is: capped at `:max-width`, | ||
| 715 | + ;; centred over the screen, the buttons gathered into a foot, and the | ||
| 716 | + ;; hover pair heard — so a card a pointer raised can be moved into and | ||
| 717 | + ;; read rather than only glanced at. Nothing about the floating is here, | ||
| 718 | + ;; though; a widget cannot lift itself out of the column it is in. That | ||
| 719 | + ;; is `render-root`, which takes the dialog out of the tree and stacks | ||
| 720 | + ;; it over what is left. This arm only has to draw the card. | ||
| 676 | :dialog | 721 | :dialog |
| 677 | - (m/Container | 722 | + (if-not (pointer?) |
| 678 | - .width double/infinity | 723 | + (m/Container |
| 679 | - .margin (m/EdgeInsets.only .bottom t/space-xs) | 724 | + .width double/infinity |
| 680 | - .padding (m/EdgeInsets.all t/space-xs) | 725 | + .margin (m/EdgeInsets.only .bottom t/space-xs) |
| 681 | - .decoration (m/BoxDecoration | 726 | + .padding (m/EdgeInsets.all t/space-xs) |
| 682 | - .color t/card | 727 | + .decoration (m/BoxDecoration |
| 683 | - .border (m/Border.all .color t/divider .width 1.0) | 728 | + .color t/card |
| 684 | - .borderRadius (m/BorderRadius.circular t/radius-m)) | 729 | + .border (m/Border.all .color t/divider .width 1.0) |
| 685 | - .child | 730 | + .borderRadius (m/BorderRadius.circular t/radius-m)) |
| 686 | - (col t/space-xxs | 731 | + .child |
| 687 | - (into [[:title-2 {:label (:label p "")}] | 732 | + (col t/space-xxs |
| 688 | - [:separator {}]] | 733 | + (into [[:title-2 {:label (:label p "")}] |
| 689 | - (body node)))) | 734 | + [:separator {}]] |
| 735 | + (body node)))) | ||
| 736 | + ;; `:slot` is libcosmic's word for "this belongs in the foot rather | ||
| 737 | + ;; than in the body". Read here for the same reason: stacked into the | ||
| 738 | + ;; body instead, Close and Bluesky are two full-width bars under the | ||
| 739 | + ;; bio rather than a row of buttons under a rule. | ||
| 740 | + (let [kids (remove nil? (map expand (body node))) | ||
| 741 | + slotted (fn [n] (let [q (props n)] (not-empty (str (:slot q ""))))) | ||
| 742 | + foot (filter slotted kids) | ||
| 743 | + ;; Secondary first, so the button that closes the card is the | ||
| 744 | + ;; one nearest the corner the pointer leaves by. | ||
| 745 | + foot (concat (remove #(= "primary" (:slot (props %))) foot) | ||
| 746 | + (filter #(= "primary" (:slot (props %))) foot))] | ||
| 747 | + (hovered | ||
| 748 | + p | ||
| 749 | + (m/ConstrainedBox | ||
| 750 | + ;; Capped both ways. The width is what libcosmic is asked for; the | ||
| 751 | + ;; height is so that a long bio is a card with a scroll in it | ||
| 752 | + ;; rather than a card taller than the window. The body is the part | ||
| 753 | + ;; that gives — the title and the buttons are why it is a dialog. | ||
| 754 | + .constraints (m/BoxConstraints | ||
| 755 | + .maxWidth (dbl (:max-width p) 520.0) | ||
| 756 | + .maxHeight (* 0.8 (.-height (.-size (m/MediaQuery.of ctx))))) | ||
| 757 | + .child | ||
| 758 | + (m/Container | ||
| 759 | + ;; Infinity under a maxWidth of 520 is 520, and under a narrower | ||
| 760 | + ;; window it is the window: the card is as wide as it is allowed | ||
| 761 | + ;; to be, never as wide as its longest line. Without it the | ||
| 762 | + ;; Column is sized to its children and the rules inside it ask | ||
| 763 | + ;; for an unbounded width, which is a layout error rather than a | ||
| 764 | + ;; narrow dialog. | ||
| 765 | + .width double/infinity | ||
| 766 | + .padding (m/EdgeInsets.all t/space-s) | ||
| 767 | + .decoration (m/BoxDecoration | ||
| 768 | + .color t/card | ||
| 769 | + .border (m/Border.all .color t/divider .width 1.0) | ||
| 770 | + .borderRadius (m/BorderRadius.circular t/radius-m)) | ||
| 771 | + .child | ||
| 772 | + ;; Built out rather than handed to `col`, because the foot is a | ||
| 773 | + ;; Row aligned to the end and `:hbox` has no word for that. One | ||
| 774 | + ;; place that wants it and no prop invented for it. | ||
| 775 | + (m/Column | ||
| 776 | + .crossAxisAlignment m/CrossAxisAlignment.start | ||
| 777 | + .mainAxisSize m/MainAxisSize.min | ||
| 778 | + .spacing t/space-xxs | ||
| 779 | + .children | ||
| 780 | + (concat [(render [:title-2 {:label (:label p "")}]) | ||
| 781 | + (render [:separator {}])] | ||
| 782 | + ;; Flexible and loose: the body takes what it needs up | ||
| 783 | + ;; to what the cap leaves, and scrolls inside that. A | ||
| 784 | + ;; short profile is still a short card. | ||
| 785 | + [(m/Flexible | ||
| 786 | + .child | ||
| 787 | + (m/SingleChildScrollView | ||
| 788 | + .child (m/Column | ||
| 789 | + .crossAxisAlignment m/CrossAxisAlignment.start | ||
| 790 | + .mainAxisSize m/MainAxisSize.min | ||
| 791 | + .spacing t/space-xxs | ||
| 792 | + .children (children (remove slotted kids)))))] | ||
| 793 | + (when (seq foot) | ||
| 794 | + [(render [:separator {}]) | ||
| 795 | + (m/Row | ||
| 796 | + .mainAxisSize m/MainAxisSize.max | ||
| 797 | + .mainAxisAlignment m/MainAxisAlignment.end | ||
| 798 | + .spacing t/space-xxs | ||
| 799 | + .children (children foot))])))))))) | ||
| 690 | 800 | ||
| 691 | :separator (m/Divider .height 1.0 .thickness 1.0 .color t/divider) | 801 | :separator (m/Divider .height 1.0 .thickness 1.0 .color t/divider) |
| 692 | 802 | ||
| @@ -789,8 +899,12 @@ | |||
| 789 | ;; A face is a way in to who someone is, so it takes the press that | 899 | ;; A face is a way in to who someone is, so it takes the press that |
| 790 | ;; opens their profile. It was drawn without one and the chat screen's | 900 | ;; opens their profile. It was drawn without one and the chat screen's |
| 791 | ;; `:on-click` went nowhere — the one gesture a phone has for this. | 901 | ;; `:on-click` went nowhere — the one gesture a phone has for this. |
| 792 | - ;; `:on-hover` and `:on-unhover` come with it on the desktop and are | 902 | + ;; |
| 793 | - ;; nothing here. | 903 | + ;; And, in a window, the gesture that comes before the press: resting on |
| 904 | + ;; a face raises the card without pinning it. `hovered` is what hears | ||
| 905 | + ;; that; the card it raises is the `:dialog` arm above, floated by | ||
| 906 | + ;; `render-root`. On the phone the screens do not even hang a hover on a | ||
| 907 | + ;; face — they ask `desktop?` first — and `hovered` is a no-op besides. | ||
| 794 | :avatar | 908 | :avatar |
| 795 | (let [s (dbl (:size p) 32.0) | 909 | (let [s (dbl (:size p) 32.0) |
| 796 | src (:src p) | 910 | src (:src p) |
| @@ -820,11 +934,13 @@ | |||
| 820 | (.toUpperCase (subs l 0 1)) | 934 | (.toUpperCase (subs l 0 1)) |
| 821 | "?")) | 935 | "?")) |
| 822 | t/text-body t/on-bg)))] | 936 | t/text-body t/on-bg)))] |
| 823 | - (if-let [on (:on-click p)] | 937 | + (hovered |
| 824 | - (m/InkWell .onTap #(on) | 938 | + p |
| 825 | - .customBorder (m/CircleBorder) | 939 | + (if-let [on (:on-click p)] |
| 826 | - .child face) | 940 | + (m/InkWell .onTap #(on) |
| 827 | - face)) | 941 | + .customBorder (m/CircleBorder) |
| 942 | + .child face) | ||
| 943 | + face))) | ||
| 828 | 944 | ||
| 829 | :image | 945 | :image |
| 830 | (let [src (or (:src p) (:path p)) | 946 | (let [src (or (:src p) (:path p)) |
| @@ -929,3 +1045,90 @@ | |||
| 929 | .mainAxisSize m/MainAxisSize.min | 1045 | .mainAxisSize m/MainAxisSize.min |
| 930 | .children (children node)) | 1046 | .children (children node)) |
| 931 | :else (m/Text (str node)))) | 1047 | :else (m/Text (str node)))) |
| 1048 | + | ||
| 1049 | +;; ------------------------------------------------------------------- root | ||
| 1050 | + | ||
| 1051 | +(defn- dialog-node? | ||
| 1052 | + [node] | ||
| 1053 | + (and (vector? node) (= :dialog (first node)))) | ||
| 1054 | + | ||
| 1055 | +(defn- lift-dialog | ||
| 1056 | + "`[tree-without-its-dialog dialog-or-nil]`. | ||
| 1057 | + | ||
| 1058 | + `frq.screens.app` puts the dialog beside the screens rather than instead of | ||
| 1059 | + one, in a wrapper that is always there so that a child coming and going | ||
| 1060 | + cannot renumber the root — see the comment on `app`. libcosmic takes that | ||
| 1061 | + node and floats it itself. Flutter has no such hand-off: the node is a widget | ||
| 1062 | + in a Column, and a widget cannot lift itself out of the column it is in. So | ||
| 1063 | + this does the lifting, and `render-root` does the floating. | ||
| 1064 | + | ||
| 1065 | + Only `:vbox` is descended into, which is every wrapper between the root and | ||
| 1066 | + the dialog and nothing else — there is no reason to walk a conversation | ||
| 1067 | + looking for one. And the node is replaced by its expansion on the way past, | ||
| 1068 | + so the component behind it runs once rather than once here and once again | ||
| 1069 | + when the tree it came out of is rendered." | ||
| 1070 | + [node] | ||
| 1071 | + (let [n (expand node)] | ||
| 1072 | + (cond | ||
| 1073 | + (dialog-node? n) [nil n] | ||
| 1074 | + (and (vector? n) (= :vbox (first n))) | ||
| 1075 | + (let [p (props n) | ||
| 1076 | + head (if (map? (second n)) [(first n) p] [(first n)])] | ||
| 1077 | + (reduce (fn [[acc found] kid] | ||
| 1078 | + (if found | ||
| 1079 | + [(conj acc kid) found] | ||
| 1080 | + (let [[kid' d] (lift-dialog kid)] | ||
| 1081 | + [(conj acc kid') (or d found)]))) | ||
| 1082 | + [head nil] | ||
| 1083 | + (body n))) | ||
| 1084 | + :else [n nil]))) | ||
| 1085 | + | ||
| 1086 | +(defn render-root | ||
| 1087 | + "The whole app as one widget: the screens, and the dialog floated over them. | ||
| 1088 | + | ||
| 1089 | + On a phone this is `render` and nothing more. The dialog there is a sheet | ||
| 1090 | + drawn where the tree puts it, which is what a screen the width of a dialog | ||
| 1091 | + wants. | ||
| 1092 | + | ||
| 1093 | + In a window it is libcosmic's arrangement, built out of a Stack: the screens | ||
| 1094 | + at their full size, a scrim, and the card centred over both. The screen keeps | ||
| 1095 | + its place in the tree either way — and so its scroll position — because the | ||
| 1096 | + dialog was never in its subtree to begin with. | ||
| 1097 | + | ||
| 1098 | + Two things about the scrim, and they are the same two things `profile-dialog` | ||
| 1099 | + says about modality. A card that was *pressed* open dims what is behind it | ||
| 1100 | + and swallows the presses meant for it: it is the thing on the screen until it | ||
| 1101 | + is dismissed. A card the pointer is merely *holding* open does neither — it | ||
| 1102 | + paints no dim, and `IgnorePointer` lets every event through to the face | ||
| 1103 | + underneath, because the face has to keep hearing the pointer or nothing would | ||
| 1104 | + ever tell the card to go." | ||
| 1105 | + [node] | ||
| 1106 | + (if-not (pointer?) | ||
| 1107 | + (render node) | ||
| 1108 | + (let [[tree dialog] (lift-dialog node)] | ||
| 1109 | + (if (nil? dialog) | ||
| 1110 | + (render tree) | ||
| 1111 | + (let [modal (boolean (:modal (props dialog)))] | ||
| 1112 | + (m/Stack | ||
| 1113 | + .children | ||
| 1114 | + [(render tree) | ||
| 1115 | + (m/Positioned.fill | ||
| 1116 | + .child | ||
| 1117 | + (let [scrim (m/ColoredBox | ||
| 1118 | + .color (if modal | ||
| 1119 | + (m/Color.fromRGBO 0 0 0 0.45) | ||
| 1120 | + (m/Color.fromRGBO 0 0 0 0.0)))] | ||
| 1121 | + (if modal | ||
| 1122 | + ;; Absorbing and not merely painting: a press on the dimmed | ||
| 1123 | + ;; half of the window is a press meant for the card. | ||
| 1124 | + (m/GestureDetector .onTap (fn [] nil) .child scrim) | ||
| 1125 | + (m/IgnorePointer .child scrim)))) | ||
| 1126 | + ;; `Center` and not a scroll view over the screen: a Center hands | ||
| 1127 | + ;; the hit test to its child's bounds and nothing more, while a | ||
| 1128 | + ;; viewport absorbs every event inside its own — which here is the | ||
| 1129 | + ;; whole window, faces included. The card does its own scrolling. | ||
| 1130 | + (m/Positioned.fill | ||
| 1131 | + .child | ||
| 1132 | + (m/Padding | ||
| 1133 | + .padding (m/EdgeInsets.all t/space-s) | ||
| 1134 | + .child (m/Center .child (render dialog))))])))))) | ||
modified
flutter/src/frq/io/dart.cljd +6 -1 | @@ -119,4 +119,9 @@ | ||
| 119 | 119 | :utf8-string utf8-string |
| 120 | 120 | :wall-nanos (fn [] (* 1000 (.-microsecondsSinceEpoch (DateTime/now)))) |
| 121 | 121 | :mono-nanos (fn [] (* 1000 (.-inMicroseconds (.-elapsed ^Stopwatch uptime)))) |
| 122 | - :local-offset-seconds local-offset-seconds})) | |
| 122 | + :local-offset-seconds local-offset-seconds | |
| 123 | + ;; Dart has one event loop and the UI runs on it, so there is no thread to | |
| 124 | + ;; hand the callback back to — a timer is the whole of it. | |
| 125 | + :after! (fn [ms f] | |
| 126 | + (Future.delayed (Duration .milliseconds (int ms)) f) | |
| 127 | + nil)})) | |
| @@ -119,4 +119,9 @@ | |||
| 119 | :utf8-string utf8-string | 119 | :utf8-string utf8-string |
| 120 | :wall-nanos (fn [] (* 1000 (.-microsecondsSinceEpoch (DateTime/now)))) | 120 | :wall-nanos (fn [] (* 1000 (.-microsecondsSinceEpoch (DateTime/now)))) |
| 121 | :mono-nanos (fn [] (* 1000 (.-inMicroseconds (.-elapsed ^Stopwatch uptime)))) | 121 | :mono-nanos (fn [] (* 1000 (.-inMicroseconds (.-elapsed ^Stopwatch uptime)))) |
| 122 | - :local-offset-seconds local-offset-seconds})) | 122 | + :local-offset-seconds local-offset-seconds |
| 123 | + ;; Dart has one event loop and the UI runs on it, so there is no thread to | ||
| 124 | + ;; hand the callback back to — a timer is the whole of it. | ||
| 125 | + :after! (fn [ms f] | ||
| 126 | + (Future.delayed (Duration .milliseconds (int ms)) f) | ||
| 127 | + nil)})) | ||
modified
flutter/src/frq/main.cljd +37 -16 | @@ -1102,10 +1102,15 @@ | ||
| 1102 | 1102 | (when-let [m (message-by-id channel id)] |
| 1103 | 1103 | (toggle-reaction! channel m emoji)) |
| 1104 | 1104 | (close-picker!))) |
| 1105 | - ;; Hover is a pointer idea. A finger is either on a pill or not on it, | |
| 1106 | - ;; so there is nothing here to raise a card about. | |
| 1107 | - :hover-reaction! (fn [_ _] nil) | |
| 1108 | - :unhover-reaction! (fn [_ _] nil) | |
| 1105 | + ;; Which pill the pointer is resting on, which `reactor-dialog` at the | |
| 1106 | + ;; root of the tree turns into a card saying who is on that reaction. | |
| 1107 | + ;; Guarded by which pill is being left, so crossing straight from one to | |
| 1108 | + ;; the next — both edges in a frame — cannot take down the card that has | |
| 1109 | + ;; just been raised. | |
| 1110 | + :hover-reaction! (fn [id emoji] (reset! cells/reaction-hover {:id id :emoji emoji})) | |
| 1111 | + :unhover-reaction! (fn [id emoji] | |
| 1112 | + (swap! cells/reaction-hover | |
| 1113 | + #(when-not (= {:id id :emoji emoji} %) %))) | |
| 1109 | 1114 | |
| 1110 | 1115 | ;; Who someone is, behind the nick on a line. `frq.profile` holds the |
| 1111 | 1116 | ;; cache and the fields; this is the tapping. |
| @@ -1116,21 +1121,28 @@ | ||
| 1116 | 1121 | (avatars/fetch! actor bump!) |
| 1117 | 1122 | (profile/open! nick actor)) |
| 1118 | 1123 | :profile-close! profile/close! |
| 1119 | - :profile-dismiss! profile/close! | |
| 1124 | + ;; `dismiss!` and not `close!`: the card is shown for `viewing` or for | |
| 1125 | + ;; `hovering`, and a Close that cleared only the first leaves the one a | |
| 1126 | + ;; pointer opened on screen with its own button doing nothing. | |
| 1127 | + :profile-dismiss! profile/dismiss! | |
| 1120 | 1128 | :profile-entry profile/entry |
| 1121 | 1129 | :profile-tick (fn [] @profile/tick) |
| 1122 | 1130 | :profile-stats-line profile/stats-line |
| 1123 | 1131 | :profile-truncate profile/truncate |
| 1124 | 1132 | :profile-web-url profile/web-url |
| 1125 | 1133 | :viewing (fn [] @profile/viewing) |
| 1126 | - ;; The pointer half of a profile, which a phone does not have: there is | |
| 1127 | - ;; no hovering a face, no crossing from the face to the card, and so no | |
| 1128 | - ;; card to hold open while it happens. | |
| 1129 | - :hovering (fn [] nil) | |
| 1130 | - :profile-hover! (fn [_ _] nil) | |
| 1131 | - :profile-unhover! (fn [_] nil) | |
| 1132 | - :profile-enter-dialog! (fn [] nil) | |
| 1133 | - :profile-leave-dialog! (fn [] nil) | |
| 1134 | + ;; The pointer half of a profile. These were no-ops while this file was | |
| 1135 | + ;; only ever an APK — a finger is either on a face or not on it — and | |
| 1136 | + ;; `just flutter-desktop` has a mouse, so the machine under them is the | |
| 1137 | + ;; shared one now. Nothing reaches them on the phone anyway: the screens | |
| 1138 | + ;; ask `desktop?` before they hang a hover on a face at all. | |
| 1139 | + :hovering (fn [] @profile/hovering) | |
| 1140 | + :profile-hover! (fn [nick actor] | |
| 1141 | + (avatars/fetch! actor bump!) | |
| 1142 | + (profile/hover! nick actor)) | |
| 1143 | + :profile-unhover! profile/unhover! | |
| 1144 | + :profile-enter-dialog! profile/enter-dialog! | |
| 1145 | + :profile-leave-dialog! profile/leave-dialog! | |
| 1134 | 1146 | ;; Their profile on the web, handed to the browser the same way the |
| 1135 | 1147 | ;; sign-in link is. |
| 1136 | 1148 | :open-url! (fn [url] (fio/open-url! url)) |
| @@ -1146,7 +1158,10 @@ | ||
| 1146 | 1158 | ;; put a link to. |
| 1147 | 1159 | :image-path (fn [url] (media/path-when-ready url)) |
| 1148 | 1160 | :wide? (fn [] false) |
| 1149 | - :desktop? (fn [] false) | |
| 1161 | + ;; True where there is a pointer to hover with. Both Flutter targets | |
| 1162 | + ;; compile this file, so it is asked rather than assumed: a window on the | |
| 1163 | + ;; desktop has a mouse, Android has a finger. | |
| 1164 | + :desktop? (fn [] (not (.-isAndroid dio/Platform))) | |
| 1150 | 1165 | :mine? (fn [m] (rooms/mine? m (str @cells/form-nick))) |
| 1151 | 1166 | |
| 1152 | 1167 | ;; Rewriting. The old text is the starting point rather than an empty |
| @@ -1198,7 +1213,10 @@ | ||
| 1198 | 1213 | nil) |
| 1199 | 1214 | :toggle-chat-list! (fn [] (swap! cells/hide-chat-list? not)) |
| 1200 | 1215 | :toggle-hide-join-part! (fn [] (swap! cells/hide-join-part? not)) |
| 1201 | - :quit! (fn [] nil) | |
| 1216 | + ;; Closing the app is a window's idea, and the Quit button it is behind | |
| 1217 | + ;; is only drawn where `desktop?` is true — Android has its own way of | |
| 1218 | + ;; leaving one. | |
| 1219 | + :quit! (fn [] (dio/exit 0)) | |
| 1202 | 1220 | :forget-session! (fn [] |
| 1203 | 1221 | (reset! cells/broker-token nil) |
| 1204 | 1222 | (reset! cells/session nil)) |
| @@ -1229,4 +1247,7 @@ | ||
| 1229 | 1247 | (let [_ (note-window-size! (.-size (m/MediaQuery.of ctx)))] |
| 1230 | 1248 | ;; `frq.screens.app` decides which screen shows, the same way it does |
| 1231 | 1249 | ;; on the desktop. The phone was switching by hand until this moved. |
| 1232 | - (h/render [screens/app])))))) | |
| 1250 | + ;; `render-root` and not `render`: the dialog `frq.screens.app` puts | |
| 1251 | + ;; beside the screens is floated over them where there is a pointer, | |
| 1252 | + ;; and drawn where it stands where there is not. | |
| 1253 | + (h/render-root [screens/app])))))) | |
| @@ -1102,10 +1102,15 @@ | |||
| 1102 | (when-let [m (message-by-id channel id)] | 1102 | (when-let [m (message-by-id channel id)] |
| 1103 | (toggle-reaction! channel m emoji)) | 1103 | (toggle-reaction! channel m emoji)) |
| 1104 | (close-picker!))) | 1104 | (close-picker!))) |
| 1105 | - ;; Hover is a pointer idea. A finger is either on a pill or not on it, | 1105 | + ;; Which pill the pointer is resting on, which `reactor-dialog` at the |
| 1106 | - ;; so there is nothing here to raise a card about. | 1106 | + ;; root of the tree turns into a card saying who is on that reaction. |
| 1107 | - :hover-reaction! (fn [_ _] nil) | 1107 | + ;; Guarded by which pill is being left, so crossing straight from one to |
| 1108 | - :unhover-reaction! (fn [_ _] nil) | 1108 | + ;; the next — both edges in a frame — cannot take down the card that has |
| 1109 | + ;; just been raised. | ||
| 1110 | + :hover-reaction! (fn [id emoji] (reset! cells/reaction-hover {:id id :emoji emoji})) | ||
| 1111 | + :unhover-reaction! (fn [id emoji] | ||
| 1112 | + (swap! cells/reaction-hover | ||
| 1113 | + #(when-not (= {:id id :emoji emoji} %) %))) | ||
| 1109 | 1114 | ||
| 1110 | ;; Who someone is, behind the nick on a line. `frq.profile` holds the | 1115 | ;; Who someone is, behind the nick on a line. `frq.profile` holds the |
| 1111 | ;; cache and the fields; this is the tapping. | 1116 | ;; cache and the fields; this is the tapping. |
| @@ -1116,21 +1121,28 @@ | |||
| 1116 | (avatars/fetch! actor bump!) | 1121 | (avatars/fetch! actor bump!) |
| 1117 | (profile/open! nick actor)) | 1122 | (profile/open! nick actor)) |
| 1118 | :profile-close! profile/close! | 1123 | :profile-close! profile/close! |
| 1119 | - :profile-dismiss! profile/close! | 1124 | + ;; `dismiss!` and not `close!`: the card is shown for `viewing` or for |
| 1125 | + ;; `hovering`, and a Close that cleared only the first leaves the one a | ||
| 1126 | + ;; pointer opened on screen with its own button doing nothing. | ||
| 1127 | + :profile-dismiss! profile/dismiss! | ||
| 1120 | :profile-entry profile/entry | 1128 | :profile-entry profile/entry |
| 1121 | :profile-tick (fn [] @profile/tick) | 1129 | :profile-tick (fn [] @profile/tick) |
| 1122 | :profile-stats-line profile/stats-line | 1130 | :profile-stats-line profile/stats-line |
| 1123 | :profile-truncate profile/truncate | 1131 | :profile-truncate profile/truncate |
| 1124 | :profile-web-url profile/web-url | 1132 | :profile-web-url profile/web-url |
| 1125 | :viewing (fn [] @profile/viewing) | 1133 | :viewing (fn [] @profile/viewing) |
| 1126 | - ;; The pointer half of a profile, which a phone does not have: there is | 1134 | + ;; The pointer half of a profile. These were no-ops while this file was |
| 1127 | - ;; no hovering a face, no crossing from the face to the card, and so no | 1135 | + ;; only ever an APK — a finger is either on a face or not on it — and |
| 1128 | - ;; card to hold open while it happens. | 1136 | + ;; `just flutter-desktop` has a mouse, so the machine under them is the |
| 1129 | - :hovering (fn [] nil) | 1137 | + ;; shared one now. Nothing reaches them on the phone anyway: the screens |
| 1130 | - :profile-hover! (fn [_ _] nil) | 1138 | + ;; ask `desktop?` before they hang a hover on a face at all. |
| 1131 | - :profile-unhover! (fn [_] nil) | 1139 | + :hovering (fn [] @profile/hovering) |
| 1132 | - :profile-enter-dialog! (fn [] nil) | 1140 | + :profile-hover! (fn [nick actor] |
| 1133 | - :profile-leave-dialog! (fn [] nil) | 1141 | + (avatars/fetch! actor bump!) |
| 1142 | + (profile/hover! nick actor)) | ||
| 1143 | + :profile-unhover! profile/unhover! | ||
| 1144 | + :profile-enter-dialog! profile/enter-dialog! | ||
| 1145 | + :profile-leave-dialog! profile/leave-dialog! | ||
| 1134 | ;; Their profile on the web, handed to the browser the same way the | 1146 | ;; Their profile on the web, handed to the browser the same way the |
| 1135 | ;; sign-in link is. | 1147 | ;; sign-in link is. |
| 1136 | :open-url! (fn [url] (fio/open-url! url)) | 1148 | :open-url! (fn [url] (fio/open-url! url)) |
| @@ -1146,7 +1158,10 @@ | |||
| 1146 | ;; put a link to. | 1158 | ;; put a link to. |
| 1147 | :image-path (fn [url] (media/path-when-ready url)) | 1159 | :image-path (fn [url] (media/path-when-ready url)) |
| 1148 | :wide? (fn [] false) | 1160 | :wide? (fn [] false) |
| 1149 | - :desktop? (fn [] false) | 1161 | + ;; True where there is a pointer to hover with. Both Flutter targets |
| 1162 | + ;; compile this file, so it is asked rather than assumed: a window on the | ||
| 1163 | + ;; desktop has a mouse, Android has a finger. | ||
| 1164 | + :desktop? (fn [] (not (.-isAndroid dio/Platform))) | ||
| 1150 | :mine? (fn [m] (rooms/mine? m (str @cells/form-nick))) | 1165 | :mine? (fn [m] (rooms/mine? m (str @cells/form-nick))) |
| 1151 | 1166 | ||
| 1152 | ;; Rewriting. The old text is the starting point rather than an empty | 1167 | ;; Rewriting. The old text is the starting point rather than an empty |
| @@ -1198,7 +1213,10 @@ | |||
| 1198 | nil) | 1213 | nil) |
| 1199 | :toggle-chat-list! (fn [] (swap! cells/hide-chat-list? not)) | 1214 | :toggle-chat-list! (fn [] (swap! cells/hide-chat-list? not)) |
| 1200 | :toggle-hide-join-part! (fn [] (swap! cells/hide-join-part? not)) | 1215 | :toggle-hide-join-part! (fn [] (swap! cells/hide-join-part? not)) |
| 1201 | - :quit! (fn [] nil) | 1216 | + ;; Closing the app is a window's idea, and the Quit button it is behind |
| 1217 | + ;; is only drawn where `desktop?` is true — Android has its own way of | ||
| 1218 | + ;; leaving one. | ||
| 1219 | + :quit! (fn [] (dio/exit 0)) | ||
| 1202 | :forget-session! (fn [] | 1220 | :forget-session! (fn [] |
| 1203 | (reset! cells/broker-token nil) | 1221 | (reset! cells/broker-token nil) |
| 1204 | (reset! cells/session nil)) | 1222 | (reset! cells/session nil)) |
| @@ -1229,4 +1247,7 @@ | |||
| 1229 | (let [_ (note-window-size! (.-size (m/MediaQuery.of ctx)))] | 1247 | (let [_ (note-window-size! (.-size (m/MediaQuery.of ctx)))] |
| 1230 | ;; `frq.screens.app` decides which screen shows, the same way it does | 1248 | ;; `frq.screens.app` decides which screen shows, the same way it does |
| 1231 | ;; on the desktop. The phone was switching by hand until this moved. | 1249 | ;; on the desktop. The phone was switching by hand until this moved. |
| 1232 | - (h/render [screens/app])))))) | 1250 | + ;; `render-root` and not `render`: the dialog `frq.screens.app` puts |
| 1251 | + ;; beside the screens is floated over them where there is a pointer, | ||
| 1252 | + ;; and drawn where it stands where there is not. | ||
| 1253 | + (h/render-root [screens/app])))))) | ||
modified
src/frq/io/jolt.clj +5 -1 | @@ -79,4 +79,8 @@ | ||
| 79 | 79 | :utf8-string (fn [bs] (String. (byte-array (map unchecked-byte bs)))) |
| 80 | 80 | :wall-nanos host/wall-nanos |
| 81 | 81 | :mono-nanos host/mono-nanos |
| 82 | - :local-offset-seconds (fn [secs] (host/tz-offset-seconds @zone secs))}) | |
| 82 | + :local-offset-seconds (fn [secs] (host/tz-offset-seconds @zone secs)) | |
| 83 | + ;; The window's own timer and not a thread that sleeps: a callback that | |
| 84 | + ;; repaints has to arrive on the thread the toolkit draws from, and | |
| 85 | + ;; `frq.platform` is where each backend lends this one its loop. | |
| 86 | + :after! platform/after!}) | |
| @@ -79,4 +79,8 @@ | |||
| 79 | :utf8-string (fn [bs] (String. (byte-array (map unchecked-byte bs)))) | 79 | :utf8-string (fn [bs] (String. (byte-array (map unchecked-byte bs)))) |
| 80 | :wall-nanos host/wall-nanos | 80 | :wall-nanos host/wall-nanos |
| 81 | :mono-nanos host/mono-nanos | 81 | :mono-nanos host/mono-nanos |
| 82 | - :local-offset-seconds (fn [secs] (host/tz-offset-seconds @zone secs))}) | 82 | + :local-offset-seconds (fn [secs] (host/tz-offset-seconds @zone secs)) |
| 83 | + ;; The window's own timer and not a thread that sleeps: a callback that | ||
| 84 | + ;; repaints has to arrive on the thread the toolkit draws from, and | ||
| 85 | + ;; `frq.platform` is where each backend lends this one its loop. | ||
| 86 | + :after! platform/after!}) | ||
modified
src/frq/profile/pointer.clj +20 -80 | @@ -1,29 +1,36 @@ | ||
| 1 | 1 | (ns frq.profile.pointer |
| 2 | - "What a pointer does about a profile, and how this half fetches one. | |
| 2 | + "How this half fetches a profile, and the picture that goes with one. | |
| 3 | 3 | |
| 4 | 4 | `frq.profile` under common/ is what a profile *is* — the cache, the fields, |
| 5 | - the URL, the counts — and both halves share it. This is the rest, and it is | |
| 6 | - all desktop by nature: hovering a face, the grace period for crossing from | |
| 7 | - the face to the card, and the card reporting its own pointer so moving into | |
| 8 | - it is not the same as leaving. A phone has none of those; a finger is either | |
| 9 | - on a name or not on it. | |
| 10 | - | |
| 11 | - The fetch lives here too, because a `future` and a blocking request is this | |
| 12 | - backend's answer to a question the other one answers by awaiting." | |
| 5 | + the URL, the counts — and what a pointer does about one: hovering a face, the | |
| 6 | + grace period for crossing to the card, the card reporting its own pointer. | |
| 7 | + All of that used to be here, on the reading that a pointer meant libcosmic. | |
| 8 | + It does not: `just flutter-desktop` is a window with a mouse in it too, and | |
| 9 | + the machine was a few atoms and a timer. It moved, and `frq.io/after!` took | |
| 10 | + the timer with it. | |
| 11 | + | |
| 12 | + What is left is the two things that genuinely differ by host. The fetch, | |
| 13 | + because a `future` and a blocking request is this backend's answer to a | |
| 14 | + question the other one answers by awaiting. And the avatar, because the | |
| 15 | + desktop downloads a file where the phone hands over a CDN URL. | |
| 16 | + | |
| 17 | + The shared names are re-exported so callers did not have to move." | |
| 13 | 18 | (:require [frq.atproto :as atproto] |
| 14 | 19 | [frq.avatars :as avatars] |
| 15 | - [frq.platform :as platform] | |
| 16 | 20 | [frq.profile :as profile])) |
| 17 | 21 | |
| 18 | -;; The shared names, re-exported so callers did not move. | |
| 19 | 22 | (def viewing profile/viewing) |
| 20 | 23 | (def hovering profile/hovering) |
| 21 | 24 | (def tick profile/tick) |
| 22 | 25 | (def entry profile/entry) |
| 23 | 26 | (def close! profile/close!) |
| 27 | +(def dismiss! profile/dismiss!) | |
| 24 | 28 | (def web-url profile/web-url) |
| 25 | 29 | (def stats-line profile/stats-line) |
| 26 | 30 | (def truncate profile/truncate) |
| 31 | +(def enter-dialog! profile/enter-dialog!) | |
| 32 | +(def leave-dialog! profile/leave-dialog!) | |
| 33 | +(def unhover! profile/unhover!) | |
| 27 | 34 | |
| 28 | 35 | (profile/install-fetch! |
| 29 | 36 | (fn [actor] |
| @@ -48,75 +55,8 @@ | ||
| 48 | 55 | (with-avatar! actor) |
| 49 | 56 | (profile/open! nick actor)) |
| 50 | 57 | |
| 51 | -;; Whether the pointer is on the dialog the hover put up. | |
| 52 | -;; | |
| 53 | -;; This is what makes a hovered profile something you can move into and read | |
| 54 | -;; rather than something you can only glance at: the dialog reports its own | |
| 55 | -;; pointer, so leaving the face is not the end of the hover if the pointer | |
| 56 | -;; turned up here instead. | |
| 57 | -(defonce ^:private over-dialog? (atom false)) | |
| 58 | - | |
| 59 | 58 | (defn hover! |
| 60 | - "The pointer has come to rest on someone's face. Starts the same two fetches | |
| 61 | - opening them would, so the card has something on it by the time it is read." | |
| 59 | + "The pointer has come to rest on someone's face." | |
| 62 | 60 | [nick actor] |
| 63 | - (reset! hovering {:nick nick :actor actor}) | |
| 64 | 61 | (with-avatar! actor) |
| 65 | - (profile/fetch! actor)) | |
| 66 | - | |
| 67 | -(defn dismiss! | |
| 68 | - "Put the profile away, however it was opened. | |
| 69 | - | |
| 70 | - The dialog is shown for `viewing` or for `hovering`, so a Close that cleared | |
| 71 | - only the first left one the pointer had opened on screen with its own button | |
| 72 | - doing nothing to it." | |
| 73 | - [] | |
| 74 | - (reset! viewing nil) | |
| 75 | - (reset! hovering nil) | |
| 76 | - ;; And the pointer's claim on it. Close takes the dialog out from under the | |
| 77 | - ;; pointer, so there is no leaving edge coming to say so — left set, it | |
| 78 | - ;; would hold the next hover open for good. | |
| 79 | - (reset! over-dialog? false)) | |
| 80 | - | |
| 81 | -;; How long the pointer may be on neither the face nor the dialog before the | |
| 82 | -;; dialog goes. | |
| 83 | -;; | |
| 84 | -;; There is a gap between the two — the dialog is centred and the face is | |
| 85 | -;; wherever the message is — and a hover that ended the instant the pointer | |
| 86 | -;; left the face would close it halfway across every time. Long enough to | |
| 87 | -;; cross, short enough that a pointer moving somewhere else entirely does not | |
| 88 | -;; drag it along. | |
| 89 | -(def ^:private grace-ms 400) | |
| 90 | - | |
| 91 | -(defn- release! | |
| 92 | - "Let `nick`'s hover go, unless something has taken it up again. | |
| 93 | - | |
| 94 | - Three things can have happened in the grace period: the pointer arrived on | |
| 95 | - the dialog, it went back to the face, or it landed on someone else's. In all | |
| 96 | - three there is a hover to keep, and it is not this one's to end — which is | |
| 97 | - what the nick guard says." | |
| 98 | - [nick] | |
| 99 | - (when-not @over-dialog? | |
| 100 | - (swap! hovering #(when-not (= nick (:nick %)) %)))) | |
| 101 | - | |
| 102 | -(defn unhover! | |
| 103 | - "The pointer has left `nick`'s face — which is not yet the end of it. | |
| 104 | - | |
| 105 | - Guarded by who is being left, so the leaving of one face cannot take down | |
| 106 | - the card of the next one: both edges arrive in the same frame when the | |
| 107 | - pointer crosses straight over." | |
| 108 | - [nick] | |
| 109 | - (platform/after! grace-ms #(release! nick))) | |
| 110 | - | |
| 111 | -(defn enter-dialog! | |
| 112 | - "The pointer is on the dialog. Whatever hover put it there is now this." | |
| 113 | - [] | |
| 114 | - (reset! over-dialog? true)) | |
| 115 | - | |
| 116 | -(defn leave-dialog! | |
| 117 | - "The pointer has left the dialog, and with it the last thing holding the | |
| 118 | - profile open — unless it went back to the face it came from." | |
| 119 | - [] | |
| 120 | - (reset! over-dialog? false) | |
| 121 | - (let [nick (:nick @hovering)] | |
| 122 | - (platform/after! grace-ms #(release! nick)))) | |
| 62 | + (profile/hover! nick actor)) | |
| @@ -1,29 +1,36 @@ | |||
| 1 | (ns frq.profile.pointer | 1 | (ns frq.profile.pointer |
| 2 | - "What a pointer does about a profile, and how this half fetches one. | 2 | + "How this half fetches a profile, and the picture that goes with one. |
| 3 | 3 | ||
| 4 | `frq.profile` under common/ is what a profile *is* — the cache, the fields, | 4 | `frq.profile` under common/ is what a profile *is* — the cache, the fields, |
| 5 | - the URL, the counts — and both halves share it. This is the rest, and it is | 5 | + the URL, the counts — and what a pointer does about one: hovering a face, the |
| 6 | - all desktop by nature: hovering a face, the grace period for crossing from | 6 | + grace period for crossing to the card, the card reporting its own pointer. |
| 7 | - the face to the card, and the card reporting its own pointer so moving into | 7 | + All of that used to be here, on the reading that a pointer meant libcosmic. |
| 8 | - it is not the same as leaving. A phone has none of those; a finger is either | 8 | + It does not: `just flutter-desktop` is a window with a mouse in it too, and |
| 9 | - on a name or not on it. | 9 | + the machine was a few atoms and a timer. It moved, and `frq.io/after!` took |
| 10 | - | 10 | + the timer with it. |
| 11 | - The fetch lives here too, because a `future` and a blocking request is this | 11 | + |
| 12 | - backend's answer to a question the other one answers by awaiting." | 12 | + What is left is the two things that genuinely differ by host. The fetch, |
| 13 | + because a `future` and a blocking request is this backend's answer to a | ||
| 14 | + question the other one answers by awaiting. And the avatar, because the | ||
| 15 | + desktop downloads a file where the phone hands over a CDN URL. | ||
| 16 | + | ||
| 17 | + The shared names are re-exported so callers did not have to move." | ||
| 13 | (:require [frq.atproto :as atproto] | 18 | (:require [frq.atproto :as atproto] |
| 14 | [frq.avatars :as avatars] | 19 | [frq.avatars :as avatars] |
| 15 | - [frq.platform :as platform] | ||
| 16 | [frq.profile :as profile])) | 20 | [frq.profile :as profile])) |
| 17 | 21 | ||
| 18 | -;; The shared names, re-exported so callers did not move. | ||
| 19 | (def viewing profile/viewing) | 22 | (def viewing profile/viewing) |
| 20 | (def hovering profile/hovering) | 23 | (def hovering profile/hovering) |
| 21 | (def tick profile/tick) | 24 | (def tick profile/tick) |
| 22 | (def entry profile/entry) | 25 | (def entry profile/entry) |
| 23 | (def close! profile/close!) | 26 | (def close! profile/close!) |
| 27 | +(def dismiss! profile/dismiss!) | ||
| 24 | (def web-url profile/web-url) | 28 | (def web-url profile/web-url) |
| 25 | (def stats-line profile/stats-line) | 29 | (def stats-line profile/stats-line) |
| 26 | (def truncate profile/truncate) | 30 | (def truncate profile/truncate) |
| 31 | +(def enter-dialog! profile/enter-dialog!) | ||
| 32 | +(def leave-dialog! profile/leave-dialog!) | ||
| 33 | +(def unhover! profile/unhover!) | ||
| 27 | 34 | ||
| 28 | (profile/install-fetch! | 35 | (profile/install-fetch! |
| 29 | (fn [actor] | 36 | (fn [actor] |
| @@ -48,75 +55,8 @@ | |||
| 48 | (with-avatar! actor) | 55 | (with-avatar! actor) |
| 49 | (profile/open! nick actor)) | 56 | (profile/open! nick actor)) |
| 50 | 57 | ||
| 51 | -;; Whether the pointer is on the dialog the hover put up. | ||
| 52 | -;; | ||
| 53 | -;; This is what makes a hovered profile something you can move into and read | ||
| 54 | -;; rather than something you can only glance at: the dialog reports its own | ||
| 55 | -;; pointer, so leaving the face is not the end of the hover if the pointer | ||
| 56 | -;; turned up here instead. | ||
| 57 | -(defonce ^:private over-dialog? (atom false)) | ||
| 58 | - | ||
| 59 | (defn hover! | 58 | (defn hover! |
| 60 | - "The pointer has come to rest on someone's face. Starts the same two fetches | 59 | + "The pointer has come to rest on someone's face." |
| 61 | - opening them would, so the card has something on it by the time it is read." | ||
| 62 | [nick actor] | 60 | [nick actor] |
| 63 | - (reset! hovering {:nick nick :actor actor}) | ||
| 64 | (with-avatar! actor) | 61 | (with-avatar! actor) |
| 65 | - (profile/fetch! actor)) | 62 | + (profile/hover! nick actor)) |
| 66 | - | ||
| 67 | -(defn dismiss! | ||
| 68 | - "Put the profile away, however it was opened. | ||
| 69 | - | ||
| 70 | - The dialog is shown for `viewing` or for `hovering`, so a Close that cleared | ||
| 71 | - only the first left one the pointer had opened on screen with its own button | ||
| 72 | - doing nothing to it." | ||
| 73 | - [] | ||
| 74 | - (reset! viewing nil) | ||
| 75 | - (reset! hovering nil) | ||
| 76 | - ;; And the pointer's claim on it. Close takes the dialog out from under the | ||
| 77 | - ;; pointer, so there is no leaving edge coming to say so — left set, it | ||
| 78 | - ;; would hold the next hover open for good. | ||
| 79 | - (reset! over-dialog? false)) | ||
| 80 | - | ||
| 81 | -;; How long the pointer may be on neither the face nor the dialog before the | ||
| 82 | -;; dialog goes. | ||
| 83 | -;; | ||
| 84 | -;; There is a gap between the two — the dialog is centred and the face is | ||
| 85 | -;; wherever the message is — and a hover that ended the instant the pointer | ||
| 86 | -;; left the face would close it halfway across every time. Long enough to | ||
| 87 | -;; cross, short enough that a pointer moving somewhere else entirely does not | ||
| 88 | -;; drag it along. | ||
| 89 | -(def ^:private grace-ms 400) | ||
| 90 | - | ||
| 91 | -(defn- release! | ||
| 92 | - "Let `nick`'s hover go, unless something has taken it up again. | ||
| 93 | - | ||
| 94 | - Three things can have happened in the grace period: the pointer arrived on | ||
| 95 | - the dialog, it went back to the face, or it landed on someone else's. In all | ||
| 96 | - three there is a hover to keep, and it is not this one's to end — which is | ||
| 97 | - what the nick guard says." | ||
| 98 | - [nick] | ||
| 99 | - (when-not @over-dialog? | ||
| 100 | - (swap! hovering #(when-not (= nick (:nick %)) %)))) | ||
| 101 | - | ||
| 102 | -(defn unhover! | ||
| 103 | - "The pointer has left `nick`'s face — which is not yet the end of it. | ||
| 104 | - | ||
| 105 | - Guarded by who is being left, so the leaving of one face cannot take down | ||
| 106 | - the card of the next one: both edges arrive in the same frame when the | ||
| 107 | - pointer crosses straight over." | ||
| 108 | - [nick] | ||
| 109 | - (platform/after! grace-ms #(release! nick))) | ||
| 110 | - | ||
| 111 | -(defn enter-dialog! | ||
| 112 | - "The pointer is on the dialog. Whatever hover put it there is now this." | ||
| 113 | - [] | ||
| 114 | - (reset! over-dialog? true)) | ||
| 115 | - | ||
| 116 | -(defn leave-dialog! | ||
| 117 | - "The pointer has left the dialog, and with it the last thing holding the | ||
| 118 | - profile open — unless it went back to the face it came from." | ||
| 119 | - [] | ||
| 120 | - (reset! over-dialog? false) | ||
| 121 | - (let [nick (:nick @hovering)] | ||
| 122 | - (platform/after! grace-ms #(release! nick)))) | ||