From f90b8234a64da54a6b031b62e9554b9cd44b0e78 Mon Sep 17 00:00:00 2001 From: Antoine HAFFREINGUE Date: Mon, 15 Jun 2026 16:55:55 +0200 Subject: [PATCH 1/2] Fix screenCurtain/Fullscreen conflict --- source/_magnifier/fullscreenMagnifier.py | 101 +++++++++++++---- source/_magnifier/magnifier.py | 51 --------- source/screenCurtain/_screenCurtain.py | 8 +- .../test_fullscreenMagnifier.py | 107 ++++++++++++++---- 4 files changed, 168 insertions(+), 99 deletions(-) diff --git a/source/_magnifier/fullscreenMagnifier.py b/source/_magnifier/fullscreenMagnifier.py index 95aff7a3b9c..07c8ede287f 100644 --- a/source/_magnifier/fullscreenMagnifier.py +++ b/source/_magnifier/fullscreenMagnifier.py @@ -10,6 +10,7 @@ from typing import override from logHandler import log +import screenCurtain import speech import ui from winBindings import magnification @@ -40,6 +41,7 @@ def __init__(self): self.currentCoordinates = Coordinates(0, 0) self._spotlightManager = SpotlightManager(self) self._displaySize = Size(self._displayOrientation.width, self._displayOrientation.height) + self._screenCurtainIsActive: bool = False @Magnifier.filterType.setter def filterType(self, value: Filter) -> None: @@ -55,11 +57,65 @@ def event_gainFocus( log.debug("Full-screen Magnifier gain focus event") nextHandler() + def _isBlockedByScreenCurtain(self) -> bool: + """ + Check if the screen curtain is active and block magnifier start accordingly. + + Returns True if the magnifier should not start. + At startup, defers silently so the magnifier auto-restarts when the screen curtain is disabled. + """ + if not (screenCurtain.screenCurtain and screenCurtain.screenCurtain.enabled): + return False + from NVDAState import _TrackNVDAInitialization + + if _TrackNVDAInitialization.isInitializationComplete(): + log.debug("Screen curtain is active, cannot start magnifier") + message = pgettext( + "magnifier", + # Translators: Message when trying to enable magnifier while screen curtain is active + "Cannot enable magnifier. Please disable screen curtain first.", + ) + ui.message(message, speechPriority=speech.priorities.Spri.NOW) + else: + self._screenCurtainIsActive = True + return True + + def onScreenCurtainEnabled(self) -> None: + """Called by screen curtain when it is enabled. Stops the magnifier if active.""" + if self._isActive: + ui.message( + pgettext( + "magnifier", + # Translators: Spoken message when magnifier is disabled due to screen curtain being enabled. + "Disabling magnifier", + ), + ) + self._stopMagnifier() + self._screenCurtainIsActive = True + else: + self._screenCurtainIsActive = False + + def onScreenCurtainDisabled(self) -> None: + """Called by screen curtain when it is disabled. Restarts the magnifier if it was active before.""" + if self._screenCurtainIsActive: + ui.message( + pgettext( + "magnifier", + # Translators: Spoken message when magnifier is re-enabled after screen curtain is disabled. + "Re-enabling magnifier", + ), + ) + self._startMagnifier() + self._updateMagnifier() + self._screenCurtainIsActive = False + @override def _startMagnifier(self) -> None: """ Start the Full-screen magnifier using windows DLL """ + if self._isBlockedByScreenCurtain(): + return super()._startMagnifier() log.debug( f"Starting magnifier with zoom level {self.zoomLevel} and filter {self.filterType} and full-screen mode {self._fullscreenMode}", @@ -82,38 +138,33 @@ def _startMagnifier(self) -> None: self._applyFilter() self._startTimer(self._updateMagnifier) - def _initializeNativeMagnification(self) -> None: + def _clearStaleApiState(self) -> None: """ - Initialize the Magnification API and apply the initial fullscreen transform. + Dummy MagInitialize/MagUninitialize cycle to clear stale Windows API state. - A dummy MagInitialize/MagUninitialize cycle is performed before the real - initialization. This is a workaround for a Windows bug: after a previous - MagSetFullscreenTransform call, MagUninitialize leaves internal state that - causes MagSetFullscreenTransform to fail with WinError 0 on the next - MagInitialize. A dummy cycle without any MagSetFullscreenTransform call - clears this stale state. - - Errors during the dummy MagInitialize/MagUninitialize cycle are intentionally - suppressed. - - Raises OSError if the real MagInitialize fails or if MagSetFullscreenTransform - fails (e.g. Windows Magnifier already holds the API). + After a MagSetFullscreenTransform or MagSetFullscreenColorEffect call, + MagUninitialize leaves internal state that causes the next MagSetFullscreenTransform + to fail. Resetting the color effect to neutral here also clears stale state + left by a screen curtain session. All errors are suppressed. """ - # Best-effort uninit ensures we start from a clean state - self._uninitializeNativeMagnification() - # Dummy cycle to clear any stale state from a previous MagSetFullscreenTransform. - dummyInitSucceeded = False try: magnification.MagInitialize() - dummyInitSucceeded = True + try: + magnification.MagSetFullscreenColorEffect(FilterMatrix.NORMAL.value) + except OSError: + pass + magnification.MagUninitialize() except OSError: pass - finally: - if dummyInitSucceeded: - try: - magnification.MagUninitialize() - except OSError: - pass + + def _initializeNativeMagnification(self) -> None: + """ + Initialize the Magnification API and apply the initial fullscreen transform. + + Raises OSError if MagInitialize or MagSetFullscreenTransform fails. + """ + self._uninitializeNativeMagnification() + self._clearStaleApiState() magnification.MagInitialize() log.debug("Magnification API initialized") # Applying the first real update verifies the API is usable without diff --git a/source/_magnifier/magnifier.py b/source/_magnifier/magnifier.py index e221bb90573..afbdbd1e76c 100644 --- a/source/_magnifier/magnifier.py +++ b/source/_magnifier/magnifier.py @@ -12,9 +12,6 @@ from comtypes import COMError from logHandler import log import wx -import ui -import speech -import screenCurtain from winAPI import _displayTracking from winAPI._displayTracking import OrientationState, getPrimaryDisplayOrientation from .utils.types import ( @@ -58,7 +55,6 @@ def __init__(self): self._recoveryAttempts: int = 0 # Register for display changes _displayTracking.displayChanged.register(self._onDisplayChanged) - self._screenCurtainIsActive: bool = False @property def filterType(self) -> Filter: @@ -169,18 +165,6 @@ def _startMagnifier(self) -> None: """ if self._isActive: return - # Check if screen curtain is active - if so, block magnifier from starting - if screenCurtain.screenCurtain and screenCurtain.screenCurtain.enabled: - log.debug("Screen curtain is active, cannot start magnifier") - - message = pgettext( - "magnifier", - # Translators: Message when trying to enable magnifier while screen curtain is active - "Cannot enable magnifier. Please disable screen curtain first.", - ) - ui.message(message, speechPriority=speech.priorities.Spri.NOW) - return - self._isActive = True self.currentCoordinates = self._focusManager.getCurrentFocusCoordinates() @@ -255,41 +239,6 @@ def _stopMagnifier(self) -> None: # Unregister from display changes _displayTracking.displayChanged.unregister(self._onDisplayChanged) - def onScreenCurtainEnabled(self) -> None: - """ - Called when screen curtain is being enabled. - Handles disabling magnifier if it's active. - """ - if self._isActive: - ui.message( - pgettext( - "magnifier", - # Translators: Spoken message when magnifier is disabled due to screen curtain being enabled. - "Disabling magnifier", - ), - ) - self._stopMagnifier() - self._screenCurtainIsActive = True - else: - self._screenCurtainIsActive = False - - def onScreenCurtainDisabled(self) -> None: - """ - Called when screen curtain is being disabled. - Handles re-enabling magnifier if it was active before screen curtain. - """ - if self._screenCurtainIsActive: - ui.message( - pgettext( - "magnifier", - # Translators: Spoken message when magnifier is re-enabled after screen curtain is disabled. - "Re-enabling magnifier", - ), - ) - self._startMagnifier() - self._updateMagnifier() - self._screenCurtainIsActive = False - def _zoom(self, direction: Direction) -> None: """ Adjust the zoom level of the magnifier diff --git a/source/screenCurtain/_screenCurtain.py b/source/screenCurtain/_screenCurtain.py index 331303f48c3..9ef77b9676a 100644 --- a/source/screenCurtain/_screenCurtain.py +++ b/source/screenCurtain/_screenCurtain.py @@ -200,11 +200,11 @@ def enable(self, *, persist: bool = False) -> None: log.debug("ScreenCurtain is already enabled.") return - # Notify magnifier that screen curtain is being enabled import _magnifier + from _magnifier.utils.types import MagnifiedView magnifierInstance = _magnifier.getMagnifier() - if magnifierInstance: + if magnifierInstance and magnifierInstance._MAGNIFIED_VIEW == MagnifiedView.FULLSCREEN: magnifierInstance.onScreenCurtainEnabled() log.debug("Enabling ScreenCurtain") @@ -258,12 +258,12 @@ def disable(self, *, persist: bool = True) -> None: nvwave.playWaveFile(os.path.join(globalVars.appDir, "waves", "screenCurtainOff.wav")) except Exception: log.exception() - # Notify magnifier that screen curtain is being disabled import _magnifier + from _magnifier.utils.types import MagnifiedView magnifierInstance = _magnifier.getMagnifier() - if magnifierInstance: + if magnifierInstance and magnifierInstance._MAGNIFIED_VIEW == MagnifiedView.FULLSCREEN: magnifierInstance.onScreenCurtainDisabled() def __del__(self) -> None: diff --git a/tests/unit/test_magnifier/test_fullscreenMagnifier.py b/tests/unit/test_magnifier/test_fullscreenMagnifier.py index 9f7f348aaa9..85ae52aaa82 100644 --- a/tests/unit/test_magnifier/test_fullscreenMagnifier.py +++ b/tests/unit/test_magnifier/test_fullscreenMagnifier.py @@ -237,12 +237,13 @@ def testAttemptRecoverySuccess(self): self.mock_mag_fs.reset_mock() magnifier._attemptRecovery() - # MagUninitialize: once best-effort at start + once in the dummy cycle finally block + # MagUninitialize: once best-effort uninit + once in _clearStaleApiState self.assertEqual(self.mock_mag_fs.MagUninitialize.call_count, 2) - # MagInitialize: once in the dummy cycle + once for the real init + # MagInitialize: once in _clearStaleApiState + once for the real init self.assertEqual(self.mock_mag_fs.MagInitialize.call_count, 2) self.mock_mag_fs.MagSetFullscreenTransform.assert_called_once_with(magnifier.zoomLevel / 100.0, 0, 0) - self.mock_mag_fs.MagSetFullscreenColorEffect.assert_called_once() + # MagSetFullscreenColorEffect: once in _clearStaleApiState + once in _attemptRecovery + self.assertEqual(self.mock_mag_fs.MagSetFullscreenColorEffect.call_count, 2) self.assertEqual(magnifier._consecutiveErrors, 0) magnifier._startTimer.assert_called_once_with(magnifier._updateMagnifier) @@ -288,14 +289,89 @@ def testUpdateLoopSurvivesSingleDoUpdateError(self): magnifier._stopMagnifier() -class TestFullScreenMagnifierApiConflict(_TestMagnifier): - """Tests for Windows Magnification API conflict detection at startup and during recovery.""" +class TestFullScreenMagnifierScreenCurtain(_TestMagnifier): + """Tests for FullScreenMagnifier interaction with the screen curtain and Magnification API conflicts.""" + + def _mockScreenCurtain(self, enabled: bool): + """Patch screenCurtain module so .screenCurtain.enabled returns the given value.""" + mock_instance = MagicMock() + mock_instance.enabled = enabled + patcher = patch("_magnifier.fullscreenMagnifier.screenCurtain") + mock_module = patcher.start() + self.addCleanup(patcher.stop) + mock_module.screenCurtain = mock_instance + + def testStartBlockedByScreenCurtain(self): + """When screen curtain is active, magnifier must not start and MagInitialize must not be called.""" + self._mockScreenCurtain(enabled=True) + with patch("NVDAState._TrackNVDAInitialization.isInitializationComplete", return_value=True): + with patch("_magnifier.fullscreenMagnifier.ui.message"): + magnifier = FullScreenMagnifier() + magnifier._startMagnifier() + + self.assertFalse(magnifier._isActive) + self.mock_mag_fs.MagInitialize.assert_not_called() + + def testStartBlockedAtStartupSetsFlag(self): + """At NVDA startup, screen curtain blocks the magnifier silently and sets _screenCurtainIsActive.""" + self._mockScreenCurtain(enabled=True) + with patch("NVDAState._TrackNVDAInitialization.isInitializationComplete", return_value=False): + with patch("_magnifier.fullscreenMagnifier.ui.message") as mock_message: + magnifier = FullScreenMagnifier() + magnifier._startMagnifier() + + self.assertFalse(magnifier._isActive) + self.assertTrue(magnifier._screenCurtainIsActive) + mock_message.assert_not_called() + self.mock_mag_fs.MagInitialize.assert_not_called() + + def testOnScreenCurtainEnabledStopsMagnifier(self): + """When screen curtain is enabled while magnifier is active, magnifier stops.""" + magnifier = FullScreenMagnifier() + magnifier._startMagnifier() + self.assertTrue(magnifier._isActive) + + with patch("_magnifier.fullscreenMagnifier.ui.message"): + magnifier.onScreenCurtainEnabled() + + self.assertFalse(magnifier._isActive) + self.assertTrue(magnifier._screenCurtainIsActive) + + def testOnScreenCurtainEnabledWhenInactive(self): + """When screen curtain is enabled while magnifier is already inactive, _screenCurtainIsActive is False.""" + magnifier = FullScreenMagnifier() + self.assertFalse(magnifier._isActive) + + magnifier.onScreenCurtainEnabled() + + self.assertFalse(magnifier._screenCurtainIsActive) + + def testOnScreenCurtainDisabledRestartsMagnifier(self): + """When screen curtain is disabled and _screenCurtainIsActive is True, magnifier restarts.""" + magnifier = FullScreenMagnifier() + magnifier._screenCurtainIsActive = True + magnifier._startMagnifier = MagicMock() + magnifier._updateMagnifier = MagicMock() + + with patch("_magnifier.fullscreenMagnifier.ui.message"): + magnifier.onScreenCurtainDisabled() + + magnifier._startMagnifier.assert_called_once() + magnifier._updateMagnifier.assert_called_once() + self.assertFalse(magnifier._screenCurtainIsActive) + + def testOnScreenCurtainDisabledDoesNothingWhenFlagIsFalse(self): + """When screen curtain is disabled but _screenCurtainIsActive is False, nothing happens.""" + magnifier = FullScreenMagnifier() + magnifier._screenCurtainIsActive = False + magnifier._startMagnifier = MagicMock() + + magnifier.onScreenCurtainDisabled() + + magnifier._startMagnifier.assert_not_called() def testCannotStartWhenWindowsMagnifierRunning(self): - """ - MagInitialize succeeds but MagSetFullscreenTransform fails: Windows Magnifier is running. - NVDA Magnifier must not start, the user must be notified, and no timer must be started. - """ + """MagSetFullscreenTransform fails because Windows Magnifier is running: magnifier must not start.""" self.mock_mag_fs.MagSetFullscreenTransform.side_effect = OSError("API in use by another magnifier") with patch("_magnifier.fullscreenMagnifier.ui.message") as mock_message: @@ -307,9 +383,7 @@ def testCannotStartWhenWindowsMagnifierRunning(self): self.assertIsNone(magnifier._timer) def testCannotStartWhenMagInitializeFails(self): - """ - MagInitialize itself fails: NVDA Magnifier must not start and the user must be notified. - """ + """MagInitialize fails: magnifier must not start and the user must be notified.""" self.mock_mag_fs.MagInitialize.side_effect = OSError("Cannot initialize magnification API") with patch("_magnifier.fullscreenMagnifier.ui.message") as mock_message: @@ -321,9 +395,7 @@ def testCannotStartWhenMagInitializeFails(self): self.assertIsNone(magnifier._timer) def testRecoveryCapStopsMagnifier(self): - """ - After _MAX_RECOVERY_ATTEMPTS failed attempts, the magnifier stops and the user is notified. - """ + """After _MAX_RECOVERY_ATTEMPTS failed attempts, the magnifier stops and the user is notified.""" magnifier = FullScreenMagnifier() magnifier._recoveryAttempts = FullScreenMagnifier._MAX_RECOVERY_ATTEMPTS @@ -334,10 +406,7 @@ def testRecoveryCapStopsMagnifier(self): mock_message.assert_called_once() def testRecoveryFailsWhenTransformStillUnavailable(self): - """ - Recovery declares failure if MagSetFullscreenTransform still raises after reinit. - This is the root cause of the Windows Magnifier conflict infinite loop. - """ + """Recovery declares failure if MagSetFullscreenTransform still raises after reinit.""" magnifier = FullScreenMagnifier() magnifier._startTimer = MagicMock() From 19a46b0c9708da3edcab912ec7a5f957af74a22e Mon Sep 17 00:00:00 2001 From: Antoine HAFFREINGUE Date: Tue, 16 Jun 2026 14:59:55 +0200 Subject: [PATCH 2/2] change import --- source/_magnifier/fullscreenMagnifier.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/source/_magnifier/fullscreenMagnifier.py b/source/_magnifier/fullscreenMagnifier.py index 07c8ede287f..7c7013c9885 100644 --- a/source/_magnifier/fullscreenMagnifier.py +++ b/source/_magnifier/fullscreenMagnifier.py @@ -8,7 +8,7 @@ """ from typing import override - +from NVDAState import _TrackNVDAInitialization from logHandler import log import screenCurtain import speech @@ -66,7 +66,6 @@ def _isBlockedByScreenCurtain(self) -> bool: """ if not (screenCurtain.screenCurtain and screenCurtain.screenCurtain.enabled): return False - from NVDAState import _TrackNVDAInitialization if _TrackNVDAInitialization.isInitializationComplete(): log.debug("Screen curtain is active, cannot start magnifier")