nandi/frqpublic Fork 0
ba4e71b
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 the phone in COSMIC's own theme, read from cosmic-config

The styling was a guess and the guess was wrong. frq.hiccup painted Material
with an indigo seed; what `crates/jolt-cosmic` actually asks libcosmic for is
title3 for :title and title4 for :title-2, body for :label and caption for
:dim-label, Container::Card at padding 12 and spacing 8, and
suggested/destructive/standard for the three kinds of button. Every arm of
the renderer is that mapping now, in libcosmic's own 4/8/12/16/24 spacing and
its corner radii, so a screen laid out for the desktop lands at the same
proportions here.

And the colours are not a guess either. tools/cosmic2cljd.py reads
~/.config/cosmic and writes frq.theme.cosmic, so the phone is painted in
whatever is set in COSMIC Settings rather than in an idea of what COSMIC
looks like. On this machine that is a cream accent, #F4E3CF, over #202833 —
nothing like the blue that was there before, which is the point.

Worth being exact about what differs: libcosmic reads the theme at run time,
so the desktop follows it as it changes, and the APK carries it as it was
when the APK was built. `just theme` moves it. The generated file is in git
rather than gitignored so a checkout on a machine with no COSMIC still
builds.

The typography scale is not in cosmic-config — libcosmic carries title1..4,
heading, body and caption as code — so those numbers stay in frq.theme.

One thing this caught that guessing could not: accent.on and destructive.on
are both black in this theme. A cream accent with white text on it is
unreadable, and that is exactly what the guess produced.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nandi committed 2026-09-11T19:08:05-07:00 Browse files
ba4e71b parent: f735240
modified flutter/src/frq/hiccup.cljd +242 -186
@@ -7,11 +7,17 @@
77 components do not know what is under the reconciler. So the screens do not
88 get rewritten for the phone; this interprets them.
99
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.
10+ The tags are glimmer's and so is the styling: every arm below is what
11+ `crates/jolt-cosmic` asks libcosmic for, in `frq.theme`'s tokens. `:title` is
12+ title3 and `:title-2` is title4; `:card` is Container::Card padding 12,
13+ spacing 8, a surface a step from the background rather than an elevation,
14+ because COSMIC does not float things; `:button` is suggested, destructive or
15+ standard; `:dim-label` is the caption class, which is the body colour stepped
16+ back rather than a colour of its own.
17+
18+ Material is underneath and deliberately not visible. No elevation, no ripple
19+ shadows, no Material 3 pill heights a screen laid out against libcosmic's
20+ 4/8/12/16/24 spacing lands at the same proportions here.
1521
1622 What this does NOT do is glimmer's reconciliation. glimmer patches the tree
1723 it painted last; Flutter rebuilds from the top and diffs its own element
@@ -21,19 +27,14 @@
2127 feels it."
2228 (:require ["dart:io" :as io]
2329 ["package:flutter/material.dart" :as m]
24- [cljd.flutter :as f]))
30+ [cljd.flutter :as f]
31+ [frq.theme :as t]))
2532
26-;; ------------------------------------------------------------------ theme
27-
28-(def ^:private gap 8.0)
2933
3034 (defn- dbl [x default]
3135 (cond (number? x) (double x)
3236 :else default))
3337
34-(defn- muted [ctx]
35- (-> (m/Theme.of ctx) .-colorScheme .-onSurface (.withValues .alpha 0.6)))
36-
3738 ;; ------------------------------------------------------------- text entry
3839
3940 (defonce ^:private controllers
@@ -67,8 +68,6 @@
6768 (defn- body [node]
6869 (let [p (second node)] (if (map? p) (drop 2 node) (drop 1 node))))
6970
70-;; --------------------------------------------------------------- children
71-
7271 (declare render)
7372
7473 (defn- children
@@ -82,183 +81,240 @@
8281 :else (conj! acc (render n))))
8382 (transient []) nodes)))
8483
85-;; ----------------------------------------------------------------- widget
84+;; ------------------------------------------------------------------ text
85+
86+(defn- txt
87+ [ctx s size color & {:keys [weight]}]
88+ (m/Text (str s)
89+ .style (m/TextStyle .fontSize size
90+ .color color
91+ .height 1.35
92+ .fontWeight (or weight m/FontWeight.w400))))
93+
94+;; ---------------------------------------------------------------- buttons
95+
96+(defn- cosmic-button
97+ "libcosmic's three button classes. Filled accent for suggested, filled red
98+ for destructive, and a component-coloured fill for standard COSMIC's
99+ standard button is a filled surface, not an outline."
100+ [ctx p on]
101+ (let [kind (cond (:destructive p) :destructive
102+ (or (:primary p) (= "primary" (:kind p))) :suggested
103+ :else :standard)
104+ bg (case kind
105+ :suggested t/accent
106+ :destructive t/destructive
107+ t/component)
108+ ;; COSMIC names the foreground for each role, and for this theme both
109+ ;; accent.on and destructive.on are black a cream accent with white
110+ ;; text on it is unreadable, which is exactly what guessing produced.
111+ fg (case kind
112+ :suggested t/on-accent
113+ :destructive t/on-destructive
114+ t/on-bg)]
115+ (m/Material
116+ .color bg
117+ .borderRadius (m/BorderRadius.circular t/radius-m)
118+ .child (m/InkWell
119+ .borderRadius (m/BorderRadius.circular t/radius-m)
120+ .onTap (when on #(on))
121+ .child (m/Padding
122+ .padding (m/EdgeInsets.symmetric .horizontal t/space-s
123+ .vertical t/space-xxs)
124+ .child (txt ctx (:label p "") t/text-body fg
125+ :weight m/FontWeight.w500))))))
86126
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))))
127+;; ----------------------------------------------------------------- widget
93128
94129 (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
130+ (f/widget
131+ :context ctx
132+ (let [p (props node)
133+ kids (children (body node))
134+ col (fn [sp cs] (m/Column .crossAxisAlignment m/CrossAxisAlignment.start
135+ .mainAxisSize m/MainAxisSize.min
136+ .spacing sp
137+ .children cs))]
138+ (case tag
139+ :vbox
140+ (let [c (col (dbl (:spacing p) 0.0) kids)]
141+ (if-let [w (:width-request p)]
142+ (m/SizedBox .width (dbl w 0.0) .child c)
143+ c))
144+
145+ ;; `page` is jolt-cosmic's scrollable container, centred and capped.
146+ :page
147+ (m/Center
148+ .child (m/ConstrainedBox
149+ .constraints (m/BoxConstraints .maxWidth (dbl (:max-width p) 520.0))
150+ .child (m/Padding
151+ .padding (m/EdgeInsets.all t/space-s)
152+ .child (col t/space-xs kids))))
153+
154+ :hbox
155+ (m/Row
156+ .crossAxisAlignment m/CrossAxisAlignment.center
157+ .mainAxisSize m/MainAxisSize.min
158+ .spacing (dbl (:spacing p) 0.0)
159+ .children kids)
160+
161+ :label (txt ctx (:label p "") t/text-body t/on-bg)
162+ :dim-label (txt ctx (:label p "") t/text-caption t/dim)
163+ :title (txt ctx (:label p "") t/text-title-3 t/on-bg
164+ :weight m/FontWeight.w600)
165+ :title-2 (txt ctx (:label p "") t/text-title-4 t/on-bg
166+ :weight m/FontWeight.w600)
167+
168+ :button (cosmic-button ctx p (:on-click p))
169+
170+ ;; button::link accent text, no underline. COSMIC links are buttons.
171+ :link
172+ (m/InkWell
173+ .onTap (when-let [on (:on-click p)] #(on))
174+ .child (txt ctx (:label p "") t/text-body t/accent))
175+
176+ ;; Container::Card: padding 12, spacing 8, fills its width unless it is
177+ ;; sitting in a row.
178+ :card
178179 (m/Container
179- .padding (m/EdgeInsets.all (* 1.5 gap))
180+ .width double/infinity
181+ .padding (m/EdgeInsets.all t/space-xs)
180182 .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)))))
183+ .color t/card
184+ .borderRadius (m/BorderRadius.circular t/radius-s))
185+ .child (col (dbl (:spacing p) t/space-xxs) kids))
186+
187+ :separator (m/Divider .height 1.0 .thickness 1.0 .color t/divider)
188+
189+ ;; A 16px indeterminate circle, with the label as a caption beside it.
190+ :spinner
191+ (m/Row
192+ .mainAxisSize m/MainAxisSize.min
193+ .spacing t/space-xxs
194+ .children (into [(m/SizedBox
195+ .width 16.0 .height 16.0
196+ .child (m/CircularProgressIndicator
197+ .strokeWidth 2.0
198+ .color t/accent))]
199+ (when-let [l (not-empty (str (:label p "")))]
200+ [(txt ctx l t/text-caption t/dim)])))
201+
202+ ;; A dot that says whether the thing is live, and the words beside it.
203+ :status
204+ (m/Row
205+ .mainAxisSize m/MainAxisSize.min
206+ .spacing 6.0
207+ .children [(m/Container
208+ .width 8.0 .height 8.0
209+ .decoration (m/BoxDecoration
210+ .color (if (:live p) t/success t/dim)
211+ .borderRadius (m/BorderRadius.circular t/radius-xs)))
212+ (txt ctx (:label p "") t/text-caption t/dim)])
213+
214+ :spacer
215+ (let [size (dbl (or (:size p) (:gap p) (:width-request p)) t/space-xxs)]
216+ (m/SizedBox .width size .height size))
217+
218+ :checkbutton
219+ (let [on (:on-toggled p)]
220+ (m/Row
221+ .mainAxisSize m/MainAxisSize.min
222+ .spacing t/space-xxxs
223+ .children [(m/SizedBox
224+ .width 20.0 .height 20.0
225+ .child (m/Checkbox
226+ .value (boolean (:active p))
227+ .activeColor t/accent
228+ .onChanged (when on (fn [v] (on (boolean v))))))
229+ (txt ctx (:label p "") t/text-body t/on-bg)]))
230+
231+ ;; text_input: a filled rounded field, no outline. COSMIC entries sit in
232+ ;; the component colour rather than behind a border.
233+ :entry
234+ (let [on-change (:on-change p)
235+ on-activate (:on-activate p)
236+ rows (:rows p)]
237+ (m/TextField
238+ .controller (controller-for (:key p) (:text p))
239+ .onChanged (when on-change #(on-change %))
240+ .onSubmitted (when on-activate (fn [_] (on-activate)))
241+ .maxLines (if rows (int rows) 1)
242+ .style (m/TextStyle .fontSize t/text-body .color t/on-bg)
243+ .cursorColor t/accent
244+ .decoration (m/InputDecoration
245+ .isDense true
246+ .filled true
247+ .fillColor t/component
248+ .hintText (:placeholder p)
249+ .hintStyle (m/TextStyle .fontSize t/text-body
250+ .color t/dim)
251+ .contentPadding (m/EdgeInsets.symmetric
252+ .horizontal t/space-xs
253+ .vertical t/space-xxs)
254+ .border (m/OutlineInputBorder
255+ .borderRadius (m/BorderRadius.circular t/radius-s)
256+ .borderSide m/BorderSide.none)
257+ .enabledBorder (m/OutlineInputBorder
258+ .borderRadius (m/BorderRadius.circular t/radius-s)
259+ .borderSide m/BorderSide.none)
260+ .focusedBorder (m/OutlineInputBorder
261+ .borderRadius (m/BorderRadius.circular t/radius-s)
262+ .borderSide (m/BorderSide .color t/accent
263+ .width 1.0)))))
264+
265+ :emoji
266+ (m/Text (str (:emoji p "")) .style (m/TextStyle .fontSize (dbl (:size p) 16.0)))
267+
268+ :avatar
269+ (let [s (dbl (:size p) 32.0)
270+ src (:src p)]
271+ (m/CircleAvatar
272+ .radius (/ s 2.0)
273+ .backgroundColor t/component
274+ .backgroundImage (when (and src (not= "" src)) (m/NetworkImage src))
275+ .child (when (or (nil? src) (= "" src))
276+ (txt ctx (let [l (str (:label p ""))]
277+ (if (pos? (count l)) (.toUpperCase (subs l 0 1)) "?"))
278+ t/text-body t/on-bg))))
279+
280+ :image
281+ (let [src (or (:src p) (:path p))
282+ w (:max-width p)
283+ h (:max-height p)
284+ img (cond
285+ (nil? src) (m/SizedBox .width 0.0 .height 0.0)
286+ (or (.startsWith (str src) "http://")
287+ (.startsWith (str src) "https://"))
288+ (m/Image.network (str src) .fit m/BoxFit.contain)
289+ :else (m/Image.file (io/File. (str src)) .fit m/BoxFit.contain))
290+ img (m/ClipRRect .borderRadius (m/BorderRadius.circular t/radius-s)
291+ .child img)
292+ img (if (or w h)
293+ (m/ConstrainedBox
294+ .constraints (m/BoxConstraints
295+ .maxWidth (dbl w double/infinity)
296+ .maxHeight (dbl h double/infinity))
297+ .child img)
298+ img)]
299+ (if-let [on (:on-click p)]
300+ (m/InkWell .onTap #(on) .child img)
301+ img))
302+
303+ :scroll
304+ (m/Expanded
305+ .child (m/SingleChildScrollView
306+ .child (col (dbl (:spacing p) 0.0) kids)))
307+
308+ ;; A tag this backend has not grown yet still shows its children which
309+ ;; is what libvidya did and what jolt-cosmic kept. The marker is here so
310+ ;; it is obvious which ones are missing: glimmer-cosmic's spike painted
311+ ;; every unknown tag as a silent column, and that is why most of frq
312+ ;; came out of it as stacked text.
313+ (m/Column
314+ .crossAxisAlignment m/CrossAxisAlignment.start
315+ .mainAxisSize m/MainAxisSize.min
316+ .children (into [(txt ctx (str "?" (name tag)) 10.0 m/Colors.orange)]
317+ kids))))))
262318
263319 (defn render
264320 "One hiccup node as a Flutter widget.
@@ -7,11 +7,17 @@
7 components do not know what is under the reconciler. So the screens do not7 components do not know what is under the reconciler. So the screens do not
8 get rewritten for the phone; this interprets them.8 get rewritten for the phone; this interprets them.
9 9
10- The tags are glimmer's, and where Flutter has no equivalent the shape is10+ The tags are glimmer's and so is the styling: every arm below is what
11- chosen to match what jvui and libcosmic drew rather than what Material would11+ `crates/jolt-cosmic` asks libcosmic for, in `frq.theme`'s tokens. `:title` is
12- do on its own: `:card` is a surface with a border and not an elevation,12+ title3 and `:title-2` is title4; `:card` is Container::Card padding 12,
13- `:dim-label` is the body colour at 60%, and `:page` is a max-width column13+ spacing 8, a surface a step from the background rather than an elevation,
14- centred in whatever it is given.14+ because COSMIC does not float things; `:button` is suggested, destructive or
15+ standard; `:dim-label` is the caption class, which is the body colour stepped
16+ back rather than a colour of its own.
17+
18+ Material is underneath and deliberately not visible. No elevation, no ripple
19+ shadows, no Material 3 pill heights a screen laid out against libcosmic's
20+ 4/8/12/16/24 spacing lands at the same proportions here.
15 21
16 What this does NOT do is glimmer's reconciliation. glimmer patches the tree22 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 element23 it painted last; Flutter rebuilds from the top and diffs its own element
@@ -21,19 +27,14 @@
21 feels it."27 feels it."
22 (:require ["dart:io" :as io]28 (:require ["dart:io" :as io]
23 ["package:flutter/material.dart" :as m]29 ["package:flutter/material.dart" :as m]
24- [cljd.flutter :as f]))30+ [cljd.flutter :as f]
31+ [frq.theme :as t]))
25 32
26-;; ------------------------------------------------------------------ theme
27-
28-(def ^:private gap 8.0)
29 33
30 (defn- dbl [x default]34 (defn- dbl [x default]
31 (cond (number? x) (double x)35 (cond (number? x) (double x)
32 :else default))36 :else default))
33 37
34-(defn- muted [ctx]
35- (-> (m/Theme.of ctx) .-colorScheme .-onSurface (.withValues .alpha 0.6)))
36-
37 ;; ------------------------------------------------------------- text entry38 ;; ------------------------------------------------------------- text entry
38 39
39 (defonce ^:private controllers40 (defonce ^:private controllers
@@ -67,8 +68,6 @@
67 (defn- body [node]68 (defn- body [node]
68 (let [p (second node)] (if (map? p) (drop 2 node) (drop 1 node))))69 (let [p (second node)] (if (map? p) (drop 2 node) (drop 1 node))))
69 70
70-;; --------------------------------------------------------------- children
71-
72 (declare render)71 (declare render)
73 72
74 (defn- children73 (defn- children
@@ -82,183 +81,240 @@
82 :else (conj! acc (render n))))81 :else (conj! acc (render n))))
83 (transient []) nodes)))82 (transient []) nodes)))
84 83
85-;; ----------------------------------------------------------------- widget84+;; ------------------------------------------------------------------ text
85+
86+(defn- txt
87+ [ctx s size color & {:keys [weight]}]
88+ (m/Text (str s)
89+ .style (m/TextStyle .fontSize size
90+ .color color
91+ .height 1.35
92+ .fontWeight (or weight m/FontWeight.w400))))
93+
94+;; ---------------------------------------------------------------- buttons
95+
96+(defn- cosmic-button
97+ "libcosmic's three button classes. Filled accent for suggested, filled red
98+ for destructive, and a component-coloured fill for standard COSMIC's
99+ standard button is a filled surface, not an outline."
100+ [ctx p on]
101+ (let [kind (cond (:destructive p) :destructive
102+ (or (:primary p) (= "primary" (:kind p))) :suggested
103+ :else :standard)
104+ bg (case kind
105+ :suggested t/accent
106+ :destructive t/destructive
107+ t/component)
108+ ;; COSMIC names the foreground for each role, and for this theme both
109+ ;; accent.on and destructive.on are black a cream accent with white
110+ ;; text on it is unreadable, which is exactly what guessing produced.
111+ fg (case kind
112+ :suggested t/on-accent
113+ :destructive t/on-destructive
114+ t/on-bg)]
115+ (m/Material
116+ .color bg
117+ .borderRadius (m/BorderRadius.circular t/radius-m)
118+ .child (m/InkWell
119+ .borderRadius (m/BorderRadius.circular t/radius-m)
120+ .onTap (when on #(on))
121+ .child (m/Padding
122+ .padding (m/EdgeInsets.symmetric .horizontal t/space-s
123+ .vertical t/space-xxs)
124+ .child (txt ctx (:label p "") t/text-body fg
125+ :weight m/FontWeight.w500))))))
86 126
87-(defn- text-style [ctx kind]127+;; ----------------------------------------------------------------- widget
88- (let [t (.-textTheme (m/Theme.of ctx))]
89- (case kind
90- :title (.-headlineSmall t)
91- :title-2 (.-titleMedium t)
92- (.-bodyMedium t))))
93 128
94 (defn- render-tag [tag node]129 (defn- render-tag [tag node]
95- (let [p (props node)130+ (f/widget
96- kids (children (body node))131+ :context ctx
97- one (fn [] (if (seq kids) (first kids) (m/SizedBox .width 0.0 .height 0.0)))]132+ (let [p (props node)
98- (case tag133+ kids (children (body node))
99- (:vbox :page)134+ col (fn [sp cs] (m/Column .crossAxisAlignment m/CrossAxisAlignment.start
100- (let [col (m/Column135+ .mainAxisSize m/MainAxisSize.min
101- .crossAxisAlignment m/CrossAxisAlignment.start136+ .spacing sp
102- .mainAxisSize m/MainAxisSize.min137+ .children cs))]
103- .spacing (dbl (:spacing p) 0.0)138+ (case tag
104- .children kids)]139+ :vbox
105- (if-let [w (:max-width p)]140+ (let [c (col (dbl (:spacing p) 0.0) kids)]
106- (m/Center .child (m/ConstrainedBox141+ (if-let [w (:width-request p)]
107- .constraints (m/BoxConstraints .maxWidth (dbl w 520.0))142+ (m/SizedBox .width (dbl w 0.0) .child c)
108- .child (m/Padding .padding (m/EdgeInsets.all gap) .child col)))143+ c))
109- col))144+
110-145+ ;; `page` is jolt-cosmic's scrollable container, centred and capped.
111- :hbox146+ :page
112- (m/Row147+ (m/Center
113- .crossAxisAlignment m/CrossAxisAlignment.center148+ .child (m/ConstrainedBox
114- .mainAxisSize m/MainAxisSize.min149+ .constraints (m/BoxConstraints .maxWidth (dbl (:max-width p) 520.0))
115- .spacing (dbl (:spacing p) 0.0)150+ .child (m/Padding
116- .children kids)151+ .padding (m/EdgeInsets.all t/space-s)
117-152+ .child (col t/space-xs kids))))
118- :label153+
119- (f/widget154+ :hbox
120- :context ctx155+ (m/Row
121- (m/Text (str (:label p "")) .style (text-style ctx :body)))156+ .crossAxisAlignment m/CrossAxisAlignment.center
122-157+ .mainAxisSize m/MainAxisSize.min
123- :dim-label158+ .spacing (dbl (:spacing p) 0.0)
124- (f/widget159+ .children kids)
125- :context ctx160+
126- (m/Text (str (:label p ""))161+ :label (txt ctx (:label p "") t/text-body t/on-bg)
127- .style (.copyWith (text-style ctx :body) .color (muted ctx))))162+ :dim-label (txt ctx (:label p "") t/text-caption t/dim)
128-163+ :title (txt ctx (:label p "") t/text-title-3 t/on-bg
129- :title164+ :weight m/FontWeight.w600)
130- (f/widget165+ :title-2 (txt ctx (:label p "") t/text-title-4 t/on-bg
131- :context ctx166+ :weight m/FontWeight.w600)
132- (m/Text (str (:label p "")) .style (text-style ctx :title)))167+
133-168+ :button (cosmic-button ctx p (:on-click p))
134- :title-2169+
135- (f/widget170+ ;; button::link accent text, no underline. COSMIC links are buttons.
136- :context ctx171+ :link
137- (m/Text (str (:label p "")) .style (text-style ctx :title-2)))172+ (m/InkWell
138-173+ .onTap (when-let [on (:on-click p)] #(on))
139- :button174+ .child (txt ctx (:label p "") t/text-body t/accent))
140- (let [on (:on-click p)175+
141- press (when on #(on))]176+ ;; Container::Card: padding 12, spacing 8, fills its width unless it is
142- (cond177+ ;; sitting in a row.
143- (:destructive p)178+ :card
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/Container179 (m/Container
179- .padding (m/EdgeInsets.all (* 1.5 gap))180+ .width double/infinity
181+ .padding (m/EdgeInsets.all t/space-xs)
180 .decoration (m/BoxDecoration182 .decoration (m/BoxDecoration
181- .borderRadius (m/BorderRadius.circular 12.0)183+ .color t/card
182- .border (m/Border.all184+ .borderRadius (m/BorderRadius.circular t/radius-s))
183- .color (-> (m/Theme.of ctx) .-colorScheme .-outlineVariant)))185+ .child (col (dbl (:spacing p) t/space-xxs) kids))
184- .child (m/Column186+
185- .crossAxisAlignment m/CrossAxisAlignment.start187+ :separator (m/Divider .height 1.0 .thickness 1.0 .color t/divider)
186- .mainAxisSize m/MainAxisSize.min188+
187- .spacing (dbl (:spacing p) gap)189+ ;; A 16px indeterminate circle, with the label as a caption beside it.
188- .children kids)))190+ :spinner
189-191+ (m/Row
190- :separator (m/Divider .height 1.0)192+ .mainAxisSize m/MainAxisSize.min
191-193+ .spacing t/space-xxs
192- :spinner194+ .children (into [(m/SizedBox
193- (m/SizedBox .width 16.0 .height 16.0195+ .width 16.0 .height 16.0
194- .child (m/CircularProgressIndicator .strokeWidth 2.0))196+ .child (m/CircularProgressIndicator
195-197+ .strokeWidth 2.0
196- :spacer198+ .color t/accent))]
197- (let [s (dbl (or (:size p) (:gap p) (:width-request p)) gap)]199+ (when-let [l (not-empty (str (:label p "")))]
198- (m/SizedBox .width s .height s))200+ [(txt ctx l t/text-caption t/dim)])))
199-201+
200- :checkbutton202+ ;; A dot that says whether the thing is live, and the words beside it.
201- (let [on (:on-toggled p)]203+ :status
202- (m/Row204+ (m/Row
203- .mainAxisSize m/MainAxisSize.min205+ .mainAxisSize m/MainAxisSize.min
204- .children [(m/Checkbox .value (boolean (:active p))206+ .spacing 6.0
205- .onChanged (when on (fn [v] (on (boolean v)))))207+ .children [(m/Container
206- (m/Text (str (:label p "")))]))208+ .width 8.0 .height 8.0
207-209+ .decoration (m/BoxDecoration
208- :emoji210+ .color (if (:live p) t/success t/dim)
209- (m/Text (str (:emoji p "")) .style (m/TextStyle .fontSize (dbl (:size p) 16.0)))211+ .borderRadius (m/BorderRadius.circular t/radius-xs)))
210-212+ (txt ctx (:label p "") t/text-caption t/dim)])
211- :avatar213+
212- (let [s (dbl (:size p) 32.0)214+ :spacer
213- src (:src p)]215+ (let [size (dbl (or (:size p) (:gap p) (:width-request p)) t/space-xxs)]
214- (m/CircleAvatar216+ (m/SizedBox .width size .height size))
215- .radius (/ s 2.0)217+
216- .backgroundImage (when (and src (not= "" src)) (m/NetworkImage src))218+ :checkbutton
217- .child (when (or (nil? src) (= "" src))219+ (let [on (:on-toggled p)]
218- (m/Text (let [l (str (:label p ""))]220+ (m/Row
219- (if (pos? (count l)) (.toUpperCase (subs l 0 1)) "?"))))))221+ .mainAxisSize m/MainAxisSize.min
220-222+ .spacing t/space-xxxs
221- :image223+ .children [(m/SizedBox
222- (let [src (or (:src p) (:path p))224+ .width 20.0 .height 20.0
223- w (:max-width p)225+ .child (m/Checkbox
224- h (:max-height p)226+ .value (boolean (:active p))
225- img (cond227+ .activeColor t/accent
226- (nil? src) (m/SizedBox .width 0.0 .height 0.0)228+ .onChanged (when on (fn [v] (on (boolean v))))))
227- (or (.startsWith (str src) "http://")229+ (txt ctx (:label p "") t/text-body t/on-bg)]))
228- (.startsWith (str src) "https://"))230+
229- (m/Image.network (str src) .fit m/BoxFit.contain)231+ ;; text_input: a filled rounded field, no outline. COSMIC entries sit in
230- :else (m/Image.file (io/File. (str src)) .fit m/BoxFit.contain))232+ ;; the component colour rather than behind a border.
231- img (if (or w h)233+ :entry
232- (m/ConstrainedBox234+ (let [on-change (:on-change p)
233- .constraints (m/BoxConstraints235+ on-activate (:on-activate p)
234- .maxWidth (dbl w double/infinity)236+ rows (:rows p)]
235- .maxHeight (dbl h double/infinity))237+ (m/TextField
236- .child img)238+ .controller (controller-for (:key p) (:text p))
237- img)]239+ .onChanged (when on-change #(on-change %))
238- (if-let [on (:on-click p)]240+ .onSubmitted (when on-activate (fn [_] (on-activate)))
239- (m/InkWell .onTap #(on) .child img)241+ .maxLines (if rows (int rows) 1)
240- img))242+ .style (m/TextStyle .fontSize t/text-body .color t/on-bg)
241-243+ .cursorColor t/accent
242- :scroll244+ .decoration (m/InputDecoration
243- (m/Expanded245+ .isDense true
244- .child (m/SingleChildScrollView246+ .filled true
245- .child (m/Column247+ .fillColor t/component
246- .crossAxisAlignment m/CrossAxisAlignment.start248+ .hintText (:placeholder p)
247- .mainAxisSize m/MainAxisSize.min249+ .hintStyle (m/TextStyle .fontSize t/text-body
248- .spacing (dbl (:spacing p) 0.0)250+ .color t/dim)
249- .children kids)))251+ .contentPadding (m/EdgeInsets.symmetric
250-252+ .horizontal t/space-xs
251- ;; Unknown tag. glimmer-cosmic's spike painted these as a column and253+ .vertical t/space-xxs)
252- ;; that is what made most of frq come out as stacked text so this says254+ .border (m/OutlineInputBorder
253- ;; so on screen instead of pretending, and the missing tag is one255+ .borderRadius (m/BorderRadius.circular t/radius-s)
254- ;; `case` arm away.256+ .borderSide m/BorderSide.none)
255- (m/Column257+ .enabledBorder (m/OutlineInputBorder
256- .crossAxisAlignment m/CrossAxisAlignment.start258+ .borderRadius (m/BorderRadius.circular t/radius-s)
257- .mainAxisSize m/MainAxisSize.min259+ .borderSide m/BorderSide.none)
258- .children (into [(m/Text (str "?" tag)260+ .focusedBorder (m/OutlineInputBorder
259- .style (m/TextStyle .fontSize 10.0261+ .borderRadius (m/BorderRadius.circular t/radius-s)
260- .color m/Colors.orange))]262+ .borderSide (m/BorderSide .color t/accent
261- kids)))))263+ .width 1.0)))))
264+
265+ :emoji
266+ (m/Text (str (:emoji p "")) .style (m/TextStyle .fontSize (dbl (:size p) 16.0)))
267+
268+ :avatar
269+ (let [s (dbl (:size p) 32.0)
270+ src (:src p)]
271+ (m/CircleAvatar
272+ .radius (/ s 2.0)
273+ .backgroundColor t/component
274+ .backgroundImage (when (and src (not= "" src)) (m/NetworkImage src))
275+ .child (when (or (nil? src) (= "" src))
276+ (txt ctx (let [l (str (:label p ""))]
277+ (if (pos? (count l)) (.toUpperCase (subs l 0 1)) "?"))
278+ t/text-body t/on-bg))))
279+
280+ :image
281+ (let [src (or (:src p) (:path p))
282+ w (:max-width p)
283+ h (:max-height p)
284+ img (cond
285+ (nil? src) (m/SizedBox .width 0.0 .height 0.0)
286+ (or (.startsWith (str src) "http://")
287+ (.startsWith (str src) "https://"))
288+ (m/Image.network (str src) .fit m/BoxFit.contain)
289+ :else (m/Image.file (io/File. (str src)) .fit m/BoxFit.contain))
290+ img (m/ClipRRect .borderRadius (m/BorderRadius.circular t/radius-s)
291+ .child img)
292+ img (if (or w h)
293+ (m/ConstrainedBox
294+ .constraints (m/BoxConstraints
295+ .maxWidth (dbl w double/infinity)
296+ .maxHeight (dbl h double/infinity))
297+ .child img)
298+ img)]
299+ (if-let [on (:on-click p)]
300+ (m/InkWell .onTap #(on) .child img)
301+ img))
302+
303+ :scroll
304+ (m/Expanded
305+ .child (m/SingleChildScrollView
306+ .child (col (dbl (:spacing p) 0.0) kids)))
307+
308+ ;; A tag this backend has not grown yet still shows its children which
309+ ;; is what libvidya did and what jolt-cosmic kept. The marker is here so
310+ ;; it is obvious which ones are missing: glimmer-cosmic's spike painted
311+ ;; every unknown tag as a silent column, and that is why most of frq
312+ ;; came out of it as stacked text.
313+ (m/Column
314+ .crossAxisAlignment m/CrossAxisAlignment.start
315+ .mainAxisSize m/MainAxisSize.min
316+ .children (into [(txt ctx (str "?" (name tag)) 10.0 m/Colors.orange)]
317+ kids))))))
262 318
263 (defn render319 (defn render
264 "One hiccup node as a Flutter widget.320 "One hiccup node as a Flutter widget.
modified flutter/src/frq/main.cljd +2 -3
@@ -20,6 +20,7 @@
2020 ["package:path_provider/path_provider.dart" :as pp]
2121 [cljd.flutter :as f]
2222 [frq.hiccup :as h]
23+ [frq.theme :as t]
2324 [frq.io.dart :as host]
2425 [frq.net.dart :as net]
2526 [frq.atproto.dart :as atproto]
@@ -127,9 +128,7 @@
127128 (let [dir (.-path (await (pp/getApplicationSupportDirectory)))]
128129 (host/install! dir)
129130 (f/run
130- (m/MaterialApp .title "frq"
131- .theme (m/ThemeData .useMaterial3 true
132- .colorSchemeSeed m/Colors.indigo))
131+ (m/MaterialApp .title "frq" .theme (t/app-theme))
133132 .home
134133 (m/Scaffold .appBar (m/AppBar .title (m/Text "frq")))
135134 .body
@@ -20,6 +20,7 @@
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.hiccup :as h]
23+ [frq.theme :as t]
23 [frq.io.dart :as host]24 [frq.io.dart :as host]
24 [frq.net.dart :as net]25 [frq.net.dart :as net]
25 [frq.atproto.dart :as atproto]26 [frq.atproto.dart :as atproto]
@@ -127,9 +128,7 @@
127 (let [dir (.-path (await (pp/getApplicationSupportDirectory)))]128 (let [dir (.-path (await (pp/getApplicationSupportDirectory)))]
128 (host/install! dir)129 (host/install! dir)
129 (f/run130 (f/run
130- (m/MaterialApp .title "frq"131+ (m/MaterialApp .title "frq" .theme (t/app-theme))
131- .theme (m/ThemeData .useMaterial3 true
132- .colorSchemeSeed m/Colors.indigo))
133 .home132 .home
134 (m/Scaffold .appBar (m/AppBar .title (m/Text "frq")))133 (m/Scaffold .appBar (m/AppBar .title (m/Text "frq")))
135 .body134 .body
added flutter/src/frq/theme.cljd +72 -0
new file mode 100644
@@ -0,0 +1,72 @@
1+(ns frq.theme
2+ "COSMIC's design tokens, as Flutter values.
3+
4+ The colours, radii and spacing are the user's own, read out of cosmic-config
5+ by tools/cosmic2cljd.py into `frq.theme.cosmic` so the phone is painted in
6+ whatever accent and surfaces COSMIC Settings is set to, rather than in a
7+ guess at what COSMIC looks like. `just theme` moves them.
8+
9+ What is not in cosmic-config is the typography scale: libcosmic carries
10+ title1..title4, heading, body and caption as code rather than as
11+ configuration. Those numbers are here, and the mapping from glimmer's tags
12+ to them is `crates/jolt-cosmic`'s — `:title` is title3, `:title-2` is title4,
13+ `:label` is body, `:dim-label` is caption."
14+ (:require ["package:flutter/material.dart" :as m]
15+ [frq.theme.cosmic :as c]))
16+
17+;; ------------------------------------------------------------------ scale
18+
19+(def space-xxxs c/space-xxxs)
20+(def space-xxs c/space-xxs)
21+(def space-xs c/space-xs)
22+(def space-s c/space-s)
23+(def space-m c/space-m)
24+
25+(def radius-xs c/radius-xs)
26+(def radius-s c/radius-s)
27+(def radius-m c/radius-m)
28+
29+;; libcosmic's typography, which is code rather than config.
30+(def text-title-3 24.0) ; :title
31+(def text-title-4 20.0) ; :title-2
32+(def text-body 14.0) ; :label
33+(def text-caption 12.0) ; :dim-label, :status, :spinner
34+
35+;; ---------------------------------------------------------------- palette
36+
37+(def accent c/accent)
38+(def on-accent c/on-accent)
39+(def bg c/bg)
40+(def on-bg c/on-bg)
41+(def component c/component)
42+(def component-hover c/component-hover)
43+(def card c/card)
44+(def card-component c/card-component)
45+(def on-card c/on-card)
46+(def destructive c/destructive)
47+(def on-destructive c/on-destructive)
48+(def success c/success)
49+(def divider c/divider)
50+
51+(def dim
52+ "`:dim-label` is libcosmic's caption class, which is the body colour stepped
53+ back rather than a colour of its own."
54+ (.withValues on-bg .alpha 0.7))
55+
56+(def brightness (if c/dark? m/Brightness.dark m/Brightness.light))
57+
58+(defn app-theme []
59+ (m/ThemeData
60+ .useMaterial3 true
61+ .brightness brightness
62+ .scaffoldBackgroundColor bg
63+ .canvasColor bg
64+ .colorScheme (m/ColorScheme.fromSeed
65+ .seedColor accent
66+ .brightness brightness
67+ .surface bg
68+ .primary accent
69+ .onPrimary on-accent)
70+ ;; COSMIC's own UI font. Absent on Android, where the fallback is Roboto —
71+ ;; a difference the tokens cannot fix and the only one that shows.
72+ .fontFamily "Fira Sans"))
new file mode 100644
@@ -0,0 +1,72 @@
1+(ns frq.theme
2+ "COSMIC's design tokens, as Flutter values.
3+
4+ The colours, radii and spacing are the user's own, read out of cosmic-config
5+ by tools/cosmic2cljd.py into `frq.theme.cosmic` so the phone is painted in
6+ whatever accent and surfaces COSMIC Settings is set to, rather than in a
7+ guess at what COSMIC looks like. `just theme` moves them.
8+
9+ What is not in cosmic-config is the typography scale: libcosmic carries
10+ title1..title4, heading, body and caption as code rather than as
11+ configuration. Those numbers are here, and the mapping from glimmer's tags
12+ to them is `crates/jolt-cosmic`'s — `:title` is title3, `:title-2` is title4,
13+ `:label` is body, `:dim-label` is caption."
14+ (:require ["package:flutter/material.dart" :as m]
15+ [frq.theme.cosmic :as c]))
16+
17+;; ------------------------------------------------------------------ scale
18+
19+(def space-xxxs c/space-xxxs)
20+(def space-xxs c/space-xxs)
21+(def space-xs c/space-xs)
22+(def space-s c/space-s)
23+(def space-m c/space-m)
24+
25+(def radius-xs c/radius-xs)
26+(def radius-s c/radius-s)
27+(def radius-m c/radius-m)
28+
29+;; libcosmic's typography, which is code rather than config.
30+(def text-title-3 24.0) ; :title
31+(def text-title-4 20.0) ; :title-2
32+(def text-body 14.0) ; :label
33+(def text-caption 12.0) ; :dim-label, :status, :spinner
34+
35+;; ---------------------------------------------------------------- palette
36+
37+(def accent c/accent)
38+(def on-accent c/on-accent)
39+(def bg c/bg)
40+(def on-bg c/on-bg)
41+(def component c/component)
42+(def component-hover c/component-hover)
43+(def card c/card)
44+(def card-component c/card-component)
45+(def on-card c/on-card)
46+(def destructive c/destructive)
47+(def on-destructive c/on-destructive)
48+(def success c/success)
49+(def divider c/divider)
50+
51+(def dim
52+ "`:dim-label` is libcosmic's caption class, which is the body colour stepped
53+ back rather than a colour of its own."
54+ (.withValues on-bg .alpha 0.7))
55+
56+(def brightness (if c/dark? m/Brightness.dark m/Brightness.light))
57+
58+(defn app-theme []
59+ (m/ThemeData
60+ .useMaterial3 true
61+ .brightness brightness
62+ .scaffoldBackgroundColor bg
63+ .canvasColor bg
64+ .colorScheme (m/ColorScheme.fromSeed
65+ .seedColor accent
66+ .brightness brightness
67+ .surface bg
68+ .primary accent
69+ .onPrimary on-accent)
70+ ;; COSMIC's own UI font. Absent on Android, where the fallback is Roboto —
71+ ;; a difference the tokens cannot fix and the only one that shows.
72+ .fontFamily "Fira Sans"))
added flutter/src/frq/theme/cosmic.cljd +52 -0
new file mode 100644
@@ -0,0 +1,52 @@
1+(ns frq.theme.cosmic
2+ "The COSMIC theme, as it was on the machine that built this.
3+
4+ GENERATED by tools/cosmic2cljd.py `just theme`. Do not edit.
5+
6+ libcosmic asks cosmic-config for these at run time, so `just run`
7+ follows COSMIC Settings as it changes. A phone has no cosmic-config,
8+ so the APK carries them instead. That is the one real difference
9+ between the two, and it is why this file is in git."
10+ (:require ["package:flutter/material.dart" :as m]))
11+
12+;; Dark theme, from ~/.config/cosmic.
13+(def dark? true)
14+
15+;; accent.base = #F4E3CFFF
16+(def accent (m/Color. 0xFFF4E3CF))
17+;; accent.on = #000000FF
18+(def on-accent (m/Color. 0xFF000000))
19+;; background.base = #202833FF
20+(def bg (m/Color. 0xFF202833))
21+;; background.component.on = #CCD1D7FF
22+(def on-bg (m/Color. 0xFFCCD1D7))
23+;; background.component.base = #343C48FF
24+(def component (m/Color. 0xFF343C48))
25+;; background.component.hover = #48505AFF
26+(def component-hover (m/Color. 0xFF48505A))
27+;; background.component.divider = #CCD1D733
28+(def divider (m/Color. 0x33CCD1D7))
29+;; primary.base = #2C3440FF
30+(def card (m/Color. 0xFF2C3440))
31+;; primary.component.base = #3B4450FF
32+(def card-component (m/Color. 0xFF3B4450))
33+;; primary.component.on = #FFFFFFFF
34+(def on-card (m/Color. 0xFFFFFFFF))
35+;; destructive.base = #FDA1A0FF
36+(def destructive (m/Color. 0xFFFDA1A0))
37+;; destructive.on = #000000FF
38+(def on-destructive (m/Color. 0xFF000000))
39+;; success.base = #92CF9CFF
40+(def success (m/Color. 0xFF92CF9C))
41+
42+;; corner_radii
43+(def radius-xs 2.0)
44+(def radius-s 8.0)
45+(def radius-m 8.0)
46+
47+;; spacing
48+(def space-xxxs 4.0)
49+(def space-xxs 8.0)
50+(def space-xs 12.0)
51+(def space-s 16.0)
52+(def space-m 24.0)
new file mode 100644
@@ -0,0 +1,52 @@
1+(ns frq.theme.cosmic
2+ "The COSMIC theme, as it was on the machine that built this.
3+
4+ GENERATED by tools/cosmic2cljd.py `just theme`. Do not edit.
5+
6+ libcosmic asks cosmic-config for these at run time, so `just run`
7+ follows COSMIC Settings as it changes. A phone has no cosmic-config,
8+ so the APK carries them instead. That is the one real difference
9+ between the two, and it is why this file is in git."
10+ (:require ["package:flutter/material.dart" :as m]))
11+
12+;; Dark theme, from ~/.config/cosmic.
13+(def dark? true)
14+
15+;; accent.base = #F4E3CFFF
16+(def accent (m/Color. 0xFFF4E3CF))
17+;; accent.on = #000000FF
18+(def on-accent (m/Color. 0xFF000000))
19+;; background.base = #202833FF
20+(def bg (m/Color. 0xFF202833))
21+;; background.component.on = #CCD1D7FF
22+(def on-bg (m/Color. 0xFFCCD1D7))
23+;; background.component.base = #343C48FF
24+(def component (m/Color. 0xFF343C48))
25+;; background.component.hover = #48505AFF
26+(def component-hover (m/Color. 0xFF48505A))
27+;; background.component.divider = #CCD1D733
28+(def divider (m/Color. 0x33CCD1D7))
29+;; primary.base = #2C3440FF
30+(def card (m/Color. 0xFF2C3440))
31+;; primary.component.base = #3B4450FF
32+(def card-component (m/Color. 0xFF3B4450))
33+;; primary.component.on = #FFFFFFFF
34+(def on-card (m/Color. 0xFFFFFFFF))
35+;; destructive.base = #FDA1A0FF
36+(def destructive (m/Color. 0xFFFDA1A0))
37+;; destructive.on = #000000FF
38+(def on-destructive (m/Color. 0xFF000000))
39+;; success.base = #92CF9CFF
40+(def success (m/Color. 0xFF92CF9C))
41+
42+;; corner_radii
43+(def radius-xs 2.0)
44+(def radius-s 8.0)
45+(def radius-m 8.0)
46+
47+;; spacing
48+(def space-xxxs 4.0)
49+(def space-xxs 8.0)
50+(def space-xs 12.0)
51+(def space-s 16.0)
52+(def space-m 24.0)
modified justfile +16 -0
@@ -32,6 +32,22 @@ jobs := env("FRQ_MAX_JOBS", "0")
3232 default:
3333 @just --list
3434
35+# Re-read the COSMIC theme into the APK.
36+#
37+# libcosmic asks cosmic-config for the accent and the surfaces at run time, so
38+# `just run` already follows COSMIC Settings as it changes. A phone has no
39+# cosmic-config, so the APK carries them instead — read here, on the machine
40+# that has them, and compiled in. That is the one real difference between the
41+# two, and it is why the generated file is in git rather than gitignored: a
42+# checkout on a machine with no COSMIC still builds.
43+#
44+# Run it after changing the theme in COSMIC Settings, then `just apk`.
45+theme:
46+ #!/usr/bin/env bash
47+ set -euo pipefail
48+ cd "{{justfile_directory()}}"
49+ python3 tools/cosmic2cljd.py flutter/src/frq/theme/cosmic.cljd
50+
3551 # The APK: ClojureDart compiled to Dart, then Flutter's Gradle build.
3652 #
3753 # Impure on purpose, and worth saying why rather than leaving it to be
@@ -32,6 +32,22 @@ jobs := env("FRQ_MAX_JOBS", "0")
32 default:32 default:
33 @just --list33 @just --list
34 34
35+# Re-read the COSMIC theme into the APK.
36+#
37+# libcosmic asks cosmic-config for the accent and the surfaces at run time, so
38+# `just run` already follows COSMIC Settings as it changes. A phone has no
39+# cosmic-config, so the APK carries them instead — read here, on the machine
40+# that has them, and compiled in. That is the one real difference between the
41+# two, and it is why the generated file is in git rather than gitignored: a
42+# checkout on a machine with no COSMIC still builds.
43+#
44+# Run it after changing the theme in COSMIC Settings, then `just apk`.
45+theme:
46+ #!/usr/bin/env bash
47+ set -euo pipefail
48+ cd "{{justfile_directory()}}"
49+ python3 tools/cosmic2cljd.py flutter/src/frq/theme/cosmic.cljd
50+
35 # The APK: ClojureDart compiled to Dart, then Flutter's Gradle build.51 # The APK: ClojureDart compiled to Dart, then Flutter's Gradle build.
36 #52 #
37 # Impure on purpose, and worth saying why rather than leaving it to be53 # Impure on purpose, and worth saying why rather than leaving it to be
added tools/cosmic2cljd.py +132 -0
new file mode 100755
@@ -0,0 +1,132 @@
1+#!/usr/bin/env python3
2+"""Read the COSMIC theme out of cosmic-config and write it as ClojureDart.
3+
4+libcosmic asks cosmic-config for the user's theme at run time, so `just run`
5+already paints frq in whatever accent and surfaces are set in COSMIC Settings.
6+A phone has no cosmic-config, so the APK cannot ask — the values are read here
7+instead, on the machine that has them, and compiled in.
8+
9+That is a real difference and worth naming: the desktop follows the theme as
10+it changes, and the APK carries the theme as it was when the APK was built.
11+`just theme` moves it.
12+
13+The config is RON. Not parsed as RON: every value this needs is either a
14+`key: "#RRGGBBAA"` line or a `key: N` line inside a named block, and a general
15+RON parser to read colours out of a flat file is a dependency nobody needs.
16+"""
17+
18+import pathlib
19+import re
20+import sys
21+
22+CONFIG = pathlib.Path.home() / ".config" / "cosmic"
23+
24+# What frq asks of a theme, as (token, file, field). `component` fields are one
25+# block deep — see the `component: (` in background/primary/secondary.
26+COLOURS = [
27+ ("accent", "accent", "base"),
28+ ("on-accent", "accent", "on"),
29+ ("bg", "background", "base"),
30+ ("on-bg", "background", "component.on"),
31+ ("component", "background", "component.base"),
32+ ("component-hover", "background", "component.hover"),
33+ ("divider", "background", "component.divider"),
34+ ("card", "primary", "base"),
35+ ("card-component", "primary", "component.base"),
36+ ("on-card", "primary", "component.on"),
37+ ("destructive", "destructive", "base"),
38+ ("on-destructive", "destructive", "on"),
39+ ("success", "success", "base"),
40+]
41+
42+
43+def read(mode: str, name: str) -> str:
44+ for version in ("v2", "v1"):
45+ p = CONFIG / f"com.system76.CosmicTheme.{mode}" / version / name
46+ if p.exists():
47+ return p.read_text()
48+ raise SystemExit(f"cosmic2cljd: no {name} under {CONFIG} — is COSMIC installed?")
49+
50+
51+def field(text: str, path: str) -> str:
52+ """`base`, or `component.base` for the one inside the component block."""
53+ if "." in path:
54+ outer, inner = path.split(".", 1)
55+ m = re.search(rf"\b{outer}:\s*\(", text)
56+ if not m:
57+ raise SystemExit(f"cosmic2cljd: no {outer} block")
58+ text = text[m.end():]
59+ path = inner
60+ m = re.search(rf'^\s*{path}:\s*"(#[0-9A-Fa-f]{{6,8}})"', text, re.M)
61+ if not m:
62+ raise SystemExit(f"cosmic2cljd: no {path}")
63+ return m.group(1)
64+
65+
66+def argb(hex_rgba: str) -> str:
67+ """#RRGGBBAA (COSMIC) to 0xAARRGGBB (Flutter's Color)."""
68+ h = hex_rgba.lstrip("#")
69+ if len(h) == 6:
70+ h += "FF"
71+ r, g, b, a = h[0:2], h[2:4], h[4:6], h[6:8]
72+ return f"0x{a}{r}{g}{b}".upper().replace("0X", "0x")
73+
74+
75+def number(text: str, key: str, default: float) -> float:
76+ m = re.search(rf"^\s*{key}:\s*\(?\s*([0-9.]+)", text, re.M)
77+ return float(m.group(1)) if m else default
78+
79+
80+def main() -> None:
81+ out = pathlib.Path(sys.argv[1] if len(sys.argv) > 1
82+ else "flutter/src/frq/theme/cosmic.cljd")
83+ dark = (CONFIG / "com.system76.CosmicTheme.Mode" / "v1" / "is_dark")
84+ is_dark = dark.exists() and dark.read_text().strip() == "true"
85+ mode = "Dark" if is_dark else "Light"
86+
87+ radii = read(mode, "corner_radii")
88+ spacing = read(mode, "spacing")
89+ files = {n: read(mode, n) for n in {f for _, f, _ in COLOURS}}
90+
91+ lines = [
92+ "(ns frq.theme.cosmic",
93+ ' "The COSMIC theme, as it was on the machine that built this.',
94+ "",
95+ " GENERATED by tools/cosmic2cljd.py — `just theme`. Do not edit.",
96+ "",
97+ " libcosmic asks cosmic-config for these at run time, so `just run`",
98+ " follows COSMIC Settings as it changes. A phone has no cosmic-config,",
99+ " so the APK carries them instead. That is the one real difference",
100+ f' between the two, and it is why this file is in git."',
101+ ' (:require ["package:flutter/material.dart" :as m]))',
102+ "",
103+ f";; {mode} theme, from ~/.config/cosmic.",
104+ f"(def dark? {str(is_dark).lower()})",
105+ "",
106+ ]
107+ for token, fname, path in COLOURS:
108+ val = field(files[fname], path)
109+ lines.append(f";; {fname}.{path} = {val}")
110+ lines.append(f"(def {token} (m/Color. {argb(val)}))")
111+ lines.append("")
112+ lines.append(";; corner_radii")
113+ for token, key, default in [("radius-xs", "radius_xs", 2.0),
114+ ("radius-s", "radius_s", 8.0),
115+ ("radius-m", "radius_m", 8.0)]:
116+ lines.append(f"(def {token} {number(radii, key, default)})")
117+ lines.append("")
118+ lines.append(";; spacing")
119+ for token, key, default in [("space-xxxs", "space_xxxs", 4.0),
120+ ("space-xxs", "space_xxs", 8.0),
121+ ("space-xs", "space_xs", 12.0),
122+ ("space-s", "space_s", 16.0),
123+ ("space-m", "space_m", 24.0)]:
124+ lines.append(f"(def {token} {number(spacing, key, default)})")
125+
126+ out.parent.mkdir(parents=True, exist_ok=True)
127+ out.write_text("\n".join(lines) + "\n")
128+ print(f"wrote {out} ({mode}, accent {field(files['accent'], 'base')})")
129+
130+
131+if __name__ == "__main__":
132+ main()
new file mode 100755
@@ -0,0 +1,132 @@
1+#!/usr/bin/env python3
2+"""Read the COSMIC theme out of cosmic-config and write it as ClojureDart.
3+
4+libcosmic asks cosmic-config for the user's theme at run time, so `just run`
5+already paints frq in whatever accent and surfaces are set in COSMIC Settings.
6+A phone has no cosmic-config, so the APK cannot ask — the values are read here
7+instead, on the machine that has them, and compiled in.
8+
9+That is a real difference and worth naming: the desktop follows the theme as
10+it changes, and the APK carries the theme as it was when the APK was built.
11+`just theme` moves it.
12+
13+The config is RON. Not parsed as RON: every value this needs is either a
14+`key: "#RRGGBBAA"` line or a `key: N` line inside a named block, and a general
15+RON parser to read colours out of a flat file is a dependency nobody needs.
16+"""
17+
18+import pathlib
19+import re
20+import sys
21+
22+CONFIG = pathlib.Path.home() / ".config" / "cosmic"
23+
24+# What frq asks of a theme, as (token, file, field). `component` fields are one
25+# block deep — see the `component: (` in background/primary/secondary.
26+COLOURS = [
27+ ("accent", "accent", "base"),
28+ ("on-accent", "accent", "on"),
29+ ("bg", "background", "base"),
30+ ("on-bg", "background", "component.on"),
31+ ("component", "background", "component.base"),
32+ ("component-hover", "background", "component.hover"),
33+ ("divider", "background", "component.divider"),
34+ ("card", "primary", "base"),
35+ ("card-component", "primary", "component.base"),
36+ ("on-card", "primary", "component.on"),
37+ ("destructive", "destructive", "base"),
38+ ("on-destructive", "destructive", "on"),
39+ ("success", "success", "base"),
40+]
41+
42+
43+def read(mode: str, name: str) -> str:
44+ for version in ("v2", "v1"):
45+ p = CONFIG / f"com.system76.CosmicTheme.{mode}" / version / name
46+ if p.exists():
47+ return p.read_text()
48+ raise SystemExit(f"cosmic2cljd: no {name} under {CONFIG} — is COSMIC installed?")
49+
50+
51+def field(text: str, path: str) -> str:
52+ """`base`, or `component.base` for the one inside the component block."""
53+ if "." in path:
54+ outer, inner = path.split(".", 1)
55+ m = re.search(rf"\b{outer}:\s*\(", text)
56+ if not m:
57+ raise SystemExit(f"cosmic2cljd: no {outer} block")
58+ text = text[m.end():]
59+ path = inner
60+ m = re.search(rf'^\s*{path}:\s*"(#[0-9A-Fa-f]{{6,8}})"', text, re.M)
61+ if not m:
62+ raise SystemExit(f"cosmic2cljd: no {path}")
63+ return m.group(1)
64+
65+
66+def argb(hex_rgba: str) -> str:
67+ """#RRGGBBAA (COSMIC) to 0xAARRGGBB (Flutter's Color)."""
68+ h = hex_rgba.lstrip("#")
69+ if len(h) == 6:
70+ h += "FF"
71+ r, g, b, a = h[0:2], h[2:4], h[4:6], h[6:8]
72+ return f"0x{a}{r}{g}{b}".upper().replace("0X", "0x")
73+
74+
75+def number(text: str, key: str, default: float) -> float:
76+ m = re.search(rf"^\s*{key}:\s*\(?\s*([0-9.]+)", text, re.M)
77+ return float(m.group(1)) if m else default
78+
79+
80+def main() -> None:
81+ out = pathlib.Path(sys.argv[1] if len(sys.argv) > 1
82+ else "flutter/src/frq/theme/cosmic.cljd")
83+ dark = (CONFIG / "com.system76.CosmicTheme.Mode" / "v1" / "is_dark")
84+ is_dark = dark.exists() and dark.read_text().strip() == "true"
85+ mode = "Dark" if is_dark else "Light"
86+
87+ radii = read(mode, "corner_radii")
88+ spacing = read(mode, "spacing")
89+ files = {n: read(mode, n) for n in {f for _, f, _ in COLOURS}}
90+
91+ lines = [
92+ "(ns frq.theme.cosmic",
93+ ' "The COSMIC theme, as it was on the machine that built this.',
94+ "",
95+ " GENERATED by tools/cosmic2cljd.py — `just theme`. Do not edit.",
96+ "",
97+ " libcosmic asks cosmic-config for these at run time, so `just run`",
98+ " follows COSMIC Settings as it changes. A phone has no cosmic-config,",
99+ " so the APK carries them instead. That is the one real difference",
100+ f' between the two, and it is why this file is in git."',
101+ ' (:require ["package:flutter/material.dart" :as m]))',
102+ "",
103+ f";; {mode} theme, from ~/.config/cosmic.",
104+ f"(def dark? {str(is_dark).lower()})",
105+ "",
106+ ]
107+ for token, fname, path in COLOURS:
108+ val = field(files[fname], path)
109+ lines.append(f";; {fname}.{path} = {val}")
110+ lines.append(f"(def {token} (m/Color. {argb(val)}))")
111+ lines.append("")
112+ lines.append(";; corner_radii")
113+ for token, key, default in [("radius-xs", "radius_xs", 2.0),
114+ ("radius-s", "radius_s", 8.0),
115+ ("radius-m", "radius_m", 8.0)]:
116+ lines.append(f"(def {token} {number(radii, key, default)})")
117+ lines.append("")
118+ lines.append(";; spacing")
119+ for token, key, default in [("space-xxxs", "space_xxxs", 4.0),
120+ ("space-xxs", "space_xxs", 8.0),
121+ ("space-xs", "space_xs", 12.0),
122+ ("space-s", "space_s", 16.0),
123+ ("space-m", "space_m", 24.0)]:
124+ lines.append(f"(def {token} {number(spacing, key, default)})")
125+
126+ out.parent.mkdir(parents=True, exist_ok=True)
127+ out.write_text("\n".join(lines) + "\n")
128+ print(f"wrote {out} ({mode}, accent {field(files['accent'], 'base')})")
129+
130+
131+if __name__ == "__main__":
132+ main()