Files
rustui-playground/app/src/terminal/component.rs
zhangheng 5bca11083f feat(terminal): add screen reader buffer and OS accessibility preferences
- Add debounced aria-live region that announces visible terminal text
  to screen readers (500ms throttle, non-empty trimmed rows only)
- Add role=application, aria-label, aria-roledescription to terminal
- Add prefers-reduced-motion listener: disables cursor blink animation
  and sets --terminal-reduce-motion CSS variable
- Add prefers-contrast: more media query with Canvas/CanvasText system
  color fallback
- Add visually hidden .terminal-screen-reader CSS utility
- Mark P7 complete in roadmap
2026-06-25 11:02:47 +08:00

3293 lines
120 KiB
Rust

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<dyn FnMut(Array, ResizeObserver)>,
}
impl Drop for ResizeObserverHandle {
fn drop(&mut self) {
self.observer.disconnect();
}
}
struct WebSocketHandle {
socket: Rc<WebSocket>,
_on_open: Closure<dyn Fn(Event)>,
_on_message: Closure<dyn Fn(MessageEvent)>,
_on_close: Closure<dyn Fn(Event)>,
_on_error: Closure<dyn Fn(ErrorEvent)>,
}
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<WebGlRenderStats>,
}
type SerializeFn = Arc<dyn Fn() -> String + Send + Sync>;
#[derive(Clone, Copy)]
pub struct TerminalHandle {
input: RwSignal<Option<String>>,
clear_token: RwSignal<u64>,
focus_token: RwSignal<u64>,
resize: RwSignal<Option<(u16, u16)>>,
scroll_to_bottom_token: RwSignal<u64>,
copy_selection_token: RwSignal<u64>,
fit_token: RwSignal<u64>,
search_request: RwSignal<Option<TerminalSearchRequest>>,
serialize_text_fn: StoredValue<Option<SerializeFn>>,
serialize_ansi_fn: StoredValue<Option<SerializeFn>>,
}
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<String>) {
self.input.set(Some(data.into()));
}
pub fn paste_text(&self, data: impl Into<String>) {
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<String>) {
self.search(query, SearchDirection::Forward);
}
pub fn search_previous(&self, query: impl Into<String>) {
self.search(query, SearchDirection::Backward);
}
pub fn clear_search(&self) {
self.search_request.set(Some(TerminalSearchRequest::Clear));
}
fn search(&self, query: impl Into<String>, 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<TerminalOptions> 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<TerminalOptions>,
#[prop(optional)] renderer: Option<Renderer>,
#[prop(optional)] on_ready: Option<Callback<TerminalHandle>>,
#[prop(optional)] on_data: Option<Callback<String>>,
#[prop(optional)] on_resize: Option<Callback<TerminalResizeEvent>>,
#[prop(optional)] on_selection_change: Option<Callback<TerminalSelectionEvent>>,
#[prop(optional)] on_title_change: Option<Callback<String>>,
#[prop(optional)] on_render: Option<Callback<TerminalRenderEvent>>,
) -> 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::<html::Div>::new();
let ime_ref = NodeRef::<html::Textarea>::new();
let canvas_ref = NodeRef::<html::Canvas>::new();
let canvas_fallback_ref = NodeRef::<html::Canvas>::new();
let measure_ref = NodeRef::<html::Span>::new();
let connection_status = RwSignal::new("Connecting".to_owned());
let ws_signal = RwSignal::new(None::<WebSocket>);
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::<Selection>);
let selecting = RwSignal::new(false);
let search_match = RwSignal::new(None::<TerminalSearchMatch>);
let search_query = RwSignal::new(String::new());
let last_webgl_stats = RwSignal::new(None::<WebGlRenderStats>);
// 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<std::cell::Cell<Option<u8>>> = Rc::new(std::cell::Cell::new(None));
let mouse_last_cell: Rc<std::cell::Cell<Option<(usize, usize)>>> =
Rc::new(std::cell::Cell::new(None));
let canvas_state: Rc<std::cell::RefCell<Option<CanvasDomState>>> =
Rc::new(std::cell::RefCell::new(None));
let canvas_fallback_state: Rc<std::cell::RefCell<Option<CanvasDomState>>> =
Rc::new(std::cell::RefCell::new(None));
let webgl_state: Rc<std::cell::RefCell<Option<WebGlRendererState>>> =
Rc::new(std::cell::RefCell::new(None));
let webgl_context_lost: Rc<std::cell::Cell<bool>> = 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<std::cell::Cell<f64>> =
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<Option<WebGlContextLifecycle>>| {
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::<HtmlCanvasElement>()?;
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::<HtmlElement>() else {
return;
};
let (css_width, css_height) = terminal_content_viewport_size(element);
let Some(canvas_el) = canvas.dyn_ref::<HtmlCanvasElement>() else {
return;
};
let Some(fallback_canvas_el) = fallback_canvas.dyn_ref::<HtmlCanvasElement>() 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<Option<WebSocketHandle>>| {
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::<dyn Fn(Event)>::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::<dyn Fn(MessageEvent)>::new(move |event: MessageEvent| {
let Some(text) = event.data().as_string() else {
return;
};
let Ok(message) = serde_json::from_str::<ServerTerminalMessage>(&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::<dyn Fn(Event)>::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::<dyn Fn(ErrorEvent)>::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<Option<ResizeObserverHandle>>| {
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::<dyn FnMut(Array, ResizeObserver)>::new(
move |entries: Array, _observer: ResizeObserver| {
let Some(options) = options_signal.try_get_untracked() else {
return;
};
let Some(entry) = entries.get(0).dyn_into::<ResizeObserverEntry>().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::<Function>())
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<std::cell::Cell<Option<(f64, u32)>>> =
Rc::new(std::cell::Cell::new(None));
let wheel_raf_pending: Rc<std::cell::Cell<bool>> = Rc::new(std::cell::Cell::new(false));
move |event: WheelEvent| {
// 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::<InputEvent>() 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::<Vec<_>>()
.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::<dyn Fn()>::new(move || {
prm2.set(mq2.matches());
});
let _ = mq3.add_event_listener_with_callback(
"change",
cb.as_ref().unchecked_ref(),
);
cb.forget();
}
}
// -- View ---------------------------------------------------------------
view! {
<section class="terminal-shell-card" style=move || {
let mut style = terminal_options_style(&options_signal.get());
if prefers_reduced_motion.get() {
style.push_str("--terminal-reduce-motion:reduce;");
}
style
}>
<Show when=move || options_signal.get().show_header>
<div class="terminal-header">
<div>
<h2 class="terminal-title">{move || options_signal.get().title}</h2>
<p class="terminal-subtitle">
{move || options_signal.get().subtitle}
</p>
</div>
<div class="terminal-status-stack">
<span class="terminal-badge">{move || connection_status.get()}</span>
<span class="terminal-metrics">
{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()
}}
</span>
</div>
</div>
</Show>
<div
node_ref=terminal_ref
class="terminal-screen"
role="application"
aria-label="Terminal"
aria-roledescription="terminal emulator"
on:click=handle_click
on:scroll=handle_scroll
on:wheel=handle_wheel
on:mousedown=handle_mousedown
on:mousemove=handle_mousemove
on:mouseup=handle_mouseup
>
<textarea
node_ref=ime_ref
class="terminal-ime"
tabindex="0"
autocomplete="off"
autocapitalize="off"
spellcheck="false"
style=ime_style
on:keydown=handle_keydown
on:input=handle_input
on:compositionstart=handle_composition_start
on:compositionend=handle_composition_end
on:paste=handle_paste
></textarea>
<span node_ref=measure_ref class="terminal-measure" aria-hidden="true">
{MEASURE_SAMPLE_TEXT}
</span>
<div
class="terminal-screen-reader"
role="status"
aria-live="polite"
aria-atomic="false"
aria-label="Terminal output"
>
{move || screen_reader_text.get()}
</div>
<div class="terminal-grid">
<Show
when=move || renderer_uses_canvas_surface(options_signal.get().renderer)
fallback=move || {
// DOM renderer: same reactive closure as before.
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_spaced_view(
total_px,
offset_px,
visible,
rects,
search_rects,
link_rects,
)
}
>
<canvas
node_ref=canvas_ref
class="terminal-canvas"
></canvas>
<canvas
node_ref=canvas_fallback_ref
class="terminal-canvas"
hidden
></canvas>
{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,
)
}}
</Show>
</div>
</div>
</section>
}
}
// ---------------------------------------------------------------------------
// 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<TerminalRow>) {
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<TerminalRow>,
rects: Vec<SelectionRect>,
search_rects: Vec<SelectionRect>,
link_rects: Vec<TerminalLinkRect>,
) -> impl IntoView {
view! {
<div
class="terminal-virtual-sizer"
style=format!("height:{total_px}px;")
aria-hidden="true"
></div>
<div
class="terminal-virtual-rows"
style=format!("transform:translateY({offset_px}px);")
>
{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()}
</div>
}
}
fn render_canvas_scroll_layers(
total_px: f64,
offset_px: f64,
rects: Vec<SelectionRect>,
search_rects: Vec<SelectionRect>,
link_rects: Vec<TerminalLinkRect>,
) -> impl IntoView {
view! {
<div
class="terminal-virtual-sizer"
style=format!("height:{total_px}px;")
aria-hidden="true"
></div>
<div
class="terminal-virtual-rows"
style=format!("transform:translateY({offset_px}px);")
>
{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()}
</div>
}
}
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! { <div class="terminal-selection" style=style aria-hidden="true"></div> }
}
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! { <div class="terminal-search-match" style=style aria-hidden="true"></div> }
}
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! {
<button
type="button"
class="terminal-link-hitbox"
style=style
title=label
on:mousedown=|event| event.stop_propagation()
on:click=move |event| {
event.prevent_default();
event.stop_propagation();
let _ = window().open_with_url_and_target(&url, "_blank");
}
></button>
}
}
#[derive(Clone, Debug, PartialEq)]
struct TerminalLinkRect {
rect: SelectionRect,
url: String,
}
fn search_match_rects(
search_match: Option<TerminalSearchMatch>,
window_start: usize,
window_end: usize,
cell_w: f64,
line_height_px: f64,
) -> Vec<SelectionRect> {
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<TerminalLink>,
window_start: usize,
cell_w: f64,
line_height_px: f64,
) -> Vec<TerminalLinkRect> {
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! {
<div class="terminal-row">
{row
.segments
.into_iter()
.map(render_terminal_segment)
.collect_view()}
</div>
}
}
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! {
<span
class="terminal-segment"
class:terminal-cursor=segment.is_cursor
style=style
>
{segment.text}
</span>
}
}
// ---------------------------------------------------------------------------
// 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<std::cell::Cell<bool>>,
render_tick: RwSignal<u64>,
view_mode: RwSignal<ViewMode>,
terminal_ref: NodeRef<html::Div>,
programmatic_scroll_top: RwSignal<i32>,
) {
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<html::Div>, programmatic_scroll_top: RwSignal<i32>) {
let callback = Closure::<dyn FnMut()>::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<html::Div>,
target_scroll_top: i32,
programmatic_scroll_top: RwSignal<i32>,
) {
let callback = Closure::<dyn FnMut()>::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<html::Div>,
scroll_top: RwSignal<i32>,
view_mode: RwSignal<ViewMode>,
programmatic_scroll_top: RwSignal<i32>,
}
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<Callback<String>>) {
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<html::Div>,
measure_ref: NodeRef<html::Span>,
options: &ResolvedTerminalOptions,
) -> TerminalViewport {
let Some(element) = terminal_ref.get_untracked() else {
return terminal_viewport_for_options(options);
};
let Some(element) = element.dyn_ref::<HtmlElement>() 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<html::Span>,
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<html::Span>) -> (f64, f64) {
let Some(element) = measure_ref.get_untracked() else {
return (DEFAULT_CELL_WIDTH, DEFAULT_CELL_HEIGHT);
};
let Some(element) = element.dyn_ref::<HtmlElement>() 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<Option<Selection>>, core: RwSignal<TerminalCore>) {
if let Some(sel) = selection.get_untracked().filter(|s| !s.is_empty()) {
let (start, end) = sel.ordered();
let text = core
.try_with_untracked(|c| c.selection_text(start, end))
.unwrap_or_default();
copy_to_clipboard(&text);
}
}
/// Send a string as terminal input, scrolling to the bottom if needed.
fn send_input_data(
data: String,
socket_signal: RwSignal<Option<WebSocket>>,
mode_signal: RwSignal<ViewMode>,
terminal_ref: NodeRef<html::Div>,
prog_scroll: RwSignal<i32>,
on_data: Option<Callback<String>>,
) {
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());
}
}