diff --git a/crates/obscura-render/src/css.rs b/crates/obscura-render/src/css.rs index 50089b3..064777d 100644 --- a/crates/obscura-render/src/css.rs +++ b/crates/obscura-render/src/css.rs @@ -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, + declarations: String, + source_order: usize, +} + +#[derive(Clone, Debug, Default, PartialEq)] +struct Keyframes { + stops: Vec, +} + #[derive(Clone, Debug)] pub(crate) struct ContainerBox { pub(crate) container_type: crate::ContainerType, @@ -468,10 +483,10 @@ pub struct Stylesheet { rules: Vec, /// Index zero is the unconditional sentinel. container_conditions: Vec, - /// 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, + /// Every offset from each `@keyframes` rule. The opacity sampler resolves + /// property-specific segments at the stylesheet's explicit sample time. + keyframes: HashMap, + animation_sample_time: crate::AnimationSampleTime, by_id: HashMap>, by_class: HashMap>, by_local: HashMap>, @@ -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 { + let value = value.trim().to_ascii_lowercase(); + match value.as_str() { + "from" => Some(0.0), + "to" => Some(1.0), + _ => value + .strip_suffix('%')? + .trim() + .parse::() + .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, +) -> Option { + 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 { + 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::() { + if parsed.is_finite() { + opacity = Some(parsed); + } + } + } + } + opacity +} + +fn animation_directed_progress( + timing: &crate::AnimationTiming, + sample_time: crate::AnimationSampleTime, +) -> Option { + 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::>(); + 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::>(), + [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::>(), + [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#"
"#)); + 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::>(), + [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::>(), + [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#""#)); + 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] diff --git a/crates/obscura-render/src/lib.rs b/crates/obscura-render/src/lib.rs index 50817b8..f90b7e9 100644 --- a/crates/obscura-render/src/lib.rs +++ b/crates/obscura-render/src/lib.rs @@ -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, - /// 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, - 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. diff --git a/crates/obscura-render/src/style.rs b/crates/obscura-render/src/style.rs index abe5a56..c5d1e2a 100644 --- a/crates/obscura-render/src/style.rs +++ b/crates/obscura-render/src/style.rs @@ -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::().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::().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 { + 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 { + 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 { + 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 { + let value = value.trim(); + if value.eq_ignore_ascii_case("infinite") { + return Some(f32::INFINITY); + } + value.parse::().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 { + 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 { + 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 { + 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 { + 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 { + 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 { + if self.consume(b'+') { + self.unary() + } else if self.consume(b'-') { + Some(self.unary()?.negated()) + } else { + self.primary() + } + } + + fn primary(&mut self) -> Option { + if self.consume(b'(') { + let value = self.sum()?; + self.consume(b')').then_some(value) + } else { + self.number() + } + } + + fn number(&mut self) -> Option { + 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::() + .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 { + 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]