brewery
notashelf /
3e49e94f5682b914c73ec2bcd545734c7f9bf31d

beer

public

A terminal worth pouring time into

Star0tarball
clone
ssh://git@git.notashelf.dev:33/notashelf/beer.git

Commit 3e49e94f5682

tarball

NotAShelf <raf@notashelf.dev> · 2026-06-26 08:57 UTC

unverified5 files changed+177-15
config: add a `[mouse-bindings]` table for rebindable buttons

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I8b4bfdd5c594a29f06c73aed0f7b07a46a6a6964
diff --git a/doc/beer.toml.5.scd b/doc/beer.toml.5.scdindex 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.rsindex 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<Self> {+        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<u8>)>,+    mouse: Vec<(MouseChord, Action)>, }  impl Bindings {@@ -119,6 +177,7 @@ impl Bindings {     pub fn from_config(         key_bindings: &HashMap<String, String>,         text_bindings: &HashMap<String, String>,+        mouse_bindings: &HashMap<String, String>,     ) -> 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<Action>| {+            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<Action> {+        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<Keysym> {     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.rsindex fbff91a..e895eef 100644--- a/src/config.rs+++ b/src/config.rs@@ -25,6 +25,9 @@ pub struct Config {     pub key_bindings: std::collections::HashMap<String, String>,     /// Chord → literal text to send (supports `\e \n \r \t \\ \xNN`).     pub text_bindings: std::collections::HashMap<String, String>,+    /// 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<String, String>, }  /// `[cursor]`: the default cursor presentation (DECSCUSR may override at runtime).diff --git a/src/wayland/handlers.rs b/src/wayland/handlers.rsindex 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<u8> {     } } +/// Map a Wayland button code to a bindable [`MouseButton`].+fn mouse_button(button: u32) -> Option<MouseButton> {+    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.rsindex 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<std::path::PathBuf>) -> 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 {