Implement IME composition support and migrate regular character input to a hidden textarea, matching the xterm.js approach. Key changes: - Add a hidden, absolutely-positioned `<textarea>` inside `.terminal-screen` that follows the terminal cursor cell. It receives focus on click, connect, and resize so the OS/browser IME candidate window appears at the cursor. - `on:input` now sends regular printable characters; `on:keydown` only handles special keys (arrows, Enter, Esc, Backspace, F-keys, Ctrl combos). - `on:compositionstart/end` track IME sessions and send the final composed string at the end, avoiding partial pinyin/jamo/kana leaks. - Move `on:paste` and `on:keydown` from `.terminal-screen` to the textarea so focus stays on the IME target. - Replace `.terminal-screen:focus` with `:focus-within` since the screen div no longer holds focus. - Add `is_regular_text_input` helper and unit test. Web-sys features extended with `InputEvent` and `CompositionEvent`. Tests: 24/24 pass, clippy clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
172 lines
12 KiB
Markdown
172 lines
12 KiB
Markdown
# 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 面。
|
||
>
|
||
> **性能目标**:当前 DOM 渲染在 80×24–160×40 的常规终端下足够流畅,但大终端(≥200×100)或高刷新率连续输出场景会出现 DOM 节点过多、重排重绘开销大的问题。因此**渲染器最终要迁到 Canvas / WebGL**,这是明确的长期目标,会在 P3 之后按优先级推进。
|
||
|
||
---
|
||
|
||
## 1. 覆盖度自评(截至本文档)
|
||
|
||
按用途粗估:
|
||
|
||
- **「跑通真实终端会话」核心用途**:约 **75–80%**
|
||
- **xterm.js 完整 API / 特性面**:约 **20–25%**
|
||
|
||
差距集中在**输入完整性**、**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 滚轮 → 方向键翻页(rAF 合并多次 wheel 事件,单次滑动最多一屏) | `component.rs::handle_wheel` |
|
||
| 基础按键:Ctrl-C/D/L、方向键、Enter/Tab/Esc/Backspace | `component.rs::map_key_to_terminal_input` |
|
||
| 完整键表(F1–F12/Home/End/PageUp/Down/Ctrl A–Z/Alt-key) | `component.rs::key_to_bytes` |
|
||
| application-cursor 模式(DECCKM,方向键切 SS3) | `component.rs::cursor_seq` + `TerminalCore::application_cursor` |
|
||
| 粘贴(Ctrl-V / Ctrl-Shift-V / 右键菜单,含 bracketed paste) | `component.rs::handle_paste` / `prepare_paste` |
|
||
| 文本选区 + 复制(自建模型,Ctrl-Shift-C / 选区存在时 Ctrl+C,跨滚动) | `component.rs` 鼠标处理 / `core.rs::selection_text` |
|
||
| 鼠标上报(vim/htop/tmux 鼠标,SGR/Default/UTF-8 编码,滚轮) | `component.rs` 鼠标处理 / `encode_mouse_report` |
|
||
| 中文 / IME 输入(拼音/日文/韩文,隐藏 textarea + composition 事件) | `component.rs::handle_input` / `handle_composition_*` |
|
||
|
||
---
|
||
|
||
## 2. 追平项(按优先级)
|
||
|
||
### P0 — 复制 / 粘贴 + 文本选区 ⭐ ✅ 已完成
|
||
|
||
**现状**:粘贴、选区、复制**全部完成并验证通过**。
|
||
|
||
**已实现**:
|
||
1. **粘贴** ✅
|
||
- `on:paste`(`ClipboardEvent`)读 `clipboard_data().get_data("text")`,归一换行为 `\r` 后作为 `Input` 发送。
|
||
- bracketed paste:应用开启时(`core.bracketed_paste()`)包进 `ESC[200~ … ESC[201~`。
|
||
- 实现于 `component.rs::handle_paste` + 纯函数 `prepare_paste`。
|
||
- 右键走浏览器默认菜单(避免依赖剪贴板读权限);Ctrl-V 为主路径。
|
||
2. **选区 + 复制** ✅(自建选区模型,xterm.js 风格)
|
||
- 选区按**逻辑行列坐标**跟踪(`Selection { anchor, head }`),鼠标 `mousedown/move/up` 拖拽更新;`point_to_cell` 做像素→单元格换算(含 padding/scroll)。
|
||
- 高亮 overlay:`selection_rects` 算每行矩形,渲染在虚拟滚动层内(共享 translateY,跟随滚动),不拆分文本段。
|
||
- 复制取 `TerminalCore::selection_text(start, end)` —— 直接从 `history_rows + screen_cache` 数据模型按行列取文本,**跨滚动、输出流动时都正确**(不依赖 DOM)。
|
||
- **Ctrl-Shift-C** 复制(Ctrl-C 保持 SIGINT);**当选区存在时 Ctrl+C 优先复制**(与 VS Code / Windows Terminal 一致),无选区时才发送 SIGINT。
|
||
- 打字清除选区。
|
||
- 实现于 `component.rs`(`Selection`/`selection_rects`/`point_to_cell`/鼠标处理)+ `core.rs::selection_text` + `core.rs::TerminalRow::text_in_cols`,均有单元测试。
|
||
3. **快捷键**:Ctrl-Shift-C 复制 / Ctrl-V 粘贴。
|
||
|
||
**已知后续项(非阻塞)**:拖拽到视窗边缘时**没有自动滚动延伸选区**(需先滚到位再选);行内 wide+组合字符混排的列切片为近似。
|
||
|
||
---
|
||
|
||
### P1 — 完整功能键 + application-cursor 模式 ✅ 已完成
|
||
|
||
**现状**:完整键表已实现并验证通过(`key_to_bytes_table` 单测覆盖)。
|
||
|
||
**已实现**:
|
||
1. **完整键表**(`component.rs::key_to_bytes`)
|
||
- F1–F4 SS3(`ESC O P/Q/R/S`)、F5–F12 CSI `~`(含 xterm 真实的 16/22 跳号)。
|
||
- Home/End、Insert/Delete、PageUp/PageDown、Enter/Tab/Esc/Backspace。
|
||
- Ctrl A..Z → 0x01..0x1a;以及 `@[\\]^_` 变体(→ 0x00/0x1b/0x1c/0x1d/0x1e/0x1f)。
|
||
- Alt+char → `ESC char`(标准 meta-as-ESC 编码)。
|
||
2. **application-cursor 模式(DECCKM)**:`cursor_seq(byte, app_cursor)` 助手;为真时方向键 / Home / End 发 SS3(`ESC O X`),否则 CSI(`ESC [ X`)。`handle_keydown` 和 `handle_wheel` 都据此切换。
|
||
3. **浏览器快捷键仲裁**:`handle_keydown` 对 bare `Ctrl+字母`(无 Shift/Alt/Meta)调用 `preventDefault()`,阻止浏览器吃掉 `Ctrl-R` 刷新、`Ctrl-F` 查找、`Ctrl-A` 全选等。`Ctrl+Shift` 组合(如 `Ctrl-Shift-C` 复制)和 `Ctrl+Alt`/`Ctrl+Meta` 组合保留给浏览器/系统。
|
||
|
||
**浏览器预留键(硬限制,无法拦截)**:`Ctrl-W`(关标签)、`Ctrl-T`(新标签)、`Ctrl-N`(新窗口)从 Chrome 4 起被浏览器保留,`preventDefault()` 对它们无效,浏览器在 `keydown` 到达页面前就消费了。这是所有网页终端的共同限制(xterm.js 同样如此),只能在浏览器之外解决(系统全局快捷键 / 扩展 / Electron 包装)。readline 退格删词可用 `Alt+Backspace` 替代 `Ctrl-W`。
|
||
4. **测试**:`key_to_bytes_table` 单测覆盖整套键表,23 个 terminal 测试全过、clippy 干净。
|
||
|
||
**有意调整的行为**(出于浏览器终端的可用性,与原生 xterm 不同):
|
||
- `Ctrl-C` (0x03) → 无选区时发 SIGINT 中断前台进程;**有选区时优先复制**,不发送 0x03。
|
||
- `Ctrl-Z` (0x1a) 与 `Ctrl-\` (0x1c) → 被前端过滤为 `None`,不发送到 PTY。原因是这两个键在默认 `stty` 下会触发 SIGTSTP / SIGQUIT,在浏览器标签页里极易误触导致前台进程被挂起或杀掉;需要这两个信号的用户可用 shell 内建命令(如 `kill -STOP` / `kill -QUIT`)替代。
|
||
|
||
---
|
||
|
||
### P2 — 鼠标上报(mouse reporting) ✅ 已完成
|
||
|
||
**现状**:已实现并验证。`vim`/`htop`/`btm`/`tmux` 的鼠标交互、tmux 选区、vim 滚轮均可以工作。
|
||
|
||
**已实现**:
|
||
1. **vt100 mouse-mode accessors**(`core.rs::mouse_protocol_mode` / `mouse_protocol_encoding`)—— 暴露 `None/Press/PressRelease/ButtonMotion/AnyMotion` 和 `Default/Utf8/Sgr`。
|
||
2. **事件监听与坐标换算**(`component.rs`)
|
||
- `on:mousedown` / `on:mouseup` / `on:mousemove` 复用现有的 DOM 事件。
|
||
- `pixel_to_grid` 把 `(clientX, clientY)` 换算成 0-based 可见网格 `(row, col)`(注意:与选区用的 `point_to_cell` 不同,这里**不加 scroll_top**,因为鼠标报告坐标是相对于应用正在绘制的当前屏幕,而非 scrollback 缓冲区)。
|
||
3. **编码**(`encode_mouse_report` 纯函数)
|
||
- **SGR(1006)**:`ESC[<b;col;row;M`(按下/移动)/ `ESC[<b;col;row;m`(释放)。ASCII-only,任意屏幕尺寸都正确。
|
||
- **Default / UTF-8**:`ESC[M` + 三字节(各 +32)。坐标 ≤94 时完全正确并 ASCII-safe;更大坐标被钳位到 94(大多数终端 likewise;现代应用都走 SGR)。
|
||
- **滚轮**:垂直/水平滚轮映射为按钮 64/65/66/67 的 `Press` 事件。
|
||
4. **模式仲裁**
|
||
- 应用开启 mouse reporting(`mode != None`)时,鼠标事件优先发给应用;否则走本地选区。
|
||
- 按住 **Shift** 点击可绕过鼠标上报、强制本地选区(xterm 行为)。
|
||
- 鼠标移动按网格单元去重,避免每像素都发报告。
|
||
5. **测试**:`encode_mouse_report`、`pixel_to_grid`、`with_mouse_modifiers`、`mouse_button_base` 均有单测;23 个 terminal 测试全过,clippy 干净。
|
||
|
||
**已知限制**:
|
||
- 主屏幕(非 alt-screen)滚动后,鼠标报告坐标仍相对于可见网格顶部;因为开启鼠标上报的应用通常运行在 alt-screen,这覆盖主要使用场景。
|
||
- 与浏览器 `Ctrl+W/T/N` 类似,浏览器可能保留某些鼠标手势(如右键菜单、拖拽文本),这些在网页终端内无法 100% 拦截。应用内鼠标操作应正常。
|
||
|
||
---
|
||
|
||
### P3 — 中文 / IME 输入 ✅ 已完成
|
||
|
||
**现状**:已实现。拼音、日文、韩文等 IME 组合输入可以正常工作;普通英文/数字输入也迁到了 `textarea` 的 `input` 事件,确保和 IME 走同一条路径。
|
||
|
||
**已实现**:
|
||
1. **隐藏 `<textarea>` IME 载体**(`component.rs` view)
|
||
- 绝对定位、透明、无边框、大小跟随终端光标单元格。
|
||
- 通过 `tabindex="0"` 成为焦点目标;`terminal-screen` 改用 `:focus-within` 显示焦点环。
|
||
2. **事件模型迁移**
|
||
- `on:input`:普通字符/数字/空格输入,读取 textarea value 后清空并发送。
|
||
- `on:compositionstart` / `on:compositionend`:IME 组合期间不发字节,`compositionend` 时发送最终文本。
|
||
- `on:keydown`:仍处理方向键、Enter、Esc、Backspace、F 键、Ctrl 组合等**非打印键**。
|
||
- `on:paste`:从 `terminal-screen` 移到 textarea,确保 focus 在 textarea 时右键/菜单粘贴也能触发。
|
||
3. **焦点管理**
|
||
- 点击 terminal-screen、WebSocket 连接成功、窗口 resize 后都聚焦 IME textarea。
|
||
4. **测试**:新增 `is_regular_text_input_detects_printable_keys` 单测,确保打印字符被正确交给 textarea;24 个 terminal 测试全过,clippy 干净。
|
||
|
||
**已知限制**:
|
||
- 主屏幕 scrollback 滚动后,光标可能位于可见区域下方,IME 候选框位置会对应到光标逻辑位置(可能偏下)。IME 主要用于当前输入, scrolled-back 场景输入较少见。
|
||
- 目前不支持 textarea 自动滚动延伸选区(和 P0 同一限制)。
|
||
|
||
---
|
||
|
||
### P4 — Canvas / WebGL 渲染器(性能目标)
|
||
|
||
**现状**:DOM 渲染。常规 80×24–160×40 终端流畅,但大终端(≥200×100)或高刷新率连续输出时,DOM 节点多、重排重绘开销大。
|
||
|
||
**计划实现**:
|
||
1. **Canvas 2D 渲染器(先做)**
|
||
- 一屏单元格一次 `fillText` / `fillRect` 绘制,减少 DOM 节点。
|
||
- 保留现有 `TerminalCore` 数据模型,渲染层换成 Canvas。
|
||
- 文本选区用 Canvas 半透明矩形;光标用 Canvas 反色或额外 DOM overlay。
|
||
2. **WebGL 渲染器(后做)**
|
||
- 把字符集预渲染到 texture atlas,用 shader 批量绘制。
|
||
- 适合超大终端 + 高刷新率场景,但实现复杂度高。
|
||
3. **可切换**
|
||
- 默认 DOM,提供 prop/api 切到 Canvas/WebGL。
|
||
|
||
**风险**:高。需要重新实现渲染层、文本测量、选区高亮、滚动逻辑,同时保证和现有 `TerminalCore` 数据模型兼容。
|
||
|
||
---
|
||
|
||
## 4. 建议推进顺序
|
||
|
||
```
|
||
P0 复制粘贴(先粘贴→可见区复制) ← 最先,体验提升最大
|
||
↓
|
||
P1 完整功能键 + application-cursor
|
||
↓
|
||
P2 鼠标上报
|
||
↓
|
||
P3 中文 IME(放在功能键之后,避免输入逻辑冲突)
|
||
↓
|
||
P4 Canvas/WebGL 渲染器(满足大终端 + 高刷新率性能目标)
|
||
```
|
||
|
||
每一项都应延续现有做法:**纯逻辑抽成可单测的函数**(如 `map_key_to_terminal_input`、`wheel_delta_to_rows`、`virtual_window`),用 `cargo test -p app --lib terminal::` 锁定,浏览器只做交互验证。
|