import os import shlex import shutil import signal import subprocess import sys import portprotonqt.themes.standart.styles as default_styles import psutil from portprotonqt.dialogs import AddGameDialog from portprotonqt.game_card import GameCard from portprotonqt.custom_widgets import FlowLayout, ClickableLabel, AutoSizeButton, NavLabel from portprotonqt.input_manager import InputManager from portprotonqt.context_menu_manager import ContextMenuManager from portprotonqt.image_utils import load_pixmap_async, round_corners, ImageCarousel from portprotonqt.steam_api import get_steam_game_info_async, get_full_steam_game_info_async, get_steam_installed_games from portprotonqt.egs_api import load_egs_games_async from portprotonqt.theme_manager import ThemeManager, load_theme_screenshots, load_logo from portprotonqt.time_utils import save_last_launch, get_last_launch, parse_playtime_file, format_playtime, get_last_launch_timestamp, format_last_launch from portprotonqt.config_utils import ( get_portproton_location, read_theme_from_config, save_theme_to_config, parse_desktop_entry, load_theme_metainfo, read_time_config, read_card_size, save_card_size, read_sort_method, read_display_filter, read_favorites, save_favorites, save_time_config, save_sort_method, save_display_filter, save_proxy_config, read_proxy_config, read_fullscreen_config, save_fullscreen_config, read_window_geometry, save_window_geometry, reset_config, clear_cache ) from portprotonqt.localization import _ from portprotonqt.logger import get_logger from portprotonqt.downloader import Downloader from PySide6.QtWidgets import (QLineEdit, QMainWindow, QStatusBar, QWidget, QVBoxLayout, QLabel, QHBoxLayout, QStackedWidget, QComboBox, QScrollArea, QSlider, QDialog, QFormLayout, QFrame, QGraphicsDropShadowEffect, QMessageBox, QGraphicsEffect, QGraphicsOpacityEffect, QApplication, QPushButton, QProgressBar, QCheckBox) from PySide6.QtGui import QIcon, QPixmap, QColor, QDesktopServices from PySide6.QtCore import Qt, QAbstractAnimation, QPropertyAnimation, QByteArray, QUrl, Signal, QTimer, Slot from typing import cast from collections.abc import Callable from concurrent.futures import ThreadPoolExecutor from datetime import datetime logger = get_logger(__name__) class MainWindow(QMainWindow): """Main window of PortProtonQT.""" settings_saved = Signal() games_loaded = Signal(list) update_progress = Signal(int) # Signal to update progress bar update_status_message = Signal(str, int) # Signal to update status message def __init__(self): super().__init__() self.setAcceptDrops(True) self.current_exec_line = None self.currentDetailPage = None self.current_play_button = None self.pending_games = [] self.game_card_cache = {} self.pending_images = {} self.total_games = 0 self.games_load_timer = QTimer(self) self.games_load_timer.setSingleShot(True) self.games_load_timer.timeout.connect(self.finalize_game_loading) self.games_loaded.connect(self.on_games_loaded) self.current_add_game_dialog = None # Добавляем таймер для дебаунсинга сохранения настроек self.settingsDebounceTimer = QTimer(self) self.settingsDebounceTimer.setSingleShot(True) self.settingsDebounceTimer.setInterval(300) # 300 мс задержка self.settingsDebounceTimer.timeout.connect(self.applySettingsDelayed) read_time_config() # Set LEGENDARY_CONFIG_PATH to ~/.cache/PortProtonQT/legendary self.legendary_config_path = os.path.join( os.getenv("XDG_CACHE_HOME", os.path.join(os.path.expanduser("~"), ".cache")), "PortProtonQT", "legendary_cache" ) os.makedirs(self.legendary_config_path, exist_ok=True) os.environ["LEGENDARY_CONFIG_PATH"] = self.legendary_config_path self.legendary_path = os.path.join(self.legendary_config_path, "legendary") self.downloader = Downloader(max_workers=4) # Создаём менеджер тем и читаем, какая тема выбрана self.theme_manager = ThemeManager() selected_theme = read_theme_from_config() self.current_theme_name = selected_theme try: self.theme = self.theme_manager.apply_theme(selected_theme) except FileNotFoundError: logger.warning(f"Тема '{selected_theme}' не найдена, применяется стандартная тема 'standart'") self.theme = self.theme_manager.apply_theme("standart") self.current_theme_name = "standart" save_theme_to_config("standart") if not self.theme: self.theme = default_styles self.card_width = read_card_size() self.setWindowTitle("PortProtonQT") self.setMinimumSize(800, 600) self.games = [] self.game_processes = [] self.target_exe = None self.current_running_button = None self.portproton_location = get_portproton_location() self.context_menu_manager = ContextMenuManager( self, self.portproton_location, self.theme, self.loadGames, self.updateGameGrid ) # Статус-бар self.setStatusBar(QStatusBar(self)) self.progress_bar = QProgressBar() self.progress_bar.setMaximumWidth(200) self.progress_bar.setTextVisible(True) self.progress_bar.setVisible(False) self.statusBar().addPermanentWidget(self.progress_bar) self.update_progress.connect(self.progress_bar.setValue) self.update_status_message.connect(self.statusBar().showMessage) # Центральный виджет и основной layout centralWidget = QWidget() self.setCentralWidget(centralWidget) mainLayout = QVBoxLayout(centralWidget) mainLayout.setSpacing(0) mainLayout.setContentsMargins(0, 0, 0, 0) # 1. ШАПКА (HEADER) self.header = QWidget() self.header.setFixedHeight(80) self.header.setStyleSheet(self.theme.MAIN_WINDOW_HEADER_STYLE) headerLayout = QVBoxLayout(self.header) headerLayout.setContentsMargins(0, 0, 0, 0) # Текст "PortProton" слева self.titleLabel = QLabel() pixmap = load_logo() if pixmap is None: width, height = self.theme.pixmapsScaledSize pixmap = QPixmap(width, height) pixmap.fill(QColor(0, 0, 0, 0)) width, height = self.theme.pixmapsScaledSize scaled_pixmap = pixmap.scaled(width, height, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation) self.titleLabel.setPixmap(scaled_pixmap) self.titleLabel.setFixedSize(scaled_pixmap.size()) self.titleLabel.setStyleSheet(self.theme.TITLE_LABEL_STYLE) headerLayout.addStretch() # 2. НАВИГАЦИЯ (КНОПКИ ВКЛАДОК) self.navWidget = QWidget() self.navWidget.setStyleSheet(self.theme.NAV_WIDGET_STYLE) navLayout = QHBoxLayout(self.navWidget) navLayout.setContentsMargins(10, 0, 10, 0) navLayout.setSpacing(0) navLayout.addWidget(self.titleLabel) self.tabButtons = {} tabs = [ _("Library"), _("Auto Install"), _("Emulators"), _("Wine Settings"), _("PortProton Settings"), _("Themes") ] for i, tabName in enumerate(tabs): btn = NavLabel(tabName) btn.setCheckable(True) btn.clicked.connect(lambda index=i: self.switchTab(index)) btn.setStyleSheet(self.theme.NAV_BUTTON_STYLE) navLayout.addWidget(btn) self.tabButtons[i] = btn self.tabButtons[0].setChecked(True) mainLayout.addWidget(self.navWidget) # 3. QStackedWidget (ВКЛАДКИ) self.stackedWidget = QStackedWidget() mainLayout.addWidget(self.stackedWidget) # Создаём все вкладки self.createInstalledTab() # вкладка 0 self.createAutoInstallTab() # вкладка 1 self.createEmulatorsTab() # вкладка 2 self.createWineTab() # вкладка 3 self.createPortProtonTab() # вкладка 4 self.createThemeTab() # вкладка 5 self.restore_state() self.setStyleSheet(self.theme.MAIN_WINDOW_STYLE) self.setStyleSheet(self.theme.MESSAGE_BOX_STYLE) self.input_manager = InputManager(self) QTimer.singleShot(0, self.loadGames) if read_fullscreen_config(): self.showFullScreen() else: width, height = read_window_geometry() if width > 0 and height > 0: self.resize(width, height) else: self.showNormal() @Slot(list) def on_games_loaded(self, games: list[tuple]): self.games = games favorites = read_favorites() sort_method = read_sort_method() # Sort by: favorites first, then descending playtime, then descending last launch if sort_method == "playtime": self.games.sort(key=lambda g: (0 if g[0] in favorites else 1, -g[11], -g[10])) # Sort by: favorites first, then alphabetically by game name elif sort_method == "alphabetical": self.games.sort(key=lambda g: (0 if g[0] in favorites else 1, g[0].lower())) # Sort by: favorites first, then leave the rest in their original order elif sort_method == "favorites": self.games.sort(key=lambda g: (0 if g[0] in favorites else 1)) # Sort by: favorites first, then descending last launch, then descending playtime elif sort_method == "last_launch": self.games.sort(key=lambda g: (0 if g[0] in favorites else 1, -g[10], -g[11])) # Fallback: same as last_launch else: self.games.sort(key=lambda g: (0 if g[0] in favorites else 1, -g[10], -g[11])) self.updateGameGrid() self.progress_bar.setVisible(False) def loadGames(self): display_filter = read_display_filter() favorites = read_favorites() self.pending_games = [] self.games = [] self.progress_bar.setValue(0) self.progress_bar.setVisible(True) if display_filter == "steam": self._load_steam_games_async(lambda games: self.games_loaded.emit(games)) elif display_filter == "portproton": self._load_portproton_games_async(lambda games: self.games_loaded.emit(games)) elif display_filter == "epic": load_egs_games_async( self.legendary_path, lambda games: self.games_loaded.emit(games), self.downloader, self.update_progress.emit, self.update_status_message.emit ) elif display_filter == "favorites": def on_all_games(portproton_games, steam_games, epic_games): games = [game for game in portproton_games + steam_games + epic_games if game[0] in favorites] self.games_loaded.emit(games) self._load_portproton_games_async( lambda pg: self._load_steam_games_async( lambda sg: load_egs_games_async( self.legendary_path, lambda eg: on_all_games(pg, sg, eg), self.downloader, self.update_progress.emit, self.update_status_message.emit ) ) ) else: def on_all_games(portproton_games, steam_games, epic_games): seen = set() games = [] for game in portproton_games + steam_games + epic_games: name = game[0] if name not in seen: seen.add(name) games.append(game) self.games_loaded.emit(games) self._load_portproton_games_async( lambda pg: self._load_steam_games_async( lambda sg: load_egs_games_async( self.legendary_path, lambda eg: on_all_games(pg, sg, eg), self.downloader, self.update_progress.emit, self.update_status_message.emit ) ) ) return [] def _load_steam_games_async(self, callback: Callable[[list[tuple]], None]): steam_games = [] installed_games = get_steam_installed_games() logger.info("Found %d installed Steam games: %s", len(installed_games), [g[0] for g in installed_games]) if not installed_games: callback(steam_games) return self.total_games = len(installed_games) self.update_progress.emit(0) # Initialize progress bar self.update_status_message.emit(_("Loading Steam games..."), 3000) processed_count = 0 def on_game_info(info: dict, name, appid, last_played, playtime_seconds): nonlocal processed_count if not info: logger.warning("No info retrieved for game %s (appid %s)", name, appid) info = { 'description': '', 'cover': '', 'controller_support': '', 'protondb_tier': '', 'name': name, 'game_source': 'steam' } last_launch = format_last_launch(datetime.fromtimestamp(last_played)) if last_played else _("Never") steam_games.append(( name, info.get('description', ''), info.get('cover', ''), appid, f"steam://rungameid/{appid}", info.get('controller_support', ''), last_launch, format_playtime(playtime_seconds), info.get('protondb_tier', ''), info.get("anticheat_status", ""), last_played, playtime_seconds, "steam" )) processed_count += 1 self.pending_games.append(None) self.update_progress.emit(len(self.pending_games)) # Update progress bar logger.info("Game %s processed, processed_count: %d/%d", name, processed_count, len(installed_games)) if processed_count == len(installed_games): callback(steam_games) for name, appid, last_played, playtime_seconds in installed_games: logger.debug("Requesting info for game %s (appid %s)", name, appid) get_full_steam_game_info_async(appid, lambda info, n=name, a=appid, lp=last_played, pt=playtime_seconds: on_game_info(info, n, a, lp, pt)) def _load_portproton_games_async(self, callback: Callable[[list[tuple]], None]): games = [] if not self.portproton_location: callback(games) return desktop_files = [entry.path for entry in os.scandir(self.portproton_location) if entry.name.endswith(".desktop")] if not desktop_files: callback(games) return self.total_games = len(desktop_files) self.update_progress.emit(0) # Initialize progress bar self.update_status_message.emit(_("Loading PortProton games..."), 3000) def on_desktop_processed(result: tuple | None, games=games): if result: games.append(result) self.pending_games.append(None) self.update_progress.emit(len(self.pending_games)) # Update progress bar if len(self.pending_games) == len(desktop_files): callback(games) with ThreadPoolExecutor() as executor: for file_path in desktop_files: executor.submit(self._process_desktop_file_async, file_path, on_desktop_processed) def _process_desktop_file_async(self, file_path: str, callback: Callable[[tuple | None], None]): entry = parse_desktop_entry(file_path) if not entry: callback(None) return desktop_name = entry.get("Name", _("Unknown Game")) if desktop_name.lower() in ["portproton", "readme"]: callback(None) return exec_line = entry.get("Exec", "") game_exe = "" exe_name = "" playtime_seconds = 0 formatted_playtime = "" if exec_line: parts = shlex.split(exec_line) game_exe = os.path.expanduser(parts[3] if len(parts) >= 4 else exec_line) repo_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) builtin_custom_folder = os.path.join(repo_root, "portprotonqt", "custom_data") xdg_data_home = os.getenv("XDG_DATA_HOME", os.path.join(os.path.expanduser("~"), ".local", "share")) user_custom_folder = os.path.join(xdg_data_home, "PortProtonQT", "custom_data") os.makedirs(user_custom_folder, exist_ok=True) builtin_cover = "" builtin_name = None builtin_desc = None user_cover = "" user_name = None user_desc = None if game_exe: exe_name = os.path.splitext(os.path.basename(game_exe))[0] builtin_game_folder = os.path.join(builtin_custom_folder, exe_name) user_game_folder = os.path.join(user_custom_folder, exe_name) os.makedirs(user_game_folder, exist_ok=True) builtin_files = set(os.listdir(builtin_game_folder)) if os.path.exists(builtin_game_folder) else set() for ext in [".jpg", ".png", ".jpeg", ".bmp"]: candidate = f"cover{ext}" if candidate in builtin_files: builtin_cover = os.path.join(builtin_game_folder, candidate) break builtin_metadata_file = os.path.join(builtin_game_folder, "metadata.txt") if os.path.exists(builtin_metadata_file): with open(builtin_metadata_file, encoding="utf-8") as f: for line in f: line = line.strip() if line.startswith("name="): builtin_name = line[len("name="):].strip() elif line.startswith("description="): builtin_desc = line[len("description="):].strip() user_files = set(os.listdir(user_game_folder)) if os.path.exists(user_game_folder) else set() for ext in [".jpg", ".png", ".jpeg", ".bmp"]: candidate = f"cover{ext}" if candidate in user_files: user_cover = os.path.join(user_game_folder, candidate) break user_metadata_file = os.path.join(user_game_folder, "metadata.txt") if os.path.exists(user_metadata_file): with open(user_metadata_file, encoding="utf-8") as f: for line in f: line = line.strip() if line.startswith("name="): user_name = line[len("name="):].strip() elif line.startswith("description="): user_desc = line[len("description="):].strip() if self.portproton_location: statistics_file = os.path.join(self.portproton_location, "data", "tmp", "statistics") try: playtime_data = parse_playtime_file(statistics_file) matching_key = next( (key for key in playtime_data if os.path.basename(key).split('.')[0] == exe_name), None ) if matching_key: playtime_seconds = playtime_data[matching_key] formatted_playtime = format_playtime(playtime_seconds) except Exception as e: print(f"Failed to parse playtime data: {e}") def on_steam_info(steam_info: dict): final_name = user_name or builtin_name or desktop_name final_desc = (user_desc if user_desc is not None else builtin_desc if builtin_desc is not None else steam_info.get("description", "")) final_cover = (user_cover if user_cover else builtin_cover if builtin_cover else steam_info.get("cover", "") or entry.get("Icon", "")) callback(( final_name, final_desc, final_cover, steam_info.get("appid", ""), exec_line, steam_info.get("controller_support", ""), get_last_launch(exe_name) if exe_name else _("Never"), formatted_playtime, steam_info.get("protondb_tier", ""), steam_info.get("anticheat_status", ""), get_last_launch_timestamp(exe_name) if exe_name else 0, playtime_seconds, "portproton" )) get_steam_game_info_async(desktop_name, exec_line, on_steam_info) def finalize_game_loading(self): logger.info("Finalizing game loading, pending_games: %d", len(self.pending_games)) if self.pending_games and all(x is None for x in self.pending_games): logger.info("All games processed, clearing pending_games") self.pending_games = [] self.update_progress.emit(0) # Hide progress bar self.progress_bar.setVisible(False) self.update_status_message.emit("", 0) # Clear status message # ВКЛАДКИ def switchTab(self, index): """Устанавливает активную вкладку по индексу.""" for i, btn in self.tabButtons.items(): btn.setChecked(i == index) self.stackedWidget.setCurrentIndex(index) def createSearchWidget(self) -> tuple[QWidget, QLineEdit]: self.container = QWidget() self.container.setStyleSheet(self.theme.CONTAINER_STYLE) layout = QHBoxLayout(self.container) layout.setContentsMargins(0, 6, 0, 0) layout.setSpacing(10) self.GameLibraryTitle = QLabel(_("Game Library")) self.GameLibraryTitle.setStyleSheet(self.theme.INSTALLED_TAB_TITLE_STYLE) layout.addWidget(self.GameLibraryTitle) self.addGameButton = AutoSizeButton(_("Add Game"), icon=self.theme_manager.get_icon("addgame")) self.addGameButton.setStyleSheet(self.theme.ADDGAME_BACK_BUTTON_STYLE) self.addGameButton.clicked.connect(self.openAddGameDialog) layout.addWidget(self.addGameButton, alignment=Qt.AlignmentFlag.AlignRight) self.searchEdit = QLineEdit() icon: QIcon = cast(QIcon, self.theme_manager.get_icon("search")) action_pos = cast(QLineEdit.ActionPosition, QLineEdit.ActionPosition.LeadingPosition) self.search_action = self.searchEdit.addAction(icon, action_pos) self.searchEdit.setMaximumWidth(200) self.searchEdit.setPlaceholderText(_("Find Games ...")) self.searchEdit.setClearButtonEnabled(True) self.searchEdit.setStyleSheet(self.theme.SEARCH_EDIT_STYLE) # Добавляем дебансирование для поиска self.searchEdit.textChanged.connect(self.startSearchDebounce) self.searchDebounceTimer = QTimer(self) self.searchDebounceTimer.setSingleShot(True) self.searchDebounceTimer.setInterval(300) self.searchDebounceTimer.timeout.connect(self.filterGamesDelayed) layout.addWidget(self.searchEdit) return self.container, self.searchEdit def startSearchDebounce(self, text): self.searchDebounceTimer.start() def filterGamesDelayed(self): """Filters games based on search text and updates the grid.""" text = self.searchEdit.text().strip().lower() if text == "": self.updateGameGrid() # Use self.games directly else: filtered = [game for game in self.games if text in game[0].lower()] self.updateGameGrid(filtered) def createInstalledTab(self): self.gamesLibraryWidget = QWidget() self.gamesLibraryWidget.setStyleSheet(self.theme.LIBRARY_WIDGET_STYLE) layout = QVBoxLayout(self.gamesLibraryWidget) layout.setSpacing(15) searchWidget, self.searchEdit = self.createSearchWidget() layout.addWidget(searchWidget) scrollArea = QScrollArea() scrollArea.setWidgetResizable(True) scrollArea.setStyleSheet(self.theme.SCROLL_AREA_STYLE) self.gamesListWidget = QWidget() self.gamesListWidget.setStyleSheet(self.theme.LIST_WIDGET_STYLE) self.gamesListLayout = FlowLayout(self.gamesListWidget) self.gamesListWidget.setLayout(self.gamesListLayout) scrollArea.setWidget(self.gamesListWidget) layout.addWidget(scrollArea) sliderLayout = QHBoxLayout() sliderLayout.addStretch() self.sizeSlider = QSlider(Qt.Orientation.Horizontal) self.sizeSlider.setMinimum(200) self.sizeSlider.setMaximum(250) self.sizeSlider.setValue(self.card_width) self.sizeSlider.setTickInterval(10) self.sizeSlider.setFixedWidth(150) self.sizeSlider.setToolTip(f"{self.card_width} px") self.sizeSlider.setStyleSheet(self.theme.SLIDER_SIZE_STYLE) sliderLayout.addWidget(self.sizeSlider) layout.addLayout(sliderLayout) self.sliderDebounceTimer = QTimer(self) self.sliderDebounceTimer.setSingleShot(True) self.sliderDebounceTimer.setInterval(40) def on_slider_value_changed(): self.setUpdatesEnabled(False) self.card_width = self.sizeSlider.value() self.sizeSlider.setToolTip(f"{self.card_width} px") self.updateGameGrid() self.setUpdatesEnabled(True) self.sizeSlider.valueChanged.connect(lambda val: self.sliderDebounceTimer.start()) self.sliderDebounceTimer.timeout.connect(on_slider_value_changed) def calculate_card_width(): available_width = scrollArea.width() - 20 spacing = self.gamesListLayout._spacing target_cards_per_row = 8 calculated_width = (available_width - spacing * (target_cards_per_row - 1)) // target_cards_per_row calculated_width = max(200, min(calculated_width, 250)) if not self.sizeSlider.value() == self.card_width: self.card_width = calculated_width self.sizeSlider.setValue(self.card_width) self.sizeSlider.setToolTip(f"{self.card_width} px") self.updateGameGrid() QTimer.singleShot(0, calculate_card_width) # Добавляем обработчик прокрутки для ленивой загрузки scrollArea.verticalScrollBar().valueChanged.connect(self.loadVisibleImages) self.stackedWidget.addWidget(self.gamesLibraryWidget) self.updateGameGrid() def resizeEvent(self, event): super().resizeEvent(event) if not hasattr(self, '_last_width'): self._last_width = self.width() if abs(self.width() - self._last_width) > 10: self._last_width = self.width() self.sliderDebounceTimer.start() def loadVisibleImages(self): visible_region = self.gamesListWidget.visibleRegion() max_concurrent_loads = 5 loaded_count = 0 for card_key, card in self.game_card_cache.items(): if card_key in self.pending_images and visible_region.contains(card.pos()) and loaded_count < max_concurrent_loads: cover_path, width, height, callback = self.pending_images.pop(card_key) load_pixmap_async(cover_path, width, height, callback) loaded_count += 1 def updateGameGrid(self, games_list=None): """Updates the game grid with the provided games list or self.games.""" if games_list is None: games_list = self.games if not games_list: self.clearLayout(self.gamesListLayout) self.game_card_cache.clear() self.pending_images.clear() return # Create a set of game names for quick lookup current_games = {game_data[0]: game_data for game_data in games_list} # Check if the grid is already up-to-date if set(current_games.keys()) == set(self.game_card_cache.keys()) and self.card_width == getattr(self, '_last_card_width', None): return # No changes needed, skip update # Track if layout has changed to decide if geometry update is needed layout_changed = False # Remove cards for games no longer in the list for card_key in list(self.game_card_cache.keys()): if card_key not in current_games: card = self.game_card_cache.pop(card_key) self.gamesListLayout.removeWidget(card) card.deleteLater() if card_key in self.pending_images: del self.pending_images[card_key] layout_changed = True # Add or update cards for current games for game_data in games_list: game_name = game_data[0] if game_name not in self.game_card_cache: # Create new card card = GameCard( *game_data, select_callback=self.openGameDetailPage, theme=self.theme, card_width=self.card_width, context_menu_manager=self.context_menu_manager ) # Connect context menu signals card.editShortcutRequested.connect(self.context_menu_manager.edit_game_shortcut) card.deleteGameRequested.connect(self.context_menu_manager.delete_game) card.addToMenuRequested.connect(self.context_menu_manager.add_to_menu) card.removeFromMenuRequested.connect(self.context_menu_manager.remove_from_menu) card.addToDesktopRequested.connect(self.context_menu_manager.add_to_desktop) card.removeFromDesktopRequested.connect(self.context_menu_manager.remove_from_desktop) card.addToSteamRequested.connect(self.context_menu_manager.add_to_steam) card.removeFromSteamRequested.connect(self.context_menu_manager.remove_from_steam) card.openGameFolderRequested.connect(self.context_menu_manager.open_game_folder) self.game_card_cache[game_name] = card self.gamesListLayout.addWidget(card) layout_changed = True elif self.card_width != getattr(self, '_last_card_width', None): # Update size only if card_width has changed card = self.game_card_cache[game_name] card.setFixedWidth(self.card_width + 20) # Account for extra_margin in GameCard # Store the current card_width self._last_card_width = self.card_width # Trigger lazy image loading for visible cards self.loadVisibleImages() # Update layout geometry only if the layout has changed if layout_changed: self.gamesListWidget.updateGeometry() def clearLayout(self, layout): """Удаляет все виджеты из layout.""" while layout.count(): child = layout.takeAt(0) if child.widget(): widget = child.widget() # Remove from game_card_cache if it's a GameCard for key, card in list(self.game_card_cache.items()): if card == widget: del self.game_card_cache[key] # Also remove from pending_images if present if key in self.pending_images: del self.pending_images[key] widget.deleteLater() def dragEnterEvent(self, event): if event.mimeData().hasUrls(): for url in event.mimeData().urls(): if url.toLocalFile().lower().endswith(".exe"): event.acceptProposedAction() return event.ignore() def dropEvent(self, event): for url in event.mimeData().urls(): path = url.toLocalFile() if path.lower().endswith(".exe"): self.openAddGameDialog(path) break def openAddGameDialog(self, exe_path=None): """Открывает диалоговое окно 'Add Game' с текущей темой.""" # Проверяем, открыт ли уже диалог if self.current_add_game_dialog is not None and self.current_add_game_dialog.isVisible(): self.current_add_game_dialog.activateWindow() # Активируем существующий диалог self.current_add_game_dialog.raise_() # Поднимаем окно return dialog = AddGameDialog(self, self.theme) self.current_add_game_dialog = dialog # Сохраняем ссылку на диалог # Предзаполняем путь к .exe при drag-and-drop if exe_path: dialog.exeEdit.setText(exe_path) dialog.nameEdit.setText(os.path.splitext(os.path.basename(exe_path))[0]) dialog.updatePreview() # Обработчик закрытия диалога def on_dialog_finished(): self.current_add_game_dialog = None # Сбрасываем ссылку при закрытии dialog.finished.connect(on_dialog_finished) if dialog.exec() == QDialog.DialogCode.Accepted: name = dialog.nameEdit.text().strip() exe_path = dialog.exeEdit.text().strip() user_cover = dialog.coverEdit.text().strip() if not name or not exe_path: return # Сохраняем .desktop файл desktop_entry, desktop_path = dialog.getDesktopEntryData() if desktop_entry and desktop_path: with open(desktop_path, "w", encoding="utf-8") as f: f.write(desktop_entry) os.chmod(desktop_path, 0o755) # Проверяем путь обложки, если он отличается от стандартной if os.path.isfile(user_cover): exe_name = os.path.splitext(os.path.basename(exe_path))[0] xdg_data_home = os.getenv("XDG_DATA_HOME", os.path.join(os.path.expanduser("~"), ".local", "share")) custom_folder = os.path.join( xdg_data_home, "PortProtonQT", "custom_data", exe_name ) os.makedirs(custom_folder, exist_ok=True) # Сохраняем пользовательскую обложку как cover.* ext = os.path.splitext(user_cover)[1].lower() if ext in [".png", ".jpg", ".jpeg", ".bmp"]: shutil.copyfile(user_cover, os.path.join(custom_folder, f"cover{ext}")) self.games = self.loadGames() self.updateGameGrid() def createAutoInstallTab(self): """Вкладка 'Auto Install'.""" self.autoInstallWidget = QWidget() self.autoInstallWidget.setStyleSheet(self.theme.OTHER_PAGES_WIDGET_STYLE) self.autoInstallWidget.setObjectName("otherPage") layout = QVBoxLayout(self.autoInstallWidget) layout.setContentsMargins(10, 18, 10, 10) self.autoInstallTitle = QLabel(_("Auto Install")) self.autoInstallTitle.setStyleSheet(self.theme.TAB_TITLE_STYLE) self.autoInstallTitle.setObjectName("tabTitle") layout.addWidget(self.autoInstallTitle) self.autoInstallContent = QLabel(_("Here you can configure automatic game installation...")) self.autoInstallContent.setStyleSheet(self.theme.CONTENT_STYLE) self.autoInstallContent.setObjectName("tabContent") layout.addWidget(self.autoInstallContent) layout.addStretch(1) self.stackedWidget.addWidget(self.autoInstallWidget) def createEmulatorsTab(self): """Вкладка 'Emulators'.""" self.emulatorsWidget = QWidget() self.emulatorsWidget.setStyleSheet(self.theme.OTHER_PAGES_WIDGET_STYLE) self.emulatorsWidget.setObjectName("otherPage") layout = QVBoxLayout(self.emulatorsWidget) layout.setContentsMargins(10, 18, 10, 10) self.emulatorsTitle = QLabel(_("Emulators")) self.emulatorsTitle.setStyleSheet(self.theme.TAB_TITLE_STYLE) self.emulatorsTitle.setObjectName("tabTitle") layout.addWidget(self.emulatorsTitle) self.emulatorsContent = QLabel(_("List of available emulators and their configuration...")) self.emulatorsContent.setStyleSheet(self.theme.CONTENT_STYLE) self.emulatorsContent.setObjectName("tabContent") layout.addWidget(self.emulatorsContent) layout.addStretch(1) self.stackedWidget.addWidget(self.emulatorsWidget) def createWineTab(self): """Вкладка 'Wine Settings'.""" self.wineWidget = QWidget() self.wineWidget.setStyleSheet(self.theme.OTHER_PAGES_WIDGET_STYLE) self.wineWidget.setObjectName("otherPage") layout = QVBoxLayout(self.wineWidget) layout.setContentsMargins(10, 18, 10, 10) self.wineTitle = QLabel(_("Wine Settings")) self.wineTitle.setStyleSheet(self.theme.TAB_TITLE_STYLE) self.wineTitle.setObjectName("tabTitle") layout.addWidget(self.wineTitle) self.wineContent = QLabel(_("Various Wine parameters and versions...")) self.wineContent.setStyleSheet(self.theme.CONTENT_STYLE) self.wineContent.setObjectName("tabContent") layout.addWidget(self.wineContent) layout.addStretch(1) self.stackedWidget.addWidget(self.wineWidget) def createPortProtonTab(self): """Вкладка 'PortProton Settings'.""" self.portProtonWidget = QWidget() self.portProtonWidget.setStyleSheet(self.theme.OTHER_PAGES_WIDGET_STYLE) self.portProtonWidget.setObjectName("otherPage") layout = QVBoxLayout(self.portProtonWidget) layout.setContentsMargins(10, 18, 10, 10) # Заголовок title = QLabel(_("PortProton Settings")) title.setStyleSheet(self.theme.TAB_TITLE_STYLE) title.setObjectName("tabTitle") title.setFocusPolicy(Qt.FocusPolicy.NoFocus) layout.addWidget(title) # Подзаголовок/описание content = QLabel(_("Main PortProton parameters...")) content.setStyleSheet(self.theme.CONTENT_STYLE) content.setObjectName("tabContent") content.setFocusPolicy(Qt.FocusPolicy.NoFocus) layout.addWidget(content) # Форма с настройками formLayout = QFormLayout() formLayout.setContentsMargins(0, 10, 0, 0) formLayout.setSpacing(10) # 1. Time detail_level self.timeDetailCombo = QComboBox() self.time_keys = ["detailed", "brief"] self.time_labels = [_("detailed"), _("brief")] self.timeDetailCombo.addItems(self.time_labels) self.timeDetailCombo.setStyleSheet(self.theme.SETTINGS_COMBO_STYLE) self.timeDetailCombo.setFocusPolicy(Qt.FocusPolicy.StrongFocus) self.timeDetailTitle = QLabel(_("Time Detail Level:")) self.timeDetailTitle.setStyleSheet(self.theme.PARAMS_TITLE_STYLE) self.timeDetailTitle.setFocusPolicy(Qt.FocusPolicy.NoFocus) current = read_time_config() try: idx = self.time_keys.index(current) except ValueError: idx = 0 self.timeDetailCombo.setCurrentIndex(idx) formLayout.addRow(self.timeDetailTitle, self.timeDetailCombo) # 2. Games sort_method self.gamesSortCombo = QComboBox() self.sort_keys = ["last_launch", "playtime", "alphabetical", "favorites"] self.sort_labels = [_("last launch"), _("playtime"), _("alphabetical"), _("favorites")] self.gamesSortCombo.addItems(self.sort_labels) self.gamesSortCombo.setStyleSheet(self.theme.SETTINGS_COMBO_STYLE) self.gamesSortCombo.setFocusPolicy(Qt.FocusPolicy.StrongFocus) self.gamesSortTitle = QLabel(_("Games Sort Method:")) self.gamesSortTitle.setStyleSheet(self.theme.PARAMS_TITLE_STYLE) self.gamesSortTitle.setFocusPolicy(Qt.FocusPolicy.NoFocus) current = read_sort_method() try: idx = self.sort_keys.index(current) except ValueError: idx = 0 self.gamesSortCombo.setCurrentIndex(idx) formLayout.addRow(self.gamesSortTitle, self.gamesSortCombo) # 3. Games display_filter self.filter_keys = ["all", "steam", "portproton", "favorites", "epic"] self.filter_labels = [_("all"), "steam", "portproton", _("favorites"), "epic games store"] self.gamesDisplayCombo = QComboBox() self.gamesDisplayCombo.addItems(self.filter_labels) self.gamesDisplayCombo.setStyleSheet(self.theme.SETTINGS_COMBO_STYLE) self.gamesDisplayCombo.setFocusPolicy(Qt.FocusPolicy.StrongFocus) self.gamesDisplayTitle = QLabel(_("Games Display Filter:")) self.gamesDisplayTitle.setStyleSheet(self.theme.PARAMS_TITLE_STYLE) self.gamesDisplayTitle.setFocusPolicy(Qt.FocusPolicy.NoFocus) current = read_display_filter() try: idx = self.filter_keys.index(current) except ValueError: idx = 0 self.gamesDisplayCombo.setCurrentIndex(idx) formLayout.addRow(self.gamesDisplayTitle, self.gamesDisplayCombo) # 4. Proxy settings self.proxyUrlEdit = QLineEdit() self.proxyUrlEdit.setPlaceholderText(_("Proxy URL")) self.proxyUrlEdit.setStyleSheet(self.theme.PROXY_INPUT_STYLE) self.proxyUrlEdit.setFocusPolicy(Qt.FocusPolicy.StrongFocus) self.proxyUrlTitle = QLabel(_("Proxy URL:")) self.proxyUrlTitle.setStyleSheet(self.theme.PARAMS_TITLE_STYLE) self.proxyUrlTitle.setFocusPolicy(Qt.FocusPolicy.NoFocus) proxy_cfg = read_proxy_config() if proxy_cfg.get("http", ""): self.proxyUrlEdit.setText(proxy_cfg["http"]) formLayout.addRow(self.proxyUrlTitle, self.proxyUrlEdit) self.proxyUserEdit = QLineEdit() self.proxyUserEdit.setPlaceholderText(_("Proxy Username")) self.proxyUserEdit.setStyleSheet(self.theme.PROXY_INPUT_STYLE) self.proxyUserEdit.setFocusPolicy(Qt.FocusPolicy.StrongFocus) self.proxyUserTitle = QLabel(_("Proxy Username:")) self.proxyUserTitle.setStyleSheet(self.theme.PARAMS_TITLE_STYLE) self.proxyUserTitle.setFocusPolicy(Qt.FocusPolicy.NoFocus) formLayout.addRow(self.proxyUserTitle, self.proxyUserEdit) self.proxyPasswordEdit = QLineEdit() self.proxyPasswordEdit.setPlaceholderText(_("Proxy Password")) self.proxyPasswordEdit.setEchoMode(QLineEdit.EchoMode.Password) self.proxyPasswordEdit.setStyleSheet(self.theme.PROXY_INPUT_STYLE) self.proxyPasswordEdit.setFocusPolicy(Qt.FocusPolicy.StrongFocus) self.proxyPasswordTitle = QLabel(_("Proxy Password:")) self.proxyPasswordTitle.setStyleSheet(self.theme.PARAMS_TITLE_STYLE) self.proxyPasswordTitle.setFocusPolicy(Qt.FocusPolicy.NoFocus) formLayout.addRow(self.proxyPasswordTitle, self.proxyPasswordEdit) # 5. Fullscreen setting for application self.fullscreenCheckBox = QCheckBox(_("Launch Application in Fullscreen")) #self.fullscreenCheckBox.setStyleSheet(self.theme.SETTINGS_CHECKBOX_STYLE) self.fullscreenCheckBox.setFocusPolicy(Qt.FocusPolicy.StrongFocus) self.fullscreenTitle = QLabel(_("Application Fullscreen Mode:")) self.fullscreenTitle.setStyleSheet(self.theme.PARAMS_TITLE_STYLE) self.fullscreenTitle.setFocusPolicy(Qt.FocusPolicy.NoFocus) current_fullscreen = read_fullscreen_config() self.fullscreenCheckBox.setChecked(current_fullscreen) formLayout.addRow(self.fullscreenTitle, self.fullscreenCheckBox) # 6. Legendary Authentication self.legendaryAuthButton = AutoSizeButton( _("Open Legendary Login"), icon=self.theme_manager.get_icon("login") ) self.legendaryAuthButton.setStyleSheet(self.theme.ACTION_BUTTON_STYLE) self.legendaryAuthButton.setFocusPolicy(Qt.FocusPolicy.StrongFocus) self.legendaryAuthButton.clicked.connect(self.openLegendaryLogin) self.legendaryAuthTitle = QLabel(_("Legendary Authentication:")) self.legendaryAuthTitle.setStyleSheet(self.theme.PARAMS_TITLE_STYLE) self.legendaryAuthTitle.setFocusPolicy(Qt.FocusPolicy.NoFocus) formLayout.addRow(self.legendaryAuthTitle, self.legendaryAuthButton) self.legendaryCodeEdit = QLineEdit() self.legendaryCodeEdit.setPlaceholderText(_("Enter Legendary Authorization Code")) self.legendaryCodeEdit.setStyleSheet(self.theme.PROXY_INPUT_STYLE) self.legendaryCodeEdit.setFocusPolicy(Qt.FocusPolicy.StrongFocus) self.legendaryCodeTitle = QLabel(_("Authorization Code:")) self.legendaryCodeTitle.setStyleSheet(self.theme.PARAMS_TITLE_STYLE) self.legendaryCodeTitle.setFocusPolicy(Qt.FocusPolicy.NoFocus) formLayout.addRow(self.legendaryCodeTitle, self.legendaryCodeEdit) self.submitCodeButton = AutoSizeButton( _("Submit Code"), icon=self.theme_manager.get_icon("save") ) self.submitCodeButton.setStyleSheet(self.theme.ACTION_BUTTON_STYLE) self.submitCodeButton.setFocusPolicy(Qt.FocusPolicy.StrongFocus) self.submitCodeButton.clicked.connect(self.submitLegendaryCode) formLayout.addRow(QLabel(""), self.submitCodeButton) layout.addLayout(formLayout) # Кнопки buttonsLayout = QHBoxLayout() buttonsLayout.setSpacing(10) # Кнопка сохранения настроек self.saveButton = AutoSizeButton( _("Save Settings"), icon=self.theme_manager.get_icon("save") ) self.saveButton.setStyleSheet(self.theme.ACTION_BUTTON_STYLE) self.saveButton.setFocusPolicy(Qt.FocusPolicy.StrongFocus) self.saveButton.clicked.connect(self.savePortProtonSettings) buttonsLayout.addWidget(self.saveButton) # Кнопка сброса настроек self.resetSettingsButton = AutoSizeButton( _("Reset Settings"), icon=self.theme_manager.get_icon("update") ) self.resetSettingsButton.setStyleSheet(self.theme.ACTION_BUTTON_STYLE) self.resetSettingsButton.setFocusPolicy(Qt.FocusPolicy.StrongFocus) self.resetSettingsButton.clicked.connect(self.resetSettings) buttonsLayout.addWidget(self.resetSettingsButton) # Кнопка очистки кэша self.clearCacheButton = AutoSizeButton( _("Clear Cache"), icon=self.theme_manager.get_icon("update") ) self.clearCacheButton.setStyleSheet(self.theme.ACTION_BUTTON_STYLE) self.clearCacheButton.setFocusPolicy(Qt.FocusPolicy.StrongFocus) self.clearCacheButton.clicked.connect(self.clearCache) buttonsLayout.addWidget(self.clearCacheButton) layout.addLayout(buttonsLayout) layout.addStretch(1) self.stackedWidget.addWidget(self.portProtonWidget) def openLegendaryLogin(self): """Opens the Legendary login page in the default web browser.""" login_url = "https://legendary.gl/epiclogin" try: QDesktopServices.openUrl(QUrl(login_url)) self.statusBar().showMessage(_("Opened Legendary login page in browser"), 3000) except Exception as e: logger.error(f"Failed to open Legendary login page: {e}") self.statusBar().showMessage(_("Failed to open Legendary login page"), 3000) def submitLegendaryCode(self): """Submits the Legendary authorization code using the legendary CLI.""" auth_code = self.legendaryCodeEdit.text().strip() if not auth_code: QMessageBox.warning(self, _("Error"), _("Please enter an authorization code")) return try: # Execute legendary auth command result = subprocess.run( [self.legendary_path, "auth", "--code", auth_code], capture_output=True, text=True, check=True ) logger.info("Legendary authentication successful: %s", result.stdout) self.statusBar().showMessage(_("Successfully authenticated with Legendary"), 3000) self.legendaryCodeEdit.clear() # Reload Epic Games Store games after successful authentication self.games = self.loadGames() self.updateGameGrid() except subprocess.CalledProcessError as e: logger.error("Legendary authentication failed: %s", e.stderr) self.statusBar().showMessage(_("Legendary authentication failed: {0}").format(e.stderr), 5000) except FileNotFoundError: logger.error("Legendary executable not found at %s", self.legendary_path) self.statusBar().showMessage(_("Legendary executable not found"), 5000) except Exception as e: logger.error("Unexpected error during Legendary authentication: %s", str(e)) self.statusBar().showMessage(_("Unexpected error during authentication"), 5000) def resetSettings(self): """Сбрасывает настройки и перезапускает приложение.""" reply = QMessageBox.question( self, _("Confirm Reset"), _("Are you sure you want to reset all settings? This action cannot be undone."), QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, QMessageBox.StandardButton.No ) if reply == QMessageBox.StandardButton.Yes: reset_config() # Показываем сообщение self.statusBar().showMessage(_("Settings reset. Restarting..."), 3000) # Перезапускаем приложение QTimer.singleShot(1000, lambda: self.restart_application()) def clearCache(self): """Очищает кэш.""" reply = QMessageBox.question( self, _("Confirm Clear Cache"), _("Are you sure you want to clear the cache? This action cannot be undone."), QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, QMessageBox.StandardButton.No ) if reply == QMessageBox.StandardButton.Yes: clear_cache() # Показываем сообщение self.statusBar().showMessage(_("Cache cleared"), 3000) def applySettingsDelayed(self): """Applies settings with the new filter and updates the game list.""" read_time_config() self.games = [] self.loadGames() display_filter = read_display_filter() for card in self.game_card_cache.values(): card.update_badge_visibility(display_filter) def savePortProtonSettings(self): """ Сохраняет параметры конфигурации в конфигурационный файл. """ time_idx = self.timeDetailCombo.currentIndex() time_key = self.time_keys[time_idx] save_time_config(time_key) sort_idx = self.gamesSortCombo.currentIndex() sort_key = self.sort_keys[sort_idx] save_sort_method(sort_key) filter_idx = self.gamesDisplayCombo.currentIndex() filter_key = self.filter_keys[filter_idx] save_display_filter(filter_key) # Сохранение proxy настроек proxy_url = self.proxyUrlEdit.text().strip() proxy_user = self.proxyUserEdit.text().strip() proxy_password = self.proxyPasswordEdit.text().strip() save_proxy_config(proxy_url, proxy_user, proxy_password) fullscreen = self.fullscreenCheckBox.isChecked() save_fullscreen_config(fullscreen) for card in self.game_card_cache.values(): card.update_badge_visibility(filter_key) if self.currentDetailPage and self.current_exec_line: current_game = next((game for game in self.games if game[4] == self.current_exec_line), None) if current_game: self.stackedWidget.removeWidget(self.currentDetailPage) self.currentDetailPage.deleteLater() self.currentDetailPage = None self.openGameDetailPage(*current_game) self.settingsDebounceTimer.start() self.settings_saved.emit() if fullscreen: self.showFullScreen() else: if self.isFullScreen(): # Переходим в нормальный режим и восстанавливаем сохраненные размеры width, height = read_window_geometry() self.showNormal() if width > 0 and height > 0: self.resize(width, height) # Сохраняем геометрию только если окно не в полноэкранном режиме if not self.isFullScreen(): save_window_geometry(self.width(), self.height()) self.statusBar().showMessage(_("Settings saved"), 3000) def createThemeTab(self): """Вкладка 'Themes'""" self.themeTabWidget = QWidget() self.themeTabWidget.setStyleSheet(self.theme.OTHER_PAGES_WIDGET_STYLE) self.themeTabWidget.setObjectName("otherPage") mainLayout = QVBoxLayout(self.themeTabWidget) mainLayout.setContentsMargins(10, 14, 10, 10) mainLayout.setSpacing(10) # 1. Верхняя строка: Заголовок и список тем self.themeTabHeaderLayout = QHBoxLayout() self.themeTabTitleLabel = QLabel(_("Select Theme:")) self.themeTabTitleLabel.setObjectName("tabTitle") self.themeTabTitleLabel.setStyleSheet(self.theme.TAB_TITLE_STYLE) self.themeTabHeaderLayout.addWidget(self.themeTabTitleLabel) self.themesCombo = QComboBox() self.themesCombo.setStyleSheet(self.theme.SETTINGS_COMBO_STYLE) self.themesCombo.setObjectName("comboString") available_themes = self.theme_manager.get_available_themes() if self.current_theme_name in available_themes: available_themes.remove(self.current_theme_name) available_themes.insert(0, self.current_theme_name) self.themesCombo.addItems(available_themes) self.themeTabHeaderLayout.addWidget(self.themesCombo) self.themeTabHeaderLayout.addStretch(1) mainLayout.addLayout(self.themeTabHeaderLayout) # 2. Карусель скриншотов self.screenshotsCarousel = ImageCarousel([]) self.screenshotsCarousel.setStyleSheet(self.theme.CAROUSEL_WIDGET_STYLE) mainLayout.addWidget(self.screenshotsCarousel, stretch=1) # 3. Информация о теме self.themeInfoLayout = QVBoxLayout() self.themeInfoLayout.setSpacing(10) self.themeMetainfoLabel = QLabel() self.themeMetainfoLabel.setWordWrap(True) self.themeInfoLayout.addWidget(self.themeMetainfoLabel) self.applyButton = AutoSizeButton(_("Apply Theme"), icon=self.theme_manager.get_icon("update")) self.applyButton.setStyleSheet(self.theme.ACTION_BUTTON_STYLE) self.applyButton.setObjectName("actionButton") self.themeInfoLayout.addWidget(self.applyButton) mainLayout.addLayout(self.themeInfoLayout) # Функция обновления превью def updateThemePreview(theme_name): meta = load_theme_metainfo(theme_name) link = meta.get("author_link", "") link_html = f'{link}' if link else _("No link") unknown_author = _("Unknown") preview_text = ( "" + _("Name:") + " " + meta.get('name', theme_name) + "
" + "" + _("Description:") + " " + meta.get('description', '') + "
" + "" + _("Author:") + " " + meta.get('author', unknown_author) + "
" + "" + _("Link:") + " " + link_html ) self.themeMetainfoLabel.setText(preview_text) self.themeMetainfoLabel.setStyleSheet(self.theme.CONTENT_STYLE) self.themeMetainfoLabel.setFocusPolicy(Qt.FocusPolicy.NoFocus) screenshots = load_theme_screenshots(theme_name) if screenshots: self.screenshotsCarousel.update_images([ (pixmap, os.path.splitext(filename)[0]) for pixmap, filename in screenshots ]) self.screenshotsCarousel.show() else: self.screenshotsCarousel.hide() updateThemePreview(self.current_theme_name) self.themesCombo.currentTextChanged.connect(updateThemePreview) # Логика применения темы def on_apply(): selected_theme = self.themesCombo.currentText() if selected_theme: theme_module = self.theme_manager.apply_theme(selected_theme) if theme_module: save_theme_to_config(selected_theme) self.statusBar().showMessage(_("Theme '{0}' applied successfully").format(selected_theme), 3000) xdg_data_home = os.getenv("XDG_DATA_HOME", os.path.join(os.path.expanduser("~"), ".local", "share")) state_file = os.path.join(xdg_data_home, "PortProtonQT", "state.txt") os.makedirs(os.path.dirname(state_file), exist_ok=True) with open(state_file, "w", encoding="utf-8") as f: f.write("theme_tab\n") QTimer.singleShot(500, lambda: self.restart_application()) else: self.statusBar().showMessage(_("Error applying theme '{0}'").format(selected_theme), 3000) self.applyButton.clicked.connect(on_apply) # Добавляем виджет в stackedWidget self.stackedWidget.addWidget(self.themeTabWidget) def restart_application(self): """Перезапускает приложение.""" if not self.isFullScreen(): save_window_geometry(self.width(), self.height()) python = sys.executable os.execl(python, python, *sys.argv) def restore_state(self): """Восстанавливает состояние приложения после перезапуска.""" xdg_cache_home = os.getenv("XDG_CACHE_HOME", os.path.join(os.path.expanduser("~"), ".cache")) state_file = os.path.join(xdg_cache_home, "PortProtonQT", "state.txt") if os.path.exists(state_file): with open(state_file, encoding="utf-8") as f: state = f.read().strip() if state == "theme_tab": self.switchTab(5) os.remove(state_file) # ЛОГИКА ДЕТАЛЬНОЙ СТРАНИЦЫ ИГРЫ def getColorPalette_async(self, cover_path, num_colors=5, sample_step=10, callback=None): def on_pixmap(pixmap): if pixmap.isNull(): if callback: callback([QColor("#1a1a1a")] * num_colors) return image = pixmap.toImage() width, height = image.width(), image.height() histogram = {} for x in range(0, width, sample_step): for y in range(0, height, sample_step): color = image.pixelColor(x, y) key = (color.red() // 32, color.green() // 32, color.blue() // 32) if key in histogram: histogram[key][0] += color.red() histogram[key][1] += color.green() histogram[key][2] += color.blue() histogram[key][3] += 1 else: histogram[key] = [color.red(), color.green(), color.blue(), 1] avg_colors = [] for _unused, (r_sum, g_sum, b_sum, count) in histogram.items(): avg_r = r_sum // count avg_g = g_sum // count avg_b = b_sum // count avg_colors.append((count, QColor(avg_r, avg_g, avg_b))) avg_colors.sort(key=lambda x: x[0], reverse=True) palette = [color for count, color in avg_colors[:num_colors]] if len(palette) < num_colors: palette += [palette[-1]] * (num_colors - len(palette)) if callback: callback(palette) load_pixmap_async(cover_path, 180, 250, on_pixmap) def darkenColor(self, color, factor=200): return color.darker(factor) def openGameDetailPage(self, name, description, cover_path=None, appid="", exec_line="", controller_support="", last_launch="", formatted_playtime="", protondb_tier="", game_source="", anticheat_status=""): detailPage = QWidget() self._animations = {} imageLabel = QLabel() imageLabel.setFixedSize(300, 400) if cover_path: def on_pixmap_ready(pixmap): rounded = round_corners(pixmap, 10) imageLabel.setPixmap(rounded) def on_palette_ready(palette): dark_palette = [self.darkenColor(color, factor=200) for color in palette] stops = ",\n".join( [f"stop:{i/(len(dark_palette)-1):.2f} {dark_palette[i].name()}" for i in range(len(dark_palette))] ) detailPage.setStyleSheet(self.theme.detail_page_style(stops)) self.getColorPalette_async(cover_path, num_colors=5, callback=on_palette_ready) load_pixmap_async(cover_path, 300, 400, on_pixmap_ready) else: detailPage.setStyleSheet(self.theme.DETAIL_PAGE_NO_COVER_STYLE) mainLayout = QVBoxLayout(detailPage) mainLayout.setContentsMargins(30, 30, 30, 30) mainLayout.setSpacing(20) backButton = AutoSizeButton(_("Back"), icon=self.theme_manager.get_icon("back")) backButton.setFixedWidth(100) backButton.setStyleSheet(self.theme.ADDGAME_BACK_BUTTON_STYLE) backButton.clicked.connect(lambda: self.goBackDetailPage(detailPage)) mainLayout.addWidget(backButton, alignment=Qt.AlignmentFlag.AlignLeft) contentFrame = QFrame() contentFrame.setStyleSheet(self.theme.DETAIL_CONTENT_FRAME_STYLE) contentFrameLayout = QHBoxLayout(contentFrame) contentFrameLayout.setContentsMargins(20, 20, 20, 20) contentFrameLayout.setSpacing(40) mainLayout.addWidget(contentFrame) # Обложка (слева) coverFrame = QFrame() coverFrame.setFixedSize(300, 400) coverFrame.setStyleSheet(self.theme.COVER_FRAME_STYLE) shadow = QGraphicsDropShadowEffect(coverFrame) shadow.setBlurRadius(20) shadow.setColor(QColor(0, 0, 0, 200)) shadow.setOffset(0, 0) coverFrame.setGraphicsEffect(shadow) coverLayout = QVBoxLayout(coverFrame) coverLayout.setContentsMargins(0, 0, 0, 0) coverLayout.addWidget(imageLabel) # Значок избранного favoriteLabelCover = ClickableLabel(coverFrame) favoriteLabelCover.setFixedSize(*self.theme.favoriteLabelSize) favoriteLabelCover.setStyleSheet(self.theme.FAVORITE_LABEL_STYLE) favorites = read_favorites() if name in favorites: favoriteLabelCover.setText("★") else: favoriteLabelCover.setText("☆") favoriteLabelCover.clicked.connect(lambda: self.toggleFavoriteInDetailPage(name, favoriteLabelCover)) favoriteLabelCover.move(8, 8) favoriteLabelCover.raise_() # Добавляем бейджи (ProtonDB, Steam, PortProton, WeAntiCheatYet) display_filter = read_display_filter() steam_visible = (str(game_source).lower() == "steam" and display_filter in ("all", "favorites")) egs_visible = (str(game_source).lower() == "epic" and display_filter in ("all", "favorites")) portproton_visible = (str(game_source).lower() == "portproton" and display_filter in ("all", "favorites")) right_margin = 8 badge_spacing = 5 top_y = 10 badge_y_positions = [] badge_width = int(300 * 2/3) # 2/3 ширины обложки (300 px) # ProtonDB бейдж protondb_text = GameCard.getProtonDBText(protondb_tier) if protondb_text: icon_filename = GameCard.getProtonDBIconFilename(protondb_tier) icon = self.theme_manager.get_icon(icon_filename, self.current_theme_name) protondbLabel = ClickableLabel( protondb_text, icon=icon, parent=coverFrame, icon_size=16, icon_space=3, ) protondbLabel.setStyleSheet(self.theme.get_protondb_badge_style(protondb_tier)) protondbLabel.setFixedWidth(badge_width) protondbLabel.clicked.connect(lambda: QDesktopServices.openUrl(QUrl(f"https://www.protondb.com/app/{appid}"))) protondb_visible = True else: protondbLabel = ClickableLabel("", parent=coverFrame, icon_size=16, icon_space=3) protondbLabel.setFixedWidth(badge_width) protondbLabel.setVisible(False) protondb_visible = False # Steam бейдж steam_icon = self.theme_manager.get_icon("steam") steamLabel = ClickableLabel( "Steam", icon=steam_icon, parent=coverFrame, icon_size=16, icon_space=5, ) steamLabel.setStyleSheet(self.theme.STEAM_BADGE_STYLE) steamLabel.setFixedWidth(badge_width) steamLabel.setVisible(steam_visible) steamLabel.clicked.connect(lambda: QDesktopServices.openUrl(QUrl(f"https://steamcommunity.com/app/{appid}"))) # Epic Games Store бейдж egs_icon = self.theme_manager.get_icon("steam") egsLabel = ClickableLabel( "Epic Games", icon=egs_icon, parent=coverFrame, icon_size=16, icon_space=5, change_cursor=False ) egsLabel.setStyleSheet(self.theme.STEAM_BADGE_STYLE) egsLabel.setFixedWidth(badge_width) egsLabel.setVisible(egs_visible) # PortProton badge portproton_icon = self.theme_manager.get_icon("ppqt-tray") portprotonLabel = ClickableLabel( "PortProton", icon=portproton_icon, parent=coverFrame, icon_size=16, icon_space=5, change_cursor=False ) portprotonLabel.setStyleSheet(self.theme.STEAM_BADGE_STYLE) portprotonLabel.setFixedWidth(badge_width) portprotonLabel.setVisible(portproton_visible) # WeAntiCheatYet бейдж anticheat_text = GameCard.getAntiCheatText(anticheat_status) if anticheat_text: icon_filename = GameCard.getAntiCheatIconFilename(anticheat_status) icon = self.theme_manager.get_icon(icon_filename, self.current_theme_name) anticheatLabel = ClickableLabel( anticheat_text, icon=icon, parent=coverFrame, icon_size=16, icon_space=3, ) anticheatLabel.setStyleSheet(self.theme.STEAM_BADGE_STYLE) anticheatLabel.setFixedWidth(badge_width) anticheatLabel.clicked.connect(lambda: QDesktopServices.openUrl(QUrl(f"https://areweanticheatyet.com/game/{name.lower().replace(' ', '-')}"))) anticheat_visible = True else: anticheatLabel = ClickableLabel("", parent=coverFrame, icon_size=16, icon_space=3) anticheatLabel.setFixedWidth(badge_width) anticheatLabel.setVisible(False) anticheat_visible = False # Расположение бейджей right_margin = 8 badge_spacing = 5 top_y = 10 badge_y_positions = [] badge_width = int(300 * 2/3) if steam_visible: steam_x = 300 - badge_width - right_margin steamLabel.move(steam_x, top_y) badge_y_positions.append(top_y + steamLabel.height()) if egs_visible: egs_x = 300 - badge_width - right_margin egs_y = badge_y_positions[-1] + badge_spacing if badge_y_positions else top_y egsLabel.move(egs_x, egs_y) badge_y_positions.append(egs_y + egsLabel.height()) if portproton_visible: portproton_x = 300 - badge_width - right_margin portproton_y = badge_y_positions[-1] + badge_spacing if badge_y_positions else top_y portprotonLabel.move(portproton_x, portproton_y) badge_y_positions.append(portproton_y + portprotonLabel.height()) if protondb_visible: protondb_x = 300 - badge_width - right_margin protondb_y = badge_y_positions[-1] + badge_spacing if badge_y_positions else top_y protondbLabel.move(protondb_x, protondb_y) badge_y_positions.append(protondb_y + protondbLabel.height()) if anticheat_visible: anticheat_x = 300 - badge_width - right_margin anticheat_y = badge_y_positions[-1] + badge_spacing if badge_y_positions else top_y anticheatLabel.move(anticheat_x, anticheat_y) anticheatLabel.raise_() protondbLabel.raise_() portprotonLabel.raise_() egsLabel.raise_() steamLabel.raise_() contentFrameLayout.addWidget(coverFrame) # Детали игры (справа) detailsWidget = QWidget() detailsWidget.setStyleSheet(self.theme.DETAILS_WIDGET_STYLE) detailsLayout = QVBoxLayout(detailsWidget) detailsLayout.setContentsMargins(20, 20, 20, 20) detailsLayout.setSpacing(15) titleLabel = QLabel(name) titleLabel.setStyleSheet(self.theme.DETAIL_PAGE_TITLE_STYLE) detailsLayout.addWidget(titleLabel) line = QFrame() line.setFrameShape(QFrame.Shape.HLine) line.setStyleSheet(self.theme.DETAIL_PAGE_LINE_STYLE) detailsLayout.addWidget(line) descLabel = QLabel(description) descLabel.setWordWrap(True) descLabel.setStyleSheet(self.theme.DETAIL_PAGE_DESC_STYLE) detailsLayout.addWidget(descLabel) infoLayout = QHBoxLayout() infoLayout.setSpacing(10) lastLaunchTitle = QLabel(_("LAST LAUNCH")) lastLaunchTitle.setStyleSheet(self.theme.LAST_LAUNCH_TITLE_STYLE) lastLaunchValue = QLabel(last_launch) lastLaunchValue.setStyleSheet(self.theme.LAST_LAUNCH_VALUE_STYLE) playTimeTitle = QLabel(_("PLAY TIME")) playTimeTitle.setStyleSheet(self.theme.PLAY_TIME_TITLE_STYLE) playTimeValue = QLabel(formatted_playtime) playTimeValue.setStyleSheet(self.theme.PLAY_TIME_VALUE_STYLE) infoLayout.addWidget(lastLaunchTitle) infoLayout.addWidget(lastLaunchValue) infoLayout.addSpacing(30) infoLayout.addWidget(playTimeTitle) infoLayout.addWidget(playTimeValue) detailsLayout.addLayout(infoLayout) if controller_support: cs = controller_support.lower() translated_cs = "" if cs == "full": translated_cs = _("full") elif cs == "partial": translated_cs = _("partial") elif cs == "none": translated_cs = _("none") gamepadSupportLabel = QLabel(_("Gamepad Support: {0}").format(translated_cs)) gamepadSupportLabel.setAlignment(Qt.AlignmentFlag.AlignCenter) gamepadSupportLabel.setStyleSheet(self.theme.GAMEPAD_SUPPORT_VALUE_STYLE) detailsLayout.addWidget(gamepadSupportLabel, alignment=Qt.AlignmentFlag.AlignCenter) detailsLayout.addStretch(1) # Определяем текущий идентификатор игры по exec_line для корректного отображения кнопки entry_exec_split = shlex.split(exec_line) if not entry_exec_split: return if entry_exec_split[0] == "env": file_to_check = entry_exec_split[2] if len(entry_exec_split) >= 3 else None elif entry_exec_split[0] == "flatpak": file_to_check = entry_exec_split[3] if len(entry_exec_split) >= 4 else None else: file_to_check = entry_exec_split[0] current_exe = os.path.basename(file_to_check) if file_to_check else None if self.target_exe is not None and current_exe == self.target_exe: playButton = AutoSizeButton(_("Stop"), icon=self.theme_manager.get_icon("stop")) else: playButton = AutoSizeButton(_("Play"), icon=self.theme_manager.get_icon("play")) playButton.setFixedSize(120, 40) playButton.setStyleSheet(self.theme.PLAY_BUTTON_STYLE) playButton.clicked.connect(lambda: self.toggleGame(exec_line, playButton)) detailsLayout.addWidget(playButton, alignment=Qt.AlignmentFlag.AlignLeft) contentFrameLayout.addWidget(detailsWidget) mainLayout.addStretch() self.stackedWidget.addWidget(detailPage) self.stackedWidget.setCurrentWidget(detailPage) self.currentDetailPage = detailPage self.current_exec_line = exec_line self.current_play_button = playButton # Анимация opacityEffect = QGraphicsOpacityEffect(detailPage) detailPage.setGraphicsEffect(opacityEffect) animation = QPropertyAnimation(opacityEffect, QByteArray(b"opacity")) animation.setDuration(800) animation.setStartValue(0) animation.setEndValue(1) animation.start(QAbstractAnimation.DeletionPolicy.DeleteWhenStopped) self._animations[detailPage] = animation animation.finished.connect( lambda: detailPage.setGraphicsEffect(cast(QGraphicsEffect, None)) ) def toggleFavoriteInDetailPage(self, game_name, label): favorites = read_favorites() if game_name in favorites: favorites.remove(game_name) label.setText("☆") else: favorites.append(game_name) label.setText("★") save_favorites(favorites) self.updateGameGrid() def activateFocusedWidget(self): """Activate the currently focused widget.""" focused_widget = QApplication.focusWidget() if not focused_widget: return if isinstance(focused_widget, ClickableLabel): focused_widget.clicked.emit() elif isinstance(focused_widget, AutoSizeButton): focused_widget.click() elif isinstance(focused_widget, QPushButton): focused_widget.click() elif isinstance(focused_widget, NavLabel): focused_widget.clicked.emit() elif isinstance(focused_widget, ImageCarousel): if focused_widget.image_items: current_item = focused_widget.image_items[focused_widget.horizontalScrollBar().value() // 100] current_item.show_fullscreen() elif isinstance(focused_widget, QLineEdit): focused_widget.setFocus() focused_widget.selectAll() elif isinstance(focused_widget, QCheckBox): focused_widget.setChecked(not focused_widget.isChecked()) elif isinstance(focused_widget, GameCard): focused_widget.select_callback( focused_widget.name, focused_widget.description, focused_widget.cover_path, focused_widget.appid, focused_widget.controller_support, focused_widget.exec_line, focused_widget.last_launch, focused_widget.formatted_playtime, focused_widget.protondb_tier, focused_widget.game_source ) def goBackDetailPage(self, page: QWidget | None) -> None: if page is None or page != self.stackedWidget.currentWidget(): return self.stackedWidget.setCurrentIndex(0) self.stackedWidget.removeWidget(page) page.deleteLater() self.currentDetailPage = None self.current_exec_line = None self.current_play_button = None def is_target_exe_running(self): """Проверяет, запущен ли процесс с именем self.target_exe через psutil.""" if not self.target_exe: return False for proc in psutil.process_iter(attrs=["name"]): if proc.info["name"].lower() == self.target_exe.lower(): return True return False def checkTargetExe(self): """ Проверяет, запущена ли игра. Если процесс игры (target_exe) обнаружен – устанавливаем флаг и обновляем кнопку. Если игра завершилась – сбрасываем флаг, обновляем кнопку и останавливаем таймер. """ target_running = self.is_target_exe_running() child_running = any(proc.poll() is None for proc in self.game_processes) if target_running: # Игра стартовала – устанавливаем флаг, обновляем кнопку на "Stop" self._gameLaunched = True if self.current_running_button is not None: self.current_running_button.setText(_("Stop")) #self._inhibit_screensaver() elif not child_running: # Игра завершилась – сбрасываем флаг, сбрасываем кнопку и останавливаем таймер self._gameLaunched = False self.resetPlayButton() #self._uninhibit_screensaver() if hasattr(self, 'checkProcessTimer') and self.checkProcessTimer is not None: self.checkProcessTimer.stop() self.checkProcessTimer.deleteLater() self.checkProcessTimer = None def resetPlayButton(self): """ Сбрасывает кнопку запуска игры: меняет текст на "Играть", устанавливает иконку и сбрасывает переменные. Вызывается, когда игра завершилась (не по нажатию кнопки). """ if self.current_running_button is not None: self.current_running_button.setText(_("Play")) icon = self.theme_manager.get_icon("play") if isinstance(icon, str): icon = QIcon(icon) # Convert path to QIcon elif icon is None: icon = QIcon() # Use empty QIcon as fallback self.current_running_button.setIcon(icon) self.current_running_button = None self.target_exe = None def toggleGame(self, exec_line, button=None): if exec_line.startswith("steam://"): url = QUrl(exec_line) QDesktopServices.openUrl(url) return entry_exec_split = shlex.split(exec_line) if entry_exec_split[0] == "env": if len(entry_exec_split) < 3: QMessageBox.warning(self, _("Error"), _("Invalid command format (native)")) return file_to_check = entry_exec_split[2] elif entry_exec_split[0] == "flatpak": if len(entry_exec_split) < 4: QMessageBox.warning(self, _("Error"), _("Invalid command format (flatpak)")) return file_to_check = entry_exec_split[3] else: file_to_check = entry_exec_split[0] if not os.path.exists(file_to_check): QMessageBox.warning(self, _("Error"), _("File not found: {0}").format(file_to_check)) return current_exe = os.path.basename(file_to_check) if self.game_processes and self.target_exe is not None and self.target_exe != current_exe: QMessageBox.warning(self, _("Error"), _("Cannot launch game while another game is running")) return update_button = button if button is not None else self.current_play_button # Если игра уже запущена для этого exe – останавливаем её по нажатию кнопки if self.game_processes and self.target_exe == current_exe: for proc in self.game_processes: try: parent = psutil.Process(proc.pid) children = parent.children(recursive=True) for child in children: try: child.terminate() except psutil.NoSuchProcess: pass psutil.wait_procs(children, timeout=5) for child in children: if child.is_running(): child.kill() os.killpg(os.getpgid(proc.pid), signal.SIGTERM) except psutil.NoSuchProcess: pass self.game_processes = [] if update_button: update_button.setText(_("Play")) icon = self.theme_manager.get_icon("play") if isinstance(icon, str): icon = QIcon(icon) elif icon is None: icon = QIcon() update_button.setIcon(icon) if hasattr(self, 'checkProcessTimer') and self.checkProcessTimer is not None: self.checkProcessTimer.stop() self.checkProcessTimer.deleteLater() self.checkProcessTimer = None self.current_running_button = None self.target_exe = None self._gameLaunched = False #self._uninhibit_screensaver() else: # Сохраняем ссылку на кнопку для сброса после завершения игры self.current_running_button = update_button self.target_exe = current_exe exe_name = os.path.splitext(current_exe)[0] env_vars = os.environ.copy() if entry_exec_split[0] == "env" and len(entry_exec_split) > 1 and 'data/scripts/start.sh' in entry_exec_split[1]: env_vars['START_FROM_STEAM'] = '1' elif entry_exec_split[0] == "flatpak": env_vars['START_FROM_STEAM'] = '1' process = subprocess.Popen(entry_exec_split, env=env_vars, shell=False, preexec_fn=os.setsid) self.game_processes.append(process) save_last_launch(exe_name, datetime.now()) if update_button: update_button.setText(_("Launching")) icon = self.theme_manager.get_icon("stop") if isinstance(icon, str): icon = QIcon(icon) elif icon is None: icon = QIcon() update_button.setIcon(icon) self.checkProcessTimer = QTimer(self) self.checkProcessTimer.timeout.connect(self.checkTargetExe) self.checkProcessTimer.start(500) def closeEvent(self, event): for proc in self.game_processes: try: os.killpg(os.getpgid(proc.pid), signal.SIGTERM) except ProcessLookupError: pass # процесс уже завершился if not read_fullscreen_config(): save_window_geometry(self.width(), self.height()) save_card_size(self.card_width) event.accept()