gnome-shell/js/ui/swipeTracker.js
Florian Müllner ac8246050d swipeTracker: Optionally require modifiers for scrolling
The design now calls for super-scroll for workspace switching
in the session, however it is currently only possible for
SwipeTracker to either handle scroll events or not.

In order to support the new use case, add a new :scroll-modifiers
property that allows specifying modifiers for which scroll events
are handled.

Part-of: <https://gitlab.gnome.org/GNOME/gnome-shell/-/merge_requests/1612>
2021-02-04 20:04:15 +00:00

619 lines
20 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 SCROLL_MULTIPLIER = 10;
const SWIPE_MULTIPLIER = 0.5;
const MIN_ANIMATION_DURATION = 100;
const MAX_ANIMATION_DURATION = 400;
const VELOCITY_THRESHOLD = 0.4;
// Derivative of easeOutCubic at t=0
const DURATION_MULTIPLIER = 3;
const ANIMATION_BASE_VELOCITY = 0.002;
const GESTURE_FINGER_COUNT = 3;
const State = {
NONE: 0,
SCROLLING: 1,
};
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] },
'end': { param_types: [GObject.TYPE_UINT] },
},
}, 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();
let delta;
if (this.orientation === Clutter.Orientation.VERTICAL)
delta = dy / TOUCHPAD_BASE_HEIGHT;
else
delta = dx / 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 * SWIPE_MULTIPLIER);
break;
case Clutter.TouchpadGesturePhase.END:
case Clutter.TouchpadGesturePhase.CANCEL:
this.emit('end', time);
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] },
'end': { param_types: [GObject.TYPE_UINT] },
'cancel': { param_types: [GObject.TYPE_UINT] },
},
}, 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);
}
vfunc_gesture_cancel(_actor) {
let time = Clutter.get_current_event_time();
this.emit('cancel', time);
}
});
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] },
'end': { param_types: [GObject.TYPE_UINT] },
},
}, 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;
let time = event.get_time();
let [dx, dy] = event.get_scroll_delta();
if (dx === 0 && dy === 0) {
this.emit('end', time);
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;
}
let delta;
if (this.orientation === Clutter.Orientation.VERTICAL)
delta = dy / TOUCHPAD_BASE_HEIGHT;
else
delta = dx / TOUCHPAD_BASE_WIDTH;
this.emit('update', time, delta * SCROLL_MULTIPLIER);
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),
'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._distance = global.screen_height;
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._endGesture.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._endGesture.bind(this));
this._touchGesture.connect('cancel', this._cancelGesture.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._endGesture.bind(this));
this._dragGesture.connect('cancel', this._cancelGesture.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._endGesture.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._prevTime = 0;
this._velocity = 0;
this._cancelled = false;
}
_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._prevTime = time;
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);
}
_updateGesture(gesture, time, delta) {
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;
if (time !== this._prevTime)
this._velocity = delta / (time - this._prevTime);
let firstPoint = this._snapPoints[0];
let lastPoint = this._snapPoints[this._snapPoints.length - 1];
this._progress = Math.clamp(this._progress, firstPoint, lastPoint);
this._progress = Math.clamp(this._progress,
this._initialProgress - 1, this._initialProgress + 1);
this.emit('update', this._progress);
this._prevTime = time;
}
_getClosestSnapPoints() {
let upper = this._snapPoints.find(p => p >= this._progress);
let lower = this._snapPoints.slice().reverse().find(p => p <= this._progress);
return [lower, upper];
}
_getEndProgress() {
if (this._cancelled)
return this._cancelProgress;
let [lower, upper] = this._getClosestSnapPoints();
let middle = (upper + lower) / 2;
if (this._progress > middle) {
let thresholdMet = this._velocity * this._distance > -VELOCITY_THRESHOLD;
return thresholdMet || this._initialProgress > upper ? upper : lower;
} else {
let thresholdMet = this._velocity * this._distance < VELOCITY_THRESHOLD;
return thresholdMet || this._initialProgress < lower ? lower : upper;
}
}
_endGesture(_gesture, _time) {
if (this._state !== State.SCROLLING)
return;
if ((this._allowedModes & Main.actionMode) === 0 || !this.enabled) {
this._interrupt();
return;
}
let endProgress = this._getEndProgress();
let velocity = ANIMATION_BASE_VELOCITY;
if ((endProgress - this._progress) * this._velocity > 0)
velocity = this._velocity;
let duration = Math.abs((this._progress - endProgress) / velocity * DURATION_MULTIPLIER);
if (duration > 0) {
duration = Math.clamp(duration,
MIN_ANIMATION_DURATION, MAX_ANIMATION_DURATION);
}
this.emit('end', duration, endProgress);
this._reset();
}
_cancelGesture(gesture, time) {
if (this._state !== State.SCROLLING)
return;
this._cancelled = true;
this._endGesture(gesture, time);
}
/**
* 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._velocity = 0;
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;
}
}
});