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",
|
||||
"MouseEvent",
|
||||
"Navigator",
|
||||
"Performance",
|
||||
"ResizeObserver",
|
||||
"ResizeObserverEntry",
|
||||
"WebGl2RenderingContext",
|
||||
@@ -77,7 +78,7 @@ web-sys = { version = "0.3", default-features = false, features = [
|
||||
] }
|
||||
|
||||
[[workspace.metadata.leptos]]
|
||||
name = "base_path_demo"
|
||||
name = "rustui_playground"
|
||||
bin-package = "server"
|
||||
lib-package = "app"
|
||||
site-root = "target/site"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Rust Browser Terminal Development Guide
|
||||
|
||||
本指南用于在 `base-path-demo` 中实现一套 Rust 终端能力:
|
||||
本指南用于在 `rustui-playground` 中实现一套 Rust 终端能力:
|
||||
|
||||
- 后端:Axum + `portable-pty` 创建真实伪终端,负责启动 shell、读写 PTY、处理 resize。
|
||||
- 前端:Rust/WASM + Leptos 实现浏览器终端组件,负责输入、渲染、滚屏、选区和终端状态。
|
||||
@@ -27,7 +27,7 @@ Shell process: bash/zsh/fish/vim/top
|
||||
建议分四层沉淀。
|
||||
|
||||
```text
|
||||
base-path-demo/
|
||||
rustui-playground/
|
||||
app/
|
||||
src/
|
||||
pages/terminal.rs
|
||||
@@ -63,7 +63,7 @@ crates/
|
||||
|
||||
当前 workspace 已有 `axum = "0.8"`,但 WebSocket 模块需要启用 `ws` feature。
|
||||
|
||||
建议先调整 `base-path-demo/Cargo.toml`:
|
||||
建议先调整 `rustui-playground/Cargo.toml`:
|
||||
|
||||
```toml
|
||||
[workspace.dependencies]
|
||||
@@ -501,7 +501,7 @@ MVP 安全策略:
|
||||
- 从消息类型抽出 `terminal-protocol`。
|
||||
- 从 server module 抽出 `terminal-pty-server`。
|
||||
|
||||
验收:`base-path-demo` 只依赖这些 crates,不再持有核心实现。
|
||||
验收:`rustui-playground` 只依赖这些 crates,不再持有核心实现。
|
||||
|
||||
### M5: 自研 parser/buffer
|
||||
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
|
||||
> 说明:本文档保留早期 scrollback 排查背景。当前终端实现已经进入 WebGL 默认渲染器、addon parity 与 P9 renderer 工程化阶段;最新状态请优先看 `TERMINAL_ROADMAP.md`。
|
||||
|
||||
本文档用于把 `base-path-demo` 当前浏览器终端实现的状态、已验证结论、未解决问题和下一步建议整理清楚,方便后续继续开发。
|
||||
本文档用于把 `rustui-playground` 当前浏览器终端实现的状态、已验证结论、未解决问题和下一步建议整理清楚,方便后续继续开发。
|
||||
|
||||
## 1. 当前目标
|
||||
|
||||
项目目标不是简单接一个现成 JS 终端,而是在 `base-path-demo` 中逐步沉淀一套 Rust 终端能力:
|
||||
项目目标不是简单接一个现成 JS 终端,而是在 `rustui-playground` 中逐步沉淀一套 Rust 终端能力:
|
||||
|
||||
- 后端:`Axum + portable-pty`
|
||||
- 前端:`Leptos + Rust/WASM`
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 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 面。
|
||||
>
|
||||
@@ -62,7 +62,7 @@
|
||||
- 实现于 `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+组合字符混排的列切片为近似。
|
||||
**已知后续项(非阻塞)**:拖拽到视窗边缘时**没有自动滚动延伸选区**(✅ 已通过上一轮 auto-scroll 实现);行内 wide+组合字符混排的列切片(✅ 已通过 grapheme_width 修正 text_in_cols / text_slice_cols)。
|
||||
|
||||
---
|
||||
|
||||
@@ -146,7 +146,7 @@
|
||||
- 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 背景由 Canvas backdrop 绘制后作为底层 texture 合成;cursor 前景文字仍进 atlas。
|
||||
- 背景、underline、cursor 背景在 WebGL 正常路径中直接绘制为纯色 quad;cursor 前景文字仍进 atlas。
|
||||
- atlas 溢出或遇到不可拆分复杂段时自动回退整屏 Canvas texture,避免真实终端内容白屏。
|
||||
- WebGL2 不可用时自动落回 Canvas 2D。
|
||||
2. **Canvas 2D 渲染器** ✅
|
||||
@@ -212,21 +212,41 @@
|
||||
- ✅ OSC 8 hyperlink 解析:支持 BEL / ST 终止,使用 cell-grid sidecar 记录链接,覆盖写入、常见 CSI 光标移动、清行/清屏、滚屏进 history 后仍能正确映射到链接 hitbox overlay。
|
||||
- 后续可继续扩展非常规控制序列覆盖面,但 P6 addon parity 的核心能力已完成。
|
||||
|
||||
### P7 — Accessibility / 可访问性
|
||||
### P7 — Accessibility / 可访问性 ✅ 已完成
|
||||
|
||||
**计划实现**:
|
||||
1. screen reader buffer / aria-live 策略。
|
||||
2. keyboard-only selection / copy。
|
||||
3. high contrast / minimum contrast ratio。
|
||||
4. reduced motion 与焦点可见性。
|
||||
**已实现**:
|
||||
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 / 字形精度
|
||||
### P8 — Unicode / 字形精度 ✅ 已完成
|
||||
|
||||
**计划实现**:
|
||||
1. emoji / ZWJ / variation selector 的 grapheme cluster 宽度策略。
|
||||
2. CJK ambiguous width 配置。
|
||||
3. ligature 可选支持。
|
||||
4. fallback font metrics 校准。
|
||||
**已实现**:
|
||||
1. **Emoji / ZWJ / variation selector 宽度策略** ✅
|
||||
- `grapheme_width()` 函数:ZWJ emoji 序列(如家族 emoji)检测在标准宽度计算之前,整体返回宽度 2。
|
||||
- `is_emoji_presentation()` 覆盖 Emoticons、Misc Symbols、Supplemental Symbols、Transport、Dingbats 等 Unicode 区块。
|
||||
- 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 工程化深化
|
||||
|
||||
@@ -251,6 +271,21 @@
|
||||
- `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。
|
||||
- 当前策略:默认 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() {
|
||||
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"
|
||||
};
|
||||
let renderer = format!("{:?}", event.renderer);
|
||||
let webgl_stats = event.webgl_stats;
|
||||
|
||||
let _ = js_sys::Reflect::set(
|
||||
&probe,
|
||||
@@ -152,6 +153,70 @@ fn update_terminal_bench_probe(event: TerminalRenderEvent) {
|
||||
&JsValue::from_str("screenRows"),
|
||||
&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"))]
|
||||
|
||||
@@ -5,9 +5,9 @@ use leptos_meta::MetaTags;
|
||||
use crate::app::App;
|
||||
|
||||
pub fn shell(options: LeptosOptions) -> impl IntoView {
|
||||
let css_href = SiteConfig::with_base_path("/pkg/base_path_demo.css");
|
||||
let js_href = SiteConfig::with_base_path("/pkg/base_path_demo.js");
|
||||
let wasm_href = SiteConfig::with_base_path("/pkg/base_path_demo.wasm");
|
||||
let css_href = SiteConfig::with_base_path("/pkg/rustui_playground.css");
|
||||
let js_href = SiteConfig::with_base_path("/pkg/rustui_playground.js");
|
||||
let wasm_href = SiteConfig::with_base_path("/pkg/rustui_playground.wasm");
|
||||
let hydration_script = format!(
|
||||
r#"import("{js_href}").then((mod) => {{
|
||||
mod.default({{ module_or_path: "{wasm_href}" }}).then(() => mod.hydrate());
|
||||
|
||||
@@ -28,7 +28,7 @@ use super::mouse::{
|
||||
use super::protocol::{ClientTerminalMessage, ServerTerminalMessage};
|
||||
use super::renderer::{
|
||||
CanvasDomState, CanvasPlanInput, CanvasSurfaceFrame, CanvasSurfaceMode,
|
||||
RenderCanvasSurfaceRequest, WebGlContextLifecycle, WebGlRendererState,
|
||||
RenderCanvasSurfaceRequest, WebGlContextLifecycle, WebGlRenderStats, WebGlRendererState,
|
||||
attach_webgl_context_lifecycle, canvas_render_plan, render_canvas_surface, render_webgl,
|
||||
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_ROWS: u16 = 12;
|
||||
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
|
||||
/// scrolling doesn't flash blank areas before the next frame arrives.
|
||||
@@ -141,6 +141,8 @@ pub struct TerminalFontOptions {
|
||||
pub family: String,
|
||||
pub size_px: f64,
|
||||
pub line_height_px: f64,
|
||||
pub ligatures: bool,
|
||||
pub cjk_ambiguous_wide: bool,
|
||||
}
|
||||
|
||||
impl Default for TerminalFontOptions {
|
||||
@@ -149,6 +151,8 @@ impl Default for TerminalFontOptions {
|
||||
family: DEFAULT_TERMINAL_FONT_FAMILY.to_owned(),
|
||||
size_px: DEFAULT_FONT_SIZE_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)>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct TerminalRenderEvent {
|
||||
pub cols: u16,
|
||||
pub rows: u16,
|
||||
@@ -243,6 +247,7 @@ pub struct TerminalRenderEvent {
|
||||
pub screen_rows: usize,
|
||||
pub mode: ViewMode,
|
||||
pub renderer: Renderer,
|
||||
pub webgl_stats: Option<WebGlRenderStats>,
|
||||
}
|
||||
|
||||
type SerializeFn = Arc<dyn Fn() -> String + Send + Sync>;
|
||||
@@ -365,6 +370,8 @@ pub(super) struct ResolvedTerminalOptions {
|
||||
pub(super) font_family: String,
|
||||
pub(super) font_size_px: f64,
|
||||
line_height_px: f64,
|
||||
pub(super) ligatures: bool,
|
||||
pub(super) cjk_ambiguous_wide: bool,
|
||||
pub(super) theme: TerminalTheme,
|
||||
pub(super) cursor: TerminalCursorOptions,
|
||||
title: String,
|
||||
@@ -390,6 +397,8 @@ impl From<TerminalOptions> for ResolvedTerminalOptions {
|
||||
},
|
||||
font_size_px: options.font.size_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,
|
||||
cursor: options.cursor,
|
||||
title: options.title,
|
||||
@@ -401,7 +410,7 @@ impl From<TerminalOptions> for ResolvedTerminalOptions {
|
||||
|
||||
fn terminal_options_style(options: &ResolvedTerminalOptions) -> String {
|
||||
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_size_px,
|
||||
options.line_height_px,
|
||||
@@ -416,6 +425,7 @@ fn terminal_options_style(options: &ResolvedTerminalOptions) -> String {
|
||||
} else {
|
||||
"none"
|
||||
},
|
||||
if options.ligatures { "normal" } else { "none" },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -514,6 +524,7 @@ pub fn TerminalPanel(
|
||||
let selecting = RwSignal::new(false);
|
||||
let search_match = RwSignal::new(None::<TerminalSearchMatch>);
|
||||
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
|
||||
// the final composed string is sent on `compositionend`.
|
||||
@@ -533,6 +544,12 @@ pub fn TerminalPanel(
|
||||
let webgl_state: Rc<std::cell::RefCell<Option<WebGlRendererState>>> =
|
||||
Rc::new(std::cell::RefCell::new(None));
|
||||
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 || {
|
||||
core_signal
|
||||
@@ -624,6 +641,7 @@ pub fn TerminalPanel(
|
||||
screen_rows: core.screen_rows().len(),
|
||||
mode,
|
||||
renderer,
|
||||
webgl_stats: last_webgl_stats.get_untracked(),
|
||||
}) {
|
||||
on_render.run(event);
|
||||
}
|
||||
@@ -640,12 +658,8 @@ pub fn TerminalPanel(
|
||||
lifecycle_webgl_state.borrow_mut().take();
|
||||
return None;
|
||||
}
|
||||
let Some(canvas) = canvas_ref.get() else {
|
||||
return None;
|
||||
};
|
||||
let Some(canvas_el) = canvas.dyn_ref::<HtmlCanvasElement>() else {
|
||||
return None;
|
||||
};
|
||||
let canvas = canvas_ref.get()?;
|
||||
let canvas_el = canvas.dyn_ref::<HtmlCanvasElement>()?;
|
||||
|
||||
attach_webgl_context_lifecycle(
|
||||
canvas_el,
|
||||
@@ -921,15 +935,14 @@ pub fn TerminalPanel(
|
||||
};
|
||||
match renderer {
|
||||
Renderer::WebGl => {
|
||||
if render_webgl(
|
||||
if let Ok(stats) = render_webgl(
|
||||
canvas_el,
|
||||
frame,
|
||||
&canvas_state,
|
||||
&webgl_state,
|
||||
&webgl_context_lost,
|
||||
)
|
||||
.is_ok()
|
||||
{
|
||||
) {
|
||||
last_webgl_stats.set(Some(stats));
|
||||
set_canvas_surface_mode(
|
||||
canvas_el,
|
||||
fallback_canvas_el,
|
||||
@@ -941,6 +954,7 @@ pub fn TerminalPanel(
|
||||
fallback_canvas_el,
|
||||
webgl_surface_mode(false),
|
||||
);
|
||||
last_webgl_stats.set(None);
|
||||
render_canvas_surface(RenderCanvasSurfaceRequest {
|
||||
canvas_el: fallback_canvas_el,
|
||||
frame,
|
||||
@@ -949,6 +963,7 @@ pub fn TerminalPanel(
|
||||
}
|
||||
}
|
||||
Renderer::Canvas => {
|
||||
last_webgl_stats.set(None);
|
||||
set_canvas_surface_mode(canvas_el, fallback_canvas_el, CanvasSurfaceMode::WebGl);
|
||||
render_canvas_surface(RenderCanvasSurfaceRequest {
|
||||
canvas_el,
|
||||
@@ -956,7 +971,9 @@ pub fn TerminalPanel(
|
||||
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>>| {
|
||||
drop(prev);
|
||||
|
||||
let Some(element) = terminal_ref.get() else {
|
||||
return None;
|
||||
};
|
||||
let Some(socket) = ws_signal.get() else {
|
||||
return None;
|
||||
};
|
||||
let element = terminal_ref.get()?;
|
||||
let socket = ws_signal.get()?;
|
||||
|
||||
let resize_socket = socket.clone();
|
||||
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
|
||||
// buffer coordinates (not DOM), so it survives re-renders and works across
|
||||
// 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 element = terminal_ref.get_untracked()?;
|
||||
let rect = element.get_bounding_client_rect();
|
||||
@@ -1707,6 +1727,7 @@ pub fn TerminalPanel(
|
||||
}
|
||||
};
|
||||
|
||||
let autoscroll_move = autoscroll_handle.clone();
|
||||
let handle_mousemove = {
|
||||
let move_selection = selection;
|
||||
let move_selecting = selecting;
|
||||
@@ -1714,6 +1735,7 @@ pub fn TerminalPanel(
|
||||
let move_held = mouse_held_button.clone();
|
||||
let move_last = mouse_last_cell.clone();
|
||||
let move_ws = ws_signal;
|
||||
let autoscroll_handle = autoscroll_move;
|
||||
move |event: MouseEvent| {
|
||||
// App mouse motion reporting.
|
||||
if !event.shift_key() {
|
||||
@@ -1770,15 +1792,53 @@ pub fn TerminalPanel(
|
||||
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 up_selecting = selecting;
|
||||
let up_core = core_signal;
|
||||
let up_held = mouse_held_button.clone();
|
||||
let up_last = mouse_last_cell.clone();
|
||||
let up_ws = ws_signal;
|
||||
let autoscroll_handle = autoscroll_up;
|
||||
move |event: MouseEvent| {
|
||||
if !event.shift_key() {
|
||||
let mode_enc = up_core
|
||||
@@ -1818,6 +1878,7 @@ pub fn TerminalPanel(
|
||||
|
||||
// Local selection end.
|
||||
up_selecting.set(false);
|
||||
stop_autoscroll(&autoscroll_handle);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1825,10 +1886,93 @@ pub fn TerminalPanel(
|
||||
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! {
|
||||
<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>
|
||||
<div class="terminal-header">
|
||||
<div>
|
||||
@@ -1869,6 +2013,9 @@ pub fn TerminalPanel(
|
||||
<div
|
||||
node_ref=terminal_ref
|
||||
class="terminal-screen"
|
||||
role="application"
|
||||
aria-label="Terminal"
|
||||
aria-roledescription="terminal emulator"
|
||||
on:click=handle_click
|
||||
on:scroll=handle_scroll
|
||||
on:wheel=handle_wheel
|
||||
@@ -1893,6 +2040,15 @@ pub fn TerminalPanel(
|
||||
<span node_ref=measure_ref class="terminal-measure" aria-hidden="true">
|
||||
{MEASURE_SAMPLE_TEXT}
|
||||
</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">
|
||||
<Show
|
||||
when=move || renderer_uses_canvas_surface(options_signal.get().renderer)
|
||||
@@ -2339,6 +2495,92 @@ fn sync_scroll_to_position(
|
||||
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)]
|
||||
struct SearchScrollContext {
|
||||
total_rows: usize,
|
||||
@@ -2856,6 +3098,8 @@ mod tests {
|
||||
family: String::new(),
|
||||
size_px: 0.0,
|
||||
line_height_px: 0.0,
|
||||
ligatures: false,
|
||||
cjk_ambiguous_wide: true,
|
||||
},
|
||||
..TerminalOptions::default()
|
||||
};
|
||||
|
||||
@@ -3,6 +3,280 @@ use vt100::{Color, Parser, Screen};
|
||||
|
||||
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_COLS: u16 = 80;
|
||||
|
||||
@@ -547,15 +821,22 @@ impl TerminalRow {
|
||||
if col >= to {
|
||||
break;
|
||||
}
|
||||
// This segment overlaps [from, to): emit the columns inside range.
|
||||
let chars: Vec<char> = seg.text.chars().collect();
|
||||
for (i, ch) in chars.iter().enumerate() {
|
||||
// Distribute chars across the segment's columns 1:1 (clamped).
|
||||
let ch_col = col + i.min(seg.cols.saturating_sub(1));
|
||||
if ch_col >= from && ch_col < to {
|
||||
out.push(*ch);
|
||||
// Walk grapheme clusters and track actual grid-column width so
|
||||
// that wide chars (2 cols) and combining marks (0 cols) are
|
||||
// sliced correctly.
|
||||
use unicode_segmentation::UnicodeSegmentation;
|
||||
for grapheme in UnicodeSegmentation::graphemes(seg.text.as_str(), true) {
|
||||
let w = grapheme_width(grapheme, false);
|
||||
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;
|
||||
}
|
||||
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 {
|
||||
let start = byte_index_for_char_col(text, start_col);
|
||||
let end = byte_index_for_char_col(text, end_col);
|
||||
text.get(start..end).unwrap_or_default().to_owned()
|
||||
use unicode_segmentation::UnicodeSegmentation;
|
||||
let mut out = String::new();
|
||||
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 {
|
||||
@@ -1723,4 +2016,51 @@ mod tests {
|
||||
"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,
|
||||
};
|
||||
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(
|
||||
...(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 canvas = document.querySelector("canvas.terminal-canvas:not([hidden])");
|
||||
if (!canvas) {
|
||||
@@ -279,7 +282,8 @@ async function runScenario(browser, options, viewport) {
|
||||
elapsedMs,
|
||||
rowsPerSecond: round(options.lines / (elapsedMs / 1000)),
|
||||
frameMs: summarizeSamples(frameSamples),
|
||||
probe,
|
||||
rendererStats: summarizeRendererStats(rendererStats),
|
||||
probe: cleanProbe,
|
||||
pixelSmoke: pixelResult,
|
||||
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) {
|
||||
const url = new URL(options.url);
|
||||
url.searchParams.set("renderer", options.renderer);
|
||||
|
||||
@@ -638,6 +638,7 @@
|
||||
);
|
||||
font-size: var(--terminal-font-size, 13px);
|
||||
line-height: var(--terminal-line-height, 18px);
|
||||
font-variant-ligatures: var(--terminal-font-ligatures, none);
|
||||
white-space: pre;
|
||||
letter-spacing: 0;
|
||||
scrollbar-gutter: stable;
|
||||
@@ -689,6 +690,7 @@
|
||||
);
|
||||
font-size: var(--terminal-font-size, 13px);
|
||||
line-height: var(--terminal-line-height, 18px);
|
||||
font-variant-ligatures: var(--terminal-font-ligatures, none);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
@@ -804,6 +806,20 @@
|
||||
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 {
|
||||
position: absolute;
|
||||
left: -9999px;
|
||||
@@ -844,6 +860,36 @@
|
||||
color: var(--cowa-text-primary);
|
||||
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 {
|
||||
@apply m-0 pt-1 text-sm;
|
||||
|
||||
Reference in New Issue
Block a user