Touchpad edge gestures for Linux/Wayland.
edgepad turns touchpad edges into command zones while keeping normal pointer movement on the center of the pad. Swipe from an edge to run commands such as changing workspaces, opening a launcher, or sending desktop notifications.
The hard part is input correctness: Type-B multitouch slots, mixed edge/center contacts, SYN_DROPPED recovery, and virtual touch cleanup are covered by replay tests before they touch real hardware.
The touchpad is split into four edge zones: left, right, top, and bottom.
A slider and a tap gesture can share the same edge. A slider and a directional gesture cannot, because both would need to own the same movement.
curl -fsSL https://raw.githubusercontent.com/assembledev/edgepad/main/install.sh | sh
Preview the install plan first:
curl -fsSL https://raw.githubusercontent.com/assembledev/edgepad/main/install.sh | sh -s -- --dry-run
The release installer is the complete setup for an x86_64 Linux desktop with systemd. It downloads
a static binary, installs the udev rules, writes a default config to
~/.config/edgepad/edgepad.toml, installs and starts the user service, then runs edgepad doctor.
It asks for sudo only when installing the udev rules.
Run the same installer command again. It keeps your config, replaces the installed files, restarts the daemon, and checks that the service is healthy.
Uninstall files created by the release installer:
curl -fsSL https://raw.githubusercontent.com/assembledev/edgepad/main/install.sh | sh -s -- --uninstall
This keeps ~/.config/edgepad/edgepad.toml. To remove the config too:
curl -fsSL https://raw.githubusercontent.com/assembledev/edgepad/main/install.sh | sh -s -- --uninstall --purge
For normal desktop use, install both the NixOS module and the Home Manager module. The NixOS module provides device access; the Home Manager module owns the config and user service. See the complete Nix setup.
The commands below only build or run the package. They do not install device rules or a user service.
Build and run from the repository:
nix build .#edgepad
./result/bin/edgepad --help
Run without installing:
nix run github:assembledev/edgepad -- --help
nix run github:assembledev/edgepad -- devices
The config installed by the release script uses desktop notifications as safe example actions. Gestures will show what they matched, but they will not change your volume, brightness, media state, or workspace until you replace those commands.
Check that the service, config, and touchpad are ready:
edgepad status
The service becomes active only after the virtual touchpad is created and the physical device is grabbed. Status output includes the ready daemon's PID, version, and selected device when systemd provides them.
If status reports a problem, run the full check:
edgepad doctor
Edit your config:
$EDITOR ~/.config/edgepad/edgepad.toml
Example config:
device = "auto"
edge_width = 0.10
tap_min_duration_ms = 40
tap_max_duration_ms = 180
double_tap_timeout_ms = 300
double_tap_max_distance = 0.04
swipe_min_distance = 0.02
[[sliders]]
zone = "left"
up = ["notify-send", "edgepad", "volume-up"]
down = ["notify-send", "edgepad", "volume-down"]
[[sliders]]
zone = "right"
up = ["notify-send", "edgepad", "brightness-up"]
down = ["notify-send", "edgepad", "brightness-down"]
[[gestures]]
zone = "top"
direction = "tap"
action = ["notify-send", "edgepad", "play-pause"]
[[gestures]]
zone = "top"
direction = "double-tap"
action = ["notify-send", "edgepad", "stop"]
Reload the user service after config changes:
systemctl --user reload edgepad.service
The daemon validates the complete file and keeps the previous configuration if the reload fails. If a touch or tap sequence is active, it defers the swap until recognition is idle, so one sequence is never interpreted using two configurations. Changing the configured input device still requires a service restart.
Watch logs:
journalctl --user -u edgepad.service -f
If pointer input behaves incorrectly, stop edgepad immediately:
systemctl --user stop edgepad.service
The physical touchpad is ungrabbed when the daemon stops. Start it again with:
systemctl --user start edgepad.service
For a foreground run, edgepad daemon reads ~/.config/edgepad/edgepad.toml by default.
device can be "auto" or an explicit event node:
device = "auto"
# device = "/dev/input/event7"
edge_width is the fraction of the touchpad reserved for edge zones on each side:
edge_width = 0.10
Individual edges can optionally override that default. Omitted overrides continue to use
edge_width:
left_edge_width = 0.08
right_edge_width = 0.12
tap_min_duration_ms ignores very short edge taps. It defaults to 40; set it to 0 to disable the guard.
tap_min_duration_ms = 40
tap_max_duration_ms prevents a stationary hold from becoming a tap. A contact must lift before
this limit, which defaults to 180 milliseconds.
tap_max_duration_ms = 180
Double-tap recognition uses both time and position. double_tap_timeout_ms is the maximum gap from
the first tap's release to the second tap's contact start. double_tap_max_distance is the maximum
normalized Euclidean distance from the first tap's release position to the second tap's start
position. The defaults are 300 milliseconds and 0.04 (4% of the normalized touchpad axes).
double_tap_timeout_ms = 300
double_tap_max_distance = 0.04
These settings matter only on an edge with a double-tap binding. If that edge also has a tap
binding, edgepad waits until the double-tap deadline before running the single-tap action. Without a
double-tap binding, ordinary taps stay immediate. See the full recognition design.
swipe_min_distance is the minimum normalized touchpad travel that turns an edge contact into a
directional gesture. It defaults to 0.02, or 2% of the corresponding touchpad axis. Smaller
movement remains a tap, so the same physical gesture behaves consistently across coordinate ranges.
Once a contact reaches this distance it no longer qualifies as a tap, even if it returns to its
starting point. A slider contact that emits any steps is also consumed by the slider and does not
emit an additional tap when released.
swipe_min_distance = 0.02
Each gesture binding has a zone, direction, and action:
[[gestures]]
zone = "top"
direction = "right"
action = ["notify-send", "edgepad", "top-right"]
Zones:
left, right, top, bottom
Directions:
up, down, left, right, tap, double-tap
Continuous controls use [[sliders]]. Side zones use vertical up/down steps; top and bottom zones use horizontal left/right steps. step is normalized touchpad travel and defaults to 0.04.
[[sliders]]
zone = "left"
step = 0.04
up = ["pamixer", "-i", "3"]
down = ["pamixer", "-d", "3"]
Slider zones can share the same edge with tap and double-tap gestures, but not with directional
[[gestures]].
Actions are argv arrays. They are not run through a shell, so write shell logic explicitly when needed:
action = ["sh", "-c", "date >> /tmp/edgepad-actions.log"]
For desktop commands, prefer running edgepad as a user service so actions inherit the user session instead of root's environment.
edgepad reads the physical touchpad, claims contacts that begin in configured edge zones, and forwards unclaimed contacts through a virtual touchpad.
For live forwarding it:
/dev/uinput;The output side does not blindly copy raw pointer-emulation events. BTN_TOUCH, BTN_TOOL_*, and legacy ABS_X/Y are synthesized from unclaimed passthrough contacts so an edge-owned finger does not leak into normal pointer movement. Physical touchpad buttons (BTN_LEFT and related pointer buttons) are passed through, and live mode preserves input properties such as INPUT_PROP_BUTTONPAD so libinput keeps clickpad behavior.
On a buttonpad/clickpad, a physical button press takes priority over edge recognition. Active edge-owned contacts are promoted to normal passthrough contacts before the button event, and remain passthrough until they lift, so physical clicks and click-drag work even inside configured edge zones. This cancels the pending edge gesture; slider steps already emitted are not rolled back. Tap-to-click does not produce a physical button event, so an edge tap keeps the normal edge-gesture behavior. Touchpads with separate buttons keep independent edge and button handling.
Double taps are recognized from Type-B contact lifecycles and kernel frame timestamps. The live loop
arms a real deadline after the first tap, so a pending single-tap action is released on time even when
the touchpad remains completely idle. Linux's BTN_TOOL_DOUBLETAP is deliberately not used: that
event code reports two simultaneous fingers, not two taps in time.
edgepad needs access to:
/dev/input/event*;/dev/uinput for virtual touchpad output.The preferred desktop setup is a user service with logind/uaccess ACLs. The NixOS module and release installer install udev rules for that mode.
Manual commands that read real input devices may need sudo, the input group, or active seat ACLs. Use edgepad doctor to see what your system is missing.
Start with these two commands:
edgepad status
edgepad doctor
Common problems:
edgepad devices, then set an explicit
device = "/dev/input/eventX" in the config./dev/uinput is not accessible: use the access fix reported by
edgepad doctor. If udev rules or group membership just changed, start a new login session.activating: edgepad is still waiting for a readable touchpad or
/dev/uinput. Check edgepad doctor and the service log.systemctl --user stop edgepad.service, then
inspect journalctl --user -u edgepad.service -b.proxy, replay, and replay-raw use the normal edgepad config. Pass --config <file> to use
another config, or --built-in-defaults to ignore it.
Device discovery is read-only:
edgepad devices
edgepad devices --all
Capture recognizer-level events from a real touchpad:
edgepad dump --device auto --out bug.ev --frames 300
edgepad replay bug.ev
The frame count is a minimum capture budget. If it is reached while a finger is down, dump asks you to release all contacts and records their release frames before exiting.
Stop edgepad.service before capture: the running daemon holds the physical touchpad with
EVIOCGRAB, so another reader receives no events. dump warns after three seconds without input
but never stops the service automatically. If input arrives later, it confirms that capture has
started and records it normally.
Capture raw evdev events for passthrough/output debugging:
edgepad dump --raw --device auto --out bug.raw.ev --frames 300
edgepad replay-raw bug.raw.ev
Inspect live routing without forwarding input:
edgepad proxy --device /dev/input/eventX --frames 300 --dry-run
Run a bounded live virtual-touchpad proxy test:
This command grabs the physical touchpad for the duration of the test. Normal pointer input is sent through edgepad's temporary virtual touchpad until the frame limit is reached.
edgepad proxy --device /dev/input/eventX --frames 300 --uinput --grab
If auto-detection finds multiple touchpads, use the event node reported by edgepad devices. If the
OS denies access to the event node or /dev/uinput, run edgepad doctor and use the access model it
reports for your system.
edgepad devices List readable input devices and touchpad candidates
edgepad status Show a short daemon/config/device summary
edgepad doctor Check config, runtime prerequisites, actions, and service health
edgepad daemon Run the live edge-gesture proxy
edgepad dump Capture touchpad events into a replay fixture
edgepad proxy Run a bounded live proxy session for diagnostics
edgepad replay Replay a parsed fixture through the recognizer
edgepad replay-raw Replay a raw evdev capture through routing and output composition
Clone the repository and enter its development shell:
git clone https://github.com/assembledev/edgepad.git
cd edgepad
nix develop
Build and check the project from there:
cargo build --locked
cargo fmt --check
cargo clippy --locked --all-targets -- -D warnings
cargo test --locked
Feedback, issues, and pull requests are welcome.
Licensed under either of:
at your option.
111 commits
Rust
95.0%
Nix
3.6%
Shell
1.4%
Touchpad edge gestures for Linux/Wayland.
edgepad turns touchpad edges into command zones while keeping normal pointer movement on the center of the pad. Swipe from an edge to run commands such as changing workspaces, opening a launcher, or sending desktop notifications.
The hard part is input correctness: Type-B multitouch slots, mixed edge/center contacts, SYN_DROPPED recovery, and virtual touch cleanup are covered by replay tests before they touch real hardware.
The touchpad is split into four edge zones: left, right, top, and bottom.
A slider and a tap gesture can share the same edge. A slider and a directional gesture cannot, because both would need to own the same movement.
curl -fsSL https://raw.githubusercontent.com/assembledev/edgepad/main/install.sh | sh
Preview the install plan first:
curl -fsSL https://raw.githubusercontent.com/assembledev/edgepad/main/install.sh | sh -s -- --dry-run
The release installer is the complete setup for an x86_64 Linux desktop with systemd. It downloads
a static binary, installs the udev rules, writes a default config to
~/.config/edgepad/edgepad.toml, installs and starts the user service, then runs edgepad doctor.
It asks for sudo only when installing the udev rules.
Run the same installer command again. It keeps your config, replaces the installed files, restarts the daemon, and checks that the service is healthy.
Uninstall files created by the release installer:
curl -fsSL https://raw.githubusercontent.com/assembledev/edgepad/main/install.sh | sh -s -- --uninstall
This keeps ~/.config/edgepad/edgepad.toml. To remove the config too:
curl -fsSL https://raw.githubusercontent.com/assembledev/edgepad/main/install.sh | sh -s -- --uninstall --purge
For normal desktop use, install both the NixOS module and the Home Manager module. The NixOS module provides device access; the Home Manager module owns the config and user service. See the complete Nix setup.
The commands below only build or run the package. They do not install device rules or a user service.
Build and run from the repository:
nix build .#edgepad
./result/bin/edgepad --help
Run without installing:
nix run github:assembledev/edgepad -- --help
nix run github:assembledev/edgepad -- devices
The config installed by the release script uses desktop notifications as safe example actions. Gestures will show what they matched, but they will not change your volume, brightness, media state, or workspace until you replace those commands.
Check that the service, config, and touchpad are ready:
edgepad status
The service becomes active only after the virtual touchpad is created and the physical device is grabbed. Status output includes the ready daemon's PID, version, and selected device when systemd provides them.
If status reports a problem, run the full check:
edgepad doctor
Edit your config:
$EDITOR ~/.config/edgepad/edgepad.toml
Example config:
device = "auto"
edge_width = 0.10
tap_min_duration_ms = 40
tap_max_duration_ms = 180
double_tap_timeout_ms = 300
double_tap_max_distance = 0.04
swipe_min_distance = 0.02
[[sliders]]
zone = "left"
up = ["notify-send", "edgepad", "volume-up"]
down = ["notify-send", "edgepad", "volume-down"]
[[sliders]]
zone = "right"
up = ["notify-send", "edgepad", "brightness-up"]
down = ["notify-send", "edgepad", "brightness-down"]
[[gestures]]
zone = "top"
direction = "tap"
action = ["notify-send", "edgepad", "play-pause"]
[[gestures]]
zone = "top"
direction = "double-tap"
action = ["notify-send", "edgepad", "stop"]
Reload the user service after config changes:
systemctl --user reload edgepad.service
The daemon validates the complete file and keeps the previous configuration if the reload fails. If a touch or tap sequence is active, it defers the swap until recognition is idle, so one sequence is never interpreted using two configurations. Changing the configured input device still requires a service restart.
Watch logs:
journalctl --user -u edgepad.service -f
If pointer input behaves incorrectly, stop edgepad immediately:
systemctl --user stop edgepad.service
The physical touchpad is ungrabbed when the daemon stops. Start it again with:
systemctl --user start edgepad.service
For a foreground run, edgepad daemon reads ~/.config/edgepad/edgepad.toml by default.
device can be "auto" or an explicit event node:
device = "auto"
# device = "/dev/input/event7"
edge_width is the fraction of the touchpad reserved for edge zones on each side:
edge_width = 0.10
Individual edges can optionally override that default. Omitted overrides continue to use
edge_width:
left_edge_width = 0.08
right_edge_width = 0.12
tap_min_duration_ms ignores very short edge taps. It defaults to 40; set it to 0 to disable the guard.
tap_min_duration_ms = 40
tap_max_duration_ms prevents a stationary hold from becoming a tap. A contact must lift before
this limit, which defaults to 180 milliseconds.
tap_max_duration_ms = 180
Double-tap recognition uses both time and position. double_tap_timeout_ms is the maximum gap from
the first tap's release to the second tap's contact start. double_tap_max_distance is the maximum
normalized Euclidean distance from the first tap's release position to the second tap's start
position. The defaults are 300 milliseconds and 0.04 (4% of the normalized touchpad axes).
double_tap_timeout_ms = 300
double_tap_max_distance = 0.04
These settings matter only on an edge with a double-tap binding. If that edge also has a tap
binding, edgepad waits until the double-tap deadline before running the single-tap action. Without a
double-tap binding, ordinary taps stay immediate. See the full recognition design.
swipe_min_distance is the minimum normalized touchpad travel that turns an edge contact into a
directional gesture. It defaults to 0.02, or 2% of the corresponding touchpad axis. Smaller
movement remains a tap, so the same physical gesture behaves consistently across coordinate ranges.
Once a contact reaches this distance it no longer qualifies as a tap, even if it returns to its
starting point. A slider contact that emits any steps is also consumed by the slider and does not
emit an additional tap when released.
swipe_min_distance = 0.02
Each gesture binding has a zone, direction, and action:
[[gestures]]
zone = "top"
direction = "right"
action = ["notify-send", "edgepad", "top-right"]
Zones:
left, right, top, bottom
Directions:
up, down, left, right, tap, double-tap
Continuous controls use [[sliders]]. Side zones use vertical up/down steps; top and bottom zones use horizontal left/right steps. step is normalized touchpad travel and defaults to 0.04.
[[sliders]]
zone = "left"
step = 0.04
up = ["pamixer", "-i", "3"]
down = ["pamixer", "-d", "3"]
Slider zones can share the same edge with tap and double-tap gestures, but not with directional
[[gestures]].
Actions are argv arrays. They are not run through a shell, so write shell logic explicitly when needed:
action = ["sh", "-c", "date >> /tmp/edgepad-actions.log"]
For desktop commands, prefer running edgepad as a user service so actions inherit the user session instead of root's environment.
edgepad reads the physical touchpad, claims contacts that begin in configured edge zones, and forwards unclaimed contacts through a virtual touchpad.
For live forwarding it:
/dev/uinput;The output side does not blindly copy raw pointer-emulation events. BTN_TOUCH, BTN_TOOL_*, and legacy ABS_X/Y are synthesized from unclaimed passthrough contacts so an edge-owned finger does not leak into normal pointer movement. Physical touchpad buttons (BTN_LEFT and related pointer buttons) are passed through, and live mode preserves input properties such as INPUT_PROP_BUTTONPAD so libinput keeps clickpad behavior.
On a buttonpad/clickpad, a physical button press takes priority over edge recognition. Active edge-owned contacts are promoted to normal passthrough contacts before the button event, and remain passthrough until they lift, so physical clicks and click-drag work even inside configured edge zones. This cancels the pending edge gesture; slider steps already emitted are not rolled back. Tap-to-click does not produce a physical button event, so an edge tap keeps the normal edge-gesture behavior. Touchpads with separate buttons keep independent edge and button handling.
Double taps are recognized from Type-B contact lifecycles and kernel frame timestamps. The live loop
arms a real deadline after the first tap, so a pending single-tap action is released on time even when
the touchpad remains completely idle. Linux's BTN_TOOL_DOUBLETAP is deliberately not used: that
event code reports two simultaneous fingers, not two taps in time.
edgepad needs access to:
/dev/input/event*;/dev/uinput for virtual touchpad output.The preferred desktop setup is a user service with logind/uaccess ACLs. The NixOS module and release installer install udev rules for that mode.
Manual commands that read real input devices may need sudo, the input group, or active seat ACLs. Use edgepad doctor to see what your system is missing.
Start with these two commands:
edgepad status
edgepad doctor
Common problems:
edgepad devices, then set an explicit
device = "/dev/input/eventX" in the config./dev/uinput is not accessible: use the access fix reported by
edgepad doctor. If udev rules or group membership just changed, start a new login session.activating: edgepad is still waiting for a readable touchpad or
/dev/uinput. Check edgepad doctor and the service log.systemctl --user stop edgepad.service, then
inspect journalctl --user -u edgepad.service -b.proxy, replay, and replay-raw use the normal edgepad config. Pass --config <file> to use
another config, or --built-in-defaults to ignore it.
Device discovery is read-only:
edgepad devices
edgepad devices --all
Capture recognizer-level events from a real touchpad:
edgepad dump --device auto --out bug.ev --frames 300
edgepad replay bug.ev
The frame count is a minimum capture budget. If it is reached while a finger is down, dump asks you to release all contacts and records their release frames before exiting.
Stop edgepad.service before capture: the running daemon holds the physical touchpad with
EVIOCGRAB, so another reader receives no events. dump warns after three seconds without input
but never stops the service automatically. If input arrives later, it confirms that capture has
started and records it normally.
Capture raw evdev events for passthrough/output debugging:
edgepad dump --raw --device auto --out bug.raw.ev --frames 300
edgepad replay-raw bug.raw.ev
Inspect live routing without forwarding input:
edgepad proxy --device /dev/input/eventX --frames 300 --dry-run
Run a bounded live virtual-touchpad proxy test:
This command grabs the physical touchpad for the duration of the test. Normal pointer input is sent through edgepad's temporary virtual touchpad until the frame limit is reached.
edgepad proxy --device /dev/input/eventX --frames 300 --uinput --grab
If auto-detection finds multiple touchpads, use the event node reported by edgepad devices. If the
OS denies access to the event node or /dev/uinput, run edgepad doctor and use the access model it
reports for your system.
edgepad devices List readable input devices and touchpad candidates
edgepad status Show a short daemon/config/device summary
edgepad doctor Check config, runtime prerequisites, actions, and service health
edgepad daemon Run the live edge-gesture proxy
edgepad dump Capture touchpad events into a replay fixture
edgepad proxy Run a bounded live proxy session for diagnostics
edgepad replay Replay a parsed fixture through the recognizer
edgepad replay-raw Replay a raw evdev capture through routing and output composition
Clone the repository and enter its development shell:
git clone https://github.com/assembledev/edgepad.git
cd edgepad
nix develop
Build and check the project from there:
cargo build --locked
cargo fmt --check
cargo clippy --locked --all-targets -- -D warnings
cargo test --locked
Feedback, issues, and pull requests are welcome.
Licensed under either of:
at your option.
111 commits
Rust
95.0%
Nix
3.6%
Shell
1.4%