Skip to main content

facet_pretty/
color.rs

1//! Color palettes and terminal theme detection for pretty-printing.
2//!
3//! The palette is inspired by [Melange](https://github.com/savq/melange-nvim)
4//! by Sergio Alquérèque — a warm, low-contrast colour scheme with matched
5//! dark and light variants. By default ([`Theme::Auto`]) the terminal
6//! background is detected once and the appropriate variant is used.
7
8use core::hash::{Hash, Hasher};
9use owo_colors::Rgb;
10use std::hash::DefaultHasher;
11use std::sync::LazyLock;
12
13/// A complete set of semantic colours used by the pretty-printer.
14///
15/// Each field maps a syntactic role to an RGB colour. Two faithful Melange
16/// variants are provided as [`Palette::MELANGE_DARK`] and
17/// [`Palette::MELANGE_LIGHT`]; custom palettes can be supplied via
18/// [`Theme::Custom`].
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub struct Palette {
21    /// Default text (struct/enum field names, plain identifiers).
22    pub foreground: Rgb,
23    /// Type names (struct, enum, container names) — rendered bold.
24    pub type_name: Rgb,
25    /// Struct field / map key labels.
26    pub field_name: Rgb,
27    /// String literals.
28    pub string: Rgb,
29    /// Numeric literals.
30    pub number: Rgb,
31    /// Keywords such as `true`, `false`, `null`.
32    pub keyword: Rgb,
33    /// Comments and elided/omitted markers.
34    pub comment: Rgb,
35    /// Punctuation and structural delimiters (braces, colons, commas).
36    pub punctuation: Rgb,
37    /// Redacted values and error markers.
38    pub error: Rgb,
39    /// Removed content (diffs).
40    pub deletion: Rgb,
41    /// Added content (diffs).
42    pub insertion: Rgb,
43    /// Distinct accents used to give each scalar type its own hue.
44    pub accents: [Rgb; 6],
45}
46
47impl Palette {
48    /// Melange dark — for terminals with a dark background.
49    ///
50    /// Values are taken verbatim from the official `melange-nvim` dark
51    /// palette and mapped to syntactic roles following its highlight groups.
52    pub const MELANGE_DARK: Self = Self {
53        foreground: Rgb(236, 225, 215), // a.fg  #ECE1D7
54        type_name: Rgb(123, 150, 149),  // c.cyan #7B9695 (Type)
55        field_name: Rgb(236, 225, 215), // a.fg  #ECE1D7 (Identifier)
56        string: Rgb(163, 169, 206),     // b.blue #A3A9CE (String)
57        number: Rgb(207, 155, 194),     // b.magenta #CF9BC2 (Number)
58        keyword: Rgb(207, 155, 194),    // b.magenta #CF9BC2 (Boolean)
59        comment: Rgb(193, 167, 142),    // a.com #C1A78E (Comment)
60        punctuation: Rgb(134, 116, 98), // a.ui  #867462 (muted UI)
61        error: Rgb(212, 119, 102),      // b.red #D47766
62        deletion: Rgb(189, 129, 131),   // c.red #BD8183
63        insertion: Rgb(133, 182, 149),  // b.green #85B695
64        accents: [
65            Rgb(212, 119, 102), // b.red     #D47766
66            Rgb(235, 192, 109), // b.yellow  #EBC06D
67            Rgb(133, 182, 149), // b.green   #85B695
68            Rgb(137, 179, 182), // b.cyan    #89B3B6
69            Rgb(163, 169, 206), // b.blue    #A3A9CE
70            Rgb(207, 155, 194), // b.magenta #CF9BC2
71        ],
72    };
73
74    /// Melange light — for terminals with a light background.
75    ///
76    /// Values are taken verbatim from the official `melange-nvim` light
77    /// palette and mapped to syntactic roles following its highlight groups.
78    pub const MELANGE_LIGHT: Self = Self {
79        foreground: Rgb(84, 67, 58),     // a.fg  #54433A
80        type_name: Rgb(115, 151, 151),   // c.cyan #739797 (Type)
81        field_name: Rgb(84, 67, 58),     // a.fg  #54433A (Identifier)
82        string: Rgb(70, 90, 164),        // b.blue #465AA4 (String)
83        number: Rgb(144, 65, 128),       // b.magenta #904180 (Number)
84        keyword: Rgb(144, 65, 128),      // b.magenta #904180 (Boolean)
85        comment: Rgb(125, 102, 88),      // a.com #7D6658 (Comment)
86        punctuation: Rgb(169, 138, 120), // a.ui  #A98A78 (muted UI)
87        error: Rgb(191, 0, 33),          // b.red #BF0021
88        deletion: Rgb(199, 123, 139),    // c.red #C77B8B
89        insertion: Rgb(58, 104, 74),     // b.green #3A684A
90        accents: [
91            Rgb(191, 0, 33),   // b.red     #BF0021
92            Rgb(160, 109, 0),  // b.yellow  #A06D00
93            Rgb(58, 104, 74),  // b.green   #3A684A
94            Rgb(61, 101, 104), // b.cyan    #3D6568
95            Rgb(70, 90, 164),  // b.blue    #465AA4
96            Rgb(144, 65, 128), // b.magenta #904180
97        ],
98    };
99
100    /// Pick a stable accent colour for a hash value.
101    ///
102    /// Used to give each distinct scalar shape its own hue while staying
103    /// within the palette's aesthetic.
104    pub fn accent(&self, hash: u64) -> Rgb {
105        self.accents[(hash % self.accents.len() as u64) as usize]
106    }
107}
108
109/// Which colour palette the pretty-printer should use.
110#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
111pub enum Theme {
112    /// Detect the terminal background once and pick the matching Melange
113    /// variant (light or dark). Falls back to dark when detection is
114    /// unavailable.
115    #[default]
116    Auto,
117    /// Always use [`Palette::MELANGE_DARK`].
118    Dark,
119    /// Always use [`Palette::MELANGE_LIGHT`].
120    Light,
121    /// Use a caller-supplied palette.
122    Custom(Palette),
123}
124
125impl Theme {
126    /// Resolve this theme to a concrete [`Palette`].
127    ///
128    /// For [`Theme::Auto`] the result is detected once per process and
129    /// cached.
130    pub fn palette(&self) -> Palette {
131        match self {
132            Theme::Auto => detected_palette(),
133            Theme::Dark => Palette::MELANGE_DARK,
134            Theme::Light => Palette::MELANGE_LIGHT,
135            Theme::Custom(palette) => *palette,
136        }
137    }
138}
139
140/// The palette chosen by [`Theme::Auto`], detected once per process.
141///
142/// Detection order:
143/// 1. The `FACET_PRETTY_THEME` environment variable (`dark` / `light`).
144/// 2. The terminal background colour, queried via the `terminal-light`
145///    crate (only when the `detect-terminal-theme` feature is enabled and
146///    stdout is a terminal).
147/// 3. Dark, as a safe default.
148pub fn detected_palette() -> Palette {
149    static DETECTED: LazyLock<Palette> = LazyLock::new(detect);
150    *DETECTED
151}
152
153fn detect() -> Palette {
154    match std::env::var("FACET_PRETTY_THEME") {
155        Ok(v) if v.eq_ignore_ascii_case("light") => return Palette::MELANGE_LIGHT,
156        Ok(v) if v.eq_ignore_ascii_case("dark") => return Palette::MELANGE_DARK,
157        _ => {}
158    }
159
160    if terminal_is_light() {
161        Palette::MELANGE_LIGHT
162    } else {
163        Palette::MELANGE_DARK
164    }
165}
166
167#[cfg(feature = "detect-terminal-theme")]
168fn terminal_is_light() -> bool {
169    use std::io::IsTerminal;
170
171    // Querying the terminal writes an OSC escape sequence to stdout and
172    // reads the reply from /dev/tty. Only do this when stdout is an
173    // interactive terminal, otherwise we would corrupt piped output.
174    if !std::io::stdout().is_terminal() {
175        return false;
176    }
177
178    // luma() returns 0 (black) .. 1 (white); 0.6 is the recommended pivot
179    // between "rather dark" and "rather light".
180    terminal_light::luma()
181        .map(|luma| luma > 0.6)
182        .unwrap_or(false)
183}
184
185#[cfg(not(feature = "detect-terminal-theme"))]
186fn terminal_is_light() -> bool {
187    false
188}
189
190/// RGB color representation.
191///
192/// Deprecated re-export shim: superseded by [`Palette`] /
193/// [`owo_colors::Rgb`] after the Melange palette migration. Kept so
194/// pre-0.47 callers keep compiling.
195#[deprecated(
196    since = "0.46.3",
197    note = "use `Palette` / `owo_colors::Rgb`; colours now come from `Theme`/`Palette`"
198)]
199#[derive(Debug, Clone, Copy, PartialEq, Eq)]
200pub struct RGB {
201    /// Red component (0-255)
202    pub r: u8,
203    /// Green component (0-255)
204    pub g: u8,
205    /// Blue component (0-255)
206    pub b: u8,
207}
208
209#[allow(deprecated)]
210impl RGB {
211    /// Create a new RGB color
212    pub const fn new(r: u8, g: u8, b: u8) -> Self {
213        Self { r, g, b }
214    }
215
216    /// Write the RGB color as ANSI foreground color code to the formatter
217    pub fn write_fg<W: core::fmt::Write>(&self, f: &mut W) -> core::fmt::Result {
218        write!(f, "\x1b[38;2;{};{};{}m", self.r, self.g, self.b)
219    }
220
221    /// Write the RGB color as ANSI background color code to the formatter
222    pub fn write_bg<W: core::fmt::Write>(&self, f: &mut W) -> core::fmt::Result {
223        write!(f, "\x1b[48;2;{};{};{}m", self.r, self.g, self.b)
224    }
225}
226
227/// A color generator that produces unique colors based on a hash value.
228///
229/// Deprecated re-export shim: colours now come from [`Palette`] / [`Theme`]
230/// (`Palette::accent` for per-value hues). Kept so pre-0.47 callers keep
231/// compiling.
232#[deprecated(
233    since = "0.46.3",
234    note = "colours now come from `Theme`/`Palette`; use `Palette::accent` for per-value hues"
235)]
236#[derive(Clone, PartialEq)]
237pub struct ColorGenerator {
238    base_hue: f32,
239    saturation: f32,
240    lightness: f32,
241}
242
243#[allow(deprecated)]
244impl Default for ColorGenerator {
245    fn default() -> Self {
246        Self::new()
247    }
248}
249
250#[allow(deprecated)]
251impl ColorGenerator {
252    /// Create a new color generator with default settings
253    pub const fn new() -> Self {
254        Self {
255            base_hue: 210.0,
256            saturation: 0.7,
257            lightness: 0.6,
258        }
259    }
260
261    /// Set the base hue (0-360)
262    pub const fn with_base_hue(mut self, hue: f32) -> Self {
263        self.base_hue = hue;
264        self
265    }
266
267    /// Set the saturation (0.0-1.0)
268    pub const fn with_saturation(mut self, saturation: f32) -> Self {
269        self.saturation = saturation.clamp(0.0, 1.0);
270        self
271    }
272
273    /// Set the lightness (0.0-1.0)
274    pub const fn with_lightness(mut self, lightness: f32) -> Self {
275        self.lightness = lightness.clamp(0.0, 1.0);
276        self
277    }
278
279    /// Generate an RGB color based on a hash value
280    pub const fn generate_color(&self, hash: u64) -> RGB {
281        // Use the hash to generate a hue offset
282        let hue_offset = (hash % 360) as f32;
283        let hue = (self.base_hue + hue_offset) % 360.0;
284
285        // Convert HSL to RGB
286        self.hsl_to_rgb(hue, self.saturation, self.lightness)
287    }
288
289    /// Generate an RGB color based on a hashable value
290    pub fn generate_color_for<T: Hash>(&self, value: &T) -> RGB {
291        let mut hasher = DefaultHasher::new();
292        value.hash(&mut hasher);
293        let hash = hasher.finish();
294        self.generate_color(hash)
295    }
296
297    /// Convert HSL color values to RGB
298    const fn hsl_to_rgb(&self, h: f32, s: f32, l: f32) -> RGB {
299        let c = (1.0 - (2.0 * l - 1.0).abs()) * s;
300        let x = c * (1.0 - ((h / 60.0) % 2.0 - 1.0).abs());
301        let m = l - c / 2.0;
302
303        let (r, g, b) = match h as u32 {
304            0..=59 => (c, x, 0.0),
305            60..=119 => (x, c, 0.0),
306            120..=179 => (0.0, c, x),
307            180..=239 => (0.0, x, c),
308            240..=299 => (x, 0.0, c),
309            _ => (c, 0.0, x),
310        };
311
312        RGB::new(
313            ((r + m) * 255.0) as u8,
314            ((g + m) * 255.0) as u8,
315            ((b + m) * 255.0) as u8,
316        )
317    }
318}
319
320#[cfg(test)]
321mod tests {
322    use super::*;
323
324    #[test]
325    fn accent_is_stable_and_in_palette() {
326        let p = Palette::MELANGE_DARK;
327        assert_eq!(p.accent(42), p.accent(42));
328        assert!(p.accents.contains(&p.accent(123_456_789)));
329    }
330
331    #[test]
332    fn theme_resolves_to_expected_palette() {
333        assert_eq!(Theme::Dark.palette(), Palette::MELANGE_DARK);
334        assert_eq!(Theme::Light.palette(), Palette::MELANGE_LIGHT);
335        let custom = Palette {
336            foreground: Rgb(1, 2, 3),
337            ..Palette::MELANGE_DARK
338        };
339        assert_eq!(Theme::Custom(custom).palette(), custom);
340    }
341}