//! Which theme the creator's TUI renders in, and where it comes from. //! //! No colour is written anywhere in this crate. There is no built-in fallback //! palette: a theme that will not resolve is an error the session reports, not //! something papered over by rendering in colours that exist in no theme file. //! //! The shared app convention is wiki `makeover-app-convention`. This surface //! differs from every other consumer of that convention in one way that decides //! the whole module: **the process rendering the TUI is not the process the //! creator is sitting in front of.** `mnw-cli` is an SSH daemon on the MNW host, //! and the terminal is at the other end of the connection. So neither of the two //! things a local TUI reads from its own environment is available here: //! //! - **The theme search path is embedded, not on disk.** `bundled_themes_dir()` //! resolves against makeover's own `CARGO_MANIFEST_DIR`, which on a deployed //! host names a cargo-registry directory belonging to the *build* machine. A //! disk tier would be absent in production and stale in staging, and the //! creator cannot drop a file on the host to fill one anyway. The embedded set //! travels with the binary and is the only tier that is always correct. //! - **The ambient mode and the colour fidelity come off the wire**, from the //! client's `TERM`, `COLORTERM` and `COLORFGBG`. Reading `std::env` here would //! describe the daemon's terminal, which is a systemd journal. //! //! What the creator chose is [`UserInfo::theme_id`](crate::api::UserInfo), which //! the server does not send yet; until it does every session follows the //! terminal. See the `ThemeSelection` handling in [`selection`]. use std::sync::OnceLock; use anyhow::{Context, Result}; use makeover::{ThemeColors, ThemeDefaults, ThemeMeta, ThemeSelection, Variant}; use makeover_tui::{Fidelity, Theme}; /// The platform's own light theme: the titular skin, and what the website wears. const DEFAULT_LIGHT: &str = "makenotwork"; /// The nearest dark theme in the embedded set. /// /// MNW authors no dark skin of its own, so this is a fallback rather than a pin: /// near-neutral greys on the same axis as `makenotwork`'s parchment, rather than /// a theme bringing a hue of its own. `ThemeSelection::resolve` reaches any /// other embedded dark theme once a creator can name one. const DEFAULT_DARK: &str = "carbonfox"; /// The themes this binary ships, parsed once. /// /// Parsed rather than listed off disk: see the module header. Held in a /// `OnceLock` because every SSH session resolves a theme and re-parsing 30-odd /// TOML files per login is a cost with no buyer. fn embedded() -> &'static [(String, ThemeColors)] { static THEMES: OnceLock> = OnceLock::new(); THEMES.get_or_init(|| { makeover::embedded_themes() .filter_map(|(id, source)| { match makeover::parse_theme_str(id, source, false) { Ok(colors) => Some((id.to_string(), colors)), // An embedded theme that will not parse is a packaging bug // in makeover, not something this creator's session can act // on. Drop it and keep the rest rather than failing every // login over a theme nobody asked for. Err(e) => { tracing::error!(theme = id, error = %e, "embedded theme failed to parse"); None } } }) .collect() }) } /// The themes a selection can resolve to. fn available() -> Vec { embedded() .iter() .map(|(_, colors)| colors.meta.clone()) .collect() } /// The platform's own light/dark pair, for a selection that follows. fn defaults() -> ThemeDefaults { ThemeDefaults::new(DEFAULT_LIGHT, DEFAULT_DARK) } /// What the creator's terminal told us about itself, captured from the SSH /// session rather than from this process. /// /// `TERM` arrives on the PTY request and is always present for a TUI session. /// `COLORTERM` and `COLORFGBG` arrive only if the client was configured to send /// them (`SendEnv`), which most are not — both fields are routinely empty, and /// the defaults each one falls back to are the documented behaviour rather than /// a degraded mode. #[derive(Debug, Clone, Default)] pub(crate) struct ClientTerminal { /// The `TERM` from the PTY request. pub(crate) term: String, /// `COLORTERM`, if the client sent it. pub(crate) colorterm: String, /// `COLORFGBG`, if the client sent it. pub(crate) colorfgbg: Option, } impl ClientTerminal { /// The terminal's answer to a `prefers-color-scheme` media query. /// /// `COLORFGBG` carries the background as a colour index; 0-6 and 8 are the /// dark ones. A terminal that says nothing reads as light, which is the /// documented default across the family. The alternative is an OSC 11 query /// and a wait for the reply before the first frame, which is a round trip /// over the creator's SSH connection for a preference the server will carry /// explicitly soon enough. fn ambient(&self) -> Variant { self.colorfgbg .as_deref() .and_then(|value| value.rsplit(';').next()) .and_then(|bg| bg.trim().parse::().ok()) .map_or(Variant::Light, |bg| { if bg <= 6 || bg == 8 { Variant::Dark } else { Variant::Light } }) } /// How much colour the far end can draw. fn fidelity(&self) -> Fidelity { Fidelity::from_env(&self.colorterm, &self.term) } } /// What this creator chose, as the convention encodes it. /// /// `None` is not "no preference expressed" but "the server did not tell us", /// and both land on `Follow` today. They separate once `UserInfo` carries the /// field: a creator who picked a theme on the website gets `Fixed`, and one who /// never opened the setting keeps following their terminal. pub(crate) fn selection(theme_id: Option<&str>) -> ThemeSelection { ThemeSelection::parse(theme_id) } /// Load the theme a selection resolves to, as this client can draw it. /// /// Quantised through [`Theme::for_terminal`] rather than handed over as 24-bit. /// Left alone, a terminal below truecolor approximates the colours itself and /// its approximation collapses tones the theme keeps apart — here that means a /// published item and a draft one stop looking different. pub(crate) fn load(selection: &ThemeSelection, client: &ClientTerminal) -> Result { let id = selection.resolve(client.ambient(), &defaults(), &available()); let colors = embedded() .iter() .find(|(embedded_id, _)| *embedded_id == id) .map(|(_, colors)| colors) .with_context(|| format!("theme `{id}` is not embedded in this binary"))?; Theme::from_theme(colors) .map(|theme| theme.for_terminal(client.fidelity())) .map_err(|e| anyhow::anyhow!("{e}")) .with_context(|| format!("theme `{id}` is incomplete")) } #[cfg(test)] pub(crate) mod tests { use super::*; /// A fixed theme for tests that need to render something. pub(crate) fn fixed() -> Theme { load( &ThemeSelection::Fixed(DEFAULT_LIGHT.into()), &ClientTerminal::default(), ) .expect("the platform's own theme loads") } // The embedded set is the whole search path, so an empty one means every // session renders nothing. Asserted here because nothing else would notice // until a creator logged in. #[test] fn the_binary_ships_its_own_themes() { assert!( !embedded().is_empty(), "no themes embedded; every session would fail to resolve one", ); } // Both ids this crate names must exist in makeover's embedded set. A rename // over there should fail here rather than at a creator's next login. // // Asserted against `embedded()` and not through `load`, because `resolve` // answers a `Fixed` id it cannot find by falling back to something it can — // so a `load` that succeeded would prove nothing about the id being named. #[test] fn the_named_defaults_are_embedded_and_complete() { for id in [DEFAULT_LIGHT, DEFAULT_DARK] { let (_, colors) = embedded() .iter() .find(|(embedded_id, _)| embedded_id == id) .unwrap_or_else(|| panic!("default theme `{id}` is not embedded")); assert!( Theme::from_theme(colors).is_ok(), "default theme `{id}` is incomplete", ); } } // Following reaches the theme matching the terminal, not a fixed default. #[test] fn following_resolves_to_the_theme_matching_the_terminal() { let available = available(); assert_eq!( ThemeSelection::Follow.resolve(Variant::Dark, &defaults(), &available), DEFAULT_DARK, ); assert_eq!( ThemeSelection::Follow.resolve(Variant::Light, &defaults(), &available), DEFAULT_LIGHT, ); } // A server that does not send the field and a creator who never chose are // the same thing today, and both follow. #[test] fn an_absent_theme_id_follows_the_terminal() { assert_eq!(selection(None), ThemeSelection::Follow); assert_eq!( selection(Some("nord")), ThemeSelection::Fixed("nord".into()) ); } // `COLORFGBG` is the client's, and it decides light from dark. #[test] fn the_clients_background_reads_as_a_variant() { for (raw, expect) in [ ("15;0", Variant::Dark), ("0;15", Variant::Light), ("15;8", Variant::Dark), ("15;7", Variant::Light), ] { let client = ClientTerminal { colorfgbg: Some(raw.to_string()), ..ClientTerminal::default() }; assert_eq!(client.ambient(), expect, "COLORFGBG={raw}"); } } // A client that sent nothing is light, not an error and not a guess. #[test] fn a_silent_client_reads_as_light() { assert_eq!(ClientTerminal::default().ambient(), Variant::Light); } // The fidelity is the client's claim, never this daemon's environment. The // Linux virtual console is the case that matters: it really does have // sixteen colours, and a truecolor guess there throws the theme away. #[test] fn the_fidelity_comes_from_the_client_not_the_daemon() { let console = ClientTerminal { term: "linux".to_string(), ..ClientTerminal::default() }; assert_eq!(console.fidelity(), Fidelity::Ansi16); let modern = ClientTerminal { term: "xterm-256color".to_string(), colorterm: "truecolor".to_string(), ..ClientTerminal::default() }; assert_eq!(modern.fidelity(), Fidelity::TrueColor); } }