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 @@ + + + +image/svg+xmlOpenclipartMultiTouch-Interface Pixel-theme 5-fingers-Pinch2011-09-16T07:24:29a dedicated set of mutlitouch functions icons in pixel cursor stylehttps://openclipart.org/detail/160885/multitouch-interface-pixel-theme-5-fingers-pinch-by-benboisBenBoisandroidcursorgestureiosipadiphonemultitouchpixelpointertablet diff --git a/files/usr/share/cinnamon/cinnamon-settings/graphics/3-fingers-touchpad.svg b/files/usr/share/cinnamon/cinnamon-settings/graphics/3-fingers-touchpad.svg new file mode 100644 index 0000000000..3eb0dd5e58 --- /dev/null +++ b/files/usr/share/cinnamon/cinnamon-settings/graphics/3-fingers-touchpad.svg @@ -0,0 +1,438 @@ + + + +image/svg+xmlOpenclipartMultiTouch-Interface Pixel-theme 5-fingers-Pinch2011-09-16T07:24:29a dedicated set of mutlitouch functions icons in pixel cursor stylehttps://openclipart.org/detail/160885/multitouch-interface-pixel-theme-5-fingers-pinch-by-benboisBenBoisandroidcursorgestureiosipadiphonemultitouchpixelpointertablet diff --git a/files/usr/share/cinnamon/cinnamon-settings/graphics/4-fingers-touchpad.svg b/files/usr/share/cinnamon/cinnamon-settings/graphics/4-fingers-touchpad.svg new file mode 100644 index 0000000000..de808f4505 --- /dev/null +++ b/files/usr/share/cinnamon/cinnamon-settings/graphics/4-fingers-touchpad.svg @@ -0,0 +1,468 @@ + + + +image/svg+xmlOpenclipartMultiTouch-Interface Pixel-theme 5-fingers-Pinch2011-09-16T07:24:29a dedicated set of mutlitouch functions icons in pixel cursor stylehttps://openclipart.org/detail/160885/multitouch-interface-pixel-theme-5-fingers-pinch-by-benboisBenBoisandroidcursorgestureiosipadiphonemultitouchpixelpointertablet diff --git a/files/usr/share/cinnamon/cinnamon-settings/graphics/5-fingers-touchpad.svg b/files/usr/share/cinnamon/cinnamon-settings/graphics/5-fingers-touchpad.svg new file mode 100644 index 0000000000..d84e654c32 --- /dev/null +++ b/files/usr/share/cinnamon/cinnamon-settings/graphics/5-fingers-touchpad.svg @@ -0,0 +1,507 @@ + + + +image/svg+xmlOpenclipartMultiTouch-Interface Pixel-theme 5-fingers-Pinch2011-09-16T07:24:29a dedicated set of mutlitouch functions icons in pixel cursor stylehttps://openclipart.org/detail/160885/multitouch-interface-pixel-theme-5-fingers-pinch-by-benboisBenBoisandroidcursorgestureiosipadiphonemultitouchpixelpointertablet diff --git a/files/usr/share/cinnamon/cinnamon-settings/graphics/Readme.txt b/files/usr/share/cinnamon/cinnamon-settings/graphics/Readme.txt new file mode 100644 index 0000000000..d46cbb9b0e --- /dev/null +++ b/files/usr/share/cinnamon/cinnamon-settings/graphics/Readme.txt @@ -0,0 +1,3 @@ +Touchpad hands were sourced and modified from CC0 Sources: +https://publicdomainvectors.org/en/free-clipart/Pixel-fingers/38257.html +https://publicdomainvectors.org/en/free-clipart/Pixel-pointing-hand/38242.html diff --git a/files/usr/share/cinnamon/cinnamon-settings/modules/cs_gestures.py b/files/usr/share/cinnamon/cinnamon-settings/modules/cs_gestures.py index 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