This is the seventh entry in A Field Guide to GTK Widgets, a series about which widget to reach for, when, and what bites you when you do. It picks up where the filter and sort post left off and replaces the workaround that one ended on. The complete, runnable code lives in the companion repo.


Where we left off

The last post built a task list four models deep: a GListStore at the bottom, then a FilterListModel, a SortListModel and a MultiSelection. It searched, hid done tasks and sorted three ways, and two parts of it were workarounds.

Hiding done tasks had to be a BoolFilter with watch-items switched on, because watch-items only sees properties through filters built from expressions. A CustomFilter was invisible to it. Sorting had nothing at all. SortListModel has no watch-items of its own, so the Mark done button finished by calling sorter.changed(gtk::SorterChange::Different) to make it look again.

That call only helps when the change goes through that button. A sync from another device or an undo would set a task’s done property somewhere else, and the list would sit out of order until something happened to re-sort it.

It also bothered me as an engineer. SorterChange::Different tells the sort model that any comparison it has made might now be wrong, so it sorts the whole list again to place the one task that changed. On ten tasks nobody would notice. A photo library or an email folder is the kind of list these models exist for, tens of thousands of items, and there it’s a full re-sort every time someone stars a photo or reads a message.

This post moves the job down to the store. When a task changes, the store says so, and every model above it re-checks that one task. watch-items goes back to its default of off, the sorter call is deleted, and “hide done” can be a closure again.


Reporting a change in place

Every list model has an items-changed signal carrying three numbers: a position, how many items were removed there, and how many were added. Each model in the stack listens for it on the model below and re-emits it in its own positions, which is how an appended task reaches the view already filtered and sorted.

Nothing stops a model from emitting items_changed(pos, 1, 1) for an item that hasn’t gone anywhere. It reads as one item removed at pos and one added in its place. When the added item is the same object as the removed one, the message is that this item changed and should be looked at again. The filter model runs it back through the filter, and the sort model finds where it belongs now. Neither needs to know which property changed.

A GListStore won’t send that for you. It knows when you insert or remove, but it has no idea when something inside an item it holds changes, so that part has to be wired up by hand.

What gets wired up is the observer pattern twice over. The store observes each task through GObject’s property notifications, and the models above observe the store through items-changed. In between, the store translates “this task’s done property changed” into “the item at position 4 changed”, the only kind of event a list model understands.


A store that reports

The companion code wraps the store in a small Rust struct called TaskStore. Tasks go in through it, so it can connect handlers on the way:

pub fn append(&self, task: &Task) {
    let ids = [
        task.connect_title_notify(self.reporter("title")),
        task.connect_done_notify(self.reporter("done")),
        task.connect_priority_notify(self.reporter("priority")),
    ];
    self.handlers.borrow_mut().insert(task.clone(), ids);
    self.store.append(task);
}

connect_done_notify and its siblings come from the #[property] derive, one per property, and each fires only for its own property. The handler finds the task’s position and reports it:

fn reporter(&self, property: &'static str) -> impl Fn(&Task) + 'static {
    let store = self.store.downgrade();
    move |task| {
        let Some(store) = store.upgrade() else {
            return;
        };
        if let Some(position) = store.find(task) {
            println!("{property} changed on {:?}: items_changed({position}, 1, 1)", task.title());
            store.items_changed(position, 1, 1);
        }
    }
}

The repo’s version also has a switch for turning reporting off, which comes up later.

The handler holds the store weakly. A GObject owns the handlers connected to it, so each task owns its handler. If the handler held the store strongly, the store would hold the task, the task its handler, and the handler the store again. It’s the same cycle the last post found in the selection handlers, one layer further down.

The other choice is which properties to watch. The answer is every property a filter or sorter reads: title for the search and the Title sort, done for “hide done” and Open first, priority for the Priority sort. The first version of the companion code watched only done and priority, because those were the two the buttons changed. Nothing in the app renames a task, so it passed every test, but a rename under the Title sort would have left the task in the wrong place with nothing in the log to say so.

GNOME Contacts makes the same call in contacts-store.vala. It listens for property changes on each contact as it’s added and ignores all of them except the display name and the favourite flag, the two its sorting and filtering depend on.

Each report costs a store.find(task), which walks the store comparing pointers. For a task list that’s nothing. For a very long list you’d keep a map from item to position instead.


Taking a task out

Adding handlers is the easy half. When a task leaves the store its handlers have to go too:

pub fn remove(&self, task: &Task) {
    if let Some(position) = self.store.find(task) {
        self.store.remove(position);
    }
    if let Some(ids) = self.handlers.borrow_mut().remove(task) {
        for id in ids {
            task.disconnect(id);
        }
    }
}

This is where the observer pattern usually goes wrong. Forgetting to unsubscribe has a name, the lapsed listener. Here a deleted task that’s still referenced somewhere else, an undo stack say, would keep firing its handler every time it changed, and each time the handler would search the whole store for a task that isn’t there. Because the handler holds the store weakly, that’s wasted work rather than a leak. With a strong reference, every deleted task would keep the entire store alive.

To check it, the companion app’s Task logs a line when it’s freed. Deleting a task printed it straight away, and closing the window printed one for every task left, so nothing was holding on.


What the log showed

The companion app logs each items-changed at the bottom of the stack and at the top, every row bind and unbind, and what’s still selected after each change. I ran it through the cases the last post had trouble with.

“Hide done” is a CustomFilter again, and watch-items is off. With “hide done” switched on, marking a task done removed the row. So did the Simulate sync button, which completes the first open task in store order without going near the selection or any handler that knows about the sorter.

The rest is in this table:

ChangeWhat the view sawRows reboundSelection
Raise to P1 under Title sort, task stays putitems-changed(2, -1, +1)nonekept
Raise to P1 under Priority sort, task moves upitems-changed(2, -7, +7)nonekept
Select all ten, Mark done under Title sortten single-item changesnoneall ten kept
Mark done with “hide done” onitems-changed(0, -1, +0)one unbindgone with the row

The second row needs explaining. The sort model didn’t report the move as one removal and one insertion. It reported a single range covering everything from the task’s old position to its new one, seven rows replaced. Even so, nothing was rebound. The ListView kept the rows it already had for those seven objects.

There’s one caveat on that. All ten tasks fitted on screen, so every item that moved already had a row. A task moving in from off-screen would need binding like any other.

Compare the selection with the last post, where a search that hid a selected row lost the selection for good. Here the selected task was removed and added back in the same signal, and MultiSelection kept it selected through every case except the one where the filter took it away.

Ten changes in a row meant ten signals and no visible cost.


At scale

The log covers ten tasks, and the worry at the top of this post is about tens of thousands. So I measured it: toggle one task’s done property under the Open first sort, and time the models alone with no window, in a release build on GTK 4.22.5. Each figure is the median of 25 changes.

Taskssorter.changed(Different)items_changed(pos, 1, 1)of which find()
100.012 ms0.005 msunder 0.001 ms
1,0000.57 ms0.016 ms0.004 ms
10,0005.5 ms0.066 ms0.035 ms
50,00028 ms0.40 ms0.28 ms
100,00053.5 ms0.60 ms0.43 ms

At 100,000 tasks the button approach spends 53 ms on one change, more than three frames at 60 Hz. Reporting the item takes 0.6 ms, and most of that is find() walking the store, which is where the item-to-position map mentioned earlier would start to pay for itself.

Those sorting times belong to this comparator as much as to GTK. Open first lowercases both titles on every comparison, and each of those allocates. A cheaper comparator would make the re-sort faster, but it would still be re-sorting all 100,000 tasks to place one.

The benchmark also checked that the list was still in order after each run, and under the Priority sort that check failed. sorter.changed(gtk::SorterChange::Different) re-sorted the CustomSorter behind Open first. It didn’t re-sort the expression sorters. A NumericSorter, a StringSorter and the MultiSorter combining them all left the list out of order after their properties changed, at every size down to ten tasks. I haven’t been into GTK’s source to find out why. Reporting each changed item with items_changed kept every sorter type in order.

That contradicts the last post, which recommended the sorter call for expression sorters too. I’ve added a correction there.


The change nobody reported

The companion app has a “Report changes” toggle that switches the store’s handlers off. I turned it off, sorted by Open first and marked a task done. It stayed where it was, which was the point of the toggle.

Then I turned reporting back on and marked a second task done. The second one dropped to the bottom with the other done tasks. The first one stayed in the middle of the open tasks, still out of place, and I had to stop and work out whether that was a bug.

It isn’t. A report covers one item at one moment, and the models above only re-check the items they’re told about. Nobody told them about the first task, and turning the toggle back on doesn’t go back and replay what was missed. That task stays wrong until it changes again or the sort order is switched.

The toggle is a demo, but the real version of it is a property missing from the watch list. If you suspect a list has drifted, emit items_changed(0, n, n) on the store, with n as its length, and the models re-check everything. On 100,000 tasks under Open first that took 0.94 s, which is fine for a one-off repair. Calling changed() on the sorter won’t do it for an expression sorter, as the benchmark showed.


When a reusable model is worth it

TaskStore is written for one item type and one list. That’s the shape Contacts uses too: handlers on a plain GListStore.

Fractal, the GNOME Matrix client, goes a step further. Its ExpressionListModel is a list model in its own right. It wraps any other list model, watches a list of expressions on every item, and reports a single-item change when one of them fires. It sits under the SortListModels for the room sidebar, the member list and the mention and room completion lists. It also keeps the item-to-position map mentioned above, so lookups don’t walk the list.

That generality costs code: implementing the list model interface, passing through changes from the model underneath, and keeping the watches in step as items come and go. For one list of one type, the store wrapper here does the job. A second list needing the same thing would be the time to write the general version.


Which fix to use

ApproachCatches changes from anywhereWorks withPer change, 100,000 tasks
sorter.changed(Different) in the button handlerNo, only changes made thereCustomSorter only53.5 ms
watch-items on the FilterListModelYesExpression filters, no sortingnot measured
items_changed(pos, 1, 1) from the storeYes, for the properties it watchesAny filter or sorter0.6 ms
items_changed(0, n, n) as a resetYesAny filter or sorter0.94 s

Two things the table doesn’t show. The store only reports the properties it watches, so the watch list has to cover everything a filter or sorter reads, and a property left off fails silently. And the handlers are observers like any other: disconnect them when an item leaves, and hold the store weakly so a forgotten one can’t keep it alive.


Next up

The list now stays in order no matter where a change comes from, and the button handlers no longer know about the sorter. Next is GtkColumnView and GtkGridView, where clickable column headers come down to handing column_view.sorter() to a SortListModel. The store from this post keeps that in order too.

The runnable version of this example is in the companion repo: cd 06-items-changed && cargo run. One widget at a time. See you in the next one.

Assisted by Claude Code (claude-opus-5-5). The experiments, bugs and conclusions are mine. How I work with AI tools is in The AI pair programmer.