config: add a [mouse-bindings] table for rebindable buttons

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I8b4bfdd5c594a29f06c73aed0f7b07a46a6a6964
This commit is contained in:
raf 2026-06-26 11:36:52 +03:00
commit 3e49e94f56
No known key found for this signature in database
GPG key ID: 29D95B64378DB4BF
5 changed files with 177 additions and 15 deletions

View file

@ -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.

View file

@ -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);
}
}

View file

@ -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).

View file

@ -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, .. } => {

View file

@ -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 {