fallback to canvas renderer if webgl is not available, debug toggle for testing (#3081)

This commit is contained in:
Mike Sawka 2026-03-18 15:57:42 -07:00 committed by GitHub
parent 134b38807f
commit c126306da1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 142 additions and 20 deletions

View file

@ -41,7 +41,7 @@ import * as jotai from "jotai";
import * as React from "react";
import { getBlockingCommand } from "./shellblocking";
import { computeTheme, DefaultTermTheme } from "./termutil";
import { TermWrap } from "./termwrap";
import { TermWrap, WebGLSupported } from "./termwrap";
export class TermViewModel implements ViewModel {
viewType: string;
@ -155,7 +155,7 @@ export class TermViewModel implements ViewModel {
if (isCmd) {
const blockMeta = get(this.blockAtom)?.meta;
let cmdText = blockMeta?.["cmd"];
let cmdArgs = blockMeta?.["cmd:args"];
const cmdArgs = blockMeta?.["cmd:args"];
if (cmdArgs != null && Array.isArray(cmdArgs) && cmdArgs.length > 0) {
cmdText += " " + cmdArgs.join(" ");
}
@ -242,7 +242,7 @@ export class TermViewModel implements ViewModel {
});
this.termTransparencyAtom = useBlockAtom(blockId, "termtransparencyatom", () => {
return jotai.atom<number>((get) => {
let value = get(getOverrideConfigAtom(this.blockId, "term:transparency")) ?? 0.5;
const value = get(getOverrideConfigAtom(this.blockId, "term:transparency")) ?? 0.5;
return boundNumber(value, 0, 1);
});
});
@ -293,6 +293,13 @@ export class TermViewModel implements ViewModel {
}
}
if (get(getSettingsKeyAtom("debug:webglstatus"))) {
const webglButton = this.getWebGlIconButton(get);
if (webglButton) {
rtn.push(webglButton);
}
}
if (blockData?.meta?.["controller"] != "cmd" && shellProcStatus != "done") {
return rtn;
}
@ -438,6 +445,38 @@ export class TermViewModel implements ViewModel {
return null;
}
getWebGlIconButton(get: jotai.Getter): IconButtonDecl | null {
if (!WebGLSupported) {
return {
elemtype: "iconbutton",
icon: "microchip",
iconColor: "var(--error-color)",
title: "WebGL not supported",
noAction: true,
};
}
if (!this.termRef.current?.webglEnabledAtom) {
return null;
}
const webglEnabled = get(this.termRef.current.webglEnabledAtom);
if (webglEnabled) {
return {
elemtype: "iconbutton",
icon: "microchip",
iconColor: "var(--success-color)",
title: "WebGL enabled (click to disable)",
click: () => this.toggleWebGl(),
};
}
return {
elemtype: "iconbutton",
icon: "microchip",
iconColor: "var(--secondary-text-color)",
title: "WebGL disabled (click to enable)",
click: () => this.toggleWebGl(),
};
}
get viewComponent(): ViewComponent {
return TerminalView as ViewComponent;
}
@ -478,6 +517,22 @@ export class TermViewModel implements ViewModel {
});
}
getTermRenderer(): "webgl" | "canvas" {
return this.termRef.current?.getTermRenderer() ?? "canvas";
}
isWebGlEnabled(): boolean {
return this.termRef.current?.isWebGlEnabled() ?? false;
}
toggleWebGl() {
if (!this.termRef.current) {
return;
}
const renderer = this.termRef.current.getTermRenderer() === "webgl" ? "canvas" : "webgl";
this.termRef.current.setTermRenderer(renderer);
}
triggerRestartAtom() {
globalStore.set(this.isRestarting, true);
setTimeout(() => {
@ -544,7 +599,7 @@ export class TermViewModel implements ViewModel {
console.log("search is open, not giving focus");
return true;
}
let termMode = globalStore.get(this.termMode);
const termMode = globalStore.get(this.termMode);
if (termMode == "term") {
if (this.termRef?.current?.terminal) {
this.termRef.current.terminal.focus();

View file

@ -18,6 +18,7 @@ import {
import * as services from "@/store/services";
import { PLATFORM, PlatformMacOS } from "@/util/platformutil";
import { base64ToArray, fireAndForget } from "@/util/util";
import { CanvasAddon } from "@xterm/addon-canvas";
import { SearchAddon } from "@xterm/addon-search";
import { SerializeAddon } from "@xterm/addon-serialize";
import { WebLinksAddon } from "@xterm/addon-web-links";
@ -49,14 +50,14 @@ const MaxRepaintTransactionMs = 2000;
function detectWebGLSupport(): boolean {
try {
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("webgl");
const ctx = canvas.getContext("webgl2");
return !!ctx;
} catch (e) {
return false;
}
}
const WebGLSupported = detectWebGLSupport();
export const WebGLSupported = detectWebGLSupport();
let loggedWebGL = false;
type TermWrapOptions = {
@ -84,7 +85,11 @@ export class TermWrap {
multiInputCallback: (data: string) => void;
sendDataHandler: (data: string) => void;
onSearchResultsDidChange?: (result: { resultIndex: number; resultCount: number }) => void;
private toDispose: TermTypes.IDisposable[] = [];
toDispose: TermTypes.IDisposable[] = [];
webglAddon: WebglAddon | null = null;
canvasAddon: CanvasAddon | null = null;
webglContextLossDisposable: TermTypes.IDisposable | null = null;
webglEnabledAtom: jotai.PrimitiveAtom<boolean>;
pasteActive: boolean = false;
lastUpdated: number;
promptMarkers: TermTypes.IMarker[] = [];
@ -142,6 +147,7 @@ export class TermWrap {
this.promptMarkers = [];
this.shellIntegrationStatusAtom = jotai.atom(null) as jotai.PrimitiveAtom<ShellIntegrationStatus | null>;
this.lastCommandAtom = jotai.atom(null) as jotai.PrimitiveAtom<string | null>;
this.webglEnabledAtom = jotai.atom(false) as jotai.PrimitiveAtom<boolean>;
this.terminal = new Terminal(options);
this.fitAddon = new FitAddon();
this.fitAddon.scrollbarWidth = 6; // this needs to match scrollbar width in term.scss
@ -179,19 +185,7 @@ export class TermWrap {
}
)
);
if (WebGLSupported && waveOptions.useWebGl) {
const webglAddon = new WebglAddon();
this.toDispose.push(
webglAddon.onContextLoss(() => {
webglAddon.dispose();
})
);
this.terminal.loadAddon(webglAddon);
if (!loggedWebGL) {
console.log("loaded webgl!");
loggedWebGL = true;
}
}
this.setTermRenderer(WebGLSupported && waveOptions.useWebGl ? "webgl" : "canvas");
// Register OSC handlers
this.terminal.parser.registerOscHandler(7, (data: string) => {
return handleOsc7Command(data, this.blockId, this.loaded);
@ -307,6 +301,60 @@ export class TermWrap {
this.terminal.options.cursorBlink = cursorBlink ?? false;
}
setTermRenderer(renderer: "webgl" | "canvas") {
if (renderer === "webgl") {
if (this.webglAddon != null) {
return;
}
if (!WebGLSupported) {
renderer = "canvas";
if (this.canvasAddon != null) {
return;
}
}
} else {
if (this.canvasAddon != null) {
return;
}
}
if (this.webglAddon != null) {
this.webglContextLossDisposable?.dispose();
this.webglContextLossDisposable = null;
this.webglAddon.dispose();
this.webglAddon = null;
globalStore.set(this.webglEnabledAtom, false);
}
if (this.canvasAddon != null) {
this.canvasAddon.dispose();
this.canvasAddon = null;
}
if (renderer === "webgl") {
const addon = new WebglAddon();
this.webglContextLossDisposable = addon.onContextLoss(() => {
this.setTermRenderer("canvas");
});
this.terminal.loadAddon(addon);
this.webglAddon = addon;
globalStore.set(this.webglEnabledAtom, true);
if (!loggedWebGL) {
console.log("loaded webgl!");
loggedWebGL = true;
}
} else {
const addon = new CanvasAddon();
this.terminal.loadAddon(addon);
this.canvasAddon = addon;
}
}
getTermRenderer(): "webgl" | "canvas" {
return this.webglAddon != null ? "webgl" : "canvas";
}
isWebGlEnabled(): boolean {
return this.webglAddon != null;
}
resetCompositionState() {
this.isComposing = false;
this.composingData = "";
@ -422,6 +470,8 @@ export class TermWrap {
} catch (_) {}
});
this.promptMarkers = [];
this.webglContextLossDisposable?.dispose();
this.webglContextLossDisposable = null;
this.terminal.dispose();
this.toDispose.forEach((d) => {
try {

View file

@ -1395,6 +1395,7 @@ declare global {
"debug:*"?: boolean;
"debug:pprofport"?: number;
"debug:pprofmemprofilerate"?: number;
"debug:webglstatus"?: boolean;
"tsunami:*"?: boolean;
"tsunami:scaffoldpath"?: string;
"tsunami:sdkreplacepath"?: string;

10
package-lock.json generated
View file

@ -22,6 +22,7 @@
"@table-nav/react": "^0.0.7",
"@tanstack/react-table": "^8.21.3",
"@tanstack/react-virtual": "^3.13.19",
"@xterm/addon-canvas": "^0.7.0",
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-search": "^0.15.0",
"@xterm/addon-serialize": "^0.13.0",
@ -10664,6 +10665,15 @@
"node": ">=10.0.0"
}
},
"node_modules/@xterm/addon-canvas": {
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-canvas/-/addon-canvas-0.7.0.tgz",
"integrity": "sha512-LF5LYcfvefJuJ7QotNRdRSPc9YASAVDeoT5uyXS/nZshZXjYplGXRECBGiznwvhNL2I8bq1Lf5MzRwstsYQ2Iw==",
"license": "MIT",
"peerDependencies": {
"@xterm/xterm": "^5.0.0"
}
},
"node_modules/@xterm/addon-fit": {
"version": "0.10.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.10.0.tgz",

View file

@ -82,6 +82,7 @@
"@table-nav/react": "^0.0.7",
"@tanstack/react-table": "^8.21.3",
"@tanstack/react-virtual": "^3.13.19",
"@xterm/addon-canvas": "^0.7.0",
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-search": "^0.15.0",
"@xterm/addon-serialize": "^0.13.0",

View file

@ -121,6 +121,7 @@ const (
ConfigKey_DebugClear = "debug:*"
ConfigKey_DebugPprofPort = "debug:pprofport"
ConfigKey_DebugPprofMemProfileRate = "debug:pprofmemprofilerate"
ConfigKey_DebugWebGlStatus = "debug:webglstatus"
ConfigKey_TsunamiClear = "tsunami:*"
ConfigKey_TsunamiScaffoldPath = "tsunami:scaffoldpath"

View file

@ -172,6 +172,7 @@ type SettingsType struct {
DebugClear bool `json:"debug:*,omitempty"`
DebugPprofPort *int `json:"debug:pprofport,omitempty"`
DebugPprofMemProfileRate *int `json:"debug:pprofmemprofilerate,omitempty"`
DebugWebGlStatus bool `json:"debug:webglstatus,omitempty"`
TsunamiClear bool `json:"tsunami:*,omitempty"`
TsunamiScaffoldPath string `json:"tsunami:scaffoldpath,omitempty"`

View file

@ -325,6 +325,9 @@
"debug:pprofmemprofilerate": {
"type": "integer"
},
"debug:webglstatus": {
"type": "boolean"
},
"tsunami:*": {
"type": "boolean"
},