#![forbid(unsafe_code)]
use std::fmt::Write as _;
use std::time::Duration;
use makeover_geometry::{in_css_layer, write_declarations};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum Moment {
Revert,
Clear,
Dismiss,
Debounce,
}
impl Moment {
#[must_use]
pub const fn ms(self) -> u32 {
match self {
Self::Revert => 1500,
Self::Clear => 2000,
Self::Dismiss => 3000,
Self::Debounce => 150,
}
}
#[must_use]
pub const fn duration(self) -> Duration {
Duration::from_millis(self.ms() as u64)
}
#[must_use]
pub const fn token(self) -> &'static str {
match self {
Self::Revert => "timing-revert",
Self::Clear => "timing-clear",
Self::Dismiss => "timing-dismiss",
Self::Debounce => "timing-debounce",
}
}
#[must_use]
pub fn css(self) -> String {
format!("{}ms", self.ms())
}
#[must_use]
pub const fn all() -> &'static [Self] {
&[Self::Revert, Self::Clear, Self::Dismiss, Self::Debounce]
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum Motion {
Fade,
Quick,
}
impl Motion {
#[must_use]
pub const fn ms(self) -> u32 {
match self {
Self::Fade => 300,
Self::Quick => 120,
}
}
#[must_use]
pub const fn duration(self) -> Duration {
Duration::from_millis(self.ms() as u64)
}
#[must_use]
pub const fn token(self) -> &'static str {
match self {
Self::Fade => "motion-fade",
Self::Quick => "motion-quick",
}
}
#[must_use]
pub fn css(self) -> String {
format!("{}ms", self.ms())
}
#[must_use]
pub const fn all() -> &'static [Self] {
&[Self::Fade, Self::Quick]
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum Cadence {
Activity,
}
impl Cadence {
#[must_use]
pub const fn ms(self) -> u32 {
match self {
Self::Activity => 500,
}
}
#[must_use]
pub const fn duration(self) -> Duration {
Duration::from_millis(self.ms() as u64)
}
#[must_use]
pub const fn cycle(self) -> Duration {
Duration::from_millis(self.ms() as u64 * 2)
}
#[must_use]
pub const fn token(self) -> &'static str {
match self {
Self::Activity => "cadence-activity",
}
}
#[must_use]
pub fn css(self) -> String {
format!("{}ms", self.ms())
}
#[must_use]
pub const fn all() -> &'static [Self] {
&[Self::Activity]
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub enum MotionPreference {
#[default]
Full,
Reduced,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Persistence {
Transient,
Standing,
}
#[must_use]
pub const fn activity_blink(preference: MotionPreference) -> Option<Duration> {
match preference {
MotionPreference::Full => Some(Cadence::Activity.duration()),
MotionPreference::Reduced => None,
}
}
#[must_use]
pub fn blink(elapsed: Duration, motion: MotionPreference) -> (bool, Option<Duration>) {
let Some(half) = activity_blink(motion).map(|half| half.as_nanos()) else {
return (true, None);
};
let Some(into) = elapsed.as_nanos().checked_rem(half * 2) else {
return (true, None);
};
let lit = into < half;
let until = if lit { half } else { half * 2 } - into;
(
lit,
Some(Duration::from_nanos(
u64::try_from(until).unwrap_or(u64::MAX),
)),
)
}
#[must_use]
pub const fn notice_lifetime(persistence: Persistence) -> Option<Duration> {
match persistence {
Persistence::Transient => Some(Moment::Dismiss.duration()),
Persistence::Standing => None,
}
}
#[must_use]
pub fn timing_css_declarations() -> String {
let mut out = String::new();
out.push_str(" /* Time. Named for what is being waited on; the number\n");
out.push_str(" follows. One duration per moment on every renderer. */\n");
write_declarations(&mut out, Moment::all(), Moment::token, Moment::css);
out.push_str("\n /* Motion: how long a change takes, not how long a state\n");
out.push_str(" lasts. A separate question, so a separate axis. */\n");
write_declarations(&mut out, Motion::all(), Motion::token, Motion::css);
out.push_str("\n /* Cadence: how often a repeating mark repeats. A\n");
out.push_str(" half-period, so a full cycle is twice it. */\n");
write_declarations(&mut out, Cadence::all(), Cadence::token, Cadence::css);
out
}
#[must_use]
pub fn reduced_motion_css() -> String {
let mut out = String::new();
out.push_str("@media (prefers-reduced-motion: reduce) {\n");
out.push_str(" :root {\n");
out.push_str(" /* Motion off. The state a stroke was heading for,\n");
out.push_str(" reached at once; nothing is removed. */\n");
for motion in Motion::all() {
let _ = writeln!(out, " --{}: 0ms;", motion.token());
}
for cadence in Cadence::all() {
let _ = writeln!(out, " --{}: 0ms;", cadence.token());
}
out.push_str(" }\n}\n");
out
}
#[must_use]
pub fn timing_css_vars() -> String {
format!(
":root {{\n{}}}\n\n{}",
timing_css_declarations(),
reduced_motion_css()
)
}
#[must_use]
pub fn timing_css() -> String {
in_css_layer(&timing_css_vars())
}
#[cfg(test)]
mod tests {
use super::*;
use makeover_geometry::CSS_LAYER;
fn rungs() -> Vec<(&'static str, u32, String, Duration)> {
let mut out: Vec<_> = Moment::all()
.iter()
.map(|r| (r.token(), r.ms(), r.css(), r.duration()))
.collect();
out.extend(
Motion::all()
.iter()
.map(|r| (r.token(), r.ms(), r.css(), r.duration())),
);
out.extend(
Cadence::all()
.iter()
.map(|r| (r.token(), r.ms(), r.css(), r.duration())),
);
out
}
#[test]
fn every_intent_resolves_to_one_number_in_three_spellings() {
for (token, ms, css, duration) in rungs() {
assert_eq!(duration.as_millis() as u32, ms, "{token}");
assert_eq!(css, format!("{ms}ms"), "{token}");
}
}
#[test]
fn the_measured_values_are_the_ones_the_tree_had() {
assert_eq!(Moment::Revert.ms(), 1500);
assert_eq!(Moment::Clear.ms(), 2000);
assert_eq!(Moment::Dismiss.ms(), 3000);
assert_eq!(Moment::Debounce.ms(), 150);
assert_eq!(Motion::Fade.ms(), 300);
assert_eq!(Cadence::Activity.ms(), 500);
}
#[test]
fn a_notice_leaves_after_its_lifetime_and_its_fade() {
assert_eq!(
notice_lifetime(Persistence::Transient),
Some(Duration::from_secs(3))
);
assert!(Motion::Fade.duration() < Moment::Dismiss.duration());
}
#[test]
fn a_notice_that_is_not_transient_has_no_lifetime() {
assert_eq!(notice_lifetime(Persistence::Standing), None);
}
#[test]
fn debounce_is_the_shortest_wait_and_a_notice_the_longest() {
assert!(
Moment::all()
.iter()
.all(|i| i.ms() >= Moment::Debounce.ms())
);
assert!(Moment::all().iter().all(|i| i.ms() <= Moment::Dismiss.ms()));
}
#[test]
fn no_intent_is_long_enough_to_be_a_poll_interval() {
assert!(Moment::all().iter().all(|i| i.ms() <= 5_000));
}
#[test]
fn the_layer_is_emitted_inside_the_family_layer() {
let css = timing_css();
assert!(css.starts_with(&format!("@layer {CSS_LAYER} {{\n")));
assert!(css.contains(" :root {"));
assert!(css.trim_end().ends_with('}'));
}
#[test]
fn every_token_reaches_the_stylesheet_exactly_once() {
let css = timing_css();
for (token, _, value, _) in rungs() {
let decl = format!("--{token}: {value}");
assert_eq!(css.matches(&decl).count(), 1, "{token}");
}
}
#[test]
fn tokens_are_prefixed_by_their_axis() {
assert!(
Moment::all()
.iter()
.all(|i| i.token().starts_with("timing-"))
);
assert!(
Motion::all()
.iter()
.all(|m| m.token().starts_with("motion-"))
);
assert!(
Cadence::all()
.iter()
.all(|c| c.token().starts_with("cadence-"))
);
}
#[test]
fn the_blink_is_a_half_period_and_the_cycle_is_twice_it() {
assert_eq!(Cadence::Activity.cycle(), Cadence::Activity.duration() * 2);
assert_eq!(Cadence::Activity.cycle(), Duration::from_secs(1));
}
#[test]
fn the_blink_is_slower_than_a_stroke_and_faster_than_a_notice() {
assert!(Cadence::Activity.duration() > Motion::Fade.duration());
assert!(Cadence::Activity.duration() < Moment::Revert.duration());
}
#[test]
fn reduced_motion_stills_the_mark_rather_than_removing_it() {
assert_eq!(
activity_blink(MotionPreference::Full),
Some(Cadence::Activity.duration())
);
assert_eq!(activity_blink(MotionPreference::Reduced), None);
}
#[test]
fn the_mark_is_lit_in_the_first_half_of_each_cycle_and_still_when_reduced() {
let half = Cadence::Activity.duration();
let full = MotionPreference::Full;
assert_eq!(blink(Duration::ZERO, full), (true, Some(half)));
assert!(blink(half.saturating_sub(Duration::from_nanos(1)), full).0);
assert_eq!(blink(half, full), (false, Some(half)));
assert_eq!(blink(Cadence::Activity.cycle(), full), (true, Some(half)));
assert!(
blink(Duration::MAX, full)
.1
.is_some_and(|until| until <= half)
);
for ms in [0, 250, 499, 500, 999, 12_345] {
assert_eq!(
blink(Duration::from_millis(ms), MotionPreference::Reduced),
(true, None)
);
}
}
#[test]
fn a_quick_answer_is_shorter_than_a_fade_and_stills_under_reduced_motion() {
assert!(Motion::Quick.duration() < Motion::Fade.duration());
assert!(reduced_motion_css().contains("--motion-quick: 0ms;"));
}
#[test]
fn reduced_motion_leaves_the_waits_alone() {
let css = reduced_motion_css();
for moment in Moment::all() {
assert!(!css.contains(moment.token()), "{}", moment.token());
}
for motion in Motion::all() {
assert!(css.contains(&format!("--{}: 0ms;", motion.token())));
}
for cadence in Cadence::all() {
assert!(css.contains(&format!("--{}: 0ms;", cadence.token())));
}
}
#[test]
fn the_motion_off_block_ships_with_the_values_it_overrides() {
let css = timing_css();
assert!(css.contains("@media (prefers-reduced-motion: reduce)"));
let vars = css
.find(&format!(
"--{}: {}",
Cadence::Activity.token(),
Cadence::Activity.css()
))
.expect("the value");
let off = css.find("prefers-reduced-motion").expect("the block");
assert!(
vars < off,
"the override has to come after what it overrides"
);
}
#[test]
fn no_two_rungs_share_a_name_or_a_value() {
let tokens: Vec<&str> = rungs().into_iter().map(|r| r.0).collect();
let mut sorted = tokens.clone();
sorted.sort_unstable();
sorted.dedup();
assert_eq!(sorted.len(), tokens.len(), "{tokens:?}");
let mut values: Vec<u32> = Moment::all().iter().map(|i| i.ms()).collect();
values.sort_unstable();
values.dedup();
assert_eq!(values.len(), Moment::all().len(), "two intents, one number");
}
}