feat: optimize add and remove game

Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
This commit is contained in:
2025-10-01 11:10:59 +05:00
parent 081cd07253
commit cb76961e4f
3 changed files with 303 additions and 82 deletions

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

@@ -6,6 +6,7 @@ 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."""
@@ -48,6 +49,10 @@ class GameLibraryManager:
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."""
@@ -91,6 +96,12 @@ class GameLibraryManager:
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:
@@ -183,56 +194,161 @@ class GameLibraryManager:
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 = games_list if games_list is not None else self.games
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()
def sort_key(game):
name = game[0]
fav_order = 0 if name in favorites else 1
if sort_method == "playtime":
return (fav_order, -game[11], -game[10])
elif sort_method == "alphabetical":
return (fav_order, name.lower())
elif sort_method == "favorites":
return (fav_order,)
# 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:
return (fav_order, -game[10], -game[11])
current_layout_order = None # Skip reorg if not dirty
sorted_games = sorted(games_list, key=sort_key)
new_card_order = []
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()
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
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
if self.context_menu_manager is None:
continue # Skip card creation if context_menu_manager is None
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))
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
)
# 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)
card.hoverChanged.connect(self._on_card_hovered)
card.focusChanged.connect(self._on_card_focused)
# 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)
@@ -243,33 +359,13 @@ class GameLibraryManager:
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)
return card
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.load_visible_images()
existing_keys = {game_key for game_key, card 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.main_window._last_card_width = self.card_width
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."""
@@ -290,6 +386,28 @@ class GameLibraryManager:
"""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):

View File

@@ -79,7 +79,7 @@ class MainWindow(QMainWindow):
self.portproton_location,
self.theme,
self.loadGames,
self.game_library_manager.update_game_grid
self.game_library_manager
)
self.game_library_manager.context_menu_manager = self.context_menu_manager
@@ -826,23 +826,118 @@ class MainWindow(QMainWindow):
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)
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}"))
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)
self.games = self.loadGames()
self.game_library_manager.update_game_grid()
# Handle user cover copy
cover_path = None
if user_cover:
ext = os.path.splitext(user_cover)[1].lower()
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
# 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'."""