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:
zhangheng
2026-06-11 19:51:06 +08:00
parent fd056ce502
commit a018609a28
5 changed files with 1410 additions and 607 deletions

135
TERMINAL_ROADMAP.md Normal file
View 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. 覆盖度自评(截至本文档)
按用途粗估:
- **「跑通真实终端会话」核心用途**:约 5060%
- **xterm.js 完整 API / 特性面**:约 1520%
差距集中在**输入完整性**、**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-screenvim / 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。缺 F1F12、Home/End/Insert/Delete、PageUp/PageDown、大量 Ctrl/Alt 组合。
**计划实现**
1. 扩展 `map_key_to_terminal_input`,补齐:
- `F1``F12``ESC O P/Q/R/S`F14`ESC[15~`F512
- `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::` 锁定,浏览器只做交互验证。

File diff suppressed because it is too large Load Diff

View File

@@ -1,145 +1,246 @@
use vt100::{Color, Parser}; use vt100::{Color, Parser, Screen};
pub const DEFAULT_ROWS: u16 = 24; pub const DEFAULT_ROWS: u16 = 24;
pub const DEFAULT_COLS: u16 = 80; 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 { pub struct TerminalCore {
parser: Parser, 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 { impl TerminalCore {
pub fn new(rows: u16, cols: u16) -> Self { pub fn new(rows: u16, cols: u16) -> Self {
Self { let mut core = Self {
parser: Parser::new(rows, cols, DEFAULT_SCROLLBACK), 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]) { /// Read the `delta` newest scrollback rows out of vt100, in chronological
self.parser.process(bytes); /// 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) { pub fn resize(&mut self, rows: u16, cols: u16) {
self.parser.set_size(rows, cols); 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); self.parser.set_scrollback(usize::MAX);
let max_scrollback = self.parser.screen().scrollback(); let total = self.parser.screen().scrollback();
let resolved_top_row = if total > self.captured_count {
requested_top_row.unwrap_or(max_scrollback).min(max_scrollback); let delta = total - self.captured_count;
let current_scrollback = max_scrollback.saturating_sub(resolved_top_row); self.capture_new_scrollback_rows(delta);
self.parser.set_scrollback(current_scrollback); 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 screen = self.parser.screen();
let (rows, cols) = screen.size(); let (rows, _) = screen.size();
let (cursor_row, cursor_col) = screen.cursor_position(); self.screen_cache =
let hide_cursor = screen.hide_cursor(); (0..rows).map(|r| render_screen_row(screen, r)).collect();
let mut rendered_rows = Vec::with_capacity(usize::from(rows)); }
for row in 0..rows { /// Access the accumulated history rows (read-only).
let mut segments = Vec::new(); pub fn history_rows(&self) -> &[TerminalRow] {
let mut current_style: Option<TerminalStyle> = None; &self.history_rows
let mut current_cursor = false; }
let mut current_text = String::new();
for col in 0..cols { /// Cached current-screen rows (read-only).
let Some(cell) = screen.cell(row, col) else { pub fn screen_rows(&self) -> &[TerminalRow] {
continue; &self.screen_cache
}; }
if cell.is_wide_continuation() {
continue;
}
let is_cursor = /// Total number of logical rows: history + current screen.
!hide_cursor && cursor_row == row && cursor_col == col; pub fn total_rows(&self) -> usize {
let style = style_from_cell(cell); self.history_rows.len() + self.screen_cache.len()
let text = if cell.has_contents() { }
cell.contents()
} else {
" ".to_owned()
};
if current_style.as_ref() == Some(&style) /// Clone the rows in the half-open window `[start, end)` of the combined
&& current_cursor == is_cursor /// `history + screen` sequence. `end` is clamped to `total_rows()`.
{ ///
current_text.push_str(&text); /// This is the virtual-scroll hot path: only the visible window (viewport
} else { /// plus a small overscan, typically <100 rows) is ever cloned, regardless
flush_segment( /// of how large the history is.
&mut segments, pub fn collect_window(&self, start: usize, end: usize) -> Vec<TerminalRow> {
&mut current_style, let total = self.total_rows();
&mut current_text, let end = end.min(total);
current_cursor, if start >= end {
); return Vec::new();
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 });
} }
let hist_len = self.history_rows.len();
let mut out = Vec::with_capacity(end - start);
TerminalSnapshot { // History portion of the window.
rows: rendered_rows, if start < hist_len {
title: screen.title().to_owned(), let h_end = end.min(hist_len);
cursor_row, out.extend_from_slice(&self.history_rows[start..h_end]);
cursor_col,
terminal_rows: rows,
terminal_cols: cols,
hide_cursor,
scrollback_offset: current_scrollback,
max_scrollback,
alternate_screen: screen.alternate_screen(),
} }
// 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)] /// Scan a byte stream for a plain `ESC [ 3 J` (erase-scrollback) sequence.
pub struct TerminalSnapshot { ///
pub rows: Vec<TerminalRow>, /// Matches only the exact `3J` parameter form that `clear` emits. It does not
pub title: String, /// match other erase modes (`0J`/`1J`/`2J`), SGR colours that happen to contain
pub cursor_row: u16, /// a `3` (`ESC[33m`), or the private `ESC[?3J` (132-column reset) form.
pub cursor_col: u16, fn contains_erase_scrollback(bytes: &[u8]) -> bool {
pub terminal_rows: u16, let mut i = 0;
pub terminal_cols: u16, while i + 2 < bytes.len() {
pub hide_cursor: bool, if bytes[i] == 0x1b && bytes[i + 1] == b'[' {
pub scrollback_offset: usize, let params_start = i + 2;
pub max_scrollback: usize, let mut j = params_start;
pub alternate_screen: bool, 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)] #[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct TerminalRow { pub struct TerminalRow {
pub segments: Vec<TerminalSegment>, pub segments: Vec<TerminalSegment>,
@@ -171,6 +320,13 @@ pub struct TerminalRow {
#[derive(Clone, Debug, PartialEq, Eq)] #[derive(Clone, Debug, PartialEq, Eq)]
pub struct TerminalSegment { pub struct TerminalSegment {
pub text: String, 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 style: TerminalStyle,
pub is_cursor: bool, 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( fn flush_segment(
segments: &mut Vec<TerminalSegment>, segments: &mut Vec<TerminalSegment>,
current_style: &mut Option<TerminalStyle>, current_style: &mut Option<TerminalStyle>,
current_text: &mut String, current_text: &mut String,
current_cols: &mut usize,
is_cursor: bool, is_cursor: bool,
) { ) {
if current_text.is_empty() { if current_text.is_empty() {
@@ -208,8 +451,10 @@ fn flush_segment(
let style = current_style.clone().unwrap_or_default(); let style = current_style.clone().unwrap_or_default();
let text = std::mem::take(current_text); let text = std::mem::take(current_text);
let cols = std::mem::take(current_cols);
segments.push(TerminalSegment { segments.push(TerminalSegment {
text, text,
cols,
style, style,
is_cursor, 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"
);
}
}

View File

@@ -1,126 +1,47 @@
const LIVE_SCROLL_THRESHOLD_PX: i32 = 24; const LIVE_SCROLL_THRESHOLD_PX: i32 = 24;
#[derive(Clone, Copy, Debug, PartialEq, Eq)] /// Simplified view-mode state — replaces the old `ScrollbackModel`.
pub struct ScrollMetrics { ///
pub viewport_rows: usize, /// - `Live`: the viewport is pinned to the bottom (latest output).
pub scrollback_rows: usize, /// - `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 { impl ViewMode {
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 {
pub fn is_live(self) -> bool { pub fn is_live(self) -> bool {
self.live matches!(self, ViewMode::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,
}
} }
} }
/// Convert browser `scrollTop` (pixels) to a row index.
pub fn scroll_top_to_row(scroll_top: i32, line_height_px: f64) -> usize { 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 ((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 { pub fn row_to_scroll_top(row: usize, line_height_px: f64) -> i32 {
((row as f64) * line_height_px).round() as 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),
}
}
}

View File

@@ -631,16 +631,23 @@
} }
.terminal-grid { .terminal-grid {
@apply flex min-h-full flex-col; position: relative;
display: block;
min-height: 100%;
} }
.terminal-row { .terminal-row {
height: 18px;
min-height: 18px; min-height: 18px;
max-height: 18px;
overflow: hidden;
white-space: pre; white-space: pre;
} }
.terminal-segment { .terminal-segment {
display: inline-block; display: inline-block;
overflow: hidden;
vertical-align: top;
white-space: pre; white-space: pre;
} }
@@ -650,11 +657,23 @@
box-shadow: 0 0 0 1px rgb(219 234 254 / 30%); 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%; width: 100%;
pointer-events: none; pointer-events: none;
} }
.terminal-virtual-rows {
position: absolute;
top: 0;
left: 0;
right: 0;
will-change: transform;
}
.terminal-measure { .terminal-measure {
position: absolute; position: absolute;
left: -9999px; left: -9999px;