From c3ddd9df4f36eddaf3c568ebfc36951355ac3d54 Mon Sep 17 00:00:00 2001 From: zhangheng Date: Thu, 18 Jun 2026 14:32:46 +0800 Subject: [PATCH] fix(terminal): robust Ctrl/paste handling + alt-screen wheel coalescing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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 --- TERMINAL_ROADMAP.md | 17 ++- app/src/terminal/component.rs | 268 +++++++++++++++++++++++++++------- 2 files changed, 223 insertions(+), 62 deletions(-) diff --git a/TERMINAL_ROADMAP.md b/TERMINAL_ROADMAP.md index 3e8c166..408c8f7 100644 --- a/TERMINAL_ROADMAP.md +++ b/TERMINAL_ROADMAP.md @@ -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`)替代。 --- diff --git a/app/src/terminal/component.rs b/app/src/terminal/component.rs index 153cd76..20a3f57 100644 --- a/app/src/terminal/component.rs +++ b/app/src/terminal/component.rs @@ -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>> = + Rc::new(std::cell::Cell::new(None)); + let wheel_raf_pending: Rc> = + 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>, core: RwSignal) { + 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 { 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"));