diff --git a/frontend/app/view/term/term-model.ts b/frontend/app/view/term/term-model.ts index 9cb1c58720..eedc3296b3 100644 --- a/frontend/app/view/term/term-model.ts +++ b/frontend/app/view/term/term-model.ts @@ -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((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(); diff --git a/frontend/app/view/term/termwrap.ts b/frontend/app/view/term/termwrap.ts index 1cd167c800..05490ad466 100644 --- a/frontend/app/view/term/termwrap.ts +++ b/frontend/app/view/term/termwrap.ts @@ -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; 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; this.lastCommandAtom = jotai.atom(null) as jotai.PrimitiveAtom; + this.webglEnabledAtom = jotai.atom(false) as jotai.PrimitiveAtom; 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 { diff --git a/frontend/types/gotypes.d.ts b/frontend/types/gotypes.d.ts index 3dede74071..f2ec1d2c64 100644 --- a/frontend/types/gotypes.d.ts +++ b/frontend/types/gotypes.d.ts @@ -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; diff --git a/package-lock.json b/package-lock.json index fbf3fa1c69..468dbdae22 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index eeb75930ed..18d04ac3df 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pkg/wconfig/metaconsts.go b/pkg/wconfig/metaconsts.go index 8195495ad2..28f481ac16 100644 --- a/pkg/wconfig/metaconsts.go +++ b/pkg/wconfig/metaconsts.go @@ -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" diff --git a/pkg/wconfig/settingsconfig.go b/pkg/wconfig/settingsconfig.go index b1b10d977f..c52012803d 100644 --- a/pkg/wconfig/settingsconfig.go +++ b/pkg/wconfig/settingsconfig.go @@ -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"` diff --git a/schema/settings.json b/schema/settings.json index 5213fed365..eda7dc3326 100644 --- a/schema/settings.json +++ b/schema/settings.json @@ -325,6 +325,9 @@ "debug:pprofmemprofilerate": { "type": "integer" }, + "debug:webglstatus": { + "type": "boolean" + }, "tsunami:*": { "type": "boolean" },