merge upstream
This commit is contained in:
		@@ -4,8 +4,9 @@ from PySide6.QtWidgets import QApplication
 | 
			
		||||
from PySide6.QtGui import QIcon
 | 
			
		||||
from portprotonqt.main_window import MainWindow
 | 
			
		||||
from portprotonqt.tray import SystemTray
 | 
			
		||||
from portprotonqt.config_utils import read_theme_from_config
 | 
			
		||||
from portprotonqt.config_utils import read_theme_from_config, save_fullscreen_config
 | 
			
		||||
from portprotonqt.logger import get_logger
 | 
			
		||||
from portprotonqt.cli import parse_args
 | 
			
		||||
 | 
			
		||||
logger = get_logger(__name__)
 | 
			
		||||
 | 
			
		||||
@@ -28,7 +29,17 @@ def main():
 | 
			
		||||
    else:
 | 
			
		||||
        logger.error(f"Qt translations for {system_locale.name()} not found in {translations_path}")
 | 
			
		||||
 | 
			
		||||
    # Парсинг аргументов командной строки
 | 
			
		||||
    args = parse_args()
 | 
			
		||||
 | 
			
		||||
    window = MainWindow()
 | 
			
		||||
 | 
			
		||||
    # Обработка флага --fullscreen
 | 
			
		||||
    if args.fullscreen:
 | 
			
		||||
        logger.info("Запуск в полноэкранном режиме по флагу --fullscreen")
 | 
			
		||||
        save_fullscreen_config(True)
 | 
			
		||||
        window.showFullScreen()
 | 
			
		||||
 | 
			
		||||
    current_theme_name = read_theme_from_config()
 | 
			
		||||
    tray = SystemTray(app, current_theme_name)
 | 
			
		||||
    tray.show_action.triggered.connect(window.show)
 | 
			
		||||
@@ -43,7 +54,9 @@ def main():
 | 
			
		||||
        tray.hide_action.triggered.connect(window.hide)
 | 
			
		||||
 | 
			
		||||
    window.settings_saved.connect(recreate_tray)
 | 
			
		||||
 | 
			
		||||
    window.show()
 | 
			
		||||
 | 
			
		||||
    sys.exit(app.exec())
 | 
			
		||||
 | 
			
		||||
if __name__ == '__main__':
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										16
									
								
								portprotonqt/cli.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								portprotonqt/cli.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,16 @@
 | 
			
		||||
import argparse
 | 
			
		||||
from portprotonqt.logger import get_logger
 | 
			
		||||
 | 
			
		||||
logger = get_logger(__name__)
 | 
			
		||||
 | 
			
		||||
def parse_args():
 | 
			
		||||
    """
 | 
			
		||||
    Парсит аргументы командной строки.
 | 
			
		||||
    """
 | 
			
		||||
    parser = argparse.ArgumentParser(description="PortProtonQt CLI")
 | 
			
		||||
    parser.add_argument(
 | 
			
		||||
        "--fullscreen",
 | 
			
		||||
        action="store_true",
 | 
			
		||||
        help="Запустить приложение в полноэкранном режиме и сохранить эту настройку"
 | 
			
		||||
    )
 | 
			
		||||
    return parser.parse_args()
 | 
			
		||||
@@ -10,7 +10,7 @@ _portproton_location = None
 | 
			
		||||
# Пути к конфигурационным файлам
 | 
			
		||||
CONFIG_FILE = os.path.join(
 | 
			
		||||
    os.getenv("XDG_CONFIG_HOME", os.path.join(os.path.expanduser("~"), ".config")),
 | 
			
		||||
    "PortProtonQT.conf"
 | 
			
		||||
    "PortProtonQt.conf"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
PORTPROTON_CONFIG_FILE = os.path.join(
 | 
			
		||||
@@ -21,7 +21,7 @@ PORTPROTON_CONFIG_FILE = os.path.join(
 | 
			
		||||
# Пути к папкам с темами
 | 
			
		||||
xdg_data_home = os.getenv("XDG_DATA_HOME", os.path.join(os.path.expanduser("~"), ".local", "share"))
 | 
			
		||||
THEMES_DIRS = [
 | 
			
		||||
    os.path.join(xdg_data_home, "PortProtonQT", "themes"),
 | 
			
		||||
    os.path.join(xdg_data_home, "PortProtonQt", "themes"),
 | 
			
		||||
    os.path.join(os.path.dirname(os.path.abspath(__file__)), "themes")
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
@@ -322,6 +322,41 @@ def save_favorites(favorites):
 | 
			
		||||
    with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
 | 
			
		||||
        cp.write(configfile)
 | 
			
		||||
 | 
			
		||||
def read_rumble_config():
 | 
			
		||||
    """
 | 
			
		||||
    Читает настройку виброотдачи геймпада из секции [Gamepad].
 | 
			
		||||
    Если параметр отсутствует, сохраняет и возвращает False по умолчанию.
 | 
			
		||||
    """
 | 
			
		||||
    cp = configparser.ConfigParser()
 | 
			
		||||
    if os.path.exists(CONFIG_FILE):
 | 
			
		||||
        try:
 | 
			
		||||
            cp.read(CONFIG_FILE, encoding="utf-8")
 | 
			
		||||
        except Exception as e:
 | 
			
		||||
            logger.error("Ошибка чтения конфигурационного файла: %s", e)
 | 
			
		||||
            save_rumble_config(False)
 | 
			
		||||
            return False
 | 
			
		||||
        if not cp.has_section("Gamepad") or not cp.has_option("Gamepad", "rumble_enabled"):
 | 
			
		||||
            save_rumble_config(False)
 | 
			
		||||
            return False
 | 
			
		||||
        return cp.getboolean("Gamepad", "rumble_enabled", fallback=False)
 | 
			
		||||
    return False
 | 
			
		||||
 | 
			
		||||
def save_rumble_config(rumble_enabled):
 | 
			
		||||
    """
 | 
			
		||||
    Сохраняет настройку виброотдачи геймпада в секцию [Gamepad].
 | 
			
		||||
    """
 | 
			
		||||
    cp = configparser.ConfigParser()
 | 
			
		||||
    if os.path.exists(CONFIG_FILE):
 | 
			
		||||
        try:
 | 
			
		||||
            cp.read(CONFIG_FILE, encoding="utf-8")
 | 
			
		||||
        except (configparser.DuplicateSectionError, configparser.DuplicateOptionError) as e:
 | 
			
		||||
            logger.error("Ошибка чтения конфигурационного файла: %s", e)
 | 
			
		||||
    if "Gamepad" not in cp:
 | 
			
		||||
        cp["Gamepad"] = {}
 | 
			
		||||
    cp["Gamepad"]["rumble_enabled"] = str(rumble_enabled)
 | 
			
		||||
    with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
 | 
			
		||||
        cp.write(configfile)
 | 
			
		||||
 | 
			
		||||
def ensure_default_proxy_config():
 | 
			
		||||
    """
 | 
			
		||||
    Проверяет наличие секции [Proxy] в конфигурационном файле.
 | 
			
		||||
@@ -342,7 +377,6 @@ def ensure_default_proxy_config():
 | 
			
		||||
            with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
 | 
			
		||||
                cp.write(configfile)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def read_proxy_config():
 | 
			
		||||
    """
 | 
			
		||||
    Читает настройки прокси из секции [Proxy] конфигурационного файла.
 | 
			
		||||
@@ -421,8 +455,6 @@ def save_fullscreen_config(fullscreen):
 | 
			
		||||
    with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
 | 
			
		||||
        cp.write(configfile)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def read_window_geometry() -> tuple[int, int]:
 | 
			
		||||
    """
 | 
			
		||||
    Читает ширину и высоту окна из секции [MainWindow] конфигурационного файла.
 | 
			
		||||
@@ -472,14 +504,14 @@ def reset_config():
 | 
			
		||||
 | 
			
		||||
def clear_cache():
 | 
			
		||||
    """
 | 
			
		||||
    Очищает кэш PortProtonQT, удаляя папку кэша.
 | 
			
		||||
    Очищает кэш PortProtonQt, удаляя папку кэша.
 | 
			
		||||
    """
 | 
			
		||||
    xdg_cache_home = os.getenv("XDG_CACHE_HOME", os.path.join(os.path.expanduser("~"), ".cache"))
 | 
			
		||||
    cache_dir = os.path.join(xdg_cache_home, "PortProtonQT")
 | 
			
		||||
    cache_dir = os.path.join(xdg_cache_home, "PortProtonQt")
 | 
			
		||||
    if os.path.exists(cache_dir):
 | 
			
		||||
        try:
 | 
			
		||||
            shutil.rmtree(cache_dir)
 | 
			
		||||
            logger.info("Кэш PortProtonQT удалён: %s", cache_dir)
 | 
			
		||||
            logger.info("Кэш PortProtonQt удалён: %s", cache_dir)
 | 
			
		||||
        except Exception as e:
 | 
			
		||||
            logger.error("Ошибка при удалении кэша: %s", e)
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -6,12 +6,13 @@ import subprocess
 | 
			
		||||
from PySide6.QtWidgets import QMessageBox, QDialog, QMenu
 | 
			
		||||
from PySide6.QtCore import QUrl, QPoint
 | 
			
		||||
from PySide6.QtGui import QDesktopServices
 | 
			
		||||
from portprotonqt.config_utils import parse_desktop_entry
 | 
			
		||||
from portprotonqt.config_utils import parse_desktop_entry, read_favorites, save_favorites
 | 
			
		||||
from portprotonqt.localization import _
 | 
			
		||||
from portprotonqt.steam_api import is_game_in_steam, add_to_steam, remove_from_steam
 | 
			
		||||
from portprotonqt.dialogs import AddGameDialog
 | 
			
		||||
 | 
			
		||||
class ContextMenuManager:
 | 
			
		||||
    """Manages context menu actions for game management in PortProtonQT."""
 | 
			
		||||
    """Manages context menu actions for game management in PortProtonQt."""
 | 
			
		||||
 | 
			
		||||
    def __init__(self, parent, portproton_location, theme, load_games_callback, update_game_grid_callback):
 | 
			
		||||
        """
 | 
			
		||||
@@ -40,6 +41,18 @@ class ContextMenuManager:
 | 
			
		||||
        """
 | 
			
		||||
 | 
			
		||||
        menu = QMenu(self.parent)
 | 
			
		||||
        menu.setStyleSheet(self.theme.CONTEXT_MENU_STYLE)
 | 
			
		||||
 | 
			
		||||
        favorites = read_favorites()
 | 
			
		||||
        is_favorite = game_card.name in favorites
 | 
			
		||||
 | 
			
		||||
        if is_favorite:
 | 
			
		||||
            favorite_action = menu.addAction(_("Remove from Favorites"))
 | 
			
		||||
            favorite_action.triggered.connect(lambda: self.toggle_favorite(game_card, False))
 | 
			
		||||
        else:
 | 
			
		||||
            favorite_action = menu.addAction(_("Add to Favorites"))
 | 
			
		||||
            favorite_action.triggered.connect(lambda: self.toggle_favorite(game_card, True))
 | 
			
		||||
 | 
			
		||||
        if game_card.game_source not in ("steam", "epic"):
 | 
			
		||||
            desktop_dir = subprocess.check_output(['xdg-user-dir', 'DESKTOP']).decode('utf-8').strip()
 | 
			
		||||
            desktop_path = os.path.join(desktop_dir, f"{game_card.name}.desktop")
 | 
			
		||||
@@ -79,6 +92,26 @@ class ContextMenuManager:
 | 
			
		||||
 | 
			
		||||
        menu.exec(game_card.mapToGlobal(pos))
 | 
			
		||||
 | 
			
		||||
    def toggle_favorite(self, game_card, add: bool):
 | 
			
		||||
        """
 | 
			
		||||
        Toggle the favorite status of a game and update its icon.
 | 
			
		||||
 | 
			
		||||
        Args:
 | 
			
		||||
            game_card: The GameCard instance to toggle.
 | 
			
		||||
            add: True to add to favorites, False to remove.
 | 
			
		||||
        """
 | 
			
		||||
        favorites = read_favorites()
 | 
			
		||||
        if add and game_card.name not in favorites:
 | 
			
		||||
            favorites.append(game_card.name)
 | 
			
		||||
            game_card.is_favorite = True
 | 
			
		||||
            self.parent.statusBar().showMessage(_("Added '{0}' to favorites").format(game_card.name), 3000)
 | 
			
		||||
        elif not add and game_card.name in favorites:
 | 
			
		||||
            favorites.remove(game_card.name)
 | 
			
		||||
            game_card.is_favorite = False
 | 
			
		||||
            self.parent.statusBar().showMessage(_("Removed '{0}' from favorites").format(game_card.name), 3000)
 | 
			
		||||
        save_favorites(favorites)
 | 
			
		||||
        game_card.update_favorite_icon()
 | 
			
		||||
 | 
			
		||||
    def _check_portproton(self):
 | 
			
		||||
        """Check if PortProton is available."""
 | 
			
		||||
        if self.portproton_location is None:
 | 
			
		||||
@@ -225,7 +258,7 @@ class ContextMenuManager:
 | 
			
		||||
                "XDG_DATA_HOME",
 | 
			
		||||
                os.path.join(os.path.expanduser("~"), ".local", "share")
 | 
			
		||||
            )
 | 
			
		||||
            custom_folder = os.path.join(xdg_data_home, "PortProtonQT", "custom_data", exe_name)
 | 
			
		||||
            custom_folder = os.path.join(xdg_data_home, "PortProtonQt", "custom_data", exe_name)
 | 
			
		||||
            if os.path.exists(custom_folder):
 | 
			
		||||
                try:
 | 
			
		||||
                    shutil.rmtree(custom_folder)
 | 
			
		||||
@@ -321,7 +354,6 @@ class ContextMenuManager:
 | 
			
		||||
 | 
			
		||||
    def edit_game_shortcut(self, game_name, exec_line, cover_path):
 | 
			
		||||
        """Opens the AddGameDialog in edit mode to modify an existing .desktop file."""
 | 
			
		||||
        from portprotonqt.dialogs import AddGameDialog  # Local import to avoid circular dependency
 | 
			
		||||
 | 
			
		||||
        if not self._check_portproton():
 | 
			
		||||
            return
 | 
			
		||||
@@ -385,7 +417,7 @@ class ContextMenuManager:
 | 
			
		||||
                    "XDG_DATA_HOME",
 | 
			
		||||
                    os.path.join(os.path.expanduser("~"), ".local", "share")
 | 
			
		||||
                )
 | 
			
		||||
                custom_folder = os.path.join(xdg_data_home, "PortProtonQT", "custom_data", exe_name)
 | 
			
		||||
                custom_folder = os.path.join(xdg_data_home, "PortProtonQt", "custom_data", exe_name)
 | 
			
		||||
                os.makedirs(custom_folder, exist_ok=True)
 | 
			
		||||
 | 
			
		||||
                ext = os.path.splitext(new_cover_path)[1].lower()
 | 
			
		||||
 
 | 
			
		||||
@@ -303,7 +303,7 @@ class Downloader(QObject):
 | 
			
		||||
 | 
			
		||||
        local_path = os.path.join(
 | 
			
		||||
            os.getenv("XDG_CACHE_HOME", os.path.join(os.path.expanduser("~"), ".cache")),
 | 
			
		||||
            "PortProtonQT", "legendary_cache", "legendary"
 | 
			
		||||
            "PortProtonQt", "legendary_cache", "legendary"
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        logger.info(f"Downloading legendary binary version {version} from {binary_url} to {local_path}")
 | 
			
		||||
 
 | 
			
		||||
@@ -22,7 +22,7 @@ def get_cache_dir() -> Path:
 | 
			
		||||
        "XDG_CACHE_HOME",
 | 
			
		||||
        os.path.join(os.path.expanduser("~"), ".cache")
 | 
			
		||||
    )
 | 
			
		||||
    cache_dir = Path(xdg_cache_home) / "PortProtonQT"
 | 
			
		||||
    cache_dir = Path(xdg_cache_home) / "PortProtonQt"
 | 
			
		||||
    cache_dir.mkdir(parents=True, exist_ok=True)
 | 
			
		||||
    return cache_dir
 | 
			
		||||
 | 
			
		||||
@@ -36,7 +36,7 @@ def get_egs_game_description_async(
 | 
			
		||||
    Asynchronously fetches the game description from the Epic Games Store API.
 | 
			
		||||
    Prioritizes GraphQL API with namespace for slug and description.
 | 
			
		||||
    Falls back to legacy API if GraphQL provides a slug but no description.
 | 
			
		||||
    Caches results in ~/.cache/PortProtonQT/egs_app_{app_name}.json.
 | 
			
		||||
    Caches results in ~/.cache/PortProtonQt/egs_app_{app_name}.json.
 | 
			
		||||
    Handles DNS resolution failures gracefully.
 | 
			
		||||
    """
 | 
			
		||||
    cache_dir = get_cache_dir()
 | 
			
		||||
@@ -423,7 +423,7 @@ def _continue_loading_egs_games(legendary_path: str, callback: Callable[[list[tu
 | 
			
		||||
            except Exception as e:
 | 
			
		||||
                logger.warning("Error processing metadata for %s: %s", app_name, str(e))
 | 
			
		||||
 | 
			
		||||
            image_folder = os.path.join(os.getenv("XDG_CACHE_HOME", os.path.join(os.path.expanduser("~"), ".cache")), "PortProtonQT", "images")
 | 
			
		||||
            image_folder = os.path.join(os.getenv("XDG_CACHE_HOME", os.path.join(os.path.expanduser("~"), ".cache")), "PortProtonQt", "images")
 | 
			
		||||
            local_path = os.path.join(image_folder, f"{app_name}.jpg") if cover_url else ""
 | 
			
		||||
 | 
			
		||||
            def on_description_fetched(api_description: str):
 | 
			
		||||
 
 | 
			
		||||
@@ -199,7 +199,7 @@ class GameCard(QFrame):
 | 
			
		||||
                icon_size=16,
 | 
			
		||||
                icon_space=3,
 | 
			
		||||
            )
 | 
			
		||||
            self.anticheatLabel.setStyleSheet(self.theme.STEAM_BADGE_STYLE)
 | 
			
		||||
            self.anticheatLabel.setStyleSheet(self.theme.get_anticheat_badge_style(anticheat_status))
 | 
			
		||||
            self.anticheatLabel.setFixedWidth(int(card_width * 2/3))
 | 
			
		||||
            anticheat_visible = True
 | 
			
		||||
        else:
 | 
			
		||||
@@ -261,46 +261,45 @@ class GameCard(QFrame):
 | 
			
		||||
        self.steam_visible = (str(self.game_source).lower() == "steam" and display_filter in ("all", "favorites"))
 | 
			
		||||
        self.egs_visible = (str(self.game_source).lower() == "epic" and display_filter in ("all", "favorites"))
 | 
			
		||||
        self.portproton_visible = (str(self.game_source).lower() == "portproton" and display_filter in ("all", "favorites"))
 | 
			
		||||
        protondb_visible = bool(self.getProtonDBText(self.protondb_tier))
 | 
			
		||||
        anticheat_visible = bool(self.getAntiCheatText(self.anticheat_status))
 | 
			
		||||
 | 
			
		||||
        # Обновляем видимость бейджей
 | 
			
		||||
        self.steamLabel.setVisible(self.steam_visible)
 | 
			
		||||
        self.egsLabel.setVisible(self.egs_visible)
 | 
			
		||||
        self.portprotonLabel.setVisible(self.portproton_visible)
 | 
			
		||||
        self.protondbLabel.setVisible(protondb_visible)
 | 
			
		||||
        self.anticheatLabel.setVisible(anticheat_visible)
 | 
			
		||||
 | 
			
		||||
        # Reposition badges
 | 
			
		||||
        # Подготавливаем список всех бейджей с их текущей видимостью
 | 
			
		||||
        badges = [
 | 
			
		||||
            (self.steam_visible, self.steamLabel),
 | 
			
		||||
            (self.egs_visible, self.egsLabel),
 | 
			
		||||
            (self.portproton_visible, self.portprotonLabel),
 | 
			
		||||
            (protondb_visible, self.protondbLabel),
 | 
			
		||||
            (anticheat_visible, self.anticheatLabel),
 | 
			
		||||
        ]
 | 
			
		||||
 | 
			
		||||
        # Пересчитываем позиции бейджей
 | 
			
		||||
        right_margin = 8
 | 
			
		||||
        badge_spacing = 5
 | 
			
		||||
        top_y = 10
 | 
			
		||||
        badge_y_positions = []
 | 
			
		||||
        badge_width = int(self.coverLabel.width() * 2/3)
 | 
			
		||||
        if self.steam_visible:
 | 
			
		||||
            steam_x = self.coverLabel.width() - badge_width - right_margin
 | 
			
		||||
            self.steamLabel.move(steam_x, top_y)
 | 
			
		||||
            badge_y_positions.append(top_y + self.steamLabel.height())
 | 
			
		||||
        if self.egs_visible:
 | 
			
		||||
            egs_x = self.coverLabel.width() - badge_width - right_margin
 | 
			
		||||
            egs_y = badge_y_positions[-1] + badge_spacing if badge_y_positions else top_y
 | 
			
		||||
            self.egsLabel.move(egs_x, egs_y)
 | 
			
		||||
            badge_y_positions.append(egs_y + self.egsLabel.height())
 | 
			
		||||
        if self.portproton_visible:
 | 
			
		||||
            portproton_x = self.coverLabel.width() - badge_width - right_margin
 | 
			
		||||
            portproton_y = badge_y_positions[-1] + badge_spacing if badge_y_positions else top_y
 | 
			
		||||
            self.portprotonLabel.move(portproton_x, portproton_y)
 | 
			
		||||
            badge_y_positions.append(portproton_y + self.portprotonLabel.height())
 | 
			
		||||
        if self.protondbLabel.isVisible():
 | 
			
		||||
            protondb_x = self.coverLabel.width() - badge_width - right_margin
 | 
			
		||||
            protondb_y = badge_y_positions[-1] + badge_spacing if badge_y_positions else top_y
 | 
			
		||||
            self.protondbLabel.move(protondb_x, protondb_y)
 | 
			
		||||
            badge_y_positions.append(protondb_y + self.protondbLabel.height())
 | 
			
		||||
        if self.anticheatLabel.isVisible():
 | 
			
		||||
            anticheat_x = self.coverLabel.width() - badge_width - right_margin
 | 
			
		||||
            anticheat_y = badge_y_positions[-1] + badge_spacing if badge_y_positions else top_y
 | 
			
		||||
            self.anticheatLabel.move(anticheat_x, anticheat_y)
 | 
			
		||||
 | 
			
		||||
            self.anticheatLabel.raise_()
 | 
			
		||||
            self.protondbLabel.raise_()
 | 
			
		||||
            self.portprotonLabel.raise_()
 | 
			
		||||
            self.egsLabel.raise_()
 | 
			
		||||
            self.steamLabel.raise_()
 | 
			
		||||
        for is_visible, badge in badges:
 | 
			
		||||
            if is_visible:
 | 
			
		||||
                badge_x = self.coverLabel.width() - badge_width - right_margin
 | 
			
		||||
                badge_y = badge_y_positions[-1] + badge_spacing if badge_y_positions else top_y
 | 
			
		||||
                badge.move(badge_x, badge_y)
 | 
			
		||||
                badge_y_positions.append(badge_y + badge.height())
 | 
			
		||||
 | 
			
		||||
        # Поднимаем бейджи в правильном порядке (от нижнего к верхнему)
 | 
			
		||||
        self.anticheatLabel.raise_()
 | 
			
		||||
        self.protondbLabel.raise_()
 | 
			
		||||
        self.portprotonLabel.raise_()
 | 
			
		||||
        self.egsLabel.raise_()
 | 
			
		||||
        self.steamLabel.raise_()
 | 
			
		||||
 | 
			
		||||
    def _show_context_menu(self, pos):
 | 
			
		||||
        """Delegate context menu display to ContextMenuManager."""
 | 
			
		||||
 
 | 
			
		||||
@@ -35,10 +35,9 @@ def load_pixmap_async(cover: str, width: int, height: int, callback: Callable[[Q
 | 
			
		||||
            y = (scaled.height() - height) // 2
 | 
			
		||||
            cropped = scaled.copy(x, y, width, height)
 | 
			
		||||
            callback(cropped)
 | 
			
		||||
            # Removed: pixmap = None (unnecessary, causes type error)
 | 
			
		||||
 | 
			
		||||
        xdg_cache_home = os.getenv("XDG_CACHE_HOME", os.path.join(os.path.expanduser("~"), ".cache"))
 | 
			
		||||
        image_folder = os.path.join(xdg_cache_home, "PortProtonQT", "images")
 | 
			
		||||
        image_folder = os.path.join(xdg_cache_home, "PortProtonQt", "images")
 | 
			
		||||
        os.makedirs(image_folder, exist_ok=True)
 | 
			
		||||
 | 
			
		||||
        if cover and cover.startswith("https://steamcdn-a.akamaihd.net/steam/apps/"):
 | 
			
		||||
 
 | 
			
		||||
@@ -1,16 +1,16 @@
 | 
			
		||||
import time
 | 
			
		||||
import threading
 | 
			
		||||
from typing import Protocol, cast
 | 
			
		||||
from evdev import InputDevice, ecodes, list_devices
 | 
			
		||||
import pyudev
 | 
			
		||||
from PySide6.QtWidgets import QWidget, QStackedWidget, QApplication, QScrollArea, QLineEdit, QDialog
 | 
			
		||||
from PySide6.QtCore import Qt, QObject, QEvent, QPoint, Signal, Slot
 | 
			
		||||
from evdev import InputDevice, InputEvent, ecodes, list_devices, ff
 | 
			
		||||
from pyudev import Context, Monitor, MonitorObserver, Device
 | 
			
		||||
from PySide6.QtWidgets import QWidget, QStackedWidget, QApplication, QScrollArea, QLineEdit, QDialog, QMenu, QComboBox, QListView, QMessageBox
 | 
			
		||||
from PySide6.QtCore import Qt, QObject, QEvent, QPoint, Signal, Slot, QTimer
 | 
			
		||||
from PySide6.QtGui import QKeyEvent
 | 
			
		||||
from portprotonqt.logger import get_logger
 | 
			
		||||
from portprotonqt.image_utils import FullscreenDialog
 | 
			
		||||
from portprotonqt.custom_widgets import NavLabel
 | 
			
		||||
from portprotonqt.game_card import GameCard
 | 
			
		||||
from portprotonqt.config_utils import read_fullscreen_config, read_window_geometry, save_window_geometry, read_auto_fullscreen_gamepad
 | 
			
		||||
from portprotonqt.config_utils import read_fullscreen_config, read_window_geometry, save_window_geometry, read_auto_fullscreen_gamepad, read_rumble_config
 | 
			
		||||
 | 
			
		||||
logger = get_logger(__name__)
 | 
			
		||||
 | 
			
		||||
@@ -25,6 +25,8 @@ class MainWindowProtocol(Protocol):
 | 
			
		||||
        ...
 | 
			
		||||
    def toggleGame(self, exec_line: str | None, button: QWidget | None = None) -> None:
 | 
			
		||||
        ...
 | 
			
		||||
    def openSystemOverlay(self) -> None:
 | 
			
		||||
            ...
 | 
			
		||||
    stackedWidget: QStackedWidget
 | 
			
		||||
    tabButtons: dict[int, QWidget]
 | 
			
		||||
    gamesListWidget: QWidget
 | 
			
		||||
@@ -32,24 +34,25 @@ class MainWindowProtocol(Protocol):
 | 
			
		||||
    current_exec_line: str | None
 | 
			
		||||
    current_add_game_dialog: QDialog | None
 | 
			
		||||
 | 
			
		||||
# Mapping of actions to evdev button codes, includes PlayStation, Xbox, and Switch controllers
 | 
			
		||||
# Mapping of actions to evdev button codes, includes Xbox and Playstation controllers
 | 
			
		||||
# https://github.com/torvalds/linux/blob/master/drivers/hid/hid-playstation.c
 | 
			
		||||
# https://github.com/torvalds/linux/blob/master/drivers/input/joystick/xpad.c
 | 
			
		||||
BUTTONS = {
 | 
			
		||||
    'confirm':   {ecodes.BTN_A},
 | 
			
		||||
    'back':      {ecodes.BTN_B},
 | 
			
		||||
    'add_game':  {ecodes.BTN_Y},
 | 
			
		||||
    'prev_tab':  {ecodes.BTN_TL, ecodes.BTN_TRIGGER_HAPPY7},
 | 
			
		||||
    'next_tab':  {ecodes.BTN_TR, ecodes.BTN_TRIGGER_HAPPY5},
 | 
			
		||||
    'confirm_stick': {ecodes.BTN_THUMBL, ecodes.BTN_THUMBR},
 | 
			
		||||
    'context_menu': {ecodes.BTN_START},
 | 
			
		||||
    'menu':      {ecodes.BTN_SELECT},
 | 
			
		||||
    'confirm':   {ecodes.BTN_A, ecodes.BTN_SOUTH}, # A / Cross
 | 
			
		||||
    'back':      {ecodes.BTN_B, ecodes.BTN_EAST},  # B / Circle
 | 
			
		||||
    'add_game':  {ecodes.BTN_Y, ecodes.BTN_NORTH}, # Y / Triangle
 | 
			
		||||
    'prev_tab':  {ecodes.BTN_TL},                  # LB / L1
 | 
			
		||||
    'next_tab':  {ecodes.BTN_TR},                  # RB / R1
 | 
			
		||||
    'context_menu': {ecodes.BTN_START},            # Start / Options
 | 
			
		||||
    'menu':      {ecodes.BTN_SELECT},              # Select / Share
 | 
			
		||||
    'guide':     {ecodes.BTN_MODE},                # Xbox / PS Home
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class InputManager(QObject):
 | 
			
		||||
    """
 | 
			
		||||
    Manages input from gamepads and keyboards for navigating the application interface.
 | 
			
		||||
    Supports gamepad hotplugging, button and axis events, and keyboard event filtering
 | 
			
		||||
    for seamless UI interaction. Enables fullscreen mode when a gamepad is connected
 | 
			
		||||
    and restores normal mode when disconnected.
 | 
			
		||||
    for seamless UI interaction.
 | 
			
		||||
    """
 | 
			
		||||
    # Signals for gamepad events
 | 
			
		||||
    button_pressed = Signal(int)  # Signal for button presses
 | 
			
		||||
@@ -69,7 +72,6 @@ class InputManager(QObject):
 | 
			
		||||
        self._parent.currentDetailPage = getattr(self._parent, 'currentDetailPage', None)
 | 
			
		||||
        self._parent.current_exec_line = getattr(self._parent, 'current_exec_line', None)
 | 
			
		||||
        self._parent.current_add_game_dialog = getattr(self._parent, 'current_add_game_dialog', None)
 | 
			
		||||
 | 
			
		||||
        self.axis_deadzone = axis_deadzone
 | 
			
		||||
        self.initial_axis_move_delay = initial_axis_move_delay
 | 
			
		||||
        self.repeat_axis_move_delay = repeat_axis_move_delay
 | 
			
		||||
@@ -80,6 +82,13 @@ class InputManager(QObject):
 | 
			
		||||
        self.gamepad_thread: threading.Thread | None = None
 | 
			
		||||
        self.running = True
 | 
			
		||||
        self._is_fullscreen = read_fullscreen_config()
 | 
			
		||||
        self.rumble_effect_id: int | None = None  # Store the rumble effect ID
 | 
			
		||||
 | 
			
		||||
        # Add variables for continuous D-pad movement
 | 
			
		||||
        self.dpad_timer = QTimer(self)
 | 
			
		||||
        self.dpad_timer.timeout.connect(self.handle_dpad_repeat)
 | 
			
		||||
        self.current_dpad_code = None  # Tracks the current D-pad axis (e.g., ABS_HAT0X, ABS_HAT0Y)
 | 
			
		||||
        self.current_dpad_value = 0    # Tracks the current D-pad direction value (e.g., -1, 1)
 | 
			
		||||
 | 
			
		||||
        # Connect signals to slots
 | 
			
		||||
        self.button_pressed.connect(self.handle_button_slot)
 | 
			
		||||
@@ -117,6 +126,48 @@ class InputManager(QObject):
 | 
			
		||||
        except Exception as e:
 | 
			
		||||
            logger.error(f"Error in handle_fullscreen_slot: {e}", exc_info=True)
 | 
			
		||||
 | 
			
		||||
    def trigger_rumble(self, duration_ms: int = 200, strong_magnitude: int = 0x8000, weak_magnitude: int = 0x8000) -> None:
 | 
			
		||||
        """Trigger a rumble effect on the gamepad if supported."""
 | 
			
		||||
        if not read_rumble_config():
 | 
			
		||||
            return
 | 
			
		||||
        if not self.gamepad:
 | 
			
		||||
            return
 | 
			
		||||
        try:
 | 
			
		||||
            # Check if the gamepad supports force feedback
 | 
			
		||||
            caps = self.gamepad.capabilities()
 | 
			
		||||
            if ecodes.EV_FF not in caps or ecodes.FF_RUMBLE not in caps.get(ecodes.EV_FF, []):
 | 
			
		||||
                logger.debug("Gamepad does not support force feedback or rumble")
 | 
			
		||||
                return
 | 
			
		||||
 | 
			
		||||
            # Create a rumble effect
 | 
			
		||||
            rumble = ff.Rumble(strong_magnitude=strong_magnitude, weak_magnitude=weak_magnitude)
 | 
			
		||||
            effect = ff.Effect(
 | 
			
		||||
                id=-1,  # Let evdev assign an ID
 | 
			
		||||
                type=ecodes.FF_RUMBLE,
 | 
			
		||||
                direction=0,  # Direction (not used for rumble)
 | 
			
		||||
                replay=ff.Replay(length=duration_ms, delay=0),
 | 
			
		||||
                u=ff.EffectType(ff_rumble_effect=rumble)
 | 
			
		||||
            )
 | 
			
		||||
 | 
			
		||||
            # Upload the effect
 | 
			
		||||
            self.rumble_effect_id = self.gamepad.upload_effect(effect)
 | 
			
		||||
            # Play the effect
 | 
			
		||||
            event = InputEvent(0, 0, ecodes.EV_FF, self.rumble_effect_id, 1)
 | 
			
		||||
            self.gamepad.write_event(event)
 | 
			
		||||
            # Schedule effect erasure after duration
 | 
			
		||||
            QTimer.singleShot(duration_ms, self.stop_rumble)
 | 
			
		||||
        except Exception as e:
 | 
			
		||||
            logger.error(f"Error triggering rumble: {e}", exc_info=True)
 | 
			
		||||
 | 
			
		||||
    def stop_rumble(self) -> None:
 | 
			
		||||
        """Stop the rumble effect and clean up."""
 | 
			
		||||
        if self.gamepad and self.rumble_effect_id is not None:
 | 
			
		||||
            try:
 | 
			
		||||
                self.gamepad.erase_effect(self.rumble_effect_id)
 | 
			
		||||
                self.rumble_effect_id = None
 | 
			
		||||
            except Exception as e:
 | 
			
		||||
                logger.error(f"Error stopping rumble: {e}", exc_info=True)
 | 
			
		||||
 | 
			
		||||
    @Slot(int)
 | 
			
		||||
    def handle_button_slot(self, button_code: int) -> None:
 | 
			
		||||
        try:
 | 
			
		||||
@@ -129,10 +180,66 @@ class InputManager(QObject):
 | 
			
		||||
                return
 | 
			
		||||
            active = QApplication.activeWindow()
 | 
			
		||||
            focused = QApplication.focusWidget()
 | 
			
		||||
            popup = QApplication.activePopupWidget()
 | 
			
		||||
 | 
			
		||||
            # Handle Guide button to open system overlay
 | 
			
		||||
            if button_code in BUTTONS['guide']:
 | 
			
		||||
                if not popup and not isinstance(active, QDialog):
 | 
			
		||||
                    self._parent.openSystemOverlay()
 | 
			
		||||
                    return
 | 
			
		||||
 | 
			
		||||
            # Handle QMenu (context menu)
 | 
			
		||||
            if isinstance(popup, QMenu):
 | 
			
		||||
                if button_code in BUTTONS['confirm']:
 | 
			
		||||
                    if popup.activeAction():
 | 
			
		||||
                        popup.activeAction().trigger()
 | 
			
		||||
                        popup.close()
 | 
			
		||||
                    return
 | 
			
		||||
                elif button_code in BUTTONS['back']:
 | 
			
		||||
                    popup.close()
 | 
			
		||||
                    return
 | 
			
		||||
                return
 | 
			
		||||
 | 
			
		||||
            # Handle QComboBox
 | 
			
		||||
            if isinstance(focused, QComboBox):
 | 
			
		||||
                if button_code in BUTTONS['confirm']:
 | 
			
		||||
                    focused.showPopup()
 | 
			
		||||
                return
 | 
			
		||||
 | 
			
		||||
            # Handle QListView
 | 
			
		||||
            if isinstance(focused, QListView):
 | 
			
		||||
                combo = None
 | 
			
		||||
                parent = focused.parentWidget()
 | 
			
		||||
                while parent:
 | 
			
		||||
                    if isinstance(parent, QComboBox):
 | 
			
		||||
                        combo = parent
 | 
			
		||||
                        break
 | 
			
		||||
                    parent = parent.parentWidget()
 | 
			
		||||
 | 
			
		||||
                if button_code in BUTTONS['confirm']:
 | 
			
		||||
                    idx = focused.currentIndex()
 | 
			
		||||
                    if idx.isValid():
 | 
			
		||||
                        if combo:
 | 
			
		||||
                            combo.setCurrentIndex(idx.row())
 | 
			
		||||
                            combo.hidePopup()
 | 
			
		||||
                            combo.setFocus(Qt.FocusReason.OtherFocusReason)
 | 
			
		||||
                        else:
 | 
			
		||||
                            focused.activated.emit(idx)
 | 
			
		||||
                            focused.clicked.emit(idx)
 | 
			
		||||
                            focused.hide()
 | 
			
		||||
                    return
 | 
			
		||||
 | 
			
		||||
                if button_code in BUTTONS['back']:
 | 
			
		||||
                    if combo:
 | 
			
		||||
                        combo.hidePopup()
 | 
			
		||||
                        combo.setFocus(Qt.FocusReason.OtherFocusReason)
 | 
			
		||||
                    else:
 | 
			
		||||
                        focused.clearSelection()
 | 
			
		||||
                        focused.hide()
 | 
			
		||||
 | 
			
		||||
            # Закрытие AddGameDialog на кнопку B
 | 
			
		||||
            if button_code in BUTTONS['back'] and isinstance(active, QDialog):
 | 
			
		||||
                active.reject()  # Закрываем диалог
 | 
			
		||||
                active.reject()
 | 
			
		||||
                return
 | 
			
		||||
 | 
			
		||||
            # FullscreenDialog
 | 
			
		||||
@@ -149,22 +256,26 @@ class InputManager(QObject):
 | 
			
		||||
            if isinstance(focused, GameCard):
 | 
			
		||||
                if button_code in BUTTONS['context_menu']:
 | 
			
		||||
                    pos = QPoint(focused.width() // 2, focused.height() // 2)
 | 
			
		||||
                    focused._show_context_menu(pos)
 | 
			
		||||
                    menu = focused._show_context_menu(pos)
 | 
			
		||||
                    if menu:
 | 
			
		||||
                        menu.setFocus(Qt.FocusReason.OtherFocusReason)
 | 
			
		||||
                    return
 | 
			
		||||
 | 
			
		||||
            # Game launch on detail page
 | 
			
		||||
            if (button_code in BUTTONS['confirm'] or button_code in BUTTONS['confirm_stick']) and self._parent.currentDetailPage is not None and self._parent.current_add_game_dialog is None:
 | 
			
		||||
            if (button_code in BUTTONS['confirm']) and self._parent.currentDetailPage is not None and self._parent.current_add_game_dialog is None:
 | 
			
		||||
                if self._parent.current_exec_line:
 | 
			
		||||
                    self.trigger_rumble()
 | 
			
		||||
                    self._parent.toggleGame(self._parent.current_exec_line, None)
 | 
			
		||||
                    return
 | 
			
		||||
 | 
			
		||||
            # Standard navigation
 | 
			
		||||
            if button_code in BUTTONS['confirm'] or button_code in BUTTONS['confirm_stick']:
 | 
			
		||||
            if button_code in BUTTONS['confirm']:
 | 
			
		||||
                self._parent.activateFocusedWidget()
 | 
			
		||||
            elif button_code in BUTTONS['back'] or button_code in BUTTONS['menu']:
 | 
			
		||||
            elif button_code in BUTTONS['back']:
 | 
			
		||||
                self._parent.goBackDetailPage(getattr(self._parent, 'currentDetailPage', None))
 | 
			
		||||
            elif button_code in BUTTONS['add_game']:
 | 
			
		||||
                self._parent.openAddGameDialog()
 | 
			
		||||
                if self._parent.stackedWidget.currentIndex() == 0:
 | 
			
		||||
                    self._parent.openAddGameDialog()
 | 
			
		||||
            elif button_code in BUTTONS['prev_tab']:
 | 
			
		||||
                idx = (self._parent.stackedWidget.currentIndex() - 1) % len(self._parent.tabButtons)
 | 
			
		||||
                self._parent.switchTab(idx)
 | 
			
		||||
@@ -176,6 +287,14 @@ class InputManager(QObject):
 | 
			
		||||
        except Exception as e:
 | 
			
		||||
            logger.error(f"Error in handle_button_slot: {e}", exc_info=True)
 | 
			
		||||
 | 
			
		||||
    def handle_dpad_repeat(self) -> None:
 | 
			
		||||
        """Handle repeated D-pad input while the D-pad is held."""
 | 
			
		||||
        if self.current_dpad_code is not None and self.current_dpad_value != 0:
 | 
			
		||||
            now = time.time()
 | 
			
		||||
            if (now - self.last_move_time) >= self.current_axis_delay:
 | 
			
		||||
                self.handle_dpad_slot(self.current_dpad_code, self.current_dpad_value, now)
 | 
			
		||||
                self.last_move_time = now
 | 
			
		||||
                self.current_axis_delay = self.repeat_axis_move_delay
 | 
			
		||||
 | 
			
		||||
    @Slot(int, int, float)
 | 
			
		||||
    def handle_dpad_slot(self, code: int, value: int, current_time: float) -> None:
 | 
			
		||||
@@ -188,6 +307,85 @@ class InputManager(QObject):
 | 
			
		||||
            if not app:
 | 
			
		||||
                return
 | 
			
		||||
            active = QApplication.activeWindow()
 | 
			
		||||
            focused = QApplication.focusWidget()
 | 
			
		||||
            popup = QApplication.activePopupWidget()
 | 
			
		||||
 | 
			
		||||
            # Update D-pad state
 | 
			
		||||
            if value != 0:
 | 
			
		||||
                self.current_dpad_code = code
 | 
			
		||||
                self.current_dpad_value = value
 | 
			
		||||
                if not self.axis_moving:
 | 
			
		||||
                    self.axis_moving = True
 | 
			
		||||
                    self.last_move_time = current_time
 | 
			
		||||
                    self.current_axis_delay = self.initial_axis_move_delay
 | 
			
		||||
                    self.dpad_timer.start(int(self.repeat_axis_move_delay * 1000))  # Start timer (in milliseconds)
 | 
			
		||||
            else:
 | 
			
		||||
                self.current_dpad_code = None
 | 
			
		||||
                self.current_dpad_value = 0
 | 
			
		||||
                self.axis_moving = False
 | 
			
		||||
                self.current_axis_delay = self.initial_axis_move_delay
 | 
			
		||||
                self.dpad_timer.stop()  # Stop timer when D-pad is released
 | 
			
		||||
                return
 | 
			
		||||
 | 
			
		||||
            # Handle SystemOverlay, AddGameDialog, or QMessageBox navigation with D-pad
 | 
			
		||||
            if isinstance(active, QDialog) and code == ecodes.ABS_HAT0X and value != 0:
 | 
			
		||||
                if isinstance(active, QMessageBox):  # Specific handling for QMessageBox
 | 
			
		||||
                    if not focused or not active.focusWidget():
 | 
			
		||||
                        # If no widget is focused, focus the first focusable widget
 | 
			
		||||
                        focusables = active.findChildren(QWidget, options=Qt.FindChildOption.FindChildrenRecursively)
 | 
			
		||||
                        focusables = [w for w in focusables if w.focusPolicy() & Qt.FocusPolicy.StrongFocus]
 | 
			
		||||
                        if focusables:
 | 
			
		||||
                            focusables[0].setFocus(Qt.FocusReason.OtherFocusReason)
 | 
			
		||||
                        return
 | 
			
		||||
                    if value > 0:  # Right
 | 
			
		||||
                        active.focusNextChild()
 | 
			
		||||
                    elif value < 0:  # Left
 | 
			
		||||
                        active.focusPreviousChild()
 | 
			
		||||
                    return
 | 
			
		||||
            elif isinstance(active, QDialog) and code == ecodes.ABS_HAT0Y and value != 0:  # Keep up/down for other dialogs
 | 
			
		||||
                if not focused or not active.focusWidget():
 | 
			
		||||
                    # If no widget is focused, focus the first focusable widget
 | 
			
		||||
                    focusables = active.findChildren(QWidget, options=Qt.FindChildOption.FindChildrenRecursively)
 | 
			
		||||
                    focusables = [w for w in focusables if w.focusPolicy() & Qt.FocusPolicy.StrongFocus]
 | 
			
		||||
                    if focusables:
 | 
			
		||||
                        focusables[0].setFocus(Qt.FocusReason.OtherFocusReason)
 | 
			
		||||
                    return
 | 
			
		||||
                if value > 0:  # Down
 | 
			
		||||
                    active.focusNextChild()
 | 
			
		||||
                elif value < 0:  # Up
 | 
			
		||||
                    active.focusPreviousChild()
 | 
			
		||||
                return
 | 
			
		||||
 | 
			
		||||
            # Handle QMenu navigation with D-pad
 | 
			
		||||
            if isinstance(popup, QMenu):
 | 
			
		||||
                if code == ecodes.ABS_HAT0Y and value != 0:
 | 
			
		||||
                    actions = popup.actions()
 | 
			
		||||
                    if actions:
 | 
			
		||||
                        current_idx = actions.index(popup.activeAction()) if popup.activeAction() in actions else 0
 | 
			
		||||
                        if value < 0:  # Up
 | 
			
		||||
                            next_idx = (current_idx - 1) % len(actions)
 | 
			
		||||
                            popup.setActiveAction(actions[next_idx])
 | 
			
		||||
                        elif value > 0:  # Down
 | 
			
		||||
                            next_idx = (current_idx + 1) % len(actions)
 | 
			
		||||
                            popup.setActiveAction(actions[next_idx])
 | 
			
		||||
                    return
 | 
			
		||||
                return
 | 
			
		||||
 | 
			
		||||
            # Handle QListView navigation with D-pad
 | 
			
		||||
            if isinstance(focused, QListView) and code == ecodes.ABS_HAT0Y and value != 0:
 | 
			
		||||
                model = focused.model()
 | 
			
		||||
                current_index = focused.currentIndex()
 | 
			
		||||
                if model and current_index.isValid():
 | 
			
		||||
                    row_count = model.rowCount()
 | 
			
		||||
                    current_row = current_index.row()
 | 
			
		||||
                    if value > 0:  # Down
 | 
			
		||||
                        next_row = min(current_row + 1, row_count - 1)
 | 
			
		||||
                        focused.setCurrentIndex(model.index(next_row, current_index.column()))
 | 
			
		||||
                    elif value < 0:  # Up
 | 
			
		||||
                        prev_row = max(current_row - 1, 0)
 | 
			
		||||
                        focused.setCurrentIndex(model.index(prev_row, current_index.column()))
 | 
			
		||||
                    focused.scrollTo(focused.currentIndex(), QListView.ScrollHint.PositionAtCenter)
 | 
			
		||||
                return
 | 
			
		||||
 | 
			
		||||
            # Fullscreen horizontal navigation
 | 
			
		||||
            if isinstance(active, FullscreenDialog) and code == ecodes.ABS_HAT0X:
 | 
			
		||||
@@ -197,19 +395,6 @@ class InputManager(QObject):
 | 
			
		||||
                    active.show_next()
 | 
			
		||||
                return
 | 
			
		||||
 | 
			
		||||
            # Handle repeated D-pad movement
 | 
			
		||||
            if value != 0:
 | 
			
		||||
                if not self.axis_moving:
 | 
			
		||||
                    self.axis_moving = True
 | 
			
		||||
                elif (current_time - self.last_move_time) < self.current_axis_delay:
 | 
			
		||||
                    return
 | 
			
		||||
                self.last_move_time = current_time
 | 
			
		||||
                self.current_axis_delay = self.repeat_axis_move_delay
 | 
			
		||||
            else:
 | 
			
		||||
                self.axis_moving = False
 | 
			
		||||
                self.current_axis_delay = self.initial_axis_move_delay
 | 
			
		||||
                return
 | 
			
		||||
 | 
			
		||||
            # Library tab navigation (index 0)
 | 
			
		||||
            if self._parent.stackedWidget.currentIndex() == 0 and code in (ecodes.ABS_HAT0X, ecodes.ABS_HAT0Y):
 | 
			
		||||
                focused = QApplication.focusWidget()
 | 
			
		||||
@@ -280,7 +465,6 @@ class InputManager(QObject):
 | 
			
		||||
                                    next_card.setFocus()
 | 
			
		||||
                                    if scroll_area:
 | 
			
		||||
                                        scroll_area.ensureWidgetVisible(next_card, 50, 50)
 | 
			
		||||
 | 
			
		||||
                elif code == ecodes.ABS_HAT0Y and value != 0:  # Up/Down
 | 
			
		||||
                    if value > 0:  # Down
 | 
			
		||||
                        next_row_idx = current_row_idx + 1
 | 
			
		||||
@@ -350,6 +534,12 @@ class InputManager(QObject):
 | 
			
		||||
        focused = QApplication.focusWidget()
 | 
			
		||||
        popup = QApplication.activePopupWidget()
 | 
			
		||||
 | 
			
		||||
        # Open system overlay with Insert
 | 
			
		||||
        if key == Qt.Key.Key_Insert:
 | 
			
		||||
            if not popup and not isinstance(QApplication.activeWindow(), QDialog):
 | 
			
		||||
                self._parent.openSystemOverlay()
 | 
			
		||||
                return True
 | 
			
		||||
 | 
			
		||||
        # Close application with Ctrl+Q
 | 
			
		||||
        if key == Qt.Key.Key_Q and modifiers & Qt.KeyboardModifier.ControlModifier:
 | 
			
		||||
            app.quit()
 | 
			
		||||
@@ -390,6 +580,23 @@ class InputManager(QObject):
 | 
			
		||||
                focused._show_context_menu(pos)
 | 
			
		||||
                return True
 | 
			
		||||
 | 
			
		||||
        # Handle Up/Down keys for non-GameCard tabs
 | 
			
		||||
        if key in (Qt.Key.Key_Up, Qt.Key.Key_Down) and not isinstance(focused, GameCard):
 | 
			
		||||
            page = self._parent.stackedWidget.currentWidget()
 | 
			
		||||
            if key == Qt.Key.Key_Down:
 | 
			
		||||
                if isinstance(focused, NavLabel):
 | 
			
		||||
                    focusables = page.findChildren(QWidget, options=Qt.FindChildOption.FindChildrenRecursively)
 | 
			
		||||
                    focusables = [w for w in focusables if w.focusPolicy() & Qt.FocusPolicy.StrongFocus]
 | 
			
		||||
                    if focusables:
 | 
			
		||||
                        focusables[0].setFocus()
 | 
			
		||||
                        return True
 | 
			
		||||
                elif focused:
 | 
			
		||||
                    focused.focusNextChild()
 | 
			
		||||
                    return True
 | 
			
		||||
            elif key == Qt.Key.Key_Up and focused:
 | 
			
		||||
                focused.focusPreviousChild()
 | 
			
		||||
                return True
 | 
			
		||||
 | 
			
		||||
        # Tab switching with Left/Right keys (non-GameCard focus or no focus)
 | 
			
		||||
        idx = self._parent.stackedWidget.currentIndex()
 | 
			
		||||
        total = len(self._parent.tabButtons)
 | 
			
		||||
@@ -520,6 +727,9 @@ class InputManager(QObject):
 | 
			
		||||
                if focusables:
 | 
			
		||||
                    focusables[0].setFocus()
 | 
			
		||||
                    return True
 | 
			
		||||
            elif focused:
 | 
			
		||||
                focused.focusNextChild()
 | 
			
		||||
                return True
 | 
			
		||||
        # Navigate up through tab content
 | 
			
		||||
        if key == Qt.Key.Key_Up:
 | 
			
		||||
            if isinstance(focused, NavLabel):
 | 
			
		||||
@@ -540,8 +750,10 @@ class InputManager(QObject):
 | 
			
		||||
        elif key == Qt.Key.Key_E:
 | 
			
		||||
            if isinstance(focused, QLineEdit):
 | 
			
		||||
                return False
 | 
			
		||||
            self._parent.openAddGameDialog()
 | 
			
		||||
            return True
 | 
			
		||||
            # Only open AddGameDialog if in library tab (index 0)
 | 
			
		||||
            if self._parent.stackedWidget.currentIndex() == 0:
 | 
			
		||||
                self._parent.openAddGameDialog()
 | 
			
		||||
                return True
 | 
			
		||||
 | 
			
		||||
        # Toggle fullscreen with F11
 | 
			
		||||
        if key == Qt.Key.Key_F11:
 | 
			
		||||
@@ -559,17 +771,17 @@ class InputManager(QObject):
 | 
			
		||||
 | 
			
		||||
    def run_udev_monitor(self) -> None:
 | 
			
		||||
        try:
 | 
			
		||||
            context = pyudev.Context()
 | 
			
		||||
            monitor = pyudev.Monitor.from_netlink(context)
 | 
			
		||||
            context = Context()
 | 
			
		||||
            monitor = Monitor.from_netlink(context)
 | 
			
		||||
            monitor.filter_by(subsystem='input')
 | 
			
		||||
            observer = pyudev.MonitorObserver(monitor, self.handle_udev_event)
 | 
			
		||||
            observer = MonitorObserver(monitor, self.handle_udev_event)
 | 
			
		||||
            observer.start()
 | 
			
		||||
            while self.running:
 | 
			
		||||
                time.sleep(1)
 | 
			
		||||
        except Exception as e:
 | 
			
		||||
            logger.error(f"Error in udev monitor: {e}", exc_info=True)
 | 
			
		||||
 | 
			
		||||
    def handle_udev_event(self, action: str, device: pyudev.Device) -> None:
 | 
			
		||||
    def handle_udev_event(self, action: str, device: Device) -> None:
 | 
			
		||||
        try:
 | 
			
		||||
            if action == 'add':
 | 
			
		||||
                time.sleep(0.1)
 | 
			
		||||
@@ -577,6 +789,7 @@ class InputManager(QObject):
 | 
			
		||||
            elif action == 'remove' and self.gamepad:
 | 
			
		||||
                if not any(self.gamepad.path == path for path in list_devices()):
 | 
			
		||||
                    logger.info("Gamepad disconnected")
 | 
			
		||||
                    self.stop_rumble()
 | 
			
		||||
                    self.gamepad = None
 | 
			
		||||
                    if self.gamepad_thread:
 | 
			
		||||
                        self.gamepad_thread.join()
 | 
			
		||||
@@ -590,6 +803,7 @@ class InputManager(QObject):
 | 
			
		||||
            new_gamepad = self.find_gamepad()
 | 
			
		||||
            if new_gamepad and new_gamepad != self.gamepad:
 | 
			
		||||
                logger.info(f"Gamepad connected: {new_gamepad.name}")
 | 
			
		||||
                self.stop_rumble()
 | 
			
		||||
                self.gamepad = new_gamepad
 | 
			
		||||
                if self.gamepad_thread:
 | 
			
		||||
                    self.gamepad_thread.join()
 | 
			
		||||
@@ -626,9 +840,7 @@ class InputManager(QObject):
 | 
			
		||||
                    continue
 | 
			
		||||
                now = time.time()
 | 
			
		||||
                if event.type == ecodes.EV_KEY and event.value == 1:
 | 
			
		||||
                    # Обработка кнопки Select для переключения полноэкранного режима
 | 
			
		||||
                    if event.code in BUTTONS['menu']:
 | 
			
		||||
                        # Переключаем полноэкранный режим
 | 
			
		||||
                        self.toggle_fullscreen.emit(not self._is_fullscreen)
 | 
			
		||||
                    else:
 | 
			
		||||
                        self.button_pressed.emit(event.code)
 | 
			
		||||
@@ -644,6 +856,7 @@ class InputManager(QObject):
 | 
			
		||||
        finally:
 | 
			
		||||
            if self.gamepad:
 | 
			
		||||
                try:
 | 
			
		||||
                    self.stop_rumble()
 | 
			
		||||
                    self.gamepad.close()
 | 
			
		||||
                except Exception:
 | 
			
		||||
                    pass
 | 
			
		||||
@@ -652,6 +865,8 @@ class InputManager(QObject):
 | 
			
		||||
    def cleanup(self) -> None:
 | 
			
		||||
        try:
 | 
			
		||||
            self.running = False
 | 
			
		||||
            self.dpad_timer.stop()
 | 
			
		||||
            self.stop_rumble()
 | 
			
		||||
            if self.gamepad_thread:
 | 
			
		||||
                self.gamepad_thread.join()
 | 
			
		||||
            if self.gamepad:
 | 
			
		||||
 
 | 
			
		||||
										
											Binary file not shown.
										
									
								
							@@ -1,6 +1,6 @@
 | 
			
		||||
# German (Germany) translations for PortProtonQT.
 | 
			
		||||
# German (Germany) translations for PortProtonQt.
 | 
			
		||||
# Copyright (C) 2025 boria138
 | 
			
		||||
# This file is distributed under the same license as the PortProtonQT
 | 
			
		||||
# This file is distributed under the same license as the PortProtonQt
 | 
			
		||||
# project.
 | 
			
		||||
# FIRST AUTHOR <EMAIL@ADDRESS>, 2025.
 | 
			
		||||
#
 | 
			
		||||
@@ -9,7 +9,7 @@ msgid ""
 | 
			
		||||
msgstr ""
 | 
			
		||||
"Project-Id-Version: PROJECT VERSION\n"
 | 
			
		||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
 | 
			
		||||
"POT-Creation-Date: 2025-06-06 20:01+0500\n"
 | 
			
		||||
"POT-Creation-Date: 2025-06-11 23:15+0500\n"
 | 
			
		||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
 | 
			
		||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
 | 
			
		||||
"Language: de_DE\n"
 | 
			
		||||
@@ -20,6 +20,12 @@ msgstr ""
 | 
			
		||||
"Content-Transfer-Encoding: 8bit\n"
 | 
			
		||||
"Generated-By: Babel 2.17.0\n"
 | 
			
		||||
 | 
			
		||||
msgid "Remove from Favorites"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Add to Favorites"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Remove from Desktop"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
@@ -47,6 +53,14 @@ msgstr ""
 | 
			
		||||
msgid "Add to Steam"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Added '{0}' to favorites"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Removed '{0}' from favorites"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Error"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
@@ -362,19 +376,10 @@ msgstr ""
 | 
			
		||||
msgid "Auto Fullscreen on Gamepad connected:"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Open Legendary Login"
 | 
			
		||||
msgid "Gamepad haptic feedback"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Legendary Authentication:"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Enter Legendary Authorization Code"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Authorization Code:"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Submit Code"
 | 
			
		||||
msgid "Gamepad haptic feedback:"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Save Settings"
 | 
			
		||||
@@ -392,22 +397,6 @@ msgstr ""
 | 
			
		||||
msgid "Failed to open Legendary login page"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Please enter an authorization code"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Successfully authenticated with Legendary"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Legendary authentication failed: {0}"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Legendary executable not found"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Unexpected error during authentication"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Confirm Reset"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
@@ -505,6 +494,42 @@ msgstr ""
 | 
			
		||||
msgid "Launching"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "System Overlay"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Reboot"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Shutdown"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Suspend"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Exit Application"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Return to Desktop"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "portprotonqt-session-select file not found at /usr/bin/"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Cancel"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Failed to reboot the system"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Failed to shutdown the system"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Failed to suspend the system"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Failed to return to desktop"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "just now"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
										
											Binary file not shown.
										
									
								
							@@ -1,6 +1,6 @@
 | 
			
		||||
# Spanish (Spain) translations for PortProtonQT.
 | 
			
		||||
# Spanish (Spain) translations for PortProtonQt.
 | 
			
		||||
# Copyright (C) 2025 boria138
 | 
			
		||||
# This file is distributed under the same license as the PortProtonQT
 | 
			
		||||
# This file is distributed under the same license as the PortProtonQt
 | 
			
		||||
# project.
 | 
			
		||||
# FIRST AUTHOR <EMAIL@ADDRESS>, 2025.
 | 
			
		||||
#
 | 
			
		||||
@@ -9,7 +9,7 @@ msgid ""
 | 
			
		||||
msgstr ""
 | 
			
		||||
"Project-Id-Version: PROJECT VERSION\n"
 | 
			
		||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
 | 
			
		||||
"POT-Creation-Date: 2025-06-06 20:01+0500\n"
 | 
			
		||||
"POT-Creation-Date: 2025-06-11 23:15+0500\n"
 | 
			
		||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
 | 
			
		||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
 | 
			
		||||
"Language: es_ES\n"
 | 
			
		||||
@@ -20,6 +20,12 @@ msgstr ""
 | 
			
		||||
"Content-Transfer-Encoding: 8bit\n"
 | 
			
		||||
"Generated-By: Babel 2.17.0\n"
 | 
			
		||||
 | 
			
		||||
msgid "Remove from Favorites"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Add to Favorites"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Remove from Desktop"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
@@ -47,6 +53,14 @@ msgstr ""
 | 
			
		||||
msgid "Add to Steam"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Added '{0}' to favorites"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Removed '{0}' from favorites"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Error"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
@@ -362,19 +376,10 @@ msgstr ""
 | 
			
		||||
msgid "Auto Fullscreen on Gamepad connected:"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Open Legendary Login"
 | 
			
		||||
msgid "Gamepad haptic feedback"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Legendary Authentication:"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Enter Legendary Authorization Code"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Authorization Code:"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Submit Code"
 | 
			
		||||
msgid "Gamepad haptic feedback:"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Save Settings"
 | 
			
		||||
@@ -392,22 +397,6 @@ msgstr ""
 | 
			
		||||
msgid "Failed to open Legendary login page"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Please enter an authorization code"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Successfully authenticated with Legendary"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Legendary authentication failed: {0}"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Legendary executable not found"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Unexpected error during authentication"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Confirm Reset"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
@@ -505,6 +494,42 @@ msgstr ""
 | 
			
		||||
msgid "Launching"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "System Overlay"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Reboot"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Shutdown"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Suspend"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Exit Application"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Return to Desktop"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "portprotonqt-session-select file not found at /usr/bin/"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Cancel"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Failed to reboot the system"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Failed to shutdown the system"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Failed to suspend the system"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Failed to return to desktop"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "just now"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,15 +1,15 @@
 | 
			
		||||
# Translations template for PortProtonQT.
 | 
			
		||||
# Translations template for PortProtonQt.
 | 
			
		||||
# Copyright (C) 2025 boria138
 | 
			
		||||
# This file is distributed under the same license as the PortProtonQT
 | 
			
		||||
# This file is distributed under the same license as the PortProtonQt
 | 
			
		||||
# project.
 | 
			
		||||
# FIRST AUTHOR <EMAIL@ADDRESS>, 2025.
 | 
			
		||||
#
 | 
			
		||||
#, fuzzy
 | 
			
		||||
msgid ""
 | 
			
		||||
msgstr ""
 | 
			
		||||
"Project-Id-Version: PortProtonQT 0.1.1\n"
 | 
			
		||||
"Project-Id-Version: PortProtonQt 0.1.1\n"
 | 
			
		||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
 | 
			
		||||
"POT-Creation-Date: 2025-06-06 20:01+0500\n"
 | 
			
		||||
"POT-Creation-Date: 2025-06-11 23:15+0500\n"
 | 
			
		||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
 | 
			
		||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
 | 
			
		||||
"Language-Team: LANGUAGE <LL@li.org>\n"
 | 
			
		||||
@@ -18,6 +18,12 @@ msgstr ""
 | 
			
		||||
"Content-Transfer-Encoding: 8bit\n"
 | 
			
		||||
"Generated-By: Babel 2.17.0\n"
 | 
			
		||||
 | 
			
		||||
msgid "Remove from Favorites"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Add to Favorites"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Remove from Desktop"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
@@ -45,6 +51,14 @@ msgstr ""
 | 
			
		||||
msgid "Add to Steam"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Added '{0}' to favorites"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Removed '{0}' from favorites"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Error"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
@@ -360,19 +374,10 @@ msgstr ""
 | 
			
		||||
msgid "Auto Fullscreen on Gamepad connected:"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Open Legendary Login"
 | 
			
		||||
msgid "Gamepad haptic feedback"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Legendary Authentication:"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Enter Legendary Authorization Code"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Authorization Code:"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Submit Code"
 | 
			
		||||
msgid "Gamepad haptic feedback:"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Save Settings"
 | 
			
		||||
@@ -390,22 +395,6 @@ msgstr ""
 | 
			
		||||
msgid "Failed to open Legendary login page"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Please enter an authorization code"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Successfully authenticated with Legendary"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Legendary authentication failed: {0}"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Legendary executable not found"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Unexpected error during authentication"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Confirm Reset"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
@@ -503,6 +492,42 @@ msgstr ""
 | 
			
		||||
msgid "Launching"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "System Overlay"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Reboot"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Shutdown"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Suspend"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Exit Application"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Return to Desktop"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "portprotonqt-session-select file not found at /usr/bin/"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Cancel"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Failed to reboot the system"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Failed to shutdown the system"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Failed to suspend the system"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Failed to return to desktop"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "just now"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
										
											Binary file not shown.
										
									
								
							@@ -1,6 +1,6 @@
 | 
			
		||||
# Russian (Russia) translations for PortProtonQT.
 | 
			
		||||
# Russian (Russia) translations for PortProtonQt.
 | 
			
		||||
# Copyright (C) 2025 boria138
 | 
			
		||||
# This file is distributed under the same license as the PortProtonQT
 | 
			
		||||
# This file is distributed under the same license as the PortProtonQt
 | 
			
		||||
# project.
 | 
			
		||||
# FIRST AUTHOR <EMAIL@ADDRESS>, 2025.
 | 
			
		||||
#
 | 
			
		||||
@@ -9,8 +9,8 @@ msgid ""
 | 
			
		||||
msgstr ""
 | 
			
		||||
"Project-Id-Version: PROJECT VERSION\n"
 | 
			
		||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
 | 
			
		||||
"POT-Creation-Date: 2025-06-06 20:01+0500\n"
 | 
			
		||||
"PO-Revision-Date: 2025-06-06 20:01+0500\n"
 | 
			
		||||
"POT-Creation-Date: 2025-06-11 23:15+0500\n"
 | 
			
		||||
"PO-Revision-Date: 2025-06-11 23:15+0500\n"
 | 
			
		||||
"Last-Translator: \n"
 | 
			
		||||
"Language: ru_RU\n"
 | 
			
		||||
"Language-Team: ru_RU <LL@li.org>\n"
 | 
			
		||||
@@ -21,6 +21,12 @@ msgstr ""
 | 
			
		||||
"Content-Transfer-Encoding: 8bit\n"
 | 
			
		||||
"Generated-By: Babel 2.17.0\n"
 | 
			
		||||
 | 
			
		||||
msgid "Remove from Favorites"
 | 
			
		||||
msgstr "Удалить из Избранного"
 | 
			
		||||
 | 
			
		||||
msgid "Add to Favorites"
 | 
			
		||||
msgstr "Добавить в Избранное"
 | 
			
		||||
 | 
			
		||||
msgid "Remove from Desktop"
 | 
			
		||||
msgstr "Удалить с рабочего стола"
 | 
			
		||||
 | 
			
		||||
@@ -48,6 +54,14 @@ msgstr "Удалить из Steam"
 | 
			
		||||
msgid "Add to Steam"
 | 
			
		||||
msgstr "Добавить в Steam"
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Added '{0}' to favorites"
 | 
			
		||||
msgstr "Добавление '{0}' в избранное"
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Removed '{0}' from favorites"
 | 
			
		||||
msgstr "Удаление '{0}' из избранного"
 | 
			
		||||
 | 
			
		||||
msgid "Error"
 | 
			
		||||
msgstr "Ошибка"
 | 
			
		||||
 | 
			
		||||
@@ -369,20 +383,11 @@ msgstr "Режим полноэкранного отображения прил
 | 
			
		||||
msgid "Auto Fullscreen on Gamepad connected:"
 | 
			
		||||
msgstr "Режим полноэкранного отображения приложения при подключении геймпада:"
 | 
			
		||||
 | 
			
		||||
msgid "Open Legendary Login"
 | 
			
		||||
msgstr "Открыть браузер для входа в Legendary"
 | 
			
		||||
msgid "Gamepad haptic feedback"
 | 
			
		||||
msgstr "Тактильная обратная связь на геймпаде"
 | 
			
		||||
 | 
			
		||||
msgid "Legendary Authentication:"
 | 
			
		||||
msgstr "Авторизация в Legendary:"
 | 
			
		||||
 | 
			
		||||
msgid "Enter Legendary Authorization Code"
 | 
			
		||||
msgstr "Введите код авторизации Legendary"
 | 
			
		||||
 | 
			
		||||
msgid "Authorization Code:"
 | 
			
		||||
msgstr "Код авторизации:"
 | 
			
		||||
 | 
			
		||||
msgid "Submit Code"
 | 
			
		||||
msgstr "Отправить код"
 | 
			
		||||
msgid "Gamepad haptic feedback:"
 | 
			
		||||
msgstr "Тактильная обратная связь на геймпаде:"
 | 
			
		||||
 | 
			
		||||
msgid "Save Settings"
 | 
			
		||||
msgstr "Сохранить настройки"
 | 
			
		||||
@@ -399,22 +404,6 @@ msgstr "Открытие страницы входа в Legendary в брауз
 | 
			
		||||
msgid "Failed to open Legendary login page"
 | 
			
		||||
msgstr "Не удалось открыть страницу входа в Legendary"
 | 
			
		||||
 | 
			
		||||
msgid "Please enter an authorization code"
 | 
			
		||||
msgstr "Пожалуйста, введите код авторизации"
 | 
			
		||||
 | 
			
		||||
msgid "Successfully authenticated with Legendary"
 | 
			
		||||
msgstr "Успешная аутентификация с Legendary"
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Legendary authentication failed: {0}"
 | 
			
		||||
msgstr "Сбой аутентификации в Legendary: {0}"
 | 
			
		||||
 | 
			
		||||
msgid "Legendary executable not found"
 | 
			
		||||
msgstr "Не найден исполняемый файл Legendary"
 | 
			
		||||
 | 
			
		||||
msgid "Unexpected error during authentication"
 | 
			
		||||
msgstr "Неожиданная ошибка при аутентификации"
 | 
			
		||||
 | 
			
		||||
msgid "Confirm Reset"
 | 
			
		||||
msgstr "Подтвердите удаление"
 | 
			
		||||
 | 
			
		||||
@@ -514,6 +503,42 @@ msgstr "Невозможно запустить игру пока запущен
 | 
			
		||||
msgid "Launching"
 | 
			
		||||
msgstr "Идёт запуск"
 | 
			
		||||
 | 
			
		||||
msgid "System Overlay"
 | 
			
		||||
msgstr "Системный оверлей"
 | 
			
		||||
 | 
			
		||||
msgid "Reboot"
 | 
			
		||||
msgstr "Перезагрузить"
 | 
			
		||||
 | 
			
		||||
msgid "Shutdown"
 | 
			
		||||
msgstr "Выключить"
 | 
			
		||||
 | 
			
		||||
msgid "Suspend"
 | 
			
		||||
msgstr "Перейти в ждущий режим"
 | 
			
		||||
 | 
			
		||||
msgid "Exit Application"
 | 
			
		||||
msgstr "Выйти из приложения"
 | 
			
		||||
 | 
			
		||||
msgid "Return to Desktop"
 | 
			
		||||
msgstr "Вернуться на рабочий стол"
 | 
			
		||||
 | 
			
		||||
msgid "portprotonqt-session-select file not found at /usr/bin/"
 | 
			
		||||
msgstr "portprotonqt-session-select не найдет"
 | 
			
		||||
 | 
			
		||||
msgid "Cancel"
 | 
			
		||||
msgstr "Отмена"
 | 
			
		||||
 | 
			
		||||
msgid "Failed to reboot the system"
 | 
			
		||||
msgstr "Не удалось перезагрузить систему"
 | 
			
		||||
 | 
			
		||||
msgid "Failed to shutdown the system"
 | 
			
		||||
msgstr "Не удалось завершить работу системы"
 | 
			
		||||
 | 
			
		||||
msgid "Failed to suspend the system"
 | 
			
		||||
msgstr "Не удалось перейти в ждущий режим"
 | 
			
		||||
 | 
			
		||||
msgid "Failed to return to desktop"
 | 
			
		||||
msgstr "Не удалось вернуться на рабочий стол"
 | 
			
		||||
 | 
			
		||||
msgid "just now"
 | 
			
		||||
msgstr "только что"
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -13,6 +13,7 @@ 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.system_overlay import SystemOverlay
 | 
			
		||||
 | 
			
		||||
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
 | 
			
		||||
@@ -25,7 +26,7 @@ from portprotonqt.config_utils import (
 | 
			
		||||
    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, read_auto_fullscreen_gamepad, save_auto_fullscreen_gamepad
 | 
			
		||||
    clear_cache, read_auto_fullscreen_gamepad, save_auto_fullscreen_gamepad, read_rumble_config, save_rumble_config
 | 
			
		||||
)
 | 
			
		||||
from portprotonqt.localization import _
 | 
			
		||||
from portprotonqt.logger import get_logger
 | 
			
		||||
@@ -43,7 +44,7 @@ from datetime import datetime
 | 
			
		||||
logger = get_logger(__name__)
 | 
			
		||||
 | 
			
		||||
class MainWindow(QMainWindow):
 | 
			
		||||
    """Main window of PortProtonQT."""
 | 
			
		||||
    """Main window of PortProtonQt."""
 | 
			
		||||
    settings_saved = Signal()
 | 
			
		||||
    games_loaded = Signal(list)
 | 
			
		||||
    update_progress = Signal(int)  # Signal to update progress bar
 | 
			
		||||
@@ -72,10 +73,10 @@ class MainWindow(QMainWindow):
 | 
			
		||||
        self.settingsDebounceTimer.timeout.connect(self.applySettingsDelayed)
 | 
			
		||||
 | 
			
		||||
        read_time_config()
 | 
			
		||||
        # Set LEGENDARY_CONFIG_PATH to ~/.cache/PortProtonQT/legendary
 | 
			
		||||
        # Set LEGENDARY_CONFIG_PATH to ~/.cache/PortProtonQt/legendary_cache
 | 
			
		||||
        self.legendary_config_path = os.path.join(
 | 
			
		||||
            os.getenv("XDG_CACHE_HOME", os.path.join(os.path.expanduser("~"), ".cache")),
 | 
			
		||||
            "PortProtonQT", "legendary_cache"
 | 
			
		||||
            "PortProtonQt", "legendary_cache"
 | 
			
		||||
        )
 | 
			
		||||
        os.makedirs(self.legendary_config_path, exist_ok=True)
 | 
			
		||||
        os.environ["LEGENDARY_CONFIG_PATH"] = self.legendary_config_path
 | 
			
		||||
@@ -97,10 +98,11 @@ class MainWindow(QMainWindow):
 | 
			
		||||
        if not self.theme:
 | 
			
		||||
            self.theme = default_styles
 | 
			
		||||
        self.card_width = read_card_size()
 | 
			
		||||
        self.setWindowTitle("PortProtonQT")
 | 
			
		||||
        self.setWindowTitle("PortProtonQt")
 | 
			
		||||
        self.setMinimumSize(800, 600)
 | 
			
		||||
 | 
			
		||||
        self.games = []
 | 
			
		||||
        self.filtered_games = self.games
 | 
			
		||||
        self.game_processes = []
 | 
			
		||||
        self.target_exe = None
 | 
			
		||||
        self.current_running_button = None
 | 
			
		||||
@@ -259,39 +261,28 @@ class MainWindow(QMainWindow):
 | 
			
		||||
                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]
 | 
			
		||||
            def on_all_games(portproton_games, steam_games):
 | 
			
		||||
                games = [game for game in portproton_games + steam_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
 | 
			
		||||
                    )
 | 
			
		||||
                    lambda sg: on_all_games(pg, sg)
 | 
			
		||||
                )
 | 
			
		||||
            )
 | 
			
		||||
        else:
 | 
			
		||||
            def on_all_games(portproton_games, steam_games, epic_games):
 | 
			
		||||
            def on_all_games(portproton_games, steam_games):
 | 
			
		||||
                seen = set()
 | 
			
		||||
                games = []
 | 
			
		||||
                for game in portproton_games + steam_games + epic_games:
 | 
			
		||||
                    name = game[0]
 | 
			
		||||
                    if name not in seen:
 | 
			
		||||
                        seen.add(name)
 | 
			
		||||
                for game in portproton_games + steam_games:
 | 
			
		||||
                    # Уникальный ключ: имя + exec_line
 | 
			
		||||
                    key = (game[0], game[4])
 | 
			
		||||
                    if key not in seen:
 | 
			
		||||
                        seen.add(key)
 | 
			
		||||
                        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
 | 
			
		||||
                    )
 | 
			
		||||
                    lambda sg: on_all_games(pg, sg)
 | 
			
		||||
                )
 | 
			
		||||
            )
 | 
			
		||||
        return []
 | 
			
		||||
@@ -394,7 +385,7 @@ class MainWindow(QMainWindow):
 | 
			
		||||
        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")
 | 
			
		||||
        user_custom_folder = os.path.join(xdg_data_home, "PortProtonQt", "custom_data")
 | 
			
		||||
        os.makedirs(user_custom_folder, exist_ok=True)
 | 
			
		||||
 | 
			
		||||
        builtin_cover = ""
 | 
			
		||||
@@ -500,6 +491,11 @@ class MainWindow(QMainWindow):
 | 
			
		||||
            btn.setChecked(i == index)
 | 
			
		||||
        self.stackedWidget.setCurrentIndex(index)
 | 
			
		||||
 | 
			
		||||
    def openSystemOverlay(self):
 | 
			
		||||
        """Opens the system overlay dialog."""
 | 
			
		||||
        overlay = SystemOverlay(self, self.theme)
 | 
			
		||||
        overlay.exec()
 | 
			
		||||
 | 
			
		||||
    def createSearchWidget(self) -> tuple[QWidget, QLineEdit]:
 | 
			
		||||
        self.container = QWidget()
 | 
			
		||||
        self.container.setStyleSheet(self.theme.CONTAINER_STYLE)
 | 
			
		||||
@@ -539,14 +535,20 @@ class MainWindow(QMainWindow):
 | 
			
		||||
    def startSearchDebounce(self, text):
 | 
			
		||||
        self.searchDebounceTimer.start()
 | 
			
		||||
 | 
			
		||||
    def on_slider_value_changed(self, value: int):
 | 
			
		||||
            self.card_width = value
 | 
			
		||||
            self.sizeSlider.setToolTip(f"{value} px")
 | 
			
		||||
            save_card_size(value)
 | 
			
		||||
            self.updateGameGrid()
 | 
			
		||||
 | 
			
		||||
    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
 | 
			
		||||
            self.filtered_games = self.games
 | 
			
		||||
        else:
 | 
			
		||||
            filtered = [game for game in self.games if text in game[0].lower()]
 | 
			
		||||
            self.updateGameGrid(filtered)
 | 
			
		||||
            self.filtered_games = [game for game in self.games if text in game[0].lower()]
 | 
			
		||||
        self.updateGameGrid(self.filtered_games)
 | 
			
		||||
 | 
			
		||||
    def createInstalledTab(self):
 | 
			
		||||
        self.gamesLibraryWidget = QWidget()
 | 
			
		||||
@@ -579,33 +581,16 @@ class MainWindow(QMainWindow):
 | 
			
		||||
        self.sizeSlider.setFixedWidth(150)
 | 
			
		||||
        self.sizeSlider.setToolTip(f"{self.card_width} px")
 | 
			
		||||
        self.sizeSlider.setStyleSheet(self.theme.SLIDER_SIZE_STYLE)
 | 
			
		||||
        self.sizeSlider.valueChanged.connect(self.on_slider_value_changed)
 | 
			
		||||
        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)
 | 
			
		||||
 | 
			
		||||
@@ -621,7 +606,6 @@ class MainWindow(QMainWindow):
 | 
			
		||||
            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()
 | 
			
		||||
@@ -638,22 +622,38 @@ class MainWindow(QMainWindow):
 | 
			
		||||
        if games_list is None:
 | 
			
		||||
            games_list = self.games
 | 
			
		||||
        if not games_list:
 | 
			
		||||
            self.clearLayout(self.gamesListLayout)
 | 
			
		||||
            # Скрываем все карточки, если список пуст
 | 
			
		||||
            for card in self.game_card_cache.values():
 | 
			
		||||
                card.hide()
 | 
			
		||||
            self.game_card_cache.clear()
 | 
			
		||||
            self.pending_images.clear()
 | 
			
		||||
            self.gamesListWidget.updateGeometry()
 | 
			
		||||
            return
 | 
			
		||||
 | 
			
		||||
        # Create a set of game names for quick lookup
 | 
			
		||||
        current_games = {game_data[0]: game_data for game_data in games_list}
 | 
			
		||||
        # Создаем словарь текущих игр с уникальным ключом (name + exec_line)
 | 
			
		||||
        current_games = {(game_data[0], game_data[4]): 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
 | 
			
		||||
        # Проверяем, изменился ли список игр или размер карточек
 | 
			
		||||
        current_game_keys = set(current_games.keys())
 | 
			
		||||
        cached_game_keys = set(self.game_card_cache.keys())
 | 
			
		||||
        card_width_changed = self.card_width != getattr(self, '_last_card_width', None)
 | 
			
		||||
 | 
			
		||||
        # Track if layout has changed to decide if geometry update is needed
 | 
			
		||||
        if current_game_keys == cached_game_keys and not card_width_changed:
 | 
			
		||||
            # Список игр и размер карточек не изменились, обновляем только видимость
 | 
			
		||||
            search_text = self.searchEdit.text().strip().lower()
 | 
			
		||||
            for game_key, card in self.game_card_cache.items():
 | 
			
		||||
                game_name = game_key[0]
 | 
			
		||||
                card.setVisible(search_text in game_name.lower() or not search_text)
 | 
			
		||||
            self.loadVisibleImages()
 | 
			
		||||
            return
 | 
			
		||||
 | 
			
		||||
        # Обновляем размер карточек, если он изменился
 | 
			
		||||
        if card_width_changed:
 | 
			
		||||
            for card in self.game_card_cache.values():
 | 
			
		||||
                card.setFixedWidth(self.card_width + 20)  # Учитываем extra_margin в GameCard
 | 
			
		||||
 | 
			
		||||
        # Удаляем карточки, которых больше нет в списке
 | 
			
		||||
        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)
 | 
			
		||||
@@ -663,11 +663,15 @@ class MainWindow(QMainWindow):
 | 
			
		||||
                    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
 | 
			
		||||
            game_key = (game_name, game_data[4])
 | 
			
		||||
            search_text = self.searchEdit.text().strip().lower()
 | 
			
		||||
            should_be_visible = search_text in game_name.lower() or not search_text
 | 
			
		||||
 | 
			
		||||
            if game_key not in self.game_card_cache:
 | 
			
		||||
                # Создаем новую карточку
 | 
			
		||||
                card = GameCard(
 | 
			
		||||
                    *game_data,
 | 
			
		||||
                    select_callback=self.openGameDetailPage,
 | 
			
		||||
@@ -675,7 +679,7 @@ class MainWindow(QMainWindow):
 | 
			
		||||
                    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)
 | 
			
		||||
@@ -685,23 +689,25 @@ class MainWindow(QMainWindow):
 | 
			
		||||
                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.game_card_cache[game_key] = 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
 | 
			
		||||
            else:
 | 
			
		||||
                # Обновляем видимость существующей карточки
 | 
			
		||||
                card = self.game_card_cache[game_key]
 | 
			
		||||
                card.setVisible(should_be_visible)
 | 
			
		||||
 | 
			
		||||
        # Store the current card_width
 | 
			
		||||
        # Сохраняем текущий 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.gamesListLayout.update()
 | 
			
		||||
            self.gamesListWidget.updateGeometry()
 | 
			
		||||
            self.gamesListWidget.update()
 | 
			
		||||
 | 
			
		||||
        # Загружаем изображения для видимых карточек
 | 
			
		||||
        self.loadVisibleImages()
 | 
			
		||||
 | 
			
		||||
    def clearLayout(self, layout):
 | 
			
		||||
        """Удаляет все виджеты из layout."""
 | 
			
		||||
@@ -742,6 +748,7 @@ class MainWindow(QMainWindow):
 | 
			
		||||
            return
 | 
			
		||||
 | 
			
		||||
        dialog = AddGameDialog(self, self.theme)
 | 
			
		||||
        dialog.setFocus(Qt.FocusReason.OtherFocusReason)
 | 
			
		||||
        self.current_add_game_dialog = dialog  # Сохраняем ссылку на диалог
 | 
			
		||||
 | 
			
		||||
        # Предзаполняем путь к .exe при drag-and-drop
 | 
			
		||||
@@ -778,7 +785,7 @@ class MainWindow(QMainWindow):
 | 
			
		||||
                        os.path.join(os.path.expanduser("~"), ".local", "share"))
 | 
			
		||||
                    custom_folder = os.path.join(
 | 
			
		||||
                        xdg_data_home,
 | 
			
		||||
                        "PortProtonQT",
 | 
			
		||||
                        "PortProtonQt",
 | 
			
		||||
                        "custom_data",
 | 
			
		||||
                        exe_name
 | 
			
		||||
                    )
 | 
			
		||||
@@ -920,7 +927,7 @@ class MainWindow(QMainWindow):
 | 
			
		||||
 | 
			
		||||
        # 3. Games display_filter
 | 
			
		||||
        self.filter_keys = ["all", "steam", "portproton", "favorites", "epic"]
 | 
			
		||||
        self.filter_labels = [_("all"), "steam", "portproton", _("favorites"), "epic games store"]
 | 
			
		||||
        self.filter_labels = [_("all"), "steam", "portproton", _("favorites")]
 | 
			
		||||
        self.gamesDisplayCombo = QComboBox()
 | 
			
		||||
        self.gamesDisplayCombo.addItems(self.filter_labels)
 | 
			
		||||
        self.gamesDisplayCombo.setStyleSheet(self.theme.SETTINGS_COMBO_STYLE)
 | 
			
		||||
@@ -983,6 +990,7 @@ class MainWindow(QMainWindow):
 | 
			
		||||
        self.autoFullscreenGamepadCheckBox = QCheckBox(_("Auto Fullscreen on Gamepad connected"))
 | 
			
		||||
        self.autoFullscreenGamepadCheckBox.setStyleSheet(self.theme.SETTINGS_CHECKBOX_STYLE)
 | 
			
		||||
        self.autoFullscreenGamepadCheckBox.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
 | 
			
		||||
        self.autoFullscreenGamepadCheckBox.setStyleSheet(self.theme.SETTINGS_CHECKBOX_STYLE)
 | 
			
		||||
        self.autoFullscreenGamepadTitle = QLabel(_("Auto Fullscreen on Gamepad connected:"))
 | 
			
		||||
        self.autoFullscreenGamepadTitle.setStyleSheet(self.theme.PARAMS_TITLE_STYLE)
 | 
			
		||||
        self.autoFullscreenGamepadTitle.setFocusPolicy(Qt.FocusPolicy.NoFocus)
 | 
			
		||||
@@ -990,36 +998,16 @@ class MainWindow(QMainWindow):
 | 
			
		||||
        self.autoFullscreenGamepadCheckBox.setChecked(current_auto_fullscreen)
 | 
			
		||||
        formLayout.addRow(self.autoFullscreenGamepadTitle, self.autoFullscreenGamepadCheckBox)
 | 
			
		||||
 | 
			
		||||
        # 7. 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)
 | 
			
		||||
        # 7. Gamepad haptic feedback config
 | 
			
		||||
        self.gamepadRumbleCheckBox = QCheckBox(_("Gamepad haptic feedback"))
 | 
			
		||||
        self.gamepadRumbleCheckBox.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
 | 
			
		||||
        self.gamepadRumbleCheckBox.setStyleSheet(self.theme.SETTINGS_CHECKBOX_STYLE)
 | 
			
		||||
        self.gamepadRumbleTitle = QLabel(_("Gamepad haptic feedback:"))
 | 
			
		||||
        self.gamepadRumbleTitle.setStyleSheet(self.theme.PARAMS_TITLE_STYLE)
 | 
			
		||||
        self.gamepadRumbleTitle.setFocusPolicy(Qt.FocusPolicy.NoFocus)
 | 
			
		||||
        current_rumble_state = read_rumble_config()
 | 
			
		||||
        self.gamepadRumbleCheckBox.setChecked(current_rumble_state)
 | 
			
		||||
        formLayout.addRow(self.gamepadRumbleTitle, self.gamepadRumbleCheckBox)
 | 
			
		||||
 | 
			
		||||
        layout.addLayout(formLayout)
 | 
			
		||||
 | 
			
		||||
@@ -1071,37 +1059,6 @@ class MainWindow(QMainWindow):
 | 
			
		||||
            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(
 | 
			
		||||
@@ -1172,6 +1129,10 @@ class MainWindow(QMainWindow):
 | 
			
		||||
        auto_fullscreen_gamepad = self.autoFullscreenGamepadCheckBox.isChecked()
 | 
			
		||||
        save_auto_fullscreen_gamepad(auto_fullscreen_gamepad)
 | 
			
		||||
 | 
			
		||||
        # Сохранение настройки виброотдачи геймпада
 | 
			
		||||
        rumble_enabled = self.gamepadRumbleCheckBox.isChecked()
 | 
			
		||||
        save_rumble_config(rumble_enabled)
 | 
			
		||||
 | 
			
		||||
        for card in self.game_card_cache.values():
 | 
			
		||||
            card.update_badge_visibility(filter_key)
 | 
			
		||||
 | 
			
		||||
@@ -1288,11 +1249,15 @@ class MainWindow(QMainWindow):
 | 
			
		||||
                    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")
 | 
			
		||||
                    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())
 | 
			
		||||
                    try:
 | 
			
		||||
                        with open(state_file, "w", encoding="utf-8") as f:
 | 
			
		||||
                            f.write("theme_tab\n")
 | 
			
		||||
                        logger.info(f"State saved to {state_file}")
 | 
			
		||||
                        QTimer.singleShot(500, lambda: self.restart_application())
 | 
			
		||||
                    except Exception as e:
 | 
			
		||||
                        logger.error(f"Failed to save state to {state_file}: {e}")
 | 
			
		||||
                else:
 | 
			
		||||
                    self.statusBar().showMessage(_("Error applying theme '{0}'").format(selected_theme), 3000)
 | 
			
		||||
 | 
			
		||||
@@ -1310,14 +1275,28 @@ class MainWindow(QMainWindow):
 | 
			
		||||
 | 
			
		||||
    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")
 | 
			
		||||
        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")
 | 
			
		||||
        logger.info(f"Checking for state file: {state_file}")
 | 
			
		||||
        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)
 | 
			
		||||
            try:
 | 
			
		||||
                with open(state_file, encoding="utf-8") as f:
 | 
			
		||||
                    state = f.read().strip()
 | 
			
		||||
                    logger.info(f"State file contents: '{state}'")
 | 
			
		||||
                    if state == "theme_tab":
 | 
			
		||||
                        logger.info("Restoring to theme tab (index 5)")
 | 
			
		||||
                        if self.stackedWidget.count() > 5:
 | 
			
		||||
                            self.switchTab(5)
 | 
			
		||||
                        else:
 | 
			
		||||
                            logger.warning("Theme tab (index 5) not available yet")
 | 
			
		||||
                    else:
 | 
			
		||||
                        logger.warning(f"Unexpected state value: '{state}'")
 | 
			
		||||
                os.remove(state_file)
 | 
			
		||||
                logger.info(f"State file {state_file} removed")
 | 
			
		||||
            except Exception as e:
 | 
			
		||||
                logger.error(f"Failed to read or process state file {state_file}: {e}")
 | 
			
		||||
        else:
 | 
			
		||||
            logger.info(f"State file {state_file} does not exist")
 | 
			
		||||
 | 
			
		||||
    # ЛОГИКА ДЕТАЛЬНОЙ СТРАНИЦЫ ИГРЫ
 | 
			
		||||
    def getColorPalette_async(self, cover_path, num_colors=5, sample_step=10, callback=None):
 | 
			
		||||
@@ -1514,7 +1493,7 @@ class MainWindow(QMainWindow):
 | 
			
		||||
                icon_size=16,
 | 
			
		||||
                icon_space=3,
 | 
			
		||||
            )
 | 
			
		||||
            anticheatLabel.setStyleSheet(self.theme.STEAM_BADGE_STYLE)
 | 
			
		||||
            anticheatLabel.setStyleSheet(self.theme.get_anticheat_badge_style(anticheat_status))
 | 
			
		||||
            anticheatLabel.setFixedWidth(badge_width)
 | 
			
		||||
            anticheatLabel.clicked.connect(lambda: QDesktopServices.openUrl(QUrl(f"https://areweanticheatyet.com/game/{name.lower().replace(' ', '-')}")))
 | 
			
		||||
            anticheat_visible = True
 | 
			
		||||
 
 | 
			
		||||
@@ -49,7 +49,7 @@ def decode_text(text: str) -> str:
 | 
			
		||||
def get_cache_dir():
 | 
			
		||||
    """Возвращает путь к каталогу кэша, создаёт его при необходимости."""
 | 
			
		||||
    xdg_cache_home = os.getenv("XDG_CACHE_HOME", os.path.join(os.path.expanduser("~"), ".cache"))
 | 
			
		||||
    cache_dir = os.path.join(xdg_cache_home, "PortProtonQT")
 | 
			
		||||
    cache_dir = os.path.join(xdg_cache_home, "PortProtonQt")
 | 
			
		||||
    os.makedirs(cache_dir, exist_ok=True)
 | 
			
		||||
    return cache_dir
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										109
									
								
								portprotonqt/system_overlay.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										109
									
								
								portprotonqt/system_overlay.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,109 @@
 | 
			
		||||
import subprocess
 | 
			
		||||
from PySide6.QtWidgets import QDialog, QVBoxLayout, QPushButton, QMessageBox
 | 
			
		||||
from PySide6.QtWidgets import QApplication
 | 
			
		||||
from PySide6.QtCore import Qt
 | 
			
		||||
from portprotonqt.logger import get_logger
 | 
			
		||||
import os
 | 
			
		||||
from portprotonqt.localization import _
 | 
			
		||||
 | 
			
		||||
logger = get_logger(__name__)
 | 
			
		||||
 | 
			
		||||
class SystemOverlay(QDialog):
 | 
			
		||||
    """Overlay dialog for system actions like reboot, sleep, shutdown, suspend, and exit."""
 | 
			
		||||
    def __init__(self, parent, theme):
 | 
			
		||||
        super().__init__(parent)
 | 
			
		||||
        self.theme = theme
 | 
			
		||||
        self.setWindowTitle(_("System Overlay"))
 | 
			
		||||
        self.setModal(True)
 | 
			
		||||
        self.setFixedSize(400, 300)
 | 
			
		||||
 | 
			
		||||
        layout = QVBoxLayout(self)
 | 
			
		||||
        layout.setContentsMargins(20, 20, 20, 20)
 | 
			
		||||
        layout.setSpacing(10)
 | 
			
		||||
 | 
			
		||||
        # Reboot button
 | 
			
		||||
        reboot_button = QPushButton(_("Reboot"))
 | 
			
		||||
        reboot_button.setStyleSheet(self.theme.OVERLAY_BUTTON_STYLE)
 | 
			
		||||
        reboot_button.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
 | 
			
		||||
        reboot_button.clicked.connect(self.reboot)
 | 
			
		||||
        layout.addWidget(reboot_button)
 | 
			
		||||
 | 
			
		||||
        # Shutdown button
 | 
			
		||||
        shutdown_button = QPushButton(_("Shutdown"))
 | 
			
		||||
        shutdown_button.setStyleSheet(self.theme.OVERLAY_BUTTON_STYLE)
 | 
			
		||||
        shutdown_button.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
 | 
			
		||||
        shutdown_button.clicked.connect(self.shutdown)
 | 
			
		||||
        layout.addWidget(shutdown_button)
 | 
			
		||||
 | 
			
		||||
        # Suspend button
 | 
			
		||||
        suspend_button = QPushButton(_("Suspend"))
 | 
			
		||||
        suspend_button.setStyleSheet(self.theme.OVERLAY_BUTTON_STYLE)
 | 
			
		||||
        suspend_button.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
 | 
			
		||||
        suspend_button.clicked.connect(self.suspend)
 | 
			
		||||
        layout.addWidget(suspend_button)
 | 
			
		||||
 | 
			
		||||
        # Exit application button
 | 
			
		||||
        exit_button = QPushButton(_("Exit Application"))
 | 
			
		||||
        exit_button.setStyleSheet(self.theme.OVERLAY_BUTTON_STYLE)
 | 
			
		||||
        exit_button.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
 | 
			
		||||
        exit_button.clicked.connect(self.exit_application)
 | 
			
		||||
        layout.addWidget(exit_button)
 | 
			
		||||
 | 
			
		||||
        # Return to Desktop button
 | 
			
		||||
        desktop_button = QPushButton(_("Return to Desktop"))
 | 
			
		||||
        desktop_button.setStyleSheet(self.theme.OVERLAY_BUTTON_STYLE)
 | 
			
		||||
        desktop_button.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
 | 
			
		||||
        desktop_button.clicked.connect(self.return_to_desktop)
 | 
			
		||||
        script_path = "/usr/bin/portprotonqt-session-select"
 | 
			
		||||
        script_exists = os.path.isfile(script_path)
 | 
			
		||||
        desktop_button.setEnabled(script_exists)
 | 
			
		||||
        if not script_exists:
 | 
			
		||||
            desktop_button.setToolTip(_("portprotonqt-session-select file not found at /usr/bin/"))
 | 
			
		||||
        layout.addWidget(desktop_button)
 | 
			
		||||
 | 
			
		||||
        # Cancel button
 | 
			
		||||
        cancel_button = QPushButton(_("Cancel"))
 | 
			
		||||
        cancel_button.setStyleSheet(self.theme.OVERLAY_BUTTON_STYLE)
 | 
			
		||||
        cancel_button.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
 | 
			
		||||
        cancel_button.clicked.connect(self.reject)
 | 
			
		||||
        layout.addWidget(cancel_button)
 | 
			
		||||
 | 
			
		||||
        # Set focus to the first button
 | 
			
		||||
        reboot_button.setFocus()
 | 
			
		||||
 | 
			
		||||
    def reboot(self):
 | 
			
		||||
        try:
 | 
			
		||||
            subprocess.run(["systemctl", "reboot"], check=True)
 | 
			
		||||
        except subprocess.CalledProcessError as e:
 | 
			
		||||
            logger.error(f"Failed to reboot: {e}")
 | 
			
		||||
            QMessageBox.warning(self, _("Error"), _("Failed to reboot the system"))
 | 
			
		||||
        self.accept()
 | 
			
		||||
 | 
			
		||||
    def shutdown(self):
 | 
			
		||||
        try:
 | 
			
		||||
            subprocess.run(["systemctl", "poweroff"], check=True)
 | 
			
		||||
        except subprocess.CalledProcessError as e:
 | 
			
		||||
            logger.error(f"Failed to shutdown: {e}")
 | 
			
		||||
            QMessageBox.warning(self, _("Error"), _("Failed to shutdown the system"))
 | 
			
		||||
        self.accept()
 | 
			
		||||
 | 
			
		||||
    def suspend(self):
 | 
			
		||||
        try:
 | 
			
		||||
            subprocess.run(["systemctl", "suspend"], check=True)
 | 
			
		||||
        except subprocess.CalledProcessError as e:
 | 
			
		||||
            logger.error(f"Failed to suspend: {e}")
 | 
			
		||||
            QMessageBox.warning(self, _("Error"), _("Failed to suspend the system"))
 | 
			
		||||
        self.accept()
 | 
			
		||||
 | 
			
		||||
    def return_to_desktop(self):
 | 
			
		||||
        try:
 | 
			
		||||
            script_path = os.path.join(os.path.dirname(__file__), "portprotonqt-session-select")
 | 
			
		||||
            subprocess.run([script_path, "desktop"], check=True)
 | 
			
		||||
        except subprocess.CalledProcessError as e:
 | 
			
		||||
            logger.error(f"Failed to return to desktop: {e}")
 | 
			
		||||
            QMessageBox.warning(self, _("Error"), _("Failed to return to desktop"))
 | 
			
		||||
        self.accept()
 | 
			
		||||
 | 
			
		||||
    def exit_application(self):
 | 
			
		||||
        QApplication.quit()
 | 
			
		||||
        self.accept()
 | 
			
		||||
@@ -11,7 +11,7 @@ logger = get_logger(__name__)
 | 
			
		||||
# Папка, где располагаются все дополнительные темы
 | 
			
		||||
xdg_data_home = os.getenv("XDG_DATA_HOME", os.path.join(os.path.expanduser("~"), ".local", "share"))
 | 
			
		||||
THEMES_DIRS = [
 | 
			
		||||
    os.path.join(xdg_data_home, "PortProtonQT", "themes"),
 | 
			
		||||
    os.path.join(xdg_data_home, "PortProtonQt", "themes"),
 | 
			
		||||
    os.path.join(os.path.dirname(os.path.abspath(__file__)), "themes")
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,5 +1,5 @@
 | 
			
		||||
[Metainfo]
 | 
			
		||||
author = BlackSnaker
 | 
			
		||||
author_link =
 | 
			
		||||
description = Стандартная тема PortProtonQT (светлый вариант)
 | 
			
		||||
description = Стандартная тема PortProtonQt (светлый вариант)
 | 
			
		||||
name = Light
 | 
			
		||||
 
 | 
			
		||||
@@ -416,6 +416,26 @@ def get_protondb_badge_style(tier):
 | 
			
		||||
        font-weight: bold;
 | 
			
		||||
    """
 | 
			
		||||
 | 
			
		||||
def get_anticheat_badge_style(status):
 | 
			
		||||
    status = status.lower()
 | 
			
		||||
    status_colors = {
 | 
			
		||||
        "supported": {"background": "rgba(102, 168, 15, 0.7)", "color": "black"},
 | 
			
		||||
        "running": {"background": "rgba(25, 113, 194, 0.7)", "color": "black"},
 | 
			
		||||
        "planned": {"background": "rgba(156, 54, 181, 0.7)", "color": "black"},
 | 
			
		||||
        "broken": {"background": "rgba(232, 89, 12, 0.7)", "color": "black"},
 | 
			
		||||
        "denied": {"background": "rgba(224, 49, 49, 0.7)", "color": "black"}
 | 
			
		||||
    }
 | 
			
		||||
    colors = status_colors.get(status, {"background": "rgba(0, 0, 0, 0.5)", "color": "white"})
 | 
			
		||||
    return f"""
 | 
			
		||||
        qproperty-alignment: AlignCenter;
 | 
			
		||||
        background-color: {colors["background"]};
 | 
			
		||||
        color: {colors["color"]};
 | 
			
		||||
        font-size: 14px;
 | 
			
		||||
        border-radius: 5px;
 | 
			
		||||
        font-family: 'Poppins';
 | 
			
		||||
        font-weight: bold;
 | 
			
		||||
    """
 | 
			
		||||
 | 
			
		||||
# СТИЛИ БЕЙДЖА STEAM
 | 
			
		||||
STEAM_BADGE_STYLE= """
 | 
			
		||||
    qproperty-alignment: AlignCenter;
 | 
			
		||||
 
 | 
			
		||||
@@ -1,5 +1,5 @@
 | 
			
		||||
[Metainfo]
 | 
			
		||||
author = Dervart
 | 
			
		||||
author_link =
 | 
			
		||||
description = Стандартная тема PortProtonQT (тёмный вариант)
 | 
			
		||||
description = Стандартная тема PortProtonQt (тёмный вариант)
 | 
			
		||||
name = Clean Dark
 | 
			
		||||
 
 | 
			
		||||
@@ -8,6 +8,40 @@ current_theme_name = read_theme_from_config()
 | 
			
		||||
favoriteLabelSize = 48, 48
 | 
			
		||||
pixmapsScaledSize = 60, 60
 | 
			
		||||
 | 
			
		||||
CONTEXT_MENU_STYLE = """
 | 
			
		||||
    QMenu {
 | 
			
		||||
        background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
 | 
			
		||||
            stop:0 rgba(40, 40, 40, 0.95),
 | 
			
		||||
            stop:1 rgba(25, 25, 25, 0.95));
 | 
			
		||||
        border: 1px solid rgba(255, 255, 255, 0.15);
 | 
			
		||||
        border-radius: 12px;
 | 
			
		||||
        color: #ffffff;
 | 
			
		||||
        font-family: 'Play';
 | 
			
		||||
        font-size: 16px;
 | 
			
		||||
        padding: 5px;
 | 
			
		||||
    }
 | 
			
		||||
    QMenu::item {
 | 
			
		||||
        padding: 8px 20px;
 | 
			
		||||
        background: transparent;
 | 
			
		||||
        border-radius: 8px;
 | 
			
		||||
        color: #ffffff;
 | 
			
		||||
    }
 | 
			
		||||
    QMenu::item:selected {
 | 
			
		||||
        background: #282a33;
 | 
			
		||||
        color: #09bec8;
 | 
			
		||||
    }
 | 
			
		||||
    QMenu::item:hover {
 | 
			
		||||
        background: #282a33;
 | 
			
		||||
        color: #09bec8;
 | 
			
		||||
    }
 | 
			
		||||
    QMenu::item:focus {
 | 
			
		||||
        background: #409EFF;
 | 
			
		||||
        color: #ffffff;
 | 
			
		||||
        border: 1px solid rgba(255, 255, 255, 0.3);
 | 
			
		||||
        border-radius: 8px;
 | 
			
		||||
    }
 | 
			
		||||
"""
 | 
			
		||||
 | 
			
		||||
# СТИЛЬ ШАПКИ ГЛАВНОГО ОКНА
 | 
			
		||||
MAIN_WINDOW_HEADER_STYLE = """
 | 
			
		||||
    QFrame {
 | 
			
		||||
@@ -90,6 +124,13 @@ SEARCH_EDIT_STYLE = """
 | 
			
		||||
    }
 | 
			
		||||
"""
 | 
			
		||||
 | 
			
		||||
SETTINGS_CHECKBOX_STYLE = """
 | 
			
		||||
    QCheckBox:focus {
 | 
			
		||||
            border: 2px solid #409EFF;
 | 
			
		||||
            background: #404554;
 | 
			
		||||
        }
 | 
			
		||||
"""
 | 
			
		||||
 | 
			
		||||
# ОТКЛЮЧАЕМ РАМКУ У QScrollArea
 | 
			
		||||
SCROLL_AREA_STYLE = """
 | 
			
		||||
    QWidget {
 | 
			
		||||
@@ -207,6 +248,28 @@ ACTION_BUTTON_STYLE = """
 | 
			
		||||
    }
 | 
			
		||||
"""
 | 
			
		||||
 | 
			
		||||
# СТИЛЬ КНОПОК ОВЕРЛЕЯ
 | 
			
		||||
OVERLAY_BUTTON_STYLE = """
 | 
			
		||||
    QPushButton {
 | 
			
		||||
        background: #3f424d;
 | 
			
		||||
        border: 1px solid rgba(255, 255, 255, 0.20);
 | 
			
		||||
        border-radius: 10px;
 | 
			
		||||
        color: #ffffff;
 | 
			
		||||
        font-size: 16px;
 | 
			
		||||
        font-family: 'Play';
 | 
			
		||||
    }
 | 
			
		||||
    QPushButton:hover {
 | 
			
		||||
        background: #282a33;
 | 
			
		||||
    }
 | 
			
		||||
    QPushButton:pressed {
 | 
			
		||||
        background: #282a33;
 | 
			
		||||
    }
 | 
			
		||||
    QPushButton:focus {
 | 
			
		||||
        border: 2px solid #409EFF;
 | 
			
		||||
        background-color: #404554;
 | 
			
		||||
    }
 | 
			
		||||
"""
 | 
			
		||||
 | 
			
		||||
# ТЕКСТОВЫЕ СТИЛИ: ЗАГОЛОВКИ И ОСНОВНОЙ КОНТЕНТ
 | 
			
		||||
TAB_TITLE_STYLE = "font-family: 'Play'; font-size: 24px; color: #ffffff; background-color: none;"
 | 
			
		||||
CONTENT_STYLE = """
 | 
			
		||||
@@ -416,6 +479,27 @@ def get_protondb_badge_style(tier):
 | 
			
		||||
        font-weight: bold;
 | 
			
		||||
    """
 | 
			
		||||
 | 
			
		||||
# СТИЛИ БЕЙДЖА WEANTICHEATYET
 | 
			
		||||
def get_anticheat_badge_style(status):
 | 
			
		||||
    status = status.lower()
 | 
			
		||||
    status_colors = {
 | 
			
		||||
        "supported": {"background": "rgba(102, 168, 15, 0.7)", "color": "black"},
 | 
			
		||||
        "running": {"background": "rgba(25, 113, 194, 0.7)", "color": "black"},
 | 
			
		||||
        "planned": {"background": "rgba(156, 54, 181, 0.7)", "color": "black"},
 | 
			
		||||
        "broken": {"background": "rgba(232, 89, 12, 0.7)", "color": "black"},
 | 
			
		||||
        "denied": {"background": "rgba(224, 49, 49, 0.7)", "color": "black"}
 | 
			
		||||
    }
 | 
			
		||||
    colors = status_colors.get(status, {"background": "rgba(0, 0, 0, 0.5)", "color": "white"})
 | 
			
		||||
    return f"""
 | 
			
		||||
        qproperty-alignment: AlignCenter;
 | 
			
		||||
        background-color: {colors["background"]};
 | 
			
		||||
        color: {colors["color"]};
 | 
			
		||||
        font-size: 16px;
 | 
			
		||||
        border-radius: 5px;
 | 
			
		||||
        font-family: 'Play';
 | 
			
		||||
        font-weight: bold;
 | 
			
		||||
    """
 | 
			
		||||
 | 
			
		||||
# СТИЛИ БЕЙДЖА STEAM
 | 
			
		||||
STEAM_BADGE_STYLE= """
 | 
			
		||||
    qproperty-alignment: AlignCenter;
 | 
			
		||||
@@ -457,6 +541,10 @@ MESSAGE_BOX_STYLE = """
 | 
			
		||||
        background: #09bec8;
 | 
			
		||||
        border-color: rgba(255, 255, 255, 0.3);
 | 
			
		||||
    }
 | 
			
		||||
    QMessageBox QPushButton:focus {
 | 
			
		||||
        border: 2px solid #409EFF;
 | 
			
		||||
        background: #404554;
 | 
			
		||||
    }
 | 
			
		||||
"""
 | 
			
		||||
 | 
			
		||||
# СТИЛИ ДЛЯ ВКЛАДКИ НАСТРОЕК PORTPROTON
 | 
			
		||||
 
 | 
			
		||||
@@ -10,7 +10,7 @@ logger = get_logger(__name__)
 | 
			
		||||
def get_cache_file_path():
 | 
			
		||||
    """Возвращает путь к файлу кеша portproton_last_launch."""
 | 
			
		||||
    cache_home = os.getenv("XDG_CACHE_HOME", os.path.join(os.path.expanduser("~"), ".cache"))
 | 
			
		||||
    return os.path.join(cache_home, "PortProtonQT", "last_launch")
 | 
			
		||||
    return os.path.join(cache_home, "PortProtonQt", "last_launch")
 | 
			
		||||
 | 
			
		||||
def save_last_launch(exe_name, launch_time):
 | 
			
		||||
    """
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user