# Copyright (C) 2024–present Loren Eteval & contributors # # This file is part of Furious. # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . """Provide widgets for system tray icon.""" from __future__ import annotations from Furious.Frozenlib import * from Furious.Qt import * from Furious.Qt import gettext as _ from Furious.Actions import * from PySide6 import QtCore from PySide6.QtWidgets import QSystemTrayIcon import logging import platform logger = logging.getLogger(__name__) __all__ = ['TrayIcon'] _MACOS_DISCONNECTED_MONOCHROME_OPACITY = 0.55 _TRANSLATABLE_ADMIN = [ _('Administrator'), ] class TrayIcon( Mixins.QTranslatable, Mixins.ConnectionAware, Mixins.ThemeAware, QSystemTrayIcon, ): """Represent system tray icon.""" def __init__(self, *args, **kwargs): """Initialize the tray icon.""" super().__init__(*args, **kwargs) actions = [ ConnectAction(isTrayAction=True), RoutingAction(isTrayAction=True), SystemProxyAction(isTrayAction=True), AppQSeperator(), ImportAction(isTrayAction=True), EditConfigurationAction(isTrayAction=True), AppQSeperator(), LanguageAction(isTrayAction=True), SettingsAction(isTrayAction=True), AppQSeperator(), ExitAction(isTrayAction=True), ] self._actions = actions self._dynamicMenuRefs = [] # Some old version PySide6 does not have setMenu method # for QAction. Protect it. Currently only used in TrayIcon if hasattr(AppQAction, 'setMenu'): logger.info('contextMenu uses setMenu implementation') for action in actions: if isinstance(action, AppQAction): if hasattr(self, f'{action}'): logger.warning(f'{self} already has action {action}') setattr(self, f'{action}', action) self._menu = AppQMenu(*actions) self.setContextMenu(self._menu) else: logger.info('contextMenu uses addMenu implementation') self._refs = [] self._menu = AppQMenu() for action in actions: if isinstance(action, AppQAction): if hasattr(self, f'{action}'): logger.warning(f'{self} already has action {action}') setattr(self, f'{action}', action) if action._menu is None: self._menu.addAction(action) else: menu = AppQMenu(*action._menu._actions, title=action.text()) menu.setIcon(action.icon()) self._refs.append(menu) self._dynamicMenuRefs.append((action, menu)) self._menu.addMenu(menu) else: self._menu.addSeparator() self.setContextMenu(self._menu) self._menu.aboutToShow.connect(self.rebuildDynamicMenus) self.setDisconnectedIcon() self.activated.connect(self.handleActivated) def rebuildDynamicMenus(self): """Handle rebuild dynamic menus for the system tray icon.""" for action in self._actions: if hasattr(action, 'rebuildMenu'): action.rebuildMenu() for action, menu in self._dynamicMenuRefs: menu.clear() for childAction in action._menu._actions: if isinstance(childAction, AppQSeperator): menu.addSeparator() elif isinstance(childAction, AppQAction): menu.addAction(childAction) menu.menuAction().setVisible(action.isVisible()) def bootstrap(self): """Handle bootstrap for the system tray icon.""" if AppSettings.isStateON_('StartupOnBoot'): # Rrefresh startup application location StartupOnBoot.on_() if AppSettings.isStateON_('Connect'): self.ConnectAction.trigger() def showMessage(self, message: str, *args, **kwargs): """Show message.""" if message: super().showMessage(_(APPLICATION_NAME), message, *args, **kwargs) def setMonochromeIconByTheme(self, theme): """Set monochrome icon by theme.""" def switchMonochrome(): """Handle switch monochrome for the system tray icon.""" if theme == 'Dark': self.setIcon(bootstrapIconWhite('monochrome-rocket-takeoff.svg')) else: self.setIcon(bootstrapIcon('monochrome-rocket-takeoff.svg')) if PLATFORM == 'Linux': # linux. Always use white icon self.setIcon(bootstrapIconWhite('monochrome-rocket-takeoff.svg')) elif PLATFORM == 'Darwin': try: release, versioninfo, machine = platform.mac_ver() majorRelease = int(release.split('.')[0]) if majorRelease <= 13: switchMonochrome() else: # macOS 14+. Always use white icon self.setIcon(bootstrapIconWhite('monochrome-rocket-takeoff.svg')) except Exception: # Any non-exit exceptions # Fall back to switch switchMonochrome() else: switchMonochrome() def setMonochromeIcon(self): """Set monochrome icon.""" if APP().isSystemTrayConnected(): self.setConnectedMonochromeIcon() else: self.setDisconnectedMonochromeIcon() def setConnectedMonochromeIcon(self): """Set connected monochrome icon.""" if PLATFORM == 'Darwin': self.setIcon(bootstrapIconMask('monochrome-rocket-takeoff.svg')) else: self.setMonochromeIconByTheme(APP().theme()) def setDisconnectedMonochromeIcon(self): """Set disconnected monochrome icon.""" if PLATFORM == 'Darwin': icon = bootstrapIconWithOpacity( 'monochrome-rocket-takeoff.svg', _MACOS_DISCONNECTED_MONOCHROME_OPACITY, isMask=True, ) self.setIcon(icon) else: self.setMonochromeIconByTheme(APP().theme()) def setDisconnectedIcon(self): """Set disconnected icon.""" if AppSettings.isStateON_('UseMonochromeTrayIcon'): self.setDisconnectedMonochromeIcon() return if ( PLATFORM == 'Darwin' or SystemRuntime.isWindows7() or (PLATFORM == 'Windows' and APP().theme() == 'Light') ): # Darker self.setIcon(bootstrapIcon('rocket-takeoff-disconnected-dark.svg')) else: self.setIcon(bootstrapIcon('rocket-takeoff-disconnected.svg')) def setConnectedIcon(self): """Set connected icon.""" if AppSettings.isStateON_('UseMonochromeTrayIcon'): self.setConnectedMonochromeIcon() return if SystemRuntime.isAdmin(): self.setIcon(bootstrapIcon('rocket-takeoff-connected-admin.svg')) else: if ( PLATFORM == 'Darwin' or SystemRuntime.isWindows7() or (PLATFORM == 'Windows' and APP().theme() == 'Light') ): # Darker self.setIcon(bootstrapIcon('rocket-takeoff-connected-dark.svg')) else: self.setIcon(bootstrapIcon('rocket-takeoff-connected.svg')) @QtCore.Slot(QSystemTrayIcon.ActivationReason) def handleActivated(self, reason): """Handle activated.""" if reason == QSystemTrayIcon.ActivationReason.DoubleClick: APP().mainWindow.show() def setCustomToolTip(self): """Set custom tool tip.""" if SystemRuntime.isAdmin(): self.setToolTip( f'{_(APPLICATION_NAME)} {APPLICATION_VERSION} ({_(ADMINISTRATOR_NAME)})' ) else: self.setToolTip(f'{_(APPLICATION_NAME)} {APPLICATION_VERSION}') def disconnectedCallback(self): """Update the system tray icon for a disconnected state.""" self.setDisconnectedIcon() def connectedCallback(self): """Update the system tray icon for a connected state.""" self.setConnectedIcon() def themeChangedCallback(self, theme): """Update the system tray icon for a theme change.""" if AppSettings.isStateON_('UseMonochromeTrayIcon'): # 'theme' is not used. Instead, always query current theme self.setMonochromeIcon() return if APP().isSystemTrayConnected(): self.setConnectedIcon() else: self.setDisconnectedIcon() def retranslate(self): """Refresh translated text for the system tray icon.""" self.setCustomToolTip()