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

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

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>
nandi committed 2026-09-12T08:31:03-07:00 Browse files
5b89e5a parent: 3fb7fd0
modified common/frq/io.cljc +12 -0
@@ -124,3 +124,15 @@
124124 backlog read in November carries messages from August."
125125 [epoch-secs]
126126 (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 @@
1111 them the screen is the nick and a line saying so, which is the honest answer
1212 rather than a spinner that never lands.
1313
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?`.
1822
1923 The fetch is a seam, because it is the one part that differs — a future and a
2024 blocking request on one side, an awaited one on the other. Nothing is fetched
2125 until a backend installs one, and `entry` simply answers nil."
2226 (:require [clojure.string :as str]
2327 [frq.atproto.core :as atproto]
24- [frq.cells :as cells]))
28+ [frq.cells :as cells]
29+ [frq.io :as io]))
2530
2631 (def directory-host atproto/directory-host)
2732
@@ -150,6 +155,85 @@
150155
151156 (defn close! [] (reset! viewing nil))
152157
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+
153237 (defn web-url
154238 "Their profile on the web, by handle where there is one and DID otherwise."
155239 [{:keys [handle did]}]
@@ -11,17 +11,22 @@
11 them the screen is the nick and a line saying so, which is the honest answer11 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: hovering14+ 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 its15+ 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`. A16+ 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 a23 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 fetched24 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-url237 (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 @@
102102 [:image {:src path :fit true :on-click #(reset! cells/lightbox nil)}]]))
103103
104104 (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.
107107
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.
111114
112115 Both buttons are always here, the Bluesky one insensitive until there is a
113116 profile to open: a dialog whose second button appears a moment after it
@@ -123,11 +126,12 @@
123126
124127 And a dialog the pointer is holding open is not modal. A modal one makes
125128 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."
131135 []
132136 (let [pinned? (some? (actions/viewing))
133137 {:keys [nick actor]} (or (actions/viewing) (actions/hovering))
@@ -339,13 +343,14 @@
339343 ;; from the conversation you were in a moment ago. A key that changes with
340344 ;; the screen makes the swap a swap: the old tree comes out whole and the new
341345 ;; 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.
349354 ;;
350355 ;; The terminal has no such thing, and a `dialog` tag it has not grown would
351356 ;; 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-dialog104 (defn- profile-dialog
105- "Who someone is, as libcosmic's own dialog: centred over the window, with105+ "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 reason108+ 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 says109+ 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 a115 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 it116 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 makes127 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 that128 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 face129+ 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 could130+ 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 shuts131+ 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 thing132+ 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 with343 ;; 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 new344 ;; 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. A346+ ;; 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 hands347+ ;; 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 is348+ ;; 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 its350+ ;; 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 file351+ ;; 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 would355 ;; 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 stays356 ;; 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 @@
13501350 ;; which is the tree under `just run` and `just cosmic` and the store
13511351 ;; copy under the flake's launcher. A terminal has no pixels to put
13521352 ;; 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)
13541359 [:image {:src "src/frq/icons/insert-image.png"
13551360 :size [36 36]
13561361 ;; 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 store1350 ;; 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 put1351 ;; 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: the1361 ;; 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 @@
4242 (let [w (:width-request p)]
4343 (when (and (number? w) (pos? w)) (double w))))
4444
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+
4575 (defn- dbl [x default]
4676 (cond (number? x) (double x)
4777 :else default))
@@ -642,51 +672,131 @@
642672 n (:count p)
643673 mine (boolean (:mine p))
644674 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)
654684 .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)))])))))))
666702
667703 ;; 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.
672706 ;;
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.
676721 :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))]))))))))
690800
691801 :separator (m/Divider .height 1.0 .thickness 1.0 .color t/divider)
692802
@@ -789,8 +899,12 @@
789899 ;; A face is a way in to who someone is, so it takes the press that
790900 ;; opens their profile. It was drawn without one and the chat screen's
791901 ;; `: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.
794908 :avatar
795909 (let [s (dbl (:size p) 32.0)
796910 src (:src p)
@@ -820,11 +934,13 @@
820934 (.toUpperCase (subs l 0 1))
821935 "?"))
822936 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)))
828944
829945 :image
830946 (let [src (or (:src p) (:path p))
@@ -929,3 +1045,90 @@
9291045 .mainAxisSize m/MainAxisSize.min
9301046 .children (children node))
9311047 :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/InkWell675+ ;; 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- .child678+ ;; is; see the `:avatar` arm, which answers the same way about a face.
649- (m/Container679+ (hovered
650- .padding (m/EdgeInsets.symmetric .horizontal pad .vertical (* 0.5 pad))680+ p
651- .decoration (m/BoxDecoration681+ (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 .child684 .child
655- (m/Row685+ (m/Container
656- .mainAxisSize m/MainAxisSize.min686+ .padding (m/EdgeInsets.symmetric .horizontal pad .vertical (* 0.5 pad))
657- .children687+ .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/TextStyle693+ .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 were703 ;; libcosmic's dialog is centred over the window with what you were
668- ;; reading dimmed behind it. There is no behind on a phone worth704+ ;; 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 a705+ ;; 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 a707+ ;; 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 opened708+ ;; 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 :dialog721 :dialog
677- (m/Container722+ (if-not (pointer?)
678- .width double/infinity723+ (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/BoxDecoration726+ .padding (m/EdgeInsets.all t/space-xs)
682- .color t/card727+ .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- .child730+ .borderRadius (m/BorderRadius.circular t/radius-m))
686- (col t/space-xxs731+ .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 that899 ;; 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's900 ;; 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 are902+ ;;
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 :avatar908 :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 :image945 :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.min1045 .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 @@
119119 :utf8-string utf8-string
120120 :wall-nanos (fn [] (* 1000 (.-microsecondsSinceEpoch (DateTime/now))))
121121 :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-string119 :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 @@
11021102 (when-let [m (message-by-id channel id)]
11031103 (toggle-reaction! channel m emoji))
11041104 (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} %) %)))
11091114
11101115 ;; Who someone is, behind the nick on a line. `frq.profile` holds the
11111116 ;; cache and the fields; this is the tapping.
@@ -1116,21 +1121,28 @@
11161121 (avatars/fetch! actor bump!)
11171122 (profile/open! nick actor))
11181123 :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!
11201128 :profile-entry profile/entry
11211129 :profile-tick (fn [] @profile/tick)
11221130 :profile-stats-line profile/stats-line
11231131 :profile-truncate profile/truncate
11241132 :profile-web-url profile/web-url
11251133 :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!
11341146 ;; Their profile on the web, handed to the browser the same way the
11351147 ;; sign-in link is.
11361148 :open-url! (fn [url] (fio/open-url! url))
@@ -1146,7 +1158,10 @@
11461158 ;; put a link to.
11471159 :image-path (fn [url] (media/path-when-ready url))
11481160 :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)))
11501165 :mine? (fn [m] (rooms/mine? m (str @cells/form-nick)))
11511166
11521167 ;; Rewriting. The old text is the starting point rather than an empty
@@ -1198,7 +1213,10 @@
11981213 nil)
11991214 :toggle-chat-list! (fn [] (swap! cells/hide-chat-list? not))
12001215 :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))
12021220 :forget-session! (fn []
12031221 (reset! cells/broker-token nil)
12041222 (reset! cells/session nil))
@@ -1229,4 +1247,7 @@
12291247 (let [_ (note-window-size! (.-size (m/MediaQuery.of ctx)))]
12301248 ;; `frq.screens.app` decides which screen shows, the same way it does
12311249 ;; 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 the1115 ;; 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/entry1128 :profile-entry profile/entry
1121 :profile-tick (fn [] @profile/tick)1129 :profile-tick (fn [] @profile/tick)
1122 :profile-stats-line profile/stats-line1130 :profile-stats-line profile/stats-line
1123 :profile-truncate profile/truncate1131 :profile-truncate profile/truncate
1124 :profile-web-url profile/web-url1132 :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 is1134+ ;; 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 no1135+ ;; 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 the1146 ;; 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 empty1167 ;; 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 does1248 ;; `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 @@
7979 :utf8-string (fn [bs] (String. (byte-array (map unchecked-byte bs))))
8080 :wall-nanos host/wall-nanos
8181 :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-nanos80 :wall-nanos host/wall-nanos
81 :mono-nanos host/mono-nanos81 :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 @@
11 (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.
33
44 `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."
1318 (:require [frq.atproto :as atproto]
1419 [frq.avatars :as avatars]
15- [frq.platform :as platform]
1620 [frq.profile :as profile]))
1721
18-;; The shared names, re-exported so callers did not move.
1922 (def viewing profile/viewing)
2023 (def hovering profile/hovering)
2124 (def tick profile/tick)
2225 (def entry profile/entry)
2326 (def close! profile/close!)
27+(def dismiss! profile/dismiss!)
2428 (def web-url profile/web-url)
2529 (def stats-line profile/stats-line)
2630 (def truncate profile/truncate)
31+(def enter-dialog! profile/enter-dialog!)
32+(def leave-dialog! profile/leave-dialog!)
33+(def unhover! profile/unhover!)
2734
2835 (profile/install-fetch!
2936 (fn [actor]
@@ -48,75 +55,8 @@
4855 (with-avatar! actor)
4956 (profile/open! nick actor))
5057
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-
5958 (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."
6260 [nick actor]
63- (reset! hovering {:nick nick :actor actor})
6461 (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.pointer1 (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 is5+ 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 from6+ 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 into7+ 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 either8+ 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 this11+
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 fetches59+ "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))))