sources: Split games into per-source models

This commit is contained in:
kramo
2025-12-29 00:22:57 +01:00
parent d956f1f12c
commit 9520c79dde
9 changed files with 125 additions and 95 deletions

View File

@@ -7,7 +7,7 @@ from typing import override
from gi.repository import Adw from gi.repository import Adw
from cartridges import collections, games from cartridges import collections, sources
from .config import APP_ID, PREFIX from .config import APP_ID, PREFIX
from .ui.window import Window from .ui.window import Window
@@ -25,7 +25,7 @@ class Application(Adw.Application):
)) ))
self.set_accels_for_action("app.quit", ("<Control>q",)) self.set_accels_for_action("app.quit", ("<Control>q",))
games.load() sources.load()
collections.load() collections.load()
@override @override

View File

@@ -7,8 +7,7 @@ from typing import TYPE_CHECKING, Any, cast
from gi.repository import Gio, GLib, GObject from gi.repository import Gio, GLib, GObject
from cartridges import SETTINGS, games from cartridges import SETTINGS
from cartridges.games import Game
from cartridges.sources import imported from cartridges.sources import imported
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -67,31 +66,6 @@ class Collection(Gio.SimpleActionGroup):
save() 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(): def load():
"""Load collections from GSettings.""" """Load collections from GSettings."""
model.splice(0, 0, tuple(_get_collections())) 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) model = Gio.ListStore.new(Collection)

View File

@@ -3,11 +3,10 @@
# SPDX-FileCopyrightText: Copyright 2025 kramo # SPDX-FileCopyrightText: Copyright 2025 kramo
# SPDX-FileCopyrightText: Copyright 2025 Jamie Gravendeel # SPDX-FileCopyrightText: Copyright 2025 Jamie Gravendeel
import itertools
import json import json
import os import os
import subprocess import subprocess
from collections.abc import Callable, Iterable from collections.abc import Callable
from gettext import gettext as _ from gettext import gettext as _
from pathlib import Path from pathlib import Path
from shlex import quote from shlex import quote
@@ -48,7 +47,6 @@ GAMES_DIR = DATA_DIR / "games"
COVERS_DIR = DATA_DIR / "covers" COVERS_DIR = DATA_DIR / "covers"
_SPEC_VERSION = 2.0 _SPEC_VERSION = 2.0
_MANUALLY_ADDED_ID = "imported"
class Game(Gio.SimpleActionGroup): class Game(Gio.SimpleActionGroup):
@@ -122,14 +120,6 @@ class Game(Gio.SimpleActionGroup):
return game 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): def play(self):
"""Run the executable command in a shell.""" """Run the executable command in a shell."""
if Path("/.flatpak-info").exists(): if Path("/.flatpak-info").exists():
@@ -179,27 +169,3 @@ class Game(Gio.SimpleActionGroup):
app = cast("Application", Gio.Application.get_default()) app = cast("Application", Gio.Application.get_default())
window = cast("Window", app.props.active_window) window = cast("Window", app.props.active_window)
window.send_toast(title, undo=undo) 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)

View File

@@ -6,12 +6,12 @@ import os
import pkgutil import pkgutil
import sys import sys
import time import time
from collections.abc import Generator from collections.abc import Generator, Iterable
from contextlib import suppress from functools import cache
from pathlib import Path from pathlib import Path
from typing import Final, Protocol, cast from typing import Final, Protocol, cast
from gi.repository import GLib from gi.repository import Gio, GLib, GObject
from cartridges.games import Game from cartridges.games import Game
@@ -41,9 +41,7 @@ OPEN = (
) )
class Source(Protocol): class _SourceModule(Protocol):
"""A source of games to import."""
ID: Final[str] ID: Final[str]
NAME: Final[str] NAME: Final[str]
@@ -53,17 +51,75 @@ class Source(Protocol):
... ...
def get_games() -> Generator[Game]: class Source(GObject.Object, Gio.ListModel): # pyright: ignore[reportIncompatibleMethodOverride]
"""Installed games from all sources.""" """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()) added = int(time.time())
for source in all_sources(): for info in pkgutil.iter_modules(__path__, prefix="."):
with suppress(OSError): module = cast(_SourceModule, importlib.import_module(info.name, __package__))
for game in source.get_games(): yield Source(module, added)
game.added = game.added or added
yield game
def all_sources() -> Generator[Source]: model = Gio.ListStore.new(Source)
"""All sources of games."""
for module in pkgutil.iter_modules(__path__, prefix="."):
yield cast(Source, importlib.import_module(module.name, __package__))

View File

@@ -1,10 +1,12 @@
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
# SPDX-FileCopyrightText: Copyright 2025 kramo # SPDX-FileCopyrightText: Copyright 2025 kramo
import itertools
import json import json
from collections.abc import Generator from collections.abc import Generator
from gettext import gettext as _ from gettext import gettext as _
from json import JSONDecodeError from json import JSONDecodeError
from pathlib import Path
from gi.repository import Gdk, GLib from gi.repository import Gdk, GLib
@@ -15,7 +17,7 @@ ID, NAME = "imported", _("Added")
def get_games() -> Generator[Game]: def get_games() -> Generator[Game]:
"""Manually added games.""" """Manually added games."""
for path in GAMES_DIR.glob("imported_*.json"): for path in get_paths():
try: try:
with path.open(encoding="utf-8") as f: with path.open(encoding="utf-8") as f:
data = json.load(f) data = json.load(f)
@@ -38,3 +40,15 @@ def get_games() -> Generator[Game]:
break break
yield game 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")

View File

@@ -12,9 +12,10 @@ from urllib.parse import quote
from gi.repository import Adw, Gdk, Gio, GLib, GObject, Gtk 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.config import PREFIX
from cartridges.games import Game from cartridges.games import Game
from cartridges.sources import imported
from .collections import CollectionsBox from .collections import CollectionsBox
from .cover import Cover # noqa: F401 from .cover import Cover # noqa: F401
@@ -124,7 +125,7 @@ class GameDetails(Adw.NavigationPage):
if not self.game.added: if not self.game.added:
self.game.added = int(time.time()) 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" self.stack.props.visible_child_name = "details"

View File

@@ -8,7 +8,7 @@ from typing import Any, override
from gi.repository import Gtk from gi.repository import Gtk
from cartridges import STATE_SETTINGS from cartridges import STATE_SETTINGS, sources
from cartridges.games import Game from cartridges.games import Game
_SORT_MODES = { _SORT_MODES = {
@@ -19,6 +19,8 @@ _SORT_MODES = {
"oldest": ("added", False), "oldest": ("added", False),
} }
model = Gtk.FlattenListModel.new(sources.model)
class GameSorter(Gtk.Sorter): class GameSorter(Gtk.Sorter):
"""A sorter for game objects. """A sorter for game objects.

View File

@@ -274,7 +274,7 @@ template $Window: Adw.ApplicationWindow {
} }
}; };
model: bind template.games; model: bind template.model;
}; };
}; };
}; };

View File

@@ -10,11 +10,11 @@ from typing import Any, TypeVar, cast
from gi.repository import Adw, Gio, GLib, GObject, Gtk 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.collections import Collection
from cartridges.config import PREFIX, PROFILE from cartridges.config import PREFIX, PROFILE
from cartridges.games import Game from cartridges.sources import imported
from cartridges.ui import collections from cartridges.ui import collections, games
from .collection_details import CollectionDetails from .collection_details import CollectionDetails
from .collections import CollectionFilter, CollectionSidebarItem from .collections import CollectionFilter, CollectionSidebarItem
@@ -61,6 +61,8 @@ class Window(Adw.ApplicationWindow):
collection_filter: CollectionFilter = Gtk.Template.Child() collection_filter: CollectionFilter = Gtk.Template.Child()
details: GameDetails = Gtk.Template.Child() details: GameDetails = Gtk.Template.Child()
model = GObject.Property(type=Gio.ListModel, default=games.model)
search_text = GObject.Property(type=str) search_text = GObject.Property(type=str)
show_hidden = GObject.Property(type=bool, default=False) show_hidden = GObject.Property(type=bool, default=False)
@@ -70,11 +72,6 @@ class Window(Adw.ApplicationWindow):
_collection_removed_signal: int | None = None _collection_removed_signal: int | None = None
_selected_sidebar_item = 0 _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) @GObject.Property(type=Collection)
def collection(self) -> Collection | None: def collection(self) -> Collection | None:
"""The currently selected collection.""" """The currently selected collection."""
@@ -257,7 +254,7 @@ class Window(Adw.ApplicationWindow):
self.details.edit() self.details.edit()
def _add(self): def _add(self):
self.details.game = Game.for_editing() self.details.game = imported.new()
if self.navigation_view.props.visible_page_tag != "details": if self.navigation_view.props.visible_page_tag != "details":
self.navigation_view.push_by_tag("details") self.navigation_view.push_by_tag("details")