Compare commits
	
		
			1 Commits
		
	
	
		
			v0.1.6
			...
			77d4287f12
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 
						
						
							
						
						77d4287f12
	
				 | 
					
					
						
@@ -12,7 +12,7 @@ jobs:
 | 
			
		||||
    name: Build AppImage
 | 
			
		||||
    runs-on: ubuntu-22.04
 | 
			
		||||
    steps:
 | 
			
		||||
      - uses: https://gitea.com/actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
 | 
			
		||||
      - uses: https://gitea.com/actions/checkout@v4
 | 
			
		||||
 | 
			
		||||
      - name: Install required dependencies
 | 
			
		||||
        run: |
 | 
			
		||||
@@ -42,7 +42,7 @@ jobs:
 | 
			
		||||
 | 
			
		||||
    strategy:
 | 
			
		||||
      matrix:
 | 
			
		||||
        fedora_version: [41, 42, 43, rawhide]
 | 
			
		||||
        fedora_version: [41, 42, rawhide]
 | 
			
		||||
 | 
			
		||||
    container:
 | 
			
		||||
      image: fedora:${{ matrix.fedora_version }}
 | 
			
		||||
@@ -63,7 +63,7 @@ jobs:
 | 
			
		||||
          echo '%_topdir /home/rpmbuild' > /home/rpmbuild/.rpmmacros
 | 
			
		||||
 | 
			
		||||
      - name: Checkout repo
 | 
			
		||||
        uses: https://gitea.com/actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
 | 
			
		||||
        uses: https://gitea.com/actions/checkout@v4
 | 
			
		||||
 | 
			
		||||
      - name: Copy fedora.spec
 | 
			
		||||
        run: |
 | 
			
		||||
@@ -84,7 +84,7 @@ jobs:
 | 
			
		||||
    name: Build Arch Package
 | 
			
		||||
    runs-on: ubuntu-22.04
 | 
			
		||||
    container:
 | 
			
		||||
      image: archlinux:base-devel@sha256:8ccc930c28ab4f483ff9bc1b53957150fbe94afe48928ebb0b14a8af41c75023
 | 
			
		||||
      image: archlinux:base-devel
 | 
			
		||||
      volumes:
 | 
			
		||||
        - /usr:/usr-host
 | 
			
		||||
        - /opt:/opt-host
 | 
			
		||||
@@ -124,7 +124,7 @@ jobs:
 | 
			
		||||
          su user -c "yes '' | makepkg --noconfirm -s -p PKGBUILD-git"
 | 
			
		||||
 | 
			
		||||
      - name: Checkout
 | 
			
		||||
        uses: https://gitea.com/actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
 | 
			
		||||
        uses: https://gitea.com/actions/checkout@v4
 | 
			
		||||
 | 
			
		||||
      - name: Upload Arch package
 | 
			
		||||
        uses: https://gitea.com/actions/gitea-upload-artifact@v4
 | 
			
		||||
 
 | 
			
		||||
@@ -8,7 +8,7 @@ on:
 | 
			
		||||
 | 
			
		||||
env:
 | 
			
		||||
  # Common version, will be used for tagging the release
 | 
			
		||||
  VERSION: 0.1.6
 | 
			
		||||
  VERSION: 0.1.4
 | 
			
		||||
  PKGDEST: "/tmp/portprotonqt"
 | 
			
		||||
  PACKAGE: "portprotonqt"
 | 
			
		||||
  GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
 | 
			
		||||
@@ -99,7 +99,7 @@ jobs:
 | 
			
		||||
 | 
			
		||||
    strategy:
 | 
			
		||||
      matrix:
 | 
			
		||||
        fedora_version: [41, 42, 43, rawhide]
 | 
			
		||||
        fedora_version: [41, 42, rawhide]
 | 
			
		||||
 | 
			
		||||
    container:
 | 
			
		||||
      image: fedora:${{ matrix.fedora_version }}
 | 
			
		||||
 
 | 
			
		||||
@@ -15,10 +15,10 @@ jobs:
 | 
			
		||||
    runs-on: ubuntu-latest
 | 
			
		||||
    steps:
 | 
			
		||||
      - name: Checkout
 | 
			
		||||
        uses: https://gitea.com/actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
 | 
			
		||||
        uses: https://gitea.com/actions/checkout@v4
 | 
			
		||||
 | 
			
		||||
      - name: Set up Python
 | 
			
		||||
        uses: https://gitea.com/actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
 | 
			
		||||
        uses: https://gitea.com/actions/setup-python@v5
 | 
			
		||||
        with:
 | 
			
		||||
          python-version-file: "pyproject.toml"
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -18,7 +18,7 @@ jobs:
 | 
			
		||||
      fedora:   ${{ steps.check.outputs.fedora }}
 | 
			
		||||
      arch:     ${{ steps.check.outputs.arch }}
 | 
			
		||||
    steps:
 | 
			
		||||
      - uses: https://gitea.com/actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
 | 
			
		||||
      - uses: https://gitea.com/actions/checkout@v4
 | 
			
		||||
        with:
 | 
			
		||||
          fetch-depth: 0
 | 
			
		||||
 | 
			
		||||
@@ -63,7 +63,7 @@ jobs:
 | 
			
		||||
    needs: changes
 | 
			
		||||
    if: needs.changes.outputs.appimage == 'true' || github.event_name == 'workflow_dispatch'
 | 
			
		||||
    steps:
 | 
			
		||||
      - uses: https://gitea.com/actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
 | 
			
		||||
      - uses: https://gitea.com/actions/checkout@v4
 | 
			
		||||
 | 
			
		||||
      - name: Install required dependencies
 | 
			
		||||
        run: |
 | 
			
		||||
@@ -115,7 +115,7 @@ jobs:
 | 
			
		||||
          echo '%_topdir /home/rpmbuild' > /home/rpmbuild/.rpmmacros
 | 
			
		||||
 | 
			
		||||
      - name: Checkout repo
 | 
			
		||||
        uses: https://gitea.com/actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
 | 
			
		||||
        uses: https://gitea.com/actions/checkout@v4
 | 
			
		||||
 | 
			
		||||
      - name: Copy fedora-git.spec
 | 
			
		||||
        run: |
 | 
			
		||||
@@ -138,7 +138,7 @@ jobs:
 | 
			
		||||
    needs: changes
 | 
			
		||||
    if: needs.changes.outputs.arch == 'true' || github.event_name == 'workflow_dispatch'
 | 
			
		||||
    container:
 | 
			
		||||
      image: archlinux:base-devel@sha256:8ccc930c28ab4f483ff9bc1b53957150fbe94afe48928ebb0b14a8af41c75023
 | 
			
		||||
      image: archlinux:base-devel
 | 
			
		||||
      volumes:
 | 
			
		||||
        - /usr:/usr-host
 | 
			
		||||
        - /opt:/opt-host
 | 
			
		||||
@@ -178,7 +178,7 @@ jobs:
 | 
			
		||||
          su user -c "yes '' | makepkg --noconfirm -s -p PKGBUILD-git"
 | 
			
		||||
 | 
			
		||||
      - name: Checkout
 | 
			
		||||
        uses: https://gitea.com/actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
 | 
			
		||||
        uses: https://gitea.com/actions/checkout@v4
 | 
			
		||||
 | 
			
		||||
      - name: Upload Arch package
 | 
			
		||||
        uses: https://gitea.com/actions/gitea-upload-artifact@v4
 | 
			
		||||
 
 | 
			
		||||
@@ -20,17 +20,17 @@ jobs:
 | 
			
		||||
    name: Check code
 | 
			
		||||
    runs-on: ubuntu-latest
 | 
			
		||||
    steps:
 | 
			
		||||
      - uses: https://gitea.com/actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
 | 
			
		||||
      - uses: https://gitea.com/actions/checkout@v4
 | 
			
		||||
 | 
			
		||||
      - name: Set up Node.js
 | 
			
		||||
        uses: https://gitea.com/actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
 | 
			
		||||
        uses: https://gitea.com/actions/setup-node@v4
 | 
			
		||||
        with:
 | 
			
		||||
          node-version: 20
 | 
			
		||||
 | 
			
		||||
      - name: Install uv manually
 | 
			
		||||
        run: |
 | 
			
		||||
          curl -LsSf https://astral.sh/uv/install.sh | sh
 | 
			
		||||
          source $HOME/.local/bin/env
 | 
			
		||||
          echo "$HOME/.cargo/bin" >> $GITHUB_PATH
 | 
			
		||||
          uv --version
 | 
			
		||||
 | 
			
		||||
      - name: Sync dependencies into venv
 | 
			
		||||
 
 | 
			
		||||
@@ -11,10 +11,10 @@ jobs:
 | 
			
		||||
    runs-on: ubuntu-latest
 | 
			
		||||
    steps:
 | 
			
		||||
      - name: Checkout
 | 
			
		||||
        uses: https://gitea.com/actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
 | 
			
		||||
        uses: https://gitea.com/actions/checkout@v4
 | 
			
		||||
 | 
			
		||||
      - name: Set up Python
 | 
			
		||||
        uses: https://gitea.com/actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
 | 
			
		||||
        uses: https://gitea.com/actions/setup-python@v5
 | 
			
		||||
        with:
 | 
			
		||||
          python-version-file: "pyproject.toml"
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -8,20 +8,19 @@ on:
 | 
			
		||||
jobs:
 | 
			
		||||
  renovate:
 | 
			
		||||
    runs-on: ubuntu-latest
 | 
			
		||||
    container: ghcr.io/renovatebot/renovate:latest@sha256:46b57bb9816dec6409e7be57e0e5f7b26d214281044f5aedd3b160be178475e2
 | 
			
		||||
    container: ghcr.io/renovatebot/renovate:latest
 | 
			
		||||
    steps:
 | 
			
		||||
      - uses: https://gitea.com/actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
 | 
			
		||||
      - uses: https://gitea.com/actions/checkout@v4
 | 
			
		||||
 | 
			
		||||
      - name: Set up Node.js
 | 
			
		||||
        uses: https://gitea.com/actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
 | 
			
		||||
        uses: https://gitea.com/actions/setup-node@v4
 | 
			
		||||
        with:
 | 
			
		||||
          node-version: 20
 | 
			
		||||
 | 
			
		||||
      - name: Install uv manually
 | 
			
		||||
        run: |
 | 
			
		||||
          curl -LsSf https://astral.sh/uv/install.sh | sh
 | 
			
		||||
          . $HOME/.local/bin/env
 | 
			
		||||
          uv --version
 | 
			
		||||
      - name: Install uv
 | 
			
		||||
        uses: https://github.com/astral-sh/setup-uv@v6
 | 
			
		||||
        with:
 | 
			
		||||
          enable-cache: true
 | 
			
		||||
 | 
			
		||||
      - name: Download external renovate config
 | 
			
		||||
        run: |
 | 
			
		||||
@@ -35,4 +34,3 @@ jobs:
 | 
			
		||||
          RENOVATE_CONFIG_FILE: "/tmp/renovate-config/config.js"
 | 
			
		||||
          LOG_LEVEL: "debug"
 | 
			
		||||
          RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }}
 | 
			
		||||
          RENOVATE_GITHUB_COM_TOKEN: ${{ secrets.RENOVATE_GITHUB_COM_TOKEN }}
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										43
									
								
								CHANGELOG.md
									
									
									
									
									
								
							
							
						
						@@ -3,44 +3,12 @@
 | 
			
		||||
Все заметные изменения в этом проекте фиксируются в этом файле.
 | 
			
		||||
Формат основан на [Keep a Changelog](https://keepachangelog.com/) и придерживается принципов [Semantic Versioning](https://semver.org/).
 | 
			
		||||
 | 
			
		||||
## [0.1.6] - 2025-09-23
 | 
			
		||||
 | 
			
		||||
### Added
 | 
			
		||||
- Кэширование шрифтов в load_theme_fonts для предотвращения повторной загрузки
 | 
			
		||||
- Проверка безопасности в theme_manager.py для всех сторонних тем, с проверкой на запрещённые модули и функции (подробности см. в коде theme_manager под полями FORBIDDEN_MODULES и FORBIDDEN_FUNCTIONS)
 | 
			
		||||
- Фильтрация ASRock LED контроллера, чтобы предотвратить его обнаружение как геймпада
 | 
			
		||||
- Подсказки по управлению в интерфейсе
 | 
			
		||||
- Поддержка боковой кнопки мыши, которая теперь работает как кнопка "назад"
 | 
			
		||||
- Аргумент cli --debug-level для указания уровня дебага
 | 
			
		||||
 | 
			
		||||
### Changed
 | 
			
		||||
- Управления с геймпада теперь перехватывается только если окно в фокусе
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
### Fixed
 | 
			
		||||
- Исправлена проблема с устаревшими кэш-файлами, вызывающими несоответствия при обновлении JSON
 | 
			
		||||
- Исправлено переключение в полноэкранный режим при нажатии кнопки "Select во время запущенной игры
 | 
			
		||||
 | 
			
		||||
### Contributors
 | 
			
		||||
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
## [0.1.5] - 2025-08-31
 | 
			
		||||
## [Unreleased]
 | 
			
		||||
 | 
			
		||||
### Added
 | 
			
		||||
- Больше типов анимаций при открытии карточки игры (подробности см. в документации).
 | 
			
		||||
- Второй тип анимации при наведении и фокусе карточки (подробности см. в документации).
 | 
			
		||||
- Анимация при закрытии карточки игры (подробности см. в документации).
 | 
			
		||||
- Добавлен обработчик нажатий стрелок на клавиатуре в поле ввода (позволяет перемещаться между символами с помощью стрелок).
 | 
			
		||||
- Система быстрого доступа (избранного) в диалоге выбора файлов.
 | 
			
		||||
- Автоматическая прокрутка для панели дисков в диалоге выбора файлов.
 | 
			
		||||
- Возможность выбора папок и / или дисков в диалоге выбора файлов через клавиатуру.
 | 
			
		||||
- Переход в родительскую директорию в диалоге выбора файлов по клавише Backspace.
 | 
			
		||||
- Пункты "Избранное" и "Недавние" в трей для быстрого запуска игр.
 | 
			
		||||
- Пункт "Выход" в трей.
 | 
			
		||||
- Пункт "Темы" в трей для быстрого переключения тем.
 | 
			
		||||
- Двойной клик по иконке трея для показа/скрытия главного окна.
 | 
			
		||||
- Запуск через трей показывает модальное окно для слежки за процессом запуска
 | 
			
		||||
 | 
			
		||||
### Changed
 | 
			
		||||
- Уменьшена длительность анимации открытия карточки с 800 до 350 мс.
 | 
			
		||||
@@ -50,9 +18,7 @@
 | 
			
		||||
- Временно удалена светлая тема.
 | 
			
		||||
- Добавление и удаление игр из Steam больше не требует перезапуска клиента.
 | 
			
		||||
- Обновлены все зависимости (затрагивает только AppImage).
 | 
			
		||||
- Приложение теперь не закрывается полностью, а сворачивается в трей.
 | 
			
		||||
- Карточки теперь все находятся друг под другом, а не в разнабой
 | 
			
		||||
- Изменено соотношение сторон карточек
 | 
			
		||||
- Удалён отдельный трей, так как у PortProton есть собственный.
 | 
			
		||||
 | 
			
		||||
### Fixed
 | 
			
		||||
- `legendary list` теперь не вызывается, если вход в EGS не был выполнен.
 | 
			
		||||
@@ -61,10 +27,7 @@
 | 
			
		||||
- Диалог добавления игры больше не добавляет игру, если `exe` не существует.
 | 
			
		||||
- Вкладки больше не переключаются стрелками, если фокус в поле ввода.
 | 
			
		||||
- Исправлено переключение слайдера: RT (Xbox) / R2 (PS), LT (Xbox) / L2 (PS).
 | 
			
		||||
- Заголовок окна диалога выбора файлов теперь можно перевести.
 | 
			
		||||
- Трей теперь можно перевести.
 | 
			
		||||
- Отображение устройств смонтированных в /run/media в диалоге выбора файлов.
 | 
			
		||||
- Закрытие диалогов добавления / редактирования игры и выбора файлов по клавише Escape.
 | 
			
		||||
- Переведен заголовок окна диалога выбора файлов.
 | 
			
		||||
 | 
			
		||||
### Contributors
 | 
			
		||||
- @Alex Smith
 | 
			
		||||
 
 | 
			
		||||
@@ -1,5 +1,5 @@
 | 
			
		||||
<div align="center">
 | 
			
		||||
  <img src="build-aux/share/icons/hicolor/scalable/apps/ru.linux_gaming.PortProtonQt.svg" width="64">
 | 
			
		||||
  <img src="https://git.linux-gaming.ru/Boria138/PortProtonQt/raw/branch/main/portprotonqt/themes/standart/images/theme_logo.svg" width="64">
 | 
			
		||||
  <h1 align="center">PortProtonQt</h1>
 | 
			
		||||
  <p align="center">Удобный графический интерфейс для управления и запуска игр из PortProton, Steam и Epic Games Store. Оно объединяет библиотеки игр в единый центр для лёгкой навигации и организации. Лёгкая структура и кроссплатформенная поддержка обеспечивают цельный игровой опыт без необходимости использования нескольких лаунчеров. Интеграция с PortProton упрощает запуск Windows-игр на Linux с минимальной настройкой.</p>
 | 
			
		||||
</div>
 | 
			
		||||
@@ -54,7 +54,6 @@ PortProtonQt использует код и зависимости от след
 | 
			
		||||
- [Legendary](https://github.com/derrod/legendary) — инструмент для работы с Epic Games Store, лицензия [GPL-3.0](https://github.com/derrod/legendary/blob/master/LICENSE).
 | 
			
		||||
- [Icoextract](https://github.com/jlu5/icoextract) — библиотека для извлечения иконок, лицензия [MIT](https://github.com/jlu5/icoextract/blob/master/LICENSE).
 | 
			
		||||
- [HowLongToBeat Python API](https://github.com/ScrappyCocco/HowLongToBeat-PythonAPI) — библиотека для взаимодействия с HowLongToBeat, лицензия [MIT](https://github.com/ScrappyCocco/HowLongToBeat-PythonAPI/blob/master/LICENSE.md).
 | 
			
		||||
- [Those Awesome Guys: Gamepad prompts images](https://thoseawesomeguys.com/prompts/) - Набор подсказок для геймпада и клавиатур, лицензия [CC0](https://creativecommons.org/public-domain/cc0/)
 | 
			
		||||
 | 
			
		||||
Полный текст лицензий см. в файле [LICENSE](LICENSE).
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -45,7 +45,7 @@ AppDir:
 | 
			
		||||
    id: ru.linux_gaming.PortProtonQt
 | 
			
		||||
    name: PortProtonQt
 | 
			
		||||
    icon: ru.linux_gaming.PortProtonQt
 | 
			
		||||
    version: 0.1.6
 | 
			
		||||
    version: 0.1.4
 | 
			
		||||
    exec: usr/bin/python3
 | 
			
		||||
    exec_args: "-m portprotonqt.app $@"
 | 
			
		||||
  apt:
 | 
			
		||||
 
 | 
			
		||||
@@ -1,5 +1,5 @@
 | 
			
		||||
pkgname=portprotonqt
 | 
			
		||||
pkgver=0.1.6
 | 
			
		||||
pkgver=0.1.4
 | 
			
		||||
pkgrel=1
 | 
			
		||||
pkgdesc="Modern GUI for managing and launching games from PortProton, Steam, and Epic Games Store"
 | 
			
		||||
arch=('any')
 | 
			
		||||
 
 | 
			
		||||
@@ -1,5 +1,5 @@
 | 
			
		||||
%global pypi_name portprotonqt
 | 
			
		||||
%global pypi_version 0.1.6
 | 
			
		||||
%global pypi_version 0.1.4
 | 
			
		||||
%global oname PortProtonQt
 | 
			
		||||
%global _python_no_extras_requires 1
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,30 +1,19 @@
 | 
			
		||||
_portprotonqt_completions() {
 | 
			
		||||
    local cur prev opts
 | 
			
		||||
    COMPREPLY=()
 | 
			
		||||
    cur="${COMP_WORDS[COMP_CWORD]}"
 | 
			
		||||
    prev="${COMP_WORDS[COMP_CWORD-1]}"
 | 
			
		||||
_portprotonqt() {
 | 
			
		||||
    local cur prev
 | 
			
		||||
    _init_completion || return
 | 
			
		||||
 | 
			
		||||
    # Available options
 | 
			
		||||
    opts="--fullscreen --debug-level --help -h"
 | 
			
		||||
 | 
			
		||||
    # Debug level choices
 | 
			
		||||
    debug_levels="ALL DEBUG INFO WARNING ERROR CRITICAL"
 | 
			
		||||
 | 
			
		||||
    case "${prev}" in
 | 
			
		||||
        --debug-level)
 | 
			
		||||
            # Complete debug levels
 | 
			
		||||
            COMPREPLY=( $(compgen -W "${debug_levels}" -- ${cur}) )
 | 
			
		||||
            return 0
 | 
			
		||||
            ;;
 | 
			
		||||
        *)
 | 
			
		||||
    case $prev in
 | 
			
		||||
        --help|-h)
 | 
			
		||||
            return
 | 
			
		||||
            ;;
 | 
			
		||||
    esac
 | 
			
		||||
 | 
			
		||||
    # Complete options
 | 
			
		||||
    if [[ ${cur} == -* ]]; then
 | 
			
		||||
        COMPREPLY=( $(compgen -W "${opts}" -- ${cur}) )
 | 
			
		||||
    if [[ "$cur" == -* ]]; then
 | 
			
		||||
        COMPREPLY=( $( compgen -W '--fullscreen' -- "$cur" ) )
 | 
			
		||||
        return 0
 | 
			
		||||
    fi
 | 
			
		||||
 | 
			
		||||
    return 0
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
complete -F _portprotonqt_completions portprotonqt
 | 
			
		||||
complete -F _portprotonqt portprotonqt
 | 
			
		||||
 
 | 
			
		||||
@@ -1777,7 +1777,7 @@
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_name": "supervive",
 | 
			
		||||
    "status": "Running"
 | 
			
		||||
    "status": "Denied"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_name": "splitgate 2",
 | 
			
		||||
@@ -4472,7 +4472,7 @@
 | 
			
		||||
    "status": "Running"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_name": "battlefield 6",
 | 
			
		||||
    "normalized_name": "f1 25",
 | 
			
		||||
    "status": "Denied"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
@@ -4482,65 +4482,5 @@
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_name": "sword of justice",
 | 
			
		||||
    "status": "Broken"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_name": "blade & soul neo",
 | 
			
		||||
    "status": "Broken"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_name": "the finals (cn)",
 | 
			
		||||
    "status": "Broken"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_name": "tom clancy's rainbow six siege x",
 | 
			
		||||
    "status": "Denied"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_name": "dragonheir silent gods",
 | 
			
		||||
    "status": "Broken"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_name": "the quinfall",
 | 
			
		||||
    "status": "Running"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_name": "redmatch 2",
 | 
			
		||||
    "status": "Broken"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_name": "blade & soul heroes",
 | 
			
		||||
    "status": "Broken"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_name": "blue archive",
 | 
			
		||||
    "status": "Running"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_name": "midnight murder club",
 | 
			
		||||
    "status": "Broken"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_name": "dungeon done",
 | 
			
		||||
    "status": "Broken"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_name": "project wraith",
 | 
			
		||||
    "status": "Broken"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_name": "solo leveling arise",
 | 
			
		||||
    "status": "Broken"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_name": "freedom wars",
 | 
			
		||||
    "status": "Running"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_name": "open fortress",
 | 
			
		||||
    "status": "Running"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_name": "no more room in hell 2",
 | 
			
		||||
    "status": "Running"
 | 
			
		||||
  }
 | 
			
		||||
]
 | 
			
		||||
							
								
								
									
										13412
									
								
								data/games_appid.json
									
									
									
									
									
								
							
							
						
						@@ -1,48 +1,4 @@
 | 
			
		||||
[
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "astroneer",
 | 
			
		||||
    "slug": "astroneer"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "anno 2205",
 | 
			
		||||
    "slug": "anno-2205"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "anno 2070",
 | 
			
		||||
    "slug": "anno-2070"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "kompas 3d v23 / компас 3d v23",
 | 
			
		||||
    "slug": "kompas-3d-v23-kompas-3d-v23"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "ultrakill (early access)",
 | 
			
		||||
    "slug": "ultrakill-early-access"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "vintage story",
 | 
			
		||||
    "slug": "vintage-story"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "disco elysium the finul cut",
 | 
			
		||||
    "slug": "disco-elysium-the-finul-cut"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "warcraft iii reign of chaos",
 | 
			
		||||
    "slug": "warcraft-iii-reign-of-chaos"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "dying light",
 | 
			
		||||
    "slug": "dying-light"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "лихо одноглазое",
 | 
			
		||||
    "slug": "liho-odnoglazoe"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "indika",
 | 
			
		||||
    "slug": "indika"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "no sleep for kaname date from ai the somnium files",
 | 
			
		||||
    "slug": "no-sleep-for-kaname-date-from-ai-the-somnium-files"
 | 
			
		||||
@@ -279,6 +235,10 @@
 | 
			
		||||
    "normalized_title": "cardlife creative survival",
 | 
			
		||||
    "slug": "cardlife-creative-survival"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "kompas 3d v23 / компас 3d v23",
 | 
			
		||||
    "slug": "kompas-3d-v23-kompas-3d-v23"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "kompas 3d v24 / компас 3d v24 beta",
 | 
			
		||||
    "slug": "kompas-3d-v24-kompas-3d-v24-beta"
 | 
			
		||||
 
 | 
			
		||||
@@ -17,6 +17,4 @@ Generated-By:
 | 
			
		||||
start.sh
 | 
			
		||||
EGS
 | 
			
		||||
Stop Game
 | 
			
		||||
Fullscreen
 | 
			
		||||
Fulscreen
 | 
			
		||||
\t
 | 
			
		||||
 
 | 
			
		||||
@@ -3,9 +3,8 @@
 | 
			
		||||
import sys
 | 
			
		||||
from pathlib import Path
 | 
			
		||||
import re
 | 
			
		||||
import ast
 | 
			
		||||
 | 
			
		||||
# Запрещенные QSS-свойства
 | 
			
		||||
# Запрещенные свойства
 | 
			
		||||
FORBIDDEN_PROPERTIES = {
 | 
			
		||||
    "box-shadow",
 | 
			
		||||
    "backdrop-filter",
 | 
			
		||||
@@ -13,55 +12,15 @@ FORBIDDEN_PROPERTIES = {
 | 
			
		||||
    "text-shadow",
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
# Запрещенные модули и функции
 | 
			
		||||
FORBIDDEN_MODULES = {
 | 
			
		||||
    "os",
 | 
			
		||||
    "subprocess",
 | 
			
		||||
    "shutil",
 | 
			
		||||
    "sys",
 | 
			
		||||
    "socket",
 | 
			
		||||
    "ctypes",
 | 
			
		||||
    "pathlib",
 | 
			
		||||
    "glob",
 | 
			
		||||
}
 | 
			
		||||
FORBIDDEN_FUNCTIONS = {
 | 
			
		||||
    "exec",
 | 
			
		||||
    "eval",
 | 
			
		||||
    "open",
 | 
			
		||||
    "__import__",
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
def check_qss_files():
 | 
			
		||||
    has_errors = False
 | 
			
		||||
    for qss_file in Path("portprotonqt/themes").glob("**/*.py"):
 | 
			
		||||
        with open(qss_file, "r") as f:
 | 
			
		||||
            content = f.read()
 | 
			
		||||
 | 
			
		||||
            # Проверка на запрещённые QSS-свойства
 | 
			
		||||
            for prop in FORBIDDEN_PROPERTIES:
 | 
			
		||||
                if re.search(rf"{prop}\s*:", content, re.IGNORECASE):
 | 
			
		||||
                    print(f"ERROR: Unknown QSS property found '{prop}' in file {qss_file}")
 | 
			
		||||
                    print(f"ERROR: Unknown qss property found '{prop}' on file {qss_file}")
 | 
			
		||||
                    has_errors = True
 | 
			
		||||
 | 
			
		||||
            # Проверка на опасные импорты и функции
 | 
			
		||||
            try:
 | 
			
		||||
                tree = ast.parse(content)
 | 
			
		||||
                for node in ast.walk(tree):
 | 
			
		||||
                    # Проверка импортов
 | 
			
		||||
                    if isinstance(node, (ast.Import, ast.ImportFrom)):
 | 
			
		||||
                        for name in node.names:
 | 
			
		||||
                            if name.name in FORBIDDEN_MODULES:
 | 
			
		||||
                                print(f"ERROR: Forbidden module '{name.name}' found in file {qss_file}")
 | 
			
		||||
                                has_errors = True
 | 
			
		||||
                    # Проверка вызовов функций
 | 
			
		||||
                    if isinstance(node, ast.Call):
 | 
			
		||||
                        if isinstance(node.func, ast.Name) and node.func.id in FORBIDDEN_FUNCTIONS:
 | 
			
		||||
                            print(f"ERROR: Forbidden function '{node.func.id}' found in file {qss_file}")
 | 
			
		||||
                            has_errors = True
 | 
			
		||||
            except SyntaxError as e:
 | 
			
		||||
                print(f"ERROR: Syntax error in file {qss_file}: {e}")
 | 
			
		||||
                has_errors = True
 | 
			
		||||
 | 
			
		||||
    return has_errors
 | 
			
		||||
 | 
			
		||||
if __name__ == "__main__":
 | 
			
		||||
 
 | 
			
		||||
@@ -21,9 +21,9 @@ Current translation status:
 | 
			
		||||
 | 
			
		||||
| Locale | Progress | Translated |
 | 
			
		||||
| :----- | -------: | ---------: |
 | 
			
		||||
| [de_DE](./de_DE/LC_MESSAGES/messages.po) | 0% | 0 of 204 |
 | 
			
		||||
| [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 of 204 |
 | 
			
		||||
| [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 204 of 204 |
 | 
			
		||||
| [de_DE](./de_DE/LC_MESSAGES/messages.po) | 0% | 0 of 195 |
 | 
			
		||||
| [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 of 195 |
 | 
			
		||||
| [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 195 of 195 |
 | 
			
		||||
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -21,9 +21,9 @@
 | 
			
		||||
 | 
			
		||||
| Локаль | Прогресс | Переведено |
 | 
			
		||||
| :----- | -------: | ---------: |
 | 
			
		||||
| [de_DE](./de_DE/LC_MESSAGES/messages.po) | 0% | 0 из 204 |
 | 
			
		||||
| [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 из 204 |
 | 
			
		||||
| [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 204 из 204 |
 | 
			
		||||
| [de_DE](./de_DE/LC_MESSAGES/messages.po) | 0% | 0 из 195 |
 | 
			
		||||
| [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 из 195 |
 | 
			
		||||
| [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 195 из 195 |
 | 
			
		||||
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -52,151 +52,102 @@ The `GAME_CARD_ANIMATION` dictionary controls all animation parameters for game
 | 
			
		||||
 | 
			
		||||
```python
 | 
			
		||||
GAME_CARD_ANIMATION = {
 | 
			
		||||
    # Type of animation when entering or exiting the detail page
 | 
			
		||||
    # Type of animation when entering and exiting the detail page
 | 
			
		||||
    # Possible values: "fade", "slide_left", "slide_right", "slide_up", "slide_down", "bounce"
 | 
			
		||||
    # Determines how the detail page appears and disappears
 | 
			
		||||
    "detail_page_animation_type": "fade",
 | 
			
		||||
 | 
			
		||||
    # Border width of the card in idle state (no hover or focus)
 | 
			
		||||
    # Affects the thickness of the border around the card when it's not selected
 | 
			
		||||
    # Value in pixels
 | 
			
		||||
    # Border width of the card in idle state (no hover or focus).
 | 
			
		||||
    # Affects the thickness of the border when the card is not highlighted.
 | 
			
		||||
    # Value in pixels.
 | 
			
		||||
    "default_border_width": 2,
 | 
			
		||||
 | 
			
		||||
    # Border width on hover
 | 
			
		||||
    # Increases the border thickness when the cursor is over the card
 | 
			
		||||
    # Value in pixels
 | 
			
		||||
    # Border width on hover.
 | 
			
		||||
    # Increases the border thickness when the cursor is over the card.
 | 
			
		||||
    # Value in pixels.
 | 
			
		||||
    "hover_border_width": 8,
 | 
			
		||||
 | 
			
		||||
    # Border width on focus (e.g., when selected via keyboard)
 | 
			
		||||
    # Increases the border thickness when the card is focused
 | 
			
		||||
    # Value in pixels
 | 
			
		||||
    # Border width on focus (e.g., selected via keyboard).
 | 
			
		||||
    # Increases the border thickness when the card is focused.
 | 
			
		||||
    # Value in pixels.
 | 
			
		||||
    "focus_border_width": 12,
 | 
			
		||||
 | 
			
		||||
    # Minimum border width during pulsing animation
 | 
			
		||||
    # Determines the minimum border thickness during the "breathing" animation
 | 
			
		||||
    # Value in pixels
 | 
			
		||||
    # Minimum border width during pulsing animation.
 | 
			
		||||
    # Sets the minimum border thickness during the "breathing" animation.
 | 
			
		||||
    # Value in pixels.
 | 
			
		||||
    "pulse_min_border_width": 8,
 | 
			
		||||
 | 
			
		||||
    # Maximum border width during pulsing animation
 | 
			
		||||
    # Determines the maximum border thickness during pulsing
 | 
			
		||||
    # Value in pixels
 | 
			
		||||
    # Maximum border width during pulsing animation.
 | 
			
		||||
    # Sets the maximum border thickness during pulsing.
 | 
			
		||||
    # Value in pixels.
 | 
			
		||||
    "pulse_max_border_width": 10,
 | 
			
		||||
 | 
			
		||||
    # Duration of the border thickness animation (e.g., on hover or focus)
 | 
			
		||||
    # Affects the speed of transition from one border width to another
 | 
			
		||||
    # Value in milliseconds
 | 
			
		||||
    # Duration of the border thickness animation (e.g., on hover or focus).
 | 
			
		||||
    # Affects the speed of transition between different border widths.
 | 
			
		||||
    # Value in milliseconds.
 | 
			
		||||
    "thickness_anim_duration": 300,
 | 
			
		||||
 | 
			
		||||
    # Duration of one pulsing animation cycle
 | 
			
		||||
    # Determines how fast the border "pulses" between min and max values
 | 
			
		||||
    # Value in milliseconds
 | 
			
		||||
    # Duration of one pulsing animation cycle.
 | 
			
		||||
    # Defines how fast the border "pulses" between min and max values.
 | 
			
		||||
    # Value in milliseconds.
 | 
			
		||||
    "pulse_anim_duration": 800,
 | 
			
		||||
 | 
			
		||||
    # Duration of the gradient rotation animation
 | 
			
		||||
    # Affects how fast the gradient border rotates around the card
 | 
			
		||||
    # Value in milliseconds
 | 
			
		||||
    # Duration of the gradient rotation animation.
 | 
			
		||||
    # Affects how fast the gradient border rotates around the card.
 | 
			
		||||
    # Value in milliseconds.
 | 
			
		||||
    "gradient_anim_duration": 3000,
 | 
			
		||||
 | 
			
		||||
    # Starting angle of the gradient (in degrees)
 | 
			
		||||
    # Determines the initial rotation point of the gradient at animation start
 | 
			
		||||
    # Starting angle of the gradient (in degrees).
 | 
			
		||||
    # Defines the initial rotation point of the gradient when the animation starts.
 | 
			
		||||
    "gradient_start_angle": 360,
 | 
			
		||||
 | 
			
		||||
    # Ending angle of the gradient (in degrees)
 | 
			
		||||
    # Determines the final rotation point of the gradient
 | 
			
		||||
    # Value 0 means a full 360° rotation
 | 
			
		||||
    # Ending angle of the gradient (in degrees).
 | 
			
		||||
    # Defines the end rotation point of the gradient.
 | 
			
		||||
    # A value of 0 means a full 360-degree rotation.
 | 
			
		||||
    "gradient_end_angle": 0,
 | 
			
		||||
 | 
			
		||||
    # Type of card animation on hover or focus
 | 
			
		||||
    # Possible values: "gradient", "scale"
 | 
			
		||||
    # "gradient" enables a rotating gradient for the border, "scale" enlarges the card
 | 
			
		||||
    "card_animation_type": "gradient",
 | 
			
		||||
 | 
			
		||||
    # Card scale in idle state
 | 
			
		||||
    # Determines the base size of the card (1.0 = 100% of original size)
 | 
			
		||||
    # Value as a fraction (e.g., 1.0 for normal size)
 | 
			
		||||
    "default_scale": 1.0,
 | 
			
		||||
 | 
			
		||||
    # Card scale on hover
 | 
			
		||||
    # Increases the card size on hover
 | 
			
		||||
    # Value as a fraction (e.g., 1.1 = 110% of original size)
 | 
			
		||||
    "hover_scale": 1.1,
 | 
			
		||||
 | 
			
		||||
    # Card scale on focus (e.g., when selected via keyboard)
 | 
			
		||||
    # Increases the card size on focus
 | 
			
		||||
    # Value as a fraction (e.g., 1.05 = 105% of original size)
 | 
			
		||||
    "focus_scale": 1.05,
 | 
			
		||||
 | 
			
		||||
    # Duration of scale animation
 | 
			
		||||
    # Affects how fast the card changes size on hover or focus
 | 
			
		||||
    # Value in milliseconds
 | 
			
		||||
    "scale_anim_duration": 200,
 | 
			
		||||
 | 
			
		||||
    # Easing curve type for border thickness increase animation (on hover/focus)
 | 
			
		||||
    # Affects the "feel" of the animation (e.g., smooth acceleration or deceleration)
 | 
			
		||||
    # Possible values: strings corresponding to QEasingCurve.Type (e.g., "OutBack", "InOutQuad")
 | 
			
		||||
    # Easing curve type for border expansion animation (on hover/focus).
 | 
			
		||||
    # Affects the "feel" of the animation (e.g., smooth acceleration or deceleration).
 | 
			
		||||
    # Possible values: strings corresponding to QEasingCurve.Type (e.g., "OutBack", "InOutQuad").
 | 
			
		||||
    "thickness_easing_curve": "OutBack",
 | 
			
		||||
 | 
			
		||||
    # Easing curve type for border thickness decrease animation (on hover/focus exit)
 | 
			
		||||
    # Affects the "feel" of returning to the default border width
 | 
			
		||||
    # Easing curve type for border contraction animation (on mouse leave/focus loss).
 | 
			
		||||
    # Affects the "feel" of returning to the original border width.
 | 
			
		||||
    "thickness_easing_curve_out": "InBack",
 | 
			
		||||
 | 
			
		||||
    # Easing curve type for scale increase animation (on hover/focus)
 | 
			
		||||
    # Affects the "feel" of the scaling animation (e.g., with a "bounce" effect)
 | 
			
		||||
    # Possible values: strings corresponding to QEasingCurve.Type
 | 
			
		||||
    "scale_easing_curve": "OutBack",
 | 
			
		||||
 | 
			
		||||
    # Easing curve type for scale decrease animation (on hover/focus exit)
 | 
			
		||||
    # Affects the "feel" of returning to the original scale
 | 
			
		||||
    "scale_easing_curve_out": "InBack",
 | 
			
		||||
 | 
			
		||||
    # Gradient colors for animated border
 | 
			
		||||
    # List of dictionaries, each specifying position (0.0–1.0) and color in hex format
 | 
			
		||||
    # Affects the appearance of the border on hover or focus if card_animation_type="gradient"
 | 
			
		||||
    # Gradient colors for the animated border.
 | 
			
		||||
    # A list of dictionaries where each defines a position (0.0–1.0) and color in hex format.
 | 
			
		||||
    # Affects the appearance of the border on hover or focus.
 | 
			
		||||
    "gradient_colors": [
 | 
			
		||||
        {"position": 0, "color": "#00fff5"},    # Starting color (cyan)
 | 
			
		||||
        {"position": 0.33, "color": "#FF5733"}, # Color at 33% (orange)
 | 
			
		||||
        {"position": 0.66, "color": "#9B59B6"}, # Color at 66% (purple)
 | 
			
		||||
        {"position": 1, "color": "#00fff5"}     # Ending color (back to cyan)
 | 
			
		||||
        {"position": 0, "color": "#00fff5"},    # Start color (cyan)
 | 
			
		||||
        {"position": 0.33, "color": "#FF5733"}, # 33% color (orange)
 | 
			
		||||
        {"position": 0.66, "color": "#9B59B6"}, # 66% color (purple)
 | 
			
		||||
        {"position": 1, "color": "#00fff5"}     # End color (back to cyan)
 | 
			
		||||
    ],
 | 
			
		||||
 | 
			
		||||
    # Duration of fade animation when entering the detail page
 | 
			
		||||
    # Affects the speed of page appearance with fade animation
 | 
			
		||||
    # Value in milliseconds
 | 
			
		||||
    # Duration of the fade animation when entering the detail page
 | 
			
		||||
    "detail_page_fade_duration": 350,
 | 
			
		||||
 | 
			
		||||
    # Duration of slide animation when entering the detail page
 | 
			
		||||
    # Affects the speed of page sliding animation
 | 
			
		||||
    # Value in milliseconds
 | 
			
		||||
    # Duration of the slide animation when entering the detail page
 | 
			
		||||
    "detail_page_slide_duration": 500,
 | 
			
		||||
 | 
			
		||||
    # Duration of bounce animation when entering the detail page
 | 
			
		||||
    # Affects the speed of page "bounce" animation
 | 
			
		||||
    # Value in milliseconds
 | 
			
		||||
    # Duration of the bounce animation when entering the detail page
 | 
			
		||||
    "detail_page_bounce_duration": 400,
 | 
			
		||||
 | 
			
		||||
    # Duration of fade animation when exiting the detail page
 | 
			
		||||
    # Affects the speed of page disappearance with fade animation
 | 
			
		||||
    # Value in milliseconds
 | 
			
		||||
    # Duration of the fade animation when exiting the detail page
 | 
			
		||||
    "detail_page_fade_duration_exit": 350,
 | 
			
		||||
 | 
			
		||||
    # Duration of slide animation when exiting the detail page
 | 
			
		||||
    # Affects the speed of page sliding animation
 | 
			
		||||
    # Value in milliseconds
 | 
			
		||||
    # Duration of the slide animation when exiting the detail page
 | 
			
		||||
    "detail_page_slide_duration_exit": 500,
 | 
			
		||||
 | 
			
		||||
    # Duration of bounce animation when exiting the detail page
 | 
			
		||||
    # Affects the speed of page "compression" animation
 | 
			
		||||
    # Value in milliseconds
 | 
			
		||||
    # Duration of the bounce animation when exiting the detail page
 | 
			
		||||
    "detail_page_bounce_duration_exit": 400,
 | 
			
		||||
 | 
			
		||||
    # Easing curve type for animations when entering the detail page
 | 
			
		||||
    # Applied to slide and bounce animations; affects the "feel" of movement
 | 
			
		||||
    # Possible values: strings corresponding to QEasingCurve.Type
 | 
			
		||||
    # Easing curve type for animation when entering the detail page
 | 
			
		||||
    # Applies to slide and bounce animations
 | 
			
		||||
    "detail_page_easing_curve": "OutCubic",
 | 
			
		||||
 | 
			
		||||
    # Easing curve type for animations when exiting the detail page
 | 
			
		||||
    # Applied to slide and bounce animations; affects the "feel" of movement
 | 
			
		||||
    # Possible values: strings corresponding to QEasingCurve.Type
 | 
			
		||||
    # Easing curve type for animation when exiting the detail page
 | 
			
		||||
    # Applies to slide and bounce animations
 | 
			
		||||
    "detail_page_easing_curve_exit": "InCubic"
 | 
			
		||||
}
 | 
			
		||||
```
 | 
			
		||||
 
 | 
			
		||||
@@ -54,104 +54,69 @@ def custom_button_style(color1, color2):
 | 
			
		||||
GAME_CARD_ANIMATION = {
 | 
			
		||||
    # Тип анимации при входе и выходе на детальную страницу
 | 
			
		||||
    # Возможные значения: "fade", "slide_left", "slide_right", "slide_up", "slide_down", "bounce"
 | 
			
		||||
    # Определяет, как детальная страница появляется и исчезает
 | 
			
		||||
    "detail_page_animation_type": "fade",
 | 
			
		||||
 | 
			
		||||
    # Ширина обводки карточки в состоянии покоя (без наведения или фокуса)
 | 
			
		||||
    # Влияет на толщину рамки вокруг карточки, когда она не выделена
 | 
			
		||||
    # Значение в пикселях
 | 
			
		||||
    # Ширина обводки карточки в состоянии покоя (без наведения или фокуса).
 | 
			
		||||
    # Влияет на толщину рамки вокруг карточки, когда она не выделена.
 | 
			
		||||
    # Значение в пикселях.
 | 
			
		||||
    "default_border_width": 2,
 | 
			
		||||
 | 
			
		||||
    # Ширина обводки при наведении курсора
 | 
			
		||||
    # Увеличивает толщину рамки, когда курсор находится над карточкой
 | 
			
		||||
    # Значение в пикселях
 | 
			
		||||
    # Ширина обводки при наведении курсора.
 | 
			
		||||
    # Увеличивает толщину рамки, когда курсор находится над карточкой.
 | 
			
		||||
    # Значение в пикселях.
 | 
			
		||||
    "hover_border_width": 8,
 | 
			
		||||
 | 
			
		||||
    # Ширина обводки при фокусе (например, при выборе с клавиатуры)
 | 
			
		||||
    # Увеличивает толщину рамки, когда карточка в фокусе
 | 
			
		||||
    # Значение в пикселях
 | 
			
		||||
    # Ширина обводки при фокусе (например, при выборе с клавиатуры).
 | 
			
		||||
    # Увеличивает толщину рамки, когда карточка в фокусе.
 | 
			
		||||
    # Значение в пикселях.
 | 
			
		||||
    "focus_border_width": 12,
 | 
			
		||||
 | 
			
		||||
    # Минимальная ширина обводки во время пульсирующей анимации
 | 
			
		||||
    # Определяет минимальную толщину рамки при пульсации (анимация "дыхания")
 | 
			
		||||
    # Значение в пикселях
 | 
			
		||||
    # Минимальная ширина обводки во время пульсирующей анимации.
 | 
			
		||||
    # Определяет минимальную толщину рамки при пульсации (анимация "дыхания").
 | 
			
		||||
    # Значение в пикселях.
 | 
			
		||||
    "pulse_min_border_width": 8,
 | 
			
		||||
 | 
			
		||||
    # Максимальная ширина обводки во время пульсирующей анимации
 | 
			
		||||
    # Определяет максимальную толщину рамки при пульсации
 | 
			
		||||
    # Значение в пикселях
 | 
			
		||||
    # Максимальная ширина обводки во время пульсирующей анимации.
 | 
			
		||||
    # Определяет максимальную толщину рамки при пульсации.
 | 
			
		||||
    # Значение в пикселях.
 | 
			
		||||
    "pulse_max_border_width": 10,
 | 
			
		||||
 | 
			
		||||
    # Длительность анимации изменения толщины обводки (например, при наведении или фокусе)
 | 
			
		||||
    # Влияет на скорость перехода от одной ширины обводки к другой
 | 
			
		||||
    # Значение в миллисекундах
 | 
			
		||||
    # Длительность анимации изменения толщины обводки (например, при наведении или фокусе).
 | 
			
		||||
    # Влияет на скорость перехода от одной ширины обводки к другой.
 | 
			
		||||
    # Значение в миллисекундах.
 | 
			
		||||
    "thickness_anim_duration": 300,
 | 
			
		||||
 | 
			
		||||
    # Длительность одного цикла пульсирующей анимации
 | 
			
		||||
    # Определяет, как быстро рамка "пульсирует" между min и max значениями
 | 
			
		||||
    # Значение в миллисекундах
 | 
			
		||||
    # Длительность одного цикла пульсирующей анимации.
 | 
			
		||||
    # Определяет, как быстро рамка "пульсирует" между min и max значениями.
 | 
			
		||||
    # Значение в миллисекундах.
 | 
			
		||||
    "pulse_anim_duration": 800,
 | 
			
		||||
 | 
			
		||||
    # Длительность анимации вращения градиента
 | 
			
		||||
    # Влияет на скорость, с которой градиентная обводка вращается вокруг карточки
 | 
			
		||||
    # Значение в миллисекундах
 | 
			
		||||
    # Длительность анимации вращения градиента.
 | 
			
		||||
    # Влияет на скорость, с которой градиентная обводка вращается вокруг карточки.
 | 
			
		||||
    # Значение в миллисекундах.
 | 
			
		||||
    "gradient_anim_duration": 3000,
 | 
			
		||||
 | 
			
		||||
    # Начальный угол градиента (в градусах)
 | 
			
		||||
    # Определяет начальную точку вращения градиента при старте анимации
 | 
			
		||||
    # Начальный угол градиента (в градусах).
 | 
			
		||||
    # Определяет начальную точку вращения градиента при старте анимации.
 | 
			
		||||
    "gradient_start_angle": 360,
 | 
			
		||||
 | 
			
		||||
    # Конечный угол градиента (в градусах)
 | 
			
		||||
    # Определяет конечную точку вращения градиента
 | 
			
		||||
    # Значение 0 означает полный поворот на 360 градусов
 | 
			
		||||
    # Конечный угол градиента (в градусах).
 | 
			
		||||
    # Определяет конечную точку вращения градиента.
 | 
			
		||||
    # Значение 0 означает полный поворот на 360 градусов.
 | 
			
		||||
    "gradient_end_angle": 0,
 | 
			
		||||
 | 
			
		||||
    # Тип анимации для карточки при наведении или фокусе
 | 
			
		||||
    # Возможные значения: "gradient", "scale"
 | 
			
		||||
    # "gradient" включает вращающийся градиент для обводки, "scale" увеличивает размер карточки
 | 
			
		||||
    "card_animation_type": "gradient",
 | 
			
		||||
 | 
			
		||||
    # Масштаб карточки в состоянии покоя
 | 
			
		||||
    # Определяет базовый размер карточки (1.0 = 100% от исходного размера)
 | 
			
		||||
    # Значение в долях (например, 1.0 для нормального размера)
 | 
			
		||||
    "default_scale": 1.0,
 | 
			
		||||
 | 
			
		||||
    # Масштаб карточки при наведении курсора
 | 
			
		||||
    # Увеличивает размер карточки при наведении
 | 
			
		||||
    # Значение в долях (например, 1.1 = 110% от исходного размера)
 | 
			
		||||
    "hover_scale": 1.1,
 | 
			
		||||
 | 
			
		||||
    # Масштаб карточки при фокусе (например, при выборе с клавиатуры)
 | 
			
		||||
    # Увеличивает размер карточки при фокусе
 | 
			
		||||
    # Значение в долях (например, 1.05 = 105% от исходного размера)
 | 
			
		||||
    "focus_scale": 1.05,
 | 
			
		||||
 | 
			
		||||
    # Длительность анимации масштабирования
 | 
			
		||||
    # Влияет на скорость изменения размера карточки при наведении или фокусе
 | 
			
		||||
    # Значение в миллисекундах
 | 
			
		||||
    "scale_anim_duration": 200,
 | 
			
		||||
 | 
			
		||||
    # Тип кривой сглаживания для анимации увеличения обводки (при наведении/фокусе)
 | 
			
		||||
    # Влияет на "чувство" анимации (например, плавное ускорение или замедление)
 | 
			
		||||
    # Возможные значения: строки, соответствующие QEasingCurve.Type (например, "OutBack", "InOutQuad")
 | 
			
		||||
    # Тип кривой сглаживания для анимации увеличения обводки (при наведении/фокусе).
 | 
			
		||||
    # Влияет на "чувство" анимации (например, плавное ускорение или замедление).
 | 
			
		||||
    # Возможные значения: строки, соответствующие QEasingCurve.Type (например, "OutBack", "InOutQuad").
 | 
			
		||||
    "thickness_easing_curve": "OutBack",
 | 
			
		||||
 | 
			
		||||
    # Тип кривой сглаживания для анимации уменьшения обводки (при уходе курсора/потере фокуса)
 | 
			
		||||
    # Влияет на "чувство" возврата к исходной ширине обводки
 | 
			
		||||
    # Тип кривой сглаживания для анимации уменьшения обводки (при уходе курсора/потере фокуса).
 | 
			
		||||
    # Влияет на "чувство" возврата к исходной ширине обводки.
 | 
			
		||||
    "thickness_easing_curve_out": "InBack",
 | 
			
		||||
 | 
			
		||||
    # Тип кривой сглаживания для анимации увеличения масштаба (при наведении/фокусе)
 | 
			
		||||
    # Влияет на "чувство" анимации масштабирования (например, с эффектом "отскока")
 | 
			
		||||
    # Возможные значения: строки, соответствующие QEasingCurve.Type
 | 
			
		||||
    "scale_easing_curve": "OutBack",
 | 
			
		||||
 | 
			
		||||
    # Тип кривой сглаживания для анимации уменьшения масштаба (при уходе курсора/потере фокуса)
 | 
			
		||||
    # Влияет на "чувство" возврата к исходному масштабу
 | 
			
		||||
    "scale_easing_curve_out": "InBack",
 | 
			
		||||
 | 
			
		||||
    # Цвета градиента для анимированной обводки
 | 
			
		||||
    # Список словарей, где каждый словарь задает позицию (0.0–1.0) и цвет в формате hex
 | 
			
		||||
    # Влияет на внешний вид обводки при наведении или фокусе, если card_animation_type="gradient"
 | 
			
		||||
    # Цвета градиента для анимированной обводки.
 | 
			
		||||
    # Список словарей, где каждый словарь задает позицию (0.0–1.0) и цвет в формате hex.
 | 
			
		||||
    # Влияет на внешний вид обводки при наведении или фокусе.
 | 
			
		||||
    "gradient_colors": [
 | 
			
		||||
        {"position": 0, "color": "#00fff5"},    # Начальный цвет (циан)
 | 
			
		||||
        {"position": 0.33, "color": "#FF5733"}, # Цвет на 33% (оранжевый)
 | 
			
		||||
@@ -160,43 +125,29 @@ GAME_CARD_ANIMATION = {
 | 
			
		||||
    ],
 | 
			
		||||
 | 
			
		||||
    # Длительность анимации fade при входе на детальную страницу
 | 
			
		||||
    # Влияет на скорость появления страницы при fade-анимации
 | 
			
		||||
    # Значение в миллисекундах
 | 
			
		||||
    "detail_page_fade_duration": 350,
 | 
			
		||||
 | 
			
		||||
    # Длительность анимации slide при входе на детальную страницу
 | 
			
		||||
    # Влияет на скорость скольжения страницы при slide-анимации
 | 
			
		||||
    # Значение в миллисекундах
 | 
			
		||||
    "detail_page_slide_duration": 500,
 | 
			
		||||
 | 
			
		||||
    # Длительность анимации bounce при входе на детальную страницу
 | 
			
		||||
    # Влияет на скорость "прыжка" страницы при bounce-анимации
 | 
			
		||||
    # Значение в миллисекундах
 | 
			
		||||
    "detail_page_bounce_duration": 400,
 | 
			
		||||
 | 
			
		||||
    # Длительность анимации fade при выходе из детальной страницы
 | 
			
		||||
    # Влияет на скорость исчезновения страницы при fade-анимации
 | 
			
		||||
    # Значение в миллисекундах
 | 
			
		||||
    "detail_page_fade_duration_exit": 350,
 | 
			
		||||
 | 
			
		||||
    # Длительность анимации slide при выходе из детальной страницы
 | 
			
		||||
    # Влияет на скорость скольжения страницы при slide-анимации
 | 
			
		||||
    # Значение в миллисекундах
 | 
			
		||||
    "detail_page_slide_duration_exit": 500,
 | 
			
		||||
 | 
			
		||||
    # Длительность анимации bounce при выходе из детальной страницы
 | 
			
		||||
    # Влияет на скорость "сжатия" страницы при bounce-анимации
 | 
			
		||||
    # Значение в миллисекундах
 | 
			
		||||
    "detail_page_bounce_duration_exit": 400,
 | 
			
		||||
 | 
			
		||||
    # Тип кривой сглаживания для анимации при входе на детальную страницу
 | 
			
		||||
    # Применяется к slide и bounce анимациям, влияет на "чувство" движения
 | 
			
		||||
    # Возможные значения: строки, соответствующие QEasingCurve.Type
 | 
			
		||||
    # Применяется к slide и bounce анимациям
 | 
			
		||||
    "detail_page_easing_curve": "OutCubic",
 | 
			
		||||
 | 
			
		||||
    # Тип кривой сглаживания для анимации при выходе из детальной страницы
 | 
			
		||||
    # Применяется к slide и bounce анимациям, влияет на "чувство" движения
 | 
			
		||||
    # Возможные значения: строки, соответствующие QEasingCurve.Type
 | 
			
		||||
    # Применяется к slide и bounce анимациям
 | 
			
		||||
    "detail_page_easing_curve_exit": "InCubic"
 | 
			
		||||
}
 | 
			
		||||
```
 | 
			
		||||
 
 | 
			
		||||
@@ -2,9 +2,8 @@ from PySide6.QtCore import QPropertyAnimation, QByteArray, QEasingCurve, QAbstra
 | 
			
		||||
from PySide6.QtGui import QPainter, QPen, QColor, QConicalGradient, QBrush
 | 
			
		||||
from PySide6.QtWidgets import QWidget, QGraphicsOpacityEffect
 | 
			
		||||
from collections.abc import Callable
 | 
			
		||||
import portprotonqt.themes.standart.styles as default_styles
 | 
			
		||||
from portprotonqt.logger import get_logger
 | 
			
		||||
from portprotonqt.config_utils import read_theme_from_config
 | 
			
		||||
from portprotonqt.theme_manager import ThemeManager
 | 
			
		||||
 | 
			
		||||
logger = get_logger(__name__)
 | 
			
		||||
 | 
			
		||||
@@ -24,27 +23,17 @@ class SafeOpacityEffect(QGraphicsOpacityEffect):
 | 
			
		||||
class GameCardAnimations:
 | 
			
		||||
    def __init__(self, game_card, theme=None):
 | 
			
		||||
        self.game_card = game_card
 | 
			
		||||
        self.theme_manager = ThemeManager()
 | 
			
		||||
        self.theme = theme if theme is not None else self.theme_manager.apply_theme(read_theme_from_config())
 | 
			
		||||
        self.theme = theme if theme is not None else default_styles
 | 
			
		||||
        self.thickness_anim: QPropertyAnimation | None = None
 | 
			
		||||
        self.gradient_anim: QPropertyAnimation | None = None
 | 
			
		||||
        self.scale_anim: QPropertyAnimation | None = None
 | 
			
		||||
        self.pulse_anim: QPropertyAnimation | None = None
 | 
			
		||||
        self._isPulseAnimationConnected = False
 | 
			
		||||
 | 
			
		||||
    def setup_animations(self):
 | 
			
		||||
        """Initialize animation properties based on theme."""
 | 
			
		||||
        """Initialize animation properties."""
 | 
			
		||||
        self.thickness_anim = QPropertyAnimation(self.game_card, QByteArray(b"borderWidth"))
 | 
			
		||||
        self.thickness_anim.setDuration(self.theme.GAME_CARD_ANIMATION["thickness_anim_duration"])
 | 
			
		||||
 | 
			
		||||
        animation_type = self.theme.GAME_CARD_ANIMATION.get("card_animation_type", "gradient")
 | 
			
		||||
        if animation_type == "gradient":
 | 
			
		||||
            self.gradient_anim = QPropertyAnimation(self.game_card, QByteArray(b"gradientAngle"))
 | 
			
		||||
            self.gradient_anim.setDuration(self.theme.GAME_CARD_ANIMATION["gradient_anim_duration"])
 | 
			
		||||
        elif animation_type == "scale":
 | 
			
		||||
            self.scale_anim = QPropertyAnimation(self.game_card, QByteArray(b"scale"))
 | 
			
		||||
            self.scale_anim.setDuration(self.theme.GAME_CARD_ANIMATION["scale_anim_duration"])
 | 
			
		||||
 | 
			
		||||
    def start_pulse_animation(self):
 | 
			
		||||
        """Start pulse animation for border width when hovered or focused."""
 | 
			
		||||
        if not (self.game_card._hovered or self.game_card._focused):
 | 
			
		||||
@@ -68,8 +57,6 @@ class GameCardAnimations:
 | 
			
		||||
        if not self.thickness_anim:
 | 
			
		||||
            self.setup_animations()
 | 
			
		||||
 | 
			
		||||
        animation_type = self.theme.GAME_CARD_ANIMATION.get("card_animation_type", "gradient")
 | 
			
		||||
 | 
			
		||||
        if self.thickness_anim:
 | 
			
		||||
            self.thickness_anim.stop()
 | 
			
		||||
            if self._isPulseAnimationConnected:
 | 
			
		||||
@@ -82,44 +69,23 @@ class GameCardAnimations:
 | 
			
		||||
            self._isPulseAnimationConnected = True
 | 
			
		||||
            self.thickness_anim.start()
 | 
			
		||||
 | 
			
		||||
        if animation_type == "gradient":
 | 
			
		||||
            if self.gradient_anim:
 | 
			
		||||
                self.gradient_anim.stop()
 | 
			
		||||
            self.gradient_anim = QPropertyAnimation(self.game_card, QByteArray(b"gradientAngle"))
 | 
			
		||||
            self.gradient_anim.setDuration(self.theme.GAME_CARD_ANIMATION["gradient_anim_duration"])
 | 
			
		||||
            self.gradient_anim.setStartValue(self.theme.GAME_CARD_ANIMATION["gradient_start_angle"])
 | 
			
		||||
            self.gradient_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["gradient_end_angle"])
 | 
			
		||||
            self.gradient_anim.setLoopCount(-1)
 | 
			
		||||
            self.gradient_anim.start()
 | 
			
		||||
        elif animation_type == "scale":
 | 
			
		||||
            if self.scale_anim:
 | 
			
		||||
                self.scale_anim.stop()
 | 
			
		||||
            self.scale_anim = QPropertyAnimation(self.game_card, QByteArray(b"scale"))
 | 
			
		||||
            self.scale_anim.setDuration(self.theme.GAME_CARD_ANIMATION["scale_anim_duration"])
 | 
			
		||||
            self.scale_anim.setEasingCurve(QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION["scale_easing_curve"]]))
 | 
			
		||||
            self.scale_anim.setStartValue(self.game_card._scale)
 | 
			
		||||
            self.scale_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["hover_scale"])
 | 
			
		||||
            self.scale_anim.start()
 | 
			
		||||
        if self.gradient_anim:
 | 
			
		||||
            self.gradient_anim.stop()
 | 
			
		||||
        self.gradient_anim = QPropertyAnimation(self.game_card, QByteArray(b"gradientAngle"))
 | 
			
		||||
        self.gradient_anim.setDuration(self.theme.GAME_CARD_ANIMATION["gradient_anim_duration"])
 | 
			
		||||
        self.gradient_anim.setStartValue(self.theme.GAME_CARD_ANIMATION["gradient_start_angle"])
 | 
			
		||||
        self.gradient_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["gradient_end_angle"])
 | 
			
		||||
        self.gradient_anim.setLoopCount(-1)
 | 
			
		||||
        self.gradient_anim.start()
 | 
			
		||||
 | 
			
		||||
    def handle_leave_event(self):
 | 
			
		||||
        """Handle mouse leave event animations."""
 | 
			
		||||
        self.game_card._hovered = False
 | 
			
		||||
        self.game_card.hoverChanged.emit(self.game_card.name, False)
 | 
			
		||||
        if not self.game_card._focused:
 | 
			
		||||
            animation_type = self.theme.GAME_CARD_ANIMATION.get("card_animation_type", "gradient")
 | 
			
		||||
            if animation_type == "gradient":
 | 
			
		||||
                if self.gradient_anim:
 | 
			
		||||
                    self.gradient_anim.stop()
 | 
			
		||||
                    self.gradient_anim = None
 | 
			
		||||
            elif animation_type == "scale":
 | 
			
		||||
                if self.scale_anim:
 | 
			
		||||
                    self.scale_anim.stop()
 | 
			
		||||
                self.scale_anim = QPropertyAnimation(self.game_card, QByteArray(b"scale"))
 | 
			
		||||
                self.scale_anim.setDuration(self.theme.GAME_CARD_ANIMATION["scale_anim_duration"])
 | 
			
		||||
                self.scale_anim.setEasingCurve(QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION["scale_easing_curve_out"]]))
 | 
			
		||||
                self.scale_anim.setStartValue(self.game_card._scale)
 | 
			
		||||
                self.scale_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["default_scale"])
 | 
			
		||||
                self.scale_anim.start()
 | 
			
		||||
            if self.gradient_anim:
 | 
			
		||||
                self.gradient_anim.stop()
 | 
			
		||||
                self.gradient_anim = None
 | 
			
		||||
            if self.pulse_anim:
 | 
			
		||||
                self.pulse_anim.stop()
 | 
			
		||||
                self.pulse_anim = None
 | 
			
		||||
@@ -142,8 +108,6 @@ class GameCardAnimations:
 | 
			
		||||
            if not self.thickness_anim:
 | 
			
		||||
                self.setup_animations()
 | 
			
		||||
 | 
			
		||||
            animation_type = self.theme.GAME_CARD_ANIMATION.get("card_animation_type", "gradient")
 | 
			
		||||
 | 
			
		||||
            if self.thickness_anim:
 | 
			
		||||
                self.thickness_anim.stop()
 | 
			
		||||
                if self._isPulseAnimationConnected:
 | 
			
		||||
@@ -156,44 +120,23 @@ class GameCardAnimations:
 | 
			
		||||
                self._isPulseAnimationConnected = True
 | 
			
		||||
                self.thickness_anim.start()
 | 
			
		||||
 | 
			
		||||
            if animation_type == "gradient":
 | 
			
		||||
                if self.gradient_anim:
 | 
			
		||||
                    self.gradient_anim.stop()
 | 
			
		||||
                self.gradient_anim = QPropertyAnimation(self.game_card, QByteArray(b"gradientAngle"))
 | 
			
		||||
                self.gradient_anim.setDuration(self.theme.GAME_CARD_ANIMATION["gradient_anim_duration"])
 | 
			
		||||
                self.gradient_anim.setStartValue(self.theme.GAME_CARD_ANIMATION["gradient_start_angle"])
 | 
			
		||||
                self.gradient_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["gradient_end_angle"])
 | 
			
		||||
                self.gradient_anim.setLoopCount(-1)
 | 
			
		||||
                self.gradient_anim.start()
 | 
			
		||||
            elif animation_type == "scale":
 | 
			
		||||
                if self.scale_anim:
 | 
			
		||||
                    self.scale_anim.stop()
 | 
			
		||||
                self.scale_anim = QPropertyAnimation(self.game_card, QByteArray(b"scale"))
 | 
			
		||||
                self.scale_anim.setDuration(self.theme.GAME_CARD_ANIMATION["scale_anim_duration"])
 | 
			
		||||
                self.scale_anim.setEasingCurve(QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION["scale_easing_curve"]]))
 | 
			
		||||
                self.scale_anim.setStartValue(self.game_card._scale)
 | 
			
		||||
                self.scale_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["focus_scale"])
 | 
			
		||||
                self.scale_anim.start()
 | 
			
		||||
            if self.gradient_anim:
 | 
			
		||||
                self.gradient_anim.stop()
 | 
			
		||||
            self.gradient_anim = QPropertyAnimation(self.game_card, QByteArray(b"gradientAngle"))
 | 
			
		||||
            self.gradient_anim.setDuration(self.theme.GAME_CARD_ANIMATION["gradient_anim_duration"])
 | 
			
		||||
            self.gradient_anim.setStartValue(self.theme.GAME_CARD_ANIMATION["gradient_start_angle"])
 | 
			
		||||
            self.gradient_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["gradient_end_angle"])
 | 
			
		||||
            self.gradient_anim.setLoopCount(-1)
 | 
			
		||||
            self.gradient_anim.start()
 | 
			
		||||
 | 
			
		||||
    def handle_focus_out_event(self):
 | 
			
		||||
        """Handle focus out event animations."""
 | 
			
		||||
        self.game_card._focused = False
 | 
			
		||||
        self.game_card.focusChanged.emit(self.game_card.name, False)
 | 
			
		||||
        if not self.game_card._hovered:
 | 
			
		||||
            animation_type = self.theme.GAME_CARD_ANIMATION.get("card_animation_type", "gradient")
 | 
			
		||||
            if animation_type == "gradient":
 | 
			
		||||
                if self.gradient_anim:
 | 
			
		||||
                    self.gradient_anim.stop()
 | 
			
		||||
                    self.gradient_anim = None
 | 
			
		||||
            elif animation_type == "scale":
 | 
			
		||||
                if self.scale_anim:
 | 
			
		||||
                    self.scale_anim.stop()
 | 
			
		||||
                self.scale_anim = QPropertyAnimation(self.game_card, QByteArray(b"scale"))
 | 
			
		||||
                self.scale_anim.setDuration(self.theme.GAME_CARD_ANIMATION["scale_anim_duration"])
 | 
			
		||||
                self.scale_anim.setEasingCurve(QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION["scale_easing_curve_out"]]))
 | 
			
		||||
                self.scale_anim.setStartValue(self.game_card._scale)
 | 
			
		||||
                self.scale_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["default_scale"])
 | 
			
		||||
                self.scale_anim.start()
 | 
			
		||||
            if self.gradient_anim:
 | 
			
		||||
                self.gradient_anim.stop()
 | 
			
		||||
                self.gradient_anim = None
 | 
			
		||||
            if self.pulse_anim:
 | 
			
		||||
                self.pulse_anim.stop()
 | 
			
		||||
                self.pulse_anim = None
 | 
			
		||||
@@ -209,13 +152,12 @@ class GameCardAnimations:
 | 
			
		||||
 | 
			
		||||
    def paint_border(self, painter: QPainter):
 | 
			
		||||
        if not painter.isActive():
 | 
			
		||||
            logger.debug("Painter is not active; skipping border paint")
 | 
			
		||||
            logger.warning("Painter is not active; skipping border paint")
 | 
			
		||||
            return
 | 
			
		||||
        painter.setRenderHint(QPainter.RenderHint.Antialiasing)
 | 
			
		||||
        pen = QPen()
 | 
			
		||||
        pen.setWidth(self.game_card._borderWidth)
 | 
			
		||||
        animation_type = self.theme.GAME_CARD_ANIMATION.get("card_animation_type", "gradient")
 | 
			
		||||
        if (self.game_card._hovered or self.game_card._focused) and animation_type == "gradient":
 | 
			
		||||
        if self.game_card._hovered or self.game_card._focused:
 | 
			
		||||
            center = self.game_card.rect().center()
 | 
			
		||||
            gradient = QConicalGradient(center, self.game_card._gradientAngle)
 | 
			
		||||
            for stop in self.theme.GAME_CARD_ANIMATION["gradient_colors"]:
 | 
			
		||||
@@ -224,18 +166,17 @@ class GameCardAnimations:
 | 
			
		||||
        else:
 | 
			
		||||
            pen.setColor(QColor(0, 0, 0, 0))
 | 
			
		||||
        painter.setPen(pen)
 | 
			
		||||
        radius = 18 * self.game_card._scale
 | 
			
		||||
        radius = 18
 | 
			
		||||
        bw = round(self.game_card._borderWidth / 2)
 | 
			
		||||
        rect = self.game_card.rect().adjusted(bw, bw, -bw, -bw)
 | 
			
		||||
        if rect.isEmpty():
 | 
			
		||||
            return
 | 
			
		||||
            return  # Avoid drawing invalid rect
 | 
			
		||||
        painter.drawRoundedRect(rect, radius, radius)
 | 
			
		||||
 | 
			
		||||
class DetailPageAnimations:
 | 
			
		||||
    def __init__(self, main_window, theme=None):
 | 
			
		||||
        self.main_window = main_window
 | 
			
		||||
        self.theme_manager = ThemeManager()
 | 
			
		||||
        self.theme = theme if theme is not None else self.theme_manager.apply_theme(read_theme_from_config())
 | 
			
		||||
        self.theme = theme if theme is not None else default_styles
 | 
			
		||||
        self.animations = main_window._animations if hasattr(main_window, '_animations') else {}
 | 
			
		||||
 | 
			
		||||
    def animate_detail_page(self, detail_page: QWidget, load_image_and_restore_effect: Callable, cleanup_animation: Callable):
 | 
			
		||||
@@ -258,7 +199,7 @@ class DetailPageAnimations:
 | 
			
		||||
                try:
 | 
			
		||||
                    detail_page.setGraphicsEffect(original_effect) # type: ignore
 | 
			
		||||
                except RuntimeError:
 | 
			
		||||
                    logger.warning("Original effect already deleted")
 | 
			
		||||
                    logger.debug("Original effect already deleted")
 | 
			
		||||
            animation.finished.connect(restore_effect)
 | 
			
		||||
            animation.finished.connect(load_image_and_restore_effect)
 | 
			
		||||
            animation.finished.connect(opacity_effect.deleteLater)
 | 
			
		||||
@@ -317,7 +258,7 @@ class DetailPageAnimations:
 | 
			
		||||
                    if isinstance(animation, QAbstractAnimation) and animation.state() == QAbstractAnimation.State.Running:
 | 
			
		||||
                        animation.stop()
 | 
			
		||||
                except RuntimeError:
 | 
			
		||||
                    logger.warning("Animation already deleted for page")
 | 
			
		||||
                    logger.debug("Animation already deleted for page")
 | 
			
		||||
                except Exception as e:
 | 
			
		||||
                    logger.error(f"Error stopping existing animation: {e}", exc_info=True)
 | 
			
		||||
                finally:
 | 
			
		||||
@@ -343,15 +284,15 @@ class DetailPageAnimations:
 | 
			
		||||
                        logger.debug("Original effect already deleted")
 | 
			
		||||
                    cleanup_callback()
 | 
			
		||||
                animation.finished.connect(restore_and_cleanup)
 | 
			
		||||
                animation.finished.connect(opacity_effect.deleteLater)
 | 
			
		||||
                animation.finished.connect(opacity_effect.deleteLater)  # Clean up effect
 | 
			
		||||
            elif animation_type in ["slide_left", "slide_right", "slide_up", "slide_down"]:
 | 
			
		||||
                duration = self.theme.GAME_CARD_ANIMATION.get("detail_page_slide_duration_exit", 500)
 | 
			
		||||
                easing_curve = QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION.get("detail_page_easing_curve_exit", "InCubic")])
 | 
			
		||||
                end_pos = {
 | 
			
		||||
                    "slide_left": QPoint(-self.main_window.width(), 0),
 | 
			
		||||
                    "slide_right": QPoint(self.main_window.width(), 0),
 | 
			
		||||
                    "slide_up": QPoint(0, self.main_window.height()),
 | 
			
		||||
                    "slide_down": QPoint(0, -self.main_window.height())
 | 
			
		||||
                    "slide_left": QPoint(-self.main_window.width(), 0),  # Exit to left (opposite of entry)
 | 
			
		||||
                    "slide_right": QPoint(self.main_window.width(), 0),  # Exit to right
 | 
			
		||||
                    "slide_up": QPoint(0, self.main_window.height()),    # Exit downward
 | 
			
		||||
                    "slide_down": QPoint(0, -self.main_window.height())  # Exit upward
 | 
			
		||||
                }[animation_type]
 | 
			
		||||
                animation = QPropertyAnimation(detail_page, QByteArray(b"pos"))
 | 
			
		||||
                animation.setDuration(duration)
 | 
			
		||||
@@ -384,4 +325,4 @@ class DetailPageAnimations:
 | 
			
		||||
        except Exception as e:
 | 
			
		||||
            logger.error(f"Error in animate_detail_page_exit: {e}", exc_info=True)
 | 
			
		||||
            self.animations.pop(detail_page, None)
 | 
			
		||||
            cleanup_callback()
 | 
			
		||||
            cleanup_callback()  # Fallback to cleanup if animation setup fails
 | 
			
		||||
 
 | 
			
		||||
@@ -4,12 +4,14 @@ from PySide6.QtWidgets import QApplication
 | 
			
		||||
from PySide6.QtGui import QIcon
 | 
			
		||||
from portprotonqt.main_window import MainWindow
 | 
			
		||||
from portprotonqt.config_utils import save_fullscreen_config
 | 
			
		||||
from portprotonqt.logger import get_logger, setup_logger
 | 
			
		||||
from portprotonqt.logger import get_logger
 | 
			
		||||
from portprotonqt.cli import parse_args
 | 
			
		||||
 | 
			
		||||
logger = get_logger(__name__)
 | 
			
		||||
 | 
			
		||||
__app_id__ = "ru.linux_gaming.PortProtonQt"
 | 
			
		||||
__app_name__ = "PortProtonQt"
 | 
			
		||||
__app_version__ = "0.1.6"
 | 
			
		||||
__app_version__ = "0.1.4"
 | 
			
		||||
 | 
			
		||||
def main():
 | 
			
		||||
    app = QApplication(sys.argv)
 | 
			
		||||
@@ -18,23 +20,17 @@ def main():
 | 
			
		||||
    app.setApplicationName(__app_name__)
 | 
			
		||||
    app.setApplicationVersion(__app_version__)
 | 
			
		||||
 | 
			
		||||
    args = parse_args()
 | 
			
		||||
 | 
			
		||||
    # Setup logger with specified debug level
 | 
			
		||||
    setup_logger(args.debug_level)
 | 
			
		||||
 | 
			
		||||
    # Reinitialize logger after setup to ensure it uses the new configuration
 | 
			
		||||
    logger = get_logger(__name__)
 | 
			
		||||
 | 
			
		||||
    system_locale = QLocale.system()
 | 
			
		||||
    qt_translator = QTranslator()
 | 
			
		||||
    translations_path = QLibraryInfo.path(QLibraryInfo.LibraryPath.TranslationsPath)
 | 
			
		||||
    if qt_translator.load(system_locale, "qtbase", "_", translations_path):
 | 
			
		||||
        app.installTranslator(qt_translator)
 | 
			
		||||
    else:
 | 
			
		||||
        logger.warning(f"Qt translations for {system_locale.name()} not found in {translations_path}, using english language")
 | 
			
		||||
        logger.error(f"Qt translations for {system_locale.name()} not found in {translations_path}")
 | 
			
		||||
 | 
			
		||||
    window = MainWindow(app_name=__app_name__)
 | 
			
		||||
    args = parse_args()
 | 
			
		||||
 | 
			
		||||
    window = MainWindow()
 | 
			
		||||
 | 
			
		||||
    if args.fullscreen:
 | 
			
		||||
        logger.info("Launching in fullscreen mode due to --fullscreen flag")
 | 
			
		||||
 
 | 
			
		||||
@@ -13,10 +13,4 @@ def parse_args():
 | 
			
		||||
        action="store_true",
 | 
			
		||||
        help="Запустить приложение в полноэкранном режиме и сохранить эту настройку"
 | 
			
		||||
    )
 | 
			
		||||
    parser.add_argument(
 | 
			
		||||
        "--debug-level",
 | 
			
		||||
        choices=['ALL', 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'],
 | 
			
		||||
        default='NOTSET',
 | 
			
		||||
        help="Установить уровень логирования (ALL для всех сообщений, по умолчанию: без логов)"
 | 
			
		||||
    )
 | 
			
		||||
    return parser.parse_args()
 | 
			
		||||
 
 | 
			
		||||
@@ -7,7 +7,7 @@ logger = get_logger(__name__)
 | 
			
		||||
 | 
			
		||||
_portproton_location = None
 | 
			
		||||
 | 
			
		||||
# Paths to configuration files
 | 
			
		||||
# Пути к конфигурационным файлам
 | 
			
		||||
CONFIG_FILE = os.path.join(
 | 
			
		||||
    os.getenv("XDG_CONFIG_HOME", os.path.join(os.path.expanduser("~"), ".config")),
 | 
			
		||||
    "PortProtonQt.conf"
 | 
			
		||||
@@ -18,32 +18,17 @@ PORTPROTON_CONFIG_FILE = os.path.join(
 | 
			
		||||
    "PortProton.conf"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
# Paths to theme directories
 | 
			
		||||
# Пути к папкам с темами
 | 
			
		||||
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(os.path.dirname(os.path.abspath(__file__)), "themes")
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
def read_config_safely(config_file: str) -> configparser.ConfigParser | None:
 | 
			
		||||
    """Safely reads a configuration file and returns a ConfigParser object or None if reading fails."""
 | 
			
		||||
    cp = configparser.ConfigParser()
 | 
			
		||||
    if not os.path.exists(config_file):
 | 
			
		||||
        logger.debug(f"Configuration file {config_file} not found")
 | 
			
		||||
        return None
 | 
			
		||||
    try:
 | 
			
		||||
        cp.read(config_file, encoding="utf-8")
 | 
			
		||||
        return cp
 | 
			
		||||
    except (configparser.DuplicateSectionError, configparser.DuplicateOptionError) as e:
 | 
			
		||||
        logger.warning(f"Invalid configuration file format: {e}")
 | 
			
		||||
        return None
 | 
			
		||||
    except Exception as e:
 | 
			
		||||
        logger.warning(f"Failed to read configuration file: {e}")
 | 
			
		||||
        return None
 | 
			
		||||
 | 
			
		||||
def read_config():
 | 
			
		||||
    """Reads the configuration file and returns a dictionary of parameters.
 | 
			
		||||
    Example line in config (no sections):
 | 
			
		||||
    """
 | 
			
		||||
    Читает конфигурационный файл и возвращает словарь параметров.
 | 
			
		||||
    Пример строки в конфиге (без секций):
 | 
			
		||||
      detail_level = detailed
 | 
			
		||||
    """
 | 
			
		||||
    config_dict = {}
 | 
			
		||||
@@ -59,17 +44,29 @@ def read_config():
 | 
			
		||||
    return config_dict
 | 
			
		||||
 | 
			
		||||
def read_theme_from_config():
 | 
			
		||||
    """Reads the theme from the [Appearance] section of the configuration file.
 | 
			
		||||
    Returns 'standart' if the parameter is not set.
 | 
			
		||||
    """
 | 
			
		||||
    cp = read_config_safely(CONFIG_FILE)
 | 
			
		||||
    if cp is None:
 | 
			
		||||
        return "standart"
 | 
			
		||||
    Читает из конфигурационного файла тему из секции [Appearance].
 | 
			
		||||
    Если параметр не задан, возвращает "standart".
 | 
			
		||||
    """
 | 
			
		||||
    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)
 | 
			
		||||
            return "standart"
 | 
			
		||||
    return cp.get("Appearance", "theme", fallback="standart")
 | 
			
		||||
 | 
			
		||||
def save_theme_to_config(theme_name):
 | 
			
		||||
    """Saves the selected theme name to the [Appearance] section of the configuration file."""
 | 
			
		||||
    cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser()
 | 
			
		||||
    """
 | 
			
		||||
    Сохраняет имя выбранной темы в секции [Appearance] конфигурационного файла.
 | 
			
		||||
    """
 | 
			
		||||
    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 "Appearance" not in cp:
 | 
			
		||||
        cp["Appearance"] = {}
 | 
			
		||||
    cp["Appearance"]["theme"] = theme_name
 | 
			
		||||
@@ -77,18 +74,34 @@ def save_theme_to_config(theme_name):
 | 
			
		||||
        cp.write(configfile)
 | 
			
		||||
 | 
			
		||||
def read_time_config():
 | 
			
		||||
    """Reads time settings from the [Time] section of the configuration file.
 | 
			
		||||
    If the section or parameter is missing, saves and returns 'detailed' as default.
 | 
			
		||||
    """
 | 
			
		||||
    cp = read_config_safely(CONFIG_FILE)
 | 
			
		||||
    if cp is None or not cp.has_section("Time") or not cp.has_option("Time", "detail_level"):
 | 
			
		||||
        save_time_config("detailed")
 | 
			
		||||
        return "detailed"
 | 
			
		||||
    return cp.get("Time", "detail_level", fallback="detailed").lower()
 | 
			
		||||
    Читает настройки времени из секции [Time] конфигурационного файла.
 | 
			
		||||
    Если секция или параметр отсутствуют, сохраняет и возвращает "detailed" по умолчанию.
 | 
			
		||||
    """
 | 
			
		||||
    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)
 | 
			
		||||
            save_time_config("detailed")
 | 
			
		||||
            return "detailed"
 | 
			
		||||
        if not cp.has_section("Time") or not cp.has_option("Time", "detail_level"):
 | 
			
		||||
            save_time_config("detailed")
 | 
			
		||||
            return "detailed"
 | 
			
		||||
        return cp.get("Time", "detail_level", fallback="detailed").lower()
 | 
			
		||||
    return "detailed"
 | 
			
		||||
 | 
			
		||||
def save_time_config(detail_level):
 | 
			
		||||
    """Saves the time detail level to the [Time] section of the configuration file."""
 | 
			
		||||
    cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser()
 | 
			
		||||
    """
 | 
			
		||||
    Сохраняет настройку уровня детализации времени в секции [Time].
 | 
			
		||||
    """
 | 
			
		||||
    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 "Time" not in cp:
 | 
			
		||||
        cp["Time"] = {}
 | 
			
		||||
    cp["Time"]["detail_level"] = detail_level
 | 
			
		||||
@@ -96,42 +109,48 @@ def save_time_config(detail_level):
 | 
			
		||||
        cp.write(configfile)
 | 
			
		||||
 | 
			
		||||
def read_file_content(file_path):
 | 
			
		||||
    """Reads the content of a file and returns it as a string."""
 | 
			
		||||
    """
 | 
			
		||||
    Читает содержимое файла и возвращает его как строку.
 | 
			
		||||
    """
 | 
			
		||||
    with open(file_path, encoding="utf-8") as f:
 | 
			
		||||
        return f.read().strip()
 | 
			
		||||
 | 
			
		||||
def get_portproton_location():
 | 
			
		||||
    """Returns the path to the PortProton directory.
 | 
			
		||||
    Checks the cached path first. If not set, reads from PORTPROTON_CONFIG_FILE.
 | 
			
		||||
    If the path is invalid, uses the default directory.
 | 
			
		||||
    """
 | 
			
		||||
    Возвращает путь к директории PortProton.
 | 
			
		||||
    Сначала проверяется кэшированный путь. Если он отсутствует, проверяется
 | 
			
		||||
    наличие пути в файле PORTPROTON_CONFIG_FILE. Если путь недоступен,
 | 
			
		||||
    используется директория по умолчанию.
 | 
			
		||||
    """
 | 
			
		||||
    global _portproton_location
 | 
			
		||||
    if _portproton_location is not None:
 | 
			
		||||
        return _portproton_location
 | 
			
		||||
 | 
			
		||||
    # Попытка чтения пути из конфигурационного файла
 | 
			
		||||
    if os.path.isfile(PORTPROTON_CONFIG_FILE):
 | 
			
		||||
        try:
 | 
			
		||||
            location = read_file_content(PORTPROTON_CONFIG_FILE).strip()
 | 
			
		||||
            if location and os.path.isdir(location):
 | 
			
		||||
                _portproton_location = location
 | 
			
		||||
                logger.info(f"PortProton path from configuration: {location}")
 | 
			
		||||
                logger.info(f"Путь PortProton из конфигурации: {location}")
 | 
			
		||||
                return _portproton_location
 | 
			
		||||
            logger.warning(f"Invalid PortProton path in configuration: {location}, using default path")
 | 
			
		||||
            logger.warning(f"Недействительный путь в конфиге PortProton: {location}")
 | 
			
		||||
        except (OSError, PermissionError) as e:
 | 
			
		||||
            logger.warning(f"Failed to read PortProton configuration file: {e}, using default path")
 | 
			
		||||
            logger.error(f"Ошибка чтения файла конфигурации PortProton: {e}")
 | 
			
		||||
 | 
			
		||||
    default_dir = os.path.join(os.path.expanduser("~"), ".var", "app", "ru.linux_gaming.PortProton")
 | 
			
		||||
    if os.path.isdir(default_dir):
 | 
			
		||||
        _portproton_location = default_dir
 | 
			
		||||
        logger.info(f"Using flatpak PortProton directory: {default_dir}")
 | 
			
		||||
        logger.info(f"Используется директория flatpak PortProton: {default_dir}")
 | 
			
		||||
        return _portproton_location
 | 
			
		||||
 | 
			
		||||
    logger.warning("PortProton configuration and flatpak directory not found")
 | 
			
		||||
    logger.warning("Конфигурация и директория flatpak PortProton не найдены")
 | 
			
		||||
    return None
 | 
			
		||||
 | 
			
		||||
def parse_desktop_entry(file_path):
 | 
			
		||||
    """Reads and parses a .desktop file using configparser.
 | 
			
		||||
    Returns None if the [Desktop Entry] section is missing.
 | 
			
		||||
    """
 | 
			
		||||
    Читает и парсит .desktop файл с помощью configparser.
 | 
			
		||||
    Если секция [Desktop Entry] отсутствует, возвращается None.
 | 
			
		||||
    """
 | 
			
		||||
    cp = configparser.ConfigParser(interpolation=None)
 | 
			
		||||
    cp.read(file_path, encoding="utf-8")
 | 
			
		||||
@@ -140,8 +159,9 @@ def parse_desktop_entry(file_path):
 | 
			
		||||
    return cp["Desktop Entry"]
 | 
			
		||||
 | 
			
		||||
def load_theme_metainfo(theme_name):
 | 
			
		||||
    """Loads theme metadata from metainfo.ini in the theme's root directory.
 | 
			
		||||
    Expected fields: author, author_link, description, name.
 | 
			
		||||
    """
 | 
			
		||||
    Загружает метаинформацию темы из файла metainfo.ini в корне папки темы.
 | 
			
		||||
    Ожидаемые поля: author, author_link, description, name.
 | 
			
		||||
    """
 | 
			
		||||
    meta = {}
 | 
			
		||||
    for themes_dir in THEMES_DIRS:
 | 
			
		||||
@@ -159,18 +179,34 @@ def load_theme_metainfo(theme_name):
 | 
			
		||||
    return meta
 | 
			
		||||
 | 
			
		||||
def read_card_size():
 | 
			
		||||
    """Reads the card size (width) from the [Cards] section.
 | 
			
		||||
    Returns 250 if the parameter is not set.
 | 
			
		||||
    """
 | 
			
		||||
    cp = read_config_safely(CONFIG_FILE)
 | 
			
		||||
    if cp is None or not cp.has_section("Cards") or not cp.has_option("Cards", "card_width"):
 | 
			
		||||
        save_card_size(250)
 | 
			
		||||
        return 250
 | 
			
		||||
    return cp.getint("Cards", "card_width", fallback=250)
 | 
			
		||||
    Читает размер карточек (ширину) из секции [Cards],
 | 
			
		||||
    Если параметр не задан, возвращает 250.
 | 
			
		||||
    """
 | 
			
		||||
    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)
 | 
			
		||||
            save_card_size(250)
 | 
			
		||||
            return 250
 | 
			
		||||
        if not cp.has_section("Cards") or not cp.has_option("Cards", "card_width"):
 | 
			
		||||
            save_card_size(250)
 | 
			
		||||
            return 250
 | 
			
		||||
        return cp.getint("Cards", "card_width", fallback=250)
 | 
			
		||||
    return 250
 | 
			
		||||
 | 
			
		||||
def save_card_size(card_width):
 | 
			
		||||
    """Saves the card size (width) to the [Cards] section."""
 | 
			
		||||
    cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser()
 | 
			
		||||
    """
 | 
			
		||||
    Сохраняет размер карточек (ширину) в секцию [Cards].
 | 
			
		||||
    """
 | 
			
		||||
    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 "Cards" not in cp:
 | 
			
		||||
        cp["Cards"] = {}
 | 
			
		||||
    cp["Cards"]["card_width"] = str(card_width)
 | 
			
		||||
@@ -178,18 +214,34 @@ def save_card_size(card_width):
 | 
			
		||||
        cp.write(configfile)
 | 
			
		||||
 | 
			
		||||
def read_sort_method():
 | 
			
		||||
    """Reads the sort method from the [Games] section.
 | 
			
		||||
    Returns 'last_launch' if the parameter is not set.
 | 
			
		||||
    """
 | 
			
		||||
    cp = read_config_safely(CONFIG_FILE)
 | 
			
		||||
    if cp is None or not cp.has_section("Games") or not cp.has_option("Games", "sort_method"):
 | 
			
		||||
        save_sort_method("last_launch")
 | 
			
		||||
        return "last_launch"
 | 
			
		||||
    return cp.get("Games", "sort_method", fallback="last_launch").lower()
 | 
			
		||||
    Читает метод сортировки из секции [Games].
 | 
			
		||||
    Если параметр не задан, возвращает last_launch.
 | 
			
		||||
    """
 | 
			
		||||
    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)
 | 
			
		||||
            save_sort_method("last_launch")
 | 
			
		||||
            return "last_launch"
 | 
			
		||||
        if not cp.has_section("Games") or not cp.has_option("Games", "sort_method"):
 | 
			
		||||
            save_sort_method("last_launch")
 | 
			
		||||
            return "last_launch"
 | 
			
		||||
        return cp.get("Games", "sort_method", fallback="last_launch").lower()
 | 
			
		||||
    return "last_launch"
 | 
			
		||||
 | 
			
		||||
def save_sort_method(sort_method):
 | 
			
		||||
    """Saves the sort method to the [Games] section."""
 | 
			
		||||
    cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser()
 | 
			
		||||
    """
 | 
			
		||||
    Сохраняет метод сортировки в секцию [Games].
 | 
			
		||||
    """
 | 
			
		||||
    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 "Games" not in cp:
 | 
			
		||||
        cp["Games"] = {}
 | 
			
		||||
    cp["Games"]["sort_method"] = sort_method
 | 
			
		||||
@@ -197,18 +249,34 @@ def save_sort_method(sort_method):
 | 
			
		||||
        cp.write(configfile)
 | 
			
		||||
 | 
			
		||||
def read_display_filter():
 | 
			
		||||
    """Reads the display_filter parameter from the [Games] section.
 | 
			
		||||
    Returns 'all' if the parameter is missing.
 | 
			
		||||
    """
 | 
			
		||||
    cp = read_config_safely(CONFIG_FILE)
 | 
			
		||||
    if cp is None or not cp.has_section("Games") or not cp.has_option("Games", "display_filter"):
 | 
			
		||||
        save_display_filter("all")
 | 
			
		||||
        return "all"
 | 
			
		||||
    return cp.get("Games", "display_filter", fallback="all").lower()
 | 
			
		||||
    Читает параметр display_filter из секции [Games].
 | 
			
		||||
    Если параметр отсутствует, сохраняет и возвращает значение "all".
 | 
			
		||||
    """
 | 
			
		||||
    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_display_filter("all")
 | 
			
		||||
            return "all"
 | 
			
		||||
        if not cp.has_section("Games") or not cp.has_option("Games", "display_filter"):
 | 
			
		||||
            save_display_filter("all")
 | 
			
		||||
            return "all"
 | 
			
		||||
        return cp.get("Games", "display_filter", fallback="all").lower()
 | 
			
		||||
    return "all"
 | 
			
		||||
 | 
			
		||||
def save_display_filter(filter_value):
 | 
			
		||||
    """Saves the display_filter parameter to the [Games] section."""
 | 
			
		||||
    cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser()
 | 
			
		||||
    """
 | 
			
		||||
    Сохраняет параметр display_filter в секцию [Games] конфигурационного файла.
 | 
			
		||||
    """
 | 
			
		||||
    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)
 | 
			
		||||
    if "Games" not in cp:
 | 
			
		||||
        cp["Games"] = {}
 | 
			
		||||
    cp["Games"]["display_filter"] = filter_value
 | 
			
		||||
@@ -216,23 +284,37 @@ def save_display_filter(filter_value):
 | 
			
		||||
        cp.write(configfile)
 | 
			
		||||
 | 
			
		||||
def read_favorites():
 | 
			
		||||
    """Reads the list of favorite games from the [Favorites] section.
 | 
			
		||||
    The list is stored as a quoted string with comma-separated names.
 | 
			
		||||
    Returns an empty list if the section or parameter is missing.
 | 
			
		||||
    """
 | 
			
		||||
    cp = read_config_safely(CONFIG_FILE)
 | 
			
		||||
    if cp is None or not cp.has_section("Favorites") or not cp.has_option("Favorites", "games"):
 | 
			
		||||
        return []
 | 
			
		||||
    favs = cp.get("Favorites", "games", fallback="").strip()
 | 
			
		||||
    if favs.startswith('"') and favs.endswith('"'):
 | 
			
		||||
        favs = favs[1:-1]
 | 
			
		||||
    return [s.strip() for s in favs.split(",") if s.strip()]
 | 
			
		||||
    Читает список избранных игр из секции [Favorites] конфигурационного файла.
 | 
			
		||||
    Список хранится как строка, заключённая в кавычки, с именами, разделёнными запятыми.
 | 
			
		||||
    Если секция или параметр отсутствуют, возвращает пустой список.
 | 
			
		||||
    """
 | 
			
		||||
    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)
 | 
			
		||||
            return []
 | 
			
		||||
        if cp.has_section("Favorites") and cp.has_option("Favorites", "games"):
 | 
			
		||||
            favs = cp.get("Favorites", "games", fallback="").strip()
 | 
			
		||||
            # Если строка начинается и заканчивается кавычками, удаляем их
 | 
			
		||||
            if favs.startswith('"') and favs.endswith('"'):
 | 
			
		||||
                favs = favs[1:-1]
 | 
			
		||||
            return [s.strip() for s in favs.split(",") if s.strip()]
 | 
			
		||||
    return []
 | 
			
		||||
 | 
			
		||||
def save_favorites(favorites):
 | 
			
		||||
    """Saves the list of favorite games to the [Favorites] section.
 | 
			
		||||
    The list is stored as a quoted string with comma-separated names.
 | 
			
		||||
    """
 | 
			
		||||
    cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser()
 | 
			
		||||
    Сохраняет список избранных игр в секцию [Favorites] конфигурационного файла.
 | 
			
		||||
    Список сохраняется как строка, заключённая в двойные кавычки, где имена игр разделены запятыми.
 | 
			
		||||
    """
 | 
			
		||||
    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)
 | 
			
		||||
    if "Favorites" not in cp:
 | 
			
		||||
        cp["Favorites"] = {}
 | 
			
		||||
    fav_str = ", ".join(favorites)
 | 
			
		||||
@@ -241,18 +323,34 @@ def save_favorites(favorites):
 | 
			
		||||
        cp.write(configfile)
 | 
			
		||||
 | 
			
		||||
def read_rumble_config():
 | 
			
		||||
    """Reads the gamepad rumble setting from the [Gamepad] section.
 | 
			
		||||
    Returns False if the parameter is missing.
 | 
			
		||||
    """
 | 
			
		||||
    cp = read_config_safely(CONFIG_FILE)
 | 
			
		||||
    if cp is None or 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)
 | 
			
		||||
    Читает настройку виброотдачи геймпада из секции [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):
 | 
			
		||||
    """Saves the gamepad rumble setting to the [Gamepad] section."""
 | 
			
		||||
    cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser()
 | 
			
		||||
    """
 | 
			
		||||
    Сохраняет настройку виброотдачи геймпада в секцию [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)
 | 
			
		||||
@@ -260,28 +358,41 @@ def save_rumble_config(rumble_enabled):
 | 
			
		||||
        cp.write(configfile)
 | 
			
		||||
 | 
			
		||||
def ensure_default_proxy_config():
 | 
			
		||||
    """Ensures the [Proxy] section exists in the configuration file.
 | 
			
		||||
    Creates it with empty values if missing.
 | 
			
		||||
    """
 | 
			
		||||
    cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser()
 | 
			
		||||
    if "Proxy" not in cp:
 | 
			
		||||
        cp.add_section("Proxy")
 | 
			
		||||
        cp["Proxy"]["proxy_url"] = ""
 | 
			
		||||
        cp["Proxy"]["proxy_user"] = ""
 | 
			
		||||
        cp["Proxy"]["proxy_password"] = ""
 | 
			
		||||
        with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
 | 
			
		||||
            cp.write(configfile)
 | 
			
		||||
    Проверяет наличие секции [Proxy] в конфигурационном файле.
 | 
			
		||||
    Если секция отсутствует, создаёт её с пустыми значениями.
 | 
			
		||||
    """
 | 
			
		||||
    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)
 | 
			
		||||
            return
 | 
			
		||||
        if not cp.has_section("Proxy"):
 | 
			
		||||
            cp.add_section("Proxy")
 | 
			
		||||
            cp["Proxy"]["proxy_url"] = ""
 | 
			
		||||
            cp["Proxy"]["proxy_user"] = ""
 | 
			
		||||
            cp["Proxy"]["proxy_password"] = ""
 | 
			
		||||
            with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
 | 
			
		||||
                cp.write(configfile)
 | 
			
		||||
 | 
			
		||||
def read_proxy_config():
 | 
			
		||||
    """Reads proxy settings from the [Proxy] section.
 | 
			
		||||
    Returns an empty dict if proxy_url is not set or empty.
 | 
			
		||||
    """
 | 
			
		||||
    Читает настройки прокси из секции [Proxy] конфигурационного файла.
 | 
			
		||||
    Если параметр proxy_url не задан или пустой, возвращает пустой словарь.
 | 
			
		||||
    """
 | 
			
		||||
    ensure_default_proxy_config()
 | 
			
		||||
    cp = read_config_safely(CONFIG_FILE)
 | 
			
		||||
    if cp is None:
 | 
			
		||||
    cp = configparser.ConfigParser()
 | 
			
		||||
    try:
 | 
			
		||||
        cp.read(CONFIG_FILE, encoding="utf-8")
 | 
			
		||||
    except Exception as e:
 | 
			
		||||
        logger.error("Ошибка чтения конфигурационного файла: %s", e)
 | 
			
		||||
        return {}
 | 
			
		||||
 | 
			
		||||
    proxy_url = cp.get("Proxy", "proxy_url", fallback="").strip()
 | 
			
		||||
    if proxy_url:
 | 
			
		||||
        # Если указаны логин и пароль, добавляем их к URL
 | 
			
		||||
        proxy_user = cp.get("Proxy", "proxy_user", fallback="").strip()
 | 
			
		||||
        proxy_password = cp.get("Proxy", "proxy_password", fallback="").strip()
 | 
			
		||||
        if "://" in proxy_url and "@" not in proxy_url and proxy_user and proxy_password:
 | 
			
		||||
@@ -291,10 +402,16 @@ def read_proxy_config():
 | 
			
		||||
    return {}
 | 
			
		||||
 | 
			
		||||
def save_proxy_config(proxy_url="", proxy_user="", proxy_password=""):
 | 
			
		||||
    """Saves proxy settings to the [Proxy] section.
 | 
			
		||||
    Creates the section if it does not exist.
 | 
			
		||||
    """
 | 
			
		||||
    cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser()
 | 
			
		||||
    Сохраняет настройки proxy в секцию [Proxy] конфигурационного файла.
 | 
			
		||||
    Если секция отсутствует, создаёт её.
 | 
			
		||||
    """
 | 
			
		||||
    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 "Proxy" not in cp:
 | 
			
		||||
        cp["Proxy"] = {}
 | 
			
		||||
    cp["Proxy"]["proxy_url"] = proxy_url
 | 
			
		||||
@@ -304,18 +421,34 @@ def save_proxy_config(proxy_url="", proxy_user="", proxy_password=""):
 | 
			
		||||
        cp.write(configfile)
 | 
			
		||||
 | 
			
		||||
def read_fullscreen_config():
 | 
			
		||||
    """Reads the fullscreen mode setting from the [Display] section.
 | 
			
		||||
    Returns False if the parameter is missing.
 | 
			
		||||
    """
 | 
			
		||||
    cp = read_config_safely(CONFIG_FILE)
 | 
			
		||||
    if cp is None or not cp.has_section("Display") or not cp.has_option("Display", "fullscreen"):
 | 
			
		||||
        save_fullscreen_config(False)
 | 
			
		||||
        return False
 | 
			
		||||
    return cp.getboolean("Display", "fullscreen", fallback=False)
 | 
			
		||||
    Читает настройку полноэкранного режима приложения из секции [Display].
 | 
			
		||||
    Если параметр отсутствует, сохраняет и возвращает 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_fullscreen_config(False)
 | 
			
		||||
            return False
 | 
			
		||||
        if not cp.has_section("Display") or not cp.has_option("Display", "fullscreen"):
 | 
			
		||||
            save_fullscreen_config(False)
 | 
			
		||||
            return False
 | 
			
		||||
        return cp.getboolean("Display", "fullscreen", fallback=False)
 | 
			
		||||
    return False
 | 
			
		||||
 | 
			
		||||
def save_fullscreen_config(fullscreen):
 | 
			
		||||
    """Saves the fullscreen mode setting to the [Display] section."""
 | 
			
		||||
    cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser()
 | 
			
		||||
    """
 | 
			
		||||
    Сохраняет настройку полноэкранного режима приложения в секцию [Display].
 | 
			
		||||
    """
 | 
			
		||||
    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 "Display" not in cp:
 | 
			
		||||
        cp["Display"] = {}
 | 
			
		||||
    cp["Display"]["fullscreen"] = str(fullscreen)
 | 
			
		||||
@@ -323,19 +456,33 @@ def save_fullscreen_config(fullscreen):
 | 
			
		||||
        cp.write(configfile)
 | 
			
		||||
 | 
			
		||||
def read_window_geometry() -> tuple[int, int]:
 | 
			
		||||
    """Reads the window width and height from the [MainWindow] section.
 | 
			
		||||
    Returns (0, 0) if the parameters are missing.
 | 
			
		||||
    """
 | 
			
		||||
    cp = read_config_safely(CONFIG_FILE)
 | 
			
		||||
    if cp is None or not cp.has_section("MainWindow"):
 | 
			
		||||
        return (0, 0)
 | 
			
		||||
    width = cp.getint("MainWindow", "width", fallback=0)
 | 
			
		||||
    height = cp.getint("MainWindow", "height", fallback=0)
 | 
			
		||||
    return (width, height)
 | 
			
		||||
    Читает ширину и высоту окна из секции [MainWindow] конфигурационного файла.
 | 
			
		||||
    Возвращает кортеж (width, height). Если данные отсутствуют, возвращает (0, 0).
 | 
			
		||||
    """
 | 
			
		||||
    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)
 | 
			
		||||
            return (0, 0)
 | 
			
		||||
        if cp.has_section("MainWindow"):
 | 
			
		||||
            width = cp.getint("MainWindow", "width", fallback=0)
 | 
			
		||||
            height = cp.getint("MainWindow", "height", fallback=0)
 | 
			
		||||
            return (width, height)
 | 
			
		||||
    return (0, 0)
 | 
			
		||||
 | 
			
		||||
def save_window_geometry(width: int, height: int):
 | 
			
		||||
    """Saves the window width and height to the [MainWindow] section."""
 | 
			
		||||
    cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser()
 | 
			
		||||
    """
 | 
			
		||||
    Сохраняет ширину и высоту окна в секцию [MainWindow] конфигурационного файла.
 | 
			
		||||
    """
 | 
			
		||||
    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 "MainWindow" not in cp:
 | 
			
		||||
        cp["MainWindow"] = {}
 | 
			
		||||
    cp["MainWindow"]["width"] = str(width)
 | 
			
		||||
@@ -344,67 +491,61 @@ def save_window_geometry(width: int, height: int):
 | 
			
		||||
        cp.write(configfile)
 | 
			
		||||
 | 
			
		||||
def reset_config():
 | 
			
		||||
    """Resets the configuration file by deleting it.
 | 
			
		||||
    Subsequent reads will use default values.
 | 
			
		||||
    """
 | 
			
		||||
    Сбрасывает конфигурационный файл, удаляя его.
 | 
			
		||||
    После этого все настройки будут возвращены к значениям по умолчанию при следующем чтении.
 | 
			
		||||
    """
 | 
			
		||||
    if os.path.exists(CONFIG_FILE):
 | 
			
		||||
        try:
 | 
			
		||||
            os.remove(CONFIG_FILE)
 | 
			
		||||
            logger.info("Configuration file %s deleted", CONFIG_FILE)
 | 
			
		||||
            logger.info("Конфигурационный файл %s удалён", CONFIG_FILE)
 | 
			
		||||
        except Exception as e:
 | 
			
		||||
            logger.warning(f"Failed to delete configuration file: {e}")
 | 
			
		||||
            logger.error("Ошибка при удалении конфигурационного файла: %s", e)
 | 
			
		||||
 | 
			
		||||
def clear_cache():
 | 
			
		||||
    """Clears the PortProtonQt cache by deleting the cache directory."""
 | 
			
		||||
    """
 | 
			
		||||
    Очищает кэш 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")
 | 
			
		||||
    if os.path.exists(cache_dir):
 | 
			
		||||
        try:
 | 
			
		||||
            shutil.rmtree(cache_dir)
 | 
			
		||||
            logger.info("PortProtonQt cache deleted: %s", cache_dir)
 | 
			
		||||
            logger.info("Кэш PortProtonQt удалён: %s", cache_dir)
 | 
			
		||||
        except Exception as e:
 | 
			
		||||
            logger.warning(f"Failed to delete cache: {e}")
 | 
			
		||||
            logger.error("Ошибка при удалении кэша: %s", e)
 | 
			
		||||
 | 
			
		||||
def read_auto_fullscreen_gamepad():
 | 
			
		||||
    """Reads the auto-fullscreen setting for gamepad from the [Display] section.
 | 
			
		||||
    Returns False if the parameter is missing.
 | 
			
		||||
    """
 | 
			
		||||
    cp = read_config_safely(CONFIG_FILE)
 | 
			
		||||
    if cp is None or not cp.has_section("Display") or not cp.has_option("Display", "auto_fullscreen_gamepad"):
 | 
			
		||||
        save_auto_fullscreen_gamepad(False)
 | 
			
		||||
        return False
 | 
			
		||||
    return cp.getboolean("Display", "auto_fullscreen_gamepad", fallback=False)
 | 
			
		||||
    Читает настройку автоматического полноэкранного режима при подключении геймпада из секции [Display].
 | 
			
		||||
    Если параметр отсутствует, сохраняет и возвращает 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_auto_fullscreen_gamepad(False)
 | 
			
		||||
            return False
 | 
			
		||||
        if not cp.has_section("Display") or not cp.has_option("Display", "auto_fullscreen_gamepad"):
 | 
			
		||||
            save_auto_fullscreen_gamepad(False)
 | 
			
		||||
            return False
 | 
			
		||||
        return cp.getboolean("Display", "auto_fullscreen_gamepad", fallback=False)
 | 
			
		||||
    return False
 | 
			
		||||
 | 
			
		||||
def save_auto_fullscreen_gamepad(auto_fullscreen):
 | 
			
		||||
    """Saves the auto-fullscreen setting for gamepad to the [Display] section."""
 | 
			
		||||
    cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser()
 | 
			
		||||
    """
 | 
			
		||||
    Сохраняет настройку автоматического полноэкранного режима при подключении геймпада в секцию [Display].
 | 
			
		||||
    """
 | 
			
		||||
    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 "Display" not in cp:
 | 
			
		||||
        cp["Display"] = {}
 | 
			
		||||
    cp["Display"]["auto_fullscreen_gamepad"] = str(auto_fullscreen)
 | 
			
		||||
    with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
 | 
			
		||||
        cp.write(configfile)
 | 
			
		||||
 | 
			
		||||
def read_favorite_folders():
 | 
			
		||||
    """Reads the list of favorite folders from the [FavoritesFolders] section.
 | 
			
		||||
    The list is stored as a quoted string with comma-separated paths.
 | 
			
		||||
    Returns an empty list if the section or parameter is missing.
 | 
			
		||||
    """
 | 
			
		||||
    cp = read_config_safely(CONFIG_FILE)
 | 
			
		||||
    if cp is None or not cp.has_section("FavoritesFolders") or not cp.has_option("FavoritesFolders", "folders"):
 | 
			
		||||
        return []
 | 
			
		||||
    favs = cp.get("FavoritesFolders", "folders", fallback="").strip()
 | 
			
		||||
    if favs.startswith('"') and favs.endswith('"'):
 | 
			
		||||
        favs = favs[1:-1]
 | 
			
		||||
    return [os.path.normpath(s.strip()) for s in favs.split(",") if s.strip() and os.path.isdir(os.path.normpath(s.strip()))]
 | 
			
		||||
 | 
			
		||||
def save_favorite_folders(folders):
 | 
			
		||||
    """Saves the list of favorite folders to the [FavoritesFolders] section.
 | 
			
		||||
    The list is stored as a quoted string with comma-separated paths.
 | 
			
		||||
    """
 | 
			
		||||
    cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser()
 | 
			
		||||
    if "FavoritesFolders" not in cp:
 | 
			
		||||
        cp["FavoritesFolders"] = {}
 | 
			
		||||
    fav_str = ", ".join([os.path.normpath(folder) for folder in folders])
 | 
			
		||||
    cp["FavoritesFolders"]["folders"] = f'"{fav_str}"'
 | 
			
		||||
    with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
 | 
			
		||||
        cp.write(configfile)
 | 
			
		||||
 
 | 
			
		||||
@@ -4,6 +4,7 @@ import glob
 | 
			
		||||
import shutil
 | 
			
		||||
import subprocess
 | 
			
		||||
import threading
 | 
			
		||||
import logging
 | 
			
		||||
import orjson
 | 
			
		||||
import psutil
 | 
			
		||||
import signal
 | 
			
		||||
@@ -11,14 +12,13 @@ from PySide6.QtWidgets import QMessageBox, QDialog, QMenu, QLineEdit, QApplicati
 | 
			
		||||
from PySide6.QtCore import QUrl, QPoint, QObject, Signal, Qt
 | 
			
		||||
from PySide6.QtGui import QDesktopServices, QIcon, QKeySequence
 | 
			
		||||
from portprotonqt.localization import _
 | 
			
		||||
from portprotonqt.config_utils import parse_desktop_entry, read_favorites, save_favorites, read_favorite_folders, save_favorite_folders
 | 
			
		||||
from portprotonqt.config_utils import parse_desktop_entry, read_favorites, save_favorites
 | 
			
		||||
from portprotonqt.steam_api import is_game_in_steam, add_to_steam, remove_from_steam
 | 
			
		||||
from portprotonqt.egs_api import add_egs_to_steam, get_egs_executable, remove_egs_from_steam
 | 
			
		||||
from portprotonqt.dialogs import AddGameDialog, FileExplorer, generate_thumbnail
 | 
			
		||||
from portprotonqt.theme_manager import ThemeManager
 | 
			
		||||
from portprotonqt.logger import get_logger
 | 
			
		||||
 | 
			
		||||
logger = get_logger(__name__)
 | 
			
		||||
logger = logging.getLogger(__name__)
 | 
			
		||||
 | 
			
		||||
class ContextMenuSignals(QObject):
 | 
			
		||||
    """Signals for thread-safe UI updates from worker threads."""
 | 
			
		||||
@@ -150,84 +150,6 @@ class ContextMenuManager:
 | 
			
		||||
 | 
			
		||||
        return hasattr(self.parent, 'target_exe') and self.parent.target_exe == current_exe
 | 
			
		||||
 | 
			
		||||
    def show_folder_context_menu(self, file_explorer, pos):
 | 
			
		||||
        """Shows the context menu for a folder in FileExplorer."""
 | 
			
		||||
        try:
 | 
			
		||||
            item = file_explorer.file_list.itemAt(pos)
 | 
			
		||||
            if not item:
 | 
			
		||||
                logger.debug("No item selected at position %s", pos)
 | 
			
		||||
                return
 | 
			
		||||
            selected = item.text()
 | 
			
		||||
            if not selected.endswith("/"):
 | 
			
		||||
                logger.debug("Selected item is not a folder: %s", selected)
 | 
			
		||||
                return  # Only for folders
 | 
			
		||||
            full_path = os.path.normpath(os.path.join(file_explorer.current_path, selected.rstrip("/")))
 | 
			
		||||
            if not os.path.isdir(full_path):
 | 
			
		||||
                logger.debug("Path is not a directory: %s", full_path)
 | 
			
		||||
                return
 | 
			
		||||
 | 
			
		||||
            menu = QMenu(file_explorer)
 | 
			
		||||
            menu.setStyleSheet(self.theme.CONTEXT_MENU_STYLE)
 | 
			
		||||
            menu.setParent(file_explorer, Qt.WindowType.Popup)  # Set transientParent for Wayland
 | 
			
		||||
 | 
			
		||||
            favorite_folders = read_favorite_folders()
 | 
			
		||||
            is_favorite = full_path in favorite_folders
 | 
			
		||||
            action_text = _("Remove from Favorites") if is_favorite else _("Add to Favorites")
 | 
			
		||||
            favorite_action = menu.addAction(self._get_safe_icon("star" if is_favorite else "star_full"), action_text)
 | 
			
		||||
            favorite_action.triggered.connect(lambda: self.toggle_favorite_folder(file_explorer, full_path, not is_favorite))
 | 
			
		||||
 | 
			
		||||
            # Disconnect file_list signals to prevent navigation during menu interaction
 | 
			
		||||
            try:
 | 
			
		||||
                file_explorer.file_list.itemClicked.disconnect(file_explorer.handle_item_click)
 | 
			
		||||
                file_explorer.file_list.itemDoubleClicked.disconnect(file_explorer.handle_item_double_click)
 | 
			
		||||
            except TypeError:
 | 
			
		||||
                pass  # Signals may not be connected
 | 
			
		||||
 | 
			
		||||
            # Reconnect signals after menu closes
 | 
			
		||||
            def reconnect_signals():
 | 
			
		||||
                try:
 | 
			
		||||
                    file_explorer.file_list.itemClicked.connect(file_explorer.handle_item_click)
 | 
			
		||||
                    file_explorer.file_list.itemDoubleClicked.connect(file_explorer.handle_item_double_click)
 | 
			
		||||
                except Exception as e:
 | 
			
		||||
                    logger.error("Error reconnecting file list signals: %s", e)
 | 
			
		||||
 | 
			
		||||
            menu.aboutToHide.connect(reconnect_signals)
 | 
			
		||||
 | 
			
		||||
            # Set focus to the first menu item
 | 
			
		||||
            actions = menu.actions()
 | 
			
		||||
            if actions:
 | 
			
		||||
                menu.setActiveAction(actions[0])
 | 
			
		||||
 | 
			
		||||
            # Map local position to global for menu display
 | 
			
		||||
            global_pos = file_explorer.file_list.mapToGlobal(pos)
 | 
			
		||||
            menu.exec(global_pos)
 | 
			
		||||
        except Exception as e:
 | 
			
		||||
            logger.error("Error showing folder context menu: %s", e)
 | 
			
		||||
 | 
			
		||||
    def toggle_favorite_folder(self, file_explorer, folder_path, add):
 | 
			
		||||
        """Adds or removes a folder from favorites."""
 | 
			
		||||
        favorite_folders = read_favorite_folders()
 | 
			
		||||
        if add:
 | 
			
		||||
            if folder_path not in favorite_folders:
 | 
			
		||||
                favorite_folders.append(folder_path)
 | 
			
		||||
                save_favorite_folders(favorite_folders)
 | 
			
		||||
                logger.info(f"Folder added to favorites: {folder_path}")
 | 
			
		||||
        else:
 | 
			
		||||
            if folder_path in favorite_folders:
 | 
			
		||||
                favorite_folders.remove(folder_path)
 | 
			
		||||
                save_favorite_folders(favorite_folders)
 | 
			
		||||
                logger.info(f"Folder removed from favorites: {folder_path}")
 | 
			
		||||
        file_explorer.update_drives_list()
 | 
			
		||||
 | 
			
		||||
    def _get_safe_icon(self, icon_name: str) -> QIcon:
 | 
			
		||||
        """Returns a QIcon, ensuring it is valid."""
 | 
			
		||||
        icon = self.theme_manager.get_icon(icon_name)
 | 
			
		||||
        if isinstance(icon, QIcon):
 | 
			
		||||
            return icon
 | 
			
		||||
        elif isinstance(icon, str) and os.path.exists(icon):
 | 
			
		||||
            return QIcon(icon)
 | 
			
		||||
        return QIcon()
 | 
			
		||||
 | 
			
		||||
    def show_context_menu(self, game_card, pos: QPoint):
 | 
			
		||||
        """
 | 
			
		||||
        Show the context menu for a game card at the specified position.
 | 
			
		||||
@@ -236,6 +158,14 @@ class ContextMenuManager:
 | 
			
		||||
            game_card: The GameCard instance requesting the context menu.
 | 
			
		||||
            pos: The position (in widget coordinates) where the menu should appear.
 | 
			
		||||
        """
 | 
			
		||||
        def get_safe_icon(icon_name: str) -> QIcon:
 | 
			
		||||
            icon = self.theme_manager.get_icon(icon_name)
 | 
			
		||||
            if isinstance(icon, QIcon):
 | 
			
		||||
                return icon
 | 
			
		||||
            elif isinstance(icon, str) and os.path.exists(icon):
 | 
			
		||||
                return QIcon(icon)
 | 
			
		||||
            return QIcon()
 | 
			
		||||
 | 
			
		||||
        menu = QMenu(self.parent)
 | 
			
		||||
        menu.setStyleSheet(self.theme.CONTEXT_MENU_STYLE)
 | 
			
		||||
 | 
			
		||||
@@ -245,7 +175,7 @@ class ContextMenuManager:
 | 
			
		||||
            exe_path = self._parse_exe_path(exec_line, game_card.name) if exec_line else None
 | 
			
		||||
            if not exe_path:
 | 
			
		||||
                # Show only "Delete from PortProton" if no valid exe
 | 
			
		||||
                delete_action = menu.addAction(self._get_safe_icon("delete"), _("Delete from PortProton"))
 | 
			
		||||
                delete_action = menu.addAction(get_safe_icon("delete"), _("Delete from PortProton"))
 | 
			
		||||
                delete_action.triggered.connect(lambda: self.delete_game(game_card.name, game_card.exec_line))
 | 
			
		||||
                menu.exec(game_card.mapToGlobal(pos))
 | 
			
		||||
                return
 | 
			
		||||
@@ -254,7 +184,7 @@ class ContextMenuManager:
 | 
			
		||||
        is_running = self._is_game_running(game_card)
 | 
			
		||||
        action_text = _("Stop Game") if is_running else _("Launch Game")
 | 
			
		||||
        action_icon = "stop" if is_running else "play"
 | 
			
		||||
        launch_action = menu.addAction(self._get_safe_icon(action_icon), action_text)
 | 
			
		||||
        launch_action = menu.addAction(get_safe_icon(action_icon), action_text)
 | 
			
		||||
        launch_action.triggered.connect(
 | 
			
		||||
            lambda: self._launch_game(game_card)
 | 
			
		||||
        )
 | 
			
		||||
@@ -263,11 +193,11 @@ class ContextMenuManager:
 | 
			
		||||
        is_favorite = game_card.name in favorites
 | 
			
		||||
        icon_name = "star_full" if is_favorite else "star"
 | 
			
		||||
        text = _("Remove from Favorites") if is_favorite else _("Add to Favorites")
 | 
			
		||||
        favorite_action = menu.addAction(self._get_safe_icon(icon_name), text)
 | 
			
		||||
        favorite_action = menu.addAction(get_safe_icon(icon_name), text)
 | 
			
		||||
        favorite_action.triggered.connect(lambda: self.toggle_favorite(game_card, not is_favorite))
 | 
			
		||||
 | 
			
		||||
        if game_card.game_source == "epic":
 | 
			
		||||
            import_action = menu.addAction(self._get_safe_icon("epic_games"), _("Import to Legendary"))
 | 
			
		||||
            import_action = menu.addAction(get_safe_icon("epic_games"), _("Import to Legendary"))
 | 
			
		||||
            import_action.triggered.connect(
 | 
			
		||||
                lambda: self.import_to_legendary(game_card.name, game_card.appid)
 | 
			
		||||
            )
 | 
			
		||||
@@ -275,13 +205,13 @@ class ContextMenuManager:
 | 
			
		||||
                is_in_steam = is_game_in_steam(game_card.name)
 | 
			
		||||
                icon_name = "delete" if is_in_steam else "steam"
 | 
			
		||||
                text = _("Remove from Steam") if is_in_steam else _("Add to Steam")
 | 
			
		||||
                steam_action = menu.addAction(self._get_safe_icon(icon_name), text)
 | 
			
		||||
                steam_action = menu.addAction(get_safe_icon(icon_name), text)
 | 
			
		||||
                steam_action.triggered.connect(
 | 
			
		||||
                    lambda: self.remove_from_steam(game_card.name, game_card.exec_line, game_card.game_source)
 | 
			
		||||
                    if is_in_steam
 | 
			
		||||
                    else self.add_egs_to_steam(game_card.name, game_card.appid)
 | 
			
		||||
                )
 | 
			
		||||
                open_folder_action = menu.addAction(self._get_safe_icon("search"), _("Open Game Folder"))
 | 
			
		||||
                open_folder_action = menu.addAction(get_safe_icon("search"), _("Open Game Folder"))
 | 
			
		||||
                open_folder_action.triggered.connect(
 | 
			
		||||
                    lambda: self.open_egs_game_folder(game_card.appid)
 | 
			
		||||
                )
 | 
			
		||||
@@ -289,7 +219,7 @@ class ContextMenuManager:
 | 
			
		||||
                desktop_path = os.path.join(desktop_dir, f"{game_card.name}.desktop")
 | 
			
		||||
                icon_name = "delete" if os.path.exists(desktop_path) else "desktop"
 | 
			
		||||
                text = _("Remove from Desktop") if os.path.exists(desktop_path) else _("Add to Desktop")
 | 
			
		||||
                desktop_action = menu.addAction(self._get_safe_icon(icon_name), text)
 | 
			
		||||
                desktop_action = menu.addAction(get_safe_icon(icon_name), text)
 | 
			
		||||
                desktop_action.triggered.connect(
 | 
			
		||||
                    lambda: self.remove_egs_from_desktop(game_card.name)
 | 
			
		||||
                    if os.path.exists(desktop_path)
 | 
			
		||||
@@ -298,7 +228,7 @@ class ContextMenuManager:
 | 
			
		||||
                applications_dir = os.path.join(os.path.expanduser("~"), ".local", "share", "applications")
 | 
			
		||||
                menu_path = os.path.join(applications_dir, f"{game_card.name}.desktop")
 | 
			
		||||
                menu_action = menu.addAction(
 | 
			
		||||
                    self._get_safe_icon("delete" if os.path.exists(menu_path) else "menu"),
 | 
			
		||||
                    get_safe_icon("delete" if os.path.exists(menu_path) else "menu"),
 | 
			
		||||
                    _("Remove from Menu") if os.path.exists(menu_path) else _("Add to Menu")
 | 
			
		||||
                )
 | 
			
		||||
                menu_action.triggered.connect(
 | 
			
		||||
@@ -312,19 +242,19 @@ class ContextMenuManager:
 | 
			
		||||
            desktop_path = os.path.join(desktop_dir, f"{game_card.name}.desktop")
 | 
			
		||||
            icon_name = "delete" if os.path.exists(desktop_path) else "desktop"
 | 
			
		||||
            text = _("Remove from Desktop") if os.path.exists(desktop_path) else _("Add to Desktop")
 | 
			
		||||
            desktop_action = menu.addAction(self._get_safe_icon(icon_name), text)
 | 
			
		||||
            desktop_action = menu.addAction(get_safe_icon(icon_name), text)
 | 
			
		||||
            desktop_action.triggered.connect(
 | 
			
		||||
                lambda: self.remove_from_desktop(game_card.name)
 | 
			
		||||
                if os.path.exists(desktop_path)
 | 
			
		||||
                else self.add_to_desktop(game_card.name, game_card.exec_line)
 | 
			
		||||
            )
 | 
			
		||||
            edit_action = menu.addAction(self._get_safe_icon("edit"), _("Edit Shortcut"))
 | 
			
		||||
            edit_action = menu.addAction(get_safe_icon("edit"), _("Edit Shortcut"))
 | 
			
		||||
            edit_action.triggered.connect(
 | 
			
		||||
                lambda: self.edit_game_shortcut(game_card.name, game_card.exec_line, game_card.cover_path)
 | 
			
		||||
            )
 | 
			
		||||
            delete_action = menu.addAction(self._get_safe_icon("delete"), _("Delete from PortProton"))
 | 
			
		||||
            delete_action = menu.addAction(get_safe_icon("delete"), _("Delete from PortProton"))
 | 
			
		||||
            delete_action.triggered.connect(lambda: self.delete_game(game_card.name, game_card.exec_line))
 | 
			
		||||
            open_folder_action = menu.addAction(self._get_safe_icon("search"), _("Open Game Folder"))
 | 
			
		||||
            open_folder_action = menu.addAction(get_safe_icon("search"), _("Open Game Folder"))
 | 
			
		||||
            open_folder_action.triggered.connect(
 | 
			
		||||
                lambda: self.open_game_folder(game_card.name, game_card.exec_line)
 | 
			
		||||
            )
 | 
			
		||||
@@ -332,7 +262,7 @@ class ContextMenuManager:
 | 
			
		||||
            menu_path = os.path.join(applications_dir, f"{game_card.name}.desktop")
 | 
			
		||||
            icon_name = "delete" if os.path.exists(menu_path) else "menu"
 | 
			
		||||
            text = _("Remove from Menu") if os.path.exists(menu_path) else _("Add to Menu")
 | 
			
		||||
            menu_action = menu.addAction(self._get_safe_icon(icon_name), text)
 | 
			
		||||
            menu_action = menu.addAction(get_safe_icon(icon_name), text)
 | 
			
		||||
            menu_action.triggered.connect(
 | 
			
		||||
                lambda: self.remove_from_menu(game_card.name)
 | 
			
		||||
                if os.path.exists(menu_path)
 | 
			
		||||
@@ -341,7 +271,7 @@ class ContextMenuManager:
 | 
			
		||||
            is_in_steam = is_game_in_steam(game_card.name)
 | 
			
		||||
            icon_name = "delete" if is_in_steam else "steam"
 | 
			
		||||
            text = _("Remove from Steam") if is_in_steam else _("Add to Steam")
 | 
			
		||||
            steam_action = menu.addAction(self._get_safe_icon(icon_name), text)
 | 
			
		||||
            steam_action = menu.addAction(get_safe_icon(icon_name), text)
 | 
			
		||||
            steam_action.triggered.connect(
 | 
			
		||||
                lambda: (
 | 
			
		||||
                    self.remove_from_steam(game_card.name, game_card.exec_line, game_card.game_source)
 | 
			
		||||
@@ -350,7 +280,7 @@ class ContextMenuManager:
 | 
			
		||||
                )
 | 
			
		||||
            )
 | 
			
		||||
 | 
			
		||||
        # Set focus to the first menu item
 | 
			
		||||
        # Устанавливаем фокус на первый элемент меню
 | 
			
		||||
        actions = menu.actions()
 | 
			
		||||
        if actions:
 | 
			
		||||
            menu.setActiveAction(actions[0])
 | 
			
		||||
@@ -492,7 +422,7 @@ class ContextMenuManager:
 | 
			
		||||
            )
 | 
			
		||||
            return
 | 
			
		||||
 | 
			
		||||
        # Use FileExplorer with directory_only=True
 | 
			
		||||
        # Используем FileExplorer с directory_only=True
 | 
			
		||||
        file_explorer = FileExplorer(
 | 
			
		||||
            parent=self.parent,
 | 
			
		||||
            theme=self.theme,
 | 
			
		||||
@@ -522,10 +452,10 @@ class ContextMenuManager:
 | 
			
		||||
            self._show_status_message(_("Importing '{game_name}' to Legendary...").format(game_name=game_name))
 | 
			
		||||
            threading.Thread(target=run_import, daemon=True).start()
 | 
			
		||||
 | 
			
		||||
        # Connect the file selection signal
 | 
			
		||||
        # Подключаем сигнал выбора файла/папки
 | 
			
		||||
        file_explorer.file_signal.file_selected.connect(on_folder_selected)
 | 
			
		||||
 | 
			
		||||
        # Center FileExplorer relative to the parent widget
 | 
			
		||||
        # Центрируем FileExplorer относительно родительского виджета
 | 
			
		||||
        parent_widget = self.parent
 | 
			
		||||
        if parent_widget:
 | 
			
		||||
            parent_geometry = parent_widget.geometry()
 | 
			
		||||
@@ -859,7 +789,7 @@ Icon={icon_path}
 | 
			
		||||
                        _("Failed to delete custom data: {error}").format(error=str(e))
 | 
			
		||||
                    )
 | 
			
		||||
 | 
			
		||||
        # Reload games list and update grid
 | 
			
		||||
        # Перезагрузка списка игр и обновление сетки
 | 
			
		||||
        self.load_games()
 | 
			
		||||
        self.update_game_grid()
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -8,8 +8,8 @@ def compute_layout(nat_sizes, rect_width, spacing, max_scale):
 | 
			
		||||
    Вычисляет расположение элементов с учетом отступов и возможного увеличения карточек.
 | 
			
		||||
    nat_sizes: массив (N, 2) с натуральными размерами элементов (ширина, высота).
 | 
			
		||||
    rect_width: доступная ширина контейнера.
 | 
			
		||||
    spacing: отступ между элементами (горизонтальный и вертикальный).
 | 
			
		||||
    max_scale: максимальный коэффициент масштабирования (например, 1.0).
 | 
			
		||||
    spacing: отступ между элементами.
 | 
			
		||||
    max_scale: максимальный коэффициент масштабирования (например, 1.2).
 | 
			
		||||
 | 
			
		||||
    Возвращает:
 | 
			
		||||
      result: массив (N, 4), где каждая строка содержит [x, y, new_width, new_height].
 | 
			
		||||
@@ -19,49 +19,16 @@ def compute_layout(nat_sizes, rect_width, spacing, max_scale):
 | 
			
		||||
    result = np.zeros((N, 4), dtype=np.int32)
 | 
			
		||||
    y = 0
 | 
			
		||||
    i = 0
 | 
			
		||||
    min_margin = 20  # Минимальный отступ по краям
 | 
			
		||||
 | 
			
		||||
    # Определяем максимальное количество элементов в ряду и общий масштаб
 | 
			
		||||
    max_items_per_row = 0
 | 
			
		||||
    global_scale = 1.0
 | 
			
		||||
    max_row_x_start = min_margin  # Начальная позиция x самого длинного ряда
 | 
			
		||||
    temp_i = 0
 | 
			
		||||
 | 
			
		||||
    # Первый проход: находим максимальное количество элементов в ряду
 | 
			
		||||
    while temp_i < N:
 | 
			
		||||
        sum_width = 0
 | 
			
		||||
        count = 0
 | 
			
		||||
        temp_j = temp_i
 | 
			
		||||
        while temp_j < N:
 | 
			
		||||
            w = nat_sizes[temp_j, 0]
 | 
			
		||||
            if count > 0 and (sum_width + spacing + w) > rect_width - 2 * min_margin:
 | 
			
		||||
                break
 | 
			
		||||
            sum_width += w
 | 
			
		||||
            count += 1
 | 
			
		||||
            temp_j += 1
 | 
			
		||||
 | 
			
		||||
        if count > max_items_per_row:
 | 
			
		||||
            max_items_per_row = count
 | 
			
		||||
            # Вычисляем масштаб для самого заполненного ряда
 | 
			
		||||
            available_width = rect_width - spacing * (count - 1) - 2 * min_margin
 | 
			
		||||
            desired_scale = available_width / sum_width if sum_width > 0 else 1.0
 | 
			
		||||
            global_scale = desired_scale if desired_scale < max_scale else max_scale
 | 
			
		||||
            # Сохраняем начальную позицию x для самого длинного ряда
 | 
			
		||||
            scaled_row_width = int(sum_width * global_scale) + spacing * (count - 1)
 | 
			
		||||
            max_row_x_start = max(min_margin, (rect_width - scaled_row_width) // 2)
 | 
			
		||||
        temp_i = temp_j
 | 
			
		||||
 | 
			
		||||
    # Второй проход: размещаем элементы
 | 
			
		||||
    while i < N:
 | 
			
		||||
        sum_width = 0
 | 
			
		||||
        row_max_height = 0
 | 
			
		||||
        count = 0
 | 
			
		||||
        j = i
 | 
			
		||||
 | 
			
		||||
        # Подбираем количество элементов для текущего ряда
 | 
			
		||||
        while j < N:
 | 
			
		||||
            w = nat_sizes[j, 0]
 | 
			
		||||
            if count > 0 and (sum_width + spacing + w) > rect_width - 2 * min_margin:
 | 
			
		||||
            # Если уже есть хотя бы один элемент и следующий не помещается с учетом spacing, выходим
 | 
			
		||||
            if count > 0 and (sum_width + spacing + w) > rect_width:
 | 
			
		||||
                break
 | 
			
		||||
            sum_width += w
 | 
			
		||||
            count += 1
 | 
			
		||||
@@ -69,19 +36,13 @@ def compute_layout(nat_sizes, rect_width, spacing, max_scale):
 | 
			
		||||
            if h > row_max_height:
 | 
			
		||||
                row_max_height = h
 | 
			
		||||
            j += 1
 | 
			
		||||
 | 
			
		||||
        # Используем глобальный масштаб для всех рядов
 | 
			
		||||
        scale = global_scale
 | 
			
		||||
        scaled_row_width = int(sum_width * scale) + spacing * (count - 1)
 | 
			
		||||
 | 
			
		||||
        # Определяем начальную координату x
 | 
			
		||||
        if count == max_items_per_row:
 | 
			
		||||
            # Центрируем полный ряд
 | 
			
		||||
            x = max(min_margin, (rect_width - scaled_row_width) // 2)
 | 
			
		||||
        else:
 | 
			
		||||
            # Выравниваем неполный ряд по левому краю, совмещая с началом самого длинного ряда
 | 
			
		||||
            x = max_row_x_start
 | 
			
		||||
 | 
			
		||||
        # Доступная ширина ряда с учетом обязательных отступов между элементами
 | 
			
		||||
        available_width = rect_width - spacing * (count - 1)
 | 
			
		||||
        desired_scale = available_width / sum_width if sum_width > 0 else 1.0
 | 
			
		||||
        # Разрешаем увеличение карточек, но не более max_scale
 | 
			
		||||
        scale = desired_scale if desired_scale < max_scale else max_scale
 | 
			
		||||
        # Выравниваем по левому краю (offset = 0)
 | 
			
		||||
        x = 0
 | 
			
		||||
        for k in range(i, j):
 | 
			
		||||
            new_w = int(nat_sizes[k, 0] * scale)
 | 
			
		||||
            new_h = int(nat_sizes[k, 1] * scale)
 | 
			
		||||
@@ -90,7 +51,6 @@ def compute_layout(nat_sizes, rect_width, spacing, max_scale):
 | 
			
		||||
            result[k, 2] = new_w
 | 
			
		||||
            result[k, 3] = new_h
 | 
			
		||||
            x += new_w + spacing
 | 
			
		||||
 | 
			
		||||
        y += int(row_max_height * scale) + spacing
 | 
			
		||||
        i = j
 | 
			
		||||
    return result, y
 | 
			
		||||
@@ -99,17 +59,18 @@ class FlowLayout(QLayout):
 | 
			
		||||
    def __init__(self, parent=None):
 | 
			
		||||
        super().__init__(parent)
 | 
			
		||||
        self.itemList = []
 | 
			
		||||
        self.setContentsMargins(20, 20, 20, 20)  # Отступы по краям
 | 
			
		||||
        self._spacing = 20  # Отступ для анимации и предотвращения перекрытий
 | 
			
		||||
        self._max_scale = 1.0  # Отключено масштабирование в layout
 | 
			
		||||
        # Устанавливаем отступы контейнера в 0 и задаем spacing между карточками
 | 
			
		||||
        self.setContentsMargins(0, 0, 0, 0)
 | 
			
		||||
        self._spacing = 3  # отступ между карточками
 | 
			
		||||
        self._max_scale = 1.2  # максимальное увеличение карточек (например, на 20%)
 | 
			
		||||
 | 
			
		||||
    def addItem(self, item: QLayoutItem) -> None:
 | 
			
		||||
        self.itemList.append(item)
 | 
			
		||||
            self.itemList.append(item)
 | 
			
		||||
 | 
			
		||||
    def takeAt(self, index: int) -> QLayoutItem:
 | 
			
		||||
        if 0 <= index < len(self.itemList):
 | 
			
		||||
            return self.itemList.pop(index)
 | 
			
		||||
        raise IndexError("Index out of range")
 | 
			
		||||
            if 0 <= index < len(self.itemList):
 | 
			
		||||
                return self.itemList.pop(index)
 | 
			
		||||
            raise IndexError("Index out of range")
 | 
			
		||||
 | 
			
		||||
    def count(self) -> int:
 | 
			
		||||
        return len(self.itemList)
 | 
			
		||||
@@ -141,7 +102,7 @@ class FlowLayout(QLayout):
 | 
			
		||||
            size = size.expandedTo(item.minimumSize())
 | 
			
		||||
        margins = self.contentsMargins()
 | 
			
		||||
        size += QSize(margins.left() + margins.right(),
 | 
			
		||||
                      margins.top() + margins.bottom())
 | 
			
		||||
                             margins.top() + margins.bottom())
 | 
			
		||||
        return size
 | 
			
		||||
 | 
			
		||||
    def doLayout(self, rect, testOnly):
 | 
			
		||||
@@ -149,12 +110,14 @@ class FlowLayout(QLayout):
 | 
			
		||||
        if N == 0:
 | 
			
		||||
            return 0
 | 
			
		||||
 | 
			
		||||
        # Собираем натуральные размеры всех элементов в массив NumPy
 | 
			
		||||
        nat_sizes = np.empty((N, 2), dtype=np.int32)
 | 
			
		||||
        for i, item in enumerate(self.itemList):
 | 
			
		||||
            s = item.sizeHint()
 | 
			
		||||
            nat_sizes[i, 0] = s.width()
 | 
			
		||||
            nat_sizes[i, 1] = s.height()
 | 
			
		||||
 | 
			
		||||
        # Вычисляем геометрию с учетом spacing и max_scale через numba-функцию
 | 
			
		||||
        geom_array, total_height = compute_layout(nat_sizes, rect.width(), self._spacing, self._max_scale)
 | 
			
		||||
 | 
			
		||||
        if not testOnly:
 | 
			
		||||
@@ -189,7 +152,7 @@ class ClickableLabel(QLabel):
 | 
			
		||||
        self._icon_size = icon_size
 | 
			
		||||
        self._icon_space = icon_space
 | 
			
		||||
        self._font_scale_factor = font_scale_factor
 | 
			
		||||
        self._card_width = 250
 | 
			
		||||
        self._card_width = 250  # Значение по умолчанию
 | 
			
		||||
        if change_cursor:
 | 
			
		||||
            self.setCursor(Qt.CursorShape.PointingHandCursor)
 | 
			
		||||
        self.updateFontSize()
 | 
			
		||||
@@ -207,23 +170,28 @@ class ClickableLabel(QLabel):
 | 
			
		||||
        self.update()
 | 
			
		||||
 | 
			
		||||
    def setCardWidth(self, card_width: int):
 | 
			
		||||
        """Обновляет ширину карточки и пересчитывает размер шрифта."""
 | 
			
		||||
        self._card_width = card_width
 | 
			
		||||
        self.updateFontSize()
 | 
			
		||||
 | 
			
		||||
    def updateFontSize(self):
 | 
			
		||||
        """Обновляет размер шрифта на основе card_width и font_scale_factor."""
 | 
			
		||||
        font = self.font()
 | 
			
		||||
        font_size = int(self._card_width * self._font_scale_factor)
 | 
			
		||||
        font.setPointSize(max(8, font_size))
 | 
			
		||||
        font.setPointSize(max(8, font_size))  # Минимальный размер шрифта 8
 | 
			
		||||
        self.setFont(font)
 | 
			
		||||
        self.update()
 | 
			
		||||
 | 
			
		||||
    def paintEvent(self, event):
 | 
			
		||||
        painter = QPainter(self)
 | 
			
		||||
        painter.setRenderHint(QPainter.RenderHint.Antialiasing)
 | 
			
		||||
 | 
			
		||||
        rect = self.contentsRect()
 | 
			
		||||
        alignment = self.alignment()
 | 
			
		||||
 | 
			
		||||
        icon_size = self._icon_size
 | 
			
		||||
        spacing = self._icon_space
 | 
			
		||||
 | 
			
		||||
        text = self.text()
 | 
			
		||||
 | 
			
		||||
        if self._icon:
 | 
			
		||||
@@ -232,11 +200,17 @@ class ClickableLabel(QLabel):
 | 
			
		||||
            pixmap = None
 | 
			
		||||
 | 
			
		||||
        fm = QFontMetrics(self.font())
 | 
			
		||||
 | 
			
		||||
        # Считаем, сколько места остаётся под текст
 | 
			
		||||
        available_width = rect.width()
 | 
			
		||||
        if pixmap:
 | 
			
		||||
            available_width -= (icon_size + spacing)
 | 
			
		||||
        # Отступы по 2px с каждой стороны
 | 
			
		||||
        available_width = max(0, available_width - 4)
 | 
			
		||||
 | 
			
		||||
        # Получаем «обрезанный» текст с многоточием
 | 
			
		||||
        display_text = fm.elidedText(text, Qt.TextElideMode.ElideRight, available_width)
 | 
			
		||||
 | 
			
		||||
        text_width = fm.horizontalAdvance(display_text)
 | 
			
		||||
        text_height = fm.height()
 | 
			
		||||
        total_width = text_width + (icon_size + spacing if pixmap else 0)
 | 
			
		||||
@@ -306,6 +280,8 @@ class AutoSizeButton(QPushButton):
 | 
			
		||||
 | 
			
		||||
        self.setCursor(Qt.CursorShape.PointingHandCursor)
 | 
			
		||||
        self.setFlat(True)
 | 
			
		||||
 | 
			
		||||
        # Изначально выставляем минимальную ширину
 | 
			
		||||
        self.setMinimumWidth(50)
 | 
			
		||||
        self.adjustFontSize()
 | 
			
		||||
 | 
			
		||||
@@ -336,6 +312,7 @@ class AutoSizeButton(QPushButton):
 | 
			
		||||
        if not self._update_size:
 | 
			
		||||
            return
 | 
			
		||||
 | 
			
		||||
        # Определяем доступную ширину внутри кнопки
 | 
			
		||||
        available_width = self.width()
 | 
			
		||||
        if self._icon:
 | 
			
		||||
            available_width -= self._icon_size
 | 
			
		||||
@@ -346,6 +323,7 @@ class AutoSizeButton(QPushButton):
 | 
			
		||||
        font = QFont(self._original_font)
 | 
			
		||||
        text = self._original_text
 | 
			
		||||
 | 
			
		||||
        # Подбираем максимально возможный размер шрифта, при котором текст укладывается
 | 
			
		||||
        chosen_size = self._max_font_size
 | 
			
		||||
        for font_size in range(self._max_font_size, self._min_font_size - 1, -1):
 | 
			
		||||
            font.setPointSize(font_size)
 | 
			
		||||
@@ -358,12 +336,14 @@ class AutoSizeButton(QPushButton):
 | 
			
		||||
        font.setPointSize(chosen_size)
 | 
			
		||||
        self.setFont(font)
 | 
			
		||||
 | 
			
		||||
        # После выбора шрифта вычисляем требуемую ширину для полного отображения текста
 | 
			
		||||
        fm = QFontMetrics(font)
 | 
			
		||||
        text_width = fm.horizontalAdvance(text)
 | 
			
		||||
        required_width = text_width + margins.left() + margins.right() + self._padding * 2
 | 
			
		||||
        if self._icon:
 | 
			
		||||
            required_width += self._icon_size
 | 
			
		||||
 | 
			
		||||
        # Если текущая ширина меньше требуемой, обновляем минимальную ширину
 | 
			
		||||
        if self.width() < required_width:
 | 
			
		||||
            self.setMinimumWidth(required_width)
 | 
			
		||||
 | 
			
		||||
@@ -373,6 +353,7 @@ class AutoSizeButton(QPushButton):
 | 
			
		||||
        if not self._update_size:
 | 
			
		||||
            return super().sizeHint()
 | 
			
		||||
        else:
 | 
			
		||||
            # Вычисляем оптимальный размер кнопки на основе текста и отступов
 | 
			
		||||
            font = self.font()
 | 
			
		||||
            fm = QFontMetrics(font)
 | 
			
		||||
            text_width = fm.horizontalAdvance(self._original_text)
 | 
			
		||||
@@ -383,6 +364,7 @@ class AutoSizeButton(QPushButton):
 | 
			
		||||
            height = fm.height() + margins.top() + margins.bottom() + self._padding
 | 
			
		||||
            return QSize(width, height)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class NavLabel(QLabel):
 | 
			
		||||
    clicked = Signal()
 | 
			
		||||
 | 
			
		||||
@@ -394,6 +376,7 @@ class NavLabel(QLabel):
 | 
			
		||||
        self._isChecked = False
 | 
			
		||||
        self.setProperty("checked", self._isChecked)
 | 
			
		||||
        self.setCursor(Qt.CursorShape.PointingHandCursor)
 | 
			
		||||
        # Explicitly enable focus
 | 
			
		||||
        self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
 | 
			
		||||
 | 
			
		||||
    def setCheckable(self, checkable):
 | 
			
		||||
@@ -412,6 +395,7 @@ class NavLabel(QLabel):
 | 
			
		||||
 | 
			
		||||
    def mousePressEvent(self, event):
 | 
			
		||||
        if event.button() == Qt.MouseButton.LeftButton:
 | 
			
		||||
            # Ensure widget can take focus on click
 | 
			
		||||
            self.setFocus(Qt.FocusReason.MouseFocusReason)
 | 
			
		||||
            if self._checkable:
 | 
			
		||||
                self.setChecked(not self._isChecked)
 | 
			
		||||
 
 | 
			
		||||
@@ -4,24 +4,23 @@ import re
 | 
			
		||||
from typing import cast, TYPE_CHECKING
 | 
			
		||||
from PySide6.QtGui import QPixmap, QIcon
 | 
			
		||||
from PySide6.QtWidgets import (
 | 
			
		||||
    QDialog, QFormLayout, QHBoxLayout, QLabel, QVBoxLayout, QListWidget, QScrollArea, QWidget, QListWidgetItem, QSizePolicy, QApplication, QProgressBar
 | 
			
		||||
    QDialog, QFormLayout, QHBoxLayout, QLabel, QVBoxLayout, QListWidget, QScrollArea, QWidget, QListWidgetItem, QSizePolicy, QApplication
 | 
			
		||||
)
 | 
			
		||||
from PySide6.QtCore import Qt, QObject, Signal, QMimeDatabase, QTimer
 | 
			
		||||
from icoextract import IconExtractor, IconExtractorError
 | 
			
		||||
from PIL import Image
 | 
			
		||||
from portprotonqt.config_utils import get_portproton_location, read_favorite_folders, read_theme_from_config
 | 
			
		||||
from portprotonqt.config_utils import get_portproton_location
 | 
			
		||||
from portprotonqt.localization import _
 | 
			
		||||
from portprotonqt.logger import get_logger
 | 
			
		||||
import portprotonqt.themes.standart.styles as default_styles
 | 
			
		||||
from portprotonqt.theme_manager import ThemeManager
 | 
			
		||||
from portprotonqt.custom_widgets import AutoSizeButton
 | 
			
		||||
from portprotonqt.downloader import Downloader
 | 
			
		||||
import psutil
 | 
			
		||||
 | 
			
		||||
if TYPE_CHECKING:
 | 
			
		||||
    from portprotonqt.main_window import MainWindow
 | 
			
		||||
 | 
			
		||||
logger = get_logger(__name__)
 | 
			
		||||
theme_manager = ThemeManager()
 | 
			
		||||
 | 
			
		||||
def generate_thumbnail(inputfile, outfile, size=128, force_resize=True):
 | 
			
		||||
    """
 | 
			
		||||
@@ -90,89 +89,11 @@ def generate_thumbnail(inputfile, outfile, size=128, force_resize=True):
 | 
			
		||||
class FileSelectedSignal(QObject):
 | 
			
		||||
    file_selected = Signal(str)  # Сигнал с путем к выбранному файлу
 | 
			
		||||
 | 
			
		||||
class GameLaunchDialog(QDialog):
 | 
			
		||||
    """Modal dialog to indicate game launch progress, similar to Steam's launch dialog."""
 | 
			
		||||
    def __init__(self, parent=None, game_name=None, theme=None, target_exe=None):
 | 
			
		||||
        super().__init__(parent)
 | 
			
		||||
        self.theme = theme if theme else theme_manager.apply_theme(read_theme_from_config())
 | 
			
		||||
        self.game_name = game_name
 | 
			
		||||
        self.target_exe = target_exe  # Store the target executable name
 | 
			
		||||
        self.setWindowTitle(_("Launching {0}").format(self.game_name))
 | 
			
		||||
        self.setModal(True)
 | 
			
		||||
        self.setFixedSize(400, 200)
 | 
			
		||||
        self.setStyleSheet(self.theme.MESSAGE_BOX_STYLE)
 | 
			
		||||
        self.setWindowModality(Qt.WindowModality.ApplicationModal)
 | 
			
		||||
        self.setWindowFlags(Qt.WindowType.Dialog | Qt.WindowType.FramelessWindowHint)
 | 
			
		||||
 | 
			
		||||
        # Layout
 | 
			
		||||
        layout = QVBoxLayout(self)
 | 
			
		||||
        layout.setContentsMargins(10, 10, 10, 10)
 | 
			
		||||
        layout.setSpacing(10)
 | 
			
		||||
 | 
			
		||||
        # Game name label
 | 
			
		||||
        label = QLabel(_("Launching {0}").format(self.game_name))
 | 
			
		||||
        label.setStyleSheet(self.theme.PARAMS_TITLE_STYLE)
 | 
			
		||||
        label.setAlignment(Qt.AlignmentFlag.AlignCenter)
 | 
			
		||||
        layout.addWidget(label)
 | 
			
		||||
 | 
			
		||||
        # Progress bar (indeterminate)
 | 
			
		||||
        self.progress_bar = QProgressBar()
 | 
			
		||||
        self.progress_bar.setStyleSheet(self.theme.PROGRESS_BAR_STYLE)
 | 
			
		||||
        self.progress_bar.setRange(0, 0)  # Indeterminate mode
 | 
			
		||||
        layout.addWidget(self.progress_bar)
 | 
			
		||||
 | 
			
		||||
        # Cancel button
 | 
			
		||||
        self.cancel_button = AutoSizeButton(_("Cancel"), icon=theme_manager.get_icon("cancel"))
 | 
			
		||||
        self.cancel_button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
 | 
			
		||||
        self.cancel_button.clicked.connect(self.reject)
 | 
			
		||||
        layout.addWidget(self.cancel_button, alignment=Qt.AlignmentFlag.AlignCenter)
 | 
			
		||||
 | 
			
		||||
        # Center dialog on parent
 | 
			
		||||
        if parent:
 | 
			
		||||
            parent_geometry = parent.geometry()
 | 
			
		||||
            center_point = parent_geometry.center()
 | 
			
		||||
            dialog_geometry = self.geometry()
 | 
			
		||||
            dialog_geometry.moveCenter(center_point)
 | 
			
		||||
            self.setGeometry(dialog_geometry)
 | 
			
		||||
 | 
			
		||||
        # Timer to check if the game process is running
 | 
			
		||||
        self.check_process_timer = QTimer(self)
 | 
			
		||||
        self.check_process_timer.timeout.connect(self.check_target_exe)
 | 
			
		||||
        self.check_process_timer.start(500)
 | 
			
		||||
 | 
			
		||||
    def is_target_exe_running(self):
 | 
			
		||||
        """Check if the target executable is running using psutil."""
 | 
			
		||||
        if not self.target_exe:
 | 
			
		||||
            return False
 | 
			
		||||
        for proc in psutil.process_iter(attrs=["name"]):
 | 
			
		||||
            if proc.info["name"].lower() == self.target_exe.lower():
 | 
			
		||||
                return True
 | 
			
		||||
        return False
 | 
			
		||||
 | 
			
		||||
    def check_target_exe(self):
 | 
			
		||||
        """Check if the game process is running and close the dialog if it is."""
 | 
			
		||||
        if self.is_target_exe_running():
 | 
			
		||||
            logger.info(f"Game {self.game_name} process detected as running, closing launch dialog")
 | 
			
		||||
            self.accept()  # Close dialog when game is running
 | 
			
		||||
            self.check_process_timer.stop()
 | 
			
		||||
            self.check_process_timer.deleteLater()
 | 
			
		||||
        elif not hasattr(self.parent(), 'game_processes') or not any(proc.poll() is None for proc in cast("MainWindow", self.parent()).game_processes):
 | 
			
		||||
            # If no child processes are running, stop the timer but keep dialog open
 | 
			
		||||
            self.check_process_timer.stop()
 | 
			
		||||
            self.check_process_timer.deleteLater()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    def reject(self):
 | 
			
		||||
        """Handle dialog cancellation."""
 | 
			
		||||
        logger.info(f"Game launch cancelled for {self.game_name}")
 | 
			
		||||
        self.check_process_timer.stop()
 | 
			
		||||
        self.check_process_timer.deleteLater()
 | 
			
		||||
        super().reject()
 | 
			
		||||
 | 
			
		||||
class FileExplorer(QDialog):
 | 
			
		||||
    def __init__(self, parent=None, theme=None, file_filter=None, initial_path=None, directory_only=False):
 | 
			
		||||
        super().__init__(parent)
 | 
			
		||||
        self.theme = theme if theme else theme_manager.apply_theme(read_theme_from_config())
 | 
			
		||||
        self.theme = theme if theme else default_styles
 | 
			
		||||
        self.theme_manager = ThemeManager()
 | 
			
		||||
        self.file_signal = FileSelectedSignal()
 | 
			
		||||
        self.file_filter = file_filter  # Store the file filter
 | 
			
		||||
        self.directory_only = directory_only  # Store the directory_only flag
 | 
			
		||||
@@ -185,15 +106,13 @@ class FileExplorer(QDialog):
 | 
			
		||||
        self.setWindowModality(Qt.WindowModality.ApplicationModal)
 | 
			
		||||
        self.setWindowFlags(self.windowFlags() & ~Qt.WindowType.WindowContextHelpButtonHint)
 | 
			
		||||
 | 
			
		||||
        # Find InputManager and ContextMenuManager from parent
 | 
			
		||||
        # Find InputManager from parent
 | 
			
		||||
        self.input_manager = None
 | 
			
		||||
        self.context_menu_manager = None
 | 
			
		||||
        parent = self.parent()
 | 
			
		||||
        while parent:
 | 
			
		||||
            if hasattr(parent, 'input_manager'):
 | 
			
		||||
                self.input_manager = cast("MainWindow", parent).input_manager
 | 
			
		||||
            if hasattr(parent, 'context_menu_manager'):
 | 
			
		||||
                self.context_menu_manager = cast("MainWindow", parent).context_menu_manager
 | 
			
		||||
                break
 | 
			
		||||
            parent = parent.parent()
 | 
			
		||||
 | 
			
		||||
        if self.input_manager:
 | 
			
		||||
@@ -218,9 +137,8 @@ class FileExplorer(QDialog):
 | 
			
		||||
                    if len(parts) < 2:
 | 
			
		||||
                        continue
 | 
			
		||||
                    mount_point = parts[1]
 | 
			
		||||
                    # Исключаем системные и временные пути, но сохраняем /run/media
 | 
			
		||||
                    if (mount_point.startswith(('/dev', '/sys', '/proc', '/tmp', '/snap', '/var/lib')) or
 | 
			
		||||
                        (mount_point.startswith('/run') and not mount_point.startswith('/run/media'))):
 | 
			
		||||
                    # Исключаем системные и временные пути
 | 
			
		||||
                    if mount_point.startswith(('/run', '/dev', '/sys', '/proc', '/tmp', '/snap', '/var/lib')):
 | 
			
		||||
                        continue
 | 
			
		||||
                    # Проверяем, является ли точка монтирования директорией и доступна ли она
 | 
			
		||||
                    if os.path.isdir(mount_point) and os.access(mount_point, os.R_OK):
 | 
			
		||||
@@ -240,7 +158,7 @@ class FileExplorer(QDialog):
 | 
			
		||||
        self.main_layout.setSpacing(10)
 | 
			
		||||
        self.setLayout(self.main_layout)
 | 
			
		||||
 | 
			
		||||
        # Панель для смонтированных дисков и избранных папок
 | 
			
		||||
        # Панель для смонтированных дисков
 | 
			
		||||
        self.drives_layout = QHBoxLayout()
 | 
			
		||||
        self.drives_scroll = QScrollArea()
 | 
			
		||||
        self.drives_scroll.setWidgetResizable(True)
 | 
			
		||||
@@ -251,7 +169,7 @@ class FileExplorer(QDialog):
 | 
			
		||||
        self.drives_scroll.setFixedHeight(70)
 | 
			
		||||
        self.main_layout.addWidget(self.drives_scroll)
 | 
			
		||||
        self.drives_scroll.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
 | 
			
		||||
        self.drives_scroll.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
 | 
			
		||||
        self.drives_scroll.setFocusPolicy(Qt.FocusPolicy.StrongFocus)  # Allow focus on scroll area
 | 
			
		||||
 | 
			
		||||
        # Путь
 | 
			
		||||
        self.path_label = QLabel()
 | 
			
		||||
@@ -263,15 +181,13 @@ class FileExplorer(QDialog):
 | 
			
		||||
        self.file_list.setStyleSheet(self.theme.FILE_EXPLORER_STYLE)
 | 
			
		||||
        self.file_list.itemClicked.connect(self.handle_item_click)
 | 
			
		||||
        self.file_list.itemDoubleClicked.connect(self.handle_item_double_click)
 | 
			
		||||
        self.file_list.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
 | 
			
		||||
        self.file_list.customContextMenuRequested.connect(self.show_folder_context_menu)
 | 
			
		||||
        self.main_layout.addWidget(self.file_list)
 | 
			
		||||
 | 
			
		||||
        # Кнопки
 | 
			
		||||
        self.button_layout = QHBoxLayout()
 | 
			
		||||
        self.button_layout.setSpacing(10)
 | 
			
		||||
        self.select_button = AutoSizeButton(_("Select"), icon=theme_manager.get_icon("apply"))
 | 
			
		||||
        self.cancel_button = AutoSizeButton(_("Cancel"), icon=theme_manager.get_icon("cancel"))
 | 
			
		||||
        self.select_button = AutoSizeButton(_("Select"), icon=self.theme_manager.get_icon("apply"))
 | 
			
		||||
        self.cancel_button = AutoSizeButton(_("Cancel"), icon=self.theme_manager.get_icon("cancel"))
 | 
			
		||||
        self.select_button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
 | 
			
		||||
        self.cancel_button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
 | 
			
		||||
        self.button_layout.addWidget(self.select_button)
 | 
			
		||||
@@ -281,13 +197,6 @@ class FileExplorer(QDialog):
 | 
			
		||||
        self.select_button.clicked.connect(self.select_item)
 | 
			
		||||
        self.cancel_button.clicked.connect(self.reject)
 | 
			
		||||
 | 
			
		||||
    def show_folder_context_menu(self, pos):
 | 
			
		||||
        """Shows the context menu for a folder using ContextMenuManager."""
 | 
			
		||||
        if self.context_menu_manager:
 | 
			
		||||
            self.context_menu_manager.show_folder_context_menu(self, pos)
 | 
			
		||||
        else:
 | 
			
		||||
            logger.warning("ContextMenuManager not found in parent")
 | 
			
		||||
 | 
			
		||||
    def move_selection(self, direction):
 | 
			
		||||
        """Перемещение выбора по списку"""
 | 
			
		||||
        current_row = self.file_list.currentRow()
 | 
			
		||||
@@ -377,96 +286,44 @@ class FileExplorer(QDialog):
 | 
			
		||||
        except Exception as e:
 | 
			
		||||
            logger.error(f"Error navigating to parent directory: {e}")
 | 
			
		||||
 | 
			
		||||
    def ensure_button_visible(self, button):
 | 
			
		||||
        """Ensure the specified button is visible in the drives_scroll area."""
 | 
			
		||||
        try:
 | 
			
		||||
            if not button or not self.drives_scroll:
 | 
			
		||||
                return
 | 
			
		||||
            # Ensure the button is visible in the scroll area
 | 
			
		||||
            self.drives_scroll.ensureWidgetVisible(button, 50, 50)
 | 
			
		||||
            logger.debug(f"Ensured button {button.text()} is visible in drives_scroll")
 | 
			
		||||
        except Exception as e:
 | 
			
		||||
            logger.error(f"Error ensuring button visible: {e}")
 | 
			
		||||
 | 
			
		||||
    def update_drives_list(self):
 | 
			
		||||
        """Обновление списка смонтированных дисков и избранных папок."""
 | 
			
		||||
        """Обновление списка смонтированных дисков"""
 | 
			
		||||
        for i in reversed(range(self.drives_layout.count())):
 | 
			
		||||
            item = self.drives_layout.itemAt(i)
 | 
			
		||||
            if item and item.widget():
 | 
			
		||||
                widget = item.widget()
 | 
			
		||||
                self.drives_layout.removeWidget(widget)
 | 
			
		||||
            widget = self.drives_layout.itemAt(i).widget()
 | 
			
		||||
            if widget:
 | 
			
		||||
                widget.deleteLater()
 | 
			
		||||
 | 
			
		||||
        self.drive_buttons = []
 | 
			
		||||
        drives = self.get_mounted_drives()
 | 
			
		||||
        favorite_folders = read_favorite_folders()
 | 
			
		||||
 | 
			
		||||
        # Добавляем смонтированные диски
 | 
			
		||||
        self.drive_buttons = []  # Store buttons for navigation
 | 
			
		||||
        for drive in drives:
 | 
			
		||||
            drive_name = os.path.basename(drive) or drive.split('/')[-1] or drive
 | 
			
		||||
            button = AutoSizeButton(drive_name, icon=theme_manager.get_icon("mount_point"))
 | 
			
		||||
            button = AutoSizeButton(drive_name, icon=self.theme_manager.get_icon("mount_point"))
 | 
			
		||||
            button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
 | 
			
		||||
            button.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
 | 
			
		||||
            button.setFocusPolicy(Qt.FocusPolicy.StrongFocus)  # Make button focusable
 | 
			
		||||
            button.clicked.connect(lambda checked, path=drive: self.change_drive(path))
 | 
			
		||||
            self.drives_layout.addWidget(button)
 | 
			
		||||
            self.drive_buttons.append(button)
 | 
			
		||||
        self.drives_layout.addStretch()
 | 
			
		||||
 | 
			
		||||
        # Добавляем избранные папки
 | 
			
		||||
        for folder in favorite_folders:
 | 
			
		||||
            folder_name = os.path.basename(folder) or folder.split('/')[-1] or folder
 | 
			
		||||
            button = AutoSizeButton(folder_name, icon=theme_manager.get_icon("folder"))
 | 
			
		||||
            button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
 | 
			
		||||
            button.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
 | 
			
		||||
            button.clicked.connect(lambda checked, path=folder: self.change_drive(path))
 | 
			
		||||
            self.drives_layout.addWidget(button)
 | 
			
		||||
            self.drive_buttons.append(button)
 | 
			
		||||
 | 
			
		||||
        # Добавляем растяжку, чтобы выровнять элементы
 | 
			
		||||
        spacer = QWidget()
 | 
			
		||||
        spacer.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
 | 
			
		||||
        self.drives_layout.addWidget(spacer)
 | 
			
		||||
        # Set focus to first drive button if available
 | 
			
		||||
        if self.drive_buttons:
 | 
			
		||||
            self.drive_buttons[0].setFocus()
 | 
			
		||||
 | 
			
		||||
    def select_drive(self):
 | 
			
		||||
        """Обрабатывает выбор диска или избранной папки через геймпад."""
 | 
			
		||||
        """Handle drive selection via gamepad"""
 | 
			
		||||
        focused_widget = QApplication.focusWidget()
 | 
			
		||||
        if isinstance(focused_widget, AutoSizeButton) and focused_widget in self.drive_buttons:
 | 
			
		||||
            drive_name = focused_widget.text().strip()  # Удаляем пробелы
 | 
			
		||||
            logger.debug(f"Выбрано имя: {drive_name}")
 | 
			
		||||
 | 
			
		||||
            # Специальная обработка корневого каталога
 | 
			
		||||
            if drive_name == "/":
 | 
			
		||||
                if os.path.isdir("/") and os.access("/", os.R_OK):
 | 
			
		||||
                    self.current_path = "/"
 | 
			
		||||
                    self.update_file_list()
 | 
			
		||||
                    logger.info("Выбран корневой каталог: /")
 | 
			
		||||
                    return
 | 
			
		||||
                else:
 | 
			
		||||
                    logger.warning("Корневой каталог недоступен: недостаточно прав или ошибка пути")
 | 
			
		||||
                    return
 | 
			
		||||
 | 
			
		||||
            # Проверяем избранные папки
 | 
			
		||||
            favorite_folders = read_favorite_folders()
 | 
			
		||||
            logger.debug(f"Избранные папки: {favorite_folders}")
 | 
			
		||||
            for folder in favorite_folders:
 | 
			
		||||
                folder_name = os.path.basename(os.path.normpath(folder)) or folder  # Для корневых путей
 | 
			
		||||
                if folder_name == drive_name and os.path.isdir(folder) and os.access(folder, os.R_OK):
 | 
			
		||||
                    self.current_path = os.path.normpath(folder)
 | 
			
		||||
                    self.update_file_list()
 | 
			
		||||
                    logger.info(f"Выбрана избранная папка: {self.current_path}")
 | 
			
		||||
                    return
 | 
			
		||||
 | 
			
		||||
            # Проверяем смонтированные диски
 | 
			
		||||
            mounted_drives = self.get_mounted_drives()
 | 
			
		||||
            logger.debug(f"Смонтированные диски: {mounted_drives}")
 | 
			
		||||
            for drive in mounted_drives:
 | 
			
		||||
                drive_basename = os.path.basename(os.path.normpath(drive)) or drive  # Для корневых путей
 | 
			
		||||
                if drive_basename == drive_name and os.path.isdir(drive) and os.access(drive, os.R_OK):
 | 
			
		||||
                    self.current_path = os.path.normpath(drive)
 | 
			
		||||
                    self.update_file_list()
 | 
			
		||||
                    logger.info(f"Выбран смонтированный диск: {self.current_path}")
 | 
			
		||||
                    return
 | 
			
		||||
 | 
			
		||||
            logger.warning(f"Путь недоступен: {drive_name}.")
 | 
			
		||||
            drive_path = None
 | 
			
		||||
            for drive in self.get_mounted_drives():
 | 
			
		||||
                drive_name = os.path.basename(drive) or drive.split('/')[-1] or drive
 | 
			
		||||
                if drive_name == focused_widget.text():
 | 
			
		||||
                    drive_path = drive
 | 
			
		||||
                    break
 | 
			
		||||
            if drive_path and os.path.isdir(drive_path) and os.access(drive_path, os.R_OK):
 | 
			
		||||
                self.current_path = os.path.normpath(drive_path)
 | 
			
		||||
                self.update_file_list()
 | 
			
		||||
            else:
 | 
			
		||||
                logger.warning(f"Путь диска недоступен: {drive_path}")
 | 
			
		||||
 | 
			
		||||
    def change_drive(self, drive_path):
 | 
			
		||||
        """Переход к выбранному диску"""
 | 
			
		||||
@@ -482,7 +339,7 @@ class FileExplorer(QDialog):
 | 
			
		||||
        try:
 | 
			
		||||
            if self.current_path != "/":
 | 
			
		||||
                item = QListWidgetItem("../")
 | 
			
		||||
                folder_icon = theme_manager.get_icon("folder")
 | 
			
		||||
                folder_icon = self.theme_manager.get_icon("folder")
 | 
			
		||||
                # Ensure the icon is a QIcon
 | 
			
		||||
                if isinstance(folder_icon, str) and os.path.isfile(folder_icon):
 | 
			
		||||
                    folder_icon = QIcon(folder_icon)
 | 
			
		||||
@@ -497,7 +354,7 @@ class FileExplorer(QDialog):
 | 
			
		||||
            # Добавляем директории
 | 
			
		||||
            for d in sorted(dirs):
 | 
			
		||||
                item = QListWidgetItem(f"{d}/")
 | 
			
		||||
                folder_icon = theme_manager.get_icon("folder")
 | 
			
		||||
                folder_icon = self.theme_manager.get_icon("folder")
 | 
			
		||||
                # Ensure the icon is a QIcon
 | 
			
		||||
                if isinstance(folder_icon, str) and os.path.isfile(folder_icon):
 | 
			
		||||
                    folder_icon = QIcon(folder_icon)
 | 
			
		||||
@@ -588,7 +445,8 @@ class AddGameDialog(QDialog):
 | 
			
		||||
    def __init__(self, parent=None, theme=None, edit_mode=False, game_name=None, exe_path=None, cover_path=None):
 | 
			
		||||
        super().__init__(parent)
 | 
			
		||||
        from portprotonqt.context_menu_manager import CustomLineEdit   # Локальный импорт
 | 
			
		||||
        self.theme = theme if theme else theme_manager.apply_theme(read_theme_from_config())
 | 
			
		||||
        self.theme = theme if theme else default_styles
 | 
			
		||||
        self.theme_manager = ThemeManager()
 | 
			
		||||
        self.edit_mode = edit_mode
 | 
			
		||||
        self.original_name = game_name
 | 
			
		||||
        self.last_exe_path = exe_path  # Store last selected exe path
 | 
			
		||||
@@ -624,7 +482,7 @@ class AddGameDialog(QDialog):
 | 
			
		||||
        if exe_path:
 | 
			
		||||
            self.exeEdit.setText(exe_path)
 | 
			
		||||
 | 
			
		||||
        exeBrowseButton = AutoSizeButton(_("Browse..."), icon=theme_manager.get_icon("search"))
 | 
			
		||||
        exeBrowseButton = AutoSizeButton(_("Browse..."), icon=self.theme_manager.get_icon("search"))
 | 
			
		||||
        exeBrowseButton.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
 | 
			
		||||
        exeBrowseButton.clicked.connect(self.browseExe)
 | 
			
		||||
        exeBrowseButton.setObjectName("exeBrowseButton")  # Для поиска кнопки
 | 
			
		||||
@@ -646,7 +504,7 @@ class AddGameDialog(QDialog):
 | 
			
		||||
        if cover_path:
 | 
			
		||||
            self.coverEdit.setText(cover_path)
 | 
			
		||||
 | 
			
		||||
        coverBrowseButton = AutoSizeButton(_("Browse..."), icon=theme_manager.get_icon("search"))
 | 
			
		||||
        coverBrowseButton = AutoSizeButton(_("Browse..."), icon=self.theme_manager.get_icon("search"))
 | 
			
		||||
        coverBrowseButton.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
 | 
			
		||||
        coverBrowseButton.clicked.connect(self.browseCover)
 | 
			
		||||
        coverBrowseButton.setObjectName("coverBrowseButton")  # Для поиска кнопки
 | 
			
		||||
@@ -675,8 +533,8 @@ class AddGameDialog(QDialog):
 | 
			
		||||
        # Dialog buttons
 | 
			
		||||
        self.button_layout = QHBoxLayout()
 | 
			
		||||
        self.button_layout.setSpacing(10)
 | 
			
		||||
        self.select_button = AutoSizeButton(_("Apply"), icon=theme_manager.get_icon("apply"))
 | 
			
		||||
        self.cancel_button = AutoSizeButton(_("Cancel"), icon=theme_manager.get_icon("cancel"))
 | 
			
		||||
        self.select_button = AutoSizeButton(_("Apply"), icon=self.theme_manager.get_icon("apply"))
 | 
			
		||||
        self.cancel_button = AutoSizeButton(_("Cancel"), icon=self.theme_manager.get_icon("cancel"))
 | 
			
		||||
        self.select_button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
 | 
			
		||||
        self.cancel_button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
 | 
			
		||||
        self.button_layout.addWidget(self.select_button)
 | 
			
		||||
 
 | 
			
		||||
@@ -2,35 +2,39 @@ from PySide6.QtGui import QPainter, QColor, QDesktopServices
 | 
			
		||||
from PySide6.QtCore import Signal, Property, Qt, QUrl
 | 
			
		||||
from PySide6.QtWidgets import QFrame, QGraphicsDropShadowEffect, QVBoxLayout, QWidget, QStackedLayout, QLabel
 | 
			
		||||
from collections.abc import Callable
 | 
			
		||||
import portprotonqt.themes.standart.styles as default_styles
 | 
			
		||||
from portprotonqt.image_utils import load_pixmap_async, round_corners
 | 
			
		||||
from portprotonqt.localization import _
 | 
			
		||||
from portprotonqt.config_utils import read_favorites, save_favorites, read_display_filter, read_theme_from_config
 | 
			
		||||
from portprotonqt.config_utils import read_favorites, save_favorites, read_display_filter
 | 
			
		||||
from portprotonqt.theme_manager import ThemeManager
 | 
			
		||||
from portprotonqt.config_utils import read_theme_from_config
 | 
			
		||||
from portprotonqt.custom_widgets import ClickableLabel
 | 
			
		||||
from portprotonqt.portproton_api import PortProtonAPI
 | 
			
		||||
from portprotonqt.downloader import Downloader
 | 
			
		||||
from portprotonqt.animations import GameCardAnimations
 | 
			
		||||
import weakref
 | 
			
		||||
from typing import cast
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class GameCard(QFrame):
 | 
			
		||||
    borderWidthChanged = Signal()
 | 
			
		||||
    gradientAngleChanged = Signal()
 | 
			
		||||
    scaleChanged = Signal()
 | 
			
		||||
    editShortcutRequested = Signal(str, str, str)
 | 
			
		||||
    deleteGameRequested = Signal(str, str)
 | 
			
		||||
    addToMenuRequested = Signal(str, str)
 | 
			
		||||
    removeFromMenuRequested = Signal(str)
 | 
			
		||||
    addToDesktopRequested = Signal(str, str)
 | 
			
		||||
    removeFromDesktopRequested = Signal(str)
 | 
			
		||||
    addToSteamRequested = Signal(str, str, str)
 | 
			
		||||
    removeFromSteamRequested = Signal(str, str)
 | 
			
		||||
    openGameFolderRequested = Signal(str, str)
 | 
			
		||||
    # Signals for context menu actions
 | 
			
		||||
    editShortcutRequested = Signal(str, str, str) # name, exec_line, cover_path
 | 
			
		||||
    deleteGameRequested = Signal(str, str)        # name, exec_line
 | 
			
		||||
    addToMenuRequested = Signal(str, str)         # name, exec_line
 | 
			
		||||
    removeFromMenuRequested = Signal(str)         # name
 | 
			
		||||
    addToDesktopRequested = Signal(str, str)      # name, exec_line
 | 
			
		||||
    removeFromDesktopRequested = Signal(str)      # name
 | 
			
		||||
    addToSteamRequested = Signal(str, str, str)   # name, exec_line, cover_path
 | 
			
		||||
    removeFromSteamRequested = Signal(str, str)   # name, exec_line
 | 
			
		||||
    openGameFolderRequested = Signal(str, str)    # name, exec_line
 | 
			
		||||
    hoverChanged = Signal(str, bool)
 | 
			
		||||
    focusChanged = Signal(str, bool)
 | 
			
		||||
 | 
			
		||||
    def __init__(self, name, description, cover_path, appid, controller_support, exec_line,
 | 
			
		||||
                 last_launch, formatted_playtime, protondb_tier, anticheat_status, last_launch_ts, playtime_seconds, game_source,
 | 
			
		||||
                 select_callback, theme=None, card_width=250, parent=None, context_menu_manager=None):
 | 
			
		||||
                last_launch, formatted_playtime, protondb_tier, anticheat_status, last_launch_ts, playtime_seconds, game_source,
 | 
			
		||||
                select_callback, theme=None, card_width=250, parent=None, context_menu_manager=None):
 | 
			
		||||
        super().__init__(parent)
 | 
			
		||||
        self.name = name
 | 
			
		||||
        self.description = description
 | 
			
		||||
@@ -45,16 +49,14 @@ class GameCard(QFrame):
 | 
			
		||||
        self.game_source = game_source
 | 
			
		||||
        self.last_launch_ts = last_launch_ts
 | 
			
		||||
        self.playtime_seconds = playtime_seconds
 | 
			
		||||
        self.base_card_width = card_width
 | 
			
		||||
        self.base_pixmap = None
 | 
			
		||||
        self.base_font_size = None
 | 
			
		||||
        self.card_width = card_width
 | 
			
		||||
 | 
			
		||||
        self.select_callback = select_callback
 | 
			
		||||
        self.context_menu_manager = context_menu_manager
 | 
			
		||||
        self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
 | 
			
		||||
        self.customContextMenuRequested.connect(self._show_context_menu)
 | 
			
		||||
        self.theme_manager = ThemeManager()
 | 
			
		||||
        self.theme = theme if theme is not None else self.theme_manager.apply_theme(read_theme_from_config())
 | 
			
		||||
        self.theme = theme if theme is not None else default_styles
 | 
			
		||||
 | 
			
		||||
        self.display_filter = read_display_filter()
 | 
			
		||||
        self.current_theme_name = read_theme_from_config()
 | 
			
		||||
@@ -65,46 +67,75 @@ class GameCard(QFrame):
 | 
			
		||||
        self.egs_visible = (str(game_source).lower() == "epic" and self.display_filter in ("all", "favorites"))
 | 
			
		||||
        self.portproton_visible = (str(game_source).lower() == "portproton" and self.display_filter in ("all", "favorites"))
 | 
			
		||||
 | 
			
		||||
        self.base_extra_margin = 20
 | 
			
		||||
        # Дополнительное пространство для анимации
 | 
			
		||||
        extra_margin = 20
 | 
			
		||||
        self.setFixedSize(card_width + extra_margin, int(card_width * 1.6) + extra_margin)
 | 
			
		||||
        self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
 | 
			
		||||
        self.setStyleSheet(self.theme.GAME_CARD_WINDOW_STYLE)
 | 
			
		||||
 | 
			
		||||
        # Параметры анимации обводки
 | 
			
		||||
        self._borderWidth = self.theme.GAME_CARD_ANIMATION["default_border_width"]
 | 
			
		||||
        self._gradientAngle = self.theme.GAME_CARD_ANIMATION["gradient_start_angle"]
 | 
			
		||||
        self._scale = self.theme.GAME_CARD_ANIMATION["default_scale"]
 | 
			
		||||
        self._hovered = False
 | 
			
		||||
        self._focused = False
 | 
			
		||||
 | 
			
		||||
        # Анимации
 | 
			
		||||
        self.animations = GameCardAnimations(self, self.theme)
 | 
			
		||||
        self.animations.setup_animations()
 | 
			
		||||
 | 
			
		||||
        self.shadow = QGraphicsDropShadowEffect(self)
 | 
			
		||||
        self.shadow.setBlurRadius(20)
 | 
			
		||||
        self.shadow.setColor(QColor(0, 0, 0, 150))
 | 
			
		||||
        self.shadow.setOffset(0, 0)
 | 
			
		||||
        self.setGraphicsEffect(self.shadow)
 | 
			
		||||
        # Тень
 | 
			
		||||
        shadow = QGraphicsDropShadowEffect(self)
 | 
			
		||||
        shadow.setBlurRadius(20)
 | 
			
		||||
        shadow.setColor(QColor(0, 0, 0, 150))
 | 
			
		||||
        shadow.setOffset(0, 0)
 | 
			
		||||
        self.setGraphicsEffect(shadow)
 | 
			
		||||
 | 
			
		||||
        self.layout_ = QVBoxLayout(self)
 | 
			
		||||
        self.layout_.setSpacing(5)
 | 
			
		||||
        self.layout_.setContentsMargins(self.base_extra_margin // 2, self.base_extra_margin // 2, self.base_extra_margin // 2, self.base_extra_margin // 2)
 | 
			
		||||
        # Отступы
 | 
			
		||||
        layout = QVBoxLayout(self)
 | 
			
		||||
        layout.setContentsMargins(extra_margin // 2, extra_margin // 2, extra_margin // 2, extra_margin // 2)
 | 
			
		||||
        layout.setSpacing(5)
 | 
			
		||||
 | 
			
		||||
        self.coverWidget = QWidget()
 | 
			
		||||
        coverLayout = QStackedLayout(self.coverWidget)
 | 
			
		||||
        # Контейнер обложки
 | 
			
		||||
        coverWidget = QWidget()
 | 
			
		||||
        coverWidget.setFixedSize(card_width, int(card_width * 1.2))
 | 
			
		||||
        coverLayout = QStackedLayout(coverWidget)
 | 
			
		||||
        coverLayout.setContentsMargins(0, 0, 0, 0)
 | 
			
		||||
        coverLayout.setStackingMode(QStackedLayout.StackingMode.StackAll)
 | 
			
		||||
 | 
			
		||||
        # Обложка
 | 
			
		||||
        self.coverLabel = QLabel()
 | 
			
		||||
        self.coverLabel.setFixedSize(card_width, int(card_width * 1.2))
 | 
			
		||||
        self.coverLabel.setStyleSheet(self.theme.COVER_LABEL_STYLE)
 | 
			
		||||
        coverLayout.addWidget(self.coverLabel)
 | 
			
		||||
 | 
			
		||||
        load_pixmap_async(cover_path or "", self.base_card_width, int(self.base_card_width * 1.5), self.on_cover_loaded)
 | 
			
		||||
        # создаём слабую ссылку на label
 | 
			
		||||
        label_ref = weakref.ref(self.coverLabel)
 | 
			
		||||
 | 
			
		||||
        self.favoriteLabel = ClickableLabel(self.coverWidget)
 | 
			
		||||
        def on_cover_loaded(pixmap):
 | 
			
		||||
            label = label_ref()
 | 
			
		||||
            if label is None:
 | 
			
		||||
                return
 | 
			
		||||
            label.setPixmap(round_corners(pixmap, 15))
 | 
			
		||||
 | 
			
		||||
        # асинхронная загрузка обложки (пустая строка даст placeholder внутри load_pixmap_async)
 | 
			
		||||
        load_pixmap_async(cover_path or "", card_width, int(card_width * 1.2), on_cover_loaded)
 | 
			
		||||
 | 
			
		||||
        # Значок избранного (звёздочка) в левом верхнем углу обложки
 | 
			
		||||
        self.favoriteLabel = ClickableLabel(coverWidget)
 | 
			
		||||
        self.favoriteLabel.setFixedSize(*self.theme.favoriteLabelSize)
 | 
			
		||||
        self.favoriteLabel.move(8, 8)
 | 
			
		||||
        self.favoriteLabel.clicked.connect(self.toggle_favorite)
 | 
			
		||||
        self.is_favorite = self.name in read_favorites()
 | 
			
		||||
        self.update_favorite_icon()
 | 
			
		||||
        self.favoriteLabel.raise_()
 | 
			
		||||
 | 
			
		||||
        # Определяем общие параметры для бейджей
 | 
			
		||||
        badge_width = int(card_width * 2/3)
 | 
			
		||||
        icon_size = int(card_width * 0.06)  # 6% от ширины карточки
 | 
			
		||||
        icon_space = int(card_width * 0.012)  # 1.2% от ширины карточки
 | 
			
		||||
        font_scale_factor = 0.06  # Шрифт будет 6% от card_width
 | 
			
		||||
 | 
			
		||||
        # ProtonDB бейдж
 | 
			
		||||
        tier_text = self.getProtonDBText(protondb_tier)
 | 
			
		||||
        if tier_text:
 | 
			
		||||
            icon_filename = self.getProtonDBIconFilename(protondb_tier)
 | 
			
		||||
@@ -112,50 +143,67 @@ class GameCard(QFrame):
 | 
			
		||||
            self.protondbLabel = ClickableLabel(
 | 
			
		||||
                tier_text,
 | 
			
		||||
                icon=icon,
 | 
			
		||||
                parent=self.coverWidget,
 | 
			
		||||
                font_scale_factor=0.06
 | 
			
		||||
                parent=coverWidget,
 | 
			
		||||
                icon_size=icon_size,
 | 
			
		||||
                icon_space=icon_space,
 | 
			
		||||
                font_scale_factor=font_scale_factor
 | 
			
		||||
            )
 | 
			
		||||
            self.protondbLabel.setStyleSheet(self.theme.get_protondb_badge_style(protondb_tier))
 | 
			
		||||
            self.protondbLabel.setFixedWidth(badge_width)
 | 
			
		||||
            self.protondbLabel.setCardWidth(card_width)
 | 
			
		||||
        else:
 | 
			
		||||
            self.protondbLabel = ClickableLabel("", parent=self.coverWidget)
 | 
			
		||||
            self.protondbLabel = ClickableLabel("", parent=coverWidget, icon_size=icon_size, icon_space=icon_space)
 | 
			
		||||
            self.protondbLabel.setFixedWidth(badge_width)
 | 
			
		||||
            self.protondbLabel.setVisible(False)
 | 
			
		||||
 | 
			
		||||
        # Steam бейдж
 | 
			
		||||
        steam_icon = self.theme_manager.get_icon("steam")
 | 
			
		||||
        self.steamLabel = ClickableLabel(
 | 
			
		||||
            "Steam",
 | 
			
		||||
            icon=steam_icon,
 | 
			
		||||
            parent=self.coverWidget,
 | 
			
		||||
            font_scale_factor=0.06
 | 
			
		||||
            parent=coverWidget,
 | 
			
		||||
            icon_size=icon_size,
 | 
			
		||||
            icon_space=icon_space,
 | 
			
		||||
            font_scale_factor=font_scale_factor
 | 
			
		||||
        )
 | 
			
		||||
        self.steamLabel.setStyleSheet(self.theme.STEAM_BADGE_STYLE)
 | 
			
		||||
        self.steamLabel.setFixedWidth(badge_width)
 | 
			
		||||
        self.steamLabel.setCardWidth(card_width)
 | 
			
		||||
        self.steamLabel.setVisible(self.steam_visible)
 | 
			
		||||
 | 
			
		||||
        # Epic Games Store бейдж
 | 
			
		||||
        egs_icon = self.theme_manager.get_icon("epic_games")
 | 
			
		||||
        self.egsLabel = ClickableLabel(
 | 
			
		||||
            "Epic Games",
 | 
			
		||||
            icon=egs_icon,
 | 
			
		||||
            parent=self.coverWidget,
 | 
			
		||||
            font_scale_factor=0.06,
 | 
			
		||||
            parent=coverWidget,
 | 
			
		||||
            icon_size=icon_size,
 | 
			
		||||
            icon_space=icon_space,
 | 
			
		||||
            font_scale_factor=font_scale_factor,
 | 
			
		||||
            change_cursor=False
 | 
			
		||||
        )
 | 
			
		||||
        self.egsLabel.setStyleSheet(self.theme.STEAM_BADGE_STYLE)
 | 
			
		||||
        self.egsLabel.setFixedWidth(badge_width)
 | 
			
		||||
        self.egsLabel.setCardWidth(card_width)
 | 
			
		||||
        self.egsLabel.setVisible(self.egs_visible)
 | 
			
		||||
 | 
			
		||||
        # PortProton бейдж
 | 
			
		||||
        portproton_icon = self.theme_manager.get_icon("portproton")
 | 
			
		||||
        self.portprotonLabel = ClickableLabel(
 | 
			
		||||
            "PortProton",
 | 
			
		||||
            icon=portproton_icon,
 | 
			
		||||
            parent=self.coverWidget,
 | 
			
		||||
            font_scale_factor=0.06
 | 
			
		||||
            parent=coverWidget,
 | 
			
		||||
            icon_size=icon_size,
 | 
			
		||||
            icon_space=icon_space,
 | 
			
		||||
            font_scale_factor=font_scale_factor
 | 
			
		||||
        )
 | 
			
		||||
        self.portprotonLabel.setStyleSheet(self.theme.STEAM_BADGE_STYLE)
 | 
			
		||||
        self.portprotonLabel.setFixedWidth(badge_width)
 | 
			
		||||
        self.portprotonLabel.setCardWidth(card_width)
 | 
			
		||||
        self.portprotonLabel.setVisible(self.portproton_visible)
 | 
			
		||||
        self.portprotonLabel.clicked.connect(self.open_portproton_forum_topic)
 | 
			
		||||
 | 
			
		||||
        # WeAntiCheatYet бейдж
 | 
			
		||||
        anticheat_text = self.getAntiCheatText(anticheat_status)
 | 
			
		||||
        if anticheat_text:
 | 
			
		||||
            icon_filename = self.getAntiCheatIconFilename(anticheat_status)
 | 
			
		||||
@@ -163,57 +211,40 @@ class GameCard(QFrame):
 | 
			
		||||
            self.anticheatLabel = ClickableLabel(
 | 
			
		||||
                anticheat_text,
 | 
			
		||||
                icon=icon,
 | 
			
		||||
                parent=self.coverWidget,
 | 
			
		||||
                font_scale_factor=0.06
 | 
			
		||||
                parent=coverWidget,
 | 
			
		||||
                icon_size=icon_size,
 | 
			
		||||
                icon_space=icon_space,
 | 
			
		||||
                font_scale_factor=font_scale_factor
 | 
			
		||||
            )
 | 
			
		||||
            self.anticheatLabel.setStyleSheet(self.theme.get_anticheat_badge_style(anticheat_status))
 | 
			
		||||
            self.anticheatLabel.setFixedWidth(badge_width)
 | 
			
		||||
            self.anticheatLabel.setCardWidth(card_width)
 | 
			
		||||
        else:
 | 
			
		||||
            self.anticheatLabel = ClickableLabel("", parent=self.coverWidget)
 | 
			
		||||
            self.anticheatLabel = ClickableLabel("", parent=coverWidget, icon_size=icon_size, icon_space=icon_space)
 | 
			
		||||
            self.anticheatLabel.setFixedWidth(badge_width)
 | 
			
		||||
            self.anticheatLabel.setVisible(False)
 | 
			
		||||
 | 
			
		||||
        # Расположение бейджей
 | 
			
		||||
        self._position_badges(card_width)
 | 
			
		||||
        self.protondbLabel.clicked.connect(self.open_protondb_report)
 | 
			
		||||
        self.steamLabel.clicked.connect(self.open_steam_page)
 | 
			
		||||
        self.anticheatLabel.clicked.connect(self.open_weanticheatyet_page)
 | 
			
		||||
 | 
			
		||||
        self.layout_.addWidget(self.coverWidget)
 | 
			
		||||
        layout.addWidget(coverWidget)
 | 
			
		||||
 | 
			
		||||
        self.nameLabel = QLabel(name)
 | 
			
		||||
        self.nameLabel.setAlignment(Qt.AlignmentFlag.AlignCenter)
 | 
			
		||||
        self.nameLabel.setStyleSheet(self.theme.GAME_CARD_NAME_LABEL_STYLE)
 | 
			
		||||
        self.layout_.addWidget(self.nameLabel)
 | 
			
		||||
        # Название игры
 | 
			
		||||
        nameLabel = QLabel(name)
 | 
			
		||||
        nameLabel.setAlignment(Qt.AlignmentFlag.AlignCenter)
 | 
			
		||||
        nameLabel.setStyleSheet(self.theme.GAME_CARD_NAME_LABEL_STYLE)
 | 
			
		||||
        layout.addWidget(nameLabel)
 | 
			
		||||
 | 
			
		||||
        font_size = self.nameLabel.font().pointSizeF()
 | 
			
		||||
        self.base_font_size = font_size if font_size > 0 else 10.0
 | 
			
		||||
 | 
			
		||||
        self.update_scale()
 | 
			
		||||
 | 
			
		||||
        # Force initial layout update to ensure correct geometry
 | 
			
		||||
        self.updateGeometry()
 | 
			
		||||
        parent = self.parentWidget()
 | 
			
		||||
        if parent:
 | 
			
		||||
            layout = parent.layout()
 | 
			
		||||
            if layout:
 | 
			
		||||
                layout.invalidate()
 | 
			
		||||
            parent.updateGeometry()
 | 
			
		||||
 | 
			
		||||
    def on_cover_loaded(self, pixmap):
 | 
			
		||||
        self.base_pixmap = pixmap
 | 
			
		||||
        self.update_cover_pixmap()
 | 
			
		||||
 | 
			
		||||
    def update_cover_pixmap(self):
 | 
			
		||||
        if self.base_pixmap:
 | 
			
		||||
            scaled_width = int(self.base_card_width * self._scale)
 | 
			
		||||
            scaled_pixmap = self.base_pixmap.scaled(scaled_width, int(scaled_width * 1.5), Qt.AspectRatioMode.KeepAspectRatioByExpanding, Qt.TransformationMode.SmoothTransformation)
 | 
			
		||||
            rounded_pixmap = round_corners(scaled_pixmap, int(15 * self._scale))
 | 
			
		||||
            self.coverLabel.setPixmap(rounded_pixmap)
 | 
			
		||||
 | 
			
		||||
    def _position_badges(self, current_width):
 | 
			
		||||
        right_margin = int(8 * self._scale)
 | 
			
		||||
        badge_spacing = int(current_width * 0.02)
 | 
			
		||||
        top_y = int(10 * self._scale)
 | 
			
		||||
    def _position_badges(self, card_width):
 | 
			
		||||
        """Позиционирует бейджи на основе ширины карточки."""
 | 
			
		||||
        right_margin = 8
 | 
			
		||||
        badge_spacing = int(card_width * 0.02)  # 2% от ширины карточки
 | 
			
		||||
        top_y = 10
 | 
			
		||||
        badge_y_positions = []
 | 
			
		||||
        badge_width = int(current_width * 2/3)
 | 
			
		||||
        badge_width = int(card_width * 2/3)
 | 
			
		||||
 | 
			
		||||
        badges = [
 | 
			
		||||
            (self.steam_visible, self.steamLabel),
 | 
			
		||||
@@ -225,99 +256,80 @@ class GameCard(QFrame):
 | 
			
		||||
 | 
			
		||||
        for is_visible, badge in badges:
 | 
			
		||||
            if is_visible:
 | 
			
		||||
                badge_x = current_width - badge_width - right_margin
 | 
			
		||||
                badge_x = card_width - badge_width - right_margin
 | 
			
		||||
                badge_y = badge_y_positions[-1] + badge_spacing if badge_y_positions else top_y
 | 
			
		||||
                badge.move(int(badge_x), int(badge_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 update_scale(self):
 | 
			
		||||
        scaled_width = int(self.base_card_width * self._scale)
 | 
			
		||||
        scaled_height = int(self.base_card_width * 1.8 * self._scale)
 | 
			
		||||
        scaled_extra = int(self.base_extra_margin * self._scale)
 | 
			
		||||
        self.setFixedSize(scaled_width + scaled_extra, scaled_height + scaled_extra)
 | 
			
		||||
        self.layout_.setContentsMargins(scaled_extra // 2, scaled_extra // 2, scaled_extra // 2, scaled_extra // 2)
 | 
			
		||||
    def update_card_size(self, new_width: int):
 | 
			
		||||
        """Обновляет размер карточки, обложки и бейджей."""
 | 
			
		||||
        self.card_width = new_width
 | 
			
		||||
        extra_margin = 20
 | 
			
		||||
        self.setFixedSize(new_width + extra_margin, int(new_width * 1.6) + extra_margin)
 | 
			
		||||
 | 
			
		||||
        self.coverWidget.setFixedSize(scaled_width, int(scaled_width * 1.5))
 | 
			
		||||
        self.coverLabel.setFixedSize(scaled_width, int(scaled_width * 1.5))
 | 
			
		||||
        if self.coverLabel is None:
 | 
			
		||||
            return
 | 
			
		||||
 | 
			
		||||
        self.update_cover_pixmap()
 | 
			
		||||
        coverWidget = self.coverLabel.parentWidget()
 | 
			
		||||
        if coverWidget is None:
 | 
			
		||||
            return
 | 
			
		||||
 | 
			
		||||
        favorite_size = (int(self.theme.favoriteLabelSize[0] * self._scale), int(self.theme.favoriteLabelSize[1] * self._scale))
 | 
			
		||||
        self.favoriteLabel.setFixedSize(*favorite_size)
 | 
			
		||||
        self.favoriteLabel.move(int(8 * self._scale), int(8 * self._scale))
 | 
			
		||||
        coverWidget.setFixedSize(new_width, int(new_width * 1.2))
 | 
			
		||||
        self.coverLabel.setFixedSize(new_width, int(new_width * 1.2))
 | 
			
		||||
 | 
			
		||||
        badge_width = int(scaled_width * 2/3)
 | 
			
		||||
        icon_size = int(scaled_width * 0.06)
 | 
			
		||||
        icon_space = int(scaled_width * 0.012)
 | 
			
		||||
        label_ref = weakref.ref(self.coverLabel)
 | 
			
		||||
        def on_cover_loaded(pixmap):
 | 
			
		||||
            label = label_ref()
 | 
			
		||||
            if label:
 | 
			
		||||
                scaled_pixmap = pixmap.scaled(new_width, int(new_width * 1.2), Qt.AspectRatioMode.KeepAspectRatioByExpanding, Qt.TransformationMode.SmoothTransformation)
 | 
			
		||||
                rounded_pixmap = round_corners(scaled_pixmap, 15)
 | 
			
		||||
                label.setPixmap(rounded_pixmap)
 | 
			
		||||
 | 
			
		||||
        load_pixmap_async(self.cover_path or "", new_width, int(new_width * 1.2), on_cover_loaded)
 | 
			
		||||
 | 
			
		||||
        # Обновляем размеры и шрифты бейджей
 | 
			
		||||
        badge_width = int(new_width * 2/3)
 | 
			
		||||
        icon_size = int(new_width * 0.06)
 | 
			
		||||
        icon_space = int(new_width * 0.012)
 | 
			
		||||
        for label in [self.steamLabel, self.egsLabel, self.portprotonLabel, self.protondbLabel, self.anticheatLabel]:
 | 
			
		||||
            if label is not None:
 | 
			
		||||
                label.setFixedWidth(badge_width)
 | 
			
		||||
                label.setIconSize(icon_size, icon_space)
 | 
			
		||||
                label.setCardWidth(scaled_width)
 | 
			
		||||
                label.setCardWidth(new_width)  # Пересчитываем размер шрифта
 | 
			
		||||
 | 
			
		||||
        self._position_badges(scaled_width)
 | 
			
		||||
        # Перепозиционируем бейджи
 | 
			
		||||
        self._position_badges(new_width)
 | 
			
		||||
 | 
			
		||||
        if self.base_font_size is not None:
 | 
			
		||||
            font = self.nameLabel.font()
 | 
			
		||||
            new_font_size = self.base_font_size * self._scale
 | 
			
		||||
            if new_font_size > 0:
 | 
			
		||||
                font.setPointSizeF(new_font_size)
 | 
			
		||||
                self.nameLabel.setFont(font)
 | 
			
		||||
 | 
			
		||||
        self.shadow.setBlurRadius(int(20 * self._scale))
 | 
			
		||||
 | 
			
		||||
        self.updateGeometry()
 | 
			
		||||
        self.update()
 | 
			
		||||
 | 
			
		||||
        # Ensure parent layout is updated safely
 | 
			
		||||
        parent = self.parentWidget()
 | 
			
		||||
        if parent:
 | 
			
		||||
            layout = parent.layout()
 | 
			
		||||
            if layout:
 | 
			
		||||
                layout.invalidate()
 | 
			
		||||
                layout.activate()
 | 
			
		||||
                layout.update()
 | 
			
		||||
            parent.updateGeometry()
 | 
			
		||||
 | 
			
		||||
    def update_card_size(self, new_width: int):
 | 
			
		||||
        self.base_card_width = new_width
 | 
			
		||||
        load_pixmap_async(self.cover_path or "", new_width, int(new_width * 1.5), self.on_cover_loaded)
 | 
			
		||||
        self.update_scale()
 | 
			
		||||
 | 
			
		||||
    def update_badge_visibility(self, display_filter: str):
 | 
			
		||||
        """Обновляет видимость бейджей на основе display_filter."""
 | 
			
		||||
        self.display_filter = display_filter
 | 
			
		||||
        self.steam_visible = (str(self.game_source).lower() == "steam" and self.display_filter in ("all", "favorites"))
 | 
			
		||||
        self.egs_visible = (str(self.game_source).lower() == "epic" and self.display_filter in ("all", "favorites"))
 | 
			
		||||
        self.portproton_visible = (str(self.game_source).lower() == "portproton" and self.display_filter in ("all", "favorites"))
 | 
			
		||||
        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)
 | 
			
		||||
 | 
			
		||||
        scaled_width = int(self.base_card_width * self._scale)
 | 
			
		||||
        self._position_badges(scaled_width)
 | 
			
		||||
 | 
			
		||||
        # Update layout after visibility changes
 | 
			
		||||
        self.updateGeometry()
 | 
			
		||||
        parent = self.parentWidget()
 | 
			
		||||
        if parent:
 | 
			
		||||
            layout = parent.layout()
 | 
			
		||||
            if layout:
 | 
			
		||||
                layout.invalidate()
 | 
			
		||||
                layout.update()
 | 
			
		||||
            parent.updateGeometry()
 | 
			
		||||
        # Перепозиционируем бейджи
 | 
			
		||||
        self._position_badges(self.card_width)
 | 
			
		||||
 | 
			
		||||
    def _show_context_menu(self, pos):
 | 
			
		||||
        """Delegate context menu display to ContextMenuManager."""
 | 
			
		||||
        if self.context_menu_manager:
 | 
			
		||||
            self.context_menu_manager.show_context_menu(self, pos)
 | 
			
		||||
 | 
			
		||||
@@ -375,6 +387,7 @@ class GameCard(QFrame):
 | 
			
		||||
        return ""
 | 
			
		||||
 | 
			
		||||
    def open_portproton_forum_topic(self):
 | 
			
		||||
        """Open the PortProton forum topic or search page for this game."""
 | 
			
		||||
        result = self.portproton_api.get_forum_topic_slug(self.name)
 | 
			
		||||
        base_url = "https://linux-gaming.ru/"
 | 
			
		||||
        if result.startswith("search?q="):
 | 
			
		||||
@@ -434,18 +447,8 @@ class GameCard(QFrame):
 | 
			
		||||
            self.gradientAngleChanged.emit()
 | 
			
		||||
            self.update()
 | 
			
		||||
 | 
			
		||||
    def getScale(self) -> float:
 | 
			
		||||
        return self._scale
 | 
			
		||||
 | 
			
		||||
    def setScale(self, value: float):
 | 
			
		||||
        if self._scale != value:
 | 
			
		||||
            self._scale = value
 | 
			
		||||
            self.update_scale()
 | 
			
		||||
            self.scaleChanged.emit()
 | 
			
		||||
 | 
			
		||||
    borderWidth = Property(int, getBorderWidth, setBorderWidth, None, "", notify=cast(Callable[[], None], borderWidthChanged))
 | 
			
		||||
    gradientAngle = Property(float, getGradientAngle, setGradientAngle, None, "", notify=cast(Callable[[], None], gradientAngleChanged))
 | 
			
		||||
    scale = Property(float, getScale, setScale, None, "", notify=cast(Callable[[], None], scaleChanged))
 | 
			
		||||
 | 
			
		||||
    def paintEvent(self, event):
 | 
			
		||||
        super().paintEvent(event)
 | 
			
		||||
@@ -484,7 +487,6 @@ class GameCard(QFrame):
 | 
			
		||||
            )
 | 
			
		||||
        super().mousePressEvent(event)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    def keyPressEvent(self, event):
 | 
			
		||||
        if event.key() in (Qt.Key.Key_Return, Qt.Key.Key_Enter):
 | 
			
		||||
            self.select_callback(
 | 
			
		||||
 
 | 
			
		||||
@@ -3,6 +3,7 @@ from PySide6.QtGui import QPen, QColor, QPixmap, QPainter, QPainterPath
 | 
			
		||||
from PySide6.QtCore import Qt, QFile, QEvent, QByteArray, QEasingCurve, QPropertyAnimation
 | 
			
		||||
from PySide6.QtWidgets import QGraphicsItem, QToolButton, QFrame, QLabel, QGraphicsScene, QHBoxLayout, QWidget, QGraphicsView, QVBoxLayout, QSizePolicy
 | 
			
		||||
from PySide6.QtWidgets import QSpacerItem, QGraphicsPixmapItem, QDialog, QApplication
 | 
			
		||||
import portprotonqt.themes.standart.styles as default_styles
 | 
			
		||||
from portprotonqt.config_utils import read_theme_from_config
 | 
			
		||||
from portprotonqt.theme_manager import ThemeManager
 | 
			
		||||
from portprotonqt.downloader import Downloader
 | 
			
		||||
@@ -176,8 +177,7 @@ class FullscreenDialog(QDialog):
 | 
			
		||||
 | 
			
		||||
        self.images = images
 | 
			
		||||
        self.current_index = current_index
 | 
			
		||||
        self.theme_manager = ThemeManager()
 | 
			
		||||
        self.theme = theme if theme is not None else self.theme_manager.apply_theme(read_theme_from_config())
 | 
			
		||||
        self.theme = theme if theme else default_styles
 | 
			
		||||
 | 
			
		||||
        self.setWindowFlags(Qt.WindowType.FramelessWindowHint | Qt.WindowType.Dialog)
 | 
			
		||||
        self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
 | 
			
		||||
@@ -378,8 +378,7 @@ class ImageCarousel(QGraphicsView):
 | 
			
		||||
        self.images = images  # Список кортежей: (QPixmap, caption)
 | 
			
		||||
        self.image_items = []
 | 
			
		||||
        self._animation = None
 | 
			
		||||
        self.theme_manager = ThemeManager()
 | 
			
		||||
        self.theme = theme if theme is not None else self.theme_manager.apply_theme(read_theme_from_config())
 | 
			
		||||
        self.theme = theme if theme else default_styles
 | 
			
		||||
        self.max_height = 300  # Default height for images
 | 
			
		||||
        self.init_ui()
 | 
			
		||||
        self.create_arrows()
 | 
			
		||||
 
 | 
			
		||||
@@ -3,11 +3,10 @@ import threading
 | 
			
		||||
import os
 | 
			
		||||
from typing import Protocol, cast
 | 
			
		||||
from evdev import InputDevice, InputEvent, ecodes, list_devices, ff
 | 
			
		||||
from enum import Enum
 | 
			
		||||
from pyudev import Context, Monitor, MonitorObserver, Device
 | 
			
		||||
from PySide6.QtWidgets import QWidget, QStackedWidget, QApplication, QScrollArea, QLineEdit, QDialog, QMenu, QComboBox, QListView, QMessageBox, QListWidget
 | 
			
		||||
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, QMouseEvent
 | 
			
		||||
from PySide6.QtGui import QKeyEvent
 | 
			
		||||
from portprotonqt.logger import get_logger
 | 
			
		||||
from portprotonqt.image_utils import FullscreenDialog
 | 
			
		||||
from portprotonqt.custom_widgets import NavLabel, AutoSizeButton
 | 
			
		||||
@@ -32,8 +31,6 @@ class MainWindowProtocol(Protocol):
 | 
			
		||||
        ...
 | 
			
		||||
    def on_slider_released(self) -> None:
 | 
			
		||||
        ...
 | 
			
		||||
    def isActiveWindow(self) -> bool:
 | 
			
		||||
        ...
 | 
			
		||||
    stackedWidget: QStackedWidget
 | 
			
		||||
    tabButtons: dict[int, QWidget]
 | 
			
		||||
    gamesListWidget: QWidget
 | 
			
		||||
@@ -41,29 +38,23 @@ class MainWindowProtocol(Protocol):
 | 
			
		||||
    current_exec_line: str | None
 | 
			
		||||
    current_add_game_dialog: AddGameDialog | None
 | 
			
		||||
 | 
			
		||||
# Mapping of actions to evdev button codes, includes Xbox, PlayStation and Nintendo 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
 | 
			
		||||
# https://github.com/torvalds/linux/blob/master/drivers/hid/hid-nintendo
 | 
			
		||||
BUTTONS = {
 | 
			
		||||
    'confirm':       {ecodes.BTN_SOUTH},           # A (Xbox) / Cross (PS) / B (Switch)
 | 
			
		||||
    'back':          {ecodes.BTN_EAST},            # B (Xbox) / Circle (PS) / A (Switch)
 | 
			
		||||
    'add_game':      {ecodes.BTN_NORTH},           # X (Xbox) / Triangle (PS) / Y (Switch)
 | 
			
		||||
    'prev_dir':      {ecodes.BTN_WEST},            # Y (Xbox) / Square (PS) / X (Switch)
 | 
			
		||||
    'prev_tab':      {ecodes.BTN_TL},              # LB (Xbox) / L1 (PS) / L (Switch)
 | 
			
		||||
    'next_tab':      {ecodes.BTN_TR},              # RB (Xbox) / R1 (PS) / R (Switch)
 | 
			
		||||
    'context_menu':  {ecodes.BTN_START},           # Start (Xbox) / Options (PS) / + (Switch)
 | 
			
		||||
    'menu':          {ecodes.BTN_SELECT},          # Select (Xbox) / Share (PS) / - (Switch)
 | 
			
		||||
    'guide':         {ecodes.BTN_MODE},            # Xbox Button / PS Button / Home (Switch)
 | 
			
		||||
    'increase_size': {ecodes.ABS_RZ},              # RT (Xbox) / R2 (PS) / ZR (Switch)
 | 
			
		||||
    'decrease_size': {ecodes.ABS_Z},               # LT (Xbox) / L2 (PS) / ZL (Switch)
 | 
			
		||||
    'confirm':       {ecodes.BTN_SOUTH},           # A (Xbox) / Cross (PS)
 | 
			
		||||
    'back':          {ecodes.BTN_EAST},            # B (Xbox) / Circle (PS)
 | 
			
		||||
    'add_game':      {ecodes.BTN_NORTH},           # X (Xbox) / Triangle (PS)
 | 
			
		||||
    'prev_dir':      {ecodes.BTN_WEST},            # Y (Xbox) / Square (PS)
 | 
			
		||||
    'prev_tab':      {ecodes.BTN_TL},              # LB (Xbox) / L1 (PS)
 | 
			
		||||
    'next_tab':      {ecodes.BTN_TR},              # RB (Xbox) / R1 (PS)
 | 
			
		||||
    'context_menu':  {ecodes.BTN_START},           # Start (Xbox) / Options (PS)
 | 
			
		||||
    'menu':          {ecodes.BTN_SELECT},          # Select (Xbox) / Share (PS)
 | 
			
		||||
    'guide':         {ecodes.BTN_MODE},            # Xbox Button / PS Button
 | 
			
		||||
    'increase_size': {ecodes.BTN_TR2},             # RT (Xbox) / R2 (PS)
 | 
			
		||||
    'decrease_size': {ecodes.BTN_TL2},             # LT (Xbox) / L2 (PS)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class GamepadType(Enum):
 | 
			
		||||
    XBOX = "Xbox"
 | 
			
		||||
    PLAYSTATION = "PlayStation"
 | 
			
		||||
    UNKNOWN = "Unknown"
 | 
			
		||||
 | 
			
		||||
class InputManager(QObject):
 | 
			
		||||
    """
 | 
			
		||||
    Manages input from gamepads and keyboards for navigating the application interface.
 | 
			
		||||
@@ -85,7 +76,6 @@ class InputManager(QObject):
 | 
			
		||||
        super().__init__(cast(QObject, main_window))
 | 
			
		||||
        self._parent = main_window
 | 
			
		||||
        self._gamepad_handling_enabled = True
 | 
			
		||||
        self.gamepad_type = GamepadType.UNKNOWN
 | 
			
		||||
        # Ensure attributes exist on main_window
 | 
			
		||||
        self._parent.currentDetailPage = getattr(self._parent, 'currentDetailPage', None)
 | 
			
		||||
        self._parent.current_exec_line = getattr(self._parent, 'current_exec_line', None)
 | 
			
		||||
@@ -142,38 +132,6 @@ class InputManager(QObject):
 | 
			
		||||
        # Initialize evdev + hotplug
 | 
			
		||||
        self.init_gamepad()
 | 
			
		||||
 | 
			
		||||
    def detect_gamepad_type(self, device: InputDevice) -> GamepadType:
 | 
			
		||||
        """
 | 
			
		||||
        Определяет тип геймпада по capabilities
 | 
			
		||||
        """
 | 
			
		||||
        caps = device.capabilities()
 | 
			
		||||
        keys = set(caps.get(ecodes.EV_KEY, []))
 | 
			
		||||
 | 
			
		||||
        # Для EV_ABS вытаскиваем только коды (первый элемент кортежа)
 | 
			
		||||
        abs_axes = {a if isinstance(a, int) else a[0] for a in caps.get(ecodes.EV_ABS, [])}
 | 
			
		||||
 | 
			
		||||
        # Xbox layout
 | 
			
		||||
        if {ecodes.BTN_SOUTH, ecodes.BTN_EAST, ecodes.BTN_NORTH, ecodes.BTN_WEST}.issubset(keys):
 | 
			
		||||
            if {ecodes.ABS_X, ecodes.ABS_Y, ecodes.ABS_RX, ecodes.ABS_RY}.issubset(abs_axes):
 | 
			
		||||
                self.gamepad_type = GamepadType.XBOX
 | 
			
		||||
                return GamepadType.XBOX
 | 
			
		||||
 | 
			
		||||
        # PlayStation layout
 | 
			
		||||
        if ecodes.BTN_TOUCH in keys or (ecodes.BTN_DPAD_UP in keys and ecodes.BTN_EAST in keys):
 | 
			
		||||
            self.gamepad_type = GamepadType.PLAYSTATION
 | 
			
		||||
            logger.info(f"Detected {self.gamepad_type.value} controller: {device.name}")
 | 
			
		||||
            return GamepadType.PLAYSTATION
 | 
			
		||||
 | 
			
		||||
        # Steam Controller / Deck (трекпады)
 | 
			
		||||
        if any(a for a in abs_axes if a >= ecodes.ABS_MT_SLOT):
 | 
			
		||||
            self.gamepad_type = GamepadType.XBOX
 | 
			
		||||
            logger.info(f"Detected {self.gamepad_type.value} controller: {device.name}")
 | 
			
		||||
            return GamepadType.XBOX
 | 
			
		||||
 | 
			
		||||
        # Fallback
 | 
			
		||||
        self.gamepad_type = GamepadType.XBOX
 | 
			
		||||
        return GamepadType.XBOX
 | 
			
		||||
 | 
			
		||||
    def enable_file_explorer_mode(self, file_explorer):
 | 
			
		||||
        """Настройка обработки геймпада для FileExplorer"""
 | 
			
		||||
        try:
 | 
			
		||||
@@ -203,20 +161,7 @@ class InputManager(QObject):
 | 
			
		||||
 | 
			
		||||
    def handle_file_explorer_button(self, button_code):
 | 
			
		||||
        try:
 | 
			
		||||
            popup = QApplication.activePopupWidget()
 | 
			
		||||
            if isinstance(popup, QMenu):
 | 
			
		||||
                if button_code in BUTTONS['confirm']:  # A button (BTN_SOUTH)
 | 
			
		||||
                    if popup.activeAction():
 | 
			
		||||
                        popup.activeAction().trigger()
 | 
			
		||||
                        popup.close()
 | 
			
		||||
                    return
 | 
			
		||||
                elif button_code in BUTTONS['back']:  # B button
 | 
			
		||||
                    popup.close()
 | 
			
		||||
                    return
 | 
			
		||||
                return  # Skip other handling if menu is open
 | 
			
		||||
 | 
			
		||||
            if not self.file_explorer or not hasattr(self.file_explorer, 'file_list'):
 | 
			
		||||
                logger.debug("No file explorer or file_list available")
 | 
			
		||||
                return
 | 
			
		||||
 | 
			
		||||
            focused_widget = QApplication.focusWidget()
 | 
			
		||||
@@ -224,37 +169,27 @@ class InputManager(QObject):
 | 
			
		||||
                if isinstance(focused_widget, AutoSizeButton) and hasattr(self.file_explorer, 'drive_buttons') and focused_widget in self.file_explorer.drive_buttons:
 | 
			
		||||
                    self.file_explorer.select_drive()  # Select the focused drive
 | 
			
		||||
                elif self.file_explorer.file_list.count() == 0:
 | 
			
		||||
                    logger.debug("File list is empty")
 | 
			
		||||
                    return
 | 
			
		||||
                else:
 | 
			
		||||
                    selected = self.file_explorer.file_list.currentItem().text()
 | 
			
		||||
                    full_path = os.path.join(self.file_explorer.current_path, selected)
 | 
			
		||||
                    if os.path.isdir(full_path):
 | 
			
		||||
                        # Открываем директорию
 | 
			
		||||
                        self.file_explorer.current_path = os.path.normpath(full_path)
 | 
			
		||||
                        self.file_explorer.update_file_list()
 | 
			
		||||
                    elif not self.file_explorer.directory_only:
 | 
			
		||||
                        # Выбираем файл, если directory_only=False
 | 
			
		||||
                        self.file_explorer.file_signal.file_selected.emit(os.path.normpath(full_path))
 | 
			
		||||
                        self.file_explorer.accept()
 | 
			
		||||
                    else:
 | 
			
		||||
                        logger.debug("Selected item is not a directory, cannot select: %s", full_path)
 | 
			
		||||
            elif button_code in BUTTONS['context_menu']:  # Start button (BTN_START)
 | 
			
		||||
                if self.file_explorer.file_list.count() == 0:
 | 
			
		||||
                    logger.debug("File list is empty, cannot show context menu")
 | 
			
		||||
                    return
 | 
			
		||||
                current_item = self.file_explorer.file_list.currentItem()
 | 
			
		||||
                if current_item:
 | 
			
		||||
                    item_rect = self.file_explorer.file_list.visualItemRect(current_item)
 | 
			
		||||
                    pos = item_rect.center()  # Use local coordinates for itemAt check
 | 
			
		||||
                    self.file_explorer.show_folder_context_menu(pos)
 | 
			
		||||
                else:
 | 
			
		||||
                    logger.debug("No item selected for context menu")
 | 
			
		||||
            elif button_code in BUTTONS['add_game']:  # X button
 | 
			
		||||
                if self.file_explorer.file_list.count() == 0:
 | 
			
		||||
                    logger.debug("File list is empty")
 | 
			
		||||
                    return
 | 
			
		||||
                selected = self.file_explorer.file_list.currentItem().text()
 | 
			
		||||
                full_path = os.path.join(self.file_explorer.current_path, selected)
 | 
			
		||||
                if os.path.isdir(full_path):
 | 
			
		||||
                    # Подтверждаем выбор директории
 | 
			
		||||
                    self.file_explorer.file_signal.file_selected.emit(os.path.normpath(full_path))
 | 
			
		||||
                    self.file_explorer.accept()
 | 
			
		||||
                else:
 | 
			
		||||
@@ -267,29 +202,12 @@ class InputManager(QObject):
 | 
			
		||||
                if self.original_button_handler:
 | 
			
		||||
                    self.original_button_handler(button_code)
 | 
			
		||||
        except Exception as e:
 | 
			
		||||
            logger.error("Error in FileExplorer button handler: %s", e)
 | 
			
		||||
            logger.error(f"Error in FileExplorer button handler: {e}")
 | 
			
		||||
 | 
			
		||||
    def handle_file_explorer_dpad(self, code, value, current_time):
 | 
			
		||||
        """Обработка движения D-pad и левого стика для FileExplorer"""
 | 
			
		||||
        try:
 | 
			
		||||
            popup = QApplication.activePopupWidget()
 | 
			
		||||
            if isinstance(popup, QMenu):
 | 
			
		||||
                if code == ecodes.ABS_HAT0Y and value != 0:
 | 
			
		||||
                    actions = popup.actions()
 | 
			
		||||
                    if not actions:
 | 
			
		||||
                        return
 | 
			
		||||
                    current_action = popup.activeAction()
 | 
			
		||||
                    current_idx = actions.index(current_action) if current_action in actions else -1
 | 
			
		||||
                    if value > 0:  # Down
 | 
			
		||||
                        next_idx = (current_idx + 1) % len(actions) if current_idx != -1 else 0
 | 
			
		||||
                        popup.setActiveAction(actions[next_idx])
 | 
			
		||||
                    elif value < 0:  # Up
 | 
			
		||||
                        next_idx = (current_idx - 1) % len(actions) if current_idx != -1 else len(actions) - 1
 | 
			
		||||
                        popup.setActiveAction(actions[next_idx])
 | 
			
		||||
                return  # Skip other handling if menu is open
 | 
			
		||||
 | 
			
		||||
            if not self.file_explorer or not hasattr(self.file_explorer, 'file_list') or not self.file_explorer.file_list:
 | 
			
		||||
                logger.debug("No file explorer or file_list available")
 | 
			
		||||
                return
 | 
			
		||||
 | 
			
		||||
            focused_widget = QApplication.focusWidget()
 | 
			
		||||
@@ -298,17 +216,14 @@ class InputManager(QObject):
 | 
			
		||||
                if not isinstance(focused_widget, AutoSizeButton) or focused_widget not in self.file_explorer.drive_buttons:
 | 
			
		||||
                    # If not focused on a drive button, focus the first one
 | 
			
		||||
                    self.file_explorer.drive_buttons[0].setFocus()
 | 
			
		||||
                    self.file_explorer.ensure_button_visible(self.file_explorer.drive_buttons[0])
 | 
			
		||||
                    return
 | 
			
		||||
                current_idx = self.file_explorer.drive_buttons.index(focused_widget)
 | 
			
		||||
                if value < 0:  # Left
 | 
			
		||||
                    next_idx = max(current_idx - 1, 0)
 | 
			
		||||
                    self.file_explorer.drive_buttons[next_idx].setFocus()
 | 
			
		||||
                    self.file_explorer.ensure_button_visible(self.file_explorer.drive_buttons[next_idx])
 | 
			
		||||
                elif value > 0:  # Right
 | 
			
		||||
                    next_idx = min(current_idx + 1, len(self.file_explorer.drive_buttons) - 1)
 | 
			
		||||
                    self.file_explorer.drive_buttons[next_idx].setFocus()
 | 
			
		||||
                    self.file_explorer.ensure_button_visible(self.file_explorer.drive_buttons[next_idx])
 | 
			
		||||
            elif code in (ecodes.ABS_HAT0Y, ecodes.ABS_Y):
 | 
			
		||||
                if isinstance(focused_widget, AutoSizeButton) and focused_widget in self.file_explorer.drive_buttons:
 | 
			
		||||
                    # Move focus to file list if navigating down from drive buttons
 | 
			
		||||
@@ -349,7 +264,7 @@ class InputManager(QObject):
 | 
			
		||||
            elif self.original_dpad_handler:
 | 
			
		||||
                self.original_dpad_handler(code, value, current_time)
 | 
			
		||||
        except Exception as e:
 | 
			
		||||
            logger.error("Error in FileExplorer dpad handler: %s", e)
 | 
			
		||||
            logger.error(f"Error in FileExplorer dpad handler: {e}")
 | 
			
		||||
 | 
			
		||||
    def handle_navigation_repeat(self):
 | 
			
		||||
        """Плавное повторение движения с переменной скоростью для FileExplorer"""
 | 
			
		||||
@@ -446,14 +361,17 @@ class InputManager(QObject):
 | 
			
		||||
        if not self._gamepad_handling_enabled:
 | 
			
		||||
            return
 | 
			
		||||
        try:
 | 
			
		||||
            # Ignore gamepad events if a game is launched
 | 
			
		||||
            if getattr(self._parent, '_gameLaunched', False):
 | 
			
		||||
                return
 | 
			
		||||
 | 
			
		||||
            app = QApplication.instance()
 | 
			
		||||
            if not app:
 | 
			
		||||
                return
 | 
			
		||||
            active = QApplication.activeWindow()
 | 
			
		||||
            focused = QApplication.focusWidget()
 | 
			
		||||
            popup = QApplication.activePopupWidget()
 | 
			
		||||
            modal_dialog = QApplication.activeModalWidget()
 | 
			
		||||
            if not app or not active:
 | 
			
		||||
                return
 | 
			
		||||
 | 
			
		||||
            # Handle Guide button to open system overlay
 | 
			
		||||
            if button_code in BUTTONS['guide']:
 | 
			
		||||
@@ -598,13 +516,16 @@ class InputManager(QObject):
 | 
			
		||||
        if not self._gamepad_handling_enabled:
 | 
			
		||||
            return
 | 
			
		||||
        try:
 | 
			
		||||
            # Ignore gamepad events if a game is launched
 | 
			
		||||
            if getattr(self._parent, '_gameLaunched', False):
 | 
			
		||||
                return
 | 
			
		||||
 | 
			
		||||
            app = QApplication.instance()
 | 
			
		||||
            if not app:
 | 
			
		||||
                return
 | 
			
		||||
            active = QApplication.activeWindow()
 | 
			
		||||
            focused = QApplication.focusWidget()
 | 
			
		||||
            popup = QApplication.activePopupWidget()
 | 
			
		||||
            if not app or not active:
 | 
			
		||||
                return
 | 
			
		||||
 | 
			
		||||
            # Update D-pad state
 | 
			
		||||
            if value != 0:
 | 
			
		||||
@@ -709,107 +630,87 @@ class InputManager(QObject):
 | 
			
		||||
                        scroll_area.ensureWidgetVisible(game_cards[0], 50, 50)
 | 
			
		||||
                    return
 | 
			
		||||
 | 
			
		||||
                cards = self._parent.gamesListWidget.findChildren(GameCard, options=Qt.FindChildOption.FindChildrenRecursively)
 | 
			
		||||
                if not cards:
 | 
			
		||||
                    return
 | 
			
		||||
                # Group cards by rows with tolerance for y-position
 | 
			
		||||
                # Group cards by rows based on y-coordinate
 | 
			
		||||
                rows = {}
 | 
			
		||||
                y_tolerance = 10  # Allow slight variations in y-position
 | 
			
		||||
                for card in cards:
 | 
			
		||||
                for card in game_cards:
 | 
			
		||||
                    y = card.pos().y()
 | 
			
		||||
                    matched = False
 | 
			
		||||
                    for row_y in rows:
 | 
			
		||||
                        if abs(y - row_y) <= y_tolerance:
 | 
			
		||||
                            rows[row_y].append(card)
 | 
			
		||||
                            matched = True
 | 
			
		||||
                            break
 | 
			
		||||
                    if not matched:
 | 
			
		||||
                        rows[y] = [card]
 | 
			
		||||
                    if y not in rows:
 | 
			
		||||
                        rows[y] = []
 | 
			
		||||
                    rows[y].append(card)
 | 
			
		||||
                # Sort cards in each row by x-coordinate
 | 
			
		||||
                for y in rows:
 | 
			
		||||
                    rows[y].sort(key=lambda c: c.pos().x())
 | 
			
		||||
                # Sort rows by y-coordinate
 | 
			
		||||
                sorted_rows = sorted(rows.items(), key=lambda x: x[0])
 | 
			
		||||
                if not sorted_rows:
 | 
			
		||||
                    return
 | 
			
		||||
                current_row_idx = None
 | 
			
		||||
                current_col_idx = None
 | 
			
		||||
                for row_idx, (_y, row_cards) in enumerate(sorted_rows):
 | 
			
		||||
                    for idx, card in enumerate(row_cards):
 | 
			
		||||
                        if card == focused:
 | 
			
		||||
                            current_row_idx = row_idx
 | 
			
		||||
                            current_col_idx = idx
 | 
			
		||||
                            break
 | 
			
		||||
                    if current_row_idx is not None:
 | 
			
		||||
                        break
 | 
			
		||||
 | 
			
		||||
                # Fallback: if focused card not found, select closest row by y-position
 | 
			
		||||
                if current_row_idx is None:
 | 
			
		||||
                    if not sorted_rows:  # Additional safety check
 | 
			
		||||
                        return
 | 
			
		||||
                    focused_y = focused.pos().y()
 | 
			
		||||
                    current_row_idx = min(range(len(sorted_rows)), key=lambda i: abs(sorted_rows[i][0] - focused_y))
 | 
			
		||||
                    if current_row_idx >= len(sorted_rows):  # Safety check
 | 
			
		||||
                        return
 | 
			
		||||
                    current_row = sorted_rows[current_row_idx][1]
 | 
			
		||||
                    focused_x = focused.pos().x() + focused.width() / 2
 | 
			
		||||
                    current_col_idx = min(range(len(current_row)), key=lambda i: abs((current_row[i].pos().x() + current_row[i].width() / 2) - focused_x), default=0) # type: ignore
 | 
			
		||||
 | 
			
		||||
                # Add null checks before using current_row_idx and current_col_idx
 | 
			
		||||
                if current_row_idx is None or current_col_idx is None or current_row_idx >= len(sorted_rows):
 | 
			
		||||
                    return
 | 
			
		||||
 | 
			
		||||
                # Find current row and column
 | 
			
		||||
                current_y = focused.pos().y()
 | 
			
		||||
                current_row_idx = next(i for i, (y, _) in enumerate(sorted_rows) if y == current_y)
 | 
			
		||||
                current_row = sorted_rows[current_row_idx][1]
 | 
			
		||||
                if code == ecodes.ABS_HAT0X and value != 0:
 | 
			
		||||
                current_col_idx = current_row.index(focused)
 | 
			
		||||
 | 
			
		||||
                if code == ecodes.ABS_HAT0X and value != 0:  # Left/Right
 | 
			
		||||
                    if value < 0:  # Left
 | 
			
		||||
                        if current_col_idx > 0:
 | 
			
		||||
                            next_card = current_row[current_col_idx - 1]
 | 
			
		||||
                            next_card.setFocus(Qt.FocusReason.OtherFocusReason)
 | 
			
		||||
                        next_col_idx = current_col_idx - 1
 | 
			
		||||
                        if next_col_idx >= 0:
 | 
			
		||||
                            next_card = current_row[next_col_idx]
 | 
			
		||||
                            next_card.setFocus()
 | 
			
		||||
                            if scroll_area:
 | 
			
		||||
                                scroll_area.ensureWidgetVisible(next_card, 50, 50)
 | 
			
		||||
                        else:
 | 
			
		||||
                            # Move to the last card of the previous row if available
 | 
			
		||||
                            if current_row_idx > 0:
 | 
			
		||||
                                prev_row = sorted_rows[current_row_idx - 1][1]
 | 
			
		||||
                                next_card = prev_row[-1] if prev_row else None
 | 
			
		||||
                                if next_card:
 | 
			
		||||
                                    next_card.setFocus(Qt.FocusReason.OtherFocusReason)
 | 
			
		||||
                                    next_card.setFocus()
 | 
			
		||||
                                    if scroll_area:
 | 
			
		||||
                                        scroll_area.ensureWidgetVisible(next_card, 50, 50)
 | 
			
		||||
                    elif value > 0:  # Right
 | 
			
		||||
                        if current_col_idx < len(current_row) - 1:
 | 
			
		||||
                            next_card = current_row[current_col_idx + 1]
 | 
			
		||||
                            next_card.setFocus(Qt.FocusReason.OtherFocusReason)
 | 
			
		||||
                        next_col_idx = current_col_idx + 1
 | 
			
		||||
                        if next_col_idx < len(current_row):
 | 
			
		||||
                            next_card = current_row[next_col_idx]
 | 
			
		||||
                            next_card.setFocus()
 | 
			
		||||
                            if scroll_area:
 | 
			
		||||
                                scroll_area.ensureWidgetVisible(next_card, 50, 50)
 | 
			
		||||
                        else:
 | 
			
		||||
                            # Move to the first card of the next row if available
 | 
			
		||||
                            if current_row_idx < len(sorted_rows) - 1:
 | 
			
		||||
                                next_row = sorted_rows[current_row_idx + 1][1]
 | 
			
		||||
                                next_card = next_row[0] if next_row else None
 | 
			
		||||
                                if next_card:
 | 
			
		||||
                                    next_card.setFocus(Qt.FocusReason.OtherFocusReason)
 | 
			
		||||
                                    next_card.setFocus()
 | 
			
		||||
                                    if scroll_area:
 | 
			
		||||
                                        scroll_area.ensureWidgetVisible(next_card, 50, 50)
 | 
			
		||||
                elif code == ecodes.ABS_HAT0Y and value != 0:
 | 
			
		||||
                elif code == ecodes.ABS_HAT0Y and value != 0:  # Up/Down
 | 
			
		||||
                    if value > 0:  # Down
 | 
			
		||||
                        if current_row_idx < len(sorted_rows) - 1:
 | 
			
		||||
                            next_row = sorted_rows[current_row_idx + 1][1]
 | 
			
		||||
                            current_x = focused.pos().x() + focused.width() / 2
 | 
			
		||||
                        next_row_idx = current_row_idx + 1
 | 
			
		||||
                        if next_row_idx < len(sorted_rows):
 | 
			
		||||
                            next_row = sorted_rows[next_row_idx][1]
 | 
			
		||||
                            # Find card in same column or closest
 | 
			
		||||
                            target_x = focused.pos().x()
 | 
			
		||||
                            next_card = min(
 | 
			
		||||
                                next_row,
 | 
			
		||||
                                key=lambda c: abs((c.pos().x() + c.width() / 2) - current_x),
 | 
			
		||||
                                key=lambda c: abs(c.pos().x() - target_x),
 | 
			
		||||
                                default=None
 | 
			
		||||
                            )
 | 
			
		||||
                            if next_card:
 | 
			
		||||
                                next_card.setFocus(Qt.FocusReason.OtherFocusReason)
 | 
			
		||||
                                next_card.setFocus()
 | 
			
		||||
                                if scroll_area:
 | 
			
		||||
                                    scroll_area.ensureWidgetVisible(next_card, 50, 50)
 | 
			
		||||
                    elif value < 0:  # Up
 | 
			
		||||
                        if current_row_idx > 0:
 | 
			
		||||
                            prev_row = sorted_rows[current_row_idx - 1][1]
 | 
			
		||||
                            current_x = focused.pos().x() + focused.width() / 2
 | 
			
		||||
                        next_row_idx = current_row_idx - 1
 | 
			
		||||
                        if next_row_idx >= 0:
 | 
			
		||||
                            next_row = sorted_rows[next_row_idx][1]
 | 
			
		||||
                            # Find card in same column or closest
 | 
			
		||||
                            target_x = focused.pos().x()
 | 
			
		||||
                            next_card = min(
 | 
			
		||||
                                prev_row,
 | 
			
		||||
                                key=lambda c: abs((c.pos().x() + c.width() / 2) - current_x),
 | 
			
		||||
                                next_row,
 | 
			
		||||
                                key=lambda c: abs(c.pos().x() - target_x),
 | 
			
		||||
                                default=None
 | 
			
		||||
                            )
 | 
			
		||||
                            if next_card:
 | 
			
		||||
                                next_card.setFocus(Qt.FocusReason.OtherFocusReason)
 | 
			
		||||
                                next_card.setFocus()
 | 
			
		||||
                                if scroll_area:
 | 
			
		||||
                                    scroll_area.ensureWidgetVisible(next_card, 50, 50)
 | 
			
		||||
                        elif current_row_idx == 0:
 | 
			
		||||
@@ -841,25 +742,6 @@ class InputManager(QObject):
 | 
			
		||||
        if not app:
 | 
			
		||||
            return super().eventFilter(obj, event)
 | 
			
		||||
 | 
			
		||||
        if event.type() == QEvent.Type.MouseButtonPress:
 | 
			
		||||
            mouse_event = cast(QMouseEvent, event)
 | 
			
		||||
            if mouse_event.button() == Qt.MouseButton.ExtraButton1:
 | 
			
		||||
                # Handle ExtraButton1 as "back" action, similar to Escape
 | 
			
		||||
                active_win = QApplication.activeWindow()
 | 
			
		||||
                focused = QApplication.focusWidget()
 | 
			
		||||
                if isinstance(focused, QLineEdit):
 | 
			
		||||
                    return False  # Skip if in QLineEdit
 | 
			
		||||
                if isinstance(active_win, QDialog):
 | 
			
		||||
                    active_win.reject()
 | 
			
		||||
                    return True
 | 
			
		||||
                self._parent.goBackDetailPage(self._parent.currentDetailPage)
 | 
			
		||||
                return True
 | 
			
		||||
 | 
			
		||||
        # Ensure obj is a QObject
 | 
			
		||||
        if not isinstance(obj, QObject):
 | 
			
		||||
            logger.debug(f"Skipping event filter for non-QObject: {type(obj).__name__}")
 | 
			
		||||
            return False
 | 
			
		||||
 | 
			
		||||
        # Handle key press and release events
 | 
			
		||||
        if not isinstance(event, QKeyEvent):
 | 
			
		||||
            return super().eventFilter(obj, event)
 | 
			
		||||
@@ -872,54 +754,6 @@ class InputManager(QObject):
 | 
			
		||||
 | 
			
		||||
        # Handle key press events
 | 
			
		||||
        if event.type() == QEvent.Type.KeyPress:
 | 
			
		||||
            # Handle FileExplorer specific logic
 | 
			
		||||
            if self.file_explorer:
 | 
			
		||||
                # Handle drive buttons in FileExplorer
 | 
			
		||||
                if key in (Qt.Key.Key_Return, Qt.Key.Key_Enter):
 | 
			
		||||
                    if isinstance(focused, AutoSizeButton) and hasattr(self.file_explorer, 'drive_buttons') and focused in self.file_explorer.drive_buttons:
 | 
			
		||||
                        self.file_explorer.select_drive()
 | 
			
		||||
                        return True
 | 
			
		||||
                    elif isinstance(focused, QListWidget) and focused == self.file_explorer.file_list:
 | 
			
		||||
                        current_item = focused.currentItem()
 | 
			
		||||
                        if current_item:
 | 
			
		||||
                            selected = current_item.text()
 | 
			
		||||
                            full_path = os.path.join(self.file_explorer.current_path, selected)
 | 
			
		||||
                            if os.path.isdir(full_path):
 | 
			
		||||
                                if selected == "../":
 | 
			
		||||
                                    self.file_explorer.previous_dir()
 | 
			
		||||
                                else:
 | 
			
		||||
                                    self.file_explorer.current_path = os.path.normpath(full_path)
 | 
			
		||||
                                    self.file_explorer.update_file_list()
 | 
			
		||||
                            elif not self.file_explorer.directory_only:
 | 
			
		||||
                                self.file_explorer.file_signal.file_selected.emit(os.path.normpath(full_path))
 | 
			
		||||
                                self.file_explorer.accept()
 | 
			
		||||
                            return True
 | 
			
		||||
                    else:
 | 
			
		||||
                        self._parent.activateFocusedWidget()
 | 
			
		||||
                        return True
 | 
			
		||||
 | 
			
		||||
                # Handle FileExplorer navigation with right arrow key
 | 
			
		||||
                if key == Qt.Key.Key_Right:
 | 
			
		||||
                    try:
 | 
			
		||||
                        if hasattr(self.file_explorer, 'drive_buttons') and self.file_explorer.drive_buttons:
 | 
			
		||||
                            if not isinstance(focused, AutoSizeButton) or focused not in self.file_explorer.drive_buttons:
 | 
			
		||||
                                self.file_explorer.drive_buttons[0].setFocus()
 | 
			
		||||
                                self.file_explorer.ensure_button_visible(self.file_explorer.drive_buttons[0])
 | 
			
		||||
                            else:
 | 
			
		||||
                                current_idx = self.file_explorer.drive_buttons.index(focused)
 | 
			
		||||
                                next_idx = min(current_idx + 1, len(self.file_explorer.drive_buttons) - 1)
 | 
			
		||||
                                self.file_explorer.drive_buttons[next_idx].setFocus()
 | 
			
		||||
                                self.file_explorer.ensure_button_visible(self.file_explorer.drive_buttons[next_idx])
 | 
			
		||||
                            return True
 | 
			
		||||
                    except Exception as e:
 | 
			
		||||
                        logger.error(f"Error handling right arrow in FileExplorer: {e}")
 | 
			
		||||
                        return True
 | 
			
		||||
 | 
			
		||||
                # Handle Backspace for FileExplorer navigation
 | 
			
		||||
                if key == Qt.Key.Key_Backspace:
 | 
			
		||||
                    self.file_explorer.previous_dir()
 | 
			
		||||
                    return True
 | 
			
		||||
 | 
			
		||||
            # Handle QLineEdit cursor movement with Left/Right arrows
 | 
			
		||||
            if isinstance(focused, QLineEdit) and key in (Qt.Key.Key_Left, Qt.Key.Key_Right):
 | 
			
		||||
                if key == Qt.Key.Key_Left:
 | 
			
		||||
@@ -944,13 +778,10 @@ class InputManager(QObject):
 | 
			
		||||
                self.file_explorer.previous_dir()
 | 
			
		||||
                return True
 | 
			
		||||
 | 
			
		||||
            # Close Dialogs with Escape
 | 
			
		||||
            if key == Qt.Key.Key_Escape:
 | 
			
		||||
                if isinstance(focused, QLineEdit):
 | 
			
		||||
                    return False
 | 
			
		||||
                if isinstance(active_win, QDialog):
 | 
			
		||||
                    active_win.reject()
 | 
			
		||||
                    return True
 | 
			
		||||
            # Close AddGameDialog with Escape
 | 
			
		||||
            if key == Qt.Key.Key_Escape and isinstance(popup, QDialog):
 | 
			
		||||
                popup.reject()
 | 
			
		||||
                return True
 | 
			
		||||
 | 
			
		||||
            # FullscreenDialog navigation
 | 
			
		||||
            if isinstance(active_win, FullscreenDialog):
 | 
			
		||||
@@ -966,7 +797,7 @@ class InputManager(QObject):
 | 
			
		||||
                    return True  # Consume event to prevent tab switching
 | 
			
		||||
 | 
			
		||||
            # Handle tab switching with Left/Right arrow keys when not in GameCard focus or QLineEdit
 | 
			
		||||
            if key in (Qt.Key.Key_Left, Qt.Key.Key_Right) and not isinstance(focused, GameCard | QLineEdit) and not self.file_explorer:
 | 
			
		||||
            if key in (Qt.Key.Key_Left, Qt.Key.Key_Right) and (not isinstance(focused, GameCard | QLineEdit) or focused is None):
 | 
			
		||||
                idx = self._parent.stackedWidget.currentIndex()
 | 
			
		||||
                total = len(self._parent.tabButtons)
 | 
			
		||||
                if key == Qt.Key.Key_Left:
 | 
			
		||||
@@ -1093,8 +924,6 @@ 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.detect_gamepad_type(new_gamepad)
 | 
			
		||||
                logger.info(f"Detected gamepad type: {self.gamepad_type.value}")
 | 
			
		||||
                self.stop_rumble()
 | 
			
		||||
                self.gamepad = new_gamepad
 | 
			
		||||
                if self.gamepad_thread:
 | 
			
		||||
@@ -1113,10 +942,6 @@ class InputManager(QObject):
 | 
			
		||||
        try:
 | 
			
		||||
            devices = [InputDevice(path) for path in list_devices()]
 | 
			
		||||
            for device in devices:
 | 
			
		||||
                # Skip ASRock LED controller (vendor ID: 26ce, product ID: 01a2)
 | 
			
		||||
                if device.info.vendor == 0x26ce and device.info.product == 0x01a2:
 | 
			
		||||
                    logger.debug(f"Skipping ASRock LED controller: {device.name}")
 | 
			
		||||
                    continue
 | 
			
		||||
                caps = device.capabilities()
 | 
			
		||||
                if ecodes.EV_KEY in caps or ecodes.EV_ABS in caps:
 | 
			
		||||
                    return device
 | 
			
		||||
@@ -1135,13 +960,6 @@ class InputManager(QObject):
 | 
			
		||||
                if event.type not in (ecodes.EV_KEY, ecodes.EV_ABS):
 | 
			
		||||
                    continue
 | 
			
		||||
                now = time.time()
 | 
			
		||||
 | 
			
		||||
                # Проверка фокуса: игнорируем события, если окно не в фокусе
 | 
			
		||||
                app = QApplication.instance()
 | 
			
		||||
                active = QApplication.activeWindow()
 | 
			
		||||
                if not app or not active:
 | 
			
		||||
                    continue
 | 
			
		||||
 | 
			
		||||
                if event.type == ecodes.EV_KEY and event.value == 1:
 | 
			
		||||
                    if event.code in BUTTONS['menu'] and not self._is_gamescope_session:
 | 
			
		||||
                        self.toggle_fullscreen.emit(not self._is_fullscreen)
 | 
			
		||||
@@ -1194,7 +1012,5 @@ class InputManager(QObject):
 | 
			
		||||
                self.gamepad_thread.join()
 | 
			
		||||
            if self.gamepad:
 | 
			
		||||
                self.gamepad.close()
 | 
			
		||||
            self.gamepad = None
 | 
			
		||||
            self.gamepad_type = GamepadType.UNKNOWN
 | 
			
		||||
        except Exception as e:
 | 
			
		||||
            logger.error(f"Error during cleanup: {e}", exc_info=True)
 | 
			
		||||
 
 | 
			
		||||
@@ -9,7 +9,7 @@ msgid ""
 | 
			
		||||
msgstr ""
 | 
			
		||||
"Project-Id-Version: PROJECT VERSION\n"
 | 
			
		||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
 | 
			
		||||
"POT-Creation-Date: 2025-09-13 11:51+0500\n"
 | 
			
		||||
"POT-Creation-Date: 2025-08-23 20:35+0500\n"
 | 
			
		||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
 | 
			
		||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
 | 
			
		||||
"Language: de_DE\n"
 | 
			
		||||
@@ -26,12 +26,6 @@ msgstr ""
 | 
			
		||||
msgid "PortProton is not found"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Remove from Favorites"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Add to Favorites"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Delete from PortProton"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
@@ -41,6 +35,12 @@ msgstr ""
 | 
			
		||||
msgid "Launch Game"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Remove from Favorites"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Add to Favorites"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Import to Legendary"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
@@ -248,19 +248,15 @@ msgstr ""
 | 
			
		||||
msgid "Select All"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Launching {0}"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Cancel"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "File Explorer"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Select"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Cancel"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Path: "
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
@@ -360,12 +356,6 @@ msgstr ""
 | 
			
		||||
msgid "Themes"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Back"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Fullscreen"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Loading Steam games..."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
@@ -555,6 +545,9 @@ msgstr ""
 | 
			
		||||
msgid "Error applying theme '{0}'"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Back"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "LAST LAUNCH"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
@@ -658,24 +651,3 @@ msgstr ""
 | 
			
		||||
msgid "sec."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Show"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Favorites"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Recent Games"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Exit"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Hide"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "No favorites"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "No recent games"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -9,7 +9,7 @@ msgid ""
 | 
			
		||||
msgstr ""
 | 
			
		||||
"Project-Id-Version: PROJECT VERSION\n"
 | 
			
		||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
 | 
			
		||||
"POT-Creation-Date: 2025-09-13 11:51+0500\n"
 | 
			
		||||
"POT-Creation-Date: 2025-08-23 20:35+0500\n"
 | 
			
		||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
 | 
			
		||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
 | 
			
		||||
"Language: es_ES\n"
 | 
			
		||||
@@ -26,12 +26,6 @@ msgstr ""
 | 
			
		||||
msgid "PortProton is not found"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Remove from Favorites"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Add to Favorites"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Delete from PortProton"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
@@ -41,6 +35,12 @@ msgstr ""
 | 
			
		||||
msgid "Launch Game"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Remove from Favorites"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Add to Favorites"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Import to Legendary"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
@@ -248,19 +248,15 @@ msgstr ""
 | 
			
		||||
msgid "Select All"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Launching {0}"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Cancel"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "File Explorer"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Select"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Cancel"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Path: "
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
@@ -360,12 +356,6 @@ msgstr ""
 | 
			
		||||
msgid "Themes"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Back"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Fullscreen"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Loading Steam games..."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
@@ -555,6 +545,9 @@ msgstr ""
 | 
			
		||||
msgid "Error applying theme '{0}'"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Back"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "LAST LAUNCH"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
@@ -658,24 +651,3 @@ msgstr ""
 | 
			
		||||
msgid "sec."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Show"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Favorites"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Recent Games"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Exit"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Hide"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "No favorites"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "No recent games"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -9,7 +9,7 @@ msgid ""
 | 
			
		||||
msgstr ""
 | 
			
		||||
"Project-Id-Version: PortProtonQt 0.1.1\n"
 | 
			
		||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
 | 
			
		||||
"POT-Creation-Date: 2025-09-13 11:51+0500\n"
 | 
			
		||||
"POT-Creation-Date: 2025-08-23 20:35+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"
 | 
			
		||||
@@ -24,12 +24,6 @@ msgstr ""
 | 
			
		||||
msgid "PortProton is not found"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Remove from Favorites"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Add to Favorites"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Delete from PortProton"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
@@ -39,6 +33,12 @@ msgstr ""
 | 
			
		||||
msgid "Launch Game"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Remove from Favorites"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Add to Favorites"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Import to Legendary"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
@@ -246,19 +246,15 @@ msgstr ""
 | 
			
		||||
msgid "Select All"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Launching {0}"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Cancel"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "File Explorer"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Select"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Cancel"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Path: "
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
@@ -358,12 +354,6 @@ msgstr ""
 | 
			
		||||
msgid "Themes"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Back"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Fullscreen"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Loading Steam games..."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
@@ -553,6 +543,9 @@ msgstr ""
 | 
			
		||||
msgid "Error applying theme '{0}'"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Back"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "LAST LAUNCH"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
@@ -656,24 +649,3 @@ msgstr ""
 | 
			
		||||
msgid "sec."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Show"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Favorites"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Recent Games"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Exit"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Hide"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "No favorites"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "No recent games"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -9,8 +9,8 @@ msgid ""
 | 
			
		||||
msgstr ""
 | 
			
		||||
"Project-Id-Version: PROJECT VERSION\n"
 | 
			
		||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
 | 
			
		||||
"POT-Creation-Date: 2025-09-13 11:51+0500\n"
 | 
			
		||||
"PO-Revision-Date: 2025-09-13 11:47+0500\n"
 | 
			
		||||
"POT-Creation-Date: 2025-08-23 20:35+0500\n"
 | 
			
		||||
"PO-Revision-Date: 2025-08-23 20:35+0500\n"
 | 
			
		||||
"Last-Translator: \n"
 | 
			
		||||
"Language: ru_RU\n"
 | 
			
		||||
"Language-Team: ru_RU <LL@li.org>\n"
 | 
			
		||||
@@ -27,12 +27,6 @@ msgstr "Ошибка"
 | 
			
		||||
msgid "PortProton is not found"
 | 
			
		||||
msgstr "PortProton не найден"
 | 
			
		||||
 | 
			
		||||
msgid "Remove from Favorites"
 | 
			
		||||
msgstr "Удалить из Избранного"
 | 
			
		||||
 | 
			
		||||
msgid "Add to Favorites"
 | 
			
		||||
msgstr "Добавить в Избранное"
 | 
			
		||||
 | 
			
		||||
msgid "Delete from PortProton"
 | 
			
		||||
msgstr "Удалить из PortProton"
 | 
			
		||||
 | 
			
		||||
@@ -42,6 +36,12 @@ msgstr "Остановить игру"
 | 
			
		||||
msgid "Launch Game"
 | 
			
		||||
msgstr "Запустить игру"
 | 
			
		||||
 | 
			
		||||
msgid "Remove from Favorites"
 | 
			
		||||
msgstr "Удалить из Избранного"
 | 
			
		||||
 | 
			
		||||
msgid "Add to Favorites"
 | 
			
		||||
msgstr "Добавить в Избранное"
 | 
			
		||||
 | 
			
		||||
msgid "Import to Legendary"
 | 
			
		||||
msgstr "Импортировать игру"
 | 
			
		||||
 | 
			
		||||
@@ -255,19 +255,15 @@ msgstr "Удалить"
 | 
			
		||||
msgid "Select All"
 | 
			
		||||
msgstr "Выбрать всё"
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Launching {0}"
 | 
			
		||||
msgstr "Идёт запуск {0}"
 | 
			
		||||
 | 
			
		||||
msgid "Cancel"
 | 
			
		||||
msgstr "Отмена"
 | 
			
		||||
 | 
			
		||||
msgid "File Explorer"
 | 
			
		||||
msgstr "Проводник"
 | 
			
		||||
 | 
			
		||||
msgid "Select"
 | 
			
		||||
msgstr "Выбрать"
 | 
			
		||||
 | 
			
		||||
msgid "Cancel"
 | 
			
		||||
msgstr "Отмена"
 | 
			
		||||
 | 
			
		||||
msgid "Path: "
 | 
			
		||||
msgstr "Путь: "
 | 
			
		||||
 | 
			
		||||
@@ -367,13 +363,6 @@ msgstr "Настройки PortProton"
 | 
			
		||||
msgid "Themes"
 | 
			
		||||
msgstr "Темы"
 | 
			
		||||
 | 
			
		||||
msgid "Back"
 | 
			
		||||
msgstr "Назад"
 | 
			
		||||
 | 
			
		||||
#, fuzzy
 | 
			
		||||
msgid "Fullscreen"
 | 
			
		||||
msgstr "Полный экран"
 | 
			
		||||
 | 
			
		||||
msgid "Loading Steam games..."
 | 
			
		||||
msgstr "Загрузка игр из Steam..."
 | 
			
		||||
 | 
			
		||||
@@ -565,6 +554,9 @@ msgstr "Тема '{0}' применена успешно"
 | 
			
		||||
msgid "Error applying theme '{0}'"
 | 
			
		||||
msgstr "Ошибка при применение темы '{0}'"
 | 
			
		||||
 | 
			
		||||
msgid "Back"
 | 
			
		||||
msgstr "Назад"
 | 
			
		||||
 | 
			
		||||
msgid "LAST LAUNCH"
 | 
			
		||||
msgstr "Последний запуск"
 | 
			
		||||
 | 
			
		||||
@@ -668,24 +660,3 @@ msgstr "мин."
 | 
			
		||||
msgid "sec."
 | 
			
		||||
msgstr "сек."
 | 
			
		||||
 | 
			
		||||
msgid "Show"
 | 
			
		||||
msgstr "Показать"
 | 
			
		||||
 | 
			
		||||
msgid "Favorites"
 | 
			
		||||
msgstr "Избранное"
 | 
			
		||||
 | 
			
		||||
msgid "Recent Games"
 | 
			
		||||
msgstr "Недавние"
 | 
			
		||||
 | 
			
		||||
msgid "Exit"
 | 
			
		||||
msgstr "Выход"
 | 
			
		||||
 | 
			
		||||
msgid "Hide"
 | 
			
		||||
msgstr "Скрыть"
 | 
			
		||||
 | 
			
		||||
msgid "No favorites"
 | 
			
		||||
msgstr "Нет избранных"
 | 
			
		||||
 | 
			
		||||
msgid "No recent games"
 | 
			
		||||
msgstr "Нет недавних игр"
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,34 +1,16 @@
 | 
			
		||||
import logging
 | 
			
		||||
 | 
			
		||||
def setup_logger(level='NOTSET'):
 | 
			
		||||
def setup_logger():
 | 
			
		||||
    """Настройка базовой конфигурации логирования."""
 | 
			
		||||
    # Clear existing handlers to prevent duplicates
 | 
			
		||||
    root_logger = logging.getLogger()
 | 
			
		||||
    for handler in root_logger.handlers[:]:
 | 
			
		||||
        root_logger.removeHandler(handler)
 | 
			
		||||
 | 
			
		||||
    # Convert string level to logging level constant, map ALL to DEBUG
 | 
			
		||||
    if level.upper() == 'ALL':
 | 
			
		||||
        log_level = logging.DEBUG
 | 
			
		||||
    else:
 | 
			
		||||
        log_level = getattr(logging, level.upper(), logging.NOTSET)
 | 
			
		||||
 | 
			
		||||
    # Configure logging with null handler if level is NOTSET
 | 
			
		||||
    if log_level == logging.NOTSET:
 | 
			
		||||
        logging.basicConfig(
 | 
			
		||||
            level=logging.NOTSET,
 | 
			
		||||
            handlers=[logging.NullHandler()]
 | 
			
		||||
        )
 | 
			
		||||
    else:
 | 
			
		||||
        logging.basicConfig(
 | 
			
		||||
            level=log_level,
 | 
			
		||||
            format='[%(levelname)s] %(message)s',
 | 
			
		||||
            handlers=[logging.StreamHandler()]
 | 
			
		||||
        )
 | 
			
		||||
    logging.basicConfig(
 | 
			
		||||
        level=logging.INFO,
 | 
			
		||||
        format='[%(levelname)s] %(message)s',
 | 
			
		||||
        handlers=[logging.StreamHandler()]
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
def get_logger(name):
 | 
			
		||||
    """Возвращает логгер для указанного модуля."""
 | 
			
		||||
    return logging.getLogger(name)
 | 
			
		||||
 | 
			
		||||
# Инициализация логгера при импорте модуля (без логов по умолчанию)
 | 
			
		||||
# Инициализация логгера при импорте модуля
 | 
			
		||||
setup_logger()
 | 
			
		||||
 
 | 
			
		||||
@@ -4,9 +4,10 @@ import shutil
 | 
			
		||||
import signal
 | 
			
		||||
import subprocess
 | 
			
		||||
import sys
 | 
			
		||||
 | 
			
		||||
import portprotonqt.themes.standart.styles as default_styles
 | 
			
		||||
import psutil
 | 
			
		||||
 | 
			
		||||
from portprotonqt.logger import get_logger
 | 
			
		||||
from portprotonqt.dialogs import AddGameDialog, FileExplorer
 | 
			
		||||
from portprotonqt.game_card import GameCard
 | 
			
		||||
from portprotonqt.animations import DetailPageAnimations
 | 
			
		||||
@@ -15,12 +16,11 @@ from portprotonqt.portproton_api import PortProtonAPI
 | 
			
		||||
from portprotonqt.input_manager import InputManager
 | 
			
		||||
from portprotonqt.context_menu_manager import ContextMenuManager, CustomLineEdit
 | 
			
		||||
from portprotonqt.system_overlay import SystemOverlay
 | 
			
		||||
from portprotonqt.input_manager import GamepadType
 | 
			
		||||
 | 
			
		||||
from portprotonqt.image_utils import load_pixmap_async, round_corners, ImageCarousel
 | 
			
		||||
from portprotonqt.steam_api import get_steam_game_info_async, get_full_steam_game_info_async, get_steam_installed_games
 | 
			
		||||
from portprotonqt.egs_api import load_egs_games_async, get_egs_executable
 | 
			
		||||
from portprotonqt.theme_manager import ThemeManager, load_theme_screenshots
 | 
			
		||||
from portprotonqt.theme_manager import ThemeManager, load_theme_screenshots, load_logo
 | 
			
		||||
from portprotonqt.time_utils import save_last_launch, get_last_launch, parse_playtime_file, format_playtime, get_last_launch_timestamp, format_last_launch
 | 
			
		||||
from portprotonqt.config_utils import (
 | 
			
		||||
    get_portproton_location, read_theme_from_config, save_theme_to_config, parse_desktop_entry,
 | 
			
		||||
@@ -31,9 +31,9 @@ from portprotonqt.config_utils import (
 | 
			
		||||
    clear_cache, read_auto_fullscreen_gamepad, save_auto_fullscreen_gamepad, read_rumble_config, save_rumble_config
 | 
			
		||||
)
 | 
			
		||||
from portprotonqt.localization import _, get_egs_language, read_metadata_translations
 | 
			
		||||
from portprotonqt.logger import get_logger
 | 
			
		||||
from portprotonqt.howlongtobeat_api import HowLongToBeat
 | 
			
		||||
from portprotonqt.downloader import Downloader
 | 
			
		||||
from portprotonqt.tray_manager import TrayManager
 | 
			
		||||
 | 
			
		||||
from PySide6.QtWidgets import (QLineEdit, QMainWindow, QStatusBar, QWidget, QVBoxLayout, QLabel, QHBoxLayout, QStackedWidget, QComboBox, QScrollArea, QSlider,
 | 
			
		||||
                               QDialog, QFormLayout, QFrame, QGraphicsDropShadowEffect, QMessageBox, QApplication, QPushButton, QProgressBar, QCheckBox, QSizePolicy)
 | 
			
		||||
@@ -52,17 +52,23 @@ class MainWindow(QMainWindow):
 | 
			
		||||
    update_progress = Signal(int)  # Signal to update progress bar
 | 
			
		||||
    update_status_message = Signal(str, int)  # Signal to update status message
 | 
			
		||||
 | 
			
		||||
    def __init__(self, app_name: str):
 | 
			
		||||
    def __init__(self):
 | 
			
		||||
        super().__init__()
 | 
			
		||||
        # Создаём менеджер тем и читаем, какая тема выбрана
 | 
			
		||||
        self.theme_manager = ThemeManager()
 | 
			
		||||
        self.is_exiting = False
 | 
			
		||||
        selected_theme = read_theme_from_config()
 | 
			
		||||
        self.current_theme_name = selected_theme
 | 
			
		||||
        self.theme = self.theme_manager.apply_theme(selected_theme)
 | 
			
		||||
        self.tray_manager = TrayManager(self, app_name, self.current_theme_name)
 | 
			
		||||
        try:
 | 
			
		||||
            self.theme = self.theme_manager.apply_theme(selected_theme)
 | 
			
		||||
        except FileNotFoundError:
 | 
			
		||||
            logger.warning(f"Тема '{selected_theme}' не найдена, применяется стандартная тема 'standart'")
 | 
			
		||||
            self.theme = self.theme_manager.apply_theme("standart")
 | 
			
		||||
            self.current_theme_name = "standart"
 | 
			
		||||
            save_theme_to_config("standart")
 | 
			
		||||
        if not self.theme:
 | 
			
		||||
            self.theme = default_styles
 | 
			
		||||
        self.card_width = read_card_size()
 | 
			
		||||
        self.setWindowTitle(app_name)
 | 
			
		||||
        self.setWindowTitle("PortProtonQt")
 | 
			
		||||
        self.setMinimumSize(800, 600)
 | 
			
		||||
 | 
			
		||||
        self.games = []
 | 
			
		||||
@@ -142,26 +148,32 @@ class MainWindow(QMainWindow):
 | 
			
		||||
        self.header.setStyleSheet(self.theme.MAIN_WINDOW_HEADER_STYLE)
 | 
			
		||||
        headerLayout = QVBoxLayout(self.header)
 | 
			
		||||
        headerLayout.setContentsMargins(0, 0, 0, 0)
 | 
			
		||||
        headerLayout.addStretch()
 | 
			
		||||
 | 
			
		||||
        self.input_manager = InputManager(self)
 | 
			
		||||
        self.input_manager.button_pressed.connect(self.updateControlHints)
 | 
			
		||||
        self.input_manager.dpad_moved.connect(self.updateControlHints)
 | 
			
		||||
        # Текст "PortProton" слева
 | 
			
		||||
        self.titleLabel = QLabel()
 | 
			
		||||
        pixmap = load_logo()
 | 
			
		||||
        if pixmap is None:
 | 
			
		||||
            width, height = self.theme.pixmapsScaledSize
 | 
			
		||||
            pixmap = QPixmap(width, height)
 | 
			
		||||
            pixmap.fill(QColor(0, 0, 0, 0))
 | 
			
		||||
        width, height = self.theme.pixmapsScaledSize
 | 
			
		||||
        scaled_pixmap = pixmap.scaled(width, height,
 | 
			
		||||
                                    Qt.AspectRatioMode.KeepAspectRatio,
 | 
			
		||||
                                    Qt.TransformationMode.SmoothTransformation)
 | 
			
		||||
        self.titleLabel.setPixmap(scaled_pixmap)
 | 
			
		||||
        self.titleLabel.setFixedSize(scaled_pixmap.size())
 | 
			
		||||
        self.titleLabel.setStyleSheet(self.theme.TITLE_LABEL_STYLE)
 | 
			
		||||
        headerLayout.addStretch()
 | 
			
		||||
 | 
			
		||||
        # 2. НАВИГАЦИЯ (КНОПКИ ВКЛАДОК)
 | 
			
		||||
        self.navWidget = QWidget()
 | 
			
		||||
        self.navWidget.setStyleSheet(self.theme.NAV_WIDGET_STYLE)
 | 
			
		||||
        navLayout = QHBoxLayout(self.navWidget)
 | 
			
		||||
        navLayout.setContentsMargins(10, 0, 10, 0)
 | 
			
		||||
        navLayout.setSpacing(10)
 | 
			
		||||
        navLayout.setSpacing(0)
 | 
			
		||||
 | 
			
		||||
         # Left navigation button (key_left or button_lb)
 | 
			
		||||
        self.leftNavButton = QLabel()
 | 
			
		||||
        self.leftNavButton.setFixedSize(32, 32)
 | 
			
		||||
        self.leftNavButton.setAlignment(Qt.AlignmentFlag.AlignCenter)
 | 
			
		||||
        navLayout.addWidget(self.leftNavButton)
 | 
			
		||||
        navLayout.addWidget(self.titleLabel)
 | 
			
		||||
 | 
			
		||||
        # Вкладки
 | 
			
		||||
        self.tabButtons = {}
 | 
			
		||||
        tabs = [
 | 
			
		||||
            _("Library"),
 | 
			
		||||
@@ -180,16 +192,6 @@ class MainWindow(QMainWindow):
 | 
			
		||||
            self.tabButtons[i] = btn
 | 
			
		||||
 | 
			
		||||
        self.tabButtons[0].setChecked(True)
 | 
			
		||||
 | 
			
		||||
        # Right navigation button (key_right or button_rb)
 | 
			
		||||
        self.rightNavButton = QLabel()
 | 
			
		||||
        self.rightNavButton.setFixedSize(32, 32)
 | 
			
		||||
        self.rightNavButton.setAlignment(Qt.AlignmentFlag.AlignCenter)
 | 
			
		||||
        navLayout.addWidget(self.rightNavButton)
 | 
			
		||||
 | 
			
		||||
        # Initial update of navigation buttons based on input device
 | 
			
		||||
        self.updateNavButtons()
 | 
			
		||||
 | 
			
		||||
        mainLayout.addWidget(self.navWidget)
 | 
			
		||||
 | 
			
		||||
        # 3. QStackedWidget (ВКЛАДКИ)
 | 
			
		||||
@@ -204,12 +206,9 @@ class MainWindow(QMainWindow):
 | 
			
		||||
        self.createPortProtonTab()   # вкладка 4
 | 
			
		||||
        self.createThemeTab()        # вкладка 5
 | 
			
		||||
 | 
			
		||||
        # Подсказки управления
 | 
			
		||||
        self.controlHintsWidget = self.createControlHintsWidget()
 | 
			
		||||
        mainLayout.addWidget(self.controlHintsWidget)
 | 
			
		||||
 | 
			
		||||
        self.restore_state()
 | 
			
		||||
 | 
			
		||||
        self.input_manager = InputManager(self)
 | 
			
		||||
        self.detail_animations = DetailPageAnimations(self, self.theme)
 | 
			
		||||
        QTimer.singleShot(0, self.loadGames)
 | 
			
		||||
 | 
			
		||||
@@ -221,212 +220,6 @@ class MainWindow(QMainWindow):
 | 
			
		||||
                self.resize(width, height)
 | 
			
		||||
            else:
 | 
			
		||||
                self.showNormal()
 | 
			
		||||
 | 
			
		||||
    def get_button_icon(self, action: str, gtype: GamepadType) -> str:
 | 
			
		||||
        """Get the icon name for a specific action and gamepad type."""
 | 
			
		||||
        mappings = {
 | 
			
		||||
            'confirm': {
 | 
			
		||||
                GamepadType.XBOX: "xbox_a",
 | 
			
		||||
                GamepadType.PLAYSTATION: "ps_cross",
 | 
			
		||||
            },
 | 
			
		||||
            'back': {
 | 
			
		||||
                GamepadType.XBOX: "xbox_b",
 | 
			
		||||
                GamepadType.PLAYSTATION: "ps_circle",
 | 
			
		||||
            },
 | 
			
		||||
            'add_game': {
 | 
			
		||||
                GamepadType.XBOX: "xbox_x",
 | 
			
		||||
                GamepadType.PLAYSTATION: "ps_triangle",
 | 
			
		||||
            },
 | 
			
		||||
            'context_menu': {
 | 
			
		||||
                GamepadType.XBOX: "xbox_start",
 | 
			
		||||
                GamepadType.PLAYSTATION: "ps_options",
 | 
			
		||||
            },
 | 
			
		||||
            'menu': {
 | 
			
		||||
                GamepadType.XBOX: "xbox_view",
 | 
			
		||||
                GamepadType.PLAYSTATION: "ps_share",
 | 
			
		||||
            },
 | 
			
		||||
        }
 | 
			
		||||
        return mappings.get(action, {}).get(gtype, "placeholder")
 | 
			
		||||
 | 
			
		||||
    def get_nav_icon(self, direction: str, gtype: GamepadType) -> str:
 | 
			
		||||
        """Get the icon name for navigation direction and gamepad type."""
 | 
			
		||||
        if direction == 'left':
 | 
			
		||||
            action = 'prev_tab'
 | 
			
		||||
        else:
 | 
			
		||||
            action = 'next_tab'
 | 
			
		||||
        mappings = {
 | 
			
		||||
            'prev_tab': {
 | 
			
		||||
                GamepadType.XBOX: "xbox_lb",
 | 
			
		||||
                GamepadType.PLAYSTATION: "ps_l1",
 | 
			
		||||
            },
 | 
			
		||||
            'next_tab': {
 | 
			
		||||
                GamepadType.XBOX: "xbox_rb",
 | 
			
		||||
                GamepadType.PLAYSTATION: "ps_r1",
 | 
			
		||||
            },
 | 
			
		||||
        }
 | 
			
		||||
        return mappings.get(action, {}).get(gtype, "placeholder")
 | 
			
		||||
 | 
			
		||||
    def createControlHintsWidget(self) -> QWidget:
 | 
			
		||||
        from portprotonqt.localization import _
 | 
			
		||||
        """Creates a widget displaying control hints for gamepad and keyboard."""
 | 
			
		||||
        logger.debug("Creating control hints widget")
 | 
			
		||||
        hintsWidget = QWidget()
 | 
			
		||||
        hintsWidget.setStyleSheet(self.theme.STATUS_BAR_STYLE)
 | 
			
		||||
 | 
			
		||||
        hintsLayout = QHBoxLayout(hintsWidget)
 | 
			
		||||
        hintsLayout.setContentsMargins(10, 0, 10, 0)
 | 
			
		||||
        hintsLayout.setSpacing(20)
 | 
			
		||||
 | 
			
		||||
        gamepad_actions = [
 | 
			
		||||
            ("confirm", _("Select")),
 | 
			
		||||
            ("back", _("Back")),
 | 
			
		||||
            ("add_game", _("Add Game")),
 | 
			
		||||
            ("context_menu", _("Menu")),
 | 
			
		||||
            ("menu", _("Fullscreen")),
 | 
			
		||||
        ]
 | 
			
		||||
 | 
			
		||||
        keyboard_hints = [
 | 
			
		||||
            ("key_enter", _("Select")),
 | 
			
		||||
            ("key_backspace", _("Back")),
 | 
			
		||||
            ("key_e", _("Add Game")),
 | 
			
		||||
            ("key_context", _("Menu")),
 | 
			
		||||
            ("key_f11", _("Fullscreen")),
 | 
			
		||||
        ]
 | 
			
		||||
 | 
			
		||||
        self.hintsLabels = []
 | 
			
		||||
 | 
			
		||||
        def makeHint(icon_name: str, action_text: str, is_gamepad: bool, action: str | None = None,):
 | 
			
		||||
            container = QWidget()
 | 
			
		||||
            layout = QHBoxLayout(container)
 | 
			
		||||
            layout.setContentsMargins(0, 0, 0, 0)
 | 
			
		||||
            layout.setSpacing(6)
 | 
			
		||||
 | 
			
		||||
            # иконка кнопки
 | 
			
		||||
            icon_label = QLabel()
 | 
			
		||||
            icon_label.setFixedSize(32, 32)
 | 
			
		||||
            icon_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
 | 
			
		||||
 | 
			
		||||
            pixmap = QPixmap()
 | 
			
		||||
            for candidate in (
 | 
			
		||||
                self.theme_manager.get_theme_image(icon_name, self.current_theme_name),
 | 
			
		||||
                self.theme_manager.get_theme_image("placeholder", self.current_theme_name),
 | 
			
		||||
            ):
 | 
			
		||||
                if candidate is not None and pixmap.load(str(candidate)):
 | 
			
		||||
                    break
 | 
			
		||||
 | 
			
		||||
            if not pixmap.isNull():
 | 
			
		||||
                icon_label.setPixmap(pixmap.scaled(
 | 
			
		||||
                    32, 32,
 | 
			
		||||
                    Qt.AspectRatioMode.KeepAspectRatio,
 | 
			
		||||
                    Qt.TransformationMode.SmoothTransformation
 | 
			
		||||
                ))
 | 
			
		||||
 | 
			
		||||
            layout.addWidget(icon_label)
 | 
			
		||||
 | 
			
		||||
            # текст действия
 | 
			
		||||
            text_label = QLabel(action_text)
 | 
			
		||||
            text_label.setStyleSheet(self.theme.LAST_LAUNCH_VALUE_STYLE)
 | 
			
		||||
            text_label.setAlignment(Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft)
 | 
			
		||||
            layout.addWidget(text_label)
 | 
			
		||||
 | 
			
		||||
            if is_gamepad:
 | 
			
		||||
                container.setVisible(False)
 | 
			
		||||
                self.hintsLabels.append((container, icon_label, action))  # Store action for dynamic update
 | 
			
		||||
            else:
 | 
			
		||||
                container.setVisible(True)
 | 
			
		||||
                self.hintsLabels.append((container, icon_label, None))  # Keyboard, no action
 | 
			
		||||
 | 
			
		||||
            hintsLayout.addWidget(container)
 | 
			
		||||
 | 
			
		||||
        # Create gamepad hints
 | 
			
		||||
        for action, text in gamepad_actions:
 | 
			
		||||
            makeHint("placeholder", text, True, action)  # Initial placeholder
 | 
			
		||||
 | 
			
		||||
        # Create keyboard hints
 | 
			
		||||
        for icon, text in keyboard_hints:
 | 
			
		||||
            makeHint(icon, text, False)
 | 
			
		||||
 | 
			
		||||
        hintsLayout.addStretch()
 | 
			
		||||
        return hintsWidget
 | 
			
		||||
 | 
			
		||||
    def updateNavButtons(self, *args) -> None:
 | 
			
		||||
        """Updates navigation buttons based on gamepad connection status and type."""
 | 
			
		||||
        is_gamepad_connected = self.input_manager.gamepad is not None
 | 
			
		||||
        gtype = self.input_manager.gamepad_type
 | 
			
		||||
        logger.debug("Updating nav buttons, gamepad connected: %s, type: %s", is_gamepad_connected, gtype.value)
 | 
			
		||||
 | 
			
		||||
        # Left navigation button
 | 
			
		||||
        left_pix = QPixmap()
 | 
			
		||||
        if is_gamepad_connected:
 | 
			
		||||
            left_icon_name = self.get_nav_icon('left', gtype)
 | 
			
		||||
        else:
 | 
			
		||||
            left_icon_name = "key_left"
 | 
			
		||||
        left_icon = self.theme_manager.get_theme_image(left_icon_name, self.current_theme_name)
 | 
			
		||||
        if left_icon:
 | 
			
		||||
            left_pix.load(str(left_icon))
 | 
			
		||||
        if not left_pix.isNull():
 | 
			
		||||
            self.leftNavButton.setPixmap(left_pix.scaled(
 | 
			
		||||
                32, 32,
 | 
			
		||||
                Qt.AspectRatioMode.KeepAspectRatio,
 | 
			
		||||
                Qt.TransformationMode.SmoothTransformation
 | 
			
		||||
            ))
 | 
			
		||||
        self.leftNavButton.setVisible(True)  # Always visible, icon changes
 | 
			
		||||
 | 
			
		||||
        # Right navigation button
 | 
			
		||||
        right_pix = QPixmap()
 | 
			
		||||
        if is_gamepad_connected:
 | 
			
		||||
            right_icon_name = self.get_nav_icon('right', gtype)
 | 
			
		||||
        else:
 | 
			
		||||
            right_icon_name = "key_right"
 | 
			
		||||
        right_icon = self.theme_manager.get_theme_image(right_icon_name, self.current_theme_name)
 | 
			
		||||
        if right_icon:
 | 
			
		||||
            right_pix.load(str(right_icon))
 | 
			
		||||
        if not right_pix.isNull():
 | 
			
		||||
            self.rightNavButton.setPixmap(right_pix.scaled(
 | 
			
		||||
                32, 32,
 | 
			
		||||
                Qt.AspectRatioMode.KeepAspectRatio,
 | 
			
		||||
                Qt.TransformationMode.SmoothTransformation
 | 
			
		||||
            ))
 | 
			
		||||
        self.rightNavButton.setVisible(True)  # Always visible, icon changes
 | 
			
		||||
 | 
			
		||||
    def updateControlHints(self, *args) -> None:
 | 
			
		||||
        """Updates control hints based on gamepad connection status and type."""
 | 
			
		||||
        is_gamepad_connected = self.input_manager.gamepad is not None
 | 
			
		||||
        gtype = self.input_manager.gamepad_type
 | 
			
		||||
        logger.debug("Updating control hints, gamepad connected: %s, type: %s", is_gamepad_connected, gtype.value)
 | 
			
		||||
 | 
			
		||||
        gamepad_actions = ['confirm', 'back', 'add_game', 'context_menu', 'menu']
 | 
			
		||||
 | 
			
		||||
        for container, icon_label, action in self.hintsLabels:
 | 
			
		||||
            if action in gamepad_actions:  # Gamepad hint
 | 
			
		||||
                if is_gamepad_connected:
 | 
			
		||||
                    container.setVisible(True)
 | 
			
		||||
                    # Update icon based on type
 | 
			
		||||
                    icon_name = self.get_button_icon(action, gtype)
 | 
			
		||||
                    icon_path = self.theme_manager.get_theme_image(icon_name, self.current_theme_name)
 | 
			
		||||
                    pixmap = QPixmap()
 | 
			
		||||
                    if icon_path:
 | 
			
		||||
                        pixmap.load(str(icon_path))
 | 
			
		||||
                    if not pixmap.isNull():
 | 
			
		||||
                        icon_label.setPixmap(pixmap.scaled(
 | 
			
		||||
                            32, 32,
 | 
			
		||||
                            Qt.AspectRatioMode.KeepAspectRatio,
 | 
			
		||||
                            Qt.TransformationMode.SmoothTransformation
 | 
			
		||||
                        ))
 | 
			
		||||
                    else:
 | 
			
		||||
                        # Fallback to placeholder
 | 
			
		||||
                        placeholder = self.theme_manager.get_theme_image("placeholder", self.current_theme_name)
 | 
			
		||||
                        if placeholder:
 | 
			
		||||
                            pixmap.load(str(placeholder))
 | 
			
		||||
                            icon_label.setPixmap(pixmap.scaled(32, 32, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation))
 | 
			
		||||
                else:
 | 
			
		||||
                    container.setVisible(False)
 | 
			
		||||
            else:  # Keyboard hint
 | 
			
		||||
                container.setVisible(not is_gamepad_connected)
 | 
			
		||||
 | 
			
		||||
        # Update navigation buttons
 | 
			
		||||
        self.updateNavButtons()
 | 
			
		||||
 | 
			
		||||
    @Slot(list)
 | 
			
		||||
    def on_games_loaded(self, games: list[tuple]):
 | 
			
		||||
        self.games = games
 | 
			
		||||
@@ -876,8 +669,6 @@ class MainWindow(QMainWindow):
 | 
			
		||||
 | 
			
		||||
        sliderLayout = QHBoxLayout()
 | 
			
		||||
        sliderLayout.addStretch()
 | 
			
		||||
 | 
			
		||||
        # Слайдер
 | 
			
		||||
        self.sizeSlider = QSlider(Qt.Orientation.Horizontal)
 | 
			
		||||
        self.sizeSlider.setMinimum(200)
 | 
			
		||||
        self.sizeSlider.setMaximum(250)
 | 
			
		||||
@@ -888,7 +679,6 @@ class MainWindow(QMainWindow):
 | 
			
		||||
        self.sizeSlider.setStyleSheet(self.theme.SLIDER_SIZE_STYLE)
 | 
			
		||||
        self.sizeSlider.sliderReleased.connect(self.on_slider_released)
 | 
			
		||||
        sliderLayout.addWidget(self.sizeSlider)
 | 
			
		||||
 | 
			
		||||
        layout.addLayout(sliderLayout)
 | 
			
		||||
 | 
			
		||||
        def calculate_card_width():
 | 
			
		||||
@@ -1341,36 +1131,36 @@ class MainWindow(QMainWindow):
 | 
			
		||||
        self.gamepadRumbleCheckBox.setChecked(current_rumble_state)
 | 
			
		||||
        formLayout.addRow(self.gamepadRumbleTitle, self.gamepadRumbleCheckBox)
 | 
			
		||||
 | 
			
		||||
        # # 8. 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 = CustomLineEdit(self, theme=self.theme)
 | 
			
		||||
        # 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)
 | 
			
		||||
        # 8. 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 = CustomLineEdit(self, theme=self.theme)
 | 
			
		||||
        self.legendaryCodeEdit.setPlaceholderText(_("Enter Legendary Authorization Code"))
 | 
			
		||||
        self.legendaryCodeEdit.setStyleSheet(self.theme.PROXY_INPUT_STYLE)
 | 
			
		||||
        self.legendaryCodeEdit.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
 | 
			
		||||
        self.legendaryCodeTitle = QLabel(_("Authorization Code:"))
 | 
			
		||||
        self.legendaryCodeTitle.setStyleSheet(self.theme.PARAMS_TITLE_STYLE)
 | 
			
		||||
        self.legendaryCodeTitle.setFocusPolicy(Qt.FocusPolicy.NoFocus)
 | 
			
		||||
        formLayout.addRow(self.legendaryCodeTitle, self.legendaryCodeEdit)
 | 
			
		||||
 | 
			
		||||
        self.submitCodeButton = AutoSizeButton(
 | 
			
		||||
            _("Submit Code"),
 | 
			
		||||
            icon=self.theme_manager.get_icon("save")
 | 
			
		||||
        )
 | 
			
		||||
        self.submitCodeButton.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
 | 
			
		||||
        self.submitCodeButton.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
 | 
			
		||||
        self.submitCodeButton.clicked.connect(self.submitLegendaryCode)
 | 
			
		||||
        formLayout.addRow(QLabel(""), self.submitCodeButton)
 | 
			
		||||
 | 
			
		||||
        layout.addLayout(formLayout)
 | 
			
		||||
 | 
			
		||||
@@ -1412,46 +1202,46 @@ class MainWindow(QMainWindow):
 | 
			
		||||
        layout.addStretch(1)
 | 
			
		||||
        self.stackedWidget.addWidget(self.portProtonWidget)
 | 
			
		||||
 | 
			
		||||
    # def openLegendaryLogin(self):
 | 
			
		||||
    #     """Opens the Legendary login page in the default web browser."""
 | 
			
		||||
    #     login_url = "https://legendary.gl/epiclogin"
 | 
			
		||||
    #     try:
 | 
			
		||||
    #         QDesktopServices.openUrl(QUrl(login_url))
 | 
			
		||||
    #         self.statusBar().showMessage(_("Opened Legendary login page in browser"), 3000)
 | 
			
		||||
    #     except Exception as e:
 | 
			
		||||
    #         logger.error(f"Failed to open Legendary login page: {e}")
 | 
			
		||||
    #         self.statusBar().showMessage(_("Failed to open Legendary login page"), 3000)
 | 
			
		||||
    #
 | 
			
		||||
    # def submitLegendaryCode(self):
 | 
			
		||||
    #     """Submits the Legendary authorization code using the legendary CLI."""
 | 
			
		||||
    #     auth_code = self.legendaryCodeEdit.text().strip()
 | 
			
		||||
    #     if not auth_code:
 | 
			
		||||
    #         QMessageBox.warning(self, _("Error"), _("Please enter an authorization code"))
 | 
			
		||||
    #         return
 | 
			
		||||
    #
 | 
			
		||||
    #     try:
 | 
			
		||||
    #         # Execute legendary auth command
 | 
			
		||||
    #         result = subprocess.run(
 | 
			
		||||
    #             [self.legendary_path, "auth", "--code", auth_code],
 | 
			
		||||
    #             capture_output=True,
 | 
			
		||||
    #             text=True,
 | 
			
		||||
    #             check=True
 | 
			
		||||
    #         )
 | 
			
		||||
    #         logger.info("Legendary authentication successful: %s", result.stdout)
 | 
			
		||||
    #         self.statusBar().showMessage(_("Successfully authenticated with Legendary"), 3000)
 | 
			
		||||
    #         self.legendaryCodeEdit.clear()
 | 
			
		||||
    #         # Reload Epic Games Store games after successful authentication
 | 
			
		||||
    #         self.games = self.loadGames()
 | 
			
		||||
    #         self.updateGameGrid()
 | 
			
		||||
    #     except subprocess.CalledProcessError as e:
 | 
			
		||||
    #         logger.error("Legendary authentication failed: %s", e.stderr)
 | 
			
		||||
    #         self.statusBar().showMessage(_("Legendary authentication failed: {0}").format(e.stderr), 5000)
 | 
			
		||||
    #     except FileNotFoundError:
 | 
			
		||||
    #         logger.error("Legendary executable not found at %s", self.legendary_path)
 | 
			
		||||
    #         self.statusBar().showMessage(_("Legendary executable not found"), 5000)
 | 
			
		||||
    #     except Exception as e:
 | 
			
		||||
    #         logger.error("Unexpected error during Legendary authentication: %s", str(e))
 | 
			
		||||
    #         self.statusBar().showMessage(_("Unexpected error during authentication"), 5000)
 | 
			
		||||
    def openLegendaryLogin(self):
 | 
			
		||||
        """Opens the Legendary login page in the default web browser."""
 | 
			
		||||
        login_url = "https://legendary.gl/epiclogin"
 | 
			
		||||
        try:
 | 
			
		||||
            QDesktopServices.openUrl(QUrl(login_url))
 | 
			
		||||
            self.statusBar().showMessage(_("Opened Legendary login page in browser"), 3000)
 | 
			
		||||
        except Exception as e:
 | 
			
		||||
            logger.error(f"Failed to open Legendary login page: {e}")
 | 
			
		||||
            self.statusBar().showMessage(_("Failed to open Legendary login page"), 3000)
 | 
			
		||||
 | 
			
		||||
    def submitLegendaryCode(self):
 | 
			
		||||
        """Submits the Legendary authorization code using the legendary CLI."""
 | 
			
		||||
        auth_code = self.legendaryCodeEdit.text().strip()
 | 
			
		||||
        if not auth_code:
 | 
			
		||||
            QMessageBox.warning(self, _("Error"), _("Please enter an authorization code"))
 | 
			
		||||
            return
 | 
			
		||||
 | 
			
		||||
        try:
 | 
			
		||||
            # Execute legendary auth command
 | 
			
		||||
            result = subprocess.run(
 | 
			
		||||
                [self.legendary_path, "auth", "--code", auth_code],
 | 
			
		||||
                capture_output=True,
 | 
			
		||||
                text=True,
 | 
			
		||||
                check=True
 | 
			
		||||
            )
 | 
			
		||||
            logger.info("Legendary authentication successful: %s", result.stdout)
 | 
			
		||||
            self.statusBar().showMessage(_("Successfully authenticated with Legendary"), 3000)
 | 
			
		||||
            self.legendaryCodeEdit.clear()
 | 
			
		||||
            # Reload Epic Games Store games after successful authentication
 | 
			
		||||
            self.games = self.loadGames()
 | 
			
		||||
            self.updateGameGrid()
 | 
			
		||||
        except subprocess.CalledProcessError as e:
 | 
			
		||||
            logger.error("Legendary authentication failed: %s", e.stderr)
 | 
			
		||||
            self.statusBar().showMessage(_("Legendary authentication failed: {0}").format(e.stderr), 5000)
 | 
			
		||||
        except FileNotFoundError:
 | 
			
		||||
            logger.error("Legendary executable not found at %s", self.legendary_path)
 | 
			
		||||
            self.statusBar().showMessage(_("Legendary executable not found"), 5000)
 | 
			
		||||
        except Exception as e:
 | 
			
		||||
            logger.error("Unexpected error during Legendary authentication: %s", str(e))
 | 
			
		||||
            self.statusBar().showMessage(_("Unexpected error during authentication"), 5000)
 | 
			
		||||
 | 
			
		||||
    def resetSettings(self):
 | 
			
		||||
        """Сбрасывает настройки и перезапускает приложение."""
 | 
			
		||||
@@ -1540,6 +1330,7 @@ class MainWindow(QMainWindow):
 | 
			
		||||
 | 
			
		||||
        self.settingsDebounceTimer.start()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
        # Управление полноэкранным режимом
 | 
			
		||||
        gamepad_connected = self.input_manager.find_gamepad() is not None
 | 
			
		||||
        if fullscreen or (auto_fullscreen_gamepad and gamepad_connected):
 | 
			
		||||
@@ -1734,7 +1525,7 @@ class MainWindow(QMainWindow):
 | 
			
		||||
        detailPage = QWidget()
 | 
			
		||||
        self._animations = {}
 | 
			
		||||
        imageLabel = QLabel()
 | 
			
		||||
        imageLabel.setFixedSize(300, 450)
 | 
			
		||||
        imageLabel.setFixedSize(300, 400)
 | 
			
		||||
        self._detail_page_active = True
 | 
			
		||||
        self._current_detail_page = detailPage
 | 
			
		||||
 | 
			
		||||
@@ -1768,7 +1559,7 @@ class MainWindow(QMainWindow):
 | 
			
		||||
                        logger.debug("Stylesheet updated with palette")
 | 
			
		||||
 | 
			
		||||
                    self.getColorPalette_async(cover_path, num_colors=5, callback=on_palette_ready)
 | 
			
		||||
                load_pixmap_async(cover_path, 300, 450, on_pixmap_ready)
 | 
			
		||||
                load_pixmap_async(cover_path, 300, 400, on_pixmap_ready)
 | 
			
		||||
            else:
 | 
			
		||||
                detailPage.setStyleSheet(self.theme.DETAIL_PAGE_NO_COVER_STYLE)
 | 
			
		||||
                detailPage.update()
 | 
			
		||||
@@ -1796,7 +1587,7 @@ class MainWindow(QMainWindow):
 | 
			
		||||
 | 
			
		||||
        # Обложка (слева)
 | 
			
		||||
        coverFrame = QFrame()
 | 
			
		||||
        coverFrame.setFixedSize(300, 450)
 | 
			
		||||
        coverFrame.setFixedSize(300, 400)
 | 
			
		||||
        coverFrame.setStyleSheet(self.theme.COVER_FRAME_STYLE)
 | 
			
		||||
        shadow = QGraphicsDropShadowEffect(coverFrame)
 | 
			
		||||
        shadow.setBlurRadius(20)
 | 
			
		||||
@@ -2238,6 +2029,8 @@ class MainWindow(QMainWindow):
 | 
			
		||||
        elif not child_running:
 | 
			
		||||
            # Игра завершилась – сбрасываем флаг, сбрасываем кнопку и останавливаем таймер
 | 
			
		||||
            self._gameLaunched = False
 | 
			
		||||
            if hasattr(self, 'input_manager'):
 | 
			
		||||
                self.input_manager.enable_gamepad_handling()
 | 
			
		||||
            self.resetPlayButton()
 | 
			
		||||
            #self._uninhibit_screensaver()
 | 
			
		||||
            if hasattr(self, 'checkProcessTimer') and self.checkProcessTimer is not None:
 | 
			
		||||
@@ -2293,6 +2086,9 @@ class MainWindow(QMainWindow):
 | 
			
		||||
            # Проверяем, запущена ли игра
 | 
			
		||||
            if self.game_processes and self.target_exe == current_exe:
 | 
			
		||||
                # Останавливаем игру
 | 
			
		||||
                if hasattr(self, 'input_manager'):
 | 
			
		||||
                    self.input_manager.enable_gamepad_handling()
 | 
			
		||||
 | 
			
		||||
                for proc in self.game_processes:
 | 
			
		||||
                    try:
 | 
			
		||||
                        parent = psutil.Process(proc.pid)
 | 
			
		||||
@@ -2352,6 +2148,10 @@ class MainWindow(QMainWindow):
 | 
			
		||||
                            icon = QIcon()
 | 
			
		||||
                        update_button.setIcon(icon)
 | 
			
		||||
 | 
			
		||||
                    # Delay disabling gamepad handling
 | 
			
		||||
                    if hasattr(self, 'input_manager'):
 | 
			
		||||
                        QTimer.singleShot(200, self.input_manager.disable_gamepad_handling)
 | 
			
		||||
 | 
			
		||||
                    self.checkProcessTimer = QTimer(self)
 | 
			
		||||
                    self.checkProcessTimer.timeout.connect(self.checkTargetExe)
 | 
			
		||||
                    self.checkProcessTimer.start(500)
 | 
			
		||||
@@ -2389,6 +2189,9 @@ class MainWindow(QMainWindow):
 | 
			
		||||
 | 
			
		||||
        # Если игра уже запущена для этого exe – останавливаем её
 | 
			
		||||
        if self.game_processes and self.target_exe == current_exe:
 | 
			
		||||
            if hasattr(self, 'input_manager'):
 | 
			
		||||
                self.input_manager.enable_gamepad_handling()
 | 
			
		||||
 | 
			
		||||
            for proc in self.game_processes:
 | 
			
		||||
                try:
 | 
			
		||||
                    parent = psutil.Process(proc.pid)
 | 
			
		||||
@@ -2436,6 +2239,10 @@ class MainWindow(QMainWindow):
 | 
			
		||||
                env_vars['START_FROM_STEAM'] = '1'
 | 
			
		||||
                env_vars['PROCESS_LOG'] = '1'
 | 
			
		||||
 | 
			
		||||
            # Delay disabling gamepad handling to allow rumble to complete
 | 
			
		||||
            if hasattr(self, 'input_manager'):
 | 
			
		||||
                QTimer.singleShot(200, self.input_manager.disable_gamepad_handling)
 | 
			
		||||
 | 
			
		||||
            # Запускаем игру
 | 
			
		||||
            try:
 | 
			
		||||
                process = subprocess.Popen(entry_exec_split, env=env_vars, shell=False, preexec_fn=os.setsid)
 | 
			
		||||
@@ -2459,51 +2266,46 @@ class MainWindow(QMainWindow):
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    def closeEvent(self, event):
 | 
			
		||||
        """Обработчик закрытия окна: сворачивает приложение в трей, если не требуется принудительный выход."""
 | 
			
		||||
        if hasattr(self, 'is_exiting') and self.is_exiting:
 | 
			
		||||
            # Принудительное закрытие: завершаем процессы и приложение
 | 
			
		||||
            for proc in self.game_processes:
 | 
			
		||||
                try:
 | 
			
		||||
                    parent = psutil.Process(proc.pid)
 | 
			
		||||
                    children = parent.children(recursive=True)
 | 
			
		||||
                    for child in children:
 | 
			
		||||
                        try:
 | 
			
		||||
                            logger.debug(f"Terminating child process {child.pid}")
 | 
			
		||||
                            child.terminate()
 | 
			
		||||
                        except psutil.NoSuchProcess:
 | 
			
		||||
                            logger.debug(f"Child process {child.pid} already terminated")
 | 
			
		||||
                    psutil.wait_procs(children, timeout=5)
 | 
			
		||||
                    for child in children:
 | 
			
		||||
                        if child.is_running():
 | 
			
		||||
                            logger.debug(f"Killing child process {child.pid}")
 | 
			
		||||
                            child.kill()
 | 
			
		||||
                    logger.debug(f"Terminating process group {proc.pid}")
 | 
			
		||||
                    os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
 | 
			
		||||
                except (psutil.NoSuchProcess, ProcessLookupError) as e:
 | 
			
		||||
                    logger.debug(f"Process {proc.pid} already terminated: {e}")
 | 
			
		||||
        """Завершает все дочерние процессы и сохраняет настройки при закрытии окна."""
 | 
			
		||||
        for proc in self.game_processes:
 | 
			
		||||
            try:
 | 
			
		||||
                parent = psutil.Process(proc.pid)
 | 
			
		||||
                children = parent.children(recursive=True)
 | 
			
		||||
                for child in children:
 | 
			
		||||
                    try:
 | 
			
		||||
                        logger.debug(f"Terminating child process {child.pid}")
 | 
			
		||||
                        child.terminate()
 | 
			
		||||
                    except psutil.NoSuchProcess:
 | 
			
		||||
                        logger.debug(f"Child process {child.pid} already terminated")
 | 
			
		||||
                psutil.wait_procs(children, timeout=5)
 | 
			
		||||
                for child in children:
 | 
			
		||||
                    if child.is_running():
 | 
			
		||||
                        logger.debug(f"Killing child process {child.pid}")
 | 
			
		||||
                        child.kill()
 | 
			
		||||
                logger.debug(f"Terminating process group {proc.pid}")
 | 
			
		||||
                os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
 | 
			
		||||
            except (psutil.NoSuchProcess, ProcessLookupError) as e:
 | 
			
		||||
                logger.debug(f"Process {proc.pid} already terminated: {e}")
 | 
			
		||||
 | 
			
		||||
            self.game_processes = []  # Очищаем список процессов
 | 
			
		||||
        self.game_processes = []  # Очищаем список процессов
 | 
			
		||||
 | 
			
		||||
            # Очищаем таймеры
 | 
			
		||||
            if hasattr(self, 'games_load_timer') and self.games_load_timer.isActive():
 | 
			
		||||
                self.games_load_timer.stop()
 | 
			
		||||
            if hasattr(self, 'settingsDebounceTimer') and self.settingsDebounceTimer.isActive():
 | 
			
		||||
                self.settingsDebounceTimer.stop()
 | 
			
		||||
            if hasattr(self, 'searchDebounceTimer') and self.searchDebounceTimer.isActive():
 | 
			
		||||
                self.searchDebounceTimer.stop()
 | 
			
		||||
            if hasattr(self, 'checkProcessTimer') and self.checkProcessTimer and self.checkProcessTimer.isActive():
 | 
			
		||||
                self.checkProcessTimer.stop()
 | 
			
		||||
                self.checkProcessTimer.deleteLater()
 | 
			
		||||
                self.checkProcessTimer = None
 | 
			
		||||
        # Сохраняем настройки окна
 | 
			
		||||
        if not read_fullscreen_config():
 | 
			
		||||
            logger.debug(f"Saving window geometry: {self.width()}x{self.height()}")
 | 
			
		||||
            save_window_geometry(self.width(), self.height())
 | 
			
		||||
        save_card_size(self.card_width)
 | 
			
		||||
 | 
			
		||||
            # Сохраняем настройки окна
 | 
			
		||||
            if not read_fullscreen_config():
 | 
			
		||||
                logger.debug(f"Saving window geometry: {self.width()}x{self.height()}")
 | 
			
		||||
                save_window_geometry(self.width(), self.height())
 | 
			
		||||
            save_card_size(self.card_width)
 | 
			
		||||
        # Очищаем таймеры и другие ресурсы
 | 
			
		||||
        if hasattr(self, 'games_load_timer') and self.games_load_timer.isActive():
 | 
			
		||||
            self.games_load_timer.stop()
 | 
			
		||||
        if hasattr(self, 'settingsDebounceTimer') and self.settingsDebounceTimer.isActive():
 | 
			
		||||
            self.settingsDebounceTimer.stop()
 | 
			
		||||
        if hasattr(self, 'searchDebounceTimer') and self.searchDebounceTimer.isActive():
 | 
			
		||||
            self.searchDebounceTimer.stop()
 | 
			
		||||
        if hasattr(self, 'checkProcessTimer') and self.checkProcessTimer and self.checkProcessTimer.isActive():
 | 
			
		||||
            self.checkProcessTimer.stop()
 | 
			
		||||
            self.checkProcessTimer.deleteLater()
 | 
			
		||||
            self.checkProcessTimer = None
 | 
			
		||||
 | 
			
		||||
            event.accept()
 | 
			
		||||
        else:
 | 
			
		||||
            # Сворачиваем в трей вместо закрытия
 | 
			
		||||
            self.hide()
 | 
			
		||||
            event.ignore()
 | 
			
		||||
        QApplication.quit()
 | 
			
		||||
        event.accept()
 | 
			
		||||
 
 | 
			
		||||
@@ -22,7 +22,6 @@ import websocket
 | 
			
		||||
import requests
 | 
			
		||||
import random
 | 
			
		||||
import base64
 | 
			
		||||
import glob
 | 
			
		||||
 | 
			
		||||
downloader = Downloader()
 | 
			
		||||
logger = get_logger(__name__)
 | 
			
		||||
@@ -266,20 +265,10 @@ def get_exiftool_data(game_exe):
 | 
			
		||||
        logger.error(f"An unexpected error occurred in get_exiftool_data for {game_exe}: {e}")
 | 
			
		||||
        return {}
 | 
			
		||||
 | 
			
		||||
def delete_cached_app_files(cache_dir: str, pattern: str):
 | 
			
		||||
    """Deletes cached files matching the given pattern in the cache directory."""
 | 
			
		||||
    try:
 | 
			
		||||
        for file_path in glob.glob(os.path.join(cache_dir, pattern)):
 | 
			
		||||
            os.remove(file_path)
 | 
			
		||||
            logger.info(f"Deleted cached file: {file_path}")
 | 
			
		||||
    except Exception as e:
 | 
			
		||||
        logger.error(f"Failed to delete cached files matching {pattern}: {e}")
 | 
			
		||||
 | 
			
		||||
def load_steam_apps_async(callback: Callable[[list], None]):
 | 
			
		||||
    """
 | 
			
		||||
    Asynchronously loads the list of Steam applications, using cache if available.
 | 
			
		||||
    Calls the callback with the list of apps.
 | 
			
		||||
    Deletes cached app detail files when downloading a new steam_apps.json.
 | 
			
		||||
    """
 | 
			
		||||
    cache_dir = get_cache_dir()
 | 
			
		||||
    cache_tar = os.path.join(cache_dir, "games_appid.tar.xz")
 | 
			
		||||
@@ -306,8 +295,6 @@ def load_steam_apps_async(callback: Callable[[list], None]):
 | 
			
		||||
            if os.path.exists(cache_tar):
 | 
			
		||||
                os.remove(cache_tar)
 | 
			
		||||
                logger.info("Archive %s deleted after extraction", cache_tar)
 | 
			
		||||
            # Delete all cached app detail files (steam_app_*.json)
 | 
			
		||||
            delete_cached_app_files(cache_dir, "steam_app_*.json")
 | 
			
		||||
            steam_apps = data if isinstance(data, list) else []
 | 
			
		||||
            logger.info("Loaded %d apps from archive", len(steam_apps))
 | 
			
		||||
            callback(steam_apps)
 | 
			
		||||
@@ -338,15 +325,11 @@ def load_steam_apps_async(callback: Callable[[list], None]):
 | 
			
		||||
            app_list_url = (
 | 
			
		||||
                "https://git.linux-gaming.ru/Boria138/PortProtonQt/raw/branch/main/data/games_appid.tar.xz"
 | 
			
		||||
            )
 | 
			
		||||
            # Delete cached app detail files before re-downloading
 | 
			
		||||
            delete_cached_app_files(cache_dir, "steam_app_*.json")
 | 
			
		||||
            downloader.download_async(app_list_url, cache_tar, timeout=5, callback=process_tar)
 | 
			
		||||
    else:
 | 
			
		||||
        app_list_url = (
 | 
			
		||||
            "https://git.linux-gaming.ru/Boria138/PortProtonQt/raw/branch/main/data/games_appid.tar.xz"
 | 
			
		||||
        )
 | 
			
		||||
        # Delete cached app detail files before downloading
 | 
			
		||||
        delete_cached_app_files(cache_dir, "steam_app_*.json")
 | 
			
		||||
        downloader.download_async(app_list_url, cache_tar, timeout=5, callback=process_tar)
 | 
			
		||||
 | 
			
		||||
def build_index(steam_apps):
 | 
			
		||||
@@ -444,7 +427,6 @@ def load_weanticheatyet_data_async(callback: Callable[[list], None]):
 | 
			
		||||
    """
 | 
			
		||||
    Asynchronously loads the list of WeAntiCheatYet data, using cache if available.
 | 
			
		||||
    Calls the callback with the list of anti-cheat data.
 | 
			
		||||
    Deletes cached anti-cheat files when downloading a new anticheat_games.json.
 | 
			
		||||
    """
 | 
			
		||||
    cache_dir = get_cache_dir()
 | 
			
		||||
    cache_tar = os.path.join(cache_dir, "anticheat_games.tar.xz")
 | 
			
		||||
@@ -501,15 +483,11 @@ def load_weanticheatyet_data_async(callback: Callable[[list], None]):
 | 
			
		||||
            app_list_url = (
 | 
			
		||||
                "https://git.linux-gaming.ru/Boria138/PortProtonQt/raw/branch/main/data/anticheat_games.tar.xz"
 | 
			
		||||
            )
 | 
			
		||||
            # Delete cached anti-cheat files before re-downloading
 | 
			
		||||
            delete_cached_app_files(cache_dir, "anticheat_*.json")  # Adjust pattern if app-specific files are added
 | 
			
		||||
            downloader.download_async(app_list_url, cache_tar, timeout=5, callback=process_tar)
 | 
			
		||||
    else:
 | 
			
		||||
        app_list_url = (
 | 
			
		||||
            "https://git.linux-gaming.ru/Boria138/PortProtonQt/raw/branch/main/data/anticheat_games.tar.xz"
 | 
			
		||||
        )
 | 
			
		||||
        # Delete cached anti-cheat files before downloading
 | 
			
		||||
        delete_cached_app_files(cache_dir, "anticheat_*.json")  # Adjust pattern if app-specific files are added
 | 
			
		||||
        downloader.download_async(app_list_url, cache_tar, timeout=5, callback=process_tar)
 | 
			
		||||
 | 
			
		||||
def build_weanticheatyet_index(anti_cheat_data):
 | 
			
		||||
 
 | 
			
		||||
@@ -1,8 +1,9 @@
 | 
			
		||||
import importlib.util
 | 
			
		||||
import os
 | 
			
		||||
import ast
 | 
			
		||||
from portprotonqt.logger import get_logger
 | 
			
		||||
from PySide6.QtGui import QIcon, QFontDatabase, QPixmap
 | 
			
		||||
from PySide6.QtSvg import QSvgRenderer
 | 
			
		||||
from PySide6.QtGui import QIcon, QColor, QFontDatabase, QPixmap, QPainter
 | 
			
		||||
 | 
			
		||||
from portprotonqt.config_utils import save_theme_to_config, load_theme_metainfo
 | 
			
		||||
 | 
			
		||||
logger = get_logger(__name__)
 | 
			
		||||
@@ -13,59 +14,6 @@ THEMES_DIRS = [
 | 
			
		||||
    os.path.join(xdg_data_home, "PortProtonQt", "themes"),
 | 
			
		||||
    os.path.join(os.path.dirname(os.path.abspath(__file__)), "themes")
 | 
			
		||||
]
 | 
			
		||||
_loaded_theme = None
 | 
			
		||||
 | 
			
		||||
# Запрещенные модули и функции
 | 
			
		||||
FORBIDDEN_MODULES = {
 | 
			
		||||
    "os",
 | 
			
		||||
    "subprocess",
 | 
			
		||||
    "shutil",
 | 
			
		||||
    "sys",
 | 
			
		||||
    "socket",
 | 
			
		||||
    "ctypes",
 | 
			
		||||
    "pathlib",
 | 
			
		||||
    "glob",
 | 
			
		||||
}
 | 
			
		||||
FORBIDDEN_FUNCTIONS = {
 | 
			
		||||
    "exec",
 | 
			
		||||
    "eval",
 | 
			
		||||
    "open",
 | 
			
		||||
    "__import__",
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
def check_theme_safety(theme_file: str) -> bool:
 | 
			
		||||
    """
 | 
			
		||||
    Проверяет файл темы на наличие запрещённых модулей и функций.
 | 
			
		||||
    Возвращает True, если файл безопасен, иначе False.
 | 
			
		||||
    """
 | 
			
		||||
    has_errors = False
 | 
			
		||||
    try:
 | 
			
		||||
        with open(theme_file) as f:
 | 
			
		||||
            content = f.read()
 | 
			
		||||
 | 
			
		||||
            # Проверка на опасные импорты и функции
 | 
			
		||||
            try:
 | 
			
		||||
                tree = ast.parse(content)
 | 
			
		||||
                for node in ast.walk(tree):
 | 
			
		||||
                    # Проверка импортов
 | 
			
		||||
                    if isinstance(node, ast.Import | ast.ImportFrom):
 | 
			
		||||
                        for name in node.names:
 | 
			
		||||
                            if name.name in FORBIDDEN_MODULES:
 | 
			
		||||
                                logger.error(f"Forbidden module '{name.name}' found in file {theme_file}")
 | 
			
		||||
                                has_errors = True
 | 
			
		||||
                    # Проверка вызовов функций
 | 
			
		||||
                    if isinstance(node, ast.Call):
 | 
			
		||||
                        if isinstance(node.func, ast.Name) and node.func.id in FORBIDDEN_FUNCTIONS:
 | 
			
		||||
                            logger.error(f"Forbidden function '{node.func.id}' found in file {theme_file}")
 | 
			
		||||
                            has_errors = True
 | 
			
		||||
            except SyntaxError as e:
 | 
			
		||||
                logger.error(f"Syntax error in file {theme_file}: {e}")
 | 
			
		||||
                has_errors = True
 | 
			
		||||
    except Exception as e:
 | 
			
		||||
        logger.error(f"Failed to check theme safety for {theme_file}: {e}")
 | 
			
		||||
        has_errors = True
 | 
			
		||||
 | 
			
		||||
    return not has_errors
 | 
			
		||||
 | 
			
		||||
def list_themes():
 | 
			
		||||
    """
 | 
			
		||||
@@ -101,13 +49,9 @@ def load_theme_screenshots(theme_name):
 | 
			
		||||
 | 
			
		||||
def load_theme_fonts(theme_name):
 | 
			
		||||
    """
 | 
			
		||||
    Загружает все шрифты выбранной темы, если они ещё не были загружены.
 | 
			
		||||
    Загружает все шрифты выбранной темы.
 | 
			
		||||
    :param theme_name: Имя темы.
 | 
			
		||||
    """
 | 
			
		||||
    global _loaded_theme
 | 
			
		||||
    if _loaded_theme == theme_name:
 | 
			
		||||
        logger.debug(f"Fonts for theme '{theme_name}' already loaded, skipping")
 | 
			
		||||
        return
 | 
			
		||||
 | 
			
		||||
    QFontDatabase.removeAllApplicationFonts()
 | 
			
		||||
    fonts_folder = None
 | 
			
		||||
    if theme_name == "standart":
 | 
			
		||||
@@ -122,7 +66,7 @@ def load_theme_fonts(theme_name):
 | 
			
		||||
                break
 | 
			
		||||
 | 
			
		||||
    if not fonts_folder or not os.path.exists(fonts_folder):
 | 
			
		||||
        logger.error(f"Fonts folder not found for theme '{theme_name}'")
 | 
			
		||||
        logger.error(f"Папка fonts не найдена для темы '{theme_name}'")
 | 
			
		||||
        return
 | 
			
		||||
 | 
			
		||||
    for filename in os.listdir(fonts_folder):
 | 
			
		||||
@@ -131,11 +75,29 @@ def load_theme_fonts(theme_name):
 | 
			
		||||
            font_id = QFontDatabase.addApplicationFont(font_path)
 | 
			
		||||
            if font_id != -1:
 | 
			
		||||
                families = QFontDatabase.applicationFontFamilies(font_id)
 | 
			
		||||
                logger.info(f"Font {filename} successfully loaded: {families}")
 | 
			
		||||
                logger.info(f"Шрифт {filename} успешно загружен: {families}")
 | 
			
		||||
            else:
 | 
			
		||||
                logger.error(f"Error loading font: {filename}")
 | 
			
		||||
                logger.error(f"Ошибка загрузки шрифта: {filename}")
 | 
			
		||||
 | 
			
		||||
    _loaded_theme = theme_name
 | 
			
		||||
def load_logo():
 | 
			
		||||
    logo_path = None
 | 
			
		||||
 | 
			
		||||
    base_dir = os.path.dirname(os.path.abspath(__file__))
 | 
			
		||||
    logo_path = os.path.join(base_dir, "themes", "standart", "images", "theme_logo.svg")
 | 
			
		||||
 | 
			
		||||
    file_extension = os.path.splitext(logo_path)[1].lower()
 | 
			
		||||
 | 
			
		||||
    if file_extension == ".svg":
 | 
			
		||||
        renderer = QSvgRenderer(logo_path)
 | 
			
		||||
        if not renderer.isValid():
 | 
			
		||||
            logger.error(f"Ошибка загрузки SVG логотипа: {logo_path}")
 | 
			
		||||
            return None
 | 
			
		||||
        pixmap = QPixmap(128, 128)
 | 
			
		||||
        pixmap.fill(QColor(0, 0, 0, 0))
 | 
			
		||||
        painter = QPainter(pixmap)
 | 
			
		||||
        renderer.render(painter)
 | 
			
		||||
        painter.end()
 | 
			
		||||
        return pixmap
 | 
			
		||||
 | 
			
		||||
class ThemeWrapper:
 | 
			
		||||
    """
 | 
			
		||||
@@ -147,83 +109,69 @@ class ThemeWrapper:
 | 
			
		||||
        self.custom_theme = custom_theme
 | 
			
		||||
        self.metainfo = metainfo or {}
 | 
			
		||||
        self.screenshots = load_theme_screenshots(self.metainfo.get("name", ""))
 | 
			
		||||
        self._default_theme = None  # Lazy-loaded default theme
 | 
			
		||||
 | 
			
		||||
    def __getattr__(self, name):
 | 
			
		||||
        if hasattr(self.custom_theme, name):
 | 
			
		||||
            return getattr(self.custom_theme, name)
 | 
			
		||||
        if self._default_theme is None:
 | 
			
		||||
            self._default_theme = load_theme("standart")  # Dynamically load standard theme
 | 
			
		||||
        return getattr(self._default_theme, name)
 | 
			
		||||
        import portprotonqt.themes.standart.styles as default_styles
 | 
			
		||||
        return getattr(default_styles, name)
 | 
			
		||||
 | 
			
		||||
def load_theme(theme_name):
 | 
			
		||||
    """
 | 
			
		||||
    Динамически загружает модуль стилей выбранной темы и метаинформацию.
 | 
			
		||||
    Все темы, включая стандартную, проходят проверку безопасности.
 | 
			
		||||
    Если выбрана стандартная тема, импортируется оригинальный styles.py.
 | 
			
		||||
    Для кастомных тем возвращается обёртка, которая подставляет недостающие атрибуты.
 | 
			
		||||
    """
 | 
			
		||||
    if theme_name == "standart":
 | 
			
		||||
        import portprotonqt.themes.standart.styles as default_styles
 | 
			
		||||
        return default_styles
 | 
			
		||||
 | 
			
		||||
    for themes_dir in THEMES_DIRS:
 | 
			
		||||
        theme_folder = os.path.join(themes_dir, theme_name)
 | 
			
		||||
        styles_file = os.path.join(theme_folder, "styles.py")
 | 
			
		||||
        if os.path.exists(styles_file):
 | 
			
		||||
            # Проверяем безопасность темы перед загрузкой
 | 
			
		||||
            if not check_theme_safety(styles_file):
 | 
			
		||||
                logger.error(f"Theme '{theme_name}' is unsafe, falling back to 'standart'")
 | 
			
		||||
                raise FileNotFoundError(f"Theme '{theme_name}' contains forbidden modules or functions")
 | 
			
		||||
 | 
			
		||||
            spec = importlib.util.spec_from_file_location("theme_styles", styles_file)
 | 
			
		||||
            if spec is None or spec.loader is None:
 | 
			
		||||
                continue
 | 
			
		||||
            custom_theme = importlib.util.module_from_spec(spec)
 | 
			
		||||
            spec.loader.exec_module(custom_theme)
 | 
			
		||||
            if theme_name == "standart":
 | 
			
		||||
                return custom_theme
 | 
			
		||||
            meta = load_theme_metainfo(theme_name)
 | 
			
		||||
            wrapper = ThemeWrapper(custom_theme, metainfo=meta)
 | 
			
		||||
            wrapper.screenshots = load_theme_screenshots(theme_name)
 | 
			
		||||
            return wrapper
 | 
			
		||||
    raise FileNotFoundError(f"Styles file not found for theme '{theme_name}'")
 | 
			
		||||
    raise FileNotFoundError(f"Файл стилей не найден для темы '{theme_name}'")
 | 
			
		||||
 | 
			
		||||
class ThemeManager:
 | 
			
		||||
    """
 | 
			
		||||
    Класс для управления темами приложения.
 | 
			
		||||
    Реализует паттерн Singleton для единого экземпляра.
 | 
			
		||||
 | 
			
		||||
    Позволяет получить список доступных тем, загрузить и применить выбранную тему.
 | 
			
		||||
    """
 | 
			
		||||
    _instance = None
 | 
			
		||||
    def __init__(self):
 | 
			
		||||
        self.current_theme_name = None
 | 
			
		||||
        self.current_theme_module = None
 | 
			
		||||
 | 
			
		||||
    def __new__(cls):
 | 
			
		||||
        if cls._instance is None:
 | 
			
		||||
            cls._instance = super().__new__(cls)
 | 
			
		||||
            cls._instance.current_theme_name = None
 | 
			
		||||
            cls._instance.current_theme_module = None
 | 
			
		||||
        return cls._instance
 | 
			
		||||
 | 
			
		||||
    def get_available_themes(self) -> list:
 | 
			
		||||
    def get_available_themes(self):
 | 
			
		||||
        """Возвращает список доступных тем."""
 | 
			
		||||
        return list_themes()
 | 
			
		||||
 | 
			
		||||
    def apply_theme(self, theme_name: str):
 | 
			
		||||
        """
 | 
			
		||||
        Применяет указанную тему, если она ещё не применена.
 | 
			
		||||
        Возвращает модуль темы или обёртку.
 | 
			
		||||
        """
 | 
			
		||||
        if self.current_theme_name == theme_name and self.current_theme_module is not None:
 | 
			
		||||
            logger.debug(f"Theme '{theme_name}' is already applied, skipping")
 | 
			
		||||
            return self.current_theme_module
 | 
			
		||||
 | 
			
		||||
        try:
 | 
			
		||||
            theme_module = load_theme(theme_name)
 | 
			
		||||
        except FileNotFoundError:
 | 
			
		||||
            logger.warning(f"Theme '{theme_name}' not found or unsafe, applying standard theme 'standart'")
 | 
			
		||||
            theme_module = load_theme("standart")
 | 
			
		||||
            theme_name = "standart"
 | 
			
		||||
            save_theme_to_config("standart")
 | 
			
		||||
    def get_theme_logo(self):
 | 
			
		||||
        """Возвращает логотип для текущей или указанной темы."""
 | 
			
		||||
        return load_logo()
 | 
			
		||||
 | 
			
		||||
    def apply_theme(self, theme_name):
 | 
			
		||||
        """
 | 
			
		||||
        Применяет выбранную тему: загружает модуль стилей, шрифты и логотип.
 | 
			
		||||
        Если загрузка прошла успешно, сохраняет выбранную тему в конфигурации.
 | 
			
		||||
        :param theme_name: Имя темы.
 | 
			
		||||
        :return: Загруженный модуль темы (или обёртка).
 | 
			
		||||
        """
 | 
			
		||||
        theme_module = load_theme(theme_name)
 | 
			
		||||
        load_theme_fonts(theme_name)
 | 
			
		||||
        self.current_theme_name = theme_name
 | 
			
		||||
        self.current_theme_module = theme_module
 | 
			
		||||
        save_theme_to_config(theme_name)
 | 
			
		||||
        logger.info(f"Theme '{theme_name}' successfully applied")
 | 
			
		||||
        logger.info(f"Тема '{theme_name}' успешно применена")
 | 
			
		||||
        return theme_module
 | 
			
		||||
 | 
			
		||||
    def get_icon(self, icon_name, theme_name=None, as_path=False):
 | 
			
		||||
@@ -278,7 +226,7 @@ class ThemeManager:
 | 
			
		||||
 | 
			
		||||
        # Если иконка всё равно не найдена
 | 
			
		||||
        if not icon_path or not os.path.exists(icon_path):
 | 
			
		||||
            logger.error(f"Warning: icon '{icon_name}' not found")
 | 
			
		||||
            logger.error(f"Предупреждение: иконка '{icon_name}' не найдена")
 | 
			
		||||
            return QIcon() if not as_path else None
 | 
			
		||||
 | 
			
		||||
        if as_path:
 | 
			
		||||
 
 | 
			
		||||
| 
		 Before Width: | Height: | Size: 880 B  | 
| 
		 Before Width: | Height: | Size: 2.0 KiB  | 
| 
		 Before Width: | Height: | Size: 874 B  | 
| 
		 Before Width: | Height: | Size: 1.3 KiB  | 
| 
		 Before Width: | Height: | Size: 943 B  | 
| 
		 Before Width: | Height: | Size: 933 B  | 
| 
		 Before Width: | Height: | Size: 956 B  | 
| 
		 Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1.1 KiB  | 
| 
		 Before Width: | Height: | Size: 1.9 KiB  | 
| 
		 Before Width: | Height: | Size: 1.7 KiB  | 
| 
		 Before Width: | Height: | Size: 1.7 KiB  | 
| 
		 Before Width: | Height: | Size: 1.3 KiB  | 
| 
		 Before Width: | Height: | Size: 1.8 KiB  | 
| 
		 Before Width: | Height: | Size: 1.4 KiB  | 
| 
		 Before Width: | Height: | Size: 1.8 KiB  | 
| 
		 Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 1.4 MiB  | 
| 
		 Before Width: | Height: | Size: 364 KiB After Width: | Height: | Size: 562 KiB  | 
| 
		 Before Width: | Height: | Size: 430 KiB After Width: | Height: | Size: 445 KiB  | 
| 
		 Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 1.4 MiB  | 
| 
		 Before Width: | Height: | Size: 104 KiB After Width: | Height: | Size: 106 KiB  | 
| 
		 Before Width: | Height: | Size: 1.0 MiB After Width: | Height: | Size: 1.1 MiB  | 
							
								
								
									
										1
									
								
								portprotonqt/themes/standart/images/theme_logo.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| 
		 After Width: | Height: | Size: 7.8 KiB  | 
| 
		 Before Width: | Height: | Size: 1.7 KiB  | 
| 
		 Before Width: | Height: | Size: 1.7 KiB  | 
| 
		 Before Width: | Height: | Size: 1.9 KiB  | 
| 
		 Before Width: | Height: | Size: 1.8 KiB  | 
| 
		 Before Width: | Height: | Size: 2.1 KiB  | 
| 
		 Before Width: | Height: | Size: 2.2 KiB  | 
| 
		 Before Width: | Height: | Size: 1.8 KiB  | 
@@ -29,104 +29,69 @@ color_h = "transparent"
 | 
			
		||||
GAME_CARD_ANIMATION = {
 | 
			
		||||
    # Тип анимации при входе и выходе на детальную страницу
 | 
			
		||||
    # Возможные значения: "fade", "slide_left", "slide_right", "slide_up", "slide_down", "bounce"
 | 
			
		||||
    # Определяет, как детальная страница появляется и исчезает
 | 
			
		||||
    "detail_page_animation_type": "fade",
 | 
			
		||||
 | 
			
		||||
    # Ширина обводки карточки в состоянии покоя (без наведения или фокуса)
 | 
			
		||||
    # Влияет на толщину рамки вокруг карточки, когда она не выделена
 | 
			
		||||
    # Значение в пикселях
 | 
			
		||||
    # Ширина обводки карточки в состоянии покоя (без наведения или фокуса).
 | 
			
		||||
    # Влияет на толщину рамки вокруг карточки, когда она не выделена.
 | 
			
		||||
    # Значение в пикселях.
 | 
			
		||||
    "default_border_width": 2,
 | 
			
		||||
 | 
			
		||||
    # Ширина обводки при наведении курсора
 | 
			
		||||
    # Увеличивает толщину рамки, когда курсор находится над карточкой
 | 
			
		||||
    # Значение в пикселях
 | 
			
		||||
    # Ширина обводки при наведении курсора.
 | 
			
		||||
    # Увеличивает толщину рамки, когда курсор находится над карточкой.
 | 
			
		||||
    # Значение в пикселях.
 | 
			
		||||
    "hover_border_width": 8,
 | 
			
		||||
 | 
			
		||||
    # Ширина обводки при фокусе (например, при выборе с клавиатуры)
 | 
			
		||||
    # Увеличивает толщину рамки, когда карточка в фокусе
 | 
			
		||||
    # Значение в пикселях
 | 
			
		||||
    # Ширина обводки при фокусе (например, при выборе с клавиатуры).
 | 
			
		||||
    # Увеличивает толщину рамки, когда карточка в фокусе.
 | 
			
		||||
    # Значение в пикселях.
 | 
			
		||||
    "focus_border_width": 12,
 | 
			
		||||
 | 
			
		||||
    # Минимальная ширина обводки во время пульсирующей анимации
 | 
			
		||||
    # Определяет минимальную толщину рамки при пульсации (анимация "дыхания")
 | 
			
		||||
    # Значение в пикселях
 | 
			
		||||
    # Минимальная ширина обводки во время пульсирующей анимации.
 | 
			
		||||
    # Определяет минимальную толщину рамки при пульсации (анимация "дыхания").
 | 
			
		||||
    # Значение в пикселях.
 | 
			
		||||
    "pulse_min_border_width": 8,
 | 
			
		||||
 | 
			
		||||
    # Максимальная ширина обводки во время пульсирующей анимации
 | 
			
		||||
    # Определяет максимальную толщину рамки при пульсации
 | 
			
		||||
    # Значение в пикселях
 | 
			
		||||
    # Максимальная ширина обводки во время пульсирующей анимации.
 | 
			
		||||
    # Определяет максимальную толщину рамки при пульсации.
 | 
			
		||||
    # Значение в пикселях.
 | 
			
		||||
    "pulse_max_border_width": 10,
 | 
			
		||||
 | 
			
		||||
    # Длительность анимации изменения толщины обводки (например, при наведении или фокусе)
 | 
			
		||||
    # Влияет на скорость перехода от одной ширины обводки к другой
 | 
			
		||||
    # Значение в миллисекундах
 | 
			
		||||
    # Длительность анимации изменения толщины обводки (например, при наведении или фокусе).
 | 
			
		||||
    # Влияет на скорость перехода от одной ширины обводки к другой.
 | 
			
		||||
    # Значение в миллисекундах.
 | 
			
		||||
    "thickness_anim_duration": 300,
 | 
			
		||||
 | 
			
		||||
    # Длительность одного цикла пульсирующей анимации
 | 
			
		||||
    # Определяет, как быстро рамка "пульсирует" между min и max значениями
 | 
			
		||||
    # Значение в миллисекундах
 | 
			
		||||
    # Длительность одного цикла пульсирующей анимации.
 | 
			
		||||
    # Определяет, как быстро рамка "пульсирует" между min и max значениями.
 | 
			
		||||
    # Значение в миллисекундах.
 | 
			
		||||
    "pulse_anim_duration": 800,
 | 
			
		||||
 | 
			
		||||
    # Длительность анимации вращения градиента
 | 
			
		||||
    # Влияет на скорость, с которой градиентная обводка вращается вокруг карточки
 | 
			
		||||
    # Значение в миллисекундах
 | 
			
		||||
    # Длительность анимации вращения градиента.
 | 
			
		||||
    # Влияет на скорость, с которой градиентная обводка вращается вокруг карточки.
 | 
			
		||||
    # Значение в миллисекундах.
 | 
			
		||||
    "gradient_anim_duration": 3000,
 | 
			
		||||
 | 
			
		||||
    # Начальный угол градиента (в градусах)
 | 
			
		||||
    # Определяет начальную точку вращения градиента при старте анимации
 | 
			
		||||
    # Начальный угол градиента (в градусах).
 | 
			
		||||
    # Определяет начальную точку вращения градиента при старте анимации.
 | 
			
		||||
    "gradient_start_angle": 360,
 | 
			
		||||
 | 
			
		||||
    # Конечный угол градиента (в градусах)
 | 
			
		||||
    # Определяет конечную точку вращения градиента
 | 
			
		||||
    # Значение 0 означает полный поворот на 360 градусов
 | 
			
		||||
    # Конечный угол градиента (в градусах).
 | 
			
		||||
    # Определяет конечную точку вращения градиента.
 | 
			
		||||
    # Значение 0 означает полный поворот на 360 градусов.
 | 
			
		||||
    "gradient_end_angle": 0,
 | 
			
		||||
 | 
			
		||||
    # Тип анимации для карточки при наведении или фокусе
 | 
			
		||||
    # Возможные значения: "gradient", "scale"
 | 
			
		||||
    # "gradient" включает вращающийся градиент для обводки, "scale" увеличивает размер карточки
 | 
			
		||||
    "card_animation_type": "gradient",
 | 
			
		||||
 | 
			
		||||
    # Масштаб карточки в состоянии покоя
 | 
			
		||||
    # Определяет базовый размер карточки (1.0 = 100% от исходного размера)
 | 
			
		||||
    # Значение в долях (например, 1.0 для нормального размера)
 | 
			
		||||
    "default_scale": 1.0,
 | 
			
		||||
 | 
			
		||||
    # Масштаб карточки при наведении курсора
 | 
			
		||||
    # Увеличивает размер карточки при наведении
 | 
			
		||||
    # Значение в долях (например, 1.1 = 110% от исходного размера)
 | 
			
		||||
    "hover_scale": 1.1,
 | 
			
		||||
 | 
			
		||||
    # Масштаб карточки при фокусе (например, при выборе с клавиатуры)
 | 
			
		||||
    # Увеличивает размер карточки при фокусе
 | 
			
		||||
    # Значение в долях (например, 1.05 = 105% от исходного размера)
 | 
			
		||||
    "focus_scale": 1.05,
 | 
			
		||||
 | 
			
		||||
    # Длительность анимации масштабирования
 | 
			
		||||
    # Влияет на скорость изменения размера карточки при наведении или фокусе
 | 
			
		||||
    # Значение в миллисекундах
 | 
			
		||||
    "scale_anim_duration": 200,
 | 
			
		||||
 | 
			
		||||
    # Тип кривой сглаживания для анимации увеличения обводки (при наведении/фокусе)
 | 
			
		||||
    # Влияет на "чувство" анимации (например, плавное ускорение или замедление)
 | 
			
		||||
    # Возможные значения: строки, соответствующие QEasingCurve.Type (например, "OutBack", "InOutQuad")
 | 
			
		||||
    # Тип кривой сглаживания для анимации увеличения обводки (при наведении/фокусе).
 | 
			
		||||
    # Влияет на "чувство" анимации (например, плавное ускорение или замедление).
 | 
			
		||||
    # Возможные значения: строки, соответствующие QEasingCurve.Type (например, "OutBack", "InOutQuad").
 | 
			
		||||
    "thickness_easing_curve": "OutBack",
 | 
			
		||||
 | 
			
		||||
    # Тип кривой сглаживания для анимации уменьшения обводки (при уходе курсора/потере фокуса)
 | 
			
		||||
    # Влияет на "чувство" возврата к исходной ширине обводки
 | 
			
		||||
    # Тип кривой сглаживания для анимации уменьшения обводки (при уходе курсора/потере фокуса).
 | 
			
		||||
    # Влияет на "чувство" возврата к исходной ширине обводки.
 | 
			
		||||
    "thickness_easing_curve_out": "InBack",
 | 
			
		||||
 | 
			
		||||
    # Тип кривой сглаживания для анимации увеличения масштаба (при наведении/фокусе)
 | 
			
		||||
    # Влияет на "чувство" анимации масштабирования (например, с эффектом "отскока")
 | 
			
		||||
    # Возможные значения: строки, соответствующие QEasingCurve.Type
 | 
			
		||||
    "scale_easing_curve": "OutBack",
 | 
			
		||||
 | 
			
		||||
    # Тип кривой сглаживания для анимации уменьшения масштаба (при уходе курсора/потере фокуса)
 | 
			
		||||
    # Влияет на "чувство" возврата к исходному масштабу
 | 
			
		||||
    "scale_easing_curve_out": "InBack",
 | 
			
		||||
 | 
			
		||||
    # Цвета градиента для анимированной обводки
 | 
			
		||||
    # Список словарей, где каждый словарь задает позицию (0.0–1.0) и цвет в формате hex
 | 
			
		||||
    # Влияет на внешний вид обводки при наведении или фокусе, если card_animation_type="gradient"
 | 
			
		||||
    # Цвета градиента для анимированной обводки.
 | 
			
		||||
    # Список словарей, где каждый словарь задает позицию (0.0–1.0) и цвет в формате hex.
 | 
			
		||||
    # Влияет на внешний вид обводки при наведении или фокусе.
 | 
			
		||||
    "gradient_colors": [
 | 
			
		||||
        {"position": 0, "color": "#00fff5"},    # Начальный цвет (циан)
 | 
			
		||||
        {"position": 0.33, "color": "#FF5733"}, # Цвет на 33% (оранжевый)
 | 
			
		||||
@@ -135,43 +100,29 @@ GAME_CARD_ANIMATION = {
 | 
			
		||||
    ],
 | 
			
		||||
 | 
			
		||||
    # Длительность анимации fade при входе на детальную страницу
 | 
			
		||||
    # Влияет на скорость появления страницы при fade-анимации
 | 
			
		||||
    # Значение в миллисекундах
 | 
			
		||||
    "detail_page_fade_duration": 350,
 | 
			
		||||
 | 
			
		||||
    # Длительность анимации slide при входе на детальную страницу
 | 
			
		||||
    # Влияет на скорость скольжения страницы при slide-анимации
 | 
			
		||||
    # Значение в миллисекундах
 | 
			
		||||
    "detail_page_slide_duration": 500,
 | 
			
		||||
 | 
			
		||||
    # Длительность анимации bounce при входе на детальную страницу
 | 
			
		||||
    # Влияет на скорость "прыжка" страницы при bounce-анимации
 | 
			
		||||
    # Значение в миллисекундах
 | 
			
		||||
    "detail_page_bounce_duration": 400,
 | 
			
		||||
 | 
			
		||||
    # Длительность анимации fade при выходе из детальной страницы
 | 
			
		||||
    # Влияет на скорость исчезновения страницы при fade-анимации
 | 
			
		||||
    # Значение в миллисекундах
 | 
			
		||||
    "detail_page_fade_duration_exit": 350,
 | 
			
		||||
 | 
			
		||||
    # Длительность анимации slide при выходе из детальной страницы
 | 
			
		||||
    # Влияет на скорость скольжения страницы при slide-анимации
 | 
			
		||||
    # Значение в миллисекундах
 | 
			
		||||
    "detail_page_slide_duration_exit": 500,
 | 
			
		||||
 | 
			
		||||
    # Длительность анимации bounce при выходе из детальной страницы
 | 
			
		||||
    # Влияет на скорость "сжатия" страницы при bounce-анимации
 | 
			
		||||
    # Значение в миллисекундах
 | 
			
		||||
    "detail_page_bounce_duration_exit": 400,
 | 
			
		||||
 | 
			
		||||
    # Тип кривой сглаживания для анимации при входе на детальную страницу
 | 
			
		||||
    # Применяется к slide и bounce анимациям, влияет на "чувство" движения
 | 
			
		||||
    # Возможные значения: строки, соответствующие QEasingCurve.Type
 | 
			
		||||
    # Применяется к slide и bounce анимациям
 | 
			
		||||
    "detail_page_easing_curve": "OutCubic",
 | 
			
		||||
 | 
			
		||||
    # Тип кривой сглаживания для анимации при выходе из детальной страницы
 | 
			
		||||
    # Применяется к slide и bounce анимациям, влияет на "чувство" движения
 | 
			
		||||
    # Возможные значения: строки, соответствующие QEasingCurve.Type
 | 
			
		||||
    # Применяется к slide и bounce анимациям
 | 
			
		||||
    "detail_page_easing_curve_exit": "InCubic"
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@@ -280,6 +231,16 @@ MAIN_WINDOW_HEADER_STYLE = f"""
 | 
			
		||||
    }}
 | 
			
		||||
"""
 | 
			
		||||
 | 
			
		||||
# СТИЛЬ ЗАГОЛОВКА (ЛОГО) В ШАПКЕ
 | 
			
		||||
TITLE_LABEL_STYLE = """
 | 
			
		||||
    QLabel {
 | 
			
		||||
        font-family: 'RASKHAL';
 | 
			
		||||
        font-size: 38px;
 | 
			
		||||
        margin: 0 0 0 0;
 | 
			
		||||
        color: #007AFF;
 | 
			
		||||
    }
 | 
			
		||||
"""
 | 
			
		||||
 | 
			
		||||
# СТИЛЬ ОБЛАСТИ НАВИГАЦИИ (КНОПКИ ВКЛАДОК)
 | 
			
		||||
NAV_WIDGET_STYLE = f"""
 | 
			
		||||
    QWidget {{
 | 
			
		||||
 
 | 
			
		||||
@@ -1,251 +0,0 @@
 | 
			
		||||
import sys
 | 
			
		||||
import subprocess
 | 
			
		||||
import shlex
 | 
			
		||||
import signal
 | 
			
		||||
import psutil
 | 
			
		||||
import os
 | 
			
		||||
from PySide6.QtWidgets import QSystemTrayIcon, QMenu, QApplication, QMessageBox
 | 
			
		||||
from PySide6.QtGui import QIcon, QAction
 | 
			
		||||
from PySide6.QtCore import QTimer
 | 
			
		||||
from portprotonqt.logger import get_logger
 | 
			
		||||
from portprotonqt.theme_manager import ThemeManager
 | 
			
		||||
from portprotonqt.localization import _
 | 
			
		||||
from portprotonqt.config_utils import read_favorites, read_theme_from_config, save_theme_to_config
 | 
			
		||||
from portprotonqt.dialogs import GameLaunchDialog
 | 
			
		||||
 | 
			
		||||
logger = get_logger(__name__)
 | 
			
		||||
 | 
			
		||||
class TrayManager:
 | 
			
		||||
    """Модуль управления системным треем для PortProtonQt.
 | 
			
		||||
 | 
			
		||||
    Обеспечивает:
 | 
			
		||||
    - Показ/скрытие главного окна по двойному клику на иконку трея.
 | 
			
		||||
    - Контекстное меню с опциями: Show/Hide, Favorites, Recent Games, Themes, Exit.
 | 
			
		||||
    - Динамическое заполнение меню Favorites, Recent Games и Themes.
 | 
			
		||||
    - Сворачивание в трей при закрытии окна, полное закрытие через Exit.
 | 
			
		||||
    """
 | 
			
		||||
 | 
			
		||||
    def __init__(self, main_window, app_name: str | None = None, theme=None):
 | 
			
		||||
        self.app_name = app_name if app_name is not None else "PortProtonQt"
 | 
			
		||||
        self.theme_manager = ThemeManager()
 | 
			
		||||
        selected_theme = read_theme_from_config()
 | 
			
		||||
        self.current_theme_name = selected_theme
 | 
			
		||||
        self.theme = self.theme_manager.apply_theme(selected_theme)
 | 
			
		||||
        self.main_window = main_window
 | 
			
		||||
        self.tray_icon = QSystemTrayIcon(self.main_window)
 | 
			
		||||
 | 
			
		||||
        icon = self.theme_manager.get_icon("portproton", self.current_theme_name)
 | 
			
		||||
        if isinstance(icon, str):
 | 
			
		||||
            icon = QIcon(icon)
 | 
			
		||||
        elif icon is None:
 | 
			
		||||
            icon = QIcon()
 | 
			
		||||
        self.tray_icon.setIcon(icon)
 | 
			
		||||
 | 
			
		||||
        self.tray_icon.activated.connect(self.handle_tray_click)
 | 
			
		||||
        self.tray_icon.setToolTip(self.app_name)
 | 
			
		||||
 | 
			
		||||
        self.tray_menu = QMenu()
 | 
			
		||||
        self.toggle_action = QAction(_("Show"), self.main_window)
 | 
			
		||||
        self.toggle_action.triggered.connect(self.toggle_window_action)
 | 
			
		||||
 | 
			
		||||
        self.favorites_menu = QMenu(_("Favorites"))
 | 
			
		||||
        self.favorites_menu.aboutToShow.connect(self.populate_favorites_menu)
 | 
			
		||||
 | 
			
		||||
        self.recent_menu = QMenu(_("Recent Games"))
 | 
			
		||||
        self.recent_menu.aboutToShow.connect(self.populate_recent_menu)
 | 
			
		||||
 | 
			
		||||
        self.themes_menu = QMenu(_("Themes"))
 | 
			
		||||
        self.themes_menu.aboutToShow.connect(self.populate_themes_menu)
 | 
			
		||||
 | 
			
		||||
        self.tray_menu.addAction(self.toggle_action)
 | 
			
		||||
        self.tray_menu.addSeparator()
 | 
			
		||||
        self.tray_menu.addMenu(self.favorites_menu)
 | 
			
		||||
        self.tray_menu.addMenu(self.recent_menu)
 | 
			
		||||
        self.tray_menu.addMenu(self.themes_menu)
 | 
			
		||||
        self.tray_menu.addSeparator()
 | 
			
		||||
        exit_action = QAction(_("Exit"), self.main_window)
 | 
			
		||||
        exit_action.triggered.connect(self.force_exit)
 | 
			
		||||
        self.tray_menu.addAction(exit_action)
 | 
			
		||||
 | 
			
		||||
        self.tray_menu.aboutToShow.connect(self.update_toggle_action)
 | 
			
		||||
 | 
			
		||||
        self.tray_icon.setContextMenu(self.tray_menu)
 | 
			
		||||
        self.tray_icon.show()
 | 
			
		||||
 | 
			
		||||
        self.main_window.is_exiting = False
 | 
			
		||||
 | 
			
		||||
        self.click_count = 0
 | 
			
		||||
        self.click_timer = QTimer()
 | 
			
		||||
        self.click_timer.setSingleShot(True)
 | 
			
		||||
        self.click_timer.timeout.connect(self.reset_click_count)
 | 
			
		||||
 | 
			
		||||
        self.launch_dialog = None
 | 
			
		||||
 | 
			
		||||
    def update_toggle_action(self):
 | 
			
		||||
        if self.main_window.isVisible():
 | 
			
		||||
            self.toggle_action.setText(_("Hide"))
 | 
			
		||||
        else:
 | 
			
		||||
            self.toggle_action.setText(_("Show"))
 | 
			
		||||
 | 
			
		||||
    def handle_tray_click(self, reason):
 | 
			
		||||
        if reason == QSystemTrayIcon.ActivationReason.Trigger:
 | 
			
		||||
            self.click_count += 1
 | 
			
		||||
            if self.click_count == 1:
 | 
			
		||||
                self.click_timer.start(300)
 | 
			
		||||
            elif self.click_count == 2:
 | 
			
		||||
                self.click_timer.stop()
 | 
			
		||||
                self.toggle_window_action()
 | 
			
		||||
                self.click_count = 0
 | 
			
		||||
 | 
			
		||||
    def reset_click_count(self):
 | 
			
		||||
        self.click_count = 0
 | 
			
		||||
 | 
			
		||||
    def toggle_window_action(self):
 | 
			
		||||
        if self.main_window.isVisible():
 | 
			
		||||
            self.main_window.hide()
 | 
			
		||||
        else:
 | 
			
		||||
            self.main_window.show()
 | 
			
		||||
            self.main_window.raise_()
 | 
			
		||||
            self.main_window.activateWindow()
 | 
			
		||||
 | 
			
		||||
    def populate_favorites_menu(self):
 | 
			
		||||
        self.favorites_menu.clear()
 | 
			
		||||
        favorites = read_favorites()
 | 
			
		||||
        if not favorites:
 | 
			
		||||
            no_fav_action = QAction(_("No favorites"), self.main_window)
 | 
			
		||||
            no_fav_action.setEnabled(False)
 | 
			
		||||
            self.favorites_menu.addAction(no_fav_action)
 | 
			
		||||
            return
 | 
			
		||||
 | 
			
		||||
        game_map = {game[0]: (game[4], game[12]) for game in self.main_window.games}
 | 
			
		||||
 | 
			
		||||
        for fav in sorted(favorites):
 | 
			
		||||
            game_data = game_map.get(fav)
 | 
			
		||||
            if game_data:
 | 
			
		||||
                exec_line, source = game_data
 | 
			
		||||
                action_text = f"{fav} ({source})"
 | 
			
		||||
                action = QAction(action_text, self.main_window)
 | 
			
		||||
                action.triggered.connect(lambda checked=False, el=exec_line, name=fav: self.launch_game_with_dialog(el, name))
 | 
			
		||||
                self.favorites_menu.addAction(action)
 | 
			
		||||
            else:
 | 
			
		||||
                logger.warning(f"Exec line not found for favorite: {fav}")
 | 
			
		||||
 | 
			
		||||
    def populate_recent_menu(self):
 | 
			
		||||
        self.recent_menu.clear()
 | 
			
		||||
        if not self.main_window.games:
 | 
			
		||||
            no_recent_action = QAction(_("No recent games"), self.main_window)
 | 
			
		||||
            no_recent_action.setEnabled(False)
 | 
			
		||||
            self.recent_menu.addAction(no_recent_action)
 | 
			
		||||
            return
 | 
			
		||||
 | 
			
		||||
        recent_games = sorted(self.main_window.games, key=lambda g: g[10], reverse=True)[:5]
 | 
			
		||||
 | 
			
		||||
        for game in recent_games:
 | 
			
		||||
            game_name = game[0]
 | 
			
		||||
            exec_line = game[4]
 | 
			
		||||
            source = game[12]
 | 
			
		||||
            action_text = f"{game_name} ({source})"
 | 
			
		||||
            action = QAction(action_text, self.main_window)
 | 
			
		||||
            action.triggered.connect(lambda checked=False, el=exec_line, name=game_name: self.launch_game_with_dialog(el, name))
 | 
			
		||||
            self.recent_menu.addAction(action)
 | 
			
		||||
 | 
			
		||||
    def launch_game_with_dialog(self, exec_line, game_name):
 | 
			
		||||
        """Launch a game with a modal dialog indicating progress."""
 | 
			
		||||
        try:
 | 
			
		||||
            # Determine target executable
 | 
			
		||||
            target_exe = None
 | 
			
		||||
            if exec_line.startswith("steam://"):
 | 
			
		||||
                # Steam games are handled differently, no target_exe needed
 | 
			
		||||
                self.launch_dialog = GameLaunchDialog(self.main_window, game_name=game_name, theme=self.theme)
 | 
			
		||||
            else:
 | 
			
		||||
                # Extract target executable from exec_line
 | 
			
		||||
                entry_exec_split = shlex.split(exec_line)
 | 
			
		||||
                if entry_exec_split[0] == "env" and len(entry_exec_split) > 2:
 | 
			
		||||
                    file_to_check = entry_exec_split[2]
 | 
			
		||||
                elif entry_exec_split[0] == "flatpak" and len(entry_exec_split) > 3:
 | 
			
		||||
                    file_to_check = entry_exec_split[3]
 | 
			
		||||
                else:
 | 
			
		||||
                    file_to_check = entry_exec_split[0]
 | 
			
		||||
 | 
			
		||||
                if not os.path.exists(file_to_check):
 | 
			
		||||
                    logger.error(f"File not found: {file_to_check}")
 | 
			
		||||
                    QMessageBox.warning(self.main_window, _("Error"), _("File not found: {0}").format(file_to_check))
 | 
			
		||||
                    return
 | 
			
		||||
 | 
			
		||||
                target_exe = os.path.basename(file_to_check)
 | 
			
		||||
                self.launch_dialog = GameLaunchDialog(self.main_window, game_name=game_name, theme=self.theme, target_exe=target_exe)
 | 
			
		||||
 | 
			
		||||
            self.launch_dialog.rejected.connect(lambda: self.cancel_game_launch(exec_line))
 | 
			
		||||
            self.launch_dialog.show()
 | 
			
		||||
 | 
			
		||||
            self.main_window.toggleGame(exec_line)
 | 
			
		||||
        except Exception as e:
 | 
			
		||||
            logger.error(f"Failed to launch game {game_name}: {e}")
 | 
			
		||||
            if self.launch_dialog:
 | 
			
		||||
                self.launch_dialog.reject()
 | 
			
		||||
                self.launch_dialog = None
 | 
			
		||||
            QMessageBox.warning(self.main_window, _("Error"), _("Failed to launch game: {0}").format(str(e)))
 | 
			
		||||
 | 
			
		||||
    def cancel_game_launch(self, exec_line):
 | 
			
		||||
        """Cancel the game launch and terminate the process, using MainWindow's stop logic."""
 | 
			
		||||
        if self.main_window.game_processes and self.main_window.target_exe:
 | 
			
		||||
            for proc in self.main_window.game_processes:
 | 
			
		||||
                try:
 | 
			
		||||
                    parent = psutil.Process(proc.pid)
 | 
			
		||||
                    children = parent.children(recursive=True)
 | 
			
		||||
                    for child in children:
 | 
			
		||||
                        try:
 | 
			
		||||
                            child.terminate()
 | 
			
		||||
                        except psutil.NoSuchProcess:
 | 
			
		||||
                            pass
 | 
			
		||||
                    psutil.wait_procs(children, timeout=5)
 | 
			
		||||
                    for child in children:
 | 
			
		||||
                        if child.is_running():
 | 
			
		||||
                            child.kill()
 | 
			
		||||
                    os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
 | 
			
		||||
                except psutil.NoSuchProcess:
 | 
			
		||||
                    pass
 | 
			
		||||
            self.main_window.game_processes = []
 | 
			
		||||
            self.main_window.resetPlayButton()
 | 
			
		||||
            if self.launch_dialog:
 | 
			
		||||
                self.launch_dialog.reject()
 | 
			
		||||
                self.launch_dialog = None
 | 
			
		||||
            logger.info(f"Game launch cancelled for exec line: {exec_line}")
 | 
			
		||||
 | 
			
		||||
    def populate_themes_menu(self):
 | 
			
		||||
        self.themes_menu.clear()
 | 
			
		||||
        available_themes = self.theme_manager.get_available_themes()
 | 
			
		||||
 | 
			
		||||
        for theme_name in sorted(available_themes):
 | 
			
		||||
            action = QAction(theme_name, self.main_window)
 | 
			
		||||
            action.setCheckable(True)
 | 
			
		||||
            action.setChecked(theme_name == self.current_theme_name)
 | 
			
		||||
            action.triggered.connect(lambda checked=False, tn=theme_name: self.switch_theme(tn))
 | 
			
		||||
            self.themes_menu.addAction(action)
 | 
			
		||||
 | 
			
		||||
    def switch_theme(self, theme_name: str):
 | 
			
		||||
        try:
 | 
			
		||||
            save_theme_to_config(theme_name)
 | 
			
		||||
            logger.info(f"Saved theme {theme_name}, restarting application to apply changes")
 | 
			
		||||
 | 
			
		||||
            executable = sys.executable
 | 
			
		||||
            args = sys.argv
 | 
			
		||||
 | 
			
		||||
            self.main_window.is_exiting = True
 | 
			
		||||
            QApplication.quit()
 | 
			
		||||
 | 
			
		||||
            subprocess.Popen([executable] + args)
 | 
			
		||||
 | 
			
		||||
        except Exception as e:
 | 
			
		||||
            logger.error(f"Failed to switch theme to {theme_name}: {e}")
 | 
			
		||||
            save_theme_to_config("standart")
 | 
			
		||||
            executable = sys.executable
 | 
			
		||||
            args = sys.argv
 | 
			
		||||
            self.main_window.is_exiting = True
 | 
			
		||||
            QApplication.quit()
 | 
			
		||||
            subprocess.Popen([executable] + args)
 | 
			
		||||
 | 
			
		||||
    def force_exit(self):
 | 
			
		||||
        self.main_window.is_exiting = True
 | 
			
		||||
        self.main_window.close()
 | 
			
		||||
        sys.exit(0)
 | 
			
		||||
@@ -1,10 +1,10 @@
 | 
			
		||||
[build-system]
 | 
			
		||||
requires = ["setuptools >= 77.0.3"]
 | 
			
		||||
requires = ["setuptools>=61.0"]
 | 
			
		||||
build-backend = "setuptools.build_meta"
 | 
			
		||||
 | 
			
		||||
[project]
 | 
			
		||||
name = "portprotonqt"
 | 
			
		||||
version = "0.1.6"
 | 
			
		||||
version = "0.1.4"
 | 
			
		||||
description = "A project to rewrite PortProton (PortWINE) using PySide"
 | 
			
		||||
readme = "README.md"
 | 
			
		||||
license = { text = "GPL-3.0" }
 | 
			
		||||
@@ -22,12 +22,12 @@ classifiers = [
 | 
			
		||||
  "Programming Language :: Python :: 3.11",
 | 
			
		||||
  "Programming Language :: Python :: 3.12",
 | 
			
		||||
  "Programming Language :: Python :: 3.13",
 | 
			
		||||
  "Operating System :: POSIX :: Linux"
 | 
			
		||||
  "Operating System :: Linux"
 | 
			
		||||
]
 | 
			
		||||
requires-python = ">=3.10"
 | 
			
		||||
dependencies = [
 | 
			
		||||
    "babel>=2.17.0",
 | 
			
		||||
    "beautifulsoup4>=4.13.5",
 | 
			
		||||
    "beautifulsoup4>=4.13.4",
 | 
			
		||||
    "evdev>=1.9.2",
 | 
			
		||||
    "icoextract>=0.2.0",
 | 
			
		||||
    "numpy>=2.2.4",
 | 
			
		||||
@@ -36,7 +36,7 @@ dependencies = [
 | 
			
		||||
    "psutil>=7.0.0",
 | 
			
		||||
    "pyside6>=6.9.1",
 | 
			
		||||
    "pyudev>=0.24.3",
 | 
			
		||||
    "requests>=2.32.5",
 | 
			
		||||
    "requests>=2.32.4",
 | 
			
		||||
    "tqdm>=4.67.1",
 | 
			
		||||
    "vdf>=3.4",
 | 
			
		||||
    "websocket-client>=1.8.0",
 | 
			
		||||
@@ -105,5 +105,5 @@ ignore = [
 | 
			
		||||
dev = [
 | 
			
		||||
    "pre-commit>=4.3.0",
 | 
			
		||||
    "pyaspeller>=2.0.2",
 | 
			
		||||
    "pyright>=1.1.404",
 | 
			
		||||
    "pyright>=1.1.403",
 | 
			
		||||
]
 | 
			
		||||
 
 | 
			
		||||
@@ -5,25 +5,21 @@
 | 
			
		||||
  "lockFileMaintenance": {
 | 
			
		||||
    "enabled": true
 | 
			
		||||
  },
 | 
			
		||||
  "pre-commit": {
 | 
			
		||||
    "enabled": true
 | 
			
		||||
  },
 | 
			
		||||
  "packageRules": [
 | 
			
		||||
    {
 | 
			
		||||
      "matchUpdateTypes": ["minor", "patch"],
 | 
			
		||||
      "automerge": true
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      "matchFileNames": [".gitea/workflows/build.yml"],
 | 
			
		||||
      "enabled": false,
 | 
			
		||||
      "description": "Disabled because download-artifact@v4 is not working"
 | 
			
		||||
      "matchDatasources": ["python-version"],
 | 
			
		||||
      "enabled": false
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      "matchFileNames": [".python-version"],
 | 
			
		||||
      "enabled": false,
 | 
			
		||||
      "enabled": false
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      "matchManagers": ["poetry", "pyenv"],
 | 
			
		||||
      "matchManagers": ["github-actions", "pre-commit"],
 | 
			
		||||
      "enabled": false
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
@@ -33,14 +29,9 @@
 | 
			
		||||
      "groupName": "Python dependencies"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      "matchPackageNames": ["numpy", "setuptools", "python"],
 | 
			
		||||
      "matchPackageNames": ["numpy", "setuptools"],
 | 
			
		||||
      "enabled": false,
 | 
			
		||||
      "description": "Disabled due to Python 3.10 incompatibility with numpy>=2.3.2 (requires Python>=3.11)"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      "matchDatasources": ["github-runners", "python-version"],
 | 
			
		||||
      "enabled": false,
 | 
			
		||||
      "description": "Prevent Renovate from updating runs-on to unsupported ubuntu-24.04"
 | 
			
		||||
    },
 | 
			
		||||
    }
 | 
			
		||||
  ]
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||