use crate::core::{Color, TextDirection};
use crate::style::ReducedMotionPreference;
use crate::style::ThemeMode;
pub type MotionPreference = ReducedMotionPreference;
pub trait EnvironmentProvider {
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 {
ThemeMode::Light
}
fn motion_preference(&self) -> MotionPreference {
MotionPreference::NoPreference
}
fn reduce_transparency(&self) -> bool {
false
}
fn system_accent(&self) -> Option<Color> {
None
}
fn high_contrast(&self) -> bool {
false
}
fn text_direction(&self) -> TextDirection {
TextDirection::LeftToRight
}
fn mirroring(&self) -> bool {
false
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq)]
pub struct PlatformEnvironment;
impl EnvironmentProvider for PlatformEnvironment {
fn text_scale(&self) -> f32 {
crate::platform::profile::text_scale()
}
fn color_scheme(&self) -> ThemeMode {
match crate::platform::profile::system_theme() {
Some(true) => ThemeMode::Dark,
_ => ThemeMode::Light,
}
}
fn locale(&self) -> Option<&'static str> {
crate::platform::platform_facts().system_locale()
}
fn motion_preference(&self) -> MotionPreference {
match crate::platform::platform_facts().reduce_motion() {
Some(true) => MotionPreference::ReduceMotion,
_ => MotionPreference::NoPreference,
}
}
fn high_contrast(&self) -> bool {
crate::platform::platform_facts().high_contrast().unwrap_or(false)
}
fn reduce_transparency(&self) -> bool {
reduce_transparency_from(crate::platform::platform_facts().reduce_transparency())
}
fn system_accent(&self) -> Option<Color> {
crate::platform::platform_facts().accent_color()
}
fn text_direction(&self) -> TextDirection {
text_direction_for_locale(self.locale())
}
}
pub fn text_direction_for_locale(locale: Option<&str>) -> TextDirection {
const RTL_LANGUAGES: [&str; 13] =
["ar", "arc", "ckb", "dv", "fa", "he", "iw", "ku", "ps", "sd", "ug", "ur", "yi"];
let Some(tag) = locale else {
return TextDirection::LeftToRight;
};
let language = tag.split(['-', '_']).next().unwrap_or(tag).to_ascii_lowercase();
if RTL_LANGUAGES.contains(&language.as_str()) {
TextDirection::RightToLeft
} else {
TextDirection::LeftToRight
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq)]
pub struct DefaultEnvironment;
impl EnvironmentProvider for DefaultEnvironment {}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct EnvironmentSnapshot {
pub text_scale: f32,
pub layout_scale: f32,
pub locale: Option<&'static str>,
pub color_scheme: ThemeMode,
pub motion_preference: MotionPreference,
pub high_contrast: bool,
pub text_direction: TextDirection,
pub mirroring: bool,
pub reduce_transparency: bool,
pub system_accent: Option<Color>,
}
impl Default for EnvironmentSnapshot {
fn default() -> Self {
Self::from_provider(&DefaultEnvironment)
}
}
impl EnvironmentSnapshot {
pub fn from_provider(provider: &dyn EnvironmentProvider) -> Self {
Self {
text_scale: provider.text_scale(),
layout_scale: provider.layout_scale(),
locale: provider.locale(),
color_scheme: provider.color_scheme(),
motion_preference: provider.motion_preference(),
high_contrast: provider.high_contrast(),
text_direction: provider.text_direction(),
mirroring: provider.mirroring(),
reduce_transparency: provider.reduce_transparency(),
system_accent: provider.system_accent(),
}
}
pub fn effective_text_scale(&self) -> f32 {
clamp_scale(self.text_scale)
}
pub fn effective_layout_scale(&self) -> f32 {
clamp_scale(self.layout_scale)
}
pub fn prefers_reduced_motion(&self) -> bool {
self.motion_preference == MotionPreference::ReduceMotion
}
}
fn clamp_scale(scale: f32) -> f32 {
if scale.is_nan() {
return 1.0;
}
scale.clamp(0.25, 8.0)
}
pub fn effective_duration(tempo: crate::style::MotionSlot, env: &EnvironmentSnapshot) -> u32 {
if env.prefers_reduced_motion() {
return 0;
}
tempo.duration_ms()
}
pub(crate) fn reduce_transparency_from(observed: Option<bool>) -> bool {
observed.unwrap_or(false)
}
thread_local! {
#[allow(clippy::missing_const_for_thread_local)]
static INSTALLED: core::cell::RefCell<Option<alloc::boxed::Box<dyn EnvironmentProvider>>> =
const { core::cell::RefCell::new(None) };
#[allow(clippy::missing_const_for_thread_local)]
static SNAPSHOT: core::cell::Cell<Option<EnvironmentSnapshot>> =
const { core::cell::Cell::new(None) };
}
pub fn install_environment(
env: alloc::boxed::Box<dyn EnvironmentProvider>,
) -> Option<alloc::boxed::Box<dyn EnvironmentProvider>> {
let previous = INSTALLED.try_with(|slot| slot.borrow_mut().replace(env)).unwrap_or(None);
refresh_environment();
previous
}
pub fn uninstall_environment() -> bool {
let had_one = INSTALLED.try_with(|slot| slot.borrow_mut().take().is_some()).unwrap_or(false);
refresh_environment();
had_one
}
pub fn refresh_environment() {
let snapshot = INSTALLED
.try_with(|slot| match &*slot.borrow() {
Some(provider) => EnvironmentSnapshot::from_provider(provider.as_ref()),
None => platform_snapshot(crate::compat::Instant::now()),
})
.unwrap_or_default();
let changed = SNAPSHOT.try_with(|slot| slot.get() != Some(snapshot)).unwrap_or(true);
let _ = SNAPSHOT.try_with(|slot| slot.set(Some(snapshot)));
if changed {
#[cfg(device_profile)]
crate::theme::invalidate_resolved_style_cache();
}
}
pub const ENVIRONMENT_POLL_INTERVAL_MS: u64 = 250;
struct PlatformPoll {
snapshot: EnvironmentSnapshot,
generation: u64,
polled_at: crate::compat::Instant,
}
thread_local! {
static PLATFORM_POLL: crate::compat::RefCell<Option<PlatformPoll>> =
const { crate::compat::RefCell::new(None) };
}
fn poll_is_due(polled_at: crate::compat::Instant, now: crate::compat::Instant) -> bool {
now.duration_since(polled_at)
>= crate::compat::Duration::from_millis(ENVIRONMENT_POLL_INTERVAL_MS)
}
fn generation_proves_unchanged(cached: u64, current: u64) -> bool {
cached != 0 && cached == current
}
fn platform_environment_generation() -> u64 {
let base = crate::platform::profile::environment_generation();
if base == 0 {
return 0;
}
let mut hash = base;
for byte in crate::platform::profile::text_scale().to_bits().to_le_bytes() {
hash ^= u64::from(byte);
hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
}
hash
}
fn platform_snapshot(now: crate::compat::Instant) -> EnvironmentSnapshot {
let cached = PLATFORM_POLL
.try_with(|slot| {
slot.borrow().as_ref().map(|poll| (poll.snapshot, poll.generation, poll.polled_at))
})
.ok()
.flatten();
if let Some((snapshot, generation, polled_at)) = cached {
if !poll_is_due(polled_at, now) {
return snapshot;
}
let current = platform_environment_generation();
if generation_proves_unchanged(generation, current) {
let _ = PLATFORM_POLL.try_with(|slot| {
if let Some(poll) = slot.borrow_mut().as_mut() {
poll.polled_at = now;
}
});
return snapshot;
}
let rebuilt = EnvironmentSnapshot::from_provider(&PlatformEnvironment);
let _ = PLATFORM_POLL.try_with(|slot| {
*slot.borrow_mut() =
Some(PlatformPoll { snapshot: rebuilt, generation: current, polled_at: now });
});
return rebuilt;
}
let generation = platform_environment_generation();
let snapshot = EnvironmentSnapshot::from_provider(&PlatformEnvironment);
let _ = PLATFORM_POLL.try_with(|slot| {
*slot.borrow_mut() = Some(PlatformPoll { snapshot, generation, polled_at: now });
});
snapshot
}
pub fn environment() -> EnvironmentSnapshot {
SNAPSHOT
.try_with(|slot| slot.get())
.ok()
.flatten()
.unwrap_or_else(|| EnvironmentSnapshot::from_provider(&PlatformEnvironment))
}
#[cfg(test)]
mod tests {
use super::*;
struct Fixed {
text_scale: f32,
motion: MotionPreference,
locale: Option<&'static str>,
direction: TextDirection,
mirroring: bool,
contrast: bool,
scheme: ThemeMode,
layout_scale: f32,
transparency: bool,
accent: Option<Color>,
}
impl Default for Fixed {
fn default() -> Self {
Self {
text_scale: 1.0,
motion: MotionPreference::NoPreference,
locale: None,
direction: TextDirection::LeftToRight,
mirroring: false,
contrast: false,
scheme: ThemeMode::Light,
layout_scale: 1.0,
transparency: false,
accent: None,
}
}
}
impl EnvironmentProvider for Fixed {
fn text_scale(&self) -> f32 {
self.text_scale
}
fn layout_scale(&self) -> f32 {
self.layout_scale
}
fn locale(&self) -> Option<&'static str> {
self.locale
}
fn color_scheme(&self) -> ThemeMode {
self.scheme
}
fn motion_preference(&self) -> MotionPreference {
self.motion
}
fn high_contrast(&self) -> bool {
self.contrast
}
fn text_direction(&self) -> TextDirection {
self.direction
}
fn mirroring(&self) -> bool {
self.mirroring
}
fn reduce_transparency(&self) -> bool {
self.transparency
}
fn system_accent(&self) -> Option<Color> {
self.accent
}
}
#[test]
fn the_environment_poll_is_bounded_and_zero_is_not_evidence() {
let now = crate::compat::Instant::now();
assert!(!poll_is_due(now, now), "a poll is not due in the same instant");
assert!(!poll_is_due(
now - crate::compat::Duration::from_millis(ENVIRONMENT_POLL_INTERVAL_MS - 1),
now
));
assert!(poll_is_due(
now - crate::compat::Duration::from_millis(ENVIRONMENT_POLL_INTERVAL_MS),
now
));
assert!(poll_is_due(
now - crate::compat::Duration::from_millis(ENVIRONMENT_POLL_INTERVAL_MS + 1),
now
));
assert!(!generation_proves_unchanged(0, 0), "zero is not a change source");
assert!(!generation_proves_unchanged(0, 7));
assert!(generation_proves_unchanged(7, 7));
assert!(!generation_proves_unchanged(7, 8));
}
#[test]
fn the_default_provider_is_neutral_in_every_dimension() {
let env = EnvironmentSnapshot::from_provider(&DefaultEnvironment);
assert_eq!(env.text_scale, 1.0);
assert_eq!(env.layout_scale, 1.0);
assert_eq!(env.locale, None, "no locale is not the same fact as the locale `en`");
assert_eq!(env.color_scheme, ThemeMode::Light);
assert_eq!(env.motion_preference, MotionPreference::NoPreference);
assert!(!env.high_contrast);
assert_eq!(env.text_direction, TextDirection::LeftToRight);
assert!(!env.mirroring);
assert!(!env.reduce_transparency, "an uninstalled build keeps materials opaque");
assert_eq!(env.system_accent, None, "no accent is not the same fact as the blue accent");
assert_eq!(env, EnvironmentSnapshot::default(), "the two spellings agree");
}
#[test]
fn a_snapshot_carries_every_fact_out_of_the_provider() {
let provider = Fixed {
text_scale: 1.5,
layout_scale: 0.8,
motion: MotionPreference::ReduceMotion,
locale: Some("he-IL"),
direction: TextDirection::RightToLeft,
mirroring: true,
contrast: true,
scheme: ThemeMode::Dark,
transparency: true,
accent: Some(Color::rgb(0xAF, 0x52, 0xDE)),
};
let env = EnvironmentSnapshot::from_provider(&provider);
assert_eq!(env.text_scale, 1.5);
assert_eq!(env.layout_scale, 0.8);
assert_eq!(env.locale, Some("he-IL"));
assert_eq!(env.color_scheme, ThemeMode::Dark);
assert_eq!(env.motion_preference, MotionPreference::ReduceMotion);
assert!(env.high_contrast);
assert_eq!(env.text_direction, TextDirection::RightToLeft);
assert!(env.mirroring);
assert!(env.reduce_transparency);
assert_eq!(env.system_accent, Some(Color::rgb(0xAF, 0x52, 0xDE)));
}
#[test]
fn an_unobserved_transparency_setting_is_not_a_user_choice() {
assert!(!reduce_transparency_from(None));
assert!(!reduce_transparency_from(Some(false)));
assert!(reduce_transparency_from(Some(true)), "an observed request is honoured");
let neutral = EnvironmentSnapshot::from_provider(&DefaultEnvironment);
assert!(!neutral.reduce_transparency);
assert_eq!(neutral.system_accent, None);
assert_eq!(
PlatformEnvironment.reduce_transparency(),
crate::platform::platform_facts().reduce_transparency().unwrap_or(false)
);
}
#[test]
fn a_hostile_scale_is_clamped_to_a_usable_range() {
for (reported, expected) in
[(0.0_f32, 0.25_f32), (-3.0, 0.25), (f32::NAN, 1.0), (100.0, 8.0)]
{
let provider = Fixed { text_scale: reported, ..Default::default() };
let env = EnvironmentSnapshot::from_provider(&provider);
assert_eq!(
env.effective_text_scale(),
expected,
"a reported scale of {reported} must degrade to {expected}"
);
assert!(env.effective_text_scale().is_finite());
}
}
#[test]
fn reduced_motion_collapses_a_duration_to_zero() {
use crate::style::MotionSlot;
let full = EnvironmentSnapshot::default();
let reduced = EnvironmentSnapshot {
motion_preference: MotionPreference::ReduceMotion,
..EnvironmentSnapshot::default()
};
let normal_ms = effective_duration(MotionSlot::Normal, &full);
assert!(normal_ms > 0, "with no preference a transition has a real duration");
assert_eq!(
effective_duration(MotionSlot::Normal, &reduced),
0,
"and none under the preference"
);
for slot in [MotionSlot::Fast, MotionSlot::Normal, MotionSlot::Slow] {
assert_eq!(effective_duration(slot, &reduced), 0, "{slot:?} must collapse too");
}
}
#[test]
fn the_tempo_still_prices_the_motion_without_a_preference() {
use crate::style::MotionSlot;
let env = EnvironmentSnapshot::default();
assert_eq!(effective_duration(MotionSlot::Fast, &env), MotionSlot::Fast.duration_ms());
assert_eq!(effective_duration(MotionSlot::Normal, &env), MotionSlot::Normal.duration_ms());
assert_eq!(effective_duration(MotionSlot::Slow, &env), MotionSlot::Slow.duration_ms());
}
#[test]
fn text_direction_follows_the_language_subtag() {
for rtl in ["ar", "ar-EG", "he", "he-IL", "fa", "ur-PK", "ckb", "iw"] {
assert_eq!(
text_direction_for_locale(Some(rtl)),
TextDirection::RightToLeft,
"{rtl} is written right-to-left"
);
}
for ltr in ["en", "en-US", "de-DE", "ja", "zh-Hant", "az-Arab"] {
assert_eq!(text_direction_for_locale(Some(ltr)), TextDirection::LeftToRight, "{ltr}");
}
assert_eq!(text_direction_for_locale(None), TextDirection::LeftToRight);
}
#[test]
fn the_default_provider_forwards_the_platform_facts() {
let provider = PlatformEnvironment;
let platform = crate::platform::platform_facts();
assert_eq!(provider.locale(), platform.system_locale());
assert_eq!(provider.high_contrast(), platform.high_contrast().unwrap_or(false));
assert_eq!(provider.reduce_transparency(), platform.reduce_transparency().unwrap_or(false));
assert_eq!(provider.system_accent(), platform.accent_color());
assert_eq!(
provider.motion_preference() == MotionPreference::ReduceMotion,
platform.reduce_motion() == Some(true)
);
assert_eq!(provider.text_direction(), text_direction_for_locale(platform.system_locale()));
}
}