diff --git a/app/src/terminal/component.rs b/app/src/terminal/component.rs index d096e92..153cd76 100644 --- a/app/src/terminal/component.rs +++ b/app/src/terminal/component.rs @@ -352,7 +352,10 @@ pub fn TerminalPanel() -> impl IntoView { return; }; - let Some(data) = map_key_to_terminal_input(&event) else { + let app_cursor = keydown_core + .try_with_untracked(|c| c.application_cursor()) + .unwrap_or(false); + let Some(data) = map_key_to_terminal_input(&event, app_cursor) else { return; }; @@ -431,18 +434,20 @@ pub fn TerminalPanel() -> impl IntoView { return; }; + let app_cursor = wheel_core + .try_with_untracked(|c| c.application_cursor()) + .unwrap_or(false); + let rows = wheel_delta_to_rows(event.delta_y(), event.delta_mode()); if rows == 0 { return; } - // Up (negative deltaY) → ESC[A, down → ESC[B, repeated per row. - let seq = if event.delta_y() < 0.0 { - "\u{1b}[A" - } else { - "\u{1b}[B" - }; - let data = seq.repeat(rows); + // Up (negative deltaY) → arrow up, down → arrow down, repeated per + // row. Use SS3 form when the app is in application-cursor mode + // so the sequence matches what the app expects. + let final_byte = if event.delta_y() < 0.0 { 'A' } else { 'B' }; + let data = cursor_seq(final_byte, app_cursor).repeat(rows); event.prevent_default(); send_terminal_message( @@ -1116,32 +1121,94 @@ fn prepare_paste(raw: &str, bracketed: bool) -> String { } } -fn map_key_to_terminal_input(event: &KeyboardEvent) -> Option { - let key = event.key(); +/// Build a cursor/nav key sequence, SS3 (`ESC O `) when the app is in +/// application-cursor mode, else CSI (`ESC [ `). +fn cursor_seq(final_byte: char, app_cursor: bool) -> String { + if app_cursor { + format!("\u{1b}O{final_byte}") + } else { + format!("\u{1b}[{final_byte}") + } +} - if event.ctrl_key() && !event.alt_key() { - return match key.as_str() { - "c" | "C" => Some("\u{3}".to_owned()), - "d" | "D" => Some("\u{4}".to_owned()), - "l" | "L" => Some("\u{c}".to_owned()), - _ => None, +/// Pure key-table: map a key + modifier state + application-cursor flag to +/// the bytes a terminal would receive for the same key. Split out from +/// `map_key_to_terminal_input` so the entire table is unit-testable without +/// constructing a `KeyboardEvent` (which isn't easy in native tests). +fn key_to_bytes( + key: &str, + ctrl: bool, + alt: bool, + meta: bool, + app_cursor: bool, +) -> Option { + if ctrl && !alt { + let c = key.chars().next()?; + let code = match c { + 'a'..='z' => (c.to_ascii_uppercase() as u8) & 0x1f, + 'A'..='Z' => (c as u8) & 0x1f, + '@' => 0x00, + '[' => 0x1b, + '\\' => 0x1c, + ']' => 0x1d, + '^' => 0x1e, + '_' => 0x1f, + _ => return None, }; + return Some((code as char).to_string()); } - match key.as_str() { + if let (true, Some(c)) = (alt && !ctrl && !meta, key.chars().next()) { + return Some(format!("\u{1b}{c}")); + } + + match key { "Enter" => Some("\r".to_owned()), "Backspace" => Some("\u{7f}".to_owned()), "Tab" => Some("\t".to_owned()), - "ArrowUp" => Some("\u{1b}[A".to_owned()), - "ArrowDown" => Some("\u{1b}[B".to_owned()), - "ArrowRight" => Some("\u{1b}[C".to_owned()), - "ArrowLeft" => Some("\u{1b}[D".to_owned()), "Escape" => Some("\u{1b}".to_owned()), - _ if key.chars().count() == 1 && !event.meta_key() => Some(key), + "ArrowUp" => Some(cursor_seq('A', app_cursor)), + "ArrowDown" => Some(cursor_seq('B', app_cursor)), + "ArrowRight" => Some(cursor_seq('C', app_cursor)), + "ArrowLeft" => Some(cursor_seq('D', app_cursor)), + "Home" => Some(cursor_seq('H', app_cursor)), + "End" => Some(cursor_seq('F', app_cursor)), + "Insert" => Some("\u{1b}[2~".to_owned()), + "Delete" => Some("\u{1b}[3~".to_owned()), + "PageUp" => Some("\u{1b}[5~".to_owned()), + "PageDown" => Some("\u{1b}[6~".to_owned()), + "F1" => Some("\u{1b}OP".to_owned()), + "F2" => Some("\u{1b}OQ".to_owned()), + "F3" => Some("\u{1b}OR".to_owned()), + "F4" => Some("\u{1b}OS".to_owned()), + "F5" => Some("\u{1b}[15~".to_owned()), + "F6" => Some("\u{1b}[17~".to_owned()), + "F7" => Some("\u{1b}[18~".to_owned()), + "F8" => Some("\u{1b}[19~".to_owned()), + "F9" => Some("\u{1b}[20~".to_owned()), + "F10" => Some("\u{1b}[21~".to_owned()), + "F11" => Some("\u{1b}[23~".to_owned()), + "F12" => Some("\u{1b}[24~".to_owned()), + _ if key.chars().count() == 1 && !meta => Some(key.to_owned()), _ => None, } } +/// Map a browser keyboard event to the bytes a terminal would receive for +/// the same key. Thin wrapper around `key_to_bytes`. +fn map_key_to_terminal_input( + event: &KeyboardEvent, + app_cursor: bool, +) -> Option { + key_to_bytes( + &event.key(), + event.ctrl_key(), + event.alt_key(), + event.meta_key(), + app_cursor, + ) +} + #[cfg(test)] mod tests { use super::*; @@ -1221,6 +1288,89 @@ mod tests { assert!(selection_rects(None, 0, 30, cols, cw).is_empty()); } + #[test] + fn key_to_bytes_table() { + // --- basic printable / control keys --- + assert_eq!(key_to_bytes("a", false, false, false, false).as_deref(), Some("a")); + assert_eq!(key_to_bytes("Z", false, false, false, false).as_deref(), Some("Z")); + assert_eq!(key_to_bytes("Enter", false, false, false, false).as_deref(), Some("\r")); + assert_eq!(key_to_bytes("Backspace", false, false, false, false).as_deref(), Some("\u{7f}")); + assert_eq!(key_to_bytes("Tab", false, false, false, false).as_deref(), Some("\t")); + assert_eq!(key_to_bytes("Escape", false, false, false, false).as_deref(), Some("\u{1b}")); + + // --- application-cursor mode toggles arrows / Home / End to SS3 --- + let up_csi = key_to_bytes("ArrowUp", false, false, false, false).unwrap(); + assert_eq!(up_csi, "\u{1b}[A"); + let up_ss3 = key_to_bytes("ArrowUp", false, false, false, true).unwrap(); + assert_eq!(up_ss3, "\u{1b}OA"); + let home_csi = key_to_bytes("Home", false, false, false, false).unwrap(); + assert_eq!(home_csi, "\u{1b}[H"); + let home_ss3 = key_to_bytes("Home", false, false, false, true).unwrap(); + assert_eq!(home_ss3, "\u{1b}OH"); + // Arrows + Home/End cover all four directions. + for (key, normal, alt) in [ + ("ArrowUp", 'A', 'A'), + ("ArrowDown", 'B', 'B'), + ("ArrowRight", 'C', 'C'), + ("ArrowLeft", 'D', 'D'), + ("Home", 'H', 'H'), + ("End", 'F', 'F'), + ] { + let (csi, ss3) = (normal, alt); + assert_eq!(key_to_bytes(key, false, false, false, false).unwrap(), format!("\u{1b}[{csi}")); + assert_eq!(key_to_bytes(key, false, false, false, true).unwrap(), format!("\u{1b}O{ss3}")); + } + + // --- paging / editing keys (always CSI) --- + assert_eq!(key_to_bytes("Insert", false, false, false, false).as_deref(), Some("\u{1b}[2~")); + assert_eq!(key_to_bytes("Delete", false, false, false, false).as_deref(), Some("\u{1b}[3~")); + assert_eq!(key_to_bytes("PageUp", false, false, false, false).as_deref(), Some("\u{1b}[5~")); + assert_eq!(key_to_bytes("PageDown", false, false, false, false).as_deref(), Some("\u{1b}[6~")); + // Paging does not change with app-cursor mode. + assert_eq!(key_to_bytes("PageUp", false, false, false, true).as_deref(), Some("\u{1b}[5~")); + + // --- function keys (F1-F4 SS3, F5-F12 CSI~ with 16/22 gaps) --- + for (key, expected) in [ + ("F1", "\u{1b}OP"), ("F2", "\u{1b}OQ"), ("F3", "\u{1b}OR"), ("F4", "\u{1b}OS"), + ("F5", "\u{1b}[15~"), ("F6", "\u{1b}[17~"), ("F7", "\u{1b}[18~"), + ("F8", "\u{1b}[19~"), ("F9", "\u{1b}[20~"), ("F10", "\u{1b}[21~"), + ("F11", "\u{1b}[23~"), ("F12", "\u{1b}[24~"), + ] { + assert_eq!(key_to_bytes(key, false, false, false, false).as_deref(), + Some(expected), "key {key}"); + } + + // --- Ctrl letters: A=0x01 .. Z=0x1a --- + for (letter, code) in [ + ("a", '\u{1}'), ("c", '\u{3}'), ("d", '\u{4}'), + ("l", '\u{c}'), ("z", '\u{1a}'), ("A", '\u{1}'), + ] { + assert_eq!( + key_to_bytes(letter, true, false, false, false).as_deref(), + Some(&code.to_string()[..]), + "ctrl+{letter}" + ); + } + // Ctrl+@ (NUL), Ctrl+[ (ESC), Ctrl+\, Ctrl+], Ctrl+^, Ctrl+_ + assert_eq!(key_to_bytes("@", true, false, false, false).as_deref(), Some("\u{0}")); + assert_eq!(key_to_bytes("[", true, false, false, false).as_deref(), Some("\u{1b}")); + assert_eq!(key_to_bytes("\\", true, false, false, false).as_deref(), Some("\u{1c}")); + assert_eq!(key_to_bytes("]", true, false, false, false).as_deref(), Some("\u{1d}")); + assert_eq!(key_to_bytes("^", true, false, false, false).as_deref(), Some("\u{1e}")); + assert_eq!(key_to_bytes("_", true, false, false, false).as_deref(), Some("\u{1f}")); + // Ctrl+Alt falls through to the printable-letter fallback (most + // terminals forward the bare char; few apps use this combo). + assert_eq!(key_to_bytes("c", true, true, false, false).as_deref(), Some("c")); + + // --- Alt+char → ESC prefix --- + assert_eq!(key_to_bytes("a", false, true, false, false).as_deref(), Some("\u{1b}a")); + assert_eq!(key_to_bytes("x", false, true, false, false).as_deref(), Some("\u{1b}x")); + // Alt+Ctrl+char falls through to the printable-letter fallback too. + assert_eq!(key_to_bytes("a", true, true, false, false).as_deref(), Some("a")); + // Meta suppresses passthrough. + assert_eq!(key_to_bytes("a", false, false, true, false), None); + } + #[test] fn prepare_paste_normalizes_newlines_and_wraps_when_bracketed() { // Plain paste: CRLF and LF both become CR, no wrapping. diff --git a/app/src/terminal/core.rs b/app/src/terminal/core.rs index c2f098b..a8b8175 100644 --- a/app/src/terminal/core.rs +++ b/app/src/terminal/core.rs @@ -287,6 +287,12 @@ impl TerminalCore { pub fn bracketed_paste(&self) -> bool { self.parser.screen().bracketed_paste() } + + /// DECCKM (application-cursor-keys) mode. When enabled, arrow / Home / End + /// keys are sent as SS3 (`ESC O X`) instead of CSI (`ESC [ X`). + pub fn application_cursor(&self) -> bool { + self.parser.screen().application_cursor() + } } impl Default for TerminalCore {