This is Part 2 of a series taking a GNOME app from an empty directory to GNOME Circle. Part 1 covered the why and the dev environment.
The app we're building
Throughout this series, we'll be building Gazette — an RSS reader. I picked it because it naturally exercises every pattern a non-trivial GNOME app needs: networking, data persistence, list/detail UI, adaptive layouts, settings, and state management. You don't need to build an RSS reader to follow along — the patterns are universal.
But before we touch GTK, before we create a window or a header bar, we need to understand the type system that everything in GNOME is built on — GObject. Every hour you spend on it before touching widgets will save you two hours of confusion when your property binding silently doesn't work or your signal callback never fires.
What is GObject and why should you care
Every widget in GTK is a GObject. That sounds like a detail you can safely skip. It isn't — it explains why property bindings work the way they do, why signals don't need explicit subscriber lists, and why half the confusion I see in gtk-rs questions comes from not knowing this layer exists.
GObject gives C what it was never designed to have: objects. You get inheritance, properties that can be observed and bound, a signal system for decoupled events, and reference counting. All achieved through C macros and conventions, which is exactly as readable as it sounds.
The challenge for Rust developers is that these two type systems have fundamentally different ideas about ownership. Rust doesn't have inheritance. GObject is built on it. Rust tracks lifetimes at compile time. GObject uses runtime reference counting. The gtk-rs bindings bridge this gap with a specific pattern — and once you've seen it once, you'll recognise it everywhere.
A simple GObject: the Feed model
We're going to create a Feed type — an RSS feed with a title, a URL, and an unread count. No widgets, no UI. Just a plain GObject that holds data, exposes properties, and emits a signal. This is the simplest way to see every piece of the pattern without distractions.
Setting up
cargo init gobject-example
cd gobject-example
[package]
name = "gobject-example"
version = "0.1.0"
edition = "2021"
[dependencies]
gtk = { package = "gtk4", version = "0.11" }
We only need gtk4 — it exposes glib and gio as gtk::glib and gtk::gio. No libadwaita for this.
The inner/outer type split
Every custom GObject in Rust is two types: an inner type that lives in mod imp and holds the actual state, and a lightweight outer type that's what your code actually touches. GObject owns the inner; the outer is just a reference-counted handle to it.
This split exists because both GObject and Rust want to own the data. GObject manages lifecycles through reference counting. Rust enforces ownership at compile time. The inner/outer pattern resolves the tension — GObject gets ownership of the inner type, and you get a cheaply-cloneable handle to pass around. If you've used Rc<RefCell<T>>, the mental model is similar — though GObject's ref count is atomic, so Arc<RefCell<T>> is closer to the truth. The atomicity is about safe counting, not thread-safe access; GTK objects still need to stay on the main thread.
The inner type
Create src/feed.rs:
mod imp {
use std::cell::{Cell, RefCell};
use std::sync::OnceLock;
use glib::subclass::Signal;
use glib::Properties;
use gtk::glib;
use gtk::prelude::*;
use gtk::subclass::prelude::*;
#[derive(Properties, Default)]
#[properties(wrapper_type = super::Feed)]
pub struct Feed {
#[property(get, set)]
title: RefCell<String>,
#[property(get, set)]
url: RefCell<String>,
#[property(get, set)]
unread_count: Cell<u32>,
}
#[glib::object_subclass]
impl ObjectSubclass for Feed {
const NAME: &'static str = "GazetteFeed";
type Type = super::Feed;
}
#[glib::derived_properties]
impl ObjectImpl for Feed {
fn signals() -> &'static [Signal] {
static SIGNALS: OnceLock<Vec<Signal>> = OnceLock::new();
SIGNALS.get_or_init(|| {
vec![Signal::builder("items-updated")
.param_types([u32::static_type()])
.build()]
})
}
}
}
Let's unpack the important parts.
Properties and interior mutability. Each field marked #[property(get, set)] becomes a GObject property — accessible by string name, bindable, observable. The RefCell<String> and Cell<u32> types are required because GObject's API is &self everywhere, never &mut self. Use Cell<T> for Copy types, RefCell<T> for everything else.
ObjectSubclass. This registers the type with GObject. NAME must be globally unique — duplicates cause a runtime panic. We don't set ParentType because the default is glib::Object. When we get to widgets, we'll set it explicitly.
ObjectImpl and signals. The #[glib::derived_properties] attribute wires up the getters and setters. The signals method defines a custom items-updated signal that carries a u32. Signals are GObject's event system — listeners connect without the emitter knowing who's listening.
The outer type
Still in src/feed.rs, below mod imp:
use glib::Object;
use gtk::glib;
use gtk::prelude::*;
glib::wrapper! {
pub struct Feed(ObjectSubclass<imp::Feed>);
}
impl Feed {
pub fn new(title: &str, url: &str) -> Self {
Object::builder()
.property("title", title)
.property("url", url)
.build()
}
pub fn mark_items_updated(&self, new_count: u32) {
self.set_unread_count(new_count);
self.emit_by_name::<()>("items-updated", &[&new_count]);
}
}
The glib::wrapper! macro generates a thin, reference-counted pointer to the inner type. Clone it freely — it just bumps the ref count.
GObjects are built through the builder pattern, setting properties by name. mark_items_updated deliberately sets the property and emits the signal as a single operation — in a real app, you don't want callers updating the count without notifying listeners.
Using it
In src/main.rs:
mod feed;
use feed::Feed;
use gtk::glib;
use gtk::prelude::*;
fn main() {
let feed = Feed::new("GNOME Planet", "https://planet.gnome.org/atom.xml");
// Read properties
println!("Feed: {} ({})", feed.title(), feed.url());
println!("Unread: {}", feed.unread_count());
// Connect to the signal
feed.connect_closure(
"items-updated",
false,
glib::closure_local!(|_feed: &Feed, count: u32| {
println!("Feed updated — {} new items", count);
}),
);
// Connect to property change notifications
feed.connect_notify(Some("unread-count"), |feed, _| {
println!("Unread count changed to {}", feed.unread_count());
});
// Update the feed
feed.mark_items_updated(5);
// Property binding — one Feed mirroring another's unread count
let mirror = Feed::new("Mirror", "https://example.com/feed.xml");
feed.bind_property("unread-count", &mirror, "unread-count")
.sync_create()
.build();
println!("Mirror unread: {}", mirror.unread_count()); // 5
feed.mark_items_updated(12);
println!("Mirror unread: {}", mirror.unread_count()); // 12
}
Feed: GNOME Planet (https://planet.gnome.org/atom.xml)
Unread: 0
Unread count changed to 5
Feed updated — 5 new items
Mirror unread: 5
Unread count changed to 12
Feed updated — 12 new items
Mirror unread: 12
No gtk::init() call — we're only using GLib objects here, no widgets, no event loop. Synchronous signal emission works fine without one; once you're dispatching across async boundaries, you'll need a running main loop.
Property binding is the one worth sitting with
Properties, signals, notifications — you wire those up manually. Binding is different. It's declarative: state a relationship once and the type system enforces it. When we build Gazette's UI, this is how we'll connect models to widgets — a property changes on a model and the bound widget updates automatically, no event handler code. The first time that happens, you stop thinking in callbacks. That's the shift.
The pattern, summarised
Every custom GObject in Rust follows this structure:
src/my_type.rs
├── mod imp {
│ ├── struct MyType { ... } // State (with Cell/RefCell)
│ ├── impl ObjectSubclass for MyType // Registration (NAME, Type, ParentType)
│ └── impl ObjectImpl for MyType // Lifecycle (properties, signals)
│ └── impl WidgetImpl, etc. // Parent traits (only for widgets)
│ }
├── glib::wrapper! { ... } // Outer type declaration
└── impl MyType { ... } // Public API
Yes, it's a lot of ceremony for a type that holds three fields. That's the price of entry — GObject was designed for C, and bridging two type systems isn't free. But the payoff is that every GObject you write after this one is the same shape. You stop thinking about the structure and start thinking about what the type actually does. The boilerplate becomes invisible by the third or fourth time.
Common mistakes
Forgetting interior mutability — title: String instead of title: RefCell<String> will fail at compile time. GObject gives you &self, never &mut self.
Duplicate NAME strings — runtime panic, not a compile error. Namespace them: GazetteFeed, not Feed.
Property name casing — GObject properties use kebab-case (unread-count), Rust methods use snake_case (unread_count()). When referencing by string — connect_notify, bind_property, Object::builder() — use kebab-case. Get it wrong and it silently fails.
Forgetting #[glib::derived_properties] — without it, the Properties derive generates Rust methods but the GObject type system doesn't know about them. Bindings and notifications won't work.
What comes next
Now that we understand GObject's type system — properties, signals, the inner/outer split — we're ready to apply it. In Part 3, we'll build Gazette's application skeleton: an adw::Application, an adw::ApplicationWindow, a header bar, and a Blueprint template. The GObject pattern is exactly the same; the trait chain is just longer.
The source code for this post is available on GitHub.