mirror of
https://github.com/aphrody-labs/obscura.git
synced 2026-10-17 12:44:52 +00:00
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.
|
||||
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#"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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},
|
||||
|
||||
Reference in new issue
Block a user