diff --git a/TERMINAL_ROADMAP.md b/TERMINAL_ROADMAP.md new file mode 100644 index 0000000..da0970a --- /dev/null +++ b/TERMINAL_ROADMAP.md @@ -0,0 +1,135 @@ +# Terminal Roadmap — 对照 xterm.js 的追平计划 + +本文档记录 `base-path-demo` 浏览器终端**当前已实现的能力**、**相比 xterm.js 仍缺失的功能**,以及每一项**计划的实现方式与优先级**,供后续开发接手。 + +> 定位提醒:本项目目标不是复刻 xterm.js 这个通用 JS 终端库,而是按 [TERMINAL_HANDOFF.md](TERMINAL_HANDOFF.md) 的方向,用 `Axum + portable-pty`(后端)+ `Leptos + Rust/WASM`(前端)沉淀一套**可复用的 Rust 终端组件**。因此追平的重点是「让真实终端会话好用」,而非铺平 xterm.js 的全部 API 面。 + +--- + +## 1. 覆盖度自评(截至本文档) + +按用途粗估: + +- **「跑通真实终端会话」核心用途**:约 50–60% +- **xterm.js 完整 API / 特性面**:约 15–20% + +差距集中在**输入完整性**、**API/生态**、**可配置性**,而非「能不能用」。 + +### 已实现(有代码 + 单元测试) + +| 能力 | 位置 | +|---|---| +| PTY 会话 + WebSocket 双向流 | `server/src/terminal/{pty_session,ws}.rs` | +| ANSI 解析(vt100 crate):颜色 / 粗体 / 斜体 / 下划线 / 反色、CSI 光标控制、擦除、滚动区 | `app/src/terminal/core.rs` | +| 256 色 + RGB 真彩 | `core.rs::indexed_color` / `resolve_*` | +| alt-screen(vim / btm / htop),含历史隔离 | `core.rs::process` | +| scrollback + 虚拟滚动(万行不卡,绝对定位) | `core.rs::collect_window` / `component.rs::virtual_window` | +| 宽字符 / 组合字符网格对齐 | `core.rs` `TerminalSegment.cols` | +| resize / reflow | `core.rs::resize` | +| `clear`(含 `ESC[3J` 清 scrollback) | `core.rs::contains_erase_scrollback` | +| alt-screen 滚轮 → 方向键翻页 | `component.rs::handle_wheel` | +| 基础按键:Ctrl-C/D/L、方向键、Enter/Tab/Esc/Backspace | `component.rs::map_key_to_terminal_input` | + +--- + +## 2. 追平项(按优先级) + +### P0 — 复制 / 粘贴 + 文本选区 ⭐ 投入产出比最高 + +**现状**:完全没有。无法选中终端文本、无法粘贴。这是「能用 vs 好用」的分界线。 + +**计划实现**: +1. **粘贴(先做,最简单)** + - 监听 `on:paste`(`ClipboardEvent`),读 `clipboard_data().get_data("text")`。 + - 直接作为 `ClientTerminalMessage::Input` 发送。 + - 若应用开启 bracketed paste 模式(vt100 的 `screen().bracketed_paste()` 为真),把粘贴内容包进 `ESC[200~ … ESC[201~`,避免 vim 等把粘贴当连续输入触发自动缩进。 +2. **选区(较复杂)** + - 因为渲染是 DOM(每行一个 `
`、每段一个 ``),可优先尝试**浏览器原生选区**:给可见行允许 `user-select: text`,监听 `copy` 事件,从 `window.getSelection()` 取文本。 + - 但虚拟滚动只渲染可见窗口,跨窗口选区会断 → 需要把「逻辑行号 → 文本」的映射暴露给选区逻辑,从 `TerminalCore` 的 `history_rows + screen_rows` 直接取纯文本(已有 `row` → 文本的能力,测试里 `row_text` 即是)。 + - MVP 可只支持**可见范围内选区 + 复制**,跨滚动选区列为后续。 +3. **快捷键**:Ctrl-Shift-C / Ctrl-Shift-V(终端惯例,避免和 Ctrl-C 中断冲突)。 + +**风险**:选区与虚拟滚动的交互是主要难点;建议 MVP 限定可见区,先把粘贴 + 可见区复制跑通。 + +--- + +### P1 — 完整功能键 + application-cursor 模式 + +**现状**:只映射了方向键、Ctrl-C/D/L、Enter/Tab/Esc/Backspace。缺 F1–F12、Home/End/Insert/Delete、PageUp/PageDown、大量 Ctrl/Alt 组合。 + +**计划实现**: +1. 扩展 `map_key_to_terminal_input`,补齐: + - `F1`–`F12` → `ESC O P/Q/R/S`(F1–4)、`ESC[15~`…(F5–12) + - `Home`/`End` → `ESC[H` / `ESC[F`(或 `ESC[1~` / `ESC[4~`) + - `Insert`/`Delete` → `ESC[2~` / `ESC[3~` + - `PageUp`/`PageDown` → `ESC[5~` / `ESC[6~` + - `Ctrl+A..Z` → 控制字符 `0x01..0x1A`(通用化,不再只 C/D/L) + - `Alt+` → 前缀 `ESC` + 字符 +2. **application-cursor 模式**:vt100 暴露 `screen().application_cursor()`。为真时,方向键要发 **SS3 形式 `ESC O A/B/C/D`** 而非 `ESC [ A/B/C/D`。`handle_keydown` 和 `handle_wheel`(alt-screen 滚轮翻页)都要据此切换序列。 + - 这能修掉「某些 vim 配置 / 全屏程序里方向键行为怪异」的潜在问题。 + +**风险**:低。纯查表扩展 + 一个模式判断。建议把按键表抽成数据驱动并加单元测试覆盖。 + +--- + +### P2 — 鼠标上报(mouse reporting) + +**现状**:没有。点击 / 拖拽位置不会发给应用,vim 鼠标、tmux 选区、btm 点击都用不了。 + +**计划实现**: +1. vt100 已解析鼠标模式:`screen().mouse_protocol_mode()`(None/Press/PressRelease/ButtonMotion/AnyMotion)和 `mouse_protocol_encoding()`(Default/Utf8/Sgr)。 +2. 在 `terminal-screen` 上监听 `on:mousedown` / `on:mouseup` / `on:mousemove`: + - 把像素坐标换算成终端 **行列**(用已测量的 cell 宽高 + 容器 rect)。 + - 按当前 encoding 编码: + - **SGR**(最常用):`ESC[`** 的标准方案(xterm.js 也是这套): + - 一个 1×1 透明、跟随光标的 `textarea` 承接 IME。 + - 监听 `compositionstart` / `compositionupdate` / `compositionend`。 + - 组合进行中不发字节;`compositionend` 时把最终文本一次性作为 Input 发送。 + - 普通 `input` 事件(非组合)也走这个 textarea,替代部分 `keydown` 逐字符逻辑。 +2. 光标定位:把隐藏 textarea 定位到终端光标处,让候选框出现在正确位置。 + +**风险**:中高。事件模型要从「keydown 逐键」迁一部分到「textarea + composition」,需保证不回归现有按键。建议**先把 P1 功能键做完再动这块**,避免两套输入逻辑互相打架。 + +--- + +## 3. 其余缺口(暂不排期,记录备查) + +xterm.js 还有这些,本项目暂列「按需再做」: + +- **链接检测**(点 URL 打开)、**搜索 / 查找**(Ctrl-F 高亮) +- **光标样式**:块 / 竖线 / 下划线 + 闪烁(目前固定块状反色) +- **Canvas / WebGL 渲染器**:当前是 DOM 渲染,够用但不如 Canvas 快;超大终端 + 高刷新率场景才需要 +- **sixel / 图片协议**、**字形连字控制** +- **Unicode 11/15 宽度表**:目前依赖 vt100 的宽度判断,极端 emoji / 新字符可能不准 +- **无障碍**(屏幕阅读器 live region) +- **公开 API / 插件系统 / 配置项**:当前是项目内定制组件,无对外 API + +--- + +## 4. 建议推进顺序 + +``` +P0 复制粘贴(先粘贴→可见区复制) ← 最先,体验提升最大 + ↓ +P1 完整功能键 + application-cursor + ↓ +P2 鼠标上报 + ↓ +P3 中文 IME(放在功能键之后,避免输入逻辑冲突) +``` + +每一项都应延续现有做法:**纯逻辑抽成可单测的函数**(如 `map_key_to_terminal_input`、`wheel_delta_to_rows`、`virtual_window`),用 `cargo test -p app --lib terminal::` 锁定,浏览器只做交互验证。 diff --git a/app/src/terminal/component.rs b/app/src/terminal/component.rs index 4519b47..6c04df4 100644 --- a/app/src/terminal/component.rs +++ b/app/src/terminal/component.rs @@ -1,4 +1,3 @@ -use std::cell::RefCell; use std::rc::Rc; use app_config::SiteConfig; @@ -9,15 +8,12 @@ use wasm_bindgen::closure::Closure; use web_sys::js_sys::{Array, Function}; use web_sys::{ BinaryType, ErrorEvent, Event, HtmlElement, KeyboardEvent, MessageEvent, - ResizeObserver, ResizeObserverEntry, WebSocket, + ResizeObserver, ResizeObserverEntry, WebSocket, WheelEvent, }; -use super::core::{ - DEFAULT_COLS, DEFAULT_ROWS, TerminalCore, TerminalRow, TerminalSegment, - TerminalSnapshot, -}; +use super::core::{TerminalCore, TerminalRow, TerminalSegment}; use super::protocol::{ClientTerminalMessage, ServerTerminalMessage}; -use super::scrollback::{ScrollMetrics, ScrollbackModel}; +use super::scrollback::ViewMode; const DEFAULT_CELL_WIDTH: f64 = 8.0; const DEFAULT_CELL_HEIGHT: f64 = 18.0; @@ -27,6 +23,10 @@ const MEASURE_SAMPLE_TEXT: &str = "WWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWW"; const SCROLL_LINE_HEIGHT: f64 = 18.0; +/// Extra rows rendered above and below the visible window so that fast +/// scrolling doesn't flash blank areas before the next frame arrives. +const VIRTUAL_OVERSCAN: usize = 5; + #[derive(Clone, Copy, Debug, PartialEq, Eq)] struct TerminalViewport { cols: u16, @@ -38,243 +38,251 @@ struct TerminalViewport { impl Default for TerminalViewport { fn default() -> Self { Self { - cols: DEFAULT_COLS, - rows: DEFAULT_ROWS, - pixel_width: (f64::from(DEFAULT_COLS) * DEFAULT_CELL_WIDTH).round() - as u16, - pixel_height: (f64::from(DEFAULT_ROWS) * DEFAULT_CELL_HEIGHT) + cols: super::core::DEFAULT_COLS, + rows: super::core::DEFAULT_ROWS, + pixel_width: (f64::from(super::core::DEFAULT_COLS) * DEFAULT_CELL_WIDTH) + .round() as u16, + pixel_height: (f64::from(super::core::DEFAULT_ROWS) * DEFAULT_CELL_HEIGHT) .round() as u16, } } } +// --------------------------------------------------------------------------- +// TerminalPanel component +// --------------------------------------------------------------------------- + #[component] pub fn TerminalPanel() -> impl IntoView { let terminal_ref = NodeRef::::new(); let measure_ref = NodeRef::::new(); - let snapshot = RwSignal::new(TerminalSnapshot::default()); let connection_status = RwSignal::new("Connecting".to_owned()); let ws_signal = RwSignal::new(None::); let current_viewport = RwSignal::new(TerminalViewport::default()); - let output_updates = RwSignal::new(0_u64); - let scrollback_model = RwSignal::new(ScrollbackModel::default()); - let ignore_next_scroll = RwSignal::new(false); - let terminal_core = Rc::new(RefCell::new(TerminalCore::default())); + let view_mode = RwSignal::new(ViewMode::Live); + // The scrollTop we last set programmatically. The scroll handler compares + // against it to robustly tell our own programmatic scrolls apart from real + // user scrolls (a single-shot "ignore next" flag was racy and caused an + // auto-pin ↔ user-scroll feedback loop / momentum bounce). + let programmatic_scroll_top = RwSignal::new(-1_i32); - Effect::new({ - let terminal_core = terminal_core.clone(); + // Terminal state lives in a reactive signal, but it is mutated via + // `update_untracked` so that feeding bytes never triggers a render on its + // own — rendering is driven explicitly by `render_tick` below. This + // decouples the (very fast, ~1µs/line) data-ingestion rate from the + // (expensive) DOM-render rate: a burst of hundreds of WebSocket packets + // from `cat`-ing a huge file collapses into a single render per frame. + let core_signal = RwSignal::new(TerminalCore::default()); - move |_| { - let url = websocket_url(); - let Ok(socket) = WebSocket::new(&url) else { - connection_status.set("Failed to connect".to_owned()); - return; - }; + // Bumped at most once per animation frame to drive exactly one render. + let render_tick = RwSignal::new(0_u64); - socket.set_binary_type(BinaryType::Arraybuffer); - let socket = Rc::new(socket); + // Current scrollTop in pixels — updated by the scroll handler so that + // the virtual-scroll render closure knows which window to materialise. + let scroll_top = RwSignal::new(0_i32); - let open_socket = socket.clone(); - let open_status = connection_status; - let open_ws = ws_signal; - let open_viewport = current_viewport; - let open_ref = terminal_ref; - let open_measure = measure_ref; - let open_snapshot = snapshot; - let open_model = scrollback_model; - let open_ignore_scroll = ignore_next_scroll; - let open_core = terminal_core.clone(); - let on_open = Closure::::new(move |_| { - open_status.set("Connected".to_owned()); - open_ws.set(Some((*open_socket).clone())); + // -- WebSocket lifecycle ------------------------------------------------ - let viewport = measure_terminal_viewport(open_ref, open_measure); - open_viewport.set(viewport); + Effect::new(move |_| { + let url = websocket_url(); + let Ok(socket) = WebSocket::new(&url) else { + connection_status.set("Failed to connect".to_owned()); + return; + }; - { - let mut core = open_core.borrow_mut(); - resize_terminal_core(&mut core, viewport); - let mut model = open_model.get_untracked(); - model.enter_live(metrics_from_core(viewport, &mut core)); - open_model.set(model); - let next_snapshot = snapshot_for_model(&mut core, model, viewport); - open_snapshot.set(next_snapshot.clone()); - sync_scroll_to_model( - open_ref, - next_snapshot, - model, - viewport, - open_ignore_scroll, - ); + socket.set_binary_type(BinaryType::Arraybuffer); + let socket = Rc::new(socket); + + // onopen + let open_socket = socket.clone(); + let open_status = connection_status; + let open_ws = ws_signal; + let open_viewport = current_viewport; + let open_ref = terminal_ref; + let open_measure = measure_ref; + let open_mode = view_mode; + let open_prog_scroll = programmatic_scroll_top; + let open_core = core_signal; + let open_tick = render_tick; + let on_open = Closure::::new(move |_| { + open_status.set("Connected".to_owned()); + open_ws.set(Some((*open_socket).clone())); + + let viewport = measure_terminal_viewport(open_ref, open_measure); + open_viewport.set(viewport); + + open_core + .update_untracked(|core| core.resize(viewport.rows, viewport.cols)); + open_tick.update(|t| *t += 1); + open_mode.set(ViewMode::Live); + + send_resize_message(&open_socket, viewport); + sync_scroll_to_bottom(open_ref, open_prog_scroll); + focus_terminal(open_ref); + }); + socket.set_onopen(Some(on_open.as_ref().unchecked_ref())); + on_open.forget(); + + // onmessage + let message_core = core_signal; + let message_status = connection_status; + let message_ref = terminal_ref; + let message_mode = view_mode; + let message_prog_scroll = programmatic_scroll_top; + let message_tick = render_tick; + // Coalescing guard: at most one animation frame is scheduled at a time. + // A burst of packets all land in `core` (untracked) but only trigger a + // single render on the next frame. + let raf_pending = Rc::new(std::cell::Cell::new(false)); + let on_message = + Closure::::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; + }; + + match message { + ServerTerminalMessage::Output { data } => { + // Feed bytes without triggering a render (untracked). + // `try_*` so a stale callback from a disposed mount + // no-ops instead of panicking. + if message_core + .try_update_untracked(|core| { + core.process(data.as_bytes()) + }) + .is_none() + { + return; + } + + // Schedule exactly one render for the next frame. + schedule_frame( + raf_pending.clone(), + message_tick, + message_mode, + message_ref, + message_prog_scroll, + ); + } + ServerTerminalMessage::Exit { code } => { + message_status.set(match code { + Some(code) => format!("Exited ({code})"), + None => "Exited".to_owned(), + }); + } + ServerTerminalMessage::Error { message } => { + message_status.set(format!("Error: {message}")); + } + ServerTerminalMessage::Pong => {} + } + }); + socket.set_onmessage(Some(on_message.as_ref().unchecked_ref())); + on_message.forget(); + + // onclose + let close_status = connection_status; + let close_ws = ws_signal; + let on_close = Closure::::new(move |_| { + close_status.set("Disconnected".to_owned()); + close_ws.set(None); + }); + socket.set_onclose(Some(on_close.as_ref().unchecked_ref())); + on_close.forget(); + + // onerror + let error_status = connection_status; + let on_error = Closure::::new(move |_| { + error_status.set("Connection error".to_owned()); + }); + socket.set_onerror(Some(on_error.as_ref().unchecked_ref())); + on_error.forget(); + }); + + // -- ResizeObserver ----------------------------------------------------- + + Effect::new(move |_| { + let Some(element) = terminal_ref.get() else { + return; + }; + let Some(socket) = ws_signal.get() else { + return; + }; + + let resize_socket = socket.clone(); + let resize_viewport = current_viewport; + let resize_ref = terminal_ref; + let resize_measure = measure_ref; + let resize_mode = view_mode; + let resize_prog_scroll = programmatic_scroll_top; + let resize_core = core_signal; + let resize_st = scroll_top; + let resize_tick = render_tick; + let callback = Closure::::new( + move |entries: Array, _observer: ResizeObserver| { + let Some(entry) = entries + .get(0) + .dyn_into::() + .ok() + else { + return; + }; + let rect = entry.content_rect(); + let viewport = measure_terminal_viewport_from_rect( + rect.width(), + rect.height(), + resize_measure, + ); + + if viewport != resize_viewport.get_untracked() { + resize_viewport.set(viewport); + + let mode_val = resize_mode.get_untracked(); + let st = resize_st.get_untracked(); + + resize_core.update_untracked(|core| { + core.resize(viewport.rows, viewport.cols); + }); + resize_tick.update(|t| *t += 1); + + if mode_val == ViewMode::Live { + sync_scroll_to_bottom(resize_ref, resize_prog_scroll); + } else { + sync_scroll_to_position( + resize_ref, + st, + resize_prog_scroll, + ); + } + + send_resize_message(&resize_socket, viewport); } - send_resize_message(&open_socket, viewport); - focus_terminal(open_ref); - }); - socket.set_onopen(Some(on_open.as_ref().unchecked_ref())); - on_open.forget(); + focus_terminal(resize_ref); + }, + ); - let message_snapshot = snapshot; - let message_core = terminal_core.clone(); - let message_status = connection_status; - let message_ref = terminal_ref; - let message_updates = output_updates; - let message_viewport = current_viewport; - let message_model = scrollback_model; - let message_ignore_scroll = ignore_next_scroll; - let on_message = - Closure::::new(move |event: MessageEvent| { - let Some(text) = event.data().as_string() else { - return; - }; + let Ok(observer) = + ResizeObserver::new(callback.as_ref().unchecked_ref::()) + else { + return; + }; - let Ok(message) = - serde_json::from_str::(&text) - else { - message_status.set("Protocol error".to_owned()); - return; - }; - - match message { - ServerTerminalMessage::Output { data } => { - let viewport = message_viewport.get_untracked(); - let mut core = message_core.borrow_mut(); - core.process(data.as_bytes()); - - let mut model = message_model.get_untracked(); - model.normalize(metrics_from_core(viewport, &mut core)); - message_model.set(model); - - let next_snapshot = - snapshot_for_model(&mut core, model, viewport); - message_snapshot.set(next_snapshot.clone()); - message_updates.update(|count| *count += 1); - - if model.is_live() { - sync_scroll_to_model( - message_ref, - next_snapshot, - model, - viewport, - message_ignore_scroll, - ); - } - - focus_terminal(message_ref); - } - 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(); - - let close_status = connection_status; - let close_ws = ws_signal; - let on_close = Closure::::new(move |_| { - close_status.set("Disconnected".to_owned()); - close_ws.set(None); - }); - socket.set_onclose(Some(on_close.as_ref().unchecked_ref())); - on_close.forget(); - - let error_status = connection_status; - let on_error = Closure::::new(move |_| { - error_status.set("Connection error".to_owned()); - }); - socket.set_onerror(Some(on_error.as_ref().unchecked_ref())); - on_error.forget(); - } + observer.observe(&element); + callback.forget(); + std::mem::forget(observer); }); - Effect::new({ - let terminal_core = terminal_core.clone(); - - move |_| { - let Some(element) = terminal_ref.get() else { - return; - }; - let Some(socket) = ws_signal.get() else { - return; - }; - - let resize_socket = socket.clone(); - let resize_viewport = current_viewport; - let resize_ref = terminal_ref; - let resize_measure = measure_ref; - let resize_snapshot = snapshot; - let resize_model = scrollback_model; - let resize_ignore_scroll = ignore_next_scroll; - let resize_core = terminal_core.clone(); - let callback = Closure::::new( - move |entries: Array, _observer: ResizeObserver| { - let Some(entry) = entries - .get(0) - .dyn_into::() - .ok() - else { - return; - }; - let rect = entry.content_rect(); - let viewport = measure_terminal_viewport_from_rect( - rect.width(), - rect.height(), - resize_measure, - ); - - if viewport != resize_viewport.get_untracked() { - resize_viewport.set(viewport); - - let mut core = resize_core.borrow_mut(); - resize_terminal_core(&mut core, viewport); - - let mut model = resize_model.get_untracked(); - model.normalize(metrics_from_core(viewport, &mut core)); - resize_model.set(model); - - let next_snapshot = - snapshot_for_model(&mut core, model, viewport); - resize_snapshot.set(next_snapshot.clone()); - sync_scroll_to_model( - resize_ref, - next_snapshot, - model, - viewport, - resize_ignore_scroll, - ); - - send_resize_message(&resize_socket, viewport); - } - - focus_terminal(resize_ref); - }, - ); - - let Ok(observer) = ResizeObserver::new( - callback.as_ref().unchecked_ref::(), - ) else { - return; - }; - - observer.observe(&element); - callback.forget(); - std::mem::forget(observer); - } - }); + // -- Keyboard input ----------------------------------------------------- let handle_keydown = { - let terminal_core = terminal_core.clone(); - let keydown_viewport = current_viewport; - let keydown_model = scrollback_model; - let keydown_ignore_scroll = ignore_next_scroll; + let keydown_mode = view_mode; + let keydown_prog_scroll = programmatic_scroll_top; move |event: KeyboardEvent| { let Some(socket) = ws_signal.get_untracked() else { @@ -285,70 +293,95 @@ pub fn TerminalPanel() -> impl IntoView { return; }; - let model = keydown_model.get_untracked(); - if !model.is_live() { - let viewport = keydown_viewport.get_untracked(); - let mut core = terminal_core.borrow_mut(); - let mut next_model = model; - next_model.enter_live(metrics_from_core(viewport, &mut core)); - keydown_model.set(next_model); - - let live_snapshot = - snapshot_for_model(&mut core, next_model, viewport); - snapshot.set(live_snapshot.clone()); - sync_scroll_to_model( - terminal_ref, - live_snapshot, - next_model, - viewport, - keydown_ignore_scroll, - ); + if !keydown_mode.get_untracked().is_live() { + keydown_mode.set(ViewMode::Live); + sync_scroll_to_bottom(terminal_ref, keydown_prog_scroll); } event.prevent_default(); - send_terminal_message(&socket, &ClientTerminalMessage::Input { data }); + send_terminal_message( + &socket, + &ClientTerminalMessage::Input { data }, + ); } }; + // -- Scroll handler ------------------------------------------------------ + let handle_scroll = { - let terminal_core = terminal_core.clone(); - let scroll_viewport = current_viewport; - let scroll_model = scrollback_model; - let scroll_ignore_next = ignore_next_scroll; + let scroll_mode = view_mode; + let scroll_prog = programmatic_scroll_top; move |_| { - if scroll_ignore_next.get_untracked() { - scroll_ignore_next.set(false); - return; - } - let Some(element) = terminal_ref.get_untracked() else { return; }; - let viewport = scroll_viewport.get_untracked(); - let mut core = terminal_core.borrow_mut(); - let metrics = metrics_from_core(viewport, &mut core); + let st = element.scroll_top(); - let mut model = scroll_model.get_untracked(); - model.apply_scroll_top( - element.scroll_top(), + // Distinguish our own programmatic scrolls from real user scrolls by + // position (robust against missed/duplicated scroll events that a + // single-shot ignore flag could not handle). If this position is + // the one we just set programmatically, treat it as an echo and do + // not touch the view mode — only record scroll_top for rendering. + let is_programmatic_echo = + (st - scroll_prog.get_untracked()).abs() <= 2; + + scroll_top.set(st); + + if is_programmatic_echo { + return; + } + + let mode = super::scrollback::view_mode_from_scroll( + st, element.scroll_height(), element.client_height(), SCROLL_LINE_HEIGHT, - metrics, ); - model.normalize(metrics); - scroll_model.set(model); + scroll_mode.set(mode); + } + }; - let next_snapshot = snapshot_for_model(&mut core, model, viewport); - snapshot.set(next_snapshot.clone()); - sync_scroll_to_model( - terminal_ref, - next_snapshot, - model, - viewport, - scroll_ignore_next, + // -- Wheel handler (alt-screen only) ------------------------------------ + // + // The alternate screen (vim / less / man) has no scrollback, so native + // wheel scrolling does nothing useful there. Like a real terminal, we + // translate wheel ticks into arrow-key presses sent to the application so + // the wheel pages through it. On the main screen we do nothing and let the + // browser scroll natively (handled by `on:scroll`). + let handle_wheel = { + let wheel_core = core_signal; + + move |event: WheelEvent| { + let in_alt = wheel_core + .try_with_untracked(|core| core.alternate_screen()) + == Some(true); + if !in_alt { + return; // main screen → native scroll + } + + let Some(socket) = ws_signal.get_untracked() else { + return; + }; + + let rows = wheel_delta_to_rows(event.delta_y(), event.delta_mode()); + if rows == 0 { + return; + } + + // Up (negative deltaY) → ESC[A, down → ESC[B, repeated per row. + let seq = if event.delta_y() < 0.0 { + "\u{1b}[A" + } else { + "\u{1b}[B" + }; + let data = seq.repeat(rows); + + event.prevent_default(); + send_terminal_message( + &socket, + &ClientTerminalMessage::Input { data }, ); } }; @@ -357,6 +390,8 @@ pub fn TerminalPanel() -> impl IntoView { focus_terminal(terminal_ref); }; + // -- View --------------------------------------------------------------- + view! {
@@ -370,20 +405,25 @@ pub fn TerminalPanel() -> impl IntoView { {move || connection_status.get()} {move || { - let viewport = current_viewport.get(); - let buffer = snapshot.get(); - let model = scrollback_model.get(); - format!( - "view {} x {} / buffer {} x {} / scroll {} of {} / mode {} / {} updates", - viewport.cols, - viewport.rows, - buffer.terminal_cols, - buffer.terminal_rows, - buffer.scrollback_offset, - buffer.max_scrollback, - if model.is_live() { "live" } else { "history" }, - output_updates.get() - ) + let _ = render_tick.get(); + // `try_*` so a stale render during hydration (when + // the signal may already be disposed) no-ops. + core_signal + .try_with_untracked(|core| { + let viewport = current_viewport.get(); + let mode = view_mode.get(); + format!( + "view {}x{} / term {}x{} / history {} / screen {} / mode {}", + viewport.cols, + viewport.rows, + core.size().1, + core.size().0, + core.history_rows().len(), + core.screen_rows().len(), + if mode.is_live() { "live" } else { "history" }, + ) + }) + .unwrap_or_default() }}
@@ -396,81 +436,110 @@ pub fn TerminalPanel() -> impl IntoView { on:click=handle_click on:keydown=handle_keydown on:scroll=handle_scroll + on:wheel=handle_wheel >
- {move || render_terminal_view(snapshot.get())} + {move || { + // Subscribe to render_tick (frame-coalesced data + // changes), scroll_top (scroll), and view_mode + // (live/history switch). + let _ = render_tick.get(); + let is_live = view_mode.get().is_live(); + let vp_rows = current_viewport.get_untracked().rows 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())); + render_spaced_view(total_px, offset_px, visible) + }}
} } -fn metrics_from_core( - viewport: TerminalViewport, - core: &mut TerminalCore, -) -> ScrollMetrics { - let snapshot = core.snapshot_live(); - ScrollMetrics { - viewport_rows: usize::from(viewport.rows), - scrollback_rows: snapshot.max_scrollback, - } -} +// --------------------------------------------------------------------------- +// Virtual-scroll rendering +// --------------------------------------------------------------------------- -fn snapshot_for_model( - core: &mut TerminalCore, - model: ScrollbackModel, - viewport: TerminalViewport, -) -> TerminalSnapshot { - let metrics = metrics_from_core(viewport, core); - if model.is_live() { - core.snapshot_live() +/// Compute the visible window slice. Returns owned data so the caller can +/// drop the `&TerminalCore` borrow before building the view. +/// +/// In live mode the window is pinned to the **bottom** (the last `vp_rows` +/// rows) regardless of `scroll_top_px`, because the DOM scrollTop is driven to +/// the bottom separately and the rendered rows must line up there. In history +/// mode the window follows `scroll_top_px`. +fn virtual_window( + core: &TerminalCore, + is_live: bool, + scroll_top_px: i32, + vp_rows: usize, +) -> (f64, f64, Vec) { + let total = core.total_rows(); + let is_alt = core.alternate_screen(); + + if is_alt || total == 0 { + // Alt-screen / empty: content fits the viewport; total height == rows. + let rows = core.screen_rows().to_vec(); + let total_px = rows.len() as f64 * SCROLL_LINE_HEIGHT; + return (total_px, 0.0, rows); + } + + let first_visible = if is_live { + // Pin to the bottom: first visible row = total - viewport. + total.saturating_sub(vp_rows) } else { - core.snapshot_for_top_row(model.top_row().min(metrics.max_top_row())) - } -} - -fn render_terminal_view(snapshot: TerminalSnapshot) -> impl IntoView { - let metrics = ScrollMetrics { - viewport_rows: usize::from(snapshot.terminal_rows), - scrollback_rows: snapshot.max_scrollback, + (f64::from(scroll_top_px.max(0)) / SCROLL_LINE_HEIGHT).floor() as usize }; - let mut model = ScrollbackModel::default(); - if snapshot.scrollback_offset == 0 { - model.enter_live(metrics); - } else { - let top_row = metrics - .max_top_row() - .saturating_sub(snapshot.scrollback_offset); - model.apply_scroll_top( - (top_row as f64 * SCROLL_LINE_HEIGHT).round() as i32, - (metrics.total_rows() as f64 * SCROLL_LINE_HEIGHT).round() as i32, - (metrics.viewport_rows as f64 * SCROLL_LINE_HEIGHT).round() as i32, - SCROLL_LINE_HEIGHT, - metrics, - ); - } - let plan = model.render_plan(metrics); + let start = first_visible.saturating_sub(VIRTUAL_OVERSCAN); + let end = (first_visible + vp_rows + VIRTUAL_OVERSCAN).min(total); + // Absolute-positioning virtual scroll: the container is ALWAYS exactly + // `total_px` tall (so the scroll geometry never changes when the visible + // window is swapped), and the rendered rows are offset to `offset_px` from + // the top. Keeping the container height constant across re-renders is what + // prevents the browser from clamping scrollTop mid-scroll and firing the + // feedback scroll events that caused the "keeps drifting up" behaviour. + let total_px = total as f64 * SCROLL_LINE_HEIGHT; + let offset_px = start as f64 * SCROLL_LINE_HEIGHT; + (total_px, offset_px, core.collect_window(start, end)) +} + +/// Render the visible rows using absolute positioning inside a fixed-height +/// container. +/// +/// The outer `.terminal-grid` is sized to the FULL content height +/// (`total_px`), constant across re-renders, so swapping the visible window +/// never changes the scroll container's geometry — the browser keeps the +/// scrollTop stable and fires no spurious scroll events. The visible rows are +/// placed in an absolutely-positioned layer offset by `offset_px`. +fn render_spaced_view( + total_px: f64, + offset_px: f64, + visible: Vec, +) -> impl IntoView { view! { - {snapshot - .rows - .into_iter() - .map(render_terminal_row) - .collect_view()} + class="terminal-virtual-rows" + style=format!("transform:translateY({offset_px}px);") + > + {visible.into_iter().map(render_terminal_row).collect_view()} + } } @@ -487,8 +556,13 @@ fn render_terminal_row(row: TerminalRow) -> impl IntoView { } fn render_terminal_segment(segment: TerminalSegment) -> impl IntoView { + // Lock the segment to an exact `cols`-cell grid width. `ch` is the advance + // of "0" in the monospace font (== the cell width), so glyphs pulled from a + // fallback font with a different advance (e.g. braille sparkline chars in + // `btm`) are clipped to the grid instead of pushing the row sideways. let style = format!( - "color:{};background:{};font-weight:{};font-style:{};text-decoration:{};", + "width:{}ch;color:{};background:{};font-weight:{};font-style:{};text-decoration:{};", + segment.cols, segment.style.color, segment.style.background, if segment.style.bold { "700" } else { "400" }, @@ -507,16 +581,125 @@ fn render_terminal_segment(segment: TerminalSegment) -> impl IntoView { } } +// --------------------------------------------------------------------------- +// Frame-coalesced render scheduling +// --------------------------------------------------------------------------- + +/// Schedule a single render for the next animation frame. +/// +/// While `raf_pending` is set, additional calls are no-ops, so any number of +/// WebSocket packets that arrive within the same frame collapse into one +/// render. On the frame, `render_tick` is bumped (triggering exactly one DOM +/// render) and, if the view is in live mode, the viewport is scrolled to the +/// bottom — once, instead of once per packet. +fn schedule_frame( + raf_pending: Rc>, + render_tick: RwSignal, + view_mode: RwSignal, + terminal_ref: NodeRef, + programmatic_scroll_top: RwSignal, +) { + if raf_pending.get() { + return; + } + raf_pending.set(true); + + let callback = Closure::once_into_js(move || { + raf_pending.set(false); + // One reactive render for everything ingested since the last frame. + // `try_*` so a frame that fires after the component is disposed + // no-ops instead of panicking. + if render_tick.try_update(|t| *t += 1).is_none() { + return; + } + + // Follow new output only while the view is in live mode. The scroll + // handler flips to History the instant the user scrolls up (and the + // position-based echo detection keeps our own programmatic scrolls from + // being mistaken for user scrolls), so this never fights the user. + if view_mode.try_get_untracked().map(|m| m.is_live()) == Some(true) { + sync_scroll_to_bottom(terminal_ref, programmatic_scroll_top); + } + }); + + let _ = window().request_animation_frame(callback.as_ref().unchecked_ref()); +} + +// --------------------------------------------------------------------------- +// Scroll synchronisation helpers +// --------------------------------------------------------------------------- + +/// Pin the viewport to the bottom. Idempotent: if already at the bottom it +/// writes nothing (so it can't interrupt the user's momentum scrolling). The +/// target position is recorded in `programmatic_scroll_top` so the scroll +/// handler can recognise the resulting scroll event as our own. +fn sync_scroll_to_bottom( + terminal_ref: NodeRef, + 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 _ = programmatic_scroll_top.try_set(max_scroll); + if (element.scroll_top() - max_scroll).abs() > 1 { + element.set_scroll_top(max_scroll); + } + })); + let _ = window().set_timeout_with_callback_and_timeout_and_arguments_0( + callback.as_ref().unchecked_ref(), + 0, + ); + callback.forget(); +} + +/// Scroll to an arbitrary pixel position (used after resize in history mode), +/// recording it as a programmatic scroll. +fn sync_scroll_to_position( + terminal_ref: NodeRef, + target_scroll_top: i32, + 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 target = target_scroll_top.min(max_scroll); + let _ = programmatic_scroll_top.try_set(target); + if (element.scroll_top() - target).abs() > 1 { + element.set_scroll_top(target); + } + })); + let _ = window().set_timeout_with_callback_and_timeout_and_arguments_0( + callback.as_ref().unchecked_ref(), + 0, + ); + callback.forget(); +} + +// --------------------------------------------------------------------------- +// Network helpers +// --------------------------------------------------------------------------- + fn websocket_url() -> String { let window = window(); let location = window.location(); let protocol = location.protocol().unwrap_or_else(|_| "http:".to_owned()); - let host = location.host().unwrap_or_else(|_| "127.0.0.1:3100".to_owned()); + let host = location + .host() + .unwrap_or_else(|_| "127.0.0.1:3100".to_owned()); let scheme = if protocol == "https:" { "wss" } else { "ws" }; format!("{scheme}://{host}{}/terminal/ws", SiteConfig::BASE_PATH) } -fn send_terminal_message(socket: &WebSocket, message: &ClientTerminalMessage) { +fn send_terminal_message( + socket: &WebSocket, + message: &ClientTerminalMessage, +) { if let Ok(payload) = serde_json::to_string(message) { let _ = socket.send_with_str(&payload); } @@ -534,9 +717,9 @@ fn send_resize_message(socket: &WebSocket, viewport: TerminalViewport) { ); } -fn resize_terminal_core(core: &mut TerminalCore, viewport: TerminalViewport) { - core.resize(viewport.rows, viewport.cols); -} +// --------------------------------------------------------------------------- +// Measurement helpers +// --------------------------------------------------------------------------- fn measure_terminal_viewport( terminal_ref: NodeRef, @@ -564,8 +747,10 @@ fn measure_terminal_viewport_from_rect( 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(MIN_COLS); + let rows = + ((safe_height / cell_height).floor().max(1.0) as u16).max(MIN_ROWS); TerminalViewport { cols, @@ -602,37 +787,30 @@ fn focus_terminal(terminal_ref: NodeRef) { } } -fn sync_scroll_to_model( - terminal_ref: NodeRef, - snapshot: TerminalSnapshot, - model: ScrollbackModel, - viewport: TerminalViewport, - ignore_next_scroll: RwSignal, -) { - let callback = Closure::::wrap(Box::new(move || { - let Some(element) = terminal_ref.get_untracked() else { - return; - }; +// --------------------------------------------------------------------------- +// Key mapping +// --------------------------------------------------------------------------- - let metrics = ScrollMetrics { - viewport_rows: usize::from(viewport.rows), - scrollback_rows: snapshot.max_scrollback, - }; - let scroll_top = model.target_scroll_top( - element.scroll_height(), - element.client_height(), - SCROLL_LINE_HEIGHT, - metrics, - ); - ignore_next_scroll.set(true); - element.set_scroll_top(scroll_top); - })); +/// 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 small +/// positive row count (the direction is taken from the sign separately). One +/// notch is clamped to scroll at least 1 and at most a viewport-ish chunk. +fn wheel_delta_to_rows(delta_y: f64, delta_mode: u32) -> usize { + const WHEEL_PIXELS_PER_ROW: f64 = SCROLL_LINE_HEIGHT; + const WHEEL_LINES_PER_PAGE: f64 = 8.0; + const MAX_ROWS_PER_EVENT: usize = 12; - let _ = window().set_timeout_with_callback_and_timeout_and_arguments_0( - callback.as_ref().unchecked_ref(), - 0, - ); - callback.forget(); + 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 + }; + (rows.round() as usize).clamp(1, MAX_ROWS_PER_EVENT) } fn map_key_to_terminal_input(event: &KeyboardEvent) -> Option { @@ -660,3 +838,98 @@ fn map_key_to_terminal_input(event: &KeyboardEvent) -> Option { _ => None, } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::terminal::core::TerminalCore; + + fn core_with_lines(n: usize) -> TerminalCore { + let mut core = TerminalCore::new(24, 80); + for i in 1..=n { + core.process(format!("line{i}\r\n").as_bytes()); + } + core + } + + #[test] + fn wheel_delta_maps_to_row_counts() { + // Pixel mode: one notch (~one line height) → at least 1 row. + assert_eq!(wheel_delta_to_rows(SCROLL_LINE_HEIGHT, 0), 1); + // A few lines of pixels → that many rows. + assert_eq!(wheel_delta_to_rows(SCROLL_LINE_HEIGHT * 3.0, 0), 3); + // Tiny pixel delta still scrolls at least one row. + assert_eq!(wheel_delta_to_rows(1.0, 0), 1); + // Line mode: delta is already in lines. + assert_eq!(wheel_delta_to_rows(3.0, 1), 3); + // Page mode scrolls a chunk (clamped to the per-event max). + assert_eq!(wheel_delta_to_rows(1.0, 2), 8); + // Sign is irrelevant here (direction handled separately); magnitude used. + assert_eq!(wheel_delta_to_rows(-SCROLL_LINE_HEIGHT * 2.0, 0), 2); + // Zero delta → no rows. + assert_eq!(wheel_delta_to_rows(0.0, 0), 0); + // Huge delta is clamped. + assert_eq!(wheel_delta_to_rows(100_000.0, 0), 12); + } + + #[test] + fn live_mode_window_is_pinned_to_bottom_regardless_of_scroll_top() { + let core = core_with_lines(1000); + let total = core.total_rows(); + let vp_rows = 24; + + // Even with a stale scroll_top of 0, live mode must render the BOTTOM + // window (this was the blank-area / no-output bug). + let (total_px, offset_px, visible) = + virtual_window(&core, true, 0, vp_rows); + + // Sizer is always the full content height (constant across renders). + assert!( + (total_px - total as f64 * SCROLL_LINE_HEIGHT).abs() < 1.0, + "sizer height must equal total content height" + ); + // The visible window is offset to the bottom: start = total-vp-overscan. + let expected_start = total - vp_rows - VIRTUAL_OVERSCAN; + assert!( + (offset_px - expected_start as f64 * SCROLL_LINE_HEIGHT).abs() < 1.0 + ); + // The window covers through the last row. + assert!( + offset_px + visible.len() as f64 * SCROLL_LINE_HEIGHT + >= total as f64 * SCROLL_LINE_HEIGHT - 1.0, + "bottom window must reach the final row" + ); + } + + #[test] + fn history_mode_window_follows_scroll_top() { + let core = core_with_lines(1000); + let total = core.total_rows(); + let vp_rows = 24; + // Scroll to row 500 (500 * 18px). + let st = (500.0 * SCROLL_LINE_HEIGHT) as i32; + let (total_px, offset_px, visible) = + virtual_window(&core, false, st, vp_rows); + + // Sizer height is constant regardless of scroll position. + assert!((total_px - total as f64 * SCROLL_LINE_HEIGHT).abs() < 1.0); + // First rendered row should be ~ (500 - overscan). + let first_row = (offset_px / SCROLL_LINE_HEIGHT).round() as usize; + assert_eq!(first_row, 500 - VIRTUAL_OVERSCAN); + assert!(!visible.is_empty()); + } + + #[test] + fn short_content_renders_without_offset() { + let core = core_with_lines(3); // far fewer than a 24-row viewport + let (total_px, offset_px, visible) = + virtual_window(&core, true, 0, 24); + // Sizer == content height, no offset, all rows rendered. + assert!( + (total_px - core.total_rows() as f64 * SCROLL_LINE_HEIGHT).abs() + < 1.0 + ); + assert_eq!(offset_px, 0.0); + assert_eq!(visible.len(), core.total_rows()); + } +} diff --git a/app/src/terminal/core.rs b/app/src/terminal/core.rs index c420248..eb3f930 100644 --- a/app/src/terminal/core.rs +++ b/app/src/terminal/core.rs @@ -1,145 +1,246 @@ -use vt100::{Color, Parser}; +use vt100::{Color, Parser, Screen}; pub const DEFAULT_ROWS: u16 = 24; pub const DEFAULT_COLS: u16 = 80; -const DEFAULT_SCROLLBACK: usize = 2_000; + +/// Upper bound on captured scrollback history. +/// +/// vt100 stores scrolled-off rows in a lazily-grown `VecDeque` capped at this +/// length, popping the oldest when full. Because we drain into `history_rows` +/// after every `process()` and `captured_count` can never exceed this value, +/// `history_rows.len()` is automatically bounded here too — no separate +/// trimming needed. Beyond this many scrolled lines the oldest history is +/// dropped, which is the standard behaviour of every real terminal. +/// +/// 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; + +// --------------------------------------------------------------------------- +// TerminalCore — parser + explicit history buffer +// --------------------------------------------------------------------------- pub struct TerminalCore { parser: Parser, + /// 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. + history_rows: Vec, + /// Cached render of the current (live) screen, refreshed after every + /// `process()` / `resize()`. Lets the UI read screen rows cheaply on + /// scroll without re-rendering from the parser. + screen_cache: Vec, + /// How many scrollback rows we have already captured into `history_rows`. + /// Equal to `parser.screen().scrollback()` after the last capture. + captured_count: usize, } impl TerminalCore { pub fn new(rows: u16, cols: u16) -> Self { - Self { - parser: Parser::new(rows, cols, DEFAULT_SCROLLBACK), + let mut core = Self { + parser: Parser::new(rows, cols, SCROLLBACK_BUF), + history_rows: Vec::new(), + screen_cache: Vec::new(), + captured_count: 0, + }; + core.refresh_screen_cache(); + core + } + + /// Feed raw PTY bytes into the parser and capture any new history rows. + /// + /// vt100 0.15 cannot show scrollback rows deeper than `viewport_rows` + /// without panicking (its `visible_rows` underflows), so we can only ever + /// read back at most `viewport_rows` scrolled-off rows at a time. To make + /// sure no single capture needs to look deeper than that, we feed the byte + /// stream in small safe slices and capture after each — bounding how many + /// rows can scroll off between captures. + pub fn process(&mut self, bytes: &[u8]) { + // `clear` emits `ESC[3J` (erase saved lines), scanned over the whole + // stream once. vt100 0.15 ignores mode 3, and our `history_rows` is our + // own data anyway, so we apply the "wipe scrollback" part here. + let erased = contains_erase_scrollback(bytes); + if erased { + self.history_rows.clear(); + } + + // 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(); + let safe_rows = (rows as usize).saturating_sub(1).max(1); + let max_slice_bytes = safe_rows * (cols as usize).max(1); + + for slice in safe_slices(bytes, max_slice_bytes) { + self.feed_slice_and_capture(slice, erased); + } + + self.refresh_screen_cache(); + } + + /// Feed one bounded slice and capture any rows it scrolled off. + fn feed_slice_and_capture(&mut self, bytes: &[u8], erased: bool) { + let was_alternate = self.parser.screen().alternate_screen(); + + self.parser.process(bytes); + + let is_alternate = self.parser.screen().alternate_screen(); + + // During alternate screen (btm / htop) we never capture history. + if is_alternate { + return; + } + + // Just exited alt-screen — rebase `captured_count` to whatever the + // main grid currently holds (the main grid was untouched during + // alt-screen, so its scrollback is unchanged from before entry). + if was_alternate && !is_alternate { + self.parser.set_scrollback(usize::MAX); + self.captured_count = self.parser.screen().scrollback(); + self.parser.set_scrollback(0); + return; + } + + // Normal (main-screen) mode — check for new scrollback rows. + self.parser.set_scrollback(usize::MAX); + let total = self.parser.screen().scrollback(); + + if erased { + // History was just wiped. Any rows still sitting in vt100's + // scrollback are pre-clear and must NOT be re-captured into the + // freshly emptied history — claim them all as already consumed so + // `captured_count` stays in lock-step with vt100's real total. + self.captured_count = total; + self.parser.set_scrollback(0); + } else if total > self.captured_count { + let delta = total - self.captured_count; + self.capture_new_scrollback_rows(delta); + self.captured_count = total; + } else { + self.parser.set_scrollback(0); } } - pub fn process(&mut self, bytes: &[u8]) { - self.parser.process(bytes); + /// Read the `delta` newest scrollback rows out of vt100, in chronological + /// order, and append them to `history_rows`. + /// + /// Pre-condition: the caller guarantees `delta ≤ viewport_rows` (enforced by + /// the byte-slicing in `process`), and `set_scrollback(usize::MAX)` was just + /// called. This bound is essential: vt100 0.15's `visible_rows` underflows + /// if the scrollback offset exceeds the viewport row count. + /// + /// With `set_scrollback(delta)` (delta ≤ viewport), screen rows `[0..delta)` + /// are exactly the `delta` most-recently scrolled-off rows, oldest-first. + /// + /// Post-condition: scrollback viewing position is reset to 0. + fn capture_new_scrollback_rows(&mut self, delta: usize) { + if delta == 0 { + self.parser.set_scrollback(0); + return; + } + let viewport_rows = self.parser.screen().size().0 as usize; + let off = delta.min(viewport_rows); // safety clamp; never underflow vt100 + + self.parser.set_scrollback(off); + let screen = self.parser.screen(); + let mut new_rows = Vec::with_capacity(off); + for r in 0..off { + new_rows.push(render_screen_row(screen, r as u16)); + } + + self.parser.set_scrollback(0); + self.history_rows.extend(new_rows); } + /// Resize the terminal. Any rows that scroll off due to reflow are + /// captured into history. pub fn resize(&mut self, rows: u16, cols: u16) { self.parser.set_size(rows, cols); - } - pub fn set_scrollback(&mut self, rows: usize) { - self.parser.set_scrollback(rows); - } - - pub fn snapshot(&mut self) -> TerminalSnapshot { - self.snapshot_live() - } - - pub fn snapshot_live(&mut self) -> TerminalSnapshot { - self.snapshot_for_top_row_internal(None) - } - - pub fn snapshot_for_top_row(&mut self, top_row: usize) -> TerminalSnapshot { - self.snapshot_for_top_row_internal(Some(top_row)) - } - - fn snapshot_for_top_row_internal( - &mut self, - requested_top_row: Option, - ) -> TerminalSnapshot { self.parser.set_scrollback(usize::MAX); - let max_scrollback = self.parser.screen().scrollback(); - let resolved_top_row = - requested_top_row.unwrap_or(max_scrollback).min(max_scrollback); - let current_scrollback = max_scrollback.saturating_sub(resolved_top_row); - self.parser.set_scrollback(current_scrollback); + let total = self.parser.screen().scrollback(); + if total > self.captured_count { + let delta = total - self.captured_count; + self.capture_new_scrollback_rows(delta); + self.captured_count = total; + } + self.parser.set_scrollback(0); + self.refresh_screen_cache(); + } + /// Re-render the current (live) screen into `screen_cache`. + /// Always reads with scrollback=0. + fn refresh_screen_cache(&mut self) { + self.parser.set_scrollback(0); let screen = self.parser.screen(); - let (rows, cols) = screen.size(); - let (cursor_row, cursor_col) = screen.cursor_position(); - let hide_cursor = screen.hide_cursor(); - let mut rendered_rows = Vec::with_capacity(usize::from(rows)); + let (rows, _) = screen.size(); + self.screen_cache = + (0..rows).map(|r| render_screen_row(screen, r)).collect(); + } - for row in 0..rows { - let mut segments = Vec::new(); - let mut current_style: Option = None; - let mut current_cursor = false; - let mut current_text = String::new(); + /// Access the accumulated history rows (read-only). + pub fn history_rows(&self) -> &[TerminalRow] { + &self.history_rows + } - for col in 0..cols { - let Some(cell) = screen.cell(row, col) else { - continue; - }; - if cell.is_wide_continuation() { - continue; - } + /// Cached current-screen rows (read-only). + pub fn screen_rows(&self) -> &[TerminalRow] { + &self.screen_cache + } - let is_cursor = - !hide_cursor && cursor_row == row && cursor_col == col; - let style = style_from_cell(cell); - let text = if cell.has_contents() { - cell.contents() - } else { - " ".to_owned() - }; + /// Total number of logical rows: history + current screen. + pub fn total_rows(&self) -> usize { + self.history_rows.len() + self.screen_cache.len() + } - if current_style.as_ref() == Some(&style) - && current_cursor == is_cursor - { - current_text.push_str(&text); - } else { - flush_segment( - &mut segments, - &mut current_style, - &mut current_text, - current_cursor, - ); - current_style = Some(style); - current_cursor = is_cursor; - current_text = text; - } - } - - if !hide_cursor && cursor_row == row && cursor_col >= cols { - flush_segment( - &mut segments, - &mut current_style, - &mut current_text, - current_cursor, - ); - segments.push(TerminalSegment { - text: " ".to_owned(), - style: TerminalStyle::default(), - is_cursor: true, - }); - } else { - flush_segment( - &mut segments, - &mut current_style, - &mut current_text, - current_cursor, - ); - } - - if segments.is_empty() { - segments.push(TerminalSegment { - text: " ".to_owned(), - style: TerminalStyle::default(), - is_cursor: false, - }); - } - - rendered_rows.push(TerminalRow { segments }); + /// Clone the rows in the half-open window `[start, end)` of the combined + /// `history + screen` sequence. `end` is clamped to `total_rows()`. + /// + /// This is the virtual-scroll hot path: only the visible window (viewport + /// plus a small overscan, typically <100 rows) is ever cloned, regardless + /// of how large the history is. + pub fn collect_window(&self, start: usize, end: usize) -> Vec { + let total = self.total_rows(); + let end = end.min(total); + if start >= end { + return Vec::new(); } + let hist_len = self.history_rows.len(); + let mut out = Vec::with_capacity(end - start); - TerminalSnapshot { - rows: rendered_rows, - title: screen.title().to_owned(), - cursor_row, - cursor_col, - terminal_rows: rows, - terminal_cols: cols, - hide_cursor, - scrollback_offset: current_scrollback, - max_scrollback, - alternate_screen: screen.alternate_screen(), + // History portion of the window. + if start < hist_len { + let h_end = end.min(hist_len); + out.extend_from_slice(&self.history_rows[start..h_end]); } + // Screen portion of the window. + if end > hist_len { + let s_start = start.saturating_sub(hist_len); + let s_end = end - hist_len; + out.extend_from_slice(&self.screen_cache[s_start..s_end]); + } + out + } + + // -- Convenience proxies for terminal metadata (all &self, no mutation) -- + + pub fn size(&self) -> (u16, u16) { + self.parser.screen().size() + } + + pub fn cursor_position(&self) -> (u16, u16) { + self.parser.screen().cursor_position() + } + + pub fn hide_cursor(&self) -> bool { + self.parser.screen().hide_cursor() + } + + pub fn title(&self) -> &str { + self.parser.screen().title() + } + + pub fn alternate_screen(&self) -> bool { + self.parser.screen().alternate_screen() } } @@ -149,20 +250,68 @@ impl Default for TerminalCore { } } -#[derive(Clone, Debug, Default, PartialEq, Eq)] -pub struct TerminalSnapshot { - pub rows: Vec, - pub title: String, - pub cursor_row: u16, - pub cursor_col: u16, - pub terminal_rows: u16, - pub terminal_cols: u16, - pub hide_cursor: bool, - pub scrollback_offset: usize, - pub max_scrollback: usize, - pub alternate_screen: bool, +/// Scan a byte stream for a plain `ESC [ 3 J` (erase-scrollback) sequence. +/// +/// Matches only the exact `3J` parameter form that `clear` emits. It does not +/// match other erase modes (`0J`/`1J`/`2J`), SGR colours that happen to contain +/// a `3` (`ESC[33m`), or the private `ESC[?3J` (132-column reset) form. +fn contains_erase_scrollback(bytes: &[u8]) -> bool { + let mut i = 0; + while i + 2 < bytes.len() { + 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';') + { + j += 1; + } + if j < bytes.len() + && bytes[j] == b'J' + && &bytes[params_start..j] == b"3" + { + return true; + } + } + i += 1; + } + false } +/// Split a byte stream into slices that each scroll off at most `viewport_rows` +/// rows, so the capture path never asks vt100 to show scrollback deeper than +/// the viewport (which would underflow `visible_rows` in vt100 0.15). +/// +/// Slices break after each `\n` and are additionally capped at `max_bytes` so +/// that even a single very long (wrapping) line can't scroll past the viewport. +/// Splitting only ever happens on a `\n` boundary or at a hard byte cap; since +/// escape sequences never contain a raw `\n`, and the parser is stateful across +/// `process()` calls, feeding the pieces in order is equivalent to feeding the +/// whole stream at once. +fn safe_slices(bytes: &[u8], max_bytes: usize) -> Vec<&[u8]> { + let cap = max_bytes.max(1); + let mut out = Vec::new(); + let mut start = 0; + let mut i = 0; + while i < bytes.len() { + let at_newline = bytes[i] == b'\n'; + let hit_cap = i - start + 1 >= cap; + if at_newline || hit_cap { + out.push(&bytes[start..=i]); + start = i + 1; + } + i += 1; + } + if start < bytes.len() { + out.push(&bytes[start..]); + } + out +} + +// --------------------------------------------------------------------------- +// Data types +// --------------------------------------------------------------------------- + #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct TerminalRow { pub segments: Vec, @@ -171,6 +320,13 @@ pub struct TerminalRow { #[derive(Clone, Debug, PartialEq, Eq)] pub struct TerminalSegment { pub text: String, + /// Number of terminal grid columns this segment occupies. This is NOT the + /// char count: a cell may hold a base char plus combining marks (several + /// codepoints, one column), and a wide char spans two columns. The UI sets + /// the segment's CSS width to exactly this many cells so a glyph rendered by + /// a fallback font (e.g. braille sparkline chars in `btm`) can never push + /// the rest of the row sideways. + pub cols: usize, pub style: TerminalStyle, pub is_cursor: bool, } @@ -196,10 +352,97 @@ impl Default for TerminalStyle { } } +// --------------------------------------------------------------------------- +// Row rendering (extracted from old snapshot_for_top_row_internal) +// --------------------------------------------------------------------------- + +fn render_screen_row(screen: &Screen, row: u16) -> TerminalRow { + let (cursor_row, cursor_col) = screen.cursor_position(); + let hide_cursor = screen.hide_cursor(); + let (_, cols) = screen.size(); + + let mut segments = Vec::new(); + let mut current_style: Option = None; + let mut current_cursor = false; + let mut current_text = String::new(); + let mut current_cols = 0usize; + + for col in 0..cols { + let Some(cell) = screen.cell(row, col) else { + continue; + }; + if cell.is_wide_continuation() { + continue; + } + + let is_cursor = !hide_cursor && cursor_row == row && cursor_col == col; + let style = style_from_cell(cell); + let text = if cell.has_contents() { + cell.contents() + } else { + " ".to_owned() + }; + let cell_cols = if cell.is_wide() { 2 } else { 1 }; + + if current_style.as_ref() == Some(&style) && current_cursor == is_cursor { + current_text.push_str(&text); + current_cols += cell_cols; + } else { + flush_segment( + &mut segments, + &mut current_style, + &mut current_text, + &mut current_cols, + current_cursor, + ); + current_style = Some(style); + current_cursor = is_cursor; + current_text = text; + current_cols = cell_cols; + } + } + + if !hide_cursor && cursor_row == row && cursor_col >= cols { + flush_segment( + &mut segments, + &mut current_style, + &mut current_text, + &mut current_cols, + current_cursor, + ); + segments.push(TerminalSegment { + text: " ".to_owned(), + cols: 1, + style: TerminalStyle::default(), + is_cursor: true, + }); + } else { + flush_segment( + &mut segments, + &mut current_style, + &mut current_text, + &mut current_cols, + current_cursor, + ); + } + + if segments.is_empty() { + segments.push(TerminalSegment { + text: " ".to_owned(), + cols: 1, + style: TerminalStyle::default(), + is_cursor: false, + }); + } + + TerminalRow { segments } +} + fn flush_segment( segments: &mut Vec, current_style: &mut Option, current_text: &mut String, + current_cols: &mut usize, is_cursor: bool, ) { if current_text.is_empty() { @@ -208,8 +451,10 @@ fn flush_segment( let style = current_style.clone().unwrap_or_default(); let text = std::mem::take(current_text); + let cols = std::mem::take(current_cols); segments.push(TerminalSegment { text, + cols, style, is_cursor, }); @@ -310,3 +555,213 @@ fn indexed_color(index: u8) -> String { } } } + +#[cfg(test)] +mod tests { + use super::*; + + fn row_text(row: &TerminalRow) -> String { + row.segments + .iter() + .map(|s| s.text.as_str()) + .collect::() + .trim_end() + .to_owned() + } + + fn non_empty_history(core: &TerminalCore) -> Vec { + core.history_rows() + .iter() + .map(row_text) + .filter(|s| !s.is_empty()) + .collect() + } + + #[test] + fn erase_scrollback_sniffer_matches_only_plain_3j() { + assert!(contains_erase_scrollback(b"\x1b[3J")); + assert!(contains_erase_scrollback(b"\x1b[H\x1b[2J\x1b[3J")); + + assert!(!contains_erase_scrollback(b"\x1b[33m")); // SGR colour + assert!(!contains_erase_scrollback(b"\x1b[2J")); // erase screen only + assert!(!contains_erase_scrollback(b"\x1b[0J")); + assert!(!contains_erase_scrollback(b"\x1b[J")); // bare J + assert!(!contains_erase_scrollback(b"\x1b[30J")); + assert!(!contains_erase_scrollback(b"\x1b[?3J")); // private mode + assert!(!contains_erase_scrollback(b"plain text 3J")); + } + + #[test] + fn single_process_with_many_lines_does_not_panic() { + // Regression: a single WS packet from `cat` can contain hundreds of + // lines, so one process() call scrolls off far more than viewport_rows. + // The capture path must never set vt100's scrollback offset beyond the + // viewport (vt100 0.15's visible_rows underflows when offset > rows). + let mut core = TerminalCore::new(24, 80); + let mut blob = String::new(); + for i in 1..=500 { + blob.push_str(&format!("bulk{i}\r\n")); + } + core.process(blob.as_bytes()); // single call, ~500 lines scroll off + + let history = non_empty_history(&core); + // Earliest and a mid line must be present, in order. + assert_eq!(history.first().map(String::as_str), Some("bulk1")); + assert!(history.iter().any(|r| r == "bulk250")); + // And the order is chronological. + let pos1 = history.iter().position(|r| r == "bulk1").unwrap(); + let pos250 = history.iter().position(|r| r == "bulk250").unwrap(); + assert!(pos1 < pos250); + } + + #[test] + fn long_output_scrolls_rows_into_history_in_order() { + let mut core = TerminalCore::new(24, 80); + core.process(b"$ "); + for i in 1..=40 { + core.process(format!("line{i}\r\n").as_bytes()); + } + + let history = non_empty_history(&core); + // 40 lines printed + prompt; 24-row screen keeps the last rows, the + // rest spilled into history in chronological order. + 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(); + assert!(screen.iter().any(|r| r == "line40")); + assert!(!history.iter().any(|r| r == "line40")); + } + + #[test] + fn collect_window_slices_across_history_screen_boundary() { + let mut core = TerminalCore::new(24, 80); + // 40 lines -> 17 in history, 24 on screen (incl. tail + prompt area). + for i in 1..=40 { + core.process(format!("line{i}\r\n").as_bytes()); + } + let hist_len = core.history_rows().len(); + let total = core.total_rows(); + assert!(hist_len > 0 && total > hist_len); + + // Window fully inside history. + let w = core.collect_window(0, 3); + assert_eq!(w.len(), 3); + assert_eq!(row_text(&w[0]), "line1"); + + // Window straddling the boundary returns history rows then screen rows, + // contiguous and in order. + let w = core.collect_window(hist_len - 2, hist_len + 2); + assert_eq!(w.len(), 4); + + // Window past the end is clamped, never panics. + let w = core.collect_window(total - 1, total + 100); + assert_eq!(w.len(), 1); + + // Degenerate / empty windows. + assert!(core.collect_window(5, 5).is_empty()); + assert!(core.collect_window(total + 10, total + 20).is_empty()); + } + + #[test] + fn clear_wipes_history_and_keeps_only_new_prompt() { + let mut core = TerminalCore::new(24, 80); + core.process(b"$ "); + for i in 1..=40 { + core.process(format!("line{i}\r\n").as_bytes()); + } + 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"); + core.process(b"$ "); + + assert!( + non_empty_history(&core).is_empty(), + "history must be empty after clear, got {:?}", + non_empty_history(&core) + ); + let screen: Vec = core + .screen_rows() + .iter() + .map(row_text) + .filter(|s| !s.is_empty()) + .collect(); + assert_eq!(screen, vec!["$".to_owned()]); + } + + #[test] + fn output_after_clear_is_captured_again() { + let mut core = TerminalCore::new(24, 80); + for i in 1..=40 { + core.process(format!("old{i}\r\n").as_bytes()); + } + core.process(b"\x1b[H\x1b[2J\x1b[3J"); + + // New long output after clear must scroll into history normally, + // and must not contain any pre-clear "old" lines. + for i in 1..=40 { + core.process(format!("new{i}\r\n").as_bytes()); + } + let history = non_empty_history(&core); + assert!(history.iter().any(|r| r == "new1"), "new output captured"); + assert!( + !history.iter().any(|r| r.starts_with("old")), + "no pre-clear lines leaked: {history:?}" + ); + } + + #[test] + fn alternate_screen_does_not_pollute_history() { + let mut core = TerminalCore::new(24, 80); + for i in 1..=30 { + core.process(format!("main{i}\r\n").as_bytes()); + } + let history_before = non_empty_history(&core); + + // Enter alt-screen (DECSET 1049), draw a full-screen TUI, exit. + core.process(b"\x1b[?1049h"); + for i in 1..=24 { + core.process(format!("\x1b[{};1Htui-row-{i}", i).as_bytes()); + } + core.process(b"\x1b[?1049l"); + + let history_after = non_empty_history(&core); + assert!( + !history_after.iter().any(|r| r.contains("tui-row")), + "alt-screen content must not enter history: {history_after:?}" + ); + // pre-alt-screen history is preserved + assert_eq!(history_before, history_after); + } + + #[test] + fn long_output_within_cap_captures_recent_lines() { + // History must keep capturing rows as they scroll off, for output up + // to the scrollback cap. (Regression for the earlier bug where vt100's + // 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); + for i in 1..=lines { + core.process(format!("ln{i}\r\n").as_bytes()); + } + + let history = non_empty_history(&core); + // A line emitted near the end (already scrolled off the 24-row screen) + // must be present in history. + let needle = format!("ln{}", lines - 100); + assert!( + history.iter().any(|r| *r == needle), + "late line {needle:?} must be captured into history" + ); + // And an early line is still there too (under the cap, nothing dropped). + assert!( + history.iter().any(|r| r == "ln1"), + "earliest line must still be present under the cap" + ); + } +} + diff --git a/app/src/terminal/scrollback.rs b/app/src/terminal/scrollback.rs index aad728b..34d1595 100644 --- a/app/src/terminal/scrollback.rs +++ b/app/src/terminal/scrollback.rs @@ -1,126 +1,47 @@ const LIVE_SCROLL_THRESHOLD_PX: i32 = 24; -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct ScrollMetrics { - pub viewport_rows: usize, - pub scrollback_rows: usize, +/// Simplified view-mode state — replaces the old `ScrollbackModel`. +/// +/// - `Live`: the viewport is pinned to the bottom (latest output). +/// - `History { scroll_offset }`: the user has scrolled up; `scroll_offset` +/// is the row index (from the top of the total content) of the first +/// visible row. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub enum ViewMode { + #[default] + Live, + History { scroll_offset: usize }, } -impl ScrollMetrics { - pub fn total_rows(self) -> usize { - self.scrollback_rows.saturating_add(self.viewport_rows) - } - - pub fn max_top_row(self) -> usize { - self.total_rows().saturating_sub(self.viewport_rows) - } - - pub fn max_scrollback_offset(self) -> usize { - self.scrollback_rows - } -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct ScrollRenderPlan { - pub top_spacer_rows: usize, - pub bottom_spacer_rows: usize, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct ScrollbackModel { - live: bool, - top_row: usize, -} - -impl Default for ScrollbackModel { - fn default() -> Self { - Self { - live: true, - top_row: 0, - } - } -} - -impl ScrollbackModel { +impl ViewMode { pub fn is_live(self) -> bool { - self.live - } - - pub fn top_row(self) -> usize { - self.top_row - } - - pub fn enter_live(&mut self, metrics: ScrollMetrics) { - self.live = true; - self.top_row = metrics.max_top_row(); - } - - pub fn normalize(&mut self, metrics: ScrollMetrics) { - if self.live { - self.top_row = metrics.max_top_row(); - } else { - self.top_row = self.top_row.min(metrics.max_top_row()); - } - } - - pub fn apply_scroll_top( - &mut self, - scroll_top: i32, - scroll_height: i32, - client_height: i32, - line_height_px: f64, - metrics: ScrollMetrics, - ) { - let distance_from_bottom = - (scroll_height - client_height - scroll_top).max(0); - if distance_from_bottom <= LIVE_SCROLL_THRESHOLD_PX { - self.enter_live(metrics); - return; - } - - self.live = false; - self.top_row = scroll_top_to_row(scroll_top, line_height_px) - .min(metrics.max_top_row()); - } - - pub fn target_scroll_top( - self, - scroll_height: i32, - client_height: i32, - line_height_px: f64, - metrics: ScrollMetrics, - ) -> i32 { - if self.live { - return (scroll_height - client_height).max(0); - } - - let max_scroll_top = (scroll_height - client_height).max(0); - row_to_scroll_top(self.top_row.min(metrics.max_top_row()), line_height_px) - .min(max_scroll_top) - } - - pub fn current_scrollback_offset(self, metrics: ScrollMetrics) -> usize { - metrics.max_top_row().saturating_sub(self.top_row) - } - - pub fn render_plan(self, metrics: ScrollMetrics) -> ScrollRenderPlan { - let top_spacer_rows = self.top_row.min(metrics.max_top_row()); - let bottom_spacer_rows = metrics - .total_rows() - .saturating_sub(top_spacer_rows) - .saturating_sub(metrics.viewport_rows); - - ScrollRenderPlan { - top_spacer_rows, - bottom_spacer_rows, - } + matches!(self, ViewMode::Live) } } +/// Convert browser `scrollTop` (pixels) to a row index. pub fn scroll_top_to_row(scroll_top: i32, line_height_px: f64) -> usize { ((f64::from(scroll_top.max(0)) / line_height_px.max(1.0)).round()) as usize } +/// Convert a row index to browser `scrollTop` (pixels). pub fn row_to_scroll_top(row: usize, line_height_px: f64) -> i32 { ((row as f64) * line_height_px).round() as i32 } + +/// Decide the `ViewMode` from the current scroll position. +pub fn view_mode_from_scroll( + scroll_top: i32, + scroll_height: i32, + client_height: i32, + line_height_px: f64, +) -> ViewMode { + let distance_from_bottom = (scroll_height - client_height - scroll_top).max(0); + if distance_from_bottom <= LIVE_SCROLL_THRESHOLD_PX { + ViewMode::Live + } else { + ViewMode::History { + scroll_offset: scroll_top_to_row(scroll_top, line_height_px), + } + } +} diff --git a/style/tailwind.css b/style/tailwind.css index 4010132..239b8c7 100644 --- a/style/tailwind.css +++ b/style/tailwind.css @@ -631,16 +631,23 @@ } .terminal-grid { - @apply flex min-h-full flex-col; + position: relative; + display: block; + min-height: 100%; } .terminal-row { + height: 18px; min-height: 18px; + max-height: 18px; + overflow: hidden; white-space: pre; } .terminal-segment { display: inline-block; + overflow: hidden; + vertical-align: top; white-space: pre; } @@ -650,11 +657,23 @@ box-shadow: 0 0 0 1px rgb(219 234 254 / 30%); } - .terminal-scroll-spacer { + /* 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. */ + .terminal-virtual-sizer { + display: block; width: 100%; pointer-events: none; } + .terminal-virtual-rows { + position: absolute; + top: 0; + left: 0; + right: 0; + will-change: transform; + } + .terminal-measure { position: absolute; left: -9999px;