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! { + + } +} + +#[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);