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

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