forked from Boria138/PortProtonQt
feat: optimize add and remove game
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
This commit is contained in:
@@ -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."""
|
||||
|
@@ -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):
|
||||
|
@@ -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'."""
|
||||
|
Reference in New Issue
Block a user