Skip to content

Bridge widgets

A bridge is a GTK widget that also behaves like a DOM element. You get a real HTMLCanvasElement (or <video>, or <iframe>) whose drawing surface is a GTK widget you can mount in any container. Browser-shaped code like canvas.getContext('webgl') or video.srcObject = stream drives the GTK surface directly, with no WebKit.WebView wrapper and no separate renderer process.

DOM ↔ GTK Bridges

Standard DOM elements, rendered natively

App code uses standard HTML elements. Each bridge translates the DOM lifecycle to a GTK widget — Cairo for 2D, OpenGL via libepoxy for WebGL, GStreamer for video, WebKit for iframes.

HTMLCanvasElement (2D) Gtk.DrawingArea

@gjsify/canvas2d Cairo + PangoCairo

HTMLCanvasElement (WebGL/WebGL2) Gtk.GLArea

@gjsify/webgl Vala/gwebgl + libepoxy

HTMLIFrameElement WebKit.WebView

@gjsify/iframe WebKit 6.0

HTMLVideoElement Gtk.Picture

@gjsify/video GStreamer + gtk4paintablesink

Native Vala bridges

Terminal @gjsify/terminal-native

Posix.isatty + ioctl TIOCGWINSZ + termios for @gjsify/tty

Shared ArrayBuffer @gjsify/sab-native

Cross-process shared memory + futex atomics for @gjsify/worker_threads

TLS @gjsify/tls-native

Direct OpenSSL access for @gjsify/tls

HTTP-Soup @gjsify/http-soup-bridge

Native Soup integration helpers for @gjsify/http

HTTP/2 @gjsify/http2-native

nghttp2 bridge for h2c, push streams, flow control

WebRTC @gjsify/webrtc-native

GLib.Idle signal marshalling for webrtcbin's streaming-thread callbacks

Every bridge is a subclass of the GTK widget it renders into, so bridge.set_size_request(), bridge.add_css_class() and win.set_child(bridge) all work the way you expect.

They follow the GTK layer rather than one runtime. Build --app gjs and the gi:// imports below resolve in the gjs host itself; build --app node and the same source runs on Node, Bun and Deno with gi:// going through @gjsify/node-gi.

That is the shape. How far each bridge is measured is a separate question and the answer differs per package, so go by the numbers rather than the shape. The headless Canvas 2D core (@gjsify/canvas2d-core) is measured at 578/578 on Node, Bun and Deno; the GTK-backed @gjsify/canvas2d at 191/191 on Node. @gjsify/webgl and @gjsify/iframe run their suites on gjs and carry no node-family measurement, and @gjsify/video ships no suite at all, so off gjs treat those three as untested rather than promised. The iframe showcases say so in their own manifest: they declare gjs only, so gjsify showcase <name> --runtime node refuses them up front instead of crashing.

Three steps, the same for all four:

TypeScript
import Adw from 'gi://Adw?version=1';
import { WebGLBridge } from '@gjsify/webgl';
const bridge = new WebGLBridge();
bridge.installGlobals(); // 1. optional: expose the browser globals
bridge.onReady((canvas, gl) => { // 2. the canvas and context are usable now
gl.clearColor(0, 0, 0, 1);
gl.clear(gl.COLOR_BUFFER_BIT);
});
const win = new Adw.ApplicationWindow({ application: app });
win.set_child(bridge); // 3. mount it like any other widget
win.present();

Wait for onReady. The underlying surface does not exist until GTK realizes the widget, so bridge.canvas is null before that. onReady fires as soon as the surface exists, and runs your callback immediately if it already does. Reading the canvas or the draw context earlier gives you a zero-sized surface at best.

installGlobals() is optional. Call it when you run library code written for a browser (Three.js, Excalibur, p5.js) which reaches for requestAnimationFrame, performance.now() or the WebGL context constructors on globalThis. Skip it for code you wrote yourself that takes the canvas as an argument. Without it, browser-written code fails with TypeError: requestAnimationFrame is not a function.

Terminal
gjsify install @gjsify/canvas2d
TypeScript
import Adw from 'gi://Adw?version=1';
import { Canvas2DBridge } from '@gjsify/canvas2d';
const bridge = new Canvas2DBridge();
bridge.installGlobals();
bridge.onReady((canvas, ctx) => {
ctx.fillStyle = '#3584e4';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.font = '24px Adwaita Sans';
ctx.fillStyle = '#fff';
ctx.fillText('Hello from GTK', 20, 40);
});
const win = new Adw.ApplicationWindow({ application: app });
win.set_default_size(600, 400);
win.set_child(bridge);
win.present();

Canvas2DBridge is a Gtk.DrawingArea backed by Cairo and PangoCairo. The canvas is created on the first draw, sized to the widget’s allocation, and its Cairo surface is blitted to the screen on every frame.

After you draw outside a requestAnimationFrame loop, call bridge.queue_draw() to push the new surface to the screen.

On a resize the bridge writes the new allocation to canvas.width / canvas.height, dispatches a DOM resize event on the canvas, notifies any ResizeObserver, and then runs your onResize callbacks. The widget owns those two numbers: a size you assign yourself is replaced by the allocation on the next draw.

Terminal
gjsify install @gjsify/webgl
TypeScript
import Adw from 'gi://Adw?version=1';
import { WebGLBridge } from '@gjsify/webgl';
const bridge = new WebGLBridge();
bridge.installGlobals();
bridge.onReady((canvas, gl) => {
gl.clearColor(0.0, 0.4, 0.6, 1.0);
gl.clear(gl.COLOR_BUFFER_BIT);
});
const win = new Adw.ApplicationWindow({ application: app });
win.set_default_size(800, 600);
win.set_child(bridge);
win.present();

WebGLBridge is a Gtk.GLArea exposing the WebGL 1 and WebGL 2 API through a Vala extension (gwebgl) over libepoxy. requestAnimationFrame is wired to the GTK frame clock, so your loop runs at vsync without any extra setup.

The drawing buffer is GTK’s, not yours. canvas.width and canvas.height are live readings of it in device pixels, the widget allocation multiplied by the surface scale factor, and they have no setter: assigning to them is ignored. A library that sizes its viewport from those properties therefore follows the widget on its own. When you want the allocation in logical pixels instead, read canvas.clientWidth / clientHeight.

What does have to follow a resize is your own GL state, so do that in onResize. Take the numbers from the canvas rather than from the callback arguments, which are logical pixels: on a scale-factor-3 display a viewport computed from those covers a ninth of the widget and the rest stays at the clear colour.

TypeScript
let gl: WebGLRenderingContext | null = null;
bridge.onReady((_canvas, ctx) => { gl = ctx; });
bridge.onResize(() => {
const canvas = bridge.canvas;
if (!gl || !canvas) return;
gl.viewport(0, 0, canvas.width, canvas.height); // device pixels
// …and your camera aspect / projection matrix
});

Some hosts have no usable GPU, and a CPU rasteriser makes every draw call succeed while running orders of magnitude slower. That looks like a frozen window rather than a slow one. rendererInfo tells you which you got:

TypeScript
bridge.onReady(() => {
console.log(bridge.rendererInfo); // { vendor: '…', renderer: '…' } or null
});

The bridge also warns on stderr once when it detects a software renderer. Treat the string as a report, not a capability check: if your app switches renderers, key that on a measured frame budget instead.

Terminal
gjsify install @gjsify/video
TypeScript
import Adw from 'gi://Adw?version=1';
import { VideoBridge } from '@gjsify/video';
const bridge = new VideoBridge();
bridge.showControls(true);
bridge.onReady(async (video) => {
// A camera / WebRTC track:
video.srcObject = await navigator.mediaDevices.getUserMedia({ video: true });
// …or a file or URL, played through a GStreamer playbin:
// video.src = 'https://example.com/clip.mp4';
});
const win = new Adw.ApplicationWindow({ application: app });
win.set_child(bridge);
win.present();

VideoBridge is a Gtk.Box containing a Gtk.Overlay over a Gtk.Picture, rendered through GStreamer’s gtk4paintablesink. The overlay is what lets showControls(true) add a play/pause, seek, time and volume bar that auto-hides after two seconds of mouse inactivity.

There is no draw context here. You hand it a source through srcObject or src and GStreamer does the rest. bridge.videoElement (also bridge.element) gives you the HTMLVideoElement outside an onReady callback, and bridge.environment gives you the bridge’s own document / window pair if you want DOM-shaped code scoped to this widget instead of to globalThis.

installGlobals() is rarely needed here: it only publishes HTMLVideoElement and a performance shim.

Terminal
gjsify install @gjsify/iframe
TypeScript
import Adw from 'gi://Adw?version=1';
import { IFrameBridge } from '@gjsify/iframe';
const bridge = new IFrameBridge();
bridge.onReady((iframe) => {
iframe.contentWindow?.addEventListener('message', handler);
});
bridge.loadHtml('<h1>Hello from WebKit</h1>');
// or: bridge.loadUri('https://example.com/');
const win = new Adw.ApplicationWindow({ application: app });
win.set_child(bridge);
win.present();

IFrameBridge is the one that really does ship a browser: it extends WebKit.WebView. Use it to embed genuine web content (a third-party site, a sandboxed app, an HTML report) inside a GTK app. The other three bridges are for the opposite job, running browser-shaped code directly on GTK with no browser engine underneath.

Use loadUri(url) and loadHtml(html, baseUri?) to navigate, because they keep WebKit and the iframe element’s attributes in step. bridge.goBack(), bridge.goForward() and bridge.reload() drive WebKit’s own history, guarded by bridge.canGoBack / bridge.canGoForward. bridge.currentUri, bridge.pageTitle and bridge.lastLoadError report the current state, and bridge.postMessage(data, targetOrigin) is the short form of bridge.iframeElement.contentWindow.postMessage(...).

onReady here means “content finished loading”. If a page has already loaded, the callback runs on the next load rather than immediately.

The two canvas bridges and the video bridge attach GTK event controllers and re-dispatch them as standard DOM events on the element, so listeners written for a browser work unchanged. (IFrameBridge needs none of this: WebKit delivers input to the page itself.)

TypeScript
bridge.onReady((canvas) => {
canvas.addEventListener('pointermove', (e) => console.log(e.clientX, e.clientY));
canvas.addEventListener('keydown', (e) => console.log(e.key, e.code));
canvas.addEventListener('wheel', (e) => zoom(e.deltaY));
});

Pointer, mouse, wheel, keyboard and focus events are covered: pointermove / pointerdown / pointerup / pointerenter / pointerleave, the matching mouse* events plus click, dblclick and contextmenu, wheel, keydown / keyup, and focus / blur with their bubbling focusin / focusout pairs.

Four ways to hear about a resize, all fed from the same GTK signal. Pick whichever suits the consumer:

PathwayUse it when
bridge.connect('resize', (w, width, height) => …)You are already writing GTK code. This is the Gtk.DrawingArea / Gtk.GLArea signal, so it is on the two canvas bridges.
bridge.onResize((width, height) => …)Same payload, less ceremony, and available on the video bridge too.
canvas.addEventListener('resize', …)The consumer is a DOM-shaped library.
new ResizeObserver(cb).observe(canvas)The library uses the standard observer API.

ResizeObserver also works when a library observes the canvas’s parent rather than the canvas. The bridge walks up the parent chain and notifies every ancestor’s subscribers, which is what makes Excalibur’s DisplayMode.FillContainer reflow correctly:

TypeScript
import { WebGLBridge } from '@gjsify/webgl';
import Excalibur from 'excalibur';
const bridge = new WebGLBridge();
bridge.installGlobals();
bridge.onReady((canvas) => {
const engine = new Excalibur.Engine({
canvasElement: canvas,
displayMode: Excalibur.DisplayMode.FillContainer,
});
engine.start();
});

Resize the widget itself with bridge.set_size_request(width, height) or by changing the parent layout. Assigning to canvas.width gets you nowhere on either canvas bridge: Canvas2DBridge replaces your value with the allocation on the next draw, and on WebGLBridge the property has no setter at all.

installGlobals() writes to globalThis, so a second call replaces the first bridge’s requestAnimationFrame. That is fine when one bridge drives everything, and a problem when you have two independent animations running side by side.

If you need both, don’t call installGlobals() at all. Keep the canvas and the context that onReady handed you, pass them into your code explicitly, and drive each loop from its own bridge.requestAnimationFrame(...).

Hold the bridge in the same scope as the window that contains it, or store both on the same object. The canvas, the GL/Cairo/GStreamer pipeline and the DOM element are torn down when the widget is unrealized or garbage-collected. A bridge assigned to a variable that goes out of scope before the window does can be collected out from under you.

Importing @gjsify/canvas2d or @gjsify/iframe has no side effects: you get the named exports and nothing lands on globalThis. The browser globals each package owns (ImageData, Path2D, HTMLIFrameElement, the WebGL context constructors) live behind a /register subpath, and gjsify build’s default --globals auto injects the ones your bundle actually references. See How it works if you want to pin them by hand or build with --globals none.