Files
rustui-playground/app/src/terminal/component.rs
zhangheng f76d341501 feat(terminal): Chinese / IME input (P3)
Implement IME composition support and migrate regular character input to
a hidden textarea, matching the xterm.js approach.

Key changes:
- Add a hidden, absolutely-positioned `<textarea>` inside `.terminal-screen`
  that follows the terminal cursor cell. It receives focus on click, connect,
  and resize so the OS/browser IME candidate window appears at the cursor.
- `on:input` now sends regular printable characters; `on:keydown` only handles
  special keys (arrows, Enter, Esc, Backspace, F-keys, Ctrl combos).
- `on:compositionstart/end` track IME sessions and send the final composed
  string at the end, avoiding partial pinyin/jamo/kana leaks.
- Move `on:paste` and `on:keydown` from `.terminal-screen` to the textarea so
  focus stays on the IME target.
- Replace `.terminal-screen:focus` with `:focus-within` since the screen div
  no longer holds focus.
- Add `is_regular_text_input` helper and unit test.

Web-sys features extended with `InputEvent` and `CompositionEvent`.

Tests: 24/24 pass, clippy clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 16:09:45 +08:00

2207 lines
86 KiB
Rust

use std::rc::Rc;
use app_config::SiteConfig;
use leptos::html;
use leptos::prelude::*;
use wasm_bindgen::JsCast;
use wasm_bindgen::closure::Closure;
use web_sys::js_sys::{Array, Function};
use web_sys::{
BinaryType, ClipboardEvent, CompositionEvent, ErrorEvent, Event, HtmlElement,
InputEvent, KeyboardEvent, MessageEvent, MouseEvent, ResizeObserver,
ResizeObserverEntry, WebSocket, WheelEvent,
};
use super::core::{
MouseProtocolEncoding, MouseProtocolMode, TerminalCore, TerminalRow, TerminalSegment,
};
use super::protocol::{ClientTerminalMessage, ServerTerminalMessage};
use super::scrollback::ViewMode;
const DEFAULT_CELL_WIDTH: f64 = 8.0;
const DEFAULT_CELL_HEIGHT: f64 = 18.0;
const MIN_COLS: u16 = 40;
const MIN_ROWS: u16 = 12;
const MEASURE_SAMPLE_TEXT: &str =
"WWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWW";
const SCROLL_LINE_HEIGHT: f64 = 18.0;
/// Extra rows rendered above and below the visible window so that fast
/// scrolling doesn't flash blank areas before the next frame arrives.
const VIRTUAL_OVERSCAN: usize = 5;
/// `.terminal-screen` inner padding in px (CSS `padding: 16px`). Mouse
/// coordinates subtract this to map onto the cell grid.
const TERMINAL_PADDING: f64 = 16.0;
/// A text selection in logical buffer coordinates `(row, col)`. `anchor` is
/// where the drag began, `head` follows the pointer; either may be the
/// top-left depending on drag direction.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct Selection {
anchor: (usize, usize),
head: (usize, usize),
}
impl Selection {
/// (top-left, bottom-right) ordered for rendering / extraction.
fn ordered(self) -> ((usize, usize), (usize, usize)) {
if self.anchor <= self.head {
(self.anchor, self.head)
} else {
(self.head, self.anchor)
}
}
/// True when the selection is empty (anchor == head): nothing to copy.
fn is_empty(self) -> bool {
self.anchor == self.head
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct TerminalViewport {
cols: u16,
rows: u16,
pixel_width: u16,
pixel_height: u16,
}
impl Default for TerminalViewport {
fn default() -> Self {
Self {
cols: super::core::DEFAULT_COLS,
rows: super::core::DEFAULT_ROWS,
pixel_width: (f64::from(super::core::DEFAULT_COLS) * DEFAULT_CELL_WIDTH)
.round() as u16,
pixel_height: (f64::from(super::core::DEFAULT_ROWS) * DEFAULT_CELL_HEIGHT)
.round() as u16,
}
}
}
// ---------------------------------------------------------------------------
// TerminalPanel component
// ---------------------------------------------------------------------------
#[component]
pub fn TerminalPanel() -> impl IntoView {
let terminal_ref = NodeRef::<html::Div>::new();
let ime_ref = NodeRef::<html::Textarea>::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(TerminalViewport::default());
let view_mode = RwSignal::new(ViewMode::Live);
// The scrollTop we last set programmatically. The scroll handler compares
// against it to robustly tell our own programmatic scrolls apart from real
// user scrolls (a single-shot "ignore next" flag was racy and caused an
// auto-pin ↔ user-scroll feedback loop / momentum bounce).
let programmatic_scroll_top = RwSignal::new(-1_i32);
// Terminal state lives in a reactive signal, but it is mutated via
// `update_untracked` so that feeding bytes never triggers a render on its
// own — rendering is driven explicitly by `render_tick` below. This
// decouples the (very fast, ~1µs/line) data-ingestion rate from the
// (expensive) DOM-render rate: a burst of hundreds of WebSocket packets
// from `cat`-ing a huge file collapses into a single render per frame.
let core_signal = RwSignal::new(TerminalCore::default());
// Bumped at most once per animation frame to drive exactly one render.
let render_tick = RwSignal::new(0_u64);
// Current scrollTop in pixels — updated by the scroll handler so that
// the virtual-scroll render closure knows which window to materialise.
let scroll_top = RwSignal::new(0_i32);
// Measured cell size (width, height) in px, kept in sync with the viewport
// measurement. Used to map mouse coordinates onto the logical cell grid.
let cell_size = RwSignal::new((DEFAULT_CELL_WIDTH, DEFAULT_CELL_HEIGHT));
// Active text selection in buffer coords, and whether a drag is in progress.
let selection = RwSignal::new(None::<Selection>);
let selecting = RwSignal::new(false);
// IME composition state: when true, regular `input` events are ignored and
// the final composed string is sent on `compositionend`.
let composing = RwSignal::new(false);
// Mouse-reporting bookkeeping (only used while an app has enabled mouse
// reporting). `mouse_held_button` remembers which button is down so we can
// emit motion events and SGR-style releases; `mouse_last_cell` throttles
// motion reports to one per grid cell (not per pixel).
let mouse_held_button: Rc<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));
// -- IME cursor follower -------------------------------------------------
//
// The hidden IME textarea's position is bound reactively to the terminal
// cursor cell so the OS/browser IME candidate window appears in the right
// place. See the `style` prop on the textarea in the view below.
let ime_style = move || {
let _ = render_tick.get();
let Some((row, col)) = core_signal
.try_with_untracked(|core| {
if core.hide_cursor() {
None
} else {
Some(core.cursor_position())
}
})
.flatten()
else {
return String::new();
};
let (cell_w, cell_h) = cell_size.get();
let left = TERMINAL_PADDING + f64::from(col) * cell_w;
let top = TERMINAL_PADDING + f64::from(row) * cell_h;
format!(
"left:{left}px;top:{top}px;width:{cell_w}px;height:{cell_h}px;"
)
};
// -- WebSocket lifecycle ------------------------------------------------
Effect::new(move |_| {
let url = websocket_url();
let Ok(socket) = WebSocket::new(&url) else {
connection_status.set("Failed to connect".to_owned());
return;
};
socket.set_binary_type(BinaryType::Arraybuffer);
let socket = Rc::new(socket);
// onopen
let open_socket = socket.clone();
let open_status = connection_status;
let open_ws = ws_signal;
let open_viewport = current_viewport;
let open_ref = terminal_ref;
let open_ime = ime_ref;
let open_measure = measure_ref;
let open_mode = view_mode;
let open_prog_scroll = programmatic_scroll_top;
let open_core = core_signal;
let open_tick = render_tick;
let on_open = Closure::<dyn Fn(Event)>::new(move |_| {
open_status.set("Connected".to_owned());
open_ws.set(Some((*open_socket).clone()));
let viewport = measure_terminal_viewport(open_ref, open_measure);
open_viewport.set(viewport);
cell_size.set(measure_cell_size(open_measure));
open_core
.update_untracked(|core| core.resize(viewport.rows, viewport.cols));
open_tick.update(|t| *t += 1);
open_mode.set(ViewMode::Live);
send_resize_message(&open_socket, viewport);
sync_scroll_to_bottom(open_ref, open_prog_scroll);
focus_ime_textarea(&open_ime);
});
socket.set_onopen(Some(on_open.as_ref().unchecked_ref()));
on_open.forget();
// onmessage
let message_core = core_signal;
let message_status = connection_status;
let message_ref = terminal_ref;
let message_mode = view_mode;
let message_prog_scroll = programmatic_scroll_top;
let message_tick = render_tick;
// Coalescing guard: at most one animation frame is scheduled at a time.
// A burst of packets all land in `core` (untracked) but only trigger a
// single render on the next frame.
let raf_pending = Rc::new(std::cell::Cell::new(false));
let on_message =
Closure::<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 {
message_status.set("Protocol error".to_owned());
return;
};
match message {
ServerTerminalMessage::Output { data } => {
// Feed bytes without triggering a render (untracked).
// `try_*` so a stale callback from a disposed mount
// no-ops instead of panicking.
if message_core
.try_update_untracked(|core| {
core.process(data.as_bytes())
})
.is_none()
{
return;
}
// Schedule exactly one render for the next frame.
schedule_frame(
raf_pending.clone(),
message_tick,
message_mode,
message_ref,
message_prog_scroll,
);
}
ServerTerminalMessage::Exit { code } => {
message_status.set(match code {
Some(code) => format!("Exited ({code})"),
None => "Exited".to_owned(),
});
}
ServerTerminalMessage::Error { message } => {
message_status.set(format!("Error: {message}"));
}
ServerTerminalMessage::Pong => {}
}
});
socket.set_onmessage(Some(on_message.as_ref().unchecked_ref()));
on_message.forget();
// onclose
let close_status = connection_status;
let close_ws = ws_signal;
let on_close = Closure::<dyn Fn(Event)>::new(move |_| {
close_status.set("Disconnected".to_owned());
close_ws.set(None);
});
socket.set_onclose(Some(on_close.as_ref().unchecked_ref()));
on_close.forget();
// onerror
let error_status = connection_status;
let on_error = Closure::<dyn Fn(ErrorEvent)>::new(move |_| {
error_status.set("Connection error".to_owned());
});
socket.set_onerror(Some(on_error.as_ref().unchecked_ref()));
on_error.forget();
});
// -- ResizeObserver -----------------------------------------------------
Effect::new(move |_| {
let Some(element) = terminal_ref.get() else {
return;
};
let Some(socket) = ws_signal.get() else {
return;
};
let resize_socket = socket.clone();
let resize_viewport = current_viewport;
let resize_ref = terminal_ref;
let resize_measure = measure_ref;
let resize_mode = view_mode;
let resize_prog_scroll = programmatic_scroll_top;
let resize_core = core_signal;
let resize_st = scroll_top;
let resize_tick = render_tick;
let resize_ime = ime_ref;
let callback = Closure::<dyn FnMut(Array, ResizeObserver)>::new(
move |entries: Array, _observer: ResizeObserver| {
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,
);
if viewport != resize_viewport.get_untracked() {
resize_viewport.set(viewport);
cell_size.set(measure_cell_size(resize_measure));
let mode_val = resize_mode.get_untracked();
let st = resize_st.get_untracked();
resize_core.update_untracked(|core| {
core.resize(viewport.rows, viewport.cols);
});
resize_tick.update(|t| *t += 1);
if mode_val == ViewMode::Live {
sync_scroll_to_bottom(resize_ref, resize_prog_scroll);
} else {
sync_scroll_to_position(
resize_ref,
st,
resize_prog_scroll,
);
}
send_resize_message(&resize_socket, viewport);
}
focus_ime_textarea(&resize_ime);
},
);
let Ok(observer) =
ResizeObserver::new(callback.as_ref().unchecked_ref::<Function>())
else {
return;
};
observer.observe(&element);
callback.forget();
std::mem::forget(observer);
});
// -- Keyboard input -----------------------------------------------------
let handle_keydown = {
let keydown_mode = view_mode;
let keydown_prog_scroll = programmatic_scroll_top;
let keydown_selection = selection;
let keydown_core = core_signal;
move |event: KeyboardEvent| {
// Ctrl-Shift-C copies the current selection. Handle this BEFORE the
// generic Ctrl+letter suppression so the browser clipboard gesture
// (and the async writeText call) is not interfered with.
if event.ctrl_key()
&& event.shift_key()
&& matches!(event.key().as_str(), "c" | "C")
{
copy_current_selection(keydown_selection, keydown_core);
event.prevent_default();
return;
}
// Paste (Ctrl+V / Ctrl+Shift+V): read the system clipboard directly
// and send it as input. Some browsers do not fire `on:paste` on a
// non-contenteditable div, so relying on the ClipboardEvent alone is
// unreliable. The keydown itself is a user gesture, which allows
// `navigator.clipboard.readText()` without a permission prompt.
let is_paste_shortcut = event.ctrl_key()
&& !event.alt_key()
&& !event.meta_key()
&& matches!(event.key().as_str(), "v" | "V");
if is_paste_shortcut {
event.prevent_default();
if !keydown_mode.get_untracked().is_live() {
keydown_mode.set(ViewMode::Live);
sync_scroll_to_bottom(terminal_ref, keydown_prog_scroll);
}
let paste_ws = ws_signal;
let paste_core = keydown_core;
wasm_bindgen_futures::spawn_local(async move {
let clipboard = window().navigator().clipboard();
let promise = clipboard.read_text();
let Ok(value) = wasm_bindgen_futures::JsFuture::from(promise).await else { return; };
let Some(text) = value.as_string() else { return; };
if text.is_empty() {
return;
}
let bracketed = paste_core
.try_with_untracked(|core| core.bracketed_paste())
== Some(true);
let data = prepare_paste(&text, bracketed);
if let Some(socket) = paste_ws.get_untracked() {
send_terminal_message(
&socket,
&ClientTerminalMessage::Input { data },
);
}
});
return;
}
// Printable characters are handled by the IME textarea's `input` /
// `composition` events so that IME composition and dead keys work.
// Only special keys (arrows, Enter, Esc, Backspace, F-keys,
// Ctrl-combos, …) are processed here.
if is_regular_text_input(
&event.key(),
event.ctrl_key(),
event.alt_key(),
event.meta_key(),
) {
return;
}
// The terminal owns bare Ctrl+letter (no Shift/Alt/Meta) so the
// browser doesn't intercept common shortcuts (Ctrl-W close tab,
// Ctrl-T new tab, Ctrl-N new window, Ctrl-R reload, Ctrl-F find,
// …). Shifted combos are excluded so Ctrl-Shift-C copy still works.
let is_bare_ctrl_letter = event.ctrl_key()
&& !event.shift_key()
&& !event.alt_key()
&& !event.meta_key()
&& event.key().chars().count() == 1;
if is_bare_ctrl_letter {
// When there is an active selection, Ctrl+C copies instead of
// sending SIGINT, matching VS Code / Windows Terminal behaviour.
if matches!(event.key().as_str(), "c" | "C")
&& keydown_selection
.get_untracked()
.map(|s| !s.is_empty())
.unwrap_or(false)
{
copy_current_selection(keydown_selection, keydown_core);
event.prevent_default();
return;
}
event.prevent_default();
}
let Some(socket) = ws_signal.get_untracked() else {
return;
};
let app_cursor = keydown_core
.try_with_untracked(|c| c.application_cursor())
.unwrap_or(false);
let Some(data) = map_key_to_terminal_input(&event, app_cursor) else {
return;
};
// Typing replaces a selection, like a normal terminal.
keydown_selection.set(None);
if !keydown_mode.get_untracked().is_live() {
keydown_mode.set(ViewMode::Live);
sync_scroll_to_bottom(terminal_ref, keydown_prog_scroll);
}
event.prevent_default();
send_terminal_message(
&socket,
&ClientTerminalMessage::Input { data },
);
}
};
// -- Scroll handler ------------------------------------------------------
let handle_scroll = {
let scroll_mode = view_mode;
let scroll_prog = programmatic_scroll_top;
move |_| {
let Some(element) = terminal_ref.get_untracked() else {
return;
};
let st = element.scroll_top();
// Distinguish our own programmatic scrolls from real user scrolls by
// position (robust against missed/duplicated scroll events that a
// single-shot ignore flag could not handle). If this position is
// the one we just set programmatically, treat it as an echo and do
// not touch the view mode — only record scroll_top for rendering.
let is_programmatic_echo =
(st - scroll_prog.get_untracked()).abs() <= 2;
scroll_top.set(st);
if is_programmatic_echo {
return;
}
let mode = super::scrollback::view_mode_from_scroll(
st,
element.scroll_height(),
element.client_height(),
SCROLL_LINE_HEIGHT,
);
scroll_mode.set(mode);
}
};
// -- Wheel handler (alt-screen only) ------------------------------------
//
// The alternate screen (vim / less / man) has no scrollback, so native
// wheel scrolling does nothing useful there. Like a real terminal, we
// translate wheel ticks into arrow-key presses sent to the application so
// the wheel pages through it. On the main screen we do nothing and let the
// browser scroll natively (handled by `on:scroll`).
let handle_wheel = {
let wheel_core = core_signal;
// Accumulated wheel delta + mode across the current animation frame.
// Each wheel event adds its delta here; the rAF callback flushes
// the batch as a single arrow press. Without this, a single
// trackpad swipe fires many wheel events and the app scrolls many
// lines, which is jarring in less/vim.
let wheel_batch: Rc<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,
);
let button = with_mouse_modifiers(
button,
event.shift_key(),
event.alt_key(),
event.ctrl_key(),
);
let data = encode_mouse_report(
button,
MouseAction::Press,
col,
row,
encoding,
);
if let Some(socket) = ws_signal.get_untracked() {
send_terminal_message(
&socket,
&ClientTerminalMessage::Input { data },
);
}
return;
}
let in_alt = wheel_core
.try_with_untracked(|core| core.alternate_screen())
== Some(true);
if !in_alt {
return; // main screen → native scroll
}
event.prevent_default();
if event.delta_y() == 0.0 {
return;
}
// Accumulate (or reset if sign flipped, to avoid silent cancel).
let new = event.delta_y();
let mode = event.delta_mode();
match wheel_batch.get() {
None => wheel_batch.set(Some((new, mode))),
Some((cur, cur_mode)) if (cur.signum() * new.signum()) >= 0.0 => {
wheel_batch.set(Some((cur + new, cur_mode)));
}
Some(_) => wheel_batch.set(Some((new, mode))),
}
if wheel_raf_pending.get() {
return;
}
wheel_raf_pending.set(true);
// Clones for the one-shot rAF closure. The outer handler stays
// `Fn` because each clone is a new Rc handle; the clones are
// moved into the rAF closure.
let wheel_batch_for_raf = wheel_batch.clone();
let wheel_raf_pending_for_raf = wheel_raf_pending.clone();
let callback = Closure::once_into_js(move || {
wheel_raf_pending_for_raf.set(false);
let Some((sum, mode)) = wheel_batch_for_raf.take() else {
return;
};
if sum == 0.0 {
return;
}
let vp_rows = current_viewport.get_untracked().rows as usize;
let max_rows = vp_rows.max(1);
let rows = wheel_rows_for_delta(sum, mode, max_rows);
if rows == 0 {
return;
}
let app_cursor = wheel_core
.try_with_untracked(|c| c.application_cursor())
.unwrap_or(false);
let final_byte = if sum < 0.0 { 'A' } else { 'B' };
let data = cursor_seq(final_byte, app_cursor).repeat(rows);
if let Some(socket) = ws_signal.get_untracked() {
send_terminal_message(
&socket,
&ClientTerminalMessage::Input { data },
);
}
});
let _ = window().request_animation_frame(callback.as_ref().unchecked_ref());
}
};
// -- Paste handler ------------------------------------------------------
//
// Send clipboard text to the PTY. If the running application enabled
// bracketed paste (vim, many shells), the text is wrapped in
// ESC[200~ … ESC[201~ so the app can tell a paste from typed input (and
// skip e.g. auto-indenting every pasted line).
let handle_paste = {
let paste_mode = view_mode;
let paste_prog_scroll = programmatic_scroll_top;
let paste_core = core_signal;
move |event: ClipboardEvent| {
let Some(raw) = event
.clipboard_data()
.and_then(|dt| dt.get_data("text").ok())
else {
return;
};
if raw.is_empty() {
return;
}
let bracketed = paste_core
.try_with_untracked(|core| core.bracketed_paste())
== Some(true);
let data = prepare_paste(&raw, bracketed);
send_input_data(
data,
ws_signal,
paste_mode,
terminal_ref,
paste_prog_scroll,
);
event.prevent_default();
}
};
// -- IME input ------------------------------------------------------------
//
// Regular characters and IME composition are handled through a hidden
// textarea that sits on top of the terminal cursor. Special keys
// (arrows, Enter, Esc, F-keys, Ctrl-combos) still go through keydown.
let handle_input = {
let input_mode = view_mode;
let input_prog_scroll = programmatic_scroll_top;
let input_composing = composing;
let input_ime = ime_ref;
move |event: Event| {
let Ok(_event) = event.dyn_into::<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,
);
}
};
let handle_composition_start = move |_: CompositionEvent| {
composing.set(true);
};
let handle_composition_end = {
let end_mode = view_mode;
let end_prog_scroll = programmatic_scroll_top;
let end_composing = composing;
let end_ime = ime_ref;
move |event: CompositionEvent| {
if !end_composing.get_untracked() {
clear_ime_textarea(&end_ime);
return;
}
end_composing.set(false);
if let Some(text) = event.data()
&& !text.is_empty()
{
send_input_data(
text,
ws_signal,
end_mode,
terminal_ref,
end_prog_scroll,
);
}
clear_ime_textarea(&end_ime);
}
};
// -- Mouse selection ----------------------------------------------------
//
// Drag to select a range of terminal text. Selection is tracked in logical
// buffer coordinates (not DOM), so it survives re-renders and works across
// rows that have scrolled out of the rendered window.
let cell_from_event = move |event: &MouseEvent| -> Option<(usize, usize)> {
let element = terminal_ref.get_untracked()?;
let rect = element.get_bounding_client_rect();
let local_x = f64::from(event.client_x()) - rect.left();
let local_y = f64::from(event.client_y()) - rect.top();
let (cell_w, cell_h) = cell_size.get_untracked();
let cols = current_viewport.get_untracked().cols as usize;
let total = core_signal
.try_with_untracked(|core| core.total_rows())
.unwrap_or(0);
Some(point_to_cell(
local_x,
local_y,
f64::from(element.scroll_top()),
cell_w,
cell_h,
cols,
total,
))
};
// Grid coordinates for mouse *reporting* — relative to the visible screen
// (no scrollback offset), so the app gets the cell under the cursor on the
// grid it is currently drawing.
let grid_from_event = move |event: &MouseEvent| -> Option<(usize, usize)> {
let element = terminal_ref.get_untracked()?;
let rect = element.get_bounding_client_rect();
let local_x = f64::from(event.client_x()) - rect.left();
let local_y = f64::from(event.client_y()) - rect.top();
let (cell_w, cell_h) = cell_size.get_untracked();
let vp = current_viewport.get_untracked();
Some(pixel_to_grid(
local_x,
local_y,
cell_w,
cell_h,
vp.cols as usize,
vp.rows as usize,
))
};
let handle_mousedown = {
let down_selection = selection;
let down_selecting = selecting;
let down_core = core_signal;
let down_held = mouse_held_button.clone();
let down_ws = ws_signal;
move |event: MouseEvent| {
// App mouse reporting takes precedence over local selection, unless
// the user holds Shift to force a selection (xterm behaviour).
if !event.shift_key() {
let mode_enc = down_core.try_with_untracked(|c| {
(c.mouse_protocol_mode(), c.mouse_protocol_encoding())
});
if let Some((mode, encoding)) = mode_enc
&& !matches!(mode, MouseProtocolMode::None)
{
event.prevent_default();
if let Some(base) = mouse_button_base(event.button()) {
let button = with_mouse_modifiers(
base,
event.shift_key(),
event.alt_key(),
event.ctrl_key(),
);
if let Some((row, col)) = grid_from_event(&event) {
down_held.set(Some(base));
let data = encode_mouse_report(
button,
MouseAction::Press,
col,
row,
encoding,
);
if let Some(socket) = down_ws.get_untracked() {
send_terminal_message(
&socket,
&ClientTerminalMessage::Input { data },
);
}
}
}
return;
}
}
// Local selection.
if event.button() != 0 {
return; // only left button starts a selection
}
let Some(cell) = cell_from_event(&event) else {
return;
};
down_selection.set(Some(Selection {
anchor: cell,
head: cell,
}));
down_selecting.set(true);
}
};
let handle_mousemove = {
let move_selection = selection;
let move_selecting = selecting;
let move_core = core_signal;
let move_held = mouse_held_button.clone();
let move_last = mouse_last_cell.clone();
let move_ws = ws_signal;
move |event: MouseEvent| {
// App mouse motion reporting.
if !event.shift_key() {
let mode_enc = move_core.try_with_untracked(|c| {
(c.mouse_protocol_mode(), c.mouse_protocol_encoding())
});
if let Some((mode, encoding)) = mode_enc {
let report = match mode {
MouseProtocolMode::ButtonMotion => move_held.get().is_some(),
MouseProtocolMode::AnyMotion => true,
_ => false,
};
if report {
event.prevent_default();
if let Some((row, col)) = grid_from_event(&event) {
// Throttle to one report per grid cell.
if move_last.get() != Some((row, col)) {
move_last.set(Some((row, col)));
let base = match move_held.get() {
Some(b) => 32 + b, // motion with button held
None => 35, // AnyMotion, no button
};
let button = with_mouse_modifiers(
base,
event.shift_key(),
event.alt_key(),
event.ctrl_key(),
);
let data = encode_mouse_report(
button,
MouseAction::Motion,
col,
row,
encoding,
);
if let Some(socket) = move_ws.get_untracked() {
send_terminal_message(
&socket,
&ClientTerminalMessage::Input { data },
);
}
}
}
return;
}
}
}
// Local selection drag.
if !move_selecting.get_untracked() {
return;
}
let Some(cell) = cell_from_event(&event) else {
return;
};
move_selection.update(|sel| {
if let Some(s) = sel {
s.head = cell;
}
});
}
};
let handle_mouseup = {
let up_selecting = selecting;
let up_core = core_signal;
let up_held = mouse_held_button.clone();
let up_last = mouse_last_cell.clone();
let up_ws = ws_signal;
move |event: MouseEvent| {
if !event.shift_key() {
let mode_enc = up_core.try_with_untracked(|c| {
(c.mouse_protocol_mode(), c.mouse_protocol_encoding())
});
if let Some((mode, encoding)) = mode_enc
&& !matches!(mode, MouseProtocolMode::None)
{
event.prevent_default();
// Press (X10) mode never reports releases; the others do.
if matches!(
mode,
MouseProtocolMode::PressRelease
| MouseProtocolMode::ButtonMotion
| MouseProtocolMode::AnyMotion
) && let Some((row, col)) = grid_from_event(&event)
{
// SGR encodes the actual button + 'm'; legacy uses
// button 3 (handled in encode_mouse_report).
let held = up_held.get().unwrap_or(0);
let button = with_mouse_modifiers(
held,
event.shift_key(),
event.alt_key(),
event.ctrl_key(),
);
let data = encode_mouse_report(
button,
MouseAction::Release,
col,
row,
encoding,
);
if let Some(socket) = up_ws.get_untracked() {
send_terminal_message(
&socket,
&ClientTerminalMessage::Input { data },
);
}
}
up_held.set(None);
up_last.set(None);
return;
}
}
// Local selection end.
up_selecting.set(false);
}
};
let handle_click = move |_| {
focus_ime_textarea(&ime_ref);
};
// -- View ---------------------------------------------------------------
view! {
<section class="terminal-shell-card">
<div class="terminal-header">
<div>
<h2 class="terminal-title">"Rust Browser Terminal"</h2>
<p class="terminal-subtitle">
"Axum + portable-pty backend, Leptos + Rust/WASM client."
</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>
<div
node_ref=terminal_ref
class="terminal-screen"
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-grid">
{move || {
// Subscribe to render_tick (frame-coalesced data
// changes), scroll_top (scroll), view_mode, selection.
let _ = render_tick.get();
let is_live = view_mode.get().is_live();
let sel = selection.get();
let (cell_w, _cell_h) = cell_size.get();
let vp = current_viewport.get_untracked();
let vp_rows = vp.rows as usize;
let cols = vp.cols as usize;
let st = scroll_top.get();
// Read core untracked — the core signal itself never
// notifies; render_tick is the explicit render trigger.
// `try_*` so a stale render during hydration no-ops to
// an empty view instead of panicking.
let (total_px, offset_px, visible) = core_signal
.try_with_untracked(|core| {
virtual_window(core, is_live, st, vp_rows)
})
.unwrap_or((0.0, 0.0, Vec::new()));
let window_start =
(offset_px / SCROLL_LINE_HEIGHT).round() as usize;
let window_end = window_start + visible.len();
let rects = selection_rects(
sel, window_start, window_end, cols, cell_w,
);
render_spaced_view(total_px, offset_px, visible, rects)
}}
</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,
) -> (f64, f64, Vec<TerminalRow>) {
let total = core.total_rows();
let is_alt = core.alternate_screen();
if is_alt || total == 0 {
// Alt-screen / empty: content fits the viewport; total height == rows.
let rows = core.screen_rows().to_vec();
let total_px = rows.len() as f64 * SCROLL_LINE_HEIGHT;
return (total_px, 0.0, rows);
}
let first_visible = if is_live {
// Pin to the bottom: first visible row = total - viewport.
total.saturating_sub(vp_rows)
} else {
(f64::from(scroll_top_px.max(0)) / SCROLL_LINE_HEIGHT).floor() as usize
};
let start = first_visible.saturating_sub(VIRTUAL_OVERSCAN);
let end = (first_visible + vp_rows + VIRTUAL_OVERSCAN).min(total);
// Absolute-positioning virtual scroll: the container is ALWAYS exactly
// `total_px` tall (so the scroll geometry never changes when the visible
// window is swapped), and the rendered rows are offset to `offset_px` from
// the top. Keeping the container height constant across re-renders is what
// prevents the browser from clamping scrollTop mid-scroll and firing the
// feedback scroll events that caused the "keeps drifting up" behaviour.
let total_px = total as f64 * SCROLL_LINE_HEIGHT;
let offset_px = start as f64 * SCROLL_LINE_HEIGHT;
(total_px, offset_px, core.collect_window(start, end))
}
/// A highlight rectangle for one selected row, positioned relative to the top
/// of the rendered (translated) rows layer.
#[derive(Clone, Copy, Debug)]
struct SelectionRect {
top_px: f64,
left_px: f64,
width_px: f64,
}
/// Compute the per-row highlight rects for `sel`, clipped to the rendered
/// window `[window_start, window_end)`. Returns empty for no/empty selection.
fn selection_rects(
sel: Option<Selection>,
window_start: usize,
window_end: usize,
cols: usize,
cell_w: f64,
) -> Vec<SelectionRect> {
let Some(sel) = sel else {
return Vec::new();
};
if sel.is_empty() {
return Vec::new();
}
let ((sr, sc), (er, ec)) = sel.ordered();
let first = sr.max(window_start);
let last = er.min(window_end.saturating_sub(1));
let mut rects = Vec::new();
for r in first..=last {
if r >= window_end {
break;
}
let (col_a, col_b) = if sr == er {
(sc, ec)
} else if r == sr {
(sc, cols)
} else if r == er {
(0, ec)
} else {
(0, cols)
};
let col_b = col_b.max(col_a);
rects.push(SelectionRect {
top_px: (r - window_start) as f64 * SCROLL_LINE_HEIGHT,
left_px: col_a as f64 * cell_w,
width_px: (col_b - col_a) as f64 * cell_w,
});
}
rects
}
/// Render the visible rows using absolute positioning inside a fixed-height
/// container.
///
/// The outer `.terminal-grid` is sized to the FULL content height
/// (`total_px`), constant across re-renders, so swapping the visible window
/// never changes the scroll container's geometry — the browser keeps the
/// scrollTop stable and fires no spurious scroll events. The visible rows are
/// placed in an absolutely-positioned layer offset by `offset_px`.
fn render_spaced_view(
total_px: f64,
offset_px: f64,
visible: Vec<TerminalRow>,
rects: Vec<SelectionRect>,
) -> 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);")
>
{rects.into_iter().map(render_selection_rect).collect_view()}
{visible.into_iter().map(render_terminal_row).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, SCROLL_LINE_HEIGHT
);
view! { <div class="terminal-selection" style=style aria-hidden="true"></div> }
}
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();
}
// ---------------------------------------------------------------------------
// Network helpers
// ---------------------------------------------------------------------------
fn websocket_url() -> String {
let window = window();
let location = window.location();
let protocol = location.protocol().unwrap_or_else(|_| "http:".to_owned());
let host = location
.host()
.unwrap_or_else(|_| "127.0.0.1:3100".to_owned());
let scheme = if protocol == "https:" { "wss" } else { "ws" };
format!("{scheme}://{host}{}/terminal/ws", SiteConfig::BASE_PATH)
}
fn send_terminal_message(
socket: &WebSocket,
message: &ClientTerminalMessage,
) {
if let Ok(payload) = serde_json::to_string(message) {
let _ = socket.send_with_str(&payload);
}
}
fn send_resize_message(socket: &WebSocket, viewport: TerminalViewport) {
send_terminal_message(
socket,
&ClientTerminalMessage::Resize {
cols: viewport.cols,
rows: viewport.rows,
pixel_width: viewport.pixel_width,
pixel_height: viewport.pixel_height,
},
);
}
// ---------------------------------------------------------------------------
// Measurement helpers
// ---------------------------------------------------------------------------
fn measure_terminal_viewport(
terminal_ref: NodeRef<html::Div>,
measure_ref: NodeRef<html::Span>,
) -> TerminalViewport {
let Some(element) = terminal_ref.get_untracked() else {
return TerminalViewport::default();
};
let Some(element) = element.dyn_ref::<HtmlElement>() else {
return TerminalViewport::default();
};
measure_terminal_viewport_from_rect(
f64::from(element.client_width().max(0)),
f64::from(element.client_height().max(0)),
measure_ref,
)
}
fn measure_terminal_viewport_from_rect(
width: f64,
height: f64,
measure_ref: NodeRef<html::Span>,
) -> TerminalViewport {
let (cell_width, cell_height) = measure_cell_size(measure_ref);
let safe_width = (width - 2.0).max(cell_width);
let safe_height = (height - 2.0).max(cell_height);
let cols =
((safe_width / cell_width).floor().max(1.0) as u16).max(MIN_COLS);
let rows =
((safe_height / cell_height).floor().max(1.0) as u16).max(MIN_ROWS);
TerminalViewport {
cols,
rows,
pixel_width: safe_width.floor().max(1.0) as u16,
pixel_height: safe_height.floor().max(1.0) as u16,
}
}
fn measure_cell_size(measure_ref: NodeRef<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,
)
}
/// Map a pointer position to a logical `(row, col)` cell.
///
/// `local_x`/`local_y` are relative to the scroll container's top-left
/// (clientX/Y minus its bounding rect). Padding is removed, the scroll offset
/// is added to Y, then the result is divided by the cell size and clamped to
/// the grid. Pure for unit-testing.
fn point_to_cell(
local_x: f64,
local_y: f64,
scroll_top: f64,
cell_w: f64,
cell_h: f64,
cols: usize,
total_rows: usize,
) -> (usize, usize) {
let x = (local_x - TERMINAL_PADDING).max(0.0);
let y = (local_y - TERMINAL_PADDING + scroll_top).max(0.0);
let col = (x / cell_w.max(1.0)).floor() as usize;
let row = (y / cell_h.max(1.0)).floor() as usize;
(
row.min(total_rows.saturating_sub(1)),
col.min(cols.saturating_sub(1)),
)
}
/// Focus the hidden IME textarea so keyboard input and composition go there.
fn focus_ime_textarea(ime_ref: &NodeRef<html::Textarea>) {
if let Some(element) = ime_ref.get_untracked() {
let _ = element.focus();
}
}
/// Write text to the system clipboard (fire-and-forget). Called from a user
/// gesture (Ctrl-Shift-C keydown), which browsers allow without a prompt in
/// secure contexts (localhost counts as secure).
fn copy_to_clipboard(text: &str) {
if text.is_empty() {
return;
}
let clipboard = window().navigator().clipboard();
let _ = clipboard.write_text(text);
}
/// Copy the current text selection to the system clipboard, if any.
fn copy_current_selection(selection: RwSignal<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>,
) {
if data.is_empty() {
return;
}
let Some(socket) = socket_signal.get_untracked() else {
return;
};
if !mode_signal.get_untracked().is_live() {
mode_signal.set(ViewMode::Live);
sync_scroll_to_bottom(terminal_ref, prog_scroll);
}
send_terminal_message(
&socket,
&ClientTerminalMessage::Input { data },
);
}
/// Read the current value of the hidden IME textarea.
fn ime_ref_text(ime_ref: &NodeRef<html::Textarea>) -> Option<String> {
ime_ref.get_untracked().map(|t| t.value())
}
/// Clear the hidden IME textarea after sending composed/typed text.
fn clear_ime_textarea(ime_ref: &NodeRef<html::Textarea>) {
if let Some(t) = ime_ref.get_untracked() {
t.set_value("");
}
}
// ---------------------------------------------------------------------------
// Key mapping
// ---------------------------------------------------------------------------
/// Number of rows to scroll for a wheel event, from its delta and delta mode.
///
/// `delta_mode`: 0 = pixels, 1 = lines, 2 = pages. We translate to a
/// positive row count (the direction is taken from the sign separately). One
/// notch is clamped to scroll at least 1 and at most `max_rows`.
fn wheel_rows_for_delta(delta_y: f64, delta_mode: u32, max_rows: usize) -> usize {
const WHEEL_PIXELS_PER_ROW: f64 = SCROLL_LINE_HEIGHT;
const WHEEL_LINES_PER_PAGE: f64 = 8.0;
let magnitude = delta_y.abs();
if magnitude == 0.0 {
return 0;
}
let rows = match delta_mode {
1 => magnitude, // already in lines
2 => magnitude * WHEEL_LINES_PER_PAGE, // pages
_ => magnitude / WHEEL_PIXELS_PER_ROW, // pixels
};
let rows = rows.round() as usize;
rows.clamp(1, max_rows.max(1))
}
// ---------------------------------------------------------------------------
// Mouse reporting
// ---------------------------------------------------------------------------
/// What kind of mouse action is being reported. Wheel ticks are reported as
/// a `Press` of button 64/65.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum MouseAction {
Press,
Release,
Motion,
}
/// Map a browser mouse button index (`MouseEvent.button`) to the terminal's
/// low button code, or `None` for buttons we don't report (e.g. button 4+).
/// Left=0, middle=1, right=2 — same numbering xterm uses.
fn mouse_button_base(browser_button: i16) -> Option<u8> {
match browser_button {
0..=2 => Some(browser_button as u8),
_ => None,
}
}
/// OR the modifier bits (shift=4, meta=8, control=16) into a mouse button
/// code, matching the xterm mouse encoding.
fn with_mouse_modifiers(button: u8, shift: bool, meta: bool, ctrl: bool) -> u8 {
let mut b = button;
if shift {
b |= 4;
}
if meta {
b |= 8;
}
if ctrl {
b |= 16;
}
b
}
/// Encode a mouse event as the bytes a terminal would receive, per the app's
/// requested encoding. `button` is the fully-composed low code (button +
/// modifiers + motion/wheel bits); `col`/`row` are 0-based grid coordinates.
///
/// SGR (1006) is fully correct and ASCII-only. The legacy Default/UTF-8
/// encodings are correct for coordinates ≤ 94 (the ASCII-safe range); beyond
/// that they are clamped, which matches what most terminals do for large
/// screens — and modern apps (vim, htop, tmux) negotiate SGR anyway.
fn encode_mouse_report(
button: u8,
action: MouseAction,
col: usize,
row: usize,
encoding: MouseProtocolEncoding,
) -> String {
match encoding {
MouseProtocolEncoding::Sgr => {
let suffix = if action == MouseAction::Release {
'm'
} else {
'M'
};
format!("\u{1b}[<{button};{};{}{suffix}", col + 1, row + 1)
}
MouseProtocolEncoding::Default | MouseProtocolEncoding::Utf8 => {
// Legacy: ESC[M followed by three values each offset by +32.
// Release is reported as button 3 in this encoding.
let b = if action == MouseAction::Release { 3 } else { button };
let c = col.min(94) as u8 + 1;
let r = row.min(94) as u8 + 1;
format!(
"\u{1b}M{}{}{}",
char::from_u32(u32::from(b) + 32).unwrap_or(' '),
char::from_u32(u32::from(c) + 32).unwrap_or(' '),
char::from_u32(u32::from(r) + 32).unwrap_or(' '),
)
}
}
}
/// Map a pixel position inside the `.terminal-screen` content box to 0-based
/// grid coordinates (row, col), clamped to the viewport. Unlike `point_to_cell`
/// this does NOT add `scroll_top`: mouse reports are relative to the grid the
/// app is currently drawing (the visible screen), not the scrollback buffer.
fn pixel_to_grid(
local_x: f64,
local_y: f64,
cell_w: f64,
cell_h: f64,
cols: usize,
rows: usize,
) -> (usize, usize) {
let x = (local_x - TERMINAL_PADDING).max(0.0);
let y = (local_y - TERMINAL_PADDING).max(0.0);
let col = ((x / cell_w.max(1.0)).floor() as usize).min(cols.saturating_sub(1));
let row = ((y / cell_h.max(1.0)).floor() as usize).min(rows.saturating_sub(1));
(row, col)
}
/// Prepare clipboard text for sending to the PTY.
///
/// Normalises line endings to CR (`\r`), the byte a terminal expects for line
/// submission (Enter sends `\r`). When the application enabled bracketed paste,
/// wraps the text in `ESC[200~ … ESC[201~`.
fn prepare_paste(raw: &str, bracketed: bool) -> String {
let normalized = raw.replace("\r\n", "\r").replace('\n', "\r");
if bracketed {
format!("\u{1b}[200~{normalized}\u{1b}[201~")
} else {
normalized
}
}
/// Build a cursor/nav key sequence, SS3 (`ESC O <b>`) when the app is in
/// application-cursor mode, else CSI (`ESC [ <b>`).
fn cursor_seq(final_byte: char, app_cursor: bool) -> String {
if app_cursor {
format!("\u{1b}O{final_byte}")
} else {
format!("\u{1b}[{final_byte}")
}
}
/// Pure key-table: map a key + modifier state + application-cursor flag to
/// the bytes a terminal would receive for the same key. Split out from
/// `map_key_to_terminal_input` so the entire table is unit-testable without
/// constructing a `KeyboardEvent` (which isn't easy in native tests).
fn key_to_bytes(
key: &str,
ctrl: bool,
alt: bool,
meta: bool,
app_cursor: bool,
) -> Option<String> {
if ctrl && !alt {
// Modifier keys like "Control" / "Shift" report ctrl=true but are not
// real Ctrl+letter combos; pressing Ctrl alone must not send Ctrl-C.
if key.chars().count() != 1 {
return None;
}
let c = key.chars().next()?;
let code = match c {
'a'..='z' => (c.to_ascii_uppercase() as u8) & 0x1f,
'A'..='Z' => (c as u8) & 0x1f,
'@' => 0x00,
'[' => 0x1b,
'\\' => 0x1c,
']' => 0x1d,
'^' => 0x1e,
'_' => 0x1f,
_ => return None,
};
// 0x1a (Ctrl-Z) and 0x1c (Ctrl-\) are caught by the shell's stty as
// SIGTSTP / SIGQUIT and would suspend / kill the foreground process.
// Filter them so accidental keypresses don't blow up the user's work;
// Ctrl-C (0x03) is kept so the user can still interrupt intentionally.
if matches!(code, 0x1a | 0x1c) {
return None;
}
return Some((code as char).to_string());
}
if let (true, Some(c)) = (alt && !ctrl && !meta, key.chars().next()) {
return Some(format!("\u{1b}{c}"));
}
match key {
"Enter" => Some("\r".to_owned()),
"Backspace" => Some("\u{7f}".to_owned()),
"Tab" => Some("\t".to_owned()),
"Escape" => Some("\u{1b}".to_owned()),
"ArrowUp" => Some(cursor_seq('A', app_cursor)),
"ArrowDown" => Some(cursor_seq('B', app_cursor)),
"ArrowRight" => Some(cursor_seq('C', app_cursor)),
"ArrowLeft" => Some(cursor_seq('D', app_cursor)),
"Home" => Some(cursor_seq('H', app_cursor)),
"End" => Some(cursor_seq('F', app_cursor)),
"Insert" => Some("\u{1b}[2~".to_owned()),
"Delete" => Some("\u{1b}[3~".to_owned()),
"PageUp" => Some("\u{1b}[5~".to_owned()),
"PageDown" => Some("\u{1b}[6~".to_owned()),
"F1" => Some("\u{1b}OP".to_owned()),
"F2" => Some("\u{1b}OQ".to_owned()),
"F3" => Some("\u{1b}OR".to_owned()),
"F4" => Some("\u{1b}OS".to_owned()),
"F5" => Some("\u{1b}[15~".to_owned()),
"F6" => Some("\u{1b}[17~".to_owned()),
"F7" => Some("\u{1b}[18~".to_owned()),
"F8" => Some("\u{1b}[19~".to_owned()),
"F9" => Some("\u{1b}[20~".to_owned()),
"F10" => Some("\u{1b}[21~".to_owned()),
"F11" => Some("\u{1b}[23~".to_owned()),
"F12" => Some("\u{1b}[24~".to_owned()),
_ if key.chars().count() == 1 && !meta => Some(key.to_owned()),
_ => None,
}
}
/// Map a browser keyboard event to the bytes a terminal would receive for
/// the same key. Thin wrapper around `key_to_bytes`.
fn map_key_to_terminal_input(
event: &KeyboardEvent,
app_cursor: bool,
) -> Option<String> {
key_to_bytes(
&event.key(),
event.ctrl_key(),
event.alt_key(),
event.meta_key(),
app_cursor,
)
}
/// Whether a keydown event represents a regular printable character that
/// should be handled by the IME textarea's `input` event, rather than sent
/// directly from `keydown`. This lets the browser manage IME composition,
/// dead keys, and upper/lower case before we see the final text.
fn is_regular_text_input(key: &str, ctrl: bool, alt: bool, meta: bool) -> bool {
key.chars().count() == 1 && !ctrl && !alt && !meta
}
#[cfg(test)]
mod tests {
use super::*;
use crate::terminal::core::TerminalCore;
fn core_with_lines(n: usize) -> TerminalCore {
let mut core = TerminalCore::new(24, 80);
for i in 1..=n {
core.process(format!("line{i}\r\n").as_bytes());
}
core
}
#[test]
fn wheel_rows_for_delta_pixel_line_page() {
// Per-event cap (12) — the old per-event contract.
const MAX: usize = 12;
// Pixel mode: one notch (~one line height) → at least 1 row.
assert_eq!(wheel_rows_for_delta(SCROLL_LINE_HEIGHT, 0, MAX), 1);
// A few lines of pixels → that many rows.
assert_eq!(wheel_rows_for_delta(SCROLL_LINE_HEIGHT * 3.0, 0, MAX), 3);
// Tiny pixel delta still scrolls at least one row.
assert_eq!(wheel_rows_for_delta(1.0, 0, MAX), 1);
// Line mode: delta is already in lines.
assert_eq!(wheel_rows_for_delta(3.0, 1, MAX), 3);
// Page mode scrolls a chunk (clamped to the max).
assert_eq!(wheel_rows_for_delta(1.0, 2, MAX), 8);
// Sign is irrelevant here (direction handled separately); magnitude used.
assert_eq!(wheel_rows_for_delta(-SCROLL_LINE_HEIGHT * 2.0, 0, MAX), 2);
// Zero delta → no rows.
assert_eq!(wheel_rows_for_delta(0.0, 0, MAX), 0);
// Huge delta is clamped to the max.
assert_eq!(wheel_rows_for_delta(100_000.0, 0, MAX), 12);
}
#[test]
fn wheel_rows_for_delta_scales_with_max_rows_for_coalesced_batches() {
// A coalesced batch sums many small wheel deltas into a single big
// scroll. The per-batch cap (max_rows) should be respected so a
// single huge swipe doesn't fly past the bottom instantly.
let big_sum = SCROLL_LINE_HEIGHT * 80.0; // would be 80 rows unclamped
// Per-event cap (12): even with a large sum, capped to 12.
assert_eq!(wheel_rows_for_delta(big_sum, 0, 12), 12);
// Per-batch cap (a 24-row viewport): bigger than per-event.
assert_eq!(wheel_rows_for_delta(big_sum, 0, 24), 24);
// Honest mapping: a 5-row coalesced swipe is 5 rows, not 12.
assert_eq!(
wheel_rows_for_delta(SCROLL_LINE_HEIGHT * 5.0, 0, 24),
5
);
// Tiny sums still floor at 1.
assert_eq!(wheel_rows_for_delta(1.0, 0, 24), 1);
}
#[test]
fn point_to_cell_maps_pixels_with_padding_and_scroll() {
let (cw, ch) = (8.0, 18.0);
// Top-left content origin is at (PADDING, PADDING) = (16,16).
assert_eq!(point_to_cell(16.0, 16.0, 0.0, cw, ch, 80, 100), (0, 0));
// One cell right and one row down.
assert_eq!(point_to_cell(16.0 + 8.0, 16.0 + 18.0, 0.0, cw, ch, 80, 100), (1, 1));
// Scroll offset shifts the row mapping down.
assert_eq!(point_to_cell(16.0, 16.0, 180.0, cw, ch, 80, 100), (10, 0));
// Clamped to grid bounds.
assert_eq!(point_to_cell(99999.0, 99999.0, 0.0, cw, ch, 80, 100), (99, 79));
// Inside the padding clamps to (0,0), never negative.
assert_eq!(point_to_cell(0.0, 0.0, 0.0, cw, ch, 80, 100), (0, 0));
}
#[test]
fn pixel_to_grid_maps_visible_screen_coordinates() {
let (cw, ch) = (8.0, 18.0);
let cols = 80;
let rows = 24;
// Top-left of the content box (padding, padding) → (0, 0).
assert_eq!(
pixel_to_grid(16.0, 16.0, cw, ch, cols, rows),
(0, 0)
);
// One cell right/down.
assert_eq!(
pixel_to_grid(16.0 + 8.0, 16.0 + 18.0, cw, ch, cols, rows),
(1, 1)
);
// Clamped to viewport bounds.
assert_eq!(
pixel_to_grid(99999.0, 99999.0, cw, ch, cols, rows),
(23, 79)
);
// Negative/local inside padding clamps to (0, 0).
assert_eq!(pixel_to_grid(0.0, 0.0, cw, ch, cols, rows), (0, 0));
}
#[test]
fn encode_mouse_report_sgr_encoding() {
// Left press at col 5, row 3 (0-based → 6,4 in the report).
assert_eq!(
encode_mouse_report(0, MouseAction::Press, 5, 3, MouseProtocolEncoding::Sgr),
"\u{1b}[<0;6;4M"
);
// Left release uses the same button code but 'm' suffix.
assert_eq!(
encode_mouse_report(0, MouseAction::Release, 5, 3, MouseProtocolEncoding::Sgr),
"\u{1b}[<0;6;4m"
);
// Motion with left button held (32+0) + shift modifier (4) = 36.
assert_eq!(
encode_mouse_report(36, MouseAction::Motion, 0, 0, MouseProtocolEncoding::Sgr),
"\u{1b}[<36;1;1M"
);
// Wheel down (65).
assert_eq!(
encode_mouse_report(65, MouseAction::Press, 10, 20, MouseProtocolEncoding::Sgr),
"\u{1b}[<65;11;21M"
);
}
#[test]
fn encode_mouse_report_default_encoding() {
// Left press: ESC M ' ' (32) '!' (33) '!' (33) for col 1 row 1.
assert_eq!(
encode_mouse_report(0, MouseAction::Press, 0, 0, MouseProtocolEncoding::Default),
"\u{1b}M !!"
);
// Release is always button 3 in Default encoding.
assert_eq!(
encode_mouse_report(0, MouseAction::Release, 0, 0, MouseProtocolEncoding::Default),
"\u{1b}M#!!"
);
// Coordinates are clamped to the ASCII-safe range (col 94 → report 95+32=127).
assert_eq!(
encode_mouse_report(0, MouseAction::Press, 200, 100, MouseProtocolEncoding::Default),
"\u{1b}M \u{7f}\u{7f}"
);
}
#[test]
fn mouse_modifiers_or_into_button_code() {
assert_eq!(with_mouse_modifiers(0, false, false, false), 0);
assert_eq!(with_mouse_modifiers(0, true, false, false), 4);
assert_eq!(with_mouse_modifiers(0, false, true, false), 8);
assert_eq!(with_mouse_modifiers(0, false, false, true), 16);
assert_eq!(with_mouse_modifiers(0, true, true, true), 28);
assert_eq!(with_mouse_modifiers(2, true, false, true), 22);
}
#[test]
fn mouse_button_base_only_maps_three_buttons() {
assert_eq!(mouse_button_base(0), Some(0));
assert_eq!(mouse_button_base(1), Some(1));
assert_eq!(mouse_button_base(2), Some(2));
assert_eq!(mouse_button_base(3), None);
assert_eq!(mouse_button_base(4), None);
}
#[test]
fn selection_rects_clip_to_window_and_span_columns() {
let cw = 8.0;
let cols = 80;
// Single-row selection cols 2..6 on row 5, window [0,30).
let sel = Some(Selection { anchor: (5, 2), head: (5, 6) });
let rects = selection_rects(sel, 0, 30, cols, cw);
assert_eq!(rects.len(), 1);
assert_eq!(rects[0].top_px, 5.0 * SCROLL_LINE_HEIGHT);
assert_eq!(rects[0].left_px, 2.0 * cw);
assert_eq!(rects[0].width_px, 4.0 * cw);
// Multi-row selection rows 4..6: first row partial, middle full, last partial.
let sel = Some(Selection { anchor: (4, 10), head: (6, 3) });
let rects = selection_rects(sel, 0, 30, cols, cw);
assert_eq!(rects.len(), 3);
assert_eq!(rects[0].left_px, 10.0 * cw); // first row starts at col 10
assert_eq!(rects[1].width_px, cols as f64 * cw); // middle row full width
assert_eq!(rects[2].left_px, 0.0); // last row from col 0
assert_eq!(rects[2].width_px, 3.0 * cw);
// Selection entirely above the window → clipped out.
let sel = Some(Selection { anchor: (1, 0), head: (2, 5) });
assert!(selection_rects(sel, 10, 30, cols, cw).is_empty());
// Empty selection → no rects.
let sel = Some(Selection { anchor: (5, 2), head: (5, 2) });
assert!(selection_rects(sel, 0, 30, cols, cw).is_empty());
assert!(selection_rects(None, 0, 30, cols, cw).is_empty());
}
#[test]
fn is_regular_text_input_detects_printable_keys() {
assert!(is_regular_text_input("a", false, false, false));
assert!(is_regular_text_input("A", false, false, false)); // Shift handled by browser
assert!(is_regular_text_input(" ", false, false, false));
assert!(!is_regular_text_input("Enter", false, false, false));
assert!(!is_regular_text_input("ArrowUp", false, false, false));
assert!(!is_regular_text_input("a", true, false, false)); // Ctrl+a
assert!(!is_regular_text_input("a", false, true, false)); // Alt+a
assert!(!is_regular_text_input("a", false, false, true)); // Meta+a
assert!(!is_regular_text_input("Dead", false, false, false));
}
#[test]
fn key_to_bytes_table() {
// --- basic printable / control keys ---
assert_eq!(key_to_bytes("a", false, false, false, false).as_deref(), Some("a"));
assert_eq!(key_to_bytes("Z", false, false, false, false).as_deref(), Some("Z"));
assert_eq!(key_to_bytes("Enter", false, false, false, false).as_deref(), Some("\r"));
assert_eq!(key_to_bytes("Backspace", false, false, false, false).as_deref(), Some("\u{7f}"));
assert_eq!(key_to_bytes("Tab", false, false, false, false).as_deref(), Some("\t"));
assert_eq!(key_to_bytes("Escape", false, false, false, false).as_deref(), Some("\u{1b}"));
// --- application-cursor mode toggles arrows / Home / End to SS3 ---
let up_csi = key_to_bytes("ArrowUp", false, false, false, false).unwrap();
assert_eq!(up_csi, "\u{1b}[A");
let up_ss3 = key_to_bytes("ArrowUp", false, false, false, true).unwrap();
assert_eq!(up_ss3, "\u{1b}OA");
let home_csi = key_to_bytes("Home", false, false, false, false).unwrap();
assert_eq!(home_csi, "\u{1b}[H");
let home_ss3 = key_to_bytes("Home", false, false, false, true).unwrap();
assert_eq!(home_ss3, "\u{1b}OH");
// Arrows + Home/End cover all four directions.
for (key, normal, alt) in [
("ArrowUp", 'A', 'A'),
("ArrowDown", 'B', 'B'),
("ArrowRight", 'C', 'C'),
("ArrowLeft", 'D', 'D'),
("Home", 'H', 'H'),
("End", 'F', 'F'),
] {
let (csi, ss3) = (normal, alt);
assert_eq!(key_to_bytes(key, false, false, false, false).unwrap(), format!("\u{1b}[{csi}"));
assert_eq!(key_to_bytes(key, false, false, false, true).unwrap(), format!("\u{1b}O{ss3}"));
}
// --- paging / editing keys (always CSI) ---
assert_eq!(key_to_bytes("Insert", false, false, false, false).as_deref(), Some("\u{1b}[2~"));
assert_eq!(key_to_bytes("Delete", false, false, false, false).as_deref(), Some("\u{1b}[3~"));
assert_eq!(key_to_bytes("PageUp", false, false, false, false).as_deref(), Some("\u{1b}[5~"));
assert_eq!(key_to_bytes("PageDown", false, false, false, false).as_deref(), Some("\u{1b}[6~"));
// Paging does not change with app-cursor mode.
assert_eq!(key_to_bytes("PageUp", false, false, false, true).as_deref(), Some("\u{1b}[5~"));
// --- function keys (F1-F4 SS3, F5-F12 CSI~ with 16/22 gaps) ---
for (key, expected) in [
("F1", "\u{1b}OP"), ("F2", "\u{1b}OQ"), ("F3", "\u{1b}OR"), ("F4", "\u{1b}OS"),
("F5", "\u{1b}[15~"), ("F6", "\u{1b}[17~"), ("F7", "\u{1b}[18~"),
("F8", "\u{1b}[19~"), ("F9", "\u{1b}[20~"), ("F10", "\u{1b}[21~"),
("F11", "\u{1b}[23~"), ("F12", "\u{1b}[24~"),
] {
assert_eq!(key_to_bytes(key, false, false, false, false).as_deref(),
Some(expected), "key {key}");
}
// --- Ctrl letters: A=0x01 .. Z=0x1a (except Z which is filtered) ---
for (letter, code) in [
("a", '\u{1}'), ("c", '\u{3}'), ("d", '\u{4}'),
("l", '\u{c}'), ("A", '\u{1}'),
] {
assert_eq!(
key_to_bytes(letter, true, false, false, false).as_deref(),
Some(&code.to_string()[..]),
"ctrl+{letter}"
);
}
// Ctrl+@ (NUL), Ctrl+[ (ESC), Ctrl+], Ctrl+^, Ctrl+_ still map.
assert_eq!(key_to_bytes("@", true, false, false, false).as_deref(), Some("\u{0}"));
assert_eq!(key_to_bytes("[", true, false, false, false).as_deref(), Some("\u{1b}"));
assert_eq!(key_to_bytes("]", true, false, false, false).as_deref(), Some("\u{1d}"));
assert_eq!(key_to_bytes("^", true, false, false, false).as_deref(), Some("\u{1e}"));
assert_eq!(key_to_bytes("_", true, false, false, false).as_deref(), Some("\u{1f}"));
// Ctrl-Z (0x1a, SIGTSTP) and Ctrl-\ (0x1c, SIGQUIT) are filtered
// out so accidental keypresses don't suspend / kill the foreground
// process. Ctrl-C (0x03) is still sent.
assert_eq!(key_to_bytes("z", true, false, false, false), None);
assert_eq!(key_to_bytes("Z", true, false, false, false), None);
assert_eq!(key_to_bytes("\\", true, false, false, false), None);
assert_eq!(key_to_bytes("c", true, false, false, false).as_deref(), Some("\u{3}"));
assert_eq!(key_to_bytes("]", true, false, false, false).as_deref(), Some("\u{1d}"));
assert_eq!(key_to_bytes("^", true, false, false, false).as_deref(), Some("\u{1e}"));
assert_eq!(key_to_bytes("_", true, false, false, false).as_deref(), Some("\u{1f}"));
// Ctrl+Alt falls through to the printable-letter fallback (most
// terminals forward the bare char; few apps use this combo).
assert_eq!(key_to_bytes("c", true, true, false, false).as_deref(), Some("c"));
// Pressing the Ctrl/Shift/Meta modifier keys alone must not emit
// anything; previously "Control" with ctrl=true was misread as "C" and
// sent SIGINT (0x03).
assert_eq!(key_to_bytes("Control", true, false, false, false), None);
assert_eq!(key_to_bytes("Shift", false, false, false, false), None);
assert_eq!(key_to_bytes("Meta", false, false, true, false), None);
// Ctrl+Shift (or any ctrl+modifier) also stays silent.
assert_eq!(key_to_bytes("Shift", true, false, false, false), None);
// --- Alt+char → ESC prefix ---
assert_eq!(key_to_bytes("a", false, true, false, false).as_deref(), Some("\u{1b}a"));
assert_eq!(key_to_bytes("x", false, true, false, false).as_deref(), Some("\u{1b}x"));
// Alt+Ctrl+char falls through to the printable-letter fallback too.
assert_eq!(key_to_bytes("a", true, true, false, false).as_deref(), Some("a"));
// Meta suppresses passthrough.
assert_eq!(key_to_bytes("a", false, false, true, false), None);
}
#[test]
fn prepare_paste_normalizes_newlines_and_wraps_when_bracketed() {
// Plain paste: CRLF and LF both become CR, no wrapping.
assert_eq!(prepare_paste("a\r\nb\nc", false), "a\rb\rc");
assert_eq!(prepare_paste("echo hi", false), "echo hi");
// Bracketed paste: same normalization, wrapped in 200~/201~.
let esc = '\u{1b}';
assert_eq!(
prepare_paste("a\nb", true),
format!("{esc}[200~a\rb{esc}[201~")
);
// Empty-ish content still wraps consistently when bracketed.
assert_eq!(prepare_paste("", true), format!("{esc}[200~{esc}[201~"));
}
#[test]
fn live_mode_window_is_pinned_to_bottom_regardless_of_scroll_top() {
let core = core_with_lines(1000);
let total = core.total_rows();
let vp_rows = 24;
// Even with a stale scroll_top of 0, live mode must render the BOTTOM
// window (this was the blank-area / no-output bug).
let (total_px, offset_px, visible) =
virtual_window(&core, true, 0, vp_rows);
// Sizer is always the full content height (constant across renders).
assert!(
(total_px - total as f64 * SCROLL_LINE_HEIGHT).abs() < 1.0,
"sizer height must equal total content height"
);
// The visible window is offset to the bottom: start = total-vp-overscan.
let expected_start = total - vp_rows - VIRTUAL_OVERSCAN;
assert!(
(offset_px - expected_start as f64 * SCROLL_LINE_HEIGHT).abs() < 1.0
);
// The window covers through the last row.
assert!(
offset_px + visible.len() as f64 * SCROLL_LINE_HEIGHT
>= total as f64 * SCROLL_LINE_HEIGHT - 1.0,
"bottom window must reach the final row"
);
}
#[test]
fn history_mode_window_follows_scroll_top() {
let core = core_with_lines(1000);
let total = core.total_rows();
let vp_rows = 24;
// Scroll to row 500 (500 * 18px).
let st = (500.0 * SCROLL_LINE_HEIGHT) as i32;
let (total_px, offset_px, visible) =
virtual_window(&core, false, st, vp_rows);
// Sizer height is constant regardless of scroll position.
assert!((total_px - total as f64 * SCROLL_LINE_HEIGHT).abs() < 1.0);
// First rendered row should be ~ (500 - overscan).
let first_row = (offset_px / SCROLL_LINE_HEIGHT).round() as usize;
assert_eq!(first_row, 500 - VIRTUAL_OVERSCAN);
assert!(!visible.is_empty());
}
#[test]
fn short_content_renders_without_offset() {
let core = core_with_lines(3); // far fewer than a 24-row viewport
let (total_px, offset_px, visible) =
virtual_window(&core, true, 0, 24);
// Sizer == content height, no offset, all rows rendered.
assert!(
(total_px - core.total_rows() as f64 * SCROLL_LINE_HEIGHT).abs()
< 1.0
);
assert_eq!(offset_px, 0.0);
assert_eq!(visible.len(), core.total_rows());
}
}