input: configurable key/text bindings, font resize, and fullscreen

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I671e429c3d4e4f3c82f4a15fed0ac73d6a6a6964
This commit is contained in:
raf 2026-06-25 11:03:15 +03:00
commit 9a680ab42e
No known key found for this signature in database
GPG key ID: 29D95B64378DB4BF
5 changed files with 429 additions and 41 deletions

318
src/bindings.rs Normal file
View file

@ -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'][..]));
}
}

View file

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

View file

@ -1,5 +1,6 @@
//! beer, a fast, software-rendered, Wayland-native terminal emulator.
mod bindings;
mod config;
mod font;
mod grid;

View file

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

View file

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