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 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", ("<Control>q",))
games.load()
sources.load()
collections.load()
@override

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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")

View File

@@ -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"

View File

@@ -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.

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 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")