Files
LorenEteval_Furious/Furious/Widget/ServerTableView.py
T
2026-09-09 01:00:57 +08:00

2118 lines
67 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) 2024present 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 widgets for user servers Qt table view."""
from __future__ import annotations
from Furious.Frozenlib import *
from Furious.Interface import *
from Furious.Models import *
from Furious.Repository import *
from Furious.Plugins import (
blankProfile,
exportConfiguration,
getPluginRegistry,
profileFromAny,
)
from Furious.Qt import *
from Furious.Qt.Signals import connectWeakly, singleShotWeakly
from Furious.Qt import gettext as _
from Furious.Service import (
ProfileTestField,
ProfileTestManager,
ProfileTestResult,
SubscriptionManager,
SubscriptionUpdateBatch,
)
from PySide6 import QtCore
from PySide6.QtGui import *
from PySide6.QtWidgets import *
from typing import Callable, Union
import re
import time
import logging
import functools
__all__ = ['ServerTableView']
logger = logging.getLogger(__name__)
registerAppSettings('ActivatedItemIndex')
# Migrate legacy settings
registerAppSettings('ServerWidgetSectionSizeTable')
registerAppSettings('UserServersHeaderViewState')
class MBoxUpdateSubsInfo(AppQMessageBox):
"""Represent m box update subs info."""
def __init__(self, *args, **kwargs):
"""Initialize the MBoxUpdateSubsInfo."""
self.successArgs = kwargs.pop('successArgs', list())
self.failureArgs = kwargs.pop('failureArgs', list())
super().__init__(*args, **kwargs)
self.setWindowTitle(_(APPLICATION_NAME))
def customText(self):
"""Return the user-facing message text for the m box update subs info."""
if self.successArgs:
text = _('Update subscription completed') + '\n\n'
else:
text = _('Update subscription failed')
for param in self.successArgs:
remark, webURL = param['remark'], param['webURL']
text += (
f'\U00002705 {remark} - {webURL} '
+ _('Configuration has been updated')
+ '\n'
)
if self.successArgs and self.failureArgs:
text += '\n'
elif self.failureArgs:
text += '\n\n'
for param in self.failureArgs:
error, remark, webURL = (
param['error'],
param['remark'],
param['webURL'],
)
# error is the specific failure reason. Not used
# for mbox elegant appearance
text += (
f'\U0000274c {remark} - {webURL} '
+ _('Configuration update failed')
+ '\n'
)
return text
def setColumnMinWidth(self):
"""Keep long subscription summaries readable in the Fluent dialog."""
if PLATFORM == 'Windows':
self.setContentMinimumWidth(
max((len(row) + 10) for row in self.text().split('\n'))
* self.fontMetrics().averageCharWidth(),
)
def retranslate(self):
"""Refresh translated text for the m box update subs info."""
self.setWindowTitle(_(self.windowTitle()))
self.setText(self.customText())
self.setColumnMinWidth()
# Ignore informative text, buttons
self.moveToCenter()
class DeleteServersProgressDialog(AppQTransientDialog):
"""Present progress and cancellation controls for delete servers."""
DEFAULT_DIALOG_SIZE = QtCore.QSize(420, 150)
SmallOperationLimit = 64
BatchSize = 128
ProgressUpdateInterval = 0.1
def __init__(self, table, indexes, showTrayMessage=True, parent=None):
"""Initialize the DeleteServersProgressDialog."""
super().__init__(parent)
self.table = table
profiles = Storage.UserServers()
self.profileIds = [
profiles[index].metadata.profileId
for index in sorted(set(indexes))
if 0 <= index < len(profiles)
]
self.showTrayMessage = showTrayMessage
self.total = len(self.profileIds)
self.nextIndex = 0
self.deletedCount = 0
self.canceled = False
self.finishedDeletion = False
self.lastStatusUpdate = 0.0
self.currentRemark = ''
self.setWindowTitle(_('Delete'))
self.setWindowModality(QtCore.Qt.WindowModality.ApplicationModal)
self.statusLabel = AppQLabel()
self.detailLabel = AppQLabel()
self.detailLabel.setWordWrap(True)
self.cancelButton = AppQPushButton(_('Cancel'))
connectWeakly(
self.cancelButton.clicked,
self,
'cancel',
sender=self.cancelButton,
)
layout = QVBoxLayout()
layout.addWidget(self.statusLabel)
layout.addWidget(self.detailLabel)
layout.addWidget(self.cancelButton)
self.setLayout(layout)
self.updateStatus()
def open(self):
"""Open the delete servers progress dialog asynchronously."""
result = super().open()
singleShotWeakly(0, self, 'deleteNext')
return result
def reject(self):
"""Reject the current delete servers progress dialog values."""
self.cancel()
def cancel(self, *_args):
"""Cancel the delete servers progress dialog operation."""
self.canceled = True
self.cancelButton.setEnabled(False)
self.updateStatus()
def updateStatus(self):
"""Update status."""
self.lastStatusUpdate = time.monotonic()
if self.canceled:
self.statusLabel.setText(
_('Canceling delete') + f'... {self.deletedCount}/{self.total}'
)
else:
self.statusLabel.setText(
_('Deleting') + f'... {self.deletedCount}/{self.total}'
)
if self.currentRemark:
self.detailLabel.setText(_('Current') + f': {self.currentRemark}')
else:
self.detailLabel.setText('')
@staticmethod
def limitedRemark(remark: str) -> str:
"""Return the limited remark value used by the delete servers progress dialog."""
remark = str(remark).strip()
if len(remark) <= 120:
return remark
return remark[:117] + '...'
def deleteNext(self):
"""Resolve captured identities and remove one bounded batch."""
if self.finishedDeletion:
return
if self.canceled or self.nextIndex >= self.total:
self.finishDeletion()
return
stop = min(self.nextIndex + self.BatchSize, self.total)
profileIds = set(self.profileIds[self.nextIndex : stop])
profiles = Storage.UserServers()
indexes = [
index
for index, profile in enumerate(profiles)
if profile.metadata.profileId in profileIds
]
self.nextIndex = stop
if indexes:
self.currentRemark = self.limitedRemark(profiles[indexes[-1]].itemRemark)
self.deletedCount += self.table.deleteItemByIndex(
indexes, showTrayMessage=self.showTrayMessage, showProgress=False
)
if self.canceled or self.nextIndex >= self.total:
self.updateStatus()
self.finishDeletion()
else:
if time.monotonic() - self.lastStatusUpdate >= self.ProgressUpdateInterval:
self.updateStatus()
singleShotWeakly(0, self, 'deleteNext')
def finishDeletion(self):
"""Stop the completed or cancelled operation exactly once."""
if self.finishedDeletion:
return
self.finishedDeletion = True
self.accept()
def retranslate(self):
"""Refresh translated text for the delete servers progress dialog."""
self.setWindowTitle(_(self.windowTitle()))
self.cancelButton.setText(_(self.cancelButton.text()))
self.updateStatus()
class ServerTableHorizontalHeader(AppQHeaderView):
"""Provide the user servers Qt table view horizontal table header."""
def __init__(self, *args, **kwargs):
"""Initialize the ServerTableHorizontalHeader."""
super().__init__(QtCore.Qt.Orientation.Horizontal, *args, **kwargs)
class ServerTableVerticalHeader(AppQHeaderView):
"""Provide the user servers Qt table view vertical table header."""
def __init__(self, *args, **kwargs):
"""Initialize the ServerTableVerticalHeader."""
super().__init__(QtCore.Qt.Orientation.Vertical, *args, **kwargs)
class ServerTableColumn:
"""Describe and render user servers Qt table view table columns."""
def __init__(self, name: str, func: Callable[[CoreConfiguration], str] = None):
"""Initialize the ServerTableColumn."""
self.name = name
self.func = func
def __call__(self, item: ServerProfile) -> str:
"""Invoke the user servers Qt table view headers as a callable."""
if callable(self.func):
return self.func(item)
else:
return getattr(item, f'item{self}')
def __eq__(self, other):
"""Compare the user servers Qt table view headers with another value."""
return str(self) == str(other)
def __str__(self):
"""Return the display text for the user servers Qt table view headers."""
return self.name
def _subscriptionRemark(item: ServerProfile) -> str:
"""Resolve a persisted subscription ID to its user-visible remark."""
if not item.itemSubscription:
return ''
subscription = Storage.UserSubs().get(item.itemSubscription, {})
if not subscription:
return _('Unknown Subscription')
remark = subscription.get('remark', '') or item.itemSubscription
return (
remark if subscription.get('enabled', True) else f'{remark} ({_("Disabled")})'
)
class UserServersTableModel(QtCore.QAbstractTableModel):
"""Expose user servers table data through a Qt item model."""
SortRole = QtCore.Qt.ItemDataRole.UserRole + 1
def __init__(self, headers: list[ServerTableColumn], parent=None):
"""Initialize the UserServersTableModel."""
super().__init__(parent)
self.headers = headers
def rowCount(self, parent=QtCore.QModelIndex()) -> int:
"""Return the number of rows exposed by the model."""
if parent.isValid():
return 0
return len(Storage.UserServers())
def columnCount(self, parent=QtCore.QModelIndex()) -> int:
"""Return the number of columns exposed by the model."""
if parent.isValid():
return 0
return len(self.headers)
def flags(self, index):
"""Return the Qt item flags for a model index."""
if not index.isValid():
return QtCore.Qt.ItemFlag.NoItemFlags
return QtCore.Qt.ItemFlag.ItemIsEnabled | QtCore.Qt.ItemFlag.ItemIsSelectable
def data(self, index, role=QtCore.Qt.ItemDataRole.DisplayRole):
"""Return the data managed by the user servers table model."""
if not index.isValid():
return None
row = index.row()
column = index.column()
if row < 0 or row >= len(Storage.UserServers()):
return None
if column < 0 or column >= len(self.headers):
return None
server = Storage.UserServers()[row]
header = self.headers[column]
text = header(server)
if (
role == QtCore.Qt.ItemDataRole.DisplayRole
or role == QtCore.Qt.ItemDataRole.ToolTipRole
):
return text
if role == QtCore.Qt.ItemDataRole.FontRole:
font = QFont(AppFontName())
if row == Storage.UserActivatedItemIndex():
font.setBold(True)
return font
if role == QtCore.Qt.ItemDataRole.ForegroundRole:
if row == Storage.UserActivatedItemIndex():
return QColor(AppHue.currentColor())
return None
if role == QtCore.Qt.ItemDataRole.TextAlignmentRole:
if str(header) == 'Latency' or str(header) == 'Speed':
return (
QtCore.Qt.AlignmentFlag.AlignRight
| QtCore.Qt.AlignmentFlag.AlignVCenter
)
return None
if role == self.SortRole:
if str(header) == 'Latency':
return self.testResultSortValue(text, 'ms')
if str(header) == 'Speed':
return self.testResultSortValue(text, ' MiB/s')
return text
return None
def headerData(
self,
section: int,
orientation: QtCore.Qt.Orientation,
role=QtCore.Qt.ItemDataRole.DisplayRole,
):
"""Return display data for a table header section."""
if role != QtCore.Qt.ItemDataRole.DisplayRole:
return None
if orientation == QtCore.Qt.Orientation.Horizontal:
if 0 <= section < len(self.headers):
return _(str(self.headers[section]))
return None
return section + 1
@staticmethod
def testResultSortValue(text: str, suffix: str):
"""Return the test result sort value value used by the user servers table model."""
if text.endswith(suffix):
text = text[: -len(suffix)]
try:
return float(text)
except Exception:
# Any non-exit exceptions
return abs(hash(text)) + 2**20
def emitRowChanged(
self,
row: int,
column: Union[int, None] = None,
roles: list[QtCore.Qt.ItemDataRole] | None = None,
):
"""Handle emit row changed for the user servers table model."""
if row < 0 or row >= self.rowCount():
return
if column is None:
left = self.index(row, 0)
right = self.index(row, self.columnCount() - 1)
else:
left = self.index(row, column)
right = left
self.dataChanged.emit(left, right, roles or [])
def emitAllChanged(self):
"""Handle emit all changed for the user servers table model."""
if self.rowCount() == 0 or self.columnCount() == 0:
return
self.dataChanged.emit(
self.index(0, 0),
self.index(self.rowCount() - 1, self.columnCount() - 1),
[],
)
@staticmethod
def refreshIndexes():
"""Refresh indexes."""
for index, item in enumerate(Storage.UserServers()):
item.index = index
def sort(
self,
column: int,
order: QtCore.Qt.SortOrder = QtCore.Qt.SortOrder.AscendingOrder,
):
"""Sort the user servers table model."""
if column < 0 or column >= self.columnCount():
return
activatedIndex = Storage.UserActivatedItemIndex()
if 0 <= activatedIndex < len(Storage.UserServers()):
activatedServerId = id(Storage.UserServers()[activatedIndex])
else:
activatedServerId = None
header = self.headers[column]
persistentIndexes = tuple(
(
QtCore.QModelIndex(index),
Storage.UserServers()[index.row()],
index.column(),
)
for index in self.persistentIndexList()
if index.isValid() and 0 <= index.row() < len(Storage.UserServers())
)
def keyFn(factory: ServerProfile):
"""Return the key fn value used by the user servers table model."""
data = header(factory)
if str(header) == 'Latency':
return self.testResultSortValue(data, 'ms')
if str(header) == 'Speed':
return self.testResultSortValue(data, ' MiB/s')
return data
self.layoutAboutToBeChanged.emit()
Storage.UserServers().sort(
key=keyFn,
reverse=order == QtCore.Qt.SortOrder.DescendingOrder,
)
self.refreshIndexes()
rowsByIdentity = {
id(profile): row for row, profile in enumerate(Storage.UserServers())
}
self.changePersistentIndexList(
[index for index, _profile, _column in persistentIndexes],
[
self.index(rowsByIdentity[id(profile)], persistentColumn)
for _index, profile, persistentColumn in persistentIndexes
],
)
if activatedServerId is not None:
for index, server in enumerate(Storage.UserServers()):
if id(server) == activatedServerId:
AppSettings.set('ActivatedItemIndex', str(index))
break
self.layoutChanged.emit()
class UserServersSortFilterProxyModel(QtCore.QSortFilterProxyModel):
"""Filter and sort user servers sort filter data."""
sortAboutToStart = QtCore.Signal()
sortCompleted = QtCore.Signal()
def __init__(self, parent=None):
"""Initialize the UserServersSortFilterProxyModel."""
super().__init__(parent)
self.searchPattern = ''
self.searchCaseSensitive = False
self.searchUseRegex = True
self.searchRegex = None
self.subscriptionFilter = None
self.sortSuspended = False
self.setSortRole(UserServersTableModel.SortRole)
self.setDynamicSortFilter(True)
def sort(
self,
column: int,
order: QtCore.Qt.SortOrder = QtCore.Qt.SortOrder.AscendingOrder,
):
"""Sort the user servers sort filter proxy model."""
if self.sortSuspended:
super().sort(-1, order)
return
if column < 0:
super().sort(column, order)
return
model = self.sourceModel()
if model is not None:
self.sortAboutToStart.emit()
try:
model.sort(column, order)
self.invalidate()
finally:
self.sortCompleted.emit()
def setSearchPattern(
self,
pattern: str,
*,
caseSensitive: bool = False,
regex: bool = True,
):
"""Set search pattern."""
self.searchPattern = str(pattern or '')
self.searchCaseSensitive = caseSensitive
self.searchUseRegex = regex
self.searchRegex = None
if self.searchPattern:
flags = 0 if caseSensitive else re.IGNORECASE
regexPattern = (
self.searchPattern if regex else re.escape(self.searchPattern)
)
try:
self.searchRegex = re.compile(regexPattern, flags)
except re.error as ex:
logger.error(
f'invalid user servers search regex: {ex}. '
f'Fall back to literal matching'
)
self.searchRegex = re.compile(re.escape(self.searchPattern), flags)
self.invalidateFilter()
def setSubscriptionFilter(self, unique: str | None):
"""Limit rows to manual profiles or one subscription group."""
self.subscriptionFilter = unique
self.invalidateFilter()
def filterAcceptsRow(self, sourceRow: int, sourceParent) -> bool:
"""Filter accepts row."""
model = self.sourceModel()
if model is None:
return True
if 0 <= sourceRow < len(Storage.UserServers()):
profile = Storage.UserServers()[sourceRow]
if self.subscriptionFilter == '':
if profile.itemSubscriptionManaged:
return False
elif (
self.subscriptionFilter is not None
and profile.itemSubscription != self.subscriptionFilter
):
return False
if not self.searchPattern or self.searchRegex is None:
return True
searchableText = '\n'.join(
str(
model.data(
model.index(sourceRow, column, sourceParent),
QtCore.Qt.ItemDataRole.DisplayRole,
)
or ''
)
for column in range(model.columnCount(sourceParent))
)
return self.searchRegex.search(searchableText) is not None
def headerData(
self,
section: int,
orientation: QtCore.Qt.Orientation,
role=QtCore.Qt.ItemDataRole.DisplayRole,
):
"""Return display data for a table header section."""
if (
orientation == QtCore.Qt.Orientation.Vertical
and role == QtCore.Qt.ItemDataRole.DisplayRole
):
return section + 1
return super().headerData(section, orientation, role)
# ALL Headers VALUE
_TRANSLATABLE_HEADERS = [
_('Remark'),
_('Protocol'),
_('Address'),
_('Port'),
_('Transport'),
_('TLS'),
_('Subscription'),
_('Latency'),
_('Speed'),
]
class ServerTableView(
Mixins.QTranslatable,
Mixins.CleanupOnExit,
Mixins.ConnectionAware,
AppQTableView,
):
"""Represent user servers Qt table view."""
activeServerChanged = QtCore.Signal()
RowHeight = 42
Headers = [
ServerTableColumn('Remark'),
ServerTableColumn('Protocol'),
ServerTableColumn('Address'),
ServerTableColumn('Port'),
ServerTableColumn('Transport'),
ServerTableColumn('TLS'),
ServerTableColumn('Subscription', _subscriptionRemark),
ServerTableColumn('Latency'),
ServerTableColumn('Speed'),
]
def __init__(self, *args, **kwargs):
"""Initialize the server table view."""
configurationEditorFactory, self.qrCodeWindowFactory, importActionsFactory = (
kwargs.pop('configurationEditorFactory'),
kwargs.pop('qrCodeWindowFactory'),
kwargs.pop('importActionsFactory'),
)
super().__init__(*args, **kwargs)
self.sourceModel = UserServersTableModel(self.Headers, parent=self)
self.proxyModel = UserServersSortFilterProxyModel(parent=self)
self.proxyModel.setSourceModel(self.sourceModel)
self.setModel(self.proxyModel)
self._sortSelectionSnapshot = None
self.proxyModel.sortAboutToStart.connect(self._captureSortSelection)
self.proxyModel.sortCompleted.connect(self._restoreSortSelection)
self.subsManager = SubscriptionManager(parent=self)
self.subsManager.subscriptionsChanged.connect(self._handleSubscriptionsChanged)
self.subsManager.subscriptionCommitted.connect(
self._handleSubscriptionCommitted
)
self.subsManager.updateCompleted.connect(
self._handleSubscriptionUpdateCompleted
)
self.profileTestManager = ProfileTestManager(parent=self)
connectWeakly(
self.profileTestManager.resultApplied,
self,
'_handleProfileTestResultApplied',
sender=self.profileTestManager,
)
self.configurationEditor = configurationEditorFactory()
# Install custom header
self.setHorizontalHeader(
ServerTableHorizontalHeader(
parent=self,
legacySectionSizeSettingsName='ServerWidgetSectionSizeTable',
sectionSizeSettingsName='UserServersHeaderViewState',
)
)
self.setVerticalHeader(ServerTableVerticalHeader(self))
self.setDefaultRowHeight(self.RowHeight)
self.horizontalHeader().setCustomSectionResizeMode()
self.horizontalHeader().restoreSectionSize()
self.proxyModel.sortSuspended = True
self.setSortingEnabled(True)
self.proxyModel.sortSuspended = False
self.proxyModel.sort(-1)
# Selection
self.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
# No drag and drop
self.setDragEnabled(False)
self.setDragDropMode(QAbstractItemView.DragDropMode.NoDragDrop)
self.setDropIndicatorShown(False)
self.setDefaultDropAction(QtCore.Qt.DropAction.IgnoreAction)
self.customizeJSONConfigActionRef = AppQAction(
_('Customize JSON Configuration...'),
icon=bootstrapIcon('braces-asterisk.svg'),
callback=lambda: self.editSelectedItemConfiguration(),
shortcut=QtCore.QKeyCombination(
QtCore.Qt.KeyboardModifier.ControlModifier,
QtCore.Qt.Key.Key_E,
),
)
self.advancedActionRef = AppQAction(
_('Advanced...'),
menu=AppQMenu(
self.customizeJSONConfigActionRef,
),
useActionGroup=False,
checkable=False,
)
self.activateSelectedServerActionRef = AppQAction(
_('Activate Selected Server'),
callback=lambda: self.activateSelectedServer(),
shortcut=QtCore.QKeyCombination(
QtCore.Qt.Key.Key_Enter,
),
)
self.moveMenu = AppQMenu(
AppQAction(
_('Move to Top'),
callback=lambda: self.moveSelectedItems('top'),
),
AppQAction(
_('Move Up'),
callback=lambda: self.moveSelectedItems('up'),
shortcut=QtCore.QKeyCombination(
QtCore.Qt.KeyboardModifier.ControlModifier,
QtCore.Qt.Key.Key_Up,
),
),
AppQAction(
_('Move Down'),
callback=lambda: self.moveSelectedItems('down'),
shortcut=QtCore.QKeyCombination(
QtCore.Qt.KeyboardModifier.ControlModifier,
QtCore.Qt.Key.Key_Down,
),
),
AppQAction(
_('Move to Bottom'),
callback=lambda: self.moveSelectedItems('bottom'),
),
parent=self,
)
self.moveActionRef = AppQAction(
_('Move...'),
menu=self.moveMenu,
useActionGroup=False,
checkable=False,
)
self.moveToSubscriptionMenu = AppQMenu(parent=self)
self.moveToSubscriptionActionRef = AppQAction(
_('Move To Subscription...'),
menu=self.moveToSubscriptionMenu,
useActionGroup=False,
checkable=False,
)
self._subscriptionActions = []
self.importActions = tuple(importActionsFactory())
self.testActions = (
AppQAction(
_('Test Ping Latency'),
callback=lambda: self.testSelectedItemPingLatency(),
shortcut=QtCore.QKeyCombination(
QtCore.Qt.KeyboardModifier.ControlModifier,
QtCore.Qt.Key.Key_P,
),
),
AppQAction(
_('Test Tcping Latency'),
callback=lambda: self.testSelectedItemTcpingLatency(),
shortcut=QtCore.QKeyCombination(
QtCore.Qt.KeyboardModifier.ControlModifier,
QtCore.Qt.Key.Key_O,
),
),
# The Tests menu intentionally exposes only the multithreaded test.
# Keep the single-threaded methods as a programmatic API for callers
# that need that scheduler explicitly.
AppQAction(
_('Test Download Speed'),
callback=lambda: self.testSelectedItemDownloadSpeedMulti(),
shortcut=QtCore.QKeyCombination(
QtCore.Qt.KeyboardModifier.ControlModifier,
QtCore.Qt.Key.Key_M,
),
),
AppQSeparator(),
AppQAction(
_('Clear Test Results'),
callback=lambda: self.clearSelectedItemTestResult(),
shortcut=QtCore.QKeyCombination(
QtCore.Qt.KeyboardModifier.ControlModifier,
QtCore.Qt.Key.Key_R,
),
),
AppQSeparator(),
AppQAction(
_('Stop All Tests'),
callback=self.profileTestManager.cancelAll,
parent=self,
),
)
contextMenuActions = [
self.moveActionRef,
AppQAction(
_('Duplicate'),
callback=lambda: self.duplicateSelectedItem(),
),
AppQAction(
_('Delete'),
callback=lambda: self.deleteSelectedItem(),
shortcut=QtCore.QKeyCombination(
QtCore.Qt.Key.Key_Delete,
),
),
self.moveToSubscriptionActionRef,
AppQSeparator(),
AppQAction(
_('Select All'),
callback=lambda: self.selectAll(),
shortcut=QtCore.QKeyCombination(
QtCore.Qt.KeyboardModifier.ControlModifier,
QtCore.Qt.Key.Key_A,
),
),
AppQSeparator(),
self.activateSelectedServerActionRef,
AppQAction(
_('Scroll To Activated Server'),
callback=lambda: self.scrollToActivatedItem(),
shortcut=QtCore.QKeyCombination(
QtCore.Qt.KeyboardModifier.ControlModifier,
QtCore.Qt.Key.Key_G,
),
),
AppQSeparator(),
self.advancedActionRef,
AppQSeparator(),
AppQAction(
_('Export Share Link To Clipboard'),
callback=lambda: self.exportSelectedItemURI(),
shortcut=QtCore.QKeyCombination(
QtCore.Qt.KeyboardModifier.ControlModifier,
QtCore.Qt.Key.Key_C,
),
),
AppQAction(
_('Export As QR Code'),
icon=bootstrapIcon('qr-code.svg'),
callback=lambda: self.exportSelectedItemQR(),
),
AppQAction(
_('Export JSON Configuration To Clipboard'),
callback=lambda: self.exportSelectedItemJSON(),
shortcut=QtCore.QKeyCombination(
QtCore.Qt.KeyboardModifier.ControlModifier,
QtCore.Qt.Key.Key_J,
),
),
]
self.contextMenu = AppQMenu(*contextMenuActions, parent=self)
self.contextMenu.aboutToShow.connect(self._rebuildSubscriptionMenu)
self._registerActionShortcuts(self.contextMenu.actions())
self._registerActionShortcuts(self.importActions)
self._registerActionShortcuts(self.testActions)
self.setContextMenuPolicy(QtCore.Qt.ContextMenuPolicy.CustomContextMenu)
self.customContextMenuRequested.connect(self.handleCustomContextMenuRequested)
# Distinguish double-click and activated
self.doubleClickedFlag = False
# Signals
self.selectionModel().selectionChanged.connect(self.handleItemSelectionChanged)
self.activated.connect(self.handleItemActivated)
self.doubleClicked.connect(self.handleItemDoubleClicked)
self.flushAll()
if self.activatedIndex().isValid():
self.setCurrentIndex(self.activatedIndex())
self.activateItemByIndex(Storage.UserActivatedItemIndex(), True)
@property
def selectedIndex(self):
"""Return the selected index value."""
indexes = list(
self.sourceRowFromProxyIndex(index)
for index in self.selectionModel().selectedRows()
)
return sorted(list(set(index for index in indexes if index >= 0)))
def sourceIndexFromProxyIndex(self, index: QtCore.QModelIndex):
"""Return the source index from proxy index value used by the user servers Qt table view."""
if not index.isValid():
return QtCore.QModelIndex()
return self.proxyModel.mapToSource(index)
def proxyIndexFromSourceIndex(self, index: QtCore.QModelIndex):
"""Return the proxy index from source index value used by the user servers Qt table view."""
if not index.isValid():
return QtCore.QModelIndex()
return self.proxyModel.mapFromSource(index)
def sourceRowFromProxyIndex(self, index: QtCore.QModelIndex) -> int:
"""Return the source row from proxy index value used by the user servers Qt table view."""
sourceIndex = self.sourceIndexFromProxyIndex(index)
if sourceIndex.isValid():
return sourceIndex.row()
return -1
def sourceRowFromProxyRow(self, row: int) -> int:
"""Return the source row from proxy row value used by the user servers Qt table view."""
return self.sourceRowFromProxyIndex(self.proxyModel.index(row, 0))
def proxyIndexFromSourceRow(self, row: int, column: int = 0):
"""Return the proxy index from source row value used by the user servers Qt table view."""
if row < 0 or row >= self.sourceModel.rowCount():
return QtCore.QModelIndex()
return self.proxyIndexFromSourceIndex(self.sourceModel.index(row, column))
def selectMultipleRows(self, indexes: list[int], clearCurrentSelection: bool):
"""Select multiple rows."""
if clearCurrentSelection:
self.selectionModel().clearSelection()
selection = self.selectionModel().selection()
for index in indexes:
proxyIndex0 = self.proxyIndexFromSourceRow(index, 0)
proxyIndex1 = self.proxyIndexFromSourceRow(index, len(self.Headers) - 1)
if proxyIndex0.isValid() and proxyIndex1.isValid():
selection.select(proxyIndex0, proxyIndex1)
self.selectionModel().select(
selection, QtCore.QItemSelectionModel.SelectionFlag.Select
)
def disconnectedCallback(self):
"""Update the user servers Qt table view for a disconnected state."""
self.sourceModel.emitRowChanged(
Storage.UserActivatedItemIndex(),
roles=[QtCore.Qt.ItemDataRole.ForegroundRole],
)
def connectedCallback(self):
"""Update the user servers Qt table view for a connected state."""
self.sourceModel.emitRowChanged(
Storage.UserActivatedItemIndex(),
roles=[QtCore.Qt.ItemDataRole.ForegroundRole],
)
def handleItemSelectionChanged(self, *args):
"""Handle item selection changed."""
if len(self.selectedIndex) > 1:
for action in [
self.customizeJSONConfigActionRef,
self.activateSelectedServerActionRef,
]:
action.setDisabled(True)
else:
for action in [
self.customizeJSONConfigActionRef,
self.activateSelectedServerActionRef,
]:
action.setDisabled(False)
@QtCore.Slot(QtCore.QModelIndex)
def handleItemActivated(self, index: QtCore.QModelIndex):
"""Handle item activated."""
if self.doubleClickedFlag:
# Ignore double-click
self.doubleClickedFlag = False
return
oldIndex = Storage.UserActivatedItemIndex()
newIndex = self.sourceRowFromProxyIndex(index)
if newIndex < 0:
return
if oldIndex == newIndex:
# Same item activated. Do nothing
return
if AppConnectionController().isConnecting():
mbox = AppQMessageBox(icon=AppQMessageBox.Icon.Information)
mbox.setText(_('Connecting. Please wait...'))
if PLATFORM != 'Darwin':
# Show the MessageBox asynchronously
mbox.open()
else:
# Show the MessageBox asynchronously
# TODO: Verify
mbox.open()
return
if oldIndex >= 0:
self.activateItemByIndex(oldIndex, False)
self.activateItemByIndex(newIndex, True)
if AppConnectionController().isConnected():
AppConnectionController().startReconnection()
def getGuiEditorByFactory(
self, factory, **kwargs
) -> Union[GuiEditorWidgetQDialog, None]:
"""Return GUI editor by factory."""
editor = getPluginRegistry().createEditorForConfig(
factory, parent=self, **kwargs
)
return editor
@QtCore.Slot(QtCore.QModelIndex)
def handleItemDoubleClicked(self, modelIndex: QtCore.QModelIndex):
"""Handle item double clicked."""
self.doubleClickedFlag = True
index = self.sourceRowFromProxyIndex(modelIndex)
if index < 0:
return
factory = Storage.UserServers()[index]
# Do not translate window title
guiEditor = self.getGuiEditorByFactory(factory, translatable=False)
if guiEditor is None:
# Unrecognized.
showMBoxUnrecognizedConfig()
return
guiEditor.setWindowTitle(f'{index + 1} - ' + factory.itemRemark)
try:
guiEditor.factoryToInput(factory)
except Exception as ex:
# Any non-exit exceptions
logger.error(f'error while converting factory to input: {ex}')
# Keep operation metadata on the transient editor. Weak dispatch avoids
# process-lifetime protection of compiled table methods in Nuitka while
# forwarding the transient sender explicitly to the operation slot.
guiEditor._modContext = (index, factory)
connectWeakly(
guiEditor.accepted,
self,
'handleGuiEditorAccepted',
sender=guiEditor,
forwardSender=True,
)
connectWeakly(
guiEditor.rejected,
self,
'handleGuiEditorRejected',
sender=guiEditor,
forwardSender=True,
)
guiEditor.open()
@QtCore.Slot(object)
def handleGuiEditorAccepted(self, editor):
"""Handle GUI editor accepted."""
if not isinstance(editor, GuiEditorWidgetQDialog):
return
index, factory = editor._modContext
logger.debug(f'guiEditor accepted with index {index}')
modified = editor.inputToFactory(factory)
# Still flush to row since remark may be modified
self.flushRow(index, factory)
if modified and index == Storage.UserActivatedItemIndex():
showMBoxNewChangesNextTime()
del editor._modContext
@QtCore.Slot(object)
def handleGuiEditorRejected(self, editor):
"""Handle GUI editor rejected."""
if not isinstance(editor, GuiEditorWidgetQDialog):
return
if hasattr(editor, '_modContext'):
del editor._modContext
@QtCore.Slot(QtCore.QPoint)
def handleCustomContextMenuRequested(self, point):
"""Handle custom context menu requested."""
self.contextMenu.exec(self.viewport().mapToGlobal(point))
def customSortFn(self, clickedIndex, **kwargs):
"""Handle custom sort fn for the user servers Qt table view."""
order = (
QtCore.Qt.SortOrder.DescendingOrder
if kwargs.get('reverse', False)
else QtCore.Qt.SortOrder.AscendingOrder
)
self.sortByColumn(clickedIndex, order)
def activatedIndex(self):
"""Activate d index."""
return self.proxyIndexFromSourceRow(Storage.UserActivatedItemIndex(), 0)
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))
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
if item.deleted or itemIndex < 0 or itemIndex >= len(Storage.UserServers()):
# Invalid item. Do nothing
return
def searchIndex(start, stop, step=1):
"""Search index."""
nonlocal itemIndex
for _index in range(start, stop, step):
if id(item) == id(Storage.UserServers()[_index]):
itemIndex = _index
item.index = itemIndex
return True
return False
if id(item) != id(Storage.UserServers()[itemIndex]):
# itemIndex doesn't match
if searchIndex(itemIndex - 1, -1, -1) or searchIndex(
itemIndex + 1, len(Storage.UserServers())
):
pass
else:
# Item isn't found in user servers. Do nothing
return
if row != itemIndex:
# Adjust row value
row = itemIndex
else:
pass
self.sourceModel.emitRowChanged(row, column)
def search(
self,
pattern: str,
*,
caseSensitive: bool = False,
regex: bool = True,
):
"""Search the user servers Qt table view."""
self.proxyModel.setSearchPattern(
pattern,
caseSensitive=caseSensitive,
regex=regex,
)
def clearSearch(self):
"""Clear search."""
self.search('')
def filterBySubscription(self, unique: str | None):
"""Show all, manual, or one subscription group's profiles."""
self.proxyModel.setSubscriptionFilter(unique)
def addServerViaGui(self, protocol, **kwargs):
"""Add server via GUI."""
factory = blankProfile(protocol)
windowTitle, translatable = (
kwargs.pop('windowTitle', APPLICATION_NAME),
kwargs.pop('translatable', True),
)
if translatable is True:
windowTitle = _(windowTitle)
guiEditor = self.getGuiEditorByFactory(
factory, windowTitle=windowTitle, translatable=translatable, **kwargs
)
if guiEditor is None:
# Unrecognized. Do nothing
return
try:
guiEditor.factoryToInput(factory)
except Exception as ex:
# Any non-exit exceptions
logger.error(f'error while converting factory to input: {ex}')
guiEditor._addContext = factory
connectWeakly(
guiEditor.accepted,
self,
'handleAddServerViaGuiAccepted',
sender=guiEditor,
forwardSender=True,
)
connectWeakly(
guiEditor.rejected,
self,
'handleAddServerViaGuiRejected',
sender=guiEditor,
forwardSender=True,
)
guiEditor.open()
@QtCore.Slot(object)
def handleAddServerViaGuiAccepted(self, editor):
"""Handle add server via GUI accepted."""
if not isinstance(editor, GuiEditorWidgetQDialog):
return
factory = editor._addContext
editor.inputToFactory(factory)
self.appendNewItemByFactory(factory)
del editor._addContext
@QtCore.Slot(object)
def handleAddServerViaGuiRejected(self, editor):
"""Handle add server via GUI rejected."""
if not isinstance(editor, GuiEditorWidgetQDialog):
return
if hasattr(editor, '_addContext'):
del editor._addContext
def flushRow(self, row: int, item: ServerProfile):
"""Refresh row."""
# flushRow is the established commit notification for both structured
# and JSON profile editors. Reconcile before presenting a replacement
# or in-place connection mutation.
self.reconcileProfileTestJobs()
itemIndex = item.index
if item.deleted or itemIndex < 0 or itemIndex >= len(Storage.UserServers()):
# Invalid item. Do nothing
return
if row != itemIndex:
row = itemIndex
self.sourceModel.emitRowChanged(row)
if row == Storage.UserActivatedItemIndex():
self.activeServerChanged.emit()
def flushAll(self):
# Refresh index
"""Refresh all."""
self.sourceModel.refreshIndexes()
self.sourceModel.emitAllChanged()
def _profileIdsForSourceRows(self, rows) -> list[str]:
"""Return stable identities for valid source rows."""
profiles = Storage.UserServers()
return [
profiles[row].metadata.profileId for row in rows if 0 <= row < len(profiles)
]
def _visibleProfileIds(self) -> list[str]:
"""Return stable identities in the current filtered display scope."""
return self._profileIdsForSourceRows(
self.sourceRowFromProxyRow(row) for row in range(self.proxyModel.rowCount())
)
def _selectedProfileIds(self) -> list[str]:
"""Return stable identities for the current row selection."""
return self._profileIdsForSourceRows(self.selectedIndex)
def _currentProfileId(self) -> str | None:
"""Return the stable identity represented by the current proxy index."""
row = self.sourceRowFromProxyIndex(self.currentIndex())
profileIds = self._profileIdsForSourceRows((row,))
return profileIds[0] if profileIds else None
@QtCore.Slot()
def _captureSortSelection(self):
"""Remember semantic view state before the proxy rebuilds its mapping."""
self._sortSelectionSnapshot = (
self._selectedProfileIds(),
self._currentProfileId(),
self.hasFocus(),
)
@QtCore.Slot()
def _restoreSortSelection(self):
"""Restore semantic view state after a source-backed header sort."""
snapshot = self._sortSelectionSnapshot
self._sortSelectionSnapshot = None
if snapshot is not None:
profileIds, currentProfileId, restoreFocus = snapshot
self._restoreProfileSelection(
profileIds,
currentProfileId,
restoreFocus=restoreFocus,
)
def _restoreProfileSelection(
self,
profileIds,
currentProfileId=None,
*,
restoreFocus=True,
):
"""Restore visible selected rows and their current identity."""
selected = set(profileIds)
rows = [
index
for index, profile in enumerate(Storage.UserServers())
if profile.metadata.profileId in selected
]
with Mixins.QBlockSignalContext(self):
self.selectMultipleRows(rows, True)
if rows:
currentRow = next(
(
row
for row in rows
if Storage.UserServers()[row].metadata.profileId
== currentProfileId
),
rows[0],
)
current = self.proxyIndexFromSourceRow(currentRow)
if current.isValid():
self.selectionModel().setCurrentIndex(
current,
QtCore.QItemSelectionModel.SelectionFlag.NoUpdate,
)
if restoreFocus:
self.setFocus(QtCore.Qt.FocusReason.ShortcutFocusReason)
def _applyRepositoryLayoutMutation(self, callback) -> bool:
"""Apply one repository reorder and preserve active and selected identities."""
selectedProfileIds = self._selectedProfileIds()
currentProfileId = self._currentProfileId()
activatedIndex = Storage.UserActivatedItemIndex()
profiles = Storage.UserServers()
activatedProfileId = (
profiles[activatedIndex].metadata.profileId
if 0 <= activatedIndex < len(profiles)
else None
)
self.sourceModel.layoutAboutToBeChanged.emit()
try:
changed = bool(callback())
if changed and activatedProfileId is not None:
for index, profile in enumerate(Storage.UserServers()):
if profile.metadata.profileId == activatedProfileId:
if index != activatedIndex:
AppSettings.set('ActivatedItemIndex', str(index))
break
finally:
self.sourceModel.layoutChanged.emit()
if not changed:
return False
if (
activatedProfileId is not None
and Storage.UserActivatedItemIndex() != activatedIndex
):
self.activeServerChanged.emit()
self.proxyModel.invalidate()
self._restoreProfileSelection(selectedProfileIds, currentProfileId)
return True
def newEmptyItem(self):
"""Handle new empty item for the user servers Qt table view."""
self.appendNewItem(remark=_('Untitled'), acceptInvalid=True)
def moveSelectedItems(self, position: str):
"""Move selected profiles inside the current filtered display scope."""
selectedProfileIds, visibleProfileIds = (
self._selectedProfileIds(),
self._visibleProfileIds(),
)
if not selectedProfileIds:
return
self._applyRepositoryLayoutMutation(
lambda: Storage.moveUserServers(
selectedProfileIds,
visibleProfileIds,
position,
)
)
def _registerActionShortcuts(self, actions):
"""Activate every nested table action only while this view has focus."""
for action in actions:
if action.isSeparator():
continue
if not action.shortcut().isEmpty():
action.setShortcutContext(QtCore.Qt.ShortcutContext.WidgetShortcut)
self.addAction(action)
submenu = action.menu() if hasattr(action, 'menu') else None
if submenu is not None:
self._registerActionShortcuts(submenu.actions())
def _clearSubscriptionActions(self):
"""Release callbacks and wrappers owned by the dynamic group submenu."""
for action in self._subscriptionActions:
self.moveToSubscriptionMenu.removeAction(action)
action.callback = None
action.deleteLater()
self._subscriptionActions.clear()
self.moveToSubscriptionMenu._actions.clear()
@QtCore.Slot()
def _rebuildSubscriptionMenu(self):
"""Rebuild subscription destinations from the live group repository."""
self._clearSubscriptionActions()
selectedProfileIds = self._selectedProfileIds()
selected = set(selectedProfileIds)
selectedSources = {
profile.metadata.subscriptionSource
for profile in Storage.UserServers()
if profile.metadata.profileId in selected
}
destinations = [(_('No subscription'), '')]
destinations.extend(
(
group.remark or group.webURL or group.id,
group.id,
)
for group in Storage.SubscriptionGroups()
)
for label, unique in destinations:
action = AppQAction(
label,
callback=functools.partial(
self.moveSelectedItemsToSubscription,
unique,
),
checkable=True,
checked=bool(selectedProfileIds) and selectedSources == {unique},
translatable=not unique,
parent=self.moveToSubscriptionMenu,
)
self._subscriptionActions.append(action)
self.moveToSubscriptionMenu._actions.append(action)
self.moveToSubscriptionMenu.addAction(action)
self.moveToSubscriptionActionRef.setEnabled(
bool(selectedProfileIds)
and any(selectedSources != {unique} for _label, unique in destinations)
)
def moveSelectedItemsToSubscription(self, unique: str):
"""Move selected profiles to one group without transferring sync ownership."""
selectedProfileIds, currentProfileId = (
self._selectedProfileIds(),
self._currentProfileId(),
)
if not selectedProfileIds:
return
if Storage.moveUserServersToSubscription(selectedProfileIds, unique):
self.sourceModel.emitAllChanged()
self.proxyModel.invalidateFilter()
self._restoreProfileSelection(selectedProfileIds, currentProfileId)
def duplicateSelectedItem(self):
"""Handle duplicate selected item for the user servers Qt table view."""
indexes = self.selectedIndex
if len(indexes) == 0:
# Nothing selected. Do nothing
return
for index in indexes:
if 0 <= index < len(Storage.UserServers()):
deepcopy = Storage.UserServers()[index].independentCopy()
# A duplicate is a new manual profile, not another profile
# managed by the source subscription.
self.appendNewItem(
remark=deepcopy.itemRemark,
config=deepcopy,
)
def deleteItemByIndex(
self, indexes, showTrayMessage=True, showProgress=True
) -> int:
"""Delete item by index."""
profiles = Storage.UserServers()
indexes = sorted({index for index in indexes if 0 <= index < len(profiles)})
if len(indexes) == 0:
# Nothing selected. Do nothing
return 0
if (
showProgress
and len(indexes) > DeleteServersProgressDialog.SmallOperationLimit
):
dialog = DeleteServersProgressDialog(
self,
indexes,
showTrayMessage=showTrayMessage,
parent=self.window(),
)
dialog.open()
return 0
activatedIndex = Storage.UserActivatedItemIndex()
deleteActivated = activatedIndex in indexes
ranges = []
for index in indexes:
if ranges and index == ranges[-1][1] + 1:
ranges[-1] = (ranges[-1][0], index)
else:
ranges.append((index, index))
# Descending ranges preserve the remaining source rows and Qt selections.
for first, last in reversed(ranges):
self.sourceModel.beginRemoveRows(QtCore.QModelIndex(), first, last)
for profile in profiles[first : last + 1]:
profile.deleted = True
del profiles[first : last + 1]
if first <= activatedIndex <= last:
activatedIndex = -1
elif last < activatedIndex:
activatedIndex -= last - first + 1
if activatedIndex != Storage.UserActivatedItemIndex():
AppSettings.set('ActivatedItemIndex', str(activatedIndex))
self.sourceModel.endRemoveRows()
self.reconcileProfileTestJobs()
# Refresh index
self.sourceModel.refreshIndexes()
self.sourceModel.emitAllChanged()
if deleteActivated:
self.activeServerChanged.emit()
controller = AppConnectionController()
if controller.isConnected():
controller.startDisconnection(
_('Disconnected') if showTrayMessage else ''
)
return len(indexes)
def deleteSelectedItem(self):
"""Delete selected item."""
indexes = self.selectedIndex
if len(indexes) == 0:
# Nothing selected. Do nothing
return
profileIds = self._profileIdsForSourceRows(indexes)
def handleResultCode(_profileIds, code):
"""Handle result code."""
if code == PySide6Legacy.enumValueWrapper(
AppQMessageBox.StandardButton.Yes
):
targets = set(_profileIds)
self.deleteItemByIndex(
index
for index, profile in enumerate(Storage.UserServers())
if profile.metadata.profileId in targets
)
else:
pass
if PLATFORM == 'Windows':
# Windows
mbox = MBoxQuestionDelete(icon=AppQMessageBox.Icon.Question)
else:
# macOS & linux
mbox = MBoxQuestionDelete(
icon=AppQMessageBox.Icon.Question, parent=self.parent()
)
mbox.setWindowModality(QtCore.Qt.WindowModality.WindowModal)
mbox.isMulti = bool(len(indexes) > 1)
mbox.possibleRemark = (
f'{indexes[0] + 1} - ' + Storage.UserServers()[indexes[0]].itemRemark
)
mbox.setText(mbox.customText())
mbox.finished.connect(functools.partial(handleResultCode, profileIds))
# Show the MessageBox asynchronously
mbox.open()
def editSelectedItemConfiguration(self):
"""Handle edit selected item configuration for the user servers Qt table view."""
indexes = self.selectedIndex
if len(indexes) == 0:
# Nothing selected. Do nothing
return
if len(indexes) != 1:
# Should not reach here
return
index = indexes[0]
title = f'{index + 1} - ' + Storage.UserServers()[index].itemRemark
self.configurationEditor.currentIndex = index
self.configurationEditor.customWindowTitle = title
self.configurationEditor.setWindowTitle(title)
self.configurationEditor.setPlainText(
Storage.UserServers()[index].toJSONString(), True
)
self.configurationEditor.show()
def activateSelectedServer(self):
"""Activate selected server."""
indexes = self.selectedIndex
if len(indexes) == 0:
# Nothing selected. Do nothing
return
if len(indexes) != 1:
# Should not reach here
return
item = self.proxyIndexFromSourceRow(indexes[0])
if item.isValid():
self.handleItemActivated(item)
def scrollToActivatedItem(self):
"""Handle scroll to activated item for the user servers Qt table view."""
activatedItem = self.activatedIndex()
if activatedItem.isValid():
self.setCurrentIndex(activatedItem)
self.scrollTo(activatedItem)
@QtCore.Slot(object, object)
def _handleProfileTestResultApplied(
self,
profile: ServerProfile,
result: ProfileTestResult,
):
"""Repaint the one repository cell committed by the test service."""
profiles = Storage.UserServers()
row = profile.index
if row < 0 or row >= len(profiles) or profiles[row] is not profile:
return
column = 'Latency' if result.field is ProfileTestField.Latency else 'Speed'
self.flushItem(row, self.Headers.index(column), profile)
def _selectedProfilesForTesting(self):
"""Resolve the current row selection into live repository profiles."""
profiles = Storage.UserServers()
return [
profiles[index]
for index in self.selectedIndex
if 0 <= index < len(profiles)
]
def reconcileProfileTestJobs(self):
"""Notify the test service after a live profile collection mutation."""
self.profileTestManager.reconcileProfiles()
def testSelectedItemPingLatency(self):
"""Request ICMP latency tests for selected profiles."""
self.profileTestManager.testPing(self._selectedProfilesForTesting())
def testSelectedItemTcpingLatency(self):
"""Request asynchronous TCP latency tests for selected profiles."""
self.profileTestManager.testTcping(self._selectedProfilesForTesting())
def testSelectedItemDownloadSpeedWithTimeout(self, timeout: int):
"""Request serial download tests for selected profiles."""
self.profileTestManager.testDownloadSpeed(
self._selectedProfilesForTesting(),
timeoutMilliseconds=timeout,
concurrent=False,
)
def testSelectedItemDownloadSpeedWithTimeoutMulti(self, timeout: int):
"""Request concurrent download tests for selected profiles."""
self.profileTestManager.testDownloadSpeed(
self._selectedProfilesForTesting(),
timeoutMilliseconds=timeout,
concurrent=True,
)
def testSelectedItemDownloadSpeed(self):
"""Run the retained serial download-test API for selected rows.
The Home context menu uses the concurrent variant; this method remains
available for programmatic callers that explicitly need serial scheduling.
"""
self.testSelectedItemDownloadSpeedWithTimeout(5000)
def testSelectedItemDownloadSpeedMulti(self):
"""Request concurrent download tests for selected profiles."""
self.testSelectedItemDownloadSpeedWithTimeoutMulti(5000)
def clearSelectedItemTestResult(self):
"""Clear selected item test result."""
self.profileTestManager.clearResults(self._selectedProfilesForTesting())
def cleanup(self):
"""Release resources owned by the user servers Qt table view."""
self.subsManager.shutdown()
self.profileTestManager.shutdown()
self._clearSubscriptionActions()
def updateSubsByUnique(self, unique: str, httpProxy: Union[str, None], **kwargs):
"""Update subs by unique."""
self.updateSubscriptions((unique,), httpProxy, **kwargs)
def updateSubscriptions(self, uniques, httpProxy: Union[str, None], **kwargs):
"""Update stable subscription IDs as one manager-owned batch."""
kwargs.pop('parent', None)
self.subsManager.configureHttpProxy(httpProxy)
self.subsManager.updateSubscriptions(uniques, **kwargs)
def updateSubs(self, httpProxy: Union[str, None], **kwargs):
"""Update subs."""
self.selectionModel().clearSelection()
self.updateSubscriptions(tuple(Storage.UserSubs()), httpProxy, **kwargs)
@QtCore.Slot()
def _handleSubscriptionsChanged(self):
"""Refresh the table after the service commits repository changes."""
self.reconcileProfileTestJobs()
self.sourceModel.beginResetModel()
self.sourceModel.endResetModel()
self.sourceModel.refreshIndexes()
self.proxyModel.invalidate()
self.flushAll()
self.activeServerChanged.emit()
@QtCore.Slot(str)
def _handleSubscriptionCommitted(self, unique: str):
"""Clear results and invalidate only work owned by one committed group."""
self.profileTestManager.invalidateSubscriptions(
{unique},
clearResults=True,
)
@QtCore.Slot(object)
def _handleSubscriptionUpdateCompleted(self, batch: SubscriptionUpdateBatch):
"""Present one semantic subscription update result batch."""
if not batch.showMessageBox:
return
mbox = MBoxUpdateSubsInfo(
successArgs=list(batch.successful),
failureArgs=list(batch.failed),
parent=self.window(),
)
mbox.setIcon(
AppQMessageBox.Icon.Information
if batch.successful
else AppQMessageBox.Icon.Critical
)
mbox.setText(mbox.customText())
mbox.setColumnMinWidth()
mbox.open()
def appendNewItemByFactory(self, factory: CoreConfiguration | ServerProfile):
"""Append one profile through the shared insertion boundary."""
self.appendNewItemsByFactories((factory,))
def appendNewItemsByFactories(self, factories):
"""Append prepared profiles with one structural notification and reconciliation."""
factories = [ensureProfile(factory) for factory in factories]
if not factories:
return
profiles = Storage.UserServers()
first = len(profiles)
for index, factory in enumerate(factories, first):
factory.index = index
self.sourceModel.beginInsertRows(
QtCore.QModelIndex(), first, first + len(factories) - 1
)
profiles.extend(factories)
self.sourceModel.endInsertRows()
self.reconcileProfileTestJobs()
if first <= Storage.UserActivatedItemIndex() < first + len(factories):
self.activeServerChanged.emit()
if first == 0:
self.setCurrentIndex(self.proxyIndexFromSourceRow(0))
if not AppConnectionController().isConnected():
self.activateItemByIndex(0, True)
def appendNewItem(self, **kwargs):
"""Append new item."""
acceptInvalid = kwargs.pop('acceptInvalid', False)
model = {
'remark': kwargs.pop('remark', ''),
'config': kwargs.pop('config', ''),
'subsId': kwargs.pop('subsId', ''),
}
factory = profileFromAny(model.pop('config', ''), **model)
if factory.isValid():
self.appendNewItemByFactory(factory)
else:
if acceptInvalid:
self.appendNewItemByFactory(factory)
else:
# The rejected input may be a complete JSON configuration or
# share URI containing credentials.
logger.error('invalid server profile input')
def exportSelectedItemURI(self):
"""Export selected item URI."""
indexes = self.selectedIndex
if len(indexes) == 0:
# Nothing selected. Do nothing
return
def toURI(factory) -> str:
"""Export the configuration as a share URI."""
assert isinstance(factory, ServerProfile)
try:
return exportConfiguration(factory)
except Exception:
# Any non-exit exceptions
return ''
# TODO: MessageBox?
QApplication.clipboard().setText(
'\n'.join(list(toURI(Storage.UserServers()[index]) for index in indexes))
)
def exportSelectedItemQR(self):
"""Export selected item QR."""
indexes = self.selectedIndex
if len(indexes) == 0:
# Nothing selected. Do nothing
return
window = self.qrCodeWindowFactory()
return window.startExportByIndex(indexes)
def exportSelectedItemJSON(self):
"""Export selected item JSON."""
indexes = self.selectedIndex
if len(indexes) == 0:
# Nothing selected. Do nothing
return
# TODO: MessageBox?
QApplication.clipboard().setText(
'\n'.join(
list(Storage.UserServers()[index].toJSONString() for index in indexes)
)
)
def showTabAndSpaces(self):
"""Show tab and spaces."""
self.configurationEditor.showTabAndSpaces()
def hideTabAndSpaces(self):
"""Hide tab and spaces."""
self.configurationEditor.hideTabAndSpaces()
def keyPressEvent(self, event):
"""Handle a key press for the user servers Qt table view."""
if event.key() == QtCore.Qt.Key.Key_Return:
if PLATFORM == 'Darwin':
# Activate by Enter key on macOS
self.handleItemActivated(self.currentIndex())
else:
super().keyPressEvent(event)
else:
super().keyPressEvent(event)
def retranslate(self):
"""Refresh translated text for the user servers Qt table view."""
self.sourceModel.headerDataChanged.emit(
QtCore.Qt.Orientation.Horizontal,
0,
len(self.Headers) - 1,
)
self._rebuildSubscriptionMenu()