feat(tray): add modal game launch dialog with process detection and cancellation

Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
This commit is contained in:
2025-08-31 12:20:52 +05:00
parent 888c9ac387
commit 84f560ed30
2 changed files with 170 additions and 37 deletions

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 if game_name else _("Game")
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

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