Skip to main content

dwind_base/
keyframes.rs

1//! Runtime registry for `@keyframes` rules.
2//!
3//! dwind's utility classes are generated as single declaration blocks, and the
4//! CSS binding pipeline has no notion of at-rules — so a `@keyframes` cannot be
5//! expressed as a utility. Historically every crate worked around that by
6//! hand-writing a `&str` blob and pushing it through
7//! [`dominator::stylesheet_raw`] at startup, which meant no deduplication, no
8//! collision detection, and every keyframe paying its cost whether or not the
9//! page used it.
10//!
11//! This module is the shared alternative. A [`Keyframes`] is a `const`-
12//! constructible handle that injects its rule the first time anything asks for
13//! its name, and never again.
14//!
15//! Prefer declaring these with the `dwkeyframes!` macro rather than by hand —
16//! it mints the matching `animate-*` utility class at the same time, so the
17//! class name stays checked by rustc.
18
19use std::collections::BTreeMap;
20use std::sync::atomic::{AtomicBool, Ordering};
21use std::sync::Mutex;
22
23/// Every keyframe name injected so far, mapped to the body it was injected
24/// with. Both constructors are `const`, so this needs no lazy-init machinery.
25static REGISTRY: Mutex<BTreeMap<&'static str, &'static str>> = Mutex::new(BTreeMap::new());
26
27/// A declared `@keyframes` rule.
28///
29/// The rule is injected lazily — constructing a `Keyframes` costs nothing, and
30/// the stylesheet is only touched once something calls [`Keyframes::name`],
31/// [`Keyframes::ensure`], or formats the handle with `{}`.
32///
33/// ```ignore
34/// static FADE: Keyframes = Keyframes::new("app-fade", "from{opacity:0;}to{opacity:1;}");
35///
36/// // `Display` registers, so composed shorthands work without ceremony:
37/// el.style("animation", &format!("{FADE} 600ms ease-out both"));
38/// ```
39pub struct Keyframes {
40    name: &'static str,
41    body: &'static str,
42    injected: AtomicBool,
43}
44
45impl Keyframes {
46    pub const fn new(name: &'static str, body: &'static str) -> Self {
47        Self {
48            name,
49            body,
50            injected: AtomicBool::new(false),
51        }
52    }
53
54    /// Injects the rule if it has not been injected yet, then returns the CSS
55    /// keyframe name for use in an `animation` shorthand.
56    pub fn name(&self) -> &'static str {
57        self.ensure();
58        self.name
59    }
60
61    /// The keyframe name *without* injecting the rule.
62    ///
63    /// Only useful in `const` contexts. If you are building an `animation`
64    /// value at runtime, use [`Keyframes::name`] or `{}` formatting instead so
65    /// the rule actually reaches the document.
66    pub const fn name_unregistered(&self) -> &'static str {
67        self.name
68    }
69
70    pub const fn body(&self) -> &'static str {
71        self.body
72    }
73
74    /// Injects the rule. Idempotent, and after the first call this is a single
75    /// relaxed atomic load.
76    pub fn ensure(&self) {
77        if self.injected.load(Ordering::Relaxed) {
78            return;
79        }
80
81        register(self.name, self.body);
82        self.injected.store(true, Ordering::Relaxed);
83    }
84
85    /// The full rule text, for server-side rendering or debugging. Does not
86    /// inject anything.
87    pub fn css(&self) -> String {
88        format!("@keyframes {} {{ {} }}", self.name, self.body)
89    }
90}
91
92impl std::fmt::Display for Keyframes {
93    /// Writes the keyframe name — and registers the rule as a side effect, so
94    /// that `format!("{KEYFRAMES} 1s linear")` cannot produce a shorthand
95    /// pointing at a rule that was never injected.
96    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
97        f.write_str(self.name())
98    }
99}
100
101/// The declaration body of a generated `animate-*` utility.
102///
103/// This exists because registration has to hang off the *declaration text*
104/// rather than off the utility class. `dwclass!` compiles a bare
105/// `animate-fade-up` into a reference to the class, but a modified
106/// `hover:animate-fade-up` or `[&::before]:animate-fade-up` builds a fresh class
107/// out of the declaration text alone and never touches the original. Attaching
108/// the side effect here is what makes every one of those paths inject the rule.
109///
110/// Reading the value — which `.raw(&*DECL)` does — registers.
111pub struct AnimationDecl {
112    keyframes: &'static Keyframes,
113    css: &'static str,
114}
115
116impl AnimationDecl {
117    pub const fn new(keyframes: &'static Keyframes, css: &'static str) -> Self {
118        Self { keyframes, css }
119    }
120}
121
122impl std::ops::Deref for AnimationDecl {
123    type Target = str;
124
125    fn deref(&self) -> &str {
126        self.keyframes.ensure();
127        self.css
128    }
129}
130
131impl std::fmt::Display for AnimationDecl {
132    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
133        f.write_str(self)
134    }
135}
136
137/// Injects `@keyframes {name} { {body} }` unless `name` is already registered.
138///
139/// Registering the same name twice with different bodies is a bug — two crates
140/// have picked the same keyframe name and one is silently winning. In debug
141/// builds that panics; in release the first registration stands.
142pub fn register(name: &'static str, body: &'static str) {
143    let mut registry = match REGISTRY.lock() {
144        Ok(registry) => registry,
145        // A poisoned lock means an earlier registration panicked. Injecting
146        // styles is not worth propagating that.
147        Err(poisoned) => poisoned.into_inner(),
148    };
149
150    if let Some(existing) = registry.get(name) {
151        debug_assert!(
152            *existing == body,
153            "@keyframes `{name}` was registered twice with different bodies. \
154             Give one of them a distinct name — dwkeyframes! namespaces by crate \
155             unless you override it with #[name = \"...\"]."
156        );
157
158        return;
159    }
160
161    registry.insert(name, body);
162    dominator::stylesheet_raw(format!("@keyframes {name} {{ {body} }}"));
163}
164
165/// Whether `name` has already been injected.
166pub fn is_registered(name: &str) -> bool {
167    match REGISTRY.lock() {
168        Ok(registry) => registry.contains_key(name),
169        Err(poisoned) => poisoned.into_inner().contains_key(name),
170    }
171}
172
173/// Every rule injected so far, concatenated. Intended for prerendering.
174pub fn registered_css() -> String {
175    let registry = match REGISTRY.lock() {
176        Ok(registry) => registry,
177        Err(poisoned) => poisoned.into_inner(),
178    };
179
180    registry
181        .iter()
182        .map(|(name, body)| format!("@keyframes {name} {{ {body} }}"))
183        .collect::<Vec<_>>()
184        .join("\n")
185}
186
187#[cfg(test)]
188mod test {
189    use super::*;
190
191    // These exercise the bookkeeping only. `register` reaches into the DOM, so
192    // the injection itself is covered by the browser tests in `dwui`.
193
194    #[test]
195    fn css_renders_a_complete_rule() {
196        let kf = Keyframes::new("test-fade", "from{opacity:0;}to{opacity:1;}");
197
198        assert_eq!(
199            kf.css(),
200            "@keyframes test-fade { from{opacity:0;}to{opacity:1;} }"
201        );
202    }
203
204    #[test]
205    fn name_unregistered_does_not_register() {
206        let kf = Keyframes::new("test-untouched", "from{opacity:0;}");
207
208        assert_eq!(kf.name_unregistered(), "test-untouched");
209        assert!(!is_registered("test-untouched"));
210    }
211}