- Add debounced aria-live region that announces visible terminal text to screen readers (500ms throttle, non-empty trimmed rows only) - Add role=application, aria-label, aria-roledescription to terminal - Add prefers-reduced-motion listener: disables cursor blink animation and sets --terminal-reduce-motion CSS variable - Add prefers-contrast: more media query with Canvas/CanvasText system color fallback - Add visually hidden .terminal-screen-reader CSS utility - Mark P7 complete in roadmap
3293 lines
120 KiB
Rust
3293 lines
120 KiB
Rust
use std::rc::Rc;
|
|
use std::sync::Arc;
|
|
|
|
use app_config::SiteConfig;
|
|
use leptos::html;
|
|
use leptos::prelude::*;
|
|
use wasm_bindgen::JsCast;
|
|
use wasm_bindgen::closure::Closure;
|
|
use web_sys::js_sys::{Array, Function};
|
|
use web_sys::{
|
|
BinaryType, ClipboardEvent, CompositionEvent, ErrorEvent, Event, HtmlCanvasElement,
|
|
HtmlElement, InputEvent, KeyboardEvent, MessageEvent, MouseEvent, ResizeObserver,
|
|
ResizeObserverEntry, WebSocket, WheelEvent,
|
|
};
|
|
|
|
use super::core::{
|
|
MouseProtocolMode, SearchDirection, TerminalCore, TerminalLink, TerminalRow,
|
|
TerminalSearchMatch, TerminalSegment,
|
|
};
|
|
use super::ime::{clear_ime_textarea, focus_ime_textarea, ime_cursor_cell, ime_ref_text};
|
|
use super::keyboard::{
|
|
cursor_seq, is_regular_text_input, map_key_to_terminal_input, prepare_paste,
|
|
};
|
|
use super::mouse::{
|
|
MouseAction, encode_mouse_report, mouse_button_base, pixel_to_grid, wheel_rows_for_delta,
|
|
with_mouse_modifiers,
|
|
};
|
|
use super::protocol::{ClientTerminalMessage, ServerTerminalMessage};
|
|
use super::renderer::{
|
|
CanvasDomState, CanvasPlanInput, CanvasSurfaceFrame, CanvasSurfaceMode,
|
|
RenderCanvasSurfaceRequest, WebGlContextLifecycle, WebGlRenderStats, WebGlRendererState,
|
|
attach_webgl_context_lifecycle, canvas_render_plan, render_canvas_surface, render_webgl,
|
|
set_canvas_surface_mode, webgl_surface_mode,
|
|
};
|
|
use super::scrollback::ViewMode;
|
|
use super::selection::{Selection, SelectionRect, selection_rects};
|
|
|
|
const DEFAULT_CELL_WIDTH: f64 = 8.0;
|
|
const DEFAULT_CELL_HEIGHT: f64 = 18.0;
|
|
const DEFAULT_FONT_SIZE_PX: f64 = 13.0;
|
|
const DEFAULT_LINE_HEIGHT_PX: f64 = 18.0;
|
|
const MIN_COLS: u16 = 40;
|
|
const MIN_ROWS: u16 = 12;
|
|
const MEASURE_SAMPLE_TEXT: &str = "WWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWW";
|
|
const DEFAULT_TERMINAL_FONT_FAMILY: &str = "\"JetBrains Mono\", \"Fira Code\", \"Cascadia Code\", \"SFMono-Regular\", \"Consolas\", monospace";
|
|
|
|
/// Extra rows rendered above and below the visible window so that fast
|
|
/// scrolling doesn't flash blank areas before the next frame arrives.
|
|
const VIRTUAL_OVERSCAN: usize = 5;
|
|
|
|
/// `.terminal-screen` inner padding in px (CSS `padding: 16px`). Mouse
|
|
/// coordinates subtract this to map onto the cell grid.
|
|
const TERMINAL_PADDING: f64 = 16.0;
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
struct TerminalViewport {
|
|
cols: u16,
|
|
rows: u16,
|
|
pixel_width: u16,
|
|
pixel_height: u16,
|
|
}
|
|
|
|
impl Default for TerminalViewport {
|
|
fn default() -> Self {
|
|
Self {
|
|
cols: super::core::DEFAULT_COLS,
|
|
rows: super::core::DEFAULT_ROWS,
|
|
pixel_width: (f64::from(super::core::DEFAULT_COLS) * DEFAULT_CELL_WIDTH).round() as u16,
|
|
pixel_height: (f64::from(super::core::DEFAULT_ROWS) * DEFAULT_CELL_HEIGHT).round()
|
|
as u16,
|
|
}
|
|
}
|
|
}
|
|
|
|
struct ResizeObserverHandle {
|
|
observer: ResizeObserver,
|
|
_callback: Closure<dyn FnMut(Array, ResizeObserver)>,
|
|
}
|
|
|
|
impl Drop for ResizeObserverHandle {
|
|
fn drop(&mut self) {
|
|
self.observer.disconnect();
|
|
}
|
|
}
|
|
|
|
struct WebSocketHandle {
|
|
socket: Rc<WebSocket>,
|
|
_on_open: Closure<dyn Fn(Event)>,
|
|
_on_message: Closure<dyn Fn(MessageEvent)>,
|
|
_on_close: Closure<dyn Fn(Event)>,
|
|
_on_error: Closure<dyn Fn(ErrorEvent)>,
|
|
}
|
|
|
|
impl Drop for WebSocketHandle {
|
|
fn drop(&mut self) {
|
|
self.socket.set_onopen(None);
|
|
self.socket.set_onmessage(None);
|
|
self.socket.set_onclose(None);
|
|
self.socket.set_onerror(None);
|
|
let _ = self.socket.close();
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// TerminalPanel component
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Which renderer draws the terminal grid. Canvas is the default stable
|
|
/// performance path; WebGL and DOM stay available for regression comparison.
|
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
|
pub enum Renderer {
|
|
WebGl,
|
|
#[default]
|
|
Canvas,
|
|
Dom,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub struct TerminalTheme {
|
|
pub foreground: String,
|
|
pub background: String,
|
|
pub cursor_foreground: String,
|
|
pub cursor_background: String,
|
|
pub selection_background: String,
|
|
}
|
|
|
|
impl Default for TerminalTheme {
|
|
fn default() -> Self {
|
|
Self {
|
|
foreground: "#dbeafe".to_owned(),
|
|
background: "#0f172a".to_owned(),
|
|
cursor_foreground: "#0f172a".to_owned(),
|
|
cursor_background: "#dbeafe".to_owned(),
|
|
selection_background: "rgb(56 189 248 / 30%)".to_owned(),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub struct TerminalFontOptions {
|
|
pub family: String,
|
|
pub size_px: f64,
|
|
pub line_height_px: f64,
|
|
}
|
|
|
|
impl Default for TerminalFontOptions {
|
|
fn default() -> Self {
|
|
Self {
|
|
family: DEFAULT_TERMINAL_FONT_FAMILY.to_owned(),
|
|
size_px: DEFAULT_FONT_SIZE_PX,
|
|
line_height_px: DEFAULT_LINE_HEIGHT_PX,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub struct TerminalOptions {
|
|
pub cols: u16,
|
|
pub rows: u16,
|
|
pub min_cols: u16,
|
|
pub min_rows: u16,
|
|
pub scrollback: usize,
|
|
pub renderer: Renderer,
|
|
pub font: TerminalFontOptions,
|
|
pub theme: TerminalTheme,
|
|
pub cursor: TerminalCursorOptions,
|
|
pub title: String,
|
|
pub subtitle: String,
|
|
pub show_header: bool,
|
|
}
|
|
|
|
impl Default for TerminalOptions {
|
|
fn default() -> Self {
|
|
Self {
|
|
cols: super::core::DEFAULT_COLS,
|
|
rows: super::core::DEFAULT_ROWS,
|
|
min_cols: MIN_COLS,
|
|
min_rows: MIN_ROWS,
|
|
scrollback: super::core::DEFAULT_SCROLLBACK,
|
|
renderer: Renderer::Canvas,
|
|
font: TerminalFontOptions::default(),
|
|
theme: TerminalTheme::default(),
|
|
cursor: TerminalCursorOptions::default(),
|
|
title: "Rust Browser Terminal".to_owned(),
|
|
subtitle: "Axum + portable-pty backend, Leptos + Rust/WASM client.".to_owned(),
|
|
show_header: true,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
|
pub enum TerminalCursorStyle {
|
|
#[default]
|
|
Block,
|
|
Underline,
|
|
Bar,
|
|
}
|
|
|
|
impl TerminalCursorStyle {
|
|
fn css_value(self) -> &'static str {
|
|
match self {
|
|
Self::Block => "block",
|
|
Self::Underline => "underline",
|
|
Self::Bar => "bar",
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub struct TerminalCursorOptions {
|
|
pub style: TerminalCursorStyle,
|
|
pub blink: bool,
|
|
}
|
|
|
|
impl Default for TerminalCursorOptions {
|
|
fn default() -> Self {
|
|
Self {
|
|
style: TerminalCursorStyle::Block,
|
|
blink: false,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub struct TerminalResizeEvent {
|
|
pub cols: u16,
|
|
pub rows: u16,
|
|
pub pixel_width: u16,
|
|
pub pixel_height: u16,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub struct TerminalSelectionEvent {
|
|
pub start: Option<(usize, usize)>,
|
|
pub end: Option<(usize, usize)>,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
|
pub struct TerminalRenderEvent {
|
|
pub cols: u16,
|
|
pub rows: u16,
|
|
pub history_rows: usize,
|
|
pub screen_rows: usize,
|
|
pub mode: ViewMode,
|
|
pub renderer: Renderer,
|
|
pub webgl_stats: Option<WebGlRenderStats>,
|
|
}
|
|
|
|
type SerializeFn = Arc<dyn Fn() -> String + Send + Sync>;
|
|
|
|
#[derive(Clone, Copy)]
|
|
pub struct TerminalHandle {
|
|
input: RwSignal<Option<String>>,
|
|
clear_token: RwSignal<u64>,
|
|
focus_token: RwSignal<u64>,
|
|
resize: RwSignal<Option<(u16, u16)>>,
|
|
scroll_to_bottom_token: RwSignal<u64>,
|
|
copy_selection_token: RwSignal<u64>,
|
|
fit_token: RwSignal<u64>,
|
|
search_request: RwSignal<Option<TerminalSearchRequest>>,
|
|
serialize_text_fn: StoredValue<Option<SerializeFn>>,
|
|
serialize_ansi_fn: StoredValue<Option<SerializeFn>>,
|
|
}
|
|
|
|
impl TerminalHandle {
|
|
fn new() -> Self {
|
|
Self {
|
|
input: RwSignal::new(None),
|
|
clear_token: RwSignal::new(0),
|
|
focus_token: RwSignal::new(0),
|
|
resize: RwSignal::new(None),
|
|
scroll_to_bottom_token: RwSignal::new(0),
|
|
copy_selection_token: RwSignal::new(0),
|
|
fit_token: RwSignal::new(0),
|
|
search_request: RwSignal::new(None),
|
|
serialize_text_fn: StoredValue::new(None),
|
|
serialize_ansi_fn: StoredValue::new(None),
|
|
}
|
|
}
|
|
|
|
pub fn write(&self, data: impl Into<String>) {
|
|
self.input.set(Some(data.into()));
|
|
}
|
|
|
|
pub fn paste_text(&self, data: impl Into<String>) {
|
|
self.write(data);
|
|
}
|
|
|
|
pub fn clear(&self) {
|
|
self.clear_token.update(|token| *token += 1);
|
|
}
|
|
|
|
pub fn focus(&self) {
|
|
self.focus_token.update(|token| *token += 1);
|
|
}
|
|
|
|
pub fn resize(&self, rows: u16, cols: u16) {
|
|
self.resize.set(Some((rows, cols)));
|
|
}
|
|
|
|
pub fn scroll_to_bottom(&self) {
|
|
self.scroll_to_bottom_token.update(|token| *token += 1);
|
|
}
|
|
|
|
pub fn copy_selection(&self) {
|
|
self.copy_selection_token.update(|token| *token += 1);
|
|
}
|
|
|
|
pub fn fit(&self) {
|
|
self.fit_token.update(|token| *token += 1);
|
|
}
|
|
|
|
pub fn serialize_text(&self) -> String {
|
|
self.serialize_text_fn
|
|
.get_value()
|
|
.as_ref()
|
|
.map(|serialize| serialize())
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
pub fn serialize_ansi(&self) -> String {
|
|
self.serialize_ansi_fn
|
|
.get_value()
|
|
.as_ref()
|
|
.map(|serialize| serialize())
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
pub fn search_next(&self, query: impl Into<String>) {
|
|
self.search(query, SearchDirection::Forward);
|
|
}
|
|
|
|
pub fn search_previous(&self, query: impl Into<String>) {
|
|
self.search(query, SearchDirection::Backward);
|
|
}
|
|
|
|
pub fn clear_search(&self) {
|
|
self.search_request.set(Some(TerminalSearchRequest::Clear));
|
|
}
|
|
|
|
fn search(&self, query: impl Into<String>, direction: SearchDirection) {
|
|
self.search_request.set(Some(TerminalSearchRequest::Find {
|
|
query: query.into(),
|
|
direction,
|
|
}));
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
enum TerminalSearchRequest {
|
|
Find {
|
|
query: String,
|
|
direction: SearchDirection,
|
|
},
|
|
Clear,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub(super) struct ResolvedTerminalOptions {
|
|
cols: u16,
|
|
rows: u16,
|
|
min_cols: u16,
|
|
min_rows: u16,
|
|
scrollback: usize,
|
|
renderer: Renderer,
|
|
pub(super) font_family: String,
|
|
pub(super) font_size_px: f64,
|
|
line_height_px: f64,
|
|
pub(super) theme: TerminalTheme,
|
|
pub(super) cursor: TerminalCursorOptions,
|
|
title: String,
|
|
subtitle: String,
|
|
show_header: bool,
|
|
}
|
|
|
|
impl From<TerminalOptions> for ResolvedTerminalOptions {
|
|
fn from(options: TerminalOptions) -> Self {
|
|
let rows = options.rows.max(1);
|
|
let cols = options.cols.max(1);
|
|
Self {
|
|
cols,
|
|
rows,
|
|
min_cols: options.min_cols.max(1),
|
|
min_rows: options.min_rows.max(1),
|
|
scrollback: options.scrollback.max(rows as usize),
|
|
renderer: options.renderer,
|
|
font_family: if options.font.family.trim().is_empty() {
|
|
DEFAULT_TERMINAL_FONT_FAMILY.to_owned()
|
|
} else {
|
|
options.font.family
|
|
},
|
|
font_size_px: options.font.size_px.max(1.0),
|
|
line_height_px: options.font.line_height_px.max(1.0),
|
|
theme: options.theme,
|
|
cursor: options.cursor,
|
|
title: options.title,
|
|
subtitle: options.subtitle,
|
|
show_header: options.show_header,
|
|
}
|
|
}
|
|
}
|
|
|
|
fn terminal_options_style(options: &ResolvedTerminalOptions) -> String {
|
|
format!(
|
|
"--terminal-font-family:{};--terminal-font-size:{}px;--terminal-line-height:{}px;--terminal-fg:{};--terminal-bg:{};--terminal-cursor-fg:{};--terminal-cursor-bg:{};--terminal-selection-bg:{};--terminal-cursor-style:{};--terminal-cursor-blink:{};",
|
|
options.font_family,
|
|
options.font_size_px,
|
|
options.line_height_px,
|
|
options.theme.foreground,
|
|
options.theme.background,
|
|
options.theme.cursor_foreground,
|
|
options.theme.cursor_background,
|
|
options.theme.selection_background,
|
|
options.cursor.style.css_value(),
|
|
if options.cursor.blink {
|
|
"terminal-cursor-blink"
|
|
} else {
|
|
"none"
|
|
},
|
|
)
|
|
}
|
|
|
|
fn terminal_viewport_for_options(options: &ResolvedTerminalOptions) -> TerminalViewport {
|
|
TerminalViewport {
|
|
cols: options.cols,
|
|
rows: options.rows,
|
|
pixel_width: (f64::from(options.cols) * DEFAULT_CELL_WIDTH)
|
|
.round()
|
|
.max(1.0) as u16,
|
|
pixel_height: (f64::from(options.rows) * options.line_height_px)
|
|
.round()
|
|
.max(1.0) as u16,
|
|
}
|
|
}
|
|
|
|
fn terminal_viewport_for_size(rows: u16, cols: u16, cell_size: (f64, f64)) -> TerminalViewport {
|
|
TerminalViewport {
|
|
cols,
|
|
rows,
|
|
pixel_width: (f64::from(cols) * cell_size.0).round().max(1.0) as u16,
|
|
pixel_height: (f64::from(rows) * cell_size.1).round().max(1.0) as u16,
|
|
}
|
|
}
|
|
|
|
fn terminal_resize_event(viewport: TerminalViewport) -> TerminalResizeEvent {
|
|
TerminalResizeEvent {
|
|
cols: viewport.cols,
|
|
rows: viewport.rows,
|
|
pixel_width: viewport.pixel_width,
|
|
pixel_height: viewport.pixel_height,
|
|
}
|
|
}
|
|
|
|
fn renderer_uses_canvas_surface(renderer: Renderer) -> bool {
|
|
matches!(renderer, Renderer::WebGl | Renderer::Canvas)
|
|
}
|
|
|
|
#[component]
|
|
pub fn TerminalPanel(
|
|
#[prop(default = TerminalOptions::default().into(), into)] options: Signal<TerminalOptions>,
|
|
#[prop(optional)] renderer: Option<Renderer>,
|
|
#[prop(optional)] on_ready: Option<Callback<TerminalHandle>>,
|
|
#[prop(optional)] on_data: Option<Callback<String>>,
|
|
#[prop(optional)] on_resize: Option<Callback<TerminalResizeEvent>>,
|
|
#[prop(optional)] on_selection_change: Option<Callback<TerminalSelectionEvent>>,
|
|
#[prop(optional)] on_title_change: Option<Callback<String>>,
|
|
#[prop(optional)] on_render: Option<Callback<TerminalRenderEvent>>,
|
|
) -> impl IntoView {
|
|
let mut resolved_options = ResolvedTerminalOptions::from(options.get_untracked());
|
|
if let Some(renderer) = renderer {
|
|
resolved_options.renderer = renderer;
|
|
}
|
|
let options_signal = RwSignal::new(resolved_options.clone());
|
|
let handle = TerminalHandle::new();
|
|
let terminal_ref = NodeRef::<html::Div>::new();
|
|
let ime_ref = NodeRef::<html::Textarea>::new();
|
|
let canvas_ref = NodeRef::<html::Canvas>::new();
|
|
let canvas_fallback_ref = NodeRef::<html::Canvas>::new();
|
|
let measure_ref = NodeRef::<html::Span>::new();
|
|
let connection_status = RwSignal::new("Connecting".to_owned());
|
|
let ws_signal = RwSignal::new(None::<WebSocket>);
|
|
let current_viewport = RwSignal::new(terminal_viewport_for_options(&resolved_options));
|
|
let view_mode = RwSignal::new(ViewMode::Live);
|
|
// The scrollTop we last set programmatically. The scroll handler compares
|
|
// against it to robustly tell our own programmatic scrolls apart from real
|
|
// user scrolls (a single-shot "ignore next" flag was racy and caused an
|
|
// auto-pin ↔ user-scroll feedback loop / momentum bounce).
|
|
let programmatic_scroll_top = RwSignal::new(-1_i32);
|
|
|
|
// Terminal state lives in a reactive signal, but it is mutated via
|
|
// `update_untracked` so that feeding bytes never triggers a render on its
|
|
// own — rendering is driven explicitly by `render_tick` below. This
|
|
// decouples the (very fast, ~1µs/line) data-ingestion rate from the
|
|
// (expensive) DOM-render rate: a burst of hundreds of WebSocket packets
|
|
// from `cat`-ing a huge file collapses into a single render per frame.
|
|
let core_signal = RwSignal::new(TerminalCore::new_with_scrollback(
|
|
resolved_options.rows,
|
|
resolved_options.cols,
|
|
resolved_options.scrollback,
|
|
));
|
|
|
|
// Bumped at most once per animation frame to drive exactly one render.
|
|
let render_tick = RwSignal::new(0_u64);
|
|
|
|
// Current scrollTop in pixels — updated by the scroll handler so that
|
|
// the virtual-scroll render closure knows which window to materialise.
|
|
let scroll_top = RwSignal::new(0_i32);
|
|
|
|
// Measured cell size (width, height) in px, kept in sync with the viewport
|
|
// measurement. Used to map mouse coordinates onto the logical cell grid.
|
|
let cell_size = RwSignal::new((DEFAULT_CELL_WIDTH, resolved_options.line_height_px));
|
|
|
|
// Active text selection in buffer coords, and whether a drag is in progress.
|
|
let selection = RwSignal::new(None::<Selection>);
|
|
let selecting = RwSignal::new(false);
|
|
let search_match = RwSignal::new(None::<TerminalSearchMatch>);
|
|
let search_query = RwSignal::new(String::new());
|
|
let last_webgl_stats = RwSignal::new(None::<WebGlRenderStats>);
|
|
|
|
// IME composition state: when true, regular `input` events are ignored and
|
|
// the final composed string is sent on `compositionend`.
|
|
let composing = RwSignal::new(false);
|
|
|
|
// Mouse-reporting bookkeeping (only used while an app has enabled mouse
|
|
// reporting). `mouse_held_button` remembers which button is down so we can
|
|
// emit motion events and SGR-style releases; `mouse_last_cell` throttles
|
|
// motion reports to one per grid cell (not per pixel).
|
|
let mouse_held_button: Rc<std::cell::Cell<Option<u8>>> = Rc::new(std::cell::Cell::new(None));
|
|
let mouse_last_cell: Rc<std::cell::Cell<Option<(usize, usize)>>> =
|
|
Rc::new(std::cell::Cell::new(None));
|
|
let canvas_state: Rc<std::cell::RefCell<Option<CanvasDomState>>> =
|
|
Rc::new(std::cell::RefCell::new(None));
|
|
let canvas_fallback_state: Rc<std::cell::RefCell<Option<CanvasDomState>>> =
|
|
Rc::new(std::cell::RefCell::new(None));
|
|
let webgl_state: Rc<std::cell::RefCell<Option<WebGlRendererState>>> =
|
|
Rc::new(std::cell::RefCell::new(None));
|
|
let webgl_context_lost: Rc<std::cell::Cell<bool>> = Rc::new(std::cell::Cell::new(false));
|
|
// Accessibility: screen-reader buffer, system motion/contrast preferences.
|
|
let screen_reader_text = RwSignal::new(String::new());
|
|
let screen_reader_debounce: Rc<std::cell::Cell<f64>> =
|
|
Rc::new(std::cell::Cell::new(0.0));
|
|
let prefers_reduced_motion = RwSignal::new(false);
|
|
|
|
|
|
handle.serialize_text_fn.set_value(Some(Arc::new(move || {
|
|
core_signal
|
|
.try_with_untracked(|core| core.buffer_text())
|
|
.unwrap_or_default()
|
|
})));
|
|
handle.serialize_ansi_fn.set_value(Some(Arc::new(move || {
|
|
core_signal
|
|
.try_with_untracked(|core| core.buffer_ansi())
|
|
.unwrap_or_default()
|
|
})));
|
|
|
|
if let Some(on_ready) = on_ready {
|
|
on_ready.run(handle);
|
|
}
|
|
|
|
// -- IME cursor follower -------------------------------------------------
|
|
//
|
|
// The hidden IME textarea's position is bound reactively to the terminal
|
|
// cursor cell so the OS/browser IME candidate window appears in the right
|
|
// place. See the `style` prop on the textarea in the view below.
|
|
let ime_style = move || {
|
|
let _ = render_tick.get();
|
|
let Some((row, col)) = core_signal
|
|
.try_with_untracked(|core| {
|
|
if core.hide_cursor() {
|
|
None
|
|
} else {
|
|
Some(ime_cursor_cell(core))
|
|
}
|
|
})
|
|
.flatten()
|
|
else {
|
|
return String::new();
|
|
};
|
|
let (cell_w, cell_h) = cell_size.get();
|
|
let left = TERMINAL_PADDING + col as f64 * cell_w;
|
|
let top = TERMINAL_PADDING + row as f64 * cell_h;
|
|
format!("left:{left}px;top:{top}px;width:{cell_w}px;height:{cell_h}px;")
|
|
};
|
|
|
|
Effect::new(move |_| {
|
|
let next = ResolvedTerminalOptions::from(options.get());
|
|
let mut next = next;
|
|
if let Some(renderer) = renderer {
|
|
next.renderer = renderer;
|
|
}
|
|
if next == options_signal.get_untracked() {
|
|
return;
|
|
}
|
|
let old = options_signal.get_untracked();
|
|
options_signal.set(next.clone());
|
|
cell_size.set((DEFAULT_CELL_WIDTH, next.line_height_px));
|
|
if old.scrollback != next.scrollback || old.rows != next.rows || old.cols != next.cols {
|
|
core_signal.set(TerminalCore::new_with_scrollback(
|
|
next.rows,
|
|
next.cols,
|
|
next.scrollback,
|
|
));
|
|
current_viewport.set(terminal_viewport_for_options(&next));
|
|
}
|
|
render_tick.update(|t| *t += 1);
|
|
sync_scroll_to_bottom(terminal_ref, programmatic_scroll_top);
|
|
});
|
|
|
|
Effect::new(move |_| {
|
|
let Some(on_title_change) = on_title_change else {
|
|
return;
|
|
};
|
|
let _ = render_tick.get();
|
|
let title = core_signal
|
|
.try_with_untracked(|core| core.title().to_owned())
|
|
.unwrap_or_default();
|
|
on_title_change.run(title);
|
|
});
|
|
|
|
Effect::new(move |_| {
|
|
let Some(on_render) = on_render else {
|
|
return;
|
|
};
|
|
let _ = render_tick.get();
|
|
let viewport = current_viewport.get();
|
|
let mode = view_mode.get();
|
|
let renderer = options_signal.get_untracked().renderer;
|
|
if let Some(event) = core_signal.try_with_untracked(|core| TerminalRenderEvent {
|
|
cols: viewport.cols,
|
|
rows: viewport.rows,
|
|
history_rows: core.history_rows().len(),
|
|
screen_rows: core.screen_rows().len(),
|
|
mode,
|
|
renderer,
|
|
webgl_stats: last_webgl_stats.get_untracked(),
|
|
}) {
|
|
on_render.run(event);
|
|
}
|
|
});
|
|
|
|
let lifecycle_webgl_state = webgl_state.clone();
|
|
let lifecycle_context_lost = webgl_context_lost.clone();
|
|
Effect::new(move |prev: Option<Option<WebGlContextLifecycle>>| {
|
|
drop(prev);
|
|
|
|
let renderer = options_signal.get().renderer;
|
|
if renderer != Renderer::WebGl {
|
|
lifecycle_context_lost.set(false);
|
|
lifecycle_webgl_state.borrow_mut().take();
|
|
return None;
|
|
}
|
|
let canvas = canvas_ref.get()?;
|
|
let canvas_el = canvas.dyn_ref::<HtmlCanvasElement>()?;
|
|
|
|
attach_webgl_context_lifecycle(
|
|
canvas_el,
|
|
lifecycle_webgl_state.clone(),
|
|
lifecycle_context_lost.clone(),
|
|
move || {
|
|
let _ = render_tick.try_update(|t| *t += 1);
|
|
},
|
|
)
|
|
});
|
|
|
|
Effect::new(move |_| {
|
|
let Some(on_selection_change) = on_selection_change else {
|
|
return;
|
|
};
|
|
let event = match selection.get() {
|
|
Some(sel) if !sel.is_empty() => {
|
|
let (start, end) = sel.ordered();
|
|
TerminalSelectionEvent {
|
|
start: Some(start),
|
|
end: Some(end),
|
|
}
|
|
}
|
|
_ => TerminalSelectionEvent {
|
|
start: None,
|
|
end: None,
|
|
},
|
|
};
|
|
on_selection_change.run(event);
|
|
});
|
|
|
|
Effect::new(move |_| {
|
|
let Some(data) = handle.input.get() else {
|
|
return;
|
|
};
|
|
handle.input.set(None);
|
|
send_input_data(
|
|
data,
|
|
ws_signal,
|
|
view_mode,
|
|
terminal_ref,
|
|
programmatic_scroll_top,
|
|
on_data,
|
|
);
|
|
});
|
|
|
|
Effect::new(move |_| {
|
|
let token = handle.clear_token.get();
|
|
if token == 0 {
|
|
return;
|
|
}
|
|
core_signal.update_untracked(|core| core.clear());
|
|
render_tick.update(|t| *t += 1);
|
|
sync_scroll_to_bottom(terminal_ref, programmatic_scroll_top);
|
|
});
|
|
|
|
Effect::new(move |_| {
|
|
let token = handle.focus_token.get();
|
|
if token == 0 {
|
|
return;
|
|
}
|
|
focus_ime_textarea(&ime_ref);
|
|
});
|
|
|
|
Effect::new(move |_| {
|
|
let Some((rows, cols)) = handle.resize.get() else {
|
|
return;
|
|
};
|
|
handle.resize.set(None);
|
|
let viewport =
|
|
terminal_viewport_for_size(rows.max(1), cols.max(1), cell_size.get_untracked());
|
|
current_viewport.set(viewport);
|
|
core_signal.update_untracked(|core| core.resize(viewport.rows, viewport.cols));
|
|
render_tick.update(|t| *t += 1);
|
|
if let Some(socket) = ws_signal.get_untracked() {
|
|
send_resize_message(&socket, viewport);
|
|
}
|
|
if let Some(on_resize) = on_resize {
|
|
on_resize.run(terminal_resize_event(viewport));
|
|
}
|
|
sync_scroll_to_bottom(terminal_ref, programmatic_scroll_top);
|
|
});
|
|
|
|
Effect::new(move |_| {
|
|
let token = handle.scroll_to_bottom_token.get();
|
|
if token == 0 {
|
|
return;
|
|
}
|
|
view_mode.set(ViewMode::Live);
|
|
sync_scroll_to_bottom(terminal_ref, programmatic_scroll_top);
|
|
});
|
|
|
|
Effect::new(move |_| {
|
|
let token = handle.copy_selection_token.get();
|
|
if token == 0 {
|
|
return;
|
|
}
|
|
copy_current_selection(selection, core_signal);
|
|
});
|
|
|
|
Effect::new(move |_| {
|
|
let token = handle.fit_token.get();
|
|
if token == 0 {
|
|
return;
|
|
}
|
|
let options = options_signal.get_untracked();
|
|
let viewport = measure_terminal_viewport(terminal_ref, measure_ref, &options);
|
|
cell_size.set(measure_cell_size(measure_ref));
|
|
|
|
if viewport != current_viewport.get_untracked() {
|
|
current_viewport.set(viewport);
|
|
core_signal.update_untracked(|core| core.resize(viewport.rows, viewport.cols));
|
|
render_tick.update(|t| *t += 1);
|
|
if let Some(socket) = ws_signal.get_untracked() {
|
|
send_resize_message(&socket, viewport);
|
|
}
|
|
if let Some(on_resize) = on_resize {
|
|
on_resize.run(terminal_resize_event(viewport));
|
|
}
|
|
}
|
|
|
|
if view_mode.get_untracked().is_live() {
|
|
sync_scroll_to_bottom(terminal_ref, programmatic_scroll_top);
|
|
} else {
|
|
sync_scroll_to_position(
|
|
terminal_ref,
|
|
scroll_top.get_untracked(),
|
|
programmatic_scroll_top,
|
|
);
|
|
}
|
|
focus_ime_textarea(&ime_ref);
|
|
});
|
|
|
|
Effect::new(move |_| {
|
|
let Some(request) = handle.search_request.get() else {
|
|
return;
|
|
};
|
|
handle.search_request.set(None);
|
|
|
|
match request {
|
|
TerminalSearchRequest::Clear => {
|
|
search_query.set(String::new());
|
|
search_match.set(None);
|
|
}
|
|
TerminalSearchRequest::Find { query, direction } => {
|
|
let query = query.trim().to_owned();
|
|
if query.is_empty() {
|
|
search_query.set(String::new());
|
|
search_match.set(None);
|
|
return;
|
|
}
|
|
|
|
let from = if search_query.get_untracked() == query {
|
|
search_match.get_untracked().map(|mat| match direction {
|
|
SearchDirection::Forward => (mat.row, mat.end_col),
|
|
SearchDirection::Backward => (mat.row, mat.start_col),
|
|
})
|
|
} else {
|
|
None
|
|
};
|
|
|
|
let found = core_signal
|
|
.try_with_untracked(|core| core.search(&query, from, direction))
|
|
.flatten();
|
|
search_query.set(query);
|
|
search_match.set(found.clone());
|
|
|
|
if let Some(mat) = found {
|
|
let total = core_signal
|
|
.try_with_untracked(|core| core.total_rows())
|
|
.unwrap_or(0);
|
|
scroll_match_into_view(
|
|
&mat,
|
|
SearchScrollContext {
|
|
total_rows: total,
|
|
viewport_rows: current_viewport.get_untracked().rows as usize,
|
|
line_height_px: options_signal.get_untracked().line_height_px,
|
|
terminal_ref,
|
|
scroll_top,
|
|
view_mode,
|
|
programmatic_scroll_top,
|
|
},
|
|
);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
// -- GPU / Canvas renderer sizing & draw ---------------------------------
|
|
//
|
|
// When the canvas-backed renderers are active, this effect sizes the surface
|
|
// to the container (accounting for DPR) and redraws the visible window once
|
|
// per render tick.
|
|
Effect::new(move |_| {
|
|
let options = options_signal.get();
|
|
let renderer = options.renderer;
|
|
if !renderer_uses_canvas_surface(renderer) {
|
|
return;
|
|
}
|
|
let _ = render_tick.get();
|
|
let Some(canvas) = canvas_ref.get() else {
|
|
return;
|
|
};
|
|
let Some(fallback_canvas) = canvas_fallback_ref.get() else {
|
|
return;
|
|
};
|
|
let Some(element) = terminal_ref.get() else {
|
|
return;
|
|
};
|
|
|
|
let dpr = window().device_pixel_ratio();
|
|
let Some(element) = element.dyn_ref::<HtmlElement>() else {
|
|
return;
|
|
};
|
|
let (css_width, css_height) = terminal_content_viewport_size(element);
|
|
let Some(canvas_el) = canvas.dyn_ref::<HtmlCanvasElement>() else {
|
|
return;
|
|
};
|
|
let Some(fallback_canvas_el) = fallback_canvas.dyn_ref::<HtmlCanvasElement>() else {
|
|
return;
|
|
};
|
|
|
|
let is_live = view_mode.get().is_live();
|
|
let st = scroll_top.get();
|
|
let vp = current_viewport.get_untracked();
|
|
let vp_rows = vp.rows as usize;
|
|
let line_height_px = options.line_height_px;
|
|
let Some((total_px, offset_px, visible)) = core_signal
|
|
.try_with_untracked(|core| virtual_window(core, is_live, st, vp_rows, line_height_px))
|
|
else {
|
|
return;
|
|
};
|
|
let window_start = (offset_px / line_height_px).round() as usize;
|
|
let plan = canvas_render_plan(CanvasPlanInput {
|
|
css_width,
|
|
css_height,
|
|
dpr,
|
|
is_live,
|
|
scroll_top_px: st,
|
|
total_px,
|
|
offset_px,
|
|
vp_rows,
|
|
line_height_px,
|
|
});
|
|
let cursor_pos = core_signal
|
|
.try_with_untracked(|core| {
|
|
if core.hide_cursor() {
|
|
return None;
|
|
}
|
|
let (cursor_row, cursor_col) = core.cursor_position();
|
|
let cursor_buffer_row = if core.alternate_screen() {
|
|
cursor_row as usize
|
|
} else {
|
|
core.history_rows().len() + cursor_row as usize
|
|
};
|
|
if cursor_buffer_row < window_start
|
|
|| cursor_buffer_row >= window_start + visible.len()
|
|
{
|
|
return None;
|
|
}
|
|
Some((cursor_buffer_row - window_start, cursor_col as usize))
|
|
})
|
|
.flatten();
|
|
|
|
let frame = CanvasSurfaceFrame {
|
|
dom_state: plan.dom_state(),
|
|
dpr,
|
|
visible: &visible,
|
|
cell_size: cell_size.get_untracked(),
|
|
content_top_px: plan.content_top_px,
|
|
cursor_pos,
|
|
options: &options,
|
|
};
|
|
match renderer {
|
|
Renderer::WebGl => {
|
|
if let Ok(stats) = render_webgl(
|
|
canvas_el,
|
|
frame,
|
|
&canvas_state,
|
|
&webgl_state,
|
|
&webgl_context_lost,
|
|
) {
|
|
last_webgl_stats.set(Some(stats));
|
|
set_canvas_surface_mode(
|
|
canvas_el,
|
|
fallback_canvas_el,
|
|
webgl_surface_mode(true),
|
|
);
|
|
} else {
|
|
set_canvas_surface_mode(
|
|
canvas_el,
|
|
fallback_canvas_el,
|
|
webgl_surface_mode(false),
|
|
);
|
|
last_webgl_stats.set(None);
|
|
render_canvas_surface(RenderCanvasSurfaceRequest {
|
|
canvas_el: fallback_canvas_el,
|
|
frame,
|
|
canvas_state: &canvas_fallback_state,
|
|
});
|
|
}
|
|
}
|
|
Renderer::Canvas => {
|
|
last_webgl_stats.set(None);
|
|
set_canvas_surface_mode(canvas_el, fallback_canvas_el, CanvasSurfaceMode::WebGl);
|
|
render_canvas_surface(RenderCanvasSurfaceRequest {
|
|
canvas_el,
|
|
frame,
|
|
canvas_state: &canvas_state,
|
|
});
|
|
}
|
|
Renderer::Dom => {
|
|
last_webgl_stats.set(None);
|
|
}
|
|
}
|
|
});
|
|
|
|
// -- WebSocket lifecycle ------------------------------------------------
|
|
|
|
Effect::new(move |prev: Option<Option<WebSocketHandle>>| {
|
|
drop(prev);
|
|
|
|
let url = websocket_url();
|
|
let Ok(socket) = WebSocket::new(&url) else {
|
|
let _ = connection_status.try_set("Failed to connect".to_owned());
|
|
return None;
|
|
};
|
|
|
|
socket.set_binary_type(BinaryType::Arraybuffer);
|
|
let socket = Rc::new(socket);
|
|
|
|
// onopen
|
|
let open_socket = socket.clone();
|
|
let open_status = connection_status;
|
|
let open_ws = ws_signal;
|
|
let open_viewport = current_viewport;
|
|
let open_ref = terminal_ref;
|
|
let open_ime = ime_ref;
|
|
let open_measure = measure_ref;
|
|
let open_mode = view_mode;
|
|
let open_prog_scroll = programmatic_scroll_top;
|
|
let open_core = core_signal;
|
|
let open_tick = render_tick;
|
|
let on_open = Closure::<dyn Fn(Event)>::new(move |_| {
|
|
if open_status.try_set("Connected".to_owned()).is_some() {
|
|
return;
|
|
}
|
|
if open_ws.try_set(Some((*open_socket).clone())).is_some() {
|
|
return;
|
|
}
|
|
|
|
let Some(options) = options_signal.try_get_untracked() else {
|
|
return;
|
|
};
|
|
let viewport = measure_terminal_viewport(open_ref, open_measure, &options);
|
|
if open_viewport.try_set(viewport).is_some() {
|
|
return;
|
|
}
|
|
if cell_size.try_set(measure_cell_size(open_measure)).is_some() {
|
|
return;
|
|
}
|
|
|
|
if open_core
|
|
.try_update_untracked(|core| core.resize(viewport.rows, viewport.cols))
|
|
.is_none()
|
|
{
|
|
return;
|
|
}
|
|
if open_tick.try_update(|t| *t += 1).is_none() {
|
|
return;
|
|
}
|
|
if open_mode.try_set(ViewMode::Live).is_some() {
|
|
return;
|
|
}
|
|
|
|
send_resize_message(&open_socket, viewport);
|
|
if let Some(on_resize) = on_resize {
|
|
on_resize.run(terminal_resize_event(viewport));
|
|
}
|
|
sync_scroll_to_bottom(open_ref, open_prog_scroll);
|
|
focus_ime_textarea(&open_ime);
|
|
});
|
|
socket.set_onopen(Some(on_open.as_ref().unchecked_ref()));
|
|
|
|
// onmessage
|
|
let message_core = core_signal;
|
|
let message_status = connection_status;
|
|
let message_ref = terminal_ref;
|
|
let message_mode = view_mode;
|
|
let message_prog_scroll = programmatic_scroll_top;
|
|
let message_tick = render_tick;
|
|
// Coalescing guard: at most one animation frame is scheduled at a time.
|
|
// A burst of packets all land in `core` (untracked) but only trigger a
|
|
// single render on the next frame.
|
|
let raf_pending = Rc::new(std::cell::Cell::new(false));
|
|
let on_message = Closure::<dyn Fn(MessageEvent)>::new(move |event: MessageEvent| {
|
|
let Some(text) = event.data().as_string() else {
|
|
return;
|
|
};
|
|
|
|
let Ok(message) = serde_json::from_str::<ServerTerminalMessage>(&text) else {
|
|
let _ = message_status.try_set("Protocol error".to_owned());
|
|
return;
|
|
};
|
|
|
|
match message {
|
|
ServerTerminalMessage::Output { data } => {
|
|
// Feed bytes without triggering a render (untracked).
|
|
// `try_*` so a stale callback from a disposed mount
|
|
// no-ops instead of panicking.
|
|
if message_core
|
|
.try_update_untracked(|core| core.process(data.as_bytes()))
|
|
.is_none()
|
|
{
|
|
return;
|
|
}
|
|
|
|
// Schedule exactly one render for the next frame.
|
|
schedule_frame(
|
|
raf_pending.clone(),
|
|
message_tick,
|
|
message_mode,
|
|
message_ref,
|
|
message_prog_scroll,
|
|
);
|
|
}
|
|
ServerTerminalMessage::Exit { code } => {
|
|
let _ = message_status.try_set(match code {
|
|
Some(code) => format!("Exited ({code})"),
|
|
None => "Exited".to_owned(),
|
|
});
|
|
}
|
|
ServerTerminalMessage::Error { message } => {
|
|
let _ = message_status.try_set(format!("Error: {message}"));
|
|
}
|
|
ServerTerminalMessage::Pong => {}
|
|
}
|
|
});
|
|
socket.set_onmessage(Some(on_message.as_ref().unchecked_ref()));
|
|
|
|
// onclose
|
|
let close_status = connection_status;
|
|
let close_ws = ws_signal;
|
|
let on_close = Closure::<dyn Fn(Event)>::new(move |_| {
|
|
let _ = close_status.try_set("Disconnected".to_owned());
|
|
let _ = close_ws.try_set(None);
|
|
});
|
|
socket.set_onclose(Some(on_close.as_ref().unchecked_ref()));
|
|
|
|
// onerror
|
|
let error_status = connection_status;
|
|
let on_error = Closure::<dyn Fn(ErrorEvent)>::new(move |_| {
|
|
let _ = error_status.try_set("Connection error".to_owned());
|
|
});
|
|
socket.set_onerror(Some(on_error.as_ref().unchecked_ref()));
|
|
|
|
Some(WebSocketHandle {
|
|
socket,
|
|
_on_open: on_open,
|
|
_on_message: on_message,
|
|
_on_close: on_close,
|
|
_on_error: on_error,
|
|
})
|
|
});
|
|
|
|
// -- ResizeObserver -----------------------------------------------------
|
|
|
|
Effect::new(move |prev: Option<Option<ResizeObserverHandle>>| {
|
|
drop(prev);
|
|
|
|
let element = terminal_ref.get()?;
|
|
let socket = ws_signal.get()?;
|
|
|
|
let resize_socket = socket.clone();
|
|
let resize_viewport = current_viewport;
|
|
let resize_ref = terminal_ref;
|
|
let resize_measure = measure_ref;
|
|
let resize_mode = view_mode;
|
|
let resize_prog_scroll = programmatic_scroll_top;
|
|
let resize_core = core_signal;
|
|
let resize_st = scroll_top;
|
|
let resize_tick = render_tick;
|
|
let resize_ime = ime_ref;
|
|
let callback = Closure::<dyn FnMut(Array, ResizeObserver)>::new(
|
|
move |entries: Array, _observer: ResizeObserver| {
|
|
let Some(options) = options_signal.try_get_untracked() else {
|
|
return;
|
|
};
|
|
let Some(entry) = entries.get(0).dyn_into::<ResizeObserverEntry>().ok() else {
|
|
return;
|
|
};
|
|
let rect = entry.content_rect();
|
|
let viewport = measure_terminal_viewport_from_rect(
|
|
rect.width(),
|
|
rect.height(),
|
|
resize_measure,
|
|
&options,
|
|
);
|
|
|
|
let Some(current_viewport) = resize_viewport.try_get_untracked() else {
|
|
return;
|
|
};
|
|
if viewport != current_viewport {
|
|
if resize_viewport.try_set(viewport).is_some() {
|
|
return;
|
|
}
|
|
if cell_size
|
|
.try_set(measure_cell_size(resize_measure))
|
|
.is_some()
|
|
{
|
|
return;
|
|
}
|
|
|
|
let Some(mode_val) = resize_mode.try_get_untracked() else {
|
|
return;
|
|
};
|
|
let Some(st) = resize_st.try_get_untracked() else {
|
|
return;
|
|
};
|
|
|
|
if resize_core
|
|
.try_update_untracked(|core| {
|
|
core.resize(viewport.rows, viewport.cols);
|
|
})
|
|
.is_none()
|
|
{
|
|
return;
|
|
}
|
|
if resize_tick.try_update(|t| *t += 1).is_none() {
|
|
return;
|
|
}
|
|
|
|
if mode_val == ViewMode::Live {
|
|
sync_scroll_to_bottom(resize_ref, resize_prog_scroll);
|
|
} else {
|
|
sync_scroll_to_position(resize_ref, st, resize_prog_scroll);
|
|
}
|
|
|
|
send_resize_message(&resize_socket, viewport);
|
|
if let Some(on_resize) = on_resize {
|
|
on_resize.run(terminal_resize_event(viewport));
|
|
}
|
|
}
|
|
|
|
focus_ime_textarea(&resize_ime);
|
|
},
|
|
);
|
|
|
|
let Ok(observer) = ResizeObserver::new(callback.as_ref().unchecked_ref::<Function>())
|
|
else {
|
|
return None;
|
|
};
|
|
|
|
observer.observe(&element);
|
|
Some(ResizeObserverHandle {
|
|
observer,
|
|
_callback: callback,
|
|
})
|
|
});
|
|
|
|
// -- Keyboard input -----------------------------------------------------
|
|
|
|
let handle_keydown = {
|
|
let keydown_mode = view_mode;
|
|
let keydown_prog_scroll = programmatic_scroll_top;
|
|
let keydown_selection = selection;
|
|
let keydown_core = core_signal;
|
|
|
|
move |event: KeyboardEvent| {
|
|
// Ctrl-Shift-C copies the current selection. Handle this BEFORE the
|
|
// generic Ctrl+letter suppression so the browser clipboard gesture
|
|
// (and the async writeText call) is not interfered with.
|
|
if event.ctrl_key() && event.shift_key() && matches!(event.key().as_str(), "c" | "C") {
|
|
copy_current_selection(keydown_selection, keydown_core);
|
|
event.prevent_default();
|
|
return;
|
|
}
|
|
|
|
// Paste (Ctrl+V / Ctrl+Shift+V): read the system clipboard directly
|
|
// and send it as input. Some browsers do not fire `on:paste` on a
|
|
// non-contenteditable div, so relying on the ClipboardEvent alone is
|
|
// unreliable. The keydown itself is a user gesture, which allows
|
|
// `navigator.clipboard.readText()` without a permission prompt.
|
|
let is_paste_shortcut = event.ctrl_key()
|
|
&& !event.alt_key()
|
|
&& !event.meta_key()
|
|
&& matches!(event.key().as_str(), "v" | "V");
|
|
|
|
if is_paste_shortcut {
|
|
event.prevent_default();
|
|
if !keydown_mode.get_untracked().is_live() {
|
|
keydown_mode.set(ViewMode::Live);
|
|
sync_scroll_to_bottom(terminal_ref, keydown_prog_scroll);
|
|
}
|
|
|
|
let paste_ws = ws_signal;
|
|
let paste_core = keydown_core;
|
|
wasm_bindgen_futures::spawn_local(async move {
|
|
let clipboard = window().navigator().clipboard();
|
|
let promise = clipboard.read_text();
|
|
let Ok(value) = wasm_bindgen_futures::JsFuture::from(promise).await else {
|
|
return;
|
|
};
|
|
let Some(text) = value.as_string() else {
|
|
return;
|
|
};
|
|
if text.is_empty() {
|
|
return;
|
|
}
|
|
|
|
let bracketed =
|
|
paste_core.try_with_untracked(|core| core.bracketed_paste()) == Some(true);
|
|
let data = prepare_paste(&text, bracketed);
|
|
|
|
if let Some(socket) = paste_ws.get_untracked() {
|
|
send_input_message(&socket, data, on_data);
|
|
}
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Printable characters are handled by the IME textarea's `input` /
|
|
// `composition` events so that IME composition and dead keys work.
|
|
// Only special keys (arrows, Enter, Esc, Backspace, F-keys,
|
|
// Ctrl-combos, …) are processed here.
|
|
if is_regular_text_input(
|
|
&event.key(),
|
|
event.ctrl_key(),
|
|
event.alt_key(),
|
|
event.meta_key(),
|
|
) {
|
|
return;
|
|
}
|
|
|
|
// The terminal owns bare Ctrl+letter (no Shift/Alt/Meta) so the
|
|
// browser doesn't intercept common shortcuts (Ctrl-W close tab,
|
|
// Ctrl-T new tab, Ctrl-N new window, Ctrl-R reload, Ctrl-F find,
|
|
// …). Shifted combos are excluded so Ctrl-Shift-C copy still works.
|
|
let is_bare_ctrl_letter = event.ctrl_key()
|
|
&& !event.shift_key()
|
|
&& !event.alt_key()
|
|
&& !event.meta_key()
|
|
&& event.key().chars().count() == 1;
|
|
|
|
if is_bare_ctrl_letter {
|
|
// When there is an active selection, Ctrl+C copies instead of
|
|
// sending SIGINT, matching VS Code / Windows Terminal behaviour.
|
|
if matches!(event.key().as_str(), "c" | "C")
|
|
&& keydown_selection
|
|
.get_untracked()
|
|
.map(|s| !s.is_empty())
|
|
.unwrap_or(false)
|
|
{
|
|
copy_current_selection(keydown_selection, keydown_core);
|
|
event.prevent_default();
|
|
return;
|
|
}
|
|
event.prevent_default();
|
|
}
|
|
|
|
let Some(socket) = ws_signal.get_untracked() else {
|
|
return;
|
|
};
|
|
|
|
let app_cursor = keydown_core
|
|
.try_with_untracked(|c| c.application_cursor())
|
|
.unwrap_or(false);
|
|
let Some(data) = map_key_to_terminal_input(&event, app_cursor) else {
|
|
return;
|
|
};
|
|
|
|
// Typing replaces a selection, like a normal terminal.
|
|
keydown_selection.set(None);
|
|
|
|
if !keydown_mode.get_untracked().is_live() {
|
|
keydown_mode.set(ViewMode::Live);
|
|
sync_scroll_to_bottom(terminal_ref, keydown_prog_scroll);
|
|
}
|
|
|
|
event.prevent_default();
|
|
send_input_message(&socket, data, on_data);
|
|
}
|
|
};
|
|
|
|
// -- Scroll handler ------------------------------------------------------
|
|
|
|
let handle_scroll = {
|
|
let scroll_mode = view_mode;
|
|
let scroll_prog = programmatic_scroll_top;
|
|
|
|
move |_| {
|
|
let Some(element) = terminal_ref.get_untracked() else {
|
|
return;
|
|
};
|
|
|
|
let st = element.scroll_top();
|
|
|
|
// Distinguish our own programmatic scrolls from real user scrolls by
|
|
// position (robust against missed/duplicated scroll events that a
|
|
// single-shot ignore flag could not handle). If this position is
|
|
// the one we just set programmatically, treat it as an echo and do
|
|
// not touch the view mode — only record scroll_top for rendering.
|
|
let is_programmatic_echo = (st - scroll_prog.get_untracked()).abs() <= 2;
|
|
|
|
scroll_top.set(st);
|
|
|
|
if is_programmatic_echo {
|
|
return;
|
|
}
|
|
|
|
let mode = super::scrollback::view_mode_from_scroll(
|
|
st,
|
|
element.scroll_height(),
|
|
element.client_height(),
|
|
options_signal.get_untracked().line_height_px,
|
|
);
|
|
scroll_mode.set(mode);
|
|
}
|
|
};
|
|
|
|
// -- Wheel handler (alt-screen only) ------------------------------------
|
|
//
|
|
// The alternate screen (vim / less / man) has no scrollback, so native
|
|
// wheel scrolling does nothing useful there. Like a real terminal, we
|
|
// translate wheel ticks into arrow-key presses sent to the application so
|
|
// the wheel pages through it. On the main screen we do nothing and let the
|
|
// browser scroll natively (handled by `on:scroll`).
|
|
let handle_wheel = {
|
|
let wheel_core = core_signal;
|
|
// Accumulated wheel delta + mode across the current animation frame.
|
|
// Each wheel event adds its delta here; the rAF callback flushes
|
|
// the batch as a single arrow press. Without this, a single
|
|
// trackpad swipe fires many wheel events and the app scrolls many
|
|
// lines, which is jarring in less/vim.
|
|
let wheel_batch: Rc<std::cell::Cell<Option<(f64, u32)>>> =
|
|
Rc::new(std::cell::Cell::new(None));
|
|
let wheel_raf_pending: Rc<std::cell::Cell<bool>> = Rc::new(std::cell::Cell::new(false));
|
|
|
|
move |event: WheelEvent| {
|
|
// If the app enabled mouse reporting, a wheel tick is reported as a
|
|
// button press (xterm: 64=up, 65=down, 66=left, 67=right) instead of
|
|
// being translated to arrow keys.
|
|
let mouse_mode = wheel_core.try_with_untracked(|core| core.mouse_protocol_mode());
|
|
if !matches!(mouse_mode, Some(MouseProtocolMode::None) | None) {
|
|
event.prevent_default();
|
|
let Some(encoding) =
|
|
wheel_core.try_with_untracked(|core| core.mouse_protocol_encoding())
|
|
else {
|
|
return;
|
|
};
|
|
|
|
let button = if event.delta_y() < 0.0 {
|
|
64
|
|
} else if event.delta_y() > 0.0 {
|
|
65
|
|
} else if event.delta_x() < 0.0 {
|
|
66
|
|
} else if event.delta_x() > 0.0 {
|
|
67
|
|
} else {
|
|
return;
|
|
};
|
|
|
|
let Some(element) = terminal_ref.get_untracked() else {
|
|
return;
|
|
};
|
|
let rect = element.get_bounding_client_rect();
|
|
let local_x = f64::from(event.client_x()) - rect.left();
|
|
let local_y = f64::from(event.client_y()) - rect.top();
|
|
let (cell_w, cell_h) = cell_size.get_untracked();
|
|
let vp = current_viewport.get_untracked();
|
|
let (row, col) = pixel_to_grid(
|
|
local_x,
|
|
local_y,
|
|
cell_w,
|
|
cell_h,
|
|
vp.cols as usize,
|
|
vp.rows as usize,
|
|
TERMINAL_PADDING,
|
|
);
|
|
|
|
let button = with_mouse_modifiers(
|
|
button,
|
|
event.shift_key(),
|
|
event.alt_key(),
|
|
event.ctrl_key(),
|
|
);
|
|
let data = encode_mouse_report(button, MouseAction::Press, col, row, encoding);
|
|
if let Some(socket) = ws_signal.get_untracked() {
|
|
send_input_message(&socket, data, on_data);
|
|
}
|
|
return;
|
|
}
|
|
|
|
let in_alt =
|
|
wheel_core.try_with_untracked(|core| core.alternate_screen()) == Some(true);
|
|
if !in_alt {
|
|
return; // main screen → native scroll
|
|
}
|
|
event.prevent_default();
|
|
if event.delta_y() == 0.0 {
|
|
return;
|
|
}
|
|
|
|
// Accumulate (or reset if sign flipped, to avoid silent cancel).
|
|
let new = event.delta_y();
|
|
let mode = event.delta_mode();
|
|
match wheel_batch.get() {
|
|
None => wheel_batch.set(Some((new, mode))),
|
|
Some((cur, cur_mode)) if (cur.signum() * new.signum()) >= 0.0 => {
|
|
wheel_batch.set(Some((cur + new, cur_mode)));
|
|
}
|
|
Some(_) => wheel_batch.set(Some((new, mode))),
|
|
}
|
|
|
|
if wheel_raf_pending.get() {
|
|
return;
|
|
}
|
|
wheel_raf_pending.set(true);
|
|
|
|
// Clones for the one-shot rAF closure. The outer handler stays
|
|
// `Fn` because each clone is a new Rc handle; the clones are
|
|
// moved into the rAF closure.
|
|
let wheel_batch_for_raf = wheel_batch.clone();
|
|
let wheel_raf_pending_for_raf = wheel_raf_pending.clone();
|
|
|
|
let callback = Closure::once_into_js(move || {
|
|
wheel_raf_pending_for_raf.set(false);
|
|
let Some((sum, mode)) = wheel_batch_for_raf.take() else {
|
|
return;
|
|
};
|
|
if sum == 0.0 {
|
|
return;
|
|
}
|
|
let vp_rows = current_viewport.get_untracked().rows as usize;
|
|
let max_rows = vp_rows.max(1);
|
|
let rows = wheel_rows_for_delta(
|
|
sum,
|
|
mode,
|
|
max_rows,
|
|
options_signal.get_untracked().line_height_px,
|
|
);
|
|
if rows == 0 {
|
|
return;
|
|
}
|
|
let app_cursor = wheel_core
|
|
.try_with_untracked(|c| c.application_cursor())
|
|
.unwrap_or(false);
|
|
let final_byte = if sum < 0.0 { 'A' } else { 'B' };
|
|
let data = cursor_seq(final_byte, app_cursor).repeat(rows);
|
|
if let Some(socket) = ws_signal.get_untracked() {
|
|
send_input_message(&socket, data, on_data);
|
|
}
|
|
});
|
|
|
|
let _ = window().request_animation_frame(callback.as_ref().unchecked_ref());
|
|
}
|
|
};
|
|
|
|
// -- Paste handler ------------------------------------------------------
|
|
//
|
|
// Send clipboard text to the PTY. If the running application enabled
|
|
// bracketed paste (vim, many shells), the text is wrapped in
|
|
// ESC[200~ … ESC[201~ so the app can tell a paste from typed input (and
|
|
// skip e.g. auto-indenting every pasted line).
|
|
let handle_paste = {
|
|
let paste_mode = view_mode;
|
|
let paste_prog_scroll = programmatic_scroll_top;
|
|
let paste_core = core_signal;
|
|
|
|
move |event: ClipboardEvent| {
|
|
let Some(raw) = event
|
|
.clipboard_data()
|
|
.and_then(|dt| dt.get_data("text").ok())
|
|
else {
|
|
return;
|
|
};
|
|
if raw.is_empty() {
|
|
return;
|
|
}
|
|
|
|
let bracketed =
|
|
paste_core.try_with_untracked(|core| core.bracketed_paste()) == Some(true);
|
|
let data = prepare_paste(&raw, bracketed);
|
|
send_input_data(
|
|
data,
|
|
ws_signal,
|
|
paste_mode,
|
|
terminal_ref,
|
|
paste_prog_scroll,
|
|
on_data,
|
|
);
|
|
event.prevent_default();
|
|
}
|
|
};
|
|
|
|
// -- IME input ------------------------------------------------------------
|
|
//
|
|
// Regular characters and IME composition are handled through a hidden
|
|
// textarea that sits on top of the terminal cursor. Special keys
|
|
// (arrows, Enter, Esc, F-keys, Ctrl-combos) still go through keydown.
|
|
let handle_input = {
|
|
let input_mode = view_mode;
|
|
let input_prog_scroll = programmatic_scroll_top;
|
|
let input_composing = composing;
|
|
let input_ime = ime_ref;
|
|
move |event: Event| {
|
|
let Ok(_event) = event.dyn_into::<InputEvent>() else {
|
|
return;
|
|
};
|
|
if input_composing.get_untracked() {
|
|
return;
|
|
}
|
|
let Some(text) = ime_ref_text(&input_ime) else {
|
|
return;
|
|
};
|
|
if text.is_empty() {
|
|
return;
|
|
}
|
|
clear_ime_textarea(&input_ime);
|
|
send_input_data(
|
|
text,
|
|
ws_signal,
|
|
input_mode,
|
|
terminal_ref,
|
|
input_prog_scroll,
|
|
on_data,
|
|
);
|
|
}
|
|
};
|
|
|
|
let handle_composition_start = move |_: CompositionEvent| {
|
|
composing.set(true);
|
|
};
|
|
|
|
let handle_composition_end = {
|
|
let end_mode = view_mode;
|
|
let end_prog_scroll = programmatic_scroll_top;
|
|
let end_composing = composing;
|
|
let end_ime = ime_ref;
|
|
move |event: CompositionEvent| {
|
|
if !end_composing.get_untracked() {
|
|
clear_ime_textarea(&end_ime);
|
|
return;
|
|
}
|
|
end_composing.set(false);
|
|
if let Some(text) = event.data()
|
|
&& !text.is_empty()
|
|
{
|
|
send_input_data(
|
|
text,
|
|
ws_signal,
|
|
end_mode,
|
|
terminal_ref,
|
|
end_prog_scroll,
|
|
on_data,
|
|
);
|
|
}
|
|
clear_ime_textarea(&end_ime);
|
|
}
|
|
};
|
|
|
|
// -- Mouse selection ----------------------------------------------------
|
|
//
|
|
// Drag to select a range of terminal text. Selection is tracked in logical
|
|
// buffer coordinates (not DOM), so it survives re-renders and works across
|
|
// rows that have scrolled out of the rendered window.
|
|
let cell_from_event = move |event: &MouseEvent| -> Option<(usize, usize)> {
|
|
let element = terminal_ref.get_untracked()?;
|
|
let rect = element.get_bounding_client_rect();
|
|
let local_x = f64::from(event.client_x()) - rect.left();
|
|
let local_y = f64::from(event.client_y()) - rect.top();
|
|
let (cell_w, cell_h) = cell_size.get_untracked();
|
|
let cols = current_viewport.get_untracked().cols as usize;
|
|
let total = core_signal
|
|
.try_with_untracked(|core| core.total_rows())
|
|
.unwrap_or(0);
|
|
Some(point_to_cell(
|
|
local_x,
|
|
local_y,
|
|
f64::from(element.scroll_top()),
|
|
cell_w,
|
|
cell_h,
|
|
cols,
|
|
total,
|
|
))
|
|
};
|
|
|
|
// Grid coordinates for mouse *reporting* — relative to the visible screen
|
|
// (no scrollback offset), so the app gets the cell under the cursor on the
|
|
// grid it is currently drawing.
|
|
let grid_from_event = move |event: &MouseEvent| -> Option<(usize, usize)> {
|
|
let element = terminal_ref.get_untracked()?;
|
|
let rect = element.get_bounding_client_rect();
|
|
let local_x = f64::from(event.client_x()) - rect.left();
|
|
let local_y = f64::from(event.client_y()) - rect.top();
|
|
let (cell_w, cell_h) = cell_size.get_untracked();
|
|
let vp = current_viewport.get_untracked();
|
|
Some(pixel_to_grid(
|
|
local_x,
|
|
local_y,
|
|
cell_w,
|
|
cell_h,
|
|
vp.cols as usize,
|
|
vp.rows as usize,
|
|
TERMINAL_PADDING,
|
|
))
|
|
};
|
|
|
|
let handle_mousedown = {
|
|
let down_selection = selection;
|
|
let down_selecting = selecting;
|
|
let down_core = core_signal;
|
|
let down_held = mouse_held_button.clone();
|
|
let down_ws = ws_signal;
|
|
move |event: MouseEvent| {
|
|
// App mouse reporting takes precedence over local selection, unless
|
|
// the user holds Shift to force a selection (xterm behaviour).
|
|
if !event.shift_key() {
|
|
let mode_enc = down_core
|
|
.try_with_untracked(|c| (c.mouse_protocol_mode(), c.mouse_protocol_encoding()));
|
|
if let Some((mode, encoding)) = mode_enc
|
|
&& !matches!(mode, MouseProtocolMode::None)
|
|
{
|
|
event.prevent_default();
|
|
if let Some(base) = mouse_button_base(event.button()) {
|
|
let button = with_mouse_modifiers(
|
|
base,
|
|
event.shift_key(),
|
|
event.alt_key(),
|
|
event.ctrl_key(),
|
|
);
|
|
if let Some((row, col)) = grid_from_event(&event) {
|
|
down_held.set(Some(base));
|
|
let data =
|
|
encode_mouse_report(button, MouseAction::Press, col, row, encoding);
|
|
if let Some(socket) = down_ws.get_untracked() {
|
|
send_input_message(&socket, data, on_data);
|
|
}
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Local selection.
|
|
if event.button() != 0 {
|
|
return; // only left button starts a selection
|
|
}
|
|
let Some(cell) = cell_from_event(&event) else {
|
|
return;
|
|
};
|
|
down_selection.set(Some(Selection {
|
|
anchor: cell,
|
|
head: cell,
|
|
}));
|
|
down_selecting.set(true);
|
|
}
|
|
};
|
|
|
|
let handle_mousemove = {
|
|
let move_selection = selection;
|
|
let move_selecting = selecting;
|
|
let move_core = core_signal;
|
|
let move_held = mouse_held_button.clone();
|
|
let move_last = mouse_last_cell.clone();
|
|
let move_ws = ws_signal;
|
|
move |event: MouseEvent| {
|
|
// App mouse motion reporting.
|
|
if !event.shift_key() {
|
|
let mode_enc = move_core
|
|
.try_with_untracked(|c| (c.mouse_protocol_mode(), c.mouse_protocol_encoding()));
|
|
if let Some((mode, encoding)) = mode_enc {
|
|
let report = match mode {
|
|
MouseProtocolMode::ButtonMotion => move_held.get().is_some(),
|
|
MouseProtocolMode::AnyMotion => true,
|
|
_ => false,
|
|
};
|
|
if report {
|
|
event.prevent_default();
|
|
if let Some((row, col)) = grid_from_event(&event) {
|
|
// Throttle to one report per grid cell.
|
|
if move_last.get() != Some((row, col)) {
|
|
move_last.set(Some((row, col)));
|
|
let base = match move_held.get() {
|
|
Some(b) => 32 + b, // motion with button held
|
|
None => 35, // AnyMotion, no button
|
|
};
|
|
let button = with_mouse_modifiers(
|
|
base,
|
|
event.shift_key(),
|
|
event.alt_key(),
|
|
event.ctrl_key(),
|
|
);
|
|
let data = encode_mouse_report(
|
|
button,
|
|
MouseAction::Motion,
|
|
col,
|
|
row,
|
|
encoding,
|
|
);
|
|
if let Some(socket) = move_ws.get_untracked() {
|
|
send_input_message(&socket, data, on_data);
|
|
}
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Local selection drag.
|
|
if !move_selecting.get_untracked() {
|
|
return;
|
|
}
|
|
let Some(cell) = cell_from_event(&event) else {
|
|
return;
|
|
};
|
|
move_selection.update(|sel| {
|
|
if let Some(s) = sel {
|
|
s.head = cell;
|
|
}
|
|
});
|
|
}
|
|
};
|
|
|
|
let handle_mouseup = {
|
|
let up_selecting = selecting;
|
|
let up_core = core_signal;
|
|
let up_held = mouse_held_button.clone();
|
|
let up_last = mouse_last_cell.clone();
|
|
let up_ws = ws_signal;
|
|
move |event: MouseEvent| {
|
|
if !event.shift_key() {
|
|
let mode_enc = up_core
|
|
.try_with_untracked(|c| (c.mouse_protocol_mode(), c.mouse_protocol_encoding()));
|
|
if let Some((mode, encoding)) = mode_enc
|
|
&& !matches!(mode, MouseProtocolMode::None)
|
|
{
|
|
event.prevent_default();
|
|
// Press (X10) mode never reports releases; the others do.
|
|
if matches!(
|
|
mode,
|
|
MouseProtocolMode::PressRelease
|
|
| MouseProtocolMode::ButtonMotion
|
|
| MouseProtocolMode::AnyMotion
|
|
) && let Some((row, col)) = grid_from_event(&event)
|
|
{
|
|
// SGR encodes the actual button + 'm'; legacy uses
|
|
// button 3 (handled in encode_mouse_report).
|
|
let held = up_held.get().unwrap_or(0);
|
|
let button = with_mouse_modifiers(
|
|
held,
|
|
event.shift_key(),
|
|
event.alt_key(),
|
|
event.ctrl_key(),
|
|
);
|
|
let data =
|
|
encode_mouse_report(button, MouseAction::Release, col, row, encoding);
|
|
if let Some(socket) = up_ws.get_untracked() {
|
|
send_input_message(&socket, data, on_data);
|
|
}
|
|
}
|
|
up_held.set(None);
|
|
up_last.set(None);
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Local selection end.
|
|
up_selecting.set(false);
|
|
}
|
|
};
|
|
|
|
let handle_click = move |_| {
|
|
focus_ime_textarea(&ime_ref);
|
|
};
|
|
|
|
// -- Accessibility: screen-reader buffer ---------------------------------
|
|
//
|
|
// Extract visible terminal text and push it into an aria-live region so
|
|
// screen readers can announce new output. Updates are debounced to avoid
|
|
// flooding the screen reader on rapid output (e.g. cat of a large file).
|
|
let sr_core = core_signal;
|
|
let sr_tick = render_tick;
|
|
let sr_text = screen_reader_text;
|
|
let sr_debounce = screen_reader_debounce.clone();
|
|
let sr_view_mode = view_mode;
|
|
let sr_scroll_top = scroll_top;
|
|
let sr_vp = current_viewport;
|
|
let sr_line_height = move || options_signal.get().line_height_px;
|
|
Effect::new(move |_| {
|
|
let _ = sr_tick.get();
|
|
let now = window().performance().map_or(0.0, |p| p.now());
|
|
// Debounce: at most one aria-live update per 500 ms.
|
|
if now - sr_debounce.get() < 500.0 {
|
|
return;
|
|
}
|
|
sr_debounce.set(now);
|
|
|
|
let line_height_px = sr_line_height();
|
|
let is_live = sr_view_mode.get().is_live();
|
|
let st = sr_scroll_top.get();
|
|
let vp_rows = sr_vp.get().rows as usize;
|
|
let visible_text = sr_core
|
|
.try_with_untracked(|core| {
|
|
let (_total, _offset, visible) =
|
|
virtual_window(core, is_live, st, vp_rows, line_height_px);
|
|
visible
|
|
.iter()
|
|
.filter_map(|row| {
|
|
let text: String = row
|
|
.segments
|
|
.iter()
|
|
.map(|seg| seg.text.as_str())
|
|
.collect();
|
|
let trimmed = text.trim_end();
|
|
if trimmed.is_empty() {
|
|
None
|
|
} else {
|
|
Some(trimmed.to_owned())
|
|
}
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.join("\n")
|
|
})
|
|
.unwrap_or_default();
|
|
if !visible_text.is_empty() {
|
|
sr_text.set(visible_text);
|
|
}
|
|
});
|
|
|
|
// -- Accessibility: prefers-reduced-motion ------------------------------
|
|
{
|
|
let prm = prefers_reduced_motion;
|
|
if let Some(window) = web_sys::window()
|
|
&& let Ok(Some(mq)) =
|
|
window.match_media("(prefers-reduced-motion: reduce)")
|
|
{
|
|
let mq = Rc::new(mq);
|
|
prm.set(mq.matches());
|
|
let prm2 = prm;
|
|
let mq2 = mq.clone();
|
|
let mq3 = mq.clone();
|
|
let cb = Closure::<dyn Fn()>::new(move || {
|
|
prm2.set(mq2.matches());
|
|
});
|
|
let _ = mq3.add_event_listener_with_callback(
|
|
"change",
|
|
cb.as_ref().unchecked_ref(),
|
|
);
|
|
cb.forget();
|
|
}
|
|
}
|
|
|
|
// -- View ---------------------------------------------------------------
|
|
|
|
view! {
|
|
<section class="terminal-shell-card" style=move || {
|
|
let mut style = terminal_options_style(&options_signal.get());
|
|
if prefers_reduced_motion.get() {
|
|
style.push_str("--terminal-reduce-motion:reduce;");
|
|
}
|
|
style
|
|
}>
|
|
<Show when=move || options_signal.get().show_header>
|
|
<div class="terminal-header">
|
|
<div>
|
|
<h2 class="terminal-title">{move || options_signal.get().title}</h2>
|
|
<p class="terminal-subtitle">
|
|
{move || options_signal.get().subtitle}
|
|
</p>
|
|
</div>
|
|
<div class="terminal-status-stack">
|
|
<span class="terminal-badge">{move || connection_status.get()}</span>
|
|
<span class="terminal-metrics">
|
|
{move || {
|
|
let _ = render_tick.get();
|
|
// `try_*` so a stale render during hydration (when
|
|
// the signal may already be disposed) no-ops.
|
|
core_signal
|
|
.try_with_untracked(|core| {
|
|
let viewport = current_viewport.get();
|
|
let mode = view_mode.get();
|
|
format!(
|
|
"view {}x{} / term {}x{} / history {} / screen {} / mode {}",
|
|
viewport.cols,
|
|
viewport.rows,
|
|
core.size().1,
|
|
core.size().0,
|
|
core.history_rows().len(),
|
|
core.screen_rows().len(),
|
|
if mode.is_live() { "live" } else { "history" },
|
|
)
|
|
})
|
|
.unwrap_or_default()
|
|
}}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</Show>
|
|
|
|
<div
|
|
node_ref=terminal_ref
|
|
class="terminal-screen"
|
|
role="application"
|
|
aria-label="Terminal"
|
|
aria-roledescription="terminal emulator"
|
|
on:click=handle_click
|
|
on:scroll=handle_scroll
|
|
on:wheel=handle_wheel
|
|
on:mousedown=handle_mousedown
|
|
on:mousemove=handle_mousemove
|
|
on:mouseup=handle_mouseup
|
|
>
|
|
<textarea
|
|
node_ref=ime_ref
|
|
class="terminal-ime"
|
|
tabindex="0"
|
|
autocomplete="off"
|
|
autocapitalize="off"
|
|
spellcheck="false"
|
|
style=ime_style
|
|
on:keydown=handle_keydown
|
|
on:input=handle_input
|
|
on:compositionstart=handle_composition_start
|
|
on:compositionend=handle_composition_end
|
|
on:paste=handle_paste
|
|
></textarea>
|
|
<span node_ref=measure_ref class="terminal-measure" aria-hidden="true">
|
|
{MEASURE_SAMPLE_TEXT}
|
|
</span>
|
|
<div
|
|
class="terminal-screen-reader"
|
|
role="status"
|
|
aria-live="polite"
|
|
aria-atomic="false"
|
|
aria-label="Terminal output"
|
|
>
|
|
{move || screen_reader_text.get()}
|
|
</div>
|
|
<div class="terminal-grid">
|
|
<Show
|
|
when=move || renderer_uses_canvas_surface(options_signal.get().renderer)
|
|
fallback=move || {
|
|
// DOM renderer: same reactive closure as before.
|
|
let _ = render_tick.get();
|
|
let options = options_signal.get();
|
|
let is_live = view_mode.get().is_live();
|
|
let sel = selection.get();
|
|
let (cell_w, _cell_h) = cell_size.get();
|
|
let vp = current_viewport.get_untracked();
|
|
let vp_rows = vp.rows as usize;
|
|
let cols = vp.cols as usize;
|
|
let st = scroll_top.get();
|
|
let line_height_px = options.line_height_px;
|
|
let (total_px, offset_px, visible) = core_signal
|
|
.try_with_untracked(|core| {
|
|
virtual_window(core, is_live, st, vp_rows, line_height_px)
|
|
})
|
|
.unwrap_or((0.0, 0.0, Vec::new()));
|
|
let window_start =
|
|
(offset_px / line_height_px).round() as usize;
|
|
let window_end = window_start + visible.len();
|
|
let rects = selection_rects(
|
|
sel, window_start, window_end, cols, cell_w, line_height_px,
|
|
);
|
|
let search_rects = search_match_rects(
|
|
search_match.get(),
|
|
window_start,
|
|
window_end,
|
|
cell_w,
|
|
line_height_px,
|
|
);
|
|
let links = core_signal
|
|
.try_with_untracked(|core| core.links_in_window(window_start, window_end))
|
|
.unwrap_or_default();
|
|
let link_rects =
|
|
link_rects(links, window_start, cell_w, line_height_px);
|
|
render_spaced_view(
|
|
total_px,
|
|
offset_px,
|
|
visible,
|
|
rects,
|
|
search_rects,
|
|
link_rects,
|
|
)
|
|
}
|
|
>
|
|
<canvas
|
|
node_ref=canvas_ref
|
|
class="terminal-canvas"
|
|
></canvas>
|
|
<canvas
|
|
node_ref=canvas_fallback_ref
|
|
class="terminal-canvas"
|
|
hidden
|
|
></canvas>
|
|
{move || {
|
|
// Canvas-backed renderers: only the scroll sizer
|
|
// and selection overlay are DOM; text/backgrounds
|
|
// are drawn by the Effect above.
|
|
let _ = render_tick.get();
|
|
let options = options_signal.get();
|
|
let is_live = view_mode.get().is_live();
|
|
let sel = selection.get();
|
|
let (cell_w, _cell_h) = cell_size.get();
|
|
let vp = current_viewport.get_untracked();
|
|
let vp_rows = vp.rows as usize;
|
|
let cols = vp.cols as usize;
|
|
let st = scroll_top.get();
|
|
let line_height_px = options.line_height_px;
|
|
let (total_px, offset_px, visible) = core_signal
|
|
.try_with_untracked(|core| {
|
|
virtual_window(core, is_live, st, vp_rows, line_height_px)
|
|
})
|
|
.unwrap_or((0.0, 0.0, Vec::new()));
|
|
let window_start =
|
|
(offset_px / line_height_px).round() as usize;
|
|
let window_end = window_start + visible.len();
|
|
let rects = selection_rects(
|
|
sel, window_start, window_end, cols, cell_w, line_height_px,
|
|
);
|
|
let search_rects = search_match_rects(
|
|
search_match.get(),
|
|
window_start,
|
|
window_end,
|
|
cell_w,
|
|
line_height_px,
|
|
);
|
|
let links = core_signal
|
|
.try_with_untracked(|core| core.links_in_window(window_start, window_end))
|
|
.unwrap_or_default();
|
|
let link_rects =
|
|
link_rects(links, window_start, cell_w, line_height_px);
|
|
render_canvas_scroll_layers(
|
|
total_px,
|
|
offset_px,
|
|
rects,
|
|
search_rects,
|
|
link_rects,
|
|
)
|
|
}}
|
|
</Show>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Virtual-scroll rendering
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Compute the visible window slice. Returns owned data so the caller can
|
|
/// drop the `&TerminalCore` borrow before building the view.
|
|
///
|
|
/// In live mode the window is pinned to the **bottom** (the last `vp_rows`
|
|
/// rows) regardless of `scroll_top_px`, because the DOM scrollTop is driven to
|
|
/// the bottom separately and the rendered rows must line up there. In history
|
|
/// mode the window follows `scroll_top_px`.
|
|
fn virtual_window(
|
|
core: &TerminalCore,
|
|
is_live: bool,
|
|
scroll_top_px: i32,
|
|
vp_rows: usize,
|
|
line_height_px: f64,
|
|
) -> (f64, f64, Vec<TerminalRow>) {
|
|
let total = core.total_rows();
|
|
let is_alt = core.alternate_screen();
|
|
let line_height_px = line_height_px.max(1.0);
|
|
|
|
if is_alt || total == 0 {
|
|
// Alt-screen / empty: content fits the viewport; total height == rows.
|
|
let rows = core.screen_rows().to_vec();
|
|
let total_px = rows.len() as f64 * line_height_px;
|
|
return (total_px, 0.0, rows);
|
|
}
|
|
|
|
let first_visible = if is_live {
|
|
// Pin to the bottom: first visible row = total - viewport.
|
|
total.saturating_sub(vp_rows)
|
|
} else {
|
|
(f64::from(scroll_top_px.max(0)) / line_height_px).floor() as usize
|
|
};
|
|
|
|
let start = first_visible.saturating_sub(VIRTUAL_OVERSCAN);
|
|
let end = (first_visible + vp_rows + VIRTUAL_OVERSCAN).min(total);
|
|
|
|
// Absolute-positioning virtual scroll: the container is ALWAYS exactly
|
|
// `total_px` tall (so the scroll geometry never changes when the visible
|
|
// window is swapped), and the rendered rows are offset to `offset_px` from
|
|
// the top. Keeping the container height constant across re-renders is what
|
|
// prevents the browser from clamping scrollTop mid-scroll and firing the
|
|
// feedback scroll events that caused the "keeps drifting up" behaviour.
|
|
let total_px = total as f64 * line_height_px;
|
|
let offset_px = start as f64 * line_height_px;
|
|
(total_px, offset_px, core.collect_window(start, end))
|
|
}
|
|
|
|
// Render the visible rows using absolute positioning inside a fixed-height
|
|
// container.
|
|
//
|
|
/// The outer `.terminal-grid` is sized to the FULL content height
|
|
/// (`total_px`), constant across re-renders, so swapping the visible window
|
|
/// never changes the scroll container's geometry — the browser keeps the
|
|
/// scrollTop stable and fires no spurious scroll events. The visible rows are
|
|
/// placed in an absolutely-positioned layer offset by `offset_px`.
|
|
fn render_spaced_view(
|
|
total_px: f64,
|
|
offset_px: f64,
|
|
visible: Vec<TerminalRow>,
|
|
rects: Vec<SelectionRect>,
|
|
search_rects: Vec<SelectionRect>,
|
|
link_rects: Vec<TerminalLinkRect>,
|
|
) -> impl IntoView {
|
|
view! {
|
|
<div
|
|
class="terminal-virtual-sizer"
|
|
style=format!("height:{total_px}px;")
|
|
aria-hidden="true"
|
|
></div>
|
|
<div
|
|
class="terminal-virtual-rows"
|
|
style=format!("transform:translateY({offset_px}px);")
|
|
>
|
|
{search_rects.into_iter().map(render_search_rect).collect_view()}
|
|
{rects.into_iter().map(render_selection_rect).collect_view()}
|
|
{link_rects.into_iter().map(render_link_rect).collect_view()}
|
|
{visible.into_iter().map(render_terminal_row).collect_view()}
|
|
</div>
|
|
}
|
|
}
|
|
|
|
fn render_canvas_scroll_layers(
|
|
total_px: f64,
|
|
offset_px: f64,
|
|
rects: Vec<SelectionRect>,
|
|
search_rects: Vec<SelectionRect>,
|
|
link_rects: Vec<TerminalLinkRect>,
|
|
) -> impl IntoView {
|
|
view! {
|
|
<div
|
|
class="terminal-virtual-sizer"
|
|
style=format!("height:{total_px}px;")
|
|
aria-hidden="true"
|
|
></div>
|
|
<div
|
|
class="terminal-virtual-rows"
|
|
style=format!("transform:translateY({offset_px}px);")
|
|
>
|
|
{search_rects.into_iter().map(render_search_rect).collect_view()}
|
|
{rects.into_iter().map(render_selection_rect).collect_view()}
|
|
{link_rects.into_iter().map(render_link_rect).collect_view()}
|
|
</div>
|
|
}
|
|
}
|
|
|
|
fn render_selection_rect(rect: SelectionRect) -> impl IntoView {
|
|
let style = format!(
|
|
"top:{}px;left:{}px;width:{}px;height:{}px;",
|
|
rect.top_px, rect.left_px, rect.width_px, rect.height_px
|
|
);
|
|
view! { <div class="terminal-selection" style=style aria-hidden="true"></div> }
|
|
}
|
|
|
|
fn render_search_rect(rect: SelectionRect) -> impl IntoView {
|
|
let style = format!(
|
|
"top:{}px;left:{}px;width:{}px;height:{}px;",
|
|
rect.top_px, rect.left_px, rect.width_px, rect.height_px
|
|
);
|
|
view! { <div class="terminal-search-match" style=style aria-hidden="true"></div> }
|
|
}
|
|
|
|
fn render_link_rect(rect: TerminalLinkRect) -> impl IntoView {
|
|
let url = rect.url;
|
|
let label = url.clone();
|
|
let style = format!(
|
|
"top:{}px;left:{}px;width:{}px;height:{}px;",
|
|
rect.rect.top_px, rect.rect.left_px, rect.rect.width_px, rect.rect.height_px
|
|
);
|
|
view! {
|
|
<button
|
|
type="button"
|
|
class="terminal-link-hitbox"
|
|
style=style
|
|
title=label
|
|
on:mousedown=|event| event.stop_propagation()
|
|
on:click=move |event| {
|
|
event.prevent_default();
|
|
event.stop_propagation();
|
|
let _ = window().open_with_url_and_target(&url, "_blank");
|
|
}
|
|
></button>
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
struct TerminalLinkRect {
|
|
rect: SelectionRect,
|
|
url: String,
|
|
}
|
|
|
|
fn search_match_rects(
|
|
search_match: Option<TerminalSearchMatch>,
|
|
window_start: usize,
|
|
window_end: usize,
|
|
cell_w: f64,
|
|
line_height_px: f64,
|
|
) -> Vec<SelectionRect> {
|
|
let Some(mat) = search_match else {
|
|
return Vec::new();
|
|
};
|
|
if mat.row < window_start || mat.row >= window_end || mat.end_col <= mat.start_col {
|
|
return Vec::new();
|
|
}
|
|
|
|
vec![SelectionRect {
|
|
top_px: (mat.row - window_start) as f64 * line_height_px.max(1.0),
|
|
left_px: mat.start_col as f64 * cell_w,
|
|
width_px: (mat.end_col - mat.start_col) as f64 * cell_w,
|
|
height_px: line_height_px.max(1.0),
|
|
}]
|
|
}
|
|
|
|
fn link_rects(
|
|
links: Vec<TerminalLink>,
|
|
window_start: usize,
|
|
cell_w: f64,
|
|
line_height_px: f64,
|
|
) -> Vec<TerminalLinkRect> {
|
|
links
|
|
.into_iter()
|
|
.filter(|link| link.end_col > link.start_col)
|
|
.map(|link| TerminalLinkRect {
|
|
rect: SelectionRect {
|
|
top_px: (link.row - window_start) as f64 * line_height_px.max(1.0),
|
|
left_px: link.start_col as f64 * cell_w,
|
|
width_px: (link.end_col - link.start_col) as f64 * cell_w,
|
|
height_px: line_height_px.max(1.0),
|
|
},
|
|
url: link.url,
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn render_terminal_row(row: TerminalRow) -> impl IntoView {
|
|
view! {
|
|
<div class="terminal-row">
|
|
{row
|
|
.segments
|
|
.into_iter()
|
|
.map(render_terminal_segment)
|
|
.collect_view()}
|
|
</div>
|
|
}
|
|
}
|
|
|
|
fn render_terminal_segment(segment: TerminalSegment) -> impl IntoView {
|
|
// Lock the segment to an exact `cols`-cell grid width. `ch` is the advance
|
|
// of "0" in the monospace font (== the cell width), so glyphs pulled from a
|
|
// fallback font with a different advance (e.g. braille sparkline chars in
|
|
// `btm`) are clipped to the grid instead of pushing the row sideways.
|
|
let style = format!(
|
|
"width:{}ch;color:{};background:{};font-weight:{};font-style:{};text-decoration:{};",
|
|
segment.cols,
|
|
segment.style.color,
|
|
segment.style.background,
|
|
if segment.style.bold { "700" } else { "400" },
|
|
if segment.style.italic {
|
|
"italic"
|
|
} else {
|
|
"normal"
|
|
},
|
|
if segment.style.underline {
|
|
"underline"
|
|
} else {
|
|
"none"
|
|
},
|
|
);
|
|
|
|
view! {
|
|
<span
|
|
class="terminal-segment"
|
|
class:terminal-cursor=segment.is_cursor
|
|
style=style
|
|
>
|
|
{segment.text}
|
|
</span>
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Frame-coalesced render scheduling
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Schedule a single render for the next animation frame.
|
|
///
|
|
/// While `raf_pending` is set, additional calls are no-ops, so any number of
|
|
/// WebSocket packets that arrive within the same frame collapse into one
|
|
/// render. On the frame, `render_tick` is bumped (triggering exactly one DOM
|
|
/// render) and, if the view is in live mode, the viewport is scrolled to the
|
|
/// bottom — once, instead of once per packet.
|
|
fn schedule_frame(
|
|
raf_pending: Rc<std::cell::Cell<bool>>,
|
|
render_tick: RwSignal<u64>,
|
|
view_mode: RwSignal<ViewMode>,
|
|
terminal_ref: NodeRef<html::Div>,
|
|
programmatic_scroll_top: RwSignal<i32>,
|
|
) {
|
|
if raf_pending.get() {
|
|
return;
|
|
}
|
|
raf_pending.set(true);
|
|
|
|
let callback = Closure::once_into_js(move || {
|
|
raf_pending.set(false);
|
|
// One reactive render for everything ingested since the last frame.
|
|
// `try_*` so a frame that fires after the component is disposed
|
|
// no-ops instead of panicking.
|
|
if render_tick.try_update(|t| *t += 1).is_none() {
|
|
return;
|
|
}
|
|
|
|
// Follow new output only while the view is in live mode. The scroll
|
|
// handler flips to History the instant the user scrolls up (and the
|
|
// position-based echo detection keeps our own programmatic scrolls from
|
|
// being mistaken for user scrolls), so this never fights the user.
|
|
if view_mode.try_get_untracked().map(|m| m.is_live()) == Some(true) {
|
|
sync_scroll_to_bottom(terminal_ref, programmatic_scroll_top);
|
|
}
|
|
});
|
|
|
|
let _ = window().request_animation_frame(callback.as_ref().unchecked_ref());
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Scroll synchronisation helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Pin the viewport to the bottom. Idempotent: if already at the bottom it
|
|
/// writes nothing (so it can't interrupt the user's momentum scrolling). The
|
|
/// target position is recorded in `programmatic_scroll_top` so the scroll
|
|
/// handler can recognise the resulting scroll event as our own.
|
|
fn sync_scroll_to_bottom(terminal_ref: NodeRef<html::Div>, programmatic_scroll_top: RwSignal<i32>) {
|
|
let callback = Closure::<dyn FnMut()>::wrap(Box::new(move || {
|
|
let Some(element) = terminal_ref.get_untracked() else {
|
|
return;
|
|
};
|
|
let max_scroll = (element.scroll_height() - element.client_height()).max(0);
|
|
let _ = programmatic_scroll_top.try_set(max_scroll);
|
|
if (element.scroll_top() - max_scroll).abs() > 1 {
|
|
element.set_scroll_top(max_scroll);
|
|
}
|
|
}));
|
|
let _ = window().set_timeout_with_callback_and_timeout_and_arguments_0(
|
|
callback.as_ref().unchecked_ref(),
|
|
0,
|
|
);
|
|
callback.forget();
|
|
}
|
|
|
|
/// Scroll to an arbitrary pixel position (used after resize in history mode),
|
|
/// recording it as a programmatic scroll.
|
|
fn sync_scroll_to_position(
|
|
terminal_ref: NodeRef<html::Div>,
|
|
target_scroll_top: i32,
|
|
programmatic_scroll_top: RwSignal<i32>,
|
|
) {
|
|
let callback = Closure::<dyn FnMut()>::wrap(Box::new(move || {
|
|
let Some(element) = terminal_ref.get_untracked() else {
|
|
return;
|
|
};
|
|
let max_scroll = (element.scroll_height() - element.client_height()).max(0);
|
|
let target = target_scroll_top.min(max_scroll);
|
|
let _ = programmatic_scroll_top.try_set(target);
|
|
if (element.scroll_top() - target).abs() > 1 {
|
|
element.set_scroll_top(target);
|
|
}
|
|
}));
|
|
let _ = window().set_timeout_with_callback_and_timeout_and_arguments_0(
|
|
callback.as_ref().unchecked_ref(),
|
|
0,
|
|
);
|
|
callback.forget();
|
|
}
|
|
|
|
#[derive(Clone, Copy)]
|
|
struct SearchScrollContext {
|
|
total_rows: usize,
|
|
viewport_rows: usize,
|
|
line_height_px: f64,
|
|
terminal_ref: NodeRef<html::Div>,
|
|
scroll_top: RwSignal<i32>,
|
|
view_mode: RwSignal<ViewMode>,
|
|
programmatic_scroll_top: RwSignal<i32>,
|
|
}
|
|
|
|
fn scroll_match_into_view(mat: &TerminalSearchMatch, ctx: SearchScrollContext) {
|
|
if ctx.total_rows == 0 {
|
|
return;
|
|
}
|
|
|
|
let viewport_rows = ctx.viewport_rows.max(1);
|
|
let half_view = viewport_rows / 2;
|
|
let first_row = mat.row.saturating_sub(half_view);
|
|
let max_first_row = ctx.total_rows.saturating_sub(viewport_rows);
|
|
let first_row = first_row.min(max_first_row);
|
|
let target = (first_row as f64 * ctx.line_height_px.max(1.0)).round() as i32;
|
|
|
|
ctx.view_mode.set(if first_row >= max_first_row {
|
|
ViewMode::Live
|
|
} else {
|
|
ViewMode::History {
|
|
scroll_offset: first_row,
|
|
}
|
|
});
|
|
ctx.scroll_top.set(target);
|
|
sync_scroll_to_position(ctx.terminal_ref, target, ctx.programmatic_scroll_top);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Network helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
fn websocket_url() -> String {
|
|
let window = window();
|
|
let location = window.location();
|
|
let protocol = location.protocol().unwrap_or_else(|_| "http:".to_owned());
|
|
let host = location
|
|
.host()
|
|
.unwrap_or_else(|_| "127.0.0.1:3100".to_owned());
|
|
let scheme = if protocol == "https:" { "wss" } else { "ws" };
|
|
format!("{scheme}://{host}{}/terminal/ws", SiteConfig::BASE_PATH)
|
|
}
|
|
|
|
fn send_terminal_message(socket: &WebSocket, message: &ClientTerminalMessage) {
|
|
if let Ok(payload) = serde_json::to_string(message) {
|
|
let _ = socket.send_with_str(&payload);
|
|
}
|
|
}
|
|
|
|
fn send_input_message(socket: &WebSocket, data: String, on_data: Option<Callback<String>>) {
|
|
if data.is_empty() {
|
|
return;
|
|
}
|
|
if let Some(on_data) = on_data {
|
|
on_data.run(data.clone());
|
|
}
|
|
send_terminal_message(socket, &ClientTerminalMessage::Input { data });
|
|
}
|
|
|
|
fn send_resize_message(socket: &WebSocket, viewport: TerminalViewport) {
|
|
send_terminal_message(
|
|
socket,
|
|
&ClientTerminalMessage::Resize {
|
|
cols: viewport.cols,
|
|
rows: viewport.rows,
|
|
pixel_width: viewport.pixel_width,
|
|
pixel_height: viewport.pixel_height,
|
|
},
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Measurement helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
fn measure_terminal_viewport(
|
|
terminal_ref: NodeRef<html::Div>,
|
|
measure_ref: NodeRef<html::Span>,
|
|
options: &ResolvedTerminalOptions,
|
|
) -> TerminalViewport {
|
|
let Some(element) = terminal_ref.get_untracked() else {
|
|
return terminal_viewport_for_options(options);
|
|
};
|
|
let Some(element) = element.dyn_ref::<HtmlElement>() else {
|
|
return terminal_viewport_for_options(options);
|
|
};
|
|
|
|
let (width, height) = terminal_content_viewport_size(element);
|
|
measure_terminal_viewport_from_rect(f64::from(width), f64::from(height), measure_ref, options)
|
|
}
|
|
|
|
fn measure_terminal_viewport_from_rect(
|
|
width: f64,
|
|
height: f64,
|
|
measure_ref: NodeRef<html::Span>,
|
|
options: &ResolvedTerminalOptions,
|
|
) -> TerminalViewport {
|
|
let (cell_width, cell_height) = measure_cell_size(measure_ref);
|
|
let safe_width = (width - 2.0).max(cell_width);
|
|
let safe_height = (height - 2.0).max(cell_height);
|
|
let cols = ((safe_width / cell_width).floor().max(1.0) as u16).max(options.min_cols);
|
|
let rows = ((safe_height / cell_height).floor().max(1.0) as u16).max(options.min_rows);
|
|
|
|
TerminalViewport {
|
|
cols,
|
|
rows,
|
|
pixel_width: safe_width.floor().max(1.0) as u16,
|
|
pixel_height: safe_height.floor().max(1.0) as u16,
|
|
}
|
|
}
|
|
|
|
fn measure_cell_size(measure_ref: NodeRef<html::Span>) -> (f64, f64) {
|
|
let Some(element) = measure_ref.get_untracked() else {
|
|
return (DEFAULT_CELL_WIDTH, DEFAULT_CELL_HEIGHT);
|
|
};
|
|
let Some(element) = element.dyn_ref::<HtmlElement>() else {
|
|
return (DEFAULT_CELL_WIDTH, DEFAULT_CELL_HEIGHT);
|
|
};
|
|
|
|
let width = f64::from(element.offset_width().max(0));
|
|
let height = f64::from(element.offset_height().max(0));
|
|
|
|
if width <= 0.0 || height <= 0.0 {
|
|
return (DEFAULT_CELL_WIDTH, DEFAULT_CELL_HEIGHT);
|
|
}
|
|
|
|
(width / (MEASURE_SAMPLE_TEXT.chars().count() as f64), height)
|
|
}
|
|
|
|
fn terminal_content_viewport_size(element: &HtmlElement) -> (i32, i32) {
|
|
let padding = (TERMINAL_PADDING * 2.0).round() as i32;
|
|
(
|
|
(element.client_width() - padding).max(1),
|
|
(element.client_height() - padding).max(1),
|
|
)
|
|
}
|
|
|
|
/// Map a pointer position to a logical `(row, col)` cell.
|
|
///
|
|
/// `local_x`/`local_y` are relative to the scroll container's top-left
|
|
/// (clientX/Y minus its bounding rect). Padding is removed, the scroll offset
|
|
/// is added to Y, then the result is divided by the cell size and clamped to
|
|
/// the grid. Pure for unit-testing.
|
|
fn point_to_cell(
|
|
local_x: f64,
|
|
local_y: f64,
|
|
scroll_top: f64,
|
|
cell_w: f64,
|
|
cell_h: f64,
|
|
cols: usize,
|
|
total_rows: usize,
|
|
) -> (usize, usize) {
|
|
let x = (local_x - TERMINAL_PADDING).max(0.0);
|
|
let y = (local_y - TERMINAL_PADDING + scroll_top).max(0.0);
|
|
let col = (x / cell_w.max(1.0)).floor() as usize;
|
|
let row = (y / cell_h.max(1.0)).floor() as usize;
|
|
(
|
|
row.min(total_rows.saturating_sub(1)),
|
|
col.min(cols.saturating_sub(1)),
|
|
)
|
|
}
|
|
|
|
/// Write text to the system clipboard (fire-and-forget). Called from a user
|
|
/// gesture (Ctrl-Shift-C keydown), which browsers allow without a prompt in
|
|
/// secure contexts (localhost counts as secure).
|
|
fn copy_to_clipboard(text: &str) {
|
|
if text.is_empty() {
|
|
return;
|
|
}
|
|
let clipboard = window().navigator().clipboard();
|
|
let _ = clipboard.write_text(text);
|
|
}
|
|
|
|
/// Copy the current text selection to the system clipboard, if any.
|
|
fn copy_current_selection(selection: RwSignal<Option<Selection>>, core: RwSignal<TerminalCore>) {
|
|
if let Some(sel) = selection.get_untracked().filter(|s| !s.is_empty()) {
|
|
let (start, end) = sel.ordered();
|
|
let text = core
|
|
.try_with_untracked(|c| c.selection_text(start, end))
|
|
.unwrap_or_default();
|
|
copy_to_clipboard(&text);
|
|
}
|
|
}
|
|
|
|
/// Send a string as terminal input, scrolling to the bottom if needed.
|
|
fn send_input_data(
|
|
data: String,
|
|
socket_signal: RwSignal<Option<WebSocket>>,
|
|
mode_signal: RwSignal<ViewMode>,
|
|
terminal_ref: NodeRef<html::Div>,
|
|
prog_scroll: RwSignal<i32>,
|
|
on_data: Option<Callback<String>>,
|
|
) {
|
|
if data.is_empty() {
|
|
return;
|
|
}
|
|
let Some(socket) = socket_signal.get_untracked() else {
|
|
return;
|
|
};
|
|
if !mode_signal.get_untracked().is_live() {
|
|
mode_signal.set(ViewMode::Live);
|
|
sync_scroll_to_bottom(terminal_ref, prog_scroll);
|
|
}
|
|
send_input_message(&socket, data, on_data);
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::terminal::core::{MouseProtocolEncoding, TerminalCore};
|
|
use crate::terminal::keyboard::key_to_bytes;
|
|
|
|
fn core_with_lines(n: usize) -> TerminalCore {
|
|
let mut core = TerminalCore::new(24, 80);
|
|
for i in 1..=n {
|
|
core.process(format!("line{i}\r\n").as_bytes());
|
|
}
|
|
core
|
|
}
|
|
|
|
fn default_options() -> ResolvedTerminalOptions {
|
|
ResolvedTerminalOptions::from(TerminalOptions::default())
|
|
}
|
|
|
|
#[test]
|
|
fn wheel_rows_for_delta_pixel_line_page() {
|
|
// Per-event cap (12) — the old per-event contract.
|
|
const MAX: usize = 12;
|
|
// Pixel mode: one notch (~one line height) → at least 1 row.
|
|
assert_eq!(
|
|
wheel_rows_for_delta(DEFAULT_LINE_HEIGHT_PX, 0, MAX, DEFAULT_LINE_HEIGHT_PX),
|
|
1
|
|
);
|
|
// A few lines of pixels → that many rows.
|
|
assert_eq!(
|
|
wheel_rows_for_delta(DEFAULT_LINE_HEIGHT_PX * 3.0, 0, MAX, DEFAULT_LINE_HEIGHT_PX),
|
|
3
|
|
);
|
|
// Tiny pixel delta still scrolls at least one row.
|
|
assert_eq!(wheel_rows_for_delta(1.0, 0, MAX, DEFAULT_LINE_HEIGHT_PX), 1);
|
|
// Line mode: delta is already in lines.
|
|
assert_eq!(wheel_rows_for_delta(3.0, 1, MAX, DEFAULT_LINE_HEIGHT_PX), 3);
|
|
// Page mode scrolls a chunk (clamped to the max).
|
|
assert_eq!(wheel_rows_for_delta(1.0, 2, MAX, DEFAULT_LINE_HEIGHT_PX), 8);
|
|
// Sign is irrelevant here (direction handled separately); magnitude used.
|
|
assert_eq!(
|
|
wheel_rows_for_delta(
|
|
-DEFAULT_LINE_HEIGHT_PX * 2.0,
|
|
0,
|
|
MAX,
|
|
DEFAULT_LINE_HEIGHT_PX
|
|
),
|
|
2
|
|
);
|
|
// Zero delta → no rows.
|
|
assert_eq!(wheel_rows_for_delta(0.0, 0, MAX, DEFAULT_LINE_HEIGHT_PX), 0);
|
|
// Huge delta is clamped to the max.
|
|
assert_eq!(
|
|
wheel_rows_for_delta(100_000.0, 0, MAX, DEFAULT_LINE_HEIGHT_PX),
|
|
12
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn wheel_rows_for_delta_scales_with_max_rows_for_coalesced_batches() {
|
|
// A coalesced batch sums many small wheel deltas into a single big
|
|
// scroll. The per-batch cap (max_rows) should be respected so a
|
|
// single huge swipe doesn't fly past the bottom instantly.
|
|
let big_sum = DEFAULT_LINE_HEIGHT_PX * 80.0; // would be 80 rows unclamped
|
|
// Per-event cap (12): even with a large sum, capped to 12.
|
|
assert_eq!(
|
|
wheel_rows_for_delta(big_sum, 0, 12, DEFAULT_LINE_HEIGHT_PX),
|
|
12
|
|
);
|
|
// Per-batch cap (a 24-row viewport): bigger than per-event.
|
|
assert_eq!(
|
|
wheel_rows_for_delta(big_sum, 0, 24, DEFAULT_LINE_HEIGHT_PX),
|
|
24
|
|
);
|
|
// Honest mapping: a 5-row coalesced swipe is 5 rows, not 12.
|
|
assert_eq!(
|
|
wheel_rows_for_delta(DEFAULT_LINE_HEIGHT_PX * 5.0, 0, 24, DEFAULT_LINE_HEIGHT_PX),
|
|
5
|
|
);
|
|
// Tiny sums still floor at 1.
|
|
assert_eq!(wheel_rows_for_delta(1.0, 0, 24, DEFAULT_LINE_HEIGHT_PX), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn point_to_cell_maps_pixels_with_padding_and_scroll() {
|
|
let (cw, ch) = (8.0, 18.0);
|
|
// Top-left content origin is at (PADDING, PADDING) = (16,16).
|
|
assert_eq!(point_to_cell(16.0, 16.0, 0.0, cw, ch, 80, 100), (0, 0));
|
|
// One cell right and one row down.
|
|
assert_eq!(
|
|
point_to_cell(16.0 + 8.0, 16.0 + 18.0, 0.0, cw, ch, 80, 100),
|
|
(1, 1)
|
|
);
|
|
// Scroll offset shifts the row mapping down.
|
|
assert_eq!(point_to_cell(16.0, 16.0, 180.0, cw, ch, 80, 100), (10, 0));
|
|
// Clamped to grid bounds.
|
|
assert_eq!(
|
|
point_to_cell(99999.0, 99999.0, 0.0, cw, ch, 80, 100),
|
|
(99, 79)
|
|
);
|
|
// Inside the padding clamps to (0,0), never negative.
|
|
assert_eq!(point_to_cell(0.0, 0.0, 0.0, cw, ch, 80, 100), (0, 0));
|
|
}
|
|
|
|
#[test]
|
|
fn pixel_to_grid_maps_visible_screen_coordinates() {
|
|
let (cw, ch) = (8.0, 18.0);
|
|
let cols = 80;
|
|
let rows = 24;
|
|
// Top-left of the content box (padding, padding) → (0, 0).
|
|
assert_eq!(
|
|
pixel_to_grid(16.0, 16.0, cw, ch, cols, rows, TERMINAL_PADDING),
|
|
(0, 0)
|
|
);
|
|
// One cell right/down.
|
|
assert_eq!(
|
|
pixel_to_grid(
|
|
16.0 + 8.0,
|
|
16.0 + 18.0,
|
|
cw,
|
|
ch,
|
|
cols,
|
|
rows,
|
|
TERMINAL_PADDING
|
|
),
|
|
(1, 1)
|
|
);
|
|
// Clamped to viewport bounds.
|
|
assert_eq!(
|
|
pixel_to_grid(99999.0, 99999.0, cw, ch, cols, rows, TERMINAL_PADDING),
|
|
(23, 79)
|
|
);
|
|
// Negative/local inside padding clamps to (0, 0).
|
|
assert_eq!(
|
|
pixel_to_grid(0.0, 0.0, cw, ch, cols, rows, TERMINAL_PADDING),
|
|
(0, 0)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn encode_mouse_report_sgr_encoding() {
|
|
// Left press at col 5, row 3 (0-based → 6,4 in the report).
|
|
assert_eq!(
|
|
encode_mouse_report(0, MouseAction::Press, 5, 3, MouseProtocolEncoding::Sgr),
|
|
"\u{1b}[<0;6;4M"
|
|
);
|
|
// Left release uses the same button code but 'm' suffix.
|
|
assert_eq!(
|
|
encode_mouse_report(0, MouseAction::Release, 5, 3, MouseProtocolEncoding::Sgr),
|
|
"\u{1b}[<0;6;4m"
|
|
);
|
|
// Motion with left button held (32+0) + shift modifier (4) = 36.
|
|
assert_eq!(
|
|
encode_mouse_report(36, MouseAction::Motion, 0, 0, MouseProtocolEncoding::Sgr),
|
|
"\u{1b}[<36;1;1M"
|
|
);
|
|
// Wheel down (65).
|
|
assert_eq!(
|
|
encode_mouse_report(65, MouseAction::Press, 10, 20, MouseProtocolEncoding::Sgr),
|
|
"\u{1b}[<65;11;21M"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn encode_mouse_report_default_encoding() {
|
|
// Left press: ESC M ' ' (32) '!' (33) '!' (33) for col 1 row 1.
|
|
assert_eq!(
|
|
encode_mouse_report(0, MouseAction::Press, 0, 0, MouseProtocolEncoding::Default),
|
|
"\u{1b}M !!"
|
|
);
|
|
// Release is always button 3 in Default encoding.
|
|
assert_eq!(
|
|
encode_mouse_report(
|
|
0,
|
|
MouseAction::Release,
|
|
0,
|
|
0,
|
|
MouseProtocolEncoding::Default
|
|
),
|
|
"\u{1b}M#!!"
|
|
);
|
|
// Coordinates are clamped to the ASCII-safe range (col 94 → report 95+32=127).
|
|
assert_eq!(
|
|
encode_mouse_report(
|
|
0,
|
|
MouseAction::Press,
|
|
200,
|
|
100,
|
|
MouseProtocolEncoding::Default
|
|
),
|
|
"\u{1b}M \u{7f}\u{7f}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn mouse_modifiers_or_into_button_code() {
|
|
assert_eq!(with_mouse_modifiers(0, false, false, false), 0);
|
|
assert_eq!(with_mouse_modifiers(0, true, false, false), 4);
|
|
assert_eq!(with_mouse_modifiers(0, false, true, false), 8);
|
|
assert_eq!(with_mouse_modifiers(0, false, false, true), 16);
|
|
assert_eq!(with_mouse_modifiers(0, true, true, true), 28);
|
|
assert_eq!(with_mouse_modifiers(2, true, false, true), 22);
|
|
}
|
|
|
|
#[test]
|
|
fn mouse_button_base_only_maps_three_buttons() {
|
|
assert_eq!(mouse_button_base(0), Some(0));
|
|
assert_eq!(mouse_button_base(1), Some(1));
|
|
assert_eq!(mouse_button_base(2), Some(2));
|
|
assert_eq!(mouse_button_base(3), None);
|
|
assert_eq!(mouse_button_base(4), None);
|
|
}
|
|
|
|
#[test]
|
|
fn selection_rects_clip_to_window_and_span_columns() {
|
|
let cw = 8.0;
|
|
let cols = 80;
|
|
// Single-row selection cols 2..6 on row 5, window [0,30).
|
|
let sel = Some(Selection {
|
|
anchor: (5, 2),
|
|
head: (5, 6),
|
|
});
|
|
let rects = selection_rects(sel, 0, 30, cols, cw, DEFAULT_LINE_HEIGHT_PX);
|
|
assert_eq!(rects.len(), 1);
|
|
assert_eq!(rects[0].top_px, 5.0 * DEFAULT_LINE_HEIGHT_PX);
|
|
assert_eq!(rects[0].left_px, 2.0 * cw);
|
|
assert_eq!(rects[0].width_px, 4.0 * cw);
|
|
|
|
// Multi-row selection rows 4..6: first row partial, middle full, last partial.
|
|
let sel = Some(Selection {
|
|
anchor: (4, 10),
|
|
head: (6, 3),
|
|
});
|
|
let rects = selection_rects(sel, 0, 30, cols, cw, DEFAULT_LINE_HEIGHT_PX);
|
|
assert_eq!(rects.len(), 3);
|
|
assert_eq!(rects[0].left_px, 10.0 * cw); // first row starts at col 10
|
|
assert_eq!(rects[1].width_px, cols as f64 * cw); // middle row full width
|
|
assert_eq!(rects[2].left_px, 0.0); // last row from col 0
|
|
assert_eq!(rects[2].width_px, 3.0 * cw);
|
|
|
|
// Selection entirely above the window → clipped out.
|
|
let sel = Some(Selection {
|
|
anchor: (1, 0),
|
|
head: (2, 5),
|
|
});
|
|
assert!(selection_rects(sel, 10, 30, cols, cw, DEFAULT_LINE_HEIGHT_PX).is_empty());
|
|
|
|
// Empty selection → no rects.
|
|
let sel = Some(Selection {
|
|
anchor: (5, 2),
|
|
head: (5, 2),
|
|
});
|
|
assert!(selection_rects(sel, 0, 30, cols, cw, DEFAULT_LINE_HEIGHT_PX).is_empty());
|
|
assert!(selection_rects(None, 0, 30, cols, cw, DEFAULT_LINE_HEIGHT_PX).is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn search_match_rects_clip_to_visible_window() {
|
|
let mat = TerminalSearchMatch {
|
|
row: 8,
|
|
start_col: 3,
|
|
end_col: 9,
|
|
text: "target".to_owned(),
|
|
};
|
|
let rects = search_match_rects(Some(mat.clone()), 5, 12, 8.0, DEFAULT_LINE_HEIGHT_PX);
|
|
assert_eq!(rects.len(), 1);
|
|
assert_eq!(rects[0].top_px, 3.0 * DEFAULT_LINE_HEIGHT_PX);
|
|
assert_eq!(rects[0].left_px, 24.0);
|
|
assert_eq!(rects[0].width_px, 48.0);
|
|
|
|
assert!(search_match_rects(Some(mat), 9, 12, 8.0, DEFAULT_LINE_HEIGHT_PX).is_empty());
|
|
assert!(search_match_rects(None, 5, 12, 8.0, DEFAULT_LINE_HEIGHT_PX).is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn link_rects_map_buffer_rows_to_window_coordinates() {
|
|
let links = vec![TerminalLink {
|
|
row: 7,
|
|
start_col: 4,
|
|
end_col: 14,
|
|
url: "https://example.com".to_owned(),
|
|
}];
|
|
let rects = link_rects(links, 5, 8.0, DEFAULT_LINE_HEIGHT_PX);
|
|
assert_eq!(rects.len(), 1);
|
|
assert_eq!(rects[0].rect.top_px, 2.0 * DEFAULT_LINE_HEIGHT_PX);
|
|
assert_eq!(rects[0].rect.left_px, 32.0);
|
|
assert_eq!(rects[0].rect.width_px, 80.0);
|
|
assert_eq!(rects[0].url, "https://example.com");
|
|
}
|
|
|
|
#[test]
|
|
fn terminal_options_resolve_safe_defaults_and_css_vars() {
|
|
let options = TerminalOptions {
|
|
rows: 0,
|
|
cols: 0,
|
|
min_rows: 0,
|
|
min_cols: 0,
|
|
scrollback: 0,
|
|
cursor: TerminalCursorOptions {
|
|
style: TerminalCursorStyle::Underline,
|
|
blink: true,
|
|
},
|
|
font: TerminalFontOptions {
|
|
family: String::new(),
|
|
size_px: 0.0,
|
|
line_height_px: 0.0,
|
|
},
|
|
..TerminalOptions::default()
|
|
};
|
|
let resolved = ResolvedTerminalOptions::from(options);
|
|
assert_eq!(resolved.rows, 1);
|
|
assert_eq!(resolved.cols, 1);
|
|
assert_eq!(resolved.min_rows, 1);
|
|
assert_eq!(resolved.min_cols, 1);
|
|
assert_eq!(resolved.scrollback, 1);
|
|
assert_eq!(resolved.font_size_px, 1.0);
|
|
assert_eq!(resolved.line_height_px, 1.0);
|
|
assert!(resolved.font_family.contains("JetBrains Mono"));
|
|
|
|
let style = terminal_options_style(&default_options());
|
|
assert!(style.contains("--terminal-font-family:"));
|
|
assert!(style.contains("--terminal-selection-bg:"));
|
|
|
|
let style = terminal_options_style(&resolved);
|
|
assert!(style.contains("--terminal-cursor-style:underline"));
|
|
assert!(style.contains("--terminal-cursor-blink:terminal-cursor-blink"));
|
|
}
|
|
|
|
#[test]
|
|
fn renderer_canvas_surface_selection() {
|
|
assert!(renderer_uses_canvas_surface(Renderer::WebGl));
|
|
assert!(renderer_uses_canvas_surface(Renderer::Canvas));
|
|
assert!(!renderer_uses_canvas_surface(Renderer::Dom));
|
|
assert_eq!(Renderer::default(), Renderer::Canvas);
|
|
}
|
|
|
|
#[test]
|
|
fn viewport_measurement_excludes_terminal_padding() {
|
|
let options = default_options();
|
|
let viewport = measure_terminal_viewport_from_rect(960.0, 684.0, NodeRef::new(), &options);
|
|
|
|
assert_eq!(viewport.pixel_width, 958);
|
|
assert_eq!(viewport.pixel_height, 682);
|
|
assert_eq!(
|
|
viewport.rows,
|
|
((684.0 - 2.0) / DEFAULT_CELL_HEIGHT).floor() as u16
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn ime_cursor_cell_uses_buffer_coordinates_after_scrollback() {
|
|
let mut core = core_with_lines(220);
|
|
core.process(b"prompt$ ");
|
|
|
|
let (row, col) = ime_cursor_cell(&core);
|
|
|
|
assert!(core.history_rows().len() > 0);
|
|
assert!(
|
|
row >= core.history_rows().len(),
|
|
"IME carrier must stay near the live prompt, not at screen-local row {row}"
|
|
);
|
|
assert!(col > 0);
|
|
}
|
|
|
|
#[test]
|
|
fn is_regular_text_input_detects_printable_keys() {
|
|
assert!(is_regular_text_input("a", false, false, false));
|
|
assert!(is_regular_text_input("A", false, false, false)); // Shift handled by browser
|
|
assert!(is_regular_text_input(" ", false, false, false));
|
|
assert!(!is_regular_text_input("Enter", false, false, false));
|
|
assert!(!is_regular_text_input("ArrowUp", false, false, false));
|
|
assert!(!is_regular_text_input("a", true, false, false)); // Ctrl+a
|
|
assert!(!is_regular_text_input("a", false, true, false)); // Alt+a
|
|
assert!(!is_regular_text_input("a", false, false, true)); // Meta+a
|
|
assert!(!is_regular_text_input("Dead", false, false, false));
|
|
}
|
|
|
|
#[test]
|
|
fn key_to_bytes_table() {
|
|
// --- basic printable / control keys ---
|
|
assert_eq!(
|
|
key_to_bytes("a", false, false, false, false).as_deref(),
|
|
Some("a")
|
|
);
|
|
assert_eq!(
|
|
key_to_bytes("Z", false, false, false, false).as_deref(),
|
|
Some("Z")
|
|
);
|
|
assert_eq!(
|
|
key_to_bytes("Enter", false, false, false, false).as_deref(),
|
|
Some("\r")
|
|
);
|
|
assert_eq!(
|
|
key_to_bytes("Backspace", false, false, false, false).as_deref(),
|
|
Some("\u{7f}")
|
|
);
|
|
assert_eq!(
|
|
key_to_bytes("Tab", false, false, false, false).as_deref(),
|
|
Some("\t")
|
|
);
|
|
assert_eq!(
|
|
key_to_bytes("Escape", false, false, false, false).as_deref(),
|
|
Some("\u{1b}")
|
|
);
|
|
|
|
// --- application-cursor mode toggles arrows / Home / End to SS3 ---
|
|
let up_csi = key_to_bytes("ArrowUp", false, false, false, false).unwrap();
|
|
assert_eq!(up_csi, "\u{1b}[A");
|
|
let up_ss3 = key_to_bytes("ArrowUp", false, false, false, true).unwrap();
|
|
assert_eq!(up_ss3, "\u{1b}OA");
|
|
let home_csi = key_to_bytes("Home", false, false, false, false).unwrap();
|
|
assert_eq!(home_csi, "\u{1b}[H");
|
|
let home_ss3 = key_to_bytes("Home", false, false, false, true).unwrap();
|
|
assert_eq!(home_ss3, "\u{1b}OH");
|
|
// Arrows + Home/End cover all four directions.
|
|
for (key, normal, alt) in [
|
|
("ArrowUp", 'A', 'A'),
|
|
("ArrowDown", 'B', 'B'),
|
|
("ArrowRight", 'C', 'C'),
|
|
("ArrowLeft", 'D', 'D'),
|
|
("Home", 'H', 'H'),
|
|
("End", 'F', 'F'),
|
|
] {
|
|
let (csi, ss3) = (normal, alt);
|
|
assert_eq!(
|
|
key_to_bytes(key, false, false, false, false).unwrap(),
|
|
format!("\u{1b}[{csi}")
|
|
);
|
|
assert_eq!(
|
|
key_to_bytes(key, false, false, false, true).unwrap(),
|
|
format!("\u{1b}O{ss3}")
|
|
);
|
|
}
|
|
|
|
// --- paging / editing keys (always CSI) ---
|
|
assert_eq!(
|
|
key_to_bytes("Insert", false, false, false, false).as_deref(),
|
|
Some("\u{1b}[2~")
|
|
);
|
|
assert_eq!(
|
|
key_to_bytes("Delete", false, false, false, false).as_deref(),
|
|
Some("\u{1b}[3~")
|
|
);
|
|
assert_eq!(
|
|
key_to_bytes("PageUp", false, false, false, false).as_deref(),
|
|
Some("\u{1b}[5~")
|
|
);
|
|
assert_eq!(
|
|
key_to_bytes("PageDown", false, false, false, false).as_deref(),
|
|
Some("\u{1b}[6~")
|
|
);
|
|
// Paging does not change with app-cursor mode.
|
|
assert_eq!(
|
|
key_to_bytes("PageUp", false, false, false, true).as_deref(),
|
|
Some("\u{1b}[5~")
|
|
);
|
|
|
|
// --- function keys (F1-F4 SS3, F5-F12 CSI~ with 16/22 gaps) ---
|
|
for (key, expected) in [
|
|
("F1", "\u{1b}OP"),
|
|
("F2", "\u{1b}OQ"),
|
|
("F3", "\u{1b}OR"),
|
|
("F4", "\u{1b}OS"),
|
|
("F5", "\u{1b}[15~"),
|
|
("F6", "\u{1b}[17~"),
|
|
("F7", "\u{1b}[18~"),
|
|
("F8", "\u{1b}[19~"),
|
|
("F9", "\u{1b}[20~"),
|
|
("F10", "\u{1b}[21~"),
|
|
("F11", "\u{1b}[23~"),
|
|
("F12", "\u{1b}[24~"),
|
|
] {
|
|
assert_eq!(
|
|
key_to_bytes(key, false, false, false, false).as_deref(),
|
|
Some(expected),
|
|
"key {key}"
|
|
);
|
|
}
|
|
|
|
// --- Ctrl letters: A=0x01 .. Z=0x1a (except Z which is filtered) ---
|
|
for (letter, code) in [
|
|
("a", '\u{1}'),
|
|
("c", '\u{3}'),
|
|
("d", '\u{4}'),
|
|
("l", '\u{c}'),
|
|
("A", '\u{1}'),
|
|
] {
|
|
assert_eq!(
|
|
key_to_bytes(letter, true, false, false, false).as_deref(),
|
|
Some(&code.to_string()[..]),
|
|
"ctrl+{letter}"
|
|
);
|
|
}
|
|
// Ctrl+@ (NUL), Ctrl+[ (ESC), Ctrl+], Ctrl+^, Ctrl+_ still map.
|
|
assert_eq!(
|
|
key_to_bytes("@", true, false, false, false).as_deref(),
|
|
Some("\u{0}")
|
|
);
|
|
assert_eq!(
|
|
key_to_bytes("[", true, false, false, false).as_deref(),
|
|
Some("\u{1b}")
|
|
);
|
|
assert_eq!(
|
|
key_to_bytes("]", true, false, false, false).as_deref(),
|
|
Some("\u{1d}")
|
|
);
|
|
assert_eq!(
|
|
key_to_bytes("^", true, false, false, false).as_deref(),
|
|
Some("\u{1e}")
|
|
);
|
|
assert_eq!(
|
|
key_to_bytes("_", true, false, false, false).as_deref(),
|
|
Some("\u{1f}")
|
|
);
|
|
// Ctrl-Z (0x1a, SIGTSTP) and Ctrl-\ (0x1c, SIGQUIT) are filtered
|
|
// out so accidental keypresses don't suspend / kill the foreground
|
|
// process. Ctrl-C (0x03) is still sent.
|
|
assert_eq!(key_to_bytes("z", true, false, false, false), None);
|
|
assert_eq!(key_to_bytes("Z", true, false, false, false), None);
|
|
assert_eq!(key_to_bytes("\\", true, false, false, false), None);
|
|
assert_eq!(
|
|
key_to_bytes("c", true, false, false, false).as_deref(),
|
|
Some("\u{3}")
|
|
);
|
|
assert_eq!(
|
|
key_to_bytes("]", true, false, false, false).as_deref(),
|
|
Some("\u{1d}")
|
|
);
|
|
assert_eq!(
|
|
key_to_bytes("^", true, false, false, false).as_deref(),
|
|
Some("\u{1e}")
|
|
);
|
|
assert_eq!(
|
|
key_to_bytes("_", true, false, false, false).as_deref(),
|
|
Some("\u{1f}")
|
|
);
|
|
// Ctrl+Alt falls through to the printable-letter fallback (most
|
|
// terminals forward the bare char; few apps use this combo).
|
|
assert_eq!(
|
|
key_to_bytes("c", true, true, false, false).as_deref(),
|
|
Some("c")
|
|
);
|
|
// Pressing the Ctrl/Shift/Meta modifier keys alone must not emit
|
|
// anything; previously "Control" with ctrl=true was misread as "C" and
|
|
// sent SIGINT (0x03).
|
|
assert_eq!(key_to_bytes("Control", true, false, false, false), None);
|
|
assert_eq!(key_to_bytes("Shift", false, false, false, false), None);
|
|
assert_eq!(key_to_bytes("Meta", false, false, true, false), None);
|
|
// Ctrl+Shift (or any ctrl+modifier) also stays silent.
|
|
assert_eq!(key_to_bytes("Shift", true, false, false, false), None);
|
|
|
|
// --- Alt+char → ESC prefix ---
|
|
assert_eq!(
|
|
key_to_bytes("a", false, true, false, false).as_deref(),
|
|
Some("\u{1b}a")
|
|
);
|
|
assert_eq!(
|
|
key_to_bytes("x", false, true, false, false).as_deref(),
|
|
Some("\u{1b}x")
|
|
);
|
|
// Alt+Ctrl+char falls through to the printable-letter fallback too.
|
|
assert_eq!(
|
|
key_to_bytes("a", true, true, false, false).as_deref(),
|
|
Some("a")
|
|
);
|
|
// Meta suppresses passthrough.
|
|
assert_eq!(key_to_bytes("a", false, false, true, false), None);
|
|
}
|
|
|
|
#[test]
|
|
fn prepare_paste_normalizes_newlines_and_wraps_when_bracketed() {
|
|
// Plain paste: CRLF and LF both become CR, no wrapping.
|
|
assert_eq!(prepare_paste("a\r\nb\nc", false), "a\rb\rc");
|
|
assert_eq!(prepare_paste("echo hi", false), "echo hi");
|
|
|
|
// Bracketed paste: same normalization, wrapped in 200~/201~.
|
|
let esc = '\u{1b}';
|
|
assert_eq!(
|
|
prepare_paste("a\nb", true),
|
|
format!("{esc}[200~a\rb{esc}[201~")
|
|
);
|
|
// Empty-ish content still wraps consistently when bracketed.
|
|
assert_eq!(prepare_paste("", true), format!("{esc}[200~{esc}[201~"));
|
|
}
|
|
|
|
#[test]
|
|
fn live_mode_window_is_pinned_to_bottom_regardless_of_scroll_top() {
|
|
let core = core_with_lines(1000);
|
|
let total = core.total_rows();
|
|
let vp_rows = 24;
|
|
|
|
// Even with a stale scroll_top of 0, live mode must render the BOTTOM
|
|
// window (this was the blank-area / no-output bug).
|
|
let (total_px, offset_px, visible) =
|
|
virtual_window(&core, true, 0, vp_rows, DEFAULT_LINE_HEIGHT_PX);
|
|
|
|
// Sizer is always the full content height (constant across renders).
|
|
assert!(
|
|
(total_px - total as f64 * DEFAULT_LINE_HEIGHT_PX).abs() < 1.0,
|
|
"sizer height must equal total content height"
|
|
);
|
|
// The visible window is offset to the bottom: start = total-vp-overscan.
|
|
let expected_start = total - vp_rows - VIRTUAL_OVERSCAN;
|
|
assert!((offset_px - expected_start as f64 * DEFAULT_LINE_HEIGHT_PX).abs() < 1.0);
|
|
// The window covers through the last row.
|
|
assert!(
|
|
offset_px + visible.len() as f64 * DEFAULT_LINE_HEIGHT_PX
|
|
>= total as f64 * DEFAULT_LINE_HEIGHT_PX - 1.0,
|
|
"bottom window must reach the final row"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn history_mode_window_follows_scroll_top() {
|
|
let core = core_with_lines(1000);
|
|
let total = core.total_rows();
|
|
let vp_rows = 24;
|
|
// Scroll to row 500 (500 * 18px).
|
|
let st = (500.0 * DEFAULT_LINE_HEIGHT_PX) as i32;
|
|
let (total_px, offset_px, visible) =
|
|
virtual_window(&core, false, st, vp_rows, DEFAULT_LINE_HEIGHT_PX);
|
|
|
|
// Sizer height is constant regardless of scroll position.
|
|
assert!((total_px - total as f64 * DEFAULT_LINE_HEIGHT_PX).abs() < 1.0);
|
|
// First rendered row should be ~ (500 - overscan).
|
|
let first_row = (offset_px / DEFAULT_LINE_HEIGHT_PX).round() as usize;
|
|
assert_eq!(first_row, 500 - VIRTUAL_OVERSCAN);
|
|
assert!(!visible.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn short_content_renders_without_offset() {
|
|
let core = core_with_lines(3); // far fewer than a 24-row viewport
|
|
let (total_px, offset_px, visible) =
|
|
virtual_window(&core, true, 0, 24, DEFAULT_LINE_HEIGHT_PX);
|
|
// Sizer == content height, no offset, all rows rendered.
|
|
assert!((total_px - core.total_rows() as f64 * DEFAULT_LINE_HEIGHT_PX).abs() < 1.0);
|
|
assert_eq!(offset_px, 0.0);
|
|
assert_eq!(visible.len(), core.total_rows());
|
|
}
|
|
}
|