use std::rc::Rc; use std::sync::Arc; 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, HtmlCanvasElement, HtmlElement, InputEvent, KeyboardEvent, MessageEvent, MouseEvent, ResizeObserver, ResizeObserverEntry, WebSocket, WheelEvent, }; use super::core::{ MouseProtocolMode, SearchDirection, TerminalCore, TerminalLink, TerminalRow, TerminalSearchMatch, TerminalSegment, }; use super::ime::{clear_ime_textarea, focus_ime_textarea, ime_cursor_cell, ime_ref_text}; use super::keyboard::{ cursor_seq, is_regular_text_input, map_key_to_terminal_input, prepare_paste, }; use super::mouse::{ MouseAction, encode_mouse_report, mouse_button_base, pixel_to_grid, wheel_rows_for_delta, with_mouse_modifiers, }; use super::protocol::{ClientTerminalMessage, ServerTerminalMessage}; use super::renderer::{ CanvasDomState, CanvasPlanInput, CanvasSurfaceFrame, CanvasSurfaceMode, RenderCanvasSurfaceRequest, WebGlContextLifecycle, WebGlRenderStats, WebGlRendererState, attach_webgl_context_lifecycle, canvas_render_plan, render_canvas_surface, render_webgl, set_canvas_surface_mode, webgl_surface_mode, }; use super::scrollback::ViewMode; use super::selection::{Selection, SelectionRect, selection_rects}; const DEFAULT_CELL_WIDTH: f64 = 8.0; const DEFAULT_CELL_HEIGHT: f64 = 18.0; const DEFAULT_FONT_SIZE_PX: f64 = 13.0; const DEFAULT_LINE_HEIGHT_PX: f64 = 18.0; const MIN_COLS: u16 = 40; const MIN_ROWS: u16 = 12; const MEASURE_SAMPLE_TEXT: &str = "WWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWW"; const DEFAULT_TERMINAL_FONT_FAMILY: &str = "\"JetBrains Mono\", \"Fira Code\", \"Cascadia Code\", \"SFMono-Regular\", \"Consolas\", monospace"; /// 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; #[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, } } } struct ResizeObserverHandle { observer: ResizeObserver, _callback: Closure, } impl Drop for ResizeObserverHandle { fn drop(&mut self) { self.observer.disconnect(); } } struct WebSocketHandle { socket: Rc, _on_open: Closure, _on_message: Closure, _on_close: Closure, _on_error: Closure, } impl Drop for WebSocketHandle { fn drop(&mut self) { self.socket.set_onopen(None); self.socket.set_onmessage(None); self.socket.set_onclose(None); self.socket.set_onerror(None); let _ = self.socket.close(); } } // --------------------------------------------------------------------------- // TerminalPanel component // --------------------------------------------------------------------------- /// Which renderer draws the terminal grid. Canvas is the default stable /// performance path; WebGL and DOM stay available for regression comparison. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub enum Renderer { WebGl, #[default] Canvas, Dom, } #[derive(Clone, Debug, PartialEq)] pub struct TerminalTheme { pub foreground: String, pub background: String, pub cursor_foreground: String, pub cursor_background: String, pub selection_background: String, } impl Default for TerminalTheme { fn default() -> Self { Self { foreground: "#dbeafe".to_owned(), background: "#0f172a".to_owned(), cursor_foreground: "#0f172a".to_owned(), cursor_background: "#dbeafe".to_owned(), selection_background: "rgb(56 189 248 / 30%)".to_owned(), } } } #[derive(Clone, Debug, PartialEq)] pub struct TerminalFontOptions { pub family: String, pub size_px: f64, pub line_height_px: f64, } impl Default for TerminalFontOptions { fn default() -> Self { Self { family: DEFAULT_TERMINAL_FONT_FAMILY.to_owned(), size_px: DEFAULT_FONT_SIZE_PX, line_height_px: DEFAULT_LINE_HEIGHT_PX, } } } #[derive(Clone, Debug, PartialEq)] pub struct TerminalOptions { pub cols: u16, pub rows: u16, pub min_cols: u16, pub min_rows: u16, pub scrollback: usize, pub renderer: Renderer, pub font: TerminalFontOptions, pub theme: TerminalTheme, pub cursor: TerminalCursorOptions, pub title: String, pub subtitle: String, pub show_header: bool, } impl Default for TerminalOptions { fn default() -> Self { Self { cols: super::core::DEFAULT_COLS, rows: super::core::DEFAULT_ROWS, min_cols: MIN_COLS, min_rows: MIN_ROWS, scrollback: super::core::DEFAULT_SCROLLBACK, renderer: Renderer::Canvas, font: TerminalFontOptions::default(), theme: TerminalTheme::default(), cursor: TerminalCursorOptions::default(), title: "Rust Browser Terminal".to_owned(), subtitle: "Axum + portable-pty backend, Leptos + Rust/WASM client.".to_owned(), show_header: true, } } } #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub enum TerminalCursorStyle { #[default] Block, Underline, Bar, } impl TerminalCursorStyle { fn css_value(self) -> &'static str { match self { Self::Block => "block", Self::Underline => "underline", Self::Bar => "bar", } } } #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct TerminalCursorOptions { pub style: TerminalCursorStyle, pub blink: bool, } impl Default for TerminalCursorOptions { fn default() -> Self { Self { style: TerminalCursorStyle::Block, blink: false, } } } #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct TerminalResizeEvent { pub cols: u16, pub rows: u16, pub pixel_width: u16, pub pixel_height: u16, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct TerminalSelectionEvent { pub start: Option<(usize, usize)>, pub end: Option<(usize, usize)>, } #[derive(Clone, Copy, Debug, PartialEq)] pub struct TerminalRenderEvent { pub cols: u16, pub rows: u16, pub history_rows: usize, pub screen_rows: usize, pub mode: ViewMode, pub renderer: Renderer, pub webgl_stats: Option, } type SerializeFn = Arc String + Send + Sync>; #[derive(Clone, Copy)] pub struct TerminalHandle { input: RwSignal>, clear_token: RwSignal, focus_token: RwSignal, resize: RwSignal>, scroll_to_bottom_token: RwSignal, copy_selection_token: RwSignal, fit_token: RwSignal, search_request: RwSignal>, serialize_text_fn: StoredValue>, serialize_ansi_fn: StoredValue>, } impl TerminalHandle { fn new() -> Self { Self { input: RwSignal::new(None), clear_token: RwSignal::new(0), focus_token: RwSignal::new(0), resize: RwSignal::new(None), scroll_to_bottom_token: RwSignal::new(0), copy_selection_token: RwSignal::new(0), fit_token: RwSignal::new(0), search_request: RwSignal::new(None), serialize_text_fn: StoredValue::new(None), serialize_ansi_fn: StoredValue::new(None), } } pub fn write(&self, data: impl Into) { self.input.set(Some(data.into())); } pub fn paste_text(&self, data: impl Into) { self.write(data); } pub fn clear(&self) { self.clear_token.update(|token| *token += 1); } pub fn focus(&self) { self.focus_token.update(|token| *token += 1); } pub fn resize(&self, rows: u16, cols: u16) { self.resize.set(Some((rows, cols))); } pub fn scroll_to_bottom(&self) { self.scroll_to_bottom_token.update(|token| *token += 1); } pub fn copy_selection(&self) { self.copy_selection_token.update(|token| *token += 1); } pub fn fit(&self) { self.fit_token.update(|token| *token += 1); } pub fn serialize_text(&self) -> String { self.serialize_text_fn .get_value() .as_ref() .map(|serialize| serialize()) .unwrap_or_default() } pub fn serialize_ansi(&self) -> String { self.serialize_ansi_fn .get_value() .as_ref() .map(|serialize| serialize()) .unwrap_or_default() } pub fn search_next(&self, query: impl Into) { self.search(query, SearchDirection::Forward); } pub fn search_previous(&self, query: impl Into) { self.search(query, SearchDirection::Backward); } pub fn clear_search(&self) { self.search_request.set(Some(TerminalSearchRequest::Clear)); } fn search(&self, query: impl Into, direction: SearchDirection) { self.search_request.set(Some(TerminalSearchRequest::Find { query: query.into(), direction, })); } } #[derive(Clone, Debug, PartialEq, Eq)] enum TerminalSearchRequest { Find { query: String, direction: SearchDirection, }, Clear, } #[derive(Clone, Debug, PartialEq)] pub(super) struct ResolvedTerminalOptions { cols: u16, rows: u16, min_cols: u16, min_rows: u16, scrollback: usize, renderer: Renderer, pub(super) font_family: String, pub(super) font_size_px: f64, line_height_px: f64, pub(super) theme: TerminalTheme, pub(super) cursor: TerminalCursorOptions, title: String, subtitle: String, show_header: bool, } impl From for ResolvedTerminalOptions { fn from(options: TerminalOptions) -> Self { let rows = options.rows.max(1); let cols = options.cols.max(1); Self { cols, rows, min_cols: options.min_cols.max(1), min_rows: options.min_rows.max(1), scrollback: options.scrollback.max(rows as usize), renderer: options.renderer, font_family: if options.font.family.trim().is_empty() { DEFAULT_TERMINAL_FONT_FAMILY.to_owned() } else { options.font.family }, font_size_px: options.font.size_px.max(1.0), line_height_px: options.font.line_height_px.max(1.0), theme: options.theme, cursor: options.cursor, title: options.title, subtitle: options.subtitle, show_header: options.show_header, } } } fn terminal_options_style(options: &ResolvedTerminalOptions) -> String { format!( "--terminal-font-family:{};--terminal-font-size:{}px;--terminal-line-height:{}px;--terminal-fg:{};--terminal-bg:{};--terminal-cursor-fg:{};--terminal-cursor-bg:{};--terminal-selection-bg:{};--terminal-cursor-style:{};--terminal-cursor-blink:{};", options.font_family, options.font_size_px, options.line_height_px, options.theme.foreground, options.theme.background, options.theme.cursor_foreground, options.theme.cursor_background, options.theme.selection_background, options.cursor.style.css_value(), if options.cursor.blink { "terminal-cursor-blink" } else { "none" }, ) } fn terminal_viewport_for_options(options: &ResolvedTerminalOptions) -> TerminalViewport { TerminalViewport { cols: options.cols, rows: options.rows, pixel_width: (f64::from(options.cols) * DEFAULT_CELL_WIDTH) .round() .max(1.0) as u16, pixel_height: (f64::from(options.rows) * options.line_height_px) .round() .max(1.0) as u16, } } fn terminal_viewport_for_size(rows: u16, cols: u16, cell_size: (f64, f64)) -> TerminalViewport { TerminalViewport { cols, rows, pixel_width: (f64::from(cols) * cell_size.0).round().max(1.0) as u16, pixel_height: (f64::from(rows) * cell_size.1).round().max(1.0) as u16, } } fn terminal_resize_event(viewport: TerminalViewport) -> TerminalResizeEvent { TerminalResizeEvent { cols: viewport.cols, rows: viewport.rows, pixel_width: viewport.pixel_width, pixel_height: viewport.pixel_height, } } fn renderer_uses_canvas_surface(renderer: Renderer) -> bool { matches!(renderer, Renderer::WebGl | Renderer::Canvas) } #[component] pub fn TerminalPanel( #[prop(default = TerminalOptions::default().into(), into)] options: Signal, #[prop(optional)] renderer: Option, #[prop(optional)] on_ready: Option>, #[prop(optional)] on_data: Option>, #[prop(optional)] on_resize: Option>, #[prop(optional)] on_selection_change: Option>, #[prop(optional)] on_title_change: Option>, #[prop(optional)] on_render: Option>, ) -> impl IntoView { let mut resolved_options = ResolvedTerminalOptions::from(options.get_untracked()); if let Some(renderer) = renderer { resolved_options.renderer = renderer; } let options_signal = RwSignal::new(resolved_options.clone()); let handle = TerminalHandle::new(); let terminal_ref = NodeRef::::new(); let ime_ref = NodeRef::::new(); let canvas_ref = NodeRef::::new(); let canvas_fallback_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(terminal_viewport_for_options(&resolved_options)); 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::new_with_scrollback( resolved_options.rows, resolved_options.cols, resolved_options.scrollback, )); // 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, resolved_options.line_height_px)); // Active text selection in buffer coords, and whether a drag is in progress. let selection = RwSignal::new(None::); let selecting = RwSignal::new(false); let search_match = RwSignal::new(None::); let search_query = RwSignal::new(String::new()); let last_webgl_stats = RwSignal::new(None::); // 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)); let canvas_state: Rc>> = Rc::new(std::cell::RefCell::new(None)); let canvas_fallback_state: Rc>> = Rc::new(std::cell::RefCell::new(None)); let webgl_state: Rc>> = Rc::new(std::cell::RefCell::new(None)); let webgl_context_lost: Rc> = Rc::new(std::cell::Cell::new(false)); // Accessibility: screen-reader buffer, system motion/contrast preferences. let screen_reader_text = RwSignal::new(String::new()); let screen_reader_debounce: Rc> = Rc::new(std::cell::Cell::new(0.0)); let prefers_reduced_motion = RwSignal::new(false); handle.serialize_text_fn.set_value(Some(Arc::new(move || { core_signal .try_with_untracked(|core| core.buffer_text()) .unwrap_or_default() }))); handle.serialize_ansi_fn.set_value(Some(Arc::new(move || { core_signal .try_with_untracked(|core| core.buffer_ansi()) .unwrap_or_default() }))); if let Some(on_ready) = on_ready { on_ready.run(handle); } // -- 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(ime_cursor_cell(core)) } }) .flatten() else { return String::new(); }; let (cell_w, cell_h) = cell_size.get(); let left = TERMINAL_PADDING + col as f64 * cell_w; let top = TERMINAL_PADDING + row as f64 * cell_h; format!("left:{left}px;top:{top}px;width:{cell_w}px;height:{cell_h}px;") }; Effect::new(move |_| { let next = ResolvedTerminalOptions::from(options.get()); let mut next = next; if let Some(renderer) = renderer { next.renderer = renderer; } if next == options_signal.get_untracked() { return; } let old = options_signal.get_untracked(); options_signal.set(next.clone()); cell_size.set((DEFAULT_CELL_WIDTH, next.line_height_px)); if old.scrollback != next.scrollback || old.rows != next.rows || old.cols != next.cols { core_signal.set(TerminalCore::new_with_scrollback( next.rows, next.cols, next.scrollback, )); current_viewport.set(terminal_viewport_for_options(&next)); } render_tick.update(|t| *t += 1); sync_scroll_to_bottom(terminal_ref, programmatic_scroll_top); }); Effect::new(move |_| { let Some(on_title_change) = on_title_change else { return; }; let _ = render_tick.get(); let title = core_signal .try_with_untracked(|core| core.title().to_owned()) .unwrap_or_default(); on_title_change.run(title); }); Effect::new(move |_| { let Some(on_render) = on_render else { return; }; let _ = render_tick.get(); let viewport = current_viewport.get(); let mode = view_mode.get(); let renderer = options_signal.get_untracked().renderer; if let Some(event) = core_signal.try_with_untracked(|core| TerminalRenderEvent { cols: viewport.cols, rows: viewport.rows, history_rows: core.history_rows().len(), screen_rows: core.screen_rows().len(), mode, renderer, webgl_stats: last_webgl_stats.get_untracked(), }) { on_render.run(event); } }); let lifecycle_webgl_state = webgl_state.clone(); let lifecycle_context_lost = webgl_context_lost.clone(); Effect::new(move |prev: Option>| { drop(prev); let renderer = options_signal.get().renderer; if renderer != Renderer::WebGl { lifecycle_context_lost.set(false); lifecycle_webgl_state.borrow_mut().take(); return None; } let canvas = canvas_ref.get()?; let canvas_el = canvas.dyn_ref::()?; attach_webgl_context_lifecycle( canvas_el, lifecycle_webgl_state.clone(), lifecycle_context_lost.clone(), move || { let _ = render_tick.try_update(|t| *t += 1); }, ) }); Effect::new(move |_| { let Some(on_selection_change) = on_selection_change else { return; }; let event = match selection.get() { Some(sel) if !sel.is_empty() => { let (start, end) = sel.ordered(); TerminalSelectionEvent { start: Some(start), end: Some(end), } } _ => TerminalSelectionEvent { start: None, end: None, }, }; on_selection_change.run(event); }); Effect::new(move |_| { let Some(data) = handle.input.get() else { return; }; handle.input.set(None); send_input_data( data, ws_signal, view_mode, terminal_ref, programmatic_scroll_top, on_data, ); }); Effect::new(move |_| { let token = handle.clear_token.get(); if token == 0 { return; } core_signal.update_untracked(|core| core.clear()); render_tick.update(|t| *t += 1); sync_scroll_to_bottom(terminal_ref, programmatic_scroll_top); }); Effect::new(move |_| { let token = handle.focus_token.get(); if token == 0 { return; } focus_ime_textarea(&ime_ref); }); Effect::new(move |_| { let Some((rows, cols)) = handle.resize.get() else { return; }; handle.resize.set(None); let viewport = terminal_viewport_for_size(rows.max(1), cols.max(1), cell_size.get_untracked()); current_viewport.set(viewport); core_signal.update_untracked(|core| core.resize(viewport.rows, viewport.cols)); render_tick.update(|t| *t += 1); if let Some(socket) = ws_signal.get_untracked() { send_resize_message(&socket, viewport); } if let Some(on_resize) = on_resize { on_resize.run(terminal_resize_event(viewport)); } sync_scroll_to_bottom(terminal_ref, programmatic_scroll_top); }); Effect::new(move |_| { let token = handle.scroll_to_bottom_token.get(); if token == 0 { return; } view_mode.set(ViewMode::Live); sync_scroll_to_bottom(terminal_ref, programmatic_scroll_top); }); Effect::new(move |_| { let token = handle.copy_selection_token.get(); if token == 0 { return; } copy_current_selection(selection, core_signal); }); Effect::new(move |_| { let token = handle.fit_token.get(); if token == 0 { return; } let options = options_signal.get_untracked(); let viewport = measure_terminal_viewport(terminal_ref, measure_ref, &options); cell_size.set(measure_cell_size(measure_ref)); if viewport != current_viewport.get_untracked() { current_viewport.set(viewport); core_signal.update_untracked(|core| core.resize(viewport.rows, viewport.cols)); render_tick.update(|t| *t += 1); if let Some(socket) = ws_signal.get_untracked() { send_resize_message(&socket, viewport); } if let Some(on_resize) = on_resize { on_resize.run(terminal_resize_event(viewport)); } } if view_mode.get_untracked().is_live() { sync_scroll_to_bottom(terminal_ref, programmatic_scroll_top); } else { sync_scroll_to_position( terminal_ref, scroll_top.get_untracked(), programmatic_scroll_top, ); } focus_ime_textarea(&ime_ref); }); Effect::new(move |_| { let Some(request) = handle.search_request.get() else { return; }; handle.search_request.set(None); match request { TerminalSearchRequest::Clear => { search_query.set(String::new()); search_match.set(None); } TerminalSearchRequest::Find { query, direction } => { let query = query.trim().to_owned(); if query.is_empty() { search_query.set(String::new()); search_match.set(None); return; } let from = if search_query.get_untracked() == query { search_match.get_untracked().map(|mat| match direction { SearchDirection::Forward => (mat.row, mat.end_col), SearchDirection::Backward => (mat.row, mat.start_col), }) } else { None }; let found = core_signal .try_with_untracked(|core| core.search(&query, from, direction)) .flatten(); search_query.set(query); search_match.set(found.clone()); if let Some(mat) = found { let total = core_signal .try_with_untracked(|core| core.total_rows()) .unwrap_or(0); scroll_match_into_view( &mat, SearchScrollContext { total_rows: total, viewport_rows: current_viewport.get_untracked().rows as usize, line_height_px: options_signal.get_untracked().line_height_px, terminal_ref, scroll_top, view_mode, programmatic_scroll_top, }, ); } } } }); // -- GPU / Canvas renderer sizing & draw --------------------------------- // // When the canvas-backed renderers are active, this effect sizes the surface // to the container (accounting for DPR) and redraws the visible window once // per render tick. Effect::new(move |_| { let options = options_signal.get(); let renderer = options.renderer; if !renderer_uses_canvas_surface(renderer) { return; } let _ = render_tick.get(); let Some(canvas) = canvas_ref.get() else { return; }; let Some(fallback_canvas) = canvas_fallback_ref.get() else { return; }; let Some(element) = terminal_ref.get() else { return; }; let dpr = window().device_pixel_ratio(); let Some(element) = element.dyn_ref::() else { return; }; let (css_width, css_height) = terminal_content_viewport_size(element); let Some(canvas_el) = canvas.dyn_ref::() else { return; }; let Some(fallback_canvas_el) = fallback_canvas.dyn_ref::() else { return; }; let is_live = view_mode.get().is_live(); let st = scroll_top.get(); let vp = current_viewport.get_untracked(); let vp_rows = vp.rows as usize; let line_height_px = options.line_height_px; let Some((total_px, offset_px, visible)) = core_signal .try_with_untracked(|core| virtual_window(core, is_live, st, vp_rows, line_height_px)) else { return; }; let window_start = (offset_px / line_height_px).round() as usize; let plan = canvas_render_plan(CanvasPlanInput { css_width, css_height, dpr, is_live, scroll_top_px: st, total_px, offset_px, vp_rows, line_height_px, }); let cursor_pos = core_signal .try_with_untracked(|core| { if core.hide_cursor() { return None; } let (cursor_row, cursor_col) = core.cursor_position(); let cursor_buffer_row = if core.alternate_screen() { cursor_row as usize } else { core.history_rows().len() + cursor_row as usize }; if cursor_buffer_row < window_start || cursor_buffer_row >= window_start + visible.len() { return None; } Some((cursor_buffer_row - window_start, cursor_col as usize)) }) .flatten(); let frame = CanvasSurfaceFrame { dom_state: plan.dom_state(), dpr, visible: &visible, cell_size: cell_size.get_untracked(), content_top_px: plan.content_top_px, cursor_pos, options: &options, }; match renderer { Renderer::WebGl => { if let Ok(stats) = render_webgl( canvas_el, frame, &canvas_state, &webgl_state, &webgl_context_lost, ) { last_webgl_stats.set(Some(stats)); set_canvas_surface_mode( canvas_el, fallback_canvas_el, webgl_surface_mode(true), ); } else { set_canvas_surface_mode( canvas_el, fallback_canvas_el, webgl_surface_mode(false), ); last_webgl_stats.set(None); render_canvas_surface(RenderCanvasSurfaceRequest { canvas_el: fallback_canvas_el, frame, canvas_state: &canvas_fallback_state, }); } } Renderer::Canvas => { last_webgl_stats.set(None); set_canvas_surface_mode(canvas_el, fallback_canvas_el, CanvasSurfaceMode::WebGl); render_canvas_surface(RenderCanvasSurfaceRequest { canvas_el, frame, canvas_state: &canvas_state, }); } Renderer::Dom => { last_webgl_stats.set(None); } } }); // -- WebSocket lifecycle ------------------------------------------------ Effect::new(move |prev: Option>| { drop(prev); let url = websocket_url(); let Ok(socket) = WebSocket::new(&url) else { let _ = connection_status.try_set("Failed to connect".to_owned()); return None; }; 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 |_| { if open_status.try_set("Connected".to_owned()).is_some() { return; } if open_ws.try_set(Some((*open_socket).clone())).is_some() { return; } let Some(options) = options_signal.try_get_untracked() else { return; }; let viewport = measure_terminal_viewport(open_ref, open_measure, &options); if open_viewport.try_set(viewport).is_some() { return; } if cell_size.try_set(measure_cell_size(open_measure)).is_some() { return; } if open_core .try_update_untracked(|core| core.resize(viewport.rows, viewport.cols)) .is_none() { return; } if open_tick.try_update(|t| *t += 1).is_none() { return; } if open_mode.try_set(ViewMode::Live).is_some() { return; } send_resize_message(&open_socket, viewport); if let Some(on_resize) = on_resize { on_resize.run(terminal_resize_event(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())); // 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 { let _ = message_status.try_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 } => { let _ = message_status.try_set(match code { Some(code) => format!("Exited ({code})"), None => "Exited".to_owned(), }); } ServerTerminalMessage::Error { message } => { let _ = message_status.try_set(format!("Error: {message}")); } ServerTerminalMessage::Pong => {} } }); socket.set_onmessage(Some(on_message.as_ref().unchecked_ref())); // onclose let close_status = connection_status; let close_ws = ws_signal; let on_close = Closure::::new(move |_| { let _ = close_status.try_set("Disconnected".to_owned()); let _ = close_ws.try_set(None); }); socket.set_onclose(Some(on_close.as_ref().unchecked_ref())); // onerror let error_status = connection_status; let on_error = Closure::::new(move |_| { let _ = error_status.try_set("Connection error".to_owned()); }); socket.set_onerror(Some(on_error.as_ref().unchecked_ref())); Some(WebSocketHandle { socket, _on_open: on_open, _on_message: on_message, _on_close: on_close, _on_error: on_error, }) }); // -- ResizeObserver ----------------------------------------------------- Effect::new(move |prev: Option>| { drop(prev); let element = terminal_ref.get()?; let socket = ws_signal.get()?; 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(options) = options_signal.try_get_untracked() else { return; }; 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, &options, ); let Some(current_viewport) = resize_viewport.try_get_untracked() else { return; }; if viewport != current_viewport { if resize_viewport.try_set(viewport).is_some() { return; } if cell_size .try_set(measure_cell_size(resize_measure)) .is_some() { return; } let Some(mode_val) = resize_mode.try_get_untracked() else { return; }; let Some(st) = resize_st.try_get_untracked() else { return; }; if resize_core .try_update_untracked(|core| { core.resize(viewport.rows, viewport.cols); }) .is_none() { return; } if resize_tick.try_update(|t| *t += 1).is_none() { return; } 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); if let Some(on_resize) = on_resize { on_resize.run(terminal_resize_event(viewport)); } } focus_ime_textarea(&resize_ime); }, ); let Ok(observer) = ResizeObserver::new(callback.as_ref().unchecked_ref::()) else { return None; }; observer.observe(&element); Some(ResizeObserverHandle { observer, _callback: callback, }) }); // -- 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_input_message(&socket, data, on_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_input_message(&socket, data, on_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(), options_signal.get_untracked().line_height_px, ); 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, TERMINAL_PADDING, ); 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_input_message(&socket, data, on_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, options_signal.get_untracked().line_height_px, ); 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_input_message(&socket, data, on_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, on_data, ); 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, on_data, ); } }; 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, on_data, ); } 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, TERMINAL_PADDING, )) }; 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_input_message(&socket, data, on_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_input_message(&socket, data, on_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_input_message(&socket, data, on_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); }; // -- Accessibility: screen-reader buffer --------------------------------- // // Extract visible terminal text and push it into an aria-live region so // screen readers can announce new output. Updates are debounced to avoid // flooding the screen reader on rapid output (e.g. cat of a large file). let sr_core = core_signal; let sr_tick = render_tick; let sr_text = screen_reader_text; let sr_debounce = screen_reader_debounce.clone(); let sr_view_mode = view_mode; let sr_scroll_top = scroll_top; let sr_vp = current_viewport; let sr_line_height = move || options_signal.get().line_height_px; Effect::new(move |_| { let _ = sr_tick.get(); let now = window().performance().map_or(0.0, |p| p.now()); // Debounce: at most one aria-live update per 500 ms. if now - sr_debounce.get() < 500.0 { return; } sr_debounce.set(now); let line_height_px = sr_line_height(); let is_live = sr_view_mode.get().is_live(); let st = sr_scroll_top.get(); let vp_rows = sr_vp.get().rows as usize; let visible_text = sr_core .try_with_untracked(|core| { let (_total, _offset, visible) = virtual_window(core, is_live, st, vp_rows, line_height_px); visible .iter() .filter_map(|row| { let text: String = row .segments .iter() .map(|seg| seg.text.as_str()) .collect(); let trimmed = text.trim_end(); if trimmed.is_empty() { None } else { Some(trimmed.to_owned()) } }) .collect::>() .join("\n") }) .unwrap_or_default(); if !visible_text.is_empty() { sr_text.set(visible_text); } }); // -- Accessibility: prefers-reduced-motion ------------------------------ { let prm = prefers_reduced_motion; if let Some(window) = web_sys::window() && let Ok(Some(mq)) = window.match_media("(prefers-reduced-motion: reduce)") { let mq = Rc::new(mq); prm.set(mq.matches()); let prm2 = prm; let mq2 = mq.clone(); let mq3 = mq.clone(); let cb = Closure::::new(move || { prm2.set(mq2.matches()); }); let _ = mq3.add_event_listener_with_callback( "change", cb.as_ref().unchecked_ref(), ); cb.forget(); } } // -- View --------------------------------------------------------------- view! {

{move || options_signal.get().title}

{move || options_signal.get().subtitle}

{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 || screen_reader_text.get()}
{move || { // Canvas-backed renderers: only the scroll sizer // and selection overlay are DOM; text/backgrounds // are drawn by the Effect above. let _ = render_tick.get(); let options = options_signal.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(); let line_height_px = options.line_height_px; let (total_px, offset_px, visible) = core_signal .try_with_untracked(|core| { virtual_window(core, is_live, st, vp_rows, line_height_px) }) .unwrap_or((0.0, 0.0, Vec::new())); let window_start = (offset_px / line_height_px).round() as usize; let window_end = window_start + visible.len(); let rects = selection_rects( sel, window_start, window_end, cols, cell_w, line_height_px, ); let search_rects = search_match_rects( search_match.get(), window_start, window_end, cell_w, line_height_px, ); let links = core_signal .try_with_untracked(|core| core.links_in_window(window_start, window_end)) .unwrap_or_default(); let link_rects = link_rects(links, window_start, cell_w, line_height_px); render_canvas_scroll_layers( total_px, offset_px, rects, search_rects, link_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, line_height_px: f64, ) -> (f64, f64, Vec) { let total = core.total_rows(); let is_alt = core.alternate_screen(); let line_height_px = line_height_px.max(1.0); 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 * line_height_px; 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)) / line_height_px).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 * line_height_px; let offset_px = start as f64 * line_height_px; (total_px, offset_px, core.collect_window(start, end)) } // 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, search_rects: Vec, link_rects: Vec, ) -> impl IntoView { view! {
{search_rects.into_iter().map(render_search_rect).collect_view()} {rects.into_iter().map(render_selection_rect).collect_view()} {link_rects.into_iter().map(render_link_rect).collect_view()} {visible.into_iter().map(render_terminal_row).collect_view()}
} } fn render_canvas_scroll_layers( total_px: f64, offset_px: f64, rects: Vec, search_rects: Vec, link_rects: Vec, ) -> impl IntoView { view! {
{search_rects.into_iter().map(render_search_rect).collect_view()} {rects.into_iter().map(render_selection_rect).collect_view()} {link_rects.into_iter().map(render_link_rect).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, rect.height_px ); view! { } } fn render_search_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, rect.height_px ); view! { } } fn render_link_rect(rect: TerminalLinkRect) -> impl IntoView { let url = rect.url; let label = url.clone(); let style = format!( "top:{}px;left:{}px;width:{}px;height:{}px;", rect.rect.top_px, rect.rect.left_px, rect.rect.width_px, rect.rect.height_px ); view! { } } #[derive(Clone, Debug, PartialEq)] struct TerminalLinkRect { rect: SelectionRect, url: String, } fn search_match_rects( search_match: Option, window_start: usize, window_end: usize, cell_w: f64, line_height_px: f64, ) -> Vec { let Some(mat) = search_match else { return Vec::new(); }; if mat.row < window_start || mat.row >= window_end || mat.end_col <= mat.start_col { return Vec::new(); } vec![SelectionRect { top_px: (mat.row - window_start) as f64 * line_height_px.max(1.0), left_px: mat.start_col as f64 * cell_w, width_px: (mat.end_col - mat.start_col) as f64 * cell_w, height_px: line_height_px.max(1.0), }] } fn link_rects( links: Vec, window_start: usize, cell_w: f64, line_height_px: f64, ) -> Vec { links .into_iter() .filter(|link| link.end_col > link.start_col) .map(|link| TerminalLinkRect { rect: SelectionRect { top_px: (link.row - window_start) as f64 * line_height_px.max(1.0), left_px: link.start_col as f64 * cell_w, width_px: (link.end_col - link.start_col) as f64 * cell_w, height_px: line_height_px.max(1.0), }, url: link.url, }) .collect() } 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(); } #[derive(Clone, Copy)] struct SearchScrollContext { total_rows: usize, viewport_rows: usize, line_height_px: f64, terminal_ref: NodeRef, scroll_top: RwSignal, view_mode: RwSignal, programmatic_scroll_top: RwSignal, } fn scroll_match_into_view(mat: &TerminalSearchMatch, ctx: SearchScrollContext) { if ctx.total_rows == 0 { return; } let viewport_rows = ctx.viewport_rows.max(1); let half_view = viewport_rows / 2; let first_row = mat.row.saturating_sub(half_view); let max_first_row = ctx.total_rows.saturating_sub(viewport_rows); let first_row = first_row.min(max_first_row); let target = (first_row as f64 * ctx.line_height_px.max(1.0)).round() as i32; ctx.view_mode.set(if first_row >= max_first_row { ViewMode::Live } else { ViewMode::History { scroll_offset: first_row, } }); ctx.scroll_top.set(target); sync_scroll_to_position(ctx.terminal_ref, target, ctx.programmatic_scroll_top); } // --------------------------------------------------------------------------- // 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_input_message(socket: &WebSocket, data: String, on_data: Option>) { if data.is_empty() { return; } if let Some(on_data) = on_data { on_data.run(data.clone()); } send_terminal_message(socket, &ClientTerminalMessage::Input { data }); } 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, options: &ResolvedTerminalOptions, ) -> TerminalViewport { let Some(element) = terminal_ref.get_untracked() else { return terminal_viewport_for_options(options); }; let Some(element) = element.dyn_ref::() else { return terminal_viewport_for_options(options); }; let (width, height) = terminal_content_viewport_size(element); measure_terminal_viewport_from_rect(f64::from(width), f64::from(height), measure_ref, options) } fn measure_terminal_viewport_from_rect( width: f64, height: f64, measure_ref: NodeRef, options: &ResolvedTerminalOptions, ) -> 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(options.min_cols); let rows = ((safe_height / cell_height).floor().max(1.0) as u16).max(options.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) } fn terminal_content_viewport_size(element: &HtmlElement) -> (i32, i32) { let padding = (TERMINAL_PADDING * 2.0).round() as i32; ( (element.client_width() - padding).max(1), (element.client_height() - padding).max(1), ) } /// 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)), ) } /// 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, on_data: Option>, ) { 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_input_message(&socket, data, on_data); } #[cfg(test)] mod tests { use super::*; use crate::terminal::core::{MouseProtocolEncoding, TerminalCore}; use crate::terminal::keyboard::key_to_bytes; 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 } fn default_options() -> ResolvedTerminalOptions { ResolvedTerminalOptions::from(TerminalOptions::default()) } #[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(DEFAULT_LINE_HEIGHT_PX, 0, MAX, DEFAULT_LINE_HEIGHT_PX), 1 ); // A few lines of pixels → that many rows. assert_eq!( wheel_rows_for_delta(DEFAULT_LINE_HEIGHT_PX * 3.0, 0, MAX, DEFAULT_LINE_HEIGHT_PX), 3 ); // Tiny pixel delta still scrolls at least one row. assert_eq!(wheel_rows_for_delta(1.0, 0, MAX, DEFAULT_LINE_HEIGHT_PX), 1); // Line mode: delta is already in lines. assert_eq!(wheel_rows_for_delta(3.0, 1, MAX, DEFAULT_LINE_HEIGHT_PX), 3); // Page mode scrolls a chunk (clamped to the max). assert_eq!(wheel_rows_for_delta(1.0, 2, MAX, DEFAULT_LINE_HEIGHT_PX), 8); // Sign is irrelevant here (direction handled separately); magnitude used. assert_eq!( wheel_rows_for_delta( -DEFAULT_LINE_HEIGHT_PX * 2.0, 0, MAX, DEFAULT_LINE_HEIGHT_PX ), 2 ); // Zero delta → no rows. assert_eq!(wheel_rows_for_delta(0.0, 0, MAX, DEFAULT_LINE_HEIGHT_PX), 0); // Huge delta is clamped to the max. assert_eq!( wheel_rows_for_delta(100_000.0, 0, MAX, DEFAULT_LINE_HEIGHT_PX), 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 = DEFAULT_LINE_HEIGHT_PX * 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, DEFAULT_LINE_HEIGHT_PX), 12 ); // Per-batch cap (a 24-row viewport): bigger than per-event. assert_eq!( wheel_rows_for_delta(big_sum, 0, 24, DEFAULT_LINE_HEIGHT_PX), 24 ); // Honest mapping: a 5-row coalesced swipe is 5 rows, not 12. assert_eq!( wheel_rows_for_delta(DEFAULT_LINE_HEIGHT_PX * 5.0, 0, 24, DEFAULT_LINE_HEIGHT_PX), 5 ); // Tiny sums still floor at 1. assert_eq!(wheel_rows_for_delta(1.0, 0, 24, DEFAULT_LINE_HEIGHT_PX), 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, TERMINAL_PADDING), (0, 0) ); // One cell right/down. assert_eq!( pixel_to_grid( 16.0 + 8.0, 16.0 + 18.0, cw, ch, cols, rows, TERMINAL_PADDING ), (1, 1) ); // Clamped to viewport bounds. assert_eq!( pixel_to_grid(99999.0, 99999.0, cw, ch, cols, rows, TERMINAL_PADDING), (23, 79) ); // Negative/local inside padding clamps to (0, 0). assert_eq!( pixel_to_grid(0.0, 0.0, cw, ch, cols, rows, TERMINAL_PADDING), (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, DEFAULT_LINE_HEIGHT_PX); assert_eq!(rects.len(), 1); assert_eq!(rects[0].top_px, 5.0 * DEFAULT_LINE_HEIGHT_PX); 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, DEFAULT_LINE_HEIGHT_PX); 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, DEFAULT_LINE_HEIGHT_PX).is_empty()); // Empty selection → no rects. let sel = Some(Selection { anchor: (5, 2), head: (5, 2), }); assert!(selection_rects(sel, 0, 30, cols, cw, DEFAULT_LINE_HEIGHT_PX).is_empty()); assert!(selection_rects(None, 0, 30, cols, cw, DEFAULT_LINE_HEIGHT_PX).is_empty()); } #[test] fn search_match_rects_clip_to_visible_window() { let mat = TerminalSearchMatch { row: 8, start_col: 3, end_col: 9, text: "target".to_owned(), }; let rects = search_match_rects(Some(mat.clone()), 5, 12, 8.0, DEFAULT_LINE_HEIGHT_PX); assert_eq!(rects.len(), 1); assert_eq!(rects[0].top_px, 3.0 * DEFAULT_LINE_HEIGHT_PX); assert_eq!(rects[0].left_px, 24.0); assert_eq!(rects[0].width_px, 48.0); assert!(search_match_rects(Some(mat), 9, 12, 8.0, DEFAULT_LINE_HEIGHT_PX).is_empty()); assert!(search_match_rects(None, 5, 12, 8.0, DEFAULT_LINE_HEIGHT_PX).is_empty()); } #[test] fn link_rects_map_buffer_rows_to_window_coordinates() { let links = vec![TerminalLink { row: 7, start_col: 4, end_col: 14, url: "https://example.com".to_owned(), }]; let rects = link_rects(links, 5, 8.0, DEFAULT_LINE_HEIGHT_PX); assert_eq!(rects.len(), 1); assert_eq!(rects[0].rect.top_px, 2.0 * DEFAULT_LINE_HEIGHT_PX); assert_eq!(rects[0].rect.left_px, 32.0); assert_eq!(rects[0].rect.width_px, 80.0); assert_eq!(rects[0].url, "https://example.com"); } #[test] fn terminal_options_resolve_safe_defaults_and_css_vars() { let options = TerminalOptions { rows: 0, cols: 0, min_rows: 0, min_cols: 0, scrollback: 0, cursor: TerminalCursorOptions { style: TerminalCursorStyle::Underline, blink: true, }, font: TerminalFontOptions { family: String::new(), size_px: 0.0, line_height_px: 0.0, }, ..TerminalOptions::default() }; let resolved = ResolvedTerminalOptions::from(options); assert_eq!(resolved.rows, 1); assert_eq!(resolved.cols, 1); assert_eq!(resolved.min_rows, 1); assert_eq!(resolved.min_cols, 1); assert_eq!(resolved.scrollback, 1); assert_eq!(resolved.font_size_px, 1.0); assert_eq!(resolved.line_height_px, 1.0); assert!(resolved.font_family.contains("JetBrains Mono")); let style = terminal_options_style(&default_options()); assert!(style.contains("--terminal-font-family:")); assert!(style.contains("--terminal-selection-bg:")); let style = terminal_options_style(&resolved); assert!(style.contains("--terminal-cursor-style:underline")); assert!(style.contains("--terminal-cursor-blink:terminal-cursor-blink")); } #[test] fn renderer_canvas_surface_selection() { assert!(renderer_uses_canvas_surface(Renderer::WebGl)); assert!(renderer_uses_canvas_surface(Renderer::Canvas)); assert!(!renderer_uses_canvas_surface(Renderer::Dom)); assert_eq!(Renderer::default(), Renderer::Canvas); } #[test] fn viewport_measurement_excludes_terminal_padding() { let options = default_options(); let viewport = measure_terminal_viewport_from_rect(960.0, 684.0, NodeRef::new(), &options); assert_eq!(viewport.pixel_width, 958); assert_eq!(viewport.pixel_height, 682); assert_eq!( viewport.rows, ((684.0 - 2.0) / DEFAULT_CELL_HEIGHT).floor() as u16 ); } #[test] fn ime_cursor_cell_uses_buffer_coordinates_after_scrollback() { let mut core = core_with_lines(220); core.process(b"prompt$ "); let (row, col) = ime_cursor_cell(&core); assert!(core.history_rows().len() > 0); assert!( row >= core.history_rows().len(), "IME carrier must stay near the live prompt, not at screen-local row {row}" ); assert!(col > 0); } #[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, DEFAULT_LINE_HEIGHT_PX); // Sizer is always the full content height (constant across renders). assert!( (total_px - total as f64 * DEFAULT_LINE_HEIGHT_PX).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 * DEFAULT_LINE_HEIGHT_PX).abs() < 1.0); // The window covers through the last row. assert!( offset_px + visible.len() as f64 * DEFAULT_LINE_HEIGHT_PX >= total as f64 * DEFAULT_LINE_HEIGHT_PX - 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 * DEFAULT_LINE_HEIGHT_PX) as i32; let (total_px, offset_px, visible) = virtual_window(&core, false, st, vp_rows, DEFAULT_LINE_HEIGHT_PX); // Sizer height is constant regardless of scroll position. assert!((total_px - total as f64 * DEFAULT_LINE_HEIGHT_PX).abs() < 1.0); // First rendered row should be ~ (500 - overscan). let first_row = (offset_px / DEFAULT_LINE_HEIGHT_PX).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, DEFAULT_LINE_HEIGHT_PX); // Sizer == content height, no offset, all rows rendered. assert!((total_px - core.total_rows() as f64 * DEFAULT_LINE_HEIGHT_PX).abs() < 1.0); assert_eq!(offset_px, 0.0); assert_eq!(visible.len(), core.total_rows()); } }