#![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 Intent {
Revert,
Clear,
Dismiss,
Debounce,
}
impl Intent {
#[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() -> [Self; 4] {
[Self::Revert, Self::Clear, Self::Dismiss, Self::Debounce]
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum Motion {
Fade,
}
impl Motion {
#[must_use]
pub const fn ms(self) -> u32 {
match self {
Self::Fade => 300,
}
}
#[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",
}
}
#[must_use]
pub fn css(self) -> String {
format!("{}ms", self.ms())
}
#[must_use]
pub const fn all() -> [Self; 1] {
[Self::Fade]
}
}
#[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() -> [Self; 1] {
[Self::Activity]
}
}
#[must_use]
pub const fn activity_blink(reduced: bool) -> Option<Duration> {
if reduced {
None
} else {
Some(Cadence::Activity.duration())
}
}
#[must_use]
pub const fn notice_lifetime(transient: bool) -> Option<Duration> {
if transient {
Some(Intent::Dismiss.duration())
} else {
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 intent on every renderer —\n");
out.push_str(" a divergence here is a bug report, not an axis. */\n");
for intent in Intent::all() {
let _ = writeln!(out, " --{}: {};", intent.token(), intent.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 intent in Intent::all() {
assert_eq!(intent.duration().as_millis() as u32, intent.ms());
assert_eq!(intent.css(), format!("{}ms", intent.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!(Intent::Revert.ms(), 1500);
assert_eq!(Intent::Clear.ms(), 2000);
assert_eq!(Intent::Dismiss.ms(), 3000);
assert_eq!(Intent::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(true), Some(Duration::from_secs(3)));
assert!(Motion::Fade.duration() < Intent::Dismiss.duration());
}
#[test]
fn a_notice_that_is_not_transient_has_no_lifetime() {
assert_eq!(notice_lifetime(false), None);
}
#[test]
fn debounce_is_the_shortest_wait_and_a_notice_the_longest() {
assert!(
Intent::all()
.iter()
.all(|i| i.ms() >= Intent::Debounce.ms())
);
assert!(Intent::all().iter().all(|i| i.ms() <= Intent::Dismiss.ms()));
}
#[test]
fn no_intent_is_long_enough_to_be_a_poll_interval() {
assert!(Intent::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 intent in Intent::all() {
let decl = format!("--{}: {}", intent.token(), intent.css());
assert_eq!(css.matches(&decl).count(), 1, "{}", intent.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!(
Intent::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() < Intent::Revert.duration());
}
#[test]
fn reduced_motion_stills_the_mark_rather_than_removing_it() {
assert_eq!(activity_blink(false), Some(Cadence::Activity.duration()));
assert_eq!(activity_blink(true), None);
}
#[test]
fn reduced_motion_leaves_the_waits_alone() {
let css = reduced_motion_css();
for intent in Intent::all() {
assert!(!css.contains(intent.token()), "{}", intent.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> = Intent::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> = Intent::all().iter().map(|i| i.ms()).collect();
values.sort_unstable();
values.dedup();
assert_eq!(values.len(), Intent::all().len(), "two intents, one number");
}
}