8 Commits

Author SHA1 Message Date
8e11dac987 chore: v0.1.5
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-31 16:08:43 +05:00
358afbdbdb chore(localization): update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-31 12:29:11 +05:00
83730499e2 chore(changelog): update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-31 12:23:55 +05:00
84f560ed30 feat(tray): add modal game launch dialog with process detection and cancellation
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-31 12:20:52 +05:00
888c9ac387 chore(theme): drop unstable mark from scale animation
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-31 11:11:07 +05:00
68d06ca05c fix(FlowLayout): Align incomplete rows with the first card of the longest row
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-31 11:09:24 +05:00
6923a5f05c chore(theme): change placeholder aspect ratio
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-31 10:03:30 +05:00
f3f85441d8 fix: scale animation is less unstable
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-30 21:21:15 +05:00
24 changed files with 720 additions and 561 deletions

View File

@@ -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 }}

View File

@@ -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 не был выполнен.

View File

@@ -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:

View File

@@ -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')

View File

@@ -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

View File

@@ -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 |
---

View File

@@ -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 |
---

View File

@@ -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",

View File

@@ -108,7 +108,6 @@ GAME_CARD_ANIMATION = {
# Тип анимации для карточки при наведении или фокусе
# Возможные значения: "gradient", "scale"
# scale крайне нестабилен и требует доработки (используйте на свой страх и риск)
# "gradient" включает вращающийся градиент для обводки, "scale" увеличивает размер карточки
"card_animation_type": "gradient",

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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 ""

View File

@@ -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 ""

View File

@@ -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 ""

View File

@@ -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

View File

@@ -83,7 +83,6 @@ GAME_CARD_ANIMATION = {
# Тип анимации для карточки при наведении или фокусе
# Возможные значения: "gradient", "scale"
# scale крайне нестабилен и требует доработки (используйте на свой страх и риск)
# "gradient" включает вращающийся градиент для обводки, "scale" увеличивает размер карточки
"card_animation_type": "gradient",

View File

@@ -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)

View File

@@ -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" }

884
uv.lock generated

File diff suppressed because it is too large Load Diff