Compare commits

..

37 Commits

Author SHA1 Message Date
Jamie Gravendeel
dab108ce8b ui: Use GObject.BindingGroup 2026-01-10 01:46:20 +01:00
Jamie Gravendeel
515bafa428 ui: Use GObject.SignalGroup 2026-01-10 01:22:55 +01:00
Jamie Gravendeel
c5cfa476ff sources: Add initial UI 2026-01-05 20:34:32 +01:00
Jamie Gravendeel
7bc9d6aee9 window: Move game filters to ui.games 2026-01-05 20:34:32 +01:00
Jamie Gravendeel
00795b83fd sources: Fix icon name 2026-01-05 20:34:32 +01:00
Jamie Gravendeel
f9cb794394 cover: Add width and height properties 2026-01-05 19:43:38 +01:00
Jamie Gravendeel
1aee234cbf cartridges: Use generic methods in favor of TypeVar 2026-01-05 19:34:02 +01:00
Jamie Gravendeel
21588fe92b application: Only use GTK after startup 2026-01-05 19:31:11 +01:00
kramo
c93a11375e game: Fix Game.save() 2025-12-29 01:29:04 +01:00
kramo
9520c79dde sources: Split games into per-source models 2025-12-29 01:29:04 +01:00
kramo
d956f1f12c sources: Dynamically import sources 2025-12-28 22:01:08 +01:00
Jamie Gravendeel
78b24f20a2 collection-details: Add a11y labels for icons 2025-12-27 19:12:22 +01:00
Jamie Gravendeel
722edb1a5b window: Disable stack transitions 2025-12-27 19:10:19 +01:00
Jamie Gravendeel
338bf91e21 collection-details: Move from GtkFlowBox to GtkGrid 2025-12-27 19:10:17 +01:00
Jamie Gravendeel
8fda6dc7c2 collections: Use a set for game ids 2025-12-27 17:51:02 +01:00
Jamie Gravendeel
b592b95302 window: Change showing order of empty pages 2025-12-27 17:00:43 +01:00
Jamie Gravendeel
b8df4ff2c7 window: Hide sidebar when items are activated 2025-12-27 17:00:43 +01:00
Jamie Gravendeel
8cc56b445e window: Add shortcut for adding collections 2025-12-27 17:00:43 +01:00
Jamie Gravendeel
453eca9122 collections: Filter out removed manually added games 2025-12-27 17:00:43 +01:00
Jamie Gravendeel
34cee68ad9 window: Add game to collection automatically 2025-12-27 17:00:43 +01:00
Jamie Gravendeel
9fe7b252d7 collections: Properly handle markup 2025-12-27 17:00:43 +01:00
Jamie Gravendeel
94db6acf37 collections: Support adding games 2025-12-27 17:00:43 +01:00
Jamie Gravendeel
7d7999d8a9 collection-details: Support editing and removing 2025-12-27 17:00:43 +01:00
Jamie Gravendeel
62004753b6 collection-details: Support adding collections 2025-12-27 17:00:43 +01:00
Jamie Gravendeel
26a09c6782 collections: Save to GSettings 2025-12-27 15:02:24 +01:00
kramo
0d2d00d8fb sources: Set date added higher up 2025-12-23 23:26:20 +01:00
kramo
d85c6e0530 sources: Remove skip_ids 2025-12-23 23:24:39 +01:00
kramo
84972c06d6 sources: Handle all OSErrors when reading files 2025-12-23 23:23:38 +01:00
kramo
ea0e5b5c47 heroic: Implement Heroic source 2025-12-23 23:23:38 +01:00
Jamie Gravendeel
d1371baf2b cartridges: Make GSettings constant 2025-12-21 19:41:05 +01:00
Jamie Gravendeel
d0b6d6457d collections: Add initial UI 2025-12-21 19:41:05 +01:00
Jamie Gravendeel
e645ade8d6 games: Split out UI into its own module 2025-12-21 19:39:32 +01:00
Jamie Gravendeel
9a9514a1ae gsettings: Add collection storing and loading 2025-12-21 19:39:31 +01:00
Jamie Gravendeel
27b2745c74 games: Add collection object and model 2025-12-21 19:35:58 +01:00
Jamie Gravendeel
fa9b94fd80 pyproject: Add dev dependency group 2025-12-21 11:31:16 +01:00
Jamie Gravendeel
b77d4ea9e8 pre-commit: Add config 2025-12-21 11:31:16 +01:00
Zoey Ahmed
eef38f73f5 gamepad: Add initial controller navigation (#406)
Co-authored-by: kramo <git@kramo.page>
Co-authored-by: Zoey Ahmed <zoethetransrat@gmail.com>
Reviewed-on: https://codeberg.org/kramo/cartridges/pulls/406
Reviewed-by: Laura Kramolis <git@kramo.page>
Reviewed-by: Jamie Gravendeel <me@jamie.garden>
Co-authored-by: Zoey Ahmed <zoeyahmed10@proton.me>
Co-committed-by: Zoey Ahmed <zoeyahmed10@proton.me>
2025-12-17 21:24:34 +01:00
62 changed files with 2107 additions and 401 deletions

53
.pre-commit-config.yaml Normal file
View File

@@ -0,0 +1,53 @@
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,12 +15,17 @@ 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")
state_settings = Gio.Settings.new(f"{APP_ID}.State")
SETTINGS = Gio.Settings.new(APP_ID)
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
from gi.events import GLibEventLoopPolicy # pyright: ignore[reportMissingImports]
from .application import Application

View File

@@ -2,15 +2,12 @@
# 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 games
from cartridges.games import Game
from cartridges.sources import Source, steam
from cartridges import collections, sources
from .config import APP_ID, PREFIX
from .ui.window import Window
@@ -22,29 +19,19 @@ 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",))
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
sources.load()
collections.load()
@override
def do_activate(self):

117
cartridges/collections.py Normal file
View File

@@ -0,0 +1,117 @@
# 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)

342
cartridges/gamepads.py Normal file
View File

@@ -0,0 +1,342 @@
# 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,22 +3,19 @@
# 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, Generator, Iterable
from collections.abc import Callable
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, override
from typing import TYPE_CHECKING, Any, NamedTuple, Self, cast
from gi.repository import Gdk, Gio, GLib, GObject, Gtk
from gi.repository import Gdk, Gio, GObject
from cartridges import DATA_DIR, state_settings
from . import DATA_DIR
if TYPE_CHECKING:
from .application import Application
@@ -46,18 +43,10 @@ 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):
@@ -131,14 +120,6 @@ class Game(Gio.SimpleActionGroup):
return game
@classmethod
def for_editing(cls) -> Self:
"""Create a game for the user to manually set its properties."""
return cls(
game_id=f"{_MANUALLY_ADDED_ID}_{_increment_manually_added_id()}",
source=_MANUALLY_ADDED_ID,
)
def play(self):
"""Run the executable command in a shell."""
if Path("/.flatpak-info").exists():
@@ -158,120 +139,33 @@ 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)
json.dump(properties, f, indent=4, sort_keys=True)
def _remove(self):
self.removed = True
self.save()
self._send(_("{} removed").format(self.name), undo=self._undo_remove)
def _undo_remove(self):
self.removed = False
self.save()
self._send(
_("{} removed").format(self.name),
undo=lambda: setattr(self, "removed", False),
)
def _hide(self):
self.hidden = True
self.save()
self._send(_("{} hidden").format(self.name), undo=self._undo_hide)
def _undo_hide(self):
self.hidden = False
self.save()
self._send(
_("{} hidden").format(self.name),
undo=lambda: setattr(self, "hidden", False),
)
def _unhide(self):
self.hidden = False
self.save()
self._send(_("{} unhidden").format(self.name), undo=self._undo_unhide)
def _undo_unhide(self):
self.hidden = True
self.save()
self._send(
_("{} unhidden").format(self.name),
undo=lambda: setattr(self, "hidden", True),
)
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,5 +1,12 @@
python.install_sources(
files('__init__.py', '__main__.py', 'application.py', 'games.py'),
files(
'__init__.py',
'__main__.py',
'application.py',
'collections.py',
'gamepads.py',
'games.py',
),
subdir: 'cartridges',
)

View File

@@ -1,19 +1,35 @@
# 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
from typing import Final, Protocol, cast
from gi.repository import GLib
from gi.repository import Gio, GLib, GObject
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 = (
@@ -25,13 +41,87 @@ OPEN = (
)
class Source(Protocol):
"""A source of games to import."""
class _SourceModule(Protocol):
ID: Final[str]
NAME: Final[str]
@staticmethod
def get_games(*, skip_ids: Iterable[str]) -> Generator[Game]:
"""Installed games, except those in `skip_ids`."""
def get_games() -> Generator[Game]:
"""Installed games."""
...
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

@@ -0,0 +1,179 @@
# 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

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

View File

@@ -0,0 +1,86 @@
using Gtk 4.0;
using Adw 1;
using GObject 2.0;
GObject.SignalGroup collection_signals {
target: bind template.collection;
target-type: typeof<$Collection>;
}
template $CollectionDetails: Adw.Dialog {
title: bind $_or(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

@@ -0,0 +1,144 @@
# SPDX-License-Identifier: GPL-3.0-or-later
# SPDX-FileCopyrightText: Copyright 2025 Jamie Gravendeel
from itertools import product
from typing import Any, NamedTuple
from gi.repository import Adw, Gio, GObject, Gtk
from cartridges import collections
from cartridges.collections import Collection
from cartridges.config import PREFIX
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()
collection_signals: GObject.SignalGroup = Gtk.Template.Child()
sort_changed = GObject.Signal()
_selected_icon: str
@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)
def __init__(self, collection: Collection, **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),
)
self.collection_signals.connect_closure(
"notify::removed",
lambda *_: self.force_close(),
after=True,
)
self.collection = collection
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()
@Gtk.Template.Callback()
def _or[T](self, _obj, first: T, second: T) -> T:
return first or second
@Gtk.Template.Callback()
def _if_else[T](self, _obj, condition: object, first: T, second: T) -> T:
return first if condition else second

View File

@@ -0,0 +1,141 @@
# 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."""
collection = GObject.Property(type=Collection)
def __init__(self, collection: Collection, **kwargs: Any):
super().__init__(**kwargs)
self.bind_property(
"title",
self,
"tooltip",
GObject.BindingFlags.SYNC_CREATE,
lambda _, name: GLib.markup_escape_text(name),
)
self._collection_bindings = GObject.BindingGroup()
flags = GObject.BindingFlags.DEFAULT
self._collection_bindings.bind("name", self, "title", flags)
self._collection_bindings.bind("icon-name", self, "icon-name", flags)
self.bind_property("collection", self._collection_bindings, "source")
self.collection = collection
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 _picture.height-request;
tightening-threshold: bind _picture.height-request;
maximum-size: bind template.height;
tightening-threshold: bind template.height;
child: Adw.Clamp {
unit: px;
maximum-size: bind _picture.width-request;
tightening-threshold: bind _picture.width-request;
maximum-size: bind template.width;
tightening-threshold: bind template.width;
child: Adw.ViewStack {
name: "cover";
visible-child-name: bind $_get_stack_child(template.paintable) as <string>;
visible-child-name: bind $_if_else(template.paintable, "cover", "icon") as <string>;
overflow: hidden;
enable-transitions: true;
Adw.ViewStackPage {
name: "cover";
child: Picture _picture {
child: Picture {
paintable: bind template.paintable;
width-request: 200;
height-request: 300;
width-request: bind template.width;
height-request: bind template.height;
content-fit: cover;
};
}
@@ -34,7 +34,10 @@ template $Cover: Adw.Bin {
name: "icon";
child: Image {
icon-name: bind template.app-icon-name;
icon-name: bind $_concat(
template.root as <Window>.application as <Application>.application-id,
"-symbolic"
) as <string>;
pixel-size: 80;
styles [

View File

@@ -3,7 +3,7 @@
from gi.repository import Adw, Gdk, GObject, Gtk
from cartridges.config import APP_ID, PREFIX
from cartridges.config import PREFIX
@Gtk.Template.from_resource(f"{PREFIX}/cover.ui")
@@ -12,12 +12,14 @@ class Cover(Adw.Bin):
__gtype_name__ = __qualname__
picture = GObject.Property(lambda self: self._picture, type=Gtk.Picture)
paintable = GObject.Property(type=Gdk.Paintable)
app_icon_name = GObject.Property(type=str, default=f"{APP_ID}-symbolic")
_picture = Gtk.Template.Child()
width = GObject.Property(type=int, default=200)
height = GObject.Property(type=int, default=300)
@Gtk.Template.Callback()
def _get_stack_child(self, _obj, paintable: Gdk.Paintable | None) -> str:
return "cover" if paintable else "icon"
def _if_else[T](self, _obj, condition: object, first: T, second: T) -> T:
return first if condition else second
@Gtk.Template.Callback()
def _concat(self, _obj, *strings: str) -> str:
return "".join(strings)

View File

@@ -1,6 +1,12 @@
using Gtk 4.0;
using Gdk 4.0;
using Adw 1;
using GObject 2.0;
GObject.SignalGroup game_signals {
target: bind template.game;
target-type: typeof<$Game>;
}
template $GameDetails: Adw.NavigationPage {
name: "details";
@@ -164,6 +170,52 @@ 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";

View File

@@ -12,8 +12,8 @@ template $GameItem: Box {
Adw.Clamp {
unit: px;
maximum-size: bind cover.picture as <Picture>.width-request;
tightening-threshold: bind cover.picture as <Picture>.width-request;
maximum-size: bind cover.width;
tightening-threshold: bind cover.width;
child: Overlay {
child: $Cover cover {
@@ -29,23 +29,61 @@ template $GameItem: Box {
margin-top: 6;
margin-end: 6;
notify::active => $_reveal_buttons();
notify::active => $_setup_collections();
menu-model: menu {
item (_("Edit"), "item.edit")
popover: PopoverMenu {
menu-model: menu {
section {
item (_("Edit"), "item.edit")
item {
label: _("Hide");
action: "game.hide";
hidden-when: "action-disabled";
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: _("Unhide");
action: "game.unhide";
hidden-when: "action-disabled";
}
item (_("Remove"), "game.remove")
};
styles [

View File

@@ -7,25 +7,25 @@ import sys
import time
from datetime import UTC, datetime
from gettext import gettext as _
from typing import Any, TypeVar, cast
from typing import Any, cast
from urllib.parse import quote
from gi.repository import Adw, Gdk, Gio, GObject, Gtk
from gi.repository import Adw, Gdk, Gio, GLib, GObject, Gtk
from cartridges import games
from cartridges import games, sources
from cartridges.config import PREFIX
from cartridges.games import Game
from cartridges.sources import imported
from .collections import CollectionsBox
from .cover import Cover # noqa: F401
_POP_ON_ACTION = "hide", "unhide", "remove"
_POP_ON_PROPERTY_NOTIFY = "hidden", "removed"
_EDITABLE_PROPERTIES = {prop.name for prop in games.PROPERTIES if prop.editable}
_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):
@@ -34,10 +34,13 @@ 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()
game_signals: GObject.SignalGroup = Gtk.Template.Child()
sort_changed = GObject.Signal()
@GObject.Property(type=Game)
@@ -50,21 +53,9 @@ class GameDetails(Adw.NavigationPage):
self._game = game
self.insert_action_group("game", game)
for action, ident in self._signal_ids.copy().items():
action.disconnect(ident)
del self._signal_ids[action]
for name in _POP_ON_ACTION:
action = cast(Gio.SimpleAction, game.lookup_action(name))
self._signal_ids[action] = action.connect(
"activate", lambda *_: self.activate_action("navigation.pop")
)
def __init__(self, **kwargs: Any):
super().__init__(**kwargs)
self._signal_ids = dict[Gio.SimpleAction, int]()
self.insert_action_group("details", group := Gio.SimpleActionGroup())
group.add_action_entries((
("edit", lambda *_: self.edit()),
@@ -76,6 +67,12 @@ 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"))
@@ -92,6 +89,13 @@ class GameDetails(Adw.NavigationPage):
valid = Gtk.ClosureExpression.new(bool, lambda _, *values: all(values), entries)
valid.bind(apply, "enabled")
for name in _POP_ON_PROPERTY_NOTIFY:
self.game_signals.connect_closure(
f"notify::{name}",
lambda *_: self.activate_action("navigation.pop"),
after=True,
)
def edit(self):
"""Enter edit mode."""
for prop in _EDITABLE_PROPERTIES:
@@ -115,9 +119,8 @@ class GameDetails(Adw.NavigationPage):
if not self.game.added:
self.game.added = int(time.time())
games.model.append(self.game)
sources.get(imported.ID).append(self.game)
self.game.save()
self.stack.props.visible_child_name = "details"
@Gtk.Template.Callback()
@@ -132,11 +135,18 @@ class GameDetails(Adw.NavigationPage):
self.stack.props.visible_child_name = "details"
@Gtk.Template.Callback()
def _or(self, _obj, first: _T, second: _T) -> _T:
def _setup_collections(self, button: Gtk.MenuButton, *_args):
if button.props.active:
self.collections_box.build()
else:
self.collections_box.finish()
@Gtk.Template.Callback()
def _or[T](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:
def _if_else[T](self, _obj, condition: object, first: T, second: T) -> T:
return first if condition else second
@Gtk.Template.Callback()

View File

@@ -8,6 +8,7 @@ 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
@@ -19,6 +20,7 @@ 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)
@@ -44,6 +46,12 @@ 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()
@@ -56,3 +64,10 @@ 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()

72
cartridges/ui/games.py Normal file
View File

@@ -0,0 +1,72 @@
# 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,9 +1,13 @@
python.install_sources(
files(
'__init__.py',
'collection_details.py',
'collections.py',
'cover.py',
'game_details.py',
'game_item.py',
'games.py',
'sources.py',
'window.py',
),
subdir: 'cartridges' / 'ui',
@@ -11,6 +15,7 @@ python.install_sources(
blueprints = custom_target(
input: files(
'collection-details.blp',
'cover.blp',
'game-details.blp',
'game-item.blp',

View File

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

50
cartridges/ui/sources.py Normal file
View File

@@ -0,0 +1,50 @@
# SPDX-License-Identifier: GPL-3.0-or-later
# SPDX-FileCopyrightText: Copyright 2025 Jamie Gravendeel
from typing import Any
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."""
source = GObject.Property(type=Source)
model = GObject.Property(type=Gio.ListModel)
def __init__(self, source: Source, **kwargs: Any):
super().__init__(**kwargs)
# https://gitlab.gnome.org/GNOME/gtk/-/issues/7959
self._model_signals = GObject.SignalGroup.new(Gio.ListModel)
self._model_signals.connect_closure(
"items-changed",
lambda model, *_: model.notify("n-items"),
after=True,
)
self._source_bindings = GObject.BindingGroup()
flags = GObject.BindingFlags.DEFAULT
self._source_bindings.bind("name", self, "title", flags)
self._source_bindings.bind("icon-name", self, "icon-name", flags)
self.bind_property("source", self._source_bindings, "source")
self.model = Gtk.FilterListModel(filter=games.filter_, watch_items=True) # pyright: ignore[reportCallIssue]
self.model.bind_property(
"n-items",
self,
"visible",
GObject.BindingFlags.SYNC_CREATE,
)
self.bind_property("source", self.model, "model")
self.source = source
model = Gtk.SortListModel.new(
sources.model,
Gtk.StringSorter.new(Gtk.PropertyExpression.new(Source, None, "name")),
)

View File

@@ -15,6 +15,7 @@
}
/* 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%);
}
@@ -23,7 +24,8 @@
padding: 12px;
}
#game-item button {
#game-item overlay > button,
#game-item overlay > menubutton > button {
color: white;
backdrop-filter: blur(9px) brightness(30%) saturate(600%);
box-shadow:
@@ -36,12 +38,12 @@
opacity, transform;
}
#game-item button.hidden {
#game-item overlay > button.hidden {
opacity: 0;
transform: translateY(3px);
}
#game-item menubutton.hidden button {
#game-item overlay > menubutton.hidden > button {
opacity: 0;
transform: translateY(-6px);
}
@@ -73,11 +75,13 @@
}
@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 button {
#game-item overlay > button,
#game-item overlay > menubutton > button {
backdrop-filter: blur(9px) brightness(20%) saturate(600%);
}

View File

@@ -1,6 +1,7 @@
<?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

@@ -1,7 +1,20 @@
using Gtk 4.0;
using Adw 1;
using GObject 2.0;
using Gio 2.0;
GObject.SignalGroup collection_signals {
target: bind template.collection;
target-type: typeof<$Collection>;
}
GObject.SignalGroup model_signals {
target: bind template.model;
target-type: typeof<Gio.ListModel>;
}
template $Window: Adw.ApplicationWindow {
realize => $_setup_gamepad_monitor();
title: _("Cartridges");
ShortcutController {
@@ -20,217 +33,310 @@ 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.ToolbarView {
[top]
Adw.HeaderBar {
title-widget: Adw.Clamp clamp {
tightening-threshold: bind clamp.maximum-size;
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: CenterBox {
hexpand: true;
sidebar: Adw.NavigationPage {
title: bind template.title;
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();
};
child: Adw.ToolbarView {
[top]
Adw.HeaderBar {
show-title: bind $_show_sidebar_title(template.settings as <Settings>.gtk-decoration-layout) as <bool>;
end-widget: MenuButton {
icon-name: "filter-symbolic";
tooltip-text: _("Sort & Filter");
margin-start: 6;
[start]
Button {
visible: bind split_view.show-sidebar;
action-name: "win.show-sidebar";
icon-name: "sidebar-show-symbolic";
tooltip-text: _("Toggle Sidebar");
}
}
menu-model: menu {
section {
label: _("Sort");
content: Adw.Sidebar sidebar {
activated => $_navigate();
setup-menu => $_setup_sidebar_menu();
notify::selected => $_update_selection();
item {
label: _("Last Played");
action: "win.sort-mode";
target: "last_played";
}
Adw.SidebarSection {
Adw.SidebarItem {
icon-name: "view-grid-symbolic";
title: _("All Games");
}
}
item {
label: _("A-Z");
action: "win.sort-mode";
target: "a-z";
}
Adw.SidebarSection sources {
title: _("Sources");
}
item {
label: _("Z-A");
action: "win.sort-mode";
target: "z-a";
}
Adw.SidebarSection collections {
title: _("Collections");
item {
label: _("Newest");
action: "win.sort-mode";
target: "newest";
}
menu-model: menu collection_menu {};
}
item {
label: _("Oldest");
action: "win.sort-mode";
target: "oldest";
}
}
section {
item (_("Show Hidden Games"), "win.show-hidden")
}
};
};
Adw.SidebarSection {
Adw.SidebarItem new_collection_item {
icon-name: "list-add-symbolic";
title: _("New Collection");
}
}
};
};
};
[start]
Button {
icon-name: "list-add-symbolic";
tooltip-text: _("Add Game");
action-name: "win.add";
}
content: Adw.NavigationPage {
title: _("Games");
[end]
MenuButton {
icon-name: "open-menu-symbolic";
tooltip-text: _("Main Menu");
primary: true;
child: Adw.ToolbarView {
[top]
Adw.HeaderBar header_bar {
title-widget: Adw.Clamp clamp {
tightening-threshold: bind clamp.maximum-size;
menu-model: menu {
item (_("Keyboard Shortcuts"), "app.shortcuts")
item (_("About Cartridges"), "app.about")
};
}
}
child: CenterBox title_box {
hexpand: true;
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>;
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();
};
Adw.ViewStackPage {
name: "grid";
end-widget: MenuButton sort_button {
icon-name: "filter-symbolic";
tooltip-text: _("Sort & Filter");
margin-start: 6;
child: ScrolledWindow {
hscrollbar-policy: never;
menu-model: menu {
section {
label: _("Sort");
child: GridView grid {
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")
}
};
};
};
};
[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;
menu-model: menu {
item (_("Keyboard Shortcuts"), "app.shortcuts")
item (_("About Cartridges"), "app.about")
};
}
}
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>;
Adw.ViewStackPage {
name: "grid";
single-click-activate: true;
activate => $_show_details();
model: NoSelection {
model: SortListModel {
sorter: $GameSorter sorter {};
child: ScrolledWindow {
hscrollbar-policy: never;
model: FilterListModel {
watch-items: true;
child: GridView grid {
name: "grid";
single-click-activate: true;
activate => $_show_details();
filter: EveryFilter {
AnyFilter {
StringFilter {
expression: expr item as <$Game>.name;
search: bind template.search-text;
}
model: NoSelection {
model: SortListModel {
sorter: $GameSorter sorter {};
StringFilter {
expression: expr item as <$Game>.developer;
search: bind template.search-text;
}
}
model: FilterListModel {
watch-items: true;
BoolFilter {
expression: expr item as <$Game>.hidden;
invert: bind template.show-hidden inverted;
}
filter: EveryFilter {
$CollectionFilter collection_filter {
collection: bind template.collection;
}
BoolFilter {
expression: expr item as <$Game>.removed;
invert: true;
}
AnyFilter {
StringFilter {
expression: expr item as <$Game>.name;
search: bind template.search-text;
}
BoolFilter {
expression: expr item as <$Game>.blacklisted;
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;
};
};
};
model: bind template.games;
factory: BuilderListItemFactory {
template ListItem {
child: $GameItem {
game: bind template.item;
position: bind template.position;
};
}
};
};
};
}
factory: BuilderListItemFactory {
template ListItem {
child: $GameItem {
game: bind template.item;
position: bind template.position;
};
}
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-collection";
child: Adw.StatusPage {
icon-name: bind template.collection as <$Collection>.icon-name;
title: bind $_format(_("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,18 +3,29 @@
# 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, TypeVar, cast
from typing import Any, cast
from gi.repository import Adw, Gio, GLib, GObject, Gtk
from cartridges import games, state_settings
from cartridges import STATE_SETTINGS
from cartridges.collections import Collection
from cartridges.config import PREFIX, PROFILE
from cartridges.games import Game, GameSorter
from cartridges.sources import imported
from cartridges.ui import collections, games, sources
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),
@@ -24,7 +35,6 @@ SORT_MODES = {
"oldest": ("added", True),
}
_T = TypeVar("_T")
type _UndoFunc = Callable[[], Any]
@@ -34,20 +44,35 @@ 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()
toast_overlay: Adw.ToastOverlay = 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()
grid: Gtk.GridView = Gtk.Template.Child()
sorter: GameSorter = Gtk.Template.Child()
collection_filter: CollectionFilter = Gtk.Template.Child()
details: GameDetails = Gtk.Template.Child()
collection = GObject.Property(type=Collection)
collection_signals: GObject.SignalGroup = Gtk.Template.Child()
model = GObject.Property(type=Gio.ListModel)
model_signals: GObject.SignalGroup = Gtk.Template.Child()
search_text = GObject.Property(type=str)
show_hidden = GObject.Property(type=bool, default=False)
@GObject.Property(type=Gio.ListStore)
def games(self) -> Gio.ListStore:
"""Model of the user's games."""
return games.model
settings = GObject.Property(type=Gtk.Settings)
_selected_sidebar_item = 0
def __init__(self, **kwargs: Any):
super().__init__(**kwargs)
@@ -55,23 +80,70 @@ 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("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)
# https://gitlab.gnome.org/GNOME/gtk/-/issues/7901
self.search_entry.set_key_capture_widget(self)
self.sources.bind_model(
sources.model,
lambda source: SourceSidebarItem(source),
)
self.collections.bind_model(
collections.model,
lambda collection: CollectionSidebarItem(collection),
)
self.add_action(state_settings.create_action("sort-mode"))
self.add_action(STATE_SETTINGS.create_action("show-sidebar"))
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()),
))
self.collection_signals.connect_closure(
"notify::removed",
lambda *_: self._collection_removed(),
after=True,
)
self.model_signals.connect_closure(
"items-changed",
lambda model, *_: None if model else self._model_emptied(),
after=True,
)
self.model = games.model
self._history: dict[Adw.Toast, _UndoFunc] = {}
def send_toast(self, title: str, *, undo: _UndoFunc | None = None):
@@ -79,7 +151,7 @@ class Window(Adw.ApplicationWindow):
Optionally display a button allowing the user to `undo` an operation.
"""
toast = Adw.Toast.new(title)
toast = Adw.Toast(title=title, use_markup=False)
if undo:
toast.props.button_label = _("Undo")
toast.props.priority = Adw.ToastPriority.HIGH
@@ -88,10 +160,77 @@ class Window(Adw.ApplicationWindow):
self.toast_overlay.add_toast(toast)
def _collection_removed(self):
self.collection = None
self.sidebar.props.selected = 0
def _model_emptied(self):
self.model = games.model
self.sidebar.props.selected = 0
@Gtk.Template.Callback()
def _if_else(self, _obj, condition: object, first: _T, second: _T) -> _T:
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]
@Gtk.Template.Callback()
def _if_else[T](self, _obj, condition: object, first: T, second: T) -> T:
return first if condition else second
@Gtk.Template.Callback()
def _format(self, _obj, string: str, *args: Any) -> str:
return string.format(*args)
@Gtk.Template.Callback()
def _show_details(self, grid: Gtk.GridView, position: int):
self.details.game = cast(Gio.ListModel, grid.props.model).get_item(position)
@@ -125,13 +264,34 @@ class Window(Adw.ApplicationWindow):
self.details.edit()
def _add(self):
self.details.game = Game.for_editing()
self.details.game = imported.new()
if self.navigation_view.props.visible_page_tag != "details":
self.navigation_view.push_by_tag("details")
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)
details.present(self)
def _edit_collection(self, pos: int):
collection = self.collections.get_item(pos).collection
details = CollectionDetails(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

@@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 326 B

View File

@@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 573 B

View File

@@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 315 B

View File

@@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 961 B

View File

@@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 421 B

View File

@@ -0,0 +1 @@
<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>

After

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

Before

Width:  |  Height:  |  Size: 268 B

After

Width:  |  Height:  |  Size: 256 B

View File

@@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 303 B

View File

@@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 509 B

View File

@@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 361 B

View File

@@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 637 B

View File

@@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 440 B

View File

@@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 736 B

View File

@@ -1,6 +1,34 @@
<?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

@@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 144 B

View File

@@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 268 B

View File

@@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 162 B

View File

@@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 557 B

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 5.7 KiB

View File

@@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 563 B

View File

@@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 954 B

View File

@@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 400 B

View File

@@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 845 B

View File

@@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 633 B

View File

@@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 479 B

View File

@@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 842 B

View File

@@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 504 B

View File

@@ -1,6 +1,9 @@
<?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">
@@ -22,5 +25,8 @@
</choices>
<default>"last_played"</default>
</key>
<key name="show-sidebar" type="b">
<default>false</default>
</key>
</schema>
</schemalist>

View File

@@ -8,9 +8,11 @@
"--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,14 +1,23 @@
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/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_details.py
cartridges/ui/game-item.blp
cartridges/ui/game_details.py
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,6 +1,13 @@
[project]
requires-python = ">= 3.13"
[dependency-groups]
dev = [
"pre-commit",
"pygobject-stubs",
"ruff",
]
[tool.pyright]
exclude = ["**/__pycache__", "**/.*", "_build/**"]
typeCheckingMode = "strict"
@@ -15,6 +22,7 @@ reportUnknownLambdaType = "none"
reportUnknownMemberType = "none"
reportUnknownParameterType = "none"
reportUnknownVariableType = "none"
reportUntypedBaseClass = "none"
reportUntypedFunctionDecorator = "none"
reportUnusedImport = "none"
reportUnusedVariable = "none"