From c67ecf37f7203a06aab8c2705aa49200adb0de1a Mon Sep 17 00:00:00 2001 From: Gingeh <39150378+Gingeh@users.noreply.github.com> Date: Sun, 3 Nov 2024 14:56:16 +1100 Subject: [PATCH] LibWeb: Implement linear easing according to latest spec --- AK/Vector.h | 4 +- .../WebAnimations/misc/easing-values.txt | 264 ++++++++++++++++++ .../WebAnimations/misc/easing-parsing.html | 2 +- .../WebAnimations/misc/easing-values.html | 54 ++++ .../LibWeb/Animations/AnimationEffect.h | 2 +- .../Libraries/LibWeb/CSS/Parser/Parser.cpp | 63 +++-- .../Libraries/LibWeb/CSS/StyleComputer.cpp | 2 + .../CSS/StyleValues/EasingStyleValue.cpp | 159 ++++++++++- .../LibWeb/CSS/StyleValues/EasingStyleValue.h | 12 +- 9 files changed, 513 insertions(+), 49 deletions(-) create mode 100644 Tests/LibWeb/Text/expected/WebAnimations/misc/easing-values.txt create mode 100644 Tests/LibWeb/Text/input/WebAnimations/misc/easing-values.html diff --git a/AK/Vector.h b/AK/Vector.h index ea8b71fc333..41ac977fa13 100644 --- a/AK/Vector.h +++ b/AK/Vector.h @@ -186,12 +186,12 @@ public: } template - Optional last_matching(TUnaryPredicate const& predicate) + Optional last_matching(TUnaryPredicate const& predicate) const requires(!contains_reference) { for (ssize_t i = size() - 1; i >= 0; --i) { if (predicate(at(i))) { - return at(i); + return Optional(at(i)); } } return {}; diff --git a/Tests/LibWeb/Text/expected/WebAnimations/misc/easing-values.txt b/Tests/LibWeb/Text/expected/WebAnimations/misc/easing-values.txt new file mode 100644 index 00000000000..b6c0a5d8089 --- /dev/null +++ b/Tests/LibWeb/Text/expected/WebAnimations/misc/easing-values.txt @@ -0,0 +1,264 @@ +linear +0: 0.00 +10: 0.10 +20: 0.20 +30: 0.30 +40: 0.40 +50: 0.50 +60: 0.60 +70: 0.70 +80: 0.80 +90: 0.90 +100: 1.00 +linear(0, 1) +0: 0.00 +10: 0.10 +20: 0.20 +30: 0.30 +40: 0.40 +50: 0.50 +60: 0.60 +70: 0.70 +80: 0.80 +90: 0.90 +100: 1.00 +linear(0, 0.5, 1) +0: 0.00 +10: 0.10 +20: 0.20 +30: 0.30 +40: 0.40 +50: 0.50 +60: 0.60 +70: 0.70 +80: 0.80 +90: 0.90 +100: 1.00 +linear(0 0%, 0.5 10%, 1 100%) +0: 0.00 +10: 0.50 +20: 0.56 +30: 0.61 +40: 0.67 +50: 0.72 +60: 0.78 +70: 0.83 +80: 0.89 +90: 0.94 +100: 1.00 +linear(0% 0, 10% 0.5, 100% 1) +0: 0.00 +10: 0.50 +20: 0.56 +30: 0.61 +40: 0.67 +50: 0.72 +60: 0.78 +70: 0.83 +80: 0.89 +90: 0.94 +100: 1.00 +linear(0% 0, 1 100%) +0: 0.00 +10: 0.10 +20: 0.20 +30: 0.30 +40: 0.40 +50: 0.50 +60: 0.60 +70: 0.70 +80: 0.80 +90: 0.90 +100: 1.00 +linear(0 0% 50%, 1 50% 100%) +0: 0.00 +10: 0.00 +20: 0.00 +30: 0.00 +40: 0.00 +50: 1.00 +60: 1.00 +70: 1.00 +80: 1.00 +90: 1.00 +100: 1.00 +linear(0.5 0% 100%) +0: 0.50 +10: 0.50 +20: 0.50 +30: 0.50 +40: 0.50 +50: 0.50 +60: 0.50 +70: 0.50 +80: 0.50 +90: 0.50 +100: 0.50 +ease +0: 0.00 +10: 0.09 +20: 0.30 +30: 0.51 +40: 0.68 +50: 0.80 +60: 0.89 +70: 0.94 +80: 0.98 +90: 0.99 +100: 1.00 +ease-in +0: 0.00 +10: 0.02 +20: 0.06 +30: 0.13 +40: 0.21 +50: 0.32 +60: 0.43 +70: 0.55 +80: 0.69 +90: 0.84 +100: 1.00 +ease-out +0: 0.00 +10: 0.16 +20: 0.31 +30: 0.45 +40: 0.57 +50: 0.68 +60: 0.79 +70: 0.87 +80: 0.94 +90: 0.98 +100: 1.00 +ease-in-out +0: 0.00 +10: 0.02 +20: 0.08 +30: 0.19 +40: 0.33 +50: 0.50 +60: 0.67 +70: 0.81 +80: 0.92 +90: 0.98 +100: 1.00 +cubic-bezier(0, 0, 0, 0) +0: 0.00 +10: 0.10 +20: 0.20 +30: 0.30 +40: 0.40 +50: 0.50 +60: 0.60 +70: 0.70 +80: 0.80 +90: 0.90 +100: 1.00 +cubic-bezier(1, 1, 1, 1) +0: 0.00 +10: 0.10 +20: 0.20 +30: 0.30 +40: 0.40 +50: 0.50 +60: 0.60 +70: 0.70 +80: 0.80 +90: 0.90 +100: 1.00 +cubic-bezier(1, 1000, 1, 1000) +0: 0.00 +10: 1.00 +20: 1.00 +30: 1.00 +40: 1.00 +50: 1.00 +60: 1.00 +70: 1.00 +80: 1.00 +90: 1.00 +100: 1.00 +step-end +0: 0.00 +10: 0.00 +20: 0.00 +30: 0.00 +40: 0.00 +50: 0.00 +60: 0.00 +70: 0.00 +80: 0.00 +90: 0.00 +100: 1.00 +steps(1000) +0: 0.00 +10: 0.10 +20: 0.20 +30: 0.30 +40: 0.40 +50: 0.50 +60: 0.60 +70: 0.70 +80: 0.80 +90: 0.90 +100: 1.00 +steps(10, jump-start) +0: 0.10 +10: 0.20 +20: 0.30 +30: 0.40 +40: 0.50 +50: 0.60 +60: 0.70 +70: 0.80 +80: 0.90 +90: 1.00 +100: 1.00 +steps(10, jump-end) +0: 0.00 +10: 0.10 +20: 0.20 +30: 0.30 +40: 0.40 +50: 0.50 +60: 0.60 +70: 0.70 +80: 0.80 +90: 0.90 +100: 1.00 +steps(10, jump-none) +0: 0.00 +10: 0.11 +20: 0.22 +30: 0.33 +40: 0.44 +50: 0.56 +60: 0.67 +70: 0.78 +80: 0.89 +90: 1.00 +100: 1.00 +steps(10, jump-both) +0: 0.09 +10: 0.18 +20: 0.27 +30: 0.36 +40: 0.45 +50: 0.55 +60: 0.64 +70: 0.73 +80: 0.82 +90: 0.91 +100: 1.00 +steps(10, end) +0: 0.00 +10: 0.10 +20: 0.20 +30: 0.30 +40: 0.40 +50: 0.50 +60: 0.60 +70: 0.70 +80: 0.80 +90: 0.90 +100: 1.00 diff --git a/Tests/LibWeb/Text/input/WebAnimations/misc/easing-parsing.html b/Tests/LibWeb/Text/input/WebAnimations/misc/easing-parsing.html index d86440e8c25..2c560400f5f 100644 --- a/Tests/LibWeb/Text/input/WebAnimations/misc/easing-parsing.html +++ b/Tests/LibWeb/Text/input/WebAnimations/misc/easing-parsing.html @@ -13,6 +13,7 @@ "linear(5% 0, 10% 0.5, 100% 1)", "linear(5% 0, 1 100%)", "linear(-14, 27 210%)", + "linear(0.5 5% 10%)", "ease", "ease-in", "ease-out", @@ -39,7 +40,6 @@ "linear(5 10)", "linear(5% 10%)", "linear(0.5 5% 10)", - "linear(0.5 5% 10%)", "cubic-bezier(0, 0, 0)", "cubic-bezier(2, 0, 0, 0)", "cubic-bezier(0, 0, 2, 0)", diff --git a/Tests/LibWeb/Text/input/WebAnimations/misc/easing-values.html b/Tests/LibWeb/Text/input/WebAnimations/misc/easing-values.html new file mode 100644 index 00000000000..8146de10e3c --- /dev/null +++ b/Tests/LibWeb/Text/input/WebAnimations/misc/easing-values.html @@ -0,0 +1,54 @@ + + + + + diff --git a/Userland/Libraries/LibWeb/Animations/AnimationEffect.h b/Userland/Libraries/LibWeb/Animations/AnimationEffect.h index fe51fd0c530..bcc584fe6f8 100644 --- a/Userland/Libraries/LibWeb/Animations/AnimationEffect.h +++ b/Userland/Libraries/LibWeb/Animations/AnimationEffect.h @@ -178,7 +178,7 @@ protected: JS::GCPtr m_associated_animation {}; // https://www.w3.org/TR/web-animations-1/#time-transformations - CSS::EasingStyleValue::Function m_timing_function { CSS::EasingStyleValue::Linear {} }; + CSS::EasingStyleValue::Function m_timing_function { CSS::EasingStyleValue::Linear::identity() }; // Used for calculating transitions in StyleComputer Phase m_previous_phase { Phase::Idle }; diff --git a/Userland/Libraries/LibWeb/CSS/Parser/Parser.cpp b/Userland/Libraries/LibWeb/CSS/Parser/Parser.cpp index 473544df297..91329178db7 100644 --- a/Userland/Libraries/LibWeb/CSS/Parser/Parser.cpp +++ b/Userland/Libraries/LibWeb/CSS/Parser/Parser.cpp @@ -6236,19 +6236,19 @@ RefPtr Parser::parse_easing_value(TokenStream& to if (part.is(Token::Type::Ident)) { auto name = part.token().ident(); auto maybe_simple_easing = [&] -> RefPtr { - if (name == "linear"sv) - return EasingStyleValue::create(EasingStyleValue::Linear {}); - if (name == "ease"sv) + if (name.equals_ignoring_ascii_case("linear"sv)) + return EasingStyleValue::create(EasingStyleValue::Linear::identity()); + if (name.equals_ignoring_ascii_case("ease"sv)) return EasingStyleValue::create(EasingStyleValue::CubicBezier::ease()); - if (name == "ease-in"sv) + if (name.equals_ignoring_ascii_case("ease-in"sv)) return EasingStyleValue::create(EasingStyleValue::CubicBezier::ease_in()); - if (name == "ease-out"sv) + if (name.equals_ignoring_ascii_case("ease-out"sv)) return EasingStyleValue::create(EasingStyleValue::CubicBezier::ease_out()); - if (name == "ease-in-out"sv) + if (name.equals_ignoring_ascii_case("ease-in-out"sv)) return EasingStyleValue::create(EasingStyleValue::CubicBezier::ease_in_out()); - if (name == "step-start"sv) + if (name.equals_ignoring_ascii_case("step-start"sv)) return EasingStyleValue::create(EasingStyleValue::Steps::step_start()); - if (name == "step-end"sv) + if (name.equals_ignoring_ascii_case("step-end"sv)) return EasingStyleValue::create(EasingStyleValue::Steps::step_end()); return {}; }(); @@ -6271,33 +6271,38 @@ RefPtr Parser::parse_easing_value(TokenStream& to argument.remove_all_matching([](auto& value) { return value.is(Token::Type::Whitespace); }); auto name = part.function().name; - if (name == "linear"sv) { + if (name.equals_ignoring_ascii_case("linear"sv)) { + // linear() = linear( [ && {0,2} ]# ) Vector stops; for (auto const& argument : comma_separated_arguments) { - if (argument.is_empty() || argument.size() > 2) - return nullptr; + TokenStream argument_tokens { argument }; - Optional offset; - Optional position; + Optional output; + Optional first_input; + Optional second_input; - for (auto const& part : argument) { - if (part.is(Token::Type::Number)) { - if (offset.has_value()) - return nullptr; - offset = part.token().number_value(); - } else if (part.is(Token::Type::Percentage)) { - if (position.has_value()) - return nullptr; - position = part.token().percentage(); - } else { - return nullptr; - }; + if (argument_tokens.next_token().is(Token::Type::Number)) + output = argument_tokens.consume_a_token().token().number_value(); + + if (argument_tokens.next_token().is(Token::Type::Percentage)) { + first_input = argument_tokens.consume_a_token().token().percentage() / 100; + if (argument_tokens.next_token().is(Token::Type::Percentage)) { + second_input = argument_tokens.consume_a_token().token().percentage() / 100; + } } - if (!offset.has_value()) + if (argument_tokens.next_token().is(Token::Type::Number)) { + if (output.has_value()) + return nullptr; + output = argument_tokens.consume_a_token().token().number_value(); + } + + if (argument_tokens.has_next_token() || !output.has_value()) return nullptr; - stops.append({ offset.value(), move(position) }); + stops.append({ output.value(), first_input, first_input.has_value() }); + if (second_input.has_value()) + stops.append({ output.value(), second_input, true }); } if (stops.is_empty()) @@ -6307,7 +6312,7 @@ RefPtr Parser::parse_easing_value(TokenStream& to return EasingStyleValue::create(EasingStyleValue::Linear { move(stops) }); } - if (name == "cubic-bezier") { + if (name.equals_ignoring_ascii_case("cubic-bezier"sv)) { if (comma_separated_arguments.size() != 4) return nullptr; @@ -6332,7 +6337,7 @@ RefPtr Parser::parse_easing_value(TokenStream& to return EasingStyleValue::create(bezier); } - if (name == "steps") { + if (name.equals_ignoring_ascii_case("steps"sv)) { if (comma_separated_arguments.is_empty() || comma_separated_arguments.size() > 2) return nullptr; diff --git a/Userland/Libraries/LibWeb/CSS/StyleComputer.cpp b/Userland/Libraries/LibWeb/CSS/StyleComputer.cpp index c40849658ab..f96349ac827 100644 --- a/Userland/Libraries/LibWeb/CSS/StyleComputer.cpp +++ b/Userland/Libraries/LibWeb/CSS/StyleComputer.cpp @@ -994,6 +994,8 @@ void StyleComputer::collect_animation_into(DOM::Element& element, Optionalkey_frame_set()->keyframes_by_key; + // FIXME: Support progress values outside [0-1] + output_progress = clamp(output_progress.value(), 0, 1); auto key = static_cast(output_progress.value() * 100.0 * Animations::KeyframeEffect::AnimationKeyFrameKeyScaleFactor); auto matching_keyframe_it = keyframes.find_largest_not_above_iterator(key); if (matching_keyframe_it.is_end()) { diff --git a/Userland/Libraries/LibWeb/CSS/StyleValues/EasingStyleValue.cpp b/Userland/Libraries/LibWeb/CSS/StyleValues/EasingStyleValue.cpp index 00a19d63559..70cf14278df 100644 --- a/Userland/Libraries/LibWeb/CSS/StyleValues/EasingStyleValue.cpp +++ b/Userland/Libraries/LibWeb/CSS/StyleValues/EasingStyleValue.cpp @@ -14,6 +14,13 @@ namespace Web::CSS { +// https://drafts.csswg.org/css-easing-1/#valdef-easing-function-linear +EasingStyleValue::Linear EasingStyleValue::Linear::identity() +{ + static Linear linear { { { 0, {}, false }, { 1, {}, false } } }; + return linear; +} + // NOTE: Magic cubic bezier values from https://www.w3.org/TR/css-easing-1/#valdef-cubic-bezier-easing-function-ease EasingStyleValue::CubicBezier EasingStyleValue::CubicBezier::ease() @@ -57,30 +64,154 @@ bool EasingStyleValue::CubicBezier::operator==(Web::CSS::EasingStyleValue::Cubic return x1 == other.x1 && y1 == other.y1 && x2 == other.x2 && y2 == other.y2; } -double EasingStyleValue::Linear::evaluate_at(double input_progress, bool) const +// https://drafts.csswg.org/css-easing/#linear-canonicalization +EasingStyleValue::Linear::Linear(Vector stops) { - return input_progress; + // To canonicalize a linear() function’s control points, perform the following: + + // 1. If the first control point lacks an input progress value, set its input progress value to 0. + if (!stops.first().input.has_value()) + stops.first().input = 0; + + // 2. If the last control point lacks an input progress value, set its input progress value to 1. + if (!stops.last().input.has_value()) + stops.last().input = 1; + + // 3. If any control point has an input progress value that is less than + // the input progress value of any preceding control point, + // set its input progress value to the largest input progress value of any preceding control point. + double largest_input = 0; + for (auto stop : stops) { + if (stop.input.has_value()) { + if (stop.input.value() < largest_input) { + stop.input = largest_input; + } else { + largest_input = stop.input.value(); + } + } + } + + // 4. If any control point still lacks an input progress value, + // then for each contiguous run of such control points, + // set their input progress values so that they are evenly spaced + // between the preceding and following control points with input progress values. + Optional run_start_idx; + for (size_t idx = 0; idx < stops.size(); idx++) { + auto stop = stops[idx]; + if (stop.input.has_value() && run_start_idx.has_value()) { + // Note: this stop is immediately after a run + // set inputs of [start, idx-1] stops to be evenly spaced between start-1 and idx + auto start_input = stops[run_start_idx.value() - 1].input.value(); + auto end_input = stops[idx].input.value(); + auto run_stop_count = idx - run_start_idx.value() + 1; + auto delta = (end_input - start_input) / run_stop_count; + for (size_t run_idx = 0; run_idx < run_stop_count; run_idx++) { + stops[run_idx + run_start_idx.value() - 1].input = start_input + delta * run_idx; + } + run_start_idx = {}; + } else if (!stop.input.has_value() && !run_start_idx.has_value()) { + // Note: this stop is the start of a run + run_start_idx = idx; + } + } + + this->stops = move(stops); } +// https://drafts.csswg.org/css-easing/#linear-easing-function-output +double EasingStyleValue::Linear::evaluate_at(double input_progress, bool before_flag) const +{ + // To calculate linear easing output progress for a given linear easing function func, + // an input progress value inputProgress, and an optional before flag (defaulting to false), + // perform the following: + + // 1. Let points be func’s control points. + // 2. If points holds only a single item, return the output progress value of that item. + if (stops.size() == 1) + return stops[0].output; + + // 3. If inputProgress matches the input progress value of the first point in points, + // and the before flag is true, return the first point’s output progress value. + if (input_progress == stops[0].input.value() && before_flag) + return stops[0].output; + + // 4. If inputProgress matches the input progress value of at least one point in points, + // return the output progress value of the last such point. + auto maybe_match = stops.last_matching([&](auto& stop) { return input_progress == stop.input.value(); }); + if (maybe_match.has_value()) + return maybe_match->output; + + // 5. Otherwise, find two control points in points, A and B, which will be used for interpolation: + Stop A; + Stop B; + + if (input_progress < stops[0].input.value()) { + // 1. If inputProgress is smaller than any input progress value in points, + // let A and B be the first two items in points. + // If A and B have the same input progress value, return A’s output progress value. + A = stops[0]; + B = stops[1]; + if (A.input == B.input) + return A.output; + } else if (input_progress > stops.last().input.value()) { + // 2. If inputProgress is larger than any input progress value in points, + // let A and B be the last two items in points. + // If A and B have the same input progress value, return B’s output progress value. + A = stops[stops.size() - 2]; + B = stops[stops.size() - 1]; + if (A.input == B.input) + return B.output; + } else { + // 3. Otherwise, let A be the last control point whose input progress value is smaller than inputProgress, + // and let B be the first control point whose input progress value is larger than inputProgress. + A = stops.last_matching([&](auto& stop) { return stop.input.value() < input_progress; }).value(); + B = stops.first_matching([&](auto& stop) { return stop.input.value() > input_progress; }).value(); + } + + // 6. Linearly interpolate (or extrapolate) inputProgress along the line defined by A and B, and return the result. + auto factor = (input_progress - A.input.value()) / (B.input.value() - A.input.value()); + return A.output + factor * (B.output - A.output); +} + +// https://drafts.csswg.org/css-easing/#linear-easing-function-serializing String EasingStyleValue::Linear::to_string() const { - StringBuilder builder; - builder.append("linear"sv); - if (!stops.is_empty()) { - builder.append('('); + // The linear keyword is serialized as itself. + if (*this == identity()) + return "linear"_string; - bool first = true; - for (auto const& stop : stops) { - if (!first) - builder.append(", "sv); + // To serialize a linear() function: + // 1. Let s be the string "linear(". + StringBuilder builder; + builder.append("linear("sv); + + // 2. Serialize each control point of the function, + // concatenate the results using the separator ", ", + // and append the result to s. + bool first = true; + for (auto stop : stops) { + if (first) { first = false; - builder.appendff("{}"sv, stop.offset); - if (stop.position.has_value()) - builder.appendff(" {}"sv, stop.position.value()); + } else { + builder.append(", "sv); } - builder.append(')'); + // To serialize a linear() control point: + // 1. Let s be the serialization, as a , of the control point’s output progress value. + builder.appendff("{}", stop.output); + + // 2. If the control point originally lacked an input progress value, return s. + // 3. Otherwise, append " " (U+0020 SPACE) to s, + // then serialize the control point’s input progress value as a and append it to s. + if (stop.had_explicit_input) { + builder.appendff(" {}%", stop.input.value() * 100); + } + + // 4. Return s. } + + // 4. Append ")" to s, and return it. + builder.append(')'); return MUST(builder.to_string()); } diff --git a/Userland/Libraries/LibWeb/CSS/StyleValues/EasingStyleValue.h b/Userland/Libraries/LibWeb/CSS/StyleValues/EasingStyleValue.h index c7f62d3a499..c9db6bdc5a0 100644 --- a/Userland/Libraries/LibWeb/CSS/StyleValues/EasingStyleValue.h +++ b/Userland/Libraries/LibWeb/CSS/StyleValues/EasingStyleValue.h @@ -17,9 +17,15 @@ namespace Web::CSS { class EasingStyleValue final : public StyleValueWithDefaultOperators { public: struct Linear { + static Linear identity(); + struct Stop { - double offset; - Optional position; + double output; + Optional input; + + // "NOTE: Serialization relies on whether or not an input progress value was originally supplied, + // so that information should be retained in the internal representation." + bool had_explicit_input; bool operator==(Stop const&) const = default; }; @@ -30,6 +36,8 @@ public: double evaluate_at(double input_progress, bool before_flag) const; String to_string() const; + + Linear(Vector stops); }; struct CubicBezier {