Files
rustui-playground/TERMINAL_ROADMAP.md
2026-06-25 14:31:42 +08:00

24 KiB
Raw Blame History

Terminal Roadmap — 对照 xterm.js 的追平计划

本文档记录 rustui-playground 浏览器终端当前已实现的能力相比 xterm.js 仍缺失的功能,以及每一项计划的实现方式与优先级,供后续开发接手。

定位提醒:本项目目标不是复刻 xterm.js 这个通用 JS 终端库,而是按 TERMINAL_HANDOFF.md 的方向,用 Axum + portable-pty(后端)+ Leptos + Rust/WASM(前端)沉淀一套高性能、可复用的 Rust 终端组件。因此追平的重点是「让真实终端会话好用且性能好」,而非铺平 xterm.js 的全部 API 面。

性能目标:终端默认渲染器已回切到 Canvas 2D保留 WebGL glyph atlas / per-cell quad 与 DOM renderer 作为可切换路径。最新基准显示 Canvas 2D 在 10k 行输出、200×80 / 240×100 viewport 下吞吐和 frame time 都明显优于当前 WebGL 实现WebGL 继续作为优化回归目标保留。


1. 覆盖度自评(截至本文档)

按用途粗估:

  • 「跑通真实终端会话」核心用途:约 85%
  • xterm.js 完整 API / 特性面:约 40%

差距集中在 API/插件生态、accessibility、搜索/链接、Unicode 深度精度、运行时可配置性、工程化性能基准,而非「能不能用」。

已实现(有代码 + 单元测试)

能力 位置
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 滚轮 → 方向键翻页rAF 合并多次 wheel 事件,单次滑动最多一屏) component.rs::handle_wheel
基础按键Ctrl-C/D/L、方向键、Enter/Tab/Esc/Backspace component.rs::map_key_to_terminal_input
完整键表F1F12/Home/End/PageUp/Down/Ctrl AZ/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_*
Canvas 2D 默认渲染器 + WebGL glyph atlas 可选路径per-cell quad、DPR glyph atlas、背景/underline/cursor backdrop、DOM 回退) component.rs::Renderer / render_canvas / canvas_render_plan / render_webgl / GlyphAtlas

2. 追平项(按优先级)

P0 — 复制 / 粘贴 + 文本选区 已完成

现状:粘贴、选区、复制全部完成并验证通过

已实现

  1. 粘贴
    • on:pasteClipboardEvent)读 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
    • 高亮 overlayselection_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.rsSelection/selection_rects/point_to_cell/鼠标处理)+ core.rs::selection_text + core.rs::TerminalRow::text_in_cols,均有单元测试。
  3. 快捷键Ctrl-Shift-C 复制 / Ctrl-V 粘贴。

已知后续项(非阻塞):拖拽到视窗边缘时没有自动滚动延伸选区 已通过上一轮 auto-scroll 实现);行内 wide+组合字符混排的列切片( 已通过 grapheme_width 修正 text_in_cols / text_slice_cols


P1 — 完整功能键 + application-cursor 模式 已完成

现状:完整键表已实现并验证通过(key_to_bytes_table 单测覆盖)。

已实现

  1. 完整键表component.rs::key_to_bytes

    • F1F4 SS3ESC O P/Q/R/S、F5F12 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 模式DECCKMcursor_seq(byte, app_cursor) 助手;为真时方向键 / Home / End 发 SS3ESC O X),否则 CSIESC [ X)。handle_keydownhandle_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 accessorscore.rs::mouse_protocol_mode / mouse_protocol_encoding)—— 暴露 None/Press/PressRelease/ButtonMotion/AnyMotionDefault/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 纯函数)
    • SGR1006ESC[<b;col;row;M(按下/移动)/ ESC[<b;col;row;m释放。ASCII-only任意屏幕尺寸都正确。
    • Default / UTF-8ESC[M + 三字节(各 +32。坐标 ≤94 时完全正确并 ASCII-safe更大坐标被钳位到 94大多数终端 likewise现代应用都走 SGR
    • 滚轮:垂直/水平滚轮映射为按钮 64/65/66/67 的 Press 事件。
  4. 模式仲裁
    • 应用开启 mouse reportingmode != None)时,鼠标事件优先发给应用;否则走本地选区。
    • 按住 Shift 点击可绕过鼠标上报、强制本地选区xterm 行为)。
    • 鼠标移动按网格单元去重,避免每像素都发报告。
  5. 测试encode_mouse_reportpixel_to_gridwith_mouse_modifiersmouse_button_base 均有单测23 个 terminal 测试全过clippy 干净。

已知限制

  • 主屏幕(非 alt-screen滚动后鼠标报告坐标仍相对于可见网格顶部因为开启鼠标上报的应用通常运行在 alt-screen这覆盖主要使用场景。
  • 与浏览器 Ctrl+W/T/N 类似,浏览器可能保留某些鼠标手势(如右键菜单、拖拽文本),这些在网页终端内无法 100% 拦截。应用内鼠标操作应正常。

P3 — 中文 / IME 输入 已完成

现状:已实现。拼音、日文、韩文等 IME 组合输入可以正常工作;普通英文/数字输入也迁到了 textareainput 事件,确保和 IME 走同一条路径。

已实现

  1. 隐藏 <textarea> IME 载体component.rs view
    • 绝对定位、透明、无边框、大小跟随终端光标单元格。
    • 通过 tabindex="0" 成为焦点目标;terminal-screen 改用 :focus-within 显示焦点环。
  2. 事件模型迁移
    • on:input:普通字符/数字/空格输入,读取 textarea value 后清空并发送。
    • on:compositionstart / on:compositionendIME 组合期间不发字节,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 单测,确保打印字符被正确交给 textarea24 个 terminal 测试全过clippy 干净。

已知限制

  • 主屏幕 scrollback 滚动后光标可能位于可见区域下方IME 候选框位置会对应到光标逻辑位置可能偏下。IME 主要用于当前输入, scrolled-back 场景输入较少见。
  • 目前不支持 textarea 自动滚动延伸选区(和 P0 同一限制)。

P4 — Canvas / WebGL 渲染器(性能目标) 已完成

现状Canvas 2D 已作为默认渲染器WebGL glyph atlas / per-cell quad 与 DOM renderer 保留为可切换路径。长输出、输入后 live/history 稳定性、IME textarea buffer 坐标、DPR backing store、虚拟滚动 sizer、selection overlay、cursor overlay 均已验证。2026-06-24 benchmark 显示 Canvas 2D 在当前实现下优于 WebGL因此默认策略先以 Canvas 为准。

已实现

  1. Canvas 2D 默认渲染器 + WebGL 可选路径
    • Renderer::Canvas 默认,Renderer::WebGl / Renderer::Dom 作为可切换回归路径。
    • WebGL2 初始化 shader/program/vertex buffer/atlas texture/fallback texture。
    • GlyphAtlas 按 grapheme + bold/italic + cell width 缓存字形ASCII、CJK wide 字符、组合字符按 unicode-segmentation / unicode-width 拆成 cell glyph。
    • 文本通过 per-cell quad 批量绘制,颜色作为 vertex attribute小字号不再走整屏纹理二次采样。
    • 背景、underline、cursor 背景在 WebGL 正常路径中直接绘制为纯色 quadcursor 前景文字仍进 atlas。
    • atlas 溢出或遇到不可拆分复杂段时自动回退整屏 Canvas texture避免真实终端内容白屏。
    • WebGL2 不可用时自动落回 Canvas 2D。
  2. Canvas 2D 渲染器
    • 保留现有 TerminalCore 数据模型,渲染层切换为 Canvas。
    • 一屏/一窗口 fillText / fillRect 绘制,避免每个 segment 都生成 DOM 节点。
    • DPR backing store 与 CSS 尺寸分离,避免高 DPI 模糊。
    • CanvasRenderPlan 统一计算 backing store、CSS size、canvas top、content offset。
    • CanvasDomState 缓存 DOM 尺寸状态,避免每帧无意义重设 backing store。
    • 保留 terminal-virtual-sizer 维持完整 scroll geometrycat 后 live 模式稳定贴底。
    • selection overlay 保持 DOM 绝对定位canvas 只负责文本/背景/光标。
    • IME textarea 改用 buffer 坐标定位,长 scrollback 后输入不会触发 live/history 抖动。
  3. 测试
    • canvas_render_plancanvas_fill_styleterminal_fontcanvas_viewport_top_pxime_cursor_cellwebgl_quad_verticeswebgl_shader_sourcesGlyphAtlassplit_segment_glyphspush_quad 均有单测。
    • cargo test -p app --lib terminal:: 当前 38 个 terminal 测试通过。

后续深化(进入新一轮 xterm.js 对标计划)

  1. WebGL context loss/recovery 与 atlas 重建。
  2. 渲染性能基准:固定脚本覆盖 10k/50k 行输出、200×80、240×100、持续输出 FPS / frame time。
  3. 浏览器截图 + canvas/WebGL pixel smoke test 固化到工程化脚本。
  4. emoji / ZWJ / variation selector / ligature 的 advanced shaping 策略。

P5 — xterm.js API / 配置面(新一轮) 已完成

目标:把当前 TerminalPanel 从页面内组件推进成可复用终端组件。

已实现

  1. TerminalOptions
    • cols/rows、scrollback、renderer、font family/size/line-height、theme、cursor style/blink、title/subtitle、show_header。
    • 支持 runtime options signal 更新字体、行高、theme 同步到 DOM / Canvas / WebGL glyph atlas / 虚拟滚动计算。
    • TerminalCore::new_with_scrollback 让 scrollback 容量进入 parser 层,而不只是 UI 参数。
  2. TerminalHandle
    • writepaste_textclearfocusresizescroll_to_bottomcopy_selection
  3. 事件回调
    • on_dataon_resizeon_selection_changeon_title_changeon_render
  4. 测试
    • 新增 options 解析 / CSS 变量 / 行高参数化 / cursor shape / cursor glyph color 相关单测。
    • cargo test -p app --lib terminal:: 当前 41 个 terminal 测试通过。
  5. 第一轮模块拆分
    • keyboard.rspaste normalization、key mapping、printable key 判断。
    • mouse.rswheel rows、mouse report 编码、visible grid mapping。
    • selection.rsselection model、selection rect 计算。
    • ime.rsIME cursor buffer 坐标和 hidden textarea helpers。

后续进入 P6

  1. TerminalHandle 增强:fit()、serialize/search/link addon 接口。
  2. 继续把 WebGL / Canvas renderer 从 component.rs 拆成专门模块。

P6 — xterm.js addon parity搜索 / 链接 / 序列化 / fit

当前进度

  1. Fit addon 等价能力
    • TerminalHandle::fit(),根据容器内容区重新测量 rows/cols更新 core viewport 并同步 PTY resize。
  2. Search
    • TerminalHandle::search_next/search_previous/clear_search
    • literal search 跨 history + screen,支持 wrap搜索结果通过 DOM overlay 高亮WebGL/Canvas/DOM renderer 共用。
  3. Serialize
    • TerminalHandle::serialize_text()TerminalCore::buffer_text(),导出当前 retained buffer 文本。
    • TerminalHandle::serialize_ansi()TerminalCore::buffer_ansi(),按当前 row/style 模型导出 ANSI-styled snapshot。
  4. Web links / OSC 8
    • URL 自动识别、点击打开WebGL/Canvas/DOM renderer 共用链接 hitbox overlay。
    • OSC 8 hyperlink 解析:支持 BEL / ST 终止,使用 cell-grid sidecar 记录链接,覆盖写入、常见 CSI 光标移动、清行/清屏、滚屏进 history 后仍能正确映射到链接 hitbox overlay。
    • 后续可继续扩展非常规控制序列覆盖面,但 P6 addon parity 的核心能力已完成。

P7 — Accessibility / 可访问性 已完成

已实现

  1. Screen reader buffer / aria-live
    • 隐藏 div[aria-live="polite"][role="status"] 实时推送可见终端文本。
    • 提取可见行文本(去尾空、过滤空行),每 500ms 防抖更新,避免大量输出时淹没屏幕阅读器。
    • role="application" + aria-label="Terminal" + aria-roledescription="terminal emulator" 标注终端区域。
  2. Keyboard-only selection / copy (已在 P0 完成)
    • Ctrl-Shift-C 复制;选区存在时 Ctrl-C 优先复制。
    • 所有键盘处理通过 IME textarea 的 on:keydown 统一入口。
  3. High contrast / minimum contrast ratio
    • 监听 prefers-contrast: more 媒体查询,自动切换 Canvas / CanvasText 系统色。
    • CSS @media (prefers-contrast: more) 覆盖终端前景/背景/边框。
  4. Reduced motion 与焦点可见性
    • 监听 prefers-reduced-motion: reduce 媒体查询,自动禁用光标闪烁动画和过渡。
    • JS 侧同步设置 --terminal-reduce-motion:reduce CSS 变量Effect 内联动 prefers_reduced_motion 信号。
    • terminal-screen:focus-within 已有可见焦点环(box-shadow: 0 0 0 3px rgb(56 189 248 / 15%))。

P8 — Unicode / 字形精度 已完成

已实现

  1. Emoji / ZWJ / variation selector 宽度策略
    • grapheme_width() 函数ZWJ emoji 序列(如家族 emoji检测在标准宽度计算之前整体返回宽度 2。
    • is_emoji_presentation() 覆盖 Emoticons、Misc Symbols、Supplemental Symbols、Transport、Dingbats 等 Unicode 区块。
    • ZWJ 非 emoji 序列返回 1combining marks 返回 1variation selectors 被吸收不额外占列。
    • 8 个单元测试覆盖 ASCII、CJK、emoji、ZWJ、combining、variation selector。
  2. CJK ambiguous width 配置
    • TerminalFontOptions::cjk_ambiguous_wide 选项(默认 true,遵循 CJK 终端惯例)。
    • is_cjk_ambiguous() 按 UAX #11 完整列举 Ambiguous East Asian Width 字符。
    • 开启时 ○、△ 等符号按宽度 2 渲染;关闭时按宽度 1。
  3. Ligature 可选支持
    • TerminalFontOptions::ligatures 选项(默认 false,终端通常不需要连字)。
    • --terminal-font-ligatures CSS 变量联动 font-variant-ligatures
  4. Fallback font 校准
    • 默认字体栈新增 Noto Sans CJK SC、Microsoft YaHei、PingFang SCCJK和 Apple Color Emoji、Segoe UI Emoji、Noto Color Emojiemoji

P9 — WebGL Renderer 工程化深化

计划实现

  1. WebGL context loss/recovery 与 atlas 重建
    • 监听 webglcontextlost / webglcontextrestoredlost 时阻止默认行为并清空 WebGlRendererState
    • context lost 或 WebGL 初始化失败时显示独立 Canvas 2D fallback surface避免同一 canvas 上 WebGL lost 后无法获取 2D context 导致白屏。
    • restore 后清空旧 state 并触发重绘,下一帧重建 shader/program/buffer/texture/glyph atlas。
    • context lifecycle listener 由 WebGlContextLifecycle 持有,组件卸载或 renderer 切换时自动移除。
  2. 渲染性能 benchmark 和自动截图 / pixel smoke test
    • scripts/terminal-bench.mjs 连接已启动的 /rustui/terminal,不负责启动/关闭服务。
    • 固定覆盖 200x80240x100 viewport支持 --lines--viewport--output 参数。
    • 记录行输出耗时、rows/sec、requestAnimationFrame frame time 摘要、window.__terminalBench render probe。
    • 截图 smoke 默认走 Playwright screenshot PNG 非空/熵检查Canvas 2D fallback 可读时额外做像素采样。
  3. Canvas 2D / WebGL runtime switch
    • /rustui/terminal 页面提供 WebGL / Canvas / DOM runtime 切换。
    • 支持 ?renderer=webgl|canvas|dom 初始化,便于浏览器回归和 benchmark 固定模式。
    • scripts/terminal-bench.mjs --renderer webgl|canvas|dom 复用同一入口跑对比,并将截图按 renderer 命名。
  4. 与 Canvas 2D 做 benchmark 对比,按 viewport size / output rate 自动选择 renderer
    • 2026-06-24 基线10k 行输出:
      • 200x80WebGL 155.27 rows/secframe p95 283.3msCanvas 311.67 rows/secframe p95 116.7ms
      • 240x100WebGL 92.48 rows/secframe p95 399.9msCanvas 157.65 rows/secframe p95 116.8ms
    • 两种 renderer 的 pixel smoke 均通过Canvas 可直接读取 canvas 像素WebGL 走 screenshot smoke。
    • 当前策略:默认 CanvasWebGL 继续通过页面切换、?renderer=webgl--renderer webgl 保留优化回归入口。
  5. WebGL renderer profiling / 慢路径拆解
    • WebGL render path 记录阶段耗时DOM/canvas 同步、backdrop 绘制、glyph collect、backdrop texture pass、glyph atlas、atlas upload、vertex build/upload、draw call。
    • 记录计数cells、atlas entries、atlas insertions/resets、full-canvas fallback 次数、glyph draw failed 次数。
    • window.__terminalBench.webglFrames 暴露每帧采样,scripts/terminal-bench.mjs 汇总为 rendererStats,用于判断慢点是整屏 backdrop texture、atlas、vertex buffer还是 fallback。
  6. WebGL backdrop texture pass 拆除
    • 正常 WebGL 路径不再每帧把 Canvas backdrop 上传为整屏 texture背景、underline、cursor 背景改为 1×1 白色纹理 + 纯色 quad 批量绘制。
    • backdropTextureMs 只在复杂 glyph / atlas 失败等 full-canvas fallback 路径产生,用于继续观察 fallback 是否被真实内容触发。
    • 页面切换、?renderer=webgl--renderer webgl 保留,下一轮基准可直接对比这次优化后 WebGL 是否追近 Canvas。
  7. WebGL glyph collect cache
    • WebGL renderer state 增加可见行 glyph 拆分缓存,相同行内容 / 样式 / theme 在滚动和连续输出时复用 grapheme 拆分结果。
    • GlyphKey.text 改为 Rc<str>,缓存命中后 materialize 行 glyph cell 时避免反复复制 glyph 字符串。
    • benchmark rendererStats 新增 rowCacheHits / rowCacheMisses,用于判断 collectMs 是否被 cache 命中率拉低。
  8. WebGL row vertex template cache
    • atlas 覆盖后按行缓存 glyph vertex template后续帧只按当前 row y 偏移 materialize减少每帧 atlas lookup / UV 计算 / quad 构建。
    • benchmark rendererStats 新增 vertexCacheHits / vertexCacheMisses,用于判断 vertexBuildMs 是否主要来自 cache miss。

4. 建议推进顺序

P0 复制粘贴(先粘贴→可见区复制)  ← 最先,体验提升最大
  ↓
P1 完整功能键 + application-cursor
  ↓
P2 鼠标上报
  ↓
P3 中文 IME(放在功能键之后,避免输入逻辑冲突)
  ↓
P4 Canvas / WebGL 渲染器(满足大终端 + 高刷新率性能目标)
  ↓
P5 API / 配置面
  ↓
P6 addon parityfit/search/serialize/web-links
  ↓
P7 accessibility
  ↓
P8 Unicode / 字形精度
  ↓
P9 WebGL renderer 工程化深化

每一项都应延续现有做法:纯逻辑抽成可单测的函数(如 map_key_to_terminal_inputwheel_delta_to_rowsvirtual_window),用 cargo test -p app --lib terminal:: 锁定,浏览器只做交互验证。