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}