use super::{AppearanceMode, Borders, Colors, Fonts, Spacing, Theme, ThemeOverrides, WidgetRole};
use crate::compat::HashMap;
use crate::core::{Color, Font};
use crate::signal::Signal;
use crate::style::{HighContrastMode, Margin, Padding, Shadow, WidgetState, WidgetStyle};
pub struct ThemeManager {
themes: HashMap<String, Theme>,
current_theme: String,
theme_changed: Signal<()>,
high_contrast: HighContrastMode,
}
impl ThemeManager {
pub fn new() -> Self {
let default = Theme::default();
let current_theme = default.name.clone();
let mut themes = HashMap::new();
themes.insert(default.name.clone(), default);
Self {
themes,
current_theme,
theme_changed: Signal::new(),
high_contrast: HighContrastMode::None,
}
}
pub fn set_high_contrast(&mut self, mode: HighContrastMode) {
self.high_contrast = mode;
self.theme_changed.emit(());
}
pub fn high_contrast(&self) -> HighContrastMode {
self.high_contrast
}
pub fn theme_names(&self) -> Vec<&str> {
self.themes.keys().map(String::as_str).collect()
}
pub fn current_theme_name(&self) -> &str {
&self.current_theme
}
pub fn set_appearance(&mut self, appearance: AppearanceMode) -> bool {
let candidate = self
.themes
.iter()
.find(|(_, theme)| theme.appearance == appearance)
.map(|(name, _)| name.clone());
match candidate {
Some(name) => self.set_theme(&name),
None => false,
}
}
#[cfg(not(alloc_frugal))]
pub fn load_theme(&mut self, path: &str) -> Result<(), Box<dyn std::error::Error>> {
let content = std::fs::read_to_string(path)?;
let theme: Theme = serde_json::from_str(&content)?;
self.themes.insert(theme.name.clone(), theme);
Ok(())
}
#[cfg(not(alloc_frugal))]
pub fn load_and_activate_theme(
&mut self,
path: &str,
) -> Result<String, Box<dyn std::error::Error>> {
let content = std::fs::read_to_string(path)?;
let theme: Theme = serde_json::from_str(&content)?;
let name = theme.name.clone();
self.themes.insert(name.clone(), theme);
debug_assert!(
self.set_theme(&name),
"the theme was just inserted under this name; set_theme must find it"
);
Ok(name)
}
#[cfg(not(alloc_frugal))]
pub fn save_theme(&self, path: &str) -> Result<(), String> {
let theme = self.current_theme().ok_or_else(|| {
format!(
"no active theme to save to '{path}': {} theme(s) are registered but none is \
active; select one with `set_theme` first",
self.themes.len()
)
})?;
let json = serde_json::to_string_pretty(theme).map_err(|e| {
format!(
"theme '{}' could not be serialized to JSON (one of its colour or \
metric fields is not representable): {e}",
theme.name
)
})?;
std::fs::write(path, &json).map_err(|e| {
format!(
"theme JSON ({} bytes) could not be written to '{path}': {e} (check that the \
directory exists and is writable)",
json.len()
)
})?;
Ok(())
}
pub fn register_theme(&mut self, theme: Theme) {
self.themes.insert(theme.name.clone(), theme);
}
pub fn set_theme(&mut self, name: &str) -> bool {
if self.themes.contains_key(name) {
self.current_theme = name.to_string();
self.theme_changed.emit(());
return true;
}
false
}
pub fn current_theme(&self) -> Option<&Theme> {
self.themes.get(&self.current_theme)
}
pub fn get_theme(&self, name: &str) -> Option<&Theme> {
self.themes.get(name)
}
pub fn on_theme_changed(&self) -> &Signal<()> {
&self.theme_changed
}
pub fn resolve_style(&self, class_name: &str) -> WidgetStyle {
self.resolve_style_for_state(class_name, None)
}
pub fn resolve_style_for_state(
&self,
class_name: &str,
state: Option<WidgetState>,
) -> WidgetStyle {
let mut style = self.resolve_base_style(class_name);
if let Some(state) = state {
let key = format!("{class_name}:{}", state_suffix(state));
if let Some(token) = self.current_theme().and_then(|t| t.overrides.styles.get(&key)) {
apply_token(&mut style, token, None);
}
}
if let Some((background, foreground)) = self.high_contrast.forced_pair() {
style.background_color = Some(background);
style.text_color = Some(foreground);
style.background_gradient = None;
}
style
}
fn resolve_base_style(&self, class_name: &str) -> WidgetStyle {
let Some(theme) = self.current_theme() else {
return WidgetStyle::default();
};
let shadow = if theme.borders.shadow {
Some(Shadow { x: 0, y: 2, blur: 6, color: Color::rgba(0, 0, 0, 60) })
} else {
None
};
let (background_color, text_color, border_color) = role_colors(theme, class_name);
let mut style = WidgetStyle {
background_color,
text_color,
border_color,
border_width: Some(theme.borders.width),
border_radius: Some(theme.borders.radius),
padding: Padding::all(theme.spacing.medium),
margin: Margin::all(theme.spacing.small),
shadow,
font: Some(theme.fonts.body.clone()),
..Default::default()
};
let token = theme.overrides.styles.get(class_name).or_else(|| {
theme.overrides.styles.get(role_key(WidgetRole::for_kind_name(class_name)))
});
if let Some(token) = token {
apply_token(&mut style, token, Some(&theme.fonts));
}
style
}
}
fn role_colors(theme: &Theme, class_name: &str) -> (Option<Color>, Option<Color>, Option<Color>) {
match WidgetRole::for_kind_name(class_name) {
WidgetRole::Primary => (
Some(theme.colors.primary),
Some(theme.colors.primary.contrast_color()),
Some(theme.colors.primary),
),
WidgetRole::Text => (None, Some(theme.colors.foreground), None),
WidgetRole::Input => (
Some(theme.colors.input_background()),
Some(theme.colors.foreground),
Some(theme.colors.secondary),
),
WidgetRole::Accent => {
(Some(theme.colors.accent), Some(theme.colors.accent.contrast_color()), None)
}
WidgetRole::Choice => (
Some(theme.colors.input_background()),
Some(theme.colors.foreground),
Some(theme.colors.secondary),
),
WidgetRole::Danger => {
(Some(theme.colors.error), Some(theme.colors.error.contrast_color()), None)
}
WidgetRole::Surface => (
Some(theme.colors.background),
Some(theme.colors.foreground),
Some(theme.colors.secondary),
),
}
}
fn role_key(role: WidgetRole) -> &'static str {
match role {
WidgetRole::Surface => "surface",
WidgetRole::Primary => "primary",
WidgetRole::Text => "text",
WidgetRole::Input => "input",
WidgetRole::Accent => "accent",
WidgetRole::Choice => "choice",
WidgetRole::Danger => "danger",
}
}
fn state_suffix(state: WidgetState) -> &'static str {
match state {
WidgetState::Normal => "normal",
WidgetState::Hover => "hover",
WidgetState::Pressed => "pressed",
WidgetState::Focused => "focused",
WidgetState::Disabled => "disabled",
WidgetState::Checked => "checked",
WidgetState::Selected => "selected",
WidgetState::Active => "active",
WidgetState::Inactive => "inactive",
WidgetState::Error => "error",
WidgetState::Warning => "warning",
WidgetState::Success => "success",
}
}
fn apply_token(style: &mut WidgetStyle, token: &super::ThemeStyleToken, fonts: Option<&Fonts>) {
if let Some(color) = token.background {
style.background_color = Some(color);
}
if let Some(color) = token.foreground {
style.text_color = Some(color);
}
if let Some(color) = token.border {
style.border_color = Some(color);
}
if let Some(width) = token.border_width {
style.border_width = Some(width);
}
if let Some(radius) = token.radius {
style.border_radius = Some(radius);
}
if let Some(opacity) = token.opacity {
style.opacity = Some(opacity.clamp(0.0, 1.0));
}
if token.shadow != super::ShadowOverride::Inherit {
style.shadow = token.shadow.apply(style.shadow.take());
}
if let Some([width, height]) = token.touch_target {
style.touch_target = Some(crate::core::Size::new(width, height));
}
if let Some(font) = &token.font {
style.font = Some(font.clone());
} else if let Some(fonts) = fonts {
let _ = fonts;
}
}
impl Colors {
pub fn input_background(&self) -> Color {
let mix = |b: u8, f: u8| ((b as u16 * 3 + f as u16) / 4) as u8;
Color::rgba(
mix(self.background.r, self.foreground.r),
mix(self.background.g, self.foreground.g),
mix(self.background.b, self.foreground.b),
self.background.a,
)
}
}
crate::impl_default_via_new!(ThemeManager);
use crate::compat::{lock, Mutex, MutexGuard, OnceLock};
pub fn global_theme_manager() -> MutexGuard<'static, ThemeManager> {
static MANAGER: OnceLock<Mutex<ThemeManager>> = OnceLock::new();
lock(MANAGER.get_or_init(|| {
let mut manager = ThemeManager::new();
let dark = Theme::dark();
manager.register_theme(dark);
Mutex::new(manager)
}))
}
pub fn theme_test_guard() -> crate::compat::MutexGuard<'static, ()> {
static GUARD: OnceLock<Mutex<()>> = OnceLock::new();
lock(GUARD.get_or_init(|| Mutex::new(())))
}
pub fn resolved_theme_style(widget_name: &str) -> Option<WidgetStyle> {
let manager = global_theme_manager();
manager.current_theme()?;
Some(manager.resolve_style(widget_name))
}
pub fn set_global_high_contrast(mode: crate::style::HighContrastMode) {
global_theme_manager().set_high_contrast(mode);
}
pub fn global_high_contrast() -> crate::style::HighContrastMode {
global_theme_manager().high_contrast()
}
impl Default for Theme {
fn default() -> Self {
Self {
name: "default".to_string(),
appearance: AppearanceMode::Light,
colors: Colors {
background: Color { r: 240, g: 240, b: 240, a: 255 },
foreground: Color { r: 0, g: 0, b: 0, a: 255 },
primary: Color { r: 33, g: 150, b: 243, a: 255 },
secondary: Color { r: 158, g: 158, b: 158, a: 255 },
accent: Color { r: 255, g: 152, b: 0, a: 255 },
error: Color { r: 244, g: 67, b: 54, a: 255 },
warning: Color { r: 255, g: 193, b: 7, a: 255 },
success: Color { r: 76, g: 175, b: 80, a: 255 },
disabled: Color { r: 200, g: 200, b: 200, a: 255 },
info: Color::INFO,
},
fonts: Fonts {
regular: Font::simple("Arial", 14.0),
bold: Font::bold("Arial", 14.0),
italic: Font::with_weight("Arial", 14.0, Font::REGULAR_WEIGHT, true),
monospace: Font::simple("Courier New", 12.0),
caption: Font::simple("Arial", 11.0),
body: Font::simple("Arial", 14.0),
title: Font::bold("Arial", 16.0),
headline: Font::bold("Arial", 20.0),
display: Font::bold("Arial", 28.0),
},
spacing: Spacing { small: 4, medium: 8, large: 16, extra_large: 24 },
borders: Borders { width: 1, radius: 4, shadow: true },
overrides: ThemeOverrides { styles: HashMap::new() },
}
}
}
impl Theme {
pub fn dark() -> Self {
Self {
name: "dark".to_string(),
appearance: AppearanceMode::Dark,
colors: Colors {
background: Color { r: 18, g: 18, b: 18, a: 255 },
foreground: Color { r: 225, g: 225, b: 225, a: 255 },
primary: Color { r: 100, g: 181, b: 246, a: 255 },
secondary: Color { r: 130, g: 130, b: 130, a: 255 },
accent: Color { r: 255, g: 171, b: 64, a: 255 },
error: Color { r: 239, g: 83, b: 80, a: 255 },
warning: Color { r: 255, g: 213, b: 79, a: 255 },
success: Color { r: 129, g: 199, b: 132, a: 255 },
disabled: Color { r: 80, g: 80, b: 80, a: 255 },
info: Color::INFO,
},
fonts: Fonts {
regular: Font::simple("Arial", 14.0),
bold: Font::bold("Arial", 14.0),
italic: Font::with_weight("Arial", 14.0, Font::REGULAR_WEIGHT, true),
monospace: Font::simple("Courier New", 12.0),
caption: Font::simple("Arial", 11.0),
body: Font::simple("Arial", 14.0),
title: Font::bold("Arial", 16.0),
headline: Font::bold("Arial", 20.0),
display: Font::bold("Arial", 28.0),
},
spacing: Spacing { small: 4, medium: 8, large: 16, extra_large: 24 },
borders: Borders { width: 1, radius: 4, shadow: true },
overrides: ThemeOverrides { styles: HashMap::new() },
}
}
}