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>
ba4e71b parent: f735240 modified
flutter/src/frq/hiccup.cljd +242 -186 | @@ -7,11 +7,17 @@ | ||
| 7 | 7 | components do not know what is under the reconciler. So the screens do not |
| 8 | 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 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. | |
| 15 | 21 | |
| 16 | 22 | What this does NOT do is glimmer's reconciliation. glimmer patches the tree |
| 17 | 23 | it painted last; Flutter rebuilds from the top and diffs its own element |
| @@ -21,19 +27,14 @@ | ||
| 21 | 27 | feels it." |
| 22 | 28 | (:require ["dart:io" :as io] |
| 23 | 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 | 34 | (defn- dbl [x default] |
| 31 | 35 | (cond (number? x) (double x) |
| 32 | 36 | :else default)) |
| 33 | 37 | |
| 34 | -(defn- muted [ctx] | |
| 35 | - (-> (m/Theme.of ctx) .-colorScheme .-onSurface (.withValues .alpha 0.6))) | |
| 36 | - | |
| 37 | 38 | ;; ------------------------------------------------------------- text entry |
| 38 | 39 | |
| 39 | 40 | (defonce ^:private controllers |
| @@ -67,8 +68,6 @@ | ||
| 67 | 68 | (defn- body [node] |
| 68 | 69 | (let [p (second node)] (if (map? p) (drop 2 node) (drop 1 node)))) |
| 69 | 70 | |
| 70 | -;; --------------------------------------------------------------- children | |
| 71 | - | |
| 72 | 71 | (declare render) |
| 73 | 72 | |
| 74 | 73 | (defn- children |
| @@ -82,183 +81,240 @@ | ||
| 82 | 81 | :else (conj! acc (render n)))) |
| 83 | 82 | (transient []) nodes))) |
| 84 | 83 | |
| 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)))))) | |
| 86 | 126 | |
| 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 | |
| 93 | 128 | |
| 94 | 129 | (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 | |
| 178 | 179 | (m/Container |
| 179 | - .padding (m/EdgeInsets.all (* 1.5 gap)) | |
| 180 | + .width double/infinity | |
| 181 | + .padding (m/EdgeInsets.all t/space-xs) | |
| 180 | 182 | .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)))))) | |
| 262 | 318 | |
| 263 | 319 | (defn render |
| 264 | 320 | "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 not | 7 | 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 is | 10 | + 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 would | 11 | + `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 column | 13 | + 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 tree | 22 | 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 | 23 | 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 entry | 38 | ;; ------------------------------------------------------------- text entry |
| 38 | 39 | ||
| 39 | (defonce ^:private controllers | 40 | (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- children | 73 | (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 | -;; ----------------------------------------------------------------- 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)))))) | ||
| 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 tag | 133 | + kids (children (body node)) |
| 99 | - (:vbox :page) | 134 | + col (fn [sp cs] (m/Column .crossAxisAlignment m/CrossAxisAlignment.start |
| 100 | - (let [col (m/Column | 135 | + .mainAxisSize m/MainAxisSize.min |
| 101 | - .crossAxisAlignment m/CrossAxisAlignment.start | 136 | + .spacing sp |
| 102 | - .mainAxisSize m/MainAxisSize.min | 137 | + .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/ConstrainedBox | 141 | + (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 | - :hbox | 146 | + :page |
| 112 | - (m/Row | 147 | + (m/Center |
| 113 | - .crossAxisAlignment m/CrossAxisAlignment.center | 148 | + .child (m/ConstrainedBox |
| 114 | - .mainAxisSize m/MainAxisSize.min | 149 | + .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 | - :label | 153 | + |
| 119 | - (f/widget | 154 | + :hbox |
| 120 | - :context ctx | 155 | + (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-label | 158 | + .spacing (dbl (:spacing p) 0.0) |
| 124 | - (f/widget | 159 | + .children kids) |
| 125 | - :context ctx | 160 | + |
| 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 | - :title | 164 | + :weight m/FontWeight.w600) |
| 130 | - (f/widget | 165 | + :title-2 (txt ctx (:label p "") t/text-title-4 t/on-bg |
| 131 | - :context ctx | 166 | + :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-2 | 169 | + |
| 135 | - (f/widget | 170 | + ;; button::link — accent text, no underline. COSMIC links are buttons. |
| 136 | - :context ctx | 171 | + :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 | - :button | 174 | + .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 | - (cond | 177 | + ;; 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/Container | 179 | (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/BoxDecoration | 182 | .decoration (m/BoxDecoration |
| 181 | - .borderRadius (m/BorderRadius.circular 12.0) | 183 | + .color t/card |
| 182 | - .border (m/Border.all | 184 | + .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/Column | 186 | + |
| 185 | - .crossAxisAlignment m/CrossAxisAlignment.start | 187 | + :separator (m/Divider .height 1.0 .thickness 1.0 .color t/divider) |
| 186 | - .mainAxisSize m/MainAxisSize.min | 188 | + |
| 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 | - :spinner | 194 | + .children (into [(m/SizedBox |
| 193 | - (m/SizedBox .width 16.0 .height 16.0 | 195 | + .width 16.0 .height 16.0 |
| 194 | - .child (m/CircularProgressIndicator .strokeWidth 2.0)) | 196 | + .child (m/CircularProgressIndicator |
| 195 | - | 197 | + .strokeWidth 2.0 |
| 196 | - :spacer | 198 | + .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 | - :checkbutton | 202 | + ;; A dot that says whether the thing is live, and the words beside it. |
| 201 | - (let [on (:on-toggled p)] | 203 | + :status |
| 202 | - (m/Row | 204 | + (m/Row |
| 203 | - .mainAxisSize m/MainAxisSize.min | 205 | + .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 | - :emoji | 210 | + .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 | - :avatar | 213 | + |
| 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/CircleAvatar | 216 | + (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 | - :image | 223 | + .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 (cond | 227 | + .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/ConstrainedBox | 234 | + (let [on-change (:on-change p) |
| 233 | - .constraints (m/BoxConstraints | 235 | + 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 | - :scroll | 244 | + .decoration (m/InputDecoration |
| 243 | - (m/Expanded | 245 | + .isDense true |
| 244 | - .child (m/SingleChildScrollView | 246 | + .filled true |
| 245 | - .child (m/Column | 247 | + .fillColor t/component |
| 246 | - .crossAxisAlignment m/CrossAxisAlignment.start | 248 | + .hintText (:placeholder p) |
| 247 | - .mainAxisSize m/MainAxisSize.min | 249 | + .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 and | 253 | + .vertical t/space-xxs) |
| 252 | - ;; that is what made most of frq come out as stacked text — so this says | 254 | + .border (m/OutlineInputBorder |
| 253 | - ;; so on screen instead of pretending, and the missing tag is one | 255 | + .borderRadius (m/BorderRadius.circular t/radius-s) |
| 254 | - ;; `case` arm away. | 256 | + .borderSide m/BorderSide.none) |
| 255 | - (m/Column | 257 | + .enabledBorder (m/OutlineInputBorder |
| 256 | - .crossAxisAlignment m/CrossAxisAlignment.start | 258 | + .borderRadius (m/BorderRadius.circular t/radius-s) |
| 257 | - .mainAxisSize m/MainAxisSize.min | 259 | + .borderSide m/BorderSide.none) |
| 258 | - .children (into [(m/Text (str "?" tag) | 260 | + .focusedBorder (m/OutlineInputBorder |
| 259 | - .style (m/TextStyle .fontSize 10.0 | 261 | + .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 render | 319 | (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 @@ | ||
| 20 | 20 | ["package:path_provider/path_provider.dart" :as pp] |
| 21 | 21 | [cljd.flutter :as f] |
| 22 | 22 | [frq.hiccup :as h] |
| 23 | + [frq.theme :as t] | |
| 23 | 24 | [frq.io.dart :as host] |
| 24 | 25 | [frq.net.dart :as net] |
| 25 | 26 | [frq.atproto.dart :as atproto] |
| @@ -127,9 +128,7 @@ | ||
| 127 | 128 | (let [dir (.-path (await (pp/getApplicationSupportDirectory)))] |
| 128 | 129 | (host/install! dir) |
| 129 | 130 | (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)) | |
| 133 | 132 | .home |
| 134 | 133 | (m/Scaffold .appBar (m/AppBar .title (m/Text "frq"))) |
| 135 | 134 | .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/run | 130 | (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 | .home | 132 | .home |
| 134 | (m/Scaffold .appBar (m/AppBar .title (m/Text "frq"))) | 133 | (m/Scaffold .appBar (m/AppBar .title (m/Text "frq"))) |
| 135 | .body | 134 | .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") | ||
| 32 | 32 | default: |
| 33 | 33 | @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 | 51 | # The APK: ClojureDart compiled to Dart, then Flutter's Gradle build. |
| 36 | 52 | # |
| 37 | 53 | # 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 --list | 33 | @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 be | 53 | # 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() | ||