forked from Boria138/PortProtonQt
		
	Compare commits
	
		
			15 Commits
		
	
	
		
			c62cc6853f
			...
			ba9d8b76d8
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| ba9d8b76d8 | |||
| e99c71c1f8 | |||
| baec62d1cb | |||
| cb76961e4f | |||
|  | 081cd07253 | ||
| b5efee29ea | |||
| 69360f7e7e | |||
|  | 39712f0591 | ||
|  | 60b508af18 | ||
|  | b6637b4163 | ||
|  | 6d9eed42f8 | ||
| 7372e3b7f5 | |||
| e0d5bd7993 | |||
|  | 12f8067af1 | ||
|  | 716a813ca9 | 
| @@ -12,17 +12,27 @@ jobs: | ||||
|     name: Build AppImage | ||||
|     runs-on: ubuntu-22.04 | ||||
|     steps: | ||||
|       - uses: https://gitea.com/actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4 | ||||
|       - uses: https://gitea.com/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 | ||||
|  | ||||
|       - name: Install required dependencies | ||||
|         run: | | ||||
|             sudo apt update | ||||
|             sudo apt install -y binutils coreutils desktop-file-utils gtk-update-icon-cache fakeroot fuse libgdk-pixbuf2.0-dev patchelf python3-pip python3-dev python3-setuptools squashfs-tools strace util-linux zsync git zstd | ||||
|             sudo apt install -y binutils coreutils desktop-file-utils gtk-update-icon-cache fakeroot fuse libgdk-pixbuf2.0-dev patchelf python3-pip python3-dev python3-setuptools python3-build python3-venv squashfs-tools strace util-linux zsync git zstd adwaita-icon-theme | ||||
|  | ||||
|       - name: Install tools | ||||
|       - name: Upgrade pip toolchain | ||||
|         run: | | ||||
|             pip3 install git+https://github.com/Boria138/appimage-builder.git | ||||
|             pip3 install uv | ||||
|           python3 -m pip install --upgrade \ | ||||
|             pip setuptools setuptools-scm wheel packaging build | ||||
|  | ||||
|       - name: Install appimage-builder | ||||
|         run: | | ||||
|           git clone https://github.com/Boria138/appimage-builder | ||||
|           cd appimage-builder | ||||
|           pip install . | ||||
|  | ||||
|       - name: Install uv | ||||
|         run: | | ||||
|           pip install uv | ||||
|  | ||||
|       - name: Build AppImage | ||||
|         run: | | ||||
| @@ -63,7 +73,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@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 | ||||
|  | ||||
|       - name: Copy fedora.spec | ||||
|         run: | | ||||
| @@ -124,7 +134,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@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 | ||||
|  | ||||
|       - name: Upload Arch package | ||||
|         uses: https://gitea.com/actions/gitea-upload-artifact@v4 | ||||
|   | ||||
| @@ -23,12 +23,22 @@ jobs: | ||||
|       - name: Install required dependencies | ||||
|         run: | | ||||
|             sudo apt update | ||||
|             sudo apt install -y binutils coreutils desktop-file-utils gtk-update-icon-cache fakeroot fuse libgdk-pixbuf2.0-dev patchelf python3-pip python3-dev python3-setuptools squashfs-tools strace util-linux zsync git zstd | ||||
|             sudo apt install -y binutils coreutils desktop-file-utils gtk-update-icon-cache fakeroot fuse libgdk-pixbuf2.0-dev patchelf python3-pip python3-dev python3-setuptools python3-build python3-venv squashfs-tools strace util-linux zsync git zstd adwaita-icon-theme | ||||
|  | ||||
|       - name: Install tools | ||||
|       - name: Upgrade pip toolchain | ||||
|         run: | | ||||
|             pip3 install git+https://github.com/Boria138/appimage-builder.git | ||||
|             pip3 install uv | ||||
|           python3 -m pip install --upgrade \ | ||||
|             pip setuptools setuptools-scm wheel packaging build | ||||
|  | ||||
|       - name: Install appimage-builder | ||||
|         run: | | ||||
|           git clone https://github.com/Boria138/appimage-builder | ||||
|           cd appimage-builder | ||||
|           pip install . | ||||
|  | ||||
|       - name: Install uv | ||||
|         run: | | ||||
|           pip install uv | ||||
|  | ||||
|       - name: Build AppImage | ||||
|         run: | | ||||
|   | ||||
| @@ -16,7 +16,7 @@ jobs: | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|       - name: Checkout | ||||
|         uses: https://gitea.com/actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4 | ||||
|         uses: https://gitea.com/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 | ||||
|  | ||||
|       - name: Set up Python | ||||
|         uses: https://gitea.com/actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 | ||||
|   | ||||
| @@ -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@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 | ||||
|         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@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 | ||||
|  | ||||
|       - 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@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 | ||||
|  | ||||
|       - name: Copy fedora-git.spec | ||||
|         run: | | ||||
| @@ -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@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 | ||||
|  | ||||
|       - name: Upload Arch package | ||||
|         uses: https://gitea.com/actions/gitea-upload-artifact@v4 | ||||
|   | ||||
| @@ -20,10 +20,10 @@ jobs: | ||||
|     name: Check code | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|       - uses: https://gitea.com/actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4 | ||||
|       - uses: https://gitea.com/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 | ||||
|  | ||||
|       - name: Set up Node.js | ||||
|         uses: https://gitea.com/actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 | ||||
|         uses: https://gitea.com/actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5 | ||||
|         with: | ||||
|           node-version: 20 | ||||
|  | ||||
|   | ||||
| @@ -11,7 +11,7 @@ jobs: | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|       - name: Checkout | ||||
|         uses: https://gitea.com/actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4 | ||||
|         uses: https://gitea.com/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 | ||||
|  | ||||
|       - name: Set up Python | ||||
|         uses: https://gitea.com/actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 | ||||
|   | ||||
| @@ -8,12 +8,12 @@ on: | ||||
| jobs: | ||||
|   renovate: | ||||
|     runs-on: ubuntu-latest | ||||
|     container: ghcr.io/renovatebot/renovate:latest@sha256:06348c526bc114c11edd9ae6412ba6993a3a3bbcc801afa6061142298b1be4fa | ||||
|     container: ghcr.io/renovatebot/renovate:latest@sha256:dd5721b9a686a40d81687643e4b71b82a0ca31fb653fd727538af69104fd388d | ||||
|     steps: | ||||
|       - uses: https://gitea.com/actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4 | ||||
|       - uses: https://gitea.com/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 | ||||
|  | ||||
|       - name: Set up Node.js | ||||
|         uses: https://gitea.com/actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 | ||||
|         uses: https://gitea.com/actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5 | ||||
|         with: | ||||
|           node-version: 20 | ||||
|  | ||||
|   | ||||
| @@ -11,12 +11,12 @@ repos: | ||||
|       - id: check-yaml | ||||
|  | ||||
|   - repo: https://github.com/astral-sh/uv-pre-commit | ||||
|     rev: 0.8.20 | ||||
|     rev: 0.8.22 | ||||
|     hooks: | ||||
|       - id: uv-lock | ||||
|  | ||||
|   - repo: https://github.com/astral-sh/ruff-pre-commit | ||||
|     rev: v0.12.8 | ||||
|     rev: v0.13.2 | ||||
|     hooks: | ||||
|       - id: ruff-check | ||||
|  | ||||
|   | ||||
| @@ -6,11 +6,15 @@ | ||||
| ## [Unreleased] | ||||
|  | ||||
| ### Added | ||||
| - Возможность скроллинга библиотеки мышью или пальцем | ||||
|  | ||||
| ### Changed | ||||
| - Проведён рефакторинг и оптимизация всего что связано с карточками и библиотекой игр | ||||
|  | ||||
| ### Fixed | ||||
| - Исправлен вылет диалога выбора файлов при выборе обложки если в папке более сотни изображений | ||||
| - Исправлено зависание при добавлении или удалении игры в Wayland | ||||
| - Исправлено зависание при поиске игр | ||||
|  | ||||
| ### Contributors | ||||
|  | ||||
| @@ -29,12 +33,12 @@ | ||||
| ### Changed | ||||
| - Управления с геймпада теперь перехватывается только если окно в фокусе | ||||
|  | ||||
|  | ||||
| ### Fixed | ||||
| - Исправлена проблема с устаревшими кэш-файлами, вызывающими несоответствия при обновлении JSON | ||||
| - Исправлено переключение в полноэкранный режим при нажатии кнопки "Select во время запущенной игры | ||||
|  | ||||
| ### Contributors | ||||
| - @wmigor (Igor Akulov) | ||||
|  | ||||
| --- | ||||
|  | ||||
|   | ||||
| @@ -1,16 +1,11 @@ | ||||
| version: 1 | ||||
| script: | ||||
|   # 1) чистим старый AppDir | ||||
|   - rm -rf AppDir || true | ||||
|   # 2) создаём структуру каталога | ||||
|   - mkdir -p AppDir/usr/local/lib/python3.10/dist-packages | ||||
|   # 3) UV: создаём виртуальное окружение и устанавливаем зависимости из pyproject.toml | ||||
|   - uv venv | ||||
|   - uv pip install --no-cache-dir ../ | ||||
|   # 4) копируем всё из .venv в AppDir | ||||
|   - cp -r .venv/lib/python3.10/site-packages/* AppDir/usr/local/lib/python3.10/dist-packages | ||||
|   - cp -r share AppDir/usr | ||||
|   # 5) чистим от ненужных модулей и бинарников | ||||
|   - rm -rf AppDir/usr/local/lib/python3.10/dist-packages/PySide6/Qt/qml/ | ||||
|   - rm -f AppDir/usr/local/lib/python3.10/dist-packages/PySide6/{assistant,designer,linguist,lrelease,lupdate} | ||||
|   - rm -f AppDir/usr/local/lib/python3.10/dist-packages/PySide6/{Qt3DAnimation*,Qt3DCore*,Qt3DExtras*,Qt3DInput*,Qt3DLogic*,Qt3DRender*,QtBluetooth*,QtCharts*,QtConcurrent*,QtDataVisualization*,QtDesigner*,QtExampleIcons*,QtGraphs*,QtGraphsWidgets*,QtHelp*,QtHttpServer*,QtLocation*,QtMultimedia*,QtMultimediaWidgets*,QtNetwork*,QtNetworkAuth*,QtNfc*,QtOpenGL*,QtOpenGLWidgets*,QtPdf*,QtPdfWidgets*,QtPositioning*,QtPrintSupport*,QtQml*,QtQuick*,QtQuick3D*,QtQuickControls2*,QtQuickTest*,QtQuickWidgets*,QtRemoteObjects*,QtScxml*,QtSensors*,QtSerialBus*,QtSerialPort*,QtSpatialAudio*,QtSql*,QtStateMachine*,QtSvgWidgets*,QtTest*,QtTextToSpeech*,QtUiTools*,QtWebChannel*,QtWebEngineCore*,QtWebEngineQuick*,QtWebEngineWidgets*,QtWebSockets*,QtWebView*,QtXml*} | ||||
| @@ -19,7 +14,6 @@ script: | ||||
| AppDir: | ||||
|   path: ./AppDir | ||||
|   after_bundle: | ||||
|     # Документация, справка, примеры | ||||
|     - rm -rf $TARGET_APPDIR/usr/share/man || true | ||||
|     - rm -rf $TARGET_APPDIR/usr/share/doc || true | ||||
|     - rm -rf $TARGET_APPDIR/usr/share/doc-base || true | ||||
| @@ -35,11 +29,8 @@ AppDir: | ||||
|     - rm -rf $TARGET_APPDIR/usr/share/metainfo || true | ||||
|     - rm -rf $TARGET_APPDIR/usr/include || true | ||||
|     - rm -rf $TARGET_APPDIR/usr/lib/pkgconfig || true | ||||
|     # Статика и отладка | ||||
|     - find $TARGET_APPDIR -type f \( -name '*.a' -o -name '*.la' -o -name '*.h' -o -name '*.cmake' -o -name '*.pdb' \) -delete || true | ||||
|     # Strip ELF бинарников (исключая Python extensions) | ||||
|     - "find $TARGET_APPDIR -type f -executable -exec file {} \\; | grep ELF | grep -v '/dist-packages/' | grep -v '/site-packages/' | cut -d: -f1 | xargs strip --strip-unneeded || true" | ||||
|     # Удаление пустых папок | ||||
|     - find $TARGET_APPDIR -type d -empty -delete || true | ||||
|   app_info: | ||||
|     id: ru.linux_gaming.PortProtonQt | ||||
| @@ -64,15 +55,12 @@ AppDir: | ||||
|       - libimage-exiftool-perl | ||||
|       - xdg-utils | ||||
|     exclude: | ||||
|       # Документация и man-страницы | ||||
|       - "*-doc" | ||||
|       - "*-man" | ||||
|       - manpages | ||||
|       - mandb | ||||
|       # Статические библиотеки | ||||
|       - "*-dev" | ||||
|       - "*-static" | ||||
|       # Дебаг-символы | ||||
|       - "*-dbg" | ||||
|       - "*-dbgsym" | ||||
|   runtime: | ||||
| @@ -83,3 +71,4 @@ AppDir: | ||||
| AppImage: | ||||
|   sign-key: None | ||||
|   arch: x86_64 | ||||
|   comp: zstd | ||||
|   | ||||
| @@ -217,7 +217,7 @@ | ||||
|   }, | ||||
|   { | ||||
|     "normalized_name": "watch_dogs 2", | ||||
|     "status": "Broken" | ||||
|     "status": "Running" | ||||
|   }, | ||||
|   { | ||||
|     "normalized_name": "zero hour", | ||||
|   | ||||
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										12688
									
								
								data/games_appid.json
									
									
									
									
									
								
							
							
						
						
									
										12688
									
								
								data/games_appid.json
									
									
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
										
											Binary file not shown.
										
									
								
							| @@ -1,4 +1,96 @@ | ||||
| [ | ||||
|   { | ||||
|     "normalized_title": "dirt rally 2.0 game of the year", | ||||
|     "slug": "dirt-rally-2-0-game-of-the-year-edition" | ||||
|   }, | ||||
|   { | ||||
|     "normalized_title": "deus ex human revolution director’s cut", | ||||
|     "slug": "deus-ex-human-revolution-director-s-cut" | ||||
|   }, | ||||
|   { | ||||
|     "normalized_title": "freelancer", | ||||
|     "slug": "freelancer" | ||||
|   }, | ||||
|   { | ||||
|     "normalized_title": "everspace", | ||||
|     "slug": "everspace" | ||||
|   }, | ||||
|   { | ||||
|     "normalized_title": "blades of time limited", | ||||
|     "slug": "blades-of-time-limited-edition" | ||||
|   }, | ||||
|   { | ||||
|     "normalized_title": "chorus", | ||||
|     "slug": "chorus" | ||||
|   }, | ||||
|   { | ||||
|     "normalized_title": "tom clancy's splinter cell pandora tomorrow", | ||||
|     "slug": "tom-clancys-splinter-cell-pandora-tomorrow" | ||||
|   }, | ||||
|   { | ||||
|     "normalized_title": "the alters", | ||||
|     "slug": "the-alters" | ||||
|   }, | ||||
|   { | ||||
|     "normalized_title": "hard reset redux", | ||||
|     "slug": "hard-reset-redux" | ||||
|   }, | ||||
|   { | ||||
|     "normalized_title": "far cry 5", | ||||
|     "slug": "far-cry-5" | ||||
|   }, | ||||
|   { | ||||
|     "normalized_title": "metal eden", | ||||
|     "slug": "metal-eden" | ||||
|   }, | ||||
|   { | ||||
|     "normalized_title": "indiana jones and the great circle", | ||||
|     "slug": "indiana-jones-and-the-great-circle" | ||||
|   }, | ||||
|   { | ||||
|     "normalized_title": "old world", | ||||
|     "slug": "old-world" | ||||
|   }, | ||||
|   { | ||||
|     "normalized_title": "witchfire", | ||||
|     "slug": "witchfire" | ||||
|   }, | ||||
|   { | ||||
|     "normalized_title": "prototype", | ||||
|     "slug": "prototype" | ||||
|   }, | ||||
|   { | ||||
|     "normalized_title": "mandragora whispers of the witch tree", | ||||
|     "slug": "mandragora-whispers-of-the-witch-tree" | ||||
|   }, | ||||
|   { | ||||
|     "normalized_title": "grand theft auto v (gta 5)", | ||||
|     "slug": "grand-theft-auto-v-gta-5" | ||||
|   }, | ||||
|   { | ||||
|     "normalized_title": "lifeless planet premier", | ||||
|     "slug": "lifeless-planet-premier-edition" | ||||
|   }, | ||||
|   { | ||||
|     "normalized_title": "warcraft iii the frozen throne", | ||||
|     "slug": "warcraft-iii-the-frozen-throne" | ||||
|   }, | ||||
|   { | ||||
|     "normalized_title": "star wars republic commando", | ||||
|     "slug": "star-wars-republic-commando" | ||||
|   }, | ||||
|   { | ||||
|     "normalized_title": "hollow knight silksong", | ||||
|     "slug": "hollow-knight-silksong" | ||||
|   }, | ||||
|   { | ||||
|     "normalized_title": "arma reforger", | ||||
|     "slug": "arma-reforger" | ||||
|   }, | ||||
|   { | ||||
|     "normalized_title": "arma 3", | ||||
|     "slug": "arma-3" | ||||
|   }, | ||||
|   { | ||||
|     "normalized_title": "astroneer", | ||||
|     "slug": "astroneer" | ||||
| @@ -195,10 +287,6 @@ | ||||
|     "normalized_title": "slitterhead", | ||||
|     "slug": "slitterhead" | ||||
|   }, | ||||
|   { | ||||
|     "normalized_title": "indiana jones and the great circle", | ||||
|     "slug": "indiana-jones-and-the-great-circle" | ||||
|   }, | ||||
|   { | ||||
|     "normalized_title": "crossout", | ||||
|     "slug": "crossout" | ||||
|   | ||||
										
											Binary file not shown.
										
									
								
							| @@ -29,7 +29,7 @@ class ContextMenuSignals(QObject): | ||||
| class ContextMenuManager: | ||||
|     """Manages context menu actions for game management in PortProtonQt.""" | ||||
|  | ||||
|     def __init__(self, parent, portproton_location, theme, load_games_callback, update_game_grid_callback): | ||||
|     def __init__(self, parent, portproton_location, theme, load_games_callback, game_library_manager): | ||||
|         """ | ||||
|         Initialize the ContextMenuManager. | ||||
|  | ||||
| @@ -45,7 +45,8 @@ class ContextMenuManager: | ||||
|         self.theme = theme | ||||
|         self.theme_manager = ThemeManager() | ||||
|         self.load_games = load_games_callback | ||||
|         self.update_game_grid = update_game_grid_callback | ||||
|         self.game_library_manager = game_library_manager | ||||
|         self.update_game_grid = game_library_manager.update_game_grid | ||||
|         self.legendary_path = os.path.join( | ||||
|             os.getenv("XDG_CACHE_HOME", os.path.join(os.path.expanduser("~"), ".cache")), | ||||
|             "PortProtonQt", "legendary_cache", "legendary" | ||||
| @@ -859,9 +860,16 @@ 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() | ||||
|         self.update_game_grid = self.game_library_manager.remove_game_incremental | ||||
|         self.game_library_manager.remove_game_incremental(game_name, exec_line) | ||||
|  | ||||
|     def add_game_incremental(self, game_data: tuple): | ||||
|         """Add game after .desktop creation.""" | ||||
|         if not self._check_portproton(): | ||||
|             return | ||||
|         # Assume game_data is built from new .desktop (name, desc, cover, etc.) | ||||
|         self.game_library_manager.add_game_incremental(game_data) | ||||
|         self._show_status_message(_("Added '{game_name}' successfully").format(game_name=game_data[0])) | ||||
|  | ||||
|     def add_to_menu(self, game_name, exec_line): | ||||
|         """Copy the .desktop file to ~/.local/share/applications.""" | ||||
|   | ||||
| @@ -4,7 +4,7 @@ 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, QProgressBar, QScroller | ||||
| ) | ||||
| from PySide6.QtCore import Qt, QObject, Signal, QMimeDatabase, QTimer, QThreadPool, QRunnable, Slot | ||||
| from icoextract import IconExtractor, IconExtractorError | ||||
| @@ -374,6 +374,9 @@ class FileExplorer(QDialog): | ||||
|         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.file_list.setHorizontalScrollMode(QListWidget.ScrollMode.ScrollPerPixel) | ||||
|         self.file_list.setVerticalScrollMode(QListWidget.ScrollMode.ScrollPerPixel) | ||||
|         QScroller.grabGesture(self.file_list.viewport(), QScroller.ScrollerGestureType.LeftMouseButtonGesture) | ||||
|         self.main_layout.addWidget(self.file_list) | ||||
|  | ||||
|         # Connect scroll signal for lazy loading | ||||
|   | ||||
							
								
								
									
										453
									
								
								portprotonqt/game_library_manager.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										453
									
								
								portprotonqt/game_library_manager.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,453 @@ | ||||
| from typing import Protocol | ||||
| from portprotonqt.game_card import GameCard | ||||
| from PySide6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QScrollArea, QSlider, QScroller | ||||
| from PySide6.QtCore import Qt, QTimer | ||||
| from portprotonqt.custom_widgets import FlowLayout | ||||
| from portprotonqt.config_utils import read_favorites, read_sort_method, read_card_size, save_card_size | ||||
| from portprotonqt.image_utils import load_pixmap_async | ||||
| from portprotonqt.context_menu_manager import ContextMenuManager, CustomLineEdit | ||||
| from collections import deque | ||||
|  | ||||
| class MainWindowProtocol(Protocol): | ||||
|     """Protocol defining the interface that MainWindow must implement for GameLibraryManager.""" | ||||
|  | ||||
|     def openGameDetailPage( | ||||
|         self, | ||||
|         name: str, | ||||
|         description: str, | ||||
|         cover_path: str | None = None, | ||||
|         appid: str = "", | ||||
|         exec_line: str = "", | ||||
|         controller_support: str = "", | ||||
|         last_launch: str = "", | ||||
|         formatted_playtime: str = "", | ||||
|         protondb_tier: str = "", | ||||
|         game_source: str = "", | ||||
|         anticheat_status: str = "", | ||||
|     ) -> None: ... | ||||
|  | ||||
|     def createSearchWidget(self) -> tuple[QWidget, CustomLineEdit]: ... | ||||
|  | ||||
|     def on_slider_released(self) -> None: ... | ||||
|  | ||||
|     # Required attributes | ||||
|     searchEdit: CustomLineEdit | ||||
|     _last_card_width: int | ||||
|     current_hovered_card: GameCard | None | ||||
|     current_focused_card: GameCard | None | ||||
|  | ||||
| class GameLibraryManager: | ||||
|     def __init__(self, main_window: MainWindowProtocol, theme, context_menu_manager: ContextMenuManager | None): | ||||
|         self.main_window = main_window | ||||
|         self.theme = theme | ||||
|         self.context_menu_manager: ContextMenuManager | None = context_menu_manager | ||||
|         self.games: list[tuple] = [] | ||||
|         self.filtered_games: list[tuple] = [] | ||||
|         self.game_card_cache = {} | ||||
|         self.pending_images = {} | ||||
|         self.card_width = read_card_size() | ||||
|         self.gamesListWidget: QWidget | None = None | ||||
|         self.gamesListLayout: FlowLayout | None = None | ||||
|         self.sizeSlider: QSlider | None = None | ||||
|         self._update_timer: QTimer | None = None | ||||
|         self._pending_update = False | ||||
|         self.pending_deletions = deque() | ||||
|         self.is_filtering = False | ||||
|         self.dirty = False | ||||
|  | ||||
|     def create_games_library_widget(self): | ||||
|         """Creates the games library widget with search, grid, and slider.""" | ||||
|         self.gamesLibraryWidget = QWidget() | ||||
|         self.gamesLibraryWidget.setStyleSheet(self.theme.LIBRARY_WIDGET_STYLE) | ||||
|         layout = QVBoxLayout(self.gamesLibraryWidget) | ||||
|         layout.setSpacing(15) | ||||
|  | ||||
|         # Search widget | ||||
|         searchWidget, self.searchEdit = self.main_window.createSearchWidget() | ||||
|         layout.addWidget(searchWidget) | ||||
|  | ||||
|         # Scroll area for game grid | ||||
|         scrollArea = QScrollArea() | ||||
|         scrollArea.setWidgetResizable(True) | ||||
|         scrollArea.setStyleSheet(self.theme.SCROLL_AREA_STYLE) | ||||
|         QScroller.grabGesture(scrollArea.viewport(), QScroller.ScrollerGestureType.LeftMouseButtonGesture) | ||||
|  | ||||
|         self.gamesListWidget = QWidget() | ||||
|         self.gamesListWidget.setStyleSheet(self.theme.LIST_WIDGET_STYLE) | ||||
|         self.gamesListLayout = FlowLayout(self.gamesListWidget) | ||||
|         self.gamesListWidget.setLayout(self.gamesListLayout) | ||||
|  | ||||
|         scrollArea.setWidget(self.gamesListWidget) | ||||
|         layout.addWidget(scrollArea) | ||||
|  | ||||
|         # Slider for card size | ||||
|         sliderLayout = QHBoxLayout() | ||||
|         sliderLayout.addStretch() | ||||
|  | ||||
|         self.sizeSlider = QSlider(Qt.Orientation.Horizontal) | ||||
|         self.sizeSlider.setMinimum(200) | ||||
|         self.sizeSlider.setMaximum(250) | ||||
|         self.sizeSlider.setValue(self.card_width) | ||||
|         self.sizeSlider.setTickInterval(10) | ||||
|         self.sizeSlider.setFixedWidth(150) | ||||
|         self.sizeSlider.setToolTip(f"{self.card_width} px") | ||||
|         self.sizeSlider.setStyleSheet(self.theme.SLIDER_SIZE_STYLE) | ||||
|         self.sizeSlider.sliderReleased.connect(self.main_window.on_slider_released) | ||||
|         sliderLayout.addWidget(self.sizeSlider) | ||||
|  | ||||
|         layout.addLayout(sliderLayout) | ||||
|  | ||||
|         # Initialize update timer | ||||
|         self._update_timer = QTimer() | ||||
|         self._update_timer.setSingleShot(True) | ||||
|         self._update_timer.setInterval(100)  # 100ms debounce | ||||
|         self._update_timer.timeout.connect(self._perform_update) | ||||
|  | ||||
|         # Calculate initial card width | ||||
|         def calculate_card_width(): | ||||
|             if self.gamesListLayout is None: | ||||
|                 return | ||||
|             available_width = scrollArea.width() - 20 | ||||
|             spacing = self.gamesListLayout._spacing | ||||
|             target_cards_per_row = 8 | ||||
|             calculated_width = (available_width - spacing * (target_cards_per_row - 1)) // target_cards_per_row | ||||
|             calculated_width = max(200, min(calculated_width, 250)) | ||||
|  | ||||
|         QTimer.singleShot(0, calculate_card_width) | ||||
|  | ||||
|         # Connect scroll event for lazy loading | ||||
|         scrollArea.verticalScrollBar().valueChanged.connect(self.load_visible_images) | ||||
|  | ||||
|         return self.gamesLibraryWidget | ||||
|  | ||||
|     def on_slider_released(self): | ||||
|         """Handles slider release to update card size.""" | ||||
|         if self.sizeSlider is None: | ||||
|             return | ||||
|         self.card_width = self.sizeSlider.value() | ||||
|         self.sizeSlider.setToolTip(f"{self.card_width} px") | ||||
|         save_card_size(self.card_width) | ||||
|         for card in self.game_card_cache.values(): | ||||
|             card.update_card_size(self.card_width) | ||||
|         self.update_game_grid() | ||||
|  | ||||
|     def load_visible_images(self): | ||||
|         """Loads images for visible game cards.""" | ||||
|         if self.gamesListWidget is None: | ||||
|             return | ||||
|         visible_region = self.gamesListWidget.visibleRegion() | ||||
|         max_concurrent_loads = 5 | ||||
|         loaded_count = 0 | ||||
|         for card_key, card in self.game_card_cache.items(): | ||||
|             if card_key in self.pending_images and visible_region.contains(card.pos()) and loaded_count < max_concurrent_loads: | ||||
|                 cover_path, width, height, callback = self.pending_images.pop(card_key) | ||||
|                 load_pixmap_async(cover_path, width, height, callback) | ||||
|                 loaded_count += 1 | ||||
|  | ||||
|     def _on_card_focused(self, game_name: str, is_focused: bool): | ||||
|         """Handles card focus events.""" | ||||
|         card_key = None | ||||
|         for key, card in self.game_card_cache.items(): | ||||
|             if card.name == game_name: | ||||
|                 card_key = key | ||||
|                 break | ||||
|  | ||||
|         if not card_key: | ||||
|             return | ||||
|  | ||||
|         card = self.game_card_cache[card_key] | ||||
|  | ||||
|         if is_focused: | ||||
|             if self.main_window.current_hovered_card and self.main_window.current_hovered_card != card: | ||||
|                 self.main_window.current_hovered_card._hovered = False | ||||
|                 self.main_window.current_hovered_card.leaveEvent(None) | ||||
|                 self.main_window.current_hovered_card = None | ||||
|             if self.main_window.current_focused_card and self.main_window.current_focused_card != card: | ||||
|                 self.main_window.current_focused_card._focused = False | ||||
|                 self.main_window.current_focused_card.clearFocus() | ||||
|             self.main_window.current_focused_card = card | ||||
|         else: | ||||
|             if self.main_window.current_focused_card == card: | ||||
|                 self.main_window.current_focused_card = None | ||||
|  | ||||
|     def _on_card_hovered(self, game_name: str, is_hovered: bool): | ||||
|         """Handles card hover events.""" | ||||
|         card_key = None | ||||
|         for key, card in self.game_card_cache.items(): | ||||
|             if card.name == game_name: | ||||
|                 card_key = key | ||||
|                 break | ||||
|  | ||||
|         if not card_key: | ||||
|             return | ||||
|  | ||||
|         card = self.game_card_cache[card_key] | ||||
|  | ||||
|         if is_hovered: | ||||
|             if self.main_window.current_focused_card and self.main_window.current_focused_card != card: | ||||
|                 self.main_window.current_focused_card._focused = False | ||||
|                 self.main_window.current_focused_card.clearFocus() | ||||
|             if self.main_window.current_hovered_card and self.main_window.current_hovered_card != card: | ||||
|                 self.main_window.current_hovered_card._hovered = False | ||||
|                 self.main_window.current_hovered_card.leaveEvent(None) | ||||
|             self.main_window.current_hovered_card = card | ||||
|         else: | ||||
|             if self.main_window.current_hovered_card == card: | ||||
|                 self.main_window.current_hovered_card = None | ||||
|  | ||||
|     def _perform_update(self): | ||||
|         """Performs the actual grid update.""" | ||||
|         if not self._pending_update: | ||||
|             return | ||||
|         self._pending_update = False | ||||
|         self._update_game_grid_immediate() | ||||
|  | ||||
|     def update_game_grid(self, games_list: list[tuple] | None = None, is_filter: bool = False): | ||||
|         """Schedules a game grid update with debouncing.""" | ||||
|         if not is_filter: | ||||
|             if games_list is not None: | ||||
|                 self.filtered_games = games_list | ||||
|             self.dirty = True  # Full rebuild only for non-filter | ||||
|         self.is_filtering = is_filter | ||||
|         self._pending_update = True | ||||
|  | ||||
|         if self._update_timer is not None: | ||||
|             self._update_timer.start() | ||||
|         else: | ||||
|             self._update_game_grid_immediate() | ||||
|  | ||||
|     def _update_game_grid_immediate(self): | ||||
|         """Updates the game grid with the provided or current game list.""" | ||||
|         if self.gamesListLayout is None or self.gamesListWidget is None: | ||||
|             return | ||||
|  | ||||
|         search_text = self.main_window.searchEdit.text().strip().lower() | ||||
|  | ||||
|         if self.is_filtering: | ||||
|             # Filter mode: do not change layout, only hide/show cards | ||||
|             self._apply_filter_visibility(search_text) | ||||
|         else: | ||||
|             # Full update: sorting, removal/addition, reorganization | ||||
|             games_list = self.filtered_games if self.filtered_games else self.games | ||||
|             favorites = read_favorites() | ||||
|             sort_method = read_sort_method() | ||||
|  | ||||
|             # Batch layout updates (extended scope) | ||||
|             self.gamesListWidget.setUpdatesEnabled(False) | ||||
|             if self.gamesListLayout is not None: | ||||
|                 self.gamesListLayout.setEnabled(False)  # Disable layout during batch | ||||
|  | ||||
|             try: | ||||
|                 # Optimized sorting: Partition favorites first, then sort subgroups | ||||
|                 def partition_sort_key(game): | ||||
|                     name = game[0] | ||||
|                     is_fav = name in favorites | ||||
|                     fav_order = 0 if is_fav else 1 | ||||
|                     if sort_method == "playtime": | ||||
|                         return (fav_order, -game[11] if game[11] else 0, -game[10] if game[10] else 0) | ||||
|                     elif sort_method == "alphabetical": | ||||
|                         return (fav_order, name.lower()) | ||||
|                     elif sort_method == "favorites": | ||||
|                         return (fav_order,) | ||||
|                     else: | ||||
|                         return (fav_order, -game[10] if game[10] else 0, -game[11] if game[11] else 0) | ||||
|  | ||||
|                 # Quick partition: Sort favorites and non-favorites separately, then merge | ||||
|                 fav_games = [g for g in games_list if g[0] in favorites] | ||||
|                 non_fav_games = [g for g in games_list if g[0] not in favorites] | ||||
|                 sorted_fav = sorted(fav_games, key=partition_sort_key) | ||||
|                 sorted_non_fav = sorted(non_fav_games, key=partition_sort_key) | ||||
|                 sorted_games = sorted_fav + sorted_non_fav | ||||
|  | ||||
|                 # Build set of current game keys for faster lookup | ||||
|                 current_game_keys = {(game[0], game[4]) for game in sorted_games} | ||||
|  | ||||
|                 # Remove cards that no longer exist (batch) | ||||
|                 cards_to_remove = [] | ||||
|                 for card_key in list(self.game_card_cache.keys()): | ||||
|                     if card_key not in current_game_keys: | ||||
|                         cards_to_remove.append(card_key) | ||||
|  | ||||
|                 for card_key in cards_to_remove: | ||||
|                     card = self.game_card_cache.pop(card_key) | ||||
|                     if self.gamesListLayout is not None: | ||||
|                         self.gamesListLayout.removeWidget(card) | ||||
|                     self.pending_deletions.append(card)  # Defer | ||||
|                     if card_key in self.pending_images: | ||||
|                         del self.pending_images[card_key] | ||||
|  | ||||
|                 # Track current layout order (only if dirty/full update needed) | ||||
|                 if self.dirty and self.gamesListLayout is not None: | ||||
|                     current_layout_order = [] | ||||
|                     for i in range(self.gamesListLayout.count()): | ||||
|                         item = self.gamesListLayout.itemAt(i) | ||||
|                         if item is not None: | ||||
|                             widget = item.widget() | ||||
|                             if widget: | ||||
|                                 for key, card in self.game_card_cache.items(): | ||||
|                                     if card == widget: | ||||
|                                         current_layout_order.append(key) | ||||
|                                         break | ||||
|                 else: | ||||
|                     current_layout_order = None  # Skip reorg if not dirty | ||||
|  | ||||
|                 new_card_order = [] | ||||
|                 cards_to_add = [] | ||||
|  | ||||
|                 for game_data in sorted_games: | ||||
|                     game_name = game_data[0] | ||||
|                     exec_line = game_data[4] | ||||
|                     game_key = (game_name, exec_line) | ||||
|                     should_be_visible = not search_text or search_text in game_name.lower() | ||||
|  | ||||
|                     if game_key in self.game_card_cache: | ||||
|                         card = self.game_card_cache[game_key] | ||||
|                         if card.isVisible() != should_be_visible: | ||||
|                             card.setVisible(should_be_visible) | ||||
|                         new_card_order.append(game_key) | ||||
|                     else: | ||||
|                         if self.context_menu_manager is None: | ||||
|                             continue | ||||
|  | ||||
|                         card = self._create_game_card(game_data) | ||||
|                         self.game_card_cache[game_key] = card | ||||
|                         card.setVisible(should_be_visible) | ||||
|                         new_card_order.append(game_key) | ||||
|                         cards_to_add.append((game_key, card)) | ||||
|  | ||||
|                 # Only reorganize if order changed AND dirty | ||||
|                 if self.dirty and self.gamesListLayout is not None and (current_layout_order is None or new_card_order != current_layout_order): | ||||
|                     # Remove all widgets from layout (batch) | ||||
|                     while self.gamesListLayout.count(): | ||||
|                         self.gamesListLayout.takeAt(0) | ||||
|  | ||||
|                     # Add widgets in new order (batch) | ||||
|                     for game_key in new_card_order: | ||||
|                         card = self.game_card_cache[game_key] | ||||
|                         self.gamesListLayout.addWidget(card) | ||||
|  | ||||
|                 self.dirty = False  # Reset flag | ||||
|  | ||||
|                 # Deferred deletions (run in timer to avoid stack overflow) | ||||
|                 if self.pending_deletions: | ||||
|                     QTimer.singleShot(0, lambda: self._flush_deletions()) | ||||
|  | ||||
|                 # Load visible images for new cards only | ||||
|                 if cards_to_add: | ||||
|                     self.load_visible_images() | ||||
|  | ||||
|             finally: | ||||
|                 if self.gamesListLayout is not None: | ||||
|                     self.gamesListLayout.setEnabled(True) | ||||
|                 self.gamesListWidget.setUpdatesEnabled(True) | ||||
|                 if self.gamesListLayout is not None: | ||||
|                     self.gamesListLayout.update() | ||||
|                 self.gamesListWidget.updateGeometry() | ||||
|                 self.main_window._last_card_width = self.card_width | ||||
|  | ||||
|         self.is_filtering = False  # Reset flag in any case | ||||
|  | ||||
|     def _apply_filter_visibility(self, search_text: str): | ||||
|         """Applies visibility to cards based on search, without changing the layout.""" | ||||
|         visible_count = 0 | ||||
|         for game_key, card in self.game_card_cache.items(): | ||||
|             game_name = card.name  # Assume GameCard has 'name' attribute | ||||
|             should_be_visible = not search_text or search_text in game_name.lower() | ||||
|             if card.isVisible() != should_be_visible: | ||||
|                 card.setVisible(should_be_visible) | ||||
|             if should_be_visible: | ||||
|                 visible_count += 1 | ||||
|                 # Load image only for newly visible cards | ||||
|                 if game_key in self.pending_images: | ||||
|                     cover_path, width, height, callback = self.pending_images.pop(game_key) | ||||
|                     load_pixmap_async(cover_path, width, height, callback) | ||||
|  | ||||
|         # Force geometry update so FlowLayout accounts for hidden widgets | ||||
|         if self.gamesListLayout is not None: | ||||
|             self.gamesListLayout.update() | ||||
|         if self.gamesListWidget is not None: | ||||
|             self.gamesListWidget.updateGeometry() | ||||
|         self.main_window._last_card_width = self.card_width | ||||
|  | ||||
|         # If search is empty, load images for visible ones | ||||
|         if not search_text: | ||||
|             self.load_visible_images() | ||||
|  | ||||
|     def _create_game_card(self, game_data: tuple) -> GameCard: | ||||
|         """Creates a new game card with all necessary connections.""" | ||||
|         card = GameCard( | ||||
|             *game_data, | ||||
|             select_callback=self.main_window.openGameDetailPage, | ||||
|             theme=self.theme, | ||||
|             card_width=self.card_width, | ||||
|             context_menu_manager=self.context_menu_manager | ||||
|         ) | ||||
|  | ||||
|         card.hoverChanged.connect(self._on_card_hovered) | ||||
|         card.focusChanged.connect(self._on_card_focused) | ||||
|  | ||||
|         if self.context_menu_manager: | ||||
|             card.editShortcutRequested.connect(self.context_menu_manager.edit_game_shortcut) | ||||
|             card.deleteGameRequested.connect(self.context_menu_manager.delete_game) | ||||
|             card.addToMenuRequested.connect(self.context_menu_manager.add_to_menu) | ||||
|             card.removeFromMenuRequested.connect(self.context_menu_manager.remove_from_menu) | ||||
|             card.addToDesktopRequested.connect(self.context_menu_manager.add_to_desktop) | ||||
|             card.removeFromDesktopRequested.connect(self.context_menu_manager.remove_from_desktop) | ||||
|             card.addToSteamRequested.connect(self.context_menu_manager.add_to_steam) | ||||
|             card.removeFromSteamRequested.connect(self.context_menu_manager.remove_from_steam) | ||||
|             card.openGameFolderRequested.connect(self.context_menu_manager.open_game_folder) | ||||
|  | ||||
|         return card | ||||
|  | ||||
|     def _flush_deletions(self): | ||||
|         """Delete pending widgets off the main update cycle.""" | ||||
|         for card in list(self.pending_deletions): | ||||
|             card.deleteLater() | ||||
|             self.pending_deletions.remove(card) | ||||
|  | ||||
|     def clear_layout(self, layout): | ||||
|         """Clears all widgets from the layout.""" | ||||
|         if layout is None: | ||||
|             return | ||||
|         while layout.count(): | ||||
|             child = layout.takeAt(0) | ||||
|             if child.widget(): | ||||
|                 widget = child.widget() | ||||
|                 for key, card in list(self.game_card_cache.items()): | ||||
|                     if card == widget: | ||||
|                         del self.game_card_cache[key] | ||||
|                         if key in self.pending_images: | ||||
|                             del self.pending_images[key] | ||||
|                 widget.deleteLater() | ||||
|  | ||||
|     def set_games(self, games: list[tuple]): | ||||
|         """Sets the games list and updates the filtered games.""" | ||||
|         self.games = games | ||||
|         self.filtered_games = self.games | ||||
|         self.dirty = True  # Full resort needed | ||||
|         self.update_game_grid() | ||||
|  | ||||
|     def add_game_incremental(self, game_data: tuple): | ||||
|         """Add a single game without full reload.""" | ||||
|         self.games.append(game_data) | ||||
|         self.filtered_games.append(game_data)  # Assume no filter active; adjust if needed | ||||
|         self.dirty = True | ||||
|         self.update_game_grid() | ||||
|  | ||||
|     def remove_game_incremental(self, game_name: str, exec_line: str): | ||||
|         """Remove a single game without full reload.""" | ||||
|         key = (game_name, exec_line) | ||||
|         self.games = [g for g in self.games if (g[0], g[4]) != key] | ||||
|         self.filtered_games = [g for g in self.filtered_games if (g[0], g[4]) != key] | ||||
|         if key in self.game_card_cache and self.gamesListLayout is not None: | ||||
|             card = self.game_card_cache.pop(key) | ||||
|             self.gamesListLayout.removeWidget(card) | ||||
|             self.pending_deletions.append(card)  # Defer deleteLater | ||||
|             if key in self.pending_images: | ||||
|                 del self.pending_images[key] | ||||
|         self.dirty = True | ||||
|         self.update_game_grid() | ||||
|  | ||||
|     def filter_games_delayed(self): | ||||
|         """Filters games based on search text and updates the grid.""" | ||||
|         self.update_game_grid(is_filter=True) | ||||
| @@ -10,7 +10,7 @@ from portprotonqt.logger import get_logger | ||||
| from portprotonqt.dialogs import AddGameDialog, FileExplorer | ||||
| from portprotonqt.game_card import GameCard | ||||
| from portprotonqt.animations import DetailPageAnimations | ||||
| from portprotonqt.custom_widgets import FlowLayout, ClickableLabel, AutoSizeButton, NavLabel | ||||
| from portprotonqt.custom_widgets import ClickableLabel, AutoSizeButton, NavLabel | ||||
| from portprotonqt.portproton_api import PortProtonAPI | ||||
| from portprotonqt.input_manager import InputManager | ||||
| from portprotonqt.context_menu_manager import ContextMenuManager, CustomLineEdit | ||||
| @@ -34,8 +34,10 @@ from portprotonqt.localization import _, get_egs_language, read_metadata_transla | ||||
| from portprotonqt.howlongtobeat_api import HowLongToBeat | ||||
| from portprotonqt.downloader import Downloader | ||||
| from portprotonqt.tray_manager import TrayManager | ||||
| from portprotonqt.game_library_manager import GameLibraryManager | ||||
|  | ||||
| from PySide6.QtWidgets import (QLineEdit, QMainWindow, QStatusBar, QWidget, QVBoxLayout, QLabel, QHBoxLayout, QStackedWidget, QComboBox, QScrollArea, QSlider, | ||||
|  | ||||
| from PySide6.QtWidgets import (QLineEdit, QMainWindow, QStatusBar, QWidget, QVBoxLayout, QLabel, QHBoxLayout, QStackedWidget, QComboBox, | ||||
|                                QDialog, QFormLayout, QFrame, QGraphicsDropShadowEffect, QMessageBox, QApplication, QPushButton, QProgressBar, QCheckBox, QSizePolicy) | ||||
| from PySide6.QtCore import Qt, QAbstractAnimation, QUrl, Signal, QTimer, Slot | ||||
| from PySide6.QtGui import QIcon, QPixmap, QColor, QDesktopServices | ||||
| @@ -47,14 +49,12 @@ from datetime import datetime | ||||
| logger = get_logger(__name__) | ||||
|  | ||||
| class MainWindow(QMainWindow): | ||||
|     """Main window of PortProtonQt.""" | ||||
|     games_loaded = Signal(list) | ||||
|     update_progress = Signal(int)  # Signal to update progress bar | ||||
|     update_status_message = Signal(str, int)  # Signal to update status message | ||||
|     update_progress = Signal(int) | ||||
|     update_status_message = Signal(str, int) | ||||
|  | ||||
|     def __init__(self, app_name: str): | ||||
|         super().__init__() | ||||
|         # Создаём менеджер тем и читаем, какая тема выбрана | ||||
|         self.theme_manager = ThemeManager() | ||||
|         self.is_exiting = False | ||||
|         selected_theme = read_theme_from_config() | ||||
| @@ -62,50 +62,50 @@ class MainWindow(QMainWindow): | ||||
|         self.theme = self.theme_manager.apply_theme(selected_theme) | ||||
|         self.tray_manager = TrayManager(self, app_name, self.current_theme_name) | ||||
|         self.card_width = read_card_size() | ||||
|         self._last_card_width = self.card_width | ||||
|         self.setWindowTitle(app_name) | ||||
|         self.setMinimumSize(800, 600) | ||||
|  | ||||
|         self.games = [] | ||||
|         self.filtered_games = self.games | ||||
|         self.game_processes = [] | ||||
|         self.target_exe = None | ||||
|         self.current_running_button = None | ||||
|         self.portproton_location = get_portproton_location() | ||||
|  | ||||
|         self.game_library_manager = GameLibraryManager(self, self.theme, None) | ||||
|  | ||||
|         self.context_menu_manager = ContextMenuManager( | ||||
|             self, | ||||
|             self.portproton_location, | ||||
|             self.theme, | ||||
|             self.loadGames, | ||||
|             self.updateGameGrid | ||||
|             self.game_library_manager | ||||
|         ) | ||||
|  | ||||
|         self.game_library_manager.context_menu_manager = self.context_menu_manager | ||||
|  | ||||
|         QApplication.setStyle("Fusion") | ||||
|         self.setStyleSheet(self.theme.MAIN_WINDOW_STYLE) | ||||
|         self.setAcceptDrops(True) | ||||
|         self.current_exec_line = None | ||||
|         self.currentDetailPage = None | ||||
|         self.current_play_button = None | ||||
|         self.current_focused_card = None | ||||
|         self.current_focused_card: GameCard | None = None | ||||
|         self.current_hovered_card: GameCard | None = None | ||||
|         self.pending_games = [] | ||||
|         self.game_card_cache = {} | ||||
|         self.pending_images = {} | ||||
|         self.total_games = 0 | ||||
|         self.games_load_timer = QTimer(self) | ||||
|         self.games_load_timer.setSingleShot(True) | ||||
|         self.games_load_timer.timeout.connect(self.finalize_game_loading) | ||||
|         self.games_loaded.connect(self.on_games_loaded) | ||||
|         self.current_add_game_dialog = None | ||||
|         self.current_hovered_card = None | ||||
|  | ||||
|         # Добавляем таймер для дебаунсинга сохранения настроек | ||||
|         self.settingsDebounceTimer = QTimer(self) | ||||
|         self.settingsDebounceTimer.setSingleShot(True) | ||||
|         self.settingsDebounceTimer.setInterval(300)  # 300 мс задержка | ||||
|         self.settingsDebounceTimer.setInterval(300) | ||||
|         self.settingsDebounceTimer.timeout.connect(self.applySettingsDelayed) | ||||
|  | ||||
|         read_time_config() | ||||
|         # Set LEGENDARY_CONFIG_PATH to ~/.cache/PortProtonQt/legendary_cache | ||||
|         self.legendary_config_path = os.path.join( | ||||
|             os.getenv("XDG_CACHE_HOME", os.path.join(os.path.expanduser("~"), ".cache")), | ||||
|             "PortProtonQt", "legendary_cache" | ||||
| @@ -144,7 +144,7 @@ class MainWindow(QMainWindow): | ||||
|         headerLayout.setContentsMargins(0, 0, 0, 0) | ||||
|         headerLayout.addStretch() | ||||
|  | ||||
|         self.input_manager = InputManager(self) | ||||
|         self.input_manager = InputManager(self) # type: ignore | ||||
|         self.input_manager.button_pressed.connect(self.updateControlHints) | ||||
|         self.input_manager.dpad_moved.connect(self.updateControlHints) | ||||
|  | ||||
| @@ -196,15 +196,13 @@ class MainWindow(QMainWindow): | ||||
|         self.stackedWidget = QStackedWidget() | ||||
|         mainLayout.addWidget(self.stackedWidget) | ||||
|  | ||||
|         # Создаём все вкладки | ||||
|         self.createInstalledTab()    # вкладка 0 | ||||
|         self.createAutoInstallTab()  # вкладка 1 | ||||
|         self.createEmulatorsTab()    # вкладка 2 | ||||
|         self.createWineTab()         # вкладка 3 | ||||
|         self.createPortProtonTab()   # вкладка 4 | ||||
|         self.createThemeTab()        # вкладка 5 | ||||
|         self.createInstalledTab() | ||||
|         self.createAutoInstallTab() | ||||
|         self.createEmulatorsTab() | ||||
|         self.createWineTab() | ||||
|         self.createPortProtonTab() | ||||
|         self.createThemeTab() | ||||
|  | ||||
|         # Подсказки управления | ||||
|         self.controlHintsWidget = self.createControlHintsWidget() | ||||
|         mainLayout.addWidget(self.controlHintsWidget) | ||||
|  | ||||
| @@ -222,6 +220,11 @@ class MainWindow(QMainWindow): | ||||
|             else: | ||||
|                 self.showNormal() | ||||
|  | ||||
|     def on_slider_released(self) -> None: | ||||
|         """Delegate to game library manager.""" | ||||
|         if hasattr(self, 'game_library_manager'): | ||||
|             self.game_library_manager.on_slider_released() | ||||
|  | ||||
|     def get_button_icon(self, action: str, gtype: GamepadType) -> str: | ||||
|         """Get the icon name for a specific action and gamepad type.""" | ||||
|         mappings = { | ||||
| @@ -429,31 +432,7 @@ class MainWindow(QMainWindow): | ||||
|  | ||||
|     @Slot(list) | ||||
|     def on_games_loaded(self, games: list[tuple]): | ||||
|         self.games = games | ||||
|         favorites = read_favorites() | ||||
|         sort_method = read_sort_method() | ||||
|  | ||||
|         # Sort by: favorites first, then descending playtime, then descending last launch | ||||
|         if sort_method == "playtime": | ||||
|             self.games.sort(key=lambda g: (0 if g[0] in favorites else 1, -g[11], -g[10])) | ||||
|  | ||||
|         # Sort by: favorites first, then alphabetically by game name | ||||
|         elif sort_method == "alphabetical": | ||||
|             self.games.sort(key=lambda g: (0 if g[0] in favorites else 1, g[0].lower())) | ||||
|  | ||||
|         # Sort by: favorites first, then leave the rest in their original order | ||||
|         elif sort_method == "favorites": | ||||
|             self.games.sort(key=lambda g: (0 if g[0] in favorites else 1)) | ||||
|  | ||||
|         # Sort by: favorites first, then descending last launch, then descending playtime | ||||
|         elif sort_method == "last_launch": | ||||
|             self.games.sort(key=lambda g: (0 if g[0] in favorites else 1, -g[10], -g[11])) | ||||
|  | ||||
|         # Fallback: same as last_launch | ||||
|         else: | ||||
|             self.games.sort(key=lambda g: (0 if g[0] in favorites else 1, -g[10], -g[11])) | ||||
|  | ||||
|         self.updateGameGrid() | ||||
|         self.game_library_manager.set_games(games) | ||||
|         self.progress_bar.setVisible(False) | ||||
|  | ||||
|     def open_portproton_forum_topic(self, topic_name: str): | ||||
| @@ -466,65 +445,6 @@ class MainWindow(QMainWindow): | ||||
|             url = QUrl(f"{base_url}t/{result}") | ||||
|         QDesktopServices.openUrl(url) | ||||
|  | ||||
|     def _on_card_focused(self, game_name: str, is_focused: bool): | ||||
|         """Обработчик сигнала focusChanged от GameCard.""" | ||||
|         card_key = None | ||||
|         for key, card in self.game_card_cache.items(): | ||||
|             if card.name == game_name: | ||||
|                 card_key = key | ||||
|                 break | ||||
|  | ||||
|         if not card_key: | ||||
|             return | ||||
|  | ||||
|         card = self.game_card_cache[card_key] | ||||
|  | ||||
|         if is_focused: | ||||
|             # Если карточка получила фокус | ||||
|             if self.current_hovered_card and self.current_hovered_card != card: | ||||
|                 # Сбрасываем текущую hovered карточку | ||||
|                 self.current_hovered_card._hovered = False | ||||
|                 self.current_hovered_card.leaveEvent(None) | ||||
|                 self.current_hovered_card = None | ||||
|             if self.current_focused_card and self.current_focused_card != card: | ||||
|                 # Сбрасываем текущую focused карточку | ||||
|                 self.current_focused_card._focused = False | ||||
|                 self.current_focused_card.clearFocus() | ||||
|             self.current_focused_card = card | ||||
|         else: | ||||
|             # Если карточка потеряла фокус | ||||
|             if self.current_focused_card == card: | ||||
|                 self.current_focused_card = None | ||||
|  | ||||
|     def _on_card_hovered(self, game_name: str, is_hovered: bool): | ||||
|         """Обработчик сигнала hoverChanged от GameCard.""" | ||||
|         card_key = None | ||||
|         for key, card in self.game_card_cache.items(): | ||||
|             if card.name == game_name: | ||||
|                 card_key = key | ||||
|                 break | ||||
|  | ||||
|         if not card_key: | ||||
|             return | ||||
|  | ||||
|         card = self.game_card_cache[card_key] | ||||
|  | ||||
|         if is_hovered: | ||||
|             # Если мышь наведена на карточку | ||||
|             if self.current_focused_card and self.current_focused_card != card: | ||||
|                 # Сбрасываем текущую focused карточку | ||||
|                 self.current_focused_card._focused = False | ||||
|                 self.current_focused_card.clearFocus() | ||||
|             if self.current_hovered_card and self.current_hovered_card != card: | ||||
|                 # Сбрасываем предыдущую hovered карточку | ||||
|                 self.current_hovered_card._hovered = False | ||||
|                 self.current_hovered_card.leaveEvent(None) | ||||
|             self.current_hovered_card = card | ||||
|         else: | ||||
|             # Если мышь покинула карточку | ||||
|             if self.current_hovered_card == card: | ||||
|                 self.current_hovered_card = None | ||||
|  | ||||
|     def loadGames(self): | ||||
|         display_filter = read_display_filter() | ||||
|         favorites = read_favorites() | ||||
| @@ -797,7 +717,7 @@ class MainWindow(QMainWindow): | ||||
|         overlay = SystemOverlay(self, self.theme) | ||||
|         overlay.exec() | ||||
|  | ||||
|     def createSearchWidget(self) -> tuple[QWidget, QLineEdit]: | ||||
|     def createSearchWidget(self) -> tuple[QWidget, CustomLineEdit]: | ||||
|         self.container = QWidget() | ||||
|         self.container.setStyleSheet(self.theme.CONTAINER_STYLE) | ||||
|         layout = QHBoxLayout(self.container) | ||||
| @@ -823,88 +743,33 @@ class MainWindow(QMainWindow): | ||||
|         self.searchEdit.setClearButtonEnabled(True) | ||||
|         self.searchEdit.setStyleSheet(self.theme.SEARCH_EDIT_STYLE) | ||||
|  | ||||
|         # Добавляем дебансирование для поиска | ||||
|         self.searchEdit.textChanged.connect(self.startSearchDebounce) | ||||
|         self.searchDebounceTimer = QTimer(self) | ||||
|         self.searchDebounceTimer.setSingleShot(True) | ||||
|         self.searchDebounceTimer.setInterval(300) | ||||
|         self.searchDebounceTimer.timeout.connect(self.filterGamesDelayed) | ||||
|         self.searchDebounceTimer.timeout.connect(self.on_search_changed) | ||||
|  | ||||
|         layout.addWidget(self.searchEdit) | ||||
|         return self.container, self.searchEdit | ||||
|  | ||||
|     def on_search_text_changed(self, text: str): | ||||
|         """Search text change handler with debounce.""" | ||||
|         self.searchDebounceTimer.stop() | ||||
|         self.searchDebounceTimer.start() | ||||
|  | ||||
|     @Slot() | ||||
|     def on_search_changed(self): | ||||
|         """Triggers filtering with delay.""" | ||||
|         if hasattr(self, 'game_library_manager'): | ||||
|             self.game_library_manager.filter_games_delayed() | ||||
|  | ||||
|     def startSearchDebounce(self, text): | ||||
|         self.searchDebounceTimer.start() | ||||
|  | ||||
|     def on_slider_released(self): | ||||
|         self.card_width = self.sizeSlider.value() | ||||
|         self.sizeSlider.setToolTip(f"{self.card_width} px") | ||||
|         save_card_size(self.card_width) | ||||
|         for card in self.game_card_cache.values(): | ||||
|             card.update_card_size(self.card_width) | ||||
|         self.updateGameGrid() | ||||
|  | ||||
|     def filterGamesDelayed(self): | ||||
|         """Filters games based on search text and updates the grid.""" | ||||
|         text = self.searchEdit.text().strip().lower() | ||||
|         if text == "": | ||||
|             self.filtered_games = self.games | ||||
|         else: | ||||
|             self.filtered_games = [game for game in self.games if text in game[0].lower()] | ||||
|         self.updateGameGrid(self.filtered_games) | ||||
|  | ||||
|     def createInstalledTab(self): | ||||
|         self.gamesLibraryWidget = QWidget() | ||||
|         self.gamesLibraryWidget.setStyleSheet(self.theme.LIBRARY_WIDGET_STYLE) | ||||
|         layout = QVBoxLayout(self.gamesLibraryWidget) | ||||
|         layout.setSpacing(15) | ||||
|  | ||||
|         searchWidget, self.searchEdit = self.createSearchWidget() | ||||
|         layout.addWidget(searchWidget) | ||||
|  | ||||
|         scrollArea = QScrollArea() | ||||
|         scrollArea.setWidgetResizable(True) | ||||
|         scrollArea.setStyleSheet(self.theme.SCROLL_AREA_STYLE) | ||||
|  | ||||
|         self.gamesListWidget = QWidget() | ||||
|         self.gamesListWidget.setStyleSheet(self.theme.LIST_WIDGET_STYLE) | ||||
|         self.gamesListLayout = FlowLayout(self.gamesListWidget) | ||||
|         self.gamesListWidget.setLayout(self.gamesListLayout) | ||||
|  | ||||
|         scrollArea.setWidget(self.gamesListWidget) | ||||
|         layout.addWidget(scrollArea) | ||||
|  | ||||
|         sliderLayout = QHBoxLayout() | ||||
|         sliderLayout.addStretch() | ||||
|  | ||||
|         # Слайдер | ||||
|         self.sizeSlider = QSlider(Qt.Orientation.Horizontal) | ||||
|         self.sizeSlider.setMinimum(200) | ||||
|         self.sizeSlider.setMaximum(250) | ||||
|         self.sizeSlider.setValue(self.card_width) | ||||
|         self.sizeSlider.setTickInterval(10) | ||||
|         self.sizeSlider.setFixedWidth(150) | ||||
|         self.sizeSlider.setToolTip(f"{self.card_width} px") | ||||
|         self.sizeSlider.setStyleSheet(self.theme.SLIDER_SIZE_STYLE) | ||||
|         self.sizeSlider.sliderReleased.connect(self.on_slider_released) | ||||
|         sliderLayout.addWidget(self.sizeSlider) | ||||
|  | ||||
|         layout.addLayout(sliderLayout) | ||||
|  | ||||
|         def calculate_card_width(): | ||||
|             available_width = scrollArea.width() - 20 | ||||
|             spacing = self.gamesListLayout._spacing | ||||
|             target_cards_per_row = 8 | ||||
|             calculated_width = (available_width - spacing * (target_cards_per_row - 1)) // target_cards_per_row | ||||
|             calculated_width = max(200, min(calculated_width, 250)) | ||||
|  | ||||
|         QTimer.singleShot(0, calculate_card_width) | ||||
|  | ||||
|         # Добавляем обработчик прокрутки для ленивой загрузки | ||||
|         scrollArea.verticalScrollBar().valueChanged.connect(self.loadVisibleImages) | ||||
|  | ||||
|         self.gamesLibraryWidget = self.game_library_manager.create_games_library_widget() | ||||
|         self.stackedWidget.addWidget(self.gamesLibraryWidget) | ||||
|         self.updateGameGrid() | ||||
|         self.game_library_manager.update_game_grid() | ||||
|  | ||||
|     def resizeEvent(self, event): | ||||
|         super().resizeEvent(event) | ||||
| @@ -922,135 +787,6 @@ class MainWindow(QMainWindow): | ||||
|         if abs(self.width() - self._last_width) > 10: | ||||
|             self._last_width = self.width() | ||||
|  | ||||
|     def loadVisibleImages(self): | ||||
|         visible_region = self.gamesListWidget.visibleRegion() | ||||
|         max_concurrent_loads = 5 | ||||
|         loaded_count = 0 | ||||
|         for card_key, card in self.game_card_cache.items(): | ||||
|             if card_key in self.pending_images and visible_region.contains(card.pos()) and loaded_count < max_concurrent_loads: | ||||
|                 cover_path, width, height, callback = self.pending_images.pop(card_key) | ||||
|                 load_pixmap_async(cover_path, width, height, callback) | ||||
|                 loaded_count += 1 | ||||
|  | ||||
|     def updateGameGrid(self, games_list=None): | ||||
|         """Обновляет сетку игровых карточек с сохранением порядка сортировки""" | ||||
|         # Подготовка данных | ||||
|         games_list = games_list if games_list is not None else self.games | ||||
|         search_text = self.searchEdit.text().strip().lower() | ||||
|         favorites = read_favorites() | ||||
|         sort_method = read_sort_method() | ||||
|  | ||||
|         # Сортируем игры согласно текущим настройкам | ||||
|         def sort_key(game): | ||||
|             name = game[0] | ||||
|             # Избранные всегда первые | ||||
|             if name in favorites: | ||||
|                 fav_order = 0 | ||||
|             else: | ||||
|                 fav_order = 1 | ||||
|  | ||||
|             if sort_method == "playtime": | ||||
|                 return (fav_order, -game[11], -game[10])  # playtime_seconds, last_launch_ts | ||||
|             elif sort_method == "alphabetical": | ||||
|                 return (fav_order, name.lower()) | ||||
|             elif sort_method == "favorites": | ||||
|                 return (fav_order,) | ||||
|             else:  # "last_launch" или по умолчанию | ||||
|                 return (fav_order, -game[10], -game[11])  # last_launch_ts, playtime_seconds | ||||
|  | ||||
|         sorted_games = sorted(games_list, key=sort_key) | ||||
|  | ||||
|         # Создаем временный список для новых карточек | ||||
|         new_card_order = [] | ||||
|  | ||||
|         # Обрабатываем каждую игру в отсортированном порядке | ||||
|         for game_data in sorted_games: | ||||
|             game_name = game_data[0] | ||||
|             exec_line = game_data[4] | ||||
|             game_key = (game_name, exec_line) | ||||
|             should_be_visible = not search_text or search_text in game_name.lower() | ||||
|  | ||||
|             # Если карточка уже существует - используем существующую | ||||
|             if game_key in self.game_card_cache: | ||||
|                 card = self.game_card_cache[game_key] | ||||
|                 card.setVisible(should_be_visible) | ||||
|                 new_card_order.append((game_key, card)) | ||||
|                 continue | ||||
|  | ||||
|             # Создаем новую карточку | ||||
|             card = GameCard( | ||||
|                 *game_data, | ||||
|                 select_callback=self.openGameDetailPage, | ||||
|                 theme=self.theme, | ||||
|                 card_width=self.card_width, | ||||
|                 context_menu_manager=self.context_menu_manager | ||||
|             ) | ||||
|  | ||||
|             # Подключаем сигналы | ||||
|             card.hoverChanged.connect(self._on_card_hovered) | ||||
|             card.focusChanged.connect(self._on_card_focused) | ||||
|  | ||||
|             # Подключаем сигналы контекстного меню | ||||
|             card.editShortcutRequested.connect(self.context_menu_manager.edit_game_shortcut) | ||||
|             card.deleteGameRequested.connect(self.context_menu_manager.delete_game) | ||||
|             card.addToMenuRequested.connect(self.context_menu_manager.add_to_menu) | ||||
|             card.removeFromMenuRequested.connect(self.context_menu_manager.remove_from_menu) | ||||
|             card.addToDesktopRequested.connect(self.context_menu_manager.add_to_desktop) | ||||
|             card.removeFromDesktopRequested.connect(self.context_menu_manager.remove_from_desktop) | ||||
|             card.addToSteamRequested.connect(self.context_menu_manager.add_to_steam) | ||||
|             card.removeFromSteamRequested.connect(self.context_menu_manager.remove_from_steam) | ||||
|             card.openGameFolderRequested.connect(self.context_menu_manager.open_game_folder) | ||||
|  | ||||
|             # Добавляем в кэш и временный список | ||||
|             self.game_card_cache[game_key] = card | ||||
|             new_card_order.append((game_key, card)) | ||||
|             card.setVisible(should_be_visible) | ||||
|  | ||||
|         # Полностью перестраиваем макет в правильном порядке, чистим FlowLayout | ||||
|         while self.gamesListLayout.count(): | ||||
|             child = self.gamesListLayout.takeAt(0) | ||||
|             if child.widget(): | ||||
|                 child.widget().setParent(None) | ||||
|  | ||||
|         # Добавляем карточки в макет в отсортированном порядке | ||||
|         for _game_key, card in new_card_order: | ||||
|             self.gamesListLayout.addWidget(card) | ||||
|  | ||||
|             # Загружаем обложку, если карточка видима | ||||
|             if card.isVisible(): | ||||
|                 self.loadVisibleImages() | ||||
|  | ||||
|         # Удаляем карточки для игр, которых больше нет в списке | ||||
|         existing_keys = {game_key for game_key, _ in new_card_order} | ||||
|         for card_key in list(self.game_card_cache.keys()): | ||||
|             if card_key not in existing_keys: | ||||
|                 card = self.game_card_cache.pop(card_key) | ||||
|                 card.deleteLater() | ||||
|                 if card_key in self.pending_images: | ||||
|                     del self.pending_images[card_key] | ||||
|  | ||||
|         # Принудительно обновляем макет | ||||
|         self.gamesListLayout.update() | ||||
|         self.gamesListWidget.updateGeometry() | ||||
|         self.gamesListWidget.update() | ||||
|  | ||||
|         # Сохраняем текущий размер карточек | ||||
|         self._last_card_width = self.card_width | ||||
|  | ||||
|     def clearLayout(self, layout): | ||||
|         """Удаляет все виджеты из layout.""" | ||||
|         while layout.count(): | ||||
|             child = layout.takeAt(0) | ||||
|             if child.widget(): | ||||
|                 widget = child.widget() | ||||
|                 # Remove from game_card_cache if it's a GameCard | ||||
|                 for key, card in list(self.game_card_cache.items()): | ||||
|                     if card == widget: | ||||
|                         del self.game_card_cache[key] | ||||
|                         # Also remove from pending_images if present | ||||
|                         if key in self.pending_images: | ||||
|                             del self.pending_images[key] | ||||
|                 widget.deleteLater() | ||||
|  | ||||
|     def dragEnterEvent(self, event): | ||||
|         if event.mimeData().hasUrls(): | ||||
| @@ -1068,26 +804,22 @@ class MainWindow(QMainWindow): | ||||
|                 break | ||||
|  | ||||
|     def openAddGameDialog(self, exe_path=None): | ||||
|         """Открывает диалоговое окно 'Add Game' с текущей темой.""" | ||||
|         # Проверяем, открыт ли уже диалог | ||||
|         if self.current_add_game_dialog is not None and self.current_add_game_dialog.isVisible(): | ||||
|             self.current_add_game_dialog.activateWindow()  # Активируем существующий диалог | ||||
|             self.current_add_game_dialog.raise_()  # Поднимаем окно | ||||
|             self.current_add_game_dialog.activateWindow() | ||||
|             self.current_add_game_dialog.raise_() | ||||
|             return | ||||
|  | ||||
|         dialog = AddGameDialog(self, self.theme) | ||||
|         dialog.setFocus(Qt.FocusReason.OtherFocusReason) | ||||
|         self.current_add_game_dialog = dialog  # Сохраняем ссылку на диалог | ||||
|         self.current_add_game_dialog = dialog | ||||
|  | ||||
|         # Предзаполняем путь к .exe при drag-and-drop | ||||
|         if exe_path: | ||||
|             dialog.exeEdit.setText(exe_path) | ||||
|             dialog.nameEdit.setText(os.path.splitext(os.path.basename(exe_path))[0]) | ||||
|             dialog.updatePreview() | ||||
|  | ||||
|         # Обработчик закрытия диалога | ||||
|         def on_dialog_finished(): | ||||
|             self.current_add_game_dialog = None  # Сбрасываем ссылку при закрытии | ||||
|             self.current_add_game_dialog = None | ||||
|  | ||||
|         dialog.finished.connect(on_dialog_finished) | ||||
|  | ||||
| @@ -1099,33 +831,124 @@ class MainWindow(QMainWindow): | ||||
|             if not name or not exe_path: | ||||
|                 return | ||||
|  | ||||
|             # Сохраняем .desktop файл | ||||
|             desktop_entry, desktop_path = dialog.getDesktopEntryData() | ||||
|             if desktop_entry and desktop_path: | ||||
|                 with open(desktop_path, "w", encoding="utf-8") as f: | ||||
|                     f.write(desktop_entry) | ||||
|                     os.chmod(desktop_path, 0o755) | ||||
|  | ||||
|                 # Проверяем путь обложки, если он отличается от стандартной | ||||
|                 if os.path.isfile(user_cover): | ||||
|                     exe_name = os.path.splitext(os.path.basename(exe_path))[0] | ||||
|                     xdg_data_home = os.getenv("XDG_DATA_HOME", | ||||
|                         os.path.join(os.path.expanduser("~"), ".local", "share")) | ||||
|                     custom_folder = os.path.join( | ||||
|                         xdg_data_home, | ||||
|                         "PortProtonQt", | ||||
|                         "custom_data", | ||||
|                         exe_name | ||||
|                     ) | ||||
|                     os.makedirs(custom_folder, exist_ok=True) | ||||
|                 exe_name = os.path.splitext(os.path.basename(exe_path))[0] | ||||
|                 xdg_data_home = os.getenv("XDG_DATA_HOME", | ||||
|                     os.path.join(os.path.expanduser("~"), ".local", "share")) | ||||
|                 custom_folder = os.path.join( | ||||
|                     xdg_data_home, | ||||
|                     "PortProtonQt", | ||||
|                     "custom_data", | ||||
|                     exe_name | ||||
|                 ) | ||||
|                 os.makedirs(custom_folder, exist_ok=True) | ||||
|  | ||||
|                     # Сохраняем пользовательскую обложку как cover.* | ||||
|                 # Handle user cover copy | ||||
|                 cover_path = None | ||||
|                 if user_cover: | ||||
|                     ext = os.path.splitext(user_cover)[1].lower() | ||||
|                     if ext in [".png", ".jpg", ".jpeg", ".bmp"]: | ||||
|                         shutil.copyfile(user_cover, os.path.join(custom_folder, f"cover{ext}")) | ||||
|                     if os.path.isfile(user_cover) and ext in [".png", ".jpg", ".jpeg", ".bmp"]: | ||||
|                         copied_cover = os.path.join(custom_folder, f"cover{ext}") | ||||
|                         shutil.copyfile(user_cover, copied_cover) | ||||
|                         cover_path = copied_cover | ||||
|  | ||||
|             self.games = self.loadGames() | ||||
|             self.updateGameGrid() | ||||
|                 # Parse .desktop (adapt from _process_desktop_file_async) | ||||
|                 entry = parse_desktop_entry(desktop_path) | ||||
|                 if not entry: | ||||
|                     return | ||||
|                 description = entry.get("Comment", "") | ||||
|                 exec_line = entry.get("Exec", exe_path) | ||||
|  | ||||
|                 # Builtin custom folder (adapt path) | ||||
|                 repo_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) | ||||
|                 builtin_custom_folder = os.path.join(repo_root, "portprotonqt", "custom_data") | ||||
|                 builtin_game_folder = os.path.join(builtin_custom_folder, exe_name) | ||||
|                 builtin_cover = "" | ||||
|                 if os.path.exists(builtin_game_folder): | ||||
|                     builtin_files = set(os.listdir(builtin_game_folder)) | ||||
|                     for ext in [".jpg", ".png", ".jpeg", ".bmp"]: | ||||
|                         candidate = f"cover{ext}" | ||||
|                         if candidate in builtin_files: | ||||
|                             builtin_cover = os.path.join(builtin_game_folder, candidate) | ||||
|                             break | ||||
|  | ||||
|                 # User cover fallback | ||||
|                 user_cover_path = cover_path  # Already set if user provided | ||||
|  | ||||
|                 # Statistics (playtime, last launch - defaults for new) | ||||
|                 playtime_seconds = 0 | ||||
|                 formatted_playtime = format_playtime(playtime_seconds) | ||||
|                 last_played_timestamp = 0 | ||||
|                 last_launch = _("Never") | ||||
|  | ||||
|                 # Language for translations | ||||
|                 language_code = get_egs_language() | ||||
|  | ||||
|                 # Read translations from metadata.txt | ||||
|                 user_metadata_file = os.path.join(custom_folder, "metadata.txt") | ||||
|                 builtin_metadata_file = os.path.join(builtin_game_folder, "metadata.txt") | ||||
|  | ||||
|                 translations = {'name': name, 'description': description} | ||||
|                 if os.path.exists(user_metadata_file): | ||||
|                     translations = read_metadata_translations(user_metadata_file, language_code) | ||||
|                 elif os.path.exists(builtin_metadata_file): | ||||
|                     translations = read_metadata_translations(builtin_metadata_file, language_code) | ||||
|  | ||||
|                 final_name = translations['name'] | ||||
|                 final_desc = translations['description'] | ||||
|  | ||||
|                 def on_steam_info(steam_info: dict): | ||||
|                     nonlocal final_name, final_desc | ||||
|                     # Adapt final_cover logic from _process_desktop_file_async | ||||
|                     final_cover = (user_cover_path if user_cover_path else | ||||
|                                 builtin_cover if builtin_cover else | ||||
|                                 steam_info.get("cover", "") or entry.get("Icon", "")) | ||||
|  | ||||
|                     # Use Steam description as fallback if no translation | ||||
|                     steam_desc = steam_info.get("description", "") | ||||
|                     if steam_desc and steam_desc != final_desc: | ||||
|                         final_desc = steam_desc | ||||
|  | ||||
|                     # Use Steam name as fallback if better | ||||
|                     steam_name = steam_info.get("name", "") | ||||
|                     if steam_name and steam_name != final_name: | ||||
|                         final_name = steam_name | ||||
|  | ||||
|                     # Build full game_data tuple with all Steam data | ||||
|                     game_data = ( | ||||
|                         final_name, | ||||
|                         final_desc, | ||||
|                         final_cover, | ||||
|                         steam_info.get("appid", ""), | ||||
|                         exec_line, | ||||
|                         steam_info.get("controller_support", ""), | ||||
|                         last_launch, | ||||
|                         formatted_playtime, | ||||
|                         steam_info.get("protondb_tier", ""), | ||||
|                         steam_info.get("anticheat_status", ""), | ||||
|                         last_played_timestamp, | ||||
|                         playtime_seconds, | ||||
|                         "portproton" | ||||
|                     ) | ||||
|  | ||||
|                     # Incremental add | ||||
|                     self.game_library_manager.add_game_incremental(game_data) | ||||
|  | ||||
|                     # Status message | ||||
|                     msg = _("Added '{name}'").format(name=final_name) | ||||
|                     self.statusBar().showMessage(msg, 3000) | ||||
|  | ||||
|                     # Trigger visible images load | ||||
|                     QTimer.singleShot(200, self.game_library_manager.load_visible_images) | ||||
|  | ||||
|                 self.update_status_message.emit(_("Enriching from Steam..."), 3000) | ||||
|                 from portprotonqt.steam_api import get_steam_game_info_async | ||||
|                 get_steam_game_info_async(final_name, exec_line, on_steam_info) | ||||
|  | ||||
|     def createAutoInstallTab(self): | ||||
|         """Вкладка 'Auto Install'.""" | ||||
| @@ -1487,18 +1310,14 @@ class MainWindow(QMainWindow): | ||||
|             self.statusBar().showMessage(_("Cache cleared"), 3000) | ||||
|  | ||||
|     def applySettingsDelayed(self): | ||||
|         """Applies settings with the new filter and updates the game list.""" | ||||
|         read_time_config() | ||||
|         self.games = [] | ||||
|         self.loadGames() | ||||
|         display_filter = read_display_filter() | ||||
|         for card in self.game_card_cache.values(): | ||||
|         for card in self.game_library_manager.game_card_cache.values(): | ||||
|             card.update_badge_visibility(display_filter) | ||||
|  | ||||
|     def savePortProtonSettings(self): | ||||
|         """ | ||||
|         Сохраняет параметры конфигурации в конфигурационный файл. | ||||
|         """ | ||||
|         time_idx = self.timeDetailCombo.currentIndex() | ||||
|         time_key = self.time_keys[time_idx] | ||||
|         save_time_config(time_key) | ||||
| @@ -1511,7 +1330,6 @@ class MainWindow(QMainWindow): | ||||
|         filter_key = self.filter_keys[filter_idx] | ||||
|         save_display_filter(filter_key) | ||||
|  | ||||
|         # Сохранение proxy настроек | ||||
|         proxy_url = self.proxyUrlEdit.text().strip() | ||||
|         proxy_user = self.proxyUserEdit.text().strip() | ||||
|         proxy_password = self.proxyPasswordEdit.text().strip() | ||||
| @@ -1523,11 +1341,10 @@ class MainWindow(QMainWindow): | ||||
|         auto_fullscreen_gamepad = self.autoFullscreenGamepadCheckBox.isChecked() | ||||
|         save_auto_fullscreen_gamepad(auto_fullscreen_gamepad) | ||||
|  | ||||
|         # Сохранение настройки виброотдачи геймпада | ||||
|         rumble_enabled = self.gamepadRumbleCheckBox.isChecked() | ||||
|         save_rumble_config(rumble_enabled) | ||||
|  | ||||
|         for card in self.game_card_cache.values(): | ||||
|         for card in self.game_library_manager.game_card_cache.values(): | ||||
|             card.update_badge_visibility(filter_key) | ||||
|  | ||||
|         if self.currentDetailPage and self.current_exec_line: | ||||
| @@ -1540,14 +1357,12 @@ 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): | ||||
|             self.showFullScreen() | ||||
|         else: | ||||
|             # Если обе галочки сняты и геймпад не подключен, возвращаем нормальное состояние | ||||
|             self.showNormal() | ||||
|             self.resize(*read_window_geometry())  # Восстанавливаем сохраненные размеры окна | ||||
|             self.resize(*read_window_geometry()) | ||||
|  | ||||
|         self.statusBar().showMessage(_("Settings saved"), 3000) | ||||
|  | ||||
| @@ -2129,7 +1944,7 @@ class MainWindow(QMainWindow): | ||||
|             favorites.append(game_name) | ||||
|             label.setText("★") | ||||
|         save_favorites(favorites) | ||||
|         self.updateGameGrid() | ||||
|         self.game_library_manager.update_game_grid() | ||||
|  | ||||
|     def activateFocusedWidget(self): | ||||
|         """Activate the currently focused widget.""" | ||||
|   | ||||
		Reference in New Issue
	
	Block a user