gnome-shell/js/ui/swipeTracker.js
Alexander Mikhaylenko 16f74ebc57 swipeTracker: Add allowLongSwipes property
Since we now have the ability to support swiping through multiple pages,
expose it as a property.

Part-of: <https://gitlab.gnome.org/GNOME/gnome-shell/-/merge_requests/1647>
2021-02-08 22:20:26 +00:00

731 lines
24 KiB
JavaScript

// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
/* exported SwipeTracker */
const { Clutter, Gio, GObject, Meta } = imports.gi;
const Main = imports.ui.main;
const Params = imports.misc.params;
// FIXME: ideally these values matches physical touchpad size. We can get the
// correct values for gnome-shell specifically, since mutter uses libinput
// directly, but GTK apps cannot get it, so use an arbitrary value so that
// it's consistent with apps.
const TOUCHPAD_BASE_HEIGHT = 300;
const TOUCHPAD_BASE_WIDTH = 400;
const EVENT_HISTORY_THRESHOLD_MS = 150;
const SCROLL_MULTIPLIER = 10;
const SWIPE_MULTIPLIER = 0.5;
const MIN_ANIMATION_DURATION = 100;
const MAX_ANIMATION_DURATION = 400;
const VELOCITY_THRESHOLD_TOUCH = 0.3;
const VELOCITY_THRESHOLD_TOUCHPAD = 0.6;
const DECELERATION_TOUCH = 0.998;
const DECELERATION_TOUCHPAD = 0.997;
const VELOCITY_CURVE_THRESHOLD = 2;
const DECELERATION_PARABOLA_MULTIPLIER = 0.35;
// Derivative of easeOutCubic at t=0
const DURATION_MULTIPLIER = 3;
const ANIMATION_BASE_VELOCITY = 0.002;
const EPSILON = 0.005;
const GESTURE_FINGER_COUNT = 3;
const State = {
NONE: 0,
SCROLLING: 1,
};
const EventHistory = class {
constructor() {
this.reset();
}
reset() {
this._data = [];
}
trim(time) {
const thresholdTime = time - EVENT_HISTORY_THRESHOLD_MS;
const index = this._data.findIndex(r => r.time >= thresholdTime);
this._data.splice(0, index);
}
append(time, delta) {
this.trim(time);
this._data.push({ time, delta });
}
calculateVelocity() {
if (this._data.length < 2)
return 0;
const firstTime = this._data[0].time;
const lastTime = this._data[this._data.length - 1].time;
if (firstTime === lastTime)
return 0;
const totalDelta = this._data.slice(1).map(a => a.delta).reduce((a, b) => a + b);
const period = lastTime - firstTime;
return totalDelta / period;
}
};
const TouchpadSwipeGesture = GObject.registerClass({
Properties: {
'enabled': GObject.ParamSpec.boolean(
'enabled', 'enabled', 'enabled',
GObject.ParamFlags.READWRITE,
true),
'orientation': GObject.ParamSpec.enum(
'orientation', 'orientation', 'orientation',
GObject.ParamFlags.READWRITE,
Clutter.Orientation, Clutter.Orientation.VERTICAL),
},
Signals: {
'begin': { param_types: [GObject.TYPE_UINT, GObject.TYPE_DOUBLE, GObject.TYPE_DOUBLE] },
'update': { param_types: [GObject.TYPE_UINT, GObject.TYPE_DOUBLE, GObject.TYPE_DOUBLE] },
'end': { param_types: [GObject.TYPE_UINT, GObject.TYPE_DOUBLE] },
},
}, class TouchpadSwipeGesture extends GObject.Object {
_init(allowedModes) {
super._init();
this._allowedModes = allowedModes;
this._touchpadSettings = new Gio.Settings({
schema_id: 'org.gnome.desktop.peripherals.touchpad',
});
this._stageCaptureEvent =
global.stage.connect('captured-event::touchpad', this._handleEvent.bind(this));
}
_handleEvent(actor, event) {
if (event.type() !== Clutter.EventType.TOUCHPAD_SWIPE)
return Clutter.EVENT_PROPAGATE;
if (event.get_touchpad_gesture_finger_count() !== GESTURE_FINGER_COUNT)
return Clutter.EVENT_PROPAGATE;
if ((this._allowedModes & Main.actionMode) === 0)
return Clutter.EVENT_PROPAGATE;
if (!this.enabled)
return Clutter.EVENT_PROPAGATE;
let time = event.get_time();
let [x, y] = event.get_coords();
let [dx, dy] = event.get_gesture_motion_delta();
const vertical = this.orientation === Clutter.Orientation.VERTICAL;
let delta = (vertical ? dy : dx) * SWIPE_MULTIPLIER;
const distance = vertical ? TOUCHPAD_BASE_HEIGHT : TOUCHPAD_BASE_WIDTH;
switch (event.get_gesture_phase()) {
case Clutter.TouchpadGesturePhase.BEGIN:
this.emit('begin', time, x, y);
break;
case Clutter.TouchpadGesturePhase.UPDATE:
if (this._touchpadSettings.get_boolean('natural-scroll'))
delta = -delta;
this.emit('update', time, delta, distance);
break;
case Clutter.TouchpadGesturePhase.END:
case Clutter.TouchpadGesturePhase.CANCEL:
this.emit('end', time, distance);
break;
}
return Clutter.EVENT_STOP;
}
destroy() {
if (this._stageCaptureEvent) {
global.stage.disconnect(this._stageCaptureEvent);
delete this._stageCaptureEvent;
}
}
});
const TouchSwipeGesture = GObject.registerClass({
Properties: {
'distance': GObject.ParamSpec.double(
'distance', 'distance', 'distance',
GObject.ParamFlags.READWRITE,
0, Infinity, 0),
'orientation': GObject.ParamSpec.enum(
'orientation', 'orientation', 'orientation',
GObject.ParamFlags.READWRITE,
Clutter.Orientation, Clutter.Orientation.VERTICAL),
},
Signals: {
'begin': { param_types: [GObject.TYPE_UINT, GObject.TYPE_DOUBLE, GObject.TYPE_DOUBLE] },
'update': { param_types: [GObject.TYPE_UINT, GObject.TYPE_DOUBLE, GObject.TYPE_DOUBLE] },
'end': { param_types: [GObject.TYPE_UINT, GObject.TYPE_DOUBLE] },
'cancel': { param_types: [GObject.TYPE_UINT, GObject.TYPE_DOUBLE] },
},
}, class TouchSwipeGesture extends Clutter.GestureAction {
_init(allowedModes, nTouchPoints, thresholdTriggerEdge) {
super._init();
this.set_n_touch_points(nTouchPoints);
this.set_threshold_trigger_edge(thresholdTriggerEdge);
this._allowedModes = allowedModes;
this._distance = global.screen_height;
global.display.connect('grab-op-begin', () => {
this.cancel();
});
this._lastPosition = 0;
}
get distance() {
return this._distance;
}
set distance(distance) {
if (this._distance === distance)
return;
this._distance = distance;
this.notify('distance');
}
vfunc_gesture_prepare(actor) {
if (!super.vfunc_gesture_prepare(actor))
return false;
if ((this._allowedModes & Main.actionMode) === 0)
return false;
let time = this.get_last_event(0).get_time();
let [xPress, yPress] = this.get_press_coords(0);
let [x, y] = this.get_motion_coords(0);
this._lastPosition =
this.orientation === Clutter.Orientation.VERTICAL ? y : x;
this.emit('begin', time, xPress, yPress);
return true;
}
vfunc_gesture_progress(_actor) {
let [x, y] = this.get_motion_coords(0);
let pos = this.orientation === Clutter.Orientation.VERTICAL ? y : x;
let delta = pos - this._lastPosition;
this._lastPosition = pos;
let time = this.get_last_event(0).get_time();
this.emit('update', time, -delta, this._distance);
return true;
}
vfunc_gesture_end(_actor) {
let time = this.get_last_event(0).get_time();
this.emit('end', time, this._distance);
}
vfunc_gesture_cancel(_actor) {
let time = Clutter.get_current_event_time();
this.emit('cancel', time, this._distance);
}
});
const ScrollGesture = GObject.registerClass({
Properties: {
'enabled': GObject.ParamSpec.boolean(
'enabled', 'enabled', 'enabled',
GObject.ParamFlags.READWRITE,
true),
'orientation': GObject.ParamSpec.enum(
'orientation', 'orientation', 'orientation',
GObject.ParamFlags.READWRITE,
Clutter.Orientation, Clutter.Orientation.VERTICAL),
'scroll-modifiers': GObject.ParamSpec.flags(
'scroll-modifiers', 'scroll-modifiers', 'scroll-modifiers',
GObject.ParamFlags.READWRITE,
Clutter.ModifierType, 0),
},
Signals: {
'begin': { param_types: [GObject.TYPE_UINT, GObject.TYPE_DOUBLE, GObject.TYPE_DOUBLE] },
'update': { param_types: [GObject.TYPE_UINT, GObject.TYPE_DOUBLE, GObject.TYPE_DOUBLE] },
'end': { param_types: [GObject.TYPE_UINT, GObject.TYPE_DOUBLE] },
},
}, class ScrollGesture extends GObject.Object {
_init(actor, allowedModes) {
super._init();
this._allowedModes = allowedModes;
this._began = false;
this._enabled = true;
actor.connect('scroll-event', this._handleEvent.bind(this));
}
get enabled() {
return this._enabled;
}
set enabled(enabled) {
if (this._enabled === enabled)
return;
this._enabled = enabled;
this._began = false;
this.notify('enabled');
}
canHandleEvent(event) {
if (event.type() !== Clutter.EventType.SCROLL)
return false;
if (event.get_scroll_source() !== Clutter.ScrollSource.FINGER &&
event.get_source_device().get_device_type() !== Clutter.InputDeviceType.TOUCHPAD_DEVICE)
return false;
if (!this.enabled)
return false;
if ((this._allowedModes & Main.actionMode) === 0)
return false;
if (this.scrollModifiers !== 0 &&
(event.get_state() & this.scrollModifiers) === 0)
return false;
return true;
}
_handleEvent(actor, event) {
if (!this.canHandleEvent(event))
return Clutter.EVENT_PROPAGATE;
if (event.get_scroll_direction() !== Clutter.ScrollDirection.SMOOTH)
return Clutter.EVENT_PROPAGATE;
const vertical = this.orientation === Clutter.Orientation.VERTICAL;
const distance = vertical ? TOUCHPAD_BASE_HEIGHT : TOUCHPAD_BASE_WIDTH;
let time = event.get_time();
let [dx, dy] = event.get_scroll_delta();
if (dx === 0 && dy === 0) {
this.emit('end', time, distance);
this._began = false;
return Clutter.EVENT_STOP;
}
if (!this._began) {
let [x, y] = event.get_coords();
this.emit('begin', time, x, y);
this._began = true;
}
const delta = (vertical ? dy : dx) * SCROLL_MULTIPLIER;
this.emit('update', time, delta, distance);
return Clutter.EVENT_STOP;
}
});
// USAGE:
//
// To correctly implement the gesture, there must be handlers for the following
// signals:
//
// begin(tracker, monitor)
// The handler should check whether a deceleration animation is currently
// running. If it is, it should stop the animation (without resetting
// progress). Then it should call:
// tracker.confirmSwipe(distance, snapPoints, currentProgress, cancelProgress)
// If it's not called, the swipe would be ignored.
// The parameters are:
// * distance: the page size;
// * snapPoints: an (sorted with ascending order) array of snap points;
// * currentProgress: the current progress;
// * cancelprogress: a non-transient value that would be used if the gesture
// is cancelled.
// If no animation was running, currentProgress and cancelProgress should be
// same. The handler may set 'orientation' property here.
//
// update(tracker, progress)
// The handler should set the progress to the given value.
//
// end(tracker, duration, endProgress)
// The handler should animate the progress to endProgress. If endProgress is
// 0, it should do nothing after the animation, otherwise it should change the
// state, e.g. change the current page or switch workspace.
// NOTE: duration can be 0 in some cases, in this case it should finish
// instantly.
/** A class for handling swipe gestures */
var SwipeTracker = GObject.registerClass({
Properties: {
'enabled': GObject.ParamSpec.boolean(
'enabled', 'enabled', 'enabled',
GObject.ParamFlags.READWRITE,
true),
'orientation': GObject.ParamSpec.enum(
'orientation', 'orientation', 'orientation',
GObject.ParamFlags.READWRITE,
Clutter.Orientation, Clutter.Orientation.VERTICAL),
'distance': GObject.ParamSpec.double(
'distance', 'distance', 'distance',
GObject.ParamFlags.READWRITE,
0, Infinity, 0),
'allow-long-swipes': GObject.ParamSpec.boolean(
'allow-long-swipes', 'allow-long-swipes', 'allow-long-swipes',
GObject.ParamFlags.READWRITE,
false),
'scroll-modifiers': GObject.ParamSpec.flags(
'scroll-modifiers', 'scroll-modifiers', 'scroll-modifiers',
GObject.ParamFlags.READWRITE,
Clutter.ModifierType, 0),
},
Signals: {
'begin': { param_types: [GObject.TYPE_UINT] },
'update': { param_types: [GObject.TYPE_DOUBLE] },
'end': { param_types: [GObject.TYPE_UINT64, GObject.TYPE_DOUBLE] },
},
}, class SwipeTracker extends GObject.Object {
_init(actor, allowedModes, params) {
super._init();
params = Params.parse(params, { allowDrag: true, allowScroll: true });
this._allowedModes = allowedModes;
this._enabled = true;
this._allowLongSwipes = false;
this._distance = global.screen_height;
this._history = new EventHistory();
this._reset();
this._touchpadGesture = new TouchpadSwipeGesture(allowedModes);
this._touchpadGesture.connect('begin', this._beginGesture.bind(this));
this._touchpadGesture.connect('update', this._updateGesture.bind(this));
this._touchpadGesture.connect('end', this._endTouchpadGesture.bind(this));
this.bind_property('enabled', this._touchpadGesture, 'enabled', 0);
this.bind_property('orientation', this._touchpadGesture, 'orientation', 0);
this._touchGesture = new TouchSwipeGesture(allowedModes,
GESTURE_FINGER_COUNT,
Clutter.GestureTriggerEdge.AFTER);
this._touchGesture.connect('begin', this._beginTouchSwipe.bind(this));
this._touchGesture.connect('update', this._updateGesture.bind(this));
this._touchGesture.connect('end', this._endTouchGesture.bind(this));
this._touchGesture.connect('cancel', this._cancelTouchGesture.bind(this));
this.bind_property('enabled', this._touchGesture, 'enabled', 0);
this.bind_property('orientation', this._touchGesture, 'orientation', 0);
this.bind_property('distance', this._touchGesture, 'distance', 0);
global.stage.add_action(this._touchGesture);
if (params.allowDrag) {
this._dragGesture = new TouchSwipeGesture(allowedModes, 1,
Clutter.GestureTriggerEdge.AFTER);
this._dragGesture.connect('begin', this._beginGesture.bind(this));
this._dragGesture.connect('update', this._updateGesture.bind(this));
this._dragGesture.connect('end', this._endTouchGesture.bind(this));
this._dragGesture.connect('cancel', this._cancelTouchGesture.bind(this));
this.bind_property('enabled', this._dragGesture, 'enabled', 0);
this.bind_property('orientation', this._dragGesture, 'orientation', 0);
this.bind_property('distance', this._dragGesture, 'distance', 0);
actor.add_action(this._dragGesture);
} else {
this._dragGesture = null;
}
if (params.allowScroll) {
this._scrollGesture = new ScrollGesture(actor, allowedModes);
this._scrollGesture.connect('begin', this._beginGesture.bind(this));
this._scrollGesture.connect('update', this._updateGesture.bind(this));
this._scrollGesture.connect('end', this._endTouchpadGesture.bind(this));
this.bind_property('enabled', this._scrollGesture, 'enabled', 0);
this.bind_property('orientation', this._scrollGesture, 'orientation', 0);
this.bind_property('scroll-modifiers',
this._scrollGesture, 'scroll-modifiers', 0);
} else {
this._scrollGesture = null;
}
}
/**
* canHandleScrollEvent:
* @param {Clutter.Event} scrollEvent: an event to check
* @returns {bool} whether the event can be handled by the tracker
*
* This function can be used to combine swipe gesture and mouse
* scrolling.
*/
canHandleScrollEvent(scrollEvent) {
if (!this.enabled || this._scrollGesture === null)
return false;
return this._scrollGesture.canHandleEvent(scrollEvent);
}
get enabled() {
return this._enabled;
}
set enabled(enabled) {
if (this._enabled === enabled)
return;
this._enabled = enabled;
if (!enabled && this._state === State.SCROLLING)
this._interrupt();
this.notify('enabled');
}
get distance() {
return this._distance;
}
set distance(distance) {
if (this._distance === distance)
return;
this._distance = distance;
this.notify('distance');
}
_reset() {
this._state = State.NONE;
this._snapPoints = [];
this._initialProgress = 0;
this._cancelProgress = 0;
this._prevOffset = 0;
this._progress = 0;
this._cancelled = false;
this._history.reset();
}
_interrupt() {
this.emit('end', 0, this._cancelProgress);
this._reset();
}
_beginTouchSwipe(gesture, time, x, y) {
if (this._dragGesture)
this._dragGesture.cancel();
this._beginGesture(gesture, time, x, y);
}
_beginGesture(gesture, time, x, y) {
if (this._state === State.SCROLLING)
return;
this._history.append(time, 0);
let rect = new Meta.Rectangle({ x, y, width: 1, height: 1 });
let monitor = global.display.get_monitor_index_for_rect(rect);
this.emit('begin', monitor);
}
_findClosestPoint(pos) {
const distances = this._snapPoints.map(x => Math.abs(x - pos));
const min = Math.min(...distances);
return distances.indexOf(min);
}
_findNextPoint(pos) {
return this._snapPoints.findIndex(p => p >= pos);
}
_findPreviousPoint(pos) {
const reversedIndex = this._snapPoints.slice().reverse().findIndex(p => p <= pos);
return this._snapPoints.length - 1 - reversedIndex;
}
_findPointForProjection(pos, velocity) {
const initial = this._findClosestPoint(this._initialProgress);
const prev = this._findPreviousPoint(pos);
const next = this._findNextPoint(pos);
if ((velocity > 0 ? prev : next) === initial)
return velocity > 0 ? next : prev;
return this._findClosestPoint(pos);
}
_getBounds(pos) {
const closest = this._findClosestPoint(pos);
let prev, next;
if (Math.abs(this._snapPoints[closest] - pos) < EPSILON) {
prev = next = closest;
} else {
prev = this._findPreviousPoint(pos);
next = this._findNextPoint(pos);
}
const lowerIndex = Math.max(prev - 1, 0);
const upperIndex = Math.min(next + 1, this._snapPoints.length - 1);
return [this._snapPoints[lowerIndex], this._snapPoints[upperIndex]];
}
_updateGesture(gesture, time, delta, distance) {
if (this._state !== State.SCROLLING)
return;
if ((this._allowedModes & Main.actionMode) === 0 || !this.enabled) {
this._interrupt();
return;
}
if (this.orientation === Clutter.Orientation.HORIZONTAL &&
Clutter.get_default_text_direction() === Clutter.TextDirection.RTL)
delta = -delta;
this._progress += delta / distance;
this._history.append(time, delta);
const [lower, upper] = this.allowLongSwipes
? [this._snapPoints[0], this._snapPoints[this._snapPoints.length - 1]]
: this._getBounds(this._initialProgress);
this._progress = Math.clamp(this._progress, lower, upper);
this.emit('update', this._progress);
}
_getEndProgress(velocity, distance, isTouchpad) {
if (this._cancelled)
return this._cancelProgress;
const threshold = isTouchpad ? VELOCITY_THRESHOLD_TOUCHPAD : VELOCITY_THRESHOLD_TOUCH;
if (Math.abs(velocity) < threshold)
return this._snapPoints[this._findClosestPoint(this._progress)];
const decel = isTouchpad ? DECELERATION_TOUCHPAD : DECELERATION_TOUCH;
const slope = decel / (1.0 - decel) / 1000.0;
let pos;
if (Math.abs(velocity) > VELOCITY_CURVE_THRESHOLD) {
const c = slope / 2 / DECELERATION_PARABOLA_MULTIPLIER;
const x = Math.abs(velocity) - VELOCITY_CURVE_THRESHOLD + c;
pos = slope * VELOCITY_CURVE_THRESHOLD +
DECELERATION_PARABOLA_MULTIPLIER * x * x -
DECELERATION_PARABOLA_MULTIPLIER * c * c;
} else {
pos = Math.abs(velocity) * slope;
}
pos = pos * Math.sign(velocity) + this._progress;
if (!this.allowLongSwipes)
pos = Math.clamp(pos, ...this._getBounds(this._initialProgress));
const index = this._findPointForProjection(pos, velocity);
return this._snapPoints[index];
}
_endTouchGesture(_gesture, time, distance) {
this._endGesture(time, distance, false);
}
_endTouchpadGesture(_gesture, time, distance) {
this._endGesture(time, distance, true);
}
_endGesture(time, distance, isTouchpad) {
if (this._state !== State.SCROLLING)
return;
if ((this._allowedModes & Main.actionMode) === 0 || !this.enabled) {
this._interrupt();
return;
}
this._history.trim(time);
let velocity = this._history.calculateVelocity();
const endProgress = this._getEndProgress(velocity, distance, isTouchpad);
velocity /= distance;
if ((endProgress - this._progress) * velocity <= 0)
velocity = ANIMATION_BASE_VELOCITY;
const nPoints = Math.max(1, Math.ceil(Math.abs(this._progress - endProgress)));
const maxDuration = MAX_ANIMATION_DURATION * Math.log2(1 + nPoints);
let duration = Math.abs((this._progress - endProgress) / velocity * DURATION_MULTIPLIER);
if (duration > 0)
duration = Math.clamp(duration, MIN_ANIMATION_DURATION, maxDuration);
this.emit('end', duration, endProgress);
this._reset();
}
_cancelTouchGesture(_gesture, time, distance) {
if (this._state !== State.SCROLLING)
return;
this._cancelled = true;
this._endGesture(time, distance, false);
}
/**
* confirmSwipe:
* @param {number} distance: swipe distance in pixels
* @param {number[]} snapPoints:
* An array of snap points, sorted in ascending order
* @param {number} currentProgress: initial progress value
* @param {number} cancelProgress: the value to be used on cancelling
*
* Confirms a swipe. User has to call this in 'begin' signal handler,
* otherwise the swipe wouldn't start. If there's an animation running,
* it should be stopped first.
*
* @cancel_progress must always be a snap point, or a value matching
* some other non-transient state.
*/
confirmSwipe(distance, snapPoints, currentProgress, cancelProgress) {
this.distance = distance;
this._snapPoints = snapPoints;
this._initialProgress = currentProgress;
this._progress = currentProgress;
this._cancelProgress = cancelProgress;
this._state = State.SCROLLING;
}
destroy() {
if (this._touchpadGesture) {
this._touchpadGesture.destroy();
delete this._touchpadGesture;
}
if (this._touchGesture) {
global.stage.remove_action(this._touchGesture);
delete this._touchGesture;
}
}
});