This is the fifth 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 rows behaving correctly under recycling. This one is about which of them is selected. The complete, runnable code lives in the companion repo.


Where we left off

Two posts ago I wrapped the list store in a NoSelection and moved on, with a note that GtkListView won’t accept a bare GListModel and that NoSelection is how you say “no selection, and I mean it.” True enough as far as it went, but it was a placeholder. Time to take the wrapper seriously.

Before any of the type names matter, it’s worth knowing where selection lives, because GTK4 moved it and that move is the whole design. In the GtkTreeView world you asked the view. gtk_tree_view_get_selection() handed you a GtkTreeSelection object that belonged to the widget, and selection was a property of the thing on screen. GTK4 inverts it. Selection is a property of the model, and the view is a renderer of model state, which is how everything else in this part of the toolkit works too. GtkListView has no get_selection() at all.

That relocation buys more than it looks like from the API diff. The selection state of your list becomes available to code that has never heard of your widget tree. You can set it before the view exists. You can test it without a display server.


Three models, one interface

GtkSelectionModel is an interface rather than a class, and the three implementations you’ll actually use are all decorators. Each one wraps a GListModel and is itself a GListModel:

let selection = gtk::NoSelection::new(Some(store.clone()));      // nothing selectable
let selection = gtk::SingleSelection::new(Some(store.clone()));  // at most one
let selection = gtk::MultiSelection::new(Some(store.clone()));   // any number

The decoration is the part to hold onto. The view doesn’t see your GListStore; it sees the selection model, which forwards item lookups straight through and adds selection state on top. Next post’s filter and sort models are the same trick applied to a different concern, which is why they compose with this one so cleanly. They slot in underneath, between the store and the selection model.

Choosing between the three is mostly obvious. NoSelection for a list that’s purely display, like a log or a feed or a set of rows whose only interaction is a button inside the row. SingleSelection for master-detail, where picking a row drives a pane somewhere else. MultiSelection when the user needs to act on a batch. What’s less obvious is that SingleSelection and MultiSelection expose that state through two completely different mechanisms, and the second one is where people get stuck.


SingleSelection and the autoselect surprise

SingleSelection gives you two properties: selected, a u32 position, and selected-item, the object itself. Watching either one is how you wire selection outward:

selection.connect_selected_item_notify(|selection| {
    let Some(item) = selection.selected_item() else {
        return;
    };
    let boxed = item.downcast::<BoxedAnyObject>().unwrap();
    let task: Ref<Task> = boxed.borrow();

    println!("selected: {}", task.title);
});

The handler never touches the GtkListView. It doesn’t ask the view what’s highlighted or walk rows looking for a CSS class, and it runs whether or not a view has been built yet. For master-detail that’s the arrangement you want: the detail pane subscribes to the selection model and never learns which widget is displaying it.

Now the surprise. SingleSelection has an autoselect property that defaults to TRUE. With it on, the model refuses to hold an empty selection. It takes item 0 as soon as the model is non-empty, and it takes something else if the selected item is removed. Your list opens with the first row highlighted, your detail pane opens already populated, and nothing in your code asked for either.

A second property is involved: can-unselect, defaulting to FALSE. The name reads like a user-interaction flag, something about whether ctrl-click can deselect. Its scope is wider than that. It gates every route to an empty selection, your own code included. With can-unselect off, set_selected(gtk::INVALID_LIST_POSITION) and unselect_all() both return without doing anything. autoselect then sits on top as a second, independent veto: even with can-unselect on, an empty selection is refused while autoselect is on.

That leaves four combinations, one of which lets a SingleSelection end up empty:

autoselectcan-unselectcan it be cleared?
falsefalseno
falsetrueyes
truefalseno
truetrueno

So you turn autoselect off. Here is where I lost an afternoon:

// Looks right. Selects item 0 anyway.
let selection = gtk::SingleSelection::builder()
    .model(&store)
    .autoselect(false)
    .can_unselect(true)
    .build();

That builder produces a model with selected == 0. The order of the builder calls is load-bearing. Properties are applied in the order you write them, and installing the model is the event that triggers the autoselect, so with .model() first, item 0 gets selected while autoselect is still sitting at its default TRUE. The .autoselect(false) that lands a moment later doesn’t retroactively undo it. Swap the two lines:

// Actually starts empty.
let selection = gtk::SingleSelection::builder()
    .autoselect(false)
    .can_unselect(true)
    .model(&store)
    .build();

The other construction paths are worth checking rather than guessing at, because the failure is so quiet. SingleSelection::new(Some(store)) followed by set_autoselect(false) fails the same way, for the same reason: the model went in first. Constructing empty, setting the property, then calling set_model() works. If you’ve already got a model that has made its choice, set_selected(gtk::INVALID_LIST_POSITION) will clear it, though only with can-unselect on and autoselect off, per the table above. Call it without those and it returns quietly, which puts you one level down in the same hole.

None of these are CONSTRUCT properties, which is why the ordering leaks through at all. They’re ordinary read-write ones. The builder applies them in the sequence you wrote, and installing the model runs the autoselect immediately rather than waiting for construction to finish.

A note on that constant. GTK_INVALID_LIST_POSITION is u32::MAX. You rarely need it in Rust, since selected_item() returns an Option and the let ... else above covers the empty case. If you do compare positions numerically, watch the emptiness test in particular. Empty is u32::MAX, so selected() == 0 is false exactly when the selection is empty and true when row 0 is legitimately picked, which is the inverse of the check you meant to write. The C habit of testing against -1 at least fails loudly: selected() returns a u32 and won’t compile against it.


MultiSelection doesn’t have a selected property

The instinct is to reach for the plural of what SingleSelection gave you, and there’s nothing there. MultiSelection has no selected-items property. The signal you connect to lives on the GtkSelectionModel interface itself:

selection.connect_selection_changed(|selection, position, n_items| {
    // position and n_items describe the range that CHANGED,
    // not the range that is now selected.
});

Those two arguments are the trap. position and n_items delimit the range whose selection state was touched. They’re an invalidation hint for the view, in the same spirit as items-changed, and they don’t tell you what is selected.

It’s easiest to see by logging both at once. Selecting row 3, then row 7, then hitting select-all on a ten-item model:

after select_item(3, false): args = (3, 1),  selection size = 1
after select_item(7, false): args = (7, 1),  selection size = 2
after select_all():          args = (0, 10), selection size = 10

The second line is the whole problem. The arguments say “one item, at position 7” while the actual selection is two rows. Code that treats those arguments as the answer gets the first line right and the second line wrong, with nothing in the API to tell it which case it’s in.

To find out what’s actually selected, re-query the model:

let bitset = selection.selection();

GtkBitset is a compact set of u32 positions. That’s the right shape when a user has just selected forty thousand rows and you’d rather not materialise a vector of them. Getting items out of it means iterating positions and looking each one up:

let bitset = selection.selection();
let mut titles = Vec::new();

if let Some((mut iter, first)) = gtk::BitsetIter::init_first(&bitset) {
    let mut position = Some(first);
    while let Some(p) = position {
        let item = selection.item(p).unwrap();
        let boxed = item.downcast::<BoxedAnyObject>().unwrap();
        let task: Ref<Task> = boxed.borrow();
        titles.push(task.title.clone());
        position = iter.next();
    }
}

Two things there earn their awkwardness. BitsetIter::init_first returns the iterator and the first value together, because an empty bitset has no first value. The Option doubles as the emptiness check, so no separate is_empty() call is needed in front of it.

The second is the lookup: selection.item(p) rather than store.item(p), because positions are in the selection model’s coordinate space. Today those two happen to be identical, since MultiSelection is a straight pass-through. They stop being identical the moment a filter or sort model joins the stack, and looking positions up in the wrong model is the kind of bug that only shows itself once a user types in a search box.


Setting selection from code

Everything so far has been reading. The GtkSelectionModel interface is also how you write:

selection.select_item(3, true);   // true = unselect everything else first
selection.select_range(0, 5, false);
selection.select_all();
selection.unselect_all();
selection.set_selection(&selected, &mask);

That bool on select_item is unselect_rest, and it does real work. select_item(3, false) on a MultiSelection adds row 3 to whatever was already selected; select_item(3, true) makes it the only one. On a SingleSelection the distinction is academic, which is how you end up internalising the wrong meaning while working with single selection and then carrying it over.

set_selection(&selected, &mask) takes two bitsets and updates every position in mask to match selected. It’s the batch primitive. Reach for it when applying a whole selection state at once, restoring a saved selection say, rather than looping select_item and emitting a signal per row.

Now the catch, which is why “available on the interface” is a weaker promise than it sounds. SingleSelection implements only select_item, unselect_item and unselect_all. Everything else falls through to the interface defaults, which return false and do nothing. select_all, select_range and set_selection are all callable on a SingleSelection, they all compile, and they all silently decline. Each returns a bool saying so, and that return value is the only thing distinguishing “done” from “declined”:

let changed = selection.select_range(0, 5, false);
// MultiSelection: true.  SingleSelection: false, and nothing happened.

Sharp edges

Set autoselect before installing the model. Installing the model is what triggers the initial selection, so a builder with .model() first leaves you item 0 selected and a property claiming otherwise.

can-unselect gates your code too, not only the user. With it off, set_selected(INVALID_LIST_POSITION) and unselect_all() are silent no-ops. Clearing a SingleSelection needs can-unselect on and autoselect off; three of the four combinations refuse.

Re-query selection() in every selection-changed handler. The position/n_items arguments are an invalidation hint for the view. Clicking rows one at a time produces ranges that happen to look like the answer, so this survives casual testing and then fails on the second click.

Look positions up through the model that gave them to you. Your store will agree today and hand back the wrong row once a filter or sort model joins the stack.

Half the interface does nothing on a SingleSelection. select_all, select_range and set_selection compile fine and return false without acting. Only select_item, unselect_item and unselect_all are implemented. On NoSelection none of them do anything. That bool return is your only signal, and it’s easy to discard.

autoselect guarantees a selection only when items exist. An empty model has nothing to select, so selected_item() still returns None and the Option handling stays load-bearing.

GTK_INVALID_LIST_POSITION is u32::MAX. selected() == -1 won’t compile against a u32, which is the loud failure. selected() == 0 compiles and inverts the emptiness check you meant. Prefer selected_item().


Next up

Selection is now something the rest of the app can read and write without touching a widget. The next post puts a second decorator in the stack: FilterListModel and SortListModel, which let you search and reorder a list without re-querying the source. That post also covers what happens to your carefully maintained selection when the rows underneath it move.

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

Reviewed with Claude, which caught the can-unselect scope error and the SingleSelection interface no-ops. Prose and conclusions are mine.