diff --git a/docs/wiki/Configuration:-Input.md b/docs/wiki/Configuration:-Input.md
index 9af94dcaef..83076019a6 100644
--- a/docs/wiki/Configuration:-Input.md
+++ b/docs/wiki/Configuration:-Input.md
@@ -2,7 +2,7 @@
In this section you can configure input devices like keyboard and mouse, and some input-related options.
-There's a section for each device type: `keyboard`, `touchpad`, `mouse`, `trackpoint`, `trackball`, `tablet`, `touch`.
+There's a section for each device type: `keyboard`, `touchpad`, `mouse`, `trackpoint`, `trackball`, `tablet`, `touchscreen`.
Settings in those sections will apply to every device of that type.
Currently, there's no way to configure specific devices individually (but that is planned).
@@ -46,6 +46,14 @@ input {
// left-handed
// disabled-on-external-mouse
// middle-emulation
+
+ // Touchpad gesture binds live in the main binds {} block using
+ // the `TouchpadSwipe` trigger with `fingers=N direction="..."`
+ // properties. This subblock only contains tuning parameters.
+ // gestures {
+ // swipe-trigger-distance 16.0
+ // swipe-progress-distance 40.0
+ // }
}
mouse {
@@ -95,10 +103,29 @@ input {
// calibration-matrix 1.0 0.0 0.0 0.0 1.0 0.0
}
- touch {
+ touchscreen {
// off
map-to-output "eDP-1"
+ // natural-scroll
// calibration-matrix 1.0 0.0 0.0 0.0 1.0 0.0
+
+ // Touchscreen gesture binds live in the main binds {} block using
+ // parameterized triggers like TouchSwipe fingers=3 direction="up",
+ // TouchPinch fingers=4 direction="in", or TouchEdge edge="left".
+ // This subblock only contains tuning parameters.
+ gestures {
+ // swipe-trigger-distance 100.0 // px of centroid motion before swipe latches
+ // edge-start-distance 30.0 // px-wide edge start zone
+ // pinch-trigger-distance 100.0 // px of spread change before pinch latches
+ // pinch-dominance-ratio 1.0 // spread must beat swipe × this (higher = stricter pinch)
+ // pinch-sensitivity 1.0
+ // pinch-progress-distance 100.0 // px of spread = IPC progress ±1.0 (signed)
+ // swipe-multi-finger-scale 1.2 // scales swipe-trigger-distance for 4+ fingers (1.0 = off)
+ // swipe-progress-distance 200.0 // px of swipe = IPC progress 1.0
+ // rotation-trigger-angle 20.0 // ° before rotation can latch
+ // rotation-dominance-ratio 0.5 // arc must beat swipe × this (higher = stricter rotation)
+ // rotation-progress-angle 90.0 // ° that map to IPC progress ±1.0
+ }
}
// disable-power-key-handling
@@ -259,11 +286,89 @@ Settings specific to `touchpad` and `mouse`:
Since: 25.08 You can also override horizontal and vertical scroll factor separately like so: `scroll-factor horizontal=2.0 vertical=-1.0`
-Settings specific to `tablet` and `touch`:
+Settings specific to `tablet` and `touchscreen`:
- `calibration-matrix`: set to six floating point numbers to change the calibration matrix. See the [`LIBINPUT_CALIBRATION_MATRIX` documentation](https://wayland.freedesktop.org/libinput/doc/latest/device-configuration-via-udev.html) for examples.
- Since: 25.02 for `tablet`
- - Since: 25.11 for `touch`
+ - Since: 25.11 for `touchscreen`
+
+Settings specific to `touchscreen`:
+
+- `natural-scroll`: Since: next if set, inverts the scrolling direction for touchscreen swipe gestures.
+- `gestures {}`: Since: next tuning parameters for touchscreen gesture recognition.
+
+> [!NOTE]
+>
+> Touchscreen gesture **binds** are configured in the main `binds {}` block using parameterized triggers like `TouchSwipe fingers=3 direction="up"`, `TouchPinch fingers=4 direction="in"`, or `TouchEdge edge="left"`. The `touchscreen { gestures { } }` subblock below only contains tuning parameters that affect *how* gestures are recognized, not *which* ones fire. See the [Gestures](./Gestures.md) wiki page for the full list of touchscreen gesture triggers.
+
+The `touchscreen { gestures { } }` tuning parameters are:
+
+All knobs are grouped as: **trigger** (classifier commit gates), **dominance** (3-way race tuning), **progress** (IPC output scaling), and **misc**.
+
+**Swipe:**
+
+- `swipe-trigger-distance `: pixels of centroid motion before a swipe gesture commits. Lower values feel more responsive but risk triggering on incidental finger drift. Default: `100.0`.
+- `swipe-multi-finger-scale `: scaling applied to `swipe-trigger-distance` for gestures with more than 3 fingers. The formula is `base * (1 + (fingers − 3) * (scale − 1))`, so with a base of 100 and scale 1.2 a 4-finger swipe needs 120 px and a 5-finger swipe needs 140 px. Default `1.2` — gives a small pinch-priority bias at high finger counts so ambiguous 4/5-finger motions resolve as pinch rather than swipe. Set `1.0` to disable the bias entirely.
+- `swipe-progress-distance `: pixels of swipe distance that map to IPC `GestureProgress = 1.0`. IPC-output knob — doesn't affect classification. Tune this for tagged external-app gestures (sidebar drawers, scrubbers, etc.). Default: `200.0`.
+
+**Pinch:**
+
+- `pinch-trigger-distance `: pixels of `|spread_change|` before a pinch gesture commits. Default: `100.0`.
+- `pinch-dominance-ratio `: `|spread_change|` must exceed `swipe_distance × this` for pinch to win the race against swipe. Higher = stricter pinch. Default: `1.0`.
+- `pinch-sensitivity `: multiplier mapping finger spread change to continuous pinch animation delta (e.g. overview open/close progress). At `1.0`, one pixel of spread change contributes one pixel to the gesture accumulator. Applies to **all** pinch-bound continuous actions — the bind's own `sensitivity=` property is ignored for pinch because raw spread-delta pixels need different scaling from linear swipe distances. Default: `1.0`.
+- `pinch-progress-distance `: pixels of spread change that map to IPC `GestureProgress = ±1.0`. Signed: positive for pinch-out, negative for pinch-in. Default: `100.0`.
+
+**Rotation:**
+
+- `rotation-trigger-angle `: cumulative rotation in **degrees** before a rotation gesture commits. Default: `20.0`. Rotation detection is an early proof of concept — see the warning in the [Rotation Gestures](./Gestures.md#rotation-gestures) section.
+- `rotation-dominance-ratio `: rotation arc length (`|cumulative_rotation| × cluster_radius`) must exceed both `swipe_distance × this` and `|spread_change| × this` for rotation to win the race. Higher = stricter rotation. Default: `0.5` (deliberately lenient — rotation almost always includes incidental translation). Matches `pinch-dominance-ratio` semantics (higher = stricter for both).
+- `rotation-progress-angle `: degrees of cumulative rotation that map to IPC `GestureProgress = ±1.0`. Signed: positive = counter-clockwise, negative = clockwise. Default: `90.0`.
+
+**Edge:**
+
+- `edge-start-distance `: width in pixels of the screen-edge start zone. A touch must *begin* within this distance from an edge to count as a `TouchEdge` gesture; touches starting farther in are treated as regular swipes. Default: `30.0`.
+
+Example:
+
+```kdl
+input {
+ touchscreen {
+ gestures {
+ swipe-trigger-distance 26.0
+ edge-start-distance 30.0
+ pinch-sensitivity 1.0
+ swipe-progress-distance 200.0
+ pinch-progress-distance 100.0
+ rotation-trigger-angle 15.0
+ rotation-dominance-ratio 0.5
+ }
+ }
+}
+```
+
+### Touchpad Gesture Tuning
+
+Since: next
+
+The `touchpad { gestures { } }` subblock contains tuning parameters for touchpad gesture recognition. Like touchscreen, the actual gesture binds (`TouchpadSwipe fingers=N direction="..."`, `TouchpadPinch fingers=N direction="..."`) live in the main `binds {}` block.
+
+- `swipe-trigger-distance `: libinput delta units of centroid motion before a swipe gesture commits. These units are acceleration-adjusted and not directly comparable to touchscreen pixels. Default: `16.0`.
+- `swipe-progress-distance `: libinput delta units of swipe motion that map to IPC `GestureProgress = 1.0`. Because libinput acceleration curves are nonlinear, the same physical swipe can produce different delta magnitudes depending on speed — this value is **not** directly comparable to the touchscreen `swipe-progress-distance`. Default: `40.0`.
+- `pinch-trigger-scale `: `|scale - 1.0|` required before a `TouchpadPinch` bind fires. libinput normalizes pinch scale (1.0 = no change, 1.5 = 50% spread out, 0.5 = 50% spread in), so this is a unitless ratio and **not** directly comparable to the touchscreen `pinch-trigger-distance` (which is in pixels). Fires once per gesture when the threshold is crossed; direction is picked from the sign of the scale change. Default: `0.15`.
+
+Example:
+
+```kdl
+input {
+ touchpad {
+ gestures {
+ swipe-trigger-distance 16.0
+ swipe-progress-distance 40.0
+ pinch-trigger-scale 0.15
+ }
+ }
+}
+```
Tablets and touchscreens are absolute pointing devices that can be mapped to a specific output like so:
@@ -273,7 +378,7 @@ input {
map-to-output "eDP-1"
}
- touch {
+ touchscreen {
map-to-output "eDP-1"
}
}
diff --git a/docs/wiki/Configuration:-Window-Rules.md b/docs/wiki/Configuration:-Window-Rules.md
index f8fa215ec3..d40f1a5988 100644
--- a/docs/wiki/Configuration:-Window-Rules.md
+++ b/docs/wiki/Configuration:-Window-Rules.md
@@ -99,6 +99,7 @@ window-rule {
clip-to-geometry true
tiled-state true
baba-is-float true
+ touchscreen-gesture-passthrough true
background-effect {
xray true
@@ -1017,6 +1018,41 @@ For example, GTK 4 pop-ups with pointing arrows (`has-arrow=true` property) are
These pop-ups with custom shapes will need the app to implement the [ext-background-effect protocol](https://wayland.app/protocols/ext-background-effect-v1) to work properly.
+#### `touchscreen-gesture-passthrough`
+
+Forward touchscreen multi-finger gestures to matching windows instead of letting niri's gesture recognizer consume them.
+
+By default, niri claims 3+ finger touchscreen swipes and pinches for compositor actions like workspace switching and overview toggle.
+This rule opts specific windows out of that behavior so apps that implement their own touch gestures (browsers, drawing apps, mapping tools) receive the raw touch events.
+
+Escape hatches that still work on passthrough windows:
+
+- **Mod+touch gestures** still trigger compositor binds — holding the mod key always bypasses passthrough so you can invoke niri actions even on a passthrough window.
+- **Edge swipes** still belong to niri — a swipe that starts in a screen-edge zone runs the edge gesture even if the window under it has passthrough enabled.
+- **2-finger touches** are unaffected — they already forward to clients by default regardless of this rule.
+
+This rule is touchscreen-only. Touchpad gestures are not affected.
+
+To discover which app-id to match, run niri with `RUST_LOG=niri=debug` and watch for lines like `touch: captured 3-finger gesture over app-id="org.mozilla.firefox"` after performing a gesture on the target window.
+
+```kdl
+// Let Firefox handle touch gestures itself (page navigation, pinch-zoom).
+window-rule {
+ match app-id="firefox"
+ match app-id="org.mozilla.firefox"
+
+ touchscreen-gesture-passthrough true
+}
+
+// Same for a drawing app and Blender.
+window-rule {
+ match app-id="org.kde.krita"
+ match app-id="org.blender.Blender"
+
+ touchscreen-gesture-passthrough true
+}
+```
+
#### Size Overrides
You can amend the window's minimum and maximum size in logical pixels.
diff --git a/docs/wiki/Design:-Gesture-IPC-Refactor.md b/docs/wiki/Design:-Gesture-IPC-Refactor.md
new file mode 100644
index 0000000000..74f7941348
--- /dev/null
+++ b/docs/wiki/Design:-Gesture-IPC-Refactor.md
@@ -0,0 +1,836 @@
+# Gesture State via Environment Variables — Design Plan
+
+> [!NOTE]
+> This is an open design RFC tied to PR [niri-wm/niri#3771](https://github.com/niri-wm/niri/pull/3771).
+> Feedback, counterproposals, and use-case testing from the niri community are welcome.
+
+## Acknowledgments
+
+The core architectural ideas in this document — env-var spawn context, stdin-pipe progress streaming, the public IPC event stream, `noop = consume` semantics, per-window `binds {}` in `window-rules` with an `unbound` sentinel for fingers=1/2 disambiguation, and the critique of the tag system as a layer violation — originated from **Atan-D-RP4** in PR review discussion on [niri-wm/niri#3771](https://github.com/niri-wm/niri/pull/3771). This document consolidates those proposals into an implementation plan and extends them in a few places (the internal-vs-IPC progress mismatch analysis in Part 11, and the earlier three-gate disambiguation sketch in Part 12 now superseded by Atan's window-rule `binds {}` proposal).
+
+## Document Status and Reading Order
+
+This document evolved in layers as design discussion progressed. Read it in order, but be aware that later parts **supersede** earlier ones in places:
+
+- **Parts 1–9** — Initial spawn + env-var + stdin-pipe proposal (self-contained, covers the tag-replacement case)
+- **Part 10** — Second-pass refinements: adds a public IPC event stream as a complementary channel, and proposes `noop = consume` semantics — this **supersedes Part 3d's claim that `noop` loses its purpose**
+- **Part 11** — Cross-cutting concern: internal vs IPC progress mismatch (applies to all paths)
+- **Part 12** — Disambiguation flow for `fingers=1`/`fingers=2` — this **supersedes Part 10c's "keep fingers=3..=10"** position. Originally a three-gate heuristic (passthrough rule + bind existence + threshold timing); **now superseded by Atan's per-window `binds {}` in `window-rules` proposal**, which collapses the three gates into one declarative mechanism. Scoped as a follow-up PR; the current PR stays at `fingers=3..=10`.
+
+Net current thinking: three complementary user paths (spawn / IPC event stream / direct action), `noop` means "compositor claims this gesture," `unbound` in a window-rule `binds {}` block releases the claim per-app, and fingers=1/2 lands in a separate follow-up PR per Part 12.
+
+## The Problem
+
+The current tag system creates a **split-brain** between configuration and consumption:
+
+1. The user writes a bind with `tag "workspace-nav"` in their niri config
+2. A separate external app must independently know to connect to niri's IPC socket, subscribe to the event stream, and filter for events with tag `"workspace-nav"`
+3. The bind config and the consuming app are coupled by a string convention that lives outside either one
+
+This doesn't fit niri's design principle where **config declares intent and the compositor executes it**. Tags leak compositor-internal state into a global IPC namespace that external apps must subscribe to and parse.
+
+## Proposal: Spawn with Gesture State in Environment Variables
+
+When a gesture fires a `spawn` action, attach the gesture's state as environment variables to the spawned process. The script reads its own env, does its thing, and exits. No IPC socket, no tag matching, no event stream — fully self-contained.
+
+## Detailed Design
+
+### Part 1: Environment Variables (Static State at Spawn Time)
+
+When `spawn` or `spawn-sh` fires from a gesture bind, set these env vars on the child process:
+
+```sh
+NIRI_GESTURE_TYPE=TouchSwipe # TouchSwipe | TouchPinch | TouchRotate | TouchEdge | TouchpadSwipe
+NIRI_GESTURE_FINGERS=3 # finger count
+NIRI_GESTURE_DIRECTION=up # up|down|left|right (swipe), in|out (pinch), cw|ccw (rotate)
+NIRI_GESTURE_EDGE=left # (edge only) top|bottom|left|right
+NIRI_GESTURE_ZONE=start # (edge only) full|start|center|end
+NIRI_GESTURE_CONTINUOUS=true # whether progress will stream on stdin
+```
+
+> [!NOTE]
+> `sensitivity` and `natural_scroll` are **not** exposed as env vars. These are compositor-internal tuning — the compositor applies them when computing the `progress` value that streams on stdin. The spawned process receives already-adjusted progress and doesn't need to know or reapply these. This keeps the env vars focused on **what happened** (gesture identity) rather than **how it was configured** (tuning knobs).
+
+This is the **easy part**. All this state is already available in `extract_bind_info()` and the `Trigger` enum at the point where `do_action` is called. The spawn functions (`spawn`, `spawn_sh`, `spawn_sync`) just need a new parameter for optional gesture context, and `spawn_sync` adds `.env()` calls before spawning.
+
+**Config example (discrete gesture):**
+```text
+binds {
+ TouchSwipe fingers=3 direction="up" {
+ spawn "notify-send" "Swiped up with 3 fingers"
+ }
+}
+```
+
+The spawned `notify-send` sees `NIRI_GESTURE_TYPE=TouchSwipe`, `NIRI_GESTURE_FINGERS=3`, etc. in its env. For discrete gestures this is all you need — the script runs, reads env, done.
+
+### Part 2: stdin Pipe (Dynamic State for Continuous Gestures)
+
+This is the **hard part** and where the real architectural value is.
+
+For continuous gestures (workspace-switch, overview, column-nav animations), the spawned process needs **live progress updates** as fingers move. Environment variables are write-once-at-spawn; they can't carry streaming state.
+
+**Solution: pipe progress to the child's stdin.**
+
+Currently, `spawn_sync` sets `stdin(Stdio::null())`. For continuous gesture spawns, change this to `stdin(Stdio::piped())` and keep the write-end of the pipe alive in the gesture's active state.
+
+#### Data format on stdin
+
+One JSON object per line (newline-delimited JSON / NDJSON):
+
+```jsonl
+{"event":"progress","progress":0.15,"dx":0.0,"dy":-8.3,"timestamp_ms":48201}
+{"event":"progress","progress":0.42,"dx":0.0,"dy":-12.1,"timestamp_ms":48217}
+{"event":"progress","progress":0.73,"dx":0.0,"dy":-9.7,"timestamp_ms":48233}
+{"event":"end","completed":true}
+```
+
+- `progress`: normalized, non-monotonic (same semantics as current `GestureProgress.progress`)
+- `dx`/`dy` or `d_spread` or `d_angle`: raw physical delta, typed by gesture kind
+- `timestamp_ms`: frame timestamp
+- Final `{"event":"end","completed":true/false}` then stdin closes
+
+A bash script consuming this looks like:
+
+```bash
+#!/bin/bash
+# NIRI_GESTURE_TYPE, NIRI_GESTURE_FINGERS, etc. are in our env
+echo "Gesture started: $NIRI_GESTURE_TYPE with $NIRI_GESTURE_FINGERS fingers"
+
+while IFS= read -r line; do
+ progress=$(echo "$line" | jq -r '.progress // empty')
+ event=$(echo "$line" | jq -r '.event')
+
+ if [ "$event" = "end" ]; then
+ completed=$(echo "$line" | jq -r '.completed')
+ echo "Gesture ended, completed=$completed"
+ break
+ fi
+
+ # Drive your animation with $progress
+ echo "Progress: $progress"
+done
+```
+
+A Rust/Python/Go consumer reads stdin line-by-line and deserializes JSON.
+
+### Part 3: Architectural Changes Required
+
+#### 3a. Spawn infrastructure (`src/utils/spawning.rs`)
+
+Current signatures:
+```rust
+pub fn spawn(command: Vec, token: Option)
+pub fn spawn_sh(command: String, token: Option)
+fn spawn_sync(command, args, token)
+```
+
+The existing signatures gain an optional gesture context parameter. Internally, `spawn` checks whether it's in a gesture context and adjusts its behavior:
+
+```rust
+// Same function, now context-aware
+pub fn spawn(command: Vec, token: Option, gesture: Option)
+ -> Option // None for keyboard spawns; Some(pipe) for gesture spawns
+```
+
+When `gesture` is `Some`:
+- Sets `NIRI_GESTURE_*` env vars on the child
+- Uses `Stdio::piped()` for stdin and returns the write-end
+- The process always gets the pipe — whether it reads stdin is the process's choice
+
+When `gesture` is `None`: behaves exactly as today (no env vars, `Stdio::null()`), returns `None`.
+
+This is the "spawn action has different behavior when called with these binds" pattern — the function itself is context-aware, not a new function.
+
+**Key concern: the double-fork.** Currently:
+1. Main thread → spawner thread → `Command::spawn()` → intermediate child → grandchild (actual process)
+2. Intermediate child exits immediately, grandchild is orphaned to init/systemd
+
+With stdin piping, the pipe's write-end must stay alive in the **compositor process** (not the spawner thread), because the gesture handler runs on the main thread and needs to write to it on every motion frame.
+
+Implementation approach for continuous spawns:
+- Do the piped spawn synchronously on the main thread — fork+exec is fast (<1ms), and gesture commit only happens once per gesture, so blocking briefly is fine
+- The double-fork still happens for process isolation, but the pipe write-end stays in compositor space
+- This avoids the complexity of threading pipe fds back from a spawner thread via channels
+
+#### 3b. Action dispatch (`src/input/mod.rs` and `src/input/touch_gesture.rs`)
+
+Currently, `do_action` handles `Action::Spawn` generically with no context about what triggered it. The gesture code should intercept spawn actions before they reach `do_action`:
+
+```rust
+// In touch_gesture.rs / mod.rs, at the point where a gesture bind fires:
+if matches!(action, Action::Spawn(_) | Action::SpawnSh(_)) {
+ let ctx = GestureSpawnContext::from_trigger(trigger, continuous);
+ let pipe = match action {
+ Action::Spawn(cmd) => spawn(cmd, Some(token), Some(ctx)),
+ Action::SpawnSh(cmd) => spawn_sh(cmd, Some(token), Some(ctx)),
+ _ => unreachable!(),
+ };
+ // If continuous, store pipe in ActiveTouchBind/ActiveSwipeBind
+} else {
+ self.do_action(action, false);
+}
+```
+
+This keeps `do_action` untouched — it doesn't need to know about gestures. The gesture code is the one that knows it's in a gesture context, so it handles spawn specially. All other actions (workspace-switch, focus-column, etc.) flow through `do_action` as before.
+
+#### 3c. Active gesture state (`src/niri.rs`)
+
+For continuous gesture spawns, the pipe write-end needs to live in the active gesture state so progress updates can write to it. With tags removed, these structs simplify — `tag` and `ipc_progress` are gone, replaced by `spawn_pipe`:
+
+```rust
+pub struct ActiveSwipeBind {
+ pub kind: ContinuousGestureKind,
+ pub sensitivity: f64,
+ pub spawn_pipe: Option, // write-end for spawned process stdin
+}
+
+pub enum ActiveTouchBind {
+ Swipe {
+ kind: ContinuousGestureKind,
+ sensitivity: f64,
+ natural_scroll: bool,
+ spawn_pipe: Option,
+ },
+ Pinch {
+ kind: ContinuousGestureKind,
+ spawn_pipe: Option,
+ start_spread: f64,
+ last_spread: f64,
+ },
+ Rotate {
+ kind: ContinuousGestureKind,
+ spawn_pipe: Option,
+ start_rotation: f64,
+ },
+}
+```
+
+On each gesture progress frame, if `spawn_pipe` is `Some`, write a JSON progress line to it. On gesture end, write the end event and drop the pipe (closes stdin).
+
+**EPIPE handling:** If the child process exits early, writing to the pipe will return EPIPE. This must be handled gracefully — just set `spawn_pipe = None` and continue (the gesture still drives compositor animations even if the external process died).
+
+#### 3d. Tags are removed entirely
+
+Since this is a private prototype, we don't need backwards compatibility. Tags are **replaced**, not supplemented.
+
+**What gets removed:**
+- `tag: Option` from `Bind`, `TouchBindEntry`, `ActiveTouchBind`, `ActiveSwipeBind`
+- `GestureBegin`, `GestureProgress`, `GestureEnd` IPC events (the tag-bearing ones)
+- Tag field in the settings UI
+- All `ipc_gesture_begin/progress/end` emission logic in `touch_gesture.rs` and `mod.rs`
+
+**What replaces each use case:**
+
+| Old (tags) | New |
+|-----------|-----|
+| Script reacts to a specific gesture | `spawn` + env vars |
+| Animation driven by gesture progress | `spawn` + stdin pipe |
+| Debug inspector sees all gestures | `RecognitionFrame` events (already exist, debug-only) — or we add a new lightweight `GestureEvent` on the IPC stream that carries the same env-var-level info but without requiring a tag in config |
+| Long-running daemon monitors gestures | Same IPC stream, but tag-free: events identify gestures by type/fingers/direction, not user-assigned strings |
+
+**The `noop` action loses its gesture-specific purpose** *(superseded — see Part 10b).* Originally this proposal drops `noop`'s special meaning along with tags, but second-pass refinements reintroduce `noop = consume` as the "compositor claims this gesture for IPC consumption" signal. The current position is: `noop` gains new meaning as the consume marker, not loses it. See Part 10b for details.
+
+**What about `niri-gesture-inspector`?** It currently uses `GestureBegin`/`GestureEnd` events. Two options:
+1. Keep a simplified, tag-free `GestureEvent` on the IPC stream (just type/fingers/direction/completed, no user tag)
+2. The inspector already uses `RecognitionFrame` events — extend those slightly to cover the commit/end phase too
+
+Option 1 is cleaner: a single `GestureEvent` that fires on every gesture commit, carrying the trigger description and completion status. No tag field, no user config needed — it's purely observational.
+
+### Part 4: Implementation Phases
+
+#### Phase 0: Rip out tags
+
+**Scope:** Remove the entire tag system — config field, IPC events, emission logic, UI.
+
+**Changes:**
+1. Remove `tag: Option` from `Bind` in `niri-config/src/binds.rs`
+2. Remove `tag` from `ActiveTouchBind` variants and `ActiveSwipeBind` in `niri.rs`
+3. Remove `GestureBegin`, `GestureProgress`, `GestureEnd` event variants from `niri-ipc/src/lib.rs`
+4. Remove all `ipc_gesture_begin/progress/end` calls in `touch_gesture.rs` and `mod.rs`
+5. Remove `extract_bind_info`'s tag extraction, simplify the tuple it returns
+6. Remove `noop` action support from gesture binds (or keep `noop` as a general action but remove its special tag-emitting behavior)
+7. Remove tag field from `TouchBindEntry` in the settings UI `config.rs`
+8. Remove tag rows from touchscreen.rs and touchpad.rs add/edit forms
+9. Add a simple, tag-free `GestureEvent` to the IPC stream for debug tools:
+ ```rust
+ GestureCommit {
+ trigger: String, // "TouchSwipe fingers=3 direction=\"up\""
+ finger_count: u8,
+ is_continuous: bool,
+ }
+ GestureFinish {
+ trigger: String,
+ completed: bool,
+ }
+ ```
+ These fire for ALL gesture commits unconditionally — no config needed. Debug tools (gesture-inspector) observe the stream without any bind config.
+
+**Complexity:** Medium (lots of deletion, but deletion is safe). The new `GestureCommit`/`GestureFinish` events are simpler than the old tagged trio because they have no user-defined fields.
+
+#### Phase 1: Environment variables + stdin pipe
+
+**Scope:** Make `spawn` context-aware when fired from gesture binds — env vars for identity, stdin pipe for progress.
+
+These ship together because the pipe is what makes this a real replacement for tags. Env vars without the pipe only covers discrete gestures; with the pipe, it covers everything.
+
+**Changes:**
+1. Define `GestureSpawnContext` struct in `spawning.rs`:
+ ```rust
+ pub struct GestureSpawnContext {
+ pub gesture_type: String, // "TouchSwipe", "TouchPinch", etc.
+ pub fingers: u8,
+ pub direction: Option, // "up", "in", "cw", etc.
+ pub edge: Option, // "left", "top", etc. (edge gestures only)
+ pub zone: Option, // "full", "start", etc. (edge gestures only)
+ }
+ ```
+ Note: no `continuous` flag — the compositor determines this from the gesture type. All gesture spawns get the pipe; `NIRI_GESTURE_CONTINUOUS` env var tells the process whether to expect progress data on stdin.
+2. Modify `spawn`/`spawn_sh`/`spawn_sync` to accept `Option` — when present, set `NIRI_GESTURE_*` env vars and use `Stdio::piped()` for stdin
+3. Return `Option` (pipe write-end) from spawn — `Some` for gesture spawns, `None` for keyboard spawns
+4. Add `spawn_pipe: Option` to `ActiveTouchBind` variants and `ActiveSwipeBind`
+5. In `touch_gesture.rs` and `mod.rs`, intercept `Action::Spawn`/`Action::SpawnSh` before `do_action` — build context from trigger, call gesture-aware spawn, store pipe in active state
+6. On gesture progress, write NDJSON line to the pipe (`O_NONBLOCK`, skip frame on `EAGAIN`)
+7. On gesture end, write `{"event":"end","completed":true/false}` and drop the pipe
+8. Handle EPIPE: set `spawn_pipe = None`, continue gesture normally
+9. **Refactor spawn for piped mode:** do the piped spawn synchronously on the main thread (fork+exec is fast, <1ms). Double-fork still happens for process isolation, but pipe write-end stays in compositor space
+
+**Complexity:** Medium-high. The spawn architecture change for piped mode is the hardest part. Non-blocking pipe writes at 120 Hz need care.
+
+**Value:** Full replacement for tags. Discrete scripts read env vars and ignore stdin. Continuous scripts read env vars and stdin. Same `spawn` action, compositor handles the rest.
+
+#### Phase 2: Reserved
+
+Originally skipped in the first-pass plan. **Filled in by Part 10d** as the `noop = consume` phase (replacing the `touchscreen-gesture-passthrough` window rule with bind-existence consumption semantics).
+
+#### Phase 3: Settings UI updates
+
+**Scope:** Update niri-touch-settings-UI to reflect the tag-free model.
+
+**Changes:**
+1. Remove all tag-related UI (already done in Phase 0)
+2. Remove `noop` from the action list for gesture binds (or keep it for "gesture exists but does nothing visible")
+3. For `spawn` actions, add a help label: "Spawned processes receive gesture state via NIRI_GESTURE_* environment variables"
+4. Consider adding a "Test" button that spawns a built-in script showing the env vars (nice-to-have)
+
+**Complexity:** Low.
+
+### Part 5: Non-Trivial Concerns
+
+#### 5a. Spawn latency on the main thread
+
+Currently spawn runs in a separate thread to avoid blocking the compositor. For piped spawns, we need the pipe fd on the main thread. Options:
+- Fork+exec is fast (~1ms) — doing it on the main thread for gesture spawns is probably fine, especially since gesture commit only happens once per gesture
+- Or: spawn in thread, send pipe fd back via a one-shot channel
+
+#### 5b. Pipe write blocking at 120 Hz
+
+If the child doesn't read fast enough, the pipe buffer fills and `write()` blocks. Solutions:
+- Use `O_NONBLOCK` on the write-end; if write returns `EAGAIN`, skip that frame (child will see the next one)
+- Pipe buffer is typically 64KB on Linux — at ~100 bytes per JSON line, that's ~640 frames of buffer, more than enough
+
+#### 5c. Child process lifecycle
+
+The child is spawned at gesture begin. What if:
+- **Child exits early:** EPIPE on write → set `spawn_pipe = None`, continue gesture normally
+- **Gesture ends before child is done:** Write end event, drop pipe (stdin closes), child sees EOF and should exit. If it doesn't, it's the child's problem (orphaned to init, same as any spawn)
+- **Multiple rapid gestures:** Each spawns a new process. Previous one gets EOF'd when the gesture ends. This is fine — same as spawning any command rapidly
+
+#### 5d. Security / information leak surface
+
+Current spawn already inherits the compositor's environment (minus RUST_BACKTRACE, plus DISPLAY and user-configured env). Adding gesture state doesn't meaningfully expand the attack surface — the spawned process already runs with the user's privileges. The gesture info (fingers, direction) isn't sensitive.
+
+The stdin pipe is scoped to the child process's fd table — no other process can read it (unlike the IPC socket which any process can connect to). This is actually **better** isolation than tags.
+
+### Part 6: Config Example — Before and After
+
+**Before (tags + external daemon):**
+```text
+// niri config — user must invent a tag name
+binds {
+ TouchSwipe fingers=3 direction="up" tag="ws-up" { noop; }
+}
+
+// Separate daemon that must:
+// 1. Be running before the gesture fires
+// 2. Connect to niri's IPC socket
+// 3. Subscribe to the event stream
+// 4. Know the exact tag string "ws-up"
+// 5. Filter events, handle begin/progress/end lifecycle
+
+// daemon.py:
+// socket = connect_niri_ipc()
+// for event in socket.event_stream():
+// if event.type == "GestureBegin" and event.tag == "ws-up":
+// handle_begin(event)
+// elif event.type == "GestureProgress" and event.tag == "ws-up":
+// drive_animation(event.progress)
+// elif event.type == "GestureEnd" and event.tag == "ws-up":
+// finish(event.completed)
+```
+
+**After (spawn + env vars + stdin):**
+```text
+// niri config — no tag, no daemon coordination
+binds {
+ TouchSwipe fingers=3 direction="up" {
+ spawn-sh "my-gesture-handler.sh"
+ }
+}
+```
+
+```bash
+#!/bin/bash
+# my-gesture-handler.sh — fully self-contained
+# Everything we need is in our environment:
+echo "Got $NIRI_GESTURE_TYPE $NIRI_GESTURE_DIRECTION with $NIRI_GESTURE_FINGERS fingers"
+
+# For continuous gestures, progress streams on stdin:
+if [ "$NIRI_GESTURE_CONTINUOUS" = "true" ]; then
+ while IFS= read -r line; do
+ progress=$(echo "$line" | jq -r '.progress // empty')
+ event=$(echo "$line" | jq -r '.event')
+ [ "$event" = "end" ] && break
+ # Drive animation with $progress
+ done
+fi
+```
+
+No tag, no daemon, no IPC socket, no event stream, no string coordination between config and consumer. The process is born knowing everything about its gesture.
+
+### Part 7: What This Means for the Architecture
+
+**Tags were a layer-violation.** They made the compositor's IPC stream carry user-defined semantics (arbitrary tag strings) that only had meaning to external processes. The compositor itself never used the tag — it just forwarded it. This is the separation concern that motivated the rethink.
+
+**Env vars + stdin is compositor-native.** The compositor already spawns processes with enriched environments (`XDG_ACTIVATION_TOKEN`, `DISPLAY`, user-configured env). Adding gesture state to the spawn environment is the same pattern — the compositor prepares the child's world, the child runs in it.
+
+**The stdin pipe replaces the IPC event stream for the per-gesture case.** Instead of a global pub-sub channel (IPC event stream) where consumers filter by tag, each gesture gets a dedicated, private, typed channel (stdin pipe) that lives exactly as long as the gesture. This is:
+- **Better isolated** — no cross-gesture interference, no global namespace
+- **Simpler lifecycle** — pipe opens at gesture begin, closes at gesture end, no subscribe/unsubscribe
+- **Self-cleaning** — when the gesture ends, the pipe closes, the process gets EOF, done
+
+**The IPC event stream survives** but becomes simpler: tag-free `GestureCommit`/`GestureFinish` events for observability tools, not for driving application logic. This is the right separation — the IPC stream is for *watching*, spawn is for *doing*.
+
+### Part 8: How `spawn` Becomes Context-Aware (Discrete vs Continuous)
+
+There's a key design question: **how does the compositor know whether a gesture spawn is discrete or continuous?**
+
+Currently, continuous vs discrete is inferred from the action type — `workspace-switch-gesture` is continuous because the compositor knows it drives an animation. `spawn` is always discrete (fire and forget). But with env-var spawn replacing tags, we need `spawn` to handle both modes.
+
+The guiding principle: the **spawn action** has different behavior **when called from a gesture bind** — `spawn` itself becomes context-aware, not a new action type.
+
+#### The approach: `spawn` always pipes on gesture binds
+
+When `spawn` fires from a gesture bind, the compositor always sets up the stdin pipe for gestures that support continuous tracking (swipe, pinch, rotate, edge). The process gets `NIRI_GESTURE_CONTINUOUS=true` in its env and progress streams on stdin.
+
+If the process doesn't care about continuous progress, it simply doesn't read stdin. The kernel pipe buffer (64KB on Linux, ~640 frames of JSON) absorbs the writes silently. When the gesture ends and the pipe closes, any unread data is discarded. No harm done.
+
+```text
+// Discrete use — script ignores stdin, reads env, does its thing
+TouchSwipe fingers=3 direction="up" {
+ spawn "notify-send" "Swiped up!"
+}
+
+// Continuous use — same spawn, script reads stdin for progress
+TouchSwipe fingers=4 direction="up" {
+ spawn-sh "my-animation-driver.sh"
+}
+```
+
+Both are just `spawn`. The compositor doesn't need to know the script's intent. The pipe is always there; the script decides whether to use it.
+
+- From a keyboard/switch bind: `spawn` fires as today — no env vars, no pipe, `Stdio::null()`
+- From a gesture bind: `spawn` sets `NIRI_GESTURE_*` env vars and pipes progress on stdin
+
+This is the most "niri" answer — the compositor figures out the right thing from context, the user just writes `spawn`. No new action types, no new config properties, no user-facing complexity.
+
+#### Alternatives considered and rejected
+
+- **`spawn-continuous` as a separate action:** Explicit, but doubles the action surface (spawn/spawn-sh/spawn-continuous/spawn-sh-continuous) for no real gain — the compositor already knows the context.
+- **`spawn-gesture` as a new action:** Clean separation, but means `spawn` on a gesture bind would be "dumb" (no env vars), wasting the opportunity to enrich the existing action. Splits the action surface unnecessarily.
+- **`continuous=true` bind property:** Unnecessary indirection — if the script doesn't want progress, it just doesn't read stdin.
+
+### Part 9: Open Questions
+
+1. **Progress format on stdin:** NDJSON (`{"progress":0.42,"dx":0.0,"dy":-12.1,"timestamp_ms":48217}`) is flexible and self-describing but requires a JSON parser. Alternative: tab-separated values (`0.42\t0.0\t-12.1\t48217`) — trivial to parse in bash with `read`, but harder to extend. JSON is probably the right default since niri's IPC already speaks JSON, and most languages parse it trivially. Feedback welcome on whether a simpler line format would better serve shell-script use cases.
+
+2. **Does spawn fully replace the external-daemon pattern?** With tags, a long-running daemon could monitor *all* gestures from one process. With spawn, each gesture gets its own short-lived process. For most users this is simpler, but a power-user who wants a single daemon reacting to multiple gesture types would need either: (a) the tag-free `GestureCommit`/`GestureFinish` IPC events proposed in Phase 0, or (b) multiple spawn binds that all call the same script (which reads its env to know which gesture fired). Option (b) is probably fine — the "daemon" pattern was always over-engineered for most use cases, but real use cases that break under (b) would be worth hearing about.
+
+3. **Does this fully address the layering concern?** The separation concern (config ↔ external app coupled by string convention) is eliminated: the process gets its context from the compositor directly via env vars, no string convention needed. But the process itself is still external — is `spawn` + env vars enough "niri-native", or would something more integrated (e.g., compositor-internal scripting for animation logic) be preferable? Opinions welcome.
+
+---
+
+## Part 10: Second-Pass Refinements (2026-04)
+
+After the initial proposal (Parts 1–9), a second round of design discussion raised additional ideas. This section captures those refinements and how they interact with the original plan.
+
+### 10a. Prefer IPC event stream over spawn-pipe for observers
+
+A complementary channel was proposed:
+
+> Extending the IPC with something like `niri msg watch-gestures` or `niri msg event-stream --filter gestures`. Emit events with associated data (which can be limited or expanded via config with some sane defaults) for all committed gestures and clients can subscribe to those events to do things.
+
+**How this relates to the existing spawn+pipe proposal:**
+
+- The spawn+pipe approach (Phase 1 above) is still valuable for **self-contained per-gesture scripts** — no IPC subscription required, the process is born knowing its context.
+- But for **long-running observers** (quickshell panels, sidebar drawers, HUDs), a public IPC event stream is the right channel — a daemon subscribes once and reacts to all gestures.
+- These are **complementary**, not competing. Both should exist.
+
+**Refinement to Phase 0:** The "tag-free `GestureCommit`/`GestureFinish`" event from Phase 0 gets upgraded from a minor observability aside to a **first-class public API**:
+
+```console
+$ niri msg event-stream | grep -E "GestureBegin|GestureProgress|GestureEnd"
+GestureBegin trigger="TouchEdge edge=\"left\"" fingers=1 continuous=true
+GestureProgress trigger="TouchEdge edge=\"left\"" progress=0.23 dx=0.0 dy=-12.1 timestamp_ms=48217
+GestureProgress trigger="TouchEdge edge=\"left\"" progress=0.47 dx=0.0 dy=-18.4 timestamp_ms=48233
+GestureEnd trigger="TouchEdge edge=\"left\"" completed=true
+```
+
+Trigger field is the **same string the user writes in config** — no invented tags, direct pattern-match. A sidebar daemon filters by `trigger="TouchEdge edge=\"left\""`, not by a user-assigned tag.
+
+Fields available on the stream (optionally filterable via config):
+- `trigger` — e.g. `"TouchSwipe fingers=3 direction=\"up\""`
+- `fingers` — finger count
+- `continuous` — whether progress will stream
+- `progress`, `delta`, `timestamp_ms` — on `GestureProgress` events
+- `completed` — on `GestureEnd` events
+
+### 10b. `noop = consume` semantics (replaces `touchscreen-gesture-passthrough`)
+
+A proposed consumption model based on whether a gesture is bound:
+
+> If the IPC is used to let some app handle a gesture, bind it to `noop` in `binds {}` with a comment, and niri knows not to forward it to clients. Without a noop bind, it gets forwarded. This removes the need for the `touchscreen-gesture-passthrough` window-rule as a simplification.
+
+**The claim/forward decision:**
+
+| Bind state | Compositor action | Client receives event |
+|------------|-------------------|----------------------|
+| No bind for this gesture | Nothing | Yes (forwarded) |
+| Bound to concrete action | Executes action, emits IPC | No (consumed) |
+| Bound to `noop` | Does nothing, emits IPC | No (consumed by IPC daemon) |
+
+This replaces the current `touchscreen-gesture-passthrough` window rule semantics for the **gesture family level**. The window rule becomes unnecessary for the "block gesture passthrough for this trigger" use case — just bind it to `noop` or an action.
+
+**Caveat:** The current `touchscreen-gesture-passthrough` is a **window rule** — scoped per-window. The proposed model is **global per-trigger**. These aren't fully equivalent:
+
+- Today: window rule lets a browser handle its own 2-finger gestures while niri still handles them over other windows
+- Proposed model: binding is global — you can't say "don't intercept 3-finger swipes over this specific window"
+
+Whether that loss of per-window scoping matters in practice is a separate design question. For the 3+ finger compositor-gesture use case it probably doesn't (users want the same gestures everywhere). For the 2-finger scroll/zoom passthrough it still matters — but that's handled by finger-range restriction, not by `noop`.
+
+**Conclusion on the window rule:** Originally considered for removal once `noop = consume` is in place. **However, Part 12 re-introduces it as Gate 1 of the fingers=1/2 disambiguation model** — the per-app escape hatch for apps like Firefox/PDF viewers that need native 1/2-finger touch. So the window rule stays if fingers=1/2 support ships; it can only be removed if the finger range stays at 3+.
+
+### 10c. Open question: fingers=1/2 with noop-consume
+
+A related suggestion:
+
+> Expand the valid finger range down to 1 finger. Especially useful for edge gestures.
+
+This interacts with `noop = consume` in a concerning way:
+
+- Binding `TouchSwipe fingers=1 direction="up" { noop; }` would **globally claim** all 1-finger up-swipes
+- Every app loses its primary scroll interaction
+- Email lists, photo viewers, web pages — all broken
+
+The `noop = consume` model works cleanly for 3+ finger gestures because they don't overlap with primary client input. Extending it to 1-2 fingers requires a spatial/temporal disambiguation mechanism — exactly what `TouchEdge` already provides via `edge-start-distance`.
+
+**Suggested position at the time of 10c (superseded — see Part 12):** Keep `fingers=3..=10` as the range for the 5 non-edge families. `TouchEdge` remains the 1-finger option (spatially restricted to avoid client conflict). If someone wants middle-of-screen 1-finger gestures, they need to propose a spatial/temporal disambiguation mechanism — not just expand the range.
+
+**Current position (Part 12):** That disambiguation mechanism exists as the three-gate model (window rule → bind-existence → threshold timing), making fingers=1/2 viable as an opt-in-per-pattern feature with zero cost for users who don't write such binds. See Part 12 for the full flow.
+
+### 10d. Refined implementation sketch
+
+Combining the original plan with the second-pass refinements:
+
+**Phase 0 — Rip out tags, add public gesture event stream**
+- Remove `tag: Option` everywhere
+- Add `GestureBegin`/`GestureProgress`/`GestureEnd` events to the public IPC stream, emitted for **all** committed gestures (no opt-in tag needed)
+- Events carry `trigger` (config-matching string), `fingers`, `continuous`, `progress`, `delta`, `completed`
+- This replaces the current tag-gated events with a universal stream
+
+**Phase 1 — spawn + env vars + stdin pipe**
+- Unchanged from the original proposal
+- For per-gesture self-contained scripts that don't need IPC
+
+**Phase 2 — `noop = consume` semantics (new)**
+- Replace the current `touch_gesture_passthrough` check with: bound gesture (including `noop`) → don't forward to client
+- Deprecate/remove `touchscreen-gesture-passthrough` window rule after verifying no real use case depends on per-window scoping
+- Document clearly: `noop` bind = "niri claims this gesture for IPC consumption"
+
+**Phase 3 — Settings UI updates** (unchanged)
+
+### 10e. What this means for users
+
+**Before (tags):**
+```text
+TouchSwipe fingers=3 direction="up" tag="ws-up" { noop; }
+```
+Plus a separate daemon that subscribes to IPC, filters by tag="ws-up", drives animation.
+
+**After (event stream + noop):**
+```text
+TouchSwipe fingers=3 direction="up" { noop; } // claims this gesture for IPC
+```
+Daemon subscribes to IPC, filters by trigger pattern-match. No invented tag names.
+
+**Or even simpler (spawn + env):**
+```text
+TouchSwipe fingers=3 direction="up" {
+ spawn-sh "my-handler.sh"
+}
+```
+Script reads `NIRI_GESTURE_*` env vars, reads stdin for progress.
+
+Three clean paths — user picks whichever fits their use case:
+1. **I want a self-contained script** → `spawn`
+2. **I want a long-running daemon watching multiple gestures** → `noop` + IPC event stream
+3. **I just want niri to do the thing** → bind to an action directly
+
+---
+
+## Part 11: Cross-cutting Concern — Internal vs IPC Progress Mismatch
+
+This concern applies to **all three paths** above (spawn, event stream, noop-consume) because it's about the fundamental design of how niri's internal gesture math relates to what external consumers see. Previously documented separately in `TAG_GESTURE_PROGRESS_MISMATCH.md` — consolidated here since it's a subproblem of the tag-replacement architecture.
+
+### The two threshold systems
+
+Niri gesture handling has two independent progress/threshold systems that are not synchronized.
+
+**1. Internal compositor animations:**
+Niri's layout code decides when to commit actions (workspace switch, column scroll, overview toggle) based on its own internal gesture math:
+
+- `workspace_switch_gesture_end()` — uses internal distance + velocity to decide whether to switch or snap back
+- `view_offset_gesture_end()` — same for column scrolling
+- `overview_gesture_update()` / `overview_gesture_end()` — own threshold for toggle commit
+
+These thresholds are **not configurable** and **not exposed** via IPC. External tools cannot know when niri will commit an action.
+
+**2. IPC progress events:**
+External tools receive `GestureProgress` events with an accumulated `progress` value:
+
+```text
+progress = accumulated_delta * sensitivity / gesture-progress-distance
+```
+
+Where:
+- `sensitivity` — per-bind config (touchscreen default: 0.4, touchpad default: 1.0)
+- `gesture-progress-distance` — configurable per-input-type (touchscreen: 200 px, touchpad: 40 libinput units)
+
+### The mismatch
+
+These two systems operate independently:
+
+- A touchscreen swipe might reach `progress = 0.8` in IPC, but niri's internal threshold commits the workspace switch at a completely different point
+- Conversely, `progress` could hit `1.0` before niri commits, or niri could commit when `progress` is only `0.3`
+- The IPC `GestureEnd { completed }` field distinguishes normal end (`true` when all fingers lift without interruption) from cancellation (`false` when a new finger arrives mid-gesture or cleanup fires on interruption). It does **not** indicate whether niri's internal threshold caused the compositor to actually commit the bound action — a touch workspace swipe that ends with all fingers lifted emits `completed: true` regardless of whether the compositor snapped forward to the new workspace or snapped back to the original.
+
+### Where this matters across the three paths
+
+- **`spawn` path:** The script's stdin stream has the same mismatch — progress values don't tell the script whether niri committed
+- **`noop` + IPC event stream path:** Same mismatch — daemons watching the event stream can't predict niri's commits
+- **`noop` with no compositor animation:** No mismatch — progress IS the sole output (the clean case)
+
+**Conclusion:** The mismatch is inherent to "gesture drives both a compositor animation AND external consumers." It's not fixed by changing the IPC channel.
+
+### Touchscreen vs touchpad scale difference
+
+The delta units are fundamentally different between input types:
+
+| Input | Delta Units | Default `gesture-progress-distance` | Default `sensitivity` |
+|-------|------------|--------------------------------------|----------------------|
+| Touchscreen | Screen pixels (large numbers, e.g., 500px per swipe) | 200 | 0.4 |
+| Touchpad | Libinput acceleration-adjusted units (small numbers, e.g., 30 per swipe) | 40 | 1.0 |
+
+Both aim for roughly equivalent physical gesture sizes, but the underlying units are incomparable. A third-party app receiving progress events from both input types gets consistent 0-1 progress values, but the raw `delta_x`/`delta_y` values will differ dramatically in scale.
+
+### Touchscreen tracks closer to internal state than touchpad
+
+In practice, touchscreen IPC progress aligns more closely with niri's internal animation state than touchpad does. This is because:
+
+- **Touchscreen** deltas are in **screen pixels** — the same unit niri's layout code uses to track scroll offset and animation position. So accumulated `progress = pixels * sensitivity / distance` naturally correlates with niri's internal `scroll_offset / output_height`.
+- **Touchpad** deltas pass through **libinput's acceleration curves** first, making the relationship between physical finger movement and layout displacement nonlinear. The same physical swipe distance can produce different delta magnitudes depending on speed, making it harder to tune IPC progress to match niri's commit point.
+
+This means an external app showing visual feedback alongside a compositor-animated gesture (e.g., a progress bar for workspace switching) will feel more in sync on touchscreen than touchpad. The mismatch on touchpad is more noticeable — niri may snap back while the external progress indicator shows 80%.
+
+### Potential fixes (independent of the IPC channel choice)
+
+- **Expose whether niri actually committed the action** in `GestureEnd` — add a `triggered` or `action_committed` field. Probably the simplest fix with highest value.
+- **Expose niri's internal gesture completion percentage** alongside IPC progress, so consumers can drive their UI from the compositor's view of commit instead of raw finger motion.
+- **Unify the two systems** so IPC progress matches the compositor's internal state. Biggest change, probably not worth it — the raw progress value is useful for apps that want to drive their own independent animations.
+
+These fixes should be tackled as part of Phase 0 (tag removal + public event stream) since they affect the event API shape.
+
+---
+
+## Part 12: Disambiguation Flow for fingers=1 / fingers=2
+
+The PR currently restricts touch gesture families (Swipe/Pinch/Rotate/Tap/TapHoldDrag) to `fingers=3..=10`. TouchEdge is hardcoded 1-finger and spatially scoped to the edge pixel range, so it doesn't conflict with general 1/2-finger app input. Opening up fingers=1 and fingers=2 on the other five families raises a disambiguation question: how do we distinguish "compositor wants this gesture" from "client wants this touch"?
+
+### Scope: deferred to a follow-up PR
+
+Per Atan's suggestion (2026-04-16), the current PR (niri-wm/niri#3771) stays scoped to `fingers=3..=10`, which is already larger than the Blur and Zoom PRs combined. fingers=1/2 support lands as a separate, focused follow-up PR built on the `window-rules` mechanism described below.
+
+### The conflict space
+
+- **fingers=1** — every tap, scroll, drag, and text selection in every app is a 1-finger touch
+- **fingers=2** — every pinch-zoom, every two-finger scroll in browsers/PDF viewers/image viewers
+
+At fingers=3+ the contract is easy because virtually no native Wayland client uses 3+ finger gestures. At 1/2 we have to arbitrate.
+
+### Current direction: per-window `binds {}` in window-rules (Atan's proposal)
+
+Rather than heuristically arbitrating at runtime (the earlier three-gate model), expose a `binds {}` block inside `window-rule {}` so apps can declaratively release gestures the compositor claimed globally. This collapses "does this app want the gesture?" from three gates into one config lookup.
+
+```text
+binds {
+ // Compositor claims 1-finger swipe up globally for IPC / bound action
+ TouchSwipe fingers=1 direction="up" { noop; }
+}
+
+window-rule {
+ match app-id="firefox"
+ binds {
+ // Release the claim for firefox — gesture forwards to client so
+ // native scroll keeps working. `unbound` is a sentinel because an
+ // empty action block is invalid KDL.
+ TouchSwipe fingers=1 direction="up" { unbound; }
+ }
+}
+```
+
+**Semantics:**
+
+- Global `binds {}` — compositor's default claim on a gesture pattern. `noop` = claim with no action (IPC/event consumer), a real action = claim + execute.
+- Window-rule `binds {}` — per-app override. `unbound` releases the claim and forwards touch events to the client (when that window is focused / under the touch centroid).
+- **Precedence:** window-rule `unbound` > window-rule action > global `noop` > global action > no bind (default passthrough).
+
+**Why this is cleaner than the old three-gate model:**
+
+- **Gate 1 (passthrough rule) + Gate 2 (bind existence) collapse into one** — the `binds {}` block in the window-rule *is* the passthrough decision, and `unbound` is its explicit keyword.
+- **Gate 3 (threshold timing) becomes simpler, not disappeared** — if the matched rule says `unbound`, the compositor can skip the grab entirely for that window (no event buffering, no latency). If the rule claims the gesture, buffering kicks in normally.
+- **Declarative, not heuristic** — intent lives in config, not in timing windows.
+- **Reuses existing infrastructure** — niri already matches window rules on `app-id` / `title` / etc.
+
+### Properties that fall out for free
+
+- **Sentinel keyword is `unbound`.** Atan used `unbound` in the original proposal — going with that. (KDL can't have empty action blocks, so a sentinel is required.)
+- **Partial direction overrides work naturally.** Each `fingers=N direction=D` combination is a separate bind entry, so a window rule can release `direction="up"` for native scroll while leaving `direction="left"` claimed by the global bind. No extra syntax needed.
+
+### Decided behavior — touch resolves like the mouse cursor (spatial, not focus-following)
+
+Window-rule matching for gestures works **exactly like mouse-cursor semantics**: the rule matches against the window the fingers are physically on, not the keyboard-focused window.
+
+**The mental model:** you can hover the mouse cursor over an unfocused firefox window and scroll-wheel — firefox scrolls without stealing focus from your terminal. Touch should behave identically: if you're typing in a terminal and you touch firefox to scroll it, firefox's window-rule applies (so its native scroll passthrough kicks in), even though the terminal still has keyboard focus. Your touch acts where your finger is, not where your keyboard cursor is blinking.
+
+**Concretely:**
+- The window-rule lookup uses the window under the touch centroid at touch-down (with first-finger-position as the multi-finger tiebreaker).
+- Keyboard focus is irrelevant to this decision — exactly as it is for mouse pointer events.
+- This means an unfocused app's window-rule `binds { ... unbound; }` works without requiring the user to focus the app first — touching it is enough.
+
+**Edge cases:**
+- **Touch on empty desktop / layer-shell surface** (no app window underneath) — no window-rule match; global `binds {}` applies as the default.
+- **Touch crossing windows mid-gesture** — already decided: claim is locked at touch-down, doesn't re-evaluate (see "Decided behavior — claim resolves at touch-down" below).
+- **Multi-finger gestures with fingers on different windows at touch-down** — centroid picks one window deterministically; first-finger-position is the tiebreaker if centroid lands on a gap.
+- **IPC-claimed gestures (`noop` with no action)** — same rule applies. The window under the touch determines whether its rule releases the claim, even when the eventual consumer is an external IPC listener.
+
+
+### Decided behavior — claim resolves at touch-down, stable for gesture lifetime
+
+The claim (compositor-grab vs client-passthrough) is decided **once**, at the moment the first finger lands, based on the window under the centroid at touch-down. It stays stable for the entire gesture lifetime — until all fingers lift — even if focus changes, the window moves, the cursor would be over a different window now, or additional fingers land later.
+
+**Why this matters in practice:**
+
+1. **The recognizer is stateful.** Once the compositor decides "this is mine," it begins accumulating `cumulative_dx`/`cumulative_dy`, computing pinch spread from initial finger spread, tracking rotation from initial angles. Flipping mid-gesture to "actually, give it to the client" would mean tearing down that state with no clean exit — the recognizer has no concept of "abort and rewind."
+
+2. **The client-event stream is also stateful.** Wayland clients expect a `touch_down → touch_motion* → touch_up` lifecycle per slot. If the compositor consumed the early events and then mid-gesture decides to forward, the client sees a `touch_motion` with no preceding `touch_down` — which is a protocol violation. The reverse (forwarding then consuming) leaves the client with a `touch_down` that never gets a `touch_up`, so it sits with a dangling slot until the next gesture cleans it up.
+
+3. **Continuous animations would visibly glitch.** A workspace-switch animation tracking finger position would reach 60% progress, then suddenly stop receiving updates because the claim flipped. The animation either snaps back, freezes, or completes phantom-style — all bad.
+
+4. **Focus-change-during-gesture is normal, not exceptional.** An interactive-move drag *deliberately* crosses windows — the whole point is moving a window across other windows. If the claim re-evaluated based on "what's under the centroid right now," every move-grab would be hijacked the moment it crossed another app's window. Touch-down resolution makes the claim about *intent at gesture start*, not *current spatial position*.
+
+5. **Late-landing fingers don't change the claim.** If the gesture started as 1-finger (compositor-claimed) and a second finger lands mid-gesture, the existing claim sticks. The new finger participates in the existing recognizer's state. Whether this triggers an unlock-to-higher-finger-count (the existing `unlock-on-new-finger` mechanism in `touch_gesture.rs`) is orthogonal — that's about gesture *type* (e.g. 3-finger swipe → 4-finger swipe), not about *who owns* the gesture.
+
+**Implementation:** the claim resolution lives in the `TouchDown` handler. The result (claimed-by-compositor vs forward-to-client + which client) gets stored on the active gesture state struct and read by every subsequent `TouchMotion`/`TouchUp`/`TouchFrame` in this gesture. No re-lookup, no re-matching of window rules.
+
+---
+
+### Superseded: three-gate disambiguation
+
+The following was the earlier design before the window-rule `binds {}` proposal. Kept for reference; no longer the active plan.
+
+#### Three-gate disambiguation (composes bind-existence + window rule + threshold timing)
+
+**Gate 1 — Window rule passthrough (app opts out):**
+
+`touchscreen-gesture-passthrough` already exists as a window rule. This is the per-app override when a user has global fingers=1/2 binds but wants specific apps to feel native:
+
+```text
+window-rule {
+ match app-id="firefox"
+ touchscreen-gesture-passthrough "always" // never defer, never consume
+}
+```
+
+When matched → forward immediately, no recognizer involvement.
+
+**Gate 2 — Bind existence = consume signal (`noop=consume` model from Part 10b):**
+
+Today `noop` is just "no action." The proposal: the *presence of any bind* (including `noop`) at a given `fingers=N direction=D` slot is the claim "compositor wants this pattern, don't forward."
+
+- No bind at `TouchSwipe fingers=1 direction="up"` → pass through (current default, unchanged)
+- `TouchSwipe fingers=1 direction="up" { noop; }` → compositor watches, claims if matched, never reaches client
+- `TouchSwipe fingers=1 direction="up" { focus-workspace-up; }` → same, plus the action runs
+
+This keeps the opt-in per-pattern rather than requiring a global "enable fingers=1/2" switch. Users who don't write fingers=1 binds experience zero behavior change.
+
+**Gate 3 — Threshold timing (recognizer decides):**
+
+When a bind exists and the window is not in passthrough, the compositor must buffer the first ~100px/200ms before deciding "bound swipe or client drag?" This is the latency cost of opting in.
+
+### Disambiguation flow
+
+```text
+TouchDown (1 or 2 fingers)
+ ↓
+Window under finger has touchscreen-gesture-passthrough="always"?
+ → yes: forward immediately, no recognizer (Gate 1)
+ ↓ no
+Any TouchSwipe/Pinch/Rotate/Tap/TapHoldDrag fingers=N bind exists for this N?
+ → no: forward immediately (current behavior preserved) (Gate 2)
+ ↓ yes
+Buffer events, run recognizer
+ ↓
+Threshold crossed, matches a bound pattern?
+ → yes: consume, drop buffered events, fire bind (Gate 3 commit)
+ ↓ no
+Timeout expired or motion stopped?
+ → yes: flush buffered events to client, resume passthrough (Gate 3 release)
+```
+
+### Cost analysis
+
+- **Zero-cost for users who don't bind fingers=1/2** — if no such bind exists, Gate 2 short-circuits to immediate passthrough. No latency, no regression.
+- **Per-pattern cost for users who do bind** — fingers=1/2 taps/drags in non-passthrough apps get buffered for threshold duration. Users accepted this cost by writing the bind.
+- **Escape hatch for power users** — window rule passthrough lets them keep global fingers=1 binds while exempting specific apps.
+
+### Why `noop=consume` is the right primitive
+
+Without it, we'd need a separate syntax to say "claim this gesture but do nothing" — either a new keyword (`TouchSwipe fingers=1 consume;`) or a separate block. Treating bind presence as the claim signal means:
+
+- No new syntax
+- `noop` gets a meaningful use (it's currently a no-op action with no purpose)
+- Composable with real actions — binding to `focus-workspace-up` implies consume, same as binding to `noop`
+- Matches the intuition that "if you told niri what to do with this gesture, niri should grab it"
+
+### Interaction with existing 2-finger scroll/touchpad semantics
+
+Touchpad 2-finger scroll is libinput-native and pre-classified — it arrives as `PointerAxis` events, not `GestureSwipe`. So `TouchpadSwipe fingers=2` would be an impossible bind (libinput never delivers 2-finger swipe events for touchpads). Touchpad fingers=1/2 disambiguation isn't really in scope — this applies to **touchscreen** fingers=1/2 only.
+
+### Open question
+
+Should fingers=1 and fingers=2 be *opt-in behind a config flag* (e.g., `allow-low-finger-gestures`) as a safety measure against users accidentally breaking their text selection? Arguments both ways:
+
+- **Opt-in flag:** explicit consent, easier to document "fingers=1 has latency cost"
+- **No flag:** writing the bind is already opt-in per Gate 2; extra flag is redundant
+
+Leaning toward no flag — the bind existence is already the opt-in signal, and Gate 2 makes the cost zero for users who don't write the binds.
diff --git a/docs/wiki/Design:-Touchscreen-Gestures.md b/docs/wiki/Design:-Touchscreen-Gestures.md
new file mode 100644
index 0000000000..3ac4ec97a4
--- /dev/null
+++ b/docs/wiki/Design:-Touchscreen-Gestures.md
@@ -0,0 +1,395 @@
+# Design: Touchscreen Gestures
+
+> [!IMPORTANT]
+> **Status: proposal / working prototype — not upstream niri's canonical design.**
+>
+> This document is not niri's official design position. It is a write-up of the choices I (the PR author) made while building a working touchscreen gesture implementation on the `feat/configurable-touch-gestures` branch, shaped by feedback from reviewers on the associated PR. The goal was to land *something that works* so there's a concrete reference point to experiment with, gather real-world feedback on, and iterate on — not to prescribe how niri should handle touch gestures long-term.
+>
+> Everything below describes what exists on this branch and why it was chosen over the alternatives I considered. It is explicitly open to being rethought, rewritten, or replaced. If you disagree with any section — especially §5 (design choices) and §6 (alternatives rejected) — that disagreement is the whole point of putting the rationale in writing. See §10 for how to push back.
+>
+> This document explains what Wayland gives us, what it doesn't, how other ecosystems solve the same problems, and why this implementation makes the specific choices it does. It is meant for contributors and reviewers deciding whether the current direction is worth building on, and for users of this branch curious about why the configuration surface looks the way it does.
+
+For how to **configure** gestures on this branch, see [Configuration: Window Rules](./Configuration:-Window-Rules.md) and the main niri config documentation. This doc is strictly about the *why*.
+
+---
+
+## 1. Scope
+
+What this doc covers:
+
+- The Wayland protocol landscape relevant to touch input
+- Why touchpad and touchscreen gestures live in different layers
+- How iOS, Android, and other Linux shells approach gesture ownership
+- The specific design choices niri makes and the reasoning behind each
+- Alternatives we considered and rejected, with rationale
+- Open questions and directions for future work
+
+What this doc does **not** cover:
+
+- Configuration syntax (see the wiki pages)
+- Specific gesture recognizer math (read `src/input/touch_gesture.rs`)
+- Touchpad gesture internals beyond "niri uses libinput via smithay" (they aren't a niri-local problem)
+
+---
+
+## 2. The Wayland protocol landscape
+
+Wayland touch input has a hard split between two worlds. Understanding the split is prerequisite to understanding why this doc exists.
+
+### 2.1 `wl_touch` (core, stable)
+
+Part of the core Wayland protocol. Exposes raw touch point lifecycle events:
+
+- `down(slot, surface, x, y)` — a new finger landed
+- `motion(slot, x, y)` — an existing finger moved
+- `up(slot)` — a finger lifted
+- `frame` — atomic batch boundary for multi-point updates
+- `cancel` — compositor revokes the touch stream
+
+That is the entire API. No semantics, no gesture recognition, no "swipe" or "pinch" primitives. The spec is explicit that gesture interpretation is the caller's responsibility. The caller here means *whoever is reading `wl_touch`* — usually the compositor, sometimes the client.
+
+### 2.2 `wp_pointer_gestures_v1` (unstable, widely adopted)
+
+A separate protocol that provides **touchpad** gestures only. Defines three semantic gesture types:
+
+- **Swipe** (`zwp_pointer_gesture_swipe_v1`) — begin / update / end lifecycle with finger count and dx/dy deltas
+- **Pinch** (`zwp_pointer_gesture_pinch_v1`) — begin / update / end with scale factor and rotation
+- **Hold** (`zwp_pointer_gesture_hold_v1`) — begin / end, used for tap-and-hold style interactions
+
+This protocol exists because libinput already does touchpad gesture recognition from the raw hardware events, and the Wayland layer just needed a standard way to expose libinput's output to clients and compositors. Niri uses this for touchpad gestures via smithay's libinput integration — no custom recognizer needed on that side.
+
+The protocol is still marked unstable (`unstable-v1`) but is implemented by all major compositors and all major toolkits. It is effectively the standard.
+
+### 2.3 The gap: no touchscreen gesture protocol
+
+There is **no** Wayland protocol for touchscreen gestures. Not stable, not unstable, not staged as a proposal in `wayland-protocols`. The explicit design position from both the Wayland community and libinput is that touchscreen gesture recognition requires context (focus, window layout, app intent) that the input stack doesn't have.
+
+There is also no protocol for **client cooperation** — no way for an app to tell the compositor "I handle 3-finger swipes in my content area, leave them alone." The closest analogue, `zwp_keyboard_shortcuts_inhibit_v1`, exists for keyboard shortcuts but has no touch equivalent.
+
+This gap is the root cause of nearly every design compromise in this document.
+
+---
+
+## 3. Why touchscreen gestures don't live in libinput
+
+libinput is the layer that turns raw kernel input device events into semantic events for compositors. It recognizes touchpad gestures (swipe, pinch, hold) and hands them up the stack cleanly. It explicitly refuses to do the same for touchscreens. The reason isn't laziness — it's a genuine architectural difference between the two input types.
+
+### 3.1 Touchpad: indirect manipulation, unambiguous recipient
+
+- Fingers move on a surface that **isn't** the thing they're affecting. The pointer is the proxy.
+- The touchpad belongs to the focused window or the compositor. There's exactly one plausible recipient for any gesture event — whoever has pointer focus.
+- Many modern touchpads (Apple Magic Trackpad, Microsoft Precision Touchpads) recognize gestures in **firmware**. The hardware says "3-finger swipe" directly. libinput forwards that, adds fallback recognition for dumber hardware, and exposes semantic events.
+- State is clean: `n` fingers down means a gesture is active with that many fingers. Any finger lifting ends it. Palm rejection is well-understood.
+- libinput can confidently say "this is a compositor/pointer-bound gesture event" because there's no other reasonable interpretation.
+
+### 3.2 Touchscreen: direct manipulation, ambiguous recipient
+
+- Fingers are **on** the thing they're affecting. The content under the finger is the target.
+- The same 3-finger contact at the same coordinates could legitimately mean:
+ - The user is drawing three strokes in a paint app
+ - Two people on a shared tablet both tapping at once
+ - A compositor workspace-switch swipe
+ - A browser pinch-to-zoom on a webpage
+ - Palm rest plus one intentional tap
+- libinput has zero visibility into what's under those coordinates. It doesn't know about Wayland surfaces, window focus, or client intent. Only the compositor has that context.
+- Hardware is also dumber: touchscreens report `(slot, x, y)` per contact point with no gesture semantics. There's nothing for libinput to forward.
+- State is messy: new fingers can arrive at any time, bezel phantom touches, hand resting, palm rejection depends on geometry libinput can't see.
+
+### 3.3 libinput's stated position
+
+Paraphrasing the libinput maintainers' public position: *"We can recognize motion from raw touch points, but we cannot tell you whether the user meant that motion for the compositor or for the app under their finger. That's a compositor decision, not an input-stack decision."*
+
+On touchpad that question has a trivial answer ("the compositor, always"). On touchscreen it doesn't, and libinput refuses to guess because a wrong guess means silently stealing input from an app. So touchscreen gesture recognition ends up **inside each compositor**, built from raw `wl_touch` events. Every major compositor has independently reinvented its own recognizer for exactly this reason.
+
+---
+
+## 4. How other ecosystems solve it
+
+Worth understanding because the design lessons map directly onto what a Wayland solution could look like.
+
+### 4.1 iOS (UIKit)
+
+iOS has an explicit gesture recognizer arbitration system baked into UIKit:
+
+- **Every view can attach gesture recognizers.** Both apps and the system.
+- **Priority chain.** System-level recognizers (home bar, control center, notification shade) sit at the top of the hierarchy.
+- **Failure requirements.** A recognizer can declare "I only activate if this other recognizer fails first." This is how UIKit handles "tap vs. long-press" and also how system edge-swipe defers to an app's swipe when appropriate.
+- **Simultaneous recognition.** Two recognizers can explicitly opt into firing at the same time — pinch + pan on a photo viewer, for example.
+- **Dedicated edge recognizers.** `UIScreenEdgePanGestureRecognizer` is a distinct type. Apps can attach their own and negotiate with the system's.
+
+The negotiation isn't a runtime question per touch event — it's **declared up front** by the view hierarchy. When a finger lands, UIKit walks the view tree from deepest to shallowest, collects every recognizer that could match, then arbitrates based on declared priority and failure rules.
+
+This works because Apple ships iOS + UIKit + the hardware as one vertically integrated stack. There is no protocol problem because there is no protocol — it's all one process model with a shared API.
+
+### 4.2 Android
+
+Android takes a different approach but lands in the same place:
+
+- **`onInterceptTouchEvent` chain.** Touch events bubble up through `ViewGroup`s. Each parent can claim ownership by returning true, at which point children stop seeing the events. This is how scroll containers steal touches from buttons mid-gesture.
+- **Standard framework classes.** `GestureDetector` and `ScaleGestureDetector` are built into the Android framework. Everyone uses the same ones, so gesture behavior is consistent across apps.
+- **System gestures live above the app.** Back, home, and recents (since Android 10 gesture nav) are handled at the `WindowManager` layer, not inside the view hierarchy.
+- **`systemGestureExclusionRects`** — this is the important one. An app can tell the system: *"in these rectangles, don't treat edge swipes as system gestures."* Games and drawing apps use this to claim screen edges when the user is actively using them for content. Apps can also read `WindowInsets.getSystemGestures()` to see where system gestures are active and lay out their UI accordingly.
+
+Android 10's gesture-nav rollout was specifically driven by this problem. Google needed to steal more of the screen edges for system gestures and ran into exactly the conflict niri runs into. Their answer was **`systemGestureExclusionRects`**: a tiny, minimal opt-out API that doesn't try to solve everything, just the most common conflict case.
+
+This is the closest real-world precedent for what a Wayland touchscreen gesture protocol could look like.
+
+### 4.3 Linux phone shells
+
+Linux mobile UX is the most instructive comparison because it's a touch-first world built on the same protocol stack we have. Every Linux phone shell has independently reinvented the same hacks:
+
+- **Phosh** (PinePhone, Librem 5, GNOME-based) — gestures handled inside Phoc, a wlroots-fork compositor. Apps receive raw `wl_touch` events; Phoc reserves edges for the app drawer and notification shade. No negotiation protocol.
+- **Plasma Mobile** — uses KWin's touch handling. Hardcoded system edges. Same story.
+- **SXMO** (minimalist postmarketOS shell) — uses `lisgd` as a separate daemon reading libinput directly. System owns everything; apps are effectively gesture-blind.
+- **Furios / Droidian** (Halium-based, Android drivers underneath) — inherits Android's gesture semantics from the hardware layer but runs regular Wayland compositors on top. Ends up with the worst of both worlds.
+
+Every one of these shells ships with the same core limitation: **system gestures are hardcoded, app gestures are whatever the toolkit happens to support, there is no negotiation**. When Firefox on a PinePhone handles pinch-to-zoom, it works because GTK handles 2-finger touches directly via `wl_touch` — not because anyone negotiated anything.
+
+### 4.4 Userspace gesture daemons
+
+Several projects have tried the "external daemon recognizes gestures, compositor reads from it" architecture:
+
+- **TouchEgg** — originally X11, adapted for Wayland. Reads libinput events directly, recognizes gestures, maps them to actions via XML config. Popular as a "make Linux feel like macOS" touchpad tool.
+- **lisgd** (libinput simple gesture daemon) — smaller scope, shell-command-based, stateless. Popular in SXMO and bespoke postmarketOS setups.
+- **InputActions** — newer, KDE-specific, funded work for Plasma 6 Wayland. Lives *inside* KWin rather than as a separate daemon.
+
+The common issue: on Wayland, any external daemon architecture breaks on **device ownership**. libinput exposes a single reader interface per device — whichever process grabs it "owns" the stream. If TouchEgg grabs exclusively, the compositor gets nothing. If neither grabs exclusively, they both see every event and double-handle. There's no "daemon sits between kernel and compositor" slot in the Wayland stack; the compositor is the input router by design.
+
+X11 had this slot because of its split server/client architecture with a routable event path. Wayland removed it deliberately for security and simplicity. This is why **compositor-agnostic gesture daemons don't work on Wayland** and why KDE moved InputActions *inside* KWin.
+
+### 4.5 The unifying observation
+
+Every ecosystem that has solved touchscreen gesture ownership has done so by **owning the whole stack** — iOS with UIKit + the OS, Android with the view system + WindowManager, KDE with InputActions + KWin. The problem isn't that the solution is hard to design. It's that the solution requires coordination between input, toolkit, and window manager, and Linux has that coordination problem stratified across dozens of unrelated projects.
+
+---
+
+## 5. Niri's design choices
+
+This section is explicitly opinionated. Each choice is labeled with its reasoning so reviewers can argue with the rationale, not just the result.
+
+### 5.1 Compositor-side recognizer from raw `wl_touch`
+
+**What:** Niri reads raw `wl_touch` events in `src/input/touch_gesture.rs` and runs its own gesture recognizer (direction lock, finger count tracking, pinch detection, edge swipe detection).
+
+**Why:** There is no alternative. libinput won't recognize touchscreen gestures. Clients receiving raw touches can't participate in compositor actions. Userspace daemons can't sit between the compositor and libinput. The compositor is the only layer that has both the input stream *and* the window context needed to make gesture routing decisions. This is the same conclusion KWin, Mutter, Phoc, and every other Wayland compositor has reached.
+
+### 5.2 Unified `binds {}` block with parameterized gesture triggers
+
+**What:** Touchscreen, touchpad, keyboard, and mouse gesture binds all live in the same `binds {}` block. Multi-finger gestures are parameterized via KDL properties: `TouchSwipe fingers=3 direction="up"` rather than hardcoded node names like `TouchSwipe3Up`. The five gesture families (`TouchSwipe`, `TouchpadSwipe`, `TouchPinch`, `TouchRotate`, `TouchEdge`) are the only first-class gesture node names; everything else is properties.
+
+**Why:**
+- **Modifier combos come for free.** `Mod+TouchSwipe fingers=3 direction="up"` reuses the existing key-bind parser with no new code paths — modifiers are stripped off the node name before property parsing begins.
+- **One lookup path.** `find_configured_bind()` handles every input type identically. `Trigger::TouchSwipe { fingers, direction }` is a struct variant, so `Eq`/`Hash` still work; bind lookup is unchanged from the hardcoded design.
+- **Consistency with niri's existing model.** Niri's keyboard and mouse binds already live in `binds {}`, and all other bind attributes (`tag=`, `natural-scroll=`, `sensitivity=`, `cooldown-ms=`) are KDL properties. Hardcoding finger count into the *node name* was the one place where touch gestures diverged from the rest of the config grammar; this closes that gap.
+- **Arbitrary finger counts.** `fingers=N` accepts any integer in `3..=10`. Users with tablets and large multitouch displays that report 6–10 contacts can bind to them without an enum change on the compositor side. The `3..=10` range is enforced by the parser with a clear error on out-of-range values.
+- **Per-family validation.** Each family has its own legal direction vocabulary (swipe takes `up/down/left/right`, pinch takes `in/out`, rotate takes `cw/ccw`, edge takes `left/right/top/bottom` with optional `zone=`). Invalid combinations are rejected at parse time, not at runtime.
+- **Hard break from the old syntax.** The previous enum-per-combination design (`TouchSwipe3Up`, `TouchEdgeTop:Left`) is gone — no dual-parse, no deprecation aliasing. A cleaner config grammar is worth the one-time migration cost for a pre-1.0 feature with a small user base.
+
+### 5.3 Tag property + IPC gesture events
+
+**What:** Gesture binds can carry an optional `tag="name"` property. Tagged binds emit `GestureBegin` / `GestureProgress` / `GestureEnd` events on niri's existing IPC event stream, letting external tools observe gestures for custom animations or UI feedback.
+
+**Why:**
+- **External extensibility without a scripting runtime.** niri doesn't need to embed Lua or JavaScript; tools subscribe to IPC events and react.
+- **Security-scoped.** Only tagged gesture binds emit IPC events. Keyboard input never appears in the event stream. This is a deliberate scoping decision — "we expose gestures because they're low-frequency, high-intent user actions, but we don't expose every keystroke."
+- **Three distinct modes.** With tags + the `noop` action, niri supports:
+ 1. **Observe** — `tag="ws"` + real action: niri runs the action and emits IPC events for external UI feedback
+ 2. **IPC-only** — `tag="drawer"` + `noop`: niri captures the gesture purely for IPC, runs no compositor action
+ 3. **Plain** — no tag: niri runs the action, no IPC emission
+- **Both discrete and continuous noop are supported.** A tagged `noop` bind on a swipe or pinch drives the full begin/update/end lifecycle, emitting continuous `GestureProgress` events for external animations — external tools can draw finger-tracked UI without the compositor performing any action of its own.
+- **Enables `niri-tag-sidebar` and similar tools** to build gesture-driven UIs without having to reimplement touch recognition themselves.
+
+### 5.4 `touchscreen-gesture-passthrough` window rule
+
+**What:** A window-rule bool field. When set on a matching window, niri's recognizer stays out of the way for touches that start on that window — events forward raw to the client for the lifetime of the gesture.
+
+**Why:**
+- **Solves the 80% case with the simplest possible mechanism.** For apps that always want touch events (browsers, drawing apps, mapping tools), a per-app static rule is enough.
+- **User-controlled, not auto-detected.** Niri makes zero attempts to guess which apps want passthrough. Heuristics like "this is Electron, probably a webapp" produce unpredictable behavior. Explicit rule or nothing.
+- **Doesn't wait for a Wayland protocol that isn't coming.** The reviewer who raised this concern ([issue discussion]) explicitly acknowledged the "elaborate automatic" version feels bad; this ships the blunt-but-predictable alternative now.
+- **Discoverability via `RUST_LOG=niri=debug`.** When niri captures a gesture, it logs the app-id of the window under the touch, letting users see exactly which app-id to add to their passthrough rules.
+
+### 5.5 Escape hatches: Mod+touch and edge zones always bypass passthrough
+
+**What:** Even on a window with `touchscreen-gesture-passthrough true`, holding the mod key or starting a touch in a screen-edge zone still triggers compositor gestures.
+
+**Why:**
+- **Discoverable fallbacks.** "Gestures don't work in this app? Try Mod+gesture, or swipe from the edge." Every passthrough window has a way to invoke compositor actions without removing the rule.
+- **Edge detection runs before window lookup.** This isn't a special case — edge zones are already evaluated before the window is even checked, so passthrough is automatically excluded.
+- **Mod+ is an explicit user intent signal.** If the user holds the mod key, they are unambiguously asking for a compositor action. Passthrough is for implicit gestures; Mod+ is explicit, so it wins.
+
+### 5.6 Per-edge zoned triggers
+
+**What:** Each screen edge is split into thirds along its perpendicular axis. `TouchEdge` accepts an optional `zone=` property — `edge="top" zone="left"`, etc. — giving 12 zoned triggers in addition to the 4 unzoned parents. Zoned triggers fall back to the parent if not configured. The zone vocabulary rotates per edge: `top`/`bottom` edges take `left|center|right`; `left`/`right` edges take `top|center|bottom`. Mismatched vocabularies are a parse error.
+
+**Why:**
+- **12 + 4 = 16 edge actions possible** without adding a new concept; power users can bind distinct actions per edge zone.
+- **Parent fallback.** A bare `TouchEdge edge="top"` catches any top-edge swipe that doesn't land in a more specific zoned bind, so adding one zoned bind doesn't break the others.
+- **Matches real-world UI patterns.** Status bars, notification shades, and app drawers all want *different* actions for different parts of the same edge.
+- **Matching UI support in external tooling.** `niri-tag-sidebar` mirrors the zone model so tagged panels can anchor to specific zones.
+
+### 5.7 Touchpad via `wp_pointer_gestures_v1` (libinput)
+
+**What:** Touchpad gestures are read from libinput via smithay's existing plumbing, exposed through the same `binds {}` block with `TouchpadSwipe fingers=N direction="..."` triggers. No compositor-side recognition.
+
+**Why:** Touchpad gesture recognition is a solved problem at the libinput layer. Writing our own recognizer for touchpad would duplicate work, produce inconsistent semantics vs. other compositors, and lose firmware-reported gesture quality from modern hardware. The right answer is "use the standard, expose it through niri's bind model."
+
+---
+
+## 6. Alternatives considered and not shipped
+
+Every decision in section 5 had alternatives. This section records the ones we looked at and why they didn't ship, so the same conversations don't have to happen repeatedly.
+
+### 6.1 Dynamic per-gesture client dialog ("does your app want this?")
+
+**The idea:** Compositor detects a gesture starting, asks the client under the touch "want this one?", client responds yes/no, compositor routes accordingly.
+
+**Why not:** Requires a Wayland protocol that doesn't exist. Also adds IPC round-trip latency on gesture start, which is noticeable for continuous gestures. Parked until a protocol emerges.
+
+### 6.2 `allow-forwarding=true` per-bind property
+
+**The idea:** Each gesture bind gets a flag saying "forward to client instead of consuming." The reviewer's original proposal.
+
+**Why not:** The reviewer themselves acknowledged it "feels way too complicated." It puts the opt-out at the wrong layer — gesture policy should follow the *target app*, not the *bind*. A user wanting Firefox to handle gestures would have to annotate every single bind with `allow-forwarding` conditionally based on the focused window, which is exactly the complexity a window rule avoids.
+
+### 6.3 Zone granularity on passthrough window rule
+
+**The idea:** Instead of `touchscreen-gesture-passthrough true`, specify which gesture classes passthrough: `touchscreen-gesture-passthrough "swipe"`, or rectangles within the window where passthrough applies, or per-finger-count opt-outs.
+
+**Why not:** Overengineering for v1. The simple bool handles the common cases (browsers, drawing apps). Zone granularity only matters when the answer is "depends on what part of the window the finger is on," which is the dynamic case that only a real protocol can solve well. Trying to approximate it with static rectangles requires the user to manually track layout changes, which is worse than nothing.
+
+If a concrete use case appears that the bool can't handle, the field type can be widened (`Option` → `Option`) without a breaking config change. Keeping v1 minimal preserves that flexibility.
+
+### 6.4 Auto-detection heuristics
+
+**The idea:** Niri guesses which apps want passthrough based on app-id patterns, toolkit detection, window class hints, etc.
+
+**Why not:** Unpredictable. "This is Electron, probably a webapp" is wrong for VSCode. "This is Chromium, probably wants gestures" is wrong for a kiosk app. Heuristics fail in ways users can't debug, and silently stealing or forwarding input based on guesses is the worst possible failure mode. Explicit rule or nothing.
+
+### 6.5 External gesture daemon (TouchEgg-style)
+
+**The idea:** Run a separate process that recognizes gestures and sends actions to niri via IPC.
+
+**Why not:** Breaks on Wayland device ownership (see section 4.4). Any daemon reading libinput directly conflicts with the compositor reading the same device. A daemon reading from niri via some new "raw touch" IPC would duplicate gesture state between processes and add latency. KDE tried the external path and pulled it in-process for exactly these reasons.
+
+### 6.6 Global "disable all gestures when this app focused"
+
+**The idea:** One big toggle — when a passthrough app has focus, niri disables all touch gestures everywhere on screen.
+
+**Why not:** Too blunt. Breaks the edge swipe and Mod+gesture escape hatches that make passthrough tolerable in the first place. A user couldn't invoke the app drawer or workspace switch without unfocusing the app first. The per-touch decision made in `on_touch_down` is strictly better — it respects escape hatches automatically.
+
+---
+
+## 7. Future directions
+
+Where this could go if the ecosystem moves.
+
+### 7.1 A minimal Wayland touchscreen gesture protocol
+
+The realistic shape, modeled on Android's `systemGestureExclusionRects`:
+
+1. Client advertises support via a new global interface (`wp_touch_gesture_exclusion_v1` or similar).
+2. Client submits per-surface rectangles: "in these regions of my window, don't handle compositor gestures."
+3. Compositor evaluates the rectangles when a touch starts; if the touch lands in an exclusion rect, forwards raw touches to the client.
+4. Rectangles update on window resize / layout change via standard surface commit.
+
+This is intentionally narrower than a full capability-negotiation protocol. It doesn't try to support "client handles swipe but not pinch" or "client wants first 100ms of the gesture to decide." Android has shipped the rect-based model for 6+ years and it covers the important cases. Getting 80% of the solution into the protocol layer beats waiting forever for 100%.
+
+**What niri could do if such a protocol existed:**
+
+- The `touchscreen-gesture-passthrough` window rule becomes a **fallback** for apps that don't participate in the protocol.
+- Apps that do participate (Firefox via GTK, Krita via Qt, etc.) get dynamic per-region control without any user configuration.
+- The discoverability debug log becomes less important because correct behavior is automatic for participating apps.
+- Niri would be one of the first compositors to support such a protocol if one is drafted.
+
+### 7.2 Unify IPC progress with niri's internal commit threshold
+
+IPC `GestureProgress` events already carry a normalized `progress: f64` (computed as `accumulated_delta * sensitivity / gesture-progress-distance`), so external consumers *do* get a 0→1 value. The unresolved problem is that niri has **two independent threshold systems** that are not synchronized:
+
+1. **IPC progress** — the value external tools see, driven by configured `gesture-progress-distance`
+2. **Internal compositor commit** — niri's layout code decides whether to snap to the next workspace / column / overview state based on its own distance and velocity math
+
+These two can disagree. A swipe can reach IPC `progress = 0.8` while niri decides to snap back, or commit when IPC `progress = 0.3`. For external UIs driven by tagged gestures, this mismatch is visible — a progress bar showing 80% while niri snaps back feels broken.
+
+The improvement is to either (a) expose niri's internal progress alongside or instead of the IPC progress, or (b) make the IPC progress drive the commit decision so the two always agree. See `GESTURE_PROGRESS_MISMATCH.md` for the full write-up.
+
+In practice the touchscreen case tracks closer than touchpad because screen pixels match niri's internal units, while libinput's acceleration-curved touchpad deltas make the touchpad mismatch more noticeable.
+
+### 7.3 Touchpad gesture passthrough (sibling rule)
+
+For completeness, a `touchpad-gesture-passthrough` window rule could be added. The shape is different — touchpad has no "window under finger," so the rule would match the focused window instead — but the config surface would look analogous. Punted from v1 because the pain is smaller (2-finger touchpad gestures already forward by default via libinput) and the semantics need more thought.
+
+### 7.4 Have `GestureEnd { completed }` reflect internal commit, not just cancellation
+
+The `completed` field on `GestureEnd` currently distinguishes two cases:
+
+- `completed: true` — gesture ended normally (all fingers lifted without external interruption)
+- `completed: false` — gesture was cancelled (a new finger arrived and restarted recognition, or cleanup fired)
+
+What it does **not** distinguish: whether niri's internal threshold actually committed the bound action. A touch workspace swipe that ends with all fingers lifted emits `completed: true` regardless of whether the compositor snapped forward to the new workspace or snapped back to the original. For tagged gestures driving external UIs, this is the same mismatch as §7.2 — the IPC event doesn't know what niri actually did.
+
+The fix is the same as §7.2: either unify the threshold systems so the answer is always knowable, or add a separate `action_committed: bool` field that propagates niri's internal snap decision. Either way, external tools should be able to answer "did the swipe actually do the thing?" from the `GestureEnd` event alone.
+
+---
+
+## 8. Open questions
+
+Explicitly inviting pushback. None of these have right answers yet.
+
+### 8.1 Should passthrough be a simple bool or support zones?
+
+Currently a simple bool. If someone comes up with a concrete use case the bool can't handle — for example, a browser where users want pinch forwarded but edge swipes intercepted — the field type would need to widen. The field name (`touchscreen-gesture-passthrough`) is generic enough that this extension is a non-breaking change (the bool becomes one arm of a widened sum type).
+
+### 8.2 Should layer-shell windows support passthrough?
+
+Currently no — `touchscreen-gesture-passthrough` is a `WindowRule` field and layer-shell surfaces don't go through window rules. A sidebar panel that wanted to claim gestures on itself has no way to do so today. Adding layer-shell passthrough is probably the right call but requires deciding where the config lives (a new `layer-rule {}` block? Matching criteria reused?) and is punted for v1.
+
+### 8.3 Should Mod+gesture always bypass passthrough?
+
+Currently yes, hard-coded. A case could be made for a `touchscreen-gesture-passthrough-respect-mod false` subfield to let passthrough *also* forward mod-combo gestures. Nobody has asked for this yet, and the hard-coded behavior preserves a discoverable escape hatch, so keeping it hard-coded feels right.
+
+### 8.4 What about gestures that start on a passthrough window and drift onto the desktop?
+
+Current behavior: once the first finger decides passthrough on touch-down, the entire gesture stays in passthrough mode until all fingers lift, even if fingers move off the window. This avoids confusing mid-gesture handoffs, but it means a user who accidentally starts a gesture on a passthrough window can't rescue it onto the compositor by dragging away. Reversing the policy (mid-gesture handoff based on current position) is probably worse, but this is the trade-off.
+
+### 8.5 Continuous `noop` semantics
+
+If we add continuous noop (section 7.2), should the delta stream be raw pixels, normalized progress, or both? Raw is more flexible but forces external tools to do their own normalization. Normalized is easier to consume but loses information. Both means more IPC traffic. No decision yet.
+
+### 8.6 Should the debug log be promoted to `info` or stay at `debug`?
+
+The `touch: captured N-finger gesture over app-id=X` log line is currently at `debug` level. That means it requires `RUST_LOG=niri=debug` to see. Promoting it to `info` would surface it by default, which helps discoverability but adds noise to logs during normal use. Leaning toward leaving it at `debug` and documenting the `RUST_LOG` requirement, but open to arguments.
+
+---
+
+## 9. Further reading
+
+External references for the design space covered in this document.
+
+### Wayland / libinput
+
+- [Wayland Protocols: `wp_pointer_gestures_v1`](https://wayland.app/protocols/pointer-gestures-unstable-v1)
+- [Wayland Book: Touch input](https://wayland-book.com/seat/touch.html)
+- [libinput gestures documentation](https://wayland.freedesktop.org/libinput/doc/latest/gestures.html)
+- [`zwp_keyboard_shortcuts_inhibit_v1`](https://wayland.app/protocols/keyboard-shortcuts-inhibit-unstable-v1) — the keyboard-side analogue of what touch gesture inhibit would need
+
+### iOS / Android
+
+- iOS: search Apple Developer docs for `UIGestureRecognizer`, `UIScreenEdgePanGestureRecognizer`
+- Android: search AOSP docs for `View.setSystemGestureExclusionRects`, `WindowInsets.getSystemGestures`
+
+### KDE / GNOME / Linux phone shells
+
+- [Input handling in spring 2025 — KDE Blogs](https://blogs.kde.org/2025/05/14/input-handling-in-spring-2025/)
+- KDE InputActions — search KDE Discuss for "InputActions mouse gestures Wayland"
+- [GNOME Shell gesture extensions](https://extensions.gnome.org/extension/4245/gesture-improvements/)
+- Phosh / Phoc source (GitLab) — how a wlroots-based mobile shell handles touch edges
+- SXMO / `lisgd` — the external-daemon model on Wayland
+
+### Niri internals
+
+- `src/input/touch_gesture.rs` — touchscreen gesture recognizer
+- `src/input/move_grab.rs` — touch-driven window move grab (interacts with gesture detection)
+- `niri-config/src/window_rule.rs` — where `touchscreen_gesture_passthrough` is parsed
+- `src/window/mod.rs` — `ResolvedWindowRules` and the rule compute path
+- [Configuration: Window Rules](./Configuration:-Window-Rules.md) — user-facing docs for the passthrough rule
diff --git a/docs/wiki/Gestures.md b/docs/wiki/Gestures.md
index 5c94d71f79..3e4cd47b95 100644
--- a/docs/wiki/Gestures.md
+++ b/docs/wiki/Gestures.md
@@ -52,19 +52,343 @@ Switch workspaces by holding Mod and the middle mouse button (or the
### Touchpad
+Since: next Touchpad gestures are configured as binds in the main `binds {}` block, the same way keyboard shortcuts are. The trigger is `TouchpadSwipe` with `fingers=N` (integer in `3..=10`) and `direction="up|down|left|right"` properties.
+
+The defaults below reproduce the built-in behavior; you can rebind them to any other action or disable them entirely.
+
+```kdl
+binds {
+ TouchpadSwipe fingers=3 direction="up" { focus-workspace-up; }
+ TouchpadSwipe fingers=3 direction="down" { focus-workspace-down; }
+ TouchpadSwipe fingers=3 direction="left" { focus-column-right; }
+ TouchpadSwipe fingers=3 direction="right" { focus-column-left; }
+ TouchpadSwipe fingers=4 direction="up" { toggle-overview; }
+ TouchpadSwipe fingers=4 direction="down" { toggle-overview; }
+}
+```
+
+Tuning parameters for touchpad gesture recognition (`swipe-trigger-distance`, `swipe-progress-distance`, `pinch-trigger-scale`) live in the `input { touchpad { gestures { } } }` subblock — see [Configuration: Input](./Configuration:-Input.md#touchpad-gesture-tuning).
+
#### Workspace Switch
-Switch workspaces with three-finger vertical swipes.
+Switch workspaces with three-finger vertical swipes (default bind).
#### Horizontal View Movement
-Move the view horizontally with three-finger horizontal swipes.
+Move the view horizontally with three-finger horizontal swipes (default bind).
#### Open and Close the Overview
Since: 25.05
-Open and close the overview with a four-finger vertical swipe.
+Open and close the overview with a four-finger vertical swipe (default bind).
+
+#### Tap-Hold Gestures
+
+Since: next
+
+Stationary N-finger tap-holds on the touchpad — fingers land, hold stationary, then lift. The action fires on release. libinput handles motion discrimination: if fingers move, the gesture is promoted to a swipe or pinch and the candidate is dropped automatically.
+
+Fast taps (where fingers lift before libinput's internal hold detection threshold) are **not** intercepted — they pass through to the focused client. This means app-level quick-tap gestures (e.g. 3-finger tap-to-paste in terminals) coexist naturally with compositor tap-hold binds.
+
+```kdl
+binds {
+ TouchpadTapHold fingers=3 { screenshot; }
+ TouchpadTapHold fingers=4 { spawn "notify-send" "4-finger tap-hold"; }
+ TouchpadTapHold fingers=5 { close-window; }
+}
+```
+
+- `fingers=` — integer in `3..=10`. Required. 1- and 2-finger holds are handled by libinput and forwarded to clients; niri only intercepts 3+ finger holds.
+- No `direction=` — tap-holds are omnidirectional. Including `direction=` is an error.
+
+Tap-holds are always **discrete** (fire-and-forget) — they cannot drive continuous animations.
+
+No niri-side tuning knobs are needed — libinput's hold gesture API handles the motion threshold and timing internally.
+
+#### Tap-Hold-Drag Gestures
+
+Since: next
+
+N-finger tap-hold-drag — fingers land, hold stationary, then start moving. The trigger activates when the held fingers begin moving, distinguishing it from a direct swipe (where fingers land already in motion). This is the same gesture macOS uses for three-finger window dragging.
+
+Tap-hold-drag can drive **continuous** actions (workspace switch, overview, view scroll) — the swipe deltas feed into the animation automatically. It can also fire discrete actions once on activation.
+
+```kdl
+binds {
+ // Continuous: hold 3 fingers, then drag to switch workspaces
+ TouchpadTapHoldDrag fingers=3 { focus-workspace-up; }
+
+ // Discrete: hold 4 fingers, then move to trigger once
+ TouchpadTapHoldDrag fingers=4 { spawn "notify-send" "drag started"; }
+}
+```
+
+- `fingers=` — integer in `3..=10`. Required.
+- No `direction=` — the drag direction is not part of the trigger. Including `direction=` is an error.
+
+The distinction between tap-hold-drag and a direct swipe is made by libinput: a tap-hold-drag is preceded by a `GestureHoldBegin` event (fingers were stationary first), while a direct swipe skips the hold phase entirely. This means the same finger count can be used for both without conflict — intent is distinguished by the pause before moving.
+
+#### Pinch Gestures
+
+Since: next
+
+N-finger touchpad pinch — fingers converging toward (or diverging from) the cluster centroid. libinput pre-classifies swipe-vs-pinch, so niri only needs a scale threshold to decide when the pinch is committed.
+
+```kdl
+binds {
+ TouchpadPinch fingers=2 direction="in" { open-overview; }
+ TouchpadPinch fingers=2 direction="out" { close-overview; }
+ TouchpadPinch fingers=3 direction="in" { spawn "rofi" "-show" "drun"; }
+}
+```
+
+- `fingers=` — integer in `2..=10`. Required. Unlike touchscreen pinch (which starts at 3 fingers to preserve 2-finger client passthrough), libinput emits touchpad pinch events natively for 2/3/4 fingers. 2-finger pinch is the most reliable.
+- `direction=` — `"in"` (scale shrinking) or `"out"` (scale growing). Required.
+
+Pinch is always **discrete** (fires once per gesture when `|scale - 1.0|` crosses `pinch-trigger-scale`). Raw pinch events still forward to Wayland clients, so app-level pinch-to-zoom (e.g. Firefox, image viewers) keeps working — the bind fires in addition to the app's own handling. Bind 3+ fingers if you want compositor-only actions without app overlap.
+
+The threshold is configured via `input { touchpad { gestures { pinch-trigger-scale } } }` — see [Configuration: Input](./Configuration:-Input.md#touchpad-gesture-tuning).
+
+> [!NOTE]
+> 3+ finger pinch works but requires fingers moving distinctly toward or away from the cluster centroid. If fingers mostly translate together, libinput classifies the motion as swipe instead and no pinch event is emitted. Rotation is not exposed on touchpad — it rides inside pinch events for 2-finger gestures only, and niri does not currently surface a `TouchpadRotate` trigger.
+
+### Touchscreen
+
+Since: next Touchscreen gestures are configured as binds in the main `binds {}` block using six parameterized node families — `TouchSwipe`, `TouchPinch`, `TouchRotate`, `TouchTap`, `TouchTapHoldDrag`, and `TouchEdge` — with KDL properties for finger count and direction. The `fingers=` property accepts any value in `3..=10`, so arbitrary finger counts are supported without an enum change.
+
+#### Swipe Gestures
+
+```kdl
+binds {
+ TouchSwipe fingers=3 direction="up" { focus-workspace-up; }
+ TouchSwipe fingers=3 direction="down" { focus-workspace-down; }
+ TouchSwipe fingers=3 direction="left" { focus-column-right; }
+ TouchSwipe fingers=3 direction="right" { focus-column-left; }
+ TouchSwipe fingers=4 direction="up" { toggle-overview; }
+ TouchSwipe fingers=4 direction="down" { toggle-overview; }
+ // fingers=5 (and 6..=10) also work.
+}
+```
+
+- `fingers=` — integer in `3..=10`. Rejecting `<3` preserves the 2-finger passthrough contract used by clients for scrolling/zooming. Required.
+- `direction=` — one of `"up"`, `"down"`, `"left"`, `"right"`. Required.
+
+#### Pinch Gestures
+
+```kdl
+binds {
+ TouchPinch fingers=3 direction="in" { open-overview; }
+ TouchPinch fingers=3 direction="out" { close-overview; }
+ // fingers=4/5/6/.../10 also work.
+}
+```
+
+- `fingers=` — integer in `3..=10`. Required.
+- `direction=` — one of `"in"` (spread shrinking) or `"out"` (spread growing). Required.
+
+Pinch vs swipe classification is controlled by the `pinch-trigger-distance` and `pinch-dominance-ratio` tuning parameters.
+
+#### Rotation Gestures
+
+> [!WARNING]
+>
+> Rotation detection is an early proof of concept and is currently **buggy and intermittent** on real hardware — recognition can misfire, lock at the wrong finger count, or fail to latch. The math, IPC, and bind plumbing are in place and tests pass, but real-world tuning still needs work. Use with caution and expect false positives / misses while this settles.
+
+Twisting the finger cluster clockwise or counter-clockwise (around its centroid) fires a rotation gesture. Rotation is detected from the averaged per-finger angle change, so the noise floor is √N lower than single-finger angular drift.
+
+```kdl
+binds {
+ // 4-finger rotation walks column focus left/right.
+ TouchRotate fingers=4 direction="ccw" { focus-column-left; }
+ TouchRotate fingers=4 direction="cw" { focus-column-right; }
+}
+```
+
+- `fingers=` — integer in `3..=10`. Required.
+- `direction=` — one of `"cw"` (clockwise on screen) or `"ccw"` (counter-clockwise on screen). Required. The sign convention assumes the y-axis points down (standard screen coordinates).
+
+Rotation classification runs before pinch and swipe classification, so a clearly rotating finger cluster wins over any incidental spread or translation. Tuning lives under `input { touchscreen { gestures { } } }`: `rotation-trigger-angle` (minimum **degrees** before it latches, default 15°), `rotation-dominance-ratio` (how much rotation arc length must dominate swipe/spread change, default 0.5 — higher = stricter rotation, matching `pinch-dominance-ratio` semantics), and `rotation-progress-angle` (degrees that map to IPC `progress = ±1.0`, default 90°).
+
+Rotation gestures are **continuous** in the same sense as pinch: binding them to a continuous-capable action animates frame-by-frame, and tagged rotations emit `GestureProgress` events where the delta is `GestureDelta::Rotate { d_radians }`.
+
+Pinch gestures are **continuous**: when bound to a continuous-capable action like `open-overview`, `close-overview`, `toggle-overview`, `focus-workspace-*`, `focus-column-*`, or `noop`, the animation tracks finger motion frame-by-frame (pinch-in smoothly opens the overview, reversing the pinch smoothly closes it again). Binding a pinch to a non-continuous action like `spawn` or `close-window` still fires the action once on recognition, as before.
+
+The animation scale for pinch is controlled by `pinch-sensitivity`, not by the bind's `sensitivity=` property — pinch has its own dedicated knob because raw spread-delta pixels need a very different scaling from linear swipe distances. Tune `pinch-sensitivity` in the `touchscreen { gestures { } }` block if pinch-to-overview feels too fast or too slow.
+
+#### Tap Gestures
+
+Since: next
+
+Stationary N-finger taps — all fingers land and lift with minimal motion. Tap detection runs in parallel with swipe/pinch/rotate recognition using a spatial dead zone, matching the approach used by Android, iOS, and libinput. If any finger drifts beyond the wobble threshold or the swipe/pinch/rotate recognizer locks first, the tap candidate is killed.
+
+```kdl
+binds {
+ TouchTap fingers=3 { screenshot; }
+ TouchTap fingers=4 { spawn "notify-send" "4-finger tap"; }
+ TouchTap fingers=5 { close-window; }
+}
+```
+
+- `fingers=` — integer in `3..=10`. Required.
+- No `direction=` — taps are omnidirectional. Including `direction=` is an error.
+
+Taps are always **discrete** (fire-and-forget) — they cannot drive continuous animations.
+
+Tuning parameters in `input { touchscreen { gestures { } } }`:
+
+- `tap-wobble-threshold` — maximum per-finger displacement (in pixels) before the tap candidate is killed. Default: 15. Increase if taps are too hard to trigger on your device; decrease if taps fire when you intended a swipe.
+- `tap-timeout-ms` — maximum duration (in milliseconds) from the third finger landing to all fingers lifting. Default: 500. Acts as a tap-vs-hold safety cap.
+
+The wobble threshold (default 15 px) sits well below the swipe trigger distance (default 100 px), creating a dead zone between 15–100 px where neither tap nor swipe fires — this handles ambiguous gestures correctly.
+
+#### Tap-Hold-Drag Gestures
+
+Since: next
+
+N-finger tap-hold-drag — fingers land, hold stationary (within the wobble threshold), then start moving. The trigger fires at the wobble-kill moment — the transition from "was a tap candidate" to "started moving." This distinguishes tap-hold-drag from a direct swipe: direct swipes move immediately without a stationary hold phase.
+
+Tap-hold-drag supports an optional `direction=` property. Directional binds are checked first; if no directional bind matches, the omnidirectional (no `direction=`) bind is used as a fallback.
+
+```kdl
+binds {
+ // Omnidirectional — fires regardless of initial movement direction
+ TouchTapHoldDrag fingers=3 { spawn "notify-send" "drag started"; }
+
+ // Directional — only fires for that initial direction
+ TouchTapHoldDrag fingers=4 direction="left" { spawn "wl-copy"; }
+ TouchTapHoldDrag fingers=4 direction="right" { spawn "wl-paste"; }
+ TouchTapHoldDrag fingers=4 direction="up" { toggle-overview; }
+}
+```
+
+- `fingers=` — integer in `3..=10`. Required.
+- `direction=` — optional. One of `"up"`, `"down"`, `"left"`, `"right"`. When omitted, the trigger is omnidirectional.
+
+Tap-hold-drag can drive **continuous** actions — when bound to a continuous-capable action, the swipe deltas feed into the animation frame-by-frame after activation. Binding to a discrete action fires it once.
+
+Tuning parameters in `input { touchscreen { gestures { } } }`:
+
+- `tap-hold-trigger-delay-ms` — minimum hold duration (in milliseconds) before a wobble-kill can activate a tap-hold-drag bind. If fingers move before this delay elapses, normal swipe/pinch/rotate recognition continues instead. Default: 200. Increase if fast swipes accidentally trigger hold-drag; decrease if hold-drag feels sluggish to activate.
+
+The hold detection also reuses the tap candidate's wobble threshold (`tap-wobble-threshold`, default 15 px). Fingers must stay within this threshold during the hold phase.
+
+#### Edge Swipes
+
+One-finger swipes that begin within `edge-start-distance` pixels of a screen edge. Useful for drawers, panels, and any edge-activated UI.
+
+```kdl
+binds {
+ TouchEdge edge="left" { focus-column-right; }
+ TouchEdge edge="right" { focus-column-left; }
+ TouchEdge edge="top" { focus-workspace-up; }
+ TouchEdge edge="bottom" { focus-workspace-down; }
+}
+```
+
+- `edge=` — one of `"left"`, `"right"`, `"top"`, `"bottom"`. Required.
+- `zone=` — optional third-of-the-edge qualifier (see Edge Zones below).
+- No `fingers=` — edge swipes are always single-finger. Including `fingers=` is an error.
+
+The edge trigger zone width is set by `edge-start-distance` in the `touchscreen { gestures { } }` block.
+
+##### Edge swipes with continuous actions (overview, workspace switch)
+
+Edge swipes can be bound to continuous actions like `toggle-overview` or `focus-workspace-up`. Two things to be aware of:
+
+- **Direction inversion:** Edge swipes feeding into overview require `natural-scroll=true` to feel correct. Without it, swiping down from the top edge tries to close overview instead of opening it.
+
+ ```kdl
+ binds {
+ TouchEdge edge="top" zone="right" natural-scroll=true { toggle-overview; }
+ }
+ ```
+
+- **Left/right edges and overview:** Continuous overview gestures currently only track vertical (`delta_y`) motion. Left and right edge swipes produce primarily horizontal motion (`delta_x`), which the overview ignores. This means `toggle-overview` on a left or right edge swipe will not work. Use top or bottom edges for overview binds. This is a known limitation.
+
+##### Edge zones
+
+Since: next
+
+Each edge is also split into three zones along its perpendicular axis so you can bind separate actions to different parts of the same edge (like Android's status bar → notification tray vs. quick-settings split, or a top-right screenshot gesture). Add a `zone=` property to restrict the bind to one third. The zone vocabulary rotates per edge to match the direction of the split:
+
+| Edge | Valid `zone=` values | Meaning |
+| --- | --- | --- |
+| `edge="top"` | `"left"` / `"center"` / `"right"` | thirds along the x-axis |
+| `edge="bottom"` | `"left"` / `"center"` / `"right"` | thirds along the x-axis |
+| `edge="left"` | `"top"` / `"center"` / `"bottom"` | thirds along the y-axis |
+| `edge="right"` | `"top"` / `"center"` / `"bottom"` | thirds along the y-axis |
+
+Mismatched vocabularies (e.g. `edge="left" zone="left"`) are a parse error.
+
+```kdl
+binds {
+ // Split the top edge into three independent actions.
+ TouchEdge edge="top" zone="left" { spawn "notify-send" "left"; }
+ TouchEdge edge="top" zone="center" { spawn "notify-send" "pull down notifications"; }
+ TouchEdge edge="top" zone="right" { spawn "screenshot.sh"; }
+
+ // Bottom-right corner for the overview; middle-bottom for app drawer.
+ TouchEdge edge="bottom" zone="center" { spawn "rofi" "-show" "drun"; }
+ TouchEdge edge="bottom" zone="right" { toggle-overview; }
+
+ // Parent bind is still valid. If no zoned bind hits for a given touch,
+ // the parent (no `zone=`) trigger is used as a fallback — so a bare
+ // `TouchEdge edge="left"` catches any left-edge swipe that doesn't land
+ // in a more specific zone bind.
+ TouchEdge edge="left" { focus-column-right; }
+}
+```
+
+Tuning parameters for touchscreen gesture recognition all live in the `input { touchscreen { gestures { } } }` subblock — see [Configuration: Input](./Configuration:-Input.md#touchscreen).
+
+### Gesture Tags and IPC Events
+
+Since: next
+
+Any gesture bind (touchscreen or touchpad) can carry a `tag="..."` property. When the gesture fires, niri emits `GestureBegin`, `GestureProgress`, and `GestureEnd` events on its IPC event stream, carrying the tag string. External applications subscribing to the event stream can react to those events — drive a sidebar drawer, show a scrubbing HUD, move a slider, etc.
+
+```kdl
+binds {
+ // Tagged workspace switch — still switches workspaces, and also
+ // emits GestureProgress events with tag="ws-nav" for external apps
+ // that want to show a progress indicator alongside the animation.
+ TouchSwipe fingers=3 direction="up" tag="ws-nav" { focus-workspace-up; }
+ TouchSwipe fingers=3 direction="down" tag="ws-nav" { focus-workspace-down; }
+
+ // Noop-tagged edge swipe — drives no compositor action, just emits
+ // IPC progress events so an external app (e.g. a sidebar drawer)
+ // can follow the finger.
+ TouchEdge edge="left" tag="sidebar-left" { noop; }
+ TouchEdge edge="right" tag="sidebar-right" { noop; }
+}
+```
+
+The three IPC events are:
+
+- **`GestureBegin { tag, trigger, finger_count, is_continuous }`** — fired when gesture recognition has locked in. `is_continuous` is true for swipe, pinch, and edge gestures bound to continuous-capable actions (including `noop`), and false for discrete gestures bound to one-shot actions.
+- **`GestureProgress { tag, progress, delta, timestamp_ms }`** — fired repeatedly while a continuous gesture is in motion.
+ - `progress` is **signed, unbounded**, normalized: it starts at `0.0` when the gesture is recognized and grows as the gesture continues. Reversing direction produces negative values, and overshoot can exceed `±1.0` — consumers should not assume the value is clamped.
+ - For **swipes and edge gestures**, progress accumulates adjusted (sensitivity-scaled, natural-scroll-adjusted) finger delta on the dominant axis, normalized by `swipe-progress-distance` (default 200 px for touchscreen, 40 libinput units for touchpad — same knob name, separate config block). Progress `±1.0` ≈ one progress-distance of movement.
+ - For **pinches**, progress is `(current_spread - start_spread) / pinch-progress-distance` (default 100 px). Positive = pinch-out (spread growing), negative = pinch-in.
+ - For **rotations**, progress is cumulative signed rotation divided by `rotation-progress-angle` (configured in **degrees**, default 90°). Positive = counter-clockwise on screen, negative = clockwise on screen.
+ - `delta` is a tagged enum carrying the per-event raw delta in a gesture-specific shape:
+ - `GestureDelta::Swipe { dx, dy }` — per-event finger delta in screen pixels (touchscreen) or libinput units (touchpad).
+ - `GestureDelta::Pinch { d_spread }` — per-event change in finger spread.
+ - `GestureDelta::Rotate { d_radians }` — per-event change in the averaged per-finger angle. Signed with the same on-screen convention as `progress`.
+- **`GestureEnd { tag, completed }`** — fired when the gesture ends (fingers released).
+
+#### Noop Gestures
+
+Binding a tagged gesture to `noop` means the gesture emits IPC events without driving any compositor animation. This is the cleanest case for external apps: progress is the sole output, and the external app has full control over its own thresholds and snap behavior. Used by [niri-tag-sidebar](https://github.com/julianjc84/niri-tag-sidebar) for edge-swipe drawer panels.
+
+#### Progress vs Compositor Animation
+
+> [!WARNING]
+>
+> When a tagged gesture *also* drives a compositor animation (e.g. a tagged workspace switch), niri uses its own internal thresholds to decide when to commit the action — these are independent of the IPC `progress` value. An external app watching the progress value can't reliably predict when niri will actually commit. For `noop` gestures this isn't a concern because progress is the sole output.
+
+The `GestureEnd.completed` field is currently hardcoded `true` for touchscreen gestures and does **not** indicate whether niri actually committed the bound action.
### All Pointing Devices
diff --git a/niri-config/src/binds.rs b/niri-config/src/binds.rs
index 0be7596fec..9e47ba76c8 100644
--- a/niri-config/src/binds.rs
+++ b/niri-config/src/binds.rs
@@ -13,9 +13,42 @@ use smithay::input::keyboard::keysyms::KEY_NoSymbol;
use smithay::input::keyboard::xkb::{keysym_from_name, KEYSYM_CASE_INSENSITIVE, KEYSYM_NO_FLAGS};
use smithay::input::keyboard::Keysym;
+use crate::input::{EdgeZone, ScreenEdge};
use crate::recent_windows::{MruDirection, MruFilter, MruScope};
use crate::utils::{expect_only_children, MergeWith};
+/// Direction for a linear swipe gesture.
+#[derive(Debug, PartialEq, Eq, Clone, Copy, Hash)]
+pub enum SwipeDirection {
+ Up,
+ Down,
+ Left,
+ Right,
+}
+
+/// Direction for a pinch gesture.
+#[derive(Debug, PartialEq, Eq, Clone, Copy, Hash)]
+pub enum PinchDirection {
+ In,
+ Out,
+}
+
+/// Direction for a rotation gesture (as seen on screen).
+#[derive(Debug, PartialEq, Eq, Clone, Copy, Hash)]
+pub enum RotateDirection {
+ /// Clockwise on screen.
+ Cw,
+ /// Counter-clockwise on screen.
+ Ccw,
+}
+
+/// Inclusive bounds on the `fingers=` property for multi-finger gestures.
+/// Parser rejects `fingers` values outside `[MIN_FINGERS, MAX_FINGERS]`.
+/// `< 3` would collide with two-finger passthrough (scroll/zoom) and plain
+/// single-finger touch handling; `> 10` exceeds any realistic hardware.
+pub const MIN_FINGERS: u8 = 3;
+pub const MAX_FINGERS: u8 = 10;
+
#[derive(Debug, Default, PartialEq)]
pub struct Binds(pub Vec);
@@ -28,6 +61,16 @@ pub struct Bind {
pub allow_when_locked: bool,
pub allow_inhibiting: bool,
pub hotkey_overlay_title: Option