Files
PortProtonQt/portprotonqt/dialogs.py
Boris Yumankulov b4564ef613
All checks were successful
Code and build check / Check code (push) Successful in 1m34s
Code and build check / Build with uv (push) Successful in 51s
feat(FileExplorer): add path history and cursor retention
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-06-29 10:00:24 +05:00

593 lines
26 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import os
import tempfile
from typing import cast, TYPE_CHECKING
from PySide6.QtGui import QPixmap, QIcon
from PySide6.QtWidgets import (
QDialog, QLineEdit, QFormLayout, QPushButton,
QHBoxLayout, QLabel, QVBoxLayout, QListWidget, QScrollArea, QWidget, QListWidgetItem
)
from PySide6.QtCore import Qt, QObject, Signal, QMimeDatabase
from icoextract import IconExtractor, IconExtractorError
from PIL import Image
from portprotonqt.config_utils import get_portproton_location
from portprotonqt.localization import _
from portprotonqt.logger import get_logger
import portprotonqt.themes.standart.styles as default_styles
from portprotonqt.theme_manager import ThemeManager
from portprotonqt.custom_widgets import AutoSizeButton
if TYPE_CHECKING:
from portprotonqt.main_window import MainWindow
logger = get_logger(__name__)
def generate_thumbnail(inputfile, outfile, size=128, force_resize=True):
"""
Generates a thumbnail for an .exe file.
inputfile: the input file path (%i)
outfile: output filename (%o)
size: determines the thumbnail output size (%s)
"""
logger.debug(f"Начинаем генерацию миниатюры: {inputfile}{outfile}, размер={size}, принудительно={force_resize}")
try:
extractor = IconExtractor(inputfile)
logger.debug("IconExtractor успешно создан.")
except (RuntimeError, IconExtractorError) as e:
logger.warning(f"Не удалось создать IconExtractor: {e}")
return False
try:
data = extractor.get_icon()
im = Image.open(data)
logger.debug(f"Извлечена иконка размером {im.size}, форматы: {im.format}, кадры: {getattr(im, 'n_frames', 1)}")
except Exception as e:
logger.warning(f"Ошибка при извлечении иконки: {e}")
return False
if force_resize:
logger.debug(f"Принудительное изменение размера иконки на {size}x{size}")
im = im.resize((size, size))
else:
if size > 256:
logger.warning('Запрошен размер больше 256, установлен 256')
size = 256
elif size not in (128, 256):
logger.warning(f'Неподдерживаемый размер {size}, установлен 128')
size = 128
if size == 256:
logger.debug("Сохраняем иконку без изменения размера (256x256)")
im.save(outfile, "PNG")
logger.info(f"Иконка сохранена в {outfile}")
return True
frames = getattr(im, 'n_frames', 1)
try:
for frame in range(frames):
im.seek(frame)
if im.size == (size, size):
logger.debug(f"Найден кадр с размером {size}x{size}")
break
except EOFError:
logger.debug("Кадры закончились до нахождения нужного размера.")
if im.size != (size, size):
logger.debug(f"Изменение размера с {im.size} на {size}x{size}")
im = im.resize((size, size))
try:
im.save(outfile, "PNG")
logger.info(f"Миниатюра успешно сохранена в {outfile}")
return True
except Exception as e:
logger.error(f"Ошибка при сохранении миниатюры: {e}")
return False
class FileSelectedSignal(QObject):
file_selected = Signal(str) # Сигнал с путем к выбранному файлу
class FileExplorer(QDialog):
def __init__(self, parent=None, theme=None, file_filter=None, initial_path=None):
super().__init__(parent)
self.theme = theme if theme else default_styles
self.theme_manager = ThemeManager()
self.file_signal = FileSelectedSignal()
self.file_filter = file_filter # Store the file filter
self.mime_db = QMimeDatabase() # Initialize QMimeDatabase for mimetype detection
self.path_history = {} # Dictionary to store last selected item per directory
self.initial_path = initial_path # Store initial path if provided
self.setup_ui()
# Настройки окна
self.setWindowModality(Qt.WindowModality.ApplicationModal)
self.setWindowFlags(self.windowFlags() & ~Qt.WindowType.WindowContextHelpButtonHint)
# Find InputManager from parent
self.input_manager = None
parent = self.parent()
while parent:
if hasattr(parent, 'input_manager'):
self.input_manager = cast("MainWindow", parent).input_manager
break
parent = parent.parent()
if self.input_manager:
self.input_manager.enable_file_explorer_mode(self)
# Initialize drives list
self.update_drives_list()
# Set initial path if provided, else default to home
self.current_path = os.path.expanduser("~") if not initial_path else os.path.normpath(initial_path)
if initial_path and not os.path.isdir(self.current_path):
self.current_path = os.path.expanduser("~") # Fallback to home if initial path is invalid
self.update_file_list()
def get_mounted_drives(self):
"""Получение списка смонтированных дисков из /proc/mounts, исключая системные пути"""
mounted_drives = []
try:
with open('/proc/mounts') as f:
for line in f:
parts = line.strip().split()
if len(parts) < 2:
continue
mount_point = parts[1]
# Исключаем системные и временные пути
if mount_point.startswith(('/run', '/dev', '/sys', '/proc', '/tmp', '/snap', '/var/lib')):
continue
# Проверяем, является ли точка монтирования директорией и доступна ли она
if os.path.isdir(mount_point) and os.access(mount_point, os.R_OK):
mounted_drives.append(mount_point)
return sorted(mounted_drives)
except Exception as e:
logger.error(f"Ошибка при получении смонтированных дисков: {e}")
return []
def setup_ui(self):
"""Настройка интерфейса"""
self.setWindowTitle("File Explorer")
self.setGeometry(100, 100, 600, 600)
self.main_layout = QVBoxLayout()
self.main_layout.setContentsMargins(10, 10, 10, 10)
self.main_layout.setSpacing(10)
self.setLayout(self.main_layout)
# Панель для смонтированных дисков
self.drives_layout = QHBoxLayout()
self.drives_scroll = QScrollArea()
self.drives_scroll.setWidgetResizable(True)
self.drives_container = QWidget()
self.drives_container.setLayout(self.drives_layout)
self.drives_scroll.setWidget(self.drives_container)
self.drives_scroll.setStyleSheet(self.theme.SCROLL_AREA_STYLE)
self.drives_scroll.setFixedHeight(60)
self.main_layout.addWidget(self.drives_scroll)
# Путь
self.path_label = QLabel()
self.path_label.setStyleSheet(self.theme.FILE_EXPLORER_PATH_LABEL_STYLE)
self.main_layout.addWidget(self.path_label)
# Список файлов
self.file_list = QListWidget()
self.file_list.setStyleSheet(self.theme.FILE_EXPLORER_STYLE)
self.file_list.itemClicked.connect(self.handle_item_click)
self.main_layout.addWidget(self.file_list)
# Кнопки
self.button_layout = QHBoxLayout()
self.button_layout.setSpacing(10)
self.select_button = AutoSizeButton(_("Select"), icon=self.theme_manager.get_icon("apply"))
self.cancel_button = AutoSizeButton(_("Cancel"), icon=self.theme_manager.get_icon("cancel"))
self.select_button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
self.cancel_button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
self.button_layout.addWidget(self.select_button)
self.button_layout.addWidget(self.cancel_button)
self.main_layout.addLayout(self.button_layout)
self.select_button.clicked.connect(self.select_item)
self.cancel_button.clicked.connect(self.reject)
def move_selection(self, direction):
"""Перемещение выбора по списку"""
current_row = self.file_list.currentRow()
if direction < 0 and current_row > 0: # Вверх
self.file_list.setCurrentRow(current_row - 1)
elif direction > 0 and current_row < self.file_list.count() - 1: # Вниз
self.file_list.setCurrentRow(current_row + 1)
self.file_list.scrollToItem(self.file_list.currentItem())
def handle_item_click(self, item):
"""Обработка клика мышью"""
self.file_list.setCurrentItem(item)
self.path_history[self.current_path] = item.text() # Save the selected item
self.select_item()
def select_item(self):
"""Обработка выбора файла/папки"""
if self.file_list.count() == 0:
return
selected = self.file_list.currentItem().text()
full_path = os.path.join(self.current_path, selected)
if os.path.isdir(full_path):
# Если выбрана директория, нормализуем путь
self.current_path = os.path.normpath(full_path)
self.update_file_list()
else:
# Для файла отправляем нормализованный путь
self.file_signal.file_selected.emit(os.path.normpath(full_path))
self.accept()
def previous_dir(self):
"""Возврат к родительской директории"""
try:
if self.current_path == "/":
return # Уже в корне
# Нормализуем путь (убираем конечный слеш, если есть)
normalized_path = os.path.normpath(self.current_path)
# Получаем родительскую директорию
parent_dir = os.path.dirname(normalized_path)
if not parent_dir:
parent_dir = "/"
# Save the current directory as the last selected item for the parent
current_dir_name = os.path.basename(normalized_path)
self.path_history[parent_dir] = current_dir_name + "/" if current_dir_name else "../"
self.current_path = parent_dir
self.update_file_list()
except Exception as e:
logger.error(f"Error navigating to parent directory: {e}")
def update_drives_list(self):
"""Обновление списка смонтированных дисков"""
for i in reversed(range(self.drives_layout.count())):
widget = self.drives_layout.itemAt(i).widget()
if widget:
widget.deleteLater()
drives = self.get_mounted_drives()
for drive in drives:
drive_name = os.path.basename(drive) or drive.split('/')[-1] or drive
button = AutoSizeButton(drive_name, icon=self.theme_manager.get_icon("mount_point"))
button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
button.clicked.connect(lambda checked, path=drive: self.change_drive(path))
self.drives_layout.addWidget(button)
self.drives_layout.addStretch()
def change_drive(self, drive_path):
"""Переход к выбранному диску"""
if os.path.isdir(drive_path) and os.access(drive_path, os.R_OK):
self.current_path = os.path.normpath(drive_path)
self.update_file_list()
else:
logger.warning(f"Путь диска недоступен: {drive_path}")
def update_file_list(self):
"""Обновление списка файлов с превью в виде иконок"""
self.file_list.clear()
try:
if self.current_path != "/":
item = QListWidgetItem("../")
folder_icon = self.theme_manager.get_icon("folder")
# Ensure the icon is a QIcon
if isinstance(folder_icon, str) and os.path.isfile(folder_icon):
folder_icon = QIcon(folder_icon)
elif not isinstance(folder_icon, QIcon):
folder_icon = QIcon() # Fallback to empty icon
item.setIcon(folder_icon)
self.file_list.addItem(item)
items = os.listdir(self.current_path)
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):
item = QListWidgetItem(f"{d}/")
folder_icon = self.theme_manager.get_icon("folder")
# Ensure the icon is a QIcon
if isinstance(folder_icon, str) and os.path.isfile(folder_icon):
folder_icon = QIcon(folder_icon)
elif not isinstance(folder_icon, QIcon):
folder_icon = QIcon() # Fallback to empty icon
item.setIcon(folder_icon)
self.file_list.addItem(item)
for f in sorted(files):
item = QListWidgetItem(f)
file_path = os.path.join(self.current_path, f)
mime_type = self.mime_db.mimeTypeForFile(file_path).name()
if mime_type.startswith("image/"):
pixmap = QPixmap(file_path)
if not pixmap.isNull():
item.setIcon(QIcon(pixmap.scaled(64, 64, Qt.AspectRatioMode.KeepAspectRatio)))
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.path_label.setText(_("Path: ") + self.current_path)
# Restore last selected item for this directory
last_item = self.path_history.get(self.current_path)
if last_item:
for i in range(self.file_list.count()):
if self.file_list.item(i).text() == last_item:
self.file_list.setCurrentRow(i)
self.file_list.scrollToItem(self.file_list.currentItem())
break
else:
self.file_list.setCurrentRow(0)
else:
self.file_list.setCurrentRow(0)
self.file_list.setFocusPolicy(Qt.FocusPolicy.NoFocus)
self.file_list.setTextElideMode(Qt.TextElideMode.ElideRight)
self.file_list.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
self.file_list.setAlternatingRowColors(True)
except PermissionError:
self.path_label.setText(f"Access denied: {self.current_path}")
def closeEvent(self, event):
"""Закрытие окна"""
try:
if self.input_manager:
self.input_manager.disable_file_explorer_mode()
if self.parent():
parent = cast("MainWindow", self.parent())
parent.activateWindow()
parent.setFocus()
except Exception as e:
logger.error(f"Error in closeEvent: {e}")
super().closeEvent(event)
def reject(self):
"""Закрытие диалога"""
if self.input_manager:
self.input_manager.disable_file_explorer_mode()
super().reject()
def accept(self):
"""Принятие диалога"""
if self.input_manager:
self.input_manager.disable_file_explorer_mode()
super().accept()
class AddGameDialog(QDialog):
def __init__(self, parent=None, theme=None, edit_mode=False, game_name=None, exe_path=None, cover_path=None):
super().__init__(parent)
self.theme = theme if theme else default_styles
self.theme_manager = ThemeManager()
self.edit_mode = edit_mode
self.original_name = game_name
self.last_exe_path = exe_path # Store last selected exe path
self.last_cover_path = cover_path # Store last selected cover path
self.setWindowTitle(_("Edit Game") if edit_mode else _("Add Game"))
self.setModal(True)
self.setStyleSheet(self.theme.MAIN_WINDOW_STYLE + self.theme.MESSAGE_BOX_STYLE)
layout = QFormLayout(self)
layout.setLabelAlignment(Qt.AlignmentFlag.AlignLeft)
# Game name
self.nameEdit = QLineEdit(self)
self.nameEdit.setStyleSheet(self.theme.ADDGAME_INPUT_STYLE)
if game_name:
self.nameEdit.setText(game_name)
name_label = QLabel(_("Game Name:"))
name_label.setStyleSheet(self.theme.PARAMS_TITLE_STYLE + " QLabel { color: #ffffff; font-size: 14px; font-weight: bold; }")
layout.addRow(name_label, self.nameEdit)
# Exe path
self.exeEdit = QLineEdit(self)
self.exeEdit.setStyleSheet(self.theme.ADDGAME_INPUT_STYLE)
if exe_path:
self.exeEdit.setText(exe_path)
exeBrowseButton = QPushButton(_("Browse..."), self)
exeBrowseButton.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
exeBrowseButton.clicked.connect(self.browseExe)
exeLayout = QHBoxLayout()
exeLayout.addWidget(self.exeEdit)
exeLayout.addWidget(exeBrowseButton)
exe_label = QLabel(_("Path to Executable:"))
exe_label.setStyleSheet(self.theme.PARAMS_TITLE_STYLE + " QLabel { color: #ffffff; font-size: 14px; font-weight: bold; }")
layout.addRow(exe_label, exeLayout)
# Cover path
self.coverEdit = QLineEdit(self)
self.coverEdit.setStyleSheet(self.theme.ADDGAME_INPUT_STYLE)
if cover_path:
self.coverEdit.setText(cover_path)
coverBrowseButton = QPushButton(_("Browse..."), self)
coverBrowseButton.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
coverBrowseButton.clicked.connect(self.browseCover)
coverLayout = QHBoxLayout()
coverLayout.addWidget(self.coverEdit)
coverLayout.addWidget(coverBrowseButton)
cover_label = QLabel(_("Custom Cover:"))
cover_label.setStyleSheet(self.theme.PARAMS_TITLE_STYLE + " QLabel { color: #ffffff; font-size: 14px; font-weight: bold; }")
layout.addRow(cover_label, coverLayout)
# Preview
self.coverPreview = QLabel(self)
self.coverPreview.setStyleSheet(self.theme.CONTENT_STYLE + " QLabel { color: #ffffff; }")
preview_label = QLabel(_("Cover Preview:"))
preview_label.setStyleSheet(self.theme.PARAMS_TITLE_STYLE + " QLabel { color: #ffffff; font-size: 14px; font-weight: bold; }")
layout.addRow(preview_label, self.coverPreview)
# Dialog buttons
self.button_layout = QHBoxLayout()
self.button_layout.setSpacing(10)
self.select_button = AutoSizeButton(_("Select"), icon=self.theme_manager.get_icon("apply"))
self.cancel_button = AutoSizeButton(_("Cancel"), icon=self.theme_manager.get_icon("cancel"))
self.select_button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
self.cancel_button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
self.button_layout.addWidget(self.select_button)
self.button_layout.addWidget(self.cancel_button)
layout.addRow(self.button_layout)
self.select_button.clicked.connect(self.accept)
self.cancel_button.clicked.connect(self.reject)
self.coverEdit.textChanged.connect(self.updatePreview)
self.exeEdit.textChanged.connect(self.updatePreview)
if edit_mode:
self.updatePreview()
def browseExe(self):
"""Открывает файловый менеджер для выбора exe-файла"""
try:
# Use last_exe_path if available and valid, otherwise fallback to home
initial_path = os.path.dirname(self.last_exe_path) if self.last_exe_path and os.path.isfile(self.last_exe_path) else None
file_explorer = FileExplorer(self, file_filter='.exe', initial_path=initial_path)
file_explorer.file_signal.file_selected.connect(self.onExeSelected)
# Центрируем FileExplorer относительно родительского виджета
parent_widget = self.parentWidget() # QWidget или None
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()
except Exception as e:
logger.error(f"Error in browseExe: {e}")
def onExeSelected(self, file_path):
"""Обработчик выбора файла в FileExplorer"""
self.exeEdit.setText(file_path)
self.last_exe_path = file_path # Update last selected exe path
if not self.edit_mode:
# Автоматически заполняем имя игры, если не в режиме редактирования
game_name = os.path.splitext(os.path.basename(file_path))[0]
self.nameEdit.setText(game_name)
# Обновляем превью
self.updatePreview()
def browseCover(self):
"""Открывает файловый менеджер для выбора изображения обложки"""
try:
# Use last_cover_path if available and valid, otherwise fallback to home
initial_path = os.path.dirname(self.last_cover_path) if self.last_cover_path and os.path.isfile(self.last_cover_path) else None
file_explorer = FileExplorer(self, file_filter=('.png', '.jpg', '.jpeg', '.bmp'), initial_path=initial_path)
file_explorer.file_signal.file_selected.connect(self.onCoverSelected)
# Центрируем FileExplorer относительно родительского виджета
parent_widget = self.parentWidget()
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()
except Exception as e:
logger.error(f"Error in browseCover: {e}")
def onCoverSelected(self, file_path):
"""Обработчик выбора файла обложки в FileExplorer"""
if file_path and os.path.splitext(file_path)[1].lower() in ('.png', '.jpg', '.jpeg', '.bmp'):
self.coverEdit.setText(file_path)
self.last_cover_path = file_path # Update last selected cover path
else:
logger.warning(f"Selected file is not a valid image: {file_path}")
def updatePreview(self):
"""Update the cover preview image."""
cover_path = self.coverEdit.text().strip()
exe_path = self.exeEdit.text().strip()
if cover_path and os.path.isfile(cover_path):
pixmap = QPixmap(cover_path)
if not pixmap.isNull():
self.coverPreview.setPixmap(pixmap.scaled(250, 250, Qt.AspectRatioMode.KeepAspectRatio))
else:
self.coverPreview.setText(_("Invalid image"))
elif os.path.isfile(exe_path):
tmp = tempfile.NamedTemporaryFile(suffix='.png', delete=False)
tmp.close()
if generate_thumbnail(exe_path, tmp.name, size=128):
pixmap = QPixmap(tmp.name)
self.coverPreview.setPixmap(pixmap)
os.unlink(tmp.name)
else:
self.coverPreview.setText(_("No cover selected"))
def getDesktopEntryData(self):
"""Returns the .desktop content and save path"""
exe_path = self.exeEdit.text().strip()
name = self.nameEdit.text().strip()
if not exe_path or not name:
return None, None
portproton_path = get_portproton_location()
if portproton_path is None:
return None, None
is_flatpak = ".var" in portproton_path
base_path = os.path.join(portproton_path, "data")
if is_flatpak:
exec_str = f'flatpak run ru.linux_gaming.PortProton "{exe_path}"'
else:
start_sh = os.path.join(base_path, "scripts", "start.sh")
exec_str = f'env "{start_sh}" "{exe_path}"'
icon_path = os.path.join(base_path, "img", f"{name}.png")
desktop_path = os.path.join(portproton_path, f"{name}.desktop")
working_dir = os.path.join(base_path, "scripts")
os.makedirs(os.path.dirname(icon_path), exist_ok=True)
# Generate thumbnail (128x128) from exe
if not generate_thumbnail(exe_path, icon_path, size=128):
logger.error(f"Failed to generate thumbnail from exe: {exe_path}")
icon_path = "" # Set empty icon if generation fails
comment = _('Launch game "{name}" with PortProton').format(name=name)
desktop_entry = f"""[Desktop Entry]
Name={name}
Comment={comment}
Exec={exec_str}
Terminal=false
Type=Application
Categories=Game;
StartupNotify=true
Path={working_dir}
Icon={icon_path}
"""
return desktop_entry, desktop_path