Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 76 additions & 26 deletions source/_magnifier/fullscreenMagnifier.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@
"""

from typing import override

from NVDAState import _TrackNVDAInitialization
from logHandler import log
import screenCurtain
import speech
import ui
from winBindings import magnification
Expand Down Expand Up @@ -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:
Expand All @@ -55,11 +57,64 @@ def event_gainFocus(
log.debug("Full-screen Magnifier gain focus event")
nextHandler()

def _isBlockedByScreenCurtain(self) -> bool:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

won't these functions need to be more generic than just the full screen magnifier?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think not, only the fullscreen will use the windows api so the other modes should not be in conflict

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why won't the other magnifiers use the windows api? what would they use instead?

"""
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

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}",
Expand All @@ -82,38 +137,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.

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.
Dummy MagInitialize/MagUninitialize cycle to clear stale Windows API 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
Expand Down
51 changes: 0 additions & 51 deletions source/_magnifier/magnifier.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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
Expand Down
8 changes: 4 additions & 4 deletions source/screenCurtain/_screenCurtain.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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:
Expand Down
107 changes: 88 additions & 19 deletions tests/unit/test_magnifier/test_fullscreenMagnifier.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand All @@ -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

Expand All @@ -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()

Expand Down
Loading