This is Part 4 of a series taking a GNOME app from an empty directory to GNOME Circle. Part 3 walked through every file Builder generated for our gazette project. Now we’re going to start changing things.

If you’re new to this stack and wondering why GTK and libadwaita are separate libraries, why GObject’s type system feels like 1990s C, or why Flatpak ships its own runtime alongside your app, there’s a short companion piece on the history of the stack. Skim it for context or skip it for code.


The XML problem

At the end of Part 3, we had a working app. A header bar, a hamburger menu, a “Hello, World!” label, and a window.ui file that looked like this:

<?xml version="1.0" encoding="UTF-8"?>
<interface>
  <requires lib="gtk" version="4.0"/>
  <requires lib="Adw" version="1.0"/>
  <template class="GazetteWindow" parent="AdwApplicationWindow">
    <property name="title" translatable="yes">Gazette</property>
    <property name="default-width">800</property>
    <property name="default-height">600</property>
    <property name="content">
      <object class="AdwToolbarView">
        <child type="top">
          <object class="AdwHeaderBar">
            <child type="end">
              <object class="GtkMenuButton">
                <property name="primary">True</property>
                <property name="icon-name">open-menu-symbolic</property>
                <property name="tooltip-text"
                          translatable="yes">Main Menu</property>
                <property name="menu-model">primary_menu</property>
              </object>
            </child>
          </object>
        </child>
        ...

Read that aloud. Notice how much of it is structural noise — <object class="...">, <property name="...">, opening and closing tags wrapping single values. The actual information — “there’s a header bar with a menu button on the right” — is buried under a layer of XML scaffolding. And we haven’t even built any UI yet.

This isn’t a fixable problem in XML. It’s how XML works. So GNOME has a different answer.


Blueprint

Blueprint is a markup language built for GTK. It compiles to the same .ui XML GTK has always loaded — the runtime artefact is identical — but the file you actually write looks like real code:

using Gtk 4.0;
using Adw 1;

template $GazetteWindow : Adw.ApplicationWindow {
  title: _("Gazette");
  default-width: 800;
  default-height: 600;

  content: Adw.ToolbarView {
    [top]
    Adw.HeaderBar {
      [end]
      MenuButton {
        primary: true;
        icon-name: "open-menu-symbolic";
        tooltip-text: _("Main Menu");
        menu-model: primary_menu;
      }
    }

    content: Label label {
      label: _("Hello, World!");

      styles ["title-1"]
    };
  };
}

menu primary_menu {
  section {
    item {
      label: _("_Preferences");
      action: "app.preferences";
    }
    item {
      label: _("_Keyboard Shortcuts");
      action: "app.shortcuts";
    }
    item {
      label: _("_About Gazette");
      action: "app.about";
    }
  }
}

Same widget tree. Same template binding. Same translatable strings. About a third the line count, and you can actually scan the structure without your eyes glazing over.

Two bits of syntax are worth knowing up front. The $ on $GazetteWindow is there because it’s a user-defined type — the compiler can’t validate it against a .gir, so the $ is your acknowledgement that you know what you’re doing. Built-in types like Adw.ApplicationWindow never need it. And [top], [end] are child types, equivalent to <child type="top"> in XML, placed immediately before the child they apply to. The rest — using for namespace imports, _("...") for translatable strings, styles ["..."] for CSS classes — you can pick up by porting.


Wiring Blueprint into the build

Blueprint is a separate compiler, not a built-in GTK feature. Before we change any UI, we need to teach Meson how to invoke it and update the GResource manifest to bundle the generated .ui files instead of the original ones.

Is blueprint-compiler available?

blueprint-compiler ships in the GNOME 50 SDK (which we upgraded to at the end of Part 3), so inside the Flatpak build sandbox we don’t need to install anything. If you’re targeting an older runtime where it isn’t included, you’d add it as a module in the Flatpak manifest:

"modules" : [
    {
        "name" : "blueprint-compiler",
        "buildsystem" : "meson",
        "cleanup" : ["*"],
        "sources" : [
            {
                "type" : "git",
                "url" : "https://gitlab.gnome.org/GNOME/blueprint-compiler.git",
                "tag" : "v0.20.4"
            }
        ]
    },
    {
        "name" : "gazette",
        ...
    }
]

For us on GNOME 50, that block isn’t necessary. Just confirming the binary exists is enough.

Updating src/meson.build

Open src/meson.build. We need to add a custom_target that runs blueprint-compiler batch-compile over our .blp files and produces matching .ui files in the build directory. Then we point the existing gnome.compile_resources call at those generated files via a dependency.

Here’s the relevant addition, sitting just above the existing compile_resources call:

blueprints = custom_target('blueprints',
  input: files(
    'window.blp',
    'shortcuts-dialog.blp',
  ),
  output: '.',
  command: [
    find_program('blueprint-compiler'),
    'batch-compile',
    '@OUTPUT@',
    '@CURRENT_SOURCE_DIR@',
    '@INPUT@',
  ],
)

gnome.compile_resources('gazette',
  'gazette.gresource.xml',
  dependencies: blueprints,
  gresource_bundle: true,
  install: true,
  install_dir: pkgdatadir,
)

The dependencies: blueprints line is what ties the two together. Without it, Meson might try to compile resources before Blueprint has produced the .ui files, and you’ll get a mystifying “file not found” error mid-build.

gazette.gresource.xml is unchanged

The resource manifest still references window.ui and shortcuts-dialog.ui. It doesn’t mention .blp at all — and that’s the part that catches people.

<?xml version="1.0" encoding="UTF-8"?>
<gresources>
  <gresource prefix="/io/github/fromthearchitect/gazette">
    <file preprocess="xml-stripblanks">window.ui</file>
    <file preprocess="xml-stripblanks">shortcuts-dialog.ui</file>
  </gresource>
</gresources>

That’s because gnome.compile_resources looks for the listed files first in the source directory, then in the build directory — and our custom_target writes the generated .ui files into the build directory at exactly the right relative path. The Rust code referencing /io/github/fromthearchitect/gazette/window.ui doesn’t know or care that the file went through a Blueprint compile step on the way in.

This is the bit I want you to internalise: Blueprint is purely a build-time concern. The runtime artefact is identical. If anything goes wrong at runtime, it’s a GTK problem, not a Blueprint problem — and the error messages will reference XML structures because that’s what GTK sees.

Updating POTFILES.in

Open po/POTFILES.in. Builder’s scaffold lists one source file alongside the desktop integration files:

data/io.github.fromthearchitect.gazette.desktop.in
data/io.github.fromthearchitect.gazette.metainfo.xml.in
data/io.github.fromthearchitect.gazette.gschema.xml
src/window.ui

Two changes. First, point at window.blp instead of window.ui. Second, add shortcuts-dialog.blp — Builder doesn’t list it, but the strings in there need extracting too:

data/io.github.fromthearchitect.gazette.desktop.in
data/io.github.fromthearchitect.gazette.metainfo.xml.in
data/io.github.fromthearchitect.gazette.gschema.xml
src/shortcuts-dialog.blp
src/window.blp

Why point at the .blp files rather than the generated .ui files? Gettext extracts strings by parsing source files for known patterns — _("...") in Blueprint, translatable="yes" in XML, gettext!() in Rust, etc. If POTFILES.in lists the generated .ui files, extraction will work, but the line numbers in your .po files will reference auto-generated paths in the build directory that change between builds. Pointing at the .blp source means translators see meaningful filenames and stable line numbers.

The Meson i18n module’s xgettext invocation already scans for _(...) and C_(...) calls regardless of file type, so listing .blp files works without any Blueprint-specific extractor.


Porting the existing UI files

Now the actual conversion. Two files: window.ui and shortcuts-dialog.ui.

If you had dozens of files to convert, you’d run blueprint-compiler port from the project root and it would scan the project, generate .blp versions of every .ui file, and update the references for you. We’ve got two files, so it’s just as quick to do it by hand and learn the syntax in the process.

window.blp

Delete src/window.ui. Create src/window.blp:

using Gtk 4.0;
using Adw 1;

template $GazetteWindow : Adw.ApplicationWindow {
  title: _("Gazette");
  default-width: 800;
  default-height: 600;

  content: Adw.ToolbarView {
    [top]
    Adw.HeaderBar {
      [end]
      MenuButton {
        primary: true;
        icon-name: "open-menu-symbolic";
        tooltip-text: _("Main Menu");
        menu-model: primary_menu;
      }
    }

    content: Label label {
      label: _("Hello, World!");

      styles ["title-1"]
    };
  };
}

menu primary_menu {
  section {
    item {
      label: _("_Preferences");
      action: "app.preferences";
    }
    item {
      label: _("_Keyboard Shortcuts");
      action: "app.shortcuts";
    }
    item {
      label: _("_About Gazette");
      action: "app.about";
    }
  }
}

Compare against the original XML if you want — every property, child, and string carries across one-to-one.

The one thing worth pointing out is Label label. In Blueprint, when you want to give a widget an ID (so the Rust side can grab it as a TemplateChild), you put the ID after the type, with no id: keyword. It feels weird at first if you’re coming from XML’s <object class="GtkLabel" id="label">, but it’s consistent with how the rest of the language treats names.

shortcuts-dialog.blp

Builder’s template for shortcuts-dialog.ui is built around Gtk.ShortcutsWindow, which was deprecated in GTK 4.18; libadwaita 1.8 shipped a successor, Adw.ShortcutsDialog. Since Part 3 put us on GNOME 50 (libadwaita 1.9), we’ll port to the modern widget while we’re already in the file.

Delete src/shortcuts-dialog.ui and create src/shortcuts-dialog.blp:

using Gtk 4.0;
using Adw 1;

Adw.ShortcutsDialog shortcuts_dialog {
  Adw.ShortcutsSection {
    title: C_("shortcut window", "General");

    Adw.ShortcutsItem {
      title: C_("shortcut window", "Show Shortcuts");
      action-name: "app.shortcuts";
    }

    Adw.ShortcutsItem {
      title: C_("shortcut window", "Quit");
      action-name: "app.quit";
    }
  }
}

C_("context", "string") is the Blueprint equivalent of XML’s <attribute name="label" translatable="yes" context="shortcut window"> — gettext context lets translators distinguish the same English word used in different UI contexts. Builder generates these for shortcut dialogs because “General” might translate differently depending on whether it’s a settings category or a shortcut group.

The other thing worth knowing about this file is the widget ID. shortcuts_dialog is what AdwApplication looks for: if a shortcuts-dialog.ui resource exists at the application’s resource base path and its root widget is an AdwShortcutsDialog with that ID, the application class automatically installs an app.shortcuts action that presents the dialog, plus a Ctrl+? accelerator. That’s why Part 3’s menu could reference app.shortcuts even though we never defined it ourselves.

The migration mapping, if you’re updating an older project: Gtk.ShortcutsWindowAdw.ShortcutsDialog, Gtk.ShortcutsGroupAdw.ShortcutsSection, Gtk.ShortcutsShortcutAdw.ShortcutsItem. The old “view → section → group → shortcut” hierarchy collapsed to “section → item”. Two features didn’t survive the trip: gesture shortcuts and per-shortcut icons. If you needed those, you stay on the deprecated widget. We don’t, so we don’t.

Gazette’s keyboard shortcuts dialog rendered as an Adw.ShortcutsDialog, showing the General section with Show Shortcuts and Quit entries

Build it

Hit Run. If everything is wired correctly, the app builds and launches looking exactly like it did at the end of Part 3.

If you get build errors, the most common causes are:

  • Forgot the dependencies: blueprints line — error references window.ui not found.
  • Typo in a Blueprint file — the compiler error is good, but it points at line numbers in the .blp, not the generated XML.
  • Old .ui file still on disk — if you copied the .blp instead of replacing the .ui, both exist and the resource bundle will pick up whichever Meson finds first.

A clean rebuild (Builder’s Build → Clean Build Output) clears up most of these.


Now we have a foundation. Let’s actually use it.

A “Hello, World!” label is fine for proving the pipeline works. It is not Gazette. Gazette needs a sidebar of feeds and an articles pane. On a phone, only one shows at a time.

Libadwaita has a widget for this exact pattern: Adw.NavigationSplitView. Side-by-side on a wide screen, single-pane navigation when collapsed — at least, once you wire up the breakpoint that tells it when to collapse. We’ll get to that.

Replace the contents of window.blp with this:

using Gtk 4.0;
using Adw 1;

template $GazetteWindow : Adw.ApplicationWindow {
  title: _("Gazette");
  default-width: 1000;
  default-height: 700;

  Adw.Breakpoint {
    condition ("max-width: 600sp")

    setters {
      split_view.collapsed: true;
    }
  }

  content: Adw.NavigationSplitView split_view {
    sidebar: Adw.NavigationPage {
      title: _("Feeds");

      child: Adw.ToolbarView {
        [top]
        Adw.HeaderBar {}

        content: ScrolledWindow {
          child: ListBox feed_list {
            selection-mode: single;

            styles ["navigation-sidebar"]

            ListBoxRow {
              child: Label {
                label: _("All Articles");
                halign: start;
                margin-start: 12;
                margin-end: 12;
                margin-top: 6;
                margin-bottom: 6;
              };
            }
          };
        };
      };
    };

    content: Adw.NavigationPage {
      title: _("Articles");

      child: Adw.ToolbarView {
        [top]
        Adw.HeaderBar {
          [end]
          MenuButton {
            primary: true;
            icon-name: "open-menu-symbolic";
            tooltip-text: _("Main Menu");
            menu-model: primary_menu;
          }
        }

        content: Adw.StatusPage placeholder {
          icon-name: "rss-symbolic";
          title: _("No Feed Selected");
          description: _("Select a feed from the sidebar to see its articles.");
        };
      };
    };
  };
}

menu primary_menu {
  section {
    item {
      label: _("_Preferences");
      action: "app.preferences";
    }
    item {
      label: _("_Keyboard Shortcuts");
      action: "app.shortcuts";
    }
    item {
      label: _("_About Gazette");
      action: "app.about";
    }
  }
}

The window is bigger by default — 1000x700 instead of 800x600 — to give two panes room to breathe. Adw.NavigationSplitView replaces the single Adw.ToolbarView, with a sidebar and a content, each holding an Adw.NavigationPage. Each pane gets its own Adw.ToolbarView and Adw.HeaderBar — the libadwaita pattern is that every pane owns its own chrome, so when the layout collapses on narrow screens each one has a working header bar with title and back button. The sidebar’s ListBox carries the navigation-sidebar style class for the standard look; the content pane shows an Adw.StatusPage empty state using rss-symbolic from adwaita-icon-theme. The hamburger menu has moved to the content pane’s header bar — that’s where it lives in most GNOME apps with this layout (Files, Music, Lollypop, Apostrophe).

The bit that catches people: Adw.NavigationSplitView doesn’t collapse on its own. Drop the window narrower without an Adw.Breakpoint and it just stays side-by-side, ignoring you. The breakpoint is what flips split_view.collapsed to true when the window narrows below 600 scaled pixels — adaptive behaviour is a property you set, not something the widget figures out. The sp unit (scaled pixels) means the threshold respects the user’s text scaling factor, so the layout collapses at the same effective width regardless of how big they’ve made their fonts.

Hit Run again.

Gazette running with the new layout: Feeds sidebar on the left with a single All Articles row, and an Articles pane on the right showing a No Feed Selected status page

The hamburger menu wires up unchanged — same app.shortcuts, app.about, and (still unimplemented) app.preferences actions Builder generated in Part 3:

Gazette’s primary menu open from the content pane’s header bar, showing Preferences, Keyboard Shortcuts, and About Gazette entries

And dragging the window narrower trips the breakpoint, collapsing the split view to single-pane navigation:

Gazette in narrow width with the split view collapsed — only the Feeds sidebar is visible, with a single All Articles row

The Rust side

Open src/window.rs. Right now it has one TemplateChild for the label we just deleted:

#[derive(Debug, Default, gtk::CompositeTemplate)]
#[template(resource = "/io/github/fromthearchitect/gazette/window.ui")]
pub struct GazetteWindow {
    #[template_child]
    pub label: TemplateChild<gtk::Label>,
}

Two things to update. First, the label field doesn’t refer to anything any more — there’s no Label label in the new window.blp. Second, we want typed handles to the new widgets so future posts can react to them.

Replace the struct with:

#[derive(Debug, Default, gtk::CompositeTemplate)]
#[template(resource = "/io/github/fromthearchitect/gazette/window.ui")]
pub struct GazetteWindow {
    #[template_child]
    pub split_view: TemplateChild<adw::NavigationSplitView>,
    #[template_child]
    pub feed_list: TemplateChild<gtk::ListBox>,
    #[template_child]
    pub placeholder: TemplateChild<adw::StatusPage>,
}

Note that the #[template(resource = ...)] path is unchanged. We didn’t rename the resource path when we switched to Blueprint — window.ui is still what gets bundled into the gresource at /io/github/fromthearchitect/gazette/window.ui, it’s just generated from window.blp now. Keeping the resource path stable is what lets the rest of the code be oblivious to the source format change.

Build and run. Same window, three new typed handles available for when we need them.


Sharp edges

Editor support

The official Blueprint compiler ships an LSP server. In GNOME Builder, support is built in — you get syntax highlighting, completion, and inline error markers automatically. In VS Code, install the Blueprint extension. In Neovim, configure nvim-lspconfig with blueprint_ls. The LSP knows about every property and signal of every widget — if you mistype defautl-width, you’ll see the squiggle before you save.

Error message lineage

When Blueprint fails to compile, the error references your .blp file with helpful line and column numbers. When GTK fails to load a .ui template at runtime — usually because you referenced a widget that doesn’t exist, or the parent doesn’t match the Rust ParentType — the error references the generated .ui file. The line numbers won’t match your .blp. That’s fine, the structure does, but it catches everyone the first time.

Blueprint can’t express everything

Blueprint covers the GTK Builder XML format completely, but a handful of edge cases require dropping back to either raw XML or runtime Rust code. The most common one I hit is custom GtkBuilder-loaded scriptable types where a property takes a complex serialised value. If you need it, you can mix .ui and .blp files in the same gresource — just list the .ui ones in the gazette.gresource.xml manifest and skip them in the Blueprint custom target.

Don’t commit the generated .ui files

The build directory holds the generated XML. Builder’s default .gitignore already excludes _build/, but if you set up a different build layout, double-check. Committing generated .ui files leads to merge conflicts every time someone tweaks a .blp.


What we have so far

A window.blp and shortcuts-dialog.blp instead of XML. A working sidebar + content layout that’s adaptive on small screens. Three typed widget handles in Rust waiting for behaviour. The same single-instance app, the same gresource path, the same menu, the same translations — but the file you actually edit when you want to change the UI now reads like a tree, not a tag soup.


What comes next

We have widgets. We don’t have any logic. Clicking a row in the sidebar does nothing. Adding a feed isn’t possible. There’s no state.

Part 5 is App Architecture Patterns — how to organise mutable state in a Rust GTK app without losing your mind. The big shape of it: GTK widgets aren’t Send, Rc<RefCell<T>> is everywhere, the clone! macro keeps closures sane, and async work lives on Tokio while UI work lives on the GLib main loop. We’ll set up the two-executor pattern and use it to wire the sidebar selection to the content pane via a real GObject signal.

That’s the post where the patterns from Part 2 stop being theoretical.


The source code at the end of this post lives on the part-4 branch of fromthearchitect/gnome-rust-gazette.