From cb76961e4ffff364c80ed249a3fb807b59c28346 Mon Sep 17 00:00:00 2001 From: Boris Yumankulov Date: Wed, 1 Oct 2025 11:10:59 +0500 Subject: [PATCH] feat: optimize add and remove game Signed-off-by: Boris Yumankulov --- portprotonqt/context_menu_manager.py | 18 +- portprotonqt/game_library_manager.py | 238 ++++++++++++++++++++------- portprotonqt/main_window.py | 129 +++++++++++++-- 3 files changed, 303 insertions(+), 82 deletions(-) diff --git a/portprotonqt/context_menu_manager.py b/portprotonqt/context_menu_manager.py index 97fb6a8..a381c34 100644 --- a/portprotonqt/context_menu_manager.py +++ b/portprotonqt/context_menu_manager.py @@ -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.""" diff --git a/portprotonqt/game_library_manager.py b/portprotonqt/game_library_manager.py index 44e1fa6..9ad61f3 100644 --- a/portprotonqt/game_library_manager.py +++ b/portprotonqt/game_library_manager.py @@ -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): diff --git a/portprotonqt/main_window.py b/portprotonqt/main_window.py index 9ed8d06..3473088 100644 --- a/portprotonqt/main_window.py +++ b/portprotonqt/main_window.py @@ -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'."""