diff --git a/Cargo.lock b/Cargo.lock index 66ded47..e2b9369 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -49,6 +49,8 @@ dependencies = [ "reqwest", "serde", "serde_json", + "unicode-segmentation", + "unicode-width", "vt100", "wasm-bindgen", "wasm-bindgen-futures", diff --git a/Cargo.toml b/Cargo.toml index ece9b6d..b44574f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,11 +34,14 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] } validator = { version = "0.20", features = ["derive"] } vt100 = "0.15" heck = "0.5" +unicode-segmentation = "1.13" +unicode-width = "0.1" js-sys = "0.3" wasm-bindgen = "0.2" wasm-bindgen-futures = "0.4" web-sys = { version = "0.3", default-features = false, features = [ "BinaryType", + "CanvasRenderingContext2d", "Clipboard", "ClipboardEvent", "CloseEvent", @@ -52,6 +55,7 @@ web-sys = { version = "0.3", default-features = false, features = [ "ErrorEvent", "Event", "EventTarget", + "HtmlCanvasElement", "HtmlElement", "InputEvent", "KeyboardEvent", @@ -61,6 +65,12 @@ web-sys = { version = "0.3", default-features = false, features = [ "Navigator", "ResizeObserver", "ResizeObserverEntry", + "WebGl2RenderingContext", + "WebGlBuffer", + "WebGlProgram", + "WebGlShader", + "WebGlTexture", + "WebGlUniformLocation", "WebSocket", "WheelEvent", "Window", diff --git a/app/Cargo.toml b/app/Cargo.toml index 8225d19..f129999 100644 --- a/app/Cargo.toml +++ b/app/Cargo.toml @@ -23,6 +23,8 @@ redis = { workspace = true, optional = true } reqwest = { workspace = true, optional = true } serde.workspace = true serde_json.workspace = true +unicode-segmentation.workspace = true +unicode-width.workspace = true vt100.workspace = true wasm-bindgen.workspace = true wasm-bindgen-futures.workspace = true diff --git a/app/src/terminal/component.rs b/app/src/terminal/component.rs index 6338fd8..ffb152d 100644 --- a/app/src/terminal/component.rs +++ b/app/src/terminal/component.rs @@ -1,30 +1,46 @@ +use std::collections::HashMap; use std::rc::Rc; +use std::sync::Arc; use app_config::SiteConfig; use leptos::html; use leptos::prelude::*; +use unicode_segmentation::UnicodeSegmentation; +use unicode_width::UnicodeWidthStr; use wasm_bindgen::JsCast; use wasm_bindgen::closure::Closure; -use web_sys::js_sys::{Array, Function}; +use web_sys::js_sys::{Array, Float32Array, Function}; use web_sys::{ - BinaryType, ClipboardEvent, CompositionEvent, ErrorEvent, Event, HtmlElement, - InputEvent, KeyboardEvent, MessageEvent, MouseEvent, ResizeObserver, - ResizeObserverEntry, WebSocket, WheelEvent, + BinaryType, CanvasRenderingContext2d, ClipboardEvent, CompositionEvent, ErrorEvent, Event, + HtmlCanvasElement, HtmlElement, InputEvent, KeyboardEvent, MessageEvent, MouseEvent, + ResizeObserver, ResizeObserverEntry, WebGl2RenderingContext, WebGlBuffer, WebGlProgram, + WebGlShader, WebGlTexture, WebGlUniformLocation, WebSocket, WheelEvent, }; use super::core::{ - MouseProtocolEncoding, MouseProtocolMode, TerminalCore, TerminalRow, TerminalSegment, + MouseProtocolMode, SearchDirection, TerminalCore, TerminalLink, TerminalRow, + TerminalSearchMatch, TerminalSegment, TerminalStyle, +}; +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::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 SCROLL_LINE_HEIGHT: f64 = 18.0; +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. @@ -34,31 +50,6 @@ const VIRTUAL_OVERSCAN: usize = 5; /// coordinates subtract this to map onto the cell grid. const TERMINAL_PADDING: f64 = 16.0; -/// A text selection in logical buffer coordinates `(row, col)`. `anchor` is -/// where the drag began, `head` follows the pointer; either may be the -/// top-left depending on drag direction. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -struct Selection { - anchor: (usize, usize), - head: (usize, usize), -} - -impl Selection { - /// (top-left, bottom-right) ordered for rendering / extraction. - fn ordered(self) -> ((usize, usize), (usize, usize)) { - if self.anchor <= self.head { - (self.anchor, self.head) - } else { - (self.head, self.anchor) - } - } - - /// True when the selection is empty (anchor == head): nothing to copy. - fn is_empty(self) -> bool { - self.anchor == self.head - } -} - #[derive(Clone, Copy, Debug, PartialEq, Eq)] struct TerminalViewport { cols: u16, @@ -72,10 +63,9 @@ impl Default for TerminalViewport { 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, + 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, } } } @@ -84,14 +74,450 @@ impl Default for TerminalViewport { // TerminalPanel component // --------------------------------------------------------------------------- +/// Which renderer draws the terminal grid. WebGL is the default performance +/// path; Canvas and DOM keep stable fallbacks for unsupported browsers. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Renderer { + #[default] + WebGl, + 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::WebGl, + 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, Eq)] +pub struct TerminalRenderEvent { + pub cols: u16, + pub rows: u16, + pub history_rows: usize, + pub screen_rows: usize, + pub mode: ViewMode, + pub renderer: Renderer, +} + +type SerializeFn = Arc String + Send + Sync>; + +#[derive(Clone, Copy)] +pub struct TerminalHandle { + input: RwSignal>, + clear_token: RwSignal, + focus_token: RwSignal, + resize: RwSignal>, + scroll_to_bottom_token: RwSignal, + copy_selection_token: RwSignal, + fit_token: RwSignal, + search_request: RwSignal>, + serialize_text_fn: StoredValue>, + serialize_ansi_fn: StoredValue>, +} + +impl TerminalHandle { + fn new() -> Self { + Self { + input: RwSignal::new(None), + clear_token: RwSignal::new(0), + focus_token: RwSignal::new(0), + resize: RwSignal::new(None), + scroll_to_bottom_token: RwSignal::new(0), + copy_selection_token: RwSignal::new(0), + fit_token: RwSignal::new(0), + search_request: RwSignal::new(None), + serialize_text_fn: StoredValue::new(None), + serialize_ansi_fn: StoredValue::new(None), + } + } + + pub fn write(&self, data: impl Into) { + self.input.set(Some(data.into())); + } + + pub fn paste_text(&self, data: impl Into) { + self.write(data); + } + + pub fn clear(&self) { + self.clear_token.update(|token| *token += 1); + } + + pub fn focus(&self) { + self.focus_token.update(|token| *token += 1); + } + + pub fn resize(&self, rows: u16, cols: u16) { + self.resize.set(Some((rows, cols))); + } + + pub fn scroll_to_bottom(&self) { + self.scroll_to_bottom_token.update(|token| *token += 1); + } + + pub fn copy_selection(&self) { + self.copy_selection_token.update(|token| *token += 1); + } + + pub fn fit(&self) { + self.fit_token.update(|token| *token += 1); + } + + pub fn serialize_text(&self) -> String { + self.serialize_text_fn + .get_value() + .as_ref() + .map(|serialize| serialize()) + .unwrap_or_default() + } + + pub fn serialize_ansi(&self) -> String { + self.serialize_ansi_fn + .get_value() + .as_ref() + .map(|serialize| serialize()) + .unwrap_or_default() + } + + pub fn search_next(&self, query: impl Into) { + self.search(query, SearchDirection::Forward); + } + + pub fn search_previous(&self, query: impl Into) { + self.search(query, SearchDirection::Backward); + } + + pub fn clear_search(&self) { + self.search_request.set(Some(TerminalSearchRequest::Clear)); + } + + fn search(&self, query: impl Into, direction: SearchDirection) { + self.search_request.set(Some(TerminalSearchRequest::Find { + query: query.into(), + direction, + })); + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +enum TerminalSearchRequest { + Find { + query: String, + direction: SearchDirection, + }, + Clear, +} + +#[derive(Clone, Debug, PartialEq)] +struct ResolvedTerminalOptions { + cols: u16, + rows: u16, + min_cols: u16, + min_rows: u16, + scrollback: usize, + renderer: Renderer, + font_family: String, + font_size_px: f64, + line_height_px: f64, + theme: TerminalTheme, + cursor: TerminalCursorOptions, + title: String, + subtitle: String, + show_header: bool, +} + +impl From for ResolvedTerminalOptions { + fn from(options: TerminalOptions) -> Self { + let rows = options.rows.max(1); + let cols = options.cols.max(1); + Self { + cols, + rows, + min_cols: options.min_cols.max(1), + min_rows: options.min_rows.max(1), + scrollback: options.scrollback.max(rows as usize), + renderer: options.renderer, + font_family: if options.font.family.trim().is_empty() { + DEFAULT_TERMINAL_FONT_FAMILY.to_owned() + } else { + options.font.family + }, + font_size_px: options.font.size_px.max(1.0), + line_height_px: options.font.line_height_px.max(1.0), + theme: options.theme, + cursor: options.cursor, + title: options.title, + subtitle: options.subtitle, + show_header: options.show_header, + } + } +} + +fn terminal_options_style(options: &ResolvedTerminalOptions) -> String { + format!( + "--terminal-font-family:{};--terminal-font-size:{}px;--terminal-line-height:{}px;--terminal-fg:{};--terminal-bg:{};--terminal-cursor-fg:{};--terminal-cursor-bg:{};--terminal-selection-bg:{};--terminal-cursor-style:{};--terminal-cursor-blink:{};", + options.font_family, + options.font_size_px, + options.line_height_px, + options.theme.foreground, + options.theme.background, + options.theme.cursor_foreground, + options.theme.cursor_background, + options.theme.selection_background, + options.cursor.style.css_value(), + if options.cursor.blink { + "terminal-cursor-blink" + } else { + "none" + }, + ) +} + +#[derive(Clone, Copy, Debug, PartialEq)] +struct CursorShapeRect { + x: f64, + y: f64, + width: f64, + height: f64, +} + +fn cursor_shape_rect( + style: TerminalCursorStyle, + x: f64, + y: f64, + cell_w: f64, + cell_h: f64, +) -> CursorShapeRect { + match style { + TerminalCursorStyle::Block => CursorShapeRect { + x, + y, + width: cell_w, + height: cell_h, + }, + TerminalCursorStyle::Underline => { + let height = (cell_h * 0.15).round().clamp(1.0, 3.0); + CursorShapeRect { + x, + y: y + cell_h - height, + width: cell_w, + height, + } + } + TerminalCursorStyle::Bar => { + let width = (cell_w * 0.18).round().clamp(1.0, 3.0); + CursorShapeRect { + x, + y, + width, + height: cell_h, + } + } + } +} + +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 themed_color<'a>(color: &'a str, options: &'a ResolvedTerminalOptions) -> &'a str { + if color == CANVAS_FG { + &options.theme.foreground + } else if color == CANVAS_BG { + &options.theme.background + } else { + color + } +} + +fn terminal_font(style: &TerminalStyle, options: &ResolvedTerminalOptions) -> String { + terminal_font_with_px(style, options.font_size_px, &options.font_family) +} + +fn terminal_font_with_px(style: &TerminalStyle, px: f64, font_family: &str) -> String { + let mut parts = Vec::with_capacity(3); + if style.bold { + parts.push("bold"); + } + if style.italic { + parts.push("italic"); + } + let px = px.max(1.0); + let font_size = format!("{px}px"); + parts.push(&font_size); + format!("{} {}", parts.join(" "), font_family) +} + +fn renderer_uses_canvas_surface(renderer: Renderer) -> bool { + matches!(renderer, Renderer::WebGl | Renderer::Canvas) +} + #[component] -pub fn TerminalPanel() -> impl IntoView { +pub fn TerminalPanel( + #[prop(default = TerminalOptions::default().into(), into)] options: Signal, + #[prop(optional)] renderer: Option, + #[prop(optional)] on_ready: Option>, + #[prop(optional)] on_data: Option>, + #[prop(optional)] on_resize: Option>, + #[prop(optional)] on_selection_change: Option>, + #[prop(optional)] on_title_change: Option>, + #[prop(optional)] on_render: Option>, +) -> impl IntoView { + let mut resolved_options = ResolvedTerminalOptions::from(options.get_untracked()); + if let Some(renderer) = renderer { + resolved_options.renderer = renderer; + } + let options_signal = RwSignal::new(resolved_options.clone()); + let handle = TerminalHandle::new(); let terminal_ref = NodeRef::::new(); let ime_ref = NodeRef::::new(); + let canvas_ref = NodeRef::::new(); let measure_ref = NodeRef::::new(); let connection_status = RwSignal::new("Connecting".to_owned()); let ws_signal = RwSignal::new(None::); - let current_viewport = RwSignal::new(TerminalViewport::default()); + 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 @@ -105,7 +531,11 @@ pub fn TerminalPanel() -> impl IntoView { // decouples the (very fast, ~1µs/line) data-ingestion rate from the // (expensive) DOM-render rate: a burst of hundreds of WebSocket packets // from `cat`-ing a huge file collapses into a single render per frame. - let core_signal = RwSignal::new(TerminalCore::default()); + 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); @@ -116,11 +546,13 @@ pub fn TerminalPanel() -> impl IntoView { // Measured cell size (width, height) in px, kept in sync with the viewport // measurement. Used to map mouse coordinates onto the logical cell grid. - let cell_size = RwSignal::new((DEFAULT_CELL_WIDTH, DEFAULT_CELL_HEIGHT)); + let cell_size = RwSignal::new((DEFAULT_CELL_WIDTH, resolved_options.line_height_px)); // Active text selection in buffer coords, and whether a drag is in progress. let selection = RwSignal::new(None::); let selecting = RwSignal::new(false); + let search_match = RwSignal::new(None::); + let search_query = RwSignal::new(String::new()); // IME composition state: when true, regular `input` events are ignored and // the final composed string is sent on `compositionend`. @@ -130,10 +562,28 @@ pub fn TerminalPanel() -> impl IntoView { // reporting). `mouse_held_button` remembers which button is down so we can // emit motion events and SGR-style releases; `mouse_last_cell` throttles // motion reports to one per grid cell (not per pixel). - let mouse_held_button: Rc>> = - Rc::new(std::cell::Cell::new(None)); + let mouse_held_button: Rc>> = Rc::new(std::cell::Cell::new(None)); let mouse_last_cell: Rc>> = Rc::new(std::cell::Cell::new(None)); + let canvas_state: Rc>> = + Rc::new(std::cell::RefCell::new(None)); + let webgl_state: Rc>> = + Rc::new(std::cell::RefCell::new(None)); + + 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 ------------------------------------------------- // @@ -147,7 +597,7 @@ pub fn TerminalPanel() -> impl IntoView { if core.hide_cursor() { None } else { - Some(core.cursor_position()) + Some(ime_cursor_cell(core)) } }) .flatten() @@ -155,13 +605,343 @@ pub fn TerminalPanel() -> impl IntoView { return String::new(); }; let (cell_w, cell_h) = cell_size.get(); - let left = TERMINAL_PADDING + f64::from(col) * cell_w; - let top = TERMINAL_PADDING + f64::from(row) * cell_h; - format!( - "left:{left}px;top:{top}px;width:{cell_w}px;height:{cell_h}px;" - ) + 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, + }) { + on_render.run(event); + } + }); + + 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(element) = terminal_ref.get() else { + return; + }; + + let dpr = window().device_pixel_ratio(); + let Some(element) = element.dyn_ref::() else { + return; + }; + let (css_width, css_height) = terminal_content_viewport_size(element); + let Some(canvas_el) = canvas.dyn_ref::() else { + return; + }; + + let 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 render_webgl(canvas_el, frame, &canvas_state, &webgl_state).is_err() { + render_canvas_surface(RenderCanvasSurfaceRequest { + canvas_el, + frame, + canvas_state: &canvas_state, + }); + } + } + Renderer::Canvas => { + render_canvas_surface(RenderCanvasSurfaceRequest { + canvas_el, + frame, + canvas_state: &canvas_state, + }); + } + Renderer::Dom => {} + } + }); + // -- WebSocket lifecycle ------------------------------------------------ Effect::new(move |_| { @@ -190,16 +970,19 @@ pub fn TerminalPanel() -> impl IntoView { open_status.set("Connected".to_owned()); open_ws.set(Some((*open_socket).clone())); - let viewport = measure_terminal_viewport(open_ref, open_measure); + let options = options_signal.get_untracked(); + let viewport = measure_terminal_viewport(open_ref, open_measure, &options); open_viewport.set(viewport); cell_size.set(measure_cell_size(open_measure)); - open_core - .update_untracked(|core| core.resize(viewport.rows, viewport.cols)); + open_core.update_untracked(|core| core.resize(viewport.rows, viewport.cols)); open_tick.update(|t| *t += 1); open_mode.set(ViewMode::Live); send_resize_message(&open_socket, viewport); + 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); }); @@ -217,54 +1000,49 @@ pub fn TerminalPanel() -> impl IntoView { // A burst of packets all land in `core` (untracked) but only trigger a // single render on the next frame. let raf_pending = Rc::new(std::cell::Cell::new(false)); - let on_message = - Closure::::new(move |event: MessageEvent| { - let Some(text) = event.data().as_string() else { - return; - }; + let on_message = Closure::::new(move |event: MessageEvent| { + let Some(text) = event.data().as_string() else { + return; + }; - let Ok(message) = - serde_json::from_str::(&text) - else { - message_status.set("Protocol error".to_owned()); - return; - }; + let Ok(message) = serde_json::from_str::(&text) else { + message_status.set("Protocol error".to_owned()); + return; + }; - match message { - ServerTerminalMessage::Output { data } => { - // Feed bytes without triggering a render (untracked). - // `try_*` so a stale callback from a disposed mount - // no-ops instead of panicking. - if message_core - .try_update_untracked(|core| { - core.process(data.as_bytes()) - }) - .is_none() - { - return; - } + match message { + ServerTerminalMessage::Output { data } => { + // Feed bytes without triggering a render (untracked). + // `try_*` so a stale callback from a disposed mount + // no-ops instead of panicking. + if message_core + .try_update_untracked(|core| core.process(data.as_bytes())) + .is_none() + { + return; + } - // Schedule exactly one render for the next frame. - schedule_frame( - raf_pending.clone(), - message_tick, - message_mode, - message_ref, - message_prog_scroll, - ); - } - ServerTerminalMessage::Exit { code } => { - message_status.set(match code { - Some(code) => format!("Exited ({code})"), - None => "Exited".to_owned(), - }); - } - ServerTerminalMessage::Error { message } => { - message_status.set(format!("Error: {message}")); - } - ServerTerminalMessage::Pong => {} + // Schedule exactly one render for the next frame. + schedule_frame( + raf_pending.clone(), + message_tick, + message_mode, + message_ref, + message_prog_scroll, + ); } - }); + ServerTerminalMessage::Exit { code } => { + message_status.set(match code { + Some(code) => format!("Exited ({code})"), + None => "Exited".to_owned(), + }); + } + ServerTerminalMessage::Error { message } => { + message_status.set(format!("Error: {message}")); + } + ServerTerminalMessage::Pong => {} + } + }); socket.set_onmessage(Some(on_message.as_ref().unchecked_ref())); on_message.forget(); @@ -309,18 +1087,16 @@ pub fn TerminalPanel() -> impl IntoView { let resize_ime = ime_ref; let callback = Closure::::new( move |entries: Array, _observer: ResizeObserver| { - let Some(entry) = entries - .get(0) - .dyn_into::() - .ok() - else { + let Some(entry) = entries.get(0).dyn_into::().ok() else { return; }; let rect = entry.content_rect(); + let options = options_signal.get_untracked(); let viewport = measure_terminal_viewport_from_rect( rect.width(), rect.height(), resize_measure, + &options, ); if viewport != resize_viewport.get_untracked() { @@ -338,22 +1114,20 @@ pub fn TerminalPanel() -> impl IntoView { 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, - ); + 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::()) + let Ok(observer) = ResizeObserver::new(callback.as_ref().unchecked_ref::()) else { return; }; @@ -375,10 +1149,7 @@ pub fn TerminalPanel() -> impl IntoView { // 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") - { + 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; @@ -406,22 +1177,22 @@ pub fn TerminalPanel() -> impl IntoView { 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; }; + 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 bracketed = + paste_core.try_with_untracked(|core| core.bracketed_paste()) == Some(true); let data = prepare_paste(&text, bracketed); if let Some(socket) = paste_ws.get_untracked() { - send_terminal_message( - &socket, - &ClientTerminalMessage::Input { data }, - ); + send_input_message(&socket, data, on_data); } }); return; @@ -486,10 +1257,7 @@ pub fn TerminalPanel() -> impl IntoView { } event.prevent_default(); - send_terminal_message( - &socket, - &ClientTerminalMessage::Input { data }, - ); + send_input_message(&socket, data, on_data); } }; @@ -511,8 +1279,7 @@ pub fn TerminalPanel() -> impl IntoView { // 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; + let is_programmatic_echo = (st - scroll_prog.get_untracked()).abs() <= 2; scroll_top.set(st); @@ -524,7 +1291,7 @@ pub fn TerminalPanel() -> impl IntoView { st, element.scroll_height(), element.client_height(), - SCROLL_LINE_HEIGHT, + options_signal.get_untracked().line_height_px, ); scroll_mode.set(mode); } @@ -546,19 +1313,17 @@ pub fn TerminalPanel() -> impl IntoView { // lines, which is jarring in less/vim. let wheel_batch: Rc>> = Rc::new(std::cell::Cell::new(None)); - let wheel_raf_pending: Rc> = - Rc::new(std::cell::Cell::new(false)); + let wheel_raf_pending: Rc> = Rc::new(std::cell::Cell::new(false)); move |event: WheelEvent| { // If the app enabled mouse reporting, a wheel tick is reported as a // button press (xterm: 64=up, 65=down, 66=left, 67=right) instead of // being translated to arrow keys. - let mouse_mode = wheel_core - .try_with_untracked(|core| core.mouse_protocol_mode()); + 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()) + let Some(encoding) = + wheel_core.try_with_untracked(|core| core.mouse_protocol_encoding()) else { return; }; @@ -590,6 +1355,7 @@ pub fn TerminalPanel() -> impl IntoView { cell_h, vp.cols as usize, vp.rows as usize, + TERMINAL_PADDING, ); let button = with_mouse_modifiers( @@ -598,25 +1364,15 @@ pub fn TerminalPanel() -> impl IntoView { event.alt_key(), event.ctrl_key(), ); - let data = encode_mouse_report( - button, - MouseAction::Press, - col, - row, - encoding, - ); + let data = encode_mouse_report(button, MouseAction::Press, col, row, encoding); if let Some(socket) = ws_signal.get_untracked() { - send_terminal_message( - &socket, - &ClientTerminalMessage::Input { data }, - ); + send_input_message(&socket, data, on_data); } return; } - let in_alt = wheel_core - .try_with_untracked(|core| core.alternate_screen()) - == Some(true); + let in_alt = + wheel_core.try_with_untracked(|core| core.alternate_screen()) == Some(true); if !in_alt { return; // main screen → native scroll } @@ -657,7 +1413,12 @@ pub fn TerminalPanel() -> impl IntoView { } 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); + let rows = wheel_rows_for_delta( + sum, + mode, + max_rows, + options_signal.get_untracked().line_height_px, + ); if rows == 0 { return; } @@ -667,10 +1428,7 @@ pub fn TerminalPanel() -> impl IntoView { let final_byte = if sum < 0.0 { 'A' } else { 'B' }; let data = cursor_seq(final_byte, app_cursor).repeat(rows); if let Some(socket) = ws_signal.get_untracked() { - send_terminal_message( - &socket, - &ClientTerminalMessage::Input { data }, - ); + send_input_message(&socket, data, on_data); } }); @@ -700,9 +1458,8 @@ pub fn TerminalPanel() -> impl IntoView { return; } - let bracketed = paste_core - .try_with_untracked(|core| core.bracketed_paste()) - == Some(true); + let bracketed = + paste_core.try_with_untracked(|core| core.bracketed_paste()) == Some(true); let data = prepare_paste(&raw, bracketed); send_input_data( data, @@ -710,6 +1467,7 @@ pub fn TerminalPanel() -> impl IntoView { paste_mode, terminal_ref, paste_prog_scroll, + on_data, ); event.prevent_default(); } @@ -745,6 +1503,7 @@ pub fn TerminalPanel() -> impl IntoView { input_mode, terminal_ref, input_prog_scroll, + on_data, ); } }; @@ -773,6 +1532,7 @@ pub fn TerminalPanel() -> impl IntoView { end_mode, terminal_ref, end_prog_scroll, + on_data, ); } clear_ime_textarea(&end_ime); @@ -822,6 +1582,7 @@ pub fn TerminalPanel() -> impl IntoView { cell_h, vp.cols as usize, vp.rows as usize, + TERMINAL_PADDING, )) }; @@ -835,9 +1596,8 @@ pub fn TerminalPanel() -> impl IntoView { // 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()) - }); + 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) { @@ -851,18 +1611,10 @@ pub fn TerminalPanel() -> impl IntoView { ); 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, - ); + let data = + encode_mouse_report(button, MouseAction::Press, col, row, encoding); if let Some(socket) = down_ws.get_untracked() { - send_terminal_message( - &socket, - &ClientTerminalMessage::Input { data }, - ); + send_input_message(&socket, data, on_data); } } } @@ -895,9 +1647,8 @@ pub fn TerminalPanel() -> impl IntoView { 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()) - }); + 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(), @@ -928,10 +1679,7 @@ pub fn TerminalPanel() -> impl IntoView { encoding, ); if let Some(socket) = move_ws.get_untracked() { - send_terminal_message( - &socket, - &ClientTerminalMessage::Input { data }, - ); + send_input_message(&socket, data, on_data); } } } @@ -963,9 +1711,8 @@ pub fn TerminalPanel() -> impl IntoView { 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()) - }); + 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) { @@ -987,18 +1734,10 @@ pub fn TerminalPanel() -> impl IntoView { event.alt_key(), event.ctrl_key(), ); - let data = encode_mouse_report( - button, - MouseAction::Release, - col, - row, - encoding, - ); + let data = + encode_mouse_report(button, MouseAction::Release, col, row, encoding); if let Some(socket) = up_ws.get_untracked() { - send_terminal_message( - &socket, - &ClientTerminalMessage::Input { data }, - ); + send_input_message(&socket, data, on_data); } } up_held.set(None); @@ -1019,12 +1758,13 @@ pub fn TerminalPanel() -> impl IntoView { // -- View --------------------------------------------------------------- view! { -
+
+
-

"Rust Browser Terminal"

+

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

- "Axum + portable-pty backend, Leptos + Rust/WASM client." + {move || options_signal.get().subtitle}

@@ -1054,6 +1794,7 @@ pub fn TerminalPanel() -> impl IntoView {
+
impl IntoView { {MEASURE_SAMPLE_TEXT}
- {move || { - // Subscribe to render_tick (frame-coalesced data - // changes), scroll_top (scroll), view_mode, selection. - let _ = render_tick.get(); - let is_live = view_mode.get().is_live(); - let sel = selection.get(); - let (cell_w, _cell_h) = cell_size.get(); - let vp = current_viewport.get_untracked(); - let vp_rows = vp.rows as usize; - let cols = vp.cols as usize; - let st = scroll_top.get(); - // Read core untracked — the core signal itself never - // notifies; render_tick is the explicit render trigger. - // `try_*` so a stale render during hydration no-ops to - // an empty view instead of panicking. - let (total_px, offset_px, visible) = core_signal - .try_with_untracked(|core| { - virtual_window(core, is_live, st, vp_rows) - }) - .unwrap_or((0.0, 0.0, Vec::new())); - let window_start = - (offset_px / SCROLL_LINE_HEIGHT).round() as usize; - let window_end = window_start + visible.len(); - let rects = selection_rects( - sel, window_start, window_end, cols, cell_w, - ); - render_spaced_view(total_px, offset_px, visible, rects) - }} + + + {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, + ) + }} +
@@ -1133,14 +1943,16 @@ fn virtual_window( is_live: bool, scroll_top_px: i32, vp_rows: usize, + line_height_px: f64, ) -> (f64, f64, Vec) { let total = core.total_rows(); let is_alt = core.alternate_screen(); + let line_height_px = line_height_px.max(1.0); if is_alt || total == 0 { // Alt-screen / empty: content fits the viewport; total height == rows. let rows = core.screen_rows().to_vec(); - let total_px = rows.len() as f64 * SCROLL_LINE_HEIGHT; + let total_px = rows.len() as f64 * line_height_px; return (total_px, 0.0, rows); } @@ -1148,7 +1960,7 @@ fn virtual_window( // Pin to the bottom: first visible row = total - viewport. total.saturating_sub(vp_rows) } else { - (f64::from(scroll_top_px.max(0)) / SCROLL_LINE_HEIGHT).floor() as usize + (f64::from(scroll_top_px.max(0)) / line_height_px).floor() as usize }; let start = first_visible.saturating_sub(VIRTUAL_OVERSCAN); @@ -1160,65 +1972,1043 @@ fn virtual_window( // the top. Keeping the container height constant across re-renders is what // prevents the browser from clamping scrollTop mid-scroll and firing the // feedback scroll events that caused the "keeps drifting up" behaviour. - let total_px = total as f64 * SCROLL_LINE_HEIGHT; - let offset_px = start as f64 * SCROLL_LINE_HEIGHT; + 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)) } -/// A highlight rectangle for one selected row, positioned relative to the top -/// of the rendered (translated) rows layer. -#[derive(Clone, Copy, Debug)] -struct SelectionRect { +// Render the visible rows using absolute positioning inside a fixed-height +// container. +// +// --------------------------------------------------------------------------- +// Canvas renderer +// --------------------------------------------------------------------------- + +/// Default terminal foreground / cursor colors, matching `tailwind.css`. +const CANVAS_FG: &str = "#dbeafe"; +const CANVAS_BG: &str = "#0f172a"; + +fn canvas_fill_style(color: &str) -> Option<&str> { + if color == "transparent" { + None + } else { + Some(color) + } +} + +fn canvas_viewport_top_px( + is_live: bool, + scroll_top_px: i32, + total_px: f64, + vp_rows: usize, + line_height_px: f64, +) -> f64 { + if is_live { + (total_px - vp_rows as f64 * line_height_px).max(0.0) + } else { + f64::from(scroll_top_px.max(0)) + } +} + +#[derive(Clone, Copy, Debug, PartialEq)] +struct CanvasDomState { + backing_width: u32, + backing_height: u32, + css_width: i32, + css_height: i32, top_px: f64, - left_px: f64, - width_px: f64, } -/// Compute the per-row highlight rects for `sel`, clipped to the rendered -/// window `[window_start, window_end)`. Returns empty for no/empty selection. -fn selection_rects( - sel: Option, - window_start: usize, - window_end: usize, - cols: usize, - cell_w: f64, -) -> Vec { - let Some(sel) = sel else { - return Vec::new(); +#[derive(Clone, Copy, Debug, PartialEq)] +struct CanvasRenderPlan { + dom_state: CanvasDomState, + content_top_px: f64, +} + +impl CanvasRenderPlan { + fn dom_state(self) -> CanvasDomState { + self.dom_state + } +} + +#[derive(Clone, Copy, Debug, PartialEq)] +struct CanvasPlanInput { + css_width: i32, + css_height: i32, + dpr: f64, + is_live: bool, + scroll_top_px: i32, + total_px: f64, + offset_px: f64, + vp_rows: usize, + line_height_px: f64, +} + +fn canvas_render_plan(input: CanvasPlanInput) -> CanvasRenderPlan { + let dpr = input.dpr.max(1.0); + let viewport_top = canvas_viewport_top_px( + input.is_live, + input.scroll_top_px, + input.total_px, + input.vp_rows, + input.line_height_px, + ); + let top_px = if input.is_live { + viewport_top + } else { + f64::from(input.scroll_top_px.max(0)) }; - if sel.is_empty() { - return Vec::new(); + + CanvasRenderPlan { + dom_state: CanvasDomState { + backing_width: (f64::from(input.css_width.max(1)) * dpr).ceil() as u32, + backing_height: (f64::from(input.css_height.max(1)) * dpr).ceil() as u32, + css_width: input.css_width.max(1), + css_height: input.css_height.max(1), + top_px, + }, + content_top_px: input.offset_px - viewport_top, + } +} + +fn sync_canvas_dom( + canvas: &HtmlCanvasElement, + next: CanvasDomState, + state: &Rc>>, +) { + let mut current = state.borrow_mut(); + if current.map(|s| s.backing_width) != Some(next.backing_width) { + canvas.set_width(next.backing_width); + } + if current.map(|s| s.backing_height) != Some(next.backing_height) { + canvas.set_height(next.backing_height); + } + if current + .map(|s| { + s.css_width != next.css_width + || s.css_height != next.css_height + || (s.top_px - next.top_px).abs() > 0.5 + }) + .unwrap_or(true) + { + let _ = canvas.set_attribute( + "style", + &format!( + "width:{}px;height:{}px;top:{}px;", + next.css_width, next.css_height, next.top_px + ), + ); + } + *current = Some(next); +} + +fn sync_canvas_bitmap(canvas: &HtmlCanvasElement, next: CanvasDomState) { + if canvas.width() != next.backing_width { + canvas.set_width(next.backing_width); + } + if canvas.height() != next.backing_height { + canvas.set_height(next.backing_height); + } +} + +fn canvas_2d_context(canvas: &HtmlCanvasElement) -> Option { + canvas + .get_context("2d") + .ok() + .flatten()? + .dyn_into::() + .ok() +} + +#[derive(Clone, Copy)] +struct CanvasSurfaceFrame<'a> { + dom_state: CanvasDomState, + dpr: f64, + visible: &'a [TerminalRow], + cell_size: (f64, f64), + content_top_px: f64, + cursor_pos: Option<(usize, usize)>, + options: &'a ResolvedTerminalOptions, +} + +struct RenderCanvasSurfaceRequest<'a> { + canvas_el: &'a HtmlCanvasElement, + frame: CanvasSurfaceFrame<'a>, + canvas_state: &'a Rc>>, +} + +fn render_canvas_surface(request: RenderCanvasSurfaceRequest<'_>) { + sync_canvas_dom( + request.canvas_el, + request.frame.dom_state, + request.canvas_state, + ); + let Some(ctx) = canvas_2d_context(request.canvas_el) else { + return; + }; + let _ = ctx.set_transform(request.frame.dpr, 0.0, 0.0, request.frame.dpr, 0.0, 0.0); + render_canvas(&ctx, request.frame); +} + +const WEBGL_VERTEX_SHADER: &str = r#" +attribute vec2 a_position; +attribute vec2 a_tex_coord; +attribute vec4 a_color; +varying vec2 v_tex_coord; +varying vec4 v_color; + +void main() { + gl_Position = vec4(a_position, 0.0, 1.0); + v_tex_coord = a_tex_coord; + v_color = a_color; +} +"#; + +const WEBGL_FRAGMENT_SHADER: &str = r#" +precision mediump float; +uniform sampler2D u_texture; +varying vec2 v_tex_coord; +varying vec4 v_color; + +void main() { + gl_FragColor = texture2D(u_texture, v_tex_coord) * v_color; +} +"#; + +const WEBGL_QUAD_VERTEX_COUNT: i32 = 6; +const WEBGL_VERTEX_FLOATS: usize = 8; +const WEBGL_QUAD_STRIDE_BYTES: i32 = (WEBGL_VERTEX_FLOATS * 4) as i32; +const WEBGL_QUAD_TEXCOORD_OFFSET_BYTES: i32 = 2 * 4; +const WEBGL_QUAD_COLOR_OFFSET_BYTES: i32 = 4 * 4; +const GLYPH_ATLAS_SIZE: u32 = 2048; +const GLYPH_ATLAS_PADDING: u32 = 2; + +fn webgl_quad_vertices() -> [f32; WEBGL_VERTEX_FLOATS * 6] { + [ + -1.0, -1.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, // + 1.0, -1.0, 1.0, 0.0, 1.0, 1.0, 1.0, 1.0, // + -1.0, 1.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, // + -1.0, 1.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, // + 1.0, -1.0, 1.0, 0.0, 1.0, 1.0, 1.0, 1.0, // + 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, + ] +} + +fn webgl_shader_sources() -> (&'static str, &'static str) { + (WEBGL_VERTEX_SHADER, WEBGL_FRAGMENT_SHADER) +} + +fn webgl_texture_filter() -> i32 { + WebGl2RenderingContext::NEAREST as i32 +} + +struct WebGlRendererState { + source_canvas: HtmlCanvasElement, + source_ctx: CanvasRenderingContext2d, + atlas_canvas: HtmlCanvasElement, + atlas_ctx: CanvasRenderingContext2d, + atlas: GlyphAtlas, + gl: WebGl2RenderingContext, + program: WebGlProgram, + atlas_texture: WebGlTexture, + fallback_texture: WebGlTexture, + buffer: WebGlBuffer, + position_location: u32, + tex_coord_location: u32, + color_location: u32, + sampler_location: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +struct GlyphKey { + text: String, + bold: bool, + italic: bool, + cols: usize, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +struct GlyphAtlasEntry { + x: u32, + y: u32, + width: u32, + height: u32, + cols: usize, +} + +#[derive(Debug)] +struct GlyphAtlas { + size: u32, + padding: u32, + next_x: u32, + next_y: u32, + row_height: u32, + entries: HashMap, + dirty: bool, +} + +impl GlyphAtlas { + fn new(size: u32, padding: u32) -> Self { + Self { + size, + padding, + next_x: padding, + next_y: padding, + row_height: 0, + entries: HashMap::new(), + dirty: true, + } + } + + fn reset(&mut self) { + self.next_x = self.padding; + self.next_y = self.padding; + self.row_height = 0; + self.entries.clear(); + self.dirty = true; + } + + fn get(&self, key: &GlyphKey) -> Option { + self.entries.get(key).copied() + } + + fn reserve(&mut self, key: GlyphKey, width: u32, height: u32) -> Option { + if let Some(entry) = self.get(&key) { + return Some(entry); + } + let width = width.max(1); + let height = height.max(1); + let padded_width = width + self.padding * 2; + let padded_height = height + self.padding * 2; + if padded_width > self.size || padded_height > self.size { + return None; + } + if self.next_x + padded_width > self.size { + self.next_x = self.padding; + self.next_y += self.row_height; + self.row_height = 0; + } + if self.next_y + padded_height > self.size { + return None; + } + + let entry = GlyphAtlasEntry { + x: self.next_x + self.padding, + y: self.next_y + self.padding, + width, + height, + cols: key.cols, + }; + self.next_x += padded_width; + self.row_height = self.row_height.max(padded_height); + self.entries.insert(key, entry); + self.dirty = true; + Some(entry) + } +} + +#[derive(Clone, Debug, PartialEq)] +struct GlyphCell { + key: GlyphKey, + row: usize, + col: usize, + fg: [f32; 4], +} + +#[derive(Clone, Copy, Debug, PartialEq)] +struct GlyphQuad { + x: f64, + y: f64, + width: f64, + height: f64, + uv: [f64; 4], + color: [f32; 4], +} + +fn split_segment_glyphs( + segment: &TerminalSegment, + row: usize, + start_col: usize, + options: &ResolvedTerminalOptions, +) -> Option> { + let mut cells = Vec::new(); + let mut col = start_col; + let mut used_cols = 0usize; + for grapheme in UnicodeSegmentation::graphemes(segment.text.as_str(), true) { + let mut cols = UnicodeWidthStr::width(grapheme); + if cols == 0 { + cols = 1; + } + if used_cols + cols > segment.cols { + return None; + } + let fg = if segment.is_cursor && options.cursor.style == TerminalCursorStyle::Block { + parse_css_rgb(&options.theme.cursor_foreground)? + } else { + parse_css_rgb(themed_color(&segment.style.color, options))? + }; + cells.push(GlyphCell { + key: GlyphKey { + text: grapheme.to_owned(), + bold: segment.style.bold, + italic: segment.style.italic, + cols, + }, + row, + col, + fg, + }); + col += cols; + used_cols += cols; + } + if used_cols == segment.cols { + Some(cells) + } else { + None + } +} + +fn collect_glyph_cells( + visible: &[TerminalRow], + options: &ResolvedTerminalOptions, +) -> Option> { + let mut cells = Vec::new(); + for (row_idx, row) in visible.iter().enumerate() { + let mut col = 0usize; + for segment in &row.segments { + cells.extend(split_segment_glyphs(segment, row_idx, col, options)?); + col += segment.cols; + } + } + Some(cells) +} + +fn parse_css_rgb(color: &str) -> Option<[f32; 4]> { + let color = color.trim(); + if color == "transparent" { + return Some([0.0, 0.0, 0.0, 0.0]); + } + if let Some(hex) = color.strip_prefix('#') + && hex.len() == 6 + { + let r = u8::from_str_radix(&hex[0..2], 16).ok()? as f32 / 255.0; + let g = u8::from_str_radix(&hex[2..4], 16).ok()? as f32 / 255.0; + let b = u8::from_str_radix(&hex[4..6], 16).ok()? as f32 / 255.0; + return Some([r, g, b, 1.0]); + } + if let Some(parts) = color + .strip_prefix("rgb(") + .and_then(|s| s.strip_suffix(')')) + .map(|s| s.split(',').collect::>()) + && parts.len() == 3 + { + let r = parts[0].trim().parse::().ok()? / 255.0; + let g = parts[1].trim().parse::().ok()? / 255.0; + let b = parts[2].trim().parse::().ok()? / 255.0; + return Some([r, g, b, 1.0]); + } + None +} + +fn push_quad(vertices: &mut Vec, quad: GlyphQuad, dom_state: CanvasDomState) { + let w = dom_state.css_width as f64; + let h = dom_state.css_height as f64; + let x1 = (quad.x / w * 2.0 - 1.0) as f32; + let x2 = ((quad.x + quad.width) / w * 2.0 - 1.0) as f32; + let y1 = (1.0 - quad.y / h * 2.0) as f32; + let y2 = (1.0 - (quad.y + quad.height) / h * 2.0) as f32; + let [u1, v1, u2, v2] = quad.uv.map(|v| v as f32); + let [r, g, b, a] = quad.color; + let data = [ + x1, y2, u1, v2, r, g, b, a, // + x2, y2, u2, v2, r, g, b, a, // + x1, y1, u1, v1, r, g, b, a, // + x1, y1, u1, v1, r, g, b, a, // + x2, y2, u2, v2, r, g, b, a, // + x2, y1, u2, v1, r, g, b, a, + ]; + vertices.extend_from_slice(&data); +} + +fn glyph_uv(entry: GlyphAtlasEntry, atlas_size: u32) -> [f64; 4] { + let size = f64::from(atlas_size); + [ + f64::from(entry.x) / size, + 1.0 - f64::from(entry.y) / size, + f64::from(entry.x + entry.width) / size, + 1.0 - f64::from(entry.y + entry.height) / size, + ] +} + +fn render_webgl( + canvas: &HtmlCanvasElement, + frame: CanvasSurfaceFrame<'_>, + canvas_state: &Rc>>, + webgl_state: &Rc>>, +) -> Result<(), String> { + sync_canvas_dom(canvas, frame.dom_state, canvas_state); + let mut state_ref = webgl_state.borrow_mut(); + if state_ref.is_none() { + *state_ref = Some(create_webgl_renderer(canvas)?); + } + let state = state_ref + .as_mut() + .ok_or_else(|| "missing WebGL renderer state".to_owned())?; + + sync_canvas_bitmap(&state.source_canvas, frame.dom_state); + let _ = state + .source_ctx + .set_transform(frame.dpr, 0.0, 0.0, frame.dpr, 0.0, 0.0); + render_canvas_backdrop(&state.source_ctx, frame); + let Some(cells) = collect_glyph_cells(frame.visible, frame.options) else { + render_canvas(&state.source_ctx, frame); + draw_webgl_texture_frame(state, frame.dom_state)?; + return Ok(()); + }; + + draw_webgl_texture_frame(state, frame.dom_state)?; + if draw_webgl_glyphs(state, frame, &cells).is_err() { + render_canvas(&state.source_ctx, frame); + draw_webgl_texture_frame(state, frame.dom_state)?; + } + Ok(()) +} + +fn create_webgl_renderer(canvas: &HtmlCanvasElement) -> Result { + let gl = canvas + .get_context("webgl2") + .map_err(|_| "failed to request WebGL2 context".to_owned())? + .ok_or_else(|| "WebGL2 context is unavailable".to_owned())? + .dyn_into::() + .map_err(|_| "context is not WebGL2".to_owned())?; + + let (vertex_source, fragment_source) = webgl_shader_sources(); + let vertex_shader = + compile_webgl_shader(&gl, WebGl2RenderingContext::VERTEX_SHADER, vertex_source)?; + let fragment_shader = compile_webgl_shader( + &gl, + WebGl2RenderingContext::FRAGMENT_SHADER, + fragment_source, + )?; + let program = link_webgl_program(&gl, &vertex_shader, &fragment_shader)?; + + let position_location = gl.get_attrib_location(&program, "a_position"); + let tex_coord_location = gl.get_attrib_location(&program, "a_tex_coord"); + let color_location = gl.get_attrib_location(&program, "a_color"); + if position_location < 0 || tex_coord_location < 0 || color_location < 0 { + return Err("WebGL shader attribute locations are missing".to_owned()); + } + let sampler_location = gl.get_uniform_location(&program, "u_texture"); + + let buffer = gl + .create_buffer() + .ok_or_else(|| "failed to create WebGL vertex buffer".to_owned())?; + gl.bind_buffer(WebGl2RenderingContext::ARRAY_BUFFER, Some(&buffer)); + let vertices = webgl_quad_vertices(); + unsafe { + let vertex_array = Float32Array::view(&vertices); + gl.buffer_data_with_array_buffer_view( + WebGl2RenderingContext::ARRAY_BUFFER, + vertex_array.as_ref(), + WebGl2RenderingContext::STATIC_DRAW, + ); + } + + let atlas_texture = gl + .create_texture() + .ok_or_else(|| "failed to create WebGL atlas texture".to_owned())?; + configure_webgl_texture(&gl, &atlas_texture); + let fallback_texture = gl + .create_texture() + .ok_or_else(|| "failed to create WebGL fallback texture".to_owned())?; + configure_webgl_texture(&gl, &fallback_texture); + gl.pixel_storei(WebGl2RenderingContext::UNPACK_FLIP_Y_WEBGL, 1); + gl.pixel_storei(WebGl2RenderingContext::UNPACK_PREMULTIPLY_ALPHA_WEBGL, 1); + gl.enable(WebGl2RenderingContext::BLEND); + gl.blend_func( + WebGl2RenderingContext::ONE, + WebGl2RenderingContext::ONE_MINUS_SRC_ALPHA, + ); + gl.disable(WebGl2RenderingContext::DEPTH_TEST); + gl.disable(WebGl2RenderingContext::CULL_FACE); + + let source_canvas = document() + .create_element("canvas") + .map_err(|_| "failed to create source canvas element".to_owned())? + .dyn_into::() + .map_err(|_| "source element is not a canvas".to_owned())?; + let source_ctx = canvas_2d_context(&source_canvas) + .ok_or_else(|| "failed to create source Canvas 2D context".to_owned())?; + let atlas_canvas = document() + .create_element("canvas") + .map_err(|_| "failed to create atlas canvas element".to_owned())? + .dyn_into::() + .map_err(|_| "atlas element is not a canvas".to_owned())?; + atlas_canvas.set_width(GLYPH_ATLAS_SIZE); + atlas_canvas.set_height(GLYPH_ATLAS_SIZE); + let atlas_ctx = canvas_2d_context(&atlas_canvas) + .ok_or_else(|| "failed to create atlas Canvas 2D context".to_owned())?; + atlas_ctx.clear_rect( + 0.0, + 0.0, + f64::from(GLYPH_ATLAS_SIZE), + f64::from(GLYPH_ATLAS_SIZE), + ); + + Ok(WebGlRendererState { + source_canvas, + source_ctx, + atlas_canvas, + atlas_ctx, + atlas: GlyphAtlas::new(GLYPH_ATLAS_SIZE, GLYPH_ATLAS_PADDING), + gl, + program, + atlas_texture, + fallback_texture, + buffer, + position_location: position_location as u32, + tex_coord_location: tex_coord_location as u32, + color_location: color_location as u32, + sampler_location, + }) +} + +fn configure_webgl_texture(gl: &WebGl2RenderingContext, texture: &WebGlTexture) { + gl.bind_texture(WebGl2RenderingContext::TEXTURE_2D, Some(texture)); + gl.tex_parameteri( + WebGl2RenderingContext::TEXTURE_2D, + WebGl2RenderingContext::TEXTURE_MIN_FILTER, + webgl_texture_filter(), + ); + gl.tex_parameteri( + WebGl2RenderingContext::TEXTURE_2D, + WebGl2RenderingContext::TEXTURE_MAG_FILTER, + webgl_texture_filter(), + ); + gl.tex_parameteri( + WebGl2RenderingContext::TEXTURE_2D, + WebGl2RenderingContext::TEXTURE_WRAP_S, + WebGl2RenderingContext::CLAMP_TO_EDGE as i32, + ); + gl.tex_parameteri( + WebGl2RenderingContext::TEXTURE_2D, + WebGl2RenderingContext::TEXTURE_WRAP_T, + WebGl2RenderingContext::CLAMP_TO_EDGE as i32, + ); +} + +fn compile_webgl_shader( + gl: &WebGl2RenderingContext, + shader_type: u32, + source: &str, +) -> Result { + let shader = gl + .create_shader(shader_type) + .ok_or_else(|| "failed to create WebGL shader".to_owned())?; + gl.shader_source(&shader, source); + gl.compile_shader(&shader); + if gl + .get_shader_parameter(&shader, WebGl2RenderingContext::COMPILE_STATUS) + .as_bool() + .unwrap_or(false) + { + Ok(shader) + } else { + Err(gl + .get_shader_info_log(&shader) + .unwrap_or_else(|| "WebGL shader compile failed".to_owned())) + } +} + +fn link_webgl_program( + gl: &WebGl2RenderingContext, + vertex_shader: &WebGlShader, + fragment_shader: &WebGlShader, +) -> Result { + let program = gl + .create_program() + .ok_or_else(|| "failed to create WebGL program".to_owned())?; + gl.attach_shader(&program, vertex_shader); + gl.attach_shader(&program, fragment_shader); + gl.link_program(&program); + if gl + .get_program_parameter(&program, WebGl2RenderingContext::LINK_STATUS) + .as_bool() + .unwrap_or(false) + { + Ok(program) + } else { + Err(gl + .get_program_info_log(&program) + .unwrap_or_else(|| "WebGL program link failed".to_owned())) + } +} + +fn bind_webgl_vertex_layout(gl: &WebGl2RenderingContext, state: &WebGlRendererState) { + gl.bind_buffer(WebGl2RenderingContext::ARRAY_BUFFER, Some(&state.buffer)); + gl.enable_vertex_attrib_array(state.position_location); + gl.vertex_attrib_pointer_with_i32( + state.position_location, + 2, + WebGl2RenderingContext::FLOAT, + false, + WEBGL_QUAD_STRIDE_BYTES, + 0, + ); + gl.enable_vertex_attrib_array(state.tex_coord_location); + gl.vertex_attrib_pointer_with_i32( + state.tex_coord_location, + 2, + WebGl2RenderingContext::FLOAT, + false, + WEBGL_QUAD_STRIDE_BYTES, + WEBGL_QUAD_TEXCOORD_OFFSET_BYTES, + ); + gl.enable_vertex_attrib_array(state.color_location); + gl.vertex_attrib_pointer_with_i32( + state.color_location, + 4, + WebGl2RenderingContext::FLOAT, + false, + WEBGL_QUAD_STRIDE_BYTES, + WEBGL_QUAD_COLOR_OFFSET_BYTES, + ); +} + +fn upload_webgl_vertices(gl: &WebGl2RenderingContext, vertices: &[f32]) { + unsafe { + let vertex_array = Float32Array::view(vertices); + gl.buffer_data_with_array_buffer_view( + WebGl2RenderingContext::ARRAY_BUFFER, + vertex_array.as_ref(), + WebGl2RenderingContext::DYNAMIC_DRAW, + ); + } +} + +fn draw_webgl_texture_frame( + state: &WebGlRendererState, + dom_state: CanvasDomState, +) -> Result<(), String> { + let gl = &state.gl; + gl.viewport( + 0, + 0, + dom_state.backing_width as i32, + dom_state.backing_height as i32, + ); + gl.clear_color(0.0, 0.0, 0.0, 0.0); + gl.clear(WebGl2RenderingContext::COLOR_BUFFER_BIT); + gl.use_program(Some(&state.program)); + + bind_webgl_vertex_layout(gl, state); + upload_webgl_vertices(gl, &webgl_quad_vertices()); + + gl.active_texture(WebGl2RenderingContext::TEXTURE0); + gl.bind_texture( + WebGl2RenderingContext::TEXTURE_2D, + Some(&state.fallback_texture), + ); + gl.uniform1i(state.sampler_location.as_ref(), 0); + gl.tex_image_2d_with_u32_and_u32_and_html_canvas_element( + WebGl2RenderingContext::TEXTURE_2D, + 0, + WebGl2RenderingContext::RGBA as i32, + WebGl2RenderingContext::RGBA, + WebGl2RenderingContext::UNSIGNED_BYTE, + &state.source_canvas, + ) + .map_err(|_| "failed to upload source canvas to WebGL fallback texture".to_owned())?; + gl.draw_arrays( + WebGl2RenderingContext::TRIANGLES, + 0, + WEBGL_QUAD_VERTEX_COUNT, + ); + Ok(()) +} + +fn draw_webgl_glyphs( + state: &mut WebGlRendererState, + frame: CanvasSurfaceFrame<'_>, + cells: &[GlyphCell], +) -> Result<(), String> { + ensure_glyphs_in_atlas(state, frame, cells)?; + if state.atlas.dirty { + state.gl.bind_texture( + WebGl2RenderingContext::TEXTURE_2D, + Some(&state.atlas_texture), + ); + state + .gl + .tex_image_2d_with_u32_and_u32_and_html_canvas_element( + WebGl2RenderingContext::TEXTURE_2D, + 0, + WebGl2RenderingContext::RGBA as i32, + WebGl2RenderingContext::RGBA, + WebGl2RenderingContext::UNSIGNED_BYTE, + &state.atlas_canvas, + ) + .map_err(|_| "failed to upload glyph atlas texture".to_owned())?; + state.atlas.dirty = false; + } + + let mut vertices = Vec::with_capacity(cells.len() * WEBGL_VERTEX_FLOATS * 6); + for cell in cells { + let Some(entry) = state.atlas.get(&cell.key) else { + continue; + }; + let x = cell.col as f64 * frame.cell_size.0; + let y = frame.content_top_px + cell.row as f64 * frame.cell_size.1; + push_quad( + &mut vertices, + GlyphQuad { + x, + y, + width: entry.cols as f64 * frame.cell_size.0, + height: frame.cell_size.1, + uv: glyph_uv(entry, state.atlas.size), + color: cell.fg, + }, + frame.dom_state, + ); + } + if vertices.is_empty() { + return Ok(()); + } + + let gl = &state.gl; + gl.use_program(Some(&state.program)); + bind_webgl_vertex_layout(gl, state); + upload_webgl_vertices(gl, &vertices); + gl.active_texture(WebGl2RenderingContext::TEXTURE0); + gl.bind_texture( + WebGl2RenderingContext::TEXTURE_2D, + Some(&state.atlas_texture), + ); + gl.uniform1i(state.sampler_location.as_ref(), 0); + gl.draw_arrays( + WebGl2RenderingContext::TRIANGLES, + 0, + (vertices.len() / WEBGL_VERTEX_FLOATS) as i32, + ); + Ok(()) +} + +fn ensure_glyphs_in_atlas( + state: &mut WebGlRendererState, + frame: CanvasSurfaceFrame<'_>, + cells: &[GlyphCell], +) -> Result<(), String> { + for attempt in 0..2 { + if attempt == 1 { + state.atlas.reset(); + state.atlas_ctx.clear_rect( + 0.0, + 0.0, + f64::from(state.atlas.size), + f64::from(state.atlas.size), + ); + } + let mut ok = true; + for cell in cells { + if state.atlas.get(&cell.key).is_none() + && draw_glyph_into_atlas(state, frame, &cell.key)?.is_none() + { + ok = false; + break; + } + } + if ok { + return Ok(()); + } + } + Err("glyph atlas is full".to_owned()) +} + +fn draw_glyph_into_atlas( + state: &mut WebGlRendererState, + frame: CanvasSurfaceFrame<'_>, + key: &GlyphKey, +) -> Result, String> { + let dpr = frame.dpr.max(1.0); + let width = (key.cols as f64 * frame.cell_size.0 * dpr).ceil() as u32; + let height = (frame.cell_size.1 * dpr).ceil() as u32; + let Some(entry) = state.atlas.reserve(key.clone(), width, height) else { + return Ok(None); + }; + let style = TerminalStyle { + color: "#ffffff".to_owned(), + background: "transparent".to_owned(), + bold: key.bold, + italic: key.italic, + underline: false, + }; + let ctx = &state.atlas_ctx; + ctx.save(); + ctx.begin_path(); + ctx.rect( + f64::from(entry.x), + f64::from(entry.y), + f64::from(entry.width), + f64::from(entry.height), + ); + ctx.clip(); + ctx.set_fill_style_str("rgba(0,0,0,0)"); + ctx.clear_rect( + f64::from(entry.x), + f64::from(entry.y), + f64::from(entry.width), + f64::from(entry.height), + ); + ctx.set_text_baseline("top"); + ctx.set_font(&terminal_font_with_px( + &style, + frame.options.font_size_px * dpr, + &frame.options.font_family, + )); + ctx.set_fill_style_str("#ffffff"); + ctx.fill_text(&key.text, f64::from(entry.x), f64::from(entry.y)) + .map_err(|_| "failed to draw glyph into atlas".to_owned())?; + ctx.restore(); + Ok(Some(entry)) +} + +fn render_canvas_backdrop(ctx: &CanvasRenderingContext2d, frame: CanvasSurfaceFrame<'_>) { + let (cell_w, cell_h) = frame.cell_size; + let canvas_width = ctx.canvas().map(|c| c.width() as f64).unwrap_or(0.0); + let canvas_height = ctx.canvas().map(|c| c.height() as f64).unwrap_or(0.0); + ctx.clear_rect(0.0, 0.0, canvas_width, canvas_height); + + for (row_idx, row) in frame.visible.iter().enumerate() { + let y = frame.content_top_px + row_idx as f64 * cell_h; + let mut col = 0usize; + for segment in &row.segments { + let x = col as f64 * cell_w; + let width = segment.cols as f64 * cell_w; + if segment.is_cursor { + ctx.set_fill_style_str(&frame.options.theme.cursor_background); + let rect = + cursor_shape_rect(frame.options.cursor.style, x, y, width.max(cell_w), cell_h); + ctx.fill_rect(rect.x, rect.y, rect.width, rect.height); + } else if set_fill_color(ctx, themed_color(&segment.style.background, frame.options)) { + ctx.fill_rect(x, y, width, cell_h); + } + if segment.style.underline { + if segment.is_cursor { + ctx.set_fill_style_str(&frame.options.theme.cursor_foreground); + } else { + set_fill_color(ctx, themed_color(&segment.style.color, frame.options)); + } + ctx.fill_rect(x, y + cell_h - 1.0, width, 1.0); + } + col += segment.cols; + } + } +} + +fn cursor_cell_text(row: &TerminalRow, cursor_col: usize) -> Option { + let mut col = 0usize; + for segment in &row.segments { + let start = col; + let end = col + segment.cols; + if cursor_col >= start && cursor_col < end { + return Some(segment.text.clone()); + } + col = end; + } + None +} + +/// Apply a color string (already resolved to `#rrggbb` or `transparent`) as the +/// Canvas fill style. Returns `true` if a solid color was set; `transparent` +/// is a no-op. +fn set_fill_color(ctx: &CanvasRenderingContext2d, color: &str) -> bool { + let Some(color) = canvas_fill_style(color) else { + return false; + }; + ctx.set_fill_style_str(color); + true +} + +/// Draw the visible terminal rows onto a Canvas 2D context. The context is +/// assumed to be scaled for DPR already; coordinates are in CSS pixels. +fn render_canvas(ctx: &CanvasRenderingContext2d, frame: CanvasSurfaceFrame<'_>) { + let (cell_w, cell_h) = frame.cell_size; + + // Clear the entire canvas. + let canvas_width = ctx.canvas().map(|c| c.width() as f64).unwrap_or(0.0); + let canvas_height = ctx.canvas().map(|c| c.height() as f64).unwrap_or(0.0); + ctx.clear_rect(0.0, 0.0, canvas_width, canvas_height); + + ctx.set_text_baseline("top"); + + // First pass: backgrounds. + for (row_idx, row) in frame.visible.iter().enumerate() { + let y = frame.content_top_px + row_idx as f64 * cell_h; + let mut col = 0usize; + for segment in &row.segments { + let x = col as f64 * cell_w; + let width = segment.cols as f64 * cell_w; + if set_fill_color(ctx, themed_color(&segment.style.background, frame.options)) { + ctx.fill_rect(x, y, width, cell_h); + } + col += segment.cols; + } + } + + // Collect cursor cell info for the second pass. + let mut cursor_cell: Option<(f64, f64, String)> = None; + if let Some((cursor_row, cursor_col)) = frame.cursor_pos + && let Some(row) = frame.visible.get(cursor_row) + && let Some(text) = cursor_cell_text(row, cursor_col) + { + cursor_cell = Some(( + cursor_col as f64 * cell_w, + frame.content_top_px + cursor_row as f64 * cell_h, + text, + )); + } + + // Second pass: text and underlines. + for (row_idx, row) in frame.visible.iter().enumerate() { + let y = frame.content_top_px + row_idx as f64 * cell_h; + let mut col = 0usize; + for segment in &row.segments { + let x = col as f64 * cell_w; + let width = segment.cols as f64 * cell_w; + + ctx.set_font(&terminal_font(&segment.style, frame.options)); + set_fill_color(ctx, themed_color(&segment.style.color, frame.options)); + // Baseline offset so text sits in the cell like the DOM line-box. + let _ = ctx.fill_text(&segment.text, x, y); + + if segment.style.underline { + ctx.fill_rect(x, y + cell_h - 1.0, width, 1.0); + } + + col += segment.cols; + } + } + + // Third pass: cursor overlay. + if let Some((cursor_x, cursor_y, text)) = cursor_cell { + ctx.set_fill_style_str(&frame.options.theme.cursor_background); + let rect = cursor_shape_rect( + frame.options.cursor.style, + cursor_x, + cursor_y, + cell_w, + cell_h, + ); + ctx.fill_rect(rect.x, rect.y, rect.width, rect.height); + if frame.options.cursor.style == TerminalCursorStyle::Block { + ctx.set_fill_style_str(&frame.options.theme.cursor_foreground); + ctx.set_font(&terminal_font(&TerminalStyle::default(), frame.options)); + let _ = ctx.fill_text(&text, cursor_x, cursor_y); + } } - let ((sr, sc), (er, ec)) = sel.ordered(); - let first = sr.max(window_start); - let last = er.min(window_end.saturating_sub(1)); - let mut rects = Vec::new(); - for r in first..=last { - if r >= window_end { - break; - } - let (col_a, col_b) = if sr == er { - (sc, ec) - } else if r == sr { - (sc, cols) - } else if r == er { - (0, ec) - } else { - (0, cols) - }; - let col_b = col_b.max(col_a); - rects.push(SelectionRect { - top_px: (r - window_start) as f64 * SCROLL_LINE_HEIGHT, - left_px: col_a as f64 * cell_w, - width_px: (col_b - col_a) as f64 * cell_w, - }); - } - rects } -/// Render the visible rows using absolute positioning inside a fixed-height -/// container. -/// /// The outer `.terminal-grid` is sized to the FULL content height /// (`total_px`), constant across re-renders, so swapping the visible window /// never changes the scroll container's geometry — the browser keeps the @@ -1229,6 +3019,8 @@ fn render_spaced_view( offset_px: f64, visible: Vec, rects: Vec, + search_rects: Vec, + link_rects: Vec, ) -> impl IntoView { view! {
+ {search_rects.into_iter().map(render_search_rect).collect_view()} {rects.into_iter().map(render_selection_rect).collect_view()} + {link_rects.into_iter().map(render_link_rect).collect_view()} {visible.into_iter().map(render_terminal_row).collect_view()}
} } +fn render_canvas_scroll_layers( + total_px: f64, + offset_px: f64, + rects: Vec, + search_rects: Vec, + link_rects: Vec, +) -> impl IntoView { + view! { + +
+ {search_rects.into_iter().map(render_search_rect).collect_view()} + {rects.into_iter().map(render_selection_rect).collect_view()} + {link_rects.into_iter().map(render_link_rect).collect_view()} +
+ } +} + fn render_selection_rect(rect: SelectionRect) -> impl IntoView { let style = format!( "top:{}px;left:{}px;width:{}px;height:{}px;", - rect.top_px, rect.left_px, rect.width_px, SCROLL_LINE_HEIGHT + rect.top_px, rect.left_px, rect.width_px, rect.height_px ); view! { } } +fn render_search_rect(rect: SelectionRect) -> impl IntoView { + let style = format!( + "top:{}px;left:{}px;width:{}px;height:{}px;", + rect.top_px, rect.left_px, rect.width_px, rect.height_px + ); + view! { } +} + +fn render_link_rect(rect: TerminalLinkRect) -> impl IntoView { + let url = rect.url; + let label = url.clone(); + let style = format!( + "top:{}px;left:{}px;width:{}px;height:{}px;", + rect.rect.top_px, rect.rect.left_px, rect.rect.width_px, rect.rect.height_px + ); + view! { + + } +} + +#[derive(Clone, Debug, PartialEq)] +struct TerminalLinkRect { + rect: SelectionRect, + url: String, +} + +fn search_match_rects( + search_match: Option, + window_start: usize, + window_end: usize, + cell_w: f64, + line_height_px: f64, +) -> Vec { + let Some(mat) = search_match else { + return Vec::new(); + }; + if mat.row < window_start || mat.row >= window_end || mat.end_col <= mat.start_col { + return Vec::new(); + } + + vec![SelectionRect { + top_px: (mat.row - window_start) as f64 * line_height_px.max(1.0), + left_px: mat.start_col as f64 * cell_w, + width_px: (mat.end_col - mat.start_col) as f64 * cell_w, + height_px: line_height_px.max(1.0), + }] +} + +fn link_rects( + links: Vec, + window_start: usize, + cell_w: f64, + line_height_px: f64, +) -> Vec { + links + .into_iter() + .filter(|link| link.end_col > link.start_col) + .map(|link| TerminalLinkRect { + rect: SelectionRect { + top_px: (link.row - window_start) as f64 * line_height_px.max(1.0), + left_px: link.start_col as f64 * cell_w, + width_px: (link.end_col - link.start_col) as f64 * cell_w, + height_px: line_height_px.max(1.0), + }, + url: link.url, + }) + .collect() +} + fn render_terminal_row(row: TerminalRow) -> impl IntoView { view! {
@@ -1277,8 +3175,16 @@ fn render_terminal_segment(segment: TerminalSegment) -> impl IntoView { 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" }, + if segment.style.italic { + "italic" + } else { + "normal" + }, + if segment.style.underline { + "underline" + } else { + "none" + }, ); view! { @@ -1344,16 +3250,12 @@ fn schedule_frame( /// writes nothing (so it can't interrupt the user's momentum scrolling). The /// target position is recorded in `programmatic_scroll_top` so the scroll /// handler can recognise the resulting scroll event as our own. -fn sync_scroll_to_bottom( - terminal_ref: NodeRef, - programmatic_scroll_top: RwSignal, -) { +fn sync_scroll_to_bottom(terminal_ref: NodeRef, programmatic_scroll_top: RwSignal) { let callback = Closure::::wrap(Box::new(move || { let Some(element) = terminal_ref.get_untracked() else { return; }; - let max_scroll = - (element.scroll_height() - element.client_height()).max(0); + let 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); @@ -1377,8 +3279,7 @@ fn sync_scroll_to_position( let Some(element) = terminal_ref.get_untracked() else { return; }; - let max_scroll = - (element.scroll_height() - element.client_height()).max(0); + 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 { @@ -1392,6 +3293,40 @@ fn sync_scroll_to_position( callback.forget(); } +#[derive(Clone, Copy)] +struct SearchScrollContext { + total_rows: usize, + viewport_rows: usize, + line_height_px: f64, + terminal_ref: NodeRef, + scroll_top: RwSignal, + view_mode: RwSignal, + programmatic_scroll_top: RwSignal, +} + +fn scroll_match_into_view(mat: &TerminalSearchMatch, ctx: SearchScrollContext) { + if ctx.total_rows == 0 { + return; + } + + let viewport_rows = ctx.viewport_rows.max(1); + let half_view = viewport_rows / 2; + let first_row = mat.row.saturating_sub(half_view); + let max_first_row = ctx.total_rows.saturating_sub(viewport_rows); + let first_row = first_row.min(max_first_row); + let target = (first_row as f64 * ctx.line_height_px.max(1.0)).round() as i32; + + ctx.view_mode.set(if first_row >= max_first_row { + ViewMode::Live + } else { + ViewMode::History { + scroll_offset: first_row, + } + }); + ctx.scroll_top.set(target); + sync_scroll_to_position(ctx.terminal_ref, target, ctx.programmatic_scroll_top); +} + // --------------------------------------------------------------------------- // Network helpers // --------------------------------------------------------------------------- @@ -1407,15 +3342,22 @@ fn websocket_url() -> String { format!("{scheme}://{host}{}/terminal/ws", SiteConfig::BASE_PATH) } -fn send_terminal_message( - socket: &WebSocket, - message: &ClientTerminalMessage, -) { +fn send_terminal_message(socket: &WebSocket, message: &ClientTerminalMessage) { if let Ok(payload) = serde_json::to_string(message) { let _ = socket.send_with_str(&payload); } } +fn send_input_message(socket: &WebSocket, data: String, on_data: Option>) { + if data.is_empty() { + return; + } + if let Some(on_data) = on_data { + on_data.run(data.clone()); + } + send_terminal_message(socket, &ClientTerminalMessage::Input { data }); +} + fn send_resize_message(socket: &WebSocket, viewport: TerminalViewport) { send_terminal_message( socket, @@ -1435,33 +3377,30 @@ fn send_resize_message(socket: &WebSocket, viewport: TerminalViewport) { fn measure_terminal_viewport( terminal_ref: NodeRef, measure_ref: NodeRef, + options: &ResolvedTerminalOptions, ) -> TerminalViewport { let Some(element) = terminal_ref.get_untracked() else { - return TerminalViewport::default(); + return terminal_viewport_for_options(options); }; let Some(element) = element.dyn_ref::() else { - return TerminalViewport::default(); + return terminal_viewport_for_options(options); }; - measure_terminal_viewport_from_rect( - f64::from(element.client_width().max(0)), - f64::from(element.client_height().max(0)), - measure_ref, - ) + let (width, height) = terminal_content_viewport_size(element); + measure_terminal_viewport_from_rect(f64::from(width), f64::from(height), measure_ref, options) } fn measure_terminal_viewport_from_rect( width: f64, height: f64, measure_ref: NodeRef, + options: &ResolvedTerminalOptions, ) -> TerminalViewport { let (cell_width, cell_height) = measure_cell_size(measure_ref); let safe_width = (width - 2.0).max(cell_width); let safe_height = (height - 2.0).max(cell_height); - let cols = - ((safe_width / cell_width).floor().max(1.0) as u16).max(MIN_COLS); - let rows = - ((safe_height / cell_height).floor().max(1.0) as u16).max(MIN_ROWS); + 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, @@ -1486,9 +3425,14 @@ fn measure_cell_size(measure_ref: NodeRef) -> (f64, f64) { 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; ( - width / (MEASURE_SAMPLE_TEXT.chars().count() as f64), - height, + (element.client_width() - padding).max(1), + (element.client_height() - padding).max(1), ) } @@ -1517,13 +3461,6 @@ fn point_to_cell( ) } -/// Focus the hidden IME textarea so keyboard input and composition go there. -fn focus_ime_textarea(ime_ref: &NodeRef) { - if let Some(element) = ime_ref.get_untracked() { - let _ = element.focus(); - } -} - /// Write text to the system clipboard (fire-and-forget). Called from a user /// gesture (Ctrl-Shift-C keydown), which browsers allow without a prompt in /// secure contexts (localhost counts as secure). @@ -1553,6 +3490,7 @@ fn send_input_data( mode_signal: RwSignal, terminal_ref: NodeRef, prog_scroll: RwSignal, + on_data: Option>, ) { if data.is_empty() { return; @@ -1564,274 +3502,15 @@ fn send_input_data( mode_signal.set(ViewMode::Live); sync_scroll_to_bottom(terminal_ref, prog_scroll); } - send_terminal_message( - &socket, - &ClientTerminalMessage::Input { data }, - ); -} - -/// Read the current value of the hidden IME textarea. -fn ime_ref_text(ime_ref: &NodeRef) -> Option { - ime_ref.get_untracked().map(|t| t.value()) -} - -/// Clear the hidden IME textarea after sending composed/typed text. -fn clear_ime_textarea(ime_ref: &NodeRef) { - if let Some(t) = ime_ref.get_untracked() { - t.set_value(""); - } -} - -// --------------------------------------------------------------------------- -// Key mapping -// --------------------------------------------------------------------------- - -/// Number of rows to scroll for a wheel event, from its delta and delta mode. -/// -/// `delta_mode`: 0 = pixels, 1 = lines, 2 = pages. We translate to a -/// positive row count (the direction is taken from the sign separately). One -/// notch is clamped to scroll at least 1 and at most `max_rows`. -fn wheel_rows_for_delta(delta_y: f64, delta_mode: u32, max_rows: usize) -> usize { - const WHEEL_PIXELS_PER_ROW: f64 = SCROLL_LINE_HEIGHT; - const WHEEL_LINES_PER_PAGE: f64 = 8.0; - - let magnitude = delta_y.abs(); - if magnitude == 0.0 { - return 0; - } - let rows = match delta_mode { - 1 => magnitude, // already in lines - 2 => magnitude * WHEEL_LINES_PER_PAGE, // pages - _ => magnitude / WHEEL_PIXELS_PER_ROW, // pixels - }; - let rows = rows.round() as usize; - rows.clamp(1, max_rows.max(1)) -} - -// --------------------------------------------------------------------------- -// Mouse reporting -// --------------------------------------------------------------------------- - -/// What kind of mouse action is being reported. Wheel ticks are reported as -/// a `Press` of button 64/65. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum MouseAction { - Press, - Release, - Motion, -} - -/// Map a browser mouse button index (`MouseEvent.button`) to the terminal's -/// low button code, or `None` for buttons we don't report (e.g. button 4+). -/// Left=0, middle=1, right=2 — same numbering xterm uses. -fn mouse_button_base(browser_button: i16) -> Option { - match browser_button { - 0..=2 => Some(browser_button as u8), - _ => None, - } -} - -/// OR the modifier bits (shift=4, meta=8, control=16) into a mouse button -/// code, matching the xterm mouse encoding. -fn with_mouse_modifiers(button: u8, shift: bool, meta: bool, ctrl: bool) -> u8 { - let mut b = button; - if shift { - b |= 4; - } - if meta { - b |= 8; - } - if ctrl { - b |= 16; - } - b -} - -/// Encode a mouse event as the bytes a terminal would receive, per the app's -/// requested encoding. `button` is the fully-composed low code (button + -/// modifiers + motion/wheel bits); `col`/`row` are 0-based grid coordinates. -/// -/// SGR (1006) is fully correct and ASCII-only. The legacy Default/UTF-8 -/// encodings are correct for coordinates ≤ 94 (the ASCII-safe range); beyond -/// that they are clamped, which matches what most terminals do for large -/// screens — and modern apps (vim, htop, tmux) negotiate SGR anyway. -fn encode_mouse_report( - button: u8, - action: MouseAction, - col: usize, - row: usize, - encoding: MouseProtocolEncoding, -) -> String { - match encoding { - MouseProtocolEncoding::Sgr => { - let suffix = if action == MouseAction::Release { - 'm' - } else { - 'M' - }; - format!("\u{1b}[<{button};{};{}{suffix}", col + 1, row + 1) - } - MouseProtocolEncoding::Default | MouseProtocolEncoding::Utf8 => { - // Legacy: ESC[M followed by three values each offset by +32. - // Release is reported as button 3 in this encoding. - let b = if action == MouseAction::Release { 3 } else { button }; - let c = col.min(94) as u8 + 1; - let r = row.min(94) as u8 + 1; - format!( - "\u{1b}M{}{}{}", - char::from_u32(u32::from(b) + 32).unwrap_or(' '), - char::from_u32(u32::from(c) + 32).unwrap_or(' '), - char::from_u32(u32::from(r) + 32).unwrap_or(' '), - ) - } - } -} - -/// Map a pixel position inside the `.terminal-screen` content box to 0-based -/// grid coordinates (row, col), clamped to the viewport. Unlike `point_to_cell` -/// this does NOT add `scroll_top`: mouse reports are relative to the grid the -/// app is currently drawing (the visible screen), not the scrollback buffer. -fn pixel_to_grid( - local_x: f64, - local_y: f64, - cell_w: f64, - cell_h: f64, - cols: usize, - rows: usize, -) -> (usize, usize) { - let x = (local_x - TERMINAL_PADDING).max(0.0); - let y = (local_y - TERMINAL_PADDING).max(0.0); - let col = ((x / cell_w.max(1.0)).floor() as usize).min(cols.saturating_sub(1)); - let row = ((y / cell_h.max(1.0)).floor() as usize).min(rows.saturating_sub(1)); - (row, col) -} - -/// Prepare clipboard text for sending to the PTY. -/// -/// Normalises line endings to CR (`\r`), the byte a terminal expects for line -/// submission (Enter sends `\r`). When the application enabled bracketed paste, -/// wraps the text in `ESC[200~ … ESC[201~`. -fn prepare_paste(raw: &str, bracketed: bool) -> String { - let normalized = raw.replace("\r\n", "\r").replace('\n', "\r"); - if bracketed { - format!("\u{1b}[200~{normalized}\u{1b}[201~") - } else { - normalized - } -} - -/// Build a cursor/nav key sequence, SS3 (`ESC O `) when the app is in -/// application-cursor mode, else CSI (`ESC [ `). -fn cursor_seq(final_byte: char, app_cursor: bool) -> String { - if app_cursor { - format!("\u{1b}O{final_byte}") - } else { - format!("\u{1b}[{final_byte}") - } -} - -/// Pure key-table: map a key + modifier state + application-cursor flag to -/// the bytes a terminal would receive for the same key. Split out from -/// `map_key_to_terminal_input` so the entire table is unit-testable without -/// constructing a `KeyboardEvent` (which isn't easy in native tests). -fn key_to_bytes( - key: &str, - ctrl: bool, - alt: bool, - meta: bool, - app_cursor: bool, -) -> Option { - if ctrl && !alt { - // Modifier keys like "Control" / "Shift" report ctrl=true but are not - // real Ctrl+letter combos; pressing Ctrl alone must not send Ctrl-C. - if key.chars().count() != 1 { - return None; - } - let c = key.chars().next()?; - let code = match c { - 'a'..='z' => (c.to_ascii_uppercase() as u8) & 0x1f, - 'A'..='Z' => (c as u8) & 0x1f, - '@' => 0x00, - '[' => 0x1b, - '\\' => 0x1c, - ']' => 0x1d, - '^' => 0x1e, - '_' => 0x1f, - _ => return None, - }; - // 0x1a (Ctrl-Z) and 0x1c (Ctrl-\) are caught by the shell's stty as - // SIGTSTP / SIGQUIT and would suspend / kill the foreground process. - // Filter them so accidental keypresses don't blow up the user's work; - // Ctrl-C (0x03) is kept so the user can still interrupt intentionally. - if matches!(code, 0x1a | 0x1c) { - return None; - } - return Some((code as char).to_string()); - } - - if let (true, Some(c)) = (alt && !ctrl && !meta, key.chars().next()) { - return Some(format!("\u{1b}{c}")); - } - - match key { - "Enter" => Some("\r".to_owned()), - "Backspace" => Some("\u{7f}".to_owned()), - "Tab" => Some("\t".to_owned()), - "Escape" => Some("\u{1b}".to_owned()), - "ArrowUp" => Some(cursor_seq('A', app_cursor)), - "ArrowDown" => Some(cursor_seq('B', app_cursor)), - "ArrowRight" => Some(cursor_seq('C', app_cursor)), - "ArrowLeft" => Some(cursor_seq('D', app_cursor)), - "Home" => Some(cursor_seq('H', app_cursor)), - "End" => Some(cursor_seq('F', app_cursor)), - "Insert" => Some("\u{1b}[2~".to_owned()), - "Delete" => Some("\u{1b}[3~".to_owned()), - "PageUp" => Some("\u{1b}[5~".to_owned()), - "PageDown" => Some("\u{1b}[6~".to_owned()), - "F1" => Some("\u{1b}OP".to_owned()), - "F2" => Some("\u{1b}OQ".to_owned()), - "F3" => Some("\u{1b}OR".to_owned()), - "F4" => Some("\u{1b}OS".to_owned()), - "F5" => Some("\u{1b}[15~".to_owned()), - "F6" => Some("\u{1b}[17~".to_owned()), - "F7" => Some("\u{1b}[18~".to_owned()), - "F8" => Some("\u{1b}[19~".to_owned()), - "F9" => Some("\u{1b}[20~".to_owned()), - "F10" => Some("\u{1b}[21~".to_owned()), - "F11" => Some("\u{1b}[23~".to_owned()), - "F12" => Some("\u{1b}[24~".to_owned()), - _ if key.chars().count() == 1 && !meta => Some(key.to_owned()), - _ => None, - } -} - -/// Map a browser keyboard event to the bytes a terminal would receive for -/// the same key. Thin wrapper around `key_to_bytes`. -fn map_key_to_terminal_input( - event: &KeyboardEvent, - app_cursor: bool, -) -> Option { - key_to_bytes( - &event.key(), - event.ctrl_key(), - event.alt_key(), - event.meta_key(), - app_cursor, - ) -} - -/// Whether a keydown event represents a regular printable character that -/// should be handled by the IME textarea's `input` event, rather than sent -/// directly from `keydown`. This lets the browser manage IME composition, -/// dead keys, and upper/lower case before we see the final text. -fn is_regular_text_input(key: &str, ctrl: bool, alt: bool, meta: bool) -> bool { - key.chars().count() == 1 && !ctrl && !alt && !meta + send_input_message(&socket, data, on_data); } #[cfg(test)] mod tests { use super::*; + use crate::terminal::core::MouseProtocolEncoding; use crate::terminal::core::TerminalCore; + use crate::terminal::keyboard::key_to_bytes; fn core_with_lines(n: usize) -> TerminalCore { let mut core = TerminalCore::new(24, 80); @@ -1841,26 +3520,47 @@ mod tests { 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(SCROLL_LINE_HEIGHT, 0, MAX), 1); + 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(SCROLL_LINE_HEIGHT * 3.0, 0, MAX), 3); + 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), 1); + 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), 3); + 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), 8); + 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(-SCROLL_LINE_HEIGHT * 2.0, 0, MAX), 2); + 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), 0); + 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), 12); + assert_eq!( + wheel_rows_for_delta(100_000.0, 0, MAX, DEFAULT_LINE_HEIGHT_PX), + 12 + ); } #[test] @@ -1868,18 +3568,24 @@ mod tests { // A coalesced batch sums many small wheel deltas into a single big // scroll. The per-batch cap (max_rows) should be respected so a // single huge swipe doesn't fly past the bottom instantly. - let big_sum = SCROLL_LINE_HEIGHT * 80.0; // would be 80 rows unclamped + 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), 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), 24); + 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(SCROLL_LINE_HEIGHT * 5.0, 0, 24), + 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), 1); + assert_eq!(wheel_rows_for_delta(1.0, 0, 24, DEFAULT_LINE_HEIGHT_PX), 1); } #[test] @@ -1888,11 +3594,17 @@ mod tests { // 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)); + 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)); + 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)); } @@ -1904,21 +3616,32 @@ mod tests { 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), + 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), + 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), + 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), (0, 0)); + assert_eq!( + pixel_to_grid(0.0, 0.0, cw, ch, cols, rows, TERMINAL_PADDING), + (0, 0) + ); } #[test] @@ -1954,12 +3677,24 @@ mod tests { ); // Release is always button 3 in Default encoding. assert_eq!( - encode_mouse_report(0, MouseAction::Release, 0, 0, MouseProtocolEncoding::Default), + 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), + encode_mouse_report( + 0, + MouseAction::Press, + 200, + 100, + MouseProtocolEncoding::Default + ), "\u{1b}M \u{7f}\u{7f}" ); } @@ -1988,16 +3723,22 @@ mod tests { 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); + 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 * SCROLL_LINE_HEIGHT); + 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); + 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 @@ -2005,13 +3746,404 @@ mod tests { assert_eq!(rects[2].width_px, 3.0 * cw); // Selection entirely above the window → clipped out. - let sel = Some(Selection { anchor: (1, 0), head: (2, 5) }); - assert!(selection_rects(sel, 10, 30, cols, cw).is_empty()); + 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).is_empty()); - assert!(selection_rects(None, 0, 30, cols, cw).is_empty()); + 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_font_matches_canvas_style_flags() { + let options = default_options(); + let base = terminal_font(&TerminalStyle::default(), &options); + assert!(base.starts_with("13px ")); + assert!(base.contains("\"JetBrains Mono\"")); + assert!(!base.starts_with("bold")); + assert!(!base.starts_with("italic")); + + let style = TerminalStyle { + bold: true, + italic: true, + ..TerminalStyle::default() + }; + let bold_italic = terminal_font(&style, &options); + assert!(bold_italic.starts_with("bold italic 13px ")); + + let style = TerminalStyle { + italic: true, + ..TerminalStyle::default() + }; + let italic = terminal_font(&style, &options); + assert!(italic.starts_with("italic 13px ")); + } + + #[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 cursor_shape_rect_matches_style() { + let block = cursor_shape_rect(TerminalCursorStyle::Block, 10.0, 20.0, 8.0, 18.0); + assert_eq!( + block, + CursorShapeRect { + x: 10.0, + y: 20.0, + width: 8.0, + height: 18.0 + } + ); + + let underline = cursor_shape_rect(TerminalCursorStyle::Underline, 10.0, 20.0, 8.0, 18.0); + assert_eq!(underline.x, 10.0); + assert_eq!(underline.width, 8.0); + assert_eq!(underline.height, 3.0); + assert_eq!(underline.y, 35.0); + + let bar = cursor_shape_rect(TerminalCursorStyle::Bar, 10.0, 20.0, 8.0, 18.0); + assert_eq!(bar.x, 10.0); + assert_eq!(bar.y, 20.0); + assert_eq!(bar.width, 1.0); + assert_eq!(bar.height, 18.0); + } + + #[test] + fn canvas_fill_style_skips_only_transparent() { + assert_eq!(canvas_fill_style("transparent"), None); + assert_eq!(canvas_fill_style("#dbeafe"), Some("#dbeafe")); + assert_eq!( + canvas_fill_style("rgba(1, 2, 3, 0.5)"), + Some("rgba(1, 2, 3, 0.5)") + ); + } + + #[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::WebGl); + } + + #[test] + fn webgl_quad_vertices_cover_full_clip_space() { + let vertices = webgl_quad_vertices(); + assert_eq!(vertices.len(), WEBGL_VERTEX_FLOATS * 6); + assert_eq!(&vertices[0..8], &[-1.0, -1.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0]); + assert_eq!(&vertices[40..48], &[1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0]); + assert_eq!(WEBGL_QUAD_VERTEX_COUNT, 6); + assert_eq!(WEBGL_QUAD_STRIDE_BYTES, 32); + assert_eq!(WEBGL_QUAD_TEXCOORD_OFFSET_BYTES, 8); + assert_eq!(WEBGL_QUAD_COLOR_OFFSET_BYTES, 16); + } + + #[test] + fn webgl_shader_sources_bind_expected_names() { + let (vertex, fragment) = webgl_shader_sources(); + assert!(vertex.contains("a_position")); + assert!(vertex.contains("a_tex_coord")); + assert!(fragment.contains("u_texture")); + assert!(fragment.contains("texture2D")); + assert!(fragment.contains("v_color")); + } + + #[test] + fn webgl_texture_filter_is_crisp_for_terminal_text() { + assert_eq!( + webgl_texture_filter(), + WebGl2RenderingContext::NEAREST as i32 + ); + } + + #[test] + fn split_segment_glyphs_tracks_width_and_color() { + let segment = TerminalSegment { + text: "a界".to_owned(), + cols: 3, + style: TerminalStyle { + color: "#80ff00".to_owned(), + ..TerminalStyle::default() + }, + is_cursor: false, + }; + let options = default_options(); + let cells = split_segment_glyphs(&segment, 2, 4, &options).expect("glyph cells"); + assert_eq!(cells.len(), 2); + assert_eq!(cells[0].key.text, "a"); + assert_eq!(cells[0].key.cols, 1); + assert_eq!(cells[0].col, 4); + assert_eq!(cells[1].key.text, "界"); + assert_eq!(cells[1].key.cols, 2); + assert_eq!(cells[1].col, 5); + assert_eq!(cells[0].fg, [128.0 / 255.0, 1.0, 0.0, 1.0]); + } + + #[test] + fn split_segment_glyphs_only_block_cursor_uses_cursor_foreground() { + let segment = TerminalSegment { + text: "x".to_owned(), + cols: 1, + style: TerminalStyle { + color: "#80ff00".to_owned(), + ..TerminalStyle::default() + }, + is_cursor: true, + }; + + let mut options = default_options(); + options.cursor.style = TerminalCursorStyle::Block; + let block = split_segment_glyphs(&segment, 0, 0, &options).expect("block"); + assert_eq!(block[0].fg, [15.0 / 255.0, 23.0 / 255.0, 42.0 / 255.0, 1.0]); + + options.cursor.style = TerminalCursorStyle::Underline; + let underline = split_segment_glyphs(&segment, 0, 0, &options).expect("underline"); + assert_eq!(underline[0].fg, [128.0 / 255.0, 1.0, 0.0, 1.0]); + } + + #[test] + fn glyph_atlas_reserves_rows_and_reuses_entries() { + let mut atlas = GlyphAtlas::new(32, 1); + let key_a = GlyphKey { + text: "a".to_owned(), + bold: false, + italic: false, + cols: 1, + }; + let first = atlas.reserve(key_a.clone(), 10, 10).expect("first"); + let reused = atlas.reserve(key_a, 10, 10).expect("reused"); + assert_eq!(first, reused); + + let key_b = GlyphKey { + text: "b".to_owned(), + bold: false, + italic: false, + cols: 1, + }; + let second = atlas.reserve(key_b, 20, 10).expect("second"); + assert!(second.y > first.y); + assert!(atlas.dirty); + } + + #[test] + fn push_quad_maps_css_pixels_to_clip_space() { + let dom = CanvasDomState { + backing_width: 200, + backing_height: 100, + css_width: 100, + css_height: 50, + top_px: 0.0, + }; + let mut vertices = Vec::new(); + push_quad( + &mut vertices, + GlyphQuad { + x: 0.0, + y: 0.0, + width: 50.0, + height: 25.0, + uv: [0.0, 1.0, 0.5, 0.5], + color: [1.0, 0.5, 0.0, 1.0], + }, + dom, + ); + assert_eq!(vertices.len(), WEBGL_VERTEX_FLOATS * 6); + assert_eq!(&vertices[0..8], &[-1.0, 0.0, 0.0, 0.5, 1.0, 0.5, 0.0, 1.0]); + } + + #[test] + fn canvas_viewport_top_pins_live_mode_to_bottom() { + let total_px = 1000.0 * DEFAULT_LINE_HEIGHT_PX; + let vp_rows = 24; + + assert_eq!( + canvas_viewport_top_px(true, 0, total_px, vp_rows, DEFAULT_LINE_HEIGHT_PX), + total_px - vp_rows as f64 * DEFAULT_LINE_HEIGHT_PX + ); + assert_eq!( + canvas_viewport_top_px(false, 500, total_px, vp_rows, DEFAULT_LINE_HEIGHT_PX), + 500.0 + ); + assert_eq!( + canvas_viewport_top_px(false, -10, total_px, vp_rows, DEFAULT_LINE_HEIGHT_PX), + 0.0 + ); + assert_eq!( + canvas_viewport_top_px(true, 0, 10.0, vp_rows, DEFAULT_LINE_HEIGHT_PX), + 0.0 + ); + } + + #[test] + fn canvas_render_plan_tracks_dpr_and_scroll_position() { + let total_px = 1000.0 * DEFAULT_LINE_HEIGHT_PX; + let offset_px = 980.0 * DEFAULT_LINE_HEIGHT_PX; + let live = canvas_render_plan(CanvasPlanInput { + css_width: 960, + css_height: 360, + dpr: 2.0, + is_live: true, + scroll_top_px: 0, + total_px, + offset_px, + vp_rows: 20, + line_height_px: DEFAULT_LINE_HEIGHT_PX, + }); + + assert_eq!(live.dom_state.backing_width, 1920); + assert_eq!(live.dom_state.backing_height, 720); + assert_eq!(live.dom_state.css_width, 960); + assert_eq!(live.dom_state.css_height, 360); + assert_eq!( + live.dom_state.top_px, + total_px - 20.0 * DEFAULT_LINE_HEIGHT_PX + ); + assert_eq!(live.content_top_px, 0.0); + + let history = canvas_render_plan(CanvasPlanInput { + css_width: 960, + css_height: 360, + dpr: 1.5, + is_live: false, + scroll_top_px: 180, + total_px, + offset_px: 90.0, + vp_rows: 20, + line_height_px: DEFAULT_LINE_HEIGHT_PX, + }); + assert_eq!(history.dom_state.backing_width, 1440); + assert_eq!(history.dom_state.backing_height, 540); + assert_eq!(history.dom_state.top_px, 180.0); + assert_eq!(history.content_top_px, -90.0); + } + + #[test] + fn cursor_cell_text_finds_segment_by_column() { + let row = TerminalRow { + segments: vec![ + TerminalSegment { + text: "ab".to_owned(), + cols: 2, + style: TerminalStyle::default(), + is_cursor: false, + }, + TerminalSegment { + text: "界".to_owned(), + cols: 2, + style: TerminalStyle::default(), + is_cursor: false, + }, + ], + }; + + assert_eq!(cursor_cell_text(&row, 0).as_deref(), Some("ab")); + assert_eq!(cursor_cell_text(&row, 2).as_deref(), Some("界")); + assert_eq!(cursor_cell_text(&row, 3).as_deref(), Some("界")); + assert_eq!(cursor_cell_text(&row, 4), None); + } + + #[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] @@ -2030,12 +4162,30 @@ mod tests { #[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}")); + 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(); @@ -2056,33 +4206,68 @@ mod tests { ("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}")); + 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~")); + 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~")); + 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~"), + ("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}"); + 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}'), + ("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(), @@ -2091,24 +4276,54 @@ mod tests { ); } // 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}")); + 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}")); + 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")); + 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). @@ -2119,10 +4334,19 @@ mod tests { 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")); + 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")); + 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); } @@ -2152,22 +4376,20 @@ mod tests { // 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); + 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 * SCROLL_LINE_HEIGHT).abs() < 1.0, + (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 * SCROLL_LINE_HEIGHT).abs() < 1.0 - ); + 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 * SCROLL_LINE_HEIGHT - >= total as f64 * SCROLL_LINE_HEIGHT - 1.0, + 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" ); } @@ -2178,14 +4400,14 @@ mod tests { let total = core.total_rows(); let vp_rows = 24; // Scroll to row 500 (500 * 18px). - let st = (500.0 * SCROLL_LINE_HEIGHT) as i32; + let st = (500.0 * DEFAULT_LINE_HEIGHT_PX) as i32; let (total_px, offset_px, visible) = - virtual_window(&core, false, st, vp_rows); + 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 * SCROLL_LINE_HEIGHT).abs() < 1.0); + 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 / SCROLL_LINE_HEIGHT).round() as usize; + let first_row = (offset_px / DEFAULT_LINE_HEIGHT_PX).round() as usize; assert_eq!(first_row, 500 - VIRTUAL_OVERSCAN); assert!(!visible.is_empty()); } @@ -2194,12 +4416,9 @@ mod tests { 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); + 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 * SCROLL_LINE_HEIGHT).abs() - < 1.0 - ); + 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()); } diff --git a/app/src/terminal/core.rs b/app/src/terminal/core.rs index 453ec4d..0d7ea3b 100644 --- a/app/src/terminal/core.rs +++ b/app/src/terminal/core.rs @@ -1,3 +1,4 @@ +use unicode_width::UnicodeWidthChar; use vt100::{Color, Parser, Screen}; pub use vt100::{MouseProtocolEncoding, MouseProtocolMode}; @@ -16,7 +17,7 @@ pub const DEFAULT_COLS: u16 = 80; /// /// The `VecDeque` only allocates as rows actually scroll off, so a large bound /// costs nothing until the user genuinely produces that much output. -const SCROLLBACK_BUF: usize = 50_000; +pub const DEFAULT_SCROLLBACK: usize = 50_000; // --------------------------------------------------------------------------- // TerminalCore — parser + explicit history buffer @@ -24,6 +25,7 @@ const SCROLLBACK_BUF: usize = 50_000; pub struct TerminalCore { parser: Parser, + scrollback_capacity: usize, /// All rows that have scrolled off the top of the screen, in chronological /// order (oldest first). These are *our* data — vt100's scrollback is /// only used transiently to capture them. @@ -35,20 +37,33 @@ pub struct TerminalCore { /// How many scrollback rows we have already captured into `history_rows`. /// Equal to `parser.screen().scrollback()` after the last capture. captured_count: usize, + osc8_state: Osc8State, } impl TerminalCore { pub fn new(rows: u16, cols: u16) -> Self { + Self::new_with_scrollback(rows, cols, DEFAULT_SCROLLBACK) + } + + pub fn new_with_scrollback(rows: u16, cols: u16, scrollback_capacity: usize) -> Self { + let scrollback_capacity = scrollback_capacity.max(rows as usize); let mut core = Self { - parser: Parser::new(rows, cols, SCROLLBACK_BUF), + parser: Parser::new(rows, cols, scrollback_capacity), + scrollback_capacity, history_rows: Vec::new(), screen_cache: Vec::new(), captured_count: 0, + osc8_state: Osc8State::new(rows as usize, cols as usize, scrollback_capacity), }; core.refresh_screen_cache(); core } + pub fn clear(&mut self) { + let (rows, cols) = self.parser.screen().size(); + *self = Self::new_with_scrollback(rows, cols, self.scrollback_capacity); + } + /// Feed raw PTY bytes into the parser and capture any new history rows. /// /// vt100 0.15 cannot show scrollback rows deeper than `viewport_rows` @@ -64,11 +79,14 @@ impl TerminalCore { let erased = contains_erase_scrollback(bytes); if erased { self.history_rows.clear(); + self.osc8_state.clear_history(); } // Max rows a single slice may scroll, kept strictly below viewport so // the capture never sets vt100's scrollback offset past the viewport. let (rows, cols) = self.parser.screen().size(); + self.osc8_state.resize(rows as usize, cols as usize); + self.osc8_state.feed(bytes); let safe_rows = (rows as usize).saturating_sub(1).max(1); let max_slice_bytes = safe_rows * (cols as usize).max(1); @@ -157,6 +175,7 @@ impl TerminalCore { /// captured into history. pub fn resize(&mut self, rows: u16, cols: u16) { self.parser.set_size(rows, cols); + self.osc8_state.resize(rows as usize, cols as usize); self.parser.set_scrollback(usize::MAX); let total = self.parser.screen().scrollback(); @@ -175,8 +194,7 @@ impl TerminalCore { self.parser.set_scrollback(0); let screen = self.parser.screen(); let (rows, _) = screen.size(); - self.screen_cache = - (0..rows).map(|r| render_screen_row(screen, r)).collect(); + self.screen_cache = (0..rows).map(|r| render_screen_row(screen, r)).collect(); } /// Access the accumulated history rows (read-only). @@ -194,6 +212,41 @@ impl TerminalCore { self.history_rows.len() + self.screen_cache.len() } + pub fn scrollback_capacity(&self) -> usize { + self.scrollback_capacity + } + + /// Plain-text snapshot of the whole retained buffer, ordered as + /// `history + current screen`. + /// + /// This is the serialize-addon text path: it reads the same row model used + /// for selection/copy and virtual scrolling, so it is independent of the + /// active renderer. + pub fn buffer_text(&self) -> String { + let mut lines = Vec::with_capacity(self.total_rows()); + for i in 0..self.total_rows() { + if let Some(row) = self.row_at(i) { + lines.push(row.text_in_cols(0, usize::MAX)); + } + } + lines.join("\n") + } + + /// ANSI-styled snapshot of the retained buffer. + /// + /// This serializes the current row/style model, not the original PTY byte + /// stream. It is intended for save/export use cases where the visible text + /// and style are more important than replaying every cursor movement. + pub fn buffer_ansi(&self) -> String { + let mut lines = Vec::with_capacity(self.total_rows()); + for i in 0..self.total_rows() { + if let Some(row) = self.row_at(i) { + lines.push(row.to_ansi()); + } + } + lines.join("\r\n") + } + /// Clone the rows in the half-open window `[start, end)` of the combined /// `history + screen` sequence. `end` is clamped to `total_rows()`. /// @@ -239,11 +292,7 @@ impl TerminalCore { /// works across the whole buffer regardless of what is currently rendered. /// /// Per-row trailing whitespace is trimmed; rows are joined with `\n`. - pub fn selection_text( - &self, - start: (usize, usize), - end: (usize, usize), - ) -> String { + pub fn selection_text(&self, start: (usize, usize), end: (usize, usize)) -> String { let (start, end) = order_points(start, end); let (sr, sc) = start; let (er, ec) = end; @@ -261,6 +310,107 @@ impl TerminalCore { lines.join("\n") } + /// Literal search across `history + current screen`. + /// + /// Search wraps around the retained buffer, like xterm's search addon. + /// Column positions are character columns for plain ASCII and most common + /// terminal output; wide/combining edge cases are still rendered safely by + /// the overlay clipping code. + pub fn search( + &self, + query: &str, + from: Option<(usize, usize)>, + direction: SearchDirection, + ) -> Option { + let query = query.trim(); + if query.is_empty() || self.total_rows() == 0 { + return None; + } + + match direction { + SearchDirection::Forward => self.search_forward(query, from.unwrap_or((0, 0))), + SearchDirection::Backward => { + let fallback = (self.total_rows().saturating_sub(1), usize::MAX); + self.search_backward(query, from.unwrap_or(fallback)) + } + } + } + + fn search_forward(&self, query: &str, from: (usize, usize)) -> Option { + let total = self.total_rows(); + let start_row = from.0.min(total.saturating_sub(1)); + for offset in 0..total { + let row_idx = (start_row + offset) % total; + let start_col = if row_idx == start_row { from.1 } else { 0 }; + let Some(row) = self.row_at(row_idx) else { + continue; + }; + let text = row.text_in_cols(0, usize::MAX); + if let Some((start_col, end_col)) = find_literal_from_col(&text, query, start_col) { + return Some(TerminalSearchMatch { + row: row_idx, + start_col, + end_col, + text: text_slice_cols(&text, start_col, end_col), + }); + } + } + None + } + + fn search_backward(&self, query: &str, from: (usize, usize)) -> Option { + let total = self.total_rows(); + let start_row = from.0.min(total.saturating_sub(1)); + for offset in 0..total { + let row_idx = (start_row + total - offset) % total; + let before_col = if row_idx == start_row { + from.1 + } else { + usize::MAX + }; + let Some(row) = self.row_at(row_idx) else { + continue; + }; + let text = row.text_in_cols(0, usize::MAX); + if let Some((start_col, end_col)) = rfind_literal_before_col(&text, query, before_col) { + return Some(TerminalSearchMatch { + row: row_idx, + start_col, + end_col, + text: text_slice_cols(&text, start_col, end_col), + }); + } + } + None + } + + /// Auto-detected web links in a visible row window. + pub fn links_in_window(&self, start: usize, end: usize) -> Vec { + let total = self.total_rows(); + let end = end.min(total); + if start >= end { + return Vec::new(); + } + + let mut links = Vec::new(); + for row_idx in start..end { + let Some(row) = self.row_at(row_idx) else { + continue; + }; + let text = row.text_in_cols(0, usize::MAX); + links.extend(detect_links_in_text(row_idx, &text)); + } + links.extend( + self.osc8_state + .links_in_window(start, end, self.history_rows.len()), + ); + links.sort_by_key(|link| (link.row, link.start_col, link.end_col)); + links.dedup_by(|a, b| { + a.row == b.row && a.start_col == b.start_col && a.end_col == b.end_col && a.url == b.url + }); + links + } + // -- Convenience proxies for terminal metadata (all &self, no mutation) -- pub fn size(&self) -> (u16, u16) { @@ -326,15 +476,10 @@ fn contains_erase_scrollback(bytes: &[u8]) -> bool { if bytes[i] == 0x1b && bytes[i + 1] == b'[' { let params_start = i + 2; let mut j = params_start; - while j < bytes.len() - && (bytes[j].is_ascii_digit() || bytes[j] == b';') - { + while j < bytes.len() && (bytes[j].is_ascii_digit() || bytes[j] == b';') { j += 1; } - if j < bytes.len() - && bytes[j] == b'J' - && &bytes[params_start..j] == b"3" - { + if j < bytes.len() && bytes[j] == b'J' && &bytes[params_start..j] == b"3" { return true; } } @@ -415,16 +560,566 @@ impl TerminalRow { } out.trim_end().to_owned() } + + pub fn to_ansi(&self) -> String { + let mut out = String::new(); + let mut current = TerminalStyle::default(); + let mut styled = false; + + for segment in self.trimmed_segments() { + if segment.style != current { + out.push_str(&ansi_sgr_for_style(&segment.style)); + current = segment.style.clone(); + styled = true; + } + out.push_str(&segment.text); + } + + if styled { + out.push_str("\x1b[0m"); + } + out + } + + fn trimmed_segments(&self) -> Vec { + let mut segments = self.segments.clone(); + while let Some(segment) = segments.last_mut() { + let trimmed = segment.text.trim_end_matches(' ').to_owned(); + if trimmed.is_empty() { + segments.pop(); + } else { + segment.text = trimmed; + segment.cols = segment.text.chars().count().min(segment.cols); + break; + } + } + segments + } +} + +fn ansi_sgr_for_style(style: &TerminalStyle) -> String { + let mut parts: Vec = vec!["0".to_owned()]; + if style.bold { + parts.push("1".to_owned()); + } + if style.italic { + parts.push("3".to_owned()); + } + if style.underline { + parts.push("4".to_owned()); + } + if let Some((red, green, blue)) = parse_hex_color(&style.color) { + parts.push(format!("38;2;{red};{green};{blue}")); + } + if style.background != "transparent" + && let Some((red, green, blue)) = parse_hex_color(&style.background) + { + parts.push(format!("48;2;{red};{green};{blue}")); + } + format!("\x1b[{}m", parts.join(";")) +} + +fn parse_hex_color(color: &str) -> Option<(u8, u8, u8)> { + let color = color.strip_prefix('#')?; + if color.len() != 6 { + return None; + } + let red = u8::from_str_radix(&color[0..2], 16).ok()?; + let green = u8::from_str_radix(&color[2..4], 16).ok()?; + let blue = u8::from_str_radix(&color[4..6], 16).ok()?; + Some((red, green, blue)) } /// Order two `(row, col)` points so the first is the top-left of the selection. -fn order_points( - a: (usize, usize), - b: (usize, usize), -) -> ((usize, usize), (usize, usize)) { +fn order_points(a: (usize, usize), b: (usize, usize)) -> ((usize, usize), (usize, usize)) { if a <= b { (a, b) } else { (b, a) } } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SearchDirection { + Forward, + Backward, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TerminalSearchMatch { + pub row: usize, + pub start_col: usize, + pub end_col: usize, + pub text: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TerminalLink { + pub row: usize, + pub start_col: usize, + pub end_col: usize, + pub url: String, +} + +fn find_literal_from_col(text: &str, query: &str, start_col: usize) -> Option<(usize, usize)> { + let start_byte = byte_index_for_char_col(text, start_col); + let rel = text.get(start_byte..)?.find(query)?; + let start = start_byte + rel; + let start_col = char_col_for_byte_index(text, start); + Some((start_col, start_col + query.chars().count())) +} + +fn rfind_literal_before_col(text: &str, query: &str, before_col: usize) -> Option<(usize, usize)> { + let before_byte = byte_index_for_char_col(text, before_col); + let start = text.get(..before_byte)?.rfind(query)?; + let start_col = char_col_for_byte_index(text, start); + Some((start_col, start_col + query.chars().count())) +} + +fn text_slice_cols(text: &str, start_col: usize, end_col: usize) -> String { + let start = byte_index_for_char_col(text, start_col); + let end = byte_index_for_char_col(text, end_col); + text.get(start..end).unwrap_or_default().to_owned() +} + +fn byte_index_for_char_col(text: &str, col: usize) -> usize { + text.char_indices() + .map(|(idx, _)| idx) + .nth(col) + .unwrap_or(text.len()) +} + +fn char_col_for_byte_index(text: &str, byte_index: usize) -> usize { + text[..byte_index.min(text.len())].chars().count() +} + +fn detect_links_in_text(row: usize, text: &str) -> Vec { + let mut links = Vec::new(); + let mut search_start = 0; + while search_start < text.len() { + let Some(start_byte) = next_url_start(text, search_start) else { + break; + }; + let mut end_byte = text[start_byte..] + .find(char::is_whitespace) + .map(|i| start_byte + i) + .unwrap_or(text.len()); + end_byte = trim_url_end(text, start_byte, end_byte); + + if end_byte > start_byte { + let start_col = char_col_for_byte_index(text, start_byte); + let end_col = char_col_for_byte_index(text, end_byte); + links.push(TerminalLink { + row, + start_col, + end_col, + url: text[start_byte..end_byte].to_owned(), + }); + } + + search_start = end_byte.max(start_byte + 1); + } + links +} + +fn next_url_start(text: &str, from: usize) -> Option { + let rest = text.get(from..)?; + ["https://", "http://"] + .into_iter() + .filter_map(|prefix| rest.find(prefix).map(|idx| from + idx)) + .min() +} + +fn trim_url_end(text: &str, start: usize, mut end: usize) -> usize { + while end > start { + let Some(ch) = text[..end].chars().next_back() else { + break; + }; + if matches!( + ch, + '.' | ',' | ';' | ':' | '!' | '?' | ')' | ']' | '}' | '"' | '\'' + ) { + end -= ch.len_utf8(); + } else { + break; + } + } + end +} + +#[derive(Clone, Debug, PartialEq, Eq)] +enum Osc8ParseMode { + Text, + Esc, + Osc(Vec), + OscEsc(Vec), + Csi(Vec), +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct Osc8State { + row: usize, + col: usize, + rows: usize, + cols: usize, + scrollback_capacity: usize, + history_rows: Vec>>, + screen_rows: Vec>>, + active_url: Option, + mode: Osc8ParseMode, + utf8_buf: Vec, +} + +impl Default for Osc8State { + fn default() -> Self { + Self::new( + DEFAULT_ROWS as usize, + DEFAULT_COLS as usize, + DEFAULT_SCROLLBACK, + ) + } +} + +impl Osc8State { + fn new(rows: usize, cols: usize, scrollback_capacity: usize) -> Self { + let rows = rows.max(1); + let cols = cols.max(1); + Self { + row: 0, + col: 0, + rows, + cols, + scrollback_capacity, + history_rows: Vec::new(), + screen_rows: vec![vec![None; cols]; rows], + active_url: None, + mode: Osc8ParseMode::Text, + utf8_buf: Vec::new(), + } + } + + fn resize(&mut self, rows: usize, cols: usize) { + let rows = rows.max(1); + self.cols = cols.max(1); + self.rows = rows; + + for row in &mut self.screen_rows { + row.resize(self.cols, None); + } + while self.screen_rows.len() > self.rows { + let row = self.screen_rows.remove(0); + self.push_history_row(row); + self.row = self.row.saturating_sub(1); + } + while self.screen_rows.len() < self.rows { + self.screen_rows.push(vec![None; self.cols]); + } + + self.row = self.row.min(self.rows.saturating_sub(1)); + if self.col >= self.cols { + self.col = self.cols.saturating_sub(1); + } + } + + fn clear_history(&mut self) { + self.history_rows.clear(); + } + + fn feed(&mut self, bytes: &[u8]) { + for &byte in bytes { + let mode = std::mem::replace(&mut self.mode, Osc8ParseMode::Text); + match mode { + Osc8ParseMode::Text => match byte { + 0x1b => self.mode = Osc8ParseMode::Esc, + b'\r' => { + self.col = 0; + self.mode = Osc8ParseMode::Text; + } + b'\n' => { + self.line_feed(); + self.mode = Osc8ParseMode::Text; + } + 0x08 => { + self.col = self.col.saturating_sub(1); + self.mode = Osc8ParseMode::Text; + } + b'\t' => { + let next_tab = ((self.col / 8) + 1) * 8; + self.advance_spaces(next_tab.saturating_sub(self.col).max(1)); + self.mode = Osc8ParseMode::Text; + } + 0x00..=0x1f => self.mode = Osc8ParseMode::Text, + _ => { + self.advance_printable_byte(byte); + self.mode = Osc8ParseMode::Text; + } + }, + Osc8ParseMode::Esc => match byte { + b']' => self.mode = Osc8ParseMode::Osc(Vec::new()), + b'[' => self.mode = Osc8ParseMode::Csi(Vec::new()), + b'c' => { + self.reset_grid(); + self.mode = Osc8ParseMode::Text; + } + _ => self.mode = Osc8ParseMode::Text, + }, + Osc8ParseMode::Osc(mut data) => match byte { + 0x07 => { + self.handle_osc(&data); + self.mode = Osc8ParseMode::Text; + } + 0x1b => self.mode = Osc8ParseMode::OscEsc(data), + _ => { + data.push(byte); + self.mode = Osc8ParseMode::Osc(data); + } + }, + Osc8ParseMode::OscEsc(mut data) => { + if byte == b'\\' { + self.handle_osc(&data); + self.mode = Osc8ParseMode::Text; + } else { + data.push(0x1b); + data.push(byte); + self.mode = Osc8ParseMode::Osc(data); + } + } + Osc8ParseMode::Csi(mut data) => { + if (0x40..=0x7e).contains(&byte) { + self.handle_csi(&data, byte); + self.mode = Osc8ParseMode::Text; + } else { + data.push(byte); + self.mode = Osc8ParseMode::Csi(data); + } + } + } + } + } + + fn links_in_window( + &self, + start: usize, + end: usize, + core_history_rows: usize, + ) -> Vec { + let mut links = Vec::new(); + let history_base = core_history_rows.saturating_sub(self.history_rows.len()); + for (i, row) in self.history_rows.iter().enumerate() { + let global_row = history_base + i; + if global_row >= start && global_row < end { + collect_osc8_row_links(row, global_row, &mut links); + } + } + for (i, row) in self.screen_rows.iter().enumerate() { + let global_row = core_history_rows + i; + if global_row >= start && global_row < end { + collect_osc8_row_links(row, global_row, &mut links); + } + } + links + } + + fn advance_printable_byte(&mut self, byte: u8) { + self.utf8_buf.push(byte); + let Ok(text) = std::str::from_utf8(&self.utf8_buf) else { + if self.utf8_buf.len() > 4 { + self.utf8_buf.clear(); + } + return; + }; + let mut chars = text.chars(); + let Some(ch) = chars.next() else { + self.utf8_buf.clear(); + return; + }; + if chars.next().is_some() { + self.utf8_buf.clear(); + return; + } + self.utf8_buf.clear(); + self.advance_printable_char(ch); + } + + fn advance_printable_char(&mut self, ch: char) { + let width = ch.width().unwrap_or(0); + if width == 0 { + return; + } + if self.col + width > self.cols { + self.line_feed(); + } + let url = self.active_url.clone(); + for offset in 0..width.min(self.cols) { + let col = self.col + offset; + if col < self.cols { + self.screen_rows[self.row][col] = url.clone(); + } + } + self.col += width; + if self.col >= self.cols { + self.col = self.cols; + } + } + + fn advance_spaces(&mut self, count: usize) { + for _ in 0..count { + self.advance_printable_char(' '); + } + } + + fn handle_osc(&mut self, data: &[u8]) { + let Ok(text) = std::str::from_utf8(data) else { + return; + }; + let Some(rest) = text.strip_prefix("8;") else { + return; + }; + let Some((_, uri)) = rest.split_once(';') else { + return; + }; + + self.active_url = if uri.is_empty() { + None + } else { + Some(uri.to_owned()) + }; + } + + fn handle_csi(&mut self, data: &[u8], final_byte: u8) { + let params = parse_csi_params(data); + match final_byte { + b'A' => self.row = self.row.saturating_sub(csi_param_or(¶ms, 0, 1)), + b'B' => self.row = (self.row + csi_param_or(¶ms, 0, 1)).min(self.rows - 1), + b'C' => self.col = (self.col + csi_param_or(¶ms, 0, 1)).min(self.cols - 1), + b'D' => self.col = self.col.saturating_sub(csi_param_or(¶ms, 0, 1)), + b'G' => { + self.col = csi_param_or(¶ms, 0, 1) + .saturating_sub(1) + .min(self.cols - 1) + } + b'H' | b'f' => { + self.row = csi_param_or(¶ms, 0, 1) + .saturating_sub(1) + .min(self.rows - 1); + self.col = csi_param_or(¶ms, 1, 1) + .saturating_sub(1) + .min(self.cols - 1); + } + b'J' => self.erase_display(csi_param_or(¶ms, 0, 0)), + b'K' => self.erase_line(csi_param_or(¶ms, 0, 0)), + _ => {} + } + } + + fn line_feed(&mut self) { + if self.row + 1 >= self.rows { + let row = self.screen_rows.remove(0); + self.push_history_row(row); + self.screen_rows.push(vec![None; self.cols]); + self.row = self.rows - 1; + } else { + self.row += 1; + } + self.col = 0; + } + + fn erase_display(&mut self, mode: usize) { + match mode { + 0 => { + self.erase_line(0); + for row in (self.row + 1)..self.rows { + self.screen_rows[row].fill(None); + } + } + 1 => { + for row in 0..self.row { + self.screen_rows[row].fill(None); + } + self.erase_line(1); + } + 2 => { + for row in &mut self.screen_rows { + row.fill(None); + } + } + 3 => self.clear_history(), + _ => {} + } + } + + fn erase_line(&mut self, mode: usize) { + match mode { + 0 => { + for col in self.col.min(self.cols)..self.cols { + self.screen_rows[self.row][col] = None; + } + } + 1 => { + for col in 0..=self.col.min(self.cols - 1) { + self.screen_rows[self.row][col] = None; + } + } + 2 => self.screen_rows[self.row].fill(None), + _ => {} + } + } + + fn push_history_row(&mut self, row: Vec>) { + self.history_rows.push(row); + if self.history_rows.len() > self.scrollback_capacity { + let drop_count = self.history_rows.len() - self.scrollback_capacity; + self.history_rows.drain(0..drop_count); + } + } + + fn reset_grid(&mut self) { + self.row = 0; + self.col = 0; + self.active_url = None; + self.history_rows.clear(); + self.screen_rows = vec![vec![None; self.cols]; self.rows]; + self.utf8_buf.clear(); + } +} + +fn parse_csi_params(data: &[u8]) -> Vec { + let text = String::from_utf8_lossy(data); + text.trim_start_matches('?') + .split(';') + .map(|part| part.parse::().unwrap_or(0)) + .collect() +} + +fn csi_param_or(params: &[usize], index: usize, default: usize) -> usize { + params + .get(index) + .copied() + .filter(|value| *value != 0) + .unwrap_or(default) +} + +fn collect_osc8_row_links( + row: &[Option], + global_row: usize, + links: &mut Vec, +) { + let mut col = 0; + while col < row.len() { + let Some(url) = row[col].as_ref() else { + col += 1; + continue; + }; + let start_col = col; + col += 1; + while col < row.len() && row[col].as_ref() == Some(url) { + col += 1; + } + links.push(TerminalLink { + row: global_row, + start_col, + end_col: col, + url: url.clone(), + }); + } +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct TerminalSegment { pub text: String, @@ -617,22 +1312,8 @@ fn resolve_background(color: Color, transparent_default: bool) -> String { fn indexed_color(index: u8) -> String { const ANSI_16: [&str; 16] = [ - "#1e293b", - "#ef4444", - "#22c55e", - "#eab308", - "#3b82f6", - "#d946ef", - "#06b6d4", - "#e2e8f0", - "#475569", - "#f87171", - "#4ade80", - "#fde047", - "#60a5fa", - "#e879f9", - "#67e8f9", - "#f8fafc", + "#1e293b", "#ef4444", "#22c55e", "#eab308", "#3b82f6", "#d946ef", "#06b6d4", "#e2e8f0", + "#475569", "#f87171", "#4ade80", "#fde047", "#60a5fa", "#e879f9", "#67e8f9", "#f8fafc", ]; match index { @@ -736,8 +1417,7 @@ mod tests { assert_eq!(history.first().map(String::as_str), Some("$ line1")); assert_eq!(history.get(1).map(String::as_str), Some("line2")); // history must be strictly the earlier lines, screen holds the tail - let screen: Vec = - core.screen_rows().iter().map(row_text).collect(); + let screen: Vec = core.screen_rows().iter().map(row_text).collect(); assert!(screen.iter().any(|r| r == "line40")); assert!(!history.iter().any(|r| r == "line40")); } @@ -798,6 +1478,149 @@ mod tests { assert_eq!(core.selection_text((0, 5), (0, 0)), "hello"); } + #[test] + fn buffer_text_serializes_history_plus_screen() { + let mut core = TerminalCore::new(3, 80); + core.process(b"alpha\r\n"); + core.process(b"beta\r\n"); + core.process(b"gamma\r\n"); + core.process(b"delta"); + + let text = core.buffer_text(); + assert!(text.contains("alpha")); + assert!(text.contains("beta")); + assert!(text.contains("gamma")); + assert!(text.contains("delta")); + assert!(text.find("alpha") < text.find("delta")); + } + + #[test] + fn buffer_ansi_serializes_current_styles() { + let mut core = TerminalCore::new(5, 80); + core.process(b"plain \x1b[1;31mbold-red\x1b[0m normal"); + + let ansi = core.buffer_ansi(); + assert!(ansi.contains("plain ")); + assert!(ansi.contains("\x1b[0;1;38;2;239;68;68m")); + assert!(ansi.contains("bold-red")); + assert!(ansi.contains(" normal")); + assert!(ansi.contains("\x1b[0m")); + } + + #[test] + fn search_wraps_forward_and_backward() { + let mut core = TerminalCore::new(5, 80); + core.process(b"alpha\r\n"); + core.process(b"beta target\r\n"); + core.process(b"gamma\r\n"); + core.process(b"delta target\r\n"); + + let first = core + .search("target", Some((0, 0)), SearchDirection::Forward) + .unwrap(); + assert_eq!(first.text, "target"); + assert_eq!(first.start_col, 5); + + let second = core + .search( + "target", + Some((first.row, first.end_col)), + SearchDirection::Forward, + ) + .unwrap(); + assert!(second.row > first.row); + + let wrapped = core + .search( + "target", + Some((second.row, second.end_col)), + SearchDirection::Forward, + ) + .unwrap(); + assert_eq!(wrapped, first); + + let backward = core + .search( + "target", + Some((first.row, first.start_col)), + SearchDirection::Backward, + ) + .unwrap(); + assert_eq!(backward, second); + } + + #[test] + fn links_in_window_detects_http_urls_and_trims_punctuation() { + let mut core = TerminalCore::new(5, 100); + core.process(b"docs https://example.com/path?q=1, then http://localhost:3000/x.\r\n"); + + let links = core.links_in_window(0, core.total_rows()); + assert_eq!(links.len(), 2); + assert_eq!(links[0].url, "https://example.com/path?q=1"); + assert_eq!(links[1].url, "http://localhost:3000/x"); + assert_eq!(links[0].start_col, 5); + assert!(links[0].end_col > links[0].start_col); + } + + #[test] + fn links_in_window_detects_osc8_hyperlinks_with_bel_and_st() { + let mut core = TerminalCore::new(5, 100); + core.process(b"open \x1b]8;;https://example.com\x07label\x1b]8;;\x07 done\r\n"); + core.process(b"next \x1b]8;;https://example.org\x1b\\site\x1b]8;;\x1b\\ end\r\n"); + + let links = core.links_in_window(0, core.total_rows()); + assert!( + links.iter().any(|link| { + link.url == "https://example.com" && link.start_col == 5 && link.end_col == 10 + }), + "BEL OSC 8 link missing: {links:?}", + ); + assert!( + links.iter().any(|link| { + link.url == "https://example.org" && link.start_col == 5 && link.end_col == 9 + }), + "ST OSC 8 link missing: {links:?}", + ); + } + + #[test] + fn osc8_links_follow_cursor_movement_and_overwrite() { + let mut core = TerminalCore::new(5, 40); + core.process(b"\x1b[3;6H\x1b]8;;https://example.com\x07link\x1b]8;;\x07"); + + let links = core.links_in_window(0, core.total_rows()); + assert!(links.iter().any(|link| { + link.url == "https://example.com" + && link.row == 2 + && link.start_col == 5 + && link.end_col == 9 + })); + + core.process(b"\x1b[3;6Hxxxx"); + let links = core.links_in_window(0, core.total_rows()); + assert!( + !links.iter().any(|link| link.url == "https://example.com"), + "overwritten OSC 8 cells must not remain clickable: {links:?}", + ); + } + + #[test] + fn osc8_links_scroll_into_history() { + let mut core = TerminalCore::new(3, 40); + core.process(b"\x1b]8;;https://example.com\x07link\x1b]8;;\x07\r\n"); + core.process(b"line2\r\n"); + core.process(b"line3\r\n"); + core.process(b"line4\r\n"); + + let links = core.links_in_window(0, core.total_rows()); + assert!( + links + .iter() + .any(|link| link.url == "https://example.com" && link.row == 0), + "scrolled OSC 8 row should remain in history links: {links:?}", + ); + } + #[test] fn clear_wipes_history_and_keeps_only_new_prompt() { let mut core = TerminalCore::new(24, 80); @@ -805,7 +1628,10 @@ mod tests { for i in 1..=40 { core.process(format!("line{i}\r\n").as_bytes()); } - assert!(!non_empty_history(&core).is_empty(), "precondition: history populated"); + assert!( + !non_empty_history(&core).is_empty(), + "precondition: history populated" + ); // `clear` => ESC[H ESC[2J ESC[3J, then shell redraws the prompt core.process(b"\x1b[H\x1b[2J\x1b[3J"); @@ -877,8 +1703,8 @@ mod tests { // saturating scrollback count froze capture at 10k lines; the cap is // now 50k and capture stays correct up to it.) let mut core = TerminalCore::new(24, 80); - let lines = 40_000; // comfortably under SCROLLBACK_BUF (50k) - assert!(lines < SCROLLBACK_BUF); + let lines = 40_000; // comfortably under DEFAULT_SCROLLBACK (50k) + assert!(lines < DEFAULT_SCROLLBACK); for i in 1..=lines { core.process(format!("ln{i}\r\n").as_bytes()); } @@ -898,4 +1724,3 @@ mod tests { ); } } - diff --git a/app/src/terminal/ime.rs b/app/src/terminal/ime.rs new file mode 100644 index 0000000..13b3c2e --- /dev/null +++ b/app/src/terminal/ime.rs @@ -0,0 +1,33 @@ +use leptos::html; +use leptos::prelude::{GetUntracked, NodeRef}; + +use super::core::TerminalCore; + +pub(crate) fn ime_cursor_cell(core: &TerminalCore) -> (usize, usize) { + let (row, col) = core.cursor_position(); + let buffer_row = if core.alternate_screen() { + usize::from(row) + } else { + core.history_rows().len() + usize::from(row) + }; + (buffer_row, usize::from(col)) +} + +/// Focus the hidden IME textarea so keyboard input and composition go there. +pub(crate) fn focus_ime_textarea(ime_ref: &NodeRef) { + if let Some(element) = ime_ref.get_untracked() { + let _ = element.focus(); + } +} + +/// Read the current value of the hidden IME textarea. +pub(crate) fn ime_ref_text(ime_ref: &NodeRef) -> Option { + ime_ref.get_untracked().map(|t| t.value()) +} + +/// Clear the hidden IME textarea after sending composed/typed text. +pub(crate) fn clear_ime_textarea(ime_ref: &NodeRef) { + if let Some(t) = ime_ref.get_untracked() { + t.set_value(""); + } +} diff --git a/app/src/terminal/keyboard.rs b/app/src/terminal/keyboard.rs new file mode 100644 index 0000000..ad27ce7 --- /dev/null +++ b/app/src/terminal/keyboard.rs @@ -0,0 +1,108 @@ +use web_sys::KeyboardEvent; + +/// Normalises line endings to CR (`\r`), the byte a terminal expects for line +/// submission (Enter sends `\r`). When the application enabled bracketed paste, +/// wraps the text in `ESC[200~ ... ESC[201~`. +pub(crate) fn prepare_paste(raw: &str, bracketed: bool) -> String { + let normalized = raw.replace("\r\n", "\r").replace('\n', "\r"); + if bracketed { + format!("\u{1b}[200~{normalized}\u{1b}[201~") + } else { + normalized + } +} + +/// Build a cursor/nav key sequence, SS3 (`ESC O `) when the app is in +/// application-cursor mode, else CSI (`ESC [ `). +pub(crate) fn cursor_seq(final_byte: char, app_cursor: bool) -> String { + if app_cursor { + format!("\u{1b}O{final_byte}") + } else { + format!("\u{1b}[{final_byte}") + } +} + +/// Pure key-table: map a key + modifier state + application-cursor flag to +/// the bytes a terminal would receive for the same key. +pub(crate) fn key_to_bytes( + key: &str, + ctrl: bool, + alt: bool, + meta: bool, + app_cursor: bool, +) -> Option { + if ctrl && !alt { + if key.chars().count() != 1 { + return None; + } + let c = key.chars().next()?; + let code = match c { + 'a'..='z' => (c.to_ascii_uppercase() as u8) & 0x1f, + 'A'..='Z' => (c as u8) & 0x1f, + '@' => 0x00, + '[' => 0x1b, + '\\' => 0x1c, + ']' => 0x1d, + '^' => 0x1e, + '_' => 0x1f, + _ => return None, + }; + if matches!(code, 0x1a | 0x1c) { + return None; + } + return Some((code as char).to_string()); + } + + if let (true, Some(c)) = (alt && !ctrl && !meta, key.chars().next()) { + return Some(format!("\u{1b}{c}")); + } + + match key { + "Enter" => Some("\r".to_owned()), + "Backspace" => Some("\u{7f}".to_owned()), + "Tab" => Some("\t".to_owned()), + "Escape" => Some("\u{1b}".to_owned()), + "ArrowUp" => Some(cursor_seq('A', app_cursor)), + "ArrowDown" => Some(cursor_seq('B', app_cursor)), + "ArrowRight" => Some(cursor_seq('C', app_cursor)), + "ArrowLeft" => Some(cursor_seq('D', app_cursor)), + "Home" => Some(cursor_seq('H', app_cursor)), + "End" => Some(cursor_seq('F', app_cursor)), + "Insert" => Some("\u{1b}[2~".to_owned()), + "Delete" => Some("\u{1b}[3~".to_owned()), + "PageUp" => Some("\u{1b}[5~".to_owned()), + "PageDown" => Some("\u{1b}[6~".to_owned()), + "F1" => Some("\u{1b}OP".to_owned()), + "F2" => Some("\u{1b}OQ".to_owned()), + "F3" => Some("\u{1b}OR".to_owned()), + "F4" => Some("\u{1b}OS".to_owned()), + "F5" => Some("\u{1b}[15~".to_owned()), + "F6" => Some("\u{1b}[17~".to_owned()), + "F7" => Some("\u{1b}[18~".to_owned()), + "F8" => Some("\u{1b}[19~".to_owned()), + "F9" => Some("\u{1b}[20~".to_owned()), + "F10" => Some("\u{1b}[21~".to_owned()), + "F11" => Some("\u{1b}[23~".to_owned()), + "F12" => Some("\u{1b}[24~".to_owned()), + _ if key.chars().count() == 1 && !meta => Some(key.to_owned()), + _ => None, + } +} + +/// Map a browser keyboard event to the bytes a terminal would receive for +/// the same key. Thin wrapper around `key_to_bytes`. +pub(crate) fn map_key_to_terminal_input(event: &KeyboardEvent, app_cursor: bool) -> Option { + key_to_bytes( + &event.key(), + event.ctrl_key(), + event.alt_key(), + event.meta_key(), + app_cursor, + ) +} + +/// Whether a keydown event represents a regular printable character that +/// should be handled by the IME textarea's `input` event. +pub(crate) fn is_regular_text_input(key: &str, ctrl: bool, alt: bool, meta: bool) -> bool { + key.chars().count() == 1 && !ctrl && !alt && !meta +} diff --git a/app/src/terminal/mod.rs b/app/src/terminal/mod.rs index 768ed66..0c8e91a 100644 --- a/app/src/terminal/mod.rs +++ b/app/src/terminal/mod.rs @@ -1,6 +1,15 @@ pub mod component; pub mod core; +pub mod ime; +pub mod keyboard; +pub mod mouse; pub mod protocol; pub mod scrollback; +pub mod selection; -pub use component::TerminalPanel; +pub use component::{ + Renderer, TerminalCursorOptions, TerminalCursorStyle, TerminalFontOptions, TerminalHandle, + TerminalOptions, TerminalPanel, TerminalRenderEvent, TerminalResizeEvent, + TerminalSelectionEvent, TerminalTheme, +}; +pub use core::{SearchDirection, TerminalLink, TerminalSearchMatch}; diff --git a/app/src/terminal/mouse.rs b/app/src/terminal/mouse.rs new file mode 100644 index 0000000..69212dd --- /dev/null +++ b/app/src/terminal/mouse.rs @@ -0,0 +1,110 @@ +use super::core::MouseProtocolEncoding; + +/// Number of rows to scroll for a wheel event, from its delta and delta mode. +pub(crate) fn wheel_rows_for_delta( + delta_y: f64, + delta_mode: u32, + max_rows: usize, + line_height_px: f64, +) -> usize { + let wheel_pixels_per_row = line_height_px.max(1.0); + const WHEEL_LINES_PER_PAGE: f64 = 8.0; + + let magnitude = delta_y.abs(); + if magnitude == 0.0 { + return 0; + } + let rows = match delta_mode { + 1 => magnitude, + 2 => magnitude * WHEEL_LINES_PER_PAGE, + _ => magnitude / wheel_pixels_per_row, + }; + let rows = rows.round() as usize; + rows.clamp(1, max_rows.max(1)) +} + +/// What kind of mouse action is being reported. Wheel ticks are reported as +/// a `Press` of button 64/65. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum MouseAction { + Press, + Release, + Motion, +} + +/// Map a browser mouse button index (`MouseEvent.button`) to the terminal's +/// low button code, or `None` for buttons we don't report. +pub(crate) fn mouse_button_base(browser_button: i16) -> Option { + match browser_button { + 0..=2 => Some(browser_button as u8), + _ => None, + } +} + +/// OR the modifier bits into a mouse button code, matching xterm encoding. +pub(crate) fn with_mouse_modifiers(button: u8, shift: bool, meta: bool, ctrl: bool) -> u8 { + let mut b = button; + if shift { + b |= 4; + } + if meta { + b |= 8; + } + if ctrl { + b |= 16; + } + b +} + +/// Encode a mouse event as the bytes a terminal would receive. +pub(crate) fn encode_mouse_report( + button: u8, + action: MouseAction, + col: usize, + row: usize, + encoding: MouseProtocolEncoding, +) -> String { + match encoding { + MouseProtocolEncoding::Sgr => { + let suffix = if action == MouseAction::Release { + 'm' + } else { + 'M' + }; + format!("\u{1b}[<{button};{};{}{suffix}", col + 1, row + 1) + } + MouseProtocolEncoding::Default | MouseProtocolEncoding::Utf8 => { + let b = if action == MouseAction::Release { + 3 + } else { + button + }; + let c = col.min(94) as u8 + 1; + let r = row.min(94) as u8 + 1; + format!( + "\u{1b}M{}{}{}", + char::from_u32(u32::from(b) + 32).unwrap_or(' '), + char::from_u32(u32::from(c) + 32).unwrap_or(' '), + char::from_u32(u32::from(r) + 32).unwrap_or(' '), + ) + } + } +} + +/// Map a pixel position inside the terminal content box to 0-based visible +/// grid coordinates (row, col), clamped to the viewport. +pub(crate) fn pixel_to_grid( + local_x: f64, + local_y: f64, + cell_w: f64, + cell_h: f64, + cols: usize, + rows: usize, + padding: f64, +) -> (usize, usize) { + let x = (local_x - padding).max(0.0); + let y = (local_y - padding).max(0.0); + let col = ((x / cell_w.max(1.0)).floor() as usize).min(cols.saturating_sub(1)); + let row = ((y / cell_h.max(1.0)).floor() as usize).min(rows.saturating_sub(1)); + (row, col) +} diff --git a/app/src/terminal/protocol.rs b/app/src/terminal/protocol.rs index 0e54330..9c08217 100644 --- a/app/src/terminal/protocol.rs +++ b/app/src/terminal/protocol.rs @@ -18,14 +18,8 @@ pub enum ClientTerminalMessage { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] pub enum ServerTerminalMessage { - Output { - data: String, - }, - Exit { - code: Option, - }, - Error { - message: String, - }, + Output { data: String }, + Exit { code: Option }, + Error { message: String }, Pong, } diff --git a/app/src/terminal/scrollback.rs b/app/src/terminal/scrollback.rs index 34d1595..36dbf93 100644 --- a/app/src/terminal/scrollback.rs +++ b/app/src/terminal/scrollback.rs @@ -10,7 +10,9 @@ const LIVE_SCROLL_THRESHOLD_PX: i32 = 24; pub enum ViewMode { #[default] Live, - History { scroll_offset: usize }, + History { + scroll_offset: usize, + }, } impl ViewMode { diff --git a/app/src/terminal/selection.rs b/app/src/terminal/selection.rs new file mode 100644 index 0000000..738f268 --- /dev/null +++ b/app/src/terminal/selection.rs @@ -0,0 +1,77 @@ +/// A text selection in logical buffer coordinates `(row, col)`. `anchor` is +/// where the drag began, `head` follows the pointer. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct Selection { + pub(crate) anchor: (usize, usize), + pub(crate) head: (usize, usize), +} + +impl Selection { + /// (top-left, bottom-right) ordered for rendering / extraction. + pub(crate) fn ordered(self) -> ((usize, usize), (usize, usize)) { + if self.anchor <= self.head { + (self.anchor, self.head) + } else { + (self.head, self.anchor) + } + } + + /// True when the selection is empty (anchor == head): nothing to copy. + pub(crate) fn is_empty(self) -> bool { + self.anchor == self.head + } +} + +/// A highlight rectangle for one selected row, positioned relative to the top +/// of the rendered rows layer. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct SelectionRect { + pub(crate) top_px: f64, + pub(crate) left_px: f64, + pub(crate) width_px: f64, + pub(crate) height_px: f64, +} + +/// Compute the per-row highlight rects for `sel`, clipped to the rendered +/// window `[window_start, window_end)`. +pub(crate) fn selection_rects( + sel: Option, + window_start: usize, + window_end: usize, + cols: usize, + cell_w: f64, + line_height_px: f64, +) -> Vec { + let Some(sel) = sel else { + return Vec::new(); + }; + if sel.is_empty() { + return Vec::new(); + } + let ((sr, sc), (er, ec)) = sel.ordered(); + let first = sr.max(window_start); + let last = er.min(window_end.saturating_sub(1)); + let mut rects = Vec::new(); + for r in first..=last { + if r >= window_end { + break; + } + let (col_a, col_b) = if sr == er { + (sc, ec) + } else if r == sr { + (sc, cols) + } else if r == er { + (0, ec) + } else { + (0, cols) + }; + let col_b = col_b.max(col_a); + rects.push(SelectionRect { + top_px: (r - window_start) as f64 * line_height_px.max(1.0), + left_px: col_a as f64 * cell_w, + width_px: (col_b - col_a) as f64 * cell_w, + height_px: line_height_px.max(1.0), + }); + } + rects +} diff --git a/server/src/terminal/pty_session.rs b/server/src/terminal/pty_session.rs index 475ecc0..d6e9c1c 100644 --- a/server/src/terminal/pty_session.rs +++ b/server/src/terminal/pty_session.rs @@ -1,9 +1,7 @@ use std::io::{Read, Write}; use std::sync::{Arc, Mutex}; -use portable_pty::{ - ChildKiller, CommandBuilder, MasterPty, PtySize, native_pty_system, -}; +use portable_pty::{ChildKiller, CommandBuilder, MasterPty, PtySize, native_pty_system}; use tokio::sync::mpsc; #[derive(Clone, Copy, Debug)] @@ -43,9 +41,7 @@ pub struct PtySession { } impl PtySession { - pub fn spawn( - size: TerminalSize, - ) -> Result<(Self, mpsc::UnboundedReceiver>), String> { + pub fn spawn(size: TerminalSize) -> Result<(Self, mpsc::UnboundedReceiver>), String> { let pty_system = native_pty_system(); let pair = pty_system .openpty(size.into_pty_size()) @@ -89,9 +85,8 @@ impl PtySession { } } Err(error) => { - let _ = tx.send( - format!("\r\n[terminal read error] {error}\r\n").into_bytes(), - ); + let _ = + tx.send(format!("\r\n[terminal read error] {error}\r\n").into_bytes()); break; } } @@ -103,12 +98,11 @@ impl PtySession { .and_then(|mut child| child.wait().ok()) .map(|status| status.exit_code() as i32); - let payload = serde_json::to_vec(&app::terminal::protocol::ServerTerminalMessage::Exit { - code: exit_code, - }) - .unwrap_or_else(|_| { - br#"{"type":"exit","code":null}"#.to_vec() - }); + let payload = + serde_json::to_vec(&app::terminal::protocol::ServerTerminalMessage::Exit { + code: exit_code, + }) + .unwrap_or_else(|_| br#"{"type":"exit","code":null}"#.to_vec()); let _ = tx.send(payload); }); diff --git a/server/src/terminal/ws.rs b/server/src/terminal/ws.rs index 9b1234c..13d4f15 100644 --- a/server/src/terminal/ws.rs +++ b/server/src/terminal/ws.rs @@ -67,8 +67,7 @@ async fn handle_terminal_socket(socket: WebSocket) { match client_message { ClientTerminalMessage::Input { data } => { if let Err(error) = session.write(&data) { - let _ = server_tx - .send(ServerTerminalMessage::Error { message: error }); + let _ = server_tx.send(ServerTerminalMessage::Error { message: error }); } } ClientTerminalMessage::Resize { @@ -83,8 +82,7 @@ async fn handle_terminal_socket(socket: WebSocket) { pixel_width, pixel_height, }) { - let _ = server_tx - .send(ServerTerminalMessage::Error { message: error }); + let _ = server_tx.send(ServerTerminalMessage::Error { message: error }); } } ClientTerminalMessage::Ping => { diff --git a/style/tailwind.css b/style/tailwind.css index 6232064..9bcbfb9 100644 --- a/style/tailwind.css +++ b/style/tailwind.css @@ -590,6 +590,7 @@ .terminal-screen { @apply relative min-h-0 flex-1 overflow-y-auto overflow-x-hidden rounded; + min-height: 250px; /* 12 rows * 18px + 16px vertical padding + 1px border */ padding: 16px; border: 1px solid #17202f; background: @@ -597,12 +598,14 @@ linear-gradient(180deg, #0b1220 0%, #0f172a 100%); box-shadow: inset 0 1px 0 rgb(255 255 255 / 4%); outline: none; - color: #dbeafe; - font-family: + color: var(--terminal-fg, #dbeafe); + font-family: var( + --terminal-font-family, "JetBrains Mono", "Fira Code", "Cascadia Code", "SFMono-Regular", - "Consolas", monospace; - font-size: 13px; - line-height: 18px; + "Consolas", monospace + ); + font-size: var(--terminal-font-size, 13px); + line-height: var(--terminal-line-height, 18px); white-space: pre; letter-spacing: 0; scrollbar-gutter: stable; @@ -636,15 +639,24 @@ line-height: 1px; } + .terminal-canvas { + position: absolute; + left: 0; + z-index: 0; + pointer-events: none; + } + .terminal-text { margin: 0; min-height: 100%; - color: #dbeafe; - font-family: + color: var(--terminal-fg, #dbeafe); + font-family: var( + --terminal-font-family, "JetBrains Mono", "Fira Code", "Cascadia Code", "SFMono-Regular", - "Consolas", monospace; - font-size: 13px; - line-height: 18px; + "Consolas", monospace + ); + font-size: var(--terminal-font-size, 13px); + line-height: var(--terminal-line-height, 18px); white-space: pre-wrap; word-break: break-word; } @@ -659,9 +671,9 @@ .terminal-row { position: relative; z-index: 1; - height: 18px; - min-height: 18px; - max-height: 18px; + height: var(--terminal-line-height, 18px); + min-height: var(--terminal-line-height, 18px); + max-height: var(--terminal-line-height, 18px); overflow: hidden; white-space: pre; } @@ -674,11 +686,45 @@ } .terminal-cursor { - background: #dbeafe !important; - color: #0f172a !important; + position: relative; + animation: var(--terminal-cursor-blink, none) 1s steps(2, start) infinite; + } + + .terminal-cursor::after { + position: absolute; + inset: 0; + display: block; + content: ""; + pointer-events: none; + background: var(--terminal-cursor-bg, #dbeafe); + } + + .terminal-shell-card[style*="--terminal-cursor-style:block"] .terminal-cursor { + background: var(--terminal-cursor-bg, #dbeafe) !important; + color: var(--terminal-cursor-fg, #0f172a) !important; box-shadow: 0 0 0 1px rgb(219 234 254 / 30%); } + .terminal-shell-card[style*="--terminal-cursor-style:block"] .terminal-cursor::after { + display: none; + } + + .terminal-shell-card[style*="--terminal-cursor-style:underline"] .terminal-cursor::after { + top: auto; + height: 3px; + } + + .terminal-shell-card[style*="--terminal-cursor-style:bar"] .terminal-cursor::after { + right: auto; + width: 2px; + } + + @keyframes terminal-cursor-blink { + 50% { + opacity: 0; + } + } + /* Absolute-positioning virtual scroll: the sizer holds the full content height so the scroll geometry is constant; the rows layer is translated to the visible window's offset. */ @@ -698,9 +744,32 @@ .terminal-selection { position: absolute; - background: rgb(56 189 248 / 30%); + background: var(--terminal-selection-bg, rgb(56 189 248 / 30%)); pointer-events: none; - z-index: 0; + z-index: 1; + } + + .terminal-search-match { + position: absolute; + background: rgb(250 204 21 / 28%); + box-shadow: inset 0 0 0 1px rgb(250 204 21 / 55%); + pointer-events: none; + z-index: 1; + } + + .terminal-link-hitbox { + position: absolute; + display: block; + padding: 0; + border: 0; + border-bottom: 1px solid rgb(125 211 252 / 75%); + background: transparent; + cursor: pointer; + z-index: 3; + } + + .terminal-link-hitbox:hover { + background: rgb(125 211 252 / 14%); } .terminal-measure { @@ -708,6 +777,13 @@ left: -9999px; top: 0; white-space: pre; + font-family: var( + --terminal-font-family, + "JetBrains Mono", "Fira Code", "Cascadia Code", "SFMono-Regular", + "Consolas", monospace + ); + font-size: var(--terminal-font-size, 13px); + line-height: var(--terminal-line-height, 18px); pointer-events: none; opacity: 0; }