From 94db6acf372b08852b6bc8b20964ecb63ba93325 Mon Sep 17 00:00:00 2001 From: Jamie Gravendeel Date: Mon, 22 Dec 2025 15:00:33 +0100 Subject: [PATCH] collections: Support adding games --- cartridges/ui/collections.py | 73 +++++++++++++++++++++++++++++++++- cartridges/ui/game-details.blp | 46 +++++++++++++++++++++ cartridges/ui/game-item.blp | 66 +++++++++++++++++++++++------- cartridges/ui/game_details.py | 9 +++++ cartridges/ui/game_item.py | 9 +++++ cartridges/ui/style.css | 10 +++-- cartridges/ui/window.blp | 2 +- cartridges/ui/window.py | 11 +++-- 8 files changed, 202 insertions(+), 24 deletions(-) diff --git a/cartridges/ui/collections.py b/cartridges/ui/collections.py index 0f5d103..af9f778 100644 --- a/cartridges/ui/collections.py +++ b/cartridges/ui/collections.py @@ -1,7 +1,8 @@ # SPDX-License-Identifier: GPL-3.0-or-later # SPDX-FileCopyrightText: Copyright 2025 Jamie Gravendeel -from typing import Any, override +from collections.abc import Iterable +from typing import Any, cast, override from gi.repository import Adw, GObject, Gtk @@ -54,6 +55,76 @@ class CollectionSidebarItem(Adw.SidebarItem): # pyright: ignore[reportAttribute self.bind_property("title", self, "tooltip", GObject.BindingFlags.SYNC_CREATE) +class CollectionButton(Gtk.ToggleButton): + """A toggle button representing a collection.""" + + collection = GObject.Property(type=Collection) + + def __init__(self, collection: Collection, **kwargs: Any): + super().__init__(**kwargs) + + self.collection = collection + self.props.child = Adw.ButtonContent( + icon_name=collection.icon_name, + label=collection.name, + can_shrink=True, + ) + + +class CollectionsBox(Adw.Bin): + """A wrap box for adding games to collections.""" + + __gtype_name__ = __qualname__ + + game = GObject.Property(type=Game) + + def __init__(self, **kwargs: Any): + super().__init__(**kwargs) + + self.props.child = self.box = Adw.WrapBox( + child_spacing=6, + line_spacing=6, + justify=Adw.JustifyMode.FILL, + justify_last_line=True, + natural_line_length=240, + ) + model.bind_property( + "n-items", + self, + "visible", + GObject.BindingFlags.SYNC_CREATE, + ) + + def build(self): + """Populate the box with collections.""" + for collection in cast(Iterable[Collection], model): + button = CollectionButton(collection) + button.props.active = self.game.game_id in collection.game_ids + self.box.append(button) + + def finish(self): + """Clear the box and save changes.""" + filter_changed = False + for button in cast(Iterable[CollectionButton], self.box): + game_ids = button.collection.game_ids + old_game_ids = game_ids.copy() + in_collection = self.game.game_id in game_ids + + if button.props.active and not in_collection: + game_ids.append(self.game.game_id) + elif not button.props.active and in_collection: + game_ids.remove(self.game.game_id) + + if game_ids != old_game_ids: + filter_changed = True + + self.box.remove_all() # pyright: ignore[reportAttributeAccessIssue] + collections.save() + + if filter_changed: + self.activate_action("win.notify-collection-filter") + + sorter = Gtk.StringSorter.new(Gtk.PropertyExpression.new(Collection, None, "name")) model = Gtk.SortListModel.new( Gtk.FilterListModel( diff --git a/cartridges/ui/game-details.blp b/cartridges/ui/game-details.blp index 84cfc6b..18921ca 100644 --- a/cartridges/ui/game-details.blp +++ b/cartridges/ui/game-details.blp @@ -164,6 +164,52 @@ template $GameDetails: Adw.NavigationPage { ] } + MenuButton { + icon-name: "collection-symbolic"; + tooltip-text: _("Collections"); + valign: center; + notify::active => $_setup_collections(); + + popover: PopoverMenu { + menu-model: menu { + item (_("New Collection"), "win.add-collection") + + item { + custom: "collections"; + } + }; + + [collections] + Box { + orientation: vertical; + visible: bind collections_box.visible; + + Separator {} + + Label { + label: _("Collections"); + halign: start; + margin-top: 6; + margin-bottom: 9; + margin-start: 12; + margin-end: 12; + + styles [ + "heading", + ] + } + + $CollectionsBox collections_box { + game: bind template.game; + } + } + }; + + styles [ + "circular", + ] + } + Button hide_button { visible: bind hide_button.sensitive; action-name: "game.hide"; diff --git a/cartridges/ui/game-item.blp b/cartridges/ui/game-item.blp index b3c5eb3..71fdce2 100644 --- a/cartridges/ui/game-item.blp +++ b/cartridges/ui/game-item.blp @@ -29,23 +29,61 @@ template $GameItem: Box { margin-top: 6; margin-end: 6; notify::active => $_reveal_buttons(); + notify::active => $_setup_collections(); - menu-model: menu { - item (_("Edit"), "item.edit") + popover: PopoverMenu { + menu-model: menu { + section { + item (_("Edit"), "item.edit") - item { - label: _("Hide"); - action: "game.hide"; - hidden-when: "action-disabled"; + item { + label: _("Hide"); + action: "game.hide"; + hidden-when: "action-disabled"; + } + + item { + label: _("Unhide"); + action: "game.unhide"; + hidden-when: "action-disabled"; + } + + item (_("Remove"), "game.remove") + } + + section { + item (_("New Collection"), "win.add-collection") + + item { + custom: "collections"; + } + } + }; + + [collections] + Box { + orientation: vertical; + visible: bind collections_box.visible; + + Separator {} + + Label { + label: _("Collections"); + halign: start; + margin-top: 6; + margin-bottom: 9; + margin-start: 12; + margin-end: 12; + + styles [ + "heading", + ] + } + + $CollectionsBox collections_box { + game: bind template.game; + } } - - item { - label: _("Unhide"); - action: "game.unhide"; - hidden-when: "action-disabled"; - } - - item (_("Remove"), "game.remove") }; styles [ diff --git a/cartridges/ui/game_details.py b/cartridges/ui/game_details.py index f1f29c7..8c61137 100644 --- a/cartridges/ui/game_details.py +++ b/cartridges/ui/game_details.py @@ -16,6 +16,7 @@ from cartridges import games from cartridges.config import PREFIX from cartridges.games import Game +from .collections import CollectionsBox from .cover import Cover # noqa: F401 _POP_ON_ACTION = "hide", "unhide", "remove" @@ -35,6 +36,7 @@ class GameDetails(Adw.NavigationPage): stack: Adw.ViewStack = Gtk.Template.Child() actions: Gtk.Box = Gtk.Template.Child() + collections_box: CollectionsBox = Gtk.Template.Child() name_entry: Adw.EntryRow = Gtk.Template.Child() developer_entry: Adw.EntryRow = Gtk.Template.Child() executable_entry: Adw.EntryRow = Gtk.Template.Child() @@ -131,6 +133,13 @@ class GameDetails(Adw.NavigationPage): self.stack.props.visible_child_name = "details" + @Gtk.Template.Callback() + def _setup_collections(self, button: Gtk.MenuButton, *_args): + if button.props.active: + self.collections_box.build() + else: + self.collections_box.finish() + @Gtk.Template.Callback() def _or(self, _obj, first: _T, second: _T) -> _T: return first or second diff --git a/cartridges/ui/game_item.py b/cartridges/ui/game_item.py index 5aa4cfa..d857bc4 100644 --- a/cartridges/ui/game_item.py +++ b/cartridges/ui/game_item.py @@ -8,6 +8,7 @@ from gi.repository import Gio, GLib, GObject, Gtk from cartridges.config import PREFIX from cartridges.games import Game +from .collections import CollectionsBox from .cover import Cover # noqa: F401 @@ -19,6 +20,7 @@ class GameItem(Gtk.Box): motion: Gtk.EventControllerMotion = Gtk.Template.Child() options: Gtk.MenuButton = Gtk.Template.Child() + collections_box: CollectionsBox = Gtk.Template.Child() play: Gtk.Button = Gtk.Template.Child() position = GObject.Property(type=int) @@ -56,3 +58,10 @@ class GameItem(Gtk.Box): ): widget.props.can_focus = widget.props.can_target = reveal (widget.remove_css_class if reveal else widget.add_css_class)("hidden") + + @Gtk.Template.Callback() + def _setup_collections(self, button: Gtk.MenuButton, *_args): + if button.props.active: + self.collections_box.build() + else: + self.collections_box.finish() diff --git a/cartridges/ui/style.css b/cartridges/ui/style.css index e57c28b..8a4b28c 100644 --- a/cartridges/ui/style.css +++ b/cartridges/ui/style.css @@ -24,7 +24,8 @@ padding: 12px; } -#game-item button { +#game-item overlay > button, +#game-item overlay > menubutton > button { color: white; backdrop-filter: blur(9px) brightness(30%) saturate(600%); box-shadow: @@ -37,12 +38,12 @@ opacity, transform; } -#game-item button.hidden { +#game-item overlay > button.hidden { opacity: 0; transform: translateY(3px); } -#game-item menubutton.hidden button { +#game-item overlay > menubutton.hidden > button { opacity: 0; transform: translateY(-6px); } @@ -83,7 +84,8 @@ outline: 5px solid var(--accent-color); } - #game-item button { + #game-item overlay > button, + #game-item overlay > menubutton > button { backdrop-filter: blur(9px) brightness(20%) saturate(600%); } diff --git a/cartridges/ui/window.blp b/cartridges/ui/window.blp index 282552c..3537aba 100644 --- a/cartridges/ui/window.blp +++ b/cartridges/ui/window.blp @@ -237,7 +237,7 @@ template $Window: Adw.ApplicationWindow { watch-items: true; filter: EveryFilter { - $CollectionFilter { + $CollectionFilter collection_filter { collection: bind template.collection; } diff --git a/cartridges/ui/window.py b/cartridges/ui/window.py index bd41159..5fcecfd 100644 --- a/cartridges/ui/window.py +++ b/cartridges/ui/window.py @@ -17,10 +17,7 @@ from cartridges.games import Game from cartridges.ui import collections from .collection_details import CollectionDetails -from .collections import ( - CollectionFilter, # noqa: F401 - CollectionSidebarItem, -) +from .collections import CollectionFilter, CollectionSidebarItem from .game_details import GameDetails from .game_item import GameItem # noqa: F401 from .games import GameSorter @@ -61,6 +58,7 @@ class Window(Adw.ApplicationWindow): toast_overlay: Adw.ToastOverlay = Gtk.Template.Child() grid: Gtk.GridView = Gtk.Template.Child() sorter: GameSorter = Gtk.Template.Child() + collection_filter: CollectionFilter = Gtk.Template.Child() details: GameDetails = Gtk.Template.Child() search_text = GObject.Property(type=str) @@ -130,6 +128,7 @@ class Window(Adw.ApplicationWindow): "u", ), ("add", lambda *_: self._add()), + ("add-collection", lambda *_: self._add_collection()), ( "edit-collection", lambda _action, param, *_: self._edit_collection(param.get_uint32()), @@ -140,6 +139,10 @@ class Window(Adw.ApplicationWindow): lambda _action, param, *_: self._remove_collection(param.get_uint32()), "u", ), + ( + "notify-collection-filter", + lambda *_: self.collection_filter.changed(Gtk.FilterChange.DIFFERENT), + ), ("undo", lambda *_: self._undo()), ))