Files
mikusays-c/src/color.rs
T
2025-11-30 00:39:08 +02:00

628 lines
19 KiB
Rust

use clap::ValueEnum;
// No external sysinfo dependency — use environment heuristics instead for Windows shell detection.
use std::error::Error;
use std::fmt;
use std::str::FromStr;
#[derive(Debug, Clone, PartialEq)]
pub enum Color {
Named(NamedColor),
Hex(String),
Rgb(u8, u8, u8),
Hsl(u16, u8, u8), // h: 0-360, s: 0-100, l: 0-100
Ansi(u8), // ANSI color code (0-255)
Truecolor(u8, u8, u8), // 24-bit RGB
}
#[derive(Debug, Clone, Copy, PartialEq, ValueEnum)]
pub enum NamedColor {
Black,
Red,
Green,
Yellow,
Blue,
Magenta,
Cyan,
White,
BrightBlack,
BrightRed,
BrightGreen,
BrightYellow,
BrightBlue,
BrightMagenta,
BrightCyan,
BrightWhite,
}
#[derive(Debug)]
pub enum ColorParseError {
InvalidFormat,
InvalidHex,
InvalidRgb,
InvalidHsl,
UnknownNamedColor,
}
impl fmt::Display for ColorParseError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ColorParseError::InvalidFormat => write!(f, "Invalid color format"),
ColorParseError::InvalidHex => write!(f, "Invalid hex color code"),
ColorParseError::InvalidRgb => write!(f, "Invalid RGB format. Expected format: R,G,B"),
ColorParseError::InvalidHsl => {
write!(f, "Invalid HSL format. Expected format: hsl(H,S%,L%)")
}
ColorParseError::UnknownNamedColor => write!(f, "Unknown named color"),
}
}
}
impl Error for ColorParseError {}
impl FromStr for Color {
type Err = ColorParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let s_lower = s.to_lowercase();
// Try named color first
if let Ok(named) = NamedColor::from_str(s, true) {
return Ok(Color::Named(named));
}
// Try hex color (#RRGGBB or #RGB)
if s.starts_with('#') {
return parse_hex_color(s);
}
// Try HSL (hsl(180,100%,50%))
if s_lower.starts_with("hsl(") && s.ends_with(')') {
return parse_hsl_color(s);
}
// Try RGB tuple (255,0,128)
if s.contains(',') {
return parse_rgb_color(s);
}
// Try ANSI code (number between 0-255)
if let Ok(code) = s.parse::<u8>() {
return Ok(Color::Ansi(code));
}
Err(ColorParseError::InvalidFormat)
}
}
impl FromStr for NamedColor {
type Err = ColorParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::from_str(s, false)
}
}
impl NamedColor {
fn from_str(s: &str, ignore_case: bool) -> Result<Self, ColorParseError> {
let s = if ignore_case {
s.to_lowercase()
} else {
s.to_string()
};
match s.as_str() {
"black" => Ok(NamedColor::Black),
"red" => Ok(NamedColor::Red),
"green" => Ok(NamedColor::Green),
"yellow" => Ok(NamedColor::Yellow),
"blue" => Ok(NamedColor::Blue),
"magenta" => Ok(NamedColor::Magenta),
"cyan" => Ok(NamedColor::Cyan),
"white" => Ok(NamedColor::White),
"brightblack" | "bright_black" => Ok(NamedColor::BrightBlack),
"brightred" | "bright_red" => Ok(NamedColor::BrightRed),
"brightgreen" | "bright_green" => Ok(NamedColor::BrightGreen),
"brightyellow" | "bright_yellow" => Ok(NamedColor::BrightYellow),
"brightblue" | "bright_blue" => Ok(NamedColor::BrightBlue),
"brightmagenta" | "bright_magenta" => Ok(NamedColor::BrightMagenta),
"brightcyan" | "bright_cyan" => Ok(NamedColor::BrightCyan),
"brightwhite" | "bright_white" => Ok(NamedColor::BrightWhite),
_ => Err(ColorParseError::UnknownNamedColor),
}
}
}
fn parse_hex_color(s: &str) -> Result<Color, ColorParseError> {
let hex = s.trim_start_matches('#');
match hex.len() {
3 => {
// #RGB format
let r = u8::from_str_radix(&hex[0..1].repeat(2), 16)
.map_err(|_| ColorParseError::InvalidHex)?;
let g = u8::from_str_radix(&hex[1..2].repeat(2), 16)
.map_err(|_| ColorParseError::InvalidHex)?;
let b = u8::from_str_radix(&hex[2..3].repeat(2), 16)
.map_err(|_| ColorParseError::InvalidHex)?;
Ok(Color::Hex(format!("#{:02x}{:02x}{:02x}", r, g, b)))
}
6 => {
// #RRGGBB format
let r = u8::from_str_radix(&hex[0..2], 16).map_err(|_| ColorParseError::InvalidHex)?;
let g = u8::from_str_radix(&hex[2..4], 16).map_err(|_| ColorParseError::InvalidHex)?;
let b = u8::from_str_radix(&hex[4..6], 16).map_err(|_| ColorParseError::InvalidHex)?;
Ok(Color::Hex(format!("#{:02x}{:02x}{:02x}", r, g, b)))
}
_ => Err(ColorParseError::InvalidHex),
}
}
fn parse_rgb_color(s: &str) -> Result<Color, ColorParseError> {
let parts: Vec<&str> = s.split(',').map(|p| p.trim()).collect();
if parts.len() != 3 {
return Err(ColorParseError::InvalidRgb);
}
let r = parts[0]
.parse::<u8>()
.map_err(|_| ColorParseError::InvalidRgb)?;
let g = parts[1]
.parse::<u8>()
.map_err(|_| ColorParseError::InvalidRgb)?;
let b = parts[2]
.parse::<u8>()
.map_err(|_| ColorParseError::InvalidRgb)?;
Ok(Color::Rgb(r, g, b))
}
fn parse_hsl_color(s: &str) -> Result<Color, ColorParseError> {
let content = s.trim_start_matches("hsl(").trim_end_matches(')');
let parts: Vec<&str> = content.split(',').map(|p| p.trim()).collect();
if parts.len() != 3 {
return Err(ColorParseError::InvalidHsl);
}
let h = parts[0]
.trim_end_matches(|c: char| !c.is_ascii_digit())
.parse::<u16>()
.map_err(|_| ColorParseError::InvalidHsl)?;
let s = parts[1]
.trim_end_matches('%')
.parse::<u8>()
.map_err(|_| ColorParseError::InvalidHsl)?;
let l = parts[2]
.trim_end_matches('%')
.parse::<u8>()
.map_err(|_| ColorParseError::InvalidHsl)?;
if h > 360 || s > 100 || l > 100 {
return Err(ColorParseError::InvalidHsl);
}
Ok(Color::Hsl(h, s, l))
}
impl Color {
/// Convert color to RGB tuple
pub fn to_rgb(&self) -> (u8, u8, u8) {
match self {
Color::Named(named) => named.to_rgb(),
Color::Hex(hex) => {
let hex = hex.trim_start_matches('#');
match hex.len() {
3 => {
// #RGB format
let r = u8::from_str_radix(&hex[0..1].repeat(2), 16).unwrap_or(0);
let g = u8::from_str_radix(&hex[1..2].repeat(2), 16).unwrap_or(0);
let b = u8::from_str_radix(&hex[2..3].repeat(2), 16).unwrap_or(0);
(r, g, b)
}
6 => {
// #RRGGBB format
let r = u8::from_str_radix(&hex[0..2], 16).unwrap_or(0);
let g = u8::from_str_radix(&hex[2..4], 16).unwrap_or(0);
let b = u8::from_str_radix(&hex[4..6], 16).unwrap_or(0);
(r, g, b)
}
_ => (0, 0, 0),
}
}
Color::Rgb(r, g, b) => (*r, *g, *b),
Color::Hsl(h, s, l) => hsl_to_rgb(*h, *s, *l),
Color::Ansi(code) => ansi_to_rgb(*code),
Color::Truecolor(r, g, b) => (*r, *g, *b),
}
}
/// Convert color to ANSI escape code for foreground
pub fn to_ansi_fg(&self, color_mode: ColorMode) -> String {
match color_mode {
ColorMode::Truecolor => self.to_truecolor_fg(),
ColorMode::Ansi256 => self.to_ansi256_fg(),
ColorMode::Ansi16 => self.to_ansi16_fg(),
ColorMode::NoColor => "".to_string(),
}
}
fn to_truecolor_fg(&self) -> String {
let (r, g, b) = self.to_rgb();
format!("\x1b[38;2;{};{};{}m", r, g, b)
}
fn to_ansi256_fg(&self) -> String {
let (r, g, b) = self.to_rgb();
let code = rgb_to_ansi256(r, g, b);
format!("\x1b[38;5;{}m", code)
}
fn to_ansi16_fg(&self) -> String {
let (r, g, b) = self.to_rgb();
let code = rgb_to_ansi16(r, g, b);
format!("\x1b[{}m", code)
}
}
impl NamedColor {
pub fn to_rgb(self) -> (u8, u8, u8) {
match self {
NamedColor::Black => (0, 0, 0),
NamedColor::Red => (197, 15, 31),
NamedColor::Green => (19, 161, 14),
NamedColor::Yellow => (193, 156, 0),
NamedColor::Blue => (0, 55, 218),
NamedColor::Magenta => (136, 23, 152),
NamedColor::Cyan => (58, 150, 221),
NamedColor::White => (204, 204, 204),
NamedColor::BrightBlack => (118, 118, 118),
NamedColor::BrightRed => (231, 72, 86),
NamedColor::BrightGreen => (22, 198, 12),
NamedColor::BrightYellow => (249, 241, 165),
NamedColor::BrightBlue => (59, 120, 255),
NamedColor::BrightMagenta => (180, 0, 158),
NamedColor::BrightCyan => (97, 214, 214),
NamedColor::BrightWhite => (242, 242, 242),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum ColorMode {
Truecolor,
Ansi256,
Ansi16,
NoColor,
}
/// Convert HSL to RGB
fn hsl_to_rgb(h: u16, s: u8, l: u8) -> (u8, u8, u8) {
let h = h as f32 / 360.0;
let s = s as f32 / 100.0;
let l = l as f32 / 100.0;
let r;
let g;
let b;
if s == 0.0 {
r = l;
g = l;
b = l;
} else {
let hue_to_rgb = |p: f32, q: f32, t: f32| -> f32 {
let mut t = t;
if t < 0.0 {
t += 1.0;
}
if t > 1.0 {
t -= 1.0;
}
if t < 1.0 / 6.0 {
return p + (q - p) * 6.0 * t;
}
if t < 1.0 / 2.0 {
return q;
}
if t < 2.0 / 3.0 {
return p + (q - p) * (2.0 / 3.0 - t) * 6.0;
}
p
};
let q = if l < 0.5 {
l * (1.0 + s)
} else {
l + s - l * s
};
let p = 2.0 * l - q;
r = hue_to_rgb(p, q, h + 1.0 / 3.0);
g = hue_to_rgb(p, q, h);
b = hue_to_rgb(p, q, h - 1.0 / 3.0);
}
(
(r * 255.0).round() as u8,
(g * 255.0).round() as u8,
(b * 255.0).round() as u8,
)
}
/// Convert RGB to ANSI 256 color code
fn rgb_to_ansi256(r: u8, g: u8, b: u8) -> u8 {
// Calculate grayscale value
let gray = (r as f32 * 0.299 + g as f32 * 0.587 + b as f32 * 0.114).round() as u8;
// Check if it's a grayscale color
if r == g && g == b {
// Grayscale ramp (232-255)
if gray <= 8 {
16
} else if gray >= 248 {
231
} else {
((gray as f32 - 8.0) / 247.0 * 24.0).round() as u8 + 232
}
} else {
// Color cube (16-231)
let r = (r as f32 / 255.0 * 5.0).round() as u8;
let g = (g as f32 / 255.0 * 5.0).round() as u8;
let b = (b as f32 / 255.0 * 5.0).round() as u8;
16 + (r * 36) + (g * 6) + b
}
}
/// Convert RGB to ANSI 16 color code
pub(crate) fn rgb_to_ansi16(r: u8, g: u8, b: u8) -> u8 {
// Calculate brightness (perceived luminance)
let brightness = (r as f32 * 0.299 + g as f32 * 0.587 + b as f32 * 0.114).round() as u8;
let is_bright = brightness > 128;
// Determine base color using simple thresholding on each channel, producing an index 0..7
let r_on = r > 128;
let g_on = g > 128;
let b_on = b > 128;
let base_idx = (r_on as u8) + (g_on as u8) * 2 + (b_on as u8) * 4; // 0..7
// SGR color codes: 30..37 for normal, 90..97 for bright
if is_bright {
90 + base_idx
} else {
30 + base_idx
}
}
/// Convert ANSI color code to RGB
fn ansi_to_rgb(code: u8) -> (u8, u8, u8) {
match code {
// Standard 16 colors
0 => (0, 0, 0), // Black
1 => (128, 0, 0), // Red
2 => (0, 128, 0), // Green
3 => (128, 128, 0), // Yellow
4 => (0, 0, 128), // Blue
5 => (128, 0, 128), // Magenta
6 => (0, 128, 128), // Cyan
7 => (192, 192, 192), // White
8 => (128, 128, 128), // Bright Black
9 => (255, 0, 0), // Bright Red
10 => (0, 255, 0), // Bright Green
11 => (255, 255, 0), // Bright Yellow
12 => (0, 0, 255), // Bright Blue
13 => (255, 0, 255), // Bright Magenta
14 => (0, 255, 255), // Bright Cyan
15 => (255, 255, 255), // Bright White
// 256-color mode
16..=231 => {
// Color cube
let code = code - 16;
let r = ((code / 36) % 6) * 51;
let g = ((code / 6) % 6) * 51;
let b = (code % 6) * 51;
(r, g, b)
}
232..=255 => {
// Grayscale ramp
let gray = (code - 232) * 10 + 8;
(gray, gray, gray)
}
}
}
// (Removed older detect_color_mode to keep a single refactored implementation)
/// Try to enable ANSI (virtual terminal processing) on Windows consoles.
/// Returns true if enabling succeeded or isn't necessary, false otherwise.
#[cfg(windows)]
pub fn enable_windows_ansi() -> bool {
// no ptr usage required; removed unused import
use winapi::um::consoleapi::{GetConsoleMode, SetConsoleMode};
use winapi::um::handleapi::INVALID_HANDLE_VALUE;
use winapi::um::processenv::GetStdHandle;
use winapi::um::winbase::STD_OUTPUT_HANDLE;
use winapi::um::wincon::ENABLE_VIRTUAL_TERMINAL_PROCESSING;
unsafe {
let stdout = GetStdHandle(STD_OUTPUT_HANDLE);
if stdout == INVALID_HANDLE_VALUE {
return false;
}
let mut mode: u32 = 0;
if GetConsoleMode(stdout, &mut mode) == 0 {
return false;
}
let new_mode = mode | ENABLE_VIRTUAL_TERMINAL_PROCESSING;
if SetConsoleMode(stdout, new_mode) == 0 {
return false;
}
}
true
}
/// Detect the parent shell name on Windows, using environment hints.
#[cfg(windows)]
pub fn detect_windows_shell() -> Option<String> {
// Heuristic checks using common env vars
if std::env::var("PSModulePath").is_ok() {
return Some("powershell".to_string());
}
if std::env::var("PWshPROFILE").is_ok() || std::env::var("PWSH_VERSION").is_ok() {
return Some("pwsh".to_string());
}
if let Ok(comspec) = std::env::var("COMSPEC") {
if comspec.to_lowercase().ends_with("cmd.exe") {
return Some("cmd.exe".to_string());
}
}
if std::env::var("WT_SESSION").is_ok() {
return Some("windows_terminal".to_string());
}
None
}
#[cfg(not(windows))]
#[allow(dead_code)]
pub fn detect_windows_shell() -> Option<String> {
None
}
/// Convert Color to a crossterm Color (helper for use across platforms)
pub fn to_crossterm_color(color: &Color) -> crossterm::style::Color {
let (r, g, b) = color.to_rgb();
crossterm::style::Color::Rgb { r, g, b }
}
/// Detect the parent shell name on Windows, using the sysinfo crate.
pub fn detect_color_mode() -> ColorMode {
if std::env::var("NO_COLOR").is_ok() {
return ColorMode::NoColor;
}
// Early checks
if std::env::var("COLORTERM")
.is_ok_and(|colorterm| colorterm.contains("truecolor") || colorterm.contains("24bit"))
{
return ColorMode::Truecolor;
}
if std::env::var("TERM").is_ok_and(|term| term.contains("256color")) {
return ColorMode::Ansi256;
}
if std::env::var("TERM").is_ok_and(|term| term.contains("color")) {
return ColorMode::Ansi16;
}
// Default fallback
#[cfg(windows)]
let mut mode = ColorMode::Ansi16;
#[cfg(not(windows))]
let mode = ColorMode::Ansi16;
// If running on Windows, try to enable ANSI (virtual terminal processing)
#[cfg(windows)]
{
if enable_windows_ansi() {
// Re-check for a better mode
if let Ok(colorterm) = std::env::var("COLORTERM") {
if colorterm.contains("truecolor") || colorterm.contains("24bit") {
mode = ColorMode::Truecolor;
}
}
if let Ok(term) = std::env::var("TERM") {
if term.contains("256color") {
mode = ColorMode::Ansi256;
}
}
} else if let Some(shell) = detect_windows_shell() {
let shell = shell.to_lowercase();
if shell.contains("cmd.exe") {
// cmd historically doesn't support ANSI; use 16 colors fallback
mode = ColorMode::Ansi16;
} else if shell.contains("powershell") || shell.contains("pwsh") {
if let Ok(term) = std::env::var("TERM") {
if term.contains("256color") {
mode = ColorMode::Ansi256;
}
}
} else {
mode = ColorMode::Ansi16;
}
}
}
mode
}
/// Convert RGB to HSV
fn rgb_to_hsv((r, g, b): (u8, u8, u8)) -> (f32, f32, f32) {
let r = r as f32 / 255.0;
let g = g as f32 / 255.0;
let b = b as f32 / 255.0;
let max = r.max(g).max(b);
let min = r.min(g).min(b);
let delta = max - min;
let h = if delta == 0.0 {
0.0
} else if max == r {
60.0 * (((g - b) / delta) % 6.0)
} else if max == g {
60.0 * (((b - r) / delta) + 2.0)
} else {
60.0 * (((r - g) / delta) + 4.0)
};
let h = if h < 0.0 { h + 360.0 } else { h };
let s = if max == 0.0 { 0.0 } else { delta / max };
let v = max;
(h, s, v)
}
/// Linear interpolation between two colors in HSV space
pub fn lerp_color_hsv(start: &Color, end: &Color, t: f32) -> Color {
let (h1, s1, v1) = rgb_to_hsv(start.to_rgb());
let (h2, s2, v2) = rgb_to_hsv(end.to_rgb());
// Handle hue interpolation correctly (shortest path around the color wheel)
let h_diff = (h2 - h1 + 180.0) % 360.0 - 180.0;
let h = (h1 + h_diff * t + 360.0) % 360.0;
let s = s1 + (s2 - s1) * t;
let v = v1 + (v2 - v1) * t;
let (r, g, b) = hsv_to_rgb(h, s, v);
Color::Truecolor(r, g, b)
}
/// Convert HSV to RGB
fn hsv_to_rgb(h: f32, s: f32, v: f32) -> (u8, u8, u8) {
let c = v * s;
let x = c * (1.0 - ((h / 60.0) % 2.0 - 1.0).abs());
let m = v - c;
let (r, g, b) = match h {
h if (0.0..60.0).contains(&h) => (c, x, 0.0),
h if (60.0..120.0).contains(&h) => (x, c, 0.0),
h if (120.0..180.0).contains(&h) => (0.0, c, x),
h if (180.0..240.0).contains(&h) => (0.0, x, c),
h if (240.0..300.0).contains(&h) => (x, 0.0, c),
_ => (c, 0.0, x),
};
(
((r + m) * 255.0).round() as u8,
((g + m) * 255.0).round() as u8,
((b + m) * 255.0).round() as u8,
)
}