From d87a69bcb0aa2ac2e4826f7721ff105f5d6652a5 Mon Sep 17 00:00:00 2001 From: Loren Eteval Date: Tue, 25 Aug 2026 19:56:51 +0800 Subject: [PATCH] Refine External Core editor layout Signed-off-by: Loren Eteval --- Furious/Backends/ExternalCore/Editor.py | 124 +++++++++++++----------- Furious/Externals/GenTranslation.py | 1 + tests/test_ui_behavior.py | 112 ++++++++++++++++++--- 3 files changed, 164 insertions(+), 73 deletions(-) diff --git a/Furious/Backends/ExternalCore/Editor.py b/Furious/Backends/ExternalCore/Editor.py index b86e5e5..ca509fa 100644 --- a/Furious/Backends/ExternalCore/Editor.py +++ b/Furious/Backends/ExternalCore/Editor.py @@ -19,7 +19,6 @@ from __future__ import annotations -from Furious.Frozenlib import GOLDEN_RATIO from Furious.Interface import EditorWidgetBinding from Furious.Models import CoreConfiguration, ServerProfile from Furious.Qt import ( @@ -34,6 +33,8 @@ from Furious.Qt import ( GuiEditorItemTextSpinBox, GuiEditorWidgetQDialog, GuiEditorWidgetQGroupBox, + addEditorGridBinding, + addEditorGridFullRow, ) from Furious.Qt import gettext as _ from Furious.Qt.Signals import connectWeakly @@ -41,11 +42,11 @@ from Furious.Qt.Signals import connectWeakly from PySide6 import QtCore from PySide6.QtWidgets import ( QFileDialog, - QFormLayout, + QGridLayout, QHBoxLayout, QPlainTextEdit, QSizePolicy, - QVBoxLayout, + QSpinBox, QWidget, ) @@ -211,7 +212,6 @@ class ExternalCoreEnvironmentInput(EditorWidgetBinding): self._title = AppQLabel(_('Environment Variables'), translatable=True) self._input = QPlainTextEdit() - self._input.setMaximumWidth(520) self._input.setStyleSheet('min-height: 120px; max-height: 136px;') self._input.setPlaceholderText(_('KEY=VALUE, one per line')) @@ -315,15 +315,12 @@ class ExternalCoreApplicationTun2socksInput(GuiEditorItemTextSwitch): class ExternalCoreTunRemoteAddressInput(EditorWidgetBinding): """Bind the remote destination used only by application-managed TUN.""" - MaximumWidth = 420 - def __init__(self): """Initialize a hostname-or-IP input independent of process paths.""" super().__init__() self._title = AppQLabel(_('TUN Remote Address'), translatable=True) self._input = AppQLineEdit() - self._input.setMaximumWidth(self.MaximumWidth) self._input.setPlaceholderText(_('Hostname, IPv4, or IPv6 address')) def widgets(self): @@ -349,39 +346,23 @@ class ExternalCoreTunRemoteAddressInput(EditorWidgetBinding): class ExternalCoreConfigurationGroup(GuiEditorWidgetQGroupBox): - """Present all External Core settings as one compact form.""" - - RemarkWidth = 420 - ProxyEndpointWidth = 360 + """Present External Core profile and process settings.""" def __init__( self, argumentsInput: ExternalCoreArgumentsInput, environmentInput: ExternalCoreEnvironmentInput, - applicationTun2socksInput: ExternalCoreApplicationTun2socksInput, - tunRemoteAddressInput: ExternalCoreTunRemoteAddressInput, ): - """Initialize the form around its shared validated inputs.""" + """Initialize the process form around its shared validated inputs.""" self._argumentsInput = argumentsInput self._environmentInput = environmentInput - self._applicationTun2socksInput = applicationTun2socksInput - self._tunRemoteAddressInput = tunRemoteAddressInput super().__init__(_('Basic Configuration')) def containerSequence(self): - """Return related profile, process, and endpoint fields in order.""" - remarkInput = GuiEditorItemBasicRemark(title=_('Remark')) - remarkInput.widgets()[1].setMaximumWidth(self.RemarkWidth) - - httpProxyInput = GuiEditorItemProxyHttp(title=_('HTTP Proxy')) - httpProxyInput.widgets()[1].setMaximumWidth(self.ProxyEndpointWidth) - - socksProxyInput = GuiEditorItemProxySocks(title=_('SOCKS Proxy')) - socksProxyInput.widgets()[1].setMaximumWidth(self.ProxyEndpointWidth) - - self._processContainers = ( - remarkInput, + """Return profile and process fields in display order.""" + return ( + GuiEditorItemBasicRemark(title=_('Remark')), ExternalCorePathInput(_('Executable'), 'executable'), ExternalCorePathInput( _('Working Directory'), @@ -392,35 +373,64 @@ class ExternalCoreConfigurationGroup(GuiEditorWidgetQGroupBox): self._argumentsInput, self._environmentInput, ) - self._runtimeContainers = ( + + def setupPageLayout(self): + """Arrange process fields as full rows like the VLESS remark row.""" + layout = QGridLayout() + layout.setColumnStretch(1, 1) + + for row, container in enumerate(self._containers): + addEditorGridFullRow(layout, container, row) + + layout.setRowStretch(len(self._containers), 1) + + return layout + + +class ExternalCoreOtherGroup(GuiEditorWidgetQGroupBox): + """Present the remaining External Core process and network settings.""" + + def __init__( + self, + applicationTun2socksInput: ExternalCoreApplicationTun2socksInput, + tunRemoteAddressInput: ExternalCoreTunRemoteAddressInput, + ): + """Initialize the secondary form around its shared validated inputs.""" + self._applicationTun2socksInput = applicationTun2socksInput + self._tunRemoteAddressInput = tunRemoteAddressInput + + super().__init__(_('Other')) + + def containerSequence(self): + """Return secondary process, proxy, and TUN fields in display order.""" + return ( ExternalCoreShutdownTimeoutInput(), - httpProxyInput, - socksProxyInput, + GuiEditorItemProxyHttp(title=_('HTTP Proxy')), + GuiEditorItemProxySocks(title=_('SOCKS Proxy')), self._applicationTun2socksInput, self._tunRemoteAddressInput, ) - return [*self._processContainers, *self._runtimeContainers] - - @staticmethod - def _formLayout(containers): - """Align one related section independently from the other section.""" - layout = QFormLayout() - layout.setContentsMargins(0, 0, 0, 0) - layout.setFormAlignment(QtCore.Qt.AlignmentFlag.AlignLeft) - layout.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.ExpandingFieldsGrow) - - for container in containers: - layout.addRow(*container.widgets()) - - return layout - def setupPageLayout(self): - """Keep process fields independent from runtime-network label widths.""" - layout = QVBoxLayout() - layout.setSpacing(18) - layout.addLayout(self._formLayout(self._processContainers)) - layout.addLayout(self._formLayout(self._runtimeContainers)) + """Arrange secondary fields and keep the timeout compact like VLESS port.""" + layout = QGridLayout() + layout.setColumnStretch(1, 1) + + timeoutInput = self._containers[0].widgets()[1] + + if isinstance(timeoutInput, QSpinBox): + timeoutInput.setSizePolicy( + QSizePolicy.Policy.Fixed, + timeoutInput.sizePolicy().verticalPolicy(), + ) + + for row, container in enumerate(self._containers): + if row in (1, 2, 4): + addEditorGridFullRow(layout, container, row) + else: + addEditorGridBinding(layout, container, row, 0) + + layout.setRowStretch(len(self._containers), 1) return layout @@ -440,24 +450,22 @@ class ExternalCoreEditor(GuiEditorWidgetQDialog): ) self._tunRemoteAddressInput.setEnabled(False) - kwargs.setdefault('style', 'portrait') - super().__init__(*args, **kwargs) - dialogWidth = 540 - - self.setFixedSize(dialogWidth, int(dialogWidth * GOLDEN_RATIO)) + self.setFixedSize(1400, 600) self.setTabText(_('External Core')) def createGroupBoxSequence(self): - """Create the editor's single compact configuration form.""" + """Create separate basic and secondary configuration groups.""" return [ ExternalCoreConfigurationGroup( self._argumentsInput, self._environmentInput, + ), + ExternalCoreOtherGroup( self._applicationTun2socksInput, self._tunRemoteAddressInput, - ) + ), ] def inputToFactory(self, config: CoreConfiguration) -> bool: diff --git a/Furious/Externals/GenTranslation.py b/Furious/Externals/GenTranslation.py index 7c035bc..0565e2a 100644 --- a/Furious/Externals/GenTranslation.py +++ b/Furious/Externals/GenTranslation.py @@ -1114,6 +1114,7 @@ TRANSLATION = { }, "Other": { "source": [ + "Furious.Backends.ExternalCore.Editor", "Furious.Backends.Hysteria1.Editor", "Furious.Backends.Hysteria2.Editor" ], diff --git a/tests/test_ui_behavior.py b/tests/test_ui_behavior.py index 3d581de..4521a02 100644 --- a/tests/test_ui_behavior.py +++ b/tests/test_ui_behavior.py @@ -27,7 +27,15 @@ from Furious.Backends.ExternalCore.Configuration import ( BLANK_CONFIG_EXTERNAL_CORE, ConfigExternalCore, ) -from Furious.Backends.ExternalCore.Editor import ExternalCoreEditor +from Furious.Backends.ExternalCore.Editor import ( + ExternalCoreApplicationTun2socksInput, + ExternalCoreArgumentsInput, + ExternalCoreConfigurationGroup, + ExternalCoreEditor, + ExternalCoreEnvironmentInput, + ExternalCoreOtherGroup, + ExternalCoreTunRemoteAddressInput, +) from Furious.Backends.Hysteria1.Editor import Hysteria1Editor from Furious.Backends.Hysteria2.Editor import Hysteria2Editor from Furious.Backends.Xray.AssetListView import XrayAssetListView @@ -104,6 +112,7 @@ from PySide6.QtTest import QTest from PySide6.QtWidgets import ( QComboBox, QHBoxLayout, + QSizePolicy, QStyle, QStyleOptionComboBox, QVBoxLayout, @@ -1007,8 +1016,7 @@ class EditorMappingTest(unittest.TestCase): self.assertEqual(tunSwitch.size(), AppQSwitch.CompactControlSize) self.assertTrue(editor._tunRemoteAddressInput.widgets()[1].isEnabled()) self.assertEqual(editor._tunRemoteAddressInput.text(), '2001:db8::42') - self.assertEqual(len(editor.groupBoxSequence()), 1) - self.assertAlmostEqual(editor.height() / editor.width(), 1.618, places=2) + self.assertEqual(len(editor.groupBoxSequence()), 2) editor._argumentsInput._input.setText( '--mode direct --label "a value with spaces"' @@ -1037,28 +1045,102 @@ class EditorMappingTest(unittest.TestCase): editor.close() def testExternalCoreSectionUsesCanonicalEditorInset(self): - """Let the section page own the same outer inset as Xray editors.""" + """Use canonical editor grids and insets for both External Core groups.""" editor = ExternalCoreEditor() - externalGroup = editor.groupBoxSequence()[0] - externalLayout = externalGroup._widget.currentWidget().layout() + externalGroups = editor.groupBoxSequence() referenceGroup = GuiVLESSGroupBoxBasic() referenceLayout = referenceGroup._widget.currentWidget().layout() + self.assertEqual(len(externalGroups), 2) + + for group in externalGroups: + externalLayout = group._widget.currentWidget().layout() + + self.assertEqual( + externalLayout.contentsMargins(), + referenceLayout.contentsMargins(), + ) + self.assertFalse(externalLayout.contentsMargins().isNull()) + self.assertIsInstance(externalLayout, type(referenceLayout)) + self.assertEqual(externalLayout.columnStretch(1), 1) + self.assertEqual( + externalLayout.verticalSpacing(), + referenceLayout.verticalSpacing(), + ) + self.assertEqual( + externalLayout.rowStretch(externalLayout.rowCount() - 1), + 1, + ) + + basicGroup, otherGroup = externalGroups + basicLayout = basicGroup._widget.currentWidget().layout() + otherLayout = otherGroup._widget.currentWidget().layout() + timeoutInput = otherGroup._containers[0].widgets()[1] + + for row, container in enumerate(basicGroup._containers): + self.assertBindingPosition(basicLayout, container, row, 0, 1, 3) + self.assertEqual( - externalLayout.contentsMargins(), - referenceLayout.contentsMargins(), + timeoutInput.sizePolicy().horizontalPolicy(), + QSizePolicy.Policy.Fixed, ) - self.assertFalse(externalLayout.contentsMargins().isNull()) - - for index in range(externalLayout.count()): - childLayout = externalLayout.itemAt(index).layout() - - self.assertIsNotNone(childLayout) - self.assertTrue(childLayout.contentsMargins().isNull()) + self.assertBindingPosition(otherLayout, otherGroup._containers[0], 0, 0, 1) + self.assertBindingPosition(otherLayout, otherGroup._containers[1], 1, 0, 1, 3) + self.assertBindingPosition(otherLayout, otherGroup._containers[2], 2, 0, 1, 3) + self.assertBindingPosition(otherLayout, otherGroup._containers[4], 4, 0, 1, 3) + self.assertGreater(basicGroup._containers[-1].widgets()[1].maximumWidth(), 520) + self.assertGreater(otherGroup._containers[1].widgets()[1].maximumWidth(), 360) + self.assertGreater(otherGroup._containers[2].widgets()[1].maximumWidth(), 360) + self.assertGreater(otherGroup._containers[4].widgets()[1].maximumWidth(), 420) + self.assertEqual(editor.size(), QtCore.QSize(1400, 600)) editor.close() referenceGroup.deleteLater() + def testExternalCoreRowsRemainTopAlignedAsSectionGrows(self): + """Assign surplus editor height below the compact form rows.""" + groups = ( + ExternalCoreConfigurationGroup( + ExternalCoreArgumentsInput(), + ExternalCoreEnvironmentInput(), + ), + ExternalCoreOtherGroup( + ExternalCoreApplicationTun2socksInput(), + ExternalCoreTunRemoteAddressInput(), + ), + ) + + for group in groups: + containers = group._containers + + group.resize(760, 900) + group.show() + processQtEvents() + + initialPositions = tuple( + container.widgets()[0].geometry().top() for container in containers + ) + page = group._widget.currentWidget() + initialBlankHeight = ( + page.height() - containers[-1].widgets()[0].geometry().bottom() + ) + + group.resize(760, 1200) + processQtEvents() + + grownPositions = tuple( + container.widgets()[0].geometry().top() for container in containers + ) + grownBlankHeight = ( + page.height() - containers[-1].widgets()[0].geometry().bottom() + ) + + self.assertEqual(grownPositions, initialPositions) + self.assertGreater(grownBlankHeight, initialBlankHeight) + + group.close() + group.deleteLater() + def testEveryProtocolEditorRetranslatesItsDedicatedWindowTitle(self): """Retain title source text when switching from Chinese to English.""" editorTypes = (