Skip to main content

ssh_browser/theme/
mod.rs

1//! What a directory listing looks like.
2//!
3//! The palettes are [base16] schemes, vendored under `crates/ssh-browser/themes/`. souta
4//! asked whether there was a standard for this rather than a hand-rolled set, and there is:
5//! base16 is a spec with several hundred schemes behind it, every one of them sixteen hex
6//! values and a name. This module is an implementation of that format.
7//!
8//! Which also settles the objection to the palettes it replaces. Inventing five of my own
9//! meant five things nobody else had opinions about; naming them after somebody's editor
10//! theme would have been a promise to keep matching a moving target in another repository.
11//! Implementing a *format* is neither.
12//!
13//! Every rule in the listing's stylesheet is written against the custom properties built
14//! here, so adding a scheme is dropping a file in — not a second copy of the layout.
15//!
16//! [base16]: https://github.com/tinted-theming/home/blob/main/styling.md
17
18use std::path::PathBuf;
19use std::sync::OnceLock;
20
21use anyhow::{Result, bail};
22
23/// The theme used when nothing says otherwise.
24///
25/// Following the operating system, because a page that ignores the system setting is the
26/// one thing every dark-mode reader notices immediately.
27pub const DEFAULT: &str = "auto";
28
29/// The pair `auto` follows the system with: base16's own reference schemes.
30///
31/// The spec's defaults rather than a favourite, so the thing you get without choosing is
32/// not a choice somebody made for you.
33const AUTO_LIGHT: &str = "default-light";
34const AUTO_DARK: &str = "default-dark";
35
36/// The vendored schemes, compiled in.
37///
38/// A curated set rather than all three hundred and thirty-nine: a list you scroll past is
39/// not a choice, and these are the ones somebody would recognise by name. Adding one is a
40/// file and a line.
41///
42/// Compiled in rather than read at startup so that a daemon is one binary with no directory
43/// of assets to lose, and so a missing scheme is a build error rather than a blank page.
44const SCHEMES: &[(&str, &str)] = &[
45    (
46        "default-light",
47        include_str!("../../themes/default-light.yaml"),
48    ),
49    (
50        "default-dark",
51        include_str!("../../themes/default-dark.yaml"),
52    ),
53    ("github", include_str!("../../themes/github.yaml")),
54    ("github-dark", include_str!("../../themes/github-dark.yaml")),
55    (
56        "catppuccin-latte",
57        include_str!("../../themes/catppuccin-latte.yaml"),
58    ),
59    (
60        "catppuccin-mocha",
61        include_str!("../../themes/catppuccin-mocha.yaml"),
62    ),
63    (
64        "gruvbox-light-hard",
65        include_str!("../../themes/gruvbox-light-hard.yaml"),
66    ),
67    (
68        "gruvbox-dark-hard",
69        include_str!("../../themes/gruvbox-dark-hard.yaml"),
70    ),
71    (
72        "solarized-light",
73        include_str!("../../themes/solarized-light.yaml"),
74    ),
75    (
76        "solarized-dark",
77        include_str!("../../themes/solarized-dark.yaml"),
78    ),
79    (
80        "rose-pine-dawn",
81        include_str!("../../themes/rose-pine-dawn.yaml"),
82    ),
83    ("rose-pine", include_str!("../../themes/rose-pine.yaml")),
84    ("one-light", include_str!("../../themes/one-light.yaml")),
85    ("onedark", include_str!("../../themes/onedark.yaml")),
86    ("nord", include_str!("../../themes/nord.yaml")),
87    (
88        "tokyo-night-dark",
89        include_str!("../../themes/tokyo-night-dark.yaml"),
90    ),
91    ("dracula", include_str!("../../themes/dracula.yaml")),
92];
93
94pub struct Theme {
95    /// What it is called in configuration and on the wire: the scheme's filename.
96    pub name: String,
97    /// What it is called in the dashboard: the scheme's own `name`.
98    pub label: String,
99    /// `light`, `dark`, or `system` for the one that follows the reader's.
100    pub variant: &'static str,
101    /// The custom-property declarations, ready to go inside a `:root` block.
102    ///
103    /// Built once at startup. These come from this crate's own vendored files and never
104    /// from anything a caller supplied, so there is nothing here to escape — and a name
105    /// arriving from outside is matched against this table rather than interpolated
106    /// anywhere. See [`css_for`].
107    vars: String,
108}
109
110fn themes() -> &'static [Theme] {
111    static PARSED: OnceLock<Vec<Theme>> = OnceLock::new();
112    PARSED.get_or_init(|| {
113        let mut out = vec![Theme {
114            name: DEFAULT.to_string(),
115            label: "Follow the system".to_string(),
116            variant: "system",
117            // Filled by `css_for`, which needs both halves and a media query.
118            vars: String::new(),
119        }];
120        for (name, text) in SCHEMES {
121            // A vendored file that will not parse is this repository's own mistake, not a
122            // reader's, and it is caught by `every_vendored_scheme_parses` rather than by
123            // somebody opening a directory and finding no colours.
124            if let Some(scheme) = Scheme::parse(text) {
125                let vars = scheme.vars();
126                out.push(Theme {
127                    name: (*name).to_string(),
128                    label: scheme.label,
129                    variant: if scheme.dark { "dark" } else { "light" },
130                    vars,
131                });
132            }
133        }
134        out
135    })
136}
137
138pub fn all() -> &'static [Theme] {
139    themes()
140}
141
142pub fn exists(name: &str) -> bool {
143    themes().iter().any(|t| t.name == name)
144}
145
146/// Refuse a name that is not one of these, and say what the choices are.
147///
148/// Called where a theme is *set* rather than where a listing is rendered. A name nobody has
149/// is a typo, and a typo that silently produced the default would leave somebody looking at
150/// one palette and at a setting that claims another.
151pub fn check(name: &str) -> Result<()> {
152    if exists(name) {
153        return Ok(());
154    }
155    let known: Vec<&str> = themes().iter().map(|t| t.name.as_str()).collect();
156    bail!("no theme called {name:?}; try one of: {}", known.join(", "))
157}
158
159/// The `:root` block for a theme, including the system-following pair when it follows.
160///
161/// An unknown name falls back to the default rather than failing: by the time a page is
162/// being rendered there is nothing useful to do with an error, and a listing with no
163/// variables set is invisible text rather than merely wrong. Names are checked where they
164/// are set.
165pub fn css_for(name: &str) -> String {
166    let found = themes().iter().find(|t| t.name == name);
167    match found {
168        Some(t) if t.variant != "system" => format!(":root{{{}}}", t.vars),
169        // Both palettes, and the browser picks. This way round so that a browser without
170        // the query still gets a complete light palette rather than no variables at all.
171        _ => {
172            let light = vars_named(AUTO_LIGHT);
173            let dark = vars_named(AUTO_DARK);
174            format!(":root{{{light}}}@media(prefers-color-scheme:dark){{:root{{{dark}}}}}")
175        }
176    }
177}
178
179fn vars_named(name: &str) -> &'static str {
180    themes()
181        .iter()
182        .find(|t| t.name == name)
183        .map_or("", |t| t.vars.as_str())
184}
185
186/// A parsed base16 file: the sixteen colours, and enough metadata to label it.
187struct Scheme {
188    label: String,
189    dark: bool,
190    palette: [String; 16],
191}
192
193impl Scheme {
194    /// Read a base16 YAML file.
195    ///
196    /// Hand-written rather than through a YAML library, because the format is `key: value`
197    /// and one indented block of the same, and the alternative is a parser for the whole of
198    /// YAML in a daemon that reads other people's filesystems. Every vendored file is held
199    /// to this by a test.
200    fn parse(text: &str) -> Option<Self> {
201        let mut label = None;
202        let mut variant = None;
203        // `None` for a slot that never appeared, which is what makes a short file fail
204        // rather than render with a hole in it.
205        let mut palette: [Option<String>; 16] = [const { None }; 16];
206
207        for line in text.lines() {
208            let Some((key, value)) = field(line) else {
209                continue;
210            };
211            match key {
212                "name" => label = Some(value),
213                "variant" => variant = Some(value),
214                _ => {
215                    if let Some(slot) = base_index(key) {
216                        palette[slot] = Some(value);
217                    }
218                }
219            }
220        }
221
222        let mut colours: Vec<String> = Vec::with_capacity(16);
223        for slot in palette {
224            colours.push(slot?);
225        }
226        Some(Self {
227            label: label?,
228            // Anything that is not said to be light is treated as dark, which is the way
229            // round that matches the schemes: a light one always says so.
230            dark: variant.as_deref() != Some("light"),
231            palette: colours.try_into().ok()?,
232        })
233    }
234
235    /// base16's sixteen slots, as the properties the listing's rules are written against.
236    ///
237    /// The mapping is the spec's own meanings rather than a guess at which colour looks
238    /// nice. `base00` is the background and `base05` the foreground in every scheme, light
239    /// or dark, which is what lets one mapping serve both: a light scheme simply has its
240    /// `base00`..`base07` running the other way.
241    fn vars(&self) -> String {
242        let c = |i: usize| self.palette[i].as_str();
243        [
244            // base00 background, base01 a shade off it, base02 the selection background.
245            format!("--bg:{}", c(0x0)),
246            format!("--hover:{}", c(0x1)),
247            format!("--line:{}", c(0x1)),
248            format!("--sel:{}", c(0x2)),
249            // base03 is comments — the least contrast a reader is still meant to read.
250            format!("--faint:{}", c(0x3)),
251            format!("--dim:{}", c(0x4)),
252            format!("--fg:{}", c(0x5)),
253            // base0D is functions and headings: the scheme's own idea of "this one matters".
254            format!("--accent:{}", c(0xD)),
255            // The type colours. base09 is markup and constants, which is where HTML belongs;
256            // base0A data; base0B strings, so media; base0D headings, so documents; base0E
257            // keywords, so code.
258            format!("--k-page:{}", c(0x9)),
259            format!("--k-doc:{}", c(0xD)),
260            format!("--k-data:{}", c(0xA)),
261            format!("--k-code:{}", c(0xE)),
262            format!("--k-media:{}", c(0xB)),
263            format!("--k-plain:{}", c(0x3)),
264        ]
265        .join(";")
266    }
267}
268
269/// `key: "value"` or `key: value`, with a trailing `# comment` dropped.
270fn field(line: &str) -> Option<(&str, String)> {
271    let line = line.trim();
272    if line.is_empty() || line.starts_with('#') {
273        return None;
274    }
275    let (key, rest) = line.split_once(':')?;
276    let rest = rest.trim();
277    let value = match rest.strip_prefix('"') {
278        // Quoted: everything to the closing quote, so a `#` inside it survives.
279        Some(quoted) => quoted.split('"').next()?,
280        // Bare: everything before a comment.
281        None => rest.split('#').next()?.trim(),
282    };
283    (!value.is_empty()).then(|| (key.trim(), value.to_string()))
284}
285
286/// `base00`..`base0F` to `0`..`15`.
287fn base_index(key: &str) -> Option<usize> {
288    let digits = key.strip_prefix("base")?;
289    (digits.len() == 2)
290        .then(|| usize::from_str_radix(digits, 16).ok())
291        .flatten()
292        .filter(|slot| *slot < 16)
293}
294
295/// Where a chosen theme is remembered between runs.
296///
297/// Beside the token rather than in the user's `config.toml`. That file is hand-written and
298/// carries their comments, and a daemon that rewrote it would eventually lose one. The
299/// config file still sets the starting value; this records a later change of mind.
300fn stored_path() -> Option<PathBuf> {
301    Some(crate::control::state_dir()?.join("theme"))
302}
303
304/// The remembered theme, if there is one and it still exists.
305///
306/// A name that no longer names a theme is ignored rather than refused: it means this file
307/// outlived a scheme being dropped, and refusing to start over a colour would be absurd.
308pub fn remembered() -> Option<String> {
309    let name = std::fs::read_to_string(stored_path()?).ok()?;
310    let name = name.trim().to_string();
311    exists(&name).then_some(name)
312}
313
314/// Remember a theme, and say where it went.
315pub fn remember(name: &str) -> Result<PathBuf> {
316    check(name)?;
317    let path = match stored_path() {
318        Some(path) => path,
319        None => bail!("no directory to remember a theme in"),
320    };
321    if let Some(dir) = path.parent() {
322        std::fs::create_dir_all(dir)?;
323    }
324    std::fs::write(&path, format!("{name}\n"))?;
325    Ok(path)
326}
327
328#[cfg(test)]
329mod tests {
330    use super::*;
331
332    /// Every vendored file, through the real parser. A scheme that will not parse is
333    /// dropped silently at startup by design — there is nothing useful to do about it while
334    /// serving a page — so this is the only thing standing between a bad file and a theme
335    /// that quietly does not exist.
336    #[test]
337    fn every_vendored_scheme_parses() {
338        for (name, text) in SCHEMES {
339            let scheme = Scheme::parse(text).unwrap_or_else(|| panic!("{name} did not parse"));
340            assert!(!scheme.label.is_empty(), "{name} has no label");
341            for (slot, colour) in scheme.palette.iter().enumerate() {
342                assert!(
343                    colour.starts_with('#') && colour.len() == 7,
344                    "{name} base{slot:02X} is {colour:?}, which is not a hex colour"
345                );
346            }
347        }
348        assert_eq!(
349            themes().len(),
350            SCHEMES.len() + 1,
351            "one of the vendored schemes was dropped, plus auto"
352        );
353    }
354
355    /// The layout is written against these, so a palette missing one renders a listing with
356    /// an unset colour — not a visual nit, invisible text.
357    #[test]
358    fn every_theme_sets_every_variable() {
359        let wanted = [
360            "--bg",
361            "--fg",
362            "--dim",
363            "--faint",
364            "--line",
365            "--hover",
366            "--sel",
367            "--accent",
368            "--k-page",
369            "--k-doc",
370            "--k-data",
371            "--k-code",
372            "--k-media",
373            "--k-plain",
374        ];
375        for theme in all() {
376            let css = css_for(&theme.name);
377            for var in wanted {
378                assert!(
379                    css.contains(&format!("{var}:")),
380                    "{} is missing {var}",
381                    theme.name
382                );
383            }
384        }
385    }
386
387    /// Both halves of the curated set are there, so choosing "light" is a real choice and
388    /// not a list of dark schemes with one exception.
389    #[test]
390    fn the_curated_set_has_light_and_dark() {
391        let light = all().iter().filter(|t| t.variant == "light").count();
392        let dark = all().iter().filter(|t| t.variant == "dark").count();
393        assert!(light >= 6, "only {light} light schemes");
394        assert!(dark >= 6, "only {dark} dark schemes");
395    }
396
397    /// The default follows the system, and following the system means shipping both.
398    #[test]
399    fn the_default_carries_a_light_and_a_dark_palette() {
400        let css = css_for(DEFAULT);
401        assert!(css.contains("prefers-color-scheme:dark"), "{css}");
402        assert!(css.contains(vars_named(AUTO_LIGHT)), "{css}");
403        assert!(css.contains(vars_named(AUTO_DARK)), "{css}");
404    }
405
406    /// Choosing one means choosing it, not preferring it. A fixed theme that still flipped
407    /// with the system setting would be the choice doing nothing.
408    #[test]
409    fn a_fixed_theme_does_not_follow_the_system() {
410        let css = css_for("gruvbox-dark-hard");
411        assert!(!css.contains("prefers-color-scheme"), "{css}");
412        // base16's mapping, not a guess: base00 is the background in every scheme.
413        assert!(css.contains("--bg:#1d2021"), "{css}");
414        assert!(css.contains("--fg:#d5c4a1"), "{css}");
415    }
416
417    /// A light scheme runs base00..base07 the other way, and the same mapping has to serve
418    /// it — which is the property that makes one mapping enough for both.
419    #[test]
420    fn a_light_scheme_maps_the_same_way_round() {
421        let css = css_for("gruvbox-light-hard");
422        assert!(
423            css.contains("--bg:#f9f5d7"),
424            "the background is base00: {css}"
425        );
426        assert!(
427            css.contains("--fg:#504945"),
428            "the foreground is base05: {css}"
429        );
430    }
431
432    #[test]
433    fn an_unknown_theme_renders_as_the_default_rather_than_as_nothing() {
434        assert_eq!(css_for("no-such-theme"), css_for(DEFAULT));
435    }
436
437    /// But it is refused where it is *set*, which is the place that can still say so.
438    #[test]
439    fn an_unknown_theme_is_refused_where_it_is_configured() {
440        let e = check("no-such-theme").expect_err("should be refused");
441        let said = format!("{e}");
442        assert!(said.contains("no-such-theme"), "{said}");
443        assert!(
444            said.contains("nord"),
445            "the error should list the themes: {said}"
446        );
447    }
448
449    #[test]
450    fn the_default_is_a_theme_that_exists() {
451        assert!(exists(DEFAULT));
452        check(DEFAULT).expect("the default must be valid");
453    }
454
455    #[test]
456    fn theme_names_are_unique() {
457        let mut names: Vec<&str> = all().iter().map(|t| t.name.as_str()).collect();
458        names.sort_unstable();
459        let before = names.len();
460        names.dedup();
461        assert_eq!(names.len(), before, "two themes share a name");
462    }
463
464    /// The shapes a base16 file actually comes in, including the trailing comments every
465    /// scheme carries and the quoted values that may hold a `#` of their own.
466    #[test]
467    fn the_parser_reads_the_shapes_these_files_come_in() {
468        assert_eq!(
469            field(r##"  base00: "#1d2021" # ----"##),
470            Some(("base00", "#1d2021".to_string()))
471        );
472        assert_eq!(
473            field(r#"name: "Gruvbox dark, hard""#),
474            Some(("name", "Gruvbox dark, hard".to_string()))
475        );
476        assert_eq!(field("# a whole-line comment"), None);
477        assert_eq!(field(""), None);
478        assert_eq!(field("palette:"), None);
479    }
480
481    #[test]
482    fn base_slots_are_read_as_hex() {
483        assert_eq!(base_index("base00"), Some(0));
484        assert_eq!(base_index("base0F"), Some(15));
485        assert_eq!(base_index("base0f"), Some(15));
486        // base24 goes further; those slots are not ours to map.
487        assert_eq!(base_index("base10"), None);
488        assert_eq!(base_index("name"), None);
489        assert_eq!(base_index("base0"), None);
490    }
491
492    /// A file that stops short renders a listing with holes in it, so it is refused whole.
493    #[test]
494    fn a_scheme_missing_a_colour_is_not_a_scheme() {
495        let short = "system: \"base16\"\nname: \"Short\"\nvariant: \"dark\"\npalette:\n  base00: \"#000000\"\n";
496        assert!(Scheme::parse(short).is_none());
497    }
498}