diff --git a/Furious/Actions/Connection.py b/Furious/Actions/Connection.py index be6fe91..a65d08f 100644 --- a/Furious/Actions/Connection.py +++ b/Furious/Actions/Connection.py @@ -180,7 +180,7 @@ class ConnectAction(AppQAction): self.setDisabled(value) try: - APP().systemTray.RoutingAction.setDisabled(value) + APP().routingController.setInteractionEnabled(not value) except (AttributeError, RuntimeError): pass diff --git a/Furious/Actions/Routing.py b/Furious/Actions/Routing.py index 94a8851..adabb59 100644 --- a/Furious/Actions/Routing.py +++ b/Furious/Actions/Routing.py @@ -15,19 +15,17 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -"""Implement tray actions for routing.""" +"""Present shared routing state through the tray action menu.""" from __future__ import annotations -from Furious.Frozenlib import * -from Furious.Repository import * -from Furious.Plugins import getPluginRegistry +from Furious.Controllers import RoutingController from Furious.Qt import * from Furious.Qt import gettext as _ -__all__ = ['RoutingAction'] +from PySide6 import QtCore -registerAppSettings('Routing', default=AppBuiltinRouting.BypassMainlandChina.value) +__all__ = ['RoutingAction'] # ALL BUILTIN ROUTING VALUE _TRANSLATABLE_BUILTIN_ROUTING = [ @@ -38,33 +36,32 @@ _TRANSLATABLE_BUILTIN_ROUTING = [ class RoutingChildAction(AppQAction): - """Handle the routing child action.""" + """Forward one tray route choice to the shared controller.""" - def __init__(self, *args, **kwargs): + def __init__( + self, + *args, + controller: RoutingController, + routingValue: str, + **kwargs, + ): """Initialize the RoutingChildAction.""" - self.routingValue = kwargs.pop('routingValue', None) + self.controller = controller + self.routingValue = routingValue super().__init__(*args, **kwargs) def triggeredCallback(self, checked): """Handle activation of the action.""" - textEnglish = self.routingValue or self.textEnglish - - if AppSettings.get('Routing') != textEnglish: - AppSettings.set('Routing', textEnglish) - - if APP().isSystemTrayConnected(): - APP().systemTray.ConnectAction.doReconnect() + self.controller.selectRouting(self.routingValue) class RoutingAction(AppQAction): - """Handle the routing action.""" + """Render application routing options as a synchronized tray menu.""" - def __init__(self, **kwargs): + def __init__(self, controller: RoutingController, **kwargs): """Initialize the RoutingAction.""" - if AppSettings.get('Routing') == 'Bypass': - # Update value for backward compatibility - AppSettings.set('Routing', AppBuiltinRouting.BypassMainlandChina.value) + self.controller = controller super().__init__( _('Routing'), @@ -73,17 +70,13 @@ class RoutingAction(AppQAction): **kwargs, ) - self.rebuildMenu() + self.controller.stateChanged.connect(self._applyState) + self.controller.interactionEnabledChanged.connect(self.setEnabled) - def routingActions(self): - """Return routing actions supported by the active core plugin.""" - config = self.activeConfig() - if config is None: - return list() + self._applyState(*self.controller.state()) - pluginRegistry = getPluginRegistry() - options = pluginRegistry.routingOptions(config) - routing = pluginRegistry.normalizeRouting(config, AppSettings.get('Routing')) + def routingActions(self, options, routing: str): + """Build tray actions from one shared controller snapshot.""" actions = list() for option in options: @@ -97,6 +90,7 @@ class RoutingAction(AppQAction): if option.translatable else option.displayName ), + controller=self.controller, routingValue=option.id, checkable=True, checked=routing == option.id, @@ -105,27 +99,20 @@ class RoutingAction(AppQAction): return actions - @staticmethod - def activeConfig(): - """Return the currently selected server configuration, if any.""" - try: - index = Storage.UserActivatedItemIndex() - servers = Storage.UserServers() + @QtCore.Slot(object, str) + def _applyState(self, options, routing: str): + """Rebuild the tray menu from one controller state snapshot.""" + oldActionGroup = getattr(self, '_actionGroup', None) - if 0 <= index < len(servers): - return servers[index] - except Exception: - # The tray may be built before persistent storage is available. - pass - - return None - - def rebuildMenu(self): - """Handle rebuild menu for the routing action.""" self._menu.clear() self._menu._actions.clear() + + if oldActionGroup is not None: + oldActionGroup.deleteLater() + self._actionGroup = AppQActionGroup(self) - actions = self.routingActions() + + actions = self.routingActions(options, routing) for action in actions: if isinstance(action, AppQSeperator): @@ -137,15 +124,8 @@ class RoutingAction(AppQAction): self._actionGroup.addAction(action) self.setVisible(bool(actions)) + self.setEnabled(self.controller.interactionEnabled) - def getGlobalAction(self): - """Return the active plugin's global routing action, if supported.""" - return next( - ( - action - for action in self._menu._actions - if isinstance(action, RoutingChildAction) - and action.routingValue == AppBuiltinRouting.Global.value - ), - None, - ) + def rebuildMenu(self): + """Refresh plugin options immediately before the tray menu opens.""" + self.controller.refresh(force=True) diff --git a/Furious/Application/DesktopApplication.py b/Furious/Application/DesktopApplication.py index ef6b72e..b791415 100644 --- a/Furious/Application/DesktopApplication.py +++ b/Furious/Application/DesktopApplication.py @@ -23,6 +23,7 @@ from Furious.Frozenlib import * from Furious.Interface import * from Furious.Core import Tun2socks from Furious.Backends import OFFICIAL_PLUGIN_TYPES +from Furious.Controllers import RoutingController from Furious.Extensions import BUNDLED_EXTENSION_TYPES from Furious.Plugins import getPluginRegistry, initializePluginRegistry from Furious.Qt import AppStyleSheet @@ -181,6 +182,7 @@ class DesktopApplication(ApplicationRunner, SingletonApplication): self.mainWindow = None self.systemTray = None self.connectionAction = None + self.routingController = None # Unified logging service and presentation self.logManager = None @@ -544,8 +546,9 @@ class DesktopApplication(ApplicationRunner, SingletonApplication): SystemProxy.off() SystemProxy.daemonOn_() - # The application owns the connection operation. Home and tray bind - # to this same action, regardless of their construction order. + # The application owns connection and routing operations. Home and + # tray bind to these same objects regardless of construction order. + self.routingController = RoutingController(parent=self) self.connectionAction = ConnectAction(isTrayAction=True) self.mainWindow = MainWindow() diff --git a/Furious/Application/TrayIcon.py b/Furious/Application/TrayIcon.py index b3d3fac..f893d5d 100644 --- a/Furious/Application/TrayIcon.py +++ b/Furious/Application/TrayIcon.py @@ -55,7 +55,7 @@ class TrayIcon( actions = [ APP().connectionAction, - RoutingAction(isTrayAction=True), + RoutingAction(APP().routingController, isTrayAction=True), ImportAction(isTrayAction=True), AppQSeperator(), ShowHomePageAction(isTrayAction=True), diff --git a/Furious/Controllers/RoutingController.py b/Furious/Controllers/RoutingController.py new file mode 100644 index 0000000..e6bebdf --- /dev/null +++ b/Furious/Controllers/RoutingController.py @@ -0,0 +1,153 @@ +# 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 . + +"""Own application routing selection independently from its UI controls.""" + +from __future__ import annotations + +from Furious.Frozenlib import ( + APP, + AppBuiltinRouting, + AppSettings, + registerAppSettings, +) +from Furious.Plugins import getPluginRegistry +from Furious.Repository import Storage + +from PySide6 import QtCore + +__all__ = ['RoutingController'] + +registerAppSettings('Routing', default=AppBuiltinRouting.BypassMainlandChina.value) + + +class RoutingController(QtCore.QObject): + """Expose one routing state shared by every application surface.""" + + stateChanged = QtCore.Signal(object, str) + optionsChanged = QtCore.Signal(object) + routingChanged = QtCore.Signal(str) + interactionEnabledChanged = QtCore.Signal(bool) + + def __init__(self, parent=None): + """Initialize persisted routing state and plugin-provided options.""" + super().__init__(parent) + + if AppSettings.get('Routing') == 'Bypass': + # Update the value used by older Furious releases. + AppSettings.set('Routing', AppBuiltinRouting.BypassMainlandChina.value) + + self._options = tuple() + self._routing = str(AppSettings.get('Routing')) + self._interactionEnabled = True + + self.refresh() + + @staticmethod + def activeConfiguration(): + """Return the active server configuration, if one is available.""" + try: + index = Storage.UserActivatedItemIndex() + servers = Storage.UserServers() + + if 0 <= index < len(servers): + return servers[index] + except Exception: + # The controller can exist before persistent storage is ready. + pass + + return None + + @property + def options(self): + """Return routing options supported by the active configuration.""" + return self._options + + @property + def routing(self) -> str: + """Return the normalized route shown by routing controls.""" + return self._routing + + @property + def interactionEnabled(self) -> bool: + """Return whether users may change routing at this lifecycle state.""" + return self._interactionEnabled + + def state(self): + """Return the current immutable option and selection snapshot.""" + return self.options, self.routing + + def refresh(self, *, force=False): + """Refresh routing capabilities from the active proxy-core plugin.""" + config = self.activeConfiguration() + registry = getPluginRegistry() + options = registry.routingOptions(config) if config is not None else tuple() + routing = ( + registry.normalizeRouting(config, AppSettings.get('Routing')) + if config is not None + else str(AppSettings.get('Routing')) + ) + + optionsChanged = options != self._options + routingChanged = routing != self._routing + + self._options = options + self._routing = routing + + if optionsChanged: + self.optionsChanged.emit(options) + + if routingChanged: + self.routingChanged.emit(routing) + + if force or optionsChanged or routingChanged: + self.stateChanged.emit(options, routing) + + return self.state() + + @QtCore.Slot(str) + def selectRouting(self, routing: str) -> bool: + """Persist a supported route and reconnect the active proxy if needed.""" + options, _current = self.refresh() + + if routing not in tuple(option.id for option in options): + return False + + if AppSettings.get('Routing') == routing: + return False + + AppSettings.set('Routing', routing) + + self.refresh(force=True) + + app = APP() + + if app is not None and app.isSystemTrayConnected(): + app.connectionAction.doReconnect() + + return True + + @QtCore.Slot(bool) + def setInteractionEnabled(self, enabled: bool): + """Publish whether routing controls may accept user interaction.""" + enabled = bool(enabled) + + if enabled == self._interactionEnabled: + return + + self._interactionEnabled = enabled + self.interactionEnabledChanged.emit(enabled) diff --git a/Furious/Controllers/__init__.py b/Furious/Controllers/__init__.py index 38163c6..4944a1e 100644 --- a/Furious/Controllers/__init__.py +++ b/Furious/Controllers/__init__.py @@ -17,6 +17,7 @@ """Expose presentation-independent application controllers.""" +from .RoutingController import RoutingController from .SettingsController import SettingsController -__all__ = ['SettingsController'] +__all__ = ['RoutingController', 'SettingsController'] diff --git a/Furious/Qt/QtWidgets.py b/Furious/Qt/QtWidgets.py index c0f01c1..1b62754 100644 --- a/Furious/Qt/QtWidgets.py +++ b/Furious/Qt/QtWidgets.py @@ -38,6 +38,7 @@ __all__ = [ 'moveToCenter', 'AppQCheckBox', 'AppQComboBox', + 'AppQComboBoxSeparatorDelegate', 'AppQDialog', 'AppQDialogButtonBox', 'AppQGroupBox', @@ -96,6 +97,55 @@ class AppQCheckBox(Mixins.QTranslatable, QCheckBox): self.setText(_(self.text())) +class AppQComboBoxSeparatorDelegate(Mixins.ThemeAware, QAbstractItemDelegate): + """Paint real combo-box separators with application theme tokens.""" + + SeparatorRoleValue = 'separator' + HorizontalMargin = 8 + + def __init__(self, wrappedDelegate, comboBox): + """Wrap Qt's native delegate without changing normal item painting.""" + self.wrappedDelegate = wrappedDelegate + self.comboBox = comboBox + + super().__init__(comboBox) + + @staticmethod + def isSeparator(index) -> bool: + """Return whether *index* was created by QComboBox.insertSeparator().""" + return ( + index.data(QtCore.Qt.ItemDataRole.AccessibleDescriptionRole) + == AppQComboBoxSeparatorDelegate.SeparatorRoleValue + ) + + def paint(self, painter, option, index): + """Paint separators and preserve Qt's native rendering for all items.""" + if not self.isSeparator(index): + self.wrappedDelegate.paint(painter, option, index) + + return + + try: + color = QColor( + AppStyleSheet.paletteForTheme(APP().theme())['border_strong'] + ) + except (AttributeError, RuntimeError): + color = option.palette.color(QPalette.ColorRole.Mid) + + line = option.rect.adjusted(self.HorizontalMargin, 0, -self.HorizontalMargin, 0) + y = line.center().y() + + painter.fillRect(line.left(), y, max(0, line.width()), 1, color) + + def sizeHint(self, option, index): + """Keep the native popup's item and separator geometry unchanged.""" + return self.wrappedDelegate.sizeHint(option, index) + + def themeChangedCallback(self, theme: str): + """Repaint the popup viewport after an application theme change.""" + self.comboBox.view().viewport().update() + + class AppQComboBox(Mixins.QTranslatable, QComboBox): """Represent app q combo box.""" @@ -103,6 +153,18 @@ class AppQComboBox(Mixins.QTranslatable, QComboBox): """Initialize the AppQComboBox.""" super().__init__(*args, **kwargs) + self._themedSeparatorDelegate = None + + def enableThemedSeparators(self): + """Theme real insertSeparator() rows while preserving native items.""" + if self._themedSeparatorDelegate is not None: + return + + self._themedSeparatorDelegate = AppQComboBoxSeparatorDelegate( + self.itemDelegate(), self + ) + self.setItemDelegate(self._themedSeparatorDelegate) + def retranslate(self): """Refresh translated text for the app q combo box.""" for index in range(self.count()): @@ -1073,10 +1135,20 @@ def showMBoxDirectRulesNotAllowed(**kwargs): def handleResultCode(code): """Handle result code.""" if code == PySide6Legacy.enumValueWrapper(AppQMessageBox.StandardButton.Yes): - globalAction = APP().systemTray.RoutingAction.getGlobalAction() - if globalAction is not None: - globalAction.trigger() - APP().systemTray.ConnectAction.trigger() + app = APP() + + controller, wasConnected = ( + app.routingController, + app.isSystemTrayConnected(), + ) + + changed = controller.selectRouting(AppBuiltinRouting.Global.value) + + if changed or controller.routing == AppBuiltinRouting.Global.value: + # Selecting a route reconnects an established connection itself. + # During an initial connection attempt, resume that attempt here. + if not wasConnected: + app.connectionAction.trigger() else: # Do nothing pass diff --git a/Furious/Qt/__init__.py b/Furious/Qt/__init__.py index 441bbb7..67a7022 100644 --- a/Furious/Qt/__init__.py +++ b/Furious/Qt/__init__.py @@ -53,6 +53,7 @@ from .QtNetwork import AppQNetworkAccessManager from .QtWidgets import ( AppQCheckBox, AppQComboBox, + AppQComboBoxSeparatorDelegate, AppQDialog, AppQDialogButtonBox, AppQGroupBox, @@ -102,6 +103,7 @@ __all__ = [ 'AppQActionGroup', 'AppQCheckBox', 'AppQComboBox', + 'AppQComboBoxSeparatorDelegate', 'AppQDialog', 'AppQDialogButtonBox', 'AppQGroupBox', diff --git a/Furious/Widget/RoutingSelector.py b/Furious/Widget/RoutingSelector.py new file mode 100644 index 0000000..964145f --- /dev/null +++ b/Furious/Widget/RoutingSelector.py @@ -0,0 +1,106 @@ +# 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 the shared routing state as a compact Fluent-style selector.""" + +from __future__ import annotations + +from Furious.Controllers import RoutingController +from Furious.Frozenlib import Mixins +from Furious.Qt import AppQComboBox +from Furious.Qt import gettext as _ + +from PySide6 import QtCore +from PySide6.QtWidgets import QSizePolicy + +__all__ = ['RoutingSelector'] + + +class RoutingSelector(AppQComboBox): + """Select routes through the application-wide routing controller.""" + + def __init__(self, controller: RoutingController, parent=None): + """Bind the selector to one shared routing controller.""" + self.controller = controller + + # This widget restores its enabled state from the shared controller in + # retranslate(), so the generic translation disable/enable wrapper is + # neither needed nor safe during connection transitions. + super().__init__(parent, useQSetDisabled=False) + + self.setObjectName('HomeRoutingSelector') + self.enableThemedSeparators() + self.setMinimumWidth(180) + self.setMaximumWidth(280) + self.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed) + self.setAccessibleName(_('Routing')) + self.setToolTip(_('Routing')) + + self.currentIndexChanged.connect(self._handleCurrentIndexChanged) + self.controller.stateChanged.connect(self._applyState) + self.controller.interactionEnabledChanged.connect(self._applyInteractionEnabled) + + self._applyState(*self.controller.state()) + + @staticmethod + def _optionText(option) -> str: + """Return an option's translated or literal display name.""" + return _(option.displayName) if option.translatable else option.displayName + + @QtCore.Slot(object, str) + def _applyState(self, options, routing: str): + """Replace items atomically from one controller state snapshot.""" + with Mixins.QBlockSignalContext(self): + self.clear() + + for option in options: + if option.separatorBefore and self.count(): + self.insertSeparator(self.count()) + + self.addItem(self._optionText(option), option.id) + + index = self.findData(routing) + + self.setCurrentIndex(index if index >= 0 else 0) + + self.setVisible(bool(options)) + self._applyInteractionEnabled(self.controller.interactionEnabled) + + @QtCore.Slot(bool) + def _applyInteractionEnabled(self, enabled: bool): + """Enable selection only when options and connection state allow it.""" + self.setEnabled(bool(enabled) and self.count() > 0) + + @QtCore.Slot(int) + def _handleCurrentIndexChanged(self, index: int): + """Forward user selection without retaining independent state.""" + routing = self.itemData(index) + + if isinstance(routing, str): + self.controller.selectRouting(routing) + + def showPopup(self): + """Refresh plugin capabilities immediately before showing options.""" + self.controller.refresh(force=True) + + super().showPopup() + + def retranslate(self): + """Refresh option text and routing accessibility metadata.""" + self._applyState(*self.controller.state()) + self.setAccessibleName(_('Routing')) + self.setToolTip(_('Routing')) diff --git a/Furious/Widget/ServerTableView.py b/Furious/Widget/ServerTableView.py index 7391a1e..1c6bfcb 100644 --- a/Furious/Widget/ServerTableView.py +++ b/Furious/Widget/ServerTableView.py @@ -1041,6 +1041,8 @@ class DeleteServersProgressDialog(AppQDialog): # Set invalid first AppSettings.set('ActivatedItemIndex', str(-1)) + self.table.activeServerChanged.emit() + if APP().isSystemTrayConnected(): if self.showTrayMessage: # Trigger disconnect @@ -1462,6 +1464,8 @@ class ServerTableView( ): """Represent user servers Qt table view.""" + activeServerChanged = QtCore.Signal() + RowHeight = 42 Headers = [ @@ -1953,6 +1957,7 @@ class ServerTableView( def activateItemByIndex(self, index, activate): """Activate item by index.""" oldIndex = Storage.UserActivatedItemIndex() + changed = activate and oldIndex != int(index) if activate: AppSettings.set('ActivatedItemIndex', str(index)) @@ -1960,6 +1965,9 @@ class ServerTableView( self.sourceModel.emitRowChanged(oldIndex) self.sourceModel.emitRowChanged(index) + if changed: + self.activeServerChanged.emit() + def flushItem(self, row: int, column: int, item: ServerProfile): """Refresh item.""" itemIndex = item.index @@ -2092,6 +2100,9 @@ class ServerTableView( self.sourceModel.emitRowChanged(row) + if row == Storage.UserActivatedItemIndex(): + self.activeServerChanged.emit() + def flushAll(self): # Refresh index """Refresh all.""" @@ -2251,6 +2262,8 @@ class ServerTableView( # Set invalid first AppSettings.set('ActivatedItemIndex', str(-1)) + self.activeServerChanged.emit() + if APP().isSystemTrayConnected(): if showTrayMessage: # Trigger disconnect @@ -2574,6 +2587,8 @@ class ServerTableView( AppSettings.set('ActivatedItemIndex', str(newActivatedIndex)) + self.activeServerChanged.emit() + self.sourceModel.refreshIndexes() self.proxyModel.invalidate() diff --git a/Furious/Widget/__init__.py b/Furious/Widget/__init__.py index 30bebd8..f222b12 100644 --- a/Furious/Widget/__init__.py +++ b/Furious/Widget/__init__.py @@ -22,6 +22,7 @@ from __future__ import annotations from .ConnectionProgressWidget import ConnectionProgressWidget from .MetricsGraph import MetricsGraphWidget from .NavigationView import NavigationView +from .RoutingSelector import RoutingSelector from .ServerTableView import ServerTableView from .SubscriptionTableView import SubscriptionTableView from .WaitingSpinner import WaitingSpinner @@ -30,6 +31,7 @@ __all__ = [ 'ConnectionProgressWidget', 'MetricsGraphWidget', 'NavigationView', + 'RoutingSelector', 'ServerTableView', 'SubscriptionTableView', 'WaitingSpinner', diff --git a/Furious/Window/HomePage.py b/Furious/Window/HomePage.py index 67ffc0a..aafbc43 100644 --- a/Furious/Window/HomePage.py +++ b/Furious/Window/HomePage.py @@ -40,6 +40,7 @@ from Furious.Actions.Import import ( ImportURIFromClipboardAction, ) from Furious.Widget.ConnectionButton import ConnectionButton +from Furious.Widget.RoutingSelector import RoutingSelector from Furious.Widget.ServerTableView import * from Furious.Window.NetworkTestDialog import * from Furious.Window.ProxyBypassDialog import * @@ -515,6 +516,8 @@ class HomePage(Mixins.QTranslatable, QMainWindow): self.activateSelectedServerForConnection, parent=self, ) + self.routingController = APP().routingController + self.routingSelector = RoutingSelector(self.routingController, parent=self) self.searchLineEdit = AppQLineEdit() self.searchLineEdit.setPlaceholderText( @@ -550,6 +553,7 @@ class HomePage(Mixins.QTranslatable, QMainWindow): self.actionLayout.setContentsMargins(0, 0, 0, 0) self.actionLayout.setSpacing(8) self.actionLayout.addWidget(self.connectButton) + self.actionLayout.addWidget(self.routingSelector) self.actionLayout.addWidget(self.serverButton) self.actionLayout.addStretch(1) self.actionLayout.addWidget(self.subscriptionFilterComboBox) @@ -577,6 +581,9 @@ class HomePage(Mixins.QTranslatable, QMainWindow): self.userServersQTableWidget.selectionModel().selectionChanged.connect( self.handleServerSelectionChanged ) + self.userServersQTableWidget.activeServerChanged.connect( + self.routingController.refresh + ) self.refreshSubscriptionFilter() self.handleServerSelectionChanged() @@ -607,6 +614,7 @@ class HomePage(Mixins.QTranslatable, QMainWindow): return False self.userServersQTableWidget.activateSelectedServer() + self.routingController.refresh() return Storage.UserActivatedItemIndex() == indexes[0]