diff --git a/docs/docs/configuration/keybindings-and-pointer.md b/docs/docs/configuration/keybindings-and-pointer.md index b3d76e3d3..3229e4aa5 100644 --- a/docs/docs/configuration/keybindings-and-pointer.md +++ b/docs/docs/configuration/keybindings-and-pointer.md @@ -7,7 +7,7 @@ sidebar_position: 5 ## Keyboard shortcuts `COMPOSITOR.key.bind(id, shortcut, handler, options?)` registers a -compositor-level keyboard or mouse-wheel shortcut. +compositor-level keyboard, mouse side-button or mouse-wheel shortcut. ```ts COMPOSITOR.key.bind('terminal', 'Super+T', () => { @@ -29,6 +29,9 @@ Combine modifiers and a key with `+`: - **Modifiers:** `Super`, `Ctrl`, `Shift`, `Alt` - **Keys:** letters (`T`, `Q`), arrows (`Left`, `Right`, `Up`, `Down`), function keys (`F`), etc. +- **Mouse side buttons:** `MouseBack` (`BTN_SIDE`, 275) and `MouseForward` + (`BTN_EXTRA`, 276). Names are case-insensitive and use the default + `on: "press"` phase. Release-only side-button bindings are rejected. - **Mouse wheel:** `WheelScrollUp`, `WheelScrollDown`, `WheelScrollLeft`, `WheelScrollRight`. Wheel shortcuts fire on discrete mouse-wheel events; touchpad scrolling remains continuous and is forwarded normally. Wheel @@ -42,8 +45,17 @@ COMPOSITOR.key.bind('screenshot', 'Super+P', () => { }); COMPOSITOR.key.bind('focus-left', 'Super+WheelScrollUp', () => focusTile(-1)); COMPOSITOR.key.bind('focus-right', 'Super+WheelScrollDown', () => focusTile(1)); +COMPOSITOR.key.bind('focus-left-button', 'Super+MouseBack', () => focusTile(-1)); +COMPOSITOR.key.bind('focus-right-button', 'Super+MouseForward', () => focusTile(1)); ``` +Side-button bindings require the configured modifiers to match exactly. For +example, `Super+MouseForward` leaves plain `MouseForward` available to the +focused application or game. A matched binding consumes both the press and its +release, even if the modifier is released first, and does not trigger a bare +modifier tap binding. System side-button shortcuts do not run while the session +is locked or a modal input grab is held. + ### Tap bindings (`on: "release"`) Binding a bare modifier with `{on: "release"}` makes it a **tap** — it fires when diff --git a/packages/shoji_wm/src/types.ts b/packages/shoji_wm/src/types.ts index c0047f619..4dec5d2be 100644 --- a/packages/shoji_wm/src/types.ts +++ b/packages/shoji_wm/src/types.ts @@ -1966,12 +1966,15 @@ export interface KeyBindingOptions { } /** - * Registers compositor-level keyboard and discrete mouse-wheel shortcuts. + * Registers compositor-level keyboard, side-button and discrete mouse-wheel shortcuts. * コンポジターレベルのキーボードおよびマウスホイールショートカットを登録します。 * * Shortcuts are identified by a string `id` (shown in help UIs). The `shortcut` * string uses modifier+key notation such as `"Super+T"`, `"Super+Shift+Left"`, - * or `"Super+WheelScrollUp"`. + * `"Super+WheelScrollUp"` or `"Super+MouseForward"`. + * `MouseBack` and `MouseForward` use the standard Linux side-button codes. + * Side-button shortcuts only accept `on: "press"`; their release is consumed + * with the press, while unmatched buttons keep reaching applications. * ショートカットは文字列 `id` で識別されます(ヘルプ UI などに表示されます)。 * `shortcut` は `"Super+T"` や `"Super+Shift+Left"` のような記法を使います。 * @@ -1987,7 +1990,7 @@ export interface KeyBindingOptions { */ export interface KeyBindingController { /** - * Register a keyboard or mouse-wheel shortcut. `id` must be unique; + * Register a keyboard, side-button or mouse-wheel shortcut. `id` must be unique; * registering the same id twice replaces the previous binding. * ショートカットを登録します。`id` は一意である必要があります。 * 同じ `id` を2回登録すると前のバインドが上書きされます。 diff --git a/src/shojiwm_lib/src/input.rs b/src/shojiwm_lib/src/input.rs index 36b88c44a..4eb604c24 100644 --- a/src/shojiwm_lib/src/input.rs +++ b/src/shojiwm_lib/src/input.rs @@ -1,7 +1,7 @@ use smithay::{ backend::{ input::{ - AbsolutePositionEvent, Axis, AxisSource, ButtonState, Event, GestureBeginEvent, + AbsolutePositionEvent, Axis, AxisSource, ButtonState, Device, Event, GestureBeginEvent, GestureEndEvent, GesturePinchUpdateEvent, GestureSwipeUpdateEvent, InputBackend, InputEvent, KeyState, KeyboardKeyEvent, PointerAxisEvent, PointerButtonEvent, PointerMotionEvent, @@ -1031,6 +1031,11 @@ impl ShojiWM { } match event { + InputEvent::DeviceRemoved { device } => { + let device_id = device.id(); + self.consumed_pointer_buttons + .retain(|(id, _)| id != &device_id); + } InputEvent::Keyboard { event, .. } => { let device = event.device(); crate::runtime_input::apply_keyboard_config_for_backend_device(self, &device); @@ -1517,6 +1522,18 @@ impl ShojiWM { let button_state = event.state(); + let button_key = (event.device().id(), button); + // The client never saw the intercepted press; do not send its release, + // even if Super was released or the session was locked in between. + if button_state == ButtonState::Released + && self.consumed_pointer_buttons.remove(&button_key) + { + return; + } + if self.consumed_pointer_buttons.contains(&button_key) { + return; + } + // A pointer button press cancels a pending modifier tap (e.g. Super+drag). if matches!(button_state, ButtonState::Pressed) { self.tap_interrupted = true; @@ -1555,6 +1572,21 @@ impl ShojiWM { return; } + if button_state == ButtonState::Pressed { + let binding_id = self + .runtime_key_bindings + .iter() + .find(|binding| { + binding.matches_mouse_button(button, &self.current_keyboard_modifiers) + }) + .map(|binding| binding.id.clone()); + if let Some(binding_id) = binding_id { + self.consumed_pointer_buttons.insert(button_key); + self.run_runtime_key_binding(&binding_id); + return; + } + } + // SSD ``s hear about every press: anchors pressed // (`trigger="click"`) and light dismiss of `Auto` popups. if button_state == ButtonState::Pressed { diff --git a/src/shojiwm_lib/src/runtime_key_binding.rs b/src/shojiwm_lib/src/runtime_key_binding.rs index 535e93932..0b44e8764 100644 --- a/src/shojiwm_lib/src/runtime_key_binding.rs +++ b/src/shojiwm_lib/src/runtime_key_binding.rs @@ -49,6 +49,11 @@ impl RuntimeKeyBindingEntry { self.shortcut.clone(), )); } + if shortcut.mouse_button.is_some() && self.on != RuntimeKeyBindingPhase::Press { + return Err(RuntimeKeyBindingParseError::MouseButtonRequiresPress( + self.shortcut.clone(), + )); + } Ok(CompiledRuntimeKeyBinding { id: self.id.clone(), phase: self.on, @@ -102,6 +107,12 @@ impl CompiledRuntimeKeyBinding { self.phase == RuntimeKeyBindingPhase::Press && self.shortcut.matches_wheel(direction, modifiers) } + + pub fn matches_mouse_button(&self, button: u32, modifiers: &ModifiersState) -> bool { + self.phase == RuntimeKeyBindingPhase::Press + && self.shortcut.mouse_button == Some(button) + && self.shortcut.modifiers_match(modifiers) + } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -113,6 +124,7 @@ pub struct RuntimeKeyShortcut { /// `None` for a modifier-only shortcut (a "tap" binding). pub keysym: Option, pub wheel_direction: Option, + pub mouse_button: Option, } impl RuntimeKeyShortcut { @@ -138,7 +150,7 @@ impl RuntimeKeyShortcut { } pub fn is_modifier_only(&self) -> bool { - self.keysym.is_none() && self.wheel_direction.is_none() + self.keysym.is_none() && self.wheel_direction.is_none() && self.mouse_button.is_none() } /// The single modifier class for a modifier-only shortcut, else `None`. @@ -175,6 +187,8 @@ pub enum RuntimeKeyBindingParseError { ModifierOnlyRequiresRelease(String), #[error("mouse-wheel shortcut `{0}` is only valid with `on: \"press\"`")] WheelRequiresPress(String), + #[error("mouse-button shortcut `{0}` is only valid with `on: \"press\"`")] + MouseButtonRequiresPress(String), #[error("`within` on `{0}` only applies to modifier-only tap bindings")] WithinRequiresTap(String), #[error("shortcut `{shortcut}` contains unknown modifier `{modifier}`")] @@ -252,6 +266,7 @@ fn parse_runtime_key_shortcut( let mut logo = false; let mut keysym = None; let mut wheel_direction = None; + let mut mouse_button = None; for part in parts { if let Some(target) = modifier_slot(part, &mut ctrl, &mut alt, &mut shift, &mut logo) { @@ -265,7 +280,7 @@ fn parse_runtime_key_shortcut( continue; } - if keysym.is_some() || wheel_direction.is_some() { + if keysym.is_some() || wheel_direction.is_some() || mouse_button.is_some() { return Err(RuntimeKeyBindingParseError::MultipleKeys { shortcut: shortcut.to_string(), }); @@ -276,6 +291,11 @@ fn parse_runtime_key_shortcut( continue; } + if let Some(button) = parse_mouse_button(part) { + mouse_button = Some(button); + continue; + } + let parsed_keysym = parse_keysym_name(part).ok_or_else(|| RuntimeKeyBindingParseError::UnknownKey { shortcut: shortcut.to_string(), @@ -284,7 +304,7 @@ fn parse_runtime_key_shortcut( keysym = Some(parsed_keysym); } - if keysym.is_none() && wheel_direction.is_none() { + if keysym.is_none() && wheel_direction.is_none() && mouse_button.is_none() { // Modifier-only shortcut ("tap"): require exactly one modifier. let modifier_count = [ctrl, alt, shift, logo] .into_iter() @@ -302,6 +322,7 @@ fn parse_runtime_key_shortcut( logo, keysym: None, wheel_direction: None, + mouse_button: None, }); } @@ -312,9 +333,18 @@ fn parse_runtime_key_shortcut( logo, keysym, wheel_direction, + mouse_button, }) } +fn parse_mouse_button(name: &str) -> Option { + match name.to_ascii_lowercase().as_str() { + "mouseback" => Some(0x113), + "mouseforward" => Some(0x114), + _ => None, + } +} + fn parse_wheel_direction(name: &str) -> Option { match name.to_ascii_lowercase().as_str() { "wheelscrollup" => Some(RuntimeWheelDirection::Up), @@ -403,6 +433,45 @@ mod tests { )); } + #[test] + fn side_buttons_require_press_and_exact_modifiers() { + let modifiers = ModifiersState { + logo: true, + ..Default::default() + }; + for (name, code) in [("MouseBack", 275), ("mouseforward", 276)] { + let shortcut = format!("Super+{name}"); + let binding = entry(&shortcut, RuntimeKeyBindingPhase::Press) + .compile() + .unwrap(); + assert!(!binding.shortcut.is_modifier_only()); + assert!(binding.matches_mouse_button(code, &modifiers)); + assert!(!binding.matches_mouse_button(272, &modifiers)); + assert!(!binding.matches_mouse_button(code, &ModifiersState::default())); + assert!(!binding.matches_mouse_button( + code, + &ModifiersState { + shift: true, + ..modifiers + }, + )); + assert!(matches!( + entry(&shortcut, RuntimeKeyBindingPhase::Release).compile(), + Err(RuntimeKeyBindingParseError::MouseButtonRequiresPress(_)), + )); + } + for shortcut in [ + "MouseBack+MouseForward", + "A+MouseBack", + "MouseForward+WheelScrollUp", + ] { + assert!(matches!( + parse_runtime_key_shortcut(shortcut), + Err(RuntimeKeyBindingParseError::MultipleKeys { .. }) + )); + } + } + #[test] fn wheel_shortcut_rejects_another_trigger() { assert!(matches!( diff --git a/src/shojiwm_lib/src/state.rs b/src/shojiwm_lib/src/state.rs index 10e3618ca..12624035d 100644 --- a/src/shojiwm_lib/src/state.rs +++ b/src/shojiwm_lib/src/state.rs @@ -528,6 +528,7 @@ pub struct ShojiWM { /// A key the grab took inside the keyboard filter, dispatched right after. pub pending_input_grab_key_event: Option, pub current_keyboard_modifiers: ModifiersState, + pub consumed_pointer_buttons: HashSet<(String, u32)>, // Modifier-only tap detection (e.g. "Super"). If no other input occurs // between press and release, the release is treated as a tap. pub tap_pressed_keys: u32, @@ -1924,6 +1925,7 @@ impl ShojiWM { runtime_input_grab: None, pending_input_grab_key_event: None, current_keyboard_modifiers: ModifiersState::default(), + consumed_pointer_buttons: HashSet::new(), tap_pressed_keys: 0, tap_armed_modifier: None, tap_interrupted: false,