aisling 0.3.17

Embeddable terminal text effects and loaders for Rust TUI applications.
Documentation
use std::fmt;
use std::str::FromStr;

/// RGB color used by cells and gradients.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct Color {
    pub r: u8,
    pub g: u8,
    pub b: u8,
}

impl Color {
    pub const BLACK: Self = Self::rgb(0, 0, 0);
    pub const WHITE: Self = Self::rgb(255, 255, 255);

    pub const fn rgb(r: u8, g: u8, b: u8) -> Self {
        Self { r, g, b }
    }

    pub fn from_hex(input: &str) -> Result<Self, ParseColorError> {
        let hex = input.trim().trim_start_matches('#');
        if hex.len() != 6 || !hex.chars().all(|c| c.is_ascii_hexdigit()) {
            return Err(ParseColorError(input.to_string()));
        }
        let r =
            u8::from_str_radix(&hex[0..2], 16).map_err(|_| ParseColorError(input.to_string()))?;
        let g =
            u8::from_str_radix(&hex[2..4], 16).map_err(|_| ParseColorError(input.to_string()))?;
        let b =
            u8::from_str_radix(&hex[4..6], 16).map_err(|_| ParseColorError(input.to_string()))?;
        Ok(Self::rgb(r, g, b))
    }

    pub fn from_xterm(code: u8) -> Self {
        const SYSTEM: [Color; 16] = [
            Color::rgb(0, 0, 0),
            Color::rgb(128, 0, 0),
            Color::rgb(0, 128, 0),
            Color::rgb(128, 128, 0),
            Color::rgb(0, 0, 128),
            Color::rgb(128, 0, 128),
            Color::rgb(0, 128, 128),
            Color::rgb(192, 192, 192),
            Color::rgb(128, 128, 128),
            Color::rgb(255, 0, 0),
            Color::rgb(0, 255, 0),
            Color::rgb(255, 255, 0),
            Color::rgb(0, 0, 255),
            Color::rgb(255, 0, 255),
            Color::rgb(0, 255, 255),
            Color::rgb(255, 255, 255),
        ];
        if code < 16 {
            return SYSTEM[code as usize];
        }
        if code <= 231 {
            let n = code - 16;
            let scale = [0, 95, 135, 175, 215, 255];
            let r = scale[(n / 36) as usize];
            let g = scale[((n % 36) / 6) as usize];
            let b = scale[(n % 6) as usize];
            return Self::rgb(r, g, b);
        }
        let gray = 8 + (code - 232) * 10;
        Self::rgb(gray, gray, gray)
    }

    pub fn to_hex(self) -> String {
        format!("#{:02x}{:02x}{:02x}", self.r, self.g, self.b)
    }

    pub fn blend(self, other: Color, amount: f32) -> Self {
        let t = amount.clamp(0.0, 1.0);
        let lerp = |a: u8, b: u8| a as f32 + (b as f32 - a as f32) * t;
        Self::rgb(
            lerp(self.r, other.r).round() as u8,
            lerp(self.g, other.g).round() as u8,
            lerp(self.b, other.b).round() as u8,
        )
    }

    pub fn dim(self, amount: f32) -> Self {
        let t = amount.clamp(0.0, 1.0);
        Self::rgb(
            (self.r as f32 * t).round() as u8,
            (self.g as f32 * t).round() as u8,
            (self.b as f32 * t).round() as u8,
        )
    }

    pub fn brighten(self, amount: f32) -> Self {
        self.blend(Color::WHITE, amount)
    }
}

impl FromStr for Color {
    type Err = ParseColorError;

    fn from_str(input: &str) -> Result<Self, Self::Err> {
        let trimmed = input.trim();
        if let Ok(code) = trimmed.parse::<u8>() {
            return Ok(Self::from_xterm(code));
        }
        Self::from_hex(trimmed)
    }
}

impl fmt::Display for Color {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(&self.to_hex())
    }
}

#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ParseColorError(String);

impl fmt::Display for ParseColorError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(
            f,
            "invalid color {:?}; expected #rrggbb, rrggbb, or xterm 0-255",
            self.0
        )
    }
}

impl std::error::Error for ParseColorError {}

/// Foreground/background color pair.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
pub struct ColorPair {
    pub fg: Option<Color>,
    pub bg: Option<Color>,
}

impl ColorPair {
    pub const fn new(fg: Option<Color>, bg: Option<Color>) -> Self {
        Self { fg, bg }
    }

    pub const fn fg(fg: Color) -> Self {
        Self {
            fg: Some(fg),
            bg: None,
        }
    }

    pub const fn bg(bg: Color) -> Self {
        Self {
            fg: None,
            bg: Some(bg),
        }
    }
}

#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
pub enum GradientDirection {
    #[default]
    Vertical,
    Horizontal,
    Radial,
    Diagonal,
}

/// Precomputed RGB gradient used by Aisling renderers.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Gradient {
    stops: Vec<Color>,
    steps: usize,
    looped: bool,
    spectrum: Vec<Color>,
}

impl Gradient {
    pub fn new(stops: impl Into<Vec<Color>>, steps: usize) -> Self {
        Self::with_loop(stops, steps, false)
    }

    pub fn with_loop(stops: impl Into<Vec<Color>>, steps: usize, looped: bool) -> Self {
        let mut stops = stops.into();
        if stops.is_empty() {
            stops.push(Color::WHITE);
        }
        let steps = steps.max(1);
        let mut gradient = Self {
            stops,
            steps,
            looped,
            spectrum: Vec::new(),
        };
        gradient.spectrum = gradient.generate();
        gradient
    }

    pub fn terminal_text_default() -> Self {
        Self::new(
            vec![
                Color::rgb(0x02, 0xb8, 0xbd),
                Color::rgb(0xc1, 0xf0, 0xe3),
                Color::rgb(0x00, 0xff, 0xa0),
            ],
            24,
        )
    }

    pub fn stops(&self) -> &[Color] {
        &self.stops
    }

    pub fn spectrum(&self) -> &[Color] {
        &self.spectrum
    }

    pub fn color_at(&self, fraction: f32) -> Color {
        if self.spectrum.is_empty() {
            return Color::WHITE;
        }
        let index = (fraction.clamp(0.0, 1.0) * (self.spectrum.len().saturating_sub(1)) as f32)
            .round() as usize;
        self.spectrum[index.min(self.spectrum.len() - 1)]
    }

    pub fn coordinate_color(
        &self,
        x: usize,
        y: usize,
        width: usize,
        height: usize,
        direction: GradientDirection,
    ) -> Color {
        let max_x = width.saturating_sub(1).max(1) as f32;
        let max_y = height.saturating_sub(1).max(1) as f32;
        let x = x as f32;
        let y = y as f32;
        let fraction = match direction {
            GradientDirection::Vertical => y / max_y,
            GradientDirection::Horizontal => x / max_x,
            GradientDirection::Diagonal => (x / max_x + y / max_y) / 2.0,
            GradientDirection::Radial => {
                let cx = max_x / 2.0;
                let cy = max_y / 2.0;
                let max_dist = (cx.powi(2) + cy.powi(2)).sqrt().max(1.0);
                (((x - cx).powi(2) + (y - cy).powi(2)).sqrt() / max_dist).clamp(0.0, 1.0)
            }
        };
        self.color_at(fraction)
    }

    fn generate(&self) -> Vec<Color> {
        if self.stops.len() == 1 {
            return vec![self.stops[0]; self.steps + 1];
        }
        let mut stops = self.stops.clone();
        if self.looped {
            stops.push(self.stops[0]);
        }
        let mut spectrum = Vec::new();
        for pair in stops.windows(2) {
            for step in 0..self.steps {
                let t = step as f32 / self.steps as f32;
                spectrum.push(pair[0].blend(pair[1], t));
            }
        }
        spectrum.push(*stops.last().unwrap_or(&Color::WHITE));
        spectrum
    }
}

impl Default for Gradient {
    fn default() -> Self {
        Self::terminal_text_default()
    }
}