use std::sync::Arc;
use anyhow::{Context, Result};
use hjkl_bonsai::DotFallbackTheme;
use ratatui::style::Color;
use serde::Deserialize;
const UI_DARK_TOML: &str = include_str!("../themes/ui-dark.toml");
const SYNTAX_DARK_TOML: &str = include_str!("../themes/syntax-dark.toml");
#[derive(Clone)]
pub struct AppTheme {
pub ui: UiTheme,
pub syntax: Arc<DotFallbackTheme>,
}
impl AppTheme {
pub fn default_dark() -> Self {
let ui = UiTheme::from_toml(UI_DARK_TOML).expect("bundled ui-dark.toml is malformed");
let syntax = Arc::new(
DotFallbackTheme::from_toml(SYNTAX_DARK_TOML)
.expect("bundled syntax-dark.toml is malformed"),
);
Self { ui, syntax }
}
}
#[derive(Clone)]
pub struct UiTheme {
pub text: Color,
pub text_dim: Color,
pub panel_bg: Color,
pub surface_bg: Color,
pub border: Color,
pub border_active: Color,
pub gutter: Color,
pub on_accent: Color,
pub mode_normal_bg: Color,
pub mode_insert_bg: Color,
pub mode_visual_bg: Color,
pub cursor_line_bg: Color,
pub search_bg: Color,
pub search_fg: Color,
pub picker_selection_bg: Color,
pub form_normal_bg: Color,
pub form_insert_bg: Color,
pub form_tag_normal_fg: Color,
pub form_tag_insert_fg: Color,
pub status_dirty_marker: Color,
pub recording_bg: Color,
pub recording_fg: Color,
}
impl UiTheme {
pub fn from_toml(s: &str) -> Result<Self> {
let raw: RawUiTheme = toml::from_str(s).context("parse ui theme TOML")?;
Ok(Self {
text: parse_hex(&raw.chrome.text)?,
text_dim: parse_hex(&raw.chrome.text_dim)?,
panel_bg: parse_hex(&raw.chrome.panel_bg)?,
surface_bg: parse_hex(&raw.chrome.surface_bg)?,
border: parse_hex(&raw.chrome.border)?,
border_active: parse_hex(&raw.chrome.border_active)?,
gutter: parse_hex(&raw.chrome.gutter)?,
on_accent: parse_hex(&raw.chrome.on_accent)?,
mode_normal_bg: parse_hex(&raw.mode.normal_bg)?,
mode_insert_bg: parse_hex(&raw.mode.insert_bg)?,
mode_visual_bg: parse_hex(&raw.mode.visual_bg)?,
cursor_line_bg: parse_hex(&raw.cursor_line.bg)?,
search_bg: parse_hex(&raw.search.bg)?,
search_fg: parse_hex(&raw.search.fg)?,
picker_selection_bg: parse_hex(&raw.picker.selection_bg)?,
form_normal_bg: parse_hex(&raw.form.normal_bg)?,
form_insert_bg: parse_hex(&raw.form.insert_bg)?,
form_tag_normal_fg: parse_hex(&raw.form.tag_normal_fg)?,
form_tag_insert_fg: parse_hex(&raw.form.tag_insert_fg)?,
status_dirty_marker: parse_hex(&raw.status.dirty_marker)?,
recording_bg: parse_hex(&raw.recording.bg)?,
recording_fg: parse_hex(&raw.recording.fg)?,
})
}
}
#[derive(Deserialize)]
struct RawUiTheme {
chrome: RawChrome,
mode: RawMode,
cursor_line: RawCursorLine,
search: RawSearch,
picker: RawPicker,
form: RawForm,
status: RawStatus,
recording: RawRecording,
}
#[derive(Deserialize)]
struct RawChrome {
text: String,
text_dim: String,
panel_bg: String,
surface_bg: String,
border: String,
border_active: String,
gutter: String,
on_accent: String,
}
#[derive(Deserialize)]
struct RawMode {
normal_bg: String,
insert_bg: String,
visual_bg: String,
}
#[derive(Deserialize)]
struct RawCursorLine {
bg: String,
}
#[derive(Deserialize)]
struct RawSearch {
bg: String,
fg: String,
}
#[derive(Deserialize)]
struct RawPicker {
selection_bg: String,
}
#[derive(Deserialize)]
struct RawForm {
normal_bg: String,
insert_bg: String,
tag_normal_fg: String,
tag_insert_fg: String,
}
#[derive(Deserialize)]
struct RawStatus {
dirty_marker: String,
}
#[derive(Deserialize)]
struct RawRecording {
bg: String,
fg: String,
}
fn parse_hex(s: &str) -> Result<Color> {
let bytes = s.as_bytes();
if bytes.len() != 7 || bytes[0] != b'#' {
anyhow::bail!("expected #rrggbb hex color, got {s:?}");
}
let r = u8::from_str_radix(&s[1..3], 16).with_context(|| format!("bad red byte in {s:?}"))?;
let g = u8::from_str_radix(&s[3..5], 16).with_context(|| format!("bad green byte in {s:?}"))?;
let b = u8::from_str_radix(&s[5..7], 16).with_context(|| format!("bad blue byte in {s:?}"))?;
Ok(Color::Rgb(r, g, b))
}
#[cfg(test)]
mod tests {
use super::*;
use hjkl_bonsai::Theme;
#[test]
fn bundled_dark_theme_loads() {
let theme = AppTheme::default_dark();
assert_eq!(theme.ui.text, Color::Rgb(0xe5, 0xe9, 0xf0));
assert_eq!(theme.ui.mode_insert_bg, Color::Rgb(0x7e, 0xe7, 0x87));
assert_eq!(theme.ui.cursor_line_bg, Color::Rgb(0x2a, 0x32, 0x40));
assert!(theme.syntax.style("keyword").is_some());
}
#[test]
fn parse_hex_rejects_short_string() {
assert!(parse_hex("#fff").is_err());
}
#[test]
fn parse_hex_rejects_missing_hash() {
assert!(parse_hex("ff9e64a").is_err());
}
#[test]
fn parse_hex_rejects_non_hex() {
assert!(parse_hex("#zzggbb").is_err());
}
#[test]
fn ui_theme_rejects_missing_field() {
let s = r##"
[chrome]
text = "#000000"
# other chrome fields missing
"##;
assert!(UiTheme::from_toml(s).is_err());
}
}