use frus_widgets::{Brightness, Theme};
use crate::application::Application;
#[derive(Default)]
pub(crate) struct ThemeFade {
current: Option<Theme>,
target: Option<Theme>,
from: Option<Theme>,
progress: f32,
}
impl ThemeFade {
pub(crate) fn animating(&self) -> bool {
self.from.is_some()
}
pub(crate) fn displayed<A: Application>(&self, app: &A) -> Theme {
match &self.current {
Some(theme) => theme.clone(),
None => app.theme(),
}
}
pub(crate) fn advance<A: Application>(
&mut self,
app: &A,
brightness: Brightness,
high_contrast: bool,
still: bool,
dt: f32,
) -> bool {
let target = app.resolved_theme(brightness, high_contrast);
let Some(current) = self.current.clone() else {
self.current = Some(target.clone());
self.target = Some(target);
return true;
};
if self.target.as_ref() != Some(&target) {
self.from = Some(current.clone());
self.target = Some(target);
self.progress = 0.0;
}
if self.from.is_none() {
return false;
}
let duration = if still {
0.0
} else {
app.theme_animation_duration().max(0.0)
};
self.progress = if duration <= 0.0 {
1.0
} else {
(self.progress + dt / duration).min(1.0)
};
let destination = self.target.clone().unwrap_or_else(|| app.theme());
let next = if self.progress >= 1.0 {
self.from = None;
destination
} else {
let t = app.theme_animation_curve().transform(self.progress);
self.from
.as_ref()
.map(|from| from.lerp(&destination, t))
.unwrap_or(destination)
};
let moved = next != current;
self.current = Some(next);
moved
}
}
#[cfg(test)]
mod tests {
use super::ThemeFade;
use crate::application::Application;
use frus_widgets::{Brightness, Theme, ThemeMode, Widget};
struct Two {
mode: ThemeMode,
dark: bool,
high_contrast: Option<f32>,
high_contrast_dark: Option<f32>,
duration: f32,
}
impl Default for Two {
fn default() -> Self {
Two {
mode: ThemeMode::System,
dark: true,
high_contrast: None,
high_contrast_dark: None,
duration: 0.2,
}
}
}
fn marked(radius: f32) -> Theme {
let mut theme = Theme::light();
theme.radius = radius;
theme
}
impl Application for Two {
type Message = ();
fn update(&mut self, _message: ()) -> crate::Command<()> {
crate::Command::none()
}
fn view(&self, _theme: &Theme) -> Box<dyn Widget<()>> {
Box::new(frus_widgets::Flex::<()>::column())
}
fn theme(&self) -> Theme {
marked(0.0)
}
fn dark_theme(&self) -> Option<Theme> {
self.dark.then(|| marked(100.0))
}
fn high_contrast_theme(&self) -> Option<Theme> {
self.high_contrast.map(marked)
}
fn high_contrast_dark_theme(&self) -> Option<Theme> {
self.high_contrast_dark.map(marked)
}
fn theme_mode(&self) -> ThemeMode {
self.mode
}
fn theme_animation_duration(&self) -> f32 {
self.duration
}
}
#[test]
fn a_platform_s_dark_mode_reaches_an_application_that_never_asked() {
let app = Two::default();
assert_eq!(
app.resolved_theme(Brightness::Dark, false).radius,
100.0,
"a dark platform, and an application that only supplied a dark theme"
);
assert_eq!(app.resolved_theme(Brightness::Light, false).radius, 0.0);
let pinned = Two {
mode: ThemeMode::Light,
..Two::default()
};
assert_eq!(pinned.resolved_theme(Brightness::Dark, false).radius, 0.0);
let pinned = Two {
mode: ThemeMode::Dark,
..Two::default()
};
assert_eq!(
pinned.resolved_theme(Brightness::Light, false).radius,
100.0
);
let unlit = Two {
mode: ThemeMode::Dark,
dark: false,
..Two::default()
};
assert_eq!(unlit.resolved_theme(Brightness::Dark, false).radius, 0.0);
}
#[test]
fn the_rungs_of_a_high_contrast_interface() {
let all = Two {
high_contrast: Some(1.0),
high_contrast_dark: Some(2.0),
..Two::default()
};
assert_eq!(all.resolved_theme(Brightness::Dark, true).radius, 2.0);
assert_eq!(all.resolved_theme(Brightness::Light, true).radius, 1.0);
assert_eq!(all.resolved_theme(Brightness::Dark, false).radius, 100.0);
assert_eq!(all.resolved_theme(Brightness::Light, false).radius, 0.0);
let no_hc_dark = Two {
high_contrast: Some(1.0),
..Two::default()
};
assert_eq!(
no_hc_dark.resolved_theme(Brightness::Dark, true).radius,
100.0,
"the dark theme, not the high-contrast light one"
);
let nothing_dark = Two {
dark: false,
high_contrast: Some(1.0),
..Two::default()
};
assert_eq!(
nothing_dark.resolved_theme(Brightness::Dark, true).radius,
1.0,
"and with no dark theme at all, the high-contrast one"
);
}
#[test]
fn the_framework_crosses_from_one_theme_to_the_next() {
let mut app = Two {
mode: ThemeMode::Light,
..Two::default()
};
let mut fade = ThemeFade::default();
assert!(fade.advance(&app, Brightness::Light, false, false, 0.016));
assert_eq!(fade.displayed(&app).radius, 0.0);
assert!(!fade.animating(), "nothing to cross on the first frame");
assert!(!fade.advance(&app, Brightness::Light, false, false, 0.016));
app.mode = ThemeMode::Dark;
assert!(fade.advance(&app, Brightness::Light, false, false, 0.1));
assert!(fade.animating(), "and it asks for another frame");
let midway = fade.displayed(&app).radius;
assert!(
midway > 0.0 && midway < 100.0,
"halfway across a 0.2s crossing: {midway}"
);
assert!(fade.advance(&app, Brightness::Light, false, false, 0.1));
assert_eq!(fade.displayed(&app).radius, 100.0);
assert!(!fade.animating());
assert!(
!fade.advance(&app, Brightness::Light, false, false, 0.1),
"an arrived theme stops asking for frames"
);
}
#[test]
fn a_new_palette_crosses_the_same_way_as_a_light_dark_switch() {
struct Seeded(std::cell::Cell<f32>);
impl Application for Seeded {
type Message = ();
fn update(&mut self, _message: ()) -> crate::Command<()> {
crate::Command::none()
}
fn view(&self, _theme: &Theme) -> Box<dyn Widget<()>> {
Box::new(frus_widgets::Flex::<()>::column())
}
fn theme(&self) -> Theme {
marked(self.0.get())
}
}
let app = Seeded(std::cell::Cell::new(0.0));
let mut fade = ThemeFade::default();
fade.advance(&app, Brightness::Light, false, false, 0.016);
assert!(!fade.animating());
app.0.set(100.0);
assert!(fade.advance(&app, Brightness::Light, false, false, 0.1));
assert!(fade.animating(), "the same crossing, on the same one theme");
let midway = fade.displayed(&app).radius;
assert!(midway > 0.0 && midway < 100.0, "{midway}");
}
#[test]
fn stillness_and_a_zero_duration_switch_at_once() {
let mut app = Two {
mode: ThemeMode::Light,
..Two::default()
};
let mut fade = ThemeFade::default();
fade.advance(&app, Brightness::Light, false, true, 0.016);
app.mode = ThemeMode::Dark;
assert!(fade.advance(&app, Brightness::Light, false, true, 0.016));
assert_eq!(
fade.displayed(&app).radius,
100.0,
"the whole way in one frame"
);
assert!(!fade.animating(), "and nothing left to animate");
let mut app = Two {
mode: ThemeMode::Light,
duration: 0.0,
..Two::default()
};
let mut fade = ThemeFade::default();
fade.advance(&app, Brightness::Light, false, false, 0.016);
app.mode = ThemeMode::Dark;
fade.advance(&app, Brightness::Light, false, false, 0.016);
assert_eq!(fade.displayed(&app).radius, 100.0);
assert!(!fade.animating());
}
#[test]
fn a_theme_can_be_asked_for_before_the_first_frame() {
let fade = ThemeFade::default();
assert_eq!(fade.displayed(&Two::default()).radius, marked(0.0).radius);
}
}