feat(terminal): full function keys + application-cursor mode (P1)
Fills out keyboard input per TERMINAL_ROADMAP.md P1: F1–F12, Home/End, Insert/Delete, PageUp/PageDown, the full Ctrl-A..Z range, and Alt+key. Arrows / Home / End now respect DECCKM (application-cursor mode) — emitted as SS3 (ESC O X) when the app enables it, else CSI (ESC [ X). The alt-screen wheel-paging handler uses the same logic so it matches the app's expectations. core.rs: - application_cursor() proxy for vt100's Screen::application_cursor(). component.rs: - cursor_seq(final_byte, app_cursor) helper: SS3 vs CSI prefix. - key_to_bytes(key, ctrl, alt, meta, app_cursor) — pure table for the whole key map, unit-testable without constructing a KeyboardEvent. F1–F4 SS3, F5–F12 CSI~ (with the real xterm 16/22 gaps), paging keys always CSI~, Ctrl A..Z → 0x01..0x1a (plus @[\\]^_ variants), Alt+char → ESC prefix. map_key_to_terminal_input becomes a thin wrapper. - handle_keydown reads app_cursor from core_signal before mapping. - handle_wheel reads app_cursor and uses cursor_seq for arrow paging. Adds key_to_bytes_table unit test (17 terminal tests total). Clean clippy. Note: 0x03/0x1a/0x1c (Ctrl-C / Ctrl-Z / Ctrl-\) reach the PTY unchanged and are interpreted by the shell's stty as SIGINT / SIGTSTP / SIGQUIT — this is real-terminal behavior (same as xterm / SSH into a real box), not a bug, and is preserved on purpose. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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<String> {
|
||||
let key = event.key();
|
||||
/// Build a cursor/nav key sequence, SS3 (`ESC O <b>`) when the app is in
|
||||
/// application-cursor mode, else CSI (`ESC [ <b>`).
|
||||
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<String> {
|
||||
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<String> {
|
||||
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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user