diff --git a/src/bindings.rs b/src/bindings.rs new file mode 100644 index 0000000..0b29cc7 --- /dev/null +++ b/src/bindings.rs @@ -0,0 +1,318 @@ +//! Configurable key bindings: chord strings (e.g. `Ctrl+Shift+C`) mapped to +//! editor actions, plus text bindings that send a literal string. + +use std::collections::HashMap; + +use smithay_client_toolkit::seat::keyboard::{KeyEvent, Keysym, Modifiers}; + +/// An action a key binding can trigger. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Action { + Copy, + Paste, + PastePrimary, + ScrollPageUp, + ScrollPageDown, + ScrollTop, + ScrollBottom, + SearchStart, + FontIncrease, + FontDecrease, + FontReset, + Fullscreen, +} + +impl Action { + fn parse(name: &str) -> Option { + Some(match name { + "copy" => Self::Copy, + "paste" => Self::Paste, + "paste-primary" => Self::PastePrimary, + "scrollback-up" => Self::ScrollPageUp, + "scrollback-down" => Self::ScrollPageDown, + "scrollback-top" => Self::ScrollTop, + "scrollback-bottom" => Self::ScrollBottom, + "search" => Self::SearchStart, + "font-increase" => Self::FontIncrease, + "font-decrease" => Self::FontDecrease, + "font-reset" => Self::FontReset, + "fullscreen" => Self::Fullscreen, + _ => return None, + }) + } +} + +/// A parsed chord: a key plus the modifiers that must be held exactly. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +struct Chord { + key: Keysym, + ctrl: bool, + shift: bool, + alt: bool, + logo: bool, +} + +impl Chord { + /// Parse a chord like `Ctrl+Shift+C`. Modifier order is irrelevant; the + /// final token is the key. Returns `None` if no key is recognized. + fn parse(spec: &str) -> Option { + let (mut ctrl, mut shift, mut alt, mut logo) = (false, false, false, false); + let mut key = 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, + _ => key = keysym_from_token(token), + } + } + Some(Self { + key: key?, + ctrl, + shift, + alt, + logo, + }) + } + + /// Whether `event`/`mods` match this chord. Letters compare case-insensitively + /// (Shift is matched via the modifier, not the keysym case). + fn matches(&self, event: &KeyEvent, mods: Modifiers) -> bool { + if mods.ctrl != self.ctrl + || mods.shift != self.shift + || mods.alt != self.alt + || mods.logo != self.logo + { + return false; + } + event.keysym == self.key + || matches!( + (self.key.key_char(), event.keysym.key_char()), + (Some(a), Some(b)) if a.eq_ignore_ascii_case(&b) + ) + } +} + +/// The resolved binding tables for a session. +#[derive(Clone, Debug, Default)] +pub struct Bindings { + keys: Vec<(Chord, Action)>, + text: Vec<(Chord, Vec)>, +} + +impl Bindings { + /// Build the tables from config, starting from the built-in defaults and + /// applying user overrides (a value of `none` unbinds a default chord). + pub fn from_config( + key_bindings: &HashMap, + text_bindings: &HashMap, + ) -> Self { + let mut keys: Vec<(Chord, Action)> = Vec::new(); + let mut add = |chord: &str, action: Action| { + if let Some(c) = Chord::parse(chord) { + keys.retain(|(existing, _)| *existing != c); + keys.push((c, action)); + } + }; + for (chord, action) in DEFAULT_BINDINGS { + if let Some(a) = Action::parse(action) { + add(chord, a); + } + } + for (chord, action) in key_bindings { + if let Some(c) = Chord::parse(chord) { + keys.retain(|(existing, _)| *existing != c); + if let Some(a) = Action::parse(action) { + keys.push((c, a)); + } else if action != "none" { + tracing::warn!("unknown key-binding action {action:?}"); + } + } + } + + let mut text = Vec::new(); + for (chord, value) in text_bindings { + if let Some(c) = Chord::parse(chord) { + text.push((c, unescape(value))); + } + } + Self { keys, text } + } + + /// The action bound to this key event, if any. + pub fn action(&self, event: &KeyEvent, mods: Modifiers) -> Option { + self.keys + .iter() + .find(|(c, _)| c.matches(event, mods)) + .map(|(_, a)| *a) + } + + /// The literal bytes bound to this key event, if any. + pub fn text(&self, event: &KeyEvent, mods: Modifiers) -> Option<&[u8]> { + self.text + .iter() + .find(|(c, _)| c.matches(event, mods)) + .map(|(_, t)| t.as_slice()) + } +} + +/// Built-in default key bindings (chord, action name). +const DEFAULT_BINDINGS: &[(&str, &str)] = &[ + ("Ctrl+Shift+C", "copy"), + ("Ctrl+Shift+V", "paste"), + ("Ctrl+Shift+F", "search"), + ("Shift+Page_Up", "scrollback-up"), + ("Shift+Page_Down", "scrollback-down"), + ("Ctrl+Shift+Home", "scrollback-top"), + ("Ctrl+Shift+End", "scrollback-bottom"), + ("Ctrl+plus", "font-increase"), + ("Ctrl+equal", "font-increase"), + ("Ctrl+minus", "font-decrease"), + ("Ctrl+0", "font-reset"), + ("F11", "fullscreen"), +]; + +/// 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(); + if let (Some(c), None) = (chars.next(), chars.clone().next()) { + // A single character; bind case-insensitively via the lowercase keysym. + return Some(Keysym::from_char(c.to_ascii_lowercase())); + } + Some(match token { + "Return" | "Enter" => Keysym::Return, + "Tab" => Keysym::Tab, + "Escape" | "Esc" => Keysym::Escape, + "Space" => Keysym::space, + "BackSpace" => Keysym::BackSpace, + "Delete" => Keysym::Delete, + "Insert" => Keysym::Insert, + "Home" => Keysym::Home, + "End" => Keysym::End, + "Page_Up" | "PageUp" | "Prior" => Keysym::Page_Up, + "Page_Down" | "PageDown" | "Next" => Keysym::Page_Down, + "Up" => Keysym::Up, + "Down" => Keysym::Down, + "Left" => Keysym::Left, + "Right" => Keysym::Right, + "plus" => Keysym::plus, + "minus" => Keysym::minus, + "equal" => Keysym::equal, + "F1" => Keysym::F1, + "F2" => Keysym::F2, + "F3" => Keysym::F3, + "F4" => Keysym::F4, + "F5" => Keysym::F5, + "F6" => Keysym::F6, + "F7" => Keysym::F7, + "F8" => Keysym::F8, + "F9" => Keysym::F9, + "F10" => Keysym::F10, + "F11" => Keysym::F11, + "F12" => Keysym::F12, + other => { + tracing::warn!("unknown key name {other:?} in binding"); + return None; + } + }) +} + +/// Decode the escapes a text binding may contain: `\e \n \r \t \\` and `\xNN`. +fn unescape(s: &str) -> Vec { + let mut out = Vec::with_capacity(s.len()); + let mut chars = s.chars().peekable(); + while let Some(c) = chars.next() { + if c != '\\' { + let mut buf = [0u8; 4]; + out.extend_from_slice(c.encode_utf8(&mut buf).as_bytes()); + continue; + } + match chars.next() { + Some('e') => out.push(0x1b), + Some('n') => out.push(b'\n'), + Some('r') => out.push(b'\r'), + Some('t') => out.push(b'\t'), + Some('\\') => out.push(b'\\'), + Some('x') => { + let hex: String = (0..2).filter_map(|_| chars.next()).collect(); + if let Ok(byte) = u8::from_str_radix(&hex, 16) { + out.push(byte); + } + } + Some(other) => { + out.push(b'\\'); + let mut buf = [0u8; 4]; + out.extend_from_slice(other.encode_utf8(&mut buf).as_bytes()); + } + None => out.push(b'\\'), + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + const NONE: Modifiers = Modifiers { + ctrl: false, + alt: false, + shift: false, + caps_lock: false, + logo: false, + num_lock: false, + }; + + fn key(keysym: Keysym) -> KeyEvent { + KeyEvent { + time: 0, + raw_code: 0, + keysym, + utf8: None, + } + } + + #[test] + fn default_copy_binding_matches_case_insensitively() { + let b = Bindings::from_config(&HashMap::new(), &HashMap::new()); + let mods = Modifiers { + ctrl: true, + shift: true, + ..NONE + }; + // Shift yields the uppercase keysym; the binding still matches. + assert_eq!(b.action(&key(Keysym::C), mods), Some(Action::Copy)); + // Without the right modifiers, no match. + assert_eq!(b.action(&key(Keysym::C), NONE), None); + } + + #[test] + fn config_overrides_and_unbinds() { + 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 cs = Modifiers { + ctrl: true, + shift: true, + ..NONE + }; + assert_eq!(b.action(&key(Keysym::C), cs), None); // unbound + let c = Modifiers { ctrl: true, ..NONE }; + assert_eq!(b.action(&key(Keysym::y), c), Some(Action::Copy)); + } + + #[test] + 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 mods = Modifiers { + ctrl: true, + shift: true, + ..NONE + }; + assert_eq!(b.text(&key(Keysym::Return), mods), Some(&[0x1b, b'\r'][..])); + } +} diff --git a/src/config.rs b/src/config.rs index 74095d9..83a8004 100644 --- a/src/config.rs +++ b/src/config.rs @@ -16,6 +16,11 @@ pub struct Config { pub cursor: Cursor, pub scrollback: Scrollback, pub bell: Bell, + /// Chord → action, e.g. `"Ctrl+Shift+C" = "copy"`. Merged over the defaults; + /// a value of `"none"` unbinds. + pub key_bindings: std::collections::HashMap, + /// Chord → literal text to send (supports `\e \n \r \t \\ \xNN`). + pub text_bindings: std::collections::HashMap, } /// `[cursor]`: the default cursor presentation (DECSCUSR may override at runtime). diff --git a/src/main.rs b/src/main.rs index 7f5b108..fbf5763 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,6 @@ //! beer, a fast, software-rendered, Wayland-native terminal emulator. +mod bindings; mod config; mod font; mod grid; diff --git a/src/render.rs b/src/render.rs index 89e2f77..74d9c8a 100644 --- a/src/render.rs +++ b/src/render.rs @@ -116,6 +116,12 @@ impl Renderer { self.pad = (pad_x as i32, pad_y as i32); } + /// Rebuild the font set at a new size (font-resize bindings). + pub fn set_font(&mut self, family: &str, size_px: u32) -> Result<(), crate::font::FontError> { + self.fonts = Fonts::new(family, size_px)?; + Ok(()) + } + /// Fill the whole buffer (including the padding margins) with the background /// colour. Called once per fresh shm buffer; per-row repaints then leave the /// margins untouched. diff --git a/src/wayland.rs b/src/wayland.rs index fa31dea..ffdf8eb 100644 --- a/src/wayland.rs +++ b/src/wayland.rs @@ -213,6 +213,10 @@ pub fn run(config: Config) -> anyhow::Result { ) .context("create shm slot pool")?; + let bindings = + crate::bindings::Bindings::from_config(&config.key_bindings, &config.text_bindings); + let font_size = config.main.font_size; + let mut app = App { registry_state: RegistryState::new(&globals), output_state: OutputState::new(&globals, &qh), @@ -250,6 +254,9 @@ pub fn run(config: Config) -> anyhow::Result { session: None, title: None, config, + bindings, + font_size, + fullscreen: false, width, height, needs_draw: false, @@ -373,6 +380,12 @@ struct App { title: Option, /// The active user configuration. config: Config, + /// Resolved key/text bindings. + bindings: crate::bindings::Bindings, + /// Current font size in pixels (changed by font-resize bindings). + font_size: u32, + /// Whether the toplevel is fullscreen. + fullscreen: bool, width: u32, height: u32, /// The grid changed and the window wants repainting on the next frame. @@ -470,50 +483,22 @@ impl App { self.session = Some(Session { pty, term }); } - /// Handle a key (initial press or repeat): Shift+PageUp/PageDown scroll the - /// viewport locally; anything else is encoded to the shell and snaps the - /// viewport back to the live screen. + /// Handle a key (initial press or repeat): configured bindings first, then + /// text bindings, else the byte encoding sent to the shell (which snaps the + /// viewport back to the live screen). fn handle_key(&mut self, event: &KeyEvent) { - // Ctrl+Shift+F toggles incremental search mode. - if self.modifiers.ctrl - && self.modifiers.shift - && matches!(event.keysym, Keysym::F | Keysym::f) - { - self.toggle_search(); - return; - } // While searching, the keyboard edits the query and navigates matches. if self.searching { self.search_key(event); return; } - // Ctrl+Shift+C/V copy the selection and paste the clipboard; these take - // precedence over the control bytes the chord would otherwise encode. - if self.modifiers.ctrl && self.modifiers.shift { - match event.keysym { - Keysym::C | Keysym::c => { - let qh = self.qh.clone(); - self.set_clipboard(&qh); - return; - } - Keysym::V | Keysym::v => { - self.paste_clipboard(); - return; - } - _ => {} - } + if let Some(action) = self.bindings.action(event, self.modifiers) { + self.dispatch_action(action); + return; } - if self.modifiers.shift && matches!(event.keysym, Keysym::Page_Up | Keysym::Page_Down) { - if let Some(session) = self.session.as_mut() { - let page = session.term.page() as isize; - let delta = if event.keysym == Keysym::Page_Up { - page - } else { - -page - }; - session.term.scroll_view(delta); - self.needs_draw = true; - } + if let Some(text) = self.bindings.text(event, self.modifiers) { + let bytes = text.to_vec(); + self.send_to_shell(&bytes); return; } @@ -521,18 +506,91 @@ impl App { .session .as_ref() .is_some_and(|s| s.term.grid().app_cursor()); - if let Some(bytes) = crate::input::encode(event, self.modifiers, app_cursor) - && let Some(session) = self.session.as_mut() - { + if let Some(bytes) = crate::input::encode(event, self.modifiers, app_cursor) { + self.send_to_shell(&bytes); + } + } + + /// Write key/text bytes to the shell, snapping the viewport to the live + /// screen and clearing any selection first. + fn send_to_shell(&mut self, bytes: &[u8]) { + if let Some(session) = self.session.as_mut() { session.term.scroll_to_bottom(); session.term.grid_mut().clear_selection(); self.needs_draw = true; - if let Err(err) = write_all(session.pty.master(), &bytes) { + if let Err(err) = write_all(session.pty.master(), bytes) { tracing::warn!("write key to pty: {err}"); } } } + /// Run a bound editor action. + fn dispatch_action(&mut self, action: crate::bindings::Action) { + use crate::bindings::Action; + match action { + Action::Copy => { + let qh = self.qh.clone(); + self.set_clipboard(&qh); + } + Action::Paste => self.paste_clipboard(), + Action::PastePrimary => self.paste_primary(), + Action::ScrollPageUp => self.scroll_page(true), + Action::ScrollPageDown => self.scroll_page(false), + Action::ScrollTop => { + if let Some(session) = self.session.as_mut() { + session.term.scroll_view(isize::MAX); + self.needs_draw = true; + } + } + Action::ScrollBottom => { + if let Some(session) = self.session.as_mut() { + session.term.scroll_to_bottom(); + self.needs_draw = true; + } + } + Action::SearchStart => self.toggle_search(), + Action::FontIncrease => self.change_font_size(self.font_size + 1), + Action::FontDecrease => self.change_font_size(self.font_size.saturating_sub(1)), + Action::FontReset => self.change_font_size(self.config.main.font_size), + Action::Fullscreen => self.toggle_fullscreen(), + } + } + + /// Scroll the viewport one page back (`up`) or toward the live screen. + fn scroll_page(&mut self, up: bool) { + if let Some(session) = self.session.as_mut() { + let page = session.term.page() as isize; + session.term.scroll_view(if up { page } else { -page }); + self.needs_draw = true; + } + } + + /// Toggle the toplevel between fullscreen and windowed. + fn toggle_fullscreen(&mut self) { + self.fullscreen = !self.fullscreen; + if self.fullscreen { + self.window.set_fullscreen(None); + } else { + self.window.unset_fullscreen(); + } + } + + /// Re-rasterize the font at `new_size`, then re-derive the grid geometry. + fn change_font_size(&mut self, new_size: u32) { + let new_size = new_size.clamp(6, 200); + if new_size == self.font_size { + return; + } + if let Err(err) = self.renderer.set_font(&self.config.main.font, new_size) { + tracing::warn!("resize font: {err:#}"); + return; + } + self.font_size = new_size; + self.frames.clear(); + self.resize_grid(); + self.needs_draw = true; + } + /// Enter or leave incremental search mode. fn toggle_search(&mut self) { self.searching = !self.searching;