diff --git a/source/_magnifier/__init__.py b/source/_magnifier/__init__.py index dedad330819..f742dd6f1ea 100644 --- a/source/_magnifier/__init__.py +++ b/source/_magnifier/__init__.py @@ -9,7 +9,8 @@ """ from typing import TYPE_CHECKING -from .fullscreenMagnifier import FullScreenMagnifier +from .config import getMagnifiedView +from .utils.types import MagnifiedView if TYPE_CHECKING: from .magnifier import Magnifier @@ -17,47 +18,107 @@ _magnifier: "Magnifier | None" = None -def initialize(): +def createMagnifier(magnifiedView: MagnifiedView) -> "Magnifier": """ - Initialize the magnifier module - For now, only the full-screen magnifier is supported + Create a magnifier instance based on the specified view. + + :param magnifiedView: The magnifier view to create + :return: The created magnifier instance + :raises ValueError: If the magnifier view is not supported + """ + + match magnifiedView: + case MagnifiedView.FULLSCREEN: + from .fullscreenMagnifier import FullScreenMagnifier + + return FullScreenMagnifier() + + case MagnifiedView.FIXED: + from .fixedMagnifier import FixedMagnifier + + return FixedMagnifier() + + case MagnifiedView.DOCKED: + from .dockedMagnifier import DockedMagnifier + + return DockedMagnifier() + + case MagnifiedView.LENS: + from .lensMagnifier import LensMagnifier + + return LensMagnifier() + + case _: + raise ValueError(f"Unsupported magnifier view: {MagnifiedView}") + + +def _setMagnifiedView(magnifiedView: MagnifiedView) -> None: + """ + Set the magnifier view, stopping the current one if active and creating a new instance. + + :param magnifiedView: The magnifier view to set """ + global _magnifier + + # Stop current magnifier if active + if _magnifier and _magnifier._isActive: + _magnifier._stopMagnifier() - magnifier = FullScreenMagnifier() - setMagnifier(magnifier) + # Create and set new magnifier instance + _magnifier = createMagnifier(magnifiedView) + + +def initialize() -> None: + """ + Initialize the magnifier module with the default magnifier view from config. + """ + magnifiedView = getMagnifiedView() + _setMagnifiedView(magnifiedView) + _magnifier._startMagnifier() def isActive() -> bool: """ - Check if magnifier is currently active for settings + Check if magnifier is currently active. + + :return: True if magnifier is active, False otherwise """ global _magnifier - return _magnifier and _magnifier._isActive + return _magnifier is not None and _magnifier._isActive -def getMagnifier() -> "Magnifier | None": +def changeMagnifiedView(magnifiedView: MagnifiedView) -> None: """ - Get current magnifier + Change the magnifier view at runtime. + Stops the current magnifier and starts a new one of the specified view. + + :param magnifiedView: The new magnifier view to use + :raises RuntimeError: If no magnifier is currently active """ global _magnifier - return _magnifier + if not _magnifier or not _magnifier._isActive: + raise RuntimeError("Cannot change magnifier view: magnifier is not active") + _setMagnifiedView(magnifiedView) + _magnifier._startMagnifier() -def setMagnifier(magnifier: "Magnifier") -> None: + +def getMagnifier() -> "Magnifier | None": """ - Set magnifier instance + Get the current magnifier instance. - :param magnifier: The magnifier instance to set + :return: The current magnifier instance, or None if not initialized """ global _magnifier - _magnifier = magnifier + return _magnifier -def terminate(): +def terminate() -> None: """ - Called when NVDA shuts down + Terminate the magnifier module. + Called when NVDA shuts down. """ global _magnifier if _magnifier and _magnifier._isActive: _magnifier._stopMagnifier() - _magnifier = None + _magnifier = None diff --git a/source/_magnifier/commands.py b/source/_magnifier/commands.py index 1a50df3a719..4ab40da2594 100644 --- a/source/_magnifier/commands.py +++ b/source/_magnifier/commands.py @@ -10,8 +10,10 @@ from typing import Literal import ui -from . import getMagnifier, initialize, terminate +from . import getMagnifier, initialize, terminate, changeMagnifiedView from .config import ( + getMagnifiedView, + setMagnifiedView, getZoomLevelString, getFilter, getFullscreenMode, @@ -25,7 +27,7 @@ from .utils.types import ( Filter, Direction, - MagnifierType, + MagnifiedView, FullScreenMode, MagnifierAction, MagnifierFollowFocusType, @@ -103,21 +105,32 @@ def toggleMagnifier() -> None: return else: initialize() - filter = getFilter() - fullscreenMode = getFullscreenMode() - - ui.message( - pgettext( + magnifiedView = getMagnifiedView() + zoomLevel = getZoomLevelString() + if magnifiedView == MagnifiedView.FULLSCREEN: + fullscreenMode = getFullscreenMode() + msg = pgettext( "magnifier", # Translators: Message announced when starting the NVDA magnifier. - "Starting magnifier with {zoomLevel} zoom level, {filter} filter, and {fullscreenMode} full-screen mode", + "Starting {magnifiedView} magnifier with {zoomLevel} zoom level, {filter} filter, and {fullscreenMode} full-screen mode", ).format( - zoomLevel=getZoomLevelString(), + magnifiedView=magnifiedView.displayString, + zoomLevel=zoomLevel, filter=filter.displayString, fullscreenMode=fullscreenMode.displayString, - ), - ) + ) + else: + msg = pgettext( + "magnifier", + # Translators: Message announced when starting the NVDA magnifier. + "Starting {magnifiedView} magnifier with {zoomLevel} zoom level and {filter} filter", + ).format( + magnifiedView=magnifiedView.displayString, + zoomLevel=zoomLevel, + filter=filter.displayString, + ) + ui.message(msg) def zoom(direction: Direction) -> None: @@ -174,8 +187,9 @@ def toggleFilter() -> None: filters = list(Filter) idx = filters.index(magnifier.filterType) magnifier.filterType = filters[(idx + 1) % len(filters)] - if magnifier._magnifierType == MagnifierType.FULLSCREEN: - magnifier._applyFilter() + if magnifier._MAGNIFIED_VIEW == MagnifiedView.FULLSCREEN: + fullscreenMagnifier: FullScreenMagnifier = magnifier + fullscreenMagnifier._applyFilter() ui.message( pgettext( "magnifier", @@ -185,6 +199,30 @@ def toggleFilter() -> None: ) +def cycleMagnifiedView() -> None: + """Cycle through magnifier views (full-screen, fixed, docked, lens)""" + magnifier: Magnifier = getMagnifier() + if magnifierIsActiveVerify( + magnifier, + MagnifierAction.CHANGE_MAGNIFIER_VIEW, + ): + views = list(MagnifiedView) + currentView = magnifier._MAGNIFIED_VIEW + idx = views.index(currentView) + newView = views[(idx + 1) % len(views)] + log.debug(f"Changing magnifier view from {currentView} to {newView}") + changeMagnifiedView(newView) + setMagnifiedView(newView) + magnifier = getMagnifier() + ui.message( + pgettext( + "magnifier", + # Translators: Message announced when changing the magnifier view with {view} being the new magnifier view. + "Magnifier view changed to {view}", + ).format(view=magnifier._MAGNIFIED_VIEW.displayString), + ) + + def toggleFollow(focusType: MagnifierFollowFocusType) -> None: """ Toggle the specified follow mode setting. @@ -255,12 +293,13 @@ def toggleFullscreenMode() -> None: magnifier, MagnifierAction.CHANGE_FULLSCREEN_MODE, ): + fullscreenMagnifier: FullScreenMagnifier = magnifier modes = list(FullScreenMode) - currentMode = magnifier._fullscreenMode + currentMode = fullscreenMagnifier._fullscreenMode idx = modes.index(currentMode) newMode = modes[(idx + 1) % len(modes)] log.debug(f"Changing full-screen mode from {currentMode} to {newMode}") - magnifier._fullscreenMode = newMode + fullscreenMagnifier._fullscreenMode = newMode ui.message( pgettext( "magnifier", @@ -281,8 +320,9 @@ def startSpotlight() -> None: magnifier, MagnifierAction.START_SPOTLIGHT, ): + fullscreenMagnifier: FullScreenMagnifier = magnifier log.debug("trying to launch spotlight mode") - if magnifier._spotlightManager._spotlightIsActive: + if fullscreenMagnifier._spotlightManager._spotlightIsActive: log.debug("found spotlight manager and it is active") ui.message( pgettext( @@ -293,7 +333,7 @@ def startSpotlight() -> None: ) else: log.debug("no active spotlight manager found, starting new one") - magnifier._startSpotlight() + fullscreenMagnifier._startSpotlight() ui.message( pgettext( "magnifier", @@ -340,7 +380,7 @@ def magnifierIsFullscreenVerify( :return: True if the magnifier is full-screen, False otherwise """ - if magnifier._magnifierType == MagnifierType.FULLSCREEN: + if magnifier._MAGNIFIED_VIEW == MagnifiedView.FULLSCREEN: return True else: ui.message( diff --git a/source/_magnifier/config.py b/source/_magnifier/config.py index e5abe6b8d96..822b86c9b2e 100644 --- a/source/_magnifier/config.py +++ b/source/_magnifier/config.py @@ -10,7 +10,7 @@ import config from dataclasses import dataclass, field -from .utils.types import Filter, FullScreenMode, MagnifierFollowFocusType +from .utils.types import Filter, FullScreenMode, MagnifierFollowFocusType, MagnifiedView class ZoomLevel: @@ -121,6 +121,24 @@ def setFilter(filter: Filter) -> None: config.conf["magnifier"]["filter"] = filter.value +def getMagnifiedView() -> MagnifiedView: + """ + Get magnifier view from config. + + :return: The magnifier view. + """ + return MagnifiedView(config.conf["magnifier"]["magnifiedView"]) + + +def setMagnifiedView(magnifiedView: MagnifiedView) -> None: + """ + Set magnifier view in settings. + + :param magnifiedView: The magnifier view to set. + """ + config.conf["magnifier"]["magnifiedView"] = magnifiedView.value + + _FOLLOW_CONFIG_KEYS: dict[MagnifierFollowFocusType, str] = { MagnifierFollowFocusType.MOUSE: "followMouse", MagnifierFollowFocusType.SYSTEM_FOCUS: "followSystemFocus", diff --git a/source/_magnifier/dockedMagnifier.py b/source/_magnifier/dockedMagnifier.py new file mode 100644 index 00000000000..befc45e4522 --- /dev/null +++ b/source/_magnifier/dockedMagnifier.py @@ -0,0 +1,29 @@ +# A part of NonVisual Desktop Access (NVDA) +# Copyright (C) 2025-2026 NV Access Limited, Antoine Haffreingue +# This file may be used under the terms of the GNU General Public License, version 2 or later, as modified by the NVDA license. +# For full terms and any additional permissions, see the NVDA license file: https://github.com/nvaccess/nvda/blob/master/copying.txt + +""" +Docked magnifier module. +""" + +from .magnifier import Magnifier +from .utils.types import MagnifiedView + + +class DockedMagnifier(Magnifier): + """Displays a magnified panel anchored to one edge of the screen.""" + + _MAGNIFIED_VIEW = MagnifiedView.DOCKED + + def __init__(self): + super().__init__() + + def _startMagnifier(self) -> None: + super()._startMagnifier() + + def _stopMagnifier(self) -> None: + super()._stopMagnifier() + + def _doUpdate(self): + pass diff --git a/source/_magnifier/fixedMagnifier.py b/source/_magnifier/fixedMagnifier.py new file mode 100644 index 00000000000..4c29bd2f235 --- /dev/null +++ b/source/_magnifier/fixedMagnifier.py @@ -0,0 +1,29 @@ +# A part of NonVisual Desktop Access (NVDA) +# Copyright (C) 2025-2026 NV Access Limited, Antoine Haffreingue +# This file may be used under the terms of the GNU General Public License, version 2 or later, as modified by the NVDA license. +# For full terms and any additional permissions, see the NVDA license file: https://github.com/nvaccess/nvda/blob/master/copying.txt + +""" +Fixed magnifier module. +""" + +from .magnifier import Magnifier +from .utils.types import MagnifiedView + + +class FixedMagnifier(Magnifier): + """Displays a floating magnified panel that can be pinned anywhere on the screen.""" + + _MAGNIFIED_VIEW = MagnifiedView.FIXED + + def __init__(self): + super().__init__() + + def _startMagnifier(self) -> None: + super()._startMagnifier() + + def _stopMagnifier(self) -> None: + super()._stopMagnifier() + + def _doUpdate(self): + pass diff --git a/source/_magnifier/fullscreenMagnifier.py b/source/_magnifier/fullscreenMagnifier.py index 34c680612e9..6557ea56c19 100644 --- a/source/_magnifier/fullscreenMagnifier.py +++ b/source/_magnifier/fullscreenMagnifier.py @@ -18,7 +18,7 @@ from .utils.spotlightManager import SpotlightManager from .utils.types import ( Filter, - MagnifierType, + MagnifiedView, FullScreenMode, Size, MagnifierParameters, @@ -29,22 +29,20 @@ class FullScreenMagnifier(Magnifier): + """Magnifier that uses the Windows Magnification API to magnify the entire screen.""" + _MAX_RECOVERY_ATTEMPTS: int = 3 + _MAGNIFIED_VIEW = MagnifiedView.FULLSCREEN def __init__(self): super().__init__() - self._magnifierType = MagnifierType.FULLSCREEN self._fullscreenMode = getFullscreenMode() self._currentCoordinates = Coordinates(0, 0) self._spotlightManager = SpotlightManager(self) self._displaySize = Size(self._displayOrientation.width, self._displayOrientation.height) self._startMagnifier() - @property - def filterType(self) -> Filter: - return self._filterType - - @filterType.setter + @Magnifier.filterType.setter def filterType(self, value: Filter) -> None: self._filterType = value if self._isActive: diff --git a/source/_magnifier/lensMagnifier.py b/source/_magnifier/lensMagnifier.py new file mode 100644 index 00000000000..83675b64f9c --- /dev/null +++ b/source/_magnifier/lensMagnifier.py @@ -0,0 +1,29 @@ +# A part of NonVisual Desktop Access (NVDA) +# Copyright (C) 2025-2026 NV Access Limited, Antoine Haffreingue +# This file may be used under the terms of the GNU General Public License, version 2 or later, as modified by the NVDA license. +# For full terms and any additional permissions, see the NVDA license file: https://github.com/nvaccess/nvda/blob/master/copying.txt + +""" +Lens magnifier module. +""" + +from .magnifier import Magnifier +from .utils.types import MagnifiedView + + +class LensMagnifier(Magnifier): + """Displays a magnified panel beside the focused object and magnifies it.""" + + _MAGNIFIED_VIEW = MagnifiedView.LENS + + def __init__(self): + super().__init__() + + def _startMagnifier(self) -> None: + super()._startMagnifier() + + def _stopMagnifier(self) -> None: + super()._stopMagnifier() + + def _doUpdate(self): + pass diff --git a/source/_magnifier/magnifier.py b/source/_magnifier/magnifier.py index fed233a8a79..4a757c88a70 100644 --- a/source/_magnifier/magnifier.py +++ b/source/_magnifier/magnifier.py @@ -21,7 +21,7 @@ from .utils.types import ( MagnifierParameters, MagnifierAction, - MagnifierType, + MagnifiedView, Direction, Filter, Coordinates, @@ -41,10 +41,10 @@ class Magnifier: _TIMER_INTERVAL_MS: int = 12 _MARGIN_BORDER: int = 50 _MAX_CONSECUTIVE_ERRORS: int = 3 + _MAGNIFIED_VIEW: MagnifiedView def __init__(self): self._displayOrientation = getPrimaryDisplayOrientation() - self._magnifierType: MagnifierType self._isActive: bool = False self._zoomLevel: float = getZoomLevel() self._panStep: int = getPanStep() @@ -61,6 +61,14 @@ def __init__(self): _displayTracking.displayChanged.register(self._onDisplayChanged) self._screenCurtainIsActive: bool = False + @property + def filterType(self) -> Filter: + return self._filterType + + @filterType.setter + def filterType(self, value: Filter) -> None: + self._filterType = value + @property def zoomLevel(self) -> float: return self._zoomLevel diff --git a/source/_magnifier/utils/types.py b/source/_magnifier/utils/types.py index 1afc75941bf..c1a47364603 100644 --- a/source/_magnifier/utils/types.py +++ b/source/_magnifier/utils/types.py @@ -48,6 +48,7 @@ class MagnifierAction(DisplayStringEnum): PAN_TOP_EDGE = auto() PAN_BOTTOM_EDGE = auto() TOGGLE_FILTER = auto() + CHANGE_MAGNIFIER_VIEW = auto() TOGGLE_FOLLOW_SETTINGS = auto() CHANGE_FULLSCREEN_MODE = auto() START_SPOTLIGHT = auto() @@ -79,6 +80,8 @@ def _displayStringLabels(self) -> dict["MagnifierAction", str]: self.TOGGLE_FOLLOW_SETTINGS: pgettext("magnifier action", "toggle follow settings"), # Translators: Action description for toggling color filters. self.TOGGLE_FILTER: pgettext("magnifier action", "toggle filters"), + # Translators: Action description for changing magnifier view. + self.CHANGE_MAGNIFIER_VIEW: pgettext("magnifier action", "change magnifier view"), # Translators: Action description for changing full-screen mode. self.CHANGE_FULLSCREEN_MODE: pgettext("magnifier action", "change full-screen mode"), # Translators: Action description for starting spotlight mode. @@ -108,7 +111,7 @@ def _displayStringLabels(self) -> dict["MagnifierFollowFocusType", str]: } -class MagnifierType(DisplayStringStrEnum): +class MagnifiedView(DisplayStringStrEnum): """Type of magnifier""" FULLSCREEN = "fullscreen" @@ -117,15 +120,15 @@ class MagnifierType(DisplayStringStrEnum): LENS = "lens" @property - def _displayStringLabels(self) -> dict["MagnifierType", str]: + def _displayStringLabels(self) -> dict["MagnifiedView", str]: return { - # Translators: Magnifier type - full-screen mode. + # Translators: Magnifier view - full-screen mode. self.FULLSCREEN: pgettext("magnifier", "Fullscreen"), - # Translators: Magnifier type - fixed mode. + # Translators: Magnifier view - fixed mode. self.FIXED: pgettext("magnifier", "Fixed"), - # Translators: Magnifier type - docked mode. + # Translators: Magnifier view - docked mode. self.DOCKED: pgettext("magnifier", "Docked"), - # Translators: Magnifier type - lens mode. + # Translators: Magnifier view - lens mode. self.LENS: pgettext("magnifier", "Lens"), } diff --git a/source/config/configSpec.py b/source/config/configSpec.py index 982532d4d5f..dd29b10dd8d 100644 --- a/source/config/configSpec.py +++ b/source/config/configSpec.py @@ -116,6 +116,7 @@ # Magnifier settings [magnifier] + magnifiedView = string(default="fullscreen") zoomLevel = float(min=1.0, max=10.0, default=2.0) isTrueCentered = boolean(default=False) filter = string(default="normal") diff --git a/tests/unit/test_magnifier/test_fullscreenMagnifier.py b/tests/unit/test_magnifier/test_fullscreenMagnifier.py index 45207e53c35..e16bab78f23 100644 --- a/tests/unit/test_magnifier/test_fullscreenMagnifier.py +++ b/tests/unit/test_magnifier/test_fullscreenMagnifier.py @@ -4,7 +4,7 @@ # For full terms and any additional permissions, see the NVDA license file: https://github.com/nvaccess/nvda/blob/master/copying.txt from unittest.mock import MagicMock, patch -from _magnifier.utils.types import Filter, FullScreenMode, MagnifierType, Direction, Coordinates +from _magnifier.utils.types import Filter, FullScreenMode, MagnifiedView, Direction, Coordinates from _magnifier.fullscreenMagnifier import FullScreenMagnifier from tests.unit.test_magnifier.test_magnifier import _TestMagnifier from _magnifier.magnifier import Magnifier @@ -22,7 +22,7 @@ def testMagnifierCreation(self): self.assertEqual(magnifier.zoomLevel, 2.0) self.assertEqual(magnifier.filterType, Filter.NORMAL) self.assertEqual(magnifier._fullscreenMode, FullScreenMode.CENTER) - self.assertEqual(magnifier._magnifierType, MagnifierType.FULLSCREEN) + self.assertEqual(magnifier._MAGNIFIED_VIEW, MagnifiedView.FULLSCREEN) self.assertTrue(magnifier._isActive) magnifier._stopMagnifier() @@ -107,6 +107,7 @@ def testMagnifierStop(self): def testMagnifierPositionCalculation(self): """Test position calculation.""" magnifier = FullScreenMagnifier() + magnifier._startMagnifier() # Test position calculation params = magnifier._getMagnifierParameters((500, 400)) @@ -130,6 +131,7 @@ def testMagnifierPositionCalculation(self): def testMagnifierZoomBoundaries(self): """Test zoom boundaries.""" magnifier = FullScreenMagnifier() + magnifier._startMagnifier() magnifier.zoomLevel = 1.0 # Test minimum boundary @@ -144,15 +146,16 @@ def testMagnifierZoomBoundaries(self): # Cleanup magnifier._stopMagnifier() - def testMagnifierTypeProperty(self): - """Test magnifierType property for FullScreenMagnifier.""" + def testMagnifiedViewProperty(self): + """Test magnifiedView property for FullScreenMagnifier.""" magnifier = FullScreenMagnifier() + magnifier._startMagnifier() # Should default to FULLSCREEN - self.assertEqual(magnifier._magnifierType, MagnifierType.FULLSCREEN) + self.assertEqual(magnifier._MAGNIFIED_VIEW, MagnifiedView.FULLSCREEN) # Test that we can read it (inherited property from Magnifier) - self.assertIsNotNone(magnifier._magnifierType) + self.assertIsNotNone(magnifier._MAGNIFIED_VIEW) # Cleanup magnifier._stopMagnifier() @@ -160,13 +163,14 @@ def testMagnifierTypeProperty(self): def testMagnifierInheritance(self): """Test inheritance structure.""" magnifier = FullScreenMagnifier() + magnifier._startMagnifier() self.assertIsInstance(magnifier, Magnifier) # Test basic properties exist self.assertTrue(hasattr(magnifier, "zoomLevel")) self.assertTrue(hasattr(magnifier, "filterType")) - self.assertTrue(hasattr(magnifier, "_magnifierType")) + self.assertTrue(hasattr(magnifier, "_MAGNIFIED_VIEW")) self.assertTrue(hasattr(magnifier, "_fullscreenMode")) self.assertTrue(hasattr(magnifier, "_isActive")) self.assertTrue(hasattr(magnifier, "_currentCoordinates")) @@ -177,6 +181,7 @@ def testMagnifierInheritance(self): def testMagnifierApiHandling(self): """Test API error handling.""" magnifier = FullScreenMagnifier() + magnifier._startMagnifier() # Mock magnification API to fail magnifier._stopTimer = MagicMock() @@ -254,6 +259,7 @@ def testAttemptRecoveryFailureStopsMagnifier(self): def testUpdateLoopSurvivesSingleDoUpdateError(self): """A single _doUpdate error does not kill the update loop.""" magnifier = FullScreenMagnifier() + magnifier._startMagnifier() magnifier._startTimer = MagicMock() magnifier._focusManager.getCurrentFocusCoordinates = MagicMock( return_value=(100, 200), @@ -342,6 +348,7 @@ class TestFullScreenMagnifierKeepMouseCentered(_TestMagnifier): def setUp(self): super().setUp() self.magnifier = FullScreenMagnifier() + self.magnifier._startMagnifier() self.screen = getPrimaryDisplayOrientation() def tearDown(self): diff --git a/tests/unit/test_magnifier/test_magnifierCommands.py b/tests/unit/test_magnifier/test_magnifierCommands.py index 9c597404ba3..ddf41f318c4 100644 --- a/tests/unit/test_magnifier/test_magnifierCommands.py +++ b/tests/unit/test_magnifier/test_magnifierCommands.py @@ -5,8 +5,8 @@ import unittest from unittest.mock import MagicMock, patch -from _magnifier.commands import zoom -from _magnifier.utils.types import Direction +from _magnifier.commands import zoom, cycleMagnifiedView +from _magnifier.utils.types import Direction, MagnifiedView class TestZoomCommand(unittest.TestCase): @@ -53,3 +53,40 @@ def testActiveZoomOut(self): zoom(Direction.OUT) self.mockToggle.assert_not_called() mag._zoom.assert_called_once_with(Direction.OUT) + + +class TestCycleMagnifiedView(unittest.TestCase): + """Tests for cycleMagnifiedView command.""" + + def setUp(self): + self.mockMessage = patch("_magnifier.commands.ui.message").start() + self.mockGetMagnifier = patch("_magnifier.commands.getMagnifier").start() + self.mockChangeMagnifiedView = patch("_magnifier.commands.changeMagnifiedView").start() + self.mockSetMagnifiedView = patch("_magnifier.commands.setMagnifiedView").start() + + def tearDown(self): + patch.stopall() + + def _makeMockMagnifier(self, magnifiedView: MagnifiedView): + magnifier = MagicMock() + magnifier._isActive = True + magnifier._MAGNIFIED_VIEW = magnifiedView + return magnifier + + def testFullCycle(self): + """All four types cycle in order and wrap back to FULLSCREEN.""" + expectedCycle = [ + (MagnifiedView.FULLSCREEN, MagnifiedView.FIXED), + (MagnifiedView.FIXED, MagnifiedView.DOCKED), + (MagnifiedView.DOCKED, MagnifiedView.LENS), + (MagnifiedView.LENS, MagnifiedView.FULLSCREEN), + ] + for currentType, expectedNext in expectedCycle: + with self.subTest(currentType=currentType): + self.mockGetMagnifier.side_effect = [ + self._makeMockMagnifier(currentType), + self._makeMockMagnifier(expectedNext), + ] + cycleMagnifiedView() + self.mockChangeMagnifiedView.assert_called_once_with(expectedNext) + self.mockChangeMagnifiedView.reset_mock()