From f51d761c456d635084b2f117a9ccbe9b96ad5aca Mon Sep 17 00:00:00 2001 From: HigherOrderLogic <73709188+HigherOrderLogic@users.noreply.github.com> Date: Tue, 20 Oct 2026 01:43:39 +1100 Subject: [PATCH 01/40] ci: modernize GH pages workflow --- .github/workflows/ci.yml | 31 +++++++++++++++++++------------ 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f25ffba301..139b50b064 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -345,12 +345,10 @@ jobs: with: path: docs/wiki/ - publish-docs: + build-docs: if: github.event_name == 'push' && github.ref == 'refs/heads/main' && github.repository == 'niri-wm/niri' needs: - test - permissions: - contents: write runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v6 @@ -376,14 +374,23 @@ jobs: - name: Generate rustdoc documentation run: cargo doc --no-deps -p niri-ipc - - run: mkdir -p publish/niri_ipc - - run: cp -r ./target/doc/* ./publish/ - - run: cp -r ./docs/site/* ./publish/ + - run: | + mkdir -p publish/niri_ipc + cp -r ./target/doc/* ./publish/ + cp -r ./docs/site/* ./publish/ - - name: Deploy documentation - if: github.ref == 'refs/heads/main' - uses: peaceiris/actions-gh-pages@v4 + - name: Upload pages artifact + uses: actions/upload-pages-artifact@v5 with: - github_token: ${{ secrets.GITHUB_TOKEN }} - publish_dir: ./publish - force_orphan: true + path: ./publish + + publish-docs: + runs-on: ubuntu-24.04 + needs: + - build-docs + permissions: + id-token: write + pages: write + steps: + - name: Deploy artifact to GitHub Pages + uses: actions/deploy-pages@v5 From aee25c9efcfa85b6029d5d1a2b7aaac7d87ae9d1 Mon Sep 17 00:00:00 2001 From: Ivan Molodetskikh Date: Tue, 20 Oct 2026 17:03:48 +0300 Subject: [PATCH 02/40] Redraw important hotkeys when hide-not-bound changes --- src/niri.rs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/niri.rs b/src/niri.rs index d7d180a04e..2f3c458143 100644 --- a/src/niri.rs +++ b/src/niri.rs @@ -1790,10 +1790,10 @@ impl State { let binds_changed = config.binds != old_config.binds; let new_mod_key = self.backend.mod_key(&config); + let mut hotkey_overlay_changed = + config.hotkey_overlay.hide_not_bound != old_config.hotkey_overlay.hide_not_bound; if new_mod_key != self.backend.mod_key(&old_config) || binds_changed { - self.niri - .hotkey_overlay - .on_hotkey_config_updated(new_mod_key); + hotkey_overlay_changed = true; self.niri.mods_with_mouse_binds = mods_with_mouse_binds(new_mod_key, &config.binds); self.niri.mods_with_horizontal_wheel_binds = mods_with_horizontal_wheel_binds(new_mod_key, &config.binds); @@ -1804,6 +1804,11 @@ impl State { self.niri.mods_with_finger_scroll_binds = mods_with_finger_scroll_binds(new_mod_key, &config.binds); } + if hotkey_overlay_changed { + self.niri + .hotkey_overlay + .on_hotkey_config_updated(new_mod_key); + } if config.window_rules != old_config.window_rules { window_rules_changed = true; From e19df12cf81ffe805609fee497da1a097066ad2f Mon Sep 17 00:00:00 2001 From: greyxp1 Date: Mon, 19 Oct 2026 18:47:06 -0400 Subject: [PATCH 03/40] render: Skip unsupported framebuffer effects --- src/render_helpers/framebuffer_effect.rs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/render_helpers/framebuffer_effect.rs b/src/render_helpers/framebuffer_effect.rs index 4e5a616300..775e400ed7 100644 --- a/src/render_helpers/framebuffer_effect.rs +++ b/src/render_helpers/framebuffer_effect.rs @@ -1,11 +1,12 @@ use std::cell::RefCell; +use std::sync::Once; use glam::{Mat3, Vec2}; use niri_config::CornerRadius; use smithay::backend::allocator::Fourcc; use smithay::backend::renderer::element::{Element, Id, RenderElement}; use smithay::backend::renderer::gles::{ - ffi, GlesError, GlesFrame, GlesRenderer, GlesTexture, Uniform, + ffi, Capability, GlesError, GlesFrame, GlesRenderer, GlesTexture, Uniform, }; use smithay::backend::renderer::utils::CommitCounter; use smithay::backend::renderer::{Frame as _, FrameContext, Offscreen, Texture as _}; @@ -166,6 +167,7 @@ impl RenderElement for FramebufferEffectElement { let output_rect = Rectangle::from_size(frame.output_size()); let transform = frame.transformation(); + let supports_blit = frame.capabilities().contains(&Capability::Blit); let mut guard = frame.renderer(); let inner = cache @@ -175,6 +177,14 @@ impl RenderElement for FramebufferEffectElement { inner.intermediate = None; + if !supports_blit { + static WARN: Once = Once::new(); + WARN.call_once(|| { + warn!("non-xray background effects disabled: renderer does not support framebuffer blitting"); + }); + return Ok(()); + } + // We want clamp-to-edge behavior for out-of-bounds pixels. However, glBlitFramebuffer // seems to skip out-of-bounds pixels, even though my reading of the docs suggests // otherwise (we use GL_LINEAR filter). So, clamp dst to the framebuffer bounds From 66a03cfbfb3172cad09f7dfc56998a9cca897ab4 Mon Sep 17 00:00:00 2001 From: Ivan Molodetskikh Date: Fri, 23 Oct 2026 17:04:19 +0300 Subject: [PATCH 04/40] config: Fix panic on a bind key with a null byte keysym_from_name() calls CString::new(key).unwrap(). --- niri-config/src/binds.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/niri-config/src/binds.rs b/niri-config/src/binds.rs index 2266d87704..27eeee3e1d 100644 --- a/niri-config/src/binds.rs +++ b/niri-config/src/binds.rs @@ -953,6 +953,10 @@ impl FromStr for Key { type Err = miette::Error; fn from_str(s: &str) -> Result { + if s.contains('\0') { + return Err(miette!("invalid key with a null byte: {s:?}")); + } + let mut modifiers = Modifiers::empty(); let mut split = s.split('+'); @@ -1115,4 +1119,10 @@ mod tests { }, ); } + + #[test] + fn null_byte() { + // Shouldn't panic. + let _ = "W\0".parse::(); + } } From 807681c13610911288abced946b7ed55d05b46c9 Mon Sep 17 00:00:00 2001 From: Ivan Molodetskikh Date: Sat, 24 Oct 2026 11:50:27 +0300 Subject: [PATCH 05/40] tests: Make keyboard enter print order-indepentent --- src/tests/client.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/tests/client.rs b/src/tests/client.rs index e6203cf3a6..d6624440d7 100644 --- a/src/tests/client.rs +++ b/src/tests/client.rs @@ -118,7 +118,7 @@ pub struct Keyboard { #[derive(Debug)] pub enum KeyboardEvent { Enter { - keys: Vec, + keys: Vec, }, Leave, Key { @@ -744,6 +744,10 @@ impl Dispatch for State { wl_keyboard::Event::Enter { surface, keys, .. } => { keyboard.surface = Some(surface.clone()); + // The order is arbitrary, but we need a fixed order for our tests. + let mut keys: Vec = bytemuck::pod_collect_to_vec(&keys); + keys.sort_unstable(); + let event = KeyboardEvent::Enter { keys }; let surface = state.surfaces.get_mut(&surface).unwrap(); surface.keyboard_events_received.push(event); From 600ec86217eacf5ec0024a521ed1fc35166476a5 Mon Sep 17 00:00:00 2001 From: Ivan Molodetskikh Date: Sun, 25 Oct 2026 09:32:39 +0300 Subject: [PATCH 06/40] config: Simplify and reconcile binds parsing - Use a Vec instead of a HashMap - Update the recent_windows version to match the normal one, printing the duplicate bind --- niri-config/src/binds.rs | 43 ++++++++++++++----------------- niri-config/src/recent_windows.rs | 32 +++++++++++++---------- 2 files changed, 38 insertions(+), 37 deletions(-) diff --git a/niri-config/src/binds.rs b/niri-config/src/binds.rs index 27eeee3e1d..724a36d44b 100644 --- a/niri-config/src/binds.rs +++ b/niri-config/src/binds.rs @@ -1,5 +1,3 @@ -use std::collections::hash_map::Entry; -use std::collections::HashMap; use std::str::FromStr; use std::time::Duration; @@ -779,9 +777,8 @@ where ) -> Result> { expect_only_children(node, ctx); - let mut seen_keys: HashMap> = HashMap::new(); - - let mut binds = Vec::new(); + let mut nodes: Vec<&knuffel::ast::SpannedNode> = Vec::new(); + let mut binds: Vec = Vec::new(); for child in node.children() { match Bind::decode_node(child, ctx) { @@ -789,26 +786,24 @@ where ctx.emit_error(e); } Ok(bind) => { - match seen_keys.entry(bind.key) { - Entry::Occupied(entry) => { - // Even though it's technically incorrect, we use - // `DecodeError::Missing` here because it labels the bind with - // "node starts here", which is the least bad option - ctx.emit_error(DecodeError::missing( - entry.get(), - "keybind first defined here", - )); + let conflict = binds.iter().position(|prev| prev.key == bind.key); + if let Some(idx) = conflict { + // Even though it's technically incorrect, we use + // `DecodeError::Missing` here because it labels the bind with + // "node starts here", which is the least bad option + ctx.emit_error(DecodeError::missing( + nodes[idx], + "keybind first defined here", + )); - ctx.emit_error(DecodeError::unexpected( - &child.node_name, - "keybind", - "duplicate keybind later defined here", - )); - } - Entry::Vacant(entry) => { - entry.insert(child); - binds.push(bind); - } + ctx.emit_error(DecodeError::unexpected( + &child.node_name, + "keybind", + "duplicate keybind later defined here", + )); + } else { + nodes.push(child); + binds.push(bind); } } } diff --git a/niri-config/src/recent_windows.rs b/niri-config/src/recent_windows.rs index 1846f03a77..62480a4099 100644 --- a/niri-config/src/recent_windows.rs +++ b/niri-config/src/recent_windows.rs @@ -1,5 +1,3 @@ -use std::collections::HashSet; - use knuffel::errors::DecodeError; use smithay::input::keyboard::Keysym; @@ -262,26 +260,34 @@ where ) -> Result> { expect_only_children(node, ctx); - let mut seen_keys = HashSet::new(); - - let mut binds = Vec::new(); + let mut nodes: Vec<&knuffel::ast::SpannedNode> = Vec::new(); + let mut binds: Vec = Vec::new(); for child in node.children() { match MruBind::decode_node(child, ctx) { + Err(e) => { + ctx.emit_error(e); + } Ok(bind) => { - if !seen_keys.insert(bind.key) { + let conflict = binds.iter().position(|prev| prev.key == bind.key); + if let Some(idx) = conflict { + // Even though it's technically incorrect, we use + // `DecodeError::Missing` here because it labels the bind with + // "node starts here", which is the least bad option + ctx.emit_error(DecodeError::missing( + nodes[idx], + "keybind first defined here", + )); + ctx.emit_error(DecodeError::unexpected( &child.node_name, "keybind", - "duplicate keybind", + "duplicate keybind later defined here", )); - continue; + } else { + nodes.push(child); + binds.push(bind); } - - binds.push(bind); - } - Err(e) => { - ctx.emit_error(e); } } } From 1b16c765db07b3be065e0086d1fd6db8925b9bb2 Mon Sep 17 00:00:00 2001 From: Justin Spedding Date: Tue, 22 Sep 2026 16:06:58 -0400 Subject: [PATCH 07/40] binds: allow bare modifier keys and Mod as bind triggers This intentionally introduces a new config parse error: binds { Alt_L { spawn "alacritty"; } Alt+Alt_L { spawn "alacritty"; } } This used to parse before (only one of the binds worked though). Now these are considered the same bind, so this config will error accordingly. --- docs/wiki/Configuration:-Key-Bindings.md | 21 +- docs/wiki/Configuration:-Recent-Windows.md | 18 +- niri-config/src/binds.rs | 287 ++++++++++++++++++++- niri-config/src/input.rs | 70 ++++- niri-config/src/lib.rs | 3 +- niri-config/src/recent_windows.rs | 19 +- src/ui/hotkey_overlay.rs | 135 ++++++++-- 7 files changed, 504 insertions(+), 49 deletions(-) diff --git a/docs/wiki/Configuration:-Key-Bindings.md b/docs/wiki/Configuration:-Key-Bindings.md index f8ee01888c..3a51b41056 100644 --- a/docs/wiki/Configuration:-Key-Bindings.md +++ b/docs/wiki/Configuration:-Key-Bindings.md @@ -15,7 +15,8 @@ binds { } ``` -The hotkey consists of modifiers separated by `+` signs, followed by an XKB key name in the end. +The hotkey consists of modifiers separated by `+` signs, followed by a trigger key in the end. +The trigger key can be either an XKB key name or Since: next release a modifier. Valid modifiers are: @@ -31,6 +32,24 @@ Valid modifiers are: This way, you can test niri in a window without causing too many conflicts with the host compositor's key bindings. For this reason, most of the default keys use the `Mod` modifier. +Specific left or right modifier keys like `Alt_L` or `Alt_R` can be used as triggers, but not as modifiers: + +```kdl +binds { + // Valid: + Ctrl+Alt_L { spawn "alacritty"; } + + // Invalid: + // Alt_L+Ctrl { spawn "alacritty"; } + + // Also, this is equivalent to just Alt_L + // because Alt_L implies the Alt modifier: + Alt+Alt_L { spawn "the Alt here is unnecessary but allowed"; } + // This is the same: + // Alt_L { spawn "alacritty"; } +} +``` + Since: 25.05 You can customize the `Mod` key [in the `input` section of the config](./Configuration:-Input.md#mod-key-mod-key-nested). > [!TIP] diff --git a/docs/wiki/Configuration:-Recent-Windows.md b/docs/wiki/Configuration:-Recent-Windows.md index 162abf2b22..d9d29a5a7a 100644 --- a/docs/wiki/Configuration:-Recent-Windows.md +++ b/docs/wiki/Configuration:-Recent-Windows.md @@ -167,7 +167,23 @@ recent-windows { The recent windows binds have lower precedence than the [normal binds](./Configuration:-Key-Bindings.md), meaning that if you have AltTab bound to something else in the normal binds, the `recent-windows` bind won't work. In this case, you can remove the conflicting normal bind. -All binds in this section must have a modifier key like Alt or Mod because the recent windows switcher remains open only while you hold any modifier key. +All binds in this section must have a modifier key like Alt or Mod (that isn't the same as the trigger key) because the recent windows switcher remains open only while you hold any modifier key. + +```kdl +recent-windows { + binds { + // Allowed: has a modifier, two keys. + Ctrl+Alt { next-window; } + + // Not allowed: only a single key. + // Alt { next-window; } + + // Not allowed: modifier matches the key + // (effectively only a single key). + // Alt+Alt_L { next-window; } + } +} +``` #### Bindings inside the switcher diff --git a/niri-config/src/binds.rs b/niri-config/src/binds.rs index 724a36d44b..3525a84f77 100644 --- a/niri-config/src/binds.rs +++ b/niri-config/src/binds.rs @@ -10,7 +10,9 @@ use niri_ipc::{ 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 tracing::warn; +use crate::input::ModKey; use crate::recent_windows::{MruDirection, MruFilter, MruScope}; use crate::utils::{expect_only_children, MergeWith}; @@ -37,6 +39,8 @@ pub struct Key { #[derive(Debug, PartialEq, Eq, Clone, Copy, Hash)] pub enum Trigger { Keysym(Keysym), + CompositorMod, + Modifier(ModKey), MouseLeft, MouseRight, MouseMiddle, @@ -55,6 +59,75 @@ pub enum Trigger { TabletStylusButton3, } +/// Returns whether `keysym` is a modifier key like `Shift_L`. +/// +/// This function matches what the niri config considers modifiers. +/// So, it does not correspond exactly to `keysym.is_modifier_key()`. +/// For example, unlike XKB, `Caps_Lock` and `ISO_Next_Group` aren't considered modifiers. +pub fn is_keysym_modifier(keysym: Keysym) -> bool { + ModKey::from_keysym(keysym).is_some() +} + +impl Trigger { + pub fn is_modifier(self) -> bool { + match self { + Self::CompositorMod | Self::Modifier(_) => true, + Self::Keysym(keysym) => is_keysym_modifier(keysym), + _ => false, + } + } + + /// The modifier flags that this trigger sets, if it is a modifier key. + pub fn implied_modifier(self) -> Modifiers { + match self { + Self::CompositorMod => Modifiers::COMPOSITOR, + Self::Modifier(modifier) => modifier.to_modifiers(), + Self::Keysym(keysym) => ModKey::from_keysym(keysym) + .map_or(Modifiers::empty(), |modifier| modifier.to_modifiers()), + _ => Modifiers::empty(), + } + } + + /// Whether the same key press can trigger both of these triggers. + fn overlaps(self, other: Self) -> bool { + match (self, other) { + (Self::Modifier(modifier), Self::Keysym(keysym)) + | (Self::Keysym(keysym), Self::Modifier(modifier)) => modifier.matches_keysym(keysym), + (a, b) => a == b, + } + } + + pub fn is_keyboard(self) -> bool { + matches!( + self, + Trigger::Keysym(_) | Trigger::CompositorMod | Trigger::Modifier(_) + ) + } +} + +impl Key { + /// Whether the two keys are triggered by the same key press. + /// + /// Two different keybinds can be triggered by the same key press. For example, a modifier key + /// can be spelled as the modifier itself (`Alt`) or as its keysym (`Alt_L`). + pub fn conflicts_with(&self, other: &Self) -> bool { + // The trigger and the modifiers match exactly. + // + // All non-modifier triggers fall into this category. + if *self == *other { + return true; + } + + // Modifiers include the trigger's implied modifier, so differing modifiers can never be + // triggered by the same key press. + if self.modifiers != other.modifiers { + return false; + } + + self.trigger.overlaps(other.trigger) + } +} + bitflags! { #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct Modifiers : u8 { @@ -786,7 +859,9 @@ where ctx.emit_error(e); } Ok(bind) => { - let conflict = binds.iter().position(|prev| prev.key == bind.key); + let conflict = binds + .iter() + .position(|prev| prev.key.conflicts_with(&bind.key)); if let Some(idx) = conflict { // Even though it's technically incorrect, we use // `DecodeError::Missing` here because it labels the bind with @@ -799,7 +874,11 @@ where ctx.emit_error(DecodeError::unexpected( &child.node_name, "keybind", - "duplicate keybind later defined here", + if binds[idx].key == bind.key { + "duplicate keybind later defined here" + } else { + "conflicting keybind later defined here" + }, )); } else { nodes.push(child); @@ -952,34 +1031,36 @@ impl FromStr for Key { return Err(miette!("invalid key with a null byte: {s:?}")); } - let mut modifiers = Modifiers::empty(); + let mut written_modifiers: Vec<(Modifiers, &str)> = Vec::new(); let mut split = s.split('+'); let key = split.next_back().unwrap(); for part in split { let part = part.trim(); - if part.eq_ignore_ascii_case("mod") { - modifiers |= Modifiers::COMPOSITOR + let modifier = if part.eq_ignore_ascii_case("mod") { + Modifiers::COMPOSITOR } else if part.eq_ignore_ascii_case("ctrl") || part.eq_ignore_ascii_case("control") { - modifiers |= Modifiers::CTRL; + Modifiers::CTRL } else if part.eq_ignore_ascii_case("shift") { - modifiers |= Modifiers::SHIFT; + Modifiers::SHIFT } else if part.eq_ignore_ascii_case("alt") { - modifiers |= Modifiers::ALT; + Modifiers::ALT } else if part.eq_ignore_ascii_case("super") || part.eq_ignore_ascii_case("win") { - modifiers |= Modifiers::SUPER; + Modifiers::SUPER } else if part.eq_ignore_ascii_case("iso_level3_shift") || part.eq_ignore_ascii_case("mod5") { - modifiers |= Modifiers::ISO_LEVEL3_SHIFT; + Modifiers::ISO_LEVEL3_SHIFT } else if part.eq_ignore_ascii_case("iso_level5_shift") || part.eq_ignore_ascii_case("mod3") { - modifiers |= Modifiers::ISO_LEVEL5_SHIFT; + Modifiers::ISO_LEVEL5_SHIFT } else { return Err(miette!("invalid modifier: {part}")); - } + }; + + written_modifiers.push((modifier, part)); } let trigger = if key.eq_ignore_ascii_case("MouseLeft") { @@ -1014,6 +1095,20 @@ impl FromStr for Key { Trigger::TabletStylusButton2 } else if key.eq_ignore_ascii_case("TabletStylusButton3") { Trigger::TabletStylusButton3 + } else if key.eq_ignore_ascii_case("Mod") { + Trigger::CompositorMod + } else if key.eq_ignore_ascii_case("Ctrl") || key.eq_ignore_ascii_case("Control") { + Trigger::Modifier(ModKey::Ctrl) + } else if key.eq_ignore_ascii_case("Shift") { + Trigger::Modifier(ModKey::Shift) + } else if key.eq_ignore_ascii_case("Alt") { + Trigger::Modifier(ModKey::Alt) + } else if key.eq_ignore_ascii_case("Super") || key.eq_ignore_ascii_case("Win") { + Trigger::Modifier(ModKey::Super) + } else if key.eq_ignore_ascii_case("ISO_Level3_Shift") || key.eq_ignore_ascii_case("Mod5") { + Trigger::Modifier(ModKey::IsoLevel3Shift) + } else if key.eq_ignore_ascii_case("ISO_Level5_Shift") || key.eq_ignore_ascii_case("Mod3") { + Trigger::Modifier(ModKey::IsoLevel5Shift) } else { let mut keysym = keysym_from_name(key, KEYSYM_CASE_INSENSITIVE); // The keyboard event handling code can receive either @@ -1049,6 +1144,15 @@ impl FromStr for Key { Trigger::Keysym(keysym) }; + let mut modifiers = trigger.implied_modifier(); + for (modifier, spelling) in written_modifiers { + if modifiers.contains(modifier) { + warn!("redundant modifier `{spelling}` in keybind `{s}`"); + } + + modifiers |= modifier; + } + Ok(Key { trigger, modifiers }) } } @@ -1115,6 +1219,165 @@ mod tests { ); } + #[test] + fn parse_bare_modifier_spellings() { + // Each modifier can be spelled with its canonical name or an alias. A bare modifier key + // parses to `Trigger::Modifier`. + for (text, modifier) in [ + ("Ctrl", ModKey::Ctrl), + ("Control", ModKey::Ctrl), + ("Shift", ModKey::Shift), + ("Alt", ModKey::Alt), + ("Super", ModKey::Super), + ("Win", ModKey::Super), + ("Mod5", ModKey::IsoLevel3Shift), + ("ISO_Level3_Shift", ModKey::IsoLevel3Shift), + ("Mod3", ModKey::IsoLevel5Shift), + ("ISO_Level5_Shift", ModKey::IsoLevel5Shift), + ] { + assert_eq!( + text.parse::().unwrap(), + Key { + trigger: Trigger::Modifier(modifier), + modifiers: modifier.to_modifiers(), + }, + "parsing `{text}`", + ); + } + } + + #[test] + fn parse_mod_as_trigger() { + // The mod key keeps its own trigger, which is resolved to a real modifier at runtime. + assert_eq!( + "Mod".parse::().unwrap(), + Key { + trigger: Trigger::CompositorMod, + modifiers: Modifiers::COMPOSITOR, + }, + ); + + // It can also be used together with other held modifiers. + assert_eq!( + "Ctrl+Mod".parse::().unwrap(), + Key { + trigger: Trigger::CompositorMod, + modifiers: Modifiers::CTRL | Modifiers::COMPOSITOR, + }, + ); + + // `Mod` also works as a held modifier of another trigger. + assert_eq!( + "Mod+Control_L".parse::().unwrap(), + Key { + trigger: Trigger::Keysym(Keysym::Control_L), + modifiers: Modifiers::COMPOSITOR | Modifiers::CTRL, + }, + ); + } + + #[test] + fn parse_multi_modifier_triggers() { + // A modifier key as the trigger with another held modifier, either spelled as the modifier + // itself or as its keysym. + assert_eq!( + "Ctrl+Alt".parse::().unwrap(), + Key { + trigger: Trigger::Modifier(ModKey::Alt), + modifiers: Modifiers::CTRL | Modifiers::ALT, + }, + ); + assert_eq!( + "Ctrl+Alt_L".parse::().unwrap(), + Key { + trigger: Trigger::Keysym(Keysym::Alt_L), + modifiers: Modifiers::CTRL | Modifiers::ALT, + }, + ); + } + + #[test] + fn parse_normalizes_redundant_modifiers() { + let key = |text: &str| text.parse::().unwrap(); + + // Modifier triggers can be written with or without the redundant modifier. + assert_eq!(key("Alt+Alt_L"), key("Alt_L")); + assert_eq!(key("Alt+Alt_R"), key("Alt_R")); + assert_eq!(key("Alt+Alt"), key("Alt")); + assert_eq!(key("Alt+Alt+Alt_L"), key("Alt_L")); + assert_eq!(key("Ctrl+Shift+Control_L"), key("Shift+Control_L")); + assert_eq!(key("Mod+Mod"), key("Mod")); + + // Duplicate modifiers are fine with non-modifier triggers. + assert_eq!(key("Alt+Alt+Q"), key("Alt+Q")); + assert_eq!(key("Mod+Mod+Q"), key("Mod+Q")); + + // Still works if spelled differently. + assert_eq!(key("Ctrl+Control+Q"), key("Ctrl+Q")); + assert_eq!(key("Super+Win+Q"), key("Super+Q")); + } + + #[test] + fn parse_modifier_trigger_includes_implied_modifier() { + // The modifier that the trigger implies is part of the key. + assert_eq!( + "Alt_L".parse::().unwrap(), + Key { + trigger: Trigger::Keysym(Keysym::Alt_L), + modifiers: Modifiers::ALT, + }, + ); + assert_eq!( + "Alt+Alt".parse::().unwrap(), + Key { + trigger: Trigger::Modifier(ModKey::Alt), + modifiers: Modifiers::ALT, + }, + ); + // `Mod` is only resolved at runtime, so no trigger implies it. + assert_eq!( + "Alt+Mod".parse::().unwrap(), + Key { + trigger: Trigger::CompositorMod, + modifiers: Modifiers::ALT | Modifiers::COMPOSITOR, + }, + ); + } + + #[test] + fn key_conflicts() { + let key = |text: &str| text.parse::().unwrap(); + + // Alt_L and Alt_R conflict with Alt, and vice versa. + let alt = key("Alt"); + let alt_l = key("Alt_L"); + let alt_r = key("Alt_R"); + assert!(alt.conflicts_with(&alt_l)); + assert!(alt.conflicts_with(&alt_r)); + assert!(alt_l.conflicts_with(&alt)); + assert!(alt_r.conflicts_with(&alt)); + + // The left and right keysyms are different keys. + assert!(!alt_l.conflicts_with(&alt_r)); + + // Different modifiers mean different binds, even for the same trigger. + assert!(!alt.conflicts_with(&key("Ctrl+Alt_L"))); + + // Non-overlapping triggers don't conflict, even with the same modifiers. + assert!(!key("Mod+Q").conflicts_with(&key("Mod+L"))); + assert!(!key("Q").conflicts_with(&key("Ctrl"))); + + // Identical keys are duplicates. + assert!(key("Q").conflicts_with(&key("Q"))); + assert!(alt.conflicts_with(&alt)); + + // The compositor mod key is only resolved at runtime, so `Mod` isn't compared against the + // mod key's own spellings. + let compositor_mod = key("Mod"); + assert!(!compositor_mod.conflicts_with(&key("Super_L"))); + assert!(!compositor_mod.conflicts_with(&key("Super"))); + } + #[test] fn null_byte() { // Shouldn't panic. diff --git a/niri-config/src/input.rs b/niri-config/src/input.rs index 78e3ed21f1..0ac0821ff9 100644 --- a/niri-config/src/input.rs +++ b/niri-config/src/input.rs @@ -1,7 +1,7 @@ use std::str::FromStr; use miette::miette; -use smithay::input::keyboard::XkbConfig; +use smithay::input::keyboard::{keysyms, Keysym, XkbConfig}; use smithay::reexports::input; use crate::binds::Modifiers; @@ -415,7 +415,7 @@ impl FromStr for WarpMouseToFocusMode { } } -#[derive(Debug, PartialEq, Eq, Clone, Copy)] +#[derive(Debug, PartialEq, Eq, Clone, Copy, Hash)] pub enum ModKey { Ctrl, Shift, @@ -436,6 +436,23 @@ impl ModKey { ModKey::IsoLevel5Shift => Modifiers::ISO_LEVEL5_SHIFT, } } + + /// Returns the modifier key for a keysym, if the keysym is a modifier key. + pub fn from_keysym(keysym: Keysym) -> Option { + match keysym.raw() { + keysyms::KEY_Control_L | keysyms::KEY_Control_R => Some(Self::Ctrl), + keysyms::KEY_Shift_L | keysyms::KEY_Shift_R => Some(Self::Shift), + keysyms::KEY_Alt_L | keysyms::KEY_Alt_R => Some(Self::Alt), + keysyms::KEY_Super_L | keysyms::KEY_Super_R => Some(Self::Super), + keysyms::KEY_ISO_Level3_Shift => Some(Self::IsoLevel3Shift), + keysyms::KEY_ISO_Level5_Shift => Some(Self::IsoLevel5Shift), + _ => None, + } + } + + pub fn matches_keysym(self, keysym: Keysym) -> bool { + Self::from_keysym(keysym) == Some(self) + } } impl FromStr for ModKey { @@ -526,6 +543,55 @@ mod tests { Input::from_part(&part) } + #[test] + fn mod_key_names_are_consistent() { + // The X11 `ModN` modifier indices are a separate numbering from the ISO level numbers, + // and the two happen to be crossed over: ISO Level 3 Shift is Mod5, and ISO Level 5 Shift + // is Mod3. This test guards against "fixing" this apparent mismatch. + for (name, modifier, keysym, modifiers) in [ + ( + "Mod5", + ModKey::IsoLevel3Shift, + keysyms::KEY_ISO_Level3_Shift, + Modifiers::ISO_LEVEL3_SHIFT, + ), + ( + "Mod3", + ModKey::IsoLevel5Shift, + keysyms::KEY_ISO_Level5_Shift, + Modifiers::ISO_LEVEL5_SHIFT, + ), + ] { + // Name -> variant. + assert_eq!( + name.parse::().unwrap(), + modifier, + "parsing `{name}`" + ); + + // Keysym -> variant. + assert_eq!( + ModKey::from_keysym(Keysym::from(keysym)), + Some(modifier), + "keysym for `{name}`", + ); + assert!(modifier.matches_keysym(Keysym::from(keysym))); + + // Variant -> held modifiers. + assert_eq!(modifier.to_modifiers(), modifiers, "modifiers for `{name}`"); + } + + // ISO_Level3_Shift and Mod5 are the same modifier, and so are ISO_Level5_Shift and Mod3. + assert_eq!( + "ISO_Level3_Shift".parse::().unwrap(), + "Mod5".parse::().unwrap() + ); + assert_eq!( + "ISO_Level5_Shift".parse::().unwrap(), + "Mod3".parse::().unwrap() + ); + } + #[test] fn parse_scroll_factor_combined() { // Test combined scroll-factor syntax diff --git a/niri-config/src/lib.rs b/niri-config/src/lib.rs index b8989e8d90..6066ebc780 100644 --- a/niri-config/src/lib.rs +++ b/niri-config/src/lib.rs @@ -229,7 +229,8 @@ where let mut config = config.borrow_mut(); let binds = &mut config.binds.0; // Remove existing binds matching any new bind. - binds.retain(|bind| !part.0.iter().any(|new| new.key == bind.key)); + binds + .retain(|bind| !part.0.iter().any(|new| new.key.conflicts_with(&bind.key))); // Add all new binds. binds.extend(part.0); } diff --git a/niri-config/src/recent_windows.rs b/niri-config/src/recent_windows.rs index 62480a4099..ba0710cc79 100644 --- a/niri-config/src/recent_windows.rs +++ b/niri-config/src/recent_windows.rs @@ -58,7 +58,7 @@ impl MergeWith for RecentWindows { if let Some(part) = &part.binds { // Remove existing binds matching any new bind. self.binds - .retain(|bind| !part.0.iter().any(|new| new.key == bind.key)); + .retain(|bind| !part.0.iter().any(|new| new.key.conflicts_with(&bind.key))); // Add all new binds. self.binds.extend(part.0.iter().cloned().map(Bind::from)); } @@ -269,7 +269,9 @@ where ctx.emit_error(e); } Ok(bind) => { - let conflict = binds.iter().position(|prev| prev.key == bind.key); + let conflict = binds + .iter() + .position(|prev| prev.key.conflicts_with(&bind.key)); if let Some(idx) = conflict { // Even though it's technically incorrect, we use // `DecodeError::Missing` here because it labels the bind with @@ -282,7 +284,11 @@ where ctx.emit_error(DecodeError::unexpected( &child.node_name, "keybind", - "duplicate keybind later defined here", + if binds[idx].key == bind.key { + "duplicate keybind later defined here" + } else { + "conflicting keybind later defined here" + }, )); } else { nodes.push(child); @@ -325,8 +331,11 @@ where .parse::() .map_err(|e| DecodeError::conversion(&node.node_name, e.wrap_err("invalid keybind")))?; + let mut modifiers = key.modifiers; + modifiers &= !key.trigger.implied_modifier(); + // A modifier is required because MRU remains on screen as long as any modifier is held. - if key.modifiers.is_empty() { + if modifiers.is_empty() { ctx.emit_error(DecodeError::unexpected( &node.node_name, "keybind", @@ -336,7 +345,7 @@ where // FIXME: To support this, all the mods_with_mouse_binds()/mods_with_*wheel_binds()/etc. // will need to learn about recent-windows bindings. - if !matches!(key.trigger, Trigger::Keysym(_)) { + if !key.trigger.is_keyboard() { ctx.emit_error(DecodeError::unexpected( &node.node_name, "key", diff --git a/src/ui/hotkey_overlay.rs b/src/ui/hotkey_overlay.rs index 34bc39559a..f95518d003 100644 --- a/src/ui/hotkey_overlay.rs +++ b/src/ui/hotkey_overlay.rs @@ -496,50 +496,37 @@ fn action_name(action: &Action) -> String { fn key_name(screen_reader: bool, mod_key: ModKey, key: &Key) -> String { let mut name = String::new(); - let has_comp_mod = key.modifiers.contains(Modifiers::COMPOSITOR); + // Do not show modifiers implied by the trigger. + let modifiers = key.modifiers & !key.trigger.implied_modifier(); + + let has_comp_mod = modifiers.contains(Modifiers::COMPOSITOR); + + let mod_key_pretty = modifier_name(mod_key); // Compositor mod goes first. if has_comp_mod { - match mod_key { - ModKey::Super => { - name.push_str("Super + "); - } - ModKey::Alt => { - name.push_str("Alt + "); - } - ModKey::Shift => { - name.push_str("Shift + "); - } - ModKey::Ctrl => { - name.push_str("Ctrl + "); - } - ModKey::IsoLevel3Shift => { - name.push_str("Mod5 + "); - } - ModKey::IsoLevel5Shift => { - name.push_str("Mod3 + "); - } - } + name.push_str(mod_key_pretty); + name.push_str(" + "); } - if key.modifiers.contains(Modifiers::SUPER) && !(has_comp_mod && mod_key == ModKey::Super) { + if modifiers.contains(Modifiers::SUPER) && !(has_comp_mod && mod_key == ModKey::Super) { name.push_str("Super + "); } - if key.modifiers.contains(Modifiers::CTRL) && !(has_comp_mod && mod_key == ModKey::Ctrl) { + if modifiers.contains(Modifiers::CTRL) && !(has_comp_mod && mod_key == ModKey::Ctrl) { name.push_str("Ctrl + "); } - if key.modifiers.contains(Modifiers::SHIFT) && !(has_comp_mod && mod_key == ModKey::Shift) { + if modifiers.contains(Modifiers::SHIFT) && !(has_comp_mod && mod_key == ModKey::Shift) { name.push_str("Shift + "); } - if key.modifiers.contains(Modifiers::ALT) && !(has_comp_mod && mod_key == ModKey::Alt) { + if modifiers.contains(Modifiers::ALT) && !(has_comp_mod && mod_key == ModKey::Alt) { name.push_str("Alt + "); } - if key.modifiers.contains(Modifiers::ISO_LEVEL3_SHIFT) + if modifiers.contains(Modifiers::ISO_LEVEL3_SHIFT) && !(has_comp_mod && mod_key == ModKey::IsoLevel3Shift) { name.push_str("Mod5 + "); } - if key.modifiers.contains(Modifiers::ISO_LEVEL5_SHIFT) + if modifiers.contains(Modifiers::ISO_LEVEL5_SHIFT) && !(has_comp_mod && mod_key == ModKey::IsoLevel5Shift) { name.push_str("Mod3 + "); @@ -547,6 +534,8 @@ fn key_name(screen_reader: bool, mod_key: ModKey, key: &Key) -> String { let pretty = match key.trigger { Trigger::Keysym(keysym) => prettify_keysym_name(screen_reader, &keysym_get_name(keysym)), + Trigger::CompositorMod => mod_key_pretty.into(), + Trigger::Modifier(modifier) => modifier_name(modifier).into(), Trigger::MouseLeft => String::from("Mouse Left"), Trigger::MouseRight => String::from("Mouse Right"), Trigger::MouseMiddle => String::from("Mouse Middle"), @@ -569,6 +558,17 @@ fn key_name(screen_reader: bool, mod_key: ModKey, key: &Key) -> String { name } +fn modifier_name(modifier: ModKey) -> &'static str { + match modifier { + ModKey::Super => "Super", + ModKey::Alt => "Alt", + ModKey::Shift => "Shift", + ModKey::Ctrl => "Ctrl", + ModKey::IsoLevel3Shift => "Mod5", + ModKey::IsoLevel5Shift => "Mod3", + } +} + fn prettify_keysym_name(screen_reader: bool, name: &str) -> String { let name = if screen_reader { name @@ -632,6 +632,87 @@ mod tests { // Not bound. assert_snapshot!(check("", Action::Screenshot(true, None)), @" (not bound) : Take a Screenshot"); + // Bare modifier binds. + assert_snapshot!( + check( + r#"binds { + Alt { close-window; } + }"#, + Action::CloseWindow, + ), + @" Alt : Close Focused Window" + ); + assert_snapshot!( + check( + r#"binds { + Ctrl { close-window; } + Shift { close-window; } + }"#, + Action::CloseWindow, + ), + @" Ctrl : Close Focused Window" + ); + assert_snapshot!( + check( + r#"binds { + Mod5 { close-window; } + Mod3 { close-window; } + }"#, + Action::CloseWindow, + ), + @" Mod5 : Close Focused Window" + ); + assert_snapshot!( + check( + r#"binds { + Super { close-window; } + }"#, + Action::CloseWindow, + ), + @" Super : Close Focused Window" + ); + + // Modifier + modifier binds: a held modifier key with another modifier key as the trigger. + assert_snapshot!( + check( + r#"binds { + Ctrl+Alt_L { close-window; } + }"#, + Action::CloseWindow, + ), + @" Ctrl + Alt_L : Close Focused Window" + ); + assert_snapshot!( + check( + r#"binds { + Alt+Ctrl { close-window; } + }"#, + Action::CloseWindow, + ), + @" Alt + Ctrl : Close Focused Window" + ); + + // A Ctrl keysym names only one side of the keyboard, so it keeps its exact name instead of + // being shortened to `Ctrl`. + assert_snapshot!( + check( + r#"binds { + Control_L { close-window; } + }"#, + Action::CloseWindow, + ), + @" Control_L : Close Focused Window" + ); + assert_snapshot!( + check( + r#"binds { + Mod+Control_R { close-window; } + }"#, + Action::CloseWindow, + ), + @" Super + Control_R : Close Focused Window" + ); + // Bound with a default title. assert_snapshot!( check( From fc3d54d9dcb62ccf781c793cc21faa5341c062aa Mon Sep 17 00:00:00 2001 From: Justin Spedding Date: Wed, 23 Sep 2026 19:45:58 -0400 Subject: [PATCH 08/40] input: use an enum for the keyboard intercept result --- src/input/mod.rs | 75 +++++++++++++++++++++++++++++++----------------- 1 file changed, 49 insertions(+), 26 deletions(-) diff --git a/src/input/mod.rs b/src/input/mod.rs index 10c1d02c2d..8129ec6928 100644 --- a/src/input/mod.rs +++ b/src/input/mod.rs @@ -19,9 +19,7 @@ use smithay::backend::input::{ }; use smithay::backend::libinput::LibinputInputBackend; use smithay::input::dnd::DnDGrab; -use smithay::input::keyboard::{ - keysyms, FilterResult, KeyboardSource, Keysym, Layout, ModifiersState, -}; +use smithay::input::keyboard::{keysyms, KeyboardSource, Keysym, Layout, ModifiersState}; use smithay::input::pointer::{ AxisFrame, ButtonEvent, CursorIcon, CursorImageStatus, Focus, GestureHoldBeginEvent, GestureHoldEndEvent, GesturePinchBeginEvent, GesturePinchEndEvent, GesturePinchUpdateEvent, @@ -134,6 +132,17 @@ impl AnyStartData { } } +/// What to do with a key event that the keyboard filter looked at. +#[derive(Debug)] +enum ShouldInterceptResult { + /// Forward the event to the focused client. + Forward, + /// Do not forward the event, and handle the given bind. + InterceptAndHandle(Bind), + /// Do not forward the event, and do nothing else. + InterceptOnly, +} + impl State { pub fn process_input_event(&mut self, event: InputEvent) where @@ -490,13 +499,15 @@ impl State { #[cfg(not(feature = "dbus"))] let _ = consumed_by_a11y; - let Some(Some(bind)) = self.niri.seat.get_keyboard().unwrap().input_from_source( + // We can't use smithay's `input` here because we want to forward _and_ handle + // events for modifier keys. This lets us do things like bind Control_L to an + // action without confusing applications about its state. + let keyboard = self.niri.seat.get_keyboard().unwrap(); + let Some((result, mods_changed)) = keyboard.input_intercept( source, self, event.key_code(), event.state(), - serial, - time, |this, mods, keysym| { let key_code = event.key_code(); let modified = keysym.modified_sym(); @@ -535,9 +546,9 @@ impl State { | Keysym::Alt_R ) { - return FilterResult::Forward; + return ShouldInterceptResult::Forward; } else { - return FilterResult::Intercept(None); + return ShouldInterceptResult::InterceptOnly; } } @@ -549,7 +560,7 @@ impl State { // Don't send this press to any clients. this.niri.suppressed_keys.insert(key_code); - return FilterResult::Intercept(None); + return ShouldInterceptResult::InterceptOnly; } // Check if all modifiers were released while the MRU UI was open. If so, close the @@ -558,9 +569,9 @@ impl State { this.do_action(Action::MruConfirm, false); if this.niri.suppressed_keys.remove(&key_code) { - return FilterResult::Intercept(None); + return ShouldInterceptResult::InterceptOnly; } else { - return FilterResult::Forward; + return ShouldInterceptResult::Forward; } } @@ -573,7 +584,7 @@ impl State { { pointer.unset_grab(this, serial, time); this.niri.suppressed_keys.insert(key_code); - return FilterResult::Intercept(None); + return ShouldInterceptResult::InterceptOnly; } } @@ -601,13 +612,13 @@ impl State { ) }; - if matches!(res, FilterResult::Forward) { + if matches!(res, ShouldInterceptResult::Forward) { // If we didn't find any bind, try other hardcoded keys. if this.niri.keyboard_focus.is_overview() && pressed { if let Some(bind) = raw.and_then(|raw| hardcoded_overview_bind(raw, *mods)) { this.niri.suppressed_keys.insert(key_code); - return FilterResult::Intercept(Some(bind)); + return ShouldInterceptResult::InterceptAndHandle(bind); } } @@ -622,13 +633,25 @@ impl State { return; }; - if !pressed { - return; + match result { + ShouldInterceptResult::Forward => { + keyboard.input_forward( + self, + event.key_code(), + event.state(), + serial, + time, + mods_changed, + ); + } + ShouldInterceptResult::InterceptAndHandle(bind) => { + if pressed { + self.handle_bind(bind.clone()); + self.start_key_repeat(bind); + } + } + ShouldInterceptResult::InterceptOnly => {} } - - self.handle_bind(bind.clone()); - - self.start_key_repeat(bind); } fn start_key_repeat(&mut self, bind: Bind) { @@ -4610,12 +4633,12 @@ fn should_intercept_key<'a>( screenshot_ui: &ScreenshotUi, disable_power_key_handling: bool, is_inhibiting_shortcuts: bool, -) -> FilterResult> { +) -> ShouldInterceptResult { // Actions are only triggered on presses, release of the key // shouldn't try to intercept anything unless we have marked // the key to suppress. if !pressed && !suppressed_keys.contains(&key_code) { - return FilterResult::Forward; + return ShouldInterceptResult::Forward; } let mut final_bind = find_bind( @@ -4663,10 +4686,10 @@ fn should_intercept_key<'a>( match (final_bind, pressed) { (Some(bind), true) => { if is_inhibiting_shortcuts && bind.allow_inhibiting { - FilterResult::Forward + ShouldInterceptResult::Forward } else { suppressed_keys.insert(key_code); - FilterResult::Intercept(Some(bind)) + ShouldInterceptResult::InterceptAndHandle(bind) } } (_, false) => { @@ -4676,9 +4699,9 @@ fn should_intercept_key<'a>( // if it was inhibited on press (forwarded to the client), it wouldn't be suppressed, // so the release would already have been forwarded at the start of this function. suppressed_keys.remove(&key_code); - FilterResult::Intercept(None) + ShouldInterceptResult::InterceptOnly } - (None, true) => FilterResult::Forward, + (None, true) => ShouldInterceptResult::Forward, } } From 5331880570ba5ad1d21997995fd018eb7b32cbdc Mon Sep 17 00:00:00 2001 From: Justin Spedding Date: Wed, 23 Sep 2026 19:45:58 -0400 Subject: [PATCH 09/40] input: support press and release bind actions There's a small behavior change: pressing a bind that is on a cooldown and has repeat=true won't start the key repeat (it used to). The reason is that for Press+Release keys it would make it annoying to track when the release bind should fire (only after the first repeated key rather than currently when it's recorded on press). --- niri-config/src/binds.rs | 258 ++++++++++--- niri-config/src/lib.rs | 174 ++++++--- niri-config/src/recent_windows.rs | 4 +- src/backend/tty.rs | 8 + src/input/mod.rs | 578 ++++++++++++++++++++++-------- src/niri.rs | 16 +- src/tests/binds.rs | 11 +- src/ui/hotkey_overlay.rs | 220 ++++++++++-- src/ui/mru.rs | 14 +- 9 files changed, 1000 insertions(+), 283 deletions(-) diff --git a/niri-config/src/binds.rs b/niri-config/src/binds.rs index 3525a84f77..4a715f6869 100644 --- a/niri-config/src/binds.rs +++ b/niri-config/src/binds.rs @@ -22,7 +22,7 @@ pub struct Binds(pub Vec); #[derive(Debug, Clone, PartialEq)] pub struct Bind { pub key: Key, - pub action: Action, + pub action: BoundAction, pub repeat: bool, pub cooldown: Option, pub allow_when_locked: bool, @@ -30,6 +30,45 @@ pub struct Bind { pub hotkey_overlay_title: Option>, } +#[derive(Debug, Clone, PartialEq)] +pub enum BoundAction { + Press(Action), + Release(Action), + Both { press: Action, release: Action }, +} + +impl Bind { + pub fn has_press(&self) -> bool { + matches!( + self.action, + BoundAction::Press(_) | BoundAction::Both { .. } + ) + } + + pub fn has_release(&self) -> bool { + matches!( + self.action, + BoundAction::Release(_) | BoundAction::Both { .. } + ) + } + + pub fn press_action(&self) -> Option<&Action> { + match &self.action { + BoundAction::Press(action) => Some(action), + BoundAction::Both { press, .. } => Some(press), + BoundAction::Release(_) => None, + } + } + + pub fn release_action(&self) -> Option<&Action> { + match &self.action { + BoundAction::Release(action) => Some(action), + BoundAction::Both { release, .. } => Some(release), + BoundAction::Press(_) => None, + } + } +} + #[derive(Debug, PartialEq, Eq, Clone, Copy, Hash)] pub struct Key { pub trigger: Trigger, @@ -921,16 +960,19 @@ where .parse::() .map_err(|e| DecodeError::conversion(&node.node_name, e.wrap_err("invalid keybind")))?; - let mut repeat = true; + let mut repeat = None; + let mut repeat_node = None; let mut cooldown = None; let mut allow_when_locked = false; let mut allow_when_locked_node = None; let mut allow_inhibiting = true; let mut hotkey_overlay_title = None; + for (name, val) in &node.properties { match &***name { "repeat" => { - repeat = knuffel::traits::DecodeScalar::decode(val, ctx)?; + repeat = Some(knuffel::traits::DecodeScalar::decode(val, ctx)?); + repeat_node = Some(name); } "cooldown-ms" => { cooldown = Some(Duration::from_millis( @@ -957,14 +999,12 @@ where } } - let mut children = node.children(); - // If the action is invalid but the key is fine, we still want to return something. // That way, the parent can handle the existence of duplicate keybinds, // even if their contents are not valid. let dummy = Self { key, - action: Action::Spawn(vec![]), + action: BoundAction::Press(Action::Spawn(vec![])), repeat: true, cooldown: None, allow_when_locked: false, @@ -972,54 +1012,182 @@ where hotkey_overlay_title: None, }; - if let Some(child) = children.next() { - for unwanted_child in children { - ctx.emit_error(DecodeError::unexpected( - unwanted_child, - "node", - "only one action is allowed per keybind", - )); - } + // direct_action is an action node that is not wrapped in a press or release section + let mut direct_action: Option = None; + let mut press_action: Option = None; + let mut release_action: Option = None; + + let decode_action = |ctx: &mut knuffel::decode::Context, + child: &knuffel::ast::SpannedNode| { match Action::decode_node(child, ctx) { - Ok(action) => { - if !matches!(action, Action::Spawn(_) | Action::SpawnSh(_)) { - if let Some(node) = allow_when_locked_node { - ctx.emit_error(DecodeError::unexpected( - node, - "property", - "allow-when-locked can only be set on spawn binds", - )); - } - } + Ok(action) => Some(action), + Err(e) => { + ctx.emit_error(e); + None + } + } + }; - // The toggle-inhibit action must always be uninhibitable. - // Otherwise, it would be impossible to trigger it. - if matches!(action, Action::ToggleKeyboardShortcutsInhibit) { - allow_inhibiting = false; - } + for child in node.children() { + let child_name = &*child.node_name; + let child_name: &str = child_name.as_ref(); + + if child_name == "press" || child_name == "release" { + let is_press = child_name == "press"; + let slot = if is_press { + &mut press_action + } else { + &mut release_action + }; + + if slot.is_some() { + ctx.emit_error(DecodeError::unexpected( + &child.node_name, + "section", + format!("duplicate `{child_name}` section"), + )); + continue; + } - Ok(Self { - key, - action, - repeat, - cooldown, - allow_when_locked, - allow_inhibiting, - hotkey_overlay_title, - }) + if direct_action.is_some() { + ctx.emit_error(DecodeError::unexpected( + &child.node_name, + "section", + "cannot mix direct actions with press/release sections", + )); + continue; } - Err(e) => { - ctx.emit_error(e); - Ok(dummy) + + // The action is the section's single child node. + let mut children = child.children(); + let Some(action_child) = children.next() else { + ctx.emit_error(DecodeError::missing( + child, + format!("expected an action for this {child_name} section"), + )); + continue; + }; + + for unwanted_child in children { + ctx.emit_error(DecodeError::unexpected( + unwanted_child, + "node", + "only one action is allowed per keybind", + )); + } + + *slot = decode_action(ctx, action_child); + } else { + if direct_action.is_some() { + ctx.emit_error(DecodeError::unexpected( + &child.node_name, + "node", + "only one action is allowed per keybind", + )); + continue; + } + + if press_action.is_some() || release_action.is_some() { + ctx.emit_error(DecodeError::unexpected( + &child.node_name, + "node", + "cannot mix direct actions with press/release sections", + )); + continue; } + + direct_action = decode_action(ctx, child); } - } else { - ctx.emit_error(DecodeError::missing( + } + + let press_action = press_action.or(direct_action); + + if release_action.is_some() + && matches!( + key.trigger, + Trigger::WheelScrollDown + | Trigger::WheelScrollUp + | Trigger::WheelScrollLeft + | Trigger::WheelScrollRight + | Trigger::TouchpadScrollDown + | Trigger::TouchpadScrollUp + | Trigger::TouchpadScrollLeft + | Trigger::TouchpadScrollRight + ) + { + ctx.emit_error(DecodeError::unexpected( node, - "expected an action for this keybind", + "bind", + "release sections are not supported for scroll binds", )); - Ok(dummy) + release_action = None; + } + + if let Some(node) = repeat_node { + if repeat == Some(true) && press_action.is_none() && release_action.is_some() { + ctx.emit_error(DecodeError::unexpected( + node, + "property", + "release-only binds cannot repeat", + )); + } + } + + if let Some(node) = allow_when_locked_node { + for action in press_action.iter().chain(release_action.iter()) { + if !matches!(action, Action::Spawn(_) | Action::SpawnSh(_)) { + ctx.emit_error(DecodeError::unexpected( + node, + "property", + "allow-when-locked can only be set on spawn binds", + )); + } + } + } + + // The toggle-inhibit action must always be uninhibitable. + // Otherwise, it would be impossible to trigger it. + if matches!(press_action, Some(Action::ToggleKeyboardShortcutsInhibit)) + || matches!(release_action, Some(Action::ToggleKeyboardShortcutsInhibit)) + { + allow_inhibiting = false; } + + let repeat = match repeat { + Some(value) => value, + None => release_action.is_none(), + }; + + let action = match (press_action, release_action) { + (Some(press), Some(release)) => BoundAction::Both { press, release }, + (Some(press), None) => BoundAction::Press(press), + (None, Some(release)) => BoundAction::Release(release), + (None, None) => { + // If a press or release section was present, an error about its missing or + // invalid action was already emitted above. + let has_section = node.children().any(|child| { + let name = child.node_name.as_ref(); + name == "press" || name == "release" + }); + if !has_section { + ctx.emit_error(DecodeError::missing( + node, + "expected an action for this keybind", + )); + } + return Ok(dummy); + } + }; + + Ok(Self { + key, + action, + repeat, + cooldown, + allow_when_locked, + allow_inhibiting, + hotkey_overlay_title, + }) } } diff --git a/niri-config/src/lib.rs b/niri-config/src/lib.rs index 6066ebc780..40eccb556b 100644 --- a/niri-config/src/lib.rs +++ b/niri-config/src/lib.rs @@ -950,6 +950,12 @@ mod tests { Mod+Shift+E allow-inhibiting=false { quit skip-confirmation=true; } Mod+WheelScrollDown cooldown-ms=150 { focus-workspace-down; } Super+Alt+S allow-when-locked=true { spawn-sh "pkill orca || exec orca"; } + Mod { + release { toggle-overview; } + } + Shift+Mod { + release { toggle-window-floating; } + } } switch-events { @@ -2006,7 +2012,9 @@ mod tests { COMPOSITOR, ), }, - action: ToggleKeyboardShortcutsInhibit, + action: Press( + ToggleKeyboardShortcutsInhibit, + ), repeat: true, cooldown: None, allow_when_locked: false, @@ -2026,7 +2034,9 @@ mod tests { SHIFT | COMPOSITOR, ), }, - action: ToggleKeyboardShortcutsInhibit, + action: Press( + ToggleKeyboardShortcutsInhibit, + ), repeat: true, cooldown: None, allow_when_locked: false, @@ -2042,10 +2052,12 @@ mod tests { COMPOSITOR, ), }, - action: Spawn( - [ - "alacritty", - ], + action: Press( + Spawn( + [ + "alacritty", + ], + ), ), repeat: true, cooldown: None, @@ -2062,7 +2074,9 @@ mod tests { COMPOSITOR, ), }, - action: CloseWindow, + action: Press( + CloseWindow, + ), repeat: true, cooldown: None, allow_when_locked: false, @@ -2080,7 +2094,9 @@ mod tests { SHIFT | COMPOSITOR, ), }, - action: FocusMonitorLeft, + action: Press( + FocusMonitorLeft, + ), repeat: true, cooldown: None, allow_when_locked: false, @@ -2096,8 +2112,10 @@ mod tests { SHIFT | COMPOSITOR, ), }, - action: FocusMonitor( - "eDP-1", + action: Press( + FocusMonitor( + "eDP-1", + ), ), repeat: true, cooldown: None, @@ -2114,7 +2132,9 @@ mod tests { CTRL | SHIFT | COMPOSITOR, ), }, - action: MoveWindowToMonitorRight, + action: Press( + MoveWindowToMonitorRight, + ), repeat: true, cooldown: None, allow_when_locked: false, @@ -2130,8 +2150,10 @@ mod tests { CTRL | ALT | COMPOSITOR, ), }, - action: MoveWindowToMonitor( - "eDP-1", + action: Press( + MoveWindowToMonitor( + "eDP-1", + ), ), repeat: true, cooldown: None, @@ -2148,8 +2170,10 @@ mod tests { CTRL | ALT | COMPOSITOR, ), }, - action: MoveColumnToMonitor( - "DP-1", + action: Press( + MoveColumnToMonitor( + "DP-1", + ), ), repeat: true, cooldown: None, @@ -2166,7 +2190,9 @@ mod tests { COMPOSITOR, ), }, - action: ConsumeWindowIntoColumn, + action: Press( + ConsumeWindowIntoColumn, + ), repeat: true, cooldown: None, allow_when_locked: false, @@ -2182,9 +2208,11 @@ mod tests { COMPOSITOR, ), }, - action: FocusWorkspace( - Index( - 1, + action: Press( + FocusWorkspace( + Index( + 1, + ), ), ), repeat: true, @@ -2202,9 +2230,11 @@ mod tests { SHIFT | COMPOSITOR, ), }, - action: FocusWorkspace( - Name( - "workspace-1", + action: Press( + FocusWorkspace( + Name( + "workspace-1", + ), ), ), repeat: true, @@ -2222,8 +2252,10 @@ mod tests { SHIFT | COMPOSITOR, ), }, - action: Quit( - true, + action: Press( + Quit( + true, + ), ), repeat: true, cooldown: None, @@ -2238,7 +2270,9 @@ mod tests { COMPOSITOR, ), }, - action: FocusWorkspaceDown, + action: Press( + FocusWorkspaceDown, + ), repeat: true, cooldown: Some( 150ms, @@ -2256,8 +2290,10 @@ mod tests { ALT | SUPER, ), }, - action: SpawnSh( - "pkill orca || exec orca", + action: Press( + SpawnSh( + "pkill orca || exec orca", + ), ), repeat: true, cooldown: None, @@ -2265,6 +2301,38 @@ mod tests { allow_inhibiting: true, hotkey_overlay_title: None, }, + Bind { + key: Key { + trigger: CompositorMod, + modifiers: Modifiers( + COMPOSITOR, + ), + }, + action: Release( + ToggleOverview, + ), + repeat: false, + cooldown: None, + allow_when_locked: false, + allow_inhibiting: true, + hotkey_overlay_title: None, + }, + Bind { + key: Key { + trigger: CompositorMod, + modifiers: Modifiers( + SHIFT | COMPOSITOR, + ), + }, + action: Release( + ToggleWindowFloating, + ), + repeat: false, + cooldown: None, + allow_when_locked: false, + allow_inhibiting: true, + hotkey_overlay_title: None, + }, ], ), switch_events: SwitchBinds { @@ -2378,13 +2446,15 @@ mod tests { ALT, ), }, - action: MruAdvance { - direction: Forward, - scope: None, - filter: Some( - All, - ), - }, + action: Press( + MruAdvance { + direction: Forward, + scope: None, + filter: Some( + All, + ), + }, + ), repeat: true, cooldown: None, allow_when_locked: false, @@ -2400,13 +2470,15 @@ mod tests { ALT, ), }, - action: MruAdvance { - direction: Forward, - scope: None, - filter: Some( - AppId, - ), - }, + action: Press( + MruAdvance { + direction: Forward, + scope: None, + filter: Some( + AppId, + ), + }, + ), repeat: true, cooldown: None, allow_when_locked: false, @@ -2422,15 +2494,17 @@ mod tests { SUPER, ), }, - action: MruAdvance { - direction: Forward, - scope: Some( - Output, - ), - filter: Some( - All, - ), - }, + action: Press( + MruAdvance { + direction: Forward, + scope: Some( + Output, + ), + filter: Some( + All, + ), + }, + ), repeat: true, cooldown: None, allow_when_locked: false, diff --git a/niri-config/src/recent_windows.rs b/niri-config/src/recent_windows.rs index ba0710cc79..958a325fe2 100644 --- a/niri-config/src/recent_windows.rs +++ b/niri-config/src/recent_windows.rs @@ -2,7 +2,7 @@ use knuffel::errors::DecodeError; use smithay::input::keyboard::Keysym; use crate::utils::{expect_only_children, MergeWith}; -use crate::{Action, Bind, Color, FloatOrInt, Key, Modifiers, Trigger}; +use crate::{Action, Bind, BoundAction, Color, FloatOrInt, Key, Modifiers, Trigger}; #[derive(Debug, PartialEq)] pub struct RecentWindows { @@ -146,7 +146,7 @@ impl From for Bind { fn from(x: MruBind) -> Self { Self { key: x.key, - action: Action::from(x.action), + action: BoundAction::Press(Action::from(x.action)), repeat: true, cooldown: None, allow_when_locked: false, diff --git a/src/backend/tty.rs b/src/backend/tty.rs index c30ca6ba6b..3629a15f79 100644 --- a/src/backend/tty.rs +++ b/src/backend/tty.rs @@ -618,6 +618,14 @@ impl Tty { SessionEvent::PauseSession => { debug!("pausing session"); + // We won't receive key or button releases for whatever is held down while the + // session is paused, so forget about all of it. + // + // Slightly cursed to do from an idle... But we don't have State here conveniently, + // and it should be fine to delay this until the loop callback. + niri.event_loop + .insert_idle(|state| state.release_held_inputs()); + self.libinput.suspend(); for device in self.devices.values_mut() { diff --git a/src/input/mod.rs b/src/input/mod.rs index 8129ec6928..e8ea269ab2 100644 --- a/src/input/mod.rs +++ b/src/input/mod.rs @@ -1,12 +1,14 @@ use std::any::Any; use std::collections::hash_map::Entry; -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use std::time::Duration; use calloop::timer::{TimeoutAction, Timer}; +use calloop::RegistrationToken; use input::event::gesture::GestureEventCoordinates as _; use niri_config::{ - Action, Bind, Binds, Config, Key, ModKey, Modifiers, MruDirection, SwitchBinds, Trigger, + is_keysym_modifier, Action, Bind, Binds, BoundAction, Config, Key, ModKey, Modifiers, + MruDirection, SwitchBinds, Trigger, }; use niri_ipc::LayoutSwitchTarget; use smithay::backend::input::{ @@ -50,7 +52,7 @@ use self::spatial_movement_grab::SpatialMovementGrab; use crate::dbus::freedesktop_a11y::KbMonBlock; use crate::layout::scrolling::ScrollDirection; use crate::layout::{ActivateWindow, LayoutElement as _}; -use crate::niri::{CastTarget, PointerVisibility, State}; +use crate::niri::{CastTarget, Niri, PointerVisibility, State}; use crate::ui::mru::{WindowMru, WindowMruUi}; use crate::ui::screenshot_ui::ScreenshotUi; use crate::utils::spawning::{spawn, spawn_sh}; @@ -137,6 +139,8 @@ impl AnyStartData { enum ShouldInterceptResult { /// Forward the event to the focused client. Forward, + /// Forward the event to the focused client, and handle the given bind. + ForwardAndHandle(Bind), /// Do not forward the event, and handle the given bind. InterceptAndHandle(Bind), /// Do not forward the event, and do nothing else. @@ -492,6 +496,13 @@ impl State { // The accessibility modifier first press must not change XKB state, so we return // early here. if block == KbMonBlock::ModifierFirstPress { + // A pending release action recorded for the key must still run. + if !pressed { + if let Some(bind) = self.niri.pending_release_binds.remove(&event.key_code()) { + self.niri.suppressed_keys.remove(&event.key_code()); + self.handle_bind(bind, false); + } + } return; } block @@ -533,8 +544,13 @@ impl State { // modifiers without accidentally leaking them to wl_keyboard.enter. So for now // let's forward modifier releases to the clients here to deal with the most // common case. - if !pressed - && matches!( + if !pressed { + // The accessibility handler consumes the release. + // So, make sure to run any pending release actions. + let bind = this.niri.pending_release_binds.remove(&key_code); + this.niri.suppressed_keys.remove(&key_code); + + let forward = matches!( modified, Keysym::Shift_L | Keysym::Shift_R @@ -544,12 +560,11 @@ impl State { | Keysym::Super_R | Keysym::Alt_L | Keysym::Alt_R - ) - { - return ShouldInterceptResult::Forward; - } else { - return ShouldInterceptResult::InterceptOnly; + ); + return bind_intercept_result(bind, forward); } + + return ShouldInterceptResult::InterceptOnly; } if this.niri.exit_confirm_dialog.is_open() && pressed { @@ -568,11 +583,13 @@ impl State { if this.niri.window_mru_ui.is_open() && !pressed && modifiers.is_empty() { this.do_action(Action::MruConfirm, false); - if this.niri.suppressed_keys.remove(&key_code) { - return ShouldInterceptResult::InterceptOnly; - } else { - return ShouldInterceptResult::Forward; - } + // A pending release action recorded for the key must still run if it + // closed the MRU instead of going through the regular bind lookup. + return released_key_intercept_result( + &mut this.niri.suppressed_keys, + &mut this.niri.pending_release_binds, + key_code, + ); } if pressed && raw == Some(Keysym::Escape) { @@ -592,6 +609,11 @@ impl State { this.niri.screenshot_ui.set_space_down(pressed); } + if pressed { + this.niri.cancel_interrupted_release_binds(); + } + + let is_locked = this.niri.is_locked(); let res = { let config = this.niri.config.borrow(); let bindings = @@ -599,6 +621,8 @@ impl State { should_intercept_key( &mut this.niri.suppressed_keys, + &mut this.niri.pending_release_binds, + &this.niri.bind_cooldown_timers, bindings, mod_key, key_code, @@ -609,6 +633,7 @@ impl State { &this.niri.screenshot_ui, this.niri.config.borrow().input.disable_power_key_handling, is_inhibiting_shortcuts, + is_locked, ) }; @@ -633,8 +658,9 @@ impl State { return; }; - match result { - ShouldInterceptResult::Forward => { + // Forward the event to the client, unless the key was intercepted. + match &result { + ShouldInterceptResult::Forward | ShouldInterceptResult::ForwardAndHandle(_) => { keyboard.input_forward( self, event.key_code(), @@ -644,13 +670,23 @@ impl State { mods_changed, ); } - ShouldInterceptResult::InterceptAndHandle(bind) => { + ShouldInterceptResult::InterceptAndHandle(_) | ShouldInterceptResult::InterceptOnly => { + if mods_changed { + keyboard.advertise_modifier_state(self); + } + } + } + + // Handle the bind, if the result came with one. + match result { + ShouldInterceptResult::ForwardAndHandle(bind) + | ShouldInterceptResult::InterceptAndHandle(bind) => { + self.handle_bind(bind.clone(), pressed); if pressed { - self.handle_bind(bind.clone()); self.start_key_repeat(bind); } } - ShouldInterceptResult::InterceptOnly => {} + ShouldInterceptResult::Forward | ShouldInterceptResult::InterceptOnly => {} } } @@ -680,7 +716,7 @@ impl State { .niri .event_loop .insert_source(repeat_timer, move |_, _, state| { - state.handle_bind(bind.clone()); + state.handle_bind(bind.clone(), true); TimeoutAction::ToDuration(repeat_duration) }) .unwrap(); @@ -710,37 +746,75 @@ impl State { self.niri.queue_redraw_all(); } - pub fn handle_bind(&mut self, bind: Bind) { - let Some(cooldown) = bind.cooldown else { - self.do_action(bind.action, bind.allow_when_locked); + pub fn handle_bind(&mut self, bind: Bind, pressed: bool) { + let action = if pressed { + bind.press_action() + } else { + bind.release_action() + }; + let Some(action) = action.cloned() else { return; }; + let allowed_when_locked = bind_allowed_when_locked(&bind, pressed); + let allowed_during_screenshot = bind_allowed_during_screenshot(&bind, pressed); + // Check this first so that it doesn't trigger the cooldown. - if self.niri.is_locked() && !(bind.allow_when_locked || allowed_when_locked(&bind.action)) { + if self.niri.is_locked() && !allowed_when_locked { return; } - match self.niri.bind_cooldown_timers.entry(bind.key) { - // The bind is on cooldown. - Entry::Occupied(_) => (), - Entry::Vacant(entry) => { - let timer = Timer::from_duration(cooldown); - let token = self - .niri - .event_loop - .insert_source(timer, move |_, _, state| { - if state.niri.bind_cooldown_timers.remove(&bind.key).is_none() { - error!("bind cooldown timer entry disappeared"); - } - TimeoutAction::Drop - }) - .unwrap(); - entry.insert(token); + // The bind lookup already substitutes the screenshot UI's own + // actions for presses, so only releases need to be filtered here. + if !pressed && self.niri.screenshot_ui.is_open() && !allowed_during_screenshot { + return; + } - self.do_action(bind.action, bind.allow_when_locked); + // Check to see if we are under a cooldown. + // We apply the cooldown to the press action for press-only and press/release. + // We apply the cooldown to the release action for release-only. + if pressed == bind.has_press() { + if let Some(cooldown) = bind.cooldown { + match self.niri.bind_cooldown_timers.entry(bind.key) { + // The bind is on cooldown, so don't run its action. + Entry::Occupied(_) => return, + Entry::Vacant(entry) => { + let timer = Timer::from_duration(cooldown); + let token = self + .niri + .event_loop + .insert_source(timer, move |_, _, state| { + if state.niri.bind_cooldown_timers.remove(&bind.key).is_none() { + error!("bind cooldown timer entry disappeared"); + } + TimeoutAction::Drop + }) + .unwrap(); + entry.insert(token); + } + } } } + + self.do_action(action, allowed_when_locked); + } + + /// Forgets all held inputs, running the release actions of anything that was still held. + pub fn release_held_inputs(&mut self) { + let mut binds = Vec::new(); + binds.extend( + self.niri + .pending_release_binds + .drain() + .map(|(_, bind)| bind), + ); + + // Forget the rest of the input state: the releases will never arrive. + self.niri.clear_input_state(); + + for bind in binds { + self.handle_bind(bind, false); + } } pub fn do_action(&mut self, action: Action, allow_when_locked: bool) { @@ -763,26 +837,18 @@ impl State { self.niri.stop_signal.stop() } Action::ChangeVt(vt) => { - self.backend.change_vt(vt); - - // Changing VT may not deliver the key releases, so clear the state. - self.niri.suppressed_keys.clear(); + // Changing VT may not deliver the key releases, so treat the held binds as + // released while the session is still usable. + self.release_held_inputs(); - // Stop ongoing key repeat if any. - if let Some(token) = self.niri.bind_repeat_timer.take() { - self.niri.event_loop.remove(token); - } + self.backend.change_vt(vt); } Action::Suspend => { - self.backend.suspend(); + // Suspend may not deliver the key releases, so treat the held binds as + // released before suspending. + self.release_held_inputs(); - // Suspend may not deliver the key releases, so clear the state. - self.niri.suppressed_keys.clear(); - - // Stop ongoing key repeat if any. - if let Some(token) = self.niri.bind_repeat_timer.take() { - self.niri.event_loop.remove(token); - } + self.backend.suspend(); } Action::PowerOffMonitors => { self.niri.deactivate_monitors(&mut self.backend); @@ -2869,16 +2935,17 @@ impl State { let mod_key = self.backend.mod_key(&self.niri.config.borrow()); - // Ignore release events for mouse clicks that triggered a bind. - if self.niri.suppressed_buttons.remove(&button_code) { - return; - } - let mods = self.niri.seat.get_keyboard().unwrap().modifier_state(); let modifiers = modifiers_from_state(mods); let mod_down = modifiers.contains(mod_key.to_modifiers()); + if self.niri.suppressed_buttons.remove(&button_code) { + return; + } + if ButtonState::Pressed == button_state { + self.niri.cancel_interrupted_release_binds(); + let mut is_mru_open = false; if let Some(mru_output) = self.niri.window_mru_ui.output() { is_mru_open = true; @@ -2917,12 +2984,14 @@ impl State { find_configured_bind(bindings, mod_key, trigger, mods) }) .filter(|bind| { - !self.niri.screenshot_ui.is_open() || allowed_during_screenshot(&bind.action) + !self.niri.screenshot_ui.is_open() || bind_allowed_during_screenshot(bind, true) }) { self.niri.suppressed_buttons.insert(button_code); - self.handle_bind(bind.clone()); + if bind.has_press() { + self.handle_bind(bind.clone(), true); + } return; - }; + } } // We received an event for the regular pointer, so show it now. @@ -3187,6 +3256,8 @@ impl State { } fn on_pointer_axis(&mut self, event: I::PointerAxisEvent) { + self.niri.cancel_interrupted_release_binds(); + let pointer = &self.niri.seat.get_pointer().unwrap(); let source = event.source(); @@ -3249,7 +3320,7 @@ impl State { trigger: Trigger::WheelScrollLeft, modifiers: Modifiers::empty(), }, - action: Action::FocusColumnLeftUnderMouse, + action: BoundAction::Press(Action::FocusColumnLeftUnderMouse), repeat: true, cooldown: None, allow_when_locked: false, @@ -3261,7 +3332,7 @@ impl State { trigger: Trigger::WheelScrollRight, modifiers: Modifiers::empty(), }, - action: Action::FocusColumnRightUnderMouse, + action: BoundAction::Press(Action::FocusColumnRightUnderMouse), repeat: true, cooldown: None, allow_when_locked: false, @@ -3281,7 +3352,7 @@ impl State { ) .filter(|bind| { !self.niri.screenshot_ui.is_open() - || allowed_during_screenshot(&bind.action) + || bind_allowed_during_screenshot(bind, true) }); let bind_right = find_configured_bind( bindings, @@ -3291,19 +3362,19 @@ impl State { ) .filter(|bind| { !self.niri.screenshot_ui.is_open() - || allowed_during_screenshot(&bind.action) + || bind_allowed_during_screenshot(bind, true) }); (bind_left, bind_right) }; if let Some(right) = bind_right { for _ in 0..ticks { - self.handle_bind(right.clone()); + self.handle_bind(right.clone(), true); } } if let Some(left) = bind_left { for _ in ticks..0 { - self.handle_bind(left.clone()); + self.handle_bind(left.clone(), true); } } } @@ -3329,7 +3400,7 @@ impl State { trigger: Trigger::WheelScrollUp, modifiers: Modifiers::empty(), }, - action: Action::FocusWorkspaceUpUnderMouse, + action: BoundAction::Press(Action::FocusWorkspaceUpUnderMouse), repeat: true, cooldown: Some(Duration::from_millis(50)), allow_when_locked: false, @@ -3341,7 +3412,7 @@ impl State { trigger: Trigger::WheelScrollDown, modifiers: Modifiers::empty(), }, - action: Action::FocusWorkspaceDownUnderMouse, + action: BoundAction::Press(Action::FocusWorkspaceDownUnderMouse), repeat: true, cooldown: Some(Duration::from_millis(50)), allow_when_locked: false, @@ -3355,7 +3426,7 @@ impl State { trigger: Trigger::WheelScrollUp, modifiers: Modifiers::empty(), }, - action: Action::FocusColumnLeftUnderMouse, + action: BoundAction::Press(Action::FocusColumnLeftUnderMouse), repeat: true, cooldown: Some(Duration::from_millis(50)), allow_when_locked: false, @@ -3367,7 +3438,7 @@ impl State { trigger: Trigger::WheelScrollDown, modifiers: Modifiers::empty(), }, - action: Action::FocusColumnRightUnderMouse, + action: BoundAction::Press(Action::FocusColumnRightUnderMouse), repeat: true, cooldown: Some(Duration::from_millis(50)), allow_when_locked: false, @@ -3387,25 +3458,25 @@ impl State { ) .filter(|bind| { !self.niri.screenshot_ui.is_open() - || allowed_during_screenshot(&bind.action) + || bind_allowed_during_screenshot(bind, true) }); let bind_down = find_configured_bind(bindings, mod_key, Trigger::WheelScrollDown, mods) .filter(|bind| { !self.niri.screenshot_ui.is_open() - || allowed_during_screenshot(&bind.action) + || bind_allowed_during_screenshot(bind, true) }); (bind_up, bind_down) }; if let Some(down) = bind_down { for _ in 0..ticks { - self.handle_bind(down.clone()); + self.handle_bind(down.clone(), true); } } if let Some(up) = bind_up { for _ in ticks..0 { - self.handle_bind(up.clone()); + self.handle_bind(up.clone(), true); } } } @@ -3539,24 +3610,24 @@ impl State { ) .filter(|bind| { !self.niri.screenshot_ui.is_open() - || allowed_during_screenshot(&bind.action) + || bind_allowed_during_screenshot(bind, true) }); let bind_right = find_configured_bind(bindings, mod_key, Trigger::TouchpadScrollRight, mods) .filter(|bind| { !self.niri.screenshot_ui.is_open() - || allowed_during_screenshot(&bind.action) + || bind_allowed_during_screenshot(bind, true) }); drop(config); if let Some(right) = bind_right { for _ in 0..ticks { - self.handle_bind(right.clone()); + self.handle_bind(right.clone(), true); } } if let Some(left) = bind_left { for _ in ticks..0 { - self.handle_bind(left.clone()); + self.handle_bind(left.clone(), true); } } } @@ -3577,24 +3648,24 @@ impl State { ) .filter(|bind| { !self.niri.screenshot_ui.is_open() - || allowed_during_screenshot(&bind.action) + || bind_allowed_during_screenshot(bind, true) }); let bind_down = find_configured_bind(bindings, mod_key, Trigger::TouchpadScrollDown, mods) .filter(|bind| { !self.niri.screenshot_ui.is_open() - || allowed_during_screenshot(&bind.action) + || bind_allowed_during_screenshot(bind, true) }); drop(config); if let Some(down) = bind_down { for _ in 0..ticks { - self.handle_bind(down.clone()); + self.handle_bind(down.clone(), true); } } if let Some(up) = bind_up { for _ in ticks..0 { - self.handle_bind(up.clone()); + self.handle_bind(up.clone(), true); } } } @@ -3771,6 +3842,8 @@ impl State { match tip_state { TabletToolTipState::Down => { + self.niri.cancel_interrupted_release_binds(); + if let Some(pos) = self.niri.tablet_cursor_location { let under = self.niri.contents_under(pos); @@ -3986,10 +4059,9 @@ impl State { if let Some(tool) = tool { let button = event.button(); + let button_state = event.button_state(); - if self.niri.suppressed_buttons.remove(&button) { - return; - } + let mod_key = self.backend.mod_key(&self.niri.config.borrow()); let trigger = match button { BTN_STYLUS => Some(Trigger::TabletStylusButton1), @@ -3998,9 +4070,17 @@ impl State { _ => None, }; + if self.niri.suppressed_buttons.remove(&button) { + return; + } + + if button_state == ButtonState::Pressed { + self.niri.cancel_interrupted_release_binds(); + } + + // Handle press binds. if let Some(trigger) = trigger { - if event.button_state() == ButtonState::Pressed { - let mod_key = self.backend.mod_key(&self.niri.config.borrow()); + if button_state == ButtonState::Pressed { let mods = self.niri.seat.get_keyboard().unwrap().modifier_state(); let modifiers = modifiers_from_state(mods); @@ -4012,11 +4092,13 @@ impl State { } .filter(|bind| { !self.niri.screenshot_ui.is_open() - || allowed_during_screenshot(&bind.action) + || bind_allowed_during_screenshot(bind, true) }); if let Some(bind) = bind { self.niri.suppressed_buttons.insert(button); - self.handle_bind(bind.clone()); + if bind.has_press() { + self.handle_bind(bind.clone(), true); + } return; } } @@ -4030,7 +4112,7 @@ impl State { &tablet::tool::ButtonEvent { serial: SERIAL_COUNTER.next_serial(), button, - state: event.button_state(), + state: button_state, time, }, ); @@ -4040,6 +4122,8 @@ impl State { } fn on_gesture_swipe_begin(&mut self, event: I::GestureSwipeBeginEvent) { + self.niri.cancel_interrupted_release_binds(); + if self.niri.window_mru_ui.is_open() || self.niri.screenshot_ui.is_open() { // Don't start swipe gestures while in the compositor UI. return; @@ -4243,6 +4327,8 @@ impl State { } fn on_gesture_pinch_begin(&mut self, event: I::GesturePinchBeginEvent) { + self.niri.cancel_interrupted_release_binds(); + let serial = SERIAL_COUNTER.next_serial(); let pointer = self.niri.seat.get_pointer().unwrap(); @@ -4314,6 +4400,8 @@ impl State { } fn on_gesture_hold_begin(&mut self, event: I::GestureHoldBeginEvent) { + self.niri.cancel_interrupted_release_binds(); + let serial = SERIAL_COUNTER.next_serial(); let pointer = self.niri.seat.get_pointer().unwrap(); @@ -4385,6 +4473,8 @@ impl State { }; let slot = evt.slot(); + self.niri.cancel_interrupted_release_binds(); + let serial = SERIAL_COUNTER.next_serial(); let under = self.niri.contents_under(pos); @@ -4617,12 +4707,59 @@ impl State { } } -/// Check whether the key should be intercepted and mark intercepted -/// pressed keys as `suppressed`, thus preventing `releases` corresponding -/// to them from being delivered. +impl Niri { + fn cancel_interrupted_release_binds(&mut self) { + retain_press_binds(&mut self.pending_release_binds); + } +} + +fn retain_press_binds(binds: &mut HashMap) { + binds.retain(|_, bind| bind.has_press()); +} + +fn released_key_intercept_result( + suppressed_keys: &mut HashSet, + pending_release_binds: &mut HashMap, + key_code: Keycode, +) -> ShouldInterceptResult { + let bind = pending_release_binds.remove(&key_code); + let suppressed = suppressed_keys.remove(&key_code); + + bind_intercept_result(bind, !suppressed) +} + +fn bind_intercept_result(bind: Option, forward: bool) -> ShouldInterceptResult { + match (bind, forward) { + (Some(bind), true) => ShouldInterceptResult::ForwardAndHandle(bind), + (Some(bind), false) => ShouldInterceptResult::InterceptAndHandle(bind), + (None, true) => ShouldInterceptResult::Forward, + (None, false) => ShouldInterceptResult::InterceptOnly, + } +} + +fn is_bind_on_cooldown(bind: &Bind, cooldown_timers: &HashMap) -> bool { + bind.cooldown.is_some() && cooldown_timers.contains_key(&bind.key) +} + +/// Checks whether the key should be intercepted, and tracks the presses of intercepted keys. +/// +/// An intercepted key press is recorded in `suppressed_keys`, which prevents its release from +/// being forwarded to clients. If the matching bind has a release action, the bind is also +/// recorded in `pending_release_binds`, so that its release action triggers when the key is +/// released, regardless of the modifiers held at that point. +/// +/// A release action only triggers if the bind's modifiers matched when the key was pressed. +/// +/// A release-only bind is cancelled by other input before this runs; see +/// `cancel_interrupted_release_binds()`. A bind with a press action always runs its release action. +/// +/// A bind whose press action is rate-limited by its cooldown is skipped entirely: +/// No action runs, and no release action is recorded. #[allow(clippy::too_many_arguments)] fn should_intercept_key<'a>( suppressed_keys: &mut HashSet, + pending_release_binds: &mut HashMap, + cooldown_timers: &HashMap, bindings: impl IntoIterator, mod_key: ModKey, key_code: Keycode, @@ -4633,12 +4770,12 @@ fn should_intercept_key<'a>( screenshot_ui: &ScreenshotUi, disable_power_key_handling: bool, is_inhibiting_shortcuts: bool, + is_locked: bool, ) -> ShouldInterceptResult { - // Actions are only triggered on presses, release of the key - // shouldn't try to intercept anything unless we have marked - // the key to suppress. - if !pressed && !suppressed_keys.contains(&key_code) { - return ShouldInterceptResult::Forward; + // A release never runs a press action, so its result depends only on the bind recorded when + // the key was pressed, and on whether that press was suppressed. + if !pressed { + return released_key_intercept_result(suppressed_keys, pending_release_binds, key_code); } let mut final_bind = find_bind( @@ -4656,7 +4793,7 @@ fn should_intercept_key<'a>( let mut use_screenshot_ui_action = true; if let Some(bind) = &final_bind { - if allowed_during_screenshot(&bind.action) { + if bind_allowed_during_screenshot(bind, true) { use_screenshot_ui_action = false; } } @@ -4669,7 +4806,7 @@ fn should_intercept_key<'a>( // Not entirely correct but it doesn't matter in how we currently use it. modifiers: Modifiers::empty(), }, - action, + action: BoundAction::Press(action), repeat: true, cooldown: None, allow_when_locked: false, @@ -4683,25 +4820,46 @@ fn should_intercept_key<'a>( } } - match (final_bind, pressed) { - (Some(bind), true) => { + match final_bind { + Some(bind) => { if is_inhibiting_shortcuts && bind.allow_inhibiting { ShouldInterceptResult::Forward + } else if bind.has_press() && is_bind_on_cooldown(&bind, cooldown_timers) { + // If on cooldown, do not run the press action, record a release action, + // or forward the press. + suppressed_keys.insert(key_code); + ShouldInterceptResult::InterceptOnly + } else if is_keysym_modifier(modified) { + if should_record_release_bind(&bind, is_locked, cooldown_timers) { + pending_release_binds.insert(key_code, bind.clone()); + } + if bind.has_press() { + suppressed_keys.insert(key_code); + // A bind with a press action takes the key press. Applications will still learn + // about the modifier state via `advertise_modifier_state()`. + ShouldInterceptResult::InterceptAndHandle(bind) + } else { + // This is to ensure that, if you have a modifier-triggered release bind like + // Ctrl+Alt, We still forward the Ctrl and Alt presses to the application + // to use for its own shortcuts. + ShouldInterceptResult::Forward + } } else { + if should_record_release_bind(&bind, is_locked, cooldown_timers) { + pending_release_binds.insert(key_code, bind.clone()); + } suppressed_keys.insert(key_code); - ShouldInterceptResult::InterceptAndHandle(bind) + if bind.has_press() { + ShouldInterceptResult::InterceptAndHandle(bind) + } else { + // Release-only bind: intercept the key press so that it doesn't reach the + // client, and remember to trigger the release action when the key is + // released. + ShouldInterceptResult::InterceptOnly + } } } - (_, false) => { - // By this point, we know that the key was suppressed on press. Even if we're inhibiting - // shortcuts, we should still suppress the release. - // But we don't need to check for shortcuts inhibition here, because - // if it was inhibited on press (forwarded to the client), it wouldn't be suppressed, - // so the release would already have been forwarded at the start of this function. - suppressed_keys.remove(&key_code); - ShouldInterceptResult::InterceptOnly - } - (None, true) => ShouldInterceptResult::Forward, + None => ShouldInterceptResult::Forward, } } @@ -4733,7 +4891,7 @@ fn find_bind<'a>( trigger: Trigger::Keysym(modified), modifiers: Modifiers::empty(), }, - action, + action: BoundAction::Press(action), // Repeating VT switches and power off doesn't make sense (and it actually happens on // FreeBSD where it doesn't get released and ends up broken). repeat: false, @@ -4749,42 +4907,89 @@ fn find_bind<'a>( }); } - let trigger = Trigger::Keysym(raw?); - find_configured_bind(bindings, mod_key, trigger, mods) + let raw = raw?; + + let modifiers = modifiers_from_state(mods); + + // A modifier key can trigger binds bound as the compositor mod key (`Mod`), by its own keysym + // (like `Alt_L`), or as the modifier itself (like `Alt`). Return the first bind that matches. + bindings + .into_iter() + .find(|bind| { + // Modifier-only binds can be written in several ways: + // - `Mod { quit; }` - Trigger::CompositorMod + // - `Super { quit; }` - Trigger::Modifier(_) + // - `Super_L { quit; }` - Trigger::Keysym(_) + // + // These only matter for keyboard keys and not for mouse or others. So, check them here, + // and then pass the bind's trigger itself to the simpler matching function. + let bind_trigger = bind.key.trigger; + trigger_matches_keysym(bind_trigger, raw, mod_key) + && bind_matches_trigger(bind, bind_trigger, mod_key, modifiers) + }) + .cloned() +} + +fn trigger_matches_keysym(trigger: Trigger, raw: Keysym, mod_key: ModKey) -> bool { + match trigger { + Trigger::CompositorMod => mod_key.matches_keysym(raw), + Trigger::Keysym(keysym) => keysym == raw, + Trigger::Modifier(modifier) => modifier.matches_keysym(raw), + _ => false, + } } +/// Finds the configured bind for a trigger and held modifiers. +/// +/// Returns the first bind with this exact trigger whose modifiers match, so overlapping binds +/// like `Mod+Q` and `Super+Q` are resolved by definition order. fn find_configured_bind<'a>( bindings: impl IntoIterator, mod_key: ModKey, trigger: Trigger, mods: ModifiersState, ) -> Option { - // Handle configured binds. - let mut modifiers = modifiers_from_state(mods); + let modifiers = modifiers_from_state(mods); + + bindings + .into_iter() + .find(|bind| bind_matches_trigger(bind, trigger, mod_key, modifiers)) + .cloned() +} - let mod_down = modifiers_from_state(mods).contains(mod_key.to_modifiers()); - if mod_down { - modifiers |= Modifiers::COMPOSITOR; +fn bind_matches_trigger(bind: &Bind, trigger: Trigger, mod_key: ModKey, mods: Modifiers) -> bool { + if bind.key.trigger != trigger { + return false; } - for bind in bindings { - if bind.key.trigger != trigger { - continue; - } + let mut held_modifiers = mods; + let mut required_modifiers = bind.key.modifiers; - let mut bind_modifiers = bind.key.modifiers; - if bind_modifiers.contains(Modifiers::COMPOSITOR) { - bind_modifiers |= mod_key.to_modifiers(); - } else if bind_modifiers.contains(mod_key.to_modifiers()) { - bind_modifiers |= Modifiers::COMPOSITOR; - } + // Check if the trigger is the mod key itself, either as `Mod`, as its own keysym (like + // `Super_L`), or as the modifier itself (like `Super`). If so, normalize the modifiers not + // to need it. + let trigger_is_mod_key = match trigger { + Trigger::CompositorMod => true, + Trigger::Modifier(modifier) => modifier == mod_key, + Trigger::Keysym(keysym) => mod_key.matches_keysym(keysym), + _ => false, + }; - if bind_modifiers == modifiers { - return Some(bind.clone()); + if trigger_is_mod_key { + held_modifiers.remove(mod_key.to_modifiers()); + required_modifiers.remove(Modifiers::COMPOSITOR | mod_key.to_modifiers()); + } else { + if held_modifiers.contains(mod_key.to_modifiers()) { + held_modifiers |= Modifiers::COMPOSITOR; + } + if required_modifiers.contains(Modifiers::COMPOSITOR) { + required_modifiers |= mod_key.to_modifiers(); + } else if required_modifiers.contains(mod_key.to_modifiers()) { + required_modifiers |= Modifiers::COMPOSITOR; } } - None + required_modifiers == held_modifiers } fn find_configured_switch_action( @@ -4912,6 +5117,36 @@ fn allowed_when_locked(action: &Action) -> bool { ) } +fn bind_allowed_when_locked(bind: &Bind, pressed: bool) -> bool { + if bind.allow_when_locked { + return true; + } + + match &bind.action { + BoundAction::Press(action) | BoundAction::Release(action) => allowed_when_locked(action), + BoundAction::Both { press, release } => { + if pressed { + allowed_when_locked(press) && allowed_when_locked(release) + } else { + // The press ran, so the release must run too. + true + } + } + } +} + +fn should_record_release_bind( + bind: &Bind, + is_locked: bool, + cooldown_timers: &HashMap, +) -> bool { + if bind.has_press() && is_bind_on_cooldown(bind, cooldown_timers) { + return false; + } + + bind.has_release() && (!is_locked || bind_allowed_when_locked(bind, true)) +} + fn allowed_during_screenshot(action: &Action) -> bool { matches!( action, @@ -4953,6 +5188,22 @@ fn allowed_during_screenshot(action: &Action) -> bool { ) } +fn bind_allowed_during_screenshot(bind: &Bind, pressed: bool) -> bool { + match &bind.action { + BoundAction::Press(action) | BoundAction::Release(action) => { + allowed_during_screenshot(action) + } + BoundAction::Both { press, release } => { + if pressed { + allowed_during_screenshot(press) && allowed_during_screenshot(release) + } else { + // The press ran, so the release must run too. + true + } + } + } +} + fn hardcoded_overview_bind(raw: Keysym, mods: ModifiersState) -> Option { let mods = modifiers_from_state(mods); if !mods.is_empty() { @@ -4979,7 +5230,7 @@ fn hardcoded_overview_bind(raw: Keysym, mods: ModifiersState) -> Option { trigger: Trigger::Keysym(raw), modifiers: Modifiers::empty(), }, - action, + action: BoundAction::Press(action), repeat, cooldown: None, allow_when_locked: false, @@ -5417,7 +5668,7 @@ mod tests { trigger: Trigger::Keysym(Keysym::q), modifiers: Modifiers::COMPOSITOR, }, - action: Action::CloseWindow, + action: BoundAction::Press(Action::CloseWindow), repeat: true, cooldown: None, allow_when_locked: false, @@ -5429,7 +5680,7 @@ mod tests { trigger: Trigger::Keysym(Keysym::h), modifiers: Modifiers::SUPER, }, - action: Action::FocusColumnLeft, + action: BoundAction::Press(Action::FocusColumnLeft), repeat: true, cooldown: None, allow_when_locked: false, @@ -5441,7 +5692,7 @@ mod tests { trigger: Trigger::Keysym(Keysym::j), modifiers: Modifiers::empty(), }, - action: Action::FocusWindowDown, + action: BoundAction::Press(Action::FocusWindowDown), repeat: true, cooldown: None, allow_when_locked: false, @@ -5453,7 +5704,7 @@ mod tests { trigger: Trigger::Keysym(Keysym::k), modifiers: Modifiers::COMPOSITOR | Modifiers::SUPER, }, - action: Action::FocusWindowUp, + action: BoundAction::Press(Action::FocusWindowUp), repeat: true, cooldown: None, allow_when_locked: false, @@ -5465,13 +5716,25 @@ mod tests { trigger: Trigger::Keysym(Keysym::l), modifiers: Modifiers::SUPER | Modifiers::ALT, }, - action: Action::FocusColumnRight, + action: BoundAction::Press(Action::FocusColumnRight), repeat: true, cooldown: None, allow_when_locked: false, allow_inhibiting: true, hotkey_overlay_title: None, }, + Bind { + key: Key { + trigger: Trigger::Keysym(Keysym::Super_L), + modifiers: Modifiers::empty(), + }, + action: BoundAction::Release(Action::ToggleOverview), + repeat: false, + cooldown: None, + allow_when_locked: false, + allow_inhibiting: true, + hotkey_overlay_title: None, + }, ]); assert_eq!( @@ -5482,7 +5745,7 @@ mod tests { ModifiersState { logo: true, ..Default::default() - } + }, ) .as_ref(), Some(&bindings.0[0]) @@ -5505,7 +5768,7 @@ mod tests { ModifiersState { logo: true, ..Default::default() - } + }, ) .as_ref(), Some(&bindings.0[1]) @@ -5528,7 +5791,7 @@ mod tests { ModifiersState { logo: true, ..Default::default() - } + }, ), None, ); @@ -5551,7 +5814,7 @@ mod tests { ModifiersState { logo: true, ..Default::default() - } + }, ) .as_ref(), Some(&bindings.0[3]) @@ -5575,7 +5838,7 @@ mod tests { logo: true, alt: true, ..Default::default() - } + }, ) .as_ref(), Some(&bindings.0[4]) @@ -5592,5 +5855,18 @@ mod tests { ), None, ); + + // A release-only bind is found the same as any other: what to do with it depends on the + // event, not on the lookup. + assert_eq!( + find_configured_bind( + &bindings.0, + ModKey::Super, + Trigger::Keysym(Keysym::Super_L), + ModifiersState::default(), + ) + .as_ref(), + Some(&bindings.0[5]) + ); } } diff --git a/src/niri.rs b/src/niri.rs index 2f3c458143..578e0f03b8 100644 --- a/src/niri.rs +++ b/src/niri.rs @@ -17,7 +17,7 @@ use niri_config::debug::PreviewRender; use niri_config::output::MaxBpc; use niri_config::window_rule::VrrRule; use niri_config::{ - Config, FloatOrInt, Key, Modifiers, OutputName, TrackLayout, WarpMouseToFocusMode, + Bind, Config, FloatOrInt, Key, Modifiers, OutputName, TrackLayout, WarpMouseToFocusMode, WorkspaceReference, Xkb, }; use smithay::backend::allocator::Fourcc; @@ -359,6 +359,8 @@ pub struct Niri { pub suppressed_keys: HashSet, /// Button codes of the mouse buttons to suppress. pub suppressed_buttons: HashSet, + /// Binds whose release action is pending, keyed by the key code. + pub pending_release_binds: HashMap, pub bind_cooldown_timers: HashMap, pub bind_repeat_timer: Option, pub keyboard_focus: KeyboardFocus, @@ -2837,6 +2839,7 @@ impl Niri { popup_grab: None, suppressed_keys: HashSet::new(), suppressed_buttons: HashSet::new(), + pending_release_binds: HashMap::new(), bind_cooldown_timers: HashMap::new(), bind_repeat_timer: Option::default(), presentation_state, @@ -6590,6 +6593,17 @@ impl Niri { Ok(()) } + pub fn clear_input_state(&mut self) { + self.suppressed_keys.clear(); + self.suppressed_buttons.clear(); + self.pending_release_binds.clear(); + + // Stop ongoing key repeat if any. + if let Some(token) = self.bind_repeat_timer.take() { + self.event_loop.remove(token); + } + } + pub fn is_locked(&self) -> bool { match self.lock_state { LockState::Unlocked | LockState::WaitingForSurfaces { .. } => false, diff --git a/src/tests/binds.rs b/src/tests/binds.rs index eb199a519b..df3e0bbc81 100644 --- a/src/tests/binds.rs +++ b/src/tests/binds.rs @@ -1,7 +1,7 @@ use std::fmt::Write as _; use insta::assert_snapshot; -use niri_config::{Action, Config}; +use niri_config::{Action, BoundAction, Config}; use smithay::backend::input::{InputEvent, InputTime, KeyState, Keycode}; use smithay::input::keyboard::xkb::Keymap; use wayland_client::protocol::wl_surface::WlSurface; @@ -39,7 +39,14 @@ fn set_up(config: &str) -> (Fixture, ClientId, WlSurface) { let mut config = Config::parse_mem(config).unwrap(); // knuffel doesn't understand #[cfg(test)]... for bind in &mut config.binds.0 { - bind.action = Action::TestAction; + bind.action = match &bind.action { + BoundAction::Press(_) => BoundAction::Press(Action::TestAction), + BoundAction::Release(_) => BoundAction::Release(Action::TestAction), + BoundAction::Both { .. } => BoundAction::Both { + press: Action::TestAction, + release: Action::TestAction, + }, + }; } let mut f = Fixture::with_config(config); diff --git a/src/ui/hotkey_overlay.rs b/src/ui/hotkey_overlay.rs index f95518d003..0b92e47406 100644 --- a/src/ui/hotkey_overlay.rs +++ b/src/ui/hotkey_overlay.rs @@ -152,13 +152,23 @@ impl HotkeyOverlay { } } +/// Returns the actions that a bind triggers. +fn bind_actions(bind: &Bind) -> impl Iterator { + bind.press_action().into_iter().chain(bind.release_action()) +} + +/// Whether the bind triggers the action, on press or on release. +fn bind_triggers(bind: &Bind, action: &Action) -> bool { + bind.press_action() == Some(action) || bind.release_action() == Some(action) +} + fn format_bind(binds: &[Bind], action: &Action) -> Option<(Option, String)> { let mut bind_with_non_null = None; let mut bind_with_custom_title = None; let mut found_null_title = false; for bind in binds { - if bind.action != *action { + if !bind_triggers(bind, action) { continue; } @@ -202,9 +212,15 @@ fn collect_actions(config: &Config) -> Vec<&Action> { // Prefer Quit(false) if found, otherwise try Quit(true), and if there's neither, fall back to // Quit(false). - if binds.iter().any(|bind| bind.action == Action::Quit(false)) { + if binds + .iter() + .any(|bind| bind_triggers(bind, &Action::Quit(false))) + { actions.push(&Action::Quit(false)); - } else if binds.iter().any(|bind| bind.action == Action::Quit(true)) { + } else if binds + .iter() + .any(|bind| bind_triggers(bind, &Action::Quit(true))) + { actions.push(&Action::Quit(true)); } else { actions.push(&Action::Quit(false)); @@ -221,14 +237,16 @@ fn collect_actions(config: &Config) -> Vec<&Action> { ]); // Prefer move-column-to-workspace-down, but fall back to move-window-to-workspace-down. - if let Some(bind) = binds + if let Some(action) = binds .iter() - .find(|bind| matches!(bind.action, Action::MoveColumnToWorkspaceDown(_))) + .flat_map(bind_actions) + .find(|action| matches!(action, Action::MoveColumnToWorkspaceDown(_))) { - actions.push(&bind.action); + actions.push(action); } else if binds .iter() - .any(|bind| matches!(bind.action, Action::MoveWindowToWorkspaceDown(_))) + .flat_map(bind_actions) + .any(|action| matches!(action, Action::MoveWindowToWorkspaceDown(_))) { actions.push(&Action::MoveWindowToWorkspaceDown(true)); } else { @@ -236,14 +254,16 @@ fn collect_actions(config: &Config) -> Vec<&Action> { } // Same for -up. - if let Some(bind) = binds + if let Some(action) = binds .iter() - .find(|bind| matches!(bind.action, Action::MoveColumnToWorkspaceUp(_))) + .flat_map(bind_actions) + .find(|action| matches!(action, Action::MoveColumnToWorkspaceUp(_))) { - actions.push(&bind.action); + actions.push(action); } else if binds .iter() - .any(|bind| matches!(bind.action, Action::MoveWindowToWorkspaceUp(_))) + .flat_map(bind_actions) + .any(|action| matches!(action, Action::MoveWindowToWorkspaceUp(_))) { actions.push(&Action::MoveWindowToWorkspaceUp(true)); } else { @@ -261,34 +281,42 @@ fn collect_actions(config: &Config) -> Vec<&Action> { ]); // Screenshot is not as important, can omit if not bound. - if let Some(bind) = binds + if let Some(action) = binds .iter() - .find(|bind| matches!(bind.action, Action::Screenshot(_, _))) + .flat_map(bind_actions) + .find(|action| matches!(action, Action::Screenshot(_, _))) { - actions.push(&bind.action); + actions.push(action); } // Add actions with a custom hotkey-overlay-title. for bind in binds { if matches!(bind.hotkey_overlay_title, Some(Some(_))) { // Avoid duplicate actions. - if !actions.contains(&&bind.action) { - actions.push(&bind.action); + if let Some(action) = bind_actions(bind).next() { + if !actions.contains(&action) { + actions.push(action); + } } } } // Add the spawn actions. - for bind in binds.iter().filter(|bind| { - matches!(bind.action, Action::Spawn(_) | Action::SpawnSh(_)) - // Only show binds with Mod or Super to filter out stuff like volume up/down. - && (bind.key.modifiers.contains(Modifiers::COMPOSITOR) - || bind.key.modifiers.contains(Modifiers::SUPER)) - // Also filter out wheel and touchpad scroll binds. - && matches!(bind.key.trigger, Trigger::Keysym(_)) + for action in binds.iter().filter_map(|bind| { + // Includes the trigger's implied modifier. + let modifiers = bind.key.modifiers; + + // Only show binds with Mod or Super to filter out stuff like volume up/down. + // Also filter out wheel and touchpad scroll binds. + if bind.key.trigger.is_keyboard() + && modifiers.intersects(Modifiers::COMPOSITOR | Modifiers::SUPER) + { + bind_actions(bind) + .find(|action| matches!(action, Action::Spawn(_) | Action::SpawnSh(_))) + } else { + None + } }) { - let action = &bind.action; - // We only show one bind for each action, so we need to deduplicate the Spawn actions. if !actions.contains(&action) { actions.push(action); @@ -297,7 +325,7 @@ fn collect_actions(config: &Config) -> Vec<&Action> { if config.hotkey_overlay.hide_not_bound { // Only keep actions that have been bound - actions.retain(|&action| binds.iter().any(|bind| bind.action == *action)) + actions.retain(|&action| binds.iter().any(|bind| bind_triggers(bind, action))) } actions @@ -497,7 +525,13 @@ fn key_name(screen_reader: bool, mod_key: ModKey, key: &Key) -> String { let mut name = String::new(); // Do not show modifiers implied by the trigger. - let modifiers = key.modifiers & !key.trigger.implied_modifier(); + let mut implied = key.trigger.implied_modifier(); + // Spellings of the mod key also imply the compositor modifier (e.g. Mod+Super_L). + let mod_modifiers = Modifiers::COMPOSITOR | mod_key.to_modifiers(); + if implied.intersects(mod_modifiers) { + implied |= mod_modifiers; + } + let modifiers = key.modifiers & !implied; let has_comp_mod = modifiers.contains(Modifiers::COMPOSITOR); @@ -713,6 +747,33 @@ mod tests { @" Super + Control_R : Close Focused Window" ); + // A release bind is shown too. + assert_snapshot!( + check( + r#"binds { + Mod { + release { toggle-overview; } + } + }"#, + Action::ToggleOverview, + ), + @" Super : Open the Overview" + ); + + // Same for the release action of a bind with both actions. + assert_snapshot!( + check( + r#"binds { + Mod+Shift+Q { + press { close-window; } + release { toggle-overview; } + } + }"#, + Action::ToggleOverview, + ), + @" Super + Shift + Q : Open the Overview" + ); + // Bound with a default title. assert_snapshot!( check( @@ -794,5 +855,110 @@ mod tests { ), @" Super + P : Hello" ); + + // Mod overlapping with trigger. + assert_snapshot!( + check( + r#"binds { + Mod+Super_L { screenshot; } + }"#, + Action::Screenshot(true, None), + ), + @" Super_L : Take a Screenshot" + ); + assert_snapshot!( + check( + r#"binds { + Mod+Super { screenshot; } + }"#, + Action::Screenshot(true, None), + ), + @" Super : Take a Screenshot" + ); + assert_snapshot!( + check( + r#"binds { + Super+Mod { screenshot; } + }"#, + Action::Screenshot(true, None), + ), + @" Super : Take a Screenshot" + ); + } + + #[test] + fn test_collect_actions() { + // Release binds count as bound, both for the actions that are always listed and for the + // ones that are only listed when bound. + let config = Config::parse_mem( + r#"binds { + Alt { + release { screenshot; } + } + Mod { + release { spawn "foot"; } + } + }"#, + ) + .unwrap(); + + let actions = collect_actions(&config); + assert!(actions.contains(&&Action::Screenshot(true, None))); + assert!(actions.contains(&&Action::Spawn(vec![String::from("foot")]))); + + // Release binds keep actions in the list with hide-not-bound. + let config = Config::parse_mem( + r#"binds { + Mod { + release { toggle-overview; } + } + } + hotkey-overlay { hide-not-bound; }"#, + ) + .unwrap(); + + assert!(collect_actions(&config).contains(&&Action::ToggleOverview)); + + // A release bind with a custom title adds its action to the list. + let config = Config::parse_mem( + r#"binds { + Mod+Q hotkey-overlay-title="Custom" { + release { center-column; } + } + }"#, + ) + .unwrap(); + + assert!(collect_actions(&config).contains(&&Action::CenterColumn)); + + // Various filtering behavior. + let config = Config::parse_mem( + r#"binds { + XF86AudioMute { spawn "Mute"; } + Super_L { spawn "Super_L"; } + Mod { spawn "Mod"; } + Shift_L { spawn "Shift_L"; } + Alt { spawn "Alt"; } + Mod+Shift_L { spawn "Mod+Shift_L"; } + Super+Ctrl { spawn "Super+Ctrl"; } + Mod+Alt { spawn "Mod+Alt"; } + }"#, + ) + .unwrap(); + + let mut snapshot = String::new(); + for action in collect_actions(&config) { + if let Action::Spawn(args) = action { + snapshot.push_str(&args[0]); + snapshot.push('\n'); + } + } + assert_snapshot!(snapshot, @" + Super_L + Mod + Mod+Shift_L + Super+Ctrl + Mod+Alt + "); } } diff --git a/src/ui/mru.rs b/src/ui/mru.rs index d8ec6de16e..c0afaaaf25 100644 --- a/src/ui/mru.rs +++ b/src/ui/mru.rs @@ -7,8 +7,8 @@ use std::time::Duration; use anyhow::ensure; use niri_config::{ - Action, Bind, Color, Config, CornerRadius, GradientInterpolation, Key, Modifiers, MruDirection, - MruFilter, MruScope, Trigger, + Action, Bind, BoundAction, Color, Config, CornerRadius, GradientInterpolation, Key, Modifiers, + MruDirection, MruFilter, MruScope, Trigger, }; use pango::FontDescription; use pangocairo::cairo::{self, ImageSurface}; @@ -1834,7 +1834,7 @@ fn make_preset_opened_binds() -> Vec { // The modifier is filled dynamically. modifiers: Modifiers::empty(), }, - action, + action: BoundAction::Press(action), repeat: true, cooldown: None, allow_when_locked: false, @@ -1882,7 +1882,11 @@ fn make_dynamic_opened_binds(config: &Config) -> Vec { let mut binds: HashMap> = HashMap::new(); for bind in &config.binds.0 { - let action = match &bind.action { + let Some(press_action) = bind.press_action() else { + continue; + }; + + let action = match press_action { Action::FocusColumnRight | Action::FocusColumnRightOrFirst | Action::FocusColumnOrMonitorRight @@ -1907,7 +1911,7 @@ fn make_dynamic_opened_binds(config: &Config) -> Vec { }; binds.entry(bind.key.trigger).or_default().push(Bind { - action, + action: BoundAction::Press(action), ..bind.clone() }); } From 73e96877be16b95f362e0b886d40a0b6e0f82429 Mon Sep 17 00:00:00 2001 From: Justin Spedding Date: Tue, 22 Sep 2026 16:08:00 -0400 Subject: [PATCH 10/40] input: add find_pointer_bind() --- src/input/mod.rs | 136 ++++++++++++----------------------------------- 1 file changed, 34 insertions(+), 102 deletions(-) diff --git a/src/input/mod.rs b/src/input/mod.rs index e8ea269ab2..c8c546d358 100644 --- a/src/input/mod.rs +++ b/src/input/mod.rs @@ -2922,6 +2922,22 @@ impl State { self.niri.queue_redraw_all(); } + fn find_pointer_bind( + &mut self, + mod_key: ModKey, + trigger: Trigger, + mods: ModifiersState, + ) -> Option { + let modifiers = modifiers_from_state(mods); + + let config = self.niri.config.borrow(); + let bindings = make_binds_iter(&config, &mut self.niri.window_mru_ui, modifiers); + + find_configured_bind(bindings, mod_key, trigger, mods).filter(|bind| { + !self.niri.screenshot_ui.is_open() || bind_allowed_during_screenshot(bind, true) + }) + } + fn on_pointer_button(&mut self, event: I::PointerButtonEvent) { let pointer = self.niri.seat.get_pointer().unwrap(); @@ -2977,15 +2993,8 @@ impl State { Some(MouseButton::Forward) => Some(Trigger::MouseForward), _ => None, } - .and_then(|trigger| { - let config = self.niri.config.borrow(); - let bindings = - make_binds_iter(&config, &mut self.niri.window_mru_ui, modifiers); - find_configured_bind(bindings, mod_key, trigger, mods) - }) - .filter(|bind| { - !self.niri.screenshot_ui.is_open() || bind_allowed_during_screenshot(bind, true) - }) { + .and_then(|trigger| self.find_pointer_bind(mod_key, trigger, mods)) + { self.niri.suppressed_buttons.insert(button_code); if bind.has_press() { self.handle_bind(bind.clone(), true); @@ -3341,30 +3350,10 @@ impl State { }); (bind_left, bind_right) } else { - let config = self.niri.config.borrow(); - let bindings = - make_binds_iter(&config, &mut self.niri.window_mru_ui, modifiers); - let bind_left = find_configured_bind( - bindings.clone(), - mod_key, - Trigger::WheelScrollLeft, - mods, - ) - .filter(|bind| { - !self.niri.screenshot_ui.is_open() - || bind_allowed_during_screenshot(bind, true) - }); - let bind_right = find_configured_bind( - bindings, - mod_key, - Trigger::WheelScrollRight, - mods, + ( + self.find_pointer_bind(mod_key, Trigger::WheelScrollLeft, mods), + self.find_pointer_bind(mod_key, Trigger::WheelScrollRight, mods), ) - .filter(|bind| { - !self.niri.screenshot_ui.is_open() - || bind_allowed_during_screenshot(bind, true) - }); - (bind_left, bind_right) }; if let Some(right) = bind_right { @@ -3447,26 +3436,10 @@ impl State { }); (bind_up, bind_down) } else { - let config = self.niri.config.borrow(); - let bindings = - make_binds_iter(&config, &mut self.niri.window_mru_ui, modifiers); - let bind_up = find_configured_bind( - bindings.clone(), - mod_key, - Trigger::WheelScrollUp, - mods, + ( + self.find_pointer_bind(mod_key, Trigger::WheelScrollUp, mods), + self.find_pointer_bind(mod_key, Trigger::WheelScrollDown, mods), ) - .filter(|bind| { - !self.niri.screenshot_ui.is_open() - || bind_allowed_during_screenshot(bind, true) - }); - let bind_down = - find_configured_bind(bindings, mod_key, Trigger::WheelScrollDown, mods) - .filter(|bind| { - !self.niri.screenshot_ui.is_open() - || bind_allowed_during_screenshot(bind, true) - }); - (bind_up, bind_down) }; if let Some(down) = bind_down { @@ -3599,26 +3572,10 @@ impl State { .horizontal_finger_scroll_tracker .accumulate(horizontal); if ticks != 0 { - let config = self.niri.config.borrow(); - let bindings = - make_binds_iter(&config, &mut self.niri.window_mru_ui, modifiers); - let bind_left = find_configured_bind( - bindings.clone(), - mod_key, - Trigger::TouchpadScrollLeft, - mods, - ) - .filter(|bind| { - !self.niri.screenshot_ui.is_open() - || bind_allowed_during_screenshot(bind, true) - }); - let bind_right = - find_configured_bind(bindings, mod_key, Trigger::TouchpadScrollRight, mods) - .filter(|bind| { - !self.niri.screenshot_ui.is_open() - || bind_allowed_during_screenshot(bind, true) - }); - drop(config); + let (bind_left, bind_right) = ( + self.find_pointer_bind(mod_key, Trigger::TouchpadScrollLeft, mods), + self.find_pointer_bind(mod_key, Trigger::TouchpadScrollRight, mods), + ); if let Some(right) = bind_right { for _ in 0..ticks { @@ -3637,26 +3594,10 @@ impl State { .vertical_finger_scroll_tracker .accumulate(vertical); if ticks != 0 { - let config = self.niri.config.borrow(); - let bindings = - make_binds_iter(&config, &mut self.niri.window_mru_ui, modifiers); - let bind_up = find_configured_bind( - bindings.clone(), - mod_key, - Trigger::TouchpadScrollUp, - mods, - ) - .filter(|bind| { - !self.niri.screenshot_ui.is_open() - || bind_allowed_during_screenshot(bind, true) - }); - let bind_down = - find_configured_bind(bindings, mod_key, Trigger::TouchpadScrollDown, mods) - .filter(|bind| { - !self.niri.screenshot_ui.is_open() - || bind_allowed_during_screenshot(bind, true) - }); - drop(config); + let (bind_up, bind_down) = ( + self.find_pointer_bind(mod_key, Trigger::TouchpadScrollUp, mods), + self.find_pointer_bind(mod_key, Trigger::TouchpadScrollDown, mods), + ); if let Some(down) = bind_down { for _ in 0..ticks { @@ -4085,16 +4026,7 @@ impl State { let modifiers = modifiers_from_state(mods); if self.niri.mods_with_tablet_stylus_binds.contains(&modifiers) { - let bind = { - let config = self.niri.config.borrow(); - let bindings = config.binds.0.iter(); - find_configured_bind(bindings, mod_key, trigger, mods) - } - .filter(|bind| { - !self.niri.screenshot_ui.is_open() - || bind_allowed_during_screenshot(bind, true) - }); - if let Some(bind) = bind { + if let Some(bind) = self.find_pointer_bind(mod_key, trigger, mods) { self.niri.suppressed_buttons.insert(button); if bind.has_press() { self.handle_bind(bind.clone(), true); @@ -5640,7 +5572,7 @@ fn make_binds_iter<'a>( config: &'a Config, mru: &'a mut WindowMruUi, mods: Modifiers, -) -> impl Iterator + Clone { +) -> impl Iterator { // Figure out the binds to use depending on whether the MRU is enabled and/or open. let general_binds = (!mru.is_open()).then_some(config.binds.0.iter()); let general_binds = general_binds.into_iter().flatten(); From ab05d6d88abf18b61bb76c3f824e239b6ab6ade2 Mon Sep 17 00:00:00 2001 From: Justin Spedding Date: Tue, 22 Sep 2026 16:08:52 -0400 Subject: [PATCH 11/40] input: support release actions on mouse buttons --- src/input/mod.rs | 24 ++++++++++++++++++++++++ src/niri.rs | 4 ++++ 2 files changed, 28 insertions(+) diff --git a/src/input/mod.rs b/src/input/mod.rs index c8c546d358..68def8a283 100644 --- a/src/input/mod.rs +++ b/src/input/mod.rs @@ -808,6 +808,12 @@ impl State { .drain() .map(|(_, bind)| bind), ); + binds.extend( + self.niri + .pending_mouse_release_binds + .drain() + .map(|(_, bind)| bind), + ); // Forget the rest of the input state: the releases will never arrive. self.niri.clear_input_state(); @@ -2955,6 +2961,14 @@ impl State { let modifiers = modifiers_from_state(mods); let mod_down = modifiers.contains(mod_key.to_modifiers()); + if ButtonState::Released == button_state { + if let Some(bind) = self.niri.pending_mouse_release_binds.remove(&button_code) { + self.niri.suppressed_buttons.remove(&button_code); + self.handle_bind(bind, false); + return; + } + } + if self.niri.suppressed_buttons.remove(&button_code) { return; } @@ -2996,6 +3010,15 @@ impl State { .and_then(|trigger| self.find_pointer_bind(mod_key, trigger, mods)) { self.niri.suppressed_buttons.insert(button_code); + if should_record_release_bind( + &bind, + self.niri.is_locked(), + &self.niri.bind_cooldown_timers, + ) { + self.niri + .pending_mouse_release_binds + .insert(button_code, bind.clone()); + } if bind.has_press() { self.handle_bind(bind.clone(), true); } @@ -4642,6 +4665,7 @@ impl State { impl Niri { fn cancel_interrupted_release_binds(&mut self) { retain_press_binds(&mut self.pending_release_binds); + retain_press_binds(&mut self.pending_mouse_release_binds); } } diff --git a/src/niri.rs b/src/niri.rs index 578e0f03b8..b36bd4995f 100644 --- a/src/niri.rs +++ b/src/niri.rs @@ -361,6 +361,8 @@ pub struct Niri { pub suppressed_buttons: HashSet, /// Binds whose release action is pending, keyed by the key code. pub pending_release_binds: HashMap, + /// Same as `pending_release_binds`, but for mouse buttons, keyed by the button code. + pub pending_mouse_release_binds: HashMap, pub bind_cooldown_timers: HashMap, pub bind_repeat_timer: Option, pub keyboard_focus: KeyboardFocus, @@ -2840,6 +2842,7 @@ impl Niri { suppressed_keys: HashSet::new(), suppressed_buttons: HashSet::new(), pending_release_binds: HashMap::new(), + pending_mouse_release_binds: HashMap::new(), bind_cooldown_timers: HashMap::new(), bind_repeat_timer: Option::default(), presentation_state, @@ -6597,6 +6600,7 @@ impl Niri { self.suppressed_keys.clear(); self.suppressed_buttons.clear(); self.pending_release_binds.clear(); + self.pending_mouse_release_binds.clear(); // Stop ongoing key repeat if any. if let Some(token) = self.bind_repeat_timer.take() { From eb9e029708f4b27f96248f334e40f1ee69703c6e Mon Sep 17 00:00:00 2001 From: Justin Spedding Date: Tue, 22 Sep 2026 16:09:49 -0400 Subject: [PATCH 12/40] input: support release actions on tablet stylus buttons --- src/input/mod.rs | 25 +++++++++++++++++++++++++ src/niri.rs | 4 ++++ 2 files changed, 29 insertions(+) diff --git a/src/input/mod.rs b/src/input/mod.rs index 68def8a283..a4be54b74d 100644 --- a/src/input/mod.rs +++ b/src/input/mod.rs @@ -814,6 +814,12 @@ impl State { .drain() .map(|(_, bind)| bind), ); + binds.extend( + self.niri + .pending_tablet_release_binds + .drain() + .map(|(_, bind)| bind), + ); // Forget the rest of the input state: the releases will never arrive. self.niri.clear_input_state(); @@ -4034,6 +4040,15 @@ impl State { _ => None, }; + // Handle release binds. + if button_state == ButtonState::Released { + if let Some(bind) = self.niri.pending_tablet_release_binds.remove(&button) { + self.niri.suppressed_buttons.remove(&button); + self.handle_bind(bind, false); + return; + } + } + if self.niri.suppressed_buttons.remove(&button) { return; } @@ -4051,6 +4066,15 @@ impl State { if self.niri.mods_with_tablet_stylus_binds.contains(&modifiers) { if let Some(bind) = self.find_pointer_bind(mod_key, trigger, mods) { self.niri.suppressed_buttons.insert(button); + if should_record_release_bind( + &bind, + self.niri.is_locked(), + &self.niri.bind_cooldown_timers, + ) { + self.niri + .pending_tablet_release_binds + .insert(button, bind.clone()); + } if bind.has_press() { self.handle_bind(bind.clone(), true); } @@ -4666,6 +4690,7 @@ impl Niri { fn cancel_interrupted_release_binds(&mut self) { retain_press_binds(&mut self.pending_release_binds); retain_press_binds(&mut self.pending_mouse_release_binds); + retain_press_binds(&mut self.pending_tablet_release_binds); } } diff --git a/src/niri.rs b/src/niri.rs index b36bd4995f..e5d7ba9ab3 100644 --- a/src/niri.rs +++ b/src/niri.rs @@ -363,6 +363,8 @@ pub struct Niri { pub pending_release_binds: HashMap, /// Same as `pending_release_binds`, but for mouse buttons, keyed by the button code. pub pending_mouse_release_binds: HashMap, + /// Same as `pending_release_binds`, but for tablet tool buttons, keyed by the button code. + pub pending_tablet_release_binds: HashMap, pub bind_cooldown_timers: HashMap, pub bind_repeat_timer: Option, pub keyboard_focus: KeyboardFocus, @@ -2843,6 +2845,7 @@ impl Niri { suppressed_buttons: HashSet::new(), pending_release_binds: HashMap::new(), pending_mouse_release_binds: HashMap::new(), + pending_tablet_release_binds: HashMap::new(), bind_cooldown_timers: HashMap::new(), bind_repeat_timer: Option::default(), presentation_state, @@ -6601,6 +6604,7 @@ impl Niri { self.suppressed_buttons.clear(); self.pending_release_binds.clear(); self.pending_mouse_release_binds.clear(); + self.pending_tablet_release_binds.clear(); // Stop ongoing key repeat if any. if let Some(token) = self.bind_repeat_timer.take() { From 3c6e215cc87e890b398c485e69c5d708368b436e Mon Sep 17 00:00:00 2001 From: Ivan Molodetskikh Date: Wed, 23 Sep 2026 19:33:43 -0400 Subject: [PATCH 13/40] tests/binds: Print events before any ops too --- src/tests/binds.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/tests/binds.rs b/src/tests/binds.rs index df3e0bbc81..5f76de8a58 100644 --- a/src/tests/binds.rs +++ b/src/tests/binds.rs @@ -78,6 +78,9 @@ fn run_f(f: &mut Fixture, id: ClientId, surface: &WlSurface, input: &str) -> Str }); let mut rv = String::new(); + for event in f.client(id).state.recent_keyboard_events(surface) { + let _ = writeln!(&mut rv, " surface {event}"); + } for op in ops { let (code, key_state) = match op { From 9828c09d32ce5c32e9f8071ce8d0b82bf9ee3907 Mon Sep 17 00:00:00 2001 From: Justin Spedding Date: Wed, 23 Sep 2026 19:33:43 -0400 Subject: [PATCH 14/40] input: add press and release bind tests --- niri-config/src/lib.rs | 440 +++++++++++ src/input/mod.rs | 456 +++++++++-- src/tests/binds.rs | 1413 +++++++++++++++++++++++++++++++++- src/tests/client.rs | 13 + src/tests/virtual_pointer.rs | 3 +- 5 files changed, 2210 insertions(+), 115 deletions(-) diff --git a/niri-config/src/lib.rs b/niri-config/src/lib.rs index 40eccb556b..378da87722 100644 --- a/niri-config/src/lib.rs +++ b/niri-config/src/lib.rs @@ -626,6 +626,7 @@ impl ConfigPath { mod tests { use insta::{assert_debug_snapshot, assert_snapshot}; use pretty_assertions::assert_eq; + use smithay::input::keyboard::Keysym; use super::*; @@ -648,6 +649,445 @@ mod tests { .unwrap() } + #[test] + fn parse_release_binds_for_supported_triggers() { + // Keys and mouse buttons have release events, so release sections are supported. + do_parse( + r#" + binds { + Mod+MouseLeft { + release { close-window; } + } + Mod+Q { + press { close-window; } + release { close-window; } + } + } + "#, + ); + } + + #[test] + fn parse_release_binds_for_unsupported_triggers() { + // Scroll ticks are dispatched as press events only, so scroll binds can't have a release + // action. + assert_parse_error( + r#" + binds { + Mod+WheelScrollDown { + release { toggle-overview; } + } + } + "#, + "release sections are not supported for scroll binds", + ); + assert_parse_error( + r#" + binds { + Mod+TouchpadScrollDown { + release { toggle-overview; } + } + } + "#, + "release sections are not supported for scroll binds", + ); + } + + #[test] + fn parse_bind_repeat_on_release_only() { + // A release-only bind has no press action to repeat, so repeat=true is rejected. + assert_parse_error( + r#" + binds { + Mod+Q repeat=true { + release { close-window; } + } + } + "#, + "release-only binds cannot repeat", + ); + + do_parse( + r#" + binds { + Mod+T repeat=true { spawn "alacritty"; } + Mod+Q repeat=true { + press { close-window; } + release { close-window; } + } + Mod+W { + release { close-window; } + } + Mod+E repeat=false { + release { close-window; } + } + } + "#, + ); + } + + /// Asserts that parsing fails with a diagnostic containing `message`. + #[track_caller] + fn assert_parse_error(text: &str, message: &str) { + let err = match Config::parse_mem(text) { + Ok(_) => panic!("expected a parse error for:\n{text}"), + Err(err) => err, + }; + + // knuffel keeps the individual diagnostics in a private field, so check the Debug + // representation, which includes their messages. + let err = format!("{err:?}"); + assert!( + err.contains(message), + "expected {message:?} in parse error:\n{err}" + ); + } + + #[test] + fn parse_bind_section_errors() { + // A direct action node and a `press` section both specify the press action, so mixing them + // is reported as mixing the two syntaxes rather than as a duplicate `press` section. + assert_parse_error( + r#" + binds { + Mod+Q { + close-window; + press { center-column; } + } + } + "#, + "cannot mix direct actions with press/release sections", + ); + + // A second direct action is a duplicate action, not a mix of the two syntaxes. + assert_parse_error( + r#" + binds { + Mod+Q { + close-window; + center-column; + } + } + "#, + "only one action is allowed per keybind", + ); + + // A direct action following a `press` section is mixing the two syntaxes. + assert_parse_error( + r#" + binds { + Mod+Q { + press { close-window; } + center-column; + } + } + "#, + "cannot mix direct actions with press/release sections", + ); + + // Two `press` sections are still duplicates. + assert_parse_error( + r#" + binds { + Mod+Q { + press { close-window; } + press { center-column; } + } + } + "#, + "duplicate `press` section", + ); + + assert_parse_error( + r#" + binds { + Mod+Q { + center-column; + release { close-window; } + } + } + "#, + "cannot mix direct actions with press/release sections", + ); + + assert_parse_error( + r#" + binds { + Mod+Q { + release { close-window; } + center-column; + } + } + "#, + "cannot mix direct actions with press/release sections", + ); + } + + #[test] + fn parse_bind_bound_action() { + let config = do_parse( + r#" + binds { + Mod+A { close-window; } + Mod+B { + press { close-window; } + } + Mod+C { + release { toggle-overview; } + } + Mod+D { + press { close-window; } + release { toggle-overview; } + } + } + "#, + ); + + let action = |index: usize| &config.binds.0[index].action; + + // A direct action and a press section both bind only the press phase. + assert!(matches!(action(0), BoundAction::Press(Action::CloseWindow))); + assert!(matches!(action(1), BoundAction::Press(Action::CloseWindow))); + + // A release section binds only the release phase. + assert!(matches!( + action(2), + BoundAction::Release(Action::ToggleOverview) + )); + + // Both sections bind both phases. + assert!(matches!( + action(3), + BoundAction::Both { + press: Action::CloseWindow, + release: Action::ToggleOverview, + } + )); + } + + #[test] + fn parse_bare_modifier_keys() { + let config = do_parse( + r#" + binds { + Mod { + release { toggle-overview; } + } + Ctrl { + release { close-window; } + } + Shift { + release { close-window; } + } + Alt { + release { close-window; } + } + Super { + release { close-window; } + } + Mod5 { + release { close-window; } + } + Mod3 { + release { close-window; } + } + + Ctrl+Alt_L { + release { close-window; } + } + } + "#, + ); + + let triggers = config + .binds + .0 + .iter() + .map(|bind| bind.key.trigger) + .collect::>(); + + assert_eq!( + triggers, + vec![ + Trigger::CompositorMod, + Trigger::Modifier(ModKey::Ctrl), + Trigger::Modifier(ModKey::Shift), + Trigger::Modifier(ModKey::Alt), + Trigger::Modifier(ModKey::Super), + Trigger::Modifier(ModKey::IsoLevel3Shift), + Trigger::Modifier(ModKey::IsoLevel5Shift), + Trigger::Keysym(Keysym::Alt_L), + ] + ); + + // Ctrl+Alt_L has modifiers of Ctrl (manually specified) and Alt (implied by the keysym). + assert_eq!( + config.binds.0[7].key.modifiers, + Modifiers::CTRL | Modifiers::ALT + ); + } + + #[test] + fn parse_bind_conflicting_modifier_keys() { + // A modifier key can be spelled as the modifier itself (`Alt`) or as its keysym (`Alt_L`), + // and a modifier key is triggered by either of its keysyms, so one of these two binds + // would always shadow the other. + assert_parse_error( + r#" + binds { + Alt { + release { toggle-overview; } + } + Alt_L { + release { close-window; } + } + } + "#, + "conflicting keybind later defined here", + ); + assert_parse_error( + r#" + recent-windows { + binds { + Alt+Control_L { next-window; } + Alt+Ctrl { next-window; } + } + } + "#, + "conflicting keybind later defined here", + ); + + // The left and right keysyms are different keys, so those can be bound separately. + do_parse( + r#" + binds { + Alt_L { + release { close-window; } + } + Alt_R { + release { center-column; } + } + } + "#, + ); + + // A modifier keysym on its own is an ordinary modifier-only bind. + let config = do_parse( + r#" + binds { + Alt_L { + release { close-window; } + } + } + "#, + ); + assert!(config.binds.0[0].key.trigger.is_modifier()); + } + + #[test] + fn parse_recent_windows_bind_modifier() { + // Modifier-only binds are allowed. + do_parse( + r#" + recent-windows { + binds { + Ctrl+Alt_L { next-window; } + Ctrl+Shift { next-window; } + } + } + "#, + ); + + // Binds must have an extra modifier. + assert_parse_error( + r#" + recent-windows { + binds { + Alt_L { next-window; } + } + } + "#, + "keybind must have a modifier key", + ); + assert_parse_error( + r#" + recent-windows { + binds { + Alt+Alt_L { next-window; } + } + } + "#, + "keybind must have a modifier key", + ); + + // A trigger with no modifier at all is rejected. + assert_parse_error( + r#" + recent-windows { + binds { + Tab { next-window; } + } + } + "#, + "keybind must have a modifier key", + ); + + // Only keyboard keys are supported here for now. + assert_parse_error( + r#" + recent-windows { + binds { + Alt+MouseLeft { next-window; } + } + } + "#, + "key must be a keyboard key", + ); + } + + #[test] + fn parse_bind_redundant_modifiers_are_the_same_bind() { + // Redundant modifiers are normalized, so these should error as duplicates. + assert_parse_error( + r#" + binds { + Alt_L { close-window; } + Alt+Alt_L { center-column; } + } + "#, + "duplicate keybind later defined here", + ); + assert_parse_error( + r#" + binds { + Alt { close-window; } + Alt+Alt { center-column; } + } + "#, + "duplicate keybind later defined here", + ); + assert_parse_error( + r#" + binds { + Mod { close-window; } + Mod+Mod { center-column; } + } + "#, + "duplicate keybind later defined here", + ); + assert_parse_error( + r#" + recent-windows { + binds { + Alt+Control_L { next-window; } + Alt+Ctrl+Control_L { next-window; } + } + } + "#, + "duplicate keybind later defined here", + ); + } + #[test] fn parse_on_xdg_activate() { let parsed = do_parse( diff --git a/src/input/mod.rs b/src/input/mod.rs index a4be54b74d..03d9fbd9cc 100644 --- a/src/input/mod.rs +++ b/src/input/mod.rs @@ -5640,82 +5640,150 @@ fn make_binds_iter<'a>( #[cfg(test)] mod tests { use super::*; + use crate::animation::Clock; + + const CLOSE_KEYSYM: Keysym = Keysym::q; + const CLOSE_KEY_CODE: Keycode = Keycode::new(CLOSE_KEYSYM.raw()); + struct TestState { + screenshot_ui: ScreenshotUi, + disable_power_key_handling: bool, + is_inhibiting: bool, + is_locked: bool, + suppressed_keys: HashSet, + pending_release_binds: HashMap, + cooldown_timers: HashMap, + } + + fn create_test_state() -> TestState { + TestState { + screenshot_ui: ScreenshotUi::new(Clock::default(), Default::default()), + disable_power_key_handling: false, + is_inhibiting: false, + is_locked: false, + suppressed_keys: HashSet::new(), + pending_release_binds: HashMap::new(), + cooldown_timers: HashMap::new(), + } + } + + /// Returns a bind with the given trigger, modifiers, and action, and default flags. + fn bind(trigger: Trigger, modifiers: Modifiers, action: BoundAction) -> Bind { + Bind { + key: Key { trigger, modifiers }, + action, + repeat: false, + cooldown: None, + allow_when_locked: false, + allow_inhibiting: true, + hotkey_overlay_title: None, + } + } + + /// Returns a `Mod+Q` bind with the given action and default flags. + fn close_bind(action: BoundAction) -> Bind { + bind(Trigger::Keysym(CLOSE_KEYSYM), Modifiers::COMPOSITOR, action) + } + + fn mod_held() -> ModifiersState { + ModifiersState { + logo: true, + ..Default::default() + } + } + + /// Processes a press of the `Mod+Q` bind that the tests are written with. + fn press_close_key( + state: &mut TestState, + bindings: &Binds, + mods: ModifiersState, + ) -> ShouldInterceptResult { + process_close_key(state, bindings, mods, KeyState::Pressed) + } + + /// Processes a release of the `Mod+Q` bind that the tests are written with. + fn release_close_key( + state: &mut TestState, + bindings: &Binds, + mods: ModifiersState, + ) -> ShouldInterceptResult { + process_close_key(state, bindings, mods, KeyState::Released) + } + + fn process_close_key( + state: &mut TestState, + bindings: &Binds, + mods: ModifiersState, + key_state: KeyState, + ) -> ShouldInterceptResult { + let pressed = key_state == KeyState::Pressed; + + if pressed { + retain_press_binds(&mut state.pending_release_binds); + } + + should_intercept_key( + &mut state.suppressed_keys, + &mut state.pending_release_binds, + &state.cooldown_timers, + &bindings.0, + ModKey::Super, + CLOSE_KEY_CODE, + CLOSE_KEYSYM, + Some(CLOSE_KEYSYM), + pressed, + mods, + &state.screenshot_ui, + state.disable_power_key_handling, + state.is_inhibiting, + state.is_locked, + ) + } + + macro_rules! assert_matches { + ($expression:expr, $pattern:pat) => { + let value = $expression; + assert!( + matches!(value, $pattern), + "Expected {:?} to match {}", + value, + stringify!($pattern) + ); + }; + } #[test] fn comp_mod_handling() { let bindings = Binds(vec![ - Bind { - key: Key { - trigger: Trigger::Keysym(Keysym::q), - modifiers: Modifiers::COMPOSITOR, - }, - action: BoundAction::Press(Action::CloseWindow), - repeat: true, - cooldown: None, - allow_when_locked: false, - allow_inhibiting: true, - hotkey_overlay_title: None, - }, - Bind { - key: Key { - trigger: Trigger::Keysym(Keysym::h), - modifiers: Modifiers::SUPER, - }, - action: BoundAction::Press(Action::FocusColumnLeft), - repeat: true, - cooldown: None, - allow_when_locked: false, - allow_inhibiting: true, - hotkey_overlay_title: None, - }, - Bind { - key: Key { - trigger: Trigger::Keysym(Keysym::j), - modifiers: Modifiers::empty(), - }, - action: BoundAction::Press(Action::FocusWindowDown), - repeat: true, - cooldown: None, - allow_when_locked: false, - allow_inhibiting: true, - hotkey_overlay_title: None, - }, - Bind { - key: Key { - trigger: Trigger::Keysym(Keysym::k), - modifiers: Modifiers::COMPOSITOR | Modifiers::SUPER, - }, - action: BoundAction::Press(Action::FocusWindowUp), - repeat: true, - cooldown: None, - allow_when_locked: false, - allow_inhibiting: true, - hotkey_overlay_title: None, - }, - Bind { - key: Key { - trigger: Trigger::Keysym(Keysym::l), - modifiers: Modifiers::SUPER | Modifiers::ALT, - }, - action: BoundAction::Press(Action::FocusColumnRight), - repeat: true, - cooldown: None, - allow_when_locked: false, - allow_inhibiting: true, - hotkey_overlay_title: None, - }, - Bind { - key: Key { - trigger: Trigger::Keysym(Keysym::Super_L), - modifiers: Modifiers::empty(), - }, - action: BoundAction::Release(Action::ToggleOverview), - repeat: false, - cooldown: None, - allow_when_locked: false, - allow_inhibiting: true, - hotkey_overlay_title: None, - }, + bind( + Trigger::Keysym(Keysym::q), + Modifiers::COMPOSITOR, + BoundAction::Press(Action::CloseWindow), + ), + bind( + Trigger::Keysym(Keysym::h), + Modifiers::SUPER, + BoundAction::Press(Action::FocusColumnLeft), + ), + bind( + Trigger::Keysym(Keysym::j), + Modifiers::empty(), + BoundAction::Press(Action::FocusWindowDown), + ), + bind( + Trigger::Keysym(Keysym::k), + Modifiers::COMPOSITOR | Modifiers::SUPER, + BoundAction::Press(Action::FocusWindowUp), + ), + bind( + Trigger::Keysym(Keysym::l), + Modifiers::SUPER | Modifiers::ALT, + BoundAction::Press(Action::FocusColumnRight), + ), + bind( + Trigger::Keysym(Keysym::Super_L), + Modifiers::empty(), + BoundAction::Release(Action::ToggleOverview), + ), ]); assert_eq!( @@ -5850,4 +5918,248 @@ mod tests { Some(&bindings.0[5]) ); } + + #[test] + fn bind_allowed_when_locked_requires_both_actions_of_a_both_bind() { + // A press could start something that its release would never undo, so both actions must be + // allowed while locked. + let both = |press, release| close_bind(BoundAction::Both { press, release }); + assert!(!bind_allowed_when_locked( + &both(Action::Suspend, Action::CloseWindow), + true + )); + assert!(!bind_allowed_when_locked( + &both(Action::CloseWindow, Action::Suspend), + true + )); + assert!(bind_allowed_when_locked( + &both(Action::Suspend, Action::PowerOffMonitors), + true + )); + } + + #[test] + fn bind_allowed_when_locked_requires_only_the_single_action() { + assert!(bind_allowed_when_locked( + &close_bind(BoundAction::Press(Action::Suspend)), + true + )); + assert!(!bind_allowed_when_locked( + &close_bind(BoundAction::Press(Action::CloseWindow)), + true + )); + assert!(bind_allowed_when_locked( + &close_bind(BoundAction::Release(Action::Suspend)), + false + )); + assert!(!bind_allowed_when_locked( + &close_bind(BoundAction::Release(Action::CloseWindow)), + false + )); + } + + #[test] + fn bind_allowed_when_locked_always_allows_a_both_bind_release() { + // The release only happens if its press ran, and the press required both actions to be + // allowed, so the release is never blocked separately. + let both = close_bind(BoundAction::Both { + press: Action::Suspend, + release: Action::CloseWindow, + }); + assert!(bind_allowed_when_locked(&both, false)); + } + + #[test] + fn allow_when_locked_allows_the_whole_bind() { + let bind = |action| Bind { + allow_when_locked: true, + ..close_bind(action) + }; + assert!(bind_allowed_when_locked( + &bind(BoundAction::Press(Action::CloseWindow)), + true + )); + assert!(bind_allowed_when_locked( + &bind(BoundAction::Release(Action::CloseWindow)), + false + )); + + let both = bind(BoundAction::Both { + press: Action::CloseWindow, + release: Action::CenterColumn, + }); + assert!(bind_allowed_when_locked(&both, true)); + assert!(bind_allowed_when_locked(&both, false)); + } + + #[test] + fn locked_both_bind_does_not_run_its_press_when_its_release_is_not_allowed() { + // `Mod+Q { press { suspend; } release { close-window; } }`: the press is allowed while + // locked but the release isn't, so the press must not run, since nothing could undo it. + let bindings = Binds(vec![close_bind(BoundAction::Both { + press: Action::Suspend, + release: Action::CloseWindow, + })]); + + let mut state = TestState { + is_locked: true, + ..create_test_state() + }; + let result = press_close_key(&mut state, &bindings, mod_held()); + + // `handle_bind()` drops the press while locked, and nothing is recorded to undo. + assert_matches!(result, ShouldInterceptResult::InterceptAndHandle(_)); + assert!(state.pending_release_binds.is_empty()); + + let result = release_close_key(&mut state, &bindings, mod_held()); + assert_matches!(result, ShouldInterceptResult::InterceptOnly); + } + + #[test] + fn both_bind_release_recorded_while_unlocked_runs_while_locked() { + // Its press action ran while unlocked, so the release must run later even if the screen got + // locked in between. + let bindings = Binds(vec![close_bind(BoundAction::Both { + press: Action::Suspend, + release: Action::CloseWindow, + })]); + + let mut state = create_test_state(); + let result = press_close_key(&mut state, &bindings, mod_held()); + assert_matches!(result, ShouldInterceptResult::InterceptAndHandle(_)); + assert!(state.pending_release_binds.contains_key(&CLOSE_KEY_CODE)); + + state.is_locked = true; + let result = release_close_key(&mut state, &bindings, mod_held()); + assert_matches!( + result, + ShouldInterceptResult::InterceptAndHandle(Bind { + action: BoundAction::Both { + release: Action::CloseWindow, + .. + }, + .. + }) + ); + assert!(state.pending_release_binds.is_empty()); + } + + #[test] + fn release_only_bind_is_recorded_on_press_and_runs_on_release() { + let bindings = Binds(vec![close_bind(BoundAction::Release(Action::CloseWindow))]); + + let mut state = create_test_state(); + let result = press_close_key(&mut state, &bindings, mod_held()); + assert_matches!(result, ShouldInterceptResult::InterceptOnly); + assert!(state.pending_release_binds.contains_key(&CLOSE_KEY_CODE)); + + let result = release_close_key(&mut state, &bindings, mod_held()); + assert_matches!( + result, + ShouldInterceptResult::InterceptAndHandle(Bind { + action: BoundAction::Release(Action::CloseWindow), + .. + }) + ); + assert!(state.pending_release_binds.is_empty()); + } + + #[test] + fn release_only_bind_allowed_while_locked_is_recorded_and_runs_while_locked() { + let bindings = Binds(vec![Bind { + allow_when_locked: true, + ..close_bind(BoundAction::Release(Action::CloseWindow)) + }]); + + let mut state = TestState { + is_locked: true, + ..create_test_state() + }; + let result = press_close_key(&mut state, &bindings, mod_held()); + assert_matches!(result, ShouldInterceptResult::InterceptOnly); + assert!(state.pending_release_binds.contains_key(&CLOSE_KEY_CODE)); + + let result = release_close_key(&mut state, &bindings, mod_held()); + assert_matches!( + result, + ShouldInterceptResult::InterceptAndHandle(Bind { + action: BoundAction::Release(Action::CloseWindow), + .. + }) + ); + assert!(state.pending_release_binds.is_empty()); + } + + #[test] + fn release_only_bind_not_allowed_while_locked_is_not_recorded_while_locked() { + let bindings = Binds(vec![close_bind(BoundAction::Release(Action::CloseWindow))]); + + let mut state = TestState { + is_locked: true, + ..create_test_state() + }; + let result = press_close_key(&mut state, &bindings, mod_held()); + assert_matches!(result, ShouldInterceptResult::InterceptOnly); + assert!(state.pending_release_binds.is_empty()); + + let result = release_close_key(&mut state, &bindings, mod_held()); + assert_matches!(result, ShouldInterceptResult::InterceptOnly); + assert!(state.pending_release_binds.is_empty()); + } + + #[test] + fn bind_allowed_during_screenshot_requires_both_actions_of_a_both_bind() { + // A press could start something that its release would never undo, so both actions must be + // allowed while the screenshot UI is open. + let both = |press, release| close_bind(BoundAction::Both { press, release }); + assert!(!bind_allowed_during_screenshot( + &both(Action::MoveColumnLeft, Action::CloseWindow), + true + )); + assert!(!bind_allowed_during_screenshot( + &both(Action::CloseWindow, Action::MoveColumnLeft), + true + )); + assert!(bind_allowed_during_screenshot( + &both(Action::MoveColumnLeft, Action::MoveWindowUp), + true + )); + } + + #[test] + fn bind_allowed_during_screenshot_requires_only_the_single_action() { + assert!(bind_allowed_during_screenshot( + &close_bind(BoundAction::Press(Action::MoveColumnLeft)), + true + )); + assert!(!bind_allowed_during_screenshot( + &close_bind(BoundAction::Press(Action::CloseWindow)), + true + )); + } + + #[test] + fn bind_allowed_during_screenshot_always_allows_a_both_bind_release() { + // The release only happens if its press ran, so the screenshot UI opening in between must + // not block it. + let both = |press, release| close_bind(BoundAction::Both { press, release }); + assert!(bind_allowed_during_screenshot( + &both(Action::MoveColumnLeft, Action::CloseWindow), + false + )); + assert!(bind_allowed_during_screenshot( + &both(Action::CloseWindow, Action::MoveColumnLeft), + false + )); + + // A release-only bind has no press to undo, so its own action must be allowed. + assert!(bind_allowed_during_screenshot( + &close_bind(BoundAction::Release(Action::MoveColumnLeft)), + false + )); + assert!(!bind_allowed_during_screenshot( + &close_bind(BoundAction::Release(Action::CloseWindow)), + false + )); + } } diff --git a/src/tests/binds.rs b/src/tests/binds.rs index 5f76de8a58..4e5afe6b4a 100644 --- a/src/tests/binds.rs +++ b/src/tests/binds.rs @@ -4,30 +4,87 @@ use insta::assert_snapshot; use niri_config::{Action, BoundAction, Config}; use smithay::backend::input::{InputEvent, InputTime, KeyState, Keycode}; use smithay::input::keyboard::xkb::Keymap; +use wayland_client::protocol::wl_pointer; use wayland_client::protocol::wl_surface::WlSurface; use crate::tests::client::ClientId; use crate::tests::fixture::Fixture; use crate::tests::test_input_backend::{TestInputBackend, TestKeyboardKeyEvent}; +#[derive(Clone, Copy)] enum Op { Press(Keycode), Release(Keycode), + MouseButton { + name: &'static str, + code: u32, + state: wl_pointer::ButtonState, + }, + /// A scroll tick. Scrolls are momentary events, so they have no press or release. + Scroll { + name: &'static str, + source: wl_pointer::AxisSource, + /// Positive scrolls down. + amount: f64, + }, } +/// Mouse buttons that can be pressed and released, named after the binds that trigger them. +const MOUSE_BUTTONS: &[(&str, u32)] = &[ + ("MouseLeft", 0x110), + ("MouseRight", 0x111), + ("MouseMiddle", 0x112), + ("MouseBack", 0x113), + ("MouseForward", 0x114), +]; + +/// Scrolls, named after the binds that trigger them. +const SCROLLS: &[(&str, wl_pointer::AxisSource, f64)] = &[ + ("WheelScrollDown", wl_pointer::AxisSource::Wheel, 10.), + ("WheelScrollUp", wl_pointer::AxisSource::Wheel, -10.), +]; + fn parse(keymap: &Keymap, input: &str) -> Vec { let mut ops = Vec::new(); for part in input.split_ascii_whitespace() { - let name = &part[1..]; + // Scrolls are momentary, so they are written without a prefix. + let (pressed, name) = match part.as_bytes().first() { + Some(b'+') => (Some(true), &part[1..]), + Some(b'-') => (Some(false), &part[1..]), + _ => (None, part), + }; + + if let Some((name, source, amount)) = SCROLLS.iter().copied().find(|(n, _, _)| *n == name) { + assert!( + pressed.is_none(), + "scrolls have no press or release: {part}" + ); + ops.push(Op::Scroll { + name, + source, + amount, + }); + continue; + } + + if let Some((name, code)) = MOUSE_BUTTONS.iter().copied().find(|(n, _)| *n == name) { + let state = match pressed { + Some(true) => wl_pointer::ButtonState::Pressed, + Some(false) => wl_pointer::ButtonState::Released, + None => panic!("a mouse button needs a + or - prefix: {part}"), + }; + ops.push(Op::MouseButton { name, code, state }); + continue; + } + let Some(key) = keymap.key_by_name(name) else { panic!("unknown key {name}"); }; - let c = part.bytes().next().unwrap(); - let op = match c { - b'+' => Op::Press(key), - b'-' => Op::Release(key), - _ => panic!("keys must begin with + or -, got {c}"), + let op = match pressed { + Some(true) => Op::Press(key), + Some(false) => Op::Release(key), + None => panic!("a key needs a + or - prefix: {part}"), }; ops.push(op); @@ -37,16 +94,19 @@ fn parse(keymap: &Keymap, input: &str) -> Vec { fn set_up(config: &str) -> (Fixture, ClientId, WlSurface) { let mut config = Config::parse_mem(config).unwrap(); - // knuffel doesn't understand #[cfg(test)]... + for bind in &mut config.binds.0 { - bind.action = match &bind.action { - BoundAction::Press(_) => BoundAction::Press(Action::TestAction), - BoundAction::Release(_) => BoundAction::Release(Action::TestAction), - BoundAction::Both { .. } => BoundAction::Both { - press: Action::TestAction, - release: Action::TestAction, - }, + let actions = match &mut bind.action { + BoundAction::Press(press) => [Some(press), None], + BoundAction::Release(release) => [None, Some(release)], + BoundAction::Both { press, release } => [Some(press), Some(release)], }; + for action in actions.into_iter().flatten() { + // knuffel doesn't understand #[cfg(test)]... + if !matches!(action, Action::Screenshot(_, _)) { + *action = Action::TestAction; + } + } } let mut f = Fixture::with_config(config); @@ -83,46 +143,81 @@ fn run_f(f: &mut Fixture, id: ClientId, surface: &WlSurface, input: &str) -> Str } for op in ops { - let (code, key_state) = match op { - Op::Press(code) => (code, KeyState::Pressed), - Op::Release(code) => (code, KeyState::Released), - }; + let prev = f.niri().test_action_count; - let state = f.niri_state(); - let keyboard = state.niri.seat.get_keyboard().unwrap(); - keyboard.with_xkb_state(state, |xkb| { - let xkb = xkb.xkb().lock().unwrap(); - let xkb_state = unsafe { xkb.state() }; - let keymap = xkb_state.get_keymap(); + match op { + Op::Press(code) | Op::Release(code) => { + let key_state = match op { + Op::Press(_) => KeyState::Pressed, + Op::Release(_) => KeyState::Released, + _ => unreachable!(), + }; - let c = match key_state { - KeyState::Pressed => "+", - KeyState::Released => "-", - }; + let state = f.niri_state(); + let keyboard = state.niri.seat.get_keyboard().unwrap(); + keyboard.with_xkb_state(state, |xkb| { + let xkb = xkb.xkb().lock().unwrap(); + let xkb_state = unsafe { xkb.state() }; + let keymap = xkb_state.get_keymap(); - let name = keymap.key_get_name(code).unwrap_or("None"); - let keysym = xkb_state.key_get_one_sym(code); + let c = match key_state { + KeyState::Pressed => "+", + KeyState::Released => "-", + }; - let _ = writeln!(&mut rv, "{c}{name} {:>3} {keysym:?}", code.raw()); - }); + let name = keymap.key_get_name(code).unwrap_or("None"); + let keysym = xkb_state.key_get_one_sym(code); - let prev = state.niri.test_action_count; + let _ = writeln!(&mut rv, "{c}{name} {:>3} {keysym:?}", code.raw()); + }); - state.process_input_event(InputEvent::::Keyboard { - event: TestKeyboardKeyEvent { - time: InputTime::from_micros(0), + let state = f.niri_state(); + state.process_input_event(InputEvent::::Keyboard { + event: TestKeyboardKeyEvent { + time: InputTime::from_micros(0), + code, + state: key_state, + count: 1, // niri doesn't use this + }, + }); + } + Op::MouseButton { + name, code, - state: key_state, - count: 1, // niri doesn't use this - }, - }); + state: button_state, + } => { + let c = match button_state { + wl_pointer::ButtonState::Pressed => "+", + wl_pointer::ButtonState::Released => "-", + _ => unreachable!(), + }; + let _ = writeln!(&mut rv, "{c}{name}"); + + let pointer = f.client(id).state.virtual_pointer(); + pointer.button(0, code, button_state); + pointer.frame(); + } + Op::Scroll { + name, + source, + amount, + } => { + let _ = writeln!(&mut rv, "{name}"); + + let pointer = f.client(id).state.virtual_pointer(); + pointer.axis_source(source); + pointer.axis(0, wl_pointer::Axis::VerticalScroll, amount); + pointer.frame(); + } + } + + f.roundtrip(id); let diff = f.niri().test_action_count - prev; for _ in 0..diff { let _ = writeln!(&mut rv, " niri test-action"); } - f.roundtrip(id); for event in f.client(id).state.recent_keyboard_events(surface) { let _ = writeln!(&mut rv, " surface {event}"); } @@ -512,3 +607,1239 @@ fn layouts() { " ); } + +#[test] +fn release_binds() { + let c = " + binds { + Mod { + release { toggle-overview; } + } + Mod+Q { + release { close-window; } + } + Mod+U { center-column; } + Ctrl+Alt_L { + release { switch-layout \"next\"; } + } + Ctrl+Alt+U { close-window; } + } + "; + + // Releasing Mod by itself runs its release action. + assert_snapshot!(run(c, "+LWIN -LWIN"), @" + +LWIN 133 XK_Super_L + surface key pressed: 125 + surface modifiers: depressed=64, latched=0, locked=0, group=0 + -LWIN 133 XK_Super_L + niri test-action + surface key released: 125 + surface modifiers: depressed=0, latched=0, locked=0, group=0 + "); + + // Pressing another key in between cancels a modifier-only release bind. + assert_snapshot!(run(c, "+LWIN +LatA -LatA -LWIN"), @" + +LWIN 133 XK_Super_L + surface key pressed: 125 + surface modifiers: depressed=64, latched=0, locked=0, group=0 + +AC01 38 XK_a + surface key pressed: 30 + -AC01 38 XK_a + surface key released: 30 + -LWIN 133 XK_Super_L + surface key released: 125 + surface modifiers: depressed=0, latched=0, locked=0, group=0 + "); + + // Pressing a key that triggers its own bind cancels the pending release bind. + assert_snapshot!(run(c, "+LWIN +LatU -LatU -LWIN"), @" + +LWIN 133 XK_Super_L + surface key pressed: 125 + surface modifiers: depressed=64, latched=0, locked=0, group=0 + +AD07 30 XK_u + niri test-action + -AD07 30 XK_u + -LWIN 133 XK_Super_L + surface key released: 125 + surface modifiers: depressed=0, latched=0, locked=0, group=0 + "); + + // A key that was already held when the bind was pressed doesn't cancel it: only input in + // between does. + assert_snapshot!(run(c, "+LatA +LWIN -LatA -LWIN"), @" + +AC01 38 XK_a + surface key pressed: 30 + +LWIN 133 XK_Super_L + surface key pressed: 125 + surface modifiers: depressed=64, latched=0, locked=0, group=0 + -AC01 38 XK_a + surface key released: 30 + -LWIN 133 XK_Super_L + niri test-action + surface key released: 125 + surface modifiers: depressed=0, latched=0, locked=0, group=0 + "); + + // A release-only bind on a regular key. + assert_snapshot!(run(c, "+LWIN +LatQ -LatQ -LWIN"), @" + +LWIN 133 XK_Super_L + surface key pressed: 125 + surface modifiers: depressed=64, latched=0, locked=0, group=0 + +AD01 24 XK_q + -AD01 24 XK_q + niri test-action + -LWIN 133 XK_Super_L + surface key released: 125 + surface modifiers: depressed=0, latched=0, locked=0, group=0 + "); + + // The release action runs even if the modifiers are released before the trigger key. + assert_snapshot!(run(c, "+LWIN +LatQ -LWIN -LatQ"), @" + +LWIN 133 XK_Super_L + surface key pressed: 125 + surface modifiers: depressed=64, latched=0, locked=0, group=0 + +AD01 24 XK_q + -LWIN 133 XK_Super_L + surface key released: 125 + surface modifiers: depressed=0, latched=0, locked=0, group=0 + -AD01 24 XK_q + niri test-action + "); + + // Another key in between cancels it, no matter the trigger. + assert_snapshot!(run(c, "+LWIN +LatQ +LatA -LatA -LWIN -LatQ"), @" + +LWIN 133 XK_Super_L + surface key pressed: 125 + surface modifiers: depressed=64, latched=0, locked=0, group=0 + +AD01 24 XK_q + +AC01 38 XK_a + surface key pressed: 30 + -AC01 38 XK_a + surface key released: 30 + -LWIN 133 XK_Super_L + surface key released: 125 + surface modifiers: depressed=0, latched=0, locked=0, group=0 + -AD01 24 XK_q + "); + + // Ctrl+Alt_L is a modifier-only bind: its whole key consists of modifiers, so releasing the + // combo on its own runs its release action. + assert_snapshot!(run(c, "+LCTL +LALT -LALT -LCTL"), @" + +LCTL 37 XK_Control_L + surface key pressed: 29 + surface modifiers: depressed=4, latched=0, locked=0, group=0 + +LALT 64 XK_Alt_L + surface key pressed: 56 + surface modifiers: depressed=12, latched=0, locked=0, group=0 + -LALT 64 XK_Alt_L + niri test-action + surface key released: 56 + surface modifiers: depressed=4, latched=0, locked=0, group=0 + -LCTL 37 XK_Control_L + surface key released: 29 + surface modifiers: depressed=0, latched=0, locked=0, group=0 + "); + + // Pressing another key in between cancels it, so using Ctrl+Alt+A as an app shortcut does not + // additionally switch the layout. + assert_snapshot!(run(c, "+LCTL +LALT +LatA -LatA -LALT -LCTL"), @" + +LCTL 37 XK_Control_L + surface key pressed: 29 + surface modifiers: depressed=4, latched=0, locked=0, group=0 + +LALT 64 XK_Alt_L + surface key pressed: 56 + surface modifiers: depressed=12, latched=0, locked=0, group=0 + +AC01 38 XK_a + surface key pressed: 30 + -AC01 38 XK_a + surface key released: 30 + -LALT 64 XK_Alt_L + surface key released: 56 + surface modifiers: depressed=4, latched=0, locked=0, group=0 + -LCTL 37 XK_Control_L + surface key released: 29 + surface modifiers: depressed=0, latched=0, locked=0, group=0 + "); + + // Pressing a key that triggers its own bind cancels the Ctrl+Alt_L release bind. + assert_snapshot!(run(c, "+LCTL +LALT +LatU -LatU -LALT -LCTL"), @" + +LCTL 37 XK_Control_L + surface key pressed: 29 + surface modifiers: depressed=4, latched=0, locked=0, group=0 + +LALT 64 XK_Alt_L + surface key pressed: 56 + surface modifiers: depressed=12, latched=0, locked=0, group=0 + +AD07 30 XK_u + niri test-action + -AD07 30 XK_u + -LALT 64 XK_Alt_L + surface key released: 56 + surface modifiers: depressed=4, latched=0, locked=0, group=0 + -LCTL 37 XK_Control_L + surface key released: 29 + surface modifiers: depressed=0, latched=0, locked=0, group=0 + "); +} + +#[test] +fn press_and_release_binds() { + let c = " + binds { + Mod+Q { + press { close-window; } + release { center-column; } + } + } + "; + + // Both actions run, and the key never reaches the surface. + assert_snapshot!(run(c, "+LWIN +LatQ -LatQ -LWIN"), @" + +LWIN 133 XK_Super_L + surface key pressed: 125 + surface modifiers: depressed=64, latched=0, locked=0, group=0 + +AD01 24 XK_q + niri test-action + -AD01 24 XK_q + niri test-action + -LWIN 133 XK_Super_L + surface key released: 125 + surface modifiers: depressed=0, latched=0, locked=0, group=0 + "); + + // The release action runs even if the modifiers are released first. + assert_snapshot!(run(c, "+LWIN +LatQ -LWIN -LatQ"), @" + +LWIN 133 XK_Super_L + surface key pressed: 125 + surface modifiers: depressed=64, latched=0, locked=0, group=0 + +AD01 24 XK_q + niri test-action + -LWIN 133 XK_Super_L + surface key released: 125 + surface modifiers: depressed=0, latched=0, locked=0, group=0 + -AD01 24 XK_q + niri test-action + "); + + // Extra modifiers held at release don't stop the release action either. + assert_snapshot!(run(c, "+LWIN +LatQ +LCTL +LFSH -LatQ -LFSH -LCTL -LWIN"), @" + +LWIN 133 XK_Super_L + surface key pressed: 125 + surface modifiers: depressed=64, latched=0, locked=0, group=0 + +AD01 24 XK_q + niri test-action + +LCTL 37 XK_Control_L + surface key pressed: 29 + surface modifiers: depressed=68, latched=0, locked=0, group=0 + +LFSH 50 XK_Shift_L + surface key pressed: 42 + surface modifiers: depressed=69, latched=0, locked=0, group=0 + -AD01 24 XK_Q + niri test-action + -LFSH 50 XK_Shift_L + surface key released: 42 + surface modifiers: depressed=68, latched=0, locked=0, group=0 + -LCTL 37 XK_Control_L + surface key released: 29 + surface modifiers: depressed=64, latched=0, locked=0, group=0 + -LWIN 133 XK_Super_L + surface key released: 125 + surface modifiers: depressed=0, latched=0, locked=0, group=0 + "); + + // Push-to-talk: other input in between doesn't cancel the release action. + assert_snapshot!(run(c, "+LWIN +LatQ +LatA -LatA -LatQ -LWIN"), @" + +LWIN 133 XK_Super_L + surface key pressed: 125 + surface modifiers: depressed=64, latched=0, locked=0, group=0 + +AD01 24 XK_q + niri test-action + +AC01 38 XK_a + surface key pressed: 30 + -AC01 38 XK_a + surface key released: 30 + -AD01 24 XK_q + niri test-action + -LWIN 133 XK_Super_L + surface key released: 125 + surface modifiers: depressed=0, latched=0, locked=0, group=0 + "); + + // Pressing the key without the mod key held doesn't run either action. + assert_snapshot!(run(c, "+LatQ +LWIN -LatQ -LWIN"), @" + +AD01 24 XK_q + surface key pressed: 16 + +LWIN 133 XK_Super_L + surface key pressed: 125 + surface modifiers: depressed=64, latched=0, locked=0, group=0 + -AD01 24 XK_q + surface key released: 16 + -LWIN 133 XK_Super_L + surface key released: 125 + surface modifiers: depressed=0, latched=0, locked=0, group=0 + "); +} + +#[test] +fn generic_modifier_press_release() { + let c = " + binds { + Super+Shift { + press { close-window; } + release { center-column; } + } + + Ctrl+Shift { + release { close-window; } + } + } + "; + + // Press Super and then two Shifts. + assert_snapshot!(run(c, "+LWIN +LFSH +RTSH -RTSH -LFSH -LWIN"), @" + +LWIN 133 XK_Super_L + surface key pressed: 125 + surface modifiers: depressed=64, latched=0, locked=0, group=0 + +LFSH 50 XK_Shift_L + niri test-action + surface modifiers: depressed=65, latched=0, locked=0, group=0 + +RTSH 62 XK_Shift_R + niri test-action + -RTSH 62 XK_Shift_R + niri test-action + -LFSH 50 XK_Shift_L + niri test-action + surface modifiers: depressed=64, latched=0, locked=0, group=0 + -LWIN 133 XK_Super_L + surface key released: 125 + surface modifiers: depressed=0, latched=0, locked=0, group=0 + "); + + // Other release order. + assert_snapshot!(run(c, "+LWIN +LFSH +RTSH -LFSH -RTSH -LWIN"), @" + +LWIN 133 XK_Super_L + surface key pressed: 125 + surface modifiers: depressed=64, latched=0, locked=0, group=0 + +LFSH 50 XK_Shift_L + niri test-action + surface modifiers: depressed=65, latched=0, locked=0, group=0 + +RTSH 62 XK_Shift_R + niri test-action + -LFSH 50 XK_Shift_L + niri test-action + -RTSH 62 XK_Shift_R + niri test-action + surface modifiers: depressed=64, latched=0, locked=0, group=0 + -LWIN 133 XK_Super_L + surface key released: 125 + surface modifiers: depressed=0, latched=0, locked=0, group=0 + "); + + // Same but release-only. + assert_snapshot!(run(c, "+LCTL +LFSH +RTSH -RTSH -LFSH -LCTL"), @" + +LCTL 37 XK_Control_L + surface key pressed: 29 + surface modifiers: depressed=4, latched=0, locked=0, group=0 + +LFSH 50 XK_Shift_L + surface key pressed: 42 + surface modifiers: depressed=5, latched=0, locked=0, group=0 + +RTSH 62 XK_Shift_R + surface key pressed: 54 + -RTSH 62 XK_Shift_R + niri test-action + surface key released: 54 + -LFSH 50 XK_Shift_L + surface key released: 42 + surface modifiers: depressed=4, latched=0, locked=0, group=0 + -LCTL 37 XK_Control_L + surface key released: 29 + surface modifiers: depressed=0, latched=0, locked=0, group=0 + "); + + // Other release order (release-only). + assert_snapshot!(run(c, "+LCTL +LFSH +RTSH -LFSH -RTSH -LCTL"), @" + +LCTL 37 XK_Control_L + surface key pressed: 29 + surface modifiers: depressed=4, latched=0, locked=0, group=0 + +LFSH 50 XK_Shift_L + surface key pressed: 42 + surface modifiers: depressed=5, latched=0, locked=0, group=0 + +RTSH 62 XK_Shift_R + surface key pressed: 54 + -LFSH 50 XK_Shift_L + surface key released: 42 + -RTSH 62 XK_Shift_R + niri test-action + surface key released: 54 + surface modifiers: depressed=4, latched=0, locked=0, group=0 + -LCTL 37 XK_Control_L + surface key released: 29 + surface modifiers: depressed=0, latched=0, locked=0, group=0 + "); + + // Now, Shift first. + assert_snapshot!(run(c, "+LFSH +LWIN -LWIN -LFSH"), @" + +LFSH 50 XK_Shift_L + surface key pressed: 42 + surface modifiers: depressed=1, latched=0, locked=0, group=0 + +LWIN 133 XK_Super_L + surface key pressed: 125 + surface modifiers: depressed=65, latched=0, locked=0, group=0 + -LWIN 133 XK_Super_L + surface key released: 125 + surface modifiers: depressed=1, latched=0, locked=0, group=0 + -LFSH 50 XK_Shift_L + surface key released: 42 + surface modifiers: depressed=0, latched=0, locked=0, group=0 + "); + + // Two Supers. + assert_snapshot!(run(c, "+LFSH +LWIN +RWIN -RWIN -LWIN -LFSH"), @" + +LFSH 50 XK_Shift_L + surface key pressed: 42 + surface modifiers: depressed=1, latched=0, locked=0, group=0 + +LWIN 133 XK_Super_L + surface key pressed: 125 + surface modifiers: depressed=65, latched=0, locked=0, group=0 + +RWIN 134 XK_Super_R + surface key pressed: 126 + -RWIN 134 XK_Super_R + surface key released: 126 + -LWIN 133 XK_Super_L + surface key released: 125 + surface modifiers: depressed=1, latched=0, locked=0, group=0 + -LFSH 50 XK_Shift_L + surface key released: 42 + surface modifiers: depressed=0, latched=0, locked=0, group=0 + "); + + // Unrelated modifier in the middle. + assert_snapshot!(run(c, "+LWIN +LCTL +LFSH -LCTL -LFSH -LWIN"), @" + +LWIN 133 XK_Super_L + surface key pressed: 125 + surface modifiers: depressed=64, latched=0, locked=0, group=0 + +LCTL 37 XK_Control_L + surface key pressed: 29 + surface modifiers: depressed=68, latched=0, locked=0, group=0 + +LFSH 50 XK_Shift_L + surface key pressed: 42 + surface modifiers: depressed=69, latched=0, locked=0, group=0 + -LCTL 37 XK_Control_L + surface key released: 29 + surface modifiers: depressed=65, latched=0, locked=0, group=0 + -LFSH 50 XK_Shift_L + surface key released: 42 + surface modifiers: depressed=64, latched=0, locked=0, group=0 + -LWIN 133 XK_Super_L + surface key released: 125 + surface modifiers: depressed=0, latched=0, locked=0, group=0 + "); +} + +#[test] +fn mod_key_press_and_release_bind() { + let c = " + binds { + Mod { + press { close-window; } + release { center-column; } + } + } + "; + + // Both actions run, and the window doesn't see the key press or release themselves, only the + // modifier state. + assert_snapshot!(run(c, "+LWIN -LWIN"), @" + +LWIN 133 XK_Super_L + niri test-action + surface modifiers: depressed=64, latched=0, locked=0, group=0 + -LWIN 133 XK_Super_L + niri test-action + surface modifiers: depressed=0, latched=0, locked=0, group=0 + "); + + // Other input in between doesn't cancel the release action. + assert_snapshot!(run(c, "+LWIN +LatA -LatA -LWIN"), @" + +LWIN 133 XK_Super_L + niri test-action + surface modifiers: depressed=64, latched=0, locked=0, group=0 + +AC01 38 XK_a + surface key pressed: 30 + -AC01 38 XK_a + surface key released: 30 + -LWIN 133 XK_Super_L + niri test-action + surface modifiers: depressed=0, latched=0, locked=0, group=0 + "); +} + +#[test] +fn mod_key_press_and_release_bind_focus_change() { + let c = " + binds { + Mod { + press { close-window; } + release { center-column; } + } + } + "; + + let (mut f, id, surface) = set_up(c); + + // Hold mod: the press action runs, and the window sees the modifier state. + run_f(&mut f, id, &surface, "+LWIN"); + + // A new window takes the keyboard focus while mod is held. + let window = f.client(id).create_window(); + let surface2 = window.surface.clone(); + window.commit(); + f.roundtrip(id); + let window = f.client(id).window(&surface2); + window.attach_new_buffer(); + window.ack_last_and_commit(); + f.roundtrip(id); + + // The new window doesn't see the key press or release, but the focus change and the + // intercepted release still update its modifier state, so it doesn't get stuck with mod held. + assert_snapshot!(run_f(&mut f, id, &surface2, "-LWIN +LatA -LatA"), @" + surface enter: [] + surface modifiers: depressed=64, latched=0, locked=0, group=0 + -LWIN 133 XK_Super_L + niri test-action + surface modifiers: depressed=0, latched=0, locked=0, group=0 + +AC01 38 XK_a + surface key pressed: 30 + -AC01 38 XK_a + surface key released: 30 + "); +} + +#[test] +fn modifier_key_trigger_binds() { + let c = " + binds { + Mod+Control_L { + press { close-window; } + release { center-column; } + } + Alt+Control_L { close-window; } + } + "; + + // A modifier key used as the trigger is intercepted, so the window doesn't see the key press + // and release, only the modifier state; both actions run. + assert_snapshot!(run(c, "+LWIN +LCTL -LCTL -LWIN"), @" + +LWIN 133 XK_Super_L + surface key pressed: 125 + surface modifiers: depressed=64, latched=0, locked=0, group=0 + +LCTL 37 XK_Control_L + niri test-action + surface modifiers: depressed=68, latched=0, locked=0, group=0 + -LCTL 37 XK_Control_L + niri test-action + surface modifiers: depressed=64, latched=0, locked=0, group=0 + -LWIN 133 XK_Super_L + surface key released: 125 + surface modifiers: depressed=0, latched=0, locked=0, group=0 + "); + + // Without the mod key held the bind doesn't match. + assert_snapshot!(run(c, "+LCTL -LCTL"), @" + +LCTL 37 XK_Control_L + surface key pressed: 29 + surface modifiers: depressed=4, latched=0, locked=0, group=0 + -LCTL 37 XK_Control_L + surface key released: 29 + surface modifiers: depressed=0, latched=0, locked=0, group=0 + "); + + // With Alt held instead, the Control_L press is intercepted, and only the press action runs. + assert_snapshot!(run(c, "+LALT +LCTL -LCTL -LALT"), @" + +LALT 64 XK_Alt_L + surface key pressed: 56 + surface modifiers: depressed=8, latched=0, locked=0, group=0 + +LCTL 37 XK_Control_L + niri test-action + surface modifiers: depressed=12, latched=0, locked=0, group=0 + -LCTL 37 XK_Control_L + surface modifiers: depressed=8, latched=0, locked=0, group=0 + -LALT 64 XK_Alt_L + surface key released: 56 + surface modifiers: depressed=0, latched=0, locked=0, group=0 + "); +} + +#[test] +fn release_bind_on_modifier_key() { + let c = " + binds { + Mod+Control_L { + release { close-window; } + } + } + "; + + // The press is forwarded so that apps see the modifier, and the release action runs when the + // modifier is released on its own. + assert_snapshot!(run(c, "+LWIN +LCTL -LCTL -LWIN"), @" + +LWIN 133 XK_Super_L + surface key pressed: 125 + surface modifiers: depressed=64, latched=0, locked=0, group=0 + +LCTL 37 XK_Control_L + surface key pressed: 29 + surface modifiers: depressed=68, latched=0, locked=0, group=0 + -LCTL 37 XK_Control_L + niri test-action + surface key released: 29 + surface modifiers: depressed=64, latched=0, locked=0, group=0 + -LWIN 133 XK_Super_L + surface key released: 125 + surface modifiers: depressed=0, latched=0, locked=0, group=0 + "); + + // Pressing another key in between cancels the release action. + assert_snapshot!(run(c, "+LWIN +LCTL +LFSH -LCTL -LFSH -LWIN"), @" + +LWIN 133 XK_Super_L + surface key pressed: 125 + surface modifiers: depressed=64, latched=0, locked=0, group=0 + +LCTL 37 XK_Control_L + surface key pressed: 29 + surface modifiers: depressed=68, latched=0, locked=0, group=0 + +LFSH 50 XK_Shift_L + surface key pressed: 42 + surface modifiers: depressed=69, latched=0, locked=0, group=0 + -LCTL 37 XK_Control_L + surface key released: 29 + surface modifiers: depressed=65, latched=0, locked=0, group=0 + -LFSH 50 XK_Shift_L + surface key released: 42 + surface modifiers: depressed=64, latched=0, locked=0, group=0 + -LWIN 133 XK_Super_L + surface key released: 125 + surface modifiers: depressed=0, latched=0, locked=0, group=0 + "); +} + +#[test] +fn modifier_key_and_keysym_triggers() { + // `Ctrl` is not the mod key, so it is matched by keysym directly: both Control_L and + // Control_R trigger it. + let by_modifier = " + binds { + Ctrl { + release { close-window; } + } + } + "; + + assert_snapshot!(run(by_modifier, "+LCTL -LCTL"), @" + +LCTL 37 XK_Control_L + surface key pressed: 29 + surface modifiers: depressed=4, latched=0, locked=0, group=0 + -LCTL 37 XK_Control_L + niri test-action + surface key released: 29 + surface modifiers: depressed=0, latched=0, locked=0, group=0 + "); + + assert_snapshot!(run(by_modifier, "+RCTL -RCTL"), @" + +RCTL 105 XK_Control_R + surface key pressed: 97 + surface modifiers: depressed=4, latched=0, locked=0, group=0 + -RCTL 105 XK_Control_R + niri test-action + surface key released: 97 + surface modifiers: depressed=0, latched=0, locked=0, group=0 + "); + + // `Control_L` is a specific key instead, so it doesn't trigger on Control_R, which reaches + // the window instead. + let by_keysym = " + binds { + Control_L { + release { close-window; } + } + } + "; + + assert_snapshot!(run(by_keysym, "+LCTL -LCTL"), @" + +LCTL 37 XK_Control_L + surface key pressed: 29 + surface modifiers: depressed=4, latched=0, locked=0, group=0 + -LCTL 37 XK_Control_L + niri test-action + surface key released: 29 + surface modifiers: depressed=0, latched=0, locked=0, group=0 + "); + + assert_snapshot!(run(by_keysym, "+RCTL -RCTL"), @" + +RCTL 105 XK_Control_R + surface key pressed: 97 + surface modifiers: depressed=4, latched=0, locked=0, group=0 + -RCTL 105 XK_Control_R + surface key released: 97 + surface modifiers: depressed=0, latched=0, locked=0, group=0 + "); +} + +#[test] +fn mod_key_trigger_with_own_modifier() { + // The trigger is the configured mod key, and has Mod at a modifier. + let bind_by_mod = " + binds { + Mod+Super_L { close-window; } + } + "; + + assert_snapshot!(run(bind_by_mod, "+LWIN -LWIN"), @" + +LWIN 133 XK_Super_L + niri test-action + surface modifiers: depressed=64, latched=0, locked=0, group=0 + -LWIN 133 XK_Super_L + surface modifiers: depressed=0, latched=0, locked=0, group=0 + "); + + let bind_by_keysym = " + binds { + Super_L { close-window; } + } + "; + + assert_snapshot!(run(bind_by_keysym, "+LWIN -LWIN"), @" + +LWIN 133 XK_Super_L + niri test-action + surface modifiers: depressed=64, latched=0, locked=0, group=0 + -LWIN 133 XK_Super_L + surface modifiers: depressed=0, latched=0, locked=0, group=0 + "); + + let bind_by_generic = " + binds { + Mod+Super { close-window; } + } + "; + + assert_snapshot!(run(bind_by_generic, "+LWIN -LWIN"), @" + +LWIN 133 XK_Super_L + niri test-action + surface modifiers: depressed=64, latched=0, locked=0, group=0 + -LWIN 133 XK_Super_L + surface modifiers: depressed=0, latched=0, locked=0, group=0 + "); +} + +#[test] +fn release_bind_requires_matching_modifiers_on_press() { + let c = " + binds { + Mod+Ctrl+Q { + release { close-window; } + } + } + "; + + // Pressing Q without the mod key held forwards it and doesn't arm the release action, even if + // the mod key is held by the time Q is released. + assert_snapshot!(run(c, "+LCTL +LatQ +LWIN -LatQ -LWIN -LCTL"), @" + +LCTL 37 XK_Control_L + surface key pressed: 29 + surface modifiers: depressed=4, latched=0, locked=0, group=0 + +AD01 24 XK_q + surface key pressed: 16 + +LWIN 133 XK_Super_L + surface key pressed: 125 + surface modifiers: depressed=68, latched=0, locked=0, group=0 + -AD01 24 XK_q + surface key released: 16 + -LWIN 133 XK_Super_L + surface key released: 125 + surface modifiers: depressed=4, latched=0, locked=0, group=0 + -LCTL 37 XK_Control_L + surface key released: 29 + surface modifiers: depressed=0, latched=0, locked=0, group=0 + "); +} + +/// `Mod` and `Mod+Q` release-only binds, used by the inhibitor tests below. +const INHIBITING_BINDS: &str = " + binds { + Mod { + release { toggle-overview; } + } + Mod+Q { + release { close-window; } + } + } +"; + +#[test] +fn inhibiting_release_binds() { + let (mut f, id, surface) = set_up(INHIBITING_BINDS); + let _inhibitor = f.client(id).state.inhibit_shortcuts(&surface); + f.roundtrip(id); + + // While inhibiting, release binds don't run, and the whole key is forwarded to the window. + assert_snapshot!(run_f(&mut f, id, &surface, "+LWIN +LatQ -LatQ -LWIN"), @" + +LWIN 133 XK_Super_L + surface key pressed: 125 + surface modifiers: depressed=64, latched=0, locked=0, group=0 + +AD01 24 XK_q + surface key pressed: 16 + -AD01 24 XK_q + surface key released: 16 + -LWIN 133 XK_Super_L + surface key released: 125 + surface modifiers: depressed=0, latched=0, locked=0, group=0 + "); +} + +#[test] +fn release_binds_run_after_the_inhibitor_is_gone() { + let (mut f, id, surface) = set_up(INHIBITING_BINDS); + let inhibitor = f.client(id).state.inhibit_shortcuts(&surface); + f.roundtrip(id); + inhibitor.destroy(); + f.roundtrip(id); + + assert_snapshot!(run_f(&mut f, id, &surface, "+LWIN +LatQ -LatQ -LWIN"), @" + +LWIN 133 XK_Super_L + surface key pressed: 125 + surface modifiers: depressed=64, latched=0, locked=0, group=0 + +AD01 24 XK_q + -AD01 24 XK_q + niri test-action + -LWIN 133 XK_Super_L + surface key released: 125 + surface modifiers: depressed=0, latched=0, locked=0, group=0 + "); +} + +#[test] +fn deactivating_the_inhibitor_does_not_arm_a_release_bind_retroactively() { + let (mut f, id, surface) = set_up(INHIBITING_BINDS); + let inhibitor = f.client(id).state.inhibit_shortcuts(&surface); + f.roundtrip(id); + + // The press while inhibiting is forwarded and arms nothing. + run_f(&mut f, id, &surface, "+LWIN +LatQ"); + + inhibitor.destroy(); + f.roundtrip(id); + + // The release is still forwarded, and still runs no action. + assert_snapshot!(run_f(&mut f, id, &surface, "-LatQ -LWIN"), @" + -AD01 24 XK_q + surface key released: 16 + -LWIN 133 XK_Super_L + surface key released: 125 + surface modifiers: depressed=0, latched=0, locked=0, group=0 + "); +} + +#[test] +fn activating_the_inhibitor_keeps_an_armed_release_bind() { + let (mut f, id, surface) = set_up(INHIBITING_BINDS); + + // The press while not inhibiting is intercepted and arms the release action. + run_f(&mut f, id, &surface, "+LWIN +LatQ"); + + let _inhibitor = f.client(id).state.inhibit_shortcuts(&surface); + f.roundtrip(id); + + // The release stays intercepted, and still runs the armed release action. + assert_snapshot!(run_f(&mut f, id, &surface, "-LatQ -LWIN"), @" + -AD01 24 XK_q + niri test-action + -LWIN 133 XK_Super_L + surface key released: 125 + surface modifiers: depressed=0, latched=0, locked=0, group=0 + "); +} + +#[test] +fn inhibiting_a_modifier_press_still_forwards_it() { + let (mut f, id, surface) = set_up(INHIBITING_BINDS); + let _inhibitor = f.client(id).state.inhibit_shortcuts(&surface); + f.roundtrip(id); + + // A modifier press is forwarded either way; the inhibitor only decides whether it arms the + // release action, and here it doesn't. + assert_snapshot!(run_f(&mut f, id, &surface, "+LWIN"), @" + +LWIN 133 XK_Super_L + surface key pressed: 125 + surface modifiers: depressed=64, latched=0, locked=0, group=0 + "); +} + +#[test] +fn armed_modifier_release_runs_after_the_inhibitor_activates() { + let (mut f, id, surface) = set_up(INHIBITING_BINDS); + + // The modifier press while not inhibiting arms its release action. + run_f(&mut f, id, &surface, "+LWIN"); + + let _inhibitor = f.client(id).state.inhibit_shortcuts(&surface); + f.roundtrip(id); + + assert_snapshot!(run_f(&mut f, id, &surface, "-LWIN"), @" + -LWIN 133 XK_Super_L + niri test-action + surface key released: 125 + surface modifiers: depressed=0, latched=0, locked=0, group=0 + "); +} + +#[test] +fn egl_release_action_runs_when_press_opens_screenshot_ui() { + // Release actions must always run if their press action ran, + // including if the screenshot UI is opened before the key is released. + let c = " + binds { + Mod+Q { + press { screenshot; } + release { close-window; } + } + } + "; + + let (mut f, id, surface) = set_up(c); + f.niri_state().backend.headless().add_renderer().unwrap(); + + assert_snapshot!(run_f(&mut f, id, &surface, "+LWIN +LatQ -LatQ -LWIN"), @" + +LWIN 133 XK_Super_L + surface key pressed: 125 + surface modifiers: depressed=64, latched=0, locked=0, group=0 + +AD01 24 XK_q + surface leave + -AD01 24 XK_q + niri test-action + -LWIN 133 XK_Super_L + "); + assert!(f.niri().screenshot_ui.is_open()); +} + +#[test] +fn cooldown_skips_whole_bind() { + // A bind on cooldown is skipped entirely: its press runs no action, and no release action is + // recorded, so its release runs no action either. + let c = " + binds { + Mod+MouseLeft cooldown-ms=1000 { + press { close-window; } + release { center-column; } + } + } + "; + + assert_snapshot!(run(c, "+LWIN +MouseLeft -MouseLeft +MouseLeft -MouseLeft -LWIN"), @" + +LWIN 133 XK_Super_L + surface key pressed: 125 + surface modifiers: depressed=64, latched=0, locked=0, group=0 + +MouseLeft + niri test-action + -MouseLeft + niri test-action + +MouseLeft + -MouseLeft + -LWIN 133 XK_Super_L + surface key released: 125 + surface modifiers: depressed=0, latched=0, locked=0, group=0 + "); +} + +#[test] +fn cooldown_only_skips_the_action() { + let c = " + binds { + Mod+Control_L cooldown-ms=1000 { close-window; } + } + "; + + // The cooldown only skips the action: the second press runs no action, but it is still + // intercepted and hidden from the window, only advertising the modifier state. + assert_snapshot!(run(c, "+LWIN +LCTL -LCTL +LCTL"), @" + +LWIN 133 XK_Super_L + surface key pressed: 125 + surface modifiers: depressed=64, latched=0, locked=0, group=0 + +LCTL 37 XK_Control_L + niri test-action + surface modifiers: depressed=68, latched=0, locked=0, group=0 + -LCTL 37 XK_Control_L + surface modifiers: depressed=64, latched=0, locked=0, group=0 + +LCTL 37 XK_Control_L + surface modifiers: depressed=68, latched=0, locked=0, group=0 + "); +} + +#[test] +fn cooldown_applies_to_release_action_of_release_only_bind() { + // The release action is what starts this bind, so the cooldown rate-limits it: it runs for the + // first release, and not for the second while the bind is on cooldown. + let c = " + binds { + Mod cooldown-ms=1000 { + release { toggle-overview; } + } + } + "; + + assert_snapshot!(run(c, "+LWIN -LWIN +LWIN -LWIN"), @" + +LWIN 133 XK_Super_L + surface key pressed: 125 + surface modifiers: depressed=64, latched=0, locked=0, group=0 + -LWIN 133 XK_Super_L + niri test-action + surface key released: 125 + surface modifiers: depressed=0, latched=0, locked=0, group=0 + +LWIN 133 XK_Super_L + surface key pressed: 125 + surface modifiers: depressed=64, latched=0, locked=0, group=0 + -LWIN 133 XK_Super_L + surface key released: 125 + surface modifiers: depressed=0, latched=0, locked=0, group=0 + "); +} + +#[test] +fn cooldown_applies_to_press_action_only() { + // The cooldown applies to the press action only, and the release action runs only if its press + // action ran, so the second press runs no action at all. + let c = " + binds { + Mod+Q cooldown-ms=1000 { + press { close-window; } + release { center-column; } + } + } + "; + + assert_snapshot!(run(c, "+LWIN +LatQ -LatQ +LatQ -LatQ -LWIN"), @" + +LWIN 133 XK_Super_L + surface key pressed: 125 + surface modifiers: depressed=64, latched=0, locked=0, group=0 + +AD01 24 XK_q + niri test-action + -AD01 24 XK_q + niri test-action + +AD01 24 XK_q + -AD01 24 XK_q + -LWIN 133 XK_Super_L + surface key released: 125 + surface modifiers: depressed=0, latched=0, locked=0, group=0 + "); +} + +#[test] +fn release_bind_runs_when_mru_closes() { + // `Alt` is a push-to-talk bind: the default Alt+Tab opens the MRU, and releasing Alt closes it + // instead of going through the regular bind lookup, so its release action must still run. + let c = " + binds { + Alt { + press { spawn \"ptt-toggle\"; } + release { spawn \"ptt-toggle\"; } + } + } + "; + + assert_snapshot!(run(c, "+LALT +TAB -LALT"), @" + +LALT 64 XK_Alt_L + niri test-action + surface modifiers: depressed=8, latched=0, locked=0, group=0 + +TAB 23 XK_Tab + surface leave + -LALT 64 XK_Alt_L + niri test-action + surface enter: [] + surface modifiers: depressed=0, latched=0, locked=0, group=0 + surface modifiers: depressed=0, latched=0, locked=0, group=0 + "); +} + +#[test] +fn mru_close_forwards_key_release_without_bind() { + // No bind is recorded for Alt, so releasing it while the MRU is open closes the MRU and the + // release is forwarded to the window like any other unsuppressed key. + let c = " + binds { + Mod+Q { close-window; } + } + "; + + assert_snapshot!(run(c, "+LALT +TAB -LALT"), @" + +LALT 64 XK_Alt_L + surface key pressed: 56 + surface modifiers: depressed=8, latched=0, locked=0, group=0 + +TAB 23 XK_Tab + surface leave + -LALT 64 XK_Alt_L + surface enter: [56] + surface modifiers: depressed=0, latched=0, locked=0, group=0 + surface key released: 56 + surface modifiers: depressed=0, latched=0, locked=0, group=0 + "); +} + +#[test] +fn vt_switch_runs_release_actions_of_held_binds() { + // A VT switch can take the input away without ever delivering the releases of everything held + // down, so the held binds are treated as released rather than forgotten. + let c = " + binds { + Mod+Q { + press { close-window; } + release { center-column; } + } + Mod+MouseLeft { + press { close-window; } + release { center-column; } + } + } + "; + + let (mut f, id, surface) = set_up(c); + + // Hold a key and a mouse button, both with press and release actions. + run_f(&mut f, id, &surface, "+LWIN +LatQ +MouseLeft"); + assert_eq!(f.niri().test_action_count, 2); + + // The VT switch runs both release actions. + f.niri_state().do_action(Action::ChangeVt(1), false); + assert_eq!(f.niri().test_action_count, 4); + + // If the releases arrive anyway, they run nothing. + run_f(&mut f, id, &surface, "-LatQ -MouseLeft"); + assert_eq!(f.niri().test_action_count, 4); +} + +#[test] +fn other_input_cancels_release_only_mouse_bind() { + // A release-only mouse bind is cancelled by other input in between, just like a release-only + // keyboard bind. + let c = " + binds { + Mod+MouseLeft { + release { close-window; } + } + } + "; + + // Pressing a key in between cancels it. + assert_snapshot!(run(c, "+LWIN +MouseLeft +LatA -LatA -MouseLeft -LWIN"), @" + +LWIN 133 XK_Super_L + surface key pressed: 125 + surface modifiers: depressed=64, latched=0, locked=0, group=0 + +MouseLeft + +AC01 38 XK_a + surface key pressed: 30 + -AC01 38 XK_a + surface key released: 30 + -MouseLeft + -LWIN 133 XK_Super_L + surface key released: 125 + surface modifiers: depressed=0, latched=0, locked=0, group=0 + "); + + // Pressing another mouse button in between cancels it. + assert_snapshot!(run(c, "+LWIN +MouseLeft +MouseRight -MouseRight -MouseLeft -LWIN"), @" + +LWIN 133 XK_Super_L + surface key pressed: 125 + surface modifiers: depressed=64, latched=0, locked=0, group=0 + +MouseLeft + +MouseRight + -MouseRight + -MouseLeft + -LWIN 133 XK_Super_L + surface key released: 125 + surface modifiers: depressed=0, latched=0, locked=0, group=0 + "); + + // With nothing else in between, the release action runs. + assert_snapshot!(run(c, "+LWIN +MouseLeft -MouseLeft -LWIN"), @" + +LWIN 133 XK_Super_L + surface key pressed: 125 + surface modifiers: depressed=64, latched=0, locked=0, group=0 + +MouseLeft + -MouseLeft + niri test-action + -LWIN 133 XK_Super_L + surface key released: 125 + surface modifiers: depressed=0, latched=0, locked=0, group=0 + "); +} + +#[test] +fn mouse_button_press_cancels_release_only_key_bind() { + // A mouse button press is other input, so it interrupts a pending release-only keyboard bind, + // including one on a bare modifier. Only the mouse bind's press action runs. + let c = " + binds { + Mod { + release { center-column; } + } + Mod+MouseLeft { close-window; } + } + "; + + assert_snapshot!(run(c, "+LWIN +MouseLeft -MouseLeft -LWIN"), @" + +LWIN 133 XK_Super_L + surface key pressed: 125 + surface modifiers: depressed=64, latched=0, locked=0, group=0 + +MouseLeft + niri test-action + -MouseLeft + -LWIN 133 XK_Super_L + surface key released: 125 + surface modifiers: depressed=0, latched=0, locked=0, group=0 + "); +} + +#[test] +fn scroll_cancels_release_only_key_bind() { + // Scrolling is input, so it cancels a pending release-only bind. Here there is no scroll bind + // for the held modifiers. + let c = " + binds { + Mod { + release { center-column; } + } + } + "; + + // The scroll in between cancels the pending release bind, so the release runs no action. + assert_snapshot!(run(c, "+LWIN WheelScrollDown -LWIN"), @" + +LWIN 133 XK_Super_L + surface key pressed: 125 + surface modifiers: depressed=64, latched=0, locked=0, group=0 + WheelScrollDown + -LWIN 133 XK_Super_L + surface key released: 125 + surface modifiers: depressed=0, latched=0, locked=0, group=0 + "); + + // Without a scroll in between, the release action runs. + assert_snapshot!(run(c, "+LWIN -LWIN"), @" + +LWIN 133 XK_Super_L + surface key pressed: 125 + surface modifiers: depressed=64, latched=0, locked=0, group=0 + -LWIN 133 XK_Super_L + niri test-action + surface key released: 125 + surface modifiers: depressed=0, latched=0, locked=0, group=0 + "); +} + +#[test] +fn redundant_modifier_binds() { + // Binds should behave identically with or without redundant modifiers. + let input = "+LALT -LALT"; + assert_eq!( + run("binds { Alt_L { release { close-window; }; }; }", input), + run("binds { Alt+Alt_L { release { close-window; }; }; }", input) + ); + + let input = "+LALT +LatQ -LatQ -LALT"; + assert_eq!( + run("binds { Alt+Q { release { close-window; }; }; }", input), + run("binds { Alt+Alt+Q { release { close-window; }; }; }", input) + ); +} diff --git a/src/tests/client.rs b/src/tests/client.rs index d6624440d7..03f8f7f17f 100644 --- a/src/tests/client.rs +++ b/src/tests/client.rs @@ -62,6 +62,7 @@ pub struct State { pub xdg_wm_base: Option, pub layer_shell: Option, pub virtual_pointer_manager: Option, + pub virtual_pointer: Option, pub spbm: Option, pub viewporter: Option, pub ksim: Option, @@ -262,6 +263,7 @@ impl Client { xdg_wm_base: None, layer_shell: None, virtual_pointer_manager: None, + virtual_pointer: None, spbm: None, viewporter: None, ksim: None, @@ -430,6 +432,17 @@ impl State { let seat = self.seats.keys().next().unwrap(); ksim.inhibit_shortcuts(surface, seat, &self.qh, ()) } + + pub fn virtual_pointer(&mut self) -> ZwlrVirtualPointerV1 { + if let Some(pointer) = &self.virtual_pointer { + return pointer.clone(); + } + + let manager = self.virtual_pointer_manager.as_ref().unwrap(); + let pointer = manager.create_virtual_pointer(None, &self.qh, ()); + self.virtual_pointer = Some(pointer.clone()); + pointer + } } impl Window { diff --git a/src/tests/virtual_pointer.rs b/src/tests/virtual_pointer.rs index b9aa02ae69..85dcbea451 100644 --- a/src/tests/virtual_pointer.rs +++ b/src/tests/virtual_pointer.rs @@ -8,8 +8,7 @@ fn axis_discrete_overflow() { let id = f.add_client(); let client = f.client(id); - let manager = client.state.virtual_pointer_manager.as_ref().unwrap(); - let pointer = manager.create_virtual_pointer(None, &client.qh, ()); + let pointer = client.state.virtual_pointer(); pointer.axis_discrete(0, wl_pointer::Axis::VerticalScroll, 0., i32::MAX); f.roundtrip(id); } From b345b12a1122e816b2449bd0c83dd5b9f92f9b02 Mon Sep 17 00:00:00 2001 From: Justin Spedding Date: Tue, 22 Sep 2026 16:10:30 -0400 Subject: [PATCH 15/40] wiki: document press and release bindings --- docs/wiki/Configuration:-Key-Bindings.md | 61 ++++++++++++++++++++++++ resources/default-config.kdl | 16 ++++++- 2 files changed, 76 insertions(+), 1 deletion(-) diff --git a/docs/wiki/Configuration:-Key-Bindings.md b/docs/wiki/Configuration:-Key-Bindings.md index 3a51b41056..4a7f1237a8 100644 --- a/docs/wiki/Configuration:-Key-Bindings.md +++ b/docs/wiki/Configuration:-Key-Bindings.md @@ -100,6 +100,67 @@ binds { This is mostly useful for the scroll bindings. +### Press and Release Bindings + +Since: next release + +Binds can be set to trigger on key press, on key release, or both. +By default, binds trigger on key press. +You can change it using `press {}` and `release {}` blocks: + +```kdl +binds { + // Trigger on press (default behavior). + Mod+T { spawn "alacritty"; } + + // Trigger on release. + // Press and release Mod by itself to open the overview. + Mod { + release { toggle-overview; } + } + + // Trigger on both press and release with different actions. + // + // If the press action runs, the release action is guaranteed to run, + // no matter how you release the keys. + // You can safely use these binds for things like push-to-talk. + Control_R { + press { spawn "notify-send" "Enable push-to-talk"; } + release { spawn "notify-send" "Disable push-to-talk"; } + } + + // Release binds trigger only if you don't press anything else in between. + // So, an Alt+Shift bind like this still lets you hit + // Alt+Shift+arrows and other shortcuts in applications. + Alt+Shift { + release { switch-layout "next"; } + } +} +``` + +Release bindings are especially useful when you want to bind a modifier key. +A normal press bind would effectively prevent you from having any other bind containing that modifier. + +Release-only binds only trigger when you don't press anything else while holding it. +If you have a release-only `Mod` bind from the example above, and press any other key or mouse button while holding `Mod`, the bind will not trigger (the overview won't open). + +In bindings with both press and release, the release action is guaranteed to trigger if the press action triggers. +So, your push-to-talk press-release binding will never get stuck pressed, no matter how you release the keys. + +By default, any bind with both press and release action does not repeat. +You can explicitly set `repeat=true` if needed: + +```kdl +binds { + // Doesn't repeat automatically + // because it has a release {} section. + Mod+Shift+Q repeat=true { + press { spawn "notify-send" "Pressed (repeating)"; } + release { spawn "notify-send" "Released"; } + } +} +``` + ### Scroll Bindings You can bind mouse wheel scroll ticks using the following syntax. diff --git a/resources/default-config.kdl b/resources/default-config.kdl index 117f2a5dec..190e59504a 100644 --- a/resources/default-config.kdl +++ b/resources/default-config.kdl @@ -401,7 +401,21 @@ binds { // Open/close the Overview: a zoomed-out view of workspaces and windows. // You can also move the mouse into the top-left hot corner, // or do a four-finger swipe up on a touchpad. - Mod+O repeat=false { toggle-overview; } + // + // This is a release bind. + // Press and release Mod by itself to trigger it. + Mod { + release { toggle-overview; } + } + + // You can also bind both press and release actions like so. + // If the press action fires, the release action + // is also guaranteed to fire. + // This is useful for binds like push-to-talk. + // Alt+Shift { + // press { spawn "notify-send" "Enable push-to-talk!"; } + // release { spawn "notify-send" "Disable push-to-talk!"; } + // } Mod+Q repeat=false { close-window; } Alt+F4 repeat=false { close-window; } From cbd94d10cd1f7f824c843399006e9f9e6aa1697f Mon Sep 17 00:00:00 2001 From: Justin Spedding Date: Sun, 18 Oct 2026 10:25:24 -0400 Subject: [PATCH 16/40] input: avoid unnecessary Bind and Action clones --- niri-config/src/binds.rs | 11 +++++++++++ src/input/mod.rs | 19 +++++++------------ 2 files changed, 18 insertions(+), 12 deletions(-) diff --git a/niri-config/src/binds.rs b/niri-config/src/binds.rs index 4a715f6869..bed03739a9 100644 --- a/niri-config/src/binds.rs +++ b/niri-config/src/binds.rs @@ -67,6 +67,17 @@ impl Bind { BoundAction::Press(_) => None, } } + + pub fn into_action(self, pressed: bool) -> Option { + match (self.action, pressed) { + (BoundAction::Press(action), true) | (BoundAction::Release(action), false) => { + Some(action) + } + (BoundAction::Both { press, .. }, true) => Some(press), + (BoundAction::Both { release, .. }, false) => Some(release), + _ => None, + } + } } #[derive(Debug, PartialEq, Eq, Clone, Copy, Hash)] diff --git a/src/input/mod.rs b/src/input/mod.rs index 03d9fbd9cc..e2225b6996 100644 --- a/src/input/mod.rs +++ b/src/input/mod.rs @@ -681,10 +681,10 @@ impl State { match result { ShouldInterceptResult::ForwardAndHandle(bind) | ShouldInterceptResult::InterceptAndHandle(bind) => { - self.handle_bind(bind.clone(), pressed); - if pressed { - self.start_key_repeat(bind); + if pressed && bind.repeat { + self.start_key_repeat(bind.clone()); } + self.handle_bind(bind, pressed); } ShouldInterceptResult::Forward | ShouldInterceptResult::InterceptOnly => {} } @@ -747,15 +747,6 @@ impl State { } pub fn handle_bind(&mut self, bind: Bind, pressed: bool) { - let action = if pressed { - bind.press_action() - } else { - bind.release_action() - }; - let Some(action) = action.cloned() else { - return; - }; - let allowed_when_locked = bind_allowed_when_locked(&bind, pressed); let allowed_during_screenshot = bind_allowed_during_screenshot(&bind, pressed); @@ -796,6 +787,10 @@ impl State { } } + let Some(action) = bind.into_action(pressed) else { + return; + }; + self.do_action(action, allowed_when_locked); } From 75db0a3c700e7587bff847f995d5b0c0fe8c7efb Mon Sep 17 00:00:00 2001 From: Ivan Molodetskikh Date: Sat, 24 Oct 2026 10:04:21 +0300 Subject: [PATCH 17/40] Make generic-modifier-only binds press-order-independent --- docs/wiki/Configuration:-Key-Bindings.md | 14 ++ niri-config/src/binds.rs | 26 ++- src/input/mod.rs | 39 +++- src/tests/binds.rs | 233 ++++++++++++++++++++++- src/ui/mru.rs | 8 + 5 files changed, 300 insertions(+), 20 deletions(-) diff --git a/docs/wiki/Configuration:-Key-Bindings.md b/docs/wiki/Configuration:-Key-Bindings.md index 4a7f1237a8..149418a302 100644 --- a/docs/wiki/Configuration:-Key-Bindings.md +++ b/docs/wiki/Configuration:-Key-Bindings.md @@ -50,6 +50,20 @@ binds { } ``` +Since: next release Generic modifiers like `Shift` or `Alt` can be used as trigger keys. +Binds consisting only of generic modifiers do not depend on the order that you press them: + +```kdl +binds { + // Will trigger both when pressing Alt then Shift, and Shift then Alt. + Alt+Shift { spawn "alacritty"; } + + // The trigger is not a generic modifier, so + // you need to press it last. Only Ctrl then Left Shift will work. + Ctrl+Shift_L { spawn "alacritty"; } +} +``` + Since: 25.05 You can customize the `Mod` key [in the `input` section of the config](./Configuration:-Input.md#mod-key-mod-key-nested). > [!TIP] diff --git a/niri-config/src/binds.rs b/niri-config/src/binds.rs index bed03739a9..7448c8f8f6 100644 --- a/niri-config/src/binds.rs +++ b/niri-config/src/binds.rs @@ -127,6 +127,10 @@ impl Trigger { } } + pub fn is_generic_modifier(self) -> bool { + matches!(self, Self::CompositorMod | Self::Modifier(_)) + } + /// The modifier flags that this trigger sets, if it is a modifier key. pub fn implied_modifier(self) -> Modifiers { match self { @@ -138,15 +142,6 @@ impl Trigger { } } - /// Whether the same key press can trigger both of these triggers. - fn overlaps(self, other: Self) -> bool { - match (self, other) { - (Self::Modifier(modifier), Self::Keysym(keysym)) - | (Self::Keysym(keysym), Self::Modifier(modifier)) => modifier.matches_keysym(keysym), - (a, b) => a == b, - } - } - pub fn is_keyboard(self) -> bool { matches!( self, @@ -174,7 +169,10 @@ impl Key { return false; } - self.trigger.overlaps(other.trigger) + // Generic-modifier-only binds are press-order-independent, so only their modifier set + // (which already includes the trigger implied modifier) has to match. + (self.trigger.is_generic_modifier() && other.trigger.is_modifier()) + || (self.trigger.is_modifier() && other.trigger.is_generic_modifier()) } } @@ -1555,6 +1553,14 @@ mod tests { let compositor_mod = key("Mod"); assert!(!compositor_mod.conflicts_with(&key("Super_L"))); assert!(!compositor_mod.conflicts_with(&key("Super"))); + + // Generic-modifier-only keys are order-independent. + assert!(key("Ctrl+Alt").conflicts_with(&key("Alt+Ctrl"))); + assert!(key("Mod+Shift+Alt").conflicts_with(&key("Shift+Alt+Mod"))); + assert!(key("Ctrl+Alt").conflicts_with(&key("Alt+Control_L"))); + assert!(key("Ctrl+Alt_L").conflicts_with(&key("Alt+Ctrl"))); + + assert!(!key("Ctrl+Alt").conflicts_with(&key("Ctrl+Alt+A"))); } #[test] diff --git a/src/input/mod.rs b/src/input/mod.rs index e2225b6996..5d15d93495 100644 --- a/src/input/mod.rs +++ b/src/input/mod.rs @@ -4892,16 +4892,47 @@ fn find_bind<'a>( bindings .into_iter() .find(|bind| { + let bind_trigger = bind.key.trigger; + // Check that the bind modifiers match the held modifiers. + // + // Pass the bind's trigger itself since we'll check the trigger match below. + // (Note that this means mod_key is not properly checked when it matches the trigger.) + if !bind_matches_trigger(bind, bind_trigger, mod_key, modifiers) { + return false; + } + + // Binds that consist only of generic modifiers can be hit in any order (trigger key + // does not have to be the last). This is consistent with how these types of binds work + // elsewhere. + // + // For example: + // - Alt + Shift to switch keyboard layout on Windows + // - Ctrl + Alt to show inlay hints in VSCode + if bind_trigger.is_generic_modifier() { + // Bind trigger matching compositor mod isn't properly checked above. + // Check it here. + if (bind_trigger == Trigger::CompositorMod + || bind_trigger == Trigger::Modifier(mod_key)) + && !modifiers.contains(mod_key.to_modifiers()) + { + return false; + } + + // Check that the key we pressed is one of the bind modifiers. + let mut raw_mods = Trigger::Keysym(raw).implied_modifier(); + if mod_key.matches_keysym(raw) { + raw_mods |= Modifiers::COMPOSITOR; + } + return !raw_mods.is_empty() && bind.key.modifiers.intersects(raw_mods); + } + // Modifier-only binds can be written in several ways: // - `Mod { quit; }` - Trigger::CompositorMod // - `Super { quit; }` - Trigger::Modifier(_) // - `Super_L { quit; }` - Trigger::Keysym(_) // - // These only matter for keyboard keys and not for mouse or others. So, check them here, - // and then pass the bind's trigger itself to the simpler matching function. - let bind_trigger = bind.key.trigger; + // These only matter for keyboard keys and not for mouse or others. So, check them here. trigger_matches_keysym(bind_trigger, raw, mod_key) - && bind_matches_trigger(bind, bind_trigger, mod_key, modifiers) }) .cloned() } diff --git a/src/tests/binds.rs b/src/tests/binds.rs index 4e5afe6b4a..9e3150ee3f 100644 --- a/src/tests/binds.rs +++ b/src/tests/binds.rs @@ -982,10 +982,10 @@ fn generic_modifier_press_release() { surface key pressed: 42 surface modifiers: depressed=1, latched=0, locked=0, group=0 +LWIN 133 XK_Super_L - surface key pressed: 125 + niri test-action surface modifiers: depressed=65, latched=0, locked=0, group=0 -LWIN 133 XK_Super_L - surface key released: 125 + niri test-action surface modifiers: depressed=1, latched=0, locked=0, group=0 -LFSH 50 XK_Shift_L surface key released: 42 @@ -998,14 +998,14 @@ fn generic_modifier_press_release() { surface key pressed: 42 surface modifiers: depressed=1, latched=0, locked=0, group=0 +LWIN 133 XK_Super_L - surface key pressed: 125 + niri test-action surface modifiers: depressed=65, latched=0, locked=0, group=0 +RWIN 134 XK_Super_R - surface key pressed: 126 + niri test-action -RWIN 134 XK_Super_R - surface key released: 126 + niri test-action -LWIN 133 XK_Super_L - surface key released: 125 + niri test-action surface modifiers: depressed=1, latched=0, locked=0, group=0 -LFSH 50 XK_Shift_L surface key released: 42 @@ -1033,6 +1033,60 @@ fn generic_modifier_press_release() { surface key released: 125 surface modifiers: depressed=0, latched=0, locked=0, group=0 "); + + // Regressions. + let c = " + binds { + Shift+Mod { close-window; } + Ctrl+Super { close-window; } + } + "; + + assert_snapshot!(run(c, "+LFSH -LFSH"), @" + +LFSH 50 XK_Shift_L + surface key pressed: 42 + surface modifiers: depressed=1, latched=0, locked=0, group=0 + -LFSH 50 XK_Shift_L + surface key released: 42 + surface modifiers: depressed=0, latched=0, locked=0, group=0 + "); + + assert_snapshot!(run(c, "+LFSH +LWIN -LWIN -LFSH"), @" + +LFSH 50 XK_Shift_L + surface key pressed: 42 + surface modifiers: depressed=1, latched=0, locked=0, group=0 + +LWIN 133 XK_Super_L + niri test-action + surface modifiers: depressed=65, latched=0, locked=0, group=0 + -LWIN 133 XK_Super_L + surface modifiers: depressed=1, latched=0, locked=0, group=0 + -LFSH 50 XK_Shift_L + surface key released: 42 + surface modifiers: depressed=0, latched=0, locked=0, group=0 + "); + + assert_snapshot!(run(c, "+LCTL -LCTL"), @" + +LCTL 37 XK_Control_L + surface key pressed: 29 + surface modifiers: depressed=4, latched=0, locked=0, group=0 + -LCTL 37 XK_Control_L + surface key released: 29 + surface modifiers: depressed=0, latched=0, locked=0, group=0 + "); + + assert_snapshot!(run(c, "+LCTL +LWIN -LWIN -LCTL"), @" + +LCTL 37 XK_Control_L + surface key pressed: 29 + surface modifiers: depressed=4, latched=0, locked=0, group=0 + +LWIN 133 XK_Super_L + niri test-action + surface modifiers: depressed=68, latched=0, locked=0, group=0 + -LWIN 133 XK_Super_L + surface modifiers: depressed=4, latched=0, locked=0, group=0 + -LCTL 37 XK_Control_L + surface key released: 29 + surface modifiers: depressed=0, latched=0, locked=0, group=0 + "); } #[test] @@ -1679,6 +1733,173 @@ fn mru_close_forwards_key_release_without_bind() { "); } +#[test] +fn mru_ignores_generic_modifier_binds() { + let c = " + binds { + Alt+Ctrl { screenshot; } + } + "; + + // If a press runs the screenshot action, + // we will get surface enter after it. + assert_snapshot!(run(c, "+LALT +TAB -TAB +LFSH -LFSH -LALT"), @" + +LALT 64 XK_Alt_L + surface key pressed: 56 + surface modifiers: depressed=8, latched=0, locked=0, group=0 + +TAB 23 XK_Tab + surface leave + -TAB 23 XK_Tab + +LFSH 50 XK_Shift_L + -LFSH 50 XK_Shift_L + -LALT 64 XK_Alt_L + surface enter: [56] + surface modifiers: depressed=0, latched=0, locked=0, group=0 + surface key released: 56 + surface modifiers: depressed=0, latched=0, locked=0, group=0 + "); + + assert_snapshot!(run(c, "+LALT +TAB -TAB +LCTL -LCTL -LALT"), @" + +LALT 64 XK_Alt_L + surface key pressed: 56 + surface modifiers: depressed=8, latched=0, locked=0, group=0 + +TAB 23 XK_Tab + surface leave + -TAB 23 XK_Tab + +LCTL 37 XK_Control_L + -LCTL 37 XK_Control_L + -LALT 64 XK_Alt_L + surface enter: [56] + surface modifiers: depressed=0, latched=0, locked=0, group=0 + surface key released: 56 + surface modifiers: depressed=0, latched=0, locked=0, group=0 + "); + + let c = " + binds { + Ctrl+Shift { screenshot; } + } + "; + + assert_snapshot!(run(c, "+LALT +TAB -TAB +LFSH -LFSH -LALT"), @" + +LALT 64 XK_Alt_L + surface key pressed: 56 + surface modifiers: depressed=8, latched=0, locked=0, group=0 + +TAB 23 XK_Tab + surface leave + -TAB 23 XK_Tab + +LFSH 50 XK_Shift_L + -LFSH 50 XK_Shift_L + -LALT 64 XK_Alt_L + surface enter: [56] + surface modifiers: depressed=0, latched=0, locked=0, group=0 + surface key released: 56 + surface modifiers: depressed=0, latched=0, locked=0, group=0 + "); + + assert_snapshot!(run(c, "+LALT +TAB -TAB +LCTL +LFSH -LFSH -LCTL -LALT"), @" + +LALT 64 XK_Alt_L + surface key pressed: 56 + surface modifiers: depressed=8, latched=0, locked=0, group=0 + +TAB 23 XK_Tab + surface leave + -TAB 23 XK_Tab + +LCTL 37 XK_Control_L + +LFSH 50 XK_Shift_L + -LFSH 50 XK_Shift_L + -LCTL 37 XK_Control_L + -LALT 64 XK_Alt_L + surface enter: [56] + surface modifiers: depressed=0, latched=0, locked=0, group=0 + surface key released: 56 + surface modifiers: depressed=0, latched=0, locked=0, group=0 + "); + + // Now with a normal trigger. + let c = " + binds { + Alt+Control_L { screenshot; } + } + "; + + assert_snapshot!(run(c, "+LALT +TAB -TAB +LFSH -LFSH -LALT"), @" + +LALT 64 XK_Alt_L + surface key pressed: 56 + surface modifiers: depressed=8, latched=0, locked=0, group=0 + +TAB 23 XK_Tab + surface leave + -TAB 23 XK_Tab + +LFSH 50 XK_Shift_L + -LFSH 50 XK_Shift_L + -LALT 64 XK_Alt_L + surface enter: [56] + surface modifiers: depressed=0, latched=0, locked=0, group=0 + surface key released: 56 + surface modifiers: depressed=0, latched=0, locked=0, group=0 + "); + + assert_snapshot!(run(c, "+LALT +TAB -TAB +LCTL -LCTL -LALT"), @" + +LALT 64 XK_Alt_L + surface key pressed: 56 + surface modifiers: depressed=8, latched=0, locked=0, group=0 + +TAB 23 XK_Tab + surface leave + -TAB 23 XK_Tab + +LCTL 37 XK_Control_L + surface enter: [56] + surface modifiers: depressed=12, latched=0, locked=0, group=0 + -LCTL 37 XK_Control_L + surface modifiers: depressed=8, latched=0, locked=0, group=0 + -LALT 64 XK_Alt_L + surface key released: 56 + surface modifiers: depressed=0, latched=0, locked=0, group=0 + "); + + let c = " + binds { + Ctrl+Shift_L { screenshot; } + } + "; + + assert_snapshot!(run(c, "+LALT +TAB -TAB +LFSH -LFSH -LALT"), @" + +LALT 64 XK_Alt_L + surface key pressed: 56 + surface modifiers: depressed=8, latched=0, locked=0, group=0 + +TAB 23 XK_Tab + surface leave + -TAB 23 XK_Tab + +LFSH 50 XK_Shift_L + surface enter: [56] + surface modifiers: depressed=9, latched=0, locked=0, group=0 + -LFSH 50 XK_Shift_L + surface modifiers: depressed=8, latched=0, locked=0, group=0 + -LALT 64 XK_Alt_L + surface key released: 56 + surface modifiers: depressed=0, latched=0, locked=0, group=0 + "); + + assert_snapshot!(run(c, "+LALT +TAB -TAB +LCTL +LFSH -LFSH -LCTL -LALT"), @" + +LALT 64 XK_Alt_L + surface key pressed: 56 + surface modifiers: depressed=8, latched=0, locked=0, group=0 + +TAB 23 XK_Tab + surface leave + -TAB 23 XK_Tab + +LCTL 37 XK_Control_L + +LFSH 50 XK_Shift_L + surface enter: [29, 56] + surface modifiers: depressed=13, latched=0, locked=0, group=0 + -LFSH 50 XK_Shift_L + surface modifiers: depressed=12, latched=0, locked=0, group=0 + -LCTL 37 XK_Control_L + surface key released: 29 + surface modifiers: depressed=8, latched=0, locked=0, group=0 + -LALT 64 XK_Alt_L + surface key released: 56 + surface modifiers: depressed=0, latched=0, locked=0, group=0 + "); +} + #[test] fn vt_switch_runs_release_actions_of_held_binds() { // A VT switch can take the input away without ever delivering the releases of everything held diff --git a/src/ui/mru.rs b/src/ui/mru.rs index c0afaaaf25..7c1194d049 100644 --- a/src/ui/mru.rs +++ b/src/ui/mru.rs @@ -1886,6 +1886,14 @@ fn make_dynamic_opened_binds(config: &Config) -> Vec { continue; }; + // The idea with dynamic binds is that their modifiers are ignored + // (set to whatever the user is currently holding). This idea does not + // make sense with order-independent generic-modifier-only binds. + // Effectively, they would trigger on every key press. + if bind.key.trigger.is_generic_modifier() { + continue; + } + let action = match press_action { Action::FocusColumnRight | Action::FocusColumnRightOrFirst From 9aa245c1b6ea73742b067e3f15db269b97cc96b5 Mon Sep 17 00:00:00 2001 From: Ivan Molodetskikh Date: Mon, 26 Oct 2026 09:10:26 +0300 Subject: [PATCH 18/40] input: Refactor screenshot binds to work like others Remove all screenshot hardcoding in the code. Behavior changes: - Escape to close now works only when no modifiers are held. - Other screenshot binds like Ctrl+C or Space used to ignore the state of some modifiers like Super, they now need them released. - Repeat is disabled for screenshot binds, practically holding P won't repeatedly cycle the pointer. - Inside the screenshot UI, the screenshot binds take precedence over allowed configured binds (e.g. if you configured Ctrl+C to spawn, or binds with MouseLeft). --- src/input/mod.rs | 103 ++++++++++++++++++---------------------- src/ui/screenshot_ui.rs | 103 ++++++++++++++++++++++++++-------------- 2 files changed, 115 insertions(+), 91 deletions(-) diff --git a/src/input/mod.rs b/src/input/mod.rs index 5d15d93495..c316332647 100644 --- a/src/input/mod.rs +++ b/src/input/mod.rs @@ -614,10 +614,15 @@ impl State { } let is_locked = this.niri.is_locked(); + let is_in_screenshot = this.niri.screenshot_ui.is_open(); let res = { let config = this.niri.config.borrow(); - let bindings = - make_binds_iter(&config, &mut this.niri.window_mru_ui, modifiers); + let bindings = make_binds_iter( + &config, + &this.niri.screenshot_ui, + &mut this.niri.window_mru_ui, + modifiers, + ); should_intercept_key( &mut this.niri.suppressed_keys, @@ -630,10 +635,10 @@ impl State { raw, pressed, *mods, - &this.niri.screenshot_ui, this.niri.config.borrow().input.disable_power_key_handling, is_inhibiting_shortcuts, is_locked, + is_in_screenshot, ) }; @@ -754,10 +759,7 @@ impl State { if self.niri.is_locked() && !allowed_when_locked { return; } - - // The bind lookup already substitutes the screenshot UI's own - // actions for presses, so only releases need to be filtered here. - if !pressed && self.niri.screenshot_ui.is_open() && !allowed_during_screenshot { + if self.niri.screenshot_ui.is_open() && !allowed_during_screenshot { return; } @@ -2938,11 +2940,14 @@ impl State { let modifiers = modifiers_from_state(mods); let config = self.niri.config.borrow(); - let bindings = make_binds_iter(&config, &mut self.niri.window_mru_ui, modifiers); + let bindings = make_binds_iter( + &config, + &self.niri.screenshot_ui, + &mut self.niri.window_mru_ui, + modifiers, + ); - find_configured_bind(bindings, mod_key, trigger, mods).filter(|bind| { - !self.niri.screenshot_ui.is_open() || bind_allowed_during_screenshot(bind, true) - }) + find_configured_bind(bindings, mod_key, trigger, mods) } fn on_pointer_button(&mut self, event: I::PointerButtonEvent) { @@ -3001,6 +3006,8 @@ impl State { if is_mru_open || self.niri.mods_with_mouse_binds.contains(&modifiers) { if let Some(bind) = match button { + // Screenshot UI takes precedence over binds. + Some(MouseButton::Left) if self.niri.screenshot_ui.is_open() => None, Some(MouseButton::Left) => Some(Trigger::MouseLeft), Some(MouseButton::Right) => Some(Trigger::MouseRight), Some(MouseButton::Middle) => Some(Trigger::MouseMiddle), @@ -3014,6 +3021,7 @@ impl State { if should_record_release_bind( &bind, self.niri.is_locked(), + self.niri.screenshot_ui.is_open(), &self.niri.bind_cooldown_timers, ) { self.niri @@ -4064,6 +4072,7 @@ impl State { if should_record_release_bind( &bind, self.niri.is_locked(), + self.niri.screenshot_ui.is_open(), &self.niri.bind_cooldown_timers, ) { self.niri @@ -4743,10 +4752,10 @@ fn should_intercept_key<'a>( raw: Option, pressed: bool, mods: ModifiersState, - screenshot_ui: &ScreenshotUi, disable_power_key_handling: bool, is_inhibiting_shortcuts: bool, is_locked: bool, + is_in_screenshot: bool, ) -> ShouldInterceptResult { // A release never runs a press action, so its result depends only on the bind recorded when // the key was pressed, and on whether that press was suppressed. @@ -4754,7 +4763,7 @@ fn should_intercept_key<'a>( return released_key_intercept_result(suppressed_keys, pending_release_binds, key_code); } - let mut final_bind = find_bind( + let final_bind = find_bind( bindings, mod_key, modified, @@ -4763,39 +4772,6 @@ fn should_intercept_key<'a>( disable_power_key_handling, ); - // Allow only a subset of compositor actions while the screenshot UI is open, since the user - // cannot see the screen. - if screenshot_ui.is_open() { - let mut use_screenshot_ui_action = true; - - if let Some(bind) = &final_bind { - if bind_allowed_during_screenshot(bind, true) { - use_screenshot_ui_action = false; - } - } - - if use_screenshot_ui_action { - if let Some(raw) = raw { - final_bind = screenshot_ui.action(raw, mods).map(|action| Bind { - key: Key { - trigger: Trigger::Keysym(raw), - // Not entirely correct but it doesn't matter in how we currently use it. - modifiers: Modifiers::empty(), - }, - action: BoundAction::Press(action), - repeat: true, - cooldown: None, - allow_when_locked: false, - // The screenshot UI owns the focus anyway, so this doesn't really matter. - // But logically, nothing can inhibit its actions. Only opening it can be - // inhibited. - allow_inhibiting: false, - hotkey_overlay_title: None, - }); - } - } - } - match final_bind { Some(bind) => { if is_inhibiting_shortcuts && bind.allow_inhibiting { @@ -4806,7 +4782,7 @@ fn should_intercept_key<'a>( suppressed_keys.insert(key_code); ShouldInterceptResult::InterceptOnly } else if is_keysym_modifier(modified) { - if should_record_release_bind(&bind, is_locked, cooldown_timers) { + if should_record_release_bind(&bind, is_locked, is_in_screenshot, cooldown_timers) { pending_release_binds.insert(key_code, bind.clone()); } if bind.has_press() { @@ -4821,7 +4797,7 @@ fn should_intercept_key<'a>( ShouldInterceptResult::Forward } } else { - if should_record_release_bind(&bind, is_locked, cooldown_timers) { + if should_record_release_bind(&bind, is_locked, is_in_screenshot, cooldown_timers) { pending_release_binds.insert(key_code, bind.clone()); } suppressed_keys.insert(key_code); @@ -5145,13 +5121,16 @@ fn bind_allowed_when_locked(bind: &Bind, pressed: bool) -> bool { fn should_record_release_bind( bind: &Bind, is_locked: bool, + is_in_screenshot: bool, cooldown_timers: &HashMap, ) -> bool { if bind.has_press() && is_bind_on_cooldown(bind, cooldown_timers) { return false; } - bind.has_release() && (!is_locked || bind_allowed_when_locked(bind, true)) + bind.has_release() + && (!is_locked || bind_allowed_when_locked(bind, true)) + && (!is_in_screenshot || bind_allowed_during_screenshot(bind, true)) } fn allowed_during_screenshot(action: &Action) -> bool { @@ -5192,6 +5171,10 @@ fn allowed_during_screenshot(action: &Action) -> bool { | Action::SetWindowWidth(_) | Action::SetWindowHeight(_) | Action::SetColumnWidth(_) + // Screenshot UI's own actions. + | Action::CancelScreenshot + | Action::ConfirmScreenshot { .. } + | Action::ScreenshotTogglePointer ) } @@ -5645,11 +5628,14 @@ fn grab_allows_hot_corner(grab: &(dyn PointerGrab + 'static)) -> bool { /// Includes dynamically populated bindings like the MRU UI. fn make_binds_iter<'a>( config: &'a Config, + screenshot_ui: &'a ScreenshotUi, mru: &'a mut WindowMruUi, mods: Modifiers, ) -> impl Iterator { + let general_binds = config.binds.0.iter(); + // Figure out the binds to use depending on whether the MRU is enabled and/or open. - let general_binds = (!mru.is_open()).then_some(config.binds.0.iter()); + let general_binds = (!mru.is_open()).then_some(general_binds); let general_binds = general_binds.into_iter().flatten(); let mru_binds = @@ -5659,22 +5645,27 @@ fn make_binds_iter<'a>( let mru_open_binds = mru.is_open().then(|| mru.opened_bindings(mods)); let mru_open_binds = mru_open_binds.into_iter().flatten(); - // General binds take precedence over the MRU binds. - general_binds.chain(mru_binds).chain(mru_open_binds) + // Screenshot UI binds already disable themselves when it's closed. + let screenshot_binds = screenshot_ui.binds(); + + // The precedence is screenshot UI, then general, then MRU binds. + screenshot_binds + .chain(general_binds) + .chain(mru_binds) + .chain(mru_open_binds) } #[cfg(test)] mod tests { use super::*; - use crate::animation::Clock; const CLOSE_KEYSYM: Keysym = Keysym::q; const CLOSE_KEY_CODE: Keycode = Keycode::new(CLOSE_KEYSYM.raw()); struct TestState { - screenshot_ui: ScreenshotUi, disable_power_key_handling: bool, is_inhibiting: bool, is_locked: bool, + is_in_screenshot: bool, suppressed_keys: HashSet, pending_release_binds: HashMap, cooldown_timers: HashMap, @@ -5682,10 +5673,10 @@ mod tests { fn create_test_state() -> TestState { TestState { - screenshot_ui: ScreenshotUi::new(Clock::default(), Default::default()), disable_power_key_handling: false, is_inhibiting: false, is_locked: false, + is_in_screenshot: false, suppressed_keys: HashSet::new(), pending_release_binds: HashMap::new(), cooldown_timers: HashMap::new(), @@ -5758,10 +5749,10 @@ mod tests { Some(CLOSE_KEYSYM), pressed, mods, - &state.screenshot_ui, state.disable_power_key_handling, state.is_inhibiting, state.is_locked, + state.is_in_screenshot, ) } diff --git a/src/ui/screenshot_ui.rs b/src/ui/screenshot_ui.rs index a1586361f0..75dada3cdf 100644 --- a/src/ui/screenshot_ui.rs +++ b/src/ui/screenshot_ui.rs @@ -7,7 +7,7 @@ use std::rc::Rc; use anyhow::Context; use arrayvec::ArrayVec; -use niri_config::{Action, Config}; +use niri_config::{Action, Bind, BoundAction, Config, Key, Modifiers, Trigger}; use niri_ipc::SizeChange; use pango::{Alignment, FontDescription}; use pangocairo::cairo::{self, ImageSurface}; @@ -17,7 +17,7 @@ use smithay::backend::renderer::element::utils::{Relocate, RelocateRenderElement use smithay::backend::renderer::element::Kind; use smithay::backend::renderer::gles::{GlesRenderer, GlesTexture}; use smithay::backend::renderer::{ExportMem, Texture as _}; -use smithay::input::keyboard::{Keysym, ModifiersState}; +use smithay::input::keyboard::Keysym; use smithay::output::{Output, WeakOutput}; use smithay::utils::{Buffer, Physical, Point, Rectangle, Scale, Size, Transform}; @@ -764,17 +764,77 @@ impl ScreenshotUi { Ok((rect.size, copy.to_vec())) } - pub fn action(&self, raw: Keysym, mods: ModifiersState) -> Option { + pub fn binds(&self) -> impl Iterator + Clone { + const fn bind(key: Key, action: Action) -> Bind { + Bind { + key, + action: BoundAction::Press(action), + repeat: false, // Not useful for these actions. + cooldown: None, + allow_when_locked: true, + allow_inhibiting: false, + hotkey_overlay_title: None, + } + } + + const BINDS: &[Bind] = &[ + bind( + Key { + trigger: Trigger::Keysym(Keysym::Escape), + modifiers: Modifiers::empty(), + }, + Action::CancelScreenshot, + ), + bind( + Key { + trigger: Trigger::Keysym(Keysym::Return), + modifiers: Modifiers::empty(), + }, + Action::ConfirmScreenshot { + write_to_disk: true, + }, + ), + bind( + Key { + trigger: Trigger::Keysym(Keysym::c), + modifiers: Modifiers::CTRL, + }, + Action::ConfirmScreenshot { + write_to_disk: false, + }, + ), + bind( + Key { + trigger: Trigger::Keysym(Keysym::p), + modifiers: Modifiers::empty(), + }, + Action::ScreenshotTogglePointer, + ), + ]; + + const SPACE: &[Bind] = &[bind( + Key { + trigger: Trigger::Keysym(Keysym::space), + modifiers: Modifiers::empty(), + }, + Action::ConfirmScreenshot { + write_to_disk: true, + }, + )]; + + // A bit of gymnastics to return an iterator over constant &Bind values. let Self::Open { button, .. } = self else { - return None; + return [].iter().chain([].iter()); }; // Pressing Space while the button is down goes into origin moving rather than capture. - if matches!(button, Button::Down { .. }) && raw == Keysym::space { - return None; - } + let space: &[Bind] = if matches!(button, Button::Down { .. }) { + &[] + } else { + SPACE + }; - action(raw, mods) + space.iter().chain(BINDS) } pub fn selection_output(&self) -> Option<&Output> { @@ -1068,33 +1128,6 @@ impl OutputScreenshot { } } -fn action(raw: Keysym, mods: ModifiersState) -> Option { - if raw == Keysym::Escape { - return Some(Action::CancelScreenshot); - } - - if mods.alt || mods.shift { - return None; - } - - if !mods.ctrl && (raw == Keysym::space || raw == Keysym::Return) { - return Some(Action::ConfirmScreenshot { - write_to_disk: true, - }); - } - if mods.ctrl && raw == Keysym::c { - return Some(Action::ConfirmScreenshot { - write_to_disk: false, - }); - } - - if !mods.ctrl && raw == Keysym::p { - return Some(Action::ScreenshotTogglePointer); - } - - None -} - pub fn rect_from_corner_points( a: Point, b: Point, From 5f23510779eb8d9f4bc38152b29dd4f2990f5ab5 Mon Sep 17 00:00:00 2001 From: Ivan Molodetskikh Date: Mon, 28 Sep 2026 10:55:22 +0300 Subject: [PATCH 19/40] tests/binds: Add more not-currently-working layouts tests --- src/tests/binds.rs | 63 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/src/tests/binds.rs b/src/tests/binds.rs index 9e3150ee3f..33109d4495 100644 --- a/src/tests/binds.rs +++ b/src/tests/binds.rs @@ -527,6 +527,9 @@ fn layouts() { binds { Q { close-window; } Shift+Slash { close-window; } + Cyrillic_ya { close-window; } + Shift+Cyrillic_che { close-window; } + Shift+Cyrillic_ES { close-window; } } "#; @@ -606,6 +609,66 @@ fn layouts() { surface modifiers: depressed=0, latched=0, locked=0, group=1 " ); + + // Я should trigger the Cyrillic_ya bind, but it currently doesn't. (Nobody binds keys like this + // in Russia, but people do in other countries with their non-ASCII characters.) + assert_snapshot!( + run(c, "+LALT -LALT +LatZ -LatZ"), + @" + +LALT 64 XK_ISO_Next_Group + surface key pressed: 56 + surface modifiers: depressed=0, latched=0, locked=0, group=1 + -LALT 64 XK_ISO_Next_Group + surface key released: 56 + +AB01 52 XK_Cyrillic_ya + surface key pressed: 44 + -AB01 52 XK_Cyrillic_ya + surface key released: 44 + " + ); + + // Non-ASCII binds should also be case-insensitive where reasonable, just like ASCII ones. Or, + // more accurately, the trigger key for a Modifier+key bind should be Modifier-insensitive. + assert_snapshot!( + run(c, "+LALT -LALT +LFSH +LatX -LatX -LFSH"), + @" + +LALT 64 XK_ISO_Next_Group + surface key pressed: 56 + surface modifiers: depressed=0, latched=0, locked=0, group=1 + -LALT 64 XK_ISO_Next_Group + surface key released: 56 + +LFSH 50 XK_Shift_L + surface key pressed: 42 + surface modifiers: depressed=1, latched=0, locked=0, group=1 + +AB02 53 XK_Cyrillic_CHE + surface key pressed: 45 + -AB02 53 XK_Cyrillic_CHE + surface key released: 45 + -LFSH 50 XK_Shift_L + surface key released: 42 + surface modifiers: depressed=0, latched=0, locked=0, group=1 + " + ); + assert_snapshot!( + run(c, "+LALT -LALT +LFSH +LatC -LatC -LFSH"), + @" + +LALT 64 XK_ISO_Next_Group + surface key pressed: 56 + surface modifiers: depressed=0, latched=0, locked=0, group=1 + -LALT 64 XK_ISO_Next_Group + surface key released: 56 + +LFSH 50 XK_Shift_L + surface key pressed: 42 + surface modifiers: depressed=1, latched=0, locked=0, group=1 + +AB03 54 XK_Cyrillic_ES + surface key pressed: 46 + -AB03 54 XK_Cyrillic_ES + surface key released: 46 + -LFSH 50 XK_Shift_L + surface key released: 42 + surface modifiers: depressed=0, latched=0, locked=0, group=1 + " + ); } #[test] From 447cf77167a8b3927cacc619e53e66d565919236 Mon Sep 17 00:00:00 2001 From: Ivan Molodetskikh Date: Mon, 26 Oct 2026 17:11:44 +0300 Subject: [PATCH 20/40] tests/binds: Add some more layouts tests --- src/tests/binds.rs | 72 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/src/tests/binds.rs b/src/tests/binds.rs index 33109d4495..e898c31058 100644 --- a/src/tests/binds.rs +++ b/src/tests/binds.rs @@ -669,6 +669,78 @@ fn layouts() { surface modifiers: depressed=0, latched=0, locked=0, group=1 " ); + + // ASCII binds on Russian should search the configured ASCII layout, not QWERTY. + // LatR corresponds to P on colemak_dh_ortho. + let c = r#" + input { + keyboard { + xkb { + layout "us,ru" + variant "colemak_dh_ortho," + options "grp:lalt_toggle" + } + } + } + + binds { + P { close-window; } + } + "#; + assert_snapshot!(run(c, "+LALT -LALT +LatR -LatR"), + @" + +LALT 64 XK_ISO_Next_Group + surface key pressed: 56 + surface modifiers: depressed=0, latched=0, locked=0, group=1 + -LALT 64 XK_ISO_Next_Group + surface key released: 56 + +AD04 27 XK_Cyrillic_ka + niri test-action + -AD04 27 XK_Cyrillic_ka + "); + + // ASCII fallback should find the first configured ASCII layout. + let c = r#" + input { + keyboard { + xkb { + layout "ru,us,us" + variant ",colemak_dh_ortho," + options "grp:lalt_toggle" + } + } + } + + binds { + P { close-window; } + } + "#; + // Input in ru (should fall back to us-colemak), + // then input in us-colemak (no fallback, works), + // then input in us-qwerty (no fallback, no bind). + assert_snapshot!(run(c, "+LatR -LatR +LALT -LALT +LatR -LatR +LALT -LALT +LatR -LatR"), + @" + +AD04 27 XK_Cyrillic_ka + niri test-action + -AD04 27 XK_Cyrillic_ka + +LALT 64 XK_ISO_Next_Group + surface key pressed: 56 + surface modifiers: depressed=0, latched=0, locked=0, group=1 + -LALT 64 XK_ISO_Next_Group + surface key released: 56 + +AD04 27 XK_p + niri test-action + -AD04 27 XK_p + +LALT 64 XK_ISO_Next_Group + surface key pressed: 56 + surface modifiers: depressed=0, latched=0, locked=0, group=2 + -LALT 64 XK_ISO_Next_Group + surface key released: 56 + +AD04 27 XK_r + surface key pressed: 19 + -AD04 27 XK_r + surface key released: 19 + "); } #[test] From 9d8f4f89f6a075a04ed24af5f13829d0bdba5b85 Mon Sep 17 00:00:00 2001 From: Ivan Molodetskikh Date: Mon, 26 Oct 2026 17:19:06 +0300 Subject: [PATCH 21/40] tests/binds: Add two more layouts tests --- src/tests/binds.rs | 48 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/src/tests/binds.rs b/src/tests/binds.rs index e898c31058..483aa2f7b4 100644 --- a/src/tests/binds.rs +++ b/src/tests/binds.rs @@ -741,6 +741,54 @@ fn layouts() { -AD04 27 XK_r surface key released: 19 "); + + // We require the modifiers to match the bind for it to trigger. + // So Ctrl-? cannot be triggered on us-qwerty since entering ? necessarily requires a Shift. + // If you spell out Shift like Super-Shift-?, it should be triggerable (but currently isn't). + let c = r#" + input { + keyboard { + xkb { + layout "us" + } + } + } + + binds { + Ctrl+question { close-window; } + Super+Shift+question { close-window; } + } + "#; + assert_snapshot!(run(c, "+LCTL +AB10 -AB10 +LFSH +AB10 -AB10"), + @" + +LCTL 37 XK_Control_L + surface key pressed: 29 + surface modifiers: depressed=4, latched=0, locked=0, group=0 + +AB10 61 XK_slash + surface key pressed: 53 + -AB10 61 XK_slash + surface key released: 53 + +LFSH 50 XK_Shift_L + surface key pressed: 42 + surface modifiers: depressed=5, latched=0, locked=0, group=0 + +AB10 61 XK_question + surface key pressed: 53 + -AB10 61 XK_question + surface key released: 53 + "); + assert_snapshot!(run(c, "+LWIN +LFSH +AB10 -AB10"), + @" + +LWIN 133 XK_Super_L + surface key pressed: 125 + surface modifiers: depressed=64, latched=0, locked=0, group=0 + +LFSH 50 XK_Shift_L + surface key pressed: 42 + surface modifiers: depressed=65, latched=0, locked=0, group=0 + +AB10 61 XK_question + surface key pressed: 53 + -AB10 61 XK_question + surface key released: 53 + "); } #[test] From 3ab862e038c8b4ced2c8c86f54cf229209884709 Mon Sep 17 00:00:00 2001 From: Ivan Molodetskikh Date: Tue, 27 Oct 2026 09:45:49 +0300 Subject: [PATCH 22/40] tests/binds: Add more layouts tests --- src/tests/binds.rs | 153 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 153 insertions(+) diff --git a/src/tests/binds.rs b/src/tests/binds.rs index 483aa2f7b4..4f1e452dfe 100644 --- a/src/tests/binds.rs +++ b/src/tests/binds.rs @@ -742,6 +742,119 @@ fn layouts() { surface key released: 19 "); + // ASCII fallback should include non-alphabetic characters so more binds keep working on us,ru. + let c = r#" + input { + keyboard { + xkb { + layout "ru,us" + } + } + } + + binds { + Comma { close-window; } + BracketLeft { close-window; } + } + "#; + assert_snapshot!(run(c, "+AD11 -AD11 +AB08 -AB08"), + @" + +AD11 34 XK_Cyrillic_ha + niri test-action + -AD11 34 XK_Cyrillic_ha + +AB08 59 XK_Cyrillic_be + niri test-action + -AB08 59 XK_Cyrillic_be + "); + + // Non-alphabetic ASCII characters shouldn't fallback to other layouts to avoid surprises. + // Q is ' on US Dvorak. + let c = r#" + input { + keyboard { + xkb { + layout "us,us" + variant "dvorak," + options "grp:lalt_toggle" + } + } + } + + binds { + Q { close-window; } + } + "#; + assert_snapshot!(run(c, "+LatQ -LatQ +LALT +LALT +LatQ -LatQ"), + @" + +AD01 24 XK_apostrophe + surface key pressed: 16 + -AD01 24 XK_apostrophe + surface key released: 16 + +LALT 64 XK_ISO_Next_Group + surface key pressed: 56 + surface modifiers: depressed=0, latched=0, locked=0, group=1 + +LALT 64 XK_ISO_Next_Group + +AD01 24 XK_q + niri test-action + -AD01 24 XK_q + "); + + // ASCII fallback shouldn't depend on holding modifiers like Shift so that binds with these + // modifiers can reliably be fallen back to. + // + // On il, the ; : key is hebrew_finalpe :, meaning that ; is not ASCII but Shift+; is. + // We don't want the fallback to differ in this case. + let c = r#" + input { + keyboard { + xkb { + layout "il,us" + } + } + } + + binds { + Semicolon { close-window; } + Shift+Semicolon { close-window; } + } + "#; + assert_snapshot!(run(c, "+AC10 -AC10 +LFSH +AC10 -AC10"), + @" + +AC10 47 XK_hebrew_finalpe + niri test-action + -AC10 47 XK_hebrew_finalpe + +LFSH 50 XK_Shift_L + surface key pressed: 42 + surface modifiers: depressed=1, latched=0, locked=0, group=0 + +AC10 47 XK_colon + niri test-action + -AC10 47 XK_colon + "); + + // ASCII fallback should work on dead keys too (currently doesn't). + // + // Greek has a dead key on ;. + let c = r#" + input { + keyboard { + xkb { + layout "gr,us" + } + } + } + + binds { + Semicolon { close-window; } + } + "#; + assert_snapshot!(run(c, "+AC10 -AC10"), + @" + +AC10 47 XK_dead_acute + surface key pressed: 39 + -AC10 47 XK_dead_acute + surface key released: 39 + "); + // We require the modifiers to match the bind for it to trigger. // So Ctrl-? cannot be triggered on us-qwerty since entering ? necessarily requires a Shift. // If you spell out Shift like Super-Shift-?, it should be triggerable (but currently isn't). @@ -789,6 +902,46 @@ fn layouts() { -AB10 61 XK_question surface key released: 53 "); + + // The BÉPO example from the docs. The key should be bindable by both « and < trigger. + // But you have to spell out Mod5 since niri requires all modifiers be spelled. + let c = r#" + input { + keyboard { + xkb { + layout "fr" + variant "bepo" + } + } + } + + binds { + Super+Mod5+guillemotleft { close-window; } + Ctrl+Mod5+less { close-window; } + } + "#; + assert_snapshot!(run(c, "+LWIN +RALT +AE02"), + @" + +LWIN 133 XK_Super_L + surface key pressed: 125 + surface modifiers: depressed=64, latched=0, locked=0, group=0 + +RALT 108 XK_ISO_Level3_Shift + surface key pressed: 100 + surface modifiers: depressed=192, latched=0, locked=0, group=0 + +AE02 11 XK_less + niri test-action + "); + assert_snapshot!(run(c, "+LCTL +RALT +AE02"), + @" + +LCTL 37 XK_Control_L + surface key pressed: 29 + surface modifiers: depressed=4, latched=0, locked=0, group=0 + +RALT 108 XK_ISO_Level3_Shift + surface key pressed: 100 + surface modifiers: depressed=132, latched=0, locked=0, group=0 + +AE02 11 XK_less + surface key pressed: 3 + "); } #[test] From 1b1dc6dcaa086ce29f60edcbf603c7f6591b1b58 Mon Sep 17 00:00:00 2001 From: Ivan Molodetskikh Date: Tue, 27 Oct 2026 09:34:55 +0300 Subject: [PATCH 23/40] input: Add Tracy span to find_bind() --- src/input/mod.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/input/mod.rs b/src/input/mod.rs index c316332647..a6b2386458 100644 --- a/src/input/mod.rs +++ b/src/input/mod.rs @@ -4823,6 +4823,8 @@ fn find_bind<'a>( mods: ModifiersState, disable_power_key_handling: bool, ) -> Option { + let _span = tracy_client::span!("find_bind"); + use keysyms::*; // Handle hardcoded binds. From ca8777d01dd5ff03b2ba61e28646faf2aecac0a1 Mon Sep 17 00:00:00 2001 From: Ivan Molodetskikh Date: Tue, 27 Oct 2026 09:44:43 +0300 Subject: [PATCH 24/40] input: Rename raw arg to key --- src/input/mod.rs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/input/mod.rs b/src/input/mod.rs index a6b2386458..9df02f0265 100644 --- a/src/input/mod.rs +++ b/src/input/mod.rs @@ -4915,11 +4915,11 @@ fn find_bind<'a>( .cloned() } -fn trigger_matches_keysym(trigger: Trigger, raw: Keysym, mod_key: ModKey) -> bool { +fn trigger_matches_keysym(trigger: Trigger, key: Keysym, mod_key: ModKey) -> bool { match trigger { - Trigger::CompositorMod => mod_key.matches_keysym(raw), - Trigger::Keysym(keysym) => keysym == raw, - Trigger::Modifier(modifier) => modifier.matches_keysym(raw), + Trigger::CompositorMod => mod_key.matches_keysym(key), + Trigger::Keysym(keysym) => keysym == key, + Trigger::Modifier(modifier) => modifier.matches_keysym(key), _ => false, } } @@ -5196,14 +5196,14 @@ fn bind_allowed_during_screenshot(bind: &Bind, pressed: bool) -> bool { } } -fn hardcoded_overview_bind(raw: Keysym, mods: ModifiersState) -> Option { +fn hardcoded_overview_bind(key: Keysym, mods: ModifiersState) -> Option { let mods = modifiers_from_state(mods); if !mods.is_empty() { return None; } let mut repeat = true; - let action = match raw { + let action = match key { Keysym::Escape | Keysym::Return => { repeat = false; Action::ToggleOverview @@ -5219,7 +5219,7 @@ fn hardcoded_overview_bind(raw: Keysym, mods: ModifiersState) -> Option { Some(Bind { key: Key { - trigger: Trigger::Keysym(raw), + trigger: Trigger::Keysym(key), modifiers: Modifiers::empty(), }, action: BoundAction::Press(action), From 2f15c28270d8da0bdf1b699dccd7b0cce772e879 Mon Sep 17 00:00:00 2001 From: Ivan Molodetskikh Date: Tue, 27 Oct 2026 09:45:22 +0300 Subject: [PATCH 25/40] input: Pass Modifiers instead of ModifiersState --- src/input/mod.rs | 51 ++++++++++++++++++++---------------------------- 1 file changed, 21 insertions(+), 30 deletions(-) diff --git a/src/input/mod.rs b/src/input/mod.rs index 9df02f0265..e83a58f264 100644 --- a/src/input/mod.rs +++ b/src/input/mod.rs @@ -634,7 +634,7 @@ impl State { modified, raw, pressed, - *mods, + modifiers, this.niri.config.borrow().input.disable_power_key_handling, is_inhibiting_shortcuts, is_locked, @@ -645,7 +645,8 @@ impl State { if matches!(res, ShouldInterceptResult::Forward) { // If we didn't find any bind, try other hardcoded keys. if this.niri.keyboard_focus.is_overview() && pressed { - if let Some(bind) = raw.and_then(|raw| hardcoded_overview_bind(raw, *mods)) + if let Some(bind) = + raw.and_then(|raw| hardcoded_overview_bind(raw, modifiers)) { this.niri.suppressed_keys.insert(key_code); return ShouldInterceptResult::InterceptAndHandle(bind); @@ -4751,7 +4752,7 @@ fn should_intercept_key<'a>( modified: Keysym, raw: Option, pressed: bool, - mods: ModifiersState, + mods: Modifiers, disable_power_key_handling: bool, is_inhibiting_shortcuts: bool, is_locked: bool, @@ -4820,7 +4821,7 @@ fn find_bind<'a>( mod_key: ModKey, modified: Keysym, raw: Option, - mods: ModifiersState, + mods: Modifiers, disable_power_key_handling: bool, ) -> Option { let _span = tracy_client::span!("find_bind"); @@ -4863,8 +4864,6 @@ fn find_bind<'a>( let raw = raw?; - let modifiers = modifiers_from_state(mods); - // A modifier key can trigger binds bound as the compositor mod key (`Mod`), by its own keysym // (like `Alt_L`), or as the modifier itself (like `Alt`). Return the first bind that matches. bindings @@ -4875,7 +4874,7 @@ fn find_bind<'a>( // // Pass the bind's trigger itself since we'll check the trigger match below. // (Note that this means mod_key is not properly checked when it matches the trigger.) - if !bind_matches_trigger(bind, bind_trigger, mod_key, modifiers) { + if !bind_matches_trigger(bind, bind_trigger, mod_key, mods) { return false; } @@ -4891,7 +4890,7 @@ fn find_bind<'a>( // Check it here. if (bind_trigger == Trigger::CompositorMod || bind_trigger == Trigger::Modifier(mod_key)) - && !modifiers.contains(mod_key.to_modifiers()) + && !mods.contains(mod_key.to_modifiers()) { return false; } @@ -5196,8 +5195,7 @@ fn bind_allowed_during_screenshot(bind: &Bind, pressed: bool) -> bool { } } -fn hardcoded_overview_bind(key: Keysym, mods: ModifiersState) -> Option { - let mods = modifiers_from_state(mods); +fn hardcoded_overview_bind(key: Keysym, mods: Modifiers) -> Option { if !mods.is_empty() { return None; } @@ -5703,18 +5701,11 @@ mod tests { bind(Trigger::Keysym(CLOSE_KEYSYM), Modifiers::COMPOSITOR, action) } - fn mod_held() -> ModifiersState { - ModifiersState { - logo: true, - ..Default::default() - } - } - /// Processes a press of the `Mod+Q` bind that the tests are written with. fn press_close_key( state: &mut TestState, bindings: &Binds, - mods: ModifiersState, + mods: Modifiers, ) -> ShouldInterceptResult { process_close_key(state, bindings, mods, KeyState::Pressed) } @@ -5723,7 +5714,7 @@ mod tests { fn release_close_key( state: &mut TestState, bindings: &Binds, - mods: ModifiersState, + mods: Modifiers, ) -> ShouldInterceptResult { process_close_key(state, bindings, mods, KeyState::Released) } @@ -5731,7 +5722,7 @@ mod tests { fn process_close_key( state: &mut TestState, bindings: &Binds, - mods: ModifiersState, + mods: Modifiers, key_state: KeyState, ) -> ShouldInterceptResult { let pressed = key_state == KeyState::Pressed; @@ -6024,13 +6015,13 @@ mod tests { is_locked: true, ..create_test_state() }; - let result = press_close_key(&mut state, &bindings, mod_held()); + let result = press_close_key(&mut state, &bindings, Modifiers::SUPER); // `handle_bind()` drops the press while locked, and nothing is recorded to undo. assert_matches!(result, ShouldInterceptResult::InterceptAndHandle(_)); assert!(state.pending_release_binds.is_empty()); - let result = release_close_key(&mut state, &bindings, mod_held()); + let result = release_close_key(&mut state, &bindings, Modifiers::SUPER); assert_matches!(result, ShouldInterceptResult::InterceptOnly); } @@ -6044,12 +6035,12 @@ mod tests { })]); let mut state = create_test_state(); - let result = press_close_key(&mut state, &bindings, mod_held()); + let result = press_close_key(&mut state, &bindings, Modifiers::SUPER); assert_matches!(result, ShouldInterceptResult::InterceptAndHandle(_)); assert!(state.pending_release_binds.contains_key(&CLOSE_KEY_CODE)); state.is_locked = true; - let result = release_close_key(&mut state, &bindings, mod_held()); + let result = release_close_key(&mut state, &bindings, Modifiers::SUPER); assert_matches!( result, ShouldInterceptResult::InterceptAndHandle(Bind { @@ -6068,11 +6059,11 @@ mod tests { let bindings = Binds(vec![close_bind(BoundAction::Release(Action::CloseWindow))]); let mut state = create_test_state(); - let result = press_close_key(&mut state, &bindings, mod_held()); + let result = press_close_key(&mut state, &bindings, Modifiers::SUPER); assert_matches!(result, ShouldInterceptResult::InterceptOnly); assert!(state.pending_release_binds.contains_key(&CLOSE_KEY_CODE)); - let result = release_close_key(&mut state, &bindings, mod_held()); + let result = release_close_key(&mut state, &bindings, Modifiers::SUPER); assert_matches!( result, ShouldInterceptResult::InterceptAndHandle(Bind { @@ -6094,11 +6085,11 @@ mod tests { is_locked: true, ..create_test_state() }; - let result = press_close_key(&mut state, &bindings, mod_held()); + let result = press_close_key(&mut state, &bindings, Modifiers::SUPER); assert_matches!(result, ShouldInterceptResult::InterceptOnly); assert!(state.pending_release_binds.contains_key(&CLOSE_KEY_CODE)); - let result = release_close_key(&mut state, &bindings, mod_held()); + let result = release_close_key(&mut state, &bindings, Modifiers::SUPER); assert_matches!( result, ShouldInterceptResult::InterceptAndHandle(Bind { @@ -6117,11 +6108,11 @@ mod tests { is_locked: true, ..create_test_state() }; - let result = press_close_key(&mut state, &bindings, mod_held()); + let result = press_close_key(&mut state, &bindings, Modifiers::SUPER); assert_matches!(result, ShouldInterceptResult::InterceptOnly); assert!(state.pending_release_binds.is_empty()); - let result = release_close_key(&mut state, &bindings, mod_held()); + let result = release_close_key(&mut state, &bindings, Modifiers::SUPER); assert_matches!(result, ShouldInterceptResult::InterceptOnly); assert!(state.pending_release_binds.is_empty()); } From 3bc662c38518716018814e014f0928626f4ac393 Mon Sep 17 00:00:00 2001 From: Ivan Molodetskikh Date: Tue, 27 Oct 2026 09:45:48 +0300 Subject: [PATCH 26/40] input: Use Option for modified Arguably more semantically correct, and will avoid running some unnecessary loops in the future. --- src/input/mod.rs | 31 +++++++++++++++++-------------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/src/input/mod.rs b/src/input/mod.rs index e83a58f264..332843caf1 100644 --- a/src/input/mod.rs +++ b/src/input/mod.rs @@ -522,6 +522,7 @@ impl State { |this, mods, keysym| { let key_code = event.key_code(); let modified = keysym.modified_sym(); + let modified = (modified != Keysym::NoSymbol).then_some(modified); let raw = keysym.raw_latin_sym_or_raw_current_sym(); let modifiers = modifiers_from_state(*mods); @@ -552,14 +553,16 @@ impl State { let forward = matches!( modified, - Keysym::Shift_L - | Keysym::Shift_R - | Keysym::Control_L - | Keysym::Control_R - | Keysym::Super_L - | Keysym::Super_R - | Keysym::Alt_L - | Keysym::Alt_R + Some( + Keysym::Shift_L + | Keysym::Shift_R + | Keysym::Control_L + | Keysym::Control_R + | Keysym::Super_L + | Keysym::Super_R + | Keysym::Alt_L + | Keysym::Alt_R + ) ); return bind_intercept_result(bind, forward); } @@ -4749,7 +4752,7 @@ fn should_intercept_key<'a>( bindings: impl IntoIterator, mod_key: ModKey, key_code: Keycode, - modified: Keysym, + modified: Option, raw: Option, pressed: bool, mods: Modifiers, @@ -4782,7 +4785,7 @@ fn should_intercept_key<'a>( // or forward the press. suppressed_keys.insert(key_code); ShouldInterceptResult::InterceptOnly - } else if is_keysym_modifier(modified) { + } else if modified.is_some_and(is_keysym_modifier) { if should_record_release_bind(&bind, is_locked, is_in_screenshot, cooldown_timers) { pending_release_binds.insert(key_code, bind.clone()); } @@ -4819,7 +4822,7 @@ fn should_intercept_key<'a>( fn find_bind<'a>( bindings: impl IntoIterator, mod_key: ModKey, - modified: Keysym, + modified: Option, raw: Option, mods: Modifiers, disable_power_key_handling: bool, @@ -4830,7 +4833,7 @@ fn find_bind<'a>( // Handle hardcoded binds. #[allow(non_upper_case_globals)] // wat - let hardcoded_action = match modified.raw() { + let hardcoded_action = match modified.map_or(KEY_NoSymbol, Keysym::raw) { modified @ KEY_XF86Switch_VT_1..=KEY_XF86Switch_VT_12 => { let vt = (modified - KEY_XF86Switch_VT_1 + 1) as i32; Some(Action::ChangeVt(vt)) @@ -4843,7 +4846,7 @@ fn find_bind<'a>( return Some(Bind { key: Key { // Not entirely correct but it doesn't matter in how we currently use it. - trigger: Trigger::Keysym(modified), + trigger: Trigger::Keysym(modified.unwrap()), modifiers: Modifiers::empty(), }, action: BoundAction::Press(action), @@ -5738,7 +5741,7 @@ mod tests { &bindings.0, ModKey::Super, CLOSE_KEY_CODE, - CLOSE_KEYSYM, + Some(CLOSE_KEYSYM), Some(CLOSE_KEYSYM), pressed, mods, From cd45650b1dee0da008cb03d44b056896cf1db18f Mon Sep 17 00:00:00 2001 From: Ivan Molodetskikh Date: Tue, 27 Oct 2026 09:45:48 +0300 Subject: [PATCH 27/40] input: Check modified for hardcoded binds instead of raw More semantically correct. If you have Escape on some xkb layer, why wouldn't it work to hit it? --- src/input/mod.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/input/mod.rs b/src/input/mod.rs index 332843caf1..661be1f14e 100644 --- a/src/input/mod.rs +++ b/src/input/mod.rs @@ -571,7 +571,7 @@ impl State { } if this.niri.exit_confirm_dialog.is_open() && pressed { - if raw == Some(Keysym::Return) { + if modified == Some(Keysym::Return) { info!("quitting after confirming exit dialog"); this.niri.stop_signal.stop(); } @@ -595,7 +595,7 @@ impl State { ); } - if pressed && raw == Some(Keysym::Escape) { + if pressed && modified == Some(Keysym::Escape) { // Cancel certain grabs on Escape. let pointer = this.niri.seat.get_pointer().unwrap(); if pointer @@ -608,7 +608,7 @@ impl State { } } - if let Some(Keysym::space) = raw { + if modified == Some(Keysym::space) { this.niri.screenshot_ui.set_space_down(pressed); } @@ -649,7 +649,7 @@ impl State { // If we didn't find any bind, try other hardcoded keys. if this.niri.keyboard_focus.is_overview() && pressed { if let Some(bind) = - raw.and_then(|raw| hardcoded_overview_bind(raw, modifiers)) + modified.and_then(|key| hardcoded_overview_bind(key, modifiers)) { this.niri.suppressed_keys.insert(key_code); return ShouldInterceptResult::InterceptAndHandle(bind); From 74bcbf18ce910b8a320c8ed0f254268dfe547f9c Mon Sep 17 00:00:00 2001 From: Ivan Molodetskikh Date: Tue, 27 Oct 2026 09:45:49 +0300 Subject: [PATCH 28/40] input: Extract find() closure in find_bind() --- src/input/mod.rs | 95 +++++++++++++++++++++++++----------------------- 1 file changed, 50 insertions(+), 45 deletions(-) diff --git a/src/input/mod.rs b/src/input/mod.rs index 661be1f14e..86b4118c85 100644 --- a/src/input/mod.rs +++ b/src/input/mod.rs @@ -4819,8 +4819,8 @@ fn should_intercept_key<'a>( } } -fn find_bind<'a>( - bindings: impl IntoIterator, +fn find_bind<'a, I: IntoIterator>( + bindings: I, mod_key: ModKey, modified: Option, raw: Option, @@ -4865,56 +4865,61 @@ fn find_bind<'a>( }); } - let raw = raw?; - // A modifier key can trigger binds bound as the compositor mod key (`Mod`), by its own keysym // (like `Alt_L`), or as the modifier itself (like `Alt`). Return the first bind that matches. - bindings - .into_iter() - .find(|bind| { - let bind_trigger = bind.key.trigger; - // Check that the bind modifiers match the held modifiers. - // - // Pass the bind's trigger itself since we'll check the trigger match below. - // (Note that this means mod_key is not properly checked when it matches the trigger.) - if !bind_matches_trigger(bind, bind_trigger, mod_key, mods) { - return false; - } - - // Binds that consist only of generic modifiers can be hit in any order (trigger key - // does not have to be the last). This is consistent with how these types of binds work - // elsewhere. - // - // For example: - // - Alt + Shift to switch keyboard layout on Windows - // - Ctrl + Alt to show inlay hints in VSCode - if bind_trigger.is_generic_modifier() { - // Bind trigger matching compositor mod isn't properly checked above. - // Check it here. - if (bind_trigger == Trigger::CompositorMod - || bind_trigger == Trigger::Modifier(mod_key)) - && !mods.contains(mod_key.to_modifiers()) - { + let find = |bindings: I, key: Keysym| { + bindings + .into_iter() + .find(|bind| { + let bind_trigger = bind.key.trigger; + // Check that the bind modifiers match the held modifiers. + // + // Pass the bind's trigger itself since we'll check the trigger match below. + // (Note that this means mod_key is not properly checked when it matches the + // trigger.) + if !bind_matches_trigger(bind, bind_trigger, mod_key, mods) { return false; } - // Check that the key we pressed is one of the bind modifiers. - let mut raw_mods = Trigger::Keysym(raw).implied_modifier(); - if mod_key.matches_keysym(raw) { - raw_mods |= Modifiers::COMPOSITOR; + // Binds that consist only of generic modifiers can be hit in any order (trigger key + // does not have to be the last). This is consistent with how these types of binds + // work elsewhere. + // + // For example: + // - Alt + Shift to switch keyboard layout on Windows + // - Ctrl + Alt to show inlay hints in VSCode + if bind_trigger.is_generic_modifier() { + // Bind trigger matching compositor mod isn't properly checked above. + // Check it here. + if (bind_trigger == Trigger::CompositorMod + || bind_trigger == Trigger::Modifier(mod_key)) + && !mods.contains(mod_key.to_modifiers()) + { + return false; + } + + // Check that the key we pressed is one of the bind modifiers. + let mut raw_mods = Trigger::Keysym(key).implied_modifier(); + if mod_key.matches_keysym(key) { + raw_mods |= Modifiers::COMPOSITOR; + } + return !raw_mods.is_empty() && bind.key.modifiers.intersects(raw_mods); } - return !raw_mods.is_empty() && bind.key.modifiers.intersects(raw_mods); - } - // Modifier-only binds can be written in several ways: - // - `Mod { quit; }` - Trigger::CompositorMod - // - `Super { quit; }` - Trigger::Modifier(_) - // - `Super_L { quit; }` - Trigger::Keysym(_) - // - // These only matter for keyboard keys and not for mouse or others. So, check them here. - trigger_matches_keysym(bind_trigger, raw, mod_key) - }) - .cloned() + // Modifier-only binds can be written in several ways: + // - `Mod { quit; }` - Trigger::CompositorMod + // - `Super { quit; }` - Trigger::Modifier(_) + // - `Super_L { quit; }` - Trigger::Keysym(_) + // + // These only matter for keyboard keys and not for mouse or others. So, check them + // here. + trigger_matches_keysym(bind_trigger, key, mod_key) + }) + .cloned() + }; + + let key = raw?; + find(bindings, key) } fn trigger_matches_keysym(trigger: Trigger, key: Keysym, mod_key: ModKey) -> bool { From fcbc971cf49a987b1e8d5ff1d64da6b1f61a1787 Mon Sep 17 00:00:00 2001 From: Ivan Molodetskikh Date: Tue, 27 Oct 2026 09:45:49 +0300 Subject: [PATCH 29/40] input: Search for binds with modified, raw, and raw_ascii See the comment in the code. First we search for modified because that's the symbol that was actually input. Fixes cases like Mod+odiaeresis binds falling back to a ASCII bind unnecessarily, or Mod+Shift+/ on a Russian layout with its \ / key. Then we search for layer-0 current layout, which makes non-ASCII binds case-insensitive too. Then we search for a layer-0 ASCII key on other layouts, but only if raw isn't ASCII itself. This is the required fallback for Russian binds. Another thing this commit changes is that dead keys now get ASCII fallback too. --- docs/wiki/Configuration:-Key-Bindings.md | 21 ++++-- src/input/mod.rs | 95 ++++++++++++++++++++---- src/tests/binds.rs | 33 ++++---- 3 files changed, 109 insertions(+), 40 deletions(-) diff --git a/docs/wiki/Configuration:-Key-Bindings.md b/docs/wiki/Configuration:-Key-Bindings.md index 149418a302..f23c52db0d 100644 --- a/docs/wiki/Configuration:-Key-Bindings.md +++ b/docs/wiki/Configuration:-Key-Bindings.md @@ -86,14 +86,25 @@ binds { > Here, look at `sym: Left` and `sym: Right`: these are the key names. > I was pressing the left and the right arrow in this example. > -> Keep in mind that binding shifted keys requires spelling out Shift and the unshifted version of the key, according to your XKB layout. -> For example, on the US QWERTY layout, < is on Shift + ,, so to bind it, you spell out something like `Mod+Shift+Comma`. +> Keep in mind that binding a key requires spelling out the modifiers that you hold when typing it. +> So, symbols that you type while holding Shift need the Shift modifier in the corresponding binding. +> For example, on the US QWERTY layout, < is on Shift + ,, so to bind it, you spell out something like `Mod+Shift+Less` or `Mod+Shift+Comma` (just `Mod+Less` will not work). > > As another example, if you've configured the French [BÉPO](https://en.wikipedia.org/wiki/B%C3%89PO) XKB layout, your < is on AltGr + «. -> AltGr is `ISO_Level3_Shift`, or equivalently `Mod5`, so to bind it, you spell out something like `Mod+Mod5+guillemotleft`. +> AltGr is `ISO_Level3_Shift`, or equivalently `Mod5`, so to bind it, you spell out something like `Mod+Mod5+Less` or `Mod+Mod5+guillemotleft`. > -> When resolving latin keys, niri will search for the *first* configured XKB layout that has the latin key. -> So for example with US QWERTY and RU layouts configured, US QWERTY will be used for latin binds. +> When resolving ASCII (latin) keys, niri will search for the *first* configured XKB layout that has the ASCII key. +> So for example with US Colemak and RU layouts configured, US Colemak will be used for ASCII binds. +> +> Keys from the current layout have priority though. +> For example, if you have US QWERTY and Russian layouts, and both `Mod+Z` and `Mod+Cyrillic_ya` binds, then on Russian the `Mod+Cyrillic_ya` bind will trigger, while on US, the `Mod+Z` bind will trigger. +> +> Also, binds that spell out the typed character have priority over binds that spell out an unshifted character. +> For example, on US QWERTY, `Mod+Shift+At` will have priority over `Mod+Shift+2` if both are configured. +> +> Since: next release You used to need to spell out the *unshifted* version of the trigger key. +> So to bind < on US QWERTY you could use only `Mod+Shift+Comma` (since , is the unshifted version of that key). +> This is no longer the case: you can spell out either version of the key. Since: 0.1.8 Binds will repeat by default (i.e. holding down a bind will make it trigger repeatedly). You can disable that for specific binds with `repeat=false`: diff --git a/src/input/mod.rs b/src/input/mod.rs index 86b4118c85..80d18e06d9 100644 --- a/src/input/mod.rs +++ b/src/input/mod.rs @@ -520,12 +520,78 @@ impl State { event.key_code(), event.state(), |this, mods, keysym| { - let key_code = event.key_code(); - let modified = keysym.modified_sym(); - let modified = (modified != Keysym::NoSymbol).then_some(modified); - let raw = keysym.raw_latin_sym_or_raw_current_sym(); + let key_code = keysym.raw_code(); let modifiers = modifiers_from_state(*mods); + // When resolving a bind, we can try three keysyms: + // - modified: the actual final keysym we got by pressing this key + // - raw: keysym from the 0th layer of the current layout (usually lowercase letter) + // - raw_ascii: keysym from the same key on an ASCII layout + // (if raw isn't ASCII itself) + let (modified, raw, raw_ascii) = { + let xkb = keysym.xkb(); + let xkb = xkb.lock().unwrap(); + + // SAFETY: xkb_state does not outlive xkb. + let (modified, layout) = unsafe { + let xkb_state = xkb.state(); + ( + xkb_state.key_get_one_sym(key_code), + Layout(xkb_state.key_get_layout(key_code)), + ) + }; + let modified = (modified != Keysym::NoSymbol).then_some(modified); + + let raw_sym = |layout| { + let raw_syms = xkb.raw_syms_for_key_in_layout(key_code, layout); + + // Match the xkb_key_get_one_sym() logic. + if raw_syms.len() == 1 { + Some(raw_syms[0]) + } else { + None + } + }; + + let raw = raw_sym(layout); + + // We include numbers and symbols too to have more binds working on us,ru and + // avoid some surprising behavior. + let is_ascii = |keysym: Keysym| { + keysym.key_char().is_some_and(|key| key.is_ascii_graphic()) + }; + + let mut raw_ascii = None; + + // Only check if raw is ASCII and not modified, so that e.g. pressing Shift + // does not change the fallback behavior on layouts where raw isn't ASCII but + // the shifted key is. + if !raw.is_some_and(is_ascii) { + // Haven't seen a latin keysym yet; try to find. + for layout in xkb.layouts().filter(|l| *l != layout) { + if let Some(raw) = raw_sym(layout) { + if is_ascii(raw) { + raw_ascii = Some(raw); + break; + } + } + } + } + + (modified, raw, raw_ascii) + }; + + // Avoid unnecessary lookups if raw is the same as modified. + let raw = raw.filter(|k| Some(*k) != modified); + + // We don't differentiate between raw and raw_ascii when it comes to bind handling. + let raw = match (raw, raw_ascii) { + (None, None) => &[][..], + (None, Some(b)) => &[b][..], + (Some(a), None) => &[a][..], + (Some(a), Some(b)) => &[a, b][..], + }; + // After updating XKB state from accessibility-grabbed keys, return right away and // don't handle them. #[cfg(feature = "dbus")] @@ -4749,11 +4815,11 @@ fn should_intercept_key<'a>( suppressed_keys: &mut HashSet, pending_release_binds: &mut HashMap, cooldown_timers: &HashMap, - bindings: impl IntoIterator, + bindings: impl Iterator + Clone, mod_key: ModKey, key_code: Keycode, modified: Option, - raw: Option, + raw: &[Keysym], pressed: bool, mods: Modifiers, disable_power_key_handling: bool, @@ -4819,11 +4885,11 @@ fn should_intercept_key<'a>( } } -fn find_bind<'a, I: IntoIterator>( +fn find_bind<'a, I: Iterator + Clone>( bindings: I, mod_key: ModKey, modified: Option, - raw: Option, + raw: &[Keysym], mods: Modifiers, disable_power_key_handling: bool, ) -> Option { @@ -4867,9 +4933,8 @@ fn find_bind<'a, I: IntoIterator>( // A modifier key can trigger binds bound as the compositor mod key (`Mod`), by its own keysym // (like `Alt_L`), or as the modifier itself (like `Alt`). Return the first bind that matches. - let find = |bindings: I, key: Keysym| { + let find = |mut bindings: I, key: Keysym| { bindings - .into_iter() .find(|bind| { let bind_trigger = bind.key.trigger; // Check that the bind modifiers match the held modifiers. @@ -4918,8 +4983,8 @@ fn find_bind<'a, I: IntoIterator>( .cloned() }; - let key = raw?; - find(bindings, key) + let mut keys = modified.into_iter().chain(raw.iter().copied()); + keys.find_map(|key| find(bindings.clone(), key)) } fn trigger_matches_keysym(trigger: Trigger, key: Keysym, mod_key: ModKey) -> bool { @@ -5639,7 +5704,7 @@ fn make_binds_iter<'a>( screenshot_ui: &'a ScreenshotUi, mru: &'a mut WindowMruUi, mods: Modifiers, -) -> impl Iterator { +) -> impl Iterator + Clone { let general_binds = config.binds.0.iter(); // Figure out the binds to use depending on whether the MRU is enabled and/or open. @@ -5743,11 +5808,11 @@ mod tests { &mut state.suppressed_keys, &mut state.pending_release_binds, &state.cooldown_timers, - &bindings.0, + bindings.0.iter(), ModKey::Super, CLOSE_KEY_CODE, Some(CLOSE_KEYSYM), - Some(CLOSE_KEYSYM), + &[], pressed, mods, state.disable_power_key_handling, diff --git a/src/tests/binds.rs b/src/tests/binds.rs index 4f1e452dfe..0fe5d7f7f7 100644 --- a/src/tests/binds.rs +++ b/src/tests/binds.rs @@ -586,9 +586,8 @@ fn layouts() { " ); - // In ru, / is on the \ / key (so, Shift + \). So, arguably, it would make sense for Shift + / - // to trigger it, but it currently doesn't (niri requires an unshifted trigger key, despite - // working fine with capital case alphabetic keys). + // In ru, / is on the \ / key (so, Shift + \). So, Shift + \ inputs / and triggers + // the Shift + / bind. assert_snapshot!( run(c, "+LALT -LALT +LFSH +BKSL -BKSL -LFSH"), @" @@ -601,17 +600,16 @@ fn layouts() { surface key pressed: 42 surface modifiers: depressed=1, latched=0, locked=0, group=1 +BKSL 51 XK_slash - surface key pressed: 43 + niri test-action -BKSL 51 XK_slash - surface key released: 43 -LFSH 50 XK_Shift_L surface key released: 42 surface modifiers: depressed=0, latched=0, locked=0, group=1 " ); - // Я should trigger the Cyrillic_ya bind, but it currently doesn't. (Nobody binds keys like this - // in Russia, but people do in other countries with their non-ASCII characters.) + // Я should trigger the Cyrillic_ya bind. (Nobody binds keys like this in Russia, + // but people do in other countries with their non-ASCII characters.) assert_snapshot!( run(c, "+LALT -LALT +LatZ -LatZ"), @" @@ -621,9 +619,8 @@ fn layouts() { -LALT 64 XK_ISO_Next_Group surface key released: 56 +AB01 52 XK_Cyrillic_ya - surface key pressed: 44 + niri test-action -AB01 52 XK_Cyrillic_ya - surface key released: 44 " ); @@ -641,9 +638,8 @@ fn layouts() { surface key pressed: 42 surface modifiers: depressed=1, latched=0, locked=0, group=1 +AB02 53 XK_Cyrillic_CHE - surface key pressed: 45 + niri test-action -AB02 53 XK_Cyrillic_CHE - surface key released: 45 -LFSH 50 XK_Shift_L surface key released: 42 surface modifiers: depressed=0, latched=0, locked=0, group=1 @@ -661,9 +657,8 @@ fn layouts() { surface key pressed: 42 surface modifiers: depressed=1, latched=0, locked=0, group=1 +AB03 54 XK_Cyrillic_ES - surface key pressed: 46 + niri test-action -AB03 54 XK_Cyrillic_ES - surface key released: 46 -LFSH 50 XK_Shift_L surface key released: 42 surface modifiers: depressed=0, latched=0, locked=0, group=1 @@ -831,7 +826,7 @@ fn layouts() { -AC10 47 XK_colon "); - // ASCII fallback should work on dead keys too (currently doesn't). + // ASCII fallback should work on dead keys too. // // Greek has a dead key on ;. let c = r#" @@ -850,14 +845,13 @@ fn layouts() { assert_snapshot!(run(c, "+AC10 -AC10"), @" +AC10 47 XK_dead_acute - surface key pressed: 39 + niri test-action -AC10 47 XK_dead_acute - surface key released: 39 "); // We require the modifiers to match the bind for it to trigger. // So Ctrl-? cannot be triggered on us-qwerty since entering ? necessarily requires a Shift. - // If you spell out Shift like Super-Shift-?, it should be triggerable (but currently isn't). + // If you spell out Shift like Super-Shift-?, it should be triggerable. let c = r#" input { keyboard { @@ -898,9 +892,8 @@ fn layouts() { surface key pressed: 42 surface modifiers: depressed=65, latched=0, locked=0, group=0 +AB10 61 XK_question - surface key pressed: 53 + niri test-action -AB10 61 XK_question - surface key released: 53 "); // The BÉPO example from the docs. The key should be bindable by both « and < trigger. @@ -940,7 +933,7 @@ fn layouts() { surface key pressed: 100 surface modifiers: depressed=132, latched=0, locked=0, group=0 +AE02 11 XK_less - surface key pressed: 3 + niri test-action "); } From b22eb842ca9506d4e2ff997b116031cdb3f650f8 Mon Sep 17 00:00:00 2001 From: Carlos Ferrabone <152314+broli@users.noreply.github.com> Date: Fri, 23 Oct 2026 20:46:00 -0300 Subject: [PATCH 30/40] backend/winit: Release held keys on lost focus --- src/backend/winit.rs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/backend/winit.rs b/src/backend/winit.rs index 624c5870ee..d2610961fb 100644 --- a/src/backend/winit.rs +++ b/src/backend/winit.rs @@ -13,6 +13,7 @@ use smithay::backend::renderer::damage::OutputDamageTracker; use smithay::backend::renderer::gles::GlesRenderer; use smithay::backend::renderer::{DebugFlags, ImportDma, ImportEgl, Renderer}; use smithay::backend::winit::{self, WinitEvent, WinitGraphicsBackend}; +use smithay::input::keyboard::KeyboardSource; use smithay::output::{Mode, Output, PhysicalProperties, Subpixel}; use smithay::reexports::calloop::LoopHandle; use smithay::reexports::wayland_protocols::wp::presentation_time::server::wp_presentation_feedback; @@ -147,7 +148,15 @@ impl Winit { state.niri.output_resized(&output); } WinitEvent::Input(event) => state.process_input_event(event), - WinitEvent::Focus(_) => (), + WinitEvent::Focus(false) => { + // Lost focus. Reset key pressed (like alt or windows key) + state.release_held_inputs(); + + if let Some(keyboard) = state.niri.seat.get_keyboard() { + keyboard.release_source(state, KeyboardSource::MAIN); + } + } + WinitEvent::Focus(true) => (), WinitEvent::Redraw => state.niri.queue_redraw(&state.backend.winit().output), WinitEvent::CloseRequested => state.niri.stop_signal.stop(), }) From 803462c709c3f69747307f8e47884cfced37f61a Mon Sep 17 00:00:00 2001 From: Carlos Ferrabone <152314+broli@users.noreply.github.com> Date: Fri, 30 Oct 2026 14:50:42 -0300 Subject: [PATCH 31/40] backend/winit: Hide host cursor in nested sessions by default Add a nested-show-host-cursor option in debug {} to restore showing the host cursor if desired. --- docs/wiki/Configuration:-Debug-Options.md | 13 +++++++++++++ niri-config/src/debug.rs | 4 ++++ niri-config/src/lib.rs | 1 + src/backend/mod.rs | 7 +++++++ src/backend/winit.rs | 9 +++++++++ src/niri.rs | 5 +++++ 6 files changed, 39 insertions(+) diff --git a/docs/wiki/Configuration:-Debug-Options.md b/docs/wiki/Configuration:-Debug-Options.md index ae546b536d..da01c89ce6 100644 --- a/docs/wiki/Configuration:-Debug-Options.md +++ b/docs/wiki/Configuration:-Debug-Options.md @@ -35,6 +35,7 @@ debug { skip-cursor-only-updates-during-vrr deactivate-unfocused-windows disable-10bit-output + nested-show-host-cursor } binds { @@ -399,3 +400,15 @@ binds { Mod+Shift+Ctrl+D { debug-toggle-damage; } } ``` + +### `nested-show-host-cursor` + +Since: next release + +Shows the host cursor when running niri as a nested window, on top of the nested niri cursor. + +```kdl +debug { + nested-show-host-cursor +} +``` diff --git a/niri-config/src/debug.rs b/niri-config/src/debug.rs index 32de7f5310..d63e5ccd57 100644 --- a/niri-config/src/debug.rs +++ b/niri-config/src/debug.rs @@ -26,6 +26,7 @@ pub struct Debug { pub deactivate_unfocused_windows: bool, pub skip_cursor_only_updates_during_vrr: bool, pub disable_10bit_output: bool, + pub nested_show_host_cursor: bool, } #[derive(knuffel::Decode, Debug, Default, PartialEq)] @@ -74,6 +75,8 @@ pub struct DebugPart { pub skip_cursor_only_updates_during_vrr: Option, #[knuffel(child)] pub disable_10bit_output: Option, + #[knuffel(child)] + pub nested_show_host_cursor: Option, } impl MergeWith for Debug { @@ -99,6 +102,7 @@ impl MergeWith for Debug { deactivate_unfocused_windows, skip_cursor_only_updates_during_vrr, disable_10bit_output, + nested_show_host_cursor, ); merge_clone_opt!((self, part), preview_render, render_drm_device); diff --git a/niri-config/src/lib.rs b/niri-config/src/lib.rs index 378da87722..0428c88229 100644 --- a/niri-config/src/lib.rs +++ b/niri-config/src/lib.rs @@ -2825,6 +2825,7 @@ mod tests { deactivate_unfocused_windows: false, skip_cursor_only_updates_during_vrr: false, disable_10bit_output: false, + nested_show_host_cursor: false, }, workspaces: [ Workspace { diff --git a/src/backend/mod.rs b/src/backend/mod.rs index 1a307daca5..815aad6b7d 100644 --- a/src/backend/mod.rs +++ b/src/backend/mod.rs @@ -213,6 +213,13 @@ impl Backend { } } + pub fn set_host_cursor_visible(&mut self, visible: bool) { + match self { + Backend::Winit(winit) => winit.set_cursor_visible(visible), + Backend::Tty(_) | Backend::Headless(_) => (), + } + } + pub fn tty_checked(&mut self) -> Option<&mut Tty> { if let Self::Tty(v) = self { Some(v) diff --git a/src/backend/winit.rs b/src/backend/winit.rs index d2610961fb..4e6127c045 100644 --- a/src/backend/winit.rs +++ b/src/backend/winit.rs @@ -57,6 +57,11 @@ impl Winit { )); let (backend, winit) = winit::init_from_attributes(builder)?; + // unless debug option is set, we dont show cursor in nested mode + if !config.borrow().debug.nested_show_host_cursor { + backend.window().set_cursor_visible(false); + } + let output = Output::new( "winit".to_string(), PhysicalProperties { @@ -411,4 +416,8 @@ impl Winit { pub fn ipc_outputs(&self) -> Arc> { self.ipc_outputs.clone() } + + pub fn set_cursor_visible(&self, visible: bool) { + self.backend.window().set_cursor_visible(visible); + } } diff --git a/src/niri.rs b/src/niri.rs index e5d7ba9ab3..de158debaa 100644 --- a/src/niri.rs +++ b/src/niri.rs @@ -1754,6 +1754,11 @@ impl State { self.niri.cursor_texture_cache.clear(); } + if config.debug.nested_show_host_cursor != old_config.debug.nested_show_host_cursor { + self.backend + .set_host_cursor_visible(config.debug.nested_show_host_cursor); + } + // We need &mut self to reload the xkb config, so just store it here. if config.input.keyboard.xkb != old_config.input.keyboard.xkb { reload_xkb = true; From 30fae8d6c7dfdb3249ecc78b18472598efac58f1 Mon Sep 17 00:00:00 2001 From: HigherOrderLogic <73709188+HigherOrderLogic@users.noreply.github.com> Date: Tue, 20 Oct 2026 01:17:02 +1100 Subject: [PATCH 32/40] config: make expand home path util generic over span type --- niri-config/src/utils.rs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/niri-config/src/utils.rs b/niri-config/src/utils.rs index 3da335cd51..8ed5499f3f 100644 --- a/niri-config/src/utils.rs +++ b/niri-config/src/utils.rs @@ -2,7 +2,6 @@ use std::env::home_dir; use std::path::PathBuf; use std::str::FromStr; -use knuffel::ast::SpannedNode; use knuffel::errors::DecodeError; use miette::miette; use regex::Regex; @@ -211,14 +210,14 @@ pub fn parse_arg_node( +pub fn expand_home_path( path: PathBuf, - spanned: &SpannedNode, + spanned: &knuffel::span::Spanned, ctx: &mut knuffel::decode::Context, ) -> Option { if let Ok(rest) = path.strip_prefix("~") { let Some(home) = home_dir() else { - ctx.emit_error(DecodeError::missing( + ctx.emit_error(DecodeError::conversion( spanned, format!("error retrieving home directory to expand {:?}", path), )); From a8cb0a39484178ff510ba56d74d26669d2c1f99b Mon Sep 17 00:00:00 2001 From: HigherOrderLogic <73709188+HigherOrderLogic@users.noreply.github.com> Date: Tue, 20 Oct 2026 16:32:11 +1100 Subject: [PATCH 33/40] config: return result when expand path instead of option --- niri-config/src/animations.rs | 8 ++++++-- niri-config/src/lib.rs | 8 ++++++-- niri-config/src/utils.rs | 9 ++++----- 3 files changed, 16 insertions(+), 9 deletions(-) diff --git a/niri-config/src/animations.rs b/niri-config/src/animations.rs index d50f44955c..d54f37979f 100644 --- a/niri-config/src/animations.rs +++ b/niri-config/src/animations.rs @@ -165,8 +165,12 @@ fn parse_custom_shader_path( for (name, val) in &spanned.properties { if &***name == "path" { let path_val = knuffel::traits::DecodeScalar::decode(val, ctx)?; - let Some(path) = expand_home_path(path_val, spanned, ctx) else { - continue; + let path = match expand_home_path(path_val, spanned, ctx) { + Ok(p) => p, + Err(e) => { + ctx.emit_error(e); + continue; + } }; let includes = ctx.get::>>().unwrap(); diff --git a/niri-config/src/lib.rs b/niri-config/src/lib.rs index 0428c88229..7665ad53e8 100644 --- a/niri-config/src/lib.rs +++ b/niri-config/src/lib.rs @@ -349,8 +349,12 @@ where let decoded_path = knuffel::traits::DecodeScalar::decode(path_val, ctx)?; // Expand ~ into the home dir - let Some(path) = expand_home_path(decoded_path, node, ctx) else { - continue; + let path = match expand_home_path(decoded_path, node, ctx) { + Ok(p) => p, + Err(e) => { + ctx.emit_error(e); + continue; + } }; let recursion = ctx.get::().unwrap().0 + 1; diff --git a/niri-config/src/utils.rs b/niri-config/src/utils.rs index 8ed5499f3f..c6e982556d 100644 --- a/niri-config/src/utils.rs +++ b/niri-config/src/utils.rs @@ -214,18 +214,17 @@ pub fn expand_home_path( path: PathBuf, spanned: &knuffel::span::Spanned, ctx: &mut knuffel::decode::Context, -) -> Option { +) -> Result> { if let Ok(rest) = path.strip_prefix("~") { let Some(home) = home_dir() else { - ctx.emit_error(DecodeError::conversion( + return Err(DecodeError::conversion( spanned, format!("error retrieving home directory to expand {:?}", path), )); - return None; }; - home.join(rest).into() + Ok(home.join(rest)) } else { let base = ctx.get::().unwrap(); - base.0.join(path).into() + Ok(base.0.join(path)) } } From 2ffab3f322133463460297569f5ac6974ad07c35 Mon Sep 17 00:00:00 2001 From: HigherOrderLogic <73709188+HigherOrderLogic@users.noreply.github.com> Date: Tue, 20 Oct 2026 16:38:49 +1100 Subject: [PATCH 34/40] config: watch xkb config file for change --- niri-config/src/input.rs | 58 +++++++++++++++++++++++++++++++++++++--- src/niri.rs | 22 ++++++--------- 2 files changed, 63 insertions(+), 17 deletions(-) diff --git a/niri-config/src/input.rs b/niri-config/src/input.rs index 0ac0821ff9..9d2c0014b3 100644 --- a/niri-config/src/input.rs +++ b/niri-config/src/input.rs @@ -1,12 +1,18 @@ +use std::cell::RefCell; +use std::fs; +use std::path::PathBuf; +use std::rc::Rc; use std::str::FromStr; +use knuffel::errors::DecodeError; +use knuffel::DecodeScalar; use miette::miette; use smithay::input::keyboard::{keysyms, Keysym, XkbConfig}; use smithay::reexports::input; use crate::binds::Modifiers; -use crate::utils::{Flag, MergeWith, Percent}; -use crate::FloatOrInt; +use crate::utils::{expand_home_path, Flag, MergeWith, Percent}; +use crate::{FloatOrInt, Includes}; #[derive(Debug, Default, PartialEq)] pub struct Input { @@ -140,7 +146,7 @@ pub struct Xkb { #[knuffel(child, unwrap(argument))] pub options: Option, #[knuffel(child, unwrap(argument))] - pub file: Option, + pub file: Option, } impl Xkb { @@ -155,6 +161,9 @@ impl Xkb { } } +#[derive(PartialEq, Eq, Clone, Debug)] +pub struct XkbFile(pub Option); + #[derive(knuffel::DecodeScalar, Debug, Default, Clone, Copy, PartialEq, Eq)] pub enum TrackLayout { /// The layout change is global. @@ -529,6 +538,49 @@ impl FromStr for TapButtonMap { } } +impl DecodeScalar for XkbFile +where + S: knuffel::traits::ErrorSpan, +{ + fn type_check( + type_name: &Option>, + ctx: &mut knuffel::decode::Context, + ) { + if let Some(type_name) = &type_name { + ctx.emit_error(DecodeError::unexpected( + type_name, + "type name", + "no type name expected for this node", + )); + } + } + + fn raw_decode( + value: &knuffel::span::Spanned, + ctx: &mut knuffel::decode::Context, + ) -> Result> { + let path = PathBuf::raw_decode(value, ctx)?; + match expand_home_path(path, value, ctx) { + Ok(p) => { + let includes = ctx.get::>>().unwrap(); + includes.borrow_mut().0.push(p.to_path_buf()); + + match fs::read_to_string(&p) { + Ok(c) => Ok(Self(Some(c))), + Err(e) => { + warn!("failed to read xkb file from {p:?}: {e}"); + Ok(Self(None)) + } + } + } + Err(e) => { + warn!("failed to expand xkb file path: {:?}", e); + Ok(Self(None)) + } + } + } +} + #[cfg(test)] mod tests { use insta::assert_debug_snapshot; diff --git a/src/niri.rs b/src/niri.rs index de158debaa..b1e318e52a 100644 --- a/src/niri.rs +++ b/src/niri.rs @@ -14,6 +14,7 @@ use _server_decoration::server::org_kde_kwin_server_decoration_manager::Mode as use anyhow::{bail, ensure, Context}; use calloop::futures::Scheduler; use niri_config::debug::PreviewRender; +use niri_config::input::XkbFile; use niri_config::output::MaxBpc; use niri_config::window_rule::VrrRule; use niri_config::{ @@ -189,8 +190,8 @@ use crate::utils::vblank_throttle::VBlankThrottle; use crate::utils::watcher::Watcher; use crate::utils::xwayland::satellite::Satellite; use crate::utils::{ - center, center_f64, expand_home, get_monotonic_time, ipc_transform_to_smithay, is_mapped, - logical_output, make_screenshot_path, output_matches_name, output_size, panel_orientation, + center, center_f64, get_monotonic_time, ipc_transform_to_smithay, is_mapped, logical_output, + make_screenshot_path, output_matches_name, output_size, panel_orientation, send_scale_transform, winit_scale, write_png_rgba8, xwayland, }; use crate::window::mapped::MappedId; @@ -1535,14 +1536,7 @@ impl State { } /// Loads the xkb keymap from a file config setting. - fn set_xkb_file(&mut self, xkb_file: String) -> anyhow::Result<()> { - let xkb_file = PathBuf::from(xkb_file); - let xkb_file = expand_home(&xkb_file) - .context("failed to expand ~")? - .unwrap_or(xkb_file); - - let keymap = std::fs::read_to_string(xkb_file).context("failed to read xkb_file")?; - + fn set_xkb_file_content(&mut self, keymap: String) -> anyhow::Result<()> { let keyboard = self.niri.seat.get_keyboard().unwrap(); let restore = self.keyboard_state_to_restore(); @@ -1558,8 +1552,8 @@ impl State { fn load_xkb_file(&mut self) { let xkb_file = self.niri.config.borrow().input.keyboard.xkb.file.clone(); - if let Some(xkb_file) = xkb_file { - if let Err(err) = self.set_xkb_file(xkb_file) { + if let Some(XkbFile(Some(keymap))) = xkb_file { + if let Err(err) = self.set_xkb_file_content(keymap) { warn!("error loading xkb_file: {err:?}"); } } @@ -1666,8 +1660,8 @@ impl State { let mut xkb = self.niri.config.borrow().input.keyboard.xkb.clone(); let mut set_xkb_config = true; - if let Some(xkb_file) = xkb.file.take() { - if let Err(err) = self.set_xkb_file(xkb_file) { + if let Some(XkbFile(Some(keymap))) = xkb.file.take() { + if let Err(err) = self.set_xkb_file_content(keymap) { warn!("error loading xkb_file: {err:?}"); } else { set_xkb_config = false; From 417530fd7e698d5bec5c95e1659fc9b3ed6f6b94 Mon Sep 17 00:00:00 2001 From: Ivan Molodetskikh Date: Fri, 30 Oct 2026 10:19:57 +0300 Subject: [PATCH 35/40] input: Commit MRU on ForwardAndHandle --- src/input/mod.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/input/mod.rs b/src/input/mod.rs index 80d18e06d9..cb0d2a921a 100644 --- a/src/input/mod.rs +++ b/src/input/mod.rs @@ -721,7 +721,12 @@ impl State { return ShouldInterceptResult::InterceptAndHandle(bind); } } + } + if matches!( + res, + ShouldInterceptResult::Forward | ShouldInterceptResult::ForwardAndHandle(_) + ) { // Interaction with the active window, immediately update the active window's // focus timestamp without waiting for a possible pending MRU lock-in delay. this.niri.mru_apply_keyboard_commit(); From 7dd805501a2814ae033de5272c3c32f350cb7dc1 Mon Sep 17 00:00:00 2001 From: Ivan Molodetskikh Date: Sat, 31 Oct 2026 08:54:37 +0300 Subject: [PATCH 36/40] input: Handle hardcoded overview binds though make_binds_iter() For consistency, also for correct handling of pass-through release-only binds. --- src/input/mod.rs | 83 +++++++++++++++++++++--------------------------- 1 file changed, 37 insertions(+), 46 deletions(-) diff --git a/src/input/mod.rs b/src/input/mod.rs index cb0d2a921a..194b4d79c7 100644 --- a/src/input/mod.rs +++ b/src/input/mod.rs @@ -690,6 +690,7 @@ impl State { &config, &this.niri.screenshot_ui, &mut this.niri.window_mru_ui, + this.niri.keyboard_focus.is_overview(), modifiers, ); @@ -711,18 +712,6 @@ impl State { ) }; - if matches!(res, ShouldInterceptResult::Forward) { - // If we didn't find any bind, try other hardcoded keys. - if this.niri.keyboard_focus.is_overview() && pressed { - if let Some(bind) = - modified.and_then(|key| hardcoded_overview_bind(key, modifiers)) - { - this.niri.suppressed_keys.insert(key_code); - return ShouldInterceptResult::InterceptAndHandle(bind); - } - } - } - if matches!( res, ShouldInterceptResult::Forward | ShouldInterceptResult::ForwardAndHandle(_) @@ -3019,6 +3008,7 @@ impl State { &config, &self.niri.screenshot_ui, &mut self.niri.window_mru_ui, + self.niri.keyboard_focus.is_overview(), modifiers, ); @@ -5273,40 +5263,6 @@ fn bind_allowed_during_screenshot(bind: &Bind, pressed: bool) -> bool { } } -fn hardcoded_overview_bind(key: Keysym, mods: Modifiers) -> Option { - if !mods.is_empty() { - return None; - } - - let mut repeat = true; - let action = match key { - Keysym::Escape | Keysym::Return => { - repeat = false; - Action::ToggleOverview - } - Keysym::Left => Action::FocusColumnLeft, - Keysym::Right => Action::FocusColumnRight, - Keysym::Up => Action::FocusWindowOrWorkspaceUp, - Keysym::Down => Action::FocusWindowOrWorkspaceDown, - _ => { - return None; - } - }; - - Some(Bind { - key: Key { - trigger: Trigger::Keysym(key), - modifiers: Modifiers::empty(), - }, - action: BoundAction::Press(action), - repeat, - cooldown: None, - allow_when_locked: false, - allow_inhibiting: false, - hotkey_overlay_title: None, - }) -} - pub fn apply_libinput_settings( config: &niri_config::Input, device: &mut input::Device, @@ -5701,6 +5657,34 @@ fn grab_allows_hot_corner(grab: &(dyn PointerGrab + 'static)) -> bool { true } +fn hardcoded_overview_binds<'a>() -> impl Iterator + Clone { + const fn bind(key: Keysym, action: Action, repeat: bool) -> Bind { + Bind { + key: Key { + trigger: Trigger::Keysym(key), + modifiers: Modifiers::empty(), + }, + action: BoundAction::Press(action), + repeat, + cooldown: None, + allow_when_locked: false, + allow_inhibiting: false, + hotkey_overlay_title: None, + } + } + + const BINDS: &[Bind] = &[ + bind(Keysym::Escape, Action::ToggleOverview, false), + bind(Keysym::Return, Action::ToggleOverview, false), + bind(Keysym::Left, Action::FocusColumnLeft, true), + bind(Keysym::Right, Action::FocusColumnRight, true), + bind(Keysym::Up, Action::FocusWindowOrWorkspaceUp, true), + bind(Keysym::Down, Action::FocusWindowOrWorkspaceDown, true), + ]; + + BINDS.iter() +} + /// Returns an iterator over bindings. /// /// Includes dynamically populated bindings like the MRU UI. @@ -5708,6 +5692,7 @@ fn make_binds_iter<'a>( config: &'a Config, screenshot_ui: &'a ScreenshotUi, mru: &'a mut WindowMruUi, + overview_focused: bool, mods: Modifiers, ) -> impl Iterator + Clone { let general_binds = config.binds.0.iter(); @@ -5726,11 +5711,17 @@ fn make_binds_iter<'a>( // Screenshot UI binds already disable themselves when it's closed. let screenshot_binds = screenshot_ui.binds(); + let overview_binds = overview_focused + .then(hardcoded_overview_binds) + .into_iter() + .flatten(); + // The precedence is screenshot UI, then general, then MRU binds. screenshot_binds .chain(general_binds) .chain(mru_binds) .chain(mru_open_binds) + .chain(overview_binds) } #[cfg(test)] From 2ae55ffc2486268ace52e30d883bfe0dfe7a9ed1 Mon Sep 17 00:00:00 2001 From: Justin Spedding Date: Thu, 29 Oct 2026 09:53:36 -0400 Subject: [PATCH 37/40] input: Flatten if-else chain in should_intercept_key() --- src/input/mod.rs | 73 +++++++++++++++++++++--------------------------- 1 file changed, 32 insertions(+), 41 deletions(-) diff --git a/src/input/mod.rs b/src/input/mod.rs index 194b4d79c7..b16a9a0efb 100644 --- a/src/input/mod.rs +++ b/src/input/mod.rs @@ -4828,7 +4828,7 @@ fn should_intercept_key<'a>( return released_key_intercept_result(suppressed_keys, pending_release_binds, key_code); } - let final_bind = find_bind( + let bind = find_bind( bindings, mod_key, modified, @@ -4836,48 +4836,39 @@ fn should_intercept_key<'a>( mods, disable_power_key_handling, ); + // If we don't have a bind, forward the key. + let Some(bind) = bind else { + return ShouldInterceptResult::Forward; + }; - match final_bind { - Some(bind) => { - if is_inhibiting_shortcuts && bind.allow_inhibiting { - ShouldInterceptResult::Forward - } else if bind.has_press() && is_bind_on_cooldown(&bind, cooldown_timers) { - // If on cooldown, do not run the press action, record a release action, - // or forward the press. - suppressed_keys.insert(key_code); - ShouldInterceptResult::InterceptOnly - } else if modified.is_some_and(is_keysym_modifier) { - if should_record_release_bind(&bind, is_locked, is_in_screenshot, cooldown_timers) { - pending_release_binds.insert(key_code, bind.clone()); - } - if bind.has_press() { - suppressed_keys.insert(key_code); - // A bind with a press action takes the key press. Applications will still learn - // about the modifier state via `advertise_modifier_state()`. - ShouldInterceptResult::InterceptAndHandle(bind) - } else { - // This is to ensure that, if you have a modifier-triggered release bind like - // Ctrl+Alt, We still forward the Ctrl and Alt presses to the application - // to use for its own shortcuts. - ShouldInterceptResult::Forward - } - } else { - if should_record_release_bind(&bind, is_locked, is_in_screenshot, cooldown_timers) { - pending_release_binds.insert(key_code, bind.clone()); - } - suppressed_keys.insert(key_code); - if bind.has_press() { - ShouldInterceptResult::InterceptAndHandle(bind) - } else { - // Release-only bind: intercept the key press so that it doesn't reach the - // client, and remember to trigger the release action when the key is - // released. - ShouldInterceptResult::InterceptOnly - } - } - } - None => ShouldInterceptResult::Forward, + // When inhibiting shortcuts, forward the key. + if is_inhibiting_shortcuts && bind.allow_inhibiting { + return ShouldInterceptResult::Forward; } + + // By default we intercept the key which hides both the press and release from clients. + // Clients still learn about the modifier state via `advertise_modifier_state()`. + // + // Modifier-only release-only binds are not intercepted by default so that + // bindings like `Ctrl+Alt` do not break application shortcuts. + let forward = modified.is_some_and(is_keysym_modifier) && !bind.has_press(); + if !forward { + suppressed_keys.insert(key_code); + } + + if should_record_release_bind(&bind, is_locked, is_in_screenshot, cooldown_timers) { + pending_release_binds.insert(key_code, bind.clone()); + } + + // We need to check cooldown here matching the check in should_record_release_bind() above. + // Otherwise, a bind on cooldown would start repeat, which would then trigger its press action + // without recording the corresponding release action. + // + // Another (arguably more correct) way would be to ensure a pending release bind when running + // key repeat. If that's implemented, this condition can be simplified. + let run_press_action = bind.has_press() && !is_bind_on_cooldown(&bind, cooldown_timers); + + bind_intercept_result(run_press_action.then_some(bind), forward) } fn find_bind<'a, I: Iterator + Clone>( From a508e3b300d252a8daca3e077e26cfa6f286b190 Mon Sep 17 00:00:00 2001 From: Justin Spedding Date: Thu, 29 Oct 2026 10:11:43 -0400 Subject: [PATCH 38/40] input: Add pass-through property to binds --- docs/wiki/Configuration:-Key-Bindings.md | 13 +++++ niri-config/src/binds.rs | 22 ++++++++ niri-config/src/lib.rs | 49 +++++++++++++++++ niri-config/src/recent_windows.rs | 1 + src/input/mod.rs | 16 +++++- src/tests/binds.rs | 70 ++++++++++++++++++++++++ src/ui/mru.rs | 1 + src/ui/screenshot_ui.rs | 1 + 8 files changed, 172 insertions(+), 1 deletion(-) diff --git a/docs/wiki/Configuration:-Key-Bindings.md b/docs/wiki/Configuration:-Key-Bindings.md index f23c52db0d..caeb400e28 100644 --- a/docs/wiki/Configuration:-Key-Bindings.md +++ b/docs/wiki/Configuration:-Key-Bindings.md @@ -186,6 +186,19 @@ binds { } ``` +By default, bound key presses/releases are not passed to applications. +You can set `pass-through=true` if you want applications to see the bound key: + +```kdl +binds { + // Let applications see the Alt key presses/releases. + Alt pass-through=true { + press { spawn "notify-send" "Pressed"; } + release { spawn "notify-send" "Released"; } + } +} +``` + ### Scroll Bindings You can bind mouse wheel scroll ticks using the following syntax. diff --git a/niri-config/src/binds.rs b/niri-config/src/binds.rs index 7448c8f8f6..0de91cc0e6 100644 --- a/niri-config/src/binds.rs +++ b/niri-config/src/binds.rs @@ -24,6 +24,7 @@ pub struct Bind { pub key: Key, pub action: BoundAction, pub repeat: bool, + pub pass_through: Option, pub cooldown: Option, pub allow_when_locked: bool, pub allow_inhibiting: bool, @@ -971,6 +972,7 @@ where let mut repeat = None; let mut repeat_node = None; + let mut pass_through = None; let mut cooldown = None; let mut allow_when_locked = false; let mut allow_when_locked_node = None; @@ -983,6 +985,24 @@ where repeat = Some(knuffel::traits::DecodeScalar::decode(val, ctx)?); repeat_node = Some(name); } + "pass-through" => { + pass_through = Some(knuffel::traits::DecodeScalar::decode(val, ctx)?); + + // Supporting this for mouse binds would be a bit weird. Mouse binds trigger + // compositor gestures, so should pass-through also trigger the corresponding + // gesture, on top of the bind? If yes, then there are some conflicts, e.g. with + // toggle-overview and the Mod+LMB move grab or LMB press-to-close-overview. + // + // All in all, this needs design, and I'm not sure why you'd want pass-through + // mouse binds anyway, so, let's do keyboard only for now. + if !key.trigger.is_keyboard() { + ctx.emit_error(DecodeError::unexpected( + name, + "property", + "pass-through is only supported for keyboard binds", + )); + } + } "cooldown-ms" => { cooldown = Some(Duration::from_millis( knuffel::traits::DecodeScalar::decode(val, ctx)?, @@ -1015,6 +1035,7 @@ where key, action: BoundAction::Press(Action::Spawn(vec![])), repeat: true, + pass_through: None, cooldown: None, allow_when_locked: false, allow_inhibiting: true, @@ -1192,6 +1213,7 @@ where key, action, repeat, + pass_through, cooldown, allow_when_locked, allow_inhibiting, diff --git a/niri-config/src/lib.rs b/niri-config/src/lib.rs index 7665ad53e8..fad654b37b 100644 --- a/niri-config/src/lib.rs +++ b/niri-config/src/lib.rs @@ -730,6 +730,35 @@ mod tests { ); } + #[test] + fn parse_bind_pass_through() { + let config = do_parse( + r#" + binds { + Mod+A pass-through=true { close-window; } + Mod+B pass-through=false { close-window; } + Mod+C { close-window; } // Should be off by default + } + "#, + ); + + assert_eq!(config.binds.0[0].pass_through, Some(true)); + assert_eq!(config.binds.0[1].pass_through, Some(false)); + assert_eq!(config.binds.0[2].pass_through, None); + } + + #[test] + fn parse_bind_pass_through_on_pointer_binds() { + assert_parse_error( + r#" + binds { + Mod+MouseLeft pass-through=true { close-window; } + } + "#, + "pass-through is only supported for keyboard binds", + ); + } + /// Asserts that parsing fails with a diagnostic containing `message`. #[track_caller] fn assert_parse_error(text: &str, message: &str) { @@ -2460,6 +2489,7 @@ mod tests { ToggleKeyboardShortcutsInhibit, ), repeat: true, + pass_through: None, cooldown: None, allow_when_locked: false, allow_inhibiting: false, @@ -2482,6 +2512,7 @@ mod tests { ToggleKeyboardShortcutsInhibit, ), repeat: true, + pass_through: None, cooldown: None, allow_when_locked: false, allow_inhibiting: false, @@ -2504,6 +2535,7 @@ mod tests { ), ), repeat: true, + pass_through: None, cooldown: None, allow_when_locked: true, allow_inhibiting: true, @@ -2522,6 +2554,7 @@ mod tests { CloseWindow, ), repeat: true, + pass_through: None, cooldown: None, allow_when_locked: false, allow_inhibiting: true, @@ -2542,6 +2575,7 @@ mod tests { FocusMonitorLeft, ), repeat: true, + pass_through: None, cooldown: None, allow_when_locked: false, allow_inhibiting: true, @@ -2562,6 +2596,7 @@ mod tests { ), ), repeat: true, + pass_through: None, cooldown: None, allow_when_locked: false, allow_inhibiting: true, @@ -2580,6 +2615,7 @@ mod tests { MoveWindowToMonitorRight, ), repeat: true, + pass_through: None, cooldown: None, allow_when_locked: false, allow_inhibiting: true, @@ -2600,6 +2636,7 @@ mod tests { ), ), repeat: true, + pass_through: None, cooldown: None, allow_when_locked: false, allow_inhibiting: true, @@ -2620,6 +2657,7 @@ mod tests { ), ), repeat: true, + pass_through: None, cooldown: None, allow_when_locked: false, allow_inhibiting: true, @@ -2638,6 +2676,7 @@ mod tests { ConsumeWindowIntoColumn, ), repeat: true, + pass_through: None, cooldown: None, allow_when_locked: false, allow_inhibiting: true, @@ -2660,6 +2699,7 @@ mod tests { ), ), repeat: true, + pass_through: None, cooldown: None, allow_when_locked: false, allow_inhibiting: true, @@ -2682,6 +2722,7 @@ mod tests { ), ), repeat: true, + pass_through: None, cooldown: None, allow_when_locked: false, allow_inhibiting: true, @@ -2702,6 +2743,7 @@ mod tests { ), ), repeat: true, + pass_through: None, cooldown: None, allow_when_locked: false, allow_inhibiting: false, @@ -2718,6 +2760,7 @@ mod tests { FocusWorkspaceDown, ), repeat: true, + pass_through: None, cooldown: Some( 150ms, ), @@ -2740,6 +2783,7 @@ mod tests { ), ), repeat: true, + pass_through: None, cooldown: None, allow_when_locked: true, allow_inhibiting: true, @@ -2756,6 +2800,7 @@ mod tests { ToggleOverview, ), repeat: false, + pass_through: None, cooldown: None, allow_when_locked: false, allow_inhibiting: true, @@ -2772,6 +2817,7 @@ mod tests { ToggleWindowFloating, ), repeat: false, + pass_through: None, cooldown: None, allow_when_locked: false, allow_inhibiting: true, @@ -2901,6 +2947,7 @@ mod tests { }, ), repeat: true, + pass_through: None, cooldown: None, allow_when_locked: false, allow_inhibiting: true, @@ -2925,6 +2972,7 @@ mod tests { }, ), repeat: true, + pass_through: None, cooldown: None, allow_when_locked: false, allow_inhibiting: true, @@ -2951,6 +2999,7 @@ mod tests { }, ), repeat: true, + pass_through: None, cooldown: None, allow_when_locked: false, allow_inhibiting: true, diff --git a/niri-config/src/recent_windows.rs b/niri-config/src/recent_windows.rs index 958a325fe2..5db0bae16f 100644 --- a/niri-config/src/recent_windows.rs +++ b/niri-config/src/recent_windows.rs @@ -148,6 +148,7 @@ impl From for Bind { key: x.key, action: BoundAction::Press(Action::from(x.action)), repeat: true, + pass_through: None, cooldown: None, allow_when_locked: false, allow_inhibiting: x.allow_inhibiting, diff --git a/src/input/mod.rs b/src/input/mod.rs index b16a9a0efb..39372d2cc8 100644 --- a/src/input/mod.rs +++ b/src/input/mod.rs @@ -3428,6 +3428,7 @@ impl State { }, action: BoundAction::Press(Action::FocusColumnLeftUnderMouse), repeat: true, + pass_through: None, cooldown: None, allow_when_locked: false, allow_inhibiting: false, @@ -3440,6 +3441,7 @@ impl State { }, action: BoundAction::Press(Action::FocusColumnRightUnderMouse), repeat: true, + pass_through: None, cooldown: None, allow_when_locked: false, allow_inhibiting: false, @@ -3488,6 +3490,7 @@ impl State { }, action: BoundAction::Press(Action::FocusWorkspaceUpUnderMouse), repeat: true, + pass_through: None, cooldown: Some(Duration::from_millis(50)), allow_when_locked: false, allow_inhibiting: false, @@ -3500,6 +3503,7 @@ impl State { }, action: BoundAction::Press(Action::FocusWorkspaceDownUnderMouse), repeat: true, + pass_through: None, cooldown: Some(Duration::from_millis(50)), allow_when_locked: false, allow_inhibiting: false, @@ -3514,6 +3518,7 @@ impl State { }, action: BoundAction::Press(Action::FocusColumnLeftUnderMouse), repeat: true, + pass_through: None, cooldown: Some(Duration::from_millis(50)), allow_when_locked: false, allow_inhibiting: false, @@ -3526,6 +3531,7 @@ impl State { }, action: BoundAction::Press(Action::FocusColumnRightUnderMouse), repeat: true, + pass_through: None, cooldown: Some(Duration::from_millis(50)), allow_when_locked: false, allow_inhibiting: false, @@ -4803,6 +4809,9 @@ fn is_bind_on_cooldown(bind: &Bind, cooldown_timers: &HashMap( // // Modifier-only release-only binds are not intercepted by default so that // bindings like `Ctrl+Alt` do not break application shortcuts. - let forward = modified.is_some_and(is_keysym_modifier) && !bind.has_press(); + let forward = bind + .pass_through + .unwrap_or(modified.is_some_and(is_keysym_modifier) && !bind.has_press()); if !forward { suppressed_keys.insert(key_code); } @@ -4905,6 +4916,7 @@ fn find_bind<'a, I: Iterator + Clone>( // Repeating VT switches and power off doesn't make sense (and it actually happens on // FreeBSD where it doesn't get released and ends up broken). repeat: false, + pass_through: None, cooldown: None, allow_when_locked: false, // In a worst-case scenario, the user has no way to unlock the compositor and a @@ -5657,6 +5669,7 @@ fn hardcoded_overview_binds<'a>() -> impl Iterator + Clone { }, action: BoundAction::Press(action), repeat, + pass_through: None, cooldown: None, allow_when_locked: false, allow_inhibiting: false, @@ -5749,6 +5762,7 @@ mod tests { key: Key { trigger, modifiers }, action, repeat: false, + pass_through: None, cooldown: None, allow_when_locked: false, allow_inhibiting: true, diff --git a/src/tests/binds.rs b/src/tests/binds.rs index 0fe5d7f7f7..9c6c692c18 100644 --- a/src/tests/binds.rs +++ b/src/tests/binds.rs @@ -1418,6 +1418,76 @@ fn generic_modifier_press_release() { "); } +#[test] +fn pass_through() { + // A normal press-only bind runs its action and forwards the key. + let c = " + binds { + Mod+Q pass-through=true { close-window; } + } + "; + + assert_snapshot!(run(c, "+LWIN +LatQ -LatQ -LWIN"), @" + +LWIN 133 XK_Super_L + surface key pressed: 125 + surface modifiers: depressed=64, latched=0, locked=0, group=0 + +AD01 24 XK_q + niri test-action + surface key pressed: 16 + -AD01 24 XK_q + surface key released: 16 + -LWIN 133 XK_Super_L + surface key released: 125 + surface modifiers: depressed=0, latched=0, locked=0, group=0 + "); + + // A modifier-only press-and-release bind runs both actions and forwards the key. + let c = " + binds { + Mod pass-through=true { + press { close-window; } + release { center-column; } + } + } + "; + + assert_snapshot!(run(c, "+LWIN -LWIN"), @" + +LWIN 133 XK_Super_L + niri test-action + surface key pressed: 125 + surface modifiers: depressed=64, latched=0, locked=0, group=0 + -LWIN 133 XK_Super_L + niri test-action + surface key released: 125 + surface modifiers: depressed=0, latched=0, locked=0, group=0 + "); + + // A bind on cooldown does not run its action, but still forwards the key. + let c = " + binds { + Mod+Q pass-through=true cooldown-ms=1000 { close-window; } + } + "; + + assert_snapshot!(run(c, "+LWIN +LatQ -LatQ +LatQ -LatQ -LWIN"), @" + +LWIN 133 XK_Super_L + surface key pressed: 125 + surface modifiers: depressed=64, latched=0, locked=0, group=0 + +AD01 24 XK_q + niri test-action + surface key pressed: 16 + -AD01 24 XK_q + surface key released: 16 + +AD01 24 XK_q + surface key pressed: 16 + -AD01 24 XK_q + surface key released: 16 + -LWIN 133 XK_Super_L + surface key released: 125 + surface modifiers: depressed=0, latched=0, locked=0, group=0 + "); +} + #[test] fn mod_key_press_and_release_bind() { let c = " diff --git a/src/ui/mru.rs b/src/ui/mru.rs index 7c1194d049..5ce7ed0855 100644 --- a/src/ui/mru.rs +++ b/src/ui/mru.rs @@ -1836,6 +1836,7 @@ fn make_preset_opened_binds() -> Vec { }, action: BoundAction::Press(action), repeat: true, + pass_through: None, cooldown: None, allow_when_locked: false, allow_inhibiting: false, diff --git a/src/ui/screenshot_ui.rs b/src/ui/screenshot_ui.rs index 75dada3cdf..da0d9922b3 100644 --- a/src/ui/screenshot_ui.rs +++ b/src/ui/screenshot_ui.rs @@ -770,6 +770,7 @@ impl ScreenshotUi { key, action: BoundAction::Press(action), repeat: false, // Not useful for these actions. + pass_through: None, cooldown: None, allow_when_locked: true, allow_inhibiting: false, From 157e11b4d36907374e11a68e2eccae46e9e3af2b Mon Sep 17 00:00:00 2001 From: Ivan Molodetskikh Date: Sat, 31 Oct 2026 09:29:14 +0300 Subject: [PATCH 39/40] config/binds: Extract Trigger::is_scroll() --- niri-config/src/binds.rs | 28 +++++++++++++++------------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/niri-config/src/binds.rs b/niri-config/src/binds.rs index 0de91cc0e6..548dfbf23f 100644 --- a/niri-config/src/binds.rs +++ b/niri-config/src/binds.rs @@ -149,6 +149,20 @@ impl Trigger { Trigger::Keysym(_) | Trigger::CompositorMod | Trigger::Modifier(_) ) } + + pub fn is_scroll(self) -> bool { + matches!( + self, + Trigger::WheelScrollDown + | Trigger::WheelScrollUp + | Trigger::WheelScrollLeft + | Trigger::WheelScrollRight + | Trigger::TouchpadScrollDown + | Trigger::TouchpadScrollUp + | Trigger::TouchpadScrollLeft + | Trigger::TouchpadScrollRight + ) + } } impl Key { @@ -1132,19 +1146,7 @@ where let press_action = press_action.or(direct_action); - if release_action.is_some() - && matches!( - key.trigger, - Trigger::WheelScrollDown - | Trigger::WheelScrollUp - | Trigger::WheelScrollLeft - | Trigger::WheelScrollRight - | Trigger::TouchpadScrollDown - | Trigger::TouchpadScrollUp - | Trigger::TouchpadScrollLeft - | Trigger::TouchpadScrollRight - ) - { + if release_action.is_some() && key.trigger.is_scroll() { ctx.emit_error(DecodeError::unexpected( node, "bind", From 8e7b5745407294ed37b4b4d8ff4cf4574d45686b Mon Sep 17 00:00:00 2001 From: Ivan Molodetskikh Date: Sat, 31 Oct 2026 09:06:12 +0300 Subject: [PATCH 40/40] input: Pass through all keyboard release-only binds by default Not just modifier-triggered. For consistency with other compositors. --- docs/wiki/Configuration:-Key-Bindings.md | 13 +++++++++++++ src/input/mod.rs | 24 +++++++++++------------- src/tests/binds.rs | 9 +++++++++ 3 files changed, 33 insertions(+), 13 deletions(-) diff --git a/docs/wiki/Configuration:-Key-Bindings.md b/docs/wiki/Configuration:-Key-Bindings.md index caeb400e28..a954fd4b42 100644 --- a/docs/wiki/Configuration:-Key-Bindings.md +++ b/docs/wiki/Configuration:-Key-Bindings.md @@ -199,6 +199,19 @@ binds { } ``` +Keyboard release-only bindings are passed through by default though. +You can set `pass-through=false` on those if you don't like that: + +```kdl +binds { + // Do not pass Mod press/release event to applications. + // (They will still see the modifiers event.) + Mod pass-through=false { + release { toggle-overview; } + } +} +``` + ### Scroll Bindings You can bind mouse wheel scroll ticks using the following syntax. diff --git a/src/input/mod.rs b/src/input/mod.rs index 39372d2cc8..cc7bb81c9b 100644 --- a/src/input/mod.rs +++ b/src/input/mod.rs @@ -7,8 +7,8 @@ use calloop::timer::{TimeoutAction, Timer}; use calloop::RegistrationToken; use input::event::gesture::GestureEventCoordinates as _; use niri_config::{ - is_keysym_modifier, Action, Bind, Binds, BoundAction, Config, Key, ModKey, Modifiers, - MruDirection, SwitchBinds, Trigger, + Action, Bind, Binds, BoundAction, Config, Key, ModKey, Modifiers, MruDirection, SwitchBinds, + Trigger, }; use niri_ipc::LayoutSwitchTarget; use smithay::backend::input::{ @@ -4858,11 +4858,9 @@ fn should_intercept_key<'a>( // By default we intercept the key which hides both the press and release from clients. // Clients still learn about the modifier state via `advertise_modifier_state()`. // - // Modifier-only release-only binds are not intercepted by default so that - // bindings like `Ctrl+Alt` do not break application shortcuts. - let forward = bind - .pass_through - .unwrap_or(modified.is_some_and(is_keysym_modifier) && !bind.has_press()); + // Release-only binds are not intercepted by default so that bindings like `Ctrl+Alt` + // do not break application shortcuts. + let forward = bind.pass_through.unwrap_or(!bind.has_press()); if !forward { suppressed_keys.insert(key_code); } @@ -6134,13 +6132,13 @@ mod tests { let mut state = create_test_state(); let result = press_close_key(&mut state, &bindings, Modifiers::SUPER); - assert_matches!(result, ShouldInterceptResult::InterceptOnly); + assert_matches!(result, ShouldInterceptResult::Forward); assert!(state.pending_release_binds.contains_key(&CLOSE_KEY_CODE)); let result = release_close_key(&mut state, &bindings, Modifiers::SUPER); assert_matches!( result, - ShouldInterceptResult::InterceptAndHandle(Bind { + ShouldInterceptResult::ForwardAndHandle(Bind { action: BoundAction::Release(Action::CloseWindow), .. }) @@ -6160,13 +6158,13 @@ mod tests { ..create_test_state() }; let result = press_close_key(&mut state, &bindings, Modifiers::SUPER); - assert_matches!(result, ShouldInterceptResult::InterceptOnly); + assert_matches!(result, ShouldInterceptResult::Forward); assert!(state.pending_release_binds.contains_key(&CLOSE_KEY_CODE)); let result = release_close_key(&mut state, &bindings, Modifiers::SUPER); assert_matches!( result, - ShouldInterceptResult::InterceptAndHandle(Bind { + ShouldInterceptResult::ForwardAndHandle(Bind { action: BoundAction::Release(Action::CloseWindow), .. }) @@ -6183,11 +6181,11 @@ mod tests { ..create_test_state() }; let result = press_close_key(&mut state, &bindings, Modifiers::SUPER); - assert_matches!(result, ShouldInterceptResult::InterceptOnly); + assert_matches!(result, ShouldInterceptResult::Forward); assert!(state.pending_release_binds.is_empty()); let result = release_close_key(&mut state, &bindings, Modifiers::SUPER); - assert_matches!(result, ShouldInterceptResult::InterceptOnly); + assert_matches!(result, ShouldInterceptResult::Forward); assert!(state.pending_release_binds.is_empty()); } diff --git a/src/tests/binds.rs b/src/tests/binds.rs index 9c6c692c18..d77b2af004 100644 --- a/src/tests/binds.rs +++ b/src/tests/binds.rs @@ -1015,8 +1015,10 @@ fn release_binds() { surface key pressed: 125 surface modifiers: depressed=64, latched=0, locked=0, group=0 +AD01 24 XK_q + surface key pressed: 16 -AD01 24 XK_q niri test-action + surface key released: 16 -LWIN 133 XK_Super_L surface key released: 125 surface modifiers: depressed=0, latched=0, locked=0, group=0 @@ -1028,11 +1030,13 @@ fn release_binds() { surface key pressed: 125 surface modifiers: depressed=64, latched=0, locked=0, group=0 +AD01 24 XK_q + surface key pressed: 16 -LWIN 133 XK_Super_L surface key released: 125 surface modifiers: depressed=0, latched=0, locked=0, group=0 -AD01 24 XK_q niri test-action + surface key released: 16 "); // Another key in between cancels it, no matter the trigger. @@ -1041,6 +1045,7 @@ fn release_binds() { surface key pressed: 125 surface modifiers: depressed=64, latched=0, locked=0, group=0 +AD01 24 XK_q + surface key pressed: 16 +AC01 38 XK_a surface key pressed: 30 -AC01 38 XK_a @@ -1049,6 +1054,7 @@ fn release_binds() { surface key released: 125 surface modifiers: depressed=0, latched=0, locked=0, group=0 -AD01 24 XK_q + surface key released: 16 "); // Ctrl+Alt_L is a modifier-only bind: its whole key consists of modifiers, so releasing the @@ -1858,8 +1864,10 @@ fn release_binds_run_after_the_inhibitor_is_gone() { surface key pressed: 125 surface modifiers: depressed=64, latched=0, locked=0, group=0 +AD01 24 XK_q + surface key pressed: 16 -AD01 24 XK_q niri test-action + surface key released: 16 -LWIN 133 XK_Super_L surface key released: 125 surface modifiers: depressed=0, latched=0, locked=0, group=0 @@ -1902,6 +1910,7 @@ fn activating_the_inhibitor_keeps_an_armed_release_bind() { assert_snapshot!(run_f(&mut f, id, &surface, "-LatQ -LWIN"), @" -AD01 24 XK_q niri test-action + surface key released: 16 -LWIN 133 XK_Super_L surface key released: 125 surface modifiers: depressed=0, latched=0, locked=0, group=0