Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ CanvasTTY is a spatial Electron desktop for real local terminals and AI-agent CL
| [Create a pixel theme with an agent](pixel-skin-agent-start.md) | Zero-context agent brief, exact install ZIP, visual checks, and validation |
| [Runtime plugins](plugins.md) | Manifest v1, permissions, HOME widgets, canvas apps, separate windows, player media/playlist APIs, SDK, and install flow |
| [Metrics and telemetry](metrics-and-telemetry.md) | Subscription limits, session token usage, source priority, privacy, stale states, and tests |
| [Terminal renderer transition check](terminal-renderer-transition.md) | Manual hide/restore check for the DOM/WebGL transition snapshot |
| [Security policy](../SECURITY.md) | Supported release, vulnerability reporting, local data boundaries, plugins, media grants, browser storage, and audit logs |
| [Changelog](../CHANGELOG.md) | User-visible fixes and features by release |

Expand Down
11 changes: 11 additions & 0 deletions docs/terminal-renderer-transition.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Terminal renderer transition check

The terminal card keeps a short DOM snapshot while xterm switches between its DOM and WebGL renderers. Use this manual check when changing renderer lifecycle code.

1. Start CanvasTTY with a clean or disposable profile and open a terminal card.
2. Produce continuous output in the card, for example `yes renderer-check` followed by `Ctrl-C`.
3. Zoom or resize the canvas across the WebGL eligibility boundary several times. The transition should reveal the new renderer with a short fade and should not leave a stale frame over the live terminal.
4. Start another burst of output, hide the CanvasTTY window or switch applications before the fade completes, then restore the window. The snapshot must be gone and the newest terminal output must be visible immediately; no old frame may cover it.
5. Repeat the hide/restore step while the card is switching renderer and while several terminal cards are visible. Check that other cards keep rendering and that selection and pinned input still work.

The automated coverage is in `tests/terminal-renderer-transition.test.mjs`. It exercises the normal render-to-paint path, the cleanup deadline when rendering or animation frames stop, document visibility cleanup, teardown in each transition phase, and reduced-motion behavior. The manual steps above remain necessary for the compositor-visible hide/restore result.
109 changes: 108 additions & 1 deletion src/renderer/src/features/terminal/TerminalCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,100 @@ function TerminalCardView({
const searchAddonRef = useRef<SearchAddon | null>(null);
const webglAddonRef = useRef<WebglAddon | null>(null);
const fitRef = useRef<(() => void) | null>(null);
const rendererTransitionRef = useRef<{
snapshot: HTMLElement;
frame: number | null;
render: { dispose(): void } | null;
fallback: number | null;
animation: Animation | null;
visibilityCleanup: (() => void) | null;
} | null>(null);
const restoreRendererTransition = (): void => {
const transition = rendererTransitionRef.current;
if (!transition) return;
rendererTransitionRef.current = null;
if (transition.frame !== null) cancelAnimationFrame(transition.frame);
if (transition.fallback !== null) window.clearTimeout(transition.fallback);
transition.visibilityCleanup?.();
transition.render?.dispose();
transition.animation?.cancel();
transition.snapshot.remove();
};
const beginRendererTransition = (terminal: Terminal): HTMLElement | null => {
restoreRendererTransition();
const element = terminal.element;
const screen = element?.querySelector<HTMLElement>(".xterm-screen");
if (!element || !screen || !screen.checkVisibility({ visibilityProperty: true })) return null;
const webglCanvas = element.dataset.renderer === "webgl"
? screen.querySelector<HTMLCanvasElement>("canvas:not([class])") : null;
if (webglCanvas?.getContext("webgl2")?.isContextLost()) return null;
const snapshot = screen.cloneNode(true) as HTMLElement;
// DOM renderer disposal removes its owner class; keep the cloned row styles scoped to the snapshot.
for (const className of element.classList) {
if (className.startsWith("xterm-dom-renderer-owner-") || className === "focus") snapshot.classList.add(className);
}
snapshot.setAttribute("data-renderer-transition", "true");
snapshot.setAttribute("aria-hidden", "true");
snapshot.inert = true;
Object.assign(snapshot.style, {
position: "absolute", left: `${screen.offsetLeft}px`, top: `${screen.offsetTop}px`,
pointerEvents: "none", zIndex: "4", overflow: "hidden",
backgroundColor: "var(--terminal-background, #202430)"
});
// cloneNode copies DOM rows, but canvas pixels must be copied before disposing WebGL.
const canvases = snapshot.querySelectorAll("canvas");
try {
screen.querySelectorAll("canvas").forEach((canvas, index) => {
const context = canvases[index].getContext("2d");
if (!context) throw new Error("Renderer snapshot unavailable");
context.drawImage(canvas, 0, 0);
});
} catch {
return null;
}
element.append(snapshot);
const onVisibilityChange = (): void => {
if (document.hidden) restoreRendererTransition();
};
document.addEventListener("visibilitychange", onVisibilityChange);
rendererTransitionRef.current = {
snapshot,
frame: null,
render: null,
fallback: window.setTimeout(restoreRendererTransition, 250),
animation: null,
visibilityCleanup: () => document.removeEventListener("visibilitychange", onVisibilityChange)
};
return snapshot;
};
const revealRendererTransition = (terminal: Terminal, snapshot: HTMLElement): void => {
const transition = rendererTransitionRef.current;
if (!transition || transition.snapshot !== snapshot) return;
const fadeAfterPaint = (): void => {
if (rendererTransitionRef.current !== transition) return;
transition.frame = null;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
restoreRendererTransition();
return;
}
transition.animation = snapshot.animate([{ opacity: 1 }, { opacity: 0 }], {
duration: 180, easing: "ease-out", fill: "forwards"
});
transition.animation.onfinish = () => {
if (rendererTransitionRef.current === transition) restoreRendererTransition();
};
};
const ready = (): void => {
if (rendererTransitionRef.current !== transition || transition.frame !== null) return;
transition.render?.dispose();
transition.render = null;
transition.frame = requestAnimationFrame(() => {
transition.frame = requestAnimationFrame(fadeAfterPaint);
});
};
transition.render = terminal.onRender(ready);
terminal.refresh(0, terminal.rows - 1);
};
const lifecycle = surfaceLifecycle({ summary: summaryMode, hidden, focused });
const lifecycleRef = useRef(lifecycle);
lifecycleRef.current = lifecycle;
Expand Down Expand Up @@ -582,6 +676,7 @@ function TerminalCardView({
return () => {
// No refit on the way out: the card is going away, its PTY size must not change.
fitRef.current = null;
restoreRendererTransition();
unregisterWebgl();
cancelAnimationFrame(frame);
detachMouseCoordinateAdapter();
Expand Down Expand Up @@ -616,11 +711,15 @@ function TerminalCardView({

const enableWebgl = (terminal: Terminal): boolean => {
if (webglAddonRef.current) return true;
const transitionElement = beginRendererTransition(terminal);
// WebglAddon takes no transparency argument in 0.19.0: it reads the stored
// terminal options, and this terminal is constructed with allowTransparency,
// so cell backgrounds stay transparent and the card's palette background
// keeps showing through the canvas exactly as it does in the DOM renderer.
const webgl = new WebglAddon();
// Preserve the last painted frame for the short snapshot when switching back to DOM. A one-shot
// readback after compositing is not reliable with preserveDrawingBuffer disabled; the bounded WebGL
// pool still limits the number of contexts that pay this cost.
const webgl = new WebglAddon(true);
webgl.onContextLoss(() => {
// GPU context gone and not restored: drop the renderer, xterm falls back to the DOM renderer with
// the buffer intact, and the pool keeps this card off WebGL for a while.
Expand All @@ -633,17 +732,20 @@ function TerminalCardView({
} catch {
// WebGL2 unavailable — stay on the DOM renderer.
webgl.dispose();
restoreRendererTransition();
return false;
}
webglAddonRef.current = webgl;
terminal.element?.setAttribute("data-renderer", "webgl");
fitRef.current?.();
if (transitionElement) revealRendererTransition(terminal, transitionElement);
return true;
};

const disableWebgl = (terminal: Terminal): void => {
const webgl = webglAddonRef.current;
if (!webgl) return;
const transitionElement = fitRef.current ? beginRendererTransition(terminal) : null;
webglAddonRef.current = null;
// The WebGL canvas is the one xterm-screen child without a layer class (the link layer is a 2D canvas).
const canvas = terminal.element?.querySelector<HTMLCanvasElement>(".xterm-screen > canvas:not([class])");
Expand All @@ -652,6 +754,7 @@ function TerminalCardView({
// WebGL snaps the cell width down to whole device pixels, so its cells can be narrower than the DOM
// renderer's. A grid fitted while on WebGL may then be too wide for DOM: fit again.
fitRef.current?.();
if (transitionElement) revealRendererTransition(terminal, transitionElement);
// Disposing the addon drops the canvas but not its context, which counts against Chromium's
// per-renderer limit until it is collected. Lose it now so the slot is really free. getContext
// returns the canvas's existing context here; it creates nothing.
Expand Down Expand Up @@ -701,6 +804,10 @@ function TerminalCardView({
}
}, [session.id, lifecycle]);

useEffect(() => {
if (!surfaceIsLive(lifecycle)) restoreRendererTransition();
}, [lifecycle]);

useEffect(() => {
const terminal = terminalRef.current;
if (!terminal) return;
Expand Down
153 changes: 153 additions & 0 deletions tests/terminal-renderer-transition.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import { stripTypeScriptTypes } from "node:module";
import { runInNewContext } from "node:vm";
import test from "node:test";

// Execute the current transition with a clock whose animation frames can stop independently of timers.
const card = await readFile(new URL("../src/renderer/src/features/terminal/TerminalCard.tsx", import.meta.url), "utf8");
const transitionSource = card.slice(card.indexOf(" const rendererTransitionRef ="), card.indexOf(" const lifecycle ="));

function fixture({ render = true, reducedMotion = false } = {}) {
let now = 0;
let nextId = 0;
const timers = new Map();
const frames = new Map();
const document = new EventTarget();
document.hidden = false;
const window = new EventTarget();
Object.assign(window, {
document,
setTimeout(callback, ms) { const id = ++nextId; timers.set(id, { at: now + ms, callback }); return id; },
clearTimeout(id) { timers.delete(id); },
requestAnimationFrame(callback) { const id = ++nextId; frames.set(id, callback); return id; },
cancelAnimationFrame(id) { frames.delete(id); },
matchMedia: () => ({ matches: reducedMotion })
});
document.defaultView = window;
let renders = new Set();
const makeClassList = (initial = []) => {
const values = new Set(initial);
return {
add(...names) { names.forEach(name => values.add(name)); },
[Symbol.iterator]() { return values[Symbol.iterator](); }
};
};
const snapshot = {
ownerDocument: document,
isConnected: false,
animation: null,
classList: makeClassList(),
style: {},
setAttribute() {},
querySelectorAll() { return []; },
remove() { this.isConnected = false; },
animate() {
this.animation = { onfinish: null, canceled: false, cancel() { this.canceled = true; } };
return this.animation;
}
};
const screen = {
offsetLeft: 0,
offsetTop: 0,
checkVisibility() { return true; },
querySelectorAll() { return []; },
cloneNode() { return snapshot; }
};
const element = {
dataset: { renderer: "dom" },
classList: makeClassList(),
querySelector() { return screen; },
append(node) { node.isConnected = true; }
};
const terminal = {
element,
rows: 24,
onRender(callback) { renders.add(callback); return { dispose() { renders.delete(callback); } }; },
refresh() { if (render) for (const callback of [...renders]) callback(); }
};
const { begin, reveal, cancel } = runInNewContext(stripTypeScriptTypes(`${transitionSource}
({ begin: beginRendererTransition, reveal: revealRendererTransition, cancel: restoreRendererTransition });`), {
useRef: () => ({ current: null }), snapshot, terminal, window, document,
requestAnimationFrame: window.requestAnimationFrame, cancelAnimationFrame: window.cancelAnimationFrame
});
const start = () => {
const transitionSnapshot = begin(terminal);
assert.equal(transitionSnapshot, snapshot);
reveal(terminal, transitionSnapshot);
};
const advance = ms => {
const until = now + ms;
for (;;) {
const next = [...timers].filter(([, timer]) => timer.at <= until).sort((a, b) => a[1].at - b[1].at)[0];
if (!next) break;
timers.delete(next[0]); now = next[1].at; next[1].callback();
}
now = until;
};
const frame = () => {
const pending = [...frames.values()]; frames.clear();
for (const callback of pending) callback();
};
return { snapshot, document, start, cancel, advance, frame,
pending: () => timers.size + frames.size + renders.size,
counts: () => ({ timers: timers.size, frames: frames.size, renders: renders.size }) };
}

test("a visible transition fades only after the new render and two paint frames", () => {
const h = fixture();
h.start();
assert.equal(h.snapshot.animation, null);
h.frame();
assert.equal(h.snapshot.animation, null);
h.frame();
assert.ok(h.snapshot.animation);
assert.equal(h.snapshot.isConnected, true);
h.snapshot.animation.onfinish();
assert.equal(h.snapshot.isConnected, false);
assert.deepEqual(h.counts(), { timers: 0, frames: 0, renders: 0 });
});

for (const render of [false, true]) {
test(`the cleanup deadline removes the snapshot without animation frames (render=${render})`, () => {
const h = fixture({ render });
h.start(); h.advance(249);
assert.equal(h.snapshot.isConnected, true);
h.advance(1);
assert.equal(h.snapshot.isConnected, false);
assert.deepEqual(h.counts(), { timers: 0, frames: 0, renders: 0 });
h.frame();
assert.equal(h.snapshot.animation, null);
});
}

test("hiding the document removes the snapshot before any suspended frames or timers run", () => {
const h = fixture();
h.start();
h.document.hidden = true;
h.document.dispatchEvent(new Event("visibilitychange"));
assert.equal(h.snapshot.isConnected, false);
assert.deepEqual(h.counts(), { timers: 0, frames: 0, renders: 0 });
});

test("teardown cancels a pending render, frames and an active fade", () => {
for (const phase of ["render", "frame", "fade"]) {
const h = fixture({ render: phase !== "render" });
h.start();
if (phase === "fade") { h.frame(); h.frame(); }
h.cancel(); h.cancel();
assert.equal(h.snapshot.isConnected, false);
assert.deepEqual(h.counts(), { timers: 0, frames: 0, renders: 0 });
if (h.snapshot.animation) assert.equal(h.snapshot.animation.canceled, true);
h.advance(1_000); h.frame();
assert.equal(h.snapshot.isConnected, false);
}
});

test("reduced motion removes the snapshot after paint without starting an animation", () => {
const h = fixture({ reducedMotion: true });
h.start(); h.frame(); h.frame();
assert.equal(h.snapshot.isConnected, false);
assert.equal(h.snapshot.animation, null);
assert.deepEqual(h.counts(), { timers: 0, frames: 0, renders: 0 });
});
2 changes: 2 additions & 0 deletions tests/webgl-context-pool.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,8 @@ test("TerminalCard routes its renderer through the pool and frees the context on
assert.match(card, /pool\.register\(session\.id,/);
assert.match(card, /webglContextPool\(\)\.contextLost\(session\.id\)/);
assert.match(card, /WEBGL_lose_context/);
assert.match(card, /data-renderer-transition/);
assert.match(card, /requestAnimationFrame\(/);
assert.doesNotMatch(card, /if \(focused && !summaryMode && zoom <= WEBGL_MAX_SCALE\) enableWebgl/);
const canvas = await readFile(new URL("../src/renderer/src/features/workspace/WorkspaceCanvas.tsx", import.meta.url), "utf8");
// Every camera move (the store's listener, no React render) and every layout change tells the pool.
Expand Down
Loading