forked from NotAShelf/beer
input: configurable key/text bindings, font resize, and fullscreen
Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: I671e429c3d4e4f3c82f4a15fed0ac73d6a6a6964
This commit is contained in:
parent
0738ce3b6f
commit
9a680ab42e
5 changed files with 429 additions and 41 deletions
318
src/bindings.rs
Normal file
318
src/bindings.rs
Normal 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'][..]));
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
140
src/wayland.rs
140
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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue