From e348670520d1c0d4e4bfd5487436c082727e107c Mon Sep 17 00:00:00 2001 From: Loren Eteval Date: Tue, 11 Aug 2026 09:58:07 +0800 Subject: [PATCH] Add unified application settings page Signed-off-by: Loren Eteval --- Furious/Actions/Settings.py | 330 ++++++------- Furious/Application/TrayIcon.py | 1 - Furious/Externals/GenTranslation.py | 304 ++++++++++-- Furious/Qt/AppStyleSheet.py | 66 ++- Furious/Widget/NavigationView.py | 22 +- Furious/Window/HomePage.py | 111 ----- Furious/Window/LogPage.py | 16 +- Furious/Window/MainWindow.py | 38 +- Furious/Window/SettingsPage.py | 728 ++++++++++++++++++++++++++++ Furious/Window/__init__.py | 2 + 10 files changed, 1275 insertions(+), 343 deletions(-) create mode 100644 Furious/Window/SettingsPage.py diff --git a/Furious/Actions/Settings.py b/Furious/Actions/Settings.py index 6111f4ad..d16c1ad9 100644 --- a/Furious/Actions/Settings.py +++ b/Furious/Actions/Settings.py @@ -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 diff --git a/Furious/Application/TrayIcon.py b/Furious/Application/TrayIcon.py index ae9c767a..5656f8f8 100644 --- a/Furious/Application/TrayIcon.py +++ b/Furious/Application/TrayIcon.py @@ -61,7 +61,6 @@ class TrayIcon( ImportAction(isTrayAction=True), EditConfigurationAction(isTrayAction=True), AppQSeperator(), - LanguageAction(isTrayAction=True), SettingsAction(isTrayAction=True), AppQSeperator(), ExitAction(isTrayAction=True), diff --git a/Furious/Externals/GenTranslation.py b/Furious/Externals/GenTranslation.py index 6b491121..4ce7e23b 100644 --- a/Furious/Externals/GenTranslation.py +++ b/Furious/Externals/GenTranslation.py @@ -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" } } diff --git a/Furious/Qt/AppStyleSheet.py b/Furious/Qt/AppStyleSheet.py index b55715f6..0f54490f 100644 --- a/Furious/Qt/AppStyleSheet.py +++ b/Furious/Qt/AppStyleSheet.py @@ -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; diff --git a/Furious/Widget/NavigationView.py b/Furious/Widget/NavigationView.py index 7ce4cc6a..1834f453 100644 --- a/Furious/Widget/NavigationView.py +++ b/Furious/Widget/NavigationView.py @@ -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) diff --git a/Furious/Window/HomePage.py b/Furious/Window/HomePage.py index d5719ac2..c5f05133 100644 --- a/Furious/Window/HomePage.py +++ b/Furious/Window/HomePage.py @@ -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' diff --git a/Furious/Window/LogPage.py b/Furious/Window/LogPage.py index d1af2065..16d99791 100644 --- a/Furious/Window/LogPage.py +++ b/Furious/Window/LogPage.py @@ -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() diff --git a/Furious/Window/MainWindow.py b/Furious/Window/MainWindow.py index 040f8a6e..6fc53f07 100644 --- a/Furious/Window/MainWindow.py +++ b/Furious/Window/MainWindow.py @@ -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) diff --git a/Furious/Window/SettingsPage.py b/Furious/Window/SettingsPage.py new file mode 100644 index 00000000..c88546fd --- /dev/null +++ b/Furious/Window/SettingsPage.py @@ -0,0 +1,728 @@ +# Copyright (C) 2024–present Loren Eteval & contributors +# +# This file is part of Furious. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""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() diff --git a/Furious/Window/__init__.py b/Furious/Window/__init__.py index 084fef90..1a9a3221 100644 --- a/Furious/Window/__init__.py +++ b/Furious/Window/__init__.py @@ -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',