Define UI comprising ListView in Blueprint for modern GTK apps

In a previous post, "Define UI comprising Dropdown in Blueprint for modern GTK apps", I presented how to use DropDown in Blueprint. Now we go with a bit more complex example, with ListView widget.

This is the UI where ListView is used, in my CoBang app:

WiFi list

It shows a list of WiFi network config, for which user will pick to generate QR code. The list is accompanied with a search box, via which user will type part of Wi-Fi name to narrow down the list, to quickly find the needed Wi-Fi network.

This is the Blueprint code, from generator-wifi-page.blp file:

Gio.ListStore wifi_list_store {
  item-type: typeof<$WifiNetworkInfo>;
}

EveryFilter wifi_search_fine_filter {
  StringFilter wifi_search_filter {
    expression: expr item as <$WifiNetworkInfo>.ssid;
    ignore-case: true;
    match-mode: substring;
    search: bind wifi_search_entry.text;
  }
  BoolFilter {
    expression: expr item as <$WifiNetworkInfo>.erroneous;
    invert: true;
  }
}

FilterListModel wifi_filter_model {
  model: wifi_list_store;
  filter: wifi_search_fine_filter;
}

template $GeneratorWiFiPage : Adw.Bin {
  Box {
    SearchEntry wifi_search_entry {
      placeholder-text: _("Search WiFi networks...");
      stop-search => $on_search_stopped();
    }

    /* Make only the list scroll so the Back button stays visible */
    ScrolledWindow {
      vexpand: true;
      hexpand: true;
      hscrollbar-policy: never;
      child: ListView wifi_list_view {
        model: SingleSelection wifi_selection {
          model: wifi_filter_model;
        };
        activate => $on_wifi_list_view_activated();

        factory: BuilderListItemFactory {
          template ListItem {
            child: Box {
              spacing: 12;

              Label {
                label: bind template.item as <$WifiNetworkInfo>.ssid;
                ellipsize: middle;
                hexpand: true;
                halign: start;
              }

              /* Signal strength icon for active connections */
              Image {
                icon-name: bind template.item as <$WifiNetworkInfo>.signal_strength_icon;
                visible: bind template.item as <$WifiNetworkInfo>.is_active;
                valign: center;
                halign: end;
              }
            };
          }
        };

      };
    }
  }
}

As in the previous post, first we also need to define a model. I also use ListStore for this purpose. It holds a list of data item of custom type WifiNetworkInfo, which is defined as:

class WifiNetworkInfo(GObject.GObject):
    __gtype_name__ = 'WifiNetworkInfo'
    # Used for finding object to update password asynchronously.
    uuid = GObject.Property(type=str, default='')
    ssid = GObject.Property(type=str)
    password = GObject.Property(type=str)
    # Ref: https://lazka.github.io/pgi-docs/#NM-1.0/classes/SettingWirelessSecurity.html#NM.SettingWirelessSecurity.props.key_mgmt
    # Possible values: 'none', 'ieee8021x', 'owe', 'wpa-psk', 'sae', 'wpa-eap', 'wpa-eap-suite-b-192'.
    # If seeing unknown value, assume 'wpa-psk'.
    key_mgmt = GObject.Property(type=str, default='none')
    # Whether this network is currently active (connected)
    is_active = GObject.Property(type=bool, default=False)
    # Whether failed to retrieve password, maybe broken storage in NetworkManager.
    erroneous = GObject.Property(type=bool, default=False)
    # Signal strength 0-100 (best effort; 0 if unknown)
    signal_strength = GObject.Property(type=int, default=0)
    # Icon name representing signal strength (e.g. network-wireless-signal-excellent-symbolic)
    signal_strength_icon = GObject.Property(type=str, default='network-wireless-signal-none-symbolic')

    __gsignals__ = {
        'changed': (GObject.SignalFlags.RUN_LAST, None, ()),
    }

    def __init__(self, ssid: str, password: str = '', key_mgmt: str = 'none', is_active: bool = False, signal_strength: int = 0, uuid: str = ''):
        super().__init__()
        self.uuid = uuid
        self.ssid = ssid
        self.password = password
        self.key_mgmt = key_mgmt
        self.is_active = is_active
        self.signal_strength = signal_strength
        # Caller should update signal_strength_icon after setting strength.

When displaying the WifiNetworkInfo in ListView, we will only display those with erroneous == False. It is because, if the config is missing data or has incorrect data, the generated QR code will be useless, we would rather not show it.

Now, jump to the ListView to see how we connect it to the model:

ListView wifi_list_view {
  model: SingleSelection wifi_selection {
    model: wifi_filter_model;
  };
}

Like the previous DropDown post, we also connect them via an intermediate SingleSelection model. But what interesting here is that, the SingleSelection still does not connect directly to the ListStore, it does so via a FilterListModel instead:

FilterListModel wifi_filter_model {
  model: wifi_list_store;
  filter: wifi_search_fine_filter;
}

As I said previously, the ListView won't show all Wi-Fi networks, it only shows valid ones and those matching the search. The narrowed down results are kept in this FilterListModel. This object has a model property to refer to the source of data, and a filter property refer to a "filter" object. Now see how this filter object is constructed:

EveryFilter wifi_search_fine_filter {
  StringFilter wifi_search_filter {
    expression: expr item as <$WifiNetworkInfo>.ssid;
    ignore-case: true;
    match-mode: substring;
  }
  BoolFilter {
    expression: expr item as <$WifiNetworkInfo>.erroneous;
    invert: true;
  }
}

We remember that there are two criteria to determine if a WiFi network config is kept:

  1. It has erroneous == False. For this we use a BoolFilter.

  2. Its SSID matches the search string. But empty search string means that all SSIDs are qualified. For this we use a StringFilter.

Both conditions must be met, so we use EveryFilter to combine them.

When defining the filters, we learn a new syntax, expr, followed by a fixed expression named item, with type cast. This one is Blueprint specific, because the generated Gtk Builder's XML will be like this:

<property name="expression"><lookup name="erroneous" type="WifiNetworkInfo"/></property>

To provide a search string to the StringFilter, we use SearchEntry. We use "property-binding" to connect the two.

For the rest, it looks pretty the same as the DropDown post, so I won't make a duplicate explanation. Hope that you understand and still support 🥰.