From 7f43c8fb423ebb1620a9cd6aeb1ac32439ecb877 Mon Sep 17 00:00:00 2001 From: Michael Webster Date: Mon, 16 Mar 2026 22:11:56 -0400 Subject: [PATCH 1/2] gestures: Add support for wayland sessions Refactor GesturesManager to support both touchegg (X11) and native clutter/libinput events (Wayland), since muffin's event handling differs enough between session types that touchegg is still needed for X11 for now. Gesture action behavior is otherwise unchanged. Follow-up fixes folded in: connect to screensaverController directly instead of a proxy, consolidate onto js/misc/mprisPlayer and drop mprisController.js, a few misc fixes, and halt event propagation when a gesture is cancelled. ref: linuxmint/wayland#99 --- data/org.cinnamon.gestures.gschema.xml | 2 +- .../cinnamon-settings/modules/cs_gestures.py | 35 +- js/misc/mprisPlayer.js | 18 +- js/ui/gestures/actions.js | 31 +- .../{ToucheggTypes.js => gestureTypes.js} | 0 js/ui/gestures/gesturesManager.js | 81 +-- js/ui/gestures/mprisController.js | 294 --------- js/ui/gestures/nativeGestureSource.js | 110 ++++ js/ui/gestures/nativeGestures.js | 574 ++++++++++++++++++ js/ui/gestures/toucheggGestureSource.js | 60 ++ po/POTFILES.in | 8 +- src/cinnamon-touchegg-client.c | 3 +- 12 files changed, 835 insertions(+), 381 deletions(-) rename js/ui/gestures/{ToucheggTypes.js => gestureTypes.js} (100%) delete mode 100644 js/ui/gestures/mprisController.js create mode 100644 js/ui/gestures/nativeGestureSource.js create mode 100644 js/ui/gestures/nativeGestures.js create mode 100644 js/ui/gestures/toucheggGestureSource.js diff --git a/data/org.cinnamon.gestures.gschema.xml b/data/org.cinnamon.gestures.gschema.xml index 8c1402aa80..cce5e2a609 100644 --- a/data/org.cinnamon.gestures.gschema.xml +++ b/data/org.cinnamon.gestures.gschema.xml @@ -3,7 +3,7 @@ gettext-domain="@GETTEXT_PACKAGE@"> false - Enables gesture support using Touchegg. + Enables gesture support. 60 diff --git a/files/usr/share/cinnamon/cinnamon-settings/modules/cs_gestures.py b/files/usr/share/cinnamon/cinnamon-settings/modules/cs_gestures.py index 82ca69465f..6e6d5f07c4 100644 --- a/files/usr/share/cinnamon/cinnamon-settings/modules/cs_gestures.py +++ b/files/usr/share/cinnamon/cinnamon-settings/modules/cs_gestures.py @@ -6,6 +6,7 @@ gi.require_version('Gtk', '3.0') from gi.repository import Gio, Gtk +from bin import util from bin.SettingsWidgets import SidePage, SettingsWidget from xapp.GSettingsWidgets import * @@ -90,8 +91,15 @@ def __init__(self, content_box): self.disabled_box = None def on_module_selected(self): - installed = GLib.find_program_in_path("touchegg") - alive = self.test_daemon_alive() + self.is_wayland = util.get_session_type() == "wayland" + + # On X11, check for touchegg; on Wayland, native gestures are used + if self.is_wayland: + installed = True + alive = True + else: + installed = GLib.find_program_in_path("touchegg") + alive = self.test_daemon_alive() if self.gesture_settings is None: self.gesture_settings = Gio.Settings(schema_id=SCHEMA) @@ -263,21 +271,24 @@ def sort_by_direction(key1, key2): self.disabled_retry_button.set_visible(False) self.disabled_page_disable_button.set_visible(False) - if not installed: - text = _("The touchegg package must be installed for gesture support.") - self.disabled_retry_button.show() - elif not self.gesture_settings.get_boolean("enabled"): + text = "" + if not self.gesture_settings.get_boolean("enabled"): self.disabled_page_switch.set_visible(True) text = _("Gestures are disabled") - elif not alive: - text = _("The Touchegg service is not running") - if self.gesture_settings.get_boolean("enabled"): - self.disabled_page_disable_button.set_visible(True) - self.disabled_retry_button.show() + elif not self.is_wayland: + # X11-specific: check for touchegg + if not installed: + text = _("The touchegg package must be installed for gesture support.") + self.disabled_retry_button.show() + elif not alive: + text = _("The Touchegg service is not running") + if self.gesture_settings.get_boolean("enabled"): + self.disabled_page_disable_button.set_visible(True) + self.disabled_retry_button.show() self.sidePage.stack.set_transition_type(Gtk.StackTransitionType.NONE) - if not enabled or not alive or not installed: + if not enabled or (not self.is_wayland and (not alive or not installed)): self.disabled_label.set_markup(f"{text}") page = "disabled" else: diff --git a/js/misc/mprisPlayer.js b/js/misc/mprisPlayer.js index 643b2f0044..d8a40b1261 100644 --- a/js/misc/mprisPlayer.js +++ b/js/misc/mprisPlayer.js @@ -538,9 +538,25 @@ var MprisPlayerManager = class MprisPlayerManager { }); } + _isInstance(busName) { + // MPRIS instances are in the form + // org.mpris.MediaPlayer2.name.instanceXXXX + // ...except for VLC, which to this day uses + // org.mpris.MediaPlayer2.name-XXXX + return busName.split('.').length > 4 || + /^org\.mpris\.MediaPlayer2\.vlc-\d+$/.test(busName); + } + _addPlayer(busName, owner) { if (this._players[owner]) { - return; // Already tracking this player + // If we already have a player for this owner, prefer the instance + // bus name over the base name - it's more specific and some players + // register both. + let existing = this._players[owner]; + if (this._isInstance(busName) && !this._isInstance(existing.getBusName())) { + existing._busName = busName; + } + return; } let player = new MprisPlayer(busName, owner); diff --git a/js/ui/gestures/actions.js b/js/ui/gestures/actions.js index 38a074557a..fa32ad7d6f 100644 --- a/js/ui/gestures/actions.js +++ b/js/ui/gestures/actions.js @@ -2,13 +2,13 @@ const { GLib, Gio, Cinnamon, Meta, Cvc } = imports.gi; const Main = imports.ui.main; -const { GestureType } = imports.ui.gestures.ToucheggTypes; -const { MprisController } = imports.ui.gestures.mprisController; +const { GestureType } = imports.ui.gestures.gestureTypes; +const { getMprisPlayerManager } = imports.misc.mprisPlayer; const Magnifier = imports.ui.magnifier; const touchpad_settings = new Gio.Settings({ schema_id: "org.cinnamon.desktop.peripherals.touchpad" }); -const CONTINUOUS_ACTION_POLL_INTERVAL = 50 * 1000; +const CONTINUOUS_ACTION_POLL_INTERVAL = 50; // milliseconds var make_action = (settings, definition, device) => { var threshold = 100; @@ -65,10 +65,7 @@ var cleanup = () => { mixer = null; } - if (mpris_controller != null) { - mpris_controller.shutdown(); - mpris_controller = null; - } + mpris_manager = null; } var BaseAction = class { @@ -142,7 +139,7 @@ var WindowOpAction = class extends BaseAction { const window = global.display.get_focus_window(); if (window == null) { - global.logWarning("WorkspaceSwitchAction: no focus window"); + global.logWarning("WindowOpAction: no focus window"); return } @@ -438,13 +435,13 @@ var VolumeAction = class extends BaseAction { } } -var mpris_controller = null; +var mpris_manager = null; var init_mpris_controller = () => { - if (mpris_controller != null) { + if (mpris_manager != null) { return; } - mpris_controller = new MprisController(); + mpris_manager = getMprisPlayerManager(); } var MediaAction = class extends BaseAction { @@ -453,28 +450,26 @@ var MediaAction = class extends BaseAction { } do_action(direction, percentage, time) { - const player = mpris_controller.get_player(); + const player = mpris_manager.getBestPlayer(); if (player == null) { return; } if (this.definition.action === "MEDIA_PLAY_PAUSE") { - player.toggle_play() + player.playPause(); } else if (this.definition.action === "MEDIA_NEXT") { - player.next_track(); + player.next(); } else if (this.definition.action === "MEDIA_PREVIOUS") { - player.previous_track(); + player.previous(); } } } -const ZOOM_SAMPLE_RATE = 20 * 1000 // 20 ms; g_get_monotonic_time() returns microseconds - var ZoomAction = class extends BaseAction { constructor(definition, device, threshold) { super(definition, device, threshold); @@ -484,7 +479,7 @@ var ZoomAction = class extends BaseAction { if (definition.custom_value !== "") { try { - let adjust = parseInt(definition.custom_value) * 1000; + let adjust = parseInt(definition.custom_value); this.poll_interval = this.poll_interval + adjust; } catch (e) {} } diff --git a/js/ui/gestures/ToucheggTypes.js b/js/ui/gestures/gestureTypes.js similarity index 100% rename from js/ui/gestures/ToucheggTypes.js rename to js/ui/gestures/gestureTypes.js diff --git a/js/ui/gestures/gesturesManager.js b/js/ui/gestures/gesturesManager.js index 2213c6c730..a5b9369dca 100644 --- a/js/ui/gestures/gesturesManager.js +++ b/js/ui/gestures/gesturesManager.js @@ -1,23 +1,21 @@ // -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- -const { Gio, GObject, Cinnamon, Meta } = imports.gi; -const Util = imports.misc.util; +const { Gio, Meta } = imports.gi; const SignalManager = imports.misc.signalManager; -const ScreenSaver = imports.misc.screenSaver; +const Main = imports.ui.main; const actions = imports.ui.gestures.actions; -const { +const { GestureType, GestureDirection, - DeviceType, GestureTypeString, GestureDirectionString, - GesturePhaseString, DeviceTypeString -} = imports.ui.gestures.ToucheggTypes; +} = imports.ui.gestures.gestureTypes; +const { NativeGestureSource } = imports.ui.gestures.nativeGestureSource; +const { ToucheggGestureSource } = imports.ui.gestures.toucheggGestureSource; const SCHEMA = "org.cinnamon.gestures"; -const TOUCHPAD_SCHEMA = "org.cinnamon.desktop.peripherals.touchpad" const NON_GESTURE_KEYS = [ "enabled", @@ -86,20 +84,24 @@ var GestureDefinition = class { var GesturesManager = class { constructor(wm) { - if (Meta.is_wayland_compositor()) { - global.log("Gestures disabled on Wayland"); - return; - } - this.signalManager = new SignalManager.SignalManager(null); this.settings = new Gio.Settings({ schema_id: SCHEMA }) + this.current_gesture = null; + this.live_actions = new Map(); + + if (Meta.is_wayland_compositor()) { + this.gestureSource = new NativeGestureSource(); + } else { + this.gestureSource = new ToucheggGestureSource(); + } this.migrate_settings(); this.signalManager.connect(this.settings, "changed", this.settings_or_devices_changed, this); - this.screenSaverProxy = new ScreenSaver.ScreenSaverProxy(); - this.client = null; - this.current_gesture = null; + + this.gestureSource.connect('gesture-begin', this.gesture_begin.bind(this)); + this.gestureSource.connect('gesture-update', this.gesture_update.bind(this)); + this.gestureSource.connect('gesture-end', this.gesture_end.bind(this)); this.settings_or_devices_changed() } @@ -142,41 +144,14 @@ var GesturesManager = class { } } - setup_client() { - if (this.client == null) { - global.log('Set up Touchegg client'); - actions.init_mixer(); - actions.init_mpris_controller(); - - this.client = new Cinnamon.ToucheggClient(); - - this.signalManager.connect(this.client, "gesture-begin", this.gesture_begin, this); - this.signalManager.connect(this.client, "gesture-update", this.gesture_update, this); - this.signalManager.connect(this.client, "gesture-end", this.gesture_end, this); - } - } - - shutdown_client() { - if (this.client == null) { - return; - } - - global.log('Shutdown Touchegg client'); - this.signalManager.disconnect("gesture-begin"); - this.signalManager.disconnect("gesture-update"); - this.signalManager.disconnect("gesture-end"); - this.client = null; - - actions.cleanup(); - } - settings_or_devices_changed(settings, key) { if (this.settings.get_boolean("enabled")) { this.setup_actions(); return; } - this.shutdown_client(); + this.gestureSource.shutdown(); + actions.cleanup(); } gesture_active() { @@ -184,8 +159,12 @@ var GesturesManager = class { } setup_actions() { - // Make sure the client is setup - this.setup_client(); + // Make sure gesture source is set up + if (!this.gestureSource.isActive()) { + actions.init_mixer(); + actions.init_mpris_controller(); + this.gestureSource.setup(); + } this.live_actions = new Map(); @@ -250,13 +229,13 @@ var GesturesManager = class { return definition; } - gesture_begin(client, type, direction, percentage, fingers, device, elapsed_time) { + gesture_begin(source, type, direction, percentage, fingers, device, elapsed_time) { if (this.current_gesture != null) { global.logWarning("New gesture started before another was completed. Clearing the old one"); this.current_gesture = null; } - if (this.screenSaverProxy.screenSaverActive) { + if (Main.screensaverController?.locked) { debug_gesture(`Ignoring 'gesture-begin', screensaver is active`); return; } @@ -277,7 +256,7 @@ var GesturesManager = class { this.current_gesture.begin(direction, percentage, elapsed_time); } - gesture_update(client, type, direction, percentage, fingers, device, elapsed_time) { + gesture_update(source, type, direction, percentage, fingers, device, elapsed_time) { if (this.current_gesture == null) { debug_gesture("Gesture update but there's no current one."); return; @@ -294,7 +273,7 @@ var GesturesManager = class { this.current_gesture.update(direction, percentage, elapsed_time); } - gesture_end(client, type, direction, percentage, fingers, device, elapsed_time) { + gesture_end(source, type, direction, percentage, fingers, device, elapsed_time) { if (this.current_gesture == null) { debug_gesture("Gesture end but there's no current one."); return; diff --git a/js/ui/gestures/mprisController.js b/js/ui/gestures/mprisController.js deleted file mode 100644 index d9e088c4d0..0000000000 --- a/js/ui/gestures/mprisController.js +++ /dev/null @@ -1,294 +0,0 @@ -// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- - -// TODO: Have both the sound applet and gestures use this? -const Interfaces = imports.misc.interfaces; - -const MEDIA_PLAYER_2_PATH = "/org/mpris/MediaPlayer2"; -const MEDIA_PLAYER_2_NAME = "org.mpris.MediaPlayer2"; -const MEDIA_PLAYER_2_PLAYER_IFACE_NAME = "org.mpris.MediaPlayer2.Player"; - -const DEBUG_MPRIS = false; - -var debug_mpris = (...args) => { - if (DEBUG_MPRIS) { - global.log(...args); - } -} - -var Player = class { - constructor(controller, bus_name, owner) { - this.controller = controller; - this.bus_name = bus_name; - this.owner = owner; - - this.player_control = null; - this.prop_handler = null; - this.prop_changed_id = 0; - - this.can_control = false; - this.is_playing = false; - this.can_play = false; - this.can_pause = false; - this.can_go_next = false; - this.can_go_previous = false; - - let async_ready_cb = (proxy, error, property) => { - if (error) - log(error); - else { - this[property] = proxy; - this.dbus_acquired(); - } - }; - - Interfaces.getDBusProxyWithOwnerAsync(MEDIA_PLAYER_2_PLAYER_IFACE_NAME, - this.bus_name, - (p, e) => async_ready_cb(p, e, 'player_control')); - - Interfaces.getDBusPropertiesAsync(this.bus_name, - MEDIA_PLAYER_2_PATH, - (p, e) => async_ready_cb(p, e, 'prop_handler')); - } - - dbus_acquired() { - if (!this.prop_handler || !this.player_control) - return; - - this.prop_changed_id = this.prop_handler.connectSignal('PropertiesChanged', (proxy, sender, [iface, props]) => { - if (iface !== MEDIA_PLAYER_2_PLAYER_IFACE_NAME) { - return; - } - - this.update_from_props(Object.keys(props)); - }); - - this.update(); - } - - update() { - this.update_from_props(null); - } - - update_from_props(prop_names) { - debug_mpris("updated props: ", prop_names); - if (!prop_names || prop_names.includes("CanControl")) - this.prop_handler.GetRemote(MEDIA_PLAYER_2_PLAYER_IFACE_NAME, 'CanControl', (value, error) => { - if (!error) - this.can_control = value[0].unpack(); - debug_mpris("update can_control:", this.can_control); - }); - - if (!prop_names || prop_names.includes("PlaybackStatus")) - this.prop_handler.GetRemote(MEDIA_PLAYER_2_PLAYER_IFACE_NAME, 'PlaybackStatus', (value, error) => { - if (!error) - this.is_playing = ["Playing", "Paused"].includes(value[0].unpack()); - debug_mpris("update status:", this.is_playing); - }); - - if (!prop_names || prop_names.includes("CanGoNext")) - this.prop_handler.GetRemote(MEDIA_PLAYER_2_PLAYER_IFACE_NAME, 'CanGoNext', (value, error) => { - if (!error) - this.can_go_next = value[0].unpack(); - debug_mpris("update can_go_next ", this.can_go_next); - }); - - if (!prop_names || prop_names.includes("CanGoPrevious")) - this.prop_handler.GetRemote(MEDIA_PLAYER_2_PLAYER_IFACE_NAME, 'CanGoPrevious', (value, error) => { - if (!error) - this.can_go_previous = value[0].unpack(); - debug_mpris("update can_go_previous ", this.can_go_previous); - }); - - if (!prop_names || prop_names.includes("CanPlay")) - this.prop_handler.GetRemote(MEDIA_PLAYER_2_PLAYER_IFACE_NAME, 'CanPlay', (value, error) => { - if (!error) - this.can_play = value[0].unpack(); - debug_mpris("update can_play ", this.can_play); - - }); - - if (!prop_names || prop_names.includes("CanPause")) - this.prop_handler.GetRemote(MEDIA_PLAYER_2_PLAYER_IFACE_NAME, 'CanPause', (value, error) => { - if (!error) - this.can_pause = value[0].unpack(); - debug_mpris("update can_pause ", this.can_pause); - }); - } - - toggle_play() { - debug_mpris("toggle play"); - if (!this.can_control) { - return; - } - - // Should we rely on the CanPlay/Pause properties or just try? - this.player_control.PlayPauseRemote(); - } - - next_track() { - debug_mpris("next track"); - if (!this.can_control) { - return; - } - if (!this.can_go_next) { - return; - } - - this.player_control.NextRemote(); - } - - previous_track() { - debug_mpris("previous track"); - if (!this.can_control) { - return; - } - - if (!this.can_go_previous) { - return; - } - - this.player_control.PreviousRemote(); - } - - destroy() { - if (this.prop_handler != null) { - this.prop_handler.disconnectSignal(this.prop_changed_id); - this.prop_changed_id = 0; - } - - this.prop_handler = null; - this.player_control = null; - } -} - -var MprisController = class { - constructor() { - this._dbus = null; - - this._players = {}; - this._active_player = null; - this._owner_changed_id = 0; - - Interfaces.getDBusAsync((proxy, error) => { - if (error) { - global.logError(error); - return; - } - - this._dbus = proxy; - - // player DBus name pattern - let name_regex = /^org\.mpris\.MediaPlayer2\./; - // load players - this._dbus.ListNamesRemote((names) => { - for (let n in names[0]) { - let name = names[0][n]; - if (name_regex.test(name)) - this._dbus.GetNameOwnerRemote(name, (owner) => this._add_player(name, owner[0])); - } - }); - - // watch players - this._owner_changed_id = this._dbus.connectSignal('NameOwnerChanged', - (proxy, sender, [name, old_owner, new_owner]) => { - if (name_regex.test(name)) { - if (new_owner && !old_owner) - this._add_player(name, new_owner); - else if (old_owner && !new_owner) - this._remove_player(name, old_owner); - else - this._change_player_owner(name, old_owner, new_owner); - } - } - ); - }); - } - - shutdown() { - if (this._owner_changed_id > 0) { - this._dbus.disconnectSignal(this._owner_changed_id); - this._owner_changed_id = 0; - this._dbus = null; - } - - for (let player in this._players) { - this._players[player].destroy(); - delete this._players[player]; - } - - this._players = null; - } - - _is_instance(busName) { - // MPRIS instances are in the form - // org.mpris.MediaPlayer2.name.instanceXXXX - // ...except for VLC, which to this day uses - // org.mpris.MediaPlayer2.name-XXXX - return busName.split('.').length > 4 || - /^org\.mpris\.MediaPlayer2\.vlc-\d+$/.test(busName); - } - - _add_player(bus_name, owner) { - debug_mpris("Add player: ", bus_name, owner); - if (this._players[owner]) { - let prev_name = this._players[owner].bus_name; - if (this._isInstance(bus_name) && !this._isInstance(prev_name)) { - this._players[owner].bus_name = bus_name; - this._players[owner].update(); - } - else { - return; - } - } else if (owner) { - let player = new Player(this, bus_name, owner); - this._players[owner] = player; - } - } - - _remove_player(bus_name, owner) { - debug_mpris("Remove player: ", bus_name, owner); - if (this._players[owner] && this._players[owner].bus_name == bus_name) { - this._players[owner].destroy(); - delete this._players[owner]; - } - } - - _change_player_owner(bus_name, old_owner, new_owner) { - if (this._players[old_owner] && bus_name == this._players[old_owner].bus_name) { - this._players[new_owner] = this._players[old_owner]; - this._players[new_owner].owner = new_owner; - delete this._players[old_owner]; - this._players[new_owner].update(); - } - } - - get_player() { - let chosen_player = null; - let first_can_control = null; - - for (let name in this._players) { - let maybe_player = this._players[name]; - - if (maybe_player.is_playing && maybe_player.can_control) { - chosen_player = maybe_player; - break; - } - - if (maybe_player.can_control && first_can_control == null) { - first_can_control = maybe_player; - } - } - - if (chosen_player) { - return chosen_player; - } - - if (first_can_control != null) { - return first_can_control; - } - - return null; - } -} - - diff --git a/js/ui/gestures/nativeGestureSource.js b/js/ui/gestures/nativeGestureSource.js new file mode 100644 index 0000000000..9be631df45 --- /dev/null +++ b/js/ui/gestures/nativeGestureSource.js @@ -0,0 +1,110 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- + +const Signals = imports.signals; + +const { + TouchpadSwipeGesture, + TouchpadPinchGesture, + TouchSwipeGesture, + TouchPinchGesture, + TouchTapGesture +} = imports.ui.gestures.nativeGestures; + +const TOUCHPAD_SWIPE_FINGER_COUNTS = [3, 4]; +const TOUCHPAD_PINCH_FINGER_COUNTS = [2, 3, 4]; +const TOUCHSCREEN_FINGER_COUNTS = [2, 3, 4, 5]; + +/** + * NativeGestureSource - Gesture source using native Clutter touchpad events + */ +var NativeGestureSource = class { + constructor() { + this._touchpadSwipeGesture = null; + this._touchpadPinchGesture = null; + this._touchSwipeGestures = []; + this._touchPinchGestures = []; + this._touchTapGestures = []; + } + + setup() { + global.log('Setting up native gesture source'); + + this._touchpadSwipeGesture = new TouchpadSwipeGesture(TOUCHPAD_SWIPE_FINGER_COUNTS); + this._touchpadSwipeGesture.connect('detected-begin', this._onGestureBegin.bind(this)); + this._touchpadSwipeGesture.connect('detected-update', this._onGestureUpdate.bind(this)); + this._touchpadSwipeGesture.connect('detected-end', this._onGestureEnd.bind(this)); + + this._touchpadPinchGesture = new TouchpadPinchGesture(TOUCHPAD_PINCH_FINGER_COUNTS); + this._touchpadPinchGesture.connect('detected-begin', this._onGestureBegin.bind(this)); + this._touchpadPinchGesture.connect('detected-update', this._onGestureUpdate.bind(this)); + this._touchpadPinchGesture.connect('detected-end', this._onGestureEnd.bind(this)); + + for (let fingers of TOUCHSCREEN_FINGER_COUNTS) { + const swipeGesture = new TouchSwipeGesture(fingers); + swipeGesture.connect('detected-begin', this._onGestureBegin.bind(this)); + swipeGesture.connect('detected-update', this._onGestureUpdate.bind(this)); + swipeGesture.connect('detected-end', this._onGestureEnd.bind(this)); + global.stage.add_action_with_name(`touch-swipe-${fingers}`, swipeGesture); + this._touchSwipeGestures.push(swipeGesture); + + const pinchGesture = new TouchPinchGesture(fingers); + pinchGesture.connect('detected-begin', this._onGestureBegin.bind(this)); + pinchGesture.connect('detected-update', this._onGestureUpdate.bind(this)); + pinchGesture.connect('detected-end', this._onGestureEnd.bind(this)); + global.stage.add_action_with_name(`touch-pinch-${fingers}`, pinchGesture); + this._touchPinchGestures.push(pinchGesture); + + const tapGesture = new TouchTapGesture(fingers); + tapGesture.connect('detected-begin', this._onGestureBegin.bind(this)); + tapGesture.connect('detected-end', this._onGestureEnd.bind(this)); + global.stage.add_action_with_name(`touch-tap-${fingers}`, tapGesture); + this._touchTapGestures.push(tapGesture); + } + } + + shutdown() { + global.log('Shutting down native gesture source'); + + if (this._touchpadSwipeGesture) { + this._touchpadSwipeGesture.destroy(); + this._touchpadSwipeGesture = null; + } + + if (this._touchpadPinchGesture) { + this._touchpadPinchGesture.destroy(); + this._touchpadPinchGesture = null; + } + + for (let gesture of this._touchSwipeGestures) { + global.stage.remove_action(gesture); + } + this._touchSwipeGestures = []; + + for (let gesture of this._touchPinchGestures) { + global.stage.remove_action(gesture); + } + this._touchPinchGestures = []; + + for (let gesture of this._touchTapGestures) { + global.stage.remove_action(gesture); + } + this._touchTapGestures = []; + } + + isActive() { + return this._touchpadSwipeGesture !== null; + } + + _onGestureBegin(source, type, direction, percentage, fingers, device, time) { + this.emit('gesture-begin', type, direction, percentage, fingers, device, time); + } + + _onGestureUpdate(source, type, direction, percentage, fingers, device, time) { + this.emit('gesture-update', type, direction, percentage, fingers, device, time); + } + + _onGestureEnd(source, type, direction, percentage, fingers, device, time) { + this.emit('gesture-end', type, direction, percentage, fingers, device, time); + } +}; +Signals.addSignalMethods(NativeGestureSource.prototype); diff --git a/js/ui/gestures/nativeGestures.js b/js/ui/gestures/nativeGestures.js new file mode 100644 index 0000000000..a4828eed5b --- /dev/null +++ b/js/ui/gestures/nativeGestures.js @@ -0,0 +1,574 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- + +const { Clutter, GObject, Gio } = imports.gi; +const Signals = imports.signals; + +const { + GestureType, + GestureDirection, + DeviceType +} = imports.ui.gestures.gestureTypes; + +// Distance thresholds for gesture detection (in pixels) +const TOUCHPAD_BASE_HEIGHT = 300; +const TOUCHPAD_BASE_WIDTH = 400; +const DRAG_THRESHOLD_DISTANCE = 16; + +const TouchpadState = { + NONE: 0, + PENDING: 1, + HANDLING: 2, + IGNORED: 3, +}; + +var TouchpadSwipeGesture = class { + constructor(fingerCounts) { + this._fingerCounts = fingerCounts; // Array of supported finger counts + this._state = TouchpadState.NONE; + this._cumulativeX = 0; + this._cumulativeY = 0; + this._direction = GestureDirection.UNKNOWN; + this._fingers = 0; + this._percentage = 0; + this._baseDistance = 0; + this._startTime = 0; + + this._touchpadSettings = new Gio.Settings({ + schema_id: 'org.cinnamon.desktop.peripherals.touchpad', + }); + + this._stageEventId = global.stage.connect( + 'captured-event::touchpad', this._handleEvent.bind(this)); + } + + _handleEvent(actor, event) { + if (event.type() !== Clutter.EventType.TOUCHPAD_SWIPE) + return Clutter.EVENT_PROPAGATE; + + const phase = event.get_gesture_phase(); + const fingers = event.get_touchpad_gesture_finger_count(); + + // Reset state on gesture begin regardless of finger count + if (phase === Clutter.TouchpadGesturePhase.BEGIN) { + this._state = TouchpadState.NONE; + this._direction = GestureDirection.UNKNOWN; + this._cumulativeX = 0; + this._cumulativeY = 0; + this._percentage = 0; + } + + // Only handle if finger count matches one we're listening for + if (!this._fingerCounts.includes(fingers)) { + return Clutter.EVENT_PROPAGATE; + } + + if (this._state === TouchpadState.IGNORED) { + return Clutter.EVENT_PROPAGATE; + } + + const time = event.get_time(); + const [dx, dy] = event.get_gesture_motion_delta_unaccelerated(); + + // Apply natural scroll setting + let adjDx = dx; + let adjDy = dy; + if (this._touchpadSettings.get_boolean('natural-scroll')) { + adjDx = -dx; + adjDy = -dy; + } + + if (this._state === TouchpadState.NONE) { + if (dx === 0 && dy === 0) { + return Clutter.EVENT_PROPAGATE; + } + + this._fingers = fingers; + this._startTime = time; + this._state = TouchpadState.PENDING; + } + + if (this._state === TouchpadState.PENDING) { + this._cumulativeX += adjDx; + this._cumulativeY += adjDy; + + const distance = Math.sqrt(this._cumulativeX ** 2 + this._cumulativeY ** 2); + + if (distance >= DRAG_THRESHOLD_DISTANCE) { + // Determine direction + // Note: dx/dy are inverted for horizontal to match touchegg convention + if (Math.abs(this._cumulativeX) > Math.abs(this._cumulativeY)) { + this._direction = this._cumulativeX > 0 ? GestureDirection.LEFT : GestureDirection.RIGHT; + this._baseDistance = TOUCHPAD_BASE_WIDTH; + } else { + this._direction = this._cumulativeY > 0 ? GestureDirection.DOWN : GestureDirection.UP; + this._baseDistance = TOUCHPAD_BASE_HEIGHT; + } + + this._cumulativeX = 0; + this._cumulativeY = 0; + this._state = TouchpadState.HANDLING; + + this.emit('detected-begin', + GestureType.SWIPE, + this._direction, + 0, + this._fingers, + DeviceType.TOUCHPAD, + this._startTime); + } else { + return Clutter.EVENT_PROPAGATE; + } + } + + // Calculate delta along the gesture direction + // Note: horizontal is inverted to match touchegg convention + let delta = 0; + if (this._direction === GestureDirection.LEFT || this._direction === GestureDirection.RIGHT) { + delta = -adjDx; // Inverted for horizontal + if (this._direction === GestureDirection.LEFT) { + delta = -delta; + } + } else { + delta = adjDy; + if (this._direction === GestureDirection.UP) { + delta = -delta; + } + } + + // Update percentage (can exceed 100%) + this._percentage += (delta / this._baseDistance) * 100; + this._percentage = Math.max(0, this._percentage); + + const handling = this._state === TouchpadState.HANDLING; + + switch (phase) { + case Clutter.TouchpadGesturePhase.BEGIN: + case Clutter.TouchpadGesturePhase.UPDATE: + this.emit('detected-update', + GestureType.SWIPE, + this._direction, + this._percentage, + this._fingers, + DeviceType.TOUCHPAD, + time); + break; + + case Clutter.TouchpadGesturePhase.END: + case Clutter.TouchpadGesturePhase.CANCEL: + this.emit('detected-end', + GestureType.SWIPE, + this._direction, + this._percentage, + this._fingers, + DeviceType.TOUCHPAD, + time); + this._state = TouchpadState.NONE; + break; + } + + return handling + ? Clutter.EVENT_STOP + : Clutter.EVENT_PROPAGATE; + } + + destroy() { + if (this._stageEventId) { + global.stage.disconnect(this._stageEventId); + this._stageEventId = 0; + } + } +}; +Signals.addSignalMethods(TouchpadSwipeGesture.prototype); + +var TouchpadPinchGesture = class { + constructor(fingerCounts) { + this._fingerCounts = fingerCounts; + this._state = TouchpadState.NONE; + this._direction = GestureDirection.UNKNOWN; + this._fingers = 0; + this._percentage = 0; + this._initialScale = 1.0; + this._startTime = 0; + + this._stageEventId = global.stage.connect( + 'captured-event::touchpad', this._handleEvent.bind(this)); + } + + _handleEvent(actor, event) { + if (event.type() !== Clutter.EventType.TOUCHPAD_PINCH) + return Clutter.EVENT_PROPAGATE; + + const phase = event.get_gesture_phase(); + const fingers = event.get_touchpad_gesture_finger_count(); + const scale = event.get_gesture_pinch_scale(); + + // Reset state on gesture begin (but don't capture scale yet - it's 0.0 on BEGIN) + if (phase === Clutter.TouchpadGesturePhase.BEGIN) { + this._state = TouchpadState.NONE; + this._direction = GestureDirection.UNKNOWN; + this._initialScale = 0; + this._percentage = 0; + return Clutter.EVENT_PROPAGATE; // Wait for UPDATE events + } + + if (!this._fingerCounts.includes(fingers)) { + return Clutter.EVENT_PROPAGATE; + } + + if (this._state === TouchpadState.IGNORED) { + return Clutter.EVENT_PROPAGATE; + } + + const time = event.get_time(); + + // Capture initial scale on first UPDATE event + if (this._state === TouchpadState.NONE && phase === Clutter.TouchpadGesturePhase.UPDATE) { + this._fingers = fingers; + this._startTime = time; + this._initialScale = scale; + this._state = TouchpadState.PENDING; + return Clutter.EVENT_PROPAGATE; // Wait for more updates to determine direction + } + + if (this._state === TouchpadState.PENDING) { + const scaleDelta = scale - this._initialScale; + + // Wait for significant scale change to determine direction + if (Math.abs(scaleDelta) >= 0.05) { + this._direction = scaleDelta > 0 ? GestureDirection.OUT : GestureDirection.IN; + this._state = TouchpadState.HANDLING; + + this.emit('detected-begin', + GestureType.PINCH, + this._direction, + 0, + this._fingers, + DeviceType.TOUCHPAD, + this._startTime); + } else { + return Clutter.EVENT_PROPAGATE; + } + } + + // Calculate percentage based on scale change from initial + // Scale typically ranges from ~0.5 to ~1.5, so a change of 0.5 = 100% + if (this._direction === GestureDirection.IN) { + // Pinching in: scale decreases from initial + this._percentage = (this._initialScale - scale) * 200; + } else { + // Pinching out: scale increases from initial + this._percentage = (scale - this._initialScale) * 200; + } + this._percentage = Math.max(0, this._percentage); + + const handling = this._state === TouchpadState.HANDLING; + + switch (phase) { + case Clutter.TouchpadGesturePhase.BEGIN: + case Clutter.TouchpadGesturePhase.UPDATE: + this.emit('detected-update', + GestureType.PINCH, + this._direction, + this._percentage, + this._fingers, + DeviceType.TOUCHPAD, + time); + break; + + case Clutter.TouchpadGesturePhase.END: + case Clutter.TouchpadGesturePhase.CANCEL: + this.emit('detected-end', + GestureType.PINCH, + this._direction, + this._percentage, + this._fingers, + DeviceType.TOUCHPAD, + time); + this._state = TouchpadState.NONE; + break; + } + + return handling + ? Clutter.EVENT_STOP + : Clutter.EVENT_PROPAGATE; + } + + destroy() { + if (this._stageEventId) { + global.stage.disconnect(this._stageEventId); + this._stageEventId = 0; + } + } +}; +Signals.addSignalMethods(TouchpadPinchGesture.prototype); + +var TouchSwipeGesture = GObject.registerClass({ + Signals: { + 'detected-begin': { param_types: [GObject.TYPE_UINT, GObject.TYPE_UINT, GObject.TYPE_DOUBLE, GObject.TYPE_INT, GObject.TYPE_UINT, GObject.TYPE_INT64] }, + 'detected-update': { param_types: [GObject.TYPE_UINT, GObject.TYPE_UINT, GObject.TYPE_DOUBLE, GObject.TYPE_INT, GObject.TYPE_UINT, GObject.TYPE_INT64] }, + 'detected-end': { param_types: [GObject.TYPE_UINT, GObject.TYPE_UINT, GObject.TYPE_DOUBLE, GObject.TYPE_INT, GObject.TYPE_UINT, GObject.TYPE_INT64] }, + }, +}, class TouchSwipeGesture extends Clutter.GestureAction { + _init(nTouchPoints) { + super._init(); + this.set_n_touch_points(nTouchPoints); + this.set_threshold_trigger_edge(Clutter.GestureTriggerEdge.AFTER); + + this._direction = GestureDirection.UNKNOWN; + this._lastPosition = { x: 0, y: 0 }; + this._startPosition = { x: 0, y: 0 }; + this._percentage = 0; + this._distance = global.screen_height; + this._nTouchPoints = nTouchPoints; + } + + vfunc_gesture_prepare(actor) { + if (!super.vfunc_gesture_prepare(actor)) { + return false; + } + + const [xPress, yPress] = this.get_press_coords(0); + const [x, y] = this.get_motion_coords(0); + const xDelta = x - xPress; + const yDelta = y - yPress; + + // Determine direction + if (Math.abs(xDelta) > Math.abs(yDelta)) { + this._direction = xDelta > 0 ? GestureDirection.RIGHT : GestureDirection.LEFT; + this._distance = global.screen_width; + } else { + this._direction = yDelta > 0 ? GestureDirection.DOWN : GestureDirection.UP; + this._distance = global.screen_height; + } + + this._startPosition = { x: xPress, y: yPress }; + this._lastPosition = { x, y }; + this._percentage = 0; + + const time = this.get_last_event(0).get_time(); + this.emit('detected-begin', + GestureType.SWIPE, + this._direction, + 0, + this._nTouchPoints, + DeviceType.TOUCHSCREEN, + time); + + return true; + } + + vfunc_gesture_progress(_actor) { + const [x, y] = this.get_motion_coords(0); + const time = this.get_last_event(0).get_time(); + + let delta = 0; + if (this._direction === GestureDirection.LEFT || this._direction === GestureDirection.RIGHT) { + delta = x - this._lastPosition.x; + if (this._direction === GestureDirection.LEFT) { + delta = -delta; + } + } else { + delta = y - this._lastPosition.y; + if (this._direction === GestureDirection.UP) { + delta = -delta; + } + } + + this._percentage += (delta / this._distance) * 100; + this._percentage = Math.max(0, this._percentage); + + this._lastPosition = { x, y }; + + this.emit('detected-update', + GestureType.SWIPE, + this._direction, + this._percentage, + this._nTouchPoints, + DeviceType.TOUCHSCREEN, + time); + + return true; + } + + vfunc_gesture_end(_actor) { + const time = this.get_last_event(0).get_time(); + this.emit('detected-end', + GestureType.SWIPE, + this._direction, + this._percentage, + this._nTouchPoints, + DeviceType.TOUCHSCREEN, + time); + } + + vfunc_gesture_cancel(_actor) { + const time = Clutter.get_current_event_time(); + this.emit('detected-end', + GestureType.SWIPE, + this._direction, + 0, + this._nTouchPoints, + DeviceType.TOUCHSCREEN, + time); + } +}); + +var TouchPinchGesture = GObject.registerClass({ + Signals: { + 'detected-begin': { param_types: [GObject.TYPE_UINT, GObject.TYPE_UINT, GObject.TYPE_DOUBLE, GObject.TYPE_INT, GObject.TYPE_UINT, GObject.TYPE_INT64] }, + 'detected-update': { param_types: [GObject.TYPE_UINT, GObject.TYPE_UINT, GObject.TYPE_DOUBLE, GObject.TYPE_INT, GObject.TYPE_UINT, GObject.TYPE_INT64] }, + 'detected-end': { param_types: [GObject.TYPE_UINT, GObject.TYPE_UINT, GObject.TYPE_DOUBLE, GObject.TYPE_INT, GObject.TYPE_UINT, GObject.TYPE_INT64] }, + }, +}, class TouchPinchGesture extends Clutter.GestureAction { + _init(nTouchPoints) { + super._init(); + // Pinch requires at least 2 touch points + this.set_n_touch_points(Math.max(2, nTouchPoints)); + this.set_threshold_trigger_edge(Clutter.GestureTriggerEdge.AFTER); + + this._direction = GestureDirection.UNKNOWN; + this._initialDistance = 0; + this._percentage = 0; + this._nTouchPoints = nTouchPoints; + } + + _getPointsDistance() { + // Calculate distance between first two touch points + if (this.get_n_current_points() < 2) { + return 0; + } + + const [x1, y1] = this.get_motion_coords(0); + const [x2, y2] = this.get_motion_coords(1); + return Math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2); + } + + vfunc_gesture_prepare(actor) { + if (!super.vfunc_gesture_prepare(actor)) { + return false; + } + + this._initialDistance = this._getPointsDistance(); + if (this._initialDistance === 0) { + return false; + } + + this._direction = GestureDirection.UNKNOWN; + this._percentage = 0; + + return true; + } + + vfunc_gesture_progress(_actor) { + const currentDistance = this._getPointsDistance(); + if (this._initialDistance === 0) { + return true; + } + + const time = this.get_last_event(0).get_time(); + const scale = currentDistance / this._initialDistance; + + // Determine direction on first significant change + if (this._direction === GestureDirection.UNKNOWN) { + if (Math.abs(scale - 1.0) >= 0.05) { + this._direction = scale > 1.0 ? GestureDirection.OUT : GestureDirection.IN; + + this.emit('detected-begin', + GestureType.PINCH, + this._direction, + 0, + this._nTouchPoints, + DeviceType.TOUCHSCREEN, + time); + } else { + return true; + } + } + + // Calculate percentage + if (this._direction === GestureDirection.IN) { + this._percentage = (1.0 - scale) * 200; + } else { + this._percentage = (scale - 1.0) * 200; + } + this._percentage = Math.max(0, this._percentage); + + this.emit('detected-update', + GestureType.PINCH, + this._direction, + this._percentage, + this._nTouchPoints, + DeviceType.TOUCHSCREEN, + time); + + return true; + } + + vfunc_gesture_end(_actor) { + if (this._direction === GestureDirection.UNKNOWN) { + return; + } + + const time = this.get_last_event(0).get_time(); + this.emit('detected-end', + GestureType.PINCH, + this._direction, + this._percentage, + this._nTouchPoints, + DeviceType.TOUCHSCREEN, + time); + } + + vfunc_gesture_cancel(_actor) { + if (this._direction === GestureDirection.UNKNOWN) { + return; + } + + const time = Clutter.get_current_event_time(); + this.emit('detected-end', + GestureType.PINCH, + this._direction, + 0, + this._nTouchPoints, + DeviceType.TOUCHSCREEN, + time); + } +}); + +var TouchTapGesture = GObject.registerClass({ + Signals: { + 'detected-begin': { param_types: [GObject.TYPE_UINT, GObject.TYPE_UINT, GObject.TYPE_DOUBLE, GObject.TYPE_INT, GObject.TYPE_UINT, GObject.TYPE_INT64] }, + 'detected-update': { param_types: [GObject.TYPE_UINT, GObject.TYPE_UINT, GObject.TYPE_DOUBLE, GObject.TYPE_INT, GObject.TYPE_UINT, GObject.TYPE_INT64] }, + 'detected-end': { param_types: [GObject.TYPE_UINT, GObject.TYPE_UINT, GObject.TYPE_DOUBLE, GObject.TYPE_INT, GObject.TYPE_UINT, GObject.TYPE_INT64] }, + }, +}, class TouchTapGesture extends Clutter.TapAction { + _init(nTouchPoints) { + super._init(); + this.set_n_touch_points(nTouchPoints); + + this._nTouchPoints = nTouchPoints; + } + + vfunc_tap(actor) { + const time = Clutter.get_current_event_time(); + + // For tap gestures, we emit begin and end immediately with 100% completion + this.emit('detected-begin', + GestureType.TAP, + GestureDirection.UNKNOWN, + 100, + this._nTouchPoints, + DeviceType.TOUCHSCREEN, + time); + + this.emit('detected-end', + GestureType.TAP, + GestureDirection.UNKNOWN, + 100, + this._nTouchPoints, + DeviceType.TOUCHSCREEN, + time); + + return true; + } +}); diff --git a/js/ui/gestures/toucheggGestureSource.js b/js/ui/gestures/toucheggGestureSource.js new file mode 100644 index 0000000000..10a100e37c --- /dev/null +++ b/js/ui/gestures/toucheggGestureSource.js @@ -0,0 +1,60 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- + +const { Cinnamon } = imports.gi; +const Signals = imports.signals; +const SignalManager = imports.misc.signalManager; + +/** + * ToucheggGestureSource - Gesture source using touchegg daemon + */ +var ToucheggGestureSource = class { + constructor() { + this._client = null; + this._signalManager = new SignalManager.SignalManager(null); + } + + setup() { + if (this._client !== null) { + return; + } + + global.log('Setting up touchegg gesture source'); + + this._client = new Cinnamon.ToucheggClient(); + + // Touchegg client already emits 'gesture-begin/update/end' signals + // Just forward them + this._signalManager.connect(this._client, "gesture-begin", this._onGestureBegin, this); + this._signalManager.connect(this._client, "gesture-update", this._onGestureUpdate, this); + this._signalManager.connect(this._client, "gesture-end", this._onGestureEnd, this); + } + + shutdown() { + if (this._client === null) { + return; + } + + global.log('Shutting down touchegg gesture source'); + this._signalManager.disconnect("gesture-begin"); + this._signalManager.disconnect("gesture-update"); + this._signalManager.disconnect("gesture-end"); + this._client = null; + } + + isActive() { + return this._client !== null; + } + + _onGestureBegin(client, type, direction, percentage, fingers, device, time) { + this.emit('gesture-begin', type, direction, percentage, fingers, device, time); + } + + _onGestureUpdate(client, type, direction, percentage, fingers, device, time) { + this.emit('gesture-update', type, direction, percentage, fingers, device, time); + } + + _onGestureEnd(client, type, direction, percentage, fingers, device, time) { + this.emit('gesture-end', type, direction, percentage, fingers, device, time); + } +}; +Signals.addSignalMethods(ToucheggGestureSource.prototype); diff --git a/po/POTFILES.in b/po/POTFILES.in index 443e3a52e7..b17349f5b7 100644 --- a/po/POTFILES.in +++ b/po/POTFILES.in @@ -10,7 +10,6 @@ data/cinnamon.desktop.in.in # data/org.cinnamon.gestures.gschema.xml # data/org.cinnamon.gschema.xml -src/cinnamon-touchegg-client.c src/cinnamon-doc-system.c src/cinnamon-tray-manager.c src/cinnamon-window-tracker.c @@ -105,6 +104,7 @@ src/cinnamon-recorder.c src/cinnamon-screen.c src/cinnamon-screenshot.c src/cinnamon-secure-text-buffer.c +src/cinnamon-touchegg-client.c files/usr/share/cinnamon/cinnamon-desktop-editor/directory-editor.ui files/usr/share/cinnamon/cinnamon-desktop-editor/launcher-editor.ui @@ -198,10 +198,12 @@ js/ui/expoThumbnail.js js/ui/extension.js js/ui/extensionSystem.js js/ui/flashspot.js -js/ui/gestures/ToucheggTypes.js +js/ui/gestures/gestureTypes.js js/ui/gestures/actions.js js/ui/gestures/gesturesManager.js -js/ui/gestures/mprisController.js +js/ui/gestures/nativeGestures.js +js/ui/gestures/nativeGestureSource.js +js/ui/gestures/toucheggGestureSource.js js/ui/hotCorner.js js/ui/ibusCandidatePopup.js js/ui/iconGrid.js diff --git a/src/cinnamon-touchegg-client.c b/src/cinnamon-touchegg-client.c index 073586b2e2..d1d8b11589 100644 --- a/src/cinnamon-touchegg-client.c +++ b/src/cinnamon-touchegg-client.c @@ -59,7 +59,8 @@ emit_our_signal (CinnamonToucheggClient *client, g_debug ("CinnamonToucheggClient signal: %s: type %u, direction %u, progress %0.1f, fingers %d, device %u, elapsed_time %lu", our_signal, type, direction, percentage, fingers, device, elapsed_time); - g_signal_emit_by_name (client, our_signal, type, direction, percentage, fingers, device, g_get_monotonic_time ()); + // Use milliseconds for consistency with Clutter event.get_time() + g_signal_emit_by_name (client, our_signal, type, direction, percentage, fingers, device, g_get_monotonic_time () / 1000); } static void From d4db30d72102016ed502ebdfc249734ada4c9b14 Mon Sep 17 00:00:00 2001 From: corbin Date: Sat, 12 Sep 2026 17:42:38 -0700 Subject: [PATCH 2/2] gestures: Add one-to-one tracked gestures Add tracked gestures. A tracked gesture follows the fingers. It updates as the fingers move. It commits or cancels when the fingers lift. Use tracked gestures for these actions: switch workspace, show Expo, show Scale, switch windows, tile a window, maximize a window, and minimize a window. Port the swipe-tracking math from GNOME Shell. Use this port for the native gesture path on Wayland. Use the same port for the touchegg gesture path on X11. Extend the Gestures settings page. Add a picture for each finger count. Tint each picture to match the theme. Add one-click templates for common setups; each also clears the gestures it does not use. List gesture rows up, down, left, then right. Filter the shown gestures by what the hardware and the session support. Add a link to this page from the Touchpad page. Fix bugs. The tile preview now shows what push_tile() will really do. The workspace scroll in Scale no longer fights its own animation. Resetting gestures no longer disables the feature. A template button no longer locks out the other button; only the highlight changes, and it follows whatever gestures are actually set. On X11, touchegg reads the touchpad through its own libinput handle, so disabling the touchpad did not stop gestures; GesturesManager now also checks the touchpad's own enabled state itself. --- data/meson.build | 5 +- data/org.cinnamon.gestures.gschema.xml | 12 +- .../graphics/2-fingers-touchpad.svg | 362 +++++++++ .../graphics/3-fingers-touchpad.svg | 438 +++++++++++ .../graphics/4-fingers-touchpad.svg | 468 ++++++++++++ .../graphics/5-fingers-touchpad.svg | 507 +++++++++++++ .../cinnamon-settings/graphics/Readme.txt | 3 + .../cinnamon-settings/modules/cs_gestures.py | 614 +++++++++++++--- .../cinnamon-settings/modules/cs_mouse.py | 13 + js/ui/appSwitcher/appSwitcher.js | 86 +++ js/ui/appSwitcher/appSwitcher3D.js | 2 +- js/ui/appSwitcher/classicSwitcher.js | 90 ++- js/ui/expo.js | 214 +++++- js/ui/expoThumbnail.js | 38 +- js/ui/gestures/actions.js | 695 +++++++++++++++++- js/ui/gestures/gesturesManager.js | 74 +- js/ui/gestures/nativeGestures.js | 39 +- js/ui/gestures/tracking.js | 156 ++++ js/ui/overview.js | 147 +++- js/ui/windowManager.js | 33 +- js/ui/workspace.js | 83 ++- js/ui/workspaceAnimation.js | 499 +++++++++++++ js/ui/workspacesView.js | 84 +++ po/POTFILES.in | 1 + 24 files changed, 4484 insertions(+), 179 deletions(-) create mode 100644 files/usr/share/cinnamon/cinnamon-settings/graphics/2-fingers-touchpad.svg create mode 100644 files/usr/share/cinnamon/cinnamon-settings/graphics/3-fingers-touchpad.svg create mode 100644 files/usr/share/cinnamon/cinnamon-settings/graphics/4-fingers-touchpad.svg create mode 100644 files/usr/share/cinnamon/cinnamon-settings/graphics/5-fingers-touchpad.svg create mode 100644 files/usr/share/cinnamon/cinnamon-settings/graphics/Readme.txt create mode 100644 js/ui/gestures/tracking.js create mode 100644 js/ui/workspaceAnimation.js diff --git a/data/meson.build b/data/meson.build index ed07398fab..6d6e0f9ed9 100644 --- a/data/meson.build +++ b/data/meson.build @@ -50,7 +50,10 @@ subdir('xsessions') subdir('services') install_data( - ['org.cinnamon.gschema.xml', 'org.cinnamon.gestures.gschema.xml'], + [ + 'org.cinnamon.gschema.xml', + 'org.cinnamon.gestures.gschema.xml', + ], install_dir: schemadir, ) diff --git a/data/org.cinnamon.gestures.gschema.xml b/data/org.cinnamon.gestures.gschema.xml index cce5e2a609..46cb63545b 100644 --- a/data/org.cinnamon.gestures.gschema.xml +++ b/data/org.cinnamon.gestures.gschema.xml @@ -32,16 +32,16 @@ - 'WORKSPACE_NEXT' + 'WORKSPACE_NEXT::follow' - 'WORKSPACE_PREVIOUS' + 'WORKSPACE_PREVIOUS::follow' - 'TOGGLE_EXPO' + 'TOGGLE_OVERVIEW::follow' - 'TOGGLE_OVERVIEW' + '' @@ -51,10 +51,10 @@ 'WINDOW_WORKSPACE_NEXT' - 'VOLUME_UP' + 'TOGGLE_EXPO::follow' - 'VOLUME_DOWN' + '' diff --git a/files/usr/share/cinnamon/cinnamon-settings/graphics/2-fingers-touchpad.svg b/files/usr/share/cinnamon/cinnamon-settings/graphics/2-fingers-touchpad.svg new file mode 100644 index 0000000000..a1da3cb976 --- /dev/null +++ b/files/usr/share/cinnamon/cinnamon-settings/graphics/2-fingers-touchpad.svg @@ -0,0 +1,362 @@ + + + +image/svg+xmlOpenclipartMultiTouch-Interface Pixel-theme 5-fingers-Pinch2011-09-16T07:24:29a dedicated set of mutlitouch functions icons in pixel cursor stylehttps://openclipart.org/detail/160885/multitouch-interface-pixel-theme-5-fingers-pinch-by-benboisBenBoisandroidcursorgestureiosipadiphonemultitouchpixelpointertablet diff --git a/files/usr/share/cinnamon/cinnamon-settings/graphics/3-fingers-touchpad.svg b/files/usr/share/cinnamon/cinnamon-settings/graphics/3-fingers-touchpad.svg new file mode 100644 index 0000000000..3eb0dd5e58 --- /dev/null +++ b/files/usr/share/cinnamon/cinnamon-settings/graphics/3-fingers-touchpad.svg @@ -0,0 +1,438 @@ + + + +image/svg+xmlOpenclipartMultiTouch-Interface Pixel-theme 5-fingers-Pinch2011-09-16T07:24:29a dedicated set of mutlitouch functions icons in pixel cursor stylehttps://openclipart.org/detail/160885/multitouch-interface-pixel-theme-5-fingers-pinch-by-benboisBenBoisandroidcursorgestureiosipadiphonemultitouchpixelpointertablet diff --git a/files/usr/share/cinnamon/cinnamon-settings/graphics/4-fingers-touchpad.svg b/files/usr/share/cinnamon/cinnamon-settings/graphics/4-fingers-touchpad.svg new file mode 100644 index 0000000000..de808f4505 --- /dev/null +++ b/files/usr/share/cinnamon/cinnamon-settings/graphics/4-fingers-touchpad.svg @@ -0,0 +1,468 @@ + + + +image/svg+xmlOpenclipartMultiTouch-Interface Pixel-theme 5-fingers-Pinch2011-09-16T07:24:29a dedicated set of mutlitouch functions icons in pixel cursor stylehttps://openclipart.org/detail/160885/multitouch-interface-pixel-theme-5-fingers-pinch-by-benboisBenBoisandroidcursorgestureiosipadiphonemultitouchpixelpointertablet diff --git a/files/usr/share/cinnamon/cinnamon-settings/graphics/5-fingers-touchpad.svg b/files/usr/share/cinnamon/cinnamon-settings/graphics/5-fingers-touchpad.svg new file mode 100644 index 0000000000..d84e654c32 --- /dev/null +++ b/files/usr/share/cinnamon/cinnamon-settings/graphics/5-fingers-touchpad.svg @@ -0,0 +1,507 @@ + + + +image/svg+xmlOpenclipartMultiTouch-Interface Pixel-theme 5-fingers-Pinch2011-09-16T07:24:29a dedicated set of mutlitouch functions icons in pixel cursor stylehttps://openclipart.org/detail/160885/multitouch-interface-pixel-theme-5-fingers-pinch-by-benboisBenBoisandroidcursorgestureiosipadiphonemultitouchpixelpointertablet diff --git a/files/usr/share/cinnamon/cinnamon-settings/graphics/Readme.txt b/files/usr/share/cinnamon/cinnamon-settings/graphics/Readme.txt new file mode 100644 index 0000000000..d46cbb9b0e --- /dev/null +++ b/files/usr/share/cinnamon/cinnamon-settings/graphics/Readme.txt @@ -0,0 +1,3 @@ +Touchpad hands were sourced and modified from CC0 Sources: +https://publicdomainvectors.org/en/free-clipart/Pixel-fingers/38257.html +https://publicdomainvectors.org/en/free-clipart/Pixel-pointing-hand/38242.html diff --git a/files/usr/share/cinnamon/cinnamon-settings/modules/cs_gestures.py b/files/usr/share/cinnamon/cinnamon-settings/modules/cs_gestures.py index 6e6d5f07c4..e5e0276b3c 100644 --- a/files/usr/share/cinnamon/cinnamon-settings/modules/cs_gestures.py +++ b/files/usr/share/cinnamon/cinnamon-settings/modules/cs_gestures.py @@ -1,10 +1,12 @@ #!/usr/bin/python3 +import glob +import os import subprocess from functools import cmp_to_key import gi gi.require_version('Gtk', '3.0') -from gi.repository import Gio, Gtk +from gi.repository import Gio, Gtk, GdkPixbuf from bin import util from bin.SettingsWidgets import SidePage, SettingsWidget @@ -18,46 +20,227 @@ ] ACTIONS = [ - # Action, Label, Allow phase selection, extra widget type [entry|slider|none], default custom val - ["", _("Disabled"), False, "none", ""], - ["WORKSPACE_NEXT", _("Switch to right workspace"), True, "none", ""], - ["WORKSPACE_PREVIOUS", _("Switch to left workspace"), True, "none", ""], + # Action, Label, Allow phase selection, extra widget type (e.g one-to-one) + # [entry|slider|none], default custom value, can follow the fingers + ["", _("Disabled"), False, "none", "", False], + ["WORKSPACE_NEXT", _("Switch to right workspace"), True, "none", "", True], + ["WORKSPACE_PREVIOUS", _("Switch to left workspace"), True, "none", "", True], # ["WORKSPACE_UP", _("Switch to the workspace above"), "none", ""], # ["WORKSPACE_DOWN", _("Switch to the workspace below"), "none", ""], - ["TOGGLE_EXPO", _("Show the workspace selector (Expo)"), True, "none", ""], - ["TOGGLE_OVERVIEW", _("Show the window selector (Scale)"), True, "none", ""], - ["MINIMIZE", _("Minimize window"), True, "none", ""], - ["MAXIMIZE", _("Maximize window"), True, "none", ""], - ["CLOSE", _("Close window"), True, "none", ""], - ["WINDOW_WORKSPACE_NEXT", _("Move window to right workspace"), True, "none", ""], - ["WINDOW_WORKSPACE_PREVIOUS", _("Move window to left workspace"), True, "none", ""], - ["FULLSCREEN", _("Make window fullscreen"), True, "none", ""], - ["UNFULLSCREEN", _("Exit window fullscreen"), True, "none", ""], - ["PUSH_TILE_UP", _("Push tile up"), True, "none", ""], - ["PUSH_TILE_DOWN", _("Push tile down"), True, "none", ""], - ["PUSH_TILE_LEFT", _("Push tile left"), True, "none", ""], - ["PUSH_TILE_RIGHT", _("Push tile right"), True, "none", ""], - ["TOGGLE_DESKTOP", _("Show desktop"), True, "none", ""], - ["VOLUME_UP", _("Volume up"), False, "none", ""], - ["VOLUME_DOWN", _("Volume down"), False, "none", ""], - ["TOGGLE_MUTE", _("Volume mute"), True, "none", ""], - ["MEDIA_PLAY_PAUSE", _("Toggle Play / Pause"), True, "none", ""], - ["MEDIA_NEXT", _("Next track"), True, "none", ""], - ["MEDIA_PREVIOUS", _("Previous track"), True, "none", ""], - ["ZOOM_IN", _("Zoom desktop in"), False, "slider", "50"], - ["ZOOM_OUT", _("Zoom desktop out"), False, "slider", "50"], - ["EXEC", _("Run a command"), True, "entry", ""], + ["TOGGLE_EXPO", _("(Expo) Show the workspace selector "), True, "none", "", True], + ["TOGGLE_OVERVIEW", _("(Scale) Show the window selector"), True, "none", "", True], + ["SWITCH_WINDOWS", _("Switch windows"), True, "none", "", True], + ["MINIMIZE", _("Minimize window"), True, "none", "", True], + ["MAXIMIZE", _("Maximize window"), True, "none", "", True], + ["CLOSE", _("Close window"), True, "none", "", False], + ["WINDOW_WORKSPACE_NEXT", _("Move window to right workspace"), True, "none", "", False], + ["WINDOW_WORKSPACE_PREVIOUS", _("Move window to left workspace"), True, "none", "", False], + ["FULLSCREEN", _("Make window fullscreen"), True, "none", "", False], + ["UNFULLSCREEN", _("Exit window fullscreen"), True, "none", "", False], + ["PUSH_TILE_UP", _("Push tile up"), True, "none", "", True], + ["PUSH_TILE_DOWN", _("Push tile down"), True, "none", "", True], + ["PUSH_TILE_LEFT", _("Push tile left"), True, "none", "", True], + ["PUSH_TILE_RIGHT", _("Push tile right"), True, "none", "", True], + ["TOGGLE_DESKTOP", _("Show desktop"), True, "none", "", False], + ["VOLUME_UP", _("Volume up"), False, "none", "", False], + ["VOLUME_DOWN", _("Volume down"), False, "none", "", False], + ["TOGGLE_MUTE", _("Volume mute"), True, "none", "", False], + ["MEDIA_PLAY_PAUSE", _("Toggle Play / Pause"), True, "none", "", False], + ["MEDIA_NEXT", _("Next track"), True, "none", "", False], + ["MEDIA_PREVIOUS", _("Previous track"), True, "none", "", False], + ["ZOOM_IN", _("Zoom desktop in"), False, "slider", "50", False], + ["ZOOM_OUT", _("Zoom desktop out"), False, "slider", "50", False], + ["EXEC", _("Run a command"), True, "entry", "", False], ] -[ACTION_ID_COL, ACTION_LABEL_COL, ACTION_ALLOW_PHASE_SELECT_COL, ACTION_EXTRA_WIDGET_TYPE_COL, ACTION_DEFAULT_CUSTOM_VALUE_COL] = range(0, 5) +[ACTION_ID_COL, ACTION_LABEL_COL, ACTION_ALLOW_PHASE_SELECT_COL, ACTION_EXTRA_WIDGET_TYPE_COL, + ACTION_DEFAULT_CUSTOM_VALUE_COL, ACTION_ALLOW_FOLLOW_COL] = range(0, 6) PHASES = [ ["start", _("Trigger at gesture start")], ["end", _("Trigger at gesture end")] ] +# Follow or modern guestures are not +# in the list above as they behave differently +# and only some actions can use them for now as we have +# to do more work per action to annimate them nicely +FOLLOW_PHASE = "follow" +DEFAULT_PHASE = "end" + +# Shipped with the xapp icons, used as the toggle for modern gestures +FOLLOW_ICON = "xsi-boot-menu-symbolic" + +# Pictures of a swipe, one per finger count. Live beside this module, +# not under the icon theme: each is an illustration, not a looked-up icon. +SETTINGS_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +GRAPHICS_DIR = os.path.join(SETTINGS_DIR, "graphics") + +# How wide to draw one of those illustrations. +SWIPE_ILLUSTRATION_SIZE = 180 + + +def swipe_illustration_path(fingers): + return os.path.join(GRAPHICS_DIR, "%d-fingers-touchpad.svg" % fingers) + +# How much of the illustration's grey-to-white gradient to pull towards the +# theme's accent color, so Mint-Y/Mint-X/Mint-L (and anything else that +# defines the same named color) give the hands a mild tint of their own. +GRADIENT_TINT_AMOUNT = 0.25 + + +def _tinted_hex(hex_color, accent, amount): + r, g, b = (int(hex_color[i:i + 2], 16) for i in (0, 2, 4)) + r += (accent.red * 255 - r) * amount + g += (accent.green * 255 - g) * amount + b += (accent.blue * 255 - b) * amount + return "#%02x%02x%02x" % (round(r), round(g), round(b)) + + +def themed_swipe_pixbuf(widget, fingers, size): + with open(swipe_illustration_path(fingers), encoding="utf-8") as f: + svg = f.read() + + found, accent = widget.get_style_context().lookup_color("theme_selected_bg_color") + if found: + for stop in ("8d8d8d", "ffffff"): + svg = svg.replace("stop-color:#%s" % stop, + "stop-color:" + _tinted_hex(stop, accent, GRADIENT_TINT_AMOUNT)) + + loader = GdkPixbuf.PixbufLoader() + loader.set_size(size, size) + loader.write(svg.encode("utf-8")) + loader.close() + return loader.get_pixbuf() + +# The column is there for every row, so that the rows line up. +FOLLOW_COLUMN_WIDTH = 42 + +# One-click bundles of gesture bindings: (name, {key: "ACTION::phase"}). +# Add an entry to add a button; the layout wraps on its own. +GESTURE_TEMPLATES = [ + (_("Easy Workspace Gestures"), { + "swipe-up-3": "TOGGLE_OVERVIEW::follow", + "swipe-left-3": "WORKSPACE_NEXT::follow", + "swipe-right-3": "WORKSPACE_PREVIOUS::follow", + "swipe-down-3": "", + "swipe-up-4": "TOGGLE_EXPO::follow", + "swipe-down-4": "", + }), + (_("Easy Window Management Gestures"), { + "swipe-up-3": "PUSH_TILE_UP::follow", + "swipe-left-3": "PUSH_TILE_LEFT::follow", + "swipe-right-3": "PUSH_TILE_RIGHT::follow", + "swipe-down-3": "PUSH_TILE_DOWN::follow", + "swipe-up-4": "MAXIMIZE::follow", + "swipe-down-4": "MINIMIZE::follow", + }), +] + +GESTURE_TEMPLATE_MAX_COLUMNS = 4 + +TEMPLATE_DIRECTIONS = {"up": _("up"), "down": _("down"), "left": _("left"), + "right": _("right"), "in": _("in"), "out": _("out")} + + +def _action_label(action_id): + return next((row[ACTION_LABEL_COL] for row in ACTIONS if row[ACTION_ID_COL] == action_id), action_id) + + +def template_tooltip(bindings): + lines = [] + for key in sorted(bindings, key=lambda k: (int(k.split("-")[2]), k.split("-")[1])): + _kind, direction, fingers = key.split("-") + action_id = bindings[key].split("::")[0] + lines.append(_("%s-finger swipe %s: %s") % + (fingers, TEMPLATE_DIRECTIONS.get(direction, direction), _action_label(action_id))) + return "\n".join(lines) + +# Finger counts a touchpad does not report. Two fingers on one is +# scrolling, and no touchpad has five to spare. +TOUCH_ONLY_FINGERS = {2, 5} + [PHASE_ID_COL, PHASE_LABEL_COL] = range(0, 2) +# The kernel reports how many fingers are down with these key codes. +FINGER_TOOL_CODES = {3: 0x14e, 4: 0x14f, 5: 0x148} +BTN_TOOL_FINGER = 0x145 + +# A device with these but not the button above is a touchscreen: it +# reports where it is touched, not how many fingers are down. +BTN_TOUCH = 0x14a +ABS_MT_POSITION_X = 0x35 + + +def _capability_bits(path): + # The kernel writes this as space-separated hex chunks, highest first. + try: + with open(path) as f: + value = f.read().strip() + except OSError: + return set() + + number = 0 + for index, chunk in enumerate(reversed(value.split())): + try: + number |= int(chunk, 16) << (64 * index) + except ValueError: + return set() + + # Bit N set means N is in the returned set. + return {bit for bit in range(1024) if number >> bit & 1} + + +def supported_finger_counts(): + # Falls back to offering everything if nothing can be read. + counts = set() + found_touchpad = False + + for device in glob.glob("/sys/class/input/event*/device"): + keys = _capability_bits(os.path.join(device, "capabilities/key")) + + if BTN_TOOL_FINGER not in keys: + continue + + found_touchpad = True + counts.update(count for count, code in FINGER_TOOL_CODES.items() if code in keys) + + if not found_touchpad: + return set(FINGER_TOOL_CODES) + + return counts + + +def has_touchscreen(): + for device in glob.glob("/sys/class/input/event*/device"): + keys = _capability_bits(os.path.join(device, "capabilities/key")) + + if BTN_TOUCH not in keys or BTN_TOOL_FINGER in keys: + continue + + if ABS_MT_POSITION_X in _capability_bits(os.path.join(device, "capabilities/abs")): + return True + + return False + + +def offered_swipe_finger_counts(): + if has_touchscreen(): + return {2, 3, 4, 5} + + return supported_finger_counts() + + +def can_follow(action): + # Whether @action can follow the fingers. Must agree with can_follow() + # in js/ui/gestures/actions.js. + for option in ACTIONS: + if option[ACTION_ID_COL] == action: + return option[ACTION_ALLOW_FOLLOW_COL] + + return False + + def parse_setting(string): pieces = string.split("::") @@ -68,10 +251,14 @@ def parse_setting(string): else: return ["", "", ""] -def setting_to_string(action="", command=None, phase="end"): +def setting_to_string(action="", command=None, phase=DEFAULT_PHASE): if action == "": return "" + # Nothing matches an empty phase, so an action with one never runs. + if not phase: + phase = DEFAULT_PHASE + if command is not None: return f"{action}::{command}::{phase}" else: @@ -143,7 +330,7 @@ def on_module_selected(self): schema = ssource.lookup(SCHEMA, True) all_keys = schema.list_keys() - order = ["left", "right", "up", "down", "in", "out"] + order = ["up", "down", "left", "right", "in", "out"] def sort_by_direction(key1, key2): v1 = 0 @@ -163,65 +350,105 @@ def sort_by_direction(key1, key2): keys = sorted([key for key in all_keys if key not in NON_GESTURE_KEYS], key=cmp_to_key(sort_by_direction)) page = SettingsPage() - self.sidePage.stack.add_titled(page, "swipe", _("Swipe")) - size_group = Gtk.SizeGroup.new(Gtk.SizeGroupMode.HORIZONTAL) - - section = page.add_section(_("Swipe with 2 fingers"), _("Touchscreen only")) + self.sidePage.stack.add_titled(page, "tweaks", _("Settings")) - for key in keys: - label = self.get_key_label(key, "swipe", 2) - if not label: - continue + size_group = Gtk.SizeGroup.new(Gtk.SizeGroupMode.HORIZONTAL) - widget = GestureComboBox(label, self.gesture_settings, key, size_group=size_group) - section.add_row(widget) + section = page.add_section(_("General")) + widget = GSettingsSwitch(_("Enable gestures"), "org.cinnamon.gestures", "enabled") + section.add_row(widget) - section = page.add_section(_("Swipe with 3 fingers")) + reset_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) + reset_button = Gtk.Button(label=_("Reset gestures to defaults"), halign=Gtk.Align.CENTER) + reset_button.connect("clicked", self.on_reset_clicked) + reset_box.pack_start(reset_button, True, False, 0) + section.add_row(reset_box) - for key in keys: - label = self.get_key_label(key, "swipe", 3) - if not label: - continue + section = page.add_section(_("Activation thresholds"), + _("In percentage of the touch surface")) - widget = GestureComboBox(label, self.gesture_settings, key, size_group=size_group) - section.add_row(widget) + widget = GSettingsRange(_("Swipe"), "org.cinnamon.gestures", "swipe-percent-threshold", + _("20%"), _("80%"), 20, 80, step=5, show_value=True) + widget.add_mark(60, Gtk.PositionType.TOP, None) + section.add_row(widget) + widget = GSettingsRange(_("Pinch"), "org.cinnamon.gestures", "pinch-percent-threshold", + _("20%"), _("80%"), 20, 80, step=5, show_value=True) + widget.add_mark(40, Gtk.PositionType.TOP, None) + section.add_row(widget) - section = page.add_section(_("Swipe with 4 fingers")) + settings = page.add_section(_("Looking for something else?")) - for key in keys: - label = self.get_key_label(key, "swipe", 4) - if not label: - continue + box = SettingsWidget() + button = Gtk.Button(label=_("Mouse and Touchpad Settings"), halign=Gtk.Align.CENTER) + button.connect("clicked", self.on_mouse_settings_button_clicked) + box.pack_start(button, True, False, 0) + settings.add_row(box) - widget = GestureComboBox(label, self.gesture_settings, key, size_group=size_group) - section.add_row(widget) - - section = page.add_section(_("Swipe with 5 fingers"), _("Touchscreen only")) + page = SettingsPage() + self.sidePage.stack.add_titled(page, "swipe", _("Swipe")) + page.set_margin_top(5) + size_group = Gtk.SizeGroup.new(Gtk.SizeGroupMode.HORIZONTAL) + label_size_group = Gtk.SizeGroup.new(Gtk.SizeGroupMode.HORIZONTAL) + section = page.add_section(_("Swipe")) + + # Tighter than the page's usual section spacing. + top = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10) + page.pack_start(top, False, False, 0) + + # Templates for easy combos button flexbox + top.pack_start(self.build_gesture_templates(), False, False, 0) + + box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10) + box.pack_start(Gtk.Image.new_from_icon_name(FOLLOW_ICON, Gtk.IconSize.BUTTON), + False, False, 0) + label = Gtk.Label( + label=_("Gestures marked with this icon can follow your fingers as they move"), + wrap=True, xalign=0.0) + box.pack_start(label, True, True, 0) + top.pack_start(box, False, False, 0) + + swipe_fingers = sorted(offered_swipe_finger_counts()) + lowest_swipe_fingers = swipe_fingers[0] if swipe_fingers else None + + for fingers in swipe_fingers: + # One picture per count, not one shared by all of them. + picture = Gtk.Image.new_from_pixbuf( + themed_swipe_pixbuf(page, fingers, SWIPE_ILLUSTRATION_SIZE)) + + subtitle = _("Touchscreen only") if fingers in TOUCH_ONLY_FINGERS else None + section, expander = self.add_finger_section( + page, _("Swipe with %d fingers") % fingers, + subtitle, fingers != lowest_swipe_fingers, picture=picture) - for key in keys: - label = self.get_key_label(key, "swipe", 5) - if not label: - continue + for key in keys: + label = self.get_key_label(key, "swipe", fingers) + if not label: + continue - widget = GestureComboBox(label, self.gesture_settings, key, size_group=size_group) - section.add_row(widget) + widget = GestureComboBox(label, self.gesture_settings, key, + size_group=size_group, + label_size_group=label_size_group) + section.add_row(widget) page = SettingsPage() self.sidePage.stack.add_titled(page, "pinch", _("Pinch")) size_group = Gtk.SizeGroup.new(Gtk.SizeGroupMode.HORIZONTAL) + label_size_group = Gtk.SizeGroup.new(Gtk.SizeGroupMode.HORIZONTAL) for fingers in range(2, 5): - section = page.add_section(_("Pinch with %d fingers") % fingers) + section, _expander = self.add_finger_section(page, _("Pinch with %d fingers") % fingers, + None, fingers != 2) for key in keys: label = self.get_key_label(key, "pinch", fingers) if not label: continue - widget = GestureComboBox(label, self.gesture_settings, key, size_group=size_group) + widget = GestureComboBox(label, self.gesture_settings, key, + size_group=size_group, label_size_group=label_size_group) section.add_row(widget) - section = page.add_section(_("Pinch with 5 fingers"), _("Touchscreen only")) + section, _expander = self.add_finger_section(page, _("Pinch with 5 fingers"), _("Touchscreen only"), True) for key in keys: label = self.get_key_label(key, "pinch", 5) @@ -229,43 +456,30 @@ def sort_by_direction(key1, key2): if not label: continue - widget = GestureComboBox(label, self.gesture_settings, key, size_group=size_group) + widget = GestureComboBox(label, self.gesture_settings, key, + size_group=size_group, label_size_group=label_size_group) section.add_row(widget) - page = SettingsPage() - self.sidePage.stack.add_titled(page, "tap", _("Tap")) - size_group = Gtk.SizeGroup.new(Gtk.SizeGroupMode.HORIZONTAL) + # Every tap gesture needs a touchscreen, so the page does too. + if has_touchscreen(): + page = SettingsPage() + self.sidePage.stack.add_titled(page, "tap", _("Tap")) + size_group = Gtk.SizeGroup.new(Gtk.SizeGroupMode.HORIZONTAL) + label_size_group = Gtk.SizeGroup.new(Gtk.SizeGroupMode.HORIZONTAL) - section = page.add_section(_("Tap"), _("Touchscreen only")) + section = page.add_section(_("Tap")) + section.set_halign(Gtk.Align.START) - for fingers in range(2, 6): - for key in keys: - label = self.get_key_label(key, "tap", fingers) + for fingers in range(2, 6): + for key in keys: + label = self.get_key_label(key, "tap", fingers) - if not label: - continue - - widget = GestureComboBox(label, self.gesture_settings, key, size_group=size_group) - section.add_row(widget) - - page = SettingsPage() - self.sidePage.stack.add_titled(page, "tweaks", _("Settings")) - - size_group = Gtk.SizeGroup.new(Gtk.SizeGroupMode.HORIZONTAL) - - section = page.add_section(_("General")) - widget = GSettingsSwitch(_("Enable gestures"), "org.cinnamon.gestures", "enabled") - section.add_row(widget) - - section = page.add_section(_("Activation thresholds"), - _("In percentage of the touch surface")) + if not label: + continue - widget = GSettingsRange(_("Swipe"), "org.cinnamon.gestures", "swipe-percent-threshold", _("20%"), _("80%"), 20, 80, step=5, show_value=True) - widget.add_mark(60, Gtk.PositionType.TOP, None) - section.add_row(widget) - widget = GSettingsRange(_("Pinch"), "org.cinnamon.gestures", "pinch-percent-threshold", _("20%"), _("80%"), 20, 80, step=5, show_value=True) - widget.add_mark(40, Gtk.PositionType.TOP, None) - section.add_row(widget) + widget = GestureComboBox(label, self.gesture_settings, key, + size_group=size_group, label_size_group=label_size_group) + section.add_row(widget) self.disabled_page_switch.set_visible(False) self.disabled_retry_button.set_visible(False) @@ -296,6 +510,115 @@ def sort_by_direction(key1, key2): GLib.idle_add(self.set_initial_page, page) + def on_mouse_settings_button_clicked(self, button): + subprocess.Popen(["cinnamon-settings", "mouse"]) + + def build_gesture_templates(self): + """A row of buttons, one per GESTURE_TEMPLATES entry, each setting a + whole bundle of gestures at once. A Gtk.FlowBox wraps on its own + once there are more than GESTURE_TEMPLATE_MAX_COLUMNS of them. + + Every button stays clickable. Whichever template's bindings are + all currently in effect gets highlighted; editing any one of its + gestures, by another template or by hand, drops that highlight. + """ + flow = Gtk.FlowBox(selection_mode=Gtk.SelectionMode.NONE, + homogeneous=True, + row_spacing=10, column_spacing=10, + min_children_per_line=1, + max_children_per_line=GESTURE_TEMPLATE_MAX_COLUMNS) + + self.template_buttons = [] + for name, bindings in GESTURE_TEMPLATES: + button = Gtk.Button(label=name, tooltip_text=template_tooltip(bindings)) + button.connect("clicked", self.on_template_clicked, bindings) + flow.add(button) + self.template_buttons.append((button, bindings)) + + self.gesture_settings.connect("changed", self.refresh_template_buttons) + self.refresh_template_buttons() + + return flow + + def template_matches(self, bindings): + return all(self.gesture_settings.get_string(key) == value + for key, value in bindings.items()) + + def refresh_template_buttons(self, *args): + for button, bindings in self.template_buttons: + if self.template_matches(bindings): + button.get_style_context().add_class("suggested-action") + else: + button.get_style_context().remove_class("suggested-action") + + def on_template_clicked(self, button, bindings): + for key, value in bindings.items(): + self.gesture_settings.set_string(key, value) + + def add_finger_section(self, page, title, subtitle, collapsible, picture=None): + """Add a section for one finger count. Collapsed by default when + @collapsible, so only the lowest supported count is shown open. + @page only needs to be a vertical Gtk.Box. + + @picture, if given, sits beside the rows, below a header that + spans the full width and does not move when the section opens. + + Returns: (section, expander); @expander is None when not collapsible. + """ + # halign=START, or a vertical box gives this its full width + # regardless of pack flags, stretching the frame with it. + content = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=20, + halign=Gtk.Align.START) + if picture is not None: + picture.set_valign(Gtk.Align.START) + content.pack_start(picture, False, False, 0) + + section = SettingsSection() + content.pack_start(section, False, False, 0) + + if not collapsible: + header = Gtk.Label(use_markup=True, xalign=0.0) + header.set_markup("%s" % GLib.markup_escape_text(title)) + page.pack_start(header, False, False, 0) + + if subtitle: + sub = Gtk.Label(label=subtitle, xalign=0.0) + sub.get_style_context().add_class("dim-label") + page.pack_start(sub, False, False, 0) + + page.pack_start(content, False, False, 0) + return section, None + + markup = "%s" % GLib.markup_escape_text(title) + if subtitle: + markup += " %s" % GLib.markup_escape_text(subtitle) + + expander = Gtk.Expander(use_markup=True, label=markup, expanded=False) + expander.add(content) + page.pack_start(expander, False, False, 0) + + return section, expander + + def on_reset_clicked(self, button): + dialog = Gtk.MessageDialog(transient_for=button.get_toplevel(), + modal=True, + message_type=Gtk.MessageType.QUESTION, + buttons=Gtk.ButtonsType.YES_NO, + text=_("Reset all gestures to their defaults?")) + response = dialog.run() + dialog.destroy() + + if response != Gtk.ResponseType.YES: + return + + # Not NON_GESTURE_KEYS: "enabled" defaults to off, and a reset + # here shouldn't turn gestures off or touch the thresholds. + ssource = Gio.SettingsSchemaSource.get_default() + schema = ssource.lookup(SCHEMA, True) + for key in schema.list_keys(): + if key not in NON_GESTURE_KEYS: + self.gesture_settings.reset(key) + def set_initial_page(self, page): if page == "disabled": Gio.Application.get_default().stack_switcher.set_opacity(0) @@ -369,7 +692,7 @@ def migrate_settings(self): if val.startswith("EXEC:"): action_string, custom_string = val.split(":") if custom_string == "": - # A RUN with no command is invalid, reset to nothing. + # A RUN with no command is invalid; reset it. self.gesture_settings.set_string(key, "") continue else: @@ -393,7 +716,7 @@ def do_scroll_event(self, event, data=None): Gtk.Widget.do_scroll_event(self, event) class GestureComboBox(SettingsWidget): - def __init__(self, label, settings=None, key=None, size_group=None): + def __init__(self, label, settings=None, key=None, size_group=None, label_size_group=None): super(GestureComboBox, self).__init__() self.props.margin = 0 self.action_map = {} @@ -402,6 +725,7 @@ def __init__(self, label, settings=None, key=None, size_group=None): self.action_value = None self.custom_value = None self.phase_value = None + self.follow_value = False self.updating_from_setting = False self.updating_settings = False @@ -409,18 +733,38 @@ def __init__(self, label, settings=None, key=None, size_group=None): self.settings = settings self.key = key - hbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=4) + # halign=START keeps the row's own hover highlight full width + # while its content hugs the left, instead of spreading apart. + hbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10, + halign=Gtk.Align.START) self.pack_start(hbox, True, True, 0) self.label = SettingsLabel(label) self.label.props.xalign = 0.0 self.label.props.yalign = 0.0 + # Holds every label in the group to the widest one's width, so a + # short label ("In") does not leave its controls out of line. label_vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4) label_vbox.pack_start(self.label, False, False, 0) hbox.pack_start(label_vbox, False, False, 6) + if label_size_group: + label_size_group.add_widget(label_vbox) + controls_vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6) - hbox.pack_end(controls_vbox, False, False, 6) + hbox.pack_start(controls_vbox, False, False, 0) + + self.follow_button = Gtk.ToggleButton( + image=Gtk.Image.new_from_icon_name(FOLLOW_ICON, Gtk.IconSize.BUTTON), + tooltip_text=_("Toggle modern gesture that smoothly follow your fingers"), + no_show_all=True) + + follow_column = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, + halign=Gtk.Align.CENTER, + valign=Gtk.Align.START, + width_request=FOLLOW_COLUMN_WIDTH) + follow_column.pack_start(self.follow_button, False, False, 0) + hbox.pack_start(follow_column, False, False, 0) # always visible self.action_combo = NonScrollingComboBox(visible=True) @@ -486,7 +830,9 @@ def __init__(self, label, settings=None, key=None, size_group=None): self.settings.connect("changed::" + key, self.on_setting_changed) self.on_setting_changed(settings, key) self.action_combo.connect("changed", self.on_my_value_changed) + self.phase_combo.connect("changed", self.on_my_value_changed) + self.follow_button.connect("toggled", self.on_follow_toggled) self.custom_entry.connect("changed", self.on_custom_entry_changed) self.adjust_range.connect("value-changed", self.on_range_value_changed) @@ -515,8 +861,36 @@ def on_my_value_changed(self, widget): self.update_control_visibilities() self.store_action_settings() + def render_follow_mark(self, layout, cell, model, tree_iter, data=None): + # Marked on every followable row or volume related, except the one already chosen: + # the button in the last column communicates that instead. + row = model[tree_iter] + mark = row[ACTION_ALLOW_FOLLOW_COL] and row[ACTION_ID_COL] != self.action_value or "VOLUME" in row[ACTION_ID_COL] + cell.set_property("icon-name", FOLLOW_ICON if mark else None) + + def on_follow_toggled(self, button): + self.sync_follow_appearance() + + if self.updating_from_setting: + return + + self.follow_value = button.get_active() + + self.update_control_visibilities() + self.store_action_settings() + + def sync_follow_appearance(self): + + context = self.follow_button.get_style_context() + + if self.follow_button.get_active(): + context.add_class("suggested-action") + else: + context.remove_class("suggested-action") + def update_control_visibilities(self): if self.action_value == "": + self.follow_button.hide() self.phase_revealer.set_reveal_child(False) self.phase_revealer.hide() self.custom_revealer.set_reveal_child(False) @@ -525,7 +899,13 @@ def update_control_visibilities(self): self.range_revealer.hide() return - phase_combo_visible = self.action_model[self.action_combo.get_active_iter()][ACTION_ALLOW_PHASE_SELECT_COL] + follow_visible = self.action_model[self.action_combo.get_active_iter()][ACTION_ALLOW_FOLLOW_COL] + self.follow_button.set_visible(follow_visible) + + # A gesture that acts the whole way through has no single phase. + following = follow_visible and self.follow_value + phase_combo_visible = self.action_model[self.action_combo.get_active_iter()][ACTION_ALLOW_PHASE_SELECT_COL] \ + and not following self.phase_revealer.set_visible(phase_combo_visible) self.phase_revealer.set_reveal_child(phase_combo_visible) @@ -559,7 +939,9 @@ def store_action_settings(self): if self.action_value == "EXEC" and self.custom_value == "": return - val = setting_to_string(self.action_value, self.custom_value, self.phase_value) + follows = self.follow_value and can_follow(self.action_value) + val = setting_to_string(self.action_value, self.custom_value, + FOLLOW_PHASE if follows else self.phase_value) self.settings.set_string(self.key, val) @@ -573,6 +955,15 @@ def on_setting_changed(self, settings, key): self.action_value, self.custom_value, self.phase_value = parse_setting(settings.get_string(key)) + # Following is stored in place of a phase, so the phase control + # returns to its default and the button carries the meaning. + self.follow_value = self.phase_value == FOLLOW_PHASE + if self.follow_value: + self.phase_value = DEFAULT_PHASE + + self.follow_button.set_active(self.follow_value) + self.sync_follow_appearance() + if self.action_value == "EXEC": if self.custom_value != "": self.action_combo.set_active_iter(self.action_map["EXEC"]) @@ -588,7 +979,10 @@ def on_setting_changed(self, settings, key): try: self.phase_combo.set_active_iter(self.phase_map[self.phase_value]) except: - self.phase_combo.set_active_iter(self.phase_map["end"]) + # Also covers an unset key, whose phase reads back empty; the + # control's own handler does not run while we set it here. + self.phase_value = DEFAULT_PHASE + self.phase_combo.set_active_iter(self.phase_map[DEFAULT_PHASE]) custom_type = self.action_model[self.action_combo.get_active_iter()][ACTION_EXTRA_WIDGET_TYPE_COL] @@ -607,7 +1001,7 @@ def on_setting_changed(self, settings, key): self.updating_from_setting = False def set_options(self): - self.action_model = Gtk.ListStore(str, str, bool, str, str) + self.action_model = Gtk.ListStore(str, str, bool, str, str, bool) for option in ACTIONS: self.action_map[option[0]] = self.action_model.append(option) @@ -615,6 +1009,16 @@ def set_options(self): self.action_combo.set_model(self.action_model) self.action_combo.set_id_column(0) + # Without this, GTK lines the active row up under the click and + # leaves blank space above a list this long doing the sums. + self.action_combo.set_wrap_width(1) + + # Marks followable actions in the list, not the chosen one: the + # button next to the row already says that. + self.follow_mark = Gtk.CellRendererPixbuf(xalign=1.0) + self.action_combo.pack_end(self.follow_mark, False) + self.action_combo.set_cell_data_func(self.follow_mark, self.render_follow_mark) + self.phase_model = Gtk.ListStore(str, str) for phase in PHASES: diff --git a/files/usr/share/cinnamon/cinnamon-settings/modules/cs_mouse.py b/files/usr/share/cinnamon/cinnamon-settings/modules/cs_mouse.py index a256e9f5da..9352067117 100755 --- a/files/usr/share/cinnamon/cinnamon-settings/modules/cs_mouse.py +++ b/files/usr/share/cinnamon/cinnamon-settings/modules/cs_mouse.py @@ -1,5 +1,7 @@ #!/usr/bin/python3 +import subprocess + import gi gi.require_version("Gtk", "3.0") gi.require_version("CDesktopEnums", "3.0") @@ -135,8 +137,19 @@ def on_module_selected(self): slider.content_widget.add_mark(0.0, Gtk.PositionType.TOP, None) settings.add_row(slider) + settings = page.add_section(_("Looking for something else?")) + + box = SettingsWidget() + button = Gtk.Button(label=_("Touchpad and Touchscreen Gesture Settings"), halign=Gtk.Align.CENTER) + button.connect("clicked", self.on_gestures_button_clicked) + box.pack_start(button, True, False, 0) + settings.add_row(box) + self.sidePage.stack.add_titled(page, "touchpad", _("Touchpad")) + def on_gestures_button_clicked(self, button): + subprocess.Popen(["cinnamon-settings", "gestures"]) + def test_button_clicked(self, widget, event): if event.type == Gdk.EventType._2BUTTON_PRESS: widget.set_label(_("Success!")) diff --git a/js/ui/appSwitcher/appSwitcher.js b/js/ui/appSwitcher/appSwitcher.js index 49f80c71b2..74f63494d0 100644 --- a/js/ui/appSwitcher/appSwitcher.js +++ b/js/ui/appSwitcher/appSwitcher.js @@ -149,6 +149,12 @@ AppSwitcher.prototype = { this.actor.connect('scroll-event', Lang.bind(this, this._scrollEvent)); this.actor.connect('button-press-event', Lang.bind(this, this.destroy)); + // No modifier to release, so no race check or delay. Not + // shown here either: a subclass is still building itself, so + // the caller shows it via showNow() once done. + if (this._modifierMask === 0) + return this._haveModal; + // There's a race condition; if the user released Alt before // we got the grab, then we won't be notified. (See // https://bugzilla.gnome.org/show_bug.cgi?id=596695 for @@ -252,6 +258,81 @@ AppSwitcher.prototype = { this._setCurrentWindow(this._windows[this._currentIndex]); }, + /** + * _selectInitial: a keybinding moves to the next window, since the key + * that opened the switcher steps through it. A gesture has no such + * press, so it opens on the current window instead. + */ + _selectInitial: function () { + if (this._modifierMask === 0) + this._select(this._currentIndex); + else + this._next(); + }, + + /** + * isGestureDriven: true if there is no modifier to release. + */ + isGestureDriven: function () { + return this._modifierMask === 0; + }, + + /** + * showNow: shows immediately, for gestures with no modifier to wait + * for. Call only after the constructor has returned; parts built + * after the modal grab do not exist while it is being taken. + */ + showNow: function () { + if (this._destroyed || this._initialDelayTimeoutId === 0) + return; + + // A switcher that fails to show still holds its modal grab, which + // takes all input with nothing on screen. Remove it instead. + try { + this._show(); + } catch (e) { + global.logError("Could not show the window switcher", e); + this.destroy(); + } + }, + + /** + * getWindowCount: the number of windows in the switcher. + */ + getWindowCount: function () { + return this._windows ? this._windows.length : 0; + }, + + /** + * selectByOffset: moves the selection by @offset windows, one at a + * time as repeated key presses do, so each style animates normally. + */ + selectByOffset: function (offset) { + if (!this._windows || this._windows.length < 2) + return; + + for (let i = 0; i < Math.abs(offset); i++) { + if (offset > 0) + this._next(); + else + this._previous(); + } + }, + + /** + * finish: ends a gesture-driven switch, activating the selection if + * @activate. Safe on an already-destroyed switcher (e.g. via Escape). + */ + finish: function (activate) { + if (this._destroyed || !this._windows) + return; + + if (activate) + this._activateSelected(); + else + this.destroy(); + }, + _updateActiveMonitor: function () { this._activeMonitor = null; if (!this._enforcePrimaryMonitor) @@ -338,6 +419,11 @@ AppSwitcher.prototype = { _keyReleaseEvent: function (actor, event) { let [x, y, mods] = global.get_pointer(); + // Nothing was held down to begin with, so a key coming up is not a + // signal to commit; Enter, Escape or a click are. + if (this._modifierMask === 0) + return true; + let state = mods & this._modifierMask; if (state == 0) { diff --git a/js/ui/appSwitcher/appSwitcher3D.js b/js/ui/appSwitcher/appSwitcher3D.js index 9c451b155f..38139aa283 100644 --- a/js/ui/appSwitcher/appSwitcher3D.js +++ b/js/ui/appSwitcher/appSwitcher3D.js @@ -78,7 +78,7 @@ AppSwitcher3D.prototype = { this._initialDelayTimeoutId = 0; - this._next(); + this._selectInitial(); }, _hidePreviews: function(endOpacity) { diff --git a/js/ui/appSwitcher/classicSwitcher.js b/js/ui/appSwitcher/classicSwitcher.js index 8da50d87f5..1e40000cec 100644 --- a/js/ui/appSwitcher/classicSwitcher.js +++ b/js/ui/appSwitcher/classicSwitcher.js @@ -17,6 +17,7 @@ const WindowUtils = imports.misc.windowUtils; // easing durations (ms) const POPUP_SCROLL_TIME = 100; +const SELECTION_SLIDE_TIME = 100; const POPUP_FADE_OUT_TIME = 100; const THUMBNAIL_FADE_TIME = 100; const PREVIEW_SWITCHER_FADEOUT_TIME = 50; @@ -142,7 +143,7 @@ ClassicSwitcher.prototype = { this.actor.opacity = 255; this._initialDelayTimeoutId = 0; - this._next(); + this._selectInitial(); }, _hide: function() { @@ -223,6 +224,12 @@ ClassicSwitcher.prototype = { _setCurrentWindow: function(window) { this._appList.highlight(this._currentIndex, false); + + // Cloning and resizing is too slow to do for every window a gesture + // passes; a gesture commits on lift, so these are never seen anyway. + if (this.isGestureDriven()) + return; + this._doWindowPreview(); this._destroyThumbnails(); @@ -509,6 +516,14 @@ SwitcherList.prototype = { this.actor.add_actor(this._leftArrow); this.actor.add_actor(this._rightArrow); + // Travels between items on its own actor, styled like a selected + // item; visible only while moving, the landing item takes over. + this._selection = new St.Widget({ style_class: 'item-box', + pseudo_class: 'selected', + visible: false }); + this._list.add_actor(this._selection); + this._list.set_child_below_sibling(this._selection, null); + this._items = []; this._highlighted = -1; this._squareItems = squareItems; @@ -590,9 +605,11 @@ SwitcherList.prototype = { }, highlight: function(index, justOutline) { - if (this._highlighted != -1) { - this._items[this._highlighted].remove_style_pseudo_class('outlined'); - this._items[this._highlighted].remove_style_pseudo_class('selected'); + let previous = this._highlighted; + + if (previous != -1) { + this._items[previous].remove_style_pseudo_class('outlined'); + this._items[previous].remove_style_pseudo_class('selected'); } this._highlighted = index; @@ -600,7 +617,7 @@ SwitcherList.prototype = { if (this._highlighted != -1) { if (justOutline) this._items[this._highlighted].add_style_pseudo_class('outlined'); - else + else if (!this._slideSelection(previous, this._highlighted)) this._items[this._highlighted].add_style_pseudo_class('selected'); } @@ -614,6 +631,46 @@ SwitcherList.prototype = { }, + /** + * _slideSelection: slides the highlight from @from to @to by + * translation, so a relayout mid-slide moves the destination instead + * of interrupting it. Returns false if there is nothing to slide. + */ + _slideSelection : function(from, to) { + if (!Main.animations_enabled || from == -1 || from == to) + return false; + + let fromBox = this._items[from].allocation; + let toBox = this._items[to].allocation; + if (fromBox.x2 - fromBox.x1 <= 0 || toBox.x2 - toBox.x1 <= 0) + return false; + + // Where the highlight is now; fast fingers can move it through + // several items faster than one slide takes, so continue from there. + let startX = fromBox.x1; + if (this._selection.visible) + startX = this._selection.allocation.x1 + this._selection.translation_x; + + this._selection.remove_all_transitions(); + this._selection.translation_x = startX - toBox.x1; + this._selection.show(); + this._list.queue_relayout(); + + this._selection.ease({ + translation_x: 0, + duration: SELECTION_SLIDE_TIME, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + onComplete: () => { + this._selection.hide(); + // The selection can move again during the slide. + if (this._highlighted == to) + this._items[to].add_style_pseudo_class('selected'); + } + }); + + return true; + }, + _scrollToLeft : function() { let x = this._items[this._highlighted].allocation.x1; this._scrollableRight = true; @@ -717,11 +774,15 @@ SwitcherList.prototype = { if (this._squareItems) childWidth = childHeight; else { - let [childMin, childNat] = children[0].get_preferred_width(childHeight); + // The items, not children[0]: the selection is a child + // too, and it does not set the item width. + let [childMin, childNat] = this._items[0].get_preferred_width(childHeight); childWidth = childMin; } } + let selectionBox = null; + for (let i = 0; i < children.length; i++) { if (this._items.indexOf(children[i]) != -1) { childBox.x1 = x; @@ -730,13 +791,26 @@ SwitcherList.prototype = { childBox.y2 = childHeight; children[i].allocate(childBox); + if (children[i] == this._items[this._highlighted]) { + selectionBox = new Clutter.ActorBox(); + selectionBox.x1 = childBox.x1; + selectionBox.y1 = childBox.y1; + selectionBox.x2 = childBox.x2; + selectionBox.y2 = childBox.y2; + } + x += this._list.spacing + childWidth; } else { - // Something else, eg, AppList's arrows; - // we don't allocate it. + // Something else, eg, AppList's arrows, or the selection + // below; we don't allocate it here. } } + // The selection is allocated at the selected item and moves by + // translation, so it needs an allocation while hidden. + if (selectionBox) + this._selection.allocate(selectionBox); + let leftPadding = this.actor.get_theme_node().get_padding(St.Side.LEFT); let rightPadding = this.actor.get_theme_node().get_padding(St.Side.RIGHT); let topPadding = this.actor.get_theme_node().get_padding(St.Side.TOP); diff --git a/js/ui/expo.js b/js/ui/expo.js index da9b4fb8bf..4e239650c9 100644 --- a/js/ui/expo.js +++ b/js/ui/expo.js @@ -1,6 +1,7 @@ // -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- const Clutter = imports.gi.Clutter; +const Gio = imports.gi.Gio; const GObject = imports.gi.GObject; const Meta = imports.gi.Meta; const St = imports.gi.St; @@ -10,6 +11,7 @@ const DND = imports.ui.dnd; const Main = imports.ui.main; const ExpoThumbnail = imports.ui.expoThumbnail; + // *************** // This shows all of the workspaces // *************** @@ -273,6 +275,32 @@ var Expo = GObject.registerClass({ if (this.visible || this.animationInProgress) return; + this._prepareVisible(); + + if (!Main.animations_enabled) { + this._showDone(); + return; + } + + this._expo.get_allocation_box(); + + let items = Main.layoutManager.monitors.map(monitor => { + let clone = new Clutter.Clone({source: this._expo.lastActiveWorkspace}); + Main.switcherGroup.add_actor(clone); + clone.set_clip(monitor.x, monitor.y, monitor.width, monitor.height); + return { cleanupActor: clone, clone }; + }); + + this._activeAnim = { items, direction: 'show' }; + this._runAnimation(true); + } + + /** + * _prepareVisible: puts everything Expo needs on screen with no + * animation, so both _animateVisible() and gestureBegin() can start + * from here. + */ + _prepareVisible() { this.visible = true; this.animationInProgress = true; @@ -297,7 +325,6 @@ var Expo = GObject.registerClass({ 'drag-end', this._hideCloseArea.bind(this), this); let activeWorkspace = this._expo.lastActiveWorkspace; - let monitorSetting = global.settings.get_boolean('workspace-expo-primary-monitor') ? Main.layoutManager.primaryMonitor : Main.layoutManager.currentMonitor; this._gradient.show(); Main.panelManager.disablePanels(); @@ -306,24 +333,189 @@ var Expo = GObject.registerClass({ this._coverPane.raise_top(); this._coverPane.show(); + + this.emit('showing'); + } - if (!Main.animations_enabled) { - this._showDone(); + /** + * moveSelection: moves the workspace highlight by @offset, for a + * horizontal swipe. Expo shows every workspace already. + */ + moveSelection(offset) { + if (this._expo) + this._expo.selectWorkspaceByOffset(offset); + } + + // --- gestures that follow the fingers --------------------------------- + // + // Opening Expo shrinks a full-screen copy into its own thumbnail. The + // caller is TrackedViewAction in ui/gestures/actions.js: begin, any + // number of update, then one end. Only pixels move, so an end at 0 + // leaves no trace of the swipe. + + get gestureInProgress() { + return this._gestureAdjustment != null; + } + + /** + * _createGestureClones: one copy per monitor, clipped by a wrapping + * group rather than the copy itself, which would shrink the clip along + * with the gesture. + * + * Returns: an array of { cleanupActor, clone } + */ + _createGestureClones() { + const activeWorkspace = this._expo.lastActiveWorkspace; + + return Main.layoutManager.monitors.map(monitor => { + const cover = new Clutter.Group(); + Main.switcherGroup.add_actor(cover); + cover.set_position(0, 0); + cover.set_clip(monitor.x, monitor.y, monitor.width, monitor.height); + + const clone = new Clutter.Clone({ source: activeWorkspace }); + cover.add_actor(clone); + clone.set_clip(monitor.x, monitor.y, monitor.width, monitor.height); + + return { cleanupActor: cover, clone }; + }); + } + + _destroyGestureClones() { + if (!this._gestureItems) return; + + this._gestureItems.forEach(({ cleanupActor }) => { + if (cleanupActor.get_parent() !== null) { + Main.switcherGroup.remove_actor(cleanupActor); + cleanupActor.destroy(); + } + }); + + this._gestureItems = null; + } + + /** + * _setGestureProgress: holds the transition at @progress, 0 (copy + * covers the monitor) to 1 (copy sits on its own thumbnail, so the + * final swap to the real one is invisible). + */ + _setGestureProgress(progress) { + const activeWorkspace = this._expo.lastActiveWorkspace; + const monitorSetting = global.settings.get_boolean('workspace-expo-primary-monitor') + ? Main.layoutManager.primaryMonitor : Main.layoutManager.currentMonitor; + + // Read the target every frame. The thumbnail box can still be + // settling into its allocation while the fingers move. + const targetX = monitorSetting.x + activeWorkspace.allocation.x1; + const targetY = monitorSetting.y + activeWorkspace.allocation.y1; + const [targetScaleX, targetScaleY] = activeWorkspace.get_scale(); + + this._gestureItems.forEach(({ clone }) => { + clone.set_position(targetX * progress, targetY * progress); + clone.set_scale(1 + (targetScaleX - 1) * progress, + 1 + (targetScaleY - 1) * progress); + }); + + this._expo.setShadeProgress(progress); + } + + /** + * gestureBegin: starts a swipe, opening a closed Expo or closing an + * open one. Opening takes the grab first. + * + * Returns: false if the swipe cannot start, in which case do not call + * gestureUpdate() or gestureEnd(). + */ + gestureBegin() { + if (this.animationInProgress || this.gestureInProgress || !Main.animations_enabled) + return false; + + const showing = !this._shown; + + this._gestureAdjustment = new St.Adjustment({ + value: showing ? 0 : 1, lower: 0, upper: 1, + }); + + if (showing) { + this.beforeShow(); + + if (!Main.pushModal(this._group, undefined, undefined, Cinnamon.ActionMode.EXPO, + () => this._dismissGrab())) { + this._gestureAdjustment = null; + return false; + } + + this._modal = true; + this._shown = true; + this._prepareVisible(); + this._expo.get_allocation_box(); } - this._expo.get_allocation_box(); + this._gestureItems = this._createGestureClones(); - let items = Main.layoutManager.monitors.map(monitor => { - let clone = new Clutter.Clone({source: activeWorkspace}); - Main.switcherGroup.add_actor(clone); - clone.set_clip(monitor.x, monitor.y, monitor.width, monitor.height); - return { cleanupActor: clone, clone }; + this._gestureAdjustment.connect('notify::value', + () => this._setGestureProgress(this._gestureAdjustment.value)); + + this._setGestureProgress(showing ? 0 : 1); + this.animationInProgress = true; + + return true; + } + + /** + * gestureUpdate: clamps @progress (0 closed to 1 open) so Expo cannot + * overshoot either end. + */ + gestureUpdate(progress) { + if (!this.gestureInProgress) + return; + + this._gestureAdjustment.value = Math.min(1, Math.max(0, progress)); + } + + /** + * gestureEnd: animates to @target over @duration ms and commits. Uses + * ::stopped rather than a completion handler, so an interrupted settle + * still cleans up. + */ + gestureEnd(target, duration) { + if (!this.gestureInProgress) + return; + + this._gestureAdjustment.ease(target, { + duration, + mode: Clutter.AnimationMode.EASE_OUT_CUBIC, + onStopped: () => this._gestureDone(target), }); + } - this._activeAnim = { items, direction: 'show' }; - this._runAnimation(true); + /** + * _gestureDone: commits @target. 1 is the tail of a normal show; 0 + * undoes what gestureBegin() prepared, the same work a finished hide + * does. + */ + _gestureDone(target) { + this._gestureAdjustment = null; + this._destroyGestureClones(); + + if (target >= 0.5) { + this._showDone(); + return; + } + + // Only pixels moved while the fingers were down, so undo the rest + // here. + const activeWorkspace = this._expo.lastActiveWorkspace; + + this._shown = false; + Main.panelManager.enablePanels(); + activeWorkspace.overviewModeOff(true, true); + + this.emit('hiding'); + this._group.hide(); + this._hideDone(); } // Return a 0..1 progress value for the currently-running transition on diff --git a/js/ui/expoThumbnail.js b/js/ui/expoThumbnail.js index 89ec37884b..481b96be1f 100644 --- a/js/ui/expoThumbnail.js +++ b/js/ui/expoThumbnail.js @@ -1296,6 +1296,23 @@ var ExpoThumbnailsBox = GObject.registerClass({ return Clutter.EVENT_PROPAGATE; } + /** + * setShadeProgress: sets the shade directly (0 undimmed to 1 fully + * dimmed) so a gesture can drive it frame by frame; easeShade() animates + * between the same ends. + */ + setShadeProgress(progress) { + const value = SHADE_NEUTRAL + (SHADE_DIMMED - SHADE_NEUTRAL) * progress; + + this.shaded = progress > 0.5; + + this.thumbnails.forEach(thumbnail => { + const effect = thumbnail.background.get_effect('shade'); + if (effect) + effect.brightness = shadeColor(value); + }); + } + easeShade(dimmed, duration) { this.shaded = dimmed; @@ -1352,7 +1369,9 @@ var ExpoThumbnailsBox = GObject.registerClass({ this.addThumbnails(0, global.workspace_manager.n_workspaces); - this.easeShade(true, SHADE_ANIMATION_TIME); + // A gesture sets the shade itself, frame by frame. + if (!Main.expo.gestureInProgress) + this.easeShade(true, SHADE_ANIMATION_TIME); this.button.raise_top(); @@ -1471,6 +1490,23 @@ var ExpoThumbnailsBox = GObject.registerClass({ } // returns true if symbol was understood, false otherwise + /** + * selectWorkspaceByOffset: the swipe equivalent of the arrow keys. Moves + * the keyboard highlight by @offset without activating, stopping at + * either end instead of wrapping. + */ + selectWorkspaceByOffset(offset) { + const previous = this.kbThumbnailIndex; + const next = Math.max(0, Math.min(this.thumbnails.length - 1, previous + offset)); + + if (next === previous) + return; + + this.kbThumbnailIndex = next; + this.thumbnails[previous].showKeyboardSelectedState(false); + this.thumbnails[next].showKeyboardSelectedState(true); + } + selectNextWorkspace(symbol) { let prevIndex = this.kbThumbnailIndex; let lastIndex = this.thumbnails.length - 1; diff --git a/js/ui/gestures/actions.js b/js/ui/gestures/actions.js index fa32ad7d6f..d2aab066b0 100644 --- a/js/ui/gestures/actions.js +++ b/js/ui/gestures/actions.js @@ -2,7 +2,8 @@ const { GLib, Gio, Cinnamon, Meta, Cvc } = imports.gi; const Main = imports.ui.main; -const { GestureType } = imports.ui.gestures.gestureTypes; +const { GestureType, GestureDirection } = imports.ui.gestures.gestureTypes; +const { SwipeProgress } = imports.ui.gestures.tracking; const { getMprisPlayerManager } = imports.misc.mprisPlayer; const Magnifier = imports.ui.magnifier; @@ -10,6 +11,32 @@ const touchpad_settings = new Gio.Settings({ schema_id: "org.cinnamon.desktop.p const CONTINUOUS_ACTION_POLL_INTERVAL = 50; // milliseconds +// The phase of an action that follows the fingers. Every other action runs +// once, at the start or the end of the gesture. +var FOLLOW_PHASE = "follow"; +var DEFAULT_PHASE = "end"; + +var can_follow = (action) => { + switch (action) { + case "WORKSPACE_NEXT": + case "WORKSPACE_PREVIOUS": + case "WORKSPACE_UP": + case "WORKSPACE_DOWN": + case "TOGGLE_EXPO": + case "TOGGLE_OVERVIEW": + case "SWITCH_WINDOWS": + case "PUSH_TILE_UP": + case "PUSH_TILE_DOWN": + case "PUSH_TILE_LEFT": + case "PUSH_TILE_RIGHT": + case "MAXIMIZE": + case "MINIMIZE": + return true; + default: + return false; + } +} + var make_action = (settings, definition, device) => { var threshold = 100; @@ -21,6 +48,36 @@ var make_action = (settings, definition, device) => { threshold = settings.get_uint("pinch-percent-threshold"); } + if (definition.phase === FOLLOW_PHASE && can_follow(definition.action)) { + switch (definition.action) { + case "WORKSPACE_NEXT": + case "WORKSPACE_PREVIOUS": + case "WORKSPACE_UP": + case "WORKSPACE_DOWN": + return new TrackedWorkspaceSwitchAction(definition, device, threshold); + case "TOGGLE_EXPO": + case "TOGGLE_OVERVIEW": + return new TrackedViewAction(definition, device, threshold); + case "SWITCH_WINDOWS": + return new TrackedWindowSwitchAction(definition, device, threshold); + case "PUSH_TILE_UP": + case "PUSH_TILE_DOWN": + case "PUSH_TILE_LEFT": + case "PUSH_TILE_RIGHT": + return new TrackedTileAction(definition, device, threshold); + case "MAXIMIZE": + return new TrackedMaximizeAction(definition, device, threshold); + case "MINIMIZE": + return new TrackedMinimizeAction(definition, device, threshold); + } + } + + // Nothing reaches this phase if the action cannot follow, so the action + // would never run. Give it the default phase instead. + if (definition.phase === FOLLOW_PHASE) { + definition.phase = DEFAULT_PHASE; + } + switch (definition.action) { case "WORKSPACE_NEXT": case "WORKSPACE_PREVIOUS": @@ -54,6 +111,8 @@ var make_action = (settings, definition, device) => { case "ZOOM_IN": case "ZOOM_OUT": return new ZoomAction(definition, device, threshold); + case "SWITCH_WINDOWS": + return new WindowSwitchAction(definition, device, threshold); case "EXEC": return new ExecAction(definition, device, threshold); } @@ -93,6 +152,322 @@ var BaseAction = class { } } +/** + * TrackedAction: + * + * An action that follows the fingers, driven by percentage of a full swipe. + * A subclass supplies _begin() (snap points and start position, or null to + * refuse), _progress(pct), _update(progress), and _finish(target, ms). + */ +var TrackedAction = class extends BaseAction { + constructor(definition, device, threshold) { + super(definition, device, threshold); + this._swipe = null; + } + + begin(direction, percentage, time) { + const setup = this._begin(direction); + if (setup == null) { + return; + } + + this._swipe = new SwipeProgress(setup.snapPoints, + setup.progress, + setup.cancelProgress !== undefined + ? setup.cancelProgress + : setup.progress, + setup.longSwipes === true); + + this._update(this._swipe.update(this._progress(percentage), time)); + } + + update(direction, percentage, time) { + if (this._swipe == null) { + return; + } + + this._update(this._swipe.update(this._progress(percentage), time)); + } + + end(direction, percentage, time) { + if (this._swipe == null) { + return; + } + + this._swipe.update(this._progress(percentage), time); + + const [target, duration] = this._swipe.end(time); + this._swipe = null; + + this._finish(target, duration); + } +} + +/** + * TrackedWorkspaceSwitchAction: moves the workspaces with the fingers. A + * snap point is a workspace. + */ +var TrackedWorkspaceSwitchAction = class extends TrackedAction { + _begin(direction) { + // The direction along the workspaces. The direction of the fingers + // is whichever gesture this action is set on. + switch (this.definition.action) { + case "WORKSPACE_NEXT": + case "WORKSPACE_DOWN": + this._direction = 1; + break; + default: + this._direction = -1; + } + + // Expo shows every workspace already. The swipe moves the + // highlight, which needs only a direction. + if (Main.expo.visible) { + this._inExpo = true; + this._base = 0; + return { snapPoints: [-1, 0, 1], progress: 0 }; + } + + this._inExpo = false; + + // The window selector scrolls between the workspaces. The swipe + // drives that scroll. + if (Main.overview.visible) { + this._scroller = Main.overview.workspacesView; + const setup = this._scroller ? this._scroller.workspaceScrollBegin() : null; + if (setup == null) { + return null; + } + + this._base = setup.progress; + return setup; + } + + this._scroller = null; + + const animation = Main.wm.workspaceAnimation; + if (animation == null) { + return null; + } + + const setup = animation.switchBegin(global.display.get_current_monitor()); + if (setup == null) { + return null; + } + + this._base = setup.progress; + + return setup; + } + + _progress(percentage) { + return this._base + this._direction * percentage / 100; + } + + _update(progress) { + if (this._inExpo) { + return; + } + + if (this._scroller) { + this._scroller.workspaceScrollUpdate(progress); + return; + } + + Main.wm.workspaceAnimation.switchUpdate(progress); + } + + _finish(target, duration) { + if (this._inExpo) { + if (target !== 0) { + Main.expo.moveSelection(target > 0 ? 1 : -1); + } + return; + } + + if (this._scroller) { + const scroller = this._scroller; + this._scroller = null; + scroller.workspaceScrollEnd(target, duration); + return; + } + + Main.wm.workspaceAnimation.switchEnd(duration, target); + } +} + +/** + * TrackedViewAction: opens or closes Expo or the window selector. Snap + * points are closed (0) and open (1). + */ +// The view an action opens and closes, or null for other actions. +var view_for_action = (action) => { + switch (action) { + case "TOGGLE_EXPO": + return Main.expo; + case "TOGGLE_OVERVIEW": + return Main.overview; + default: + return null; + } +} + +var TrackedViewAction = class extends TrackedAction { + _begin(direction) { + const view = view_for_action(this.definition.action); + + // One view at a time. Otherwise one opens on top of the other. + if ((Main.expo.visible && view !== Main.expo) || + (Main.overview.visible && view !== Main.overview)) { + return null; + } + + this._showing = !view.visible; + + // The physical swipe direction does not matter, only the progress. + if (!view.gestureBegin()) { + return null; + } + + this._view = view; + + return { snapPoints: [0, 1], progress: this._showing ? 0 : 1 }; + } + + _progress(percentage) { + const travelled = percentage / VIEW_SWIPE_PERCENT; + + return this._showing ? travelled : 1 - travelled; + } + + _update(progress) { + this._view.gestureUpdate(progress); + } + + _finish(target, duration) { + const view = this._view; + this._view = null; + + view.gestureEnd(target >= 0.5 ? 1 : 0, duration); + } +} + +// How much of a swipe opens a view completely. +const VIEW_SWIPE_PERCENT = 65; + +// How much of a swipe moves the window switcher on by one window. +const PERCENT_PER_WINDOW = 15; + +// Opens the Alt-Tab switcher. An empty modifier tells it to stay up +// until something selects a window, since a gesture has none to release. +var open_window_switcher = () => { + const switcher = Main.wm._createAppSwitcher({ + get_name: () => "switch-windows", + get_mask: () => 0, + }); + + // No windows, or the grab failed. The switcher removes itself. + if (!switcher || !switcher._haveModal) { + return null; + } + + if (switcher.getWindowCount() < 2) { + switcher.finish(false); + return null; + } + + // Only after it is built. A switcher that fails to show removes itself. + switcher.showNow(); + + return switcher._destroyed ? null : switcher; +} + +// The switcher left open by a swipe. The next swipe moves this one instead +// of opening another. +let standing_switcher = null; + +/** + * WindowSwitchAction: opens the switcher and moves it one window, same as + * a single Alt-Tab press. It stays open for another swipe, Enter, or Escape. + */ +var WindowSwitchAction = class extends BaseAction { + do_action(direction, percentage, time) { + const offset = backwards(direction) ? -1 : 1; + + if (standing_switcher != null && !standing_switcher._destroyed) { + standing_switcher.selectByOffset(offset); + return; + } + + standing_switcher = open_window_switcher(); + if (standing_switcher == null) { + return; + } + + standing_switcher.selectByOffset(offset); + } +} + +// The list is read in the direction of the swipe. +var backwards = (direction) => { + return direction === GestureDirection.LEFT || direction === GestureDirection.UP; +} + +/** + * TrackedWindowSwitchAction: moves the switcher's selection with the + * fingers. A snap point is a window; the switcher wraps, so one swipe can + * reach the whole list in either direction. + */ +var TrackedWindowSwitchAction = class extends TrackedAction { + _begin(direction) { + const switcher = open_window_switcher(); + if (switcher == null) { + return null; + } + + this._switcher = switcher; + this._step = 0; + this._direction = backwards(direction) ? -1 : 1; + + const count = switcher.getWindowCount(); + const snapPoints = []; + for (let i = -(count - 1); i <= count - 1; i++) { + snapPoints.push(i); + } + + return { snapPoints, progress: 0, longSwipes: true }; + } + + _progress(percentage) { + return this._direction * percentage / PERCENT_PER_WINDOW; + } + + _update(progress) { + const step = Math.round(progress); + if (step === this._step) { + return; + } + + this._switcher.selectByOffset(step - this._step); + this._step = step; + } + + _finish(target, duration) { + const switcher = this._switcher; + this._switcher = null; + + const step = Math.round(target); + if (step !== this._step) { + switcher.selectByOffset(step - this._step); + this._step = step; + } + + // The window comes forward as the fingers lift. A swipe that + // returns to the start selects nothing. + switcher.finish(this._step !== 0); + } +} + var WorkspaceSwitchAction = class extends BaseAction { constructor(definition, device, threshold) { super(definition, device, threshold); @@ -245,6 +620,324 @@ var WindowOpAction = class extends BaseAction { } } +function lerp_rect(a, b, t) { + return { + x: Math.round(a.x + (b.x - a.x) * t), + y: Math.round(a.y + (b.y - a.y) * t), + width: Math.round(a.width + (b.width - a.width) * t), + height: Math.round(a.height + (b.height - a.height) * t), + }; +} + +// The real MotionDirection push_tile() needs for each tile action, so the +// commit below is the exact call the classic (untracked) action makes. +const TILE_DIRECTION = { + PUSH_TILE_LEFT: Meta.MotionDirection.LEFT, + PUSH_TILE_RIGHT: Meta.MotionDirection.RIGHT, + PUSH_TILE_UP: Meta.MotionDirection.UP, + PUSH_TILE_DOWN: Meta.MotionDirection.DOWN, +}; + +// The same push, as a Meta.TileMode, for working out where push_tile() +// will actually land (see next_tile_mode() below). +const TILE_MODE_DIRECTION = { + PUSH_TILE_LEFT: Meta.TileMode.LEFT, + PUSH_TILE_RIGHT: Meta.TileMode.RIGHT, + PUSH_TILE_UP: Meta.TileMode.TOP, + PUSH_TILE_DOWN: Meta.TileMode.BOTTOM, +}; + +// A window's floating size is not readable back from Meta, so a restore +// (untile or unmaximize) is previewed as a centered box at this fraction +// of the work area rather than the window's real pre-tile size. +const RESTORE_PREVIEW_SCALE = 0.7; + +function restore_preview_rect(work) { + const w = Math.round(work.width * RESTORE_PREVIEW_SCALE); + const h = Math.round(work.height * RESTORE_PREVIEW_SCALE); + return { + x: work.x + Math.round((work.width - w) / 2), + y: work.y + Math.round((work.height - h) / 2), + width: w, + height: h, + }; +} + +// Mirrors muffin's get_new_tile_mode() (keybindings.c): where push_tile() +// lands given the current tile mode and a push in @direction. Keeping this +// in step with muffin is what lets the preview show a restore instead of +// sliding to the opposite half. +function next_tile_mode(direction, current) { + const { NONE, MAXIMIZED, LEFT, RIGHT, TOP, BOTTOM, ULC, LLC, URC, LRC } = Meta.TileMode; + switch (current) { + case NONE: + return direction; + case MAXIMIZED: + if (direction === LEFT) return LEFT; + if (direction === RIGHT) return RIGHT; + if (direction === TOP) return TOP; + return TOP; // BOTTOM + case LEFT: + if (direction === LEFT) return LEFT; + if (direction === RIGHT) return NONE; + if (direction === TOP) return ULC; + return LLC; // BOTTOM + case RIGHT: + if (direction === LEFT) return NONE; + if (direction === RIGHT) return RIGHT; + if (direction === TOP) return URC; + return LRC; // BOTTOM + case TOP: + if (direction === LEFT) return ULC; + if (direction === RIGHT) return URC; + if (direction === TOP) return MAXIMIZED; + return NONE; // BOTTOM + case BOTTOM: + if (direction === LEFT) return LLC; + if (direction === RIGHT) return LRC; + if (direction === TOP) return NONE; + return BOTTOM; // BOTTOM + case ULC: + if (direction === LEFT) return ULC; + if (direction === RIGHT) return TOP; + if (direction === TOP) return ULC; + return LEFT; // BOTTOM + case LLC: + if (direction === LEFT) return LLC; + if (direction === RIGHT) return BOTTOM; + if (direction === TOP) return LEFT; + return LLC; // BOTTOM + case URC: + if (direction === LEFT) return TOP; + if (direction === RIGHT) return URC; + if (direction === TOP) return URC; + return RIGHT; // BOTTOM + case LRC: + if (direction === LEFT) return BOTTOM; + if (direction === RIGHT) return LRC; + if (direction === TOP) return RIGHT; + return LRC; // BOTTOM + default: + return current; + } +} + +// The rect push_tile() will give a window in @mode, mirroring muffin's +// meta_window_get_tile_area() (halves and quarters of the work area). +function tile_area_for_mode(work, mode) { + const half_w = Math.round(work.width / 2); + const half_h = Math.round(work.height / 2); + + switch (mode) { + case Meta.TileMode.LEFT: + return { x: work.x, y: work.y, width: half_w, height: work.height }; + case Meta.TileMode.RIGHT: + return { x: work.x + work.width - half_w, y: work.y, width: half_w, height: work.height }; + case Meta.TileMode.TOP: + return { x: work.x, y: work.y, width: work.width, height: half_h }; + case Meta.TileMode.BOTTOM: + return { x: work.x, y: work.y + work.height - half_h, width: work.width, height: half_h }; + case Meta.TileMode.ULC: + return { x: work.x, y: work.y, width: half_w, height: half_h }; + case Meta.TileMode.URC: + return { x: work.x + work.width - half_w, y: work.y, width: half_w, height: half_h }; + case Meta.TileMode.LLC: + return { x: work.x, y: work.y + work.height - half_h, width: half_w, height: half_h }; + case Meta.TileMode.LRC: + return { x: work.x + work.width - half_w, y: work.y + work.height - half_h, width: half_w, height: half_h }; + default: // MAXIMIZED + return { x: work.x, y: work.y, width: work.width, height: work.height }; + } +} + +/** + * TrackedTileAction: previews the tile push_tile() will actually make, + * following muffin's own tile-navigation state (see next_tile_mode()), + * so an inverse push previews a restore rather than the opposite half. + */ +var TrackedTileAction = class extends TrackedAction { + _begin(direction) { + const window = global.display.get_focus_window(); + if (window == null || !actionable_window_types.includes(window.window_type)) { + return null; + } + + const actor = window.get_compositor_private(); + if (actor == null) { + return null; + } + + const work = window.get_work_area_current_monitor(); + if (work == null) { + return null; + } + + const current_mode = (window.maximized_horizontally && window.maximized_vertically) + ? Meta.TileMode.MAXIMIZED : window.tile_mode; + const new_mode = next_tile_mode(TILE_MODE_DIRECTION[this.definition.action], current_mode); + + // Matches do_tile_move()'s own no-op check: nothing will happen. + if (new_mode === current_mode) { + return null; + } + + this._target = new_mode === Meta.TileMode.NONE + ? restore_preview_rect(work) : tile_area_for_mode(work, new_mode); + + this._window = window; + this._start = window.get_frame_rect(); + this._monitor = window.get_monitor(); + + Main.wm._showTilePreview(null, window, new Meta.Rectangle(this._start), this._monitor); + + return { snapPoints: [0, 1], progress: 0 }; + } + + _progress(percentage) { + return percentage / VIEW_SWIPE_PERCENT; + } + + _update(progress) { + const rect = lerp_rect(this._start, this._target, Math.min(1, Math.max(0, progress))); + Main.wm._tilePreview.show(this._window, new Meta.Rectangle(rect), this._monitor, false, 0); + } + + _finish(target, duration) { + Main.wm._hideTilePreview(); + + if (target >= 0.5) { + global.display.push_tile(this._window, TILE_DIRECTION[this.definition.action]); + } + + this._window = null; + } +} + +/** + * TrackedMaximizeAction: maximizes or restores with the fingers, using the + * same tile preview as TrackedTileAction. An already-maximized window is + * the "open" end, so the swipe restores it; the commit always calls the + * real maximize/unmaximize, so it lands at the right size either way. + */ + +var TrackedMaximizeAction = class extends TrackedAction { + _begin(direction) { + const window = global.display.get_focus_window(); + if (window == null || !actionable_window_types.includes(window.window_type) || + !window.can_maximize()) { + return null; + } + + const actor = window.get_compositor_private(); + if (actor == null) { + return null; + } + + const work = window.get_work_area_current_monitor(); + if (work == null) { + return null; + } + + this._window = window; + this._monitor = window.get_monitor(); + this._maximized = window.maximized_horizontally && window.maximized_vertically; + + this._openRect = { x: work.x, y: work.y, width: work.width, height: work.height }; + + if (this._maximized) { + this._closedRect = restore_preview_rect(work); + } else { + this._closedRect = window.get_frame_rect(); + } + + const startRect = this._maximized ? this._openRect : this._closedRect; + Main.wm._showTilePreview(null, window, new Meta.Rectangle(startRect), this._monitor); + + return { snapPoints: [0, 1], progress: this._maximized ? 1 : 0 }; + } + + _progress(percentage) { + const travelled = percentage / VIEW_SWIPE_PERCENT; + return this._maximized ? 1 - travelled : travelled; + } + + _update(progress) { + const rect = lerp_rect(this._closedRect, this._openRect, Math.min(1, Math.max(0, progress))); + Main.wm._tilePreview.show(this._window, new Meta.Rectangle(rect), this._monitor, false, 0); + } + + _finish(target, duration) { + Main.wm._hideTilePreview(); + + if (target >= 0.5 && !this._maximized) { + this._window.maximize(Meta.MaximizeFlags.BOTH); + } else if (target < 0.5 && this._maximized) { + this._window.unmaximize(Meta.MaximizeFlags.BOTH); + } + + this._window = null; + } +} + +/** + * TrackedMinimizeAction: shrinks towards a box at the bottom of the work + * area. Forward only: a minimized window has no frame left to gesture on. + */ +const MINIMIZE_PREVIEW_SIZE = 40; + +var TrackedMinimizeAction = class extends TrackedAction { + _begin(direction) { + const window = global.display.get_focus_window(); + if (window == null || !actionable_window_types.includes(window.window_type) || + !window.can_minimize()) { + return null; + } + + const actor = window.get_compositor_private(); + if (actor == null) { + return null; + } + + const work = window.get_work_area_current_monitor(); + if (work == null) { + return null; + } + + this._window = window; + this._monitor = window.get_monitor(); + this._start = window.get_frame_rect(); + this._target = { + x: work.x + Math.round((work.width - MINIMIZE_PREVIEW_SIZE) / 2), + y: work.y + work.height - MINIMIZE_PREVIEW_SIZE, + width: MINIMIZE_PREVIEW_SIZE, + height: MINIMIZE_PREVIEW_SIZE, + }; + + Main.wm._showTilePreview(null, window, new Meta.Rectangle(this._start), this._monitor); + + return { snapPoints: [0, 1], progress: 0 }; + } + + _progress(percentage) { + return percentage / VIEW_SWIPE_PERCENT; + } + + _update(progress) { + const rect = lerp_rect(this._start, this._target, Math.min(1, Math.max(0, progress))); + Main.wm._tilePreview.show(this._window, new Meta.Rectangle(rect), this._monitor, false, 0); + } + + _finish(target, duration) { + Main.wm._hideTilePreview(); + + if (target >= 0.5) { + this._window.minimize(); + } + + this._window = null; + } +} + var GlobalDesktopAction = class extends BaseAction { constructor(definition, device, threshold) { super(definition, device, threshold); diff --git a/js/ui/gestures/gesturesManager.js b/js/ui/gestures/gesturesManager.js index a5b9369dca..eca89cb6f7 100644 --- a/js/ui/gestures/gesturesManager.js +++ b/js/ui/gestures/gesturesManager.js @@ -16,6 +16,15 @@ const { NativeGestureSource } = imports.ui.gestures.nativeGestureSource; const { ToucheggGestureSource } = imports.ui.gestures.toucheggGestureSource; const SCHEMA = "org.cinnamon.gestures"; +const TOUCHPAD_SCHEMA = "org.cinnamon.desktop.peripherals.touchpad"; + +// A swipe and the swipe back. +const OPPOSITE_DIRECTION = { + [GestureDirection.UP]: GestureDirection.DOWN, + [GestureDirection.DOWN]: GestureDirection.UP, + [GestureDirection.LEFT]: GestureDirection.RIGHT, + [GestureDirection.RIGHT]: GestureDirection.LEFT, +}; const NON_GESTURE_KEYS = [ "enabled", @@ -86,6 +95,7 @@ var GesturesManager = class { constructor(wm) { this.signalManager = new SignalManager.SignalManager(null); this.settings = new Gio.Settings({ schema_id: SCHEMA }) + this.touchpad_settings = new Gio.Settings({ schema_id: TOUCHPAD_SCHEMA }) this.current_gesture = null; this.live_actions = new Map(); @@ -98,6 +108,7 @@ var GesturesManager = class { this.migrate_settings(); this.signalManager.connect(this.settings, "changed", this.settings_or_devices_changed, this); + this.signalManager.connect(this.touchpad_settings, "changed::send-events", this.settings_or_devices_changed, this); this.gestureSource.connect('gesture-begin', this.gesture_begin.bind(this)); this.gestureSource.connect('gesture-update', this.gesture_update.bind(this)); @@ -117,6 +128,18 @@ var GesturesManager = class { } const val = this.settings.get_string(key); + + // Nothing matches an empty phase, so an action stored with one + // never runs. Older settings pages wrote them that way. + const parts = val.split("::"); + if (parts.length > 1 && parts[parts.length - 1] === "") { + const custom = parts.length === 3 ? parts[1] : ""; + this.settings.set_string(key, custom === "" + ? `${parts[0]}::end` + : `${parts[0]}::${custom}::end`); + continue; + } + if (val === '' || val.includes("::")) { continue; } @@ -144,8 +167,16 @@ var GesturesManager = class { } } + // On X11, touchegg reads the touchpad through its own libinput handle, + // so muffin telling libinput to stop the device never reaches it: it + // keeps recognizing gestures even with the touchpad off. Rather than + // rely on that, gate on the setting directly, here, ourselves. + touchpad_enabled() { + return this.touchpad_settings.get_string("send-events") !== "disabled"; + } + settings_or_devices_changed(settings, key) { - if (this.settings.get_boolean("enabled")) { + if (this.settings.get_boolean("enabled") && this.touchpad_enabled()) { this.setup_actions(); return; } @@ -217,6 +248,41 @@ var GesturesManager = class { } } + /** + * definition_for: + * + * What this gesture does: the action set on it, or a way out of a view. + */ + definition_for(type, direction, fingers) { + const definition = this.lookup_definition(type, direction, fingers); + if (definition != null) { + return definition; + } + + return this.lookup_way_back(type, direction, fingers); + } + + /** + * lookup_way_back: a swipe with nothing set takes the opposite + * direction's action if it opens a view that is open, so a view is + * always closable even when only its opener is bound. + */ + lookup_way_back(type, direction, fingers) { + const opposite = OPPOSITE_DIRECTION[direction]; + if (opposite === undefined) { + return null; + } + + const definition = this.lookup_definition(type, opposite, fingers); + if (definition == null) { + return null; + } + + const view = actions.view_for_action(definition.action); + + return view != null && view.visible ? definition : null; + } + lookup_definition(type, direction, fingers) { const key = this.construct_map_key(type, direction, fingers); const definition = this.live_actions.get(key); @@ -240,7 +306,7 @@ var GesturesManager = class { return; } - const definition_match = this.lookup_definition(type, direction, fingers); + const definition_match = this.definition_for(type, direction, fingers); if (definition_match == null) { debug_gesture(`No definition for (${DeviceTypeString[device]}) ${GestureTypeString[type]}, ${GestureDirectionString[direction]}, fingers: ${fingers}`); @@ -262,7 +328,7 @@ var GesturesManager = class { return; } - const def = this.lookup_definition(type, direction, fingers); + const def = this.definition_for(type, direction, fingers); if (def == null || def !== this.current_gesture.definition) { this.current_gesture = null; global.logWarning("Invalid gesture update received, clearing current gesture"); @@ -279,7 +345,7 @@ var GesturesManager = class { return; } - const def = this.lookup_definition(type, direction, fingers); + const def = this.definition_for(type, direction, fingers); if (def == null || def !== this.current_gesture.definition) { this.current_gesture = null; global.logWarning("Invalid gesture end received, clearing current gesture"); diff --git a/js/ui/gestures/nativeGestures.js b/js/ui/gestures/nativeGestures.js index a4828eed5b..8f5fb67ef1 100644 --- a/js/ui/gestures/nativeGestures.js +++ b/js/ui/gestures/nativeGestures.js @@ -1,6 +1,6 @@ // -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- -const { Clutter, GObject, Gio } = imports.gi; +const { Clutter, GObject } = imports.gi; const Signals = imports.signals; const { @@ -33,10 +33,6 @@ var TouchpadSwipeGesture = class { this._baseDistance = 0; this._startTime = 0; - this._touchpadSettings = new Gio.Settings({ - schema_id: 'org.cinnamon.desktop.peripherals.touchpad', - }); - this._stageEventId = global.stage.connect( 'captured-event::touchpad', this._handleEvent.bind(this)); } @@ -69,14 +65,6 @@ var TouchpadSwipeGesture = class { const time = event.get_time(); const [dx, dy] = event.get_gesture_motion_delta_unaccelerated(); - // Apply natural scroll setting - let adjDx = dx; - let adjDy = dy; - if (this._touchpadSettings.get_boolean('natural-scroll')) { - adjDx = -dx; - adjDy = -dy; - } - if (this._state === TouchpadState.NONE) { if (dx === 0 && dy === 0) { return Clutter.EVENT_PROPAGATE; @@ -88,19 +76,19 @@ var TouchpadSwipeGesture = class { } if (this._state === TouchpadState.PENDING) { - this._cumulativeX += adjDx; - this._cumulativeY += adjDy; + this._cumulativeX += dx; + this._cumulativeY += dy; const distance = Math.sqrt(this._cumulativeX ** 2 + this._cumulativeY ** 2); if (distance >= DRAG_THRESHOLD_DISTANCE) { - // Determine direction - // Note: dx/dy are inverted for horizontal to match touchegg convention + // Direction of the fingers, not of scroll content; matches + // the touchscreen gestures below and Touchegg on X11. if (Math.abs(this._cumulativeX) > Math.abs(this._cumulativeY)) { - this._direction = this._cumulativeX > 0 ? GestureDirection.LEFT : GestureDirection.RIGHT; + this._direction = this._cumulativeX < 0 ? GestureDirection.LEFT : GestureDirection.RIGHT; this._baseDistance = TOUCHPAD_BASE_WIDTH; } else { - this._direction = this._cumulativeY > 0 ? GestureDirection.DOWN : GestureDirection.UP; + this._direction = this._cumulativeY < 0 ? GestureDirection.UP : GestureDirection.DOWN; this._baseDistance = TOUCHPAD_BASE_HEIGHT; } @@ -120,19 +108,12 @@ var TouchpadSwipeGesture = class { } } - // Calculate delta along the gesture direction - // Note: horizontal is inverted to match touchegg convention + // How far the fingers have gone in the direction they started in. let delta = 0; if (this._direction === GestureDirection.LEFT || this._direction === GestureDirection.RIGHT) { - delta = -adjDx; // Inverted for horizontal - if (this._direction === GestureDirection.LEFT) { - delta = -delta; - } + delta = this._direction === GestureDirection.LEFT ? -dx : dx; } else { - delta = adjDy; - if (this._direction === GestureDirection.UP) { - delta = -delta; - } + delta = this._direction === GestureDirection.UP ? -dy : dy; } // Update percentage (can exceed 100%) diff --git a/js/ui/gestures/tracking.js b/js/ui/gestures/tracking.js new file mode 100644 index 0000000000..3bc82a5cdc --- /dev/null +++ b/js/ui/gestures/tracking.js @@ -0,0 +1,156 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- + +// Progress arithmetic for gestures that follow the fingers: a percentage +// of a full swipe becomes a position on a line of snap points, and lands on +// one when the fingers lift, further on if thrown. A reduced port of the +// swipe tracker in GNOME Shell, reading no events, so it serves both +// gesture sources. + +// Progress per millisecond. Above this speed, a release is a throw. +const FLICK_VELOCITY = 0.001; + +// How far a throw carries, in milliseconds at the speed it left at. +const PROJECTION = 450; + +// Limits for the settle, which is timed from the speed of the release. +const DURATION_MULTIPLIER = 3; +const MIN_DURATION = 100; +const MAX_DURATION = 300; + +// Velocity is measured over the end of the swipe only. +const VELOCITY_WINDOW = 150; + +function clamp(value, min, max) { + return Math.min(Math.max(value, min), max); +} + +/** + * SwipeProgress: + * @snapPoints: the positions the transition can rest at, in ascending order + * @initial: the position the gesture starts at + * @cancel: the position to return to if the gesture is cancelled + * @longSwipes: true if one swipe can cross more than one snap point. False + * by default, which keeps one swipe to one workspace. + * + * Holds the position of a swipe, and finds where it lands. + */ +var SwipeProgress = class { + constructor(snapPoints, initial, cancel, longSwipes = false) { + this.snapPoints = snapPoints; + this.progress = initial; + this.cancelProgress = cancel; + this.longSwipes = longSwipes; + + this._initial = initial; + this._history = []; + } + + /** + * update: + * @progress: the position the fingers have reached + * @time: the time of the report, in milliseconds + * + * Returns: the position, held inside the bounds of the swipe. + */ + update(progress, time) { + const [lower, upper] = this._bounds(); + this.progress = clamp(progress, lower, upper); + + this._history.push([time, this.progress]); + while (this._history.length > 2 && + time - this._history[0][0] > VELOCITY_WINDOW) + this._history.shift(); + + return this.progress; + } + + /** + * end: + * @time: the time the fingers lifted, in milliseconds + * + * Returns: [target, duration]. The target is the snap point the gesture + * lands on. A duration of 0 means there is nothing left to animate. + */ + end(time) { + const velocity = this._velocity(time); + const [lower, upper] = this._bounds(); + + let target; + if (Math.abs(velocity) < FLICK_VELOCITY) { + target = this._closest(this.progress); + } else { + target = this._closest(clamp(this.progress + velocity * PROJECTION, + lower, upper)); + + // A throw always moves on, even a short one. + if (velocity > 0 && target <= this.progress) + target = this._next(this.progress); + else if (velocity < 0 && target >= this.progress) + target = this._previous(this.progress); + } + + const distance = Math.abs(target - this.progress); + if (distance === 0) + return [target, 0]; + + const duration = velocity === 0 + ? MAX_DURATION + : Math.abs(distance / velocity * DURATION_MULTIPLIER); + + return [target, clamp(duration, MIN_DURATION, MAX_DURATION)]; + } + + /** + * cancel: + * + * Returns: [target, duration] for a gesture that was interrupted. + */ + cancel() { + return [this.cancelProgress, MIN_DURATION]; + } + + // The range the swipe can travel: one snap point each side of the start, + // or the whole line for a long swipe. + _bounds() { + const first = this.snapPoints[0]; + const last = this.snapPoints[this.snapPoints.length - 1]; + + if (this.longSwipes) + return [first, last]; + + return [Math.max(this._previous(this._initial), first), + Math.min(this._next(this._initial), last)]; + } + + _closest(position) { + return this.snapPoints.reduce((best, point) => + Math.abs(point - position) < Math.abs(best - position) ? point : best, + this.snapPoints[0]); + } + + _next(position) { + const point = this.snapPoints.find(p => p > position + Number.EPSILON); + return point === undefined ? this.snapPoints[this.snapPoints.length - 1] : point; + } + + _previous(position) { + const points = this.snapPoints.filter(p => p < position - Number.EPSILON); + return points.length === 0 ? this.snapPoints[0] : points[points.length - 1]; + } + + // Only the end of the swipe says where the fingers were going. + _velocity(time) { + if (this._history.length < 2) + return 0; + + const recent = this._history.filter(([t]) => time - t <= VELOCITY_WINDOW); + if (recent.length < 2) + return 0; + + const [firstTime, firstProgress] = recent[0]; + const [lastTime, lastProgress] = recent[recent.length - 1]; + const elapsed = lastTime - firstTime; + + return elapsed > 0 ? (lastProgress - firstProgress) / elapsed : 0; + } +}; diff --git a/js/ui/overview.js b/js/ui/overview.js index fec9faab46..6089e5d279 100644 --- a/js/ui/overview.js +++ b/js/ui/overview.js @@ -6,9 +6,12 @@ const Meta = imports.gi.Meta; const St = imports.gi.St; const Cinnamon = imports.gi.Cinnamon; +const Gio = imports.gi.Gio; + const Main = imports.ui.main; const MessageTray = imports.ui.messageTray; const WorkspacesView = imports.ui.workspacesView; + // *************** // This shows all of the windows on the current workspace // *************** @@ -247,6 +250,22 @@ var Overview = GObject.registerClass({ if (this.visible || this.animationInProgress) return; + this._prepareVisible(); + + this._shadeEffect.brightness = shadeColor(SHADE_NEUTRAL); + this._group.ease_property('@effects.shade.brightness', shadeColor(SHADE_DIMMED), { + duration: ANIMATION_TIME * 0.45, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + onComplete: () => this._showDone() + }); + } + + /** + * _prepareVisible: puts everything the window selector needs on screen + * with no animation, so both _animateVisible() and gestureBegin() can + * start from here. + */ + _prepareVisible() { // The live background is inside global.window_group, which is hidden // below, so build a separate one for the overview. this._background = Main.createFullScreenBackground(); @@ -277,16 +296,134 @@ var Overview = GObject.registerClass({ this._coverPane.raise_top(); this._coverPane.show(); + this.emit('showing'); + } - this._shadeEffect.brightness = shadeColor(SHADE_NEUTRAL); - this._group.ease_property('@effects.shade.brightness', shadeColor(SHADE_DIMMED), { - duration: ANIMATION_TIME * 0.45, - mode: Clutter.AnimationMode.EASE_OUT_QUAD, - onComplete: () => this._showDone() + // --- gestures that follow the fingers --------------------------------- + // + // Opening the selector shades the desktop and moves every window into + // its grid slot. The caller is TrackedViewAction in + // ui/gestures/actions.js: begin, any number of update, then one end. + // + // Workspace.zoomToOverview() checks gestureInProgress to skip its own + // animation; the first update moves the windows from there instead. + + get gestureInProgress() { + return this._gestureAdjustment != null; + } + + /** + * _setGestureProgress: holds the shade and every window at @progress + * between its real position and its grid slot. + */ + _setGestureProgress(progress) { + const shade = SHADE_NEUTRAL + (SHADE_DIMMED - SHADE_NEUTRAL) * progress; + this._shadeEffect.brightness = shadeColor(Math.round(shade)); + + if (this.workspacesView) + this.workspacesView.setGestureProgress(progress); + } + + /** + * gestureBegin: starts a swipe, opening a closed selector or closing + * an open one. The progress must exist before _prepareVisible() runs, + * since building the workspaces view reads gestureInProgress. + * + * Returns: false if the swipe cannot start, in which case do not call + * gestureUpdate() or gestureEnd(). + */ + gestureBegin() { + if (this.animationInProgress || this.gestureInProgress || !Main.animations_enabled) + return false; + + const showing = !this._shown; + + if (showing) { + if (!Main.pushModal(this._group, undefined, undefined, Cinnamon.ActionMode.OVERVIEW, + () => this._dismissGrab())) + return false; + + this._modal = true; + this._shown = true; + this._gestureAdjustment = new St.Adjustment({ value: 0, lower: 0, upper: 1 }); + this._prepareVisible(); + } else { + this._gestureAdjustment = new St.Adjustment({ value: 1, lower: 0, upper: 1 }); + } + + this._gestureAdjustment.connect('notify::value', + () => this._setGestureProgress(this._gestureAdjustment.value)); + + this.workspacesView.prepareGesture(); + this._setGestureProgress(showing ? 0 : 1); + this.animationInProgress = true; + + return true; + } + + /** + * gestureUpdate: clamps @progress (0 closed to 1 open) so the windows + * cannot overshoot either end. + */ + gestureUpdate(progress) { + if (!this.gestureInProgress) + return; + + this._gestureAdjustment.value = Math.min(1, Math.max(0, progress)); + } + + /** + * gestureEnd: animates to @target over @duration ms and commits. Uses + * ::stopped rather than a completion handler, so an interrupted settle + * still cleans up. + */ + gestureEnd(target, duration) { + if (!this.gestureInProgress) + return; + + this._gestureAdjustment.ease(target, { + duration, + mode: Clutter.AnimationMode.EASE_OUT_CUBIC, + onStopped: () => this._gestureDone(target), }); } + /** + * _gestureDone: commits @target. 1 is the tail of a normal show; 0 + * undoes what gestureBegin() prepared, the same work a finished hide + * does. + */ + _gestureDone(target) { + this._gestureAdjustment = null; + + // A click or a key can hide the selector while the settle runs. The + // hide has then undone everything this would commit. + if (!this.visible || this._hideInProgress) + return; + + if (target >= 0.5) { + if (this.workspacesView) + this.workspacesView.endGesture(true); + + this._showDone(); + return; + } + + // Only pixels moved while the fingers were down, so undo the rest + // here. + this._shown = false; + Main.panelManager.enablePanels(); + + if (this.workspacesView) { + this.workspacesView.endGesture(false); + this.workspacesView.hide(); + } + + this.emit('hiding'); + this._hideDone(); + } + hide() { if (!this._shown) return; diff --git a/js/ui/windowManager.js b/js/ui/windowManager.js index 45f2ef7873..2c7c3a09bc 100644 --- a/js/ui/windowManager.js +++ b/js/ui/windowManager.js @@ -16,6 +16,7 @@ const ModalDialog = imports.ui.modalDialog; const WmGtkDialogs = imports.ui.wmGtkDialogs; const CloseDialog = imports.ui.closeDialog; const WorkspaceOsd = imports.ui.workspaceOsd; +const WorkspaceAnimation = imports.ui.workspaceAnimation; const {CoverflowSwitcher} = imports.ui.appSwitcher.coverflowSwitcher; const {TimelineSwitcher} = imports.ui.appSwitcher.timelineSwitcher; @@ -337,6 +338,10 @@ var WindowManager = class WindowManager { this._switchData = null; this._workspaceOsds = {}; + // Public: ui/gestures actions drive it, and _switchWorkspace() must + // know when a swipe has already animated the switch. + this.workspaceAnimation = new WorkspaceAnimation.WorkspaceAnimationController(); + this._cinnamonwm.connect('kill-window-effects', (cinnamonwm, actor) => { this._unminimizeWindowDone(cinnamonwm, actor); this._minimizeWindowDone(cinnamonwm, actor); @@ -1146,6 +1151,20 @@ var WindowManager = class WindowManager { } _switchWorkspace(cinnamonwm, from, to, direction) { + if (this.workspaceAnimation) { + if (this.workspaceAnimation.gestureActive) { + // The swipe moved the workspaces already. + Main.soundManager.play('switch'); + this.showWorkspaceOSD(); + cinnamonwm.completed_switch_workspace(); + return; + } + + // Something else switched workspaces during a swipe. Drop the + // swipe, or the live windows stay hidden behind its clones. + this.workspaceAnimation.cancelSwitchAnimation(); + } + if (!Main.animations_enabled || Main.modalCount) { this.showWorkspaceOSD(); cinnamonwm.completed_switch_workspace(); @@ -1347,18 +1366,20 @@ var WindowManager = class WindowManager { this._windowMenuManager.showWindowMenuForWindow(window, menu, rect); } + /** + * _createAppSwitcher: @binding may be a real keybinding or a gesture's + * stand-in. Returns the switcher, or null if there is nothing to switch. + */ _createAppSwitcher(binding) { - if (AppSwitcher.getWindowsForBinding(binding).length === 0) return; + if (AppSwitcher.getWindowsForBinding(binding).length === 0) return null; switch (global.settings.get_string('alttab-switcher-style')) { case 'coverflow': - new CoverflowSwitcher(binding); - break; + return new CoverflowSwitcher(binding); case 'timeline': - new TimelineSwitcher(binding); - break; + return new TimelineSwitcher(binding); default: - new ClassicSwitcher(binding); + return new ClassicSwitcher(binding); } } diff --git a/js/ui/workspace.js b/js/ui/workspace.js index 98ef37aff1..21df32cdb7 100644 --- a/js/ui/workspace.js +++ b/js/ui/workspace.js @@ -29,6 +29,13 @@ const WINDOWOVERLAY_ICON_SIZE = 16; var menuShowing = null; var menuClone = null; +/** + * lerp: the value @progress of the way from @start to @end. + */ +function lerp(start, end, progress) { + return start + (end - start) * progress; +} + function closeContextMenu(requestor) { let requestorShowingMenu = menuClone && menuClone === requestor; if (menuShowing) { @@ -931,8 +938,67 @@ class WorkspaceMonitor extends Clutter.Actor { return false; } + /** + * prepareOverviewGesture: lays out the overview once and keeps it, for + * setOverviewProgress() to move towards, so slots stay still and + * nothing recomputes per frame. + */ + prepareOverviewGesture() { + const slots = this._computeAllWindowSlots(this._windows.length); + + this._gestureLayout = this._windows.map((clone, i) => { + const [x, y, scale] = this._computeWindowLayout(clone.metaWindow, slots[i]); + return { clone, x, y, scale }; + }); + } + + /** + * setOverviewProgress: holds every window at @progress (0 real + * position, 1 overview layout) between the two positionWindows() + * animates between; overlays stay hidden until endOverviewGesture(). + */ + setOverviewProgress(progress) { + if (!this._gestureLayout) + return; + + for (const { clone, x, y, scale } of this._gestureLayout) { + if (clone.overlay) + clone.overlay.hide(); + + if (clone.metaWindow.showing_on_its_workspace()) { + clone.set_position(lerp(clone.origX, x, progress), + lerp(clone.origY, y, progress)); + clone.set_scale(lerp(1, scale, progress), lerp(1, scale, progress)); + clone.opacity = 255; + } else { + // A hidden window has no start position, so it grows from + // the middle and fades in, as positionWindows() does. + clone.set_position(lerp(this._width / 2, x, progress), + lerp(this._height / 2, y, progress)); + clone.set_scale(scale * progress, scale * progress); + clone.opacity = Math.round(255 * progress); + } + } + + if (this._emptyPlaceHolder && this._emptyPlaceHolder.visible) + this._emptyPlaceHolder.opacity = Math.round(255 * progress); + } + + /** + * endOverviewGesture: drops the kept layout. If @shown, snaps the + * windows to the real layout, restoring the overlays too. + */ + endOverviewGesture(shown) { + this._gestureLayout = null; + + // The real layout also restores the window titles and close + // buttons, which the gesture hid. + if (shown) + this.positionWindows(0); + } + zoomToOverview() { - let animate = Main.animations_enabled; + let animate = Main.animations_enabled && !Main.overview.gestureInProgress; if (Main.overview.animationInProgress && animate) this.positionWindows(WindowPositionFlags.ANIMATE | WindowPositionFlags.INITIAL); @@ -1378,6 +1444,21 @@ var Workspace = GObject.registerClass({ this._monitors.forEach(monitor => monitor.zoomToOverview()); } + // Delegates to the per-monitor parts, as the animated methods above + // do: a workspace covers every monitor, but the layout is per-monitor. + + prepareOverviewGesture() { + this._monitors.forEach(monitor => monitor.prepareOverviewGesture()); + } + + setOverviewProgress(progress) { + this._monitors.forEach(monitor => monitor.setOverviewProgress(progress)); + } + + endOverviewGesture(shown) { + this._monitors.forEach(monitor => monitor.endOverviewGesture(shown)); + } + hasMaximizedWindows() { for(let monitor of this._monitors) { if (monitor.hasMaximizedWindows()) diff --git a/js/ui/workspaceAnimation.js b/js/ui/workspaceAnimation.js new file mode 100644 index 0000000000..fd3dc77801 --- /dev/null +++ b/js/ui/workspaceAnimation.js @@ -0,0 +1,499 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- + +// Ported from GNOME Shell. Each monitor gets a MonitorGroup holding one +// WorkspaceGroup per workspace, side by side; a gesture sets the group's +// offset so the windows follow the fingers (see switchBegin() and its +// caller, TrackedWorkspaceSwitchAction in ui/gestures/actions.js). Only +// gestures use this; keybindings and applets use windowManager.js instead. +// +// Differences from upstream: the wallpaper comes from +// Meta.create_background_for_monitor(); global.window_group is hidden +// throughout, so live windows cannot show through the gap; and there is no +// panel offset for vertical layouts, since Cinnamon uses one row. + +const { Clutter, GObject, Meta, St } = imports.gi; + +const Main = imports.ui.main; +const Layout = imports.ui.layout; + +var WORKSPACE_SPACING = 100; + +/** + * WorkspaceGroup: one workspace's window copies, for one monitor. A group + * with no workspace holds the windows on all of them, and stays put. + */ +var WorkspaceGroup = GObject.registerClass( +class WorkspaceGroup extends Clutter.Actor { + _init(workspace, monitor) { + super._init({ + width: monitor.width, + height: monitor.height, + clip_to_allocation: true, + }); + + this._workspace = workspace; + this._monitor = monitor; + this._windowRecords = []; + + if (this._workspace) { + this._background = new Clutter.Actor(); + this.add_child(this._background); + + const wallpaper = + Meta.create_background_for_monitor(global.display, this._monitor.index); + if (wallpaper) { + wallpaper.set_size(this._monitor.width, this._monitor.height); + this._background.add_child(wallpaper); + } + + this._createDesktopWindows(); + } + + this._createWindows(); + + this.connect('destroy', this._onDestroy.bind(this)); + global.display.connectObject('restacked', + this._syncStacking.bind(this), this); + } + + get workspace() { + return this._workspace; + } + + _shouldShowWindow(window) { + if (!window.showing_on_its_workspace() || this._isDesktopWindow(window)) + return false; + + if (window.is_override_redirect() || + window.get_window_type() === Meta.WindowType.OVERRIDE_OTHER) + return false; + + if (!this._windowIsOnThisMonitor(window)) + return false; + + const isSticky = window.is_on_all_workspaces(); + + // No workspace means we should show windows that are on all workspaces + if (!this._workspace) + return isSticky; + + // Otherwise only show windows that are (only) on that workspace + return !isSticky && window.located_on_workspace(this._workspace); + } + + _syncStacking() { + const windowActors = global.get_window_actors().filter(w => + this._shouldShowWindow(w.meta_window)); + + let lastRecord; + const bottomActor = this._background ? this._background : null; + + for (const windowActor of windowActors) { + const record = this._windowRecords.find(r => r.windowActor === windowActor); + if (!record) + continue; + + this.set_child_above_sibling(record.clone, + lastRecord ? lastRecord.clone : bottomActor); + lastRecord = record; + } + } + + _isDesktopWindow(metaWindow) { + return metaWindow.get_window_type() === Meta.WindowType.DESKTOP; + } + + _windowIsOnThisMonitor(metaWindow) { + const geometry = global.display.get_monitor_geometry(this._monitor.index); + const [intersects] = metaWindow.get_frame_rect().intersect(geometry); + return intersects; + } + + _createDesktopWindows() { + // The desktop window (nemo-desktop and friends) is sticky, so every + // workspace gets its own copy of it on top of the wallpaper. + const desktopActors = global.get_window_actors().filter(w => + this._isDesktopWindow(w.meta_window) && this._windowIsOnThisMonitor(w.meta_window)); + + desktopActors.map(a => this._createClone(a)).forEach( + clone => this._background.add_child(clone)); + } + + _createWindows() { + const windowActors = global.get_window_actors().filter(w => + this._shouldShowWindow(w.meta_window)); + + windowActors.map(a => this._createClone(a)).forEach( + clone => this.add_child(clone)); + } + + _createClone(windowActor) { + const clone = new Clutter.Clone({ + source: windowActor, + x: windowActor.x - this._monitor.x, + y: windowActor.y - this._monitor.y, + }); + + const record = { windowActor, clone }; + + windowActor.connectObject('destroy', () => { + clone.destroy(); + this._windowRecords.splice(this._windowRecords.indexOf(record), 1); + }, this); + + this._windowRecords.push(record); + return clone; + } + + _removeWindows() { + for (const record of this._windowRecords) + record.clone.destroy(); + + this._windowRecords = []; + } + + _onDestroy() { + this._removeWindows(); + } +}); + +var MonitorGroup = GObject.registerClass({ + Properties: { + 'progress': GObject.ParamSpec.double( + 'progress', 'progress', 'progress', + GObject.ParamFlags.READWRITE, + -Infinity, Infinity, 0), + }, +}, class MonitorGroup extends St.Widget { + _init(monitor, workspaceIndices) { + super._init({ + clip_to_allocation: true, + // Painted directly rather than by theme, to guarantee the gap + // between workspaces does not show whatever sits below. + style: 'background-color: black;', + }); + + this._monitor = monitor; + + this.add_constraint(new Layout.MonitorConstraint({ index: monitor.index })); + + this._container = new Clutter.Actor(); + this.add_child(this._container); + + this.add_child(new WorkspaceGroup(null, monitor)); + + this._workspaceGroups = []; + + const workspaceManager = global.workspace_manager; + const activeWorkspace = workspaceManager.get_active_workspace(); + + let x = 0; + let y = 0; + + for (const i of workspaceIndices) { + const ws = workspaceManager.get_workspace_by_index(i); + const group = new WorkspaceGroup(ws, monitor); + + this._workspaceGroups.push(group); + this._container.add_child(group); + group.set_position(x, y); + + if (this._isVertical) + y += this.baseDistance; + else if (Clutter.get_default_text_direction() === Clutter.TextDirection.RTL) + x -= this.baseDistance; + else + x += this.baseDistance; + } + + this.progress = this.getWorkspaceProgress(activeWorkspace); + } + + get _isVertical() { + return global.workspace_manager.layout_rows === -1; + } + + /** + * baseDistance: how far one whole workspace travels: the monitor plus + * the gap between them. + */ + get baseDistance() { + const spacing = + WORKSPACE_SPACING * St.ThemeContext.get_for_stage(global.stage).scale_factor; + + if (this._isVertical) + return this._monitor.height + spacing; + else + return this._monitor.width + spacing; + } + + get progress() { + if (this._isVertical) + return -this._container.y / this.baseDistance; + else if (this.get_text_direction() === Clutter.TextDirection.RTL) + return this._container.x / this.baseDistance; + else + return -this._container.x / this.baseDistance; + } + + set progress(p) { + if (this._isVertical) + this._container.y = -Math.round(p * this.baseDistance); + else if (this.get_text_direction() === Clutter.TextDirection.RTL) + this._container.x = Math.round(p * this.baseDistance); + else + this._container.x = -Math.round(p * this.baseDistance); + + this.notify('progress'); + } + + get index() { + return this._monitor.index; + } + + getWorkspaceProgress(workspace) { + const group = this._workspaceGroups.find(g => + g.workspace.index() === workspace.index()); + return this._getWorkspaceGroupProgress(group); + } + + _getWorkspaceGroupProgress(group) { + if (this._isVertical) + return group.y / this.baseDistance; + else if (this.get_text_direction() === Clutter.TextDirection.RTL) + return -group.x / this.baseDistance; + else + return group.x / this.baseDistance; + } + + /** + * getSnapPoints: the progress value of every workspace, ascending. + */ + getSnapPoints() { + return this._workspaceGroups.map(g => this._getWorkspaceGroupProgress(g)); + } + + findClosestWorkspace(progress) { + const distances = this.getSnapPoints().map(p => Math.abs(p - progress)); + const index = distances.indexOf(Math.min(...distances)); + return this._workspaceGroups[index].workspace; + } + + /** + * _interpolateProgress: maps @progress from @monitorGroup's terms to + * this group's, since monitors of different sizes travel different + * distances for the same workspace. + */ + _interpolateProgress(progress, monitorGroup) { + if (this.index === monitorGroup.index) + return progress; + + const points1 = monitorGroup.getSnapPoints(); + const points2 = this.getSnapPoints(); + + const upper = points1.indexOf(points1.find(p => p >= progress)); + const lower = points1.indexOf(points1.slice().reverse().find(p => p <= progress)); + + if (points1[upper] === points1[lower]) + return points2[upper]; + + const t = (progress - points1[lower]) / (points1[upper] - points1[lower]); + + return points2[lower] + (points2[upper] - points2[lower]) * t; + } + + updateSwipeForMonitor(progress, monitorGroup) { + this.progress = this._interpolateProgress(progress, monitorGroup); + } +}); + +var WorkspaceAnimationController = class { + constructor() { + this._switchData = null; + } + + // The gesture manager calls these three when a gesture is set to switch + // workspaces. Only one swipe runs at a time. + + /** + * _prepareWorkspaceSwitch: builds the copies the swipe slides and + * hides the live windows; _finishWorkspaceSwitch() undoes it. + */ + _prepareWorkspaceSwitch() { + if (this._switchData) + return; + + const nWorkspaces = global.workspace_manager.get_n_workspaces(); + const workspaceIndices = [...Array(nWorkspaces).keys()]; + + const switchData = {}; + + this._switchData = switchData; + // Every monitor gets a group since the live windows are hidden, but + // with workspaces-only-on-primary only one follows the swipe. + switchData.monitors = []; + switchData.animatedMonitors = []; + switchData.gestureActivated = false; + + const onlyOnPrimary = Meta.prefs_get_workspaces_only_on_primary(); + + for (const monitor of Main.layoutManager.monitors) { + const group = new MonitorGroup(monitor, workspaceIndices); + + Main.switcherGroup.add_actor(group); + switchData.monitors.push(group); + + if (!onlyOnPrimary || monitor.index === Main.layoutManager.primaryIndex) + switchData.animatedMonitors.push(group); + } + + Meta.disable_unredirect_for_display(global.display); + global.window_group.hide(); + } + + _finishWorkspaceSwitch(switchData) { + this._switchData = null; + + // The overview and expo hide the window group themselves; leave it + // alone if one of them took over while we were animating. + if (!Main.overview.visible && !Main.expo.visible) + global.window_group.show(); + + Meta.enable_unredirect_for_display(global.display); + + switchData.monitors.forEach(m => m.destroy()); + } + + _findMonitorGroup(monitorIndex) { + return this._switchData.animatedMonitors.find(m => m.index === monitorIndex); + } + + /** + * switchBegin: prepares the workspaces on @monitor for a gesture, + * hiding the live windows. + * + * Returns: { snapPoints, progress, cancelProgress } (a snap point is a + * workspace), or null if unswipeable, in which case nothing was + * prepared and the other two methods must not be called. + */ + switchBegin(monitor) { + if (Meta.prefs_get_workspaces_only_on_primary() && + monitor !== Main.layoutManager.primaryIndex) + return null; + + if (!Main.animations_enabled || Main.modalCount > 0) + return null; + + if (global.workspace_manager.get_n_workspaces() < 2) + return null; + + if (this._switchData && this._switchData.gestureActivated) { + for (const group of this._switchData.animatedMonitors) + group.remove_all_transitions(); + } else { + this._prepareWorkspaceSwitch(); + } + + const monitorGroup = this._findMonitorGroup(monitor); + if (!monitorGroup) { + // Nothing to swipe here. Undo the preparation, or the window + // group stays hidden. + if (!this._switchData.gestureActivated) + this._finishWorkspaceSwitch(this._switchData); + return null; + } + + const progress = monitorGroup.progress; + const closestWs = monitorGroup.findClosestWorkspace(progress); + const cancelProgress = monitorGroup.getWorkspaceProgress(closestWs); + + this._switchData.baseMonitorGroup = monitorGroup; + + return { + snapPoints: monitorGroup.getSnapPoints(), + baseDistance: monitorGroup.baseDistance, + progress, + cancelProgress, + }; + } + + switchUpdate(progress) { + if (!this._switchData) + return; + + for (const monitorGroup of this._switchData.animatedMonitors) + monitorGroup.updateSwipeForMonitor(progress, this._switchData.baseMonitorGroup); + } + + /** + * switchEnd: slides to @endProgress over @duration ms, then activates + * that workspace once settled, since activating early would animate + * the same change a second time. + */ + switchEnd(duration, endProgress) { + if (!this._switchData) + return; + + const switchData = this._switchData; + switchData.gestureActivated = true; + + const newWs = switchData.baseMonitorGroup.findClosestWorkspace(endProgress); + const endTime = Clutter.get_current_event_time(); + + const params = { + duration, + mode: Clutter.AnimationMode.EASE_OUT_CUBIC, + }; + + // Animate the primary monitor last: a duration of 0 completes + // immediately, which would destroy the groups mid-loop. + const primaryGroup = + switchData.animatedMonitors.find(m => m.index === Main.layoutManager.primaryIndex); + + for (const monitorGroup of switchData.animatedMonitors) { + if (monitorGroup === primaryGroup) + continue; + + monitorGroup.ease_property('progress', + monitorGroup.getWorkspaceProgress(newWs), params); + } + + const lastGroup = primaryGroup ? primaryGroup : switchData.animatedMonitors[0]; + if (!lastGroup) { + this._finishWorkspaceSwitch(switchData); + return; + } + + lastGroup.ease_property('progress', lastGroup.getWorkspaceProgress(newWs), { + ...params, + onComplete: () => { + if (!newWs.active) + newWs.activate(endTime); + this._finishWorkspaceSwitch(switchData); + }, + }); + } + + /** + * gestureActive: true from the moment a swipe commits until it has + * settled. windowManager checks this to skip animating a switch twice. + */ + get gestureActive() { + return this._switchData !== null && this._switchData.gestureActivated; + } + + /** + * cancelSwitchAnimation: drops a swipe still following the fingers, for + * when something else switches workspaces mid-gesture. Leaves a swipe + * that has already committed to finish. + */ + cancelSwitchAnimation() { + if (!this._switchData) + return; + + if (this._switchData.gestureActivated) + return; + + this._finishWorkspaceSwitch(this._switchData); + } +}; diff --git a/js/ui/workspacesView.js b/js/ui/workspacesView.js index 4c55dc679b..42b30392f0 100644 --- a/js/ui/workspacesView.js +++ b/js/ui/workspacesView.js @@ -129,6 +129,36 @@ class WorkspacesView extends St.Widget { return this._workspaces[index]; } + // Overview drives these three during a swipe; see ui/overview.js. + // Every workspace is prepared, matching zoomToOverview(). + + /** + * prepareGesture: lays out every workspace for the overview, ready to + * be held partway by setGestureProgress(). + */ + prepareGesture() { + for (const workspace of this._workspaces) + workspace.prepareOverviewGesture(); + } + + /** + * setGestureProgress: + * @progress: 0 for the real desktop layout, 1 for the overview layout + */ + setGestureProgress(progress) { + for (const workspace of this._workspaces) + workspace.setOverviewProgress(progress); + } + + /** + * endGesture: + * @shown: whether the swipe ended with the overview open + */ + endGesture(shown) { + for (const workspace of this._workspaces) + workspace.endOverviewGesture(shown); + } + hide() { let activeWorkspaceIndex = global.workspace_manager.get_active_workspace_index(); let activeWorkspace = this._workspaces[activeWorkspaceIndex]; @@ -267,6 +297,60 @@ class WorkspacesView extends St.Widget { this.add_child(workspace); } + /** + * workspaceScrollBegin: a gesture drives the scroll between the + * side-by-side workspaces, one per snap point. + * + * Returns: { snapPoints, progress }, or null if there is one workspace. + */ + workspaceScrollBegin() { + if (this._workspaces.length < 2) + return null; + + // Same flag a mouse swipe-scroll sets: without it, + // _activeWorkspaceChanged() reacts to each boundary crossing with + // its own ease, fighting the per-frame value this gesture writes. + this._scrolling = true; + + return { + snapPoints: this._workspaces.map((workspace, index) => index), + progress: this._scrollAdjustment.value, + }; + } + + workspaceScrollUpdate(progress) { + this._scrollAdjustment.value = progress; + } + + /** + * workspaceScrollEnd: scrolls to @target over @duration ms, then + * activates it once settled, since activating early animates the + * same change twice. + */ + workspaceScrollEnd(target, duration) { + const land = () => { + const workspace = global.workspace_manager.get_workspace_by_index(target); + if (workspace && !workspace.active) + workspace.activate(global.get_current_time()); + + // Only now: clearing it earlier would mistake this activation + // for one crossed mid-swipe and skip it. + this._scrolling = false; + }; + + if (duration === 0) { + this._scrollAdjustment.value = target; + land(); + return; + } + + this._scrollAdjustment.ease(target, { + duration, + mode: Clutter.AnimationMode.EASE_OUT_CUBIC, + onComplete: land, + }); + } + _activeWorkspaceChanged(wm, from, to, direction) { if (this._scrolling) return; diff --git a/po/POTFILES.in b/po/POTFILES.in index b17349f5b7..cff914c85c 100644 --- a/po/POTFILES.in +++ b/po/POTFILES.in @@ -204,6 +204,7 @@ js/ui/gestures/gesturesManager.js js/ui/gestures/nativeGestures.js js/ui/gestures/nativeGestureSource.js js/ui/gestures/toucheggGestureSource.js +js/ui/gestures/tracking.js js/ui/hotCorner.js js/ui/ibusCandidatePopup.js js/ui/iconGrid.js