diff --git a/Furious/Application/DesktopApplication.py b/Furious/Application/DesktopApplication.py index 3876e6c..18c2684 100644 --- a/Furious/Application/DesktopApplication.py +++ b/Furious/Application/DesktopApplication.py @@ -58,7 +58,7 @@ from Furious.Controllers import ( ) from Furious.Extensions import BUNDLED_EXTENSION_TYPES from Furious.Plugins import initializePluginRegistry -from Furious.Qt import AppQMessageBox, AppStyleSheet +from Furious.Qt import AppQMessageBox, AppStyleSheet, ThemeTransition from Furious.Qt.TextEditorTheme import configureEditorLogMetadata from Furious.Qt import gettext as _ from Furious.Repository import Storage @@ -419,6 +419,8 @@ class DesktopApplication(ApplicationRunner, SingletonApplication): # Theme Detect self.currentTheme = None self.themeDetectTimer = None + self._appliedTheme = None + self.themeTransition = ThemeTransition(parent=self) self.mainWindow = None self.systemTray = None @@ -799,6 +801,8 @@ class DesktopApplication(ApplicationRunner, SingletonApplication): def _cleanupUI(self): """Hide and release application-owned top-level UI objects.""" + self.themeTransition.stop() + if self.systemTray is not None: self.systemTray.hide() self.systemTray.deleteLater() @@ -895,13 +899,27 @@ class DesktopApplication(ApplicationRunner, SingletonApplication): """Handle apply style sheet for theme for the application.""" self.setStyleSheet(AppStyleSheet.forTheme(theme)) + def _applyResolvedTheme(self, theme, notifyThemeAware): + """Apply one effective theme through the centralized transition path.""" + theme = AppStyleSheet.normalizeTheme(theme) + animate = self._appliedTheme is not None and self._appliedTheme != theme + + def applyTheme(): + """Commit the destination style and refresh all derived visuals.""" + self.applyStyleSheetForTheme(theme) + self._appliedTheme = theme + notifyThemeAware(theme) + + self.themeTransition.apply(applyTheme, animate=animate) + def applyThemePreference(self): """Apply the resolved preference and refresh every theme-aware object.""" theme = self.theme() - self.applyStyleSheetForTheme(theme) - - Mixins.ThemeAware.callThemeChangedCallbackUnchecked(theme) + self._applyResolvedTheme( + theme, + Mixins.ThemeAware.callThemeChangedCallbackUnchecked, + ) @QtCore.Slot(str) def handleSystemThemeChanged(self, theme): @@ -917,9 +935,7 @@ class DesktopApplication(ApplicationRunner, SingletonApplication): if theme not in [AppStyleSheet.Dark, AppStyleSheet.Light]: theme = self.systemTheme() - self.applyStyleSheetForTheme(theme) - - Mixins.ThemeAware.callThemeChangedCallback(theme) + self._applyResolvedTheme(theme, Mixins.ThemeAware.callThemeChangedCallback) @QtCore.Slot() def cleanup(self): diff --git a/Furious/Qt/AGENTS.md b/Furious/Qt/AGENTS.md index 3fad7ed..dcf466a 100644 --- a/Furious/Qt/AGENTS.md +++ b/Furious/Qt/AGENTS.md @@ -32,6 +32,10 @@ Use the `manage-qt-pyside6-lifetimes` skill for Qt ownership or lifecycle work. dispose timers, models, delegates, replies, event filters, menus, actions, shortcuts, watchers, animations, and effects. - Only the GUI thread mutates widgets. Slots do not sleep or perform unbounded host/network/process work. Create coalescing/render timers once and do not multiply them across show/hide cycles. +- Theme changes commit the destination QSS and `ThemeAware` state immediately through the application-owned + `ThemeTransition`; its non-interactive, per-window snapshots are presentation-only and must be interrupted or + disposed on replacement, geometry change, window teardown, and application cleanup. Do not add competing per-widget + theme animations. - Each `QNetworkReply` has one manager/context owner, one freshness rule, and one terminal deletion path. Shared slots use `sender()`/stored context rather than per-reply closures; do not attach ad-hoc attributes to third-party Qt objects. diff --git a/Furious/Qt/ThemeTransition.py b/Furious/Qt/ThemeTransition.py new file mode 100644 index 0000000..a700c94 --- /dev/null +++ b/Furious/Qt/ThemeTransition.py @@ -0,0 +1,266 @@ +# 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 one application-wide, interruption-safe theme cross-fade.""" + +from __future__ import annotations + +from PySide6 import QtCore, QtGui +from PySide6.QtWidgets import QApplication, QStyle, QWidget + +from .Signals import connectWeakly + +from shiboken6 import isValid + +from collections.abc import Callable, Iterable + +__all__ = ['ThemeTransition'] + + +class _ThemeSnapshotOverlay(QWidget): + """Paint one old-theme window snapshot without intercepting input.""" + + def __init__(self, snapshot: QtGui.QPixmap, parent: QWidget): + """Initialize an overlay owned by its source top-level window.""" + super().__init__(parent) + + self._snapshot = snapshot + self._opacity = 1.0 + + self.setObjectName(ThemeTransition.OverlayObjectName) + self.setAttribute(QtCore.Qt.WidgetAttribute.WA_TransparentForMouseEvents) + self.setAttribute(QtCore.Qt.WidgetAttribute.WA_NoSystemBackground) + self.setAttribute(QtCore.Qt.WidgetAttribute.WA_TranslucentBackground) + self.setFocusPolicy(QtCore.Qt.FocusPolicy.NoFocus) + self.setGeometry(parent.rect()) + + def opacity(self) -> float: + """Return the currently painted snapshot opacity.""" + return self._opacity + + def setOpacity(self, opacity: float): + """Set the snapshot opacity and schedule a repaint.""" + opacity = min(max(float(opacity), 0.0), 1.0) + + if self._opacity == opacity: + return + + self._opacity = opacity + self.update() + + opacityValue = QtCore.Property(float, opacity, setOpacity) + + def paintEvent(self, _event): + """Paint the device-pixel-ratio-aware snapshot.""" + painter = QtGui.QPainter(self) + painter.setOpacity(self._opacity) + painter.drawPixmap(QtCore.QPoint(0, 0), self._snapshot) + + +class ThemeTransition(QtCore.QObject): + """Cross-fade visible top-level widgets after an immediate theme change.""" + + DefaultDuration = 500 + OverlayObjectName = 'FuriousThemeTransitionOverlay' + + transitionStarted = QtCore.Signal() + transitionFinished = QtCore.Signal() + + def __init__( + self, + parent=None, + *, + duration: int = DefaultDuration, + windowProvider: Callable[[], Iterable[QWidget]] | None = None, + animationsEnabled: Callable[[], bool] | None = None, + ): + """Initialize the process-lifetime transition coordinator.""" + super().__init__(parent) + + self._duration = max(int(duration), 1) + self._windowProvider = windowProvider or QApplication.topLevelWidgets + self._animationsEnabled = animationsEnabled + self._animations = {} + self._animationsByWindow = {} + + def isRunning(self) -> bool: + """Return whether any window snapshot is currently fading.""" + return bool(self._animations) + + def activeOverlayCount(self) -> int: + """Return the number of active per-window snapshot overlays.""" + return len(self._animations) + + def _styleAllowsAnimations(self) -> bool: + """Honor Qt's platform/style animation preference when available.""" + if self._animationsEnabled is not None: + return bool(self._animationsEnabled()) + + application = QApplication.instance() + + if application is None or application.closingDown(): + return False + + style = application.style() + + if style is None: + return True + + duration = style.styleHint(QStyle.StyleHint.SH_Widget_Animation_Duration) + + return duration != 0 + + @staticmethod + def _canCapture(window) -> bool: + """Return whether *window* is a visible application top-level widget.""" + return ( + isinstance(window, QWidget) + and isValid(window) + and window.isWindow() + and window.isVisible() + and not window.size().isEmpty() + and window.windowType() != QtCore.Qt.WindowType.Desktop + ) + + def _captureWindows(self): + """Capture each currently visible window, including an interrupted fade.""" + captures = [] + seen = set() + + for window in self._windowProvider(): + if not self._canCapture(window) or id(window) in seen: + continue + + seen.add(id(window)) + snapshot = window.grab() + + if not snapshot.isNull(): + captures.append((window, snapshot)) + + return captures + + def apply(self, applyTheme: Callable[[], None], *, animate: bool = True): + """Apply a theme immediately and fade from the previously visible state.""" + if not callable(applyTheme): + raise TypeError('theme application callback must be callable') + + captures = [] + + if animate and self._styleAllowsAnimations(): + captures = self._captureWindows() + + # Capturing first preserves the user's current composite appearance when a + # rapid second switch interrupts an in-progress transition. + self.stop() + + applyTheme() + + for window, snapshot in captures: + if not self._canCapture(window): + continue + + overlay = _ThemeSnapshotOverlay(snapshot, window) + + animation = QtCore.QPropertyAnimation( + overlay, + b'opacityValue', + self, + ) + animation.setDuration(self._duration) + animation.setStartValue(1.0) + animation.setEndValue(0.0) + animation.setEasingCurve(QtCore.QEasingCurve.Type.OutCubic) + + self._animations[animation] = (window, overlay) + self._animationsByWindow[window] = animation + + window.installEventFilter(self) + + connectWeakly( + animation.finished, + self, + '_handleAnimationFinished', + sender=animation, + forwardSender=True, + ) + + overlay.show() + overlay.raise_() + + if not self._animations: + return + + self.transitionStarted.emit() + + for animation in tuple(self._animations): + animation.start() + + def _releaseAnimation(self, animation, *, notify=True): + """Release one animation and its transient overlay exactly once.""" + transition = self._animations.pop(animation, None) + + if transition is None: + return + + window, overlay = transition + + self._animationsByWindow.pop(window, None) + + try: + if isValid(window): + window.removeEventFilter(self) + except RuntimeError: + pass + + if isValid(animation): + animation.stop() + animation.deleteLater() + + if isValid(overlay): + overlay.hide() + overlay.deleteLater() + + if notify and not self._animations: + self.transitionFinished.emit() + + @QtCore.Slot(QtCore.QObject) + def _handleAnimationFinished(self, animation): + """Release the overlay belonging to a completed animation.""" + self._releaseAnimation(animation) + + def eventFilter(self, watched, event): + """Drop stale snapshots when a transitioning window changes geometry.""" + if watched in self._animationsByWindow and event.type() in ( + QtCore.QEvent.Type.Resize, + QtCore.QEvent.Type.WindowStateChange, + QtCore.QEvent.Type.Hide, + QtCore.QEvent.Type.Close, + QtCore.QEvent.Type.Destroy, + ): + self._releaseAnimation(self._animationsByWindow.get(watched)) + + return super().eventFilter(watched, event) + + def stop(self): + """Stop and dispose every active transition without leaving an overlay.""" + wasRunning = self.isRunning() + + for animation in tuple(self._animations): + self._releaseAnimation(animation, notify=False) + + if wasRunning: + self.transitionFinished.emit() diff --git a/Furious/Qt/__init__.py b/Furious/Qt/__init__.py index 70b3fa0..19c3d89 100644 --- a/Furious/Qt/__init__.py +++ b/Furious/Qt/__init__.py @@ -53,6 +53,7 @@ from .QtGui import ( ) from .QtNetwork import AppQNetworkAccessManager from .Signals import connectWeakly, singleShotWeakly +from .ThemeTransition import ThemeTransition from .QtWidgets import ( AppQComboBox, AppQComboBoxSeparatorDelegate, @@ -159,6 +160,7 @@ __all__ = [ 'MBoxQuestionDelete', 'MBoxUnrecognizedConfig', 'SUPPORTED_LANGUAGE', + 'ThemeTransition', 'HttpGetManager', 'bootstrapIcon', 'bootstrapIconMask', diff --git a/tests/README.md b/tests/README.md index 813e447..dceae92 100644 --- a/tests/README.md +++ b/tests/README.md @@ -54,6 +54,7 @@ strategy in an individual test. | Bounded service work, update validation, plugin UI, and worker lifetime | `test_service_runtime.py` | | Frozenlib state helpers and mocked platform-operation boundaries | `test_frozenlib.py` | | Settings sandbox, navigation overlay behavior, public exports, and scale/theme isolation | `test_isolation_and_navigation.py`, `test_public_api.py`, `test_layout_matrix.py` | +| Theme cross-fade activation, interruption, multi-window cleanup, and animation policy | `test_theme_transition.py` | | AppQMainWindow lifecycle, subclass policies, geometry restoration, and migration | `test_main_window_geometry.py` | | AppQDialog first-presentation geometry, native show paths, centering, and async lifetime | `test_dialog_geometry.py` | | Editor mappings, lazy log rendering, routing/message-box/connection UI | `test_ui_behavior.py` | @@ -107,7 +108,7 @@ Then run the desired test tier. python -m unittest discover -s tests -v # Regular logic, persistence, plugin, controller, codec, and UI regressions -python -m unittest tests.test_interface tests.test_models_and_services tests.test_repository_contracts tests.test_architecture_refactors tests.test_plugin_architecture tests.test_hysteria1_protocol tests.test_hysteria2_compatibility tests.test_controllers tests.test_subscription_manager tests.test_subscription_sync tests.test_socks_uri tests.test_shadowsocks_uri tests.test_backend_editor_contract tests.test_xray_asset_download tests.test_native_tun_semantics tests.test_metrics_behavior tests.test_endpoint_info tests.test_service_runtime tests.test_frozenlib tests.test_isolation_and_navigation tests.test_main_window_geometry tests.test_dialog_geometry tests.test_ui_behavior tests.test_qt_interactions tests.test_stylesheet_states tests.test_public_api -v +python -m unittest tests.test_interface tests.test_models_and_services tests.test_repository_contracts tests.test_architecture_refactors tests.test_plugin_architecture tests.test_hysteria1_protocol tests.test_hysteria2_compatibility tests.test_controllers tests.test_subscription_manager tests.test_subscription_sync tests.test_socks_uri tests.test_shadowsocks_uri tests.test_backend_editor_contract tests.test_xray_asset_download tests.test_native_tun_semantics tests.test_metrics_behavior tests.test_endpoint_info tests.test_service_runtime tests.test_frozenlib tests.test_isolation_and_navigation tests.test_main_window_geometry tests.test_dialog_geometry tests.test_ui_behavior tests.test_qt_interactions tests.test_stylesheet_states tests.test_theme_transition tests.test_public_api -v # Direct Qt/process integration and destruction/lifetime checks python -m unittest tests.test_application_process tests.test_external_core tests.test_layout_matrix tests.test_qt_lifetime -v @@ -124,7 +125,7 @@ python -m unittest tests.test_very_heavy -v python -m unittest tests.test_log_manager_generation.VeryHeavyGenerationLogManagerTest -v # Shared-state order-independence spot check -python -m unittest tests.test_public_api tests.test_stylesheet_states tests.test_qt_interactions tests.test_ui_behavior tests.test_dialog_geometry tests.test_main_window_geometry tests.test_isolation_and_navigation tests.test_frozenlib tests.test_service_runtime tests.test_endpoint_info tests.test_metrics_behavior tests.test_native_tun_semantics tests.test_xray_asset_download tests.test_backend_editor_contract tests.test_shadowsocks_uri tests.test_socks_uri tests.test_subscription_sync tests.test_subscription_manager tests.test_controllers tests.test_hysteria2_compatibility tests.test_hysteria1_protocol tests.test_plugin_architecture tests.test_architecture_refactors tests.test_repository_contracts tests.test_models_and_services tests.test_interface -v +python -m unittest tests.test_public_api tests.test_theme_transition tests.test_stylesheet_states tests.test_qt_interactions tests.test_ui_behavior tests.test_dialog_geometry tests.test_main_window_geometry tests.test_isolation_and_navigation tests.test_frozenlib tests.test_service_runtime tests.test_endpoint_info tests.test_metrics_behavior tests.test_native_tun_semantics tests.test_xray_asset_download tests.test_backend_editor_contract tests.test_shadowsocks_uri tests.test_socks_uri tests.test_subscription_sync tests.test_subscription_manager tests.test_controllers tests.test_hysteria2_compatibility tests.test_hysteria1_protocol tests.test_plugin_architecture tests.test_architecture_refactors tests.test_repository_contracts tests.test_models_and_services tests.test_interface -v python -m unittest discover -s tests -v ``` diff --git a/tests/test_architecture_refactors.py b/tests/test_architecture_refactors.py index affa1e5..6e35bcc 100644 --- a/tests/test_architecture_refactors.py +++ b/tests/test_architecture_refactors.py @@ -197,6 +197,53 @@ class ApplicationLifecycleTransactionTest(TestCase): self.assertIn('command-line arguments: 2 provided', output) self.assertNotIn(secret, output) + def testResolvedThemeSkipsStartupAnimationAndTransitionsLaterChanges(self): + """Commit both paths immediately but animate only a changed live theme.""" + transitionCalls = [] + + class ImmediateTransition: + """Record transition policy while invoking the real commit callback.""" + + @staticmethod + def apply(callback, *, animate): + transitionCalls.append(animate) + callback() + + application = SimpleNamespace( + _appliedTheme=None, + themeTransition=ImmediateTransition(), + applyStyleSheetForTheme=mock.Mock(), + ) + notifyThemeAware = mock.Mock() + + DesktopApplication._applyResolvedTheme( + application, + AppStyleSheet.Light, + notifyThemeAware, + ) + DesktopApplication._applyResolvedTheme( + application, + AppStyleSheet.Dark, + notifyThemeAware, + ) + + self.assertEqual(transitionCalls, [False, True]) + self.assertEqual(application._appliedTheme, AppStyleSheet.Dark) + self.assertEqual( + application.applyStyleSheetForTheme.call_args_list, + [ + mock.call(AppStyleSheet.Light), + mock.call(AppStyleSheet.Dark), + ], + ) + self.assertEqual( + notifyThemeAware.call_args_list, + [ + mock.call(AppStyleSheet.Light), + mock.call(AppStyleSheet.Dark), + ], + ) + def testInvalidCoreLaunchDiagnosticsDoNotRenderPayloads(self): """Describe invalid launch types without rendering secret arguments.""" secret = 'password=do-not-log-this' @@ -983,13 +1030,16 @@ class ApplicationLifecycleTransactionTest(TestCase): def testUICleanupRemovesMainWindowEventFilter(self): """Detach application policy before releasing the persistent window.""" mainWindow = mock.Mock() + themeTransition = mock.Mock() application = SimpleNamespace( mainWindow=mainWindow, systemTray=None, + themeTransition=themeTransition, ) DesktopApplication._cleanupUI(application) + themeTransition.stop.assert_called_once_with() mainWindow.removeEventFilter.assert_called_once_with(application) mainWindow.hide.assert_called_once_with() mainWindow.deleteLater.assert_called_once_with() diff --git a/tests/test_theme_transition.py b/tests/test_theme_transition.py new file mode 100644 index 0000000..9d83aea --- /dev/null +++ b/tests/test_theme_transition.py @@ -0,0 +1,199 @@ +# 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 . + +"""Verify real Qt animation and ownership for application theme transitions.""" + +from __future__ import annotations + +import unittest + +from PySide6 import QtCore +from PySide6.QtTest import QSignalSpy +from PySide6.QtWidgets import QWidget + +from shiboken6 import isValid + +from Furious.Qt import ThemeTransition + +from tests.support import application, processQtEvents, waitFor + + +class ThemeTransitionTest(unittest.TestCase): + """Exercise cross-fades through the real Qt event loop.""" + + def setUp(self): + """Create per-test windows while retaining one process-wide application.""" + application() + + self.windows = [] + self.transitions = [] + + def tearDown(self): + """Release every transient overlay, animation, coordinator, and window.""" + for transition in self.transitions: + transition.stop() + transition.deleteLater() + + for window in self.windows: + window.close() + window.deleteLater() + + processQtEvents() + + def createWindow(self): + """Create and show one deterministic top-level transition target.""" + window = QWidget() + window.resize(320, 180) + window.show() + + self.windows.append(window) + + processQtEvents() + + return window + + def createTransition(self, windows, *, duration=60, enabled=True): + """Create one coordinator with deterministic animation policy.""" + transition = ThemeTransition( + duration=duration, + windowProvider=lambda: tuple(windows), + animationsEnabled=lambda: enabled, + ) + + self.transitions.append(transition) + + return transition + + @staticmethod + def overlays(window): + """Return the transition overlays currently owned by *window*.""" + return window.findChildren( + QWidget, + ThemeTransition.OverlayObjectName, + QtCore.Qt.FindChildOption.FindDirectChildrenOnly, + ) + + def testThemeIsAppliedImmediatelyThenSnapshotCompletesAndIsRemoved(self): + """Keep destination state live beneath one real fading snapshot.""" + window = self.createWindow() + transition = self.createTransition([window]) + started = QSignalSpy(transition.transitionStarted) + finished = QSignalSpy(transition.transitionFinished) + + def applyDarkTheme(): + """Represent the application's synchronous destination-theme commit.""" + window.setProperty('testTheme', 'Dark') + window.setStyleSheet('background-color: #10151d;') + + transition.apply(applyDarkTheme) + + self.assertEqual(window.property('testTheme'), 'Dark') + self.assertTrue(transition.isRunning()) + self.assertEqual(transition.activeOverlayCount(), 1) + self.assertEqual(len(self.overlays(window)), 1) + self.assertEqual(started.count(), 1) + self.assertTrue( + transition.findChildren(QtCore.QPropertyAnimation), + 'the transition must use a real QPropertyAnimation', + ) + + self.assertTrue(waitFor(lambda: not transition.isRunning())) + processQtEvents() + + self.assertEqual(finished.count(), 1) + self.assertEqual(transition.activeOverlayCount(), 0) + self.assertEqual(self.overlays(window), []) + self.assertEqual( + transition.findChildren(QtCore.QPropertyAnimation), + [], + ) + + def testRapidRepeatedSwitchReplacesRatherThanStacksTransitions(self): + """Restart from the visible composite and retain only the newest target.""" + window = self.createWindow() + transition = self.createTransition([window], duration=160) + started = QSignalSpy(transition.transitionStarted) + finished = QSignalSpy(transition.transitionFinished) + + transition.apply(lambda: window.setProperty('testTheme', 'Dark')) + firstOverlay = self.overlays(window)[0] + + transition.apply(lambda: window.setProperty('testTheme', 'Light')) + + self.assertEqual(window.property('testTheme'), 'Light') + self.assertEqual(transition.activeOverlayCount(), 1) + self.assertEqual(started.count(), 2) + self.assertEqual(finished.count(), 1) + + processQtEvents() + + self.assertFalse(isValid(firstOverlay)) + self.assertEqual(len(self.overlays(window)), 1) + self.assertTrue(waitFor(lambda: not transition.isRunning())) + processQtEvents() + + self.assertEqual(finished.count(), 2) + self.assertEqual(self.overlays(window), []) + + def testMultipleWindowsTransitionAndResizeIndependently(self): + """Cancel only a resized window's stale snapshot while peers continue.""" + firstWindow = self.createWindow() + secondWindow = self.createWindow() + transition = self.createTransition( + [firstWindow, secondWindow], + duration=120, + ) + finished = QSignalSpy(transition.transitionFinished) + + def applyLightTheme(): + """Commit the same destination theme to both top-level windows.""" + firstWindow.setProperty('testTheme', 'Light') + secondWindow.setProperty('testTheme', 'Light') + + transition.apply(applyLightTheme) + + self.assertEqual(transition.activeOverlayCount(), 2) + firstWindow.resize(360, 200) + processQtEvents() + + self.assertEqual(firstWindow.property('testTheme'), 'Light') + self.assertEqual(secondWindow.property('testTheme'), 'Light') + self.assertEqual(self.overlays(firstWindow), []) + self.assertEqual(transition.activeOverlayCount(), 1) + self.assertTrue(transition.isRunning()) + self.assertTrue(waitFor(lambda: not transition.isRunning())) + processQtEvents() + + self.assertEqual(finished.count(), 1) + self.assertEqual(self.overlays(secondWindow), []) + + def testDisabledAnimationsNeverDelayOrOverlayTheDestinationTheme(self): + """Honor the animation policy while preserving immediate theme activation.""" + window = self.createWindow() + transition = self.createTransition([window], enabled=False) + started = QSignalSpy(transition.transitionStarted) + + transition.apply(lambda: window.setProperty('testTheme', 'Dark')) + + self.assertEqual(window.property('testTheme'), 'Dark') + self.assertFalse(transition.isRunning()) + self.assertEqual(started.count(), 0) + self.assertEqual(self.overlays(window), []) + + +if __name__ == '__main__': + unittest.main()