UI-First Search With List Models

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.StringFilters.

One of the Gtk.StringFilters 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.StringFilters’ 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 ;)

Leave a Reply

Your email address will not be published. Required fields are marked *