diff --git a/Furious/Application/DesktopApplication.py b/Furious/Application/DesktopApplication.py index ce0c208..a8e3ebf 100644 --- a/Furious/Application/DesktopApplication.py +++ b/Furious/Application/DesktopApplication.py @@ -24,6 +24,7 @@ from Furious.Interface import * from Furious.Core import Tun2socks from Furious.Backends import OFFICIAL_PLUGIN_TYPES from Furious.Controllers import ( + APPLICATION_THEME_SETTING, ConnectionController, RoutingController, SettingsController, @@ -322,8 +323,8 @@ class DesktopApplication(ApplicationRunner, SingletonApplication): self._userSubs = Storage.UserSubs() self._userTUNSettings = Storage.UserTUNSettings() - def isDarkMode(self): - """Return whether dark mode.""" + def isSystemDarkMode(self): + """Return whether the current system palette appears dark.""" backgroudColor = self.palette().color(QPalette.ColorRole.Window) return backgroudColor.lightness() < 128 @@ -340,24 +341,26 @@ class DesktopApplication(ApplicationRunner, SingletonApplication): logger.error('darkdetect.theme() is not implemented on this platform') - return AppStyleSheet.Dark if self.isDarkMode() else AppStyleSheet.Light + return AppStyleSheet.Dark if self.isSystemDarkMode() else AppStyleSheet.Light - def isDarkModeEnabled(self): - # if SystemRuntime.flatpakID(): - # return self.isDarkMode() - # - # if not SystemRuntime.isAdmin(): - # return AppSettings.isStateON_('DarkMode') - # else: - # return self.isDarkMode() + def themePreference(self) -> ApplicationTheme: + """Return the authoritative persisted application theme preference.""" + return ApplicationTheme(AppSettings.get(APPLICATION_THEME_SETTING)) - """Return whether dark mode enabled.""" - return AppSettings.isStateON_('DarkMode') + def followsSystemAppearance(self) -> bool: + """Return whether system appearance controls the application theme.""" + return self.themePreference() == ApplicationTheme.System + + def usesForcedDarkTheme(self) -> bool: + """Return whether the application explicitly forces its dark theme.""" + return self.themePreference() == ApplicationTheme.Dark def theme(self): - """Return the theme value used by the application.""" - if self.isDarkModeEnabled(): - return AppStyleSheet.Dark + """Resolve the effective light or dark application theme.""" + preference = self.themePreference() + + if preference != ApplicationTheme.System: + return preference.value return self.systemTheme() @@ -365,15 +368,9 @@ class DesktopApplication(ApplicationRunner, SingletonApplication): """Handle apply style sheet for theme for the application.""" self.setStyleSheet(AppStyleSheet.forTheme(theme)) - def switchToDarkMode(self): - """Handle switch to dark mode for the application.""" - self.applyStyleSheetForTheme(AppStyleSheet.Dark) - - Mixins.ThemeAware.callThemeChangedCallbackUnchecked(AppStyleSheet.Dark) - - def switchToAutoMode(self): - """Handle switch to auto mode for the application.""" - theme = self.systemTheme() + def applyThemePreference(self): + """Apply the resolved preference and refresh every theme-aware object.""" + theme = self.theme() self.applyStyleSheetForTheme(theme) @@ -382,11 +379,18 @@ class DesktopApplication(ApplicationRunner, SingletonApplication): @QtCore.Slot(str) def handleSystemThemeChanged(self, theme): """Handle system theme changed.""" + if not self.followsSystemAppearance(): + logger.info( + f'ignore system theme \'{theme}\' change while application theme ' + f'is forced to \'{self.themePreference().value}\'' + ) + + return + if theme not in [AppStyleSheet.Dark, AppStyleSheet.Light]: theme = self.systemTheme() - if not self.isDarkModeEnabled(): - self.applyStyleSheetForTheme(theme) + self.applyStyleSheetForTheme(theme) Mixins.ThemeAware.callThemeChangedCallback(theme) @@ -590,6 +594,10 @@ class DesktopApplication(ApplicationRunner, SingletonApplication): SystemProxy.off() SystemProxy.daemonOn_() + # Resolve the stored preference before constructing application UI so + # newly created widgets use the correct palette from their first frame. + self.applyThemePreference() + self.mainWindow = MainWindow() self.systemTray = TrayIcon() @@ -610,11 +618,6 @@ class DesktopApplication(ApplicationRunner, SingletonApplication): # Ensure the main window is shown when the dock icon is clicked self.applicationStateChanged.connect(onApplicationStateChange) - if AppSettings.isStateON_('DarkMode'): - self.switchToDarkMode() - else: - self.switchToAutoMode() - self.systemTray.show() self.systemTray.setCustomToolTip() self.systemTray.bootstrap() diff --git a/Furious/Backends/Xray/AssetListWidget.py b/Furious/Backends/Xray/AssetListWidget.py index eb9fb6a..35b76ad 100644 --- a/Furious/Backends/Xray/AssetListWidget.py +++ b/Furious/Backends/Xray/AssetListWidget.py @@ -128,8 +128,8 @@ class XrayAssetListWidget(Mixins.ThemeAware, AppQListWidget): item = QListWidgetItem(f'{filename:{maxlen + 6}}{mdate}') item.setFont(QFont(AppFontName())) - if AppSettings.isStateON_('DarkMode'): - # Custom dark mode + if APP().usesForcedDarkTheme(): + # Explicit dark theme item.setIcon(bootstrapIconWhite('file-earmark.svg')) else: if theme == 'Dark': diff --git a/Furious/Controllers/SettingsController.py b/Furious/Controllers/SettingsController.py index b543617..59dae2e 100644 --- a/Furious/Controllers/SettingsController.py +++ b/Furious/Controllers/SettingsController.py @@ -27,10 +27,21 @@ from Furious.Service.TrafficStatsManager import ( METRICS_COLLECTION_SETTING, ) -__all__ = ['SettingsController'] +from PySide6 import QtCore + +__all__ = ['APPLICATION_THEME_SETTING', 'SettingsController'] + +APPLICATION_THEME_SETTING = 'ApplicationTheme' +# Migrate legacy settings +_LEGACY_DARK_MODE_SETTING = 'DarkMode' registerAppSettings('VPNMode', isBinary=True) -registerAppSettings('DarkMode', isBinary=True) +registerAppSettings( + APPLICATION_THEME_SETTING, + validRange=[theme.value for theme in ApplicationTheme], + default=ApplicationTheme.System.value, +) +registerAppSettings(_LEGACY_DARK_MODE_SETTING, isBinary=True) registerAppSettings('UseMonochromeTrayIcon', isBinary=True) if PLATFORM == 'Darwin': @@ -56,9 +67,63 @@ registerAppSettings( ) +def _legacyDarkModeValue(preference: ApplicationTheme) -> str: + """Return the closest legacy binary representation of a preference.""" + if preference == ApplicationTheme.Dark: + return AppBinarySettings.ON_ + + # Legacy releases represented both automatic and forced light as "not dark". + return AppBinarySettings.OFF + + +def _synchronizeLegacyDarkModeSetting(): + """Reconcile and retain the legacy dark-mode compatibility setting.""" + settings = QtCore.QSettings() + + hasLegacyValue, hasThemeValue = ( + settings.contains(_LEGACY_DARK_MODE_SETTING), + settings.contains(APPLICATION_THEME_SETTING), + ) + + if hasLegacyValue: + legacyValue = settings.value(_LEGACY_DARK_MODE_SETTING) + + legacyDarkEnabled = str(legacyValue).strip().lower() == AppBinarySettings.ON_ + + if not hasThemeValue: + preference = ( + ApplicationTheme.Dark if legacyDarkEnabled else ApplicationTheme.System + ) + + AppSettings.set(APPLICATION_THEME_SETTING, preference.value) + else: + preference = ApplicationTheme(AppSettings.get(APPLICATION_THEME_SETTING)) + + # A mismatch means an older release changed its binary setting. + if legacyDarkEnabled and preference != ApplicationTheme.Dark: + preference = ApplicationTheme.Dark + + AppSettings.set(APPLICATION_THEME_SETTING, preference.value) + elif not legacyDarkEnabled and preference == ApplicationTheme.Dark: + preference = ApplicationTheme.System + + AppSettings.set(APPLICATION_THEME_SETTING, preference.value) + else: + preference = ApplicationTheme(AppSettings.get(APPLICATION_THEME_SETTING)) + + AppSettings.set( + _LEGACY_DARK_MODE_SETTING, + _legacyDarkModeValue(preference), + ) + + class SettingsController: """Apply application settings independently from their presentation.""" + def __init__(self): + """Synchronize theme persistence after Qt application metadata is ready.""" + _synchronizeLegacyDarkModeSetting() + @staticmethod def _setBinary(settingName: str, enabled: bool): """Persist one registered binary setting.""" @@ -77,21 +142,24 @@ class SettingsController: showMBoxNewChangesNextTime() - @classmethod - def setDarkMode(cls, enabled: bool): - """Switch between the explicit dark theme and automatic mode.""" - cls._setBinary('DarkMode', enabled) - + @staticmethod + def setApplicationTheme(theme: ApplicationTheme | str): + """Persist and immediately apply one application theme preference.""" try: - if enabled: - APP().switchToDarkMode() - else: - APP().switchToAutoMode() - except Exception: - # Any non-exit exceptions + preference = ApplicationTheme(theme) + except (TypeError, ValueError): + return - # The controller can be exercised before the full desktop UI exists. - pass + AppSettings.set(APPLICATION_THEME_SETTING, preference.value) + AppSettings.set( + _LEGACY_DARK_MODE_SETTING, + _legacyDarkModeValue(preference), + ) + + applyThemePreference = getattr(APP(), 'applyThemePreference', None) + + if callable(applyThemePreference): + applyThemePreference() @staticmethod def setLanguage(language: str): diff --git a/Furious/Controllers/__init__.py b/Furious/Controllers/__init__.py index c195c25..6eff31f 100644 --- a/Furious/Controllers/__init__.py +++ b/Furious/Controllers/__init__.py @@ -23,9 +23,10 @@ from .ConnectionController import ( ConnectionState, ) from .RoutingController import RoutingController -from .SettingsController import SettingsController +from .SettingsController import APPLICATION_THEME_SETTING, SettingsController __all__ = [ + 'APPLICATION_THEME_SETTING', 'ConnectionController', 'ConnectionError', 'ConnectionState', diff --git a/Furious/Externals/GenTranslation.py b/Furious/Externals/GenTranslation.py index 9a81bc7..aba9197 100644 --- a/Furious/Externals/GenTranslation.py +++ b/Furious/Externals/GenTranslation.py @@ -974,14 +974,6 @@ TRANSLATION = { "ZH": "设置", "isReviewed": "True" }, - "Dark Mode": { - "source": [ - "Furious.Window.SettingsPage" - ], - "RU": "Темный режим", - "ZH": "深色模式", - "isReviewed": "True" - }, "Startup On Boot": { "source": [ "Furious.Window.SettingsPage" @@ -2423,14 +2415,6 @@ TRANSLATION = { "ZH": "通过当前代理连接路由系统流量。", "isReviewed": "True" }, - "Use the application dark theme instead of automatic appearance.": { - "source": [ - "Furious.Window.SettingsPage" - ], - "RU": "Использовать тёмную тему приложения вместо автоматического оформления.", - "ZH": "使用应用程序深色主题,而不跟随自动外观。", - "isReviewed": "True" - }, "Choose the language used by the application interface.": { "source": [ "Furious.Window.SettingsPage" @@ -2846,5 +2830,45 @@ TRANSLATION = { "RU": "Отключение", "ZH": "正在断开连接", "isReviewed": "True" + }, + "Follow System Appearance": { + "source": [ + "Furious.Window.SettingsPage" + ], + "ZH": "跟随系统外观", + "isReviewed": "True", + "RU": "Следовать оформлению системы" + }, + "Light Theme": { + "source": [ + "Furious.Window.SettingsPage" + ], + "ZH": "浅色主题", + "isReviewed": "True", + "RU": "Светлая тема" + }, + "Dark Theme": { + "source": [ + "Furious.Window.SettingsPage" + ], + "ZH": "深色主题", + "isReviewed": "True", + "RU": "Тёмная тема" + }, + "Application Theme": { + "source": [ + "Furious.Window.SettingsPage" + ], + "ZH": "应用程序主题", + "isReviewed": "True", + "RU": "Тема приложения" + }, + "Choose how the application appearance is determined.": { + "source": [ + "Furious.Window.SettingsPage" + ], + "ZH": "选择应用程序外观的确定方式。", + "isReviewed": "True", + "RU": "Выберите способ определения оформления приложения." } } diff --git a/Furious/Frozenlib/Enum.py b/Furious/Frozenlib/Enum.py index 0153429..95775d6 100644 --- a/Furious/Frozenlib/Enum.py +++ b/Furious/Frozenlib/Enum.py @@ -22,12 +22,21 @@ from __future__ import annotations from enum import Enum __all__ = [ + 'ApplicationTheme', 'AppBuiltinCommand', 'AppBuiltinRouting', 'AppBuiltinProxyMode', ] +class ApplicationTheme(Enum): + """Enumerate the persisted application appearance preferences.""" + + System = 'System' + Light = 'Light' + Dark = 'Dark' + + class AppBuiltinCommand(Enum): """Enumerate app builtin command.""" diff --git a/Furious/Frozenlib/Mixins.py b/Furious/Frozenlib/Mixins.py index 75130db..72c13d4 100644 --- a/Furious/Frozenlib/Mixins.py +++ b/Furious/Frozenlib/Mixins.py @@ -187,20 +187,7 @@ class Mixins: @staticmethod def callThemeChangedCallback(theme: str): - """Call theme changed callback.""" - try: - app = QApplication.instance() - - if app is not None and app.isDarkModeEnabled(): - # Ignore application dark detect system - logger.info(f'ignore system theme \'{theme}\' changes in dark mode') - - return - except Exception: - # Any non-exit exceptions - - pass - + """Notify registered objects after an accepted system theme change.""" logger.info(f'system theme changed to \'{theme}\'') Mixins.ThemeAware.callThemeChangedCallbackUnchecked(theme) diff --git a/Furious/Frozenlib/__init__.py b/Furious/Frozenlib/__init__.py index b19303e..abcd5b6 100644 --- a/Furious/Frozenlib/__init__.py +++ b/Furious/Frozenlib/__init__.py @@ -66,7 +66,12 @@ from .Constants import ( XRAY_ASSET_PATH_GEOIP, XRAY_ASSET_PATH_GEOSITE, ) -from .Enum import AppBuiltinCommand, AppBuiltinProxyMode, AppBuiltinRouting +from .Enum import ( + ApplicationTheme, + AppBuiltinCommand, + AppBuiltinProxyMode, + AppBuiltinRouting, +) from .Globals import ( APP, AppConnectionController, @@ -117,6 +122,7 @@ __all__ = [ 'APPLICATION_TUN2SOCKS_INTERFACE_DNS_ADDRESS', 'APPLICATION_TUN2SOCKS_NETWORK_INTERFACE_NAME', 'APPLICATION_VERSION', + 'ApplicationTheme', 'AppBinarySettings', 'AppBuiltinCommand', 'AppBuiltinProxyMode', diff --git a/Furious/Qt/QtGui.py b/Furious/Qt/QtGui.py index f7dfd6e..4dbc6fb 100644 --- a/Furious/Qt/QtGui.py +++ b/Furious/Qt/QtGui.py @@ -235,8 +235,8 @@ class AppQAction(Mixins.QTranslatable, Mixins.ThemeAware, QAction): if not self.iconFileName: return - if AppSettings.isStateON_('DarkMode'): - # Custom dark mode + if APP().usesForcedDarkTheme(): + # Explicit dark theme super().setIcon(bootstrapIconWhite(self.iconFileName)) return diff --git a/Furious/Qt/QtWidgets.py b/Furious/Qt/QtWidgets.py index fec740e..9c5c0cf 100644 --- a/Furious/Qt/QtWidgets.py +++ b/Furious/Qt/QtWidgets.py @@ -1634,8 +1634,8 @@ class AppQPushButton(Mixins.QTranslatable, Mixins.ThemeAware, QPushButton): if not self.iconFileName: return - if AppSettings.isStateON_('DarkMode'): - # Custom dark mode + if APP().usesForcedDarkTheme(): + # Explicit dark theme super().setIcon(bootstrapIconWhite(self.iconFileName)) return @@ -1729,7 +1729,7 @@ class AppQIconTextPushButton( if not self.iconFileName: return - if AppSettings.isStateON_('DarkMode'): + if APP().usesForcedDarkTheme(): IconTextPushButton.setIcon( self, bootstrapIconWhite(self.iconFileName), diff --git a/Furious/Window/SettingsPage.py b/Furious/Window/SettingsPage.py index 5520cf0..43a878c 100644 --- a/Furious/Window/SettingsPage.py +++ b/Furious/Window/SettingsPage.py @@ -19,6 +19,7 @@ from __future__ import annotations +from Furious.Controllers import APPLICATION_THEME_SETTING from Furious.Frozenlib import * from Furious.Plugins import ( CapabilityKind, @@ -501,6 +502,61 @@ class _LanguageSettingsCard(_SettingsCard): AppSettingsController().setLanguage(language) +class _ApplicationThemeSettingsCard(_SettingsCard): + """Select the source of the application light or dark appearance.""" + + Options = ( + ('Follow System Appearance', ApplicationTheme.System), + ('Light Theme', ApplicationTheme.Light), + ('Dark Theme', ApplicationTheme.Dark), + ) + + _TranslatableOptions = ( + _('Follow System Appearance'), + _('Light Theme'), + _('Dark Theme'), + ) + + def __init__(self, title='', description='', parent=None): + """Initialize the translated application-theme selector.""" + self.comboBox = AppQComboBox() + self.comboBox.setObjectName('SettingsComboBox') + self.comboBox.setMinimumWidth(260) + + for label, preference in self.Options: + self.comboBox.addItem(_(label), preference.value) + + self.sync() + + self.comboBox.currentIndexChanged.connect(self._selectionChanged) + + super().__init__( + 'moon-stars.svg', + self.comboBox, + title, + description, + parent=parent, + ) + + def sync(self): + """Select the persisted preference without applying it again.""" + blocker = QtCore.QSignalBlocker(self.comboBox) + + index = self.comboBox.findData(AppSettings.get(APPLICATION_THEME_SETTING)) + + self.comboBox.setCurrentIndex(max(index, 0)) + + del blocker + + @QtCore.Slot(int) + def _selectionChanged(self, _index: int): + """Persist and apply the selected application theme preference.""" + preference = self.comboBox.currentData() + + if isinstance(preference, str): + AppSettingsController().setApplicationTheme(preference) + + class _SystemProxySettingsCard(_SettingsCard): """Select how Furious manages the operating-system proxy.""" @@ -651,18 +707,15 @@ class SettingsPage(Mixins.QTranslatable, QMainWindow): self.tunModeCard.checkBox.setEnabled(self._tunModeAvailable) ( - self.darkModeCard, + self.applicationThemeCard, self.languageCard, self.monochromeCard, self.startupCard, self.powerSaveCard, ) = ( - _ToggleSettingsCard( - 'moon-stars.svg', - 'DarkMode', - AppSettingsController().setDarkMode, - _('Dark Mode'), - _('Use the application dark theme instead of automatic appearance.'), + _ApplicationThemeSettingsCard( + _('Application Theme'), + _('Choose how the application appearance is determined.'), ), _LanguageSettingsCard( _('Language'), @@ -692,7 +745,7 @@ class SettingsPage(Mixins.QTranslatable, QMainWindow): ) self.generalSection.addCard(self.tunModeCard) - self.generalSection.addCard(self.darkModeCard) + self.generalSection.addCard(self.applicationThemeCard) self.generalSection.addCard(self.languageCard) self.generalSection.addCard(self.monochromeCard)