This is the fourth entry in A Field Guide to GTK Widgets — a series about which widget to reach for, when, and what bites you when you do. The previous post got a list on screen with a factory it deliberately didn’t explain. This one explains it. The complete, runnable code lives in the companion repo.
Where we left off
The list-mindset post got a GtkListView on screen with just enough factory code to prove the model-driven approach worked, and it said so directly: setup builds a row’s widgets once, bind fills them in, and GTK recycles rows as you scroll — the same widgets get re-bound to different items instead of new rows being built for each one. That recycling is the entire reason GtkListView scrolls well through ten thousand rows without breaking a sweat, and it’s also the thing that breaks people’s mental model the first time they hit it, because nothing in the API shouts about it. You write bind once, it looks like a constructor, and it isn’t one.
This post is the factory in full: all four lifecycle signals, what happens if you skip the ones that don’t feel necessary, and the second way to build a factory that most tutorials skip straight past.
The four signals, not two
SignalListItemFactory exposes four connection points, and the previous post only used two of them. In practice bind is where most of the real logic lives — it attaches a specific item’s data to the widget tree, and runs every time GTK assigns a row slot to an item, including reuse. setup is the one that runs first: it builds that widget tree, once per recycled row slot rather than once per item. unbind undoes whatever bind did, right before the row slot gets reassigned to a different item. teardown means the widget tree is being destroyed for good — it’s the one I reach for least of the four.
// task_row.rs
factory.connect_setup(|_, list_item| {
let label = gtk::Label::builder().xalign(0.0).build();
list_item
.downcast_ref::<gtk::ListItem>()
.unwrap()
.set_child(Some(&label));
});
factory.connect_bind(|_, list_item| {
let list_item = list_item.downcast_ref::<gtk::ListItem>().unwrap();
let boxed = list_item.item().and_downcast::<BoxedAnyObject>().unwrap();
let task: Ref<Task> = boxed.borrow();
let label = list_item.child().and_downcast::<gtk::Label>().unwrap();
label.set_label(&task.title);
if task.done {
label.add_css_class("dim-label");
}
});
That last line is the trap, and it’s the one that got me the first time I hit it. add_css_class is not idempotent the way set_label is — it doesn’t replace a previous state, it accumulates. Scroll this list far enough that a row gets recycled from a done task onto a not-done one, and the class is still there. Nothing crashes. Nothing warns you. You just get a task that renders dim for no reason anyone can see by reading bind on its own, because the bug isn’t in bind — it’s in the signal you didn’t write.
Why unbind is the fix, not bind doing more
The instinct is to patch bind so it handles both cases:
label.set_css_classes(if task.done { &["dim-label"] } else { &[] });
That works here, and for a single CSS class it’s arguably fine. It stops working the moment bind does anything that isn’t purely declarative — a signal handler connected to a button inside the row, a spawned async task tied to this item, a GtkExpression watching a property. Reconnecting a signal handler in bind without disconnecting the previous one doesn’t overwrite it; it stacks a second handler on top of the first, and now the row fires its click callback twice, then three times, then four, silently, for as long as the app runs. It’s the most common factory bug I’ve seen reported, and every instance I’ve traced back had the same root cause: something was set up in bind with no matching teardown in unbind.
The fix is to treat bind and unbind as a pair, symmetrical on purpose:
factory.connect_bind(|_, list_item| {
let list_item = list_item.downcast_ref::<gtk::ListItem>().unwrap();
let boxed = list_item.item().and_downcast::<BoxedAnyObject>().unwrap();
let task: Ref<Task> = boxed.borrow();
let label = list_item.child().and_downcast::<gtk::Label>().unwrap();
label.set_label(&task.title);
if task.done {
label.add_css_class("dim-label");
}
});
factory.connect_unbind(|_, list_item| {
let list_item = list_item.downcast_ref::<gtk::ListItem>().unwrap();
let label = list_item.child().and_downcast::<gtk::Label>().unwrap();
label.remove_css_class("dim-label");
});
unbind runs right before this row slot gets handed to a different item, so anything bind might have done conditionally gets a guaranteed place to be undone unconditionally. It costs a few extra lines for something as small as one CSS class. It’s the difference between a working app and an unreproducible bug report for a connected signal handler, and by the time that bug report shows up, the factory code that caused it is long forgotten.
setup and teardown follow the same pairing, just less often needed: setup builds the widget tree, teardown runs when GTK is discarding that row slot entirely (the list shrank, the view is closing), not merely reassigning it. Most factories never touch teardown because widgets clean up their own children automatically — it exists for things you allocated outside the widget tree and GTK has no way to know about.
The other kind of factory
Everything so far has been SignalListItemFactory: build the row in Rust, imperatively, in a closure. GTK also has BuilderListItemFactory, which takes a .ui file — the same GtkBuilder XML you’d use for a whole window — and uses it as the template for each row:
<!-- task_row.ui -->
<interface>
<template class="GtkListItem">
<property name="child">
<object class="GtkBox">
<property name="spacing">6</property>
<child>
<object class="GtkImage">
<property name="icon-name">task-due-symbolic</property>
</object>
</child>
<child>
<object class="GtkLabel" id="label">
<property name="xalign">0.0</property>
</object>
</child>
</object>
</property>
</template>
</interface>
let bytes = glib::Bytes::from_static(include_bytes!("task_row.ui"));
let factory = gtk::BuilderListItemFactory::from_bytes(gtk::BuilderScope::NONE, &bytes);
The declarative version wins on exactly one axis: rows with real layout — an icon and a label, two labels stacked, a switch on the trailing edge — read as a row when you look at the XML, the way the widget tree reads in Blueprint or .ui for a window. But BuilderListItemFactory isn’t a variant of the setup/bind/unbind/teardown rhythm — it doesn’t have those signals at all. setup, bind, unbind, and teardown belong specifically to SignalListItemFactory; the base GtkListItemFactory class defines none of them, and BuilderListItemFactory doesn’t add any of its own. There is no connect_bind to call on it. Its only way of getting item data onto a row is a GtkExpression binding declared in the template itself — <binding><lookup name="title">item</lookup></binding> — which watches a property on the item and keeps a widget property in sync with it automatically, no Rust callback involved.
That means BuilderListItemFactory requires a real GObject with real properties, full stop. It’s not merely more ceremony for our Task, wrapped as it is in a BoxedAnyObject — it’s off the table entirely, because BoxedAnyObject doesn’t expose title or done as properties an expression can look up, and there’s no imperative hook left to fill that gap by hand. If you want a .ui-described row over a plain-struct payload like ours, the pattern isn’t BuilderListItemFactory — it’s still SignalListItemFactory, with a setup handler that builds the child from a .ui file (via gtk::Builder) or a composite template widget, and bind/unbind wired normally as signals underneath it. You get the markup; you keep the callbacks. Reach for BuilderListItemFactory on its own only once you’ve already paid for a proper GObject subclass with the properties an expression needs; keep plain SignalListItemFactory when the row is a handful of widgets and the logic is the whole story, which is most rows.
Sharp edges
bind running more than once isn’t a bug in your code — it’s a bug in this code, specifically when it manages any state beyond what it can also fully overwrite. If every line in bind reassigns a value unconditionally (set_label, set_visible, set_css_classes with the complete list every time), there’s nothing for unbind to clean up, because nothing accumulates. The moment bind does anything additive — add_css_class, connect_clicked, spawning a task — it needs an unbind that undoes exactly that, or the row slot slowly becomes a superposition of every item that’s ever occupied it.
unbind fires before reassignment, not before destruction. If you’re holding a resource that genuinely needs to outlive one item but not the whole factory — a debounce timer, say — unbind is still the right place to cancel it, not teardown. teardown is rarer than either of the other three; most factories never implement it, and I’ve reached for it out of caution when unbind was what I actually meant, which is itself a small sharp edge.
BuilderListItemFactory has no connect_bind to skip — setup/bind/unbind/teardown are signals on SignalListItemFactory specifically, not on the base GtkListItemFactory class the two factory types share. BuilderListItemFactory’s only binding mechanism is a GtkExpression declared in the template, which needs a real GObject property to watch. With a BoxedAnyObject payload like ours, there’s no property for an expression to look up and no signal to connect instead, so the .ui template route is closed, not just less convenient. That’s easy to miss on a skim of the docs, because the two factory classes look, from their names, like they should be more different than they are, and because most examples you’ll find online use a real GObject model where the expression shortcut is available.
Next up
Rows now behave correctly under recycling, one at a time. The next post is what happens with more than one row selected at once: GtkSelectionModel, the difference between SingleSelection and MultiSelection, and wiring a selection change back out to the rest of the app without reaching into the view to ask it what’s highlighted.
The runnable version of this example is in the companion repo — cd 03-first-factory && cargo run. One widget at a time. See you in the next one.
