Skip to main content

ggsci_ratatui/
lib.rs

1//! Adapt [ggsci] palettes to [`ratatui_core`] colors and styles.
2//!
3//! Core palettes can be sampled with [`colors`], while the dedicated
4//! [`iterm_colors`] and [`gephi_colors`] helpers preserve the distinct inputs
5//! required by fixed iTerm themes and generative Gephi palettes. All three
6//! sources still use discrete or continuous scale semantics from [ggsci].
7
8use ratatui_core::style::{Color, Style};
9
10const ANSI_CUBE_LEVELS: [u8; 6] = [0, 95, 135, 175, 215, 255];
11
12/// Selects how RGB values are represented in a terminal.
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
14pub enum ColorMode {
15    /// Preserve the original channels as a 24-bit terminal color.
16    #[default]
17    TrueColor,
18    /// Quantize to the xterm 256-color cube and grayscale ramp.
19    Ansi256,
20}
21
22/// Converts a ggsci color to a ratatui color.
23pub trait ToRatatuiColor {
24    /// Converts to a 24-bit ratatui color.
25    #[must_use]
26    fn to_ratatui_color(self) -> Color;
27
28    /// Converts using the requested terminal color mode.
29    #[must_use]
30    fn to_ratatui_color_with(self, mode: ColorMode) -> Color;
31}
32
33impl ToRatatuiColor for ggsci::Rgb {
34    fn to_ratatui_color(self) -> Color {
35        color(self, ColorMode::TrueColor)
36    }
37
38    fn to_ratatui_color_with(self, mode: ColorMode) -> Color {
39        color(self, mode)
40    }
41}
42
43/// Converts an RGB value to a ratatui color.
44#[must_use]
45pub fn color(rgb: ggsci::Rgb, mode: ColorMode) -> Color {
46    match mode {
47        ColorMode::TrueColor => Color::Rgb(rgb.r(), rgb.g(), rgb.b()),
48        ColorMode::Ansi256 => Color::Indexed(ansi256_index(rgb)),
49    }
50}
51
52/// Returns the nearest xterm 256-color index for an RGB value.
53///
54/// Only indices 16 through 255 are considered, avoiding the 16 colors that a
55/// terminal theme can redefine. Distance is squared Euclidean distance in
56/// 8-bit sRGB space, with the lower index winning an exact tie.
57#[must_use]
58pub fn ansi256_index(rgb: ggsci::Rgb) -> u8 {
59    nearest_ansi256(rgb)
60}
61
62/// Composites an RGBA source over an opaque background and converts it.
63///
64/// Source-over compositing is performed before any ANSI-256 quantization.
65#[must_use]
66pub fn rgba_over(rgba: ggsci::Rgba, background: ggsci::Rgb, mode: ColorMode) -> Color {
67    let alpha = u32::from(rgba.a());
68    let inverse_alpha = 255 - alpha;
69    let composite_channel = |source: u8, destination: u8| {
70        let numerator = u32::from(source) * alpha + u32::from(destination) * inverse_alpha + 127;
71        u8::try_from(numerator / 255).unwrap_or(u8::MAX)
72    };
73    let red = composite_channel(rgba.r(), background.r());
74    let green = composite_channel(rgba.g(), background.g());
75    let blue = composite_channel(rgba.b(), background.b());
76    let rgb =
77        ggsci::Rgb::from_hex((u32::from(red) << 16) | (u32::from(green) << 8) | u32::from(blue));
78
79    color(rgb, mode)
80}
81
82/// Samples a core palette and converts its colors.
83///
84/// [`ggsci::Palette::sample`] performs the authoritative kind-aware dispatch:
85/// discrete palettes return category colors and continuous palettes return
86/// interpolated gradient samples.
87///
88/// # Errors
89///
90/// Returns lookup errors for an invalid palette specification, or
91/// [`ggsci::Error::TooManyColorsRequested`] if a discrete palette is too
92/// short.
93pub fn colors(spec: &str, n: usize, mode: ColorMode) -> Result<Vec<Color>, ggsci::Error> {
94    ggsci::palette_by_spec(spec)?
95        .sample(n)
96        .map(|colors| convert_colors(colors, mode))
97}
98
99/// Interpolates and converts a continuous core palette.
100///
101/// # Errors
102///
103/// Returns lookup errors for an invalid palette specification, or
104/// [`ggsci::Error::NotContinuousPalette`] when the requested palette is
105/// discrete.
106pub fn continuous_colors(
107    spec: &str,
108    n: usize,
109    options: ggsci::ContinuousOptions,
110    mode: ColorMode,
111) -> Result<Vec<Color>, ggsci::Error> {
112    ggsci::palette_by_spec(spec)?
113        .interpolate_with(n, options)
114        .map(|colors| convert_colors(colors, mode))
115}
116
117/// Takes and converts colors from a fixed discrete iTerm theme variant.
118///
119/// # Errors
120///
121/// Returns [`ggsci::Error::UnknownItermPalette`] when lookup fails, or
122/// [`ggsci::Error::TooManyItermColorsRequested`] when `n` exceeds the fixed
123/// variant length.
124pub fn iterm_colors(
125    palette: &str,
126    variant: ggsci::ItermVariant,
127    n: usize,
128    mode: ColorMode,
129) -> Result<Vec<Color>, ggsci::Error> {
130    ggsci::iterm_palette(palette)?
131        .take(variant, n)
132        .map(|colors| convert_colors(colors, mode))
133}
134
135/// Generates and converts colors from a discrete Gephi palette.
136///
137/// # Errors
138///
139/// Returns [`ggsci::Error::UnknownGephiPalette`] when lookup fails, or
140/// [`ggsci::Error::GephiGenerationFailed`] when generation fails.
141pub fn gephi_colors(palette: &str, n: usize, mode: ColorMode) -> Result<Vec<Color>, ggsci::Error> {
142    ggsci::gephi_palette(palette)?
143        .generate(n)
144        .map(|colors| convert_colors(colors, mode))
145}
146
147/// Generates reproducible colors from a discrete Gephi palette and converts
148/// them.
149///
150/// # Errors
151///
152/// Returns [`ggsci::Error::UnknownGephiPalette`] when lookup fails, or
153/// [`ggsci::Error::GephiGenerationFailed`] when generation fails.
154pub fn gephi_colors_with_seed(
155    palette: &str,
156    n: usize,
157    seed: u64,
158    mode: ColorMode,
159) -> Result<Vec<Color>, ggsci::Error> {
160    ggsci::gephi_palette(palette)?
161        .generate_with_seed(n, seed)
162        .map(|colors| convert_colors(colors, mode))
163}
164
165/// Builds one foreground style per color.
166#[must_use]
167pub fn foreground_styles(colors: &[Color]) -> Vec<Style> {
168    colors
169        .iter()
170        .copied()
171        .map(|color| Style::new().fg(color))
172        .collect()
173}
174
175/// Builds one background style per color.
176#[must_use]
177pub fn background_styles(colors: &[Color]) -> Vec<Style> {
178    colors
179        .iter()
180        .copied()
181        .map(|color| Style::new().bg(color))
182        .collect()
183}
184
185fn convert_colors(colors: Vec<ggsci::Rgb>, mode: ColorMode) -> Vec<Color> {
186    colors.into_iter().map(|rgb| color(rgb, mode)).collect()
187}
188
189fn nearest_ansi256(rgb: ggsci::Rgb) -> u8 {
190    let mut closest_index = 16;
191    let mut closest_distance = u32::MAX;
192
193    for index in 16_u8..=u8::MAX {
194        let candidate = ansi256_rgb(index);
195        let distance = squared_distance(rgb, candidate);
196        if distance < closest_distance {
197            closest_index = index;
198            closest_distance = distance;
199        }
200    }
201
202    closest_index
203}
204
205fn ansi256_rgb(index: u8) -> [u8; 3] {
206    if index <= 231 {
207        let offset = index - 16;
208        let red = offset / 36;
209        let green = (offset % 36) / 6;
210        let blue = offset % 6;
211        [
212            ANSI_CUBE_LEVELS[usize::from(red)],
213            ANSI_CUBE_LEVELS[usize::from(green)],
214            ANSI_CUBE_LEVELS[usize::from(blue)],
215        ]
216    } else {
217        let level = 8 + 10 * (index - 232);
218        [level, level, level]
219    }
220}
221
222fn squared_distance(rgb: ggsci::Rgb, candidate: [u8; 3]) -> u32 {
223    let red = i32::from(rgb.r()) - i32::from(candidate[0]);
224    let green = i32::from(rgb.g()) - i32::from(candidate[1]);
225    let blue = i32::from(rgb.b()) - i32::from(candidate[2]);
226
227    u32::try_from(red * red + green * green + blue * blue)
228        .expect("squared RGB distance is nonnegative")
229}
230
231#[cfg(test)]
232mod tests {
233    use ggsci::{ContinuousOptions, Error, ItermVariant, PaletteKind, Rgb, Rgba};
234    use ratatui_core::style::{Color, Style};
235
236    use super::{
237        ColorMode, ToRatatuiColor, ansi256_index, background_styles, color, colors,
238        continuous_colors, foreground_styles, gephi_colors, gephi_colors_with_seed, iterm_colors,
239        rgba_over,
240    };
241
242    #[test]
243    fn converts_rgb_to_truecolor() {
244        let rgb = Rgb::from_hex(0x12_34_56);
245        assert_eq!(
246            color(rgb, ColorMode::TrueColor),
247            Color::Rgb(0x12, 0x34, 0x56)
248        );
249    }
250
251    #[test]
252    fn extension_trait_converts_with_default_and_explicit_modes() {
253        let rgb = Rgb::from_hex(0xFF_00_00);
254        assert_eq!(rgb.to_ratatui_color(), Color::Rgb(255, 0, 0));
255        assert_eq!(
256            rgb.to_ratatui_color_with(ColorMode::Ansi256),
257            Color::Indexed(196)
258        );
259    }
260
261    #[test]
262    fn maps_expected_ansi256_colors() {
263        let cases = [
264            (0x00_00_00, 16),
265            (0xFF_FF_FF, 231),
266            (0xFF_00_00, 196),
267            (0x00_FF_00, 46),
268            (0x00_00_FF, 21),
269            (0x80_80_80, 244),
270        ];
271
272        for (hex, expected) in cases {
273            assert_eq!(ansi256_index(Rgb::from_hex(hex)), expected);
274        }
275    }
276
277    #[test]
278    fn ansi256_ties_choose_the_lower_index() {
279        // Red 115 is equidistant from the cube levels 95 and 135.
280        assert_eq!(ansi256_index(Rgb::from_hex(0x73_00_00)), 52);
281    }
282
283    #[test]
284    fn composites_rgba_before_conversion() {
285        let background = Rgb::from_hex(0x00_00_FF);
286        assert_eq!(
287            rgba_over(
288                Rgba::from_hex(0xFF_00_00_00),
289                background,
290                ColorMode::TrueColor
291            ),
292            Color::Rgb(0, 0, 255)
293        );
294        assert_eq!(
295            rgba_over(
296                Rgba::from_hex(0xFF_00_00_80),
297                background,
298                ColorMode::TrueColor
299            ),
300            Color::Rgb(128, 0, 127)
301        );
302        assert_eq!(
303            rgba_over(
304                Rgba::from_hex(0xFF_00_00_FF),
305                background,
306                ColorMode::TrueColor
307            ),
308            Color::Rgb(255, 0, 0)
309        );
310        assert_eq!(
311            rgba_over(
312                Rgba::from_hex(0xFF_00_00_80),
313                background,
314                ColorMode::Ansi256
315            ),
316            color(Rgb::from_hex(0x80_00_7F), ColorMode::Ansi256)
317        );
318    }
319
320    #[test]
321    fn generic_colors_dispatches_discrete_palettes() {
322        assert_eq!(
323            colors("npg:nrc", 3, ColorMode::TrueColor).unwrap(),
324            [
325                Color::Rgb(0xE6, 0x4B, 0x35),
326                Color::Rgb(0x4D, 0xBB, 0xD5),
327                Color::Rgb(0x00, 0xA0, 0x87),
328            ]
329        );
330    }
331
332    #[test]
333    fn generic_colors_dispatches_continuous_palettes_beyond_anchor_count() {
334        let palette = ggsci::palette_by_spec("material:blue-grey").unwrap();
335        assert_eq!(palette.kind(), PaletteKind::Continuous);
336        let n = palette.len() + 7;
337        let converted = colors("material:blue-grey", n, ColorMode::TrueColor).unwrap();
338        let sampled = palette
339            .sample(n)
340            .unwrap()
341            .into_iter()
342            .map(ToRatatuiColor::to_ratatui_color)
343            .collect::<Vec<_>>();
344        assert_eq!(converted.len(), n);
345        assert_eq!(converted, sampled);
346        assert!(matches!(
347            palette.take(1),
348            Err(Error::NotDiscretePalette { .. })
349        ));
350    }
351
352    #[test]
353    fn reverses_explicit_continuous_output() {
354        let forward = continuous_colors(
355            "gsea:default",
356            13,
357            ContinuousOptions::new(),
358            ColorMode::TrueColor,
359        )
360        .unwrap();
361        let reversed = continuous_colors(
362            "gsea:default",
363            13,
364            ContinuousOptions::new().with_reverse(true),
365            ColorMode::TrueColor,
366        )
367        .unwrap();
368        assert_eq!(reversed, forward.into_iter().rev().collect::<Vec<_>>());
369    }
370
371    #[test]
372    fn converts_fixed_discrete_iterm_colors() {
373        let palette = ggsci::iterm_palette("Rose Pine").unwrap();
374        assert_eq!(palette.kind(), PaletteKind::Discrete);
375        assert_eq!(
376            iterm_colors("Rose Pine", ItermVariant::Normal, 3, ColorMode::TrueColor,).unwrap(),
377            [
378                Color::Rgb(0x9C, 0xCF, 0xD8),
379                Color::Rgb(0xF6, 0xC1, 0x77),
380                Color::Rgb(0xEB, 0x6F, 0x92),
381            ]
382        );
383    }
384
385    #[test]
386    fn converts_generative_discrete_gephi_colors_deterministically() {
387        let palette = ggsci::gephi_palette("fancy-light").unwrap();
388        assert_eq!(palette.kind(), PaletteKind::Discrete);
389        let first = gephi_colors_with_seed("fancy-light", 5, 42, ColorMode::TrueColor).unwrap();
390        let second = gephi_colors_with_seed("fancy-light", 5, 42, ColorMode::TrueColor).unwrap();
391        assert_eq!(first.len(), 5);
392        assert_eq!(first, second);
393    }
394
395    #[test]
396    fn unseeded_gephi_colors_have_the_requested_shape_and_representation() {
397        let generated = gephi_colors("default", 3, ColorMode::TrueColor).unwrap();
398        assert_eq!(generated.len(), 3);
399        assert!(
400            generated
401                .into_iter()
402                .all(|color| matches!(color, Color::Rgb(_, _, _)))
403        );
404    }
405
406    #[test]
407    fn builds_foreground_styles() {
408        let colors = [Color::Rgb(1, 2, 3), Color::Indexed(42)];
409        assert_eq!(
410            foreground_styles(&colors),
411            [Style::new().fg(colors[0]), Style::new().fg(colors[1])]
412        );
413    }
414
415    #[test]
416    fn builds_background_styles() {
417        let colors = [Color::Rgb(1, 2, 3), Color::Indexed(42)];
418        assert_eq!(
419            background_styles(&colors),
420            [Style::new().bg(colors[0]), Style::new().bg(colors[1])]
421        );
422    }
423}