diff --git a/cartridges/games.py b/cartridges/games.py index 0c8ed47..3fdd7c4 100644 --- a/cartridges/games.py +++ b/cartridges/games.py @@ -11,7 +11,7 @@ from gi.repository import ( Gio, GLib, GObject, - Json, # pyright: ignore[reportAttributeAccessIssue, reportUnknownVariableType] + Json, # pyright: ignore[reportAttributeAccessIssue] ) DATA_DIR = Path(GLib.get_user_data_dir(), "cartridges") diff --git a/cartridges/ui/window.blp b/cartridges/ui/window.blp index 0c2d997..8b42d86 100644 --- a/cartridges/ui/window.blp +++ b/cartridges/ui/window.blp @@ -7,6 +7,11 @@ template $Window: Adw.ApplicationWindow { default-height: 700; ShortcutController { + Shortcut { + trigger: "f"; + action: "action(win.search)"; + } + Shortcut { trigger: "w"; action: "action(window.close)"; @@ -16,24 +21,114 @@ template $Window: Adw.ApplicationWindow { content: Adw.ToolbarView { [top] Adw.HeaderBar { + title-widget: Adw.Clamp { + maximum-size: 500; + tightening-threshold: 500; + + child: SearchEntry search_entry { + hexpand: true; + placeholder-text: _("Search games"); + search-started => $_search_started(); + search-changed => $_search_changed(); + stop-search => $_stop_search(); + }; + }; + [end] MenuButton { - tooltip-text: _("Main Menu"); icon-name: "open-menu-symbolic"; + tooltip-text: _("Main Menu"); primary: true; menu-model: menu { item (_("About Cartridges"), "app.about") }; } + + [end] + MenuButton { + icon-name: "filter-symbolic"; + tooltip-text: _("Sort & Filter"); + + menu-model: menu { + section { + label: _("Sort"); + + item { + label: _("A-Z"); + action: "win.sort"; + target: "a-z"; + } + + item { + label: _("Z-A"); + action: "win.sort"; + target: "z-a"; + } + + item { + label: _("Newest"); + action: "win.sort"; + target: "newest"; + } + + item { + label: _("Oldest"); + action: "win.sort"; + target: "oldest"; + } + + item { + label: _("Last Played"); + action: "win.sort"; + target: "last_played"; + } + } + }; + } } content: ScrolledWindow { - child: GridView { + child: GridView grid { name: "grid"; model: NoSelection { - model: bind template.games; + model: SortListModel { + sorter: CustomSorter sorter {}; + + model: FilterListModel { + filter: EveryFilter { + AnyFilter { + StringFilter { + expression: expr item as <$Game>.name; + search: bind template.search-text; + } + + StringFilter { + expression: expr item as <$Game>.developer; + search: bind template.search-text; + } + } + + BoolFilter { + expression: expr item as <$Game>.hidden; + invert: true; + } + + BoolFilter { + expression: expr item as <$Game>.removed; + invert: true; + } + + BoolFilter { + expression: expr item as <$Game>.blacklisted; + invert: true; + } + }; + + model: bind template.games; + }; + }; }; factory: BuilderListItemFactory { diff --git a/cartridges/ui/window.py b/cartridges/ui/window.py index 30424a5..29a4763 100644 --- a/cartridges/ui/window.py +++ b/cartridges/ui/window.py @@ -1,12 +1,23 @@ # SPDX-License-Identifier: GPL-3.0-or-later # SPDX-FileCopyrightText: Copyright 2025 Zoey Ahmed +import locale +from collections.abc import Generator from typing import Any -from gi.repository import Adw, Gio, GObject, Gtk +from gi.repository import Adw, Gio, GLib, GObject, Gtk from cartridges import games from cartridges.config import PREFIX, PROFILE +from cartridges.games import Game + +SORT_MODES = { + "a-z": ("name", False), + "z-a": ("name", True), + "newest": ("added", False), + "oldest": ("added", True), + "last_played": ("last-played", True), +} @Gtk.Template.from_resource(f"{PREFIX}/window.ui") @@ -15,6 +26,15 @@ class Window(Adw.ApplicationWindow): __gtype_name__ = __qualname__ + search_entry: Gtk.SearchEntry = Gtk.Template.Child() + grid: Gtk.GridView = Gtk.Template.Child() + sorter: Gtk.CustomSorter = Gtk.Template.Child() + + search_text = GObject.Property(type=str) + + _sort_prop = "name" + _invert_sort = False + @GObject.Property(type=Gio.ListStore) def games(self) -> Gio.ListStore: """Model of the user's games.""" @@ -25,3 +45,50 @@ class Window(Adw.ApplicationWindow): if PROFILE == "development": self.add_css_class("devel") + + # https://gitlab.gnome.org/GNOME/gtk/-/issues/7901 + self.search_entry.set_key_capture_widget(self) + self.sorter.set_sort_func(self._sort_func) + + self.add_action_entries(( + ("search", lambda *_: self.search_entry.grab_focus()), + ("sort", self._sort, "s", "'a-z'"), + )) + + def _sort(self, action: Gio.SimpleAction, parameter: GLib.Variant, *_args) -> None: + action.change_state(parameter) + prop, invert = SORT_MODES[parameter.get_string()] + opposite = (self._sort_prop == prop) and (self._invert_sort != invert) + self._sort_prop, self._invert_sort = prop, invert + self.sorter.changed( + Gtk.SorterChange.INVERTED if opposite else Gtk.SorterChange.DIFFERENT + ) + + def _sort_func(self, game1: Game, game2: Game, _) -> int: + a = (game2 if self._invert_sort else game1).get_property(self._sort_prop) + b = (game1 if self._invert_sort else game2).get_property(self._sort_prop) + return ( + locale.strcoll(*self._sortable(a, b)) + if isinstance(a, str) + else (a > b) - (a < b) + or locale.strcoll(*self._sortable(game1.name, game2.name)) + ) + + @staticmethod + def _sortable(*strings: str) -> Generator[str]: + for string in strings: + yield string.lower().removeprefix("the ") + + @Gtk.Template.Callback() + def _search_started(self, entry: Gtk.SearchEntry) -> None: + entry.grab_focus() + + @Gtk.Template.Callback() + def _search_changed(self, entry: Gtk.SearchEntry) -> None: + self.search_text = entry.props.text + entry.grab_focus() + + @Gtk.Template.Callback() + def _stop_search(self, entry: Gtk.SearchEntry) -> None: + entry.props.text = "" + self.grid.grab_focus() diff --git a/data/icons/filter-symbolic.svg b/data/icons/filter-symbolic.svg new file mode 100644 index 0000000..68937b4 --- /dev/null +++ b/data/icons/filter-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/data/icons/icons.gresource.xml.in b/data/icons/icons.gresource.xml.in index cedeb1c..7805cc5 100644 --- a/data/icons/icons.gresource.xml.in +++ b/data/icons/icons.gresource.xml.in @@ -1,5 +1,6 @@ + filter-symbolic.svg diff --git a/pyproject.toml b/pyproject.toml index 6859fd0..7a0e9e3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,6 +11,7 @@ reportUnknownArgumentType = "none" reportUnknownLambdaType = "none" reportUnknownMemberType = "none" reportUnknownParameterType = "none" +reportUnknownVariableType = "none" reportUntypedFunctionDecorator = "none" reportUnusedImport = "none" reportUnusedVariable = "none" @@ -68,6 +69,4 @@ suppress-dummy-args = true suppress-none-returning = true [tool.ruff.lint.pydocstyle] -property-decorators = [ - "gi.repository.GObject.Property", -] +property-decorators = ["gi.repository.GObject.Property"]