This is the third entry in A Field Guide to GTK Widgets — a series about which widget to reach for, when, and what bites you when you do. We’re jumping straight to lists, ahead of layout and navigation, because it’s the material the reference documents worst. Each post stands on its own, and the complete, runnable code for this one lives in the companion repo.


The list you’d build without thinking

Say you’re showing a handful of tasks. The obvious way to do it doesn’t require reading any documentation at all:

let list_box = gtk::ListBox::new();

for task in &tasks {
    let label = gtk::Label::builder()
        .label(&task.title)
        .xalign(0.0)
        .build();
    list_box.append(&label);
}

Loop over your data, build a row, append it. It’s the same instinct you’d bring from almost any UI toolkit that isn’t fully declarative, and for a fixed handful of items it’s completely fine — we’ll come back to exactly when it’s fine later in this post. The trouble starts the moment the data stops holding still.


Where that breaks

A task gets marked done somewhere else in the app. The list needs to re-sort so completed tasks drop to the bottom. A “show completed” toggle needs to filter rows in and out. With the loop-and-append approach, every one of those is a hand-rolled patch to a widget tree you built once and now have to keep in sync by hand: find the right row, remove it, rebuild it, reinsert it at the right index, and don’t get the index wrong or you’ll silently update the wrong task. None of this is exotic; it’s the normal shape of a list that does anything at all, and it’s exactly the kind of code that works fine in testing and comes apart three weeks later.

This is the specific pain GTK4 redesigned around, and it’s worth naming what it replaced. Older GTK did this with GtkTreeView, GtkListStore, and cell renderers — a whole apparatus for exactly this problem, and it’s largely deprecated now. The series intro opens with the story of finding a six-year-old Stack Overflow answer built on exactly that apparatus, getting it working, and only later discovering the toolkit had moved on without telling you. This post is the other half of that story: what actually replaced it, and why it’s better once it clicks.


GListModel: data as the source of truth

The modern answer inverts the relationship. Instead of a widget tree you mutate by hand, you keep your data in a list model — something implementing the GListModel interface — and the view watches it. GListModel isn’t a widget; it’s an interface any GObject can implement to say “I’m an ordered, indexable collection, and I’ll tell you when I change.” The concrete type you’ll reach for constantly is GListStore: a plain, mutable, in-memory list model you own and push items into.

Once data and view are genuinely separate concerns, the files should say so too. This example is small enough to fit in one, but I’ve split it the way I would in a real app instead — the way the companion repo actually has it: task.rs for the data, task_row.rs for turning one task into one row, window.rs for wiring the two into a window. The boundary is easier to see with it actually drawn.

// task.rs
pub struct Task {
    pub title: String,
    pub done: bool,
}

pub fn starting_tasks() -> Vec<Task> {
    vec![
        Task {
            title: "Write the list-mindset post".to_string(),
            done: false,
        },
        Task {
            title: "Record the cover art".to_string(),
            done: false,
        },
        Task {
            title: "Reply to the GTK forum thread".to_string(),
            done: true,
        },
    ]
}

And in window.rs, the store gets built from it:

// window.rs
let store = gio::ListStore::new::<BoxedAnyObject>();
for task in starting_tasks() {
    store.append(&BoxedAnyObject::new(task));
}

Two things stand out here. First, GListStore only holds GObjects, not arbitrary Rust structs — a plain Task can’t go in directly. The usual fix is a real GObject subclass with proper properties, which is the right call for anything long-lived, but that’s its own topic and I’ve already covered the subclassing mechanics in the GNOME/Rust series rather than repeat it here. For a case like this one, where the items are plain data and nothing needs to bind to their properties, glib::BoxedAnyObject is the honest shortcut: it wraps any Rust value as a GObject with no subclassing at all, at the cost of borrow()/borrow_mut() runtime checks instead of compile-time property access. Reach for it when you just need data in a list model; reach for a real subclass when something needs to observe a property changing.

Second, and this is the point of the whole post: you never touch a widget here. task.rs doesn’t import gtk at all — nothing above creates a label or a row. The store is just data. Getting it on screen needs two more pieces: a selection model and a factory. Then the mutation story pays off.


The other half, briefly

GtkListView won’t take a bare list model; it needs a selection model wrapped around it, even if you don’t care about selection at all. NoSelection is the model for exactly that case — a pass-through that reports nothing selectable. And it needs a factory: a recipe for turning one item into one row. That recipe is the entire job of task_row.rs:

// task_row.rs
use std::cell::Ref;

use gtk::glib::BoxedAnyObject;
use gtk::prelude::*;

use crate::task::Task;

pub fn factory() -> gtk::SignalListItemFactory {
    let factory = gtk::SignalListItemFactory::new();

    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(&if task.done {
            format!("✓ {}", task.title)
        } else {
            task.title.clone()
        });
    });

    factory
}

I’m deliberately not unpacking this fully. setup builds a row’s widgets once; bind fills them in with a specific item’s data, and this is the part that catches nearly everyone: GTK recycles rows as you scroll, so bind runs again and again for the same widgets with different items, not just once at creation. That recycling rhythm, the paired unbind step, and the difference between this signal-based factory and the builder-template kind are the entire subject of the next post. For now, treat task_row.rs as plumbing: enough to get something on screen so we can look at what happens when the data underneath it changes.

Back in window.rs, wiring the model to the view is two lines — the selection wrapper, and handing both to the view:

// window.rs
let selection = gtk::NoSelection::new(Some(store.clone()));
let list_view = gtk::ListView::new(Some(selection), Some(task_row::factory()));

The payoff: update the model, not the view

Here’s the whole reason any of this is worth the extra ceremony. With the list built and already on screen, appending to the store is all it takes to add a row — still in window.rs, still nowhere near task_row.rs:

// window.rs
glib::timeout_add_seconds_local(2, move || {
    store.append(&BoxedAnyObject::new(Task {
        title: "Ship the draft".to_string(),
        done: false,
    }));
    glib::ControlFlow::Break
});

Run this and a fourth task appears two seconds after the window opens, with nothing above touching list_view, walking the widget tree, or computing an insertion index. GListStore::append fires the model’s items-changed signal, the GtkListView is already listening for it, and it asks the factory for a row for the new item. The view is a function of the model; you stopped writing the function by hand.

That leaves main.rs almost embarrassingly small — its only job is declaring the three modules and handing GTK an entry point:

// main.rs
use adw::prelude::*;
use gtk::glib;

mod task;
mod task_row;
mod window;

const APP_ID: &str = "dev.fromthearchitect.gtkwidgets.ListMindset";

fn main() -> glib::ExitCode {
    let app = adw::Application::builder().application_id(APP_ID).build();
    app.connect_activate(window::build);
    app.run()
}

That’s deliberate: the file that boots the app shouldn’t be the file that knows how the app works. It’s a small example, so the split is a bit generous for its size — but the boundary is the same one a bigger app needs, and drawing it here is cheaper than retrofitting it later.

This is the shift the field-guide post promised: describe the relationship between data and widget once, and let the toolkit run it. Everything else in this cluster of posts (selection, sorting, filtering, GtkColumnView) is a variation on this same shape.


So which list do you actually build?

None of this means GtkListBox is deprecated, and I want to say that plainly, because it’s easy to walk away from a post like this assuming every list needs to be re-architected. It doesn’t.

GtkListBox (plain widgets, one per row, appended by hand — the thing at the top of this post) is still exactly correct for a short, mostly-static list: a settings page, a handful of radio-style options, a preferences group. Libadwaita builds directly on top of it: AdwActionRow and friends are GtkListBox rows, because that combination is genuinely the right tool for that job, and you’ll see it constantly once this series reaches forms and settings.

The real decision is about scale and volatility, not correctness. Reach for GListModel and GtkListView when the list is long enough to need recycling to scroll well, or when the data moves — items added, removed, or reordered from outside the widget tree, the way tasks completing or articles syncing in the background would. Keep GtkListBox when the list is short and mostly fixed once it’s built. Figuring out which bucket you’re in before you start saves you a rewrite later — and the reference won’t help you here, because neither widget is “wrong.”


Sharp edges

A few things caught me the first time I worked through this pattern. The first one at least fails loudly; the other two don’t — they compile and run fine while getting it wrong.

GtkListView won’t take a bare GListModel. It specifically wants something implementing GtkSelectionModel, so if you skip the NoSelection wrapper assuming you’ll deal with selection later, it doesn’t compile at all — that’s not optional scaffolding, it’s the documented way to say “no selection, and I mean it.”

The second is sneakier, and I checked it rather than take it on faith: mutating an item in place doesn’t touch its row. Swap the append in the demo above for grabbing store.item(0) and flipping its done field with borrow_mut(), and the row just sits there, unchanged — I ran exactly that and watched nothing happen. Nothing tells the view anything happened, because items-changed is a structural signal (items added, removed, or moved), not a property-change signal. To make an existing row reflect new data, you either replace the item outright (remove and reinsert, or GListStore::splice) or move up to a real GObject subclass with a proper property and let the factory bind a notification instead of copying data once. It’s exactly why the demo appends a new task rather than flipping a flag on an existing one — flipping it wouldn’t have shown you anything.

The third is the one worth repeating because it’s the single most common bug report against this API: the factory’s bind runs more than once. It’s not a constructor — it reruns every time GTK recycles a row for a new item, so anything you set up conditionally needs tearing down again in unbind, or it leaks onto the next item that reuses the row. This example is too simple to hit that; a set_label call is idempotent. Almost anything more involved (a signal handler, a CSS class, a spawned task) won’t be, and that’s the entire subject of the next post.


Next up

You’ve now got the mental model and just enough factory code to prove it works. The next post stops hand-waving over that factory: GtkListView properly, the difference between a SignalListItemFactory and a BuilderListItemFactory, and the bind/unbind recycling rhythm in full — including the leak this post’s example was too simple to trigger.

The runnable version of this example is in the companion repocd 02-list-mindset && cargo run. One widget at a time. See you in the next one.