Unify Home and tray connection controls

Signed-off-by: Loren Eteval <loren.eteval@proton.me>
This commit is contained in:
Loren Eteval
2026-08-12 12:24:03 +08:00
parent 53ddbb4190
commit 67a451bda0
8 changed files with 294 additions and 81 deletions
+97 -25
View File
@@ -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()
+2 -1
View File
@@ -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',
+7 -4
View File
@@ -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()
+1 -1
View File
@@ -54,7 +54,7 @@ class TrayIcon(
super().__init__(*args, **kwargs)
actions = [
ConnectAction(isTrayAction=True),
APP().connectionAction,
RoutingAction(isTrayAction=True),
ImportAction(isTrayAction=True),
AppQSeperator(),
+8
View File
@@ -2875,5 +2875,13 @@ TRANSLATION = {
"RU": "Неизвестная подписка",
"ZH": "未知订阅",
"isReviewed": "True"
},
"Disconnecting": {
"source": [
"Furious.Actions.Connection"
],
"RU": "Отключение",
"ZH": "正在断开连接",
"isReviewed": "True"
}
}
+2
View File
@@ -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;
+96
View File
@@ -0,0 +1,96 @@
# 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/>.
"""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()
+81 -50
View File
@@ -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."""