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 8c1402aa80..46cb63545b 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
@@ -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 @@
+
+
+
+
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 @@
+
+
+
+
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 @@
+
+
+
+
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 @@
+
+
+
+
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 82ca69465f..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,11 +1,14 @@
#!/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
from xapp.GSettingsWidgets import *
@@ -17,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("::")
@@ -67,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:
@@ -90,8 +278,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)
@@ -135,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
@@ -155,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)
@@ -221,63 +456,53 @@ 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)
-
- section = page.add_section(_("Tap"), _("Touchscreen only"))
+ # 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)
- 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(_("Tap"))
+ section.set_halign(Gtk.Align.START)
- section = page.add_section(_("General"))
- widget = GSettingsSwitch(_("Enable gestures"), "org.cinnamon.gestures", "enabled")
- section.add_row(widget)
+ for fingers in range(2, 6):
+ for key in keys:
+ label = self.get_key_label(key, "tap", fingers)
- 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)
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:
@@ -285,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)
@@ -358,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:
@@ -382,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 = {}
@@ -391,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
@@ -398,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)
@@ -475,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)
@@ -504,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)
@@ -514,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)
@@ -548,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)
@@ -562,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"])
@@ -577,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]
@@ -596,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)
@@ -604,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/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/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 38a074557a..d2aab066b0 100644
--- a/js/ui/gestures/actions.js
+++ b/js/ui/gestures/actions.js
@@ -2,13 +2,40 @@
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, GestureDirection } = imports.ui.gestures.gestureTypes;
+const { SwipeProgress } = imports.ui.gestures.tracking;
+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
+
+// 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);
}
@@ -65,10 +124,7 @@ var cleanup = () => {
mixer = null;
}
- if (mpris_controller != null) {
- mpris_controller.shutdown();
- mpris_controller = null;
- }
+ mpris_manager = null;
}
var BaseAction = class {
@@ -96,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);
@@ -142,7 +514,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
}
@@ -248,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);
@@ -438,13 +1128,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 +1143,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 +1172,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..eca89cb6f7 100644
--- a/js/ui/gestures/gesturesManager.js
+++ b/js/ui/gestures/gesturesManager.js
@@ -1,23 +1,30 @@
// -*- 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 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,20 +93,26 @@ 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.touchpad_settings = new Gio.Settings({ schema_id: TOUCHPAD_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.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));
+ this.gestureSource.connect('gesture-end', this.gesture_end.bind(this));
this.settings_or_devices_changed()
}
@@ -115,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;
}
@@ -142,41 +167,22 @@ 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();
+ // 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;
}
- this.shutdown_client();
+ this.gestureSource.shutdown();
+ actions.cleanup();
}
gesture_active() {
@@ -184,8 +190,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();
@@ -238,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);
@@ -250,18 +295,18 @@ 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;
}
- 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}`);
@@ -277,13 +322,13 @@ 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;
}
- 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");
@@ -294,13 +339,13 @@ 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;
}
- 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/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..8f5fb67ef1
--- /dev/null
+++ b/js/ui/gestures/nativeGestures.js
@@ -0,0 +1,555 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+
+const { Clutter, GObject } = 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._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();
+
+ 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 += dx;
+ this._cumulativeY += dy;
+
+ const distance = Math.sqrt(this._cumulativeX ** 2 + this._cumulativeY ** 2);
+
+ if (distance >= DRAG_THRESHOLD_DISTANCE) {
+ // 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._baseDistance = TOUCHPAD_BASE_WIDTH;
+ } else {
+ this._direction = this._cumulativeY < 0 ? GestureDirection.UP : GestureDirection.DOWN;
+ 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;
+ }
+ }
+
+ // 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 = this._direction === GestureDirection.LEFT ? -dx : dx;
+ } else {
+ delta = this._direction === GestureDirection.UP ? -dy : dy;
+ }
+
+ // 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/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 443e3a52e7..cff914c85c 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,13 @@ 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/gestures/tracking.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