#![forbid(unsafe_code)]
use std::fmt::Write as _;
use std::time::Duration;
use makeover_geometry::in_css_layer;
#[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 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");
for moment in Moment::all() {
let _ = writeln!(out, " --{}: {};", 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");
for motion in Motion::all() {
let _ = writeln!(out, " --{}: {};", 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");
for cadence in Cadence::all() {
let _ = writeln!(out, " --{}: {};", 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::*;
#[test]
fn every_intent_resolves_to_one_number_in_three_spellings() {
for moment in Moment::all() {
assert_eq!(moment.duration().as_millis() as u32, moment.ms());
assert_eq!(moment.css(), format!("{}ms", moment.ms()));
}
for motion in Motion::all() {
assert_eq!(motion.duration().as_millis() as u32, motion.ms());
}
for cadence in Cadence::all() {
assert_eq!(cadence.duration().as_millis() as u32, cadence.ms());
assert_eq!(cadence.css(), format!("{}ms", cadence.ms()));
}
}
#[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("@layer makeover {\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 moment in Moment::all() {
let decl = format!("--{}: {}", moment.token(), moment.css());
assert_eq!(css.matches(&decl).count(), 1, "{}", moment.token());
}
for motion in Motion::all() {
let decl = format!("--{}: {}", motion.token(), motion.css());
assert_eq!(css.matches(&decl).count(), 1, "{}", motion.token());
}
for cadence in Cadence::all() {
let decl = format!("--{}: {}", cadence.token(), cadence.css());
assert_eq!(css.matches(&decl).count(), 1, "{}", cadence.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 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 mut tokens: Vec<&str> = Moment::all().iter().map(|i| i.token()).collect();
tokens.extend(Motion::all().iter().map(|m| m.token()));
tokens.extend(Cadence::all().iter().map(|c| c.token()));
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");
}
}