<?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>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>
    <item>
      <title>Building GNOME Apps with Rust, Part 5: State and Signals</title>
      <dc:creator>Justin - FromTheArchitect</dc:creator>
      <pubDate>Mon, 10 Aug 2026 09:39:54 +0000</pubDate>
      <link>https://dev.to/fromthearchitect/building-gnome-apps-with-rust-part-5-state-and-signals-4pb9</link>
      <guid>https://dev.to/fromthearchitect/building-gnome-apps-with-rust-part-5-state-and-signals-4pb9</guid>
      <description>&lt;p&gt;&lt;em&gt;This is Part 5 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-4-blueprint-5dce"&gt;Part 4&lt;/a&gt; replaced our XML templates with Blueprint and grew the window into the real Gazette layout — a sidebar, a content pane, and three typed widget handles waiting for behaviour. This is the post where they get some.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;If the GObject machinery in here feels unfamiliar — &lt;code&gt;mod imp&lt;/code&gt;, properties, signals — &lt;a href="https://dev.to/fromthearchitect/building-gnome-apps-with-rust-part-2-gobject-in-rust-the-type-system-explained-310n"&gt;Part 2&lt;/a&gt; is the reference. This is where those patterns stop being theoretical.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The click that goes nowhere
&lt;/h2&gt;

&lt;p&gt;Right now, clicking a sidebar row does nothing. GTK draws a selection highlight, the row picks up its hover state, and that's it — our code never sees the click. The whole point of the sidebar is that it drives the content pane, and we haven't wired that yet.&lt;/p&gt;

&lt;p&gt;By the end of this post, selecting a row drives everything to its right. The shape I keep coming back to in GTK apps is a chain — model to list to selection to signal to content — and getting those links in the right order is half the work. The other half is interior mutability: where state lives, when &lt;code&gt;RefCell&lt;/code&gt; earns its place, when &lt;code&gt;OnceCell&lt;/code&gt; does, and why the &lt;code&gt;Rc&amp;lt;RefCell&amp;lt;T&amp;gt;&amp;gt;&lt;/code&gt; reflex most of us carry over from non-GUI Rust almost never belongs in a &lt;code&gt;mod imp&lt;/code&gt;-shaped app. I'll take each one where it comes up rather than in the abstract.&lt;/p&gt;

&lt;p&gt;Concretely, we'll build a real &lt;code&gt;Feed&lt;/code&gt; GObject with &lt;code&gt;name&lt;/code&gt; and &lt;code&gt;uri&lt;/code&gt; properties, swap that frozen sidebar row for a &lt;code&gt;gio::ListStore&amp;lt;Feed&amp;gt;&lt;/code&gt; seeded with four real feeds, define a custom &lt;code&gt;feed-selected(Feed)&lt;/code&gt; signal on the window, and make the content pane react — feed name as the title, URL as the description, the empty-state placeholder showing only when nothing's selected. That &lt;code&gt;feed-selected&lt;/code&gt; signal is the seam Part 6 plugs Tokio into: the synchronous handler we write here gets joined by an asynchronous fetch handler that lives entirely in another file, and designing for that boundary now is the quieter lesson of the post.&lt;/p&gt;




&lt;h2&gt;
  
  
  Feed, for real
&lt;/h2&gt;

&lt;p&gt;Part 2 sketched a &lt;code&gt;Feed&lt;/code&gt; GObject — a title, an &lt;code&gt;items-updated&lt;/code&gt; signal, the whole &lt;code&gt;mod imp&lt;/code&gt; apparatus. None of it landed in source. We got away with it because Parts 3 through 4 only needed widgets, not data. That ends here. Before we can have a sidebar that &lt;em&gt;means&lt;/em&gt; something, we need a model for what the sidebar contains.&lt;/p&gt;

&lt;p&gt;Create &lt;code&gt;src/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;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;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;Properties&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="nn"&gt;gtk&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;subclass&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;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;RefCell&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;imp&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;super&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="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="p"&gt;}&lt;/span&gt;

    &lt;span class="nd"&gt;#[glib::object_subclass]&lt;/span&gt;
    &lt;span class="k"&gt;impl&lt;/span&gt; &lt;span class="n"&gt;ObjectSubclass&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;const&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="k"&gt;'static&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;"GazetteFeed"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;Type&lt;/span&gt; &lt;span class="o"&gt;=&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;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;#[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="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;wrapper!&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;struct&lt;/span&gt; &lt;span class="nf"&gt;Feed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ObjectSubclass&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nn"&gt;imp&lt;/span&gt;&lt;span class="p"&gt;::&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="p"&gt;}&lt;/span&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;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="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;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="k"&gt;Self&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;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;"name"&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="nf"&gt;.property&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"uri"&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="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;Three things stand out against that Part 2 sketch. I left the signals out: &lt;code&gt;items-updated&lt;/code&gt; was the right instinct — feeds change when fresh content arrives — but with no fetching code yet, emitting it would be ceremony for nothing, so it comes back in Part 6 alongside the Tokio fetcher that gives it something to announce. The properties use &lt;code&gt;#[derive(Properties)]&lt;/code&gt; paired with &lt;code&gt;#[glib::derived_properties]&lt;/code&gt;, which is the modern declaration form: the macro generates the &lt;code&gt;properties()&lt;/code&gt;, &lt;code&gt;set_property()&lt;/code&gt;, and &lt;code&gt;property()&lt;/code&gt; boilerplate from the struct fields, where the older tutorials you'll still find hand-roll &lt;code&gt;glib::ParamSpecString::builder("name").build()&lt;/code&gt;. Identical runtime behaviour, dramatically less typing, one fewer place to fat-finger a property name. And each field sits in a &lt;code&gt;RefCell&lt;/code&gt; because properties have to be settable through GObject's runtime API — that demands interior mutability, and &lt;code&gt;RefCell&lt;/code&gt; is the simplest answer for fields written occasionally and read everywhere.&lt;/p&gt;

&lt;p&gt;One thing about that &lt;code&gt;NAME&lt;/code&gt; constant: it's the GObject type name registered with the runtime. Prefix it with the application — &lt;code&gt;GazetteFeed&lt;/code&gt;, not just &lt;code&gt;Feed&lt;/code&gt; — so it doesn't collide with another library's &lt;code&gt;Feed&lt;/code&gt; type. Names are global; collisions abort the process at type registration. Once your app loads a &lt;code&gt;Feed&lt;/code&gt; type, no other library in the same process can.&lt;/p&gt;

&lt;p&gt;Wire the module into &lt;code&gt;src/main.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;mod&lt;/span&gt; &lt;span class="n"&gt;application&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;config&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;feed&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;use&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;application&lt;/span&gt;&lt;span class="p"&gt;::&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;use&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;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="k"&gt;use&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;window&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Feed&lt;/code&gt; is now a real type the rest of the codebase can build on.&lt;/p&gt;




&lt;h2&gt;
  
  
  A list bound to a model
&lt;/h2&gt;

&lt;p&gt;The sidebar in &lt;code&gt;window.blp&lt;/code&gt; currently has one hard-coded row:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ListBox feed_list {
  selection-mode: single;
  styles ["navigation-sidebar"]

  ListBoxRow {
    child: Label {
      label: _("All Articles");
      halign: start;
      margin-start: 12;
      margin-end: 12;
      margin-top: 6;
      margin-bottom: 6;
    };
  }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That row is frozen in the markup. It can't grow, shrink, or change without touching the &lt;code&gt;.blp&lt;/code&gt; file. Real apps don't work that way — feeds get added, removed, renamed. The markup needs to describe the &lt;em&gt;shape&lt;/em&gt; of a row, and the runtime needs to produce one row per item in some collection. GTK has a name for this pattern: model/view binding.&lt;/p&gt;

&lt;p&gt;Strip the inner &lt;code&gt;ListBoxRow&lt;/code&gt; out. The new sidebar block:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;content: ScrolledWindow {
  child: ListBox feed_list {
    selection-mode: single;
    styles ["navigation-sidebar"]
  };
};
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Empty &lt;code&gt;ListBox&lt;/code&gt;. The rows come from Rust now.&lt;/p&gt;

&lt;p&gt;In &lt;code&gt;src/window.rs&lt;/code&gt;, add the imports we'll need and grow the &lt;code&gt;imp&lt;/code&gt; struct:&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="nn"&gt;cell&lt;/span&gt;&lt;span class="p"&gt;::{&lt;/span&gt;&lt;span class="n"&gt;OnceCell&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;RefCell&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;adw&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;subclass&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="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;gio&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;use&lt;/span&gt; &lt;span class="k"&gt;crate&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="k"&gt;const&lt;/span&gt; &lt;span class="n"&gt;SAMPLE_FEEDS&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="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="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="o"&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="s"&gt;"This Week in GNOME"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"https://thisweek.gnome.org/index.xml"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"LWN.net Headlines"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="s"&gt;"https://lwn.net/headlines/newrss"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Hacker News"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;        &lt;span class="s"&gt;"https://hnrss.org/frontpage"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"From the Architect"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"https://fromthearchitect.dev/index.xml"&lt;/span&gt;&lt;span class="p"&gt;),&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;imp&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;super&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="nd"&gt;#[derive(Debug,&lt;/span&gt; &lt;span class="nd"&gt;Default,&lt;/span&gt; &lt;span class="nd"&gt;gtk::CompositeTemplate)]&lt;/span&gt;
    &lt;span class="nd"&gt;#[template(resource&lt;/span&gt; &lt;span class="nd"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"/io/github/fromthearchitect/gazette/window.ui"&lt;/span&gt;&lt;span class="nd"&gt;)]&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;GazetteWindow&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nd"&gt;#[template_child]&lt;/span&gt;
        &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;split_view&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;TemplateChild&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&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;NavigationSplitView&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;#[template_child]&lt;/span&gt;
        &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;feed_list&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;TemplateChild&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;ListBox&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;#[template_child]&lt;/span&gt;
        &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;placeholder&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;TemplateChild&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&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;StatusPage&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;pub&lt;/span&gt; &lt;span class="n"&gt;feeds&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;gio&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;ListStore&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;pub&lt;/span&gt; &lt;span class="n"&gt;selected_feed&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;Option&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;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// ... ObjectSubclass impl unchanged&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two new fields. In the window's &lt;code&gt;ObjectImpl::constructed&lt;/code&gt;, populate the store and bind 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;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;GazetteWindow&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;constructed&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="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="nf"&gt;.parent_constructed&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="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="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;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="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.feeds&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;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;.expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"feeds set once"&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;.feed_list&lt;/span&gt;&lt;span class="nf"&gt;.bind_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;store&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="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="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;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="nf"&gt;.expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"item is a Feed"&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;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="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="nf"&gt;.halign&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;Align&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Start&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="nf"&gt;.margin_start&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="nf"&gt;.margin_end&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="nf"&gt;.margin_top&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;span class="nf"&gt;.margin_bottom&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;span class="nf"&gt;.build&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="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;bind_model&lt;/code&gt; takes the store and a closure, and it accepts our store because &lt;code&gt;gio::ListStore&lt;/code&gt; implements &lt;code&gt;gio::ListModel&lt;/code&gt; — it binds against that trait, not the concrete type. That indirection is the part I actually care about: it's what lets me wrap the store in a sort or filter model &lt;em&gt;later&lt;/em&gt; without rewriting this call, which is exactly the refactor Gazette grows into once feeds need ordering. The mechanics from there are unremarkable — &lt;code&gt;gtk::ListBox&lt;/code&gt; runs the closure once per item at bind time, then again whenever the model's &lt;code&gt;items-changed&lt;/code&gt; signal reports an add or change, and because the closure returns a bare &lt;code&gt;gtk::Widget&lt;/code&gt;, &lt;code&gt;ListBox&lt;/code&gt; wraps it in a &lt;code&gt;GtkListBoxRow&lt;/code&gt; so we never construct one ourselves.&lt;/p&gt;

&lt;p&gt;Run the app. The sidebar now shows four feeds — &lt;em&gt;This Week in GNOME&lt;/em&gt;, &lt;em&gt;LWN.net Headlines&lt;/em&gt;, &lt;em&gt;Hacker News&lt;/em&gt;, &lt;em&gt;From the Architect&lt;/em&gt; — and you can click between them. Selection still doesn't &lt;em&gt;do&lt;/em&gt; anything, but the rows are real.&lt;/p&gt;

&lt;p&gt;One trap worth flagging — and a clarification about what that generic actually buys you. &lt;code&gt;gio::ListStore::new::&amp;lt;Feed&amp;gt;()&lt;/code&gt; parameterises the store on the Rust side, and that's worth doing: it keeps the typed API honest and documents what the store holds. But it's a Rust-side convenience, not a hard runtime guarantee — at the GObject level the store holds plain &lt;code&gt;glib::Object&lt;/code&gt;. So items coming back &lt;em&gt;out&lt;/em&gt; of it — through &lt;code&gt;item()&lt;/code&gt;, or through the &lt;code&gt;ListModel&lt;/code&gt; interface a sort or filter model would hand you — still arrive as &lt;code&gt;glib::Object&lt;/code&gt; and need the explicit &lt;code&gt;and_downcast::&amp;lt;Feed&amp;gt;()&lt;/code&gt; you'll see in the selection handler shortly. Parameterise with your concrete type, but treat it as intent and documentation, not as enforcement at the model boundary.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where state lives
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;imp&lt;/code&gt; struct now carries two things that aren't template children: &lt;code&gt;feeds&lt;/code&gt; and &lt;code&gt;selected_feed&lt;/code&gt;. This is the right place for them. &lt;strong&gt;State on a GObject lives in the inner &lt;code&gt;imp&lt;/code&gt; struct, never on the outer wrapper.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The reason is structural. &lt;code&gt;GazetteWindow&lt;/code&gt; — the outer type — is a thin handle. &lt;code&gt;glib::wrapper!&lt;/code&gt; makes it a transparent newtype around a refcounted pointer. Add a field to the outer type and you've added a field that won't be shared between clones of the handle, won't be visible across closures, and won't survive being passed back through GTK's C API. None of those failure modes is loud; the code compiles, the field exists, you set it, and then a second handle to the "same" window can't see what you wrote.&lt;/p&gt;

&lt;p&gt;The inner &lt;code&gt;imp::GazetteWindow&lt;/code&gt;, by contrast, is the actual heap-allocated object. There's exactly one of it per window, all handles point to it, and any code holding a handle can reach into it via &lt;code&gt;obj.imp()&lt;/code&gt;. State you want every part of the app to agree on goes there.&lt;/p&gt;

&lt;p&gt;This is one of the framework's rules rather than something you can derive from first principles, and I learned it the slow way — by adding a field to the outer type, setting it, and then losing an afternoon to state that silently wasn't shared between two handles to the "same" window. Memorise it — &lt;em&gt;state lives in &lt;code&gt;imp&lt;/code&gt;&lt;/em&gt; — and a surprising number of the architectural questions you might have about a GTK Rust app stop being questions.&lt;/p&gt;

&lt;h3&gt;
  
  
  Which cell?
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;feeds: OnceCell&amp;lt;gio::ListStore&amp;gt;&lt;/code&gt; and &lt;code&gt;selected_feed: RefCell&amp;lt;Option&amp;lt;Feed&amp;gt;&amp;gt;&lt;/code&gt; — two different cells for two different lifetimes, and the difference is the whole point. The &lt;code&gt;feeds&lt;/code&gt; store is built once in &lt;code&gt;constructed()&lt;/code&gt; and read forever after, never replaced; that's exactly what &lt;code&gt;OnceCell&lt;/code&gt; is for, and its &lt;code&gt;set&lt;/code&gt; returning &lt;code&gt;Err&lt;/code&gt; when it's already been set means the code asserts that contract for me instead of trusting me to remember it. The &lt;code&gt;selected_feed&lt;/code&gt; changes every time the user picks a different row, so it needs the ongoing mutability &lt;code&gt;RefCell&lt;/code&gt; gives. (A third category needs no cell at all: values the GObject machinery sets once at construction — template children, derived properties — that your own code never touches again.)&lt;/p&gt;

&lt;p&gt;&lt;code&gt;RefCell&lt;/code&gt; adds a runtime borrow check: &lt;code&gt;borrow()&lt;/code&gt; and &lt;code&gt;borrow_mut()&lt;/code&gt; panic if you take incompatible borrows at once. In this domain that's exactly what I want — GTK callbacks run on the main thread one at a time, so the panic only fires when &lt;em&gt;my&lt;/em&gt; code has gone re-entrant in a way I didn't notice, and the first time it happened to me it pointed straight at a bug class I'd otherwise have spent an afternoon bisecting.&lt;/p&gt;

&lt;p&gt;Notice the &lt;code&gt;gio::ListStore&lt;/code&gt; itself isn't wrapped in &lt;code&gt;RefCell&lt;/code&gt;, even though we mutate it constantly with &lt;code&gt;store.append(...)&lt;/code&gt;. &lt;code&gt;ListStore&lt;/code&gt; is a GObject, and GObjects do their own reference counting and stay mutable through their GObject API without needing Rust-side cells. Wrapping it would compile but be redundant — you'd be borrow-checking access to a pointer.&lt;/p&gt;

&lt;p&gt;My rule of thumb, after enough of these: template children and properties are naked fields with the right macro attribute; anything that owns a GObject — a &lt;code&gt;gio::ListStore&lt;/code&gt;, another widget — is a naked field or an &lt;code&gt;OnceCell&lt;/code&gt;, never a &lt;code&gt;RefCell&lt;/code&gt;; a Rust value that genuinely mutates gets a &lt;code&gt;RefCell&lt;/code&gt;, or a &lt;code&gt;Cell&lt;/code&gt; if it's &lt;code&gt;Copy&lt;/code&gt;; and a value set once and read everywhere gets a &lt;code&gt;OnceCell&lt;/code&gt;. Once that split is reflexive, most of the "which wrapper goes here?" questions stop being questions.&lt;/p&gt;




&lt;h2&gt;
  
  
  Wiring selection, and the &lt;code&gt;clone!&lt;/code&gt; macro
&lt;/h2&gt;

&lt;p&gt;The rows render but selecting them does nothing. Wire selection 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;obj&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="nf"&gt;.obj&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;.feed_list&lt;/span&gt;&lt;span class="nf"&gt;.connect_row_selected&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;clone!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nd"&gt;#[weak]&lt;/span&gt; &lt;span class="n"&gt;obj&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;row&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;feed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt;
            &lt;span class="nf"&gt;.and_then&lt;/span&gt;&lt;span class="p"&gt;(|&lt;/span&gt;&lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="nf"&gt;.index&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="nf"&gt;.try_into&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;.and_then&lt;/span&gt;&lt;span class="p"&gt;(|&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;u32&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="n"&gt;obj&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;.feeds&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;.unwrap&lt;/span&gt;&lt;span class="p"&gt;()&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;i&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;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="n"&gt;obj&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;.selected_feed&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;feed&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;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;feed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&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;obj&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;"feed-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="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;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;(We define the &lt;code&gt;feed-selected&lt;/code&gt; signal in the next section. Pretend it exists for a moment.)&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;glib::clone!&lt;/code&gt; macro is doing a lot of work. Without it, the equivalent is:&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;obj_weak&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;obj&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;self&lt;/span&gt;&lt;span class="py"&gt;.feed_list&lt;/span&gt;&lt;span class="nf"&gt;.connect_row_selected&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;row&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;obj&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;obj_weak&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="c1"&gt;// ... rest of the closure ...&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The macro hides the &lt;code&gt;downgrade()&lt;/code&gt; / &lt;code&gt;upgrade()&lt;/code&gt; dance. The &lt;code&gt;#[weak]&lt;/code&gt; attribute means the closure holds a &lt;em&gt;weak&lt;/em&gt; reference to &lt;code&gt;obj&lt;/code&gt;: it doesn't keep the window alive. When the closure runs, the macro tries to upgrade the weak reference; if the window is gone, the closure returns early.&lt;/p&gt;

&lt;p&gt;The alternative is &lt;code&gt;#[strong]&lt;/code&gt;, which captures by clone, incrementing the refcount. You almost never want that for a closure connected to one of the window's own widgets, because it builds a reference cycle:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The window owns the widget.&lt;/li&gt;
&lt;li&gt;The widget owns the signal handler (the closure).&lt;/li&gt;
&lt;li&gt;The closure now strongly owns the window.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The window can't drop because the widget's closure points to it; the widget can't drop because the window owns it; the closure can't drop because the widget holds it. Your app appears to leak windows on close. &lt;code&gt;#[weak]&lt;/code&gt; breaks the cycle — the closure points weakly back at the window, and when the window's last strong reference is released, the chain unwinds cleanly.&lt;/p&gt;

&lt;p&gt;This is the most common GTK Rust memory leak I run into, and it hides well: most apps have one window for their whole lifetime, so nothing looks wrong until you write a multi-window app — or, in my case, a test that spun windows up and tore them down in a loop, where the slowly climbing memory finally gave it away. The fix is always the same: weak the captures into widget-connected closures.&lt;/p&gt;

&lt;p&gt;(One bit of translation if you're reading older code: the &lt;code&gt;clone!(@weak obj =&amp;gt; move |...| {...})&lt;/code&gt; syntax was deprecated in &lt;code&gt;glib&lt;/code&gt; 0.20. The &lt;code&gt;#[weak]&lt;/code&gt; attribute form is the current spelling. They behave identically.)&lt;/p&gt;




&lt;h2&gt;
  
  
  A custom signal on &lt;code&gt;GazetteWindow&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;The selection handler emits &lt;code&gt;feed-selected&lt;/code&gt;, but we haven't defined it. Adding a custom signal is the same pattern as Part 2's &lt;code&gt;Feed::items-updated&lt;/code&gt;, plugged into the window instead of the model.&lt;/p&gt;

&lt;p&gt;In &lt;code&gt;mod imp&lt;/code&gt; in &lt;code&gt;src/window.rs&lt;/code&gt;, give &lt;code&gt;ObjectImpl&lt;/code&gt; a &lt;code&gt;signals()&lt;/code&gt; method:&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="nn"&gt;sync&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;OnceLock&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;glib&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;subclass&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="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;GazetteWindow&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="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;"feed-selected"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="nf"&gt;.param_types&lt;/span&gt;&lt;span class="p"&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;static_type&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;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;constructed&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="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="nf"&gt;.parent_constructed&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="c1"&gt;// ... store setup, bind_model, connect_row_selected ...&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;signals()&lt;/code&gt; is a static method GObject calls once, when the type is first registered, to learn what signals it defines. The &lt;code&gt;OnceLock&lt;/code&gt; cache is conventional — GObject doesn't require it, but it means the &lt;code&gt;vec!&lt;/code&gt; allocation only runs once. (Note this is &lt;code&gt;std::sync::OnceLock&lt;/code&gt;, the thread-safe one: a &lt;code&gt;static&lt;/code&gt; has to be &lt;code&gt;Sync&lt;/code&gt;, so the single-threaded &lt;code&gt;std::cell::OnceCell&lt;/code&gt; we reached for in the imp fields wouldn't compile here — same family, different guarantees.) Our signal is named &lt;code&gt;"feed-selected"&lt;/code&gt; and carries one parameter, a &lt;code&gt;Feed&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That's enough to make the &lt;code&gt;emit_by_name&lt;/code&gt; call in the selection handler legal. But a signal with no listeners does nothing — so let's give it a consumer. The handler decides what to do when a feed is selected: update the placeholder.&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;obj&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="nf"&gt;.obj&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&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="nd"&gt;#[weak]&lt;/span&gt; &lt;span class="n"&gt;obj&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="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;imp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;obj&lt;/span&gt;&lt;span class="nf"&gt;.imp&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
            &lt;span class="n"&gt;imp&lt;/span&gt;&lt;span class="py"&gt;.placeholder&lt;/span&gt;&lt;span class="nf"&gt;.set_icon_name&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="s"&gt;"rss-symbolic"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
            &lt;span class="n"&gt;imp&lt;/span&gt;&lt;span class="py"&gt;.placeholder&lt;/span&gt;&lt;span class="nf"&gt;.set_title&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="nf"&gt;.name&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
            &lt;span class="n"&gt;imp&lt;/span&gt;&lt;span class="py"&gt;.placeholder&lt;/span&gt;&lt;span class="nf"&gt;.set_description&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;feed&lt;/span&gt;&lt;span class="nf"&gt;.uri&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;A few new pieces:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;connect_closure&lt;/code&gt;&lt;/strong&gt; is the lower-level form of signal connection. We need it because the signal is custom — there's no auto-generated &lt;code&gt;connect_feed_selected&lt;/code&gt; method, so we connect by name.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;glib::closure_local!&lt;/code&gt;&lt;/strong&gt; wraps the Rust closure in a &lt;code&gt;glib::Closure&lt;/code&gt; the GObject system can store. It's the bridge between Rust closures and GObject's signal machinery, and it accepts the same &lt;code&gt;#[weak]&lt;/code&gt; / &lt;code&gt;#[strong]&lt;/code&gt; attributes as &lt;code&gt;clone!&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;false&lt;/code&gt;&lt;/strong&gt; is the &lt;code&gt;after&lt;/code&gt; flag — whether this handler runs before or after the &lt;em&gt;other&lt;/em&gt; handlers connected to the signal. It's easy to read it as "after the default handler," but that's a separate mechanism (the handler baked into the type definition); &lt;code&gt;after&lt;/code&gt; only orders you against the other external connections. With a single handler it makes no difference; by convention, pass &lt;code&gt;false&lt;/code&gt; unless you specifically need to run last.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Two handlers now exist on &lt;code&gt;feed-selected&lt;/code&gt;: the implicit one in &lt;code&gt;connect_row_selected&lt;/code&gt; that &lt;em&gt;emits&lt;/em&gt; it, and the explicit one in &lt;code&gt;connect_closure&lt;/code&gt; that &lt;em&gt;consumes&lt;/em&gt; it. Neither side names the other; the signal is all they share.&lt;/p&gt;

&lt;p&gt;Run the app. Click &lt;em&gt;This Week in GNOME&lt;/em&gt;; the content pane updates to show the feed name as the title and the URL as the description. Click &lt;em&gt;Hacker News&lt;/em&gt;; it switches. Selection has meaning now.&lt;/p&gt;




&lt;h2&gt;
  
  
  When &lt;code&gt;Rc&amp;lt;RefCell&amp;lt;T&amp;gt;&amp;gt;&lt;/code&gt; isn't the answer
&lt;/h2&gt;

&lt;p&gt;The instinct, especially coming from non-GUI Rust, is to reach for &lt;code&gt;Rc&amp;lt;RefCell&amp;lt;T&amp;gt;&amp;gt;&lt;/code&gt; whenever two closures need to share mutable state — it's the canonical answer to "shared mutable state without locks," and my first couple of GTK apps were quietly littered with it.&lt;/p&gt;

&lt;p&gt;Suppose both &lt;code&gt;connect_row_selected&lt;/code&gt; and the &lt;code&gt;feed-selected&lt;/code&gt; handler wanted to read the currently-selected &lt;code&gt;Feed&lt;/code&gt;. The wrong instinct produces this:&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;// Don't do this.&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;selected&lt;/span&gt; &lt;span class="o"&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;rc&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="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="nn"&gt;RefCell&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="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;let&lt;/span&gt; &lt;span class="n"&gt;selected_clone&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;selected&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;self&lt;/span&gt;&lt;span class="py"&gt;.feed_list&lt;/span&gt;&lt;span class="nf"&gt;.connect_row_selected&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;row&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;selected_clone&lt;/span&gt;&lt;span class="nf"&gt;.borrow_mut&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="cm"&gt;/* derive feed from row */&lt;/span&gt; &lt;span class="nb"&gt;None&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;selected_clone&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;selected&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;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="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;_current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;selected_clone&lt;/span&gt;&lt;span class="nf"&gt;.borrow&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="c1"&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 compiles, runs, and is reasonable Rust outside the GTK context. Inside it, the state belongs in &lt;code&gt;imp&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;self&lt;/span&gt;&lt;span class="py"&gt;.feed_list&lt;/span&gt;&lt;span class="nf"&gt;.connect_row_selected&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;clone!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nd"&gt;#[weak]&lt;/span&gt; &lt;span class="n"&gt;obj&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;row&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;feed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="cm"&gt;/* derive feed from row */&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="n"&gt;obj&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;.selected_feed&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;feed&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;Both closures already reach the same &lt;code&gt;selected_feed&lt;/code&gt; through &lt;code&gt;obj.imp()&lt;/code&gt;. There's no need for an additional &lt;code&gt;Rc&lt;/code&gt; — GObject's reference counting &lt;em&gt;is&lt;/em&gt; the &lt;code&gt;Rc&lt;/code&gt;. There's no need for a separate &lt;code&gt;RefCell&lt;/code&gt; — the field is already in one on the imp struct.&lt;/p&gt;

&lt;p&gt;The rule of thumb: &lt;strong&gt;any time you find yourself typing &lt;code&gt;Rc&amp;lt;RefCell&amp;lt;T&amp;gt;&amp;gt;&lt;/code&gt; inside an &lt;code&gt;imp&lt;/code&gt; block, stop.&lt;/strong&gt; The state you're trying to share lives on the imp; closures reach it through &lt;code&gt;obj.imp()&lt;/code&gt;; the GObject lifecycle handles the sharing. It can be appropriate at the &lt;em&gt;edges&lt;/em&gt; of a system — prototyping a small example without a wrapping GObject — but inside a real GObject-based architecture, it's a smell.&lt;/p&gt;




&lt;h2&gt;
  
  
  Binding the view to state
&lt;/h2&gt;

&lt;p&gt;The placeholder updates imperatively inside the &lt;code&gt;feed-selected&lt;/code&gt; handler — three &lt;code&gt;set_*&lt;/code&gt; calls per selection. &lt;code&gt;bind_property&lt;/code&gt; promises to replace that with a declarative link, and the tempting move is to drop it straight into the handler:&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;// Tempting, and wrong.&lt;/span&gt;
&lt;span class="n"&gt;feed&lt;/span&gt;&lt;span class="nf"&gt;.bind_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="o"&gt;&amp;amp;*&lt;/span&gt;&lt;span class="n"&gt;imp&lt;/span&gt;&lt;span class="py"&gt;.placeholder&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;.sync_create&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;This compiles and looks right — select a feed, the title updates — and I shipped exactly this shape once and didn't notice for weeks. The catch is that the handler runs &lt;em&gt;once per selection&lt;/em&gt;, so every click builds a fresh &lt;code&gt;glib::Binding&lt;/code&gt; and nothing tears down the previous one. Click between four feeds and the placeholder's &lt;code&gt;title&lt;/code&gt; now has four live bindings pointing at it, each holding a reference to a feed. They stay quiet only because a feed's &lt;code&gt;name&lt;/code&gt; never changes after creation — and the moment it does, which is exactly what Part 6's fetcher will make happen, every stale binding fires and an &lt;em&gt;unselected&lt;/em&gt; feed can overwrite the &lt;em&gt;selected&lt;/em&gt; one's title. A binding built in a per-event handler is a leak with a delay on it.&lt;/p&gt;

&lt;p&gt;The fix isn't to manage the binding's lifetime by hand. It's to notice what the placeholder actually is: a pure function of &lt;em&gt;which feed is selected&lt;/em&gt;. That's state, and we have a rule for state — it lives in &lt;code&gt;imp&lt;/code&gt;. Promote &lt;code&gt;selected_feed&lt;/code&gt; from a plain field to a GObject property, and the placeholder can bind to it &lt;strong&gt;once&lt;/strong&gt;, for the life of the window.&lt;/p&gt;

&lt;p&gt;Add the &lt;code&gt;Properties&lt;/code&gt; derive to the window's imp struct and annotate the field:&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="nd"&gt;gtk::CompositeTemplate,&lt;/span&gt; &lt;span class="nd"&gt;glib::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::GazetteWindow)]&lt;/span&gt;
&lt;span class="nd"&gt;#[template(resource&lt;/span&gt; &lt;span class="nd"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"/io/github/fromthearchitect/gazette/window.ui"&lt;/span&gt;&lt;span class="nd"&gt;)]&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;GazetteWindow&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nd"&gt;#[template_child]&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;split_view&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;TemplateChild&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&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;NavigationSplitView&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;#[template_child]&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;feed_list&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;TemplateChild&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;ListBox&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;#[template_child]&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;placeholder&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;TemplateChild&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&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;StatusPage&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;pub&lt;/span&gt; &lt;span class="n"&gt;feeds&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;gio&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;ListStore&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="nd"&gt;nullable)]&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;selected_feed&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;Option&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;&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;Add &lt;code&gt;#[glib::derived_properties]&lt;/code&gt; above the window's &lt;code&gt;impl ObjectImpl&lt;/code&gt; block — the same pairing &lt;code&gt;Feed&lt;/code&gt; already uses. &lt;code&gt;nullable&lt;/code&gt; is what lets the property hold &lt;code&gt;None&lt;/code&gt;: nothing is selected at startup, and the user can clear the selection later.&lt;/p&gt;

&lt;p&gt;The selection handler now &lt;em&gt;sets the property&lt;/em&gt; instead of replacing the cell directly. Going through the generated setter is what emits &lt;code&gt;notify::selected-feed&lt;/code&gt;, and that notification drives the binding:&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;self&lt;/span&gt;&lt;span class="py"&gt;.feed_list&lt;/span&gt;&lt;span class="nf"&gt;.connect_row_selected&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;clone!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nd"&gt;#[weak]&lt;/span&gt; &lt;span class="n"&gt;obj&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;row&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;feed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt;
            &lt;span class="nf"&gt;.and_then&lt;/span&gt;&lt;span class="p"&gt;(|&lt;/span&gt;&lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="nf"&gt;.index&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="nf"&gt;.try_into&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;.and_then&lt;/span&gt;&lt;span class="p"&gt;(|&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;u32&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="n"&gt;obj&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;.feeds&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;.unwrap&lt;/span&gt;&lt;span class="p"&gt;()&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;i&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;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="n"&gt;obj&lt;/span&gt;&lt;span class="nf"&gt;.set_selected_feed&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;.as_ref&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;feed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&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;obj&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;"feed-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="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;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;Then bind the placeholder to the property once, 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="n"&gt;obj&lt;/span&gt;&lt;span class="nf"&gt;.bind_property&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"selected-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="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.placeholder&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;.transform_to&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;feed&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="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="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;feed&lt;/span&gt;&lt;span class="nf"&gt;.map_or_else&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="p"&gt;||&lt;/span&gt; &lt;span class="s"&gt;"No Feed Selected"&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;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="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="nf"&gt;.sync_create&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;obj&lt;/span&gt;&lt;span class="nf"&gt;.bind_property&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"selected-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="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.placeholder&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"description"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;.transform_to&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;feed&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="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="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;feed&lt;/span&gt;&lt;span class="nf"&gt;.map_or_else&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="p"&gt;||&lt;/span&gt; &lt;span class="s"&gt;"Select a feed from the sidebar to see its articles."&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;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="nf"&gt;.uri&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="nf"&gt;.sync_create&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;&lt;code&gt;transform_to&lt;/code&gt; runs whenever the source changes, mapping the selected &lt;code&gt;Feed&lt;/code&gt; to a string for the target. The &lt;code&gt;None&lt;/code&gt; arm matters as much as the &lt;code&gt;Some&lt;/code&gt; arm: when the selection clears, the binding restores the empty-state text — the placeholder handles deselection for free, with no code in the handler. &lt;code&gt;sync_create()&lt;/code&gt; copies the current value (&lt;code&gt;None&lt;/code&gt; at startup) the moment the binding is built, which is why the placeholder reads "No Feed Selected" before anything is clicked.&lt;/p&gt;

&lt;p&gt;With the placeholder now driven by the property, the &lt;code&gt;feed-selected&lt;/code&gt; &lt;em&gt;placeholder&lt;/em&gt; handler has nothing left to do — delete it. And that deletion sharpens the point about routing through a signal. Reflecting state in the UI — the title just following the selection — is a binding's job: declarative, set up once. &lt;em&gt;Acting&lt;/em&gt; on a change — fetching the feed, logging, anything with a side effect — is a signal's job, and &lt;code&gt;feed-selected&lt;/code&gt; stays exactly for that. The placeholder was never really a signal consumer; it was a view of state, and now it's wired that way.&lt;/p&gt;

&lt;p&gt;So what &lt;em&gt;does&lt;/em&gt; listen to &lt;code&gt;feed-selected&lt;/code&gt; now? For the moment, a logger — proof the seam works:&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="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;Run, click feeds, watch the terminal. Two independent things now happen on every selection: the placeholder updates through the property binding, and the log prints through the signal handler — and neither is wired to the other. Part 6 connects a second handler to the same signal, an async fetcher that reads the feed's URL, and it slots in without the binding or this logger ever knowing it arrived.&lt;/p&gt;




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

&lt;p&gt;A few things that bite, all on the GObject-runtime seam where Rust's compile-time guarantees stop and GObject's runtime conventions begin.&lt;/p&gt;

&lt;h3&gt;
  
  
  Signal names aren't checked
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;emit_by_name::&amp;lt;()&amp;gt;("feed-selected", ...)&lt;/code&gt; compiles fine if you typo the name. It fails at runtime when the type system can't find a signal by that exact string, and panics with a message about a name lookup miss. There's no compile-time check. The remedies: care, integration tests that exercise every signal path, or &lt;code&gt;const&lt;/code&gt;-ing the name in one place and using it in both &lt;code&gt;signals()&lt;/code&gt; and the emit sites.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;connect_row_selected&lt;/code&gt; fires with &lt;code&gt;None&lt;/code&gt; on deselection
&lt;/h3&gt;

&lt;p&gt;When the selection clears — programmatically, or because something else takes focus — &lt;code&gt;row&lt;/code&gt; is &lt;code&gt;None&lt;/code&gt;. Code that always assumes a feed exists goes quietly wrong. Branch on &lt;code&gt;row&lt;/code&gt; early; the &lt;code&gt;Option&lt;/code&gt; is part of the API for a reason.&lt;/p&gt;

&lt;h3&gt;
  
  
  Borrow panics inside signal handlers
&lt;/h3&gt;

&lt;p&gt;If you hold a &lt;code&gt;RefCell&lt;/code&gt; borrow across a signal emission and a handler for that signal tries to borrow the same cell, you panic — signal handlers run synchronously, so the emit doesn't return until they're done. The shape that triggers it: &lt;code&gt;let mut state = self.thing.borrow_mut();&lt;/code&gt; followed immediately by &lt;code&gt;obj.emit_by_name::&amp;lt;()&amp;gt;(...)&lt;/code&gt;, still inside that borrow. The fix is to scope the borrow tightly so it ends before you emit. (The derived property setters are safe here: each scopes its own &lt;code&gt;borrow_mut&lt;/code&gt;, writes, and returns &lt;em&gt;before&lt;/em&gt; GObject emits &lt;code&gt;notify&lt;/code&gt;.)&lt;/p&gt;

&lt;h3&gt;
  
  
  Kebab-case at the GObject level, snake_case in Rust
&lt;/h3&gt;

&lt;p&gt;A struct field &lt;code&gt;selected_feed&lt;/code&gt; becomes the property &lt;code&gt;selected-feed&lt;/code&gt; in &lt;code&gt;bind_property&lt;/code&gt; and friends, with accessors &lt;code&gt;selected_feed()&lt;/code&gt; / &lt;code&gt;set_selected_feed(...)&lt;/code&gt; on the Rust side. The derive macro does the conversion. Mistype the kebab-case form — &lt;code&gt;bind_property("selected_feed", ...)&lt;/code&gt; — and the binding fails to find a property by that name. Not silently: GLib prints a &lt;code&gt;g_warning&lt;/code&gt; to stderr and the binding does nothing.&lt;/p&gt;




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

&lt;p&gt;Selecting a feed does something. The structure model → list → selection → signal → content is in place, and four real RSS feeds populate the sidebar at startup. The state we added — &lt;code&gt;feeds: OnceCell&amp;lt;gio::ListStore&amp;gt;&lt;/code&gt;, &lt;code&gt;selected_feed&lt;/code&gt; as a nullable property — lives in &lt;code&gt;imp&lt;/code&gt;, where the GObject machinery already manages sharing across closures and lifetimes. &lt;code&gt;Rc&amp;lt;RefCell&amp;lt;T&amp;gt;&amp;gt;&lt;/code&gt; doesn't appear anywhere in this codebase, and probably won't.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;feed-selected&lt;/code&gt; signal is a deliberate seam. The placeholder doesn't even use it — selection state flows through a property binding instead, because reflecting state and reacting to events are different jobs. The signal is for reacting: right now a single logging handler listens.&lt;/p&gt;




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

&lt;p&gt;That logging handler is a placeholder for the real one. Part 6 — &lt;strong&gt;Fetching Feeds&lt;/strong&gt; — connects an asynchronous fetcher to &lt;code&gt;feed-selected&lt;/code&gt;: it hits each feed's URL, parses the RSS or Atom response, and emits the &lt;code&gt;items-updated&lt;/code&gt; signal we deferred back at the top of this post as articles arrive.&lt;/p&gt;

&lt;p&gt;That fetcher needs a runtime GTK doesn't have. Part 6 explains why GTK is single-threaded around a single main loop, why blocking that loop freezes the window, and how the two-executor pattern lets Tokio do network work without ever touching a widget on the wrong thread. The seam we built here is what it plugs into.&lt;/p&gt;




&lt;p&gt;The source code at the end of this post lives on the &lt;code&gt;part-5&lt;/code&gt; branch of &lt;a href="https://github.com/fromthearchitect/gnome-rust-gazette/tree/part-5" 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>gobject</category>
    </item>
  </channel>
</rss>
