diff --git a/cartridges/ui/collections.py b/cartridges/ui/collections.py new file mode 100644 index 0000000..e01eeec --- /dev/null +++ b/cartridges/ui/collections.py @@ -0,0 +1,53 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# SPDX-FileCopyrightText: Copyright 2025 Jamie Gravendeel + +from typing import Any, override + +from gi.repository import Adw, GObject, Gtk + +from cartridges.collections import Collection +from cartridges.games import Game + + +class CollectionFilter(Gtk.Filter): + """Filter games based on a selected collection.""" + + __gtype_name__ = __qualname__ + + @GObject.Property(type=Collection) + def collection(self) -> Collection | None: + """The collection used for filtering.""" + return self._collection + + @collection.setter + def collection(self, collection: Collection | None): + self._collection = collection + self.changed(Gtk.FilterChange.DIFFERENT) + + @override + def do_match(self, game: Game) -> bool: # pyright: ignore[reportIncompatibleMethodOverride] + if not self.collection: + return True + + return game.game_id in self.collection.game_ids + + +class CollectionSidebarItem(Adw.SidebarItem): # pyright: ignore[reportAttributeAccessIssue] + """A sidebar item representing a collection.""" + + @GObject.Property(type=Collection) + def collection(self) -> Collection: + """The collection that `self` represents.""" + return self._collection + + @collection.setter + def collection(self, collection: Collection): + self._collection = collection + flags = GObject.BindingFlags.SYNC_CREATE + collection.bind_property("name", self, "title", flags) + collection.bind_property("icon-name", self, "icon-name", flags) + + def __init__(self, **kwargs: Any): + super().__init__(**kwargs) + + self.bind_property("title", self, "tooltip", GObject.BindingFlags.SYNC_CREATE) diff --git a/cartridges/ui/meson.build b/cartridges/ui/meson.build index cc2be3d..e430786 100644 --- a/cartridges/ui/meson.build +++ b/cartridges/ui/meson.build @@ -1,6 +1,7 @@ python.install_sources( files( '__init__.py', + 'collections.py', 'cover.py', 'game_details.py', 'game_item.py', diff --git a/cartridges/ui/shortcuts-dialog.blp b/cartridges/ui/shortcuts-dialog.blp index ee518b1..51aa1dd 100644 --- a/cartridges/ui/shortcuts-dialog.blp +++ b/cartridges/ui/shortcuts-dialog.blp @@ -10,6 +10,11 @@ Adw.ShortcutsDialog shortcuts_dialog { accelerator: "f"; } + Adw.ShortcutsItem { + title: _("Toggle Sidebar"); + accelerator: "F9"; + } + Adw.ShortcutsItem { title: _("Main Menu"); accelerator: "F10"; diff --git a/cartridges/ui/window.blp b/cartridges/ui/window.blp index 8ef9fe5..2ee440e 100644 --- a/cartridges/ui/window.blp +++ b/cartridges/ui/window.blp @@ -26,12 +26,25 @@ template $Window: Adw.ApplicationWindow { action: "action(win.undo)"; } + Shortcut { + trigger: "F9"; + action: "action(win.show-sidebar)"; + } + Shortcut { trigger: "w"; action: "action(window.close)"; } } + Adw.Breakpoint { + condition ("max-width: 730px") // 3 columns + 48px of inherent padding + + setters { + split_view.collapsed: true; + } + } + content: Adw.NavigationView navigation_view { Adw.NavigationPage { title: bind template.title; @@ -41,198 +54,267 @@ template $Window: Adw.ApplicationWindow { "view", ] - child: Adw.ToolbarView { - [top] - Adw.HeaderBar header_bar { - title-widget: Adw.Clamp clamp { - tightening-threshold: bind clamp.maximum-size; + child: Adw.OverlaySplitView split_view { + sidebar-width-unit: px; + min-sidebar-width: 224; // Width of 1 column + max-sidebar-width: bind split_view.min-sidebar-width; - child: CenterBox title_box { - hexpand: true; + sidebar: Adw.NavigationPage { + title: bind template.title; - center-widget: SearchEntry search_entry { - hexpand: true; - placeholder-text: _("Search games"); - search-started => $_search_started(); - search-changed => $_search_changed(); - activate => $_search_activate(); - stop-search => $_stop_search(); - }; + child: Adw.ToolbarView { + [top] + Adw.HeaderBar { + show-title: bind $_show_sidebar_title(template.settings as .gtk-decoration-layout) as ; - end-widget: MenuButton sort_button { - icon-name: "filter-symbolic"; - tooltip-text: _("Sort & Filter"); - margin-start: 6; + [start] + Button { + visible: bind split_view.show-sidebar; + action-name: "win.show-sidebar"; + icon-name: "sidebar-show-symbolic"; + tooltip-text: _("Toggle Sidebar"); + } + } - menu-model: menu { - section { - label: _("Sort"); + content: Adw.Sidebar sidebar { + activated => $_navigate(); - item { - label: _("Last Played"); - action: "win.sort-mode"; - target: "last_played"; - } + Adw.SidebarSection { + Adw.SidebarItem { + icon-name: "view-grid-symbolic"; + title: _("All Games"); + } + } - item { - label: _("A-Z"); - action: "win.sort-mode"; - target: "a-z"; - } - - item { - label: _("Z-A"); - action: "win.sort-mode"; - target: "z-a"; - } - - item { - label: _("Newest"); - action: "win.sort-mode"; - target: "newest"; - } - - item { - label: _("Oldest"); - action: "win.sort-mode"; - target: "oldest"; - } - } - - section { - item (_("Show Hidden Games"), "win.show-hidden") - } - }; - }; + Adw.SidebarSection collections { + title: _("Collections"); + } }; }; + }; - [start] - Button { - icon-name: "list-add-symbolic"; - tooltip-text: _("Add Game"); - action-name: "win.add"; - } + content: Adw.NavigationPage { + title: _("Games"); - [end] - MenuButton main_menu_button { - icon-name: "open-menu-symbolic"; - tooltip-text: _("Main Menu"); - primary: true; + child: Adw.ToolbarView { + [top] + Adw.HeaderBar header_bar { + title-widget: Adw.Clamp clamp { + tightening-threshold: bind clamp.maximum-size; - menu-model: menu { - item (_("Keyboard Shortcuts"), "app.shortcuts") - item (_("About Cartridges"), "app.about") - }; - } - } + child: CenterBox title_box { + hexpand: true; - content: Adw.ToastOverlay toast_overlay { - child: Adw.ViewStack { - enable-transitions: true; - visible-child-name: bind $_if_else( - grid.model as .n-items, - "grid", - $_if_else( - template.search-text, - "empty-search", - $_if_else(template.show-hidden, "empty-hidden", "empty") as - ) as - ) as ; + center-widget: SearchEntry search_entry { + hexpand: true; + placeholder-text: _("Search games"); + search-started => $_search_started(); + search-changed => $_search_changed(); + activate => $_search_activate(); + stop-search => $_stop_search(); + }; - Adw.ViewStackPage { - name: "grid"; + end-widget: MenuButton sort_button { + icon-name: "filter-symbolic"; + tooltip-text: _("Sort & Filter"); + margin-start: 6; - child: ScrolledWindow { - hscrollbar-policy: never; + menu-model: menu { + section { + label: _("Sort"); - child: GridView grid { + item { + label: _("Last Played"); + action: "win.sort-mode"; + target: "last_played"; + } + + item { + label: _("A-Z"); + action: "win.sort-mode"; + target: "a-z"; + } + + item { + label: _("Z-A"); + action: "win.sort-mode"; + target: "z-a"; + } + + item { + label: _("Newest"); + action: "win.sort-mode"; + target: "newest"; + } + + item { + label: _("Oldest"); + action: "win.sort-mode"; + target: "oldest"; + } + } + + section { + item (_("Show Hidden Games"), "win.show-hidden") + } + }; + }; + }; + }; + + [start] + Button { + visible: bind split_view.show-sidebar inverted; + action-name: "win.show-sidebar"; + icon-name: "sidebar-show-symbolic"; + tooltip-text: _("Toggle Sidebar"); + } + + [start] + Button { + icon-name: "list-add-symbolic"; + tooltip-text: _("Add Game"); + action-name: "win.add"; + } + + [end] + MenuButton main_menu_button { + icon-name: "open-menu-symbolic"; + tooltip-text: _("Main Menu"); + primary: true; + + menu-model: menu { + item (_("Keyboard Shortcuts"), "app.shortcuts") + item (_("About Cartridges"), "app.about") + }; + } + } + + content: Adw.ToastOverlay toast_overlay { + child: Adw.ViewStack { + enable-transitions: true; + visible-child-name: bind $_if_else( + grid.model as .n-items, + "grid", + $_if_else( + template.search-text, + "empty-search", + $_if_else( + template.show-hidden, + "empty-hidden", + $_if_else(template.collection, "empty-collection", "empty") as + ) as + ) as + ) as ; + + Adw.ViewStackPage { name: "grid"; - single-click-activate: true; - activate => $_show_details(); - model: NoSelection { - model: SortListModel { - sorter: $GameSorter sorter {}; + child: ScrolledWindow { + hscrollbar-policy: never; - model: FilterListModel { - watch-items: true; + child: GridView grid { + name: "grid"; + single-click-activate: true; + activate => $_show_details(); - filter: EveryFilter { - AnyFilter { - StringFilter { - expression: expr item as <$Game>.name; - search: bind template.search-text; - } + model: NoSelection { + model: SortListModel { + sorter: $GameSorter sorter {}; - StringFilter { - expression: expr item as <$Game>.developer; - search: bind template.search-text; - } - } + model: FilterListModel { + watch-items: true; - BoolFilter { - expression: expr item as <$Game>.hidden; - invert: bind template.show-hidden inverted; - } + filter: EveryFilter { + $CollectionFilter { + collection: bind template.collection; + } - BoolFilter { - expression: expr item as <$Game>.removed; - invert: true; - } + AnyFilter { + StringFilter { + expression: expr item as <$Game>.name; + search: bind template.search-text; + } - BoolFilter { - expression: expr item as <$Game>.blacklisted; - invert: true; - } + StringFilter { + expression: expr item as <$Game>.developer; + search: bind template.search-text; + } + } + + BoolFilter { + expression: expr item as <$Game>.hidden; + invert: bind template.show-hidden inverted; + } + + BoolFilter { + expression: expr item as <$Game>.removed; + invert: true; + } + + BoolFilter { + expression: expr item as <$Game>.blacklisted; + invert: true; + } + }; + + model: bind template.games; + }; }; + }; - model: bind template.games; + factory: BuilderListItemFactory { + template ListItem { + child: $GameItem { + game: bind template.item; + position: bind template.position; + }; + } }; }; }; + } - factory: BuilderListItemFactory { - template ListItem { - child: $GameItem { - game: bind template.item; - position: bind template.position; - }; - } + Adw.ViewStackPage { + name: "empty-search"; + + child: Adw.StatusPage { + icon-name: "edit-find-symbolic"; + title: _("No Games Found"); + description: _("Try a different search"); }; - }; + } + + Adw.ViewStackPage { + name: "empty-hidden"; + + child: Adw.StatusPage { + icon-name: "view-conceal-symbolic"; + title: _("No Hidden Games"); + description: _("Games you hide will appear here"); + }; + } + + Adw.ViewStackPage { + name: "empty-collection"; + + child: Adw.StatusPage { + icon-name: bind template.collection as <$Collection>.icon-name; + title: bind $_format(_("No Games in {}"), template.collection as <$Collection>.name) as ; + }; + } + + Adw.ViewStackPage { + name: "empty"; + + child: Adw.StatusPage { + icon-name: bind template.application as .application-id; + title: _("No Games"); + description: _("Use the + button to add games"); + }; + } }; - } - - Adw.ViewStackPage { - name: "empty-search"; - - child: Adw.StatusPage { - icon-name: "edit-find-symbolic"; - title: _("No Games Found"); - description: _("Try a different search"); - }; - } - - Adw.ViewStackPage { - name: "empty-hidden"; - - child: Adw.StatusPage { - icon-name: "view-conceal-symbolic"; - title: _("No Hidden Games"); - description: _("Games you hide will appear here"); - }; - } - - Adw.ViewStackPage { - name: "empty"; - - child: Adw.StatusPage { - icon-name: bind template.application as .application-id; - title: _("No Games"); - description: _("Use the + button to add games"); - }; - } + }; }; }; }; diff --git a/cartridges/ui/window.py b/cartridges/ui/window.py index 7f953c4..e2b9aef 100644 --- a/cartridges/ui/window.py +++ b/cartridges/ui/window.py @@ -10,10 +10,15 @@ from typing import Any, TypeVar, cast from gi.repository import Adw, Gio, GLib, GObject, Gtk -from cartridges import games, state_settings +from cartridges import collections, games, state_settings +from cartridges.collections import Collection from cartridges.config import PREFIX, PROFILE from cartridges.games import Game +from .collections import ( + CollectionFilter, # noqa: F401 + CollectionSidebarItem, +) from .game_details import GameDetails from .game_item import GameItem # noqa: F401 from .games import GameSorter @@ -40,6 +45,8 @@ class Window(Adw.ApplicationWindow): __gtype_name__ = __qualname__ + split_view: Adw.OverlaySplitView = Gtk.Template.Child() + collections: Adw.SidebarSection = Gtk.Template.Child() # pyright: ignore[reportAttributeAccessIssue] navigation_view: Adw.NavigationView = Gtk.Template.Child() header_bar: Adw.HeaderBar = Gtk.Template.Child() title_box: Gtk.CenterBox = Gtk.Template.Child() @@ -53,6 +60,9 @@ class Window(Adw.ApplicationWindow): search_text = GObject.Property(type=str) show_hidden = GObject.Property(type=bool, default=False) + collection = GObject.Property(type=Collection) + + settings = GObject.Property(type=Gtk.Settings) @GObject.Property(type=Gio.ListStore) def games(self) -> Gio.ListStore: @@ -65,14 +75,22 @@ class Window(Adw.ApplicationWindow): if PROFILE == "development": self.add_css_class("devel") + self.settings = self.get_settings() + flags = Gio.SettingsBindFlags.DEFAULT state_settings.bind("width", self, "default-width", flags) state_settings.bind("height", self, "default-height", flags) state_settings.bind("is-maximized", self, "maximized", flags) + state_settings.bind("show-sidebar", self.split_view, "show-sidebar", flags) # https://gitlab.gnome.org/GNOME/gtk/-/issues/7901 self.search_entry.set_key_capture_widget(self) + self.collections.bind_model( + collections.model, + lambda collection: CollectionSidebarItem(collection=collection), + ) + self.add_action(state_settings.create_action("show-sidebar")) self.add_action(state_settings.create_action("sort-mode")) self.add_action(Gio.PropertyAction.new("show-hidden", self, "show-hidden")) self.add_action_entries(( @@ -102,6 +120,18 @@ class Window(Adw.ApplicationWindow): self.toast_overlay.add_toast(toast) + @Gtk.Template.Callback() + def _show_sidebar_title(self, _obj, layout: str) -> bool: + right_window_controls = layout.replace("appmenu", "").startswith(":") + return right_window_controls and not sys.platform.startswith("darwin") + + @Gtk.Template.Callback() + def _navigate(self, sidebar: Adw.Sidebar, index: int): # pyright: ignore[reportAttributeAccessIssue] + item = sidebar.get_item(index) + self.collection = ( + item.collection if isinstance(item, CollectionSidebarItem) else None + ) + @Gtk.Template.Callback() def _setup_gamepad_monitor(self, *_args): if sys.platform.startswith("linux"): @@ -112,6 +142,10 @@ class Window(Adw.ApplicationWindow): def _if_else(self, _obj, condition: object, first: _T, second: _T) -> _T: return first if condition else second + @Gtk.Template.Callback() + def _format(self, _obj, string: str, *args: Any) -> str: + return string.format(*args) + @Gtk.Template.Callback() def _show_details(self, grid: Gtk.GridView, position: int): self.details.game = cast(Gio.ListModel, grid.props.model).get_item(position) diff --git a/data/icons/icons.gresource.xml.in b/data/icons/icons.gresource.xml.in index 7805cc5..872fbbc 100644 --- a/data/icons/icons.gresource.xml.in +++ b/data/icons/icons.gresource.xml.in @@ -1,6 +1,7 @@ + collection-symbolic.svg filter-symbolic.svg diff --git a/data/page.kramo.Cartridges.gschema.xml.in b/data/page.kramo.Cartridges.gschema.xml.in index 97bf8cb..042dcea 100644 --- a/data/page.kramo.Cartridges.gschema.xml.in +++ b/data/page.kramo.Cartridges.gschema.xml.in @@ -25,5 +25,8 @@ "last_played" + + false + diff --git a/po/POTFILES.in b/po/POTFILES.in index 3417e4d..b584dda 100644 --- a/po/POTFILES.in +++ b/po/POTFILES.in @@ -4,6 +4,7 @@ cartridges/gamepads.py cartridges/games.py cartridges/sources/__init__.py cartridges/sources/steam.py +cartridges/ui/collections.py cartridges/ui/cover.blp cartridges/ui/cover.py cartridges/ui/game-details.blp diff --git a/pyproject.toml b/pyproject.toml index 6e5be6a..cd028cf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,6 +22,7 @@ reportUnknownLambdaType = "none" reportUnknownMemberType = "none" reportUnknownParameterType = "none" reportUnknownVariableType = "none" +reportUntypedBaseClass = "none" reportUntypedFunctionDecorator = "none" reportUnusedImport = "none" reportUnusedVariable = "none"