From 72ad3c51f64fe2f58d182b8ea36aac89acb4b3c2 Mon Sep 17 00:00:00 2001 From: Philip Balinov Date: Sat, 18 Apr 2026 09:27:14 +0300 Subject: [PATCH 1/6] Add continuous three-finger drag gesture for text selection on Wayland Adds a new DRAG gesture handler and _drag internal command that simulate a held mouse button while tracking finger movement via a persistent uinput virtual device. This enables three-finger drag-to-select text and drag-and-drop on both Wayland and Xorg. Key implementation details: - COMMAND_drag creates a uinput device at startup (not per-gesture) so Wayland's compositor has time to discover it before the first event - DRAG gesture handler intercepts GESTURE_SWIPE events (since libinput has no GESTURE_DRAG type) when a drag config matches the finger count - Handles gesture cancellation mid-drag to ensure BTN_LEFT is released - Requires python3-evdev and user in the 'input' group Co-Authored-By: Claude Sonnet 4.6 --- libinput-gestures | 114 ++++++++++++++++++++++++++++++++++++++++- libinput-gestures.conf | 20 ++++++++ pyproject.toml | 9 ++++ 3 files changed, 142 insertions(+), 1 deletion(-) diff --git a/libinput-gestures b/libinput-gestures index 0fad10f..3bb851a 100755 --- a/libinput-gestures +++ b/libinput-gestures @@ -398,6 +398,54 @@ 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 + 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 + ec = self._ecodes + self._uinput.write(ec.EV_KEY, ec.BTN_LEFT, 1) + self._uinput.syn() + + def mousemove(self, dx: int, dy: int) -> None: + if not self._uinput: + return + ec = self._ecodes + if dx: + self._uinput.write(ec.EV_REL, ec.REL_X, dx) + if dy: + self._uinput.write(ec.EV_REL, ec.REL_Y, dy) + self._uinput.syn() + + def mouseup(self) -> None: + if not self._uinput: + return + ec = self._ecodes + self._uinput.write(ec.EV_KEY, ec.BTN_LEFT, 0) + self._uinput.syn() + + def run(self) -> None: + pass + + # Table of gesture handlers handlers = {} @@ -477,6 +525,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 +699,57 @@ 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',) + + 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 type(self._cmd).__name__ == '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 type(self._cmd).__name__ == '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 type(self._cmd).__name__ == '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 hasattr(self, '_cmd') and self._cmd and not args.debug: + if type(self._cmd).__name__ == 'COMMAND_drag': + self._cmd.mouseup() + else: + runcmd(self._cmd.argslist + ['mouseup', '1'], block=False) + + # Table of configuration commands conf_commands = {} @@ -991,7 +1094,14 @@ def main() -> None: ) elif event == 'BEGIN': - if handler := handlers.get(gesture): + drag_handler = handlers.get('DRAG') + if gesture == 'SWIPE' and drag_handler and ( + drag_handler.motions.get(('drag', fingers)) + or drag_handler.motions.get('drag') + ): + handler = drag_handler + handler.begin(fingers) + elif handler := handlers.get(gesture): handler.begin(fingers) else: print(f'Unknown gesture received: {gesture}.', file=sys.stderr) @@ -1000,6 +1110,8 @@ def main() -> None: 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..3bb0191 100644 --- a/libinput-gestures.conf +++ b/libinput-gestures.conf @@ -142,6 +142,26 @@ 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) +# +gesture drag drag 3 _drag +# +# 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..3312eaf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,3 +1,12 @@ +[project] +name = "libinput-gestures" +version = "2.80" +requires-python = ">=3.10" +dependencies = ["evdev"] + +[dependency-groups] +dev = ["ruff", "ty"] + [tool.mypy] implicit_optional = true warn_no_return = false From 1f72906892ef64b0765935e47ddebf2f35939bad Mon Sep 17 00:00:00 2001 From: Philip Balinov Date: Sat, 18 Apr 2026 09:48:49 +0300 Subject: [PATCH 2/6] Add configurable release delay for drag-lock / lift-and-continue When a delay is specified (e.g. `_drag 500`), BTN_LEFT is held for that many milliseconds after fingers are lifted. A new three-finger gesture within the window cancels the timer and resumes dragging seamlessly, allowing the user to reposition fingers at the touchpad edge without losing the selection. Co-Authored-By: Claude Sonnet 4.6 --- libinput-gestures | 21 ++++++++++++++++++++- libinput-gestures.conf | 2 +- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/libinput-gestures b/libinput-gestures index 3bb851a..7197da2 100755 --- a/libinput-gestures +++ b/libinput-gestures @@ -405,6 +405,9 @@ class COMMAND_drag(COMMAND): def __init__(self, largs: list): super().__init__(largs) self._uinput = None + self._release_delay = float(largs[1]) / 1000.0 if len(largs) > 1 else 0.0 + self._release_timer: threading.Timer | None = None + self._dragging = False try: from evdev import UInput, ecodes cap = {ecodes.EV_REL: [ecodes.REL_X, ecodes.REL_Y], ecodes.EV_KEY: [ecodes.BTN_LEFT]} @@ -421,9 +424,14 @@ class COMMAND_drag(COMMAND): def mousedown(self) -> None: if not self._uinput: return + if self._release_timer is not None: + self._release_timer.cancel() + self._release_timer = None + return ec = self._ecodes self._uinput.write(ec.EV_KEY, ec.BTN_LEFT, 1) self._uinput.syn() + self._dragging = True def mousemove(self, dx: int, dy: int) -> None: if not self._uinput: @@ -435,13 +443,24 @@ class COMMAND_drag(COMMAND): self._uinput.write(ec.EV_REL, ec.REL_Y, dy) self._uinput.syn() - def mouseup(self) -> None: + def _do_mouseup(self) -> None: + self._release_timer = None + self._dragging = False if not self._uinput: return ec = self._ecodes self._uinput.write(ec.EV_KEY, ec.BTN_LEFT, 0) self._uinput.syn() + def mouseup(self) -> None: + 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._do_mouseup() + def run(self) -> None: pass diff --git a/libinput-gestures.conf b/libinput-gestures.conf index 3bb0191..c1e2a1a 100644 --- a/libinput-gestures.conf +++ b/libinput-gestures.conf @@ -156,7 +156,7 @@ gesture swipe right xdotool key alt+Left # Ensure your user is in the 'input' group: # sudo usermod -aG input $USER (then log out and back in) # -gesture drag drag 3 _drag +gesture drag drag 3 _drag 500 # # xdotool: works only for Xorg and XWayland clients. # From 9828ce914c63e833338e4c2940e8bd98076f15d1 Mon Sep 17 00:00:00 2001 From: Philip Balinov Date: Sat, 18 Apr 2026 10:04:42 +0300 Subject: [PATCH 3/6] Address code review: thread safety, isinstance, optional evdev dep - Return cls from add_internal_command decorator so isinstance(cmd, COMMAND_drag) works correctly instead of type().__name__ string check - Add threading.Lock to COMMAND_drag to guard shared state accessed from both the main thread and the release timer thread - Initialize self._ecodes = None explicitly before the try block - Wrap bad delay arg in try/except ValueError with a clear warning - Make evdev an optional dependency ([project.optional-dependencies] uinput) so users of the xdotool backend don't need it - Initialize _cmd = None as a class-level attribute, removing the hasattr() guard in cancel() - Comment out the drag gesture in the default conf; users opt in via their personal ~/.config/libinput-gestures.conf Co-Authored-By: Claude Sonnet 4.6 --- libinput-gestures | 80 +++++++++++++++++++++++------------------- libinput-gestures.conf | 10 +++++- pyproject.toml | 5 ++- 3 files changed, 57 insertions(+), 38 deletions(-) diff --git a/libinput-gestures b/libinput-gestures index 7197da2..b6f5bb8 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): @@ -405,9 +406,15 @@ class COMMAND_drag(COMMAND): def __init__(self, largs: list): super().__init__(largs) self._uinput = None - self._release_delay = float(largs[1]) / 1000.0 if len(largs) > 1 else 0.0 + 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]} @@ -424,42 +431,45 @@ class COMMAND_drag(COMMAND): def mousedown(self) -> None: if not self._uinput: return - if self._release_timer is not None: - self._release_timer.cancel() - self._release_timer = None - return - ec = self._ecodes - self._uinput.write(ec.EV_KEY, ec.BTN_LEFT, 1) - self._uinput.syn() - self._dragging = True + 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 - ec = self._ecodes if dx: - self._uinput.write(ec.EV_REL, ec.REL_X, dx) + self._uinput.write(self._ecodes.EV_REL, self._ecodes.REL_X, dx) if dy: - self._uinput.write(ec.EV_REL, ec.REL_Y, dy) + self._uinput.write(self._ecodes.EV_REL, self._ecodes.REL_Y, dy) self._uinput.syn() def _do_mouseup(self) -> None: - self._release_timer = None - self._dragging = False - if not self._uinput: - return - ec = self._ecodes - self._uinput.write(ec.EV_KEY, ec.BTN_LEFT, 0) - self._uinput.syn() + 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: - 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._do_mouseup() + 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 run(self) -> None: pass @@ -723,13 +733,14 @@ 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 type(self._cmd).__name__ == 'COMMAND_drag': + if isinstance(self._cmd, COMMAND_drag): self._cmd.mousedown() else: runcmd(self._cmd.argslist + ['mousedown', '1'], block=False) @@ -745,7 +756,7 @@ class DRAG(GESTURE): if self._cmd and not args.debug: dx, dy = round(x), round(y) if dx or dy: - if type(self._cmd).__name__ == 'COMMAND_drag': + if isinstance(self._cmd, COMMAND_drag): self._cmd.mousemove(dx, dy) else: runcmd(self._cmd.argslist + ['mousemove_relative', '--', str(dx), str(dy)]) @@ -755,15 +766,15 @@ class DRAG(GESTURE): if args.verbose: print(f'{PROGNAME}: DRAG drag {self.fingers} {self.data}') if self._cmd and not args.debug: - if type(self._cmd).__name__ == 'COMMAND_drag': + 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 hasattr(self, '_cmd') and self._cmd and not args.debug: - if type(self._cmd).__name__ == 'COMMAND_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) @@ -1114,10 +1125,7 @@ def main() -> None: elif event == 'BEGIN': drag_handler = handlers.get('DRAG') - if gesture == 'SWIPE' and drag_handler and ( - drag_handler.motions.get(('drag', fingers)) - or drag_handler.motions.get('drag') - ): + if gesture == 'SWIPE' and drag_handler and drag_handler.motions.get(('drag', fingers)): handler = drag_handler handler.begin(fingers) elif handler := handlers.get(gesture): diff --git a/libinput-gestures.conf b/libinput-gestures.conf index c1e2a1a..d8fb2dd 100644 --- a/libinput-gestures.conf +++ b/libinput-gestures.conf @@ -155,8 +155,16 @@ gesture swipe right xdotool key alt+Left # 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 # -gesture drag drag 3 _drag 500 +# 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. # diff --git a/pyproject.toml b/pyproject.toml index 3312eaf..11f36b0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,7 +2,10 @@ name = "libinput-gestures" version = "2.80" requires-python = ">=3.10" -dependencies = ["evdev"] +dependencies = [] + +[project.optional-dependencies] +uinput = ["evdev"] [dependency-groups] dev = ["ruff", "ty"] From 57745460de469194a1504fd2be2099045f7d1ee1 Mon Sep 17 00:00:00 2001 From: Philip Balinov Date: Sat, 18 Apr 2026 10:09:01 +0300 Subject: [PATCH 4/6] End drag grace period immediately on any non-resuming gesture Any BEGIN event during the lift-and-continue window that is not a matching three-finger drag will now immediately release BTN_LEFT rather than waiting for the timer to expire. Co-Authored-By: Claude Sonnet 4.6 --- libinput-gestures | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/libinput-gestures b/libinput-gestures index b6f5bb8..563b0ea 100755 --- a/libinput-gestures +++ b/libinput-gestures @@ -471,6 +471,17 @@ class COMMAND_drag(COMMAND): self._uinput.write(self._ecodes.EV_KEY, self._ecodes.BTN_LEFT, 0) self._uinput.syn() + 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 @@ -779,6 +790,11 @@ class DRAG(GESTURE): 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 = {} @@ -1128,10 +1144,13 @@ def main() -> None: if gesture == 'SWIPE' and drag_handler and drag_handler.motions.get(('drag', fingers)): handler = drag_handler handler.begin(fingers) - elif handler := handlers.get(gesture): - 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: From 2f84c5af1652aee53ff1c52e732973503325cc6b Mon Sep 17 00:00:00 2001 From: Philip Balinov Date: Sat, 18 Apr 2026 11:25:40 +0300 Subject: [PATCH 5/6] docs: document drag gesture, _drag command, and evdev dependency Co-Authored-By: Claude Sonnet 4.6 --- README.md | 54 +++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 51 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 39dedb6..501493e 100644 --- a/README.md +++ b/README.md @@ -60,15 +60,22 @@ 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`. The +recommended approach is a udev rule: + + echo 'KERNEL=="uinput", MODE="0660", GROUP="input"' | sudo tee /etc/udev/rules.d/99-uinput.rules + sudo udevadm control --reload-rules && sudo udevadm trigger NOTE: Arch users can now just install [_libinput-gestures from the AUR_][AUR]. Then skip to the next CONFIGURATION section. @@ -112,6 +119,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 +359,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, From 8395ea96853f0535b60354b906befd5c2e454e4d Mon Sep 17 00:00:00 2001 From: Philip Balinov Date: Tue, 28 Apr 2026 23:26:04 +0300 Subject: [PATCH 6/6] Cancel drag grace period on pointer movement; document uinput module setup End the release grace period immediately when a POINTER_MOTION event is seen, so moving a single finger after lifting three does not continue text selection for the duration of the delay. Also document the modprobe / modules-load.d steps needed to ensure /dev/uinput exists before applying the udev rule. Co-Authored-By: Claude Sonnet 4.6 --- README.md | 11 ++++++++--- libinput-gestures | 11 ++++++++++- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 501493e..e0dac4e 100644 --- a/README.md +++ b/README.md @@ -71,11 +71,16 @@ them. If you are unsure initially, install both of them. # E.g. On Fedora: sudo dnf install wmctrl xdotool python3-evdev -The `_drag` command also requires write access to `/dev/uinput`. The -recommended approach is a udev rule: +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 control --reload-rules && sudo udevadm trigger + 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. diff --git a/libinput-gestures b/libinput-gestures index 563b0ea..e091f4d 100755 --- a/libinput-gestures +++ b/libinput-gestures @@ -471,6 +471,10 @@ class COMMAND_drag(COMMAND): 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: @@ -1114,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 @@ -1140,7 +1150,6 @@ def main() -> None: ) elif event == 'BEGIN': - drag_handler = handlers.get('DRAG') if gesture == 'SWIPE' and drag_handler and drag_handler.motions.get(('drag', fingers)): handler = drag_handler handler.begin(fingers)