mirror of
https://github.com/LorenEteval/Furious.git
synced 2026-09-22 23:08:08 +03:00
Add application theme preference
Signed-off-by: Loren Eteval <loren.eteval@proton.me>
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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',
|
||||
|
||||
Vendored
+40
-16
@@ -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": "Выберите способ определения оформления приложения."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user