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 da728fd28a..b81bf7a779 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 c057d66da5..5faa03870c 100644 --- a/po/POTFILES.in +++ b/po/POTFILES.in @@ -12,7 +12,6 @@ data/cinnamon2d.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 @@ -107,6 +106,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 @@ -200,10 +200,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