From 9520c79ddea0beb70e62429640af384d07634cd8 Mon Sep 17 00:00:00 2001 From: kramo Date: Mon, 29 Dec 2025 00:22:57 +0100 Subject: [PATCH] sources: Split games into per-source models --- cartridges/application.py | 4 +- cartridges/collections.py | 48 ++++++++---------- cartridges/games.py | 36 +------------- cartridges/sources/__init__.py | 90 +++++++++++++++++++++++++++------- cartridges/sources/imported.py | 16 +++++- cartridges/ui/game_details.py | 5 +- cartridges/ui/games.py | 4 +- cartridges/ui/window.blp | 2 +- cartridges/ui/window.py | 15 +++--- 9 files changed, 125 insertions(+), 95 deletions(-) diff --git a/cartridges/application.py b/cartridges/application.py index a0c8e7f..62973b7 100644 --- a/cartridges/application.py +++ b/cartridges/application.py @@ -7,7 +7,7 @@ from typing import override from gi.repository import Adw -from cartridges import collections, games +from cartridges import collections, sources from .config import APP_ID, PREFIX from .ui.window import Window @@ -25,7 +25,7 @@ class Application(Adw.Application): )) self.set_accels_for_action("app.quit", ("q",)) - games.load() + sources.load() collections.load() @override diff --git a/cartridges/collections.py b/cartridges/collections.py index 57293b9..647d733 100644 --- a/cartridges/collections.py +++ b/cartridges/collections.py @@ -7,8 +7,7 @@ from typing import TYPE_CHECKING, Any, cast from gi.repository import Gio, GLib, GObject -from cartridges import SETTINGS, games -from cartridges.games import Game +from cartridges import SETTINGS from cartridges.sources import imported if TYPE_CHECKING: @@ -67,31 +66,6 @@ class Collection(Gio.SimpleActionGroup): save() -def _get_collections() -> Generator[Collection]: - manually_added_game_ids = { - game.game_id - for game in cast(Iterable[Game], games.model) - if game.source.startswith(imported.ID) - } - for data in SETTINGS.get_value("collections").unpack(): - if data.get("removed"): - continue - - try: - yield Collection( - name=data["name"], - icon=data["icon"], - game_ids={ - ident - for ident in data["game-ids"] - if not ident.startswith(imported.ID) - or ident in manually_added_game_ids - }, - ) - except (KeyError, TypeError): - continue - - def load(): """Load collections from GSettings.""" model.splice(0, 0, tuple(_get_collections())) @@ -120,4 +94,24 @@ def save(): ) +def _get_collections() -> Generator[Collection]: + imported_ids = {p.stem for p in imported.get_paths()} + for data in SETTINGS.get_value("collections").unpack(): + if data.get("removed"): + continue + + try: + yield Collection( + name=data["name"], + icon=data["icon"], + game_ids={ + ident + for ident in data["game-ids"] + if not ident.startswith(imported.ID) or ident in imported_ids + }, + ) + except (KeyError, TypeError): + continue + + model = Gio.ListStore.new(Collection) diff --git a/cartridges/games.py b/cartridges/games.py index 027b548..3ce522a 100644 --- a/cartridges/games.py +++ b/cartridges/games.py @@ -3,11 +3,10 @@ # SPDX-FileCopyrightText: Copyright 2025 kramo # SPDX-FileCopyrightText: Copyright 2025 Jamie Gravendeel -import itertools import json import os import subprocess -from collections.abc import Callable, Iterable +from collections.abc import Callable from gettext import gettext as _ from pathlib import Path from shlex import quote @@ -48,7 +47,6 @@ GAMES_DIR = DATA_DIR / "games" COVERS_DIR = DATA_DIR / "covers" _SPEC_VERSION = 2.0 -_MANUALLY_ADDED_ID = "imported" class Game(Gio.SimpleActionGroup): @@ -122,14 +120,6 @@ class Game(Gio.SimpleActionGroup): return game - @classmethod - def for_editing(cls) -> Self: - """Create a game for the user to manually set its properties.""" - return cls( - game_id=f"{_MANUALLY_ADDED_ID}_{_increment_manually_added_id()}", - source=_MANUALLY_ADDED_ID, - ) - def play(self): """Run the executable command in a shell.""" if Path("/.flatpak-info").exists(): @@ -179,27 +169,3 @@ class Game(Gio.SimpleActionGroup): app = cast("Application", Gio.Application.get_default()) window = cast("Window", app.props.active_window) window.send_toast(title, undo=undo) - - -def load(): - """Populate `games.model` with all games from all sources.""" - from . import sources - - model.splice(0, 0, tuple(sources.get_games())) - - -def _increment_manually_added_id() -> int: - numbers = { - game.game_id.split("_")[1] - for game in cast(Iterable[Game], model) - if game.game_id.startswith(_MANUALLY_ADDED_ID) - } - - for count in itertools.count(): - if count not in numbers: - return count - - raise ValueError - - -model = Gio.ListStore.new(Game) diff --git a/cartridges/sources/__init__.py b/cartridges/sources/__init__.py index 463027e..c28d8e9 100644 --- a/cartridges/sources/__init__.py +++ b/cartridges/sources/__init__.py @@ -6,12 +6,12 @@ import os import pkgutil import sys import time -from collections.abc import Generator -from contextlib import suppress +from collections.abc import Generator, Iterable +from functools import cache from pathlib import Path from typing import Final, Protocol, cast -from gi.repository import GLib +from gi.repository import Gio, GLib, GObject from cartridges.games import Game @@ -41,9 +41,7 @@ OPEN = ( ) -class Source(Protocol): - """A source of games to import.""" - +class _SourceModule(Protocol): ID: Final[str] NAME: Final[str] @@ -53,17 +51,75 @@ class Source(Protocol): ... -def get_games() -> Generator[Game]: - """Installed games from all sources.""" +class Source(GObject.Object, Gio.ListModel): # pyright: ignore[reportIncompatibleMethodOverride] + """A source of games to import.""" + + id = GObject.Property(type=str) + name = GObject.Property(type=str) + icon_name = GObject.Property(type=str) + + _module: _SourceModule + + def __init__(self, module: _SourceModule, added: int): + super().__init__() + + self.id, self.name, self._module = module.ID, module.NAME, module + self.bind_property( + "name", + self, + "icon-name", + GObject.BindingFlags.SYNC_CREATE, + lambda _, name: f"{name}-symbolic", + ) + + try: + self._games = list(self._get_games(added)) + except OSError: + self._games = [] + + def do_get_item(self, position: int) -> Game | None: + """Get the item at `position`.""" + try: + return self._games[position] + except IndexError: + return None + + def do_get_item_type(self) -> type[Game]: + """Get the type of the items in `self`.""" + return Game + + def do_get_n_items(self) -> int: + """Get the number of items in `self`.""" + return len(self._games) + + def append(self, game: Game): + """Append `game` to `self`.""" + pos = len(self._games) + self._games.append(game) + self.items_changed(pos, 0, 1) + + def _get_games(self, added: int) -> Generator[Game]: + for game in self._module.get_games(): + game.added = game.added or added + yield game + + +def load(): + """Populate `sources.model` with all sources.""" + model.splice(0, 0, tuple(_get_sources())) + + +@cache +def get(ident: str) -> Source: + """Get the source with `ident`.""" + return next(s for s in cast(Iterable[Source], model) if s.id == ident) + + +def _get_sources() -> Generator[Source]: added = int(time.time()) - for source in all_sources(): - with suppress(OSError): - for game in source.get_games(): - game.added = game.added or added - yield game + for info in pkgutil.iter_modules(__path__, prefix="."): + module = cast(_SourceModule, importlib.import_module(info.name, __package__)) + yield Source(module, added) -def all_sources() -> Generator[Source]: - """All sources of games.""" - for module in pkgutil.iter_modules(__path__, prefix="."): - yield cast(Source, importlib.import_module(module.name, __package__)) +model = Gio.ListStore.new(Source) diff --git a/cartridges/sources/imported.py b/cartridges/sources/imported.py index b1093a6..6785d26 100644 --- a/cartridges/sources/imported.py +++ b/cartridges/sources/imported.py @@ -1,10 +1,12 @@ # SPDX-License-Identifier: GPL-3.0-or-later # SPDX-FileCopyrightText: Copyright 2025 kramo +import itertools import json from collections.abc import Generator from gettext import gettext as _ from json import JSONDecodeError +from pathlib import Path from gi.repository import Gdk, GLib @@ -15,7 +17,7 @@ ID, NAME = "imported", _("Added") def get_games() -> Generator[Game]: """Manually added games.""" - for path in GAMES_DIR.glob("imported_*.json"): + for path in get_paths(): try: with path.open(encoding="utf-8") as f: data = json.load(f) @@ -38,3 +40,15 @@ def get_games() -> Generator[Game]: break yield game + + +def new() -> Game: + """Create a new game for the user to manually set its properties.""" + numbers = {int(p.stem.rsplit("_", 1)[1]) for p in get_paths()} + number = next(i for i in itertools.count() if i not in numbers) + return Game(game_id=f"{ID}_{number}", source=ID) + + +def get_paths() -> Generator[Path]: + """Get the paths of all imported games on disk.""" + yield from GAMES_DIR.glob("imported_*.json") diff --git a/cartridges/ui/game_details.py b/cartridges/ui/game_details.py index 2d41262..a1197b1 100644 --- a/cartridges/ui/game_details.py +++ b/cartridges/ui/game_details.py @@ -12,9 +12,10 @@ from urllib.parse import quote from gi.repository import Adw, Gdk, Gio, GLib, GObject, Gtk -from cartridges import games +from cartridges import games, sources from cartridges.config import PREFIX from cartridges.games import Game +from cartridges.sources import imported from .collections import CollectionsBox from .cover import Cover # noqa: F401 @@ -124,7 +125,7 @@ class GameDetails(Adw.NavigationPage): if not self.game.added: self.game.added = int(time.time()) - games.model.append(self.game) + sources.get(imported.ID).append(self.game) self.stack.props.visible_child_name = "details" diff --git a/cartridges/ui/games.py b/cartridges/ui/games.py index 5e4fa08..f7e2902 100644 --- a/cartridges/ui/games.py +++ b/cartridges/ui/games.py @@ -8,7 +8,7 @@ from typing import Any, override from gi.repository import Gtk -from cartridges import STATE_SETTINGS +from cartridges import STATE_SETTINGS, sources from cartridges.games import Game _SORT_MODES = { @@ -19,6 +19,8 @@ _SORT_MODES = { "oldest": ("added", False), } +model = Gtk.FlattenListModel.new(sources.model) + class GameSorter(Gtk.Sorter): """A sorter for game objects. diff --git a/cartridges/ui/window.blp b/cartridges/ui/window.blp index e90fd27..fec10e6 100644 --- a/cartridges/ui/window.blp +++ b/cartridges/ui/window.blp @@ -274,7 +274,7 @@ template $Window: Adw.ApplicationWindow { } }; - model: bind template.games; + model: bind template.model; }; }; }; diff --git a/cartridges/ui/window.py b/cartridges/ui/window.py index b7c72da..2c6971c 100644 --- a/cartridges/ui/window.py +++ b/cartridges/ui/window.py @@ -10,11 +10,11 @@ from typing import Any, TypeVar, cast from gi.repository import Adw, Gio, GLib, GObject, Gtk -from cartridges import STATE_SETTINGS, games +from cartridges import STATE_SETTINGS from cartridges.collections import Collection from cartridges.config import PREFIX, PROFILE -from cartridges.games import Game -from cartridges.ui import collections +from cartridges.sources import imported +from cartridges.ui import collections, games from .collection_details import CollectionDetails from .collections import CollectionFilter, CollectionSidebarItem @@ -61,6 +61,8 @@ class Window(Adw.ApplicationWindow): collection_filter: CollectionFilter = Gtk.Template.Child() details: GameDetails = Gtk.Template.Child() + model = GObject.Property(type=Gio.ListModel, default=games.model) + search_text = GObject.Property(type=str) show_hidden = GObject.Property(type=bool, default=False) @@ -70,11 +72,6 @@ class Window(Adw.ApplicationWindow): _collection_removed_signal: int | None = None _selected_sidebar_item = 0 - @GObject.Property(type=Gio.ListStore) - def games(self) -> Gio.ListStore: - """Model of the user's games.""" - return games.model - @GObject.Property(type=Collection) def collection(self) -> Collection | None: """The currently selected collection.""" @@ -257,7 +254,7 @@ class Window(Adw.ApplicationWindow): self.details.edit() def _add(self): - self.details.game = Game.for_editing() + self.details.game = imported.new() if self.navigation_view.props.visible_page_tag != "details": self.navigation_view.push_by_tag("details")