forked from Boria138/PortProtonQt
Compare commits
8 Commits
eb90836710
...
v0.1.5
| Author | SHA1 | Date | |
|---|---|---|---|
|
8e11dac987
|
|||
|
358afbdbdb
|
|||
|
83730499e2
|
|||
|
84f560ed30
|
|||
|
888c9ac387
|
|||
|
68d06ca05c
|
|||
|
6923a5f05c
|
|||
|
f3f85441d8
|
@@ -8,7 +8,7 @@ on:
|
||||
|
||||
env:
|
||||
# Common version, will be used for tagging the release
|
||||
VERSION: 0.1.4
|
||||
VERSION: 0.1.5
|
||||
PKGDEST: "/tmp/portprotonqt"
|
||||
PACKAGE: "portprotonqt"
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
Все заметные изменения в этом проекте фиксируются в этом файле.
|
||||
Формат основан на [Keep a Changelog](https://keepachangelog.com/) и придерживается принципов [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [Unreleased]
|
||||
## [0.1.5] - 2025-08-31
|
||||
|
||||
### Added
|
||||
- Больше типов анимаций при открытии карточки игры (подробности см. в документации).
|
||||
@@ -18,6 +18,7 @@
|
||||
- Пункт "Выход" в трей.
|
||||
- Пункт "Темы" в трей для быстрого переключения тем.
|
||||
- Двойной клик по иконке трея для показа/скрытия главного окна.
|
||||
- Запуск через трей показывает модальное окно для слежки за процессом запуска
|
||||
|
||||
### Changed
|
||||
- Уменьшена длительность анимации открытия карточки с 800 до 350 мс.
|
||||
@@ -29,6 +30,7 @@
|
||||
- Обновлены все зависимости (затрагивает только AppImage).
|
||||
- Приложение теперь не закрывается полностью, а сворачивается в трей.
|
||||
- Карточки теперь все находятся друг под другом, а не в разнабой
|
||||
- Изменено соотношение сторон карточек
|
||||
|
||||
### Fixed
|
||||
- `legendary list` теперь не вызывается, если вход в EGS не был выполнен.
|
||||
|
||||
@@ -45,7 +45,7 @@ AppDir:
|
||||
id: ru.linux_gaming.PortProtonQt
|
||||
name: PortProtonQt
|
||||
icon: ru.linux_gaming.PortProtonQt
|
||||
version: 0.1.4
|
||||
version: 0.1.5
|
||||
exec: usr/bin/python3
|
||||
exec_args: "-m portprotonqt.app $@"
|
||||
apt:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
pkgname=portprotonqt
|
||||
pkgver=0.1.4
|
||||
pkgver=0.1.5
|
||||
pkgrel=1
|
||||
pkgdesc="Modern GUI for managing and launching games from PortProton, Steam, and Epic Games Store"
|
||||
arch=('any')
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
%global pypi_name portprotonqt
|
||||
%global pypi_version 0.1.4
|
||||
%global pypi_version 0.1.5
|
||||
%global oname PortProtonQt
|
||||
%global _python_no_extras_requires 1
|
||||
|
||||
|
||||
@@ -21,9 +21,9 @@ Current translation status:
|
||||
|
||||
| Locale | Progress | Translated |
|
||||
| :----- | -------: | ---------: |
|
||||
| [de_DE](./de_DE/LC_MESSAGES/messages.po) | 0% | 0 of 202 |
|
||||
| [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 of 202 |
|
||||
| [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 202 of 202 |
|
||||
| [de_DE](./de_DE/LC_MESSAGES/messages.po) | 0% | 0 of 203 |
|
||||
| [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 of 203 |
|
||||
| [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 203 of 203 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -21,9 +21,9 @@
|
||||
|
||||
| Локаль | Прогресс | Переведено |
|
||||
| :----- | -------: | ---------: |
|
||||
| [de_DE](./de_DE/LC_MESSAGES/messages.po) | 0% | 0 из 202 |
|
||||
| [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 из 202 |
|
||||
| [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 202 из 202 |
|
||||
| [de_DE](./de_DE/LC_MESSAGES/messages.po) | 0% | 0 из 203 |
|
||||
| [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 из 203 |
|
||||
| [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 203 из 203 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -108,7 +108,6 @@ GAME_CARD_ANIMATION = {
|
||||
|
||||
# Type of card animation on hover or focus
|
||||
# Possible values: "gradient", "scale"
|
||||
# "scale" is unstable and requires adjustments (use at your own risk)
|
||||
# "gradient" enables a rotating gradient for the border, "scale" enlarges the card
|
||||
"card_animation_type": "gradient",
|
||||
|
||||
|
||||
@@ -108,7 +108,6 @@ GAME_CARD_ANIMATION = {
|
||||
|
||||
# Тип анимации для карточки при наведении или фокусе
|
||||
# Возможные значения: "gradient", "scale"
|
||||
# scale крайне нестабилен и требует доработки (используйте на свой страх и риск)
|
||||
# "gradient" включает вращающийся градиент для обводки, "scale" увеличивает размер карточки
|
||||
"card_animation_type": "gradient",
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ logger = get_logger(__name__)
|
||||
|
||||
__app_id__ = "ru.linux_gaming.PortProtonQt"
|
||||
__app_name__ = "PortProtonQt"
|
||||
__app_version__ = "0.1.4"
|
||||
__app_version__ = "0.1.5"
|
||||
|
||||
def main():
|
||||
app = QApplication(sys.argv)
|
||||
|
||||
@@ -5,11 +5,11 @@ from PySide6.QtGui import QFont, QFontMetrics, QPainter
|
||||
|
||||
def compute_layout(nat_sizes, rect_width, spacing, max_scale):
|
||||
"""
|
||||
Вычисляет расположение элементов с учетом отступов и максимального масштабирования карточек.
|
||||
Вычисляет расположение элементов с учетом отступов и возможного увеличения карточек.
|
||||
nat_sizes: массив (N, 2) с натуральными размерами элементов (ширина, высота).
|
||||
rect_width: доступная ширина контейнера.
|
||||
spacing: отступ между элементами.
|
||||
max_scale: максимальный коэффициент масштабирования (например, 1.2).
|
||||
spacing: отступ между элементами (горизонтальный и вертикальный).
|
||||
max_scale: максимальный коэффициент масштабирования (например, 1.0).
|
||||
|
||||
Возвращает:
|
||||
result: массив (N, 4), где каждая строка содержит [x, y, new_width, new_height].
|
||||
@@ -19,55 +19,89 @@ def compute_layout(nat_sizes, rect_width, spacing, max_scale):
|
||||
result = np.zeros((N, 4), dtype=np.int32)
|
||||
y = 0
|
||||
i = 0
|
||||
min_margin = 20 # Минимальный отступ по краям
|
||||
|
||||
# Определяем максимальное количество элементов в ряду и общий масштаб
|
||||
max_items_per_row = 0
|
||||
global_scale = 1.0
|
||||
max_row_x_start = min_margin # Начальная позиция x самого длинного ряда
|
||||
temp_i = 0
|
||||
|
||||
# Первый проход: находим максимальное количество элементов в ряду
|
||||
while temp_i < N:
|
||||
sum_width = 0
|
||||
count = 0
|
||||
temp_j = temp_i
|
||||
while temp_j < N:
|
||||
w = nat_sizes[temp_j, 0]
|
||||
if count > 0 and (sum_width + spacing + w) > rect_width - 2 * min_margin:
|
||||
break
|
||||
sum_width += w
|
||||
count += 1
|
||||
temp_j += 1
|
||||
|
||||
if count > max_items_per_row:
|
||||
max_items_per_row = count
|
||||
# Вычисляем масштаб для самого заполненного ряда
|
||||
available_width = rect_width - spacing * (count - 1) - 2 * min_margin
|
||||
desired_scale = available_width / sum_width if sum_width > 0 else 1.0
|
||||
global_scale = desired_scale if desired_scale < max_scale else max_scale
|
||||
# Сохраняем начальную позицию x для самого длинного ряда
|
||||
scaled_row_width = int(sum_width * global_scale) + spacing * (count - 1)
|
||||
max_row_x_start = max(min_margin, (rect_width - scaled_row_width) // 2)
|
||||
temp_i = temp_j
|
||||
|
||||
# Второй проход: размещаем элементы
|
||||
while i < N:
|
||||
sum_width = 0
|
||||
row_max_height = 0
|
||||
count = 0
|
||||
j = i
|
||||
|
||||
# Подбираем количество элементов для текущего ряда с учетом max_scale
|
||||
scaled_sizes = nat_sizes * max_scale
|
||||
# Подбираем количество элементов для текущего ряда
|
||||
while j < N:
|
||||
w = scaled_sizes[j, 0]
|
||||
if count > 0 and (sum_width + spacing + w) > rect_width:
|
||||
w = nat_sizes[j, 0]
|
||||
if count > 0 and (sum_width + spacing + w) > rect_width - 2 * min_margin:
|
||||
break
|
||||
sum_width += w
|
||||
count += 1
|
||||
h = scaled_sizes[j, 1]
|
||||
h = nat_sizes[j, 1]
|
||||
if h > row_max_height:
|
||||
row_max_height = h
|
||||
j += 1
|
||||
|
||||
# Вычисляем общую ширину ряда включая отступы
|
||||
total_row_width = sum_width + spacing * (count - 1)
|
||||
# Используем глобальный масштаб для всех рядов
|
||||
scale = global_scale
|
||||
scaled_row_width = int(sum_width * scale) + spacing * (count - 1)
|
||||
|
||||
# Вычисляем смещение для центрирования ряда
|
||||
x_offset = (rect_width - total_row_width) // 2
|
||||
# Определяем начальную координату x
|
||||
if count == max_items_per_row:
|
||||
# Центрируем полный ряд
|
||||
x = max(min_margin, (rect_width - scaled_row_width) // 2)
|
||||
else:
|
||||
# Выравниваем неполный ряд по левому краю, совмещая с началом самого длинного ряда
|
||||
x = max_row_x_start
|
||||
|
||||
# Размещаем элементы в ряду с центрированием
|
||||
x = x_offset
|
||||
for k in range(i, j):
|
||||
new_w = int(nat_sizes[k, 0] * max_scale)
|
||||
new_h = int(nat_sizes[k, 1] * max_scale)
|
||||
new_w = int(nat_sizes[k, 0] * scale)
|
||||
new_h = int(nat_sizes[k, 1] * scale)
|
||||
result[k, 0] = x
|
||||
result[k, 1] = y
|
||||
result[k, 2] = new_w
|
||||
result[k, 3] = new_h
|
||||
x += new_w + spacing
|
||||
|
||||
y += int(row_max_height) + spacing
|
||||
y += int(row_max_height * scale) + spacing
|
||||
i = j
|
||||
|
||||
return result, y
|
||||
|
||||
class FlowLayout(QLayout):
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.itemList = []
|
||||
self.setContentsMargins(0, 0, 0, 0)
|
||||
self._spacing = 3
|
||||
self._max_scale = 1.2
|
||||
self.setContentsMargins(20, 20, 20, 20) # Отступы по краям
|
||||
self._spacing = 20 # Отступ для анимации и предотвращения перекрытий
|
||||
self._max_scale = 1.0 # Отключено масштабирование в layout
|
||||
|
||||
def addItem(self, item: QLayoutItem) -> None:
|
||||
self.itemList.append(item)
|
||||
@@ -104,12 +138,10 @@ class FlowLayout(QLayout):
|
||||
def minimumSize(self):
|
||||
size = QSize()
|
||||
for item in self.itemList:
|
||||
# Учитываем максимальный масштаб при расчете минимального размера
|
||||
item_size = item.sizeHint()
|
||||
scaled_size = QSize(int(item_size.width() * self._max_scale), int(item_size.height() * self._max_scale))
|
||||
size = size.expandedTo(scaled_size)
|
||||
size = size.expandedTo(item.minimumSize())
|
||||
margins = self.contentsMargins()
|
||||
size += QSize(margins.left() + margins.right(), margins.top() + margins.bottom())
|
||||
size += QSize(margins.left() + margins.right(),
|
||||
margins.top() + margins.bottom())
|
||||
return size
|
||||
|
||||
def doLayout(self, rect, testOnly):
|
||||
@@ -157,7 +189,7 @@ class ClickableLabel(QLabel):
|
||||
self._icon_size = icon_size
|
||||
self._icon_space = icon_space
|
||||
self._font_scale_factor = font_scale_factor
|
||||
self._card_width = 250 # Значение по умолчанию
|
||||
self._card_width = 250
|
||||
if change_cursor:
|
||||
self.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
self.updateFontSize()
|
||||
@@ -175,28 +207,23 @@ class ClickableLabel(QLabel):
|
||||
self.update()
|
||||
|
||||
def setCardWidth(self, card_width: int):
|
||||
"""Обновляет ширину карточки и пересчитывает размер шрифта."""
|
||||
self._card_width = card_width
|
||||
self.updateFontSize()
|
||||
|
||||
def updateFontSize(self):
|
||||
"""Обновляет размер шрифта на основе card_width и font_scale_factor."""
|
||||
font = self.font()
|
||||
font_size = int(self._card_width * self._font_scale_factor)
|
||||
font.setPointSize(max(8, font_size)) # Минимальный размер шрифта 8
|
||||
font.setPointSize(max(8, font_size))
|
||||
self.setFont(font)
|
||||
self.update()
|
||||
|
||||
def paintEvent(self, event):
|
||||
painter = QPainter(self)
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
|
||||
rect = self.contentsRect()
|
||||
alignment = self.alignment()
|
||||
|
||||
icon_size = self._icon_size
|
||||
spacing = self._icon_space
|
||||
|
||||
text = self.text()
|
||||
|
||||
if self._icon:
|
||||
@@ -205,17 +232,11 @@ class ClickableLabel(QLabel):
|
||||
pixmap = None
|
||||
|
||||
fm = QFontMetrics(self.font())
|
||||
|
||||
# Считаем, сколько места остаётся под текст
|
||||
available_width = rect.width()
|
||||
if pixmap:
|
||||
available_width -= (icon_size + spacing)
|
||||
# Отступы по 2px с каждой стороны
|
||||
available_width = max(0, available_width - 4)
|
||||
|
||||
# Получаем «обрезанный» текст с многоточием
|
||||
display_text = fm.elidedText(text, Qt.TextElideMode.ElideRight, available_width)
|
||||
|
||||
text_width = fm.horizontalAdvance(display_text)
|
||||
text_height = fm.height()
|
||||
total_width = text_width + (icon_size + spacing if pixmap else 0)
|
||||
@@ -285,8 +306,6 @@ class AutoSizeButton(QPushButton):
|
||||
|
||||
self.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
self.setFlat(True)
|
||||
|
||||
# Изначально выставляем минимальную ширину
|
||||
self.setMinimumWidth(50)
|
||||
self.adjustFontSize()
|
||||
|
||||
@@ -317,7 +336,6 @@ class AutoSizeButton(QPushButton):
|
||||
if not self._update_size:
|
||||
return
|
||||
|
||||
# Определяем доступную ширину внутри кнопки
|
||||
available_width = self.width()
|
||||
if self._icon:
|
||||
available_width -= self._icon_size
|
||||
@@ -328,7 +346,6 @@ class AutoSizeButton(QPushButton):
|
||||
font = QFont(self._original_font)
|
||||
text = self._original_text
|
||||
|
||||
# Подбираем максимально возможный размер шрифта, при котором текст укладывается
|
||||
chosen_size = self._max_font_size
|
||||
for font_size in range(self._max_font_size, self._min_font_size - 1, -1):
|
||||
font.setPointSize(font_size)
|
||||
@@ -341,14 +358,12 @@ class AutoSizeButton(QPushButton):
|
||||
font.setPointSize(chosen_size)
|
||||
self.setFont(font)
|
||||
|
||||
# После выбора шрифта вычисляем требуемую ширину для полного отображения текста
|
||||
fm = QFontMetrics(font)
|
||||
text_width = fm.horizontalAdvance(text)
|
||||
required_width = text_width + margins.left() + margins.right() + self._padding * 2
|
||||
if self._icon:
|
||||
required_width += self._icon_size
|
||||
|
||||
# Если текущая ширина меньше требуемой, обновляем минимальную ширину
|
||||
if self.width() < required_width:
|
||||
self.setMinimumWidth(required_width)
|
||||
|
||||
@@ -358,7 +373,6 @@ class AutoSizeButton(QPushButton):
|
||||
if not self._update_size:
|
||||
return super().sizeHint()
|
||||
else:
|
||||
# Вычисляем оптимальный размер кнопки на основе текста и отступов
|
||||
font = self.font()
|
||||
fm = QFontMetrics(font)
|
||||
text_width = fm.horizontalAdvance(self._original_text)
|
||||
@@ -369,7 +383,6 @@ class AutoSizeButton(QPushButton):
|
||||
height = fm.height() + margins.top() + margins.bottom() + self._padding
|
||||
return QSize(width, height)
|
||||
|
||||
|
||||
class NavLabel(QLabel):
|
||||
clicked = Signal()
|
||||
|
||||
@@ -381,7 +394,6 @@ class NavLabel(QLabel):
|
||||
self._isChecked = False
|
||||
self.setProperty("checked", self._isChecked)
|
||||
self.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
# Explicitly enable focus
|
||||
self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||
|
||||
def setCheckable(self, checkable):
|
||||
@@ -400,7 +412,6 @@ class NavLabel(QLabel):
|
||||
|
||||
def mousePressEvent(self, event):
|
||||
if event.button() == Qt.MouseButton.LeftButton:
|
||||
# Ensure widget can take focus on click
|
||||
self.setFocus(Qt.FocusReason.MouseFocusReason)
|
||||
if self._checkable:
|
||||
self.setChecked(not self._isChecked)
|
||||
|
||||
@@ -4,7 +4,7 @@ import re
|
||||
from typing import cast, TYPE_CHECKING
|
||||
from PySide6.QtGui import QPixmap, QIcon
|
||||
from PySide6.QtWidgets import (
|
||||
QDialog, QFormLayout, QHBoxLayout, QLabel, QVBoxLayout, QListWidget, QScrollArea, QWidget, QListWidgetItem, QSizePolicy, QApplication
|
||||
QDialog, QFormLayout, QHBoxLayout, QLabel, QVBoxLayout, QListWidget, QScrollArea, QWidget, QListWidgetItem, QSizePolicy, QApplication, QProgressBar
|
||||
)
|
||||
from PySide6.QtCore import Qt, QObject, Signal, QMimeDatabase, QTimer
|
||||
from icoextract import IconExtractor, IconExtractorError
|
||||
@@ -16,6 +16,7 @@ import portprotonqt.themes.standart.styles as default_styles
|
||||
from portprotonqt.theme_manager import ThemeManager
|
||||
from portprotonqt.custom_widgets import AutoSizeButton
|
||||
from portprotonqt.downloader import Downloader
|
||||
import psutil
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from portprotonqt.main_window import MainWindow
|
||||
@@ -89,6 +90,86 @@ def generate_thumbnail(inputfile, outfile, size=128, force_resize=True):
|
||||
class FileSelectedSignal(QObject):
|
||||
file_selected = Signal(str) # Сигнал с путем к выбранному файлу
|
||||
|
||||
class GameLaunchDialog(QDialog):
|
||||
"""Modal dialog to indicate game launch progress, similar to Steam's launch dialog."""
|
||||
def __init__(self, parent=None, game_name=None, theme=None, target_exe=None):
|
||||
super().__init__(parent)
|
||||
self.theme = theme if theme else default_styles
|
||||
self.theme_manager = ThemeManager()
|
||||
self.game_name = game_name
|
||||
self.target_exe = target_exe # Store the target executable name
|
||||
self.setWindowTitle(_("Launching {0}").format(self.game_name))
|
||||
self.setModal(True)
|
||||
self.setFixedSize(400, 200)
|
||||
self.setStyleSheet(self.theme.MESSAGE_BOX_STYLE)
|
||||
self.setWindowModality(Qt.WindowModality.ApplicationModal)
|
||||
self.setWindowFlags(Qt.WindowType.Dialog | Qt.WindowType.FramelessWindowHint)
|
||||
|
||||
# Layout
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(10, 10, 10, 10)
|
||||
layout.setSpacing(10)
|
||||
|
||||
# Game name label
|
||||
label = QLabel(_("Launching {0}").format(self.game_name))
|
||||
label.setStyleSheet(self.theme.PARAMS_TITLE_STYLE)
|
||||
label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
layout.addWidget(label)
|
||||
|
||||
# Progress bar (indeterminate)
|
||||
self.progress_bar = QProgressBar()
|
||||
self.progress_bar.setStyleSheet(self.theme.PROGRESS_BAR_STYLE)
|
||||
self.progress_bar.setRange(0, 0) # Indeterminate mode
|
||||
layout.addWidget(self.progress_bar)
|
||||
|
||||
# Cancel button
|
||||
self.cancel_button = AutoSizeButton(_("Cancel"), icon=self.theme_manager.get_icon("cancel"))
|
||||
self.cancel_button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
self.cancel_button.clicked.connect(self.reject)
|
||||
layout.addWidget(self.cancel_button, alignment=Qt.AlignmentFlag.AlignCenter)
|
||||
|
||||
# Center dialog on parent
|
||||
if parent:
|
||||
parent_geometry = parent.geometry()
|
||||
center_point = parent_geometry.center()
|
||||
dialog_geometry = self.geometry()
|
||||
dialog_geometry.moveCenter(center_point)
|
||||
self.setGeometry(dialog_geometry)
|
||||
|
||||
# Timer to check if the game process is running
|
||||
self.check_process_timer = QTimer(self)
|
||||
self.check_process_timer.timeout.connect(self.check_target_exe)
|
||||
self.check_process_timer.start(500)
|
||||
|
||||
def is_target_exe_running(self):
|
||||
"""Check if the target executable is running using psutil."""
|
||||
if not self.target_exe:
|
||||
return False
|
||||
for proc in psutil.process_iter(attrs=["name"]):
|
||||
if proc.info["name"].lower() == self.target_exe.lower():
|
||||
return True
|
||||
return False
|
||||
|
||||
def check_target_exe(self):
|
||||
"""Check if the game process is running and close the dialog if it is."""
|
||||
if self.is_target_exe_running():
|
||||
logger.info(f"Game {self.game_name} process detected as running, closing launch dialog")
|
||||
self.accept() # Close dialog when game is running
|
||||
self.check_process_timer.stop()
|
||||
self.check_process_timer.deleteLater()
|
||||
elif not hasattr(self.parent(), 'game_processes') or not any(proc.poll() is None for proc in cast("MainWindow", self.parent()).game_processes):
|
||||
# If no child processes are running, stop the timer but keep dialog open
|
||||
self.check_process_timer.stop()
|
||||
self.check_process_timer.deleteLater()
|
||||
|
||||
|
||||
def reject(self):
|
||||
"""Handle dialog cancellation."""
|
||||
logger.info(f"Game launch cancelled for {self.game_name}")
|
||||
self.check_process_timer.stop()
|
||||
self.check_process_timer.deleteLater()
|
||||
super().reject()
|
||||
|
||||
class FileExplorer(QDialog):
|
||||
def __init__(self, parent=None, theme=None, file_filter=None, initial_path=None, directory_only=False):
|
||||
super().__init__(parent)
|
||||
|
||||
Binary file not shown.
@@ -9,7 +9,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PROJECT VERSION\n"
|
||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
||||
"POT-Creation-Date: 2025-08-26 13:17+0500\n"
|
||||
"POT-Creation-Date: 2025-08-31 12:28+0500\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language: de_DE\n"
|
||||
@@ -248,15 +248,19 @@ msgstr ""
|
||||
msgid "Select All"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Launching {0}"
|
||||
msgstr ""
|
||||
|
||||
msgid "Cancel"
|
||||
msgstr ""
|
||||
|
||||
msgid "File Explorer"
|
||||
msgstr ""
|
||||
|
||||
msgid "Select"
|
||||
msgstr ""
|
||||
|
||||
msgid "Cancel"
|
||||
msgstr ""
|
||||
|
||||
msgid "Path: "
|
||||
msgstr ""
|
||||
|
||||
|
||||
Binary file not shown.
@@ -9,7 +9,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PROJECT VERSION\n"
|
||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
||||
"POT-Creation-Date: 2025-08-26 13:17+0500\n"
|
||||
"POT-Creation-Date: 2025-08-31 12:28+0500\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language: es_ES\n"
|
||||
@@ -248,15 +248,19 @@ msgstr ""
|
||||
msgid "Select All"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Launching {0}"
|
||||
msgstr ""
|
||||
|
||||
msgid "Cancel"
|
||||
msgstr ""
|
||||
|
||||
msgid "File Explorer"
|
||||
msgstr ""
|
||||
|
||||
msgid "Select"
|
||||
msgstr ""
|
||||
|
||||
msgid "Cancel"
|
||||
msgstr ""
|
||||
|
||||
msgid "Path: "
|
||||
msgstr ""
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PortProtonQt 0.1.1\n"
|
||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
||||
"POT-Creation-Date: 2025-08-26 13:17+0500\n"
|
||||
"POT-Creation-Date: 2025-08-31 12:28+0500\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||
@@ -246,15 +246,19 @@ msgstr ""
|
||||
msgid "Select All"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Launching {0}"
|
||||
msgstr ""
|
||||
|
||||
msgid "Cancel"
|
||||
msgstr ""
|
||||
|
||||
msgid "File Explorer"
|
||||
msgstr ""
|
||||
|
||||
msgid "Select"
|
||||
msgstr ""
|
||||
|
||||
msgid "Cancel"
|
||||
msgstr ""
|
||||
|
||||
msgid "Path: "
|
||||
msgstr ""
|
||||
|
||||
|
||||
Binary file not shown.
@@ -9,8 +9,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PROJECT VERSION\n"
|
||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
||||
"POT-Creation-Date: 2025-08-26 13:17+0500\n"
|
||||
"PO-Revision-Date: 2025-08-26 13:16+0500\n"
|
||||
"POT-Creation-Date: 2025-08-31 12:28+0500\n"
|
||||
"PO-Revision-Date: 2025-08-31 12:28+0500\n"
|
||||
"Last-Translator: \n"
|
||||
"Language: ru_RU\n"
|
||||
"Language-Team: ru_RU <LL@li.org>\n"
|
||||
@@ -255,15 +255,19 @@ msgstr "Удалить"
|
||||
msgid "Select All"
|
||||
msgstr "Выбрать всё"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Launching {0}"
|
||||
msgstr "Идёт запуск {0}"
|
||||
|
||||
msgid "Cancel"
|
||||
msgstr "Отмена"
|
||||
|
||||
msgid "File Explorer"
|
||||
msgstr "Проводник"
|
||||
|
||||
msgid "Select"
|
||||
msgstr "Выбрать"
|
||||
|
||||
msgid "Cancel"
|
||||
msgstr "Отмена"
|
||||
|
||||
msgid "Path: "
|
||||
msgstr "Путь: "
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.6 KiB |
@@ -83,7 +83,6 @@ GAME_CARD_ANIMATION = {
|
||||
|
||||
# Тип анимации для карточки при наведении или фокусе
|
||||
# Возможные значения: "gradient", "scale"
|
||||
# scale крайне нестабилен и требует доработки (используйте на свой страх и риск)
|
||||
# "gradient" включает вращающийся градиент для обводки, "scale" увеличивает размер карточки
|
||||
"card_animation_type": "gradient",
|
||||
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import sys
|
||||
import subprocess
|
||||
from PySide6.QtWidgets import QSystemTrayIcon, QMenu, QApplication
|
||||
import shlex
|
||||
import signal
|
||||
import psutil
|
||||
import os
|
||||
from PySide6.QtWidgets import QSystemTrayIcon, QMenu, QApplication, QMessageBox
|
||||
from PySide6.QtGui import QIcon, QAction
|
||||
from PySide6.QtCore import QTimer
|
||||
from portprotonqt.logger import get_logger
|
||||
@@ -8,6 +12,7 @@ from portprotonqt.theme_manager import ThemeManager
|
||||
import portprotonqt.themes.standart.styles as default_styles
|
||||
from portprotonqt.localization import _
|
||||
from portprotonqt.config_utils import read_favorites, read_theme_from_config, save_theme_to_config
|
||||
from portprotonqt.dialogs import GameLaunchDialog
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@@ -16,18 +21,25 @@ class TrayManager:
|
||||
|
||||
Обеспечивает:
|
||||
- Показ/скрытие главного окна по двойному клику на иконку трея.
|
||||
- Контекстное меню с опциями: Show/Hide (переключается в зависимости от состояния окна),
|
||||
Favorites (быстрый запуск избранных игр), Recent Games (быстрый запуск недавних игр), Themes (быстрая смена тем), Exit.
|
||||
- Меню Favorites, Recent Games и Themes динамически заполняются при показе (через aboutToShow).
|
||||
- При закрытии окна (крестик) приложение сворачивается в трей, а не закрывается.
|
||||
Полное закрытие только через Exit в меню.
|
||||
- Контекстное меню с опциями: Show/Hide, Favorites, Recent Games, Themes, Exit.
|
||||
- Динамическое заполнение меню Favorites, Recent Games и Themes.
|
||||
- Сворачивание в трей при закрытии окна, полное закрытие через Exit.
|
||||
"""
|
||||
|
||||
def __init__(self, main_window, app_name: str | None = None, theme=None):
|
||||
self.app_name = app_name if app_name is not None else "PortProtonQt"
|
||||
self.theme_manager = ThemeManager()
|
||||
self.theme = theme if theme is not None else default_styles
|
||||
self.current_theme_name = read_theme_from_config()
|
||||
selected_theme = read_theme_from_config()
|
||||
self.current_theme_name = selected_theme
|
||||
try:
|
||||
self.theme = self.theme_manager.apply_theme(selected_theme)
|
||||
except FileNotFoundError:
|
||||
logger.warning(f"Тема '{selected_theme}' не найдена, применяется стандартная тема 'standart'")
|
||||
self.theme = self.theme_manager.apply_theme("standart")
|
||||
self.current_theme_name = "standart"
|
||||
save_theme_to_config("standart")
|
||||
if not self.theme:
|
||||
self.theme = default_styles
|
||||
self.main_window = main_window
|
||||
self.tray_icon = QSystemTrayIcon(self.main_window)
|
||||
|
||||
@@ -41,24 +53,19 @@ class TrayManager:
|
||||
self.tray_icon.activated.connect(self.handle_tray_click)
|
||||
self.tray_icon.setToolTip(self.app_name)
|
||||
|
||||
# Контекстное меню
|
||||
self.tray_menu = QMenu()
|
||||
self.toggle_action = QAction(_("Show"), self.main_window)
|
||||
self.toggle_action.triggered.connect(self.toggle_window_action)
|
||||
|
||||
# Подменю для избранных игр
|
||||
self.favorites_menu = QMenu(_("Favorites"))
|
||||
self.favorites_menu.aboutToShow.connect(self.populate_favorites_menu)
|
||||
|
||||
# Подменю для недавних игр
|
||||
self.recent_menu = QMenu(_("Recent Games"))
|
||||
self.recent_menu.aboutToShow.connect(self.populate_recent_menu)
|
||||
|
||||
# Подменю для тем
|
||||
self.themes_menu = QMenu(_("Themes"))
|
||||
self.themes_menu.aboutToShow.connect(self.populate_themes_menu)
|
||||
|
||||
# Добавляем действия в меню
|
||||
self.tray_menu.addAction(self.toggle_action)
|
||||
self.tray_menu.addSeparator()
|
||||
self.tray_menu.addMenu(self.favorites_menu)
|
||||
@@ -74,41 +81,35 @@ class TrayManager:
|
||||
self.tray_icon.setContextMenu(self.tray_menu)
|
||||
self.tray_icon.show()
|
||||
|
||||
# Флаг для принудительного выхода
|
||||
self.main_window.is_exiting = False
|
||||
|
||||
# Переменные для отслеживания двойного клика
|
||||
self.click_count = 0
|
||||
self.click_timer = QTimer()
|
||||
self.click_timer.setSingleShot(True)
|
||||
self.click_timer.timeout.connect(self.reset_click_count)
|
||||
|
||||
self.launch_dialog = None
|
||||
|
||||
def update_toggle_action(self):
|
||||
"""Update toggle_action text based on window visibility."""
|
||||
if self.main_window.isVisible():
|
||||
self.toggle_action.setText(_("Hide"))
|
||||
else:
|
||||
self.toggle_action.setText(_("Show"))
|
||||
|
||||
def handle_tray_click(self, reason):
|
||||
"""Обрабатывает клики по иконке трея, отслеживая двойной клик."""
|
||||
if reason == QSystemTrayIcon.ActivationReason.Trigger:
|
||||
self.click_count += 1
|
||||
if self.click_count == 1:
|
||||
# Запускаем таймер для ожидания второго клика (300 мс - стандартное время для двойного клика)
|
||||
self.click_timer.start(300)
|
||||
elif self.click_count == 2:
|
||||
# Двойной клик зафиксирован
|
||||
self.click_timer.stop()
|
||||
self.toggle_window_action()
|
||||
self.click_count = 0
|
||||
|
||||
def reset_click_count(self):
|
||||
"""Сбрасывает счетчик кликов, если таймер истек."""
|
||||
self.click_count = 0
|
||||
|
||||
def toggle_window_action(self):
|
||||
"""Toggle window visibility and update action text."""
|
||||
if self.main_window.isVisible():
|
||||
self.main_window.hide()
|
||||
else:
|
||||
@@ -117,7 +118,6 @@ class TrayManager:
|
||||
self.main_window.activateWindow()
|
||||
|
||||
def populate_favorites_menu(self):
|
||||
"""Динамически заполняет меню избранных игр с указанием источника."""
|
||||
self.favorites_menu.clear()
|
||||
favorites = read_favorites()
|
||||
if not favorites:
|
||||
@@ -134,13 +134,12 @@ class TrayManager:
|
||||
exec_line, source = game_data
|
||||
action_text = f"{fav} ({source})"
|
||||
action = QAction(action_text, self.main_window)
|
||||
action.triggered.connect(lambda checked=False, el=exec_line: self.main_window.toggleGame(el))
|
||||
action.triggered.connect(lambda checked=False, el=exec_line, name=fav: self.launch_game_with_dialog(el, name))
|
||||
self.favorites_menu.addAction(action)
|
||||
else:
|
||||
logger.warning(f"Exec line not found for favorite: {fav}")
|
||||
|
||||
def populate_recent_menu(self):
|
||||
"""Динамически заполняет меню недавних игр (топ-5 по timestamp) с указанием источника."""
|
||||
self.recent_menu.clear()
|
||||
if not self.main_window.games:
|
||||
no_recent_action = QAction(_("No recent games"), self.main_window)
|
||||
@@ -156,45 +155,99 @@ class TrayManager:
|
||||
source = game[12]
|
||||
action_text = f"{game_name} ({source})"
|
||||
action = QAction(action_text, self.main_window)
|
||||
action.triggered.connect(lambda checked=False, el=exec_line: self.main_window.toggleGame(el))
|
||||
action.triggered.connect(lambda checked=False, el=exec_line, name=game_name: self.launch_game_with_dialog(el, name))
|
||||
self.recent_menu.addAction(action)
|
||||
|
||||
def launch_game_with_dialog(self, exec_line, game_name):
|
||||
"""Launch a game with a modal dialog indicating progress."""
|
||||
try:
|
||||
# Determine target executable
|
||||
target_exe = None
|
||||
if exec_line.startswith("steam://"):
|
||||
# Steam games are handled differently, no target_exe needed
|
||||
self.launch_dialog = GameLaunchDialog(self.main_window, game_name=game_name, theme=self.theme)
|
||||
else:
|
||||
# Extract target executable from exec_line
|
||||
entry_exec_split = shlex.split(exec_line)
|
||||
if entry_exec_split[0] == "env" and len(entry_exec_split) > 2:
|
||||
file_to_check = entry_exec_split[2]
|
||||
elif entry_exec_split[0] == "flatpak" and len(entry_exec_split) > 3:
|
||||
file_to_check = entry_exec_split[3]
|
||||
else:
|
||||
file_to_check = entry_exec_split[0]
|
||||
|
||||
if not os.path.exists(file_to_check):
|
||||
logger.error(f"File not found: {file_to_check}")
|
||||
QMessageBox.warning(self.main_window, _("Error"), _("File not found: {0}").format(file_to_check))
|
||||
return
|
||||
|
||||
target_exe = os.path.basename(file_to_check)
|
||||
self.launch_dialog = GameLaunchDialog(self.main_window, game_name=game_name, theme=self.theme, target_exe=target_exe)
|
||||
|
||||
self.launch_dialog.rejected.connect(lambda: self.cancel_game_launch(exec_line))
|
||||
self.launch_dialog.show()
|
||||
|
||||
self.main_window.toggleGame(exec_line)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to launch game {game_name}: {e}")
|
||||
if self.launch_dialog:
|
||||
self.launch_dialog.reject()
|
||||
self.launch_dialog = None
|
||||
QMessageBox.warning(self.main_window, _("Error"), _("Failed to launch game: {0}").format(str(e)))
|
||||
|
||||
def cancel_game_launch(self, exec_line):
|
||||
"""Cancel the game launch and terminate the process, using MainWindow's stop logic."""
|
||||
if self.main_window.game_processes and self.main_window.target_exe:
|
||||
for proc in self.main_window.game_processes:
|
||||
try:
|
||||
parent = psutil.Process(proc.pid)
|
||||
children = parent.children(recursive=True)
|
||||
for child in children:
|
||||
try:
|
||||
child.terminate()
|
||||
except psutil.NoSuchProcess:
|
||||
pass
|
||||
psutil.wait_procs(children, timeout=5)
|
||||
for child in children:
|
||||
if child.is_running():
|
||||
child.kill()
|
||||
os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
|
||||
except psutil.NoSuchProcess:
|
||||
pass
|
||||
self.main_window.game_processes = []
|
||||
self.main_window.resetPlayButton()
|
||||
if self.launch_dialog:
|
||||
self.launch_dialog.reject()
|
||||
self.launch_dialog = None
|
||||
logger.info(f"Game launch cancelled for exec line: {exec_line}")
|
||||
|
||||
def populate_themes_menu(self):
|
||||
"""Динамически заполняет меню тем, позволяя переключать доступные темы."""
|
||||
self.themes_menu.clear()
|
||||
available_themes = self.theme_manager.get_available_themes()
|
||||
current_theme = read_theme_from_config()
|
||||
|
||||
for theme_name in sorted(available_themes):
|
||||
action = QAction(theme_name, self.main_window)
|
||||
action.setCheckable(True)
|
||||
action.setChecked(theme_name == current_theme)
|
||||
action.setChecked(theme_name == self.current_theme_name)
|
||||
action.triggered.connect(lambda checked=False, tn=theme_name: self.switch_theme(tn))
|
||||
self.themes_menu.addAction(action)
|
||||
|
||||
def switch_theme(self, theme_name: str):
|
||||
"""Сохраняет выбранную тему и перезапускает приложение для применения изменений."""
|
||||
try:
|
||||
# Сохраняем новую тему в конфигурации
|
||||
save_theme_to_config(theme_name)
|
||||
logger.info(f"Saved theme {theme_name}, restarting application to apply changes")
|
||||
|
||||
# Получаем текущий исполняемый файл и аргументы
|
||||
executable = sys.executable
|
||||
args = sys.argv
|
||||
|
||||
# Закрываем текущее приложение
|
||||
self.main_window.is_exiting = True
|
||||
QApplication.quit()
|
||||
|
||||
# Перезапускаем приложение
|
||||
subprocess.Popen([executable] + args)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to switch theme to {theme_name}: {e}")
|
||||
# В случае ошибки сохраняем стандартную тему
|
||||
save_theme_to_config("standart")
|
||||
# Перезапускаем приложение с дефолтной темой
|
||||
executable = sys.executable
|
||||
args = sys.argv
|
||||
self.main_window.is_exiting = True
|
||||
@@ -202,7 +255,6 @@ class TrayManager:
|
||||
subprocess.Popen([executable] + args)
|
||||
|
||||
def force_exit(self):
|
||||
"""Принудительно закрывает приложение."""
|
||||
self.main_window.is_exiting = True
|
||||
self.main_window.close()
|
||||
sys.exit(0)
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "portprotonqt"
|
||||
version = "0.1.4"
|
||||
version = "0.1.5"
|
||||
description = "A project to rewrite PortProton (PortWINE) using PySide"
|
||||
readme = "README.md"
|
||||
license = { text = "GPL-3.0" }
|
||||
|
||||
Reference in New Issue
Block a user