Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion docs/docs/configuration/keybindings-and-pointer.md
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand All @@ -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
Expand All @@ -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
Expand Down
9 changes: 6 additions & 3 deletions packages/shoji_wm/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"` のような記法を使います。
*
Expand All @@ -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回登録すると前のバインドが上書きされます。
Expand Down
34 changes: 33 additions & 1 deletion src/shojiwm_lib/src/input.rs
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 `<Popup>`s hear about every press: anchors pressed
// (`trigger="click"`) and light dismiss of `Auto` popups.
if button_state == ButtonState::Pressed {
Expand Down
75 changes: 72 additions & 3 deletions src/shojiwm_lib/src/runtime_key_binding.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)]
Expand All @@ -113,6 +124,7 @@ pub struct RuntimeKeyShortcut {
/// `None` for a modifier-only shortcut (a "tap" binding).
pub keysym: Option<xkb::Keysym>,
pub wheel_direction: Option<RuntimeWheelDirection>,
pub mouse_button: Option<u32>,
}

impl RuntimeKeyShortcut {
Expand All @@ -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`.
Expand Down Expand Up @@ -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}`")]
Expand Down Expand Up @@ -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) {
Expand All @@ -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(),
});
Expand All @@ -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(),
Expand All @@ -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()
Expand All @@ -302,6 +322,7 @@ fn parse_runtime_key_shortcut(
logo,
keysym: None,
wheel_direction: None,
mouse_button: None,
});
}

Expand All @@ -312,9 +333,18 @@ fn parse_runtime_key_shortcut(
logo,
keysym,
wheel_direction,
mouse_button,
})
}

fn parse_mouse_button(name: &str) -> Option<u32> {
match name.to_ascii_lowercase().as_str() {
"mouseback" => Some(0x113),
"mouseforward" => Some(0x114),
_ => None,
}
}

fn parse_wheel_direction(name: &str) -> Option<RuntimeWheelDirection> {
match name.to_ascii_lowercase().as_str() {
"wheelscrollup" => Some(RuntimeWheelDirection::Up),
Expand Down Expand 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!(
Expand Down
2 changes: 2 additions & 0 deletions src/shojiwm_lib/src/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<crate::runtime_input_grab::InputGrabEventSnapshot>,
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,
Expand Down Expand Up @@ -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,
Expand Down