Paint glimmer's hiccup with Flutter, rather than porting the screens
The screens do not need porting, and the first read of this was wrong about
it. Counted against the source: frq.state is 1,930 lines with ZERO glimmer
calls — its entire dependency is `:refer [atom]`, shadowing core's atom with
a ratom, and everything after is swap!/reset!/deref. frq.app is 1,834 lines
with exactly one, `r/reaction`; the rest is data, `[:vbox {:spacing 6} ...]`
over about twenty tags, naming no toolkit anywhere.
Which is the thing frq.cosmic and frq.tui have been saying in their
docstrings all along: the components do not know what is under the
reconciler. So Flutter is a third backend rather than a fork, and this is it
— frq.hiccup walks the hiccup and emits widgets.
Where Flutter has no equivalent the shape follows what jvui and libcosmic
drew rather than what Material would do alone: :card is a bordered surface
and not an elevation, :dim-label is the body colour at 60%, :page is a
max-width column centred in what it is given. An unknown tag paints an orange
?tag instead of silently becoming a column — glimmer-cosmic's spike did the
silent thing, and that is why most of frq came out of it as stacked text.
:entry keeps its TextEditingController in an atom keyed by :key. A controller
built during a build is a new one every rebuild, and the caret jumps to the
start on the keystroke after. glimmer's own reconciler matches children by
:key for the same reason.
No reconciliation of our own: Flutter rebuilds from the top and diffs its own
element tree, so a cell firing rebuilds the screen rather than the subtree
that read it. Fine at this size, and the thing to revisit if a message list
ever feels it.
frq.main still paints a hand-written tree, and not because the screens need
changing — requiring them pulls frq.state, which pulls frq.irc, which reaches
for jolt.host. Every tag in it is one frq.app uses. The real work left is the
other end of the app: irc, atproto, oauth, msgsig, avatars, media, profile,
platform — the namespaces that touch the host, in the order flutter/README.md
now lists.
Runs on the device: title, dim-label, a bordered card, an entry with its
placeholder, primary and destructive buttons, a separator and a checkbutton,
all from tags frq.app writes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>bc877e5 parent: e2b0e6b modified
flutter/README.md +45 -6 | @@ -105,9 +105,48 @@ its own nix derivation and never written anywhere, which is why the first | ||
| 105 | 105 | install over it needed an uninstall: Android will not update a package across a |
| 106 | 106 | signature change. |
| 107 | 107 | |
| 108 | -## What it paints | |
| 109 | - | |
| 110 | -`frq.main` is a socket, not the client: the clock and the saved session, read | |
| 111 | -through exactly the `common/` namespaces the desktop reads them through. That | |
| 112 | -is the whole point of it — proof the shared half compiles and runs under a | |
| 113 | -second compiler. The screens are still to be written. | |
| 108 | +## The screens are not rewritten | |
| 109 | + | |
| 110 | +`frq.hiccup` is a glimmer backend, the same way glimmer-cosmic and glimmer-tui | |
| 111 | +are. It walks the hiccup `frq.app` already produces and emits Flutter widgets, | |
| 112 | +so the screens are shared rather than forked. | |
| 113 | + | |
| 114 | +This is worth being precise about, because the first read of the port said | |
| 115 | +otherwise. Measured against the source: | |
| 116 | + | |
| 117 | +* `frq.state` is 1,930 lines and makes **zero** glimmer calls. Its whole | |
| 118 | + dependency on glimmer is `:refer [atom]` — it shadows core's `atom` with a | |
| 119 | + ratom, and everything after that is `swap!`, `reset!` and `deref`. | |
| 120 | +* `frq.app` is 1,834 lines and makes **one**: `r/reaction`. The rest is data — | |
| 121 | + `[:vbox {:spacing 6} ...]` over about twenty tags, naming no toolkit. | |
| 122 | + | |
| 123 | +So what a Flutter port needs is an interpreter for that data, not a rewrite of | |
| 124 | +it. What genuinely has to be ported is the other end: `frq.irc`, `frq.atproto`, | |
| 125 | +`frq.oauth`, `frq.avatars`, `frq.media`, `frq.profile`, `frq.platform` — the | |
| 126 | +namespaces that touch the host. Which is what `frq.io` is for, and where | |
| 127 | +`dart:io` pays for the whole exercise. | |
| 128 | + | |
| 129 | +What `frq.hiccup` does not do is glimmer's reconciliation: Flutter rebuilds | |
| 130 | +from the top and diffs its own element tree, so a cell firing rebuilds the | |
| 131 | +screen rather than the subtree that read it. Fine at this size. | |
| 132 | + | |
| 133 | +`frq.main` still paints a hand-written tree rather than `frq.app`'s own. Not | |
| 134 | +because the screens need changing — because requiring them pulls `frq.state`, | |
| 135 | +which pulls `frq.irc`, which reaches for jolt.host. The tree it paints uses | |
| 136 | +only tags `frq.app` uses, so it is a test of the backend and nothing more. | |
| 137 | + | |
| 138 | +## The order to do the rest in | |
| 139 | + | |
| 140 | +1. **`frq.irc`** (433) — the parser is pure; the reader is a blocking thread in | |
| 141 | + a `future` and becomes a `Stream` over `SecureSocket`. This is also what | |
| 142 | + makes sign-in work on a phone at all. | |
| 143 | +2. **`frq.atproto`** (209), **`frq.oauth`** (182) — hand-rolled HTTPS over | |
| 144 | + OpenSSL bindings today, `dart:io` and `package:http` here. | |
| 145 | +3. **`frq.msgsig`** (268), **`frq.wire`** (81) — need a crypto seam beside the | |
| 146 | + io one. | |
| 147 | +4. **`frq.avatars`**, **`frq.media`**, **`frq.profile`**, **`frq.platform`** — | |
| 148 | + small, and mostly fetch-and-cache. | |
| 149 | +5. **`frq.state`** moves to `common/` as `.cljc`, with `atom` resolved per | |
| 150 | + platform by reader conditional. | |
| 151 | +6. **`frq.app`** follows it, and the tags it uses that `frq.hiccup` does not | |
| 152 | + cover yet paint as an orange `?tag` until they do. | |
| @@ -105,9 +105,48 @@ its own nix derivation and never written anywhere, which is why the first | |||
| 105 | install over it needed an uninstall: Android will not update a package across a | 105 | install over it needed an uninstall: Android will not update a package across a |
| 106 | signature change. | 106 | signature change. |
| 107 | 107 | ||
| 108 | -## What it paints | 108 | +## The screens are not rewritten |
| 109 | - | 109 | + |
| 110 | -`frq.main` is a socket, not the client: the clock and the saved session, read | 110 | +`frq.hiccup` is a glimmer backend, the same way glimmer-cosmic and glimmer-tui |
| 111 | -through exactly the `common/` namespaces the desktop reads them through. That | 111 | +are. It walks the hiccup `frq.app` already produces and emits Flutter widgets, |
| 112 | -is the whole point of it — proof the shared half compiles and runs under a | 112 | +so the screens are shared rather than forked. |
| 113 | -second compiler. The screens are still to be written. | 113 | + |
| 114 | +This is worth being precise about, because the first read of the port said | ||
| 115 | +otherwise. Measured against the source: | ||
| 116 | + | ||
| 117 | +* `frq.state` is 1,930 lines and makes **zero** glimmer calls. Its whole | ||
| 118 | + dependency on glimmer is `:refer [atom]` — it shadows core's `atom` with a | ||
| 119 | + ratom, and everything after that is `swap!`, `reset!` and `deref`. | ||
| 120 | +* `frq.app` is 1,834 lines and makes **one**: `r/reaction`. The rest is data — | ||
| 121 | + `[:vbox {:spacing 6} ...]` over about twenty tags, naming no toolkit. | ||
| 122 | + | ||
| 123 | +So what a Flutter port needs is an interpreter for that data, not a rewrite of | ||
| 124 | +it. What genuinely has to be ported is the other end: `frq.irc`, `frq.atproto`, | ||
| 125 | +`frq.oauth`, `frq.avatars`, `frq.media`, `frq.profile`, `frq.platform` — the | ||
| 126 | +namespaces that touch the host. Which is what `frq.io` is for, and where | ||
| 127 | +`dart:io` pays for the whole exercise. | ||
| 128 | + | ||
| 129 | +What `frq.hiccup` does not do is glimmer's reconciliation: Flutter rebuilds | ||
| 130 | +from the top and diffs its own element tree, so a cell firing rebuilds the | ||
| 131 | +screen rather than the subtree that read it. Fine at this size. | ||
| 132 | + | ||
| 133 | +`frq.main` still paints a hand-written tree rather than `frq.app`'s own. Not | ||
| 134 | +because the screens need changing — because requiring them pulls `frq.state`, | ||
| 135 | +which pulls `frq.irc`, which reaches for jolt.host. The tree it paints uses | ||
| 136 | +only tags `frq.app` uses, so it is a test of the backend and nothing more. | ||
| 137 | + | ||
| 138 | +## The order to do the rest in | ||
| 139 | + | ||
| 140 | +1. **`frq.irc`** (433) — the parser is pure; the reader is a blocking thread in | ||
| 141 | + a `future` and becomes a `Stream` over `SecureSocket`. This is also what | ||
| 142 | + makes sign-in work on a phone at all. | ||
| 143 | +2. **`frq.atproto`** (209), **`frq.oauth`** (182) — hand-rolled HTTPS over | ||
| 144 | + OpenSSL bindings today, `dart:io` and `package:http` here. | ||
| 145 | +3. **`frq.msgsig`** (268), **`frq.wire`** (81) — need a crypto seam beside the | ||
| 146 | + io one. | ||
| 147 | +4. **`frq.avatars`**, **`frq.media`**, **`frq.profile`**, **`frq.platform`** — | ||
| 148 | + small, and mostly fetch-and-cache. | ||
| 149 | +5. **`frq.state`** moves to `common/` as `.cljc`, with `atom` resolved per | ||
| 150 | + platform by reader conditional. | ||
| 151 | +6. **`frq.app`** follows it, and the tags it uses that `frq.hiccup` does not | ||
| 152 | + cover yet paint as an orange `?tag` until they do. | ||
added
flutter/src/frq/hiccup.cljd +282 -0 | new file mode 100644 | ||
| @@ -0,0 +1,282 @@ | ||
| 1 | +(ns frq.hiccup | |
| 2 | + "glimmer's hiccup, painted by Flutter. | |
| 3 | + | |
| 4 | + This is a backend, not a port. `frq.app` is 1,834 lines of | |
| 5 | + `[:vbox {:spacing 6} ...]` over about twenty tags, and it names no toolkit | |
| 6 | + anywhere — the same trick `frq.cosmic` and `frq.tui` rely on, where the | |
| 7 | + components do not know what is under the reconciler. So the screens do not | |
| 8 | + get rewritten for the phone; this interprets them. | |
| 9 | + | |
| 10 | + The tags are glimmer's, and where Flutter has no equivalent the shape is | |
| 11 | + chosen to match what jvui and libcosmic drew rather than what Material would | |
| 12 | + do on its own: `:card` is a surface with a border and not an elevation, | |
| 13 | + `:dim-label` is the body colour at 60%, and `:page` is a max-width column | |
| 14 | + centred in whatever it is given. | |
| 15 | + | |
| 16 | + What this does NOT do is glimmer's reconciliation. glimmer patches the tree | |
| 17 | + it painted last; Flutter rebuilds from the top and diffs its own element | |
| 18 | + tree, which is the same job done by the framework instead of by us. The cost | |
| 19 | + is that a cell firing rebuilds the whole screen rather than the subtree that | |
| 20 | + read it — fine at this size, and the thing to revisit if a message list ever | |
| 21 | + feels it." | |
| 22 | + (:require ["dart:io" :as io] | |
| 23 | + ["package:flutter/material.dart" :as m] | |
| 24 | + [cljd.flutter :as f])) | |
| 25 | + | |
| 26 | +;; ------------------------------------------------------------------ theme | |
| 27 | + | |
| 28 | +(def ^:private gap 8.0) | |
| 29 | + | |
| 30 | +(defn- dbl [x default] | |
| 31 | + (cond (number? x) (double x) | |
| 32 | + :else default)) | |
| 33 | + | |
| 34 | +(defn- muted [ctx] | |
| 35 | + (-> (m/Theme.of ctx) .-colorScheme .-onSurface (.withValues .alpha 0.6))) | |
| 36 | + | |
| 37 | +;; ------------------------------------------------------------- text entry | |
| 38 | + | |
| 39 | +(defonce ^:private controllers | |
| 40 | + ;; TextEditingController per `:key`, because a controller made during a build | |
| 41 | + ;; is a new one every rebuild — the cursor jumps to the start and the | |
| 42 | + ;; selection is lost on the keystroke after it. | |
| 43 | + ;; | |
| 44 | + ;; Keyed rather than positional: glimmer's own reconciler matches children by | |
| 45 | + ;; `:key` for the same reason, and an entry that moves in the tree should | |
| 46 | + ;; keep what is typed in it. | |
| 47 | + (atom {})) | |
| 48 | + | |
| 49 | +(defn- controller-for [k text] | |
| 50 | + (let [k (or k ::anonymous) | |
| 51 | + c (or (get @controllers k) | |
| 52 | + (let [c (m/TextEditingController .text (or text ""))] | |
| 53 | + (swap! controllers assoc k c) | |
| 54 | + c))] | |
| 55 | + ;; Only when it differs, and never while it would move the caret: the cell | |
| 56 | + ;; is the authority for what the entry says, but the person typing is the | |
| 57 | + ;; authority for where they are in it. | |
| 58 | + (when (and text (not= text (.-text c))) | |
| 59 | + (set! (.-text c) text)) | |
| 60 | + c)) | |
| 61 | + | |
| 62 | +;; ------------------------------------------------------------------ props | |
| 63 | + | |
| 64 | +(defn- props [node] | |
| 65 | + (let [p (second node)] (if (map? p) p {}))) | |
| 66 | + | |
| 67 | +(defn- body [node] | |
| 68 | + (let [p (second node)] (if (map? p) (drop 2 node) (drop 1 node)))) | |
| 69 | + | |
| 70 | +;; --------------------------------------------------------------- children | |
| 71 | + | |
| 72 | +(declare render) | |
| 73 | + | |
| 74 | +(defn- children | |
| 75 | + "Flatten seqs, drop nils. `(for [...] ...)` in a component yields a seq in | |
| 76 | + the child position and glimmer splices it; so does this." | |
| 77 | + [nodes] | |
| 78 | + (persistent! | |
| 79 | + (reduce (fn [acc n] | |
| 80 | + (cond (nil? n) acc | |
| 81 | + (seq? n) (reduce conj! acc (children n)) | |
| 82 | + :else (conj! acc (render n)))) | |
| 83 | + (transient []) nodes))) | |
| 84 | + | |
| 85 | +;; ----------------------------------------------------------------- widget | |
| 86 | + | |
| 87 | +(defn- text-style [ctx kind] | |
| 88 | + (let [t (.-textTheme (m/Theme.of ctx))] | |
| 89 | + (case kind | |
| 90 | + :title (.-headlineSmall t) | |
| 91 | + :title-2 (.-titleMedium t) | |
| 92 | + (.-bodyMedium t)))) | |
| 93 | + | |
| 94 | +(defn- render-tag [tag node] | |
| 95 | + (let [p (props node) | |
| 96 | + kids (children (body node)) | |
| 97 | + one (fn [] (if (seq kids) (first kids) (m/SizedBox .width 0.0 .height 0.0)))] | |
| 98 | + (case tag | |
| 99 | + (:vbox :page) | |
| 100 | + (let [col (m/Column | |
| 101 | + .crossAxisAlignment m/CrossAxisAlignment.start | |
| 102 | + .mainAxisSize m/MainAxisSize.min | |
| 103 | + .spacing (dbl (:spacing p) 0.0) | |
| 104 | + .children kids)] | |
| 105 | + (if-let [w (:max-width p)] | |
| 106 | + (m/Center .child (m/ConstrainedBox | |
| 107 | + .constraints (m/BoxConstraints .maxWidth (dbl w 520.0)) | |
| 108 | + .child (m/Padding .padding (m/EdgeInsets.all gap) .child col))) | |
| 109 | + col)) | |
| 110 | + | |
| 111 | + :hbox | |
| 112 | + (m/Row | |
| 113 | + .crossAxisAlignment m/CrossAxisAlignment.center | |
| 114 | + .mainAxisSize m/MainAxisSize.min | |
| 115 | + .spacing (dbl (:spacing p) 0.0) | |
| 116 | + .children kids) | |
| 117 | + | |
| 118 | + :label | |
| 119 | + (f/widget | |
| 120 | + :context ctx | |
| 121 | + (m/Text (str (:label p "")) .style (text-style ctx :body))) | |
| 122 | + | |
| 123 | + :dim-label | |
| 124 | + (f/widget | |
| 125 | + :context ctx | |
| 126 | + (m/Text (str (:label p "")) | |
| 127 | + .style (.copyWith (text-style ctx :body) .color (muted ctx)))) | |
| 128 | + | |
| 129 | + :title | |
| 130 | + (f/widget | |
| 131 | + :context ctx | |
| 132 | + (m/Text (str (:label p "")) .style (text-style ctx :title))) | |
| 133 | + | |
| 134 | + :title-2 | |
| 135 | + (f/widget | |
| 136 | + :context ctx | |
| 137 | + (m/Text (str (:label p "")) .style (text-style ctx :title-2))) | |
| 138 | + | |
| 139 | + :button | |
| 140 | + (let [on (:on-click p) | |
| 141 | + press (when on #(on))] | |
| 142 | + (cond | |
| 143 | + (:destructive p) | |
| 144 | + (m/OutlinedButton .onPressed press .child (m/Text (str (:label p "")))) | |
| 145 | + (:primary p) | |
| 146 | + (m/FilledButton .onPressed press .child (m/Text (str (:label p "")))) | |
| 147 | + :else | |
| 148 | + (m/TextButton .onPressed press .child (m/Text (str (:label p "")))))) | |
| 149 | + | |
| 150 | + :link | |
| 151 | + (let [on (:on-click p)] | |
| 152 | + (m/InkWell | |
| 153 | + .onTap (when on #(on)) | |
| 154 | + .child (f/widget | |
| 155 | + :context ctx | |
| 156 | + (m/Text (str (:label p "")) | |
| 157 | + .style (.copyWith (text-style ctx :body) | |
| 158 | + .decoration m/TextDecoration.underline | |
| 159 | + .color (-> (m/Theme.of ctx) .-colorScheme .-primary)))))) | |
| 160 | + | |
| 161 | + :entry | |
| 162 | + (let [on-change (:on-change p) | |
| 163 | + on-activate (:on-activate p) | |
| 164 | + rows (:rows p)] | |
| 165 | + (m/TextField | |
| 166 | + .controller (controller-for (:key p) (:text p)) | |
| 167 | + .onChanged (when on-change #(on-change %)) | |
| 168 | + .onSubmitted (when on-activate (fn [_] (on-activate))) | |
| 169 | + .maxLines (if rows (int rows) 1) | |
| 170 | + .decoration (m/InputDecoration | |
| 171 | + .isDense true | |
| 172 | + .border (m/OutlineInputBorder) | |
| 173 | + .hintText (:placeholder p)))) | |
| 174 | + | |
| 175 | + :card | |
| 176 | + (f/widget | |
| 177 | + :context ctx | |
| 178 | + (m/Container | |
| 179 | + .padding (m/EdgeInsets.all (* 1.5 gap)) | |
| 180 | + .decoration (m/BoxDecoration | |
| 181 | + .borderRadius (m/BorderRadius.circular 12.0) | |
| 182 | + .border (m/Border.all | |
| 183 | + .color (-> (m/Theme.of ctx) .-colorScheme .-outlineVariant))) | |
| 184 | + .child (m/Column | |
| 185 | + .crossAxisAlignment m/CrossAxisAlignment.start | |
| 186 | + .mainAxisSize m/MainAxisSize.min | |
| 187 | + .spacing (dbl (:spacing p) gap) | |
| 188 | + .children kids))) | |
| 189 | + | |
| 190 | + :separator (m/Divider .height 1.0) | |
| 191 | + | |
| 192 | + :spinner | |
| 193 | + (m/SizedBox .width 16.0 .height 16.0 | |
| 194 | + .child (m/CircularProgressIndicator .strokeWidth 2.0)) | |
| 195 | + | |
| 196 | + :spacer | |
| 197 | + (let [s (dbl (or (:size p) (:gap p) (:width-request p)) gap)] | |
| 198 | + (m/SizedBox .width s .height s)) | |
| 199 | + | |
| 200 | + :checkbutton | |
| 201 | + (let [on (:on-toggled p)] | |
| 202 | + (m/Row | |
| 203 | + .mainAxisSize m/MainAxisSize.min | |
| 204 | + .children [(m/Checkbox .value (boolean (:active p)) | |
| 205 | + .onChanged (when on (fn [v] (on (boolean v))))) | |
| 206 | + (m/Text (str (:label p "")))])) | |
| 207 | + | |
| 208 | + :emoji | |
| 209 | + (m/Text (str (:emoji p "")) .style (m/TextStyle .fontSize (dbl (:size p) 16.0))) | |
| 210 | + | |
| 211 | + :avatar | |
| 212 | + (let [s (dbl (:size p) 32.0) | |
| 213 | + src (:src p)] | |
| 214 | + (m/CircleAvatar | |
| 215 | + .radius (/ s 2.0) | |
| 216 | + .backgroundImage (when (and src (not= "" src)) (m/NetworkImage src)) | |
| 217 | + .child (when (or (nil? src) (= "" src)) | |
| 218 | + (m/Text (let [l (str (:label p ""))] | |
| 219 | + (if (pos? (count l)) (.toUpperCase (subs l 0 1)) "?")))))) | |
| 220 | + | |
| 221 | + :image | |
| 222 | + (let [src (or (:src p) (:path p)) | |
| 223 | + w (:max-width p) | |
| 224 | + h (:max-height p) | |
| 225 | + img (cond | |
| 226 | + (nil? src) (m/SizedBox .width 0.0 .height 0.0) | |
| 227 | + (or (.startsWith (str src) "http://") | |
| 228 | + (.startsWith (str src) "https://")) | |
| 229 | + (m/Image.network (str src) .fit m/BoxFit.contain) | |
| 230 | + :else (m/Image.file (io/File. (str src)) .fit m/BoxFit.contain)) | |
| 231 | + img (if (or w h) | |
| 232 | + (m/ConstrainedBox | |
| 233 | + .constraints (m/BoxConstraints | |
| 234 | + .maxWidth (dbl w double/infinity) | |
| 235 | + .maxHeight (dbl h double/infinity)) | |
| 236 | + .child img) | |
| 237 | + img)] | |
| 238 | + (if-let [on (:on-click p)] | |
| 239 | + (m/InkWell .onTap #(on) .child img) | |
| 240 | + img)) | |
| 241 | + | |
| 242 | + :scroll | |
| 243 | + (m/Expanded | |
| 244 | + .child (m/SingleChildScrollView | |
| 245 | + .child (m/Column | |
| 246 | + .crossAxisAlignment m/CrossAxisAlignment.start | |
| 247 | + .mainAxisSize m/MainAxisSize.min | |
| 248 | + .spacing (dbl (:spacing p) 0.0) | |
| 249 | + .children kids))) | |
| 250 | + | |
| 251 | + ;; Unknown tag. glimmer-cosmic's spike painted these as a column and | |
| 252 | + ;; that is what made most of frq come out as stacked text — so this says | |
| 253 | + ;; so on screen instead of pretending, and the missing tag is one | |
| 254 | + ;; `case` arm away. | |
| 255 | + (m/Column | |
| 256 | + .crossAxisAlignment m/CrossAxisAlignment.start | |
| 257 | + .mainAxisSize m/MainAxisSize.min | |
| 258 | + .children (into [(m/Text (str "?" tag) | |
| 259 | + .style (m/TextStyle .fontSize 10.0 | |
| 260 | + .color m/Colors.orange))] | |
| 261 | + kids))))) | |
| 262 | + | |
| 263 | +(defn render | |
| 264 | + "One hiccup node as a Flutter widget. | |
| 265 | + | |
| 266 | + A vector whose head is a function is a component: glimmer calls it with the | |
| 267 | + rest of the vector as arguments and renders what comes back, and so does | |
| 268 | + this. Everything else is a tag." | |
| 269 | + [node] | |
| 270 | + (cond | |
| 271 | + (nil? node) (m/SizedBox .width 0.0 .height 0.0) | |
| 272 | + (string? node) (m/Text node) | |
| 273 | + (vector? node) | |
| 274 | + (let [head (first node)] | |
| 275 | + (if (keyword? head) | |
| 276 | + (render-tag head node) | |
| 277 | + (render (apply head (rest node))))) | |
| 278 | + (seq? node) | |
| 279 | + (m/Column .crossAxisAlignment m/CrossAxisAlignment.start | |
| 280 | + .mainAxisSize m/MainAxisSize.min | |
| 281 | + .children (children node)) | |
| 282 | + :else (m/Text (str node)))) | |
| new file mode 100644 | |||
| @@ -0,0 +1,282 @@ | |||
| 1 | +(ns frq.hiccup | ||
| 2 | + "glimmer's hiccup, painted by Flutter. | ||
| 3 | + | ||
| 4 | + This is a backend, not a port. `frq.app` is 1,834 lines of | ||
| 5 | + `[:vbox {:spacing 6} ...]` over about twenty tags, and it names no toolkit | ||
| 6 | + anywhere — the same trick `frq.cosmic` and `frq.tui` rely on, where the | ||
| 7 | + components do not know what is under the reconciler. So the screens do not | ||
| 8 | + get rewritten for the phone; this interprets them. | ||
| 9 | + | ||
| 10 | + The tags are glimmer's, and where Flutter has no equivalent the shape is | ||
| 11 | + chosen to match what jvui and libcosmic drew rather than what Material would | ||
| 12 | + do on its own: `:card` is a surface with a border and not an elevation, | ||
| 13 | + `:dim-label` is the body colour at 60%, and `:page` is a max-width column | ||
| 14 | + centred in whatever it is given. | ||
| 15 | + | ||
| 16 | + What this does NOT do is glimmer's reconciliation. glimmer patches the tree | ||
| 17 | + it painted last; Flutter rebuilds from the top and diffs its own element | ||
| 18 | + tree, which is the same job done by the framework instead of by us. The cost | ||
| 19 | + is that a cell firing rebuilds the whole screen rather than the subtree that | ||
| 20 | + read it — fine at this size, and the thing to revisit if a message list ever | ||
| 21 | + feels it." | ||
| 22 | + (:require ["dart:io" :as io] | ||
| 23 | + ["package:flutter/material.dart" :as m] | ||
| 24 | + [cljd.flutter :as f])) | ||
| 25 | + | ||
| 26 | +;; ------------------------------------------------------------------ theme | ||
| 27 | + | ||
| 28 | +(def ^:private gap 8.0) | ||
| 29 | + | ||
| 30 | +(defn- dbl [x default] | ||
| 31 | + (cond (number? x) (double x) | ||
| 32 | + :else default)) | ||
| 33 | + | ||
| 34 | +(defn- muted [ctx] | ||
| 35 | + (-> (m/Theme.of ctx) .-colorScheme .-onSurface (.withValues .alpha 0.6))) | ||
| 36 | + | ||
| 37 | +;; ------------------------------------------------------------- text entry | ||
| 38 | + | ||
| 39 | +(defonce ^:private controllers | ||
| 40 | + ;; TextEditingController per `:key`, because a controller made during a build | ||
| 41 | + ;; is a new one every rebuild — the cursor jumps to the start and the | ||
| 42 | + ;; selection is lost on the keystroke after it. | ||
| 43 | + ;; | ||
| 44 | + ;; Keyed rather than positional: glimmer's own reconciler matches children by | ||
| 45 | + ;; `:key` for the same reason, and an entry that moves in the tree should | ||
| 46 | + ;; keep what is typed in it. | ||
| 47 | + (atom {})) | ||
| 48 | + | ||
| 49 | +(defn- controller-for [k text] | ||
| 50 | + (let [k (or k ::anonymous) | ||
| 51 | + c (or (get @controllers k) | ||
| 52 | + (let [c (m/TextEditingController .text (or text ""))] | ||
| 53 | + (swap! controllers assoc k c) | ||
| 54 | + c))] | ||
| 55 | + ;; Only when it differs, and never while it would move the caret: the cell | ||
| 56 | + ;; is the authority for what the entry says, but the person typing is the | ||
| 57 | + ;; authority for where they are in it. | ||
| 58 | + (when (and text (not= text (.-text c))) | ||
| 59 | + (set! (.-text c) text)) | ||
| 60 | + c)) | ||
| 61 | + | ||
| 62 | +;; ------------------------------------------------------------------ props | ||
| 63 | + | ||
| 64 | +(defn- props [node] | ||
| 65 | + (let [p (second node)] (if (map? p) p {}))) | ||
| 66 | + | ||
| 67 | +(defn- body [node] | ||
| 68 | + (let [p (second node)] (if (map? p) (drop 2 node) (drop 1 node)))) | ||
| 69 | + | ||
| 70 | +;; --------------------------------------------------------------- children | ||
| 71 | + | ||
| 72 | +(declare render) | ||
| 73 | + | ||
| 74 | +(defn- children | ||
| 75 | + "Flatten seqs, drop nils. `(for [...] ...)` in a component yields a seq in | ||
| 76 | + the child position and glimmer splices it; so does this." | ||
| 77 | + [nodes] | ||
| 78 | + (persistent! | ||
| 79 | + (reduce (fn [acc n] | ||
| 80 | + (cond (nil? n) acc | ||
| 81 | + (seq? n) (reduce conj! acc (children n)) | ||
| 82 | + :else (conj! acc (render n)))) | ||
| 83 | + (transient []) nodes))) | ||
| 84 | + | ||
| 85 | +;; ----------------------------------------------------------------- widget | ||
| 86 | + | ||
| 87 | +(defn- text-style [ctx kind] | ||
| 88 | + (let [t (.-textTheme (m/Theme.of ctx))] | ||
| 89 | + (case kind | ||
| 90 | + :title (.-headlineSmall t) | ||
| 91 | + :title-2 (.-titleMedium t) | ||
| 92 | + (.-bodyMedium t)))) | ||
| 93 | + | ||
| 94 | +(defn- render-tag [tag node] | ||
| 95 | + (let [p (props node) | ||
| 96 | + kids (children (body node)) | ||
| 97 | + one (fn [] (if (seq kids) (first kids) (m/SizedBox .width 0.0 .height 0.0)))] | ||
| 98 | + (case tag | ||
| 99 | + (:vbox :page) | ||
| 100 | + (let [col (m/Column | ||
| 101 | + .crossAxisAlignment m/CrossAxisAlignment.start | ||
| 102 | + .mainAxisSize m/MainAxisSize.min | ||
| 103 | + .spacing (dbl (:spacing p) 0.0) | ||
| 104 | + .children kids)] | ||
| 105 | + (if-let [w (:max-width p)] | ||
| 106 | + (m/Center .child (m/ConstrainedBox | ||
| 107 | + .constraints (m/BoxConstraints .maxWidth (dbl w 520.0)) | ||
| 108 | + .child (m/Padding .padding (m/EdgeInsets.all gap) .child col))) | ||
| 109 | + col)) | ||
| 110 | + | ||
| 111 | + :hbox | ||
| 112 | + (m/Row | ||
| 113 | + .crossAxisAlignment m/CrossAxisAlignment.center | ||
| 114 | + .mainAxisSize m/MainAxisSize.min | ||
| 115 | + .spacing (dbl (:spacing p) 0.0) | ||
| 116 | + .children kids) | ||
| 117 | + | ||
| 118 | + :label | ||
| 119 | + (f/widget | ||
| 120 | + :context ctx | ||
| 121 | + (m/Text (str (:label p "")) .style (text-style ctx :body))) | ||
| 122 | + | ||
| 123 | + :dim-label | ||
| 124 | + (f/widget | ||
| 125 | + :context ctx | ||
| 126 | + (m/Text (str (:label p "")) | ||
| 127 | + .style (.copyWith (text-style ctx :body) .color (muted ctx)))) | ||
| 128 | + | ||
| 129 | + :title | ||
| 130 | + (f/widget | ||
| 131 | + :context ctx | ||
| 132 | + (m/Text (str (:label p "")) .style (text-style ctx :title))) | ||
| 133 | + | ||
| 134 | + :title-2 | ||
| 135 | + (f/widget | ||
| 136 | + :context ctx | ||
| 137 | + (m/Text (str (:label p "")) .style (text-style ctx :title-2))) | ||
| 138 | + | ||
| 139 | + :button | ||
| 140 | + (let [on (:on-click p) | ||
| 141 | + press (when on #(on))] | ||
| 142 | + (cond | ||
| 143 | + (:destructive p) | ||
| 144 | + (m/OutlinedButton .onPressed press .child (m/Text (str (:label p "")))) | ||
| 145 | + (:primary p) | ||
| 146 | + (m/FilledButton .onPressed press .child (m/Text (str (:label p "")))) | ||
| 147 | + :else | ||
| 148 | + (m/TextButton .onPressed press .child (m/Text (str (:label p "")))))) | ||
| 149 | + | ||
| 150 | + :link | ||
| 151 | + (let [on (:on-click p)] | ||
| 152 | + (m/InkWell | ||
| 153 | + .onTap (when on #(on)) | ||
| 154 | + .child (f/widget | ||
| 155 | + :context ctx | ||
| 156 | + (m/Text (str (:label p "")) | ||
| 157 | + .style (.copyWith (text-style ctx :body) | ||
| 158 | + .decoration m/TextDecoration.underline | ||
| 159 | + .color (-> (m/Theme.of ctx) .-colorScheme .-primary)))))) | ||
| 160 | + | ||
| 161 | + :entry | ||
| 162 | + (let [on-change (:on-change p) | ||
| 163 | + on-activate (:on-activate p) | ||
| 164 | + rows (:rows p)] | ||
| 165 | + (m/TextField | ||
| 166 | + .controller (controller-for (:key p) (:text p)) | ||
| 167 | + .onChanged (when on-change #(on-change %)) | ||
| 168 | + .onSubmitted (when on-activate (fn [_] (on-activate))) | ||
| 169 | + .maxLines (if rows (int rows) 1) | ||
| 170 | + .decoration (m/InputDecoration | ||
| 171 | + .isDense true | ||
| 172 | + .border (m/OutlineInputBorder) | ||
| 173 | + .hintText (:placeholder p)))) | ||
| 174 | + | ||
| 175 | + :card | ||
| 176 | + (f/widget | ||
| 177 | + :context ctx | ||
| 178 | + (m/Container | ||
| 179 | + .padding (m/EdgeInsets.all (* 1.5 gap)) | ||
| 180 | + .decoration (m/BoxDecoration | ||
| 181 | + .borderRadius (m/BorderRadius.circular 12.0) | ||
| 182 | + .border (m/Border.all | ||
| 183 | + .color (-> (m/Theme.of ctx) .-colorScheme .-outlineVariant))) | ||
| 184 | + .child (m/Column | ||
| 185 | + .crossAxisAlignment m/CrossAxisAlignment.start | ||
| 186 | + .mainAxisSize m/MainAxisSize.min | ||
| 187 | + .spacing (dbl (:spacing p) gap) | ||
| 188 | + .children kids))) | ||
| 189 | + | ||
| 190 | + :separator (m/Divider .height 1.0) | ||
| 191 | + | ||
| 192 | + :spinner | ||
| 193 | + (m/SizedBox .width 16.0 .height 16.0 | ||
| 194 | + .child (m/CircularProgressIndicator .strokeWidth 2.0)) | ||
| 195 | + | ||
| 196 | + :spacer | ||
| 197 | + (let [s (dbl (or (:size p) (:gap p) (:width-request p)) gap)] | ||
| 198 | + (m/SizedBox .width s .height s)) | ||
| 199 | + | ||
| 200 | + :checkbutton | ||
| 201 | + (let [on (:on-toggled p)] | ||
| 202 | + (m/Row | ||
| 203 | + .mainAxisSize m/MainAxisSize.min | ||
| 204 | + .children [(m/Checkbox .value (boolean (:active p)) | ||
| 205 | + .onChanged (when on (fn [v] (on (boolean v))))) | ||
| 206 | + (m/Text (str (:label p "")))])) | ||
| 207 | + | ||
| 208 | + :emoji | ||
| 209 | + (m/Text (str (:emoji p "")) .style (m/TextStyle .fontSize (dbl (:size p) 16.0))) | ||
| 210 | + | ||
| 211 | + :avatar | ||
| 212 | + (let [s (dbl (:size p) 32.0) | ||
| 213 | + src (:src p)] | ||
| 214 | + (m/CircleAvatar | ||
| 215 | + .radius (/ s 2.0) | ||
| 216 | + .backgroundImage (when (and src (not= "" src)) (m/NetworkImage src)) | ||
| 217 | + .child (when (or (nil? src) (= "" src)) | ||
| 218 | + (m/Text (let [l (str (:label p ""))] | ||
| 219 | + (if (pos? (count l)) (.toUpperCase (subs l 0 1)) "?")))))) | ||
| 220 | + | ||
| 221 | + :image | ||
| 222 | + (let [src (or (:src p) (:path p)) | ||
| 223 | + w (:max-width p) | ||
| 224 | + h (:max-height p) | ||
| 225 | + img (cond | ||
| 226 | + (nil? src) (m/SizedBox .width 0.0 .height 0.0) | ||
| 227 | + (or (.startsWith (str src) "http://") | ||
| 228 | + (.startsWith (str src) "https://")) | ||
| 229 | + (m/Image.network (str src) .fit m/BoxFit.contain) | ||
| 230 | + :else (m/Image.file (io/File. (str src)) .fit m/BoxFit.contain)) | ||
| 231 | + img (if (or w h) | ||
| 232 | + (m/ConstrainedBox | ||
| 233 | + .constraints (m/BoxConstraints | ||
| 234 | + .maxWidth (dbl w double/infinity) | ||
| 235 | + .maxHeight (dbl h double/infinity)) | ||
| 236 | + .child img) | ||
| 237 | + img)] | ||
| 238 | + (if-let [on (:on-click p)] | ||
| 239 | + (m/InkWell .onTap #(on) .child img) | ||
| 240 | + img)) | ||
| 241 | + | ||
| 242 | + :scroll | ||
| 243 | + (m/Expanded | ||
| 244 | + .child (m/SingleChildScrollView | ||
| 245 | + .child (m/Column | ||
| 246 | + .crossAxisAlignment m/CrossAxisAlignment.start | ||
| 247 | + .mainAxisSize m/MainAxisSize.min | ||
| 248 | + .spacing (dbl (:spacing p) 0.0) | ||
| 249 | + .children kids))) | ||
| 250 | + | ||
| 251 | + ;; Unknown tag. glimmer-cosmic's spike painted these as a column and | ||
| 252 | + ;; that is what made most of frq come out as stacked text — so this says | ||
| 253 | + ;; so on screen instead of pretending, and the missing tag is one | ||
| 254 | + ;; `case` arm away. | ||
| 255 | + (m/Column | ||
| 256 | + .crossAxisAlignment m/CrossAxisAlignment.start | ||
| 257 | + .mainAxisSize m/MainAxisSize.min | ||
| 258 | + .children (into [(m/Text (str "?" tag) | ||
| 259 | + .style (m/TextStyle .fontSize 10.0 | ||
| 260 | + .color m/Colors.orange))] | ||
| 261 | + kids))))) | ||
| 262 | + | ||
| 263 | +(defn render | ||
| 264 | + "One hiccup node as a Flutter widget. | ||
| 265 | + | ||
| 266 | + A vector whose head is a function is a component: glimmer calls it with the | ||
| 267 | + rest of the vector as arguments and renders what comes back, and so does | ||
| 268 | + this. Everything else is a tag." | ||
| 269 | + [node] | ||
| 270 | + (cond | ||
| 271 | + (nil? node) (m/SizedBox .width 0.0 .height 0.0) | ||
| 272 | + (string? node) (m/Text node) | ||
| 273 | + (vector? node) | ||
| 274 | + (let [head (first node)] | ||
| 275 | + (if (keyword? head) | ||
| 276 | + (render-tag head node) | ||
| 277 | + (render (apply head (rest node))))) | ||
| 278 | + (seq? node) | ||
| 279 | + (m/Column .crossAxisAlignment m/CrossAxisAlignment.start | ||
| 280 | + .mainAxisSize m/MainAxisSize.min | ||
| 281 | + .children (children node)) | ||
| 282 | + :else (m/Text (str node)))) | ||
modified
flutter/src/frq/main.cljd +44 -7 | @@ -19,22 +19,59 @@ | ||
| 19 | 19 | (:require ["package:flutter/material.dart" :as m] |
| 20 | 20 | ["package:path_provider/path_provider.dart" :as pp] |
| 21 | 21 | [cljd.flutter :as f] |
| 22 | + [frq.hiccup :as h] | |
| 22 | 23 | [frq.io.dart :as host] |
| 23 | 24 | [frq.clock :as clock] |
| 24 | 25 | [frq.store :as store])) |
| 25 | 26 | |
| 27 | +(defonce ^:private tick (atom 0)) | |
| 28 | + | |
| 29 | +(defn- demo | |
| 30 | + "A hiccup tree in glimmer's tags, rendered by `frq.hiccup`. | |
| 31 | + | |
| 32 | + Hand-written, and that is the point of it for now: `frq.app`'s own screens | |
| 33 | + cannot be required here yet — `frq.state` reaches for jolt.host through | |
| 34 | + frq.irc and everything under it — so this exercises the tags they are built | |
| 35 | + from until that chain is portable. Every tag here is one `frq.app` uses, and | |
| 36 | + none of it is Flutter." | |
| 37 | + [] | |
| 38 | + [:page {:max-width 520} | |
| 39 | + [:title {:label "frq"}] | |
| 40 | + [:dim-label {:label "freeq client — guest, or your Bluesky identity."}] | |
| 41 | + [:card {} | |
| 42 | + [:title-2 {:label "Sign in with Bluesky"}] | |
| 43 | + [:dim-label {:label "Opens your browser for AT Protocol OAuth. freeq's broker hands back a token; no password passes through frq."}] | |
| 44 | + [:label {:label "Handle"}] | |
| 45 | + [:entry {:key :handle | |
| 46 | + :placeholder "alice.bsky.social" | |
| 47 | + :on-change (fn [_] nil)}] | |
| 48 | + [:hbox {:spacing 8} | |
| 49 | + [:button {:label "Connect" :primary true :on-click #(swap! tick inc)}] | |
| 50 | + [:button {:label "Forget saved session" :destructive true :on-click #(swap! tick inc)}]] | |
| 51 | + [:separator {}] | |
| 52 | + [:checkbutton {:label "TLS" :active true :on-toggled (fn [_] nil)}] | |
| 53 | + [:hbox {:spacing 6} | |
| 54 | + [:spinner {}] | |
| 55 | + [:dim-label {:label "Resuming your session…"}]]] | |
| 56 | + [:card {} | |
| 57 | + [:title-2 {:label "Shared with the desktop"}] | |
| 58 | + [:label {:label (str "clock " (clock/clock-time (clock/now-ms)))}] | |
| 59 | + [:label {:label (str "store " (if-let [s (store/load-session)] | |
| 60 | + (str "signed in as " (:handle s)) | |
| 61 | + "no saved session"))}] | |
| 62 | + [:dim-label {:label "Both read through frq.io, same source as jolt."}]]]) | |
| 63 | + | |
| 26 | 64 | (defn ^:async main [] |
| 27 | 65 | (m/WidgetsFlutterBinding.ensureInitialized) |
| 28 | 66 | (let [dir (.-path (await (pp/getApplicationSupportDirectory)))] |
| 29 | 67 | (host/install! dir) |
| 30 | 68 | (f/run |
| 31 | - (m/MaterialApp .title "frq") | |
| 69 | + (m/MaterialApp .title "frq" | |
| 70 | + .theme (m/ThemeData .useMaterial3 true | |
| 71 | + .colorSchemeSeed m/Colors.indigo)) | |
| 32 | 72 | .home |
| 33 | 73 | (m/Scaffold .appBar (m/AppBar .title (m/Text "frq"))) |
| 34 | 74 | .body |
| 35 | - m/Center | |
| 36 | - (m/Text (str "frq\n" | |
| 37 | - (clock/clock-time (clock/now-ms)) "\n" | |
| 38 | - (if-let [s (store/load-session)] | |
| 39 | - (str "signed in as " (:handle s)) | |
| 40 | - "no saved session")))))) | |
| 75 | + (f/widget :watch [_ tick]) | |
| 76 | + (m/SingleChildScrollView) | |
| 77 | + (h/render (demo))))) | |
| @@ -19,22 +19,59 @@ | |||
| 19 | (:require ["package:flutter/material.dart" :as m] | 19 | (:require ["package:flutter/material.dart" :as m] |
| 20 | ["package:path_provider/path_provider.dart" :as pp] | 20 | ["package:path_provider/path_provider.dart" :as pp] |
| 21 | [cljd.flutter :as f] | 21 | [cljd.flutter :as f] |
| 22 | + [frq.hiccup :as h] | ||
| 22 | [frq.io.dart :as host] | 23 | [frq.io.dart :as host] |
| 23 | [frq.clock :as clock] | 24 | [frq.clock :as clock] |
| 24 | [frq.store :as store])) | 25 | [frq.store :as store])) |
| 25 | 26 | ||
| 27 | +(defonce ^:private tick (atom 0)) | ||
| 28 | + | ||
| 29 | +(defn- demo | ||
| 30 | + "A hiccup tree in glimmer's tags, rendered by `frq.hiccup`. | ||
| 31 | + | ||
| 32 | + Hand-written, and that is the point of it for now: `frq.app`'s own screens | ||
| 33 | + cannot be required here yet — `frq.state` reaches for jolt.host through | ||
| 34 | + frq.irc and everything under it — so this exercises the tags they are built | ||
| 35 | + from until that chain is portable. Every tag here is one `frq.app` uses, and | ||
| 36 | + none of it is Flutter." | ||
| 37 | + [] | ||
| 38 | + [:page {:max-width 520} | ||
| 39 | + [:title {:label "frq"}] | ||
| 40 | + [:dim-label {:label "freeq client — guest, or your Bluesky identity."}] | ||
| 41 | + [:card {} | ||
| 42 | + [:title-2 {:label "Sign in with Bluesky"}] | ||
| 43 | + [:dim-label {:label "Opens your browser for AT Protocol OAuth. freeq's broker hands back a token; no password passes through frq."}] | ||
| 44 | + [:label {:label "Handle"}] | ||
| 45 | + [:entry {:key :handle | ||
| 46 | + :placeholder "alice.bsky.social" | ||
| 47 | + :on-change (fn [_] nil)}] | ||
| 48 | + [:hbox {:spacing 8} | ||
| 49 | + [:button {:label "Connect" :primary true :on-click #(swap! tick inc)}] | ||
| 50 | + [:button {:label "Forget saved session" :destructive true :on-click #(swap! tick inc)}]] | ||
| 51 | + [:separator {}] | ||
| 52 | + [:checkbutton {:label "TLS" :active true :on-toggled (fn [_] nil)}] | ||
| 53 | + [:hbox {:spacing 6} | ||
| 54 | + [:spinner {}] | ||
| 55 | + [:dim-label {:label "Resuming your session…"}]]] | ||
| 56 | + [:card {} | ||
| 57 | + [:title-2 {:label "Shared with the desktop"}] | ||
| 58 | + [:label {:label (str "clock " (clock/clock-time (clock/now-ms)))}] | ||
| 59 | + [:label {:label (str "store " (if-let [s (store/load-session)] | ||
| 60 | + (str "signed in as " (:handle s)) | ||
| 61 | + "no saved session"))}] | ||
| 62 | + [:dim-label {:label "Both read through frq.io, same source as jolt."}]]]) | ||
| 63 | + | ||
| 26 | (defn ^:async main [] | 64 | (defn ^:async main [] |
| 27 | (m/WidgetsFlutterBinding.ensureInitialized) | 65 | (m/WidgetsFlutterBinding.ensureInitialized) |
| 28 | (let [dir (.-path (await (pp/getApplicationSupportDirectory)))] | 66 | (let [dir (.-path (await (pp/getApplicationSupportDirectory)))] |
| 29 | (host/install! dir) | 67 | (host/install! dir) |
| 30 | (f/run | 68 | (f/run |
| 31 | - (m/MaterialApp .title "frq") | 69 | + (m/MaterialApp .title "frq" |
| 70 | + .theme (m/ThemeData .useMaterial3 true | ||
| 71 | + .colorSchemeSeed m/Colors.indigo)) | ||
| 32 | .home | 72 | .home |
| 33 | (m/Scaffold .appBar (m/AppBar .title (m/Text "frq"))) | 73 | (m/Scaffold .appBar (m/AppBar .title (m/Text "frq"))) |
| 34 | .body | 74 | .body |
| 35 | - m/Center | 75 | + (f/widget :watch [_ tick]) |
| 36 | - (m/Text (str "frq\n" | 76 | + (m/SingleChildScrollView) |
| 37 | - (clock/clock-time (clock/now-ms)) "\n" | 77 | + (h/render (demo))))) |
| 38 | - (if-let [s (store/load-session)] | ||
| 39 | - (str "signed in as " (:handle s)) | ||
| 40 | - "no saved session")))))) | ||