use std::rc::Rc; use app_config::SiteConfig; use leptos::html; use leptos::prelude::*; use wasm_bindgen::JsCast; use wasm_bindgen::closure::Closure; use web_sys::js_sys::{Array, Function}; use web_sys::{ BinaryType, ClipboardEvent, CompositionEvent, ErrorEvent, Event, HtmlElement, InputEvent, KeyboardEvent, MessageEvent, MouseEvent, ResizeObserver, ResizeObserverEntry, WebSocket, WheelEvent, }; use super::core::{ MouseProtocolEncoding, MouseProtocolMode, TerminalCore, TerminalRow, TerminalSegment, }; use super::protocol::{ClientTerminalMessage, ServerTerminalMessage}; use super::scrollback::ViewMode; const DEFAULT_CELL_WIDTH: f64 = 8.0; const DEFAULT_CELL_HEIGHT: f64 = 18.0; const MIN_COLS: u16 = 40; const MIN_ROWS: u16 = 12; const MEASURE_SAMPLE_TEXT: &str = "WWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWW"; const SCROLL_LINE_HEIGHT: f64 = 18.0; /// Extra rows rendered above and below the visible window so that fast /// scrolling doesn't flash blank areas before the next frame arrives. const VIRTUAL_OVERSCAN: usize = 5; /// `.terminal-screen` inner padding in px (CSS `padding: 16px`). Mouse /// coordinates subtract this to map onto the cell grid. const TERMINAL_PADDING: f64 = 16.0; /// A text selection in logical buffer coordinates `(row, col)`. `anchor` is /// where the drag began, `head` follows the pointer; either may be the /// top-left depending on drag direction. #[derive(Clone, Copy, Debug, PartialEq, Eq)] struct Selection { anchor: (usize, usize), head: (usize, usize), } impl Selection { /// (top-left, bottom-right) ordered for rendering / extraction. fn ordered(self) -> ((usize, usize), (usize, usize)) { if self.anchor <= self.head { (self.anchor, self.head) } else { (self.head, self.anchor) } } /// True when the selection is empty (anchor == head): nothing to copy. fn is_empty(self) -> bool { self.anchor == self.head } } #[derive(Clone, Copy, Debug, PartialEq, Eq)] struct TerminalViewport { cols: u16, rows: u16, pixel_width: u16, pixel_height: u16, } impl Default for TerminalViewport { fn default() -> Self { Self { cols: super::core::DEFAULT_COLS, rows: super::core::DEFAULT_ROWS, pixel_width: (f64::from(super::core::DEFAULT_COLS) * DEFAULT_CELL_WIDTH) .round() as u16, pixel_height: (f64::from(super::core::DEFAULT_ROWS) * DEFAULT_CELL_HEIGHT) .round() as u16, } } } // --------------------------------------------------------------------------- // TerminalPanel component // --------------------------------------------------------------------------- #[component] pub fn TerminalPanel() -> impl IntoView { let terminal_ref = NodeRef::::new(); let ime_ref = NodeRef::::new(); let measure_ref = NodeRef::::new(); let connection_status = RwSignal::new("Connecting".to_owned()); let ws_signal = RwSignal::new(None::); let current_viewport = RwSignal::new(TerminalViewport::default()); let view_mode = RwSignal::new(ViewMode::Live); // The scrollTop we last set programmatically. The scroll handler compares // against it to robustly tell our own programmatic scrolls apart from real // user scrolls (a single-shot "ignore next" flag was racy and caused an // auto-pin ↔ user-scroll feedback loop / momentum bounce). let programmatic_scroll_top = RwSignal::new(-1_i32); // Terminal state lives in a reactive signal, but it is mutated via // `update_untracked` so that feeding bytes never triggers a render on its // own — rendering is driven explicitly by `render_tick` below. This // decouples the (very fast, ~1µs/line) data-ingestion rate from the // (expensive) DOM-render rate: a burst of hundreds of WebSocket packets // from `cat`-ing a huge file collapses into a single render per frame. let core_signal = RwSignal::new(TerminalCore::default()); // Bumped at most once per animation frame to drive exactly one render. let render_tick = RwSignal::new(0_u64); // Current scrollTop in pixels — updated by the scroll handler so that // the virtual-scroll render closure knows which window to materialise. let scroll_top = RwSignal::new(0_i32); // Measured cell size (width, height) in px, kept in sync with the viewport // measurement. Used to map mouse coordinates onto the logical cell grid. let cell_size = RwSignal::new((DEFAULT_CELL_WIDTH, DEFAULT_CELL_HEIGHT)); // Active text selection in buffer coords, and whether a drag is in progress. let selection = RwSignal::new(None::); let selecting = RwSignal::new(false); // IME composition state: when true, regular `input` events are ignored and // the final composed string is sent on `compositionend`. let composing = RwSignal::new(false); // Mouse-reporting bookkeeping (only used while an app has enabled mouse // reporting). `mouse_held_button` remembers which button is down so we can // emit motion events and SGR-style releases; `mouse_last_cell` throttles // motion reports to one per grid cell (not per pixel). let mouse_held_button: Rc>> = Rc::new(std::cell::Cell::new(None)); let mouse_last_cell: Rc>> = Rc::new(std::cell::Cell::new(None)); // -- IME cursor follower ------------------------------------------------- // // The hidden IME textarea's position is bound reactively to the terminal // cursor cell so the OS/browser IME candidate window appears in the right // place. See the `style` prop on the textarea in the view below. let ime_style = move || { let _ = render_tick.get(); let Some((row, col)) = core_signal .try_with_untracked(|core| { if core.hide_cursor() { None } else { Some(core.cursor_position()) } }) .flatten() else { return String::new(); }; let (cell_w, cell_h) = cell_size.get(); let left = TERMINAL_PADDING + f64::from(col) * cell_w; let top = TERMINAL_PADDING + f64::from(row) * cell_h; format!( "left:{left}px;top:{top}px;width:{cell_w}px;height:{cell_h}px;" ) }; // -- WebSocket lifecycle ------------------------------------------------ Effect::new(move |_| { let url = websocket_url(); let Ok(socket) = WebSocket::new(&url) else { connection_status.set("Failed to connect".to_owned()); return; }; socket.set_binary_type(BinaryType::Arraybuffer); let socket = Rc::new(socket); // onopen let open_socket = socket.clone(); let open_status = connection_status; let open_ws = ws_signal; let open_viewport = current_viewport; let open_ref = terminal_ref; let open_ime = ime_ref; let open_measure = measure_ref; let open_mode = view_mode; let open_prog_scroll = programmatic_scroll_top; let open_core = core_signal; let open_tick = render_tick; let on_open = Closure::::new(move |_| { open_status.set("Connected".to_owned()); open_ws.set(Some((*open_socket).clone())); let viewport = measure_terminal_viewport(open_ref, open_measure); open_viewport.set(viewport); cell_size.set(measure_cell_size(open_measure)); open_core .update_untracked(|core| core.resize(viewport.rows, viewport.cols)); open_tick.update(|t| *t += 1); open_mode.set(ViewMode::Live); send_resize_message(&open_socket, viewport); sync_scroll_to_bottom(open_ref, open_prog_scroll); focus_ime_textarea(&open_ime); }); socket.set_onopen(Some(on_open.as_ref().unchecked_ref())); on_open.forget(); // onmessage let message_core = core_signal; let message_status = connection_status; let message_ref = terminal_ref; let message_mode = view_mode; let message_prog_scroll = programmatic_scroll_top; let message_tick = render_tick; // Coalescing guard: at most one animation frame is scheduled at a time. // A burst of packets all land in `core` (untracked) but only trigger a // single render on the next frame. let raf_pending = Rc::new(std::cell::Cell::new(false)); let on_message = Closure::::new(move |event: MessageEvent| { let Some(text) = event.data().as_string() else { return; }; let Ok(message) = serde_json::from_str::(&text) else { message_status.set("Protocol error".to_owned()); return; }; match message { ServerTerminalMessage::Output { data } => { // Feed bytes without triggering a render (untracked). // `try_*` so a stale callback from a disposed mount // no-ops instead of panicking. if message_core .try_update_untracked(|core| { core.process(data.as_bytes()) }) .is_none() { return; } // Schedule exactly one render for the next frame. schedule_frame( raf_pending.clone(), message_tick, message_mode, message_ref, message_prog_scroll, ); } ServerTerminalMessage::Exit { code } => { message_status.set(match code { Some(code) => format!("Exited ({code})"), None => "Exited".to_owned(), }); } ServerTerminalMessage::Error { message } => { message_status.set(format!("Error: {message}")); } ServerTerminalMessage::Pong => {} } }); socket.set_onmessage(Some(on_message.as_ref().unchecked_ref())); on_message.forget(); // onclose let close_status = connection_status; let close_ws = ws_signal; let on_close = Closure::::new(move |_| { close_status.set("Disconnected".to_owned()); close_ws.set(None); }); socket.set_onclose(Some(on_close.as_ref().unchecked_ref())); on_close.forget(); // onerror let error_status = connection_status; let on_error = Closure::::new(move |_| { error_status.set("Connection error".to_owned()); }); socket.set_onerror(Some(on_error.as_ref().unchecked_ref())); on_error.forget(); }); // -- ResizeObserver ----------------------------------------------------- Effect::new(move |_| { let Some(element) = terminal_ref.get() else { return; }; let Some(socket) = ws_signal.get() else { return; }; let resize_socket = socket.clone(); let resize_viewport = current_viewport; let resize_ref = terminal_ref; let resize_measure = measure_ref; let resize_mode = view_mode; let resize_prog_scroll = programmatic_scroll_top; let resize_core = core_signal; let resize_st = scroll_top; let resize_tick = render_tick; let resize_ime = ime_ref; let callback = Closure::::new( move |entries: Array, _observer: ResizeObserver| { let Some(entry) = entries .get(0) .dyn_into::() .ok() else { return; }; let rect = entry.content_rect(); let viewport = measure_terminal_viewport_from_rect( rect.width(), rect.height(), resize_measure, ); if viewport != resize_viewport.get_untracked() { resize_viewport.set(viewport); cell_size.set(measure_cell_size(resize_measure)); let mode_val = resize_mode.get_untracked(); let st = resize_st.get_untracked(); resize_core.update_untracked(|core| { core.resize(viewport.rows, viewport.cols); }); resize_tick.update(|t| *t += 1); if mode_val == ViewMode::Live { sync_scroll_to_bottom(resize_ref, resize_prog_scroll); } else { sync_scroll_to_position( resize_ref, st, resize_prog_scroll, ); } send_resize_message(&resize_socket, viewport); } focus_ime_textarea(&resize_ime); }, ); let Ok(observer) = ResizeObserver::new(callback.as_ref().unchecked_ref::()) else { return; }; observer.observe(&element); callback.forget(); std::mem::forget(observer); }); // -- Keyboard input ----------------------------------------------------- let handle_keydown = { let keydown_mode = view_mode; let keydown_prog_scroll = programmatic_scroll_top; let keydown_selection = selection; let keydown_core = core_signal; move |event: KeyboardEvent| { // 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") { 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; } // Printable characters are handled by the IME textarea's `input` / // `composition` events so that IME composition and dead keys work. // Only special keys (arrows, Enter, Esc, Backspace, F-keys, // Ctrl-combos, …) are processed here. if is_regular_text_input( &event.key(), event.ctrl_key(), event.alt_key(), event.meta_key(), ) { 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; }; let app_cursor = keydown_core .try_with_untracked(|c| c.application_cursor()) .unwrap_or(false); let Some(data) = map_key_to_terminal_input(&event, app_cursor) else { return; }; // Typing replaces a selection, like a normal terminal. keydown_selection.set(None); if !keydown_mode.get_untracked().is_live() { keydown_mode.set(ViewMode::Live); sync_scroll_to_bottom(terminal_ref, keydown_prog_scroll); } event.prevent_default(); send_terminal_message( &socket, &ClientTerminalMessage::Input { data }, ); } }; // -- Scroll handler ------------------------------------------------------ let handle_scroll = { let scroll_mode = view_mode; let scroll_prog = programmatic_scroll_top; move |_| { let Some(element) = terminal_ref.get_untracked() else { return; }; let st = element.scroll_top(); // Distinguish our own programmatic scrolls from real user scrolls by // position (robust against missed/duplicated scroll events that a // single-shot ignore flag could not handle). If this position is // the one we just set programmatically, treat it as an echo and do // not touch the view mode — only record scroll_top for rendering. let is_programmatic_echo = (st - scroll_prog.get_untracked()).abs() <= 2; scroll_top.set(st); if is_programmatic_echo { return; } let mode = super::scrollback::view_mode_from_scroll( st, element.scroll_height(), element.client_height(), SCROLL_LINE_HEIGHT, ); scroll_mode.set(mode); } }; // -- Wheel handler (alt-screen only) ------------------------------------ // // The alternate screen (vim / less / man) has no scrollback, so native // wheel scrolling does nothing useful there. Like a real terminal, we // translate wheel ticks into arrow-key presses sent to the application so // the wheel pages through it. On the main screen we do nothing and let the // 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| { // If the app enabled mouse reporting, a wheel tick is reported as a // button press (xterm: 64=up, 65=down, 66=left, 67=right) instead of // being translated to arrow keys. let mouse_mode = wheel_core .try_with_untracked(|core| core.mouse_protocol_mode()); if !matches!(mouse_mode, Some(MouseProtocolMode::None) | None) { event.prevent_default(); let Some(encoding) = wheel_core .try_with_untracked(|core| core.mouse_protocol_encoding()) else { return; }; let button = if event.delta_y() < 0.0 { 64 } else if event.delta_y() > 0.0 { 65 } else if event.delta_x() < 0.0 { 66 } else if event.delta_x() > 0.0 { 67 } else { return; }; let Some(element) = terminal_ref.get_untracked() else { return; }; let rect = element.get_bounding_client_rect(); let local_x = f64::from(event.client_x()) - rect.left(); let local_y = f64::from(event.client_y()) - rect.top(); let (cell_w, cell_h) = cell_size.get_untracked(); let vp = current_viewport.get_untracked(); let (row, col) = pixel_to_grid( local_x, local_y, cell_w, cell_h, vp.cols as usize, vp.rows as usize, ); let button = with_mouse_modifiers( button, event.shift_key(), event.alt_key(), event.ctrl_key(), ); let data = encode_mouse_report( button, MouseAction::Press, col, row, encoding, ); if let Some(socket) = ws_signal.get_untracked() { send_terminal_message( &socket, &ClientTerminalMessage::Input { data }, ); } return; } let in_alt = wheel_core .try_with_untracked(|core| core.alternate_screen()) == Some(true); if !in_alt { return; // main screen → native scroll } event.prevent_default(); if event.delta_y() == 0.0 { return; } // 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))), } 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()); } }; // -- Paste handler ------------------------------------------------------ // // Send clipboard text to the PTY. If the running application enabled // bracketed paste (vim, many shells), the text is wrapped in // ESC[200~ … ESC[201~ so the app can tell a paste from typed input (and // skip e.g. auto-indenting every pasted line). let handle_paste = { let paste_mode = view_mode; let paste_prog_scroll = programmatic_scroll_top; let paste_core = core_signal; move |event: ClipboardEvent| { let Some(raw) = event .clipboard_data() .and_then(|dt| dt.get_data("text").ok()) else { return; }; if raw.is_empty() { return; } let bracketed = paste_core .try_with_untracked(|core| core.bracketed_paste()) == Some(true); let data = prepare_paste(&raw, bracketed); send_input_data( data, ws_signal, paste_mode, terminal_ref, paste_prog_scroll, ); event.prevent_default(); } }; // -- IME input ------------------------------------------------------------ // // Regular characters and IME composition are handled through a hidden // textarea that sits on top of the terminal cursor. Special keys // (arrows, Enter, Esc, F-keys, Ctrl-combos) still go through keydown. let handle_input = { let input_mode = view_mode; let input_prog_scroll = programmatic_scroll_top; let input_composing = composing; let input_ime = ime_ref; move |event: Event| { let Ok(_event) = event.dyn_into::() else { return; }; if input_composing.get_untracked() { return; } let Some(text) = ime_ref_text(&input_ime) else { return; }; if text.is_empty() { return; } clear_ime_textarea(&input_ime); send_input_data( text, ws_signal, input_mode, terminal_ref, input_prog_scroll, ); } }; let handle_composition_start = move |_: CompositionEvent| { composing.set(true); }; let handle_composition_end = { let end_mode = view_mode; let end_prog_scroll = programmatic_scroll_top; let end_composing = composing; let end_ime = ime_ref; move |event: CompositionEvent| { if !end_composing.get_untracked() { clear_ime_textarea(&end_ime); return; } end_composing.set(false); if let Some(text) = event.data() && !text.is_empty() { send_input_data( text, ws_signal, end_mode, terminal_ref, end_prog_scroll, ); } clear_ime_textarea(&end_ime); } }; // -- Mouse selection ---------------------------------------------------- // // Drag to select a range of terminal text. Selection is tracked in logical // buffer coordinates (not DOM), so it survives re-renders and works across // rows that have scrolled out of the rendered window. let cell_from_event = move |event: &MouseEvent| -> Option<(usize, usize)> { let element = terminal_ref.get_untracked()?; let rect = element.get_bounding_client_rect(); let local_x = f64::from(event.client_x()) - rect.left(); let local_y = f64::from(event.client_y()) - rect.top(); let (cell_w, cell_h) = cell_size.get_untracked(); let cols = current_viewport.get_untracked().cols as usize; let total = core_signal .try_with_untracked(|core| core.total_rows()) .unwrap_or(0); Some(point_to_cell( local_x, local_y, f64::from(element.scroll_top()), cell_w, cell_h, cols, total, )) }; // Grid coordinates for mouse *reporting* — relative to the visible screen // (no scrollback offset), so the app gets the cell under the cursor on the // grid it is currently drawing. let grid_from_event = move |event: &MouseEvent| -> Option<(usize, usize)> { let element = terminal_ref.get_untracked()?; let rect = element.get_bounding_client_rect(); let local_x = f64::from(event.client_x()) - rect.left(); let local_y = f64::from(event.client_y()) - rect.top(); let (cell_w, cell_h) = cell_size.get_untracked(); let vp = current_viewport.get_untracked(); Some(pixel_to_grid( local_x, local_y, cell_w, cell_h, vp.cols as usize, vp.rows as usize, )) }; let handle_mousedown = { let down_selection = selection; let down_selecting = selecting; let down_core = core_signal; let down_held = mouse_held_button.clone(); let down_ws = ws_signal; move |event: MouseEvent| { // App mouse reporting takes precedence over local selection, unless // the user holds Shift to force a selection (xterm behaviour). if !event.shift_key() { let mode_enc = down_core.try_with_untracked(|c| { (c.mouse_protocol_mode(), c.mouse_protocol_encoding()) }); if let Some((mode, encoding)) = mode_enc && !matches!(mode, MouseProtocolMode::None) { event.prevent_default(); if let Some(base) = mouse_button_base(event.button()) { let button = with_mouse_modifiers( base, event.shift_key(), event.alt_key(), event.ctrl_key(), ); if let Some((row, col)) = grid_from_event(&event) { down_held.set(Some(base)); let data = encode_mouse_report( button, MouseAction::Press, col, row, encoding, ); if let Some(socket) = down_ws.get_untracked() { send_terminal_message( &socket, &ClientTerminalMessage::Input { data }, ); } } } return; } } // Local selection. if event.button() != 0 { return; // only left button starts a selection } let Some(cell) = cell_from_event(&event) else { return; }; down_selection.set(Some(Selection { anchor: cell, head: cell, })); down_selecting.set(true); } }; let handle_mousemove = { let move_selection = selection; let move_selecting = selecting; let move_core = core_signal; let move_held = mouse_held_button.clone(); let move_last = mouse_last_cell.clone(); let move_ws = ws_signal; move |event: MouseEvent| { // App mouse motion reporting. if !event.shift_key() { let mode_enc = move_core.try_with_untracked(|c| { (c.mouse_protocol_mode(), c.mouse_protocol_encoding()) }); if let Some((mode, encoding)) = mode_enc { let report = match mode { MouseProtocolMode::ButtonMotion => move_held.get().is_some(), MouseProtocolMode::AnyMotion => true, _ => false, }; if report { event.prevent_default(); if let Some((row, col)) = grid_from_event(&event) { // Throttle to one report per grid cell. if move_last.get() != Some((row, col)) { move_last.set(Some((row, col))); let base = match move_held.get() { Some(b) => 32 + b, // motion with button held None => 35, // AnyMotion, no button }; let button = with_mouse_modifiers( base, event.shift_key(), event.alt_key(), event.ctrl_key(), ); let data = encode_mouse_report( button, MouseAction::Motion, col, row, encoding, ); if let Some(socket) = move_ws.get_untracked() { send_terminal_message( &socket, &ClientTerminalMessage::Input { data }, ); } } } return; } } } // Local selection drag. if !move_selecting.get_untracked() { return; } let Some(cell) = cell_from_event(&event) else { return; }; move_selection.update(|sel| { if let Some(s) = sel { s.head = cell; } }); } }; let handle_mouseup = { let up_selecting = selecting; let up_core = core_signal; let up_held = mouse_held_button.clone(); let up_last = mouse_last_cell.clone(); let up_ws = ws_signal; move |event: MouseEvent| { if !event.shift_key() { let mode_enc = up_core.try_with_untracked(|c| { (c.mouse_protocol_mode(), c.mouse_protocol_encoding()) }); if let Some((mode, encoding)) = mode_enc && !matches!(mode, MouseProtocolMode::None) { event.prevent_default(); // Press (X10) mode never reports releases; the others do. if matches!( mode, MouseProtocolMode::PressRelease | MouseProtocolMode::ButtonMotion | MouseProtocolMode::AnyMotion ) && let Some((row, col)) = grid_from_event(&event) { // SGR encodes the actual button + 'm'; legacy uses // button 3 (handled in encode_mouse_report). let held = up_held.get().unwrap_or(0); let button = with_mouse_modifiers( held, event.shift_key(), event.alt_key(), event.ctrl_key(), ); let data = encode_mouse_report( button, MouseAction::Release, col, row, encoding, ); if let Some(socket) = up_ws.get_untracked() { send_terminal_message( &socket, &ClientTerminalMessage::Input { data }, ); } } up_held.set(None); up_last.set(None); return; } } // Local selection end. up_selecting.set(false); } }; let handle_click = move |_| { focus_ime_textarea(&ime_ref); }; // -- View --------------------------------------------------------------- view! {

"Rust Browser Terminal"

"Axum + portable-pty backend, Leptos + Rust/WASM client."

{move || connection_status.get()} {move || { let _ = render_tick.get(); // `try_*` so a stale render during hydration (when // the signal may already be disposed) no-ops. core_signal .try_with_untracked(|core| { let viewport = current_viewport.get(); let mode = view_mode.get(); format!( "view {}x{} / term {}x{} / history {} / screen {} / mode {}", viewport.cols, viewport.rows, core.size().1, core.size().0, core.history_rows().len(), core.screen_rows().len(), if mode.is_live() { "live" } else { "history" }, ) }) .unwrap_or_default() }}
{move || { // Subscribe to render_tick (frame-coalesced data // changes), scroll_top (scroll), view_mode, selection. let _ = render_tick.get(); let is_live = view_mode.get().is_live(); let sel = selection.get(); let (cell_w, _cell_h) = cell_size.get(); let vp = current_viewport.get_untracked(); let vp_rows = vp.rows as usize; let cols = vp.cols as usize; let st = scroll_top.get(); // Read core untracked — the core signal itself never // notifies; render_tick is the explicit render trigger. // `try_*` so a stale render during hydration no-ops to // an empty view instead of panicking. let (total_px, offset_px, visible) = core_signal .try_with_untracked(|core| { virtual_window(core, is_live, st, vp_rows) }) .unwrap_or((0.0, 0.0, Vec::new())); let window_start = (offset_px / SCROLL_LINE_HEIGHT).round() as usize; let window_end = window_start + visible.len(); let rects = selection_rects( sel, window_start, window_end, cols, cell_w, ); render_spaced_view(total_px, offset_px, visible, rects) }}
} } // --------------------------------------------------------------------------- // Virtual-scroll rendering // --------------------------------------------------------------------------- /// Compute the visible window slice. Returns owned data so the caller can /// drop the `&TerminalCore` borrow before building the view. /// /// In live mode the window is pinned to the **bottom** (the last `vp_rows` /// rows) regardless of `scroll_top_px`, because the DOM scrollTop is driven to /// the bottom separately and the rendered rows must line up there. In history /// mode the window follows `scroll_top_px`. fn virtual_window( core: &TerminalCore, is_live: bool, scroll_top_px: i32, vp_rows: usize, ) -> (f64, f64, Vec) { let total = core.total_rows(); let is_alt = core.alternate_screen(); if is_alt || total == 0 { // Alt-screen / empty: content fits the viewport; total height == rows. let rows = core.screen_rows().to_vec(); let total_px = rows.len() as f64 * SCROLL_LINE_HEIGHT; return (total_px, 0.0, rows); } let first_visible = if is_live { // Pin to the bottom: first visible row = total - viewport. total.saturating_sub(vp_rows) } else { (f64::from(scroll_top_px.max(0)) / SCROLL_LINE_HEIGHT).floor() as usize }; let start = first_visible.saturating_sub(VIRTUAL_OVERSCAN); let end = (first_visible + vp_rows + VIRTUAL_OVERSCAN).min(total); // Absolute-positioning virtual scroll: the container is ALWAYS exactly // `total_px` tall (so the scroll geometry never changes when the visible // window is swapped), and the rendered rows are offset to `offset_px` from // the top. Keeping the container height constant across re-renders is what // prevents the browser from clamping scrollTop mid-scroll and firing the // feedback scroll events that caused the "keeps drifting up" behaviour. let total_px = total as f64 * SCROLL_LINE_HEIGHT; let offset_px = start as f64 * SCROLL_LINE_HEIGHT; (total_px, offset_px, core.collect_window(start, end)) } /// A highlight rectangle for one selected row, positioned relative to the top /// of the rendered (translated) rows layer. #[derive(Clone, Copy, Debug)] struct SelectionRect { top_px: f64, left_px: f64, width_px: f64, } /// Compute the per-row highlight rects for `sel`, clipped to the rendered /// window `[window_start, window_end)`. Returns empty for no/empty selection. fn selection_rects( sel: Option, window_start: usize, window_end: usize, cols: usize, cell_w: f64, ) -> Vec { let Some(sel) = sel else { return Vec::new(); }; if sel.is_empty() { return Vec::new(); } let ((sr, sc), (er, ec)) = sel.ordered(); let first = sr.max(window_start); let last = er.min(window_end.saturating_sub(1)); let mut rects = Vec::new(); for r in first..=last { if r >= window_end { break; } let (col_a, col_b) = if sr == er { (sc, ec) } else if r == sr { (sc, cols) } else if r == er { (0, ec) } else { (0, cols) }; let col_b = col_b.max(col_a); rects.push(SelectionRect { top_px: (r - window_start) as f64 * SCROLL_LINE_HEIGHT, left_px: col_a as f64 * cell_w, width_px: (col_b - col_a) as f64 * cell_w, }); } rects } /// Render the visible rows using absolute positioning inside a fixed-height /// container. /// /// The outer `.terminal-grid` is sized to the FULL content height /// (`total_px`), constant across re-renders, so swapping the visible window /// never changes the scroll container's geometry — the browser keeps the /// scrollTop stable and fires no spurious scroll events. The visible rows are /// placed in an absolutely-positioned layer offset by `offset_px`. fn render_spaced_view( total_px: f64, offset_px: f64, visible: Vec, rects: Vec, ) -> impl IntoView { view! {
{rects.into_iter().map(render_selection_rect).collect_view()} {visible.into_iter().map(render_terminal_row).collect_view()}
} } fn render_selection_rect(rect: SelectionRect) -> impl IntoView { let style = format!( "top:{}px;left:{}px;width:{}px;height:{}px;", rect.top_px, rect.left_px, rect.width_px, SCROLL_LINE_HEIGHT ); view! { } } fn render_terminal_row(row: TerminalRow) -> impl IntoView { view! {
{row .segments .into_iter() .map(render_terminal_segment) .collect_view()}
} } fn render_terminal_segment(segment: TerminalSegment) -> impl IntoView { // Lock the segment to an exact `cols`-cell grid width. `ch` is the advance // of "0" in the monospace font (== the cell width), so glyphs pulled from a // fallback font with a different advance (e.g. braille sparkline chars in // `btm`) are clipped to the grid instead of pushing the row sideways. let style = format!( "width:{}ch;color:{};background:{};font-weight:{};font-style:{};text-decoration:{};", segment.cols, segment.style.color, segment.style.background, if segment.style.bold { "700" } else { "400" }, if segment.style.italic { "italic" } else { "normal" }, if segment.style.underline { "underline" } else { "none" }, ); view! { {segment.text} } } // --------------------------------------------------------------------------- // Frame-coalesced render scheduling // --------------------------------------------------------------------------- /// Schedule a single render for the next animation frame. /// /// While `raf_pending` is set, additional calls are no-ops, so any number of /// WebSocket packets that arrive within the same frame collapse into one /// render. On the frame, `render_tick` is bumped (triggering exactly one DOM /// render) and, if the view is in live mode, the viewport is scrolled to the /// bottom — once, instead of once per packet. fn schedule_frame( raf_pending: Rc>, render_tick: RwSignal, view_mode: RwSignal, terminal_ref: NodeRef, programmatic_scroll_top: RwSignal, ) { if raf_pending.get() { return; } raf_pending.set(true); let callback = Closure::once_into_js(move || { raf_pending.set(false); // One reactive render for everything ingested since the last frame. // `try_*` so a frame that fires after the component is disposed // no-ops instead of panicking. if render_tick.try_update(|t| *t += 1).is_none() { return; } // Follow new output only while the view is in live mode. The scroll // handler flips to History the instant the user scrolls up (and the // position-based echo detection keeps our own programmatic scrolls from // being mistaken for user scrolls), so this never fights the user. if view_mode.try_get_untracked().map(|m| m.is_live()) == Some(true) { sync_scroll_to_bottom(terminal_ref, programmatic_scroll_top); } }); let _ = window().request_animation_frame(callback.as_ref().unchecked_ref()); } // --------------------------------------------------------------------------- // Scroll synchronisation helpers // --------------------------------------------------------------------------- /// Pin the viewport to the bottom. Idempotent: if already at the bottom it /// writes nothing (so it can't interrupt the user's momentum scrolling). The /// target position is recorded in `programmatic_scroll_top` so the scroll /// handler can recognise the resulting scroll event as our own. fn sync_scroll_to_bottom( terminal_ref: NodeRef, programmatic_scroll_top: RwSignal, ) { let callback = Closure::::wrap(Box::new(move || { let Some(element) = terminal_ref.get_untracked() else { return; }; let max_scroll = (element.scroll_height() - element.client_height()).max(0); let _ = programmatic_scroll_top.try_set(max_scroll); if (element.scroll_top() - max_scroll).abs() > 1 { element.set_scroll_top(max_scroll); } })); let _ = window().set_timeout_with_callback_and_timeout_and_arguments_0( callback.as_ref().unchecked_ref(), 0, ); callback.forget(); } /// Scroll to an arbitrary pixel position (used after resize in history mode), /// recording it as a programmatic scroll. fn sync_scroll_to_position( terminal_ref: NodeRef, target_scroll_top: i32, programmatic_scroll_top: RwSignal, ) { let callback = Closure::::wrap(Box::new(move || { let Some(element) = terminal_ref.get_untracked() else { return; }; let max_scroll = (element.scroll_height() - element.client_height()).max(0); let target = target_scroll_top.min(max_scroll); let _ = programmatic_scroll_top.try_set(target); if (element.scroll_top() - target).abs() > 1 { element.set_scroll_top(target); } })); let _ = window().set_timeout_with_callback_and_timeout_and_arguments_0( callback.as_ref().unchecked_ref(), 0, ); callback.forget(); } // --------------------------------------------------------------------------- // Network helpers // --------------------------------------------------------------------------- fn websocket_url() -> String { let window = window(); let location = window.location(); let protocol = location.protocol().unwrap_or_else(|_| "http:".to_owned()); let host = location .host() .unwrap_or_else(|_| "127.0.0.1:3100".to_owned()); let scheme = if protocol == "https:" { "wss" } else { "ws" }; format!("{scheme}://{host}{}/terminal/ws", SiteConfig::BASE_PATH) } fn send_terminal_message( socket: &WebSocket, message: &ClientTerminalMessage, ) { if let Ok(payload) = serde_json::to_string(message) { let _ = socket.send_with_str(&payload); } } fn send_resize_message(socket: &WebSocket, viewport: TerminalViewport) { send_terminal_message( socket, &ClientTerminalMessage::Resize { cols: viewport.cols, rows: viewport.rows, pixel_width: viewport.pixel_width, pixel_height: viewport.pixel_height, }, ); } // --------------------------------------------------------------------------- // Measurement helpers // --------------------------------------------------------------------------- fn measure_terminal_viewport( terminal_ref: NodeRef, measure_ref: NodeRef, ) -> TerminalViewport { let Some(element) = terminal_ref.get_untracked() else { return TerminalViewport::default(); }; let Some(element) = element.dyn_ref::() else { return TerminalViewport::default(); }; measure_terminal_viewport_from_rect( f64::from(element.client_width().max(0)), f64::from(element.client_height().max(0)), measure_ref, ) } fn measure_terminal_viewport_from_rect( width: f64, height: f64, measure_ref: NodeRef, ) -> TerminalViewport { let (cell_width, cell_height) = measure_cell_size(measure_ref); let safe_width = (width - 2.0).max(cell_width); let safe_height = (height - 2.0).max(cell_height); let cols = ((safe_width / cell_width).floor().max(1.0) as u16).max(MIN_COLS); let rows = ((safe_height / cell_height).floor().max(1.0) as u16).max(MIN_ROWS); TerminalViewport { cols, rows, pixel_width: safe_width.floor().max(1.0) as u16, pixel_height: safe_height.floor().max(1.0) as u16, } } fn measure_cell_size(measure_ref: NodeRef) -> (f64, f64) { let Some(element) = measure_ref.get_untracked() else { return (DEFAULT_CELL_WIDTH, DEFAULT_CELL_HEIGHT); }; let Some(element) = element.dyn_ref::() else { return (DEFAULT_CELL_WIDTH, DEFAULT_CELL_HEIGHT); }; let width = f64::from(element.offset_width().max(0)); let height = f64::from(element.offset_height().max(0)); if width <= 0.0 || height <= 0.0 { return (DEFAULT_CELL_WIDTH, DEFAULT_CELL_HEIGHT); } ( width / (MEASURE_SAMPLE_TEXT.chars().count() as f64), height, ) } /// Map a pointer position to a logical `(row, col)` cell. /// /// `local_x`/`local_y` are relative to the scroll container's top-left /// (clientX/Y minus its bounding rect). Padding is removed, the scroll offset /// is added to Y, then the result is divided by the cell size and clamped to /// the grid. Pure for unit-testing. fn point_to_cell( local_x: f64, local_y: f64, scroll_top: f64, cell_w: f64, cell_h: f64, cols: usize, total_rows: usize, ) -> (usize, usize) { let x = (local_x - TERMINAL_PADDING).max(0.0); let y = (local_y - TERMINAL_PADDING + scroll_top).max(0.0); let col = (x / cell_w.max(1.0)).floor() as usize; let row = (y / cell_h.max(1.0)).floor() as usize; ( row.min(total_rows.saturating_sub(1)), col.min(cols.saturating_sub(1)), ) } /// Focus the hidden IME textarea so keyboard input and composition go there. fn focus_ime_textarea(ime_ref: &NodeRef) { if let Some(element) = ime_ref.get_untracked() { let _ = element.focus(); } } /// Write text to the system clipboard (fire-and-forget). Called from a user /// gesture (Ctrl-Shift-C keydown), which browsers allow without a prompt in /// secure contexts (localhost counts as secure). fn copy_to_clipboard(text: &str) { if text.is_empty() { return; } let clipboard = window().navigator().clipboard(); 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); } } /// Send a string as terminal input, scrolling to the bottom if needed. fn send_input_data( data: String, socket_signal: RwSignal>, mode_signal: RwSignal, terminal_ref: NodeRef, prog_scroll: RwSignal, ) { if data.is_empty() { return; } let Some(socket) = socket_signal.get_untracked() else { return; }; if !mode_signal.get_untracked().is_live() { mode_signal.set(ViewMode::Live); sync_scroll_to_bottom(terminal_ref, prog_scroll); } send_terminal_message( &socket, &ClientTerminalMessage::Input { data }, ); } /// Read the current value of the hidden IME textarea. fn ime_ref_text(ime_ref: &NodeRef) -> Option { ime_ref.get_untracked().map(|t| t.value()) } /// Clear the hidden IME textarea after sending composed/typed text. fn clear_ime_textarea(ime_ref: &NodeRef) { if let Some(t) = ime_ref.get_untracked() { t.set_value(""); } } // --------------------------------------------------------------------------- // 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 /// positive row count (the direction is taken from the sign separately). One /// 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; let magnitude = delta_y.abs(); if magnitude == 0.0 { return 0; } let rows = match delta_mode { 1 => magnitude, // already in lines 2 => magnitude * WHEEL_LINES_PER_PAGE, // pages _ => magnitude / WHEEL_PIXELS_PER_ROW, // pixels }; let rows = rows.round() as usize; rows.clamp(1, max_rows.max(1)) } // --------------------------------------------------------------------------- // Mouse reporting // --------------------------------------------------------------------------- /// What kind of mouse action is being reported. Wheel ticks are reported as /// a `Press` of button 64/65. #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum MouseAction { Press, Release, Motion, } /// Map a browser mouse button index (`MouseEvent.button`) to the terminal's /// low button code, or `None` for buttons we don't report (e.g. button 4+). /// Left=0, middle=1, right=2 — same numbering xterm uses. fn mouse_button_base(browser_button: i16) -> Option { match browser_button { 0..=2 => Some(browser_button as u8), _ => None, } } /// OR the modifier bits (shift=4, meta=8, control=16) into a mouse button /// code, matching the xterm mouse encoding. fn with_mouse_modifiers(button: u8, shift: bool, meta: bool, ctrl: bool) -> u8 { let mut b = button; if shift { b |= 4; } if meta { b |= 8; } if ctrl { b |= 16; } b } /// Encode a mouse event as the bytes a terminal would receive, per the app's /// requested encoding. `button` is the fully-composed low code (button + /// modifiers + motion/wheel bits); `col`/`row` are 0-based grid coordinates. /// /// SGR (1006) is fully correct and ASCII-only. The legacy Default/UTF-8 /// encodings are correct for coordinates ≤ 94 (the ASCII-safe range); beyond /// that they are clamped, which matches what most terminals do for large /// screens — and modern apps (vim, htop, tmux) negotiate SGR anyway. fn encode_mouse_report( button: u8, action: MouseAction, col: usize, row: usize, encoding: MouseProtocolEncoding, ) -> String { match encoding { MouseProtocolEncoding::Sgr => { let suffix = if action == MouseAction::Release { 'm' } else { 'M' }; format!("\u{1b}[<{button};{};{}{suffix}", col + 1, row + 1) } MouseProtocolEncoding::Default | MouseProtocolEncoding::Utf8 => { // Legacy: ESC[M followed by three values each offset by +32. // Release is reported as button 3 in this encoding. let b = if action == MouseAction::Release { 3 } else { button }; let c = col.min(94) as u8 + 1; let r = row.min(94) as u8 + 1; format!( "\u{1b}M{}{}{}", char::from_u32(u32::from(b) + 32).unwrap_or(' '), char::from_u32(u32::from(c) + 32).unwrap_or(' '), char::from_u32(u32::from(r) + 32).unwrap_or(' '), ) } } } /// Map a pixel position inside the `.terminal-screen` content box to 0-based /// grid coordinates (row, col), clamped to the viewport. Unlike `point_to_cell` /// this does NOT add `scroll_top`: mouse reports are relative to the grid the /// app is currently drawing (the visible screen), not the scrollback buffer. fn pixel_to_grid( local_x: f64, local_y: f64, cell_w: f64, cell_h: f64, cols: usize, rows: usize, ) -> (usize, usize) { let x = (local_x - TERMINAL_PADDING).max(0.0); let y = (local_y - TERMINAL_PADDING).max(0.0); let col = ((x / cell_w.max(1.0)).floor() as usize).min(cols.saturating_sub(1)); let row = ((y / cell_h.max(1.0)).floor() as usize).min(rows.saturating_sub(1)); (row, col) } /// Prepare clipboard text for sending to the PTY. /// /// Normalises line endings to CR (`\r`), the byte a terminal expects for line /// submission (Enter sends `\r`). When the application enabled bracketed paste, /// wraps the text in `ESC[200~ … ESC[201~`. fn prepare_paste(raw: &str, bracketed: bool) -> String { let normalized = raw.replace("\r\n", "\r").replace('\n', "\r"); if bracketed { format!("\u{1b}[200~{normalized}\u{1b}[201~") } else { normalized } } /// Build a cursor/nav key sequence, SS3 (`ESC O `) when the app is in /// application-cursor mode, else CSI (`ESC [ `). fn cursor_seq(final_byte: char, app_cursor: bool) -> String { if app_cursor { format!("\u{1b}O{final_byte}") } else { format!("\u{1b}[{final_byte}") } } /// Pure key-table: map a key + modifier state + application-cursor flag to /// the bytes a terminal would receive for the same key. Split out from /// `map_key_to_terminal_input` so the entire table is unit-testable without /// constructing a `KeyboardEvent` (which isn't easy in native tests). fn key_to_bytes( key: &str, ctrl: bool, alt: bool, meta: bool, 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, 'A'..='Z' => (c as u8) & 0x1f, '@' => 0x00, '[' => 0x1b, '\\' => 0x1c, ']' => 0x1d, '^' => 0x1e, '_' => 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()); } if let (true, Some(c)) = (alt && !ctrl && !meta, key.chars().next()) { return Some(format!("\u{1b}{c}")); } match key { "Enter" => Some("\r".to_owned()), "Backspace" => Some("\u{7f}".to_owned()), "Tab" => Some("\t".to_owned()), "Escape" => Some("\u{1b}".to_owned()), "ArrowUp" => Some(cursor_seq('A', app_cursor)), "ArrowDown" => Some(cursor_seq('B', app_cursor)), "ArrowRight" => Some(cursor_seq('C', app_cursor)), "ArrowLeft" => Some(cursor_seq('D', app_cursor)), "Home" => Some(cursor_seq('H', app_cursor)), "End" => Some(cursor_seq('F', app_cursor)), "Insert" => Some("\u{1b}[2~".to_owned()), "Delete" => Some("\u{1b}[3~".to_owned()), "PageUp" => Some("\u{1b}[5~".to_owned()), "PageDown" => Some("\u{1b}[6~".to_owned()), "F1" => Some("\u{1b}OP".to_owned()), "F2" => Some("\u{1b}OQ".to_owned()), "F3" => Some("\u{1b}OR".to_owned()), "F4" => Some("\u{1b}OS".to_owned()), "F5" => Some("\u{1b}[15~".to_owned()), "F6" => Some("\u{1b}[17~".to_owned()), "F7" => Some("\u{1b}[18~".to_owned()), "F8" => Some("\u{1b}[19~".to_owned()), "F9" => Some("\u{1b}[20~".to_owned()), "F10" => Some("\u{1b}[21~".to_owned()), "F11" => Some("\u{1b}[23~".to_owned()), "F12" => Some("\u{1b}[24~".to_owned()), _ if key.chars().count() == 1 && !meta => Some(key.to_owned()), _ => None, } } /// Map a browser keyboard event to the bytes a terminal would receive for /// the same key. Thin wrapper around `key_to_bytes`. fn map_key_to_terminal_input( event: &KeyboardEvent, app_cursor: bool, ) -> Option { key_to_bytes( &event.key(), event.ctrl_key(), event.alt_key(), event.meta_key(), app_cursor, ) } /// Whether a keydown event represents a regular printable character that /// should be handled by the IME textarea's `input` event, rather than sent /// directly from `keydown`. This lets the browser manage IME composition, /// dead keys, and upper/lower case before we see the final text. fn is_regular_text_input(key: &str, ctrl: bool, alt: bool, meta: bool) -> bool { key.chars().count() == 1 && !ctrl && !alt && !meta } #[cfg(test)] mod tests { use super::*; use crate::terminal::core::TerminalCore; fn core_with_lines(n: usize) -> TerminalCore { let mut core = TerminalCore::new(24, 80); for i in 1..=n { core.process(format!("line{i}\r\n").as_bytes()); } core } #[test] 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_rows_for_delta(SCROLL_LINE_HEIGHT, 0, MAX), 1); // A few lines of pixels → that many rows. 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_rows_for_delta(1.0, 0, MAX), 1); // Line mode: delta is already in lines. 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_rows_for_delta(-SCROLL_LINE_HEIGHT * 2.0, 0, MAX), 2); // Zero delta → no rows. 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] fn point_to_cell_maps_pixels_with_padding_and_scroll() { let (cw, ch) = (8.0, 18.0); // Top-left content origin is at (PADDING, PADDING) = (16,16). assert_eq!(point_to_cell(16.0, 16.0, 0.0, cw, ch, 80, 100), (0, 0)); // One cell right and one row down. assert_eq!(point_to_cell(16.0 + 8.0, 16.0 + 18.0, 0.0, cw, ch, 80, 100), (1, 1)); // Scroll offset shifts the row mapping down. assert_eq!(point_to_cell(16.0, 16.0, 180.0, cw, ch, 80, 100), (10, 0)); // Clamped to grid bounds. assert_eq!(point_to_cell(99999.0, 99999.0, 0.0, cw, ch, 80, 100), (99, 79)); // Inside the padding clamps to (0,0), never negative. assert_eq!(point_to_cell(0.0, 0.0, 0.0, cw, ch, 80, 100), (0, 0)); } #[test] fn pixel_to_grid_maps_visible_screen_coordinates() { let (cw, ch) = (8.0, 18.0); let cols = 80; let rows = 24; // Top-left of the content box (padding, padding) → (0, 0). assert_eq!( pixel_to_grid(16.0, 16.0, cw, ch, cols, rows), (0, 0) ); // One cell right/down. assert_eq!( pixel_to_grid(16.0 + 8.0, 16.0 + 18.0, cw, ch, cols, rows), (1, 1) ); // Clamped to viewport bounds. assert_eq!( pixel_to_grid(99999.0, 99999.0, cw, ch, cols, rows), (23, 79) ); // Negative/local inside padding clamps to (0, 0). assert_eq!(pixel_to_grid(0.0, 0.0, cw, ch, cols, rows), (0, 0)); } #[test] fn encode_mouse_report_sgr_encoding() { // Left press at col 5, row 3 (0-based → 6,4 in the report). assert_eq!( encode_mouse_report(0, MouseAction::Press, 5, 3, MouseProtocolEncoding::Sgr), "\u{1b}[<0;6;4M" ); // Left release uses the same button code but 'm' suffix. assert_eq!( encode_mouse_report(0, MouseAction::Release, 5, 3, MouseProtocolEncoding::Sgr), "\u{1b}[<0;6;4m" ); // Motion with left button held (32+0) + shift modifier (4) = 36. assert_eq!( encode_mouse_report(36, MouseAction::Motion, 0, 0, MouseProtocolEncoding::Sgr), "\u{1b}[<36;1;1M" ); // Wheel down (65). assert_eq!( encode_mouse_report(65, MouseAction::Press, 10, 20, MouseProtocolEncoding::Sgr), "\u{1b}[<65;11;21M" ); } #[test] fn encode_mouse_report_default_encoding() { // Left press: ESC M ' ' (32) '!' (33) '!' (33) for col 1 row 1. assert_eq!( encode_mouse_report(0, MouseAction::Press, 0, 0, MouseProtocolEncoding::Default), "\u{1b}M !!" ); // Release is always button 3 in Default encoding. assert_eq!( encode_mouse_report(0, MouseAction::Release, 0, 0, MouseProtocolEncoding::Default), "\u{1b}M#!!" ); // Coordinates are clamped to the ASCII-safe range (col 94 → report 95+32=127). assert_eq!( encode_mouse_report(0, MouseAction::Press, 200, 100, MouseProtocolEncoding::Default), "\u{1b}M \u{7f}\u{7f}" ); } #[test] fn mouse_modifiers_or_into_button_code() { assert_eq!(with_mouse_modifiers(0, false, false, false), 0); assert_eq!(with_mouse_modifiers(0, true, false, false), 4); assert_eq!(with_mouse_modifiers(0, false, true, false), 8); assert_eq!(with_mouse_modifiers(0, false, false, true), 16); assert_eq!(with_mouse_modifiers(0, true, true, true), 28); assert_eq!(with_mouse_modifiers(2, true, false, true), 22); } #[test] fn mouse_button_base_only_maps_three_buttons() { assert_eq!(mouse_button_base(0), Some(0)); assert_eq!(mouse_button_base(1), Some(1)); assert_eq!(mouse_button_base(2), Some(2)); assert_eq!(mouse_button_base(3), None); assert_eq!(mouse_button_base(4), None); } #[test] fn selection_rects_clip_to_window_and_span_columns() { let cw = 8.0; let cols = 80; // Single-row selection cols 2..6 on row 5, window [0,30). let sel = Some(Selection { anchor: (5, 2), head: (5, 6) }); let rects = selection_rects(sel, 0, 30, cols, cw); assert_eq!(rects.len(), 1); assert_eq!(rects[0].top_px, 5.0 * SCROLL_LINE_HEIGHT); assert_eq!(rects[0].left_px, 2.0 * cw); assert_eq!(rects[0].width_px, 4.0 * cw); // Multi-row selection rows 4..6: first row partial, middle full, last partial. let sel = Some(Selection { anchor: (4, 10), head: (6, 3) }); let rects = selection_rects(sel, 0, 30, cols, cw); assert_eq!(rects.len(), 3); assert_eq!(rects[0].left_px, 10.0 * cw); // first row starts at col 10 assert_eq!(rects[1].width_px, cols as f64 * cw); // middle row full width assert_eq!(rects[2].left_px, 0.0); // last row from col 0 assert_eq!(rects[2].width_px, 3.0 * cw); // Selection entirely above the window → clipped out. let sel = Some(Selection { anchor: (1, 0), head: (2, 5) }); assert!(selection_rects(sel, 10, 30, cols, cw).is_empty()); // Empty selection → no rects. let sel = Some(Selection { anchor: (5, 2), head: (5, 2) }); assert!(selection_rects(sel, 0, 30, cols, cw).is_empty()); assert!(selection_rects(None, 0, 30, cols, cw).is_empty()); } #[test] fn is_regular_text_input_detects_printable_keys() { assert!(is_regular_text_input("a", false, false, false)); assert!(is_regular_text_input("A", false, false, false)); // Shift handled by browser assert!(is_regular_text_input(" ", false, false, false)); assert!(!is_regular_text_input("Enter", false, false, false)); assert!(!is_regular_text_input("ArrowUp", false, false, false)); assert!(!is_regular_text_input("a", true, false, false)); // Ctrl+a assert!(!is_regular_text_input("a", false, true, false)); // Alt+a assert!(!is_regular_text_input("a", false, false, true)); // Meta+a assert!(!is_regular_text_input("Dead", false, false, false)); } #[test] fn key_to_bytes_table() { // --- basic printable / control keys --- assert_eq!(key_to_bytes("a", false, false, false, false).as_deref(), Some("a")); assert_eq!(key_to_bytes("Z", false, false, false, false).as_deref(), Some("Z")); assert_eq!(key_to_bytes("Enter", false, false, false, false).as_deref(), Some("\r")); assert_eq!(key_to_bytes("Backspace", false, false, false, false).as_deref(), Some("\u{7f}")); assert_eq!(key_to_bytes("Tab", false, false, false, false).as_deref(), Some("\t")); assert_eq!(key_to_bytes("Escape", false, false, false, false).as_deref(), Some("\u{1b}")); // --- application-cursor mode toggles arrows / Home / End to SS3 --- let up_csi = key_to_bytes("ArrowUp", false, false, false, false).unwrap(); assert_eq!(up_csi, "\u{1b}[A"); let up_ss3 = key_to_bytes("ArrowUp", false, false, false, true).unwrap(); assert_eq!(up_ss3, "\u{1b}OA"); let home_csi = key_to_bytes("Home", false, false, false, false).unwrap(); assert_eq!(home_csi, "\u{1b}[H"); let home_ss3 = key_to_bytes("Home", false, false, false, true).unwrap(); assert_eq!(home_ss3, "\u{1b}OH"); // Arrows + Home/End cover all four directions. for (key, normal, alt) in [ ("ArrowUp", 'A', 'A'), ("ArrowDown", 'B', 'B'), ("ArrowRight", 'C', 'C'), ("ArrowLeft", 'D', 'D'), ("Home", 'H', 'H'), ("End", 'F', 'F'), ] { let (csi, ss3) = (normal, alt); assert_eq!(key_to_bytes(key, false, false, false, false).unwrap(), format!("\u{1b}[{csi}")); assert_eq!(key_to_bytes(key, false, false, false, true).unwrap(), format!("\u{1b}O{ss3}")); } // --- paging / editing keys (always CSI) --- assert_eq!(key_to_bytes("Insert", false, false, false, false).as_deref(), Some("\u{1b}[2~")); assert_eq!(key_to_bytes("Delete", false, false, false, false).as_deref(), Some("\u{1b}[3~")); assert_eq!(key_to_bytes("PageUp", false, false, false, false).as_deref(), Some("\u{1b}[5~")); assert_eq!(key_to_bytes("PageDown", false, false, false, false).as_deref(), Some("\u{1b}[6~")); // Paging does not change with app-cursor mode. assert_eq!(key_to_bytes("PageUp", false, false, false, true).as_deref(), Some("\u{1b}[5~")); // --- function keys (F1-F4 SS3, F5-F12 CSI~ with 16/22 gaps) --- for (key, expected) in [ ("F1", "\u{1b}OP"), ("F2", "\u{1b}OQ"), ("F3", "\u{1b}OR"), ("F4", "\u{1b}OS"), ("F5", "\u{1b}[15~"), ("F6", "\u{1b}[17~"), ("F7", "\u{1b}[18~"), ("F8", "\u{1b}[19~"), ("F9", "\u{1b}[20~"), ("F10", "\u{1b}[21~"), ("F11", "\u{1b}[23~"), ("F12", "\u{1b}[24~"), ] { assert_eq!(key_to_bytes(key, false, false, false, false).as_deref(), Some(expected), "key {key}"); } // --- 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}'), ("A", '\u{1}'), ] { assert_eq!( key_to_bytes(letter, true, false, false, false).as_deref(), Some(&code.to_string()[..]), "ctrl+{letter}" ); } // 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{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")); assert_eq!(key_to_bytes("x", false, true, false, false).as_deref(), Some("\u{1b}x")); // Alt+Ctrl+char falls through to the printable-letter fallback too. assert_eq!(key_to_bytes("a", true, true, false, false).as_deref(), Some("a")); // Meta suppresses passthrough. assert_eq!(key_to_bytes("a", false, false, true, false), None); } #[test] fn prepare_paste_normalizes_newlines_and_wraps_when_bracketed() { // Plain paste: CRLF and LF both become CR, no wrapping. assert_eq!(prepare_paste("a\r\nb\nc", false), "a\rb\rc"); assert_eq!(prepare_paste("echo hi", false), "echo hi"); // Bracketed paste: same normalization, wrapped in 200~/201~. let esc = '\u{1b}'; assert_eq!( prepare_paste("a\nb", true), format!("{esc}[200~a\rb{esc}[201~") ); // Empty-ish content still wraps consistently when bracketed. assert_eq!(prepare_paste("", true), format!("{esc}[200~{esc}[201~")); } #[test] fn live_mode_window_is_pinned_to_bottom_regardless_of_scroll_top() { let core = core_with_lines(1000); let total = core.total_rows(); let vp_rows = 24; // Even with a stale scroll_top of 0, live mode must render the BOTTOM // window (this was the blank-area / no-output bug). let (total_px, offset_px, visible) = virtual_window(&core, true, 0, vp_rows); // Sizer is always the full content height (constant across renders). assert!( (total_px - total as f64 * SCROLL_LINE_HEIGHT).abs() < 1.0, "sizer height must equal total content height" ); // The visible window is offset to the bottom: start = total-vp-overscan. let expected_start = total - vp_rows - VIRTUAL_OVERSCAN; assert!( (offset_px - expected_start as f64 * SCROLL_LINE_HEIGHT).abs() < 1.0 ); // The window covers through the last row. assert!( offset_px + visible.len() as f64 * SCROLL_LINE_HEIGHT >= total as f64 * SCROLL_LINE_HEIGHT - 1.0, "bottom window must reach the final row" ); } #[test] fn history_mode_window_follows_scroll_top() { let core = core_with_lines(1000); let total = core.total_rows(); let vp_rows = 24; // Scroll to row 500 (500 * 18px). let st = (500.0 * SCROLL_LINE_HEIGHT) as i32; let (total_px, offset_px, visible) = virtual_window(&core, false, st, vp_rows); // Sizer height is constant regardless of scroll position. assert!((total_px - total as f64 * SCROLL_LINE_HEIGHT).abs() < 1.0); // First rendered row should be ~ (500 - overscan). let first_row = (offset_px / SCROLL_LINE_HEIGHT).round() as usize; assert_eq!(first_row, 500 - VIRTUAL_OVERSCAN); assert!(!visible.is_empty()); } #[test] fn short_content_renders_without_offset() { let core = core_with_lines(3); // far fewer than a 24-row viewport let (total_px, offset_px, visible) = virtual_window(&core, true, 0, 24); // Sizer == content height, no offset, all rows rendered. assert!( (total_px - core.total_rows() as f64 * SCROLL_LINE_HEIGHT).abs() < 1.0 ); assert_eq!(offset_px, 0.0); assert_eq!(visible.len(), core.total_rows()); } }