//! 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"))
);
}
}