diff --git a/README.md b/README.md
index ba00169..6e98a83 100644
--- a/README.md
+++ b/README.md
@@ -38,5 +38,11 @@ instead:
npm run terminal:bench -- --url http://127.0.0.1:3100/rustui/terminal --browser-executable /usr/bin/google-chrome
```
+Compare renderer modes with:
+
+```bash
+npm run terminal:bench -- --url http://127.0.0.1:3100/rustui/terminal --renderer canvas --browser-executable /usr/bin/google-chrome
+```
+
It writes `target/terminal-bench/terminal-bench.json` plus screenshots when
Playwright is available in the workspace.
diff --git a/TERMINAL_ROADMAP.md b/TERMINAL_ROADMAP.md
index e21f8c0..273791b 100644
--- a/TERMINAL_ROADMAP.md
+++ b/TERMINAL_ROADMAP.md
@@ -241,7 +241,10 @@
- 固定覆盖 `200x80`、`240x100` viewport,支持 `--lines`、`--viewport`、`--output` 参数。
- 记录行输出耗时、rows/sec、requestAnimationFrame frame time 摘要、`window.__terminalBench` render probe。
- 截图 smoke 默认走 Playwright screenshot PNG 非空/熵检查;Canvas 2D fallback 可读时额外做像素采样。
-3. Canvas 2D / WebGL runtime switch。
+3. **Canvas 2D / WebGL runtime switch** ✅
+ - `/rustui/terminal` 页面提供 WebGL / Canvas / DOM runtime 切换。
+ - 支持 `?renderer=webgl|canvas|dom` 初始化,便于浏览器回归和 benchmark 固定模式。
+ - `scripts/terminal-bench.mjs --renderer webgl|canvas|dom` 复用同一入口跑对比,并将截图按 renderer 命名。
4. 与 Canvas 2D 做 benchmark 对比,按 viewport size / output rate 自动选择 renderer。
---
diff --git a/app/src/pages/terminal.rs b/app/src/pages/terminal.rs
index 0e69b69..a1cba15 100644
--- a/app/src/pages/terminal.rs
+++ b/app/src/pages/terminal.rs
@@ -1,22 +1,91 @@
use leptos::prelude::*;
use crate::components::layout::{AdminShell, Breadcrumb};
-use crate::terminal::{TerminalPanel, TerminalRenderEvent};
+use crate::terminal::{Renderer, TerminalOptions, TerminalPanel, TerminalRenderEvent};
#[component]
pub fn TerminalPage() -> impl IntoView {
+ let renderer = RwSignal::new(initial_terminal_renderer());
+ let terminal_options = Signal::derive(move || TerminalOptions {
+ renderer: renderer.get(),
+ ..TerminalOptions::default()
+ });
let on_render = Callback::new(update_terminal_bench_probe);
view! {
}
}
+#[component]
+fn TerminalRendererSwitch(renderer: RwSignal) -> impl IntoView {
+ view! {
+
+ "Renderer"
+
+
+
+
+ }
+}
+
+#[component]
+fn RendererButton(
+ renderer: RwSignal,
+ value: Renderer,
+ label: &'static str,
+) -> impl IntoView {
+ view! {
+
+ }
+}
+
+#[cfg(target_arch = "wasm32")]
+fn initial_terminal_renderer() -> Renderer {
+ web_sys::window()
+ .and_then(|window| window.location().search().ok())
+ .and_then(|query| renderer_from_query(&query))
+ .unwrap_or_default()
+}
+
+#[cfg(not(target_arch = "wasm32"))]
+fn initial_terminal_renderer() -> Renderer {
+ Renderer::default()
+}
+
+#[cfg(any(target_arch = "wasm32", test))]
+fn renderer_from_query(query: &str) -> Option {
+ query
+ .trim_start_matches('?')
+ .split('&')
+ .filter_map(|part| part.split_once('='))
+ .find_map(|(key, value)| (key == "renderer").then(|| parse_renderer(value)).flatten())
+}
+
+#[cfg(any(target_arch = "wasm32", test))]
+fn parse_renderer(value: &str) -> Option {
+ match value.to_ascii_lowercase().as_str() {
+ "webgl" | "web-gl" => Some(Renderer::WebGl),
+ "canvas" | "canvas2d" | "canvas-2d" => Some(Renderer::Canvas),
+ "dom" => Some(Renderer::Dom),
+ _ => None,
+ }
+}
+
#[cfg(target_arch = "wasm32")]
fn update_terminal_bench_probe(event: TerminalRenderEvent) {
use wasm_bindgen::JsValue;
@@ -87,3 +156,28 @@ fn update_terminal_bench_probe(event: TerminalRenderEvent) {
#[cfg(not(target_arch = "wasm32"))]
fn update_terminal_bench_probe(_event: TerminalRenderEvent) {}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn renderer_query_parses_supported_values() {
+ assert_eq!(
+ renderer_from_query("?renderer=webgl"),
+ Some(Renderer::WebGl)
+ );
+ assert_eq!(
+ renderer_from_query("?foo=1&renderer=canvas2d"),
+ Some(Renderer::Canvas)
+ );
+ assert_eq!(renderer_from_query("?renderer=dom"), Some(Renderer::Dom));
+ }
+
+ #[test]
+ fn renderer_query_ignores_unknown_values() {
+ assert_eq!(renderer_from_query("?renderer=auto"), None);
+ assert_eq!(renderer_from_query("?other=webgl"), None);
+ assert_eq!(renderer_from_query(""), None);
+ }
+}
diff --git a/scripts/terminal-bench.mjs b/scripts/terminal-bench.mjs
index 1604fdb..e35264a 100644
--- a/scripts/terminal-bench.mjs
+++ b/scripts/terminal-bench.mjs
@@ -24,6 +24,7 @@ export function parseArgs(argv) {
screenshot: true,
browserChannel: process.env.PLAYWRIGHT_BROWSER_CHANNEL || "",
browserExecutable: process.env.PLAYWRIGHT_BROWSER_EXECUTABLE || "",
+ renderer: "webgl",
};
for (let index = 0; index < argv.length; index += 1) {
@@ -50,6 +51,8 @@ export function parseArgs(argv) {
options.browserChannel = next();
} else if (arg === "--browser-executable") {
options.browserExecutable = next();
+ } else if (arg === "--renderer") {
+ options.renderer = parseRenderer(next());
} else if (arg === "--no-screenshot") {
options.screenshot = false;
} else if (arg === "--help" || arg === "-h") {
@@ -173,11 +176,18 @@ async function runScenario(browser, options, viewport) {
const page = await context.newPage();
const frameSamples = [];
try {
- await page.goto(options.url, { waitUntil: "networkidle", timeout: options.timeoutMs });
- await page.waitForSelector(".terminal-screen", { timeout: options.timeoutMs });
- await page.waitForSelector("canvas.terminal-canvas:not([hidden])", {
+ await page.goto(benchUrl(options), {
+ waitUntil: "networkidle",
timeout: options.timeoutMs,
});
+ await page.waitForSelector(".terminal-screen", { timeout: options.timeoutMs });
+ if (options.renderer === "dom") {
+ await page.waitForSelector(".terminal-row", { timeout: options.timeoutMs });
+ } else {
+ await page.waitForSelector("canvas.terminal-canvas:not([hidden])", {
+ timeout: options.timeoutMs,
+ });
+ }
await page.evaluate(() => {
window.__terminalBenchFrames = [];
@@ -236,7 +246,10 @@ async function runScenario(browser, options, viewport) {
const screenshot = await page.screenshot({ fullPage: false });
const screenshotSmoke = pngSmokeFromBuffer(screenshot);
if (options.screenshot) {
- screenshotPath = path.join(options.outputDir, `terminal-${viewport.name}.png`);
+ screenshotPath = path.join(
+ options.outputDir,
+ `terminal-${options.renderer}-${viewport.name}.png`,
+ );
fs.writeFileSync(screenshotPath, screenshot);
}
@@ -256,6 +269,7 @@ async function runScenario(browser, options, viewport) {
return {
viewport,
+ requestedRenderer: options.renderer,
lines: options.lines,
elapsedMs,
rowsPerSecond: round(options.lines / (elapsedMs / 1000)),
@@ -269,6 +283,12 @@ async function runScenario(browser, options, viewport) {
}
}
+function benchUrl(options) {
+ const url = new URL(options.url);
+ url.searchParams.set("renderer", options.renderer);
+ return url.toString();
+}
+
function browserLaunchOptions(options) {
if (options.browserChannel) {
return { channel: options.browserChannel };
@@ -393,6 +413,14 @@ function positiveInt(value, label) {
return parsed;
}
+function parseRenderer(value) {
+ const normalized = value.toLowerCase();
+ if (["webgl", "canvas", "dom"].includes(normalized)) {
+ return normalized;
+ }
+ throw new Error(`--renderer must be one of: webgl, canvas, dom`);
+}
+
function round(value) {
return Math.round(value * 100) / 100;
}
@@ -409,6 +437,7 @@ Options:
--lines Default: ${DEFAULT_LINES}
--viewport x Run one scenario with inferred browser size
--viewport x:x Run one scenario with explicit browser size
+ --renderer webgl, canvas, or dom. Default: webgl
--browser-channel Use an installed browser channel, e.g. chrome
--browser-executable Use a local Chromium/Chrome executable path
--timeout-ms Default: 120000
diff --git a/style/tailwind.css b/style/tailwind.css
index 9bcbfb9..b8e791d 100644
--- a/style/tailwind.css
+++ b/style/tailwind.css
@@ -538,6 +538,38 @@
padding-bottom: 4px;
}
+ .terminal-page-toolbar {
+ @apply mb-3 flex shrink-0 items-center gap-1;
+ min-height: 32px;
+ }
+
+ .terminal-renderer-label {
+ color: var(--cowa-text-tertiary);
+ font-size: 12px;
+ line-height: 18px;
+ margin-right: 4px;
+ }
+
+ .terminal-renderer-option {
+ @apply h-8 rounded border bg-white px-3;
+ border-color: var(--cowa-border);
+ color: var(--cowa-text-secondary);
+ font-size: 12px;
+ line-height: 18px;
+ }
+
+ .terminal-renderer-option:hover {
+ border-color: var(--cowa-primary);
+ color: var(--cowa-primary);
+ }
+
+ .terminal-renderer-option.is-active {
+ border-color: var(--cowa-primary);
+ background: var(--cowa-primary-soft);
+ color: var(--cowa-primary);
+ font-weight: 600;
+ }
+
.terminal-shell-card {
@apply flex flex-1 flex-col gap-4 rounded border bg-white;
border-color: var(--cowa-border);