render: apply settled animation end states

This commit is contained in:
SGavrl committed 2026-07-26 14:38:24 +00:00
1 parent ec5950da06
commit b03c663952
5 files changed
+253

No files matched your search

+122
View File
@@ -24,6 +24,10 @@ struct Rule {
/// An indexed set of author rules ready for fast per-element matching.
pub struct Stylesheet {
rules: Vec<Rule>,
/// Final declarations from `@keyframes name { ... 100%/to { ... } }`.
/// Static screenshots are taken after the requested settle interval, so a
/// finite forwards-filled animation contributes this declaration block.
keyframe_ends: HashMap<String, String>,
by_id: HashMap<String, Vec<usize>>,
by_class: HashMap<String, Vec<usize>>,
by_local: HashMap<String, Vec<usize>>,
@@ -59,6 +63,7 @@ impl Stylesheet {
) -> Self {
let mut sheet = Stylesheet {
rules: Vec::new(),
keyframe_ends: HashMap::new(),
by_id: HashMap::new(),
by_class: HashMap::new(),
by_local: HashMap::new(),
@@ -68,6 +73,9 @@ impl Stylesheet {
};
let mut order = 0usize;
for src in sources {
for (name, decls) in extract_keyframe_end_states(src) {
sheet.keyframe_ends.insert(name, decls);
}
for (selector, decls) in parse_stylesheet_for_viewport(src, viewport) {
let sel_trim = selector.trim();
if let Some(base) = strip_pseudo_element(sel_trim, "before") {
@@ -219,6 +227,21 @@ impl Stylesheet {
}
let expanded = substitute_vars(&inline_normal, props, 0);
crate::style::apply_declarations(style, &expanded);
// CSS animations form a cascade origin above normal author rules and
// below author !important. A static renderer has no compositor clock;
// after page settling, the useful deterministic state for a finite
// forwards/both animation is its final keyframe. Infinite animations
// intentionally keep their ordinary computed values.
if style.animation_fill_forwards && !style.animation_iteration_infinite {
if let Some(name) = style.animation_name.as_deref() {
if let Some(decls) = self.keyframe_ends.get(name) {
let expanded = substitute_vars(decls, props, 0);
crate::style::apply_declarations(style, &expanded);
}
}
}
for &(_, _, i) in &matched {
let expanded = substitute_vars(&self.rules[i].important_decls, props, 0);
crate::style::apply_declarations(style, &expanded);
@@ -229,6 +252,85 @@ impl Stylesheet {
}
}
/// Collect the declaration block for each keyframes rule's `to`/`100%` stop.
/// This is deliberately independent of selector parsing: keyframe selectors
/// are percentages, not DOM selectors, and must never enter the rule index.
fn extract_keyframe_end_states(css: &str) -> Vec<(String, String)> {
let lower = css.to_ascii_lowercase();
let mut found = Vec::new();
let mut cursor = 0usize;
while cursor < css.len() {
let standard = lower[cursor..].find("@keyframes").map(|offset| {
(cursor + offset, "@keyframes".len())
});
let webkit = lower[cursor..].find("@-webkit-keyframes").map(|offset| {
(cursor + offset, "@-webkit-keyframes".len())
});
let Some((start, keyword_len)) = (match (standard, webkit) {
(Some(a), Some(b)) => Some(if a.0 <= b.0 { a } else { b }),
(Some(a), None) => Some(a),
(None, Some(b)) => Some(b),
(None, None) => None,
}) else {
break;
};
let after_keyword = start + keyword_len;
let Some(open_rel) = css[after_keyword..].find('{') else { break };
let open = after_keyword + open_rel;
let name = css[after_keyword..open].trim();
if name.is_empty() {
cursor = open + 1;
continue;
}
let mut depth = 1i32;
let mut in_quote: Option<char> = None;
let mut escaped = false;
let mut close = None;
for (offset, ch) in css[open + 1..].char_indices() {
if let Some(quote) = in_quote {
if escaped {
escaped = false;
} else if ch == '\\' {
escaped = true;
} else if ch == quote {
in_quote = None;
}
continue;
}
if ch == '"' || ch == '\'' {
in_quote = Some(ch);
} else if ch == '{' {
depth += 1;
} else if ch == '}' {
depth -= 1;
if depth == 0 {
close = Some(open + 1 + offset);
break;
}
}
}
let Some(close) = close else { break };
let inner = &css[open + 1..close];
let mut final_decls = None;
for (selector, decls) in parse_stylesheet_for_viewport(inner, (1280.0, 720.0)) {
if selector
.split(',')
.any(|part| matches!(part.trim().to_ascii_lowercase().as_str(), "to" | "100%"))
{
final_decls = Some(decls);
}
}
if let Some(decls) = final_decls {
found.push((name.to_string(), decls));
}
cursor = close + 1;
}
found
}
/// Substitute every `var(--name, fallback?)` in `input` with its resolved
/// value from `props` (recursively, so a token that itself references another
/// token resolves; a depth cap guards against reference cycles). A missing
@@ -1014,6 +1116,26 @@ fn split_media_query_list(query: &str) -> Vec<&str> {
mod tests {
use super::*;
#[test]
fn keyframes_extract_only_the_final_animation_state() {
let css = r#"
@keyframes dismiss {
from { opacity: 1; visibility: visible; }
50% { opacity: .5; }
to { opacity: 0; visibility: hidden; }
}
@-webkit-keyframes slide {
0% { transform: translateX(0); }
100% { transform: translateX(20px); }
}
"#;
let ends: HashMap<_, _> = extract_keyframe_end_states(css).into_iter().collect();
assert!(ends["dismiss"].contains("opacity: 0"));
assert!(ends["dismiss"].contains("visibility: hidden"));
assert!(!ends["dismiss"].contains("opacity: 1"));
assert!(ends["slide"].contains("translateX(20px)"));
}
#[test]
fn media_rules_use_the_live_width_and_height() {
let css = r#"