Repaint for every cell, follow the conversation, and send once
Four things reported off the phone in one sitting, and two causes. People, Overview and hide join/part each flipped exactly the cell they were meant to flip and repainted nothing. `cljd.flutter` rebuilds for what its `:watch` names, that list was written by hand in the widget, and a list written by hand drifts — those three were never on it. So `frq.cells` now enumerates itself and the phone watches the enumeration. The enumeration is a function rather than a def, which is the part ClojureDart makes you care about: a def becomes a Dart top-level variable, Dart initialises those on first read, and the first attempt at this — cells registering themselves into a defonce as they were defined — installed no watches at all, because nothing ever read the thing they registered into. It printed nothing to say so either. `tools/check-common.py` now fails if the list falls behind the definitions, since a list kept by hand is only as good as what checks it. Sending put the message in the room twice, and that one is mine from an hour ago: `echo-message` was negotiated, which is the cap that makes the server send our own line back, and send-draft! went on adding a local copy as it had to before. It adds one only when the server will not. And a `:scroll` had no controller at all, so the chat opened at the top of the backlog and stayed there as lines arrived. It follows the end now, on open and as messages land — but not when someone is reading back through the backlog, which is the one behaviour worse than not following at all. Verified on a Pixel 6a: both toggles light and open their panes, the chat opens on the last line, and a sent message appears once. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
8ead4ea parent: 86c2e1b modified
common/frq/cells.cljc +30 -0 | @@ -212,3 +212,33 @@ | ||
| 212 | 212 | ;; Where the picker is looking, or nil when it is closed. A path, so the |
| 213 | 213 | ;; browsing is just this cell moving. |
| 214 | 214 | (defonce image-picker (atom nil)) |
| 215 | + | |
| 216 | + | |
| 217 | +;; ------------------------------------------------------------- enumeration | |
| 218 | + | |
| 219 | +(defn all-cells | |
| 220 | + "Every cell above, for a backend that has to be told what to watch. | |
| 221 | + | |
| 222 | + glimmer needs no such list: a component that derefs a ratom is subscribed to | |
| 223 | + it by the act of dereferencing, so the desktop hears about a change it never | |
| 224 | + declared an interest in. `cljd.flutter` works from the other end — `:watch` | |
| 225 | + names what a widget rebuilds for — and the phone kept that list in the | |
| 226 | + widget, where it drifted: People, Overview and hide join/part each flipped | |
| 227 | + the cell they were meant to flip and repainted nothing, because the cell was | |
| 228 | + not named there. | |
| 229 | + | |
| 230 | + A function and not a `def`, which is the part ClojureDart makes you care | |
| 231 | + about: a `def` becomes a Dart top-level variable, those initialise on first | |
| 232 | + read, and a list nothing has read yet is still empty at the moment the | |
| 233 | + watches are installed. Calling it also forces every cell it names. | |
| 234 | + | |
| 235 | + `tools/check-common.py` fails the build if this falls behind the | |
| 236 | + definitions, because a list kept by hand is only as good as what checks it." | |
| 237 | + [] | |
| 238 | + [screen status error connecting? form-host form-port form-tls? form-nick | |
| 239 | + auth-mode form-handle form-app-password session channels current | |
| 240 | + join-input search broker-token login-url draft editing replying-to | |
| 241 | + attachment jump-tick show-users? hide-chat-list? overview? at-present? | |
| 242 | + emoji-group emoji-search highlight jump-to lightbox overview-return | |
| 243 | + reacting window-height window-width reaction-hover hide-join-part? | |
| 244 | + image-picker]) | |
| @@ -212,3 +212,33 @@ | |||
| 212 | ;; Where the picker is looking, or nil when it is closed. A path, so the | 212 | ;; Where the picker is looking, or nil when it is closed. A path, so the |
| 213 | ;; browsing is just this cell moving. | 213 | ;; browsing is just this cell moving. |
| 214 | (defonce image-picker (atom nil)) | 214 | (defonce image-picker (atom nil)) |
| 215 | + | ||
| 216 | + | ||
| 217 | +;; ------------------------------------------------------------- enumeration | ||
| 218 | + | ||
| 219 | +(defn all-cells | ||
| 220 | + "Every cell above, for a backend that has to be told what to watch. | ||
| 221 | + | ||
| 222 | + glimmer needs no such list: a component that derefs a ratom is subscribed to | ||
| 223 | + it by the act of dereferencing, so the desktop hears about a change it never | ||
| 224 | + declared an interest in. `cljd.flutter` works from the other end — `:watch` | ||
| 225 | + names what a widget rebuilds for — and the phone kept that list in the | ||
| 226 | + widget, where it drifted: People, Overview and hide join/part each flipped | ||
| 227 | + the cell they were meant to flip and repainted nothing, because the cell was | ||
| 228 | + not named there. | ||
| 229 | + | ||
| 230 | + A function and not a `def`, which is the part ClojureDart makes you care | ||
| 231 | + about: a `def` becomes a Dart top-level variable, those initialise on first | ||
| 232 | + read, and a list nothing has read yet is still empty at the moment the | ||
| 233 | + watches are installed. Calling it also forces every cell it names. | ||
| 234 | + | ||
| 235 | + `tools/check-common.py` fails the build if this falls behind the | ||
| 236 | + definitions, because a list kept by hand is only as good as what checks it." | ||
| 237 | + [] | ||
| 238 | + [screen status error connecting? form-host form-port form-tls? form-nick | ||
| 239 | + auth-mode form-handle form-app-password session channels current | ||
| 240 | + join-input search broker-token login-url draft editing replying-to | ||
| 241 | + attachment jump-tick show-users? hide-chat-list? overview? at-present? | ||
| 242 | + emoji-group emoji-search highlight jump-to lightbox overview-return | ||
| 243 | + reacting window-height window-width reaction-hover hide-join-part? | ||
| 244 | + image-picker]) | ||
modified
flutter/src/frq/hiccup.cljd +57 -2 | @@ -143,6 +143,57 @@ | ||
| 143 | 143 | [node] |
| 144 | 144 | (prose? node)) |
| 145 | 145 | |
| 146 | +;; A ScrollController per `:scroll-key`, and what was last done with it. | |
| 147 | +;; | |
| 148 | +;; Kept outside the widget tree because `render` is a plain function, not a | |
| 149 | +;; `f/widget` with state of its own: the tree is rebuilt from scratch on every | |
| 150 | +;; repaint, and a controller made during a build would start at the top each | |
| 151 | +;; time and forget where the reader was. | |
| 152 | +(defonce ^:private scroll-controllers (atom {})) | |
| 153 | +(defonce ^:private scroll-marks (atom {})) | |
| 154 | + | |
| 155 | +(defn- scroll-controller-for | |
| 156 | + "The one controller for `k`, made the first time it is asked for. | |
| 157 | + | |
| 158 | + Named apart from the text-editing `controller-for` above deliberately: they | |
| 159 | + are the same idea for two different widgets, and when they shared a name the | |
| 160 | + compiler took the second and left every entry on screen calling it with an | |
| 161 | + argument too many." | |
| 162 | + [k] | |
| 163 | + (or (get @scroll-controllers k) | |
| 164 | + (let [c (m/ScrollController)] | |
| 165 | + (swap! scroll-controllers assoc k c) | |
| 166 | + c))) | |
| 167 | + | |
| 168 | +(defn- end-ward! | |
| 169 | + "Put `k` at the end after this frame, when it should be. | |
| 170 | + | |
| 171 | + Two reasons to, and they are not the same reason. The token — what the | |
| 172 | + screen passes as `:scroll-to-bottom`, which is `jump-tick` — changing means | |
| 173 | + someone asked to be taken to the present. Already being at the end means new | |
| 174 | + lines should push the view along rather than pile up below it, which is what | |
| 175 | + a conversation does and the whole reason a chat opens at the bottom. | |
| 176 | + | |
| 177 | + And not otherwise: someone reading back through the backlog is at neither, | |
| 178 | + and yanking them to the end as each message arrives is the one behaviour | |
| 179 | + worse than not following at all. | |
| 180 | + | |
| 181 | + After the frame, because the extent being scrolled to is the height of | |
| 182 | + content that has not been laid out yet at the point this is called." | |
| 183 | + [k ctrl token] | |
| 184 | + (let [mark (get @scroll-marks k ::fresh) | |
| 185 | + pos (when (.-hasClients ctrl) (.-position ctrl)) | |
| 186 | + at-end (or (= mark ::fresh) | |
| 187 | + (nil? pos) | |
| 188 | + (>= (.-pixels pos) (- (.-maxScrollExtent pos) 24.0)))] | |
| 189 | + (when (or (not= mark token) at-end) | |
| 190 | + (swap! scroll-marks assoc k token) | |
| 191 | + (.addPostFrameCallback | |
| 192 | + (.-instance m/WidgetsBinding) | |
| 193 | + (fn [_] | |
| 194 | + (when (.-hasClients ctrl) | |
| 195 | + (.jumpTo ctrl (.-maxScrollExtent (.-position ctrl))))))))) | |
| 196 | + | |
| 146 | 197 | (defn- fills-column? |
| 147 | 198 | "Whether a node takes the height its column has left over. |
| 148 | 199 | |
| @@ -513,8 +564,12 @@ | ||
| 513 | 564 | ;; two ParentDataWidgets on one RenderObject, which Flutter calls |
| 514 | 565 | ;; "competing" and then draws nothing. |
| 515 | 566 | :scroll |
| 516 | - (m/SingleChildScrollView | |
| 517 | - .child (col (dbl (:spacing p) 0.0) (body node))) | |
| 567 | + (let [k (str (:scroll-key p)) | |
| 568 | + ctrl (when (seq k) (scroll-controller-for k))] | |
| 569 | + (when ctrl (end-ward! k ctrl (:scroll-to-bottom p))) | |
| 570 | + (m/SingleChildScrollView | |
| 571 | + .controller ctrl | |
| 572 | + .child (col (dbl (:spacing p) 0.0) (body node)))) | |
| 518 | 573 | |
| 519 | 574 | ;; A tag this backend has not grown yet still shows its children — which |
| 520 | 575 | ;; is what libvidya did and what jolt-cosmic kept. The marker is here so |
| @@ -143,6 +143,57 @@ | |||
| 143 | [node] | 143 | [node] |
| 144 | (prose? node)) | 144 | (prose? node)) |
| 145 | 145 | ||
| 146 | +;; A ScrollController per `:scroll-key`, and what was last done with it. | ||
| 147 | +;; | ||
| 148 | +;; Kept outside the widget tree because `render` is a plain function, not a | ||
| 149 | +;; `f/widget` with state of its own: the tree is rebuilt from scratch on every | ||
| 150 | +;; repaint, and a controller made during a build would start at the top each | ||
| 151 | +;; time and forget where the reader was. | ||
| 152 | +(defonce ^:private scroll-controllers (atom {})) | ||
| 153 | +(defonce ^:private scroll-marks (atom {})) | ||
| 154 | + | ||
| 155 | +(defn- scroll-controller-for | ||
| 156 | + "The one controller for `k`, made the first time it is asked for. | ||
| 157 | + | ||
| 158 | + Named apart from the text-editing `controller-for` above deliberately: they | ||
| 159 | + are the same idea for two different widgets, and when they shared a name the | ||
| 160 | + compiler took the second and left every entry on screen calling it with an | ||
| 161 | + argument too many." | ||
| 162 | + [k] | ||
| 163 | + (or (get @scroll-controllers k) | ||
| 164 | + (let [c (m/ScrollController)] | ||
| 165 | + (swap! scroll-controllers assoc k c) | ||
| 166 | + c))) | ||
| 167 | + | ||
| 168 | +(defn- end-ward! | ||
| 169 | + "Put `k` at the end after this frame, when it should be. | ||
| 170 | + | ||
| 171 | + Two reasons to, and they are not the same reason. The token — what the | ||
| 172 | + screen passes as `:scroll-to-bottom`, which is `jump-tick` — changing means | ||
| 173 | + someone asked to be taken to the present. Already being at the end means new | ||
| 174 | + lines should push the view along rather than pile up below it, which is what | ||
| 175 | + a conversation does and the whole reason a chat opens at the bottom. | ||
| 176 | + | ||
| 177 | + And not otherwise: someone reading back through the backlog is at neither, | ||
| 178 | + and yanking them to the end as each message arrives is the one behaviour | ||
| 179 | + worse than not following at all. | ||
| 180 | + | ||
| 181 | + After the frame, because the extent being scrolled to is the height of | ||
| 182 | + content that has not been laid out yet at the point this is called." | ||
| 183 | + [k ctrl token] | ||
| 184 | + (let [mark (get @scroll-marks k ::fresh) | ||
| 185 | + pos (when (.-hasClients ctrl) (.-position ctrl)) | ||
| 186 | + at-end (or (= mark ::fresh) | ||
| 187 | + (nil? pos) | ||
| 188 | + (>= (.-pixels pos) (- (.-maxScrollExtent pos) 24.0)))] | ||
| 189 | + (when (or (not= mark token) at-end) | ||
| 190 | + (swap! scroll-marks assoc k token) | ||
| 191 | + (.addPostFrameCallback | ||
| 192 | + (.-instance m/WidgetsBinding) | ||
| 193 | + (fn [_] | ||
| 194 | + (when (.-hasClients ctrl) | ||
| 195 | + (.jumpTo ctrl (.-maxScrollExtent (.-position ctrl))))))))) | ||
| 196 | + | ||
| 146 | (defn- fills-column? | 197 | (defn- fills-column? |
| 147 | "Whether a node takes the height its column has left over. | 198 | "Whether a node takes the height its column has left over. |
| 148 | 199 | ||
| @@ -513,8 +564,12 @@ | |||
| 513 | ;; two ParentDataWidgets on one RenderObject, which Flutter calls | 564 | ;; two ParentDataWidgets on one RenderObject, which Flutter calls |
| 514 | ;; "competing" and then draws nothing. | 565 | ;; "competing" and then draws nothing. |
| 515 | :scroll | 566 | :scroll |
| 516 | - (m/SingleChildScrollView | 567 | + (let [k (str (:scroll-key p)) |
| 517 | - .child (col (dbl (:spacing p) 0.0) (body node))) | 568 | + ctrl (when (seq k) (scroll-controller-for k))] |
| 569 | + (when ctrl (end-ward! k ctrl (:scroll-to-bottom p))) | ||
| 570 | + (m/SingleChildScrollView | ||
| 571 | + .controller ctrl | ||
| 572 | + .child (col (dbl (:spacing p) 0.0) (body node)))) | ||
| 518 | 573 | ||
| 519 | ;; A tag this backend has not grown yet still shows its children — which | 574 | ;; A tag this backend has not grown yet still shows its children — which |
| 520 | ;; is what libvidya did and what jolt-cosmic kept. The marker is here so | 575 | ;; is what libvidya did and what jolt-cosmic kept. The marker is here so |
modified
flutter/src/frq/main.cljd +36 -26 | @@ -88,6 +88,28 @@ | ||
| 88 | 88 | |
| 89 | 89 | (defonce ^:private attempt (atom 0)) |
| 90 | 90 | |
| 91 | +;; Ticked whenever any cell changes, so one `:watch` covers all of them. | |
| 92 | +;; | |
| 93 | +;; What this replaced was a list of cells named by hand in the widget below, | |
| 94 | +;; and everything not on it was a control that flipped its cell and repainted | |
| 95 | +;; nothing — People, Overview and hide join/part all did. | |
| 96 | +(defonce ^:private repaint (atom 0)) | |
| 97 | + | |
| 98 | +(defn- watch-cells! | |
| 99 | + "Every cell in `frq.cells` ticking `repaint` when it changes. | |
| 100 | + | |
| 101 | + Called from `main` rather than run from a top-level `defonce`, and that is | |
| 102 | + not a style preference: ClojureDart compiles a `def` to a Dart top-level | |
| 103 | + variable, Dart initialises those on first read, and a `defonce` whose value | |
| 104 | + nothing ever reads simply never runs. The first attempt at this installed no | |
| 105 | + watches at all and printed nothing to say so." | |
| 106 | + [] | |
| 107 | + (doseq [c (cells/all-cells)] | |
| 108 | + (add-watch c ::repaint | |
| 109 | + (fn [_ _ old new] | |
| 110 | + (when (not= old new) (swap! repaint inc)) | |
| 111 | + nil)))) | |
| 112 | + | |
| 91 | 113 | (defonce ^:private caps |
| 92 | 114 | ;; What the server has agreed to on this connection. `frq.irc.handshake` |
| 93 | 115 | ;; holds none of it: it takes the set and hands a new one back, so the |
| @@ -289,9 +311,13 @@ | ||
| 289 | 311 | room (str @cells/current)] |
| 290 | 312 | (when (and (seq text) (seq room) @conn) |
| 291 | 313 | (net/send-line! @conn (str "PRIVMSG " room " :" text)) |
| 292 | - ;; Echoed locally: the server does not send our own PRIVMSG back. | |
| 293 | - (swap! cells/channels update room | |
| 294 | - #(update % :messages conj {:from @cells/form-nick :text text})) | |
| 314 | + ;; Echoed locally only when the server will not echo it back. With | |
| 315 | + ;; `echo-message` negotiated it does — that is what the cap is for, and | |
| 316 | + ;; it is how a client learns the msgid of its own line — so adding one | |
| 317 | + ;; here as well put every sent message in the room twice. | |
| 318 | + (when-not (handshake/acked? @caps "echo-message") | |
| 319 | + (swap! cells/channels update room | |
| 320 | + #(update % :messages conj {:from @cells/form-nick :text text}))) | |
| 295 | 321 | (reset! cells/draft "")))) |
| 296 | 322 | |
| 297 | 323 | (defn ^:async main [] |
| @@ -311,6 +337,9 @@ | ||
| 311 | 337 | ;; same signature OpenSSL gives on the desktop, so a signature minted |
| 312 | 338 | ;; here verifies the same way at the server. |
| 313 | 339 | (crypto-dart/install!) |
| 340 | + ;; Before any widget is built: a cell that changes before its watch is on | |
| 341 | + ;; is a change the screen never hears about. | |
| 342 | + (watch-cells!) | |
| 314 | 343 | ;; What the shared screen calls. The desktop installs frq.state's |
| 315 | 344 | ;; reducers here; this installs the phone's. |
| 316 | 345 | (actions/install! |
| @@ -369,29 +398,10 @@ | ||
| 369 | 398 | .body |
| 370 | 399 | m/SafeArea |
| 371 | 400 | (f/widget |
| 372 | - :let [c-status cells/status | |
| 373 | - c-error cells/error | |
| 374 | - c-connecting cells/connecting? | |
| 375 | - c-mode cells/auth-mode | |
| 376 | - c-handle cells/form-handle | |
| 377 | - c-nick cells/form-nick | |
| 378 | - c-host cells/form-host | |
| 379 | - c-port cells/form-port | |
| 380 | - c-tls cells/form-tls? | |
| 381 | - c-apppw cells/form-app-password | |
| 382 | - c-broker cells/broker-token | |
| 383 | - c-login cells/login-url | |
| 384 | - c-screen cells/screen | |
| 385 | - c-draft cells/draft | |
| 386 | - c-channels cells/channels | |
| 387 | - c-current cells/current] | |
| 388 | - :watch [st c-status er c-error cn c-connecting am c-mode | |
| 389 | - fh c-handle nk c-nick hs c-host pt c-port tl c-tls | |
| 390 | - ap c-apppw bt c-broker lu c-login ls lines sc c-screen dr c-draft ch c-channels cu c-current] | |
| 391 | - ;; No scroll view around the screen. A `:page` scrolls itself now, and a | |
| 392 | - ;; `:vbox :fill-height` wants the bounded height the Scaffold gives it — | |
| 393 | - ;; wrapping the tree took that away, and `:scroll`'s Expanded then sat | |
| 394 | - ;; in an unbounded column. | |
| 401 | + ;; One watch, not twenty. `lines` is a local atom and not a cell, so it | |
| 402 | + ;; is named beside the tick; everything under `frq.cells` arrives | |
| 403 | + ;; through `repaint`. | |
| 404 | + :watch [tick repaint ls lines] | |
| 395 | 405 | (h/render |
| 396 | 406 | ;; `frq.screens.app` decides which screen shows, the same way it does |
| 397 | 407 | ;; on the desktop. The phone was switching by hand until this moved. |
| @@ -88,6 +88,28 @@ | |||
| 88 | 88 | ||
| 89 | (defonce ^:private attempt (atom 0)) | 89 | (defonce ^:private attempt (atom 0)) |
| 90 | 90 | ||
| 91 | +;; Ticked whenever any cell changes, so one `:watch` covers all of them. | ||
| 92 | +;; | ||
| 93 | +;; What this replaced was a list of cells named by hand in the widget below, | ||
| 94 | +;; and everything not on it was a control that flipped its cell and repainted | ||
| 95 | +;; nothing — People, Overview and hide join/part all did. | ||
| 96 | +(defonce ^:private repaint (atom 0)) | ||
| 97 | + | ||
| 98 | +(defn- watch-cells! | ||
| 99 | + "Every cell in `frq.cells` ticking `repaint` when it changes. | ||
| 100 | + | ||
| 101 | + Called from `main` rather than run from a top-level `defonce`, and that is | ||
| 102 | + not a style preference: ClojureDart compiles a `def` to a Dart top-level | ||
| 103 | + variable, Dart initialises those on first read, and a `defonce` whose value | ||
| 104 | + nothing ever reads simply never runs. The first attempt at this installed no | ||
| 105 | + watches at all and printed nothing to say so." | ||
| 106 | + [] | ||
| 107 | + (doseq [c (cells/all-cells)] | ||
| 108 | + (add-watch c ::repaint | ||
| 109 | + (fn [_ _ old new] | ||
| 110 | + (when (not= old new) (swap! repaint inc)) | ||
| 111 | + nil)))) | ||
| 112 | + | ||
| 91 | (defonce ^:private caps | 113 | (defonce ^:private caps |
| 92 | ;; What the server has agreed to on this connection. `frq.irc.handshake` | 114 | ;; What the server has agreed to on this connection. `frq.irc.handshake` |
| 93 | ;; holds none of it: it takes the set and hands a new one back, so the | 115 | ;; holds none of it: it takes the set and hands a new one back, so the |
| @@ -289,9 +311,13 @@ | |||
| 289 | room (str @cells/current)] | 311 | room (str @cells/current)] |
| 290 | (when (and (seq text) (seq room) @conn) | 312 | (when (and (seq text) (seq room) @conn) |
| 291 | (net/send-line! @conn (str "PRIVMSG " room " :" text)) | 313 | (net/send-line! @conn (str "PRIVMSG " room " :" text)) |
| 292 | - ;; Echoed locally: the server does not send our own PRIVMSG back. | 314 | + ;; Echoed locally only when the server will not echo it back. With |
| 293 | - (swap! cells/channels update room | 315 | + ;; `echo-message` negotiated it does — that is what the cap is for, and |
| 294 | - #(update % :messages conj {:from @cells/form-nick :text text})) | 316 | + ;; it is how a client learns the msgid of its own line — so adding one |
| 317 | + ;; here as well put every sent message in the room twice. | ||
| 318 | + (when-not (handshake/acked? @caps "echo-message") | ||
| 319 | + (swap! cells/channels update room | ||
| 320 | + #(update % :messages conj {:from @cells/form-nick :text text}))) | ||
| 295 | (reset! cells/draft "")))) | 321 | (reset! cells/draft "")))) |
| 296 | 322 | ||
| 297 | (defn ^:async main [] | 323 | (defn ^:async main [] |
| @@ -311,6 +337,9 @@ | |||
| 311 | ;; same signature OpenSSL gives on the desktop, so a signature minted | 337 | ;; same signature OpenSSL gives on the desktop, so a signature minted |
| 312 | ;; here verifies the same way at the server. | 338 | ;; here verifies the same way at the server. |
| 313 | (crypto-dart/install!) | 339 | (crypto-dart/install!) |
| 340 | + ;; Before any widget is built: a cell that changes before its watch is on | ||
| 341 | + ;; is a change the screen never hears about. | ||
| 342 | + (watch-cells!) | ||
| 314 | ;; What the shared screen calls. The desktop installs frq.state's | 343 | ;; What the shared screen calls. The desktop installs frq.state's |
| 315 | ;; reducers here; this installs the phone's. | 344 | ;; reducers here; this installs the phone's. |
| 316 | (actions/install! | 345 | (actions/install! |
| @@ -369,29 +398,10 @@ | |||
| 369 | .body | 398 | .body |
| 370 | m/SafeArea | 399 | m/SafeArea |
| 371 | (f/widget | 400 | (f/widget |
| 372 | - :let [c-status cells/status | 401 | + ;; One watch, not twenty. `lines` is a local atom and not a cell, so it |
| 373 | - c-error cells/error | 402 | + ;; is named beside the tick; everything under `frq.cells` arrives |
| 374 | - c-connecting cells/connecting? | 403 | + ;; through `repaint`. |
| 375 | - c-mode cells/auth-mode | 404 | + :watch [tick repaint ls lines] |
| 376 | - c-handle cells/form-handle | ||
| 377 | - c-nick cells/form-nick | ||
| 378 | - c-host cells/form-host | ||
| 379 | - c-port cells/form-port | ||
| 380 | - c-tls cells/form-tls? | ||
| 381 | - c-apppw cells/form-app-password | ||
| 382 | - c-broker cells/broker-token | ||
| 383 | - c-login cells/login-url | ||
| 384 | - c-screen cells/screen | ||
| 385 | - c-draft cells/draft | ||
| 386 | - c-channels cells/channels | ||
| 387 | - c-current cells/current] | ||
| 388 | - :watch [st c-status er c-error cn c-connecting am c-mode | ||
| 389 | - fh c-handle nk c-nick hs c-host pt c-port tl c-tls | ||
| 390 | - ap c-apppw bt c-broker lu c-login ls lines sc c-screen dr c-draft ch c-channels cu c-current] | ||
| 391 | - ;; No scroll view around the screen. A `:page` scrolls itself now, and a | ||
| 392 | - ;; `:vbox :fill-height` wants the bounded height the Scaffold gives it — | ||
| 393 | - ;; wrapping the tree took that away, and `:scroll`'s Expanded then sat | ||
| 394 | - ;; in an unbounded column. | ||
| 395 | (h/render | 405 | (h/render |
| 396 | ;; `frq.screens.app` decides which screen shows, the same way it does | 406 | ;; `frq.screens.app` decides which screen shows, the same way it does |
| 397 | ;; on the desktop. The phone was switching by hand until this moved. | 407 | ;; on the desktop. The phone was switching by hand until this moved. |
added
tools/__pycache__/check-common.cpython-314.pyc +0 -0 | new file mode 100644 | ||
| Binary files /dev/null and b/tools/__pycache__/check-common.cpython-314.pyc differ | ||
| new file mode 100644 | |||
| Binary files /dev/null and b/tools/__pycache__/check-common.cpython-314.pyc differ | Binary files /dev/null and b/tools/__pycache__/check-common.cpython-314.pyc differ | ||
modified
tools/check-common.py +27 -4 | @@ -184,6 +184,27 @@ def select(src): | ||
| 184 | 184 | i = hit + 2 |
| 185 | 185 | |
| 186 | 186 | |
| 187 | +def cells_enumerated(root): | |
| 188 | + """frq.cells/all-cells against the cells actually defined. | |
| 189 | + | |
| 190 | + The phone watches what this list names and nothing else, so a cell missing | |
| 191 | + from it is a control that flips state and repaints nothing — which is a | |
| 192 | + bug that looks like a dead button and gets reported as one. | |
| 193 | + """ | |
| 194 | + path = root / "frq" / "cells.cljc" | |
| 195 | + if not path.exists(): | |
| 196 | + return [] | |
| 197 | + src = strip(path.read_text()) | |
| 198 | + defined = re.findall(r"\(defonce ([\w?!*<>+-]+) \(atom ", src) | |
| 199 | + body = src[src.index("(defn all-cells"):] if "(defn all-cells" in src else "" | |
| 200 | + listed = set(re.findall(r"[\w?!*<>+-]+", body[body.index("[", body.index("[]") + 2):])) if body else set() | |
| 201 | + missing = [d for d in defined if d not in listed] | |
| 202 | + return [ | |
| 203 | + (path, 0, d, "defined but missing from frq.cells/all-cells — the phone will not repaint for it") | |
| 204 | + for d in missing | |
| 205 | + ] | |
| 206 | + | |
| 207 | + | |
| 187 | 208 | def main(): |
| 188 | 209 | root = Path(sys.argv[1] if len(sys.argv) > 1 else "common") |
| 189 | 210 | bad = [] |
| @@ -194,13 +215,15 @@ def main(): | ||
| 194 | 215 | m = pattern.search(line) |
| 195 | 216 | if m: |
| 196 | 217 | bad.append((path, lineno, m.group(0).strip(), why)) |
| 218 | + bad += cells_enumerated(root) | |
| 197 | 219 | for path, lineno, tok, why in bad: |
| 198 | - print(f"{path}:{lineno}: {tok!r} is {why}", file=sys.stderr) | |
| 220 | + where = f"{path}:{lineno}" if lineno else str(path) | |
| 221 | + print(f"{where}: {tok!r} is {why}", file=sys.stderr) | |
| 199 | 222 | if bad: |
| 200 | 223 | print( |
| 201 | - f"\n{len(bad)} thing(s) under {root}/ that ClojureDart cannot compile.\n" | |
| 202 | - "common/ is built by both backends: ask frq.io for the host, and add\n" | |
| 203 | - "the call to both implementations. See CLAUDE.md.", | |
| 224 | + f"\n{len(bad)} thing(s) wrong under {root}/, which both backends compile.\n" | |
| 225 | + "If it needs the host, ask frq.io and add the call to both\n" | |
| 226 | + "implementations. See CLAUDE.md.", | |
| 204 | 227 | file=sys.stderr, |
| 205 | 228 | ) |
| 206 | 229 | return 1 |
| @@ -184,6 +184,27 @@ def select(src): | |||
| 184 | i = hit + 2 | 184 | i = hit + 2 |
| 185 | 185 | ||
| 186 | 186 | ||
| 187 | +def cells_enumerated(root): | ||
| 188 | + """frq.cells/all-cells against the cells actually defined. | ||
| 189 | + | ||
| 190 | + The phone watches what this list names and nothing else, so a cell missing | ||
| 191 | + from it is a control that flips state and repaints nothing — which is a | ||
| 192 | + bug that looks like a dead button and gets reported as one. | ||
| 193 | + """ | ||
| 194 | + path = root / "frq" / "cells.cljc" | ||
| 195 | + if not path.exists(): | ||
| 196 | + return [] | ||
| 197 | + src = strip(path.read_text()) | ||
| 198 | + defined = re.findall(r"\(defonce ([\w?!*<>+-]+) \(atom ", src) | ||
| 199 | + body = src[src.index("(defn all-cells"):] if "(defn all-cells" in src else "" | ||
| 200 | + listed = set(re.findall(r"[\w?!*<>+-]+", body[body.index("[", body.index("[]") + 2):])) if body else set() | ||
| 201 | + missing = [d for d in defined if d not in listed] | ||
| 202 | + return [ | ||
| 203 | + (path, 0, d, "defined but missing from frq.cells/all-cells — the phone will not repaint for it") | ||
| 204 | + for d in missing | ||
| 205 | + ] | ||
| 206 | + | ||
| 207 | + | ||
| 187 | def main(): | 208 | def main(): |
| 188 | root = Path(sys.argv[1] if len(sys.argv) > 1 else "common") | 209 | root = Path(sys.argv[1] if len(sys.argv) > 1 else "common") |
| 189 | bad = [] | 210 | bad = [] |
| @@ -194,13 +215,15 @@ def main(): | |||
| 194 | m = pattern.search(line) | 215 | m = pattern.search(line) |
| 195 | if m: | 216 | if m: |
| 196 | bad.append((path, lineno, m.group(0).strip(), why)) | 217 | bad.append((path, lineno, m.group(0).strip(), why)) |
| 218 | + bad += cells_enumerated(root) | ||
| 197 | for path, lineno, tok, why in bad: | 219 | for path, lineno, tok, why in bad: |
| 198 | - print(f"{path}:{lineno}: {tok!r} is {why}", file=sys.stderr) | 220 | + where = f"{path}:{lineno}" if lineno else str(path) |
| 221 | + print(f"{where}: {tok!r} is {why}", file=sys.stderr) | ||
| 199 | if bad: | 222 | if bad: |
| 200 | print( | 223 | print( |
| 201 | - f"\n{len(bad)} thing(s) under {root}/ that ClojureDart cannot compile.\n" | 224 | + f"\n{len(bad)} thing(s) wrong under {root}/, which both backends compile.\n" |
| 202 | - "common/ is built by both backends: ask frq.io for the host, and add\n" | 225 | + "If it needs the host, ask frq.io and add the call to both\n" |
| 203 | - "the call to both implementations. See CLAUDE.md.", | 226 | + "implementations. See CLAUDE.md.", |
| 204 | file=sys.stderr, | 227 | file=sys.stderr, |
| 205 | ) | 228 | ) |
| 206 | return 1 | 229 | return 1 |