1use ratatui_core::style::{Color, Style};
9
10const ANSI_CUBE_LEVELS: [u8; 6] = [0, 95, 135, 175, 215, 255];
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
14pub enum ColorMode {
15 #[default]
17 TrueColor,
18 Ansi256,
20}
21
22pub trait ToRatatuiColor {
24 #[must_use]
26 fn to_ratatui_color(self) -> Color;
27
28 #[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#[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#[must_use]
58pub fn ansi256_index(rgb: ggsci::Rgb) -> u8 {
59 nearest_ansi256(rgb)
60}
61
62#[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
82pub 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
99pub 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
117pub 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
135pub 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
147pub 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#[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#[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 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}