use super::types::{Material, SystemAccent};
use super::{
AppearanceMode, Borders, Colors, Fonts, IconSlot, IconTokens, Motion, Spacing, Theme,
ThemeOverrides, WidgetRole,
};
use crate::compat::atomic::{AtomicU64, Ordering};
use crate::compat::{HashMap, Rc, Vec};
use crate::core::{Color, Font};
use crate::signal::Signal;
use crate::style::{
HighContrastMode, Margin, Padding, SemanticState, ThemeMode, WidgetState, WidgetStyle,
};
static THEME_REVISION: AtomicU64 = AtomicU64::new(0);
#[cfg(test)]
thread_local! {
static TEST_THEME_REVISION: core::cell::Cell<u64> = const { core::cell::Cell::new(0) };
}
fn theme_revision() -> u64 {
#[cfg(test)]
{
TEST_THEME_REVISION.with(|revision| revision.get())
}
#[cfg(not(test))]
{
THEME_REVISION.load(Ordering::Relaxed)
}
}
fn bump_theme_revision() {
THEME_REVISION.fetch_add(1, Ordering::Relaxed);
#[cfg(test)]
TEST_THEME_REVISION.with(|revision| revision.set(revision.get().wrapping_add(1)));
}
fn note_theme_mutation() {
bump_theme_revision();
#[cfg(not(alloc_frugal))]
crate::widget::runtime::arm_global_repaint();
}
pub const THEME_CROSSFADE_DEFAULT_MS: u32 = 220;
pub struct ThemeManager {
themes: HashMap<String, Theme>,
registration_order: Vec<String>,
current_theme: String,
theme_changed: Signal<()>,
high_contrast: HighContrastMode,
crossfade: Option<ThemeCrossfade>,
}
#[derive(Debug, Clone)]
struct ThemeCrossfade {
from: Colors,
elapsed_ms: u32,
duration_ms: u32,
}
impl ThemeManager {
pub fn new() -> Self {
let default = Theme::default();
let current_theme = default.name.clone();
let registration_order = Vec::from([current_theme.clone()]);
let mut themes = HashMap::new();
themes.insert(default.name.clone(), default);
Self {
themes,
registration_order,
current_theme,
theme_changed: Signal::new(),
high_contrast: HighContrastMode::None,
crossfade: None,
}
}
pub fn set_high_contrast(&mut self, mode: HighContrastMode) {
self.high_contrast = mode;
note_theme_mutation();
self.theme_changed.emit(());
}
pub fn high_contrast(&self) -> HighContrastMode {
self.high_contrast
}
pub fn reduce_transparency(&self) -> bool {
crate::style::environment::environment().reduce_transparency
}
pub fn material_layer(&self, material: Material) -> Option<(u32, Option<(Color, f32)>)> {
if self.material_is_opaque(material) {
return None;
}
let radius = material.backdrop_blur_radius()?;
Some((radius, material.tint(&self.effective_colors())))
}
pub fn material_is_opaque(&self, material: Material) -> bool {
material == Material::None || self.reduce_transparency()
}
pub fn material_surface(&self, material: Material) -> Color {
material.opaque_surface(&self.effective_colors())
}
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
.registration_order
.iter()
.find(|name| self.themes.get(*name).is_some_and(|theme| theme.appearance == appearance))
.cloned();
match candidate {
Some(name) => self.set_theme(&name),
None => false,
}
}
pub fn register_theme(&mut self, theme: Theme) {
let name = theme.name.clone();
let is_new = self.themes.insert(name.clone(), theme).is_none();
if is_new {
self.registration_order.push(name);
}
note_theme_mutation();
}
pub fn follow_environment_appearance(&mut self) -> bool {
match crate::style::environment::environment().color_scheme {
ThemeMode::Light => self.set_appearance(AppearanceMode::Light),
ThemeMode::Dark => self.set_appearance(AppearanceMode::Dark),
ThemeMode::Auto => false,
}
}
pub fn refresh_system_accent(&mut self) -> bool {
crate::style::environment::refresh_environment();
let Some(reported) = crate::style::environment::environment().system_accent else {
return false;
};
let reported = SystemAccent::nearest(reported);
let Some(theme) = self.themes.get(&self.current_theme) else {
return false;
};
let dark = theme.appearance == AppearanceMode::Dark;
let recorded = theme.system_accent.color(dark);
let derived_from_recorded =
theme.colors.primary == recorded && theme.colors.accent == recorded;
if theme.system_accent == reported || !derived_from_recorded {
return false;
}
let accent = reported.color(dark);
let Some(theme) = self.themes.get_mut(&self.current_theme) else {
return false;
};
theme.system_accent = reported;
theme.colors.primary = accent;
theme.colors.accent = accent;
theme.overrides.styles = super::preset_states::preset_state_overrides(&theme.colors);
note_theme_mutation();
self.theme_changed.emit(());
true
}
#[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.register_theme(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.register_theme(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 set_theme(&mut self, name: &str) -> bool {
if self.themes.contains_key(name) {
self.current_theme = name.to_string();
note_theme_mutation();
self.theme_changed.emit(());
return true;
}
false
}
pub fn set_theme_faded(&mut self, name: &str, duration_ms: u32) -> bool {
if !self.themes.contains_key(name) {
return false;
}
if name == self.current_theme {
self.crossfade = None;
return self.set_theme(name);
}
let from = self.effective_colors();
let reduced = crate::style::environment::environment().prefers_reduced_motion();
let duration_ms = if reduced { 0 } else { duration_ms };
let changed = self.set_theme(name);
debug_assert!(changed, "the name was checked against the registry above");
self.crossfade = if duration_ms == 0 {
None
} else {
Some(ThemeCrossfade { from, elapsed_ms: 0, duration_ms })
};
changed
}
pub fn set_theme_faded_default(&mut self, name: &str) -> bool {
self.set_theme_faded(name, THEME_CROSSFADE_DEFAULT_MS)
}
pub fn effective_colors(&self) -> Colors {
let Some(theme) = self.current_theme() else {
return Colors::default();
};
let Some(crossfade) = &self.crossfade else {
return theme.colors.clone();
};
if crossfade.duration_ms == 0 {
return theme.colors.clone();
}
let progress = (crossfade.elapsed_ms as f32 / crossfade.duration_ms as f32).clamp(0.0, 1.0);
let eased = crate::style::EasingFunction::EaseOut.apply(progress);
crossfade.from.blend(&theme.colors, eased)
}
pub fn tick_theme_crossfade(&mut self, delta_ms: u32) -> bool {
let Some(crossfade) = self.crossfade.as_mut() else {
return false;
};
crossfade.elapsed_ms = crossfade.elapsed_ms.saturating_add(delta_ms);
let finished = crossfade.elapsed_ms >= crossfade.duration_ms;
if finished {
self.crossfade = None;
}
bump_theme_revision();
!finished
}
pub fn is_crossfading(&self) -> bool {
self.crossfade.is_some()
}
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 icon_size(&self, slot: IconSlot) -> Option<u32> {
self.current_theme().map(|theme| theme.icon_size(slot))
}
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(
&self,
kind_name: &str,
class_name: Option<&str>,
state: Option<WidgetState>,
) -> WidgetStyle {
let mut style = self.resolve_base_style_for(kind_name, class_name);
if let Some(state) = state {
let suffixes = match class_name {
Some(class) => [
Some(format!("{class}:{}", state.state_suffix())),
Some(format!("{kind_name}:{}", state.state_suffix())),
None,
],
None => [None, Some(format!("{kind_name}:{}", state.state_suffix())), None],
};
for key in suffixes.into_iter().flatten() {
if let Some(token) = self.current_theme().and_then(|t| t.overrides.styles.get(&key))
{
let theme = self.current_theme();
apply_token(&mut style, token, theme);
break;
}
}
}
self.apply_high_contrast(&mut style);
style
}
fn resolve_base_style_for(&self, kind_name: &str, class_name: Option<&str>) -> WidgetStyle {
let Some(theme) = self.current_theme() else {
return WidgetStyle::default();
};
let mut style =
self.role_base_style(theme, kind_name, WidgetRole::for_kind_name(kind_name));
if let Some(class_name) = class_name {
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));
}
}
style
}
fn apply_high_contrast(&self, style: &mut WidgetStyle) {
if let Some((background, foreground)) = self.high_contrast.forced_pair() {
style.background_color = Some(background);
style.text_color = Some(foreground);
style.background_gradient = 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 {
if let Some(theme) = self.current_theme() {
if !theme.overrides.styles.is_empty() {
let key = format!("{class_name}:{}", state.state_suffix());
if let Some(token) = theme.overrides.styles.get(&key) {
apply_token(&mut style, token, Some(theme));
}
}
}
}
self.apply_high_contrast(&mut style);
style
}
fn role_base_style(&self, theme: &Theme, kind_name: &str, role: WidgetRole) -> WidgetStyle {
let surface = if theme.flat_surfaces {
crate::render::SurfaceStyle::solid()
} else {
crate::render::role_surface_style(kind_name)
};
let shadow = theme.elevation(surface.elevation, Color::BLACK);
let colors = self.effective_colors();
let (background_color, text_color, border_color) = role_colors(&colors, role);
let touch_target = Some(crate::platform::profile::recommended_touch_target().dimensions());
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,
surface: Some(surface),
touch_target,
font: Some(
theme
.fonts
.body
.clone()
.scaled(crate::style::environment::environment().effective_text_scale()),
),
..Default::default()
}
}
fn resolve_base_style(&self, class_name: &str) -> WidgetStyle {
let Some(theme) = self.current_theme() else {
return WidgetStyle::default();
};
let role = WidgetRole::for_kind_name(class_name);
let mut style = self.role_base_style(theme, class_name, role);
let token = theme
.overrides
.styles
.get(class_name)
.or_else(|| theme.overrides.styles.get(role_key(role)));
if let Some(token) = token {
apply_token(&mut style, token, Some(theme));
}
style
}
}
fn role_colors(colors: &Colors, role: WidgetRole) -> (Option<Color>, Option<Color>, Option<Color>) {
match role {
WidgetRole::Primary => {
(Some(colors.primary), Some(colors.primary.contrast_color()), Some(colors.primary))
}
WidgetRole::Text => (None, Some(colors.foreground), None),
WidgetRole::Input => {
(Some(colors.input_background()), Some(colors.foreground), Some(colors.secondary))
}
WidgetRole::Accent => (Some(colors.accent), Some(colors.accent.contrast_color()), None),
WidgetRole::Choice => {
(Some(colors.input_background()), Some(colors.foreground), Some(colors.secondary))
}
WidgetRole::Danger => (Some(colors.error), Some(colors.error.contrast_color()), None),
WidgetRole::Surface => (
Some(colors.surface_container),
Some(colors.foreground),
Some(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 apply_token(style: &mut WidgetStyle, token: &super::ThemeStyleToken, theme: Option<&Theme>) {
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(padding) = &token.padding {
style.padding = padding.resolve();
}
if let Some(opacity) = token.opacity {
style.opacity = Some(crate::style::normalized_opacity(opacity));
}
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));
}
apply_surface_token(style, token, theme, token.shadow == super::ShadowOverride::Inherit);
if let Some(font) = &token.font {
style.font = Some(font.clone());
}
}
fn apply_surface_token(
style: &mut WidgetStyle,
token: &super::ThemeStyleToken,
theme: Option<&Theme>,
may_derive_shadow: bool,
) {
let mut surface = style.surface.unwrap_or_else(crate::render::SurfaceStyle::solid);
let mut rederive_shadow = false;
if let Some(level) = token.elevation {
surface.elevation = level.0;
rederive_shadow = may_derive_shadow;
}
if token.bevel != super::BevelOverride::Inherit {
surface.bevel = token.bevel.apply(surface.bevel);
}
if let Some(material) = token.material {
surface.material = material.0;
}
if let Some(hairline) = token.hairline {
surface.hairline = hairline.0;
}
if rederive_shadow {
if let Some(theme) = theme {
style.shadow = theme.elevation(surface.elevation, crate::core::Color::BLACK);
}
}
style.surface = Some(surface);
style.reconcile_shadow_into_surface();
}
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> {
#[cfg(test)]
{
TEST_MANAGER.with(|manager| lock(manager))
}
#[cfg(not(test))]
{
static MANAGER: OnceLock<Mutex<ThemeManager>> = OnceLock::new();
lock(MANAGER.get_or_init(seeded_theme_manager))
}
}
fn seeded_theme_manager() -> Mutex<ThemeManager> {
let mut manager = ThemeManager::new();
let dark = Theme::dark();
manager.register_theme(dark);
#[cfg(all(target_os = "macos", feature = "cocoa-legacy"))]
crate::platform::macos::environment::install_system_color_observer();
Mutex::new(manager)
}
#[cfg(test)]
thread_local! {
static TEST_MANAGER: &'static Mutex<ThemeManager> =
crate::compat::Box::leak(crate::compat::Box::new(seeded_theme_manager()));
}
pub fn theme_test_guard() -> crate::compat::MutexGuard<'static, ()> {
static GUARD: OnceLock<Mutex<()>> = OnceLock::new();
lock(GUARD.get_or_init(|| Mutex::new(())))
}
struct CacheEntry<T> {
revision: u64,
last_used: u64,
value: T,
}
impl<T> CacheEntry<T> {
fn new(revision: u64, value: T) -> Self {
Self { revision, last_used: next_use_tick(), value }
}
fn mark_used(&mut self) {
self.last_used = next_use_tick();
}
}
thread_local! {
static CACHE_USE_TICK: core::cell::Cell<u64> = const { core::cell::Cell::new(0) };
}
fn next_use_tick() -> u64 {
CACHE_USE_TICK.with(|tick| {
let next = tick.get().wrapping_add(1);
tick.set(next);
next
})
}
type ResolvedStyleCache = HashMap<String, CacheEntry<Rc<WidgetStyle>>>;
thread_local! {
static RESOLVED_STYLE_CACHE: crate::compat::RefCell<ResolvedStyleCache> =
crate::compat::RefCell::new(HashMap::new());
}
const RESOLVED_STYLE_CACHE_LIMIT: usize = 256;
pub(crate) fn invalidate_resolved_style_cache() {
bump_theme_revision();
}
type StateStyleCache = HashMap<String, HashMap<Option<WidgetState>, CacheEntry<Rc<WidgetStyle>>>>;
thread_local! {
static RESOLVED_STATE_STYLE_CACHE: crate::compat::RefCell<StateStyleCache> =
crate::compat::RefCell::new(HashMap::new());
}
const RESOLVED_STATE_STYLE_CACHE_LIMIT: usize = 256;
type SemanticBorderCache = HashMap<String, HashMap<SemanticState, CacheEntry<Option<Color>>>>;
thread_local! {
static RESOLVED_SEMANTIC_BORDER_CACHE: crate::compat::RefCell<SemanticBorderCache> =
crate::compat::RefCell::new(HashMap::new());
}
const RESOLVED_SEMANTIC_BORDER_CACHE_LIMIT: usize = 256;
fn evict_style_least_recently_used(cache: &mut ResolvedStyleCache) {
let coldest =
cache.iter().min_by_key(|(_, entry)| entry.last_used).map(|(name, _)| name.clone());
if let Some(name) = coldest {
cache.remove(&name);
}
}
fn evict_state_least_recently_used(cache: &mut StateStyleCache) {
let mut coldest: Option<(String, Option<WidgetState>, u64)> = None;
for (kind, states) in cache.iter() {
for (state, entry) in states.iter() {
let colder = match &coldest {
Some((_, _, stamp)) => entry.last_used < *stamp,
None => true,
};
if colder {
coldest = Some((kind.clone(), *state, entry.last_used));
}
}
}
if let Some((kind, state, _)) = coldest {
let emptied = match cache.get_mut(&kind) {
Some(states) => {
states.remove(&state);
states.is_empty()
}
None => false,
};
if emptied {
cache.remove(&kind);
}
}
}
fn evict_semantic_border_least_recently_used(cache: &mut SemanticBorderCache) {
let mut coldest: Option<(String, SemanticState, u64)> = None;
for (kind, meanings) in cache.iter() {
for (meaning, entry) in meanings.iter() {
let colder = match &coldest {
Some((_, _, stamp)) => entry.last_used < *stamp,
None => true,
};
if colder {
coldest = Some((kind.clone(), *meaning, entry.last_used));
}
}
}
if let Some((kind, meaning, _)) = coldest {
let emptied = match cache.get_mut(&kind) {
Some(meanings) => {
meanings.remove(&meaning);
meanings.is_empty()
}
None => false,
};
if emptied {
cache.remove(&kind);
}
}
}
pub fn resolved_theme_style_shared(widget_name: &str) -> Option<Rc<WidgetStyle>> {
let revision = theme_revision();
let hit = RESOLVED_STYLE_CACHE.with(|cache| {
let mut cache = cache.borrow_mut();
let entry = cache.get_mut(widget_name)?;
if entry.revision != revision {
return None;
}
entry.mark_used();
Some(Rc::clone(&entry.value))
});
if let Some(style) = hit {
return Some(style);
}
let style = {
let manager = global_theme_manager();
manager.current_theme()?;
manager.resolve_style(widget_name)
};
let style = Rc::new(style);
let revision = theme_revision();
RESOLVED_STYLE_CACHE.with(|cache| {
let mut cache = cache.borrow_mut();
if cache.len() >= RESOLVED_STYLE_CACHE_LIMIT {
evict_style_least_recently_used(&mut cache);
}
cache.insert(widget_name.to_string(), CacheEntry::new(revision, Rc::clone(&style)));
});
Some(style)
}
pub fn resolved_theme_style(widget_name: &str) -> Option<WidgetStyle> {
resolved_theme_style_shared(widget_name).map(|style| (*style).clone())
}
thread_local! {
static ACTIVE_PALETTE_CACHE: crate::compat::RefCell<Option<(u64, Rc<Colors>)>> =
const { crate::compat::RefCell::new(None) };
}
pub fn active_palette_shared() -> Option<Rc<Colors>> {
let revision = theme_revision();
if let Some(colors) = ACTIVE_PALETTE_CACHE.with(|cache| {
cache
.borrow()
.as_ref()
.filter(|(cached_revision, _)| *cached_revision == revision)
.map(|(_, colors)| Rc::clone(colors))
}) {
return Some(colors);
}
let manager = global_theme_manager();
manager.current_theme()?;
let colors = Rc::new(manager.effective_colors());
let revision = theme_revision();
ACTIVE_PALETTE_CACHE.with(|cache| {
*cache.borrow_mut() = Some((revision, Rc::clone(&colors)));
});
Some(colors)
}
pub fn resolved_theme_style_for(kind_name: &str, class_name: Option<&str>) -> Option<WidgetStyle> {
let manager = global_theme_manager();
manager.current_theme()?;
Some(manager.resolve_style_for(kind_name, class_name, None))
}
pub fn resolved_theme_style_for_state_shared(
kind_name: &str,
state: crate::style::WidgetState,
) -> Option<Rc<WidgetStyle>> {
let revision = theme_revision();
let cached = RESOLVED_STATE_STYLE_CACHE.with(|cache| {
let mut cache = cache.borrow_mut();
let entry = cache.get_mut(kind_name)?.get_mut(&Some(state))?;
if entry.revision != revision {
return None;
}
entry.mark_used();
Some(Rc::clone(&entry.value))
});
if let Some(style) = cached {
return Some(style);
}
let manager = global_theme_manager();
manager.current_theme()?;
let style = Rc::new(manager.resolve_style_for_state(kind_name, Some(state)));
let revision = theme_revision();
RESOLVED_STATE_STYLE_CACHE.with(|cache| {
let mut cache = cache.borrow_mut();
let entries: usize = cache.values().map(|states| states.len()).sum();
if entries >= RESOLVED_STATE_STYLE_CACHE_LIMIT {
evict_state_least_recently_used(&mut cache);
}
cache
.entry(kind_name.to_string())
.or_default()
.insert(Some(state), CacheEntry::new(revision, Rc::clone(&style)));
});
Some(style)
}
pub fn resolved_theme_style_for_state(
kind_name: &str,
state: crate::style::WidgetState,
) -> Option<WidgetStyle> {
resolved_theme_style_for_state_shared(kind_name, state).map(|style| (*style).clone())
}
pub fn resolved_semantic_border(kind_name: &str, semantic: SemanticState) -> Option<Color> {
let suffix = semantic.state_suffix()?;
let revision = theme_revision();
let cached = RESOLVED_SEMANTIC_BORDER_CACHE.with(|cache| {
let mut cache = cache.borrow_mut();
let entry = cache.get_mut(kind_name)?.get_mut(&semantic)?;
if entry.revision != revision {
return None;
}
entry.mark_used();
Some(entry.value)
});
if let Some(border) = cached {
return border;
}
let border = {
let manager = global_theme_manager();
match manager.current_theme() {
Some(theme) => {
let key = format!("{kind_name}:{suffix}");
theme.overrides.styles.get(&key).and_then(|token| token.border)
}
None => None,
}
};
let revision = theme_revision();
RESOLVED_SEMANTIC_BORDER_CACHE.with(|cache| {
let mut cache = cache.borrow_mut();
let entries: usize = cache.values().map(|meanings| meanings.len()).sum();
if entries >= RESOLVED_SEMANTIC_BORDER_CACHE_LIMIT {
evict_semantic_border_least_recently_used(&mut cache);
}
cache
.entry(kind_name.to_string())
.or_default()
.insert(semantic, CacheEntry::new(revision, border));
});
border
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum SemanticColor {
Info,
Success,
Warning,
Error,
}
impl SemanticColor {
pub const ALL: [SemanticColor; 4] =
[SemanticColor::Info, SemanticColor::Success, SemanticColor::Warning, SemanticColor::Error];
pub fn token(self) -> &'static str {
match self {
SemanticColor::Info => "info",
SemanticColor::Success => "success",
SemanticColor::Warning => "warning",
SemanticColor::Error => "error",
}
}
pub fn of(self, theme: &Theme) -> Color {
match self {
SemanticColor::Info => theme.colors.info,
SemanticColor::Success => theme.colors.success,
SemanticColor::Warning => theme.colors.warning,
SemanticColor::Error => theme.colors.error,
}
}
}
pub fn semantic_color(token: SemanticColor) -> Option<Color> {
let colors = active_palette_shared()?;
Some(match token {
SemanticColor::Info => colors.info,
SemanticColor::Success => colors.success,
SemanticColor::Warning => colors.warning,
SemanticColor::Error => colors.error,
})
}
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 },
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 },
..Colors::default()
},
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 },
icons: IconTokens::default(),
borders: Borders { width: 1, radius: 4, shadow: true },
flat_surfaces: true,
system_accent: SystemAccent::Blue,
metrics_profile: crate::widget::metrics::ControlMetricsProfile::Material,
overrides: ThemeOverrides {
styles: crate::theme::preset_states::preset_state_overrides(&Colors::default()),
},
motion: crate::theme::Motion::default(),
}
}
}
impl Theme {
pub fn dark() -> Self {
let 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 { r: 138, g: 180, b: 248, a: 255 },
outline: Color { r: 147, g: 143, b: 153, a: 255 },
outline_variant: Color { r: 73, g: 69, b: 79, a: 255 },
scrim: Color { r: 0, g: 0, b: 0, a: 130 },
surface_container: Color { r: 30, g: 30, b: 33, a: 255 },
surface_container_high: Color { r: 40, g: 40, b: 44, a: 255 },
inverse_surface: Color { r: 228, g: 225, b: 229, a: 255 },
on_inverse_surface: Color { r: 49, g: 48, b: 51, a: 255 },
};
Self {
name: "dark".to_string(),
appearance: AppearanceMode::Dark,
colors: colors.clone(),
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 },
icons: IconTokens::default(),
borders: Borders { width: 1, radius: 4, shadow: true },
flat_surfaces: true,
system_accent: SystemAccent::Blue,
metrics_profile: crate::widget::metrics::ControlMetricsProfile::Material,
overrides: ThemeOverrides {
styles: crate::theme::preset_states::preset_state_overrides(&colors),
},
motion: crate::theme::Motion::default(),
}
}
pub fn dimensional() -> Self {
let mut theme =
Theme { name: "dimensional".to_string(), flat_surfaces: false, ..Theme::default() };
theme.overrides.styles = crate::theme::preset_states::preset_state_overrides(&theme.colors);
for kind in ["button", "toggle_button", "tool_button"] {
theme.overrides.styles.insert(
format!("{kind}:pressed"),
crate::theme::ThemeStyleToken {
bevel: crate::theme::BevelOverride::Set(crate::render::BevelDirection::Inset),
..crate::theme::ThemeStyleToken::default()
},
);
}
theme
}
pub fn macos_light() -> Self {
let system_accent = host_system_accent();
let accent = system_accent.color(false);
let mut theme = Theme {
name: "macos-light".to_string(),
flat_surfaces: true,
system_accent,
metrics_profile: crate::widget::metrics::ControlMetricsProfile::Macos,
..Theme::default()
};
theme.appearance = AppearanceMode::Light;
theme.borders = Borders { width: 1, radius: 6, shadow: true };
theme.colors = Colors {
background: Color::rgb(236, 236, 236),
foreground: Color::rgb(29, 29, 31),
primary: accent,
secondary: Color::rgb(110, 110, 115),
accent,
error: Color::rgb(255, 59, 48),
warning: Color::rgb(255, 204, 0),
success: Color::rgb(52, 199, 89),
disabled: Color::rgb(199, 199, 204),
info: Color::rgb(50, 173, 230),
outline: Color::rgb(142, 142, 147),
outline_variant: Color::rgb(209, 209, 214),
scrim: Color::rgba(0, 0, 0, 92),
surface_container: Color::rgb(245, 245, 247),
surface_container_high: Color::rgb(255, 255, 255),
inverse_surface: Color::rgb(29, 29, 31),
on_inverse_surface: Color::rgb(245, 245, 247),
};
theme.overrides.styles = crate::theme::preset_states::preset_state_overrides(&theme.colors);
if let Some(family) = register_macos_system_ui_face() {
apply_system_ui_family(&mut theme.fonts, family);
}
theme.motion = Motion { fast: 120, normal: 220, slow: 300, ..Motion::default() };
theme
}
pub fn macos_dark() -> Self {
let system_accent = host_system_accent();
let accent = system_accent.color(true);
let mut theme = Theme {
name: "macos-dark".to_string(),
flat_surfaces: true,
system_accent,
metrics_profile: crate::widget::metrics::ControlMetricsProfile::Macos,
..Theme::dark()
};
theme.appearance = AppearanceMode::Dark;
theme.borders = Borders { width: 1, radius: 6, shadow: true };
theme.colors = Colors {
background: Color::rgb(30, 30, 30),
foreground: Color::rgb(245, 245, 247),
primary: accent,
secondary: Color::rgb(152, 152, 157),
accent,
error: Color::rgb(255, 69, 58),
warning: Color::rgb(255, 214, 10),
success: Color::rgb(48, 209, 88),
disabled: Color::rgb(72, 72, 74),
info: Color::rgb(100, 210, 255),
outline: Color::rgb(142, 142, 147),
outline_variant: Color::rgb(58, 58, 60),
scrim: Color::rgba(0, 0, 0, 120),
surface_container: Color::rgb(44, 44, 46),
surface_container_high: Color::rgb(58, 58, 60),
inverse_surface: Color::rgb(245, 245, 247),
on_inverse_surface: Color::rgb(29, 29, 31),
};
theme.overrides.styles = crate::theme::preset_states::preset_state_overrides(&theme.colors);
if let Some(family) = register_macos_system_ui_face() {
apply_system_ui_family(&mut theme.fonts, family);
}
theme.motion = Motion { fast: 120, normal: 220, slow: 300, ..Motion::default() };
theme
}
}
fn host_system_accent() -> SystemAccent {
crate::style::environment::environment()
.system_accent
.map(SystemAccent::nearest)
.unwrap_or(SystemAccent::Blue)
}
fn apply_system_ui_family(fonts: &mut Fonts, family: &str) {
fonts.regular = fonts.regular.with_family(family);
fonts.bold = fonts.bold.with_family(family);
fonts.italic = fonts.italic.with_family(family);
fonts.caption = fonts.caption.with_family(family);
fonts.body = fonts.body.with_family(family);
fonts.title = fonts.title.with_family(family);
fonts.headline = fonts.headline.with_family(family);
fonts.display = fonts.display.with_family(family);
}
#[cfg(all(target_os = "macos", feature = "cocoa-legacy", feature = "runtime-fonts"))]
fn register_macos_system_ui_face() -> Option<&'static str> {
crate::platform::macos::environment::register_system_ui_face()
}
#[cfg(all(
target_os = "macos",
not(feature = "cocoa-legacy"),
feature = "macos",
feature = "runtime-fonts"
))]
fn register_macos_system_ui_face() -> Option<&'static str> {
crate::platform::macos_objc2::native::register_system_ui_face()
}
#[cfg(not(all(
target_os = "macos",
any(feature = "cocoa-legacy", feature = "macos"),
feature = "runtime-fonts"
)))]
fn register_macos_system_ui_face() -> Option<&'static str> {
None
}
#[cfg(all(test, feature = "desktop", widgets_unstripped))]
mod environment_appearance_tests {
use super::*;
use crate::core::TextDirection;
use crate::style::environment::{
install_environment, uninstall_environment, EnvironmentProvider,
};
use crate::style::{MotionPreference, ThemeMode};
use alloc::boxed::Box;
struct DeviceAsking(ThemeMode);
impl EnvironmentProvider for DeviceAsking {
fn text_scale(&self) -> f32 {
1.0
}
fn layout_scale(&self) -> f32 {
1.0
}
fn locale(&self) -> Option<&'static str> {
None
}
fn color_scheme(&self) -> ThemeMode {
self.0
}
fn motion_preference(&self) -> MotionPreference {
MotionPreference::NoPreference
}
fn high_contrast(&self) -> bool {
false
}
fn reduce_transparency(&self) -> bool {
false
}
fn system_accent(&self) -> Option<crate::core::Color> {
None
}
fn text_direction(&self) -> TextDirection {
TextDirection::LeftToRight
}
fn mirroring(&self) -> bool {
false
}
}
fn manager_with_both_appearances() -> ThemeManager {
let mut manager = ThemeManager::new();
let mut dark = Theme::dark();
dark.name = "dark-fixture".to_string();
dark.appearance = AppearanceMode::Dark;
manager.register_theme(dark);
manager
}
#[test]
fn the_devices_appearance_chooses_the_active_theme() {
let _guard = crate::theme::theme_test_guard();
let mut manager = manager_with_both_appearances();
assert_eq!(manager.current_theme_name(), Theme::default().name, "light at rest");
let previous = install_environment(Box::new(DeviceAsking(ThemeMode::Dark)));
let changed = manager.follow_environment_appearance();
assert!(changed, "the appearance actually changed, so the answer must be `true`");
assert_eq!(manager.current_theme_name(), "dark-fixture");
install_environment(Box::new(DeviceAsking(ThemeMode::Light)));
assert!(manager.follow_environment_appearance());
assert_eq!(manager.current_theme_name(), Theme::default().name);
drop(previous);
uninstall_environment();
}
#[test]
fn an_auto_preference_changes_nothing_rather_than_guessing_light() {
let _guard = crate::theme::theme_test_guard();
let mut manager = manager_with_both_appearances();
assert!(manager.set_appearance(AppearanceMode::Dark));
assert_eq!(manager.current_theme_name(), "dark-fixture");
let previous = install_environment(Box::new(DeviceAsking(ThemeMode::Auto)));
assert!(
!manager.follow_environment_appearance(),
"an unresolved preference must not be answered by guessing"
);
assert_eq!(
manager.current_theme_name(),
"dark-fixture",
"and it must not have moved the active theme off dark"
);
drop(previous);
uninstall_environment();
}
#[test]
fn a_device_asking_for_an_unregistered_appearance_is_reported_not_guessed() {
let _guard = crate::theme::theme_test_guard();
let mut manager = ThemeManager::new();
let before = manager.current_theme_name().to_string();
let previous = install_environment(Box::new(DeviceAsking(ThemeMode::Dark)));
assert!(
!manager.follow_environment_appearance(),
"no registered theme provides dark, so the answer is `false`"
);
assert_eq!(
manager.current_theme_name(),
before,
"and the light theme the app had still stands rather than being swapped arbitrarily"
);
drop(previous);
uninstall_environment();
}
}
#[cfg(all(test, feature = "desktop", widgets_unstripped))]
mod macos_preset_tests {
use super::*;
#[test]
fn the_macos_presets_state_their_appearance_and_roles() {
let expected_accent = host_system_accent();
let light = Theme::macos_light();
assert_eq!(light.name, "macos-light");
assert_eq!(light.appearance, AppearanceMode::Light);
assert_eq!(light.system_accent, expected_accent);
assert_eq!(light.colors.primary, expected_accent.color(false));
assert_eq!(
light.colors.accent, light.colors.primary,
"accent is the host accent, not orange"
);
assert!(light.flat_surfaces, "macOS controls are flat");
assert_eq!(light.borders.radius, 6);
let dark = Theme::macos_dark();
assert_eq!(dark.name, "macos-dark");
assert_eq!(dark.appearance, AppearanceMode::Dark);
assert_eq!(dark.system_accent, expected_accent);
assert_eq!(dark.colors.primary, expected_accent.color(true));
assert_eq!(dark.colors.accent, dark.colors.primary);
assert!(dark.flat_surfaces);
assert_ne!(
Theme::default().colors.primary,
light.colors.primary,
"the default light theme must stay neutral"
);
assert_ne!(
Theme::dark().colors.primary,
dark.colors.primary,
"the built-in dark preset must stay as it was"
);
}
#[test]
fn the_macos_presets_declare_the_macos_metrics_profile() {
use crate::widget::metrics::ControlMetricsProfile;
assert_eq!(Theme::macos_light().metrics_profile, ControlMetricsProfile::Macos);
assert_eq!(Theme::macos_dark().metrics_profile, ControlMetricsProfile::Macos);
}
#[test]
fn the_macos_preset_only_names_the_system_ui_face_when_it_was_registered() {
let registered = register_macos_system_ui_face();
let light = Theme::macos_light();
match registered {
Some(family) => {
assert_eq!(light.fonts.regular.family(), family);
assert_eq!(light.fonts.body.family(), family);
assert_eq!(light.fonts.title.family(), family);
assert_eq!(
light.fonts.monospace.family(),
"Courier New",
"the monospace role keeps a fixed-advance face"
);
}
None => {
assert_eq!(
light.fonts.regular.family(),
"Arial",
"without a registered face the preset keeps the compiled family"
);
}
}
}
#[test]
fn the_macos_presets_state_the_native_motion_tokens() {
for theme in [Theme::macos_light(), Theme::macos_dark()] {
assert_eq!(theme.motion.fast, 120, "{} fast", theme.name);
assert_eq!(theme.motion.normal, 220, "{} normal", theme.name);
assert_eq!(theme.motion.slow, 300, "{} slow", theme.name);
assert_eq!(
theme.motion.curve,
crate::style::MotionCurve::Easing,
"the macOS presets keep the easing curve"
);
}
assert_eq!(Theme::default().motion.fast, 100, "the existing default is unchanged");
assert_eq!(Theme::dark().motion.normal, 200, "and so is the built-in dark preset");
}
}
#[cfg(all(test, feature = "desktop", widgets_unstripped))]
mod theme_crossfade_tests {
use super::*;
use crate::style::environment::{
install_environment, uninstall_environment, EnvironmentProvider,
};
use crate::style::MotionPreference;
use alloc::boxed::Box;
fn manager_with_dark() -> ThemeManager {
let mut manager = ThemeManager::new();
manager.register_theme(Theme::dark());
manager
}
#[test]
fn a_faded_switch_passes_through_an_intermediate_colour() {
let _guard = crate::theme::theme_test_guard();
let mut manager = manager_with_dark();
let from = manager.effective_colors();
let to = Theme::dark().colors;
assert!(manager.set_theme_faded("dark", THEME_CROSSFADE_DEFAULT_MS));
assert_eq!(
manager.effective_colors().background,
from.background,
"the frame the switch was requested on still shows the source palette"
);
let half = THEME_CROSSFADE_DEFAULT_MS / 2;
assert!(manager.tick_theme_crossfade(half), "halfway through, another frame is owed");
let middle = manager.effective_colors();
assert_ne!(middle.background, from.background);
assert_ne!(middle.background, to.background);
assert_ne!(middle.foreground, from.foreground);
assert_ne!(middle.foreground, to.foreground);
assert!(
!manager.tick_theme_crossfade(THEME_CROSSFADE_DEFAULT_MS - half),
"the transition reports that it is finished"
);
assert_eq!(manager.effective_colors().background, to.background);
assert_eq!(manager.effective_colors().foreground, to.foreground);
}
#[test]
fn reduced_motion_makes_a_faded_switch_instant() {
struct Reduced;
impl EnvironmentProvider for Reduced {
fn motion_preference(&self) -> MotionPreference {
MotionPreference::ReduceMotion
}
}
let _guard = crate::theme::theme_test_guard();
let mut manager = manager_with_dark();
let previous = install_environment(Box::new(Reduced));
assert!(manager.set_theme_faded_default("dark"));
assert_eq!(
manager.effective_colors().background,
Theme::dark().colors.background,
"the target palette is in force immediately"
);
assert!(!manager.tick_theme_crossfade(16), "no transition is running");
drop(previous);
uninstall_environment();
}
#[test]
fn the_plain_switch_is_still_instant() {
let _guard = crate::theme::theme_test_guard();
let mut manager = manager_with_dark();
assert!(manager.set_theme("dark"));
assert_eq!(manager.effective_colors().background, Theme::dark().colors.background);
assert!(!manager.tick_theme_crossfade(16), "`set_theme` started no transition");
}
#[test]
fn an_older_theme_file_loads_with_the_new_fields_at_their_defaults() {
use crate::widget::metrics::ControlMetricsProfile;
let mut value = serde_json::to_value(Theme::default()).expect("a theme serialises");
let object = value.as_object_mut().expect("a theme is a JSON object");
assert!(
object.remove("system_accent").is_some(),
"the fixture must exercise the old shape"
);
assert!(
object.remove("metrics_profile").is_some(),
"the fixture must exercise the old shape"
);
let legacy: Theme = serde_json::from_value(value).expect("an older file must still load");
assert_eq!(legacy.system_accent, SystemAccent::Blue);
assert_eq!(legacy.metrics_profile, ControlMetricsProfile::Material);
}
#[test]
fn a_new_theme_defaults_to_the_blue_accent_and_the_material_profile() {
use crate::widget::metrics::ControlMetricsProfile;
let theme = Theme::default();
assert_eq!(theme.system_accent, SystemAccent::Blue);
assert_eq!(theme.metrics_profile, ControlMetricsProfile::Material);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn probe_window_background_colours() {
for theme in [Theme::default(), Theme::dark(), Theme::macos_light(), Theme::macos_dark()] {
let name = theme.name.clone();
let mut manager = ThemeManager::new();
manager.register_theme(theme);
manager.set_theme(&name);
let style = manager.resolve_style("window");
eprintln!(
"[PROBE] {name}: window bg={:?} theme bg={:?} surface={:?} primary={:?} error={:?}",
style.background_color,
manager.effective_colors().background,
manager.effective_colors().surface_container,
manager.effective_colors().primary,
manager.effective_colors().error
);
}
}
#[test]
fn a_theme_mutation_arms_a_full_repaint() {
let before = crate::widget::runtime::global_repaint_epoch_for_test();
note_theme_mutation();
assert_eq!(
crate::widget::runtime::global_repaint_epoch_for_test(),
before + 1,
"a theme change must arm exactly one full repaint"
);
}
#[test]
fn the_system_ui_family_maps_every_text_role_and_preserves_weights() {
let mut fonts = Theme::default().fonts;
let monospace = fonts.monospace.family().to_string();
let families = [
fonts.regular.family().to_string(),
fonts.bold.family().to_string(),
fonts.italic.family().to_string(),
fonts.caption.family().to_string(),
fonts.body.family().to_string(),
fonts.title.family().to_string(),
fonts.headline.family().to_string(),
fonts.display.family().to_string(),
];
let weights = [
fonts.regular.weight(),
fonts.bold.weight(),
fonts.italic.weight(),
fonts.caption.weight(),
fonts.body.weight(),
fonts.title.weight(),
fonts.headline.weight(),
fonts.display.weight(),
];
apply_system_ui_family(&mut fonts, "System UI Fixture");
for family in [
&fonts.regular,
&fonts.bold,
&fonts.italic,
&fonts.caption,
&fonts.body,
&fonts.title,
&fonts.headline,
&fonts.display,
] {
assert_eq!(family.family(), "System UI Fixture", "every text role moves");
}
assert_eq!(
fonts.monospace.family(),
monospace,
"the monospace role keeps a fixed-advance face"
);
let moved_weights = [
fonts.regular.weight(),
fonts.bold.weight(),
fonts.italic.weight(),
fonts.caption.weight(),
fonts.body.weight(),
fonts.title.weight(),
fonts.headline.weight(),
fonts.display.weight(),
];
assert_eq!(moved_weights, weights, "a family change must not change a role's weight");
assert_eq!(
fonts.bold.weight(),
crate::core::Font::BOLD_WEIGHT,
"the bold role asks for 700, which the variable face supplies from its wght axis"
);
assert!(families.iter().all(|family| family != "System UI Fixture"));
}
}
#[cfg(test)]
mod material_tests {
use super::*;
use crate::style::environment::{
install_environment, uninstall_environment, DefaultEnvironment, EnvironmentProvider,
};
use alloc::boxed::Box;
struct ReducedTransparency;
impl EnvironmentProvider for ReducedTransparency {
fn reduce_transparency(&self) -> bool {
true
}
}
#[test]
fn a_material_requests_a_blur_and_a_tint() {
let previous = install_environment(Box::new(DefaultEnvironment));
let manager = ThemeManager::new();
let (radius, tint) =
manager.material_layer(Material::Sidebar).expect("a sidebar asks for a layer");
assert_eq!(radius, Material::Sidebar.backdrop_blur_radius().unwrap());
let (colour, weight) = tint.expect("the sidebar is tinted");
assert_eq!(colour, manager.effective_colors().surface_container);
assert!(weight > 0.0 && weight <= 1.0, "the tint weight is a fraction: {weight}");
assert!(manager.material_layer(Material::None).is_none(), "None is the identity");
drop(previous);
uninstall_environment();
}
#[test]
fn reduced_transparency_degrades_a_material_to_an_opaque_surface() {
let previous = install_environment(Box::new(ReducedTransparency));
let manager = ThemeManager::new();
assert!(manager.reduce_transparency());
assert!(
manager.material_is_opaque(Material::WindowChrome),
"a host that reduced transparency gets an opaque surface"
);
assert!(
manager.material_layer(Material::WindowChrome).is_none(),
"a host that reduced transparency must not get a backdrop layer"
);
let surface = manager.material_surface(Material::Sidebar);
assert_eq!(surface, manager.effective_colors().surface_container);
assert_eq!(surface.a, 255, "the degraded surface is opaque");
drop(previous);
uninstall_environment();
}
#[cfg(not(alloc_frugal))]
#[test]
fn a_legacy_motion_block_defaults_to_the_easing_curve() {
let motion: Motion =
serde_json::from_str(r#"{"fast":1,"normal":2,"slow":3,"easing":"EaseOut"}"#)
.expect("the legacy block must load");
assert_eq!(motion.curve, crate::style::MotionCurve::Easing);
assert_eq!(Motion::default().curve, crate::style::MotionCurve::Easing);
}
}
#[cfg(test)]
mod system_accent_refresh_tests {
use super::*;
use crate::style::environment::{
install_environment, uninstall_environment, DefaultEnvironment, EnvironmentProvider,
};
use alloc::boxed::Box;
struct Accent(Option<Color>);
impl EnvironmentProvider for Accent {
fn system_accent(&self) -> Option<Color> {
self.0
}
}
#[test]
fn a_changed_system_accent_rederives_the_active_palette_once() {
let neutral = install_environment(Box::new(DefaultEnvironment));
let theme = Theme::macos_light();
assert_eq!(theme.system_accent, SystemAccent::Blue);
assert_eq!(theme.colors.primary, SystemAccent::Blue.color(false));
let previous =
install_environment(Box::new(Accent(Some(SystemAccent::Purple.color(false)))));
let mut manager = ThemeManager::new();
manager.register_theme(theme);
assert!(manager.set_theme("macos-light"));
assert_eq!(manager.effective_colors().primary, SystemAccent::Blue.color(false));
assert!(
manager.refresh_system_accent(),
"a changed accent must re-derive the palette of a theme derived from it"
);
assert_eq!(
manager.current_theme().expect("the active theme is registered").system_accent,
SystemAccent::Purple,
"the theme records the accent it was re-derived from"
);
assert_eq!(manager.effective_colors().primary, SystemAccent::Purple.color(false));
assert_eq!(manager.effective_colors().accent, SystemAccent::Purple.color(false));
let settled = manager.effective_colors();
assert!(
!manager.refresh_system_accent(),
"a refresh with no accent change must not re-derive again"
);
assert_eq!(manager.effective_colors().primary, settled.primary);
assert_eq!(manager.effective_colors().accent, settled.accent);
drop(previous);
drop(neutral);
uninstall_environment();
}
#[test]
fn an_accent_refresh_leaves_a_neutral_palette_alone() {
let neutral = install_environment(Box::new(DefaultEnvironment));
let previous =
install_environment(Box::new(Accent(Some(SystemAccent::Purple.color(false)))));
let mut manager = ThemeManager::new();
let before = manager.effective_colors();
assert!(
!manager.refresh_system_accent(),
"the neutral preset did not derive its action colours from the host accent"
);
assert_eq!(manager.effective_colors().primary, before.primary);
assert_eq!(manager.effective_colors().accent, before.accent);
drop(previous);
drop(neutral);
uninstall_environment();
}
}
#[cfg(test)]
mod resolution_cache_tests {
use super::*;
use crate::theme::{PaddingToken, ThemeStyleToken};
fn theme_naming_borders(name: &str, entries: &[(&str, Color)]) -> Theme {
let mut styles = crate::compat::BTreeMap::new();
for (key, border) in entries {
styles.insert(
(*key).to_string(),
ThemeStyleToken { border: Some(*border), ..ThemeStyleToken::default() },
);
}
Theme { name: name.to_string(), overrides: ThemeOverrides { styles }, ..Theme::default() }
}
#[test]
fn a_theme_switch_invalidates_the_semantic_border_cache() {
let _guard = theme_test_guard();
RESOLVED_SEMANTIC_BORDER_CACHE.with(|cache| cache.borrow_mut().clear());
let first = Color::rgb(11, 22, 33);
let second = Color::rgb(44, 55, 66);
{
let mut manager = global_theme_manager();
manager.register_theme(theme_naming_borders("border-a", &[("probe:error", first)]));
manager.register_theme(theme_naming_borders(
"border-b",
&[("probe:error", second), ("probe:warning", second)],
));
assert!(manager.set_theme("border-a"));
}
assert_eq!(resolved_semantic_border("probe", SemanticState::Error), Some(first));
assert_eq!(resolved_semantic_border("probe", SemanticState::Warning), None);
let poisoned = Color::rgb(200, 201, 202);
RESOLVED_SEMANTIC_BORDER_CACHE.with(|cache| {
let mut cache = cache.borrow_mut();
let entry = cache
.get_mut("probe")
.and_then(|meanings| meanings.get_mut(&SemanticState::Error))
.expect("the first read must have cached the error border");
entry.value = Some(poisoned);
});
assert_eq!(
resolved_semantic_border("probe", SemanticState::Error),
Some(poisoned),
"a same-revision read must be answered from the cache, not re-resolved"
);
{
let mut manager = global_theme_manager();
assert!(manager.set_theme("border-b"));
}
assert_eq!(
resolved_semantic_border("probe", SemanticState::Error),
Some(second),
"a theme switch must invalidate the poisoned entry"
);
assert_eq!(
resolved_semantic_border("probe", SemanticState::Warning),
Some(second),
"a cached `None` must not outlive the theme it was resolved under"
);
}
#[test]
fn a_cache_hit_refreshes_the_recency_stamp() {
let _guard = theme_test_guard();
let revision = theme_revision();
let stale = 0;
RESOLVED_STYLE_CACHE.with(|cache| {
let mut cache = cache.borrow_mut();
cache.clear();
cache.insert(
"probe-kind".to_string(),
CacheEntry { revision, last_used: stale, value: Rc::new(WidgetStyle::default()) },
);
});
let _ = resolved_theme_style_shared("probe-kind");
RESOLVED_STYLE_CACHE.with(|cache| {
let cache = cache.borrow();
let entry = cache.get("probe-kind").expect("the entry is still cached");
assert_eq!(entry.revision, revision, "a hit keeps the entry it resolved");
assert!(
entry.last_used > stale,
"a hit must become the most recently used entry, not keep its old stamp"
);
});
}
#[test]
fn the_style_cache_evicts_the_least_recently_used_name() {
let _guard = theme_test_guard();
let revision = theme_revision();
RESOLVED_STYLE_CACHE.with(|cache| {
let mut cache = cache.borrow_mut();
cache.clear();
for index in 0..RESOLVED_STYLE_CACHE_LIMIT {
cache.insert(
format!("probe-{index}"),
CacheEntry {
revision,
last_used: index as u64,
value: Rc::new(WidgetStyle::default()),
},
);
}
});
let _ = resolved_theme_style_shared("brand-new-kind");
RESOLVED_STYLE_CACHE.with(|cache| {
let cache = cache.borrow();
assert!(!cache.contains_key("probe-0"), "the coldest entry is evicted");
assert!(cache.contains_key("probe-1"), "one eviction, not a table clear");
assert!(cache.contains_key("brand-new-kind"), "the new resolution stays");
assert_eq!(cache.len(), RESOLVED_STYLE_CACHE_LIMIT, "the bound still holds");
});
}
#[test]
fn the_state_cache_evicts_one_state_not_the_whole_kind() {
let _guard = theme_test_guard();
let revision = theme_revision();
RESOLVED_STATE_STYLE_CACHE.with(|cache| {
let mut cache = cache.borrow_mut();
cache.clear();
let mut hot_kind = HashMap::new();
hot_kind.insert(
Some(WidgetState::Hover),
CacheEntry { revision, last_used: 1_000, value: Rc::new(WidgetStyle::default()) },
);
hot_kind.insert(
Some(WidgetState::Pressed),
CacheEntry { revision, last_used: 1, value: Rc::new(WidgetStyle::default()) },
);
cache.insert("probe-kind".to_string(), hot_kind);
let mut tick = 2u64;
while cache.values().map(|states| states.len()).sum::<usize>()
< RESOLVED_STATE_STYLE_CACHE_LIMIT
{
cache.insert(
format!("filler-{tick}"),
HashMap::from([(
Some(WidgetState::Normal),
CacheEntry {
revision,
last_used: tick,
value: Rc::new(WidgetStyle::default()),
},
)]),
);
tick += 1;
}
});
let _ = resolved_theme_style_for_state_shared("brand-new-kind", WidgetState::Normal);
RESOLVED_STATE_STYLE_CACHE.with(|cache| {
let cache = cache.borrow();
let kind = cache.get("probe-kind").expect("only one state of the kind was cold");
assert!(
!kind.contains_key(&Some(WidgetState::Pressed)),
"the coldest single entry is evicted"
);
assert!(
kind.contains_key(&Some(WidgetState::Hover)),
"the hot sibling state must survive; evicting the whole kind is the G-169 defect"
);
assert!(cache.contains_key("brand-new-kind"));
});
}
#[test]
fn a_padding_token_resolves_into_the_resolved_style() {
let mut manager = ThemeManager::new();
let mut styles = crate::compat::BTreeMap::new();
styles.insert(
"probe".to_string(),
ThemeStyleToken {
padding: Some(PaddingToken {
all: Some(3),
horizontal: Some(12),
..PaddingToken::default()
}),
..ThemeStyleToken::default()
},
);
manager.register_theme(Theme {
name: "padding-probe".to_string(),
overrides: ThemeOverrides { styles },
..Theme::default()
});
assert!(manager.set_theme("padding-probe"));
let style = manager.resolve_style("probe");
assert_eq!(style.padding.top, 3, "the general layer wins where the axis is silent");
assert_eq!(style.padding.bottom, 3);
assert_eq!(style.padding.left, 12, "the axis layer overrides the general one");
assert_eq!(style.padding.right, 12);
}
#[test]
fn icon_size_tokens_are_readable() {
let defaults = IconTokens::default();
assert_eq!((defaults.small, defaults.medium, defaults.large), (16, 18, 32));
assert_eq!(Theme::default().icon_size(IconSlot::Small), 16);
assert_eq!(Theme::default().icon_size(IconSlot::Medium), 18);
assert_eq!(Theme::default().icon_size(IconSlot::Large), 32);
let mut manager = ThemeManager::new();
manager.register_theme(Theme {
name: "icon-probe".to_string(),
icons: IconTokens { small: 20, medium: 24, large: 40 },
..Theme::default()
});
assert!(manager.set_theme("icon-probe"));
assert_eq!(manager.icon_size(IconSlot::Small), Some(20));
assert_eq!(manager.icon_size(IconSlot::Medium), Some(24));
assert_eq!(manager.icon_size(IconSlot::Large), Some(40));
}
}