Compare commits

...

10 Commits

Author SHA1 Message Date
zhangheng
561a062cf2 fix(terminal): change repo name 2026-06-25 14:31:42 +08:00
zhangheng
b7a8bf49c4 fix(terminal): use grapheme_width for accurate column slicing
text_in_cols and text_slice_cols now walk grapheme clusters with
grapheme_width() instead of assuming 1 char = 1 column.  This fixes
column slicing for:
- Wide characters (CJK, emoji) that occupy 2 grid columns
- Combining marks that occupy 0 columns

The prior 1:1 char∶col mapping caused selection-copy and serialize
output to misalign column ranges when wide/combining characters were
present in a row.

- Update roadmap: mark both P0 follow-up items as resolved
2026-06-25 12:37:11 +08:00
zhangheng
8d73d6eabc feat(terminal): auto-scroll viewport during selection drag near edges
When the user drags a text selection and the cursor reaches within 40px
of the top or bottom edge of the terminal, the viewport auto-scrolls at
a speed proportional to the distance from the edge (up to 8 px/frame).
The selection head extends as the view scrolls, enabling cross-page
selection without manually scrolling first.

- Add start_autoscroll / stop_autoscroll helpers using setInterval (~60fps)
- Detect edge proximity in handle_mousemove after updating selection head
- Stop auto-scroll on mouseup or when cursor leaves edge zone
- Switch view_mode to History/Live based on scroll position
2026-06-25 12:10:05 +08:00
zhangheng
3603321dea feat(terminal): add Unicode precision — CJK ambiguous width, emoji ZWJ, ligatures
- Add grapheme_width() with emoji ZWJ sequence detection (width 2)
  and comprehensive is_cjk_ambiguous() per UAX #11
- Add TerminalFontOptions::{ligatures, cjk_ambiguous_wide} options
- Add is_emoji_presentation() covering major emoji Unicode blocks
- Update default font stack with CJK and emoji fallback families
- Add --terminal-font-ligatures CSS variable via font-variant-ligatures
- Add 8 unit tests for grapheme_width covering ASCII, CJK, emoji,
  ZWJ sequences, combining chars, and variation selectors
- Mark P8 complete in roadmap
2026-06-25 11:37:17 +08:00
zhangheng
5bca11083f feat(terminal): add screen reader buffer and OS accessibility preferences
- Add debounced aria-live region that announces visible terminal text
  to screen readers (500ms throttle, non-empty trimmed rows only)
- Add role=application, aria-label, aria-roledescription to terminal
- Add prefers-reduced-motion listener: disables cursor blink animation
  and sets --terminal-reduce-motion CSS variable
- Add prefers-contrast: more media query with Canvas/CanvasText system
  color fallback
- Add visually hidden .terminal-screen-reader CSS utility
- Mark P7 complete in roadmap
2026-06-25 11:02:47 +08:00
zhangheng
057d74d44c perf(terminal): cache webgl row vertex templates 2026-06-24 21:18:32 +08:00
zhangheng
fb54acf88b perf(terminal): cache webgl row glyph collection 2026-06-24 20:16:58 +08:00
zhangheng
a1d5752d59 perf(terminal): draw webgl backdrops with quads 2026-06-24 18:46:44 +08:00
zhangheng
fb1a6d5fe7 fix(terminal): retain webgl profiling samples 2026-06-24 18:17:37 +08:00
zhangheng
a039f7fd5f test(terminal): profile webgl render stages 2026-06-24 17:46:54 +08:00
13 changed files with 1604 additions and 154 deletions

View File

@@ -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"

View File

@@ -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

View File

@@ -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`

View File

@@ -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 正常路径中直接绘制为纯色 quadcursor 前景文字仍进 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 序列返回 1combining marks 返回 1variation selectors 被吸收不额外占列
- 8 个单元测试覆盖 ASCII、CJK、emoji、ZWJ、combining、variation selector。
2. **CJK ambiguous width 配置**
- `TerminalFontOptions::cjk_ambiguous_wide` 选项(默认 `true`,遵循 CJK 终端惯例)。
- `is_cjk_ambiguous()` 按 UAX #11 完整列举 Ambiguous East Asian Width 字符。
- 开启时 ○、△ 等符号按宽度 2 渲染;关闭时按宽度 1。
3. **Ligature 可选支持**
- `TerminalFontOptions::ligatures` 选项(默认 `false`,终端通常不需要连字)。
- `--terminal-font-ligatures` CSS 变量联动 `font-variant-ligatures`
4. **Fallback font 校准**
- 默认字体栈新增 Noto Sans CJK SC、Microsoft YaHei、PingFang SCCJK和 Apple Color Emoji、Segoe UI Emoji、Noto Color Emojiemoji
### P9 — WebGL Renderer 工程化深化
@@ -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。
- 当前策略:默认 CanvasWebGL 继续通过页面切换、`?renderer=webgl``--renderer webgl` 保留优化回归入口。
5. **WebGL renderer profiling / 慢路径拆解**
- WebGL render path 记录阶段耗时DOM/canvas 同步、backdrop 绘制、glyph collect、backdrop texture pass、glyph atlas、atlas upload、vertex build/upload、draw call。
- 记录计数cells、atlas entries、atlas insertions/resets、full-canvas fallback 次数、glyph draw failed 次数。
- `window.__terminalBench.webglFrames` 暴露每帧采样,`scripts/terminal-bench.mjs` 汇总为 `rendererStats`,用于判断慢点是整屏 backdrop texture、atlas、vertex buffer还是 fallback。
6. **WebGL backdrop texture pass 拆除**
- 正常 WebGL 路径不再每帧把 Canvas backdrop 上传为整屏 texture背景、underline、cursor 背景改为 1×1 白色纹理 + 纯色 quad 批量绘制。
- `backdropTextureMs` 只在复杂 glyph / atlas 失败等 full-canvas fallback 路径产生,用于继续观察 fallback 是否被真实内容触发。
- 页面切换、`?renderer=webgl``--renderer webgl` 保留,下一轮基准可直接对比这次优化后 WebGL 是否追近 Canvas。
7. **WebGL glyph collect cache**
- WebGL renderer state 增加可见行 glyph 拆分缓存,相同行内容 / 样式 / theme 在滚动和连续输出时复用 grapheme 拆分结果。
- `GlyphKey.text` 改为 `Rc<str>`,缓存命中后 materialize 行 glyph cell 时避免反复复制 glyph 字符串。
- benchmark `rendererStats` 新增 `rowCacheHits` / `rowCacheMisses`,用于判断 `collectMs` 是否被 cache 命中率拉低。
8. **WebGL row vertex template cache**
- atlas 覆盖后按行缓存 glyph vertex template后续帧只按当前 row y 偏移 materialize减少每帧 atlas lookup / UV 计算 / quad 构建。
- benchmark `rendererStats` 新增 `vertexCacheHits` / `vertexCacheMisses`,用于判断 `vertexBuildMs` 是否主要来自 cache miss。
---

View File

@@ -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");
}

View File

@@ -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"))]

View File

@@ -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());

View File

@@ -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()
};

View File

@@ -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);
}
}

View File

@@ -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

View File

@@ -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);

View File

@@ -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;