惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

H
Help Net Security
J
Java Code Geeks
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
H
Hackread – Cybersecurity News, Data Breaches, AI and More
V
Visual Studio Blog
G
Google Developers Blog
V
V2EX
The Register - Security
The Register - Security
博客园 - 三生石上(FineUI控件)
云风的 BLOG
云风的 BLOG
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
CTFtime.org: upcoming CTF events
CTFtime.org: upcoming CTF events
博客园_首页
S
SegmentFault 最新的问题
博客园 - Franky
Martin Fowler
Martin Fowler
Stack Overflow Blog
Stack Overflow Blog
A
About on SuperTechFans
人人都是产品经理
人人都是产品经理
aimingoo的专栏
aimingoo的专栏
罗磊的独立博客
C
Check Point Blog
MyScale Blog
MyScale Blog
T
The Blog of Author Tim Ferriss
MongoDB | Blog
MongoDB | Blog
The GitHub Blog
The GitHub Blog
Last Week in AI
Last Week in AI
Microsoft Azure Blog
Microsoft Azure Blog
IT之家
IT之家
F
Fortinet All Blogs
Jina AI
Jina AI
P
Proofpoint News Feed
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
阮一峰的网络日志
阮一峰的网络日志
B
Blog
L
LangChain Blog
月光博客
月光博客
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
宝玉的分享
宝玉的分享
博客园 - 【当耐特】
T
Tailwind CSS Blog
酷 壳 – CoolShell
酷 壳 – CoolShell
Microsoft Security Blog
Microsoft Security Blog
WordPress大学
WordPress大学
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
B
Blog RSS Feed
博客园 - 聂微东
Hugging Face - Blog
Hugging Face - Blog
M
MIT News - Artificial intelligence
GbyAI
GbyAI

DEV Community

Authentication Security Deep Dive: From Brute Force to Salted Hashing (With Java Examples) Why AI Systems Don’t Fail — They Drift Spilling beans for how i learn for exam😁"Reinforcement Learning Cheat Sheet" I Replaced Chrome with Safari for AI Browser Automation. Here's What Broke (and What Finally Worked) How Python Borrows Other People's Work The $40 Architecture: Processing 1 Billion API Requests with 99.99% Uptime Vibe Coding: A Workflow Guide (From Zero to SaaS) Most webhook security guides protect the wrong side. The scary part is delivery. Headless CMS for TanStack Start: Build a Blog with Cosmic EU Age Verification App "Hacked in 2 Minutes" — What Actually Happened Comfy Cloud’s delete function does not actually remove files Running AI Models on GPU Cloud Servers: A Beginner Guide Event-driven media intelligence with AWS Step Functions and Bedrock I scored 500 AI prompts across 8 quality dimensions — here's what broke How to Call Google Gemini API from Next.js (Free Tier, No Backend Needed) The Portal Protocol: Reclaiming Human Connection in the Age of AI How to Fix Your Team's Scattered Knowledge Problem With a Self-Hosted Forum Intro to tc Cloud Functors: A Graph-First Mental Model for the Modern Cloud Designing Multi-Tenant Backends With Both Ownership and Team Access I Built a Neumorphic CSS Library with 77+ Components — Here's What I Learned PostgreSQL Performance Optimization: Why Connection Pooling Is Critical at Scale Cómo construí un SaaS multi-rubro para gestionar expensas en Argentina con FastAPI + Vue 3 🚀 I Built an Ethical Hacking Scanner Tool – Open Source Project I Replaced /usage and /context in Claude Code With a Single Statusline A Pythonic Way to Handle Emails (IMAP/SMTP) with Auto-Discovery and AI-Ready Design I Collected 8.9 Million Polymarket Price Points — Here's What I Found About How Markets Really Move EcoTrack AI — Carbon Footprint Tracker & Dashboard Everyone's Using AI. No One Agrees How. 5 self-hosted ebook managers worth trying in 2026 Building Your First AI Agent with LangChain: From Chatbot to Autonomous Assistant Common SOC 2 Failures (Real World) Stop Vibe-Checking Your AI App: A Practical Guide to Evals How to Use SonarQube and SonarScanner Locally to Level Up Your Code Quality Your Next To-Do App Is Dead — I Replaced Mine with an OpenClaw AI Sign a Nostr event in 60 lines of Python using coincurve — no nostr-sdk, no nbxplorer, no rust toolchain ITGC Audit Explained Like You’re in Big 4 Patch Tuesday abril 2026: Microsoft parcha 163 vulnerabilidades y un zero-day en SharePoint Stop scraping everything: a better way to track competitor price changes Listing on MCPize + the Official MCP Registry while routing payments OUTSIDE the marketplace — how I kept 100% of my x402 revenue Building an AI-Powered Risk Intelligence System Using Serverless Architecture Why We Ripped Function Overloading Out of Our AI Toolchain Testing AI-Generated Code: How to Actually Know If It Works SaaS Churn Is Killing Your Business. Here Is What to Do About It (Without a Support Team) The Speed of AI Is No Longer Linear - And Self-Improving Models Are Why How to Implement RBAC for MCP Tools: A Practical Guide for Engineering Teams From Standard Quote to Persuasive Proposal: AI Automation for Arborists I built a CLI that scaffolds complete multi-tenant SaaS apps Axios CVE-2025–62718: The Silent SSRF Bug That Could Be Hiding in Your Node.js App Right Now The dashboard that ended our friendship Data Pipelines Explained Simply (and How to Build Them with Python) The Hidden Cost of AI Systems Nobody Talks About. undefined vs undeclared, and how typeof behaves Switching from file-based jobs to NATS/Kafka in Rust without changing code io_uring Adventures: Rust Servers That Love Syscalls Why Agentic AI is Killing the Traditional Database The POUR principles of web accessibility for developers and designers Quantum Neural Network 3D — A Deep Dive into Interactive WebGL Visualization How To Install Caveman In Codex On macOS And Windows Automation Pipeline Reliability: Why Your Workflow Breaks When Nobody Is Watching I Built an 'Open World' AI Coding Agent — It Works From ANY Folder From Freelancing to Product: A Tech Service Company's SaaS Transformation China's AI Giants: Adding Tencent Hunyuan & ByteDance Doubao to AI University (74 Providers) On the Vibe Coders and Their Lies clerk: Auto-Summarize Your Claude Code Sessions AI Weekly — 2026/04/10–04/17 | The Model Lockdown Is Here, but the Toolchain Is the Real Battleground AI 週報 — 2026/04/10–2026/04/17 模型封鎖潮來了,但工具鏈才是真戰場 Maybe this is how Open-Source apps are born... 🚀 Fine-Tune LLMs with LoRA and QLoRA: 2026 Guide tRPC v11 + Next.js App Router: End-to-End Type Safety Without the Boilerplate ShadCN UI in 2026: Why I Stopped Installing Component Libraries and Started Owning My Components SaaS Billing in React Server Components: Stripe + Supabase Without a Single `useEffect` Join our DEV Weekend Challenge — $1,000 in Prizes Across TEN winners! Submissions Due April 20 at 6:59 AM UTC. Implementing FSRS Spaced Repetition in Flutter + Supabase — Adding Memory Science to an AI Learning App "I Texted My Localhost From the Train — Claude Code Fixed the Bug Before I Got Home" I Built a Sales Prep AI and It Went Deeper Than Expected Design to Code #2: One JSON, Eleven Outputs Solving the 100M-Row Problem: A Summary Table Pattern for High-Volume Push Notification Logs Flutter Web With Wasm: What Actually Changes For Developers I Built 50 Royalty-Free Soundtracks for My Side Project in a Weekend Using AI Music Generation The Vibe Coding Security Checklist: 7 Things to Check Before You Ship Stop Letting Googlebot Guess Fix Your React App's SEO Right Desconstruindo o Streaming do LinkedIn: Como Criar um Engine de Extração de Vídeo de Alta Performance com HLS e FFmpeg (EDA Part-1) EDA (Exploratory Data Analysis) Explained With Real Life — Why Looking at Your Data Is the Most Important Step in Machine Learning Brand Relationship Management at Scale: Our 4-Touch Outreach System for 200+ Brands Why String.fromEnvironment() Might Return an Empty String in Dart JGuardrails 1.0.0 — Hardening Java LLM Apps Against Jailbreaks, Toxicity, and Prompt Injection Plan and Schedule a Full Week of Threads Content From One Claude Conversation Coding Cat Oran Ep3, Five Tables Changed Everything Updated: BFF Pattern I'm done watching freelancers get buried by 200 proposals. So I'm building the alternative. This is my first post BFS Algorithm in Java Step by Step Tutorial with Examples Tracking LLM Pricing Monthly: An Open Dataset for 22 AI Models How We Measure Content ROI on a Comparison Site: Revenue Attribution Without Perfect Data Introducing Nova AI Ops: The AI-Native Operating System for SRE Teams I built a free desktop video downloader for Windows — Grabbit How Talkie OCR Helps Vision-Impaired & Dyslexic Users Read the World Around Them VRCFaceTracking安装和iPhone面捕配置教程,有bug Even CrowdStrike Can't See Your Agents The Automation Gold Rush: What n8n Workflows and Claude Are Opening Up for Developers Right Now
Building GNOME Apps with Rust, Part 4: Blueprint
fromthearchi · 2026-05-08 · via DEV Community

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

Enter fullscreen mode Exit fullscreen mode

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";
    }
  }
}

Enter fullscreen mode Exit fullscreen mode

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",
        ...
    }
]

Enter fullscreen mode Exit fullscreen mode

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,
)

Enter fullscreen mode Exit fullscreen mode

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>

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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";
    }
  }
}

Enter fullscreen mode Exit fullscreen mode

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";
    }
  }
}

Enter fullscreen mode Exit fullscreen mode

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";
    }
  }
}

Enter fullscreen mode Exit fullscreen mode

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>,
}

Enter fullscreen mode Exit fullscreen mode

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>,
}

Enter fullscreen mode Exit fullscreen mode

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.