brewery
notashelf /
9a680ab42eb6afcc676d8a55233e01d8bfcbc6e9

beer

public

A terminal worth pouring time into

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

Commit 9a680ab42eb6

tarball

NotAShelf <raf@notashelf.dev> · 2026-06-26 07:21 UTC

unverified5 files changed+429-41
input: configurable key/text bindings, font resize, and fullscreen

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I671e429c3d4e4f3c82f4a15fed0ac73d6a6a6964
diff --git a/src/bindings.rs b/src/bindings.rsnew file mode 100644index 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<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'][..]));+    }+}diff --git a/src/config.rs b/src/config.rsindex 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<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).diff --git a/src/main.rs b/src/main.rsindex 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.rsindex 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.rsindex fa31dea..ffdf8eb 100644--- a/src/wayland.rs+++ b/src/wayland.rs@@ -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;