This is Part 5 of a series taking a GNOME app from an empty directory to GNOME Circle. Part 4 replaced our XML templates with Blueprint and grew the window into the real Gazette layout — a sidebar, a content pane, and three typed widget handles waiting for behaviour. This is the post where they get some.
If the GObject machinery in here feels unfamiliar — mod imp, properties, signals — Part 2 is the reference. This is where those patterns stop being theoretical.
The click that goes nowhere
Right now, clicking a sidebar row does nothing. GTK draws a selection highlight, the row picks up its hover state, and that’s it — our code never sees the click. The whole point of the sidebar is that it drives the content pane, and we haven’t wired that yet.
By the end of this post, selecting a row drives everything to its right. The shape I keep coming back to in GTK apps is a chain — model to list to selection to signal to content — and getting those links in the right order is half the work. The other half is interior mutability: where state lives, when RefCell earns its place, when OnceCell does, and why the Rc<RefCell<T>> reflex most of us carry over from non-GUI Rust almost never belongs in a mod imp-shaped app. I’ll take each one where it comes up rather than in the abstract.
Concretely, we’ll build a real Feed GObject with name and uri properties, swap that frozen sidebar row for a gio::ListStore<Feed> seeded with four real feeds, define a custom feed-selected(Feed) signal on the window, and make the content pane react — feed name as the title, URL as the description, the empty-state placeholder showing only when nothing’s selected. That feed-selected signal is the seam Part 6 plugs Tokio into: the synchronous handler we write here gets joined by an asynchronous fetch handler that lives entirely in another file, and designing for that boundary now is the quieter lesson of the post.
Feed, for real
Part 2 sketched a Feed GObject — a title, an items-updated signal, the whole mod imp apparatus. None of it landed in source. We got away with it because Parts 3 through 4 only needed widgets, not data. That ends here. Before we can have a sidebar that means something, we need a model for what the sidebar contains.
Create src/feed.rs:
use gtk::glib;
use gtk::glib::Properties;
use gtk::prelude::*;
use gtk::subclass::prelude::*;
use std::cell::RefCell;
mod imp {
use super::*;
#[derive(Debug, Default, Properties)]
#[properties(wrapper_type = super::Feed)]
pub struct Feed {
#[property(get, set)]
pub name: RefCell<String>,
#[property(get, set)]
pub uri: RefCell<String>,
}
#[glib::object_subclass]
impl ObjectSubclass for Feed {
const NAME: &'static str = "GazetteFeed";
type Type = super::Feed;
}
#[glib::derived_properties]
impl ObjectImpl for Feed {}
}
glib::wrapper! {
pub struct Feed(ObjectSubclass<imp::Feed>);
}
impl Feed {
pub fn new(name: &str, uri: &str) -> Self {
glib::Object::builder()
.property("name", name)
.property("uri", uri)
.build()
}
}
Three things stand out against that Part 2 sketch. I left the signals out: items-updated was the right instinct — feeds change when fresh content arrives — but with no fetching code yet, emitting it would be ceremony for nothing, so it comes back in Part 6 alongside the Tokio fetcher that gives it something to announce. The properties use #[derive(Properties)] paired with #[glib::derived_properties], which is the modern declaration form: the macro generates the properties(), set_property(), and property() boilerplate from the struct fields, where the older tutorials you’ll still find hand-roll glib::ParamSpecString::builder("name").build(). Identical runtime behaviour, dramatically less typing, one fewer place to fat-finger a property name. And each field sits in a RefCell because properties have to be settable through GObject’s runtime API — that demands interior mutability, and RefCell is the simplest answer for fields written occasionally and read everywhere.
One thing about that NAME constant: it’s the GObject type name registered with the runtime. Prefix it with the application — GazetteFeed, not just Feed — so it doesn’t collide with another library’s Feed type. Names are global; collisions abort the process at type registration. Once your app loads a Feed type, no other library in the same process can.
Wire the module into src/main.rs:
mod application;
mod config;
mod feed;
mod window;
use self::application::GazetteApplication;
use self::feed::Feed;
use self::window::GazetteWindow;
Feed is now a real type the rest of the codebase can build on.
A list bound to a model
The sidebar in window.blp currently has one hard-coded row:
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;
};
}
}
That row is frozen in the markup. It can’t grow, shrink, or change without touching the .blp file. Real apps don’t work that way — feeds get added, removed, renamed. The markup needs to describe the shape of a row, and the runtime needs to produce one row per item in some collection. GTK has a name for this pattern: model/view binding.
Strip the inner ListBoxRow out. The new sidebar block:
content: ScrolledWindow {
child: ListBox feed_list {
selection-mode: single;
styles ["navigation-sidebar"]
};
};
Empty ListBox. The rows come from Rust now.
In src/window.rs, add the imports we’ll need and grow the imp struct:
use std::cell::{OnceCell, RefCell};
use adw::subclass::prelude::*;
use gtk::prelude::*;
use gtk::{gio, glib};
use crate::Feed;
const SAMPLE_FEEDS: &[(&str, &str)] = &[
("This Week in GNOME", "https://thisweek.gnome.org/index.xml"),
("LWN.net Headlines", "https://lwn.net/headlines/newrss"),
("Hacker News", "https://hnrss.org/frontpage"),
("From the Architect", "https://fromthearchitect.dev/index.xml"),
];
mod imp {
use super::*;
#[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>,
pub feeds: OnceCell<gio::ListStore>,
pub selected_feed: RefCell<Option<Feed>>,
}
// ... ObjectSubclass impl unchanged
}
Two new fields. In the window’s ObjectImpl::constructed, populate the store and bind it:
impl ObjectImpl for GazetteWindow {
fn constructed(&self) {
self.parent_constructed();
let store = gio::ListStore::new::<Feed>();
for (name, uri) in SAMPLE_FEEDS {
store.append(&Feed::new(name, uri));
}
self.feeds.set(store.clone()).expect("feeds set once");
self.feed_list.bind_model(Some(&store), |item| {
let feed = item.downcast_ref::<Feed>().expect("item is a Feed");
gtk::Label::builder()
.label(feed.name())
.halign(gtk::Align::Start)
.margin_start(12)
.margin_end(12)
.margin_top(6)
.margin_bottom(6)
.build()
.upcast()
});
}
}
bind_model takes the store and a closure, and it accepts our store because gio::ListStore implements gio::ListModel — it binds against that trait, not the concrete type. That indirection is the part I actually care about: it’s what lets me wrap the store in a sort or filter model later without rewriting this call, which is exactly the refactor Gazette grows into once feeds need ordering. The mechanics from there are unremarkable — gtk::ListBox runs the closure once per item at bind time, then again whenever the model’s items-changed signal reports an add or change, and because the closure returns a bare gtk::Widget, ListBox wraps it in a GtkListBoxRow so we never construct one ourselves.
Run the app. The sidebar now shows four feeds — This Week in GNOME, LWN.net Headlines, Hacker News, From the Architect — and you can click between them. Selection still doesn’t do anything, but the rows are real.
One trap worth flagging — and a clarification about what that generic actually buys you. gio::ListStore::new::<Feed>() parameterises the store on the Rust side, and that’s worth doing: it keeps the typed API honest and documents what the store holds. But it’s a Rust-side convenience, not a hard runtime guarantee — at the GObject level the store holds plain glib::Object. So items coming back out of it — through item(), or through the ListModel interface a sort or filter model would hand you — still arrive as glib::Object and need the explicit and_downcast::<Feed>() you’ll see in the selection handler shortly. Parameterise with your concrete type, but treat it as intent and documentation, not as enforcement at the model boundary.
Where state lives
The imp struct now carries two things that aren’t template children: feeds and selected_feed. This is the right place for them. State on a GObject lives in the inner imp struct, never on the outer wrapper.
The reason is structural. GazetteWindow — the outer type — is a thin handle. glib::wrapper! makes it a transparent newtype around a refcounted pointer. Add a field to the outer type and you’ve added a field that won’t be shared between clones of the handle, won’t be visible across closures, and won’t survive being passed back through GTK’s C API. None of those failure modes is loud; the code compiles, the field exists, you set it, and then a second handle to the “same” window can’t see what you wrote.
The inner imp::GazetteWindow, by contrast, is the actual heap-allocated object. There’s exactly one of it per window, all handles point to it, and any code holding a handle can reach into it via obj.imp(). State you want every part of the app to agree on goes there.
This is one of the framework’s rules rather than something you can derive from first principles, and I learned it the slow way — by adding a field to the outer type, setting it, and then losing an afternoon to state that silently wasn’t shared between two handles to the “same” window. Memorise it — state lives in imp — and a surprising number of the architectural questions you might have about a GTK Rust app stop being questions.
Which cell?
feeds: OnceCell<gio::ListStore> and selected_feed: RefCell<Option<Feed>> — two different cells for two different lifetimes, and the difference is the whole point. The feeds store is built once in constructed() and read forever after, never replaced; that’s exactly what OnceCell is for, and its set returning Err when it’s already been set means the code asserts that contract for me instead of trusting me to remember it. The selected_feed changes every time the user picks a different row, so it needs the ongoing mutability RefCell gives. (A third category needs no cell at all: values the GObject machinery sets once at construction — template children, derived properties — that your own code never touches again.)
RefCell adds a runtime borrow check: borrow() and borrow_mut() panic if you take incompatible borrows at once. In this domain that’s exactly what I want — GTK callbacks run on the main thread one at a time, so the panic only fires when my code has gone re-entrant in a way I didn’t notice, and the first time it happened to me it pointed straight at a bug class I’d otherwise have spent an afternoon bisecting.
Notice the gio::ListStore itself isn’t wrapped in RefCell, even though we mutate it constantly with store.append(...). ListStore is a GObject, and GObjects do their own reference counting and stay mutable through their GObject API without needing Rust-side cells. Wrapping it would compile but be redundant — you’d be borrow-checking access to a pointer.
My rule of thumb, after enough of these: template children and properties are naked fields with the right macro attribute; anything that owns a GObject — a gio::ListStore, another widget — is a naked field or an OnceCell, never a RefCell; a Rust value that genuinely mutates gets a RefCell, or a Cell if it’s Copy; and a value set once and read everywhere gets a OnceCell. Once that split is reflexive, most of the “which wrapper goes here?” questions stop being questions.
Wiring selection, and the clone! macro
The rows render but selecting them does nothing. Wire selection up:
let obj = self.obj();
self.feed_list.connect_row_selected(glib::clone!(
#[weak] obj,
move |_, row| {
let feed = row
.and_then(|row| row.index().try_into().ok())
.and_then(|i: u32| obj.imp().feeds.get().unwrap().item(i))
.and_downcast::<Feed>();
obj.imp().selected_feed.replace(feed.clone());
if let Some(feed) = feed {
obj.emit_by_name::<()>("feed-selected", &[&feed]);
}
}
));
(We define the feed-selected signal in the next section. Pretend it exists for a moment.)
The glib::clone! macro is doing a lot of work. Without it, the equivalent is:
let obj_weak = obj.downgrade();
self.feed_list.connect_row_selected(move |_, row| {
let Some(obj) = obj_weak.upgrade() else { return };
// ... rest of the closure ...
});
The macro hides the downgrade() / upgrade() dance. The #[weak] attribute means the closure holds a weak reference to obj: it doesn’t keep the window alive. When the closure runs, the macro tries to upgrade the weak reference; if the window is gone, the closure returns early.
The alternative is #[strong], which captures by clone, incrementing the refcount. You almost never want that for a closure connected to one of the window’s own widgets, because it builds a reference cycle:
- The window owns the widget.
- The widget owns the signal handler (the closure).
- The closure now strongly owns the window.
The window can’t drop because the widget’s closure points to it; the widget can’t drop because the window owns it; the closure can’t drop because the widget holds it. Your app appears to leak windows on close. #[weak] breaks the cycle — the closure points weakly back at the window, and when the window’s last strong reference is released, the chain unwinds cleanly.
This is the most common GTK Rust memory leak I run into, and it hides well: most apps have one window for their whole lifetime, so nothing looks wrong until you write a multi-window app — or, in my case, a test that spun windows up and tore them down in a loop, where the slowly climbing memory finally gave it away. The fix is always the same: weak the captures into widget-connected closures.
(One bit of translation if you’re reading older code: the clone!(@weak obj => move |...| {...}) syntax was deprecated in glib 0.20. The #[weak] attribute form is the current spelling. They behave identically.)
A custom signal on GazetteWindow
The selection handler emits feed-selected, but we haven’t defined it. Adding a custom signal is the same pattern as Part 2’s Feed::items-updated, plugged into the window instead of the model.
In mod imp in src/window.rs, give ObjectImpl a signals() method:
use std::sync::OnceLock;
use glib::subclass::Signal;
impl ObjectImpl for GazetteWindow {
fn signals() -> &'static [Signal] {
static SIGNALS: OnceLock<Vec<Signal>> = OnceLock::new();
SIGNALS.get_or_init(|| {
vec![Signal::builder("feed-selected")
.param_types([Feed::static_type()])
.build()]
})
}
fn constructed(&self) {
self.parent_constructed();
// ... store setup, bind_model, connect_row_selected ...
}
}
signals() is a static method GObject calls once, when the type is first registered, to learn what signals it defines. The OnceLock cache is conventional — GObject doesn’t require it, but it means the vec! allocation only runs once. (Note this is std::sync::OnceLock, the thread-safe one: a static has to be Sync, so the single-threaded std::cell::OnceCell we reached for in the imp fields wouldn’t compile here — same family, different guarantees.) Our signal is named "feed-selected" and carries one parameter, a Feed.
That’s enough to make the emit_by_name call in the selection handler legal. But a signal with no listeners does nothing — so let’s give it a consumer. The handler decides what to do when a feed is selected: update the placeholder.
let obj = self.obj();
obj.connect_closure(
"feed-selected",
false,
glib::closure_local!(
#[weak] obj,
move |_window: &super::GazetteWindow, feed: Feed| {
let imp = obj.imp();
imp.placeholder.set_icon_name(Some("rss-symbolic"));
imp.placeholder.set_title(&feed.name());
imp.placeholder.set_description(Some(&feed.uri()));
}
),
);
A few new pieces:
connect_closureis the lower-level form of signal connection. We need it because the signal is custom — there’s no auto-generatedconnect_feed_selectedmethod, so we connect by name.glib::closure_local!wraps the Rust closure in aglib::Closurethe GObject system can store. It’s the bridge between Rust closures and GObject’s signal machinery, and it accepts the same#[weak]/#[strong]attributes asclone!.falseis theafterflag — whether this handler runs before or after the other handlers connected to the signal. It’s easy to read it as “after the default handler,” but that’s a separate mechanism (the handler baked into the type definition);afteronly orders you against the other external connections. With a single handler it makes no difference; by convention, passfalseunless you specifically need to run last.
Two handlers now exist on feed-selected: the implicit one in connect_row_selected that emits it, and the explicit one in connect_closure that consumes it. Neither side names the other; the signal is all they share.
Run the app. Click This Week in GNOME; the content pane updates to show the feed name as the title and the URL as the description. Click Hacker News; it switches. Selection has meaning now.
When Rc<RefCell<T>> isn’t the answer
The instinct, especially coming from non-GUI Rust, is to reach for Rc<RefCell<T>> whenever two closures need to share mutable state — it’s the canonical answer to “shared mutable state without locks,” and my first couple of GTK apps were quietly littered with it.
Suppose both connect_row_selected and the feed-selected handler wanted to read the currently-selected Feed. The wrong instinct produces this:
// Don't do this.
let selected = std::rc::Rc::new(std::cell::RefCell::new(None::<Feed>));
let selected_clone = selected.clone();
self.feed_list.connect_row_selected(move |_, row| {
*selected_clone.borrow_mut() = /* derive feed from row */ None;
});
let selected_clone = selected.clone();
obj.connect_closure("feed-selected", false, glib::closure_local!(
move |_window: &super::GazetteWindow, _feed: Feed| {
let _current = selected_clone.borrow();
// ...
}
));
This compiles, runs, and is reasonable Rust outside the GTK context. Inside it, the state belongs in imp:
self.feed_list.connect_row_selected(glib::clone!(
#[weak] obj,
move |_, row| {
let feed = /* derive feed from row */;
obj.imp().selected_feed.replace(feed);
}
));
Both closures already reach the same selected_feed through obj.imp(). There’s no need for an additional Rc — GObject’s reference counting is the Rc. There’s no need for a separate RefCell — the field is already in one on the imp struct.
The rule of thumb: any time you find yourself typing Rc<RefCell<T>> inside an imp block, stop. The state you’re trying to share lives on the imp; closures reach it through obj.imp(); the GObject lifecycle handles the sharing. It can be appropriate at the edges of a system — prototyping a small example without a wrapping GObject — but inside a real GObject-based architecture, it’s a smell.
Binding the view to state
The placeholder updates imperatively inside the feed-selected handler — three set_* calls per selection. bind_property promises to replace that with a declarative link, and the tempting move is to drop it straight into the handler:
// Tempting, and wrong.
feed.bind_property("name", &*imp.placeholder, "title")
.sync_create()
.build();
This compiles and looks right — select a feed, the title updates — and I shipped exactly this shape once and didn’t notice for weeks. The catch is that the handler runs once per selection, so every click builds a fresh glib::Binding and nothing tears down the previous one. Click between four feeds and the placeholder’s title now has four live bindings pointing at it, each holding a reference to a feed. They stay quiet only because a feed’s name never changes after creation — and the moment it does, which is exactly what Part 6’s fetcher will make happen, every stale binding fires and an unselected feed can overwrite the selected one’s title. A binding built in a per-event handler is a leak with a delay on it.
The fix isn’t to manage the binding’s lifetime by hand. It’s to notice what the placeholder actually is: a pure function of which feed is selected. That’s state, and we have a rule for state — it lives in imp. Promote selected_feed from a plain field to a GObject property, and the placeholder can bind to it once, for the life of the window.
Add the Properties derive to the window’s imp struct and annotate the field:
#[derive(Debug, Default, gtk::CompositeTemplate, glib::Properties)]
#[properties(wrapper_type = super::GazetteWindow)]
#[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>,
pub feeds: OnceCell<gio::ListStore>,
#[property(get, set, nullable)]
pub selected_feed: RefCell<Option<Feed>>,
}
Add #[glib::derived_properties] above the window’s impl ObjectImpl block — the same pairing Feed already uses. nullable is what lets the property hold None: nothing is selected at startup, and the user can clear the selection later.
The selection handler now sets the property instead of replacing the cell directly. Going through the generated setter is what emits notify::selected-feed, and that notification drives the binding:
self.feed_list.connect_row_selected(glib::clone!(
#[weak] obj,
move |_, row| {
let feed = row
.and_then(|row| row.index().try_into().ok())
.and_then(|i: u32| obj.imp().feeds.get().unwrap().item(i))
.and_downcast::<Feed>();
obj.set_selected_feed(feed.as_ref());
if let Some(feed) = feed {
obj.emit_by_name::<()>("feed-selected", &[&feed]);
}
}
));
Then bind the placeholder to the property once, in constructed:
obj.bind_property("selected-feed", &*self.placeholder, "title")
.transform_to(|_, feed: Option<Feed>| {
Some(feed.map_or_else(
|| "No Feed Selected".to_string(),
|feed| feed.name(),
))
})
.sync_create()
.build();
obj.bind_property("selected-feed", &*self.placeholder, "description")
.transform_to(|_, feed: Option<Feed>| {
Some(feed.map_or_else(
|| "Select a feed from the sidebar to see its articles.".to_string(),
|feed| feed.uri(),
))
})
.sync_create()
.build();
transform_to runs whenever the source changes, mapping the selected Feed to a string for the target. The None arm matters as much as the Some arm: when the selection clears, the binding restores the empty-state text — the placeholder handles deselection for free, with no code in the handler. sync_create() copies the current value (None at startup) the moment the binding is built, which is why the placeholder reads “No Feed Selected” before anything is clicked.
With the placeholder now driven by the property, the feed-selected placeholder handler has nothing left to do — delete it. And that deletion sharpens the point about routing through a signal. Reflecting state in the UI — the title just following the selection — is a binding’s job: declarative, set up once. Acting on a change — fetching the feed, logging, anything with a side effect — is a signal’s job, and feed-selected stays exactly for that. The placeholder was never really a signal consumer; it was a view of state, and now it’s wired that way.
So what does listen to feed-selected now? For the moment, a logger — proof the seam works:
obj.connect_closure(
"feed-selected",
false,
glib::closure_local!(
move |_window: &super::GazetteWindow, feed: Feed| {
eprintln!("feed selected: {} ({})", feed.name(), feed.uri());
}
),
);
Run, click feeds, watch the terminal. Two independent things now happen on every selection: the placeholder updates through the property binding, and the log prints through the signal handler — and neither is wired to the other. Part 6 connects a second handler to the same signal, an async fetcher that reads the feed’s URL, and it slots in without the binding or this logger ever knowing it arrived.
Sharp edges
A few things that bite, all on the GObject-runtime seam where Rust’s compile-time guarantees stop and GObject’s runtime conventions begin.
Signal names aren’t checked
emit_by_name::<()>("feed-selected", ...) compiles fine if you typo the name. It fails at runtime when the type system can’t find a signal by that exact string, and panics with a message about a name lookup miss. There’s no compile-time check. The remedies: care, integration tests that exercise every signal path, or const-ing the name in one place and using it in both signals() and the emit sites.
connect_row_selected fires with None on deselection
When the selection clears — programmatically, or because something else takes focus — row is None. Code that always assumes a feed exists goes quietly wrong. Branch on row early; the Option is part of the API for a reason.
Borrow panics inside signal handlers
If you hold a RefCell borrow across a signal emission and a handler for that signal tries to borrow the same cell, you panic — signal handlers run synchronously, so the emit doesn’t return until they’re done. The shape that triggers it: let mut state = self.thing.borrow_mut(); followed immediately by obj.emit_by_name::<()>(...), still inside that borrow. The fix is to scope the borrow tightly so it ends before you emit. (The derived property setters are safe here: each scopes its own borrow_mut, writes, and returns before GObject emits notify.)
Kebab-case at the GObject level, snake_case in Rust
A struct field selected_feed becomes the property selected-feed in bind_property and friends, with accessors selected_feed() / set_selected_feed(...) on the Rust side. The derive macro does the conversion. Mistype the kebab-case form — bind_property("selected_feed", ...) — and the binding fails to find a property by that name. Not silently: GLib prints a g_warning to stderr and the binding does nothing.
What we have so far
Selecting a feed does something. The structure model → list → selection → signal → content is in place, and four real RSS feeds populate the sidebar at startup. The state we added — feeds: OnceCell<gio::ListStore>, selected_feed as a nullable property — lives in imp, where the GObject machinery already manages sharing across closures and lifetimes. Rc<RefCell<T>> doesn’t appear anywhere in this codebase, and probably won’t.
The feed-selected signal is a deliberate seam. The placeholder doesn’t even use it — selection state flows through a property binding instead, because reflecting state and reacting to events are different jobs. The signal is for reacting: right now a single logging handler listens.
What comes next
That logging handler is a placeholder for the real one. Part 6 — Fetching Feeds — connects an asynchronous fetcher to feed-selected: it hits each feed’s URL, parses the RSS or Atom response, and emits the items-updated signal we deferred back at the top of this post as articles arrive.
That fetcher needs a runtime GTK doesn’t have. Part 6 explains why GTK is single-threaded around a single main loop, why blocking that loop freezes the window, and how the two-executor pattern lets Tokio do network work without ever touching a widget on the wrong thread. The seam we built here is what it plugs into.
The source code at the end of this post lives on the part-5 branch of fromthearchitect/gnome-rust-gazette.
