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>
2207 lines
86 KiB
Rust
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());
|
|
}
|
|
}
|