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:
@@ -27,7 +27,7 @@
|
||||
| 宽字符 / 组合字符网格对齐 | `core.rs` `TerminalSegment.cols` |
|
||||
| resize / reflow | `core.rs::resize` |
|
||||
| `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` |
|
||||
| 完整键表(F1–F12/Home/End/PageUp/Down/Ctrl A–Z/Alt-key) | `component.rs::key_to_bytes` |
|
||||
| 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)。
|
||||
- 高亮 overlay:`selection_rects` 算每行矩形,渲染在虚拟滚动层内(共享 translateY,跟随滚动),不拆分文本段。
|
||||
- 复制取 `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`,均有单元测试。
|
||||
3. **快捷键**:Ctrl-Shift-C 复制 / Ctrl-V 粘贴。
|
||||
|
||||
@@ -71,14 +72,14 @@
|
||||
- Ctrl A..Z → 0x01..0x1a;以及 `@[\\]^_` 变体(→ 0x00/0x1b/0x1c/0x1d/0x1e/0x1f)。
|
||||
- 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` 都据此切换。
|
||||
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-C` (0x03) → SIGINT 中断前台进程
|
||||
- `Ctrl-Z` (0x1a) → SIGTSTP 暂停前台进程(shell 提示符立刻返回)
|
||||
- `Ctrl-\` (0x1c) → SIGQUIT 杀掉前台进程(shell 提示符返回)
|
||||
**浏览器预留键(硬限制,无法拦截)**:`Ctrl-W`(关标签)、`Ctrl-T`(新标签)、`Ctrl-N`(新窗口)从 Chrome 4 起被浏览器保留,`preventDefault()` 对它们无效,浏览器在 `keydown` 到达页面前就消费了。这是所有网页终端的共同限制(xterm.js 同样如此),只能在浏览器之外解决(系统全局快捷键 / 扩展 / Electron 包装)。readline 退格删词可用 `Alt+Backspace` 替代 `Ctrl-W`。
|
||||
4. **测试**:`key_to_bytes_table` 单测覆盖整套键表,18 个 terminal 测试全过、clippy 干净。
|
||||
|
||||
这些是 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`)替代。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -327,27 +327,87 @@ pub fn TerminalPanel() -> impl IntoView {
|
||||
let keydown_core = core_signal;
|
||||
|
||||
move |event: KeyboardEvent| {
|
||||
// Ctrl-Shift-C copies the current selection (Ctrl-C is left alone so
|
||||
// it still sends SIGINT).
|
||||
// Ctrl-Shift-C copies the current selection. Handle this BEFORE the
|
||||
// generic Ctrl+letter suppression so the browser clipboard gesture
|
||||
// (and the async writeText call) is not interfered with.
|
||||
if event.ctrl_key()
|
||||
&& event.shift_key()
|
||||
&& matches!(event.key().as_str(), "c" | "C")
|
||||
{
|
||||
if let Some(sel) =
|
||||
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);
|
||||
}
|
||||
copy_current_selection(keydown_selection, keydown_core);
|
||||
event.prevent_default();
|
||||
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 {
|
||||
return;
|
||||
};
|
||||
@@ -421,6 +481,15 @@ pub fn TerminalPanel() -> impl IntoView {
|
||||
// browser scroll natively (handled by `on:scroll`).
|
||||
let handle_wheel = {
|
||||
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| {
|
||||
let in_alt = wheel_core
|
||||
@@ -429,31 +498,61 @@ pub fn TerminalPanel() -> impl IntoView {
|
||||
if !in_alt {
|
||||
return; // main screen → native scroll
|
||||
}
|
||||
|
||||
let Some(socket) = ws_signal.get_untracked() else {
|
||||
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 {
|
||||
event.prevent_default();
|
||||
if event.delta_y() == 0.0 {
|
||||
return;
|
||||
}
|
||||
|
||||
// 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);
|
||||
// Accumulate (or reset if sign flipped, to avoid silent cancel).
|
||||
let new = event.delta_y();
|
||||
let mode = event.delta_mode();
|
||||
match wheel_batch.get() {
|
||||
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();
|
||||
send_terminal_message(
|
||||
&socket,
|
||||
&ClientTerminalMessage::Input { data },
|
||||
);
|
||||
if wheel_raf_pending.get() {
|
||||
return;
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
/// 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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// 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
|
||||
/// notch is clamped to scroll at least 1 and at most a viewport-ish chunk.
|
||||
fn wheel_delta_to_rows(delta_y: f64, delta_mode: u32) -> usize {
|
||||
/// notch is clamped to scroll at least 1 and at most `max_rows`.
|
||||
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_LINES_PER_PAGE: f64 = 8.0;
|
||||
const MAX_ROWS_PER_EVENT: usize = 12;
|
||||
|
||||
let magnitude = delta_y.abs();
|
||||
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
|
||||
_ => 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.
|
||||
@@ -1143,6 +1253,11 @@ fn key_to_bytes(
|
||||
app_cursor: bool,
|
||||
) -> Option<String> {
|
||||
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 code = match c {
|
||||
'a'..='z' => (c.to_ascii_uppercase() as u8) & 0x1f,
|
||||
@@ -1155,6 +1270,13 @@ fn key_to_bytes(
|
||||
'_' => 0x1f,
|
||||
_ => 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());
|
||||
}
|
||||
|
||||
@@ -1223,23 +1345,44 @@ mod tests {
|
||||
}
|
||||
|
||||
#[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.
|
||||
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.
|
||||
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.
|
||||
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.
|
||||
assert_eq!(wheel_delta_to_rows(3.0, 1), 3);
|
||||
// Page mode scrolls a chunk (clamped to the per-event max).
|
||||
assert_eq!(wheel_delta_to_rows(1.0, 2), 8);
|
||||
assert_eq!(wheel_rows_for_delta(3.0, 1, MAX), 3);
|
||||
// Page mode scrolls a chunk (clamped to the max).
|
||||
assert_eq!(wheel_rows_for_delta(1.0, 2, MAX), 8);
|
||||
// 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.
|
||||
assert_eq!(wheel_delta_to_rows(0.0, 0), 0);
|
||||
// Huge delta is clamped.
|
||||
assert_eq!(wheel_delta_to_rows(100_000.0, 0), 12);
|
||||
assert_eq!(wheel_rows_for_delta(0.0, 0, MAX), 0);
|
||||
// Huge delta is clamped to the max.
|
||||
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]
|
||||
@@ -1340,10 +1483,10 @@ mod tests {
|
||||
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 [
|
||||
("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!(
|
||||
key_to_bytes(letter, true, false, false, false).as_deref(),
|
||||
@@ -1351,16 +1494,33 @@ mod tests {
|
||||
"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{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{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"));
|
||||
// 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 ---
|
||||
assert_eq!(key_to_bytes("a", false, true, false, false).as_deref(), Some("\u{1b}a"));
|
||||
|
||||
Reference in New Issue
Block a user