limb 0.1.0

A focused CLI for git worktree management
Documentation
//! Colour palettes for the picker.
//!
//! Themes are const-constructed: [`Theme::resolve`] returns a `Theme`
//! whose `dirty()` / `ahead()` / etc. methods hand out ready-to-use
//! ratatui [`Style`]s. `no_color` forces the `Plain` palette (all
//! `Color::Reset`), honoured by both `--no-color` and `$NO_COLOR`.

use ratatui::style::{Color, Modifier, Style};

/// Name-level palette selection (CLI / config `ui.theme`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ThemeKind {
    Vesper,
    Default,
    Nord,
    Gruvbox,
    Solarized,
    Plain,
}

impl ThemeKind {
    /// Parses a theme name (case-insensitive); unknown names return
    /// [`ThemeKind::Vesper`] (the built-in default).
    #[must_use]
    pub fn from_name(name: &str) -> Self {
        match name.to_ascii_lowercase().as_str() {
            "default" => Self::Default,
            "nord" => Self::Nord,
            "gruvbox" => Self::Gruvbox,
            "solarized" => Self::Solarized,
            "plain" | "none" | "no-color" => Self::Plain,
            _ => Self::Vesper,
        }
    }
}

/// Resolved palette. Construct via [`Self::resolve`].
pub struct Theme {
    accent: Color,
    muted: Color,
    border: Color,
    dirty: Color,
    ahead: Color,
    behind: Color,
    clean: Color,
    selection_bg: Option<Color>,
}

impl Theme {
    /// Returns the palette for `kind`, collapsing to `Plain` when
    /// `no_color` is `true`.
    #[must_use]
    pub const fn resolve(kind: ThemeKind, no_color: bool) -> Self {
        if no_color {
            return Self::plain();
        }
        match kind {
            ThemeKind::Vesper => Self::vesper(),
            ThemeKind::Default => Self::default_theme(),
            ThemeKind::Nord => Self::nord(),
            ThemeKind::Gruvbox => Self::gruvbox(),
            ThemeKind::Solarized => Self::solarized(),
            ThemeKind::Plain => Self::plain(),
        }
    }

    #[must_use]
    pub fn title(&self) -> Style {
        Style::default()
            .fg(self.accent)
            .add_modifier(Modifier::BOLD)
    }

    #[must_use]
    pub fn muted(&self) -> Style {
        Style::default().fg(self.muted)
    }

    #[must_use]
    pub fn accent(&self) -> Style {
        Style::default()
            .fg(self.accent)
            .add_modifier(Modifier::BOLD)
    }

    #[must_use]
    pub fn border(&self) -> Style {
        Style::default().fg(self.border)
    }

    #[must_use]
    pub fn selected(&self) -> Style {
        self.selection_bg.map_or_else(
            || {
                Style::default()
                    .add_modifier(Modifier::REVERSED)
                    .add_modifier(Modifier::BOLD)
            },
            |bg| Style::default().bg(bg).add_modifier(Modifier::BOLD),
        )
    }

    #[must_use]
    pub fn dirty(&self) -> Style {
        Style::default().fg(self.dirty)
    }

    #[must_use]
    pub fn ahead(&self) -> Style {
        Style::default().fg(self.ahead)
    }

    #[must_use]
    pub fn behind(&self) -> Style {
        Style::default().fg(self.behind)
    }

    #[must_use]
    pub fn clean(&self) -> Style {
        Style::default().fg(self.clean)
    }

    const fn plain() -> Self {
        Self {
            accent: Color::Reset,
            muted: Color::Reset,
            border: Color::Reset,
            dirty: Color::Reset,
            ahead: Color::Reset,
            behind: Color::Reset,
            clean: Color::Reset,
            selection_bg: None,
        }
    }

    const fn vesper() -> Self {
        Self {
            accent: Color::Rgb(0xff, 0xc7, 0x99),
            muted: Color::Rgb(0xa0, 0xa0, 0xa0),
            border: Color::Rgb(0x28, 0x28, 0x28),
            dirty: Color::Rgb(0xff, 0xc7, 0x99),
            ahead: Color::Rgb(0x99, 0xff, 0xe4),
            behind: Color::Rgb(0xff, 0x80, 0x80),
            clean: Color::Rgb(0x50, 0x50, 0x50),
            selection_bg: Some(Color::Rgb(0x23, 0x23, 0x23)),
        }
    }

    const fn default_theme() -> Self {
        Self {
            accent: Color::Cyan,
            muted: Color::DarkGray,
            border: Color::DarkGray,
            dirty: Color::Yellow,
            ahead: Color::Green,
            behind: Color::Red,
            clean: Color::DarkGray,
            selection_bg: None,
        }
    }

    const fn nord() -> Self {
        Self {
            accent: Color::Rgb(0x88, 0xc0, 0xd0),
            muted: Color::Rgb(0x4c, 0x56, 0x6a),
            border: Color::Rgb(0x4c, 0x56, 0x6a),
            dirty: Color::Rgb(0xeb, 0xcb, 0x8b),
            ahead: Color::Rgb(0xa3, 0xbe, 0x8c),
            behind: Color::Rgb(0xbf, 0x61, 0x6a),
            clean: Color::Rgb(0x4c, 0x56, 0x6a),
            selection_bg: Some(Color::Rgb(0x3b, 0x42, 0x52)),
        }
    }

    const fn gruvbox() -> Self {
        Self {
            accent: Color::Rgb(0xfa, 0xbd, 0x2f),
            muted: Color::Rgb(0x92, 0x83, 0x74),
            border: Color::Rgb(0x92, 0x83, 0x74),
            dirty: Color::Rgb(0xfa, 0xbd, 0x2f),
            ahead: Color::Rgb(0xb8, 0xbb, 0x26),
            behind: Color::Rgb(0xfb, 0x49, 0x34),
            clean: Color::Rgb(0x92, 0x83, 0x74),
            selection_bg: Some(Color::Rgb(0x3c, 0x38, 0x36)),
        }
    }

    const fn solarized() -> Self {
        Self {
            accent: Color::Rgb(0x26, 0x8b, 0xd2),
            muted: Color::Rgb(0x58, 0x6e, 0x75),
            border: Color::Rgb(0x58, 0x6e, 0x75),
            dirty: Color::Rgb(0xb5, 0x89, 0x00),
            ahead: Color::Rgb(0x85, 0x99, 0x00),
            behind: Color::Rgb(0xdc, 0x32, 0x2f),
            clean: Color::Rgb(0x58, 0x6e, 0x75),
            selection_bg: Some(Color::Rgb(0x07, 0x36, 0x42)),
        }
    }
}

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

    #[test]
    fn names_resolve() {
        assert_eq!(ThemeKind::from_name("vesper"), ThemeKind::Vesper);
        assert_eq!(ThemeKind::from_name("VESPER"), ThemeKind::Vesper);
        assert_eq!(ThemeKind::from_name("default"), ThemeKind::Default);
        assert_eq!(ThemeKind::from_name("nord"), ThemeKind::Nord);
        assert_eq!(ThemeKind::from_name("NORD"), ThemeKind::Nord);
        assert_eq!(ThemeKind::from_name("gruvbox"), ThemeKind::Gruvbox);
        assert_eq!(ThemeKind::from_name("solarized"), ThemeKind::Solarized);
        assert_eq!(ThemeKind::from_name("plain"), ThemeKind::Plain);
        assert_eq!(ThemeKind::from_name("unknown"), ThemeKind::Vesper);
        assert_eq!(ThemeKind::from_name(""), ThemeKind::Vesper);
    }

    #[test]
    fn no_color_matches_plain_theme() {
        let nord_no_color = Theme::resolve(ThemeKind::Nord, true);
        let plain = Theme::resolve(ThemeKind::Plain, false);
        assert_eq!(nord_no_color.dirty(), plain.dirty());
        assert_eq!(nord_no_color.accent(), plain.accent());
    }

    #[test]
    fn themed_colors_differ_from_plain() {
        let gruvbox = Theme::resolve(ThemeKind::Gruvbox, false);
        let plain = Theme::resolve(ThemeKind::Plain, false);
        assert_ne!(gruvbox.dirty(), plain.dirty());
    }

    #[test]
    fn vesper_differs_from_plain_and_default() {
        let vesper = Theme::resolve(ThemeKind::Vesper, false);
        let plain = Theme::resolve(ThemeKind::Plain, false);
        let default = Theme::resolve(ThemeKind::Default, false);
        assert_ne!(vesper.accent(), plain.accent());
        assert_ne!(vesper.accent(), default.accent());
        assert_ne!(vesper.selected(), default.selected());
    }
}