Add unified application settings page

Signed-off-by: Loren Eteval <loren.eteval@proton.me>
This commit is contained in:
Loren Eteval
2026-08-12 12:21:49 +08:00
parent 066ff99a63
commit e348670520
10 changed files with 1275 additions and 343 deletions
+149 -181
View File
@@ -23,7 +23,7 @@ from Furious.Frozenlib import *
from Furious.Qt import *
from Furious.Qt import gettext as _
__all__ = ['SettingsAction']
__all__ = ['SettingsAction', 'SettingsController', 'TUNModeAction']
registerAppSettings('VPNMode', isBinary=True)
registerAppSettings('DarkMode', isBinary=True)
@@ -47,11 +47,134 @@ registerAppSettings(
)
registerAppSettings('ShowTabAndSpacesInEditor', isBinary=True)
# Administrator, Superuser
_TRANSLATABLE_TUN_MODE = [
_('TUN Mode Disabled (Administrator)'),
_('TUN Mode Disabled (Superuser)'),
]
class SettingsController:
"""Apply application settings independently from their presentation."""
@staticmethod
def setTUNMode(enabled: bool):
"""Persist the global TUN mode and notify active workflows."""
if PLATFORM != 'Linux':
assert SystemRuntime.isAdmin()
if enabled:
AppSettings.turnON_('VPNMode')
else:
AppSettings.turnOFF('VPNMode')
showMBoxNewChangesNextTime()
@staticmethod
def setDarkMode(enabled: bool):
"""Switch between the explicit dark theme and automatic mode."""
if enabled:
AppSettings.turnON_('DarkMode')
try:
APP().switchToDarkMode()
except Exception:
# Any non-exit exceptions
pass
else:
AppSettings.turnOFF('DarkMode')
try:
APP().switchToAutoMode()
except Exception:
# Any non-exit exceptions
pass
@staticmethod
def setLanguage(language: str):
"""Persist a supported UI language and refresh translated objects."""
if language not in SUPPORTED_LANGUAGE:
return
if AppSettings.get('Language') != language:
AppSettings.set('Language', language)
Mixins.QTranslatable.retranslateAll()
@staticmethod
def setMonochromeTrayIcon(enabled: bool):
"""Persist and immediately refresh the tray-icon presentation."""
if enabled:
AppSettings.turnON_('UseMonochromeTrayIcon')
APP().systemTray.setMonochromeIcon()
else:
AppSettings.turnOFF('UseMonochromeTrayIcon')
if APP().isSystemTrayConnected():
APP().systemTray.setConnectedIcon()
else:
APP().systemTray.setDisconnectedIcon()
@staticmethod
def setDockIconHidden(enabled: bool):
"""Apply the macOS dock-icon visibility preference."""
if enabled:
APP().installDockIconVisibilityFeature()
AppSettings.turnON_('HideDockIcon')
else:
APP().installDockIconVisibilityFeature(remove=True)
AppSettings.turnOFF('HideDockIcon')
@staticmethod
def setStartupOnBoot(enabled: bool):
"""Apply the platform startup registration preference."""
if enabled:
StartupOnBoot.on_()
AppSettings.turnON_('StartupOnBoot')
else:
StartupOnBoot.off()
AppSettings.turnOFF('StartupOnBoot')
@staticmethod
def setPowerSaveMode(enabled: bool):
"""Persist power-saving behavior for the next connection."""
if enabled:
AppSettings.turnON_('PowerSaveMode')
else:
AppSettings.turnOFF('PowerSaveMode')
showMBoxNewChangesNextTime()
@staticmethod
def setForceLocalProxy(enabled: bool):
"""Persist local system-proxy address normalization."""
if enabled:
AppSettings.turnON_('ForceToLocalhostWhenSettingLocalProxy')
else:
AppSettings.turnOFF('ForceToLocalhostWhenSettingLocalProxy')
showMBoxNewChangesNextTime()
@staticmethod
def setAutoUpdateAssets(enabled: bool):
"""Persist automatic core-asset updates."""
if enabled:
AppSettings.turnON_('AutoUpdateAssetFiles')
else:
AppSettings.turnOFF('AutoUpdateAssetFiles')
@staticmethod
def setConnectionProgressVisible(enabled: bool):
"""Persist connection-progress visibility."""
if enabled:
AppSettings.turnON_('ShowProgressBarWhenConnecting')
else:
AppSettings.turnOFF('ShowProgressBarWhenConnecting')
@staticmethod
def setEditorWhitespaceVisible(enabled: bool):
"""Apply and persist editor whitespace visibility."""
if enabled:
APP().mainWindow.showTabAndSpaces()
AppSettings.turnON_('ShowTabAndSpacesInEditor')
else:
APP().mainWindow.hideTabAndSpaces()
AppSettings.turnOFF('ShowTabAndSpacesInEditor')
class TUNModeAction(AppQAction):
@@ -65,23 +188,18 @@ class TUNModeAction(AppQAction):
if SystemRuntime.isAdmin():
super().__init__(_('TUN Mode'), **kwargs)
else:
super().__init__(
_(f'TUN Mode Disabled ({ADMINISTRATOR_NAME})'), **kwargs
)
if ADMINISTRATOR_NAME == 'Administrator':
text = _('TUN Mode Disabled (Administrator)')
else:
text = _('TUN Mode Disabled (Superuser)')
super().__init__(text, **kwargs)
self.setDisabled(True)
def triggeredCallback(self, checked):
"""Handle activation of the action."""
if PLATFORM != 'Linux':
assert SystemRuntime.isAdmin()
if checked:
AppSettings.turnON_('VPNMode')
else:
AppSettings.turnOFF('VPNMode')
showMBoxNewChangesNextTime()
SettingsController.setTUNMode(checked)
class SettingsChildAction(AppQAction):
@@ -94,187 +212,37 @@ class SettingsChildAction(AppQAction):
def triggeredCallback(self, checked):
"""Handle activation of the action."""
if self.textCompare('Dark Mode'):
# Settings turn on/off order matters here
if checked:
AppSettings.turnON_('DarkMode')
try:
APP().switchToDarkMode()
except Exception:
# Any non-exit exceptions
pass
else:
AppSettings.turnOFF('DarkMode')
try:
APP().switchToAutoMode()
except Exception:
# Any non-exit exceptions
pass
SettingsController.setDarkMode(checked)
elif self.textCompare('Use Monochrome Tray Icon'):
# Settings turn on/off order matters here
if checked:
AppSettings.turnON_('UseMonochromeTrayIcon')
APP().systemTray.setMonochromeIcon()
else:
AppSettings.turnOFF('UseMonochromeTrayIcon')
if APP().isSystemTrayConnected():
APP().systemTray.setConnectedIcon()
else:
APP().systemTray.setDisconnectedIcon()
SettingsController.setMonochromeTrayIcon(checked)
elif self.textCompare('Hide Dock Icon'):
if checked:
APP().installDockIconVisibilityFeature()
AppSettings.turnON_('HideDockIcon')
else:
APP().installDockIconVisibilityFeature(remove=True)
AppSettings.turnOFF('HideDockIcon')
SettingsController.setDockIconHidden(checked)
elif self.textCompare('Startup On Boot'):
if checked:
StartupOnBoot.on_()
AppSettings.turnON_('StartupOnBoot')
else:
StartupOnBoot.off()
AppSettings.turnOFF('StartupOnBoot')
SettingsController.setStartupOnBoot(checked)
elif self.textCompare('Power Save Mode'):
if checked:
AppSettings.turnON_('PowerSaveMode')
else:
AppSettings.turnOFF('PowerSaveMode')
showMBoxNewChangesNextTime()
SettingsController.setPowerSaveMode(checked)
elif self.textCompare('Force To 127.0.0.1 When Setting Local Proxy'):
if checked:
AppSettings.turnON_('ForceToLocalhostWhenSettingLocalProxy')
else:
AppSettings.turnOFF('ForceToLocalhostWhenSettingLocalProxy')
showMBoxNewChangesNextTime()
SettingsController.setForceLocalProxy(checked)
elif self.textCompare('Automatically Update Asset Files'):
if checked:
AppSettings.turnON_('AutoUpdateAssetFiles')
else:
AppSettings.turnOFF('AutoUpdateAssetFiles')
SettingsController.setAutoUpdateAssets(checked)
elif self.textCompare('Show Progress Bar When Connecting'):
if checked:
AppSettings.turnON_('ShowProgressBarWhenConnecting')
else:
AppSettings.turnOFF('ShowProgressBarWhenConnecting')
SettingsController.setConnectionProgressVisible(checked)
elif self.textCompare('Show Tab And Spaces In Editor'):
if checked:
APP().mainWindow.showTabAndSpaces()
AppSettings.turnON_('ShowTabAndSpacesInEditor')
else:
APP().mainWindow.hideTabAndSpaces()
AppSettings.turnOFF('ShowTabAndSpacesInEditor')
SettingsController.setEditorWhitespaceVisible(checked)
class SettingsAction(AppQAction):
"""Handle the settings action."""
"""Navigate from the system tray to the page-based settings UI."""
def __init__(self, **kwargs):
"""Initialize the SettingsAction."""
if SystemRuntime.flatpakID():
tunActions = []
else:
tunActions = [
TUNModeAction(
checkable=True,
checked=AppSettings.isStateON_('VPNMode'),
),
AppQAction(
_('Customize Tun2socks Settings...'),
icon=bootstrapIcon('diagram-3.svg'),
checkable=False,
callback=lambda: APP().mainWindow.getGuiTUNSettings().open(),
),
AppQSeperator(),
]
if PLATFORM == 'Darwin':
hideDockIconAction = [
SettingsChildAction(
_('Hide Dock Icon'),
checkable=True,
checked=AppSettings.isStateON_('HideDockIcon'),
)
]
else:
hideDockIconAction = []
if not SystemRuntime.isAssetsFolderWritable():
autoUpdateAssetFilesAction = []
else:
autoUpdateAssetFilesAction = (
SettingsChildAction(
_('Automatically Update Asset Files'),
checkable=True,
checked=AppSettings.isStateON_('AutoUpdateAssetFiles'),
),
)
super().__init__(
_('Settings'),
icon=bootstrapIcon('gear-wide-connected.svg'),
menu=AppQMenu(
*tunActions,
SettingsChildAction(
_('Dark Mode'),
checkable=True,
checked=AppSettings.isStateON_('DarkMode'),
),
SettingsChildAction(
_('Use Monochrome Tray Icon'),
checkable=True,
checked=AppSettings.isStateON_('UseMonochromeTrayIcon'),
),
*hideDockIconAction,
AppQSeperator(),
SettingsChildAction(
_('Startup On Boot'),
checkable=True,
checked=AppSettings.isStateON_('StartupOnBoot'),
),
SettingsChildAction(
_('Power Save Mode'),
checkable=True,
checked=AppSettings.isStateON_('PowerSaveMode'),
),
AppQSeperator(),
SettingsChildAction(
_('Force To 127.0.0.1 When Setting Local Proxy'),
checkable=True,
checked=AppSettings.isStateON_(
'ForceToLocalhostWhenSettingLocalProxy'
),
),
*autoUpdateAssetFilesAction,
SettingsChildAction(
_('Show Progress Bar When Connecting'),
checkable=True,
checked=AppSettings.isStateON_('ShowProgressBarWhenConnecting'),
),
SettingsChildAction(
_('Show Tab And Spaces In Editor'),
checkable=True,
checked=AppSettings.isStateON_('ShowTabAndSpacesInEditor'),
),
),
useActionGroup=False,
callback=lambda: APP().mainWindow.showSettingsPage(),
**kwargs,
)
def getTUNModeAction(self) -> AppQAction:
# 1st action
"""Return TUN mode action."""
return self._menu.actions()[0]
"""Return the Settings page's compatibility TUN action."""
return APP().mainWindow.settingsPage.tunModeAction
-1
View File
@@ -61,7 +61,6 @@ class TrayIcon(
ImportAction(isTrayAction=True),
EditConfigurationAction(isTrayAction=True),
AppQSeperator(),
LanguageAction(isTrayAction=True),
SettingsAction(isTrayAction=True),
AppQSeperator(),
ExitAction(isTrayAction=True),
+264 -40
View File
@@ -388,6 +388,7 @@ TRANSLATION = {
"View": {
"source": [
"Furious.Window.LogPage",
"Furious.Window.SettingsPage",
"Furious.Window.TextEditorWindow"
],
"RU": "Вид",
@@ -811,6 +812,7 @@ TRANSLATION = {
},
"Log": {
"source": [
"Furious.Window.LogPage",
"Furious.Window.MainWindow"
],
"RU": "Журнал событий",
@@ -873,14 +875,6 @@ TRANSLATION = {
"ZH": "编辑订阅...",
"isReviewed": "True"
},
"Tools": {
"source": [
"Furious.Window.HomePage"
],
"RU": "Инструменты",
"ZH": "工具",
"isReviewed": "True"
},
"Plugins": {
"source": [
"Furious.Window.HomePage"
@@ -899,7 +893,7 @@ TRANSLATION = {
},
"Open Application Folder": {
"source": [
"Furious.Window.HomePage"
"Furious.Window.SettingsPage"
],
"RU": "Открыть папку приложения",
"ZH": "打开应用程序文件夹",
@@ -907,7 +901,7 @@ TRANSLATION = {
},
"Check For Updates": {
"source": [
"Furious.Window.HomePage"
"Furious.Window.SettingsPage"
],
"RU": "Проверка обновлений",
"ZH": "检查更新",
@@ -915,7 +909,7 @@ TRANSLATION = {
},
"About": {
"source": [
"Furious.Window.HomePage"
"Furious.Window.SettingsPage"
],
"RU": "О нас",
"ZH": "关于",
@@ -1004,7 +998,8 @@ TRANSLATION = {
},
"Language": {
"source": [
"Furious.Actions.Language"
"Furious.Actions.Language",
"Furious.Window.SettingsPage"
],
"RU": "Язык",
"ZH": "语言",
@@ -1020,7 +1015,8 @@ TRANSLATION = {
},
"TUN Mode": {
"source": [
"Furious.Actions.Settings"
"Furious.Actions.Settings",
"Furious.Window.SettingsPage"
],
"RU": "TUN режим",
"ZH": "TUN模式",
@@ -1028,7 +1024,8 @@ TRANSLATION = {
},
"TUN Mode Disabled (Administrator)": {
"source": [
"Furious.Actions.Settings"
"Furious.Actions.Settings",
"Furious.Window.SettingsPage"
],
"RU": "TUN режим отключен (Administrator)",
"ZH": "TUN模式已禁用(管理员)",
@@ -1036,7 +1033,8 @@ TRANSLATION = {
},
"TUN Mode Disabled (Superuser)": {
"source": [
"Furious.Actions.Settings"
"Furious.Actions.Settings",
"Furious.Window.SettingsPage"
],
"RU": "TUN режим отключен (Superuser)",
"ZH": "TUN模式已禁用(Superuser)",
@@ -1044,7 +1042,9 @@ TRANSLATION = {
},
"Settings": {
"source": [
"Furious.Actions.Settings"
"Furious.Actions.Settings",
"Furious.Window.MainWindow",
"Furious.Window.SettingsPage"
],
"RU": "Настройки",
"ZH": "设置",
@@ -1052,7 +1052,7 @@ TRANSLATION = {
},
"Dark Mode": {
"source": [
"Furious.Actions.Settings"
"Furious.Window.SettingsPage"
],
"RU": "Темный режим",
"ZH": "深色模式",
@@ -1060,7 +1060,7 @@ TRANSLATION = {
},
"Startup On Boot": {
"source": [
"Furious.Actions.Settings"
"Furious.Window.SettingsPage"
],
"RU": "Автозапуск при загрузке системы",
"ZH": "开机启动",
@@ -1068,7 +1068,7 @@ TRANSLATION = {
},
"Force To 127.0.0.1 When Setting Local Proxy": {
"source": [
"Furious.Actions.Settings"
"Furious.Window.SettingsPage"
],
"RU": "Принудительно 127.0.0.1 для локального прокси",
"ZH": "设置本地代理时强制使用127.0.0.1",
@@ -1076,7 +1076,7 @@ TRANSLATION = {
},
"Automatically Update Asset Files": {
"source": [
"Furious.Actions.Settings"
"Furious.Window.SettingsPage"
],
"RU": "Автоматически обновлять файлы активов",
"ZH": "自动更新资源文件",
@@ -1084,7 +1084,7 @@ TRANSLATION = {
},
"Show Progress Bar When Connecting": {
"source": [
"Furious.Actions.Settings"
"Furious.Window.SettingsPage"
],
"RU": "Отображение индикатора выполнения при подключении",
"ZH": "连接时显示进度条",
@@ -1092,7 +1092,7 @@ TRANSLATION = {
},
"Show Tab And Spaces In Editor": {
"source": [
"Furious.Actions.Settings"
"Furious.Window.SettingsPage"
],
"RU": "Показывать табуляцию и пробелы в редакторе",
"ZH": "在编辑器中显示制表符和空格",
@@ -1210,14 +1210,6 @@ TRANSLATION = {
"ZH": "请完善服务器配置",
"isReviewed": "True"
},
"Help": {
"source": [
"Furious.Window.HomePage"
],
"RU": "Помощь",
"ZH": "帮助",
"isReviewed": "True"
},
"Test Tcping Latency": {
"source": [
"Furious.Widget.ServerTableView"
@@ -1302,7 +1294,7 @@ TRANSLATION = {
},
"Use Monochrome Tray Icon": {
"source": [
"Furious.Actions.Settings"
"Furious.Window.SettingsPage"
],
"RU": "Использовать монохромный значок в трее",
"ZH": "使用单色托盘图标",
@@ -1310,7 +1302,7 @@ TRANSLATION = {
},
"Hide Dock Icon": {
"source": [
"Furious.Actions.Settings"
"Furious.Window.SettingsPage"
],
"RU": "Скрыть значок Dock",
"ZH": "隐藏程序坞图标",
@@ -1350,7 +1342,7 @@ TRANSLATION = {
},
"Power Save Mode": {
"source": [
"Furious.Actions.Settings"
"Furious.Window.SettingsPage"
],
"RU": "Режим энергосбережения",
"ZH": "省电模式",
@@ -1580,8 +1572,7 @@ TRANSLATION = {
},
"Customize Tun2socks Settings...": {
"source": [
"Furious.Actions.Settings",
"Furious.Window.HomePage"
"Furious.Window.SettingsPage"
],
"RU": "Настроить параметры Tun2socks...",
"ZH": "自定义Tun2socks设置...",
@@ -1677,7 +1668,7 @@ TRANSLATION = {
},
"Restart The Application As Administrator": {
"source": [
"Furious.Window.HomePage"
"Furious.Window.SettingsPage"
],
"RU": "Перезапустите приложение от имени администратора",
"ZH": "以管理员身份重新启动应用程序",
@@ -1685,7 +1676,7 @@ TRANSLATION = {
},
"Restart The Application As Superuser": {
"source": [
"Furious.Window.HomePage"
"Furious.Window.SettingsPage"
],
"RU": "Перезапустите приложение от имени суперпользователя",
"ZH": "以Superuser身份重新启动应用程序",
@@ -1717,7 +1708,7 @@ TRANSLATION = {
},
"Customize System Proxy Bypass Address...": {
"source": [
"Furious.Window.HomePage"
"Furious.Window.SettingsPage"
],
"RU": "Настроить адреса исключений системного прокси...",
"ZH": "自定义系统代理绕过地址...",
@@ -1750,7 +1741,7 @@ TRANSLATION = {
},
"Customize Network Test URL...": {
"source": [
"Furious.Window.HomePage"
"Furious.Window.SettingsPage"
],
"RU": "Настроить URL теста сети...",
"ZH": "自定义网络测试URL...",
@@ -2292,7 +2283,8 @@ TRANSLATION = {
},
"Application": {
"source": [
"Furious.Window.LogPage"
"Furious.Window.LogPage",
"Furious.Window.SettingsPage"
],
"RU": "Приложение",
"ZH": "应用程序",
@@ -2485,5 +2477,237 @@ TRANSLATION = {
"RU": "Ожидание сетевой статистики",
"ZH": "正在等待网络统计数据",
"isReviewed": "True"
},
"General": {
"source": [
"Furious.Window.SettingsPage"
],
"RU": "Общие",
"ZH": "常规",
"isReviewed": "True"
},
"Connection and Interface": {
"source": [
"Furious.Window.SettingsPage"
],
"RU": "Подключение и интерфейс",
"ZH": "连接和接口",
"isReviewed": "True"
},
"Hysteria2 Traffic Statistics": {
"source": [
"Furious.Window.SettingsPage"
],
"RU": "Статистика трафика Hysteria2",
"ZH": "Hysteria2流量统计",
"isReviewed": "True"
},
"Route system traffic through the active proxy connection.": {
"source": [
"Furious.Window.SettingsPage"
],
"RU": "Направлять системный трафик через активное прокси-подключение.",
"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"
],
"RU": "Выберите язык интерфейса приложения.",
"ZH": "选择应用程序界面使用的语言。",
"isReviewed": "True"
},
"Use a theme-aware single-color system tray icon.": {
"source": [
"Furious.Window.SettingsPage"
],
"RU": "Использовать одноцветный значок в системном трее с учётом темы.",
"ZH": "使用适配主题的单色系统托盘图标。",
"isReviewed": "True"
},
"Start the application automatically after signing in.": {
"source": [
"Furious.Window.SettingsPage"
],
"RU": "Автоматически запускать приложение после входа в систему.",
"ZH": "登录系统后自动启动应用程序。",
"isReviewed": "True"
},
"Reduce background activity when the application is idle.": {
"source": [
"Furious.Window.SettingsPage"
],
"RU": "Снижать фоновую активность, когда приложение не используется.",
"ZH": "应用程序空闲时减少后台活动。",
"isReviewed": "True"
},
"Keep the application available from the menu bar only.": {
"source": [
"Furious.Window.SettingsPage"
],
"RU": "Оставлять приложение доступным только из строки меню.",
"ZH": "仅通过菜单栏保留应用程序入口。",
"isReviewed": "True"
},
"Open": {
"source": [
"Furious.Window.SettingsPage"
],
"RU": "Открыть",
"ZH": "打开",
"isReviewed": "True"
},
"Configure the external Tun2socks network interface and routing.": {
"source": [
"Furious.Window.SettingsPage"
],
"RU": "Настройте внешний сетевой интерфейс Tun2socks и маршрутизацию.",
"ZH": "配置外部Tun2socks网络接口和路由。",
"isReviewed": "True"
},
"Choose destinations that bypass the operating system proxy.": {
"source": [
"Furious.Window.SettingsPage"
],
"RU": "Выберите адреса, которые обходят системный прокси.",
"ZH": "选择绕过操作系统代理的目标地址。",
"isReviewed": "True"
},
"Choose the URL used for download speed tests.": {
"source": [
"Furious.Window.SettingsPage"
],
"RU": "Выберите URL для тестов скорости загрузки.",
"ZH": "选择下载速度测试使用的URL。",
"isReviewed": "True"
},
"Use the IPv4 loopback address when configuring the system proxy.": {
"source": [
"Furious.Window.SettingsPage"
],
"RU": "Использовать IPv4-адрес обратной петли при настройке системного прокси.",
"ZH": "配置系统代理时使用IPv4回环地址。",
"isReviewed": "True"
},
"Show connection progress while proxy services are starting.": {
"source": [
"Furious.Window.SettingsPage"
],
"RU": "Показывать ход подключения во время запуска прокси-служб.",
"ZH": "代理服务启动时显示连接进度。",
"isReviewed": "True"
},
"Display whitespace markers in configuration editors.": {
"source": [
"Furious.Window.SettingsPage"
],
"RU": "Показывать маркеры пробелов в редакторах конфигурации.",
"ZH": "在配置编辑器中显示空白字符标记。",
"isReviewed": "True"
},
"Keep supported proxy-core data files up to date.": {
"source": [
"Furious.Window.SettingsPage"
],
"RU": "Поддерживать файлы данных прокси-ядер в актуальном состоянии.",
"ZH": "保持受支持的代理核心数据文件为最新版本。",
"isReviewed": "True"
},
"Traffic Stats API URL": {
"source": [
"Furious.Window.SettingsPage"
],
"RU": "URL API статистики трафика",
"ZH": "流量统计API URL",
"isReviewed": "True"
},
"Hysteria2 server API address; /traffic is appended automatically.": {
"source": [
"Furious.Window.SettingsPage"
],
"RU": "Адрес API сервера Hysteria2; /traffic добавляется автоматически.",
"ZH": "Hysteria2服务器API地址;将自动附加 /traffic。",
"isReviewed": "True"
},
"Traffic Stats Client ID": {
"source": [
"Furious.Window.SettingsPage"
],
"RU": "Идентификатор клиента статистики",
"ZH": "流量统计客户端ID",
"isReviewed": "True"
},
"Traffic Stats API Secret": {
"source": [
"Furious.Window.SettingsPage"
],
"RU": "Секрет API статистики трафика",
"ZH": "流量统计API密钥",
"isReviewed": "True"
},
"Authorization value configured by the Hysteria2 server.": {
"source": [
"Furious.Window.SettingsPage"
],
"RU": "Значение авторизации, настроенное на сервере Hysteria2.",
"ZH": "Hysteria2服务器配置的Authorization值。",
"isReviewed": "True"
},
"Check": {
"source": [
"Furious.Window.SettingsPage"
],
"RU": "Проверить",
"ZH": "检查",
"isReviewed": "True"
},
"Check for a newer application and proxy-core release.": {
"source": [
"Furious.Window.SettingsPage"
],
"RU": "Проверить наличие новой версии приложения и прокси-ядра.",
"ZH": "检查应用程序和代理核心是否有新版本。",
"isReviewed": "True"
},
"Restart": {
"source": [
"Furious.Window.SettingsPage"
],
"RU": "Перезапустить",
"ZH": "重新启动",
"isReviewed": "True"
},
"Restart with privileges required by system-level networking.": {
"source": [
"Furious.Window.SettingsPage"
],
"RU": "Перезапустить с правами, необходимыми для системной настройки сети.",
"ZH": "使用系统级网络所需的权限重新启动。",
"isReviewed": "True"
},
"Open the folder containing the current application.": {
"source": [
"Furious.Window.SettingsPage"
],
"RU": "Открыть папку с текущим приложением.",
"ZH": "打开包含当前应用程序的文件夹。",
"isReviewed": "True"
},
"Authentication ID reported by the Hysteria2 server.": {
"source": [
"Furious.Window.SettingsPage"
],
"RU": "Идентификатор аутентификации, указанный сервером Hysteria2.",
"ZH": "Hysteria2服务器报告的身份验证 ID。",
"isReviewed": "True"
}
}
+64 -2
View File
@@ -322,17 +322,79 @@ class AppStyleSheet:
}}
QScrollArea#MetricsScrollArea,
QWidget#MetricsPageContent {{
QWidget#MetricsPageContent,
QScrollArea#SettingsScrollArea,
QWidget#SettingsPageContent,
QWidget#LogPageContent {{
border: none;
background-color: {palette['window']};
}}
QLabel#MetricsPageTitle {{
QLabel#MetricsPageTitle,
QLabel#LogPageTitle,
QLabel#SettingsPageTitle {{
color: {palette['text_strong']};
font-size: 16pt;
font-weight: 600;
}}
QLabel#SettingsSectionTitle {{
padding: 4px 2px;
color: {palette['text_strong']};
font-size: 12pt;
font-weight: 600;
}}
QFrame#SettingsCard {{
border: 1px solid {palette['border']};
border-radius: 9px;
background-color: {palette['panel']};
}}
QFrame#SettingsCard:hover {{
border-color: {palette['border_strong']};
background-color: {palette['panel_alt']};
}}
QLabel#SettingsCardTitle {{
color: {palette['text_strong']};
font-weight: 600;
}}
QLabel#SettingsCardDescription {{
color: {palette['muted']};
}}
QPushButton#SettingsActionButton {{
min-width: 82px;
}}
QCheckBox#SettingsToggle::indicator {{
width: 34px;
height: 18px;
border: 1px solid {palette['border_strong']};
border-radius: 9px;
background-color: {palette['raised']};
image: none;
}}
QCheckBox#SettingsToggle::indicator:hover {{
border-color: {palette['accent']};
background-color: {palette['hover']};
}}
QCheckBox#SettingsToggle::indicator:checked {{
border-color: {palette['accent']};
background-color: {palette['accent']};
image: none;
}}
QCheckBox#SettingsToggle::indicator:disabled {{
border-color: {palette['border']};
background-color: {palette['raised']};
image: none;
}}
QFrame#MetricsSection {{
border: 1px solid {palette['border']};
border-radius: 10px;
+21 -1
View File
@@ -104,6 +104,7 @@ class _NavigationPage:
widget: QWidget
button: _NavigationButton
translatable: bool = True
placement: str = 'top'
class NavigationView(Mixins.QTranslatable, Mixins.ThemeAware, QWidget):
@@ -145,9 +146,16 @@ class NavigationView(Mixins.QTranslatable, Mixins.ThemeAware, QWidget):
self.pageButtonLayout = QVBoxLayout()
self.pageButtonLayout.setContentsMargins(0, 6, 0, 0)
self.pageButtonLayout.setSpacing(4)
self.navigationLayout.addLayout(self.pageButtonLayout)
self.navigationLayout.addStretch()
self.bottomPageButtonLayout = QVBoxLayout()
self.bottomPageButtonLayout.setContentsMargins(0, 0, 0, 0)
self.bottomPageButtonLayout.setSpacing(4)
self.navigationLayout.addLayout(self.bottomPageButtonLayout)
self.pageButtonGroup = QButtonGroup(self)
self.pageButtonGroup.setExclusive(True)
@@ -180,6 +188,7 @@ class NavigationView(Mixins.QTranslatable, Mixins.ThemeAware, QWidget):
iconFileName: str,
*,
translatable: bool = True,
placement: str = 'top',
):
"""Register a page and create its navigation control."""
if not isinstance(pageId, str) or not pageId:
@@ -191,6 +200,9 @@ class NavigationView(Mixins.QTranslatable, Mixins.ThemeAware, QWidget):
if not isinstance(widget, QWidget):
raise TypeError('widget must be a QWidget')
if placement not in ('top', 'bottom'):
raise ValueError("placement must be either 'top' or 'bottom'")
button = _NavigationButton(
parent=self.navigationPanel,
hasSelectionIndicator=True,
@@ -216,10 +228,17 @@ class NavigationView(Mixins.QTranslatable, Mixins.ThemeAware, QWidget):
widget,
button,
translatable,
placement,
)
self._pages[pageId] = page
self.pageButtonGroup.addButton(button)
self.pageButtonLayout.addWidget(button)
if placement == 'bottom':
self.bottomPageButtonLayout.addWidget(button)
else:
self.pageButtonLayout.addWidget(button)
self.pageStack.addWidget(widget)
self._updatePageText(page)
@@ -336,6 +355,7 @@ class NavigationView(Mixins.QTranslatable, Mixins.ThemeAware, QWidget):
def _updatePageText(self, page: _NavigationPage):
"""Apply translated navigation text and compact-mode tooltips."""
text = _(page.title) if page.translatable else page.title
page.button.setText(text)
page.button.setToolTip('' if self._expanded else text)
-111
View File
@@ -511,66 +511,6 @@ class HomePage(Mixins.QTranslatable, QMainWindow):
),
]
if SystemRuntime.flatpakID():
customizeTUNSettingsAction = []
else:
customizeTUNSettingsAction = [
AppQAction(
_('Customize Tun2socks Settings...'),
icon=bootstrapIcon('diagram-3.svg'),
checkable=False,
callback=lambda: self.getGuiTUNSettings().open(),
),
]
if PLATFORM == 'Windows' or PLATFORM == 'Darwin':
_TRANSLATABLE_RESTART_AS_ADMIN = [
_('Restart The Application As Administrator'),
_('Restart The Application As Superuser'),
]
restartAsAdminAction = [
AppQAction(
_(f'Restart The Application As {ADMINISTRATOR_NAME}'),
icon=bootstrapIcon('arrow-clockwise.svg'),
checkable=False,
callback=lambda: self.restartAsAdmin(),
),
]
else:
restartAsAdminAction = []
if PLATFORM == 'Darwin' or SystemRuntime.flatpakID():
openAppFolderAction = []
else:
openAppFolderAction = [
AppQAction(
_('Open Application Folder'),
icon=bootstrapIcon('folder2-open.svg'),
checkable=False,
callback=lambda: self.openApplicationFolder(),
),
]
toolsActions = [
*customizeTUNSettingsAction,
AppQAction(
_('Customize System Proxy Bypass Address...'),
checkable=False,
callback=lambda: self.customizeProxyBypassDialog.open(),
),
AppQAction(
_('Customize Network Test URL...'),
icon=bootstrapIcon('speedometer2.svg'),
checkable=False,
callback=lambda: self.customizeNetworkTestDialog.open(),
),
]
systemTools = [*restartAsAdminAction, *openAppFolderAction]
if systemTools:
toolsActions.extend([AppQSeperator(), *systemTools])
corePluginActions = []
for plugin in pluginRegistry.pluginsWithCapability(
@@ -679,29 +619,6 @@ class HomePage(Mixins.QTranslatable, QMainWindow):
checkable=False,
),
*pluginsToolbarActions,
AppQSeperator(),
AppQAction(
_('Tools'),
icon=bootstrapIcon('tools.svg'),
menu=AppQMenu(*toolsActions),
useSetMenu=False,
useActionGroup=False,
checkable=False,
),
AppQSeperator(),
AppQAction(
_('Check For Updates'),
icon=bootstrapIcon('download.svg'),
checkable=False,
callback=lambda: self.checkForUpdates(parent=self),
),
AppQSeperator(),
AppQAction(
_('About'),
icon=bootstrapIcon('info-circle.svg'),
checkable=False,
callback=lambda: self.openAboutPage(),
),
)
self.toolbar.setObjectName('HomePageToolBar')
self.toolbar.setMovable(False)
@@ -728,37 +645,11 @@ class HomePage(Mixins.QTranslatable, QMainWindow):
'actions': [*pluginActions],
}
toolsMenu = {
'name': 'Tools',
'actions': [*toolsActions],
}
helpMenu = {
'name': 'Help',
'actions': [
AppQAction(
_('Check For Updates'),
icon=bootstrapIcon('download.svg'),
checkable=False,
callback=lambda: self.checkForUpdates(parent=self),
),
AppQSeperator(),
AppQAction(
_('About'),
icon=bootstrapIcon('info-circle.svg'),
checkable=False,
callback=lambda: self.openAboutPage(),
),
],
}
# Corresponds to menus defined above
_TRANSLATABLE_MENU_NAME = [
_('Server'),
_('Subscription'),
_('Plugins'),
_('Tools'),
_('Help'),
]
# Menus
@@ -766,8 +657,6 @@ class HomePage(Mixins.QTranslatable, QMainWindow):
serverMenu,
subsMenu,
pluginsMenu,
toolsMenu,
helpMenu,
):
menuName = menuDict['name']
menuObjName = f'_{menuName}Menu'
+14 -2
View File
@@ -132,7 +132,9 @@ class LogPage(Mixins.QTranslatable, QMainWindow):
self.manager = manager
self._preferredFilter = str(AppSettings.get('LogViewerSelectedCategory'))
self.filterLabel = AppQLabel(_('Log Type'))
self.pageTitleLabel = AppQLabel(translatable=False)
self.pageTitleLabel.setObjectName('LogPageTitle')
self.filterLabel = AppQLabel(translatable=False)
self.filterComboBox = QComboBox()
self.filterComboBox.setMinimumWidth(180)
@@ -143,15 +145,21 @@ class LogPage(Mixins.QTranslatable, QMainWindow):
self.textBrowser.setLineWrapMode(DraculaTextBrowser.LineWrapMode.NoWrap)
filterLayout = QHBoxLayout()
filterLayout.setContentsMargins(0, 0, 0, 0)
filterLayout.setSpacing(8)
filterLayout.addWidget(self.pageTitleLabel)
filterLayout.addStretch(1)
filterLayout.addWidget(self.filterLabel)
filterLayout.addWidget(self.filterComboBox)
filterLayout.addStretch()
centralLayout = QVBoxLayout()
centralLayout.setContentsMargins(20, 18, 20, 20)
centralLayout.setSpacing(14)
centralLayout.addLayout(filterLayout)
centralLayout.addWidget(self.textBrowser)
centralWidget = QWidget()
centralWidget.setObjectName('LogPageContent')
centralWidget.setLayout(centralLayout)
self.setCentralWidget(centralWidget)
@@ -221,6 +229,8 @@ class LogPage(Mixins.QTranslatable, QMainWindow):
self.manager.entryAdded.connect(self._entryAdded)
self.manager.entriesCleared.connect(self._entriesCleared)
self.retranslate()
def _categoryText(self, category) -> str:
"""Return a category's translated or literal display label."""
if category.id == APPLICATION_LOG_CATEGORY:
@@ -313,5 +323,7 @@ class LogPage(Mixins.QTranslatable, QMainWindow):
selectedCategoryId = self.filterComboBox.currentData()
self.pageTitleLabel.setText(_('Log'))
self.filterLabel.setText(_('Log Type'))
self._populateFilters(selectedCategoryId)
self._refreshEntries()
+33 -5
View File
@@ -28,6 +28,7 @@ from Furious.Widget.NavigationView import NavigationView
from Furious.Window.HomePage import HomePage
from Furious.Window.LogPage import LogPage
from Furious.Window.MetricsPage import MetricsPage
from Furious.Window.SettingsPage import SettingsPage
from PySide6 import QtCore
@@ -46,7 +47,12 @@ registerAppSettings('AppMainWindowState')
registerAppSettings('AppMainWindowSelectedPage', default='home')
registerAppSettings('AppMainWindowNavigationExpanded', isBinary=True)
_TRANSLATABLE_NAVIGATION_LABELS = (_('Home'), _('Metrics'), _('Log'))
_TRANSLATABLE_NAVIGATION_LABELS = (
_('Home'),
_('Log'),
_('Metrics'),
_('Settings'),
)
class MainWindow(AppQMainWindow):
@@ -75,6 +81,16 @@ class MainWindow(AppQMainWindow):
parent=self.navigationView,
)
self.logPage = AppLogPage()
self.settingsPage = SettingsPage(
tunSettingsDialogFactory=self.homePage.getGuiTUNSettings,
proxyBypassDialog=self.homePage.customizeProxyBypassDialog,
networkTestDialog=self.homePage.customizeNetworkTestDialog,
checkForUpdates=self.homePage.checkForUpdates,
openAboutPage=self.homePage.openAboutPage,
restartAsAdmin=self.homePage.restartAsAdmin,
openApplicationFolder=self.homePage.openApplicationFolder,
parent=self.navigationView,
)
if not isinstance(self.logPage, LogPage):
raise TypeError('application log page must be a LogPage')
@@ -85,6 +101,12 @@ class MainWindow(AppQMainWindow):
'Home',
'house-door.svg',
)
self.navigationView.addPage(
'log',
self.logPage,
'Log',
'pin-angle.svg',
)
self.navigationView.addPage(
'metrics',
self.metricsPage,
@@ -92,10 +114,11 @@ class MainWindow(AppQMainWindow):
'speedometer2.svg',
)
self.navigationView.addPage(
'log',
self.logPage,
'Log',
'pin-angle.svg',
'settings',
self.settingsPage,
'Settings',
'gear-wide-connected.svg',
placement='bottom',
)
self.navigationView.pageChanged.connect(self._pageChanged)
self.navigationView.expandedChanged.connect(self._navigationExpandedChanged)
@@ -140,6 +163,11 @@ class MainWindow(AppQMainWindow):
"""Navigate directly to the unified logging page."""
self.showPage('log')
def showSettingsPage(self):
"""Navigate directly to application settings."""
self.show()
self.showPage('settings')
def updateSubsByUnique(self, unique: str, httpProxy: Union[str, None], **kwargs):
"""Forward a subscription update to the home page."""
self.homePage.updateSubsByUnique(unique, httpProxy, **kwargs)
+728
View File
@@ -0,0 +1,728 @@
# Copyright (C) 2024–present Loren Eteval & contributors <loren.eteval@proton.me>
#
# 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 <https://www.gnu.org/licenses/>.
"""Present application configuration as a Fluent page of reusable cards."""
from __future__ import annotations
from Furious.Actions.Language import LANGUAGE_TO_ABBR
from Furious.Actions.Settings import (
SettingsController,
TUNModeAction,
)
from Furious.Backends.Hysteria2.Stats import (
HYSTERIA2_STATS_CLIENT_ID_SETTING,
HYSTERIA2_STATS_SECRET_SETTING,
HYSTERIA2_STATS_URL_SETTING,
)
from Furious.Frozenlib import *
from Furious.Qt import *
from Furious.Qt import gettext as _
from PySide6 import QtCore, QtGui
from PySide6.QtWidgets import *
from collections.abc import Callable
__all__ = ['SettingsPage']
class _SettingsSwitch(QCheckBox):
"""Paint a lightweight Fluent-style binary switch with Qt."""
ControlSize = QtCore.QSize(38, 22)
def __init__(self, parent=None):
"""Initialize the compact switch control."""
super().__init__(parent)
self.setObjectName('SettingsToggle')
self.setCursor(QtCore.Qt.CursorShape.PointingHandCursor)
self.setFixedSize(self.ControlSize)
self.toggled.connect(lambda _checked: self.update())
def paintEvent(self, event):
"""Draw the track and animated-position-equivalent thumb."""
del event
palette = AppStyleSheet.Palettes[APP().theme()]
painter = QtGui.QPainter(self)
painter.setRenderHint(QtGui.QPainter.RenderHint.Antialiasing)
track = QtCore.QRectF(1, 2, self.width() - 2, self.height() - 4)
radius = track.height() / 2
if not self.isEnabled():
background, border, thumb = (
palette['raised'],
palette['border'],
palette['disabled'],
)
elif self.isChecked():
background, border, thumb = (
palette['accent'],
palette['accent'],
palette['accent_text'],
)
else:
background, border, thumb = (
palette['raised'],
palette['border_strong'],
palette['muted'],
)
painter.setPen(QtGui.QPen(QtGui.QColor(border), 1))
painter.setBrush(QtGui.QColor(background))
painter.drawRoundedRect(track, radius, radius)
thumbDiameter = track.height() - 6
thumbX = (
track.right() - thumbDiameter - 3 if self.isChecked() else track.left() + 3
)
thumbRect = QtCore.QRectF(
thumbX,
track.top() + 3,
thumbDiameter,
thumbDiameter,
)
painter.setPen(QtCore.Qt.PenStyle.NoPen)
painter.setBrush(QtGui.QColor(thumb))
painter.drawEllipse(thumbRect)
if self.hasFocus():
focusRect = track.adjusted(-0.5, -0.5, 0.5, 0.5)
painter.setBrush(QtCore.Qt.BrushStyle.NoBrush)
painter.setPen(QtGui.QPen(QtGui.QColor(palette['accent']), 1))
painter.drawRoundedRect(focusRect, radius, radius)
class _SettingsCard(Mixins.ThemeAware, QFrame):
"""Lay out a themed icon, title, description, and trailing control."""
IconSize = QtCore.QSize(20, 20)
def __init__(self, iconFileName: str, control: QWidget, parent=None):
"""Initialize one reusable settings row."""
super().__init__(parent)
self.setObjectName('SettingsCard')
self.iconFileName = iconFileName
self.iconLabel = QLabel(parent=self)
self.iconLabel.setObjectName('SettingsCardIcon')
self.iconLabel.setFixedSize(self.IconSize)
self.titleLabel = QLabel(parent=self)
self.titleLabel.setObjectName('SettingsCardTitle')
self.descriptionLabel = QLabel(parent=self)
self.descriptionLabel.setObjectName('SettingsCardDescription')
self.descriptionLabel.setWordWrap(True)
self.control = control
self.control.setParent(self)
textLayout = QVBoxLayout()
textLayout.setContentsMargins(0, 0, 0, 0)
textLayout.setSpacing(2)
textLayout.addWidget(self.titleLabel)
textLayout.addWidget(self.descriptionLabel)
layout = QHBoxLayout(self)
layout.setContentsMargins(16, 12, 16, 12)
layout.setSpacing(14)
layout.addWidget(self.iconLabel, 0, QtCore.Qt.AlignmentFlag.AlignTop)
layout.addLayout(textLayout, 1)
layout.addSpacing(16)
layout.addWidget(self.control, 0, QtCore.Qt.AlignmentFlag.AlignVCenter)
self.setIconByTheme(APP().theme())
def setTexts(self, title: str, description: str):
"""Set already translated card text."""
self.titleLabel.setText(title)
self.descriptionLabel.setText(description)
def setIconByTheme(self, theme: str):
"""Refresh the card icon for the active theme."""
iconFactory = (
bootstrapIconWhite if theme == AppStyleSheet.Dark else bootstrapIcon
)
self.iconLabel.setPixmap(iconFactory(self.iconFileName).pixmap(self.IconSize))
def themeChangedCallback(self, theme: str):
"""Refresh themed card artwork."""
self.setIconByTheme(theme)
self.control.update()
class _ToggleSettingsCard(_SettingsCard):
"""Bind one binary application preference to a trailing checkbox."""
def __init__(self, iconFileName, settingName, callback, parent=None):
"""Initialize a persistent binary setting card."""
self.settingName = settingName
self.checkBox = _SettingsSwitch()
self.checkBox.setChecked(AppSettings.isStateON_(settingName))
self.checkBox.toggled.connect(callback)
super().__init__(iconFileName, self.checkBox, parent)
def sync(self):
"""Refresh the control from persistent state without applying it."""
blocker = QtCore.QSignalBlocker(self.checkBox)
self.checkBox.setChecked(AppSettings.isStateON_(self.settingName))
del blocker
class _ActionSettingsCard(_SettingsCard):
"""Expose an existing command through one settings row."""
def __init__(self, iconFileName, callback: Callable, parent=None):
"""Initialize a card with a compact trailing action button."""
self.button = QPushButton()
self.button.setObjectName('SettingsActionButton')
self.button.clicked.connect(callback)
super().__init__(iconFileName, self.button, parent)
class _LineEditSettingsCard(_SettingsCard):
"""Persist one text setting when editing finishes."""
def __init__(
self,
iconFileName,
settingName,
*,
placeholder='',
secret=False,
strip=True,
parent=None,
):
"""Initialize one bounded settings text editor."""
self.settingName = settingName
self.strip = strip
self.lineEdit = QLineEdit()
self.lineEdit.setObjectName('SettingsLineEdit')
self.lineEdit.setMaximumWidth(420)
self.lineEdit.setMinimumWidth(260)
self.lineEdit.setPlaceholderText(placeholder)
self.lineEdit.setText(str(AppSettings.get(settingName) or ''))
if secret:
self.lineEdit.setEchoMode(QLineEdit.EchoMode.Password)
self.lineEdit.editingFinished.connect(self.persist)
super().__init__(iconFileName, self.lineEdit, parent)
def persist(self):
"""Store the current text without coupling it to graph code."""
value = self.lineEdit.text()
if self.strip:
value = value.strip()
AppSettings.set(self.settingName, value)
class _LanguageSettingsCard(_SettingsCard):
"""Select and apply one supported application language."""
def __init__(self, parent=None):
"""Initialize the stable language-name selector."""
self.comboBox = QComboBox()
self.comboBox.setObjectName('SettingsComboBox')
self.comboBox.setMinimumWidth(180)
for languageName, language in LANGUAGE_TO_ABBR.items():
self.comboBox.addItem(languageName, language)
self.sync()
self.comboBox.currentIndexChanged.connect(self._selectionChanged)
super().__init__('globe2.svg', self.comboBox, parent)
def sync(self):
"""Select the persisted language without retriggering translation."""
if not hasattr(self, 'comboBox'):
return
blocker = QtCore.QSignalBlocker(self.comboBox)
index = self.comboBox.findData(AppSettings.get('Language'))
self.comboBox.setCurrentIndex(max(index, 0))
del blocker
@QtCore.Slot(int)
def _selectionChanged(self, _index: int):
"""Delegate language application to the settings controller."""
language = self.comboBox.currentData()
if isinstance(language, str):
SettingsController.setLanguage(language)
class _SettingsSection(QWidget):
"""Group a translated heading and a stack of settings cards."""
def __init__(self, parent=None):
"""Initialize an empty settings section."""
super().__init__(parent)
self.titleLabel = QLabel(parent=self)
self.titleLabel.setObjectName('SettingsSectionTitle')
self.cards = []
self.layout = QVBoxLayout(self)
self.layout.setContentsMargins(0, 0, 0, 0)
self.layout.setSpacing(6)
self.layout.addWidget(self.titleLabel)
def addCard(self, card: _SettingsCard):
"""Append a card while retaining it for translation and syncing."""
self.cards.append(card)
self.layout.addWidget(card)
class SettingsPage(Mixins.QTranslatable, QMainWindow):
"""Compose application settings without owning their operational logic."""
def __init__(
self,
*,
tunSettingsDialogFactory,
proxyBypassDialog,
networkTestDialog,
checkForUpdates,
openAboutPage,
restartAsAdmin,
openApplicationFolder,
parent=None,
):
"""Build settings sections around existing actions and dialogs."""
super().__init__(parent)
self.setObjectName('SettingsPage')
(
self._tunSettingsDialogFactory,
self._proxyBypassDialog,
self._networkTestDialog,
self._checkForUpdates,
self._openAboutPage,
self._restartAsAdmin,
self._openApplicationFolder,
) = (
tunSettingsDialogFactory,
proxyBypassDialog,
networkTestDialog,
checkForUpdates,
openAboutPage,
restartAsAdmin,
openApplicationFolder,
)
self.pageTitleLabel = QLabel()
self.pageTitleLabel.setObjectName('SettingsPageTitle')
self.generalSection = _SettingsSection()
self.connectionSection = _SettingsSection()
self.hysteriaSection = _SettingsSection()
self.applicationSection = _SettingsSection()
self.tunModeAction = TUNModeAction(
checkable=True,
checked=AppSettings.isStateON_('VPNMode'),
)
self.tunModeCard = _ToggleSettingsCard(
'shield-check.svg',
'VPNMode',
self._setTUNMode,
)
self.tunModeCard.checkBox.setEnabled(self.tunModeAction.isEnabled())
self.tunModeAction.changed.connect(self._syncTUNModeAction)
self.tunModeAction.toggled.connect(self._syncTUNModeAction)
(
self.darkModeCard,
self.languageCard,
self.monochromeCard,
self.startupCard,
self.powerSaveCard,
) = (
_ToggleSettingsCard(
'moon-stars.svg',
'DarkMode',
SettingsController.setDarkMode,
),
_LanguageSettingsCard(),
_ToggleSettingsCard(
'circle-half.svg',
'UseMonochromeTrayIcon',
SettingsController.setMonochromeTrayIcon,
),
_ToggleSettingsCard(
'power.svg',
'StartupOnBoot',
SettingsController.setStartupOnBoot,
),
_ToggleSettingsCard(
'battery-half.svg',
'PowerSaveMode',
SettingsController.setPowerSaveMode,
),
)
self.generalSection.addCard(self.tunModeCard)
self.generalSection.addCard(self.darkModeCard)
self.generalSection.addCard(self.languageCard)
self.generalSection.addCard(self.monochromeCard)
if PLATFORM == 'Darwin':
self.hideDockCard = _ToggleSettingsCard(
'window.svg',
'HideDockIcon',
SettingsController.setDockIconHidden,
)
self.generalSection.addCard(self.hideDockCard)
else:
self.hideDockCard = None
self.generalSection.addCard(self.startupCard)
self.generalSection.addCard(self.powerSaveCard)
(
self.tunSettingsCard,
self.proxyBypassCard,
self.networkTestCard,
self.forceLocalhostCard,
self.connectionProgressCard,
self.editorWhitespaceCard,
) = (
_ActionSettingsCard(
'diagram-3.svg',
self._openTUNSettings,
),
_ActionSettingsCard(
'signpost-split.svg',
self._proxyBypassDialog.open,
),
_ActionSettingsCard(
'link-45deg.svg',
self._networkTestDialog.open,
),
_ToggleSettingsCard(
'pc-display-horizontal.svg',
'ForceToLocalhostWhenSettingLocalProxy',
SettingsController.setForceLocalProxy,
),
_ToggleSettingsCard(
'hourglass-split.svg',
'ShowProgressBarWhenConnecting',
SettingsController.setConnectionProgressVisible,
),
_ToggleSettingsCard(
'text-paragraph.svg',
'ShowTabAndSpacesInEditor',
SettingsController.setEditorWhitespaceVisible,
),
)
if not SystemRuntime.flatpakID():
self.connectionSection.addCard(self.tunSettingsCard)
self.connectionSection.addCard(self.proxyBypassCard)
self.connectionSection.addCard(self.networkTestCard)
self.connectionSection.addCard(self.forceLocalhostCard)
self.connectionSection.addCard(self.connectionProgressCard)
self.connectionSection.addCard(self.editorWhitespaceCard)
if SystemRuntime.isAssetsFolderWritable():
self.autoAssetsCard = _ToggleSettingsCard(
'cloud-arrow-down.svg',
'AutoUpdateAssetFiles',
SettingsController.setAutoUpdateAssets,
)
self.connectionSection.addCard(self.autoAssetsCard)
else:
self.autoAssetsCard = None
(
self.hysteriaURLCard,
self.hysteriaClientIdCard,
self.hysteriaSecretCard,
) = (
_LineEditSettingsCard(
'activity.svg',
HYSTERIA2_STATS_URL_SETTING,
placeholder='http://server:9999',
),
_LineEditSettingsCard(
'person-badge.svg',
HYSTERIA2_STATS_CLIENT_ID_SETTING,
),
_LineEditSettingsCard(
'key.svg',
HYSTERIA2_STATS_SECRET_SETTING,
secret=True,
strip=False,
),
)
self.hysteriaSection.addCard(self.hysteriaURLCard)
self.hysteriaSection.addCard(self.hysteriaClientIdCard)
self.hysteriaSection.addCard(self.hysteriaSecretCard)
(
self.updateCard,
self.aboutCard,
) = (
_ActionSettingsCard(
'download.svg',
lambda: self._checkForUpdates(parent=self),
),
_ActionSettingsCard(
'info-circle.svg',
self._openAboutPage,
),
)
self.applicationSection.addCard(self.updateCard)
self.applicationSection.addCard(self.aboutCard)
if PLATFORM == 'Windows' or PLATFORM == 'Darwin':
self.restartCard = _ActionSettingsCard(
'arrow-clockwise.svg',
self._restartAsAdmin,
)
self.applicationSection.addCard(self.restartCard)
else:
self.restartCard = None
if PLATFORM != 'Darwin' and not SystemRuntime.flatpakID():
self.openFolderCard = _ActionSettingsCard(
'folder2-open.svg',
self._openApplicationFolder,
)
self.applicationSection.addCard(self.openFolderCard)
else:
self.openFolderCard = None
contentWidget = QWidget()
contentWidget.setObjectName('SettingsPageContent')
contentLayout = QVBoxLayout(contentWidget)
contentLayout.setContentsMargins(20, 18, 20, 24)
contentLayout.setSpacing(22)
contentLayout.addWidget(self.pageTitleLabel)
contentLayout.addWidget(self.generalSection)
contentLayout.addWidget(self.connectionSection)
contentLayout.addWidget(self.hysteriaSection)
contentLayout.addWidget(self.applicationSection)
contentLayout.addStretch(1)
self.scrollArea = QScrollArea()
self.scrollArea.setObjectName('SettingsScrollArea')
self.scrollArea.setWidgetResizable(True)
self.scrollArea.setFrameShape(QFrame.Shape.NoFrame)
self.scrollArea.setWidget(contentWidget)
self.setCentralWidget(self.scrollArea)
self.retranslate()
def _setTUNMode(self, enabled: bool):
"""Trigger the compatibility action used by connection state."""
if self.tunModeAction.isChecked() != enabled:
self.tunModeAction.trigger()
@QtCore.Slot()
def _syncTUNModeAction(self, *_args):
"""Mirror compatibility-action state into the settings control."""
blocker = QtCore.QSignalBlocker(self.tunModeCard.checkBox)
self.tunModeCard.checkBox.setChecked(self.tunModeAction.isChecked())
self.tunModeCard.checkBox.setEnabled(self.tunModeAction.isEnabled())
del blocker
def _openTUNSettings(self):
"""Create and retain the existing Tun2socks settings dialog."""
self._tunSettingsDialogFactory(parent=self).open()
def setTUNModeControlEnabled(self, enabled: bool):
"""Disable TUN changes while a connection transition is active."""
self.tunModeAction.setEnabled(enabled)
self.tunModeCard.checkBox.setEnabled(enabled)
@staticmethod
def _setActionCardText(card, buttonText, title, description):
"""Apply translated action-card text consistently."""
card.button.setText(buttonText)
card.setTexts(title, description)
def showEvent(self, event):
"""Synchronize controls in case a legacy action changed a setting."""
super().showEvent(event)
for section in (
self.generalSection,
self.connectionSection,
self.applicationSection,
):
for card in section.cards:
sync = getattr(card, 'sync', None)
if callable(sync):
sync()
self.languageCard.sync()
def retranslate(self):
"""Refresh section, card, button, and accessibility text."""
self.pageTitleLabel.setText(_('Settings'))
self.generalSection.titleLabel.setText(_('General'))
self.connectionSection.titleLabel.setText(_('Connection and Interface'))
self.hysteriaSection.titleLabel.setText(_('Hysteria2 Traffic Statistics'))
self.applicationSection.titleLabel.setText(_('Application'))
tunTitle = _('TUN Mode')
if PLATFORM != 'Linux' and not SystemRuntime.isAdmin():
if ADMINISTRATOR_NAME == 'Administrator':
tunTitle = _('TUN Mode Disabled (Administrator)')
else:
tunTitle = _('TUN Mode Disabled (Superuser)')
self.tunModeCard.setTexts(
tunTitle,
_('Route system traffic through the active proxy connection.'),
)
self.darkModeCard.setTexts(
_('Dark Mode'),
_('Use the application dark theme instead of automatic appearance.'),
)
self.languageCard.setTexts(
_('Language'),
_('Choose the language used by the application interface.'),
)
self.monochromeCard.setTexts(
_('Use Monochrome Tray Icon'),
_('Use a theme-aware single-color system tray icon.'),
)
self.startupCard.setTexts(
_('Startup On Boot'),
_('Start the application automatically after signing in.'),
)
self.powerSaveCard.setTexts(
_('Power Save Mode'),
_('Reduce background activity when the application is idle.'),
)
if self.hideDockCard is not None:
self.hideDockCard.setTexts(
_('Hide Dock Icon'),
_('Keep the application available from the menu bar only.'),
)
self._setActionCardText(
self.tunSettingsCard,
_('Open'),
_('Customize Tun2socks Settings...'),
_('Configure the external Tun2socks network interface and routing.'),
)
self._setActionCardText(
self.proxyBypassCard,
_('Open'),
_('Customize System Proxy Bypass Address...'),
_('Choose destinations that bypass the operating system proxy.'),
)
self._setActionCardText(
self.networkTestCard,
_('Open'),
_('Customize Network Test URL...'),
_('Choose the URL used for download speed tests.'),
)
self.forceLocalhostCard.setTexts(
_('Force To 127.0.0.1 When Setting Local Proxy'),
_('Use the IPv4 loopback address when configuring the system proxy.'),
)
self.connectionProgressCard.setTexts(
_('Show Progress Bar When Connecting'),
_('Show connection progress while proxy services are starting.'),
)
self.editorWhitespaceCard.setTexts(
_('Show Tab And Spaces In Editor'),
_('Display whitespace markers in configuration editors.'),
)
if self.autoAssetsCard is not None:
self.autoAssetsCard.setTexts(
_('Automatically Update Asset Files'),
_('Keep supported proxy-core data files up to date.'),
)
self.hysteriaURLCard.setTexts(
_('Traffic Stats API URL'),
_('Hysteria2 server API address; /traffic is appended automatically.'),
)
self.hysteriaClientIdCard.setTexts(
_('Traffic Stats Client ID'),
_('Authentication ID reported by the Hysteria2 server.'),
)
self.hysteriaSecretCard.setTexts(
_('Traffic Stats API Secret'),
_('Authorization value configured by the Hysteria2 server.'),
)
self._setActionCardText(
self.updateCard,
_('Check'),
_('Check For Updates'),
_('Check for a newer application and proxy-core release.'),
)
self._setActionCardText(
self.aboutCard,
_('View'),
_('About'),
f'{APPLICATION_NAME} {APPLICATION_VERSION}',
)
if self.restartCard is not None:
if ADMINISTRATOR_NAME == 'Administrator':
restartTitle = _('Restart The Application As Administrator')
else:
restartTitle = _('Restart The Application As Superuser')
self._setActionCardText(
self.restartCard,
_('Restart'),
restartTitle,
_('Restart with privileges required by system-level networking.'),
)
if self.openFolderCard is not None:
self._setActionCardText(
self.openFolderCard,
_('Open'),
_('Open Application Folder'),
_('Open the folder containing the current application.'),
)
self.languageCard.sync()
+2
View File
@@ -24,6 +24,7 @@ from .HomePage import HomePage
from .LogPage import LogPage
from .MetricsPage import MetricsPage
from .MainWindow import MainWindow
from .SettingsPage import SettingsPage
from .NetworkTestDialog import NetworkTestDialog
from .ProxyBypassDialog import ProxyBypassDialog
from .QRCodeWindow import QRCodeWindow
@@ -37,6 +38,7 @@ __all__ = [
'LogPage',
'MetricsPage',
'MainWindow',
'SettingsPage',
'NetworkTestDialog',
'ProxyBypassDialog',
'QRCodeWindow',