clone
ssh://git@git.notashelf.dev:33/notashelf/beer.gitCommit 9a680ab42eb6
tarballunverified5 files changed+429-41
@@ -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<Self> {+ 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<Self> {+ 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<u8>)>,+}++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<String, String>,+ text_bindings: &HashMap<String, String>,+ ) -> 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<Action> {+ 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<Keysym> {+ 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<u8> {+ 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'][..]));+ }+}@@ -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<String, String>,+ /// Chord → literal text to send (supports `\e \n \r \t \\ \xNN`).+ pub text_bindings: std::collections::HashMap<String, String>, } /// `[cursor]`: the default cursor presentation (DECSCUSR may override at runtime).@@ -1,5 +1,6 @@ //! beer, a fast, software-rendered, Wayland-native terminal emulator. +mod bindings; mod config; mod font; mod grid;@@ -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.@@ -213,6 +213,10 @@ pub fn run(config: Config) -> anyhow::Result<ExitCode> { ) .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<ExitCode> { session: None, title: None, config,+ bindings,+ font_size,+ fullscreen: false, width, height, needs_draw: false,@@ -373,6 +380,12 @@ struct App { title: Option<String>, /// 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;