# preferences.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 . # # SPDX-License-Identifier: GPL-3.0-or-later import logging import re from pathlib import Path from shutil import rmtree from typing import Any, Callable, Optional from gi.repository import Adw, Gio, GLib, Gtk from src import shared from src.game import Game from src.importer.sources.bottles_source import BottlesSource from src.importer.sources.flatpak_source import FlatpakSource from src.importer.sources.heroic_source import HeroicSource from src.importer.sources.itch_source import ItchSource from src.importer.sources.legendary_source import LegendarySource from src.importer.sources.location import UnresolvableLocationError from src.importer.sources.lutris_source import LutrisSource from src.importer.sources.retroarch_source import RetroarchSource from src.importer.sources.source import Source from src.importer.sources.steam_source import SteamSource from src.utils.create_dialog import create_dialog @Gtk.Template(resource_path=shared.PREFIX + "/gtk/preferences.ui") class PreferencesWindow(Adw.PreferencesWindow): __gtype_name__ = "PreferencesWindow" general_page = Gtk.Template.Child() import_page = Gtk.Template.Child() sgdb_page = Gtk.Template.Child() sources_group = Gtk.Template.Child() exit_after_launch_switch = Gtk.Template.Child() cover_launches_game_switch = Gtk.Template.Child() high_quality_images_switch = Gtk.Template.Child() remove_missing_switch = Gtk.Template.Child() steam_expander_row = Gtk.Template.Child() steam_data_action_row = Gtk.Template.Child() steam_data_file_chooser_button = Gtk.Template.Child() lutris_expander_row = Gtk.Template.Child() lutris_data_action_row = Gtk.Template.Child() lutris_data_file_chooser_button = Gtk.Template.Child() lutris_cache_action_row = Gtk.Template.Child() lutris_cache_file_chooser_button = Gtk.Template.Child() lutris_import_steam_switch = Gtk.Template.Child() lutris_import_flatpak_switch = Gtk.Template.Child() heroic_expander_row = Gtk.Template.Child() heroic_config_action_row = Gtk.Template.Child() heroic_config_file_chooser_button = Gtk.Template.Child() heroic_import_epic_switch = Gtk.Template.Child() heroic_import_gog_switch = Gtk.Template.Child() heroic_import_amazon_switch = Gtk.Template.Child() heroic_import_sideload_switch = Gtk.Template.Child() bottles_expander_row = Gtk.Template.Child() bottles_data_action_row = Gtk.Template.Child() bottles_data_file_chooser_button = Gtk.Template.Child() itch_expander_row = Gtk.Template.Child() itch_config_action_row = Gtk.Template.Child() itch_config_file_chooser_button = Gtk.Template.Child() legendary_expander_row = Gtk.Template.Child() legendary_config_action_row = Gtk.Template.Child() legendary_config_file_chooser_button = Gtk.Template.Child() retroarch_expander_row = Gtk.Template.Child() retroarch_config_action_row = Gtk.Template.Child() retroarch_config_file_chooser_button = Gtk.Template.Child() flatpak_expander_row = Gtk.Template.Child() flatpak_data_action_row = Gtk.Template.Child() flatpak_data_file_chooser_button = Gtk.Template.Child() flatpak_import_launchers_switch = Gtk.Template.Child() sgdb_key_group = Gtk.Template.Child() sgdb_key_entry_row = Gtk.Template.Child() sgdb_switch = Gtk.Template.Child() sgdb_switch_row = Gtk.Template.Child() sgdb_prefer_switch = Gtk.Template.Child() sgdb_animated_switch = Gtk.Template.Child() danger_zone_group = Gtk.Template.Child() reset_action_row = Gtk.Template.Child() reset_button = Gtk.Template.Child() remove_all_games_button = Gtk.Template.Child() removed_games: set[Game] = set() warning_menu_buttons: dict = {} def __init__(self, **kwargs: Any) -> None: super().__init__(**kwargs) self.win = shared.win self.file_chooser = Gtk.FileDialog() self.set_transient_for(self.win) self.toast = Adw.Toast.new(_("All games removed")) self.toast.set_button_label(_("Undo")) self.toast.connect("button-clicked", self.undo_remove_all, None) self.toast.set_priority(Adw.ToastPriority.HIGH) (shortcut_controller := Gtk.ShortcutController()).add_shortcut( Gtk.Shortcut.new( Gtk.ShortcutTrigger.parse_string("z"), Gtk.CallbackAction.new(self.undo_remove_all), ) ) self.add_controller(shortcut_controller) # General self.remove_all_games_button.connect("clicked", self.remove_all_games) # Debug if shared.PROFILE == "development": self.reset_action_row.set_visible(True) self.reset_button.connect("clicked", self.reset_app) self.set_default_size(-1, 560) # Sources settings for source_class in ( BottlesSource, FlatpakSource, HeroicSource, ItchSource, LegendarySource, LutrisSource, RetroarchSource, SteamSource, ): source = source_class() if not source.is_available: expander_row = getattr(self, f"{source.source_id}_expander_row") expander_row.set_visible(False) else: self.init_source_row(source) # SteamGridDB def sgdb_key_changed(*_args: Any) -> None: 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.connect("changed", sgdb_key_changed) self.sgdb_key_group.set_description( _( "An API key is required to use SteamGridDB. You can generate one {}here{}." ).format( '', "" ) ) def set_sgdb_sensitive(widget: Adw.EntryRow) -> None: if not widget.get_text(): shared.schema.set_boolean("sgdb", False) self.sgdb_switch_row.set_sensitive(widget.get_text()) self.sgdb_key_entry_row.connect("changed", set_sgdb_sensitive) set_sgdb_sensitive(self.sgdb_key_entry_row) # Switches self.bind_switches( { "exit-after-launch", "cover-launches-game", "high-quality-images", "remove-missing", "lutris-import-steam", "lutris-import-flatpak", "heroic-import-epic", "heroic-import-gog", "heroic-import-amazon", "heroic-import-sideload", "flatpak-import-launchers", "sgdb", "sgdb-prefer", "sgdb-animated", } ) def get_switch(self, setting: str) -> Any: return getattr(self, f'{setting.replace("-", "_")}_switch') def bind_switches(self, settings: set[str]) -> None: for setting in settings: shared.schema.bind( setting, self.get_switch(setting), "active", Gio.SettingsBindFlags.DEFAULT, ) def choose_folder( self, _widget: Any, callback: Callable, callback_data: Optional[str] = None ) -> None: self.file_chooser.select_folder(self.win, None, callback, callback_data) def undo_remove_all(self, *_args: Any) -> None: for game in self.removed_games: game.removed = False game.save() game.update() self.removed_games = set() self.toast.dismiss() def remove_all_games(self, *_args: Any) -> None: for game in shared.store: if not game.removed: self.removed_games.add(game) game.removed = True game.save() game.update() if self.win.stack.get_visible_child() == self.win.details_view: self.win.on_go_back_action() self.add_toast(self.toast) def reset_app(self, *_args: Any) -> None: rmtree(shared.data_dir / "cartridges", True) rmtree(shared.config_dir / "cartridges", True) rmtree(shared.cache_dir / "cartridges", True) for key in ( (settings_schema_source := Gio.SettingsSchemaSource.get_default()) .lookup(shared.APP_ID, True) .list_keys() ): shared.schema.reset(key) for key in settings_schema_source.lookup( shared.APP_ID + ".State", True ).list_keys(): shared.state_schema.reset(key) shared.win.get_application().quit() def update_source_action_row_paths(self, source: Source) -> None: """Set the dir subtitle for a source's action rows""" for location_name, location in source.locations._asdict().items(): # Get the action row to subtitle action_row = getattr( self, f"{source.source_id}_{location_name}_action_row", None ) if not action_row: continue path = Path(shared.schema.get_string(location.schema_key)).expanduser() # Remove the path prefix if picked via Flatpak portal subtitle = re.sub("/run/user/\\d*/doc/.*/", "", str(path)) action_row.set_subtitle(subtitle) def resolve_locations(self, source: Source) -> None: """Resolve locations and add a warning if location cannot be found""" def clear_warning_selection(_widget: Any, label: Gtk.Label) -> None: label.select_region(-1, -1) for location_name, location in source.locations._asdict().items(): action_row = getattr( self, f"{source.source_id}_{location_name}_action_row", None ) if not action_row: continue try: location.resolve() except UnresolvableLocationError: popover = Gtk.Popover( child=( label := Gtk.Label( label=( '' + _("Installation Not Found") + "\n" + _("Select a valid directory.") ), use_markup=True, wrap=True, max_width_chars=50, halign=Gtk.Align.CENTER, valign=Gtk.Align.CENTER, justify=Gtk.Justification.CENTER, margin_top=9, margin_bottom=9, margin_start=12, margin_end=12, selectable=True, ) ) ) popover.connect("show", clear_warning_selection, label) menu_button = Gtk.MenuButton( icon_name="dialog-warning-symbolic", valign=Gtk.Align.CENTER, popover=popover, ) menu_button.add_css_class("warning") action_row.add_prefix(menu_button) self.warning_menu_buttons[source.source_id] = menu_button def init_source_row(self, source: Source) -> None: """Initialize a preference row for a source class""" def set_dir(_widget: Any, result: Gio.Task, location_name: str) -> None: """Callback called when a dir picker button is clicked""" try: path = Path(self.file_chooser.select_folder_finish(result).get_path()) except GLib.GError: return # Good picked location location = source.locations._asdict()[location_name] if location.check_candidate(path): shared.schema.set_string(location.schema_key, str(path)) self.update_source_action_row_paths(source) if self.warning_menu_buttons.get(source.source_id): action_row = getattr( self, f"{source.source_id}_{location_name}_action_row", None ) action_row.remove( # type: ignore self.warning_menu_buttons[source.source_id] ) self.warning_menu_buttons.pop(source.source_id) logging.debug("User-set value for %s is %s", location.schema_key, path) # Bad picked location, inform user else: title = _("Invalid Directory") dialog = create_dialog( self, title, location.invalid_subtitle.format(source.name), "choose_folder", _("Set Location"), ) def on_response(widget: Any, response: str) -> None: if response == "choose_folder": self.choose_folder(widget, set_dir, location_name) dialog.connect("response", on_response) # Bind expander row activation to source being enabled expander_row = getattr(self, f"{source.source_id}_expander_row") shared.schema.bind( source.source_id, expander_row, "enable-expansion", Gio.SettingsBindFlags.DEFAULT, ) # Connect dir picker buttons for location_name in source.locations._asdict(): button = getattr( self, f"{source.source_id}_{location_name}_file_chooser_button", None ) if button is not None: button.connect("clicked", self.choose_folder, set_dir, location_name) # Set the source row subtitles self.resolve_locations(source) self.update_source_action_row_paths(source)