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:
@@ -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