diff --git a/portprotonqt/app.py b/portprotonqt/app.py index 69e33e5..582f9c0 100644 --- a/portprotonqt/app.py +++ b/portprotonqt/app.py @@ -30,7 +30,7 @@ def main(): args = parse_args() - window = MainWindow() + window = MainWindow(app_name=__app_name__) if args.fullscreen: logger.info("Launching in fullscreen mode due to --fullscreen flag") diff --git a/portprotonqt/main_window.py b/portprotonqt/main_window.py index f886e81..c656931 100644 --- a/portprotonqt/main_window.py +++ b/portprotonqt/main_window.py @@ -34,6 +34,7 @@ from portprotonqt.localization import _, get_egs_language, read_metadata_transla from portprotonqt.logger import get_logger from portprotonqt.howlongtobeat_api import HowLongToBeat 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, 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_status_message = Signal(str, int) # Signal to update status message - def __init__(self): + def __init__(self, app_name: str): super().__init__() # Создаём менеджер тем и читаем, какая тема выбрана self.theme_manager = ThemeManager() + self.is_exiting = False selected_theme = read_theme_from_config() self.current_theme_name = selected_theme try: @@ -67,8 +69,9 @@ class MainWindow(QMainWindow): save_theme_to_config("standart") if not self.theme: self.theme = default_styles + self.tray_manager = TrayManager(self, app_name, self.current_theme_name) self.card_width = read_card_size() - self.setWindowTitle("PortProtonQt") + self.setWindowTitle(app_name) self.setMinimumSize(800, 600) self.games = [] @@ -2266,46 +2269,51 @@ class MainWindow(QMainWindow): def closeEvent(self, event): - """Завершает все дочерние процессы и сохраняет настройки при закрытии окна.""" - for proc in self.game_processes: - try: - parent = psutil.Process(proc.pid) - children = parent.children(recursive=True) - for child in children: - try: - logger.debug(f"Terminating child process {child.pid}") - child.terminate() - except psutil.NoSuchProcess: - logger.debug(f"Child process {child.pid} already terminated") - psutil.wait_procs(children, timeout=5) - for child in children: - if child.is_running(): - logger.debug(f"Killing child process {child.pid}") - child.kill() - logger.debug(f"Terminating process group {proc.pid}") - os.killpg(os.getpgid(proc.pid), signal.SIGTERM) - except (psutil.NoSuchProcess, ProcessLookupError) as e: - logger.debug(f"Process {proc.pid} already terminated: {e}") + """Обработчик закрытия окна: сворачивает приложение в трей, если не требуется принудительный выход.""" + if hasattr(self, 'is_exiting') and self.is_exiting: + # Принудительное закрытие: завершаем процессы и приложение + for proc in self.game_processes: + try: + parent = psutil.Process(proc.pid) + children = parent.children(recursive=True) + for child in children: + try: + logger.debug(f"Terminating child process {child.pid}") + child.terminate() + except psutil.NoSuchProcess: + logger.debug(f"Child process {child.pid} already terminated") + psutil.wait_procs(children, timeout=5) + for child in children: + if child.is_running(): + logger.debug(f"Killing child process {child.pid}") + child.kill() + logger.debug(f"Terminating process group {proc.pid}") + 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(): - logger.debug(f"Saving window geometry: {self.width()}x{self.height()}") - save_window_geometry(self.width(), self.height()) - save_card_size(self.card_width) + # Очищаем таймеры + if hasattr(self, 'games_load_timer') and self.games_load_timer.isActive(): + self.games_load_timer.stop() + if hasattr(self, 'settingsDebounceTimer') and self.settingsDebounceTimer.isActive(): + 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(): - self.games_load_timer.stop() - if hasattr(self, 'settingsDebounceTimer') and self.settingsDebounceTimer.isActive(): - 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 not read_fullscreen_config(): + logger.debug(f"Saving window geometry: {self.width()}x{self.height()}") + save_window_geometry(self.width(), self.height()) + save_card_size(self.card_width) - QApplication.quit() - event.accept() + event.accept() + else: + # Сворачиваем в трей вместо закрытия + self.hide() + event.ignore() diff --git a/portprotonqt/tray_manager.py b/portprotonqt/tray_manager.py new file mode 100644 index 0000000..e8217f9 --- /dev/null +++ b/portprotonqt/tray_manager.py @@ -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)