use std::path::PathBuf;
use ratatui::style::Color;
use serde::Deserialize;
#[derive(Clone, Copy)]
pub struct Theme {
pub bg: Color,
pub fg: Color,
pub fg_dim: Color,
pub accent: Color,
pub accent2: Color,
pub border: Color,
pub border_dim: Color,
pub success: Color,
pub warning: Color,
pub error: Color,
pub user_msg: Color,
pub assistant_msg: Color,
pub tool_msg: Color,
pub research_msg: Color,
}
impl Default for Theme {
fn default() -> Self {
Self {
bg: Color::Black,
fg: Color::White,
fg_dim: Color::DarkGray,
accent: Color::Cyan,
accent2: Color::Magenta,
border: Color::Cyan,
border_dim: Color::DarkGray,
success: Color::Green,
warning: Color::Yellow,
error: Color::Red,
user_msg: Color::Cyan,
assistant_msg: Color::White,
tool_msg: Color::Yellow,
research_msg: Color::Magenta,
}
}
}
#[derive(Deserialize)]
struct Alacritty {
colors: ColorsSection,
}
#[derive(Deserialize)]
struct ColorsSection {
primary: Primary,
normal: Palette,
bright: Palette,
}
#[derive(Deserialize)]
struct Primary {
background: String,
foreground: String,
}
#[derive(Deserialize)]
struct Palette {
black: String,
red: String,
green: String,
yellow: String,
magenta: String,
cyan: String,
}
fn hex_to_color(s: &str) -> Option<Color> {
let s = s.trim_start_matches('#');
if s.len() != 6 {
return None;
}
let r = u8::from_str_radix(&s[0..2], 16).ok()?;
let g = u8::from_str_radix(&s[2..4], 16).ok()?;
let b = u8::from_str_radix(&s[4..6], 16).ok()?;
Some(Color::Rgb(r, g, b))
}
impl Theme {
fn from_alacritty(a: &Alacritty) -> Self {
let fallback = Self::default();
let n = &a.colors.normal;
let b = &a.colors.bright;
Self {
bg: hex_to_color(&a.colors.primary.background).unwrap_or(fallback.bg),
fg: hex_to_color(&a.colors.primary.foreground).unwrap_or(fallback.fg),
fg_dim: hex_to_color(&b.black).unwrap_or(fallback.fg_dim),
accent: hex_to_color(&n.cyan).unwrap_or(fallback.accent),
accent2: hex_to_color(&n.magenta).unwrap_or(fallback.accent2),
border: hex_to_color(&n.cyan).unwrap_or(fallback.border),
border_dim: hex_to_color(&b.black).unwrap_or(fallback.border_dim),
success: hex_to_color(&n.green).unwrap_or(fallback.success),
warning: hex_to_color(&n.yellow).unwrap_or(fallback.warning),
error: hex_to_color(&n.red).unwrap_or(fallback.error),
user_msg: hex_to_color(&n.cyan).unwrap_or(fallback.user_msg),
assistant_msg: hex_to_color(&a.colors.primary.foreground)
.unwrap_or(fallback.assistant_msg),
tool_msg: hex_to_color(&n.yellow).unwrap_or(fallback.tool_msg),
research_msg: hex_to_color(&n.magenta).unwrap_or(fallback.research_msg),
}
}
}
fn omarchy_current_dir() -> Option<PathBuf> {
Some(std::env::home_dir()?.join(".config/omarchy/current"))
}
pub fn blend(a: Color, b: Color, t: f32) -> Color {
let mix = |x: u8, y: u8| {
let (xf, yf) = (f32::from(x), f32::from(y));
#[allow(
clippy::cast_possible_truncation,
clippy::cast_possible_wrap,
clippy::cast_sign_loss
)]
{
(xf + (yf - xf) * t).round() as u8
}
};
match (a, b) {
(Color::Rgb(r1, g1, b1), Color::Rgb(r2, g2, b2)) => {
Color::Rgb(mix(r1, r2), mix(g1, g2), mix(b1, b2))
}
_ => a,
}
}
pub fn current_link_target() -> Option<PathBuf> {
std::fs::read_link(omarchy_current_dir()?).ok()
}
pub fn load() -> Theme {
omarchy_current_dir()
.map(|d| d.join("theme/alacritty.toml"))
.and_then(|p| std::fs::read_to_string(p).ok())
.and_then(|s| toml::from_str::<Alacritty>(&s).ok())
.map(|a| Theme::from_alacritty(&a))
.unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_omarchy_alacritty_toml() {
let toml = r##"
[colors.primary]
background = "#0B0C16"
foreground = "#ddf7ff"
[colors.normal]
black = "#0B0C16"
red = "#50f872"
green = "#4fe88f"
yellow = "#50f7d4"
blue = "#829dd4"
magenta = "#86a7df"
cyan = "#7cf8f7"
white = "#85E1FB"
[colors.bright]
black = "#6a6e95"
red = "#85ff9d"
green = "#9cf7c2"
yellow = "#a4ffec"
blue = "#c4d2ed"
magenta = "#cddbf4"
cyan = "#d1fffe"
white = "#ddf7ff"
"##;
let a: Alacritty = toml::from_str(toml).unwrap();
let theme = Theme::from_alacritty(&a);
assert_eq!(theme.accent, Color::Rgb(0x7c, 0xf8, 0xf7));
assert_eq!(theme.fg, Color::Rgb(0xdd, 0xf7, 0xff));
}
#[test]
fn missing_file_falls_back_to_default() {
let _ = load();
}
#[test]
fn malformed_toml_falls_back_to_default() {
let bad = "not = [valid";
let parsed: Option<Alacritty> = toml::from_str(bad).ok();
assert!(parsed.is_none());
}
}