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}