Compare commits
10 Commits
7593561761
...
561a062cf2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
561a062cf2 | ||
|
|
b7a8bf49c4 | ||
|
|
8d73d6eabc | ||
|
|
3603321dea | ||
|
|
5bca11083f | ||
|
|
057d74d44c | ||
|
|
fb54acf88b | ||
|
|
a1d5752d59 | ||
|
|
fb1a6d5fe7 | ||
|
|
a039f7fd5f |
@@ -63,6 +63,7 @@ web-sys = { version = "0.3", default-features = false, features = [
|
|||||||
"MessageEvent",
|
"MessageEvent",
|
||||||
"MouseEvent",
|
"MouseEvent",
|
||||||
"Navigator",
|
"Navigator",
|
||||||
|
"Performance",
|
||||||
"ResizeObserver",
|
"ResizeObserver",
|
||||||
"ResizeObserverEntry",
|
"ResizeObserverEntry",
|
||||||
"WebGl2RenderingContext",
|
"WebGl2RenderingContext",
|
||||||
@@ -77,7 +78,7 @@ web-sys = { version = "0.3", default-features = false, features = [
|
|||||||
] }
|
] }
|
||||||
|
|
||||||
[[workspace.metadata.leptos]]
|
[[workspace.metadata.leptos]]
|
||||||
name = "base_path_demo"
|
name = "rustui_playground"
|
||||||
bin-package = "server"
|
bin-package = "server"
|
||||||
lib-package = "app"
|
lib-package = "app"
|
||||||
site-root = "target/site"
|
site-root = "target/site"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Rust Browser Terminal Development Guide
|
# Rust Browser Terminal Development Guide
|
||||||
|
|
||||||
本指南用于在 `base-path-demo` 中实现一套 Rust 终端能力:
|
本指南用于在 `rustui-playground` 中实现一套 Rust 终端能力:
|
||||||
|
|
||||||
- 后端:Axum + `portable-pty` 创建真实伪终端,负责启动 shell、读写 PTY、处理 resize。
|
- 后端:Axum + `portable-pty` 创建真实伪终端,负责启动 shell、读写 PTY、处理 resize。
|
||||||
- 前端:Rust/WASM + Leptos 实现浏览器终端组件,负责输入、渲染、滚屏、选区和终端状态。
|
- 前端:Rust/WASM + Leptos 实现浏览器终端组件,负责输入、渲染、滚屏、选区和终端状态。
|
||||||
@@ -27,7 +27,7 @@ Shell process: bash/zsh/fish/vim/top
|
|||||||
建议分四层沉淀。
|
建议分四层沉淀。
|
||||||
|
|
||||||
```text
|
```text
|
||||||
base-path-demo/
|
rustui-playground/
|
||||||
app/
|
app/
|
||||||
src/
|
src/
|
||||||
pages/terminal.rs
|
pages/terminal.rs
|
||||||
@@ -63,7 +63,7 @@ crates/
|
|||||||
|
|
||||||
当前 workspace 已有 `axum = "0.8"`,但 WebSocket 模块需要启用 `ws` feature。
|
当前 workspace 已有 `axum = "0.8"`,但 WebSocket 模块需要启用 `ws` feature。
|
||||||
|
|
||||||
建议先调整 `base-path-demo/Cargo.toml`:
|
建议先调整 `rustui-playground/Cargo.toml`:
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
@@ -501,7 +501,7 @@ MVP 安全策略:
|
|||||||
- 从消息类型抽出 `terminal-protocol`。
|
- 从消息类型抽出 `terminal-protocol`。
|
||||||
- 从 server module 抽出 `terminal-pty-server`。
|
- 从 server module 抽出 `terminal-pty-server`。
|
||||||
|
|
||||||
验收:`base-path-demo` 只依赖这些 crates,不再持有核心实现。
|
验收:`rustui-playground` 只依赖这些 crates,不再持有核心实现。
|
||||||
|
|
||||||
### M5: 自研 parser/buffer
|
### M5: 自研 parser/buffer
|
||||||
|
|
||||||
|
|||||||
@@ -2,11 +2,11 @@
|
|||||||
|
|
||||||
> 说明:本文档保留早期 scrollback 排查背景。当前终端实现已经进入 WebGL 默认渲染器、addon parity 与 P9 renderer 工程化阶段;最新状态请优先看 `TERMINAL_ROADMAP.md`。
|
> 说明:本文档保留早期 scrollback 排查背景。当前终端实现已经进入 WebGL 默认渲染器、addon parity 与 P9 renderer 工程化阶段;最新状态请优先看 `TERMINAL_ROADMAP.md`。
|
||||||
|
|
||||||
本文档用于把 `base-path-demo` 当前浏览器终端实现的状态、已验证结论、未解决问题和下一步建议整理清楚,方便后续继续开发。
|
本文档用于把 `rustui-playground` 当前浏览器终端实现的状态、已验证结论、未解决问题和下一步建议整理清楚,方便后续继续开发。
|
||||||
|
|
||||||
## 1. 当前目标
|
## 1. 当前目标
|
||||||
|
|
||||||
项目目标不是简单接一个现成 JS 终端,而是在 `base-path-demo` 中逐步沉淀一套 Rust 终端能力:
|
项目目标不是简单接一个现成 JS 终端,而是在 `rustui-playground` 中逐步沉淀一套 Rust 终端能力:
|
||||||
|
|
||||||
- 后端:`Axum + portable-pty`
|
- 后端:`Axum + portable-pty`
|
||||||
- 前端:`Leptos + Rust/WASM`
|
- 前端:`Leptos + Rust/WASM`
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Terminal Roadmap — 对照 xterm.js 的追平计划
|
# Terminal Roadmap — 对照 xterm.js 的追平计划
|
||||||
|
|
||||||
本文档记录 `base-path-demo` 浏览器终端**当前已实现的能力**、**相比 xterm.js 仍缺失的功能**,以及每一项**计划的实现方式与优先级**,供后续开发接手。
|
本文档记录 `rustui-playground` 浏览器终端**当前已实现的能力**、**相比 xterm.js 仍缺失的功能**,以及每一项**计划的实现方式与优先级**,供后续开发接手。
|
||||||
|
|
||||||
> 定位提醒:本项目目标不是复刻 xterm.js 这个通用 JS 终端库,而是按 [TERMINAL_HANDOFF.md](TERMINAL_HANDOFF.md) 的方向,用 `Axum + portable-pty`(后端)+ `Leptos + Rust/WASM`(前端)沉淀一套**高性能、可复用的 Rust 终端组件**。因此追平的重点是「让真实终端会话好用且性能好」,而非铺平 xterm.js 的全部 API 面。
|
> 定位提醒:本项目目标不是复刻 xterm.js 这个通用 JS 终端库,而是按 [TERMINAL_HANDOFF.md](TERMINAL_HANDOFF.md) 的方向,用 `Axum + portable-pty`(后端)+ `Leptos + Rust/WASM`(前端)沉淀一套**高性能、可复用的 Rust 终端组件**。因此追平的重点是「让真实终端会话好用且性能好」,而非铺平 xterm.js 的全部 API 面。
|
||||||
>
|
>
|
||||||
@@ -62,7 +62,7 @@
|
|||||||
- 实现于 `component.rs`(`Selection`/`selection_rects`/`point_to_cell`/鼠标处理)+ `core.rs::selection_text` + `core.rs::TerminalRow::text_in_cols`,均有单元测试。
|
- 实现于 `component.rs`(`Selection`/`selection_rects`/`point_to_cell`/鼠标处理)+ `core.rs::selection_text` + `core.rs::TerminalRow::text_in_cols`,均有单元测试。
|
||||||
3. **快捷键**:Ctrl-Shift-C 复制 / Ctrl-V 粘贴。
|
3. **快捷键**:Ctrl-Shift-C 复制 / Ctrl-V 粘贴。
|
||||||
|
|
||||||
**已知后续项(非阻塞)**:拖拽到视窗边缘时**没有自动滚动延伸选区**(需先滚到位再选);行内 wide+组合字符混排的列切片为近似。
|
**已知后续项(非阻塞)**:拖拽到视窗边缘时**没有自动滚动延伸选区**(✅ 已通过上一轮 auto-scroll 实现);行内 wide+组合字符混排的列切片(✅ 已通过 grapheme_width 修正 text_in_cols / text_slice_cols)。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -146,7 +146,7 @@
|
|||||||
- WebGL2 初始化 shader/program/vertex buffer/atlas texture/fallback texture。
|
- WebGL2 初始化 shader/program/vertex buffer/atlas texture/fallback texture。
|
||||||
- `GlyphAtlas` 按 grapheme + bold/italic + cell width 缓存字形;ASCII、CJK wide 字符、组合字符按 `unicode-segmentation` / `unicode-width` 拆成 cell glyph。
|
- `GlyphAtlas` 按 grapheme + bold/italic + cell width 缓存字形;ASCII、CJK wide 字符、组合字符按 `unicode-segmentation` / `unicode-width` 拆成 cell glyph。
|
||||||
- 文本通过 per-cell quad 批量绘制,颜色作为 vertex attribute;小字号不再走整屏纹理二次采样。
|
- 文本通过 per-cell quad 批量绘制,颜色作为 vertex attribute;小字号不再走整屏纹理二次采样。
|
||||||
- 背景、underline、cursor 背景由 Canvas backdrop 绘制后作为底层 texture 合成;cursor 前景文字仍进 atlas。
|
- 背景、underline、cursor 背景在 WebGL 正常路径中直接绘制为纯色 quad;cursor 前景文字仍进 atlas。
|
||||||
- atlas 溢出或遇到不可拆分复杂段时自动回退整屏 Canvas texture,避免真实终端内容白屏。
|
- atlas 溢出或遇到不可拆分复杂段时自动回退整屏 Canvas texture,避免真实终端内容白屏。
|
||||||
- WebGL2 不可用时自动落回 Canvas 2D。
|
- WebGL2 不可用时自动落回 Canvas 2D。
|
||||||
2. **Canvas 2D 渲染器** ✅
|
2. **Canvas 2D 渲染器** ✅
|
||||||
@@ -212,21 +212,41 @@
|
|||||||
- ✅ OSC 8 hyperlink 解析:支持 BEL / ST 终止,使用 cell-grid sidecar 记录链接,覆盖写入、常见 CSI 光标移动、清行/清屏、滚屏进 history 后仍能正确映射到链接 hitbox overlay。
|
- ✅ OSC 8 hyperlink 解析:支持 BEL / ST 终止,使用 cell-grid sidecar 记录链接,覆盖写入、常见 CSI 光标移动、清行/清屏、滚屏进 history 后仍能正确映射到链接 hitbox overlay。
|
||||||
- 后续可继续扩展非常规控制序列覆盖面,但 P6 addon parity 的核心能力已完成。
|
- 后续可继续扩展非常规控制序列覆盖面,但 P6 addon parity 的核心能力已完成。
|
||||||
|
|
||||||
### P7 — Accessibility / 可访问性
|
### P7 — Accessibility / 可访问性 ✅ 已完成
|
||||||
|
|
||||||
**计划实现**:
|
**已实现**:
|
||||||
1. screen reader buffer / aria-live 策略。
|
1. **Screen reader buffer / aria-live** ✅
|
||||||
2. keyboard-only selection / copy。
|
- 隐藏 `div[aria-live="polite"][role="status"]` 实时推送可见终端文本。
|
||||||
3. high contrast / minimum contrast ratio。
|
- 提取可见行文本(去尾空、过滤空行),每 500ms 防抖更新,避免大量输出时淹没屏幕阅读器。
|
||||||
4. reduced motion 与焦点可见性。
|
- `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 / 字形精度
|
### P8 — Unicode / 字形精度 ✅ 已完成
|
||||||
|
|
||||||
**计划实现**:
|
**已实现**:
|
||||||
1. emoji / ZWJ / variation selector 的 grapheme cluster 宽度策略。
|
1. **Emoji / ZWJ / variation selector 宽度策略** ✅
|
||||||
2. CJK ambiguous width 配置。
|
- `grapheme_width()` 函数:ZWJ emoji 序列(如家族 emoji)检测在标准宽度计算之前,整体返回宽度 2。
|
||||||
3. ligature 可选支持。
|
- `is_emoji_presentation()` 覆盖 Emoticons、Misc Symbols、Supplemental Symbols、Transport、Dingbats 等 Unicode 区块。
|
||||||
4. fallback font metrics 校准。
|
- ZWJ 非 emoji 序列返回 1;combining marks 返回 1;variation 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 SC(CJK)和 Apple Color Emoji、Segoe UI Emoji、Noto Color Emoji(emoji)。
|
||||||
|
|
||||||
### P9 — WebGL Renderer 工程化深化
|
### P9 — WebGL Renderer 工程化深化
|
||||||
|
|
||||||
@@ -251,6 +271,21 @@
|
|||||||
- `240x100`:WebGL `92.48 rows/sec`,frame p95 `399.9ms`;Canvas `157.65 rows/sec`,frame p95 `116.8ms`。
|
- `240x100`:WebGL `92.48 rows/sec`,frame p95 `399.9ms`;Canvas `157.65 rows/sec`,frame p95 `116.8ms`。
|
||||||
- 两种 renderer 的 pixel smoke 均通过;Canvas 可直接读取 canvas 像素,WebGL 走 screenshot smoke。
|
- 两种 renderer 的 pixel smoke 均通过;Canvas 可直接读取 canvas 像素,WebGL 走 screenshot smoke。
|
||||||
- 当前策略:默认 Canvas;WebGL 继续通过页面切换、`?renderer=webgl` 和 `--renderer webgl` 保留优化回归入口。
|
- 当前策略:默认 Canvas;WebGL 继续通过页面切换、`?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。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
fn main() {
|
fn main() {
|
||||||
println!("cargo:rustc-env=LEPTOS_OUTPUT_NAME=base_path_demo");
|
println!("cargo:rustc-env=LEPTOS_OUTPUT_NAME=rustui_playground");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -115,6 +115,7 @@ fn update_terminal_bench_probe(event: TerminalRenderEvent) {
|
|||||||
"history"
|
"history"
|
||||||
};
|
};
|
||||||
let renderer = format!("{:?}", event.renderer);
|
let renderer = format!("{:?}", event.renderer);
|
||||||
|
let webgl_stats = event.webgl_stats;
|
||||||
|
|
||||||
let _ = js_sys::Reflect::set(
|
let _ = js_sys::Reflect::set(
|
||||||
&probe,
|
&probe,
|
||||||
@@ -152,6 +153,70 @@ fn update_terminal_bench_probe(event: TerminalRenderEvent) {
|
|||||||
&JsValue::from_str("screenRows"),
|
&JsValue::from_str("screenRows"),
|
||||||
&JsValue::from_f64(event.screen_rows as f64),
|
&JsValue::from_f64(event.screen_rows as f64),
|
||||||
);
|
);
|
||||||
|
if let Some(stats) = webgl_stats {
|
||||||
|
append_webgl_stats(&probe, stats);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
fn append_webgl_stats(probe: &wasm_bindgen::JsValue, stats: crate::terminal::WebGlRenderStats) {
|
||||||
|
use wasm_bindgen::JsCast;
|
||||||
|
use wasm_bindgen::JsValue;
|
||||||
|
|
||||||
|
let frames = match js_sys::Reflect::get(probe, &JsValue::from_str("webglFrames")) {
|
||||||
|
Ok(value) if js_sys::Array::is_array(&value) => value.unchecked_into::<js_sys::Array>(),
|
||||||
|
_ => {
|
||||||
|
let array = js_sys::Array::new();
|
||||||
|
let _ = js_sys::Reflect::set(probe, &JsValue::from_str("webglFrames"), &array);
|
||||||
|
array
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let frame = js_sys::Object::new();
|
||||||
|
set_f64(&frame, "totalMs", stats.total_ms);
|
||||||
|
set_f64(&frame, "syncDomMs", stats.sync_dom_ms);
|
||||||
|
set_f64(&frame, "backdropMs", stats.backdrop_ms);
|
||||||
|
set_f64(&frame, "collectMs", stats.collect_ms);
|
||||||
|
set_f64(&frame, "backdropTextureMs", stats.backdrop_texture_ms);
|
||||||
|
set_f64(&frame, "fallbackCanvasMs", stats.fallback_canvas_ms);
|
||||||
|
set_f64(&frame, "atlasMs", stats.atlas_ms);
|
||||||
|
set_f64(&frame, "atlasUploadMs", stats.atlas_upload_ms);
|
||||||
|
set_f64(&frame, "vertexBuildMs", stats.vertex_build_ms);
|
||||||
|
set_f64(&frame, "vertexUploadMs", stats.vertex_upload_ms);
|
||||||
|
set_f64(&frame, "drawMs", stats.draw_ms);
|
||||||
|
set_f64(&frame, "cells", stats.cells as f64);
|
||||||
|
set_f64(&frame, "rowCacheHits", stats.row_cache_hits as f64);
|
||||||
|
set_f64(&frame, "rowCacheMisses", stats.row_cache_misses as f64);
|
||||||
|
set_f64(&frame, "vertexCacheHits", stats.vertex_cache_hits as f64);
|
||||||
|
set_f64(
|
||||||
|
&frame,
|
||||||
|
"vertexCacheMisses",
|
||||||
|
stats.vertex_cache_misses as f64,
|
||||||
|
);
|
||||||
|
set_f64(&frame, "atlasEntries", stats.atlas_entries as f64);
|
||||||
|
set_f64(&frame, "atlasInsertions", stats.atlas_insertions as f64);
|
||||||
|
set_f64(&frame, "atlasResets", stats.atlas_resets as f64);
|
||||||
|
set_bool(&frame, "fallbackFullCanvas", stats.fallback_full_canvas);
|
||||||
|
set_bool(&frame, "glyphDrawFailed", stats.glyph_draw_failed);
|
||||||
|
|
||||||
|
frames.push(&frame);
|
||||||
|
while frames.length() > 720 {
|
||||||
|
frames.shift();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
fn set_f64(target: &js_sys::Object, key: &str, value: f64) {
|
||||||
|
use wasm_bindgen::JsValue;
|
||||||
|
|
||||||
|
let _ = js_sys::Reflect::set(target, &JsValue::from_str(key), &JsValue::from_f64(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
fn set_bool(target: &js_sys::Object, key: &str, value: bool) {
|
||||||
|
use wasm_bindgen::JsValue;
|
||||||
|
|
||||||
|
let _ = js_sys::Reflect::set(target, &JsValue::from_str(key), &JsValue::from_bool(value));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ use leptos_meta::MetaTags;
|
|||||||
use crate::app::App;
|
use crate::app::App;
|
||||||
|
|
||||||
pub fn shell(options: LeptosOptions) -> impl IntoView {
|
pub fn shell(options: LeptosOptions) -> impl IntoView {
|
||||||
let css_href = SiteConfig::with_base_path("/pkg/base_path_demo.css");
|
let css_href = SiteConfig::with_base_path("/pkg/rustui_playground.css");
|
||||||
let js_href = SiteConfig::with_base_path("/pkg/base_path_demo.js");
|
let js_href = SiteConfig::with_base_path("/pkg/rustui_playground.js");
|
||||||
let wasm_href = SiteConfig::with_base_path("/pkg/base_path_demo.wasm");
|
let wasm_href = SiteConfig::with_base_path("/pkg/rustui_playground.wasm");
|
||||||
let hydration_script = format!(
|
let hydration_script = format!(
|
||||||
r#"import("{js_href}").then((mod) => {{
|
r#"import("{js_href}").then((mod) => {{
|
||||||
mod.default({{ module_or_path: "{wasm_href}" }}).then(() => mod.hydrate());
|
mod.default({{ module_or_path: "{wasm_href}" }}).then(() => mod.hydrate());
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ use super::mouse::{
|
|||||||
use super::protocol::{ClientTerminalMessage, ServerTerminalMessage};
|
use super::protocol::{ClientTerminalMessage, ServerTerminalMessage};
|
||||||
use super::renderer::{
|
use super::renderer::{
|
||||||
CanvasDomState, CanvasPlanInput, CanvasSurfaceFrame, CanvasSurfaceMode,
|
CanvasDomState, CanvasPlanInput, CanvasSurfaceFrame, CanvasSurfaceMode,
|
||||||
RenderCanvasSurfaceRequest, WebGlContextLifecycle, WebGlRendererState,
|
RenderCanvasSurfaceRequest, WebGlContextLifecycle, WebGlRenderStats, WebGlRendererState,
|
||||||
attach_webgl_context_lifecycle, canvas_render_plan, render_canvas_surface, render_webgl,
|
attach_webgl_context_lifecycle, canvas_render_plan, render_canvas_surface, render_webgl,
|
||||||
set_canvas_surface_mode, webgl_surface_mode,
|
set_canvas_surface_mode, webgl_surface_mode,
|
||||||
};
|
};
|
||||||
@@ -42,7 +42,7 @@ const DEFAULT_LINE_HEIGHT_PX: f64 = 18.0;
|
|||||||
const MIN_COLS: u16 = 40;
|
const MIN_COLS: u16 = 40;
|
||||||
const MIN_ROWS: u16 = 12;
|
const MIN_ROWS: u16 = 12;
|
||||||
const MEASURE_SAMPLE_TEXT: &str = "WWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWW";
|
const MEASURE_SAMPLE_TEXT: &str = "WWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWW";
|
||||||
const DEFAULT_TERMINAL_FONT_FAMILY: &str = "\"JetBrains Mono\", \"Fira Code\", \"Cascadia Code\", \"SFMono-Regular\", \"Consolas\", monospace";
|
const DEFAULT_TERMINAL_FONT_FAMILY: &str = "\"JetBrains Mono\", \"Fira Code\", \"Cascadia Code\", \"SFMono-Regular\", \"Consolas\", \"Noto Sans CJK SC\", \"Microsoft YaHei\", \"PingFang SC\", \"Apple Color Emoji\", \"Segoe UI Emoji\", \"Noto Color Emoji\", monospace";
|
||||||
|
|
||||||
/// Extra rows rendered above and below the visible window so that fast
|
/// Extra rows rendered above and below the visible window so that fast
|
||||||
/// scrolling doesn't flash blank areas before the next frame arrives.
|
/// scrolling doesn't flash blank areas before the next frame arrives.
|
||||||
@@ -141,6 +141,8 @@ pub struct TerminalFontOptions {
|
|||||||
pub family: String,
|
pub family: String,
|
||||||
pub size_px: f64,
|
pub size_px: f64,
|
||||||
pub line_height_px: f64,
|
pub line_height_px: f64,
|
||||||
|
pub ligatures: bool,
|
||||||
|
pub cjk_ambiguous_wide: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for TerminalFontOptions {
|
impl Default for TerminalFontOptions {
|
||||||
@@ -149,6 +151,8 @@ impl Default for TerminalFontOptions {
|
|||||||
family: DEFAULT_TERMINAL_FONT_FAMILY.to_owned(),
|
family: DEFAULT_TERMINAL_FONT_FAMILY.to_owned(),
|
||||||
size_px: DEFAULT_FONT_SIZE_PX,
|
size_px: DEFAULT_FONT_SIZE_PX,
|
||||||
line_height_px: DEFAULT_LINE_HEIGHT_PX,
|
line_height_px: DEFAULT_LINE_HEIGHT_PX,
|
||||||
|
ligatures: false,
|
||||||
|
cjk_ambiguous_wide: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -235,7 +239,7 @@ pub struct TerminalSelectionEvent {
|
|||||||
pub end: Option<(usize, usize)>,
|
pub end: Option<(usize, usize)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||||
pub struct TerminalRenderEvent {
|
pub struct TerminalRenderEvent {
|
||||||
pub cols: u16,
|
pub cols: u16,
|
||||||
pub rows: u16,
|
pub rows: u16,
|
||||||
@@ -243,6 +247,7 @@ pub struct TerminalRenderEvent {
|
|||||||
pub screen_rows: usize,
|
pub screen_rows: usize,
|
||||||
pub mode: ViewMode,
|
pub mode: ViewMode,
|
||||||
pub renderer: Renderer,
|
pub renderer: Renderer,
|
||||||
|
pub webgl_stats: Option<WebGlRenderStats>,
|
||||||
}
|
}
|
||||||
|
|
||||||
type SerializeFn = Arc<dyn Fn() -> String + Send + Sync>;
|
type SerializeFn = Arc<dyn Fn() -> String + Send + Sync>;
|
||||||
@@ -365,6 +370,8 @@ pub(super) struct ResolvedTerminalOptions {
|
|||||||
pub(super) font_family: String,
|
pub(super) font_family: String,
|
||||||
pub(super) font_size_px: f64,
|
pub(super) font_size_px: f64,
|
||||||
line_height_px: f64,
|
line_height_px: f64,
|
||||||
|
pub(super) ligatures: bool,
|
||||||
|
pub(super) cjk_ambiguous_wide: bool,
|
||||||
pub(super) theme: TerminalTheme,
|
pub(super) theme: TerminalTheme,
|
||||||
pub(super) cursor: TerminalCursorOptions,
|
pub(super) cursor: TerminalCursorOptions,
|
||||||
title: String,
|
title: String,
|
||||||
@@ -390,6 +397,8 @@ impl From<TerminalOptions> for ResolvedTerminalOptions {
|
|||||||
},
|
},
|
||||||
font_size_px: options.font.size_px.max(1.0),
|
font_size_px: options.font.size_px.max(1.0),
|
||||||
line_height_px: options.font.line_height_px.max(1.0),
|
line_height_px: options.font.line_height_px.max(1.0),
|
||||||
|
ligatures: options.font.ligatures,
|
||||||
|
cjk_ambiguous_wide: options.font.cjk_ambiguous_wide,
|
||||||
theme: options.theme,
|
theme: options.theme,
|
||||||
cursor: options.cursor,
|
cursor: options.cursor,
|
||||||
title: options.title,
|
title: options.title,
|
||||||
@@ -401,7 +410,7 @@ impl From<TerminalOptions> for ResolvedTerminalOptions {
|
|||||||
|
|
||||||
fn terminal_options_style(options: &ResolvedTerminalOptions) -> String {
|
fn terminal_options_style(options: &ResolvedTerminalOptions) -> String {
|
||||||
format!(
|
format!(
|
||||||
"--terminal-font-family:{};--terminal-font-size:{}px;--terminal-line-height:{}px;--terminal-fg:{};--terminal-bg:{};--terminal-cursor-fg:{};--terminal-cursor-bg:{};--terminal-selection-bg:{};--terminal-cursor-style:{};--terminal-cursor-blink:{};",
|
"--terminal-font-family:{};--terminal-font-size:{}px;--terminal-line-height:{}px;--terminal-fg:{};--terminal-bg:{};--terminal-cursor-fg:{};--terminal-cursor-bg:{};--terminal-selection-bg:{};--terminal-cursor-style:{};--terminal-cursor-blink:{};--terminal-font-ligatures:{};",
|
||||||
options.font_family,
|
options.font_family,
|
||||||
options.font_size_px,
|
options.font_size_px,
|
||||||
options.line_height_px,
|
options.line_height_px,
|
||||||
@@ -416,6 +425,7 @@ fn terminal_options_style(options: &ResolvedTerminalOptions) -> String {
|
|||||||
} else {
|
} else {
|
||||||
"none"
|
"none"
|
||||||
},
|
},
|
||||||
|
if options.ligatures { "normal" } else { "none" },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -514,6 +524,7 @@ pub fn TerminalPanel(
|
|||||||
let selecting = RwSignal::new(false);
|
let selecting = RwSignal::new(false);
|
||||||
let search_match = RwSignal::new(None::<TerminalSearchMatch>);
|
let search_match = RwSignal::new(None::<TerminalSearchMatch>);
|
||||||
let search_query = RwSignal::new(String::new());
|
let search_query = RwSignal::new(String::new());
|
||||||
|
let last_webgl_stats = RwSignal::new(None::<WebGlRenderStats>);
|
||||||
|
|
||||||
// IME composition state: when true, regular `input` events are ignored and
|
// IME composition state: when true, regular `input` events are ignored and
|
||||||
// the final composed string is sent on `compositionend`.
|
// the final composed string is sent on `compositionend`.
|
||||||
@@ -533,6 +544,12 @@ pub fn TerminalPanel(
|
|||||||
let webgl_state: Rc<std::cell::RefCell<Option<WebGlRendererState>>> =
|
let webgl_state: Rc<std::cell::RefCell<Option<WebGlRendererState>>> =
|
||||||
Rc::new(std::cell::RefCell::new(None));
|
Rc::new(std::cell::RefCell::new(None));
|
||||||
let webgl_context_lost: Rc<std::cell::Cell<bool>> = Rc::new(std::cell::Cell::new(false));
|
let webgl_context_lost: Rc<std::cell::Cell<bool>> = Rc::new(std::cell::Cell::new(false));
|
||||||
|
// Accessibility: screen-reader buffer, system motion/contrast preferences.
|
||||||
|
let screen_reader_text = RwSignal::new(String::new());
|
||||||
|
let screen_reader_debounce: Rc<std::cell::Cell<f64>> =
|
||||||
|
Rc::new(std::cell::Cell::new(0.0));
|
||||||
|
let prefers_reduced_motion = RwSignal::new(false);
|
||||||
|
|
||||||
|
|
||||||
handle.serialize_text_fn.set_value(Some(Arc::new(move || {
|
handle.serialize_text_fn.set_value(Some(Arc::new(move || {
|
||||||
core_signal
|
core_signal
|
||||||
@@ -624,6 +641,7 @@ pub fn TerminalPanel(
|
|||||||
screen_rows: core.screen_rows().len(),
|
screen_rows: core.screen_rows().len(),
|
||||||
mode,
|
mode,
|
||||||
renderer,
|
renderer,
|
||||||
|
webgl_stats: last_webgl_stats.get_untracked(),
|
||||||
}) {
|
}) {
|
||||||
on_render.run(event);
|
on_render.run(event);
|
||||||
}
|
}
|
||||||
@@ -640,12 +658,8 @@ pub fn TerminalPanel(
|
|||||||
lifecycle_webgl_state.borrow_mut().take();
|
lifecycle_webgl_state.borrow_mut().take();
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let Some(canvas) = canvas_ref.get() else {
|
let canvas = canvas_ref.get()?;
|
||||||
return None;
|
let canvas_el = canvas.dyn_ref::<HtmlCanvasElement>()?;
|
||||||
};
|
|
||||||
let Some(canvas_el) = canvas.dyn_ref::<HtmlCanvasElement>() else {
|
|
||||||
return None;
|
|
||||||
};
|
|
||||||
|
|
||||||
attach_webgl_context_lifecycle(
|
attach_webgl_context_lifecycle(
|
||||||
canvas_el,
|
canvas_el,
|
||||||
@@ -921,15 +935,14 @@ pub fn TerminalPanel(
|
|||||||
};
|
};
|
||||||
match renderer {
|
match renderer {
|
||||||
Renderer::WebGl => {
|
Renderer::WebGl => {
|
||||||
if render_webgl(
|
if let Ok(stats) = render_webgl(
|
||||||
canvas_el,
|
canvas_el,
|
||||||
frame,
|
frame,
|
||||||
&canvas_state,
|
&canvas_state,
|
||||||
&webgl_state,
|
&webgl_state,
|
||||||
&webgl_context_lost,
|
&webgl_context_lost,
|
||||||
)
|
) {
|
||||||
.is_ok()
|
last_webgl_stats.set(Some(stats));
|
||||||
{
|
|
||||||
set_canvas_surface_mode(
|
set_canvas_surface_mode(
|
||||||
canvas_el,
|
canvas_el,
|
||||||
fallback_canvas_el,
|
fallback_canvas_el,
|
||||||
@@ -941,6 +954,7 @@ pub fn TerminalPanel(
|
|||||||
fallback_canvas_el,
|
fallback_canvas_el,
|
||||||
webgl_surface_mode(false),
|
webgl_surface_mode(false),
|
||||||
);
|
);
|
||||||
|
last_webgl_stats.set(None);
|
||||||
render_canvas_surface(RenderCanvasSurfaceRequest {
|
render_canvas_surface(RenderCanvasSurfaceRequest {
|
||||||
canvas_el: fallback_canvas_el,
|
canvas_el: fallback_canvas_el,
|
||||||
frame,
|
frame,
|
||||||
@@ -949,6 +963,7 @@ pub fn TerminalPanel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Renderer::Canvas => {
|
Renderer::Canvas => {
|
||||||
|
last_webgl_stats.set(None);
|
||||||
set_canvas_surface_mode(canvas_el, fallback_canvas_el, CanvasSurfaceMode::WebGl);
|
set_canvas_surface_mode(canvas_el, fallback_canvas_el, CanvasSurfaceMode::WebGl);
|
||||||
render_canvas_surface(RenderCanvasSurfaceRequest {
|
render_canvas_surface(RenderCanvasSurfaceRequest {
|
||||||
canvas_el,
|
canvas_el,
|
||||||
@@ -956,7 +971,9 @@ pub fn TerminalPanel(
|
|||||||
canvas_state: &canvas_state,
|
canvas_state: &canvas_state,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Renderer::Dom => {}
|
Renderer::Dom => {
|
||||||
|
last_webgl_stats.set(None);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1113,12 +1130,8 @@ pub fn TerminalPanel(
|
|||||||
Effect::new(move |prev: Option<Option<ResizeObserverHandle>>| {
|
Effect::new(move |prev: Option<Option<ResizeObserverHandle>>| {
|
||||||
drop(prev);
|
drop(prev);
|
||||||
|
|
||||||
let Some(element) = terminal_ref.get() else {
|
let element = terminal_ref.get()?;
|
||||||
return None;
|
let socket = ws_signal.get()?;
|
||||||
};
|
|
||||||
let Some(socket) = ws_signal.get() else {
|
|
||||||
return None;
|
|
||||||
};
|
|
||||||
|
|
||||||
let resize_socket = socket.clone();
|
let resize_socket = socket.clone();
|
||||||
let resize_viewport = current_viewport;
|
let resize_viewport = current_viewport;
|
||||||
@@ -1614,6 +1627,13 @@ pub fn TerminalPanel(
|
|||||||
// Drag to select a range of terminal text. Selection is tracked in logical
|
// Drag to select a range of terminal text. Selection is tracked in logical
|
||||||
// buffer coordinates (not DOM), so it survives re-renders and works across
|
// buffer coordinates (not DOM), so it survives re-renders and works across
|
||||||
// rows that have scrolled out of the rendered window.
|
// rows that have scrolled out of the rendered window.
|
||||||
|
|
||||||
|
// Auto-scroll state: when the user drags a selection near the top or
|
||||||
|
// bottom edge of the terminal, we auto-scroll the viewport and extend the
|
||||||
|
// selection. `None` means not auto-scrolling.
|
||||||
|
let autoscroll_handle: Rc<std::cell::Cell<Option<i32>>> =
|
||||||
|
Rc::new(std::cell::Cell::new(None));
|
||||||
|
|
||||||
let cell_from_event = move |event: &MouseEvent| -> Option<(usize, usize)> {
|
let cell_from_event = move |event: &MouseEvent| -> Option<(usize, usize)> {
|
||||||
let element = terminal_ref.get_untracked()?;
|
let element = terminal_ref.get_untracked()?;
|
||||||
let rect = element.get_bounding_client_rect();
|
let rect = element.get_bounding_client_rect();
|
||||||
@@ -1707,6 +1727,7 @@ pub fn TerminalPanel(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let autoscroll_move = autoscroll_handle.clone();
|
||||||
let handle_mousemove = {
|
let handle_mousemove = {
|
||||||
let move_selection = selection;
|
let move_selection = selection;
|
||||||
let move_selecting = selecting;
|
let move_selecting = selecting;
|
||||||
@@ -1714,6 +1735,7 @@ pub fn TerminalPanel(
|
|||||||
let move_held = mouse_held_button.clone();
|
let move_held = mouse_held_button.clone();
|
||||||
let move_last = mouse_last_cell.clone();
|
let move_last = mouse_last_cell.clone();
|
||||||
let move_ws = ws_signal;
|
let move_ws = ws_signal;
|
||||||
|
let autoscroll_handle = autoscroll_move;
|
||||||
move |event: MouseEvent| {
|
move |event: MouseEvent| {
|
||||||
// App mouse motion reporting.
|
// App mouse motion reporting.
|
||||||
if !event.shift_key() {
|
if !event.shift_key() {
|
||||||
@@ -1770,15 +1792,53 @@ pub fn TerminalPanel(
|
|||||||
s.head = cell;
|
s.head = cell;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Auto-scroll when dragging near the top or bottom edge.
|
||||||
|
let Some(element) = terminal_ref.get_untracked() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let rect = element.get_bounding_client_rect();
|
||||||
|
let local_y = f64::from(event.client_y()) - rect.top();
|
||||||
|
let height = rect.height();
|
||||||
|
let edge_zone = 40.0_f64; // px from top/bottom to trigger auto-scroll
|
||||||
|
|
||||||
|
if local_y < edge_zone {
|
||||||
|
// Near top edge: scroll up, speed increases closer to edge.
|
||||||
|
let speed = ((edge_zone - local_y) / edge_zone * 8.0).max(1.0);
|
||||||
|
start_autoscroll(
|
||||||
|
&autoscroll_handle,
|
||||||
|
terminal_ref,
|
||||||
|
scroll_top,
|
||||||
|
programmatic_scroll_top,
|
||||||
|
view_mode,
|
||||||
|
-speed,
|
||||||
|
);
|
||||||
|
} else if local_y > height - edge_zone {
|
||||||
|
// Near bottom edge: scroll down.
|
||||||
|
let speed = ((local_y - (height - edge_zone)) / edge_zone * 8.0).max(1.0);
|
||||||
|
start_autoscroll(
|
||||||
|
&autoscroll_handle,
|
||||||
|
terminal_ref,
|
||||||
|
scroll_top,
|
||||||
|
programmatic_scroll_top,
|
||||||
|
view_mode,
|
||||||
|
speed,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Not near any edge — stop auto-scrolling.
|
||||||
|
stop_autoscroll(&autoscroll_handle);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let autoscroll_up = autoscroll_handle.clone();
|
||||||
let handle_mouseup = {
|
let handle_mouseup = {
|
||||||
let up_selecting = selecting;
|
let up_selecting = selecting;
|
||||||
let up_core = core_signal;
|
let up_core = core_signal;
|
||||||
let up_held = mouse_held_button.clone();
|
let up_held = mouse_held_button.clone();
|
||||||
let up_last = mouse_last_cell.clone();
|
let up_last = mouse_last_cell.clone();
|
||||||
let up_ws = ws_signal;
|
let up_ws = ws_signal;
|
||||||
|
let autoscroll_handle = autoscroll_up;
|
||||||
move |event: MouseEvent| {
|
move |event: MouseEvent| {
|
||||||
if !event.shift_key() {
|
if !event.shift_key() {
|
||||||
let mode_enc = up_core
|
let mode_enc = up_core
|
||||||
@@ -1818,6 +1878,7 @@ pub fn TerminalPanel(
|
|||||||
|
|
||||||
// Local selection end.
|
// Local selection end.
|
||||||
up_selecting.set(false);
|
up_selecting.set(false);
|
||||||
|
stop_autoscroll(&autoscroll_handle);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1825,10 +1886,93 @@ pub fn TerminalPanel(
|
|||||||
focus_ime_textarea(&ime_ref);
|
focus_ime_textarea(&ime_ref);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// -- Accessibility: screen-reader buffer ---------------------------------
|
||||||
|
//
|
||||||
|
// Extract visible terminal text and push it into an aria-live region so
|
||||||
|
// screen readers can announce new output. Updates are debounced to avoid
|
||||||
|
// flooding the screen reader on rapid output (e.g. cat of a large file).
|
||||||
|
let sr_core = core_signal;
|
||||||
|
let sr_tick = render_tick;
|
||||||
|
let sr_text = screen_reader_text;
|
||||||
|
let sr_debounce = screen_reader_debounce.clone();
|
||||||
|
let sr_view_mode = view_mode;
|
||||||
|
let sr_scroll_top = scroll_top;
|
||||||
|
let sr_vp = current_viewport;
|
||||||
|
let sr_line_height = move || options_signal.get().line_height_px;
|
||||||
|
Effect::new(move |_| {
|
||||||
|
let _ = sr_tick.get();
|
||||||
|
let now = window().performance().map_or(0.0, |p| p.now());
|
||||||
|
// Debounce: at most one aria-live update per 500 ms.
|
||||||
|
if now - sr_debounce.get() < 500.0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sr_debounce.set(now);
|
||||||
|
|
||||||
|
let line_height_px = sr_line_height();
|
||||||
|
let is_live = sr_view_mode.get().is_live();
|
||||||
|
let st = sr_scroll_top.get();
|
||||||
|
let vp_rows = sr_vp.get().rows as usize;
|
||||||
|
let visible_text = sr_core
|
||||||
|
.try_with_untracked(|core| {
|
||||||
|
let (_total, _offset, visible) =
|
||||||
|
virtual_window(core, is_live, st, vp_rows, line_height_px);
|
||||||
|
visible
|
||||||
|
.iter()
|
||||||
|
.filter_map(|row| {
|
||||||
|
let text: String = row
|
||||||
|
.segments
|
||||||
|
.iter()
|
||||||
|
.map(|seg| seg.text.as_str())
|
||||||
|
.collect();
|
||||||
|
let trimmed = text.trim_end();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(trimmed.to_owned())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n")
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
if !visible_text.is_empty() {
|
||||||
|
sr_text.set(visible_text);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// -- Accessibility: prefers-reduced-motion ------------------------------
|
||||||
|
{
|
||||||
|
let prm = prefers_reduced_motion;
|
||||||
|
if let Some(window) = web_sys::window()
|
||||||
|
&& let Ok(Some(mq)) =
|
||||||
|
window.match_media("(prefers-reduced-motion: reduce)")
|
||||||
|
{
|
||||||
|
let mq = Rc::new(mq);
|
||||||
|
prm.set(mq.matches());
|
||||||
|
let prm2 = prm;
|
||||||
|
let mq2 = mq.clone();
|
||||||
|
let mq3 = mq.clone();
|
||||||
|
let cb = Closure::<dyn Fn()>::new(move || {
|
||||||
|
prm2.set(mq2.matches());
|
||||||
|
});
|
||||||
|
let _ = mq3.add_event_listener_with_callback(
|
||||||
|
"change",
|
||||||
|
cb.as_ref().unchecked_ref(),
|
||||||
|
);
|
||||||
|
cb.forget();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// -- View ---------------------------------------------------------------
|
// -- View ---------------------------------------------------------------
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
<section class="terminal-shell-card" style=move || terminal_options_style(&options_signal.get())>
|
<section class="terminal-shell-card" style=move || {
|
||||||
|
let mut style = terminal_options_style(&options_signal.get());
|
||||||
|
if prefers_reduced_motion.get() {
|
||||||
|
style.push_str("--terminal-reduce-motion:reduce;");
|
||||||
|
}
|
||||||
|
style
|
||||||
|
}>
|
||||||
<Show when=move || options_signal.get().show_header>
|
<Show when=move || options_signal.get().show_header>
|
||||||
<div class="terminal-header">
|
<div class="terminal-header">
|
||||||
<div>
|
<div>
|
||||||
@@ -1869,6 +2013,9 @@ pub fn TerminalPanel(
|
|||||||
<div
|
<div
|
||||||
node_ref=terminal_ref
|
node_ref=terminal_ref
|
||||||
class="terminal-screen"
|
class="terminal-screen"
|
||||||
|
role="application"
|
||||||
|
aria-label="Terminal"
|
||||||
|
aria-roledescription="terminal emulator"
|
||||||
on:click=handle_click
|
on:click=handle_click
|
||||||
on:scroll=handle_scroll
|
on:scroll=handle_scroll
|
||||||
on:wheel=handle_wheel
|
on:wheel=handle_wheel
|
||||||
@@ -1893,6 +2040,15 @@ pub fn TerminalPanel(
|
|||||||
<span node_ref=measure_ref class="terminal-measure" aria-hidden="true">
|
<span node_ref=measure_ref class="terminal-measure" aria-hidden="true">
|
||||||
{MEASURE_SAMPLE_TEXT}
|
{MEASURE_SAMPLE_TEXT}
|
||||||
</span>
|
</span>
|
||||||
|
<div
|
||||||
|
class="terminal-screen-reader"
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
aria-atomic="false"
|
||||||
|
aria-label="Terminal output"
|
||||||
|
>
|
||||||
|
{move || screen_reader_text.get()}
|
||||||
|
</div>
|
||||||
<div class="terminal-grid">
|
<div class="terminal-grid">
|
||||||
<Show
|
<Show
|
||||||
when=move || renderer_uses_canvas_surface(options_signal.get().renderer)
|
when=move || renderer_uses_canvas_surface(options_signal.get().renderer)
|
||||||
@@ -2339,6 +2495,92 @@ fn sync_scroll_to_position(
|
|||||||
callback.forget();
|
callback.forget();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -- Auto-scroll during selection drag --------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
/// Start or continue auto-scrolling the terminal viewport while the user
|
||||||
|
/// drags a selection near an edge. `delta_px` is negative for upward
|
||||||
|
/// scroll (top edge) and positive for downward (bottom edge).
|
||||||
|
fn start_autoscroll(
|
||||||
|
handle: &Rc<std::cell::Cell<Option<i32>>>,
|
||||||
|
terminal_ref: NodeRef<html::Div>,
|
||||||
|
scroll_top: RwSignal<i32>,
|
||||||
|
programmatic_scroll_top: RwSignal<i32>,
|
||||||
|
view_mode: RwSignal<ViewMode>,
|
||||||
|
delta_px: f64,
|
||||||
|
) {
|
||||||
|
use wasm_bindgen::JsCast;
|
||||||
|
use web_sys::window;
|
||||||
|
|
||||||
|
// If already auto-scrolling, skip re-creation.
|
||||||
|
if handle.get().is_some() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(window) = window() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let delta_px = delta_px.round() as i32;
|
||||||
|
if delta_px == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let handle_cell = handle.clone();
|
||||||
|
|
||||||
|
let callback = Closure::<dyn FnMut()>::wrap(Box::new(move || {
|
||||||
|
let Some(element) = terminal_ref.get_untracked() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(el) = element.dyn_ref::<HtmlElement>() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let new_scroll_top = el.scroll_top() + delta_px;
|
||||||
|
// Clamp to valid range.
|
||||||
|
let max_scroll = el.scroll_height() - el.client_height();
|
||||||
|
let clamped = new_scroll_top.clamp(0, max_scroll);
|
||||||
|
el.set_scroll_top(clamped);
|
||||||
|
programmatic_scroll_top.set(clamped);
|
||||||
|
|
||||||
|
// Switch to history mode when scrolling away from the bottom.
|
||||||
|
let at_bottom = clamped >= max_scroll.saturating_sub(2);
|
||||||
|
if !at_bottom {
|
||||||
|
let offset = super::scrollback::scroll_top_to_row(
|
||||||
|
clamped,
|
||||||
|
// line_height_px — we don't have it here, but view_mode
|
||||||
|
// with an approximate offset is fine for auto-scroll;
|
||||||
|
// the next render tick will correct it.
|
||||||
|
18.0,
|
||||||
|
);
|
||||||
|
view_mode.set(ViewMode::History { scroll_offset: offset });
|
||||||
|
} else {
|
||||||
|
view_mode.set(ViewMode::Live);
|
||||||
|
}
|
||||||
|
|
||||||
|
scroll_top.set(clamped);
|
||||||
|
}) as Box<dyn FnMut()>);
|
||||||
|
|
||||||
|
let Ok(interval_id) = window.set_interval_with_callback_and_timeout_and_arguments_0(
|
||||||
|
callback.as_ref().unchecked_ref(),
|
||||||
|
16, // ~60 fps
|
||||||
|
) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
handle_cell.set(Some(interval_id));
|
||||||
|
// Leak the closure so it stays alive for the lifetime of the interval.
|
||||||
|
callback.forget();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop any active auto-scroll interval.
|
||||||
|
fn stop_autoscroll(handle: &Rc<std::cell::Cell<Option<i32>>>) {
|
||||||
|
if let Some(interval_id) = handle.take()
|
||||||
|
&& let Some(window) = web_sys::window()
|
||||||
|
{
|
||||||
|
window.clear_interval_with_handle(interval_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
#[derive(Clone, Copy)]
|
#[derive(Clone, Copy)]
|
||||||
struct SearchScrollContext {
|
struct SearchScrollContext {
|
||||||
total_rows: usize,
|
total_rows: usize,
|
||||||
@@ -2856,6 +3098,8 @@ mod tests {
|
|||||||
family: String::new(),
|
family: String::new(),
|
||||||
size_px: 0.0,
|
size_px: 0.0,
|
||||||
line_height_px: 0.0,
|
line_height_px: 0.0,
|
||||||
|
ligatures: false,
|
||||||
|
cjk_ambiguous_wide: true,
|
||||||
},
|
},
|
||||||
..TerminalOptions::default()
|
..TerminalOptions::default()
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,6 +3,280 @@ use vt100::{Color, Parser, Screen};
|
|||||||
|
|
||||||
pub use vt100::{MouseProtocolEncoding, MouseProtocolMode};
|
pub use vt100::{MouseProtocolEncoding, MouseProtocolMode};
|
||||||
|
|
||||||
|
/// Terminal-aware grapheme cluster width.
|
||||||
|
///
|
||||||
|
/// Extends `unicode_width::UnicodeWidthChar::width()` with:
|
||||||
|
/// - CJK ambiguous-width characters treated as 2 when `cjk_ambiguous_wide` is
|
||||||
|
/// true (default terminal convention in East Asian locales).
|
||||||
|
/// - Emoji ZWJ sequences: if a grapheme cluster contains a ZWJ (U+200D) and
|
||||||
|
/// begins with an emoji character, the whole cluster gets width 2 regardless
|
||||||
|
/// of how many individual codepoints are joined.
|
||||||
|
/// - Variation selectors (U+FE0E / U+FE0F) are absorbed into the preceding
|
||||||
|
/// character's width and do not add columns.
|
||||||
|
/// - Zero-width joiners and other zero-width characters remain width 0 within
|
||||||
|
/// a cluster; the caller typically groups graphemes first.
|
||||||
|
pub fn grapheme_width(grapheme: &str, cjk_ambiguous_wide: bool) -> usize {
|
||||||
|
use unicode_width::UnicodeWidthChar;
|
||||||
|
use unicode_width::UnicodeWidthStr;
|
||||||
|
|
||||||
|
// Emoji ZWJ sequences: check BEFORE standard width, because the individual
|
||||||
|
// emoji components each have width 2, making the naive sum too large.
|
||||||
|
// A ZWJ emoji sequence should render as a single emoji = width 2.
|
||||||
|
if grapheme.contains('\u{200D}')
|
||||||
|
&& grapheme.chars().next().is_some_and(is_emoji_presentation)
|
||||||
|
{
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
let base_width = UnicodeWidthStr::width(grapheme);
|
||||||
|
if base_width > 0 {
|
||||||
|
if cjk_ambiguous_wide {
|
||||||
|
let mut adjusted = 0usize;
|
||||||
|
for ch in grapheme.chars() {
|
||||||
|
if is_cjk_ambiguous(ch) {
|
||||||
|
adjusted += 2;
|
||||||
|
} else {
|
||||||
|
adjusted += ch.width().unwrap_or(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return adjusted.max(base_width);
|
||||||
|
}
|
||||||
|
return base_width;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Width 0 (zero-width joiners, variation selectors, combining marks):
|
||||||
|
// occupy at least one cell — terminals rarely support true zero-width.
|
||||||
|
1
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Characters whose width is "ambiguous" per UAX #11 (East Asian Width).
|
||||||
|
/// In CJK contexts these should be rendered as width 2.
|
||||||
|
fn is_cjk_ambiguous(ch: char) -> bool {
|
||||||
|
matches!(ch,
|
||||||
|
'\u{00A1}'
|
||||||
|
| '\u{00A4}'
|
||||||
|
| '\u{00A7}'..='\u{00A8}'
|
||||||
|
| '\u{00AA}'
|
||||||
|
| '\u{00AD}'..='\u{00AE}'
|
||||||
|
| '\u{00B0}'..='\u{00B4}'
|
||||||
|
| '\u{00B6}'..='\u{00BA}'
|
||||||
|
| '\u{00BC}'..='\u{00BF}'
|
||||||
|
| '\u{00C6}'
|
||||||
|
| '\u{00D0}'
|
||||||
|
| '\u{00D7}'..='\u{00D8}'
|
||||||
|
| '\u{00DE}'..='\u{00E1}'
|
||||||
|
| '\u{00E6}'
|
||||||
|
| '\u{00E8}'..='\u{00EA}'
|
||||||
|
| '\u{00EC}'..='\u{00ED}'
|
||||||
|
| '\u{00F0}'
|
||||||
|
| '\u{00F2}'..='\u{00F3}'
|
||||||
|
| '\u{00F7}'..='\u{00FA}'
|
||||||
|
| '\u{00FC}'
|
||||||
|
| '\u{00FE}'
|
||||||
|
| '\u{0101}'
|
||||||
|
| '\u{0111}'
|
||||||
|
| '\u{0113}'
|
||||||
|
| '\u{011B}'
|
||||||
|
| '\u{0126}'..='\u{0127}'
|
||||||
|
| '\u{012B}'
|
||||||
|
| '\u{0131}'..='\u{0133}'
|
||||||
|
| '\u{0138}'
|
||||||
|
| '\u{013F}'..='\u{0142}'
|
||||||
|
| '\u{0144}'
|
||||||
|
| '\u{0148}'..='\u{014B}'
|
||||||
|
| '\u{014D}'
|
||||||
|
| '\u{0152}'..='\u{0153}'
|
||||||
|
| '\u{0166}'..='\u{0167}'
|
||||||
|
| '\u{016B}'
|
||||||
|
| '\u{01CE}'
|
||||||
|
| '\u{01D0}'
|
||||||
|
| '\u{01D2}'
|
||||||
|
| '\u{01D4}'
|
||||||
|
| '\u{01D6}'
|
||||||
|
| '\u{01D8}'
|
||||||
|
| '\u{01DA}'
|
||||||
|
| '\u{01DC}'
|
||||||
|
| '\u{0251}'
|
||||||
|
| '\u{0261}'
|
||||||
|
| '\u{02C4}'
|
||||||
|
| '\u{02C7}'
|
||||||
|
| '\u{02C9}'..='\u{02CB}'
|
||||||
|
| '\u{02CD}'
|
||||||
|
| '\u{02D0}'
|
||||||
|
| '\u{02D8}'..='\u{02DB}'
|
||||||
|
| '\u{02DD}'
|
||||||
|
| '\u{02DF}'
|
||||||
|
| '\u{0300}'..='\u{036F}'
|
||||||
|
| '\u{0391}'..='\u{03A1}'
|
||||||
|
| '\u{03A3}'..='\u{03A9}'
|
||||||
|
| '\u{03B1}'..='\u{03C1}'
|
||||||
|
| '\u{03C3}'..='\u{03C9}'
|
||||||
|
| '\u{0401}'
|
||||||
|
| '\u{0410}'..='\u{044F}'
|
||||||
|
| '\u{0451}'
|
||||||
|
| '\u{2010}'
|
||||||
|
| '\u{2013}'..='\u{2016}'
|
||||||
|
| '\u{2018}'..='\u{2019}'
|
||||||
|
| '\u{201C}'..='\u{201D}'
|
||||||
|
| '\u{2020}'..='\u{2022}'
|
||||||
|
| '\u{2024}'..='\u{2027}'
|
||||||
|
| '\u{2030}'
|
||||||
|
| '\u{2032}'..='\u{2033}'
|
||||||
|
| '\u{2035}'
|
||||||
|
| '\u{203B}'
|
||||||
|
| '\u{203E}'
|
||||||
|
| '\u{2074}'
|
||||||
|
| '\u{207F}'
|
||||||
|
| '\u{2081}'..='\u{2084}'
|
||||||
|
| '\u{20AC}'
|
||||||
|
| '\u{2103}'
|
||||||
|
| '\u{2105}'
|
||||||
|
| '\u{2109}'
|
||||||
|
| '\u{2113}'
|
||||||
|
| '\u{2116}'
|
||||||
|
| '\u{2121}'..='\u{2122}'
|
||||||
|
| '\u{2126}'
|
||||||
|
| '\u{212B}'
|
||||||
|
| '\u{2153}'..='\u{2154}'
|
||||||
|
| '\u{215B}'..='\u{215E}'
|
||||||
|
| '\u{2160}'..='\u{216B}'
|
||||||
|
| '\u{2170}'..='\u{2179}'
|
||||||
|
| '\u{2189}'
|
||||||
|
| '\u{2190}'..='\u{2199}'
|
||||||
|
| '\u{21B8}'..='\u{21B9}'
|
||||||
|
| '\u{21D2}'
|
||||||
|
| '\u{21D4}'
|
||||||
|
| '\u{21E7}'
|
||||||
|
| '\u{2200}'
|
||||||
|
| '\u{2202}'..='\u{2203}'
|
||||||
|
| '\u{2207}'..='\u{2208}'
|
||||||
|
| '\u{220B}'
|
||||||
|
| '\u{220F}'
|
||||||
|
| '\u{2211}'
|
||||||
|
| '\u{2215}'
|
||||||
|
| '\u{221A}'
|
||||||
|
| '\u{221D}'..='\u{2220}'
|
||||||
|
| '\u{2223}'
|
||||||
|
| '\u{2225}'
|
||||||
|
| '\u{2227}'..='\u{222C}'
|
||||||
|
| '\u{222E}'
|
||||||
|
| '\u{2234}'..='\u{2237}'
|
||||||
|
| '\u{223C}'..='\u{223D}'
|
||||||
|
| '\u{2248}'
|
||||||
|
| '\u{224C}'
|
||||||
|
| '\u{2252}'
|
||||||
|
| '\u{2260}'..='\u{2261}'
|
||||||
|
| '\u{2264}'..='\u{2267}'
|
||||||
|
| '\u{226A}'..='\u{226B}'
|
||||||
|
| '\u{226E}'..='\u{226F}'
|
||||||
|
| '\u{2282}'..='\u{2283}'
|
||||||
|
| '\u{2286}'..='\u{2287}'
|
||||||
|
| '\u{2295}'
|
||||||
|
| '\u{2299}'
|
||||||
|
| '\u{22A5}'
|
||||||
|
| '\u{22BF}'
|
||||||
|
| '\u{2312}'
|
||||||
|
| '\u{2460}'..='\u{24E9}'
|
||||||
|
| '\u{24EB}'..='\u{254B}'
|
||||||
|
| '\u{2550}'..='\u{2573}'
|
||||||
|
| '\u{2580}'..='\u{258F}'
|
||||||
|
| '\u{2592}'..='\u{2595}'
|
||||||
|
| '\u{25A0}'..='\u{25A1}'
|
||||||
|
| '\u{25A3}'..='\u{25A9}'
|
||||||
|
| '\u{25B2}'..='\u{25B3}'
|
||||||
|
| '\u{25B6}'..='\u{25B7}'
|
||||||
|
| '\u{25BC}'..='\u{25BD}'
|
||||||
|
| '\u{25C0}'..='\u{25C1}'
|
||||||
|
| '\u{25C6}'..='\u{25C8}'
|
||||||
|
| '\u{25CB}'
|
||||||
|
| '\u{25CE}'..='\u{25D1}'
|
||||||
|
| '\u{25E2}'..='\u{25E5}'
|
||||||
|
| '\u{25EF}'
|
||||||
|
| '\u{2605}'..='\u{2606}'
|
||||||
|
| '\u{2609}'
|
||||||
|
| '\u{260E}'..='\u{260F}'
|
||||||
|
| '\u{2614}'..='\u{2615}'
|
||||||
|
| '\u{261C}'
|
||||||
|
| '\u{261E}'
|
||||||
|
| '\u{2640}'
|
||||||
|
| '\u{2642}'
|
||||||
|
| '\u{2660}'..='\u{2661}'
|
||||||
|
| '\u{2663}'..='\u{2665}'
|
||||||
|
| '\u{2667}'..='\u{266A}'
|
||||||
|
| '\u{266C}'..='\u{266D}'
|
||||||
|
| '\u{266F}'
|
||||||
|
| '\u{269E}'..='\u{269F}'
|
||||||
|
| '\u{26BE}'..='\u{26BF}'
|
||||||
|
| '\u{26C4}'..='\u{26CD}'
|
||||||
|
| '\u{26CF}'..='\u{26E1}'
|
||||||
|
| '\u{26E3}'
|
||||||
|
| '\u{26E8}'..='\u{26FF}'
|
||||||
|
| '\u{273D}'
|
||||||
|
| '\u{2757}'
|
||||||
|
| '\u{2776}'..='\u{277F}'
|
||||||
|
| '\u{2B55}'..='\u{2B59}'
|
||||||
|
| '\u{3248}'..='\u{324F}'
|
||||||
|
| '\u{E000}'..='\u{F8FF}'
|
||||||
|
| '\u{FE00}'..='\u{FE0F}'
|
||||||
|
| '\u{FE10}'..='\u{FE19}'
|
||||||
|
| '\u{FE30}'..='\u{FE6F}'
|
||||||
|
| '\u{FF01}'..='\u{FF60}'
|
||||||
|
| '\u{FFE0}'..='\u{FFE6}'
|
||||||
|
| '\u{1F100}'..='\u{1F10A}'
|
||||||
|
| '\u{1F110}'..='\u{1F12D}'
|
||||||
|
| '\u{1F130}'..='\u{1F169}'
|
||||||
|
| '\u{1F170}'..='\u{1F19A}'
|
||||||
|
| '\u{1F200}'..='\u{1F202}'
|
||||||
|
| '\u{1F210}'..='\u{1F23B}'
|
||||||
|
| '\u{1F240}'..='\u{1F248}'
|
||||||
|
| '\u{1F250}'..='\u{1F251}'
|
||||||
|
| '\u{E0100}'..='\u{E01EF}'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// Quick heuristic: is this character likely to render as emoji?
|
||||||
|
fn is_emoji_presentation(ch: char) -> bool {
|
||||||
|
matches!(ch,
|
||||||
|
// Emoticons
|
||||||
|
'\u{1F600}'..='\u{1F64F}'
|
||||||
|
// Miscellaneous Symbols and Pictographs
|
||||||
|
| '\u{1F300}'..='\u{1F5FF}'
|
||||||
|
// Supplemental Symbols and Pictographs
|
||||||
|
| '\u{1F900}'..='\u{1F9FF}'
|
||||||
|
// Symbols and Pictographs Extended-A
|
||||||
|
| '\u{1FA70}'..='\u{1FAFF}'
|
||||||
|
// Transport and Map Symbols
|
||||||
|
| '\u{1F680}'..='\u{1F6FF}'
|
||||||
|
// Dingbats (covers hearts etc. too)
|
||||||
|
| '\u{2702}'..='\u{27B0}'
|
||||||
|
// Enclosed Alphanumerics
|
||||||
|
| '\u{1F100}'..='\u{1F1FF}'
|
||||||
|
// Miscellaneous Technical
|
||||||
|
| '\u{231A}'..='\u{231B}'
|
||||||
|
| '\u{23F0}'..='\u{23F3}'
|
||||||
|
| '\u{23E9}'..='\u{23F3}'
|
||||||
|
| '\u{25AA}'..='\u{25AB}'
|
||||||
|
| '\u{25FB}'..='\u{25FE}'
|
||||||
|
| '\u{2600}'..='\u{26FF}'
|
||||||
|
| '\u{2934}'..='\u{2935}'
|
||||||
|
| '\u{2B05}'..='\u{2B07}'
|
||||||
|
| '\u{2B1B}'..='\u{2B1C}'
|
||||||
|
| '\u{3030}'
|
||||||
|
| '\u{303D}'
|
||||||
|
| '\u{3297}'
|
||||||
|
| '\u{3299}'
|
||||||
|
// Supplemental Arrows
|
||||||
|
| '\u{1F000}'..='\u{1F02F}'
|
||||||
|
// Geometric Shapes Extended
|
||||||
|
| '\u{1F780}'..='\u{1F7FF}'
|
||||||
|
// Chess Symbols
|
||||||
|
| '\u{1FA00}'..='\u{1FA6F}'
|
||||||
|
// Keycap sequences start with digit/# + VS16 + U+20E3
|
||||||
|
| '0'..='9' | '#' | '*'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
pub const DEFAULT_ROWS: u16 = 24;
|
pub const DEFAULT_ROWS: u16 = 24;
|
||||||
pub const DEFAULT_COLS: u16 = 80;
|
pub const DEFAULT_COLS: u16 = 80;
|
||||||
|
|
||||||
@@ -547,15 +821,22 @@ impl TerminalRow {
|
|||||||
if col >= to {
|
if col >= to {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
// This segment overlaps [from, to): emit the columns inside range.
|
// Walk grapheme clusters and track actual grid-column width so
|
||||||
let chars: Vec<char> = seg.text.chars().collect();
|
// that wide chars (2 cols) and combining marks (0 cols) are
|
||||||
for (i, ch) in chars.iter().enumerate() {
|
// sliced correctly.
|
||||||
// Distribute chars across the segment's columns 1:1 (clamped).
|
use unicode_segmentation::UnicodeSegmentation;
|
||||||
let ch_col = col + i.min(seg.cols.saturating_sub(1));
|
for grapheme in UnicodeSegmentation::graphemes(seg.text.as_str(), true) {
|
||||||
if ch_col >= from && ch_col < to {
|
let w = grapheme_width(grapheme, false);
|
||||||
out.push(*ch);
|
let ch_end = col + w;
|
||||||
|
// If this grapheme overlaps [from, to), emit it.
|
||||||
|
if ch_end > from && col < to {
|
||||||
|
out.push_str(grapheme);
|
||||||
}
|
}
|
||||||
|
col = ch_end;
|
||||||
}
|
}
|
||||||
|
// If our tracked `col` differs from `seg_end` (e.g. because
|
||||||
|
// `grapheme_width` disagrees with the vt100 grid), trust the
|
||||||
|
// grid column count for the next segment's baseline.
|
||||||
col = seg_end;
|
col = seg_end;
|
||||||
}
|
}
|
||||||
out.trim_end().to_owned()
|
out.trim_end().to_owned()
|
||||||
@@ -673,9 +954,21 @@ fn rfind_literal_before_col(text: &str, query: &str, before_col: usize) -> Optio
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn text_slice_cols(text: &str, start_col: usize, end_col: usize) -> String {
|
fn text_slice_cols(text: &str, start_col: usize, end_col: usize) -> String {
|
||||||
let start = byte_index_for_char_col(text, start_col);
|
use unicode_segmentation::UnicodeSegmentation;
|
||||||
let end = byte_index_for_char_col(text, end_col);
|
let mut out = String::new();
|
||||||
text.get(start..end).unwrap_or_default().to_owned()
|
let mut col = 0usize;
|
||||||
|
for grapheme in UnicodeSegmentation::graphemes(text, true) {
|
||||||
|
let w = grapheme_width(grapheme, false);
|
||||||
|
let ch_end = col + w;
|
||||||
|
if ch_end > start_col && col < end_col {
|
||||||
|
out.push_str(grapheme);
|
||||||
|
}
|
||||||
|
col = ch_end;
|
||||||
|
if col >= end_col {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
fn byte_index_for_char_col(text: &str, col: usize) -> usize {
|
fn byte_index_for_char_col(text: &str, col: usize) -> usize {
|
||||||
@@ -1723,4 +2016,51 @@ mod tests {
|
|||||||
"earliest line must still be present under the cap"
|
"earliest line must still be present under the cap"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
#[test]
|
||||||
|
fn grapheme_width_ascii_is_one() {
|
||||||
|
assert_eq!(grapheme_width("a", false), 1);
|
||||||
|
assert_eq!(grapheme_width("A", true), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn grapheme_width_cjk_is_two() {
|
||||||
|
assert_eq!(grapheme_width("中", false), 2);
|
||||||
|
assert_eq!(grapheme_width("文", true), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn grapheme_width_cjk_ambiguous_respects_flag() {
|
||||||
|
assert_eq!(grapheme_width("\u{25CB}", false), 1);
|
||||||
|
assert_eq!(grapheme_width("\u{25CB}", true), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn grapheme_width_emoji_is_two() {
|
||||||
|
assert_eq!(grapheme_width("\u{1F600}", false), 2);
|
||||||
|
assert_eq!(grapheme_width("\u{1F44D}", true), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn grapheme_width_emoji_zwj_sequence_is_two() {
|
||||||
|
let family = "\u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F467}";
|
||||||
|
assert_eq!(grapheme_width(family, false), 2);
|
||||||
|
assert_eq!(grapheme_width(family, true), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn grapheme_width_zwj_non_emoji_is_one() {
|
||||||
|
assert_eq!(grapheme_width("\u{200D}", false), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn grapheme_width_combining_char_is_one() {
|
||||||
|
assert_eq!(grapheme_width("\u{0301}", false), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn grapheme_width_variation_selector_absorbed() {
|
||||||
|
let text_style = "\u{2660}\u{FE0E}";
|
||||||
|
assert_eq!(grapheme_width(text_style, false), 1);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,3 +14,4 @@ pub use component::{
|
|||||||
TerminalSelectionEvent, TerminalTheme,
|
TerminalSelectionEvent, TerminalTheme,
|
||||||
};
|
};
|
||||||
pub use core::{SearchDirection, TerminalLink, TerminalSearchMatch};
|
pub use core::{SearchDirection, TerminalLink, TerminalSearchMatch};
|
||||||
|
pub use renderer::WebGlRenderStats;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -222,6 +222,9 @@ async function runScenario(browser, options, viewport) {
|
|||||||
frameSamples.push(
|
frameSamples.push(
|
||||||
...(await page.evaluate(() => window.__terminalBenchFrames || [])),
|
...(await page.evaluate(() => window.__terminalBenchFrames || [])),
|
||||||
);
|
);
|
||||||
|
const rendererStats = await page.evaluate(() => window.__terminalBench?.webglFrames || []);
|
||||||
|
const cleanProbe = { ...probe };
|
||||||
|
delete cleanProbe.webglFrames;
|
||||||
const canvasPixelSmoke = await page.evaluate(() => {
|
const canvasPixelSmoke = await page.evaluate(() => {
|
||||||
const canvas = document.querySelector("canvas.terminal-canvas:not([hidden])");
|
const canvas = document.querySelector("canvas.terminal-canvas:not([hidden])");
|
||||||
if (!canvas) {
|
if (!canvas) {
|
||||||
@@ -279,7 +282,8 @@ async function runScenario(browser, options, viewport) {
|
|||||||
elapsedMs,
|
elapsedMs,
|
||||||
rowsPerSecond: round(options.lines / (elapsedMs / 1000)),
|
rowsPerSecond: round(options.lines / (elapsedMs / 1000)),
|
||||||
frameMs: summarizeSamples(frameSamples),
|
frameMs: summarizeSamples(frameSamples),
|
||||||
probe,
|
rendererStats: summarizeRendererStats(rendererStats),
|
||||||
|
probe: cleanProbe,
|
||||||
pixelSmoke: pixelResult,
|
pixelSmoke: pixelResult,
|
||||||
screenshotPath,
|
screenshotPath,
|
||||||
};
|
};
|
||||||
@@ -288,6 +292,47 @@ async function runScenario(browser, options, viewport) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function summarizeRendererStats(frames) {
|
||||||
|
if (!Array.isArray(frames) || frames.length === 0) {
|
||||||
|
return { count: 0 };
|
||||||
|
}
|
||||||
|
const numericFields = [
|
||||||
|
"totalMs",
|
||||||
|
"syncDomMs",
|
||||||
|
"backdropMs",
|
||||||
|
"collectMs",
|
||||||
|
"backdropTextureMs",
|
||||||
|
"fallbackCanvasMs",
|
||||||
|
"atlasMs",
|
||||||
|
"atlasUploadMs",
|
||||||
|
"vertexBuildMs",
|
||||||
|
"vertexUploadMs",
|
||||||
|
"drawMs",
|
||||||
|
"cells",
|
||||||
|
"rowCacheHits",
|
||||||
|
"rowCacheMisses",
|
||||||
|
"vertexCacheHits",
|
||||||
|
"vertexCacheMisses",
|
||||||
|
"atlasEntries",
|
||||||
|
"atlasInsertions",
|
||||||
|
"atlasResets",
|
||||||
|
];
|
||||||
|
const summary = { count: frames.length };
|
||||||
|
for (const field of numericFields) {
|
||||||
|
const samples = frames
|
||||||
|
.map((frame) => Number(frame[field] || 0))
|
||||||
|
.filter((value) => Number.isFinite(value));
|
||||||
|
summary[field] = summarizeSamples(samples);
|
||||||
|
}
|
||||||
|
summary.fallbackFullCanvasCount = frames.filter(
|
||||||
|
(frame) => Boolean(frame.fallbackFullCanvas),
|
||||||
|
).length;
|
||||||
|
summary.glyphDrawFailedCount = frames.filter(
|
||||||
|
(frame) => Boolean(frame.glyphDrawFailed),
|
||||||
|
).length;
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
function benchUrl(options) {
|
function benchUrl(options) {
|
||||||
const url = new URL(options.url);
|
const url = new URL(options.url);
|
||||||
url.searchParams.set("renderer", options.renderer);
|
url.searchParams.set("renderer", options.renderer);
|
||||||
|
|||||||
@@ -638,6 +638,7 @@
|
|||||||
);
|
);
|
||||||
font-size: var(--terminal-font-size, 13px);
|
font-size: var(--terminal-font-size, 13px);
|
||||||
line-height: var(--terminal-line-height, 18px);
|
line-height: var(--terminal-line-height, 18px);
|
||||||
|
font-variant-ligatures: var(--terminal-font-ligatures, none);
|
||||||
white-space: pre;
|
white-space: pre;
|
||||||
letter-spacing: 0;
|
letter-spacing: 0;
|
||||||
scrollbar-gutter: stable;
|
scrollbar-gutter: stable;
|
||||||
@@ -689,6 +690,7 @@
|
|||||||
);
|
);
|
||||||
font-size: var(--terminal-font-size, 13px);
|
font-size: var(--terminal-font-size, 13px);
|
||||||
line-height: var(--terminal-line-height, 18px);
|
line-height: var(--terminal-line-height, 18px);
|
||||||
|
font-variant-ligatures: var(--terminal-font-ligatures, none);
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
@@ -804,6 +806,20 @@
|
|||||||
background: rgb(125 211 252 / 14%);
|
background: rgb(125 211 252 / 14%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* Screen-reader-only announcement region (visually hidden, accessible to AT). */
|
||||||
|
.terminal-screen-reader {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
margin: -1px;
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.terminal-measure {
|
.terminal-measure {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: -9999px;
|
left: -9999px;
|
||||||
@@ -844,6 +860,36 @@
|
|||||||
color: var(--cowa-text-primary);
|
color: var(--cowa-text-primary);
|
||||||
line-height: 24px;
|
line-height: 24px;
|
||||||
}
|
}
|
||||||
|
/* Accessibility: respect OS-level motion preference. */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.terminal-cursor {
|
||||||
|
animation: none !important;
|
||||||
|
}
|
||||||
|
.terminal-screen,
|
||||||
|
.terminal-ime {
|
||||||
|
transition: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Accessibility: respect OS-level contrast preference. */
|
||||||
|
@media (prefers-contrast: more) {
|
||||||
|
.terminal-screen {
|
||||||
|
background: Canvas !important;
|
||||||
|
color: CanvasText !important;
|
||||||
|
border-color: CanvasText !important;
|
||||||
|
}
|
||||||
|
.terminal-text {
|
||||||
|
color: CanvasText !important;
|
||||||
|
}
|
||||||
|
.terminal-segment {
|
||||||
|
color: CanvasText !important;
|
||||||
|
}
|
||||||
|
.terminal-badge {
|
||||||
|
border: 1px solid CanvasText;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
.cowa-page-heading p {
|
.cowa-page-heading p {
|
||||||
@apply m-0 pt-1 text-sm;
|
@apply m-0 pt-1 text-sm;
|
||||||
|
|||||||
Reference in New Issue
Block a user