use anyhow::{Context as _, Result, anyhow};
use std::{
collections::BTreeMap,
fs,
path::{Path, PathBuf},
sync::Arc,
};
use crate::{
App, BorrowAppContext, BoxShadow, FileWatchEvent, FileWatcher, FontWeight, Global, Hsla,
Pixels, Rgba, SharedString, Subscription, Window, WindowAppearance, black,
colors::{Colors, GlobalColors},
point, px,
};
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(default)]
pub struct Theme {
pub colors: ThemeColors,
pub typography: ThemeTypography,
pub spacing: ThemeSpacing,
pub radii: ThemeRadii,
pub shadows: ThemeShadows,
}
impl Default for Theme {
fn default() -> Self {
Self::light()
}
}
impl Global for Theme {}
impl Theme {
pub fn init(cx: &mut App) {
cx.update_default_global::<ThemeRuntime, _>(|runtime, cx| {
if runtime.sync_subscription.is_none() {
runtime.sync_subscription = Some(cx.observe_global::<Theme>(|cx| {
sync_theme_colors(cx);
cx.refresh_windows();
}));
}
});
if !cx.has_global::<Theme>() {
cx.set_global(Self::default());
}
sync_theme_colors(cx);
}
pub fn light() -> Self {
Self::from_colors(Colors::light())
}
pub fn dark() -> Self {
Self::from_colors(Colors::dark())
}
pub fn for_appearance(window: &Window) -> Self {
match window.appearance() {
WindowAppearance::Light | WindowAppearance::VibrantLight => Self::light(),
WindowAppearance::Dark | WindowAppearance::VibrantDark => Self::dark(),
}
}
pub fn from_json_str(input: &str) -> Result<Self> {
serde_json::from_str(input).context("failed to parse theme JSON")
}
pub fn from_toml_str(input: &str) -> Result<Self> {
toml::from_str(input).context("failed to parse theme TOML")
}
pub fn from_path(path: impl AsRef<Path>) -> Result<Self> {
let path = path.as_ref();
let input = fs::read_to_string(path)
.with_context(|| format!("failed to read theme file {}", path.display()))?;
match ThemeFileFormat::from_path(path) {
Some(ThemeFileFormat::Json) => Self::from_json_str(&input),
Some(ThemeFileFormat::Toml) => Self::from_toml_str(&input),
None => {
let (primary_label, primary, secondary_label, secondary) =
if input.trim_start().starts_with('{') {
(
"JSON",
Self::from_json_str(&input),
"TOML",
Self::from_toml_str(&input),
)
} else {
(
"TOML",
Self::from_toml_str(&input),
"JSON",
Self::from_json_str(&input),
)
};
match primary {
Ok(theme) => Ok(theme),
Err(primary_error) => match secondary {
Ok(theme) => Ok(theme),
Err(secondary_error) => Err(anyhow!(
"failed to parse theme file {} as {} or {}: {primary_error:#}; {secondary_error:#}",
path.display(),
primary_label,
secondary_label,
)),
},
}
}
}
}
fn from_colors(colors: Colors) -> Self {
Self {
colors: colors.into(),
typography: ThemeTypography::default(),
spacing: ThemeSpacing::default(),
radii: ThemeRadii::default(),
shadows: ThemeShadows::default(),
}
}
}
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(default)]
pub struct ThemeColors {
pub background: Hsla,
pub surface: Hsla,
pub primary: Hsla,
pub accent: Hsla,
pub muted: Hsla,
pub foreground: Hsla,
pub border: Hsla,
pub separator: Hsla,
pub selected_text: Hsla,
pub error: Hsla,
pub warning: Hsla,
pub success: Hsla,
pub custom: BTreeMap<SharedString, Hsla>,
}
impl Default for ThemeColors {
fn default() -> Self {
Colors::default().into()
}
}
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(default)]
pub struct ThemeTypography {
pub ui_font_family: SharedString,
pub ui_font_weight: FontWeight,
pub ui_font_size: Pixels,
pub ui_line_height: Pixels,
pub code_font_family: SharedString,
pub code_font_size: Pixels,
}
impl Default for ThemeTypography {
fn default() -> Self {
Self {
ui_font_family: ".SystemUIFont".into(),
ui_font_weight: FontWeight::NORMAL,
ui_font_size: px(14.),
ui_line_height: px(20.),
code_font_family: "Menlo".into(),
code_font_size: px(13.),
}
}
}
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(default)]
pub struct ThemeSpacing {
pub xs: Pixels,
pub sm: Pixels,
pub md: Pixels,
pub lg: Pixels,
pub xl: Pixels,
pub xxl: Pixels,
}
impl Default for ThemeSpacing {
fn default() -> Self {
Self {
xs: px(4.),
sm: px(8.),
md: px(12.),
lg: px(16.),
xl: px(24.),
xxl: px(32.),
}
}
}
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(default)]
pub struct ThemeRadii {
pub sm: Pixels,
pub md: Pixels,
pub lg: Pixels,
pub xl: Pixels,
pub pill: Pixels,
}
impl Default for ThemeRadii {
fn default() -> Self {
Self {
sm: px(4.),
md: px(8.),
lg: px(12.),
xl: px(16.),
pill: px(999.),
}
}
}
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(default)]
pub struct ThemeShadows {
pub sm: BoxShadow,
pub md: BoxShadow,
pub lg: BoxShadow,
}
impl Default for ThemeShadows {
fn default() -> Self {
Self {
sm: BoxShadow {
color: black().opacity(0.10),
offset: point(px(0.), px(1.)),
blur_radius: px(2.),
spread_radius: px(0.),
inset: false,
},
md: BoxShadow {
color: black().opacity(0.14),
offset: point(px(0.), px(8.)),
blur_radius: px(24.),
spread_radius: px(-8.),
inset: false,
},
lg: BoxShadow {
color: black().opacity(0.18),
offset: point(px(0.), px(16.)),
blur_radius: px(40.),
spread_radius: px(-12.),
inset: false,
},
}
}
}
impl From<Colors> for ThemeColors {
fn from(colors: Colors) -> Self {
Self {
background: colors.background.into(),
surface: colors.container.into(),
primary: colors.selected.into(),
accent: colors.selected.into(),
muted: colors.disabled.into(),
foreground: colors.text.into(),
border: colors.border.into(),
separator: colors.separator.into(),
selected_text: colors.selected_text.into(),
error: Rgba::try_from("#dc2626").unwrap().into(),
warning: Rgba::try_from("#f59e0b").unwrap().into(),
success: Rgba::try_from("#16a34a").unwrap().into(),
custom: BTreeMap::default(),
}
}
}
impl From<&Theme> for Colors {
fn from(theme: &Theme) -> Self {
Self {
text: theme.colors.foreground.into(),
selected_text: theme.colors.selected_text.into(),
background: theme.colors.background.into(),
disabled: theme.colors.muted.into(),
selected: theme.colors.primary.into(),
border: theme.colors.border.into(),
separator: theme.colors.separator.into(),
container: theme.colors.surface.into(),
}
}
}
pub(crate) fn normalize_theme_path(path: &Path) -> Result<PathBuf> {
let path = if path.is_absolute() {
path.to_path_buf()
} else {
std::env::current_dir()
.context("failed to resolve current directory while loading theme")?
.join(path)
};
path.canonicalize()
.with_context(|| format!("failed to resolve theme file {}", path.display()))
}
pub(crate) fn theme_file_event_matches_target(event: &FileWatchEvent, watched_path: &Path) -> bool {
match event {
FileWatchEvent::Created(path)
| FileWatchEvent::Modified(path)
| FileWatchEvent::Deleted(path) => same_theme_path(path, watched_path),
FileWatchEvent::Renamed { from, to } => {
same_theme_path(from, watched_path) || same_theme_path(to, watched_path)
}
FileWatchEvent::Error { path, .. } => same_theme_path(path, watched_path),
}
}
pub(crate) fn retain_file_watcher(cx: &mut App, watcher: FileWatcher) {
cx.update_default_global::<ThemeRuntime, _>(|runtime, _| {
runtime.file_watchers.push(watcher);
});
}
fn same_theme_path(candidate: &Path, watched_path: &Path) -> bool {
if candidate == watched_path {
return true;
}
let absolute = if candidate.is_absolute() {
candidate.to_path_buf()
} else if let Ok(current_dir) = std::env::current_dir() {
current_dir.join(candidate)
} else {
return false;
};
absolute.canonicalize().unwrap_or(absolute) == watched_path
}
fn sync_theme_colors(cx: &mut App) {
let colors = Colors::from(cx.global::<Theme>());
cx.set_global(GlobalColors(Arc::new(colors)));
}
#[derive(Default)]
struct ThemeRuntime {
sync_subscription: Option<Subscription>,
file_watchers: Vec<FileWatcher>,
}
impl Global for ThemeRuntime {}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum ThemeFileFormat {
Json,
Toml,
}
impl ThemeFileFormat {
fn from_path(path: &Path) -> Option<Self> {
match path.extension().and_then(|extension| extension.to_str()) {
Some(extension) if extension.eq_ignore_ascii_case("json") => Some(Self::Json),
Some(extension) if extension.eq_ignore_ascii_case("toml") => Some(Self::Toml),
_ => None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::{
fs,
path::PathBuf,
sync::atomic::{AtomicU64, Ordering},
};
use crate::colors::DefaultColors;
static NEXT_TEMP_DIR_ID: AtomicU64 = AtomicU64::new(0);
#[kael::test]
fn setting_theme_updates_default_colors(cx: &mut crate::TestAppContext) {
cx.update(|cx| {
cx.set_global(Theme::dark());
});
cx.read(|cx| {
let colors = cx.default_colors();
assert_eq!(colors.background, Colors::dark().background);
assert_eq!(colors.text, Colors::dark().text);
assert_eq!(colors.container, Colors::dark().container);
});
}
#[kael::test]
fn observe_theme_file_loads_initial_theme(cx: &mut crate::TestAppContext) {
let directory = create_temp_theme_dir();
let theme_path = directory.join("theme.toml");
cx.on_quit({
let directory = directory.clone();
move || {
let _ = fs::remove_dir_all(directory);
}
});
fs::write(
&theme_path,
r##"
[colors]
background = "#101820"
surface = "#19232d"
primary = "#2563eb"
foreground = "#f9fafb"
"##,
)
.unwrap();
cx.update(|cx| {
cx.observe_theme_file(&theme_path, |theme, cx| cx.set_global(theme))
.unwrap();
});
let expected_theme = Theme::from_path(&theme_path).unwrap();
let expected_colors = Colors::from(&expected_theme);
cx.read_global::<Theme, _>(|theme, _| {
assert_eq!(theme, &expected_theme);
});
cx.read(|cx| {
let colors = cx.default_colors();
assert_eq!(colors.background, expected_colors.background);
assert_eq!(colors.selected, expected_colors.selected);
assert_eq!(colors.text, expected_colors.text);
});
}
#[kael::test]
fn matching_theme_event_reloads_theme(cx: &mut crate::TestAppContext) {
let directory = create_temp_theme_dir();
let theme_path = directory.join("theme.toml");
cx.on_quit({
let directory = directory.clone();
move || {
let _ = fs::remove_dir_all(directory);
}
});
fs::write(&theme_path, "[colors]\nbackground = \"#101820\"\n").unwrap();
let watched_path = normalize_theme_path(&theme_path).unwrap();
cx.update(|cx| Theme::init(cx));
fs::write(
&theme_path,
"[colors]\nbackground = \"#1f2937\"\nprimary = \"#2563eb\"\nforeground = \"#f9fafb\"\n",
)
.unwrap();
let expected_theme = Theme::from_path(&theme_path).unwrap();
let expected_colors = Colors::from(&expected_theme);
cx.update(|cx| {
crate::app::handle_theme_file_event(
cx,
&FileWatchEvent::Modified(watched_path.clone()),
&watched_path,
&mut |theme, cx| cx.set_global(theme),
)
.unwrap();
});
cx.read_global::<Theme, _>(|theme, _| {
assert_eq!(theme, &expected_theme);
});
cx.read(|cx| {
let colors = cx.default_colors();
assert_eq!(colors.background, expected_colors.background);
assert_eq!(colors.selected, expected_colors.selected);
assert_eq!(colors.text, expected_colors.text);
});
}
#[test]
fn theme_file_event_matching_filters_unrelated_paths() {
let directory = create_temp_theme_dir();
let watched_path = directory.join("theme.toml");
let other_path = directory.join("other.toml");
fs::write(&watched_path, "[colors]\nbackground = \"#000000\"\n").unwrap();
fs::write(&other_path, "[colors]\nbackground = \"#ffffff\"\n").unwrap();
let watched_path = normalize_theme_path(&watched_path).unwrap();
assert!(theme_file_event_matches_target(
&FileWatchEvent::Modified(watched_path.clone()),
&watched_path,
));
assert!(theme_file_event_matches_target(
&FileWatchEvent::Renamed {
from: other_path,
to: watched_path.clone(),
},
&watched_path,
));
assert!(!theme_file_event_matches_target(
&FileWatchEvent::Modified(directory.join("unrelated.toml")),
&watched_path,
));
let _ = fs::remove_dir_all(directory);
}
fn create_temp_theme_dir() -> PathBuf {
let directory = std::env::temp_dir().join(format!(
"kael-theme-test-{}-{}",
std::process::id(),
NEXT_TEMP_DIR_ID.fetch_add(1, Ordering::Relaxed)
));
fs::create_dir_all(&directory).unwrap();
directory
}
}