Merge branch 'main' into libadwaita-1.4

This commit is contained in:
kramo
2023-08-16 15:59:38 +02:00
24 changed files with 622 additions and 451 deletions

View File

@@ -69,6 +69,14 @@ template $PreferencesWindow : Adw.PreferencesWindow {
title: _("Import"); title: _("Import");
icon-name: "document-save-symbolic"; icon-name: "document-save-symbolic";
Adw.PreferencesGroup import_behavior_group {
title: _("Behavior");
Adw.SwitchRow remove_missing_switch {
title: _("Remove Uninstalled Games");
}
}
Adw.PreferencesGroup sources_group { Adw.PreferencesGroup sources_group {
title: _("Sources"); title: _("Sources");

View File

@@ -10,6 +10,9 @@
<key name="high-quality-images" type="b"> <key name="high-quality-images" type="b">
<default>false</default> <default>false</default>
</key> </key>
<key name="remove-missing" type="b">
<default>true</default>
</key>
<key name="steam" type="b"> <key name="steam" type="b">
<default>true</default> <default>true</default>
</key> </key>

View File

@@ -15,6 +15,7 @@ src/game.py
src/preferences.py src/preferences.py
src/utils/create_dialog.py src/utils/create_dialog.py
src/importer/importer.py
src/importer/sources/source.py src/importer/sources/source.py
src/importer/sources/location.py src/importer/sources/location.py
src/store/managers/sgdb_manager.py src/store/managers/sgdb_manager.py

View File

@@ -8,13 +8,13 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: Cartridges\n" "Project-Id-Version: Cartridges\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2023-08-13 12:49+0200\n" "POT-Creation-Date: 2023-08-16 11:06+0200\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n" "Language-Team: LANGUAGE <LL@li.org>\n"
"Language: \n" "Language: \n"
"MIME-Version: 1.0\n" "MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=CHARSET\n" "Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n" "Content-Transfer-Encoding: 8bit\n"
#: data/hu.kramo.Cartridges.desktop.in:3 #: data/hu.kramo.Cartridges.desktop.in:3
@@ -58,8 +58,9 @@ msgstr ""
msgid "Game Details" msgid "Game Details"
msgstr "" msgstr ""
#: data/hu.kramo.Cartridges.metainfo.xml.in:42 data/gtk/window.blp:416 #: data/hu.kramo.Cartridges.metainfo.xml.in:42 data/gtk/window.blp:418
#: src/details_window.py:241 #: src/details_window.py:241 src/importer/importer.py:292
#: src/importer/importer.py:342
msgid "Preferences" msgid "Preferences"
msgstr "" msgstr ""
@@ -117,7 +118,7 @@ msgid "Quit"
msgstr "" msgstr ""
#: data/gtk/help-overlay.blp:19 data/gtk/window.blp:217 data/gtk/window.blp:257 #: data/gtk/help-overlay.blp:19 data/gtk/window.blp:217 data/gtk/window.blp:257
#: data/gtk/window.blp:323 #: data/gtk/window.blp:324
msgid "Search" msgid "Search"
msgstr "" msgstr ""
@@ -129,7 +130,8 @@ msgstr ""
msgid "Shortcuts" msgid "Shortcuts"
msgstr "" msgstr ""
#: data/gtk/help-overlay.blp:34 src/game.py:102 src/preferences.py:118 #: data/gtk/help-overlay.blp:34 src/game.py:103 src/preferences.py:120
#: src/importer/importer.py:366
msgid "Undo" msgid "Undo"
msgstr "" msgstr ""
@@ -157,7 +159,8 @@ msgstr ""
msgid "Remove game" msgid "Remove game"
msgstr "" msgstr ""
#: data/gtk/preferences.blp:13 data/gtk/preferences.blp:291 #: data/gtk/preferences.blp:13 data/gtk/preferences.blp:89
#: data/gtk/preferences.blp:304
msgid "Behavior" msgid "Behavior"
msgstr "" msgstr ""
@@ -193,110 +196,114 @@ msgstr ""
msgid "Remove All Games" msgid "Remove All Games"
msgstr "" msgstr ""
#: data/gtk/preferences.blp:85 data/gtk/window.blp:27 data/gtk/window.blp:442 #: data/gtk/preferences.blp:85 data/gtk/window.blp:27 data/gtk/window.blp:444
msgid "Import" msgid "Import"
msgstr "" msgstr ""
#: data/gtk/preferences.blp:89 #: data/gtk/preferences.blp:92
msgid "Remove Uninstalled Games"
msgstr ""
#: data/gtk/preferences.blp:102
msgid "Sources" msgid "Sources"
msgstr "" msgstr ""
#: data/gtk/preferences.blp:92 #: data/gtk/preferences.blp:105
msgid "Steam" msgid "Steam"
msgstr "" msgstr ""
#: data/gtk/preferences.blp:96 data/gtk/preferences.blp:110 #: data/gtk/preferences.blp:109 data/gtk/preferences.blp:123
#: data/gtk/preferences.blp:151 data/gtk/preferences.blp:201 #: data/gtk/preferences.blp:164 data/gtk/preferences.blp:214
#: data/gtk/preferences.blp:215 data/gtk/preferences.blp:229 #: data/gtk/preferences.blp:228 data/gtk/preferences.blp:242
#: data/gtk/preferences.blp:243 data/gtk/preferences.blp:257 #: data/gtk/preferences.blp:256 data/gtk/preferences.blp:270
msgid "Install Location" msgid "Install Location"
msgstr "" msgstr ""
#: data/gtk/preferences.blp:106 #: data/gtk/preferences.blp:119
msgid "Lutris" msgid "Lutris"
msgstr "" msgstr ""
#: data/gtk/preferences.blp:119 #: data/gtk/preferences.blp:132
msgid "Cache Location" msgid "Cache Location"
msgstr "" msgstr ""
#: data/gtk/preferences.blp:128 #: data/gtk/preferences.blp:141
msgid "Import Steam Games" msgid "Import Steam Games"
msgstr "" msgstr ""
#: data/gtk/preferences.blp:137 #: data/gtk/preferences.blp:150
msgid "Import Flatpak Games" msgid "Import Flatpak Games"
msgstr "" msgstr ""
#: data/gtk/preferences.blp:147 #: data/gtk/preferences.blp:160
msgid "Heroic" msgid "Heroic"
msgstr "" msgstr ""
#: data/gtk/preferences.blp:160 #: data/gtk/preferences.blp:173
msgid "Import Epic Games" msgid "Import Epic Games"
msgstr "" msgstr ""
#: data/gtk/preferences.blp:169 #: data/gtk/preferences.blp:182
msgid "Import GOG Games" msgid "Import GOG Games"
msgstr "" msgstr ""
#: data/gtk/preferences.blp:178 #: data/gtk/preferences.blp:191
msgid "Import Amazon Games" msgid "Import Amazon Games"
msgstr "" msgstr ""
#: data/gtk/preferences.blp:187 #: data/gtk/preferences.blp:200
msgid "Import Sideloaded Games" msgid "Import Sideloaded Games"
msgstr "" msgstr ""
#: data/gtk/preferences.blp:197 #: data/gtk/preferences.blp:210
msgid "Bottles" msgid "Bottles"
msgstr "" msgstr ""
#: data/gtk/preferences.blp:211 #: data/gtk/preferences.blp:224
msgid "itch" msgid "itch"
msgstr "" msgstr ""
#: data/gtk/preferences.blp:225 #: data/gtk/preferences.blp:238
msgid "Legendary" msgid "Legendary"
msgstr "" msgstr ""
#: data/gtk/preferences.blp:239 #: data/gtk/preferences.blp:252
msgid "RetroArch" msgid "RetroArch"
msgstr "" msgstr ""
#: data/gtk/preferences.blp:253 #: data/gtk/preferences.blp:266
msgid "Flatpak" msgid "Flatpak"
msgstr "" msgstr ""
#: data/gtk/preferences.blp:266 #: data/gtk/preferences.blp:279
msgid "Import Game Launchers" msgid "Import Game Launchers"
msgstr "" msgstr ""
#: data/gtk/preferences.blp:279 #: data/gtk/preferences.blp:292
msgid "SteamGridDB" msgid "SteamGridDB"
msgstr "" msgstr ""
#: data/gtk/preferences.blp:283 #: data/gtk/preferences.blp:296
msgid "Authentication" msgid "Authentication"
msgstr "" msgstr ""
#: data/gtk/preferences.blp:286 #: data/gtk/preferences.blp:299
msgid "API Key" msgid "API Key"
msgstr "" msgstr ""
#: data/gtk/preferences.blp:294 #: data/gtk/preferences.blp:307
msgid "Use SteamGridDB" msgid "Use SteamGridDB"
msgstr "" msgstr ""
#: data/gtk/preferences.blp:295 #: data/gtk/preferences.blp:308
msgid "Download images when adding or importing games" msgid "Download images when adding or importing games"
msgstr "" msgstr ""
#: data/gtk/preferences.blp:304 #: data/gtk/preferences.blp:317
msgid "Prefer Over Official Images" msgid "Prefer Over Official Images"
msgstr "" msgstr ""
#: data/gtk/preferences.blp:313 #: data/gtk/preferences.blp:326
msgid "Prefer Animated Images" msgid "Prefer Animated Images"
msgstr "" msgstr ""
@@ -324,7 +331,7 @@ msgstr ""
msgid "Games you hide will appear here." msgid "Games you hide will appear here."
msgstr "" msgstr ""
#: data/gtk/window.blp:64 data/gtk/window.blp:304 #: data/gtk/window.blp:64 data/gtk/window.blp:305
msgid "Back" msgid "Back"
msgstr "" msgstr ""
@@ -336,51 +343,59 @@ msgstr ""
msgid "Play" msgid "Play"
msgstr "" msgstr ""
#: data/gtk/window.blp:243 data/gtk/window.blp:435 #: data/gtk/window.blp:243 data/gtk/window.blp:437
msgid "Add Game" msgid "Add Game"
msgstr "" msgstr ""
#: data/gtk/window.blp:250 data/gtk/window.blp:316 #: data/gtk/window.blp:250 data/gtk/window.blp:317
msgid "Main Menu" msgid "Main Menu"
msgstr "" msgstr ""
#: data/gtk/window.blp:311 #: data/gtk/window.blp:272
msgid "Search games"
msgstr ""
#: data/gtk/window.blp:312
msgid "Hidden Games" msgid "Hidden Games"
msgstr "" msgstr ""
#: data/gtk/window.blp:374 #: data/gtk/window.blp:339
msgid "Search hidden games"
msgstr ""
#: data/gtk/window.blp:376
msgid "Sort" msgid "Sort"
msgstr "" msgstr ""
#: data/gtk/window.blp:377 #: data/gtk/window.blp:379
msgid "A-Z" msgid "A-Z"
msgstr "" msgstr ""
#: data/gtk/window.blp:383 #: data/gtk/window.blp:385
msgid "Z-A" msgid "Z-A"
msgstr "" msgstr ""
#: data/gtk/window.blp:389 #: data/gtk/window.blp:391
msgid "Newest" msgid "Newest"
msgstr "" msgstr ""
#: data/gtk/window.blp:395 #: data/gtk/window.blp:397
msgid "Oldest" msgid "Oldest"
msgstr "" msgstr ""
#: data/gtk/window.blp:401 #: data/gtk/window.blp:403
msgid "Last Played" msgid "Last Played"
msgstr "" msgstr ""
#: data/gtk/window.blp:408 #: data/gtk/window.blp:410
msgid "Show Hidden" msgid "Show Hidden"
msgstr "" msgstr ""
#: data/gtk/window.blp:421 #: data/gtk/window.blp:423
msgid "Keyboard Shortcuts" msgid "Keyboard Shortcuts"
msgstr "" msgstr ""
#: data/gtk/window.blp:426 #: data/gtk/window.blp:428
msgid "About Cartridges" msgid "About Cartridges"
msgstr "" msgstr ""
@@ -465,52 +480,84 @@ msgid "Couldn't Apply Preferences"
msgstr "" msgstr ""
#. The variable is the title of the game #. The variable is the title of the game
#: src/game.py:138 #: src/game.py:139
msgid "{} launched" msgid "{} launched"
msgstr "" msgstr ""
#. The variable is the title of the game #. The variable is the title of the game
#: src/game.py:152 #: src/game.py:153
msgid "{} hidden" msgid "{} hidden"
msgstr "" msgstr ""
#: src/game.py:152 #: src/game.py:153
msgid "{} unhidden" msgid "{} unhidden"
msgstr "" msgstr ""
#: src/game.py:169 #. The variable is the title of the game
#. The variable is the number of games removed
#: src/game.py:170 src/importer/importer.py:363
msgid "{} removed" msgid "{} removed"
msgstr "" msgstr ""
#: src/preferences.py:117 #: src/preferences.py:119
msgid "All games removed" msgid "All games removed"
msgstr "" msgstr ""
#: src/preferences.py:166 #: src/preferences.py:168
msgid "" msgid ""
"An API key is required to use SteamGridDB. You can generate one {}here{}." "An API key is required to use SteamGridDB. You can generate one {}here{}."
msgstr "" msgstr ""
#: src/preferences.py:295 #: src/preferences.py:294
msgid "Installation Not Found" msgid "Installation Not Found"
msgstr "" msgstr ""
#: src/preferences.py:297 #: src/preferences.py:296
msgid "Select a valid directory." msgid "Select a valid directory."
msgstr "" msgstr ""
#: src/preferences.py:363 #: src/preferences.py:351
msgid "Invalid Directory" msgid "Invalid Directory"
msgstr "" msgstr ""
#: src/preferences.py:369 #: src/preferences.py:357
msgid "Set Location" msgid "Set Location"
msgstr "" msgstr ""
#: src/utils/create_dialog.py:25 #: src/utils/create_dialog.py:25 src/importer/importer.py:291
msgid "Dismiss" msgid "Dismiss"
msgstr "" msgstr ""
#: src/importer/importer.py:128
msgid "Importing Games…"
msgstr ""
#: src/importer/importer.py:290
msgid "Warning"
msgstr ""
#: src/importer/importer.py:311
msgid "The following errors occured during import:"
msgstr ""
#: src/importer/importer.py:339
msgid "No new games found"
msgstr ""
#: src/importer/importer.py:351
msgid "1 game imported"
msgstr ""
#. The variable is the number of games
#: src/importer/importer.py:355
msgid "{} games imported"
msgstr ""
#. A single game removed
#: src/importer/importer.py:359
msgid "1 removed"
msgstr ""
#. The variable is the name of the source #. The variable is the name of the source
#: src/importer/sources/location.py:33 #: src/importer/sources/location.py:33
msgid "Select the {} cache directory." msgid "Select the {} cache directory."

View File

@@ -19,6 +19,7 @@
import os import os
from time import time from time import time
from typing import Any
from gi.repository import Adw, Gio, GLib, Gtk from gi.repository import Adw, Gio, GLib, Gtk
from PIL import Image from PIL import Image
@@ -52,16 +53,15 @@ class DetailsWindow(Adw.Window):
apply_button = Gtk.Template.Child() apply_button = Gtk.Template.Child()
cover_changed = False cover_changed: bool = False
def __init__(self, game=None, **kwargs): def __init__(self, game: Game | None = None, **kwargs: Any):
super().__init__(**kwargs) super().__init__(**kwargs)
self.win = shared.win self.game: Game = game
self.game = game self.game_cover: GameCover = GameCover({self.cover})
self.game_cover = GameCover({self.cover})
self.set_transient_for(self.win) self.set_transient_for(shared.win)
if self.game: if self.game:
self.set_title(_("Game Details")) self.set_title(_("Game Details"))
@@ -114,7 +114,7 @@ class DetailsWindow(Adw.Window):
self.exec_info_label.set_label(exec_info_text) self.exec_info_label.set_label(exec_info_text)
def clear_info_selection(*_args): def clear_info_selection(*_args: Any) -> None:
self.exec_info_label.select_region(-1, -1) self.exec_info_label.select_region(-1, -1)
self.exec_info_popover.connect("show", clear_info_selection) self.exec_info_popover.connect("show", clear_info_selection)
@@ -130,13 +130,13 @@ class DetailsWindow(Adw.Window):
self.set_focus(self.name) self.set_focus(self.name)
self.present() self.present()
def delete_pixbuf(self, *_args): def delete_pixbuf(self, *_args: Any) -> None:
self.game_cover.new_cover() self.game_cover.new_cover()
self.cover_button_delete_revealer.set_reveal_child(False) self.cover_button_delete_revealer.set_reveal_child(False)
self.cover_changed = True self.cover_changed = True
def apply_preferences(self, *_args): def apply_preferences(self, *_args: Any) -> None:
final_name = self.name.get_text() final_name = self.name.get_text()
final_developer = self.developer.get_text() final_developer = self.developer.get_text()
final_executable = self.executable.get_text() final_executable = self.executable.get_text()
@@ -202,10 +202,10 @@ class DetailsWindow(Adw.Window):
self.game.developer = final_developer or None self.game.developer = final_developer or None
self.game.executable = final_executable self.game.executable = final_executable
if self.game.game_id in self.win.game_covers.keys(): if self.game.game_id in shared.win.game_covers.keys():
self.win.game_covers[self.game.game_id].animation = None shared.win.game_covers[self.game.game_id].animation = None
self.win.game_covers[self.game.game_id] = self.game_cover shared.win.game_covers[self.game.game_id] = self.game_cover
if self.cover_changed: if self.cover_changed:
save_cover( save_cover(
@@ -228,9 +228,9 @@ class DetailsWindow(Adw.Window):
self.game_cover.pictures.remove(self.cover) self.game_cover.pictures.remove(self.cover)
self.close() self.close()
self.win.show_details_page(self.game) shared.win.show_details_page(self.game)
def update_cover_callback(self, manager: SGDBManager): def update_cover_callback(self, manager: SGDBManager) -> None:
# Set the game as not loading # Set the game as not loading
self.game.set_loading(-1) self.game.set_loading(-1)
self.game.update() self.game.update()
@@ -247,25 +247,25 @@ class DetailsWindow(Adw.Window):
_("Preferences"), _("Preferences"),
).connect("response", self.update_cover_error_response) ).connect("response", self.update_cover_error_response)
def update_cover_error_response(self, _widget, response): def update_cover_error_response(self, _widget: Any, response: str) -> None:
if response == "open_preferences": if response == "open_preferences":
shared.win.get_application().on_preferences_action(page_name="sgdb") shared.win.get_application().on_preferences_action(page_name="sgdb")
def focus_executable(self, *_args): def focus_executable(self, *_args: Any) -> None:
self.set_focus(self.executable) self.set_focus(self.executable)
def toggle_loading(self): def toggle_loading(self) -> None:
self.apply_button.set_sensitive(not self.apply_button.get_sensitive()) self.apply_button.set_sensitive(not self.apply_button.get_sensitive())
self.spinner.set_spinning(not self.spinner.get_spinning()) self.spinner.set_spinning(not self.spinner.get_spinning())
self.cover_overlay.set_opacity(not self.cover_overlay.get_opacity()) self.cover_overlay.set_opacity(not self.cover_overlay.get_opacity())
def set_cover(self, _source, result, *_args): def set_cover(self, _source: Any, result: Gio.Task, *_args: Any) -> None:
try: try:
path = self.file_dialog.open_finish(result).get_path() path = self.file_dialog.open_finish(result).get_path()
except GLib.GError: except GLib.GError:
return return
def resize(): def resize() -> None:
if cover := resize_cover(path): if cover := resize_cover(path):
self.game_cover.new_cover(cover) self.game_cover.new_cover(cover)
self.cover_button_delete_revealer.set_reveal_child(True) self.cover_button_delete_revealer.set_reveal_child(True)
@@ -275,5 +275,5 @@ class DetailsWindow(Adw.Window):
self.toggle_loading() self.toggle_loading()
GLib.Thread.new(None, resize) GLib.Thread.new(None, resize)
def choose_cover(self, *_args): def choose_cover(self, *_args: Any) -> None:
self.file_dialog.open(self, None, self.set_cover) self.file_dialog.open(self, None, self.set_cover)

View File

@@ -23,10 +23,12 @@ import shlex
import subprocess import subprocess
from pathlib import Path from pathlib import Path
from time import time from time import time
from typing import Any
from gi.repository import Adw, GObject, Gtk from gi.repository import Adw, GObject, Gtk
from src import shared from src import shared
from src.game_cover import GameCover
# pylint: disable=too-many-instance-attributes # pylint: disable=too-many-instance-attributes
@@ -45,23 +47,23 @@ class Game(Gtk.Box):
game_options = Gtk.Template.Child() game_options = Gtk.Template.Child()
hidden_game_options = Gtk.Template.Child() hidden_game_options = Gtk.Template.Child()
loading = 0 loading: int = 0
filtered = False filtered: bool = False
added = None added: int
executable = None executable: str
game_id = None game_id: str
source = None source: str
hidden = False hidden: bool = False
last_played = 0 last_played: int = 0
name = None name: str
developer = None developer: str | None = None
removed = False removed: bool = False
blacklisted = False blacklisted: bool = False
game_cover = None game_cover: GameCover = None
version = 0 version: int = 0
def __init__(self, data, **kwargs): def __init__(self, data: dict[str, Any], **kwargs: Any) -> None:
super().__init__(**kwargs) super().__init__(**kwargs)
self.win = shared.win self.win = shared.win
@@ -82,20 +84,20 @@ class Game(Gtk.Box):
shared.schema.connect("changed", self.schema_changed) shared.schema.connect("changed", self.schema_changed)
def update_values(self, data): def update_values(self, data: dict[str, Any]) -> None:
for key, value in data.items(): for key, value in data.items():
# Convert executables to strings # Convert executables to strings
if key == "executable" and isinstance(value, list): if key == "executable" and isinstance(value, list):
value = shlex.join(value) value = shlex.join(value)
setattr(self, key, value) setattr(self, key, value)
def update(self): def update(self) -> None:
self.emit("update-ready", {}) self.emit("update-ready", {})
def save(self): def save(self) -> None:
self.emit("save-ready", {}) self.emit("save-ready", {})
def create_toast(self, title, action=None): def create_toast(self, title: str, action: str | None = None) -> None:
toast = Adw.Toast.new(title.format(self.name)) toast = Adw.Toast.new(title.format(self.name))
toast.set_priority(Adw.ToastPriority.HIGH) toast.set_priority(Adw.ToastPriority.HIGH)
toast.set_use_markup(False) toast.set_use_markup(False)
@@ -112,7 +114,7 @@ class Game(Gtk.Box):
self.win.toast_overlay.add_toast(toast) self.win.toast_overlay.add_toast(toast)
def launch(self): def launch(self) -> None:
self.last_played = int(time()) self.last_played = int(time())
self.save() self.save()
self.update() self.update()
@@ -130,7 +132,7 @@ class Game(Gtk.Box):
cwd=Path.home(), cwd=Path.home(),
shell=True, shell=True,
start_new_session=True, start_new_session=True,
creationflags=subprocess.CREATE_NEW_PROCESS_GROUP if os.name == "nt" else 0, creationflags=subprocess.CREATE_NEW_PROCESS_GROUP if os.name == "nt" else 0, # type: ignore
) )
if shared.schema.get_boolean("exit-after-launch"): if shared.schema.get_boolean("exit-after-launch"):
@@ -139,7 +141,7 @@ class Game(Gtk.Box):
# The variable is the title of the game # The variable is the title of the game
self.create_toast(_("{} launched")) self.create_toast(_("{} launched"))
def toggle_hidden(self, toast=True): def toggle_hidden(self, toast: bool = True) -> None:
self.hidden = not self.hidden self.hidden = not self.hidden
self.save() self.save()
@@ -155,7 +157,7 @@ class Game(Gtk.Box):
"hide", "hide",
) )
def remove_game(self): def remove_game(self) -> None:
# Add "removed=True" to the game properties so it can be deleted on next init # Add "removed=True" to the game properties so it can be deleted on next init
self.removed = True self.removed = True
self.save() self.save()
@@ -164,53 +166,58 @@ class Game(Gtk.Box):
if self.win.navigation_view.get_visible_page() == self.win.details_page: if self.win.navigation_view.get_visible_page() == self.win.details_page:
self.win.navigation_view.pop() self.win.navigation_view.pop()
# The variable is the title of the game self.create_toast(
self.create_toast(_("{} removed").format(self.name), "remove") # The variable is the title of the game
_("{} removed").format(GLib.markup_escape_text(self.name)),
"remove",
)
def set_loading(self, state): def set_loading(self, state: int) -> None:
self.loading += state self.loading += state
loading = self.loading > 0 loading = self.loading > 0
self.cover.set_opacity(int(not loading)) self.cover.set_opacity(int(not loading))
self.spinner.set_spinning(loading) self.spinner.set_spinning(loading)
def get_cover_path(self): def get_cover_path(self) -> Path | None:
cover_path = shared.covers_dir / f"{self.game_id}.gif" cover_path = shared.covers_dir / f"{self.game_id}.gif"
if cover_path.is_file(): if cover_path.is_file():
return cover_path return cover_path # type: ignore
cover_path = shared.covers_dir / f"{self.game_id}.tiff" cover_path = shared.covers_dir / f"{self.game_id}.tiff"
if cover_path.is_file(): if cover_path.is_file():
return cover_path return cover_path # type: ignore
return None return None
def toggle_play(self, _widget, _prop1, _prop2, state=True): def toggle_play(
self, _widget: Any, _prop1: Any, _prop2: Any, state: bool = True
) -> None:
if not self.menu_button.get_active(): if not self.menu_button.get_active():
self.play_revealer.set_reveal_child(not state) self.play_revealer.set_reveal_child(not state)
self.menu_revealer.set_reveal_child(not state) self.menu_revealer.set_reveal_child(not state)
def main_button_clicked(self, _widget, button): def main_button_clicked(self, _widget: Any, button: bool) -> None:
if shared.schema.get_boolean("cover-launches-game") ^ button: if shared.schema.get_boolean("cover-launches-game") ^ button:
self.launch() self.launch()
else: else:
self.win.show_details_page(self) self.win.show_details_page(self)
def set_play_icon(self): def set_play_icon(self) -> None:
self.play_button.set_icon_name( self.play_button.set_icon_name(
"help-about-symbolic" "help-about-symbolic"
if shared.schema.get_boolean("cover-launches-game") if shared.schema.get_boolean("cover-launches-game")
else "media-playback-start-symbolic" else "media-playback-start-symbolic"
) )
def schema_changed(self, _settings, key): def schema_changed(self, _settings: Any, key: str) -> None:
if key == "cover-launches-game": if key == "cover-launches-game":
self.set_play_icon() self.set_play_icon()
@GObject.Signal(name="update-ready", arg_types=[object]) @GObject.Signal(name="update-ready", arg_types=[object])
def update_ready(self, _additional_data) -> None: def update_ready(self, _additional_data): # type: ignore
"""Signal emitted when the game needs updating""" """Signal emitted when the game needs updating"""
@GObject.Signal(name="save-ready", arg_types=[object]) @GObject.Signal(name="save-ready", arg_types=[object])
def save_ready(self, _additional_data) -> None: def save_ready(self, _additional_data): # type: ignore
"""Signal emitted when the game needs saving""" """Signal emitted when the game needs saving"""

View File

@@ -17,19 +17,22 @@
# #
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
from gi.repository import Gdk, GdkPixbuf, Gio, GLib from pathlib import Path
from typing import Any, Callable
from gi.repository import Gdk, GdkPixbuf, Gio, GLib, Gtk
from PIL import Image, ImageFilter, ImageStat from PIL import Image, ImageFilter, ImageStat
from src import shared from src import shared
class GameCover: class GameCover:
texture = None texture: Gdk.Texture | None
blurred = None blurred: Gdk.Texture | None
luminance = None luminance: tuple[float, float] | None
path = None path: Path | None
animation = None animation: GdkPixbuf.PixbufAnimation | None
anim_iter = None anim_iter: GdkPixbuf.PixbufAnimationIter | None
placeholder = Gdk.Texture.new_from_resource( placeholder = Gdk.Texture.new_from_resource(
shared.PREFIX + "/library_placeholder.svg" shared.PREFIX + "/library_placeholder.svg"
@@ -38,21 +41,21 @@ class GameCover:
shared.PREFIX + "/library_placeholder_small.svg" shared.PREFIX + "/library_placeholder_small.svg"
) )
def __init__(self, pictures, path=None): def __init__(self, pictures: set[Gtk.Picture], path: Path | None = None) -> None:
self.pictures = pictures self.pictures = pictures
self.new_cover(path) self.new_cover(path)
# Wrap the function in another one as Gio.Task.run_in_thread does not allow for passing args # Wrap the function in another one as Gio.Task.run_in_thread does not allow for passing args
def create_func(self, path): def create_func(self, path: Path | None) -> Callable:
self.animation = GdkPixbuf.PixbufAnimation.new_from_file(str(path)) self.animation = GdkPixbuf.PixbufAnimation.new_from_file(str(path))
self.anim_iter = self.animation.get_iter() self.anim_iter = self.animation.get_iter()
def wrapper(task, *_args): def wrapper(task: Gio.Task, *_args: Any) -> None:
self.update_animation((task, self.animation)) self.update_animation((task, self.animation))
return wrapper return wrapper
def new_cover(self, path=None): def new_cover(self, path: Path | None = None) -> None:
self.animation = None self.animation = None
self.texture = None self.texture = None
self.blurred = None self.blurred = None
@@ -69,14 +72,14 @@ class GameCover:
if not self.animation: if not self.animation:
self.set_texture(self.texture) self.set_texture(self.texture)
def get_texture(self): def get_texture(self) -> Gdk.Texture:
return ( return (
Gdk.Texture.new_for_pixbuf(self.animation.get_static_image()) Gdk.Texture.new_for_pixbuf(self.animation.get_static_image())
if self.animation if self.animation
else self.texture else self.texture
) )
def get_blurred(self): def get_blurred(self) -> Gdk.Texture:
if not self.blurred: if not self.blurred:
if self.path: if self.path:
with Image.open(self.path) as image: with Image.open(self.path) as image:
@@ -94,24 +97,24 @@ class GameCover:
stat = ImageStat.Stat(image.convert("L")) stat = ImageStat.Stat(image.convert("L"))
# Luminance values for light and dark mode # Luminance values for light and dark mode
self.luminance = [ self.luminance = (
min((stat.mean[0] + stat.extrema[0][0]) / 510, 0.7), min((stat.mean[0] + stat.extrema[0][0]) / 510, 0.7),
max((stat.mean[0] + stat.extrema[0][1]) / 510, 0.3), max((stat.mean[0] + stat.extrema[0][1]) / 510, 0.3),
] )
else: else:
self.blurred = self.placeholder_small self.blurred = self.placeholder_small
self.luminance = (0.3, 0.5) self.luminance = (0.3, 0.5)
return self.blurred return self.blurred
def add_picture(self, picture): def add_picture(self, picture: Gtk.Picture) -> None:
self.pictures.add(picture) self.pictures.add(picture)
if not self.animation: if not self.animation:
self.set_texture(self.texture) self.set_texture(self.texture)
else: else:
self.update_animation((self.task, self.animation)) self.update_animation((self.task, self.animation))
def set_texture(self, texture): def set_texture(self, texture: Gdk.Texture) -> None:
self.pictures.discard( self.pictures.discard(
picture for picture in self.pictures if not picture.is_visible() picture for picture in self.pictures if not picture.is_visible()
) )
@@ -121,13 +124,13 @@ class GameCover:
for picture in self.pictures: for picture in self.pictures:
picture.set_paintable(texture or self.placeholder) picture.set_paintable(texture or self.placeholder)
def update_animation(self, data): def update_animation(self, data: GdkPixbuf.PixbufAnimation) -> None:
if self.animation == data[1]: if self.animation == data[1]:
self.anim_iter.advance() self.anim_iter.advance() # type: ignore
self.set_texture(Gdk.Texture.new_for_pixbuf(self.anim_iter.get_pixbuf())) self.set_texture(Gdk.Texture.new_for_pixbuf(self.anim_iter.get_pixbuf())) # type: ignore
delay_time = self.anim_iter.get_delay_time() delay_time = self.anim_iter.get_delay_time() # type: ignore
GLib.timeout_add( GLib.timeout_add(
20 if delay_time < 20 else delay_time, 20 if delay_time < 20 else delay_time,
self.update_animation, self.update_animation,

View File

@@ -49,8 +49,16 @@ class Importer(ErrorProducer):
n_pipelines_done: int = 0 n_pipelines_done: int = 0
game_pipelines: set[Pipeline] = None game_pipelines: set[Pipeline] = None
removed_game_ids: set[str] = set()
imported_game_ids: set[str] = set()
def __init__(self): def __init__(self):
super().__init__() super().__init__()
# TODO: make this stateful
shared.store.new_game_ids = set()
shared.store.duplicate_game_ids = set()
self.game_pipelines = set() self.game_pipelines = set()
self.sources = set() self.sources = set()
@@ -218,9 +226,37 @@ class Importer(ErrorProducer):
if self.finished: if self.finished:
self.import_callback() self.import_callback()
def remove_games(self):
"""Set removed to True for missing games"""
if not shared.schema.get_boolean("remove-missing"):
return
for game in shared.store:
if game.removed:
continue
if game.source == "imported":
continue
if not shared.schema.get_boolean(game.base_source):
continue
if game.game_id in shared.store.duplicate_game_ids:
continue
if game.game_id in shared.store.new_game_ids:
continue
logging.debug("Removing missing game %s (%s)", game.name, game.game_id)
game.removed = True
game.save()
game.update()
self.removed_game_ids.add(game.game_id)
def import_callback(self): def import_callback(self):
"""Callback called when importing has finished""" """Callback called when importing has finished"""
logging.info("Import done") logging.info("Import done")
self.remove_games()
self.imported_game_ids = shared.store.new_game_ids
shared.store.new_game_ids = set()
shared.store.duplicate_game_ids = set()
self.import_dialog.close() self.import_dialog.close()
self.summary_toast = self.create_summary_toast() self.summary_toast = self.create_summary_toast()
self.create_error_dialog() self.create_error_dialog()
@@ -280,27 +316,60 @@ class Importer(ErrorProducer):
dialog.present() dialog.present()
def undo_import(self, *_args):
for game_id in self.imported_game_ids:
shared.store[game_id].removed = True
shared.store[game_id].update()
shared.store[game_id].save()
for game_id in self.removed_game_ids:
shared.store[game_id].removed = False
shared.store[game_id].update()
shared.store[game_id].save()
self.imported_game_ids = set()
self.removed_game_ids = set()
self.summary_toast.dismiss()
logging.info("Import undone")
def create_summary_toast(self): def create_summary_toast(self):
"""N games imported toast""" """N games imported, removed toast"""
toast = Adw.Toast(timeout=0, priority=Adw.ToastPriority.HIGH) toast = Adw.Toast(timeout=0, priority=Adw.ToastPriority.HIGH)
if self.n_games_added == 0: if not self.n_games_added:
toast.set_title(_("No new games found")) toast_title = _("No new games found")
toast.set_button_label(_("Preferences"))
toast.connect( if not self.removed_game_ids:
"button-clicked", toast.set_button_label(_("Preferences"))
self.dialog_response_callback, toast.connect(
"open_preferences", "button-clicked",
"import", self.dialog_response_callback,
) "open_preferences",
"import",
)
elif self.n_games_added == 1: elif self.n_games_added == 1:
toast.set_title(_("1 game imported")) toast_title = _("1 game imported")
elif self.n_games_added > 1: elif self.n_games_added > 1:
# The variable is the number of games # The variable is the number of games
toast.set_title(_("{} games imported").format(self.n_games_added)) toast_title = _("{} games imported").format(self.n_games_added)
if (removed_length := len(self.removed_game_ids)) == 1:
# A single game removed
toast_title += ", " + _("1 removed")
elif removed_length > 1:
# The variable is the number of games removed
toast_title += ", " + _("{} removed").format(removed_length)
if self.n_games_added or self.removed_game_ids:
toast.set_button_label(_("Undo"))
toast.connect("button-clicked", self.undo_import)
toast.set_title(toast_title)
shared.win.toast_overlay.add_toast(toast) shared.win.toast_overlay.add_toast(toast)
return toast return toast

View File

@@ -90,18 +90,22 @@ class BottlesSource(URLExecutableSource):
url_format = 'bottles:run/"{bottle_name}"/"{game_name}"' url_format = 'bottles:run/"{bottle_name}"/"{game_name}"'
available_on = {"linux"} available_on = {"linux"}
locations = BottlesLocations( locations: BottlesLocations
Location(
schema_key="bottles-location", def __init__(self) -> None:
candidates=( super().__init__()
shared.flatpak_dir / "com.usebottles.bottles" / "data" / "bottles", self.locations = BottlesLocations(
shared.data_dir / "bottles/", Location(
shared.home / ".local" / "share" / "bottles", schema_key="bottles-location",
), candidates=(
paths={ shared.flatpak_dir / "com.usebottles.bottles" / "data" / "bottles",
"library.yml": LocationSubPath("library.yml"), shared.data_dir / "bottles/",
"data.yml": LocationSubPath("data.yml"), shared.home / ".local" / "share" / "bottles",
}, ),
invalid_subtitle=Location.DATA_INVALID_SUBTITLE, paths={
"library.yml": LocationSubPath("library.yml"),
"data.yml": LocationSubPath("data.yml"),
},
invalid_subtitle=Location.DATA_INVALID_SUBTITLE,
)
) )
)

View File

@@ -125,17 +125,21 @@ class FlatpakSource(Source):
executable_format = "flatpak run {flatpak_id}" executable_format = "flatpak run {flatpak_id}"
available_on = {"linux"} available_on = {"linux"}
locations = FlatpakLocations( locations: FlatpakLocations
Location(
schema_key="flatpak-location", def __init__(self) -> None:
candidates=( super().__init__()
"/var/lib/flatpak/", self.locations = FlatpakLocations(
shared.data_dir / "flatpak", Location(
), schema_key="flatpak-location",
paths={ candidates=(
"applications": LocationSubPath("exports/share/applications", True), "/var/lib/flatpak/",
"icons": LocationSubPath("exports/share/icons", True), shared.data_dir / "flatpak",
}, ),
invalid_subtitle=Location.DATA_INVALID_SUBTITLE, paths={
"applications": LocationSubPath("exports/share/applications", True),
"icons": LocationSubPath("exports/share/icons", True),
},
invalid_subtitle=Location.DATA_INVALID_SUBTITLE,
)
) )
)

View File

@@ -21,12 +21,12 @@
import json import json
import logging import logging
from abc import abstractmethod from abc import abstractmethod
from functools import cached_property
from hashlib import sha256 from hashlib import sha256
from json import JSONDecodeError from json import JSONDecodeError
from pathlib import Path from pathlib import Path
from time import time from time import time
from typing import Iterable, NamedTuple, Optional, TypedDict from typing import Iterable, NamedTuple, Optional, TypedDict
from functools import cached_property
from src import shared from src import shared
from src.game import Game from src.game import Game
@@ -108,7 +108,9 @@ class SubSourceIterable(Iterable):
"game_id": self.source.game_id_format.format( "game_id": self.source.game_id_format.format(
service=self.service, game_id=app_name service=self.service, game_id=app_name
), ),
"executable": self.source.executable_format.format(runner=runner, app_name=app_name), "executable": self.source.executable_format.format(
runner=runner, app_name=app_name
),
"hidden": self.source_iterable.is_hidden(app_name), "hidden": self.source_iterable.is_hidden(app_name),
} }
game = Game(values) game = Game(values)
@@ -363,27 +365,31 @@ class HeroicSource(URLExecutableSource):
url_format = "heroic://launch/{runner}/{app_name}" url_format = "heroic://launch/{runner}/{app_name}"
available_on = {"linux", "win32"} available_on = {"linux", "win32"}
locations = HeroicLocations( locations: HeroicLocations
Location(
schema_key="heroic-location",
candidates=(
shared.config_dir / "heroic",
shared.home / ".config" / "heroic",
shared.flatpak_dir
/ "com.heroicgameslauncher.hgl"
/ "config"
/ "heroic",
shared.appdata_dir / "heroic",
),
paths={
"config.json": LocationSubPath("config.json"),
"store_config.json": LocationSubPath("store/config.json"),
},
invalid_subtitle=Location.CONFIG_INVALID_SUBTITLE,
)
)
@property @property
def game_id_format(self) -> str: def game_id_format(self) -> str:
"""The string format used to construct game IDs""" """The string format used to construct game IDs"""
return self.source_id + "_{service}_{game_id}" return self.source_id + "_{service}_{game_id}"
def __init__(self) -> None:
super().__init__()
self.locations = HeroicLocations(
Location(
schema_key="heroic-location",
candidates=(
shared.config_dir / "heroic",
shared.home / ".config" / "heroic",
shared.flatpak_dir
/ "com.heroicgameslauncher.hgl"
/ "config"
/ "heroic",
shared.appdata_dir / "heroic",
),
paths={
"config.json": LocationSubPath("config.json"),
"store_config.json": LocationSubPath("store/config.json"),
},
invalid_subtitle=Location.CONFIG_INVALID_SUBTITLE,
)
)

View File

@@ -86,18 +86,22 @@ class ItchSource(URLExecutableSource):
url_format = "itch://caves/{cave_id}/launch" url_format = "itch://caves/{cave_id}/launch"
available_on = {"linux", "win32"} available_on = {"linux", "win32"}
locations = ItchLocations( locations: ItchLocations
Location(
schema_key="itch-location", def __init__(self) -> None:
candidates=( super().__init__()
shared.flatpak_dir / "io.itch.itch" / "config" / "itch", self.locations = ItchLocations(
shared.config_dir / "itch", Location(
shared.home / ".config" / "itch", schema_key="itch-location",
shared.appdata_dir / "itch", candidates=(
), shared.flatpak_dir / "io.itch.itch" / "config" / "itch",
paths={ shared.config_dir / "itch",
"butler.db": LocationSubPath("db/butler.db"), shared.home / ".config" / "itch",
}, shared.appdata_dir / "itch",
invalid_subtitle=Location.CONFIG_INVALID_SUBTITLE, ),
paths={
"butler.db": LocationSubPath("db/butler.db"),
},
invalid_subtitle=Location.CONFIG_INVALID_SUBTITLE,
)
) )
)

View File

@@ -26,7 +26,7 @@ from typing import NamedTuple
from src import shared from src import shared
from src.game import Game from src.game import Game
from src.importer.sources.location import Location, LocationSubPath from src.importer.sources.location import Location, LocationSubPath
from src.importer.sources.source import Source, SourceIterationResult, SourceIterable from src.importer.sources.source import Source, SourceIterable, SourceIterationResult
class LegendarySourceIterable(SourceIterable): class LegendarySourceIterable(SourceIterable):
@@ -100,17 +100,21 @@ class LegendarySource(Source):
available_on = {"linux"} available_on = {"linux"}
iterable_class = LegendarySourceIterable iterable_class = LegendarySourceIterable
locations = LegendaryLocations( locations: LegendaryLocations
Location(
schema_key="legendary-location", def __init__(self) -> None:
candidates=( super().__init__()
shared.config_dir / "legendary", self.locations = LegendaryLocations(
shared.home / ".config" / "legendary", Location(
), schema_key="legendary-location",
paths={ candidates=(
"installed.json": LocationSubPath("installed.json"), shared.config_dir / "legendary",
"metadata": LocationSubPath("metadata", True), shared.home / ".config" / "legendary",
}, ),
invalid_subtitle=Location.CONFIG_INVALID_SUBTITLE, paths={
"installed.json": LocationSubPath("installed.json"),
"metadata": LocationSubPath("metadata", True),
},
invalid_subtitle=Location.CONFIG_INVALID_SUBTITLE,
)
) )
)

View File

@@ -1,7 +1,7 @@
import logging import logging
from pathlib import Path
from typing import Mapping, Iterable, NamedTuple
from os import PathLike from os import PathLike
from pathlib import Path
from typing import Iterable, Mapping, NamedTuple
from src import shared from src import shared
@@ -70,7 +70,7 @@ class Location:
def resolve(self) -> None: def resolve(self) -> None:
"""Choose a root path from the candidates for the location. """Choose a root path from the candidates for the location.
If none fits, raise a UnresolvableLocationError""" If none fits, raise an UnresolvableLocationError"""
if self.root is not None: if self.root is not None:
return return

View File

@@ -100,33 +100,37 @@ class LutrisSource(URLExecutableSource):
# FIXME possible bug: config picks ~/.var... and cache picks ~/.local... # FIXME possible bug: config picks ~/.var... and cache picks ~/.local...
locations = LutrisLocations( locations: LutrisLocations
Location(
schema_key="lutris-location",
candidates=(
shared.flatpak_dir / "net.lutris.Lutris" / "data" / "lutris",
shared.data_dir / "lutris",
shared.home / ".local" / "share" / "lutris",
),
paths={
"pga.db": LocationSubPath("pga.db"),
},
invalid_subtitle=Location.DATA_INVALID_SUBTITLE,
),
Location(
schema_key="lutris-cache-location",
candidates=(
shared.flatpak_dir / "net.lutris.Lutris" / "cache" / "lutris",
shared.cache_dir / "lutris",
shared.home / ".cache" / "lutris",
),
paths={
"coverart": LocationSubPath("coverart", True),
},
invalid_subtitle=Location.CACHE_INVALID_SUBTITLE,
),
)
@property @property
def game_id_format(self): def game_id_format(self):
return self.source_id + "_{runner}_{game_id}" return self.source_id + "_{runner}_{game_id}"
def __init__(self) -> None:
super().__init__()
self.locations = LutrisLocations(
Location(
schema_key="lutris-location",
candidates=(
shared.flatpak_dir / "net.lutris.Lutris" / "data" / "lutris",
shared.data_dir / "lutris",
shared.home / ".local" / "share" / "lutris",
),
paths={
"pga.db": LocationSubPath("pga.db"),
},
invalid_subtitle=Location.DATA_INVALID_SUBTITLE,
),
Location(
schema_key="lutris-cache-location",
candidates=(
shared.flatpak_dir / "net.lutris.Lutris" / "cache" / "lutris",
shared.cache_dir / "lutris",
shared.home / ".cache" / "lutris",
),
paths={
"coverart": LocationSubPath("coverart", True),
},
invalid_subtitle=Location.CACHE_INVALID_SUBTITLE,
),
)

View File

@@ -145,28 +145,7 @@ class RetroarchSource(Source):
available_on = {"linux"} available_on = {"linux"}
iterable_class = RetroarchSourceIterable iterable_class = RetroarchSourceIterable
locations = RetroarchLocations( locations: RetroarchLocations
Location(
schema_key="retroarch-location",
candidates=[
shared.flatpak_dir / "org.libretro.RetroArch" / "config" / "retroarch",
shared.config_dir / "retroarch",
shared.home / ".config" / "retroarch",
# TODO: Windows support, waiting for executable path setting improvement
# Path("C:\\RetroArch-Win64"),
# Path("C:\\RetroArch-Win32"),
# TODO: UWP support (URL handler - https://github.com/libretro/RetroArch/pull/13563)
# shared.local_appdata_dir
# / "Packages"
# / "1e4cf179-f3c2-404f-b9f3-cb2070a5aad8_8ngdn9a6dx1ma"
# / "LocalState",
],
paths={
"retroarch.cfg": LocationSubPath("retroarch.cfg"),
},
invalid_subtitle=Location.CONFIG_INVALID_SUBTITLE,
)
)
@property @property
def executable_format(self): def executable_format(self):
@@ -176,15 +155,6 @@ class RetroarchSource(Source):
args = '-L "{core_path}" "{rom_path}"' args = '-L "{core_path}" "{rom_path}"'
return f"{base} {args}" return f"{base} {args}"
def __init__(self) -> None:
super().__init__()
try:
self.locations.config.candidates.append(self.get_steam_location())
except (OSError, KeyError, UnresolvableLocationError):
logging.debug("Steam isn't installed")
except ValueError as error:
logging.debug("RetroArch Steam location candiate not found", exc_info=error)
def get_steam_location(self) -> str: def get_steam_location(self) -> str:
""" """
Get the RetroArch installed via Steam location Get the RetroArch installed via Steam location
@@ -214,3 +184,37 @@ class RetroarchSource(Source):
return Path(f"{library_path}/steamapps/common/RetroArch") return Path(f"{library_path}/steamapps/common/RetroArch")
# Not found # Not found
raise ValueError("RetroArch not found in Steam library") raise ValueError("RetroArch not found in Steam library")
def __init__(self) -> None:
super().__init__()
self.locations = RetroarchLocations(
Location(
schema_key="retroarch-location",
candidates=[
shared.flatpak_dir
/ "org.libretro.RetroArch"
/ "config"
/ "retroarch",
shared.config_dir / "retroarch",
shared.home / ".config" / "retroarch",
# TODO: Windows support, waiting for executable path setting improvement
# Path("C:\\RetroArch-Win64"),
# Path("C:\\RetroArch-Win32"),
# TODO: UWP support (URL handler - https://github.com/libretro/RetroArch/pull/13563)
# shared.local_appdata_dir
# / "Packages"
# / "1e4cf179-f3c2-404f-b9f3-cb2070a5aad8_8ngdn9a6dx1ma"
# / "LocalState",
],
paths={
"retroarch.cfg": LocationSubPath("retroarch.cfg"),
},
invalid_subtitle=Location.CONFIG_INVALID_SUBTITLE,
)
)
try:
self.locations.config.candidates.append(self.get_steam_location())
except (OSError, KeyError, UnresolvableLocationError):
logging.debug("Steam isn't installed")
except ValueError as error:
logging.debug("RetroArch Steam location candiate not found", exc_info=error)

View File

@@ -56,6 +56,9 @@ class Source(Iterable):
variant: str = None variant: str = None
available_on: set[str] = set() available_on: set[str] = set()
iterable_class: type[SourceIterable] iterable_class: type[SourceIterable]
# NOTE: Locations must be set at __init__ time, not in the class definition.
# They must not be shared between source instances.
locations: Collection[Location] locations: Collection[Location]
@property @property
@@ -85,10 +88,7 @@ class Source(Iterable):
Get an iterator for the source Get an iterator for the source
:raises UnresolvableLocationError: Not iterable if any of the locations are unresolvable :raises UnresolvableLocationError: Not iterable if any of the locations are unresolvable
""" """
for location_name in ("data", "cache", "config"): for location in self.locations:
location = getattr(self, f"{location_name}_location", None)
if location is None:
continue
location.resolve() location.resolve()
return iter(self.iterable_class(self)) return iter(self.iterable_class(self))

View File

@@ -120,19 +120,25 @@ class SteamSource(URLExecutableSource):
iterable_class = SteamSourceIterable iterable_class = SteamSourceIterable
url_format = "steam://rungameid/{game_id}" url_format = "steam://rungameid/{game_id}"
locations = SteamLocations( locations: SteamLocations
Location(
schema_key="steam-location", def __init__(self) -> None:
candidates=( super().__init__()
shared.home / ".steam" / "steam", self.locations = SteamLocations(
shared.data_dir / "Steam", Location(
shared.flatpak_dir / "com.valvesoftware.Steam" / "data" / "Steam", schema_key="steam-location",
shared.programfiles32_dir / "Steam", candidates=(
), shared.home / ".steam" / "steam",
paths={ shared.data_dir / "Steam",
"libraryfolders.vdf": LocationSubPath("steamapps/libraryfolders.vdf"), shared.flatpak_dir / "com.valvesoftware.Steam" / "data" / "Steam",
"librarycache": LocationSubPath("appcache/librarycache", True), shared.programfiles32_dir / "Steam",
}, ),
invalid_subtitle=Location.DATA_INVALID_SUBTITLE, paths={
"libraryfolders.vdf": LocationSubPath(
"steamapps/libraryfolders.vdf"
),
"librarycache": LocationSubPath("appcache/librarycache", True),
},
invalid_subtitle=Location.DATA_INVALID_SUBTITLE,
)
) )
)

View File

@@ -21,6 +21,7 @@ import json
import lzma import lzma
import os import os
import sys import sys
from typing import Any
import gi import gi
@@ -56,15 +57,15 @@ from src.window import CartridgesWindow
class CartridgesApplication(Adw.Application): class CartridgesApplication(Adw.Application):
state = shared.AppState.DEFAULT state = shared.AppState.DEFAULT
win = None win: CartridgesWindow
def __init__(self): def __init__(self) -> None:
shared.store = Store() shared.store = Store()
super().__init__( super().__init__(
application_id=shared.APP_ID, flags=Gio.ApplicationFlags.FLAGS_NONE application_id=shared.APP_ID, flags=Gio.ApplicationFlags.FLAGS_NONE
) )
def do_activate(self): # pylint: disable=arguments-differ def do_activate(self) -> None: # pylint: disable=arguments-differ
"""Called on app creation""" """Called on app creation"""
setup_logging() setup_logging()
@@ -145,14 +146,17 @@ class CartridgesApplication(Adw.Application):
self.win.present() self.win.present()
def load_games_from_disk(self): def load_games_from_disk(self) -> None:
if shared.games_dir.is_dir(): if shared.games_dir.is_dir():
for game_file in shared.games_dir.iterdir(): for game_file in shared.games_dir.iterdir():
data = json.load(game_file.open()) try:
data = json.load(game_file.open())
except (OSError, json.decoder.JSONDecodeError):
continue
game = Game(data) game = Game(data)
shared.store.add_game(game, {"skip_save": True}) shared.store.add_game(game, {"skip_save": True})
def get_source_name(self, source_id): def get_source_name(self, source_id: str):
if source_id == "all": if source_id == "all":
name = _("All Games") name = _("All Games")
elif source_id == "imported": elif source_id == "imported":
@@ -161,7 +165,7 @@ class CartridgesApplication(Adw.Application):
name = globals()[f'{source_id.split("_")[0].title()}Source'].name name = globals()[f'{source_id.split("_")[0].title()}Source'].name
return name return name
def on_about_action(self, *_args): def on_about_action(self, *_args: Any) -> None:
# Get the debug info from the log files # Get the debug info from the log files
debug_str = "" debug_str = ""
for i, path in enumerate(shared.log_files): for i, path in enumerate(shared.log_files):
@@ -207,8 +211,12 @@ class CartridgesApplication(Adw.Application):
about.present() about.present()
def on_preferences_action( def on_preferences_action(
self, _action=None, _parameter=None, page_name=None, expander_row=None self,
): _action: Any = None,
_parameter: Any = None,
page_name: (str | None) = None,
expander_row: (str | None) = None,
) -> CartridgesWindow:
win = PreferencesWindow() win = PreferencesWindow()
if page_name: if page_name:
win.set_visible_page_name(page_name) win.set_visible_page_name(page_name)
@@ -218,76 +226,76 @@ class CartridgesApplication(Adw.Application):
return win return win
def on_launch_game_action(self, *_args): def on_launch_game_action(self, *_args: Any) -> None:
self.win.active_game.launch() self.win.active_game.launch()
def on_hide_game_action(self, *_args): def on_hide_game_action(self, *_args: Any) -> None:
self.win.active_game.toggle_hidden() self.win.active_game.toggle_hidden()
def on_edit_game_action(self, *_args): def on_edit_game_action(self, *_args: Any) -> None:
DetailsWindow(self.win.active_game) DetailsWindow(self.win.active_game)
def on_add_game_action(self, *_args): def on_add_game_action(self, *_args: Any) -> None:
DetailsWindow() DetailsWindow()
def on_import_action(self, *_args): def on_import_action(self, *_args: Any) -> None:
importer = Importer() shared.importer = Importer()
if shared.schema.get_boolean("lutris"): if shared.schema.get_boolean("lutris"):
importer.add_source(LutrisSource()) shared.importer.add_source(LutrisSource())
if shared.schema.get_boolean("steam"): if shared.schema.get_boolean("steam"):
importer.add_source(SteamSource()) shared.importer.add_source(SteamSource())
if shared.schema.get_boolean("heroic"): if shared.schema.get_boolean("heroic"):
importer.add_source(HeroicSource()) shared.importer.add_source(HeroicSource())
if shared.schema.get_boolean("bottles"): if shared.schema.get_boolean("bottles"):
importer.add_source(BottlesSource()) shared.importer.add_source(BottlesSource())
if shared.schema.get_boolean("flatpak"): if shared.schema.get_boolean("flatpak"):
importer.add_source(FlatpakSource()) shared.importer.add_source(FlatpakSource())
if shared.schema.get_boolean("itch"): if shared.schema.get_boolean("itch"):
importer.add_source(ItchSource()) shared.importer.add_source(ItchSource())
if shared.schema.get_boolean("legendary"): if shared.schema.get_boolean("legendary"):
importer.add_source(LegendarySource()) shared.importer.add_source(LegendarySource())
if shared.schema.get_boolean("retroarch"): if shared.schema.get_boolean("retroarch"):
importer.add_source(RetroarchSource()) shared.importer.add_source(RetroarchSource())
importer.run() shared.importer.run()
def on_remove_game_action(self, *_args): def on_remove_game_action(self, *_args: Any) -> None:
self.win.active_game.remove_game() self.win.active_game.remove_game()
def on_remove_game_details_view_action(self, *_args): def on_remove_game_details_view_action(self, *_args: Any) -> None:
if self.win.navigation_view.get_visible_page() == self.win.details_page: if self.win.navigation_view.get_visible_page() == self.win.details_page:
self.on_remove_game_action() self.on_remove_game_action()
def search(self, uri): def search(self, uri: str) -> None:
Gio.AppInfo.launch_default_for_uri(f"{uri}{self.win.active_game.name}") Gio.AppInfo.launch_default_for_uri(f"{uri}{self.win.active_game.name}")
def on_igdb_search_action(self, *_args): def on_igdb_search_action(self, *_args: Any) -> None:
self.search("https://www.igdb.com/search?type=1&q=") self.search("https://www.igdb.com/search?type=1&q=")
def on_sgdb_search_action(self, *_args): def on_sgdb_search_action(self, *_args: Any) -> None:
self.search("https://www.steamgriddb.com/search/grids?term=") self.search("https://www.steamgriddb.com/search/grids?term=")
def on_protondb_search_action(self, *_args): def on_protondb_search_action(self, *_args: Any) -> None:
self.search("https://www.protondb.com/search?q=") self.search("https://www.protondb.com/search?q=")
def on_lutris_search_action(self, *_args): def on_lutris_search_action(self, *_args: Any) -> None:
self.search("https://lutris.net/games?q=") self.search("https://lutris.net/games?q=")
def on_hltb_search_action(self, *_args): def on_hltb_search_action(self, *_args: Any) -> None:
self.search("https://howlongtobeat.com/?q=") self.search("https://howlongtobeat.com/?q=")
def on_quit_action(self, *_args): def on_quit_action(self, *_args: Any) -> None:
self.quit() self.quit()
def create_actions(self, actions): def create_actions(self, actions: set) -> None:
for action in actions: for action in actions:
simple_action = Gio.SimpleAction.new(action[0], None) simple_action = Gio.SimpleAction.new(action[0], None)
@@ -303,7 +311,7 @@ class CartridgesApplication(Adw.Application):
scope.add_action(simple_action) scope.add_action(simple_action)
def main(_version): def main(_version: int) -> Any:
"""App entry point""" """App entry point"""
app = CartridgesApplication() app = CartridgesApplication()
return app.run(sys.argv) return app.run(sys.argv)

View File

@@ -21,10 +21,12 @@ import logging
import re import re
from pathlib import Path from pathlib import Path
from shutil import rmtree from shutil import rmtree
from typing import Any, Callable
from gi.repository import Adw, Gio, GLib, Gtk from gi.repository import Adw, Gio, GLib, Gtk
from src import shared from src import shared
from src.game import Game
from src.importer.sources.bottles_source import BottlesSource from src.importer.sources.bottles_source import BottlesSource
from src.importer.sources.flatpak_source import FlatpakSource from src.importer.sources.flatpak_source import FlatpakSource
from src.importer.sources.heroic_source import HeroicSource from src.importer.sources.heroic_source import HeroicSource
@@ -52,6 +54,8 @@ class PreferencesWindow(Adw.PreferencesWindow):
cover_launches_game_switch = Gtk.Template.Child() cover_launches_game_switch = Gtk.Template.Child()
high_quality_images_switch = Gtk.Template.Child() high_quality_images_switch = Gtk.Template.Child()
remove_missing_switch = Gtk.Template.Child()
steam_expander_row = Gtk.Template.Child() steam_expander_row = Gtk.Template.Child()
steam_data_action_row = Gtk.Template.Child() steam_data_action_row = Gtk.Template.Child()
steam_data_file_chooser_button = Gtk.Template.Child() steam_data_file_chooser_button = Gtk.Template.Child()
@@ -104,10 +108,10 @@ class PreferencesWindow(Adw.PreferencesWindow):
reset_button = Gtk.Template.Child() reset_button = Gtk.Template.Child()
remove_all_games_button = Gtk.Template.Child() remove_all_games_button = Gtk.Template.Child()
removed_games = set() removed_games: set[Game] = set()
warning_menu_buttons = {} warning_menu_buttons: dict = {}
def __init__(self, **kwargs): def __init__(self, **kwargs: Any) -> None:
super().__init__(**kwargs) super().__init__(**kwargs)
self.win = shared.win self.win = shared.win
self.file_chooser = Gtk.FileDialog() self.file_chooser = Gtk.FileDialog()
@@ -153,7 +157,7 @@ class PreferencesWindow(Adw.PreferencesWindow):
self.init_source_row(source) self.init_source_row(source)
# SteamGridDB # SteamGridDB
def sgdb_key_changed(*_args): def sgdb_key_changed(*_args: Any) -> None:
shared.schema.set_string("sgdb-key", self.sgdb_key_entry_row.get_text()) shared.schema.set_string("sgdb-key", self.sgdb_key_entry_row.get_text())
self.sgdb_key_entry_row.set_text(shared.schema.get_string("sgdb-key")) self.sgdb_key_entry_row.set_text(shared.schema.get_string("sgdb-key"))
@@ -169,10 +173,11 @@ class PreferencesWindow(Adw.PreferencesWindow):
# Switches # Switches
self.bind_switches( self.bind_switches(
( {
"exit-after-launch", "exit-after-launch",
"cover-launches-game", "cover-launches-game",
"high-quality-images", "high-quality-images",
"remove-missing",
"lutris-import-steam", "lutris-import-steam",
"lutris-import-flatpak", "lutris-import-flatpak",
"heroic-import-epic", "heroic-import-epic",
@@ -183,10 +188,10 @@ class PreferencesWindow(Adw.PreferencesWindow):
"sgdb", "sgdb",
"sgdb-prefer", "sgdb-prefer",
"sgdb-animated", "sgdb-animated",
) }
) )
def set_sgdb_sensitive(widget): def set_sgdb_sensitive(widget: Adw.EntryRow) -> None:
if not widget.get_text(): if not widget.get_text():
shared.schema.set_boolean("sgdb", False) shared.schema.set_boolean("sgdb", False)
@@ -195,10 +200,10 @@ class PreferencesWindow(Adw.PreferencesWindow):
self.sgdb_key_entry_row.connect("changed", set_sgdb_sensitive) self.sgdb_key_entry_row.connect("changed", set_sgdb_sensitive)
set_sgdb_sensitive(self.sgdb_key_entry_row) set_sgdb_sensitive(self.sgdb_key_entry_row)
def get_switch(self, setting): def get_switch(self, setting: str) -> Any:
return getattr(self, f'{setting.replace("-", "_")}_switch') return getattr(self, f'{setting.replace("-", "_")}_switch')
def bind_switches(self, settings): def bind_switches(self, settings: set[str]) -> None:
for setting in settings: for setting in settings:
shared.schema.bind( shared.schema.bind(
setting, setting,
@@ -207,10 +212,12 @@ class PreferencesWindow(Adw.PreferencesWindow):
Gio.SettingsBindFlags.DEFAULT, Gio.SettingsBindFlags.DEFAULT,
) )
def choose_folder(self, _widget, callback, callback_data=None): def choose_folder(
self, _widget: Any, callback: Callable, callback_data: str | None = None
) -> None:
self.file_chooser.select_folder(self.win, None, callback, callback_data) self.file_chooser.select_folder(self.win, None, callback, callback_data)
def undo_remove_all(self, *_args): def undo_remove_all(self, *_args: Any) -> None:
shared.win.get_application().state = shared.AppState.UNDO_REMOVE_ALL_GAMES shared.win.get_application().state = shared.AppState.UNDO_REMOVE_ALL_GAMES
for game in self.removed_games: for game in self.removed_games:
game.removed = False game.removed = False
@@ -222,7 +229,7 @@ class PreferencesWindow(Adw.PreferencesWindow):
shared.win.get_application().state = shared.AppState.DEFAULT shared.win.get_application().state = shared.AppState.DEFAULT
shared.win.create_source_rows() shared.win.create_source_rows()
def remove_all_games(self, *_args): def remove_all_games(self, *_args: Any) -> None:
shared.win.get_application().state = shared.AppState.REMOVE_ALL_GAMES shared.win.get_application().state = shared.AppState.REMOVE_ALL_GAMES
for game in shared.store: for game in shared.store:
if not game.removed: if not game.removed:
@@ -238,7 +245,7 @@ class PreferencesWindow(Adw.PreferencesWindow):
shared.win.get_application().state = shared.AppState.DEFAULT shared.win.get_application().state = shared.AppState.DEFAULT
shared.win.create_source_rows() shared.win.create_source_rows()
def reset_app(self, *_args): def reset_app(self, *_args: Any) -> None:
rmtree(shared.data_dir / "cartridges", True) rmtree(shared.data_dir / "cartridges", True)
rmtree(shared.config_dir / "cartridges", True) rmtree(shared.config_dir / "cartridges", True)
rmtree(shared.cache_dir / "cartridges", True) rmtree(shared.cache_dir / "cartridges", True)
@@ -256,28 +263,24 @@ class PreferencesWindow(Adw.PreferencesWindow):
shared.win.get_application().quit() shared.win.get_application().quit()
def update_source_action_row_paths(self, source): def update_source_action_row_paths(self, source: Source) -> None:
"""Set the dir subtitle for a source's action rows""" """Set the dir subtitle for a source's action rows"""
for location in ("data", "config", "cache"): for location_name, location in source.locations._asdict().items():
# Get the action row to subtitle # Get the action row to subtitle
action_row = getattr( action_row = getattr(
self, f"{source.source_id}_{location}_action_row", None self, f"{source.source_id}_{location_name}_action_row", None
) )
if not action_row: if not action_row:
continue continue
path = Path(shared.schema.get_string(location.schema_key)).expanduser()
infix = "-cache" if location == "cache" else ""
key = f"{source.source_id}{infix}-location"
path = Path(shared.schema.get_string(key)).expanduser()
# Remove the path prefix if picked via Flatpak portal # Remove the path prefix if picked via Flatpak portal
subtitle = re.sub("/run/user/\\d*/doc/.*/", "", str(path)) subtitle = re.sub("/run/user/\\d*/doc/.*/", "", str(path))
action_row.set_subtitle(subtitle) action_row.set_subtitle(subtitle)
def resolve_locations(self, source: Source): def resolve_locations(self, source: Source) -> None:
"""Resolve locations and add a warning if location cannot be found""" """Resolve locations and add a warning if location cannot be found"""
def clear_warning_selection(_widget, label): def clear_warning_selection(_widget: Any, label: Gtk.Label) -> None:
label.select_region(-1, -1) label.select_region(-1, -1)
for location_name, location in source.locations._asdict().items(): for location_name, location in source.locations._asdict().items():
@@ -327,40 +330,30 @@ class PreferencesWindow(Adw.PreferencesWindow):
action_row.add_prefix(menu_button) action_row.add_prefix(menu_button)
self.warning_menu_buttons[source.source_id] = menu_button self.warning_menu_buttons[source.source_id] = menu_button
def init_source_row(self, source: Source): def init_source_row(self, source: Source) -> None:
"""Initialize a preference row for a source class""" """Initialize a preference row for a source class"""
def set_dir(_widget, result, location_name): def set_dir(_widget: Any, result: Gio.Task, location_name: str) -> None:
"""Callback called when a dir picker button is clicked""" """Callback called when a dir picker button is clicked"""
try: try:
path = Path(self.file_chooser.select_folder_finish(result).get_path()) path = Path(self.file_chooser.select_folder_finish(result).get_path())
except GLib.GError: except GLib.GError:
return return
# Good picked location # Good picked location
location = getattr(source.locations, location_name) location = source.locations._asdict()[location_name]
if location.check_candidate(path): if location.check_candidate(path):
# Set the schema shared.schema.set_string(location.schema_key, str(path))
match location_name:
case "config" | "data":
infix = ""
case _:
infix = f"-{location_name}"
key = f"{source.source_id}{infix}-location"
value = str(path)
shared.schema.set_string(key, value)
# Update the row
self.update_source_action_row_paths(source) self.update_source_action_row_paths(source)
if self.warning_menu_buttons.get(source.source_id): if self.warning_menu_buttons.get(source.source_id):
action_row = getattr( action_row = getattr(
self, f"{source.source_id}_{location_name}_action_row", None self, f"{source.source_id}_{location_name}_action_row", None
) )
action_row.remove(self.warning_menu_buttons[source.source_id]) action_row.remove( # type: ignore
self.warning_menu_buttons[source.source_id]
)
self.warning_menu_buttons.pop(source.source_id) self.warning_menu_buttons.pop(source.source_id)
logging.debug("User-set value for %s is %s", location.schema_key, path)
logging.debug("User-set value for schema key %s: %s", key, value)
# Bad picked location, inform user # Bad picked location, inform user
else: else:
@@ -373,7 +366,7 @@ class PreferencesWindow(Adw.PreferencesWindow):
_("Set Location"), _("Set Location"),
) )
def on_response(widget, response): def on_response(widget: Any, response: str) -> None:
if response == "choose_folder": if response == "choose_folder":
self.choose_folder(widget, set_dir, location_name) self.choose_folder(widget, set_dir, location_name)

View File

@@ -106,5 +106,5 @@ class Pipeline(GObject.Object):
self.advance() self.advance()
@GObject.Signal(name="advanced") @GObject.Signal(name="advanced")
def advanced(self) -> None: def advanced(self): # type: ignore
"""Signal emitted when the pipeline has advanced""" """Signal emitted when the pipeline has advanced"""

View File

@@ -33,12 +33,16 @@ class Store:
pipeline_managers: set[Manager] pipeline_managers: set[Manager]
pipelines: dict[str, Pipeline] pipelines: dict[str, Pipeline]
source_games: MutableMapping[str, MutableMapping[str, Game]] source_games: MutableMapping[str, MutableMapping[str, Game]]
new_game_ids: set[str]
duplicate_game_ids: set[str]
def __init__(self) -> None: def __init__(self) -> None:
self.managers = {} self.managers = {}
self.pipeline_managers = set() self.pipeline_managers = set()
self.pipelines = {} self.pipelines = {}
self.source_games = {} self.source_games = {}
self.new_game_ids = set()
self.duplicate_game_ids = set()
def __contains__(self, obj: object) -> bool: def __contains__(self, obj: object) -> bool:
"""Check if the game is present in the store with the `in` keyword""" """Check if the game is present in the store with the `in` keyword"""
@@ -87,7 +91,7 @@ class Store:
self.pipeline_managers.discard(self.managers[manager_type]) self.pipeline_managers.discard(self.managers[manager_type])
def cleanup_game(self, game: Game) -> None: def cleanup_game(self, game: Game) -> None:
"""Remove a game's files""" """Remove a game's files, dismiss any loose toasts"""
for path in ( for path in (
shared.games_dir / f"{game.game_id}.json", shared.games_dir / f"{game.game_id}.json",
shared.covers_dir / f"{game.game_id}.tiff", shared.covers_dir / f"{game.game_id}.tiff",
@@ -95,6 +99,14 @@ class Store:
): ):
path.unlink(missing_ok=True) path.unlink(missing_ok=True)
# TODO: don't run this if the state is startup
for undo in ("remove", "hide"):
try:
shared.win.toasts[(game, undo)].dismiss()
shared.win.toasts.pop((game, undo))
except KeyError:
pass
def add_game( def add_game(
self, game: Game, additional_data: dict, run_pipeline=True self, game: Game, additional_data: dict, run_pipeline=True
) -> Pipeline | None: ) -> Pipeline | None:
@@ -114,6 +126,7 @@ class Store:
if not stored_game: if not stored_game:
# New game, do as normal # New game, do as normal
logging.debug("New store game %s (%s)", game.name, game.game_id) logging.debug("New store game %s (%s)", game.name, game.game_id)
self.new_game_ids.add(game.game_id)
elif stored_game.removed: elif stored_game.removed:
# Will replace a removed game, cleanup its remains # Will replace a removed game, cleanup its remains
logging.debug( logging.debug(
@@ -122,9 +135,11 @@ class Store:
game.game_id, game.game_id,
) )
self.cleanup_game(stored_game) self.cleanup_game(stored_game)
self.new_game_ids.add(game.game_id)
else: else:
# Duplicate game, ignore it # Duplicate game, ignore it
logging.debug("Duplicate store game %s (%s)", game.name, game.game_id) logging.debug("Duplicate store game %s (%s)", game.name, game.game_id)
self.duplicate_game_ids.add(game.game_id)
return None return None
# Connect signals # Connect signals

View File

@@ -1,32 +0,0 @@
# check_install.py
#
# Copyright 2022-2023 kramo
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
# SPDX-License-Identifier: GPL-3.0-or-later
from pathlib import Path
# TODO delegate to the sources
def check_install(check, locations, setting=None, subdirs=(Path(),)):
for location in locations:
for subdir in (Path(),) + subdirs:
if (location / subdir / check).exists():
if setting:
setting[0].set_string(setting[1], str(location / subdir))
return location / subdir
return False

View File

@@ -17,9 +17,13 @@
# #
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
from gi.repository import Adw, Gtk from typing import Any
from gi.repository import Adw, Gio, GLib, Gtk
from src import shared from src import shared
from src.game import Game
from src.game_cover import GameCover
from src.utils.relative_date import relative_date from src.utils.relative_date import relative_date
@@ -73,13 +77,13 @@ class CartridgesWindow(Adw.ApplicationWindow):
hidden_search_entry = Gtk.Template.Child() hidden_search_entry = Gtk.Template.Child()
hidden_search_button = Gtk.Template.Child() hidden_search_button = Gtk.Template.Child()
game_covers = {} game_covers: dict = {}
toasts = {} toasts: dict = {}
active_game = None active_game: Game
details_view_game_cover = None details_view_game_cover: GameCover | None = None
sort_state = "a-z" sort_state: str = "a-z"
filter_state = "all" filter_state: str = "all"
source_rows = {} source_rows: dict = {}
def create_source_rows(self): def create_source_rows(self):
def get_removed(source_id): def get_removed(source_id):
@@ -204,7 +208,7 @@ class CartridgesWindow(Adw.ApplicationWindow):
if self.overlay_split_view.get_collapsed(): if self.overlay_split_view.get_collapsed():
self.overlay_split_view.set_show_sidebar(False) self.overlay_split_view.set_show_sidebar(False)
def __init__(self, **kwargs): def __init__(self, **kwargs: Any) -> None:
super().__init__(**kwargs) super().__init__(**kwargs)
self.details_view.set_measure_overlay(self.details_view_toolbar_view, True) self.details_view.set_measure_overlay(self.details_view_toolbar_view, True)
@@ -249,11 +253,11 @@ class CartridgesWindow(Adw.ApplicationWindow):
style_manager.connect("notify::dark", self.set_details_view_opacity) style_manager.connect("notify::dark", self.set_details_view_opacity)
style_manager.connect("notify::high-contrast", self.set_details_view_opacity) style_manager.connect("notify::high-contrast", self.set_details_view_opacity)
def search_changed(self, _widget, hidden): def search_changed(self, _widget: Any, hidden: bool) -> None:
# Refresh search filter on keystroke in search box # Refresh search filter on keystroke in search box
(self.hidden_library if hidden else self.library).invalidate_filter() (self.hidden_library if hidden else self.library).invalidate_filter()
def set_library_child(self): def set_library_child(self) -> None:
child, hidden_child = self.notice_empty, self.hidden_notice_empty child, hidden_child = self.notice_empty, self.hidden_notice_empty
for game in shared.store: for game in shared.store:
@@ -286,7 +290,7 @@ class CartridgesWindow(Adw.ApplicationWindow):
remove_from_overlay(self.hidden_notice_empty) remove_from_overlay(self.hidden_notice_empty)
remove_from_overlay(self.hidden_notice_no_results) remove_from_overlay(self.hidden_notice_no_results)
def filter_func(self, child): def filter_func(self, child: Gtk.Widget) -> bool:
game = child.get_child() game = child.get_child()
text = ( text = (
( (
@@ -314,10 +318,10 @@ class CartridgesWindow(Adw.ApplicationWindow):
return not filtered return not filtered
def set_active_game(self, _widget, _pspec, game): def set_active_game(self, _widget: Any, _pspec: Any, game: Game) -> None:
self.active_game = game self.active_game = game
def show_details_page(self, game): def show_details_page(self, game: Game) -> None:
self.active_game = game self.active_game = game
self.details_view_cover.set_opacity(int(not game.loading)) self.details_view_cover.set_opacity(int(not game.loading))
@@ -365,7 +369,7 @@ class CartridgesWindow(Adw.ApplicationWindow):
self.set_details_view_opacity() self.set_details_view_opacity()
def set_details_view_opacity(self, *_args): def set_details_view_opacity(self, *_args: Any) -> None:
if self.navigation_view.get_visible_page() != self.details_page: if self.navigation_view.get_visible_page() != self.details_page:
return return
@@ -376,12 +380,12 @@ class CartridgesWindow(Adw.ApplicationWindow):
return return
self.details_view_blurred_cover.set_opacity( self.details_view_blurred_cover.set_opacity(
1 - self.details_view_game_cover.luminance[0] 1 - self.details_view_game_cover.luminance[0] # type: ignore
if style_manager.get_dark() if style_manager.get_dark()
else self.details_view_game_cover.luminance[1] else self.details_view_game_cover.luminance[1] # type: ignore
) )
def sort_func(self, child1, child2): def sort_func(self, child1: Gtk.Widget, child2: Gtk.Widget) -> int:
var, order = "name", True var, order = "name", True
if self.sort_state in ("newest", "oldest"): if self.sort_state in ("newest", "oldest"):
@@ -391,7 +395,7 @@ class CartridgesWindow(Adw.ApplicationWindow):
elif self.sort_state == "a-z": elif self.sort_state == "a-z":
order = False order = False
def get_value(index): def get_value(index: int) -> str:
return str( return str(
getattr((child1.get_child(), child2.get_child())[index], var) getattr((child1.get_child(), child2.get_child())[index], var)
).lower() ).lower()
@@ -412,24 +416,24 @@ class CartridgesWindow(Adw.ApplicationWindow):
) )
self.overlay_split_view.set_show_sidebar(value) self.overlay_split_view.set_show_sidebar(value)
def on_go_to_parent_action(self, *_args): def on_go_to_parent_action(self, *_args: Any) -> None:
if self.navigation_view.get_visible_page() == self.details_page: if self.navigation_view.get_visible_page() == self.details_page:
self.navigation_view.pop() self.navigation_view.pop()
def on_go_home_action(self, *_args): def on_go_home_action(self, *_args: Any) -> None:
self.navigation_view.pop_to_page(self.library_page) self.navigation_view.pop_to_page(self.library_page)
def on_show_hidden_action(self, *_args): def on_show_hidden_action(self, *_args: Any) -> None:
self.navigation_view.push(self.hidden_library_page) self.navigation_view.push(self.hidden_library_page)
def on_sort_action(self, action, state): def on_sort_action(self, action: Gio.SimpleAction, state: GLib.Variant) -> None:
action.set_state(state) action.set_state(state)
self.sort_state = str(state).strip("'") self.sort_state = str(state).strip("'")
self.library.invalidate_sort() self.library.invalidate_sort()
shared.state_schema.set_string("sort-mode", self.sort_state) shared.state_schema.set_string("sort-mode", self.sort_state)
def on_toggle_search_action(self, *_args): def on_toggle_search_action(self, *_args: Any) -> None:
if self.navigation_view.get_visible_page() == self.library_page: if self.navigation_view.get_visible_page() == self.library_page:
search_bar = self.search_bar search_bar = self.search_bar
search_entry = self.search_entry search_entry = self.search_entry
@@ -446,7 +450,7 @@ class CartridgesWindow(Adw.ApplicationWindow):
search_entry.set_text("") search_entry.set_text("")
def on_escape_action(self, *_args): def on_escape_action(self, *_args: Any) -> None:
if ( if (
self.get_focus() == self.search_entry.get_focus_child() self.get_focus() == self.search_entry.get_focus_child()
or self.hidden_search_entry.get_focus_child() or self.hidden_search_entry.get_focus_child()
@@ -455,7 +459,7 @@ class CartridgesWindow(Adw.ApplicationWindow):
else: else:
self.navigation_view.pop() self.navigation_view.pop()
def show_details_page_search(self, widget): def show_details_page_search(self, widget: Gtk.Widget) -> None:
library = ( library = (
self.hidden_library if widget == self.hidden_search_entry else self.library self.hidden_library if widget == self.hidden_search_entry else self.library
) )
@@ -471,30 +475,39 @@ class CartridgesWindow(Adw.ApplicationWindow):
index += 1 index += 1
def on_undo_action(self, _widget, game=None, undo=None): def on_undo_action(
self, _widget: Any, game: Game | None = None, undo: str | None = None
) -> None:
if not game: # If the action was activated via Ctrl + Z if not game: # If the action was activated via Ctrl + Z
if shared.importer and (
shared.importer.imported_game_ids or shared.importer.removed_game_ids
):
shared.importer.undo_import()
return
try: try:
game = tuple(self.toasts.keys())[-1][0] game = tuple(self.toasts.keys())[-1][0]
undo = tuple(self.toasts.keys())[-1][1] undo = tuple(self.toasts.keys())[-1][1]
except IndexError: except IndexError:
return return
if undo == "hide": if game:
game.toggle_hidden(False) if undo == "hide":
game.toggle_hidden(False)
elif undo == "remove": elif undo == "remove":
game.removed = False game.removed = False
game.save() game.save()
game.update() game.update()
self.toasts[(game, undo)].dismiss() self.toasts[(game, undo)].dismiss()
self.toasts.pop((game, undo)) self.toasts.pop((game, undo))
def on_open_menu_action(self, *_args): def on_open_menu_action(self, *_args: Any) -> None:
if self.navigation_view.get_visible_page() == self.library_page: if self.navigation_view.get_visible_page() == self.library_page:
self.primary_menu_button.popup() self.primary_menu_button.popup()
elif self.navigation_view.get_visible_page() == self.hidden_library_page: elif self.navigation_view.get_visible_page() == self.hidden_library_page:
self.hidden_primary_menu_button.popup() self.hidden_primary_menu_button.popup()
def on_close_action(self, *_args): def on_close_action(self, *_args: Any) -> None:
self.close() self.close()