From f61800fe4f82a2549c52b168b9bf89416f140881 Mon Sep 17 00:00:00 2001 From: Leonhard Kargl Date: Tue, 28 Jan 2025 02:18:00 +0100 Subject: [PATCH] Introduce GestureController --- src/Gestures/GestureController.vala | 202 ++++++++++++++++++++++++++++ src/Gestures/PropertyTarget.vala | 24 ++++ src/Gestures/SpringTimeline.vala | 194 ++++++++++++++++++++++++++ src/meson.build | 3 + 4 files changed, 423 insertions(+) create mode 100644 src/Gestures/GestureController.vala create mode 100644 src/Gestures/PropertyTarget.vala create mode 100644 src/Gestures/SpringTimeline.vala diff --git a/src/Gestures/GestureController.vala b/src/Gestures/GestureController.vala new file mode 100644 index 000000000..2806f28cb --- /dev/null +++ b/src/Gestures/GestureController.vala @@ -0,0 +1,202 @@ +/* + * Copyright 2025 elementary, Inc. (https://elementary.io) + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Authored by: Leonhard Kargl + */ + +public abstract class Gala.GestureTarget : Object { + /** + * The actor manipulated by the gesture. The associated frame clock + * will be used for animation timelines. + */ + public Clutter.Actor actor { get; construct; } + + public abstract void update (double progress); +} + +public class Gala.GestureController : Object { + /** + * When a gesture ends with a velocity greater than this constant, the action is not cancelled, + * even if the animation threshold has not been reached. + */ + private const double SUCCESS_VELOCITY_THRESHOLD = 0.003; + + /** + * Maximum velocity allowed on gesture update. + */ + private const double MAX_VELOCITY = 0.01; + + // These are for calculations that only have to be done once + public signal void commit (double progress); + + public GestureSettings.GestureAction action { get; construct set; } + public double distance { get; construct set; } + public double overshoot_lower_clamp { get; construct set; default = 0d; } + public double overshoot_upper_clamp { get; construct set; default = 1d; } + + private double _progress = 0; + public double progress { + get { return _progress; } + set { + _progress = value; + + var lower_clamp_int = (int) overshoot_lower_clamp; + var upper_clamp_int = (int) overshoot_upper_clamp; + + double stretched_percentage = 0; + if (progress < lower_clamp_int) { + stretched_percentage = (progress - lower_clamp_int) * - (overshoot_lower_clamp - lower_clamp_int); + } else if (progress > upper_clamp_int) { + stretched_percentage = (progress - upper_clamp_int) * (overshoot_upper_clamp - upper_clamp_int); + } + + var clamped = progress.clamp (lower_clamp_int, upper_clamp_int); + + target.update (clamped + stretched_percentage); + } + } + + public GestureTarget target { get; construct set; } + + private ToucheggBackend touchpad_backend; + private ScrollBackend scroll_backend; + + private bool recognizing = false; + private double previous_percentage; + private uint64 previous_time; + private double previous_delta; + private double velocity; + // Used to check whether to cancel. Necessary because on_end is often called + // with the same percentage as the last update so this is the one before the last update. + private double old_previous; + private int direction_multiplier; + + private Clutter.Timeline? timeline; + + public GestureController (GestureSettings.GestureAction action) { + Object (action: action); + } + + public void enable_touchpad () { + touchpad_backend = ToucheggBackend.get_default (); + touchpad_backend.on_gesture_detected.connect (gesture_detected); + touchpad_backend.on_begin.connect (gesture_begin); + touchpad_backend.on_update.connect (gesture_update); + touchpad_backend.on_end.connect (gesture_end); + } + + public void enable_scroll (Clutter.Actor actor, Clutter.Orientation orientation) { + scroll_backend = new ScrollBackend (actor, orientation, new GestureSettings ()); + scroll_backend.on_gesture_detected.connect (gesture_detected); + scroll_backend.on_begin.connect (gesture_begin); + scroll_backend.on_update.connect (gesture_update); + scroll_backend.on_end.connect (gesture_end); + } + + private void prepare () { + if (timeline != null) { + timeline.stop (); + timeline = null; + } + } + + private bool gesture_detected (GestureBackend backend, Gesture gesture, uint32 timestamp) { + recognizing = GestureSettings.get_action (gesture) == action || GestureSettings.get_action (gesture) == NONE; + + if (recognizing) { + if (gesture.direction == UP || gesture.direction == RIGHT) { + direction_multiplier = 1; + } else { + direction_multiplier = -1; + } + } + + return recognizing; + } + + private void gesture_begin (double percentage, uint64 elapsed_time) { + if (!recognizing) { + return; + } + + prepare (); + + previous_percentage = percentage; + previous_time = elapsed_time; + } + + private void gesture_update (double percentage, uint64 elapsed_time) { + if (!recognizing) { + return; + } + + var updated_delta = previous_delta; + if (elapsed_time != previous_time) { + double distance = percentage - previous_percentage; + double time = (double)(elapsed_time - previous_time); + velocity = (distance / time); + + if (velocity > MAX_VELOCITY) { + velocity = MAX_VELOCITY; + var used_percentage = MAX_VELOCITY * time + previous_percentage; + updated_delta += percentage - used_percentage; + } + } + + progress += calculate_applied_delta (percentage, updated_delta); + + old_previous = previous_percentage; + previous_percentage = percentage; + previous_time = elapsed_time; + previous_delta = updated_delta; + } + + private void gesture_end (double percentage, uint64 elapsed_time) { + if (!recognizing) { + return; + } + + progress += calculate_applied_delta (percentage, previous_delta); + + int completions = (int) Math.round (progress); + + if (velocity.abs () > SUCCESS_VELOCITY_THRESHOLD) { + completions += velocity > 0 ? direction_multiplier : -direction_multiplier; + } + + var lower_clamp_int = (int) overshoot_lower_clamp; + var upper_clamp_int = (int) overshoot_upper_clamp; + + completions = completions.clamp (lower_clamp_int, upper_clamp_int); + + recognizing = false; + + finish (velocity, (double) completions); + + previous_percentage = 0; + previous_time = 0; + previous_delta = 0; + velocity = 0; + old_previous = 0; + direction_multiplier = 0; + } + + private inline double calculate_applied_delta (double percentage, double percentage_delta) { + return ((percentage - percentage_delta) - (previous_percentage - previous_delta)) * direction_multiplier; + } + + private void finish (double velocity, double to) { + var transition = new SpringTimeline (target.actor, progress, to, velocity, 1, 0.5, 500); + transition.progress.connect ((value) => progress = value); + + timeline = transition; + + commit (to); + } + + public void goto (double to) { + prepare (); + finish (0.005, to); + } +} diff --git a/src/Gestures/PropertyTarget.vala b/src/Gestures/PropertyTarget.vala new file mode 100644 index 000000000..0f2c30de1 --- /dev/null +++ b/src/Gestures/PropertyTarget.vala @@ -0,0 +1,24 @@ +/* + * Copyright 2025 elementary, Inc. (https://elementary.io) + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Authored by: Leonhard Kargl + */ + +public class Gala.PropertyTarget : GestureTarget { + /** + * The property that will be animated. To be properly animated it has to be marked as + * animatable in the Clutter documentation and should be numeric. + */ + public string property { get; construct; } + + public Clutter.Interval interval { get; construct; } + + public PropertyTarget (Clutter.Actor actor, string property, Type value_type, Value from_value, Value to_value) { + Object (actor: actor, property: property, interval: new Clutter.Interval.with_values (value_type, from_value, to_value)); + } + + public override void update (double progress) { + actor.set_property (property, interval.compute (progress)); + } +} diff --git a/src/Gestures/SpringTimeline.vala b/src/Gestures/SpringTimeline.vala new file mode 100644 index 000000000..658dcca28 --- /dev/null +++ b/src/Gestures/SpringTimeline.vala @@ -0,0 +1,194 @@ +/* + * Copyright 2025 elementary, Inc. (https://elementary.io) + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Authored by: Leonhard Kargl + */ + +public class Gala.SpringTimeline : Clutter.Timeline { + private const double DELTA = 0.001; + + public signal void progress (double value); + + public double value_from { get; construct; } + public double value_to { get; construct; } + public double initial_velocity { get; construct; } + + public double damping { get; construct; } + public double mass { get; construct; } + public double stiffness { get; construct; } + + public double epsilon { get; construct; default = 0.0001; } + + public bool clamp { get; construct; } + + public SpringTimeline (Clutter.Actor actor, double value_from, double value_to, double initial_velocity, double damping_ratio, double mass, double stiffness) { + var critical_damping = 2 * Math.sqrt (mass * stiffness); + + Object ( + actor: actor, + value_from: value_from, + value_to: value_to, + initial_velocity: initial_velocity, + damping: critical_damping * damping_ratio, + mass: mass, + stiffness: stiffness + ); + + duration = calculate_duration (); + + start (); + } + + private bool approx (double a, double b, double epsilon) { + return (a - b).abs () < epsilon || a == b; + } + + private double oscillate (uint time, out double? velocity) { + double b = damping; + double m = mass; + double k = stiffness; + double v0 = initial_velocity; + + double t = time / 1000.0; + + double beta = b / (2 * m); + double omega0 = Math.sqrt (k / m); + + double x0 = value_from - value_to; + + double envelope = Math.exp (-beta * t); + + /* + * Solutions of the form C1*e^(lambda1*x) + C2*e^(lambda2*x) + * for the differential equation m*ẍ+b*ẋ+kx = 0 + */ + + /* Critically damped */ + /* double.EPSILON is too small for this specific comparison, so we use + * FLT_EPSILON even though it's doubles */ + if (approx (beta, omega0, float.EPSILON)) { + velocity = envelope * (-beta * t * v0 - beta * beta * t * x0 + v0); + + return value_to + envelope * (x0 + (beta * x0 + v0) * t); + } + + /* Underdamped */ + if (beta < omega0) { + double omega1 = Math.sqrt ((omega0 * omega0) - (beta * beta)); + + velocity = envelope * (v0 * Math.cos (omega1 * t) - (x0 * omega1 + (beta * beta * x0 + beta * v0) / (omega1)) * Math.sin (omega1 * t)); + + return value_to + envelope * (x0 * Math.cos (omega1 * t) + ((beta * x0 + v0) / omega1) * Math.sin (omega1 * t)); + } + + /* Overdamped */ + if (beta > omega0) { + double omega2 = Math.sqrt ((beta * beta) - (omega0 * omega0)); + + velocity = envelope * (v0 * Math.cosh (omega2 * t) + (omega2 * x0 - (beta * beta * x0 + beta * v0) / omega2) * Math.sinh (omega2 * t)); + + return value_to + envelope * (x0 * Math.cosh (omega2 * t) + ((beta * x0 + v0) / omega2) * Math.sinh (omega2 * t)); + } + + warning ("Shouldnt reach here"); + velocity = 0; + return 0; + } + + private const int MAX_ITERATIONS = 20000; + private uint get_first_zero () { + /* The first frame is not that important and we avoid finding the trivial 0 + * for in-place animations. */ + uint i = 1; + double y = oscillate (i, null); + + while ((value_to - value_from > double.EPSILON && value_to - y > epsilon) || + (value_from - value_to > double.EPSILON && y - value_to > epsilon) + ) { + if (i > MAX_ITERATIONS) + return 0; + + y = oscillate (++i, null); + } + + return i; + } + + private uint calculate_duration () { + double beta = damping / (2 * mass); + double omega0; + double x0, y0; + double x1, y1; + double m; + + int i = 0; + + if (approx (beta, 0, double.EPSILON) || beta < 0) { + warning ("INFINITE"); + return -1; + } + + if (clamp) { + if (approx (value_to, value_from, double.EPSILON)) + return 0; + + return get_first_zero (); + } + + omega0 = Math.sqrt (stiffness / mass); + + /* + * As first ansatz for the overdamped solution, + * and general estimation for the oscillating ones + * we take the value of the envelope when it's < epsilon + */ + x0 = -Math.log (epsilon) / beta; + + /* double.EPSILON is too small for this specific comparison, so we use + * FLT_EPSILON even though it's doubles */ + if (approx (beta, omega0, float.EPSILON) || beta < omega0) + return (uint) (x0 * 1000); + + /* + * Since the overdamped solution decays way slower than the envelope + * we need to use the value of the oscillation itself. + * Newton's root finding method is a good candidate in this particular case: + * https://en.wikipedia.org/wiki/Newton%27s_method + */ + y0 = oscillate ((uint) (x0 * 1000), null); + m = (oscillate ((uint) ((x0 + DELTA) * 1000), null) - y0) / DELTA; + + x1 = (value_to - y0 + m * x0) / m; + y1 = oscillate ((uint) (x1 * 1000), null); + + while ((value_to - y1).abs () > epsilon) { + if (i>1000) + return 0; + + x0 = x1; + y0 = y1; + + m = (oscillate ((uint) ((x0 + DELTA) * 1000), null) - y0) / DELTA; + + x1 = (value_to - y0 + m * x0) / m; + y1 = oscillate ((uint) (x1 * 1000), null); + i++; + } + + return (uint) (x1 * 1000); + } + + public override void new_frame (int time) { + double velocity; + double val = oscillate (time, out velocity); + + progress (val); + } + + public override void stopped (bool is_finished) { + if (is_finished) { + progress (value_to); + } + } +} diff --git a/src/meson.build b/src/meson.build index d50a6c9f5..f6e3caa43 100644 --- a/src/meson.build +++ b/src/meson.build @@ -35,10 +35,13 @@ gala_bin_sources = files( 'ColorFilters/FilterManager.vala', 'ColorFilters/MonochromeEffect.vala', 'Gestures/Gesture.vala', + 'Gestures/GestureController.vala', 'Gestures/GesturePropertyTransition.vala', 'Gestures/GestureSettings.vala', 'Gestures/GestureTracker.vala', + 'Gestures/PropertyTarget.vala', 'Gestures/ScrollBackend.vala', + 'Gestures/SpringTimeline.vala', 'Gestures/ToucheggBackend.vala', 'HotCorners/Barrier.vala', 'HotCorners/HotCorner.vala',