use std::collections::HashMap;
#[derive(Debug, Clone, Default, fig::ToValue, fig::FromValue)]
pub struct ColorPalette {
#[fig(default, skip_serializing_if = "Option::is_none")]
pub bg: Option<String>,
#[fig(default, skip_serializing_if = "Option::is_none")]
pub text: Option<String>,
#[fig(default, skip_serializing_if = "Option::is_none")]
pub text_muted: Option<String>,
#[fig(default, skip_serializing_if = "Option::is_none")]
pub accent: Option<String>,
#[fig(default, skip_serializing_if = "Option::is_none")]
pub accent_hover: Option<String>,
#[fig(default, skip_serializing_if = "Option::is_none")]
pub border: Option<String>,
#[fig(default, skip_serializing_if = "Option::is_none")]
pub code_bg: Option<String>,
#[fig(default, skip_serializing_if = "Option::is_none")]
pub surface_bg: Option<String>,
#[fig(default, skip_serializing_if = "Option::is_none")]
pub surface_border: Option<String>,
#[fig(default, skip_serializing_if = "Option::is_none")]
pub surface_shadow: Option<String>,
#[fig(default, skip_serializing_if = "Option::is_none")]
pub divider_color: Option<String>,
}
impl ColorPalette {
pub fn to_css_vars(&self) -> String {
let mut vars = String::new();
let mappings: &[(&Option<String>, &str)] = &[
(&self.bg, "--bg"),
(&self.text, "--text"),
(&self.text_muted, "--text-muted"),
(&self.accent, "--accent"),
(&self.accent_hover, "--accent-hover"),
(&self.border, "--border"),
(&self.code_bg, "--code-bg"),
(&self.surface_bg, "--surface-bg"),
(&self.surface_border, "--surface-border"),
(&self.surface_shadow, "--surface-shadow"),
(&self.divider_color, "--divider-color"),
];
for (value, name) in mappings {
if let Some(v) = value {
vars.push_str(&format!(" {}: {};\n", name, v));
}
}
vars
}
}
#[derive(Debug, Clone, Default, fig::ToValue, fig::FromValue, PartialEq)]
#[fig(rename_all = "lowercase")]
pub enum FontFamily {
Inter,
#[default]
System,
Serif,
Mono,
}
impl FontFamily {
pub fn to_css(&self) -> &'static str {
match self {
Self::Inter => {
r#""Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif"#
}
Self::System => {
r#"-apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif"#
}
Self::Serif => r#""Georgia", "Times New Roman", serif"#,
Self::Mono => r#""SF Mono", Monaco, "Cascadia Code", "Fira Code", monospace"#,
}
}
pub fn from_str_lossy(s: &str) -> Self {
match s {
"inter" => Self::Inter,
"system" => Self::System,
"serif" => Self::Serif,
"mono" => Self::Mono,
_ => Self::System,
}
}
}
#[derive(Debug, Clone, Default, fig::ToValue, fig::FromValue, PartialEq)]
#[fig(rename_all = "lowercase")]
pub enum ContentWidth {
Narrow,
#[default]
Medium,
Wide,
Full,
}
impl ContentWidth {
pub fn to_css(&self) -> &'static str {
match self {
Self::Narrow => "55ch",
Self::Medium => "65ch",
Self::Wide => "85ch",
Self::Full => "none",
}
}
pub fn from_str_lossy(s: &str) -> Self {
match s {
"narrow" => Self::Narrow,
"medium" => Self::Medium,
"wide" => Self::Wide,
"full" => Self::Full,
_ => Self::Medium,
}
}
}
#[derive(Debug, Clone, Default, fig::ToValue, fig::FromValue)]
pub struct TypographySettings {
#[fig(default)]
pub font_family: FontFamily,
#[fig(default, skip_serializing_if = "Option::is_none")]
pub base_font_size: Option<f64>,
#[fig(default, skip_serializing_if = "Option::is_none")]
pub line_height: Option<f64>,
#[fig(default)]
pub content_width: ContentWidth,
}
impl TypographySettings {
pub fn to_css_vars(&self) -> String {
let mut vars = String::new();
vars.push_str(&format!(
" --font-family: {};\n",
self.font_family.to_css()
));
if let Some(size) = self.base_font_size {
vars.push_str(&format!(" --font-size: {}px;\n", size));
}
if let Some(lh) = self.line_height {
vars.push_str(&format!(" --line-height: {};\n", lh));
}
vars.push_str(&format!(
" --content-max-width: {};\n",
self.content_width.to_css()
));
vars
}
}
#[derive(Debug, Clone)]
pub struct FaviconAsset {
pub filename: String,
pub mime_type: String,
pub data: Vec<u8>,
}
#[derive(Debug, Clone, Default, fig::ToValue, fig::FromValue)]
pub struct ThemeAppearance {
#[fig(default, skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
#[fig(default)]
pub light: ColorPalette,
#[fig(default)]
pub dark: ColorPalette,
#[fig(skip)]
pub favicon: Option<FaviconAsset>,
#[fig(default)]
pub typography: Option<TypographySettings>,
}
impl ThemeAppearance {
pub fn to_css_overrides(&self) -> String {
let light_vars = self.light.to_css_vars();
let dark_vars = self.dark.to_css_vars();
let typo_vars = self
.typography
.as_ref()
.map(|t| t.to_css_vars())
.unwrap_or_default();
if light_vars.is_empty() && dark_vars.is_empty() && typo_vars.is_empty() {
return String::new();
}
let mut css = String::new();
if !light_vars.is_empty() || !typo_vars.is_empty() {
css.push_str(&format!(":root {{\n{}{}}}\n", typo_vars, light_vars));
}
if !dark_vars.is_empty() {
css.push_str(&format!(
"@media (prefers-color-scheme: dark) {{\n :root {{\n{}\n }}\n}}\n",
dark_vars
));
}
css
}
pub fn from_app_palette(
light: &HashMap<String, String>,
dark: &HashMap<String, String>,
) -> Self {
Self {
id: None,
light: Self::map_palette(light),
dark: Self::map_palette(dark),
favicon: None,
typography: None,
}
}
pub fn generate_favicon_svg(&self) -> FaviconAsset {
let accent = self.light.accent.as_deref().unwrap_or("#6366f1");
let svg = format!(
r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><circle cx="16" cy="16" r="14" fill="{}"/></svg>"#,
accent
);
FaviconAsset {
filename: "favicon.svg".to_string(),
mime_type: "image/svg+xml".to_string(),
data: svg.into_bytes(),
}
}
pub fn favicon_or_default(&self) -> FaviconAsset {
match &self.favicon {
Some(f) => f.clone(),
None => self.generate_favicon_svg(),
}
}
fn map_palette(colors: &HashMap<String, String>) -> ColorPalette {
ColorPalette {
bg: colors.get("background").cloned(),
text: colors.get("foreground").cloned(),
text_muted: colors.get("muted-foreground").cloned(),
accent: colors.get("primary").cloned(),
accent_hover: colors.get("ring").cloned(),
border: colors.get("border").cloned(),
code_bg: colors.get("secondary").cloned(),
surface_bg: colors.get("card").cloned(),
surface_border: colors.get("sidebar-border").cloned(),
surface_shadow: None,
divider_color: None,
}
}
}