sources: Split games into per-source models
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -274,7 +274,7 @@ template $Window: Adw.ApplicationWindow {
|
||||
}
|
||||
};
|
||||
|
||||
model: bind template.games;
|
||||
model: bind template.model;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user