diff --git a/README.md b/README.md index 39dedb6..e0dac4e 100644 --- a/README.md +++ b/README.md @@ -60,15 +60,27 @@ them. If you are unsure initially, install both of them. |------------|-------------| |`wmctrl` |Necessary for `_internal` command, as per default configuration| |`xdotool` |Simulates keyboard and mouse actions for Xorg or XWayland based apps| +|`python3-evdev`|Necessary for `_drag` command (continuous drag/text selection on Wayland)| # E.g. On Arch: - sudo pacman -S wmctrl xdotool + sudo pacman -S wmctrl xdotool python-evdev # E.g. On Debian based systems, e.g. Ubuntu: - sudo apt-get install wmctrl xdotool + sudo apt-get install wmctrl xdotool python3-evdev # E.g. On Fedora: - sudo dnf install wmctrl xdotool + sudo dnf install wmctrl xdotool python3-evdev + +The `_drag` command also requires write access to `/dev/uinput`. First, +ensure the `uinput` kernel module is loaded and set to load on boot: + + sudo modprobe uinput + echo uinput | sudo tee /etc/modules-load.d/uinput.conf + +Then apply a udev rule so the `input` group has write access: + + echo 'KERNEL=="uinput", MODE="0660", GROUP="input"' | sudo tee /etc/udev/rules.d/99-uinput.rules + sudo udevadm trigger /dev/uinput NOTE: Arch users can now just install [_libinput-gestures from the AUR_][AUR]. Then skip to the next CONFIGURATION section. @@ -112,6 +124,7 @@ and options described in that file. The available gestures are: |`pinch anticlockwise` || |`hold on` |Open new web browser tab. See description of [hold gestures](#hold-gestures). | |`hold on+N` (for `N` seconds, e.g. 1.5) |After extra hold time delay, close browser tab. See description of [hold gestures](#hold-gestures). | +|`drag drag` |Continuous text selection / drag-and-drop. See description of [drag gestures](#drag-gestures). | NOTE: If you don't use "natural" scrolling direction for your touchpad then you may want to swap the default left/right and up/down @@ -351,6 +364,46 @@ holds which will print the times to the screen so you can choose what to configure for your hold gestures. Run `libinput-gestures-setup restart` to restart `libinput-gestures` after updating your configuration. +### DRAG GESTURES + +Drag gestures simulate holding mouse button 1 while tracking finger +movement, enabling continuous text selection and drag-and-drop. Unlike +swipe gestures which fire a single action at the end, drag gestures emit +mouse events in real time throughout the motion. + +To enable 3-finger drag, add the following to your +`~/.config/libinput-gestures.conf`: + + gesture drag drag 3 _drag + +The `_drag` internal command uses a persistent `uinput` virtual mouse +device to inject `BTN_LEFT` + relative mouse movement events. It works +natively with both Wayland and Xorg clients and requires `python3-evdev` +and write access to `/dev/uinput` (see [INSTALLATION](#installation)). + +#### Lift-and-continue delay + +An optional delay in milliseconds can be specified to enable +lift-and-continue behaviour — the mouse button is held for that duration +after fingers are lifted, allowing you to reposition fingers at the +touchpad edge and resume the selection: + + gesture drag drag 3 _drag 500 + +A new gesture with the same finger count within the delay window resumes +the drag seamlessly. Any other gesture (different finger count, tap, +etc.) ends the drag immediately regardless of the remaining delay. + +#### GNOME Wayland note + +On GNOME Wayland, the compositor intercepts 3-finger swipe gestures +natively. To free them up for drag gestures, install the [_Window +Gestures_](https://extensions.gnome.org/extension/4245/window-gestures/) +GNOME shell extension and set it to use 4 fingers (swapping the default +3/4 finger assignments). This moves workspace switching and window +management to 4-finger gestures, leaving 3 fingers available for +`libinput-gestures` drag. + ### AUTOMATIC STOP/RESTART ON D-BUS EVENTS SUCH AS SUSPEND There are some situations where you may want to automatically stop, diff --git a/libinput-gestures b/libinput-gestures index 0fad10f..e091f4d 100755 --- a/libinput-gestures +++ b/libinput-gestures @@ -232,9 +232,10 @@ class COMMAND: internal_commands = {} -def add_internal_command(cls) -> None: +def add_internal_command(cls): "Add configuration command to command lookup table based on name" internal_commands[re.sub('^COMMAND', '', cls.__name__)] = cls + return cls class MyArgumentParser(argparse.ArgumentParser): @@ -398,6 +399,97 @@ class COMMAND_internal(COMMAND): ) +@add_internal_command +class COMMAND_drag(COMMAND): + "Internal drag command handler using a uinput virtual mouse device (requires python3-evdev)" + + def __init__(self, largs: list): + super().__init__(largs) + self._uinput = None + self._ecodes = None + self._lock = threading.Lock() + self._release_timer: threading.Timer | None = None + self._dragging = False + try: + self._release_delay = float(largs[1]) / 1000.0 if len(largs) > 1 else 0.0 + except ValueError: + print(f'Warning: invalid _drag delay "{largs[1]}", using 0', file=sys.stderr) + self._release_delay = 0.0 + try: + from evdev import UInput, ecodes + cap = {ecodes.EV_REL: [ecodes.REL_X, ecodes.REL_Y], ecodes.EV_KEY: [ecodes.BTN_LEFT]} + self._uinput = UInput(cap, name='libinput-gestures-drag') + self._ecodes = ecodes + except ImportError: + print( + 'Warning: python3-evdev is not installed; _drag command unavailable.', + file=sys.stderr, + ) + except Exception as e: + print(f'Warning: failed to create uinput device: {e}', file=sys.stderr) + + def mousedown(self) -> None: + if not self._uinput: + return + with self._lock: + if self._release_timer is not None: + self._release_timer.cancel() + self._release_timer = None + return + self._uinput.write(self._ecodes.EV_KEY, self._ecodes.BTN_LEFT, 1) + self._uinput.syn() + self._dragging = True + + def mousemove(self, dx: int, dy: int) -> None: + if not self._uinput: + return + if dx: + self._uinput.write(self._ecodes.EV_REL, self._ecodes.REL_X, dx) + if dy: + self._uinput.write(self._ecodes.EV_REL, self._ecodes.REL_Y, dy) + self._uinput.syn() + + def _do_mouseup(self) -> None: + with self._lock: + self._release_timer = None + self._dragging = False + if not self._uinput: + return + self._uinput.write(self._ecodes.EV_KEY, self._ecodes.BTN_LEFT, 0) + self._uinput.syn() + + def mouseup(self) -> None: + with self._lock: + if not self._uinput or not self._dragging: + return + if self._release_delay > 0: + self._release_timer = threading.Timer(self._release_delay, self._do_mouseup) + self._release_timer.start() + else: + self._release_timer = None + self._dragging = False + self._uinput.write(self._ecodes.EV_KEY, self._ecodes.BTN_LEFT, 0) + self._uinput.syn() + + def in_grace_period(self) -> bool: + with self._lock: + return self._release_timer is not None + + def end_grace(self) -> None: + "If a release timer is pending, cancel it and release BTN_LEFT immediately" + with self._lock: + if self._release_timer is not None: + self._release_timer.cancel() + self._release_timer = None + self._dragging = False + if self._uinput: + self._uinput.write(self._ecodes.EV_KEY, self._ecodes.BTN_LEFT, 0) + self._uinput.syn() + + def run(self) -> None: + pass + + # Table of gesture handlers handlers = {} @@ -477,6 +569,10 @@ class GESTURE(ABC): def update(self, coords: str) -> bool: return True + def cancel(self) -> None: + "Called when a gesture is cancelled mid-motion; override to clean up" + pass + def action(self, motion: str, *, command: COMMAND | None = None) -> None: "Action a motion command for this gesture" if command is None: @@ -647,6 +743,63 @@ class HOLD(GESTURE): self.action(motion, command=command) +@add_gesture_handler +class DRAG(GESTURE): + "Class to handle continuous drag gestures via simulated mouse button hold and movement" + + SUPPORTED_MOTIONS = ('drag',) + _cmd: COMMAND | None = None + + def begin(self, fingers: str) -> None: + super().begin(fingers) + key = ('drag', fingers) if fingers else 'drag' + self._cmd = self.motions.get(key) or self.motions.get('drag') + if self._cmd and not args.debug: + if isinstance(self._cmd, COMMAND_drag): + self._cmd.mousedown() + else: + runcmd(self._cmd.argslist + ['mousedown', '1'], block=False) + + def update(self, coords: str) -> bool: + try: + x = float(coords[0]) + y = float(coords[1]) + except (ValueError, IndexError): + return False + self.data[0] += x + self.data[1] += y + if self._cmd and not args.debug: + dx, dy = round(x), round(y) + if dx or dy: + if isinstance(self._cmd, COMMAND_drag): + self._cmd.mousemove(dx, dy) + else: + runcmd(self._cmd.argslist + ['mousemove_relative', '--', str(dx), str(dy)]) + return True + + def end(self) -> None: + if args.verbose: + print(f'{PROGNAME}: DRAG drag {self.fingers} {self.data}') + if self._cmd and not args.debug: + if isinstance(self._cmd, COMMAND_drag): + self._cmd.mouseup() + else: + runcmd(self._cmd.argslist + ['mouseup', '1'], block=False) + + def cancel(self) -> None: + "Release mouse button if gesture is cancelled mid-drag" + if self._cmd and not args.debug: + if isinstance(self._cmd, COMMAND_drag): + self._cmd.mouseup() + else: + runcmd(self._cmd.argslist + ['mouseup', '1'], block=False) + + def end_grace(self) -> None: + "End any pending release grace period immediately" + if self._cmd and isinstance(self._cmd, COMMAND_drag): + self._cmd.end_grace() + + # Table of configuration commands conf_commands = {} @@ -965,6 +1118,12 @@ def main() -> None: print(line.strip()) continue + # Cancel drag grace period immediately on any pointer movement (e.g. single finger) + drag_handler = handlers.get('DRAG') + if drag_handler and drag_handler._cmd and isinstance(drag_handler._cmd, COMMAND_drag) \ + and drag_handler._cmd.in_grace_period() and 'POINTER_MOTION' in line: + drag_handler.end_grace() + # Only interested in gestures if 'GESTURE_' not in line or ' +' not in line: continue @@ -991,15 +1150,23 @@ def main() -> None: ) elif event == 'BEGIN': - if handler := handlers.get(gesture): + if gesture == 'SWIPE' and drag_handler and drag_handler.motions.get(('drag', fingers)): + handler = drag_handler handler.begin(fingers) else: - print(f'Unknown gesture received: {gesture}.', file=sys.stderr) + if drag_handler: + drag_handler.end_grace() + if handler := handlers.get(gesture): + handler.begin(fingers) + else: + print(f'Unknown gesture received: {gesture}.', file=sys.stderr) elif event == 'END': # Ignore gesture if final action is cancelled if handler: if params != 'cancelled': handler.end() + else: + handler.cancel() handler = None else: print( diff --git a/libinput-gestures.conf b/libinput-gestures.conf index 2bafb77..d8fb2dd 100644 --- a/libinput-gestures.conf +++ b/libinput-gestures.conf @@ -142,6 +142,34 @@ gesture swipe right xdotool key alt+Left # swipe up 4 amixer set Master "8%+" # swipe down 4 amixer set Master "8%-" +############################################################################### +# DRAG GESTURES: +############################################################################### + +# Drag gestures simulate holding mouse button 1 while tracking finger movement, +# enabling 3-finger text selection and drag-and-drop. +# +# Two backends are available: +# +# _drag: uses a uinput virtual mouse device (requires python3-evdev). +# Works natively with all Wayland and Xorg clients. +# Ensure your user is in the 'input' group: +# sudo usermod -aG input $USER (then log out and back in) +# Also ensure /dev/uinput is group-accessible: +# echo 'KERNEL=="uinput", MODE="0660", GROUP="input"' | sudo tee /etc/udev/rules.d/99-uinput.rules +# +# An optional delay (milliseconds) enables lift-and-continue: the mouse +# button is held for that duration after fingers are lifted, so you can +# reposition fingers at the touchpad edge and resume the selection. The +# drag only resumes if the same number of fingers are placed back down +# within the delay window; accidental 1- or 2-finger touches are ignored. +# +# gesture drag drag 3 _drag 500 +# +# xdotool: works only for Xorg and XWayland clients. +# +# gesture drag 3 drag xdotool + ############################################################################### # PINCH GESTURES: ############################################################################### diff --git a/pyproject.toml b/pyproject.toml index 07a458b..11f36b0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,3 +1,15 @@ +[project] +name = "libinput-gestures" +version = "2.80" +requires-python = ">=3.10" +dependencies = [] + +[project.optional-dependencies] +uinput = ["evdev"] + +[dependency-groups] +dev = ["ruff", "ty"] + [tool.mypy] implicit_optional = true warn_no_return = false