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: env:
# Common version, will be used for tagging the release # Common version, will be used for tagging the release
VERSION: 0.1.4 VERSION: 0.1.5
PKGDEST: "/tmp/portprotonqt" PKGDEST: "/tmp/portprotonqt"
PACKAGE: "portprotonqt" PACKAGE: "portprotonqt"
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}

View File

@@ -3,7 +3,7 @@
Все заметные изменения в этом проекте фиксируются в этом файле. Все заметные изменения в этом проекте фиксируются в этом файле.
Формат основан на [Keep a Changelog](https://keepachangelog.com/) и придерживается принципов [Semantic Versioning](https://semver.org/). Формат основан на [Keep a Changelog](https://keepachangelog.com/) и придерживается принципов [Semantic Versioning](https://semver.org/).
## [Unreleased] ## [0.1.5] - 2025-08-31
### Added ### Added
- Больше типов анимаций при открытии карточки игры (подробности см. в документации). - Больше типов анимаций при открытии карточки игры (подробности см. в документации).
@@ -18,6 +18,7 @@
- Пункт "Выход" в трей. - Пункт "Выход" в трей.
- Пункт "Темы" в трей для быстрого переключения тем. - Пункт "Темы" в трей для быстрого переключения тем.
- Двойной клик по иконке трея для показа/скрытия главного окна. - Двойной клик по иконке трея для показа/скрытия главного окна.
- Запуск через трей показывает модальное окно для слежки за процессом запуска
### Changed ### Changed
- Уменьшена длительность анимации открытия карточки с 800 до 350 мс. - Уменьшена длительность анимации открытия карточки с 800 до 350 мс.
@@ -29,6 +30,7 @@
- Обновлены все зависимости (затрагивает только AppImage). - Обновлены все зависимости (затрагивает только AppImage).
- Приложение теперь не закрывается полностью, а сворачивается в трей. - Приложение теперь не закрывается полностью, а сворачивается в трей.
- Карточки теперь все находятся друг под другом, а не в разнабой - Карточки теперь все находятся друг под другом, а не в разнабой
- Изменено соотношение сторон карточек
### Fixed ### Fixed
- `legendary list` теперь не вызывается, если вход в EGS не был выполнен. - `legendary list` теперь не вызывается, если вход в EGS не был выполнен.

View File

@@ -45,7 +45,7 @@ AppDir:
id: ru.linux_gaming.PortProtonQt id: ru.linux_gaming.PortProtonQt
name: PortProtonQt name: PortProtonQt
icon: ru.linux_gaming.PortProtonQt icon: ru.linux_gaming.PortProtonQt
version: 0.1.4 version: 0.1.5
exec: usr/bin/python3 exec: usr/bin/python3
exec_args: "-m portprotonqt.app $@" exec_args: "-m portprotonqt.app $@"
apt: apt:

View File

@@ -1,5 +1,5 @@
pkgname=portprotonqt pkgname=portprotonqt
pkgver=0.1.4 pkgver=0.1.5
pkgrel=1 pkgrel=1
pkgdesc="Modern GUI for managing and launching games from PortProton, Steam, and Epic Games Store" pkgdesc="Modern GUI for managing and launching games from PortProton, Steam, and Epic Games Store"
arch=('any') arch=('any')

View File

@@ -1,5 +1,5 @@
%global pypi_name portprotonqt %global pypi_name portprotonqt
%global pypi_version 0.1.4 %global pypi_version 0.1.5
%global oname PortProtonQt %global oname PortProtonQt
%global _python_no_extras_requires 1 %global _python_no_extras_requires 1

View File

@@ -21,9 +21,9 @@ Current translation status:
| Locale | Progress | Translated | | Locale | Progress | Translated |
| :----- | -------: | ---------: | | :----- | -------: | ---------: |
| [de_DE](./de_DE/LC_MESSAGES/messages.po) | 0% | 0 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 202 | | [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 of 203 |
| [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 202 of 202 | | [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 | | [de_DE](./de_DE/LC_MESSAGES/messages.po) | 0% | 0 из 203 |
| [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 из 202 | | [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 из 203 |
| [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 202 из 202 | | [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 # Type of card animation on hover or focus
# Possible values: "gradient", "scale" # 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 # "gradient" enables a rotating gradient for the border, "scale" enlarges the card
"card_animation_type": "gradient", "card_animation_type": "gradient",

View File

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

View File

@@ -11,7 +11,7 @@ logger = get_logger(__name__)
__app_id__ = "ru.linux_gaming.PortProtonQt" __app_id__ = "ru.linux_gaming.PortProtonQt"
__app_name__ = "PortProtonQt" __app_name__ = "PortProtonQt"
__app_version__ = "0.1.4" __app_version__ = "0.1.5"
def main(): def main():
app = QApplication(sys.argv) 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): def compute_layout(nat_sizes, rect_width, spacing, max_scale):
""" """
Вычисляет расположение элементов с учетом отступов и максимального масштабирования карточек. Вычисляет расположение элементов с учетом отступов и возможного увеличения карточек.
nat_sizes: массив (N, 2) с натуральными размерами элементов (ширина, высота). nat_sizes: массив (N, 2) с натуральными размерами элементов (ширина, высота).
rect_width: доступная ширина контейнера. rect_width: доступная ширина контейнера.
spacing: отступ между элементами. spacing: отступ между элементами (горизонтальный и вертикальный).
max_scale: максимальный коэффициент масштабирования (например, 1.2). max_scale: максимальный коэффициент масштабирования (например, 1.0).
Возвращает: Возвращает:
result: массив (N, 4), где каждая строка содержит [x, y, new_width, new_height]. 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) result = np.zeros((N, 4), dtype=np.int32)
y = 0 y = 0
i = 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: while i < N:
sum_width = 0 sum_width = 0
row_max_height = 0 row_max_height = 0
count = 0 count = 0
j = i j = i
# Подбираем количество элементов для текущего ряда с учетом max_scale # Подбираем количество элементов для текущего ряда
scaled_sizes = nat_sizes * max_scale
while j < N: while j < N:
w = scaled_sizes[j, 0] w = nat_sizes[j, 0]
if count > 0 and (sum_width + spacing + w) > rect_width: if count > 0 and (sum_width + spacing + w) > rect_width - 2 * min_margin:
break break
sum_width += w sum_width += w
count += 1 count += 1
h = scaled_sizes[j, 1] h = nat_sizes[j, 1]
if h > row_max_height: if h > row_max_height:
row_max_height = h row_max_height = h
j += 1 j += 1
# Вычисляем общую ширину ряда включая отступы # Используем глобальный масштаб для всех рядов
total_row_width = sum_width + spacing * (count - 1) scale = global_scale
scaled_row_width = int(sum_width * scale) + spacing * (count - 1)
# Вычисляем смещение для центрирования ряда # Определяем начальную координату x
x_offset = (rect_width - total_row_width) // 2 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): for k in range(i, j):
new_w = int(nat_sizes[k, 0] * max_scale) new_w = int(nat_sizes[k, 0] * scale)
new_h = int(nat_sizes[k, 1] * max_scale) new_h = int(nat_sizes[k, 1] * scale)
result[k, 0] = x result[k, 0] = x
result[k, 1] = y result[k, 1] = y
result[k, 2] = new_w result[k, 2] = new_w
result[k, 3] = new_h result[k, 3] = new_h
x += new_w + spacing x += new_w + spacing
y += int(row_max_height) + spacing y += int(row_max_height * scale) + spacing
i = j i = j
return result, y return result, y
class FlowLayout(QLayout): class FlowLayout(QLayout):
def __init__(self, parent=None): def __init__(self, parent=None):
super().__init__(parent) super().__init__(parent)
self.itemList = [] self.itemList = []
self.setContentsMargins(0, 0, 0, 0) self.setContentsMargins(20, 20, 20, 20) # Отступы по краям
self._spacing = 3 self._spacing = 20 # Отступ для анимации и предотвращения перекрытий
self._max_scale = 1.2 self._max_scale = 1.0 # Отключено масштабирование в layout
def addItem(self, item: QLayoutItem) -> None: def addItem(self, item: QLayoutItem) -> None:
self.itemList.append(item) self.itemList.append(item)
@@ -104,12 +138,10 @@ class FlowLayout(QLayout):
def minimumSize(self): def minimumSize(self):
size = QSize() size = QSize()
for item in self.itemList: for item in self.itemList:
# Учитываем максимальный масштаб при расчете минимального размера size = size.expandedTo(item.minimumSize())
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)
margins = self.contentsMargins() 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 return size
def doLayout(self, rect, testOnly): def doLayout(self, rect, testOnly):
@@ -157,7 +189,7 @@ class ClickableLabel(QLabel):
self._icon_size = icon_size self._icon_size = icon_size
self._icon_space = icon_space self._icon_space = icon_space
self._font_scale_factor = font_scale_factor self._font_scale_factor = font_scale_factor
self._card_width = 250 # Значение по умолчанию self._card_width = 250
if change_cursor: if change_cursor:
self.setCursor(Qt.CursorShape.PointingHandCursor) self.setCursor(Qt.CursorShape.PointingHandCursor)
self.updateFontSize() self.updateFontSize()
@@ -175,28 +207,23 @@ class ClickableLabel(QLabel):
self.update() self.update()
def setCardWidth(self, card_width: int): def setCardWidth(self, card_width: int):
"""Обновляет ширину карточки и пересчитывает размер шрифта."""
self._card_width = card_width self._card_width = card_width
self.updateFontSize() self.updateFontSize()
def updateFontSize(self): def updateFontSize(self):
"""Обновляет размер шрифта на основе card_width и font_scale_factor."""
font = self.font() font = self.font()
font_size = int(self._card_width * self._font_scale_factor) 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.setFont(font)
self.update() self.update()
def paintEvent(self, event): def paintEvent(self, event):
painter = QPainter(self) painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing) painter.setRenderHint(QPainter.RenderHint.Antialiasing)
rect = self.contentsRect() rect = self.contentsRect()
alignment = self.alignment() alignment = self.alignment()
icon_size = self._icon_size icon_size = self._icon_size
spacing = self._icon_space spacing = self._icon_space
text = self.text() text = self.text()
if self._icon: if self._icon:
@@ -205,17 +232,11 @@ class ClickableLabel(QLabel):
pixmap = None pixmap = None
fm = QFontMetrics(self.font()) fm = QFontMetrics(self.font())
# Считаем, сколько места остаётся под текст
available_width = rect.width() available_width = rect.width()
if pixmap: if pixmap:
available_width -= (icon_size + spacing) available_width -= (icon_size + spacing)
# Отступы по 2px с каждой стороны
available_width = max(0, available_width - 4) available_width = max(0, available_width - 4)
# Получаем «обрезанный» текст с многоточием
display_text = fm.elidedText(text, Qt.TextElideMode.ElideRight, available_width) display_text = fm.elidedText(text, Qt.TextElideMode.ElideRight, available_width)
text_width = fm.horizontalAdvance(display_text) text_width = fm.horizontalAdvance(display_text)
text_height = fm.height() text_height = fm.height()
total_width = text_width + (icon_size + spacing if pixmap else 0) total_width = text_width + (icon_size + spacing if pixmap else 0)
@@ -285,8 +306,6 @@ class AutoSizeButton(QPushButton):
self.setCursor(Qt.CursorShape.PointingHandCursor) self.setCursor(Qt.CursorShape.PointingHandCursor)
self.setFlat(True) self.setFlat(True)
# Изначально выставляем минимальную ширину
self.setMinimumWidth(50) self.setMinimumWidth(50)
self.adjustFontSize() self.adjustFontSize()
@@ -317,7 +336,6 @@ class AutoSizeButton(QPushButton):
if not self._update_size: if not self._update_size:
return return
# Определяем доступную ширину внутри кнопки
available_width = self.width() available_width = self.width()
if self._icon: if self._icon:
available_width -= self._icon_size available_width -= self._icon_size
@@ -328,7 +346,6 @@ class AutoSizeButton(QPushButton):
font = QFont(self._original_font) font = QFont(self._original_font)
text = self._original_text text = self._original_text
# Подбираем максимально возможный размер шрифта, при котором текст укладывается
chosen_size = self._max_font_size chosen_size = self._max_font_size
for font_size in range(self._max_font_size, self._min_font_size - 1, -1): for font_size in range(self._max_font_size, self._min_font_size - 1, -1):
font.setPointSize(font_size) font.setPointSize(font_size)
@@ -341,14 +358,12 @@ class AutoSizeButton(QPushButton):
font.setPointSize(chosen_size) font.setPointSize(chosen_size)
self.setFont(font) self.setFont(font)
# После выбора шрифта вычисляем требуемую ширину для полного отображения текста
fm = QFontMetrics(font) fm = QFontMetrics(font)
text_width = fm.horizontalAdvance(text) text_width = fm.horizontalAdvance(text)
required_width = text_width + margins.left() + margins.right() + self._padding * 2 required_width = text_width + margins.left() + margins.right() + self._padding * 2
if self._icon: if self._icon:
required_width += self._icon_size required_width += self._icon_size
# Если текущая ширина меньше требуемой, обновляем минимальную ширину
if self.width() < required_width: if self.width() < required_width:
self.setMinimumWidth(required_width) self.setMinimumWidth(required_width)
@@ -358,7 +373,6 @@ class AutoSizeButton(QPushButton):
if not self._update_size: if not self._update_size:
return super().sizeHint() return super().sizeHint()
else: else:
# Вычисляем оптимальный размер кнопки на основе текста и отступов
font = self.font() font = self.font()
fm = QFontMetrics(font) fm = QFontMetrics(font)
text_width = fm.horizontalAdvance(self._original_text) text_width = fm.horizontalAdvance(self._original_text)
@@ -369,7 +383,6 @@ class AutoSizeButton(QPushButton):
height = fm.height() + margins.top() + margins.bottom() + self._padding height = fm.height() + margins.top() + margins.bottom() + self._padding
return QSize(width, height) return QSize(width, height)
class NavLabel(QLabel): class NavLabel(QLabel):
clicked = Signal() clicked = Signal()
@@ -381,7 +394,6 @@ class NavLabel(QLabel):
self._isChecked = False self._isChecked = False
self.setProperty("checked", self._isChecked) self.setProperty("checked", self._isChecked)
self.setCursor(Qt.CursorShape.PointingHandCursor) self.setCursor(Qt.CursorShape.PointingHandCursor)
# Explicitly enable focus
self.setFocusPolicy(Qt.FocusPolicy.StrongFocus) self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
def setCheckable(self, checkable): def setCheckable(self, checkable):
@@ -400,7 +412,6 @@ class NavLabel(QLabel):
def mousePressEvent(self, event): def mousePressEvent(self, event):
if event.button() == Qt.MouseButton.LeftButton: if event.button() == Qt.MouseButton.LeftButton:
# Ensure widget can take focus on click
self.setFocus(Qt.FocusReason.MouseFocusReason) self.setFocus(Qt.FocusReason.MouseFocusReason)
if self._checkable: if self._checkable:
self.setChecked(not self._isChecked) self.setChecked(not self._isChecked)

View File

@@ -4,7 +4,7 @@ import re
from typing import cast, TYPE_CHECKING from typing import cast, TYPE_CHECKING
from PySide6.QtGui import QPixmap, QIcon from PySide6.QtGui import QPixmap, QIcon
from PySide6.QtWidgets import ( 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 PySide6.QtCore import Qt, QObject, Signal, QMimeDatabase, QTimer
from icoextract import IconExtractor, IconExtractorError 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.theme_manager import ThemeManager
from portprotonqt.custom_widgets import AutoSizeButton from portprotonqt.custom_widgets import AutoSizeButton
from portprotonqt.downloader import Downloader from portprotonqt.downloader import Downloader
import psutil
if TYPE_CHECKING: if TYPE_CHECKING:
from portprotonqt.main_window import MainWindow from portprotonqt.main_window import MainWindow
@@ -89,6 +90,86 @@ def generate_thumbnail(inputfile, outfile, size=128, force_resize=True):
class FileSelectedSignal(QObject): class FileSelectedSignal(QObject):
file_selected = Signal(str) # Сигнал с путем к выбранному файлу 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): class FileExplorer(QDialog):
def __init__(self, parent=None, theme=None, file_filter=None, initial_path=None, directory_only=False): def __init__(self, parent=None, theme=None, file_filter=None, initial_path=None, directory_only=False):
super().__init__(parent) super().__init__(parent)

View File

@@ -9,7 +9,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PROJECT VERSION\n" "Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\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" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language: de_DE\n" "Language: de_DE\n"
@@ -248,15 +248,19 @@ msgstr ""
msgid "Select All" msgid "Select All"
msgstr "" msgstr ""
#, python-brace-format
msgid "Launching {0}"
msgstr ""
msgid "Cancel"
msgstr ""
msgid "File Explorer" msgid "File Explorer"
msgstr "" msgstr ""
msgid "Select" msgid "Select"
msgstr "" msgstr ""
msgid "Cancel"
msgstr ""
msgid "Path: " msgid "Path: "
msgstr "" msgstr ""

View File

@@ -9,7 +9,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PROJECT VERSION\n" "Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\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" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language: es_ES\n" "Language: es_ES\n"
@@ -248,15 +248,19 @@ msgstr ""
msgid "Select All" msgid "Select All"
msgstr "" msgstr ""
#, python-brace-format
msgid "Launching {0}"
msgstr ""
msgid "Cancel"
msgstr ""
msgid "File Explorer" msgid "File Explorer"
msgstr "" msgstr ""
msgid "Select" msgid "Select"
msgstr "" msgstr ""
msgid "Cancel"
msgstr ""
msgid "Path: " msgid "Path: "
msgstr "" msgstr ""

View File

@@ -9,7 +9,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PortProtonQt 0.1.1\n" "Project-Id-Version: PortProtonQt 0.1.1\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\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" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n" "Language-Team: LANGUAGE <LL@li.org>\n"
@@ -246,15 +246,19 @@ msgstr ""
msgid "Select All" msgid "Select All"
msgstr "" msgstr ""
#, python-brace-format
msgid "Launching {0}"
msgstr ""
msgid "Cancel"
msgstr ""
msgid "File Explorer" msgid "File Explorer"
msgstr "" msgstr ""
msgid "Select" msgid "Select"
msgstr "" msgstr ""
msgid "Cancel"
msgstr ""
msgid "Path: " msgid "Path: "
msgstr "" msgstr ""

View File

@@ -9,8 +9,8 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PROJECT VERSION\n" "Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\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: 2025-08-26 13:16+0500\n" "PO-Revision-Date: 2025-08-31 12:28+0500\n"
"Last-Translator: \n" "Last-Translator: \n"
"Language: ru_RU\n" "Language: ru_RU\n"
"Language-Team: ru_RU <LL@li.org>\n" "Language-Team: ru_RU <LL@li.org>\n"
@@ -255,15 +255,19 @@ msgstr "Удалить"
msgid "Select All" msgid "Select All"
msgstr "Выбрать всё" msgstr "Выбрать всё"
#, python-brace-format
msgid "Launching {0}"
msgstr "Идёт запуск {0}"
msgid "Cancel"
msgstr "Отмена"
msgid "File Explorer" msgid "File Explorer"
msgstr "Проводник" msgstr "Проводник"
msgid "Select" msgid "Select"
msgstr "Выбрать" msgstr "Выбрать"
msgid "Cancel"
msgstr "Отмена"
msgid "Path: " msgid "Path: "
msgstr "Путь: " 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" # Возможные значения: "gradient", "scale"
# scale крайне нестабилен и требует доработки (используйте на свой страх и риск)
# "gradient" включает вращающийся градиент для обводки, "scale" увеличивает размер карточки # "gradient" включает вращающийся градиент для обводки, "scale" увеличивает размер карточки
"card_animation_type": "gradient", "card_animation_type": "gradient",

View File

@@ -1,6 +1,10 @@
import sys import sys
import subprocess 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.QtGui import QIcon, QAction
from PySide6.QtCore import QTimer from PySide6.QtCore import QTimer
from portprotonqt.logger import get_logger from portprotonqt.logger import get_logger
@@ -8,6 +12,7 @@ from portprotonqt.theme_manager import ThemeManager
import portprotonqt.themes.standart.styles as default_styles import portprotonqt.themes.standart.styles as default_styles
from portprotonqt.localization import _ from portprotonqt.localization import _
from portprotonqt.config_utils import read_favorites, read_theme_from_config, save_theme_to_config from portprotonqt.config_utils import read_favorites, read_theme_from_config, save_theme_to_config
from portprotonqt.dialogs import GameLaunchDialog
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -16,18 +21,25 @@ class TrayManager:
Обеспечивает: Обеспечивает:
- Показ/скрытие главного окна по двойному клику на иконку трея. - Показ/скрытие главного окна по двойному клику на иконку трея.
- Контекстное меню с опциями: Show/Hide (переключается в зависимости от состояния окна), - Контекстное меню с опциями: Show/Hide, Favorites, Recent Games, Themes, Exit.
Favorites (быстрый запуск избранных игр), Recent Games (быстрый запуск недавних игр), Themes (быстрая смена тем), Exit. - Динамическое заполнение меню Favorites, Recent Games и Themes.
- Меню Favorites, Recent Games и Themes динамически заполняются при показе (через aboutToShow). - Сворачивание в трей при закрытии окна, полное закрытие через Exit.
- При закрытии окна (крестик) приложение сворачивается в трей, а не закрывается.
Полное закрытие только через Exit в меню.
""" """
def __init__(self, main_window, app_name: str | None = None, theme=None): 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.app_name = app_name if app_name is not None else "PortProtonQt"
self.theme_manager = ThemeManager() self.theme_manager = ThemeManager()
self.theme = theme if theme is not None else default_styles selected_theme = read_theme_from_config()
self.current_theme_name = 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.main_window = main_window
self.tray_icon = QSystemTrayIcon(self.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.activated.connect(self.handle_tray_click)
self.tray_icon.setToolTip(self.app_name) self.tray_icon.setToolTip(self.app_name)
# Контекстное меню
self.tray_menu = QMenu() self.tray_menu = QMenu()
self.toggle_action = QAction(_("Show"), self.main_window) self.toggle_action = QAction(_("Show"), self.main_window)
self.toggle_action.triggered.connect(self.toggle_window_action) self.toggle_action.triggered.connect(self.toggle_window_action)
# Подменю для избранных игр
self.favorites_menu = QMenu(_("Favorites")) self.favorites_menu = QMenu(_("Favorites"))
self.favorites_menu.aboutToShow.connect(self.populate_favorites_menu) self.favorites_menu.aboutToShow.connect(self.populate_favorites_menu)
# Подменю для недавних игр
self.recent_menu = QMenu(_("Recent Games")) self.recent_menu = QMenu(_("Recent Games"))
self.recent_menu.aboutToShow.connect(self.populate_recent_menu) self.recent_menu.aboutToShow.connect(self.populate_recent_menu)
# Подменю для тем
self.themes_menu = QMenu(_("Themes")) self.themes_menu = QMenu(_("Themes"))
self.themes_menu.aboutToShow.connect(self.populate_themes_menu) self.themes_menu.aboutToShow.connect(self.populate_themes_menu)
# Добавляем действия в меню
self.tray_menu.addAction(self.toggle_action) self.tray_menu.addAction(self.toggle_action)
self.tray_menu.addSeparator() self.tray_menu.addSeparator()
self.tray_menu.addMenu(self.favorites_menu) self.tray_menu.addMenu(self.favorites_menu)
@@ -74,41 +81,35 @@ class TrayManager:
self.tray_icon.setContextMenu(self.tray_menu) self.tray_icon.setContextMenu(self.tray_menu)
self.tray_icon.show() self.tray_icon.show()
# Флаг для принудительного выхода
self.main_window.is_exiting = False self.main_window.is_exiting = False
# Переменные для отслеживания двойного клика
self.click_count = 0 self.click_count = 0
self.click_timer = QTimer() self.click_timer = QTimer()
self.click_timer.setSingleShot(True) self.click_timer.setSingleShot(True)
self.click_timer.timeout.connect(self.reset_click_count) self.click_timer.timeout.connect(self.reset_click_count)
self.launch_dialog = None
def update_toggle_action(self): def update_toggle_action(self):
"""Update toggle_action text based on window visibility."""
if self.main_window.isVisible(): if self.main_window.isVisible():
self.toggle_action.setText(_("Hide")) self.toggle_action.setText(_("Hide"))
else: else:
self.toggle_action.setText(_("Show")) self.toggle_action.setText(_("Show"))
def handle_tray_click(self, reason): def handle_tray_click(self, reason):
"""Обрабатывает клики по иконке трея, отслеживая двойной клик."""
if reason == QSystemTrayIcon.ActivationReason.Trigger: if reason == QSystemTrayIcon.ActivationReason.Trigger:
self.click_count += 1 self.click_count += 1
if self.click_count == 1: if self.click_count == 1:
# Запускаем таймер для ожидания второго клика (300 мс - стандартное время для двойного клика)
self.click_timer.start(300) self.click_timer.start(300)
elif self.click_count == 2: elif self.click_count == 2:
# Двойной клик зафиксирован
self.click_timer.stop() self.click_timer.stop()
self.toggle_window_action() self.toggle_window_action()
self.click_count = 0 self.click_count = 0
def reset_click_count(self): def reset_click_count(self):
"""Сбрасывает счетчик кликов, если таймер истек."""
self.click_count = 0 self.click_count = 0
def toggle_window_action(self): def toggle_window_action(self):
"""Toggle window visibility and update action text."""
if self.main_window.isVisible(): if self.main_window.isVisible():
self.main_window.hide() self.main_window.hide()
else: else:
@@ -117,7 +118,6 @@ class TrayManager:
self.main_window.activateWindow() self.main_window.activateWindow()
def populate_favorites_menu(self): def populate_favorites_menu(self):
"""Динамически заполняет меню избранных игр с указанием источника."""
self.favorites_menu.clear() self.favorites_menu.clear()
favorites = read_favorites() favorites = read_favorites()
if not favorites: if not favorites:
@@ -134,13 +134,12 @@ class TrayManager:
exec_line, source = game_data exec_line, source = game_data
action_text = f"{fav} ({source})" action_text = f"{fav} ({source})"
action = QAction(action_text, self.main_window) 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) self.favorites_menu.addAction(action)
else: else:
logger.warning(f"Exec line not found for favorite: {fav}") logger.warning(f"Exec line not found for favorite: {fav}")
def populate_recent_menu(self): def populate_recent_menu(self):
"""Динамически заполняет меню недавних игр (топ-5 по timestamp) с указанием источника."""
self.recent_menu.clear() self.recent_menu.clear()
if not self.main_window.games: if not self.main_window.games:
no_recent_action = QAction(_("No recent games"), self.main_window) no_recent_action = QAction(_("No recent games"), self.main_window)
@@ -156,45 +155,99 @@ class TrayManager:
source = game[12] source = game[12]
action_text = f"{game_name} ({source})" action_text = f"{game_name} ({source})"
action = QAction(action_text, self.main_window) 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) 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): def populate_themes_menu(self):
"""Динамически заполняет меню тем, позволяя переключать доступные темы."""
self.themes_menu.clear() self.themes_menu.clear()
available_themes = self.theme_manager.get_available_themes() available_themes = self.theme_manager.get_available_themes()
current_theme = read_theme_from_config()
for theme_name in sorted(available_themes): for theme_name in sorted(available_themes):
action = QAction(theme_name, self.main_window) action = QAction(theme_name, self.main_window)
action.setCheckable(True) 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)) action.triggered.connect(lambda checked=False, tn=theme_name: self.switch_theme(tn))
self.themes_menu.addAction(action) self.themes_menu.addAction(action)
def switch_theme(self, theme_name: str): def switch_theme(self, theme_name: str):
"""Сохраняет выбранную тему и перезапускает приложение для применения изменений."""
try: try:
# Сохраняем новую тему в конфигурации
save_theme_to_config(theme_name) save_theme_to_config(theme_name)
logger.info(f"Saved theme {theme_name}, restarting application to apply changes") logger.info(f"Saved theme {theme_name}, restarting application to apply changes")
# Получаем текущий исполняемый файл и аргументы
executable = sys.executable executable = sys.executable
args = sys.argv args = sys.argv
# Закрываем текущее приложение
self.main_window.is_exiting = True self.main_window.is_exiting = True
QApplication.quit() QApplication.quit()
# Перезапускаем приложение
subprocess.Popen([executable] + args) subprocess.Popen([executable] + args)
except Exception as e: except Exception as e:
logger.error(f"Failed to switch theme to {theme_name}: {e}") logger.error(f"Failed to switch theme to {theme_name}: {e}")
# В случае ошибки сохраняем стандартную тему
save_theme_to_config("standart") save_theme_to_config("standart")
# Перезапускаем приложение с дефолтной темой
executable = sys.executable executable = sys.executable
args = sys.argv args = sys.argv
self.main_window.is_exiting = True self.main_window.is_exiting = True
@@ -202,7 +255,6 @@ class TrayManager:
subprocess.Popen([executable] + args) subprocess.Popen([executable] + args)
def force_exit(self): def force_exit(self):
"""Принудительно закрывает приложение."""
self.main_window.is_exiting = True self.main_window.is_exiting = True
self.main_window.close() self.main_window.close()
sys.exit(0) sys.exit(0)

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "portprotonqt" name = "portprotonqt"
version = "0.1.4" version = "0.1.5"
description = "A project to rewrite PortProton (PortWINE) using PySide" description = "A project to rewrite PortProton (PortWINE) using PySide"
readme = "README.md" readme = "README.md"
license = { text = "GPL-3.0" } license = { text = "GPL-3.0" }

884
uv.lock generated

File diff suppressed because it is too large Load Diff