From e914e98fb0a7f2468c93efcfef37ada8fe7b775e Mon Sep 17 00:00:00 2001 From: Loren Eteval Date: Fri, 14 Aug 2026 10:16:55 +0800 Subject: [PATCH] Completed the repository-wide Qt/PySide6 lifetime audit Signed-off-by: Loren Eteval --- Furious/Actions/Import.py | 3 + Furious/Application/DesktopApplication.py | 37 +++++++-- Furious/Backends/Xray/RoutingWindow.py | 33 ++++++-- Furious/Qt/QtGui.py | 20 +++-- Furious/Qt/QtWidgets.py | 39 ++++++++-- Furious/Qt/TextEditor.py | 33 ++++---- Furious/Widget/ServerTableView.py | 92 +++++++++++++---------- Furious/Window/HomePage.py | 6 +- Furious/Window/NetworkTestDialog.py | 21 ++++-- Furious/Window/ProxyBypassDialog.py | 5 +- Furious/Window/SubscriptionPage.py | 7 +- Furious/Window/TextEditorWindow.py | 64 ++++++++-------- Furious/Window/TunSettingsDialog.py | 15 ++-- 13 files changed, 232 insertions(+), 143 deletions(-) diff --git a/Furious/Actions/Import.py b/Furious/Actions/Import.py index f9545a0..6cb62ab 100644 --- a/Furious/Actions/Import.py +++ b/Furious/Actions/Import.py @@ -486,6 +486,9 @@ class ImportQRCodeOnTheScreenAction(Mixins.CleanupOnExit, AppQAction): super().__init__( _('Scan QR Code On The Screen'), icon=bootstrapIcon('qr-code-scan.svg'), + # Tray and page actions each own a distinct native screen-capture + # handle, so every instance must close its own handle at shutdown. + uniqueCleanup=False, **kwargs, ) diff --git a/Furious/Application/DesktopApplication.py b/Furious/Application/DesktopApplication.py index 4d146cf..b4b6635 100644 --- a/Furious/Application/DesktopApplication.py +++ b/Furious/Application/DesktopApplication.py @@ -49,7 +49,6 @@ import sys import logging import platform import threading -import functools import traceback import darkdetect @@ -199,19 +198,33 @@ class DesktopApplication(ApplicationRunner, SingletonApplication): self._userTUNSettings = None # ThreadPool - self.threadPool = QtCore.QThreadPool() + self.threadPool = QtCore.QThreadPool(self) self.threadPool.setMaxThreadCount(max(OS_CPU_COUNT // 2, 1)) @callRateLimited(maxCallPerSecond=2) @QtCore.Slot() def handleNewConnection(self): """Handle new connection.""" - socket = self.server.nextPendingConnection() - socket.readyRead.connect(functools.partial(self.handleNewData, socket)) + while self.server.hasPendingConnections(): + socket = self.server.nextPendingConnection() - @QtCore.Slot(QLocalSocket) - def handleNewData(self, socket: QLocalSocket): + if socket is None: + continue + + # QLocalServer owns pending sockets until they are explicitly + # released. Use sender() instead of a partial that retains each + # socket and dispose it after the one-command protocol completes. + socket.readyRead.connect(self.handleNewData) + socket.disconnected.connect(socket.deleteLater) + + @QtCore.Slot() + def handleNewData(self): """Handle new data.""" + socket = self.sender() + + if not isinstance(socket, QLocalSocket): + return + data = socket.readAll().data() if isinstance(data, bytes): @@ -235,6 +248,14 @@ class DesktopApplication(ApplicationRunner, SingletonApplication): # TODO: Not implemented pass + # The singleton IPC channel carries exactly one command. Closing it + # here both wakes RunAs clients and prevents completed QLocalSocket + # children from accumulating under the application-wide server. + socket.disconnectFromServer() + + if socket.state() == QLocalSocket.LocalSocketState.UnconnectedState: + socket.deleteLater() + def configureLogging(self): """Configure logging.""" self.logManager = LogManager(parent=self) @@ -528,7 +549,7 @@ class DesktopApplication(ApplicationRunner, SingletonApplication): self.handleSystemThemeChanged(currentTheme) self.currentTheme = self.systemTheme() - self.themeDetectTimer = QtCore.QTimer() + self.themeDetectTimer = QtCore.QTimer(self) self.themeDetectTimer.timeout.connect(handleTimeout) self.themeDetectTimer.start(1000) else: @@ -545,7 +566,7 @@ class DesktopApplication(ApplicationRunner, SingletonApplication): 'darkdetect listener is not implemented on this platform' ) - self.themeDetector = ApplicationThemeDetector() + self.themeDetector = ApplicationThemeDetector(self) self.themeDetector.themeChanged.connect(self.handleSystemThemeChanged) self.themeListenerThread = threading.Thread( diff --git a/Furious/Backends/Xray/RoutingWindow.py b/Furious/Backends/Xray/RoutingWindow.py index a57efe3..63a973d 100644 --- a/Furious/Backends/Xray/RoutingWindow.py +++ b/Furious/Backends/Xray/RoutingWindow.py @@ -696,17 +696,26 @@ class RoutingRulesQListWidget(AppQListWidget): self.setSelectionMode(AppQListWidget.SelectionMode.ExtendedSelection) self.setContextMenuPolicy(QtCore.Qt.ContextMenuPolicy.CustomContextMenu) - self.itemDoubleClicked.connect(lambda _item: self.editRequested.emit()) + self.itemDoubleClicked.connect(self._requestEdit) self.customContextMenuRequested.connect(self.handleCustomContextMenuRequested) - self.contextDeleteAction = AppQAction( - _('Delete'), - callback=lambda: self.deleteRequested.emit(), - ) + self.contextDeleteAction = AppQAction(_('Delete')) + self.contextDeleteAction.triggered.connect(self._requestDelete) + self.contextMenu = AppQMenu(self.contextDeleteAction, parent=self) self.flushAll() + @QtCore.Slot(QListWidgetItem) + def _requestEdit(self, _item): + """Forward a row double-click without retaining a Python closure.""" + self.editRequested.emit() + + @QtCore.Slot(bool) + def _requestDelete(self, _checked=False): + """Request deletion without retaining a callback closure.""" + self.deleteRequested.emit() + def rules(self): """Return the rules value used by the routing rules Qt list widget.""" rules = self.routing.setdefault('rules', list()) @@ -824,7 +833,10 @@ class RoutingRulesDialog(AppQTransientDialog): def addRule(self): """Add rule.""" rule = {'type': 'field', 'outboundTag': 'proxy', 'ruleTag': 'New Rule'} - dialog = RoutingRuleEditDialog(rule, parent=None) + # 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.""" @@ -842,7 +854,7 @@ class RoutingRulesDialog(AppQTransientDialog): return index = indexes[0] - dialog = RoutingRuleEditDialog(self.listWidget.ruleAt(index), parent=None) + dialog = RoutingRuleEditDialog(self.listWidget.ruleAt(index), parent=self) def handleResultCode(_index, code): """Handle result code.""" @@ -1149,9 +1161,14 @@ class UserRoutingTableView(Mixins.QTranslatable, AppQTableView): routing = self.sourceModel.routingByRow(indexes[0]) dialog = RoutingRulesDialog(routing, parent=self) - dialog.finished.connect(lambda _code: self.flushAll()) + dialog.finished.connect(self._rulesDialogFinished) 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.""" diff --git a/Furious/Qt/QtGui.py b/Furious/Qt/QtGui.py index d534445..f7dfd6e 100644 --- a/Furious/Qt/QtGui.py +++ b/Furious/Qt/QtGui.py @@ -174,17 +174,21 @@ class AppQAction(Mixins.QTranslatable, Mixins.ThemeAware, QAction): if shortcut is not None: self.setShortcut(shortcut) - @QtCore.Slot(bool) - def triggerSignal(paramChecked): - """Handle trigger signal for the app q action.""" - logger.info(f'action is \'{self.textEnglish}\'. Checked is {paramChecked}') + # Connect to a real QObject method rather than a nested closure that + # captures this action. PySide stores Python callables connected to a + # signal outside the normal Python object graph; a closure here leaves + # an otherwise unowned QAction alive indefinitely. + self.triggered.connect(self._handleTriggered) - if callable(self.callback): - self.callback() + @QtCore.Slot(bool) + def _handleTriggered(self, paramChecked): + """Dispatch activation without creating a signal/self reference cycle.""" + logger.info(f'action is \'{self.textEnglish}\'. Checked is {paramChecked}') - self.triggeredCallback(paramChecked) + if callable(self.callback): + self.callback() - self.triggered.connect(triggerSignal) + self.triggeredCallback(paramChecked) def addAction(self, action): """Add action.""" diff --git a/Furious/Qt/QtWidgets.py b/Furious/Qt/QtWidgets.py index f76afdf..fec740e 100644 --- a/Furious/Qt/QtWidgets.py +++ b/Furious/Qt/QtWidgets.py @@ -187,7 +187,11 @@ class AppQDialog(Mixins.QTranslatable, Mixins.ConnectionAware, QDialog): """Initialize the AppQDialog.""" super().__init__(*args, **kwargs) - self._lifetimeKey = id(self) + # Use a per-instance token rather than id(self). A finished transient + # can release its Python wrapper before Qt processes deleteLater(); an + # ID reused by a newer dialog must not let the older destroyed signal + # evict that newer dialog from the asynchronous lifetime registry. + self._lifetimeKey = object() self._firstShowPending = True # Do not store a nested closure that captures this dialog on the dialog @@ -460,7 +464,7 @@ class AppQMainWindow( """Initialize the AppQMainWindow.""" super().__init__(*args, **kwargs) - self._lifetimeKey = id(self) + self._lifetimeKey = object() self._firstShowPending = True release = functools.partial( @@ -614,6 +618,19 @@ class _AppMessageBoxMask(QFrame): return super().eventFilter(watched, event) + def dispose(self): + """Detach from the long-lived owner before scheduling destruction.""" + owner = self.parentWidget() + + if owner is not None: + try: + owner.removeEventFilter(self) + except RuntimeError: + # The owner can be destroyed as part of the same close path. + pass + + self.deleteLater() + class AppQMessageBox(AppQTransientDialog): """Present a responsive Fluent dialog with QMessageBox-compatible APIs.""" @@ -682,7 +699,7 @@ class AppQMessageBox(AppQTransientDialog): super().__init__(parent=parent, **kwargs) - self._lifetimeKey = id(self) + self._lifetimeKey = object() self._windowMask = None self._icon = self.Icon.NoIcon self._text = '' @@ -814,7 +831,7 @@ class AppQMessageBox(AppQTransientDialog): button = QPushButton(str(text), self.buttonFrame) button.setMinimumHeight(34) button.setAttribute(QtCore.Qt.WidgetAttribute.WA_LayoutUsesWidgetRect) - button.clicked.connect(functools.partial(self._buttonWasClicked, button)) + button.clicked.connect(self._handleButtonClicked) self._buttonRoles[button] = role @@ -1058,9 +1075,7 @@ class AppQMessageBox(AppQTransientDialog): customButton = button customButton.setParent(self.buttonFrame) customButton.setAttribute(QtCore.Qt.WidgetAttribute.WA_LayoutUsesWidgetRect) - customButton.clicked.connect( - functools.partial(self._buttonWasClicked, customButton) - ) + customButton.clicked.connect(self._handleButtonClicked) self._buttonRoles[customButton] = role self._rebuildButtonLayout() @@ -1072,6 +1087,14 @@ class AppQMessageBox(AppQTransientDialog): return self._createButton(button, role) + @QtCore.Slot(bool) + def _handleButtonClicked(self, _checked=False): + """Dispatch a child button through sender-based QObject ownership.""" + button = self.sender() + + if isinstance(button, QAbstractButton): + self._buttonWasClicked(button) + def removeButton(self, button): """Remove one custom or standard button.""" self._buttonRoles.pop(button, None) @@ -1399,7 +1422,7 @@ class AppQMessageBox(AppQTransientDialog): self._windowMask = None if mask is not None: - mask.deleteLater() + mask.dispose() def moveToCenter(self): """Move to center.""" diff --git a/Furious/Qt/TextEditor.py b/Furious/Qt/TextEditor.py index 73b8378..6df432d 100644 --- a/Furious/Qt/TextEditor.py +++ b/Furious/Qt/TextEditor.py @@ -274,7 +274,7 @@ class AppQTextBrowser(SupportPointSizeSettings, QTextBrowser): if delta > 0: self.zoomIn() - if delta < 0: + elif delta < 0: self.zoomOut() else: super().wheelEvent(event) @@ -306,23 +306,26 @@ class DraculaTextEditor(Mixins.ThemeAware, AppQPlainTextEdit): ) ) - @QtCore.Slot(bool) - def handleModificationChanged(changed): - """Handle modification changed.""" - if changed: - self.document().setModified(False) + # QObject-bound slots are important here. Nested closures connected to + # the editor's own signals retain the complete editor widget tree in + # PySide even after its window has been destroyed. + self.modificationChanged.connect(self._handleModificationChanged) + self.cursorPositionChanged.connect(self._handleCursorPositionChanged) - if callable(self._modificationChangedCb): - self._modificationChangedCb() + @QtCore.Slot(bool) + def _handleModificationChanged(self, changed): + """Notify the registered modification callback for a real edit.""" + if changed: + self.document().setModified(False) - @QtCore.Slot() - def handleCursorPositionChanged(): - """Handle cursor position changed.""" - if callable(self._cursorPositionChangedCb): - self._cursorPositionChangedCb(self.textCursor()) + if callable(self._modificationChangedCb): + self._modificationChangedCb() - self.modificationChanged.connect(handleModificationChanged) - self.cursorPositionChanged.connect(handleCursorPositionChanged) + @QtCore.Slot() + def _handleCursorPositionChanged(self): + """Notify the registered cursor-position callback.""" + if callable(self._cursorPositionChangedCb): + self._cursorPositionChangedCb(self.textCursor()) def themeChangedCallback(self, theme: str): """Refresh editor chrome after an application theme change.""" diff --git a/Furious/Widget/ServerTableView.py b/Furious/Widget/ServerTableView.py index 72e7e6e..37f61d2 100644 --- a/Furious/Widget/ServerTableView.py +++ b/Furious/Widget/ServerTableView.py @@ -1891,29 +1891,24 @@ class ServerTableView( logger.error(f'error while converting factory to input: {ex}') - guiEditor.accepted.connect( - functools.partial( - self.handleGuiEditorAccepted, - guiEditor, - index, - factory, - ) - ) - guiEditor.rejected.connect( - functools.partial( - self.handleGuiEditorRejected, - guiEditor, - ) - ) + # Keep operation metadata on the transient editor and use QObject-bound + # slots. Partials stored by PySide otherwise retain both the complete + # editor tree and this application-lifetime table until disconnection. + guiEditor._modContext = (index, factory) + guiEditor.accepted.connect(self.handleGuiEditorAccepted) + guiEditor.rejected.connect(self.handleGuiEditorRejected) guiEditor.open() - def handleGuiEditorAccepted( - self, - editor: GuiEditorWidgetQDialog, - index: int, - factory: ServerProfile, - ): + @QtCore.Slot() + def handleGuiEditorAccepted(self): """Handle GUI editor accepted.""" + editor = self.sender() + + if not isinstance(editor, GuiEditorWidgetQDialog): + return + + index, factory = editor._modContext + logger.debug(f'guiEditor accepted with index {index}') modified = editor.inputToFactory(factory) @@ -1927,12 +1922,22 @@ class ServerTableView( editor.accepted.disconnect() editor.rejected.disconnect() - @staticmethod - def handleGuiEditorRejected(editor: GuiEditorWidgetQDialog): + del editor._modContext + + @QtCore.Slot() + def handleGuiEditorRejected(self): """Handle GUI editor rejected.""" + editor = self.sender() + + if not isinstance(editor, GuiEditorWidgetQDialog): + return + editor.accepted.disconnect() editor.rejected.disconnect() + if hasattr(editor, '_modContext'): + del editor._modContext + @QtCore.Slot(QtCore.QPoint) def handleCustomContextMenuRequested(self, point): """Handle custom context menu requested.""" @@ -2052,27 +2057,21 @@ class ServerTableView( logger.error(f'error while converting factory to input: {ex}') - guiEditor.accepted.connect( - functools.partial( - self.handleAddServerViaGuiAccepted, - guiEditor, - factory, - ) - ) - guiEditor.rejected.connect( - functools.partial( - self.handleAddServerViaGuiRejected, - guiEditor, - ) - ) + guiEditor._addContext = factory + guiEditor.accepted.connect(self.handleAddServerViaGuiAccepted) + guiEditor.rejected.connect(self.handleAddServerViaGuiRejected) guiEditor.open() - def handleAddServerViaGuiAccepted( - self, - editor: GuiEditorWidgetQDialog, - factory: ServerProfile, - ): + @QtCore.Slot() + def handleAddServerViaGuiAccepted(self): """Handle add server via GUI accepted.""" + editor = self.sender() + + if not isinstance(editor, GuiEditorWidgetQDialog): + return + + factory = editor._addContext + editor.inputToFactory(factory) self.appendNewItemByFactory(factory) @@ -2080,11 +2079,22 @@ class ServerTableView( editor.accepted.disconnect() editor.rejected.disconnect() - def handleAddServerViaGuiRejected(self, editor: GuiEditorWidgetQDialog): + del editor._addContext + + @QtCore.Slot() + def handleAddServerViaGuiRejected(self): """Handle add server via GUI rejected.""" + editor = self.sender() + + if not isinstance(editor, GuiEditorWidgetQDialog): + return + editor.accepted.disconnect() editor.rejected.disconnect() + if hasattr(editor, '_addContext'): + del editor._addContext + def flushRow(self, row: int, item: ServerProfile): """Refresh row.""" itemIndex = item.index diff --git a/Furious/Window/HomePage.py b/Furious/Window/HomePage.py index cb851a6..bbe479d 100644 --- a/Furious/Window/HomePage.py +++ b/Furious/Window/HomePage.py @@ -729,10 +729,8 @@ class HomePage(Mixins.QTranslatable, QMainWindow): """Handle the home-page request to restart with elevated privileges.""" if not SystemRuntime.isScriptMode(): if not SystemRuntime.isAdmin(): - process = QtCore.QProcess() - if PLATFORM == 'Windows': - process.startDetached( + QtCore.QProcess.startDetached( 'powershell', arguments=[ '-Command', @@ -741,7 +739,7 @@ class HomePage(Mixins.QTranslatable, QMainWindow): ], ) elif PLATFORM == 'Darwin': - process.startDetached( + QtCore.QProcess.startDetached( 'osascript', arguments=[ '-e', diff --git a/Furious/Window/NetworkTestDialog.py b/Furious/Window/NetworkTestDialog.py index 871fcc5..3cabd84 100644 --- a/Furious/Window/NetworkTestDialog.py +++ b/Furious/Window/NetworkTestDialog.py @@ -27,7 +27,6 @@ from PySide6 import QtCore from PySide6.QtWidgets import * import logging -import functools __all__ = ['NetworkTestDialog'] @@ -76,13 +75,9 @@ class NetworkTestDialog(AppQDialog): self.dialogBtns.rejected.connect(self.reject) self.speedTestURLResetBtn = AppQPushButton(_('Reset')) - self.speedTestURLResetBtn.clicked.connect( - lambda: self.speedTestURLEdit.setText(NETWORK_SPEED_TEST_URL) - ) + self.speedTestURLResetBtn.clicked.connect(self._resetSpeedTestURL) self.connectivityResetBtn = AppQPushButton(_('Reset')) - self.connectivityResetBtn.clicked.connect( - lambda: self.connectivityEdit.setText(NETWORK_CONNECTIVITY_TEST_URL) - ) + self.connectivityResetBtn.clicked.connect(self._resetConnectivityURL) self.speedTestURLHboxLayout = QHBoxLayout() self.speedTestURLHboxLayout.addWidget(self.speedTestURLEdit) @@ -102,7 +97,17 @@ class NetworkTestDialog(AppQDialog): self.setLayout(layout) - self.finished.connect(functools.partial(self.handleResultCode)) + self.finished.connect(self.handleResultCode) + + @QtCore.Slot() + def _resetSpeedTestURL(self): + """Restore the default speed-test URL.""" + self.speedTestURLEdit.setText(NETWORK_SPEED_TEST_URL) + + @QtCore.Slot() + def _resetConnectivityURL(self): + """Restore the default connectivity-test URL.""" + self.connectivityEdit.setText(NETWORK_CONNECTIVITY_TEST_URL) def setWidthAndHeight(self): """Apply the default size for the GUI customize network test dialog.""" diff --git a/Furious/Window/ProxyBypassDialog.py b/Furious/Window/ProxyBypassDialog.py index 87e004f..53fac9f 100644 --- a/Furious/Window/ProxyBypassDialog.py +++ b/Furious/Window/ProxyBypassDialog.py @@ -27,7 +27,6 @@ from PySide6 import QtCore from PySide6.QtWidgets import * import logging -import functools __all__ = ['ProxyBypassDialog'] @@ -65,7 +64,7 @@ class ProxyBypassDialog(AppQDialog): self.dialogBtns.rejected.connect(self.reject) self.resetBtn = AppQPushButton(_('Reset')) - self.resetBtn.clicked.connect(functools.partial(self.handleResetButtonClicked)) + self.resetBtn.clicked.connect(self.handleResetButtonClicked) self.hboxLayout = QHBoxLayout() self.hboxLayout.addWidget(self.resetBtn) @@ -79,7 +78,7 @@ class ProxyBypassDialog(AppQDialog): self.setLayout(layout) - self.finished.connect(functools.partial(self.handleResultCode)) + self.finished.connect(self.handleResultCode) def setWidthAndHeight(self): """Apply the default size for the GUI customize proxy bypass dialog.""" diff --git a/Furious/Window/SubscriptionPage.py b/Furious/Window/SubscriptionPage.py index daa81e7..e16ea21 100644 --- a/Furious/Window/SubscriptionPage.py +++ b/Furious/Window/SubscriptionPage.py @@ -229,7 +229,7 @@ class SubscriptionPage(Mixins.QTranslatable, Mixins.ThemeAware, QMainWindow): deleteUniqueCallback=self._deleteProfilesForSubscription, parent=self, ) - self.table.doubleClicked.connect(lambda _index: self.editSelected()) + self.table.doubleClicked.connect(self._editDoubleClicked) self.table.groupsChanged.connect( self.serverTable.subsManager.subscriptionsChanged.emit ) @@ -282,6 +282,11 @@ class SubscriptionPage(Mixins.QTranslatable, Mixins.ThemeAware, QMainWindow): self.setIconsByTheme(APP().theme()) self.retranslate() + @QtCore.Slot(QtCore.QModelIndex) + def _editDoubleClicked(self, _index): + """Edit the selected subscription after a table double-click.""" + self.editSelected() + def _deleteProfilesForSubscription(self, unique: str): """Remove profiles belonging to a deleted subscription group.""" indexes = [ diff --git a/Furious/Window/TextEditorWindow.py b/Furious/Window/TextEditorWindow.py index 56546f5..2b9d14f 100644 --- a/Furious/Window/TextEditorWindow.py +++ b/Furious/Window/TextEditorWindow.py @@ -373,43 +373,43 @@ class TextEditorWindow(AppQMainWindow): def setIndent(self): """Set indent.""" - - def handleResultCode(_indentSpinBox, code): - """Handle result code.""" - if code == PySide6Legacy.enumValueWrapper(AppQDialog.DialogCode.Accepted): - plain = self.jsonEditor.toPlainText() - - try: - jsonObject = JSONEncoder.decode(plain) - except Exception as ex: - # Any non-exit exceptions - - mbox = MBoxJSONDecodeError( - icon=AppQMessageBox.Icon.Critical, parent=self - ) - mbox.error = str(ex) - mbox.setWindowTitle(_('Error setting indent')) - mbox.setWindowModality(QtCore.Qt.WindowModality.WindowModal) - mbox.setText(mbox.customText()) - - # Show the MessageBox asynchronously - mbox.open() - else: - text = JSONEncoder.encode(jsonObject, indent=_indentSpinBox.value()) - - self.setPlainText(text, False) - else: - # Do nothing - pass - indentSpinBox = IndentDialog(parent=self) - indentSpinBox.finished.connect( - functools.partial(handleResultCode, indentSpinBox) - ) + indentSpinBox.finished.connect(self._indentDialogFinished) # Show the MessageBox asynchronously indentSpinBox.open() + @QtCore.Slot(int) + def _indentDialogFinished(self, code): + """Apply the selected indentation without retaining the dialog.""" + indentSpinBox = self.sender() + + if not isinstance(indentSpinBox, IndentDialog): + return + + if code != PySide6Legacy.enumValueWrapper(AppQDialog.DialogCode.Accepted): + return + + plain = self.jsonEditor.toPlainText() + + try: + jsonObject = JSONEncoder.decode(plain) + except Exception as ex: + # Any non-exit exceptions + + mbox = MBoxJSONDecodeError(icon=AppQMessageBox.Icon.Critical, parent=self) + mbox.error = str(ex) + mbox.setWindowTitle(_('Error setting indent')) + mbox.setWindowModality(QtCore.Qt.WindowModality.WindowModal) + mbox.setText(mbox.customText()) + + # Show the MessageBox asynchronously + mbox.open() + else: + text = JSONEncoder.encode(jsonObject, indent=indentSpinBox.value()) + + self.setPlainText(text, False) + def showTabAndSpaces(self): """Show tab and spaces.""" textOption = QTextOption() diff --git a/Furious/Window/TunSettingsDialog.py b/Furious/Window/TunSettingsDialog.py index 73eef97..b062de1 100644 --- a/Furious/Window/TunSettingsDialog.py +++ b/Furious/Window/TunSettingsDialog.py @@ -30,7 +30,6 @@ from PySide6.QtGui import * from PySide6.QtWidgets import * import logging -import functools __all__ = ['TunSettingsDialog'] @@ -307,21 +306,22 @@ class TunSettingsDialog(GuiEditorWidgetQDialog): self.setFixedSize(int(620 * GOLDEN_RATIO), int(620)) # Shallow copy - config = Storage.UserTUNSettings() + self._config = Storage.UserTUNSettings() try: - self.factoryToInput(config) + self.factoryToInput(self._config) except Exception as ex: # Any non-exit exceptions logger.error(f'error while converting factory to input: {ex}') - self.accepted.connect(functools.partial(self.handleAccepted, config)) - self.rejected.connect(functools.partial(self.handleRejected)) + self.accepted.connect(self.handleAccepted) + self.rejected.connect(self.handleRejected) - def handleAccepted(self, config: dict): + @QtCore.Slot() + def handleAccepted(self): """Handle accepted.""" - modified = self.inputToFactory(config) + modified = self.inputToFactory(self._config) if modified and SystemRuntime.isTUNMode(): showMBoxNewChangesNextTime() @@ -329,6 +329,7 @@ class TunSettingsDialog(GuiEditorWidgetQDialog): self.accepted.disconnect() self.rejected.disconnect() + @QtCore.Slot() def handleRejected(self): """Handle rejected.""" self.accepted.disconnect()