<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Justin - FromTheArchitect</title>
    <description>The latest articles on DEV Community by Justin - FromTheArchitect (@fromthearchitect).</description>
    <link>https://dev.to/fromthearchitect</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3846970%2F105073c7-9d87-4514-b33f-d466199f9dd2.jpeg</url>
      <title>DEV Community: Justin - FromTheArchitect</title>
      <link>https://dev.to/fromthearchitect</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/fromthearchitect"/>
    <language>en</language>
    <item>
      <title>Sortable Columns in GTK4: ColumnView, ColumnViewSorter and GridView</title>
      <dc:creator>Justin - FromTheArchitect</dc:creator>
      <pubDate>Tue, 27 Oct 2026 06:07:14 +0000</pubDate>
      <link>https://dev.to/fromthearchitect/sortable-columns-in-gtk4-columnview-columnviewsorter-and-gridview-3ol9</link>
      <guid>https://dev.to/fromthearchitect/sortable-columns-in-gtk4-columnview-columnviewsorter-and-gridview-3ol9</guid>
      <description>&lt;p&gt;&lt;em&gt;This is the eighth entry in &lt;a href="https://dev.to/fromthearchitect/a-field-guide-to-gtk-widgets-3m4f"&gt;A Field Guide to GTK Widgets&lt;/a&gt;, a series about which widget to reach for, when, and what bites you when you do. The &lt;a href="https://dev.to/fromthearchitect/keeping-a-gtk4-sortlistmodel-in-order-when-items-change-2763"&gt;previous post&lt;/a&gt; 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 &lt;a href="https://github.com/fromthearchitect/gtk-widgets-field-guide/tree/main/07-columns-grids" rel="noopener noreferrer"&gt;companion repo&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Where we left off
&lt;/h2&gt;

&lt;p&gt;Every view in this series so far has been a &lt;code&gt;ListView&lt;/code&gt;: 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.&lt;/p&gt;

&lt;p&gt;This post swaps the view. The example is a small photo library, twelve photos from three cameras, shown two ways. One is a &lt;code&gt;ColumnView&lt;/code&gt;, 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 &lt;code&gt;GridView&lt;/code&gt; of tiles. They sit in an AdwViewStack with a switcher in the header bar, and underneath they share one model stack.&lt;/p&gt;




&lt;h2&gt;
  
  
  A table is a list with columns
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;So the factory code from &lt;a href="https://dev.to/fromthearchitect/your-first-factory-gtklistview-and-the-bindunbind-rhythm-4c1d"&gt;the factory post&lt;/a&gt; carries over almost unchanged. The one difference is the object that setup is handed. A ListView passes a &lt;code&gt;ListItem&lt;/code&gt;. A ColumnView passes a &lt;code&gt;ColumnViewCell&lt;/code&gt;, which has the same &lt;code&gt;item&lt;/code&gt; 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 &lt;code&gt;v4_12&lt;/code&gt; feature. The companion code turns on &lt;code&gt;gnome_50&lt;/code&gt;, which covers it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;label_factory&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;xalign&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;f32&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;impl&lt;/span&gt; &lt;span class="nf"&gt;Fn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;gtk&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Expression&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nn"&gt;gtk&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Expression&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="k"&gt;'static&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nn"&gt;gtk&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;SignalListItemFactory&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;factory&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;gtk&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;SignalListItemFactory&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="n"&gt;factory&lt;/span&gt;&lt;span class="nf"&gt;.connect_setup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;move&lt;/span&gt; &lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cell&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;cell&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;cell&lt;/span&gt;&lt;span class="py"&gt;.downcast_ref&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nn"&gt;gtk&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;ColumnViewCell&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="nf"&gt;.unwrap&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;widget&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;gtk&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;Label&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="nf"&gt;.xalign&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;xalign&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="nf"&gt;.ellipsize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;pango&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;EllipsizeMode&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;End&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="nf"&gt;.build&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="n"&gt;cell&lt;/span&gt;&lt;span class="nf"&gt;.set_child&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;widget&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

        &lt;span class="nf"&gt;label&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;gtk&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;ColumnViewCell&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;this_expression&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"item"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nf"&gt;.upcast&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;&lt;span class="nf"&gt;.bind&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;widget&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s"&gt;"label"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cell&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="n"&gt;factory&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;name&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nn"&gt;gtk&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;ColumnViewColumn&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;factory&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;label_factory&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="py"&gt;.chain_property&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Photo&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nf"&gt;.upcast&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;

    &lt;span class="nn"&gt;gtk&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;ColumnViewColumn&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="nf"&gt;.title&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Name"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;.factory&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;factory&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;.sorter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nn"&gt;gtk&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;StringSorter&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;property&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;))))&lt;/span&gt;
        &lt;span class="nf"&gt;.expand&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;.resizable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;.build&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A &lt;code&gt;ColumnViewColumn&lt;/code&gt; 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.&lt;/p&gt;




&lt;h2&gt;
  
  
  Sizing columns
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;expand&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;resizable&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;fixed-width&lt;/code&gt; pins a column. I set &lt;code&gt;.fixed_width(96)&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;Dragging a header sideways to reorder columns needs nothing at all. Columns are reorderable by default. The &lt;code&gt;reorderable&lt;/code&gt; property belongs to the ColumnView itself, so turning it off locks every column in place.&lt;/p&gt;




&lt;h2&gt;
  
  
  Clickable headers need wiring
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;What the click changes is the ColumnView's own sorter, a &lt;code&gt;ColumnViewSorter&lt;/code&gt; that it builds out of the columns' sorters. A sorter only reorders anything when a &lt;code&gt;SortListModel&lt;/code&gt; 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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;column_view&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;gtk&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;ColumnView&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;None&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nn"&gt;gtk&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;SelectionModel&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;column_view&lt;/span&gt;&lt;span class="nf"&gt;.append_column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nn"&gt;columns&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;name&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;span class="c1"&gt;// ...camera, taken, size, rating&lt;/span&gt;

&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;sorter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;column_view&lt;/span&gt;
    &lt;span class="nf"&gt;.sorter&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="py"&gt;.and_downcast&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nn"&gt;gtk&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;ColumnViewSorter&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="nf"&gt;.unwrap&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;sort_model&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;gtk&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;SortListModel&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;photos&lt;/span&gt;&lt;span class="nf"&gt;.model&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="nf"&gt;.clone&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt; &lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sorter&lt;/span&gt;&lt;span class="nf"&gt;.clone&lt;/span&gt;&lt;span class="p"&gt;()));&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;selection&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;gtk&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;MultiSelection&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sort_model&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="n"&gt;column_view&lt;/span&gt;&lt;span class="nf"&gt;.set_model&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;selection&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;sorter()&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;The starting sort is newest first, set in code with &lt;code&gt;column_view.sort_by_column(Some(&amp;amp;taken), gtk::SortType::Descending)&lt;/code&gt;. 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.&lt;/p&gt;




&lt;h2&gt;
  
  
  Sort on the value behind the label
&lt;/h2&gt;

&lt;p&gt;Taken shows a date and Size shows "9.1 MB", but neither column's sorter reads what's on screen. Each one is a &lt;code&gt;NumericSorter&lt;/code&gt; on the raw property: Unix seconds for Taken, bytes for Size. The label is produced separately, by a closure on the expression:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="py"&gt;.chain_property&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Photo&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"size"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="py"&gt;.chain_closure&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;glib&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nd"&gt;closure!&lt;/span&gt;&lt;span class="p"&gt;(|&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Option&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nn"&gt;glib&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Object&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;u64&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nn"&gt;glib&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;format_size&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;size&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nf"&gt;.to_string&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;}))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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 (&lt;code&gt;gtk::Collation::Filename&lt;/code&gt;, 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.&lt;/p&gt;




&lt;h2&gt;
  
  
  What the sorter does with each click
&lt;/h2&gt;

&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;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"])
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A &lt;a href="https://docs.gtk.org/gtk4/class.ColumnViewSorter.html" rel="noopener noreferrer"&gt;ColumnViewSorter&lt;/a&gt; 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.&lt;/p&gt;

&lt;p&gt;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 &lt;a href="https://gitlab.gnome.org/GNOME/gtk/-/blob/gtk-4-22/gtk/gtkcolumnviewsorter.c" rel="noopener noreferrer"&gt;GTK's source&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;In a scratch test I called sort_by_column for Taken, then Size, then Camera, and each time &lt;code&gt;n_sort_columns()&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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 &lt;a href="https://docs.gtk.org/gtk4/class.ColumnViewSorter.html" rel="noopener noreferrer"&gt;ColumnViewSorter docs&lt;/a&gt; (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.&lt;/p&gt;




&lt;h2&gt;
  
  
  Keeping the table sorted when a photo changes
&lt;/h2&gt;

&lt;p&gt;Header sorting is a SortListModel underneath, so it inherits the problem the last two posts dealt with: SortListModel has no &lt;code&gt;watch-items&lt;/code&gt;, 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.&lt;/p&gt;

&lt;p&gt;The first version of the Rate up button ended with &lt;code&gt;sorter.changed(gtk::SorterChange::Different)&lt;/code&gt; 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:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What the SortListModel holds&lt;/th&gt;
&lt;th&gt;After changed(Different)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;NumericSorter on rating&lt;/td&gt;
&lt;td&gt;out of order&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MultiSorter holding that NumericSorter&lt;/td&gt;
&lt;td&gt;out of order&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ColumnViewSorter holding that NumericSorter&lt;/td&gt;
&lt;td&gt;in order&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;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. &lt;a href="https://gitlab.gnome.org/GNOME/gtk/-/blob/gtk-4-22/gtk/gtksortlistmodel.c" rel="noopener noreferrer"&gt;GTK's source&lt;/a&gt; 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.&lt;/p&gt;

&lt;p&gt;The example doesn't rely on it. The store from the last post comes across as &lt;code&gt;PhotoStore&lt;/code&gt;, and Rate up just sets the rating:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="n"&gt;rate_up&lt;/span&gt;&lt;span class="nf"&gt;.connect_clicked&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;move&lt;/span&gt; &lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;photo&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;selected_photos&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;photo&lt;/span&gt;&lt;span class="nf"&gt;.set_rating&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;photo&lt;/span&gt;&lt;span class="nf"&gt;.rating&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nf"&gt;.min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;selected_photos&lt;/code&gt; copies the selected items into a &lt;code&gt;Vec&lt;/code&gt; before the loop starts. Each &lt;code&gt;set_rating&lt;/code&gt; re-sorts the model and moves positions around, so walking the selection's bitset live would skip or repeat photos.&lt;/p&gt;

&lt;p&gt;The store hears &lt;code&gt;notify::rating&lt;/code&gt; and emits &lt;code&gt;items_changed&lt;/code&gt; 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.&lt;/p&gt;




&lt;h2&gt;
  
  
  The same photos as a grid
&lt;/h2&gt;

&lt;p&gt;The GridView takes the same selection model as the table:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;grid_view&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;gtk&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;GridView&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;selection&lt;/span&gt;&lt;span class="nf"&gt;.clone&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt; &lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;tile&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;factory&lt;/span&gt;&lt;span class="p"&gt;()));&lt;/span&gt;
&lt;span class="n"&gt;grid_view&lt;/span&gt;&lt;span class="nf"&gt;.set_min_columns&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;grid_view&lt;/span&gt;&lt;span class="nf"&gt;.set_max_columns&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;My photo app, &lt;a href="https://fromthearchitect.dev/series/building-moments/" rel="noopener noreferrer"&gt;Moments&lt;/a&gt;, 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;




&lt;h2&gt;
  
  
  Sharp edges
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Header clicks do nothing until you wire the sorter.&lt;/strong&gt; Hand &lt;code&gt;column_view.sorter()&lt;/code&gt; to a SortListModel under the selection, which means building the view before its model.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sort on stored values, format for display.&lt;/strong&gt; A NumericSorter on bytes or timestamps, and a closure on the expression for the label.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ellipsize labels in resizable columns.&lt;/strong&gt; Otherwise the longest label sets the narrowest the column can go.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;sort_by_column replaces the sort; a header click adds to it.&lt;/strong&gt; The tie-breaker stack only builds up through clicks, and only a click on the front column flips its direction.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Report item changes from the store.&lt;/strong&gt; &lt;code&gt;changed()&lt;/code&gt; 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.&lt;/p&gt;




&lt;h2&gt;
  
  
  Next up
&lt;/h2&gt;

&lt;p&gt;The table is flat: every photo is one row at the same level. The next post adds hierarchy with &lt;code&gt;GtkTreeListModel&lt;/code&gt; and &lt;code&gt;GtkTreeExpander&lt;/code&gt;, 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.&lt;/p&gt;

&lt;p&gt;The runnable version of this example is in the &lt;a href="https://github.com/fromthearchitect/gtk-widgets-field-guide/tree/main/07-columns-grids" rel="noopener noreferrer"&gt;companion repo&lt;/a&gt;: &lt;code&gt;cd 07-columns-grids &amp;amp;&amp;amp; cargo run&lt;/code&gt;. One widget at a time. See you in the next one.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Assisted by Claude Code (claude-opus-5-5). The experiments, bugs and conclusions are mine. How I work with AI tools is in &lt;a href="https://fromthearchitect.dev/posts/the-ai-pair-programmer/" rel="noopener noreferrer"&gt;The AI pair programmer&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>gtk</category>
      <category>rust</category>
      <category>gnome</category>
      <category>libadwaita</category>
    </item>
    <item>
      <title>Keeping a GTK4 SortListModel in Order When Items Change</title>
      <dc:creator>Justin - FromTheArchitect</dc:creator>
      <pubDate>Fri, 23 Oct 2026 10:00:00 +0000</pubDate>
      <link>https://dev.to/fromthearchitect/keeping-a-gtk4-sortlistmodel-in-order-when-items-change-2763</link>
      <guid>https://dev.to/fromthearchitect/keeping-a-gtk4-sortlistmodel-in-order-when-items-change-2763</guid>
      <description>&lt;p&gt;&lt;em&gt;This is the seventh entry in &lt;a href="https://dev.to/fromthearchitect/a-field-guide-to-gtk-widgets-3m4f"&gt;A Field Guide to GTK Widgets&lt;/a&gt;, a series about which widget to reach for, when, and what bites you when you do. It picks up where &lt;a href="https://dev.to/fromthearchitect/filtering-and-sorting-without-re-querying-filterlistmodel-and-sortlistmodel-31h4"&gt;the filter and sort post&lt;/a&gt; left off and replaces the workaround that one ended on. The complete, runnable code lives in the &lt;a href="https://github.com/fromthearchitect/gtk-widgets-field-guide/tree/main/06-items-changed" rel="noopener noreferrer"&gt;companion repo&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Where we left off
&lt;/h2&gt;

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

&lt;p&gt;Hiding done tasks had to be a &lt;code&gt;BoolFilter&lt;/code&gt; with &lt;code&gt;watch-items&lt;/code&gt; switched on, because watch-items only sees properties through filters built from expressions. A &lt;code&gt;CustomFilter&lt;/code&gt; 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 &lt;code&gt;sorter.changed(gtk::SorterChange::Different)&lt;/code&gt; to make it look again.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;It also bothered me as an engineer. &lt;code&gt;SorterChange::Different&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;




&lt;h2&gt;
  
  
  Reporting a change in place
&lt;/h2&gt;

&lt;p&gt;Every list model has an &lt;code&gt;items-changed&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;Nothing stops a model from emitting &lt;code&gt;items_changed(pos, 1, 1)&lt;/code&gt; for an item that hasn't gone anywhere. It reads as one item removed at &lt;code&gt;pos&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;




&lt;h2&gt;
  
  
  A store that reports
&lt;/h2&gt;

&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;ids&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="nf"&gt;.connect_title_notify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="nf"&gt;.reporter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
        &lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="nf"&gt;.connect_done_notify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="nf"&gt;.reporter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"done"&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
        &lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="nf"&gt;.connect_priority_notify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="nf"&gt;.reporter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"priority"&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
    &lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.handlers&lt;/span&gt;&lt;span class="nf"&gt;.borrow_mut&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="nf"&gt;.insert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="nf"&gt;.clone&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;ids&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.store&lt;/span&gt;&lt;span class="nf"&gt;.append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;connect_done_notify&lt;/code&gt; and its siblings come from the &lt;code&gt;#[property]&lt;/code&gt; derive, one per property, and each fires only for its own property. The handler finds the task's position and reports it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;reporter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;property&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;'static&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;impl&lt;/span&gt; &lt;span class="nf"&gt;Fn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="k"&gt;'static&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;store&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.store&lt;/span&gt;&lt;span class="nf"&gt;.downgrade&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;move&lt;/span&gt; &lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="nf"&gt;.upgrade&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;};&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;position&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="nf"&gt;.find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nd"&gt;println!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"{property} changed on {:?}: items_changed({position}, 1, 1)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="nf"&gt;.title&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
            &lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="nf"&gt;.items_changed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;position&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The repo's version also has a switch for turning reporting off, which comes up later.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;GNOME Contacts makes the same call in &lt;a href="https://gitlab.gnome.org/GNOME/gnome-contacts/-/blob/main/src/contacts-store.vala" rel="noopener noreferrer"&gt;contacts-store.vala&lt;/a&gt;. 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.&lt;/p&gt;

&lt;p&gt;Each report costs a &lt;code&gt;store.find(task)&lt;/code&gt;, 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.&lt;/p&gt;




&lt;h2&gt;
  
  
  Taking a task out
&lt;/h2&gt;

&lt;p&gt;Adding handlers is the easy half. When a task leaves the store its handlers have to go too:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;remove&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;position&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.store&lt;/span&gt;&lt;span class="nf"&gt;.find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.store&lt;/span&gt;&lt;span class="nf"&gt;.remove&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;position&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ids&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.handlers&lt;/span&gt;&lt;span class="nf"&gt;.borrow_mut&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="nf"&gt;.remove&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;ids&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="nf"&gt;.disconnect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is where the observer pattern usually goes wrong. Forgetting to unsubscribe has a name, the &lt;a href="https://en.wikipedia.org/wiki/Lapsed_listener_problem" rel="noopener noreferrer"&gt;lapsed listener&lt;/a&gt;. 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;




&lt;h2&gt;
  
  
  What the log showed
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;"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.&lt;/p&gt;

&lt;p&gt;The rest is in this table:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Change&lt;/th&gt;
&lt;th&gt;What the view saw&lt;/th&gt;
&lt;th&gt;Rows rebound&lt;/th&gt;
&lt;th&gt;Selection&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Raise to P1 under Title sort, task stays put&lt;/td&gt;
&lt;td&gt;items-changed(2, -1, +1)&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;td&gt;kept&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Raise to P1 under Priority sort, task moves up&lt;/td&gt;
&lt;td&gt;items-changed(2, -7, +7)&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;td&gt;kept&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Select all ten, Mark done under Title sort&lt;/td&gt;
&lt;td&gt;ten single-item changes&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;td&gt;all ten kept&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mark done with "hide done" on&lt;/td&gt;
&lt;td&gt;items-changed(0, -1, +0)&lt;/td&gt;
&lt;td&gt;one unbind&lt;/td&gt;
&lt;td&gt;gone with the row&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Ten changes in a row meant ten signals and no visible cost.&lt;/p&gt;




&lt;h2&gt;
  
  
  At scale
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tasks&lt;/th&gt;
&lt;th&gt;sorter.changed(Different)&lt;/th&gt;
&lt;th&gt;items_changed(pos, 1, 1)&lt;/th&gt;
&lt;th&gt;of which find()&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;0.012 ms&lt;/td&gt;
&lt;td&gt;0.005 ms&lt;/td&gt;
&lt;td&gt;under 0.001 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1,000&lt;/td&gt;
&lt;td&gt;0.57 ms&lt;/td&gt;
&lt;td&gt;0.016 ms&lt;/td&gt;
&lt;td&gt;0.004 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10,000&lt;/td&gt;
&lt;td&gt;5.5 ms&lt;/td&gt;
&lt;td&gt;0.066 ms&lt;/td&gt;
&lt;td&gt;0.035 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;50,000&lt;/td&gt;
&lt;td&gt;28 ms&lt;/td&gt;
&lt;td&gt;0.40 ms&lt;/td&gt;
&lt;td&gt;0.28 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;100,000&lt;/td&gt;
&lt;td&gt;53.5 ms&lt;/td&gt;
&lt;td&gt;0.60 ms&lt;/td&gt;
&lt;td&gt;0.43 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The benchmark also checked that the list was still in order after each run, and under the Priority sort that check failed. &lt;code&gt;sorter.changed(gtk::SorterChange::Different)&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;That contradicts the last post, which recommended the sorter call for expression sorters too. I've added a correction there.&lt;/p&gt;




&lt;h2&gt;
  
  
  The change nobody reported
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;items_changed(0, n, n)&lt;/code&gt; 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 &lt;code&gt;changed()&lt;/code&gt; on the sorter won't do it for an expression sorter, as the benchmark showed.&lt;/p&gt;




&lt;h2&gt;
  
  
  When a reusable model is worth it
&lt;/h2&gt;

&lt;p&gt;TaskStore is written for one item type and one list. That's the shape Contacts uses too: handlers on a plain GListStore.&lt;/p&gt;

&lt;p&gt;Fractal, the GNOME Matrix client, goes a step further. Its &lt;a href="https://gitlab.gnome.org/World/fractal/-/blob/main/src/utils/expression_list_model.rs" rel="noopener noreferrer"&gt;ExpressionListModel&lt;/a&gt; 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;




&lt;h2&gt;
  
  
  Which fix to use
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Approach&lt;/th&gt;
&lt;th&gt;Catches changes from anywhere&lt;/th&gt;
&lt;th&gt;Works with&lt;/th&gt;
&lt;th&gt;Per change, 100,000 tasks&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;sorter.changed(Different) in the button handler&lt;/td&gt;
&lt;td&gt;No, only changes made there&lt;/td&gt;
&lt;td&gt;CustomSorter only&lt;/td&gt;
&lt;td&gt;53.5 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;watch-items on the FilterListModel&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Expression filters, no sorting&lt;/td&gt;
&lt;td&gt;not measured&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;items_changed(pos, 1, 1) from the store&lt;/td&gt;
&lt;td&gt;Yes, for the properties it watches&lt;/td&gt;
&lt;td&gt;Any filter or sorter&lt;/td&gt;
&lt;td&gt;0.6 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;items_changed(0, n, n) as a reset&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Any filter or sorter&lt;/td&gt;
&lt;td&gt;0.94 s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;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.&lt;/p&gt;




&lt;h2&gt;
  
  
  Next up
&lt;/h2&gt;

&lt;p&gt;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 &lt;code&gt;GtkColumnView&lt;/code&gt; and &lt;code&gt;GtkGridView&lt;/code&gt;, where clickable column headers come down to handing &lt;code&gt;column_view.sorter()&lt;/code&gt; to a SortListModel. The store from this post keeps that in order too.&lt;/p&gt;

&lt;p&gt;The runnable version of this example is in the &lt;a href="https://github.com/fromthearchitect/gtk-widgets-field-guide/tree/main/06-items-changed" rel="noopener noreferrer"&gt;companion repo&lt;/a&gt;: &lt;code&gt;cd 06-items-changed &amp;amp;&amp;amp; cargo run&lt;/code&gt;. One widget at a time. See you in the next one.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Assisted by Claude Code (claude-opus-5-5). The experiments, bugs and conclusions are mine. How I work with AI tools is in &lt;a href="https://fromthearchitect.dev/posts/the-ai-pair-programmer/" rel="noopener noreferrer"&gt;The AI pair programmer&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>gtk</category>
      <category>rust</category>
      <category>gnome</category>
      <category>libadwaita</category>
    </item>
    <item>
      <title>Filtering and Sorting Without Re-querying: FilterListModel and SortListModel</title>
      <dc:creator>Justin - FromTheArchitect</dc:creator>
      <pubDate>Thu, 15 Oct 2026 10:53:57 +0000</pubDate>
      <link>https://dev.to/fromthearchitect/filtering-and-sorting-without-re-querying-filterlistmodel-and-sortlistmodel-31h4</link>
      <guid>https://dev.to/fromthearchitect/filtering-and-sorting-without-re-querying-filterlistmodel-and-sortlistmodel-31h4</guid>
      <description>&lt;p&gt;&lt;em&gt;This is the sixth entry in &lt;a href="https://dev.to/fromthearchitect/a-field-guide-to-gtk-widgets-3m4f"&gt;A Field Guide to GTK Widgets&lt;/a&gt;, a series about which widget to reach for, when, and what bites you when you do. The &lt;a href="https://dev.to/fromthearchitect/selection-modelled-why-gtk4-puts-selection-in-the-model-not-the-view-2hj7"&gt;previous post&lt;/a&gt; put selection in the model. This one adds search and sort underneath it. The complete, runnable code lives in the &lt;a href="https://github.com/fromthearchitect/gtk-widgets-field-guide/tree/main/05-filter-sort" rel="noopener noreferrer"&gt;companion repo&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Where we left off
&lt;/h2&gt;

&lt;p&gt;Last time the stack was two layers deep: a &lt;code&gt;GListStore&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;




&lt;h2&gt;
  
  
  The stack
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;filter_model&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;gtk&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;FilterListModel&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="nf"&gt;.clone&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt; &lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;every&lt;/span&gt;&lt;span class="nf"&gt;.clone&lt;/span&gt;&lt;span class="p"&gt;()));&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;sort_model&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
    &lt;span class="nn"&gt;gtk&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;SortListModel&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;filter_model&lt;/span&gt;&lt;span class="nf"&gt;.clone&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt; &lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;sorters&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;by_index&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)));&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;selection&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;gtk&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;MultiSelection&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sort_model&lt;/span&gt;&lt;span class="nf"&gt;.clone&lt;/span&gt;&lt;span class="p"&gt;()));&lt;/span&gt;

&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;list_view&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;gtk&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;ListView&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;selection&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;task_row&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;factory&lt;/span&gt;&lt;span class="p"&gt;()));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;FilterListModel&lt;/code&gt; and &lt;code&gt;SortListModel&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;You don't connect anything between the layers. Each wrapper subscribes to its child's &lt;code&gt;items-changed&lt;/code&gt; 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.&lt;/p&gt;




&lt;h2&gt;
  
  
  Task has to become a GObject
&lt;/h2&gt;

&lt;p&gt;Up to now &lt;code&gt;Task&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;So Task becomes a GObject subclass with three real properties:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="nd"&gt;#[derive(Default,&lt;/span&gt; &lt;span class="nd"&gt;Properties)]&lt;/span&gt;
&lt;span class="nd"&gt;#[properties(wrapper_type&lt;/span&gt; &lt;span class="nd"&gt;=&lt;/span&gt; &lt;span class="nd"&gt;super::Task)]&lt;/span&gt;
&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nd"&gt;#[property(get,&lt;/span&gt; &lt;span class="nd"&gt;set)]&lt;/span&gt;
    &lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;RefCell&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nd"&gt;#[property(get,&lt;/span&gt; &lt;span class="nd"&gt;set)]&lt;/span&gt;
    &lt;span class="n"&gt;done&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Cell&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nd"&gt;#[property(get,&lt;/span&gt; &lt;span class="nd"&gt;set)]&lt;/span&gt;
    &lt;span class="n"&gt;priority&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Cell&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;u32&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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 &lt;code&gt;#[property(get, set)]&lt;/code&gt; attribute is new to you, &lt;a href="https://fromthearchitect.dev/posts/gnome-rust-part-2-gobject/" rel="noopener noreferrer"&gt;GObject in Rust: The Type System Explained&lt;/a&gt; walks through the same pattern one piece at a time. For now what matters is that &lt;code&gt;task.done()&lt;/code&gt; and &lt;code&gt;task.set_done(true)&lt;/code&gt; exist, and that setting a property emits &lt;code&gt;notify::done&lt;/code&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Searching and hiding with filters
&lt;/h2&gt;

&lt;p&gt;The search box is a &lt;code&gt;StringFilter&lt;/code&gt;. You give it an expression naming the property to match against, and it does the rest:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;property&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nn"&gt;gtk&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;PropertyExpression&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nn"&gt;gtk&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;PropertyExpression&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;static_type&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nn"&gt;gtk&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;Expression&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;NONE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nn"&gt;gtk&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;StringFilter&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;filter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;gtk&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;StringFilter&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;property&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;)));&lt;/span&gt;
    &lt;span class="n"&gt;filter&lt;/span&gt;&lt;span class="nf"&gt;.set_match_mode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;gtk&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;StringFilterMatchMode&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Substring&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;filter&lt;/span&gt;&lt;span class="nf"&gt;.set_ignore_case&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;filter&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

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

&lt;p&gt;Hiding done tasks is a &lt;code&gt;BoolFilter&lt;/code&gt; on &lt;code&gt;done&lt;/code&gt;, inverted so it keeps the tasks that aren't. To combine the two I put both in an &lt;code&gt;EveryFilter&lt;/code&gt;, 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 &lt;code&gt;every.n_items() - 1&lt;/code&gt; relies on the bool filter being the last child, which holds here because nothing else is ever appended after it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="n"&gt;hide&lt;/span&gt;&lt;span class="nf"&gt;.connect_toggled&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;move&lt;/span&gt; &lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="n"&gt;button&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;button&lt;/span&gt;&lt;span class="nf"&gt;.is_active&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;every&lt;/span&gt;&lt;span class="nf"&gt;.append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;not_done&lt;/span&gt;&lt;span class="nf"&gt;.clone&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;every&lt;/span&gt;&lt;span class="nf"&gt;.remove&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;every&lt;/span&gt;&lt;span class="nf"&gt;.n_items&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Adding or removing a child counts as the filter changing, so the FilterListModel re-filters without being told.&lt;/p&gt;

&lt;p&gt;That's the version that works. It isn't the version I wrote first.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why a done task didn't hide
&lt;/h2&gt;

&lt;p&gt;My first "hide done" was a &lt;code&gt;CustomFilter&lt;/code&gt;, the closure form. It read a shared flag and the task's done property:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;filter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;gtk&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;CustomFilter&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;move&lt;/span&gt; &lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;task&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="py"&gt;.downcast_ref&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="nf"&gt;.unwrap&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="nf"&gt;.get&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="nf"&gt;.done&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A closure gives GTK no way to know when its answer changes, so the toggle handler flipped the flag and then called &lt;code&gt;filter.changed(gtk::FilterChange::MoreStrict)&lt;/code&gt;. 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;My first suspect was &lt;code&gt;store.clone()&lt;/code&gt;. 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.&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;watch-items&lt;/code&gt; property for this, added in GTK 4.20 and off by default. In gtk4-rs, &lt;code&gt;set_watch_items&lt;/code&gt; only exists when the crate's &lt;code&gt;v4_20&lt;/code&gt; feature is on (the companion code gets it through &lt;code&gt;gnome_50&lt;/code&gt;), 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.&lt;/p&gt;

&lt;p&gt;GTK's own documentation for set_watch_items is more careful:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;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:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Filter&lt;/th&gt;
&lt;th&gt;watch-items&lt;/th&gt;
&lt;th&gt;Task marked done&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;BoolFilter on done&lt;/td&gt;
&lt;td&gt;off&lt;/td&gt;
&lt;td&gt;row stays&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;BoolFilter on done&lt;/td&gt;
&lt;td&gt;on&lt;/td&gt;
&lt;td&gt;row removed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CustomFilter reading done&lt;/td&gt;
&lt;td&gt;on&lt;/td&gt;
&lt;td&gt;row stays&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;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.&lt;/p&gt;




&lt;h2&gt;
  
  
  Re-sorting after the data changes
&lt;/h2&gt;

&lt;p&gt;The dropdown offers three orders, and swapping between them is &lt;code&gt;sort_model.set_sorter(...)&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;title&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nn"&gt;gtk&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;StringSorter&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nn"&gt;gtk&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;StringSorter&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;property&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;priority&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nn"&gt;gtk&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Sorter&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;sorter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;gtk&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;MultiSorter&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="n"&gt;sorter&lt;/span&gt;&lt;span class="nf"&gt;.append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;gtk&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;NumericSorter&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;property&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"priority"&lt;/span&gt;&lt;span class="p"&gt;))));&lt;/span&gt;
    &lt;span class="n"&gt;sorter&lt;/span&gt;&lt;span class="nf"&gt;.append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;title&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
    &lt;span class="n"&gt;sorter&lt;/span&gt;&lt;span class="nf"&gt;.upcast&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;p&gt;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.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Correction, 18 October 2026&lt;/strong&gt;&lt;br&gt;
The sorter call below only re-sorts a CustomSorter, such as Open first. Benchmarking a follow-up post on GTK 4.22.5 found that &lt;code&gt;sorter.changed(gtk::SorterChange::Different)&lt;/code&gt; leaves expression sorters (a NumericSorter, a StringSorter, or a MultiSorter of them) out of order after their items' properties change, so under the Priority sort it does nothing. Reporting the changed item with &lt;code&gt;items_changed(pos, 1, 1)&lt;/code&gt; works for every sorter type, and &lt;a href="https://fromthearchitect.dev/posts/gtk-widgets-items-changed/" rel="noopener noreferrer"&gt;the follow-up post&lt;/a&gt; shows how.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The fix is to tell the sorter yourself, after you change the data:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="n"&gt;mark_done&lt;/span&gt;&lt;span class="nf"&gt;.connect_clicked&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;move&lt;/span&gt; &lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;task&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;selected_tasks&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="nf"&gt;.set_done&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sorter&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sorted&lt;/span&gt;&lt;span class="nf"&gt;.sorter&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;sorter&lt;/span&gt;&lt;span class="nf"&gt;.changed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;gtk&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;SorterChange&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Different&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;A sturdier fix&lt;/strong&gt;&lt;br&gt;
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 &lt;code&gt;items_changed(pos, 1, 1)&lt;/code&gt; 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 &lt;a href="https://gitlab.gnome.org/GNOME/gnome-contacts/-/blob/main/src/contacts-store.vala" rel="noopener noreferrer"&gt;contacts-store.vala&lt;/a&gt;, listening only to the two properties that affect its order. Fractal wraps it in a reusable &lt;a href="https://gitlab.gnome.org/World/fractal/-/blob/main/src/utils/expression_list_model.rs" rel="noopener noreferrer"&gt;ExpressionListModel&lt;/a&gt; that sits under its SortListModels. That approach gets its own post next.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Binding rows with expressions
&lt;/h2&gt;

&lt;p&gt;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 &lt;code&gt;item&lt;/code&gt; property:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;gtk&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;ListItem&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;this_expression&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"item"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="py"&gt;.chain_property&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;.bind&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"label"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;list_item&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="py"&gt;.chain_property&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"done"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;.bind&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;done&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"visible"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;list_item&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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 &lt;a href="https://dev.to/fromthearchitect/your-first-factory-gtklistview-and-the-bindunbind-rhythm-4c1d"&gt;the factory post&lt;/a&gt; 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.&lt;/p&gt;




&lt;h2&gt;
  
  
  What happens to the selection
&lt;/h2&gt;

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

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;What it doesn't do is tell you. That re-sort emitted items-changed on the selection model and no &lt;code&gt;selection-changed&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;None&lt;/code&gt; and stays &lt;code&gt;None&lt;/code&gt; when the row comes back. The neighbour is arguably the worse outcome, because nothing on screen looks wrong.&lt;/p&gt;

&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="n"&gt;selection&lt;/span&gt;&lt;span class="nf"&gt;.connect_selection_changed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;move&lt;/span&gt; &lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="n"&gt;selection&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;selection&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="n"&gt;selection&lt;/span&gt;&lt;span class="nf"&gt;.connect_items_changed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;move&lt;/span&gt; &lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="n"&gt;selection&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;selection&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Avoiding a reference cycle in your handlers
&lt;/h2&gt;

&lt;p&gt;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 &lt;code&gt;selection&lt;/code&gt; so it could read the counts. That closure was then connected to a signal on &lt;code&gt;selection&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;update_status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="nf"&gt;.clone&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="nf"&gt;.clone&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
    &lt;span class="nn"&gt;Rc&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;move&lt;/span&gt; &lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="n"&gt;selection&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nn"&gt;gtk&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;MultiSelection&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="nf"&gt;.set_label&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nd"&gt;format!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="s"&gt;"{} of {} shown, {} selected"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;selection&lt;/span&gt;&lt;span class="nf"&gt;.n_items&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
            &lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="nf"&gt;.n_items&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
            &lt;span class="n"&gt;selection&lt;/span&gt;&lt;span class="nf"&gt;.selection&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="nf"&gt;.size&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The label and the store are still captured strongly, which is fine: neither of them holds the selection, so there's no loop. &lt;code&gt;glib::clone!&lt;/code&gt; 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.&lt;/p&gt;




&lt;h2&gt;
  
  
  Sharp edges
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Build filters from expressions if items change.&lt;/strong&gt; 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 &lt;code&gt;changed()&lt;/code&gt; call.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sorting never notices item changes.&lt;/strong&gt; 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 &lt;code&gt;changed()&lt;/code&gt; call, or the model owes the list an items-changed.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Corrected 18 October 2026: the &lt;code&gt;changed()&lt;/code&gt; call only works for a CustomSorter. For expression sorters, report the item with items-changed instead, as &lt;a href="https://fromthearchitect.dev/posts/gtk-widgets-items-changed/" rel="noopener noreferrer"&gt;the follow-up post&lt;/a&gt; does.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Listen to items-changed as well as selection-changed.&lt;/strong&gt; 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.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Collect before you mutate.&lt;/strong&gt; With watch-items on, changing an item can remove its row and shift the positions you were about to visit.&lt;/p&gt;




&lt;h2&gt;
  
  
  Next up
&lt;/h2&gt;

&lt;p&gt;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, &lt;code&gt;GtkColumnView&lt;/code&gt; and &lt;code&gt;GtkGridView&lt;/code&gt;, where clickable column headers come down to handing &lt;code&gt;column_view.sorter()&lt;/code&gt; to a SortListModel.&lt;/p&gt;

&lt;p&gt;The runnable version of this example is in the &lt;a href="https://github.com/fromthearchitect/gtk-widgets-field-guide/tree/main/05-filter-sort" rel="noopener noreferrer"&gt;companion repo&lt;/a&gt;: &lt;code&gt;cd 05-filter-sort &amp;amp;&amp;amp; cargo run&lt;/code&gt;. One widget at a time. See you in the next one.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Assisted by Claude Code (claude-opus-5-5). The experiments, bugs and conclusions are mine. How I work with AI tools is in &lt;a href="https://fromthearchitect.dev/posts/the-ai-pair-programmer/" rel="noopener noreferrer"&gt;The AI pair programmer&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>gtk</category>
      <category>rust</category>
      <category>gnome</category>
      <category>libadwaita</category>
    </item>
    <item>
      <title>Selection, Modelled: Why GTK4 Puts Selection in the Model, Not the View</title>
      <dc:creator>Justin - FromTheArchitect</dc:creator>
      <pubDate>Mon, 28 Sep 2026 23:00:00 +0000</pubDate>
      <link>https://dev.to/fromthearchitect/selection-modelled-why-gtk4-puts-selection-in-the-model-not-the-view-2hj7</link>
      <guid>https://dev.to/fromthearchitect/selection-modelled-why-gtk4-puts-selection-in-the-model-not-the-view-2hj7</guid>
      <description>&lt;p&gt;&lt;em&gt;This is the fifth entry in &lt;a href="https://fromthearchitect.dev/posts/gtk-widgets-field-guide/" rel="noopener noreferrer"&gt;A Field Guide to GTK Widgets&lt;/a&gt; — a series about which widget to reach for, when, and what bites you when you do. The &lt;a href="https://fromthearchitect.dev/posts/gtk-widgets-first-factory/" rel="noopener noreferrer"&gt;previous post&lt;/a&gt; got rows behaving correctly under recycling. This one is about which of them is selected. The complete, runnable code lives in the &lt;a href="https://github.com/fromthearchitect/gtk-widgets-field-guide/tree/main/04-selection" rel="noopener noreferrer"&gt;companion repo&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Where we left off
&lt;/h2&gt;

&lt;p&gt;Two posts ago I wrapped the list store in a &lt;code&gt;NoSelection&lt;/code&gt; and moved on, with a note that &lt;code&gt;GtkListView&lt;/code&gt; won't accept a bare &lt;code&gt;GListModel&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;Before any of the type names matter, it's worth knowing &lt;em&gt;where&lt;/em&gt; selection lives, because GTK4 moved it and that move is the whole design. In the &lt;code&gt;GtkTreeView&lt;/code&gt; world you asked the view. &lt;code&gt;gtk_tree_view_get_selection()&lt;/code&gt; handed you a &lt;code&gt;GtkTreeSelection&lt;/code&gt; 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 &lt;strong&gt;model&lt;/strong&gt;, 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 &lt;code&gt;get_selection()&lt;/code&gt; at all.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;




&lt;h2&gt;
  
  
  Three models, one interface
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;GtkSelectionModel&lt;/code&gt; 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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;selection&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;gtk&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;NoSelection&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="nf"&gt;.clone&lt;/span&gt;&lt;span class="p"&gt;()));&lt;/span&gt;      &lt;span class="c1"&gt;// nothing selectable&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;selection&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;gtk&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;SingleSelection&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="nf"&gt;.clone&lt;/span&gt;&lt;span class="p"&gt;()));&lt;/span&gt;  &lt;span class="c1"&gt;// at most one&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;selection&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;gtk&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;MultiSelection&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="nf"&gt;.clone&lt;/span&gt;&lt;span class="p"&gt;()));&lt;/span&gt;   &lt;span class="c1"&gt;// any number&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The decoration is the part to hold onto. The view doesn't see your &lt;code&gt;GListStore&lt;/code&gt;; 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.&lt;/p&gt;

&lt;p&gt;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. &lt;code&gt;SingleSelection&lt;/code&gt; for master-detail, where picking a row drives a pane somewhere else. &lt;code&gt;MultiSelection&lt;/code&gt; 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.&lt;/p&gt;




&lt;h2&gt;
  
  
  SingleSelection and the autoselect surprise
&lt;/h2&gt;

&lt;p&gt;SingleSelection gives you two properties: &lt;code&gt;selected&lt;/code&gt;, a &lt;code&gt;u32&lt;/code&gt; position, and &lt;code&gt;selected-item&lt;/code&gt;, the object itself. Watching either one is how you wire selection outward:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="n"&gt;selection&lt;/span&gt;&lt;span class="nf"&gt;.connect_selected_item_notify&lt;/span&gt;&lt;span class="p"&gt;(|&lt;/span&gt;&lt;span class="n"&gt;selection&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;selection&lt;/span&gt;&lt;span class="nf"&gt;.selected_item&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;boxed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="py"&gt;.downcast&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;BoxedAnyObject&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="nf"&gt;.unwrap&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Ref&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;boxed&lt;/span&gt;&lt;span class="nf"&gt;.borrow&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="nd"&gt;println!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"selected: {}"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="py"&gt;.title&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Now the surprise. SingleSelection has an &lt;code&gt;autoselect&lt;/code&gt; property that &lt;strong&gt;defaults to &lt;code&gt;TRUE&lt;/code&gt;&lt;/strong&gt;. 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.&lt;/p&gt;

&lt;p&gt;A second property is involved: &lt;code&gt;can-unselect&lt;/code&gt;, defaulting to &lt;code&gt;FALSE&lt;/code&gt;. The name reads like a user-interaction flag, something about whether ctrl-click can deselect. Its scope is wider than that. It gates &lt;em&gt;every&lt;/em&gt; route to an empty selection, your own code included. With can-unselect off, &lt;code&gt;set_selected(gtk::INVALID_LIST_POSITION)&lt;/code&gt; and &lt;code&gt;unselect_all()&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;That leaves four combinations, one of which lets a SingleSelection end up empty:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;autoselect&lt;/th&gt;
&lt;th&gt;can-unselect&lt;/th&gt;
&lt;th&gt;can it be cleared?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;false&lt;/td&gt;
&lt;td&gt;false&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;false&lt;/td&gt;
&lt;td&gt;true&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;yes&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;true&lt;/td&gt;
&lt;td&gt;false&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;true&lt;/td&gt;
&lt;td&gt;true&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;So you turn autoselect off. Here is where I lost an afternoon:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Looks right. Selects item 0 anyway.&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;selection&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;gtk&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;SingleSelection&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="nf"&gt;.model&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;.autoselect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;.can_unselect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;.build&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Actually starts empty.&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;selection&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;gtk&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;SingleSelection&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="nf"&gt;.autoselect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;.can_unselect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;.model&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;.build&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The other construction paths are worth checking rather than guessing at, because the failure is so quiet. &lt;code&gt;SingleSelection::new(Some(store))&lt;/code&gt; followed by &lt;code&gt;set_autoselect(false)&lt;/code&gt; fails the same way, for the same reason: the model went in first. Constructing empty, setting the property, then calling &lt;code&gt;set_model()&lt;/code&gt; works. If you've already got a model that has made its choice, &lt;code&gt;set_selected(gtk::INVALID_LIST_POSITION)&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;None of these are &lt;code&gt;CONSTRUCT&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;A note on that constant. &lt;code&gt;GTK_INVALID_LIST_POSITION&lt;/code&gt; is &lt;code&gt;u32::MAX&lt;/code&gt;. You rarely need it in Rust, since &lt;code&gt;selected_item()&lt;/code&gt; returns an &lt;code&gt;Option&lt;/code&gt; and the &lt;code&gt;let ... else&lt;/code&gt; above covers the empty case. If you do compare positions numerically, watch the emptiness test in particular. Empty is &lt;code&gt;u32::MAX&lt;/code&gt;, so &lt;code&gt;selected() == 0&lt;/code&gt; 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 &lt;code&gt;-1&lt;/code&gt; at least fails loudly: &lt;code&gt;selected()&lt;/code&gt; returns a u32 and won't compile against it.&lt;/p&gt;




&lt;h2&gt;
  
  
  MultiSelection doesn't have a selected property
&lt;/h2&gt;

&lt;p&gt;The instinct is to reach for the plural of what SingleSelection gave you, and there's nothing there. MultiSelection has no &lt;code&gt;selected-items&lt;/code&gt; property. The signal you connect to lives on the GtkSelectionModel interface itself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="n"&gt;selection&lt;/span&gt;&lt;span class="nf"&gt;.connect_selection_changed&lt;/span&gt;&lt;span class="p"&gt;(|&lt;/span&gt;&lt;span class="n"&gt;selection&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;position&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;n_items&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// position and n_items describe the range that CHANGED,&lt;/span&gt;
    &lt;span class="c1"&gt;// not the range that is now selected.&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;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
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;To find out what's actually selected, re-query the model:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;bitset&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;selection&lt;/span&gt;&lt;span class="nf"&gt;.selection&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;GtkBitset&lt;/code&gt; 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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;bitset&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;selection&lt;/span&gt;&lt;span class="nf"&gt;.selection&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;titles&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;Vec&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;iter&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;first&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;gtk&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;BitsetIter&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;init_first&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;bitset&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;position&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;first&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;position&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;selection&lt;/span&gt;&lt;span class="nf"&gt;.item&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nf"&gt;.unwrap&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;boxed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="py"&gt;.downcast&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;BoxedAnyObject&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="nf"&gt;.unwrap&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Ref&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;boxed&lt;/span&gt;&lt;span class="nf"&gt;.borrow&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="n"&gt;titles&lt;/span&gt;&lt;span class="nf"&gt;.push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="py"&gt;.title&lt;/span&gt;&lt;span class="nf"&gt;.clone&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
        &lt;span class="n"&gt;position&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;iter&lt;/span&gt;&lt;span class="nf"&gt;.next&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;p&gt;The second is the lookup: &lt;code&gt;selection.item(p)&lt;/code&gt; rather than &lt;code&gt;store.item(p)&lt;/code&gt;, 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.&lt;/p&gt;




&lt;h2&gt;
  
  
  Setting selection from code
&lt;/h2&gt;

&lt;p&gt;Everything so far has been reading. The GtkSelectionModel interface is also how you write:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="n"&gt;selection&lt;/span&gt;&lt;span class="nf"&gt;.select_item&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// true = unselect everything else first&lt;/span&gt;
&lt;span class="n"&gt;selection&lt;/span&gt;&lt;span class="nf"&gt;.select_range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;selection&lt;/span&gt;&lt;span class="nf"&gt;.select_all&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="n"&gt;selection&lt;/span&gt;&lt;span class="nf"&gt;.unselect_all&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="n"&gt;selection&lt;/span&gt;&lt;span class="nf"&gt;.set_selection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;selected&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;mask&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;bool&lt;/code&gt; on &lt;code&gt;select_item&lt;/code&gt; is &lt;code&gt;unselect_rest&lt;/code&gt;, and it does real work. &lt;code&gt;select_item(3, false)&lt;/code&gt; on a MultiSelection adds row 3 to whatever was already selected; &lt;code&gt;select_item(3, true)&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;set_selection(&amp;amp;selected, &amp;amp;mask)&lt;/code&gt; takes two bitsets and updates every position in &lt;code&gt;mask&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;false&lt;/code&gt; 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":&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;changed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;selection&lt;/span&gt;&lt;span class="nf"&gt;.select_range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// MultiSelection: true.  SingleSelection: false, and nothing happened.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Sharp edges
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Set autoselect before installing the model.&lt;/strong&gt; Installing the model is what triggers the initial selection, so a builder with &lt;code&gt;.model()&lt;/code&gt; first leaves you item 0 selected and a property claiming otherwise.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Re-query &lt;code&gt;selection()&lt;/code&gt; in every &lt;code&gt;selection-changed&lt;/code&gt; handler.&lt;/strong&gt; 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.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Half the interface does nothing on a SingleSelection.&lt;/strong&gt; 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.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;GTK_INVALID_LIST_POSITION is &lt;code&gt;u32::MAX&lt;/code&gt;.&lt;/strong&gt; &lt;code&gt;selected() == -1&lt;/code&gt; won't compile against a u32, which is the loud failure. &lt;code&gt;selected() == 0&lt;/code&gt; compiles and inverts the emptiness check you meant. Prefer &lt;code&gt;selected_item()&lt;/code&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Next up
&lt;/h2&gt;

&lt;p&gt;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: &lt;code&gt;FilterListModel&lt;/code&gt; and &lt;code&gt;SortListModel&lt;/code&gt;, 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.&lt;/p&gt;

&lt;p&gt;The runnable version of this example is in the &lt;a href="https://github.com/fromthearchitect/gtk-widgets-field-guide/tree/main/04-selection" rel="noopener noreferrer"&gt;companion repo&lt;/a&gt; — &lt;code&gt;cd 04-selection &amp;amp;&amp;amp; cargo run&lt;/code&gt;. One widget at a time. See you in the next one.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Reviewed with Claude, which caught the can-unselect scope error and the SingleSelection interface no-ops. Prose and conclusions are mine.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>gtk</category>
      <category>rust</category>
      <category>gnome</category>
      <category>libadwaita</category>
    </item>
    <item>
      <title>Your First Factory: GtkListView and the Bind/Unbind Rhythm</title>
      <dc:creator>Justin - FromTheArchitect</dc:creator>
      <pubDate>Mon, 21 Sep 2026 23:00:00 +0000</pubDate>
      <link>https://dev.to/fromthearchitect/your-first-factory-gtklistview-and-the-bindunbind-rhythm-4c1d</link>
      <guid>https://dev.to/fromthearchitect/your-first-factory-gtklistview-and-the-bindunbind-rhythm-4c1d</guid>
      <description>&lt;p&gt;&lt;em&gt;This is the fourth entry in &lt;a href="https://fromthearchitect.dev/posts/gtk-widgets-field-guide/" rel="noopener noreferrer"&gt;A Field Guide to GTK Widgets&lt;/a&gt; — a series about which widget to reach for, when, and what bites you when you do. The &lt;a href="https://fromthearchitect.dev/posts/gtk-widgets-list-mindset/" rel="noopener noreferrer"&gt;previous post&lt;/a&gt; got a list on screen with a factory it deliberately didn't explain. This one explains it. The complete, runnable code lives in the &lt;a href="https://github.com/fromthearchitect/gtk-widgets-field-guide/tree/main/03-first-factory" rel="noopener noreferrer"&gt;companion repo&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Where we left off
&lt;/h2&gt;

&lt;p&gt;The list-mindset post got a &lt;code&gt;GtkListView&lt;/code&gt; on screen with just enough factory code to prove the model-driven approach worked, and it said so directly: &lt;code&gt;setup&lt;/code&gt; builds a row's widgets once, &lt;code&gt;bind&lt;/code&gt; fills them in, and GTK &lt;strong&gt;recycles&lt;/strong&gt; rows as you scroll — the same widgets get re-bound to different items instead of new rows being built for each one. That recycling is the entire reason &lt;code&gt;GtkListView&lt;/code&gt; scrolls well through ten thousand rows without breaking a sweat, and it's also the thing that breaks people's mental model the first time they hit it, because nothing in the API shouts about it. You write &lt;code&gt;bind&lt;/code&gt; once, it looks like a constructor, and it isn't one.&lt;/p&gt;

&lt;p&gt;This post is the factory in full: all four lifecycle signals, what happens if you skip the ones that don't feel necessary, and the second way to build a factory that most tutorials skip straight past.&lt;/p&gt;




&lt;h2&gt;
  
  
  The four signals, not two
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;SignalListItemFactory&lt;/code&gt; exposes four connection points, and the previous post only used two of them. In practice &lt;code&gt;bind&lt;/code&gt; is where most of the real logic lives — it attaches a specific item's data to the widget tree, and runs every time GTK assigns a row slot to an item, including reuse. &lt;code&gt;setup&lt;/code&gt; is the one that runs first: it builds that widget tree, once per recycled row slot rather than once per item. &lt;code&gt;unbind&lt;/code&gt; undoes whatever &lt;code&gt;bind&lt;/code&gt; did, right before the row slot gets reassigned to a different item. &lt;code&gt;teardown&lt;/code&gt; means the widget tree is being destroyed for good — it's the one I reach for least of the four.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="c1"&gt;// task_row.rs&lt;/span&gt;
&lt;span class="n"&gt;factory&lt;/span&gt;&lt;span class="nf"&gt;.connect_setup&lt;/span&gt;&lt;span class="p"&gt;(|&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;list_item&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;label&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;gtk&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;Label&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="nf"&gt;.xalign&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nf"&gt;.build&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="n"&gt;list_item&lt;/span&gt;
        &lt;span class="py"&gt;.downcast_ref&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nn"&gt;gtk&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;ListItem&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="nf"&gt;.unwrap&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="nf"&gt;.set_child&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="n"&gt;factory&lt;/span&gt;&lt;span class="nf"&gt;.connect_bind&lt;/span&gt;&lt;span class="p"&gt;(|&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;list_item&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;list_item&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;list_item&lt;/span&gt;&lt;span class="py"&gt;.downcast_ref&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nn"&gt;gtk&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;ListItem&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="nf"&gt;.unwrap&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;boxed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;list_item&lt;/span&gt;&lt;span class="nf"&gt;.item&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="py"&gt;.and_downcast&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;BoxedAnyObject&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="nf"&gt;.unwrap&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Ref&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;boxed&lt;/span&gt;&lt;span class="nf"&gt;.borrow&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;label&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;list_item&lt;/span&gt;&lt;span class="nf"&gt;.child&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="py"&gt;.and_downcast&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nn"&gt;gtk&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Label&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="nf"&gt;.unwrap&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="nf"&gt;.set_label&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="py"&gt;.title&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="py"&gt;.done&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="nf"&gt;.add_css_class&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"dim-label"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That last line is the trap, and it's the one that got me the first time I hit it. &lt;code&gt;add_css_class&lt;/code&gt; is not idempotent the way &lt;code&gt;set_label&lt;/code&gt; is — it doesn't replace a previous state, it accumulates. Scroll this list far enough that a row gets recycled from a &lt;em&gt;done&lt;/em&gt; task onto a &lt;em&gt;not-done&lt;/em&gt; one, and the class is still there. Nothing crashes. Nothing warns you. You just get a task that renders dim for no reason anyone can see by reading &lt;code&gt;bind&lt;/code&gt; on its own, because the bug isn't in &lt;code&gt;bind&lt;/code&gt; — it's in the signal you didn't write.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why &lt;code&gt;unbind&lt;/code&gt; is the fix, not &lt;code&gt;bind&lt;/code&gt; doing more
&lt;/h2&gt;

&lt;p&gt;The instinct is to patch &lt;code&gt;bind&lt;/code&gt; so it handles both cases:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="nf"&gt;.set_css_classes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="py"&gt;.done&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"dim-label"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That works here, and for a single CSS class it's arguably fine. It stops working the moment &lt;code&gt;bind&lt;/code&gt; does anything that isn't purely declarative — a signal handler connected to a button inside the row, a spawned async task tied to this item, a &lt;code&gt;GtkExpression&lt;/code&gt; watching a property. Reconnecting a signal handler in &lt;code&gt;bind&lt;/code&gt; without disconnecting the previous one doesn't overwrite it; it stacks a second handler on top of the first, and now the row fires its click callback twice, then three times, then four, silently, for as long as the app runs. It's the most common factory bug I've seen reported, and every instance I've traced back had the same root cause: something was set up in &lt;code&gt;bind&lt;/code&gt; with no matching teardown in &lt;code&gt;unbind&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The fix is to treat &lt;code&gt;bind&lt;/code&gt; and &lt;code&gt;unbind&lt;/code&gt; as a pair, symmetrical on purpose:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="n"&gt;factory&lt;/span&gt;&lt;span class="nf"&gt;.connect_bind&lt;/span&gt;&lt;span class="p"&gt;(|&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;list_item&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;list_item&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;list_item&lt;/span&gt;&lt;span class="py"&gt;.downcast_ref&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nn"&gt;gtk&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;ListItem&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="nf"&gt;.unwrap&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;boxed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;list_item&lt;/span&gt;&lt;span class="nf"&gt;.item&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="py"&gt;.and_downcast&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;BoxedAnyObject&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="nf"&gt;.unwrap&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Ref&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;boxed&lt;/span&gt;&lt;span class="nf"&gt;.borrow&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;label&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;list_item&lt;/span&gt;&lt;span class="nf"&gt;.child&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="py"&gt;.and_downcast&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nn"&gt;gtk&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Label&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="nf"&gt;.unwrap&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="nf"&gt;.set_label&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="py"&gt;.title&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="py"&gt;.done&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="nf"&gt;.add_css_class&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"dim-label"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="n"&gt;factory&lt;/span&gt;&lt;span class="nf"&gt;.connect_unbind&lt;/span&gt;&lt;span class="p"&gt;(|&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;list_item&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;list_item&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;list_item&lt;/span&gt;&lt;span class="py"&gt;.downcast_ref&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nn"&gt;gtk&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;ListItem&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="nf"&gt;.unwrap&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;label&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;list_item&lt;/span&gt;&lt;span class="nf"&gt;.child&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="py"&gt;.and_downcast&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nn"&gt;gtk&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Label&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="nf"&gt;.unwrap&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="nf"&gt;.remove_css_class&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"dim-label"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;unbind&lt;/code&gt; runs right before this row slot gets handed to a different item, so anything &lt;code&gt;bind&lt;/code&gt; might have done conditionally gets a guaranteed place to be undone unconditionally. It costs a few extra lines for something as small as one CSS class. It's the difference between a working app and an unreproducible bug report for a connected signal handler, and by the time that bug report shows up, the factory code that caused it is long forgotten.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;setup&lt;/code&gt; and &lt;code&gt;teardown&lt;/code&gt; follow the same pairing, just less often needed: &lt;code&gt;setup&lt;/code&gt; builds the widget tree, &lt;code&gt;teardown&lt;/code&gt; runs when GTK is discarding that row slot entirely (the list shrank, the view is closing), not merely reassigning it. Most factories never touch &lt;code&gt;teardown&lt;/code&gt; because widgets clean up their own children automatically — it exists for things you allocated outside the widget tree and GTK has no way to know about.&lt;/p&gt;




&lt;h2&gt;
  
  
  The other kind of factory
&lt;/h2&gt;

&lt;p&gt;Everything so far has been &lt;code&gt;SignalListItemFactory&lt;/code&gt;: build the row in Rust, imperatively, in a closure. GTK also has &lt;code&gt;BuilderListItemFactory&lt;/code&gt;, which takes a &lt;code&gt;.ui&lt;/code&gt; file — the same GtkBuilder XML you'd use for a whole window — and uses it as the template for each row:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- task_row.ui --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;interface&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;template&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"GtkListItem"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;property&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"child"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;object&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"GtkBox"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;property&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"spacing"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;6&lt;span class="nt"&gt;&amp;lt;/property&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;child&amp;gt;&lt;/span&gt;
          &lt;span class="nt"&gt;&amp;lt;object&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"GtkImage"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
            &lt;span class="nt"&gt;&amp;lt;property&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"icon-name"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;task-due-symbolic&lt;span class="nt"&gt;&amp;lt;/property&amp;gt;&lt;/span&gt;
          &lt;span class="nt"&gt;&amp;lt;/object&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;/child&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;child&amp;gt;&lt;/span&gt;
          &lt;span class="nt"&gt;&amp;lt;object&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"GtkLabel"&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"label"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
            &lt;span class="nt"&gt;&amp;lt;property&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"xalign"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;0.0&lt;span class="nt"&gt;&amp;lt;/property&amp;gt;&lt;/span&gt;
          &lt;span class="nt"&gt;&amp;lt;/object&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;/child&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;/object&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/property&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/template&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/interface&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;bytes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;glib&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;Bytes&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;from_static&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nd"&gt;include_bytes!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"task_row.ui"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;factory&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;gtk&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;BuilderListItemFactory&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;from_bytes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;gtk&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;BuilderScope&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;NONE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The declarative version wins on exactly one axis: rows with real layout — an icon and a label, two labels stacked, a switch on the trailing edge — read as a row when you look at the XML, the way the widget tree reads in Blueprint or &lt;code&gt;.ui&lt;/code&gt; for a window. But &lt;code&gt;BuilderListItemFactory&lt;/code&gt; isn't a variant of the setup/bind/unbind/teardown rhythm — it doesn't have those signals at all. &lt;code&gt;setup&lt;/code&gt;, &lt;code&gt;bind&lt;/code&gt;, &lt;code&gt;unbind&lt;/code&gt;, and &lt;code&gt;teardown&lt;/code&gt; belong specifically to &lt;code&gt;SignalListItemFactory&lt;/code&gt;; the base &lt;code&gt;GtkListItemFactory&lt;/code&gt; class defines none of them, and &lt;code&gt;BuilderListItemFactory&lt;/code&gt; doesn't add any of its own. There is no &lt;code&gt;connect_bind&lt;/code&gt; to call on it. Its only way of getting item data onto a row is a &lt;code&gt;GtkExpression&lt;/code&gt; binding declared in the template itself — &lt;code&gt;&amp;lt;binding&amp;gt;&amp;lt;lookup name="title"&amp;gt;item&amp;lt;/lookup&amp;gt;&amp;lt;/binding&amp;gt;&lt;/code&gt; — which watches a property on the item and keeps a widget property in sync with it automatically, no Rust callback involved.&lt;/p&gt;

&lt;p&gt;That means &lt;code&gt;BuilderListItemFactory&lt;/code&gt; requires a real GObject with real properties, full stop. It's not merely more ceremony for our &lt;code&gt;Task&lt;/code&gt;, wrapped as it is in a &lt;code&gt;BoxedAnyObject&lt;/code&gt; — it's off the table entirely, because &lt;code&gt;BoxedAnyObject&lt;/code&gt; doesn't expose &lt;code&gt;title&lt;/code&gt; or &lt;code&gt;done&lt;/code&gt; as properties an expression can look up, and there's no imperative hook left to fill that gap by hand. If you want a &lt;code&gt;.ui&lt;/code&gt;-described row over a plain-struct payload like ours, the pattern isn't &lt;code&gt;BuilderListItemFactory&lt;/code&gt; — it's still &lt;code&gt;SignalListItemFactory&lt;/code&gt;, with a &lt;code&gt;setup&lt;/code&gt; handler that builds the child from a &lt;code&gt;.ui&lt;/code&gt; file (via &lt;code&gt;gtk::Builder&lt;/code&gt;) or a composite template widget, and &lt;code&gt;bind&lt;/code&gt;/&lt;code&gt;unbind&lt;/code&gt; wired normally as signals underneath it. You get the markup; you keep the callbacks. Reach for &lt;code&gt;BuilderListItemFactory&lt;/code&gt; on its own only once you've already paid for a proper GObject subclass with the properties an expression needs; keep plain &lt;code&gt;SignalListItemFactory&lt;/code&gt; when the row is a handful of widgets and the logic is the whole story, which is most rows.&lt;/p&gt;




&lt;h2&gt;
  
  
  Sharp edges
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;bind&lt;/code&gt; running more than once isn't a bug in your code — it's a bug in &lt;em&gt;this&lt;/em&gt; code, specifically when it manages any state beyond what it can also fully overwrite. If every line in &lt;code&gt;bind&lt;/code&gt; reassigns a value unconditionally (&lt;code&gt;set_label&lt;/code&gt;, &lt;code&gt;set_visible&lt;/code&gt;, &lt;code&gt;set_css_classes&lt;/code&gt; with the &lt;em&gt;complete&lt;/em&gt; list every time), there's nothing for &lt;code&gt;unbind&lt;/code&gt; to clean up, because nothing accumulates. The moment &lt;code&gt;bind&lt;/code&gt; does anything additive — &lt;code&gt;add_css_class&lt;/code&gt;, &lt;code&gt;connect_clicked&lt;/code&gt;, spawning a task — it needs an &lt;code&gt;unbind&lt;/code&gt; that undoes exactly that, or the row slot slowly becomes a superposition of every item that's ever occupied it.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;unbind&lt;/code&gt; fires before reassignment, not before destruction. If you're holding a resource that genuinely needs to outlive one item but not the whole factory — a debounce timer, say — &lt;code&gt;unbind&lt;/code&gt; is still the right place to cancel it, not &lt;code&gt;teardown&lt;/code&gt;. &lt;code&gt;teardown&lt;/code&gt; is rarer than either of the other three; most factories never implement it, and I've reached for it out of caution when &lt;code&gt;unbind&lt;/code&gt; was what I actually meant, which is itself a small sharp edge.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;BuilderListItemFactory&lt;/code&gt; has no &lt;code&gt;connect_bind&lt;/code&gt; to skip — &lt;code&gt;setup&lt;/code&gt;/&lt;code&gt;bind&lt;/code&gt;/&lt;code&gt;unbind&lt;/code&gt;/&lt;code&gt;teardown&lt;/code&gt; are signals on &lt;code&gt;SignalListItemFactory&lt;/code&gt; specifically, not on the base &lt;code&gt;GtkListItemFactory&lt;/code&gt; class the two factory types share. &lt;code&gt;BuilderListItemFactory&lt;/code&gt;'s only binding mechanism is a &lt;code&gt;GtkExpression&lt;/code&gt; declared in the template, which needs a real GObject property to watch. With a &lt;code&gt;BoxedAnyObject&lt;/code&gt; payload like ours, there's no property for an expression to look up and no signal to connect instead, so the &lt;code&gt;.ui&lt;/code&gt; template route is closed, not just less convenient. That's easy to miss on a skim of the docs, because the two factory classes look, from their names, like they should be more different than they are, and because most examples you'll find online use a real GObject model where the expression shortcut is available.&lt;/p&gt;




&lt;h2&gt;
  
  
  Next up
&lt;/h2&gt;

&lt;p&gt;Rows now behave correctly under recycling, one at a time. The next post is what happens with more than one row selected at once: &lt;code&gt;GtkSelectionModel&lt;/code&gt;, the difference between &lt;code&gt;SingleSelection&lt;/code&gt; and &lt;code&gt;MultiSelection&lt;/code&gt;, and wiring a selection change back out to the rest of the app without reaching into the view to ask it what's highlighted.&lt;/p&gt;

&lt;p&gt;The runnable version of this example is in the &lt;a href="https://github.com/fromthearchitect/gtk-widgets-field-guide/tree/main/03-first-factory" rel="noopener noreferrer"&gt;companion repo&lt;/a&gt; — &lt;code&gt;cd 03-first-factory &amp;amp;&amp;amp; cargo run&lt;/code&gt;. One widget at a time. See you in the next one.&lt;/p&gt;

</description>
      <category>gtk</category>
      <category>rust</category>
      <category>gnome</category>
      <category>libadwaita</category>
    </item>
    <item>
      <title>Stop Thinking in Rows: GListModel and the Modern List Mindset</title>
      <dc:creator>Justin - FromTheArchitect</dc:creator>
      <pubDate>Mon, 14 Sep 2026 23:00:00 +0000</pubDate>
      <link>https://dev.to/fromthearchitect/stop-thinking-in-rows-glistmodel-and-the-modern-list-mindset-2cb3</link>
      <guid>https://dev.to/fromthearchitect/stop-thinking-in-rows-glistmodel-and-the-modern-list-mindset-2cb3</guid>
      <description>&lt;p&gt;&lt;em&gt;This is the third entry in &lt;a href="https://fromthearchitect.dev/posts/gtk-widgets-field-guide/" rel="noopener noreferrer"&gt;A Field Guide to GTK Widgets&lt;/a&gt; — 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 &lt;a href="https://github.com/fromthearchitect/gtk-widgets-field-guide/tree/main/02-list-mindset" rel="noopener noreferrer"&gt;companion repo&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The list you'd build without thinking
&lt;/h2&gt;

&lt;p&gt;Say you're showing a handful of tasks. The obvious way to do it doesn't require reading any documentation at all:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;list_box&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;gtk&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;ListBox&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;task&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;tasks&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;label&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;gtk&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;Label&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="nf"&gt;.label&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="py"&gt;.title&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;.xalign&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;.build&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="n"&gt;list_box&lt;/span&gt;&lt;span class="nf"&gt;.append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where that breaks
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;This is the specific pain GTK4 redesigned around, and it's worth naming what it replaced. Older GTK did this with &lt;code&gt;GtkTreeView&lt;/code&gt;, &lt;code&gt;GtkListStore&lt;/code&gt;, and cell renderers — a whole apparatus for exactly this problem, and it's largely deprecated now. The &lt;a href="https://fromthearchitect.dev/posts/gtk-widgets-field-guide/" rel="noopener noreferrer"&gt;series intro&lt;/a&gt; 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.&lt;/p&gt;




&lt;h2&gt;
  
  
  GListModel: data as the source of truth
&lt;/h2&gt;

&lt;p&gt;The modern answer inverts the relationship. Instead of a widget tree you mutate by hand, you keep your data in a &lt;strong&gt;list model&lt;/strong&gt; — something implementing the &lt;code&gt;GListModel&lt;/code&gt; interface — and the view watches it. &lt;code&gt;GListModel&lt;/code&gt; 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 &lt;code&gt;GListStore&lt;/code&gt;: a plain, mutable, in-memory list model you own and push items into.&lt;/p&gt;

&lt;p&gt;Once data and view are genuinely separate concerns, the &lt;em&gt;files&lt;/em&gt; 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 &lt;a href="https://github.com/fromthearchitect/gtk-widgets-field-guide/tree/main/02-list-mindset" rel="noopener noreferrer"&gt;companion repo&lt;/a&gt; actually has it: &lt;code&gt;task.rs&lt;/code&gt; for the data, &lt;code&gt;task_row.rs&lt;/code&gt; for turning one task into one row, &lt;code&gt;window.rs&lt;/code&gt; for wiring the two into a window. The boundary is easier to see with it actually drawn.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="c1"&gt;// task.rs&lt;/span&gt;
&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;done&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;starting_tasks&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Vec&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nd"&gt;vec!&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="n"&gt;Task&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"Write the list-mindset post"&lt;/span&gt;&lt;span class="nf"&gt;.to_string&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
            &lt;span class="n"&gt;done&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="n"&gt;Task&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"Record the cover art"&lt;/span&gt;&lt;span class="nf"&gt;.to_string&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
            &lt;span class="n"&gt;done&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="n"&gt;Task&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"Reply to the GTK forum thread"&lt;/span&gt;&lt;span class="nf"&gt;.to_string&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
            &lt;span class="n"&gt;done&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And in &lt;code&gt;window.rs&lt;/code&gt;, the store gets built from it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="c1"&gt;// window.rs&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;store&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;gio&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;ListStore&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;new&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;BoxedAnyObject&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;task&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;starting_tasks&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="nf"&gt;.append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nn"&gt;BoxedAnyObject&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things stand out here. First, &lt;code&gt;GListStore&lt;/code&gt; only holds GObjects, not arbitrary Rust structs — a plain &lt;code&gt;Task&lt;/code&gt; 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 &lt;a href="https://fromthearchitect.dev/posts/gnome-rust-part-2-gobject/" rel="noopener noreferrer"&gt;GNOME/Rust series&lt;/a&gt; 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, &lt;code&gt;glib::BoxedAnyObject&lt;/code&gt; is the honest shortcut: it wraps any Rust value as a GObject with no subclassing at all, at the cost of &lt;code&gt;borrow()&lt;/code&gt;/&lt;code&gt;borrow_mut()&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;Second, and this is the point of the whole post: &lt;strong&gt;you never touch a widget here.&lt;/strong&gt; &lt;code&gt;task.rs&lt;/code&gt; doesn't import &lt;code&gt;gtk&lt;/code&gt; 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.&lt;/p&gt;




&lt;h2&gt;
  
  
  The other half, briefly
&lt;/h2&gt;

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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="c1"&gt;// task_row.rs&lt;/span&gt;
&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nn"&gt;std&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;cell&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Ref&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nn"&gt;gtk&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;glib&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;BoxedAnyObject&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nn"&gt;gtk&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;prelude&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="k"&gt;crate&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;task&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;factory&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nn"&gt;gtk&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;SignalListItemFactory&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;factory&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;gtk&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;SignalListItemFactory&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="n"&gt;factory&lt;/span&gt;&lt;span class="nf"&gt;.connect_setup&lt;/span&gt;&lt;span class="p"&gt;(|&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;list_item&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;label&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;gtk&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;Label&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="nf"&gt;.xalign&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nf"&gt;.build&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="n"&gt;list_item&lt;/span&gt;
            &lt;span class="py"&gt;.downcast_ref&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nn"&gt;gtk&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;ListItem&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="nf"&gt;.unwrap&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="nf"&gt;.set_child&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="n"&gt;factory&lt;/span&gt;&lt;span class="nf"&gt;.connect_bind&lt;/span&gt;&lt;span class="p"&gt;(|&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;list_item&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;list_item&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;list_item&lt;/span&gt;&lt;span class="py"&gt;.downcast_ref&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nn"&gt;gtk&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;ListItem&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="nf"&gt;.unwrap&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;boxed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;list_item&lt;/span&gt;&lt;span class="nf"&gt;.item&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="py"&gt;.and_downcast&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;BoxedAnyObject&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="nf"&gt;.unwrap&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Ref&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;boxed&lt;/span&gt;&lt;span class="nf"&gt;.borrow&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;label&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;list_item&lt;/span&gt;&lt;span class="nf"&gt;.child&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="py"&gt;.and_downcast&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nn"&gt;gtk&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Label&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="nf"&gt;.unwrap&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

        &lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="nf"&gt;.set_label&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="py"&gt;.done&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nd"&gt;format!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"✓ {}"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="py"&gt;.title&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="py"&gt;.title&lt;/span&gt;&lt;span class="nf"&gt;.clone&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="n"&gt;factory&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I'm deliberately not unpacking this fully. &lt;code&gt;setup&lt;/code&gt; builds a row's widgets once; &lt;code&gt;bind&lt;/code&gt; fills them in with a specific item's data, and this is the part that catches nearly everyone: GTK &lt;strong&gt;recycles&lt;/strong&gt; rows as you scroll, so &lt;code&gt;bind&lt;/code&gt; runs again and again for the same widgets with different items, not just once at creation. That recycling rhythm, the paired &lt;code&gt;unbind&lt;/code&gt; 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 &lt;code&gt;task_row.rs&lt;/code&gt; as plumbing: enough to get something on screen so we can look at what happens when the data underneath it changes.&lt;/p&gt;

&lt;p&gt;Back in &lt;code&gt;window.rs&lt;/code&gt;, wiring the model to the view is two lines — the selection wrapper, and handing both to the view:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="c1"&gt;// window.rs&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;selection&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;gtk&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;NoSelection&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="nf"&gt;.clone&lt;/span&gt;&lt;span class="p"&gt;()));&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;list_view&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;gtk&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;ListView&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;selection&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;task_row&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;factory&lt;/span&gt;&lt;span class="p"&gt;()));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  The payoff: update the model, not the view
&lt;/h2&gt;

&lt;p&gt;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 &lt;code&gt;window.rs&lt;/code&gt;, still nowhere near &lt;code&gt;task_row.rs&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="c1"&gt;// window.rs&lt;/span&gt;
&lt;span class="nn"&gt;glib&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;timeout_add_seconds_local&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;move&lt;/span&gt; &lt;span class="p"&gt;||&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="nf"&gt;.append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nn"&gt;BoxedAnyObject&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Task&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"Ship the draft"&lt;/span&gt;&lt;span class="nf"&gt;.to_string&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="n"&gt;done&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;}));&lt;/span&gt;
    &lt;span class="nn"&gt;glib&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;ControlFlow&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Break&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run this and a fourth task appears two seconds after the window opens, with nothing above touching &lt;code&gt;list_view&lt;/code&gt;, walking the widget tree, or computing an insertion index. &lt;code&gt;GListStore::append&lt;/code&gt; fires the model's &lt;code&gt;items-changed&lt;/code&gt; signal, the &lt;code&gt;GtkListView&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;That leaves &lt;code&gt;main.rs&lt;/code&gt; almost embarrassingly small — its only job is declaring the three modules and handing GTK an entry point:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="c1"&gt;// main.rs&lt;/span&gt;
&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nn"&gt;adw&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;prelude&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nn"&gt;gtk&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;glib&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;mod&lt;/span&gt; &lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;mod&lt;/span&gt; &lt;span class="n"&gt;task_row&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;mod&lt;/span&gt; &lt;span class="n"&gt;window&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="n"&gt;APP_ID&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"dev.fromthearchitect.gtkwidgets.ListMindset"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nn"&gt;glib&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;ExitCode&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;adw&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;Application&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="nf"&gt;.application_id&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;APP_ID&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nf"&gt;.build&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="nf"&gt;.connect_activate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;window&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;build&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="nf"&gt;.run&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;This is the shift the &lt;a href="https://fromthearchitect.dev/posts/gtk-widgets-field-guide/" rel="noopener noreferrer"&gt;field-guide post&lt;/a&gt; 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, &lt;code&gt;GtkColumnView&lt;/code&gt;) is a variation on this same shape.&lt;/p&gt;




&lt;h2&gt;
  
  
  So which list do you actually build?
&lt;/h2&gt;

&lt;p&gt;None of this means &lt;code&gt;GtkListBox&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;GtkListBox&lt;/code&gt; (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: &lt;code&gt;AdwActionRow&lt;/code&gt; and friends are &lt;code&gt;GtkListBox&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;The real decision is about scale and volatility, not correctness. Reach for &lt;code&gt;GListModel&lt;/code&gt; and &lt;code&gt;GtkListView&lt;/code&gt; 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 &lt;code&gt;GtkListBox&lt;/code&gt; 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."&lt;/p&gt;




&lt;h2&gt;
  
  
  Sharp edges
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;GtkListView&lt;/code&gt; won't take a bare &lt;code&gt;GListModel&lt;/code&gt;. It specifically wants something implementing &lt;code&gt;GtkSelectionModel&lt;/code&gt;, so if you skip the &lt;code&gt;NoSelection&lt;/code&gt; 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."&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;store.item(0)&lt;/code&gt; and flipping its &lt;code&gt;done&lt;/code&gt; field with &lt;code&gt;borrow_mut()&lt;/code&gt;, and the row just sits there, unchanged — I ran exactly that and watched nothing happen. Nothing tells the view anything happened, because &lt;code&gt;items-changed&lt;/code&gt; is a &lt;em&gt;structural&lt;/em&gt; 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 &lt;code&gt;GListStore::splice&lt;/code&gt;) 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.&lt;/p&gt;

&lt;p&gt;The third is the one worth repeating because it's the single most common bug report against this API: the factory's &lt;code&gt;bind&lt;/code&gt; 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 &lt;code&gt;unbind&lt;/code&gt;, or it leaks onto the next item that reuses the row. This example is too simple to hit that; a &lt;code&gt;set_label&lt;/code&gt; 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.&lt;/p&gt;




&lt;h2&gt;
  
  
  Next up
&lt;/h2&gt;

&lt;p&gt;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: &lt;code&gt;GtkListView&lt;/code&gt; properly, the difference between a &lt;code&gt;SignalListItemFactory&lt;/code&gt; and a &lt;code&gt;BuilderListItemFactory&lt;/code&gt;, and the bind/unbind recycling rhythm in full — including the leak this post's example was too simple to trigger.&lt;/p&gt;

&lt;p&gt;The runnable version of this example is in the &lt;a href="https://github.com/fromthearchitect/gtk-widgets-field-guide/tree/main/02-list-mindset" rel="noopener noreferrer"&gt;companion repo&lt;/a&gt; — &lt;code&gt;cd 02-list-mindset &amp;amp;&amp;amp; cargo run&lt;/code&gt;. One widget at a time. See you in the next one.&lt;/p&gt;

</description>
      <category>gtk</category>
      <category>rust</category>
      <category>gnome</category>
      <category>libadwaita</category>
    </item>
    <item>
      <title>GtkApplicationWindow vs AdwApplicationWindow: Which Window a GNOME App Should Use</title>
      <dc:creator>Justin - FromTheArchitect</dc:creator>
      <pubDate>Mon, 07 Sep 2026 23:00:00 +0000</pubDate>
      <link>https://dev.to/fromthearchitect/the-application-shell-gtkapplicationwindow-vs-adwapplicationwindow-494c</link>
      <guid>https://dev.to/fromthearchitect/the-application-shell-gtkapplicationwindow-vs-adwapplicationwindow-494c</guid>
      <description>&lt;p&gt;&lt;em&gt;This is the first proper entry in &lt;a href="https://fromthearchitect.dev/posts/gtk-widgets-field-guide/" rel="noopener noreferrer"&gt;A Field Guide to GTK Widgets&lt;/a&gt; — a series about which widget to reach for, when, and what bites you when you do. We start where every app starts: the window. Each post stands on its own, and the complete, runnable code for this one lives in the &lt;a href="https://github.com/fromthearchitect/gtk-widgets-field-guide/tree/main/01-application-shell" rel="noopener noreferrer"&gt;companion repo&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Two windows, and the one you should reach for
&lt;/h2&gt;

&lt;p&gt;You sit down to write a GNOME app. The very first widget you need is the window, and the toolkit immediately hands you a choice it doesn't explain: &lt;code&gt;GtkApplicationWindow&lt;/code&gt;, from GTK itself, or &lt;code&gt;AdwApplicationWindow&lt;/code&gt;, from Libadwaita. The reference describes both accurately and tells you nothing about which one you want.&lt;/p&gt;

&lt;p&gt;Here's the short answer, so the rest of this post is the &lt;em&gt;why&lt;/em&gt;: in 2026, for anything you intend to ship as a GNOME app, reach for &lt;code&gt;AdwApplicationWindow&lt;/code&gt;. The reason isn't cosmetics — it's structural, and it changes how you build everything above it. The difference comes down to one thing: where the titlebar lives.&lt;/p&gt;




&lt;h2&gt;
  
  
  GtkApplicationWindow: the honest baseline
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;GtkApplicationWindow&lt;/code&gt; is the plain GTK window, and there's nothing wrong with it. It has a content area and a dedicated &lt;em&gt;titlebar slot&lt;/em&gt;, and you fill the slot with a header bar:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;header&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;gtk&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;HeaderBar&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;window&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;gtk&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;ApplicationWindow&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="nf"&gt;.application&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;.title&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Application Shell"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;.default_width&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;420&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;.build&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="n"&gt;window&lt;/span&gt;&lt;span class="nf"&gt;.set_titlebar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;header&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="n"&gt;window&lt;/span&gt;&lt;span class="nf"&gt;.set_child&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="n"&gt;window&lt;/span&gt;&lt;span class="nf"&gt;.present&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two slots, two setters: &lt;code&gt;set_titlebar&lt;/code&gt; for the bar across the top, &lt;code&gt;set_child&lt;/code&gt; for everything underneath. If you don't call &lt;code&gt;set_titlebar&lt;/code&gt; at all, GTK gives you a default one so the window is still draggable and closable. This works, it ships, and for a tool that doesn't care about looking GNOME-native it's entirely fine.&lt;/p&gt;

&lt;p&gt;The limitation is baked into that titlebar slot. It's a &lt;em&gt;special&lt;/em&gt; region the window manages separately from its content — which means your header bar can never be anything other than a strip pinned to the very top, the full width of the window, for the whole life of the app. The moment you want a layout where the header is part of a collapsible sidebar, or where two panes each carry their own bar, the slot is in your way. That's not a rare want in modern GNOME; it's the standard adaptive pattern. So the slot that makes &lt;code&gt;GtkApplicationWindow&lt;/code&gt; simple is the same slot that makes it a dead end.&lt;/p&gt;




&lt;h2&gt;
  
  
  AdwApplicationWindow: no titlebar slot at all
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;AdwApplicationWindow&lt;/code&gt; removes the slot. That sounds like a downgrade and is actually the whole point. There is no separate titlebar region — the window has a single content child that fills it edge to edge, and the header bar becomes an &lt;em&gt;ordinary widget you place inside that content&lt;/em&gt;, like any other.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;window&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;adw&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;ApplicationWindow&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="nf"&gt;.application&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;.title&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Application Shell"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;.content&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;// one child, no titlebar slot&lt;/span&gt;
    &lt;span class="nf"&gt;.build&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice the method is &lt;code&gt;content&lt;/code&gt;, not &lt;code&gt;child&lt;/code&gt; plus &lt;code&gt;titlebar&lt;/code&gt;. That single change is what unlocks adaptive layouts. Because the header bar is now just a widget in the tree, you can put it wherever the layout needs it: at the top of the window, yes, but equally at the top of one pane in a split view, or inside a page that slides away on a phone. The window stops dictating where chrome can live. Libadwaita's rounded corners, its dialogs that draw as sheets attached to the window, and the adaptive split views the whole platform is built around all assume this shape — content all the way out, chrome composed inside it.&lt;/p&gt;

&lt;p&gt;This is the part I'd most want a newcomer to internalise, because it inverts the mental model you arrive with. The window is not a frame with a title bar and a body. It's an empty box you fill with one widget, and &lt;em&gt;you&lt;/em&gt; assemble the chrome inside it. Once that clicks, the rest of Libadwaita stops looking like a pile of special cases and starts looking like a kit.&lt;/p&gt;

&lt;p&gt;If the window is just an empty box, you need two more pieces to fill it: the header bar, and something to manage the relationship between it and your content.&lt;/p&gt;




&lt;h2&gt;
  
  
  AdwHeaderBar: the bar itself
&lt;/h2&gt;

&lt;p&gt;The header bar goes inside the content, and you almost always want the Libadwaita one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;header&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;adw&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;HeaderBar&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;AdwHeaderBar&lt;/code&gt; and &lt;code&gt;GtkHeaderBar&lt;/code&gt; look nearly identical and share most of their API, so it's fair to ask why bother. &lt;code&gt;AdwHeaderBar&lt;/code&gt; is built to live &lt;em&gt;inside&lt;/em&gt; the content rather than in a titlebar slot, it knows how to integrate with the adaptive containers (a split view can tell each pane's header bar to show or hide its window controls so you never get two close buttons), and it carries the platform's responsive behaviour for free. &lt;code&gt;GtkHeaderBar&lt;/code&gt; predates all of that and assumes it's being set as a titlebar.&lt;/p&gt;

&lt;p&gt;By default a header bar shows the window's title in the centre and the window controls — close, and minimise/maximise where the desktop uses them — at the ends. That's why the example sets &lt;code&gt;.title(...)&lt;/code&gt; on the &lt;em&gt;window&lt;/em&gt;: the header bar reads it. When you want a title and subtitle, or a custom widget in the centre, you set a title widget (&lt;code&gt;AdwWindowTitle&lt;/code&gt; is the usual one) explicitly — a detail for a later post. For now, an empty &lt;code&gt;AdwHeaderBar&lt;/code&gt; already gives you a draggable, closable, titled bar.&lt;/p&gt;

&lt;p&gt;The rule of thumb: in a GNOME app, reach for &lt;code&gt;AdwHeaderBar&lt;/code&gt;. The only time you'd drop to &lt;code&gt;GtkHeaderBar&lt;/code&gt; is if you're deliberately &lt;em&gt;not&lt;/em&gt; building on Libadwaita — and if that's true, you wouldn't be using &lt;code&gt;AdwApplicationWindow&lt;/code&gt; either.&lt;/p&gt;




&lt;h2&gt;
  
  
  AdwToolbarView: the chrome manager
&lt;/h2&gt;

&lt;p&gt;You could pack the header bar and your content into a &lt;code&gt;GtkBox&lt;/code&gt; and call it done. It would even look right — at first. But you'd be hand-rolling something Libadwaita does properly, and &lt;code&gt;AdwToolbarView&lt;/code&gt; is the widget for it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;toolbar_view&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;adw&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;ToolbarView&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="n"&gt;toolbar_view&lt;/span&gt;&lt;span class="nf"&gt;.add_top_bar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;header&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;toolbar_view&lt;/span&gt;&lt;span class="nf"&gt;.set_content&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;AdwToolbarView&lt;/code&gt; manages the bars around your content — any number across the top, any number across the bottom — and the content between them. The reason to use it over a plain box is everything it does that a box doesn't. It styles the bars to match the platform, and more importantly, it &lt;em&gt;reacts to the content scrolling underneath&lt;/em&gt;: with its default flat style, a header bar sits borderless while the content is at the top and gains a subtle undershoot shadow the instant the content scrolls beneath it, so the user always knows there's more above. Bottom bars get the same treatment in reverse. (Want a persistent line instead of a shadow — useful when the bar and the content have different backgrounds? That's the &lt;code&gt;top-bar-style&lt;/code&gt; property set to &lt;code&gt;RaisedBorder&lt;/code&gt;; the default is &lt;code&gt;Flat&lt;/code&gt;.) That scroll-aware shadow is the kind of thing you only notice when it's missing — and it costs nothing if you're already using &lt;code&gt;AdwToolbarView&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;A bottom bar — an action bar, a media control strip — is the same call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="n"&gt;toolbar_view&lt;/span&gt;&lt;span class="nf"&gt;.add_bottom_bar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;controls&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The mistake to avoid is putting the header bar into &lt;code&gt;set_content&lt;/code&gt; instead of &lt;code&gt;add_top_bar&lt;/code&gt;. It compiles, and it even renders something bar-shaped at the top of your content — but now it scrolls away with the content and gets none of the bar styling, because you've told the toolbar view it's &lt;em&gt;page material&lt;/em&gt;, not chrome. The bar methods are how the widget knows the difference.&lt;/p&gt;




&lt;h2&gt;
  
  
  Putting the shell together
&lt;/h2&gt;

&lt;p&gt;Three widgets, nested in one clear order — window holds a toolbar view, toolbar view holds a header bar and a page:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;build_ui&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nn"&gt;adw&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Application&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;header&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;adw&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;HeaderBar&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;content&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;gtk&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;Label&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="nf"&gt;.label&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"The shell is the chrome around this label."&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;.build&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;toolbar_view&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;adw&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;ToolbarView&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="n"&gt;toolbar_view&lt;/span&gt;&lt;span class="nf"&gt;.add_top_bar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;header&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;toolbar_view&lt;/span&gt;&lt;span class="nf"&gt;.set_content&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;window&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;adw&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;ApplicationWindow&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="nf"&gt;.application&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;.title&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Application Shell"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;.default_width&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;420&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;.default_height&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;320&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;.content&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;toolbar_view&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;.build&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="n"&gt;window&lt;/span&gt;&lt;span class="nf"&gt;.present&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Build your &lt;code&gt;Application&lt;/code&gt; as an &lt;code&gt;adw::Application&lt;/code&gt;, not a &lt;code&gt;gtk::Application&lt;/code&gt; — it's easy to miss, and the &lt;a href="https://github.com/fromthearchitect/gtk-widgets-field-guide/tree/main/01-application-shell" rel="noopener noreferrer"&gt;full example&lt;/a&gt; makes it explicit at the top. Constructing the Adwaita application type is what initialises Libadwaita — its stylesheet, its settings, the dark-mode plumbing. Use a plain &lt;code&gt;gtk::Application&lt;/code&gt; with Adwaita widgets and you'll get a window that's subtly unstyled at best and a panic at worst. It's a one-word change that's easy to forget and annoying to diagnose, so make it a habit.&lt;/p&gt;

&lt;p&gt;That's the shell. From here out, we're just filling it.&lt;/p&gt;




&lt;h2&gt;
  
  
  Sharp edges
&lt;/h2&gt;

&lt;p&gt;A few things that bite, all of them on the seam between the GTK window you might expect and the Adwaita one you're actually using.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;AdwApplicationWindow&lt;/code&gt; inherits &lt;code&gt;set_child&lt;/code&gt; and &lt;code&gt;set_titlebar&lt;/code&gt; from &lt;code&gt;GtkWindow&lt;/code&gt;, so they compile — but it overrides their behaviour, and the docs are explicit that you must use &lt;code&gt;set_content&lt;/code&gt; instead. Call &lt;code&gt;set_child&lt;/code&gt; on an &lt;code&gt;AdwApplicationWindow&lt;/code&gt; and you'll get a runtime warning and a window that doesn't lay out the way you meant. If you've ported code from &lt;code&gt;GtkApplicationWindow&lt;/code&gt;, this is the first thing to fix: one &lt;code&gt;content&lt;/code&gt; child, no titlebar.&lt;/p&gt;

&lt;p&gt;Because &lt;code&gt;AdwApplicationWindow&lt;/code&gt; has no default titlebar, forgetting to add a header bar entirely gives you a window with no title, no close button, and nothing obvious to grab. On &lt;code&gt;GtkApplicationWindow&lt;/code&gt; the default titlebar hides this mistake; here, nothing does. If your window comes up looking stranded, check that something in the content tree is actually an &lt;code&gt;AdwHeaderBar&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Worth repeating because it's the one that wastes an afternoon: a header bar handed to &lt;code&gt;set_content&lt;/code&gt; — or buried in a &lt;code&gt;GtkBox&lt;/code&gt; that you then set as content — loses its bar styling and scrolls away with the page. If your header looks flat and slides off the top when you scroll, that's this: it belongs in &lt;code&gt;add_top_bar&lt;/code&gt;, not the content.&lt;/p&gt;

&lt;p&gt;And an empty &lt;code&gt;AdwHeaderBar&lt;/code&gt; shows the &lt;em&gt;window's&lt;/em&gt; title, not its own. If the centre of your bar is empty, you probably never called &lt;code&gt;.title(...)&lt;/code&gt; on the window. Set it there, or give the header bar an explicit title widget — but don't expect a title to appear from nowhere.&lt;/p&gt;




&lt;h2&gt;
  
  
  Next up
&lt;/h2&gt;

&lt;p&gt;You've got a window with proper chrome and an empty content area staring back at you. Everything from here is filling that area — and the next stretch of posts jumps straight to the material that's most underserved: showing lists of things. Before any specific list widget, the next post is about the mental shift GTK4 demands first — from hand-building rows to describing your data as a model and letting the toolkit drive the view.&lt;/p&gt;

&lt;p&gt;The runnable version of this shell is in the &lt;a href="https://github.com/fromthearchitect/gtk-widgets-field-guide/tree/main/01-application-shell" rel="noopener noreferrer"&gt;companion repo&lt;/a&gt; — &lt;code&gt;cd 01-application-shell &amp;amp;&amp;amp; cargo run&lt;/code&gt;. One widget at a time. See you in the next one.&lt;/p&gt;

</description>
      <category>gtk</category>
      <category>rust</category>
      <category>gnome</category>
      <category>libadwaita</category>
    </item>
    <item>
      <title>A Field Guide to GTK Widgets</title>
      <dc:creator>Justin - FromTheArchitect</dc:creator>
      <pubDate>Mon, 31 Aug 2026 23:00:00 +0000</pubDate>
      <link>https://dev.to/fromthearchitect/a-field-guide-to-gtk-widgets-3m4f</link>
      <guid>https://dev.to/fromthearchitect/a-field-guide-to-gtk-widgets-3m4f</guid>
      <description>&lt;p&gt;&lt;em&gt;This is the opening post of a series that works through GTK4 and Libadwaita together, one practical interface decision at a time. The posts are grouped around the things you actually build — a window, a list, a settings page — rather than the library a widget happens to ship in. Each post stands alone; together they're meant to be the field guide the reference documentation isn't.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The problem with the reference
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://docs.gtk.org/gtk4/" rel="noopener noreferrer"&gt;GTK4 documentation&lt;/a&gt; is genuinely good. Every widget has a page. Every property, signal, and method is listed. The class hierarchies are accurate and the prose is precise. If you already know which widget you need, the reference will tell you everything about it.&lt;/p&gt;

&lt;p&gt;That last sentence is the whole problem.&lt;/p&gt;

&lt;p&gt;When you sit down to build a real interface, you almost never start from "I need a &lt;code&gt;GtkColumnView&lt;/code&gt;." You start from "I need to show a list of articles, some of them unread, sortable by date, and it has to feel right on a phone." Nothing in the reference answers &lt;em&gt;that&lt;/em&gt;. The reference is organised by what each widget &lt;strong&gt;is&lt;/strong&gt;. The work is organised by what you're &lt;strong&gt;trying to do&lt;/strong&gt; — and the translation between those two is exactly the knowledge that doesn't get written down.&lt;/p&gt;

&lt;p&gt;So you do what everyone does. You search. You find a six-year-old Stack Overflow answer using &lt;code&gt;GtkTreeView&lt;/code&gt; and &lt;code&gt;GtkListStore&lt;/code&gt;, copy it, get it working, and only much later discover that the modern toolkit replaced that entire pattern with something completely different — list models and factories — that nobody told you about because the old way still compiles. You ship the deprecated path because it was the first thing that worked.&lt;/p&gt;

&lt;p&gt;I've done this more times than I'd like to admit. This series is my attempt to write down the missing layer that the reference leaves to experience: &lt;strong&gt;which widget to reach for, when, and what's going to bite you when you do.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  What a "field guide" means here
&lt;/h2&gt;

&lt;p&gt;A field guide isn't an encyclopedia. A bird encyclopedia describes every feather; a field guide tells you how to tell a sparrow from a finch while it's moving. That distinction drives every choice in this series.&lt;/p&gt;

&lt;p&gt;Every post leads with a decision rather than a widget. The title of a typical entry is closer to "how do I show a list?" than "the &lt;code&gt;GtkListView&lt;/code&gt; class" — the widget is the answer to a question I'll pose first, in terms of a problem you actually have. And I'll show the trade-offs rather than just the happy path, because GTK frequently hands you three widgets that all &lt;em&gt;could&lt;/em&gt; do the job: &lt;code&gt;GtkListBox&lt;/code&gt;, &lt;code&gt;GtkListView&lt;/code&gt;, or &lt;code&gt;GtkColumnView&lt;/code&gt; for a list; &lt;code&gt;GtkBox&lt;/code&gt; or &lt;code&gt;GtkGrid&lt;/code&gt; for layout; the new &lt;code&gt;AdwDialog&lt;/code&gt; or the patterns it replaced. Knowing which to pick, and what each one costs you, is the actual skill.&lt;/p&gt;

&lt;p&gt;Mostly, though, the gotcha is the point. Scrolling that silently doesn't work because the policy is wrong; a factory that gets called far more often than you expected; rows that look identical but behave differently. These are the things that cost you an afternoon, and they're exactly what the reference omits, because strictly speaking everything is working as documented. The flip side is that I won't pad. GTK has a hundred-odd widgets and some of them, like &lt;code&gt;GtkSeparator&lt;/code&gt; and &lt;code&gt;GtkSpinner&lt;/code&gt;, don't warrant a thousand words. Where a widget is trivial I'll group it with its neighbours and tell you the one property you'll actually reach for, rather than stretch a separator into a post.&lt;/p&gt;




&lt;h2&gt;
  
  
  The modern GTK mindset
&lt;/h2&gt;

&lt;p&gt;Before any specific widget, there's a shift in thinking that GTK4 quietly demands, and it trips up nearly everyone arriving from older GTK, other toolkits, or stale tutorials.&lt;/p&gt;

&lt;p&gt;For years, the way you built a UI was imperative and row-oriented. You created widgets, packed them into containers, and when the data changed you went back into the widget tree and updated it by hand. I still have a lot of code that works exactly this way. It holds up fine until the data starts moving, at which point it fights you at every turn. And the specific machinery for &lt;em&gt;displaying&lt;/em&gt; data that way — &lt;code&gt;GtkTreeView&lt;/code&gt;, &lt;code&gt;GtkListStore&lt;/code&gt;, cell renderers — is largely deprecated now. (Packing widgets into a &lt;code&gt;GtkBox&lt;/code&gt; or &lt;code&gt;GtkGrid&lt;/code&gt; is not going anywhere; it's still how you build any non-list UI. It's the row-oriented data-display path that's on the way out.)&lt;/p&gt;

&lt;p&gt;The modern way is model-driven. You keep your data in a list model, and you hand GTK a &lt;em&gt;factory&lt;/em&gt;: a small recipe for turning one item into one row. GTK calls that factory as needed and recycles the widgets as the user scrolls. The recycling is the part that catches people out — your factory's bind step runs again every time a row is reused for a different item, not just once when the widget is first created, so anything you set up has to be torn down again on unbind. You stop reaching into the tree when data changes; you update the model and let the UI follow.&lt;/p&gt;

&lt;p&gt;If you've built a &lt;code&gt;RecyclerView&lt;/code&gt; on Android or a &lt;code&gt;UICollectionView&lt;/code&gt; on iOS, that recycle-and-bind rhythm will feel immediately familiar. If you're coming from SwiftUI or Compose, adjust your expectations: GTK isn't diffing a declarative view tree for you, so you still wire widgets up by hand inside the factory callbacks. And if you're coming from GTK2 or 3, this is a real reorientation — one worth making deliberately rather than discovering by accident halfway through a project.&lt;/p&gt;

&lt;p&gt;You'll see this theme return constantly: &lt;strong&gt;describe the relationship between data and widget once, then let the toolkit run it.&lt;/strong&gt; It shows up in list views, in property bindings, in &lt;code&gt;GtkExpression&lt;/code&gt;, in the Libadwaita rows. Internalise it early and the rest of the toolkit stops surprising you.&lt;/p&gt;




&lt;h2&gt;
  
  
  How the series is organised
&lt;/h2&gt;

&lt;p&gt;By concept, not by library.&lt;/p&gt;

&lt;p&gt;The temptation is to do all of GTK first and then all of &lt;a href="https://gnome.pages.gitlab.gnome.org/libadwaita/" rel="noopener noreferrer"&gt;Libadwaita&lt;/a&gt;. I'm deliberately not doing that. Sorting widgets by the library they live in is an accident of how the code is packaged; it has almost nothing to do with the problem in front of you. Real interface work doesn't divide along library lines. You don't have "GTK problems" and "Libadwaita problems"; you have jobs: lay out a window, show a list, build a settings page, give the user feedback. So that's how the posts are grouped. Each one takes a job and reaches for whichever toolkit has the right tool, and very often both are in play at once.&lt;/p&gt;

&lt;p&gt;That last part matters more than it sounds. For a great many jobs, GTK gives you the raw mechanism and Libadwaita gives you the GNOME-shaped version on top of it: a &lt;code&gt;GtkListBox&lt;/code&gt; row versus an &lt;code&gt;AdwActionRow&lt;/code&gt;, a hand-assembled dialog versus an &lt;code&gt;AdwAlertDialog&lt;/code&gt;, &lt;code&gt;GtkHeaderBar&lt;/code&gt; versus &lt;code&gt;AdwHeaderBar&lt;/code&gt;. Knowing the Libadwaita version exists, and knowing when to ignore it and reach for the GTK primitive instead, is the kind of judgement that only comes from use. The reference documents the two libraries as if they were strangers. They aren't. You use them together, in the same widget tree, all day long. Libadwaita is also younger and far less blogged-about than GTK, so the example gap is widest exactly where the two meet, which is where this series spends most of its time.&lt;/p&gt;

&lt;p&gt;Within each concept, posts are tiered honestly. Some carry a full treatment. Some get a tight, focused post. Some widgets are clustered with their siblings. I'll always tell you which, so you know whether you're getting a deep dive or a quick orientation. And where the &lt;a href="https://developer.gnome.org/hig/" rel="noopener noreferrer"&gt;GNOME Human Interface Guidelines&lt;/a&gt; have an opinion about the job at hand, I'll point it out — following them is most of what makes an app feel native.&lt;/p&gt;




&lt;h2&gt;
  
  
  The code
&lt;/h2&gt;

&lt;p&gt;Abstract examples are forgettable, so every post that shows code ships a small, runnable example alongside it — its own self-contained project in the &lt;a href="https://github.com/fromthearchitect/gtk-widgets-field-guide" rel="noopener noreferrer"&gt;companion repo&lt;/a&gt;, one folder per post. No shared app sits underneath the series: each example is scoped to the one decision that post is about, so you can clone a single folder, &lt;code&gt;cargo run&lt;/code&gt; it, and see exactly the widget in question without pulling in a wider codebase to understand it.&lt;/p&gt;

&lt;p&gt;If you want to see the same widgets doing real work in a full application, my &lt;a href="https://fromthearchitect.dev/posts/gnome-rust-part-1-getting-started/" rel="noopener noreferrer"&gt;Building GNOME Apps with Rust&lt;/a&gt; series builds one — Gazette, a small RSS reader — from scratch. The two series are independent, but they pair well.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why Rust
&lt;/h2&gt;

&lt;p&gt;Every code sample in this series is &lt;strong&gt;Rust&lt;/strong&gt;, using the &lt;a href="https://gtk-rs.org/" rel="noopener noreferrer"&gt;gtk4-rs&lt;/a&gt; and &lt;a href="https://world.pages.gitlab.gnome.org/Rust/libadwaita-rs/" rel="noopener noreferrer"&gt;libadwaita-rs&lt;/a&gt; bindings.&lt;/p&gt;

&lt;p&gt;I made the case for Rust at length in the &lt;a href="https://fromthearchitect.dev/posts/gnome-rust-part-1-getting-started/" rel="noopener noreferrer"&gt;GNOME/Rust series&lt;/a&gt;, so I won't relitigate it here. The short version: the bindings are mature and idiomatic, a lot of the most interesting new GNOME apps are being written in Rust and that's the direction I'd bet on, and Rust's type system catches a whole category of UI-state bugs before you run the program. It's the default I'd recommend for new GNOME work in 2026.&lt;/p&gt;

&lt;p&gt;If you write your GTK in C, Python, or Vala, you're still in the right place. The &lt;em&gt;decisions&lt;/em&gt; this series is about — which widget, when, and why — are language-neutral. The factory pattern is the factory pattern whether you express it in Rust closures or Python callbacks. You'll just be mentally translating the snippets, and GTK's consistent API naming across bindings makes that nearly mechanical.&lt;/p&gt;

&lt;p&gt;What I won't do is re-explain GObject subclassing or the project setup from scratch in every post — that ground is covered in the GNOME/Rust series, and I'll link back to it rather than repeat it. If you want the full environment setup (Flatpak SDK, Builder, the toolchain), &lt;a href="https://fromthearchitect.dev/posts/gnome-rust-part-1-getting-started/" rel="noopener noreferrer"&gt;start there&lt;/a&gt;. For this series, a working gtk4-rs project and a copy of &lt;a href="https://apps.gnome.org/Workbench/" rel="noopener noreferrer"&gt;Workbench&lt;/a&gt; for quick experiments is all you really need.&lt;/p&gt;




&lt;h2&gt;
  
  
  Next up: the application shell
&lt;/h2&gt;

&lt;p&gt;We start where every app starts: the window itself. &lt;code&gt;GtkApplicationWindow&lt;/code&gt; versus &lt;code&gt;AdwApplicationWindow&lt;/code&gt;, the &lt;code&gt;AdwHeaderBar&lt;/code&gt; that sits on top of it, and &lt;code&gt;AdwToolbarView&lt;/code&gt; for the bars above and below your content. You open on a plain GTK window and, within a few lines, reach for the Adwaita chrome that makes it look like it belongs on the desktop — the two libraries working together from line one.&lt;/p&gt;

&lt;p&gt;From there we jump straight into the list machinery, which is where modern GTK really lives — ahead of layout and navigation, because it's the genuinely underserved material and the gap this post itself just called out. If you've ever stared at a &lt;code&gt;GtkListView&lt;/code&gt; wondering what a "factory" is and why your rows aren't appearing, that's the cluster of posts I'm most looking forward to writing.&lt;/p&gt;

&lt;p&gt;One widget at a time. See you in the next one.&lt;/p&gt;

</description>
      <category>gtk</category>
      <category>rust</category>
      <category>gnome</category>
      <category>libadwaita</category>
    </item>
    <item>
      <title>Excavating Legacy ETL: The AI Never Asserts a Fact It Could Look Up</title>
      <dc:creator>Justin - FromTheArchitect</dc:creator>
      <pubDate>Tue, 25 Aug 2026 23:00:00 +0000</pubDate>
      <link>https://dev.to/fromthearchitect/excavating-legacy-etl-the-ai-never-asserts-a-fact-it-could-look-up-1594</link>
      <guid>https://dev.to/fromthearchitect/excavating-legacy-etl-the-ai-never-asserts-a-fact-it-could-look-up-1594</guid>
      <description>&lt;p&gt;Every platform modernisation programme has the same first act, and it is never the interesting one. Before anyone gets to write a line of &lt;a href="https://www.getdbt.com/" rel="noopener noreferrer"&gt;dbt&lt;/a&gt;, somebody has to work out what the current thing actually &lt;em&gt;does&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;In the &lt;a href="https://help.sap.com/docs/SAP_DATA_SERVICES" rel="noopener noreferrer"&gt;SAP BusinessObjects Data Services&lt;/a&gt; case — BODS, if you've had the pleasure — that means inheriting a few hundred dataflows whose authors left the organisation years ago. The logic lives in exported XML. The business rationale lived in the heads of people who are now at other companies. Nobody can tell you which flows are duplicates, which ones nothing reads any more, or what any given one is &lt;em&gt;for&lt;/em&gt;. There is a spreadsheet somewhere claiming to be an inventory. It is wrong in ways nobody can enumerate.&lt;/p&gt;

&lt;p&gt;This is archaeology. It is also, on paper, the exact shape of problem that large language models should eat for breakfast: mountains of semi-structured text, pattern recognition, summarisation into prose a human can read.&lt;/p&gt;

&lt;p&gt;So I tried the obvious thing, and the obvious thing failed in a way that turned out to be the most useful finding of the whole exercise.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why pointing an LLM at the XML doesn't work
&lt;/h2&gt;

&lt;p&gt;Paste a BODS dataflow export into a chat window and ask what it does, and you get an answer. It is fluent, well-structured, and mostly correct.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Mostly&lt;/em&gt; is the problem.&lt;/p&gt;

&lt;p&gt;The output is a blend of two very different things: columns and mappings that are genuinely in the XML, and plausible-sounding business context that is not. &lt;code&gt;AMOUNT_USD&lt;/code&gt; is a currency-normalised revenue measure — fine, that's a reasonable inference. But it will also tell you, with identical confidence and no change of register, that the flow runs nightly, or that &lt;code&gt;STATUS&lt;/code&gt; follows the standard active/inactive convention, or that a column feeds the finance dashboard. None of those facts are in the file. They are priors about what ETL usually looks like, rendered in the same voice as the facts.&lt;/p&gt;

&lt;p&gt;For most LLM tasks that's an acceptable cost. For this one it's fatal, because on a migration the facts &lt;em&gt;are&lt;/em&gt; the deliverable. A spec that gets the join predicate subtly wrong doesn't fail loudly — it produces a fact table where every row has silently been stamped with the wrong foreign key. You find out during reconciliation, six weeks and a lot of goodwill later.&lt;/p&gt;

&lt;p&gt;The failure mode isn't that the model can't read the XML. It reads it fine. The failure mode is that the output gives you no way to tell which sentences came from the file and which came from the model's expectations, and a human reviewer holding a 40-page spec cannot re-derive every claim by hand. That's the whole reason you asked for help in the first place.&lt;/p&gt;

&lt;p&gt;So I stopped trying to make the model more accurate, and changed what it was allowed to do.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one rule the whole design hangs on
&lt;/h2&gt;

&lt;p&gt;The system I ended up with — &lt;a href="https://github.com/justinf555/etl-archaeologist" rel="noopener noreferrer"&gt;etl-archaeologist&lt;/a&gt; — is built around a single constraint:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The AI never asserts a fact it could look up. The deterministic layer never interprets.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Two layers, one hard boundary. A plain Python layer parses the BODS exports into a fact store: sources, targets, columns, datatypes, mapping expressions, column lineage, join predicates, cross-flow dependencies. No model involved. Same input, same facts, every time.&lt;/p&gt;

&lt;p&gt;An agent layer then &lt;em&gt;reads that store&lt;/em&gt; and writes the parts a script genuinely cannot: what the pipeline is for, what the grain and keys probably are, whether to lift-and-shift it or redesign it, whether to migrate it at all.&lt;/p&gt;

&lt;p&gt;That split sounds like process pedantry. It isn't — it's what makes the output reviewable. Every factual section of a finished spec traces to a row in a database. Every interpretive sentence carries &lt;code&gt;(inferred — confirm with SME)&lt;/code&gt;. A reviewer reading the spec knows, at every line, whether they're checking a fact or challenging a judgement. Those are completely different cognitive tasks and mixing them is what makes AI-generated documentation exhausting to review.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer one: facts
&lt;/h2&gt;

&lt;p&gt;The extractor is unglamorous on purpose. &lt;code&gt;lxml&lt;/code&gt; in, &lt;a href="https://duckdb.org/" rel="noopener noreferrer"&gt;DuckDB&lt;/a&gt; out, about 150 lines:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;TABLE&lt;/span&gt; &lt;span class="nf"&gt;dataflow      &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;df_name&lt;/span&gt; &lt;span class="n"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;job&lt;/span&gt; &lt;span class="n"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;project&lt;/span&gt; &lt;span class="n"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;source_file&lt;/span&gt; &lt;span class="n"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;TABLE&lt;/span&gt; &lt;span class="nf"&gt;reads         &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;df_name&lt;/span&gt; &lt;span class="n"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;object_key&lt;/span&gt; &lt;span class="n"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;datastore&lt;/span&gt; &lt;span class="n"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;table_name&lt;/span&gt; &lt;span class="n"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;TABLE&lt;/span&gt; &lt;span class="nf"&gt;writes        &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;df_name&lt;/span&gt; &lt;span class="n"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;object_key&lt;/span&gt; &lt;span class="n"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;datastore&lt;/span&gt; &lt;span class="n"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;table_name&lt;/span&gt; &lt;span class="n"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;TABLE&lt;/span&gt; &lt;span class="nf"&gt;column_def    &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;df_name&lt;/span&gt; &lt;span class="n"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;col_name&lt;/span&gt; &lt;span class="n"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;datatype&lt;/span&gt; &lt;span class="n"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;mapping_text&lt;/span&gt; &lt;span class="n"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;TABLE&lt;/span&gt; &lt;span class="nf"&gt;column_lineage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;df_name&lt;/span&gt; &lt;span class="n"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;target_col&lt;/span&gt; &lt;span class="n"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;src_table&lt;/span&gt; &lt;span class="n"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;src_col&lt;/span&gt; &lt;span class="n"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;TABLE&lt;/span&gt; &lt;span class="nf"&gt;source_column &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;df_name&lt;/span&gt; &lt;span class="n"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;datastore&lt;/span&gt; &lt;span class="n"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;table_name&lt;/span&gt; &lt;span class="n"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;col_name&lt;/span&gt; &lt;span class="n"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;TABLE&lt;/span&gt; &lt;span class="nf"&gt;predicate     &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;df_name&lt;/span&gt; &lt;span class="n"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;predicate_text&lt;/span&gt; &lt;span class="n"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;TABLE&lt;/span&gt; &lt;span class="nf"&gt;predicate_ref &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;df_name&lt;/span&gt; &lt;span class="n"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;src_table&lt;/span&gt; &lt;span class="n"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;src_col&lt;/span&gt; &lt;span class="n"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Predicates get stored twice, which looks redundant until you need it. &lt;code&gt;predicate&lt;/code&gt; keeps the join or filter expression as verbatim text, because that's what has to be reproduced exactly on the target platform. &lt;code&gt;predicate_ref&lt;/code&gt; holds the same expression exploded into the individual source columns it touches — the extractor regexes &lt;code&gt;table.column&lt;/code&gt; references out of the predicate and keeps the ones that resolve to a real source table. Without that second table, any column used &lt;em&gt;only&lt;/em&gt; as a join key looks unreferenced, and the dead-code query below would confidently recommend dropping the very columns holding the estate together.&lt;/p&gt;

&lt;p&gt;The one piece that needed actual thought is column lineage. A BODS dataflow isn't a single mapping step; it's a chain of transforms, and most columns pass through several of them untouched before landing in the target. If you only record each hop, the final target column's origin is a graph walk away. So the extractor threads origins forward hop by hop — a mapped column records the source columns its expression references, a generated column (a surrogate key, an SCD date) records that it was generated, and a pass-through column inherits whatever the upstream column already knew:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="c1"&gt;# pass-through: inherit the upstream column's origin
&lt;/span&gt;    &lt;span class="n"&gt;prev&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;acc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;frm&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;src&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;expr&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;«pass-through»&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...})&lt;/span&gt;
    &lt;span class="n"&gt;info&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;src&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prev&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;src&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;expr&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;prev&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;expr&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;...}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;By the last transform, every target column knows the source columns it came from and the expression that produced it. That's the fact an SME can confirm or deny in five seconds, and it's the one an LLM is most tempted to smooth over.&lt;/p&gt;

&lt;p&gt;The thing I underrated going in: this layer is not just an input to the AI. &lt;strong&gt;It's the test oracle.&lt;/strong&gt; Because it's exact and reproducible, you can later use it to check the agent's homework — which turns out to be the entire trick.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer two: the questions one file can't answer
&lt;/h2&gt;

&lt;p&gt;A second deterministic pass looks across all pipelines at once, because there is a whole class of question that no amount of careful reading of a single export can answer. "Is this a duplicate?" is unanswerable from inside the file.&lt;/p&gt;

&lt;p&gt;Four queries do most of the work. Dependencies are a join between who writes an object and who reads it. Duplicate candidates are pairs of flows writing the same target, scored by column-set Jaccard overlap. Dead-flow candidates are flows nothing else reads. And dead code — my favourite, because it's pure profit — is source columns pulled into a flow and then never referenced by any mapping &lt;em&gt;or&lt;/em&gt; any predicate, which is where &lt;code&gt;predicate_ref&lt;/code&gt; earns its place:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;sc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;df_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;table_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;col_name&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;source_column&lt;/span&gt; &lt;span class="n"&gt;sc&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;column_lineage&lt;/span&gt; &lt;span class="n"&gt;cl&lt;/span&gt;
                  &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;cl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;df_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;sc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;df_name&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;cl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;src_table&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;sc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;table_name&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;cl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;src_col&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;sc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;col_name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;predicate_ref&lt;/span&gt; &lt;span class="n"&gt;pr&lt;/span&gt;
                  &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;pr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;df_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;sc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;df_name&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;pr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;src_table&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;sc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;table_name&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;pr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;src_col&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;sc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;col_name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Across seven pipelines that found seven columns being extracted for no reason at all. On a real estate that number is a rounding error on the migration effort you don't have to spend.&lt;/p&gt;

&lt;p&gt;Worth being precise about what "dead" means here, because it's narrower than it sounds. The column never reaches the target, so nothing downstream of &lt;em&gt;this flow&lt;/em&gt; can be reading it — that part is safe. What it doesn't mean is that the column is dead in the source system. Another flow may well use it, and something outside BODS almost certainly does. The finding is "stop pulling this column into this pipeline", not "drop this column".&lt;/p&gt;

&lt;p&gt;Jaccard — intersection over union — is about as crude as similarity metrics get, and I chose it for that. It needs no tuning and no threshold anyone will argue about in a workshop, and the hard part of the question has already been answered by the join: these two flows write the &lt;em&gt;same table&lt;/em&gt;. The score only has to rank what's left for a human to look at. I don't know it's the right metric. I know it hasn't yet surfaced a pair that wasn't worth two minutes of attention.&lt;/p&gt;

&lt;p&gt;Notice what all four have in common: they're detection, not judgement. Exact, exhaustive, auditable, and completely free of opinion. Which is exactly what you want feeding the next layer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer three: making "look it up" cheaper than guessing
&lt;/h2&gt;

&lt;p&gt;The boundary between the layers only holds if the agent has an easy way to &lt;em&gt;actually&lt;/em&gt; look things up. So the fact store — the DuckDB file — gets a fact &lt;em&gt;server&lt;/em&gt; in front of it: a read-only &lt;a href="https://modelcontextprotocol.io/" rel="noopener noreferrer"&gt;MCP&lt;/a&gt; interface exposing &lt;code&gt;list_pipelines&lt;/code&gt;, &lt;code&gt;get_pipeline_facts&lt;/code&gt;, &lt;code&gt;rationalisation_report&lt;/code&gt;, and a general &lt;code&gt;query&lt;/code&gt; for anything the first three don't cover. Store is the data; server is the only door the agent gets to walk through, and the door is deliberately one-way:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sql&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sql&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;select&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;with&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)):&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;read-only fact store: only SELECT / WITH queries are allowed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;_q&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sql&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the part I'd most encourage people to steal. Telling a model "don't hallucinate" is a wish. Giving it a cheap, obvious, structured way to get the real answer is a design. When the ground truth is one tool call away, the model reaches for it — and when it does invent something anyway, the same tool is sitting right there to catch it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer four: the part that actually needs judgement
&lt;/h2&gt;

&lt;p&gt;With facts handled, what's left for the agent is genuinely interesting, and it's more than prose polish.&lt;/p&gt;

&lt;p&gt;Take the dead-flow signal. The detector flagged four candidates, all with identical structural evidence: &lt;em&gt;nothing else in the corpus reads your output&lt;/em&gt;. Deterministically they are indistinguishable. And yet the right answer differs for every one of them, because "no downstream consumer in the BODS metadata" means completely different things depending on what the flow is:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;strong&gt;monthly sales aggregate&lt;/strong&gt; with no downstream reader isn't dead. It's terminal — the consumer is a BI tool the fact store cannot see. &lt;strong&gt;Keep&lt;/strong&gt;, and go confirm the dashboard exists.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;conformed product dimension&lt;/strong&gt; with no downstream reader is the same story, only more so — a conformed dimension exists precisely so that things outside the warehouse can slice on it. &lt;strong&gt;Keep&lt;/strong&gt;, verify.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;customer SCD2 history table&lt;/strong&gt; — same shape again. It's a history mart. Terminal by design.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;two-column pass-through in an isolated &lt;code&gt;Legacy&lt;/code&gt; project&lt;/strong&gt;, reading a source table literally named &lt;code&gt;OLD_ACCOUNTS&lt;/code&gt;, writing to a staging area, referenced by nothing: that one is a genuine orphan. &lt;strong&gt;Retire.&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One signal, four different verdicts, and the discriminator in every case is context that lives nowhere in the metadata — naming conventions, which datastore the target sits in, what kind of table it is, whether "terminal" is normal for that kind of table. That's the work. The detector found the candidates; the agent adjudicated them; and it overruled the detector three times out of four, which is precisely what you want a judgement layer to do.&lt;/p&gt;

&lt;p&gt;The second thing the agent is good at is smelling migration risk in an expression. From the order fact load:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="n"&gt;ifthenelse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ORDERS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CURRENCY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'USD'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ORDERS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AMOUNT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ORDERS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AMOUNT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;66&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A hard-coded FX rate. One rate, for every currency, for all time. The agent flagged it — and then, correctly, refused to fix it: preserve &lt;code&gt;0.66&lt;/code&gt; exactly through cutover so reconciliation is byte-for-byte, and raise the rate-table question as a &lt;em&gt;deferred&lt;/em&gt; redesign afterwards. That's the right call and it's a slightly subtle one. The instinct on seeing bad code is to improve it. The instinct that survives a migration is to reproduce it faithfully and change it in a separate, visible step.&lt;/p&gt;

&lt;p&gt;The find I liked most, though, came out of the duplicate pair. Two flows both writing &lt;code&gt;DIM_CUSTOMER&lt;/code&gt;, 60% column overlap, obviously redundant. But look at how each one derives &lt;code&gt;STATUS&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="n"&gt;DF_DIM_CUSTOMER&lt;/span&gt;          &lt;span class="n"&gt;ifthenelse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;CUSTOMER&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ACTIVE_FLG&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'Y'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'ACTIVE'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'INACTIVE'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;DF_DIM_CUSTOMER_REFRESH&lt;/span&gt;  &lt;span class="n"&gt;ifthenelse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;CUSTOMER&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ACTIVE_FLG&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'A'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'ACTIVE'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'INACTIVE'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same column, same source, different sentinel. &lt;code&gt;'Y'&lt;/code&gt; in one, &lt;code&gt;'A'&lt;/code&gt; in the other. Merge those two flows by picking whichever expression you happened to be looking at, and you silently flip the active/inactive status of some portion of the customer base. It's a two-character difference across two files, and it went straight into the report as a hard blocker on the merge. That's the class of thing manual archaeology misses at 3pm on a Thursday, and it's a good illustration of what the two-layer split buys you: the &lt;em&gt;comparison&lt;/em&gt; was deterministic, the &lt;em&gt;significance&lt;/em&gt; was judgement, and neither layer could have produced the finding alone.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fixed templates, because consistency is a feature
&lt;/h2&gt;

&lt;p&gt;Every agent-authored artefact fills a fixed template — one for pipeline specs, one for the portfolio report, one for verification verdicts. Same headings, same order, every run, with &lt;code&gt;- none&lt;/code&gt; where a section is empty rather than the section quietly vanishing.&lt;/p&gt;

&lt;p&gt;This is a small thing that pays continuously. Consistent structure means specs diff cleanly — against each other and against their own previous versions — reviewers build muscle memory, and changing the house style is a one-file edit instead of a re-prompt. Mostly it kills a whole category of drift you'd otherwise catch by feel, late, in review.&lt;/p&gt;

&lt;h2&gt;
  
  
  The gate: assume the spec is wrong
&lt;/h2&gt;

&lt;p&gt;Here's the honest bit. Everything above still leaves you with output from a non-deterministic process. Constraints reduce the error rate; they don't take it to zero. Trusting the specs because the architecture is nice would be exactly the mistake I started out trying to avoid.&lt;/p&gt;

&lt;p&gt;So the last step is an adversarial verification pass. One subagent per spec, with an explicit brief: &lt;strong&gt;try to break it.&lt;/strong&gt; Assume every factual claim is wrong until ground truth proves it right. Do not read the spec and then go looking for support — establish ground truth independently first, from the fact store &lt;em&gt;and&lt;/em&gt; the raw XML, then extract every factual claim from the spec and classify it: SUPPORTED, UNSUPPORTED, CONTRADICTED, or MISLABELLED.&lt;/p&gt;

&lt;p&gt;The output is a claim-by-claim table with a PASS/FAIL verdict:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Claim&lt;/th&gt;
&lt;th&gt;Status&lt;/th&gt;
&lt;th&gt;Evidence&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Dead code: &lt;code&gt;OLD_ACCOUNTS.CLOSED_DT&lt;/code&gt; read but never mapped&lt;/td&gt;
&lt;td&gt;SUPPORTED&lt;/td&gt;
&lt;td&gt;facts &lt;code&gt;unused_source_columns = [{OLD_ACCOUNTS, CLOSED_DT}]&lt;/code&gt;; XML declares &lt;code&gt;CLOSED_DT&lt;/code&gt; in &lt;code&gt;&amp;lt;DISource&amp;gt;&lt;/code&gt; schema but it is absent from the &lt;code&gt;Query&lt;/code&gt; transform output schema&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Join/filter predicates: none&lt;/td&gt;
&lt;td&gt;SUPPORTED&lt;/td&gt;
&lt;td&gt;facts &lt;code&gt;predicates = []&lt;/code&gt; — genuinely empty. The added "None" line is accurate, not invented&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That second row is the one that made me trust the design. The verifier didn't just check that the stated facts were right; it checked that a claim of &lt;em&gt;absence&lt;/em&gt; was honest. "There are no filters here" is exactly the kind of statement that's easy to write and easy to be wrong about.&lt;/p&gt;

&lt;p&gt;The obvious objection is that I've now got a non-deterministic process checking a non-deterministic process, and that's fair. The verifier is an agent too; it can miss things. What it can't easily do is miss things &lt;em&gt;in the same direction&lt;/em&gt; as the author, because it isn't reading the author's reasoning — it goes back to the fact store and the raw XML and builds its own picture first. Two independent passes over the same ground truth have to fail in correlated ways to let an error through, which is a meaningfully harder thing to do by accident. That's a reduction in risk, not a proof of correctness, and it's exactly why the gate needs calibrating rather than trusting.&lt;/p&gt;

&lt;p&gt;Which is what the planted errors are for. Two things came out of running the gate for real. First, on a deliberate planted-error test it caught every injected error while still passing the clean spec — so it isn't just manufacturing objections to look diligent. Second, and more usefully, it caught a defect I wasn't looking for: a spec that had been correct when written and had gone &lt;strong&gt;stale&lt;/strong&gt;, because a new pipeline was added to the corpus afterwards and its dependency list no longer reflected reality.&lt;/p&gt;

&lt;p&gt;That reframed the whole thing for me. I built the gate expecting to catch hallucination. What it actually earns its keep on is staleness — documentation drifting behind the source. Which, if you think about it, is the failure mode that has plagued every hand-written data dictionary ever produced. The difference is that here regenerating and re-verifying costs minutes, so the documentation can be treated as &lt;em&gt;derived&lt;/em&gt; rather than authored, and re-derived whenever the source moves.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell someone starting this
&lt;/h2&gt;

&lt;p&gt;Split facts from judgement before you write a single prompt — not as a review step afterwards, as an architecture. If a script can determine it, a script should determine it, and the model should be reading the script's output rather than the raw source. Every other decision here fell out of that one, including the ones I didn't see coming.&lt;/p&gt;

&lt;p&gt;The corollary is that ground truth has to be &lt;em&gt;cheap to reach&lt;/em&gt;. A read-only fact server does more for accuracy than any amount of prompt engineering about accuracy, because it changes the economics rather than the instructions: guessing has to be the more expensive option, or eventually it won't be the one taken.&lt;/p&gt;

&lt;p&gt;Then label inference at the sentence level. A disclaimer at the top of a document is decoration — a reviewer stops seeing it by page two. &lt;code&gt;(inferred — confirm with SME)&lt;/code&gt; sitting in front of the specific claim is a routing instruction: this line is for the data owner, that line is for the migration engineer.&lt;/p&gt;

&lt;p&gt;And when you verify, verify against the source rather than the summary. A checker that reads the spec and goes looking for support will find it every time; one that reconstructs ground truth independently and &lt;em&gt;then&lt;/em&gt; hunts for contradictions is doing a completely different job, and only the second one tells you anything you didn't already believe.&lt;/p&gt;

&lt;p&gt;Then the caveats, because I said I'd be honest about them.&lt;/p&gt;

&lt;p&gt;The corpus I've been describing is synthetic — deliberately planted with a known answer key so I could measure whether the method recovers the truths rather than just producing confident-looking documents. It's a test of the approach, not a sample of any real estate, and no production data goes anywhere near it. Real BODS XML also nests its attributes differently to my samples, so pointing this at a live export means recalibrating the XPath in the extractor once. That's the only part that changes — but it does have to change, and I'd expect it to be a fiddlier afternoon than it sounds.&lt;/p&gt;

&lt;p&gt;The larger limit is that detection only sees what BODS records. It cannot see Tableau, Athena, or the analyst with a scheduled query nobody remembers writing. That's precisely why every retire and merge recommendation is gated on a human plus external-consumer evidence rather than fired automatically — the tool is allowed to nominate, never to decide.&lt;/p&gt;

&lt;p&gt;None of this made the model smarter. It made the model's job smaller. The interesting judgement — is this dead, is this a duplicate, is that hard-coded rate a bug or a contract — is exactly as hard as it was before, and it's still the reason anyone is doing the work. What changed is that it's no longer tangled up with a hundred low-level facts a database can answer exactly, and a reviewer can finally tell the two apart at a glance.&lt;/p&gt;

&lt;p&gt;Archaeology, done properly, is mostly about knowing which layer you're digging in.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;The &lt;code&gt;etl-archaeologist&lt;/code&gt; tooling was written with Claude Code — the co-author trailers are in its commit history. The two-layer split and the constraint it hangs on are mine; most of the implementation is not. I didn't keep the prompt transcripts, which for a post arguing you should be able to audit what the AI did is an omission I notice.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>dataengineering</category>
      <category>etl</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Two YubiKeys, One Prompt Too Many</title>
      <dc:creator>Justin - FromTheArchitect</dc:creator>
      <pubDate>Thu, 20 Aug 2026 23:00:00 +0000</pubDate>
      <link>https://dev.to/fromthearchitect/two-yubikeys-one-prompt-too-many-d78</link>
      <guid>https://dev.to/fromthearchitect/two-yubikeys-one-prompt-too-many-d78</guid>
      <description>&lt;p&gt;I carry two YubiKeys. One lives on my keyring, one lives in a drawer, and both are enrolled everywhere that matters. A hardware token with no backup is a single point of failure you have chosen on purpose.&lt;/p&gt;

&lt;p&gt;For months I had a small irritation I never bothered to chase. Some days &lt;code&gt;git push&lt;/code&gt; asked for my PIN, I typed it, and it worked. Other days it asked for my PIN, failed, and then asked again, and the second one worked. Same command, same repo, same laptop. The variable turned out to be which of the two keys happened to be in the USB port.&lt;/p&gt;

&lt;p&gt;It is a five-second annoyance — and I hit it maybe fifteen times a day.&lt;/p&gt;

&lt;h2&gt;
  
  
  What was actually happening
&lt;/h2&gt;

&lt;p&gt;Both keys are &lt;code&gt;sk-ssh-ed25519&lt;/code&gt; credentials, and both get loaded into the gnome-keyring agent automatically:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;ssh-add &lt;span class="nt"&gt;-l&lt;/span&gt;
256 SHA256:5imEVNLX...  yk1-12345678 &lt;span class="o"&gt;(&lt;/span&gt;ED25519-SK&lt;span class="o"&gt;)&lt;/span&gt;
256 SHA256:ATug2xnP...  yk2-12345679 &lt;span class="o"&gt;(&lt;/span&gt;ED25519-SK&lt;span class="o"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I had no &lt;code&gt;~/.ssh/config&lt;/code&gt; at all. Without one, ssh offers the agent's keys in the order the agent hands them over, and the server takes the first one it recognises. GitHub has both of these enrolled, so it accepts the first offer every time. Which means ssh commits to &lt;code&gt;yk1&lt;/code&gt; before anything has checked whether the token in the port can actually satisfy it.&lt;/p&gt;

&lt;p&gt;With &lt;code&gt;yk1&lt;/code&gt; in the port, that guess is right and you see one prompt. With &lt;code&gt;yk2&lt;/code&gt; in the port, ssh asks the attached token to sign for a credential it has never heard of. You get a PIN dialog, you type your PIN, it fails, ssh falls through to the second key, and that one works.&lt;/p&gt;

&lt;p&gt;The detail that makes this worse than it needs to be is how these keys were generated. Both are resident keys with user verification required. The flags byte in the private key blob is &lt;code&gt;0x25&lt;/code&gt;, which decodes, using the constant names from OpenSSH's &lt;code&gt;sk-api.h&lt;/code&gt;, as:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;0x25 = SSH_SK_USER_PRESENCE_REQD | SSH_SK_USER_VERIFICATION_REQD | SSH_SK_RESIDENT_KEY
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;USER_VERIFICATION_REQD&lt;/code&gt; is what turns this from invisible into annoying, and the ordering that causes it sits above the token rather than inside it. Before ssh can request an assertion at all, the client has to obtain a PIN/UV auth token from the authenticator, and that is the step that puts the dialog on screen. Only after you have typed the PIN does the assertion request go down the wire, and only then does the key answer that it has never held this credential. Without the flag, the wrong-key attempt would fail silently and I would never have noticed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The information was already there
&lt;/h2&gt;

&lt;p&gt;Here is the part I feel slightly silly about. Look at the comments on those keys again:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;yk1-12345678
yk2-12345679
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Past me, generating these, had already written the serial number of the owning YubiKey into each key's comment. The mapping from "which token is plugged in" to "which key can possibly work" had been sitting in &lt;code&gt;ssh-add -l&lt;/code&gt; output the entire time, and I had spent months not reading it.&lt;/p&gt;

&lt;p&gt;OpenSSH gives you exactly the right hook for this. &lt;code&gt;Match exec&lt;/code&gt; runs a shell command and applies the block if it exits zero:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Match host github.com,gitlab.com exec "%d/.ssh/yk-plugged 12345678"
    IdentityFile ~/.ssh/id_ed25519_sk_yk1
    IdentitiesOnly yes

Match host github.com,gitlab.com exec "%d/.ssh/yk-plugged 12345679"
    IdentityFile ~/.ssh/id_ed25519_sk_yk2
    IdentitiesOnly yes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note the &lt;code&gt;host&lt;/code&gt; criterion, because leaving it off is the mistake that turns a papercut fix into a bad afternoon. &lt;code&gt;Match exec&lt;/code&gt; on its own applies to every destination, and the first block that matches pins &lt;code&gt;IdentitiesOnly&lt;/code&gt; and a single &lt;code&gt;IdentityFile&lt;/code&gt; for whatever you happen to be connecting to. Every other host, with every other key, stops authenticating.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;IdentitiesOnly yes&lt;/code&gt; is the load-bearing line, and it is the one people leave out. I had assumed the name meant "ignore the agent". The &lt;code&gt;-v&lt;/code&gt; output says otherwise: the agent still returns both keys. What the option actually restricts is which of them are eligible to be offered: only the ones whose public half matches an &lt;code&gt;IdentityFile&lt;/code&gt; entry in scope. Two keys in, one considered.&lt;/p&gt;

&lt;p&gt;That matching is also why the &lt;code&gt;IdentityFile&lt;/code&gt; paths have to exist on disk even though the keys live in the agent. Since these are resident credentials, it is entirely possible to arrive on a fresh machine, pull them off the token with &lt;code&gt;ssh-add -K&lt;/code&gt;, and have nothing in &lt;code&gt;~/.ssh&lt;/code&gt; at all. In that state the blocks match nothing and do nothing, which is confusing for about ten minutes.&lt;/p&gt;

&lt;p&gt;If neither serial matches, no block applies, and you fall back to the old behaviour of trying everything. That is the correct failure mode: a config that gets worse than no config when it does not understand the situation is a config you will eventually curse. If both keys happen to be attached, both blocks match and both &lt;code&gt;IdentityFile&lt;/code&gt; entries land in scope (&lt;code&gt;IdentityFile&lt;/code&gt; accumulates across directives, unlike most keywords), so you are back to a coin flip and possibly one doomed prompt. Correct behaviour, but it does mean the fix looks broken if you plug in the drawer key without unplugging the keyring one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bit that was actually hard
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;Match exec&lt;/code&gt; runs on every single ssh invocation. Every &lt;code&gt;git fetch&lt;/code&gt;, every &lt;code&gt;scp&lt;/code&gt;, every &lt;code&gt;ssh -G&lt;/code&gt; a tool makes to introspect your config. Whatever goes in there has to be cheap.&lt;/p&gt;

&lt;p&gt;My first instinct was to read the serial out of sysfs, the way you would for most USB devices. That does not work:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; /sys/bus/usb/devices/3-4/serial

&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; /sys/bus/usb/devices/3-4/product
YubiKey OTP+FIDO+CCID
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Empty. A YubiKey does not publish its serial in the USB device descriptor. You have to ask the device over one of its application interfaces, which in practice means &lt;code&gt;ykman&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;ykman list &lt;span class="nt"&gt;--serials&lt;/span&gt;
12345678
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That call takes about 350 milliseconds on my laptop, most of it spent bringing the CCID interface up. Two &lt;code&gt;Match exec&lt;/code&gt; blocks means two invocations, so seven tenths of a second on every git operation in order to save five seconds fifteen times a day. The arithmetic does not work.&lt;/p&gt;

&lt;p&gt;So the helper caches. The question is what to key the cache on, and "a timeout" is the wrong answer. A 60-second TTL buys you either a minute of wrong answers after a swap or a minute of pointless &lt;code&gt;ykman&lt;/code&gt; calls, and you get to choose which.&lt;/p&gt;

&lt;p&gt;There is a better key available. The Linux USB stack assigns a device number on enumeration, and it increments. Swap tokens, even into the same physical port, and the number changes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="c"&gt;# yk1 attached
&lt;/span&gt;/&lt;span class="n"&gt;sys&lt;/span&gt;/&lt;span class="n"&gt;bus&lt;/span&gt;/&lt;span class="n"&gt;usb&lt;/span&gt;/&lt;span class="n"&gt;devices&lt;/span&gt;/&lt;span class="m"&gt;3&lt;/span&gt;-&lt;span class="m"&gt;4&lt;/span&gt;/  &lt;span class="n"&gt;busnum&lt;/span&gt;=&lt;span class="m"&gt;3&lt;/span&gt; &lt;span class="n"&gt;devnum&lt;/span&gt;=&lt;span class="m"&gt;10&lt;/span&gt;

&lt;span class="c"&gt;# swapped to yk2, same port
&lt;/span&gt;/&lt;span class="n"&gt;sys&lt;/span&gt;/&lt;span class="n"&gt;bus&lt;/span&gt;/&lt;span class="n"&gt;usb&lt;/span&gt;/&lt;span class="n"&gt;devices&lt;/span&gt;/&lt;span class="m"&gt;3&lt;/span&gt;-&lt;span class="m"&gt;4&lt;/span&gt;/  &lt;span class="n"&gt;busnum&lt;/span&gt;=&lt;span class="m"&gt;3&lt;/span&gt; &lt;span class="n"&gt;devnum&lt;/span&gt;=&lt;span class="m"&gt;11&lt;/span&gt;

&lt;span class="c"&gt;# swapped back to yk1
&lt;/span&gt;/&lt;span class="n"&gt;sys&lt;/span&gt;/&lt;span class="n"&gt;bus&lt;/span&gt;/&lt;span class="n"&gt;usb&lt;/span&gt;/&lt;span class="n"&gt;devices&lt;/span&gt;/&lt;span class="m"&gt;3&lt;/span&gt;-&lt;span class="m"&gt;4&lt;/span&gt;/  &lt;span class="n"&gt;busnum&lt;/span&gt;=&lt;span class="m"&gt;3&lt;/span&gt; &lt;span class="n"&gt;devnum&lt;/span&gt;=&lt;span class="m"&gt;12&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That gives a cache key derived from the thing whose change I care about. Reading it costs a &lt;code&gt;cat&lt;/code&gt; of a sysfs file. No timeout, no staleness window, no daemon watching udev.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/bin/sh&lt;/span&gt;
&lt;span class="c"&gt;# Exit 0 if a YubiKey with the given serial number is attached.&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-eu&lt;/span&gt;
&lt;span class="nv"&gt;want&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt;

&lt;span class="c"&gt;# Fingerprint the attached Yubico (vendor 1050) USB devices.&lt;/span&gt;
&lt;span class="nv"&gt;fp&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;d &lt;span class="k"&gt;in&lt;/span&gt; /sys/bus/usb/devices/&lt;span class="k"&gt;*&lt;/span&gt;/&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
    &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$d&lt;/span&gt;&lt;span class="s2"&gt;/idVendor"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;
    &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$d&lt;/span&gt;&lt;span class="s2"&gt;/idVendor"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; 1050 &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="k"&gt;continue
    &lt;/span&gt;&lt;span class="nv"&gt;fp&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$fp&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$d&lt;/span&gt;&lt;span class="s2"&gt;/busnum"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;-&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$d&lt;/span&gt;&lt;span class="s2"&gt;/devnum"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;,"&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$fp&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;1  &lt;span class="c"&gt;# nothing attached; no point spawning ykman&lt;/span&gt;

&lt;span class="c"&gt;# Deliberately no /tmp fallback; see below.&lt;/span&gt;
&lt;span class="nv"&gt;cache&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;XDG_RUNTIME_DIR&lt;/span&gt;:?&lt;span class="k"&gt;}&lt;/span&gt;/yk-serials
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$cache&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-n1&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$cache&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$fp&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;&lt;span class="nv"&gt;serials&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; +2 &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$cache&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;else
    &lt;/span&gt;&lt;span class="nv"&gt;serials&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;ykman list &lt;span class="nt"&gt;--serials&lt;/span&gt; 2&amp;gt;/dev/null&lt;span class="si"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nv"&gt;serials&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;
    &lt;span class="c"&gt;# Don't cache a failed lookup (busy pcscd, ykman missing); it would stick&lt;/span&gt;
    &lt;span class="c"&gt;# until the next replug.&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$serials&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
        &lt;/span&gt;&lt;span class="nv"&gt;tmp&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$cache&lt;/span&gt;.&lt;span class="nv"&gt;$$&lt;/span&gt;
        &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;umask &lt;/span&gt;077&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%s\n%s\n'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$fp&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$serials&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$tmp&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;mv&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$tmp&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$cache&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;fi
fi

&lt;/span&gt;&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%s\n'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$serials&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-qx&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$want&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details in there are scar tissue rather than design. The early exit when no Yubico device is present keeps &lt;code&gt;ykman&lt;/code&gt; from being spawned at all on a laptop with nothing plugged in, which is the common case when I am working offline. And declining to cache an empty result matters because a transient failure — a busy &lt;code&gt;pcscd&lt;/code&gt;, a &lt;code&gt;ykman&lt;/code&gt; that is not installed yet — would otherwise be cached against a fingerprint that stays valid until the next replug.&lt;/p&gt;

&lt;p&gt;The hard requirement on &lt;code&gt;XDG_RUNTIME_DIR&lt;/code&gt; is there for a related reason. It is torn down at logout, so the cache can never outlive a boot. Fall back to &lt;code&gt;/tmp&lt;/code&gt; on a system where &lt;code&gt;/tmp&lt;/code&gt; survives a reboot and the fingerprint stops meaning anything: device numbers are allocated from the low end again after every boot, so a stale entry has a very good chance of matching one.&lt;/p&gt;

&lt;p&gt;Warm, the helper returns in about 9ms. Resolving the whole config with both &lt;code&gt;Match&lt;/code&gt; blocks in it (&lt;code&gt;ssh -G github.com&lt;/code&gt;) comes to 87ms, the remainder being ssh's own startup rather than anything I added. Cold, on the first connection after a swap, you pay the 350ms once.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checking it actually works
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;ssh -v&lt;/code&gt; is useful here because it prints the whole decision path, key by key, rather than just the result. With the backup key in the port:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;debug1: get_agent_identities: agent returned 2 keys
debug1: Will attempt key: ~/.ssh/id_ed25519_sk_yk2 ED25519-SK
debug1: Offering public key: ~/.ssh/id_ed25519_sk_yk2 ED25519-SK
debug1: Server accepts key: ~/.ssh/id_ed25519_sk_yk2 ED25519-SK
Authenticated to github.com using &lt;span class="s2"&gt;"publickey"&lt;/span&gt;&lt;span class="nb"&gt;.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two keys in, one attempt out. &lt;code&gt;agent returned 2 keys&lt;/code&gt; alongside a single &lt;code&gt;Offering public key&lt;/code&gt; is the whole fix between them: the agent's inventory is unchanged, and the filtering happened before anything went out on the wire, let alone reached the token.&lt;/p&gt;

&lt;p&gt;I tested the other direction too, and I want to be honest about what that proved, because it is less than it looks. With &lt;code&gt;yk1&lt;/code&gt; attached, the run is identical and authentication succeeds on the first attempt. But &lt;strong&gt;that direction already worked before any of this&lt;/strong&gt;, purely because &lt;code&gt;id_ed25519_sk_yk1&lt;/code&gt; sorts before &lt;code&gt;id_ed25519_sk_yk2&lt;/code&gt; and got offered first by luck. The value of testing it was confirming I had not broken the path that was fine. The &lt;code&gt;yk2&lt;/code&gt; case is the one that fixed a real failure.&lt;/p&gt;

&lt;p&gt;That asymmetry is also why the bug survived so long. Half my usage was already correct, so it never looked like a broken configuration — it looked like the drawer key being flaky.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this does not fix
&lt;/h2&gt;

&lt;p&gt;It does not remove the PIN prompt. It removes the &lt;em&gt;doomed&lt;/em&gt; one. I still verify and still touch, which is the entire reason for carrying a hardware token at all; a change that removed those would be trading the whole point for a fraction of a second.&lt;/p&gt;

&lt;p&gt;There is one theoretical hole. USB device numbers wrap at 128, so a sufficiently unlucky sequence of replugs could land on a device number matching the cached one while holding a different token. The cost would be a single stale prompt, self-correcting on the next swap. I thought about defending against it for about a minute and then decided that engineering around a 1-in-128 chance of reproducing the exact bug I started with was not a good use of an afternoon.&lt;/p&gt;

&lt;h2&gt;
  
  
  The general shape
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;Match exec&lt;/code&gt; is the most underused thing in &lt;code&gt;ssh_config&lt;/code&gt;. Most people know &lt;code&gt;Match host&lt;/code&gt; and stop there, and then work around everything else with wrapper scripts or shell aliases or a mess of &lt;code&gt;-i&lt;/code&gt; flags in their git remotes. But the predicate can be &lt;em&gt;any command&lt;/em&gt; — which means your ssh config can respond to essentially any fact about the machine it is running on: which VPN is up, which network you are on, whether a particular file exists, which hardware token is in the port.&lt;/p&gt;

&lt;p&gt;The constraint that makes it interesting is that it sits in the hot path of every connection, so the fact you want has to be cheap to establish. Half the work here was not "how do I select the right key", which is four lines. It was "how do I answer a 350ms question in under 10ms without ever answering it wrong". That is a caching problem, and the trick is nearly always the same — find the thing that already changes when your answer changes, and key on that instead of on the clock.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Setup notes: Fedora, OpenSSH 10.2p1, YubiKey 5C NFC on firmware 5.4.3, keys held by the gnome-keyring agent at &lt;code&gt;$XDG_RUNTIME_DIR/gcr/ssh&lt;/code&gt;. Serial numbers in the examples are made up; use your own from &lt;code&gt;ykman list --serials&lt;/code&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Reviewed with Claude, which caught the unscoped &lt;code&gt;Match&lt;/code&gt; blocks and an error in how I'd explained the PIN/UV ordering. Prose and conclusions are mine.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ssh</category>
      <category>yubikey</category>
      <category>fido2</category>
      <category>linux</category>
    </item>
    <item>
      <title>Building GNOME Apps with Rust, Part 6: Fetching Feeds</title>
      <dc:creator>Justin - FromTheArchitect</dc:creator>
      <pubDate>Tue, 18 Aug 2026 23:16:31 +0000</pubDate>
      <link>https://dev.to/fromthearchitect/building-gnome-apps-with-rust-part-6-fetching-feeds-bjf</link>
      <guid>https://dev.to/fromthearchitect/building-gnome-apps-with-rust-part-6-fetching-feeds-bjf</guid>
      <description>&lt;p&gt;&lt;em&gt;This is Part 6 of a series taking a GNOME app from an empty directory to GNOME Circle. &lt;a href="https://dev.to/fromthearchitect/building-gnome-apps-with-rust-part-5-state-and-signals-4pb9"&gt;Part 5&lt;/a&gt; wired the sidebar to a real &lt;code&gt;Feed&lt;/code&gt; GObject and a &lt;code&gt;feed-selected&lt;/code&gt; signal — selecting a row updates the content pane and prints a line to the terminal. This is the post where that line stops being a placeholder and becomes a real network fetch.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The click that still does nothing real
&lt;/h2&gt;

&lt;p&gt;Select &lt;em&gt;This Week in GNOME&lt;/em&gt;, then select &lt;em&gt;Hacker News&lt;/em&gt;. The placeholder swaps in the new name and URL — everything the app knows about that feed, and none of what's actually in it. Every &lt;code&gt;Feed&lt;/code&gt; in the sidebar has a &lt;code&gt;uri&lt;/code&gt; pointing at a real RSS or Atom document on the actual internet, and nothing in the codebase has ever asked one of them what's there.&lt;/p&gt;

&lt;p&gt;The obvious fix — fetch the URL when &lt;code&gt;feed-selected&lt;/code&gt; fires — is one line of code and the wrong line of code. GTK doesn't have a spare thread lying around to do that on, and the thread it does have is busy.&lt;/p&gt;

&lt;p&gt;By the end of this post, selecting &lt;em&gt;Hacker News&lt;/em&gt; in the sidebar prints real headlines to the terminal, fetched over the network, without the window so much as flickering. We'll build a Tokio runtime alongside GTK's own, give &lt;code&gt;Feed&lt;/code&gt; somewhere to put what it fetches, and draw a hard line neither executor is allowed to cross.&lt;/p&gt;




&lt;h2&gt;
  
  
  The freeze
&lt;/h2&gt;

&lt;p&gt;Wire the obvious fix into the &lt;code&gt;feed-selected&lt;/code&gt; handler first, so you can watch it fail:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="n"&gt;obj&lt;/span&gt;&lt;span class="nf"&gt;.connect_closure&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s"&gt;"feed-selected"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nn"&gt;glib&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nd"&gt;closure_local!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;move&lt;/span&gt; &lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="n"&gt;_window&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;super&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;GazetteWindow&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;feed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Feed&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// Don't do this.&lt;/span&gt;
        &lt;span class="nn"&gt;std&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;thread&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;std&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;time&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;from_secs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
        &lt;span class="nd"&gt;eprintln!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"fetched (fake): {}"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;feed&lt;/span&gt;&lt;span class="nf"&gt;.name&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
    &lt;span class="p"&gt;}),&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;std::thread::sleep&lt;/code&gt; stands in for "network request in flight." Run the app and click a feed. For two seconds the window stops responding to anything — no hover states, no resize, no cursor blink, nothing repaints. Click a different feed while the first is still "fetching" and the click doesn't register until the sleep ends; it queues up invisibly and the window only notices you clicked at all once the first one finishes.&lt;/p&gt;

&lt;p&gt;If you have GTK Inspector open (&lt;code&gt;GTK_DEBUG=interactive&lt;/code&gt;, or Ctrl+Shift+I) while you try this, its own live views stall for the same two seconds — Inspector needs the app's main loop to be responsive to answer its own queries, so a frozen loop is invisible to Inspector too. Worth knowing the next time you're not sure whether &lt;em&gt;your&lt;/em&gt; code froze or something else did.&lt;/p&gt;

&lt;p&gt;Revert the handler to the logging-only version from Part 5 before continuing. The freeze was the demonstration, not a step you keep.&lt;/p&gt;




&lt;h2&gt;
  
  
  One main loop, nothing else runs until you give it back
&lt;/h2&gt;

&lt;p&gt;GTK schedules everything through a single &lt;code&gt;glib::MainContext&lt;/code&gt;, running cooperatively on a single thread: one job finishes before the next one starts. When you clicked a feed a moment ago, the redraw that should have shown the hover state, the cursor update, the eventual repaint once the click registered — all of it queued behind your &lt;code&gt;feed-selected&lt;/code&gt; handler, waiting for &lt;code&gt;std::thread::sleep&lt;/code&gt; to let go of the only thread any of it can run on.&lt;/p&gt;

&lt;p&gt;That's a deliberate tradeoff. Exactly one thread ever touches a widget, so there's no locking, no data races, no &lt;code&gt;Mutex&amp;lt;Widget&amp;gt;&lt;/code&gt;. The price is that the one thread has to keep coming back. Block it — a sleep, a synchronous file read, a blocking network call, anything — and every other queued job waits behind you.&lt;/p&gt;

&lt;p&gt;This is also why "just spawn a thread and do it there" isn't a fix, only a different failure. Almost every GTK/GObject type is &lt;code&gt;!Send&lt;/code&gt;: the bindings won't even let you move a &lt;code&gt;Feed&lt;/code&gt; or a widget handle across a thread boundary — it's a compile error, not a runtime one. GObject's own machinery assumes single-threaded access and doesn't defend against concurrent calls from a second thread. A background thread can compute a result, but it can't hand that result to a widget directly. Something still has to get the result back onto the one thread allowed to touch GTK state.&lt;/p&gt;




&lt;h2&gt;
  
  
  Two executors, one job each
&lt;/h2&gt;

&lt;p&gt;The pattern that resolves this has a name because the shape recurs in every GTK app that talks to a network: two executors, each with a rule it never breaks.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;GLib main loop&lt;/strong&gt; owns every widget, every GObject, every signal, and it must never block.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tokio&lt;/strong&gt; owns anything that can block — the network request here, file I/O or database queries later. It runs on its own thread pool, and it never touches a widget or a GObject directly. That's its rule.&lt;/p&gt;

&lt;p&gt;The two only meet at one seam: a future running on the GLib main context can &lt;code&gt;.await&lt;/code&gt; a handle to work Tokio is doing, and when that work finishes, execution resumes back on the main context with the result now sitting there as a plain value. Nothing crosses except that value — no &lt;code&gt;Feed&lt;/code&gt;, no &lt;code&gt;gtk::Label&lt;/code&gt;, nothing GTK owns ever travels to a Tokio thread, and no raw socket or blocking read ever runs on the GLib thread. The rest of this post is standing up both executors and building that one seam.&lt;/p&gt;




&lt;h2&gt;
  
  
  Standing up the second executor
&lt;/h2&gt;

&lt;p&gt;Add the dependencies:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;&lt;span class="py"&gt;tokio&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="py"&gt;version&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"1.53.1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="py"&gt;features&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"rt-multi-thread"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"net"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"time"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="py"&gt;feed-rs&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"2.4.0"&lt;/span&gt;
&lt;span class="py"&gt;reqwest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="py"&gt;version&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"0.12"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="py"&gt;default-features&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="py"&gt;features&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"rustls-tls"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;rt-multi-thread&lt;/code&gt; gives the runtime its worker threads; &lt;code&gt;net&lt;/code&gt; and &lt;code&gt;time&lt;/code&gt; give it the I/O and timer drivers &lt;code&gt;reqwest&lt;/code&gt; — and any future &lt;code&gt;tokio::time::sleep&lt;/code&gt; — actually needs. That matters more than it looks like it should: &lt;code&gt;reqwest&lt;/code&gt; also depends on &lt;code&gt;tokio&lt;/code&gt; and pulls in &lt;code&gt;net&lt;/code&gt;/&lt;code&gt;time&lt;/code&gt; itself, and Cargo unifies features across the whole dependency graph. Leave them off &lt;code&gt;tokio&lt;/code&gt;'s own line and the project still compiles today, by accident of what else happens to be in &lt;code&gt;Cargo.toml&lt;/code&gt;. It breaks the day &lt;code&gt;reqwest&lt;/code&gt; changes its feature list, or the day someone copies this dependency line into a project with no &lt;code&gt;reqwest&lt;/code&gt; in it.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;reqwest&lt;/code&gt; with &lt;code&gt;default-features = false&lt;/code&gt; and &lt;code&gt;rustls-tls&lt;/code&gt; pulls in a pure-Rust TLS stack instead of linking against the system's OpenSSL — one less thing the Flatpak manifest needs to account for. The &lt;code&gt;0.12&lt;/code&gt; pin is deliberate too: &lt;code&gt;0.13&lt;/code&gt; renamed that feature to &lt;code&gt;rustls&lt;/code&gt; and made it the default backend, which would leave the line above redundant rather than wrong, but &lt;code&gt;0.12&lt;/code&gt; is what this post was written and tested against, and mixing versions is a worse trap than an old pin.&lt;/p&gt;

&lt;p&gt;The runtime itself is built once, in &lt;code&gt;main()&lt;/code&gt;, before the GTK application exists:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="c1"&gt;// The second executor. Built before the GTK application and dropped&lt;/span&gt;
&lt;span class="c1"&gt;// after it exits, so it outlives every fetch that borrows its handle —&lt;/span&gt;
&lt;span class="c1"&gt;// the GLib main loop is the other executor, and it never blocks&lt;/span&gt;
&lt;span class="c1"&gt;// waiting on this one.&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;tokio_runtime&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;tokio&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;runtime&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;Builder&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new_multi_thread&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="nf"&gt;.worker_threads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;.thread_name&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"gazette-fetch"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;.enable_all&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="nf"&gt;.build&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="nf"&gt;.expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"failed to build Tokio runtime"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;GazetteApplication&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s"&gt;"io.github.fromthearchitect.gazette"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nn"&gt;gio&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;ApplicationFlags&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;empty&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="n"&gt;tokio_runtime&lt;/span&gt;&lt;span class="nf"&gt;.handle&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="nf"&gt;.clone&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;exit_code&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="nf"&gt;.run&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="c1"&gt;// `tokio_runtime` would drop here anyway at end of scope; the explicit&lt;/span&gt;
&lt;span class="c1"&gt;// drop just documents the ordering. `Runtime::drop` blocks this thread&lt;/span&gt;
&lt;span class="c1"&gt;// until every worker stops and abandons whatever tasks haven't&lt;/span&gt;
&lt;span class="c1"&gt;// finished — abrupt, not a cooperative cancel — which is still better&lt;/span&gt;
&lt;span class="c1"&gt;// than a fetch outliving the window that would have consumed it.&lt;/span&gt;
&lt;span class="nf"&gt;drop&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tokio_runtime&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;exit_code&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;app.run()&lt;/code&gt; blocks until the GTK application quits — that's the GLib main loop, occupying this thread for the entire life of the app. The Tokio runtime doesn't need this thread; &lt;code&gt;Builder::new_multi_thread&lt;/code&gt; spins up its own worker threads (two of them here, named for easy identification in a debugger) and hands back a &lt;code&gt;Runtime&lt;/code&gt; you only need to keep alive, not sit inside. &lt;code&gt;tokio_runtime.handle().clone()&lt;/code&gt; is the thing that actually travels: a cheap, cloneable reference that can schedule work onto the runtime from any thread, including the GLib main thread.&lt;/p&gt;

&lt;p&gt;One trap worth flagging on its own: &lt;strong&gt;forgetting &lt;code&gt;.enable_all()&lt;/code&gt; fails silently until the first real request.&lt;/strong&gt; A &lt;code&gt;Builder&lt;/code&gt; without it produces a runtime with no I/O or timer driver — a different gap from the missing Cargo features above, this one is about whether a correctly-compiled runtime &lt;em&gt;instance&lt;/em&gt; turns its drivers on. It builds fine, &lt;code&gt;main()&lt;/code&gt; runs fine, the window opens fine, and the first time a spawned task tries to do the thing a runtime is for — &lt;code&gt;reqwest::get&lt;/code&gt; or &lt;code&gt;tokio::time::sleep&lt;/code&gt; — it panics with a message about no I/O driver running. The bug is invisible until the exact code path that needs the missing driver executes, which is exactly the fetch path this post is building.&lt;/p&gt;

&lt;p&gt;That &lt;code&gt;Handle&lt;/code&gt; needs a home the rest of the app can reach. &lt;code&gt;GazetteApplication&lt;/code&gt; already owns everything else global to a running instance, so it owns this too — the same &lt;code&gt;OnceCell&lt;/code&gt; shape Part 5 used for &lt;code&gt;feeds: OnceCell&amp;lt;gio::ListStore&amp;gt;&lt;/code&gt; on the window: set once, read forever after.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="nd"&gt;#[derive(Debug,&lt;/span&gt; &lt;span class="nd"&gt;Default)]&lt;/span&gt;
&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="n"&gt;GazetteApplication&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// The second executor. Built once in `main()`, before the GTK&lt;/span&gt;
    &lt;span class="c1"&gt;// application runs, and outlives every fetch that borrows it.&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;tokio&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;OnceCell&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nn"&gt;tokio&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;runtime&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Handle&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;impl&lt;/span&gt; &lt;span class="n"&gt;GazetteApplication&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;application_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;flags&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nn"&gt;gio&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;ApplicationFlags&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;tokio&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nn"&gt;tokio&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;runtime&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Handle&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;Self&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;Self&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;glib&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="nf"&gt;.property&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"application-id"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;application_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="nf"&gt;.property&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"flags"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;flags&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="nf"&gt;.property&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"resource-base-path"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"/io/github/fromthearchitect/gazette"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="nf"&gt;.build&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="nf"&gt;.imp&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="py"&gt;.tokio&lt;/span&gt;
            &lt;span class="nf"&gt;.set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tokio&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="nf"&gt;.expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"tokio handle set once at construction"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;app&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="cd"&gt;/// The shared Tokio runtime handle — the executor every network fetch&lt;/span&gt;
    &lt;span class="cd"&gt;/// runs on. Available from any `GazetteWindow` via its `application()`.&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;tokio_handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nn"&gt;tokio&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;runtime&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Handle&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="nf"&gt;.imp&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="py"&gt;.tokio&lt;/span&gt;&lt;span class="nf"&gt;.get&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="nf"&gt;.expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"tokio handle set"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nf"&gt;.clone&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;build()&lt;/code&gt; returns a fully constructed object. If &lt;code&gt;GazetteApplication&lt;/code&gt; ever grows a &lt;code&gt;constructed()&lt;/code&gt; that reaches for &lt;code&gt;tokio_handle()&lt;/code&gt;, it will find the cell empty — &lt;code&gt;constructed()&lt;/code&gt; runs &lt;em&gt;during&lt;/em&gt; &lt;code&gt;build()&lt;/code&gt;, before the line that sets &lt;code&gt;tokio&lt;/code&gt; ever executes. Today's code escapes that trap by timing rather than by ordering: nothing that needs the handle runs until GTK calls &lt;code&gt;activate&lt;/code&gt;, and &lt;code&gt;activate&lt;/code&gt; doesn't fire until &lt;code&gt;app.run()&lt;/code&gt;, long after &lt;code&gt;new&lt;/code&gt; has returned with &lt;code&gt;tokio&lt;/code&gt; set. Any window can reach the runtime through &lt;code&gt;window.application()&lt;/code&gt;, downcast to &lt;code&gt;GazetteApplication&lt;/code&gt;, and call &lt;code&gt;tokio_handle()&lt;/code&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Fetching, off the main thread
&lt;/h2&gt;

&lt;p&gt;The fetch itself lives in a new file, &lt;code&gt;src/fetch.rs&lt;/code&gt;, deliberately separate from &lt;code&gt;feed.rs&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nn"&gt;std&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="cd"&gt;/// A single parsed entry from a feed. Plain data — not a GObject. It's&lt;/span&gt;
&lt;span class="cd"&gt;/// built on the Tokio runtime and only crosses onto the GLib main context&lt;/span&gt;
&lt;span class="cd"&gt;/// as a value, via `Feed::set_items`.&lt;/span&gt;
&lt;span class="nd"&gt;#[derive(Debug,&lt;/span&gt; &lt;span class="nd"&gt;Clone)]&lt;/span&gt;
&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="n"&gt;FeedItem&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;link&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;summary&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Option&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nd"&gt;#[derive(Debug)]&lt;/span&gt;
&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;enum&lt;/span&gt; &lt;span class="n"&gt;FetchError&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;reqwest&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nf"&gt;Parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;feed_rs&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;parser&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;ParseFeedError&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;impl&lt;/span&gt; &lt;span class="nn"&gt;fmt&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Display&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;FetchError&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;fmt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="nn"&gt;fmt&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Formatter&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nv"&gt;'_&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nn"&gt;fmt&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;Result&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;match&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nn"&gt;FetchError&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nd"&gt;write!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"request failed: {e}"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="nn"&gt;FetchError&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;Parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nd"&gt;write!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"couldn't parse feed: {e}"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="cd"&gt;/// Fetches and parses a feed at `uri`. Runs entirely on the Tokio runtime —&lt;/span&gt;
&lt;span class="cd"&gt;/// nothing in this function, or anything it calls, touches a widget or a&lt;/span&gt;
&lt;span class="cd"&gt;/// GObject. That's what makes it safe to run off the GLib main thread.&lt;/span&gt;
&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;fetch_feed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;uri&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Result&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;Vec&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;FeedItem&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;FetchError&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;reqwest&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;uri&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;.await&lt;/span&gt;
        &lt;span class="nf"&gt;.map_err&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;FetchError&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;
        &lt;span class="nf"&gt;.error_for_status&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="nf"&gt;.map_err&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;FetchError&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;bytes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="nf"&gt;.bytes&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="nf"&gt;.map_err&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;FetchError&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;parsed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;feed_rs&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;parser&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;..&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;&lt;span class="nf"&gt;.map_err&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;FetchError&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Parse&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;parsed&lt;/span&gt;
        &lt;span class="py"&gt;.entries&lt;/span&gt;
        &lt;span class="nf"&gt;.into_iter&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="nf"&gt;.map&lt;/span&gt;&lt;span class="p"&gt;(|&lt;/span&gt;&lt;span class="n"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="n"&gt;FeedItem&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;entry&lt;/span&gt;
                &lt;span class="py"&gt;.title&lt;/span&gt;
                &lt;span class="nf"&gt;.map&lt;/span&gt;&lt;span class="p"&gt;(|&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="py"&gt;.content&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="nf"&gt;.unwrap_or_else&lt;/span&gt;&lt;span class="p"&gt;(||&lt;/span&gt; &lt;span class="s"&gt;"Untitled"&lt;/span&gt;&lt;span class="nf"&gt;.to_string&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt;
            &lt;span class="n"&gt;link&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;entry&lt;/span&gt;
                &lt;span class="py"&gt;.links&lt;/span&gt;
                &lt;span class="nf"&gt;.first&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
                &lt;span class="nf"&gt;.map&lt;/span&gt;&lt;span class="p"&gt;(|&lt;/span&gt;&lt;span class="n"&gt;link&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="n"&gt;link&lt;/span&gt;&lt;span class="py"&gt;.href&lt;/span&gt;&lt;span class="nf"&gt;.clone&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
                &lt;span class="nf"&gt;.unwrap_or_default&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
            &lt;span class="n"&gt;summary&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;entry&lt;/span&gt;&lt;span class="py"&gt;.summary&lt;/span&gt;&lt;span class="nf"&gt;.map&lt;/span&gt;&lt;span class="p"&gt;(|&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="py"&gt;.content&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;})&lt;/span&gt;
        &lt;span class="nf"&gt;.collect&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;FeedItem&lt;/code&gt; is a plain struct rather than a &lt;code&gt;glib::wrapper!&lt;/code&gt; type, and that matters: constructing a GObject means calling into GObject's type system, and this function has no business doing that from a Tokio worker thread. &lt;code&gt;fetch_feed&lt;/code&gt; produces data; something back on the main thread decides what GObject, if any, that data becomes.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;error_for_status()&lt;/code&gt; matters more than it looks. Without it, &lt;code&gt;reqwest::get&lt;/code&gt; resolves &lt;code&gt;Ok&lt;/code&gt; for a 404, a 500, or a hosting provider's error interstitial — the HTTP request itself succeeded even though the content is a lie. Skip the check and &lt;code&gt;feed_rs::parser::parse&lt;/code&gt; gets handed an HTML error page instead of a feed, fails to parse it, and the caller sees &lt;code&gt;FetchError::Parse&lt;/code&gt; for what was actually a request-level failure. Checking status before reading the body is what keeps &lt;code&gt;Request&lt;/code&gt; meaning "didn't get a real feed body at all" — whether the cause is DNS, a timeout, or a 404 — and &lt;code&gt;Parse&lt;/code&gt; meaning "got a body and it wasn't a feed."&lt;/p&gt;

&lt;p&gt;&lt;code&gt;feed_rs::parser::parse&lt;/code&gt; takes anything implementing &lt;code&gt;std::io::Read&lt;/code&gt; (a byte slice qualifies) and returns entries regardless of whether the source was RSS or Atom. Gazette's four sample feeds are a mix of both — &lt;em&gt;This Week in GNOME&lt;/em&gt; and &lt;em&gt;From the Architect&lt;/em&gt; are Atom, &lt;em&gt;LWN&lt;/em&gt; and the Hacker News mirror are RSS — and &lt;code&gt;fetch_feed&lt;/code&gt; doesn't need to know or care which. That format detection is &lt;code&gt;feed-rs&lt;/code&gt;'s job, done once, before any of Gazette's code sees an entry.&lt;/p&gt;

&lt;p&gt;One thing worth knowing, not fixing here: &lt;code&gt;reqwest::get&lt;/code&gt; is a convenience function that builds a fresh &lt;code&gt;Client&lt;/code&gt; — and with it, a fresh connection pool and TLS session — on every call. Fine for a post about the boundary between two executors, wasteful in code that fetches the same handful of feeds over and over. Building a &lt;code&gt;Client&lt;/code&gt; once and sharing it alongside the Tokio handle is a refinement for a later post, once persistence is in the picture.&lt;/p&gt;




&lt;h2&gt;
  
  
  Crossing back
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;feed-selected&lt;/code&gt; handler is where the two executors actually meet:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="n"&gt;obj&lt;/span&gt;&lt;span class="nf"&gt;.connect_closure&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s"&gt;"feed-selected"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nn"&gt;glib&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nd"&gt;closure_local!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;move&lt;/span&gt; &lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="n"&gt;window&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;super&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;GazetteWindow&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;feed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Feed&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nd"&gt;eprintln!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"feed selected: {} ({})"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;feed&lt;/span&gt;&lt;span class="nf"&gt;.name&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;feed&lt;/span&gt;&lt;span class="nf"&gt;.uri&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;

        &lt;span class="c1"&gt;// The GLib executor owns `feed` and `window`; the Tokio&lt;/span&gt;
        &lt;span class="c1"&gt;// executor (below) only ever sees the URI string it needs&lt;/span&gt;
        &lt;span class="c1"&gt;// to fetch. Neither side touches the other's objects&lt;/span&gt;
        &lt;span class="c1"&gt;// directly — the `.await` on the join handle is the only&lt;/span&gt;
        &lt;span class="c1"&gt;// crossing.&lt;/span&gt;
        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;tokio_handle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;window&lt;/span&gt;
            &lt;span class="nf"&gt;.application&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="py"&gt;.and_downcast&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;crate&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;application&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;GazetteApplication&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="nf"&gt;.expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"window has a GazetteApplication"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="nf"&gt;.tokio_handle&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;uri&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;feed&lt;/span&gt;&lt;span class="nf"&gt;.uri&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

        &lt;span class="nn"&gt;glib&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;MainContext&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;default&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="nf"&gt;.spawn_local&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;move&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tokio_handle&lt;/span&gt;
                &lt;span class="nf"&gt;.spawn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;move&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;crate&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;fetch_feed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;uri&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="k"&gt;.await&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
                &lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

            &lt;span class="k"&gt;match&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;feed&lt;/span&gt;&lt;span class="nf"&gt;.set_items&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
                &lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;Err&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fetch_err&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                    &lt;span class="nd"&gt;eprintln!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"fetch failed for {}: {fetch_err}"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;feed&lt;/span&gt;&lt;span class="nf"&gt;.name&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
                &lt;span class="p"&gt;}&lt;/span&gt;
                &lt;span class="nf"&gt;Err&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;join_err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                    &lt;span class="nd"&gt;eprintln!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                        &lt;span class="s"&gt;"fetch task for {} did not complete: {join_err}"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                        &lt;span class="n"&gt;feed&lt;/span&gt;&lt;span class="nf"&gt;.name&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
                    &lt;span class="p"&gt;);&lt;/span&gt;
                &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;}),&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read it from the outside in. &lt;code&gt;glib::MainContext::default().spawn_local(async move { ... })&lt;/code&gt; schedules the outer future on the GLib main context — the same executor that owns every widget — running it cooperatively alongside everything else, exactly like a signal handler. Because it's confined to the main context, it's allowed to hold &lt;code&gt;feed&lt;/code&gt; and eventually call &lt;code&gt;feed.set_items(...)&lt;/code&gt;. That's still executor-one territory.&lt;/p&gt;

&lt;p&gt;Inside it, &lt;code&gt;tokio_handle.spawn(async move { crate::fetch::fetch_feed(&amp;amp;uri).await })&lt;/code&gt; hands the actual work to executor two. &lt;code&gt;Handle::spawn&lt;/code&gt; returns immediately with a &lt;code&gt;JoinHandle&lt;/code&gt;; it doesn't block the caller, which is the entire point of calling it from the main thread. &lt;code&gt;.await&lt;/code&gt;ing that &lt;code&gt;JoinHandle&lt;/code&gt; is where the outer future pauses: control genuinely returns to the GLib main loop, which goes on painting, handling input, and dispatching other signals while Tokio's worker threads do the fetch. When the Tokio task finishes, the main loop resumes this specific future exactly where it left off, back on the main thread, with &lt;code&gt;result&lt;/code&gt; now holding a value.&lt;/p&gt;

&lt;p&gt;Why &lt;code&gt;Ok(Ok(items))&lt;/code&gt; and not just &lt;code&gt;Ok(items)&lt;/code&gt;? Two different things can go wrong, and they're wrapped separately. The outer &lt;code&gt;Result&lt;/code&gt; comes from &lt;code&gt;JoinHandle&lt;/code&gt; itself — its &lt;code&gt;Err&lt;/code&gt; means the spawned task panicked or was cancelled, a Tokio-level failure that has nothing to do with feeds. The inner &lt;code&gt;Result&lt;/code&gt; is &lt;code&gt;fetch_feed&lt;/code&gt;'s own &lt;code&gt;Result&amp;lt;Vec&amp;lt;FeedItem&amp;gt;, FetchError&amp;gt;&lt;/code&gt;, a normal, expected outcome of trying to fetch something over a network that might be down. Matching both layers explicitly is what forces you to decide what each one means, rather than letting &lt;code&gt;?&lt;/code&gt; collapse them into a single generic failure.&lt;/p&gt;

&lt;p&gt;And a specific footgun: &lt;strong&gt;&lt;code&gt;tokio::spawn(...)&lt;/code&gt;, the free function, panics here — &lt;code&gt;tokio_handle.spawn(...)&lt;/code&gt; doesn't.&lt;/strong&gt; The free function only works from &lt;em&gt;inside&lt;/em&gt; a future that's already running on a Tokio runtime. Called from the GLib main thread, which isn't inside any Tokio runtime, it panics; the exact wording has changed across Tokio versions, but it always amounts to "no runtime found in this context." &lt;code&gt;Handle::spawn&lt;/code&gt;, called on the &lt;code&gt;Handle&lt;/code&gt; cloned out in &lt;code&gt;main()&lt;/code&gt;, is explicitly designed to work from any thread, runtime or not — that's the entire reason &lt;code&gt;GazetteApplication&lt;/code&gt; stores a &lt;code&gt;Handle&lt;/code&gt; instead of a &lt;code&gt;Runtime&lt;/code&gt;. If you ever see that panic, you've reached for the free function where you needed the handle.&lt;/p&gt;

&lt;p&gt;Run the app now. Click &lt;em&gt;Hacker News&lt;/em&gt;: after a brief pause — a real network round trip, with the window still live under your cursor — real headlines appear on stderr. Click &lt;em&gt;LWN.net Headlines&lt;/em&gt; before the first fetch has printed anything, and both proceed independently.&lt;/p&gt;




&lt;h2&gt;
  
  
  Feed remembers what it fetched
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;set_items&lt;/code&gt; is doing real work in that handler, and &lt;code&gt;Feed&lt;/code&gt; needs somewhere to put what it's given:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="k"&gt;crate&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;FeedItem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nd"&gt;#[derive(Debug,&lt;/span&gt; &lt;span class="nd"&gt;Default,&lt;/span&gt; &lt;span class="nd"&gt;Properties)]&lt;/span&gt;
&lt;span class="nd"&gt;#[properties(wrapper_type&lt;/span&gt; &lt;span class="nd"&gt;=&lt;/span&gt; &lt;span class="nd"&gt;super::Feed)]&lt;/span&gt;
&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="n"&gt;Feed&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nd"&gt;#[property(get,&lt;/span&gt; &lt;span class="nd"&gt;set)]&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;RefCell&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nd"&gt;#[property(get,&lt;/span&gt; &lt;span class="nd"&gt;set)]&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;uri&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;RefCell&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nd"&gt;#[property(get,&lt;/span&gt; &lt;span class="nd"&gt;set)]&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;unread_count&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Cell&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;u32&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;

    &lt;span class="c1"&gt;// Plain data, not a property: `Vec&amp;lt;FeedItem&amp;gt;` has no `glib::Value`&lt;/span&gt;
    &lt;span class="c1"&gt;// mapping, and nothing outside this module needs to bind to it&lt;/span&gt;
    &lt;span class="c1"&gt;// directly. `items()` and `set_items()` are the accessors.&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;RefCell&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;Vec&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;FeedItem&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;impl&lt;/span&gt; &lt;span class="n"&gt;Feed&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Vec&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;FeedItem&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="nf"&gt;.imp&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="py"&gt;.items&lt;/span&gt;&lt;span class="nf"&gt;.borrow&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="nf"&gt;.clone&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="cd"&gt;/// Replaces this feed's items and emits `items-updated`. The count&lt;/span&gt;
    &lt;span class="cd"&gt;/// crossing over from a real fetch is what makes `unread-count` real&lt;/span&gt;
    &lt;span class="cd"&gt;/// too, rather than the placeholder zero every sample feed starts with.&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;set_items&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Vec&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;FeedItem&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="nf"&gt;.set_unread_count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="nf"&gt;.len&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nb"&gt;u32&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="nf"&gt;.imp&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="py"&gt;.items&lt;/span&gt;&lt;span class="nf"&gt;.replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.emit_by_name&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"items-updated"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="p"&gt;[]);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;items&lt;/code&gt; isn't a &lt;code&gt;#[property]&lt;/code&gt;: a &lt;code&gt;Vec&amp;lt;FeedItem&amp;gt;&lt;/code&gt; has no &lt;code&gt;glib::Value&lt;/code&gt; representation the way a &lt;code&gt;String&lt;/code&gt; or a &lt;code&gt;u32&lt;/code&gt; does, and nothing outside &lt;code&gt;Feed&lt;/code&gt; needs to bind a widget to the raw list. It's a &lt;code&gt;RefCell&lt;/code&gt; for the same reason &lt;code&gt;selected_feed&lt;/code&gt; was one back in Part 5 — a value that changes over the object's life, read from one place (&lt;code&gt;items()&lt;/code&gt;), written from one place (&lt;code&gt;set_items()&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;items-updated&lt;/code&gt; signal was declared back in Part 2, on a &lt;code&gt;Feed&lt;/code&gt; that never actually got new items, then Part 5 dropped it entirely to avoid emitting ceremony for nothing. It comes back here on the same &lt;code&gt;impl&lt;/code&gt; block that already derives &lt;code&gt;Feed&lt;/code&gt;'s properties — with one change from Part 2's sketch:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="nd"&gt;#[glib::derived_properties]&lt;/span&gt;
&lt;span class="k"&gt;impl&lt;/span&gt; &lt;span class="n"&gt;ObjectImpl&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;Feed&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;signals&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;'static&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Signal&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="n"&gt;SIGNALS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;OnceLock&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;Vec&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Signal&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;OnceLock&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="n"&gt;SIGNALS&lt;/span&gt;&lt;span class="nf"&gt;.get_or_init&lt;/span&gt;&lt;span class="p"&gt;(||&lt;/span&gt; &lt;span class="nd"&gt;vec!&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nn"&gt;Signal&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"items-updated"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nf"&gt;.build&lt;/span&gt;&lt;span class="p"&gt;()])&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Part 2's version carried a &lt;code&gt;u32&lt;/code&gt; payload — the new item count, passed directly on the signal. This one carries nothing. &lt;code&gt;Feed&lt;/code&gt; has an &lt;code&gt;unread-count&lt;/code&gt; property now, so a listener that wants the count can just read &lt;code&gt;feed.unread_count()&lt;/code&gt; instead of catching it off the emission. A signal that announces &lt;em&gt;something changed&lt;/em&gt; and lets listeners go look is easier to evolve than one that ships a payload every listener has to keep accepting — that's the shape the rest of this post builds on.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;#[glib::derived_properties]&lt;/code&gt; has to stay on this &lt;code&gt;impl&lt;/code&gt;, even though &lt;code&gt;signals()&lt;/code&gt; has nothing to do with properties: it's what generates &lt;code&gt;properties()&lt;/code&gt;, &lt;code&gt;property()&lt;/code&gt;, and &lt;code&gt;set_property()&lt;/code&gt; from the &lt;code&gt;#[property]&lt;/code&gt; fields on the struct above. Drop it while adding &lt;code&gt;signals()&lt;/code&gt; and every &lt;code&gt;#[property]&lt;/code&gt; accessor &lt;code&gt;Feed&lt;/code&gt; already has — &lt;code&gt;name()&lt;/code&gt;, &lt;code&gt;uri()&lt;/code&gt;, &lt;code&gt;set_unread_count()&lt;/code&gt; — silently stops working.&lt;/p&gt;

&lt;p&gt;What's different now is that &lt;code&gt;set_items&lt;/code&gt; is the only thing that ever emits &lt;code&gt;items-updated&lt;/code&gt;, and it's only ever called with data that actually came off the network. &lt;code&gt;set_unread_count&lt;/code&gt; runs first, before the emit, and the count that shows up in the sidebar is the number of items this fetch found.&lt;/p&gt;

&lt;p&gt;That's a narrower claim than "unread," on purpose. Every fetch sets &lt;code&gt;unread-count&lt;/code&gt; to the &lt;em&gt;total&lt;/em&gt; item count, not the count of items the user hasn't seen. Refetch a feed after reading everything in it and the badge goes straight back up to the full number. &lt;code&gt;unread_count&lt;/code&gt; is standing in for a property Gazette doesn't have the machinery to compute correctly yet — real read-tracking needs per-item state, which doesn't exist until this series adds persistence. Until then, read the name as aspirational, not as a bug you need to chase.&lt;/p&gt;




&lt;h2&gt;
  
  
  A consumer for items-updated
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;set_items&lt;/code&gt; emits; something has to be listening. Each sample feed gets a logging handler when the store is built, in &lt;code&gt;constructed()&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;store&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;gio&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;ListStore&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;new&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Feed&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;uri&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;SAMPLE_FEEDS&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;feed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;Feed&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;uri&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="c1"&gt;// Reacting to items-updated is a separate job from fetching&lt;/span&gt;
    &lt;span class="c1"&gt;// them — the fetch (wired below, on feed-selected) never&lt;/span&gt;
    &lt;span class="c1"&gt;// logs directly, it just calls `feed.set_items(...)`.&lt;/span&gt;
    &lt;span class="n"&gt;feed&lt;/span&gt;&lt;span class="nf"&gt;.connect_closure&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="s"&gt;"items-updated"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nn"&gt;glib&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nd"&gt;closure_local!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;move&lt;/span&gt; &lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="n"&gt;feed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Feed&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;items&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;feed&lt;/span&gt;&lt;span class="nf"&gt;.items&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
            &lt;span class="nd"&gt;eprintln!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"{}: {} items"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;feed&lt;/span&gt;&lt;span class="nf"&gt;.name&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="nf"&gt;.len&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
            &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="nf"&gt;.iter&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="nf"&gt;.take&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="nd"&gt;eprintln!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"  - {}"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="py"&gt;.title&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;}),&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="nf"&gt;.append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;feed&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice the fetch code in &lt;code&gt;feed-selected&lt;/code&gt; never calls &lt;code&gt;eprintln!&lt;/code&gt; on success. It just calls &lt;code&gt;feed.set_items(items)&lt;/code&gt; and stops. Printing what arrived is this closure's job, connected once per feed, entirely separate from the code that went and got the data. That's the same lesson Part 5 landed with the placeholder binding and the &lt;code&gt;feed-selected&lt;/code&gt; logger: fetching is one job, reacting to what was fetched is another, and the signal between them is what keeps neither one needing to know the other exists. A future persistence layer connects its own handler to &lt;code&gt;items-updated&lt;/code&gt; later without touching a line of &lt;code&gt;fetch.rs&lt;/code&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Sharp edges
&lt;/h2&gt;

&lt;p&gt;A &lt;code&gt;RefCell&lt;/code&gt; borrow held across an &lt;code&gt;.await&lt;/code&gt; fails the same way a borrow held across a signal emit did in Part 5, just harder to spot. The failure shape: &lt;code&gt;let items = self.items.borrow_mut(); some_future.await;&lt;/code&gt; inside a &lt;code&gt;spawn_local&lt;/code&gt; block, where the code resumed after the &lt;code&gt;.await&lt;/code&gt; — or a handler running on a different turn of the main loop, if the borrow escapes further — tries to borrow the same cell. Signal handlers run synchronously, so a Part 5-style borrow panic happens right where you'd look for it. An &lt;code&gt;.await&lt;/code&gt; point hands control back to the main loop for an arbitrary amount of time: anything else the loop dispatches in between, including another handler that reaches for the same &lt;code&gt;RefCell&lt;/code&gt;, can trigger the panic somewhere that looks nothing like the code that caused it. Keep borrows scoped tightly, and never hold one across an &lt;code&gt;.await&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;What happens if you click the same feed five times in a row? Five independent fetches run concurrently, each racing to call &lt;code&gt;set_items&lt;/code&gt; on the same &lt;code&gt;Feed&lt;/code&gt; when it finishes — last one to land wins, and &lt;code&gt;items-updated&lt;/code&gt; fires five times for one selection. Harmless for a post about the executor boundary, but worth knowing before you go adding a refresh action, which makes hammering that shortcut the obvious next thing to try. A real fix would track the in-flight &lt;code&gt;JoinHandle&lt;/code&gt; on &lt;code&gt;Feed&lt;/code&gt; and abort or ignore a new request while one's outstanding — outside this post's scope, but that's where the fix would go.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;window.application()&lt;/code&gt; returns &lt;code&gt;None&lt;/code&gt; before the window has one, and &lt;code&gt;expect&lt;/code&gt; turns that into a panic. In practice this never fires here: &lt;code&gt;GazetteWindow::new&lt;/code&gt; always takes an application, and nothing runs &lt;code&gt;feed-selected&lt;/code&gt; before the window exists. The &lt;code&gt;expect&lt;/code&gt; documents an invariant rather than checking one — this window always has an application by the time it can receive input. If you ever construct a &lt;code&gt;GazetteWindow&lt;/code&gt; without one, a test most likely, this is where it breaks.&lt;/p&gt;




&lt;h2&gt;
  
  
  What we have so far
&lt;/h2&gt;

&lt;p&gt;Selecting a feed now does real work. A Tokio runtime, built once alongside the GTK application and never touched by it directly, does the actual fetching and parsing, and the only thing that ever crosses back onto the GLib main thread is a &lt;code&gt;Result&lt;/code&gt; full of plain data. &lt;code&gt;Feed::items-updated&lt;/code&gt; fires for real for the first time since Part 2 declared it, driven by whatever &lt;code&gt;feed-rs&lt;/code&gt; found at the far end of a real URL.&lt;/p&gt;

&lt;p&gt;Nothing about this displays anywhere yet — the items live in a &lt;code&gt;RefCell&lt;/code&gt; a logging handler reads, and the sidebar still shows nothing but a name and a count. The fetch mechanism this post built doesn't need to change at all to support that; it already hands off a &lt;code&gt;Vec&amp;lt;FeedItem&amp;gt;&lt;/code&gt; to whatever wants to render it.&lt;/p&gt;




&lt;h2&gt;
  
  
  What comes next
&lt;/h2&gt;

&lt;p&gt;That's the gap the next post closes: an article list for the selected feed, a content pane for the selected article, and the adaptive layout that collapses both into a single pane on a narrow window. The two-executor pattern from this post doesn't change to support any of it.&lt;/p&gt;

&lt;p&gt;Further out, &lt;code&gt;unread_count&lt;/code&gt; and &lt;code&gt;items&lt;/code&gt; want somewhere durable to live, so a refetch doesn't erase read state and a restart doesn't lose it either. That's the persistence layer this series has been setting up for since the &lt;code&gt;OnceCell&lt;/code&gt; and &lt;code&gt;RefCell&lt;/code&gt; conventions in Part 5 — a real place for &lt;code&gt;items-updated&lt;/code&gt; to write to, instead of a logging handler standing in for one.&lt;/p&gt;




&lt;p&gt;The source code at the end of this post lives on the &lt;code&gt;part-6&lt;/code&gt; branch of &lt;a href="https://github.com/fromthearchitect/gnome-rust-gazette/tree/part-6" rel="noopener noreferrer"&gt;fromthearchitect/gnome-rust-gazette&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>gnome</category>
      <category>rust</category>
      <category>gtk</category>
      <category>tokio</category>
    </item>
    <item>
      <title>Moments: The Photo App I Built to Win an Argument</title>
      <dc:creator>Justin - FromTheArchitect</dc:creator>
      <pubDate>Fri, 14 Aug 2026 08:17:33 +0000</pubDate>
      <link>https://dev.to/fromthearchitect/moments-the-photo-app-i-built-to-win-an-argument-1edm</link>
      <guid>https://dev.to/fromthearchitect/moments-the-photo-app-i-built-to-win-an-argument-1edm</guid>
      <description>&lt;p&gt;&lt;em&gt;A phone call with a mate turned into a wager: could AI actually build a real application from scratch? Five months, 898 commits and 40,000 lines of Rust later, here's the verdict — and the app.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  It started with a phone call
&lt;/h2&gt;

&lt;p&gt;Like most of my questionable decisions, this one started on the phone with a mate, talking about technology the way we always do. The conversation drifted, as every technical conversation eventually does now, into AI. And we ended up circling the same question everyone circles: is this stuff actually capable of building real software, or is it just very confident autocomplete?&lt;/p&gt;

&lt;p&gt;He was the sceptic. I was less sure. I'd been watching AI coding tools long enough to see signs they could do more than trivial hello-world demos — I'd even &lt;a href="https://dev.to/posts/ai_pair_programmer_post/"&gt;written about the right way to work with them&lt;/a&gt; more than a year earlier. But I don't think either of us honestly believed the answer would be a full-featured desktop application.&lt;/p&gt;

&lt;p&gt;So I did the thing you should never do on a phone call after 9pm: I made a declaration. I would definitively prove, one way or the other, whether AI could build a real application from scratch.&lt;/p&gt;

&lt;p&gt;Not from a single prompt. That's a stupid idea, and it's a strawman both camps love: the boosters because it makes a great demo, the sceptics because it makes a great failure. I mean built the way software actually gets built: architecture, iteration, review, tests, releases. Using the pair programming approach I'd already laid out on this blog — human as navigator, AI as driver, with the conversation between them doing the real work.&lt;/p&gt;

&lt;p&gt;If it failed, I'd write that post. It would have been an easier post to write, honestly. This is the other one.&lt;/p&gt;




&lt;h2&gt;
  
  
  Picking a fight worth having
&lt;/h2&gt;

&lt;p&gt;If you're going to test whether AI can build real software, the project has to be real: something with real complexity, a demanding platform, and a reason to exist beyond the experiment.&lt;/p&gt;

&lt;p&gt;I've been an on-again, off-again Linux user for the best part of thirty years. The kernel was never the problem. The desktop was never really the problem either, especially lately. The thing that has always dragged me back to commercial operating systems is the application ecosystem. It's better today than it has ever been, thanks to an enormous number of dedicated open source developers. But there are still categories where the open source options simply don't compete with their commercial rivals. Photos. Email. The apps your family actually touches.&lt;/p&gt;

&lt;p&gt;And that second part matters, because this wasn't abstract for me. I've been trying to move my family away from Apple Photos, and self-hosted &lt;a href="https://immich.app/" rel="noopener noreferrer"&gt;Immich&lt;/a&gt; is a brilliant back end for that. But my family has spent too many years inside the Apple ecosystem to accept a web interface for something as personal as their photo library. They want a real application. Native, fast, polished. The kind of thing the Linux desktop is chronically short of.&lt;/p&gt;

&lt;p&gt;So that was the wager, fully formed: a modern GTK4/libadwaita photo management application for the GNOME desktop, written in Rust. (All the cool kids use Rust. It's been a long time since I was a kid.)&lt;/p&gt;

&lt;p&gt;There was one more reason this experiment appealed to me, and it's the one I suspect resonates with most working engineers. My background is software engineering. I've built some large, complex, performance-critical systems for companies big and small. Building software has never been the hard part. &lt;em&gt;Finding the time&lt;/em&gt; has. A career plus a family leaves you time-poor in exactly the way that kills side projects, and it's why I'd never seriously committed to a sizable open source project before. If AI could compress the gap between the architecture in my head and working code on disk, the economics of contributing to open source change completely — not just for me, for every experienced engineer who's been sitting on the sidelines for the same reason.&lt;/p&gt;




&lt;h2&gt;
  
  
  What it was actually like
&lt;/h2&gt;

&lt;p&gt;Five months later I can tell you the answer to the wager, but the honest version of this story is the middle, not the ending. Because the first few weeks were not smooth.&lt;/p&gt;

&lt;p&gt;The early problems were all variations on the same theme: an AI will cheerfully solve the problem in front of it with no memory of how it solved the same problem last Tuesday. I'd find duplicate implementations of the same logic in different corners of the codebase. I'd find three different patterns for the same category of problem, each locally reasonable, collectively a mess. I had to learn the right size to chunk features — too big and the AI loses the thread, too small and you spend more time briefing than you save.&lt;/p&gt;

&lt;p&gt;But the biggest lesson was about architecture, and it's the one I'd put on a poster. &lt;strong&gt;Claude's instinct is to reach the outcome in front of it as fast as possible.&lt;/strong&gt; It is genuinely good at that. What it does not do — unless you make it — is weigh that outcome against the long-term shape of the system. Left unsupervised, you get software that works today and calcifies by next month. Which, now that I write it down, also describes plenty of human teams I've worked with.&lt;/p&gt;

&lt;p&gt;So the collaboration settled into a division of labour that will look very familiar to anyone who read my pair programming post:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Mine:&lt;/strong&gt; application architecture, design, and feature management. Every structural decision — the backend abstraction, the sync model, the storage layout — came from me. I was the navigator, holding the long-term picture.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Claude's:&lt;/strong&gt; the code. The overwhelming majority of the actual Rust was written by the AI, driving under direction.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ours:&lt;/strong&gt; the review loop. Every change went through a pull request, reviewed and audited before merge — the same discipline I'd demand on any professional team. No PR process, no experiment; it's the review loop that makes the whole thing defensible.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Part way through I added a technique I now consider essential: &lt;strong&gt;adversarial review&lt;/strong&gt;. Don't let the AI that wrote the code be the only AI that reads it. A second model — or even the same model under a deliberately hostile prompt — catches code smells, duplication and quiet regressions far earlier than a friendly one does. The author is always too fond of its own work. That turns out to be true of machines too.&lt;/p&gt;

&lt;p&gt;Somewhere in those months, the experiment stopped feeling like an experiment. That's the part I didn't expect. Within days it was apparent that working this way wasn't just &lt;em&gt;possible&lt;/em&gt; — it was &lt;em&gt;preferred&lt;/em&gt;. I wasn't tolerating the AI to prove a point. I was reaching for it because the loop of architect–direct–review–refine was producing better software, faster, than I would have produced alone in the hours I actually have.&lt;/p&gt;




&lt;h2&gt;
  
  
  The verdict
&lt;/h2&gt;

&lt;p&gt;Here's what the wager produced:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;~40,000 lines of Rust&lt;/strong&gt;, GTK4 and libadwaita&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;898 commits&lt;/strong&gt; over five months, first commit 22 March 2026&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Every change human-reviewed&lt;/strong&gt; before merge, through a PR workflow&lt;/li&gt;
&lt;li&gt;Releases through &lt;strong&gt;v0.4.1&lt;/strong&gt;, shipped as Flatpak bundles with checksums, and GPG signatures on signed releases&lt;/li&gt;
&lt;li&gt;Built almost entirely in evenings and spare hours: the time budget of a person with a job and a family, not a funded team&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Now overlay the cost lens, because this is where the wager stops being a curiosity and starts being an economic argument. The entire AI side of this project ran on a Claude Code subscription — I started on the Pro plan and very quickly determined it wasn't enough, so most of the project was built on the Max 5x tier, at roughly AU$150 a month. During heavy development I'd regularly burn through 80–90% of my weekly token allocation, but I never went over it. So call it $150 a month, all in, for a coding partner that helped produce a 40,000-line application. That's not cheap as subscriptions go, and I won't pretend it's nothing — but weighed against what it unlocked, I found it an easy cost to justify. For me, the constraint was never the money. It was only ever the time. I'll admit I spent more evenings on this than my family would have liked while it was all coming together. Now that we have a functioning app, it's consuming far less of them.&lt;/p&gt;

&lt;p&gt;And here is the verdict, as plainly as I can put it: &lt;strong&gt;AI can absolutely build a real application — but not alone, and that's precisely the point.&lt;/strong&gt; The single-prompt fantasy is still a fantasy. What is real, right now, is an experienced engineer setting the architecture and standards, an AI doing the driving, and a review process keeping everyone honest. That combination settled the phone argument — and it removed the constraint that has kept me, and I suspect a great many engineers like me, out of open source for years.&lt;/p&gt;

&lt;p&gt;I called my mate to concede that neither of us had been right. He's yet to install a Linux desktop, so I'm calling it a draw.&lt;/p&gt;




&lt;h2&gt;
  
  
  Meet Moments
&lt;/h2&gt;

&lt;p&gt;Enough about the wager — here's the app.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/justinf555/Moments" rel="noopener noreferrer"&gt;Moments&lt;/a&gt;&lt;/strong&gt; is a photo management application for the GNOME desktop. It's built for exactly the situation my family is in: your photos live either on your own disk or on a self-hosted Immich server, and you want a fast, native way to actually browse them.&lt;/p&gt;

&lt;p&gt;It talks to a local folder or to an Immich server. The Immich backend caches metadata and thumbnails locally in SQLite, so browsing, searching and anything you've already opened works fully offline; a photo you've never viewed before will fetch its full-resolution original on demand, then keep it in a local cache for next time. The grid is keyset-paginated with six zoom levels, and it stays smooth through large libraries.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdjs50zxss9a02oy98bbm.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdjs50zxss9a02oy98bbm.jpg" alt="The Moments photo grid, showing a synced Immich library with sidebar navigation and albums" width="800" height="469"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Beyond that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;RAW and modern formats&lt;/strong&gt; — CR2, NEF, ARW, DNG and friends, alongside JPEG, PNG, WebP, HEIC and TIFF&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Video&lt;/strong&gt; — import and playback via GStreamer&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Albums, People and Favourites&lt;/strong&gt; — including face data synced from Immich&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;EXIF metadata&lt;/strong&gt; — camera, lens, exposure and GPS in the detail panel&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Non-destructive rotate, flip and crop&lt;/strong&gt;, with adjustments and filters behind experimental flags&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It's GPL-3.0-or-later, and it's a real daily-driver application — my family's photo library runs on it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Installing it&lt;/strong&gt; takes a download and two commands. Every release ships a single-file Flatpak bundle (with checksums, and GPG signatures on signed releases):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;flatpak &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--user&lt;/span&gt; moments-&amp;lt;version&amp;gt;-x86_64.flatpak
flatpak run io.github.justinf555.Moments
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Grab the latest bundle from the &lt;a href="https://github.com/justinf555/Moments/releases/latest" rel="noopener noreferrer"&gt;releases page&lt;/a&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Flathub question
&lt;/h2&gt;

&lt;p&gt;The obvious question is why those two commands aren't just &lt;code&gt;flatpak install flathub io.github.justinf555.Moments&lt;/code&gt;. That deserves a straight answer, because it's part of this story too.&lt;/p&gt;

&lt;p&gt;I submitted Moments to Flathub on 27 March 2026, five days after the project's first commit — early, deliberately, so the review could run alongside development. The submission worked through the normal review cycle: manifest fixes, portal permissions, test builds passing on both architectures.&lt;/p&gt;

&lt;p&gt;Then, at the end of May, &lt;a href="https://docs.flathub.org/docs/for-app-authors/requirements" rel="noopener noreferrer"&gt;Flathub changed its inclusion policy&lt;/a&gt;. The new rule is a blanket one: &lt;em&gt;"Applications containing AI-generated or AI-assisted code, documentation, or any other content are not allowed."&lt;/em&gt; Not AI-generated slop with no human behind it — which the previous policy already prohibited, rightly — but AI-assisted anything. Two days after the policy landed, my submission was closed. The &lt;a href="https://github.com/flathub/flathub/pull/8227" rel="noopener noreferrer"&gt;full exchange is public&lt;/a&gt;, and I'd encourage you to read it and draw your own conclusions rather than take my summary of it. Read closely and you'll notice I described the AI's share of the codebase as "parts of the app" in that thread — more of it than I was comfortable admitting mid-argument, if I'm honest. This post is the fuller account.&lt;/p&gt;

&lt;p&gt;I withdrew the submission, and I said my piece on the way out, which I'll stand behind here rather than repeat at length: the old policy drew the correct line — AI-generated code with no human behind it, not allowed; AI-assisted code that a human reviewed, tested and stands behind, allowed. The new policy erases that line. It stops judging software by whether it's good, tested and maintained, and starts judging it by how it was typed.&lt;/p&gt;

&lt;p&gt;I understand where the policy comes from. Maintainers are drowning in low-effort AI submissions, and I have real sympathy for that — I wrote &lt;a href="https://dev.to/posts/why-open-source-doesnt-embrace-ai/"&gt;a whole post about the open source community's fraught relationship with AI&lt;/a&gt; before I was ever personally on the receiving end of it. Flathub's repo, Flathub's rules. But I think the blanket version of the rule is a mistake, and I think this app is a concrete counterexample: working GPL software, architected and reviewed by a human, in a category the Linux desktop has needed for decades. The policy does allow exceptions for "mature, well-maintained projects", so perhaps Moments and Flathub will meet again down the track. The door's open on my side.&lt;/p&gt;

&lt;p&gt;In the meantime, the Flatpak bundles work everywhere Flatpak does, and updates are one download away.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I'd ask of you
&lt;/h2&gt;

&lt;p&gt;Three things, in order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Install it.&lt;/strong&gt; Point it at a folder of photos or an Immich server and see what you think. It's &lt;a href="https://github.com/justinf555/Moments/releases/latest" rel="noopener noreferrer"&gt;a download and two commands&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Star the repo.&lt;/strong&gt; &lt;a href="https://github.com/justinf555/Moments" rel="noopener noreferrer"&gt;github.com/justinf555/Moments&lt;/a&gt; — discovery is the thing losing Flathub actually cost this project, and a star is the closest substitute GitHub offers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tell me what's broken or missing.&lt;/strong&gt; &lt;a href="https://github.com/justinf555/Moments/issues" rel="noopener noreferrer"&gt;Issues&lt;/a&gt; for bugs and feature requests, &lt;a href="https://github.com/justinf555/Moments/discussions" rel="noopener noreferrer"&gt;Discussions&lt;/a&gt; for everything else. This is the part of open source no AI can do: real people, with real photo libraries, telling you what actually matters.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;And if you're an experienced engineer who's been sitting on an idea for years because you couldn't find the time — that's the real takeaway of the wager. The time problem is the one that just changed. Go and settle an argument of your own.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>opensource</category>
      <category>rust</category>
      <category>pairprogramming</category>
    </item>
  </channel>
</rss>
