554 lines
23 KiB
Python
554 lines
23 KiB
Python
import os
|
||
import tempfile
|
||
import time
|
||
|
||
|
||
from PySide6.QtGui import QPixmap
|
||
from PySide6.QtWidgets import (
|
||
QDialog, QLineEdit, QFormLayout, QPushButton,
|
||
QHBoxLayout, QDialogButtonBox, QFileDialog, QLabel, QVBoxLayout, QListWidget
|
||
)
|
||
from PySide6.QtCore import Qt, QObject, Signal, QTimer
|
||
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.themes.standart.styles import FileExplorerStyles
|
||
|
||
from evdev import ecodes
|
||
|
||
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):
|
||
super().__init__(parent)
|
||
self.file_signal = FileSelectedSignal()
|
||
self.setup_ui()
|
||
|
||
# Настройки окна
|
||
self.setWindowModality(Qt.WindowModality.ApplicationModal)
|
||
self.setWindowFlags(self.windowFlags() & ~Qt.WindowType.WindowContextHelpButtonHint)
|
||
self.setStyleSheet(FileExplorerStyles.WINDOW_STYLE)
|
||
|
||
# Для управления геймпадом
|
||
self.input_manager = None
|
||
self.gamepad_connected = False
|
||
self.setup_gamepad_handling()
|
||
|
||
# Для плавного перемещения
|
||
self.nav_timer = QTimer(self)
|
||
self.nav_timer.timeout.connect(self.handle_navigation_repeat)
|
||
self.current_direction = 0
|
||
self.last_nav_time = 0
|
||
self.initial_nav_delay = 0.1 # Начальная задержка перед первым повторением (сек)
|
||
self.repeat_nav_delay = 0.05 # Интервал между повторениями (сек)
|
||
self.stick_activated = False
|
||
self.stick_value = 0 # Текущее значение стика (для плавности)
|
||
self.dead_zone = 8000 # Мертвая зона стика
|
||
|
||
def setup_ui(self):
|
||
"""Настройка интерфейса"""
|
||
self.setWindowTitle("File Explorer")
|
||
self.setGeometry(100, 100, 800, 600)
|
||
|
||
self.layout = QVBoxLayout()
|
||
self.layout.setContentsMargins(10, 10, 10, 10)
|
||
self.layout.setSpacing(10)
|
||
self.setLayout(self.layout)
|
||
|
||
self.path_label = QLabel()
|
||
self.path_label.setStyleSheet(FileExplorerStyles.PATH_LABEL_STYLE)
|
||
self.layout.addWidget(self.path_label)
|
||
|
||
# Список файлов
|
||
self.file_list = QListWidget()
|
||
self.file_list.setStyleSheet(FileExplorerStyles.LIST_STYLE)
|
||
self.file_list.itemClicked.connect(self.handle_item_click)
|
||
self.layout.addWidget(self.file_list)
|
||
|
||
# Кнопки всякие
|
||
self.button_layout = QHBoxLayout()
|
||
self.button_layout.setSpacing(10)
|
||
self.select_button = QPushButton("Select (A)")
|
||
self.cancel_button = QPushButton("Cancel (B)")
|
||
self.select_button.setStyleSheet(FileExplorerStyles.BUTTON_STYLE)
|
||
self.cancel_button.setStyleSheet(FileExplorerStyles.BUTTON_STYLE)
|
||
self.button_layout.addWidget(self.select_button)
|
||
self.button_layout.addWidget(self.cancel_button)
|
||
self.layout.addLayout(self.button_layout)
|
||
|
||
self.select_button.clicked.connect(self.select_item)
|
||
self.cancel_button.clicked.connect(self.reject)
|
||
|
||
# Чтобы хомяк открывался
|
||
self.current_path = os.path.expanduser("~")
|
||
self.update_file_list()
|
||
|
||
def setup_gamepad_handling(self):
|
||
"""Настройка обработки геймпада"""
|
||
parent = self.parent()
|
||
while parent:
|
||
if hasattr(parent, 'input_manager'):
|
||
self.input_manager = parent.input_manager
|
||
break
|
||
parent = parent.parent()
|
||
|
||
if self.input_manager:
|
||
try:
|
||
# Сохраняем оригинальные обработчики геймпада (нужно для возврата управления)
|
||
self.original_button_handler = self.input_manager.handle_button_slot
|
||
self.original_dpad_handler = self.input_manager.handle_dpad_slot
|
||
self.original_gamepad_state = self.input_manager._gamepad_handling_enabled
|
||
|
||
# Устанавливаем свои обработчики геймпада для файлового менеджера
|
||
self.input_manager.handle_button_slot = self.handle_gamepad_button
|
||
self.input_manager.handle_dpad_slot = self.handle_dpad_movement
|
||
self.input_manager._gamepad_handling_enabled = True
|
||
|
||
self.gamepad_connected = True
|
||
logger.debug("Gamepad handling successfully connected")
|
||
except Exception as e:
|
||
logger.error(f"Error connecting gamepad handlers: {e}")
|
||
self.gamepad_connected = False
|
||
|
||
def handle_gamepad_button(self, button_code):
|
||
"""Обработка кнопок геймпада"""
|
||
try:
|
||
if not self or not hasattr(self, 'file_list'):
|
||
return
|
||
|
||
if button_code in {ecodes.BTN_SOUTH}: # Кнопка A
|
||
self.select_item()
|
||
elif button_code in {ecodes.BTN_EAST}: # Кнопка B
|
||
self.close()
|
||
else:
|
||
if hasattr(self, 'original_button_handler') and self.original_button_handler:
|
||
self.original_button_handler(button_code)
|
||
except Exception as e:
|
||
logger.error(f"Error in gamepad button handler: {e}")
|
||
|
||
def handle_dpad_movement(self, code, value, current_time):
|
||
"""Обработка движения D-pad и левого стика"""
|
||
try:
|
||
if not self or not hasattr(self, 'file_list') or not self.file_list:
|
||
return
|
||
|
||
if not self.file_list.count():
|
||
return
|
||
|
||
if code in (ecodes.ABS_HAT0Y, ecodes.ABS_Y):
|
||
# Для D-pad - реакция с фиксированной скоростью
|
||
if code == ecodes.ABS_HAT0Y:
|
||
if value != 0:
|
||
self.current_direction = value
|
||
self.stick_value = 1.0 # Максимальная скорость для D-pad, чтобы скачков не было
|
||
if not self.nav_timer.isActive():
|
||
self.move_selection(self.current_direction)
|
||
self.last_nav_time = current_time
|
||
self.nav_timer.start(int(self.initial_nav_delay * 1000))
|
||
else:
|
||
self.current_direction = 0
|
||
self.nav_timer.stop()
|
||
|
||
# Для стика - плавное управление с учетом степени отклонения
|
||
elif code == ecodes.ABS_Y:
|
||
if abs(value) < self.dead_zone:
|
||
if self.stick_activated:
|
||
self.current_direction = 0
|
||
self.nav_timer.stop()
|
||
self.stick_activated = False
|
||
return
|
||
|
||
# Рассчитываем "силу" отклонения (0.3 - 1.0)
|
||
normalized_value = (abs(value) - self.dead_zone) / (32768 - self.dead_zone)
|
||
speed_factor = 0.3 + (normalized_value * 0.7) # От 30% до 100% скорости
|
||
|
||
self.current_direction = -1 if value < 0 else 1
|
||
self.stick_value = speed_factor
|
||
self.stick_activated = True
|
||
|
||
if not self.nav_timer.isActive():
|
||
self.move_selection(self.current_direction)
|
||
self.last_nav_time = current_time
|
||
self.nav_timer.start(int(self.initial_nav_delay * 1000))
|
||
|
||
else:
|
||
if hasattr(self, 'original_dpad_handler') and self.original_dpad_handler:
|
||
self.original_dpad_handler(code, value, current_time)
|
||
except Exception as e:
|
||
logger.error(f"Error in dpad handler: {e}")
|
||
|
||
def handle_navigation_repeat(self):
|
||
"""Плавное повторение движения с переменной скоростью"""
|
||
try:
|
||
if not self or not hasattr(self, 'file_list') or not self.file_list:
|
||
return
|
||
|
||
if self.current_direction != 0:
|
||
now = time.time()
|
||
# Динамический интервал в зависимости от stick_value
|
||
dynamic_delay = self.repeat_nav_delay / self.stick_value
|
||
if now - self.last_nav_time >= dynamic_delay:
|
||
self.move_selection(self.current_direction)
|
||
self.last_nav_time = now
|
||
except Exception as e:
|
||
logger.error(f"Error in navigation repeat: {e}")
|
||
|
||
def restore_gamepad_handling(self):
|
||
"""Восстановление оригинальных обработчиков главного окна программы (дефолт возвращаем)"""
|
||
try:
|
||
if self.input_manager and self.gamepad_connected:
|
||
if hasattr(self, 'original_button_handler') and self.original_button_handler:
|
||
self.input_manager.handle_button_slot = self.original_button_handler
|
||
|
||
if hasattr(self, 'original_dpad_handler') and self.original_dpad_handler:
|
||
self.input_manager.handle_dpad_slot = self.original_dpad_handler
|
||
|
||
if hasattr(self, 'original_gamepad_state'):
|
||
self.input_manager._gamepad_handling_enabled = self.original_gamepad_state
|
||
|
||
self.gamepad_connected = False
|
||
logger.debug("Gamepad handling successfully restored")
|
||
except Exception as e:
|
||
logger.error(f"Error restoring gamepad handlers: {e}")
|
||
|
||
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.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 = full_path
|
||
self.update_file_list()
|
||
else:
|
||
self.file_signal.file_selected.emit(full_path)
|
||
self.accept()
|
||
|
||
def update_file_list(self):
|
||
"""Обновление списка файлов"""
|
||
self.file_list.clear()
|
||
try:
|
||
if self.current_path != "/":
|
||
self.file_list.addItem("../")
|
||
|
||
items = os.listdir(self.current_path)
|
||
dirs = [d for d in items if os.path.isdir(os.path.join(self.current_path, d))]
|
||
files = [f for f in items if os.path.isfile(os.path.join(self.current_path, f))]
|
||
|
||
for d in sorted(dirs):
|
||
self.file_list.addItem(f"{d}/")
|
||
|
||
for f in sorted(files):
|
||
self.file_list.addItem(f)
|
||
|
||
self.path_label.setText(f"Path: {self.current_path}")
|
||
self.file_list.setCurrentRow(0)
|
||
|
||
except PermissionError:
|
||
self.path_label.setText(f"Access denied: {self.current_path}")
|
||
|
||
def closeEvent(self, event):
|
||
"""Закрываем окно"""
|
||
try:
|
||
self.restore_gamepad_handling()
|
||
self.nav_timer.stop()
|
||
|
||
if self.parent():
|
||
self.parent().activateWindow()
|
||
self.parent().setFocus()
|
||
except Exception as e:
|
||
logger.error(f"Error in closeEvent: {e}")
|
||
|
||
super().closeEvent(event)
|
||
|
||
def reject(self):
|
||
"""Закрываем окно диалога (два)"""
|
||
self.restore_gamepad_handling()
|
||
super().reject()
|
||
|
||
def accept(self):
|
||
"""Принятие (применение) диалога"""
|
||
self.restore_gamepad_handling()
|
||
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.edit_mode = edit_mode
|
||
self.original_name = game_name
|
||
|
||
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
|
||
buttonBox = QDialogButtonBox(
|
||
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
|
||
)
|
||
buttonBox.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||
buttonBox.accepted.connect(self.accept)
|
||
buttonBox.rejected.connect(self.reject)
|
||
layout.addRow(buttonBox)
|
||
|
||
self.coverEdit.textChanged.connect(self.updatePreview)
|
||
self.exeEdit.textChanged.connect(self.updatePreview)
|
||
|
||
if edit_mode:
|
||
self.updatePreview()
|
||
|
||
def browseExe(self):
|
||
"""Открывает файловый менеджер для выбора exe-файла"""
|
||
try:
|
||
file_explorer = FileExplorer(self)
|
||
file_explorer.file_signal.file_selected.connect(self.onExeSelected)
|
||
|
||
if self.parent():
|
||
center_point = self.parent().geometry().center()
|
||
file_explorer.move(center_point - file_explorer.rect().center())
|
||
|
||
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)
|
||
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:
|
||
file_explorer = FileExplorer(self)
|
||
file_explorer.file_signal.file_selected.connect(self.onCoverSelected)
|
||
|
||
if self.parent():
|
||
center_point = self.parent().geometry().center()
|
||
file_explorer.move(center_point - file_explorer.rect().center())
|
||
|
||
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)
|
||
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
|