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` |
|
| 宽字符 / 组合字符网格对齐 | `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` |
|
||||||
| 完整键表(F1–F12/Home/End/PageUp/Down/Ctrl A–Z/Alt-key) | `component.rs::key_to_bytes` |
|
| 完整键表(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` |
|
| 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`)替代。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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,32 +498,62 @@ 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() {
|
||||||
|
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(
|
send_terminal_message(
|
||||||
&socket,
|
&socket,
|
||||||
&ClientTerminalMessage::Input { data },
|
&ClientTerminalMessage::Input { data },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let _ = window().request_animation_frame(callback.as_ref().unchecked_ref());
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// -- Paste handler ------------------------------------------------------
|
// -- Paste handler ------------------------------------------------------
|
||||||
@@ -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"));
|
||||||
|
|||||||
Reference in New Issue
Block a user