collections: Add initial UI

This commit is contained in:
Jamie Gravendeel
2025-12-19 17:25:11 +01:00
parent e645ade8d6
commit d0b6d6457d
9 changed files with 343 additions and 162 deletions

View File

@@ -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)

View File

@@ -1,6 +1,7 @@
python.install_sources(
files(
'__init__.py',
'collections.py',
'cover.py',
'game_details.py',
'game_item.py',

View File

@@ -10,6 +10,11 @@ Adw.ShortcutsDialog shortcuts_dialog {
accelerator: "<Control>f";
}
Adw.ShortcutsItem {
title: _("Toggle Sidebar");
accelerator: "F9";
}
Adw.ShortcutsItem {
title: _("Main Menu");
accelerator: "F10";

View File

@@ -26,12 +26,25 @@ template $Window: Adw.ApplicationWindow {
action: "action(win.undo)";
}
Shortcut {
trigger: "F9";
action: "action(win.show-sidebar)";
}
Shortcut {
trigger: "<Control>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 <Settings>.gtk-decoration-layout) as <bool>;
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 <NoSelection>.n-items,
"grid",
$_if_else(
template.search-text,
"empty-search",
$_if_else(template.show-hidden, "empty-hidden", "empty") as <string>
) as <string>
) as <string>;
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 <NoSelection>.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 <string>
) as <string>
) as <string>
) as <string>;
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 <string>;
};
}
Adw.ViewStackPage {
name: "empty";
child: Adw.StatusPage {
icon-name: bind template.application as <Application>.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>.application-id;
title: _("No Games");
description: _("Use the + button to add games");
};
}
};
};
};
};

View File

@@ -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)

View File

@@ -1,6 +1,7 @@
<?xml version="1.0" encoding="UTF-8" ?>
<gresources>
<gresource prefix="@PREFIX@/icons/scalable/actions">
<file>collection-symbolic.svg</file>
<file>filter-symbolic.svg</file>
</gresource>
</gresources>

View File

@@ -25,5 +25,8 @@
</choices>
<default>"last_played"</default>
</key>
<key name="show-sidebar" type="b">
<default>false</default>
</key>
</schema>
</schemalist>

View File

@@ -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

View File

@@ -22,6 +22,7 @@ reportUnknownLambdaType = "none"
reportUnknownMemberType = "none"
reportUnknownParameterType = "none"
reportUnknownVariableType = "none"
reportUntypedBaseClass = "none"
reportUntypedFunctionDecorator = "none"
reportUnusedImport = "none"
reportUnusedVariable = "none"