use crate::core::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 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()
}
}
#[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,
}
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(),
}
}
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()
}
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 => EnvironmentSnapshot::from_provider(&PlatformEnvironment),
})
.unwrap_or_default();
let _ = SNAPSHOT.try_with(|slot| slot.set(Some(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,
}
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,
}
}
}
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
}
}
#[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_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,
};
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);
}
#[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());
}
}