fix(terminal): robust Ctrl/paste handling + alt-screen wheel coalescing

Keyboard input fixes addressing reported regressions:

- Pressing Ctrl alone no longer sends SIGINT: key_to_bytes now guards on
  a single-character key, so modifier keys "Control"/"Shift"/"Meta" are
  not misread as Ctrl-C (0x03).
- Ctrl-Shift-C copy restored: the copy branch runs before the generic
  Ctrl+letter suppression so the clipboard user-gesture stays intact.
- Ctrl+C copies when a selection exists (VS Code / Windows Terminal
  behaviour); without a selection it still sends SIGINT.
- Ctrl+V / Ctrl-Shift+V paste via navigator.clipboard.readText() —
  reliable across browsers that don't fire paste on a non-contenteditable
  div; on:paste retained for right-click / menu paste.
- Bare Ctrl+letter (no Shift/Alt/Meta) is preventDefault'd so the browser
  stops intercepting Ctrl-R/F/A etc. (Ctrl-W/T/N stay browser-reserved
  and cannot be intercepted by any web page).
- Filter Ctrl-Z (0x1a) / Ctrl-\ (0x1c) so stty SIGTSTP/SIGQUIT don't
  surprise-kill the foreground process; Ctrl-C (0x03) is kept.

Alt-screen wheel:

- Coalesce multiple wheel events per animation frame into a single
  arrow-key batch (Rc<Cell> accumulator + requestAnimationFrame), so one
  trackpad swipe pages at most one viewport instead of flying past.

Tests: 18/18 pass, clippy clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
zhangheng
2026-06-18 14:32:46 +08:00
parent 511b212334
commit c3ddd9df4f
2 changed files with 223 additions and 62 deletions

View File

@@ -27,7 +27,7 @@
| 宽字符 / 组合字符网格对齐 | `core.rs` `TerminalSegment.cols` | | 宽字符 / 组合字符网格对齐 | `core.rs` `TerminalSegment.cols` |
| resize / reflow | `core.rs::resize` | | resize / reflow | `core.rs::resize` |
| `clear`(含 `ESC[3J` 清 scrollback | `core.rs::contains_erase_scrollback` | | `clear`(含 `ESC[3J` 清 scrollback | `core.rs::contains_erase_scrollback` |
| alt-screen 滚轮 → 方向键翻页 | `component.rs::handle_wheel` | | alt-screen 滚轮 → 方向键翻页rAF 合并多次 wheel 事件,单次滑动最多一屏) | `component.rs::handle_wheel` |
| 基础按键Ctrl-C/D/L、方向键、Enter/Tab/Esc/Backspace | `component.rs::map_key_to_terminal_input` | | 基础按键Ctrl-C/D/L、方向键、Enter/Tab/Esc/Backspace | `component.rs::map_key_to_terminal_input` |
| 完整键表F1F12/Home/End/PageUp/Down/Ctrl AZ/Alt-key | `component.rs::key_to_bytes` | | 完整键表F1F12/Home/End/PageUp/Down/Ctrl AZ/Alt-key | `component.rs::key_to_bytes` |
| application-cursor 模式DECCKM方向键切 SS3 | `component.rs::cursor_seq` + `TerminalCore::application_cursor` | | application-cursor 模式DECCKM方向键切 SS3 | `component.rs::cursor_seq` + `TerminalCore::application_cursor` |
@@ -52,7 +52,8 @@
- 选区按**逻辑行列坐标**跟踪(`Selection { anchor, head }`),鼠标 `mousedown/move/up` 拖拽更新;`point_to_cell` 做像素→单元格换算(含 padding/scroll - 选区按**逻辑行列坐标**跟踪(`Selection { anchor, head }`),鼠标 `mousedown/move/up` 拖拽更新;`point_to_cell` 做像素→单元格换算(含 padding/scroll
- 高亮 overlay`selection_rects` 算每行矩形,渲染在虚拟滚动层内(共享 translateY跟随滚动不拆分文本段。 - 高亮 overlay`selection_rects` 算每行矩形,渲染在虚拟滚动层内(共享 translateY跟随滚动不拆分文本段。
- 复制取 `TerminalCore::selection_text(start, end)` —— 直接从 `history_rows + screen_cache` 数据模型按行列取文本,**跨滚动、输出流动时都正确**(不依赖 DOM - 复制取 `TerminalCore::selection_text(start, end)` —— 直接从 `history_rows + screen_cache` 数据模型按行列取文本,**跨滚动、输出流动时都正确**(不依赖 DOM
- **Ctrl-Shift-C** 复制Ctrl-C 保持 SIGINT打字清除选区 - **Ctrl-Shift-C** 复制Ctrl-C 保持 SIGINT**当选区存在时 Ctrl+C 优先复制**(与 VS Code / Windows Terminal 一致),无选区时才发送 SIGINT
- 打字清除选区。
- 实现于 `component.rs``Selection`/`selection_rects`/`point_to_cell`/鼠标处理)+ `core.rs::selection_text` + `core.rs::TerminalRow::text_in_cols`,均有单元测试。 - 实现于 `component.rs``Selection`/`selection_rects`/`point_to_cell`/鼠标处理)+ `core.rs::selection_text` + `core.rs::TerminalRow::text_in_cols`,均有单元测试。
3. **快捷键**Ctrl-Shift-C 复制 / Ctrl-V 粘贴。 3. **快捷键**Ctrl-Shift-C 复制 / Ctrl-V 粘贴。
@@ -71,14 +72,14 @@
- Ctrl A..Z → 0x01..0x1a;以及 `@[\\]^_` 变体(→ 0x00/0x1b/0x1c/0x1d/0x1e/0x1f - Ctrl A..Z → 0x01..0x1a;以及 `@[\\]^_` 变体(→ 0x00/0x1b/0x1c/0x1d/0x1e/0x1f
- Alt+char → `ESC char`(标准 meta-as-ESC 编码)。 - Alt+char → `ESC char`(标准 meta-as-ESC 编码)。
2. **application-cursor 模式DECCKM**`cursor_seq(byte, app_cursor)` 助手;为真时方向键 / Home / End 发 SS3`ESC O X`),否则 CSI`ESC [ X`)。`handle_keydown``handle_wheel` 都据此切换。 2. **application-cursor 模式DECCKM**`cursor_seq(byte, app_cursor)` 助手;为真时方向键 / Home / End 发 SS3`ESC O X`),否则 CSI`ESC [ X`)。`handle_keydown``handle_wheel` 都据此切换。
3. **测试**`key_to_bytes_table` 单测覆盖整套键表17 个 terminal 测试全过、clippy 干净 3. **浏览器快捷键仲裁**`handle_keydown` 对 bare `Ctrl+字母`(无 Shift/Alt/Meta调用 `preventDefault()`,阻止浏览器吃掉 `Ctrl-R` 刷新、`Ctrl-F` 查找、`Ctrl-A` 全选等。`Ctrl+Shift` 组合(如 `Ctrl-Shift-C` 复制)和 `Ctrl+Alt`/`Ctrl+Meta` 组合保留给浏览器/系统
**有意保留的"看起来像 bug"行为**(与真终端/xterm 一致): **浏览器预留键(硬限制,无法拦截)**`Ctrl-W`(关标签)、`Ctrl-T`(新标签)、`Ctrl-N`(新窗口)从 Chrome 4 起被浏览器保留,`preventDefault()` 对它们无效,浏览器在 `keydown` 到达页面前就消费了。这是所有网页终端的共同限制xterm.js 同样如此),只能在浏览器之外解决(系统全局快捷键 / 扩展 / Electron 包装。readline 退格删词可用 `Alt+Backspace` 替代 `Ctrl-W`
- `Ctrl-C` (0x03) → SIGINT 中断前台进程 4. **测试**`key_to_bytes_table` 单测覆盖整套键表18 个 terminal 测试全过、clippy 干净。
- `Ctrl-Z` (0x1a) → SIGTSTP 暂停前台进程shell 提示符立刻返回)
- `Ctrl-\` (0x1c) → SIGQUIT 杀掉前台进程shell 提示符返回)
这些是 bash 等的 `stty` 默认行为intr/quit/susp由内核 tty 纪律触发,与 xterm/SSH 进真终端一样——不是本组件 bug也按用户确认保留。 **有意调整的行为**(出于浏览器终端的可用性,与原生 xterm 不同):
- `Ctrl-C` (0x03) → 无选区时发 SIGINT 中断前台进程;**有选区时优先复制**,不发送 0x03。
- `Ctrl-Z` (0x1a) 与 `Ctrl-\` (0x1c) → 被前端过滤为 `None`,不发送到 PTY。原因是这两个键在默认 `stty` 下会触发 SIGTSTP / SIGQUIT在浏览器标签页里极易误触导致前台进程被挂起或杀掉需要这两个信号的用户可用 shell 内建命令(如 `kill -STOP` / `kill -QUIT`)替代。
--- ---

View File

@@ -327,27 +327,87 @@ pub fn TerminalPanel() -> impl IntoView {
let keydown_core = core_signal; let keydown_core = core_signal;
move |event: KeyboardEvent| { move |event: KeyboardEvent| {
// Ctrl-Shift-C copies the current selection (Ctrl-C is left alone so // Ctrl-Shift-C copies the current selection. Handle this BEFORE the
// it still sends SIGINT). // generic Ctrl+letter suppression so the browser clipboard gesture
// (and the async writeText call) is not interfered with.
if event.ctrl_key() if event.ctrl_key()
&& event.shift_key() && event.shift_key()
&& matches!(event.key().as_str(), "c" | "C") && matches!(event.key().as_str(), "c" | "C")
{ {
if let Some(sel) = copy_current_selection(keydown_selection, keydown_core);
keydown_selection.get_untracked().filter(|s| !s.is_empty())
{
let (start, end) = sel.ordered();
let text = keydown_core
.try_with_untracked(|core| {
core.selection_text(start, end)
})
.unwrap_or_default();
copy_to_clipboard(&text);
}
event.prevent_default(); event.prevent_default();
return; return;
} }
// Paste (Ctrl+V / Ctrl+Shift+V): read the system clipboard directly
// and send it as input. Some browsers do not fire `on:paste` on a
// non-contenteditable div, so relying on the ClipboardEvent alone is
// unreliable. The keydown itself is a user gesture, which allows
// `navigator.clipboard.readText()` without a permission prompt.
let is_paste_shortcut = event.ctrl_key()
&& !event.alt_key()
&& !event.meta_key()
&& matches!(event.key().as_str(), "v" | "V");
if is_paste_shortcut {
event.prevent_default();
if !keydown_mode.get_untracked().is_live() {
keydown_mode.set(ViewMode::Live);
sync_scroll_to_bottom(terminal_ref, keydown_prog_scroll);
}
let paste_ws = ws_signal;
let paste_core = keydown_core;
wasm_bindgen_futures::spawn_local(async move {
let clipboard = window().navigator().clipboard();
let promise = clipboard.read_text();
let Ok(value) = wasm_bindgen_futures::JsFuture::from(promise).await else { return; };
let Some(text) = value.as_string() else { return; };
if text.is_empty() {
return;
}
let bracketed = paste_core
.try_with_untracked(|core| core.bracketed_paste())
== Some(true);
let data = prepare_paste(&text, bracketed);
if let Some(socket) = paste_ws.get_untracked() {
send_terminal_message(
&socket,
&ClientTerminalMessage::Input { data },
);
}
});
return;
}
// The terminal owns bare Ctrl+letter (no Shift/Alt/Meta) so the
// browser doesn't intercept common shortcuts (Ctrl-W close tab,
// Ctrl-T new tab, Ctrl-N new window, Ctrl-R reload, Ctrl-F find,
// …). Shifted combos are excluded so Ctrl-Shift-C copy still works.
let is_bare_ctrl_letter = event.ctrl_key()
&& !event.shift_key()
&& !event.alt_key()
&& !event.meta_key()
&& event.key().chars().count() == 1;
if is_bare_ctrl_letter {
// When there is an active selection, Ctrl+C copies instead of
// sending SIGINT, matching VS Code / Windows Terminal behaviour.
if matches!(event.key().as_str(), "c" | "C")
&& keydown_selection
.get_untracked()
.map(|s| !s.is_empty())
.unwrap_or(false)
{
copy_current_selection(keydown_selection, keydown_core);
event.prevent_default();
return;
}
event.prevent_default();
}
let Some(socket) = ws_signal.get_untracked() else { let Some(socket) = ws_signal.get_untracked() else {
return; return;
}; };
@@ -421,6 +481,15 @@ pub fn TerminalPanel() -> impl IntoView {
// browser scroll natively (handled by `on:scroll`). // browser scroll natively (handled by `on:scroll`).
let handle_wheel = { let handle_wheel = {
let wheel_core = core_signal; let wheel_core = core_signal;
// Accumulated wheel delta + mode across the current animation frame.
// Each wheel event adds its delta here; the rAF callback flushes
// the batch as a single arrow press. Without this, a single
// trackpad swipe fires many wheel events and the app scrolls many
// lines, which is jarring in less/vim.
let wheel_batch: Rc<std::cell::Cell<Option<(f64, u32)>>> =
Rc::new(std::cell::Cell::new(None));
let wheel_raf_pending: Rc<std::cell::Cell<bool>> =
Rc::new(std::cell::Cell::new(false));
move |event: WheelEvent| { move |event: WheelEvent| {
let in_alt = wheel_core let in_alt = wheel_core
@@ -429,31 +498,61 @@ pub fn TerminalPanel() -> impl IntoView {
if !in_alt { if !in_alt {
return; // main screen → native scroll return; // main screen → native scroll
} }
event.prevent_default();
let Some(socket) = ws_signal.get_untracked() else { if event.delta_y() == 0.0 {
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; return;
} }
// Up (negative deltaY) → arrow up, down → arrow down, repeated per // Accumulate (or reset if sign flipped, to avoid silent cancel).
// row. Use SS3 form when the app is in application-cursor mode let new = event.delta_y();
// so the sequence matches what the app expects. let mode = event.delta_mode();
let final_byte = if event.delta_y() < 0.0 { 'A' } else { 'B' }; match wheel_batch.get() {
let data = cursor_seq(final_byte, app_cursor).repeat(rows); None => wheel_batch.set(Some((new, mode))),
Some((cur, cur_mode)) if (cur.signum() * new.signum()) >= 0.0 => {
wheel_batch.set(Some((cur + new, cur_mode)));
}
Some(_) => wheel_batch.set(Some((new, mode))),
}
event.prevent_default(); if wheel_raf_pending.get() {
send_terminal_message( return;
&socket, }
&ClientTerminalMessage::Input { data }, wheel_raf_pending.set(true);
);
// Clones for the one-shot rAF closure. The outer handler stays
// `Fn` because each clone is a new Rc handle; the clones are
// moved into the rAF closure.
let wheel_batch_for_raf = wheel_batch.clone();
let wheel_raf_pending_for_raf = wheel_raf_pending.clone();
let callback = Closure::once_into_js(move || {
wheel_raf_pending_for_raf.set(false);
let Some((sum, mode)) = wheel_batch_for_raf.take() else {
return;
};
if sum == 0.0 {
return;
}
let vp_rows = current_viewport.get_untracked().rows as usize;
let max_rows = vp_rows.max(1);
let rows = wheel_rows_for_delta(sum, mode, max_rows);
if rows == 0 {
return;
}
let app_cursor = wheel_core
.try_with_untracked(|c| c.application_cursor())
.unwrap_or(false);
let final_byte = if sum < 0.0 { 'A' } else { 'B' };
let data = cursor_seq(final_byte, app_cursor).repeat(rows);
if let Some(socket) = ws_signal.get_untracked() {
send_terminal_message(
&socket,
&ClientTerminalMessage::Input { data },
);
}
});
let _ = window().request_animation_frame(callback.as_ref().unchecked_ref());
} }
}; };
@@ -1081,19 +1180,29 @@ fn copy_to_clipboard(text: &str) {
let _ = clipboard.write_text(text); let _ = clipboard.write_text(text);
} }
/// Copy the current text selection to the system clipboard, if any.
fn copy_current_selection(selection: RwSignal<Option<Selection>>, core: RwSignal<TerminalCore>) {
if let Some(sel) = selection.get_untracked().filter(|s| !s.is_empty()) {
let (start, end) = sel.ordered();
let text = core
.try_with_untracked(|c| c.selection_text(start, end))
.unwrap_or_default();
copy_to_clipboard(&text);
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Key mapping // Key mapping
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/// Number of rows to scroll for a wheel event, from its delta and delta mode. /// Number of rows to scroll for a wheel event, from its delta and delta mode.
/// ///
/// `delta_mode`: 0 = pixels, 1 = lines, 2 = pages. We translate to a small /// `delta_mode`: 0 = pixels, 1 = lines, 2 = pages. We translate to a
/// positive row count (the direction is taken from the sign separately). One /// positive row count (the direction is taken from the sign separately). One
/// notch is clamped to scroll at least 1 and at most a viewport-ish chunk. /// notch is clamped to scroll at least 1 and at most `max_rows`.
fn wheel_delta_to_rows(delta_y: f64, delta_mode: u32) -> usize { fn wheel_rows_for_delta(delta_y: f64, delta_mode: u32, max_rows: usize) -> usize {
const WHEEL_PIXELS_PER_ROW: f64 = SCROLL_LINE_HEIGHT; const WHEEL_PIXELS_PER_ROW: f64 = SCROLL_LINE_HEIGHT;
const WHEEL_LINES_PER_PAGE: f64 = 8.0; const WHEEL_LINES_PER_PAGE: f64 = 8.0;
const MAX_ROWS_PER_EVENT: usize = 12;
let magnitude = delta_y.abs(); let magnitude = delta_y.abs();
if magnitude == 0.0 { if magnitude == 0.0 {
@@ -1104,7 +1213,8 @@ fn wheel_delta_to_rows(delta_y: f64, delta_mode: u32) -> usize {
2 => magnitude * WHEEL_LINES_PER_PAGE, // pages 2 => magnitude * WHEEL_LINES_PER_PAGE, // pages
_ => magnitude / WHEEL_PIXELS_PER_ROW, // pixels _ => magnitude / WHEEL_PIXELS_PER_ROW, // pixels
}; };
(rows.round() as usize).clamp(1, MAX_ROWS_PER_EVENT) let rows = rows.round() as usize;
rows.clamp(1, max_rows.max(1))
} }
/// Prepare clipboard text for sending to the PTY. /// Prepare clipboard text for sending to the PTY.
@@ -1143,6 +1253,11 @@ fn key_to_bytes(
app_cursor: bool, app_cursor: bool,
) -> Option<String> { ) -> Option<String> {
if ctrl && !alt { if ctrl && !alt {
// Modifier keys like "Control" / "Shift" report ctrl=true but are not
// real Ctrl+letter combos; pressing Ctrl alone must not send Ctrl-C.
if key.chars().count() != 1 {
return None;
}
let c = key.chars().next()?; let c = key.chars().next()?;
let code = match c { let code = match c {
'a'..='z' => (c.to_ascii_uppercase() as u8) & 0x1f, 'a'..='z' => (c.to_ascii_uppercase() as u8) & 0x1f,
@@ -1155,6 +1270,13 @@ fn key_to_bytes(
'_' => 0x1f, '_' => 0x1f,
_ => return None, _ => return None,
}; };
// 0x1a (Ctrl-Z) and 0x1c (Ctrl-\) are caught by the shell's stty as
// SIGTSTP / SIGQUIT and would suspend / kill the foreground process.
// Filter them so accidental keypresses don't blow up the user's work;
// Ctrl-C (0x03) is kept so the user can still interrupt intentionally.
if matches!(code, 0x1a | 0x1c) {
return None;
}
return Some((code as char).to_string()); return Some((code as char).to_string());
} }
@@ -1223,23 +1345,44 @@ mod tests {
} }
#[test] #[test]
fn wheel_delta_maps_to_row_counts() { fn wheel_rows_for_delta_pixel_line_page() {
// Per-event cap (12) — the old per-event contract.
const MAX: usize = 12;
// Pixel mode: one notch (~one line height) → at least 1 row. // Pixel mode: one notch (~one line height) → at least 1 row.
assert_eq!(wheel_delta_to_rows(SCROLL_LINE_HEIGHT, 0), 1); assert_eq!(wheel_rows_for_delta(SCROLL_LINE_HEIGHT, 0, MAX), 1);
// A few lines of pixels → that many rows. // A few lines of pixels → that many rows.
assert_eq!(wheel_delta_to_rows(SCROLL_LINE_HEIGHT * 3.0, 0), 3); assert_eq!(wheel_rows_for_delta(SCROLL_LINE_HEIGHT * 3.0, 0, MAX), 3);
// Tiny pixel delta still scrolls at least one row. // Tiny pixel delta still scrolls at least one row.
assert_eq!(wheel_delta_to_rows(1.0, 0), 1); assert_eq!(wheel_rows_for_delta(1.0, 0, MAX), 1);
// Line mode: delta is already in lines. // Line mode: delta is already in lines.
assert_eq!(wheel_delta_to_rows(3.0, 1), 3); assert_eq!(wheel_rows_for_delta(3.0, 1, MAX), 3);
// Page mode scrolls a chunk (clamped to the per-event max). // Page mode scrolls a chunk (clamped to the max).
assert_eq!(wheel_delta_to_rows(1.0, 2), 8); assert_eq!(wheel_rows_for_delta(1.0, 2, MAX), 8);
// Sign is irrelevant here (direction handled separately); magnitude used. // Sign is irrelevant here (direction handled separately); magnitude used.
assert_eq!(wheel_delta_to_rows(-SCROLL_LINE_HEIGHT * 2.0, 0), 2); assert_eq!(wheel_rows_for_delta(-SCROLL_LINE_HEIGHT * 2.0, 0, MAX), 2);
// Zero delta → no rows. // Zero delta → no rows.
assert_eq!(wheel_delta_to_rows(0.0, 0), 0); assert_eq!(wheel_rows_for_delta(0.0, 0, MAX), 0);
// Huge delta is clamped. // Huge delta is clamped to the max.
assert_eq!(wheel_delta_to_rows(100_000.0, 0), 12); assert_eq!(wheel_rows_for_delta(100_000.0, 0, MAX), 12);
}
#[test]
fn wheel_rows_for_delta_scales_with_max_rows_for_coalesced_batches() {
// A coalesced batch sums many small wheel deltas into a single big
// scroll. The per-batch cap (max_rows) should be respected so a
// single huge swipe doesn't fly past the bottom instantly.
let big_sum = SCROLL_LINE_HEIGHT * 80.0; // would be 80 rows unclamped
// Per-event cap (12): even with a large sum, capped to 12.
assert_eq!(wheel_rows_for_delta(big_sum, 0, 12), 12);
// Per-batch cap (a 24-row viewport): bigger than per-event.
assert_eq!(wheel_rows_for_delta(big_sum, 0, 24), 24);
// Honest mapping: a 5-row coalesced swipe is 5 rows, not 12.
assert_eq!(
wheel_rows_for_delta(SCROLL_LINE_HEIGHT * 5.0, 0, 24),
5
);
// Tiny sums still floor at 1.
assert_eq!(wheel_rows_for_delta(1.0, 0, 24), 1);
} }
#[test] #[test]
@@ -1340,10 +1483,10 @@ mod tests {
Some(expected), "key {key}"); Some(expected), "key {key}");
} }
// --- Ctrl letters: A=0x01 .. Z=0x1a --- // --- Ctrl letters: A=0x01 .. Z=0x1a (except Z which is filtered) ---
for (letter, code) in [ for (letter, code) in [
("a", '\u{1}'), ("c", '\u{3}'), ("d", '\u{4}'), ("a", '\u{1}'), ("c", '\u{3}'), ("d", '\u{4}'),
("l", '\u{c}'), ("z", '\u{1a}'), ("A", '\u{1}'), ("l", '\u{c}'), ("A", '\u{1}'),
] { ] {
assert_eq!( assert_eq!(
key_to_bytes(letter, true, false, false, false).as_deref(), key_to_bytes(letter, true, false, false, false).as_deref(),
@@ -1351,16 +1494,33 @@ mod tests {
"ctrl+{letter}" "ctrl+{letter}"
); );
} }
// Ctrl+@ (NUL), Ctrl+[ (ESC), Ctrl+\, Ctrl+], Ctrl+^, Ctrl+_ // Ctrl+@ (NUL), Ctrl+[ (ESC), Ctrl+], Ctrl+^, Ctrl+_ still map.
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{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{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-Z (0x1a, SIGTSTP) and Ctrl-\ (0x1c, SIGQUIT) are filtered
// out so accidental keypresses don't suspend / kill the foreground
// process. Ctrl-C (0x03) is still sent.
assert_eq!(key_to_bytes("z", true, false, false, false), None);
assert_eq!(key_to_bytes("Z", true, false, false, false), None);
assert_eq!(key_to_bytes("\\", true, false, false, false), None);
assert_eq!(key_to_bytes("c", true, false, false, false).as_deref(), Some("\u{3}"));
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{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{1e}"));
assert_eq!(key_to_bytes("_", true, false, false, false).as_deref(), Some("\u{1f}")); assert_eq!(key_to_bytes("_", true, false, false, false).as_deref(), Some("\u{1f}"));
// Ctrl+Alt falls through to the printable-letter fallback (most // Ctrl+Alt falls through to the printable-letter fallback (most
// terminals forward the bare char; few apps use this combo). // terminals forward the bare char; few apps use this combo).
assert_eq!(key_to_bytes("c", true, true, false, false).as_deref(), Some("c")); assert_eq!(key_to_bytes("c", true, true, false, false).as_deref(), Some("c"));
// Pressing the Ctrl/Shift/Meta modifier keys alone must not emit
// anything; previously "Control" with ctrl=true was misread as "C" and
// sent SIGINT (0x03).
assert_eq!(key_to_bytes("Control", true, false, false, false), None);
assert_eq!(key_to_bytes("Shift", false, false, false, false), None);
assert_eq!(key_to_bytes("Meta", false, false, true, false), None);
// Ctrl+Shift (or any ctrl+modifier) also stays silent.
assert_eq!(key_to_bytes("Shift", true, false, false, false), None);
// --- Alt+char → ESC prefix --- // --- 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("a", false, true, false, false).as_deref(), Some("\u{1b}a"));