render: apply settled animation end states
This commit is contained in:
1 parent
ec5950da06
commit
b03c663952
5 files changed
+253
No files matched your search
@@ -24,6 +24,10 @@ struct Rule {
|
|||||||
/// An indexed set of author rules ready for fast per-element matching.
|
/// An indexed set of author rules ready for fast per-element matching.
|
||||||
pub struct Stylesheet {
|
pub struct Stylesheet {
|
||||||
rules: Vec<Rule>,
|
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_id: HashMap<String, Vec<usize>>,
|
||||||
by_class: HashMap<String, Vec<usize>>,
|
by_class: HashMap<String, Vec<usize>>,
|
||||||
by_local: HashMap<String, Vec<usize>>,
|
by_local: HashMap<String, Vec<usize>>,
|
||||||
@@ -59,6 +63,7 @@ impl Stylesheet {
|
|||||||
) -> Self {
|
) -> Self {
|
||||||
let mut sheet = Stylesheet {
|
let mut sheet = Stylesheet {
|
||||||
rules: Vec::new(),
|
rules: Vec::new(),
|
||||||
|
keyframe_ends: HashMap::new(),
|
||||||
by_id: HashMap::new(),
|
by_id: HashMap::new(),
|
||||||
by_class: HashMap::new(),
|
by_class: HashMap::new(),
|
||||||
by_local: HashMap::new(),
|
by_local: HashMap::new(),
|
||||||
@@ -68,6 +73,9 @@ impl Stylesheet {
|
|||||||
};
|
};
|
||||||
let mut order = 0usize;
|
let mut order = 0usize;
|
||||||
for src in sources {
|
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) {
|
for (selector, decls) in parse_stylesheet_for_viewport(src, viewport) {
|
||||||
let sel_trim = selector.trim();
|
let sel_trim = selector.trim();
|
||||||
if let Some(base) = strip_pseudo_element(sel_trim, "before") {
|
if let Some(base) = strip_pseudo_element(sel_trim, "before") {
|
||||||
@@ -219,6 +227,21 @@ impl Stylesheet {
|
|||||||
}
|
}
|
||||||
let expanded = substitute_vars(&inline_normal, props, 0);
|
let expanded = substitute_vars(&inline_normal, props, 0);
|
||||||
crate::style::apply_declarations(style, &expanded);
|
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 {
|
for &(_, _, i) in &matched {
|
||||||
let expanded = substitute_vars(&self.rules[i].important_decls, props, 0);
|
let expanded = substitute_vars(&self.rules[i].important_decls, props, 0);
|
||||||
crate::style::apply_declarations(style, &expanded);
|
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
|
/// Substitute every `var(--name, fallback?)` in `input` with its resolved
|
||||||
/// value from `props` (recursively, so a token that itself references another
|
/// value from `props` (recursively, so a token that itself references another
|
||||||
/// token resolves; a depth cap guards against reference cycles). A missing
|
/// 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 {
|
mod tests {
|
||||||
use super::*;
|
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]
|
#[test]
|
||||||
fn media_rules_use_the_live_width_and_height() {
|
fn media_rules_use_the_live_width_and_height() {
|
||||||
let css = r#"
|
let css = r#"
|
||||||
|
|||||||
@@ -398,6 +398,13 @@ pub struct LayoutStyle {
|
|||||||
/// `opacity`, own (non-inherited) value in 0.0-1.0. `None` means the
|
/// `opacity`, own (non-inherited) value in 0.0-1.0. `None` means the
|
||||||
/// default of 1.0.
|
/// default of 1.0.
|
||||||
pub opacity: Option<f32>,
|
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
|
/// `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
|
/// 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
|
/// cells inherit it); obscura applies it as main-axis alignment of the
|
||||||
|
|||||||
@@ -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")),
|
"visibility" => style.visibility_hidden = Some(value.eq_ignore_ascii_case("hidden")),
|
||||||
"opacity" => style.opacity = value.trim().parse::<f32>().ok(),
|
"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" => {
|
"z-index" => {
|
||||||
style.z_index = match value.trim() {
|
style.z_index = match value.trim() {
|
||||||
"auto" | "inherit" | "initial" => None,
|
"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.
|
/// Parse the common CSS Box Alignment values that taffy can represent.
|
||||||
///
|
///
|
||||||
/// The outer option distinguishes an invalid declaration from `auto`, which
|
/// The outer option distinguishes an invalid declaration from `auto`, which
|
||||||
@@ -3189,6 +3257,25 @@ mod tests {
|
|||||||
assert!(flow_root.flow_root);
|
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]
|
#[test]
|
||||||
fn authored_display_replaces_internal_flex_provenance() {
|
fn authored_display_replaces_internal_flex_provenance() {
|
||||||
let native_cell = compute_style("td", None);
|
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>
|
||||||
@@ -16,6 +16,9 @@
|
|||||||
{"name": "outer static candidate is reparented after coordinate harvest", "color": "#862e9c", "x": 84, "y": 390, "width": 160, "height": 100},
|
{"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}
|
{"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": [
|
"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 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},
|
{"name": "two auto margins center a max-width block", "color": "#1971c2", "x": 200, "y": 100, "width": 500, "height": 40},
|
||||||
|
|||||||
Reference in new issue
Block a user