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;
|
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.
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user