From e19e0a97ef8e1f8ae046741a3bf30895c9a28074 Mon Sep 17 00:00:00 2001 From: Olivia Palmu Date: Mon, 14 Aug 2023 14:07:56 +0300 Subject: [PATCH 1/3] Make a separate wrapper around colors --- examples/smiley.rs | 4 +- src/color.rs | 221 ++++++++++++++++++++++++++++++++++++++++++++- src/screen.rs | 6 +- src/sprite.rs | 6 +- 4 files changed, 230 insertions(+), 7 deletions(-) diff --git a/examples/smiley.rs b/examples/smiley.rs index e26e80d..80204b5 100644 --- a/examples/smiley.rs +++ b/examples/smiley.rs @@ -25,6 +25,8 @@ fn main() -> io::Result<()> { } fn draw_smiley(screen: &mut Screen, x: u16, y: u16, blit: Blit) { - let smiley = Sprite::from_braille_string(&["⢌⣈⠄"], Some(Color::Green)).unwrap(); + let smiley = + Sprite::from_braille_string(&["⢌⣈⠄"], Some(Color::from_rgb_approximate(0, 255, 0))) + .unwrap(); screen.draw_sprite(&smiley, x, y, blit); } diff --git a/src/color.rs b/src/color.rs index bf3315e..085ad33 100644 --- a/src/color.rs +++ b/src/color.rs @@ -2,9 +2,153 @@ //! //! This uses [`crossterm::style::Color`] to represent ANSI terminal colors. +use std::cmp::Ordering; + +use crossterm::style; + use crate::cell::Cell; -pub use crossterm::style::Color; +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord)] +pub struct Color(pub u8); + +// RGB, GREYSCALE: These are the values most terminals seem to use +// RGB must begin with 0 and end with 255 +const RGB: [u8; 6] = [0, 95, 135, 175, 215, 255]; +const GREYSCALE: [u8; 24] = { + let mut x = [0u8; 24]; + let mut i = 0u8; + while i < 24 { + x[i as usize] = i * 10 + 8; + i += 1; + } + x +}; + +/// Picks the ANSI RGB component that's closest to the input +fn interpolate_component(scale: &[u8], target: u8) -> u8 { + let next_ansi = scale + .iter() + .position(|&next| next >= target) + .unwrap_or(scale.len() - 1) as u8; + let next = scale[next_ansi as usize]; + + match target.cmp(&next) { + // either that the target was matched right on, or the default value at the end of the + // scale was used (which is as close as it can get) + Ordering::Greater | Ordering::Equal => next_ansi, + Ordering::Less => { + // implies the first value of the scale was nonzero, and the target was below it + if next_ansi == 0 { + next_ansi + } else { + let prev_ansi = next_ansi - 1; + let prev = scale[prev_ansi as usize]; + // simple linear distance + if target - prev > next - target { + next_ansi + } else { + prev_ansi + } + } + } + } +} + +/// diagonal distance from a to b +fn dist(a: (u8, u8, u8), b: (u8, u8, u8)) -> f32 { + let (a_r, a_g, a_b) = a; + let (b_r, b_g, b_b) = b; + ((a_r as f32 - b_r as f32).abs().powi(3) + + (a_g as f32 - b_g as f32).abs().powi(3) + + (a_b as f32 - b_b as f32).abs().powi(3)) + .cbrt() +} + +impl Color { + /// Creates a new color from an 8-bit ANSI color value. + pub fn new(color: u8) -> Self { + Self(color) + } + /// Returns an ANSI color that is visually similar to the specified + /// RGB value. This will not always be accurate, because there are only + /// 256 ANSI colors compared to 256^3 RGB values. + /// + /// The process used to approximate a color is as follows: + /// * Find the ANSI color that is componentwise closest to the RGB triplet + /// using linear distance for each component + /// * Find the ANSI greyscale value that is closest to the RGB triplet when + /// converted to greyscale, using a simple sum of components + /// * Pick the option out of these two that minimizes the distance to the input + /// color, using cartesian distance as a metric. (Prefer the componentwise + /// option on a tie.) + /// + /// This is a very rudimentary method but computationally very simple. + pub fn from_rgb_approximate(r: u8, g: u8, b: u8) -> Self { + let components = Self::from_ansi_components( + interpolate_component(&RGB, r), + interpolate_component(&RGB, g), + interpolate_component(&RGB, b), + ); + let greyscale = Self::from_ansi_greyscale(interpolate_component( + &GREYSCALE, + ((r as u16 + g as u16 + b as u16) / 3) as u8, + )); + + let components_rgb = components.to_rgb_approximate(); + let greyscale_rgb = greyscale.to_rgb_approximate(); + + if dist(components_rgb, (r, g, b)) > dist(greyscale_rgb, (r, g, b)) { + greyscale + } else { + components + } + } + /// Returns a new color with the specified from red, green and blue components. + /// Each component may span from 0 to 5 (inclusive). If any values are higher, they + /// are clipped to the maximum value (5). + pub fn from_ansi_components(r: u8, g: u8, b: u8) -> Self { + Self(r.min(5) * 36 + g.min(5) * 6 + b.min(5) + 16) + } + /// Returns a new color with the specified greyscale value. The value may be + /// between 0 and 23 (inclusive), and represents a scale from black to white. + /// If any values are higher, they are clipped to the maximum value (23). + /// + /// Note that most terminals will not represent 0 with black and 23 with white; + /// consider using `from_ansi_rgb(0, 0, 0)` and `from_ansi_rgb(5, 5, 5)` instead. + pub fn from_ansi_greyscale(step: u8) -> Self { + Self(232 + step.min(23)) + } + /// Returns the approximate RGB color associated with this ANSI color. + /// + /// This is not always accurate; terminals may always choose to theme + /// ANSI colors differently. In particular, the standard and high-intensity + /// ANSI colors (color values from 0 to 15) are often altered by custom themes. + pub fn to_rgb_approximate(self) -> (u8, u8, u8) { + match self.0 { + 0..=15 => todo!("Standard colors not supported yet"), + 16..=231 => { + let offset = self.0 - 16; + let r = (offset / 36) % 6; + let g = (offset / 6) % 6; + let b = offset % 6; + (RGB[r as usize], RGB[g as usize], RGB[b as usize]) + } + 232..=255 => { + let step = self.0 - 232; + ( + GREYSCALE[step as usize], + GREYSCALE[step as usize], + GREYSCALE[step as usize], + ) + } + } + } + + /// Returns the equivalent crossterm color, for the purposes of integration + pub fn to_crossterm_color(self) -> style::Color { + style::Color::AnsiValue(self.0) + } +} pub struct ColorFlags { /// When `true`, color is applied when the cell is drawn, even if the cell is empty. @@ -31,3 +175,78 @@ impl ColoredCell { self.cell = self.cell | cell; } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_from_ansi_components() { + assert_eq!(Color::from_ansi_components(1, 2, 3), Color::new(67)); + assert_eq!(Color::from_ansi_components(0, 0, 0), Color::new(16)); + assert_eq!(Color::from_ansi_components(5, 5, 5), Color::new(231)); + assert_eq!(Color::from_ansi_components(6, 7, 8), Color::new(231)); + } + #[test] + fn test_from_ansi_greyscale() { + assert_eq!(Color::from_ansi_greyscale(0), Color::new(232)); + assert_eq!(Color::from_ansi_greyscale(1), Color::new(233)); + assert_eq!(Color::from_ansi_greyscale(10), Color::new(242)); + assert_eq!(Color::from_ansi_greyscale(23), Color::new(255)); + assert_eq!(Color::from_ansi_greyscale(100), Color::new(255)); + } + + #[test] + fn test_component_approximation_exact() { + assert_eq!( + Color::from_rgb_approximate(0, 0, 0), + Color::from_ansi_components(0, 0, 0) + ); + assert_eq!( + Color::from_rgb_approximate(255, 255, 255), + Color::from_ansi_components(5, 5, 5) + ); + assert_eq!( + Color::from_rgb_approximate(95, 135, 215), + Color::from_ansi_components(1, 2, 4) + ) + } + #[test] + fn test_greyscale_approximation_exact() { + assert_eq!( + Color::from_rgb_approximate(8, 8, 8), + Color::from_ansi_greyscale(0) + ); + assert_eq!( + Color::from_rgb_approximate(58, 58, 58), + Color::from_ansi_greyscale(5) + ); + assert_eq!( + Color::from_rgb_approximate(238, 238, 238), + Color::from_ansi_greyscale(23) + ) + } + + #[test] + fn test_component_approximation() { + assert_eq!( + Color::from_rgb_approximate(1, 0, 0), + Color::from_ansi_components(0, 0, 0) + ); + assert_eq!( + Color::from_rgb_approximate(129, 251, 2), + Color::from_ansi_components(2, 5, 0) + ); + } + #[test] + fn test_greyscale_approximation() { + assert_eq!( + Color::from_rgb_approximate(64, 59, 62), + Color::from_ansi_greyscale(5) + ); + assert_eq!( + Color::from_rgb_approximate(240, 241, 242), + Color::from_ansi_greyscale(23) + ); + } +} diff --git a/src/screen.rs b/src/screen.rs index 566bc82..674ff14 100644 --- a/src/screen.rs +++ b/src/screen.rs @@ -247,7 +247,7 @@ impl Screen { /// use ti::color::Color; /// /// let mut screen = Screen::new_cells(2, 1); - /// let color = Color::AnsiValue(23); + /// let color = Color::new(23); /// assert!(screen.draw_cell_color(color, 1, 0)); /// assert_eq!(screen.get_color(1, 0), Some(color)); /// ``` @@ -321,7 +321,7 @@ impl Screen { /// use ti::color::Color; /// /// let mut screen = Screen::new_cells(2, 2); - /// let color = Color::AnsiValue(123); + /// let color = Color::new(123); /// assert_eq!(screen.get_color(999, 999), None); /// screen.draw_cell_color(color, 0, 0); /// assert_eq!(screen.get_color(0, 0), Some(color)); @@ -461,7 +461,7 @@ impl Screen { } if color != cur_color { if let Some(color) = color { - buf.queue(SetForegroundColor(color))?; + buf.queue(SetForegroundColor(color.to_crossterm_color()))?; } cur_color = color; } diff --git a/src/sprite.rs b/src/sprite.rs index 2a6e207..0d41924 100644 --- a/src/sprite.rs +++ b/src/sprite.rs @@ -165,7 +165,8 @@ impl Sprite { /// Parses a sprite from dynamic image data. /// - /// The `rescale_filter` declares the method used to resize the + /// The `rescale_filter` declares the method used to resize to a specified resolution, and `downscale_filter` declares + /// the method used to thumbnail each cell into a single color. #[cfg(feature = "images")] fn from_image_data_rgb_resize( img: DynamicImage, @@ -184,7 +185,8 @@ impl Sprite { for (x, y, Rgba([r, g, b, _])) in colors.pixels() { let index = index(x as u16, y as u16, width_cells); - data[index] = ColoredCell::new(Cell::new(0xff), Some(Color::Rgb { r, g, b })); + data[index] = + ColoredCell::new(Cell::new(0xff), Some(Color::from_rgb_approximate(r, g, b))); } Sprite::new(data, width_cells, height_cells) From cb22ceee8e5a5eb9e8c5f15effb0b5302de1ee10 Mon Sep 17 00:00:00 2001 From: Olivia Palmu Date: Tue, 15 Aug 2023 10:14:58 +0300 Subject: [PATCH 2/3] Support standard colors --- README.md | 2 +- examples/smiley.rs | 6 ++--- src/color.rs | 58 ++++++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 59 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 8fac948..cef0483 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ lit and unlit pixels sufficiently. In that case, this repository also includes t ## Next steps -- [ ] Convert true colors to palette colors +- [x] Convert true colors to palette colors - [ ] Operations to read sprites from image files in more advanced ways - [ ] A simple rendering loop - [ ] Read input diff --git a/examples/smiley.rs b/examples/smiley.rs index 80204b5..9f1d9b7 100644 --- a/examples/smiley.rs +++ b/examples/smiley.rs @@ -1,7 +1,7 @@ use std::{io, time::Duration}; use ti::{ - color::Color, + color::standard, screen::{Blit, Screen}, sprite::Sprite, }; @@ -25,8 +25,6 @@ fn main() -> io::Result<()> { } fn draw_smiley(screen: &mut Screen, x: u16, y: u16, blit: Blit) { - let smiley = - Sprite::from_braille_string(&["⢌⣈⠄"], Some(Color::from_rgb_approximate(0, 255, 0))) - .unwrap(); + let smiley = Sprite::from_braille_string(&["⢌⣈⠄"], Some(standard::GREEN)).unwrap(); screen.draw_sprite(&smiley, x, y, blit); } diff --git a/src/color.rs b/src/color.rs index 085ad33..6f2dd40 100644 --- a/src/color.rs +++ b/src/color.rs @@ -64,9 +64,44 @@ fn dist(a: (u8, u8, u8), b: (u8, u8, u8)) -> f32 { .cbrt() } +macro_rules! define_standard_colors { + ($($name:ident $str:literal $num:literal),+) => { + $( + #[doc = "The ANSI standard"] + #[doc = $str] + #[doc = "color. Its appearance varies across terminals and themes."] + pub const $name: Color = Color::new($num); + )+ + }; +} + +/// This module contains the 16 ANSI standard colors, supported by almost all terminals. If you want your program to be +/// maximally visible on all terminals, and don't mind the colors looking slightly different, you can use these. +pub mod standard { + use super::Color; + define_standard_colors! { + BLACK "black" 0, + RED "red" 1, + GREEN "green" 2, + YELLOW "yellow" 3, + BLUE "blue" 4, + MAGENTA "magenta" 5, + CYAN "cyan" 6, + WHITE "white" 7, + BRIGHT_BLACK "bright black" 8, + BRIGHT_RED "bright red" 9, + BRIGHT_GREEN "bright green" 10, + BRIGHT_YELLOW "bright yellow" 11, + BRIGHT_BLUE "bright blue" 12, + BRIGHT_MAGENTA "bright magenta" 13, + BRIGHT_CYAN "bright cyan" 14, + BRIGHT_WHITE "bright white" 15 + } +} + impl Color { /// Creates a new color from an 8-bit ANSI color value. - pub fn new(color: u8) -> Self { + pub const fn new(color: u8) -> Self { Self(color) } /// Returns an ANSI color that is visually similar to the specified @@ -125,7 +160,25 @@ impl Color { /// ANSI colors (color values from 0 to 15) are often altered by custom themes. pub fn to_rgb_approximate(self) -> (u8, u8, u8) { match self.0 { - 0..=15 => todo!("Standard colors not supported yet"), + // The standard colors are simple approximations, because every terminal does it differently. + // This is a particularly simple choice of colors, following the windows XP console. + 0 => (0, 0, 0), + 1 => (128, 0, 0), + 2 => (0, 128, 0), + 3 => (128, 128, 0), + 4 => (0, 0, 128), + 5 => (128, 0, 128), + 6 => (0, 128, 128), + 7 => (192, 192, 192), + 8 => (128, 128, 128), + 9 => (255, 0, 0), + 10 => (0, 255, 0), + 11 => (255, 255, 0), + 12 => (0, 0, 255), + 13 => (255, 0, 255), + 14 => (0, 255, 255), + 15 => (255, 255, 255), + // 3-component (RGB) colors 16..=231 => { let offset = self.0 - 16; let r = (offset / 36) % 6; @@ -133,6 +186,7 @@ impl Color { let b = offset % 6; (RGB[r as usize], RGB[g as usize], RGB[b as usize]) } + // Greyscale colors 232..=255 => { let step = self.0 - 232; ( From c66fe91fe635d5dfd8caed2a30769356c54479a4 Mon Sep 17 00:00:00 2001 From: Olivia Palmu Date: Tue, 15 Aug 2023 10:20:00 +0300 Subject: [PATCH 3/3] More tests --- src/color.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/color.rs b/src/color.rs index 6f2dd40..0d36d07 100644 --- a/src/color.rs +++ b/src/color.rs @@ -303,4 +303,11 @@ mod tests { Color::from_ansi_greyscale(23) ); } + #[test] + fn test_greyscale_incrementing() { + let colors: Vec<_> = (0..24).map(Color::from_ansi_greyscale).collect(); + let mut sorted = colors.clone(); + sorted.sort_by(|a, b| a.to_rgb_approximate().0.cmp(&b.to_rgb_approximate().0)); + assert_eq!(colors, sorted) + } }