# 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 the application window for user routing window."""
from __future__ import annotations
from Furious.Frozenlib import *
from Furious.Models import *
from Furious.Repository import *
from Furious.Qt import *
from Furious.Qt.Signals import connectWeakly
from Furious.Qt import gettext as _
from PySide6 import QtCore
from PySide6.QtGui import *
from PySide6.QtWidgets import *
import copy
import uuid
import logging
import functools
from .Routing import cleanRoutingRule
__all__ = [
'XrayRoutingWindow',
# Begin export for testing
'RoutingPreviewDialog',
'RoutingRuleEditDialog',
'RoutingRulesDialog',
# End export for testing
]
logger = logging.getLogger(__name__)
registerAppSettings('UserRoutingWindowGeometry')
registerAppSettings('UserRoutingWindowState')
registerAppSettings('UserRoutingHeaderViewState')
DOMAIN_STRATEGIES, ROUTING_STATES = (
['AsIs', 'IPIfNonMatch', 'IPOnDemand'],
['Enabled', 'Disabled'],
)
def valuesToList(text: str) -> list[str]:
"""Return the values to list value used by the application."""
values = list()
for line in text.splitlines():
for value in line.split(','):
value = value.strip()
if value:
values.append(value)
return values
def listToText(value) -> str:
"""Return the list to text value used by the application."""
if isinstance(value, list):
return '\n'.join(str(item) for item in value)
return ''
def routingStateText(state: str) -> str:
"""Return the routing state text value used by the application."""
if state == 'Enabled':
return _('Enabled')
elif state == 'Disabled':
return _('Disabled')
return _(state)
def addRoutingStateItems(combo: QComboBox):
"""Add routing state items."""
combo.clear()
for state in ROUTING_STATES:
combo.addItem(routingStateText(state), state)
def comboCurrentData(combo: QComboBox, default=''):
"""Return the combo current data value used by the application."""
data = combo.currentData()
if data is None:
return default
return data
def routingObjectFromProfile(routingProfile: dict):
"""Return the routing object from profile value used by the application."""
if not isinstance(routingProfile, dict):
routingProfile = dict()
domainStrategy = routingProfile.get('domainStrategy', 'AsIs')
if domainStrategy not in DOMAIN_STRATEGIES:
domainStrategy = 'AsIs'
rules = list(
filter(
lambda rule: rule is not None,
list(cleanRoutingRule(rule) for rule in routingProfile.get('rules', [])),
)
)
return {
'domainStrategy': domainStrategy,
'domainMatcher': 'hybrid',
'rules': rules,
}
class RoutingPreviewDialog(AppQTransientDialog):
"""Present the routing preview dialog."""
FIXED_DIALOG_SIZE = QtCore.QSize(400, int(400 * GOLDEN_RATIO))
def __init__(self, routingProfile: dict, parent=None):
"""Initialize the RoutingPreviewDialog."""
super().__init__(parent)
self.setWindowTitle(_('Preview Routing'))
self.setWindowModality(QtCore.Qt.WindowModality.WindowModal)
self.textEditor = DraculaJSONTextEditor(fontFamily=AppFontName())
self.textEditor.setLineWrapMode(DraculaJSONTextEditor.LineWrapMode.NoWrap)
self.textEditor.setPlainText(
UJSONEncoder.encode(routingObjectFromProfile(routingProfile), indent=4)
)
self.textEditor.setReadOnly(True)
layout = QVBoxLayout()
layout.addWidget(self.textEditor)
self.setLayout(layout)
class RoutingTextEditDialog(AppQTransientDialog):
"""Present the routing text edit dialog."""
FIXED_DIALOG_SIZE = QtCore.QSize(760, 470)
def __init__(self, text='', parent=None):
"""Initialize the RoutingTextEditDialog."""
super().__init__(parent)
self.setWindowTitle(_('Edit Text'))
self.setWindowModality(QtCore.Qt.WindowModality.WindowModal)
self.textEdit = QTextEdit()
self.textEdit.setPlainText(text)
self.dialogBtns = AppQDialogButtonBox(QtCore.Qt.Orientation.Horizontal)
self.dialogBtns.addButton(_('OK'), AppQDialogButtonBox.ButtonRole.AcceptRole)
self.dialogBtns.addButton(
_('Cancel'),
AppQDialogButtonBox.ButtonRole.RejectRole,
)
connectWeakly(self.dialogBtns.accepted, self, 'accept')
connectWeakly(self.dialogBtns.rejected, self, 'reject')
layout = QVBoxLayout()
layout.addWidget(self.textEdit)
layout.addWidget(self.dialogBtns)
self.setLayout(layout)
def text(self):
"""Return the text value."""
return self.textEdit.toPlainText()
class RoutingTextEdit(Mixins.QTranslatable, QTextEdit):
"""Represent routing text edit."""
def __init__(self, plainText='', placeholderText='', parent=None):
"""Initialize the RoutingTextEdit."""
super().__init__(parent)
self.setPlainText(plainText)
self.setPlaceholderText(placeholderText)
self.setToolTip(_('Double-click to enlarge'))
def mouseDoubleClickEvent(self, event):
"""Handle the mouse double click event."""
dialog = RoutingTextEditDialog(self.toPlainText(), parent=self)
def handleResultCode(code):
"""Handle result code."""
if code == PySide6Legacy.enumValueWrapper(AppQDialog.DialogCode.Accepted):
self.setPlainText(dialog.text())
dialog.finished.connect(handleResultCode)
dialog.open()
event.accept()
def retranslate(self):
"""Refresh translated text for the routing text edit."""
self.setPlaceholderText(_(self.placeholderText()))
self.setToolTip(_(self.toolTip()))
class RoutingDocumentationURL(AppQLabel):
"""Represent routing documentation URL."""
URL = 'https://xtls.github.io/config/routing.html'
def __init__(self, *args, **kwargs):
"""Initialize the RoutingDocumentationURL."""
super().__init__(*args, **kwargs)
self.setWebsiteURL()
self.linkActivated.connect(self.handleLinkActivated)
def setWebsiteURL(self):
"""Set website URL."""
self.setText(
'
'
f''
''
+ _('Routing Documentation')
+ '
'
)
@staticmethod
def handleLinkActivated(link: str):
"""Handle link activated."""
if QDesktopServices.openUrl(QtCore.QUrl(link)):
logger.info(f'open link \'{link}\' success')
else:
logger.error(f'open link \'{link}\' failed')
def retranslate(self):
"""Refresh translated text for the routing documentation URL."""
self.setWebsiteURL()
class RoutingProfilesModel(QtCore.QAbstractTableModel):
"""Expose routing profiles data through a Qt item model."""
Headers = ['Remark', 'Domain Strategy', 'State']
def rowCount(self, parent=QtCore.QModelIndex()) -> int:
"""Return the number of rows exposed by the model."""
if parent.isValid():
return 0
return len(Storage.UserRoutings())
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 headerData(self, section, 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:
header = self.Headers[section]
if header in ['Remark', 'State']:
return _(header)
return header
return section + 1
def routingUniqueByRow(self, row: int):
"""Return the routing unique by row value used by the routing profiles model."""
return list(Storage.UserRoutings().keys())[row]
def routingByRow(self, row: int):
"""Return the routing by row value used by the routing profiles model."""
return Storage.UserRoutings()[self.routingUniqueByRow(row)]
def data(self, index, role=QtCore.Qt.ItemDataRole.DisplayRole):
"""Return the data managed by the routing profiles model."""
if not index.isValid():
return None
row, column = index.row(), index.column()
if row < 0 or row >= len(Storage.UserRoutings()):
return None
routing = self.routingByRow(row)
if role in [
QtCore.Qt.ItemDataRole.DisplayRole,
QtCore.Qt.ItemDataRole.EditRole,
]:
if column == 0:
return routing.get('remark', '')
elif column == 1:
return routing.get('domainStrategy', 'AsIs')
elif column == 2:
return routingStateText(
'Enabled' if routing.get('enabled', True) else 'Disabled'
)
return None
def setData(self, index, value, role=QtCore.Qt.ItemDataRole.EditRole):
"""Update model data for the requested role."""
return False
def emitAllChanged(self):
"""Handle emit all changed for the routing profiles model."""
if self.rowCount() == 0:
return
self.dataChanged.emit(
self.index(0, 0),
self.index(self.rowCount() - 1, self.columnCount() - 1),
)
class RoutingRuleEditDialog(AppQTransientDialog):
"""Present the routing rule edit dialog."""
DEFAULT_DIALOG_SIZE = QtCore.QSize(int(800 * GOLDEN_RATIO), 800)
MatchInputHeight = 65
ShortInputWidth = 240
@staticmethod
def textEdit(text='', placeholderText=''):
"""Return the text edit value used by the routing rule edit dialog."""
widget = RoutingTextEdit(text, placeholderText)
widget.setFixedHeight(RoutingRuleEditDialog.MatchInputHeight)
return widget
@staticmethod
def lineEdit(text='', placeholderText=''):
"""Return the line edit value used by the routing rule edit dialog."""
widget = AppQLineEdit(text)
widget.setPlaceholderText(placeholderText)
widget.setFixedHeight(RoutingRuleEditDialog.MatchInputHeight)
return widget
@staticmethod
def _createGroupBox(title: str, rows):
"""Create one routing section using the protocol-editor grid structure."""
page = QWidget()
pageLayout = QGridLayout()
pageLayout.setColumnStretch(1, 1)
pageLayout.setColumnStretch(3, 1)
for row, (labelText, inputWidget) in enumerate(rows):
pageLayout.addWidget(
AppQLabel(labelText, translatable=False),
row,
0,
)
pageLayout.addWidget(inputWidget, row, 1, 1, 3)
pageLayout.setRowStretch(len(rows), 1)
page.setLayout(pageLayout)
groupBox = AppQGroupBox(title, translatable=False)
pageStack = QStackedWidget()
pageStack.addWidget(page)
groupBoxLayout = QVBoxLayout()
groupBoxLayout.addWidget(pageStack)
groupBox.setLayout(groupBoxLayout)
return groupBox
def __init__(self, rule: dict, parent=None):
"""Initialize the RoutingRuleEditDialog."""
super().__init__(parent)
self.rule = dict(rule)
self.setWindowTitle(_('Edit Routing Rule'))
self.setWindowModality(QtCore.Qt.WindowModality.WindowModal)
(
self.ruleTagEdit,
self.outboundEdit,
self.balancerTagEdit,
) = (
self.lineEdit(
self.rule.get('ruleTag', ''),
_('Optional rule name, e.g. block-ads'),
),
self.lineEdit(
self.rule.get('outboundTag', ''),
_('Outbound tag, e.g. proxy, direct, block'),
),
self.lineEdit(
str(self.rule.get('balancerTag', '')),
_('Balancer tag, e.g. balancer'),
),
)
self.networkCombo = AppQComboBox(translatable=False)
self.networkCombo.addItems(['', 'tcp', 'udp', 'tcp,udp'])
self.networkCombo.setCurrentText(self.rule.get('network', ''))
self.networkCombo.setMaximumWidth(self.ShortInputWidth)
portPlaceholder = _('e.g. 53,443,1000-2000')
(
self.portEdit,
self.sourcePortEdit,
self.localPortEdit,
self.vlessRouteEdit,
self.domainEdit,
self.ipEdit,
self.sourceIPEdit,
self.localIPEdit,
self.userEdit,
self.protocolEdit,
self.inboundTagEdit,
self.processEdit,
) = (
self.textEdit(
str(self.rule.get('port', '')),
portPlaceholder,
),
self.textEdit(
str(self.rule.get('sourcePort', '')),
portPlaceholder,
),
self.textEdit(
str(self.rule.get('localPort', '')),
portPlaceholder,
),
self.textEdit(
str(self.rule.get('vlessRoute', '')),
portPlaceholder,
),
self.textEdit(
listToText(self.rule.get('domain', [])),
_(r'e.g. domain:xray.com, geosite:cn, regexp:\.google\.com$'),
),
self.textEdit(
listToText(self.rule.get('ip', [])),
_('e.g. 10.0.0.0/8, geoip:cn, !geoip:private'),
),
self.textEdit(
listToText(self.rule.get('sourceIP', [])),
_('e.g. 10.0.0.1, 192.168.1.0/24'),
),
self.textEdit(
listToText(self.rule.get('localIP', [])),
_('e.g. 192.168.0.25'),
),
self.textEdit(
listToText(self.rule.get('user', [])),
_('e.g. love@xray.com'),
),
self.textEdit(
listToText(self.rule.get('protocol', [])),
_('e.g. http, tls, quic, bittorrent'),
),
self.textEdit(
listToText(self.rule.get('inboundTag', [])),
_('e.g. tag-vmess'),
),
self.textEdit(
listToText(self.rule.get('process', [])),
_('e.g. curl'),
),
)
self.dialogBtns = AppQDialogButtonBox(QtCore.Qt.Orientation.Horizontal)
self.dialogBtns.addButton(_('OK'), AppQDialogButtonBox.ButtonRole.AcceptRole)
self.dialogBtns.addButton(
_('Cancel'), AppQDialogButtonBox.ButtonRole.RejectRole
)
connectWeakly(self.dialogBtns.accepted, self, 'accept')
connectWeakly(self.dialogBtns.rejected, self, 'reject')
(
generalGroup,
destinationGroup,
sourceGroup,
identityGroup,
) = (
self._createGroupBox(
'General',
(
('Rule Tag', self.ruleTagEdit),
('OutBound', self.outboundEdit),
('Balancer Tag', self.balancerTagEdit),
('Network', self.networkCombo),
),
),
self._createGroupBox(
'Destination Match',
(
('Domain', self.domainEdit),
('IP', self.ipEdit),
('Port', self.portEdit),
('VLESS Route', self.vlessRouteEdit),
),
),
self._createGroupBox(
'Source / Local Match',
(
('Source IP', self.sourceIPEdit),
('Source Port', self.sourcePortEdit),
('Local IP', self.localIPEdit),
('Local Port', self.localPortEdit),
),
),
self._createGroupBox(
'Identity / Process / Protocol Match',
(
('User', self.userEdit),
('Inbound Tag', self.inboundTagEdit),
('Process', self.processEdit),
('Protocol', self.protocolEdit),
),
),
)
matchGrid = QGridLayout()
matchGrid.addWidget(generalGroup, 0, 0)
matchGrid.addWidget(destinationGroup, 0, 1)
matchGrid.addWidget(sourceGroup, 1, 0)
matchGrid.addWidget(identityGroup, 1, 1)
matchGrid.setColumnStretch(0, 1)
matchGrid.setColumnStretch(1, 1)
matchPage = QWidget()
matchPage.setLayout(matchGrid)
bottomLayout = QHBoxLayout()
bottomLayout.addWidget(RoutingDocumentationURL())
bottomLayout.addStretch(1)
bottomLayout.addWidget(self.dialogBtns)
layout = QVBoxLayout()
layout.addWidget(matchPage)
layout.addLayout(bottomLayout)
self.setLayout(layout)
def routingRule(self):
"""Return the routing rule value used by the routing rule edit dialog."""
rule = {
'type': 'field',
'outboundTag': self.outboundEdit.text().strip() or 'proxy',
}
for key, value in [
('ruleTag', self.ruleTagEdit.text().strip()),
('network', self.networkCombo.currentText().strip()),
('port', self.portEdit.toPlainText().strip()),
('sourcePort', self.sourcePortEdit.toPlainText().strip()),
('localPort', self.localPortEdit.toPlainText().strip()),
('vlessRoute', self.vlessRouteEdit.toPlainText().strip()),
('balancerTag', self.balancerTagEdit.text().strip()),
]:
if value:
rule[key] = value
for key, widget in [
('domain', self.domainEdit),
('ip', self.ipEdit),
('sourceIP', self.sourceIPEdit),
('localIP', self.localIPEdit),
('user', self.userEdit),
('protocol', self.protocolEdit),
('inboundTag', self.inboundTagEdit),
('process', self.processEdit),
]:
values = valuesToList(widget.toPlainText())
if values:
rule[key] = values
return rule
class RoutingRemarkEditDialog(AppQTransientDialog):
"""Present the routing remark edit dialog."""
DEFAULT_DIALOG_SIZE = QtCore.QSize(420, 120)
def __init__(self, remark: str, parent=None):
"""Initialize the RoutingRemarkEditDialog."""
super().__init__(parent)
self.setWindowTitle(_('Edit Routing Remark'))
self.setWindowModality(QtCore.Qt.WindowModality.WindowModal)
self.remarkEdit = AppQLineEdit(remark)
self.dialogBtns = AppQDialogButtonBox(QtCore.Qt.Orientation.Horizontal)
self.dialogBtns.addButton(_('OK'), AppQDialogButtonBox.ButtonRole.AcceptRole)
self.dialogBtns.addButton(
_('Cancel'),
AppQDialogButtonBox.ButtonRole.RejectRole,
)
connectWeakly(self.dialogBtns.accepted, self, 'accept')
connectWeakly(self.dialogBtns.rejected, self, 'reject')
layout = QFormLayout()
layout.addRow(AppQLabel(_('Remark')), self.remarkEdit)
layout.addRow(self.dialogBtns)
self.setLayout(layout)
def remark(self):
"""Return the remark value used by the routing remark edit dialog."""
return self.remarkEdit.text().strip()
class RoutingProfileEditDialog(AppQTransientDialog):
"""Present the routing profile edit dialog."""
DEFAULT_DIALOG_SIZE = QtCore.QSize(460, 160)
def __init__(self, parent=None):
"""Initialize the RoutingProfileEditDialog."""
super().__init__(parent)
self.setWindowTitle(_('Add Routing'))
self.setWindowModality(QtCore.Qt.WindowModality.WindowModal)
self.remarkEdit = AppQLineEdit(_('New Routing'))
self.domainStrategyCombo = AppQComboBox(translatable=False)
self.domainStrategyCombo.addItems(DOMAIN_STRATEGIES)
self.domainStrategyCombo.setCurrentText('AsIs')
self.enabledCombo = AppQComboBox(translatable=False)
addRoutingStateItems(self.enabledCombo)
self.enabledCombo.setCurrentIndex(ROUTING_STATES.index('Enabled'))
self.dialogBtns = AppQDialogButtonBox(QtCore.Qt.Orientation.Horizontal)
self.dialogBtns.addButton(_('OK'), AppQDialogButtonBox.ButtonRole.AcceptRole)
self.dialogBtns.addButton(
_('Cancel'),
AppQDialogButtonBox.ButtonRole.RejectRole,
)
connectWeakly(self.dialogBtns.accepted, self, 'accept')
connectWeakly(self.dialogBtns.rejected, self, 'reject')
layout = QFormLayout()
layout.addRow(AppQLabel(_('Remark')), self.remarkEdit)
layout.addRow(
AppQLabel('Domain Strategy', translatable=False),
self.domainStrategyCombo,
)
layout.addRow(AppQLabel(_('State')), self.enabledCombo)
layout.addRow(self.dialogBtns)
self.setLayout(layout)
def routing(self):
"""Return the routing value used by the routing profile edit dialog."""
return {
'remark': self.remarkEdit.text().strip() or _('New Routing'),
'domainStrategy': self.domainStrategyCombo.currentText(),
'enabled': comboCurrentData(self.enabledCombo, 'Enabled') == 'Enabled',
'rules': [],
}
class RoutingRulesModel(QtCore.QAbstractListModel):
"""Expose the live routing rule list in backend matching order."""
def __init__(self, routing: dict, parent=None):
super().__init__(parent)
self.routing = routing
if not isinstance(routing.get('rules'), list):
routing['rules'] = []
def rules(self):
return self.routing['rules']
def rowCount(self, parent=QtCore.QModelIndex()):
return 0 if parent.isValid() else len(self.rules())
def flags(self, index):
flags = super().flags(index)
if index.isValid():
return flags | QtCore.Qt.ItemFlag.ItemIsDragEnabled
# Only insertion between rows is meaningful for a flat rule list.
return flags | QtCore.Qt.ItemFlag.ItemIsDropEnabled
def supportedDropActions(self):
return QtCore.Qt.DropAction.MoveAction
def data(self, index, role=QtCore.Qt.ItemDataRole.DisplayRole):
if index.isValid() and 0 <= index.row() < self.rowCount():
if role == QtCore.Qt.ItemDataRole.DisplayRole:
return self.ruleText(self.rules()[index.row()])
return None
@staticmethod
def ruleText(rule: dict) -> str:
"""Preview each filled destination field within a shared text budget."""
name = rule.get('ruleTag', '') or 'Untitled Rule'
outbound = rule.get('outboundTag', 'proxy')
fields = []
# Use the same field names and order as the Destination Match editor.
for key, label in (
('domain', 'domain'),
('ip', 'ip'),
('port', 'port'),
('vlessRoute', 'vless route'),
):
value = rule.get(key)
if value is None or value == '' or value == []:
continue
extra = ''
if isinstance(value, list):
extra = f' +{len(value) - 1}' if len(value) > 1 else ''
value = value[0]
text = str(value).strip()
if text:
fields.append((label, text, extra))
prefix = f'{name} -> {outbound}'
if not fields:
return prefix
# Reserve room for every label: a long domain must not crowd out Port
# or VLESS Route. The view's native elision handles narrower windows.
overhead = sum(len(label) + 2 for label, _text, _extra in fields)
overhead += 2 * (len(fields) - 1)
valueBudget = min(48, (96 - overhead) // len(fields))
previews = []
for label, text, extra in fields:
limit = max(1, valueBudget - len(extra))
preview = ' '.join(text[: limit + 1].split())
if len(text) > limit:
preview = preview[: limit - 1].rstrip() + '…'
previews.append(f'{label}: {preview}{extra}')
return f'{prefix} ({"; ".join(previews)})'
def moveRows(
self, sourceParent, sourceRow, count, destinationParent, destinationChild
):
"""Move the actual rules; Qt retains selection and persistent indexes."""
if (
sourceParent.isValid()
or destinationParent.isValid()
or count <= 0
or sourceRow < 0
or sourceRow + count > self.rowCount()
or not 0 <= destinationChild <= self.rowCount()
or sourceRow <= destinationChild <= sourceRow + count
):
return False
if not self.beginMoveRows(
sourceParent,
sourceRow,
sourceRow + count - 1,
destinationParent,
destinationChild,
):
return False
rules = self.rules()
moved = rules[sourceRow : sourceRow + count]
del rules[sourceRow : sourceRow + count]
insertion = (
destinationChild - count
if destinationChild > sourceRow
else destinationChild
)
rules[insertion:insertion] = moved
self.endMoveRows()
return True
def appendRule(self, rule):
row = self.rowCount()
self.beginInsertRows(QtCore.QModelIndex(), row, row)
self.rules().append(rule)
self.endInsertRows()
def setRule(self, row, rule):
self.rules()[row] = rule
index = self.index(row, 0)
self.dataChanged.emit(index, index, [QtCore.Qt.ItemDataRole.DisplayRole])
def deleteRules(self, rows):
for row in sorted(set(rows), reverse=True):
if 0 <= row < self.rowCount():
self.beginRemoveRows(QtCore.QModelIndex(), row, row)
self.rules().pop(row)
self.endRemoveRows()
class RoutingRulesListView(AppQListView):
"""Provide the model-based routing rules list."""
editRequested = QtCore.Signal()
def __init__(self, routing: dict, parent=None):
"""Initialize the routing rules list view."""
super().__init__(parent)
self.routing = routing
self.rulesModel = RoutingRulesModel(routing, parent=self)
self.setModel(self.rulesModel)
self.setAlternatingRowColors(True)
self.setTextElideMode(QtCore.Qt.TextElideMode.ElideRight)
self.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
self.setSelectionBehavior(AppQListView.SelectionBehavior.SelectRows)
self.setSelectionMode(AppQListView.SelectionMode.ExtendedSelection)
self.setEditTriggers(AppQListView.EditTrigger.NoEditTriggers)
self.setDragDropMode(AppQListView.DragDropMode.InternalMove)
self.setDragDropOverwriteMode(False)
self.setDropIndicatorShown(True)
self.setDefaultDropAction(QtCore.Qt.DropAction.MoveAction)
connectWeakly(self.doubleClicked, self, '_requestEdit')
@QtCore.Slot(QtCore.QModelIndex)
def _requestEdit(self, _index):
"""Forward a row double-click without retaining a Python closure."""
self.editRequested.emit()
def rules(self):
"""Return the rules represented by the routing list."""
return self.rulesModel.rules()
def ruleAt(self, index: int):
"""Return the rule represented by one list row."""
return self.rules()[index]
def ruleText(self, rule: dict) -> str:
"""Return the display text for one routing rule."""
return self.rulesModel.ruleText(rule)
def selectedRuleText(self):
"""Select ed rule text."""
indexes = self.selectedIndex
if not indexes:
return ''
return self.ruleText(self.ruleAt(indexes[0]))
def appendRule(self, rule: dict):
"""Append rule."""
self.rulesModel.appendRule(rule)
def setRule(self, index: int, rule: dict):
"""Set rule."""
self.rulesModel.setRule(index, rule)
def deleteRules(self, indexes: list[int]):
"""Delete rules."""
self.rulesModel.deleteRules(indexes)
def moveSelectedRules(self, position: str):
"""Move selected rows with Home's stable multi-selection semantics."""
rows = self.selectedIndex
if not rows or position not in ('up', 'down'):
return
backwards = position == 'down'
boundary = self.rulesModel.rowCount() - 1 if backwards else 0
root = QtCore.QModelIndex()
# Work toward the destination edge so unprocessed source rows keep
# their indexes. Adjacent selected rows never cross each other.
for row in reversed(rows) if backwards else rows:
if position == 'up':
target = max(boundary, row - 1)
else:
target = min(boundary, row + 1)
destination = target + 1 if target > row else target
self.rulesModel.moveRow(root, row, root, destination)
boundary = target - 1 if backwards else target + 1
self.scrollTo(self.currentIndex())
self.setFocus()
class RoutingRulesDialog(AppQTransientDialog):
"""Present the routing rules dialog."""
FIXED_DIALOG_SIZE = QtCore.QSize(760, 470)
def __init__(self, routing: dict, parent=None):
"""Initialize the RoutingRulesDialog."""
super().__init__(parent)
self.routing = routing
self.setWindowTitle(_('Routing Rules'))
self.setWindowModality(QtCore.Qt.WindowModality.WindowModal)
self.listView = RoutingRulesListView(self.routing, parent=self)
connectWeakly(self.listView.editRequested, self, 'editRule')
self.addButton = AppQPushButton(
_('Add'),
icon=bootstrapIcon('plus-lg.svg'),
)
connectWeakly(self.addButton.clicked, self, 'addRule')
self.deleteButton = AppQPushButton(
_('Delete'),
icon=bootstrapIcon('trash.svg'),
)
connectWeakly(self.deleteButton.clicked, self, 'deleteRule')
self.moveMenu = AppQMenu(parent=self)
for text, position, key in (
(_('Move Up'), 'up', QtCore.Qt.Key.Key_Up),
(_('Move Down'), 'down', QtCore.Qt.Key.Key_Down),
):
action = AppQAction(text, parent=self.moveMenu)
action.setData(position)
action.setShortcut(
QtCore.QKeyCombination(QtCore.Qt.KeyboardModifier.ControlModifier, key)
)
action.setShortcutContext(QtCore.Qt.ShortcutContext.WidgetShortcut)
self.moveMenu.addAction(action)
self.listView.addAction(action)
connectWeakly(self.moveMenu.triggered, self, '_moveRules')
self.moveButton = AppQMenuPushButton(
_('Move'),
icon=bootstrapIcon('arrows-move.svg'),
popupMenu=self.moveMenu,
parent=self,
)
self.closeWindowButton = AppQPushButton(
_('Close Window'),
icon=bootstrapIcon('window-x.svg'),
)
connectWeakly(self.closeWindowButton.clicked, self, 'close')
for button in (
self.addButton,
self.deleteButton,
self.moveButton,
self.closeWindowButton,
):
button.setAutoDefault(False)
button.setDefault(False)
actionLayout = QHBoxLayout()
actionLayout.setContentsMargins(0, 0, 0, 0)
actionLayout.setSpacing(8)
actionLayout.addWidget(self.addButton)
actionLayout.addWidget(self.deleteButton)
actionLayout.addWidget(self.moveButton)
actionLayout.addStretch(1)
actionLayout.addWidget(self.closeWindowButton)
layout = QVBoxLayout()
layout.setContentsMargins(20, 18, 20, 20)
layout.setSpacing(14)
layout.addLayout(actionLayout)
layout.addWidget(self.listView)
self.setLayout(layout)
@QtCore.Slot(QAction)
def _moveRules(self, action):
"""Apply the same model mutation for menu and keyboard activation."""
self.listView.moveSelectedRules(action.data())
def addRule(self):
"""Add rule."""
rule = {'type': 'field'}
# This editor is subordinate to the transient rules dialog. Parenting
# it prevents an asynchronous child from outliving its owner and later
# invoking a callback on a deleted list widget.
dialog = RoutingRuleEditDialog(rule, parent=self)
def handleResultCode(code):
"""Handle result code."""
if code == PySide6Legacy.enumValueWrapper(AppQDialog.DialogCode.Accepted):
self.listView.appendRule(dialog.routingRule())
dialog.finished.connect(handleResultCode)
dialog.open()
def editRule(self):
"""Handle edit rule for the routing rules dialog."""
indexes = self.listView.selectedIndex
if len(indexes) != 1:
return
index = QtCore.QPersistentModelIndex(
self.listView.rulesModel.index(indexes[0], 0)
)
dialog = RoutingRuleEditDialog(self.listView.ruleAt(index.row()), parent=self)
def handleResultCode(_index, code):
"""Handle result code."""
if (
code == PySide6Legacy.enumValueWrapper(AppQDialog.DialogCode.Accepted)
and _index.isValid()
):
self.listView.setRule(_index.row(), dialog.routingRule())
dialog.finished.connect(functools.partial(handleResultCode, index))
dialog.open()
def deleteRule(self):
"""Delete rule."""
indexes = self.listView.selectedIndex
if len(indexes) == 0:
return
indexes = [
QtCore.QPersistentModelIndex(self.listView.rulesModel.index(row, 0))
for row in indexes
]
def handleResultCode(_indexes, code):
"""Handle result code."""
if code == PySide6Legacy.enumValueWrapper(
AppQMessageBox.StandardButton.Yes
):
self.listView.deleteRules(
[index.row() for index in _indexes if index.isValid()]
)
else:
# Do not delete
pass
if PLATFORM == 'Windows':
# Windows
mbox = MBoxQuestionDelete(icon=AppQMessageBox.Icon.Question)
else:
# macOS & linux
mbox = MBoxQuestionDelete(
icon=AppQMessageBox.Icon.Question,
parent=self,
)
mbox.setWindowModality(QtCore.Qt.WindowModality.WindowModal)
mbox.isMulti = bool(len(indexes) > 1)
mbox.possibleRemark = self.listView.selectedRuleText()
mbox.setText(mbox.customText())
mbox.finished.connect(functools.partial(handleResultCode, indexes))
# Show the MessageBox asynchronously
mbox.open()
class UserRoutingQTableViewHorizontalHeader(AppQHeaderView):
"""Provide the user routing Qt table view horizontal table header."""
def __init__(self, *args, **kwargs):
"""Initialize the UserRoutingQTableViewHorizontalHeader."""
super().__init__(QtCore.Qt.Orientation.Horizontal, *args, **kwargs)
class UserRoutingTableView(Mixins.QTranslatable, AppQTableView):
"""Represent user routing table view."""
RowHeight = 42
def __init__(self, parent=None):
"""Initialize the UserRoutingTableView."""
super().__init__(parent)
self.sourceModel = RoutingProfilesModel(parent=self)
self.setModel(self.sourceModel)
self.setHorizontalHeader(
UserRoutingQTableViewHorizontalHeader(
parent=self,
sectionSizeSettingsName='UserRoutingHeaderViewState',
)
)
self.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
self.setDragDropMode(QAbstractItemView.DragDropMode.NoDragDrop)
self.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
self.setSortingEnabled(False)
self.doubleClicked.connect(self.editSelectedRules)
self.setDefaultRowHeight(self.RowHeight)
self.configureHeader()
self.flushAll()
def configureHeader(self):
"""Configure header."""
header = self.horizontalHeader()
header.setSectionsMovable(True)
header.setFirstSectionMovable(True)
header.setCustomSectionResizeMode()
header.restoreSectionSize()
@property
def selectedIndex(self):
"""Return the selected index value."""
return sorted(
list(set(index.row() for index in self.selectionModel().selectedRows()))
)
def routingUniqueByRow(self, row):
"""Return the routing unique by row value used by the user routing table view."""
return self.sourceModel.routingUniqueByRow(row)
def flushItem(self, row: int, column: int):
"""Refresh item."""
index = self.sourceModel.index(row, column)
if column == 1:
combo = AppQComboBox(parent=self.viewport(), translatable=False)
combo.addItems(DOMAIN_STRATEGIES)
combo.setMinimumWidth(0)
combo.setCurrentText(
self.sourceModel.routingByRow(row).get('domainStrategy', 'AsIs')
)
combo.currentTextChanged.connect(
lambda text, _row=row: self.setDomainStrategy(_row, text)
)
self.setIndexWidget(index, combo)
elif column == 2:
combo = AppQComboBox(parent=self.viewport(), translatable=False)
addRoutingStateItems(combo)
combo.setMinimumWidth(0)
combo.setCurrentIndex(
ROUTING_STATES.index(
'Enabled'
if self.sourceModel.routingByRow(row).get('enabled', True)
else 'Disabled'
)
)
combo.currentIndexChanged.connect(
lambda _index, _row=row, _combo=combo: self.setEnabled(
_row,
comboCurrentData(_combo, 'Enabled'),
)
)
self.setIndexWidget(index, combo)
def flushRow(self, row: int):
"""Refresh row."""
for column in range(self.sourceModel.columnCount()):
self.flushItem(row, column)
def flushAll(self):
"""Refresh all."""
self.sourceModel.emitAllChanged()
for row in range(self.sourceModel.rowCount()):
self.flushRow(row)
def retranslate(self):
"""Refresh translated text for the user routing table view."""
self.sourceModel.headerDataChanged.emit(
QtCore.Qt.Orientation.Horizontal,
0,
self.sourceModel.columnCount() - 1,
)
self.flushAll()
def setDomainStrategy(self, row: int, text: str):
"""Set domain strategy."""
if row < 0 or row >= self.sourceModel.rowCount():
return
self.sourceModel.routingByRow(row)['domainStrategy'] = text
self.sourceModel.emitAllChanged()
def setEnabled(self, row: int, state: str):
"""Set enabled."""
if row < 0 or row >= self.sourceModel.rowCount():
return
self.sourceModel.routingByRow(row)['enabled'] = state == 'Enabled'
self.sourceModel.emitAllChanged()
def appendNewItem(self):
"""Append new item."""
dialog = RoutingProfileEditDialog(parent=self)
def handleResultCode(code):
"""Handle result code."""
if code != PySide6Legacy.enumValueWrapper(AppQDialog.DialogCode.Accepted):
return
row = self.sourceModel.rowCount()
unique = str(uuid.uuid4())
self.sourceModel.beginInsertRows(QtCore.QModelIndex(), row, row)
Storage.UserRoutings()[unique] = dialog.routing()
self.sourceModel.endInsertRows()
self.flushRow(row)
dialog.finished.connect(handleResultCode)
dialog.open()
def deleteSelectedItem(self):
"""Delete selected item."""
indexes = self.selectedIndex
if not indexes:
# Nothing selected. Do nothing
return
def handleResultCode(_indexes, code):
"""Handle result code."""
if code != PySide6Legacy.enumValueWrapper(
AppQMessageBox.StandardButton.Yes
):
return
for i in range(len(_indexes)):
deleteIndex = _indexes[i] - i
deleteUnique = self.routingUniqueByRow(deleteIndex)
self.sourceModel.beginRemoveRows(
QtCore.QModelIndex(),
deleteIndex,
deleteIndex,
)
Storage.UserRoutings().pop(deleteUnique)
self.sourceModel.endRemoveRows()
self.flushAll()
if PLATFORM == 'Windows':
# Windows
mbox = MBoxQuestionDelete(icon=AppQMessageBox.Icon.Question)
else:
# macOS & linux
mbox = MBoxQuestionDelete(
icon=AppQMessageBox.Icon.Question,
parent=self,
)
mbox.setWindowModality(QtCore.Qt.WindowModality.WindowModal)
mbox.isMulti = bool(len(indexes) > 1)
mbox.possibleRemark = self.sourceModel.data(
self.sourceModel.index(indexes[0], 0),
QtCore.Qt.ItemDataRole.DisplayRole,
)
mbox.setText(mbox.customText())
mbox.finished.connect(functools.partial(handleResultCode, indexes))
# Show the MessageBox asynchronously
mbox.open()
def renameSelectedItem(self):
"""Handle rename selected item for the user routing table view."""
indexes = self.selectedIndex
if not indexes:
# Nothing selected. Do nothing
return
if len(indexes) > 1:
# Multiple items selected. Do nothing
return
routing = self.sourceModel.routingByRow(indexes[0])
dialog = RoutingRemarkEditDialog(routing.get('remark', ''), parent=self)
def handleResultCode(code):
"""Handle result code."""
if code != PySide6Legacy.enumValueWrapper(AppQDialog.DialogCode.Accepted):
return
remark = dialog.remark()
if remark:
routing['remark'] = remark
self.sourceModel.emitAllChanged()
dialog.finished.connect(handleResultCode)
dialog.open()
def previewSelectedItem(self):
"""Handle preview selected item for the user routing table view."""
indexes = self.selectedIndex
if len(indexes) != 1:
return
routing = copy.deepcopy(self.sourceModel.routingByRow(indexes[0]))
dialog = RoutingPreviewDialog(routing, parent=self)
dialog.open()
def editSelectedRules(self):
"""Handle edit selected rules for the user routing table view."""
indexes = self.selectedIndex
if not indexes:
# Nothing selected. Do nothing
return
if len(indexes) > 1:
# Multiple items selected. Do nothing
return
routing = self.sourceModel.routingByRow(indexes[0])
dialog = RoutingRulesDialog(routing, parent=self)
connectWeakly(
dialog.finished,
self,
'_rulesDialogFinished',
sender=dialog,
)
dialog.open()
@QtCore.Slot(int)
def _rulesDialogFinished(self, _code):
"""Refresh routing presentation after the rules dialog finishes."""
self.flushAll()
class XrayRoutingWindow(AppQMainWindow):
"""Present the user routing window."""
DEFAULT_WINDOW_SIZE = QtCore.QSize(980, 560)
def __init__(self, *args, **kwargs):
"""Initialize the Xray routing editor window."""
super().__init__(*args, **kwargs)
self.setWindowTitle(_('Edit Routing'))
self.tableView = UserRoutingTableView(parent=self)
self.addButton, self.previewButton, self.renameButton, self.deleteButton = (
AppQPushButton(
_('Add'),
icon=bootstrapIcon('plus-lg.svg'),
),
AppQPushButton(
_('Preview'),
icon=bootstrapIcon('file-earmark-text.svg'),
),
AppQPushButton(
_('Rename'),
icon=bootstrapIcon('pencil-square.svg'),
),
AppQPushButton(
_('Delete'),
icon=bootstrapIcon('trash.svg'),
),
)
for button, methodName in (
(self.addButton, 'appendNewItem'),
(self.previewButton, 'previewSelectedItem'),
(self.renameButton, 'renameSelectedItem'),
(self.deleteButton, 'deleteSelectedItem'),
):
connectWeakly(
button.clicked,
self.tableView,
methodName,
sender=button,
)
actionLayout = QHBoxLayout()
actionLayout.setContentsMargins(0, 0, 0, 0)
actionLayout.setSpacing(8)
actionLayout.addWidget(self.addButton)
actionLayout.addWidget(self.previewButton)
actionLayout.addWidget(self.renameButton)
actionLayout.addWidget(self.deleteButton)
actionLayout.addStretch(1)
centralWidget = QWidget()
centralWidget.setObjectName('UserRoutingWindowContent')
layout = QVBoxLayout(centralWidget)
layout.setContentsMargins(20, 18, 20, 20)
layout.setSpacing(14)
layout.addLayout(actionLayout)
layout.addWidget(self.tableView)
self.setCentralWidget(centralWidget)
def prepareInitialGeometry(self):
"""Restore routing-window geometry and state before its first show."""
savedGeometry = AppSettings.get('UserRoutingWindowGeometry')
if savedGeometry is None:
self.resize(self.DEFAULT_WINDOW_SIZE)
else:
try:
restored = self.restoreInitialGeometry(savedGeometry)
except Exception:
# Any non-exit exceptions
logger.exception('unexpected routing-window geometry restore failure')
restored = False
if not restored:
logger.warning(
'saved routing-window geometry was invalid and was ignored'
)
self.resize(self.DEFAULT_WINDOW_SIZE)
savedState = AppSettings.get('UserRoutingWindowState')
if savedState is None:
return
try:
restored = self.restoreState(savedState)
except Exception:
# Any non-exit exceptions
logger.exception('unexpected routing-window state restore failure')
return
if not restored:
logger.warning('saved routing-window state was invalid and was ignored')
def cleanup(self):
"""Release resources owned by the user routing window."""
# Settings eagerly owns this reusable editor even when Edit Routing was
# never opened. Saving then would replace the user's geometry with Qt's
# tiny pre-show placeholder, so leave the persisted state untouched.
if not self.hasPreparedInitialGeometry():
return
AppSettings.set('UserRoutingWindowGeometry', self.saveGeometry())
AppSettings.set('UserRoutingWindowState', self.saveState())