diff --git a/Furious/Actions/Connection.py b/Furious/Actions/Connection.py index 559c01f..be6fe91 100644 --- a/Furious/Actions/Connection.py +++ b/Furious/Actions/Connection.py @@ -36,17 +36,36 @@ from Furious.Widget.ConnectionProgressWidget import ConnectionProgressWidget from PySide6 import QtCore +from enum import Enum + import queue import logging import functools -__all__ = ['ConnectAction'] +__all__ = ['ConnectionState', 'ConnectAction'] logger = logging.getLogger(__name__) registerAppSettings('Connect', isBinary=True) +class ConnectionState(Enum): + """Describe the shared application connection lifecycle.""" + + Disconnected = 'Connect' + Connecting = 'Connecting' + Connected = 'Disconnect' + Disconnecting = 'Disconnecting' + + +_TRANSLATABLE_CONNECTION_STATES = ( + _('Connect'), + _('Connecting'), + _('Disconnect'), + _('Disconnecting'), +) + + def validateProxyServer(server) -> bool: """Validate proxy server.""" try: @@ -63,7 +82,9 @@ def validateProxyServer(server) -> bool: class ConnectAction(AppQAction): - """Handle the connect action.""" + """Own connection operations and expose their state to every UI surface.""" + + stateChanged = QtCore.Signal(object) def __init__(self, **kwargs): """Initialize the ConnectAction.""" @@ -77,23 +98,62 @@ class ConnectAction(AppQAction): self.actionQueue = queue.Queue() self.coreManager = ConnectionManager() self.progressBar = ConnectionProgressWidget() + self._state = ConnectionState.Disconnected + self._activeConfiguration = None self.actionTimer = QtCore.QTimer() self.actionTimer.timeout.connect(lambda: self.callActionFromQueue()) self.updatesManager = UpdateManager() + @property + def state(self) -> ConnectionState: + """Return the current connection lifecycle state.""" + return self._state + + @property + def activeConfiguration(self): + """Return the configuration owned by the current connection attempt.""" + return self._activeConfiguration + + def _applyStatePresentation(self): + """Apply the tray action presentation for the shared lifecycle state.""" + state = self.state + self.setText(_(state.value)) + self.setChecked( + state + in ( + ConnectionState.Connecting, + ConnectionState.Connected, + ) + ) + + if state in (ConnectionState.Connecting, ConnectionState.Connected): + self.setIcon(bootstrapIcon('lock-fill.svg')) + else: + self.setIcon(bootstrapIcon('unlock-fill.svg')) + + self.setDisabledAction( + state in (ConnectionState.Connecting, ConnectionState.Disconnecting) + ) + + def _setState(self, state: ConnectionState): + """Publish one atomic lifecycle transition and its action presentation.""" + changed = state is not self._state + self._state = state + self._applyStatePresentation() + + if changed: + self.stateChanged.emit(state) + def reset(self): """Restore the connect action to its initial state.""" self.hideProgressBar(True) - self.setText(_('Connect')) - self.setIcon(bootstrapIcon('unlock-fill.svg')) - self.setChecked(False) + self._activeConfiguration = None AppSettings.turnOFF('Connect') - # Accept new action - self.setDisabledAction(False) + self._setState(ConnectionState.Disconnected) def showProgressBar(self): """Show progress bar.""" @@ -119,7 +179,10 @@ class ConnectAction(AppQAction): """Set disabled action.""" self.setDisabled(value) - APP().systemTray.RoutingAction.setDisabled(value) + try: + APP().systemTray.RoutingAction.setDisabled(value) + except (AttributeError, RuntimeError): + pass try: APP().mainWindow.settingsPage.setConnectionControlsEnabled(not value) @@ -128,35 +191,38 @@ class ConnectAction(AppQAction): def isConnected(self) -> bool: """Return whether connected.""" - return self.textCompare('Disconnect') + return self.state is ConnectionState.Connected def isConnecting(self): """Return whether connecting.""" - return self.textCompare('Connecting') + return self.state is ConnectionState.Connecting + + def isDisconnecting(self): + """Return whether disconnecting.""" + return self.state is ConnectionState.Disconnecting def doConnecting(self): """Handle do connecting for the connect action.""" - self.setText(_('Connecting')) - self.setIcon(bootstrapIcon('lock-fill.svg')) - # Do not accept new action - self.setDisabledAction(True) + self._setState(ConnectionState.Connecting) self.showProgressBar() def doConnected(self): """Handle do connected for the connect action.""" self.hideProgressBar(True) - # Connected - self.setText(_('Disconnect')) AppSettings.turnON_('Connect') - Mixins.ConnectionAware.callConnectedCallback() + self._setState(ConnectionState.Connected) - # Accept new action - self.setDisabledAction(False) + Mixins.ConnectionAware.callConnectedCallback() def doDisconnect(self): """Handle do disconnect for the connect action.""" + if self.state is ConnectionState.Disconnected: + return + + self._setState(ConnectionState.Disconnecting) + SystemProxy.off() self.actionTimer.stop() @@ -188,7 +254,7 @@ class ConnectAction(AppQAction): def doConnect(self): # Connect action """Return the do connect value used by the connect action.""" - assert self.textCompare('Connect') + assert self.state is ConnectionState.Disconnected if not Storage.UserServers(): AppSettings.turnOFF('Connect') @@ -261,6 +327,7 @@ class ConnectAction(AppQAction): return + self._activeConfiguration = config self.doConnecting() logManager = AppLogManager() @@ -424,10 +491,15 @@ class ConnectAction(AppQAction): def triggeredCallback(self, checked): """Handle activation of the action.""" - if checked: + if self.state is ConnectionState.Disconnected: self.doConnect() - else: - # Disconnect action - assert self.textCompare('Disconnect') - + elif self.state is ConnectionState.Connected: self.doDisconnectWithTrayMessage(_('Disconnected')) + else: + # A disabled transition action should not normally be triggered. + # Restore its checked presentation if code triggered it directly. + self._applyStatePresentation() + + def retranslate(self): + """Refresh the state-derived action text.""" + self._applyStatePresentation() diff --git a/Furious/Actions/__init__.py b/Furious/Actions/__init__.py index 7c0cf2b..7cd1bc9 100644 --- a/Furious/Actions/__init__.py +++ b/Furious/Actions/__init__.py @@ -19,7 +19,7 @@ from __future__ import annotations -from .Connection import ConnectAction +from .Connection import ConnectionState, ConnectAction from .Exit import ExitAction from .Home import ShowHomePageAction from .Import import ( @@ -34,6 +34,7 @@ from .Routing import RoutingAction __all__ = [ 'ConnectAction', + 'ConnectionState', 'ExitAction', 'ImportAction', 'ImportFromFileAction', diff --git a/Furious/Application/DesktopApplication.py b/Furious/Application/DesktopApplication.py index 2a6704a..ef6b72e 100644 --- a/Furious/Application/DesktopApplication.py +++ b/Furious/Application/DesktopApplication.py @@ -30,6 +30,7 @@ from Furious.Qt.TextEditorTheme import configureEditorLogMetadata from Furious.Qt import gettext as _ from Furious.Repository import * from Furious.Service import ApplicationLogHandler, LogManager +from Furious.Actions.Connection import ConnectAction from Furious.Application.TrayIcon import * from Furious.Window.LogPage import * from Furious.Window.MainWindow import * @@ -179,6 +180,7 @@ class DesktopApplication(ApplicationRunner, SingletonApplication): self.mainWindow = None self.systemTray = None + self.connectionAction = None # Unified logging service and presentation self.logManager = None @@ -295,10 +297,7 @@ class DesktopApplication(ApplicationRunner, SingletonApplication): def isSystemTrayConnected(self): """Return whether system tray connected.""" - if isinstance(self.systemTray, TrayIcon): - return self.systemTray.ConnectAction.isConnected() - else: - return False + return self.connectionAction is not None and self.connectionAction.isConnected() def isDarkMode(self): """Return whether dark mode.""" @@ -545,6 +544,10 @@ 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. + self.connectionAction = ConnectAction(isTrayAction=True) + self.mainWindow = MainWindow() self.systemTray = TrayIcon() diff --git a/Furious/Application/TrayIcon.py b/Furious/Application/TrayIcon.py index 9dc8235..b3d3fac 100644 --- a/Furious/Application/TrayIcon.py +++ b/Furious/Application/TrayIcon.py @@ -54,7 +54,7 @@ class TrayIcon( super().__init__(*args, **kwargs) actions = [ - ConnectAction(isTrayAction=True), + APP().connectionAction, RoutingAction(isTrayAction=True), ImportAction(isTrayAction=True), AppQSeperator(), diff --git a/Furious/Externals/GenTranslation.py b/Furious/Externals/GenTranslation.py index da3b9ed..c965c74 100644 --- a/Furious/Externals/GenTranslation.py +++ b/Furious/Externals/GenTranslation.py @@ -2875,5 +2875,13 @@ TRANSLATION = { "RU": "Неизвестная подписка", "ZH": "未知订阅", "isReviewed": "True" + }, + "Disconnecting": { + "source": [ + "Furious.Actions.Connection" + ], + "RU": "Отключение", + "ZH": "正在断开连接", + "isReviewed": "True" } } diff --git a/Furious/Qt/AppStyleSheet.py b/Furious/Qt/AppStyleSheet.py index 1fdb0b6..6d8f44c 100644 --- a/Furious/Qt/AppStyleSheet.py +++ b/Furious/Qt/AppStyleSheet.py @@ -327,6 +327,7 @@ class AppStyleSheet: QWidget#SettingsPageContent, QScrollArea#SubscriptionScrollArea, QWidget#SubscriptionPageContent, + QWidget#HomePageContent, QWidget#LogPageContent {{ border: none; background-color: {palette['window']}; @@ -335,6 +336,7 @@ class AppStyleSheet: QLabel#MetricsPageTitle, QLabel#LogPageTitle, QLabel#SubscriptionPageTitle, + QLabel#HomePageTitle, QLabel#SettingsPageTitle {{ color: {palette['text_strong']}; font-size: 16pt; diff --git a/Furious/Widget/ConnectionButton.py b/Furious/Widget/ConnectionButton.py new file mode 100644 index 0000000..0e8a6ce --- /dev/null +++ b/Furious/Widget/ConnectionButton.py @@ -0,0 +1,96 @@ +# 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 . + +"""Provide connection controls backed by the shared connection action.""" + +from __future__ import annotations + +from Furious.Actions.Connection import ConnectionState + +from PySide6 import QtCore +from PySide6.QtWidgets import QPushButton, QSizePolicy + +from typing import Callable + +__all__ = ['ConnectionButton'] + + +class ConnectionButton(QPushButton): + """Present a shared connection action with a local selection policy.""" + + def __init__( + self, + connectionAction, + activateSelected: Callable[[], bool], + parent=None, + ): + """Bind to one connection action without duplicating its state machine.""" + super().__init__(parent) + + self.connectionAction = connectionAction + self.activateSelected = activateSelected + self._selectionCount = 0 + + self.setObjectName('ConnectionButton') + self.setMinimumWidth(124) + self.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) + self.setIconSize(QtCore.QSize(18, 18)) + + self.clicked.connect(self._handleClicked) + self.connectionAction.changed.connect(self.syncPresentation) + self.connectionAction.stateChanged.connect(self.syncPresentation) + + self.syncPresentation() + + def setSelectionCount(self, count: int): + """Update the disconnected-state selection policy.""" + self._selectionCount = max(0, count) + self.syncPresentation() + + @QtCore.Slot() + def syncPresentation(self, *_args): + """Mirror text/icon/state while applying only Home's selection rule.""" + action = self.connectionAction + state = action.state + + self.setText(action.text()) + self.setIcon(action.icon()) + + if state is ConnectionState.Disconnected: + # Zero-selection remains intentionally clickable but is a no-op. + enabled = self._selectionCount <= 1 and action.isEnabled() + elif state is ConnectionState.Connected: + # Disconnect always targets the active connection, not selection. + enabled = action.isEnabled() + else: + enabled = False + + self.setEnabled(enabled) + + @QtCore.Slot() + def _handleClicked(self): + """Delegate connect/disconnect to the shared action.""" + state = self.connectionAction.state + + if state is ConnectionState.Disconnected: + if self._selectionCount != 1 or not self.activateSelected(): + return + + if self.connectionAction.state is ConnectionState.Disconnected: + self.connectionAction.trigger() + elif state is ConnectionState.Connected: + self.connectionAction.trigger() diff --git a/Furious/Window/HomePage.py b/Furious/Window/HomePage.py index 82e301c..67ffc0a 100644 --- a/Furious/Window/HomePage.py +++ b/Furious/Window/HomePage.py @@ -39,6 +39,7 @@ from Furious.Actions.Import import ( ImportQRCodeOnTheScreenAction, ImportURIFromClipboardAction, ) +from Furious.Widget.ConnectionButton import ConnectionButton from Furious.Widget.ServerTableView import * from Furious.Window.NetworkTestDialog import * from Furious.Window.ProxyBypassDialog import * @@ -474,49 +475,14 @@ class HomePage(Mixins.QTranslatable, QMainWindow): ) ) - if hasattr(AppQAction, 'setMenu'): - self.toolbar = AppQToolBar( - AppQAction( - _('Server'), - icon=bootstrapIcon('server.svg'), - menu=AppQMenu(*serverActions), - useSetMenu=False, - useActionGroup=False, - checkable=False, - ), - ) - self.toolbar.setObjectName('HomePageToolBar') - self.toolbar.setMovable(False) - self.toolbar.setFloatable(False) - self.toolbar.setIconSize(QtCore.QSize(64, 32)) - self.toolbar.setToolButtonStyle( - QtCore.Qt.ToolButtonStyle.ToolButtonTextUnderIcon - ) - self.addToolBar(self.toolbar) - else: - # Menu actions - serverMenu = { - 'name': 'Server', - 'actions': [*serverActions], - } - - # Corresponds to menus defined above - _TRANSLATABLE_MENU_NAME = [ - _('Server'), - ] - - # Menus - for menuDict in (serverMenu,): - menuName = menuDict['name'] - menuObjName = f'_{menuName}Menu' - menu = AppQMenu( - *menuDict['actions'], title=_(menuName), parent=self.menuBar() - ) - - # Set reference - setattr(self, menuObjName, menu) - - self.menuBar().addMenu(menu) + self.serverMenu = AppQMenu(*serverActions, parent=self) + self.serverButton = AppQPushButton( + _('Server'), + icon=bootstrapIcon('server.svg'), + ) + # Keep the protocol/profile creation menu without presenting this as a + # split or drop-down button. The regular button opens the menu itself. + self.serverButton.clicked.connect(self.showServerMenu) self.setContextMenuPolicy(QtCore.Qt.ContextMenuPolicy.NoContextMenu) @@ -536,9 +502,19 @@ class HomePage(Mixins.QTranslatable, QMainWindow): self.statusBar().addPermanentWidget(self.connectionStatus) self._widget = QWidget() + self._widget.setObjectName('HomePageContent') self._layout = QVBoxLayout(self._widget) + self._layout.setContentsMargins(20, 18, 20, 20) + self._layout.setSpacing(12) - self.searchLayout = QHBoxLayout() + self.pageTitleLabel = AppQLabel(_('Server')) + self.pageTitleLabel.setObjectName('HomePageTitle') + + self.connectButton = ConnectionButton( + APP().connectionAction, + self.activateSelectedServerForConnection, + parent=self, + ) self.searchLineEdit = AppQLineEdit() self.searchLineEdit.setPlaceholderText( @@ -546,17 +522,41 @@ class HomePage(Mixins.QTranslatable, QMainWindow): 'Search servers with text or regex, e.g. trojan, hk|jp, ^vmess, (us|sg).*tls' ) ) + self.searchLineEdit.setMinimumWidth(280) + self.searchLineEdit.setMaximumWidth(700) + self.searchLineEdit.setSizePolicy( + QSizePolicy.Policy.Expanding, + QSizePolicy.Policy.Fixed, + ) self.searchButton = SearchButton() self.subscriptionFilterComboBox = AppQComboBox() - self.subscriptionFilterComboBox.setMinimumWidth(180) + self.subscriptionFilterComboBox.setMinimumWidth(190) + self.subscriptionFilterComboBox.setMaximumWidth(300) + self.subscriptionFilterComboBox.setSizePolicy( + QSizePolicy.Policy.Preferred, + QSizePolicy.Policy.Fixed, + ) - self.searchLayout.addWidget(self.searchLineEdit) - self.searchLayout.addWidget(self.subscriptionFilterComboBox) - self.searchLayout.addWidget(self.searchButton) + self.headerLayout = QHBoxLayout() + self.headerLayout.setContentsMargins(0, 0, 0, 0) + self.headerLayout.setSpacing(8) + self.headerLayout.addWidget(self.pageTitleLabel) + self.headerLayout.addStretch(1) + self.headerLayout.addWidget(self.searchLineEdit, 4) + self.headerLayout.addWidget(self.searchButton) - self._layout.addLayout(self.searchLayout) - self._layout.addWidget(self.userServersQTableWidget) + self.actionLayout = QHBoxLayout() + self.actionLayout.setContentsMargins(0, 0, 0, 0) + self.actionLayout.setSpacing(8) + self.actionLayout.addWidget(self.connectButton) + self.actionLayout.addWidget(self.serverButton) + self.actionLayout.addStretch(1) + self.actionLayout.addWidget(self.subscriptionFilterComboBox) + + self._layout.addLayout(self.headerLayout) + self._layout.addLayout(self.actionLayout) + self._layout.addWidget(self.userServersQTableWidget, 1) self.searchButton.clicked.connect( lambda: self.userServersQTableWidget.search(self.searchLineEdit.text()) @@ -574,11 +574,42 @@ class HomePage(Mixins.QTranslatable, QMainWindow): self.userServersQTableWidget.subsManager.subscriptionsChanged.connect( self.refreshSubscriptionFilter ) + self.userServersQTableWidget.selectionModel().selectionChanged.connect( + self.handleServerSelectionChanged + ) self.refreshSubscriptionFilter() + self.handleServerSelectionChanged() self.setCentralWidget(self._widget) + @QtCore.Slot() + def showServerMenu(self): + """Open server creation actions below the regular Server button.""" + position = self.serverButton.mapToGlobal( + QtCore.QPoint(0, self.serverButton.height() + 2) + ) + + self.serverMenu.popup(position) + + @QtCore.Slot() + def handleServerSelectionChanged(self, *_args): + """Apply Home's selection policy to the shared connection control.""" + self.connectButton.setSelectionCount( + len(self.userServersQTableWidget.selectedIndex) + ) + + def activateSelectedServerForConnection(self) -> bool: + """Activate exactly one selected server before requesting connection.""" + indexes = self.userServersQTableWidget.selectedIndex + + if len(indexes) != 1: + return False + + self.userServersQTableWidget.activateSelectedServer() + + return Storage.UserActivatedItemIndex() == indexes[0] + @QtCore.Slot(str) def handleUserServersSearchTextChanged(self, text: str): """Handle user servers search text changed."""