| 27 |
27 |
|
//! picks. That is better than what the script did as well as simpler, because
|
| 28 |
28 |
|
//! the OS switching now repaints immediately rather than at the next launch.
|
| 29 |
29 |
|
//!
|
|
30 |
+ |
//! # Every theme, and the one in force
|
|
31 |
+ |
//!
|
|
32 |
+ |
//! The sheet carries two things. First the blocks for the stored selection,
|
|
33 |
+ |
//! unkeyed, which is what the document paints before a line of script has run
|
|
34 |
+ |
//! and is why a pinned theme is right on the first frame. Then one block per
|
|
35 |
+ |
//! choice the picker offers, keyed by `makeover::THEME_ATTRIBUTE` on the root
|
|
36 |
+ |
//! element, so a choice can be applied by setting an attribute instead of by
|
|
37 |
+ |
//! re-linking a stylesheet.
|
|
38 |
+ |
//!
|
|
39 |
+ |
//! The keyed set includes `makeover::FOLLOW`, which is not an installed theme
|
|
40 |
+ |
//! and needs a block anyway: without one, picking Follow System after a pinned
|
|
41 |
+ |
//! start would fall back to the unkeyed blocks, and those are the pin. Its
|
|
42 |
+ |
//! block is the pair the unkeyed default carries when nothing is pinned --
|
|
43 |
+ |
//! light, then dark behind the media query -- so the browser keeps picking.
|
|
44 |
+ |
//!
|
|
45 |
+ |
//! `frontend/js/host.js` is what writes the attribute; see its third job. It
|
|
46 |
+ |
//! has to be a script because the answer to the write is swapped into the page
|
|
47 |
+ |
//! by htmx, which parses the response's `<html>` away and never touches
|
|
48 |
+ |
//! `document.documentElement`. So the renderer cannot reach the root of a
|
|
49 |
+ |
//! document that is already open, and the picker can.
|
|
50 |
+ |
//!
|
| 30 |
51 |
|
//! # Resolved once
|
| 31 |
52 |
|
//!
|
| 32 |
53 |
|
//! Filled from `install` at startup, beside the two `Late` states, because the
|
| 33 |
|
- |
//! passthrough that serves it is a closure over no state. So **a pinned theme
|
| 34 |
|
- |
//! change takes effect at the next launch**, and following the system is live.
|
| 35 |
|
- |
//! Applying a pinned change without relaunching needs the document reloaded,
|
| 36 |
|
- |
//! and nothing in `quasi_router::Response` says that: `Goto` is an htmx
|
| 37 |
|
- |
//! navigation, which swaps the body and leaves the head alone. Filed on
|
| 38 |
|
- |
//! quasicoherent.
|
|
54 |
+ |
//! passthrough that serves it is a closure over no state. The sheet is a
|
|
55 |
+ |
//! function of the theme directories and the stored selection, and the
|
|
56 |
+ |
//! selection only decides which blocks are unkeyed: a change lands through the
|
|
57 |
+ |
//! attribute at once, and is read back here on the next launch.
|
| 39 |
58 |
|
|
| 40 |
59 |
|
use std::path::PathBuf;
|
| 41 |
60 |
|
use std::sync::OnceLock;
|
| 84 |
103 |
|
SHEET.get().map_or("", String::as_str)
|
| 85 |
104 |
|
}
|
| 86 |
105 |
|
|
| 87 |
|
- |
/// The intent tokens for a stored selection, as CSS.
|
|
106 |
+ |
/// The intent tokens for a stored selection, as CSS, followed by a keyed block
|
|
107 |
+ |
/// per choice the picker offers.
|
| 88 |
108 |
|
///
|
| 89 |
|
- |
/// `selection` is verbatim from the store: `None` or `"system"` to follow the
|
| 90 |
|
- |
/// OS, or a theme id to pin.
|
|
109 |
+ |
/// `selection` is verbatim from the store: `None` or `makeover::FOLLOW` to
|
|
110 |
+ |
/// follow the OS, or a theme id to pin.
|
| 91 |
111 |
|
fn sheet(dirs: &[(PathBuf, bool)], selection: Option<&str>) -> String {
|
| 92 |
112 |
|
let available = makeover::list_themes_from_dirs(dirs);
|
| 93 |
|
- |
let chosen = ThemeSelection::parse(selection);
|
| 94 |
|
- |
let for_variant = |variant| {
|
| 95 |
|
- |
let id = chosen.resolve(variant, &defaults(), &available);
|
| 96 |
|
- |
makeover::load_semantic(dirs, &id)
|
| 97 |
|
- |
.map(|tokens| makeover::intent_css_vars(&tokens))
|
| 98 |
|
- |
.unwrap_or_default()
|
| 99 |
|
- |
};
|
|
113 |
+ |
let banner = "/* Every theme GoingsOn offers, keyed by the root attribute, with\n \
|
|
114 |
+ |
the stored choice unkeyed on top. Rendered by makeover at\n \
|
|
115 |
+ |
startup. Not a file on disk: see src/quasi/theming.rs. */\n";
|
| 100 |
116 |
|
|
| 101 |
|
- |
let light = for_variant(Variant::Light);
|
| 102 |
|
- |
let dark = for_variant(Variant::Dark);
|
| 103 |
|
- |
let banner = "/* The chosen theme's intent tokens, resolved by makeover at\n \
|
| 104 |
|
- |
startup from the `theme` config key. Not a file on disk: see\n \
|
| 105 |
|
- |
src/quasi/theming.rs. */\n";
|
|
117 |
+ |
let mut out = String::from(banner);
|
|
118 |
+ |
out.push_str(&unkeyed(
|
|
119 |
+ |
dirs,
|
|
120 |
+ |
&available,
|
|
121 |
+ |
&ThemeSelection::parse(selection),
|
|
122 |
+ |
));
|
|
123 |
+ |
out.push_str(&keyed(dirs, &available));
|
|
124 |
+ |
out
|
|
125 |
+ |
}
|
| 106 |
126 |
|
|
| 107 |
|
- |
// A pinned theme resolves to itself whichever variant is asked, so the two
|
| 108 |
|
- |
// are equal and the media query would be a second copy of the same block.
|
|
127 |
+ |
/// The blocks that apply when the root element names no theme: the stored
|
|
128 |
+ |
/// selection, resolved.
|
|
129 |
+ |
///
|
|
130 |
+ |
/// What the document paints before any script runs. A pinned theme resolves to
|
|
131 |
+ |
/// itself whichever variant is asked, so the two are equal and the media query
|
|
132 |
+ |
/// would be a second copy of the block above it.
|
|
133 |
+ |
fn unkeyed(
|
|
134 |
+ |
dirs: &[(PathBuf, bool)],
|
|
135 |
+ |
available: &[makeover::ThemeMeta],
|
|
136 |
+ |
chosen: &ThemeSelection,
|
|
137 |
+ |
) -> String {
|
|
138 |
+ |
let light = vars_for(dirs, available, chosen, Variant::Light);
|
|
139 |
+ |
let dark = vars_for(dirs, available, chosen, Variant::Dark);
|
| 109 |
140 |
|
if light == dark {
|
| 110 |
|
- |
return format!("{banner}{light}");
|
|
141 |
+ |
return light;
|
| 111 |
142 |
|
}
|
| 112 |
|
- |
format!("{banner}{light}\n@media (prefers-color-scheme: dark) {{\n{dark}}}\n")
|
|
143 |
+ |
format!("{light}\n@media (prefers-color-scheme: dark) {{\n{dark}}}\n")
|
|
144 |
+ |
}
|
|
145 |
+ |
|
|
146 |
+ |
/// One block per choice the picker offers, each behind the root attribute.
|
|
147 |
+ |
///
|
|
148 |
+ |
/// Every installed theme, plus [`makeover::FOLLOW`], which is a choice and not
|
|
149 |
+ |
/// a theme: it is emitted as the same light-then-dark pair the unkeyed default
|
|
150 |
+ |
/// carries when nothing is pinned, so picking it hands the decision back to the
|
|
151 |
+ |
/// browser rather than to whatever was pinned when the app started.
|
|
152 |
+ |
///
|
|
153 |
+ |
/// Ordered by id, then Follow last, so two builds of the same directories emit
|
|
154 |
+ |
/// the same bytes.
|
|
155 |
+ |
fn keyed(dirs: &[(PathBuf, bool)], available: &[makeover::ThemeMeta]) -> String {
|
|
156 |
+ |
let mut ids: Vec<&str> = available.iter().map(|meta| meta.id.as_str()).collect();
|
|
157 |
+ |
ids.sort_unstable();
|
|
158 |
+ |
|
|
159 |
+ |
let mut out = String::new();
|
|
160 |
+ |
for id in ids {
|
|
161 |
+ |
// A theme that will not load costs its own block and nothing else: the
|
|
162 |
+ |
// directories include a user-writable one, and one bad file there is
|
|
163 |
+ |
// not a reason to serve a sheet with no colours in it.
|
|
164 |
+ |
if let Ok(tokens) = makeover::load_semantic(dirs, id) {
|
|
165 |
+ |
out.push('\n');
|
|
166 |
+ |
out.push_str(&makeover::keyed_intent_css_vars(id, &tokens));
|
|
167 |
+ |
}
|
|
168 |
+ |
}
|
|
169 |
+ |
|
|
170 |
+ |
let follow = ThemeSelection::Follow;
|
|
171 |
+ |
let light = vars_keyed(dirs, available, &follow, Variant::Light);
|
|
172 |
+ |
let dark = vars_keyed(dirs, available, &follow, Variant::Dark);
|
|
173 |
+ |
out.push('\n');
|
|
174 |
+ |
out.push_str(&light);
|
|
175 |
+ |
if dark != light {
|
|
176 |
+ |
out.push_str("\n@media (prefers-color-scheme: dark) {\n");
|
|
177 |
+ |
out.push_str(&dark);
|
|
178 |
+ |
out.push_str("}\n");
|
|
179 |
+ |
}
|
|
180 |
+ |
out
|
|
181 |
+ |
}
|
|
182 |
+ |
|
|
183 |
+ |
/// One variant of a selection as an unkeyed `:root` block.
|
|
184 |
+ |
fn vars_for(
|
|
185 |
+ |
dirs: &[(PathBuf, bool)],
|
|
186 |
+ |
available: &[makeover::ThemeMeta],
|
|
187 |
+ |
chosen: &ThemeSelection,
|
|
188 |
+ |
variant: Variant,
|
|
189 |
+ |
) -> String {
|
|
190 |
+ |
let id = chosen.resolve(variant, &defaults(), available);
|
|
191 |
+ |
makeover::load_semantic(dirs, &id)
|
|
192 |
+ |
.map(|tokens| makeover::intent_css_vars(&tokens))
|
|
193 |
+ |
.unwrap_or_default()
|
|
194 |
+ |
}
|
|
195 |
+ |
|
|
196 |
+ |
/// One variant of a selection as a block keyed to [`makeover::FOLLOW`].
|
|
197 |
+ |
fn vars_keyed(
|
|
198 |
+ |
dirs: &[(PathBuf, bool)],
|
|
199 |
+ |
available: &[makeover::ThemeMeta],
|
|
200 |
+ |
chosen: &ThemeSelection,
|
|
201 |
+ |
variant: Variant,
|
|
202 |
+ |
) -> String {
|
|
203 |
+ |
let id = chosen.resolve(variant, &defaults(), available);
|
|
204 |
+ |
makeover::load_semantic(dirs, &id)
|
|
205 |
+ |
.map(|tokens| makeover::keyed_intent_css_vars(makeover::FOLLOW, &tokens))
|
|
206 |
+ |
.unwrap_or_default()
|
| 113 |
207 |
|
}
|
| 114 |
208 |
|
|
| 115 |
209 |
|
#[cfg(test)]
|
| 126 |
220 |
|
}
|
| 127 |
221 |
|
|
| 128 |
222 |
|
#[test]
|
| 129 |
|
- |
fn a_pinned_theme_is_one_root_block_and_no_media_query() {
|
| 130 |
|
- |
let css = sheet(&dirs(), Some("goingson"));
|
|
223 |
+ |
fn a_pinned_theme_is_one_unkeyed_block_and_no_media_query() {
|
|
224 |
+ |
let css = unkeyed(
|
|
225 |
+ |
&dirs(),
|
|
226 |
+ |
&makeover::list_themes_from_dirs(&dirs()),
|
|
227 |
+ |
&ThemeSelection::parse(Some("goingson")),
|
|
228 |
+ |
);
|
| 131 |
229 |
|
assert!(css.contains(":root {"), "{css}");
|
| 132 |
230 |
|
assert!(
|
| 133 |
231 |
|
!css.contains("prefers-color-scheme"),
|
| 138 |
236 |
|
|
| 139 |
237 |
|
#[test]
|
| 140 |
238 |
|
fn following_the_system_renders_both_variants() {
|
| 141 |
|
- |
let css = sheet(&dirs(), Some("system"));
|
|
239 |
+ |
let css = unkeyed(
|
|
240 |
+ |
&dirs(),
|
|
241 |
+ |
&makeover::list_themes_from_dirs(&dirs()),
|
|
242 |
+ |
&ThemeSelection::parse(Some(makeover::FOLLOW)),
|
|
243 |
+ |
);
|
| 142 |
244 |
|
assert!(
|
| 143 |
245 |
|
css.contains("@media (prefers-color-scheme: dark)"),
|
| 144 |
246 |
|
"the browser is what picks, because Rust cannot see the \
|
| 147 |
249 |
|
assert_eq!(css.matches(":root {").count(), 2, "{css}");
|
| 148 |
250 |
|
}
|
| 149 |
251 |
|
|
|
252 |
+ |
/// The whole point of the sheet: every theme is in it, whichever one is
|
|
253 |
+ |
/// stored, so switching is an attribute rather than a second request.
|
|
254 |
+ |
#[test]
|
|
255 |
+ |
fn every_installed_theme_has_a_keyed_block() {
|
|
256 |
+ |
let css = sheet(&dirs(), Some("goingson"));
|
|
257 |
+ |
let installed = makeover::list_themes_from_dirs(&dirs());
|
|
258 |
+ |
assert!(!installed.is_empty(), "the checkout has themes");
|
|
259 |
+ |
for theme in &installed {
|
|
260 |
+ |
let block = format!(":root[{}=\"{}\"]", makeover::THEME_ATTRIBUTE, theme.id);
|
|
261 |
+ |
assert!(css.contains(&block), "{} has no keyed block", theme.id);
|
|
262 |
+ |
}
|
|
263 |
+ |
}
|
|
264 |
+ |
|
|
265 |
+ |
/// Follow System is a choice and not a theme, and it needs a keyed block
|
|
266 |
+ |
/// for that reason: picking it after a pinned start must hand the decision
|
|
267 |
+ |
/// back to the browser rather than fall through to the pin.
|
|
268 |
+ |
#[test]
|
|
269 |
+ |
fn following_the_system_is_keyed_too_and_carries_both_variants() {
|
|
270 |
+ |
let css = sheet(&dirs(), Some("goingson"));
|
|
271 |
+ |
let block = format!(
|
|
272 |
+ |
":root[{}=\"{}\"]",
|
|
273 |
+ |
makeover::THEME_ATTRIBUTE,
|
|
274 |
+ |
makeover::FOLLOW
|
|
275 |
+ |
);
|
|
276 |
+ |
assert_eq!(css.matches(&block).count(), 2, "{css}");
|
|
277 |
+ |
// The second of the two is behind the query, which is what makes it
|
|
278 |
+ |
// follow rather than pin the light theme.
|
|
279 |
+ |
let dark = css
|
|
280 |
+ |
.rfind("@media (prefers-color-scheme: dark)")
|
|
281 |
+ |
.expect("a dark query");
|
|
282 |
+ |
assert!(css[dark..].contains(&block), "{}", &css[dark..]);
|
|
283 |
+ |
}
|
|
284 |
+ |
|
|
285 |
+ |
/// A pin is unkeyed as well as keyed, so the first frame is right before
|
|
286 |
+ |
/// any script has run.
|
|
287 |
+ |
#[test]
|
|
288 |
+ |
fn the_stored_choice_is_what_applies_with_no_attribute_set() {
|
|
289 |
+ |
let pinned = sheet(&dirs(), Some("catppuccin-latte"));
|
|
290 |
+ |
let bare = pinned
|
|
291 |
+ |
.split(&format!(":root[{}", makeover::THEME_ATTRIBUTE))
|
|
292 |
+ |
.next()
|
|
293 |
+ |
.expect("the unkeyed half");
|
|
294 |
+ |
let alone = unkeyed(
|
|
295 |
+ |
&dirs(),
|
|
296 |
+ |
&makeover::list_themes_from_dirs(&dirs()),
|
|
297 |
+ |
&ThemeSelection::parse(Some("catppuccin-latte")),
|
|
298 |
+ |
);
|
|
299 |
+ |
assert!(bare.contains(alone.trim_end()), "{bare}");
|
|
300 |
+ |
}
|
|
301 |
+ |
|
| 150 |
302 |
|
/// An unset key means the same thing as "system": the picker's first choice
|
| 151 |
303 |
|
/// is Follow System and an install that has never touched it is following.
|
| 152 |
304 |
|
#[test]
|
| 153 |
305 |
|
fn an_unset_selection_follows_the_system() {
|
| 154 |
|
- |
assert_eq!(sheet(&dirs(), None), sheet(&dirs(), Some("system")));
|
|
306 |
+ |
assert_eq!(sheet(&dirs(), None), sheet(&dirs(), Some(makeover::FOLLOW)));
|
| 155 |
307 |
|
}
|
| 156 |
308 |
|
|
| 157 |
309 |
|
/// A theme that was pinned and has since been deleted falls back rather
|
| 163 |
315 |
|
let css = sheet(&dirs(), Some("no-such-theme"));
|
| 164 |
316 |
|
assert!(css.contains("--surface-page"), "{css}");
|
| 165 |
317 |
|
}
|
|
318 |
+ |
|
|
319 |
+ |
/// Byte-stable, which is what lets the sheet be rendered once and held.
|
|
320 |
+ |
#[test]
|
|
321 |
+ |
fn the_sheet_is_the_same_bytes_twice() {
|
|
322 |
+ |
assert_eq!(
|
|
323 |
+ |
sheet(&dirs(), Some("goingson")),
|
|
324 |
+ |
sheet(&dirs(), Some("goingson"))
|
|
325 |
+ |
);
|
|
326 |
+ |
}
|
| 166 |
327 |
|
}
|