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}