3 Commits

Author SHA1 Message Date
48048a3f50 feat: replace QFileDialog with custom FileExplorer for Legendary import
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-06-30 00:25:28 +05:00
7c617eef78 chore(changelog): update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-06-29 23:25:27 +05:00
08ba801f74 fix: prevent empty area when updating game grid thank to @Vector_null
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-06-29 23:23:22 +05:00
5 changed files with 201 additions and 136 deletions

View File

@@ -26,6 +26,7 @@
- Ошибки темы в нативном пакете - Ошибки темы в нативном пакете
- Ошибки темы в Gamescope - Ошибки темы в Gamescope
- Размер иконок для desktop файлов теперь 128x128 - Размер иконок для desktop файлов теперь 128x128
- Пустая область при обновлении сетки игр
### Contributors ### Contributors
- @Dervart - @Dervart

View File

@@ -6,14 +6,14 @@ import subprocess
import threading import threading
import logging import logging
import orjson import orjson
from PySide6.QtWidgets import QMessageBox, QDialog, QMenu, QFileDialog from PySide6.QtWidgets import QMessageBox, QDialog, QMenu
from PySide6.QtCore import QUrl, QPoint, QObject, Signal, Qt from PySide6.QtCore import QUrl, QPoint, QObject, Signal, Qt
from PySide6.QtGui import QDesktopServices from PySide6.QtGui import QDesktopServices
from portprotonqt.localization import _ from portprotonqt.localization import _
from portprotonqt.config_utils import parse_desktop_entry, read_favorites, save_favorites from portprotonqt.config_utils import parse_desktop_entry, read_favorites, save_favorites
from portprotonqt.steam_api import is_game_in_steam, add_to_steam, remove_from_steam from portprotonqt.steam_api import is_game_in_steam, add_to_steam, remove_from_steam
from portprotonqt.egs_api import add_egs_to_steam, get_egs_executable, remove_egs_from_steam from portprotonqt.egs_api import add_egs_to_steam, get_egs_executable, remove_egs_from_steam
from portprotonqt.dialogs import AddGameDialog, generate_thumbnail from portprotonqt.dialogs import AddGameDialog, FileExplorer, generate_thumbnail
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -282,35 +282,56 @@ class ContextMenuManager:
""" """
if not self._check_portproton(): if not self._check_portproton():
return return
folder_path = QFileDialog.getExistingDirectory(
self.parent, _("Select Game Installation Folder"), os.path.expanduser("~")
)
if not folder_path:
self._show_status_message(_("No folder selected"))
return
if not os.path.exists(self.legendary_path): if not os.path.exists(self.legendary_path):
self.signals.show_warning_dialog.emit( self.signals.show_warning_dialog.emit(
_("Error"), _("Error"),
_("Legendary executable not found at {path}").format(path=self.legendary_path) _("Legendary executable not found at {path}").format(path=self.legendary_path)
) )
return return
def run_import():
cmd = [self.legendary_path, "import", app_name, folder_path] # Используем FileExplorer с directory_only=True
try: file_explorer = FileExplorer(
subprocess.run(cmd, capture_output=True, text=True, check=True) parent=self.parent,
self.signals.show_info_dialog.emit( theme=self.theme,
_("Success"), initial_path=os.path.expanduser("~"),
_("Imported '{game_name}' to Legendary").format(game_name=game_name) directory_only=True
) )
except subprocess.CalledProcessError as e:
self.signals.show_warning_dialog.emit( def on_folder_selected(folder_path):
_("Error"), if not folder_path:
_("Failed to import '{game_name}' to Legendary: {error}").format( self._show_status_message(_("No folder selected"))
game_name=game_name, error=e.stderr return
def run_import():
cmd = [self.legendary_path, "import", app_name, folder_path]
try:
subprocess.run(cmd, capture_output=True, text=True, check=True)
self.signals.show_info_dialog.emit(
_("Success"),
_("Imported '{game_name}' to Legendary").format(game_name=game_name)
) )
) except subprocess.CalledProcessError as e:
self._show_status_message(_("Importing '{game_name}' to Legendary...").format(game_name=game_name)) self.signals.show_warning_dialog.emit(
threading.Thread(target=run_import, daemon=True).start() _("Error"),
_("Failed to import '{game_name}' to Legendary: {error}").format(
game_name=game_name, error=e.stderr
)
)
self._show_status_message(_("Importing '{game_name}' to Legendary...").format(game_name=game_name))
threading.Thread(target=run_import, daemon=True).start()
# Подключаем сигнал выбора файла/папки
file_explorer.file_signal.file_selected.connect(on_folder_selected)
# Центрируем FileExplorer относительно родительского виджета
parent_widget = self.parent
if parent_widget:
parent_geometry = parent_widget.geometry()
center_point = parent_geometry.center()
file_explorer_geometry = file_explorer.geometry()
file_explorer_geometry.moveCenter(center_point)
file_explorer.setGeometry(file_explorer_geometry)
file_explorer.show()
def toggle_favorite(self, game_card, add: bool): def toggle_favorite(self, game_card, add: bool):
""" """

View File

@@ -88,12 +88,13 @@ class FileSelectedSignal(QObject):
file_selected = Signal(str) # Сигнал с путем к выбранному файлу file_selected = Signal(str) # Сигнал с путем к выбранному файлу
class FileExplorer(QDialog): class FileExplorer(QDialog):
def __init__(self, parent=None, theme=None, file_filter=None, initial_path=None): def __init__(self, parent=None, theme=None, file_filter=None, initial_path=None, directory_only=False):
super().__init__(parent) super().__init__(parent)
self.theme = theme if theme else default_styles self.theme = theme if theme else default_styles
self.theme_manager = ThemeManager() self.theme_manager = ThemeManager()
self.file_signal = FileSelectedSignal() self.file_signal = FileSelectedSignal()
self.file_filter = file_filter # Store the file filter self.file_filter = file_filter # Store the file filter
self.directory_only = directory_only # Store the directory_only flag
self.mime_db = QMimeDatabase() # Initialize QMimeDatabase for mimetype detection self.mime_db = QMimeDatabase() # Initialize QMimeDatabase for mimetype detection
self.path_history = {} # Dictionary to store last selected item per directory self.path_history = {} # Dictionary to store last selected item per directory
self.initial_path = initial_path # Store initial path if provided self.initial_path = initial_path # Store initial path if provided
@@ -216,13 +217,21 @@ class FileExplorer(QDialog):
full_path = os.path.join(self.current_path, selected) full_path = os.path.join(self.current_path, selected)
if os.path.isdir(full_path): if os.path.isdir(full_path):
# Если выбрана директория, нормализуем путь if self.directory_only:
self.current_path = os.path.normpath(full_path) # Подтверждаем выбор директории
self.update_file_list() self.file_signal.file_selected.emit(os.path.normpath(full_path))
self.accept()
else:
# Открываем директорию
self.current_path = os.path.normpath(full_path)
self.update_file_list()
else: else:
# Для файла отправляем нормализованный путь if not self.directory_only:
self.file_signal.file_selected.emit(os.path.normpath(full_path)) # Для файла отправляем нормализованный путь
self.accept() self.file_signal.file_selected.emit(os.path.normpath(full_path))
self.accept()
else:
logger.debug("Selected item is not a directory, ignoring: %s", full_path)
def previous_dir(self): def previous_dir(self):
"""Возврат к родительской директории""" """Возврат к родительской директории"""
@@ -288,14 +297,7 @@ class FileExplorer(QDialog):
items = os.listdir(self.current_path) items = os.listdir(self.current_path)
dirs = [d for d in items if os.path.isdir(os.path.join(self.current_path, d))] dirs = [d for d in items if os.path.isdir(os.path.join(self.current_path, d))]
# Apply file filter if provided # Добавляем директории
files = [f for f in items if os.path.isfile(os.path.join(self.current_path, f))]
if self.file_filter:
if isinstance(self.file_filter, str):
files = [f for f in files if f.lower().endswith(self.file_filter)]
elif isinstance(self.file_filter, tuple):
files = [f for f in files if any(f.lower().endswith(ext) for ext in self.file_filter)]
for d in sorted(dirs): for d in sorted(dirs):
item = QListWidgetItem(f"{d}/") item = QListWidgetItem(f"{d}/")
folder_icon = self.theme_manager.get_icon("folder") folder_icon = self.theme_manager.get_icon("folder")
@@ -307,25 +309,34 @@ class FileExplorer(QDialog):
item.setIcon(folder_icon) item.setIcon(folder_icon)
self.file_list.addItem(item) self.file_list.addItem(item)
for f in sorted(files): # Добавляем файлы только если directory_only=False
item = QListWidgetItem(f) if not self.directory_only:
file_path = os.path.join(self.current_path, f) files = [f for f in items if os.path.isfile(os.path.join(self.current_path, f))]
mime_type = self.mime_db.mimeTypeForFile(file_path).name() if self.file_filter:
if isinstance(self.file_filter, str):
files = [f for f in files if f.lower().endswith(self.file_filter)]
elif isinstance(self.file_filter, tuple):
files = [f for f in files if any(f.lower().endswith(ext) for ext in self.file_filter)]
if mime_type.startswith("image/"): for f in sorted(files):
pixmap = QPixmap(file_path) item = QListWidgetItem(f)
if not pixmap.isNull(): file_path = os.path.join(self.current_path, f)
item.setIcon(QIcon(pixmap.scaled(64, 64, Qt.AspectRatioMode.KeepAspectRatio))) mime_type = self.mime_db.mimeTypeForFile(file_path).name()
elif file_path.lower().endswith(".exe"):
tmp = tempfile.NamedTemporaryFile(suffix='.png', delete=False) if mime_type.startswith("image/"):
tmp.close() pixmap = QPixmap(file_path)
if generate_thumbnail(file_path, tmp.name, size=64):
pixmap = QPixmap(tmp.name)
if not pixmap.isNull(): if not pixmap.isNull():
item.setIcon(QIcon(pixmap)) item.setIcon(QIcon(pixmap.scaled(64, 64, Qt.AspectRatioMode.KeepAspectRatio)))
os.unlink(tmp.name) elif file_path.lower().endswith(".exe"):
tmp = tempfile.NamedTemporaryFile(suffix='.png', delete=False)
tmp.close()
if generate_thumbnail(file_path, tmp.name, size=64):
pixmap = QPixmap(tmp.name)
if not pixmap.isNull():
item.setIcon(QIcon(pixmap))
os.unlink(tmp.name)
self.file_list.addItem(item) self.file_list.addItem(item)
self.path_label.setText(_("Path: ") + self.current_path) self.path_label.setText(_("Path: ") + self.current_path)

View File

@@ -1,5 +1,6 @@
import time import time
import threading import threading
import os
from typing import Protocol, cast from typing import Protocol, cast
from evdev import InputDevice, InputEvent, ecodes, list_devices, ff from evdev import InputDevice, InputEvent, ecodes, list_devices, ff
from pyudev import Context, Monitor, MonitorObserver, Device from pyudev import Context, Monitor, MonitorObserver, Device
@@ -162,8 +163,28 @@ class InputManager(QObject):
if not self.file_explorer or not hasattr(self.file_explorer, 'file_list'): if not self.file_explorer or not hasattr(self.file_explorer, 'file_list'):
return return
if button_code in BUTTONS['confirm']: if button_code in BUTTONS['add_game']:
self.file_explorer.select_item() if self.file_explorer.file_list.count() == 0:
return
selected = self.file_explorer.file_list.currentItem().text()
full_path = os.path.join(self.file_explorer.current_path, selected)
if os.path.isdir(full_path):
# Подтверждаем выбор директории
self.file_explorer.file_signal.file_selected.emit(os.path.normpath(full_path))
self.file_explorer.accept()
else:
logger.debug("Selected item is not a directory: %s", full_path)
elif button_code in BUTTONS['confirm']:
if self.file_explorer.file_list.count() == 0:
return
selected = self.file_explorer.file_list.currentItem().text()
full_path = os.path.join(self.file_explorer.current_path, selected)
if os.path.isdir(full_path):
# Открываем директорию
self.file_explorer.current_path = os.path.normpath(full_path)
self.file_explorer.update_file_list()
else:
logger.debug("Selected item is not a directory, cannot open: %s", full_path)
elif button_code in BUTTONS['back']: elif button_code in BUTTONS['back']:
self.file_explorer.close() self.file_explorer.close()
elif button_code in BUTTONS['prev_dir']: elif button_code in BUTTONS['prev_dir']:

View File

@@ -696,98 +696,109 @@ class MainWindow(QMainWindow):
loaded_count += 1 loaded_count += 1
def updateGameGrid(self, games_list=None): def updateGameGrid(self, games_list=None):
"""Updates the game grid with the provided games list or self.games.""" """Обновляет сетку игровых карточек с сохранением порядка сортировки"""
if games_list is None: # Подготовка данных
games_list = self.games games_list = games_list if games_list is not None else self.games
if not games_list: search_text = self.searchEdit.text().strip().lower()
# Скрываем все карточки, если список пуст favorites = read_favorites()
for card in self.game_card_cache.values(): sort_method = read_sort_method()
card.hide()
self.game_card_cache.clear()
self.pending_images.clear()
self.gamesListWidget.updateGeometry()
return
# Создаем словарь текущих игр с уникальным ключом (name + exec_line) # Сортируем игры согласно текущим настройкам
current_games = {(game_data[0], game_data[4]): game_data for game_data in games_list} def sort_key(game):
name = game[0]
# Избранные всегда первые
if name in favorites:
fav_order = 0
else:
fav_order = 1
# Проверяем, изменился ли список игр или размер карточек if sort_method == "playtime":
current_game_keys = set(current_games.keys()) return (fav_order, -game[11], -game[10]) # playtime_seconds, last_launch_ts
cached_game_keys = set(self.game_card_cache.keys()) elif sort_method == "alphabetical":
card_width_changed = self.card_width != getattr(self, '_last_card_width', None) 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
if current_game_keys == cached_game_keys and not card_width_changed: sorted_games = sorted(games_list, key=sort_key)
# Список игр и размер карточек не изменились, обновляем только видимость
search_text = self.searchEdit.text().strip().lower()
for game_key, card in self.game_card_cache.items():
game_name = game_key[0]
card.setVisible(search_text in game_name.lower() or not search_text)
self.loadVisibleImages()
return
# Обновляем размер карточек, если он изменился # Создаем временный список для новых карточек
if card_width_changed: new_card_order = []
for card in self.game_card_cache.values():
card.setFixedWidth(self.card_width + 20) # Учитываем extra_margin в GameCard
# Удаляем карточки, которых больше нет в списке # Обрабатываем каждую игру в отсортированном порядке
layout_changed = False 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()): for card_key in list(self.game_card_cache.keys()):
if card_key not in current_games: if card_key not in existing_keys:
card = self.game_card_cache.pop(card_key) card = self.game_card_cache.pop(card_key)
self.gamesListLayout.removeWidget(card)
card.deleteLater() card.deleteLater()
if card_key in self.pending_images: if card_key in self.pending_images:
del self.pending_images[card_key] del self.pending_images[card_key]
layout_changed = True
# Добавляем новые карточки и обновляем существующие
for game_data in games_list:
game_name = game_data[0]
game_key = (game_name, game_data[4])
search_text = self.searchEdit.text().strip().lower()
should_be_visible = search_text in game_name.lower() or not search_text
if game_key not in self.game_card_cache:
# Создаем новую карточку
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
self.gamesListLayout.addWidget(card)
layout_changed = True
else:
# Обновляем видимость существующей карточки
card = self.game_card_cache[game_key]
card.setVisible(should_be_visible)
# Сохраняем текущий card_width
self._last_card_width = self.card_width
# Принудительно обновляем макет # Принудительно обновляем макет
if layout_changed: self.gamesListLayout.update()
self.gamesListLayout.update() self.gamesListWidget.updateGeometry()
self.gamesListWidget.updateGeometry() self.gamesListWidget.update()
self.gamesListWidget.update()
# Загружаем изображения для видимых карточек # Сохраняем текущий размер карточек
self.loadVisibleImages() self._last_card_width = self.card_width
def clearLayout(self, layout): def clearLayout(self, layout):
"""Удаляет все виджеты из layout.""" """Удаляет все виджеты из layout."""