collections: Add initial UI
This commit is contained in:
53
cartridges/ui/collections.py
Normal file
53
cartridges/ui/collections.py
Normal 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)
|
||||
@@ -1,6 +1,7 @@
|
||||
python.install_sources(
|
||||
files(
|
||||
'__init__.py',
|
||||
'collections.py',
|
||||
'cover.py',
|
||||
'game_details.py',
|
||||
'game_item.py',
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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");
|
||||
};
|
||||
}
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -25,5 +25,8 @@
|
||||
</choices>
|
||||
<default>"last_played"</default>
|
||||
</key>
|
||||
<key name="show-sidebar" type="b">
|
||||
<default>false</default>
|
||||
</key>
|
||||
</schema>
|
||||
</schemalist>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -22,6 +22,7 @@ reportUnknownLambdaType = "none"
|
||||
reportUnknownMemberType = "none"
|
||||
reportUnknownParameterType = "none"
|
||||
reportUnknownVariableType = "none"
|
||||
reportUntypedBaseClass = "none"
|
||||
reportUntypedFunctionDecorator = "none"
|
||||
reportUnusedImport = "none"
|
||||
reportUnusedVariable = "none"
|
||||
|
||||
Reference in New Issue
Block a user