4 Commits

Author SHA1 Message Date
baec62d1cb chore(changelog): update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-01 11:19:52 +05:00
cb76961e4f feat: optimize add and remove game
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-01 11:19:37 +05:00
Gitea Actions
081cd07253 chore: update steam apps list 2025-10-01T00:01:41Z 2025-10-01 00:01:42 +00:00
b5efee29ea chore: cleanup MainWindow class
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-30 15:43:11 +05:00
10 changed files with 12588 additions and 1138 deletions

View File

@@ -6,11 +6,14 @@
## [Unreleased]
### Added
- Возможность скроллинга библиотеки мышью или пальцем
### Changed
- Проведён рефакторинг и оптимизация всего что связано с карточками и библиотекой игр
### Fixed
- Исправлен вылет диалога выбора файлов при выборе обложки если в папке более сотни изображений
- Исправлено зависание при добавлении или удалении игры в Wayland
### Contributors
@@ -29,12 +32,12 @@
### Changed
- Управления с геймпада теперь перехватывается только если окно в фокусе
### Fixed
- Исправлена проблема с устаревшими кэш-файлами, вызывающими несоответствия при обновлении JSON
- Исправлено переключение в полноэкранный режим при нажатии кнопки "Select во время запущенной игры
### Contributors
- @wmigor (Igor Akulov)
---

View File

@@ -217,7 +217,7 @@
},
{
"normalized_name": "watch_dogs 2",
"status": "Broken"
"status": "Running"
},
{
"normalized_name": "zero hour",

Binary file not shown.

File diff suppressed because it is too large Load Diff

Binary file not shown.

View File

@@ -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 directors 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.

View File

@@ -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."""

View 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)

View File

@@ -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,9 +34,11 @@ 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,
QDialog, QFormLayout, QFrame, QGraphicsDropShadowEffect, QMessageBox, QApplication, QPushButton, QProgressBar, QCheckBox, QSizePolicy, QScroller)
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
from typing import cast
@@ -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,12 +743,11 @@ 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.game_library_manager.filter_games_delayed)
layout.addWidget(self.searchEdit)
return self.container, self.searchEdit
@@ -836,76 +755,10 @@ class MainWindow(QMainWindow):
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)
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)
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)
@@ -923,135 +776,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():
@@ -1069,26 +793,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)
@@ -1100,33 +820,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'."""
@@ -1488,18 +1299,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)
@@ -1512,7 +1319,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()
@@ -1524,11 +1330,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:
@@ -1541,14 +1346,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)
@@ -2130,7 +1933,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."""