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            // base08 is what every scheme paints an error in, base0B what it paints a string
265            // in. The dashboard had its own red and green, written as two hex values no
266            // palette had a say in — which is why choosing a dark theme turned the daemon's
267            // pages dark and left the dashboard white.
268            format!("--bad:{}", c(0x8)),
269            format!("--good:{}", c(0xB)),
270        ]
271        .join(";")
272    }
273}
274
275/// `key: "value"` or `key: value`, with a trailing `# comment` dropped.
276fn field(line: &str) -> Option<(&str, String)> {
277    let line = line.trim();
278    if line.is_empty() || line.starts_with('#') {
279        return None;
280    }
281    let (key, rest) = line.split_once(':')?;
282    let rest = rest.trim();
283    let value = match rest.strip_prefix('"') {
284        // Quoted: everything to the closing quote, so a `#` inside it survives.
285        Some(quoted) => quoted.split('"').next()?,
286        // Bare: everything before a comment.
287        None => rest.split('#').next()?.trim(),
288    };
289    (!value.is_empty()).then(|| (key.trim(), value.to_string()))
290}
291
292/// `base00`..`base0F` to `0`..`15`.
293fn base_index(key: &str) -> Option<usize> {
294    let digits = key.strip_prefix("base")?;
295    (digits.len() == 2)
296        .then(|| usize::from_str_radix(digits, 16).ok())
297        .flatten()
298        .filter(|slot| *slot < 16)
299}
300
301/// Where a chosen theme is remembered between runs.
302///
303/// Beside the token rather than in the user's `config.toml`. That file is hand-written and
304/// carries their comments, and a daemon that rewrote it would eventually lose one. The
305/// config file still sets the starting value; this records a later change of mind.
306fn stored_path() -> Option<PathBuf> {
307    Some(crate::control::state_dir()?.join("theme"))
308}
309
310/// The remembered theme, if there is one and it still exists.
311///
312/// A name that no longer names a theme is ignored rather than refused: it means this file
313/// outlived a scheme being dropped, and refusing to start over a colour would be absurd.
314pub fn remembered() -> Option<String> {
315    let name = std::fs::read_to_string(stored_path()?).ok()?;
316    let name = name.trim().to_string();
317    exists(&name).then_some(name)
318}
319
320/// Remember a theme, and say where it went.
321pub fn remember(name: &str) -> Result<PathBuf> {
322    check(name)?;
323    let path = match stored_path() {
324        Some(path) => path,
325        None => bail!("no directory to remember a theme in"),
326    };
327    if let Some(dir) = path.parent() {
328        std::fs::create_dir_all(dir)?;
329    }
330    std::fs::write(&path, format!("{name}\n"))?;
331    Ok(path)
332}
333
334#[cfg(test)]
335mod tests {
336    use super::*;
337
338    /// Every vendored file, through the real parser. A scheme that will not parse is
339    /// dropped silently at startup by design — there is nothing useful to do about it while
340    /// serving a page — so this is the only thing standing between a bad file and a theme
341    /// that quietly does not exist.
342    #[test]
343    fn every_vendored_scheme_parses() {
344        for (name, text) in SCHEMES {
345            let scheme = Scheme::parse(text).unwrap_or_else(|| panic!("{name} did not parse"));
346            assert!(!scheme.label.is_empty(), "{name} has no label");
347            for (slot, colour) in scheme.palette.iter().enumerate() {
348                assert!(
349                    colour.starts_with('#') && colour.len() == 7,
350                    "{name} base{slot:02X} is {colour:?}, which is not a hex colour"
351                );
352            }
353        }
354        assert_eq!(
355            themes().len(),
356            SCHEMES.len() + 1,
357            "one of the vendored schemes was dropped, plus auto"
358        );
359    }
360
361    /// The layout is written against these, so a palette missing one renders a listing with
362    /// an unset colour — not a visual nit, invisible text.
363    #[test]
364    fn every_theme_sets_every_variable() {
365        let wanted = [
366            "--bg",
367            "--fg",
368            "--dim",
369            "--faint",
370            "--line",
371            "--hover",
372            "--sel",
373            "--accent",
374            "--k-page",
375            "--k-doc",
376            "--k-data",
377            "--k-code",
378            "--k-media",
379            "--k-plain",
380            "--bad",
381            "--good",
382        ];
383        for theme in all() {
384            let css = css_for(&theme.name);
385            for var in wanted {
386                assert!(
387                    css.contains(&format!("{var}:")),
388                    "{} is missing {var}",
389                    theme.name
390                );
391            }
392        }
393    }
394
395    /// Both halves of the curated set are there, so choosing "light" is a real choice and
396    /// not a list of dark schemes with one exception.
397    #[test]
398    fn the_curated_set_has_light_and_dark() {
399        let light = all().iter().filter(|t| t.variant == "light").count();
400        let dark = all().iter().filter(|t| t.variant == "dark").count();
401        assert!(light >= 6, "only {light} light schemes");
402        assert!(dark >= 6, "only {dark} dark schemes");
403    }
404
405    /// The default follows the system, and following the system means shipping both.
406    #[test]
407    fn the_default_carries_a_light_and_a_dark_palette() {
408        let css = css_for(DEFAULT);
409        assert!(css.contains("prefers-color-scheme:dark"), "{css}");
410        assert!(css.contains(vars_named(AUTO_LIGHT)), "{css}");
411        assert!(css.contains(vars_named(AUTO_DARK)), "{css}");
412    }
413
414    /// Choosing one means choosing it, not preferring it. A fixed theme that still flipped
415    /// with the system setting would be the choice doing nothing.
416    #[test]
417    fn a_fixed_theme_does_not_follow_the_system() {
418        let css = css_for("gruvbox-dark-hard");
419        assert!(!css.contains("prefers-color-scheme"), "{css}");
420        // base16's mapping, not a guess: base00 is the background in every scheme.
421        assert!(css.contains("--bg:#1d2021"), "{css}");
422        assert!(css.contains("--fg:#d5c4a1"), "{css}");
423    }
424
425    /// A light scheme runs base00..base07 the other way, and the same mapping has to serve
426    /// it — which is the property that makes one mapping enough for both.
427    #[test]
428    fn a_light_scheme_maps_the_same_way_round() {
429        let css = css_for("gruvbox-light-hard");
430        assert!(
431            css.contains("--bg:#f9f5d7"),
432            "the background is base00: {css}"
433        );
434        assert!(
435            css.contains("--fg:#504945"),
436            "the foreground is base05: {css}"
437        );
438    }
439
440    #[test]
441    fn an_unknown_theme_renders_as_the_default_rather_than_as_nothing() {
442        assert_eq!(css_for("no-such-theme"), css_for(DEFAULT));
443    }
444
445    /// But it is refused where it is *set*, which is the place that can still say so.
446    #[test]
447    fn an_unknown_theme_is_refused_where_it_is_configured() {
448        let e = check("no-such-theme").expect_err("should be refused");
449        let said = format!("{e}");
450        assert!(said.contains("no-such-theme"), "{said}");
451        assert!(
452            said.contains("nord"),
453            "the error should list the themes: {said}"
454        );
455    }
456
457    #[test]
458    fn the_default_is_a_theme_that_exists() {
459        assert!(exists(DEFAULT));
460        check(DEFAULT).expect("the default must be valid");
461    }
462
463    #[test]
464    fn theme_names_are_unique() {
465        let mut names: Vec<&str> = all().iter().map(|t| t.name.as_str()).collect();
466        names.sort_unstable();
467        let before = names.len();
468        names.dedup();
469        assert_eq!(names.len(), before, "two themes share a name");
470    }
471
472    /// The shapes a base16 file actually comes in, including the trailing comments every
473    /// scheme carries and the quoted values that may hold a `#` of their own.
474    #[test]
475    fn the_parser_reads_the_shapes_these_files_come_in() {
476        assert_eq!(
477            field(r##"  base00: "#1d2021" # ----"##),
478            Some(("base00", "#1d2021".to_string()))
479        );
480        assert_eq!(
481            field(r#"name: "Gruvbox dark, hard""#),
482            Some(("name", "Gruvbox dark, hard".to_string()))
483        );
484        assert_eq!(field("# a whole-line comment"), None);
485        assert_eq!(field(""), None);
486        assert_eq!(field("palette:"), None);
487    }
488
489    #[test]
490    fn base_slots_are_read_as_hex() {
491        assert_eq!(base_index("base00"), Some(0));
492        assert_eq!(base_index("base0F"), Some(15));
493        assert_eq!(base_index("base0f"), Some(15));
494        // base24 goes further; those slots are not ours to map.
495        assert_eq!(base_index("base10"), None);
496        assert_eq!(base_index("name"), None);
497        assert_eq!(base_index("base0"), None);
498    }
499
500    /// A file that stops short renders a listing with holes in it, so it is refused whole.
501    #[test]
502    fn a_scheme_missing_a_colour_is_not_a_scheme() {
503        let short = "system: \"base16\"\nname: \"Short\"\nvariant: \"dark\"\npalette:\n  base00: \"#000000\"\n";
504        assert!(Scheme::parse(short).is_none());
505    }
506}