Add application theme preference

Signed-off-by: Loren Eteval <loren.eteval@proton.me>
This commit is contained in:
Loren Eteval
2026-08-17 08:52:19 +08:00
parent 77312094d9
commit 11b47f7680
11 changed files with 245 additions and 94 deletions
+35 -32
View File
@@ -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()
+2 -2
View File
@@ -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':
+83 -15
View File
@@ -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):
+2 -1
View File
@@ -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',
+40 -16
View File
@@ -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": "Выберите способ определения оформления приложения."
}
}
+9
View File
@@ -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."""
+1 -14
View File
@@ -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)
+7 -1
View File
@@ -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',
+2 -2
View File
@@ -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
+3 -3
View File
@@ -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),
+61 -8
View File
@@ -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)