Random logo for hjkl.rocks

hjkl.rocks

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
A GTK window with a button
The window created by the code above

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:

A GTK window with a label greeting the user, an input box to change the name, another label with a numeric counter, and a couple of buttons to change the counter
The window created by the Svelte code

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?).