grav-bar 26.9.1

Fast, zero-dependency, and themed status line for the Google Antigravity CLI. Compatible also with Claude code.
//! Color themes.
//!
//! A theme names the five text colors used by the bar and a usage gradient that
//! colors percentages and gauge cells by how full they are.

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Color {
    /// 24-bit truecolor.
    Rgb(u8, u8, u8),
    /// A plain SGR code such as `33` (yellow) or `90` (bright black).
    Ansi(u8),
}

#[derive(Clone, Copy, Debug)]
pub enum Gradient {
    /// RGB stops spread evenly over 0..=100 and interpolated linearly.
    Stops(&'static [(u8, u8, u8)]),
    /// 16-color fallback: green below 50, yellow below 80, red above.
    Thresholds,
}

#[derive(Debug)]
pub struct Theme {
    pub name: &'static str,
    pub user: Color,
    pub path: Color,
    pub branch: Color,
    pub model: Color,
    pub dim: Color,
    pub gradient: Gradient,
}

pub const RESET: &str = "\x1b[0m";
pub const BOLD: &str = "\x1b[1m";

/// SGR sequence that sets the foreground to `c`.
pub fn fg(c: Color) -> String {
    match c {
        Color::Rgb(r, g, b) => format!("\x1b[38;2;{r};{g};{b}m"),
        Color::Ansi(n) => format!("\x1b[{n}m"),
    }
}

use Color::{Ansi, Rgb};

static THEMES: [Theme; 7] = [
    // Mirrors ~/.claude/statusline-command.sh: sky / lavender / pink / indigo / slate.
    Theme {
        name: "default",
        user: Rgb(125, 211, 252),
        path: Rgb(196, 181, 253),
        branch: Rgb(244, 114, 182),
        model: Rgb(129, 140, 248),
        dim: Rgb(100, 116, 139),
        gradient: Gradient::Stops(&[(96, 165, 250), (167, 139, 250), (244, 114, 182)]),
    },
    // The original grav-bar look; works on terminals without truecolor.
    Theme {
        name: "classic",
        user: Ansi(36),
        path: Ansi(33),
        branch: Ansi(35),
        model: Ansi(34),
        dim: Ansi(90),
        gradient: Gradient::Thresholds,
    },
    Theme {
        name: "nord",
        user: Rgb(136, 192, 208),
        path: Rgb(129, 161, 193),
        branch: Rgb(180, 142, 173),
        model: Rgb(143, 188, 187),
        dim: Rgb(76, 86, 106),
        gradient: Gradient::Stops(&[(163, 190, 140), (235, 203, 139), (191, 97, 106)]),
    },
    Theme {
        name: "catppuccin-mocha",
        user: Rgb(137, 220, 235),
        path: Rgb(180, 190, 254),
        branch: Rgb(245, 194, 231),
        model: Rgb(203, 166, 247),
        dim: Rgb(108, 112, 134),
        gradient: Gradient::Stops(&[(166, 227, 161), (249, 226, 175), (243, 139, 168)]),
    },
    Theme {
        name: "gruvbox",
        user: Rgb(142, 192, 124),
        path: Rgb(250, 189, 47),
        branch: Rgb(211, 134, 155),
        model: Rgb(131, 165, 152),
        dim: Rgb(146, 131, 116),
        gradient: Gradient::Stops(&[(184, 187, 38), (254, 128, 25), (251, 73, 52)]),
    },
    Theme {
        name: "dracula",
        user: Rgb(139, 233, 253),
        path: Rgb(189, 147, 249),
        branch: Rgb(255, 121, 198),
        model: Rgb(80, 250, 123),
        dim: Rgb(98, 114, 164),
        gradient: Gradient::Stops(&[(80, 250, 123), (241, 250, 140), (255, 85, 85)]),
    },
    Theme {
        name: "tokyo-night",
        user: Rgb(125, 207, 255),
        path: Rgb(122, 162, 247),
        branch: Rgb(187, 154, 247),
        model: Rgb(42, 195, 222),
        dim: Rgb(86, 95, 137),
        gradient: Gradient::Stops(&[(158, 206, 106), (224, 175, 104), (247, 118, 142)]),
    },
];

impl Theme {
    pub fn all() -> &'static [Theme] {
        &THEMES
    }

    pub fn default_theme() -> &'static Theme {
        &THEMES[0]
    }

    /// Case-insensitive lookup. `catppuccin` is accepted for `catppuccin-mocha`.
    pub fn by_name(name: &str) -> Option<&'static Theme> {
        let name = name.trim();
        let name = if name.eq_ignore_ascii_case("catppuccin") {
            "catppuccin-mocha"
        } else {
            name
        };
        THEMES.iter().find(|t| t.name.eq_ignore_ascii_case(name))
    }

    /// Foreground SGR for a 0..=100 usage percentage on this theme's gradient.
    pub fn grad_color(&self, pct: u32) -> String {
        match self.gradient {
            Gradient::Thresholds => fg(Ansi(if pct < 50 {
                32
            } else if pct < 80 {
                33
            } else {
                31
            })),
            Gradient::Stops(stops) => {
                let p = pct.min(100) as i32;
                let n = stops.len() as i32 - 1;
                if n <= 0 {
                    let (r, g, b) = stops[0];
                    return fg(Rgb(r, g, b));
                }
                let seg = (p * n / 100).min(n - 1);
                let t = p * n - seg * 100; // 0..=100 within the segment
                let (r0, g0, b0) = stops[seg as usize];
                let (r1, g1, b1) = stops[seg as usize + 1];
                let lerp = |a: u8, b: u8| (a as i32 + (b as i32 - a as i32) * t / 100) as u8;
                fg(Rgb(lerp(r0, r1), lerp(g0, g1), lerp(b0, b1)))
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn every_theme_resolves_by_name() {
        for t in Theme::all() {
            assert_eq!(Theme::by_name(t.name).unwrap().name, t.name);
            assert_eq!(Theme::by_name(&t.name.to_uppercase()).unwrap().name, t.name);
        }
        assert_eq!(
            Theme::by_name("catppuccin").unwrap().name,
            "catppuccin-mocha"
        );
        assert!(Theme::by_name("bogus").is_none());
        assert_eq!(Theme::default_theme().name, "default");
    }

    #[test]
    fn gradient_hits_stops_at_0_50_100() {
        let t = Theme::default_theme();
        assert_eq!(t.grad_color(0), fg(Rgb(96, 165, 250)));
        assert_eq!(t.grad_color(50), fg(Rgb(167, 139, 250)));
        assert_eq!(t.grad_color(100), fg(Rgb(244, 114, 182)));
        assert_eq!(t.grad_color(250), fg(Rgb(244, 114, 182)));
        // Midpoint of the first segment, matching the bash integer arithmetic.
        assert_eq!(t.grad_color(25), fg(Rgb(131, 152, 250)));
    }

    #[test]
    fn classic_uses_thresholds() {
        let t = Theme::by_name("classic").unwrap();
        assert_eq!(t.grad_color(0), "\x1b[32m");
        assert_eq!(t.grad_color(49), "\x1b[32m");
        assert_eq!(t.grad_color(50), "\x1b[33m");
        assert_eq!(t.grad_color(80), "\x1b[31m");
    }
}