Files
LorenEteval_Furious/Furious/Window/SettingsPage.py
T
2026-08-12 12:21:57 +08:00

745 lines
25 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 Furious.Service.TrafficStatsManager import (
CLEAR_TRAFFIC_USAGE_ON_RECONNECT_SETTING,
)
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.clearTrafficUsageCard,
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(
'arrow-repeat.svg',
CLEAR_TRAFFIC_USAGE_ON_RECONNECT_SETTING,
SettingsController.setClearTrafficUsageOnReconnect,
),
_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.clearTrafficUsageCard)
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.clearTrafficUsageCard.setTexts(
_('Clear Traffic Usage Statistics On Reconnect'),
_(
'Start accumulated upload and download usage from zero after reconnecting.'
),
)
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()