Skip to main content

termprofile/convert/
mod.rs

1mod adapt;
2mod ansi_256_to_16;
3mod ansi_256_to_rgb;
4mod color;
5#[cfg(feature = "ratatui")]
6mod ratatui;
7
8pub use adapt::*;
9use ansi_256_to_16::ANSI_256_TO_16;
10use ansi_256_to_rgb::ANSI_256_TO_RGB;
11use anstyle::{Ansi256Color, AnsiColor, RgbColor};
12pub use color::*;
13use palette::Srgb;
14
15use crate::TermProfile;
16
17impl TermProfile {
18    /// Adapts the color into its nearest compatible variant.
19    pub fn adapt_color<C>(&self, color: C) -> Option<C>
20    where
21        C: AdaptableColor,
22    {
23        if *self < Self::Ansi16 {
24            return None;
25        }
26        if color.as_ansi_16().is_some() {
27            Some(color)
28        } else if let Some(index) = color.as_ansi_256() {
29            if *self >= Self::Ansi256 {
30                Some(color)
31            } else {
32                Some(C::from_ansi_16(ansi256_to_ansi16(index.0)))
33            }
34        } else if let Some(rgb_color) = color.as_rgb() {
35            if *self == Self::TrueColor {
36                Some(color)
37            } else {
38                let ansi256_index = rgb_to_ansi256(rgb_color);
39                if *self == Self::Ansi256 {
40                    Some(C::from_ansi_256(ansi256_index.into()))
41                } else {
42                    Some(C::from_ansi_16(ansi256_to_ansi16(ansi256_index)))
43                }
44            }
45        } else {
46            Some(color)
47        }
48    }
49
50    /// Adapts the style into its nearest compatible variant.
51    pub fn adapt_style<S>(&self, mut style: S) -> S
52    where
53        S: AdaptableStyle,
54    {
55        if *self == Self::NoTty {
56            return S::default();
57        }
58        if let Some(color) = style.get_fg_color() {
59            style = style.fg_color(self.adapt_color(color));
60        }
61        if let Some(color) = style.get_bg_color() {
62            style = style.bg_color(self.adapt_color(color));
63        }
64        if let Some(color) = style.get_underline_color() {
65            style = style.underline_color(self.adapt_color(color));
66        }
67        style
68    }
69}
70
71/// Converts the indexed ANSI color into its nearest 16-color variant.
72pub fn ansi256_to_ansi16(ansi256_index: u8) -> AnsiColor {
73    match ANSI_256_TO_16[&ansi256_index] {
74        0 => AnsiColor::Black,
75        1 => AnsiColor::Red,
76        2 => AnsiColor::Green,
77        3 => AnsiColor::Yellow,
78        4 => AnsiColor::Blue,
79        5 => AnsiColor::Magenta,
80        6 => AnsiColor::Cyan,
81        7 => AnsiColor::White,
82        8 => AnsiColor::BrightBlack,
83        9 => AnsiColor::BrightRed,
84        10 => AnsiColor::BrightGreen,
85        11 => AnsiColor::BrightYellow,
86        12 => AnsiColor::BrightBlue,
87        13 => AnsiColor::BrightMagenta,
88        14 => AnsiColor::BrightCyan,
89        15 => AnsiColor::BrightWhite,
90        _ => unreachable!(),
91    }
92}
93
94#[cfg(feature = "color-cache")]
95static COLOR_CACHE: std::sync::LazyLock<std::sync::Mutex<lru::LruCache<RgbColor, u8>>> =
96    std::sync::LazyLock::new(|| lru::LruCache::new(256.try_into().expect("invalid size")).into());
97
98#[cfg(feature = "color-cache")]
99static CACHE_ENABLED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
100
101/// Enables the LRU color cache.
102#[cfg(feature = "color-cache")]
103pub fn set_color_cache_enabled(enabled: bool) {
104    CACHE_ENABLED.store(enabled, std::sync::atomic::Ordering::SeqCst);
105}
106
107/// Sets the size of the LRU color cache.
108///
109/// # Panics
110///
111/// If the lock on the cache is poisoned
112#[cfg(feature = "color-cache")]
113pub fn set_color_cache_size(size: std::num::NonZeroUsize) {
114    COLOR_CACHE.lock().expect("lock poisoned").resize(size);
115}
116
117/// Converts the RGB color to an ANSI 256 color.
118///
119/// # Panics
120///
121/// If the lock on the cache is poisoned
122#[cfg(feature = "color-cache")]
123pub fn rgb_to_ansi256(color: RgbColor) -> u8 {
124    if CACHE_ENABLED.load(std::sync::atomic::Ordering::Relaxed) {
125        if let Some(cached) = COLOR_CACHE.lock().expect("lock poisoned").get(&color) {
126            return *cached;
127        }
128        let converted = rgb_to_ansi256_inner(color);
129        COLOR_CACHE
130            .lock()
131            .expect("lock poisoned")
132            .put(color, converted);
133        converted
134    } else {
135        rgb_to_ansi256_inner(color)
136    }
137}
138
139/// Converts the RGB color to an ANSI 256 color.
140#[cfg(not(feature = "color-cache"))]
141pub fn rgb_to_ansi256(color: RgbColor) -> u8 {
142    rgb_to_ansi256_inner(color)
143}
144
145fn get_color_index<const N: usize>(val: u8, breakpoints: [u8; N]) -> usize {
146    breakpoints.iter().position(|p| val < *p).unwrap_or(N)
147}
148
149// breakpoints were calculated using the distance to each color component
150// FF0000 for red, etc.
151fn red_color_index(val: u8) -> usize {
152    get_color_index(val, [49, 116, 156, 196, 236])
153}
154
155fn green_color_index(val: u8) -> usize {
156    get_color_index(val, [48, 116, 156, 196, 236])
157}
158
159fn blue_color_index(val: u8) -> usize {
160    get_color_index(val, [48, 116, 156, 196, 236])
161}
162
163const COLOR_INTERVALS: [u8; 6] = [0x00, 0x5f, 0x87, 0xaf, 0xd7, 0xff];
164
165// Implementation adapted from here with some tweaks:
166// https://github.com/charmbracelet/x/blob/f402b009fe75b24997fc2342a2605ecc3a268486/ansi/color.go
167// See https://invisible-island.net/xterm/xterm.faq.html#color_by_number
168fn rgb_to_ansi256_inner(color: RgbColor) -> u8 {
169    let srgb = Srgb::new(color.r(), color.g(), color.b());
170
171    let qr = red_color_index(srgb.red);
172    let qg = green_color_index(srgb.green);
173    let qb = blue_color_index(srgb.blue);
174    let cr = COLOR_INTERVALS[qr];
175    let cg = COLOR_INTERVALS[qg];
176    let cb = COLOR_INTERVALS[qb];
177    let color_index = (36 * qr + 6 * qg + qb + 16) as u8;
178
179    if cr == srgb.red && cg == srgb.green && cb == srgb.blue {
180        return color_index;
181    }
182    let average = ((srgb.red as u32 + srgb.green as u32 + srgb.blue as u32) / 3) as u8;
183    let gray_index = if average > 238 {
184        23
185    } else {
186        (average.saturating_sub(3)) / 10
187    };
188    let gray_value = 8 + 10 * gray_index;
189
190    let color2 = Srgb::new(cr, cg, cb);
191    let gray2 = Srgb::new(gray_value, gray_value, gray_value);
192
193    let color_distance = distance_squared(srgb, color2);
194    let gray_distance = distance_squared(srgb, gray2);
195    if color_distance <= gray_distance {
196        color_index
197    } else {
198        232 + gray_index
199    }
200}
201
202/// Converts the indexed ANSI color into its RGB equivalent.
203pub fn ansi256_to_rgb(ansi: Ansi256Color) -> RgbColor {
204    ANSI_256_TO_RGB[ansi.0 as usize]
205}
206
207// Color distance is tricky. There's a bunch of ways to do it and which way is best
208// is a bit subjective.
209// After trying a bunch of methods, this seems to get the best results on average.
210// See https://stackoverflow.com/a/9085524
211// We save a bit of computational power by not taking the square root here, since
212// we only care about comparing relative distance, not absolute distances.
213fn distance_squared(rgb1: Srgb<u8>, rgb2: Srgb<u8>) -> u32 {
214    let r_mean = (rgb1.red as i32 + rgb2.red as i32) / 2;
215    let r = (rgb1.red as i32) - (rgb2.red as i32);
216    let g = (rgb1.green as i32) - (rgb2.green as i32);
217    let b = (rgb1.blue as i32) - (rgb2.blue as i32);
218    ((((512 + r_mean) * r * r) >> 8) + 4 * g * g + (((767 - r_mean) * b * b) >> 8)) as u32
219}
220
221#[cfg(test)]
222#[path = "./convert_test.rs"]
223mod convert_test;