diff --git a/apps/lumbridge/src/input.rs b/apps/lumbridge/src/input.rs new file mode 100644 index 0000000..2e4bab8 --- /dev/null +++ b/apps/lumbridge/src/input.rs @@ -0,0 +1,160 @@ +//! Translating a keystroke the window system reported into bytes a PTY accepts. +//! +//! The renderer knows what was pressed and the terminal engine knows what a +//! shell expects; this is the table between them, and it is deliberately +//! written in terms neither of them owns. A key arrives here as three plain +//! values — the key name, the printable character the platform composed from +//! it if there was one, and a set of modifier flags — so the whole mapping can +//! be exercised by naming those three values, with no window open and no event +//! loop running. +//! +//! That matters more here than almost anywhere else in the shell, because this +//! is where the terminal has already been badly wrong once. Requiring a +//! printable character before anything reached the PTY silently swallowed every +//! control byte: ctrl-c, ctrl-d, ctrl-a, ctrl-r and ctrl-k, which between them +//! are most of what makes a terminal a terminal. Nothing crashed, nothing +//! logged, the shell just ignored you. Every rule below that looks like an edge +//! case is one of those failures, and each has a test named after the mistake +//! rather than after the function. +//! +//! What stays in the view layer is the one step that cannot be described +//! without gpui: reading `KeyDownEvent.keystroke.modifiers` into +//! [`KeyModifiers`]. That is a field-by-field copy of a renderer type, so it +//! lives beside the renderer and hands the result down here. + +use lumbridge_terminal::{KeyModifiers, TerminalKey, TerminalKeyEvent, TerminalScroll}; + +pub(crate) fn terminal_key_from_parts( + key: &str, + key_char: Option<&str>, + modifiers: KeyModifiers, +) -> Option { + let key = match key { + "enter" => TerminalKey::Enter, + "backspace" => TerminalKey::Backspace, + "tab" => TerminalKey::Tab, + "escape" => TerminalKey::Escape, + "up" => TerminalKey::Up, + "down" => TerminalKey::Down, + "left" => TerminalKey::Left, + "right" => TerminalKey::Right, + "home" => TerminalKey::Home, + "end" => TerminalKey::End, + "insert" => TerminalKey::Insert, + "delete" => TerminalKey::Delete, + "pageup" => TerminalKey::PageUp, + "pagedown" => TerminalKey::PageDown, + function + if function.strip_prefix('f').is_some_and(|suffix| { + !suffix.is_empty() && suffix.bytes().all(|byte| byte.is_ascii_digit()) + }) => + { + let number = function.strip_prefix('f')?.parse::().ok()?; + TerminalKey::Function(number) + } + _ if !modifiers.contains(KeyModifiers::PLATFORM) => { + // GPUI reports no `key_char` for a control chord, because ctrl-k + // produces no printable character. Taking `key_char` alone meant + // every control byte was dropped before it reached the PTY — not + // just ctrl-k, but ctrl-c, ctrl-d, ctrl-a and ctrl-r as well, which + // is most of what makes a terminal usable. Fall back to the key + // name when it names a single character; the engine applies the + // control transformation. + let text = key_char + .filter(|text| !text.is_empty()) + .map(str::to_owned) + .or_else(|| (key.chars().count() == 1).then(|| key.to_owned()))?; + TerminalKey::Text(text) + } + _ => return None, + }; + Some(TerminalKeyEvent { key, modifiers }) +} + +pub(crate) fn terminal_scroll_from_parts( + key: &str, + modifiers: KeyModifiers, +) -> Option { + if !modifiers.contains(KeyModifiers::SHIFT) { + return None; + } + match key { + "pageup" => Some(TerminalScroll::PageUp), + "pagedown" => Some(TerminalScroll::PageDown), + "home" => Some(TerminalScroll::Top), + "end" => Some(TerminalScroll::Bottom), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::{terminal_key_from_parts, terminal_scroll_from_parts}; + use lumbridge_terminal::{KeyModifiers, TerminalKey, TerminalScroll}; + + #[test] + fn maps_named_and_composed_gpui_keys_to_terminal_input() { + let enter = terminal_key_from_parts("enter", None, KeyModifiers::default()).unwrap(); + assert_eq!(enter.key, TerminalKey::Enter); + + let literal_f = terminal_key_from_parts("f", Some("f"), KeyModifiers::default()).unwrap(); + assert_eq!(literal_f.key, TerminalKey::Text("f".into())); + + let function = terminal_key_from_parts("f12", None, KeyModifiers::default()).unwrap(); + assert_eq!(function.key, TerminalKey::Function(12)); + + let composed = terminal_key_from_parts("é", Some("é"), KeyModifiers::default()).unwrap(); + assert_eq!(composed.key, TerminalKey::Text("é".into())); + } + + /// The regression this test exists for: a control chord carries no + /// printable character, so requiring one dropped every control byte. + #[test] + fn a_control_chord_still_reaches_the_terminal_without_a_key_char() { + for letter in ["k", "c", "d", "a", "r"] { + let event = terminal_key_from_parts(letter, None, KeyModifiers::CONTROL) + .unwrap_or_else(|| panic!("ctrl-{letter} must reach the terminal")); + assert_eq!(event.key, TerminalKey::Text(letter.to_owned())); + assert!(event.modifiers.contains(KeyModifiers::CONTROL)); + } + } + + #[test] + fn a_printable_character_still_wins_over_the_key_name() { + // Shift-a arrives as key "a" with key_char "A"; the character is what + // the terminal should receive. + let event = terminal_key_from_parts("a", Some("A"), KeyModifiers::SHIFT).expect("shift-a"); + assert_eq!(event.key, TerminalKey::Text("A".to_owned())); + } + + #[test] + fn a_named_key_with_no_character_is_still_refused() { + // Multi-character key names that are not in the table above are not + // text and must not be sent as though they were. + assert!(terminal_key_from_parts("capslock", None, KeyModifiers::default()).is_none()); + assert!(terminal_key_from_parts("shift", None, KeyModifiers::default()).is_none()); + } + + #[test] + fn reserves_platform_text_shortcuts_for_the_workspace() { + assert!(terminal_key_from_parts("c", Some("c"), KeyModifiers::PLATFORM).is_none()); + let control = terminal_key_from_parts("c", Some("c"), KeyModifiers::CONTROL).unwrap(); + assert_eq!(control.key, TerminalKey::Text("c".into())); + } + + #[test] + fn shift_navigation_controls_scrollback_without_reaching_the_pty() { + assert_eq!( + terminal_scroll_from_parts("pageup", KeyModifiers::SHIFT), + Some(TerminalScroll::PageUp) + ); + assert_eq!( + terminal_scroll_from_parts("end", KeyModifiers::SHIFT), + Some(TerminalScroll::Bottom) + ); + assert_eq!( + terminal_scroll_from_parts("pageup", KeyModifiers::default()), + None + ); + } +} diff --git a/apps/lumbridge/src/main.rs b/apps/lumbridge/src/main.rs index 6c1d4e5..d71eb20 100644 --- a/apps/lumbridge/src/main.rs +++ b/apps/lumbridge/src/main.rs @@ -1,5 +1,6 @@ mod attention; mod geometry; +mod input; mod keymap; mod panel_registry; mod settings_view; @@ -23,8 +24,7 @@ use lumbridge_runtime::{ use lumbridge_storage::Store; use lumbridge_terminal::{ KeyModifiers, TerminalCellStyle, TerminalColor, TerminalCursorShape, TerminalDimensions, - TerminalEngine, TerminalEngineOptions, TerminalKey, TerminalKeyEvent, TerminalNamedColor, - TerminalScroll, TerminalSnapshot, + TerminalEngine, TerminalEngineOptions, TerminalNamedColor, TerminalScroll, TerminalSnapshot, }; use lumbridge_ui_fixture::{ ActionOutcome, OutputSource, PaneId as FixturePaneId, PaneStatus, ShellAction, ShellModel, @@ -32,6 +32,8 @@ use lumbridge_ui_fixture::{ }; use attention::{Attention, AttentionKind, AttentionSignal, AttentionSource}; +use input::{terminal_key_from_parts, terminal_scroll_from_parts}; + use geometry::{ APP_FOOTER_HEIGHT, CellMetrics, TERMINAL_CELL_HEIGHT, TERMINAL_CELL_WIDTH, WindowSize, clamp_to_u16, terminal_dimensions_for_window, visible_panel_count, visible_panel_range, @@ -3499,66 +3501,6 @@ fn key_modifiers(event: &KeyDownEvent) -> KeyModifiers { modifiers } -fn terminal_key_from_parts( - key: &str, - key_char: Option<&str>, - modifiers: KeyModifiers, -) -> Option { - let key = match key { - "enter" => TerminalKey::Enter, - "backspace" => TerminalKey::Backspace, - "tab" => TerminalKey::Tab, - "escape" => TerminalKey::Escape, - "up" => TerminalKey::Up, - "down" => TerminalKey::Down, - "left" => TerminalKey::Left, - "right" => TerminalKey::Right, - "home" => TerminalKey::Home, - "end" => TerminalKey::End, - "insert" => TerminalKey::Insert, - "delete" => TerminalKey::Delete, - "pageup" => TerminalKey::PageUp, - "pagedown" => TerminalKey::PageDown, - function - if function.strip_prefix('f').is_some_and(|suffix| { - !suffix.is_empty() && suffix.bytes().all(|byte| byte.is_ascii_digit()) - }) => - { - let number = function.strip_prefix('f')?.parse::().ok()?; - TerminalKey::Function(number) - } - _ if !modifiers.contains(KeyModifiers::PLATFORM) => { - // GPUI reports no `key_char` for a control chord, because ctrl-k - // produces no printable character. Taking `key_char` alone meant - // every control byte was dropped before it reached the PTY — not - // just ctrl-k, but ctrl-c, ctrl-d, ctrl-a and ctrl-r as well, which - // is most of what makes a terminal usable. Fall back to the key - // name when it names a single character; the engine applies the - // control transformation. - let text = key_char - .filter(|text| !text.is_empty()) - .map(str::to_owned) - .or_else(|| (key.chars().count() == 1).then(|| key.to_owned()))?; - TerminalKey::Text(text) - } - _ => return None, - }; - Some(TerminalKeyEvent { key, modifiers }) -} - -fn terminal_scroll_from_parts(key: &str, modifiers: KeyModifiers) -> Option { - if !modifiers.contains(KeyModifiers::SHIFT) { - return None; - } - match key { - "pageup" => Some(TerminalScroll::PageUp), - "pagedown" => Some(TerminalScroll::PageDown), - "home" => Some(TerminalScroll::Top), - "end" => Some(TerminalScroll::Bottom), - _ => None, - } -} - fn live_pty_script(seed: Option) -> &'static str { match seed { Some(SeedPane::CodexRuntime) => { @@ -3628,77 +3570,9 @@ fn main() { #[cfg(test)] mod tests { use super::{ - KeyModifiers, TerminalEngine, TerminalEngineOptions, TerminalKey, TerminalScroll, - indexed_terminal_color, terminal_key_from_parts, terminal_paint_rows, - terminal_scroll_from_parts, + TerminalEngine, TerminalEngineOptions, indexed_terminal_color, terminal_paint_rows, }; - #[test] - fn maps_named_and_composed_gpui_keys_to_terminal_input() { - let enter = terminal_key_from_parts("enter", None, KeyModifiers::default()).unwrap(); - assert_eq!(enter.key, TerminalKey::Enter); - - let literal_f = terminal_key_from_parts("f", Some("f"), KeyModifiers::default()).unwrap(); - assert_eq!(literal_f.key, TerminalKey::Text("f".into())); - - let function = terminal_key_from_parts("f12", None, KeyModifiers::default()).unwrap(); - assert_eq!(function.key, TerminalKey::Function(12)); - - let composed = terminal_key_from_parts("é", Some("é"), KeyModifiers::default()).unwrap(); - assert_eq!(composed.key, TerminalKey::Text("é".into())); - } - - /// The regression this test exists for: a control chord carries no - /// printable character, so requiring one dropped every control byte. - #[test] - fn a_control_chord_still_reaches_the_terminal_without_a_key_char() { - for letter in ["k", "c", "d", "a", "r"] { - let event = terminal_key_from_parts(letter, None, KeyModifiers::CONTROL) - .unwrap_or_else(|| panic!("ctrl-{letter} must reach the terminal")); - assert_eq!(event.key, TerminalKey::Text(letter.to_owned())); - assert!(event.modifiers.contains(KeyModifiers::CONTROL)); - } - } - - #[test] - fn a_printable_character_still_wins_over_the_key_name() { - // Shift-a arrives as key "a" with key_char "A"; the character is what - // the terminal should receive. - let event = terminal_key_from_parts("a", Some("A"), KeyModifiers::SHIFT).expect("shift-a"); - assert_eq!(event.key, TerminalKey::Text("A".to_owned())); - } - - #[test] - fn a_named_key_with_no_character_is_still_refused() { - // Multi-character key names that are not in the table above are not - // text and must not be sent as though they were. - assert!(terminal_key_from_parts("capslock", None, KeyModifiers::default()).is_none()); - assert!(terminal_key_from_parts("shift", None, KeyModifiers::default()).is_none()); - } - - #[test] - fn reserves_platform_text_shortcuts_for_the_workspace() { - assert!(terminal_key_from_parts("c", Some("c"), KeyModifiers::PLATFORM).is_none()); - let control = terminal_key_from_parts("c", Some("c"), KeyModifiers::CONTROL).unwrap(); - assert_eq!(control.key, TerminalKey::Text("c".into())); - } - - #[test] - fn shift_navigation_controls_scrollback_without_reaching_the_pty() { - assert_eq!( - terminal_scroll_from_parts("pageup", KeyModifiers::SHIFT), - Some(TerminalScroll::PageUp) - ); - assert_eq!( - terminal_scroll_from_parts("end", KeyModifiers::SHIFT), - Some(TerminalScroll::Bottom) - ); - assert_eq!( - terminal_scroll_from_parts("pageup", KeyModifiers::default()), - None - ); - } - #[test] fn terminal_paint_rows_merge_runs_and_preserve_cursor_boundary() { let mut terminal = TerminalEngine::new(TerminalEngineOptions::default());