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

Answer the jump button on the phone

Two halves were missing and each made the other invisible. `:jump-to-present!`
set `at-present?` and stopped there, so the press hid its own button and left
the reader in last week; and nothing on this backend ever set the flag back to
false, so the button never appeared to be pressed in the first place.

The tick is the other half of the action, as `frq.state` has it. The flag is
what the screen reads to show the button, and the tick is what a `:scroll`
reads as "take me to the end" — `end-ward!` was already listening for it.

`:on-change` is the way back: the screens say "end" or "middle" and this now
says it, off a listener added once per controller rather than once per build,
and only when the answer changes — a flick is a callback per pixel, and each
one that reached a cell would rebuild the screen under the finger.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nandi committed 2026-09-12T07:50:28-07:00 Browse files
cc17757 parent: c945c1f
modified flutter/src/frq/hiccup.cljd +44 -2
@@ -224,6 +224,46 @@
224224 (when (.-hasClients ctrl)
225225 (.jumpTo ctrl (.-maxScrollExtent (.-position ctrl)))))))))
226226
227+(defonce ^:private scroll-watch
228+ ;; Per `:scroll-key`: the controller currently being listened to, the
229+ ;; `:on-change` the screen last handed us, and the last answer given.
230+ ;;
231+ ;; The listener is added once per controller and the callback is looked up
232+ ;; through here when it fires, because the closure a build hands us is a new
233+ ;; one every build adding it each time would stack a listener per frame on
234+ ;; a widget whose whole job is to fire often.
235+ (atom {}))
236+
237+(defn- watch-end!
238+ "Tell `on-change` whether `k` is showing the end, as the reader moves it.
239+
240+ \"end\" or \"middle\", which is what the window backend's scroll area says
241+ and what `chat-screen` reads to decide whether the jump button is needed.
242+ Only when the answer changes: every pixel of a flick is a callback, and each
243+ one that reached a cell would rebuild the screen under the finger.
244+
245+ The same 24-point slack `end-ward!` uses, and for the same reason — a list
246+ that has just been jumped to its extent can sit a fraction short of it, and
247+ a reader who is at the bottom should not be told they are not."
248+ [k ^m/ScrollController ctrl on-change]
249+ (when on-change
250+ (let [entry (get @scroll-watch k)]
251+ (swap! scroll-watch assoc k (assoc entry :on-change on-change :ctrl ctrl))
252+ (when-not (identical? ctrl (:ctrl entry))
253+ (.addListener
254+ ctrl
255+ (fn []
256+ (when (.-hasClients ctrl)
257+ (let [pos (.-position ctrl)
258+ at-end (>= (.-pixels ^m/ScrollPosition pos)
259+ (- (.-maxScrollExtent ^m/ScrollPosition pos) 24.0))
260+ said (if at-end "end" "middle")
261+ {prev :said f :on-change} (get @scroll-watch k)]
262+ (when (not= prev said)
263+ (swap! scroll-watch assoc-in [k :said] said)
264+ (when f (f said)))))
265+ nil))))))
266+
227267 (defn- fills-column?
228268 "Whether a node takes the height its column has left over.
229269
@@ -734,7 +774,8 @@
734774 ;; "competing" and then draws nothing.
735775 :scroll
736776 (let [k (str (:scroll-key p))
737- token (:scroll-to-bottom p)]
777+ token (:scroll-to-bottom p)
778+ on-change (:on-change p)]
738779 (f/widget
739780 ;; `:managed`, so the controller belongs to this widget and is
740781 ;; disposed with it. One controller per key in a global map was the
@@ -744,7 +785,8 @@
744785 ;; "ScrollController attached to multiple scroll views", which took
745786 ;; the whole screen.
746787 :managed [ctrl (m/ScrollController)]
747- :let [_ (end-ward! k ctrl token)]
788+ :let [_ (watch-end! k ctrl on-change)
789+ _ (end-ward! k ctrl token)]
748790 (m/SingleChildScrollView
749791 .controller ctrl
750792 .child (col (dbl (:spacing p) 0.0) (body node)))))
@@ -224,6 +224,46 @@
224 (when (.-hasClients ctrl)224 (when (.-hasClients ctrl)
225 (.jumpTo ctrl (.-maxScrollExtent (.-position ctrl)))))))))225 (.jumpTo ctrl (.-maxScrollExtent (.-position ctrl)))))))))
226 226
227+(defonce ^:private scroll-watch
228+ ;; Per `:scroll-key`: the controller currently being listened to, the
229+ ;; `:on-change` the screen last handed us, and the last answer given.
230+ ;;
231+ ;; The listener is added once per controller and the callback is looked up
232+ ;; through here when it fires, because the closure a build hands us is a new
233+ ;; one every build adding it each time would stack a listener per frame on
234+ ;; a widget whose whole job is to fire often.
235+ (atom {}))
236+
237+(defn- watch-end!
238+ "Tell `on-change` whether `k` is showing the end, as the reader moves it.
239+
240+ \"end\" or \"middle\", which is what the window backend's scroll area says
241+ and what `chat-screen` reads to decide whether the jump button is needed.
242+ Only when the answer changes: every pixel of a flick is a callback, and each
243+ one that reached a cell would rebuild the screen under the finger.
244+
245+ The same 24-point slack `end-ward!` uses, and for the same reason — a list
246+ that has just been jumped to its extent can sit a fraction short of it, and
247+ a reader who is at the bottom should not be told they are not."
248+ [k ^m/ScrollController ctrl on-change]
249+ (when on-change
250+ (let [entry (get @scroll-watch k)]
251+ (swap! scroll-watch assoc k (assoc entry :on-change on-change :ctrl ctrl))
252+ (when-not (identical? ctrl (:ctrl entry))
253+ (.addListener
254+ ctrl
255+ (fn []
256+ (when (.-hasClients ctrl)
257+ (let [pos (.-position ctrl)
258+ at-end (>= (.-pixels ^m/ScrollPosition pos)
259+ (- (.-maxScrollExtent ^m/ScrollPosition pos) 24.0))
260+ said (if at-end "end" "middle")
261+ {prev :said f :on-change} (get @scroll-watch k)]
262+ (when (not= prev said)
263+ (swap! scroll-watch assoc-in [k :said] said)
264+ (when f (f said)))))
265+ nil))))))
266+
227 (defn- fills-column?267 (defn- fills-column?
228 "Whether a node takes the height its column has left over.268 "Whether a node takes the height its column has left over.
229 269
@@ -734,7 +774,8 @@
734 ;; "competing" and then draws nothing.774 ;; "competing" and then draws nothing.
735 :scroll775 :scroll
736 (let [k (str (:scroll-key p))776 (let [k (str (:scroll-key p))
737- token (:scroll-to-bottom p)]777+ token (:scroll-to-bottom p)
778+ on-change (:on-change p)]
738 (f/widget779 (f/widget
739 ;; `:managed`, so the controller belongs to this widget and is780 ;; `:managed`, so the controller belongs to this widget and is
740 ;; disposed with it. One controller per key in a global map was the781 ;; disposed with it. One controller per key in a global map was the
@@ -744,7 +785,8 @@
744 ;; "ScrollController attached to multiple scroll views", which took785 ;; "ScrollController attached to multiple scroll views", which took
745 ;; the whole screen.786 ;; the whole screen.
746 :managed [ctrl (m/ScrollController)]787 :managed [ctrl (m/ScrollController)]
747- :let [_ (end-ward! k ctrl token)]788+ :let [_ (watch-end! k ctrl on-change)
789+ _ (end-ward! k ctrl token)]
748 (m/SingleChildScrollView790 (m/SingleChildScrollView
749 .controller ctrl791 .controller ctrl
750 .child (col (dbl (:spacing p) 0.0) (body node)))))792 .child (col (dbl (:spacing p) 0.0) (body node)))))
modified flutter/src/frq/main.cljd +8 -1
@@ -1237,7 +1237,14 @@
12371237 :forget-session! (fn []
12381238 (reset! cells/broker-token nil)
12391239 (reset! cells/session nil))
1240- :jump-to-present! (fn [] (reset! cells/at-present? true))
1240+ ;; Back to the newest line. Both halves, in the order `frq.state` gives
1241+ ;; them: the flag is what takes the button off the screen, and the tick
1242+ ;; is what `frq.hiccup`'s scroll reads as "someone asked to be taken to
1243+ ;; the end". Setting the flag alone hid the button and left the reader
1244+ ;; where they were — which is the one outcome worse than no button.
1245+ :jump-to-present! (fn []
1246+ (reset! cells/at-present? true)
1247+ (swap! cells/jump-tick inc))
12411248 :open-channel! open-room!})
12421249 (f/run
12431250 (m/MaterialApp .title "frq" .theme (t/app-theme))
@@ -1237,7 +1237,14 @@
1237 :forget-session! (fn []1237 :forget-session! (fn []
1238 (reset! cells/broker-token nil)1238 (reset! cells/broker-token nil)
1239 (reset! cells/session nil))1239 (reset! cells/session nil))
1240- :jump-to-present! (fn [] (reset! cells/at-present? true))1240+ ;; Back to the newest line. Both halves, in the order `frq.state` gives
1241+ ;; them: the flag is what takes the button off the screen, and the tick
1242+ ;; is what `frq.hiccup`'s scroll reads as "someone asked to be taken to
1243+ ;; the end". Setting the flag alone hid the button and left the reader
1244+ ;; where they were — which is the one outcome worse than no button.
1245+ :jump-to-present! (fn []
1246+ (reset! cells/at-present? true)
1247+ (swap! cells/jump-tick inc))
1241 :open-channel! open-room!})1248 :open-channel! open-room!})
1242 (f/run1249 (f/run
1243 (m/MaterialApp .title "frq" .theme (t/app-theme))1250 (m/MaterialApp .title "frq" .theme (t/app-theme))