From 370cebe170f401dddfea3d5b00bd5ddd0c0de041 Mon Sep 17 00:00:00 2001 From: Loren Eteval Date: Sun, 9 Aug 2026 11:36:00 +0800 Subject: [PATCH] Refine Qt top-level widget lifetimes Signed-off-by: Loren Eteval --- Furious/Qt/QtWidgets.py | 271 +++++++++------------ Furious/Widget/ConnectionProgressWidget.py | 1 + Furious/Widget/ServerTableView.py | 1 - Furious/Widget/SubscriptionTableView.py | 1 - Furious/Window/QRCodeWindow.py | 24 -- 5 files changed, 122 insertions(+), 176 deletions(-) diff --git a/Furious/Qt/QtWidgets.py b/Furious/Qt/QtWidgets.py index 84284b2..55c77fa 100644 --- a/Furious/Qt/QtWidgets.py +++ b/Furious/Qt/QtWidgets.py @@ -65,29 +65,6 @@ __all__ = [ ] -_OpenDialogs = {} - - -def _releaseOpenDialog(key, *_args): - """Release the temporary strong reference for a finished dialog.""" - _OpenDialogs.pop(key, None) - - -def _retainOpenDialog(dialog): - """Keep a shown dialog alive until it finishes or is destroyed.""" - key = id(dialog) - _OpenDialogs[key] = dialog - - if not getattr(dialog, '_furiousOpenDialogLifetimeConnected', False): - release = functools.partial(_releaseOpenDialog, key) - - dialog.finished.connect(release) - dialog.destroyed.connect(release) - dialog._furiousOpenDialogLifetimeConnected = True - - return key - - def moveToCenter(widget, parent=None): """Move to center.""" geometry = widget.frameGeometry() @@ -134,11 +111,32 @@ class AppQComboBox(Mixins.QTranslatable, QComboBox): class AppQDialog(Mixins.QTranslatable, Mixins.ConnectionAware, QDialog): """Present the app Qt dialog.""" + _openDialogs = {} + + @staticmethod + def _releaseOpenDialog(key, *_args): + """Release an asynchronously opened dialog after it finishes.""" + AppQDialog._openDialogs.pop(key, None) + def __init__(self, *args, **kwargs): """Initialize the AppQDialog.""" super().__init__(*args, **kwargs) - self._firstShowCall = True + @callOnceOnly + def connect(key): + """Connect the lifetime release signals once.""" + release = functools.partial(AppQDialog._releaseOpenDialog, key) + + self.finished.connect(release) + self.destroyed.connect(release) + + @callOnceOnly + def firstShow(): + """Apply the first-show sizing once.""" + self.setWidthAndHeight() + + self._connectOnce = connect + self._firstShow = firstShow if PLATFORM != 'Darwin': self.setWidthAndHeight() @@ -156,27 +154,29 @@ class AppQDialog(Mixins.QTranslatable, Mixins.ConnectionAware, QDialog): return super().exec() def open(self): - """Open the app Qt dialog asynchronously.""" - self.show() + """Open and retain the dialog until it finishes or is destroyed.""" + key = id(self) + AppQDialog._openDialogs[key] = self - return super().open() - - def show(self): - """Show and position the app Qt dialog.""" - key = _retainOpenDialog(self) + self._connectOnce(key) try: - super().show() + self.show() + + return super().open() except Exception: - _releaseOpenDialog(key) + # Any non-exit exceptions + + AppQDialog._releaseOpenDialog(key) raise - if PLATFORM == 'Darwin': - if self._firstShowCall: - self.setWidthAndHeight() + def show(self): + """Show and position the app Qt dialog.""" + super().show() - self._firstShowCall = False + if PLATFORM == 'Darwin': + self._firstShow() moveToCenter(self) @@ -218,7 +218,7 @@ class AppQGroupBox(Mixins.QTranslatable, QGroupBox): self.setTitle(_(self.title())) -class AppQHeaderView(Mixins.CleanupOnExit, Mixins.ConnectionAware, QHeaderView): +class AppQHeaderView(Mixins.CleanupOnExit, QHeaderView): """Represent app q header view.""" def sectionSizeSettingsEmpty(self): @@ -250,7 +250,6 @@ class AppQHeaderView(Mixins.CleanupOnExit, Mixins.ConnectionAware, QHeaderView): self.sectionSizeTable = {} self.setSectionsClickable(True) - # self.setStyleSheet(self.getStyleSheet(AppHue.currentColor())) self.setFont(QFont(AppFontName())) # self.sectionResized.connect(self.handleSectionResized) @@ -308,23 +307,6 @@ class AppQHeaderView(Mixins.CleanupOnExit, Mixins.ConnectionAware, QHeaderView): else: self.setSectionResizeMode(index, AppQHeaderView.ResizeMode.Stretch) - @staticmethod - def getStyleSheet(color): - """Return style sheet.""" - return f'QHeaderView::section:hover {{ background-color: {color}; }}' - - def disconnectedCallback(self): - # self.setStyleSheet(self.getStyleSheet(AppHue.disconnectedColor())) - - """Update the app q header view for a disconnected state.""" - pass - - def connectedCallback(self): - # self.setStyleSheet(self.getStyleSheet(AppHue.connectedColor())) - - """Update the app q header view for a connected state.""" - pass - # Legacy method. Not used # @QtCore.Slot(int, int, int) # def handleSectionResized(self, index: int, oldSize: int, newSize: int): @@ -371,44 +353,18 @@ class AppQLineEdit(Mixins.QTranslatable, QLineEdit): self.setPlaceholderText(_(self.placeholderText())) -class AppQListWidget(Mixins.ConnectionAware, QListWidget): +class AppQListWidget(QListWidget): """Provide the app Qt list widget.""" def __init__(self, *args, **kwargs): """Initialize the AppQListWidget.""" super().__init__(*args, **kwargs) - # self.setSelectionColor(AppHue.disconnectedColor()) - - def setSelectionColor(self, color): - """Set selection color.""" - self.setStyleSheet( - f'QListWidget::item:selected {{' - f' background: {color};' - f'}}' - f'' - f'QListWidget::item:hover {{' - f' background: {color};' - f'}}' - ) - @property def selectedIndex(self): """Return the selected index value.""" return sorted(list(set(index.row() for index in self.selectedIndexes()))) - def disconnectedCallback(self): - # self.setSelectionColor(AppHue.disconnectedColor()) - - """Update the app Qt list widget for a disconnected state.""" - pass - - def connectedCallback(self): - # self.setSelectionColor(AppHue.connectedColor()) - - """Update the app Qt list widget for a connected state.""" - pass - class AppQMainWindow( Mixins.QTranslatable, @@ -418,11 +374,30 @@ class AppQMainWindow( ): """Present the app q main window.""" + _openWindows = {} + + @staticmethod + def _releaseOpenWindow(key, *_args): + """Release a shown main window after it closes or is destroyed.""" + AppQMainWindow._openWindows.pop(key, None) + def __init__(self, *args, **kwargs): """Initialize the AppQMainWindow.""" super().__init__(*args, **kwargs) - self._firstShowCall = True + @callOnceOnly + def connect(key): + """Connect the lifetime release signal once.""" + release = functools.partial(AppQMainWindow._releaseOpenWindow, key) + self.destroyed.connect(release) + + @callOnceOnly + def firstShow(): + """Apply the first-show sizing once.""" + self.setWidthAndHeight() + + self._connectOnce = connect + self._firstShow = firstShow self.setWindowIcon(AppHue.currentWindowIcon()) @@ -437,14 +412,23 @@ class AppQMainWindow( pass def show(self): - """Show and position the app q main window.""" - super().show() + """Show, position, and retain the window until it closes.""" + key = id(self) + AppQMainWindow._openWindows[key] = self + + self._connectOnce(key) + + try: + super().show() + except Exception: + # Any non-exit exceptions + + AppQMainWindow._releaseOpenWindow(key) + + raise if PLATFORM == 'Darwin': - if self._firstShowCall: - self.setWidthAndHeight() - - self._firstShowCall = False + self._firstShow() moveToCenter(self) @@ -454,6 +438,16 @@ class AppQMainWindow( self.activateWindow() self.raise_() + def event(self, event): + """Release this window after Qt accepts its close event.""" + closes = event.type() == QtCore.QEvent.Type.Close + result = super().event(event) + + if closes and event.isAccepted(): + AppQMainWindow._releaseOpenWindow(id(self)) + + return result + def retranslate(self): """Refresh translated text for the app q main window.""" self.setWindowTitle(_(self.windowTitle())) @@ -510,10 +504,27 @@ class AppQMenuBar(QMenuBar): class AppQMessageBox(Mixins.QTranslatable, Mixins.ConnectionAware, QMessageBox): """Represent app q message box.""" + _openMessageBoxes = {} + + @staticmethod + def _releaseOpenMessageBox(key, *_args): + """Release an asynchronously opened message box after it finishes.""" + AppQMessageBox._openMessageBoxes.pop(key, None) + def __init__(self, *args, **kwargs): """Initialize the AppQMessageBox.""" super().__init__(*args, **kwargs) + @callOnceOnly + def connect(key): + """Connect the lifetime release signals once.""" + release = functools.partial(AppQMessageBox._releaseOpenMessageBox, key) + + self.finished.connect(release) + self.destroyed.connect(release) + + self._connectOnce = connect + self.setWindowIcon(AppHue.currentWindowIcon()) def moveToCenter(self): @@ -523,15 +534,8 @@ class AppQMessageBox(Mixins.QTranslatable, Mixins.ConnectionAware, QMessageBox): return self def show(self): - """Show the message box and retain it until it finishes.""" - key = _retainOpenDialog(self) - - try: - return super().show() - except Exception: - _releaseOpenDialog(key) - - raise + """Show the app q message box.""" + return super().show() def exec(self): """Show and execute the app q message box modally.""" @@ -541,11 +545,23 @@ class AppQMessageBox(Mixins.QTranslatable, Mixins.ConnectionAware, QMessageBox): return super().exec() def open(self): - """Open the app q message box asynchronously.""" - self.show() - self.moveToCenter() + """Open and retain the message box until it finishes or is destroyed.""" + key = id(self) + AppQMessageBox._openMessageBoxes[key] = self - return super().open() + self._connectOnce(key) + + try: + self.show() + self.moveToCenter() + + return super().open() + except Exception: + # Any non-exit exceptions + + AppQMessageBox._releaseOpenMessageBox(key) + + raise def retranslate(self): """Refresh translated text for the app q message box.""" @@ -669,7 +685,7 @@ class AppQSpinBox(QSpinBox): self.resizeHints() -class AppQTableView(Mixins.ConnectionAware, QTableView): +class AppQTableView(QTableView): """Represent app Qt table view.""" def __init__(self, *args, **kwargs): @@ -684,27 +700,8 @@ class AppQTableView(Mixins.ConnectionAware, QTableView): self.verticalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Fixed) self.verticalHeader().setDefaultSectionSize(height) - @staticmethod - def getStyleSheet(color): - """Return style sheet.""" - return f'QTableView {{ selection-background-color: {color}; }}' - def setSelectionColor(self, color): - # self.setStyleSheet(self.getStyleSheet(color)) - - """Set selection color.""" - pass - - def disconnectedCallback(self): - """Update the app Qt table view for a disconnected state.""" - self.setSelectionColor(AppHue.disconnectedColor()) - - def connectedCallback(self): - """Update the app Qt table view for a connected state.""" - self.setSelectionColor(AppHue.connectedColor()) - - -class AppQTableWidget(Mixins.ConnectionAware, QTableWidget): +class AppQTableWidget(QTableWidget): """Provide the app Qt table widget.""" def __init__(self, *args, **kwargs): @@ -719,17 +716,6 @@ class AppQTableWidget(Mixins.ConnectionAware, QTableWidget): """Return the selected index value.""" return sorted(list(set(index.row() for index in self.selectedIndexes()))) - @staticmethod - def getStyleSheet(color): - """Return style sheet.""" - return f'QTableWidget {{ selection-background-color: {color}; }}' - - def setSelectionColor(self, color): - # self.setStyleSheet(self.getStyleSheet(color)) - - """Set selection color.""" - pass - def activateItemByIndex(self, index, activate): """Activate item by index.""" if activate: @@ -776,14 +762,6 @@ class AppQTableWidget(Mixins.ConnectionAware, QTableWidget): selection, QtCore.QItemSelectionModel.SelectionFlag.Select ) - def disconnectedCallback(self): - """Update the app Qt table widget for a disconnected state.""" - self.setSelectionColor(AppHue.disconnectedColor()) - - def connectedCallback(self): - """Update the app Qt table widget for a connected state.""" - self.setSelectionColor(AppHue.connectedColor()) - class AppQTabWidget(Mixins.QTranslatable, QTabWidget): """Provide the app q tab widget.""" @@ -818,8 +796,6 @@ class AppQToolBar(Mixins.QTranslatable, QToolBar): # Do nothing pass - self.setStyleSheet(self.getStyleSheet()) - self.actionTriggered.connect(self.showMenuBelow) @QtCore.Slot(AppQAction) @@ -847,11 +823,6 @@ class AppQToolBar(Mixins.QTranslatable, QToolBar): if isinstance(menu, AppQMenu): menu.exec(button.mapToGlobal(button.rect().bottomLeft())) - @staticmethod - def getStyleSheet(): - """Return style sheet.""" - return f'QToolBar {{ spacing: 5px; }}' - def retranslate(self): """Refresh translated text for the app q tool bar.""" self.setWindowTitle(_(self.windowTitle())) diff --git a/Furious/Widget/ConnectionProgressWidget.py b/Furious/Widget/ConnectionProgressWidget.py index 00bec46..773eb4f 100644 --- a/Furious/Widget/ConnectionProgressWidget.py +++ b/Furious/Widget/ConnectionProgressWidget.py @@ -66,6 +66,7 @@ class ConnectionProgressBar(Mixins.ConnectionAware, QProgressBar): style = self.style() style.unpolish(self) style.polish(self) + self.update() def start(self, msec: int): diff --git a/Furious/Widget/ServerTableView.py b/Furious/Widget/ServerTableView.py index 31ee158..c972d99 100644 --- a/Furious/Widget/ServerTableView.py +++ b/Furious/Widget/ServerTableView.py @@ -1444,7 +1444,6 @@ class ServerTableView( self.proxyModel.sort(-1) # Selection - self.setSelectionColor(AppHue.disconnectedColor()) self.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) diff --git a/Furious/Widget/SubscriptionTableView.py b/Furious/Widget/SubscriptionTableView.py index 642eea7..4d5e620 100644 --- a/Furious/Widget/SubscriptionTableView.py +++ b/Furious/Widget/SubscriptionTableView.py @@ -341,7 +341,6 @@ class SubscriptionTableView(Mixins.QTranslatable, AppQTableView): self.setSortingEnabled(False) # Selection - self.setSelectionColor(AppHue.disconnectedColor()) self.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) diff --git a/Furious/Window/QRCodeWindow.py b/Furious/Window/QRCodeWindow.py index ec4d57b..76536e0 100644 --- a/Furious/Window/QRCodeWindow.py +++ b/Furious/Window/QRCodeWindow.py @@ -30,18 +30,10 @@ from PySide6.QtGui import * from PySide6.QtWidgets import * import io -import functools import pyqrcode __all__ = ['QRCodeWindow'] -_OpenQRCodeWindows = {} - - -def _releaseOpenQRCodeWindow(key, *_args): - """Release an exported QR-code window after Qt destroys it.""" - _OpenQRCodeWindows.pop(key, None) - class QRCodeWindow(AppQMainWindow): """Present the QR code window.""" @@ -54,28 +46,12 @@ class QRCodeWindow(AppQMainWindow): self.setFixedSize(640, 640) self.setAttribute(QtCore.Qt.WidgetAttribute.WA_DeleteOnClose, True) - self._lifetimeKey = id(self) - self.destroyed.connect( - functools.partial(_releaseOpenQRCodeWindow, self._lifetimeKey) - ) - self.tabWidget = QTabWidget(self) self.tabWidget.setTabsClosable(True) self.tabWidget.tabCloseRequested.connect(self.handleTabCloseRequested) self.setCentralWidget(self.tabWidget) - def show(self): - """Show and retain this unparented window until Qt destroys it.""" - _OpenQRCodeWindows[self._lifetimeKey] = self - - try: - return super().show() - except Exception: - _releaseOpenQRCodeWindow(self._lifetimeKey) - - raise - def tabCount(self) -> int: """Return the tab count value used by the QR code window.""" return self.tabWidget.count()