diff --git a/source/_magnifier/commands.py b/source/_magnifier/commands.py index 7c75e2ec42c..a4766a1c2bb 100644 --- a/source/_magnifier/commands.py +++ b/source/_magnifier/commands.py @@ -12,9 +12,9 @@ import ui from . import getMagnifier, initialize, terminate from .config import ( - getDefaultZoomLevelString, - getDefaultFilter, - getDefaultFullscreenMode, + getZoomLevelString, + getFilter, + getFullscreenMode, ZoomLevel, ) from .magnifier import Magnifier @@ -100,8 +100,8 @@ def toggleMagnifier() -> None: else: initialize() - filter = getDefaultFilter() - fullscreenMode = getDefaultFullscreenMode() + filter = getFilter() + fullscreenMode = getFullscreenMode() ui.message( pgettext( @@ -109,7 +109,7 @@ def toggleMagnifier() -> None: # Translators: Message announced when starting the NVDA magnifier. "Starting magnifier with {zoomLevel} zoom level, {filter} filter, and {fullscreenMode} full-screen mode", ).format( - zoomLevel=getDefaultZoomLevelString(), + zoomLevel=getZoomLevelString(), filter=filter.displayString, fullscreenMode=fullscreenMode.displayString, ), diff --git a/source/_magnifier/config.py b/source/_magnifier/config.py index 7fc38ba6ab5..17d86d358bd 100644 --- a/source/_magnifier/config.py +++ b/source/_magnifier/config.py @@ -49,111 +49,120 @@ def zoom_strings(cls) -> list[str]: ] -def getDefaultZoomLevel() -> float: +def getZoomLevel() -> float: """ - Get default zoom level from config. + Get zoom level from config. - :return: The default zoom level. + :return: The zoom level. """ - zoomLevel = config.conf["magnifier"]["defaultZoomLevel"] + zoomLevel = config.conf["magnifier"]["zoomLevel"] return zoomLevel -def getDefaultZoomLevelString() -> str: +def getZoomLevelString() -> str: """ - Get default zoom level as a formatted string. + Get zoom level as a formatted string. :return: Formatted zoom level string. """ - zoomLevel = getDefaultZoomLevel() + zoomLevel = getZoomLevel() zoomValues = ZoomLevel.zoom_range() zoomStrings = ZoomLevel.zoom_strings() - zoomIndex = zoomValues.index(zoomLevel) - return zoomStrings[zoomIndex] + if not zoomValues: + # Fallback: format the current zoom level directly if no predefined values are available. + return ZoomLevel.ZOOM_MESSAGE.format( + zoomLevel=f"{zoomLevel:.1f}", + ) + # Find the index of the zoom value closest to the configured zoom level. + closestIndex = min( + range(len(zoomValues)), + key=lambda i: abs(zoomValues[i] - zoomLevel), + ) + return zoomStrings[closestIndex] -def setDefaultZoomLevel(zoomLevel: float) -> None: +def setZoomLevel(zoomLevel: float) -> None: """ - Set default zoom level from settings. + Set zoom level from settings. :param zoomLevel: The zoom level to set. """ if "magnifier" not in config.conf: config.conf["magnifier"] = {} - config.conf["magnifier"]["defaultZoomLevel"] = zoomLevel + config.conf["magnifier"]["zoomLevel"] = zoomLevel -def getDefaultPanStep() -> int: +def getPanStep() -> int: """ - Get default pan value from config. + Get pan value from config. - :return: The default pan value. + :return: The pan value. """ - return config.conf["magnifier"]["defaultPanStep"] + return config.conf["magnifier"]["panStep"] -def setDefaultPanStep(panStep: int) -> None: +def setPanStep(panStep: int) -> None: """ - Set default pan value from settings. + Set pan value from settings. :param panStep: The pan value to set. """ if "magnifier" not in config.conf: config.conf["magnifier"] = {} - config.conf["magnifier"]["defaultPanStep"] = panStep + config.conf["magnifier"]["panStep"] = panStep -def getDefaultFilter() -> Filter: +def getFilter() -> Filter: """ - Get default filter from config. + Get filter from config. - :return: The default filter. + :return: The filter. """ - return Filter(config.conf["magnifier"]["defaultFilter"]) + return Filter(config.conf["magnifier"]["filter"]) -def setDefaultFilter(filter: Filter) -> None: +def setFilter(filter: Filter) -> None: """ - Set default filter from settings. + Set filter from settings. :param filter: The filter to set. """ - config.conf["magnifier"]["defaultFilter"] = filter.value + config.conf["magnifier"]["filter"] = filter.value -def getDefaultFullscreenMode() -> FullScreenMode: +def isTrueCentered() -> bool: """ - Get default full-screen mode from config. + Check if true centered mode is enabled in config. - :return: The default full-screen mode. + :return: True if true centered mode is enabled, False otherwise. """ - return FullScreenMode(config.conf["magnifier"]["defaultFullscreenMode"]) + return config.conf["magnifier"]["isTrueCentered"] -def setDefaultFullscreenMode(mode: FullScreenMode) -> None: +def shouldKeepMouseCentered() -> bool: """ - Set default full-screen mode from settings. + Check if mouse pointer should be kept centered in magnifier view. - :param mode: The full-screen mode to set. + :return: True if mouse should be kept centered, False otherwise. """ - config.conf["magnifier"]["defaultFullscreenMode"] = mode.value + return config.conf["magnifier"]["keepMouseCentered"] -def isTrueCentered() -> bool: +def getFullscreenMode() -> FullScreenMode: """ - Check if true centered mode is enabled in config. + Get full-screen mode from config. - :return: True if true centered mode is enabled, False otherwise. + :return: The full-screen mode. """ - return config.conf["magnifier"]["isTrueCentered"] + return FullScreenMode(config.conf["magnifier"]["fullscreenMode"]) -def shouldKeepMouseCentered() -> bool: +def setFullscreenMode(mode: FullScreenMode) -> None: """ - Check if mouse pointer should be kept centered in magnifier view. + Set full-screen mode from settings. - :return: True if mouse should be kept centered, False otherwise. + :param mode: The full-screen mode to set. """ - return config.conf["magnifier"]["keepMouseCentered"] + config.conf["magnifier"]["fullscreenMode"] = mode.value diff --git a/source/_magnifier/fullscreenMagnifier.py b/source/_magnifier/fullscreenMagnifier.py index 35c9fb076c5..5ddf4960a89 100644 --- a/source/_magnifier/fullscreenMagnifier.py +++ b/source/_magnifier/fullscreenMagnifier.py @@ -14,16 +14,25 @@ from .magnifier import Magnifier from .utils.filterHandler import FilterMatrix from .utils.spotlightManager import SpotlightManager -from .utils.types import Filter, Coordinates, FullScreenMode -from .config import getDefaultFullscreenMode +from .utils.types import ( + Filter, + MagnifierType, + FullScreenMode, + Size, + MagnifierParameters, + Coordinates, +) +from .config import getFullscreenMode, isTrueCentered class FullScreenMagnifier(Magnifier): def __init__(self): super().__init__() - self._fullscreenMode = getDefaultFullscreenMode() + 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 @@ -104,6 +113,9 @@ def _applyFilter(self) -> None: """ Apply the current color filter to the full-screen magnifier """ + if not self._isActive: + return + try: match self.filterType: case Filter.NORMAL: @@ -124,12 +136,15 @@ def _fullscreenMagnifier(self, coordinates: Coordinates) -> None: :coordinates: The (x, y) coordinates to center the magnifier on """ - left, top, visibleWidth, visibleHeight = self._getMagnifierPosition(coordinates) + if not self._isActive: + return + + params = self._getMagnifierParameters(coordinates) try: result = magnification.MagSetFullscreenTransform( self.zoomLevel, - left, - top, + params.coordinates.x, + params.coordinates.y, ) if not result: log.debug("Failed to set full-screen transform") @@ -167,10 +182,11 @@ def _keepMouseCentered(self) -> None: ): log.debug("Mouse button pressed, skipping cursor repositioning to avoid interfering with click") return - coords = self._getCoordinatesForMode(self._currentCoordinates) - left, top, visibleWidth, visibleHeight = self._getMagnifierPosition(coords) - centerX = left + visibleWidth // 2 - centerY = top + visibleHeight // 2 + + coordinates = self._getCoordinatesForMode(self._currentCoordinates) + params = self._getMagnifierParameters(coordinates) + centerX = params.coordinates.x + params.magnifierSize.width // 2 + centerY = params.coordinates.y + params.magnifierSize.height // 2 winUser.setCursorPos(centerX, centerY) def _borderPos( @@ -186,14 +202,16 @@ def _borderPos( :return: The adjusted position (x, y) of the focus point """ focusX, focusY = coordinates - lastLeft, lastTop, visibleWidth, visibleHeight = self._getMagnifierPosition( - self._lastScreenPosition, - ) + params = self._getMagnifierParameters(self._lastScreenPosition) + magnifierWidth = params.magnifierSize.width + magnifierHeight = params.magnifierSize.height + lastLeft = params.coordinates.x + lastTop = params.coordinates.y minX = lastLeft + self._MARGIN_BORDER - maxX = lastLeft + visibleWidth - self._MARGIN_BORDER + maxX = lastLeft + magnifierWidth - self._MARGIN_BORDER minY = lastTop + self._MARGIN_BORDER - maxY = lastTop + visibleHeight - self._MARGIN_BORDER + maxY = lastTop + magnifierHeight - self._MARGIN_BORDER dx = 0 dy = 0 @@ -210,8 +228,8 @@ def _borderPos( if dx != 0 or dy != 0: return Coordinates( - self._lastScreenPosition[0] + dx, - self._lastScreenPosition[1] + dy, + self._lastScreenPosition.x + dx, + self._lastScreenPosition.y + dy, ) else: return self._lastScreenPosition @@ -231,21 +249,21 @@ def _relativePos( zoom = self.zoomLevel mouseX, mouseY = coordinates - visibleWidth = self._displayOrientation.width / zoom - visibleHeight = self._displayOrientation.height / zoom + magnifierWidth = self._displayOrientation.width / zoom + magnifierHeight = self._displayOrientation.height / zoom margin = int(zoom * 10) # Calculate left/top maintaining mouse relative position - left = mouseX - (mouseX / self._displayOrientation.width) * (visibleWidth - margin) - top = mouseY - (mouseY / self._displayOrientation.height) * (visibleHeight - margin) + left = mouseX - (mouseX / self._displayOrientation.width) * (magnifierWidth - margin) + top = mouseY - (mouseY / self._displayOrientation.height) * (magnifierHeight - margin) # Clamp to screen boundaries - left = max(0, min(left, self._displayOrientation.width - visibleWidth)) - top = max(0, min(top, self._displayOrientation.height - visibleHeight)) + left = max(0, min(left, self._displayOrientation.width - magnifierWidth)) + top = max(0, min(top, self._displayOrientation.height - magnifierHeight)) # Return center of zoom window - centerX = int(left + visibleWidth / 2) - centerY = int(top + visibleHeight / 2) + centerX = int(left + magnifierWidth / 2) + centerY = int(top + magnifierHeight / 2) self._lastScreenPosition = Coordinates(centerX, centerY) return self._lastScreenPosition @@ -265,3 +283,31 @@ def _stopSpotlight(self) -> None: """ self._spotlightManager._spotlightIsActive = False self._startTimer(self._updateMagnifier) + + def _getMagnifierParameters(self, coordinates: Coordinates) -> MagnifierParameters: + """ + Compute the top-left corner of the magnifier window centered on (x, y) + + :param coordinates: The (x, y) coordinates to center the magnifier on + + :return: The size, position and filter of the magnifier window + """ + x, y = coordinates + # Calculate the size of the capture area at the current zoom level + magnifierWidth = self._displayOrientation.width / self.zoomLevel + magnifierHeight = self._displayOrientation.height / self.zoomLevel + + # Compute the top-left corner so that (x, y) is at the center + left = int(x - (magnifierWidth / 2)) + top = int(y - (magnifierHeight / 2)) + + # Clamp to screen boundaries only if not in true center mode + if not isTrueCentered(): + left = max(0, min(left, int(self._displayOrientation.width - magnifierWidth))) + top = max(0, min(top, int(self._displayOrientation.height - magnifierHeight))) + + return MagnifierParameters( + Size(int(magnifierWidth), int(magnifierHeight)), + Coordinates(left, top), + self._filterType, + ) diff --git a/source/_magnifier/magnifier.py b/source/_magnifier/magnifier.py index e3ebf34dee0..19f26e42e82 100644 --- a/source/_magnifier/magnifier.py +++ b/source/_magnifier/magnifier.py @@ -18,17 +18,17 @@ from winAPI import _displayTracking from winAPI._displayTracking import OrientationState, getPrimaryDisplayOrientation from .utils.types import ( - MagnifierPosition, + MagnifierParameters, MagnifierAction, - Coordinates, MagnifierType, Direction, Filter, + Coordinates, ) from .config import ( - getDefaultZoomLevel, - getDefaultPanStep, - getDefaultFilter, + getZoomLevel, + getPanStep, + getFilter, ZoomLevel, isTrueCentered, shouldKeepMouseCentered, @@ -42,16 +42,16 @@ class Magnifier: def __init__(self): self._displayOrientation = getPrimaryDisplayOrientation() - self._magnifierType: MagnifierType = MagnifierType.FULLSCREEN + self._magnifierType: MagnifierType self._isActive: bool = False - self._zoomLevel: float = getDefaultZoomLevel() - self._panStep: int = getDefaultPanStep() + self._zoomLevel: float = getZoomLevel() + self._panStep: int = getPanStep() self._timer: None | wx.Timer = None self._focusManager = FocusManager() self._lastScreenPosition = Coordinates(0, 0) self._currentCoordinates = Coordinates(0, 0) self._lastFocusCoordinates = Coordinates(0, 0) - self._filterType: Filter = getDefaultFilter() + self._filterType: Filter = getFilter() self._isManualPanning: bool = False # Register for display changes _displayTracking.displayChanged.register(self._onDisplayChanged) @@ -298,29 +298,12 @@ def _stopTimer(self) -> None: else: log.debug("no timer to stop") - def _getMagnifierPosition( - self, - coordinates: Coordinates, - ) -> MagnifierPosition: + def _getMagnifierParameters(self, coordinates: Coordinates) -> MagnifierParameters: """ Compute the top-left corner of the magnifier window centered on (x, y) :param coordinates: The (x, y) coordinates to center the magnifier on - :return: The position and size of the magnifier window + :return: The size, position and filter of the magnifier window """ - x, y = coordinates - # Calculate the size of the capture area at the current zoom level - visibleWidth = self._displayOrientation.width / self.zoomLevel - visibleHeight = self._displayOrientation.height / self.zoomLevel - - # Compute the top-left corner so that (x, y) is at the center - left = int(x - (visibleWidth / 2)) - top = int(y - (visibleHeight / 2)) - - # Clamp to screen boundaries only if not in true center mode - if not isTrueCentered(): - left = max(0, min(left, int(self._displayOrientation.width - visibleWidth))) - top = max(0, min(top, int(self._displayOrientation.height - visibleHeight))) - - return MagnifierPosition(left, top, int(visibleWidth), int(visibleHeight)) + raise NotImplementedError("Subclasses must implement this method") diff --git a/source/_magnifier/utils/focusManager.py b/source/_magnifier/utils/focusManager.py index 20c04b00f6f..a4fe0c80a2c 100644 --- a/source/_magnifier/utils/focusManager.py +++ b/source/_magnifier/utils/focusManager.py @@ -11,7 +11,7 @@ import api import winUser import mouseHandler -from .types import Coordinates, FocusType +from .types import FocusType, Coordinates class FocusManager: diff --git a/source/_magnifier/utils/types.py b/source/_magnifier/utils/types.py index cc18b9f70b6..3d4a1add0c6 100644 --- a/source/_magnifier/utils/types.py +++ b/source/_magnifier/utils/types.py @@ -12,12 +12,11 @@ from utils.displayString import DisplayStringStrEnum, DisplayStringEnum -class MagnifierParams(NamedTuple): - """Named tuple representing magnifier parameters for initialization""" +class Coordinates(NamedTuple): + """Named tuple representing x and y coordinates""" - zoomLevel: float - filter: str - fullscreenMode: str + x: int + y: int class Direction(Enum): @@ -27,6 +26,13 @@ class Direction(Enum): OUT = False +class Size(NamedTuple): + """Named tuple representing width and height""" + + width: int + height: int + + class MagnifierAction(DisplayStringEnum): """Actions that can be performed with the magnifier""" @@ -80,6 +86,7 @@ class MagnifierType(DisplayStringStrEnum): """Type of magnifier""" FULLSCREEN = "fullscreen" + FIXED = "fixed" DOCKED = "docked" LENS = "lens" @@ -88,6 +95,8 @@ def _displayStringLabels(self) -> dict["MagnifierType", str]: return { # Translators: Magnifier type - full-screen mode. self.FULLSCREEN: pgettext("magnifier", "Fullscreen"), + # Translators: Magnifier type - fixed mode. + self.FIXED: pgettext("magnifier", "Fixed"), # Translators: Magnifier type - docked mode. self.DOCKED: pgettext("magnifier", "Docked"), # Translators: Magnifier type - lens mode. @@ -95,6 +104,23 @@ def _displayStringLabels(self) -> dict["MagnifierType", str]: } +class Filter(DisplayStringStrEnum): + NORMAL = "normal" + GRAYSCALE = "grayscale" + INVERTED = "inverted" + + @property + def _displayStringLabels(self) -> dict["Filter", str]: + return { + # Translators: Magnifier color filter - no filter applied. + self.NORMAL: pgettext("magnifier", "Normal"), + # Translators: Magnifier color filter - grayscale/black and white. + self.GRAYSCALE: pgettext("magnifier", "Grayscale"), + # Translators: Magnifier color filter - inverted colors. + self.INVERTED: pgettext("magnifier", "Inverted"), + } + + class FocusType(Enum): """Type of focus being tracked by the magnifier""" @@ -103,13 +129,12 @@ class FocusType(Enum): NAVIGATOR = auto() -class MagnifierPosition(NamedTuple): - """Named tuple representing the position and size of the magnifier window""" +class MagnifierParameters(NamedTuple): + """Named tuple representing the size and position of the magnifier""" - left: int - top: int - visibleWidth: int - visibleHeight: int + magnifierSize: Size + coordinates: Coordinates + filter: Filter class Coordinates(NamedTuple): diff --git a/source/config/configSpec.py b/source/config/configSpec.py index 378ad16bdd8..98eb85bf58c 100644 --- a/source/config/configSpec.py +++ b/source/config/configSpec.py @@ -116,13 +116,12 @@ # Magnifier settings [magnifier] - defaultZoomLevel = float(min=1.0, max=10.0, default=2.0) - defaultPanStep = integer(min=1, max=100, default=10) - defaultFullscreenMode = string(default="center") + zoomLevel = float(min=1.0, max=10.0, default=2.0) + panStep = integer(min=1, max=100, default=10) isTrueCentered = boolean(default=False) - defaultFilter = string(default="normal") + filter = string(default="normal") + fullscreenMode = string(default="center") keepMouseCentered = boolean(default=false) - saveShortcutChanges = boolean(default=false) # Presentation settings [presentation] diff --git a/source/gui/settingsDialogs.py b/source/gui/settingsDialogs.py index 482d784d8f2..6ba57acf92b 100644 --- a/source/gui/settingsDialogs.py +++ b/source/gui/settingsDialogs.py @@ -6035,39 +6035,39 @@ def makeSettings( ) # ZOOM SETTINGS - # Translators: The label for a setting in magnifier settings to select the default zoom level. - defaultZoomLabelText = _("Default &zoom level:") + # Translators: The label for a setting in magnifier settings to select the zoom level. + zoomLabelText = _("&Zoom level:") zoomValues = magnifierConfig.ZoomLevel.zoom_range() zoomChoices = magnifierConfig.ZoomLevel.zoom_strings() - self.defaultZoomList = sHelper.addLabeledControl( - defaultZoomLabelText, + self.zoomList = sHelper.addLabeledControl( + zoomLabelText, wx.Choice, choices=zoomChoices, ) self.bindHelpEvent( - "MagnifierDefaultZoom", - self.defaultZoomList, + "MagnifierZoom", + self.zoomList, ) - # Set default value from config - defaultZoom = magnifierConfig.getDefaultZoomLevel() - zoomIndex = bisect.bisect_left(zoomValues, defaultZoom) + # Set value from config + zoomLevel = magnifierConfig.getZoomLevel() + zoomIndex = bisect.bisect_left(zoomValues, zoomLevel) # Find the closest value if zoomIndex == 0: closestIndex = 0 elif zoomIndex >= len(zoomValues): closestIndex = len(zoomValues) - 1 else: - closestIndex = min(zoomIndex - 1, zoomIndex, key=lambda i: abs(zoomValues[i] - defaultZoom)) - self.defaultZoomList.SetSelection(closestIndex) + closestIndex = min(zoomIndex - 1, zoomIndex, key=lambda i: abs(zoomValues[i] - zoomLevel)) + self.zoomList.SetSelection(closestIndex) # PAN SETTINGS # Translators: The label for a setting in magnifier settings to select the pan step size (in percentage). panStepSizeLabelText = _("&Panning step size (%):") - self.defaultPanSpinCtrl = sHelper.addLabeledControl( + self.panSpinCtrl = sHelper.addLabeledControl( panStepSizeLabelText, wx.SpinCtrl, min=1, @@ -6075,40 +6075,40 @@ def makeSettings( ) self.bindHelpEvent( "magnifierPanStep", - self.defaultPanSpinCtrl, + self.panSpinCtrl, ) - # Set default value from config - defaultPan = magnifierConfig.getDefaultPanStep() - self.defaultPanSpinCtrl.SetValue(defaultPan) + # Set value from config + panStep = magnifierConfig.getPanStep() + self.panSpinCtrl.SetValue(panStep) # FILTER SETTINGS # Translators: The label for a setting in magnifier settings to select the default filter - defaultFilterLabelText = _("Default &filter:") + filterLabelText = _("&filter:") filterChoices = [f.displayString for f in Filter] - self.defaultFilterList = sHelper.addLabeledControl( - defaultFilterLabelText, + self.filterList = sHelper.addLabeledControl( + filterLabelText, wx.Choice, choices=filterChoices, ) - self.bindHelpEvent("MagnifierDefaultFilter", self.defaultFilterList) + self.bindHelpEvent("MagnifierFilter", self.filterList) - # Set default value from config - defaultFilter = magnifierConfig.getDefaultFilter() - self.defaultFilterList.SetSelection(list(Filter).index(defaultFilter)) + # Set value from config + filterValue = magnifierConfig.getFilter() + self.filterList.SetSelection(list(Filter).index(filterValue)) # FULLSCREEN MODE SETTINGS - # Translators: The label for a setting in magnifier settings to select the default full-screen mode - defaultFullscreenModeLabelText = _("Default &fullscreen mode:") + # Translators: The label for a setting in magnifier settings to select the full-screen mode + fullscreenModeLabelText = _("&fullscreen mode:") fullscreenModeChoices = [mode.displayString for mode in FullScreenMode] if FullScreenMode else [] - self.defaultFullscreenModeList = sHelper.addLabeledControl( - defaultFullscreenModeLabelText, + self.fullscreenModeList = sHelper.addLabeledControl( + fullscreenModeLabelText, wx.Choice, choices=fullscreenModeChoices, ) self.bindHelpEvent( - "MagnifierDefaultFullscreenFocusMode", - self.defaultFullscreenModeList, + "MagnifierFullscreenFocusMode", + self.fullscreenModeList, ) # TRUE CENTER @@ -6122,8 +6122,8 @@ def makeSettings( self.trueCenterCheckBox.SetValue(magnifierConfig.isTrueCentered()) # Set default value from config - defaultFullscreenMode = magnifierConfig.getDefaultFullscreenMode() - self.defaultFullscreenModeList.SetSelection(list(FullScreenMode).index(defaultFullscreenMode)) + defaultFullscreenMode = magnifierConfig.getFullscreenMode() + self.fullscreenModeList.SetSelection(list(FullScreenMode).index(defaultFullscreenMode)) # KEEP MOUSE CENTERED # Translators: The label for a checkbox to keep the mouse pointer centered in the magnifier view @@ -6137,16 +6137,16 @@ def makeSettings( def onSave(self): """Save the current selections to config.""" - selectedZoom = self.defaultZoomList.GetSelection() - magnifierConfig.setDefaultZoomLevel(magnifierConfig.ZoomLevel.zoom_range()[selectedZoom]) + selectedZoom = self.zoomList.GetSelection() + magnifierConfig.setZoomLevel(magnifierConfig.ZoomLevel.zoom_range()[selectedZoom]) - magnifierConfig.setDefaultPanStep(self.defaultPanSpinCtrl.GetValue()) + magnifierConfig.setPanStep(self.panSpinCtrl.GetValue()) - selectedFilterIdx = self.defaultFilterList.GetSelection() - magnifierConfig.setDefaultFilter(list(Filter)[selectedFilterIdx]) + selectedFilterIdx = self.filterList.GetSelection() + magnifierConfig.setFilter(list(Filter)[selectedFilterIdx]) - selectedModeIdx = self.defaultFullscreenModeList.GetSelection() - magnifierConfig.setDefaultFullscreenMode(list(FullScreenMode)[selectedModeIdx]) + selectedModeIdx = self.fullscreenModeList.GetSelection() + magnifierConfig.setFullscreenMode(list(FullScreenMode)[selectedModeIdx]) config.conf["magnifier"]["isTrueCentered"] = self.trueCenterCheckBox.GetValue() config.conf["magnifier"]["keepMouseCentered"] = self.keepMouseCenteredCheckBox.GetValue() diff --git a/tests/unit/test_magnifier/test_focusManager.py b/tests/unit/test_magnifier/test_focusManager.py index 24ca10e77d9..b1300b465ed 100644 --- a/tests/unit/test_magnifier/test_focusManager.py +++ b/tests/unit/test_magnifier/test_focusManager.py @@ -5,7 +5,7 @@ from dataclasses import dataclass from _magnifier.utils.focusManager import FocusManager -from _magnifier.utils.types import Coordinates, FocusType +from _magnifier.utils.types import FocusType, Coordinates import unittest from unittest.mock import MagicMock, Mock, patch import mouseHandler @@ -110,7 +110,7 @@ def testGetCurrentFocusCoordinates(self): FocusTestParam( navigatorObjectPos=Coordinates(0, 0), systemFocusPos=Coordinates(0, 0), - mousePos=(0, 0), + mousePos=Coordinates(0, 0), leftPressed=True, expectedCoords=Coordinates(0, 0), expectedFocus=FocusType.MOUSE, @@ -119,7 +119,7 @@ def testGetCurrentFocusCoordinates(self): FocusTestParam( navigatorObjectPos=Coordinates(0, 0), systemFocusPos=Coordinates(0, 0), - mousePos=(10, 10), + mousePos=Coordinates(10, 10), leftPressed=False, expectedCoords=Coordinates(10, 10), expectedFocus=FocusType.MOUSE, @@ -128,7 +128,7 @@ def testGetCurrentFocusCoordinates(self): FocusTestParam( navigatorObjectPos=Coordinates(0, 0), systemFocusPos=Coordinates(15, 15), - mousePos=(0, 0), + mousePos=Coordinates(0, 0), leftPressed=False, expectedCoords=Coordinates(15, 15), expectedFocus=FocusType.SYSTEM_FOCUS, @@ -137,7 +137,7 @@ def testGetCurrentFocusCoordinates(self): FocusTestParam( navigatorObjectPos=Coordinates(20, 20), systemFocusPos=Coordinates(0, 0), - mousePos=(0, 0), + mousePos=Coordinates(0, 0), leftPressed=False, expectedCoords=Coordinates(20, 20), expectedFocus=FocusType.NAVIGATOR, @@ -146,7 +146,7 @@ def testGetCurrentFocusCoordinates(self): FocusTestParam( navigatorObjectPos=Coordinates(30, 30), systemFocusPos=Coordinates(15, 15), - mousePos=(0, 0), + mousePos=Coordinates(0, 0), leftPressed=False, expectedCoords=Coordinates(30, 30), expectedFocus=FocusType.NAVIGATOR, @@ -155,7 +155,7 @@ def testGetCurrentFocusCoordinates(self): FocusTestParam( navigatorObjectPos=Coordinates(0, 0), systemFocusPos=Coordinates(0, 0), - mousePos=(0, 0), + mousePos=Coordinates(0, 0), leftPressed=False, expectedCoords=Coordinates(0, 0), expectedFocus=FocusType.MOUSE, @@ -165,7 +165,7 @@ def testGetCurrentFocusCoordinates(self): FocusTestParam( navigatorObjectPos=Coordinates(0, 0), systemFocusPos=Coordinates(0, 0), - mousePos=(0, 0), + mousePos=Coordinates(0, 0), leftPressed=False, expectedCoords=Coordinates(0, 0), expectedFocus=FocusType.NAVIGATOR, @@ -175,7 +175,7 @@ def testGetCurrentFocusCoordinates(self): FocusTestParam( navigatorObjectPos=Coordinates(10, 10), systemFocusPos=Coordinates(0, 0), - mousePos=(20, 20), + mousePos=Coordinates(20, 20), leftPressed=False, expectedCoords=Coordinates(20, 20), expectedFocus=FocusType.MOUSE, @@ -184,7 +184,7 @@ def testGetCurrentFocusCoordinates(self): FocusTestParam( navigatorObjectPos=Coordinates(10, 10), systemFocusPos=Coordinates(15, 15), - mousePos=(20, 20), + mousePos=Coordinates(20, 20), leftPressed=True, expectedCoords=Coordinates(20, 20), expectedFocus=FocusType.MOUSE, diff --git a/tests/unit/test_magnifier/test_fullscreenMagnifier.py b/tests/unit/test_magnifier/test_fullscreenMagnifier.py index f300db49d3e..76e5fa5c7f3 100644 --- a/tests/unit/test_magnifier/test_fullscreenMagnifier.py +++ b/tests/unit/test_magnifier/test_fullscreenMagnifier.py @@ -11,12 +11,13 @@ from winAPI._displayTracking import getPrimaryDisplayOrientation -class TestMagnifierEndToEnd(_TestMagnifier): - """End-to-end test suite for Magnifier functionality.""" +class TestFullscreenMagnifierEndToEnd(_TestMagnifier): + """End-to-end test suite for fullscreen magnifier functionality.""" def testMagnifierCreation(self): """Test creating a magnifier.""" magnifier = FullScreenMagnifier() + magnifier._startMagnifier() self.assertEqual(magnifier.zoomLevel, 2.0) self.assertEqual(magnifier.filterType, Filter.NORMAL) @@ -29,6 +30,7 @@ def testMagnifierCreation(self): def testMagnifierZoom(self): """Test zoom functionality.""" magnifier = FullScreenMagnifier() + magnifier._startMagnifier() # Set initial zoom to 1.0 for predictable testing magnifier.zoomLevel = 1.0 @@ -48,6 +50,7 @@ def testMagnifierZoom(self): def testMagnifierCoordinates(self): """Test coordinate handling.""" magnifier = FullScreenMagnifier() + magnifier._startMagnifier() # Test setting coordinates magnifier._currentCoordinates = (100, 200) @@ -63,6 +66,7 @@ def testMagnifierCoordinates(self): def testMagnifierUpdate(self): """Test magnifier update cycle.""" magnifier = FullScreenMagnifier() + magnifier._startMagnifier() # Mock the update methods magnifier._getCoordinatesForMode = MagicMock(return_value=(150, 250)) @@ -85,6 +89,7 @@ def testMagnifierUpdate(self): def testMagnifierStop(self): """Test stopping the magnifier.""" magnifier = FullScreenMagnifier() + magnifier._startMagnifier() # Mock the timer magnifier._stopTimer = MagicMock() @@ -104,20 +109,20 @@ def testMagnifierPositionCalculation(self): magnifier = FullScreenMagnifier() # Test position calculation - left, top, width, height = magnifier._getMagnifierPosition((500, 400)) + params = magnifier._getMagnifierParameters((500, 400)) # Basic checks - self.assertIsInstance(left, int) - self.assertIsInstance(top, int) - self.assertIsInstance(width, int) - self.assertIsInstance(height, int) + self.assertIsInstance(params.coordinates.x, int) + self.assertIsInstance(params.coordinates.y, int) + self.assertIsInstance(params.magnifierSize.width, int) + self.assertIsInstance(params.magnifierSize.height, int) # Width and height should be screen size divided by zoom expectedWidth = int(magnifier._displayOrientation.width / 2.0) expectedHeight = int(magnifier._displayOrientation.height / 2.0) - self.assertEqual(width, expectedWidth) - self.assertEqual(height, expectedHeight) + self.assertEqual(params.magnifierSize.width, expectedWidth) + self.assertEqual(params.magnifierSize.height, expectedHeight) # Cleanup magnifier._stopMagnifier() @@ -190,6 +195,7 @@ def testMagnifierSimpleLifecycle(self): """Test simple magnifier lifecycle.""" # Create magnifier magnifier = FullScreenMagnifier() + magnifier._startMagnifier() self.assertTrue(magnifier._isActive) self.assertEqual(magnifier.zoomLevel, 2.0) @@ -229,8 +235,11 @@ def tearDown(self): def _expectedCenter(self, rawCoords: Coordinates) -> tuple[int, int]: """Compute the expected cursor position using the same pipeline as _keepMouseCentered.""" coords = self.magnifier._getCoordinatesForMode(rawCoords) - left, top, w, h = self.magnifier._getMagnifierPosition(coords) - return left + w // 2, top + h // 2 + params = self.magnifier._getMagnifierParameters(coords) + return ( + params.coordinates.x + params.magnifierSize.width // 2, + params.coordinates.y + params.magnifierSize.height // 2, + ) def testSkipsWhenLeftButtonPressed(self): """Cursor is not moved when the left mouse button is held.""" diff --git a/tests/unit/test_magnifier/test_magnifier.py b/tests/unit/test_magnifier/test_magnifier.py index b901507ea15..cd85c61f240 100644 --- a/tests/unit/test_magnifier/test_magnifier.py +++ b/tests/unit/test_magnifier/test_magnifier.py @@ -3,8 +3,8 @@ # 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 -from _magnifier.magnifier import Magnifier, MagnifierType -from _magnifier.utils.types import Filter, Direction, Coordinates, MagnifierAction +from _magnifier.magnifier import Magnifier +from _magnifier.utils.types import Filter, Direction, MagnifierAction, Coordinates import unittest from winAPI._displayTracking import getPrimaryDisplayOrientation from unittest.mock import MagicMock, patch @@ -22,9 +22,14 @@ def setUpClass(cls): def setUp(self): """Setup before each test - mock magnification API to prevent actual screen magnification.""" - # Mock the Windows Magnification API to prevent affecting the user's screen - self.mag_patcher = patch("winBindings.magnification") - self.mock_mag = self.mag_patcher.start() + # Patch both the source module and the imported alias used by FullScreenMagnifier. + # This avoids leaking real Magnification API state across unrelated test suites. + self._magPatchers = [ + patch("winBindings.magnification"), + patch("_magnifier.fullscreenMagnifier.magnification"), + ] + self.mock_mag = self._magPatchers[1].start() + self._magPatchers[0].start() # Configure mocked API methods to return success self.mock_mag.MagInitialize.return_value = True @@ -34,7 +39,8 @@ def setUp(self): def tearDown(self): """Cleanup after each test.""" - self.mag_patcher.stop() + for magPatcher in reversed(self._magPatchers): + magPatcher.stop() class TestMagnifier(_TestMagnifier): @@ -63,7 +69,6 @@ def testMagnifierCreation(self): """Can we create a magnifier with valid parameters?""" self.assertEqual(self.magnifier.zoomLevel, 2.0) self.assertEqual(self.magnifier._filterType, Filter.NORMAL) - self.assertEqual(self.magnifier._magnifierType, MagnifierType.FULLSCREEN) self.assertFalse(self.magnifier._isActive) self.assertIsNotNone(self.magnifier._focusManager) @@ -400,50 +405,3 @@ def testStopTimer(self): # Test stopping when no timer exists (should not raise error) self.magnifier._stopTimer() self.assertIsNone(self.magnifier._timer) - - def testMagnifierPosition(self): - """Computing magnifier position and size.""" - x, y = int(self.screenWidth / 2), int(self.screenHeight / 2) - left, top, width, height = self.magnifier._getMagnifierPosition((x, y)) - - expected_width = int(self.screenWidth / self.magnifier.zoomLevel) - expected_height = int(self.screenHeight / self.magnifier.zoomLevel) - expected_left = int(x - (expected_width / 2)) - expected_top = int(y - (expected_height / 2)) - - self.assertEqual(left, expected_left) - self.assertEqual(top, expected_top) - self.assertEqual(width, expected_width) - self.assertEqual(height, expected_height) - - # Test left clamping - left, top, width, height = self.magnifier._getMagnifierPosition((100, 540)) - self.assertGreaterEqual(left, 0) - - # Test right clamping - left, top, width, height = self.magnifier._getMagnifierPosition((1800, 540)) - self.assertLessEqual(left + width, self.screenWidth) - - # Test different zoom level - self.magnifier.zoomLevel = 4.0 - left, top, width, height = self.magnifier._getMagnifierPosition((960, 540)) - expected_width = int(self.screenWidth / self.magnifier.zoomLevel) - expected_height = int(self.screenHeight / self.magnifier.zoomLevel) - self.assertEqual(width, expected_width) - self.assertEqual(height, expected_height) - - def testMagnifierPositionTrueCentered(self): - """Test magnifier position calculation with true centered mode.""" - x, y = int(self.screenWidth / 2), int(self.screenHeight / 2) - with patch("source._magnifier.magnifier.isTrueCentered", return_value=True): - left, top, width, height = self.magnifier._getMagnifierPosition((x, y)) - - expected_width = int(self.screenWidth / self.magnifier.zoomLevel) - expected_height = int(self.screenHeight / self.magnifier.zoomLevel) - expected_left = int(x - (expected_width / 2)) - expected_top = int(y - (expected_height / 2)) - - self.assertEqual(left, expected_left) - self.assertEqual(top, expected_top) - self.assertEqual(width, expected_width) - self.assertEqual(height, expected_height) diff --git a/user_docs/en/userGuide.md b/user_docs/en/userGuide.md index 5c8580c9c5c..8b49fc48d92 100644 --- a/user_docs/en/userGuide.md +++ b/user_docs/en/userGuide.md @@ -2868,7 +2868,7 @@ Key: `NVDA+control+w` The Magnifier category in the NVDA Settings dialog allows you to configure the default behavior of NVDA's built-in [Magnifier](#Magnifier) feature. This settings category contains the following options: -##### Default zoom level {#MagnifierDefaultZoom} +##### Zoom level {#MagnifierZoom} This slider allows you to set the default zoom level when the magnifier is first enabled. The zoom level can range from 1.0 (no magnification) to 10.0 (maximum magnification). @@ -2881,9 +2881,9 @@ You can always adjust the zoom level on the fly using the zoom in (`NVDA+shift+e |Options |1.0 to 10.0| |Default |2.0| -##### Default color filter {#MagnifierDefaultFilter} +##### Filter {#MagnifierFilter} -This combo box allows you to select the default color filter to apply when the magnifier is first enabled. +This combo box allows you to select the filter to apply when the magnifier is first enabled. You can cycle through the color filters by pressing `NVDA+shift+i`. The available options are: @@ -2899,9 +2899,9 @@ The available options are: | Grayscale | Converts all colors to shades of gray, which can help reduce eye strain and improve contrast. | | Inverted | Inverts all colors on the screen, which can be helpful for users who prefer light text on dark backgrounds or have photophobia. | -##### Default focus mode {#MagnifierDefaultFullscreenFocusMode} +##### Focus mode {#MagnifierFullscreenFocusMode} -This combo box allows you to select the default focus tracking mode when the magnifier is first enabled. +This combo box allows you to select the focus tracking mode when the magnifier is first enabled. To cycle through the focus tracking modes, please assign a custom gesture using the [Input Gestures dialog](#InputGestures). The available options are: