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>
cc17757 parent: c945c1f modified
flutter/src/frq/hiccup.cljd +44 -2 | @@ -224,6 +224,46 @@ | ||
| 224 | 224 | (when (.-hasClients ctrl) |
| 225 | 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 | 267 | (defn- fills-column? |
| 228 | 268 | "Whether a node takes the height its column has left over. |
| 229 | 269 | |
| @@ -734,7 +774,8 @@ | ||
| 734 | 774 | ;; "competing" and then draws nothing. |
| 735 | 775 | :scroll |
| 736 | 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 | 779 | (f/widget |
| 739 | 780 | ;; `:managed`, so the controller belongs to this widget and is |
| 740 | 781 | ;; disposed with it. One controller per key in a global map was the |
| @@ -744,7 +785,8 @@ | ||
| 744 | 785 | ;; "ScrollController attached to multiple scroll views", which took |
| 745 | 786 | ;; the whole screen. |
| 746 | 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 | 790 | (m/SingleChildScrollView |
| 749 | 791 | .controller ctrl |
| 750 | 792 | .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 | :scroll | 775 | :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/widget | 779 | (f/widget |
| 739 | ;; `:managed`, so the controller belongs to this widget and is | 780 | ;; `:managed`, so the controller belongs to this widget and is |
| 740 | ;; disposed with it. One controller per key in a global map was the | 781 | ;; 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 took | 785 | ;; "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/SingleChildScrollView | 790 | (m/SingleChildScrollView |
| 749 | .controller ctrl | 791 | .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 @@ | ||
| 1237 | 1237 | :forget-session! (fn [] |
| 1238 | 1238 | (reset! cells/broker-token nil) |
| 1239 | 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 | 1248 | :open-channel! open-room!}) |
| 1242 | 1249 | (f/run |
| 1243 | 1250 | (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/run | 1249 | (f/run |
| 1243 | (m/MaterialApp .title "frq" .theme (t/app-theme)) | 1250 | (m/MaterialApp .title "frq" .theme (t/app-theme)) |