brewery
notashelf /
b2d656e7bdb32eb98555552a718b22ba9b2b8a69

beer

public

A terminal worth pouring time into

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

Commit b2d656e7bdb3

tarball

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

unverified5 files changed+322-8
input: encode keyboard events and send them to the shell

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I6ee2acd5f74575f4bcc2f41417207c626a6a6964
diff --git a/src/grid.rs b/src/grid.rsindex d735107..78c0132 100644--- a/src/grid.rs+++ b/src/grid.rs@@ -124,6 +124,8 @@ pub struct Grid {     alt_saved: Option<Vec<Vec<Cell>>>,     cursor_shape: CursorShape,     cursor_visible: bool,+    /// Application cursor-keys mode (DECCKM): arrows send SS3 instead of CSI.+    app_cursor: bool,     /// Cursor colour from OSC 12; `None` follows the cell under the cursor.     cursor_color: Option<(u8, u8, u8)>, }@@ -154,6 +156,7 @@ impl Grid {             cursor_shape: CursorShape::default(),             cursor_visible: true,             cursor_color: None,+            app_cursor: false,         }     } @@ -250,6 +253,14 @@ impl Grid {         self.cursor_color     } +    pub fn set_app_cursor(&mut self, on: bool) {+        self.app_cursor = on;+    }++    pub fn app_cursor(&self) -> bool {+        self.app_cursor+    }+     // --- printing ---      /// Place a printable character at the cursor, honouring width and autowrap.diff --git a/src/input.rs b/src/input.rsnew file mode 100644index 0000000..e5755a1--- /dev/null+++ b/src/input.rs@@ -0,0 +1,186 @@+//! Keyboard encoding: translate decoded key events into the byte sequences a+//! terminal application expects (xterm/VT-style).++use smithay_client_toolkit::seat::keyboard::{KeyEvent, Keysym, Modifiers};++/// Encode a key press into bytes for the PTY, or `None` if it produces no input.+pub fn encode(event: &KeyEvent, mods: Modifiers, app_cursor: bool) -> Option<Vec<u8>> {+    let seq = match event.keysym {+        Keysym::Return | Keysym::KP_Enter => prefix_alt(b"\r".to_vec(), mods),+        Keysym::BackSpace => prefix_alt(b"\x7f".to_vec(), mods),+        Keysym::Tab if mods.shift => b"\x1b[Z".to_vec(),+        Keysym::Tab => b"\t".to_vec(),+        Keysym::Escape => b"\x1b".to_vec(),++        Keysym::Up => csi_letter(b'A', mods, app_cursor),+        Keysym::Down => csi_letter(b'B', mods, app_cursor),+        Keysym::Right => csi_letter(b'C', mods, app_cursor),+        Keysym::Left => csi_letter(b'D', mods, app_cursor),+        Keysym::Home => csi_letter(b'H', mods, app_cursor),+        Keysym::End => csi_letter(b'F', mods, app_cursor),++        Keysym::Insert => csi_tilde(2, mods),+        Keysym::Delete => csi_tilde(3, mods),+        Keysym::Page_Up => csi_tilde(5, mods),+        Keysym::Page_Down => csi_tilde(6, mods),++        // F1-F4 are SS3-introduced; F5+ use the CSI tilde forms.+        Keysym::F1 => fkey(b'P', mods),+        Keysym::F2 => fkey(b'Q', mods),+        Keysym::F3 => fkey(b'R', mods),+        Keysym::F4 => fkey(b'S', mods),+        Keysym::F5 => csi_tilde(15, mods),+        Keysym::F6 => csi_tilde(17, mods),+        Keysym::F7 => csi_tilde(18, mods),+        Keysym::F8 => csi_tilde(19, mods),+        Keysym::F9 => csi_tilde(20, mods),+        Keysym::F10 => csi_tilde(21, mods),+        Keysym::F11 => csi_tilde(23, mods),+        Keysym::F12 => csi_tilde(24, mods),++        // Everything else: the xkb-composed text (which already folds in Ctrl),+        // with Alt sending an ESC prefix (meta).+        _ => {+            let text = event.utf8.as_ref()?;+            if text.is_empty() {+                return None;+            }+            prefix_alt(text.as_bytes().to_vec(), mods)+        }+    };+    Some(seq)+}++fn prefix_alt(bytes: Vec<u8>, mods: Modifiers) -> Vec<u8> {+    if mods.alt {+        let mut out = Vec::with_capacity(bytes.len() + 1);+        out.push(0x1b);+        out.extend_from_slice(&bytes);+        out+    } else {+        bytes+    }+}++/// xterm modifier parameter: 1 + a bitfield of the held modifiers.+fn modifier_param(mods: Modifiers) -> u8 {+    1 + u8::from(mods.shift)+        + (u8::from(mods.alt) << 1)+        + (u8::from(mods.ctrl) << 2)+        + (u8::from(mods.logo) << 3)+}++/// Cursor/edit keys: `ESC [ X` (or `ESC O X` in application-cursor mode), and+/// `ESC [ 1 ; m X` when modifiers are held.+fn csi_letter(final_byte: u8, mods: Modifiers, app_cursor: bool) -> Vec<u8> {+    let m = modifier_param(mods);+    if m == 1 {+        vec![0x1b, if app_cursor { b'O' } else { b'[' }, final_byte]+    } else {+        let mut v = format!("\x1b[1;{m}").into_bytes();+        v.push(final_byte);+        v+    }+}++/// Keypad-style keys: `ESC [ n ~`, with `ESC [ n ; m ~` when modifiers are held.+fn csi_tilde(n: u8, mods: Modifiers) -> Vec<u8> {+    let m = modifier_param(mods);+    if m == 1 {+        format!("\x1b[{n}~").into_bytes()+    } else {+        format!("\x1b[{n};{m}~").into_bytes()+    }+}++fn fkey(final_byte: u8, mods: Modifiers) -> Vec<u8> {+    let m = modifier_param(mods);+    if m == 1 {+        vec![0x1b, b'O', final_byte]+    } else {+        let mut v = format!("\x1b[1;{m}").into_bytes();+        v.push(final_byte);+        v+    }+}++#[cfg(test)]+mod tests {+    use super::*;++    fn key(keysym: Keysym, utf8: Option<&str>) -> KeyEvent {+        KeyEvent {+            time: 0,+            raw_code: 0,+            keysym,+            utf8: utf8.map(str::to_owned),+        }+    }++    const NONE: Modifiers = Modifiers {+        ctrl: false,+        alt: false,+        shift: false,+        caps_lock: false,+        logo: false,+        num_lock: false,+    };++    #[test]+    fn plain_text_passes_through() {+        assert_eq!(+            encode(&key(Keysym::a, Some("a")), NONE, false),+            Some(b"a".to_vec())+        );+    }++    #[test]+    fn alt_prefixes_escape() {+        let mods = Modifiers { alt: true, ..NONE };+        assert_eq!(+            encode(&key(Keysym::a, Some("a")), mods, false),+            Some(b"\x1ba".to_vec())+        );+    }++    #[test]+    fn arrows_respect_application_mode() {+        assert_eq!(+            encode(&key(Keysym::Up, None), NONE, false),+            Some(b"\x1b[A".to_vec())+        );+        assert_eq!(+            encode(&key(Keysym::Up, None), NONE, true),+            Some(b"\x1bOA".to_vec())+        );+    }++    #[test]+    fn modified_arrow_uses_csi_param() {+        let mods = Modifiers { ctrl: true, ..NONE };+        assert_eq!(+            encode(&key(Keysym::Right, None), mods, false),+            Some(b"\x1b[1;5C".to_vec())+        );+    }++    #[test]+    fn special_keys() {+        assert_eq!(+            encode(&key(Keysym::Return, None), NONE, false),+            Some(b"\r".to_vec())+        );+        assert_eq!(+            encode(&key(Keysym::BackSpace, None), NONE, false),+            Some(b"\x7f".to_vec())+        );+        assert_eq!(+            encode(&key(Keysym::Delete, None), NONE, false),+            Some(b"\x1b[3~".to_vec())+        );+        assert_eq!(+            encode(&key(Keysym::F5, None), NONE, false),+            Some(b"\x1b[15~".to_vec())+        );+    }+}diff --git a/src/main.rs b/src/main.rsindex e9e3a15..d87afd6 100644--- a/src/main.rs+++ b/src/main.rs@@ -2,6 +2,7 @@  mod font; mod grid;+mod input; mod pty; mod render; mod vt;diff --git a/src/vt.rs b/src/vt.rsindex 1166164..e8fd8a4 100644--- a/src/vt.rs+++ b/src/vt.rs@@ -133,6 +133,7 @@ impl Term {                     }                 }                 (false, 4) => self.grid.set_insert(on),+                (true, 1) => self.grid.set_app_cursor(on),                 (true, 25) => self.grid.set_cursor_visible(on),                 // App-cursor/bracketed-paste/mouse/sync modes affect input and                 // rendering, which arrive with the keyboard and renderer.diff --git a/src/wayland.rs b/src/wayland.rsindex 098a803..cc2aa33 100644--- a/src/wayland.rs+++ b/src/wayland.rs@@ -19,12 +19,15 @@ use crate::render::Renderer; use crate::vt::Term; use smithay_client_toolkit::{     compositor::{CompositorHandler, CompositorState},-    delegate_compositor, delegate_output, delegate_registry, delegate_seat, delegate_shm,-    delegate_xdg_shell, delegate_xdg_window,+    delegate_compositor, delegate_keyboard, delegate_output, delegate_registry, delegate_seat,+    delegate_shm, delegate_xdg_shell, delegate_xdg_window,     output::{OutputHandler, OutputState},     registry::{ProvidesRegistryState, RegistryState},     registry_handlers,-    seat::{Capability, SeatHandler, SeatState},+    seat::{+        Capability, SeatHandler, SeatState,+        keyboard::{KeyEvent, KeyboardHandler, Keysym, Modifiers, RawModifiers, RepeatInfo},+    },     shell::{         WaylandSurface,         xdg::{@@ -37,7 +40,7 @@ use smithay_client_toolkit::{ use wayland_client::{     Connection, QueueHandle,     globals::registry_queue_init,-    protocol::{wl_output, wl_seat, wl_shm, wl_surface},+    protocol::{wl_keyboard, wl_output, wl_seat, wl_shm, wl_surface}, };  /// Default window size in pixels before the compositor suggests one.@@ -87,6 +90,8 @@ pub fn run() -> anyhow::Result<ExitCode> {         window,         renderer,         loop_handle: event_loop.handle(),+        keyboard: None,+        modifiers: Modifiers::default(),         // The PTY is spawned on the first configure, once the real window size         // is known, so the shell starts at the final size and is not hit by a         // startup SIGWINCH storm that makes it reprint its prompt.@@ -151,6 +156,8 @@ struct App {     window: Window,     renderer: Renderer,     loop_handle: LoopHandle<'static, App>,+    keyboard: Option<wl_keyboard::WlKeyboard>,+    modifiers: Modifiers,     /// `None` until the first configure spawns the shell.     session: Option<Session>,     /// Last title applied to the toplevel, to avoid redundant requests.@@ -225,6 +232,20 @@ impl App {         });     } +    /// Encode a key event and write it to the shell.+    fn send_key(&self, event: &KeyEvent) {+        let app_cursor = self+            .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_ref()+            && let Err(err) = write_all(session.pty.master(), &bytes)+        {+            tracing::warn!("write key to pty: {err}");+        }+    }+     /// After parsing child output: send any replies, sync the title, repaint.     fn after_feed(&mut self) {         let Some(session) = self.session.as_mut() else {@@ -404,10 +425,16 @@ impl SeatHandler for App {     fn new_capability(         &mut self,         _: &Connection,-        _: &QueueHandle<Self>,-        _: wl_seat::WlSeat,-        _: Capability,+        qh: &QueueHandle<Self>,+        seat: wl_seat::WlSeat,+        capability: Capability,     ) {+        if capability == Capability::Keyboard && self.keyboard.is_none() {+            match self.seat_state.get_keyboard(qh, &seat, None) {+                Ok(keyboard) => self.keyboard = Some(keyboard),+                Err(err) => tracing::warn!("get keyboard: {err}"),+            }+        }     }      fn remove_capability(@@ -415,13 +442,100 @@ impl SeatHandler for App {         _: &Connection,         _: &QueueHandle<Self>,         _: wl_seat::WlSeat,-        _: Capability,+        capability: Capability,     ) {+        if capability == Capability::Keyboard+            && let Some(keyboard) = self.keyboard.take()+        {+            keyboard.release();+        }     }      fn remove_seat(&mut self, _: &Connection, _: &QueueHandle<Self>, _: wl_seat::WlSeat) {} } +impl KeyboardHandler for App {+    fn enter(+        &mut self,+        _: &Connection,+        _: &QueueHandle<Self>,+        _: &wl_keyboard::WlKeyboard,+        _: &wl_surface::WlSurface,+        _: u32,+        _: &[u32],+        _: &[Keysym],+    ) {+        self.focused = true;+        self.dirty = true;+    }++    fn leave(+        &mut self,+        _: &Connection,+        _: &QueueHandle<Self>,+        _: &wl_keyboard::WlKeyboard,+        _: &wl_surface::WlSurface,+        _: u32,+    ) {+        self.focused = false;+        self.dirty = true;+    }++    fn press_key(+        &mut self,+        _: &Connection,+        _: &QueueHandle<Self>,+        _: &wl_keyboard::WlKeyboard,+        _: u32,+        event: KeyEvent,+    ) {+        self.send_key(&event);+    }++    fn repeat_key(+        &mut self,+        _: &Connection,+        _: &QueueHandle<Self>,+        _: &wl_keyboard::WlKeyboard,+        _: u32,+        event: KeyEvent,+    ) {+        self.send_key(&event);+    }++    fn release_key(+        &mut self,+        _: &Connection,+        _: &QueueHandle<Self>,+        _: &wl_keyboard::WlKeyboard,+        _: u32,+        _: KeyEvent,+    ) {+    }++    fn update_modifiers(+        &mut self,+        _: &Connection,+        _: &QueueHandle<Self>,+        _: &wl_keyboard::WlKeyboard,+        _: u32,+        modifiers: Modifiers,+        _: RawModifiers,+        _: u32,+    ) {+        self.modifiers = modifiers;+    }++    fn update_repeat_info(+        &mut self,+        _: &Connection,+        _: &QueueHandle<Self>,+        _: &wl_keyboard::WlKeyboard,+        _: RepeatInfo,+    ) {+    }+}+ impl OutputHandler for App {     fn output_state(&mut self) -> &mut OutputState {         &mut self.output_state@@ -445,6 +559,7 @@ delegate_compositor!(App); delegate_output!(App); delegate_shm!(App); delegate_seat!(App);+delegate_keyboard!(App); delegate_xdg_shell!(App); delegate_xdg_window!(App); delegate_registry!(App);