refactor(terminal): explicit history buffer + virtual scrolling
Replace the spacer-estimate scrollback model with an explicit history row buffer owned by TerminalCore, rendered with absolute-positioned virtual scrolling so huge outputs (cat of 50k-line files) stay smooth. core.rs: - TerminalCore owns history_rows + a cached screen, captured after each process() via a bounded "conveyor belt" read of vt100 scrollback - feed bytes in newline-bounded safe slices so a capture never reads deeper than viewport_rows (works around vt100 0.15 visible_rows subtract-overflow panic) - handle clear (ESC[3J) by wiping our own history buffer - TerminalSegment carries grid column count for exact-width rendering - collect_window() clones only the visible slice component.rs: - TerminalCore lives in an RwSignal; render driven by an rAF-coalesced render_tick so packet bursts collapse into one frame - virtual_window pins to the bottom in live mode, follows scroll_top in history mode; absolute-positioned rows in a fixed-height sizer keep scroll geometry constant (fixes the "keeps drifting up" bounce) - position-based programmatic-scroll detection replaces the racy ignore-next-scroll flag - alt-screen wheel events translate to arrow keys (vim/less paging) - try_* signal access guards against disposal during hydration style: lock row height to 18px, segment width to Nch (exact grid so fallback-font glyphs like btm braille can't push the row sideways), absolute-position the virtual rows layer. Adds 12 unit tests (capture, clear, alt-screen, window slicing, wheel mapping) and TERMINAL_ROADMAP.md tracking xterm.js parity work. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
135
TERMINAL_ROADMAP.md
Normal file
135
TERMINAL_ROADMAP.md
Normal file
@@ -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(每行一个 `<div>`、每段一个 `<span>`),可优先尝试**浏览器原生选区**:给可见行允许 `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+<key>` → 前缀 `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[<b;col;row;M`(按下)/ `m`(释放)。
|
||||
- Default:`ESC[M` + 三字节。
|
||||
- 只在 `mouse_protocol_mode != None` 时拦截,否则放行(不破坏选区/原生行为)。
|
||||
3. 与 P0 选区冲突处理:应用开启鼠标上报时,鼠标事件优先发给应用;否则用于本地选区。
|
||||
|
||||
**风险**:中。坐标换算边界 + 与选区的优先级仲裁需要仔细测。
|
||||
|
||||
---
|
||||
|
||||
### P3 — 中文 / IME 输入
|
||||
|
||||
**现状**:`map_key_to_terminal_input` 只处理 `key.chars().count() == 1` 的单字符,IME 组合输入(拼音候选)完全没接。
|
||||
|
||||
**计划实现**:
|
||||
1. 改用 **`composition` 事件 + 隐藏 `<textarea>`** 的标准方案(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::` 锁定,浏览器只做交互验证。
|
||||
@@ -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,33 +38,52 @@ 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::<html::Div>::new();
|
||||
let measure_ref = NodeRef::<html::Span>::new();
|
||||
let snapshot = RwSignal::new(TerminalSnapshot::default());
|
||||
let connection_status = RwSignal::new("Connecting".to_owned());
|
||||
let ws_signal = RwSignal::new(None::<WebSocket>);
|
||||
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 |_| {
|
||||
// Bumped at most once per animation frame to drive exactly one render.
|
||||
let render_tick = RwSignal::new(0_u64);
|
||||
|
||||
// Current scrollTop in pixels — updated by the scroll handler so that
|
||||
// the virtual-scroll render closure knows which window to materialise.
|
||||
let scroll_top = RwSignal::new(0_i32);
|
||||
|
||||
// -- WebSocket lifecycle ------------------------------------------------
|
||||
|
||||
Effect::new(move |_| {
|
||||
let url = websocket_url();
|
||||
let Ok(socket) = WebSocket::new(&url) else {
|
||||
connection_status.set("Failed to connect".to_owned());
|
||||
@@ -74,16 +93,17 @@ pub fn TerminalPanel() -> impl IntoView {
|
||||
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_snapshot = snapshot;
|
||||
let open_model = scrollback_model;
|
||||
let open_ignore_scroll = ignore_next_scroll;
|
||||
let open_core = terminal_core.clone();
|
||||
let open_mode = view_mode;
|
||||
let open_prog_scroll = programmatic_scroll_top;
|
||||
let open_core = core_signal;
|
||||
let open_tick = render_tick;
|
||||
let on_open = Closure::<dyn Fn(Event)>::new(move |_| {
|
||||
open_status.set("Connected".to_owned());
|
||||
open_ws.set(Some((*open_socket).clone()));
|
||||
@@ -91,37 +111,29 @@ pub fn TerminalPanel() -> impl IntoView {
|
||||
let viewport = measure_terminal_viewport(open_ref, open_measure);
|
||||
open_viewport.set(viewport);
|
||||
|
||||
{
|
||||
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,
|
||||
);
|
||||
}
|
||||
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();
|
||||
|
||||
let message_snapshot = snapshot;
|
||||
let message_core = terminal_core.clone();
|
||||
// onmessage
|
||||
let message_core = core_signal;
|
||||
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 message_mode = view_mode;
|
||||
let message_prog_scroll = programmatic_scroll_top;
|
||||
let message_tick = render_tick;
|
||||
// Coalescing guard: at most one animation frame is scheduled at a time.
|
||||
// A burst of packets all land in `core` (untracked) but only trigger a
|
||||
// single render on the next frame.
|
||||
let raf_pending = Rc::new(std::cell::Cell::new(false));
|
||||
let on_message =
|
||||
Closure::<dyn Fn(MessageEvent)>::new(move |event: MessageEvent| {
|
||||
let Some(text) = event.data().as_string() else {
|
||||
@@ -137,30 +149,26 @@ pub fn TerminalPanel() -> impl IntoView {
|
||||
|
||||
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,
|
||||
);
|
||||
// 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;
|
||||
}
|
||||
|
||||
focus_terminal(message_ref);
|
||||
// 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 {
|
||||
@@ -177,6 +185,7 @@ pub fn TerminalPanel() -> impl IntoView {
|
||||
socket.set_onmessage(Some(on_message.as_ref().unchecked_ref()));
|
||||
on_message.forget();
|
||||
|
||||
// onclose
|
||||
let close_status = connection_status;
|
||||
let close_ws = ws_signal;
|
||||
let on_close = Closure::<dyn Fn(Event)>::new(move |_| {
|
||||
@@ -186,19 +195,18 @@ pub fn TerminalPanel() -> impl IntoView {
|
||||
socket.set_onclose(Some(on_close.as_ref().unchecked_ref()));
|
||||
on_close.forget();
|
||||
|
||||
// onerror
|
||||
let error_status = connection_status;
|
||||
let on_error = Closure::<dyn Fn(ErrorEvent)>::new(move |_| {
|
||||
error_status.set("Connection error".to_owned());
|
||||
});
|
||||
socket.set_onerror(Some(on_error.as_ref().unchecked_ref()));
|
||||
on_error.forget();
|
||||
}
|
||||
});
|
||||
|
||||
Effect::new({
|
||||
let terminal_core = terminal_core.clone();
|
||||
// -- ResizeObserver -----------------------------------------------------
|
||||
|
||||
move |_| {
|
||||
Effect::new(move |_| {
|
||||
let Some(element) = terminal_ref.get() else {
|
||||
return;
|
||||
};
|
||||
@@ -210,10 +218,11 @@ pub fn TerminalPanel() -> impl IntoView {
|
||||
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 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::<dyn FnMut(Array, ResizeObserver)>::new(
|
||||
move |entries: Array, _observer: ResizeObserver| {
|
||||
let Some(entry) = entries
|
||||
@@ -233,23 +242,23 @@ pub fn TerminalPanel() -> impl IntoView {
|
||||
if viewport != resize_viewport.get_untracked() {
|
||||
resize_viewport.set(viewport);
|
||||
|
||||
let mut core = resize_core.borrow_mut();
|
||||
resize_terminal_core(&mut core, viewport);
|
||||
let mode_val = resize_mode.get_untracked();
|
||||
let st = resize_st.get_untracked();
|
||||
|
||||
let mut model = resize_model.get_untracked();
|
||||
model.normalize(metrics_from_core(viewport, &mut core));
|
||||
resize_model.set(model);
|
||||
resize_core.update_untracked(|core| {
|
||||
core.resize(viewport.rows, viewport.cols);
|
||||
});
|
||||
resize_tick.update(|t| *t += 1);
|
||||
|
||||
let next_snapshot =
|
||||
snapshot_for_model(&mut core, model, viewport);
|
||||
resize_snapshot.set(next_snapshot.clone());
|
||||
sync_scroll_to_model(
|
||||
if mode_val == ViewMode::Live {
|
||||
sync_scroll_to_bottom(resize_ref, resize_prog_scroll);
|
||||
} else {
|
||||
sync_scroll_to_position(
|
||||
resize_ref,
|
||||
next_snapshot,
|
||||
model,
|
||||
viewport,
|
||||
resize_ignore_scroll,
|
||||
st,
|
||||
resize_prog_scroll,
|
||||
);
|
||||
}
|
||||
|
||||
send_resize_message(&resize_socket, viewport);
|
||||
}
|
||||
@@ -258,23 +267,22 @@ pub fn TerminalPanel() -> impl IntoView {
|
||||
},
|
||||
);
|
||||
|
||||
let Ok(observer) = ResizeObserver::new(
|
||||
callback.as_ref().unchecked_ref::<Function>(),
|
||||
) else {
|
||||
let Ok(observer) =
|
||||
ResizeObserver::new(callback.as_ref().unchecked_ref::<Function>())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
observer.observe(&element);
|
||||
callback.forget();
|
||||
std::mem::forget(observer);
|
||||
}
|
||||
});
|
||||
|
||||
// -- Keyboard input -----------------------------------------------------
|
||||
|
||||
let handle_keydown = {
|
||||
let 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! {
|
||||
<section class="terminal-shell-card">
|
||||
<div class="terminal-header">
|
||||
@@ -370,20 +405,25 @@ pub fn TerminalPanel() -> impl IntoView {
|
||||
<span class="terminal-badge">{move || connection_status.get()}</span>
|
||||
<span class="terminal-metrics">
|
||||
{move || {
|
||||
let _ = render_tick.get();
|
||||
// `try_*` so a stale render during hydration (when
|
||||
// the signal may already be disposed) no-ops.
|
||||
core_signal
|
||||
.try_with_untracked(|core| {
|
||||
let viewport = current_viewport.get();
|
||||
let buffer = snapshot.get();
|
||||
let model = scrollback_model.get();
|
||||
let mode = view_mode.get();
|
||||
format!(
|
||||
"view {} x {} / buffer {} x {} / scroll {} of {} / mode {} / {} updates",
|
||||
"view {}x{} / term {}x{} / history {} / screen {} / mode {}",
|
||||
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()
|
||||
core.size().1,
|
||||
core.size().0,
|
||||
core.history_rows().len(),
|
||||
core.screen_rows().len(),
|
||||
if mode.is_live() { "live" } else { "history" },
|
||||
)
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
@@ -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
|
||||
>
|
||||
<span node_ref=measure_ref class="terminal-measure" aria-hidden="true">
|
||||
{MEASURE_SAMPLE_TEXT}
|
||||
</span>
|
||||
<div class="terminal-grid">
|
||||
{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)
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Compute the visible window slice. Returns owned data so the caller can
|
||||
/// drop the `&TerminalCore` borrow before building the view.
|
||||
///
|
||||
/// In live mode the window is pinned to the **bottom** (the last `vp_rows`
|
||||
/// rows) regardless of `scroll_top_px`, because the DOM scrollTop is driven to
|
||||
/// the bottom separately and the rendered rows must line up there. In history
|
||||
/// mode the window follows `scroll_top_px`.
|
||||
fn virtual_window(
|
||||
core: &TerminalCore,
|
||||
is_live: bool,
|
||||
scroll_top_px: i32,
|
||||
vp_rows: usize,
|
||||
) -> (f64, f64, Vec<TerminalRow>) {
|
||||
let total = core.total_rows();
|
||||
let is_alt = core.alternate_screen();
|
||||
|
||||
if is_alt || total == 0 {
|
||||
// Alt-screen / empty: content fits the viewport; total height == rows.
|
||||
let rows = core.screen_rows().to_vec();
|
||||
let total_px = rows.len() as f64 * SCROLL_LINE_HEIGHT;
|
||||
return (total_px, 0.0, rows);
|
||||
}
|
||||
|
||||
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()
|
||||
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<TerminalRow>,
|
||||
) -> impl IntoView {
|
||||
view! {
|
||||
<div
|
||||
class="terminal-scroll-spacer"
|
||||
style=format!("height:{}px;", plan.top_spacer_rows as f64 * SCROLL_LINE_HEIGHT)
|
||||
class="terminal-virtual-sizer"
|
||||
style=format!("height:{total_px}px;")
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
{snapshot
|
||||
.rows
|
||||
.into_iter()
|
||||
.map(render_terminal_row)
|
||||
.collect_view()}
|
||||
<div
|
||||
class="terminal-scroll-spacer"
|
||||
style=format!("height:{}px;", plan.bottom_spacer_rows as f64 * SCROLL_LINE_HEIGHT)
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
class="terminal-virtual-rows"
|
||||
style=format!("transform:translateY({offset_px}px);")
|
||||
>
|
||||
{visible.into_iter().map(render_terminal_row).collect_view()}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<std::cell::Cell<bool>>,
|
||||
render_tick: RwSignal<u64>,
|
||||
view_mode: RwSignal<ViewMode>,
|
||||
terminal_ref: NodeRef<html::Div>,
|
||||
programmatic_scroll_top: RwSignal<i32>,
|
||||
) {
|
||||
if raf_pending.get() {
|
||||
return;
|
||||
}
|
||||
raf_pending.set(true);
|
||||
|
||||
let callback = Closure::once_into_js(move || {
|
||||
raf_pending.set(false);
|
||||
// One reactive render for everything ingested since the last frame.
|
||||
// `try_*` so a frame that fires after the component is disposed
|
||||
// no-ops instead of panicking.
|
||||
if render_tick.try_update(|t| *t += 1).is_none() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Follow new output only while the view is in live mode. The scroll
|
||||
// handler flips to History the instant the user scrolls up (and the
|
||||
// position-based echo detection keeps our own programmatic scrolls from
|
||||
// being mistaken for user scrolls), so this never fights the user.
|
||||
if view_mode.try_get_untracked().map(|m| m.is_live()) == Some(true) {
|
||||
sync_scroll_to_bottom(terminal_ref, programmatic_scroll_top);
|
||||
}
|
||||
});
|
||||
|
||||
let _ = window().request_animation_frame(callback.as_ref().unchecked_ref());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scroll synchronisation helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Pin the viewport to the bottom. Idempotent: if already at the bottom it
|
||||
/// writes nothing (so it can't interrupt the user's momentum scrolling). The
|
||||
/// target position is recorded in `programmatic_scroll_top` so the scroll
|
||||
/// handler can recognise the resulting scroll event as our own.
|
||||
fn sync_scroll_to_bottom(
|
||||
terminal_ref: NodeRef<html::Div>,
|
||||
programmatic_scroll_top: RwSignal<i32>,
|
||||
) {
|
||||
let callback = Closure::<dyn FnMut()>::wrap(Box::new(move || {
|
||||
let Some(element) = terminal_ref.get_untracked() else {
|
||||
return;
|
||||
};
|
||||
let max_scroll =
|
||||
(element.scroll_height() - element.client_height()).max(0);
|
||||
let _ = programmatic_scroll_top.try_set(max_scroll);
|
||||
if (element.scroll_top() - max_scroll).abs() > 1 {
|
||||
element.set_scroll_top(max_scroll);
|
||||
}
|
||||
}));
|
||||
let _ = window().set_timeout_with_callback_and_timeout_and_arguments_0(
|
||||
callback.as_ref().unchecked_ref(),
|
||||
0,
|
||||
);
|
||||
callback.forget();
|
||||
}
|
||||
|
||||
/// Scroll to an arbitrary pixel position (used after resize in history mode),
|
||||
/// recording it as a programmatic scroll.
|
||||
fn sync_scroll_to_position(
|
||||
terminal_ref: NodeRef<html::Div>,
|
||||
target_scroll_top: i32,
|
||||
programmatic_scroll_top: RwSignal<i32>,
|
||||
) {
|
||||
let callback = Closure::<dyn FnMut()>::wrap(Box::new(move || {
|
||||
let Some(element) = terminal_ref.get_untracked() else {
|
||||
return;
|
||||
};
|
||||
let max_scroll =
|
||||
(element.scroll_height() - element.client_height()).max(0);
|
||||
let target = target_scroll_top.min(max_scroll);
|
||||
let _ = programmatic_scroll_top.try_set(target);
|
||||
if (element.scroll_top() - target).abs() > 1 {
|
||||
element.set_scroll_top(target);
|
||||
}
|
||||
}));
|
||||
let _ = window().set_timeout_with_callback_and_timeout_and_arguments_0(
|
||||
callback.as_ref().unchecked_ref(),
|
||||
0,
|
||||
);
|
||||
callback.forget();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Network helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn websocket_url() -> String {
|
||||
let window = window();
|
||||
let location = window.location();
|
||||
let protocol = location.protocol().unwrap_or_else(|_| "http:".to_owned());
|
||||
let host = location.host().unwrap_or_else(|_| "127.0.0.1:3100".to_owned());
|
||||
let 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<html::Div>,
|
||||
@@ -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<html::Div>) {
|
||||
}
|
||||
}
|
||||
|
||||
fn sync_scroll_to_model(
|
||||
terminal_ref: NodeRef<html::Div>,
|
||||
snapshot: TerminalSnapshot,
|
||||
model: ScrollbackModel,
|
||||
viewport: TerminalViewport,
|
||||
ignore_next_scroll: RwSignal<bool>,
|
||||
) {
|
||||
let callback = Closure::<dyn FnMut()>::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<String> {
|
||||
@@ -660,3 +838,98 @@ fn map_key_to_terminal_input(event: &KeyboardEvent) -> Option<String> {
|
||||
_ => 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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<TerminalRow>,
|
||||
/// 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<TerminalRow>,
|
||||
/// 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]) {
|
||||
self.parser.process(bytes);
|
||||
// `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);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<usize>,
|
||||
) -> 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));
|
||||
|
||||
for row in 0..rows {
|
||||
let mut segments = Vec::new();
|
||||
let mut current_style: Option<TerminalStyle> = None;
|
||||
let mut current_cursor = false;
|
||||
let mut current_text = String::new();
|
||||
|
||||
for col in 0..cols {
|
||||
let Some(cell) = screen.cell(row, col) else {
|
||||
continue;
|
||||
};
|
||||
if cell.is_wide_continuation() {
|
||||
continue;
|
||||
let (rows, _) = screen.size();
|
||||
self.screen_cache =
|
||||
(0..rows).map(|r| render_screen_row(screen, r)).collect();
|
||||
}
|
||||
|
||||
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()
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
/// Access the accumulated history rows (read-only).
|
||||
pub fn history_rows(&self) -> &[TerminalRow] {
|
||||
&self.history_rows
|
||||
}
|
||||
|
||||
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,
|
||||
);
|
||||
/// Cached current-screen rows (read-only).
|
||||
pub fn screen_rows(&self) -> &[TerminalRow] {
|
||||
&self.screen_cache
|
||||
}
|
||||
|
||||
if segments.is_empty() {
|
||||
segments.push(TerminalSegment {
|
||||
text: " ".to_owned(),
|
||||
style: TerminalStyle::default(),
|
||||
is_cursor: false,
|
||||
});
|
||||
/// Total number of logical rows: history + current screen.
|
||||
pub fn total_rows(&self) -> usize {
|
||||
self.history_rows.len() + self.screen_cache.len()
|
||||
}
|
||||
|
||||
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<TerminalRow> {
|
||||
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);
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
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(),
|
||||
// -- 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,19 +250,67 @@ impl Default for TerminalCore {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct TerminalSnapshot {
|
||||
pub rows: Vec<TerminalRow>,
|
||||
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 {
|
||||
@@ -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<TerminalStyle> = 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<TerminalSegment>,
|
||||
current_style: &mut Option<TerminalStyle>,
|
||||
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::<String>()
|
||||
.trim_end()
|
||||
.to_owned()
|
||||
}
|
||||
|
||||
fn non_empty_history(core: &TerminalCore) -> Vec<String> {
|
||||
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<String> =
|
||||
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<String> = 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user