Sample CSS animation opacity at capture time
This commit is contained in:
@@ -96,6 +96,21 @@ struct PseudoRule {
|
||||
container_condition_id: ContainerConditionId,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
struct KeyframeStop {
|
||||
/// CSS keyframes always provide an offset. Keeping this optional makes the
|
||||
/// normalization rule explicit and reusable by future script-created
|
||||
/// keyframes without changing the sampler.
|
||||
offset: Option<f32>,
|
||||
declarations: String,
|
||||
source_order: usize,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
struct Keyframes {
|
||||
stops: Vec<KeyframeStop>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct ContainerBox {
|
||||
pub(crate) container_type: crate::ContainerType,
|
||||
@@ -468,10 +483,10 @@ pub struct Stylesheet {
|
||||
rules: Vec<Rule>,
|
||||
/// Index zero is the unconditional sentinel.
|
||||
container_conditions: Vec<ContainerConditionNode>,
|
||||
/// 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>,
|
||||
/// Every offset from each `@keyframes` rule. The opacity sampler resolves
|
||||
/// property-specific segments at the stylesheet's explicit sample time.
|
||||
keyframes: HashMap<String, Keyframes>,
|
||||
animation_sample_time: crate::AnimationSampleTime,
|
||||
by_id: HashMap<String, Vec<usize>>,
|
||||
by_class: HashMap<String, Vec<usize>>,
|
||||
by_local: HashMap<String, Vec<usize>>,
|
||||
@@ -544,6 +559,20 @@ impl Stylesheet {
|
||||
tree: &DomTree,
|
||||
sources: &[String],
|
||||
viewport: (f32, f32),
|
||||
) -> Self {
|
||||
Self::parse_for_viewport_at_animation_time(
|
||||
tree,
|
||||
sources,
|
||||
viewport,
|
||||
crate::AnimationSampleTime::default(),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn parse_for_viewport_at_animation_time(
|
||||
tree: &DomTree,
|
||||
sources: &[String],
|
||||
viewport: (f32, f32),
|
||||
animation_sample_time: crate::AnimationSampleTime,
|
||||
) -> Self {
|
||||
let mut sheet = Stylesheet {
|
||||
rules: Vec::new(),
|
||||
@@ -551,7 +580,8 @@ impl Stylesheet {
|
||||
parent: ContainerConditionId::NONE,
|
||||
alternatives: Vec::new(),
|
||||
}],
|
||||
keyframe_ends: HashMap::new(),
|
||||
keyframes: HashMap::new(),
|
||||
animation_sample_time,
|
||||
by_id: HashMap::new(),
|
||||
by_class: HashMap::new(),
|
||||
by_local: HashMap::new(),
|
||||
@@ -561,8 +591,8 @@ 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 (name, keyframes) in extract_keyframes(src) {
|
||||
sheet.keyframes.insert(name, keyframes);
|
||||
}
|
||||
let parsed = parse_stylesheet_for_viewport_preserving_containers(
|
||||
src,
|
||||
@@ -969,19 +999,28 @@ impl Stylesheet {
|
||||
let expanded = substitute_declarations(&inline_normal, props);
|
||||
crate::style::apply_declarations_with_locked_color_scheme(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_declarations(decls, props);
|
||||
crate::style::apply_declarations_with_locked_color_scheme(
|
||||
style,
|
||||
&expanded,
|
||||
);
|
||||
// Animation control properties from author !important participate in
|
||||
// computed timing, while the animated value itself remains below the
|
||||
// important author origin. Resolve those controls on a temporary style
|
||||
// before sampling, then let the ordinary important pass override the
|
||||
// sampled opacity where appropriate.
|
||||
let mut animation_style = style.clone();
|
||||
for &(_, _, i) in &matched {
|
||||
let expanded = substitute_declarations(&self.rules[i].important_decls, props);
|
||||
crate::style::apply_animation_declarations(&mut animation_style, &expanded);
|
||||
}
|
||||
let expanded = substitute_declarations(&inline_important, props);
|
||||
crate::style::apply_animation_declarations(&mut animation_style, &expanded);
|
||||
if let Some(name) = animation_style.animation_name.as_deref() {
|
||||
if let Some(keyframes) = self.keyframes.get(name) {
|
||||
if let Some(opacity) = sample_animation_opacity(
|
||||
keyframes,
|
||||
style.opacity.unwrap_or(1.0),
|
||||
&animation_style.animation_timing,
|
||||
self.animation_sample_time,
|
||||
props,
|
||||
) {
|
||||
style.opacity = Some(opacity);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -999,10 +1038,10 @@ 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)> {
|
||||
/// Collect every offset and declaration block from standard and prefixed
|
||||
/// keyframes rules. Keyframe selectors are percentages rather than DOM
|
||||
/// selectors and must never enter the ordinary rule index.
|
||||
fn extract_keyframes(css: &str) -> Vec<(String, Keyframes)> {
|
||||
let lower = css.to_ascii_lowercase();
|
||||
let mut found = Vec::new();
|
||||
let mut cursor = 0usize;
|
||||
@@ -1061,23 +1100,248 @@ fn extract_keyframe_end_states(css: &str) -> Vec<(String, String)> {
|
||||
}
|
||||
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);
|
||||
let mut stops = Vec::new();
|
||||
for (source_order, (selector, declarations)) in
|
||||
parse_stylesheet_for_viewport(inner, (1280.0, 720.0))
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
for part in selector.split(',') {
|
||||
if let Some(offset) = parse_keyframe_offset(part) {
|
||||
stops.push(KeyframeStop {
|
||||
offset: Some(offset),
|
||||
declarations: declarations.clone(),
|
||||
source_order,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(decls) = final_decls {
|
||||
found.push((name.to_string(), decls));
|
||||
if !stops.is_empty() {
|
||||
found.push((name.to_string(), Keyframes { stops }));
|
||||
}
|
||||
cursor = close + 1;
|
||||
}
|
||||
found
|
||||
}
|
||||
|
||||
fn parse_keyframe_offset(value: &str) -> Option<f32> {
|
||||
let value = value.trim().to_ascii_lowercase();
|
||||
match value.as_str() {
|
||||
"from" => Some(0.0),
|
||||
"to" => Some(1.0),
|
||||
_ => value
|
||||
.strip_suffix('%')?
|
||||
.trim()
|
||||
.parse::<f32>()
|
||||
.ok()
|
||||
.filter(|offset| offset.is_finite() && (0.0..=100.0).contains(offset))
|
||||
.map(|offset| offset / 100.0),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum AnimationPhase {
|
||||
Before,
|
||||
Active,
|
||||
After,
|
||||
}
|
||||
|
||||
fn sample_animation_opacity(
|
||||
keyframes: &Keyframes,
|
||||
underlying: f32,
|
||||
timing: &crate::AnimationTiming,
|
||||
sample_time: crate::AnimationSampleTime,
|
||||
props: &HashMap<String, String>,
|
||||
) -> Option<f32> {
|
||||
let progress = animation_directed_progress(timing, sample_time)?;
|
||||
let normalized = normalized_keyframe_offsets(&keyframes.stops);
|
||||
let mut opacity_stops = Vec::<(f32, usize, f32)>::new();
|
||||
for (offset, stop) in normalized {
|
||||
let expanded = substitute_declarations(&stop.declarations, props);
|
||||
if let Some(opacity) = opacity_from_declarations(&expanded) {
|
||||
opacity_stops.push((offset, stop.source_order, opacity));
|
||||
}
|
||||
}
|
||||
if opacity_stops.is_empty() {
|
||||
return None;
|
||||
}
|
||||
opacity_stops.sort_by(|left, right| {
|
||||
left.0
|
||||
.total_cmp(&right.0)
|
||||
.then(left.1.cmp(&right.1))
|
||||
});
|
||||
let mut merged = Vec::<(f32, f32)>::new();
|
||||
for (offset, _, opacity) in opacity_stops {
|
||||
if merged.last().is_some_and(|last| last.0 == offset) {
|
||||
merged.last_mut().unwrap().1 = opacity;
|
||||
} else {
|
||||
merged.push((offset, opacity));
|
||||
}
|
||||
}
|
||||
if merged.first().is_none_or(|stop| stop.0 > 0.0) {
|
||||
merged.insert(0, (0.0, underlying));
|
||||
}
|
||||
if merged.last().is_none_or(|stop| stop.0 < 1.0) {
|
||||
merged.push((1.0, underlying));
|
||||
}
|
||||
if progress <= merged[0].0 {
|
||||
return Some(merged[0].1.clamp(0.0, 1.0));
|
||||
}
|
||||
for pair in merged.windows(2) {
|
||||
let (from_offset, from_value) = pair[0];
|
||||
let (to_offset, to_value) = pair[1];
|
||||
if progress <= to_offset {
|
||||
if progress == to_offset || to_offset == from_offset {
|
||||
return Some(to_value.clamp(0.0, 1.0));
|
||||
}
|
||||
let position = (progress - from_offset) / (to_offset - from_offset);
|
||||
return Some((from_value + (to_value - from_value) * position).clamp(0.0, 1.0));
|
||||
}
|
||||
}
|
||||
Some(merged.last().unwrap().1.clamp(0.0, 1.0))
|
||||
}
|
||||
|
||||
fn opacity_from_declarations(declarations: &str) -> Option<f32> {
|
||||
let mut opacity = None;
|
||||
for declaration in crate::style::split_declarations(declarations) {
|
||||
let Some((name, value)) = declaration.split_once(':') else { continue };
|
||||
if name.trim().eq_ignore_ascii_case("opacity") {
|
||||
if let Ok(parsed) = value.trim().parse::<f32>() {
|
||||
if parsed.is_finite() {
|
||||
opacity = Some(parsed);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
opacity
|
||||
}
|
||||
|
||||
fn animation_directed_progress(
|
||||
timing: &crate::AnimationTiming,
|
||||
sample_time: crate::AnimationSampleTime,
|
||||
) -> Option<f32> {
|
||||
let duration = timing.duration_ms.max(0.0);
|
||||
let iterations = timing.iteration_count.max(0.0);
|
||||
let active_duration = if duration == 0.0 || iterations == 0.0 {
|
||||
0.0
|
||||
} else {
|
||||
duration * iterations
|
||||
};
|
||||
let local_time = if timing.play_state == crate::AnimationPlayState::Paused {
|
||||
0.0
|
||||
} else {
|
||||
sample_time.milliseconds
|
||||
};
|
||||
if !local_time.is_finite() {
|
||||
return None;
|
||||
}
|
||||
let end_time = (timing.delay_ms + active_duration).max(0.0);
|
||||
let before_boundary = timing.delay_ms.clamp(0.0, end_time);
|
||||
let after_boundary = (timing.delay_ms + active_duration).min(end_time).max(0.0);
|
||||
let phase = if local_time < before_boundary {
|
||||
AnimationPhase::Before
|
||||
} else if local_time >= after_boundary {
|
||||
AnimationPhase::After
|
||||
} else {
|
||||
AnimationPhase::Active
|
||||
};
|
||||
let active_time = match phase {
|
||||
AnimationPhase::Before => {
|
||||
if !matches!(
|
||||
timing.fill_mode,
|
||||
crate::AnimationFillMode::Backwards | crate::AnimationFillMode::Both
|
||||
) {
|
||||
return None;
|
||||
}
|
||||
(local_time - timing.delay_ms).max(0.0)
|
||||
}
|
||||
AnimationPhase::Active => local_time - timing.delay_ms,
|
||||
AnimationPhase::After => {
|
||||
if !matches!(
|
||||
timing.fill_mode,
|
||||
crate::AnimationFillMode::Forwards | crate::AnimationFillMode::Both
|
||||
) {
|
||||
return None;
|
||||
}
|
||||
(local_time - timing.delay_ms).clamp(0.0, active_duration)
|
||||
}
|
||||
};
|
||||
|
||||
let overall_progress = if duration == 0.0 {
|
||||
if phase == AnimationPhase::Before { 0.0 } else { iterations }
|
||||
} else {
|
||||
active_time / duration
|
||||
};
|
||||
if !overall_progress.is_finite() {
|
||||
return Some(match timing.direction {
|
||||
crate::AnimationDirection::Reverse | crate::AnimationDirection::AlternateReverse => 1.0,
|
||||
_ => 0.0,
|
||||
});
|
||||
}
|
||||
let mut current_iteration = overall_progress.floor().max(0.0);
|
||||
let mut simple_progress = overall_progress.rem_euclid(1.0);
|
||||
if phase == AnimationPhase::After
|
||||
&& iterations > 0.0
|
||||
&& simple_progress == 0.0
|
||||
{
|
||||
simple_progress = 1.0;
|
||||
current_iteration = (current_iteration - 1.0).max(0.0);
|
||||
}
|
||||
let reverse = match timing.direction {
|
||||
crate::AnimationDirection::Normal => false,
|
||||
crate::AnimationDirection::Reverse => true,
|
||||
crate::AnimationDirection::Alternate => current_iteration.rem_euclid(2.0) >= 1.0,
|
||||
crate::AnimationDirection::AlternateReverse => current_iteration.rem_euclid(2.0) < 1.0,
|
||||
};
|
||||
Some(if reverse {
|
||||
1.0 - simple_progress
|
||||
} else {
|
||||
simple_progress
|
||||
})
|
||||
}
|
||||
|
||||
fn normalized_keyframe_offsets(stops: &[KeyframeStop]) -> Vec<(f32, &KeyframeStop)> {
|
||||
if stops.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut offsets = stops.iter().map(|stop| stop.offset).collect::<Vec<_>>();
|
||||
if offsets.len() == 1 {
|
||||
offsets[0] = Some(offsets[0].unwrap_or(1.0));
|
||||
} else {
|
||||
if offsets[0].is_none() {
|
||||
offsets[0] = Some(0.0);
|
||||
}
|
||||
let last = offsets.len() - 1;
|
||||
if offsets[last].is_none() {
|
||||
offsets[last] = Some(1.0);
|
||||
}
|
||||
}
|
||||
let mut index = 0usize;
|
||||
while index < offsets.len() {
|
||||
if offsets[index].is_some() {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
let start = index - 1;
|
||||
let mut end = index + 1;
|
||||
while offsets[end].is_none() {
|
||||
end += 1;
|
||||
}
|
||||
let from = offsets[start].unwrap();
|
||||
let to = offsets[end].unwrap();
|
||||
let span = (end - start) as f32;
|
||||
for missing in index..end {
|
||||
offsets[missing] = Some(from + (to - from) * (missing - start) as f32 / span);
|
||||
}
|
||||
index = end + 1;
|
||||
}
|
||||
stops
|
||||
.iter()
|
||||
.zip(offsets)
|
||||
.map(|(stop, offset)| (offset.unwrap(), stop))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Resolve variables one declaration at a time. An invalid variable poisons
|
||||
/// its entire declaration at computed-value time, but must not erase unrelated
|
||||
/// declarations in the same rule.
|
||||
@@ -2506,7 +2770,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keyframes_extract_only_the_final_animation_state() {
|
||||
fn keyframes_retain_every_animation_offset() {
|
||||
let css = r#"
|
||||
@keyframes dismiss {
|
||||
from { opacity: 1; visibility: visible; }
|
||||
@@ -2518,11 +2782,289 @@ mod tests {
|
||||
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)"));
|
||||
let keyframes: HashMap<_, _> = extract_keyframes(css).into_iter().collect();
|
||||
let dismiss = normalized_keyframe_offsets(&keyframes["dismiss"].stops);
|
||||
assert_eq!(
|
||||
dismiss.iter().map(|(offset, _)| *offset).collect::<Vec<_>>(),
|
||||
[0.0, 0.5, 1.0]
|
||||
);
|
||||
assert!(dismiss[0].1.declarations.contains("opacity: 1"));
|
||||
assert!(dismiss[2].1.declarations.contains("visibility: hidden"));
|
||||
let slide = normalized_keyframe_offsets(&keyframes["slide"].stops);
|
||||
assert_eq!(
|
||||
slide.iter().map(|(offset, _)| *offset).collect::<Vec<_>>(),
|
||||
[0.0, 1.0]
|
||||
);
|
||||
assert!(slide[1].1.declarations.contains("translateX(20px)"));
|
||||
}
|
||||
|
||||
fn sampled_animation_style(css: &str, sample_ms: f32, target_id: &str) -> LayoutStyle {
|
||||
let tree = obscura_dom::parse_html(&format!(r#"<div id="{target_id}"></div>"#));
|
||||
let target = tree.get_element_by_id(target_id).unwrap();
|
||||
let sheet = Stylesheet::parse_for_viewport_at_animation_time(
|
||||
&tree,
|
||||
&[css.to_string()],
|
||||
(1280.0, 720.0),
|
||||
crate::AnimationSampleTime {
|
||||
milliseconds: sample_ms,
|
||||
},
|
||||
);
|
||||
let node = tree.get_node(target).unwrap();
|
||||
let element = node.as_element().unwrap();
|
||||
let mut matcher = tree.matcher();
|
||||
let mut style = LayoutStyle::default();
|
||||
sheet.apply(
|
||||
&tree,
|
||||
&mut matcher,
|
||||
target,
|
||||
node.get_attribute("id"),
|
||||
&[],
|
||||
element.local.as_ref(),
|
||||
&mut style,
|
||||
&HashMap::new(),
|
||||
None,
|
||||
);
|
||||
style
|
||||
}
|
||||
|
||||
fn sampled_fade(extra: &str, sample_ms: f32) -> f32 {
|
||||
let css = format!(
|
||||
r#"
|
||||
@keyframes fade {{ from {{ opacity:0 }} to {{ opacity:1 }} }}
|
||||
#target {{
|
||||
opacity:.4;
|
||||
animation:fade 1s linear infinite;
|
||||
{extra}
|
||||
}}
|
||||
"#
|
||||
);
|
||||
sampled_animation_style(&css, sample_ms, "target")
|
||||
.opacity
|
||||
.unwrap_or(1.0)
|
||||
}
|
||||
|
||||
fn assert_opacity(actual: f32, expected: f32) {
|
||||
assert!(
|
||||
(actual - expected).abs() < 0.0001,
|
||||
"expected opacity {expected}, got {actual}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn animation_timeline_handles_delay_fill_iterations_and_direction_at_t0() {
|
||||
assert_opacity(sampled_fade("", 0.0), 0.0);
|
||||
assert_opacity(sampled_fade("animation-delay:250ms", 0.0), 0.4);
|
||||
assert_opacity(
|
||||
sampled_fade("animation-delay:250ms;animation-fill-mode:backwards", 0.0),
|
||||
0.0,
|
||||
);
|
||||
assert_opacity(
|
||||
sampled_fade(
|
||||
"animation-delay:250ms;animation-fill-mode:backwards;animation-direction:reverse",
|
||||
0.0,
|
||||
),
|
||||
1.0,
|
||||
);
|
||||
assert_opacity(sampled_fade("animation-delay:-250ms", 0.0), 0.25);
|
||||
assert_opacity(sampled_fade("animation-delay:-1s", 0.0), 0.0);
|
||||
assert_opacity(
|
||||
sampled_fade("animation-delay:-1s;animation-direction:alternate", 0.0),
|
||||
1.0,
|
||||
);
|
||||
assert_opacity(
|
||||
sampled_fade(
|
||||
"animation-delay:-1s;animation-iteration-count:1;animation-fill-mode:none",
|
||||
0.0,
|
||||
),
|
||||
0.4,
|
||||
);
|
||||
assert_opacity(
|
||||
sampled_fade(
|
||||
"animation-delay:-1s;animation-iteration-count:1;animation-fill-mode:forwards",
|
||||
0.0,
|
||||
),
|
||||
1.0,
|
||||
);
|
||||
assert_opacity(
|
||||
sampled_fade(
|
||||
"animation-delay:-1s;animation-iteration-count:1;animation-fill-mode:forwards;animation-direction:reverse",
|
||||
0.0,
|
||||
),
|
||||
0.0,
|
||||
);
|
||||
assert_opacity(
|
||||
sampled_fade(
|
||||
"animation-delay:-2s;animation-iteration-count:2;animation-fill-mode:forwards;animation-direction:alternate",
|
||||
0.0,
|
||||
),
|
||||
0.0,
|
||||
);
|
||||
assert_opacity(
|
||||
sampled_fade(
|
||||
"animation-iteration-count:0;animation-fill-mode:forwards",
|
||||
0.0,
|
||||
),
|
||||
0.0,
|
||||
);
|
||||
assert_opacity(
|
||||
sampled_fade(
|
||||
"animation-duration:0s;animation-iteration-count:1;animation-fill-mode:none",
|
||||
0.0,
|
||||
),
|
||||
0.4,
|
||||
);
|
||||
assert_opacity(
|
||||
sampled_fade(
|
||||
"animation-duration:0s;animation-iteration-count:1;animation-fill-mode:forwards",
|
||||
0.0,
|
||||
),
|
||||
1.0,
|
||||
);
|
||||
assert_opacity(
|
||||
sampled_fade(
|
||||
"animation-delay:-2.5s;animation-iteration-count:2.5;animation-fill-mode:forwards",
|
||||
0.0,
|
||||
),
|
||||
0.5,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn animation_calc_delay_pause_and_important_origin_are_deterministic() {
|
||||
assert_opacity(
|
||||
sampled_fade("--step:.1s;animation-delay:calc(var(--step) * -2.5)", 0.0),
|
||||
0.25,
|
||||
);
|
||||
assert_opacity(sampled_fade("animation-play-state:paused", 500.0), 0.0);
|
||||
assert_opacity(sampled_fade("", 500.0), 0.5);
|
||||
assert_opacity(
|
||||
sampled_fade(
|
||||
"animation-delay:-250ms !important;opacity:.8 !important",
|
||||
0.0,
|
||||
),
|
||||
0.8,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opacity_segments_use_underlying_endpoints_and_exact_later_boundaries() {
|
||||
let missing_endpoints = r#"
|
||||
@keyframes middle { 50% { opacity:1 } }
|
||||
#target { opacity:.4; animation:middle 1s linear 1 both }
|
||||
"#;
|
||||
assert_opacity(
|
||||
sampled_animation_style(missing_endpoints, 250.0, "target")
|
||||
.opacity
|
||||
.unwrap(),
|
||||
0.7,
|
||||
);
|
||||
assert_opacity(
|
||||
sampled_animation_style(missing_endpoints, 750.0, "target")
|
||||
.opacity
|
||||
.unwrap(),
|
||||
0.7,
|
||||
);
|
||||
let exact_boundary = r#"
|
||||
@keyframes peak {
|
||||
0% { opacity:0 }
|
||||
50% { opacity:1 }
|
||||
100% { opacity:0 }
|
||||
}
|
||||
#target { opacity:.4; animation:peak 1s linear 1 both }
|
||||
"#;
|
||||
assert_opacity(
|
||||
sampled_animation_style(exact_boundary, 500.0, "target")
|
||||
.opacity
|
||||
.unwrap(),
|
||||
1.0,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_keyframe_offsets_follow_web_animation_distribution() {
|
||||
let stop = |offset| KeyframeStop {
|
||||
offset,
|
||||
declarations: "opacity:1".to_string(),
|
||||
source_order: 0,
|
||||
};
|
||||
let single = [stop(None)];
|
||||
assert_eq!(normalized_keyframe_offsets(&single)[0].0, 1.0);
|
||||
let pair = [stop(None), stop(None)];
|
||||
assert_eq!(
|
||||
normalized_keyframe_offsets(&pair)
|
||||
.iter()
|
||||
.map(|(offset, _)| *offset)
|
||||
.collect::<Vec<_>>(),
|
||||
[0.0, 1.0]
|
||||
);
|
||||
let distributed = [
|
||||
stop(Some(0.0)),
|
||||
stop(None),
|
||||
stop(None),
|
||||
stop(Some(0.75)),
|
||||
stop(None),
|
||||
];
|
||||
assert_eq!(
|
||||
normalized_keyframe_offsets(&distributed)
|
||||
.iter()
|
||||
.map(|(offset, _)| *offset)
|
||||
.collect::<Vec<_>>(),
|
||||
[0.0, 0.25, 0.5, 0.75, 1.0]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mozilla_stagger_samples_only_the_delay_zero_frame() {
|
||||
let mut html = String::new();
|
||||
let mut selectors = String::new();
|
||||
for frame in 0..12 {
|
||||
html.push_str(&format!(r#"<svg id="frame{frame}" class="frame"></svg>"#));
|
||||
selectors.push_str(&format!(
|
||||
"#frame{frame}{{animation-delay:calc(var(--base-delay) * {frame})}}"
|
||||
));
|
||||
}
|
||||
let tree = obscura_dom::parse_html(&html);
|
||||
let css = format!(
|
||||
r#"
|
||||
@keyframes wave {{
|
||||
0%, 8.333% {{ opacity:1 }}
|
||||
8.4%, to {{ opacity:0 }}
|
||||
}}
|
||||
.frame {{
|
||||
--base-delay:.1s;
|
||||
opacity:0;
|
||||
animation:wave 1.2s linear infinite;
|
||||
}}
|
||||
{selectors}
|
||||
"#
|
||||
);
|
||||
let sheet = Stylesheet::parse_for_viewport_at_animation_time(
|
||||
&tree,
|
||||
&[css],
|
||||
(1280.0, 720.0),
|
||||
crate::AnimationSampleTime::default(),
|
||||
);
|
||||
for frame in 0..12 {
|
||||
let id = format!("frame{frame}");
|
||||
let target = tree.get_element_by_id(&id).unwrap();
|
||||
let node = tree.get_node(target).unwrap();
|
||||
let element = node.as_element().unwrap();
|
||||
let classes = vec!["frame".to_string()];
|
||||
let mut matcher = tree.matcher();
|
||||
let mut style = LayoutStyle::default();
|
||||
sheet.apply(
|
||||
&tree,
|
||||
&mut matcher,
|
||||
target,
|
||||
node.get_attribute("id"),
|
||||
&classes,
|
||||
element.local.as_ref(),
|
||||
&mut style,
|
||||
&HashMap::new(),
|
||||
None,
|
||||
);
|
||||
assert_opacity(style.opacity.unwrap(), if frame == 0 { 1.0 } else { 0.0 });
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -533,13 +533,11 @@ 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
|
||||
/// First CSS animation name and its timing contract. The stylesheet
|
||||
/// sampler contributes animated opacity 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,
|
||||
pub animation_timing: AnimationTiming,
|
||||
/// `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
|
||||
@@ -809,6 +807,67 @@ pub enum WhiteSpace {
|
||||
BreakSpaces,
|
||||
}
|
||||
|
||||
/// Deterministic CSS animation sample time relative to the animation's
|
||||
/// timeline origin. Screenshot rendering currently uses the initial value
|
||||
/// (0ms); keeping it explicit lets capture plumbing freeze other engines at
|
||||
/// the same instant instead of depending on wall-clock settling.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Default)]
|
||||
pub struct AnimationSampleTime {
|
||||
pub milliseconds: f32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum AnimationDirection {
|
||||
#[default]
|
||||
Normal,
|
||||
Reverse,
|
||||
Alternate,
|
||||
AlternateReverse,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum AnimationFillMode {
|
||||
#[default]
|
||||
None,
|
||||
Forwards,
|
||||
Backwards,
|
||||
Both,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum AnimationPlayState {
|
||||
#[default]
|
||||
Running,
|
||||
Paused,
|
||||
}
|
||||
|
||||
/// Timing fields for the first CSS animation. Milliseconds are used
|
||||
/// internally so `s`, `ms`, negative delays, and calculated stagger values
|
||||
/// share one unit.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct AnimationTiming {
|
||||
pub duration_ms: f32,
|
||||
pub delay_ms: f32,
|
||||
/// A finite non-negative count, or positive infinity for `infinite`.
|
||||
pub iteration_count: f32,
|
||||
pub direction: AnimationDirection,
|
||||
pub fill_mode: AnimationFillMode,
|
||||
pub play_state: AnimationPlayState,
|
||||
}
|
||||
|
||||
impl Default for AnimationTiming {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
duration_ms: 0.0,
|
||||
delay_ms: 0.0,
|
||||
iteration_count: 1.0,
|
||||
direction: AnimationDirection::Normal,
|
||||
fill_mode: AnimationFillMode::None,
|
||||
play_state: AnimationPlayState::Running,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The implemented `text-wrap-style` values. Other line-breaking strategies
|
||||
/// such as `pretty` remain unsupported until their distinct scoring model is
|
||||
/// available; treating them as `auto` would make `@supports` lie.
|
||||
|
||||
@@ -314,6 +314,16 @@ pub(crate) fn apply_declarations_with_locked_color_scheme(
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn apply_animation_declarations(style: &mut LayoutStyle, css: &str) {
|
||||
for raw in split_declarations(css) {
|
||||
let Some((name, value)) = raw.trim().split_once(':') else { continue };
|
||||
let name = name.trim().to_ascii_lowercase();
|
||||
if name == "animation" || name.starts_with("animation-") {
|
||||
apply_value(style, &name, value.trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_color_scheme(
|
||||
style: &mut LayoutStyle,
|
||||
value: &str,
|
||||
@@ -1116,20 +1126,82 @@ fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) {
|
||||
"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") {
|
||||
style.animation_name = if first.is_empty()
|
||||
|| matches!(
|
||||
first.to_ascii_lowercase().as_str(),
|
||||
"none" | "initial" | "inherit" | "unset" | "revert" | "revert-layer"
|
||||
)
|
||||
{
|
||||
None
|
||||
} else {
|
||||
Some(first.to_string())
|
||||
};
|
||||
}
|
||||
"animation-duration" => {
|
||||
let first = split_top_level(value, ',').into_iter().next().unwrap_or("").trim();
|
||||
if matches!(
|
||||
first.to_ascii_lowercase().as_str(),
|
||||
"initial" | "inherit" | "unset" | "revert" | "revert-layer"
|
||||
) {
|
||||
style.animation_timing.duration_ms = 0.0;
|
||||
} else if let Some(milliseconds) = parse_animation_time_ms(first).filter(|time| *time >= 0.0) {
|
||||
style.animation_timing.duration_ms = milliseconds;
|
||||
}
|
||||
}
|
||||
"animation-delay" => {
|
||||
let first = split_top_level(value, ',').into_iter().next().unwrap_or("").trim();
|
||||
if matches!(
|
||||
first.to_ascii_lowercase().as_str(),
|
||||
"initial" | "inherit" | "unset" | "revert" | "revert-layer"
|
||||
) {
|
||||
style.animation_timing.delay_ms = 0.0;
|
||||
} else if let Some(milliseconds) = parse_animation_time_ms(first) {
|
||||
style.animation_timing.delay_ms = milliseconds;
|
||||
}
|
||||
}
|
||||
"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");
|
||||
if matches!(
|
||||
first.to_ascii_lowercase().as_str(),
|
||||
"initial" | "inherit" | "unset" | "revert" | "revert-layer"
|
||||
) {
|
||||
style.animation_timing.fill_mode = crate::AnimationFillMode::None;
|
||||
} else if let Some(fill) = parse_animation_fill_mode(first) {
|
||||
style.animation_timing.fill_mode = fill;
|
||||
}
|
||||
}
|
||||
"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");
|
||||
if matches!(
|
||||
first.to_ascii_lowercase().as_str(),
|
||||
"initial" | "inherit" | "unset" | "revert" | "revert-layer"
|
||||
) {
|
||||
style.animation_timing.iteration_count = 1.0;
|
||||
} else if let Some(iterations) = parse_animation_iteration_count(first) {
|
||||
style.animation_timing.iteration_count = iterations;
|
||||
}
|
||||
}
|
||||
"animation-direction" => {
|
||||
let first = split_top_level(value, ',').into_iter().next().unwrap_or("").trim();
|
||||
if matches!(
|
||||
first.to_ascii_lowercase().as_str(),
|
||||
"initial" | "inherit" | "unset" | "revert" | "revert-layer"
|
||||
) {
|
||||
style.animation_timing.direction = crate::AnimationDirection::Normal;
|
||||
} else if let Some(direction) = parse_animation_direction(first) {
|
||||
style.animation_timing.direction = direction;
|
||||
}
|
||||
}
|
||||
"animation-play-state" => {
|
||||
let first = split_top_level(value, ',').into_iter().next().unwrap_or("").trim();
|
||||
if matches!(
|
||||
first.to_ascii_lowercase().as_str(),
|
||||
"initial" | "inherit" | "unset" | "revert" | "revert-layer"
|
||||
) {
|
||||
style.animation_timing.play_state = crate::AnimationPlayState::Running;
|
||||
} else if let Some(play_state) = parse_animation_play_state(first) {
|
||||
style.animation_timing.play_state = play_state;
|
||||
}
|
||||
}
|
||||
"z-index" => {
|
||||
style.z_index = match value.trim() {
|
||||
@@ -1510,8 +1582,12 @@ pub(crate) fn supports_declaration(name: &str, value: &str) -> bool {
|
||||
| "opacity"
|
||||
| "animation"
|
||||
| "animation-name"
|
||||
| "animation-duration"
|
||||
| "animation-delay"
|
||||
| "animation-fill-mode"
|
||||
| "animation-iteration-count"
|
||||
| "animation-direction"
|
||||
| "animation-play-state"
|
||||
| "z-index"
|
||||
| "clear"
|
||||
| "vertical-align"
|
||||
@@ -1606,6 +1682,12 @@ pub(crate) fn supports_declaration(name: &str, value: &str) -> bool {
|
||||
"auto" | "wrap" | "balance" | "wrap balance" | "balance wrap"
|
||||
),
|
||||
"text-wrap-style" => matches!(value.to_ascii_lowercase().as_str(), "auto" | "balance"),
|
||||
"animation-duration" => parse_animation_time_ms(value).is_some_and(|time| time >= 0.0),
|
||||
"animation-delay" => parse_animation_time_ms(value).is_some(),
|
||||
"animation-iteration-count" => parse_animation_iteration_count(value).is_some(),
|
||||
"animation-direction" => parse_animation_direction(value).is_some(),
|
||||
"animation-fill-mode" => parse_animation_fill_mode(value).is_some(),
|
||||
"animation-play-state" => parse_animation_play_state(value).is_some(),
|
||||
"float" => matches!(
|
||||
value.to_ascii_lowercase().as_str(),
|
||||
"none" | "left" | "right"
|
||||
@@ -1646,15 +1728,22 @@ pub(crate) fn supports_declaration(name: &str, value: &str) -> bool {
|
||||
}
|
||||
|
||||
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") {
|
||||
if first.is_empty() {
|
||||
return;
|
||||
}
|
||||
if matches!(
|
||||
first.to_ascii_lowercase().as_str(),
|
||||
"initial" | "inherit" | "unset" | "revert" | "revert-layer"
|
||||
) {
|
||||
style.animation_name = None;
|
||||
style.animation_timing = crate::AnimationTiming::default();
|
||||
return;
|
||||
}
|
||||
|
||||
let mut name = None;
|
||||
let mut timing = crate::AnimationTiming::default();
|
||||
let mut time_count = 0usize;
|
||||
for token in split_ws_paren(first) {
|
||||
let lower = token.to_ascii_lowercase();
|
||||
let timing_keyword = matches!(
|
||||
@@ -1663,36 +1752,259 @@ fn apply_animation_shorthand(style: &mut LayoutStyle, value: &str) {
|
||||
) || 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"
|
||||
{
|
||||
if let Some(time) = parse_animation_time_ms(&lower) {
|
||||
if time_count == 0 {
|
||||
if time < 0.0 {
|
||||
return;
|
||||
}
|
||||
timing.duration_ms = time;
|
||||
} else if time_count == 1 {
|
||||
timing.delay_ms = time;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
time_count += 1;
|
||||
} else if let Some(direction) = parse_animation_direction(&lower) {
|
||||
timing.direction = direction;
|
||||
} else if let Some(fill) = parse_animation_fill_mode(&lower) {
|
||||
timing.fill_mode = fill;
|
||||
} else if let Some(play_state) = parse_animation_play_state(&lower) {
|
||||
timing.play_state = play_state;
|
||||
} else if let Some(iterations) = parse_animation_iteration_count(&lower) {
|
||||
timing.iteration_count = iterations;
|
||||
} else if timing_keyword {
|
||||
continue;
|
||||
} else if style.animation_name.is_none() {
|
||||
style.animation_name = Some(token.to_string());
|
||||
} else if lower == "none" {
|
||||
if name.is_some() {
|
||||
return;
|
||||
}
|
||||
} else if name.is_none() {
|
||||
name = Some(token.to_string());
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
style.animation_name = name;
|
||||
style.animation_timing = timing;
|
||||
}
|
||||
|
||||
fn parse_animation_direction(value: &str) -> Option<crate::AnimationDirection> {
|
||||
match value.trim().to_ascii_lowercase().as_str() {
|
||||
"normal" => Some(crate::AnimationDirection::Normal),
|
||||
"reverse" => Some(crate::AnimationDirection::Reverse),
|
||||
"alternate" => Some(crate::AnimationDirection::Alternate),
|
||||
"alternate-reverse" => Some(crate::AnimationDirection::AlternateReverse),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_animation_fill_mode(value: &str) -> Option<crate::AnimationFillMode> {
|
||||
match value.trim().to_ascii_lowercase().as_str() {
|
||||
"none" => Some(crate::AnimationFillMode::None),
|
||||
"forwards" => Some(crate::AnimationFillMode::Forwards),
|
||||
"backwards" => Some(crate::AnimationFillMode::Backwards),
|
||||
"both" => Some(crate::AnimationFillMode::Both),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_animation_play_state(value: &str) -> Option<crate::AnimationPlayState> {
|
||||
match value.trim().to_ascii_lowercase().as_str() {
|
||||
"running" => Some(crate::AnimationPlayState::Running),
|
||||
"paused" => Some(crate::AnimationPlayState::Paused),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_animation_iteration_count(value: &str) -> Option<f32> {
|
||||
let value = value.trim();
|
||||
if value.eq_ignore_ascii_case("infinite") {
|
||||
return Some(f32::INFINITY);
|
||||
}
|
||||
value.parse::<f32>().ok().filter(|count| count.is_finite() && *count >= 0.0)
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum AnimationCalcValue {
|
||||
Number(f32),
|
||||
Milliseconds(f32),
|
||||
}
|
||||
|
||||
impl AnimationCalcValue {
|
||||
fn negated(self) -> Self {
|
||||
match self {
|
||||
Self::Number(value) => Self::Number(-value),
|
||||
Self::Milliseconds(value) => Self::Milliseconds(-value),
|
||||
}
|
||||
}
|
||||
|
||||
fn add(self, other: Self, subtract: bool) -> Option<Self> {
|
||||
let sign = if subtract { -1.0 } else { 1.0 };
|
||||
match (self, other) {
|
||||
(Self::Number(left), Self::Number(right)) => Some(Self::Number(left + sign * right)),
|
||||
(Self::Milliseconds(left), Self::Milliseconds(right)) => {
|
||||
Some(Self::Milliseconds(left + sign * right))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn multiply(self, other: Self) -> Option<Self> {
|
||||
match (self, other) {
|
||||
(Self::Number(left), Self::Number(right)) => Some(Self::Number(left * right)),
|
||||
(Self::Milliseconds(time), Self::Number(number))
|
||||
| (Self::Number(number), Self::Milliseconds(time)) => {
|
||||
Some(Self::Milliseconds(time * number))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn divide(self, other: Self) -> Option<Self> {
|
||||
match (self, other) {
|
||||
(_, Self::Number(0.0)) => None,
|
||||
(Self::Number(left), Self::Number(right)) => Some(Self::Number(left / right)),
|
||||
(Self::Milliseconds(time), Self::Number(number)) => {
|
||||
Some(Self::Milliseconds(time / number))
|
||||
}
|
||||
(Self::Milliseconds(left), Self::Milliseconds(right)) if right != 0.0 => {
|
||||
Some(Self::Number(left / right))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct AnimationCalcParser<'a> {
|
||||
input: &'a [u8],
|
||||
position: usize,
|
||||
}
|
||||
|
||||
impl AnimationCalcParser<'_> {
|
||||
fn skip_whitespace(&mut self) {
|
||||
while self.input.get(self.position).is_some_and(u8::is_ascii_whitespace) {
|
||||
self.position += 1;
|
||||
}
|
||||
}
|
||||
|
||||
fn consume(&mut self, byte: u8) -> bool {
|
||||
self.skip_whitespace();
|
||||
if self.input.get(self.position) == Some(&byte) {
|
||||
self.position += 1;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fn sum(&mut self) -> Option<AnimationCalcValue> {
|
||||
let mut value = self.product()?;
|
||||
loop {
|
||||
if self.consume(b'+') {
|
||||
value = value.add(self.product()?, false)?;
|
||||
} else if self.consume(b'-') {
|
||||
value = value.add(self.product()?, true)?;
|
||||
} else {
|
||||
return Some(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn product(&mut self) -> Option<AnimationCalcValue> {
|
||||
let mut value = self.unary()?;
|
||||
loop {
|
||||
if self.consume(b'*') {
|
||||
value = value.multiply(self.unary()?)?;
|
||||
} else if self.consume(b'/') {
|
||||
value = value.divide(self.unary()?)?;
|
||||
} else {
|
||||
return Some(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn unary(&mut self) -> Option<AnimationCalcValue> {
|
||||
if self.consume(b'+') {
|
||||
self.unary()
|
||||
} else if self.consume(b'-') {
|
||||
Some(self.unary()?.negated())
|
||||
} else {
|
||||
self.primary()
|
||||
}
|
||||
}
|
||||
|
||||
fn primary(&mut self) -> Option<AnimationCalcValue> {
|
||||
if self.consume(b'(') {
|
||||
let value = self.sum()?;
|
||||
self.consume(b')').then_some(value)
|
||||
} else {
|
||||
self.number()
|
||||
}
|
||||
}
|
||||
|
||||
fn number(&mut self) -> Option<AnimationCalcValue> {
|
||||
self.skip_whitespace();
|
||||
let start = self.position;
|
||||
let mut saw_digit = false;
|
||||
while let Some(byte) = self.input.get(self.position) {
|
||||
if byte.is_ascii_digit() {
|
||||
saw_digit = true;
|
||||
self.position += 1;
|
||||
} else if *byte == b'.' {
|
||||
self.position += 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if !saw_digit {
|
||||
return None;
|
||||
}
|
||||
let number = std::str::from_utf8(&self.input[start..self.position])
|
||||
.ok()?
|
||||
.parse::<f32>()
|
||||
.ok()?;
|
||||
if self.input.get(self.position..self.position + 2) == Some(b"ms") {
|
||||
self.position += 2;
|
||||
Some(AnimationCalcValue::Milliseconds(number))
|
||||
} else if self.input.get(self.position) == Some(&b's') {
|
||||
self.position += 1;
|
||||
Some(AnimationCalcValue::Milliseconds(number * 1000.0))
|
||||
} else {
|
||||
Some(AnimationCalcValue::Number(number))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn parse_animation_time_ms(value: &str) -> Option<f32> {
|
||||
let normalized = value.trim().to_ascii_lowercase();
|
||||
let is_calc = normalized.starts_with("calc(") && normalized.ends_with(')');
|
||||
if !is_calc
|
||||
&& normalized
|
||||
.bytes()
|
||||
.any(|byte| matches!(byte, b'+' | b'*' | b'/' | b'(' | b')'))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let expression = if is_calc {
|
||||
&normalized[5..normalized.len() - 1]
|
||||
} else {
|
||||
normalized.as_str()
|
||||
};
|
||||
let mut parser = AnimationCalcParser {
|
||||
input: expression.as_bytes(),
|
||||
position: 0,
|
||||
};
|
||||
let result = parser.sum()?;
|
||||
parser.skip_whitespace();
|
||||
if parser.position != parser.input.len() {
|
||||
return None;
|
||||
}
|
||||
match result {
|
||||
AnimationCalcValue::Milliseconds(milliseconds) if milliseconds.is_finite() => {
|
||||
Some(milliseconds)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse the common CSS Box Alignment values that taffy can represent.
|
||||
@@ -4591,22 +4903,51 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn animation_shorthand_preserves_settled_forward_fill_contract() {
|
||||
fn animation_shorthand_parses_the_first_timing_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);
|
||||
assert_eq!(finite.animation_timing.duration_ms, 600.0);
|
||||
assert_eq!(finite.animation_timing.fill_mode, crate::AnimationFillMode::Forwards);
|
||||
assert_eq!(finite.animation_timing.iteration_count, 1.0);
|
||||
|
||||
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);
|
||||
assert_eq!(infinite.animation_timing.duration_ms, 1000.0);
|
||||
assert_eq!(infinite.animation_timing.fill_mode, crate::AnimationFillMode::None);
|
||||
assert!(infinite.animation_timing.iteration_count.is_infinite());
|
||||
|
||||
let calculated = compute_style(
|
||||
"div",
|
||||
Some(
|
||||
"animation:wave 1.2s linear infinite;\
|
||||
animation-delay:calc(.1s * -2.5);\
|
||||
animation-direction:alternate-reverse;\
|
||||
animation-fill-mode:both;\
|
||||
animation-play-state:paused",
|
||||
),
|
||||
);
|
||||
assert_eq!(calculated.animation_timing.delay_ms, -250.0);
|
||||
assert_eq!(
|
||||
calculated.animation_timing.direction,
|
||||
crate::AnimationDirection::AlternateReverse
|
||||
);
|
||||
assert_eq!(calculated.animation_timing.fill_mode, crate::AnimationFillMode::Both);
|
||||
assert_eq!(calculated.animation_timing.play_state, crate::AnimationPlayState::Paused);
|
||||
assert_eq!(parse_animation_time_ms("calc(1s / 2 + 25ms)"), Some(525.0));
|
||||
assert_eq!(parse_animation_time_ms("1s * 2"), None);
|
||||
|
||||
let reset = compute_style(
|
||||
"div",
|
||||
Some("animation:fade 2s -1s 3 reverse both paused;animation:initial"),
|
||||
);
|
||||
assert_eq!(reset.animation_name, None);
|
||||
assert_eq!(reset.animation_timing, crate::AnimationTiming::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user