Skip to main content

oopsie/
theme.rs

1//! Color themes for the whole crash report — error chain, span trace,
2//! backtrace, and panic output.
3//!
4//! A [`Theme`] is a small named palette; its `const fn` role accessors
5//! ([`Theme::function_name`], [`Theme::line_number`], …) map each part of a
6//! report onto a palette color. The shipped themes are curated presets, exposed
7//! as associated constants ([`Theme::CATPPUCCIN_MOCHA`], [`Theme::DRACULA`],
8//! [`Theme::NORD`], …); pick one. The active default is read from a
9//! process-global slot via [`get_theme`] and replaced with [`set_theme`].
10
11use std::sync::{PoisonError, RwLock};
12
13use crate::style::Style;
14use crate::trace_printer::TraceTheme;
15
16/// A color theme: nine named colors plus the role accessors that paint a
17/// report from them. Small and `Copy`.
18///
19/// Choose one of the shipped presets (the associated constants) and install it
20/// with [`set_theme`]; the palette colors are intentionally opaque.
21#[derive(Clone, Copy, Debug, Eq, PartialEq)]
22pub struct Theme {
23    red: (u8, u8, u8),
24    peach: (u8, u8, u8),
25    yellow: (u8, u8, u8),
26    teal: (u8, u8, u8),
27    sky: (u8, u8, u8),
28    blue: (u8, u8, u8),
29    mauve: (u8, u8, u8),
30    overlay1: (u8, u8, u8),
31    overlay0: (u8, u8, u8),
32}
33
34const _: () = assert!(
35    std::mem::size_of::<Theme>() <= 32,
36    "Theme is copied on every get_theme(); keep it a small palette",
37);
38
39/// Parse a `#rrggbb` literal at compile time. Written as a `&str` so editors
40/// render the color swatch inline next to each preset.
41const fn hex(literal: &str) -> (u8, u8, u8) {
42    // Require exact #rrggbb: a bare hex parse would silently accept shorthand or
43    // a missing `#` as a *different* color instead of failing the build.
44    assert!(
45        literal.len() == 7 && literal.as_bytes()[0] == b'#',
46        "color literal must be in #rrggbb form",
47    );
48    let (_, digits) = literal.split_at(1);
49    let Ok(value) = u32::from_str_radix(digits, 16) else {
50        panic!("color literal has a non-hex digit")
51    };
52    (
53        ((value >> 16) & 0xFF) as u8,
54        ((value >> 8) & 0xFF) as u8,
55        (value & 0xFF) as u8,
56    )
57}
58
59const fn style((r, g, b): (u8, u8, u8)) -> Style {
60    Style::new().truecolor(r, g, b)
61}
62
63impl Theme {
64    /// Catppuccin Mocha — the dark default. <https://catppuccin.com/palette>
65    pub const CATPPUCCIN_MOCHA: Self = Self {
66        red: hex("#f38ba8"),
67        peach: hex("#fab387"),
68        yellow: hex("#f9e2af"),
69        teal: hex("#94e2d5"),
70        sky: hex("#89dceb"),
71        blue: hex("#89b4fa"),
72        mauve: hex("#cba6f7"),
73        overlay1: hex("#7f849c"),
74        overlay0: hex("#6c7086"),
75    };
76
77    /// Catppuccin Macchiato — a softer, warmer dark flavor.
78    pub const CATPPUCCIN_MACCHIATO: Self = Self {
79        red: hex("#ed8796"),
80        peach: hex("#f5a97f"),
81        yellow: hex("#eed49f"),
82        teal: hex("#8bd5ca"),
83        sky: hex("#91d7e3"),
84        blue: hex("#8aadf4"),
85        mauve: hex("#c6a0f6"),
86        overlay1: hex("#8087a2"),
87        overlay0: hex("#6e738d"),
88    };
89
90    /// Catppuccin Frappé — the lightest of the dark flavors.
91    pub const CATPPUCCIN_FRAPPE: Self = Self {
92        red: hex("#e78284"),
93        peach: hex("#ef9f76"),
94        yellow: hex("#e5c890"),
95        teal: hex("#81c8be"),
96        sky: hex("#99d1db"),
97        blue: hex("#8caaee"),
98        mauve: hex("#ca9ee6"),
99        overlay1: hex("#838ba7"),
100        overlay0: hex("#737994"),
101    };
102
103    /// Catppuccin Latte — the light flavor, for light-background terminals.
104    pub const CATPPUCCIN_LATTE: Self = Self {
105        red: hex("#d20f39"),
106        peach: hex("#fe640b"),
107        yellow: hex("#df8e1d"),
108        teal: hex("#179299"),
109        sky: hex("#04a5e5"),
110        blue: hex("#1e66f5"),
111        mauve: hex("#8839ef"),
112        overlay1: hex("#8c8fa1"),
113        overlay0: hex("#9ca0b0"),
114    };
115
116    /// Dracula. <https://draculatheme.com>
117    pub const DRACULA: Self = Self {
118        red: hex("#ff5555"),
119        peach: hex("#ffb86c"),
120        yellow: hex("#f1fa8c"),
121        teal: hex("#8be9fd"),
122        sky: hex("#8be9fd"),
123        blue: hex("#bd93f9"),
124        mauve: hex("#bd93f9"),
125        overlay1: hex("#6272a4"),
126        overlay0: hex("#6272a4"),
127    };
128
129    /// Nord. <https://www.nordtheme.com>
130    pub const NORD: Self = Self {
131        red: hex("#bf616a"),
132        peach: hex("#d08770"),
133        yellow: hex("#ebcb8b"),
134        teal: hex("#8fbcbb"),
135        sky: hex("#88c0d0"),
136        blue: hex("#81a1c1"),
137        mauve: hex("#b48ead"),
138        overlay1: hex("#4c566a"),
139        overlay0: hex("#4c566a"),
140    };
141
142    /// The active default — Catppuccin Mocha.
143    pub const DEFAULT: Self = Self::CATPPUCCIN_MOCHA;
144
145    // ── Trace roles ─────────────────────────────────────────────────────────
146
147    /// Backtrace/span-trace frame index.
148    #[must_use]
149    pub const fn frame_number(&self) -> Style {
150        style(self.overlay1)
151    }
152    /// Function / span name.
153    #[must_use]
154    pub const fn function_name(&self) -> Style {
155        style(self.red)
156    }
157    /// The `::h…` hash suffix on a function name.
158    #[must_use]
159    pub const fn function_hash(&self) -> Style {
160        style(self.overlay0)
161    }
162    /// Source file path.
163    #[must_use]
164    pub const fn file_path(&self) -> Style {
165        style(self.mauve)
166    }
167    /// `:line:col` location.
168    #[must_use]
169    pub const fn line_number(&self) -> Style {
170        style(self.peach)
171    }
172    /// Inline separators (`at`, `:`, `with`).
173    #[must_use]
174    pub const fn separator(&self) -> Style {
175        style(self.overlay0)
176    }
177    /// Span field list.
178    #[must_use]
179    pub const fn fields(&self) -> Style {
180        style(self.sky)
181    }
182    /// The `━ BACKTRACE ━` / `━ SPANTRACE ━` banner.
183    #[must_use]
184    pub const fn header(&self) -> Style {
185        style(self.mauve)
186    }
187    /// The `… N frames hidden …` notice.
188    #[must_use]
189    pub const fn frames_hidden(&self) -> Style {
190        style(self.teal)
191    }
192
193    // ── Error-chain / panic roles ───────────────────────────────────────────
194
195    /// The `Error` headline, and the panic header line.
196    #[must_use]
197    pub const fn error_title(&self) -> Style {
198        style(self.red).bold()
199    }
200    /// The `[error_code]` token next to the headline.
201    #[must_use]
202    pub const fn error_code(&self) -> Style {
203        style(self.blue)
204    }
205    /// The brackets around the error code.
206    #[must_use]
207    pub const fn delimiter(&self) -> Style {
208        style(self.overlay0)
209    }
210    /// The `├─▶` / `╰─▶` arrows joining the error chain.
211    #[must_use]
212    pub const fn cause(&self) -> Style {
213        style(self.yellow)
214    }
215    /// The `help` label.
216    #[must_use]
217    pub const fn help(&self) -> Style {
218        style(self.teal)
219    }
220    /// The panic message body.
221    #[must_use]
222    pub const fn panic_message(&self) -> Style {
223        style(self.sky)
224    }
225    /// The panic location (`file:line:col`).
226    #[must_use]
227    pub const fn panic_location(&self) -> Style {
228        style(self.mauve)
229    }
230    /// Dimmed hints, e.g. the `RUST_BACKTRACE` note.
231    #[must_use]
232    pub const fn hint(&self) -> Style {
233        style(self.overlay0)
234    }
235
236    /// Bundle the trace roles into a [`TraceTheme`] for [`TracePrinter`].
237    ///
238    /// [`TracePrinter`]: crate::trace_printer::TracePrinter
239    #[must_use]
240    pub const fn trace(&self) -> TraceTheme {
241        TraceTheme {
242            frame_number: self.frame_number(),
243            function_name: self.function_name(),
244            function_hash: self.function_hash(),
245            file_path: self.file_path(),
246            line_number: self.line_number(),
247            separator: self.separator(),
248            fields: self.fields(),
249            header: self.header(),
250            frames_hidden: self.frames_hidden(),
251        }
252    }
253}
254
255static THEME: RwLock<Theme> = RwLock::new(Theme::DEFAULT);
256
257/// Replace the process-global theme used by [`Report`](crate::Report) and the
258/// panic hook. Affects every report that doesn't carry a per-instance override.
259#[inline]
260pub fn set_theme(theme: Theme) {
261    *THEME.write().unwrap_or_else(PoisonError::into_inner) = theme;
262}
263
264/// The current process-global theme.
265#[must_use]
266#[inline]
267pub fn get_theme() -> Theme {
268    *THEME.read().unwrap_or_else(PoisonError::into_inner)
269}
270
271#[cfg(test)]
272mod tests {
273    use super::*;
274
275    #[test]
276    fn set_get_theme_roundtrips() {
277        let original = get_theme();
278        set_theme(Theme::DRACULA);
279        assert_eq!(get_theme(), Theme::DRACULA);
280        set_theme(Theme::NORD);
281        assert_eq!(get_theme(), Theme::NORD);
282        set_theme(original);
283    }
284}