Skip to main content

max / goingson

12.3 KB · 316 lines History Blame Raw
1 //! The chosen theme, as a stylesheet the document links.
2 //!
3 //! # Why a stylesheet rather than a script
4 //!
5 //! Resolve the theme to its intent tokens through `makeover::intent_css_vars`
6 //! and let the result override the stylesheet's own `:root`. That is one
7 //! TOML-to-CSS mapping shared by the three apps, served here at an address
8 //! rather than inlined in a `<head>` the app does not build per request.
9 //!
10 //! # Following the system, without a frontend to ask
11 //!
12 //! `prefers-color-scheme` is a browser fact and Rust cannot see it, which is
13 //! why `commands::themes::resolve_theme` takes an `ambient` argument. A
14 //! stylesheet can see it, so a selection of "system" is not resolved here at
15 //! all: both variants are rendered, the dark one behind the media query, and
16 //! the browser picks. The OS switching then repaints immediately.
17 //!
18 //! # Every theme, and the one in force
19 //!
20 //! The sheet carries two things. First the blocks for the stored selection,
21 //! unkeyed, which is what the document paints before a line of script has run
22 //! and is why a pinned theme is right on the first frame. Then one block per
23 //! choice the picker offers, keyed by `makeover::THEME_ATTRIBUTE` on the root
24 //! element, so a choice can be applied by setting an attribute instead of by
25 //! re-linking a stylesheet.
26 //!
27 //! The keyed set includes `makeover::FOLLOW`, which is not an installed theme
28 //! and needs a block anyway: without one, picking Follow System after a pinned
29 //! start would fall back to the unkeyed blocks, and those are the pin. Its
30 //! block is the pair the unkeyed default carries when nothing is pinned:
31 //! light, then dark behind the media query, so the browser keeps picking.
32 //!
33 //! `frontend/js/host.js` is what writes the attribute; see its third job. It
34 //! has to be a script because the answer to the write is swapped into the page
35 //! by htmx, which parses the response's `<html>` away and never touches
36 //! `document.documentElement`. So the renderer cannot reach the root of a
37 //! document that is already open, and the picker can.
38 //!
39 //! # Resolved once
40 //!
41 //! Filled from `install` at startup, beside the two `Late` states, because the
42 //! passthrough that serves it is a closure over no state. The sheet is a
43 //! function of the theme directories and the stored selection, and the
44 //! selection only decides which blocks are unkeyed: a change lands through the
45 //! attribute at once, and is read back here on the next launch.
46
47 use std::path::PathBuf;
48 use std::sync::OnceLock;
49
50 use makeover::{ThemeDefaults, ThemeSelection, Variant};
51
52 use crate::state::AppState;
53
54 /// The rendered sheet, resolved at startup.
55 static SHEET: OnceLock<String> = OnceLock::new();
56
57 /// The address the document links, and the one `assets` answers.
58 pub const ADDRESS: &str = "/static/theme.css";
59
60 /// The config key Appearance writes.
61 const KEY: &str = "theme";
62
63 /// The themes GoingsOn falls back to when the user follows the system rather
64 /// than pinning one.
65 ///
66 /// The same pair `commands::themes` names, and for the same reason: what "the
67 /// light one" means is this app's answer, where everything around it is
68 /// makeover's.
69 fn defaults() -> ThemeDefaults {
70 ThemeDefaults::new("goingson", "catppuccin-mocha")
71 }
72
73 /// Resolve the stored selection and hold the sheet it renders to.
74 ///
75 /// Called once per process, from the same place the protocols' state is handed
76 /// over. A second call is ignored rather than refused: both entry points build
77 /// one `AppState` and this follows it.
78 pub fn install(state: &AppState) {
79 let selection = crate::commands::all_config(state)
80 .ok()
81 .and_then(|config| config.get(KEY).cloned());
82 let _ = SHEET.set(sheet(&state.theme_dirs, selection.as_deref()));
83 }
84
85 /// The sheet, or the stock one if a request beats [`install`].
86 ///
87 /// The same gap the deferred protocol answers 503 in. A stylesheet has no such
88 /// answer worth making, and the stock theme is what the fallback would render
89 /// anyway.
90 pub fn css() -> &'static str {
91 SHEET.get().map_or("", String::as_str)
92 }
93
94 /// The intent tokens for a stored selection, as CSS, followed by a keyed block
95 /// per choice the picker offers.
96 ///
97 /// `selection` is verbatim from the store: `None` or `makeover::FOLLOW` to
98 /// follow the OS, or a theme id to pin.
99 fn sheet(dirs: &[(PathBuf, bool)], selection: Option<&str>) -> String {
100 let available = makeover::list_themes_from_dirs(dirs);
101 let banner = "/* Every theme GoingsOn offers, keyed by the root attribute, with\n \
102 the stored choice unkeyed on top. Rendered by makeover at\n \
103 startup. Not a file on disk: see src/quasi/theming.rs. */\n";
104
105 let mut out = String::from(banner);
106 out.push_str(&unkeyed(
107 dirs,
108 &available,
109 &ThemeSelection::parse(selection),
110 ));
111 out.push_str(&keyed(dirs, &available));
112 out
113 }
114
115 /// The blocks that apply when the root element names no theme: the stored
116 /// selection, resolved.
117 ///
118 /// What the document paints before any script runs. A pinned theme resolves to
119 /// itself whichever variant is asked, so the two are equal and the media query
120 /// would be a second copy of the block above it.
121 fn unkeyed(
122 dirs: &[(PathBuf, bool)],
123 available: &[makeover::ThemeMeta],
124 chosen: &ThemeSelection,
125 ) -> String {
126 let light = vars_for(dirs, available, chosen, Variant::Light);
127 let dark = vars_for(dirs, available, chosen, Variant::Dark);
128 if light == dark {
129 return light;
130 }
131 format!("{light}\n@media (prefers-color-scheme: dark) {{\n{dark}}}\n")
132 }
133
134 /// One block per choice the picker offers, each behind the root attribute.
135 ///
136 /// Every installed theme, plus [`makeover::FOLLOW`], which is a choice and not
137 /// a theme: it is emitted as the same light-then-dark pair the unkeyed default
138 /// carries when nothing is pinned, so picking it hands the decision back to the
139 /// browser rather than to whatever was pinned when the app started.
140 ///
141 /// Ordered by id, then Follow last, so two builds of the same directories emit
142 /// the same bytes.
143 fn keyed(dirs: &[(PathBuf, bool)], available: &[makeover::ThemeMeta]) -> String {
144 let mut ids: Vec<&str> = available.iter().map(|meta| meta.id.as_str()).collect();
145 ids.sort_unstable();
146
147 let mut out = String::new();
148 for id in ids {
149 // A theme that will not load costs its own block and nothing else: the
150 // directories include a user-writable one, and one bad file there is
151 // not a reason to serve a sheet with no colours in it.
152 if let Ok(tokens) = makeover::load_semantic(dirs, id) {
153 out.push('\n');
154 out.push_str(&makeover::keyed_intent_css_vars(id, &tokens));
155 }
156 }
157
158 let follow = ThemeSelection::Follow;
159 let light = vars_keyed(dirs, available, &follow, Variant::Light);
160 let dark = vars_keyed(dirs, available, &follow, Variant::Dark);
161 out.push('\n');
162 out.push_str(&light);
163 if dark != light {
164 out.push_str("\n@media (prefers-color-scheme: dark) {\n");
165 out.push_str(&dark);
166 out.push_str("}\n");
167 }
168 out
169 }
170
171 /// One variant of a selection as an unkeyed `:root` block.
172 fn vars_for(
173 dirs: &[(PathBuf, bool)],
174 available: &[makeover::ThemeMeta],
175 chosen: &ThemeSelection,
176 variant: Variant,
177 ) -> String {
178 let id = chosen.resolve(variant, &defaults(), available);
179 makeover::load_semantic(dirs, &id)
180 .map(|tokens| makeover::intent_css_vars(&tokens))
181 .unwrap_or_default()
182 }
183
184 /// One variant of a selection as a block keyed to [`makeover::FOLLOW`].
185 fn vars_keyed(
186 dirs: &[(PathBuf, bool)],
187 available: &[makeover::ThemeMeta],
188 chosen: &ThemeSelection,
189 variant: Variant,
190 ) -> String {
191 let id = chosen.resolve(variant, &defaults(), available);
192 makeover::load_semantic(dirs, &id)
193 .map(|tokens| makeover::keyed_intent_css_vars(makeover::FOLLOW, &tokens))
194 .unwrap_or_default()
195 }
196
197 #[cfg(test)]
198 mod tests {
199 use super::*;
200
201 /// The tree's own theme directory, which `build.rs` materializes from
202 /// makeover. Present in a checkout, which is where tests run.
203 pub(super) fn dirs() -> Vec<(PathBuf, bool)> {
204 vec![(
205 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("themes"),
206 false,
207 )]
208 }
209
210 #[test]
211 fn a_pinned_theme_is_one_unkeyed_block_and_no_media_query() {
212 let css = unkeyed(
213 &dirs(),
214 &makeover::list_themes_from_dirs(&dirs()),
215 &ThemeSelection::parse(Some("goingson")),
216 );
217 assert!(css.contains(":root {"), "{css}");
218 assert!(
219 !css.contains("prefers-color-scheme"),
220 "a pinned theme resolves the same either way, so the query would \
221 hold a copy of the block above it:\n{css}"
222 );
223 }
224
225 #[test]
226 fn following_the_system_renders_both_variants() {
227 let css = unkeyed(
228 &dirs(),
229 &makeover::list_themes_from_dirs(&dirs()),
230 &ThemeSelection::parse(Some(makeover::FOLLOW)),
231 );
232 assert!(
233 css.contains("@media (prefers-color-scheme: dark)"),
234 "the browser is what picks, because Rust cannot see the \
235 preference:\n{css}"
236 );
237 assert_eq!(css.matches(":root {").count(), 2, "{css}");
238 }
239
240 /// The whole point of the sheet: every theme is in it, whichever one is
241 /// stored, so switching is an attribute rather than a second request.
242 #[test]
243 fn every_installed_theme_has_a_keyed_block() {
244 let css = sheet(&dirs(), Some("goingson"));
245 let installed = makeover::list_themes_from_dirs(&dirs());
246 assert!(!installed.is_empty(), "the checkout has themes");
247 for theme in &installed {
248 let block = format!(":root[{}=\"{}\"]", makeover::THEME_ATTRIBUTE, theme.id);
249 assert!(css.contains(&block), "{} has no keyed block", theme.id);
250 }
251 }
252
253 /// Follow System is a choice and not a theme, and it needs a keyed block
254 /// for that reason: picking it after a pinned start must hand the decision
255 /// back to the browser rather than fall through to the pin.
256 #[test]
257 fn following_the_system_is_keyed_too_and_carries_both_variants() {
258 let css = sheet(&dirs(), Some("goingson"));
259 let block = format!(
260 ":root[{}=\"{}\"]",
261 makeover::THEME_ATTRIBUTE,
262 makeover::FOLLOW
263 );
264 assert_eq!(css.matches(&block).count(), 2, "{css}");
265 // The second of the two is behind the query, which is what makes it
266 // follow rather than pin the light theme.
267 let dark = css
268 .rfind("@media (prefers-color-scheme: dark)")
269 .expect("a dark query");
270 assert!(css[dark..].contains(&block), "{}", &css[dark..]);
271 }
272
273 /// A pin is unkeyed as well as keyed, so the first frame is right before
274 /// any script has run.
275 #[test]
276 fn the_stored_choice_is_what_applies_with_no_attribute_set() {
277 let pinned = sheet(&dirs(), Some("catppuccin-latte"));
278 let bare = pinned
279 .split(&format!(":root[{}", makeover::THEME_ATTRIBUTE))
280 .next()
281 .expect("the unkeyed half");
282 let alone = unkeyed(
283 &dirs(),
284 &makeover::list_themes_from_dirs(&dirs()),
285 &ThemeSelection::parse(Some("catppuccin-latte")),
286 );
287 assert!(bare.contains(alone.trim_end()), "{bare}");
288 }
289
290 /// An unset key means the same thing as "system": the picker's first choice
291 /// is Follow System and an install that has never touched it is following.
292 #[test]
293 fn an_unset_selection_follows_the_system() {
294 assert_eq!(sheet(&dirs(), None), sheet(&dirs(), Some(makeover::FOLLOW)));
295 }
296
297 /// A theme that was pinned and has since been deleted falls back rather
298 /// than rendering nothing, which is `ThemeSelection::resolve`'s own
299 /// promise and is worth holding here because an empty sheet would leave
300 /// the stylesheet's stale `:root` in charge and look like it worked.
301 #[test]
302 fn a_pinned_theme_that_is_gone_falls_back_to_a_real_one() {
303 let css = sheet(&dirs(), Some("no-such-theme"));
304 assert!(css.contains("--surface-page"), "{css}");
305 }
306
307 /// Byte-stable, which is what lets the sheet be rendered once and held.
308 #[test]
309 fn the_sheet_is_the_same_bytes_twice() {
310 assert_eq!(
311 sheet(&dirs(), Some("goingson")),
312 sheet(&dirs(), Some("goingson"))
313 );
314 }
315 }
316