From 46dc9710b0f18deca234ce042a28098e5e9d6a9a Mon Sep 17 00:00:00 2001 From: Loren Eteval Date: Thu, 13 Aug 2026 16:49:38 +0800 Subject: [PATCH] Fix shared Qt object lifetimes Signed-off-by: Loren Eteval --- Furious/Actions/Import.py | 11 +-- Furious/Backends/Xray/RoutingWindow.py | 12 +-- Furious/Core/CoreProcessWorker.py | 37 ++++++++ Furious/Qt/EditorWidgets.py | 2 +- Furious/Qt/QtWidgets.py | 99 ++++++++++++---------- Furious/Qt/WebGETManager.py | 54 ++++++++---- Furious/Qt/__init__.py | 2 + Furious/Service/ConnectionManager.py | 10 +++ Furious/Service/ConnectivityManager.py | 47 +++++++--- Furious/Widget/ConnectionProgressWidget.py | 27 +++--- Furious/Widget/ServerTableView.py | 12 ++- Furious/Widget/SubscriptionTableView.py | 4 + Furious/Window/HomePage.py | 15 ++-- Furious/Window/IndentDialog.py | 2 +- Furious/Window/SubscriptionPage.py | 2 +- Furious/Window/TextEditorWindow.py | 9 +- 16 files changed, 224 insertions(+), 121 deletions(-) diff --git a/Furious/Actions/Import.py b/Furious/Actions/Import.py index e0a915bc..f9545a0b 100644 --- a/Furious/Actions/Import.py +++ b/Furious/Actions/Import.py @@ -130,11 +130,9 @@ def importURIs(*uris, failureCallback: Union[Callable[[], None], None] = None): mbox.open() -class ImportURIsProgressDialog(AppQDialog): +class ImportURIsProgressDialog(AppQTransientDialog): """Present progress and cancellation controls for import ur is.""" - ActiveDialogs = list() - def __init__( self, uris: Tuple[str, ...], @@ -190,8 +188,6 @@ class ImportURIsProgressDialog(AppQDialog): def open(self): """Open the import ur is progress dialog asynchronously.""" - ImportURIsProgressDialog.ActiveDialogs.append(self) - result = super().open() self.spinner.start() @@ -271,11 +267,6 @@ class ImportURIsProgressDialog(AppQDialog): self.spinner.stop() self.accept() - try: - ImportURIsProgressDialog.ActiveDialogs.remove(self) - except ValueError: - pass - if self.canceled: return diff --git a/Furious/Backends/Xray/RoutingWindow.py b/Furious/Backends/Xray/RoutingWindow.py index 00fa1387..fb226424 100644 --- a/Furious/Backends/Xray/RoutingWindow.py +++ b/Furious/Backends/Xray/RoutingWindow.py @@ -125,7 +125,7 @@ def routingObjectFromProfile(routingProfile: dict): } -class RoutingPreviewDialog(AppQDialog): +class RoutingPreviewDialog(AppQTransientDialog): """Present the routing preview dialog.""" def __init__(self, routingProfile: dict, parent=None): @@ -168,7 +168,7 @@ class RoutingPreviewDialog(AppQDialog): self.setFixedSize(400, int(400 * GOLDEN_RATIO)) -class RoutingTextEditDialog(AppQDialog): +class RoutingTextEditDialog(AppQTransientDialog): """Present the routing text edit dialog.""" def __init__(self, text='', parent=None): @@ -358,7 +358,7 @@ class RoutingProfilesModel(QtCore.QAbstractTableModel): ) -class RoutingRuleEditDialog(AppQDialog): +class RoutingRuleEditDialog(AppQTransientDialog): """Present the routing rule edit dialog.""" MatchInputHeight = 72 @@ -587,7 +587,7 @@ class RoutingRuleEditDialog(AppQDialog): return rule -class RoutingRemarkEditDialog(AppQDialog): +class RoutingRemarkEditDialog(AppQTransientDialog): """Present the routing remark edit dialog.""" def __init__(self, remark: str, parent=None): @@ -623,7 +623,7 @@ class RoutingRemarkEditDialog(AppQDialog): return self.remarkEdit.text().strip() -class RoutingProfileEditDialog(AppQDialog): +class RoutingProfileEditDialog(AppQTransientDialog): """Present the routing profile edit dialog.""" def __init__(self, parent=None): @@ -770,7 +770,7 @@ class RoutingRulesQListWidget(AppQListWidget): self.contextMenu.exec(self.viewport().mapToGlobal(point)) -class RoutingRulesDialog(AppQDialog): +class RoutingRulesDialog(AppQTransientDialog): """Present the routing rules dialog.""" def __init__(self, routing: dict, parent=None): diff --git a/Furious/Core/CoreProcessWorker.py b/Furious/Core/CoreProcessWorker.py index 69786e74..710c4afe 100644 --- a/Furious/Core/CoreProcessWorker.py +++ b/Furious/Core/CoreProcessWorker.py @@ -161,6 +161,24 @@ class MsgQueue(multiprocessing.queues.Queue): """Stop timer.""" self.timer.stop() + def dispose(self): + """Release Qt and multiprocessing resources after final process use.""" + self.stopTimer() + + try: + self.timer.timeout.disconnect(self.processMsg) + except (RuntimeError, TypeError): + pass + + self.timer.deleteLater() + self.callback = None + self.backgroundOptimizer = None + + try: + self.close() + except (OSError, ValueError): + pass + @property def optimizer(self): """Return the optimizer value.""" @@ -293,6 +311,19 @@ class CoreProcessMonitor(CoreProcess, ABC): self.process = None + def dispose(self): + """Release the monitor timer after this kernel leaves its owner pool.""" + self.daemon.stop() + + try: + self.daemon.timeout.disconnect(self.queryIsAlive) + except (RuntimeError, TypeError): + pass + + self.daemon.deleteLater() + self.closeProcess() + self._exitCallback = None + class CoreProcessWorker(CoreProcessMonitor, ABC): """Run and monitor a proxy core in a child process.""" @@ -325,6 +356,12 @@ class CoreProcessWorker(CoreProcessMonitor, ABC): # Reset internal process self.closeProcess() + def dispose(self): + """Release final queue, callback, process, and Qt timer ownership.""" + self.msgQueue.dispose() + + super().dispose() + def start(self, **kwargs) -> bool: """Start the core process worker.""" return self.startWithSpec(CoreLaunchSpec.fromProcessKwargs(**kwargs)) diff --git a/Furious/Qt/EditorWidgets.py b/Furious/Qt/EditorWidgets.py index c09974aa..6c7754ff 100644 --- a/Furious/Qt/EditorWidgets.py +++ b/Furious/Qt/EditorWidgets.py @@ -372,7 +372,7 @@ class GuiEditorWidgetQGroupBox(GuiEditorWidget, AppQGroupBox): return layout -class GuiEditorWidgetQDialog(EditorBinding, AppQDialog): +class GuiEditorWidgetQDialog(EditorBinding, AppQTransientDialog): """Present the GUI editor widget Qt dialog.""" def __init__(self, *args, **kwargs): diff --git a/Furious/Qt/QtWidgets.py b/Furious/Qt/QtWidgets.py index d55e645f..169d5072 100644 --- a/Furious/Qt/QtWidgets.py +++ b/Furious/Qt/QtWidgets.py @@ -40,6 +40,7 @@ __all__ = [ 'AppQComboBox', 'AppQComboBoxSeparatorDelegate', 'AppQDialog', + 'AppQTransientDialog', 'AppQDialogButtonBox', 'AppQGroupBox', 'AppQHeaderView', @@ -186,21 +187,19 @@ class AppQDialog(Mixins.QTranslatable, Mixins.ConnectionAware, QDialog): """Initialize the AppQDialog.""" super().__init__(*args, **kwargs) - @callOnceOnly - def connect(key): - """Connect the lifetime release signals once.""" - release = functools.partial(AppQDialog._releaseOpenDialog, key) + self._lifetimeKey = id(self) + self._firstShowPending = True - self.finished.connect(release) - self.destroyed.connect(release) + # Do not store a nested closure that captures this dialog on the dialog + # itself. Such a self-cycle delays wrapper collection and is especially + # costly for widget trees in compiled builds. + release = functools.partial( + AppQDialog._releaseOpenDialog, + self._lifetimeKey, + ) - @callOnceOnly - def firstShow(): - """Apply the first-show sizing once.""" - self.setWidthAndHeight() - - self._connectOnce = connect - self._firstShow = firstShow + self.finished.connect(release) + self.destroyed.connect(release) if PLATFORM != 'Darwin': self.setWidthAndHeight() @@ -219,11 +218,9 @@ class AppQDialog(Mixins.QTranslatable, Mixins.ConnectionAware, QDialog): def open(self): """Open and retain the dialog until it finishes or is destroyed.""" - key = id(self) + key = self._lifetimeKey AppQDialog._openDialogs[key] = self - self._connectOnce(key) - try: self.show() @@ -239,8 +236,9 @@ class AppQDialog(Mixins.QTranslatable, Mixins.ConnectionAware, QDialog): """Show and position the app Qt dialog.""" super().show() - if PLATFORM == 'Darwin': - self._firstShow() + if PLATFORM == 'Darwin' and self._firstShowPending: + self._firstShowPending = False + self.setWidthAndHeight() moveToCenter(self) @@ -257,6 +255,19 @@ class AppQDialog(Mixins.QTranslatable, Mixins.ConnectionAware, QDialog): self.setWindowIcon(AppHue.connectedWindowIcon()) +class AppQTransientDialog(AppQDialog): + """Present a one-shot dialog that destroys its Qt object when closed.""" + + def __init__(self, *args, **kwargs): + """Initialize a dialog whose accepted/rejected lifetime is transient.""" + super().__init__(*args, **kwargs) + + # QDialog normally hides on accept/reject. A long-lived Qt parent then + # keeps each closed dialog in its child tree indefinitely. Transient + # dialogs opt into native deletion so both Qt and Python ownership end. + self.setAttribute(QtCore.Qt.WidgetAttribute.WA_DeleteOnClose, True) + + class AppQDialogButtonBox(Mixins.QTranslatable, QDialogButtonBox): """Represent app Qt dialog button box.""" @@ -449,19 +460,14 @@ class AppQMainWindow( """Initialize the AppQMainWindow.""" super().__init__(*args, **kwargs) - @callOnceOnly - def connect(key): - """Connect the lifetime release signal once.""" - release = functools.partial(AppQMainWindow._releaseOpenWindow, key) - self.destroyed.connect(release) + self._lifetimeKey = id(self) + self._firstShowPending = True - @callOnceOnly - def firstShow(): - """Apply the first-show sizing once.""" - self.setWidthAndHeight() - - self._connectOnce = connect - self._firstShow = firstShow + release = functools.partial( + AppQMainWindow._releaseOpenWindow, + self._lifetimeKey, + ) + self.destroyed.connect(release) self.setWindowIcon(AppHue.currentWindowIcon()) @@ -477,11 +483,9 @@ class AppQMainWindow( def show(self): """Show, position, and retain the window until it closes.""" - key = id(self) + key = self._lifetimeKey AppQMainWindow._openWindows[key] = self - self._connectOnce(key) - try: super().show() except Exception: @@ -491,8 +495,9 @@ class AppQMainWindow( raise - if PLATFORM == 'Darwin': - self._firstShow() + if PLATFORM == 'Darwin' and self._firstShowPending: + self._firstShowPending = False + self.setWidthAndHeight() moveToCenter(self) @@ -508,7 +513,7 @@ class AppQMainWindow( result = super().event(event) if closes and event.isAccepted(): - AppQMainWindow._releaseOpenWindow(id(self)) + AppQMainWindow._releaseOpenWindow(self._lifetimeKey) return result @@ -603,15 +608,19 @@ class AppQMessageBox(Mixins.QTranslatable, Mixins.ConnectionAware, QMessageBox): """Initialize the AppQMessageBox.""" super().__init__(*args, **kwargs) - @callOnceOnly - def connect(key): - """Connect the lifetime release signals once.""" - release = functools.partial(AppQMessageBox._releaseOpenMessageBox, key) + self._lifetimeKey = id(self) - self.finished.connect(release) - self.destroyed.connect(release) + release = functools.partial( + AppQMessageBox._releaseOpenMessageBox, + self._lifetimeKey, + ) - self._connectOnce = connect + self.finished.connect(release) + self.destroyed.connect(release) + + # Message boxes in Furious are one-shot notifications or questions. + # Parent ownership must not retain a hidden native box after completion. + self.setAttribute(QtCore.Qt.WidgetAttribute.WA_DeleteOnClose, True) self.setWindowIcon(AppHue.currentWindowIcon()) @@ -634,11 +643,9 @@ class AppQMessageBox(Mixins.QTranslatable, Mixins.ConnectionAware, QMessageBox): def open(self): """Open and retain the message box until it finishes or is destroyed.""" - key = id(self) + key = self._lifetimeKey AppQMessageBox._openMessageBoxes[key] = self - self._connectOnce(key) - try: self.show() self.moveToCenter() diff --git a/Furious/Qt/WebGETManager.py b/Furious/Qt/WebGETManager.py index 5862b755..cdcc27a4 100644 --- a/Furious/Qt/WebGETManager.py +++ b/Furious/Qt/WebGETManager.py @@ -28,7 +28,6 @@ from PySide6.QtNetwork import * from typing import Union import logging -import functools __all__ = ['WebGETManager'] @@ -46,6 +45,7 @@ class WebGETManager(AppQNetworkAccessManager): self.mustCallOnce = kwargs.pop('mustCallOnce', True) self.mustCalled = False + self._replyContexts = {} def successCallback(self, networkReply: QNetworkReply, **kwargs): """Handle a successful network operation.""" @@ -86,6 +86,29 @@ class WebGETManager(AppQNetworkAccessManager): """Handle ready read by network reply.""" self.hasDataCallback(networkReply, **kwargs) + @QtCore.Slot() + def _handleReadyRead(self): + """Dispatch ready-read data without a closure retaining the reply.""" + networkReply = self.sender() + + if isinstance(networkReply, QNetworkReply): + self.handleReadyReadByNetworkReply( + networkReply, + **self._replyContexts.get(id(networkReply), {}), + ) + + @QtCore.Slot() + def _handleFinished(self): + """Dispatch and release one completed network reply.""" + networkReply = self.sender() + + if not isinstance(networkReply, QNetworkReply): + return + + kwargs = self._replyContexts.pop(id(networkReply), {}) + + self.handleFinishedByNetworkReply(networkReply, **kwargs) + def handleFinishedByNetworkReply(self, networkReply: QNetworkReply, **kwargs): """Handle finished by network reply.""" try: @@ -113,7 +136,14 @@ class WebGETManager(AppQNetworkAccessManager): self.successCallback(networkReply, **kwargs) finally: - self.must(**kwargs) + try: + self.must(**kwargs) + finally: + # QNetworkAccessManager owns replies by default and does not + # remove completed children automatically. All response data + # has been consumed by this point. The shared slots above use + # sender(), so no per-request closure retains this wrapper. + networkReply.deleteLater() def configureHttpProxy(self, httpProxy: Union[str, None]) -> bool: """Configure HTTP proxy.""" @@ -133,19 +163,11 @@ class WebGETManager(AppQNetworkAccessManager): else: networkReply = self.get(QNetworkRequest(QtCore.QUrl(request))) - networkReply.readyRead.connect( - functools.partial( - self.handleReadyReadByNetworkReply, - networkReply, - **kwargs, - ) - ) - networkReply.finished.connect( - functools.partial( - self.handleFinishedByNetworkReply, - networkReply, - **kwargs, - ) - ) + key = id(networkReply) + + self._replyContexts[key] = dict(kwargs) + + networkReply.readyRead.connect(self._handleReadyRead) + networkReply.finished.connect(self._handleFinished) return networkReply diff --git a/Furious/Qt/__init__.py b/Furious/Qt/__init__.py index 7989e7f2..166e32e8 100644 --- a/Furious/Qt/__init__.py +++ b/Furious/Qt/__init__.py @@ -55,6 +55,7 @@ from .QtWidgets import ( AppQComboBox, AppQComboBoxSeparatorDelegate, AppQDialog, + AppQTransientDialog, AppQDialogButtonBox, AppQGroupBox, AppQHeaderView, @@ -106,6 +107,7 @@ __all__ = [ 'AppQComboBox', 'AppQComboBoxSeparatorDelegate', 'AppQDialog', + 'AppQTransientDialog', 'AppQDialogButtonBox', 'AppQGroupBox', 'AppQHeaderView', diff --git a/Furious/Service/ConnectionManager.py b/Furious/Service/ConnectionManager.py index ea47bc31..13a34889 100644 --- a/Furious/Service/ConnectionManager.py +++ b/Furious/Service/ConnectionManager.py @@ -547,6 +547,16 @@ class ConnectionManager(Mixins.CleanupOnExit): # Any non-exit exceptions logger.error(f'error stopping core process: {ex}') + finally: + dispose = getattr(process, 'dispose', None) + + if callable(dispose): + try: + dispose() + except Exception as ex: + # Any non-exit exceptions + + logger.error(f'error disposing core process: {ex}') finally: self.processesPool.clear() diff --git a/Furious/Service/ConnectivityManager.py b/Furious/Service/ConnectivityManager.py index 93727007..430d9a5a 100644 --- a/Furious/Service/ConnectivityManager.py +++ b/Furious/Service/ConnectivityManager.py @@ -26,7 +26,6 @@ from PySide6 import QtCore from PySide6.QtNetwork import * import logging -import functools __all__ = ['ConnectivityManager'] @@ -47,11 +46,22 @@ class ConnectivityManager(Mixins.ConnectionAware, WebGETManager): self.jobStatus = False self.jobInterval = ConnectivityManager.MIN_JOB_INTERVAL + self._testingEnabled = False + self._activeReply = None - self.jobTimeoutTimer = QtCore.QTimer() - self.jobArrangeTimer = QtCore.QTimer() + self.jobTimeoutTimer = QtCore.QTimer(self) + self.jobTimeoutTimer.setSingleShot(True) + self.jobTimeoutTimer.timeout.connect(self._abortActiveReply) - self.jobArrangeTimer.timeout.connect(lambda: self.startSingleTest()) + self.jobArrangeTimer = QtCore.QTimer(self) + self.jobArrangeTimer.setSingleShot(True) + self.jobArrangeTimer.timeout.connect(self.startSingleTest) + + @QtCore.Slot() + def _abortActiveReply(self): + """Abort the one currently active connectivity request.""" + if isinstance(self._activeReply, QNetworkReply): + self._activeReply.abort() def recalculateJobInterval(self, jobStatus: bool) -> int: """Return the recalculate job interval value used by the network connectivity manager.""" @@ -72,17 +82,30 @@ class ConnectivityManager(Mixins.ConnectionAware, WebGETManager): def successCallback(self, networkReply, **kwargs): """Handle a successful network operation.""" + if self._activeReply is networkReply: + self._activeReply = None + self.jobTimeoutTimer.stop() - self.jobArrangeTimer.start(self.recalculateJobInterval(jobStatus=True)) + + if self._testingEnabled: + self.jobArrangeTimer.start(self.recalculateJobInterval(jobStatus=True)) def failureCallback(self, networkReply, **kwargs): """Handle a failed network operation.""" + if self._activeReply is networkReply: + self._activeReply = None + self.jobTimeoutTimer.stop() - self.jobArrangeTimer.start(self.recalculateJobInterval(jobStatus=False)) + + if self._testingEnabled: + self.jobArrangeTimer.start(self.recalculateJobInterval(jobStatus=False)) def startSingleTest(self): # Use custom network connectivity test URL if possible """Start single test.""" + if not self._testingEnabled or self._activeReply is not None: + return + settings = AppSettings.get('CustomNetworkConnectivityTestURL') if isinstance(settings, str): @@ -90,20 +113,15 @@ class ConnectivityManager(Mixins.ConnectionAware, WebGETManager): else: url = NETWORK_CONNECTIVITY_TEST_URL - networkReply = self.webGET(url) - - def abort(_networkReply): - """Cancel the active network connectivity manager operation.""" - if isinstance(_networkReply, QNetworkReply): - _networkReply.abort() - - self.jobTimeoutTimer.timeout.connect(functools.partial(abort, networkReply)) + self._activeReply = self.webGET(url) self.jobTimeoutTimer.start(ConnectivityManager.MIN_JOB_INTERVAL - 500) def stopTest(self): """Stop test.""" + self._testingEnabled = False self.jobArrangeTimer.stop() self.jobTimeoutTimer.stop() + self._abortActiveReply() def connectedCallback(self): """Update the network connectivity manager for a connected state.""" @@ -114,6 +132,7 @@ class ConnectivityManager(Mixins.ConnectionAware, WebGETManager): self.stopTest() else: self.jobInterval = ConnectivityManager.MIN_JOB_INTERVAL + self._testingEnabled = True self.jobArrangeTimer.start(self.jobInterval) diff --git a/Furious/Widget/ConnectionProgressWidget.py b/Furious/Widget/ConnectionProgressWidget.py index c01fd4b4..d572c2f2 100644 --- a/Furious/Widget/ConnectionProgressWidget.py +++ b/Furious/Widget/ConnectionProgressWidget.py @@ -38,22 +38,23 @@ class ConnectionProgressBar(Mixins.ConnectionAware, QProgressBar): self.setRange(0, 100) - @QtCore.Slot() - def update(): - # Update the progress bar value - """Update the auto update progress bar.""" - if self.value() < 90: - self.setValue(self.value() + 1) - - # Stop the timer when the progress bar reaches 100% - if self.value() > 99: - self.timer.stop() - - self.timer = QtCore.QTimer() - self.timer.timeout.connect(update) + # The timer belongs to the progress bar. Parenting it and connecting + # to a normal method avoids a parentless timer/closure cycle surviving + # after the widget's Qt lifetime ends. + self.timer = QtCore.QTimer(self) + self.timer.timeout.connect(self._advance) self._setConnectionState('disconnected') + @QtCore.Slot() + def _advance(self): + """Advance the connection progress animation.""" + if self.value() < 90: + self.setValue(self.value() + 1) + + if self.value() > 99: + self.timer.stop() + def _setConnectionState(self, state: str): """Expose semantic state to the application-owned progress style.""" if self.property('connectionState') == state: diff --git a/Furious/Widget/ServerTableView.py b/Furious/Widget/ServerTableView.py index 3db494e9..d8505e88 100644 --- a/Furious/Widget/ServerTableView.py +++ b/Furious/Widget/ServerTableView.py @@ -524,11 +524,11 @@ class TestDownloadSpeedWorker(WebGETManager): self.networkReply = None self.elapsedTimer = QtCore.QElapsedTimer() - self.timeoutTimer = QtCore.QTimer() + self.timeoutTimer = QtCore.QTimer(self) self.timeoutTimer.setSingleShot(True) self.timeoutTimer.timeout.connect(self.handleTimeout) - def mustCall(self): + def mustCall(self, **kwargs): """Perform the required completion hook.""" self.timeoutTimer.stop() self.finished.emit(self) @@ -880,10 +880,16 @@ class DownloadSpeedTestScheduler(QtCore.QObject): return self.releasePort(port) + + # Completed workers are children of the long-lived scheduler. Merely + # removing the Python dictionary entry would leave every worker (and + # its network/timer children) in the scheduler's QObject tree. + worker.deleteLater() + self.scheduleDrain() -class DeleteServersProgressDialog(AppQDialog): +class DeleteServersProgressDialog(AppQTransientDialog): """Present progress and cancellation controls for delete servers.""" def __init__(self, table, indexes, showTrayMessage=True, parent=None): diff --git a/Furious/Widget/SubscriptionTableView.py b/Furious/Widget/SubscriptionTableView.py index 2bc2648c..8c0c2cf0 100644 --- a/Furious/Widget/SubscriptionTableView.py +++ b/Furious/Widget/SubscriptionTableView.py @@ -648,6 +648,10 @@ class SubscriptionTableView(Mixins.QTranslatable, AppQTableView): self.timers.pop(deleteIndex) self.timerConnected.pop(deleteIndex) + # The table is the timer's Qt parent, so dropping the list + # reference alone would retain it until application exit. + qtimer.deleteLater() + self.sourceModel.endRemoveRows() if callable(self.deleteUniqueCallback): diff --git a/Furious/Window/HomePage.py b/Furious/Window/HomePage.py index 0e064b8c..cb851a60 100644 --- a/Furious/Window/HomePage.py +++ b/Furious/Window/HomePage.py @@ -436,6 +436,9 @@ class HomePage(Mixins.QTranslatable, QMainWindow): importActionsFactory=self.serverImportActions, ) pluginRegistry = getPluginRegistry() + + # These two settings dialogs intentionally remain parent-owned and are + # refreshed/reused by SettingsPage instead of being recreated. self.customizeProxyBypassDialog = ProxyBypassDialog(parent=self) self.customizeNetworkTestDialog = NetworkTestDialog(parent=self) @@ -669,16 +672,10 @@ class HomePage(Mixins.QTranslatable, QMainWindow): self.userServersQTableWidget.hideTabAndSpaces() def getGuiTUNSettings(self, **kwargs): - """Return GUI TUN settings.""" + """Create a transient TUN settings editor for the settings page.""" + parent = kwargs.pop('parent', self) - @functools.lru_cache(None) - def cachedGuiTUNSettings(): - """Return the TUN settings editor owned by the home page.""" - parent = kwargs.pop('parent', self) - - return TunSettingsDialog(parent=parent, **kwargs) - - guiTUNSettings = cachedGuiTUNSettings() + guiTUNSettings = TunSettingsDialog(parent=parent, **kwargs) guiTUNSettings.factoryToInput(Storage.UserTUNSettings()) return guiTUNSettings diff --git a/Furious/Window/IndentDialog.py b/Furious/Window/IndentDialog.py index 719082da..cf16305e 100644 --- a/Furious/Window/IndentDialog.py +++ b/Furious/Window/IndentDialog.py @@ -28,7 +28,7 @@ from PySide6.QtWidgets import * __all__ = ['IndentDialog'] -class IndentDialog(AppQDialog): +class IndentDialog(AppQTransientDialog): """Represent indent spin box.""" def __init__(self, parent=None): diff --git a/Furious/Window/SubscriptionPage.py b/Furious/Window/SubscriptionPage.py index ffce6123..daa81e76 100644 --- a/Furious/Window/SubscriptionPage.py +++ b/Furious/Window/SubscriptionPage.py @@ -54,7 +54,7 @@ def _validSubscriptionURL(value) -> str: return url -class _SubscriptionEditorDialog(AppQDialog): +class _SubscriptionEditorDialog(AppQTransientDialog): """Edit one complete subscription definition with validation.""" def __init__(self, subscription=None, parent=None): diff --git a/Furious/Window/TextEditorWindow.py b/Furious/Window/TextEditorWindow.py index 1926cac6..376a4475 100644 --- a/Furious/Window/TextEditorWindow.py +++ b/Furious/Window/TextEditorWindow.py @@ -400,6 +400,10 @@ class TextEditorWindow(AppQMainWindow): def closeEvent(self, event: QtCore.QEvent): """Handle closure of the text editor window.""" if self.modified: + # A close event arrives accepted by default. Keep the reusable + # editor alive unless the user explicitly saves or discards. + event.ignore() + mbox = MBoxQuestionSave(icon=AppQMessageBox.Icon.Question, parent=self) mbox.setWindowModality(QtCore.Qt.WindowModality.WindowModal) @@ -428,8 +432,11 @@ class TextEditorWindow(AppQMainWindow): # Show the MessageBox and wait for the user to close it mbox.exec() + + if event.isAccepted(): + super().closeEvent(event) else: - event.accept() + super().closeEvent(event) def retranslate(self): # Do nothing