forked from Boria138/PortProtonQt
Compare commits
6 Commits
download-t
...
d05f2fccd6
Author | SHA1 | Date | |
---|---|---|---|
d05f2fccd6 | |||
baec62d1cb
|
|||
cb76961e4f
|
|||
|
081cd07253 | ||
b5efee29ea
|
|||
69360f7e7e |
@@ -6,11 +6,14 @@
|
|||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
- Возможность скроллинга библиотеки мышью или пальцем
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
- Проведён рефакторинг и оптимизация всего что связано с карточками и библиотекой игр
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
- Исправлен вылет диалога выбора файлов при выборе обложки если в папке более сотни изображений
|
- Исправлен вылет диалога выбора файлов при выборе обложки если в папке более сотни изображений
|
||||||
|
- Исправлено зависание при добавлении или удалении игры в Wayland
|
||||||
|
|
||||||
### Contributors
|
### Contributors
|
||||||
|
|
||||||
@@ -29,12 +32,12 @@
|
|||||||
### Changed
|
### Changed
|
||||||
- Управления с геймпада теперь перехватывается только если окно в фокусе
|
- Управления с геймпада теперь перехватывается только если окно в фокусе
|
||||||
|
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
- Исправлена проблема с устаревшими кэш-файлами, вызывающими несоответствия при обновлении JSON
|
- Исправлена проблема с устаревшими кэш-файлами, вызывающими несоответствия при обновлении JSON
|
||||||
- Исправлено переключение в полноэкранный режим при нажатии кнопки "Select во время запущенной игры
|
- Исправлено переключение в полноэкранный режим при нажатии кнопки "Select во время запущенной игры
|
||||||
|
|
||||||
### Contributors
|
### Contributors
|
||||||
|
- @wmigor (Igor Akulov)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
@@ -217,7 +217,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"normalized_name": "watch_dogs 2",
|
"normalized_name": "watch_dogs 2",
|
||||||
"status": "Broken"
|
"status": "Running"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"normalized_name": "zero hour",
|
"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",
|
"normalized_title": "astroneer",
|
||||||
"slug": "astroneer"
|
"slug": "astroneer"
|
||||||
@@ -195,10 +287,6 @@
|
|||||||
"normalized_title": "slitterhead",
|
"normalized_title": "slitterhead",
|
||||||
"slug": "slitterhead"
|
"slug": "slitterhead"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"normalized_title": "indiana jones and the great circle",
|
|
||||||
"slug": "indiana-jones-and-the-great-circle"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"normalized_title": "crossout",
|
"normalized_title": "crossout",
|
||||||
"slug": "crossout"
|
"slug": "crossout"
|
||||||
|
Binary file not shown.
@@ -29,7 +29,7 @@ class ContextMenuSignals(QObject):
|
|||||||
class ContextMenuManager:
|
class ContextMenuManager:
|
||||||
"""Manages context menu actions for game management in PortProtonQt."""
|
"""Manages context menu actions for game management in PortProtonQt."""
|
||||||
|
|
||||||
def __init__(self, parent, portproton_location, theme, load_games_callback, update_game_grid_callback):
|
def __init__(self, parent, portproton_location, theme, load_games_callback, game_library_manager):
|
||||||
"""
|
"""
|
||||||
Initialize the ContextMenuManager.
|
Initialize the ContextMenuManager.
|
||||||
|
|
||||||
@@ -45,7 +45,8 @@ class ContextMenuManager:
|
|||||||
self.theme = theme
|
self.theme = theme
|
||||||
self.theme_manager = ThemeManager()
|
self.theme_manager = ThemeManager()
|
||||||
self.load_games = load_games_callback
|
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(
|
self.legendary_path = os.path.join(
|
||||||
os.getenv("XDG_CACHE_HOME", os.path.join(os.path.expanduser("~"), ".cache")),
|
os.getenv("XDG_CACHE_HOME", os.path.join(os.path.expanduser("~"), ".cache")),
|
||||||
"PortProtonQt", "legendary_cache", "legendary"
|
"PortProtonQt", "legendary_cache", "legendary"
|
||||||
@@ -859,9 +860,16 @@ Icon={icon_path}
|
|||||||
_("Failed to delete custom data: {error}").format(error=str(e))
|
_("Failed to delete custom data: {error}").format(error=str(e))
|
||||||
)
|
)
|
||||||
|
|
||||||
# Reload games list and update grid
|
self.update_game_grid = self.game_library_manager.remove_game_incremental
|
||||||
self.load_games()
|
self.game_library_manager.remove_game_incremental(game_name, exec_line)
|
||||||
self.update_game_grid()
|
|
||||||
|
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):
|
def add_to_menu(self, game_name, exec_line):
|
||||||
"""Copy the .desktop file to ~/.local/share/applications."""
|
"""Copy the .desktop file to ~/.local/share/applications."""
|
||||||
|
@@ -4,7 +4,7 @@ import re
|
|||||||
from typing import cast, TYPE_CHECKING
|
from typing import cast, TYPE_CHECKING
|
||||||
from PySide6.QtGui import QPixmap, QIcon
|
from PySide6.QtGui import QPixmap, QIcon
|
||||||
from PySide6.QtWidgets import (
|
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 PySide6.QtCore import Qt, QObject, Signal, QMimeDatabase, QTimer, QThreadPool, QRunnable, Slot
|
||||||
from icoextract import IconExtractor, IconExtractorError
|
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.itemDoubleClicked.connect(self.handle_item_double_click)
|
||||||
self.file_list.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
|
self.file_list.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
|
||||||
self.file_list.customContextMenuRequested.connect(self.show_folder_context_menu)
|
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)
|
self.main_layout.addWidget(self.file_list)
|
||||||
|
|
||||||
# Connect scroll signal for lazy loading
|
# Connect scroll signal for lazy loading
|
||||||
|
420
portprotonqt/game_library_manager.py
Normal file
420
portprotonqt/game_library_manager.py
Normal file
@@ -0,0 +1,420 @@
|
|||||||
|
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() # Queue for deferred widget deletion
|
||||||
|
self.dirty = False # Flag for when full resort is needed
|
||||||
|
|
||||||
|
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):
|
||||||
|
"""Schedules a game grid update with debouncing."""
|
||||||
|
if games_list is not None:
|
||||||
|
self.filtered_games = games_list
|
||||||
|
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
|
||||||
|
|
||||||
|
games_list = self.filtered_games if self.filtered_games else self.games
|
||||||
|
search_text = self.main_window.searchEdit.text().strip().lower()
|
||||||
|
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
|
||||||
|
|
||||||
|
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."""
|
||||||
|
text = self.main_window.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.update_game_grid(self.filtered_games)
|
@@ -10,10 +10,11 @@ from portprotonqt.logger import get_logger
|
|||||||
from portprotonqt.dialogs import AddGameDialog, FileExplorer
|
from portprotonqt.dialogs import AddGameDialog, FileExplorer
|
||||||
from portprotonqt.game_card import GameCard
|
from portprotonqt.game_card import GameCard
|
||||||
from portprotonqt.animations import DetailPageAnimations
|
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.portproton_api import PortProtonAPI
|
||||||
from portprotonqt.input_manager import InputManager
|
from portprotonqt.input_manager import InputManager
|
||||||
from portprotonqt.context_menu_manager import ContextMenuManager, CustomLineEdit
|
from portprotonqt.context_menu_manager import ContextMenuManager, CustomLineEdit
|
||||||
|
from portprotonqt.preloader import Preloader
|
||||||
from portprotonqt.system_overlay import SystemOverlay
|
from portprotonqt.system_overlay import SystemOverlay
|
||||||
from portprotonqt.input_manager import GamepadType
|
from portprotonqt.input_manager import GamepadType
|
||||||
|
|
||||||
@@ -34,8 +35,10 @@ from portprotonqt.localization import _, get_egs_language, read_metadata_transla
|
|||||||
from portprotonqt.howlongtobeat_api import HowLongToBeat
|
from portprotonqt.howlongtobeat_api import HowLongToBeat
|
||||||
from portprotonqt.downloader import Downloader
|
from portprotonqt.downloader import Downloader
|
||||||
from portprotonqt.tray_manager import TrayManager
|
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)
|
QDialog, QFormLayout, QFrame, QGraphicsDropShadowEffect, QMessageBox, QApplication, QPushButton, QProgressBar, QCheckBox, QSizePolicy)
|
||||||
from PySide6.QtCore import Qt, QAbstractAnimation, QUrl, Signal, QTimer, Slot
|
from PySide6.QtCore import Qt, QAbstractAnimation, QUrl, Signal, QTimer, Slot
|
||||||
from PySide6.QtGui import QIcon, QPixmap, QColor, QDesktopServices
|
from PySide6.QtGui import QIcon, QPixmap, QColor, QDesktopServices
|
||||||
@@ -47,14 +50,12 @@ from datetime import datetime
|
|||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
class MainWindow(QMainWindow):
|
class MainWindow(QMainWindow):
|
||||||
"""Main window of PortProtonQt."""
|
|
||||||
games_loaded = Signal(list)
|
games_loaded = Signal(list)
|
||||||
update_progress = Signal(int) # Signal to update progress bar
|
update_progress = Signal(int)
|
||||||
update_status_message = Signal(str, int) # Signal to update status message
|
update_status_message = Signal(str, int)
|
||||||
|
|
||||||
def __init__(self, app_name: str):
|
def __init__(self, app_name: str):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
# Создаём менеджер тем и читаем, какая тема выбрана
|
|
||||||
self.theme_manager = ThemeManager()
|
self.theme_manager = ThemeManager()
|
||||||
self.is_exiting = False
|
self.is_exiting = False
|
||||||
selected_theme = read_theme_from_config()
|
selected_theme = read_theme_from_config()
|
||||||
@@ -62,50 +63,50 @@ class MainWindow(QMainWindow):
|
|||||||
self.theme = self.theme_manager.apply_theme(selected_theme)
|
self.theme = self.theme_manager.apply_theme(selected_theme)
|
||||||
self.tray_manager = TrayManager(self, app_name, self.current_theme_name)
|
self.tray_manager = TrayManager(self, app_name, self.current_theme_name)
|
||||||
self.card_width = read_card_size()
|
self.card_width = read_card_size()
|
||||||
|
self._last_card_width = self.card_width
|
||||||
self.setWindowTitle(app_name)
|
self.setWindowTitle(app_name)
|
||||||
self.setMinimumSize(800, 600)
|
self.setMinimumSize(800, 600)
|
||||||
|
|
||||||
self.games = []
|
self.games = []
|
||||||
self.filtered_games = self.games
|
|
||||||
self.game_processes = []
|
self.game_processes = []
|
||||||
self.target_exe = None
|
self.target_exe = None
|
||||||
self.current_running_button = None
|
self.current_running_button = None
|
||||||
self.portproton_location = get_portproton_location()
|
self.portproton_location = get_portproton_location()
|
||||||
|
|
||||||
|
self.game_library_manager = GameLibraryManager(self, self.theme, None)
|
||||||
|
|
||||||
self.context_menu_manager = ContextMenuManager(
|
self.context_menu_manager = ContextMenuManager(
|
||||||
self,
|
self,
|
||||||
self.portproton_location,
|
self.portproton_location,
|
||||||
self.theme,
|
self.theme,
|
||||||
self.loadGames,
|
self.loadGames,
|
||||||
self.updateGameGrid
|
self.game_library_manager
|
||||||
)
|
)
|
||||||
|
|
||||||
|
self.game_library_manager.context_menu_manager = self.context_menu_manager
|
||||||
|
|
||||||
QApplication.setStyle("Fusion")
|
QApplication.setStyle("Fusion")
|
||||||
self.setStyleSheet(self.theme.MAIN_WINDOW_STYLE)
|
self.setStyleSheet(self.theme.MAIN_WINDOW_STYLE)
|
||||||
self.setAcceptDrops(True)
|
self.setAcceptDrops(True)
|
||||||
self.current_exec_line = None
|
self.current_exec_line = None
|
||||||
self.currentDetailPage = None
|
self.currentDetailPage = None
|
||||||
self.current_play_button = 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.pending_games = []
|
||||||
self.game_card_cache = {}
|
|
||||||
self.pending_images = {}
|
|
||||||
self.total_games = 0
|
self.total_games = 0
|
||||||
self.games_load_timer = QTimer(self)
|
self.games_load_timer = QTimer(self)
|
||||||
self.games_load_timer.setSingleShot(True)
|
self.games_load_timer.setSingleShot(True)
|
||||||
self.games_load_timer.timeout.connect(self.finalize_game_loading)
|
self.games_load_timer.timeout.connect(self.finalize_game_loading)
|
||||||
self.games_loaded.connect(self.on_games_loaded)
|
self.games_loaded.connect(self.on_games_loaded)
|
||||||
self.current_add_game_dialog = None
|
self.current_add_game_dialog = None
|
||||||
self.current_hovered_card = None
|
|
||||||
|
|
||||||
# Добавляем таймер для дебаунсинга сохранения настроек
|
|
||||||
self.settingsDebounceTimer = QTimer(self)
|
self.settingsDebounceTimer = QTimer(self)
|
||||||
self.settingsDebounceTimer.setSingleShot(True)
|
self.settingsDebounceTimer.setSingleShot(True)
|
||||||
self.settingsDebounceTimer.setInterval(300) # 300 мс задержка
|
self.settingsDebounceTimer.setInterval(300)
|
||||||
self.settingsDebounceTimer.timeout.connect(self.applySettingsDelayed)
|
self.settingsDebounceTimer.timeout.connect(self.applySettingsDelayed)
|
||||||
|
|
||||||
read_time_config()
|
read_time_config()
|
||||||
# Set LEGENDARY_CONFIG_PATH to ~/.cache/PortProtonQt/legendary_cache
|
|
||||||
self.legendary_config_path = os.path.join(
|
self.legendary_config_path = os.path.join(
|
||||||
os.getenv("XDG_CACHE_HOME", os.path.join(os.path.expanduser("~"), ".cache")),
|
os.getenv("XDG_CACHE_HOME", os.path.join(os.path.expanduser("~"), ".cache")),
|
||||||
"PortProtonQt", "legendary_cache"
|
"PortProtonQt", "legendary_cache"
|
||||||
@@ -144,7 +145,7 @@ class MainWindow(QMainWindow):
|
|||||||
headerLayout.setContentsMargins(0, 0, 0, 0)
|
headerLayout.setContentsMargins(0, 0, 0, 0)
|
||||||
headerLayout.addStretch()
|
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.button_pressed.connect(self.updateControlHints)
|
||||||
self.input_manager.dpad_moved.connect(self.updateControlHints)
|
self.input_manager.dpad_moved.connect(self.updateControlHints)
|
||||||
|
|
||||||
@@ -196,15 +197,13 @@ class MainWindow(QMainWindow):
|
|||||||
self.stackedWidget = QStackedWidget()
|
self.stackedWidget = QStackedWidget()
|
||||||
mainLayout.addWidget(self.stackedWidget)
|
mainLayout.addWidget(self.stackedWidget)
|
||||||
|
|
||||||
# Создаём все вкладки
|
self.createInstalledTab()
|
||||||
self.createInstalledTab() # вкладка 0
|
self.createAutoInstallTab()
|
||||||
self.createAutoInstallTab() # вкладка 1
|
self.createEmulatorsTab()
|
||||||
self.createEmulatorsTab() # вкладка 2
|
self.createWineTab()
|
||||||
self.createWineTab() # вкладка 3
|
self.createPortProtonTab()
|
||||||
self.createPortProtonTab() # вкладка 4
|
self.createThemeTab()
|
||||||
self.createThemeTab() # вкладка 5
|
|
||||||
|
|
||||||
# Подсказки управления
|
|
||||||
self.controlHintsWidget = self.createControlHintsWidget()
|
self.controlHintsWidget = self.createControlHintsWidget()
|
||||||
mainLayout.addWidget(self.controlHintsWidget)
|
mainLayout.addWidget(self.controlHintsWidget)
|
||||||
|
|
||||||
@@ -221,6 +220,22 @@ class MainWindow(QMainWindow):
|
|||||||
self.resize(width, height)
|
self.resize(width, height)
|
||||||
else:
|
else:
|
||||||
self.showNormal()
|
self.showNormal()
|
||||||
|
self._preloader = Preloader(parent=self)
|
||||||
|
self._update_preloader_position()
|
||||||
|
|
||||||
|
def _update_preloader_position(self):
|
||||||
|
if self._preloader:
|
||||||
|
self._preloader.move(self.rect().center() - self._preloader.rect().center())
|
||||||
|
|
||||||
|
def _close_preloader(self):
|
||||||
|
if self._preloader:
|
||||||
|
self._preloader.close()
|
||||||
|
self._preloader = None
|
||||||
|
|
||||||
|
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:
|
def get_button_icon(self, action: str, gtype: GamepadType) -> str:
|
||||||
"""Get the icon name for a specific action and gamepad type."""
|
"""Get the icon name for a specific action and gamepad type."""
|
||||||
@@ -429,32 +444,9 @@ class MainWindow(QMainWindow):
|
|||||||
|
|
||||||
@Slot(list)
|
@Slot(list)
|
||||||
def on_games_loaded(self, games: list[tuple]):
|
def on_games_loaded(self, games: list[tuple]):
|
||||||
self.games = games
|
self.game_library_manager.set_games(games)
|
||||||
favorites = read_favorites()
|
|
||||||
sort_method = read_sort_method()
|
|
||||||
|
|
||||||
# Sort by: favorites first, then descending playtime, then descending last launch
|
|
||||||
if sort_method == "playtime":
|
|
||||||
self.games.sort(key=lambda g: (0 if g[0] in favorites else 1, -g[11], -g[10]))
|
|
||||||
|
|
||||||
# Sort by: favorites first, then alphabetically by game name
|
|
||||||
elif sort_method == "alphabetical":
|
|
||||||
self.games.sort(key=lambda g: (0 if g[0] in favorites else 1, g[0].lower()))
|
|
||||||
|
|
||||||
# Sort by: favorites first, then leave the rest in their original order
|
|
||||||
elif sort_method == "favorites":
|
|
||||||
self.games.sort(key=lambda g: (0 if g[0] in favorites else 1))
|
|
||||||
|
|
||||||
# Sort by: favorites first, then descending last launch, then descending playtime
|
|
||||||
elif sort_method == "last_launch":
|
|
||||||
self.games.sort(key=lambda g: (0 if g[0] in favorites else 1, -g[10], -g[11]))
|
|
||||||
|
|
||||||
# Fallback: same as last_launch
|
|
||||||
else:
|
|
||||||
self.games.sort(key=lambda g: (0 if g[0] in favorites else 1, -g[10], -g[11]))
|
|
||||||
|
|
||||||
self.updateGameGrid()
|
|
||||||
self.progress_bar.setVisible(False)
|
self.progress_bar.setVisible(False)
|
||||||
|
self._close_preloader()
|
||||||
|
|
||||||
def open_portproton_forum_topic(self, topic_name: str):
|
def open_portproton_forum_topic(self, topic_name: str):
|
||||||
"""Open the PortProton forum topic or search page for this game."""
|
"""Open the PortProton forum topic or search page for this game."""
|
||||||
@@ -466,65 +458,6 @@ class MainWindow(QMainWindow):
|
|||||||
url = QUrl(f"{base_url}t/{result}")
|
url = QUrl(f"{base_url}t/{result}")
|
||||||
QDesktopServices.openUrl(url)
|
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):
|
def loadGames(self):
|
||||||
display_filter = read_display_filter()
|
display_filter = read_display_filter()
|
||||||
favorites = read_favorites()
|
favorites = read_favorites()
|
||||||
@@ -797,7 +730,7 @@ class MainWindow(QMainWindow):
|
|||||||
overlay = SystemOverlay(self, self.theme)
|
overlay = SystemOverlay(self, self.theme)
|
||||||
overlay.exec()
|
overlay.exec()
|
||||||
|
|
||||||
def createSearchWidget(self) -> tuple[QWidget, QLineEdit]:
|
def createSearchWidget(self) -> tuple[QWidget, CustomLineEdit]:
|
||||||
self.container = QWidget()
|
self.container = QWidget()
|
||||||
self.container.setStyleSheet(self.theme.CONTAINER_STYLE)
|
self.container.setStyleSheet(self.theme.CONTAINER_STYLE)
|
||||||
layout = QHBoxLayout(self.container)
|
layout = QHBoxLayout(self.container)
|
||||||
@@ -823,12 +756,11 @@ class MainWindow(QMainWindow):
|
|||||||
self.searchEdit.setClearButtonEnabled(True)
|
self.searchEdit.setClearButtonEnabled(True)
|
||||||
self.searchEdit.setStyleSheet(self.theme.SEARCH_EDIT_STYLE)
|
self.searchEdit.setStyleSheet(self.theme.SEARCH_EDIT_STYLE)
|
||||||
|
|
||||||
# Добавляем дебансирование для поиска
|
|
||||||
self.searchEdit.textChanged.connect(self.startSearchDebounce)
|
self.searchEdit.textChanged.connect(self.startSearchDebounce)
|
||||||
self.searchDebounceTimer = QTimer(self)
|
self.searchDebounceTimer = QTimer(self)
|
||||||
self.searchDebounceTimer.setSingleShot(True)
|
self.searchDebounceTimer.setSingleShot(True)
|
||||||
self.searchDebounceTimer.setInterval(300)
|
self.searchDebounceTimer.setInterval(300)
|
||||||
self.searchDebounceTimer.timeout.connect(self.filterGamesDelayed)
|
self.searchDebounceTimer.timeout.connect(self.game_library_manager.filter_games_delayed)
|
||||||
|
|
||||||
layout.addWidget(self.searchEdit)
|
layout.addWidget(self.searchEdit)
|
||||||
return self.container, self.searchEdit
|
return self.container, self.searchEdit
|
||||||
@@ -836,75 +768,10 @@ class MainWindow(QMainWindow):
|
|||||||
def startSearchDebounce(self, text):
|
def startSearchDebounce(self, text):
|
||||||
self.searchDebounceTimer.start()
|
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):
|
def createInstalledTab(self):
|
||||||
self.gamesLibraryWidget = QWidget()
|
self.gamesLibraryWidget = self.game_library_manager.create_games_library_widget()
|
||||||
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.stackedWidget.addWidget(self.gamesLibraryWidget)
|
self.stackedWidget.addWidget(self.gamesLibraryWidget)
|
||||||
self.updateGameGrid()
|
self.game_library_manager.update_game_grid()
|
||||||
|
|
||||||
def resizeEvent(self, event):
|
def resizeEvent(self, event):
|
||||||
super().resizeEvent(event)
|
super().resizeEvent(event)
|
||||||
@@ -921,136 +788,8 @@ class MainWindow(QMainWindow):
|
|||||||
self._last_width = self.width()
|
self._last_width = self.width()
|
||||||
if abs(self.width() - self._last_width) > 10:
|
if abs(self.width() - self._last_width) > 10:
|
||||||
self._last_width = self.width()
|
self._last_width = self.width()
|
||||||
|
self._update_preloader_position()
|
||||||
|
|
||||||
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):
|
def dragEnterEvent(self, event):
|
||||||
if event.mimeData().hasUrls():
|
if event.mimeData().hasUrls():
|
||||||
@@ -1068,26 +807,22 @@ class MainWindow(QMainWindow):
|
|||||||
break
|
break
|
||||||
|
|
||||||
def openAddGameDialog(self, exe_path=None):
|
def openAddGameDialog(self, exe_path=None):
|
||||||
"""Открывает диалоговое окно 'Add Game' с текущей темой."""
|
|
||||||
# Проверяем, открыт ли уже диалог
|
|
||||||
if self.current_add_game_dialog is not None and self.current_add_game_dialog.isVisible():
|
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.activateWindow()
|
||||||
self.current_add_game_dialog.raise_() # Поднимаем окно
|
self.current_add_game_dialog.raise_()
|
||||||
return
|
return
|
||||||
|
|
||||||
dialog = AddGameDialog(self, self.theme)
|
dialog = AddGameDialog(self, self.theme)
|
||||||
dialog.setFocus(Qt.FocusReason.OtherFocusReason)
|
dialog.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||||
self.current_add_game_dialog = dialog # Сохраняем ссылку на диалог
|
self.current_add_game_dialog = dialog
|
||||||
|
|
||||||
# Предзаполняем путь к .exe при drag-and-drop
|
|
||||||
if exe_path:
|
if exe_path:
|
||||||
dialog.exeEdit.setText(exe_path)
|
dialog.exeEdit.setText(exe_path)
|
||||||
dialog.nameEdit.setText(os.path.splitext(os.path.basename(exe_path))[0])
|
dialog.nameEdit.setText(os.path.splitext(os.path.basename(exe_path))[0])
|
||||||
dialog.updatePreview()
|
dialog.updatePreview()
|
||||||
|
|
||||||
# Обработчик закрытия диалога
|
|
||||||
def on_dialog_finished():
|
def on_dialog_finished():
|
||||||
self.current_add_game_dialog = None # Сбрасываем ссылку при закрытии
|
self.current_add_game_dialog = None
|
||||||
|
|
||||||
dialog.finished.connect(on_dialog_finished)
|
dialog.finished.connect(on_dialog_finished)
|
||||||
|
|
||||||
@@ -1099,33 +834,124 @@ class MainWindow(QMainWindow):
|
|||||||
if not name or not exe_path:
|
if not name or not exe_path:
|
||||||
return
|
return
|
||||||
|
|
||||||
# Сохраняем .desktop файл
|
|
||||||
desktop_entry, desktop_path = dialog.getDesktopEntryData()
|
desktop_entry, desktop_path = dialog.getDesktopEntryData()
|
||||||
if desktop_entry and desktop_path:
|
if desktop_entry and desktop_path:
|
||||||
with open(desktop_path, "w", encoding="utf-8") as f:
|
with open(desktop_path, "w", encoding="utf-8") as f:
|
||||||
f.write(desktop_entry)
|
f.write(desktop_entry)
|
||||||
os.chmod(desktop_path, 0o755)
|
os.chmod(desktop_path, 0o755)
|
||||||
|
|
||||||
# Проверяем путь обложки, если он отличается от стандартной
|
exe_name = os.path.splitext(os.path.basename(exe_path))[0]
|
||||||
if os.path.isfile(user_cover):
|
xdg_data_home = os.getenv("XDG_DATA_HOME",
|
||||||
exe_name = os.path.splitext(os.path.basename(exe_path))[0]
|
os.path.join(os.path.expanduser("~"), ".local", "share"))
|
||||||
xdg_data_home = os.getenv("XDG_DATA_HOME",
|
custom_folder = os.path.join(
|
||||||
os.path.join(os.path.expanduser("~"), ".local", "share"))
|
xdg_data_home,
|
||||||
custom_folder = os.path.join(
|
"PortProtonQt",
|
||||||
xdg_data_home,
|
"custom_data",
|
||||||
"PortProtonQt",
|
exe_name
|
||||||
"custom_data",
|
)
|
||||||
exe_name
|
os.makedirs(custom_folder, exist_ok=True)
|
||||||
)
|
|
||||||
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()
|
ext = os.path.splitext(user_cover)[1].lower()
|
||||||
if ext in [".png", ".jpg", ".jpeg", ".bmp"]:
|
if os.path.isfile(user_cover) and ext in [".png", ".jpg", ".jpeg", ".bmp"]:
|
||||||
shutil.copyfile(user_cover, os.path.join(custom_folder, f"cover{ext}"))
|
copied_cover = os.path.join(custom_folder, f"cover{ext}")
|
||||||
|
shutil.copyfile(user_cover, copied_cover)
|
||||||
|
cover_path = copied_cover
|
||||||
|
|
||||||
self.games = self.loadGames()
|
# Parse .desktop (adapt from _process_desktop_file_async)
|
||||||
self.updateGameGrid()
|
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):
|
def createAutoInstallTab(self):
|
||||||
"""Вкладка 'Auto Install'."""
|
"""Вкладка 'Auto Install'."""
|
||||||
@@ -1487,18 +1313,14 @@ class MainWindow(QMainWindow):
|
|||||||
self.statusBar().showMessage(_("Cache cleared"), 3000)
|
self.statusBar().showMessage(_("Cache cleared"), 3000)
|
||||||
|
|
||||||
def applySettingsDelayed(self):
|
def applySettingsDelayed(self):
|
||||||
"""Applies settings with the new filter and updates the game list."""
|
|
||||||
read_time_config()
|
read_time_config()
|
||||||
self.games = []
|
self.games = []
|
||||||
self.loadGames()
|
self.loadGames()
|
||||||
display_filter = read_display_filter()
|
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)
|
card.update_badge_visibility(display_filter)
|
||||||
|
|
||||||
def savePortProtonSettings(self):
|
def savePortProtonSettings(self):
|
||||||
"""
|
|
||||||
Сохраняет параметры конфигурации в конфигурационный файл.
|
|
||||||
"""
|
|
||||||
time_idx = self.timeDetailCombo.currentIndex()
|
time_idx = self.timeDetailCombo.currentIndex()
|
||||||
time_key = self.time_keys[time_idx]
|
time_key = self.time_keys[time_idx]
|
||||||
save_time_config(time_key)
|
save_time_config(time_key)
|
||||||
@@ -1511,7 +1333,6 @@ class MainWindow(QMainWindow):
|
|||||||
filter_key = self.filter_keys[filter_idx]
|
filter_key = self.filter_keys[filter_idx]
|
||||||
save_display_filter(filter_key)
|
save_display_filter(filter_key)
|
||||||
|
|
||||||
# Сохранение proxy настроек
|
|
||||||
proxy_url = self.proxyUrlEdit.text().strip()
|
proxy_url = self.proxyUrlEdit.text().strip()
|
||||||
proxy_user = self.proxyUserEdit.text().strip()
|
proxy_user = self.proxyUserEdit.text().strip()
|
||||||
proxy_password = self.proxyPasswordEdit.text().strip()
|
proxy_password = self.proxyPasswordEdit.text().strip()
|
||||||
@@ -1523,11 +1344,10 @@ class MainWindow(QMainWindow):
|
|||||||
auto_fullscreen_gamepad = self.autoFullscreenGamepadCheckBox.isChecked()
|
auto_fullscreen_gamepad = self.autoFullscreenGamepadCheckBox.isChecked()
|
||||||
save_auto_fullscreen_gamepad(auto_fullscreen_gamepad)
|
save_auto_fullscreen_gamepad(auto_fullscreen_gamepad)
|
||||||
|
|
||||||
# Сохранение настройки виброотдачи геймпада
|
|
||||||
rumble_enabled = self.gamepadRumbleCheckBox.isChecked()
|
rumble_enabled = self.gamepadRumbleCheckBox.isChecked()
|
||||||
save_rumble_config(rumble_enabled)
|
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)
|
card.update_badge_visibility(filter_key)
|
||||||
|
|
||||||
if self.currentDetailPage and self.current_exec_line:
|
if self.currentDetailPage and self.current_exec_line:
|
||||||
@@ -1540,14 +1360,12 @@ class MainWindow(QMainWindow):
|
|||||||
|
|
||||||
self.settingsDebounceTimer.start()
|
self.settingsDebounceTimer.start()
|
||||||
|
|
||||||
# Управление полноэкранным режимом
|
|
||||||
gamepad_connected = self.input_manager.find_gamepad() is not None
|
gamepad_connected = self.input_manager.find_gamepad() is not None
|
||||||
if fullscreen or (auto_fullscreen_gamepad and gamepad_connected):
|
if fullscreen or (auto_fullscreen_gamepad and gamepad_connected):
|
||||||
self.showFullScreen()
|
self.showFullScreen()
|
||||||
else:
|
else:
|
||||||
# Если обе галочки сняты и геймпад не подключен, возвращаем нормальное состояние
|
|
||||||
self.showNormal()
|
self.showNormal()
|
||||||
self.resize(*read_window_geometry()) # Восстанавливаем сохраненные размеры окна
|
self.resize(*read_window_geometry())
|
||||||
|
|
||||||
self.statusBar().showMessage(_("Settings saved"), 3000)
|
self.statusBar().showMessage(_("Settings saved"), 3000)
|
||||||
|
|
||||||
@@ -2129,7 +1947,7 @@ class MainWindow(QMainWindow):
|
|||||||
favorites.append(game_name)
|
favorites.append(game_name)
|
||||||
label.setText("★")
|
label.setText("★")
|
||||||
save_favorites(favorites)
|
save_favorites(favorites)
|
||||||
self.updateGameGrid()
|
self.game_library_manager.update_game_grid()
|
||||||
|
|
||||||
def activateFocusedWidget(self):
|
def activateFocusedWidget(self):
|
||||||
"""Activate the currently focused widget."""
|
"""Activate the currently focused widget."""
|
||||||
|
50
portprotonqt/preloader.py
Normal file
50
portprotonqt/preloader.py
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import time
|
||||||
|
|
||||||
|
from PySide6.QtCore import QRect
|
||||||
|
from PySide6.QtGui import QPainter, QPen, QBrush, Qt, QColor, QConicalGradient
|
||||||
|
from PySide6.QtWidgets import QWidget
|
||||||
|
|
||||||
|
|
||||||
|
class Preloader(QWidget):
|
||||||
|
def __init__(self, speed=180.0, line_line_width=20, color=QColor(0, 120, 215), parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.setFixedSize(150, 150)
|
||||||
|
self._speed = speed
|
||||||
|
self._line_width = line_line_width
|
||||||
|
self._color1 = color
|
||||||
|
self._color2 = QColor(color.red(), color.green(), color.blue(), 0)
|
||||||
|
self._start_time = time.time()
|
||||||
|
|
||||||
|
def showEvent(self, event):
|
||||||
|
self._start_time = time.time()
|
||||||
|
|
||||||
|
def paintEvent(self, event):
|
||||||
|
rect = self._get_preloader_rect()
|
||||||
|
center = rect.center()
|
||||||
|
painter = QPainter(self)
|
||||||
|
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||||
|
painter.setPen(self._get_pen())
|
||||||
|
painter.translate(center)
|
||||||
|
painter.rotate(self._get_angle())
|
||||||
|
painter.translate(-center)
|
||||||
|
painter.drawArc(rect, 0, 270 * 16)
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
def _get_pen(self) -> QPen:
|
||||||
|
gradient = QConicalGradient()
|
||||||
|
gradient.setCenter(self.rect().center())
|
||||||
|
gradient.setColorAt(0, self._color1)
|
||||||
|
gradient.setColorAt(1, self._color2)
|
||||||
|
pen = QPen(QBrush(gradient), self._line_width)
|
||||||
|
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
|
||||||
|
return pen
|
||||||
|
|
||||||
|
def _get_angle(self) -> float:
|
||||||
|
duration = time.time() - self._start_time
|
||||||
|
return (self._speed * duration) % 360.0
|
||||||
|
|
||||||
|
def _get_preloader_rect(self) -> QRect:
|
||||||
|
size = self._line_width // 2
|
||||||
|
rect = self.rect()
|
||||||
|
rect.adjust(size, size, -size, -size)
|
||||||
|
return rect
|
Reference in New Issue
Block a user