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

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>
nandi committed 2026-09-11T18:05:30-07:00 Browse files
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
105105 install over it needed an uninstall: Android will not update a package across a
106106 signature change.
107107
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 a105 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 paints108+## The screens are not rewritten
109-109+
110-`frq.main` is a socket, not the client: the clock and the saved session, read110+`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. That111+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 a112+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 @@
1919 (:require ["package:flutter/material.dart" :as m]
2020 ["package:path_provider/path_provider.dart" :as pp]
2121 [cljd.flutter :as f]
22+ [frq.hiccup :as h]
2223 [frq.io.dart :as host]
2324 [frq.clock :as clock]
2425 [frq.store :as store]))
2526
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+
2664 (defn ^:async main []
2765 (m/WidgetsFlutterBinding.ensureInitialized)
2866 (let [dir (.-path (await (pp/getApplicationSupportDirectory)))]
2967 (host/install! dir)
3068 (f/run
31- (m/MaterialApp .title "frq")
69+ (m/MaterialApp .title "frq"
70+ .theme (m/ThemeData .useMaterial3 true
71+ .colorSchemeSeed m/Colors.indigo))
3272 .home
3373 (m/Scaffold .appBar (m/AppBar .title (m/Text "frq")))
3474 .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/run68 (f/run
31- (m/MaterialApp .title "frq")69+ (m/MaterialApp .title "frq"
70+ .theme (m/ThemeData .useMaterial3 true
71+ .colorSchemeSeed m/Colors.indigo))
32 .home72 .home
33 (m/Scaffold .appBar (m/AppBar .title (m/Text "frq")))73 (m/Scaffold .appBar (m/AppBar .title (m/Text "frq")))
34 .body74 .body
35- m/Center75+ (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"))))))