A Linux accessibility tool that remaps the middle mouse button to screen zoom controls, with support for per-application allowlisting.
- Quick click: Zoom in (Super+=)
- Hold + drag down-right (toward 5 o'clock): Reset zoom (Super+0) - triggers immediately when threshold reached
- Allowlist support: Specified applications receive native middle-click passthrough instead of zoom behavior
- Complete middle-click blocking: For non-allowlisted apps, middle-click is entirely blocked (no passthrough)
Install the following packages:
# Arch Linux (AUR)
yay -S evsieve
# Also required
pacman -S xdotool ydotool evtest- evsieve: Grabs the input device and blocks/filters events
- xdotool: Detects focused window and process name for allowlist checking
- ydotool: Sends synthetic key presses and mouse clicks
- evtest: (optional) Useful for debugging input events
All files are located in ~/.local/bin/:
#!/bin/bash
# Middle Mouse Button Zoom Script with Gesture Support (evsieve version)
# Quick click: Super+= (zoom in)
# Hold + drag down-right (toward 5 o'clock): Super+0 (reset zoom) - triggers immediately
#
# ALLOWLIST: Apps in middle-mouse-allowlist.json get native middle-click passthrough
# (zoom features disabled). All other apps have middle-click blocked.
#
# Uses evsieve to grab the device and block middle-click from passing through.
DEVICE="/dev/input/by-id/usb-Kensington_Orbit_Fusion_Wireless_Trackball-event-mouse"
STATE_FILE="/tmp/middle-mouse-state"
ALLOWLIST_FILE="${HOME}/.local/bin/middle-mouse-allowlist.json"
HANDLER_SCRIPT="${HOME}/.local/bin/middle-mouse-handler.sh"
MOVE_THRESHOLD_X=50
MOVE_THRESHOLD_Y=50
cleanup() {
rm -f "$STATE_FILE"
# Kill any lingering mouse tracker
pkill -f "middle-mouse-tracker" 2>/dev/null
exit 0
}
trap cleanup SIGTERM SIGINT
rm -f "$STATE_FILE"
echo "idle 0 0" > "$STATE_FILE"
echo "Starting middle-mouse-zoom with evsieve..."
echo "Middle-click is blocked for all apps except those in allowlist."
# Export for handler
export STATE_FILE ALLOWLIST_FILE MOVE_THRESHOLD_X MOVE_THRESHOLD_Y
# Use evsieve to:
# 1. Grab the device exclusively
# 2. Use --hook to call handler on middle button press/release
# 3. Block BTN_MIDDLE from output (prevents passthrough)
# 4. Pass rel:x and rel:y through --print to track movement
# 5. Output all other events to a virtual device
evsieve \
--input "$DEVICE" grab \
--hook btn:middle:1 exec-shell="$HANDLER_SCRIPT press" \
--hook btn:middle:0 exec-shell="$HANDLER_SCRIPT release" \
--print rel:x rel:y \
--block btn:middle \
--output name="Kensington Orbit (filtered)" \
2>/dev/null | "$HANDLER_SCRIPT" track#!/bin/bash
# Handler script for middle-mouse-zoom evsieve hooks
# Called by evsieve with argument: "press", "release", or "track"
STATE_FILE="${STATE_FILE:-/tmp/middle-mouse-state}"
ALLOWLIST_FILE="${ALLOWLIST_FILE:-${HOME}/.local/bin/middle-mouse-allowlist.json}"
MOVE_THRESHOLD_X="${MOVE_THRESHOLD_X:-50}"
MOVE_THRESHOLD_Y="${MOVE_THRESHOLD_Y:-50}"
# Load allowlist
load_allowlist() {
if [ -f "$ALLOWLIST_FILE" ]; then
grep -oP '(?<=")[^"]+(?=")' "$ALLOWLIST_FILE" | grep -v "description\|allowlist" | tr '\n' '|' | sed 's/|$//'
fi
}
ALLOWLIST_CACHE=$(load_allowlist)
# Check if focused window's process is in allowlist
is_allowlisted_app() {
[ -z "$ALLOWLIST_CACHE" ] && return 1
local window_id=$(xdotool getactivewindow 2>/dev/null)
[ -z "$window_id" ] && return 1
local pid=$(xdotool getwindowpid "$window_id" 2>/dev/null)
[ -z "$pid" ] && return 1
local proc_name=$(cat /proc/"$pid"/comm 2>/dev/null)
[ -z "$proc_name" ] && return 1
echo "$proc_name" | grep -qiE "$ALLOWLIST_CACHE"
}
send_middle_click() {
ydotool click 0xC0002
}
check_and_trigger_gesture() {
local cur_x=$1
local cur_y=$2
if [ "$cur_x" -gt "$MOVE_THRESHOLD_X" ] && [ "$cur_y" -gt "$MOVE_THRESHOLD_Y" ]; then
# Reset zoom: Super+0
ydotool key 125:1 11:1 11:0 125:0
return 0
fi
return 1
}
case "$1" in
press)
if is_allowlisted_app; then
echo "allowlisted 0 0" > "$STATE_FILE"
else
echo "pressed 0 0" > "$STATE_FILE"
fi
;;
release)
read -r state cur_x cur_y < "$STATE_FILE" 2>/dev/null
if [ "$state" = "allowlisted" ]; then
send_middle_click
elif [ "$state" = "pressed" ] || [ "$state" = "dragging" ]; then
# Only zoom in if minimal movement (not a drag gesture)
if [ "$cur_x" -gt "-$MOVE_THRESHOLD_X" ] && [ "$cur_x" -lt "$MOVE_THRESHOLD_X" ] && \
[ "$cur_y" -gt "-$MOVE_THRESHOLD_Y" ] && [ "$cur_y" -lt "$MOVE_THRESHOLD_Y" ]; then
# Zoom in: Super+=
ydotool key 125:1 13:1 13:0 125:0
fi
fi
# If state was "triggered", gesture already executed - do nothing
echo "idle 0 0" > "$STATE_FILE"
;;
track)
# Track mouse movement from evsieve --print output
# Format: Event: type:code = rel:x value = 5 domain = ...
while read -r line; do
read -r state cur_x cur_y < "$STATE_FILE" 2>/dev/null
# Skip if idle, allowlisted, or already triggered
[ "$state" = "idle" ] && continue
[ "$state" = "allowlisted" ] && continue
[ "$state" = "triggered" ] && continue
if echo "$line" | grep -q "rel:x"; then
rel_x=$(echo "$line" | grep -oP 'value\s*=\s*\K-?[0-9]+')
[ -z "$rel_x" ] && continue
new_x=$((cur_x + rel_x))
if check_and_trigger_gesture "$new_x" "$cur_y"; then
echo "triggered $new_x $cur_y" > "$STATE_FILE"
else
echo "dragging $new_x $cur_y" > "$STATE_FILE"
fi
elif echo "$line" | grep -q "rel:y"; then
rel_y=$(echo "$line" | grep -oP 'value\s*=\s*\K-?[0-9]+')
[ -z "$rel_y" ] && continue
new_y=$((cur_y + rel_y))
if check_and_trigger_gesture "$cur_x" "$new_y"; then
echo "triggered $cur_x $new_y" > "$STATE_FILE"
else
echo "dragging $cur_x $new_y" > "$STATE_FILE"
fi
fi
done
;;
esac{
"description": "Apps where middle-click passthrough is ENABLED (bypasses zoom remapping)",
"allowlist": [
"steam_app_",
"csgo",
"dota2",
"hl2_linux"
]
}-
Create the files in
~/.local/bin/:mkdir -p ~/.local/bin # Copy the scripts above to: # ~/.local/bin/middle-mouse-zoom.sh # ~/.local/bin/middle-mouse-handler.sh # ~/.local/bin/middle-mouse-allowlist.json
-
Make scripts executable:
chmod +x ~/.local/bin/middle-mouse-zoom.sh chmod +x ~/.local/bin/middle-mouse-handler.sh
-
Update the
DEVICEvariable inmiddle-mouse-zoom.shto match your pointing device:# Prefer a stable symlink (survives reboots): ls -l /dev/input/by-id/ | grep -Ei 'mouse|trackball|touchpad' ls -l /dev/input/by-path/ | grep -Ei 'mouse|trackball|touchpad' # (Optional) verify you picked the right one by watching events while you move/click: sudo evtest /dev/input/by-id/<your-device>-event-mouse
-
Ensure ydotool daemon is running:
systemctl --user enable --now ydotool.service
Create ~/.config/systemd/user/middle-mouse-zoom.service:
[Unit]
Description=Middle Mouse Zoom Script
Wants=graphical-session.target
After=graphical-session.target ydotool.service
Requires=ydotool.service
PartOf=graphical-session.target
[Service]
Type=simple
ExecStart=%h/.local/bin/middle-mouse-zoom.sh
Restart=always
RestartSec=3
[Install]
WantedBy=graphical-session.targetNote: on many systems, access to /dev/input/event* is granted by logind after the session becomes active. The script now waits until it can read the device before starting evsieve.
Enable and start (run these without sudo):
systemctl --user daemon-reload
systemctl --user enable middle-mouse-zoom
systemctl --user start middle-mouse-zoomEdit MOVE_THRESHOLD_X and MOVE_THRESHOLD_Y in middle-mouse-zoom.sh to change how far you need to drag before the reset zoom gesture triggers. Default is 50 pixels in each direction.
Edit middle-mouse-allowlist.json and add process names to the allowlist array. Use partial matches (regex patterns work). To find a process name:
# While the app is focused:
xdotool getactivewindow getwindowpid | xargs -I{} cat /proc/{}/comm- evsieve grabs the trackball device exclusively and creates a virtual output device
- Middle button events are blocked from the output (preventing passthrough)
--hooktriggers the handler script on press/release--printstreams mouse movement to the handler'strackmode- The handler tracks cumulative movement while the button is held
- On release, it either sends zoom-in (minimal movement) or does nothing (gesture already triggered)
If you run sudo systemctl --user ... you may get:
Failed to connect to user scope bus ... $DBUS_SESSION_BUS_ADDRESS and $XDG_RUNTIME_DIR not defined
Fix: run the command as your regular user (no sudo):
systemctl --user restart middle-mouse-zoom.serviceIf you truly need to run it from a root shell, target the user instance explicitly:
# replace $USER if needed
uid=$(id -u "$USER")
sudo -u "$USER" XDG_RUNTIME_DIR="/run/user/$uid" \
systemctl --user restart middle-mouse-zoom.service- Check the device path exists:
ls -la /dev/input/by-id/ /dev/input/by-path/ - If you set
DEVICE=/dev/input/eventX, switch to a/dev/input/by-id/...(orby-path) symlink sinceeventXnumbers can change - Ensure you have permission: user may need to be in
inputgroup
- Check ydotool is running:
systemctl --user status ydotool.service - Verify key codes match your desktop environment's zoom shortcuts
- Check xdotool can get window info:
xdotool getactivewindow - Verify process name matches:
cat /proc/$(xdotool getactivewindow getwindowpid)/comm