4-Finger Swipe to Change Workspace in labwc on Wayland

If you use labwc on a laptop, you probably miss the 4-finger swipe to switch workspaces from GNOME or macOS. labwc 0.20 has no gesture support in rc.xml, and the usual answer for Linux gestures, touchegg, does not run on a pure Wayland session. Here is a setup that works: a small daemon that reads your touchpad through libinput and switches workspaces with wtype.

I run this on a Xiaomi Book Pro 14 (CachyOS, labwc 0.20.2, touchpad BLTP7853:00 347D:7853). The same steps apply to any Arch-based distro and most labwc setups.

Why touchegg does not work here

touchegg 2.0.18 is the first thing most guides suggest, and I tried it. It fails twice on this setup:

  1. The touchegg client needs an X11 display. On a pure Wayland labwc session (my rc.xml has <xwayland>no</xwayland>) it aborts immediately:
    what(): Error opening your X11 display. Make sure your DISPLAY environment variable is set
    
  2. Its CHANGE_DESKTOP action targets GNOME and KDE window managers. labwc ignores it.

touchegg detects gestures through libinput, so the hardware side is fine. The problem is only how actions get delivered. The fix is to skip touchegg and go straight from libinput to a Wayland-native key sender.

How it works

Three pieces:

  1. libinput debug-events reports swipe gestures from the touchpad, including finger count and movement deltas.
  2. A Python script watches that output, accumulates movement for 4-finger swipes, and decides the direction once the gesture ends.
  3. wtype presses Ctrl+Super+Left or Ctrl+Super+Right, which labwc already binds to GoToDesktop in rc.xml.

No AUR packages, no X11, no compositor patches.

Step 1: Check prerequisites

You need libinput, wtype, and Python 3. Your user must be in the input group so the daemon can read the touchpad device:

sudo pacman -S libinput wtype python
sudo usermod -aG input $USER

Log out and back in after adding the group, then verify:

groups | grep -o input
libinput list-devices | grep -A2 -i touchpad | head -n 5

Look for Capabilities: ... gesture on your touchpad. If gesture is missing, the hardware does not report multi-finger swipes and this method cannot work.

Step 2: Confirm the labwc keybinds exist

Open ~/.config/labwc/rc.xml and check the <keyboard> section for workspace switching binds:

<keybind key="C-W-Left"><action name="GoToDesktop" to="left" /></keybind>
<keybind key="C-W-Right"><action name="GoToDesktop" to="right" /></keybind>

In labwc syntax C-W-Left means Ctrl + Super + Left. If these lines are missing, add them inside <keyboard> and reload with labwc --reconfigure. The gesture daemon presses exactly this combination, so an existing shortcut you already use keeps working alongside swipes.

Step 3: Install the gesture daemon

Save this as ~/.local/bin/labwc-4finger-gestures:

#!/usr/bin/env python3
"""4-finger touchpad swipe -> labwc workspace switch (Wayland-native).


Reads `libinput debug-events`, accumulates SWIPE dx/dy for 4-finger
gestures, then emulates Ctrl+Super+Left/Right via `wtype`
(which matches the GoToDesktop keybinds in ~/.config/labwc/rc.xml).


Direction (GNOME-style, natural):
  swipe LEFT  (fingers move left)  -> next workspace (Right)
  swipe RIGHT (fingers move right) -> previous workspace (Left)
  swipe UP    -> next workspace (Right)
  swipe DOWN  -> previous workspace (Left)


Needs: user in `input` group, `wtype`, `libinput`.
"""
import os
import re
import subprocess
import sys
import time


THRESHOLD = float(os.environ.get("LABWC_GESTURE_THRESHOLD", "70.0"))
COOLDOWN = float(os.environ.get("LABWC_GESTURE_COOLDOWN", "0.6"))
WTYPE = "/usr/bin/wtype"


RE_BEGIN = re.compile(r"GESTURE_SWIPE_BEGIN\s+\+\S+\s+(\d+)\s*$")
RE_UPDATE = re.compile(r"GESTURE_SWIPE_UPDATE\s+(?:\d+\s+)?\+\S+\s+(\d+)\s+(-?[\d.]+)/\s*(-?[\d.]+)")
RE_END = re.compile(r"GESTURE_SWIPE_END\s+\+\S+\s+(\d+)\s*$")
RE_CANCEL = re.compile(r"GESTURE_SWIPE_CANCELLED|GESTURE_CANCELLED")




def send_key(key: str) -> None:
    env = dict(os.environ)
    env.setdefault("XDG_RUNTIME_DIR", "/run/user/1000")
    env.setdefault("WAYLAND_DISPLAY", "wayland-0")
    subprocess.run(
        [WTYPE, "-M", "ctrl", "-M", "logo", "-k", key, "-m", "logo", "-m", "ctrl"],
        env=env,
        stdout=subprocess.DEVNULL,
        stderr=subprocess.DEVNULL,
        check=False,
    )




def decide(dx: float, dy: float) -> str | None:
    """Return wtype key (Left/Right) or None if below threshold."""
    if abs(dx) >= abs(dy) and abs(dx) >= THRESHOLD:
        return "Right" if dx < 0 else "Left"  # swipe left -> next
    if abs(dy) > abs(dx) and abs(dy) >= THRESHOLD:
        return "Right" if dy < 0 else "Left"  # swipe up -> next
    return None




def main() -> int:
    fingers = 0
    dx = dy = 0.0
    tracking = False
    last_action = 0.0


    proc = subprocess.Popen(
        ["libinput", "debug-events"],
        stdout=subprocess.PIPE,
        stderr=subprocess.DEVNULL,
        text=True,
        bufsize=1,
    )
    assert proc.stdout is not None
    print("labwc-4finger-gestures: listening (threshold=%.0f)" % THRESHOLD, flush=True)


    for line in proc.stdout:
        if RE_CANCEL.search(line):
            tracking = False
            continue
        m = RE_BEGIN.search(line)
        if m:
            fingers = int(m.group(1))
            dx = dy = 0.0
            tracking = fingers == 4
            continue
        m = RE_UPDATE.search(line)
        if m and tracking and int(m.group(1)) == 4:
            dx += float(m.group(2))
            dy += float(m.group(3))
            continue
        m = RE_END.search(line)
        if m and tracking:
            if int(m.group(1)) == 4:
                # This libinput version reports no totals on END;
                # use dx/dy accumulated from UPDATE lines.
                key = decide(dx, dy)
                now = time.monotonic()
                if key and now - last_action >= COOLDOWN:
                    last_action = now
                    print(f"swipe dx={dx:.0f} dy={dy:.0f} -> {key}", flush=True)
                    send_key(key)
            tracking = False
    return proc.wait()




if __name__ == "__main__":
    sys.exit(main())

Make it executable:

chmod +x ~/.local/bin/labwc-4finger-gestures

Two details in this script come from real debugging, so keep them. The END lines in libinput 1.32 carry no movement totals, only the finger count, which is why the script accumulates UPDATE deltas instead of reading the totals. And the patterns anchor on the timestamp format (+0.095s), because an early version matched digits inside the timestamp and dropped every gesture.

Step 4: Run it as a user service

Create ~/.config/systemd/user/labwc-4finger-gestures.service:

[Unit]
Description=4-finger touchpad swipe to switch labwc workspace (libinput + wtype)
After=graphical-session.target
PartOf=graphical-session.target


[Service]
Type=simple
Environment=XDG_RUNTIME_DIR=/run/user/1000
Environment=WAYLAND_DISPLAY=wayland-0
ExecStart=%h/.local/bin/labwc-4finger-gestures
Restart=always
RestartSec=2


[Install]
WantedBy=default.target

Enable and start it:

systemctl --user daemon-reload
systemctl --user enable --now labwc-4finger-gestures.service
systemctl --user status labwc-4finger-gestures.service

The service survives reboots and restarts the daemon if it crashes. If your Wayland socket has a different name, check ls /run/user/1000/wayland* and adjust WAYLAND_DISPLAY accordingly.

Step 5: Test

Swipe 4 fingers left or right on the touchpad with a deliberate motion. The workspace should switch, same as pressing Ctrl+Super+Left/Right.

Watch the log to confirm the daemon sees your gestures:

journalctl --user -u labwc-4finger-gestures -f

A working swipe prints a line like:

swipe dx=-186 dy=-8 -> Right

If lines appear but the workspace does not switch, the problem is on the wtype side. Test it directly:

XDG_RUNTIME_DIR=/run/user/1000 WAYLAND_DISPLAY=wayland-0 \
  wtype -M ctrl -M logo -k Left -m logo -m ctrl

If no lines appear when you swipe, the problem is detection. Run libinput debug-events in a terminal and swipe; you should see GESTURE_SWIPE_BEGIN lines with a 4 at the end. If you only see 3, your touchpad caps out at 3 fingers and you need to change fingers == 4 to fingers == 3 in the script.

Tuning

Two environment variables adjust the feel. Set them with systemctl --user edit labwc-4finger-gestures.service under [Service]:

Environment=LABWC_GESTURE_THRESHOLD=50.0
Environment=LABWC_GESTURE_COOLDOWN=0.6

LABWC_GESTURE_THRESHOLD (default 70) is the minimum swipe distance in libinput units. On my touchpad a full swipe measures 150 to 350, and accidental brushes stay under 50, so 70 ignores mistakes without missing real swipes. Lower it if light flicks do nothing, raise it if workspaces switch when you did not mean it.

LABWC_GESTURE_COOLDOWN (default 0.6 seconds) blocks double triggers from one long swipe.

To flip the direction (swipe left goes to the previous workspace instead of the next), swap "Right" and "Left" in the decide function.

Troubleshooting

Gestures stop working after a labwc restart

Restart the service so its libinput debug-events child reopens the touchpad device with fresh file descriptors:

systemctl --user restart labwc-4finger-gestures.service

Service is running but nothing happens

Check that the child process exists and the socket name matches:

pgrep -af "4finger|debug-events"
ls -la /run/user/1000/wayland*

If the socket is wayland-1 instead of wayland-0, update the service file and restart.

Swipes work but also fire twice

Raise the cooldown:

Environment=LABWC_GESTURE_COOLDOWN=1.0

I want 3-finger swipes instead

Change both fingers == 4 comparisons to fingers == 3 in the script and restart the service. Keep in mind labwc has no 3-finger conventions, so pick whatever feels right.

Summary

labwc has no built-in touchpad gestures, and touchegg cannot fill the gap on pure Wayland because its client requires X11. A short Python daemon reading libinput debug-events plus wtype for key emulation covers exactly the missing piece: 4-finger swipes trigger the GoToDesktop keybinds you already have. The whole setup is one script, one systemd user unit, and no extra packages beyond wtype.