Skip to main content

talk_core/
palette.rs

1#[derive(Clone, Copy, Debug, PartialEq, Eq)]
2pub struct Rgb { pub r: u8, pub g: u8, pub b: u8 }
3
4impl Rgb {
5    pub const fn new(r: u8, g: u8, b: u8) -> Self { Rgb { r, g, b } }
6}
7
8/// The talk pillar brand tone — rust, from pilgrim-ios rust.colorset (light). The
9/// rendered `rust` palette is a brighter dark-terminal variant of this anchor.
10pub const RUST: Rgb = Rgb::new(160, 99, 75);
11
12/// One paintable tone. `Color` is an explicit RGB; the `Terminal*` variants defer to
13/// the terminal's own foreground so `mono` matches any background.
14#[derive(Clone, Copy, Debug, PartialEq, Eq)]
15pub enum Tone {
16    Color(Rgb),
17    Terminal,
18    TerminalFaint,
19}
20
21/// A named, pinnable palette.
22#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
23pub enum Theme {
24    #[default]
25    Rust,
26    HighContrast,
27    Mono,
28}
29
30impl Theme {
31    pub const NAMES: [&'static str; 3] = ["rust", "high-contrast", "mono"];
32
33    /// Parse a config string (trimmed, case-insensitive). `None` for unknown names.
34    #[allow(clippy::should_implement_trait)] // inherent parser returns Option, not FromStr's Result
35    pub fn from_str(s: &str) -> Option<Theme> {
36        match s.trim().to_ascii_lowercase().as_str() {
37            "rust" => Some(Theme::Rust),
38            "high-contrast" => Some(Theme::HighContrast),
39            "mono" => Some(Theme::Mono),
40            _ => None,
41        }
42    }
43}
44
45/// The three tones a renderer paints from: `core` = settled text (brightest), `dim` =
46/// the live edge and the question (mid), `edge` = borders/header/status (quietest).
47#[derive(Clone, Copy, Debug, PartialEq, Eq)]
48pub struct Palette {
49    pub core: Tone,
50    pub dim: Tone,
51    pub edge: Tone,
52}
53
54/// The tones for a theme. The `Color` triples are tuned to clear the WCAG contrast
55/// targets in the palette tests against a representative dark background; they may be
56/// re-tuned for the demo as long as those tests stay green.
57pub fn palette(theme: Theme) -> Palette {
58    use Tone::{Color, Terminal, TerminalFaint};
59    match theme {
60        Theme::Rust => Palette {
61            core: Color(Rgb::new(210, 146, 118)),
62            dim: Color(Rgb::new(170, 124, 104)),
63            edge: Color(Rgb::new(150, 122, 112)),
64        },
65        Theme::HighContrast => Palette {
66            core: Color(Rgb::new(236, 205, 186)),
67            dim: Color(Rgb::new(198, 152, 124)),
68            edge: Color(Rgb::new(176, 142, 126)),
69        },
70        Theme::Mono => Palette { core: Terminal, dim: TerminalFaint, edge: TerminalFaint },
71    }
72}
73
74#[cfg(test)]
75mod tests {
76    use super::*;
77
78    /// WCAG sRGB relative luminance (0.0–1.0).
79    fn rel_lum(c: Rgb) -> f64 {
80        fn ch(v: u8) -> f64 {
81            let s = v as f64 / 255.0;
82            if s <= 0.03928 { s / 12.92 } else { ((s + 0.055) / 1.055).powf(2.4) }
83        }
84        0.2126 * ch(c.r) + 0.7152 * ch(c.g) + 0.0722 * ch(c.b)
85    }
86    fn contrast(fg: Rgb, bg: Rgb) -> f64 {
87        let (a, b) = (rel_lum(fg), rel_lum(bg));
88        let (hi, lo) = if a >= b { (a, b) } else { (b, a) };
89        (hi + 0.05) / (lo + 0.05)
90    }
91    fn rgb_of(t: Tone) -> Rgb {
92        match t { Tone::Color(c) => c, _ => panic!("expected a Color tone") }
93    }
94
95    /// A representative "light" dark-terminal background (One Dark) — the worst
96    /// case among dark terminals for bright foreground tones.
97    const DARK_BG: Rgb = Rgb::new(40, 44, 52);
98
99    #[test]
100    fn rust_is_the_brand_anchor() {
101        assert_eq!(RUST, Rgb::new(160, 99, 75));
102    }
103
104    #[test]
105    fn color_palettes_clear_the_contrast_targets() {
106        for theme in [Theme::Rust, Theme::HighContrast] {
107            let p = palette(theme);
108            assert!(contrast(rgb_of(p.core), DARK_BG) >= 4.5, "{theme:?} core too low");
109            assert!(contrast(rgb_of(p.dim), DARK_BG) >= 3.0, "{theme:?} dim too low");
110            assert!(contrast(rgb_of(p.edge), DARK_BG) >= 3.0, "{theme:?} edge too low");
111        }
112    }
113
114    #[test]
115    fn tones_keep_their_brightness_order() {
116        for theme in [Theme::Rust, Theme::HighContrast] {
117            let p = palette(theme);
118            assert!(rel_lum(rgb_of(p.core)) >= rel_lum(rgb_of(p.dim)));
119            assert!(rel_lum(rgb_of(p.dim)) >= rel_lum(rgb_of(p.edge)));
120        }
121    }
122
123    #[test]
124    fn mono_uses_the_terminal_foreground() {
125        let p = palette(Theme::Mono);
126        assert_eq!(p.core, Tone::Terminal);
127        assert_eq!(p.dim, Tone::TerminalFaint);
128        assert_eq!(p.edge, Tone::TerminalFaint);
129    }
130
131    #[test]
132    fn theme_from_str_parses_canonical_names() {
133        assert_eq!(Theme::from_str("rust"), Some(Theme::Rust));
134        assert_eq!(Theme::from_str("high-contrast"), Some(Theme::HighContrast));
135        assert_eq!(Theme::from_str("  Mono "), Some(Theme::Mono));
136        assert_eq!(Theme::from_str("bogus"), None);
137        assert_eq!(Theme::default(), Theme::Rust);
138    }
139}