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:
zhangheng
2026-06-17 20:37:58 +08:00
parent 8863977c0d
commit ef4396247c
2 changed files with 178 additions and 22 deletions

View File

@@ -352,7 +352,10 @@ pub fn TerminalPanel() -> impl IntoView {
return; 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; return;
}; };
@@ -431,18 +434,20 @@ pub fn TerminalPanel() -> impl IntoView {
return; 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()); let rows = wheel_delta_to_rows(event.delta_y(), event.delta_mode());
if rows == 0 { if rows == 0 {
return; return;
} }
// Up (negative deltaY) → ESC[A, down → ESC[B, repeated per row. // Up (negative deltaY) → arrow up, down → arrow down, repeated per
let seq = if event.delta_y() < 0.0 { // row. Use SS3 form when the app is in application-cursor mode
"\u{1b}[A" // so the sequence matches what the app expects.
} else { let final_byte = if event.delta_y() < 0.0 { 'A' } else { 'B' };
"\u{1b}[B" let data = cursor_seq(final_byte, app_cursor).repeat(rows);
};
let data = seq.repeat(rows);
event.prevent_default(); event.prevent_default();
send_terminal_message( 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> { /// Build a cursor/nav key sequence, SS3 (`ESC O <b>`) when the app is in
let key = event.key(); /// 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() { /// Pure key-table: map a key + modifier state + application-cursor flag to
return match key.as_str() { /// the bytes a terminal would receive for the same key. Split out from
"c" | "C" => Some("\u{3}".to_owned()), /// `map_key_to_terminal_input` so the entire table is unit-testable without
"d" | "D" => Some("\u{4}".to_owned()), /// constructing a `KeyboardEvent` (which isn't easy in native tests).
"l" | "L" => Some("\u{c}".to_owned()), fn key_to_bytes(
_ => None, 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()), "Enter" => Some("\r".to_owned()),
"Backspace" => Some("\u{7f}".to_owned()), "Backspace" => Some("\u{7f}".to_owned()),
"Tab" => Some("\t".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()), "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, _ => 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -1221,6 +1288,89 @@ mod tests {
assert!(selection_rects(None, 0, 30, cols, cw).is_empty()); 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] #[test]
fn prepare_paste_normalizes_newlines_and_wraps_when_bracketed() { fn prepare_paste_normalizes_newlines_and_wraps_when_bracketed() {
// Plain paste: CRLF and LF both become CR, no wrapping. // Plain paste: CRLF and LF both become CR, no wrapping.

View File

@@ -287,6 +287,12 @@ impl TerminalCore {
pub fn bracketed_paste(&self) -> bool { pub fn bracketed_paste(&self) -> bool {
self.parser.screen().bracketed_paste() 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 { impl Default for TerminalCore {