diff --git a/docs/wiki/Configuration:-Window-Rules.md b/docs/wiki/Configuration:-Window-Rules.md
index f8fa215ec3..6fd36fb344 100644
--- a/docs/wiki/Configuration:-Window-Rules.md
+++ b/docs/wiki/Configuration:-Window-Rules.md
@@ -594,6 +594,32 @@ window-rule {
> This is because window title (and app ID) are not double-buffered in the Wayland protocol, so they are not tied to specific window contents.
> There's no robust way for Firefox to synchronize visibly showing a different tab and changing the window title.
+#### `block-pointer-constraints`
+
+Since: next release
+
+Suppress activation of [`zwp_pointer_constraints_v1`](https://wayland.app/protocols/pointer-constraints-unstable-v1) requests originating from this window.
+Affects both `locked` and `confined` constraint variants.
+
+The constraint can still be requested and bound to the window's surfaces by the client.
+niri simply never activates it, so the client observes a constraint that remains inactive for its entire lifetime.
+Well-behaved clients gate relative-motion mode and similar features on the constraint being active, so they degrade gracefully to normal absolute-motion input.
+
+Useful for opting out of apps that request a pointer-lock as part of a tooltip, annotation, or overlay UI that the user doesn't want to engage when the cursor merely crosses the surface.
+
+For example, Zoom's `annotate_toolbar` overlay window requests a pointer-lock the user typically doesn't want to engage:
+
+```kdl
+window-rule {
+ match app-id="^Zoom$" title="^annotate_toolbar$"
+ block-pointer-constraints true
+}
+```
+
+> [!NOTE]
+> This is unrelated to keyboard focus or pointer focus.
+> Cursor entry into the window's surface still updates pointer focus normally (and triggers [`focus-follows-mouse`](./Configuration:-Input.md#focus-follows-mouse) if configured) — only the pointer-lock/confine activation is gated.
+
#### `opacity`
Set the opacity of the window.
diff --git a/niri-config/src/lib.rs b/niri-config/src/lib.rs
index 909aeb80a5..6f128c71bf 100644
--- a/niri-config/src/lib.rs
+++ b/niri-config/src/lib.rs
@@ -890,6 +890,7 @@ mod tests {
default-window-height { fixed 500; }
default-column-display "tabbed"
default-floating-position x=100 y=-200 relative-to="bottom-left"
+ block-pointer-constraints true
focus-ring {
off
@@ -1857,6 +1858,9 @@ mod tests {
clip_to_geometry: None,
baba_is_float: None,
block_out_from: None,
+ block_pointer_constraints: Some(
+ true,
+ ),
variable_refresh_rate: None,
default_column_display: Some(
Tabbed,
diff --git a/niri-config/src/window_rule.rs b/niri-config/src/window_rule.rs
index f2bc2ad157..a6619c3088 100644
--- a/niri-config/src/window_rule.rs
+++ b/niri-config/src/window_rule.rs
@@ -66,6 +66,8 @@ pub struct WindowRule {
#[knuffel(child, unwrap(argument))]
pub block_out_from: Option,
#[knuffel(child, unwrap(argument))]
+ pub block_pointer_constraints: Option,
+ #[knuffel(child, unwrap(argument))]
pub variable_refresh_rate: Option,
#[knuffel(child, unwrap(argument, str))]
pub default_column_display: Option,
diff --git a/src/niri.rs b/src/niri.rs
index 190ef09d55..2bc60ead12 100644
--- a/src/niri.rs
+++ b/src/niri.rs
@@ -6080,6 +6080,22 @@ impl Niri {
return;
}
+ // Window-rule opt-out: a window matched by a rule with
+ // `block-pointer-constraints true` short-circuits before
+ // activation. The constraint stays bound but inactive for its
+ // lifetime, so the protocol behaves like a silent no-op.
+ // Resolving root + looking up the window rules is deferred
+ // until here so the no-constraint fast path (every pointer
+ // motion over a surface without a constraint) doesn't pay
+ // for it. Pointer-constraints can be requested on subsurfaces
+ // / popups, but rules resolve on the toplevel.
+ let root = self.find_root_shell_surface(surface);
+ if let Some((mapped, _)) = self.layout.find_window_and_output(&root) {
+ if mapped.rules().block_pointer_constraints == Some(true) {
+ return;
+ }
+ }
+
// Constraint does not apply if not within region.
if let Some(region) = constraint.region() {
let pointer_pos = pointer.current_location();
diff --git a/src/window/mod.rs b/src/window/mod.rs
index 14527cd242..ad52a56eb1 100644
--- a/src/window/mod.rs
+++ b/src/window/mod.rs
@@ -111,6 +111,9 @@ pub struct ResolvedWindowRules {
/// Whether to block out this window from certain render targets.
pub block_out_from: Option,
+ /// Whether to block pointer-constraints (locked + confined) for this window.
+ pub block_pointer_constraints: Option,
+
/// Whether to enable VRR on this window's primary output if it is on-demand.
pub variable_refresh_rate: Option,
@@ -293,6 +296,9 @@ impl ResolvedWindowRules {
if let Some(x) = rule.block_out_from {
resolved.block_out_from = Some(x);
}
+ if let Some(x) = rule.block_pointer_constraints {
+ resolved.block_pointer_constraints = Some(x);
+ }
if let Some(x) = rule.variable_refresh_rate {
resolved.variable_refresh_rate = Some(x);
}