//! The chosen theme, as a stylesheet the document links. //! //! # Why a stylesheet rather than a script //! //! Resolve the theme to its intent tokens through `makeover::intent_css_vars` //! and let the result override the stylesheet's own `:root`. That is one //! TOML-to-CSS mapping shared by the three apps, served here at an address //! rather than inlined in a `` the app does not build per request. //! //! # Following the system, without a frontend to ask //! //! `prefers-color-scheme` is a browser fact and Rust cannot see it, which is //! why `commands::themes::resolve_theme` takes an `ambient` argument. A //! stylesheet can see it, so a selection of "system" is not resolved here at //! all: both variants are rendered, the dark one behind the media query, and //! the browser picks. The OS switching then repaints immediately. //! //! # Every theme, and the one in force //! //! The sheet carries two things. First the blocks for the stored selection, //! unkeyed, which is what the document paints before a line of script has run //! and is why a pinned theme is right on the first frame. Then one block per //! choice the picker offers, keyed by `makeover::THEME_ATTRIBUTE` on the root //! element, so a choice can be applied by setting an attribute instead of by //! re-linking a stylesheet. //! //! The keyed set includes `makeover::FOLLOW`, which is not an installed theme //! and needs a block anyway: without one, picking Follow System after a pinned //! start would fall back to the unkeyed blocks, and those are the pin. Its //! block is the pair the unkeyed default carries when nothing is pinned: //! light, then dark behind the media query, so the browser keeps picking. //! //! `frontend/js/host.js` is what writes the attribute; see its third job. It //! has to be a script because the answer to the write is swapped into the page //! by htmx, which parses the response's `` away and never touches //! `document.documentElement`. So the renderer cannot reach the root of a //! document that is already open, and the picker can. //! //! # Resolved once //! //! Filled from `install` at startup, beside the two `Late` states, because the //! passthrough that serves it is a closure over no state. The sheet is a //! function of the theme directories and the stored selection, and the //! selection only decides which blocks are unkeyed: a change lands through the //! attribute at once, and is read back here on the next launch. use std::path::PathBuf; use std::sync::OnceLock; use makeover::{ThemeDefaults, ThemeSelection, Variant}; use crate::state::AppState; /// The rendered sheet, resolved at startup. static SHEET: OnceLock = OnceLock::new(); /// The address the document links, and the one `assets` answers. pub const ADDRESS: &str = "/static/theme.css"; /// The config key Appearance writes. const KEY: &str = "theme"; /// The themes GoingsOn falls back to when the user follows the system rather /// than pinning one. /// /// The same pair `commands::themes` names, and for the same reason: what "the /// light one" means is this app's answer, where everything around it is /// makeover's. fn defaults() -> ThemeDefaults { ThemeDefaults::new("goingson", "catppuccin-mocha") } /// Resolve the stored selection and hold the sheet it renders to. /// /// Called once per process, from the same place the protocols' state is handed /// over. A second call is ignored rather than refused: both entry points build /// one `AppState` and this follows it. pub fn install(state: &AppState) { let selection = crate::commands::all_config(state) .ok() .and_then(|config| config.get(KEY).cloned()); let _ = SHEET.set(sheet(&state.theme_dirs, selection.as_deref())); } /// The sheet, or the stock one if a request beats [`install`]. /// /// The same gap the deferred protocol answers 503 in. A stylesheet has no such /// answer worth making, and the stock theme is what the fallback would render /// anyway. pub fn css() -> &'static str { SHEET.get().map_or("", String::as_str) } /// The intent tokens for a stored selection, as CSS, followed by a keyed block /// per choice the picker offers. /// /// `selection` is verbatim from the store: `None` or `makeover::FOLLOW` to /// follow the OS, or a theme id to pin. fn sheet(dirs: &[(PathBuf, bool)], selection: Option<&str>) -> String { let available = makeover::list_themes_from_dirs(dirs); let banner = "/* Every theme GoingsOn offers, keyed by the root attribute, with\n \ the stored choice unkeyed on top. Rendered by makeover at\n \ startup. Not a file on disk: see src/quasi/theming.rs. */\n"; let mut out = String::from(banner); out.push_str(&unkeyed( dirs, &available, &ThemeSelection::parse(selection), )); out.push_str(&keyed(dirs, &available)); out } /// The blocks that apply when the root element names no theme: the stored /// selection, resolved. /// /// What the document paints before any script runs. A pinned theme resolves to /// itself whichever variant is asked, so the two are equal and the media query /// would be a second copy of the block above it. fn unkeyed( dirs: &[(PathBuf, bool)], available: &[makeover::ThemeMeta], chosen: &ThemeSelection, ) -> String { let light = vars_for(dirs, available, chosen, Variant::Light); let dark = vars_for(dirs, available, chosen, Variant::Dark); if light == dark { return light; } format!("{light}\n@media (prefers-color-scheme: dark) {{\n{dark}}}\n") } /// One block per choice the picker offers, each behind the root attribute. /// /// Every installed theme, plus [`makeover::FOLLOW`], which is a choice and not /// a theme: it is emitted as the same light-then-dark pair the unkeyed default /// carries when nothing is pinned, so picking it hands the decision back to the /// browser rather than to whatever was pinned when the app started. /// /// Ordered by id, then Follow last, so two builds of the same directories emit /// the same bytes. fn keyed(dirs: &[(PathBuf, bool)], available: &[makeover::ThemeMeta]) -> String { let mut ids: Vec<&str> = available.iter().map(|meta| meta.id.as_str()).collect(); ids.sort_unstable(); let mut out = String::new(); for id in ids { // A theme that will not load costs its own block and nothing else: the // directories include a user-writable one, and one bad file there is // not a reason to serve a sheet with no colours in it. if let Ok(tokens) = makeover::load_semantic(dirs, id) { out.push('\n'); out.push_str(&makeover::keyed_intent_css_vars(id, &tokens)); } } let follow = ThemeSelection::Follow; let light = vars_keyed(dirs, available, &follow, Variant::Light); let dark = vars_keyed(dirs, available, &follow, Variant::Dark); out.push('\n'); out.push_str(&light); if dark != light { out.push_str("\n@media (prefers-color-scheme: dark) {\n"); out.push_str(&dark); out.push_str("}\n"); } out } /// One variant of a selection as an unkeyed `:root` block. fn vars_for( dirs: &[(PathBuf, bool)], available: &[makeover::ThemeMeta], chosen: &ThemeSelection, variant: Variant, ) -> String { let id = chosen.resolve(variant, &defaults(), available); makeover::load_semantic(dirs, &id) .map(|tokens| makeover::intent_css_vars(&tokens)) .unwrap_or_default() } /// One variant of a selection as a block keyed to [`makeover::FOLLOW`]. fn vars_keyed( dirs: &[(PathBuf, bool)], available: &[makeover::ThemeMeta], chosen: &ThemeSelection, variant: Variant, ) -> String { let id = chosen.resolve(variant, &defaults(), available); makeover::load_semantic(dirs, &id) .map(|tokens| makeover::keyed_intent_css_vars(makeover::FOLLOW, &tokens)) .unwrap_or_default() } #[cfg(test)] mod tests { use super::*; /// The tree's own theme directory, which `build.rs` materializes from /// makeover. Present in a checkout, which is where tests run. pub(super) fn dirs() -> Vec<(PathBuf, bool)> { vec![( PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("themes"), false, )] } #[test] fn a_pinned_theme_is_one_unkeyed_block_and_no_media_query() { let css = unkeyed( &dirs(), &makeover::list_themes_from_dirs(&dirs()), &ThemeSelection::parse(Some("goingson")), ); assert!(css.contains(":root {"), "{css}"); assert!( !css.contains("prefers-color-scheme"), "a pinned theme resolves the same either way, so the query would \ hold a copy of the block above it:\n{css}" ); } #[test] fn following_the_system_renders_both_variants() { let css = unkeyed( &dirs(), &makeover::list_themes_from_dirs(&dirs()), &ThemeSelection::parse(Some(makeover::FOLLOW)), ); assert!( css.contains("@media (prefers-color-scheme: dark)"), "the browser is what picks, because Rust cannot see the \ preference:\n{css}" ); assert_eq!(css.matches(":root {").count(), 2, "{css}"); } /// The whole point of the sheet: every theme is in it, whichever one is /// stored, so switching is an attribute rather than a second request. #[test] fn every_installed_theme_has_a_keyed_block() { let css = sheet(&dirs(), Some("goingson")); let installed = makeover::list_themes_from_dirs(&dirs()); assert!(!installed.is_empty(), "the checkout has themes"); for theme in &installed { let block = format!(":root[{}=\"{}\"]", makeover::THEME_ATTRIBUTE, theme.id); assert!(css.contains(&block), "{} has no keyed block", theme.id); } } /// Follow System is a choice and not a theme, and it needs a keyed block /// for that reason: picking it after a pinned start must hand the decision /// back to the browser rather than fall through to the pin. #[test] fn following_the_system_is_keyed_too_and_carries_both_variants() { let css = sheet(&dirs(), Some("goingson")); let block = format!( ":root[{}=\"{}\"]", makeover::THEME_ATTRIBUTE, makeover::FOLLOW ); assert_eq!(css.matches(&block).count(), 2, "{css}"); // The second of the two is behind the query, which is what makes it // follow rather than pin the light theme. let dark = css .rfind("@media (prefers-color-scheme: dark)") .expect("a dark query"); assert!(css[dark..].contains(&block), "{}", &css[dark..]); } /// A pin is unkeyed as well as keyed, so the first frame is right before /// any script has run. #[test] fn the_stored_choice_is_what_applies_with_no_attribute_set() { let pinned = sheet(&dirs(), Some("catppuccin-latte")); let bare = pinned .split(&format!(":root[{}", makeover::THEME_ATTRIBUTE)) .next() .expect("the unkeyed half"); let alone = unkeyed( &dirs(), &makeover::list_themes_from_dirs(&dirs()), &ThemeSelection::parse(Some("catppuccin-latte")), ); assert!(bare.contains(alone.trim_end()), "{bare}"); } /// An unset key means the same thing as "system": the picker's first choice /// is Follow System and an install that has never touched it is following. #[test] fn an_unset_selection_follows_the_system() { assert_eq!(sheet(&dirs(), None), sheet(&dirs(), Some(makeover::FOLLOW))); } /// A theme that was pinned and has since been deleted falls back rather /// than rendering nothing, which is `ThemeSelection::resolve`'s own /// promise and is worth holding here because an empty sheet would leave /// the stylesheet's stale `:root` in charge and look like it worked. #[test] fn a_pinned_theme_that_is_gone_falls_back_to_a_real_one() { let css = sheet(&dirs(), Some("no-such-theme")); assert!(css.contains("--surface-page"), "{css}"); } /// Byte-stable, which is what lets the sheet be rendered once and held. #[test] fn the_sheet_is_the_same_bytes_twice() { assert_eq!( sheet(&dirs(), Some("goingson")), sheet(&dirs(), Some("goingson")) ); } }