feat: returned tray and added favorites and recent to it

Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
This commit is contained in:
2025-08-26 13:00:16 +05:00
parent 31a7ef3e7e
commit 46253115ff
3 changed files with 191 additions and 42 deletions

View File

@@ -30,7 +30,7 @@ def main():
args = parse_args() args = parse_args()
window = MainWindow() window = MainWindow(app_name=__app_name__)
if args.fullscreen: if args.fullscreen:
logger.info("Launching in fullscreen mode due to --fullscreen flag") logger.info("Launching in fullscreen mode due to --fullscreen flag")

View File

@@ -34,6 +34,7 @@ from portprotonqt.localization import _, get_egs_language, read_metadata_transla
from portprotonqt.logger import get_logger from portprotonqt.logger import get_logger
from portprotonqt.howlongtobeat_api import HowLongToBeat from portprotonqt.howlongtobeat_api import HowLongToBeat
from portprotonqt.downloader import Downloader from portprotonqt.downloader import Downloader
from portprotonqt.tray_manager import TrayManager
from PySide6.QtWidgets import (QLineEdit, QMainWindow, QStatusBar, QWidget, QVBoxLayout, QLabel, QHBoxLayout, QStackedWidget, QComboBox, QScrollArea, QSlider, from PySide6.QtWidgets import (QLineEdit, QMainWindow, QStatusBar, QWidget, QVBoxLayout, QLabel, QHBoxLayout, QStackedWidget, QComboBox, QScrollArea, QSlider,
QDialog, QFormLayout, QFrame, QGraphicsDropShadowEffect, QMessageBox, QApplication, QPushButton, QProgressBar, QCheckBox, QSizePolicy) QDialog, QFormLayout, QFrame, QGraphicsDropShadowEffect, QMessageBox, QApplication, QPushButton, QProgressBar, QCheckBox, QSizePolicy)
@@ -52,10 +53,11 @@ class MainWindow(QMainWindow):
update_progress = Signal(int) # Signal to update progress bar update_progress = Signal(int) # Signal to update progress bar
update_status_message = Signal(str, int) # Signal to update status message update_status_message = Signal(str, int) # Signal to update status message
def __init__(self): def __init__(self, app_name: str):
super().__init__() super().__init__()
# Создаём менеджер тем и читаем, какая тема выбрана # Создаём менеджер тем и читаем, какая тема выбрана
self.theme_manager = ThemeManager() self.theme_manager = ThemeManager()
self.is_exiting = False
selected_theme = read_theme_from_config() selected_theme = read_theme_from_config()
self.current_theme_name = selected_theme self.current_theme_name = selected_theme
try: try:
@@ -67,8 +69,9 @@ class MainWindow(QMainWindow):
save_theme_to_config("standart") save_theme_to_config("standart")
if not self.theme: if not self.theme:
self.theme = default_styles self.theme = default_styles
self.tray_manager = TrayManager(self, app_name, self.current_theme_name)
self.card_width = read_card_size() self.card_width = read_card_size()
self.setWindowTitle("PortProtonQt") self.setWindowTitle(app_name)
self.setMinimumSize(800, 600) self.setMinimumSize(800, 600)
self.games = [] self.games = []
@@ -2266,46 +2269,51 @@ class MainWindow(QMainWindow):
def closeEvent(self, event): def closeEvent(self, event):
"""Завершает все дочерние процессы и сохраняет настройки при закрытии окна.""" """Обработчик закрытия окна: сворачивает приложение в трей, если не требуется принудительный выход."""
for proc in self.game_processes: if hasattr(self, 'is_exiting') and self.is_exiting:
try: # Принудительное закрытие: завершаем процессы и приложение
parent = psutil.Process(proc.pid) for proc in self.game_processes:
children = parent.children(recursive=True) try:
for child in children: parent = psutil.Process(proc.pid)
try: children = parent.children(recursive=True)
logger.debug(f"Terminating child process {child.pid}") for child in children:
child.terminate() try:
except psutil.NoSuchProcess: logger.debug(f"Terminating child process {child.pid}")
logger.debug(f"Child process {child.pid} already terminated") child.terminate()
psutil.wait_procs(children, timeout=5) except psutil.NoSuchProcess:
for child in children: logger.debug(f"Child process {child.pid} already terminated")
if child.is_running(): psutil.wait_procs(children, timeout=5)
logger.debug(f"Killing child process {child.pid}") for child in children:
child.kill() if child.is_running():
logger.debug(f"Terminating process group {proc.pid}") logger.debug(f"Killing child process {child.pid}")
os.killpg(os.getpgid(proc.pid), signal.SIGTERM) child.kill()
except (psutil.NoSuchProcess, ProcessLookupError) as e: logger.debug(f"Terminating process group {proc.pid}")
logger.debug(f"Process {proc.pid} already terminated: {e}") os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
except (psutil.NoSuchProcess, ProcessLookupError) as e:
logger.debug(f"Process {proc.pid} already terminated: {e}")
self.game_processes = [] # Очищаем список процессов self.game_processes = [] # Очищаем список процессов
# Сохраняем настройки окна # Очищаем таймеры
if not read_fullscreen_config(): if hasattr(self, 'games_load_timer') and self.games_load_timer.isActive():
logger.debug(f"Saving window geometry: {self.width()}x{self.height()}") self.games_load_timer.stop()
save_window_geometry(self.width(), self.height()) if hasattr(self, 'settingsDebounceTimer') and self.settingsDebounceTimer.isActive():
save_card_size(self.card_width) self.settingsDebounceTimer.stop()
if hasattr(self, 'searchDebounceTimer') and self.searchDebounceTimer.isActive():
self.searchDebounceTimer.stop()
if hasattr(self, 'checkProcessTimer') and self.checkProcessTimer and self.checkProcessTimer.isActive():
self.checkProcessTimer.stop()
self.checkProcessTimer.deleteLater()
self.checkProcessTimer = None
# Очищаем таймеры и другие ресурсы # Сохраняем настройки окна
if hasattr(self, 'games_load_timer') and self.games_load_timer.isActive(): if not read_fullscreen_config():
self.games_load_timer.stop() logger.debug(f"Saving window geometry: {self.width()}x{self.height()}")
if hasattr(self, 'settingsDebounceTimer') and self.settingsDebounceTimer.isActive(): save_window_geometry(self.width(), self.height())
self.settingsDebounceTimer.stop() save_card_size(self.card_width)
if hasattr(self, 'searchDebounceTimer') and self.searchDebounceTimer.isActive():
self.searchDebounceTimer.stop()
if hasattr(self, 'checkProcessTimer') and self.checkProcessTimer and self.checkProcessTimer.isActive():
self.checkProcessTimer.stop()
self.checkProcessTimer.deleteLater()
self.checkProcessTimer = None
QApplication.quit() event.accept()
event.accept() else:
# Сворачиваем в трей вместо закрытия
self.hide()
event.ignore()

View File

@@ -0,0 +1,141 @@
import sys
from PySide6.QtWidgets import QSystemTrayIcon, QMenu
from PySide6.QtGui import QIcon, QAction
from portprotonqt.logger import get_logger
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
logger = get_logger(__name__)
class TrayManager:
"""Модуль управления системным треем для PortProtonQt.
Обеспечивает:
- Показ/скрытие главного окна по клику на иконку трея.
- Контекстное меню с опциями: Show/Hide (переключается в зависимости от состояния окна),
Favorites (быстрый запуск избранных игр), Recent Games (быстрый запуск недавних игр), Exit.
- Меню Favorites и Recent Games динамически заполняются при показе (через aboutToShow).
- При закрытии окна (крестик) приложение сворачивается в трей, а не закрывается.
Полное закрытие только через 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()
self.main_window = main_window
self.tray_icon = QSystemTrayIcon(self.main_window)
icon = self.theme_manager.get_icon("portproton", self.current_theme_name)
if isinstance(icon, str):
icon = QIcon(icon)
elif icon is None:
icon = QIcon()
self.tray_icon.setIcon(icon)
self.tray_icon.activated.connect(self.toggle_window)
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)
# Подменю для недавних игр (топ-5 по последнему запуску)
self.recent_menu = QMenu(_("Recent Games"))
self.recent_menu.aboutToShow.connect(self.populate_recent_menu)
# Добавляем действия в меню
self.tray_menu.addAction(self.toggle_action)
self.tray_menu.addSeparator()
self.tray_menu.addMenu(self.favorites_menu)
self.tray_menu.addMenu(self.recent_menu)
self.tray_menu.addSeparator()
exit_action = QAction(_("Exit"), self.main_window)
exit_action.triggered.connect(self.force_exit)
self.tray_menu.addAction(exit_action)
self.tray_menu.aboutToShow.connect(self.update_toggle_action)
self.tray_icon.setContextMenu(self.tray_menu)
self.tray_icon.show()
# Флаг для принудительного выхода
self.main_window.is_exiting = False
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 toggle_window(self, reason):
"""Переключает видимость окна по клику на иконку трея."""
if reason == QSystemTrayIcon.ActivationReason.Trigger:
self.toggle_window_action()
def toggle_window_action(self):
"""Toggle window visibility and update action text."""
if self.main_window.isVisible():
self.main_window.hide()
else:
self.main_window.show()
self.main_window.raise_()
self.main_window.activateWindow()
def populate_favorites_menu(self):
"""Динамически заполняет меню избранных игр с указанием источника."""
self.favorites_menu.clear()
favorites = read_favorites()
if not favorites:
no_fav_action = QAction(_("No favorites"), self.main_window)
no_fav_action.setEnabled(False)
self.favorites_menu.addAction(no_fav_action)
return
game_map = {game[0]: (game[4], game[12]) for game in self.main_window.games}
for fav in sorted(favorites):
game_data = game_map.get(fav)
if game_data:
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))
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)
no_recent_action.setEnabled(False)
self.recent_menu.addAction(no_recent_action)
return
recent_games = sorted(self.main_window.games, key=lambda g: g[10], reverse=True)[:5]
for game in recent_games:
game_name = game[0]
exec_line = game[4]
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))
self.recent_menu.addAction(action)
def force_exit(self):
"""Принудительно закрывает приложение."""
self.main_window.is_exiting = True
self.main_window.close()
sys.exit(0)