Compare commits
37 Commits
rewrite-sa
...
rewrite-si
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dab108ce8b | ||
|
|
515bafa428 | ||
|
|
c5cfa476ff | ||
|
|
7bc9d6aee9 | ||
|
|
00795b83fd | ||
|
|
f9cb794394 | ||
|
|
1aee234cbf | ||
|
|
21588fe92b | ||
|
|
c93a11375e | ||
|
|
9520c79dde | ||
|
|
d956f1f12c | ||
|
|
78b24f20a2 | ||
|
|
722edb1a5b | ||
|
|
338bf91e21 | ||
|
|
8fda6dc7c2 | ||
|
|
b592b95302 | ||
|
|
b8df4ff2c7 | ||
|
|
8cc56b445e | ||
|
|
453eca9122 | ||
|
|
34cee68ad9 | ||
|
|
9fe7b252d7 | ||
|
|
94db6acf37 | ||
|
|
7d7999d8a9 | ||
|
|
62004753b6 | ||
|
|
26a09c6782 | ||
|
|
0d2d00d8fb | ||
|
|
d85c6e0530 | ||
|
|
84972c06d6 | ||
|
|
ea0e5b5c47 | ||
|
|
d1371baf2b | ||
|
|
d0b6d6457d | ||
|
|
e645ade8d6 | ||
|
|
9a9514a1ae | ||
|
|
27b2745c74 | ||
|
|
fa9b94fd80 | ||
|
|
b77d4ea9e8 | ||
|
|
eef38f73f5 |
53
.pre-commit-config.yaml
Normal 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
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
import sys
|
||||
|
||||
from gi.events import GLibEventLoopPolicy
|
||||
from gi.events import GLibEventLoopPolicy # pyright: ignore[reportMissingImports]
|
||||
|
||||
from .application import Application
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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))
|
||||
@@ -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,10 +139,10 @@ 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")
|
||||
with path.open(encoding="utf-8") as f:
|
||||
json.dump(properties, f, indent=4)
|
||||
GAMES_DIR.mkdir(parents=True, exist_ok=True)
|
||||
path = (GAMES_DIR / self.game_id).with_suffix(".json")
|
||||
with path.open("w", encoding="utf-8") as f:
|
||||
json.dump(properties, f, indent=4, sort_keys=True)
|
||||
|
||||
def _remove(self):
|
||||
self.removed = True
|
||||
@@ -188,80 +169,3 @@ class Game(Gio.SimpleActionGroup):
|
||||
app = cast("Application", Gio.Application.get_default())
|
||||
window = cast("Window", app.props.active_window)
|
||||
window.send_toast(title, undo=undo)
|
||||
|
||||
|
||||
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
|
||||
|
||||
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)
|
||||
|
||||
@@ -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',
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
179
cartridges/sources/heroic.py
Normal 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,
|
||||
)
|
||||
54
cartridges/sources/imported.py
Normal 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")
|
||||
@@ -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,
|
||||
|
||||
86
cartridges/ui/collection-details.blp
Normal 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",
|
||||
]
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
}
|
||||
144
cartridges/ui/collection_details.py
Normal 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
|
||||
141
cartridges/ui/collections.py
Normal 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,
|
||||
)
|
||||
@@ -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 [
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 [
|
||||
|
||||
@@ -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,7 +119,7 @@ class GameDetails(Adw.NavigationPage):
|
||||
|
||||
if not self.game.added:
|
||||
self.game.added = int(time.time())
|
||||
games.model.append(self.game)
|
||||
sources.get(imported.ID).append(self.game)
|
||||
|
||||
self.stack.props.visible_child_name = "details"
|
||||
|
||||
@@ -131,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()
|
||||
|
||||
@@ -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
@@ -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))
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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")),
|
||||
)
|
||||
@@ -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%);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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");
|
||||
};
|
||||
}
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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)()
|
||||
|
||||
1
data/icons/apply-symbolic.svg
Normal 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 |
1
data/icons/ball-symbolic.svg
Normal 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 |
1
data/icons/cancel-symbolic.svg
Normal 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 |
1
data/icons/car-symbolic.svg
Normal 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("#gpa:foreground") 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("#gpa:foreground") 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 |
1
data/icons/city-symbolic.svg
Normal 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 |
1
data/icons/collection-symbolic.svg
Normal 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 |
@@ -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 |
1
data/icons/fist-symbolic.svg
Normal 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 |
1
data/icons/flashlight-symbolic.svg
Normal 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 |
1
data/icons/gamepad-symbolic.svg
Normal 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 |
1
data/icons/globe-symbolic.svg
Normal 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 |
1
data/icons/gun-symbolic.svg
Normal 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 |
1
data/icons/heart-symbolic.svg
Normal 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("#gpa:foreground") 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 |
1
data/icons/heroic-symbolic.svg
Normal 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 |
1
data/icons/horse-symbolic.svg
Normal 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 |
@@ -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>
|
||||
|
||||
1
data/icons/imported-symbolic.svg
Normal 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 |
1
data/icons/knife-symbolic.svg
Normal 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 |
1
data/icons/map-symbolic.svg
Normal 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 |
1
data/icons/music-symbolic.svg
Normal 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 |
|
Before Width: | Height: | Size: 5.8 KiB After Width: | Height: | Size: 5.7 KiB |
1
data/icons/people-symbolic.svg
Normal 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 |
1
data/icons/private-symbolic.svg
Normal 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("#gpa:foreground") 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("#gpa:foreground") 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 |
1
data/icons/puzzle-symbolic.svg
Normal 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 |
1
data/icons/skull-symbolic.svg
Normal 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 |
1
data/icons/sprout-symbolic.svg
Normal 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 |
1
data/icons/star-symbolic.svg
Normal 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 |
1
data/icons/steam-symbolic.svg
Normal 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 |
1
data/icons/step-over-symbolic.svg
Normal 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 |
@@ -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>
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||