Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 56 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
173 changes: 170 additions & 3 deletions libinput-gestures
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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 = {}

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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 = {}

Expand Down Expand Up @@ -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
Expand All @@ -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(
Expand Down
28 changes: 28 additions & 0 deletions libinput-gestures.conf
Original file line number Diff line number Diff line change
Expand Up @@ -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:
###############################################################################
Expand Down
12 changes: 12 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -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
Expand Down