use bevy::prelude::*;
use bevy::window::{PrimaryWindow, Window, WindowTheme, WindowThemeChanged};
use crate::provider::{PropertyTarget, StoredValue, ValueSource};
use crate::resources::ResourceKey;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum AppTheme {
#[default]
Unspecified,
Light,
Dark,
}
impl AppTheme {
fn from_window(theme: WindowTheme) -> Self {
match theme {
WindowTheme::Light => AppTheme::Light,
WindowTheme::Dark => AppTheme::Dark,
}
}
}
#[derive(Resource, Debug, Clone, Default)]
pub struct PfAppTheme {
user: AppTheme,
platform: AppTheme,
generation: u64,
}
impl PfAppTheme {
pub fn requested(&self) -> AppTheme {
if self.user != AppTheme::Unspecified {
self.user
} else {
self.platform
}
}
pub fn user(&self) -> AppTheme {
self.user
}
pub fn platform(&self) -> AppTheme {
self.platform
}
pub fn generation(&self) -> u64 {
self.generation
}
pub fn set_user(&mut self, theme: AppTheme) {
let before = self.requested();
self.user = theme;
if self.requested() != before {
self.generation += 1;
}
}
pub(crate) fn set_platform(&mut self, theme: AppTheme) {
let before = self.requested();
self.platform = theme;
if self.requested() != before {
self.generation += 1;
}
}
}
pub fn app_theme(world: &World) -> AppTheme {
world
.get_resource::<PfAppTheme>()
.map(PfAppTheme::requested)
.unwrap_or_default()
}
pub fn set_user_app_theme(world: &mut World, theme: AppTheme) {
let mut current = world.get_resource_or_insert_with(PfAppTheme::default);
current.set_user(theme);
}
#[derive(Debug, Clone)]
pub enum ThemeArm {
Value(StoredValue),
Dynamic(ResourceKey),
}
#[derive(Debug, Clone, Default)]
pub struct ThemeArms {
pub light: Option<ThemeArm>,
pub dark: Option<ThemeArm>,
pub default: Option<ThemeArm>,
}
impl ThemeArms {
pub fn pick(&self, theme: AppTheme) -> Option<&ThemeArm> {
match theme {
AppTheme::Dark => self.dark.as_ref().or(self.default.as_ref()),
_ => self.light.as_ref().or(self.default.as_ref()),
}
}
}
#[derive(Debug, Clone)]
pub struct AppThemeEntry {
pub arms: ThemeArms,
pub target: PropertyTarget,
pub tier: ValueSource,
}
#[derive(Component, Debug, Default, Clone)]
pub struct PfAppThemeRefs(pub Vec<AppThemeEntry>);
#[derive(Resource, Default)]
pub(crate) struct LastAppThemeGen(pub u64);
pub(crate) fn track_os_theme(
windows: Query<&Window, With<PrimaryWindow>>,
mut changes: MessageReader<WindowThemeChanged>,
mut theme: ResMut<PfAppTheme>,
) {
if let Ok(window) = windows.single()
&& let Some(reported) = window.window_theme
{
theme.set_platform(AppTheme::from_window(reported));
}
for change in changes.read() {
theme.set_platform(AppTheme::from_window(change.theme));
}
}
#[cfg(test)]
mod tests {
use super::*;
fn arms(light: Option<&str>, dark: Option<&str>, default: Option<&str>) -> ThemeArms {
let arm = |s: Option<&str>| {
s.map(|s| match s {
"null" => ThemeArm::Value(None),
other => ThemeArm::Value(Some(crate::resources::PfValue::String(other.into()))),
})
};
ThemeArms {
light: arm(light),
dark: arm(dark),
default: arm(default),
}
}
fn picked(arms: &ThemeArms, theme: AppTheme) -> Option<Option<String>> {
arms.pick(theme).map(|a| match a {
ThemeArm::Value(Some(crate::resources::PfValue::String(s))) => Some(s.clone()),
ThemeArm::Value(_) => None,
ThemeArm::Dynamic(_) => Some("<dynamic>".into()),
})
}
#[test]
fn unspecified_resolves_as_light_not_as_default() {
let a = arms(Some("day"), Some("night"), Some("fallback"));
assert_eq!(picked(&a, AppTheme::Light), Some(Some("day".into())));
assert_eq!(picked(&a, AppTheme::Dark), Some(Some("night".into())));
assert_eq!(
picked(&a, AppTheme::Unspecified),
Some(Some("day".into())),
"Unspecified shares Light's arm — it is NOT its own case"
);
}
#[test]
fn a_missing_arm_falls_back_to_default_and_never_to_the_other_theme() {
let no_dark = arms(Some("day"), None, Some("fallback"));
assert_eq!(
picked(&no_dark, AppTheme::Dark),
Some(Some("fallback".into()))
);
let no_light = arms(None, Some("night"), Some("fallback"));
assert_eq!(
picked(&no_light, AppTheme::Light),
Some(Some("fallback".into()))
);
let bare = arms(Some("day"), None, None);
assert!(picked(&bare, AppTheme::Dark).is_none());
}
#[test]
fn an_arm_written_as_null_is_supplied_and_stops_the_fallback() {
let a = arms(Some("null"), None, Some("fallback"));
assert_eq!(
picked(&a, AppTheme::Light),
Some(None),
"Light={{x:Null}} must yield null, NOT fall through to Default"
);
assert_eq!(picked(&a, AppTheme::Dark), Some(Some("fallback".into())));
}
#[test]
fn the_generation_moves_only_when_the_effective_theme_does() {
let mut theme = PfAppTheme::default();
assert_eq!(theme.requested(), AppTheme::Unspecified);
theme.set_user(AppTheme::Dark);
assert_eq!((theme.requested(), theme.generation()), (AppTheme::Dark, 1));
theme.set_user(AppTheme::Dark);
assert_eq!(
theme.generation(),
1,
"setting the same theme must not fire"
);
theme.set_platform(AppTheme::Light);
assert_eq!((theme.requested(), theme.generation()), (AppTheme::Dark, 1));
theme.set_user(AppTheme::Unspecified);
assert_eq!(
(theme.requested(), theme.generation()),
(AppTheme::Light, 2)
);
}
}