Using Svelte for GTK apps
How to write Svelte code that creates GTK widgets instead of HTML elements.
I’ve recently discovered that you can create GTK applications with JavaScript, and I thought, wouldn’t it be cool if I could use this with Svelte?
Well, I found a way to do it, and I’m going to show you how.
GJS
GJS is a JavaScript engine that uses SpiderMonkey and GNOME libraries to create GNOME applications.
Here’s a simple example of a GTK application written in JavaScript using GJS:
import Gtk from 'gi://Gtk?version=4.0';
import GLib from 'gi://GLib';
// Initialize the app
Gtk.init();
const loop = GLib.MainLoop.new(null, false);
// Create a window
const win = new Gtk.Window({
title: 'My Window',
default_width: 300,
default_height: 250,
});
win.connect('close-request', () => {
loop.quit();
});
// Add a button to close the window
const button = new Gtk.Button({
label: 'Close the Window',
valign: Gtk.Align.CENTER,
halign: Gtk.Align.CENTER,
});
button.connect('clicked', () => win.close());
win.set_child(button);
// Run the app
win.present();
loop.run();
This code creates a window with a button that closes the window when clicked.
You can run it with the gjs
command:
gjs --module app.js
As you can see, the code is a bit verbose and not very declarative. This is where Svelte comes in.
Svelte for GTK
With Svelte, we can write interfaces in a more declarative way. For example, the program above could be written like this:
<script>
let win;
</script>
<GtkWindow bind:this={win} title="My Window">
<GtkButton on:clicked={() => win.close()}>Close the Window</GtkButton>
</GtkWindow>
To do this, we need to create those Gtk*
components.
In Svelte, a component is a JavaScript class (in Svelte 3 and 4; starting with Svelte 5, they will be functions). I initially wanted to create the components by hand, something like this:
class GtkButton extends SvelteComponent {
constructor(options) {
// ...
}
}
But I soon realized that this might be more complex that I initially thought, due to the need to handle many of Svelte’s internal mechanics.
My next option was to have a Svelte file for each widget, and link it to its parent using Svelte’s context feature.
For example the GtkButton
component would look like this:
<script>
import Gtk from 'gi://Gtk?version=4.0';
import { createEventDispatcher, getContext, setContext } from 'svelte';
// Input props
export let label;
// Create the widget and add it to its parent
const widget = new Gtk.Button({ label });
getContext('gtk.parent').append(widget);
// Add the widget to the context so children can get a reference to it
setContext('gtk.parent', widget);
// Update the label if the parent passes a new one
$: widget.set_label(label);
// Emit the clicked event
const dispatch = createEventDispatcher();
widget.connect('clicked', (ev) => dispatch(name, ev));
</script>
While this works, I started to notice a pattern and wanted to see I could get
away with a single generic .svelte
file and make a subclass for each widget
to define their specific behavior, like bindings and events.
I came up with this:
<script>
import { createEventDispatcher, getContext, setContext } from 'svelte';
import Gtk from 'gi://Gtk?version=4.0';
// Props to customize the component
export let gtkClass; // GTK class to instantiate
export let events = []; // GTK events to connect
export let value; // Prop to bind input values
export let bindings = []; // Binding rules
export let react; // Called whenever rest props change
// Create the widget
const widget = new gtkClass({
valign: Gtk.Align.CENTER,
halign: Gtk.Align.CENTER,
...$$restProps,
});
// Add it to its parent
const parent = getContext('gtk.parent');
if (parent instanceof Gtk.Window) {
parent.set_child(widget);
} else {
parent.append(widget);
}
setContext('gtk.parent', widget);
// Handle events
const dispatch = createEventDispatcher();
events.forEach((name) => {
widget.connect(name, (ev) => dispatch(name, ev));
});
// Handle bindings
bindings.forEach(({ event, getValue, setValue }) => {
setValue(widget, value);
widget.connect(event, () => {
value = getValue(widget);
});
});
$: react && react(widget, $$restProps);
</script>
<slot />
I could then define widgets like this:
// Entry is a text input box
export class GtkEntry extends GtkGenericWidget {
constructor(options) {
options.props = {
...options.props,
gtkClass: Gtk.Entry,
// This allows to do `bind:value={text}`
bindings: [
{
event: 'changed',
getValue: (widget) => widget.get_text(),
setValue: (widget, value) => widget.set_text(value),
},
],
};
super(options);
}
}
// Label is just to display text
export class GtkLabel extends GtkGenericWidget {
constructor(options) {
options.props = {
...options.props,
gtkClass: Gtk.Label,
// This updates the label in the GTK widget whenever
// the prop we pass gets updated
react: (widget, props) => {
widget.set_label(props.label);
},
};
super(options);
}
}
Of course, if needed, I could still define each widget on its own file, and
that’s what I did for GtkWindow
. Ultimately, I think that’s a better approach
for more control and performance
(using $$restProps
has some performance penalty, for example),
but for now, I’m happy with this.
With all this in place, I could define more complex interfaces with less code:
<script>
import { Gtk, GtkWindow, GtkBox, GtkButton, GtkLabel, GtkEntry } from './svelte-gjs';
let counter = 0;
let text = 'World';
</script>
<GtkWindow title="My Svelte-Gtk Window!" default_width={280} default_height={160}>
<GtkBox spacing={10} orientation={Gtk.Orientation.VERTICAL}>
<GtkLabel label={`Hello ${text}!`} />
<GtkEntry bind:value={text} />
<GtkLabel label={`Counter: ${counter}`} />
<GtkBox spacing={10} orientation={Gtk.Orientation.HORIZONTAL}>
<GtkButton label="+" on:clicked={() => (counter += 1)} />
<GtkButton label="-" on:clicked={() => (counter -= 1)} />
</GtkBox>
</GtkBox>
</GtkWindow>
Finally, we can instantiate our app to run it, however, I had to do some patching since Svelte still thinks it’s running inside a browser:
import Gtk from 'gi://Gtk?version=4.0';
import GLib from 'gi://GLib';
import App from './App.svelte';
// Initialize the app
Gtk.init();
const loop = GLib.MainLoop.new(null, false);
// There's no document in GJS's window, and Svelte tries to create
// empty spaces between the components.
window.document = {
createTextNode() {
return {};
},
};
// Svelte uses this to create the events it emits, but it's not available in GJS
window.CustomEvent = class CustomEvent {
constructor(name, options) {
this.name = name;
this.options = options;
}
};
// `target` is supposed to be an HTML node, and Svelte calls `insertBefore`
// to mount the app.
// In our case, I'm "mounting" the app directly in `GtkWindow.svelte` by
// calling `window.present()` inside the `onMount` event.
export const app = new App({
context: new Map([['gtk.loop', loop]]),
target: {
insertBefore: () => {},
},
});
loop.run();
This results in the following little program:
If you want to try it out, you can find the code in this repo. The instructions to run it are on the README.
Conclusions
If we start building a widget library, along with some type definitions and styling (GTK supports CSS so it shouldn’t be too hard), I can see something like this taking off.
I think Svelte has really raised the bar on how we build user interfaces, and I hope we’ll see its innovations applied to other platforms outside the web. It would be cool to have a Sveltified Vala, for example (Svala?).