You can find the repository with the code here.
When managing large amounts of data, manual widget creation finds its limits. Not only because managing both data and UI separately is tedious, but also because performance will be a real concern.
Luckily, there’s two solutions for this in GTK:
1. Gtk.ListView
using a factory: more performant since it reuses widgets when the list gets long
2. Gtk.ListBox
‘s bind_model()
: less performant, but can use boxed list styling
This blog post provides an example of a Gtk.ListView
containing my pets, which is sorted, can be searched, and is primarily made in Blueprint.
The app starts with a plain window:
from gi.repository import Adw, Gtk @Gtk.Template.from_resource("/app/example/Pets/window.ui") class Window(Adw.ApplicationWindow): """The main window.""" __gtype_name__ = "Window"
using Gtk 4.0; using Adw 1; template $Window: Adw.ApplicationWindow { title: _("Pets"); default-width: 450; default-height: 450; content: Adw.ToolbarView { [top] Adw.HeaderBar {} } }
Data Object
The Gtk.ListView
needs a data object to work with, which in this example is a pet with a name and species.
This requires a GObject.Object
called Pet
with those properties, and a GObject.GEnum
called Species
:
from gi.repository import Adw, GObject, Gtk class Species(GObject.GEnum): """The species of an animal.""" NONE = 0 CAT = 1 DOG = 2 […] class Pet(GObject.Object): """Data for a pet.""" __gtype_name__ = "Pet" name = GObject.Property(type=str) species = GObject.Property(type=Species, default=Species.NONE)
List View
Now that there’s a data object to work with, the app needs a Gtk.ListView
with a factory and model.
To start with, there’s a Gtk.ListView
wrapped in a Gtk.ScrolledWindow
to make it scrollable, using the .navigation-sidebar
style class for padding:
content: Adw.ToolbarView { […] content: ScrolledWindow { child: ListView { styles [ "navigation-sidebar", ] }; }; };
Factory
The factory builds a Gtk.ListItem
for each object in the model, and utilizes bindings to show the data in the Gtk.ListItem
:
content: ListView { […] factory: BuilderListItemFactory { template ListItem { child: Label { halign: start; label: bind template.item as <$Pet>.name; }; } }; };
Model
Models can be modified through nesting. The data itself can be in any Gio.ListModel
, in this case a Gio.ListStore
works well.
The Gtk.ListView
expects a Gtk.SelectionModel
because that’s how it manages its selection, so the Gio.ListStore
is wrapped in a Gtk.NoSelection
:
using Gtk 4.0; using Adw 1; using Gio 2.0; […] content: ListView { […] model: NoSelection { model: Gio.ListStore { item-type: typeof<$Pet>; $Pet { name: "Herman"; species: cat; } $Pet { name: "Saartje"; species: dog; } $Pet { name: "Sofie"; species: dog; } $Pet { name: "Rex"; species: dog; } $Pet { name: "Lady"; species: dog; } $Pet { name: "Lieke"; species: dog; } $Pet { name: "Grumpy"; species: cat; } }; }; };
Sorting
To easily parse the list, the pets should be sorted by both name and species.
To implement this, the Gio.ListStore
has to be wrapped in a Gtk.SortListModel
which has a Gtk.MultiSorter
with two sorters, a Gtk.NumericSorter
and a Gtk.StringSorter
.
Both of these need an expression: the property that needs to be compared.
The Gtk.NumericSorter
expects an integer, not a Species
, so the app needs a helper method to convert it:
class Window(Adw.ApplicationWindow): […] @Gtk.Template.Callback() def _species_to_int(self, _obj: Any, species: Species) -> int: return int(species)
model: NoSelection { model: SortListModel { sorter: MultiSorter { NumericSorter { expression: expr $_species_to_int(item as <$Pet>.species) as <int>; } StringSorter { expression: expr item as <$Pet>.name; } }; model: Gio.ListStore { […] }; }; };
To learn more about closures, such as the one used in the
Gtk.NumericSorter
, consider reading my previous blog post.
Search
To look up pets even faster, the user should be able to search for them by both their name and species.
Filtering
First, the Gtk.ListView
‘s model needs the logic to filter the list by name or species.
This can be done with a Gtk.FilterListModel
which has a Gtk.AnyFilter
with two Gtk.StringFilter
s.
One of the Gtk.StringFilter
s expects a string, not a Species
, so the app needs another helper method to convert it:
class Window(Adw.ApplicationWindow): […] @Gtk.Template.Callback() def _species_to_string(self, _obj: Any, species: Species) -> str: return species.value_nick
model: NoSelection { model: FilterListModel { filter: AnyFilter { StringFilter { expression: expr item as <$Pet>.name; } StringFilter { expression: expr $_species_to_string(item as <$Pet>.species) as <string>; } }; model: SortListModel { […] }; }; };
Entry
To actually search with the filters, the app needs a Gtk.SearchBar
with a Gtk.SearchEntry
.
The Gtk.SearchEntry
‘s text
property needs to be bound to the Gtk.StringFilter
s’ search
properties to filter the list on demand.
To be able to start searching by typing from anywhere in the window, the Gtk.SearchEntry
‘s key-capture-widget
has to be set to the window, in this case the template
itself:
content: Adw.ToolbarView { […] [top] SearchBar { key-capture-widget: template; child: SearchEntry search_entry { hexpand: true; placeholder-text: _("Search pets"); }; } content: ScrolledWindow { child: ListView { […] model: NoSelection { model: FilterListModel { filter: AnyFilter { StringFilter { search: bind search_entry.text; […] } StringFilter { search: bind search_entry.text; […] } }; model: SortListModel { […] }; }; }; }; }; };
Toggle Button
The Gtk.SearchBar
should also be toggleable with a Gtk.ToggleButton
.
To do so, the Gtk.SearchEntry
‘s search-mode-enabled
property should be bidirectionally bound to the Gtk.ToggleButton
‘s active
property:
content: Adw.ToolbarView { [top] Adw.HeaderBar { [start] ToggleButton search_button { icon-name: "edit-find-symbolic"; tooltip-text: _("Search"); } } [top] SearchBar { search-mode-enabled: bind search_button.active bidirectional; […] } […] };
The search_button
should also be toggleable with a shortcut, which can be added with a Gtk.ShortcutController
:
[start] ToggleButton search_button { […] ShortcutController { scope: managed; Shortcut { trigger: "<Control>f"; action: "activate"; } } }
Empty State
Last but not least, the view should fall back to an Adw.StatusPage
if there are no search results.
This can be done with a closure for the visible-child-name
property in an Adw.ViewStack
or Gtk.Stack
. I generally prefer an Adw.ViewStack
due to its animation curve.
The closure takes the amount of items in the Gtk.NoSelection
as input, and returns the correct Adw.ViewStackPage
name:
class Window(Adw.ApplicationWindow): […] @Gtk.Template.Callback() def _get_visible_child_name(self, _obj: Any, items: int) -> str: return "content" if items else "empty"
content: Adw.ToolbarView { […] content: Adw.ViewStack { visible-child-name: bind $_get_visible_child_name(selection_model.n-items) as <string>; enable-transitions: true; Adw.ViewStackPage { name: "content"; child: ScrolledWindow { child: ListView { […] model: NoSelection selection_model { […] }; }; }; } Adw.ViewStackPage { name: "empty"; child: Adw.StatusPage { icon-name: "edit-find-symbolic"; title: _("No Results Found"); description: _("Try a different search"); }; } }; };
End Result
from typing import Any from gi.repository import Adw, GObject, Gtk class Species(GObject.GEnum): """The species of an animal.""" NONE = 0 CAT = 1 DOG = 2 @Gtk.Template.from_resource("/org/example/Pets/window.ui") class Window(Adw.ApplicationWindow): """The main window.""" __gtype_name__ = "Window" @Gtk.Template.Callback() def _get_visible_child_name(self, _obj: Any, items: int) -> str: return "content" if items else "empty" @Gtk.Template.Callback() def _species_to_string(self, _obj: Any, species: Species) -> str: return species.value_nick @Gtk.Template.Callback() def _species_to_int(self, _obj: Any, species: Species) -> int: return int(species) class Pet(GObject.Object): """Data about a pet.""" __gtype_name__ = "Pet" name = GObject.Property(type=str) species = GObject.Property(type=Species, default=Species.NONE)
using Gtk 4.0; using Adw 1; using Gio 2.0; template $Window: Adw.ApplicationWindow { title: _("Pets"); default-width: 450; default-height: 450; content: Adw.ToolbarView { [top] Adw.HeaderBar { [start] ToggleButton search_button { icon-name: "edit-find-symbolic"; tooltip-text: _("Search"); ShortcutController { scope: managed; Shortcut { trigger: "<Control>f"; action: "activate"; } } } } [top] SearchBar { key-capture-widget: template; search-mode-enabled: bind search_button.active bidirectional; child: SearchEntry search_entry { hexpand: true; placeholder-text: _("Search pets"); }; } content: Adw.ViewStack { visible-child-name: bind $_get_visible_child_name(selection_model.n-items) as <string>; enable-transitions: true; Adw.ViewStackPage { name: "content"; child: ScrolledWindow { child: ListView { styles [ "navigation-sidebar", ] factory: BuilderListItemFactory { template ListItem { child: Label { halign: start; label: bind template.item as <$Pet>.name; }; } }; model: NoSelection selection_model { model: FilterListModel { filter: AnyFilter { StringFilter { expression: expr item as <$Pet>.name; search: bind search_entry.text; } StringFilter { expression: expr $_species_to_string(item as <$Pet>.species) as <string>; search: bind search_entry.text; } }; model: SortListModel { sorter: MultiSorter { NumericSorter { expression: expr $_species_to_int(item as <$Pet>.species) as <int>; } StringSorter { expression: expr item as <$Pet>.name; } }; model: Gio.ListStore { item-type: typeof<$Pet>; $Pet { name: "Herman"; species: cat; } $Pet { name: "Saartje"; species: dog; } $Pet { name: "Sofie"; species: dog; } $Pet { name: "Rex"; species: dog; } $Pet { name: "Lady"; species: dog; } $Pet { name: "Lieke"; species: dog; } $Pet { name: "Grumpy"; species: cat; } }; }; }; }; }; }; } Adw.ViewStackPage { name: "empty"; child: Adw.StatusPage { icon-name: "edit-find-symbolic"; title: _("No Results Found"); description: _("Try a different search"); }; } }; }; }
List models are pretty complicated, but I hope that this example provides a good idea of what’s possible from Blueprint, and is a good stepping stone to learn more.
Thanks for reading!
PS: a shout out to Markus for guessing what I’d write about next ;)