├── src ├── lib.rs ├── format │ ├── mod.rs │ ├── colors.rs │ ├── text.rs │ └── stylable.rs └── color │ ├── mod.rs │ ├── error.rs │ ├── hex.rs │ ├── validate.rs │ ├── rgb.rs │ └── ansi.rs ├── media └── social.png ├── examples ├── basic.rs ├── 10_print.rs ├── color.rs ├── format.rs ├── palettes.rs ├── readme.rs └── user_input.rs ├── .gitignore ├── Cargo.toml ├── .github └── workflows │ └── release.yml ├── Cargo.lock ├── tests ├── color.rs └── format.rs └── README.md /src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod color; 2 | pub mod format; 3 | -------------------------------------------------------------------------------- /media/social.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ronilan/terminal_style/HEAD/media/social.png -------------------------------------------------------------------------------- /examples/basic.rs: -------------------------------------------------------------------------------- 1 | fn main() { 2 | let text = "Hello!"; 3 | let output = terminal_style::format::bold(text); 4 | println!("{}", output); 5 | } 6 | -------------------------------------------------------------------------------- /src/format/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod colors; 2 | pub mod stylable; 3 | pub mod text; 4 | 5 | pub use colors::{background, color}; 6 | pub use stylable::Stylable; 7 | pub use text::{bold, faint, inverse, italic, underline}; 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # anything, anywhere starting with __ 2 | __* 3 | 4 | # Local development 5 | *.env 6 | *.dev 7 | 8 | # Apple 9 | .DS_Store 10 | .AppleDouble 11 | .LSOverride 12 | Icon 13 | 14 | # Thumbnails 15 | ._* 16 | 17 | # Files that might appear on external disk 18 | .Spotlight-V100 19 | .Trashes 20 | 21 | # Rust 22 | /target 23 | -------------------------------------------------------------------------------- /src/color/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod ansi; 2 | pub mod error; 3 | pub mod hex; 4 | pub mod rgb; 5 | pub mod validate; 6 | 7 | pub use ansi::{ansi8_to_hex, ansi8_to_rgb, ansi_from_color_definition, IntoColorString}; 8 | pub use error::ColorConversionError; 9 | pub use hex::{hex_to_ansi8, hex_to_rgb}; 10 | pub use rgb::{rgb_to_ansi8, rgb_to_hex}; 11 | pub use validate::{validate_ansi, validate_hex}; 12 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "terminal_style" 3 | version = "0.4.0" 4 | edition = "2021" 5 | 6 | description = "A minimal library for styling terminal text using ANSI escape codes." 7 | license = "MIT" 8 | documentation = "https://docs.rs/terminal_style" 9 | homepage = "https://github.com/ronilan/terminal_style" 10 | repository = "https://github.com/ronilan/terminal_style" 11 | 12 | [dev-dependencies] 13 | rand = "0.8" -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Publish to crates.io 2 | on: 3 | release: 4 | types: [ published ] 5 | 6 | jobs: 7 | publish: 8 | runs-on: ubuntu-latest 9 | environment: release # Optional: for enhanced security 10 | permissions: 11 | id-token: write # Required for OIDC token exchange 12 | steps: 13 | - uses: actions/checkout@v4 14 | - uses: rust-lang/crates-io-auth-action@v1 15 | id: auth 16 | - run: cargo publish 17 | env: 18 | CARGO_REGISTRY_TOKEN: ${{ steps.auth.outputs.token }} 19 | -------------------------------------------------------------------------------- /examples/10_print.rs: -------------------------------------------------------------------------------- 1 | use rand::Rng; 2 | use std::io::{stdout, Write}; 3 | use std::{thread, time}; 4 | use terminal_style::format::color; 5 | 6 | fn main() -> Result<(), Box> { 7 | let mut n: u32 = 0; 8 | let mut rng = rand::thread_rng(); 9 | 10 | loop { 11 | let base = n as f64 + (rng.gen::() * 100.0); 12 | let c = 16 + ((base as u32) % 216); 13 | let slash = if rng.gen_bool(0.5) { "╱" } else { "╲" }; 14 | 15 | let colored_text = color(c as u8, slash)?; 16 | print!("{}", colored_text); 17 | stdout().flush().unwrap(); 18 | 19 | thread::sleep(time::Duration::from_millis(1)); 20 | n = n.wrapping_add(1); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /examples/color.rs: -------------------------------------------------------------------------------- 1 | use terminal_style::format::{background, color}; 2 | 3 | fn main() -> Result<(), terminal_style::color::ColorConversionError> { 4 | println!("=== Color Examples ===\n"); 5 | 6 | // Foreground colors using hex, rgb, and ansi 7 | println!("{}", color("#FF4500", "Hex input: OrangeRed")?); // Hex string 8 | println!("{}", color([0, 128, 0], "RGB input: Green")?); // RGB array 9 | println!("{}", color(21u8, "ANSI input: Blue-ish")?); // ANSI 8-bit code 10 | 11 | // Background colors using hex, rgb, and ansi 12 | println!("{}", background("#FFD700", "Hex BG: Gold background")?); 13 | println!("{}", background([75, 0, 130], "RGB BG: Indigo background")?); 14 | println!("{}", background(196u8, "ANSI BG: Bright Red background")?); 15 | 16 | Ok(()) 17 | } 18 | -------------------------------------------------------------------------------- /src/color/error.rs: -------------------------------------------------------------------------------- 1 | use std::fmt; 2 | 3 | #[derive(Debug)] 4 | pub enum ColorConversionError { 5 | InvalidHex(String), 6 | InvalidRgb(String), 7 | InvalidAnsiValue(i32), 8 | UnknownFormat(String), 9 | } 10 | 11 | impl fmt::Display for ColorConversionError { 12 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 13 | match self { 14 | ColorConversionError::InvalidHex(s) => write!(f, "Invalid hex string: {}", s), 15 | ColorConversionError::InvalidRgb(s) => write!(f, "Invalid RGB value: {}", s), 16 | ColorConversionError::InvalidAnsiValue(v) => write!(f, "Invalid ANSI value: {}", v), 17 | ColorConversionError::UnknownFormat(s) => write!(f, "Unknown format: {}", s), 18 | } 19 | } 20 | } 21 | 22 | impl std::error::Error for ColorConversionError {} 23 | -------------------------------------------------------------------------------- /src/format/colors.rs: -------------------------------------------------------------------------------- 1 | use super::stylable::Stylable; 2 | use crate::color::{ansi_from_color_definition, ColorConversionError, IntoColorString}; 3 | 4 | /// Foreground color 5 | pub fn color(color_input: C, text: T) -> Result 6 | where 7 | C: Copy + IntoColorString, 8 | T: Stylable, 9 | { 10 | // Closure now returns Result 11 | let f = |s: &str| -> Result { 12 | let code = ansi_from_color_definition(color_input)?; 13 | Ok(format!("\x1b[38;5;{}m{}\x1b[0m", code, s)) 14 | }; 15 | 16 | // Apply to the Stylable text, propagating errors 17 | text.apply_result(f) 18 | } 19 | 20 | /// Background color 21 | pub fn background(color_input: C, text: T) -> Result 22 | where 23 | C: Copy + IntoColorString, 24 | T: Stylable, 25 | { 26 | let f = |s: &str| -> Result { 27 | let code = ansi_from_color_definition(color_input)?; 28 | Ok(format!("\x1b[48;5;{}m{}\x1b[0m", code, s)) 29 | }; 30 | 31 | text.apply_result(f) 32 | } 33 | -------------------------------------------------------------------------------- /examples/format.rs: -------------------------------------------------------------------------------- 1 | use terminal_style::format::bold; 2 | use terminal_style::format::faint; 3 | use terminal_style::format::inverse; 4 | use terminal_style::format::italic; 5 | use terminal_style::format::underline; 6 | 7 | fn main() { 8 | println!("=== Format Examples ===\n"); 9 | 10 | println!("{}", bold("Bold text")); 11 | println!("{}", italic("Italic text")); 12 | println!("{}", faint("Faint text")); 13 | println!("{}", inverse("Inverse text")); 14 | println!("{}", underline("Underline text")); 15 | println!(); 16 | 17 | println!( 18 | "Combined: {} {} {} {} {}", 19 | bold("Bold"), 20 | italic("Italic"), 21 | faint("Faint"), 22 | inverse("Inverse"), 23 | underline("Underline") 24 | ); 25 | println!(); 26 | 27 | println!("With special chars: {}", bold("Bold #123!")); 28 | println!("Multi-line:\n{}", italic("Line1\nLine2")); 29 | println!(); 30 | 31 | println!("Empty strings: '{}', '{}'", faint(""), inverse("")); 32 | println!(); 33 | 34 | println!( 35 | "Practical:\n{}: Warning!\n{}: Note\n{}: Hint\n{}: Important\n{}: Alert", 36 | bold("WARNING"), 37 | italic("Note"), 38 | faint("Hint"), 39 | underline("Important"), 40 | inverse("ALERT") 41 | ); 42 | } 43 | -------------------------------------------------------------------------------- /examples/palettes.rs: -------------------------------------------------------------------------------- 1 | use terminal_style::format::{background, color}; 2 | 3 | fn main() -> Result<(), terminal_style::color::ColorConversionError> { 4 | println!("=== Palettes Examples ===\n"); 5 | 6 | for code in 0..=15 { 7 | print!("{}", background(code, " ")?); 8 | } 9 | println!(); 10 | 11 | for row in 0..6 { 12 | for col in 0..36 { 13 | let code = row * 36 + col + 16; 14 | print!("{}", background(code as u8, " ")?); 15 | } 16 | println!(); 17 | } 18 | println!(); 19 | 20 | for code in 232..=255 { 21 | print!("{}", background(code, " ")?); 22 | } 23 | println!(); 24 | println!(); 25 | 26 | for code in 0..=15 { 27 | let ch = ((code - 16) % 94 + 33) as u8 as char; // printable ASCII range: 33–126 28 | print!("{}", color(code as u8, ch.to_string().as_str())?); 29 | } 30 | println!(); 31 | 32 | for row in 0..6 { 33 | for col in 0..36 { 34 | let code = row * 36 + col + 16; 35 | 36 | let ch = ((code - 16) % 94 + 33) as u8 as char; // printable ASCII range: 33–126 37 | print!("{}", color(code as u8, ch.to_string().as_str())?); 38 | } 39 | println!(); 40 | } 41 | println!(); 42 | 43 | for code in 232..=255 { 44 | let ch = ((code - 16) % 94 + 33) as u8 as char; // printable ASCII range: 33–126 45 | print!("{}", color(code as u8, ch.to_string().as_str())?); 46 | } 47 | println!(); 48 | 49 | Ok(()) 50 | } 51 | -------------------------------------------------------------------------------- /src/format/text.rs: -------------------------------------------------------------------------------- 1 | // format.rs 2 | use super::stylable::Stylable; 3 | use crate::color::{ansi_from_color_definition, ColorConversionError, IntoColorString}; 4 | 5 | // --- Text formatting functions --- 6 | pub fn bold(input: T) -> T::Output { 7 | input.apply(|s| format!("\x1b[1m{}\x1b[0m", s)) 8 | } 9 | 10 | pub fn italic(input: T) -> T::Output { 11 | input.apply(|s| format!("\x1b[3m{}\x1b[0m", s)) 12 | } 13 | 14 | pub fn faint(input: T) -> T::Output { 15 | input.apply(|s| format!("\x1b[2m{}\x1b[0m", s)) 16 | } 17 | 18 | pub fn inverse(input: T) -> T::Output { 19 | input.apply(|s| format!("\x1b[7m{}\x1b[0m", s)) 20 | } 21 | 22 | pub fn underline(input: T) -> T::Output { 23 | input.apply(|s| format!("\x1b[4m{}\x1b[0m", s)) 24 | } 25 | 26 | // --- Coloring functions --- 27 | pub fn color(color_input: C, text: T) -> Result 28 | where 29 | C: Copy + IntoColorString, 30 | T: Stylable, 31 | { 32 | let f = |s: &str| -> Result { 33 | let code = ansi_from_color_definition(color_input)?; 34 | Ok(format!("\x1b[38;5;{}m{}\x1b[0m", code, s)) 35 | }; 36 | text.apply_result(f) 37 | } 38 | 39 | pub fn background(color_input: C, text: T) -> Result 40 | where 41 | C: Copy + IntoColorString, 42 | T: Stylable, 43 | { 44 | let f = |s: &str| -> Result { 45 | let code = ansi_from_color_definition(color_input)?; 46 | Ok(format!("\x1b[48;5;{}m{}\x1b[0m", code, s)) 47 | }; 48 | text.apply_result(f) 49 | } 50 | -------------------------------------------------------------------------------- /src/color/hex.rs: -------------------------------------------------------------------------------- 1 | use super::rgb::rgb_to_ansi8; 2 | 3 | /// Converts a hex color string (e.g. "#FF00AA" or "F0A") to an RGB array. 4 | /// 5 | /// This function assumes that the input is always valid and performs no error checking. 6 | /// It supports both 6-character ("RRGGBB") and 3-character ("RGB") hex codes, with or without a leading `#`. 7 | /// 8 | /// # Examples 9 | /// 10 | /// ``` 11 | /// assert_eq!(terminal_style::color::hex_to_rgb("#FF00AA"), [255, 0, 170]); 12 | /// assert_eq!(terminal_style::color::hex_to_rgb("F0A"), [255, 0, 170]); 13 | /// ``` 14 | pub fn hex_to_rgb(hex: &str) -> [u8; 3] { 15 | let hex = hex.trim_start_matches('#'); 16 | 17 | if hex.len() == 6 { 18 | // Full 6-digit hex code: extract and convert each component 19 | let r = u8::from_str_radix(&hex[0..2], 16).unwrap(); 20 | let g = u8::from_str_radix(&hex[2..4], 16).unwrap(); 21 | let b = u8::from_str_radix(&hex[4..6], 16).unwrap(); 22 | [r, g, b] 23 | } else { 24 | // 3-digit shorthand: duplicate each character 25 | let r = u8::from_str_radix(&hex[0..1].repeat(2), 16).unwrap(); 26 | let g = u8::from_str_radix(&hex[1..2].repeat(2), 16).unwrap(); 27 | let b = u8::from_str_radix(&hex[2..3].repeat(2), 16).unwrap(); 28 | [r, g, b] 29 | } 30 | } 31 | 32 | /// Converts a hex color string to an ANSI 8-bit color value. 33 | /// 34 | /// This function first converts the hex color to RGB using [`hex_to_rgb`], 35 | /// then maps the RGB value to the corresponding ANSI 8-bit color using [`rgb_to_ansi8`]. 36 | /// 37 | /// # Examples 38 | /// 39 | /// ``` 40 | /// assert_eq!(terminal_style::color::hex_to_ansi8("#FF0000"), 196); // Red 41 | /// assert_eq!(terminal_style::color::hex_to_ansi8("0F0"), 46); // Green 42 | /// ``` 43 | pub fn hex_to_ansi8(hex: &str) -> u8 { 44 | let rgb = hex_to_rgb(hex); 45 | rgb_to_ansi8(rgb) 46 | } 47 | -------------------------------------------------------------------------------- /src/color/validate.rs: -------------------------------------------------------------------------------- 1 | use super::error::ColorConversionError; 2 | 3 | /// Validates whether a given string is a valid 3- or 6-digit hexadecimal color. 4 | /// 5 | /// Accepts strings with or without a leading `#`, and checks that the remaining 6 | /// characters are valid ASCII hexadecimal digits (`0-9`, `a-f`, `A-F`), with a length of 7 | /// exactly 3 or 6. 8 | /// 9 | /// # Arguments 10 | /// 11 | /// * `input` - A string slice that may represent a hex color value (e.g. `"#FFA07A"` or `"0F0"`). 12 | /// 13 | /// # Returns 14 | /// 15 | /// * `Ok(())` if the input is a valid 3- or 6-digit hex string. 16 | /// * `Err(ColorConversionError::InvalidHex)` if the input is invalid. 17 | /// 18 | /// # Examples 19 | /// 20 | /// ``` 21 | /// assert!(terminal_style::color::validate_hex("#ffcc00").is_ok()); 22 | /// assert!(terminal_style::color::validate_hex("abc").is_ok()); 23 | /// assert!(terminal_style::color::validate_hex("xyz").is_err()); 24 | /// ``` 25 | 26 | pub fn validate_hex(input: &str) -> Result<(), ColorConversionError> { 27 | let hex = input.strip_prefix('#').unwrap_or(input); 28 | 29 | if !(hex.len() == 6 || hex.len() == 3) || !hex.chars().all(|c| c.is_ascii_hexdigit()) { 30 | return Err(ColorConversionError::InvalidHex(format!( 31 | "Expected 3 or 6-digit hex string, got: {}", 32 | input 33 | ))); 34 | } 35 | 36 | Ok(()) 37 | } 38 | 39 | /// Validates that an ANSI color value is within the 0–255 range. 40 | /// 41 | /// # Arguments 42 | /// 43 | /// * `value` - An integer value to validate. 44 | /// 45 | /// # Returns 46 | /// 47 | /// * `Ok(())` if the value is between 0 and 255 inclusive. 48 | /// * `Err(ColorConversionError::InvalidAnsiValue)` if the value is out of range. 49 | pub fn validate_ansi(value: i32) -> Result<(), ColorConversionError> { 50 | if !(0..=255).contains(&value) { 51 | return Err(ColorConversionError::InvalidAnsiValue(value)); 52 | } 53 | 54 | Ok(()) 55 | } 56 | -------------------------------------------------------------------------------- /examples/readme.rs: -------------------------------------------------------------------------------- 1 | use terminal_style::{ 2 | color::ColorConversionError, 3 | format::{background, bold, color, underline}, 4 | }; 5 | 6 | fn main() -> Result<(), ColorConversionError> { 7 | // --- Single string styling --- 8 | let text = "Styled!"; 9 | 10 | // Using `?` to propagate errors if color conversion fails 11 | let fg = color("#FF1493", text)?; // Foreground color (pink) 12 | let bg = background("#EEDDFF", text)?; // Background color (lavender) 13 | let bolded = bold(fg.clone()); // Bold formatting 14 | 15 | println!("FG: {}", fg); 16 | println!("BG: {}", bg); 17 | println!("Bold: {}", bolded); 18 | 19 | // --- 1D vector of strings --- 20 | let texts_1d = vec!["Red".to_string(), "Green".to_string(), "Blue".to_string()]; 21 | let colored_1d = color([255, 0, 0], texts_1d.clone())?; // Red foreground 22 | let bolded_1d = bold(texts_1d.clone()); 23 | 24 | println!("\n1D Colored vector:"); 25 | for line in &colored_1d { 26 | println!("{}", line); 27 | } 28 | 29 | println!("\n1D Bold vector:"); 30 | for line in &bolded_1d { 31 | println!("{}", line); 32 | } 33 | 34 | // --- 2D vector of strings --- 35 | let texts_2d = vec![ 36 | vec!["A".to_string(), "B".to_string()], 37 | vec!["C".to_string(), "D".to_string()], 38 | ]; 39 | 40 | let bolded_2d: Vec> = bold(texts_2d.clone()); 41 | let bg_colored_2d = background([255, 105, 180], texts_2d.clone())?; // Pink background 42 | let bold_underline_bg_2d = bold(underline(bg_colored_2d.clone())); 43 | 44 | // Output demo 45 | println!("\n2D Bold vector:"); 46 | for row in &bolded_2d { 47 | for cell in row { 48 | print!("{} ", cell); 49 | } 50 | println!(); 51 | } 52 | 53 | println!("\n2D Background colored vector:"); 54 | for row in &bg_colored_2d { 55 | for cell in row { 56 | print!("{} ", cell); 57 | } 58 | println!(); 59 | } 60 | 61 | println!("\n2D Bold + Underline + Background colored vector:"); 62 | for row in &bold_underline_bg_2d { 63 | for cell in row { 64 | print!("{} ", cell); 65 | } 66 | println!(); 67 | } 68 | 69 | Ok(()) 70 | } 71 | -------------------------------------------------------------------------------- /src/color/rgb.rs: -------------------------------------------------------------------------------- 1 | /// Converts an RGB color array into a hex color string. 2 | /// 3 | /// # Arguments 4 | /// 5 | /// * `arr` - An array `[u8; 3]` representing red, green, and blue components. 6 | /// 7 | /// # Returns 8 | /// 9 | /// A hex color string in the format `#RRGGBB`. 10 | /// 11 | /// # Example 12 | /// 13 | /// ``` 14 | /// assert_eq!(terminal_style::color::rgb_to_hex([255, 0, 170]), "#FF00AA"); 15 | /// ``` 16 | pub fn rgb_to_hex(arr: [u8; 3]) -> String { 17 | format!("#{:02X}{:02X}{:02X}", arr[0], arr[1], arr[2]) 18 | } 19 | 20 | /// Converts an RGB color array into an ANSI 8-bit color code. 21 | /// 22 | /// This function maps RGB values either to a grayscale range (232–255) or to 23 | /// the 6×6×6 ANSI color cube (16–231) depending on whether all components are equal. 24 | /// 25 | /// 26 | /// # Arguments 27 | /// 28 | /// * `arr` - An array `[u8; 3]` representing red, green, and blue components. 29 | /// 30 | /// # Returns 31 | /// 32 | /// An `u8` ANSI 8-bit color code (0–255). 33 | /// 34 | /// # Example 35 | /// 36 | /// ``` 37 | /// assert_eq!(terminal_style::color::rgb_to_ansi8([255, 0, 0]), 196); // Bright red 38 | /// assert_eq!(terminal_style::color::rgb_to_ansi8([128, 128, 128]), 244); // Mid gray 39 | /// ``` 40 | pub fn rgb_to_ansi8(arr: [u8; 3]) -> u8 { 41 | let first = &arr[0]; 42 | let is_gray = arr.iter().all(|item| item == first); 43 | 44 | if is_gray { 45 | // Grayscale calculation: map to range 232–255 46 | let gray = ((arr[0] as f64) / 240.0 * 24.0).round() as u8; 47 | 48 | // Clamp gray to valid ANSI grayscale range 49 | // Keep values close to black and white in the 216 palette 50 | if gray < 1 { 51 | return 16; 52 | } 53 | if gray > 24 { 54 | return 231; 55 | } 56 | return 231 + gray; 57 | } 58 | 59 | // For non-gray, map RGB to 6×6×6 color cube (values 16–231) 60 | // the real ANSI palette is not equally spaced 61 | // it goes 0, 95, 135, 175, 215, 255 62 | // cutoffs for rgb allocation are are 75, 115, 155, 195, 235 63 | // for each r, g and b, if under 75 0, else generate "cuts" of 40 64 | // multiply by 6 powered. 65 | arr.iter().enumerate().fold(16, |val, (index, &x)| { 66 | let contribution = if x < 75 { 67 | 0 // Too dark, contributes nothing 68 | } else { 69 | // Scale the component to 1–5 range, then weight by position 70 | let scaled = ((x - 75) as f64 / 200.0 * 5.0).floor() as u8 + 1; 71 | scaled * 6_u8.pow(2 - index as u32) // 36 for R, 6 for G, 1 for B 72 | }; 73 | val + contribution 74 | }) 75 | } 76 | -------------------------------------------------------------------------------- /examples/user_input.rs: -------------------------------------------------------------------------------- 1 | use std::io::{self, Write}; 2 | use terminal_style::color::{hex_to_ansi8, validate_ansi, validate_hex, ColorConversionError}; 3 | use terminal_style::format::color; 4 | 5 | fn main() -> Result<(), ColorConversionError> { 6 | // Choose format once 7 | let format = loop { 8 | println!("Choose color input format: [ansi] or [hex] (or type 'exit' to quit):"); 9 | print!("> "); 10 | io::stdout().flush().unwrap(); 11 | 12 | let mut format_input = String::new(); 13 | io::stdin().read_line(&mut format_input).unwrap(); 14 | let format_input = format_input.trim().to_lowercase(); 15 | 16 | match format_input.as_str() { 17 | "ansi" | "hex" => break format_input, 18 | "exit" => return Ok(()), 19 | _ => eprintln!("❌ Invalid format. Please enter 'ansi' or 'hex'."), 20 | } 21 | }; 22 | 23 | // Keep prompting for value in selected format 24 | loop { 25 | match format.as_str() { 26 | "ansi" => { 27 | print!("Enter ANSI code (0–255), or 'exit' to quit: "); 28 | io::stdout().flush().unwrap(); 29 | 30 | let mut input = String::new(); 31 | io::stdin().read_line(&mut input).unwrap(); 32 | let input = input.trim(); 33 | 34 | if input.eq_ignore_ascii_case("exit") { 35 | break; 36 | } 37 | 38 | match input.parse::() { 39 | Ok(num) => match validate_ansi(num) { 40 | Ok(()) => println!("{}", color(num as u8, "Hello World")?), 41 | Err(err) => eprintln!("❌ {}", err), 42 | }, 43 | Err(_) => eprintln!("❌ Invalid number format"), 44 | } 45 | } 46 | 47 | "hex" => { 48 | print!("Enter hex color (e.g. #C0ffee, BADA55, #B52, aaa), or 'exit' to quit: "); 49 | io::stdout().flush().unwrap(); 50 | 51 | let mut input = String::new(); 52 | io::stdin().read_line(&mut input).unwrap(); 53 | let input = input.trim(); 54 | 55 | if input.eq_ignore_ascii_case("exit") { 56 | break; 57 | } 58 | 59 | match validate_hex(input) { 60 | Ok(()) => { 61 | let ansi_code = hex_to_ansi8(input); 62 | println!("{}", color(ansi_code, "Hello World")?); 63 | } 64 | Err(err) => eprintln!("❌ {}", err), 65 | } 66 | } 67 | 68 | _ => unreachable!(), // format already validated 69 | } 70 | 71 | println!(); // spacing between inputs 72 | } 73 | 74 | Ok(()) 75 | } 76 | -------------------------------------------------------------------------------- /Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 3 4 | 5 | [[package]] 6 | name = "cfg-if" 7 | version = "1.0.1" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268" 10 | 11 | [[package]] 12 | name = "getrandom" 13 | version = "0.2.16" 14 | source = "registry+https://github.com/rust-lang/crates.io-index" 15 | checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" 16 | dependencies = [ 17 | "cfg-if", 18 | "libc", 19 | "wasi", 20 | ] 21 | 22 | [[package]] 23 | name = "libc" 24 | version = "0.2.174" 25 | source = "registry+https://github.com/rust-lang/crates.io-index" 26 | checksum = "1171693293099992e19cddea4e8b849964e9846f4acee11b3948bcc337be8776" 27 | 28 | [[package]] 29 | name = "ppv-lite86" 30 | version = "0.2.21" 31 | source = "registry+https://github.com/rust-lang/crates.io-index" 32 | checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" 33 | dependencies = [ 34 | "zerocopy", 35 | ] 36 | 37 | [[package]] 38 | name = "proc-macro2" 39 | version = "1.0.95" 40 | source = "registry+https://github.com/rust-lang/crates.io-index" 41 | checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778" 42 | dependencies = [ 43 | "unicode-ident", 44 | ] 45 | 46 | [[package]] 47 | name = "quote" 48 | version = "1.0.40" 49 | source = "registry+https://github.com/rust-lang/crates.io-index" 50 | checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" 51 | dependencies = [ 52 | "proc-macro2", 53 | ] 54 | 55 | [[package]] 56 | name = "rand" 57 | version = "0.8.5" 58 | source = "registry+https://github.com/rust-lang/crates.io-index" 59 | checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" 60 | dependencies = [ 61 | "libc", 62 | "rand_chacha", 63 | "rand_core", 64 | ] 65 | 66 | [[package]] 67 | name = "rand_chacha" 68 | version = "0.3.1" 69 | source = "registry+https://github.com/rust-lang/crates.io-index" 70 | checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" 71 | dependencies = [ 72 | "ppv-lite86", 73 | "rand_core", 74 | ] 75 | 76 | [[package]] 77 | name = "rand_core" 78 | version = "0.6.4" 79 | source = "registry+https://github.com/rust-lang/crates.io-index" 80 | checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" 81 | dependencies = [ 82 | "getrandom", 83 | ] 84 | 85 | [[package]] 86 | name = "syn" 87 | version = "2.0.104" 88 | source = "registry+https://github.com/rust-lang/crates.io-index" 89 | checksum = "17b6f705963418cdb9927482fa304bc562ece2fdd4f616084c50b7023b435a40" 90 | dependencies = [ 91 | "proc-macro2", 92 | "quote", 93 | "unicode-ident", 94 | ] 95 | 96 | [[package]] 97 | name = "terminal_style" 98 | version = "0.4.0" 99 | dependencies = [ 100 | "rand", 101 | ] 102 | 103 | [[package]] 104 | name = "unicode-ident" 105 | version = "1.0.18" 106 | source = "registry+https://github.com/rust-lang/crates.io-index" 107 | checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" 108 | 109 | [[package]] 110 | name = "wasi" 111 | version = "0.11.1+wasi-snapshot-preview1" 112 | source = "registry+https://github.com/rust-lang/crates.io-index" 113 | checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" 114 | 115 | [[package]] 116 | name = "zerocopy" 117 | version = "0.8.26" 118 | source = "registry+https://github.com/rust-lang/crates.io-index" 119 | checksum = "1039dd0d3c310cf05de012d8a39ff557cb0d23087fd44cad61df08fc31907a2f" 120 | dependencies = [ 121 | "zerocopy-derive", 122 | ] 123 | 124 | [[package]] 125 | name = "zerocopy-derive" 126 | version = "0.8.26" 127 | source = "registry+https://github.com/rust-lang/crates.io-index" 128 | checksum = "9ecf5b4cc5364572d7f4c329661bcc82724222973f2cab6f050a4e5c22f75181" 129 | dependencies = [ 130 | "proc-macro2", 131 | "quote", 132 | "syn", 133 | ] 134 | -------------------------------------------------------------------------------- /tests/color.rs: -------------------------------------------------------------------------------- 1 | use terminal_style::color::{ 2 | ansi8_to_hex, ansi8_to_rgb, rgb_to_ansi8, rgb_to_hex, validate_ansi, validate_hex, 3 | ColorConversionError, 4 | }; 5 | 6 | // 7 | // 1. RGB ↔ Hex 8 | // 9 | #[test] 10 | fn test_rgb_to_hex() { 11 | assert_eq!(rgb_to_hex([255, 0, 0]), "#FF0000"); 12 | assert_eq!(rgb_to_hex([0, 255, 0]), "#00FF00"); 13 | assert_eq!(rgb_to_hex([0, 0, 255]), "#0000FF"); 14 | assert_eq!(rgb_to_hex([127, 127, 127]), "#7F7F7F"); 15 | assert_eq!(rgb_to_hex([0, 0, 0]), "#000000"); 16 | assert_eq!(rgb_to_hex([255, 255, 255]), "#FFFFFF"); 17 | } 18 | 19 | // 20 | // 2. RGB → ANSI8 21 | // 22 | #[test] 23 | fn test_rgb_to_ansi8_color_cube() { 24 | assert_eq!(rgb_to_ansi8([255, 0, 0]), 196); // Red 25 | assert_eq!(rgb_to_ansi8([0, 255, 0]), 46); // Green 26 | assert_eq!(rgb_to_ansi8([0, 0, 255]), 21); // Blue 27 | assert_eq!(rgb_to_ansi8([255, 255, 0]), 226); // Yellow 28 | assert_eq!(rgb_to_ansi8([0, 255, 255]), 51); // Cyan 29 | assert_eq!(rgb_to_ansi8([255, 0, 255]), 201); // Magenta 30 | } 31 | 32 | #[test] 33 | fn test_rgb_to_ansi8_grayscale() { 34 | assert_eq!(rgb_to_ansi8([0, 0, 0]), 16); // Black 35 | assert_eq!(rgb_to_ansi8([128, 128, 128]), 244); // Middle gray 36 | assert_eq!(rgb_to_ansi8([255, 255, 255]), 231); // White 37 | } 38 | 39 | 40 | // 41 | // 3. ANSI8 → RGB 42 | // 43 | #[test] 44 | fn test_ansi8_to_rgb_standard_colors() { 45 | assert_eq!(ansi8_to_rgb(196), [255, 0, 0]); // Red 46 | assert_eq!(ansi8_to_rgb(46), [0, 255, 0]); // Green 47 | assert_eq!(ansi8_to_rgb(21), [0, 0, 255]); // Blue 48 | assert_eq!(ansi8_to_rgb(231), [255, 255, 255]); // Last in color cube 49 | } 50 | 51 | #[test] 52 | fn test_ansi8_to_rgb_grayscale_range() { 53 | assert_eq!(ansi8_to_rgb(232), [8, 8, 8]); 54 | assert_eq!(ansi8_to_rgb(243), [118, 118, 118]); 55 | assert_eq!(ansi8_to_rgb(255), [238, 238, 238]); 56 | } 57 | 58 | 59 | // 60 | // 4. Round-trip conversions 61 | // 62 | #[test] 63 | fn test_ansi8_to_hex_roundtrip() { 64 | let ansi_values = [16u8, 46, 196, 226, 231, 243, 255]; 65 | for &code in &ansi_values { 66 | let hex = ansi8_to_hex(code); 67 | let rgb = ansi8_to_rgb(code); 68 | assert_eq!(hex, rgb_to_hex(rgb)); 69 | } 70 | } 71 | 72 | // 73 | // 5. Hex validation 74 | // 75 | #[test] 76 | fn test_validate_hex_valid_inputs() { 77 | let valid_hexes = [ 78 | "#fff", "#FFFFFF", "#000000", "abc", "123456", 79 | ]; 80 | 81 | for input in valid_hexes { 82 | assert!(validate_hex(input).is_ok(), "Expected '{}' to be valid", input); 83 | } 84 | } 85 | 86 | #[test] 87 | fn test_validate_hex_mixed_case() { 88 | assert!(validate_hex("#AbC123").is_ok()); 89 | assert!(validate_hex("aBcDeF").is_ok()); 90 | } 91 | 92 | #[test] 93 | fn test_validate_hex_invalid_inputs() { 94 | let cases = [ 95 | ("", "empty string"), 96 | ("#", "just a hash"), 97 | ("#12", "too short"), 98 | ("#1234", "invalid length"), 99 | ("#12345g", "contains non-hex digit"), 100 | ("12345z", "non-hex without #"), 101 | ("#abcd", "length 4 invalid"), 102 | ("##123456", "extra hash"), 103 | ]; 104 | 105 | for (input, desc) in cases { 106 | assert!(validate_hex(input).is_err(), "Expected '{}' to be invalid ({})", input, desc); 107 | } 108 | } 109 | 110 | // 111 | // 6. ANSI validation 112 | // 113 | #[test] 114 | fn test_validate_ansi_valid_values() { 115 | assert!(validate_ansi(0).is_ok()); 116 | assert!(validate_ansi(128).is_ok()); 117 | assert!(validate_ansi(255).is_ok()); 118 | } 119 | 120 | #[test] 121 | fn test_validate_ansi_invalid_values() { 122 | assert!(matches!(validate_ansi(-1), Err(ColorConversionError::InvalidAnsiValue(-1)))); 123 | assert!(matches!(validate_ansi(256), Err(ColorConversionError::InvalidAnsiValue(256)))); 124 | } 125 | 126 | #[test] 127 | fn test_validate_ansi_error_value() { 128 | if let Err(ColorConversionError::InvalidAnsiValue(val)) = validate_ansi(300) { 129 | assert_eq!(val, 300); 130 | } else { 131 | panic!("Expected InvalidAnsiValue error"); 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /src/format/stylable.rs: -------------------------------------------------------------------------------- 1 | // stylable.rs 2 | pub trait Stylable { 3 | type Output; 4 | 5 | fn apply(&self, f: F) -> Self::Output 6 | where 7 | F: Fn(&str) -> String; 8 | 9 | fn apply_result(&self, f: F) -> Result 10 | where 11 | F: Fn(&str) -> Result, 12 | E: std::fmt::Debug; 13 | } 14 | 15 | // --- Implement Stylable --- 16 | 17 | impl Stylable for String { 18 | type Output = String; 19 | 20 | fn apply(&self, f: F) -> Self::Output 21 | where 22 | F: Fn(&str) -> String, 23 | { 24 | f(self) 25 | } 26 | 27 | fn apply_result(&self, f: F) -> Result 28 | where 29 | F: Fn(&str) -> Result, 30 | E: std::fmt::Debug, 31 | { 32 | f(self) 33 | } 34 | } 35 | 36 | impl Stylable for &str { 37 | type Output = String; 38 | 39 | fn apply(&self, f: F) -> Self::Output 40 | where 41 | F: Fn(&str) -> String, 42 | { 43 | f(self) 44 | } 45 | 46 | fn apply_result(&self, f: F) -> Result 47 | where 48 | F: Fn(&str) -> Result, 49 | E: std::fmt::Debug, 50 | { 51 | f(self) 52 | } 53 | } 54 | 55 | // Implement Stylable for &String 56 | impl Stylable for &String { 57 | type Output = String; 58 | 59 | fn apply(&self, f: F) -> Self::Output 60 | where 61 | F: Fn(&str) -> String, 62 | { 63 | f(self.as_str()) 64 | } 65 | 66 | fn apply_result(&self, f: F) -> Result 67 | where 68 | F: Fn(&str) -> Result, 69 | E: std::fmt::Debug, 70 | { 71 | f(self.as_str()) 72 | } 73 | } 74 | 75 | impl Stylable for Vec { 76 | type Output = Vec; 77 | 78 | fn apply(&self, f: F) -> Self::Output 79 | where 80 | F: Fn(&str) -> String, 81 | { 82 | self.iter().map(|s| f(s)).collect() 83 | } 84 | 85 | fn apply_result(&self, f: F) -> Result 86 | where 87 | F: Fn(&str) -> Result, 88 | E: std::fmt::Debug, 89 | { 90 | let mut out = Vec::with_capacity(self.len()); 91 | for s in self { 92 | out.push(f(s)?); 93 | } 94 | Ok(out) 95 | } 96 | } 97 | 98 | impl Stylable for Vec> { 99 | type Output = Vec>; 100 | 101 | fn apply(&self, f: F) -> Self::Output 102 | where 103 | F: Fn(&str) -> String, 104 | { 105 | self.iter() 106 | .map(|row| row.iter().map(|s| f(s)).collect()) 107 | .collect() 108 | } 109 | 110 | fn apply_result(&self, f: F) -> Result 111 | where 112 | F: Fn(&str) -> Result, 113 | E: std::fmt::Debug, 114 | { 115 | let mut out = Vec::with_capacity(self.len()); 116 | for row in self { 117 | let mut new_row = Vec::with_capacity(row.len()); 118 | for s in row { 119 | new_row.push(f(s)?); 120 | } 121 | out.push(new_row); 122 | } 123 | Ok(out) 124 | } 125 | } 126 | 127 | // Forward &Vec to Vec 128 | impl Stylable for &Vec { 129 | type Output = Vec; 130 | 131 | fn apply(&self, f: F) -> Self::Output 132 | where 133 | F: Fn(&str) -> String, 134 | { 135 | (*self).apply(f) 136 | } 137 | 138 | fn apply_result(&self, f: F) -> Result 139 | where 140 | F: Fn(&str) -> Result, 141 | E: std::fmt::Debug, 142 | { 143 | (*self).apply_result(f) 144 | } 145 | } 146 | 147 | // Forward &Vec> to Vec> 148 | impl Stylable for &Vec> { 149 | type Output = Vec>; 150 | 151 | fn apply(&self, f: F) -> Self::Output 152 | where 153 | F: Fn(&str) -> String, 154 | { 155 | (*self).apply(f) 156 | } 157 | 158 | fn apply_result(&self, f: F) -> Result 159 | where 160 | F: Fn(&str) -> Result, 161 | E: std::fmt::Debug, 162 | { 163 | (*self).apply_result(f) 164 | } 165 | } 166 | -------------------------------------------------------------------------------- /src/color/ansi.rs: -------------------------------------------------------------------------------- 1 | use super::{ 2 | error::ColorConversionError, 3 | hex::hex_to_ansi8, 4 | rgb::{rgb_to_ansi8, rgb_to_hex}, 5 | validate::validate_hex, 6 | }; 7 | 8 | /// Converts various types of color representations into an ANSI 8-bit color string. 9 | /// 10 | /// This function uses the `IntoColorString` trait to accept multiple input types, 11 | /// including RGB arrays, hex strings, and u8 ANSI values. 12 | pub fn ansi_from_color_definition(input: T) -> Result 13 | where 14 | T: IntoColorString, 15 | { 16 | input.into_color_string() 17 | } 18 | 19 | /// A trait for converting different color formats into an ANSI 8-bit color string. 20 | pub trait IntoColorString { 21 | fn into_color_string(self) -> Result; 22 | } 23 | 24 | /// Implements conversion from an RGB array to an ANSI color string. 25 | impl IntoColorString for [u8; 3] { 26 | fn into_color_string(self) -> Result { 27 | Ok(rgb_to_ansi8(self).to_string()) 28 | } 29 | } 30 | 31 | /// Implements conversion from a `String` hex color to an ANSI color string. 32 | /// Validates the hex format before conversion. 33 | impl IntoColorString for String { 34 | fn into_color_string(self) -> Result { 35 | validate_hex(&self)?; 36 | Ok(hex_to_ansi8(&self).to_string()) 37 | } 38 | } 39 | 40 | /// Implements conversion from a `&str` hex color to an ANSI color string. 41 | /// Validates the hex format before conversion. 42 | impl IntoColorString for &str { 43 | fn into_color_string(self) -> Result { 44 | validate_hex(self)?; 45 | Ok(hex_to_ansi8(self).to_string()) 46 | } 47 | } 48 | 49 | /// Implements conversion from an ANSI 8-bit value directly to a string. 50 | impl IntoColorString for u8 { 51 | fn into_color_string(self) -> Result { 52 | Ok(self.to_string()) 53 | } 54 | } 55 | 56 | /// Converts an ANSI 8-bit color value to an RGB array. 57 | /// 58 | /// Supports standard ANSI color ranges: 59 | /// - 0–15: system colors 60 | /// - 16–231: 6×6×6 color cube 61 | /// - 232–255: grayscale ramp 62 | pub fn ansi8_to_rgb(num: u8) -> [u8; 3] { 63 | match num { 64 | 0..=6 => { 65 | // Low-intensity primary colors 66 | let mut bits = format!("{:03b}", num) 67 | .chars() 68 | .rev() 69 | .map(|c| c.to_digit(2).unwrap()) 70 | .map(|b| (b * 255 / 2) as u8) 71 | .collect::>(); 72 | 73 | // Ensure array has 3 components 74 | while bits.len() < 3 { 75 | bits.push(0); 76 | } 77 | 78 | [bits[0], bits[1], bits[2]] 79 | } 80 | 7 => [192, 192, 192], // Light gray 81 | 8 => [127, 127, 127], // Dark gray 82 | 9..=15 => { 83 | // High-intensity primary colors 84 | let bits = format!("{:03b}", num - 8) 85 | .chars() 86 | .rev() 87 | .map(|c| c.to_digit(2).unwrap()) 88 | .map(|b| (b * 255) as u8) 89 | .collect::>(); 90 | 91 | [bits[0], bits[1], bits[2]] 92 | } 93 | 16..=231 => { 94 | // 6×6×6 color cube (216 colors) 95 | let index = num - 16; 96 | let r = index / 36; 97 | let g = (index / 6) % 6; 98 | let b = index % 6; 99 | 100 | let scale = |x: u8| { 101 | if x == 0 { 102 | 0 103 | } else { 104 | (x as u16 * 200 / 5 + 55) as u8 105 | } 106 | }; 107 | 108 | [scale(r), scale(g), scale(b)] 109 | } 110 | 232..=255 => { 111 | // Grayscale ramp (24 steps) 112 | let gray = ((num - 231) as u16 * 240 / 24).saturating_sub(2) as u8; 113 | [gray, gray, gray] 114 | } // No _ => branch needed because all u8 values are matched above. 115 | } 116 | } 117 | 118 | /// All values in the ANSI 8-bit range (0–255) are supported. This function 119 | /// internally converts the ANSI code to RGB using [`ansi8_to_rgb`] and then 120 | /// formats it as a hex string using [`rgb_to_hex`]. 121 | /// 122 | /// # Example 123 | /// 124 | /// ``` 125 | /// assert_eq!(terminal_style::color::ansi8_to_hex(196), "#FF0000"); // Bright red 126 | /// ``` 127 | pub fn ansi8_to_hex(num: u8) -> String { 128 | let rgb = ansi8_to_rgb(num); 129 | 130 | rgb_to_hex(rgb) 131 | } 132 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # terminal_style 2 | 3 | A minimal Rust library for styling terminal text using ANSI escape codes. Supports 256-color as well as bold, italic, faint, underline, and inverse formatting. Easily apply foreground/background colors from hex, RGB, or ANSI 8-bit values to strings, 1D vectors, and 2D vectors. Perfect for simple CLI tools. 4 | 5 | 6 | 7 | ## Installation 8 | 9 | `terminal_style` is published as a [crate](https://crates.io/crates/terminal_style) on crates.io. 10 | 11 | ```bash 12 | cargo add terminal_style 13 | ``` 14 | 15 | ## Features 16 | 17 | - Convert RGB or Hex to ANSI 256-color 18 | - Apply foreground/background color to strings 19 | - Format text as **bold**, *italic*, faint, inverse, or underline 20 | - Graceful handling of invalid color inputs 21 | 22 | ## Usage 23 | 24 | Formatting functions work with strings, vectors, and 2D vectors of strings seamlessly. It also supports references, so you can pass either owned or borrowed values. 25 | 26 | ### Supported Input Types 27 | 28 | | Input Type | Output Type | Description | 29 | |----------------------|-----------------|--------------------------------------------| 30 | | `String` | `String` | Single string formatting | 31 | | `&str` | `String` | Single string formatting | 32 | | `&String` | `String` | Reference forwarding to `String` | 33 | | `Vec` | `Vec` | Format each element individually | 34 | | `&Vec` | `Vec` | Reference forwarding to owned `Vec`| 35 | | `Vec>` | `Vec>` | Format every element in each subvector | 36 | | `&Vec>` | `Vec>` | Reference forwarding to owned `Vec>` | 37 | 38 | 39 | ### Example 40 | 41 | ```rust 42 | 43 | use terminal_style::{ 44 | format::{bold, underline, color, background}, 45 | color::ColorConversionError, 46 | }; 47 | 48 | fn main() -> Result<(), ColorConversionError> { 49 | // --- Single string styling --- 50 | let text = "Styled!"; 51 | 52 | // Using `?` to propagate errors if color conversion fails 53 | let fg = color("#FF1493", text)?; // Foreground color (pink) 54 | let bg = background("#EEDDFF", text)?; // Background color (lavender) 55 | let bolded = bold(fg.clone()); // Bold formatting 56 | 57 | println!("FG: {}", fg); 58 | println!("BG: {}", bg); 59 | println!("Bold: {}", bolded); 60 | 61 | // --- 1D vector of strings --- 62 | let texts_1d = vec!["Red".to_string(), "Green".to_string(), "Blue".to_string()]; 63 | let colored_1d = color([255, 0, 0], texts_1d.clone())?; // Red foreground 64 | let bolded_1d = bold(texts_1d.clone()); 65 | 66 | println!("\n1D Colored vector:"); 67 | for line in &colored_1d { 68 | println!("{}", line); 69 | } 70 | 71 | println!("\n1D Bold vector:"); 72 | for line in &bolded_1d { 73 | println!("{}", line); 74 | } 75 | 76 | // --- 2D vector of strings --- 77 | let texts_2d = vec![ 78 | vec!["A".to_string(), "B".to_string()], 79 | vec!["C".to_string(), "D".to_string()], 80 | ]; 81 | 82 | let bolded_2d: Vec> = bold(texts_2d.clone()); 83 | let bg_colored_2d = background([255, 105, 180], texts_2d.clone())?; // Pink background 84 | let bold_underline_bg_2d = bold(underline(bg_colored_2d.clone())); 85 | 86 | // Output demo 87 | println!("\n2D Bold vector:"); 88 | for row in &bolded_2d { 89 | for cell in row { 90 | print!("{} ", cell); 91 | } 92 | println!(); 93 | } 94 | 95 | println!("\n2D Background colored vector:"); 96 | for row in &bg_colored_2d { 97 | for cell in row { 98 | print!("{} ", cell); 99 | } 100 | println!(); 101 | } 102 | 103 | println!("\n2D Bold + Underline + Background colored vector:"); 104 | for row in &bold_underline_bg_2d { 105 | for cell in row { 106 | print!("{} ", cell); 107 | } 108 | println!(); 109 | } 110 | 111 | Ok(()) 112 | } 113 | 114 | ``` 115 | 116 | ## Color Conversion Examples 117 | 118 | Utility functions enable converting between RGB, HEX, and ANSI 8-bit values. 119 | 120 | ```rust 121 | use terminal_style::color::*; 122 | 123 | fn main() { 124 | // RGB to Hex 125 | assert_eq!(rgb_to_hex([255, 165, 0]), "#FFA500"); 126 | 127 | // Hex to RGB 128 | assert_eq!(hex_to_rgb("#00FF00"), [0, 255, 0]); 129 | 130 | // RGB to ANSI 131 | assert_eq!(rgb_to_ansi8([255, 0, 0]), 196); 132 | 133 | // Hex to ANSI 134 | assert_eq!(hex_to_ansi8("0000FF"), 21); 135 | 136 | // ANSI to RGB 137 | assert_eq!(ansi8_to_rgb(46), Some([0, 255, 0])); 138 | 139 | // ANSI to HEX 140 | assert_eq!(ansi8_to_hex(196), "#FF0000"); // Red 141 | } 142 | ``` 143 | 144 | Additional examples in the `examples` folder. 145 | 146 | ## Tests 147 | 148 | ```bash 149 | cargo test 150 | ``` 151 | 152 | ## Structure 153 | 154 | - `color/`: Utility color conversions (hex, rgb, ansi) 155 | - `format/`: Terminal text styling functions 156 | - `tests/`: Test suite 157 | - `examples/`: Usage examples 158 | 159 | ## Authors 160 | 161 | [Ron Ilan](https://www.ronilan.com) 162 | 163 | ## License 164 | [MIT](https://en.wikipedia.org/wiki/MIT_License) 165 | 166 | ###### Coded in Rust with a little help from the LLMs. Based on the lovingly handcrafted [colors.crumb](https://github.com/ronilan/colors.crumb).. Derived from work done on [Impossible.js](https://github.com/ronilan/that-is-impossible). Enjoy. 167 | 168 | Fabriqué au Canada : Made in Canada 🇨🇦 169 | -------------------------------------------------------------------------------- /tests/format.rs: -------------------------------------------------------------------------------- 1 | use terminal_style::format::{background, bold, color, faint, inverse, italic, underline}; 2 | 3 | #[test] 4 | fn test_background_with_rgb_array() { 5 | let styled = background([0, 0, 255], "Blue").unwrap(); 6 | assert_eq!(styled, "\x1b[48;5;21mBlue\x1b[0m"); 7 | } 8 | 9 | #[test] 10 | fn test_background_with_u8() { 11 | let styled = background(226u8, "Yellow Background").unwrap(); 12 | assert_eq!(styled, "\x1b[48;5;226mYellow Background\x1b[0m"); 13 | } 14 | 15 | #[test] 16 | fn test_background_with_hex_string() { 17 | let styled = background("#00FFFF", "Cyan").unwrap(); 18 | assert_eq!(styled, "\x1b[48;5;51mCyan\x1b[0m"); 19 | } 20 | 21 | #[test] 22 | fn test_background_with_invalid_hex() { 23 | let result = background("##bad", "Oops"); 24 | assert!(result.is_err()); 25 | } 26 | 27 | #[test] 28 | fn test_color_with_rgb_array() { 29 | let styled = color([255, 0, 0], "Red").unwrap(); 30 | assert_eq!(styled, "\x1b[38;5;196mRed\x1b[0m"); 31 | } 32 | 33 | #[test] 34 | fn test_color_with_u8() { 35 | let styled = color(82u8, "ANSI Green").unwrap(); 36 | assert_eq!(styled, "\x1b[38;5;82mANSI Green\x1b[0m"); 37 | } 38 | 39 | #[test] 40 | fn test_color_with_hex_string() { 41 | let styled = color("#00FF00", "Green").unwrap(); 42 | assert_eq!(styled, "\x1b[38;5;46mGreen\x1b[0m"); 43 | } 44 | 45 | #[test] 46 | fn test_color_with_invalid_hex() { 47 | let result = color("#XYZ", "Invalid"); 48 | assert!(result.is_err()); 49 | } 50 | 51 | #[test] 52 | fn test_bold() { 53 | let text = "Hello, World!"; 54 | let result = bold(text); 55 | assert_eq!(result, "\x1b[1mHello, World!\x1b[0m"); 56 | } 57 | 58 | #[test] 59 | fn test_bold_empty_string() { 60 | let text = ""; 61 | let result = bold(text); 62 | assert_eq!(result, "\x1b[1m\x1b[0m"); 63 | } 64 | 65 | #[test] 66 | fn test_italic() { 67 | let text = "Hello, World!"; 68 | let result = italic(text); 69 | assert_eq!(result, "\x1b[3mHello, World!\x1b[0m"); 70 | } 71 | 72 | #[test] 73 | fn test_italic_empty_string() { 74 | let text = ""; 75 | let result = italic(text); 76 | assert_eq!(result, "\x1b[3m\x1b[0m"); 77 | } 78 | 79 | #[test] 80 | fn test_faint() { 81 | let text = "Hello, World!"; 82 | let result = faint(text); 83 | assert_eq!(result, "\x1b[2mHello, World!\x1b[0m"); 84 | } 85 | 86 | #[test] 87 | fn test_faint_empty_string() { 88 | let text = ""; 89 | let result = faint(text); 90 | assert_eq!(result, "\x1b[2m\x1b[0m"); 91 | } 92 | 93 | #[test] 94 | fn test_inverse() { 95 | let text = "Hello, World!"; 96 | let result = inverse(text); 97 | assert_eq!(result, "\x1b[7mHello, World!\x1b[0m"); 98 | } 99 | 100 | #[test] 101 | fn test_inverse_empty_string() { 102 | let text = ""; 103 | let result = inverse(text); 104 | assert_eq!(result, "\x1b[7m\x1b[0m"); 105 | } 106 | 107 | #[test] 108 | fn test_underline() { 109 | let text = "Hello, World!"; 110 | let result = underline(text); 111 | assert_eq!(result, "\x1b[4mHello, World!\x1b[0m"); 112 | } 113 | 114 | #[test] 115 | fn test_underline_empty_string() { 116 | let text = ""; 117 | let result = underline(text); 118 | assert_eq!(result, "\x1b[4m\x1b[0m"); 119 | } 120 | 121 | #[test] 122 | fn test_bold_with_special_characters() { 123 | let text = "Hello, World! 123 @#$%"; 124 | let result = bold(text); 125 | assert_eq!(result, "\x1b[1mHello, World! 123 @#$%\x1b[0m"); 126 | } 127 | 128 | #[test] 129 | fn test_italic_with_special_characters() { 130 | let text = "Hello, World! 123 @#$%"; 131 | let result = italic(text); 132 | assert_eq!(result, "\x1b[3mHello, World! 123 @#$%\x1b[0m"); 133 | } 134 | 135 | #[test] 136 | fn test_faint_with_special_characters() { 137 | let text = "Hello, World! 123 @#$%"; 138 | let result = faint(text); 139 | assert_eq!(result, "\x1b[2mHello, World! 123 @#$%\x1b[0m"); 140 | } 141 | 142 | #[test] 143 | fn test_inverse_with_special_characters() { 144 | let text = "Hello, World! 123 @#$%"; 145 | let result = inverse(text); 146 | assert_eq!(result, "\x1b[7mHello, World! 123 @#$%\x1b[0m"); 147 | } 148 | 149 | #[test] 150 | fn test_bold_with_newlines() { 151 | let text = "Hello\nWorld!"; 152 | let result = bold(text); 153 | assert_eq!(result, "\x1b[1mHello\nWorld!\x1b[0m"); 154 | } 155 | 156 | #[test] 157 | fn test_italic_with_newlines() { 158 | let text = "Hello\nWorld!"; 159 | let result = italic(text); 160 | assert_eq!(result, "\x1b[3mHello\nWorld!\x1b[0m"); 161 | } 162 | 163 | #[test] 164 | fn test_faint_with_newlines() { 165 | let text = "Hello\nWorld!"; 166 | let result = faint(text); 167 | assert_eq!(result, "\x1b[2mHello\nWorld!\x1b[0m"); 168 | } 169 | 170 | #[test] 171 | fn test_inverse_with_newlines() { 172 | let text = "Hello\nWorld!"; 173 | let result = inverse(text); 174 | assert_eq!(result, "\x1b[7mHello\nWorld!\x1b[0m"); 175 | } 176 | 177 | #[test] 178 | fn test_formatting_differences() { 179 | let text = "Test"; 180 | let bold_result = bold(text); 181 | let italic_result = italic(text); 182 | let faint_result = faint(text); 183 | let inverse_result = inverse(text); 184 | 185 | // All should be different 186 | assert_ne!(bold_result, italic_result); 187 | assert_ne!(bold_result, faint_result); 188 | assert_ne!(bold_result, inverse_result); 189 | assert_ne!(italic_result, faint_result); 190 | assert_ne!(italic_result, inverse_result); 191 | assert_ne!(faint_result, inverse_result); 192 | 193 | // All should start with different escape codes 194 | assert!(bold_result.starts_with("\x1b[1m")); 195 | assert!(italic_result.starts_with("\x1b[3m")); 196 | assert!(faint_result.starts_with("\x1b[2m")); 197 | assert!(inverse_result.starts_with("\x1b[7m")); 198 | 199 | // All should end with reset 200 | assert!(bold_result.ends_with("\x1b[0m")); 201 | assert!(italic_result.ends_with("\x1b[0m")); 202 | assert!(faint_result.ends_with("\x1b[0m")); 203 | assert!(inverse_result.ends_with("\x1b[0m")); 204 | } 205 | 206 | #[test] 207 | fn test_bold_ref_string() { 208 | let s = String::from("Hello"); 209 | let result = bold(&s); 210 | assert_eq!(result, "\x1b[1mHello\x1b[0m"); 211 | } 212 | 213 | #[test] 214 | fn test_color_ref_string() { 215 | let s = String::from("Red"); 216 | let result: String = color(196u8, &s).unwrap(); 217 | assert_eq!(result, "\x1b[38;5;196mRed\x1b[0m"); 218 | } 219 | 220 | #[test] 221 | fn test_bold_ref_vector() { 222 | let texts = vec!["A".to_string(), "B".to_string()]; 223 | let styled: Vec = bold(&texts); // use reference 224 | let expected: Vec = texts 225 | .iter() 226 | .map(|t| format!("\x1b[1m{}\x1b[0m", t)) 227 | .collect(); 228 | assert_eq!(styled, expected); 229 | } 230 | 231 | #[test] 232 | fn test_color_ref_vector() { 233 | let texts = vec!["Red".to_string(), "Blue".to_string()]; 234 | let styled: Vec = color(196u8, &texts).unwrap(); 235 | let expected: Vec = texts 236 | .iter() 237 | .map(|t| format!("\x1b[38;5;196m{}\x1b[0m", t)) 238 | .collect(); 239 | assert_eq!(styled, expected); 240 | } 241 | 242 | #[test] 243 | fn test_bold_ref_2d_vector() { 244 | let texts_2d = vec![ 245 | vec!["A".to_string(), "B".to_string()], 246 | vec!["C".to_string(), "D".to_string()], 247 | ]; 248 | let styled: Vec> = bold(&texts_2d); // reference 249 | let expected: Vec> = texts_2d 250 | .iter() 251 | .map(|row| row.iter().map(|t| format!("\x1b[1m{}\x1b[0m", t)).collect()) 252 | .collect(); 253 | assert_eq!(styled, expected); 254 | } 255 | 256 | #[test] 257 | fn test_color_ref_2d_vector() { 258 | let texts_2d = vec![ 259 | vec!["One".to_string(), "Two".to_string()], 260 | vec!["Three".to_string(), "Four".to_string()], 261 | ]; 262 | let styled: Vec> = color(46u8, &texts_2d).unwrap(); // reference 263 | let expected: Vec> = texts_2d 264 | .iter() 265 | .map(|row| { 266 | row.iter() 267 | .map(|t| format!("\x1b[38;5;46m{}\x1b[0m", t)) 268 | .collect() 269 | }) 270 | .collect(); 271 | assert_eq!(styled, expected); 272 | } 273 | 274 | #[test] 275 | fn test_vector_foreground() { 276 | let texts = vec!["Red".to_string(), "Green".to_string(), "Blue".to_string()]; 277 | let colored: Vec = color(196u8, texts.clone()).unwrap(); // ANSI Red 278 | let expected: Vec = texts 279 | .iter() 280 | .map(|t| format!("\x1b[38;5;196m{}\x1b[0m", t)) 281 | .collect(); 282 | assert_eq!(colored, expected); 283 | } 284 | 285 | #[test] 286 | fn test_vector_background() { 287 | let texts = vec!["Red".to_string(), "Green".to_string(), "Blue".to_string()]; 288 | let bg_colored: Vec = background(21u8, texts.clone()).unwrap(); // ANSI Blue BG 289 | let expected: Vec = texts 290 | .iter() 291 | .map(|t| format!("\x1b[48;5;21m{}\x1b[0m", t)) 292 | .collect(); 293 | assert_eq!(bg_colored, expected); 294 | } 295 | 296 | #[test] 297 | fn test_bold_vector() { 298 | let texts = vec!["A".to_string(), "B".to_string()]; 299 | let styled: Vec = bold(texts.clone()); // directly on Vec 300 | let expected: Vec = texts 301 | .iter() 302 | .map(|t| format!("\x1b[1m{}\x1b[0m", t)) 303 | .collect(); 304 | assert_eq!(styled, expected); 305 | } 306 | 307 | #[test] 308 | fn test_italic_vector() { 309 | let texts = vec!["A".to_string(), "B".to_string()]; 310 | let styled: Vec = italic(texts.clone()); 311 | let expected: Vec = texts 312 | .iter() 313 | .map(|t| format!("\x1b[3m{}\x1b[0m", t)) 314 | .collect(); 315 | assert_eq!(styled, expected); 316 | } 317 | 318 | #[test] 319 | fn test_faint_vector() { 320 | let texts = vec!["A".to_string(), "B".to_string()]; 321 | let styled: Vec = faint(texts.clone()); 322 | let expected: Vec = texts 323 | .iter() 324 | .map(|t| format!("\x1b[2m{}\x1b[0m", t)) 325 | .collect(); 326 | assert_eq!(styled, expected); 327 | } 328 | 329 | #[test] 330 | fn test_inverse_vector() { 331 | let texts = vec!["A".to_string(), "B".to_string()]; 332 | let styled: Vec = inverse(texts.clone()); 333 | let expected: Vec = texts 334 | .iter() 335 | .map(|t| format!("\x1b[7m{}\x1b[0m", t)) 336 | .collect(); 337 | assert_eq!(styled, expected); 338 | } 339 | 340 | #[test] 341 | fn test_underline_vector() { 342 | let texts = vec!["A".to_string(), "B".to_string()]; 343 | let styled: Vec = underline(texts.clone()); 344 | let expected: Vec = texts 345 | .iter() 346 | .map(|t| format!("\x1b[4m{}\x1b[0m", t)) 347 | .collect(); 348 | assert_eq!(styled, expected); 349 | } 350 | 351 | #[test] 352 | fn test_bold_2d_vector() { 353 | let texts_2d = vec![ 354 | vec!["A".to_string(), "B".to_string()], 355 | vec!["C".to_string(), "D".to_string()], 356 | ]; 357 | let styled: Vec> = bold(texts_2d.clone()); // directly on Vec> 358 | let expected: Vec> = texts_2d 359 | .iter() 360 | .map(|row| row.iter().map(|t| format!("\x1b[1m{}\x1b[0m", t)).collect()) 361 | .collect(); 362 | assert_eq!(styled, expected); 363 | } 364 | 365 | #[test] 366 | fn test_italic_2d_vector() { 367 | let texts_2d = vec![ 368 | vec!["A".to_string(), "B".to_string()], 369 | vec!["C".to_string(), "D".to_string()], 370 | ]; 371 | let styled: Vec> = italic(texts_2d.clone()); 372 | let expected: Vec> = texts_2d 373 | .iter() 374 | .map(|row| row.iter().map(|t| format!("\x1b[3m{}\x1b[0m", t)).collect()) 375 | .collect(); 376 | assert_eq!(styled, expected); 377 | } 378 | 379 | #[test] 380 | fn test_faint_2d_vector() { 381 | let texts_2d = vec![ 382 | vec!["A".to_string(), "B".to_string()], 383 | vec!["C".to_string(), "D".to_string()], 384 | ]; 385 | let styled: Vec> = faint(texts_2d.clone()); 386 | let expected: Vec> = texts_2d 387 | .iter() 388 | .map(|row| row.iter().map(|t| format!("\x1b[2m{}\x1b[0m", t)).collect()) 389 | .collect(); 390 | assert_eq!(styled, expected); 391 | } 392 | 393 | #[test] 394 | fn test_inverse_2d_vector() { 395 | let texts_2d = vec![ 396 | vec!["A".to_string(), "B".to_string()], 397 | vec!["C".to_string(), "D".to_string()], 398 | ]; 399 | let styled: Vec> = inverse(texts_2d.clone()); 400 | let expected: Vec> = texts_2d 401 | .iter() 402 | .map(|row| row.iter().map(|t| format!("\x1b[7m{}\x1b[0m", t)).collect()) 403 | .collect(); 404 | assert_eq!(styled, expected); 405 | } 406 | 407 | #[test] 408 | fn test_underline_2d_vector() { 409 | let texts_2d = vec![ 410 | vec!["A".to_string(), "B".to_string()], 411 | vec!["C".to_string(), "D".to_string()], 412 | ]; 413 | let styled: Vec> = underline(texts_2d.clone()); 414 | let expected: Vec> = texts_2d 415 | .iter() 416 | .map(|row| row.iter().map(|t| format!("\x1b[4m{}\x1b[0m", t)).collect()) 417 | .collect(); 418 | assert_eq!(styled, expected); 419 | } 420 | 421 | #[test] 422 | fn test_2d_vector_foreground() { 423 | let texts_2d = vec![ 424 | vec!["One".to_string(), "Two".to_string()], 425 | vec!["Three".to_string(), "Four".to_string()], 426 | ]; 427 | let colored_2d: Vec> = color(46u8, texts_2d.clone()).unwrap(); // ANSI Green 428 | let expected_2d: Vec> = texts_2d 429 | .iter() 430 | .map(|row| { 431 | row.iter() 432 | .map(|t| format!("\x1b[38;5;46m{}\x1b[0m", t)) 433 | .collect() 434 | }) 435 | .collect(); 436 | assert_eq!(colored_2d, expected_2d); 437 | } 438 | 439 | #[test] 440 | fn test_2d_vector_background() { 441 | let texts_2d = vec![ 442 | vec!["One".to_string(), "Two".to_string()], 443 | vec!["Three".to_string(), "Four".to_string()], 444 | ]; 445 | let bg_colored_2d: Vec> = background(226u8, texts_2d.clone()).unwrap(); // ANSI Yellow BG 446 | let expected_bg_2d: Vec> = texts_2d 447 | .iter() 448 | .map(|row| { 449 | row.iter() 450 | .map(|t| format!("\x1b[48;5;226m{}\x1b[0m", t)) 451 | .collect() 452 | }) 453 | .collect(); 454 | assert_eq!(bg_colored_2d, expected_bg_2d); 455 | } 456 | --------------------------------------------------------------------------------