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#"
+7
View File
@@ -398,6 +398,13 @@ pub struct LayoutStyle {
/// `opacity`, own (non-inherited) value in 0.0-1.0. `None` means the
/// default of 1.0.
pub opacity: Option<f32>,
/// First CSS animation name and the parts of its timing contract needed by
/// a settled, static screenshot. A finite animation with a forwards/both
/// fill mode contributes its 100% keyframe after normal declarations and
/// before author `!important`, matching the animation cascade origin.
pub animation_name: Option<String>,
pub animation_fill_forwards: bool,
pub animation_iteration_infinite: bool,
/// `vertical-align` for a table cell's content. Cells effectively default
/// to `middle` in browsers (the HTML UA sheet sets it on row groups and
/// cells inherit it); obscura applies it as main-axis alignment of the
+87
View File
@@ -649,6 +649,24 @@ fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) {
}
"visibility" => style.visibility_hidden = Some(value.eq_ignore_ascii_case("hidden")),
"opacity" => style.opacity = value.trim().parse::<f32>().ok(),
"animation" => apply_animation_shorthand(style, value),
"animation-name" => {
let first = split_top_level(value, ',').into_iter().next().unwrap_or("").trim();
style.animation_name = if first.is_empty() || first.eq_ignore_ascii_case("none") {
None
} else {
Some(first.to_string())
};
}
"animation-fill-mode" => {
let first = split_top_level(value, ',').into_iter().next().unwrap_or("").trim();
style.animation_fill_forwards =
first.eq_ignore_ascii_case("forwards") || first.eq_ignore_ascii_case("both");
}
"animation-iteration-count" => {
let first = split_top_level(value, ',').into_iter().next().unwrap_or("").trim();
style.animation_iteration_infinite = first.eq_ignore_ascii_case("infinite");
}
"z-index" => {
style.z_index = match value.trim() {
"auto" | "inherit" | "initial" => None,
@@ -827,6 +845,56 @@ fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) {
}
}
fn apply_animation_shorthand(style: &mut LayoutStyle, value: &str) {
style.animation_name = None;
style.animation_fill_forwards = false;
style.animation_iteration_infinite = false;
let first = split_top_level(value, ',').into_iter().next().unwrap_or("").trim();
if first.is_empty() || first.eq_ignore_ascii_case("none") {
return;
}
for token in split_ws_paren(first) {
let lower = token.to_ascii_lowercase();
let timing_keyword = matches!(
lower.as_str(),
"linear" | "ease" | "ease-in" | "ease-out" | "ease-in-out"
) || lower.starts_with("cubic-bezier(")
|| lower.starts_with("steps(")
|| lower.starts_with("linear(");
let time = lower
.strip_suffix("ms")
.or_else(|| lower.strip_suffix('s'))
.and_then(|number| number.parse::<f32>().ok())
.is_some();
let control_keyword = matches!(
lower.as_str(),
"normal"
| "reverse"
| "alternate"
| "alternate-reverse"
| "backwards"
| "running"
| "paused"
);
if lower == "forwards" || lower == "both" {
style.animation_fill_forwards = true;
} else if lower == "infinite" {
style.animation_iteration_infinite = true;
} else if time
|| timing_keyword
|| control_keyword
|| lower.parse::<f32>().is_ok()
|| lower == "none"
{
continue;
} else if style.animation_name.is_none() {
style.animation_name = Some(token.to_string());
}
}
}
/// Parse the common CSS Box Alignment values that taffy can represent.
///
/// The outer option distinguishes an invalid declaration from `auto`, which
@@ -3189,6 +3257,25 @@ mod tests {
assert!(flow_root.flow_root);
}
#[test]
fn animation_shorthand_preserves_settled_forward_fill_contract() {
let finite = compute_style(
"div",
Some("animation: dismiss-overlay .6s ease-out forwards"),
);
assert_eq!(finite.animation_name.as_deref(), Some("dismiss-overlay"));
assert!(finite.animation_fill_forwards);
assert!(!finite.animation_iteration_infinite);
let infinite = compute_style(
"div",
Some("animation: pulse 1s linear infinite"),
);
assert_eq!(infinite.animation_name.as_deref(), Some("pulse"));
assert!(!infinite.animation_fill_forwards);
assert!(infinite.animation_iteration_infinite);
}
#[test]
fn authored_display_replaces_internal_flex_provenance() {
let native_cell = compute_style("td", None);
@@ -0,0 +1,34 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Animation fill forwards</title>
<style>
html, body { margin: 0; }
#content {
width: 360px;
height: 180px;
background: #18723a;
}
#overlay {
position: fixed;
inset: 0;
width: 360px;
height: 180px;
background: #a22;
}
@keyframes dismiss-overlay {
from { opacity: 1; }
to { opacity: 0; visibility: hidden; }
}
#overlay.dismissed {
animation: dismiss-overlay 100ms ease-out forwards;
}
</style>
</head>
<body>
<div id="content"></div>
<div id="overlay"></div>
<script>document.getElementById("overlay").classList.add("dismissed");</script>
</body>
</html>
+3
View File
@@ -16,6 +16,9 @@
{"name": "outer static candidate is reparented after coordinate harvest", "color": "#862e9c", "x": 84, "y": 390, "width": 160, "height": 100},
{"name": "nested static candidate survives outer reparenting", "color": "#0b7285", "x": 154, "y": 413, "width": 20, "height": 20}
],
"animation-fill-forwards": [
{"name": "finite forwards animation exposes final hidden state", "color": "#18723a", "x": 0, "y": 0, "width": 360, "height": 180}
],
"block-auto-margins": [
{"name": "two auto margins center a fixed-width block", "color": "#087f5b", "x": 300, "y": 20, "width": 300, "height": 40},
{"name": "two auto margins center a max-width block", "color": "#1971c2", "x": 200, "y": 100, "width": 500, "height": 40},