Multitasking view 1:1 switch desktop animation with scroll events (#1051)

This commit is contained in:
José Expósito 2021-02-23 01:34:43 +01:00 committed by GitHub
parent 625d40cb1a
commit 3a0c856d7a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 590 additions and 40 deletions

View File

@ -31,5 +31,7 @@ namespace Gala {
// Duration of the workspace switch animation
WORKSPACE_SWITCH_MIN = 300,
WORKSPACE_SWITCH = 400,
// Duration of the nudge animation when trying to switch to at the end of the workspace list
NUDGE = 360,
}
}

View File

@ -67,7 +67,7 @@ public class Gala.GestureAnimationDirector : Object {
velocity = 0;
}
public GestureAnimationDirector(int min_animation_duration, int max_animation_duration) {
public GestureAnimationDirector (int min_animation_duration, int max_animation_duration) {
Object (min_animation_duration: min_animation_duration, max_animation_duration: max_animation_duration);
}

40
src/Gestures/Gesture.vala Normal file
View File

@ -0,0 +1,40 @@
/*
* Copyright 2021 elementary, Inc (https://elementary.io)
* 2021 José Expósito <jose.exposito89@gmail.com>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
namespace Gala {
public enum GestureDirection {
UNKNOWN = 0,
// GestureType.SWIPE and GestureType.SCROLL
UP = 1,
DOWN = 2,
LEFT = 3,
RIGHT = 4,
// GestureType.PINCH
IN = 5,
OUT = 6,
}
public class Gesture {
public Gdk.EventType type;
public GestureDirection direction;
public int fingers;
public Gdk.InputSource performed_on_device_type;
}
}

View File

@ -0,0 +1,50 @@
/*
* Copyright 2021 elementary, Inc (https://elementary.io)
* 2021 José Expósito <jose.exposito89@gmail.com>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
/**
* Utility class to access the gesture settings. Easily accessible through GestureTracker.settings.
*/
public class Gala.GestureSettings : Object {
private static GLib.Settings gala_settings;
private static GLib.Settings touchpad_settings;
public const string MULTITASKING_ENABLED = "multitasking-gesture-enabled";
public const string MULTITASKING_FINGERS = "multitasking-gesture-fingers";
public const string WORKSPACE_ENABLED = "workspaces-gesture-enabled";
public const string WORKSPACE_FINGERS = "workspaces-gesture-fingers";
static construct {
gala_settings = new GLib.Settings ("io.elementary.desktop.wm.gestures");
touchpad_settings = new GLib.Settings ("org.gnome.desktop.peripherals.touchpad");
}
public bool is_natural_scroll_enabled (Gdk.InputSource device_type) {
return (device_type == Gdk.InputSource.TOUCHSCREEN)
? true
: touchpad_settings.get_boolean ("natural-scroll");
}
public bool is_gesture_enabled (string setting_id) {
return gala_settings.get_boolean (setting_id);
}
public int gesture_fingers (string setting_id) {
return gala_settings.get_int (setting_id);
}
}

View File

@ -0,0 +1,268 @@
/*
* Copyright 2021 elementary, Inc (https://elementary.io)
* 2021 José Expósito <jose.exposito89@gmail.com>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
/**
* Allow to use multi-touch gestures from different sources (backends).
* Usage:
* - Create a new instance of the class
* - Use the enable_* methods to enable different backends
* - Connect the on_gesture_detected to your code
* - When on_gesture_detected is emitted, if you want to handle the gesture, call connect_handlers
* to start receiving events
* - on_begin will be emitted once right after on_gesture_detected
* - on_update will be emitted 0 or more times
* - on_end will be emitted once when the gesture end
* - When on_end is emitted, the handler connected with connect_handlers will be automatically
* disconnected and you will only receive on_gesture_detected signals
* - The enabled flag is usually disabled on_end and re-enabled once the end animation finish. In
* this way, new gestures are not received while animating
*/
public class Gala.GestureTracker : Object {
/**
* Percentage of the animation to be completed to apply the action.
*/
private const double SUCCESS_PERCENTAGE_THRESHOLD = 0.2;
/**
* 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;
/**
* When a gesture ends with less velocity that this constant, this velocity is used instead.
*/
private const double ANIMATION_BASE_VELOCITY = 0.002;
/**
* Maximum velocity allowed on gesture update.
*/
private const double MAX_VELOCITY = 0.01;
/**
* Multiplier used to match libhandy's animation duration.
*/
private const int DURATION_MULTIPLIER = 3;
public GestureSettings settings { get; construct; }
public int min_animation_duration { get; construct; }
public int max_animation_duration { get; construct; }
/**
* Property to control when event signals are emitted or not.
*/
public bool enabled { get; set; default = true; }
/**
* Emitted when a new gesture is detected.
* If the receiving code needs to handle this gesture, it should call to connect_handlers to
* start receiving updates.
* @param gesture Information about the gesture.
*/
public signal void on_gesture_detected (Gesture gesture);
/**
* Emitted right after on_gesture_detected with the initial gesture information.
* @param percentage Value between 0 and 1.
*/
public signal void on_begin (double percentage);
/**
* Called every time the percentage changes.
* @param percentage Value between 0 and 1.
*/
public signal void on_update (double percentage);
/**
* @param percentage Value between 0 and 1.
* @param cancel_action
* @param calculated_duration
*/
public signal void on_end (double percentage, bool cancel_action, int calculated_duration);
public delegate void OnBegin (double percentage);
public delegate void OnUpdate (double percentage);
public delegate void OnEnd (double percentage, bool cancel_action, int calculated_duration);
/**
* Scroll backend used if enable_scroll is called.
*/
private ScrollBackend scroll_backend;
private Gee.ArrayList<ulong> handlers;
private double previous_percentage;
private uint64 previous_time;
private double percentage_delta;
private double velocity;
construct {
settings = new GestureSettings ();
handlers = new Gee.ArrayList<ulong> ();
previous_percentage = 0;
previous_time = 0;
percentage_delta = 0;
velocity = 0;
}
public GestureTracker (int min_animation_duration, int max_animation_duration) {
Object (min_animation_duration: min_animation_duration, max_animation_duration: max_animation_duration);
}
/**
* Allow to receive scroll gestures.
* @param actor Clutter actor that will receive the scroll events.
* @param orientation If we are interested in the horizontal or vertical axis.
*/
public void enable_scroll (Clutter.Actor actor, Clutter.Orientation orientation) {
scroll_backend = new ScrollBackend (actor, orientation);
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);
}
public void connect_handlers (owned OnBegin? on_begin_handler, owned OnUpdate? on_update_handler, owned OnEnd? on_end_handler) {
if (on_begin_handler != null) {
ulong handler_id = on_begin.connect ((percentage) => on_begin_handler (percentage));
handlers.add (handler_id);
}
if (on_update_handler != null) {
ulong handler_id = on_update.connect ((percentage) => on_update_handler (percentage));
handlers.add (handler_id);
}
if (on_end_handler != null) {
ulong handler_id = on_end.connect ((percentage, cancel_action, duration) => on_end_handler (percentage, cancel_action, duration));
handlers.add (handler_id);
}
}
private void disconnect_all_handlers () {
foreach (var handler in handlers) {
disconnect (handler);
}
handlers.clear ();
}
/**
* Utility method to calculate the current animation value based on the percentage of the
* gesture performed.
* Animations are always linear, as they are 1:1 to the user's movement.
* @param initial_value Animation start value.
* @param target_value Animation end value.
* @param percentage Current animation percentage.
* @param rounded If the returned value should be rounded to match physical pixels.
* Default to false because some animations, like for example scaling an actor, use intermediate
* values not divisible by physical pixels.
* @returns The linear animation value at the specified percentage.
*/
public static float animation_value (float initial_value, float target_value, double percentage, bool rounded = false) {
float value = ((target_value - initial_value) * (float) percentage) + initial_value;
if (rounded) {
var scale_factor = InternalUtils.get_ui_scaling_factor ();
value = (float) Math.round (value * scale_factor) / scale_factor;
}
return value;
}
private void gesture_detected (Gesture gesture) {
if (!enabled) {
return;
}
on_gesture_detected (gesture);
}
private void gesture_begin (double percentage, uint64 elapsed_time) {
if (!enabled) {
return;
}
on_begin (percentage);
previous_percentage = percentage;
previous_time = elapsed_time;
}
private void gesture_update (double percentage, uint64 elapsed_time) {
if (!enabled) {
return;
}
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;
percentage_delta += percentage - used_percentage;
}
}
on_update (applied_percentage (percentage, percentage_delta));
previous_percentage = percentage;
previous_time = elapsed_time;
}
private void gesture_end (double percentage, uint64 elapsed_time) {
if (!enabled) {
return;
}
double end_percentage = applied_percentage (percentage, percentage_delta);
bool cancel_action = (end_percentage < SUCCESS_PERCENTAGE_THRESHOLD)
&& ((end_percentage <= previous_percentage) && (velocity < SUCCESS_VELOCITY_THRESHOLD));
int calculated_duration = calculate_end_animation_duration (end_percentage, cancel_action);
on_end (end_percentage, cancel_action, calculated_duration);
disconnect_all_handlers ();
previous_percentage = 0;
previous_time = 0;
percentage_delta = 0;
velocity = 0;
}
private static double applied_percentage (double percentage, double percentage_delta) {
return (percentage - percentage_delta).clamp (0, 1);
}
/**
* Calculates the end animation duration using the current gesture velocity.
*/
private int calculate_end_animation_duration (double end_percentage, bool cancel_action) {
double animation_velocity = (velocity > ANIMATION_BASE_VELOCITY)
? velocity
: ANIMATION_BASE_VELOCITY;
double pending_percentage = cancel_action ? end_percentage : 1 - end_percentage;
int duration = ((int)(pending_percentage / animation_velocity).abs () * DURATION_MULTIPLIER)
.clamp (min_animation_duration, max_animation_duration);
return duration;
}
}

View File

@ -0,0 +1,126 @@
/*
* Copyright 2021 elementary, Inc (https://elementary.io)
* 2021 José Expósito <jose.exposito89@gmail.com>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
/**
* This gesture backend transforms the touchpad scroll events received by an actor into gestures.
*/
public class Gala.ScrollBackend : Object {
// Mutter does not expose the size of the touchpad, so we use the same values as GTK apps.
// From GNOME Shell, TOUCHPAD_BASE_[WIDTH|HEIGHT] / SCROLL_MULTIPLIER
// https://gitlab.gnome.org/GNOME/gnome-shell/-/blob/master/js/ui/swipeTracker.js
private const double FINISH_DELTA_HORIZONTAL = 40;
private const double FINISH_DELTA_VERTICAL = 30;
public signal void on_gesture_detected (Gesture gesture);
public signal void on_begin (double delta, uint64 time);
public signal void on_update (double delta, uint64 time);
public signal void on_end (double delta, uint64 time);
public Clutter.Actor actor { get; construct; }
public Clutter.Orientation orientation { get; construct; }
private bool started;
private double delta_x;
private double delta_y;
private GestureDirection direction;
construct {
started = false;
delta_x = 0;
delta_y = 0;
direction = GestureDirection.UNKNOWN;
}
public ScrollBackend (Clutter.Actor actor, Clutter.Orientation orientation) {
Object (actor: actor, orientation: orientation);
actor.scroll_event.connect (on_scroll_event);
}
private bool on_scroll_event (Clutter.ScrollEvent event) {
if (!can_handle_event (event)) {
return false;
}
uint64 time = event.get_time ();
double x, y;
event.get_scroll_delta (out x, out y);
delta_x += x;
delta_y += y;
if (!started) {
if (delta_x != 0 || delta_y != 0) {
Gesture gesture = build_gesture (delta_x, delta_y, orientation);
started = true;
direction = gesture.direction;
on_gesture_detected (gesture);
double delta = calculate_delta (delta_x, delta_y, direction);
on_begin (delta, time);
}
} else {
double delta = calculate_delta (delta_x, delta_y, direction);
if (x == 0 && y == 0) {
started = false;
delta_x = 0;
delta_y = 0;
direction = GestureDirection.UNKNOWN;
on_end (delta, time);
} else {
on_update (delta, time);
}
}
return true;
}
private static bool can_handle_event (Clutter.ScrollEvent event) {
return event.get_type () == Clutter.EventType.SCROLL
&& event.get_source_device ().get_device_type () == Clutter.InputDeviceType.TOUCHPAD_DEVICE
&& event.get_scroll_direction () == Clutter.ScrollDirection.SMOOTH;
}
private static Gesture build_gesture (double delta_x, double delta_y, Clutter.Orientation orientation) {
GestureDirection direction;
if (orientation == Clutter.Orientation.HORIZONTAL) {
direction = delta_x > 0 ? GestureDirection.RIGHT : GestureDirection.LEFT;
} else {
direction = delta_y > 0 ? GestureDirection.DOWN : GestureDirection.UP;
}
return new Gesture () {
type = Gdk.EventType.SCROLL,
direction = direction,
fingers = 2,
performed_on_device_type = Gdk.InputSource.TOUCHPAD
};
}
private static double calculate_delta (double delta_x, double delta_y, GestureDirection direction) {
bool is_horizontal = (direction == GestureDirection.LEFT || direction == GestureDirection.RIGHT);
double used_delta = is_horizontal ? delta_x : delta_y;
double finish_delta = is_horizontal ? FINISH_DELTA_HORIZONTAL : FINISH_DELTA_VERTICAL;
bool is_positive = (direction == GestureDirection.RIGHT || direction == GestureDirection.DOWN);
double clamp_low = is_positive ? 0 : -1;
double clamp_high = is_positive ? 1 : 0;
double normalized_delta = (used_delta / finish_delta).clamp (clamp_low, clamp_high).abs ();
return normalized_delta;
}
}

View File

@ -29,6 +29,7 @@ namespace Gala {
public const AnimationMode ANIMATION_MODE = AnimationMode.EASE_OUT_QUAD;
private GestureAnimationDirector gesture_animation_director;
private GestureTracker gesture_tracker;
const int SMOOTH_SCROLL_DELAY = 500;
@ -39,8 +40,6 @@ namespace Gala {
bool opened = false;
bool animating = false;
bool is_smooth_scrolling = false;
List<MonitorClone> window_containers_monitors;
IconGroupContainer icon_groups;
@ -61,6 +60,10 @@ namespace Gala {
gesture_animation_director = new GestureAnimationDirector (ANIMATION_DURATION, ANIMATION_DURATION);
gesture_tracker = new GestureTracker (AnimationDuration.WORKSPACE_SWITCH_MIN, AnimationDuration.WORKSPACE_SWITCH);
gesture_tracker.enable_scroll (this, Clutter.Orientation.HORIZONTAL);
gesture_tracker.on_gesture_detected.connect (on_gesture_detected);
workspaces = new Actor ();
workspaces.set_easing_mode (AnimationMode.EASE_OUT_QUAD);
@ -164,14 +167,17 @@ namespace Gala {
}
/**
* Scroll through workspaces
* Scroll through workspaces with the mouse wheel. Smooth scrolling is handled by
* GestureTracker.
*/
public override bool scroll_event (ScrollEvent scroll_event) {
if (!opened)
if (!opened) {
return true;
}
if (scroll_event.direction != ScrollDirection.SMOOTH)
if (scroll_event.direction != ScrollDirection.SMOOTH) {
return false;
}
double dx, dy;
#if VALA_0_32
@ -181,43 +187,92 @@ namespace Gala {
event->get_scroll_delta (out dx, out dy);
#endif
var direction = MotionDirection.LEFT;
// concept from maya to detect mouse wheel and proper smooth scroll and prevent
// too much repetition on the events
// concept from maya to detect mouse wheel
if (Math.fabs (dy) == 1.0) {
// mouse wheel scroll
direction = dy > 0 ? MotionDirection.RIGHT : MotionDirection.LEFT;
} else if (!is_smooth_scrolling) {
// actual smooth scroll
var choice = Math.fabs (dx) > Math.fabs (dy) ? dx : dy;
var direction = dy > 0 ? MotionDirection.RIGHT : MotionDirection.LEFT;
if (choice > 0.3)
direction = MotionDirection.RIGHT;
else if (choice < -0.3)
direction = MotionDirection.LEFT;
else
return false;
unowned Meta.WorkspaceManager manager = display.get_workspace_manager ();
var active_workspace = manager.get_active_workspace ();
var new_workspace = active_workspace.get_neighbor (direction);
is_smooth_scrolling = true;
Timeout.add (SMOOTH_SCROLL_DELAY, () => {
is_smooth_scrolling = false;
return false;
});
} else
// smooth scroll delay still active
return false;
unowned Meta.WorkspaceManager manager = display.get_workspace_manager ();
var active_workspace = manager.get_active_workspace ();
var new_workspace = active_workspace.get_neighbor (direction);
if (active_workspace != new_workspace)
new_workspace.activate (display.get_current_time ());
if (active_workspace != new_workspace) {
new_workspace.activate (display.get_current_time ());
}
}
return false;
}
private void on_gesture_detected (Gesture gesture) {
if (gesture.type == Gdk.EventType.SCROLL) {
Meta.MotionDirection direction = (gesture.direction == GestureDirection.LEFT)
? Meta.MotionDirection.LEFT
: Meta.MotionDirection.RIGHT;
switch_workspace_with_gesture (direction);
}
}
private void switch_workspace_with_gesture (Meta.MotionDirection direction) {
unowned Meta.WorkspaceManager manager = display.get_workspace_manager ();
var num_workspaces = manager.get_n_workspaces ();
var active_workspace_index = manager.get_active_workspace ().index ();
var target_workspace_index = (direction == Meta.MotionDirection.LEFT)
? active_workspace_index - 1
: active_workspace_index + 1;
float initial_x = workspaces.x;
float target_x = 0;
bool is_nudge_animation = (target_workspace_index < 0 || target_workspace_index >= num_workspaces);
if (is_nudge_animation) {
var nudge_delta = (direction == Meta.MotionDirection.LEFT)
? WindowManagerGala.NUDGE_GAP
: -WindowManagerGala.NUDGE_GAP;
target_x = initial_x + nudge_delta * InternalUtils.get_ui_scaling_factor ();
} else {
foreach (var child in workspaces.get_children ()) {
unowned WorkspaceClone workspace_clone = (WorkspaceClone) child;
var index = workspace_clone.workspace.index ();
if (index == target_workspace_index) {
target_x = -workspace_clone.multitasking_view_x ();
break;
}
}
}
debug ("Starting MultitaskingView switch workspace animation:");
debug ("Active workspace index: %d", active_workspace_index);
debug ("Target workspace index: %d", target_workspace_index);
debug ("Total number of workspaces: %d", num_workspaces);
debug ("Is nudge animation: %s", is_nudge_animation ? "Yes" : "No");
debug ("Initial X: %f", initial_x);
debug ("Target X: %f", target_x);
GestureAnimationDirector.OnUpdate on_animation_update = (percentage) => {
var x = GestureTracker.animation_value (initial_x, target_x, percentage, true);
workspaces.x = x;
};
GestureAnimationDirector.OnEnd on_animation_end = (percentage, cancel_action, calculated_duration) => {
gesture_tracker.enabled = false;
var duration = is_nudge_animation ? (AnimationDuration.NUDGE / 2) : calculated_duration;
workspaces.set_easing_duration (duration);
workspaces.x = (is_nudge_animation || cancel_action) ? initial_x : target_x;
workspaces.get_transition ("x").completed.connect (() => {
gesture_tracker.enabled = true;
if (!is_nudge_animation && !cancel_action) {
manager.get_workspace_by_index (target_workspace_index).activate (display.get_current_time ());
update_positions (false);
}
});
};
gesture_tracker.connect_handlers (null, (owned) on_animation_update, (owned) on_animation_end);
}
/**
* Places the WorkspaceClones, moves the view so that the active one is shown
* and does the same for the IconGroups.

View File

@ -100,6 +100,11 @@ namespace Gala {
private bool animating_switch_workspace = false;
private GestureAnimationDirector gesture_animation_director;
/**
* Amount of pixels to move on the nudge animation.
*/
public const float NUDGE_GAP = 32;
/**
* Gap to show between workspaces while switching between them.
*/
@ -495,8 +500,8 @@ namespace Gala {
}
private void play_nudge_animation (Meta.MotionDirection direction) {
int duration = 360;
var dest = (direction == Meta.MotionDirection.LEFT ? 32.0f : -32.0f);
var dest = (direction == Meta.MotionDirection.LEFT ? NUDGE_GAP : -NUDGE_GAP);
dest *= InternalUtils.get_ui_scaling_factor ();
GestureAnimationDirector.OnUpdate on_animation_update = (percentage) => {
var x = GestureAnimationDirector.animation_value (0.0f, dest, percentage, true);
@ -505,7 +510,7 @@ namespace Gala {
GestureAnimationDirector.OnEnd on_animation_end = (percentage, cancel_action) => {
var nudge_gesture = new Clutter.PropertyTransition ("x") {
duration = (duration / 2),
duration = (AnimationDuration.NUDGE / 2),
remove_on_complete = true,
progress_mode = Clutter.AnimationMode.LINEAR
};
@ -523,7 +528,7 @@ namespace Gala {
GLib.Value[] x = { dest };
var nudge = new Clutter.KeyframeTransition ("translation-x") {
duration = duration,
duration = AnimationDuration.NUDGE,
remove_on_complete = true,
progress_mode = Clutter.AnimationMode.EASE_IN_QUAD
};

View File

@ -23,6 +23,10 @@ gala_bin_sources = files(
'Background/BackgroundManager.vala',
'Background/BackgroundSource.vala',
'Background/SystemBackground.vala',
'Gestures/Gesture.vala',
'Gestures/GestureSettings.vala',
'Gestures/GestureTracker.vala',
'Gestures/ScrollBackend.vala',
'Widgets/IconGroup.vala',
'Widgets/IconGroupContainer.vala',
'Widgets/MonitorClone.vala',