DEV Community

Cover image for Filtering and Sorting Without Re-querying: FilterListModel and SortListModel
Justin - FromTheArchitect
Justin - FromTheArchitect

Posted on Originally published at fromthearchitect.dev

Filtering and Sorting Without Re-querying: FilterListModel and SortListModel

This is the sixth 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 put selection in the model. This one adds search and sort underneath it. The complete, runnable code lives in the companion repo.


Where we left off

Last time the stack was two layers deep: a GListStore with a MultiSelection on top. I closed with a warning to look positions up through the model that handed them to you, because the store would stop agreeing with the view once a filter or sort model joined in, and this post adds both of them.

The goal is a task list with a search box, a "hide done" toggle and a sort dropdown, where none of those controls ever touch the store. Typing in the search box doesn't rebuild anything. The store keeps every task in the order they were added, and the layers above it decide what the view sees.


The stack

let filter_model = gtk::FilterListModel::new(Some(store.clone()), Some(every.clone()));
let sort_model =
    gtk::SortListModel::new(Some(filter_model.clone()), Some(sorters::by_index(0)));
let selection = gtk::MultiSelection::new(Some(sort_model.clone()));

let list_view = gtk::ListView::new(Some(selection), Some(task_row::factory()));
Enter fullscreen mode Exit fullscreen mode

FilterListModel and SortListModel work like the selection models from last time. Each takes another list model as its input and is itself a list model, which is what lets them stack: the filter model wraps the store, the sort model wraps the filter model, and the selection wraps the sort model. The view is handed only the top layer and never learns the others exist.

Order matters a little. Filtering first means the sorter only ever sees the rows that survived, which is less work on a long list. Selection goes last because the positions it hands out have to be positions in the list the user is looking at, and that's the filtered, sorted one.

You don't connect anything between the layers. Each wrapper subscribes to its child's items-changed when you hand it the child, and re-emits the change in its own positions. Append to the store and the new task arrives at the view already filtered and in sorted position.


Task has to become a GObject

Up to now Task has been a plain struct inside a BoxedAnyObject, and the row factory downcast and borrowed it. That works when your own closure is the only thing reading the data. Most of GTK's filters and sorters don't take a closure. They take an expression, which looks values up by property name, and a BoxedAnyObject has no properties to look up.

So Task becomes a GObject subclass with three real properties:

#[derive(Default, Properties)]
#[properties(wrapper_type = super::Task)]
pub struct Task {
    #[property(get, set)]
    title: RefCell<String>,
    #[property(get, set)]
    done: Cell<bool>,
    #[property(get, set)]
    priority: Cell<u32>,
}
Enter fullscreen mode Exit fullscreen mode

Plus the usual object_subclass and wrapper boilerplate, which is in the companion repo and which I'll skip here. If the inner/outer type split or the #[property(get, set)] attribute is new to you, GObject in Rust: The Type System Explained walks through the same pattern one piece at a time. For now what matters is that task.done() and task.set_done(true) exist, and that setting a property emits notify::done.


Searching and hiding with filters

The search box is a StringFilter. You give it an expression naming the property to match against, and it does the rest:

fn property(name: &str) -> gtk::PropertyExpression {
    gtk::PropertyExpression::new(Task::static_type(), gtk::Expression::NONE, name)
}

pub fn search() -> gtk::StringFilter {
    let filter = gtk::StringFilter::new(Some(property("title")));
    filter.set_match_mode(gtk::StringFilterMatchMode::Substring);
    filter.set_ignore_case(true);
    filter
}
Enter fullscreen mode Exit fullscreen mode

Substring matching and ignoring case are already the defaults, so those two setters change nothing. I keep them because the search behaviour is then readable without looking the defaults up.

Wiring it to the entry is one line, filter.set_search(Some(&entry.text())) in the search-changed handler. Setting the search string re-runs the filter on its own.

Hiding done tasks is a BoolFilter on done, inverted so it keeps the tasks that aren't. To combine the two I put both in an EveryFilter, which passes an item only if every child filter does (AnyFilter is the "or" version). The toggle adds the bool filter to the EveryFilter when it's switched on and removes it when it's switched off. Removing by every.n_items() - 1 relies on the bool filter being the last child, which holds here because nothing else is ever appended after it:

hide.connect_toggled(move |button| {
    if button.is_active() {
        every.append(not_done.clone());
    } else {
        every.remove(every.n_items() - 1);
    }
});
Enter fullscreen mode Exit fullscreen mode

Adding or removing a child counts as the filter changing, so the FilterListModel re-filters without being told.

That's the version that works. It isn't the version I wrote first.


Why a done task didn't hide

My first "hide done" was a CustomFilter, the closure form. It read a shared flag and the task's done property:

let filter = gtk::CustomFilter::new(move |item| {
    let task = item.downcast_ref::<Task>().unwrap();
    !(state.get() && task.done())
});
Enter fullscreen mode Exit fullscreen mode

A closure gives GTK no way to know when its answer changes, so the toggle handler flipped the flag and then called filter.changed(gtk::FilterChange::MoreStrict). MoreStrict promises the model that nothing hidden now will come back, so it only re-checks the rows on screen. LessStrict promises the opposite. That part worked, and switching the toggle on hid the done tasks as expected.

Then I selected a visible task, pressed Mark done, and it stayed on screen with its tick showing, in a list that was supposed to be hiding done tasks.

My first suspect was store.clone(). The filter model was built from a clone of the store, and for a minute I wondered if Mark done was changing a task in a different copy. It isn't. Cloning a gtk-rs object bumps a reference count and hands you a second pointer to the same GObject, so there's one store and one set of tasks. The tick appearing in the row proved the property had changed.

The actual problem was that when a task's done property changed, nothing told the filter model to run that task through the filter again. FilterListModel has a watch-items property for this, added in GTK 4.20 and off by default. In gtk4-rs, set_watch_items only exists when the crate's v4_20 feature is on (the companion code gets it through gnome_50), so without it you get a missing-method compile error rather than a hint about versions. The explanation I found when I searched for it said that turning it on makes the model listen for changes to every property on every item. If that were the whole story, it would have fixed the CustomFilter, but with watch-items on the row still stayed on screen.

GTK's own documentation for set_watch_items is more careful:

This allows implementations of GtkFilter that support expression watching to react to property changes. This property has no effect if the current filter doesn't support watching items.

The filter model doesn't subscribe to anything on its own. It asks the filter which properties to watch, and only a filter built from expressions can answer. A closure can't say what it reads. I checked all three combinations against the same task:

Filter watch-items Task marked done
BoolFilter on done off row stays
BoolFilter on done on row removed
CustomFilter reading done on row stays

So the fix was two changes together: rebuild "hide done" as the BoolFilter above, and turn watch-items on. The companion app leaves a toggle for watch-items so you can switch it off and watch the bug come back.


Re-sorting after the data changes

The dropdown offers three orders, and swapping between them is sort_model.set_sorter(...).

fn title() -> gtk::StringSorter {
    gtk::StringSorter::new(Some(property("title")))
}

fn priority() -> gtk::Sorter {
    let sorter = gtk::MultiSorter::new();
    sorter.append(gtk::NumericSorter::new(Some(property("priority"))));
    sorter.append(title());
    sorter.upcast()
}
Enter fullscreen mode Exit fullscreen mode

StringSorter ignores case by default. MultiSorter only consults its second sorter on a tie, so tasks of equal priority come out alphabetical. The third order, "Open first", is a CustomSorter, because sinking done tasks and then sorting by title is easier to write than to express. It returns a std Ordering and calls .into() to get GTK's.

After the filter episode I expected SortListModel to have a watch-items of its own. As of GTK 4.22 it doesn't. Its properties are incremental, item-type, model, n-items, pending, section-sorter and sorter, and nothing in that list watches items. Under "Open first", a task you mark done stays where it was. The same goes for the expression-based sorters: I changed a task's priority under the Priority sort and it didn't move either.

The fix is to tell the sorter yourself, after you change the data:

mark_done.connect_clicked(move |_| {
    for task in selected_tasks(&model) {
        task.set_done(true);
    }
    if let Some(sorter) = sorted.sorter() {
        sorter.changed(gtk::SorterChange::Different);
    }
});
Enter fullscreen mode Exit fullscreen mode

Collecting the tasks before changing any of them matters too. With watch-items on, each set_done can filter a row out and shift every position after it, so iterating the selection's bitset while mutating would skip rows.

A sturdier fix
Calling the sorter from the button works, but only for changes that go through that button. A sync or an undo that changes a task somewhere else would leave the list out of order. The sturdier fix is for the model to report the change itself: when a task's property changes, emit items_changed(pos, 1, 1) for its position, and the filter and sort models above re-check just that one item. GNOME Contacts does this on a plain GListStore in contacts-store.vala, listening only to the two properties that affect its order. Fractal wraps it in a reusable ExpressionListModel that sits under its SortListModels. That approach gets its own post next.


Binding rows with expressions

A side effect of Task being a GObject is that the row factory no longer needs bind and unbind handlers. In setup, each widget is bound to an expression rooted at the list item's item property:

let item = gtk::ListItem::this_expression("item");
item.chain_property::<Task>("title")
    .bind(&title, "label", Some(list_item));
item.chain_property::<Task>("done")
    .bind(&done, "visible", Some(list_item));
Enter fullscreen mode Exit fullscreen mode

GTK re-evaluates those when the row slot is recycled onto a different item and when the bound property changes. That second part is why the tick appears the moment you press Mark done, without the factory being involved. The bind/unbind rhythm from the factory post is still how things work underneath; the expressions just do the bookkeeping. The binding belongs to the target widget, so there's nothing to clean up on unbind either.


What happens to the selection

Last post promised this. I selected two tasks under the Title sort, then changed things underneath them and logged what the MultiSelection reported.

A re-sort keeps the selection. The two tasks were at positions 1 and 5. After switching to the Priority sort they were at 5 and 8, still selected. MultiSelection tracks the items themselves.

What it doesn't do is tell you. That re-sort emitted items-changed on the selection model and no selection-changed at all. The selection is the same set of tasks, so from the model's point of view nothing about selection changed. Anything holding on to positions is now holding the wrong ones.

A filter is different. With the same two selected, I searched for "draft", which hid one of them. Clearing the search brought the row back unselected, and again only items-changed was emitted.

SingleSelection follows its item through a re-sort too, but what happens when a filter removes it depends on autoselect. With autoselect on, which is the default, it quietly selects a neighbour: my selected task was hidden by the search and "Draft the filter and sort post" became the selection, and stayed selected after the search was cleared. With autoselect off, selected_item becomes None and stays None when the row comes back. The neighbour is arguably the worse outcome, because nothing on screen looks wrong.

The practical consequence is in the status bar of the companion app, which shows "7 of 10 shown, 2 selected". If it only listened to selection-changed, a search that hid a selected row would leave it claiming two selected. It listens to both signals:

selection.connect_selection_changed(move |selection, _, _| {
    update(selection);
});
selection.connect_items_changed(move |selection, _, _, _| {
    update(selection);
});
Enter fullscreen mode Exit fullscreen mode

Avoiding a reference cycle in your handlers

Those two handlers had a bug in my first draft, and not one you'd see by running the app. The status update was a closure that captured a clone of selection so it could read the counts. That closure was then connected to a signal on selection.

A GObject owns the handlers connected to it. So the selection model owned a closure that owned the selection model, which is a reference cycle. Because MultiSelection holds the sort model, which holds the filter model, which holds the store, the whole stack would outlive the window.

I tested both versions by building the stack, dropping every handle and checking a weak reference afterwards. With the captured clone, the selection and the store were both still alive. With the fix, both had been freed by the time I checked.

The fix is to stop capturing. Every signal hands the handler the object that emitted it, so the closure takes the selection as a parameter and uses that:

let update_status = {
    let (status, store) = (status.clone(), store.clone());
    Rc::new(move |selection: &gtk::MultiSelection| {
        status.set_label(&format!(
            "{} of {} shown, {} selected",
            selection.n_items(),
            store.n_items(),
            selection.selection().size()
        ));
    })
};
Enter fullscreen mode Exit fullscreen mode

The label and the store are still captured strongly, which is fine: neither of them holds the selection, so there's no loop. glib::clone! with a weak capture is the other common fix, and the right one when the handler is connected to a different object from the one it reads.


Sharp edges

Build filters from expressions if items change. watch-items re-checks an item only through filters that can name what they read, such as StringFilter, BoolFilter, or an EveryFilter holding them. A CustomFilter is invisible to it, so a closure filter that depends on item state needs its own changed() call.

Sorting never notices item changes. As of GTK 4.22, editing a property that a sorter compares leaves the row where it was, even with an expression sorter. Whatever changes the data owes the sorter a changed() call, or the model owes the list an items-changed.

Listen to items-changed as well as selection-changed. A re-sort moves selected rows without a selection-changed, and a filter can drop or swap the selection without one either. On a SingleSelection with autoselect on, the swap lands on a neighbour. If a selection has to survive a search, keep it in your own state.

Don't capture the emitter in its own handler. Use the reference the signal passes in, or a weak clone. A strong clone keeps the selection model and everything under it alive.

Collect before you mutate. With watch-items on, changing an item can remove its row and shift the positions you were about to visit.


Next up

The list now searches, hides and reorders, and the store hasn't changed once. The next post replaces the button-handler re-sort with a model that reports its own item changes, using what GNOME Contacts and Fractal do in production. After that, GtkColumnView and GtkGridView, where clickable column headers come down to handing column_view.sorter() to a SortListModel.

The runnable version of this example is in the companion repo: cd 05-filter-sort && 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.

Top comments (0)