This is the eighth 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 taught the store to report its own changes. This one puts a table and a grid on top of it. The complete, runnable code lives in the companion repo.
Where we left off
Every view in this series so far has been a ListView: one row per item, one widget per row. The model stack under it grew from a store and a selection to four layers, and the last post made the store report when an item changed so the sort model above it could keep up.
This post swaps the view. The example is a small photo library, twelve photos from three cameras, shown two ways. One is a ColumnView, a table with a header you can click to sort, columns you can drag wider or narrower, and columns you can drag into a different order. The other is a GridView of tiles. They sit in an AdwViewStack with a switcher in the header bar, and underneath they share one model stack.
A table is a list with columns
The thing that made ColumnView click for me is that it isn't a different kind of view. It's a ListView where each row is split into cells, and each column brings its own factory to build its cell. There's still one model, and each item in it is still one row.
So the factory code from the factory post carries over almost unchanged. The one difference is the object that setup is handed. A ListView passes a ListItem. A ColumnView passes a ColumnViewCell, which has the same item property, so expressions rooted at it read exactly as they did before. ColumnViewCell arrived in GTK 4.12, so that's the floor for this post, and in gtk4-rs it needs at least the v4_12 feature. The companion code turns on gnome_50, which covers it.
fn label_factory(
xalign: f32,
label: impl Fn(gtk::Expression) -> gtk::Expression + 'static,
) -> gtk::SignalListItemFactory {
let factory = gtk::SignalListItemFactory::new();
factory.connect_setup(move |_, cell| {
let cell = cell.downcast_ref::<gtk::ColumnViewCell>().unwrap();
let widget = gtk::Label::builder()
.xalign(xalign)
.ellipsize(pango::EllipsizeMode::End)
.build();
cell.set_child(Some(&widget));
label(gtk::ColumnViewCell::this_expression("item").upcast()).bind(
&widget,
"label",
Some(cell),
);
});
factory
}
All five columns in the example are labels, so one helper builds every factory and each column passes in the expression for its own text. Name is the simplest:
pub fn name() -> gtk::ColumnViewColumn {
let factory = label_factory(0.0, |item| item.chain_property::<Photo>("name").upcast());
gtk::ColumnViewColumn::builder()
.title("Name")
.factory(&factory)
.sorter(>k::StringSorter::new(Some(property("name"))))
.expand(true)
.resizable(true)
.build()
}
A ColumnViewColumn bundles the header title, the cell factory, the sorter that clicking the header will use, and how the column sizes itself. Photo is a GObject with five properties for the same reason Task became one two posts ago: the sorters and expressions find their data by property name.
Sizing columns
expand hands a column a share of any spare width. I gave it only to Name, so when the window grows, Name gets wider and the rest stay at their natural width. If two columns expanded they'd split the spare width between them.
resizable lets the user drag the column's right edge, and it's why every label in the factory ellipsizes. A label that can't ellipsize asks for its whole text as its minimum width, so the longest file name in the column would set how narrow Name could go. With ellipsizing on, the minimum drops to a few characters and the rest of the name becomes an ellipsis.
fixed-width pins a column. I set .fixed_width(96) on Size and left it out of the resizable ones, so that column never changes width however the others move. The numbers line up on the right because Size's labels get an xalign of 1.0 from the factory helper; the width has nothing to do with it.
Dragging a header sideways to reorder columns needs nothing at all. Columns are reorderable by default. The reorderable property belongs to the ColumnView itself, so turning it off locks every column in place.
Clickable headers need wiring
Each column has a sorter, and clicking its header shows a sort arrow. On its own, that's all that happens, and the rows stay where they are.
What the click changes is the ColumnView's own sorter, a ColumnViewSorter that it builds out of the columns' sorters. A sorter only reorders anything when a SortListModel is using it, and nothing has connected one to the other yet. Making that connection forces an order on the construction code. The view has to exist before you can ask for its sorter, and the sort model has to exist before the view can be given its model. So the view is built first, empty:
let column_view = gtk::ColumnView::new(None::<gtk::SelectionModel>);
column_view.append_column(&columns::name());
// ...camera, taken, size, rating
let sorter = column_view
.sorter()
.and_downcast::<gtk::ColumnViewSorter>()
.unwrap();
let sort_model = gtk::SortListModel::new(Some(photos.model().clone()), Some(sorter.clone()));
let selection = gtk::MultiSelection::new(Some(sort_model));
column_view.set_model(Some(&selection));
sorter() returns a plain gtk::Sorter. The downcast is only needed if you want to ask it which column is sorted, which the example does for its status bar.
The starting sort is newest first, set in code with column_view.sort_by_column(Some(&taken), gtk::SortType::Descending). I assumed that was the same thing as a user clicking the Taken header twice. On a fresh view it is. The difference only shows once the user has built up a stack of clicks, and it turned up in the log.
Sort on the value behind the label
Taken shows a date and Size shows "9.1 MB", but neither column's sorter reads what's on screen. Each one is a NumericSorter on the raw property: Unix seconds for Taken, bytes for Size. The label is produced separately, by a closure on the expression:
item.chain_property::<Photo>("size")
.chain_closure::<String>(glib::closure!(|_: Option<glib::Object>, size: u64| {
glib::format_size(size).to_string()
}))
The sample sizes straddle 10 MB on purpose. Sort the formatted strings and "12.4 MB" comes before "9.1 MB", because "1" sorts before "9". Dates fare worse, since "1 Mar 2025" would sort next to "1 Sep 2025". StringSorter's filename collation (gtk::Collation::Filename, GTK 4.10) compares embedded numbers as numbers and would fix that pair, but it would still put "1.2 MB" ahead of "900 kB". Keeping the stored value raw and formatting only for display means the column sorts correctly with no extra code.
What the sorter does with each click
The example logs the sorter's full list of sort columns every time it changes. Starting from newest first, I clicked Camera, Taken twice, Size and Rating, in that order:
sorter changed: Sorted by Camera ascending (sort columns: ["Camera Ascending", "Taken Descending"])
sorter changed: Sorted by Taken ascending (sort columns: ["Taken Ascending", "Camera Ascending"])
sorter changed: Sorted by Taken descending (sort columns: ["Taken Descending", "Camera Ascending"])
sorter changed: Sorted by Size ascending (sort columns: ["Size Ascending", "Taken Descending", "Camera Ascending"])
sorter changed: Sorted by Rating ascending (sort columns: ["Rating Ascending", "Size Ascending", "Taken Descending", "Camera Ascending"])
A ColumnViewSorter keeps a stack. The column you click goes to the front and every column clicked before it stays behind as a tie-breaker, most recent click first. After the Camera click, photos from the same camera came out newest first, because Taken was second in line.
Clicking the front column flips its direction. Clicking one further back brings it to the front ascending, whatever direction it had before. My log can't tell those two apart (Taken was descending when I clicked it, and would have come back ascending either way), so that rule comes from GTK's source.
In a scratch test I called sort_by_column for Taken, then Size, then Camera, and each time n_sort_columns() came back as 1. Each call wipes the stack and starts again with the one column you pass, so with nothing behind Camera, ties fell back to the order the photos sit in the store.
None of that shows in the header, which only ever draws an arrow on the front column. For a photo library the stacking is what you'd want: sort by camera and each camera's photos are still in date order from the last click.
If your app restores a saved sort on startup with sort_by_column, it can only ever restore one column. That includes the recipe in the ColumnViewSorter docs (the restore example near the end), which calls sort_by_column once per saved column, working from the back of the stack forward. Each call wipes the one before, so only the last column it passes survives. The function that pushes a column onto the stack, the one a header click uses, is private to GTK.
Keeping the table sorted when a photo changes
Header sorting is a SortListModel underneath, so it inherits the problem the last two posts dealt with: SortListModel has no watch-items, and that's still true in GTK 4.24. Sorted by Rating, a photo you rate up stays where it was unless something tells the sort model to look again.
The first version of the Rate up button ended with sorter.changed(gtk::SorterChange::Different) on the ColumnViewSorter. I wrote it from habit, then remembered the correction on the filter and sort post: that call leaves expression sorters out of order. Rating's sorter is a NumericSorter, so I expected the button not to work. It worked. That was enough of a surprise to test properly, rating up the five lowest photos and checking the order after each change:
| What the SortListModel holds | After changed(Different) |
|---|---|
| NumericSorter on rating | out of order |
| MultiSorter holding that NumericSorter | out of order |
| ColumnViewSorter holding that NumericSorter | in order |
Same NumericSorter in all three, GTK 4.22.5, at 12 photos and at 1,200. Wrapped in a ColumnViewSorter, it re-sorts. Used directly or inside a MultiSorter, it doesn't. GTK's source has the answer: SortListModel reuses its cached sort keys when the new ones look compatible, and a NumericSorter's keys still hold the old rating. ColumnViewSorter has no keys of its own, so its comparisons always see the live value. GTK doesn't document any of that, so I wouldn't build on it. It does mean a table can look fine with the button-handler approach and then go wrong the day the same sorter is reused in a plain list.
The example doesn't rely on it. The store from the last post comes across as PhotoStore, and Rate up just sets the rating:
rate_up.connect_clicked(move |_| {
for photo in selected_photos(&model) {
photo.set_rating((photo.rating() + 1).min(5));
}
});
selected_photos copies the selected items into a Vec before the loop starts. Each set_rating re-sorts the model and moves positions around, so walking the selection's bitset live would skip or repeat photos.
The store hears notify::rating and emits items_changed for that photo, and the sort model re-places it under whichever column is in front. What changes in a table is the watch list. The task list's sorters read three properties. Here every column sorts on a different property, so the store watches all five, and a column whose property was left off would drift out of order without any warning.
The same photos as a grid
The GridView takes the same selection model as the table:
let grid_view = gtk::GridView::new(Some(selection.clone()), Some(tile::factory()));
grid_view.set_min_columns(2);
grid_view.set_max_columns(6);
Its factory is a plain ListItem factory, one tile per photo, with an icon, the name and the star rating bound by expressions. GridView works out how many tiles fit across, between the minimum and maximum you give it, and wraps the rest onto new rows. There are no columns to configure in the table sense and no headers, so the grid can't change the sort itself. It shows whatever order the model has.
My photo app, Moments, leans on that fitting for zoom. Its photo grid allows anywhere from 2 to 20 columns, and zooming swaps in a factory whose cells ask for a bigger or smaller square, from 96 up to 320 pixels. Nothing sets a column count anywhere; the grid works it out again from the new tile size.
Because both views read the same MultiSelection, they share the sort and the selection. I selected three photos in the table, switched to the grid, and the same three were highlighted, in the same positions. Click Rating in the table and the grid reorders too. Add photo appends to the store, and under the starting sort the new photo shows up first in both views.
Sharing one selection model is fine even with both views on screen at once. Here only one is visible, but the hidden one stays subscribed to the model, so every change reaches both. I haven't measured what that costs at scale; with twelve photos it's nothing.
Sharp edges
Header clicks do nothing until you wire the sorter. Hand column_view.sorter() to a SortListModel under the selection, which means building the view before its model.
Sort on stored values, format for display. A NumericSorter on bytes or timestamps, and a closure on the expression for the label.
Ellipsize labels in resizable columns. Otherwise the longest label sets the narrowest the column can go.
sort_by_column replaces the sort; a header click adds to it. The tie-breaker stack only builds up through clicks, and only a click on the front column flips its direction.
Report item changes from the store. changed() on a ColumnViewSorter re-sorted in my tests and the same NumericSorter used directly didn't, which turned out to be sort-key caching in SortListModel. I'd rather not have a table's correctness hang on which sorter happens to sit on top, so the store emits items_changed for every property a column sorts on.
Next up
The table is flat: every photo is one row at the same level. The next post adds hierarchy with GtkTreeListModel and GtkTreeExpander, which is where people still land on Stack Overflow answers about the deprecated GtkTreeView. A ColumnView is also where a tree is most useful, with an expander in the first column and sortable columns beside it.
The runnable version of this example is in the companion repo: cd 07-columns-grids && 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)