diff --git a/cartridges/collections.py b/cartridges/collections.py index b5411ee..8ad9258 100644 --- a/cartridges/collections.py +++ b/cartridges/collections.py @@ -21,6 +21,11 @@ class Collection(GObject.Object): icon_name = GObject.Property(type=str) + @GObject.Property(type=bool, default=True) + def in_model(self) -> bool: + """Whether `self` has been added to the model.""" + return self in model + def __init__(self, **kwargs: Any): super().__init__(**kwargs) @@ -54,6 +59,9 @@ def load(): model.splice(0, 0, tuple(_get_collections())) save() + for collection in model: + collection.notify("in-model") + def save(): """Save collections to GSettings.""" diff --git a/cartridges/ui/collection-details.blp b/cartridges/ui/collection-details.blp new file mode 100644 index 0000000..c569228 --- /dev/null +++ b/cartridges/ui/collection-details.blp @@ -0,0 +1,58 @@ +using Gtk 4.0; +using Adw 1; + +template $CollectionDetails: Adw.Dialog { + title: _("New Collection"); + content-width: 360; + default-widget: apply_button; + focus-widget: name_entry; + + child: Adw.ToolbarView { + [top] + Adw.HeaderBar { + show-start-title-buttons: false; + show-end-title-buttons: false; + + [start] + Button { + action-name: "window.close"; + icon-name: "cancel-symbolic"; + tooltip-text: _("Cancel"); + } + + [end] + Button apply_button { + action-name: "details.apply"; + icon-name: "apply-symbolic"; + tooltip-text: _("Add"); + + styles [ + "suggested-action", + ] + } + } + + content: Adw.PreferencesPage { + Adw.PreferencesGroup { + Adw.EntryRow name_entry { + title: _("Name"); + activates-default: true; + } + } + + Adw.PreferencesGroup { + FlowBox icons_box { + min-children-per-line: 7; + + styles [ + "navigation-sidebar", + ] + } + + styles [ + "card", + ] + } + }; + }; +} diff --git a/cartridges/ui/collection_details.py b/cartridges/ui/collection_details.py new file mode 100644 index 0000000..ef9c721 --- /dev/null +++ b/cartridges/ui/collection_details.py @@ -0,0 +1,90 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# SPDX-FileCopyrightText: Copyright 2025 Jamie Gravendeel + +from typing import Any, cast + +from gi.repository import Adw, Gio, GObject, Gtk + +from cartridges import collections +from cartridges.collections import Collection +from cartridges.config import PREFIX + +ICONS = ( + "collection", + "star", + "heart", + "music", + "people", + "skull", + "private", + "globe", + "map", + "city", + "car", + "horse", + "sprout", + "step-over", + "gamepad", + "ball", + "puzzle", + "flashlight", + "knife", + "gun", + "fist", +) + + +@Gtk.Template.from_resource(f"{PREFIX}/collection-details.ui") +class CollectionDetails(Adw.Dialog): + """The details of a category.""" + + __gtype_name__ = __qualname__ + + name_entry: Adw.EntryRow = Gtk.Template.Child() + icons_box: Gtk.FlowBox = Gtk.Template.Child() + + collection = GObject.Property(type=Collection) + + def __init__(self, **kwargs: Any): + super().__init__(**kwargs) + + self.insert_action_group("details", group := Gio.SimpleActionGroup()) + + group.add_action(apply := Gio.SimpleAction.new("apply")) + apply.connect("activate", lambda *_: self._apply()) + self.name_entry.bind_property( + "text", + apply, + "enabled", + GObject.BindingFlags.SYNC_CREATE, + transform_to=lambda _, text: bool(text), + ) + + icons = Gtk.StringList.new(tuple(f"{icon}-symbolic" for icon in ICONS)) + self.icons_box.bind_model( + icons, + lambda string: Gtk.FlowBoxChild( + name="collection-icon-child", + child=Gtk.Image.new_from_icon_name(string.props.string), + halign=Gtk.Align.CENTER, + ), + ) + self.icons_box.select_child( + cast( + Gtk.FlowBoxChild, + self.icons_box.get_child_at_index(ICONS.index(self.collection.icon)), + ) + ) + + def _apply(self): + self.collection.name = self.name_entry.props.text + self.collection.icon = ICONS[ + self.icons_box.get_selected_children()[0].get_index() + ] + + if not self.collection.in_model: + collections.model.append(self.collection) + self.collection.notify("in-model") + + collections.save() + self.close() diff --git a/cartridges/ui/collections.py b/cartridges/ui/collections.py index e01eeec..0f5d103 100644 --- a/cartridges/ui/collections.py +++ b/cartridges/ui/collections.py @@ -5,6 +5,7 @@ from typing import Any, override from gi.repository import Adw, GObject, Gtk +from cartridges import collections from cartridges.collections import Collection from cartridges.games import Game @@ -51,3 +52,17 @@ class CollectionSidebarItem(Adw.SidebarItem): # pyright: ignore[reportAttribute super().__init__(**kwargs) self.bind_property("title", self, "tooltip", GObject.BindingFlags.SYNC_CREATE) + + +sorter = Gtk.StringSorter.new(Gtk.PropertyExpression.new(Collection, None, "name")) +model = Gtk.SortListModel.new( + Gtk.FilterListModel( + model=collections.model, + filter=Gtk.BoolFilter( + expression=Gtk.PropertyExpression.new(Collection, None, "removed"), + invert=True, + ), + watch_items=True, # pyright: ignore[reportCallIssue] + ), + sorter, +) diff --git a/cartridges/ui/meson.build b/cartridges/ui/meson.build index e430786..e92caf3 100644 --- a/cartridges/ui/meson.build +++ b/cartridges/ui/meson.build @@ -1,6 +1,7 @@ python.install_sources( files( '__init__.py', + 'collection_details.py', 'collections.py', 'cover.py', 'game_details.py', @@ -13,6 +14,7 @@ python.install_sources( blueprints = custom_target( input: files( + 'collection-details.blp', 'cover.blp', 'game-details.blp', 'game-item.blp', diff --git a/cartridges/ui/style.css b/cartridges/ui/style.css index 27cc367..e57c28b 100644 --- a/cartridges/ui/style.css +++ b/cartridges/ui/style.css @@ -63,6 +63,10 @@ filter: saturate(300%) opacity(50%); } +#collection-icon-child { + border-radius: 9999px; +} + @media (prefers-color-scheme: dark) { #details list { background: rgb(from white r g b / 10%); diff --git a/cartridges/ui/ui.gresource.xml.in b/cartridges/ui/ui.gresource.xml.in index 0bf75ae..fffc6a6 100644 --- a/cartridges/ui/ui.gresource.xml.in +++ b/cartridges/ui/ui.gresource.xml.in @@ -1,6 +1,7 @@ + collection-details.ui cover.ui game-details.ui game-item.ui diff --git a/cartridges/ui/window.blp b/cartridges/ui/window.blp index 2ee440e..3b60d3f 100644 --- a/cartridges/ui/window.blp +++ b/cartridges/ui/window.blp @@ -78,6 +78,8 @@ template $Window: Adw.ApplicationWindow { content: Adw.Sidebar sidebar { activated => $_navigate(); + setup-menu => $_setup_sidebar_menu(); + notify::selected => $_update_selection(); Adw.SidebarSection { Adw.SidebarItem { @@ -89,6 +91,13 @@ template $Window: Adw.ApplicationWindow { Adw.SidebarSection collections { title: _("Collections"); } + + Adw.SidebarSection { + Adw.SidebarItem new_collection_item { + icon-name: "list-add-symbolic"; + title: _("New Collection"); + } + } }; }; }; diff --git a/cartridges/ui/window.py b/cartridges/ui/window.py index f871477..a37abae 100644 --- a/cartridges/ui/window.py +++ b/cartridges/ui/window.py @@ -10,11 +10,13 @@ from typing import Any, TypeVar, cast from gi.repository import Adw, Gio, GLib, GObject, Gtk -from cartridges import STATE_SETTINGS, collections, games +from cartridges import STATE_SETTINGS, games from cartridges.collections import Collection from cartridges.config import PREFIX, PROFILE from cartridges.games import Game +from cartridges.ui import collections +from .collection_details import CollectionDetails from .collections import ( CollectionFilter, # noqa: F401 CollectionSidebarItem, @@ -47,6 +49,7 @@ class Window(Adw.ApplicationWindow): split_view: Adw.OverlaySplitView = Gtk.Template.Child() collections: Adw.SidebarSection = Gtk.Template.Child() # pyright: ignore[reportAttributeAccessIssue] + new_collection_item: Adw.SidebarItem = 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() @@ -64,6 +67,8 @@ class Window(Adw.ApplicationWindow): settings = GObject.Property(type=Gtk.Settings) + _selected_sidebar_item = 0 + @GObject.Property(type=Gio.ListStore) def games(self) -> Gio.ListStore: """Model of the user's games.""" @@ -128,9 +133,24 @@ class Window(Adw.ApplicationWindow): @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 - ) + + match item: + case self.new_collection_item: + self._add_collection() + sidebar.props.selected = self._selected_sidebar_item + case CollectionSidebarItem(): + self.collection = item.collection + case _: + self.collection = None + + if item is not self.new_collection_item: + self._selected_sidebar_item = index + + @Gtk.Template.Callback() + def _update_selection(self, sidebar: Adw.Sidebar, *_args): # pyright: ignore[reportAttributeAccessIssue] + if sidebar.props.selected_item is self.new_collection_item: + sidebar.props.selected = self._selected_sidebar_item + self._selected_sidebar_item = sidebar.props.selected @Gtk.Template.Callback() def _setup_gamepad_monitor(self, *_args): @@ -186,6 +206,10 @@ class Window(Adw.ApplicationWindow): self.details.edit() + def _add_collection(self): + details = CollectionDetails(collection=Collection()) + details.present(self) + def _undo(self, toast: Adw.Toast | None = None): if toast: self._history.pop(toast)() diff --git a/data/icons/apply-symbolic.svg b/data/icons/apply-symbolic.svg new file mode 100644 index 0000000..aeee44d --- /dev/null +++ b/data/icons/apply-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/data/icons/ball-symbolic.svg b/data/icons/ball-symbolic.svg new file mode 100644 index 0000000..61246eb --- /dev/null +++ b/data/icons/ball-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/data/icons/cancel-symbolic.svg b/data/icons/cancel-symbolic.svg new file mode 100644 index 0000000..7b97b6f --- /dev/null +++ b/data/icons/cancel-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/data/icons/car-symbolic.svg b/data/icons/car-symbolic.svg new file mode 100644 index 0000000..3e1a331 --- /dev/null +++ b/data/icons/car-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/data/icons/city-symbolic.svg b/data/icons/city-symbolic.svg new file mode 100644 index 0000000..c241bef --- /dev/null +++ b/data/icons/city-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/data/icons/fist-symbolic.svg b/data/icons/fist-symbolic.svg new file mode 100644 index 0000000..f083931 --- /dev/null +++ b/data/icons/fist-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/data/icons/flashlight-symbolic.svg b/data/icons/flashlight-symbolic.svg new file mode 100644 index 0000000..090c0d8 --- /dev/null +++ b/data/icons/flashlight-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/data/icons/gamepad-symbolic.svg b/data/icons/gamepad-symbolic.svg new file mode 100644 index 0000000..211231e --- /dev/null +++ b/data/icons/gamepad-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/data/icons/globe-symbolic.svg b/data/icons/globe-symbolic.svg new file mode 100644 index 0000000..5021902 --- /dev/null +++ b/data/icons/globe-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/data/icons/gun-symbolic.svg b/data/icons/gun-symbolic.svg new file mode 100644 index 0000000..459dbe3 --- /dev/null +++ b/data/icons/gun-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/data/icons/heart-symbolic.svg b/data/icons/heart-symbolic.svg new file mode 100644 index 0000000..4c3911a --- /dev/null +++ b/data/icons/heart-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/data/icons/horse-symbolic.svg b/data/icons/horse-symbolic.svg new file mode 100644 index 0000000..c688662 --- /dev/null +++ b/data/icons/horse-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 872fbbc..ac3aa52 100644 --- a/data/icons/icons.gresource.xml.in +++ b/data/icons/icons.gresource.xml.in @@ -1,7 +1,30 @@ - collection-symbolic.svg + apply-symbolic.svg + cancel-symbolic.svg filter-symbolic.svg + + collection-symbolic.svg + ball-symbolic.svg + car-symbolic.svg + city-symbolic.svg + fist-symbolic.svg + flashlight-symbolic.svg + gamepad-symbolic.svg + globe-symbolic.svg + gun-symbolic.svg + heart-symbolic.svg + horse-symbolic.svg + knife-symbolic.svg + map-symbolic.svg + music-symbolic.svg + people-symbolic.svg + private-symbolic.svg + puzzle-symbolic.svg + skull-symbolic.svg + sprout-symbolic.svg + star-symbolic.svg + step-over-symbolic.svg diff --git a/data/icons/knife-symbolic.svg b/data/icons/knife-symbolic.svg new file mode 100644 index 0000000..dbfcd4e --- /dev/null +++ b/data/icons/knife-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/data/icons/map-symbolic.svg b/data/icons/map-symbolic.svg new file mode 100644 index 0000000..8104245 --- /dev/null +++ b/data/icons/map-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/data/icons/music-symbolic.svg b/data/icons/music-symbolic.svg new file mode 100644 index 0000000..90f658c --- /dev/null +++ b/data/icons/music-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/data/icons/people-symbolic.svg b/data/icons/people-symbolic.svg new file mode 100644 index 0000000..247c1ed --- /dev/null +++ b/data/icons/people-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/data/icons/private-symbolic.svg b/data/icons/private-symbolic.svg new file mode 100644 index 0000000..00c39a4 --- /dev/null +++ b/data/icons/private-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/data/icons/puzzle-symbolic.svg b/data/icons/puzzle-symbolic.svg new file mode 100644 index 0000000..8fb8132 --- /dev/null +++ b/data/icons/puzzle-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/data/icons/skull-symbolic.svg b/data/icons/skull-symbolic.svg new file mode 100644 index 0000000..f170cd3 --- /dev/null +++ b/data/icons/skull-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/data/icons/sprout-symbolic.svg b/data/icons/sprout-symbolic.svg new file mode 100644 index 0000000..6e0053c --- /dev/null +++ b/data/icons/sprout-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/data/icons/star-symbolic.svg b/data/icons/star-symbolic.svg new file mode 100644 index 0000000..2474840 --- /dev/null +++ b/data/icons/star-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/data/icons/step-over-symbolic.svg b/data/icons/step-over-symbolic.svg new file mode 100644 index 0000000..3d77715 --- /dev/null +++ b/data/icons/step-over-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/po/POTFILES.in b/po/POTFILES.in index 6cca4ad..c877ff6 100644 --- a/po/POTFILES.in +++ b/po/POTFILES.in @@ -6,6 +6,8 @@ cartridges/sources/__init__.py cartridges/sources/heroic.py cartridges/sources/imported.py cartridges/sources/steam.py +cartridges/ui/collection-details.blp +cartridges/ui/collection_details.py cartridges/ui/collections.py cartridges/ui/cover.blp cartridges/ui/cover.py