Compare commits

..

1 Commits

Author SHA1 Message Date
Jamie Gravendeel
6e3272cbf9 games: Save edits to games to disk 2025-12-11 14:17:18 +01:00
63 changed files with 414 additions and 2138 deletions

View File

@@ -1,53 +0,0 @@
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0
hooks:
- id: check-added-large-files
- id: trailing-whitespace
- id: end-of-file-fixer
exclude_types: [svg]
- id: file-contents-sorter
files: po/LINGUAS|po/POTFILES\.in
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.14.10
hooks:
- id: ruff-check
args: [--select, I, --fix] # Only fix unsorted imports automatically
- id: ruff-check
- id: ruff-format
- repo: local
hooks:
- id: pyright
name: pyright
language: node
additional_dependencies: [pyright]
types_or: [python, pyi]
entry: pyright
- id: blueprint-format # https://gitlab.gnome.org/GNOME/blueprint-compiler/-/merge_requests/258
name: blueprint
language: system
files: \.blp$
entry: blueprint-compiler format --fix --no-diff
- id: meson-format
name: meson
language: python
files: meson\.build|meson\.options
entry: meson format --inplace
- id: prettier
name: prettier
language: node
additional_dependencies: [prettier]
types_or: [css, json, yaml]
entry: prettier --write
- id: svgo
name: svgo
language: node
additional_dependencies: [svgo]
types: [svg]
entry: svgo

View File

@@ -15,17 +15,12 @@ gi.require_versions({
"Adw": "1",
})
if sys.platform.startswith("linux"):
gi.require_version("Manette", "0.2")
from gi.repository import Gio, GLib
from .config import APP_ID, LOCALEDIR, PKGDATADIR
DATA_DIR = Path(GLib.get_user_data_dir(), "cartridges")
SETTINGS = Gio.Settings.new(APP_ID)
STATE_SETTINGS = Gio.Settings.new(f"{APP_ID}.State")
state_settings = Gio.Settings.new(f"{APP_ID}.State")
_RESOURCES = ("data", "icons", "ui")

View File

@@ -3,7 +3,7 @@
import sys
from gi.events import GLibEventLoopPolicy # pyright: ignore[reportMissingImports]
from gi.events import GLibEventLoopPolicy
from .application import Application

View File

@@ -2,12 +2,15 @@
# SPDX-FileCopyrightText: Copyright 2025 Zoey Ahmed
# SPDX-FileCopyrightText: Copyright 2025 kramo
from collections.abc import Generator, Iterable
from gettext import gettext as _
from typing import override
from gi.repository import Adw
from cartridges import collections, sources
from cartridges import games
from cartridges.games import Game
from cartridges.sources import Source, steam
from .config import APP_ID, PREFIX
from .ui.window import Window
@@ -19,19 +22,29 @@ class Application(Adw.Application):
def __init__(self):
super().__init__(application_id=APP_ID)
@override
def do_startup(self):
Adw.Application.do_startup(self)
self.props.style_manager.props.color_scheme = Adw.ColorScheme.PREFER_DARK
self.add_action_entries((
("quit", lambda *_: self.quit()),
("about", lambda *_: self._present_about_dialog()),
))
self.set_accels_for_action("app.quit", ("<Control>q",))
sources.load()
collections.load()
saved = tuple(games.load())
new = self.import_games(steam, skip_ids={g.game_id for g in saved})
games.model.splice(0, 0, (*saved, *new))
@staticmethod
def import_games(*sources: Source, skip_ids: Iterable[str]) -> Generator[Game]:
"""Import games from `sources`, skipping ones in `skip_ids`."""
for source in sources:
try:
yield from source.get_games(skip_ids=skip_ids)
except FileNotFoundError:
continue
@override
def do_startup(self):
Adw.Application.do_startup(self)
Adw.StyleManager.get_default().props.color_scheme = Adw.ColorScheme.PREFER_DARK
@override
def do_activate(self):

View File

@@ -1,117 +0,0 @@
# SPDX-License-Identifier: GPL-3.0-or-later
# SPDX-FileCopyrightText: Copyright 2025 Jamie Gravendeel
from collections.abc import Generator, Iterable
from gettext import gettext as _
from typing import TYPE_CHECKING, Any, cast
from gi.repository import Gio, GLib, GObject
from cartridges import SETTINGS
from cartridges.sources import imported
if TYPE_CHECKING:
from .application import Application
from .ui.window import Window
class Collection(Gio.SimpleActionGroup):
"""Collection data class."""
__gtype_name__ = __qualname__
name = GObject.Property(type=str)
icon = GObject.Property(type=str, default="collection")
game_ids = GObject.Property(type=object)
removed = GObject.Property(type=bool, default=False)
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)
self.game_ids = self.game_ids or set()
self.bind_property(
"icon",
self,
"icon-name",
GObject.BindingFlags.SYNC_CREATE,
lambda _, name: f"{name}-symbolic",
)
self.add_action(remove := Gio.SimpleAction.new("remove"))
remove.connect("activate", lambda *_: self._remove())
self.bind_property(
"in-model",
remove,
"enabled",
GObject.BindingFlags.SYNC_CREATE,
)
def _remove(self):
self.removed = True
save()
app = cast("Application", Gio.Application.get_default())
window = cast("Window", app.props.active_window)
window.send_toast(_("{} removed").format(self.name), undo=self._undo_remove)
def _undo_remove(self):
self.removed = False
save()
def load():
"""Load collections from GSettings."""
model.splice(0, 0, tuple(_get_collections()))
save()
for collection in model:
collection.notify("in-model")
def save():
"""Save collections to GSettings."""
SETTINGS.set_value(
"collections",
GLib.Variant(
"aa{sv}",
(
{
"name": GLib.Variant.new_string(collection.name),
"icon": GLib.Variant.new_string(collection.icon),
"game-ids": GLib.Variant.new_strv(tuple(collection.game_ids)),
"removed": GLib.Variant.new_boolean(collection.removed),
}
for collection in cast(Iterable[Collection], model)
),
),
)
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

@@ -1,342 +0,0 @@
# SPDX-License-Identifier: GPL-3.0-or-later
# SPDX-FileCopyrightText: Copyright 2025 Zoey Ahmed
import math
from collections.abc import Generator
from typing import TYPE_CHECKING, Any, cast
from gi.repository import Adw, Gio, GLib, GObject, Gtk, Manette
from .ui.game_item import GameItem
if TYPE_CHECKING:
from .ui.window import Window
STICK_DEADZONE = 0.5
REPEAT_DELAY = 280
class Gamepad(GObject.Object):
"""Data class for gamepad, including UI navigation."""
window: "Window"
device: Manette.Device
def __init__(self, device: Manette.Device, **kwargs: Any):
super().__init__(**kwargs)
self.device = device
self._allowed_inputs = {
Gtk.DirectionType.UP,
Gtk.DirectionType.DOWN,
Gtk.DirectionType.LEFT,
Gtk.DirectionType.RIGHT,
}
self.device.connect("button-press-event", self._on_button_press_event)
self.device.connect("absolute-axis-event", self._on_analog_axis_event)
@staticmethod
def _get_rtl_direction(
a: Gtk.DirectionType, b: Gtk.DirectionType
) -> Gtk.DirectionType:
return a if Gtk.Widget.get_default_direction() == Gtk.TextDirection.RTL else b
def _lock_input(self, direction: Gtk.DirectionType):
self._allowed_inputs.remove(direction)
GLib.timeout_add(REPEAT_DELAY, lambda *_: self._allowed_inputs.add(direction))
def _on_button_press_event(self, _device: Manette.Device, event: Manette.Event):
_success, button = event.get_button()
match button: # Xbox / Nintendo / PlayStation
case 304: # A / B / Circle
self._on_activate_button_pressed()
case 305: # B / A / Cross
self._on_return_button_pressed()
case 307: # Y / X / Triangle
self.window.search_entry.grab_focus()
case 308: # X / Y / Square
pass
case 310: # Left Shoulder Button
pass
case 311: # Right Shoulder Button
pass
case 314: # Back / - / Options
pass
case 315: # Start / + / Share
pass
case 544:
self._move_vertically(Gtk.DirectionType.UP)
case 545:
self._move_vertically(Gtk.DirectionType.DOWN)
case 546:
self._move_horizontally(Gtk.DirectionType.LEFT)
case 547:
self._move_horizontally(Gtk.DirectionType.RIGHT)
def _on_analog_axis_event(self, _device: Manette.Device, event: Manette.Event):
_, axis, value = event.get_absolute()
if abs(value) < STICK_DEADZONE:
return
match axis:
case 0:
direction = (
Gtk.DirectionType.LEFT if value < 0 else Gtk.DirectionType.RIGHT
)
if direction in self._allowed_inputs:
self._lock_input(direction)
self._move_horizontally(direction)
case 1:
direction = (
Gtk.DirectionType.UP if value < 0 else Gtk.DirectionType.DOWN
)
if direction in self._allowed_inputs:
self._lock_input(direction)
self._move_vertically(direction)
def _on_activate_button_pressed(self):
if self.window.navigation_view.props.visible_page_tag == "details":
if focus_widget := self.window.props.focus_widget:
focus_widget.activate()
return
if self._is_focused_on_top_bar() and (
focus_widget := self.window.props.focus_widget
):
if isinstance(focus_widget, Gtk.ToggleButton):
focus_widget.props.active = True
return
focus_widget.activate()
return
self.window.grid.activate_action(
"list.activate-item",
GLib.Variant.new_uint32(self._get_current_position()),
)
def _on_return_button_pressed(self):
if self.window.navigation_view.props.visible_page_tag == "details":
if self.window.details.stack.props.visible_child_name == "edit":
self.window.details.activate_action("details.cancel")
return
if self._is_focused_on_top_bar():
self.window.sort_button.props.active = False
return
self.window.navigation_view.pop_to_tag("games")
open_menu = self._get_active_menu_button()
if open_menu:
open_menu.set_active(False)
open_menu.grab_focus()
return
self.window.grid.grab_focus()
self.window.props.focus_visible = True
def _navigate_to_game_position(self, new_pos: int):
if new_pos >= 0 and new_pos <= self._n_grid_games() - 1:
self.window.grid.scroll_to(new_pos, Gtk.ListScrollFlags.FOCUS, None)
else:
self.window.props.display.beep()
def _move_horizontally(self, direction: Gtk.DirectionType):
if self._is_focused_on_top_bar():
if not self.window.header_bar.child_focus(direction):
self.window.header_bar.keynav_failed(direction)
self.window.props.focus_visible = True
return
if self._can_navigate_games_page():
self._navigate_to_game_position(
self._get_current_position()
+ (
-1
if direction
== self._get_rtl_direction(
Gtk.DirectionType.RIGHT, Gtk.DirectionType.LEFT
)
else 1
)
)
return
if self.window.navigation_view.props.visible_page_tag == "details":
if self.window.details.stack.props.visible_child_name == "details":
self._navigate_action_buttons(direction)
return
if (
(focus_widget := self.window.props.focus_widget)
and (parent := focus_widget.props.parent)
and not parent.child_focus(direction)
):
parent.keynav_failed(direction)
def _move_vertically(self, direction: Gtk.DirectionType):
if self._is_focused_on_top_bar() and direction == Gtk.DirectionType.DOWN:
self.window.grid.grab_focus()
return
if self._can_navigate_games_page():
if not (game := self._get_focused_game()):
return
current_grid_columns = math.floor(
self.window.grid.get_width() / game.get_width()
)
new_pos = self._get_current_position() + (
-current_grid_columns
if direction == Gtk.DirectionType.UP
else current_grid_columns
)
if new_pos < 0 and direction == Gtk.DirectionType.UP:
self.window.search_entry.grab_focus()
return
self._navigate_to_game_position(new_pos)
return
if self.window.navigation_view.props.visible_page_tag != "details":
return
if self.window.details.stack.props.visible_child_name == "details":
self._navigate_action_buttons(direction)
return
if not (focus_widget := self.window.props.focus_widget):
return
if not (
current_row := (
focus_widget.get_ancestor(Adw.EntryRow)
or focus_widget.get_ancestor(Adw.WrapBox)
)
):
self.window.header_bar.grab_focus()
if not (focus_widget := self.window.get_focus_child()):
return
if focus_widget.child_focus(direction):
self.window.props.focus_visible = True
return
focus_widget.keynav_failed(direction)
return
if not (current_box := current_row.get_ancestor(Gtk.Box)):
return
if not current_box.child_focus(direction):
current_box.keynav_failed(direction)
self.window.props.focus_visible = True
def _navigate_action_buttons(self, direction: Gtk.DirectionType):
if not (focus_widget := self.window.props.focus_widget):
return
widget = focus_widget
for _ in range(2): # Try to focus the actions, then try the play button
if not (widget := widget.props.parent):
break
if widget.child_focus(direction):
self.window.props.focus_visible = True
return
# Focus on header bar if the user goes up/down
self.window.header_bar.grab_focus()
if not (focus_widget := self.window.get_focus_child()):
return
if focus_widget.child_focus(direction):
self.window.props.focus_visible = True
return
focus_widget.keynav_failed(direction)
def _get_active_menu_button(self) -> Gtk.MenuButton | None:
for button in self.window.main_menu_button, self.window.sort_button:
if button.props.active:
return button
return None
def _get_focused_game(self) -> Gtk.Widget | None:
self.window.grid.grab_focus()
if (
focused_game := self.window.props.focus_widget
) and focused_game.get_ancestor(Gtk.GridView):
return focused_game
return None
def _get_current_position(self) -> int:
if (game_widget := self._get_focused_game()) and isinstance(
item := game_widget.get_first_child(), GameItem
):
return item.position
return 0
def _can_navigate_games_page(self) -> bool:
return bool(
self.window.navigation_view.props.visible_page_tag == "games"
and self._n_grid_games()
and not self._get_active_menu_button()
)
def _is_focused_on_top_bar(self) -> bool:
return bool(
self.window.header_bar.get_focus_child()
and not self._get_active_menu_button()
)
def _n_grid_games(self) -> int:
return cast(Gtk.SingleSelection, self.window.grid.props.model).props.n_items
def _iterate_devices() -> Generator[Gamepad]:
monitor_iter = monitor.iterate()
has_next = True
while has_next:
has_next, device = monitor_iter.next()
if device:
yield Gamepad(device)
def _remove_device(device: Manette.Device):
model.remove(
next(
pos
for pos, gamepad in enumerate(model)
if cast(Gamepad, gamepad).device == device
)
)
def _update_window_style(model: Gio.ListStore):
if model.props.n_items > 0:
Gamepad.window.add_css_class("controller-connected")
else:
Gamepad.window.remove_css_class("controller-connected")
def setup_monitor():
"""Connect monitor to device connect/disconnect signals."""
monitor.connect(
"device-connected",
lambda _, device: model.append(Gamepad(device)),
)
monitor.connect("device-disconnected", lambda _, device: _remove_device(device))
model.splice(0, 0, tuple(_iterate_devices()))
monitor = Manette.Monitor()
model = Gio.ListStore.new(Gamepad)
model.connect("items-changed", lambda model, *_: _update_window_style(model))

View File

@@ -3,19 +3,22 @@
# SPDX-FileCopyrightText: Copyright 2025 kramo
# SPDX-FileCopyrightText: Copyright 2025 Jamie Gravendeel
import itertools
import json
import locale
import os
import subprocess
from collections.abc import Callable
from collections.abc import Callable, Generator, Iterable
from gettext import gettext as _
from json import JSONDecodeError
from pathlib import Path
from shlex import quote
from types import UnionType
from typing import TYPE_CHECKING, Any, NamedTuple, Self, cast
from typing import TYPE_CHECKING, Any, NamedTuple, Self, cast, override
from gi.repository import Gdk, Gio, GObject
from gi.repository import Gdk, Gio, GLib, GObject, Gtk
from . import DATA_DIR
from cartridges import DATA_DIR, state_settings
if TYPE_CHECKING:
from .application import Application
@@ -43,10 +46,18 @@ PROPERTIES: tuple[_GameProp, ...] = (
_GameProp("version", float),
)
GAMES_DIR = DATA_DIR / "games"
COVERS_DIR = DATA_DIR / "covers"
_GAMES_DIR = DATA_DIR / "games"
_COVERS_DIR = DATA_DIR / "covers"
_SPEC_VERSION = 2.0
_MANUALLY_ADDED_ID = "imported"
_SORT_MODES = {
"last_played": ("last-played", True),
"a-z": ("name", False),
"z-a": ("name", True),
"newest": ("added", True),
"oldest": ("added", False),
}
class Game(Gio.SimpleActionGroup):
@@ -120,6 +131,14 @@ 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():
@@ -139,33 +158,120 @@ class Game(Gio.SimpleActionGroup):
"""Save the game's properties to disk."""
properties = {prop.name: getattr(self, prop.name) for prop in PROPERTIES}
GAMES_DIR.mkdir(parents=True, exist_ok=True)
path = (GAMES_DIR / self.game_id).with_suffix(".json")
_GAMES_DIR.mkdir(parents=True, exist_ok=True)
path = (_GAMES_DIR / self.game_id).with_suffix(".json")
with path.open("w", encoding="utf-8") as f:
json.dump(properties, f, indent=4, sort_keys=True)
json.dump(properties, f, indent=4)
def _remove(self):
self.removed = True
self._send(
_("{} removed").format(self.name),
undo=lambda: setattr(self, "removed", False),
)
self.save()
self._send(_("{} removed").format(self.name), undo=self._undo_remove)
def _undo_remove(self):
self.removed = False
self.save()
def _hide(self):
self.hidden = True
self._send(
_("{} hidden").format(self.name),
undo=lambda: setattr(self, "hidden", False),
)
self.save()
self._send(_("{} hidden").format(self.name), undo=self._undo_hide)
def _undo_hide(self):
self.hidden = False
self.save()
def _unhide(self):
self.hidden = False
self._send(
_("{} unhidden").format(self.name),
undo=lambda: setattr(self, "hidden", True),
)
self.save()
self._send(_("{} unhidden").format(self.name), undo=self._undo_unhide)
def _undo_unhide(self):
self.hidden = True
self.save()
def _send(self, title: str, *, undo: Callable[[], Any]):
app = cast("Application", Gio.Application.get_default())
window = cast("Window", app.props.active_window)
window.send_toast(title, undo=undo)
class GameSorter(Gtk.Sorter):
"""A sorter for game objects.
Automatically updates if the "sort-mode" GSetting changes.
"""
__gtype_name__ = __qualname__
def __init__(self, **kwargs: Any):
super().__init__(**kwargs)
state_settings.connect(
"changed::sort-mode", lambda *_: self.changed(Gtk.SorterChange.DIFFERENT)
)
@override
def do_compare(self, game1: Game, game2: Game) -> Gtk.Ordering: # pyright: ignore[reportIncompatibleMethodOverride]
prop, invert = _SORT_MODES[state_settings.get_string("sort-mode")]
a = (game2 if invert else game1).get_property(prop)
b = (game1 if invert else game2).get_property(prop)
return Gtk.Ordering(
self._name_cmp(a, b)
if isinstance(a, str)
else ((a > b) - (a < b)) or self._name_cmp(game1.name, game2.name)
)
@staticmethod
def _name_cmp(a: str, b: str) -> int:
a, b = (name.lower().removeprefix("the ") for name in (a, b))
return max(-1, min(locale.strcoll(a, b), 1))
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
def load() -> Generator[Game]:
"""Load previously saved games from disk."""
for path in _GAMES_DIR.glob("*.json"):
try:
with path.open(encoding="utf-8") as f:
data = json.load(f)
except (JSONDecodeError, UnicodeDecodeError):
continue
if data.get("removed"):
path.unlink()
continue
try:
game = Game.from_data(data)
except TypeError:
continue
cover_path = _COVERS_DIR / game.game_id
for ext in ".gif", ".tiff":
filename = str(cover_path.with_suffix(ext))
try:
game.cover = Gdk.Texture.new_from_filename(filename)
except GLib.Error:
continue
else:
break
yield game
model = Gio.ListStore.new(Game)

View File

@@ -1,12 +1,5 @@
python.install_sources(
files(
'__init__.py',
'__main__.py',
'application.py',
'collections.py',
'gamepads.py',
'games.py',
),
files('__init__.py', '__main__.py', 'application.py', 'games.py'),
subdir: 'cartridges',
)

View File

@@ -1,35 +1,19 @@
# SPDX-License-Identifier: GPL-3.0-or-later
# SPDX-FileCopyrightText: Copyright 2025 kramo
import importlib
import os
import pkgutil
import sys
import time
from collections.abc import Generator, Iterable
from functools import cache
from pathlib import Path
from typing import Final, Protocol, cast
from typing import Final, Protocol
from gi.repository import Gio, GLib, GObject
from gi.repository import GLib
from cartridges.games import Game
DATA = Path(GLib.get_user_data_dir())
CONFIG = Path(GLib.get_user_config_dir())
CACHE = Path(GLib.get_user_cache_dir())
FLATPAK = Path.home() / ".var" / "app"
HOST_DATA = Path(os.getenv("HOST_XDG_DATA_HOME", Path.home() / ".local" / "share"))
HOST_CONFIG = Path(os.getenv("HOST_XDG_CONFIG_HOME", Path.home() / ".config"))
HOST_CACHE = Path(os.getenv("HOST_XDG_CACHE_HOME", Path.home() / ".cache"))
PROGRAM_FILES_X86 = Path(os.getenv("PROGRAMFILES(X86)", r"C:\Program Files (x86)"))
APPDATA = Path(os.getenv("APPDATA", r"C:\Users\Default\AppData\Roaming"))
LOCAL_APPDATA = Path(
os.getenv("CSIDL_LOCAL_APPDATA", r"C:\Users\Default\AppData\Local")
)
APPLICATION_SUPPORT = Path.home() / "Library" / "Application Support"
OPEN = (
@@ -41,87 +25,13 @@ OPEN = (
)
class _SourceModule(Protocol):
class Source(Protocol):
"""A source of games to import."""
ID: Final[str]
NAME: Final[str]
@staticmethod
def get_games() -> Generator[Game]:
"""Installed games."""
def get_games(*, skip_ids: Iterable[str]) -> Generator[Game]:
"""Installed games, except those in `skip_ids`."""
...
class Source(GObject.Object, Gio.ListModel): # pyright: ignore[reportIncompatibleMethodOverride]
"""A source of games to import."""
__gtype_name__ = __qualname__
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(
"id",
self,
"icon-name",
GObject.BindingFlags.SYNC_CREATE,
lambda _, ident: f"{ident}-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 info in pkgutil.iter_modules(__path__, prefix="."):
module = cast(_SourceModule, importlib.import_module(info.name, __package__))
yield Source(module, added)
model = Gio.ListStore.new(Source)

View File

@@ -1,179 +0,0 @@
# SPDX-License-Identifier: GPL-3.0-or-later
# SPDX-FileCopyrightText: Copyright 2023 Geoffrey Coulaud
# SPDX-FileCopyrightText: Copyright 2022-2025 kramo
import json
from abc import ABC, abstractmethod
from collections.abc import Generator, Iterable
from contextlib import suppress
from gettext import gettext as _
from hashlib import sha256
from json import JSONDecodeError
from pathlib import Path
from typing import Any, override
from gi.repository import Gdk, GLib
from cartridges.games import Game
from . import APPDATA, APPLICATION_SUPPORT, CONFIG, FLATPAK, HOST_CONFIG, OPEN
ID, NAME = "heroic", _("Heroic")
_CONFIG_PATHS = (
CONFIG / "heroic",
HOST_CONFIG / "heroic",
FLATPAK / "com.heroicgameslauncher.hgl" / "config" / "heroic",
APPDATA / "heroic",
APPLICATION_SUPPORT / "heroic",
)
class _Source(ABC):
ID: str
COVER_URI_PARAMS = ""
LIBRARY_KEY = "library"
@classmethod
@abstractmethod
def library_path(cls) -> Path: ...
@classmethod
def installed_app_names(cls) -> set[str] | None:
return None
class _SideloadSource(_Source):
ID = "sideload"
LIBRARY_KEY = "games"
@classmethod
def library_path(cls) -> Path:
return Path("sideload_apps", "library.json")
class _StoreSource(_Source):
_INSTALLED_PATH: Path
@classmethod
def library_path(cls) -> Path:
return Path("store_cache", f"{cls.ID}_library.json")
@override
@classmethod
def installed_app_names(cls) -> set[str]:
try:
with (_config_dir() / cls._INSTALLED_PATH).open() as fp:
data = json.load(fp)
except (OSError, JSONDecodeError):
return set()
return set(cls._installed(data))
@staticmethod
def _installed(data: Any) -> Generator[str]: # noqa: ANN401
with suppress(AttributeError):
yield from data.keys()
class _LegendarySource(_StoreSource):
ID = "legendary"
COVER_URI_PARAMS = "?h=400&resize=1&w=300"
_INSTALLED_PATH = Path("legendaryConfig", "legendary", "installed.json")
class _GOGSource(_StoreSource):
ID = "gog"
LIBRARY_KEY = "games"
_INSTALLED_PATH = Path("gog_store", "installed.json")
@override
@staticmethod
def _installed(data: Any) -> Generator[str]:
with suppress(TypeError, KeyError):
for entry in data["installed"]:
with suppress(TypeError, KeyError):
yield entry["appName"]
class _NileSource(_StoreSource):
ID = "nile"
LIBRARY_PATH = Path("store_cache", "nile_library.json")
_INSTALLED_PATH = Path("nile_config", "nile", "installed.json")
@override
@staticmethod
def _installed(data: Any) -> Generator[str]:
with suppress(TypeError):
for entry in data:
with suppress(TypeError, KeyError):
yield entry["id"]
def get_games() -> Generator[Game]:
"""Installed Heroic games."""
for source in _LegendarySource, _GOGSource, _NileSource, _SideloadSource:
yield from _games_from(source)
def _config_dir() -> Path:
for path in _CONFIG_PATHS:
if path.is_dir():
return path
raise FileNotFoundError
def _hidden_app_names() -> Generator[str]:
try:
with (_config_dir() / "store" / "config.json").open() as fp:
config = json.load(fp)
except (OSError, JSONDecodeError):
return
with suppress(TypeError, KeyError):
for game in config["games"]["hidden"]:
with suppress(TypeError, KeyError):
yield game["appName"]
def _games_from(source: type[_Source]) -> Generator[Game]:
try:
with (_config_dir() / source.library_path()).open() as fp:
library = json.load(fp)
except (OSError, JSONDecodeError):
return
if not isinstance(library := library.get(source.LIBRARY_KEY), Iterable):
return
source_id = f"{ID}_{source.ID}"
images_cache = _config_dir() / "images-cache"
installed = source.installed_app_names()
hidden = set(_hidden_app_names())
for entry in library:
with suppress(TypeError, KeyError):
app_name = entry["app_name"]
if (installed is not None) and (app_name not in installed):
continue
cover_uri = f"{entry.get('art_square', '')}{source.COVER_URI_PARAMS}"
cover_path = images_cache / sha256(cover_uri.encode()).hexdigest()
try:
cover = Gdk.Texture.new_from_filename(str(cover_path))
except GLib.Error:
cover = None
yield Game(
executable=f"{OPEN} heroic://launch/{entry['runner']}/{app_name}",
game_id=f"{source_id}_{app_name}",
source=source_id,
hidden=app_name in hidden,
name=entry["title"],
developer=entry.get("developer"),
cover=cover,
)

View File

@@ -1,54 +0,0 @@
# 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
from cartridges.games import COVERS_DIR, GAMES_DIR, Game
ID, NAME = "imported", _("Added")
def get_games() -> Generator[Game]:
"""Manually added games."""
for path in get_paths():
try:
with path.open(encoding="utf-8") as f:
data = json.load(f)
except (JSONDecodeError, UnicodeDecodeError):
continue
try:
game = Game.from_data(data)
except TypeError:
continue
cover_path = COVERS_DIR / game.game_id
for ext in ".gif", ".tiff":
filename = str(cover_path.with_suffix(ext))
try:
game.cover = Gdk.Texture.new_from_filename(filename)
except GLib.Error:
continue
else:
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

@@ -6,8 +6,9 @@ import itertools
import logging
import re
import struct
import time
from collections import defaultdict
from collections.abc import Generator, Sequence
from collections.abc import Generator, Iterable, Sequence
from contextlib import suppress
from gettext import gettext as _
from os import SEEK_CUR
@@ -106,13 +107,15 @@ class _AppInfo(NamedTuple):
return cls(common.get("type"), developer, capsule)
def get_games() -> Generator[Game]:
def get_games(*, skip_ids: Iterable[str]) -> Generator[Game]:
"""Installed Steam games."""
added = int(time.time())
librarycache = _data_dir() / "appcache" / "librarycache"
with (_data_dir() / "appcache" / "appinfo.vdf").open("rb") as fp:
appinfo = defaultdict(_AppInfo, _parse_appinfo_vdf(fp))
appids = set()
appids = {i.rsplit("_", 1)[-1] for i in skip_ids if i.startswith(f"{ID}_")}
for manifest in _manifests():
try:
name, appid, stateflags, lastplayed = _App.from_manifest(manifest)
@@ -135,6 +138,7 @@ def get_games() -> Generator[Game]:
appids.add(appid)
yield Game(
added=added,
executable=f"{OPEN} steam://rungameid/{appid}",
game_id=f"{ID}_{appid}",
source=ID,

View File

@@ -1,46 +0,0 @@
# SPDX-License-Identifier: GPL-3.0-or-later
# SPDX-FileCopyrightText: Copyright 2025 Jamie Gravendeel
from collections.abc import Callable
from typing import Any
from gi.repository import Gtk
def _closure[**P, R](func: Callable[P, R]) -> object: # gi._gtktemplate.CallThing
@Gtk.Template.Callback()
@staticmethod
def wrapper(_obj, *args: P.args, **kwargs: P.kwargs) -> R:
return func(*args, **kwargs)
return wrapper
@_closure
def boolean(value: object) -> bool:
"""Get a boolean for `value`."""
return bool(value)
@_closure
def concat(*strings: str) -> str:
"""Join `strings`."""
return "".join(strings)
@_closure
def either[T](first: T, second: T) -> T:
"""Return `first` or `second`."""
return first or second
@_closure
def format_string(string: str, *args: Any) -> str:
"""Format `string` with `args`."""
return string.format(*args)
@_closure
def if_else[T](condition: object, first: T, second: T) -> T:
"""Return `first` or `second` depending on `condition`."""
return first if condition else second

View File

@@ -1,80 +0,0 @@
using Gtk 4.0;
using Adw 1;
template $CollectionDetails: Adw.Dialog {
title: bind $either(template.collection as <$Collection>.name, _("New Collection")) as <string>;
content-width: 360;
default-widget: apply_button;
focus-widget: name_entry;
ShortcutController {
Shortcut {
trigger: "Delete|KP_Delete";
action: "action(collection.remove)";
}
}
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: bind $if_else(template.collection as <$Collection>.in-model, _("Apply"), _("Add")) as <string>;
styles [
"suggested-action",
]
}
}
content: Adw.PreferencesPage {
Adw.PreferencesGroup {
Adw.EntryRow name_entry {
title: _("Name");
text: bind template.collection as <$Collection>.name;
activates-default: true;
}
}
Adw.PreferencesGroup {
Grid icons_grid {
column-spacing: 6;
row-spacing: 6;
margin-top: 6;
margin-bottom: 6;
margin-start: 6;
margin-end: 6;
}
styles [
"card",
]
}
Adw.PreferencesGroup {
visible: bind remove_row.sensitive;
Adw.ButtonRow remove_row {
title: _("Remove");
action-name: "collection.remove";
styles [
"destructive-action",
]
}
}
};
};
}

View File

@@ -1,134 +0,0 @@
# SPDX-License-Identifier: GPL-3.0-or-later
# SPDX-FileCopyrightText: Copyright 2025 Jamie Gravendeel
from itertools import product
from typing import Any, NamedTuple, cast
from gi.repository import Adw, Gio, GObject, Gtk
from cartridges import collections
from cartridges.collections import Collection
from cartridges.config import PREFIX
from cartridges.ui import closures
class _Icon(NamedTuple):
name: str
a11y_label: str
_ICONS = (
_Icon("collection", "📚"),
_Icon("star", ""),
_Icon("heart", "❤️"),
_Icon("music", "🎵"),
_Icon("people", "🧑"),
_Icon("skull", "💀"),
_Icon("private", "🕵️"),
_Icon("globe", "🌐"),
_Icon("map", "🗺"),
_Icon("city", "🏙️"),
_Icon("car", "🚗"),
_Icon("horse", "🐎"),
_Icon("sprout", "🌱"),
_Icon("step-over", "🪜"),
_Icon("gamepad", "🎮"),
_Icon("ball", ""),
_Icon("puzzle", "🧩"),
_Icon("flashlight", "🔦"),
_Icon("knife", "🔪"),
_Icon("gun", "🔫"),
_Icon("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_grid: Gtk.Grid = Gtk.Template.Child()
sort_changed = GObject.Signal()
_selected_icon: str
either = closures.either
if_else = closures.if_else
@GObject.Property(type=Collection)
def collection(self) -> Collection:
"""The collection that `self` represents."""
return self._collection
@collection.setter
def collection(self, collection: Collection):
self._collection = collection
self.insert_action_group("collection", collection)
remove_action = cast(Gio.SimpleAction, collection.lookup_action("remove"))
remove_action.connect("activate", lambda *_: self.force_close())
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),
)
group_button = None
for index, (row, col) in enumerate(product(range(3), range(7))):
icon = _ICONS[index].name
button = Gtk.ToggleButton(
icon_name=f"{icon}-symbolic",
hexpand=True,
halign=Gtk.Align.CENTER,
)
button.update_property(
(Gtk.AccessibleProperty.LABEL,), (_ICONS[index].a11y_label,)
)
button.add_css_class("circular")
button.add_css_class("flat")
if group_button:
button.props.group = group_button
else:
group_button = button
button.connect(
"toggled",
lambda _, icon: setattr(self, "_selected_icon", icon),
icon,
)
if icon == self.collection.icon:
button.props.active = True
self.icons_grid.attach(button, col, row, 1, 1)
def _apply(self):
name = self.name_entry.props.text
if self.collection.name != name:
self.collection.name = name
if self.collection.in_model:
self.emit("sort-changed")
self.collection.icon = self._selected_icon
if not self.collection.in_model:
collections.model.append(self.collection)
self.collection.notify("in-model")
collections.save()
self.close()

View File

@@ -1,144 +0,0 @@
# SPDX-License-Identifier: GPL-3.0-or-later
# SPDX-FileCopyrightText: Copyright 2025 Jamie Gravendeel
from collections.abc import Iterable
from typing import Any, cast, override
from gi.repository import Adw, GLib, GObject, Gtk
from cartridges import collections
from cartridges.collections import Collection
from cartridges.games import Game
class CollectionFilter(Gtk.Filter):
"""Filter games based on a selected collection."""
__gtype_name__ = __qualname__
@GObject.Property(type=Collection)
def collection(self) -> Collection | None:
"""The collection used for filtering."""
return self._collection
@collection.setter
def collection(self, collection: Collection | None):
self._collection = collection
self.changed(Gtk.FilterChange.DIFFERENT)
@override
def do_match(self, game: Game) -> bool: # pyright: ignore[reportIncompatibleMethodOverride]
if not self.collection:
return True
return game.game_id in self.collection.game_ids
class CollectionSidebarItem(Adw.SidebarItem): # pyright: ignore[reportAttributeAccessIssue]
"""A sidebar item representing a collection."""
@GObject.Property(type=Collection)
def collection(self) -> Collection:
"""The collection that `self` represents."""
return self._collection
@collection.setter
def collection(self, collection: Collection):
self._collection = collection
flags = GObject.BindingFlags.SYNC_CREATE
collection.bind_property("name", self, "title", flags)
collection.bind_property("icon-name", self, "icon-name", flags)
def __init__(self, **kwargs: Any):
super().__init__(**kwargs)
self.bind_property(
"title",
self,
"tooltip",
GObject.BindingFlags.SYNC_CREATE,
lambda _, name: GLib.markup_escape_text(name),
)
class CollectionButton(Gtk.ToggleButton):
"""A toggle button representing a collection."""
collection = GObject.Property(type=Collection)
def __init__(self, collection: Collection, **kwargs: Any):
super().__init__(**kwargs)
self.collection = collection
self.props.child = Adw.ButtonContent(
icon_name=collection.icon_name,
label=collection.name,
can_shrink=True,
)
class CollectionsBox(Adw.Bin):
"""A wrap box for adding games to collections."""
__gtype_name__ = __qualname__
game = GObject.Property(type=Game)
def __init__(self, **kwargs: Any):
super().__init__(**kwargs)
self.props.child = self.box = Adw.WrapBox(
child_spacing=6,
line_spacing=6,
justify=Adw.JustifyMode.FILL,
justify_last_line=True,
natural_line_length=240,
)
model.bind_property(
"n-items",
self,
"visible",
GObject.BindingFlags.SYNC_CREATE,
)
def build(self):
"""Populate the box with collections."""
for collection in cast(Iterable[Collection], model):
button = CollectionButton(collection)
button.props.active = self.game.game_id in collection.game_ids
self.box.append(button)
def finish(self):
"""Clear the box and save changes."""
filter_changed = False
for button in cast(Iterable[CollectionButton], self.box):
game_ids = button.collection.game_ids
old_game_ids = game_ids.copy()
if button.props.active:
game_ids.add(self.game.game_id)
else:
game_ids.discard(self.game.game_id)
if game_ids != old_game_ids:
filter_changed = True
self.box.remove_all() # pyright: ignore[reportAttributeAccessIssue]
collections.save()
if filter_changed:
self.activate_action("win.notify-collection-filter")
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,
)

View File

@@ -5,27 +5,27 @@ template $Cover: Adw.Bin {
child: Adw.Clamp {
orientation: vertical;
unit: px;
maximum-size: bind template.height;
tightening-threshold: bind template.height;
maximum-size: bind _picture.height-request;
tightening-threshold: bind _picture.height-request;
child: Adw.Clamp {
unit: px;
maximum-size: bind template.width;
tightening-threshold: bind template.width;
maximum-size: bind _picture.width-request;
tightening-threshold: bind _picture.width-request;
child: Adw.ViewStack {
name: "cover";
visible-child-name: bind $if_else(template.paintable, "cover", "icon") as <string>;
visible-child-name: bind $_get_stack_child(template.paintable) as <string>;
overflow: hidden;
enable-transitions: true;
Adw.ViewStackPage {
name: "cover";
child: Picture {
child: Picture _picture {
paintable: bind template.paintable;
width-request: bind template.width;
height-request: bind template.height;
width-request: 200;
height-request: 300;
content-fit: cover;
};
}
@@ -34,10 +34,7 @@ template $Cover: Adw.Bin {
name: "icon";
child: Image {
icon-name: bind $concat(
template.root as <Window>.application as <Application>.application-id,
"-symbolic"
) as <string>;
icon-name: bind template.app-icon-name;
pixel-size: 80;
styles [

View File

@@ -3,8 +3,7 @@
from gi.repository import Adw, Gdk, GObject, Gtk
from cartridges.config import PREFIX
from cartridges.ui import closures
from cartridges.config import APP_ID, PREFIX
@Gtk.Template.from_resource(f"{PREFIX}/cover.ui")
@@ -13,9 +12,12 @@ class Cover(Adw.Bin):
__gtype_name__ = __qualname__
picture = GObject.Property(lambda self: self._picture, type=Gtk.Picture)
paintable = GObject.Property(type=Gdk.Paintable)
width = GObject.Property(type=int, default=200)
height = GObject.Property(type=int, default=300)
app_icon_name = GObject.Property(type=str, default=f"{APP_ID}-symbolic")
concat = closures.concat
if_else = closures.if_else
_picture = Gtk.Template.Child()
@Gtk.Template.Callback()
def _get_stack_child(self, _obj, paintable: Gdk.Paintable | None) -> str:
return "cover" if paintable else "icon"

View File

@@ -5,7 +5,7 @@ using Adw 1;
template $GameDetails: Adw.NavigationPage {
name: "details";
tag: "details";
title: bind $either(template.game as <$Game>.name, _("Add Game")) as <string>;
title: bind $_or(template.game as <$Game>.name, _("Add Game")) as <string>;
hidden => $_cancel();
ShortcutController {
@@ -101,7 +101,7 @@ template $GameDetails: Adw.NavigationPage {
Label developer_label {
label: bind template.game as <$Game>.developer;
visible: bind $boolean(template.game as <$Game>.developer) as <bool>;
visible: bind $_bool(template.game as <$Game>.developer) as <bool>;
halign: start;
max-width-chars: 36;
wrap: true;
@@ -119,20 +119,14 @@ template $GameDetails: Adw.NavigationPage {
spacing: 9;
Label last_played_label {
label: bind $format_string(
_("Last played: {}"),
$_pretty_date(template.game as <$Game>.last_played) as <string>
) as <string>;
label: bind $_date_label(_("Last played: {}"), template.game as <$Game>.last_played) as <string>;
halign: start;
wrap: true;
wrap-mode: word_char;
}
Label added_label {
label: bind $format_string(
_("Added: {}"),
$_pretty_date(template.game as <$Game>.added) as <string>
) as <string>;
label: bind $_date_label(_("Added: {}"), template.game as <$Game>.added) as <string>;
halign: start;
wrap: true;
wrap-mode: word_char;
@@ -170,52 +164,6 @@ template $GameDetails: Adw.NavigationPage {
]
}
MenuButton {
icon-name: "collection-symbolic";
tooltip-text: _("Collections");
valign: center;
notify::active => $_setup_collections();
popover: PopoverMenu {
menu-model: menu {
item (_("New Collection"), "details.add-collection")
item {
custom: "collections";
}
};
[collections]
Box {
orientation: vertical;
visible: bind collections_box.visible;
Separator {}
Label {
label: _("Collections");
halign: start;
margin-top: 6;
margin-bottom: 9;
margin-start: 12;
margin-end: 12;
styles [
"heading",
]
}
$CollectionsBox collections_box {
game: bind template.game;
}
}
};
styles [
"circular",
]
}
Button hide_button {
visible: bind hide_button.sensitive;
action-name: "game.hide";
@@ -382,7 +330,7 @@ template $GameDetails: Adw.NavigationPage {
Button apply_button {
action-name: "details.apply";
label: bind $if_else(template.game as <$Game>.added, _("Apply"), _("Add")) as <string>;
label: bind $_if_else(template.game as <$Game>.added, _("Apply"), _("Add")) as <string>;
styles [
"pill",

View File

@@ -12,8 +12,8 @@ template $GameItem: Box {
Adw.Clamp {
unit: px;
maximum-size: bind cover.width;
tightening-threshold: bind cover.width;
maximum-size: bind cover.picture as <Picture>.width-request;
tightening-threshold: bind cover.picture as <Picture>.width-request;
child: Overlay {
child: $Cover cover {
@@ -29,61 +29,23 @@ template $GameItem: Box {
margin-top: 6;
margin-end: 6;
notify::active => $_reveal_buttons();
notify::active => $_setup_collections();
popover: PopoverMenu {
menu-model: menu {
section {
item (_("Edit"), "item.edit")
menu-model: menu {
item (_("Edit"), "item.edit")
item {
label: _("Hide");
action: "game.hide";
hidden-when: "action-disabled";
}
item {
label: _("Unhide");
action: "game.unhide";
hidden-when: "action-disabled";
}
item (_("Remove"), "game.remove")
}
section {
item (_("New Collection"), "item.add-collection")
item {
custom: "collections";
}
}
};
[collections]
Box {
orientation: vertical;
visible: bind collections_box.visible;
Separator {}
Label {
label: _("Collections");
halign: start;
margin-top: 6;
margin-bottom: 9;
margin-start: 12;
margin-end: 12;
styles [
"heading",
]
}
$CollectionsBox collections_box {
game: bind template.game;
}
item {
label: _("Hide");
action: "game.hide";
hidden-when: "action-disabled";
}
item {
label: _("Unhide");
action: "game.unhide";
hidden-when: "action-disabled";
}
item (_("Remove"), "game.remove")
};
styles [

View File

@@ -7,18 +7,15 @@ import sys
import time
from datetime import UTC, datetime
from gettext import gettext as _
from typing import Any, cast
from typing import Any, TypeVar, cast
from urllib.parse import quote
from gi.repository import Adw, Gdk, Gio, GLib, GObject, Gtk
from gi.repository import Adw, Gdk, Gio, GObject, Gtk
from cartridges import games, sources
from cartridges import games
from cartridges.config import PREFIX
from cartridges.games import Game
from cartridges.sources import imported
from cartridges.ui import closures
from .collections import CollectionsBox
from .cover import Cover # noqa: F401
_POP_ON_ACTION = "hide", "unhide", "remove"
@@ -27,6 +24,8 @@ _REQUIRED_PROPERTIES = {
prop.name for prop in games.PROPERTIES if prop.editable and prop.required
}
_T = TypeVar("_T")
@Gtk.Template.from_resource(f"{PREFIX}/game-details.ui")
class GameDetails(Adw.NavigationPage):
@@ -35,19 +34,12 @@ class GameDetails(Adw.NavigationPage):
__gtype_name__ = __qualname__
stack: Adw.ViewStack = Gtk.Template.Child()
actions: Gtk.Box = Gtk.Template.Child()
collections_box: CollectionsBox = Gtk.Template.Child()
name_entry: Adw.EntryRow = Gtk.Template.Child()
developer_entry: Adw.EntryRow = Gtk.Template.Child()
executable_entry: Adw.EntryRow = Gtk.Template.Child()
sort_changed = GObject.Signal()
boolean = closures.boolean
either = closures.either
format_string = closures.format_string
if_else = closures.if_else
@GObject.Property(type=Game)
def game(self) -> Game:
"""The game whose details to show."""
@@ -84,12 +76,6 @@ class GameDetails(Adw.NavigationPage):
),
"s",
),
(
"add-collection",
lambda *_: self.activate_action(
"win.add-collection", GLib.Variant.new_string(self.game.game_id)
),
),
))
group.add_action(apply := Gio.SimpleAction.new("apply"))
@@ -129,8 +115,9 @@ class GameDetails(Adw.NavigationPage):
if not self.game.added:
self.game.added = int(time.time())
sources.get(imported.ID).append(self.game)
games.model.append(self.game)
self.game.save()
self.stack.props.visible_child_name = "details"
@Gtk.Template.Callback()
@@ -145,11 +132,12 @@ class GameDetails(Adw.NavigationPage):
self.stack.props.visible_child_name = "details"
@Gtk.Template.Callback()
def _setup_collections(self, button: Gtk.MenuButton, *_args):
if button.props.active:
self.collections_box.build()
else:
self.collections_box.finish()
def _or(self, _obj, first: _T, second: _T) -> _T:
return first or second
@Gtk.Template.Callback()
def _if_else(self, _obj, condition: object, first: _T, second: _T) -> _T:
return first if condition else second
@Gtk.Template.Callback()
def _downscale_image(self, _obj, cover: Gdk.Texture | None) -> Gdk.Texture | None:
@@ -161,10 +149,10 @@ class GameDetails(Adw.NavigationPage):
return None
@Gtk.Template.Callback()
def _pretty_date(self, _obj, timestamp: int) -> str:
def _date_label(self, _obj, label: str, timestamp: int) -> str:
date = datetime.fromtimestamp(timestamp, UTC)
now = datetime.now(UTC)
return (
return label.format(
_("Never")
if not timestamp
else _("Today")
@@ -186,6 +174,10 @@ class GameDetails(Adw.NavigationPage):
else date.strftime("%Y")
)
@Gtk.Template.Callback()
def _bool(self, _obj, o: object) -> bool:
return bool(o)
@Gtk.Template.Callback()
def _format_more_info(self, _obj, label: str) -> str:
executable = _("program")

View File

@@ -8,7 +8,6 @@ from gi.repository import Gio, GLib, GObject, Gtk
from cartridges.config import PREFIX
from cartridges.games import Game
from .collections import CollectionsBox
from .cover import Cover # noqa: F401
@@ -20,7 +19,6 @@ class GameItem(Gtk.Box):
motion: Gtk.EventControllerMotion = Gtk.Template.Child()
options: Gtk.MenuButton = Gtk.Template.Child()
collections_box: CollectionsBox = Gtk.Template.Child()
play: Gtk.Button = Gtk.Template.Child()
position = GObject.Property(type=int)
@@ -46,12 +44,6 @@ class GameItem(Gtk.Box):
"win.edit", GLib.Variant.new_uint32(self.position)
),
),
(
"add-collection",
lambda *_: self.activate_action(
"win.add-collection", GLib.Variant.new_string(self.game.game_id)
),
),
))
self._reveal_buttons()
@@ -64,10 +56,3 @@ class GameItem(Gtk.Box):
):
widget.props.can_focus = widget.props.can_target = reveal
(widget.remove_css_class if reveal else widget.add_css_class)("hidden")
@Gtk.Template.Callback()
def _setup_collections(self, button: Gtk.MenuButton, *_args):
if button.props.active:
self.collections_box.build()
else:
self.collections_box.finish()

View File

@@ -1,72 +0,0 @@
# SPDX-License-Identifier: GPL-3.0-or-later
# SPDX-FileCopyrightText: Copyright 2025 Zoey Ahmed
# SPDX-FileCopyrightText: Copyright 2025 kramo
# SPDX-FileCopyrightText: Copyright 2025 Jamie Gravendeel
import locale
from typing import Any, override
from gi.repository import Gtk
from cartridges import STATE_SETTINGS, sources
from cartridges.games import Game
_SORT_MODES = {
"last_played": ("last-played", True),
"a-z": ("name", False),
"z-a": ("name", True),
"newest": ("added", True),
"oldest": ("added", False),
}
filter_ = Gtk.EveryFilter()
filter_.append(
Gtk.BoolFilter(
expression=Gtk.PropertyExpression.new(Game, None, "removed"),
invert=True,
)
)
filter_.append(
Gtk.BoolFilter(
expression=Gtk.PropertyExpression.new(Game, None, "blacklisted"),
invert=True,
)
)
model = Gtk.FilterListModel(
model=Gtk.FlattenListModel.new(sources.model),
filter=filter_,
watch_items=True, # pyright: ignore[reportCallIssue]
)
class GameSorter(Gtk.Sorter):
"""A sorter for game objects.
Automatically updates if the "sort-mode" GSetting changes.
"""
__gtype_name__ = __qualname__
def __init__(self, **kwargs: Any):
super().__init__(**kwargs)
STATE_SETTINGS.connect(
"changed::sort-mode", lambda *_: self.changed(Gtk.SorterChange.DIFFERENT)
)
@override
def do_compare(self, game1: Game, game2: Game) -> Gtk.Ordering: # pyright: ignore[reportIncompatibleMethodOverride]
prop, invert = _SORT_MODES[STATE_SETTINGS.get_string("sort-mode")]
a = (game2 if invert else game1).get_property(prop)
b = (game1 if invert else game2).get_property(prop)
return Gtk.Ordering(
self._name_cmp(a, b)
if isinstance(a, str)
else ((a > b) - (a < b)) or self._name_cmp(game1.name, game2.name)
)
@staticmethod
def _name_cmp(a: str, b: str) -> int:
a, b = (name.lower().removeprefix("the ") for name in (a, b))
return max(-1, min(locale.strcoll(a, b), 1))

View File

@@ -1,14 +1,9 @@
python.install_sources(
files(
'__init__.py',
'closures.py',
'collection_details.py',
'collections.py',
'cover.py',
'game_details.py',
'game_item.py',
'games.py',
'sources.py',
'window.py',
),
subdir: 'cartridges' / 'ui',
@@ -16,7 +11,6 @@ python.install_sources(
blueprints = custom_target(
input: files(
'collection-details.blp',
'cover.blp',
'game-details.blp',
'game-item.blp',

View File

@@ -10,11 +10,6 @@ Adw.ShortcutsDialog shortcuts_dialog {
accelerator: "<Control>f";
}
Adw.ShortcutsItem {
title: _("Toggle Sidebar");
accelerator: "F9";
}
Adw.ShortcutsItem {
title: _("Main Menu");
accelerator: "F10";
@@ -49,13 +44,4 @@ Adw.ShortcutsDialog shortcuts_dialog {
accelerator: "Delete";
}
}
Adw.ShortcutsSection {
title: _("Collections");
Adw.ShortcutsItem {
title: _("Add Collection");
accelerator: "<Control><Shift>n";
}
}
}

View File

@@ -1,44 +0,0 @@
# SPDX-License-Identifier: GPL-3.0-or-later
# SPDX-FileCopyrightText: Copyright 2025 Jamie Gravendeel
from gi.repository import Adw, Gio, GObject, Gtk
from cartridges import sources
from cartridges.sources import Source
from cartridges.ui import games
class SourceSidebarItem(Adw.SidebarItem): # pyright: ignore[reportAttributeAccessIssue]
"""A sidebar item representing a source."""
model = GObject.Property(type=Gio.ListModel)
@GObject.Property(type=Source)
def source(self) -> Source:
"""The source that `self` represents."""
return self._source
@source.setter
def source(self, source: Source):
self._source = source
flags = GObject.BindingFlags.SYNC_CREATE
source.bind_property("name", self, "title", flags)
source.bind_property("icon-name", self, "icon-name", flags)
self.model = Gtk.FilterListModel(
model=source,
filter=games.filter_,
watch_items=True, # pyright: ignore[reportCallIssue]
)
# https://gitlab.gnome.org/GNOME/gtk/-/issues/7959
self.model.connect(
"items-changed",
lambda *_: self.set_property("visible", self.model.props.n_items),
)
self.props.visible = self.model.props.n_items
model = Gtk.SortListModel.new(
sources.model,
Gtk.StringSorter.new(Gtk.PropertyExpression.new(Source, None, "name")),
)

View File

@@ -15,7 +15,6 @@
}
/* https://gitlab.gnome.org/World/highscore/-/blob/cea3c7492d0b3c78a8b79ca60e9a86e8cdd4ceaf/src/library/library.css#L86 */
.controller-connected #grid > child:focus-within #cover,
#grid > child:focus-within:focus-visible #cover {
outline: 5px solid rgb(from var(--accent-color) r g b / 50%);
}
@@ -24,8 +23,7 @@
padding: 12px;
}
#game-item overlay > button,
#game-item overlay > menubutton > button {
#game-item button {
color: white;
backdrop-filter: blur(9px) brightness(30%) saturate(600%);
box-shadow:
@@ -38,12 +36,12 @@
opacity, transform;
}
#game-item overlay > button.hidden {
#game-item button.hidden {
opacity: 0;
transform: translateY(3px);
}
#game-item overlay > menubutton.hidden > button {
#game-item menubutton.hidden button {
opacity: 0;
transform: translateY(-6px);
}
@@ -75,13 +73,11 @@
}
@media (prefers-contrast: more) {
.controller-connected #grid > child:focus-within #cover,
#grid > child:focus-within:focus-visible #cover {
outline: 5px solid var(--accent-color);
}
#game-item overlay > button,
#game-item overlay > menubutton > button {
#game-item button {
backdrop-filter: blur(9px) brightness(20%) saturate(600%);
}

View File

@@ -1,7 +1,6 @@
<?xml version="1.0" encoding="UTF-8" ?>
<gresources>
<gresource prefix="@PREFIX@">
<file>collection-details.ui</file>
<file>cover.ui</file>
<file>game-details.ui</file>
<file>game-item.ui</file>

View File

@@ -2,7 +2,6 @@ using Gtk 4.0;
using Adw 1;
template $Window: Adw.ApplicationWindow {
realize => $_setup_gamepad_monitor();
title: _("Cartridges");
ShortcutController {
@@ -21,313 +20,217 @@ template $Window: Adw.ApplicationWindow {
action: "action(win.add)";
}
Shortcut {
trigger: "<Control><Shift>n";
action: "action(win.add-collection)";
arguments: "''";
}
Shortcut {
trigger: "<Control>z";
action: "action(win.undo)";
}
Shortcut {
trigger: "F9";
action: "action(win.show-sidebar)";
}
Shortcut {
trigger: "<Control>w";
action: "action(window.close)";
}
}
Adw.Breakpoint {
condition ("max-width: 730px") // 3 columns + 48px of inherent padding
setters {
split_view.collapsed: true;
}
}
content: Adw.NavigationView navigation_view {
Adw.NavigationPage {
title: bind template.title;
tag: "games";
styles [
"view",
]
child: Adw.OverlaySplitView split_view {
sidebar-width-unit: px;
min-sidebar-width: 224; // Width of 1 column
max-sidebar-width: bind split_view.min-sidebar-width;
child: Adw.ToolbarView {
[top]
Adw.HeaderBar {
title-widget: Adw.Clamp clamp {
tightening-threshold: bind clamp.maximum-size;
sidebar: Adw.NavigationPage {
title: bind template.title;
child: CenterBox {
hexpand: true;
child: Adw.ToolbarView {
[top]
Adw.HeaderBar {
show-title: bind $_show_sidebar_title(template.settings as <Settings>.gtk-decoration-layout) as <bool>;
[start]
Button {
visible: bind split_view.show-sidebar;
action-name: "win.show-sidebar";
icon-name: "sidebar-show-symbolic";
tooltip-text: _("Toggle Sidebar");
}
}
content: Adw.Sidebar sidebar {
activated => $_navigate();
setup-menu => $_setup_sidebar_menu();
notify::selected => $_update_selection();
Adw.SidebarSection {
Adw.SidebarItem {
icon-name: "view-grid-symbolic";
title: _("All Games");
}
}
Adw.SidebarSection sources {
title: _("Sources");
}
Adw.SidebarSection collections {
title: _("Collections");
menu-model: menu collection_menu {};
}
Adw.SidebarSection {
Adw.SidebarItem new_collection_item {
icon-name: "list-add-symbolic";
title: _("New Collection");
}
}
};
};
};
content: Adw.NavigationPage {
title: _("Games");
child: Adw.ToolbarView {
[top]
Adw.HeaderBar header_bar {
title-widget: Adw.Clamp clamp {
tightening-threshold: bind clamp.maximum-size;
child: CenterBox title_box {
hexpand: true;
center-widget: SearchEntry search_entry {
hexpand: true;
placeholder-text: _("Search games");
search-started => $_search_started();
search-changed => $_search_changed();
activate => $_search_activate();
stop-search => $_stop_search();
};
end-widget: MenuButton sort_button {
icon-name: "filter-symbolic";
tooltip-text: _("Sort & Filter");
margin-start: 6;
menu-model: menu {
section {
label: _("Sort");
item {
label: _("Last Played");
action: "win.sort-mode";
target: "last_played";
}
item {
label: _("A-Z");
action: "win.sort-mode";
target: "a-z";
}
item {
label: _("Z-A");
action: "win.sort-mode";
target: "z-a";
}
item {
label: _("Newest");
action: "win.sort-mode";
target: "newest";
}
item {
label: _("Oldest");
action: "win.sort-mode";
target: "oldest";
}
}
section {
item (_("Show Hidden Games"), "win.show-hidden")
}
};
};
};
center-widget: SearchEntry search_entry {
hexpand: true;
placeholder-text: _("Search games");
search-started => $_search_started();
search-changed => $_search_changed();
activate => $_search_activate();
stop-search => $_stop_search();
};
[start]
Button {
visible: bind split_view.show-sidebar inverted;
action-name: "win.show-sidebar";
icon-name: "sidebar-show-symbolic";
tooltip-text: _("Toggle Sidebar");
}
[start]
Button {
icon-name: "list-add-symbolic";
tooltip-text: _("Add Game");
action-name: "win.add";
}
[end]
MenuButton main_menu_button {
icon-name: "open-menu-symbolic";
tooltip-text: _("Main Menu");
primary: true;
end-widget: MenuButton {
icon-name: "filter-symbolic";
tooltip-text: _("Sort & Filter");
margin-start: 6;
menu-model: menu {
item (_("Keyboard Shortcuts"), "app.shortcuts")
item (_("About Cartridges"), "app.about")
section {
label: _("Sort");
item {
label: _("Last Played");
action: "win.sort-mode";
target: "last_played";
}
item {
label: _("A-Z");
action: "win.sort-mode";
target: "a-z";
}
item {
label: _("Z-A");
action: "win.sort-mode";
target: "z-a";
}
item {
label: _("Newest");
action: "win.sort-mode";
target: "newest";
}
item {
label: _("Oldest");
action: "win.sort-mode";
target: "oldest";
}
}
section {
item (_("Show Hidden Games"), "win.show-hidden")
}
};
}
}
};
};
};
content: Adw.ToastOverlay toast_overlay {
child: Adw.ViewStack {
visible-child-name: bind $if_else(
grid.model as <NoSelection>.n-items,
"grid",
$if_else(
template.search-text,
"empty-search",
$if_else(
template.collection,
"empty-collection",
$if_else(template.show-hidden, "empty-hidden", "empty") as <string>
) as <string>
) as <string>
) as <string>;
[start]
Button {
icon-name: "list-add-symbolic";
tooltip-text: _("Add Game");
action-name: "win.add";
}
Adw.ViewStackPage {
[end]
MenuButton {
icon-name: "open-menu-symbolic";
tooltip-text: _("Main Menu");
primary: true;
menu-model: menu {
item (_("Keyboard Shortcuts"), "app.shortcuts")
item (_("About Cartridges"), "app.about")
};
}
}
content: Adw.ToastOverlay toast_overlay {
child: Adw.ViewStack {
enable-transitions: true;
visible-child-name: bind $_if_else(
grid.model as <NoSelection>.n-items,
"grid",
$_if_else(
template.search-text,
"empty-search",
$_if_else(template.show-hidden, "empty-hidden", "empty") as <string>
) as <string>
) as <string>;
Adw.ViewStackPage {
name: "grid";
child: ScrolledWindow {
hscrollbar-policy: never;
child: GridView grid {
name: "grid";
single-click-activate: true;
activate => $_show_details();
child: ScrolledWindow {
hscrollbar-policy: never;
model: NoSelection {
model: SortListModel {
sorter: $GameSorter sorter {};
child: GridView grid {
name: "grid";
single-click-activate: true;
activate => $_show_details();
model: FilterListModel {
watch-items: true;
model: NoSelection {
model: SortListModel {
sorter: $GameSorter sorter {};
filter: EveryFilter {
AnyFilter {
StringFilter {
expression: expr item as <$Game>.name;
search: bind template.search-text;
}
model: FilterListModel {
watch-items: true;
StringFilter {
expression: expr item as <$Game>.developer;
search: bind template.search-text;
}
}
filter: EveryFilter {
$CollectionFilter collection_filter {
collection: bind template.collection;
}
BoolFilter {
expression: expr item as <$Game>.hidden;
invert: bind template.show-hidden inverted;
}
AnyFilter {
StringFilter {
expression: expr item as <$Game>.name;
search: bind template.search-text;
}
BoolFilter {
expression: expr item as <$Game>.removed;
invert: true;
}
StringFilter {
expression: expr item as <$Game>.developer;
search: bind template.search-text;
}
}
BoolFilter {
expression: expr item as <$Game>.hidden;
invert: bind template.show-hidden inverted;
}
};
model: bind template.model;
};
BoolFilter {
expression: expr item as <$Game>.blacklisted;
invert: true;
}
};
};
factory: BuilderListItemFactory {
template ListItem {
child: $GameItem {
game: bind template.item;
position: bind template.position;
};
}
model: bind template.games;
};
};
};
}
Adw.ViewStackPage {
name: "empty-search";
child: Adw.StatusPage {
icon-name: "edit-find-symbolic";
title: _("No Games Found");
description: _("Try a different search");
factory: BuilderListItemFactory {
template ListItem {
child: $GameItem {
game: bind template.item;
position: bind template.position;
};
}
};
}
Adw.ViewStackPage {
name: "empty-hidden";
child: Adw.StatusPage {
icon-name: "view-conceal-symbolic";
title: _("No Hidden Games");
description: _("Games you hide will appear here");
};
}
Adw.ViewStackPage {
name: "empty-collection";
child: Adw.StatusPage {
icon-name: bind template.collection as <$Collection>.icon-name;
title: bind $format_string(
_("No Games in {}"),
template.collection as <$Collection>.name,
) as <string>;
};
}
Adw.ViewStackPage {
name: "empty";
child: Adw.StatusPage {
icon-name: bind template.application as <Application>.application-id;
title: _("No Games");
description: _("Use the + button to add games");
};
}
};
};
};
}
Adw.ViewStackPage {
name: "empty-search";
child: Adw.StatusPage {
icon-name: "edit-find-symbolic";
title: _("No Games Found");
description: _("Try a different search");
};
}
Adw.ViewStackPage {
name: "empty-hidden";
child: Adw.StatusPage {
icon-name: "view-conceal-symbolic";
title: _("No Hidden Games");
description: _("Games you hide will appear here");
};
}
Adw.ViewStackPage {
name: "empty";
child: Adw.StatusPage {
icon-name: bind template.application as <Application>.application-id;
title: _("No Games");
description: _("Use the + button to add games");
};
}
};
};
};

View File

@@ -3,29 +3,18 @@
# SPDX-FileCopyrightText: Copyright 2022-2025 kramo
# SPDX-FileCopyrightText: Copyright 2025 Jamie Gravendeel
import sys
from collections.abc import Callable
from gettext import gettext as _
from typing import Any, cast
from typing import Any, TypeVar, cast
from gi.repository import Adw, Gio, GLib, GObject, Gtk
from cartridges import STATE_SETTINGS
from cartridges.collections import Collection
from cartridges import games, state_settings
from cartridges.config import PREFIX, PROFILE
from cartridges.sources import Source, imported
from cartridges.ui import closures, collections, games, sources
from cartridges.games import Game, GameSorter
from .collection_details import CollectionDetails
from .collections import CollectionFilter, CollectionSidebarItem
from .game_details import GameDetails
from .game_item import GameItem # noqa: F401
from .games import GameSorter
from .sources import SourceSidebarItem
if sys.platform.startswith("linux"):
from cartridges import gamepads
from cartridges.gamepads import Gamepad
SORT_MODES = {
"last_played": ("last-played", True),
@@ -35,6 +24,7 @@ SORT_MODES = {
"oldest": ("added", True),
}
_T = TypeVar("_T")
type _UndoFunc = Callable[[], Any]
@@ -44,58 +34,20 @@ class Window(Adw.ApplicationWindow):
__gtype_name__ = __qualname__
split_view: Adw.OverlaySplitView = Gtk.Template.Child()
sidebar: Adw.Sidebar = Gtk.Template.Child() # pyright: ignore[reportAttributeAccessIssue]
sources: Adw.SidebarSection = Gtk.Template.Child() # pyright: ignore[reportAttributeAccessIssue]
collections: Adw.SidebarSection = Gtk.Template.Child() # pyright: ignore[reportAttributeAccessIssue]
new_collection_item: Adw.SidebarItem = Gtk.Template.Child() # pyright: ignore[reportAttributeAccessIssue]
collection_menu: Gio.Menu = Gtk.Template.Child()
navigation_view: Adw.NavigationView = Gtk.Template.Child()
header_bar: Adw.HeaderBar = Gtk.Template.Child()
title_box: Gtk.CenterBox = Gtk.Template.Child()
search_entry: Gtk.SearchEntry = Gtk.Template.Child()
sort_button: Gtk.MenuButton = Gtk.Template.Child()
main_menu_button: Gtk.MenuButton = Gtk.Template.Child()
toast_overlay: Adw.ToastOverlay = Gtk.Template.Child()
search_entry: Gtk.SearchEntry = Gtk.Template.Child()
grid: Gtk.GridView = Gtk.Template.Child()
sorter: GameSorter = Gtk.Template.Child()
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)
settings = GObject.Property(type=Gtk.Settings)
_collection: Collection | None = None
_collection_removed_signal: int | None = None
_selected_sidebar_item = 0
format_string = closures.format_string
if_else = closures.if_else
@GObject.Property(type=Collection)
def collection(self) -> Collection | None:
"""The currently selected collection."""
return self._collection
@collection.setter
def collection(self, collection: Collection | None):
if self._collection and self._collection_removed_signal:
self._collection.disconnect(self._collection_removed_signal)
self._collection = collection
self._collection_removed_signal = (
collection.connect("notify::removed", lambda *_: self._collection_removed())
if collection
else None
)
def _collection_removed(self):
self.collection = None
self.sidebar.props.selected = 0
@GObject.Property(type=Gio.ListStore)
def games(self) -> Gio.ListStore:
"""Model of the user's games."""
return games.model
def __init__(self, **kwargs: Any):
super().__init__(**kwargs)
@@ -103,52 +55,20 @@ class Window(Adw.ApplicationWindow):
if PROFILE == "development":
self.add_css_class("devel")
self.settings = self.get_settings()
flags = Gio.SettingsBindFlags.DEFAULT
STATE_SETTINGS.bind("width", self, "default-width", flags)
STATE_SETTINGS.bind("height", self, "default-height", flags)
STATE_SETTINGS.bind("is-maximized", self, "maximized", flags)
STATE_SETTINGS.bind("show-sidebar", self.split_view, "show-sidebar", flags)
state_settings.bind("width", self, "default-width", flags)
state_settings.bind("height", self, "default-height", flags)
state_settings.bind("is-maximized", self, "maximized", flags)
# https://gitlab.gnome.org/GNOME/gtk/-/issues/7901
self.search_entry.set_key_capture_widget(self)
self.sources.bind_model(sources.model, self._create_source_item)
self.collections.bind_model(
collections.model,
lambda collection: CollectionSidebarItem(collection=collection),
)
self.add_action(STATE_SETTINGS.create_action("show-sidebar"))
self.add_action(STATE_SETTINGS.create_action("sort-mode"))
self.add_action(state_settings.create_action("sort-mode"))
self.add_action(Gio.PropertyAction.new("show-hidden", self, "show-hidden"))
self.add_action_entries((
("search", lambda *_: self.search_entry.grab_focus()),
(
"edit",
lambda _action, param, *_: self._edit(param.get_uint32()),
"u",
),
("edit", lambda _action, param, *_: self._edit(param.get_uint32()), "u"),
("add", lambda *_: self._add()),
(
"add-collection",
lambda _action, param, *_: self._add_collection(param.get_string()),
"s",
),
(
"edit-collection",
lambda _action, param, *_: self._edit_collection(param.get_uint32()),
"u",
),
(
"remove-collection",
lambda _action, param, *_: self._remove_collection(param.get_uint32()),
"u",
),
(
"notify-collection-filter",
lambda *_: self.collection_filter.changed(Gtk.FilterChange.DIFFERENT),
),
("undo", lambda *_: self._undo()),
))
@@ -159,7 +79,7 @@ class Window(Adw.ApplicationWindow):
Optionally display a button allowing the user to `undo` an operation.
"""
toast = Adw.Toast(title=title, use_markup=False)
toast = Adw.Toast.new(title)
if undo:
toast.props.button_label = _("Undo")
toast.props.priority = Adw.ToastPriority.HIGH
@@ -168,72 +88,9 @@ class Window(Adw.ApplicationWindow):
self.toast_overlay.add_toast(toast)
def _create_source_item(self, source: Source) -> SourceSidebarItem:
item = SourceSidebarItem(source=source)
item.connect(
"notify::visible",
lambda item, _: self._source_empty() if not item.props.visible else None,
)
return item
def _source_empty(self):
self.model = games.model
self.sidebar.props.selected = 0
@Gtk.Template.Callback()
def _show_sidebar_title(self, _obj, layout: str) -> bool:
right_window_controls = layout.replace("appmenu", "").startswith(":")
return right_window_controls and not sys.platform.startswith("darwin")
@Gtk.Template.Callback()
def _navigate(self, sidebar: Adw.Sidebar, index: int): # pyright: ignore[reportAttributeAccessIssue]
item = sidebar.get_item(index)
match item:
case self.new_collection_item:
self._add_collection()
sidebar.props.selected = self._selected_sidebar_item
case SourceSidebarItem():
self.collection = None
self.model = item.model
case CollectionSidebarItem():
self.collection = item.collection
self.model = games.model
case _:
self.collection = None
self.model = games.model
if item is not self.new_collection_item:
self._selected_sidebar_item = index
if self.split_view.props.collapsed:
self.split_view.props.show_sidebar = False
@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_sidebar_menu(self, _sidebar, item: Adw.SidebarItem): # pyright: ignore[reportAttributeAccessIssue]
if isinstance(item, CollectionSidebarItem):
menu = self.collection_menu
menu.remove_all()
menu.append(
_("Edit"),
f"win.edit-collection(uint32 {item.get_section_index()})",
)
menu.append(
_("Remove"),
f"win.remove-collection(uint32 {item.get_section_index()})",
)
@Gtk.Template.Callback()
def _setup_gamepad_monitor(self, *_args):
if sys.platform.startswith("linux"):
Gamepad.window = self # pyright: ignore[reportPossiblyUnboundVariable]
gamepads.setup_monitor() # pyright: ignore[reportPossiblyUnboundVariable]
def _if_else(self, _obj, condition: object, first: _T, second: _T) -> _T:
return first if condition else second
@Gtk.Template.Callback()
def _show_details(self, grid: Gtk.GridView, position: int):
@@ -268,34 +125,13 @@ class Window(Adw.ApplicationWindow):
self.details.edit()
def _add(self):
self.details.game = imported.new()
self.details.game = Game.for_editing()
if self.navigation_view.props.visible_page_tag != "details":
self.navigation_view.push_by_tag("details")
self.details.edit()
def _add_collection(self, game_id: str | None = None):
collection = Collection()
if game_id:
collection.game_ids.add(game_id)
details = CollectionDetails(collection=collection)
details.present(self)
def _edit_collection(self, pos: int):
collection = self.collections.get_item(pos).collection
details = CollectionDetails(collection=collection)
details.connect(
"sort-changed",
lambda *_: collections.sorter.changed(Gtk.SorterChange.DIFFERENT),
)
details.present(self)
def _remove_collection(self, pos: int):
collection = self.collections.get_item(pos).collection
collection.activate_action("remove")
def _undo(self, toast: Adw.Toast | None = None):
if toast:
self._history.pop(toast)()

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path fill="#222" d="M13.754 4.668c.176-.2.262-.461.246-.723a1 1 0 0 0-.34-.687 1 1 0 0 0-.726-.246 1 1 0 0 0-.688.34L5.95 10.547 3.707 8.3A1 1 0 0 0 2 9.01a1 1 0 0 0 .293.708l3 3c.195.195.465.3.742.293.278-.012.535-.133.719-.344zm0 0"/></svg>

Before

Width:  |  Height:  |  Size: 326 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path fill="#222" d="M8 0C3.594 0 0 3.594 0 8s3.594 8 8 8 8-3.594 8-8-3.594-8-8-8m.469 2.02a5.98 5.98 0 0 1 5.511 5.52q-3.109.234-4.718 1.94c-1.055 1.114-1.586 2.649-1.723 4.5A5.98 5.98 0 0 1 2.02 8.47c2.06-.125 3.626-.656 4.708-1.739 1.085-1.085 1.617-2.652 1.742-4.71m-1.004.007c-.121 1.864-.598 3.149-1.445 3.996-.844.844-2.13 1.32-3.997 1.446a5.98 5.98 0 0 1 5.442-5.442m6.508 6.516a5.98 5.98 0 0 1-5.434 5.434c.137-1.653.61-2.922 1.45-3.809.855-.91 2.136-1.477 3.984-1.625m0 0"/></svg>

Before

Width:  |  Height:  |  Size: 573 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path fill="#222" d="M3.02 2a1 1 0 0 0-.707 1.707L6.605 8l-4.292 4.293a1 1 0 1 0 1.414 1.414L8.02 9.414l4.293 4.293a1 1 0 1 0 1.414-1.414L9.434 8l4.293-4.293a1 1 0 1 0-1.415-1.414L8.02 6.586 3.727 2.293A1 1 0 0 0 3.02 2m0 0"/></svg>

Before

Width:  |  Height:  |  Size: 315 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:gpa="https://www.gtk.org/grappa" gpa:keywords="car vehicle ice combustion engine internal" gpa:state="0" gpa:version="1" width="16" height="16"><path gpa:fill="none" gpa:stroke="foreground" gpa:stroke-width="0.25 1 1.5" fill="none" stroke="url(&quot;#gpa:foreground&quot;) rgb(0,0,0)" stroke-dashoffset="3.2" stroke-linecap="round" stroke-linejoin="round" d="M6 13.496h4" class="transparent-fill foreground-stroke"/><path gpa:fill="none" gpa:stroke="foreground" gpa:stroke-width="0.5 2 3" fill="none" stroke="url(&quot;#gpa:foreground&quot;) rgb(0,0,0)" stroke-dashoffset="3.2" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 13.5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0m7 0a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0M1 9h11.486A2.514 2.514 0 0 1 15 11.514 2.256 2.256 0 0 1 13 14M3 14a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h5c1.226 0 2.346.693 2.894 1.789L12 9" class="transparent-fill foreground-stroke"/></svg>

Before

Width:  |  Height:  |  Size: 961 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path fill="#222" d="M6 0v4H5v2H4v9H3V6H0v10h16v-1h-1v-4h-1V3l-3 3v5h-1v4H9V6H8V4H7V0zm6.5 6a.499.499 0 1 1 0 1 .499.499 0 1 1 0-1M1 7h1v1H1zm4 0h3v1H5zm7.5 1a.499.499 0 1 1 0 1 .499.499 0 1 1 0-1M1 9h1v1H1zm4 0h3v1H5zm7.5 1.008a.499.499 0 1 1-.5.5c0-.278.223-.5.5-.5M1 11h1v1H1zm4 0h3v1H5zm6 1h3v1h-3zM1 13h1v1H1zm4 0h3v1H5zm0 0"/></svg>

Before

Width:  |  Height:  |  Size: 421 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path fill="#222" d="M1.5 2h2c.277 0 .5.223.5.5v12c0 .277-.223.5-.5.5h-2a.5.5 0 0 1-.5-.5v-12c0-.277.223-.5.5-.5m4 2h1c.277 0 .5.223.5.5v10c0 .277-.223.5-.5.5h-1a.5.5 0 0 1-.5-.5v-10c0-.277.223-.5.5-.5m3-1h1c.277 0 .5.223.5.5v11c0 .277-.223.5-.5.5h-1a.5.5 0 0 1-.5-.5v-11c0-.277.223-.5.5-.5m2.207-1.54.965-.26a.505.505 0 0 1 .613.355l3.363 12.558a.497.497 0 0 1-.355.61l-.965.261a.506.506 0 0 1-.613-.355L10.352 2.074a.5.5 0 0 1 .355-.613m0 0"/></svg>

Before

Width:  |  Height:  |  Size: 534 B

View File

@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path fill="#222" d="M1.5 2h13a1.5 1.5 0 0 1 0 3h-13a1.5 1.5 0 0 1 0-3m3 5h7a1.5 1.5 0 0 1 0 3h-7a1.5 1.5 0 0 1 0-3m3 5h1a1.5 1.5 0 0 1 0 3h-1a1.5 1.5 0 0 1 0-3m0 0"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><g fill="#222"><path d="M1.5 2h13a1.5 1.5 0 0 1 0 3h-13a1.5 1.5 0 0 1 0-3M4.5 7h7a1.5 1.5 0 0 1 0 3h-7a1.5 1.5 0 0 1 0-3M7.5 12h1a1.5 1.5 0 0 1 0 3h-1a1.5 1.5 0 0 1 0-3m0 0"/></g></svg>

Before

Width:  |  Height:  |  Size: 256 B

After

Width:  |  Height:  |  Size: 268 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path fill="#222" d="M1.977 7c-.551 0-1 .45-1 1v1.617c0 1.098.32 2.168.921 3.086l.114.172A4.72 4.72 0 0 0 5.949 15H12c1.645 0 3-1.355 3-3V9.914c-.004-.008 0-.008 0-.016 0-.023-.012-.043-.012-.066v-.016c0-.023-.011-.043-.015-.066-.012-.027-.016-.055-.024-.082l-.031-.074s0-.012-.008-.016q-.019-.029-.031-.062s0-.008-.012-.012q-.016-.03-.039-.059-.023-.036-.047-.066-.02-.026-.043-.05c0-.009-.011-.009-.015-.017l-.008-.007q-.026-.029-.05-.051l-.013-.008a.3.3 0 0 0-.054-.043s-.008-.012-.012-.012l-.059-.039-.011-.011-.063-.032-.074-.035c-.012 0-.012-.008-.016-.008q-.03-.017-.066-.027h-.012a.4.4 0 0 0-.066-.016c-.024-.011-.047-.011-.067-.015h-.047c-.023 0-.046-.008-.066-.008H8.703a2 2 0 0 1-1.73.996h-2v1c-.551 0-1-.45-1-1v-1h3a1 1 0 0 0 .304-.047c.028-.008.051-.02.078-.027.29-.121.508-.371.586-.676.012-.027.012-.055.02-.082q.012-.082.012-.168a1 1 0 0 0-1-.996zM8 2c-.555 0-1 .445-1 1v3h2V3a1 1 0 0 0-1-1m2.973-.004a1 1 0 0 0-1 1V7a1 1 0 1 0 2 0V2.996c0-.55-.45-1-1-1m3 1.5a1 1 0 0 0-1 1V7a1 1 0 1 0 2 0V4.496c0-.55-.45-1-1-1m-9-.496c-.551 0-1 .45-1 1v2h2V4a1 1 0 0 0-1-1m0 0"/></svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path fill="#222" d="M4.945 0C4 0 4 1 4 1h8s0-1-1-1zM4 2a4 4 0 0 0 2 3.465V15s-.023 1 2 1c1.922 0 2-1 2-1l.004-9.54A4 4 0 0 0 12 2zm4 4.5c.555 0 1 .445 1 1v1c0 .555-.445 1-1 1s-1-.445-1-1v-1c0-.555.445-1 1-1m0 0"/></svg>

Before

Width:  |  Height:  |  Size: 303 B

View File

@@ -1 +0,0 @@
<svg xmlns:gpa="https://www.gtk.org/grappa" gpa:keywords="input-gaming applications-games controller gamepad" gpa:state="0" gpa:version="1" width="16" height="16"><path fill="none" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M1 5.51V11a3 3 0 0 0 3 3c.552 0 1-.336 1-1a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2c0 .717.53 1 1 1a3 3 0 0 0 3-3V5.51A4.51 4.51 0 0 0 10.49 1H5.51A4.51 4.51 0 0 0 1 5.51M12.025 6h.024M10 4h.025M4 6h4M6 4v4" class="foreground-stroke transparent-fill"/></svg>

Before

Width:  |  Height:  |  Size: 509 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path fill="#222" d="M7.52-.004c-4.13 0-7.5 3.371-7.5 7.5s3.37 7.5 7.5 7.5c4.128 0 7.5-3.371 7.5-7.5s-3.372-7.5-7.5-7.5m0 2c.253 0 .503.024.75.055.19.261.382.594.55 1.027.106.277.203.586.29.918H5.93q.13-.5.289-.918c.168-.433.36-.765.547-1.027.25-.031.496-.055.754-.055m-2.09.406c-.047.11-.102.203-.145.317-.148.386-.27.82-.379 1.277h-1.62A5.5 5.5 0 0 1 5.43 2.402m4.175 0a5.5 5.5 0 0 1 2.145 1.594h-1.617a10 10 0 0 0-.38-1.277c-.042-.114-.097-.207-.148-.317M2.621 4.996h2.086c-.098.629-.148 1.3-.168 2H2.055a5.6 5.6 0 0 1 .566-2m3.098 0h3.597c.106.617.16 1.293.184 2H5.54c.022-.707.077-1.383.179-2m4.613 0h2.082c.313.61.504 1.285.566 2H10.5c-.02-.7-.07-1.371-.168-2m-8.277 3h2.484c.02.7.07 1.375.168 2H2.621a5.6 5.6 0 0 1-.566-2m3.484 0H9.5a15 15 0 0 1-.184 2H5.72a16 16 0 0 1-.18-2m4.961 0h2.48a5.4 5.4 0 0 1-.566 2h-2.082c.098-.625.148-1.3.168-2m-7.215 3h1.621c.11.457.23.89.38 1.274.042.117.097.21.144.32a5.5 5.5 0 0 1-2.145-1.594m2.645 0h3.18q-.13.5-.29.918a4.4 4.4 0 0 1-.55 1.027 6 6 0 0 1-.75.055c-.254 0-.504-.023-.75-.055a4.4 4.4 0 0 1-.551-1.027 9 9 0 0 1-.29-.918m4.203 0h1.617a5.5 5.5 0 0 1-2.145 1.594c.051-.11.106-.203.149-.32.148-.383.27-.817.379-1.274m0 0"/></svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path fill="#222" d="M5 2.988V4.98a1 1 0 0 0-1-.992H1v1h2c.55 0 1 .45 1 1v.395a5.27 5.27 0 0 0-2.543 2.18l-.75 1.25A4.9 4.9 0 0 0 0 12.359v.63c0 .55.45 1 1 1h3c.55 0 1-.45 1-1v-3c0-.552.45-1 1-1 .195.98.96 1.792 2 2v-2h3v-3h5v-3h-1l-1 1h-3v-1zm2 1h3v1H7zm0 2h3v1H7zm0 0"/></svg>

Before

Width:  |  Height:  |  Size: 361 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:gpa="https://www.gtk.org/grappa" gpa:keywords="heart emote-love" gpa:state="0" gpa:version="1" width="16" height="16"><path gpa:fill="none" gpa:states="0" gpa:stroke="foreground" gpa:stroke-width="0.5 2 3" fill="none" stroke="url(&quot;#gpa:foreground&quot;) rgb(0,0,0)" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 5.757A3.763 3.763 0 0 0 11.23 2C9.86 2 8.66 2.73 8 3.822A3.77 3.77 0 0 0 4.77 2 3.763 3.763 0 0 0 1 5.757c0 1.038.43 2.03 1.19 2.738h-.001l5.725 5.496 5.898-5.496h-.002c.76-.708 1.19-1.7 1.19-2.738" class="transparent-fill foreground-stroke"/></svg>

Before

Width:  |  Height:  |  Size: 637 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none"><path fill="#000" fill-rule="evenodd" d="m7.872 16-3.817-3.083L2 2.79 7.872 0l5.871 2.789-2.055 10.128zm0-4.257-.294-.293-.88-7.927 1.1-1.908 1.174 1.908-.807 7.927zm-.294.367-.147.367-1.761.294-.294-.66.294-.662 1.761.294zm-.073.734-.22 1.541.587.294.587-.294-.22-1.541-.367-.22zm.807-.367-.147-.367.147-.367 1.761-.293.294.66-.294.66z" clip-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 440 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><g fill="#222"><path d="M4.523 5.79C3.95 8.43 5.32 11 9 11l-.45-.105 4 2c.434.214.962.093 1.25-.293l1.5-2c.235-.317.266-.743.075-1.086l-5-9a1 1 0 0 0-1.43-.348l-1.5 1L8.008 1 7.156.992a5.724 5.724 0 0 0-5.726 4.93L.164 14.859A1.006 1.006 0 0 0 1.156 16H8c.55 0 1-.45 1-1v-2c0-.55-.45-1-1-1s-1 .45-1 1v2l1-1H1.156l.989 1.14L3.41 6.204a3.72 3.72 0 0 1 3.73-3.21L7.993 3c.2 0 .395-.059.563-.168l1.5-1-1.43-.348 5 9 .074-1.086-1.5 2 1.246-.293-4-2A1 1 0 0 0 9 9c-1.285 0-1.898-.371-2.242-.828-.34-.457-.457-1.14-.281-1.961a.996.996 0 0 0-.766-1.188.996.996 0 0 0-1.188.766m0 0"/><path d="M10 6c0 .55-.45 1-1 1s-1-.45-1-1 .45-1 1-1 1 .45 1 1m0 0"/></g></svg>

Before

Width:  |  Height:  |  Size: 736 B

View File

@@ -1,34 +1,6 @@
<?xml version="1.0" encoding="UTF-8" ?>
<gresources>
<gresource prefix="@PREFIX@/icons/scalable/actions">
<file>apply-symbolic.svg</file>
<file>cancel-symbolic.svg</file>
<file>filter-symbolic.svg</file>
<!-- Sources -->
<file>heroic-symbolic.svg</file>
<file>imported-symbolic.svg</file>
<file>steam-symbolic.svg</file>
<!-- Categories -->
<file>collection-symbolic.svg</file>
<file>ball-symbolic.svg</file>
<file>car-symbolic.svg</file>
<file>city-symbolic.svg</file>
<file>fist-symbolic.svg</file>
<file>flashlight-symbolic.svg</file>
<file>gamepad-symbolic.svg</file>
<file>globe-symbolic.svg</file>
<file>gun-symbolic.svg</file>
<file>heart-symbolic.svg</file>
<file>horse-symbolic.svg</file>
<file>knife-symbolic.svg</file>
<file>map-symbolic.svg</file>
<file>music-symbolic.svg</file>
<file>people-symbolic.svg</file>
<file>private-symbolic.svg</file>
<file>puzzle-symbolic.svg</file>
<file>skull-symbolic.svg</file>
<file>sprout-symbolic.svg</file>
<file>star-symbolic.svg</file>
<file>step-over-symbolic.svg</file>
</gresource>
</gresources>

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path fill="#222" d="M7 1v6H1v2h6v6h2V9h6V7H9V1zm0 0"/></svg>

Before

Width:  |  Height:  |  Size: 144 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path fill="#222" d="M14.285.582a1.5 1.5 0 0 0-1.015.441L9.734 4.56l1.414 1.414 2.536-2.535a1.5 1.5 0 1 0 .601-2.856M9.027 5.266.543 13.754c.707.703 5.656 1.41 12.02-4.953zm0 0"/></svg>

Before

Width:  |  Height:  |  Size: 268 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path fill="#222" d="M15 2v10l-5 3-5-3-5 3V5l5-3 5 3zM5 3v8l5 3V6zm0 0"/></svg>

Before

Width:  |  Height:  |  Size: 162 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:gpa="https://www.gtk.org/grappa" gpa:keywords="folder-music note folder sound tune audio-x-generic" gpa:state="0" gpa:version="1" width="16" height="16"><path fill="none" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14 11.485V4a2 2 0 0 0-2-2H8a2 2 0 0 0-2 2l.001 7.539M14 11.485a2.485 2.485 0 1 1-4.97 0 2.485 2.485 0 0 1 4.97 0m-7.999.054C6.001 12.91 4.86 14 3.487 14a2.485 2.485 0 0 1 0-4.97C4.859 9.03 6 10.166 6 11.539" class="foreground-stroke transparent-fill"/></svg>

Before

Width:  |  Height:  |  Size: 557 B

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 5.7 KiB

After

Width:  |  Height:  |  Size: 5.8 KiB

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:gpa="https://www.gtk.org/grappa" gpa:keywords="people human person user account system-users" gpa:state="0" gpa:version="1" width="16" height="16"><path fill="none" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.282 8A3.04 3.04 0 0 0 1 10.499V12h2m2-5.557a2.722 2.722 0 1 1 2.175-4.358M6.203 11a3.04 3.04 0 0 0-1.282 2.499V15H15v-1.501c0-1.12-.588-2.096-1.459-2.613m-.82-3.164a2.722 2.722 0 1 1-5.443 0 2.722 2.722 0 0 1 5.444 0" class="foreground-stroke transparent-fill"/></svg>

Before

Width:  |  Height:  |  Size: 563 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:gpa="https://www.gtk.org/grappa" gpa:keywords="privacy private window session browser hat glasses tracking" gpa:state="1" gpa:version="1" width="16" height="16"><path gpa:fill="none" gpa:stroke="foreground" gpa:stroke-width="0.5 2 3" fill="none" stroke="url(&quot;#gpa:foreground&quot;) rgb(0,0,0)" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m4 7 .66-3.297a2.123 2.123 0 0 1 4.033-.42L9 4a1.503 1.503 0 0 1 2.89.041L13 7M2 7h12" class="transparent-fill foreground-stroke"/><path gpa:fill="none" gpa:stroke="foreground" gpa:stroke-width="0.25 0.999998 1.5" fill="none" stroke="url(&quot;#gpa:foreground&quot;) rgb(0,0,0)" stroke-linejoin="round" d="M6.475 11.643c.18-.775.887-1.123 1.584-1.123.696 0 1.312.48 1.468 1.152m3.973-.162a1.99 1.99 0 1 1-3.98 0 1.99 1.99 0 0 1 3.98 0Zm-7.02 0a1.99 1.99 0 1 1-3.98 0 1.99 1.99 0 0 1 3.98 0Z" class="transparent-fill foreground-stroke"/></svg>

Before

Width:  |  Height:  |  Size: 954 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path fill="#222" d="M6.5 1C5.668 1 5 1.668 5 2.5V4H2c-.555 0-1 .445-1 1v3h1.5C3.332 8 4 8.668 4 9.5S3.332 11 2.5 11H1v3c0 .555.445 1 1 1h3v-1.5c0-.832.668-1.5 1.5-1.5s1.5.668 1.5 1.5V15h3c.555 0 1-.445 1-1v-3h1.5c.832 0 1.5-.668 1.5-1.5S14.332 8 13.5 8H12V5c0-.555-.445-1-1-1H8V2.5C8 1.668 7.332 1 6.5 1m0 0"/></svg>

Before

Width:  |  Height:  |  Size: 400 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><g fill="#222"><path d="M0 7c0 2.414 1.059 3.66 2.5 4.492l-.5-.867V13.5C2 14.883 3.117 16 4.5 16S7 14.883 7 13.5V13H5v.5C5 14.883 6.117 16 7.5 16s2.5-1.117 2.5-2.5V13H8v.5c0 1.383 1.117 2.5 2.5 2.5s2.5-1.117 2.5-2.496l.008-1.938-.559.895C15.391 11.016 16 9.328 16 7c0-3.957-3.656-7-8-7-4.348 0-8 3.043-8 7m14 0c0 .934-.105 1.547-.41 2.063-.3.515-.86 1.03-2.024 1.605q-.274.133-.554.254L11 13.5c0 .293-.207.5-.5.5s-.5-.207-.5-.5V13H8v.5c0 .293-.207.5-.5.5s-.5-.207-.5-.5V13H5v.5c0 .293-.207.5-.5.5s-.5-.207-.5-.5v-3.48a8 8 0 0 1-.5-.262c-.508-.293-.852-.586-1.094-.977S2 7.863 2 7c0-2.7 2.582-5 6-5s6 2.3 6 5m0 0"/><path fill-rule="evenodd" d="M6 7c0 .55-.45 1-1 1s-1-.45-1-1 .45-1 1-1 1 .45 1 1m6 0c0 .55-.45 1-1 1s-1-.45-1-1 .45-1 1-1 1 .45 1 1m0 0"/></g></svg>

Before

Width:  |  Height:  |  Size: 845 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path fill="#222" d="M15 0c-3.512 0-6.312 1.434-6.89 4.668C6.925 2.84 4.671 2 2 2c-.55 0-1 .45-1 1 0 4.328 2.613 6.95 6 6.71V14H3c-.55 0-1 .45-1 1s.45 1 1 1h10c.55 0 1-.45 1-1s-.45-1-1-1H9V8c4.91 0 7-2.21 7-7 0-.55-.45-1-1-1m-1.043 2.055c-.113 1.375-.477 2.261-1.043 2.832-.57.582-1.476.96-2.914 1.078 0-.754.133-1.352.352-1.828.222-.485.53-.856.937-1.168.637-.477 1.547-.793 2.668-.914M3.067 4.059c1.109.12 2.007.441 2.64.921.406.317.715.692.938 1.176.19.422.316.93.347 1.551-1.039.11-1.89-.176-2.566-.77-.668-.59-1.184-1.542-1.36-2.878m0 0"/></svg>

Before

Width:  |  Height:  |  Size: 633 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:gpa="https://www.gtk.org/grappa" gpa:keywords="starred" gpa:state="1" gpa:version="1" width="16" height="16"><path gpa:fill="foreground" gpa:states="1" d="M4.556 9.172 1.39 6.92a.864.864 0 0 1 .503-1.567l4.01.013 1.246-3.74a.896.896 0 0 1 1.702.005l1.201 3.688 4.056.015a.873.873 0 0 1 .501 1.585l-3.17 2.253 1.302 3.612a.912.912 0 0 1-1.355 1.072L8.04 11.672l-3.349 2.185a.929.929 0 0 1-1.385-1.084Z" class="foreground-fill"/></svg>

Before

Width:  |  Height:  |  Size: 479 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none"><g clip-path="url(#a)"><path fill="#000" fill-rule="evenodd" d="M9.352 5.1a1.509 1.509 0 1 0 2.51 1.675A1.509 1.509 0 0 0 9.352 5.1m2.923-.277a2.009 2.009 0 1 1-3.34 2.231 2.009 2.009 0 0 1 3.34-2.23ZM5.01 12.131l-.983-.407a1.7 1.7 0 0 0 3.108-.103 1.696 1.696 0 0 0-1.213-2.29 1.7 1.7 0 0 0-.966.07l1.015.421a1.249 1.249 0 0 1-.96 2.307zM2.546 2.121A8 8 0 0 1 7.966 0l.003.013a7.99 7.99 0 0 1 7.159 4.432 7.996 7.996 0 0 1-4.277 11.018 7.99 7.99 0 0 1-8.274-1.558A8 8 0 0 1 .279 10.18l3.064 1.267A2.264 2.264 0 0 0 7.823 11v-.107l2.718-1.938h.063A3.016 3.016 0 1 0 7.589 5.94v.031l-1.906 2.76h-.126c-.454 0-.898.138-1.273.395L0 7.354A8 8 0 0 1 2.546 2.12Z" clip-rule="evenodd"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

Before

Width:  |  Height:  |  Size: 842 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path fill="#222" d="M7 2.926c-4.043 0-5.895 3.613-5.895 3.613l1.786.902S4.14 4.926 7 4.926c2.055 0 3.098 1.394 3.484 2.074h-.91C8.242 7 8 8.254 8 9.035l6-.047.043-6c-1.043 0-2.031.524-2.031 1.668v.93l-.063.062C11.191 4.558 9.633 2.926 7 2.926M2.383 8.988c-.688 0-1.266.582-1.266 1.266v3.469c0 .683.578 1.265 1.266 1.265h3.469c.683 0 1.265-.582 1.265-1.265v-3.47c0-.683-.582-1.265-1.265-1.265zm.734 2h2v2h-2zm0 0"/></svg>

Before

Width:  |  Height:  |  Size: 504 B

View File

@@ -1,9 +1,6 @@
<?xml version="1.0" encoding="UTF-8" ?>
<schemalist gettext-domain="cartridges">
<schema id="@APP_ID@" path="@PREFIX@/">
<key name="collections" type="aa{sv}">
<default>[]</default>
</key>
</schema>
<schema id="@APP_ID@.State" path="@PREFIX@/State/">
<key name="width" type="i">
@@ -25,8 +22,5 @@
</choices>
<default>"last_played"</default>
</key>
<key name="show-sidebar" type="b">
<default>false</default>
</key>
</schema>
</schemalist>

View File

@@ -8,11 +8,9 @@
"--share=ipc",
"--socket=fallback-x11",
"--device=dri",
"--device=input",
"--socket=wayland",
"--talk-name=org.freedesktop.Flatpak",
"--filesystem=host:ro",
"--filesystem=~/.var/app/com.heroicgameslauncher.hgl/config/heroic/:ro",
"--filesystem=~/.var/app/com.valvesoftware.Steam/data/Steam/:ro"
],
"cleanup": [

View File

@@ -1,24 +1,14 @@
cartridges/application.py
cartridges/collections.py
cartridges/gamepads.py
cartridges/games.py
cartridges/sources/__init__.py
cartridges/sources/heroic.py
cartridges/sources/imported.py
cartridges/sources/steam.py
cartridges/ui/closures.py
cartridges/ui/collection-details.blp
cartridges/ui/collection_details.py
cartridges/ui/collections.py
cartridges/ui/cover.blp
cartridges/ui/cover.py
cartridges/ui/game-details.blp
cartridges/ui/game-item.blp
cartridges/ui/game_details.py
cartridges/ui/game-item.blp
cartridges/ui/game_item.py
cartridges/ui/games.py
cartridges/ui/shortcuts-dialog.blp
cartridges/ui/sources.py
cartridges/ui/window.blp
cartridges/ui/window.py
data/page.kramo.Cartridges.desktop.in

View File

@@ -1,13 +1,6 @@
[project]
requires-python = ">= 3.13"
[dependency-groups]
dev = [
"pre-commit",
"pygobject-stubs",
"ruff",
]
[tool.pyright]
exclude = ["**/__pycache__", "**/.*", "_build/**"]
typeCheckingMode = "strict"
@@ -22,7 +15,6 @@ reportUnknownLambdaType = "none"
reportUnknownMemberType = "none"
reportUnknownParameterType = "none"
reportUnknownVariableType = "none"
reportUntypedBaseClass = "none"
reportUntypedFunctionDecorator = "none"
reportUnusedImport = "none"
reportUnusedVariable = "none"