From 3e49e94f5682b914c73ec2bcd545734c7f9bf31d Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Fri, 26 Jun 2026 11:36:52 +0300 Subject: [PATCH] config: add a `[mouse-bindings]` table for rebindable buttons Signed-off-by: NotAShelf Change-Id: I8b4bfdd5c594a29f06c73aed0f7b07a46a6a6964 --- doc/beer.toml.5.scd | 16 +++++ src/bindings.rs | 130 ++++++++++++++++++++++++++++++++++++++-- src/config.rs | 3 + src/wayland/handlers.rs | 29 ++++++--- src/wayland/mod.rs | 14 +++-- 5 files changed, 177 insertions(+), 15 deletions(-) diff --git a/doc/beer.toml.5.scd b/doc/beer.toml.5.scd index 9317efd..c00d237 100644 --- a/doc/beer.toml.5.scd +++ b/doc/beer.toml.5.scd @@ -123,6 +123,22 @@ _unicode-input_. "Ctrl+`" = "none" ``` +# [mouse-bindings] + +A table of _button-chord_ = _action_ entries, merged over the built-in +defaults. A chord is optional modifiers and a button joined by _+_, e.g. +_"Shift+Middle"_. Buttons are _Left_, _Middle_, and _Right_. The actions are +the same set as *[key-bindings]*. An action of _"none"_ unbinds the chord. The +left button's select/drag is a built-in gesture and is not rebindable. + +The only default is _"Middle" = "paste-primary"_. + +``` +[mouse-bindings] +"Right" = "paste" +"Middle" = "none" +``` + # [shell-integration] Behaviour driven by *OSC 7* (cwd) and *OSC 133* (prompt) marks. diff --git a/src/bindings.rs b/src/bindings.rs index 46e3ca5..21f6837 100644 --- a/src/bindings.rs +++ b/src/bindings.rs @@ -106,11 +106,69 @@ impl Chord { } } +/// A mouse button that a `[mouse-bindings]` chord can name. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum MouseButton { + Left, + Middle, + Right, +} + +/// A parsed mouse chord: a button plus the modifiers that must be held exactly. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +struct MouseChord { + button: MouseButton, + ctrl: bool, + shift: bool, + alt: bool, + logo: bool, +} + +impl MouseChord { + /// Parse a chord like `Shift+Middle`; the final non-modifier token is the + /// button. Returns `None` if no button is recognized. + fn parse(spec: &str) -> Option { + let (mut ctrl, mut shift, mut alt, mut logo) = (false, false, false, false); + let mut button = None; + for token in spec.split('+').map(str::trim).filter(|t| !t.is_empty()) { + match token.to_ascii_lowercase().as_str() { + "ctrl" | "control" => ctrl = true, + "shift" => shift = true, + "alt" | "mod1" | "meta" => alt = true, + "super" | "logo" | "cmd" | "mod4" => logo = true, + "left" | "button1" => button = Some(MouseButton::Left), + "middle" | "button2" => button = Some(MouseButton::Middle), + "right" | "button3" => button = Some(MouseButton::Right), + other => { + tracing::warn!("unknown mouse button {other:?} in binding"); + return None; + } + } + } + Some(Self { + button: button?, + ctrl, + shift, + alt, + logo, + }) + } + + fn matches(&self, button: MouseButton, mods: Modifiers) -> bool { + self.button == button + && mods.ctrl == self.ctrl + && mods.shift == self.shift + && mods.alt == self.alt + && mods.logo == self.logo + } +} + /// The resolved binding tables for a session. #[derive(Clone, Debug, Default)] pub struct Bindings { keys: Vec<(Chord, Action)>, text: Vec<(Chord, Vec)>, + mouse: Vec<(MouseChord, Action)>, } impl Bindings { @@ -119,6 +177,7 @@ impl Bindings { pub fn from_config( key_bindings: &HashMap, text_bindings: &HashMap, + mouse_bindings: &HashMap, ) -> Self { let mut keys: Vec<(Chord, Action)> = Vec::new(); let mut add = |chord: &str, action: Action| { @@ -149,7 +208,30 @@ impl Bindings { text.push((c, unescape(value))); } } - Self { keys, text } + + let mut mouse: Vec<(MouseChord, Action)> = Vec::new(); + let mut add_mouse = |chord: &str, action: Option| { + if let Some(c) = MouseChord::parse(chord) { + mouse.retain(|(existing, _)| *existing != c); + if let Some(a) = action { + mouse.push((c, a)); + } + } + }; + for (chord, action) in DEFAULT_MOUSE_BINDINGS { + add_mouse(chord, Action::parse(action)); + } + for (chord, action) in mouse_bindings { + if action == "none" { + add_mouse(chord, None); + } else if let Some(a) = Action::parse(action) { + add_mouse(chord, Some(a)); + } else { + tracing::warn!("unknown mouse-binding action {action:?}"); + } + } + + Self { keys, text, mouse } } /// The action bound to this key event, if any. @@ -167,6 +249,14 @@ impl Bindings { .find(|(c, _)| c.matches(event, mods)) .map(|(_, t)| t.as_slice()) } + + /// The action bound to this mouse button + modifiers, if any. + pub fn mouse_action(&self, button: MouseButton, mods: Modifiers) -> Option { + self.mouse + .iter() + .find(|(c, _)| c.matches(button, mods)) + .map(|(_, a)| *a) + } } /// Built-in default key bindings (chord, action name). @@ -190,6 +280,10 @@ const DEFAULT_BINDINGS: &[(&str, &str)] = &[ ("Ctrl+Shift+U", "unicode-input"), ]; +/// Built-in default mouse bindings (chord, action name). Left-button select and +/// drag are built-in gestures, not bindings, so only the other buttons appear. +const DEFAULT_MOUSE_BINDINGS: &[(&str, &str)] = &[("Middle", "paste-primary")]; + /// Map a key token to a keysym: a single character, or a named special key. fn keysym_from_token(token: &str) -> Option { let mut chars = token.chars(); @@ -292,7 +386,7 @@ mod tests { #[test] fn default_copy_binding_matches_case_insensitively() { - let b = Bindings::from_config(&HashMap::new(), &HashMap::new()); + let b = Bindings::from_config(&HashMap::new(), &HashMap::new(), &HashMap::new()); let mods = Modifiers { ctrl: true, shift: true, @@ -309,7 +403,7 @@ mod tests { let mut kb = HashMap::new(); kb.insert("Ctrl+Shift+C".to_string(), "none".to_string()); kb.insert("Ctrl+y".to_string(), "copy".to_string()); - let b = Bindings::from_config(&kb, &HashMap::new()); + let b = Bindings::from_config(&kb, &HashMap::new(), &HashMap::new()); let cs = Modifiers { ctrl: true, shift: true, @@ -324,7 +418,7 @@ mod tests { fn text_binding_unescapes() { let mut tb = HashMap::new(); tb.insert("Ctrl+Shift+Return".to_string(), "\\x1b\\r".to_string()); - let b = Bindings::from_config(&HashMap::new(), &tb); + let b = Bindings::from_config(&HashMap::new(), &tb, &HashMap::new()); let mods = Modifiers { ctrl: true, shift: true, @@ -332,4 +426,32 @@ mod tests { }; assert_eq!(b.text(&key(Keysym::Return), mods), Some(&[0x1b, b'\r'][..])); } + + #[test] + fn mouse_bindings_default_override_and_unbind() { + // Default: middle button pastes the primary selection. + let b = Bindings::from_config(&HashMap::new(), &HashMap::new(), &HashMap::new()); + assert_eq!( + b.mouse_action(MouseButton::Middle, NONE), + Some(Action::PastePrimary) + ); + assert_eq!(b.mouse_action(MouseButton::Right, NONE), None); + + // Config rebinds right to paste and unbinds the middle default. + let mut mb = HashMap::new(); + mb.insert("Right".to_string(), "paste".to_string()); + mb.insert("Middle".to_string(), "none".to_string()); + let b = Bindings::from_config(&HashMap::new(), &HashMap::new(), &mb); + assert_eq!( + b.mouse_action(MouseButton::Right, NONE), + Some(Action::Paste) + ); + assert_eq!(b.mouse_action(MouseButton::Middle, NONE), None); + // Modifiers must match exactly. + let shift = Modifiers { + shift: true, + ..NONE + }; + assert_eq!(b.mouse_action(MouseButton::Right, shift), None); + } } diff --git a/src/config.rs b/src/config.rs index fbff91a..e895eef 100644 --- a/src/config.rs +++ b/src/config.rs @@ -25,6 +25,9 @@ pub struct Config { pub key_bindings: std::collections::HashMap, /// Chord → literal text to send (supports `\e \n \r \t \\ \xNN`). pub text_bindings: std::collections::HashMap, + /// Mouse chord (e.g. `"Middle"`, `"Shift+Right"`) → action. Merged over the + /// defaults; `"none"` unbinds. Left-button select/drag stays built in. + pub mouse_bindings: std::collections::HashMap, } /// `[cursor]`: the default cursor presentation (DECSCUSR may override at runtime). diff --git a/src/wayland/handlers.rs b/src/wayland/handlers.rs index e1c93c9..95002e4 100644 --- a/src/wayland/handlers.rs +++ b/src/wayland/handlers.rs @@ -1,4 +1,5 @@ use super::*; +use crate::bindings::MouseButton; impl CompositorHandler for App { fn scale_factor_changed( @@ -330,6 +331,16 @@ fn button_code(button: u32) -> Option { } } +/// Map a Wayland button code to a bindable [`MouseButton`]. +fn mouse_button(button: u32) -> Option { + match button { + BTN_LEFT => Some(MouseButton::Left), + BTN_MIDDLE => Some(MouseButton::Middle), + BTN_RIGHT => Some(MouseButton::Right), + _ => None, + } +} + impl PointerHandler for App { fn pointer_frame( &mut self, @@ -372,13 +383,17 @@ impl PointerHandler for App { self.pressed_button = Some(code); continue; } - match *button { - BTN_LEFT => { - self.press_cell = self.cell_at(self.pointer_pos.0, self.pointer_pos.1); - self.pointer_press(*time); - } - BTN_MIDDLE => self.paste_primary(), - _ => {} + // A configured `[mouse-bindings]` action (Middle defaults to + // primary paste) fires before the built-in left-drag select. + if let Some(mb) = mouse_button(*button) + && let Some(action) = self.bindings.mouse_action(mb, self.modifiers) + { + self.dispatch_action(action); + continue; + } + if *button == BTN_LEFT { + self.press_cell = self.cell_at(self.pointer_pos.0, self.pointer_pos.1); + self.pointer_press(*time); } } PointerEventKind::Release { button, .. } => { diff --git a/src/wayland/mod.rs b/src/wayland/mod.rs index 446eb46..b74ba1f 100644 --- a/src/wayland/mod.rs +++ b/src/wayland/mod.rs @@ -217,8 +217,11 @@ pub fn run(config: Config, config_path: Option) -> anyhow::R ) .context("create shm slot pool")?; - let bindings = - crate::bindings::Bindings::from_config(&config.key_bindings, &config.text_bindings); + let bindings = crate::bindings::Bindings::from_config( + &config.key_bindings, + &config.text_bindings, + &config.mouse_bindings, + ); let font_size = config.main.font_size; let mut app = App { @@ -934,8 +937,11 @@ impl App { /// Re-read the config file and apply it in place (SIGUSR1). fn reload_config(&mut self) { let new = Config::load(self.config_path.as_deref()); - self.bindings = - crate::bindings::Bindings::from_config(&new.key_bindings, &new.text_bindings); + self.bindings = crate::bindings::Bindings::from_config( + &new.key_bindings, + &new.text_bindings, + &new.mouse_bindings, + ); let font_changed = new.main.font != self.config.main.font || new.main.font_size != self.config.main.font_size; if font_changed {