use ratatui::style::{Color, Modifier, Style};
use crate::model::PrState;
use crate::tui::app::{Mode, StatusKind};
const ACCENT: Color = Color::Rgb(97, 175, 239);
const GREEN: Color = Color::Rgb(152, 195, 121);
const RED: Color = Color::Rgb(224, 108, 117);
const YELLOW: Color = Color::Rgb(229, 192, 123);
const ORANGE: Color = Color::Rgb(209, 154, 102);
const CYAN: Color = Color::Rgb(86, 182, 194);
const MAGENTA: Color = Color::Rgb(198, 120, 221);
const GRAY: Color = Color::Rgb(92, 99, 112);
const SELECTION_BG: Color = Color::Rgb(62, 68, 81);
const CHIP_FG: Color = Color::Rgb(30, 33, 39);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Palette {
pub accent: Color,
pub green: Color,
pub red: Color,
pub yellow: Color,
pub orange: Color,
pub cyan: Color,
pub magenta: Color,
pub gray: Color,
pub selection_bg: Color,
pub chip_fg: Color,
}
impl Palette {
pub fn one_dark() -> Palette {
Palette {
accent: ACCENT,
green: GREEN,
red: RED,
yellow: YELLOW,
orange: ORANGE,
cyan: CYAN,
magenta: MAGENTA,
gray: GRAY,
selection_bg: SELECTION_BG,
chip_fg: CHIP_FG,
}
}
pub fn solarized() -> Palette {
Palette {
accent: Color::Rgb(38, 139, 210), green: Color::Rgb(133, 153, 0), red: Color::Rgb(220, 50, 47), yellow: Color::Rgb(181, 137, 0), orange: Color::Rgb(203, 75, 22), cyan: Color::Rgb(42, 161, 152), magenta: Color::Rgb(211, 54, 130), gray: Color::Rgb(88, 110, 117), selection_bg: Color::Rgb(7, 54, 66), chip_fg: Color::Rgb(0, 43, 54), }
}
}
impl Default for Palette {
fn default() -> Self {
Palette::one_dark()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ThemePreset {
#[default]
OneDark,
Solarized,
}
impl ThemePreset {
pub fn parse(s: &str) -> Option<ThemePreset> {
match s {
"one-dark" => Some(ThemePreset::OneDark),
"solarized" => Some(ThemePreset::Solarized),
_ => None,
}
}
pub fn id(self) -> &'static str {
match self {
ThemePreset::OneDark => "one-dark",
ThemePreset::Solarized => "solarized",
}
}
pub fn palette(self) -> Palette {
match self {
ThemePreset::OneDark => Palette::one_dark(),
ThemePreset::Solarized => Palette::solarized(),
}
}
}
pub struct Theme {
enabled: bool,
palette: Palette,
}
impl Theme {
pub fn new(enabled: bool) -> Theme {
Theme {
enabled,
palette: Palette::one_dark(),
}
}
pub fn with_palette(enabled: bool, palette: Palette) -> Theme {
Theme { enabled, palette }
}
pub fn enabled(&self) -> bool {
self.enabled
}
fn fg(&self, color: Color) -> Style {
if self.enabled {
Style::default().fg(color)
} else {
Style::default()
}
}
fn styled(&self, color: Color, modifier: Modifier) -> Style {
if self.enabled {
Style::default().fg(color).add_modifier(modifier)
} else {
Style::default()
}
}
pub fn current(&self) -> Style {
self.styled(self.palette.green, Modifier::BOLD)
}
pub fn missing(&self) -> Style {
self.fg(self.palette.red)
}
pub fn detached(&self) -> Style {
self.fg(self.palette.yellow)
}
pub fn branchless(&self) -> Style {
self.fg(self.palette.gray)
}
pub fn dirty(&self) -> Style {
self.fg(self.palette.yellow)
}
pub fn untracked(&self) -> Style {
self.fg(self.palette.cyan)
}
pub fn absent(&self) -> Style {
self.fg(self.palette.gray)
}
pub fn spinner(&self) -> Style {
self.fg(self.palette.gray)
}
pub fn ahead(&self, count: u32) -> Style {
if count > 0 {
self.fg(self.palette.green)
} else {
self.fg(self.palette.gray)
}
}
pub fn behind(&self, count: u32) -> Style {
if count > 0 {
self.fg(self.palette.red)
} else {
self.fg(self.palette.gray)
}
}
pub fn commit_hash(&self) -> Style {
self.fg(self.palette.orange)
}
pub fn time(&self) -> Style {
self.fg(self.palette.gray)
}
pub fn branch(&self, is_current: bool, is_detached: bool) -> Style {
if is_detached {
self.fg(self.palette.yellow)
} else if is_current {
self.styled(self.palette.accent, Modifier::BOLD)
} else {
Style::default()
}
}
pub fn pr_state(&self, state: PrState) -> Style {
let color = match state {
PrState::Open => self.palette.green,
PrState::Draft => self.palette.gray,
PrState::Merged => self.palette.magenta,
PrState::Closed => self.palette.red,
};
self.fg(color)
}
pub fn selection(&self) -> Style {
if self.enabled {
Style::default()
.bg(self.palette.selection_bg)
.add_modifier(Modifier::BOLD)
} else {
Style::default().add_modifier(Modifier::REVERSED)
}
}
pub fn selection_symbol(&self) -> &'static str {
if self.enabled { "▌ " } else { "> " }
}
pub fn mode_chip(&self, mode: &Mode) -> Style {
if !self.enabled {
return Style::default().add_modifier(Modifier::REVERSED);
}
let color = match mode {
Mode::List => self.palette.accent,
Mode::Filter => self.palette.yellow,
Mode::Create(_) => self.palette.green,
Mode::PrPicker(_) => self.palette.magenta,
Mode::PrCompose(_) => self.palette.green,
Mode::Checkout(_) => self.palette.accent,
Mode::ConfirmRemove(_) => self.palette.red,
Mode::ConfirmCreate(_) => self.palette.green,
Mode::ConfirmDeleteBranch { .. } => self.palette.red,
Mode::ConfirmStaleBase(_) => self.palette.yellow,
Mode::ConfirmInitSubmodules(_) => self.palette.green,
Mode::ConfirmQuit { .. } => self.palette.red,
Mode::Help => self.palette.cyan,
};
Style::default()
.bg(color)
.fg(self.palette.chip_fg)
.add_modifier(Modifier::BOLD)
}
pub fn border(&self, focused: bool) -> Style {
match (self.enabled, focused) {
(true, true) => Style::default().fg(self.palette.accent),
(true, false) => Style::default().fg(self.palette.gray),
(false, true) => Style::default(),
(false, false) => Style::default().add_modifier(Modifier::DIM),
}
}
pub fn title(&self, focused: bool) -> Style {
match (self.enabled, focused) {
(true, true) => Style::default()
.fg(self.palette.accent)
.add_modifier(Modifier::BOLD),
(true, false) => Style::default().fg(self.palette.gray),
(false, true) => Style::default().add_modifier(Modifier::BOLD),
(false, false) => Style::default().add_modifier(Modifier::DIM),
}
}
pub fn hint_key(&self) -> Style {
self.styled(self.palette.accent, Modifier::BOLD)
}
pub fn hint_label(&self) -> Style {
self.fg(self.palette.gray)
}
pub fn label(&self) -> Style {
self.fg(self.palette.gray)
}
pub fn accent(&self) -> Style {
self.fg(self.palette.accent)
}
pub fn url(&self) -> Style {
if self.enabled {
Style::default()
.fg(self.palette.accent)
.add_modifier(Modifier::UNDERLINED)
} else {
Style::default()
}
}
pub fn status(&self, kind: StatusKind) -> Style {
match kind {
StatusKind::Success => self.fg(self.palette.green),
StatusKind::Error => self.fg(self.palette.red),
StatusKind::Info => Style::default(),
}
}
pub fn error(&self) -> Style {
self.fg(self.palette.red)
}
pub fn warning(&self) -> Style {
self.fg(self.palette.yellow)
}
pub fn success(&self) -> Style {
self.fg(self.palette.green)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn enabled_applies_color_disabled_is_plain() {
let on = Theme::new(true);
let off = Theme::new(false);
assert!(on.enabled());
assert_eq!(on.current().fg, Some(GREEN));
assert!(on.current().add_modifier.contains(Modifier::BOLD));
assert_eq!(off.current(), Style::default());
assert_eq!(off.commit_hash(), Style::default());
}
#[test]
fn ahead_behind_mute_at_zero() {
let t = Theme::new(true);
assert_eq!(t.ahead(2).fg, Some(GREEN));
assert_eq!(t.ahead(0).fg, Some(GRAY));
assert_eq!(t.behind(3).fg, Some(RED));
assert_eq!(t.behind(0).fg, Some(GRAY));
}
#[test]
fn pr_state_colors() {
let t = Theme::new(true);
assert_eq!(t.pr_state(PrState::Open).fg, Some(GREEN));
assert_eq!(t.pr_state(PrState::Draft).fg, Some(GRAY));
assert_eq!(t.pr_state(PrState::Merged).fg, Some(MAGENTA));
assert_eq!(t.pr_state(PrState::Closed).fg, Some(RED));
}
#[test]
fn branch_role_styling() {
let t = Theme::new(true);
assert_eq!(t.branch(false, true).fg, Some(YELLOW)); assert_eq!(t.branch(true, false).fg, Some(ACCENT)); assert_eq!(t.branch(false, false), Style::default()); assert_eq!(t.branchless().fg, Some(GRAY));
assert_eq!(Theme::new(false).branchless(), Style::default());
}
#[test]
fn selection_uses_bg_or_reversed() {
assert_eq!(Theme::new(true).selection().bg, Some(SELECTION_BG));
assert!(
Theme::new(false)
.selection()
.add_modifier
.contains(Modifier::REVERSED)
);
assert_eq!(Theme::new(true).selection_symbol(), "▌ ");
assert_eq!(Theme::new(false).selection_symbol(), "> ");
}
#[test]
fn mode_chip_colors_per_mode() {
let t = Theme::new(true);
assert_eq!(t.mode_chip(&Mode::List).bg, Some(ACCENT));
assert_eq!(t.mode_chip(&Mode::Filter).bg, Some(YELLOW));
assert_eq!(t.mode_chip(&Mode::Help).bg, Some(CYAN));
assert_eq!(t.mode_chip(&Mode::ConfirmRemove(0)).bg, Some(RED));
assert_eq!(t.mode_chip(&Mode::ConfirmCreate(0)).bg, Some(GREEN));
assert!(
Theme::new(false)
.mode_chip(&Mode::List)
.add_modifier
.contains(Modifier::REVERSED)
);
}
#[test]
fn focus_changes_border_and_title() {
let t = Theme::new(true);
assert_eq!(t.border(true).fg, Some(ACCENT));
assert_eq!(t.border(false).fg, Some(GRAY));
assert_eq!(t.title(true).fg, Some(ACCENT));
assert!(t.title(true).add_modifier.contains(Modifier::BOLD));
let off = Theme::new(false);
assert!(off.title(true).add_modifier.contains(Modifier::BOLD));
assert!(off.border(false).add_modifier.contains(Modifier::DIM));
}
#[test]
fn status_severity_colors() {
let t = Theme::new(true);
assert_eq!(t.status(StatusKind::Success).fg, Some(GREEN));
assert_eq!(t.status(StatusKind::Error).fg, Some(RED));
assert_eq!(t.status(StatusKind::Info), Style::default());
}
#[test]
fn preset_parse_and_id_round_trip() {
assert_eq!(ThemePreset::parse("one-dark"), Some(ThemePreset::OneDark));
assert_eq!(
ThemePreset::parse("solarized"),
Some(ThemePreset::Solarized)
);
assert_eq!(ThemePreset::parse("nope"), None);
assert_eq!(ThemePreset::OneDark.id(), "one-dark");
assert_eq!(ThemePreset::Solarized.id(), "solarized");
assert_eq!(ThemePreset::default(), ThemePreset::OneDark);
assert_eq!(ThemePreset::OneDark.palette(), Palette::one_dark());
}
#[test]
fn one_dark_palette_matches_legacy_constants() {
let p = Palette::one_dark();
assert_eq!(p.accent, ACCENT);
assert_eq!(p.green, GREEN);
assert_eq!(p.red, RED);
assert_eq!(p.yellow, YELLOW);
assert_eq!(p.orange, ORANGE);
assert_eq!(p.cyan, CYAN);
assert_eq!(p.magenta, MAGENTA);
assert_eq!(p.gray, GRAY);
assert_eq!(p.selection_bg, SELECTION_BG);
assert_eq!(p.chip_fg, CHIP_FG);
assert_eq!(Palette::default(), p);
}
#[test]
fn with_palette_applies_custom_colors() {
let mut p = Palette::one_dark();
p.green = Color::Rgb(1, 2, 3);
p.accent = Color::Rgb(4, 5, 6);
let t = Theme::with_palette(true, p);
assert_eq!(t.current().fg, Some(Color::Rgb(1, 2, 3)));
assert_eq!(t.border(true).fg, Some(Color::Rgb(4, 5, 6)));
let sol = Palette::solarized();
assert_ne!(sol.accent, ACCENT);
assert_ne!(sol.green, GREEN);
assert_eq!(Theme::with_palette(false, p).current(), Style::default());
}
}