feat: returned tray and added favorites and recent to it
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
This commit is contained in:
@@ -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")
|
||||||
|
@@ -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()
|
||||||
|
141
portprotonqt/tray_manager.py
Normal file
141
portprotonqt/tray_manager.py
Normal 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)
|
Reference in New Issue
Block a user