diff --git a/src/grid.rs b/src/grid.rs index d735107..78c0132 100644 --- a/src/grid.rs +++ b/src/grid.rs @@ -124,6 +124,8 @@ pub struct Grid { alt_saved: Option>>, 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.rs new file mode 100644 index 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> { + 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, mods: Modifiers) -> Vec { + 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 { + 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 { + 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 { + 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.rs index 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.rs index 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.rs index 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 { 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, + modifiers: Modifiers, /// `None` until the first configure spawns the shell. session: Option, /// 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, - _: wl_seat::WlSeat, - _: Capability, + qh: &QueueHandle, + 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, _: 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, _: wl_seat::WlSeat) {} } +impl KeyboardHandler for App { + fn enter( + &mut self, + _: &Connection, + _: &QueueHandle, + _: &wl_keyboard::WlKeyboard, + _: &wl_surface::WlSurface, + _: u32, + _: &[u32], + _: &[Keysym], + ) { + self.focused = true; + self.dirty = true; + } + + fn leave( + &mut self, + _: &Connection, + _: &QueueHandle, + _: &wl_keyboard::WlKeyboard, + _: &wl_surface::WlSurface, + _: u32, + ) { + self.focused = false; + self.dirty = true; + } + + fn press_key( + &mut self, + _: &Connection, + _: &QueueHandle, + _: &wl_keyboard::WlKeyboard, + _: u32, + event: KeyEvent, + ) { + self.send_key(&event); + } + + fn repeat_key( + &mut self, + _: &Connection, + _: &QueueHandle, + _: &wl_keyboard::WlKeyboard, + _: u32, + event: KeyEvent, + ) { + self.send_key(&event); + } + + fn release_key( + &mut self, + _: &Connection, + _: &QueueHandle, + _: &wl_keyboard::WlKeyboard, + _: u32, + _: KeyEvent, + ) { + } + + fn update_modifiers( + &mut self, + _: &Connection, + _: &QueueHandle, + _: &wl_keyboard::WlKeyboard, + _: u32, + modifiers: Modifiers, + _: RawModifiers, + _: u32, + ) { + self.modifiers = modifiers; + } + + fn update_repeat_info( + &mut self, + _: &Connection, + _: &QueueHandle, + _: &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);