Skip to content

Native Adwaita Apps

Every native GNOME app starts with the same code: an Adw.Application, a CSS bootstrap, quit and about actions, a sidebar, a content stack. @gjsify/adwaita-app hands you those pieces so you can skip straight to your views.

Nothing here hides Adw or Gtk. Your views are plain Gtk.Widgets, the shell returns real Adwaita objects, and you can drop any helper you don’t want. Declare what those views look like in Blueprint, as Describe the window in Blueprint shows, and keep TypeScript for what they do.

Terminal
gjsify install @gjsify/adwaita-app

The package is written against gi:// and @girs/*, so one source builds for either target and you pick by the runtime you already have:

Terminal
gjsify build src/main.ts --app gjs --outfile dist/app.gjs.mjs # for gjs
gjsify build src/main.ts --app node --outfile dist/app.node.mjs # for node, bun and deno
gjsify run dist/app.gjs.mjs
gjsify run --runtime node dist/app.node.mjs # or --runtime bun / --runtime deno

With no --app the CLI targets whatever runtime is executing it, so a bare gjsify build follows your install. What actually differs is one layer: on gjs the Adw and Gtk namespaces resolve in the host itself, with nothing in between, and gjs exists on Linux but not on Windows. Node, Bun and Deno share a single --app node bundle (Node-API is their common ABI) and reach gi:// through @gjsify/node-gi, which is the route that also covers macOS and Windows.

The shell’s own suites (the nav model, LoadToken, the dialog model) are built and run twice, --app gjs on gjs and --app node on Node. Bun and Deno load that same --app node bundle but are not exercised for this package yet, so take them as untested here rather than promised. Its widget modules need a display, so what CI proves there is GTK and Adwaita under node-gi generally: an unchanged Adw.Application on Linux, and the full Adwaita storybook gallery on Linux and Windows.

runAdwaitaApp builds the application, wires the standard actions, and runs it. Give it an application id and a function that builds your main window.

TypeScript
import Adw from 'gi://Adw?version=1';
import GObject from 'gi://GObject?version=2.0';
import { runAdwaitaApp } from '@gjsify/adwaita-app';
class MainWindow extends Adw.ApplicationWindow {
constructor(app: Adw.Application) {
super({ application: app, defaultWidth: 900, defaultHeight: 640 });
// declare the content in Blueprint, see below
}
static { GObject.registerClass({ GTypeName: 'MyMainWindow' }, this); }
}
await runAdwaitaApp({
applicationId: 'org.example.App',
createWindow: (app) => new MainWindow(app),
css: '/* optional app CSS, applied display-wide on startup */',
about: { applicationName: 'My App', version: '1.0.0', developerName: 'Me' },
});

createWindow runs once, on the first activate. After that the same window is presented again, so starting the app a second time brings the running one to the front instead of opening a duplicate. When that happens you get a line on stderr saying so, which saves you from reading a silent exit code 0 as a crash.

OptionDefaultWhat it does
applicationIdrequiredGApplication id, e.g. org.example.App.
createWindowrequired(app) => Gtk.Window, called once on first activate.
flagsGio.ApplicationFlags.DEFAULT_FLAGSGApplication flags.
cssnoneCSS string applied display-wide on startup.
aboutnoneFields for an Adw.AboutDialog; wires an app.about action.
quitActiontrueWire app.quit on <primary>q.
devtoolsenv-gatedtrue force-enables, an object passes devtools options through, false turns it off. Left out, it stays gated on GJSIFY_DEVTOOLS, which is safe in a release build.
onStartupnone(app) => void, runs on startup after CSS and devtools are wired.

Use runAsync(), not run(). Under sync run() a view that awaits anything never finishes loading, so its spinner keeps spinning. runApplication(app, argv) is exported for exactly this: it runs any Gio.Application on runAsync and adds the second-instance notice.

TypeScript
import Adw from 'gi://Adw?version=1';
import { runApplication } from '@gjsify/adwaita-app';
import system from 'system';
const myApp = new Adw.Application({ applicationId: 'org.example.App' });
await runApplication(myApp, [system.programInvocationName, ...system.programArgs]);

system is a bare built-in module, not a GJS-only import: @gjsify/node-gi carries it across, so that line resolves in an --app node bundle too.

Write the widget tree in a .blp file. Every _("…") string in it is one xgettext extracts, and a reviewer can read the layout without running the app.

Code
using Gtk 4.0;
using Adw 1;
template $MyMainWindow: Adw.ApplicationWindow {
default-width: 900;
default-height: 640;
content: Adw.ToolbarView {
[top]
Adw.HeaderBar {}
content: Adw.StatusPage status {
title: _("Nothing open");
icon-name: "document-open-symbolic";
};
};
}

Import the file and pass it as the class’s Template. gjsify build compiles the import to GtkBuilder XML for --app gjs and --app node alike, in process, so no blueprint-compiler needs to be installed. InternalChildren lists the ids you want back, and each one arrives as this._<id>:

TypeScript
import Adw from 'gi://Adw?version=1';
import GObject from 'gi://GObject?version=2.0';
import Template from './window.blp';
class MainWindow extends Adw.ApplicationWindow {
declare private _status: Adw.StatusPage;
static {
GObject.registerClass({ GTypeName: 'MyMainWindow', Template, InternalChildren: ['status'] }, this);
}
showFile(name: string): void {
this._status.set_title(name);
}
}

template $MyMainWindow names the GTypeName, so the two have to match. For the .blp import to type-check, install @gjsify/vite-plugin-blueprint as a dev dependency and add @gjsify/vite-plugin-blueprint/types to compilerOptions.types. Rows that follow your data go into a container the template declares, filled from TypeScript.

createNavShell(window, options) builds an Adw.NavigationSplitView with a sidebar Gtk.ListBox and a content Gtk.Stack, from a plain array of nav items. It also adds the responsive breakpoint to your window, so the split view collapses on narrow screens.

Call it from your Adw.ApplicationWindow subclass, fill the returned stack, and set the returned widget as the window content.

TypeScript
import Adw from 'gi://Adw?version=1';
import { createNavShell, type NavItem } from '@gjsify/adwaita-app';
const NAV: NavItem[] = [
{ id: 'overview', label: 'Overview', icon: 'go-home-symbolic' },
{ id: 'reports', label: 'Reports', icon: 'x-office-spreadsheet-symbolic', subtitle: 'Monthly' },
];
// Your own views. Any Gtk.Widget does; these two are placeholders.
const buildOverview = (): Adw.StatusPage => new Adw.StatusPage({ title: 'Overview' });
const buildReports = (): Adw.StatusPage => new Adw.StatusPage({ title: 'Reports' });
const shell = createNavShell(this, {
items: NAV,
sidebarTitle: 'My App',
onSelect: (item) => shell.stack.set_visible_child_name(item.id),
});
shell.stack.add_named(buildOverview(), 'overview');
shell.stack.add_named(buildReports(), 'reports');
this.set_content(shell.widget);
shell.selectById('overview');

You get back { widget, stack, contentHeader, setItems, selectById, selectByIndex }. contentHeader is the content pane’s Adw.HeaderBar, so pack your own buttons into it. Selecting a row while the shell is collapsed reveals the content pane for you.

Two more options are worth knowing: sidebarHeaderStart / sidebarHeaderEnd take a widget to pack into the sidebar header (an open button, a menu button), and collapseWidth moves the breakpoint away from its 720px default.

If the nav items do not exist when the window does — an inbox the app loads asynchronously, say — pass an empty items and call setItems(items) when they arrive. It rebuilds the rows in place, so onSelect keeps receiving the item that actually belongs to the clicked row. The selected id survives an update that still carries it (silently: a rebuild is not a selection), and selectById / selectByIndex resolve against the items you last passed.

headerFunc(row, before) adds group headers — it is wired to Gtk.ListBox.set_header_func, and before is the NavItem above row (null for the first), so a section title is one comparison. sidebarBottomBar and contentBottomBar put a widget under the sidebar list and under the content stack respectively.

A view backed by an async source needs three states: a spinner while it loads, the content when it arrives, an error page when it doesn’t. LoadingStack is a Gtk.Stack that already has those three pages, named loading, content and error.

TypeScript
import Adw from 'gi://Adw?version=1';
import { LoadingStack } from '@gjsify/adwaita-app';
const buildReportView = (): Adw.StatusPage => new Adw.StatusPage({ title: 'Report' });
const stack = new LoadingStack({ widthRequest: 360, heightRequest: 220 });
stack.setContent(buildReportView());
stack.setError('Could not load the report', 'Check your connection and try again.');
stack.showContent();

loadIntoStack drives it. Pass the stack, a shared LoadToken, a load function (sync or async) and a fill function that renders the result.

TypeScript
import Adw from 'gi://Adw?version=1';
import { LoadToken, loadIntoStack } from '@gjsify/adwaita-app';
interface Report {
readonly rows: readonly string[];
}
const currentYear = new Date().getFullYear();
// Your own async source, and your own renderer for what it returns.
const fetchReport = async (year: number): Promise<Report> => ({ rows: [`Total ${year}`] });
const renderReport = (data: Report): Adw.StatusPage => new Adw.StatusPage({ title: data.rows[0] });
const token = new LoadToken();
function reload(): void {
loadIntoStack({
stack,
token,
load: () => fetchReport(currentYear),
fill: (data) => stack.setContent(renderReport(data)),
onError: (err) => console.error(err),
});
}

Each call takes a fresh ticket from the token. If you click through the sidebar quickly, a slow load whose ticket is no longer current is dropped instead of overwriting the view you are now looking at. loadIntoStack never rejects, so you don’t need a catch around it.

Using your own stack instead of LoadingStack? Give its children the names loading, content and error, or override them with loadingName / contentName / errorName.

These wrap the response-signal Adwaita widgets so you can await them.

TypeScript
import Adw from 'gi://Adw?version=1';
import {
confirmDialog, errorDialog,
registerToastOverlay, showToast,
pickFile, saveFile,
} from '@gjsify/adwaita-app';
// The parent every one of these dialogs is modal to: your own window, not a
// browser `window`. GJS has no such global.
const myWindow = new Adw.ApplicationWindow({ application: new Adw.Application({ applicationId: 'org.example.App' }) });
if (await confirmDialog(myWindow, { heading: 'Delete this report?', destructive: true, defaultResponse: 'cancel' })) {
// the user said yes
}
const err = new Error('Unsupported file format'); // whatever you caught
await errorDialog(myWindow, 'Import failed', String(err));
const myToastOverlay = new Adw.ToastOverlay();
registerToastOverlay(myToastOverlay); // once, while building the window
showToast('Saved.'); // from anywhere afterwards
showToast('Still working…', 0); // 0 seconds = sticky
const path = await pickFile(myWindow, {
title: 'Open project',
filters: [{ name: 'JSON', patterns: ['*.json'] }],
});
const target = await saveFile(myWindow, { title: 'Export', initialName: 'report.csv' });

pickFile and saveFile resolve to null when the user cancels, so a cancel is a normal value and not an exception.

Pair destructive: true with defaultResponse: 'cancel'. A “really delete this?” dialog exists to catch the accidental gesture. Make the confirm response the default and the reflex of pressing Enter to get rid of a dialog performs the deletion instead of escaping it.

readAppDevHooks({ prefix }) reads three environment variables, so you can restart into the view you’re working on instead of clicking there every time.

TypeScript
import { readAppDevHooks, resolveInitialNavIndex } from '@gjsify/adwaita-app';
// Whatever `MYAPP_FILE` should open: your own document store.
const store = { load: (path: string): void => console.log('opening', path) };
const hooks = readAppDevHooks({ prefix: 'MYAPP' });
shell.selectByIndex(resolveInitialNavIndex(NAV, hooks.view));
if (hooks.file) store.load(hooks.file);
if (hooks.debug) console.log('verbose load logging on');
VariableEffect
MYAPP_VIEWNav item id to open on startup. Falls back to the first item when the id is unknown.
MYAPP_FILEA file path your app can auto-load.
MYAPP_DEBUGtrue unless unset, empty, 0, false or no.
Terminal
MYAPP_VIEW=reports gjsify run dist/app.gjs.mjs
MYAPP_VIEW=reports gjsify run --runtime node dist/app.node.mjs
  • Devtools & MCP for screenshotting and driving the running app.
  • Storybook for developing a widget on its own, with live controls.
  • GObject Classes for the registerClass rules your window and widget subclasses follow.
  • Adwaita gallery for the widgets to put inside your views.