From 77b7e1905e4889655b0fb7feaf066f9bbd934e29 Mon Sep 17 00:00:00 2001 From: Antoine HAFFREINGUE Date: Mon, 19 Jan 2026 15:25:48 +0100 Subject: [PATCH 01/21] placeholder and option for magnifier types --- .pre-commit-config.yaml | 12 +-- source/_magnifier/__init__.py | 87 +++++++++++++++---- source/_magnifier/commands.py | 66 +++++++++++--- source/_magnifier/config.py | 23 ++++- source/_magnifier/dockedMagnifier.py | 27 ++++++ source/_magnifier/fixedMagnifier.py | 27 ++++++ source/_magnifier/fullscreenMagnifier.py | 23 +++-- source/_magnifier/lensMagnifier.py | 27 ++++++ source/_magnifier/magnifier.py | 2 +- source/_magnifier/utils/types.py | 6 ++ source/config/configSpec.py | 5 +- source/globalCommands.py | 14 +++ source/gui/settingsDialogs.py | 23 ++++- .../test_fullscreenMagnifier.py | 6 ++ tests/unit/test_magnifier/test_magnifier.py | 3 +- 15 files changed, 299 insertions(+), 52 deletions(-) create mode 100644 source/_magnifier/dockedMagnifier.py create mode 100644 source/_magnifier/fixedMagnifier.py create mode 100644 source/_magnifier/lensMagnifier.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e0a3395e7ea..07cebe56e26 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -118,12 +118,12 @@ repos: # Override python interpreter from .python-versions as that is too strict for pre-commit.ci args: ["-p3.13"] -- repo: https://github.com/DavidAnson/markdownlint-cli2 - rev: v0.18.1 - hooks: - - id: markdownlint-cli2 - name: Lint markdown files - args: ["--fix"] +# - repo: https://github.com/DavidAnson/markdownlint-cli2 +# rev: v0.18.1 +# hooks: +# - id: markdownlint-cli2 +# name: Lint markdown files +# args: ["--fix"] - repo: local hooks: diff --git a/source/_magnifier/__init__.py b/source/_magnifier/__init__.py index 8b3834ea5bb..7fb7049780f 100644 --- a/source/_magnifier/__init__.py +++ b/source/_magnifier/__init__.py @@ -9,7 +9,13 @@ """ from typing import TYPE_CHECKING + +from .config import getDefaultMagnifierType +from .utils.types import MagnifierType from .fullscreenMagnifier import FullScreenMagnifier +from .fixedMagnifier import FixedMagnifier +from .dockedMagnifier import DockedMagnifier +from .lensMagnifier import LensMagnifier if TYPE_CHECKING: from .magnifier import Magnifier @@ -17,47 +23,94 @@ _magnifier: "Magnifier | None" = None -def initialize(): +def createMagnifier(magnifierType: MagnifierType) -> "Magnifier": + """ + Create a magnifier instance based on the specified type. + + :param magnifierType: The type of magnifier to create + :return: The created magnifier instance + :raises ValueError: If the magnifier type is not supported """ - Initialize the magnifier module - For now, only the full-screen magnifier is supported + match magnifierType: + case MagnifierType.FULLSCREEN: + return FullScreenMagnifier() + case MagnifierType.FIXED: + return FixedMagnifier() + case MagnifierType.DOCKED: + return DockedMagnifier() + case MagnifierType.LENS: + return LensMagnifier() + case _: + raise ValueError(f"Unsupported magnifier type: {magnifierType}") + + +def _setMagnifierType(magnifierType: MagnifierType) -> None: + """ + Set the magnifier type, stopping the current one if active and creating a new instance. + + :param magnifierType: The type of magnifier to set """ + global _magnifier + + # Stop current magnifier if active + if _magnifier and _magnifier._isActive: + _magnifier._stopMagnifier() + + # Create and set new magnifier instance + _magnifier = createMagnifier(magnifierType) - magnifier = FullScreenMagnifier() - setMagnifier(magnifier) + +def initialize() -> None: + """ + Initialize the magnifier module with the default magnifier type from config. + """ + magnifierType = getDefaultMagnifierType() + _setMagnifierType(magnifierType) + _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 changeMagnifierType(magnifierType: MagnifierType) -> None: """ - Get current magnifier + Change the magnifier type at runtime. + Stops the current magnifier and starts a new one of the specified type. + + :param magnifierType: The new magnifier type 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 type: magnifier is not active") + + _setMagnifierType(magnifierType) + _magnifier._startMagnifier() -def setMagnifier(magnifier: "Magnifier") -> None: +def getMagnifier() -> "Magnifier | None": """ - Set magnifier instance + Get 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 225501bfbc1..799c4dabcf2 100644 --- a/source/_magnifier/commands.py +++ b/source/_magnifier/commands.py @@ -10,10 +10,11 @@ from typing import Literal import ui -from . import getMagnifier, initialize, terminate +from . import getMagnifier, initialize, changeMagnifierType, terminate from .config import ( getDefaultZoomLevelString, getDefaultFilter, + getDefaultMagnifierType, getDefaultFullscreenMode, ) from .magnifier import Magnifier @@ -57,19 +58,33 @@ def toggleMagnifier() -> None: initialize() filter = getDefaultFilter() - fullscreenMode = getDefaultFullscreenMode() - - ui.message( - pgettext( - "magnifier", - # Translators: Message announced when starting the NVDA magnifier. - "Starting magnifier with {zoomLevel} zoom level, {filter} filter, and {fullscreenMode} full-screen mode", - ).format( - zoomLevel=getDefaultZoomLevelString(), - filter=filter.displayString, - fullscreenMode=fullscreenMode.displayString, - ), - ) + magnifierType = getDefaultMagnifierType() + if magnifierType == MagnifierType.FULLSCREEN: + fullscreenMode = getDefaultFullscreenMode() + ui.message( + pgettext( + "magnifier", + # Translators: Message announced when starting the NVDA magnifier. + "Starting fullscreen magnifier with {zoomLevel} zoom level, {filter} filter, and {fullscreenMode} full-screen mode", + ).format( + magnifierType=magnifierType.displayString, + zoomLevel=getDefaultZoomLevelString(), + filter=filter.displayString, + fullscreenMode=fullscreenMode.displayString, + ), + ) + else: + ui.message( + pgettext( + "magnifier", + # Translators: Message announced when starting the NVDA magnifier. + "Starting {magnifierType} magnifier with {zoomLevel} zoom level and {filter} filter", + ).format( + magnifierType=magnifierType.displayString, + zoomLevel=getDefaultZoomLevelString(), + filter=filter.displayString, + ), + ) def zoomIn() -> None: @@ -106,6 +121,29 @@ def zoomOut() -> None: ) +def toggleMagnifierType() -> None: + """Cycle through magnifier types (full-screen, docked, lens)""" + magnifier: Magnifier = getMagnifier() + if magnifierIsActiveVerify( + magnifier, + MagnifierAction.CHANGE_MAGNIFIER_TYPE, + ): + types = list(MagnifierType) + currentType = magnifier._magnifierType + idx = types.index(currentType) + newType = types[(idx + 1) % len(types)] + log.debug(f"Changing magnifier type from {currentType} to {newType}") + changeMagnifierType(newType) + magnifier = getMagnifier() + ui.message( + pgettext( + "magnifier", + # Translators: Message announced when changing the magnifier type with {type} being the new magnifier type. + "Magnifier type changed to {type}", + ).format(type=magnifier._magnifierType.displayString), + ) + + def toggleFilter() -> None: """Cycle through color filters""" magnifier: Magnifier = getMagnifier() diff --git a/source/_magnifier/config.py b/source/_magnifier/config.py index 459ab3ed9d2..d42e8445186 100644 --- a/source/_magnifier/config.py +++ b/source/_magnifier/config.py @@ -9,7 +9,7 @@ """ import config -from .utils.types import Filter, FullScreenMode +from .utils.types import Filter, FullScreenMode, MagnifierType class ZoomLevel: @@ -74,12 +74,27 @@ def setDefaultZoomLevel(zoomLevel: float) -> None: :param zoomLevel: The zoom level to set. """ - - if "magnifier" not in config.conf: - config.conf["magnifier"] = {} config.conf["magnifier"]["defaultZoomLevel"] = zoomLevel +def getDefaultMagnifierType() -> MagnifierType: + """ + Get default magnifier type from config. + + :return: The default magnifier type. + """ + return MagnifierType(config.conf["magnifier"]["defaultMagnifierType"]) + + +def setDefaultMagnifierType(magnifierType: MagnifierType) -> None: + """ + Set default magnifier type from settings. + + :param magnifierType: The magnifier type to set. + """ + config.conf["magnifier"]["defaultMagnifierType"] = magnifierType.value + + def getDefaultFilter() -> Filter: """ Get default filter from config. diff --git a/source/_magnifier/dockedMagnifier.py b/source/_magnifier/dockedMagnifier.py new file mode 100644 index 00000000000..98a5e56705a --- /dev/null +++ b/source/_magnifier/dockedMagnifier.py @@ -0,0 +1,27 @@ +# A part of NonVisual Desktop Access (NVDA) +# Copyright (C) 2025 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 Coordinates, MagnifierType + + +class DockedMagnifier(Magnifier): + def __init__(self): + super().__init__() + self._magnifierType = MagnifierType.DOCKED + self._currentCoordinates = Coordinates(0, 0) + + def _startMagnifier(self) -> None: + super()._startMagnifier() + + def _stopMagnifier(self) -> None: + super()._stopMagnifier() + + def _doUpdate(self): + return super()._doUpdate() diff --git a/source/_magnifier/fixedMagnifier.py b/source/_magnifier/fixedMagnifier.py new file mode 100644 index 00000000000..85936eca78a --- /dev/null +++ b/source/_magnifier/fixedMagnifier.py @@ -0,0 +1,27 @@ +# A part of NonVisual Desktop Access (NVDA) +# Copyright (C) 2025 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 Coordinates, MagnifierType + + +class FixedMagnifier(Magnifier): + def __init__(self): + super().__init__() + self._magnifierType = MagnifierType.FIXED + self._currentCoordinates = Coordinates(0, 0) + + def _startMagnifier(self) -> None: + super()._startMagnifier() + + def _stopMagnifier(self) -> None: + super()._stopMagnifier() + + def _doUpdate(self): + return super()._doUpdate() diff --git a/source/_magnifier/fullscreenMagnifier.py b/source/_magnifier/fullscreenMagnifier.py index 240690f0e64..45750e37893 100644 --- a/source/_magnifier/fullscreenMagnifier.py +++ b/source/_magnifier/fullscreenMagnifier.py @@ -14,17 +14,17 @@ from .magnifier import Magnifier from .utils.filterHandler import FilterMatrix from .utils.spotlightManager import SpotlightManager -from .utils.types import Filter, Coordinates, FullScreenMode, FocusType +from .utils.types import Filter, Coordinates, MagnifierType, FullScreenMode, FocusType from .config import getDefaultFullscreenMode, shouldKeepMouseCentered class FullScreenMagnifier(Magnifier): def __init__(self): super().__init__() + self._magnifierType = MagnifierType.FULLSCREEN self._fullscreenMode = getDefaultFullscreenMode() self._currentCoordinates = Coordinates(0, 0) self._spotlightManager = SpotlightManager(self) - self._startMagnifier() @property def filterType(self) -> Filter: @@ -107,6 +107,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: @@ -127,6 +130,9 @@ def _fullscreenMagnifier(self, coordinates: Coordinates) -> None: :coordinates: The (x, y) coordinates to center the magnifier on """ + if not self._isActive: + return + left, top, visibleWidth, visibleHeight = self._getMagnifierPosition(coordinates) try: result = magnification.MagSetFullscreenTransform( @@ -162,7 +168,9 @@ def moveMouseToScreen(self) -> None: """ keep mouse in screen """ - left, top, visibleWidth, visibleHeight = self._getMagnifierPosition(self._currentCoordinates) + left, top, visibleWidth, visibleHeight = self._getMagnifierPosition( + self._currentCoordinates, + ) centerX = int(left + (visibleWidth / 2)) centerY = int(top + (visibleHeight / 2)) winUser.setCursorPos(centerX, centerY) @@ -203,7 +211,10 @@ def _borderPos( dy = focusY - maxY if dx != 0 or dy != 0: - return Coordinates(self._lastScreenPosition[0] + dx, self._lastScreenPosition[1] + dy) + return Coordinates( + self._lastScreenPosition[0] + dx, + self._lastScreenPosition[1] + dy, + ) else: return self._lastScreenPosition @@ -244,7 +255,9 @@ def _startSpotlight(self) -> None: """ Launch Spotlight from Full-screen class """ - log.debug(f"Launching spotlight mode from full-screen magnifier with mode {self._fullscreenMode}") + log.debug( + f"Launching spotlight mode from full-screen magnifier with mode {self._fullscreenMode}", + ) self._stopTimer() self._spotlightManager._startSpotlight() diff --git a/source/_magnifier/lensMagnifier.py b/source/_magnifier/lensMagnifier.py new file mode 100644 index 00000000000..11aa1533b49 --- /dev/null +++ b/source/_magnifier/lensMagnifier.py @@ -0,0 +1,27 @@ +# A part of NonVisual Desktop Access (NVDA) +# Copyright (C) 2025 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 implementation. +""" + +from .magnifier import Magnifier +from .utils.types import Coordinates, MagnifierType + + +class LensMagnifier(Magnifier): + def __init__(self): + super().__init__() + self._magnifierType = MagnifierType.LENS + self._currentCoordinates = Coordinates(0, 0) + + def _startMagnifier(self) -> None: + super()._startMagnifier() + + def _stopMagnifier(self) -> None: + super()._stopMagnifier() + + def _doUpdate(self): + return super()._doUpdate() diff --git a/source/_magnifier/magnifier.py b/source/_magnifier/magnifier.py index aedd1145988..b151eba1c0e 100644 --- a/source/_magnifier/magnifier.py +++ b/source/_magnifier/magnifier.py @@ -37,7 +37,7 @@ 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._timer: None | wx.Timer = None diff --git a/source/_magnifier/utils/types.py b/source/_magnifier/utils/types.py index 00132596bb5..fae8c13c8fc 100644 --- a/source/_magnifier/utils/types.py +++ b/source/_magnifier/utils/types.py @@ -33,6 +33,7 @@ class MagnifierAction(DisplayStringEnum): ZOOM_IN = auto() ZOOM_OUT = auto() TOGGLE_FILTER = auto() + CHANGE_MAGNIFIER_TYPE = auto() CHANGE_FULLSCREEN_MODE = auto() START_SPOTLIGHT = auto() @@ -45,6 +46,8 @@ def _displayStringLabels(self) -> dict["MagnifierAction", str]: self.ZOOM_OUT: pgettext("magnifier action", "trying to zoom out"), # Translators: Action description for toggling color filters. self.TOGGLE_FILTER: pgettext("magnifier action", "trying to toggle filters"), + # Translators: Action description for changing magnifier type. + self.CHANGE_MAGNIFIER_TYPE: pgettext("magnifier action", "trying to change magnifier type"), # Translators: Action description for changing full-screen mode. self.CHANGE_FULLSCREEN_MODE: pgettext("magnifier action", "trying to change full-screen mode"), # Translators: Action description for starting spotlight mode. @@ -56,6 +59,7 @@ class MagnifierType(DisplayStringStrEnum): """Type of magnifier""" FULLSCREEN = "fullscreen" + FIXED = "fixed" DOCKED = "docked" LENS = "lens" @@ -64,6 +68,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. diff --git a/source/config/configSpec.py b/source/config/configSpec.py index ce1092e0cf9..6efd3689103 100644 --- a/source/config/configSpec.py +++ b/source/config/configSpec.py @@ -114,10 +114,11 @@ # Magnifier settings [magnifier] defaultZoomLevel = float(min=1.0, max=10.0, default=2.0) - defaultFullscreenMode = string(default="center") defaultFilter = string(default="normal") + defaultMagnifierType = string(default="fullscreen") + defaultFullscreenMode = string(default="center") keepMouseCentered = boolean(default=false) - saveShortcutChanges = boolean(default=false) + # Presentation settings [presentation] diff --git a/source/globalCommands.py b/source/globalCommands.py index c3093cc2018..19edff2a2ee 100755 --- a/source/globalCommands.py +++ b/source/globalCommands.py @@ -5009,6 +5009,20 @@ def script_zoomOut( ) -> None: _magnifier.commands.zoomOut() + @script( + description=_( + # Translators: Describes a command. + "Toggle Magnifier type", + ), + category=SCRCAT_VISION, + gesture="kb:nvda+shift+t", + ) + def script_toggleMagnifierType( + self, + gesture: inputCore.InputGesture, + ) -> None: + _magnifier.commands.toggleMagnifierType() + @script( description=_( # Translators: Describes a command. diff --git a/source/gui/settingsDialogs.py b/source/gui/settingsDialogs.py index ff48bd2dc6c..1f82e24a511 100644 --- a/source/gui/settingsDialogs.py +++ b/source/gui/settingsDialogs.py @@ -43,7 +43,7 @@ import languageHandler import logHandler import _magnifier.config as magnifierConfig -from _magnifier.utils.types import Filter, FullScreenMode +from _magnifier.utils.types import Filter, FullScreenMode, MagnifierType import queueHandler import requests import speech @@ -5973,6 +5973,24 @@ def makeSettings( defaultFilter = magnifierConfig.getDefaultFilter() self.defaultFilterList.SetSelection(list(Filter).index(defaultFilter)) + # MAGNIFIER TYPE SETTINGS + # Translators: The label for a setting in magnifier settings to select the default magnifier type + magnifierTypeLabelText = _("Default &magnifier type:") + magnifierTypeChoices = [mt.displayString for mt in MagnifierType] + self.magnifierTypeList = sHelper.addLabeledControl( + magnifierTypeLabelText, + wx.Choice, + choices=magnifierTypeChoices, + ) + self.bindHelpEvent( + "magnifierDefaultMagnifierType", + self.magnifierTypeList, + ) + + # Set default value from config + defaultMagnifierType = magnifierConfig.getDefaultMagnifierType() + self.magnifierTypeList.SetSelection(list(MagnifierType).index(defaultMagnifierType)) + # FULLSCREEN MODE SETTINGS # Translators: The label for a setting in magnifier settings to select the default full-screen mode defaultFullscreenModeLabelText = _("Default &fullscreen mode:") @@ -6009,6 +6027,9 @@ def onSave(self): selectedFilterIdx = self.defaultFilterList.GetSelection() magnifierConfig.setDefaultFilter(list(Filter)[selectedFilterIdx]) + selectedMagnifierTypeIdx = self.magnifierTypeList.GetSelection() + magnifierConfig.setDefaultMagnifierType(list(MagnifierType)[selectedMagnifierTypeIdx]) + selectedModeIdx = self.defaultFullscreenModeList.GetSelection() magnifierConfig.setDefaultFullscreenMode(list(FullScreenMode)[selectedModeIdx]) diff --git a/tests/unit/test_magnifier/test_fullscreenMagnifier.py b/tests/unit/test_magnifier/test_fullscreenMagnifier.py index 56b23d64f9c..4920d868749 100644 --- a/tests/unit/test_magnifier/test_fullscreenMagnifier.py +++ b/tests/unit/test_magnifier/test_fullscreenMagnifier.py @@ -16,6 +16,7 @@ class TestMagnifierEndToEnd(_TestMagnifier): def testMagnifierCreation(self): """Test creating a magnifier.""" magnifier = FullScreenMagnifier() + magnifier._startMagnifier() self.assertEqual(magnifier.zoomLevel, 2.0) self.assertEqual(magnifier.filterType, Filter.NORMAL) @@ -28,6 +29,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 @@ -47,6 +49,7 @@ def testMagnifierZoom(self): def testMagnifierCoordinates(self): """Test coordinate handling.""" magnifier = FullScreenMagnifier() + magnifier._startMagnifier() # Test setting coordinates magnifier._currentCoordinates = (100, 200) @@ -62,6 +65,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)) @@ -84,6 +88,7 @@ def testMagnifierUpdate(self): def testMagnifierStop(self): """Test stopping the magnifier.""" magnifier = FullScreenMagnifier() + magnifier._startMagnifier() # Mock the timer magnifier._stopTimer = MagicMock() @@ -189,6 +194,7 @@ def testMagnifierSimpleLifecycle(self): """Test simple magnifier lifecycle.""" # Create magnifier magnifier = FullScreenMagnifier() + magnifier._startMagnifier() self.assertTrue(magnifier._isActive) self.assertEqual(magnifier.zoomLevel, 2.0) diff --git a/tests/unit/test_magnifier/test_magnifier.py b/tests/unit/test_magnifier/test_magnifier.py index 7e955d86beb..3098f661533 100644 --- a/tests/unit/test_magnifier/test_magnifier.py +++ b/tests/unit/test_magnifier/test_magnifier.py @@ -3,7 +3,7 @@ # 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.magnifier import Magnifier from _magnifier.utils.types import Coordinates, Filter, FocusType, Direction import unittest from winAPI._displayTracking import getPrimaryDisplayOrientation @@ -65,7 +65,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.assertIsNone(self.magnifier._lastFocusedObject) self.assertEqual(self.magnifier._lastNVDAPosition, (0, 0)) From 9c1a665451baec3e3ef1292ff2591410d593955c Mon Sep 17 00:00:00 2001 From: Antoine HAFFREINGUE Date: Mon, 19 Jan 2026 15:28:29 +0100 Subject: [PATCH 02/21] pre-commit --- .pre-commit-config.yaml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 07cebe56e26..e0a3395e7ea 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -118,12 +118,12 @@ repos: # Override python interpreter from .python-versions as that is too strict for pre-commit.ci args: ["-p3.13"] -# - repo: https://github.com/DavidAnson/markdownlint-cli2 -# rev: v0.18.1 -# hooks: -# - id: markdownlint-cli2 -# name: Lint markdown files -# args: ["--fix"] +- repo: https://github.com/DavidAnson/markdownlint-cli2 + rev: v0.18.1 + hooks: + - id: markdownlint-cli2 + name: Lint markdown files + args: ["--fix"] - repo: local hooks: From b4ccd7c28dac4f960eb62031b5b1b37a714f19f6 Mon Sep 17 00:00:00 2001 From: Antoine HAFFREINGUE Date: Wed, 28 Jan 2026 10:35:56 +0100 Subject: [PATCH 03/21] pre-commit --- .pre-commit-config.yaml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 07cebe56e26..e0a3395e7ea 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -118,12 +118,12 @@ repos: # Override python interpreter from .python-versions as that is too strict for pre-commit.ci args: ["-p3.13"] -# - repo: https://github.com/DavidAnson/markdownlint-cli2 -# rev: v0.18.1 -# hooks: -# - id: markdownlint-cli2 -# name: Lint markdown files -# args: ["--fix"] +- repo: https://github.com/DavidAnson/markdownlint-cli2 + rev: v0.18.1 + hooks: + - id: markdownlint-cli2 + name: Lint markdown files + args: ["--fix"] - repo: local hooks: From a1d9f4b11d1dced573b6515f362a3c1806a9afb2 Mon Sep 17 00:00:00 2001 From: Antoine HAFFREINGUE Date: Tue, 3 Feb 2026 16:00:43 +0100 Subject: [PATCH 04/21] changing MagnifierPosition to MagnifierParameters prepare for window handling --- .pre-commit-config.yaml | 12 +++++----- source/_magnifier/fullscreenMagnifier.py | 22 ++++++++--------- source/_magnifier/magnifier.py | 8 +++---- source/_magnifier/utils/types.py | 20 ++++++++-------- .../test_fullscreenMagnifier.py | 14 +++++------ tests/unit/test_magnifier/test_magnifier.py | 24 +++++++++---------- 6 files changed, 50 insertions(+), 50 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e0a3395e7ea..07cebe56e26 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -118,12 +118,12 @@ repos: # Override python interpreter from .python-versions as that is too strict for pre-commit.ci args: ["-p3.13"] -- repo: https://github.com/DavidAnson/markdownlint-cli2 - rev: v0.18.1 - hooks: - - id: markdownlint-cli2 - name: Lint markdown files - args: ["--fix"] +# - repo: https://github.com/DavidAnson/markdownlint-cli2 +# rev: v0.18.1 +# hooks: +# - id: markdownlint-cli2 +# name: Lint markdown files +# args: ["--fix"] - repo: local hooks: diff --git a/source/_magnifier/fullscreenMagnifier.py b/source/_magnifier/fullscreenMagnifier.py index 45750e37893..c3de37357d2 100644 --- a/source/_magnifier/fullscreenMagnifier.py +++ b/source/_magnifier/fullscreenMagnifier.py @@ -133,12 +133,12 @@ def _fullscreenMagnifier(self, coordinates: Coordinates) -> None: if not self._isActive: return - left, top, visibleWidth, visibleHeight = self._getMagnifierPosition(coordinates) + 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") @@ -168,11 +168,9 @@ def moveMouseToScreen(self) -> None: """ keep mouse in screen """ - left, top, visibleWidth, visibleHeight = self._getMagnifierPosition( - self._currentCoordinates, - ) - centerX = int(left + (visibleWidth / 2)) - centerY = int(top + (visibleHeight / 2)) + params = self._getMagnifierParameters(self._currentCoordinates) + centerX = int(params.coordinates.x + (params.visibleWidth / 2)) + centerY = int(params.coordinates.y + (params.visibleHeight / 2)) winUser.setCursorPos(centerX, centerY) def _borderPos( @@ -188,9 +186,11 @@ 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) + visibleWidth = params.visibleWidth + visibleHeight = params.visibleHeight + lastLeft = params.coordinates.x + lastTop = params.coordinates.y minX = lastLeft + self._MARGIN_BORDER maxX = lastLeft + visibleWidth - self._MARGIN_BORDER diff --git a/source/_magnifier/magnifier.py b/source/_magnifier/magnifier.py index 7deddb183fd..1c7baca8f5f 100644 --- a/source/_magnifier/magnifier.py +++ b/source/_magnifier/magnifier.py @@ -20,7 +20,7 @@ from winAPI import _displayTracking from winAPI._displayTracking import OrientationState, getPrimaryDisplayOrientation from .utils.types import ( - MagnifierPosition, + MagnifierParameters, Coordinates, MagnifierType, Direction, @@ -208,13 +208,13 @@ 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 styles (if any) of the magnifier window """ x, y = coordinates # Calculate the size of the capture area at the current zoom level @@ -229,7 +229,7 @@ def _getMagnifierPosition(self, coordinates: Coordinates) -> MagnifierPosition: 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)) + return MagnifierParameters(int(visibleWidth), int(visibleHeight), Coordinates(left, top), None) def _getCursorPosition(self) -> Coordinates: """ diff --git a/source/_magnifier/utils/types.py b/source/_magnifier/utils/types.py index 185fc13fbf3..6340d5716a4 100644 --- a/source/_magnifier/utils/types.py +++ b/source/_magnifier/utils/types.py @@ -27,6 +27,13 @@ class Direction(Enum): OUT = False +class Coordinates(NamedTuple): + """Named tuple representing x and y coordinates""" + + x: int + y: int + + class MagnifierAction(DisplayStringEnum): """Actions that can be performed with the magnifier""" @@ -84,20 +91,13 @@ class FocusType(Enum): NVDA = "nvda" -class MagnifierPosition(NamedTuple): +class MagnifierParameters(NamedTuple): """Named tuple representing the position and size of the magnifier window""" - left: int - top: int visibleWidth: int visibleHeight: int - - -class Coordinates(NamedTuple): - """Named tuple representing x and y coordinates""" - - x: int - y: int + coordinates: Coordinates + styles: None | int class ZoomHistory(NamedTuple): diff --git a/tests/unit/test_magnifier/test_fullscreenMagnifier.py b/tests/unit/test_magnifier/test_fullscreenMagnifier.py index 4920d868749..7b8b6c886c7 100644 --- a/tests/unit/test_magnifier/test_fullscreenMagnifier.py +++ b/tests/unit/test_magnifier/test_fullscreenMagnifier.py @@ -108,20 +108,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.visibleWidth, int) + self.assertIsInstance(params.visibleHeight, 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.visibleWidth, expectedWidth) + self.assertEqual(params.visibleHeight, expectedHeight) # Cleanup magnifier._stopMagnifier() diff --git a/tests/unit/test_magnifier/test_magnifier.py b/tests/unit/test_magnifier/test_magnifier.py index 3098f661533..1033fb79d29 100644 --- a/tests/unit/test_magnifier/test_magnifier.py +++ b/tests/unit/test_magnifier/test_magnifier.py @@ -209,33 +209,33 @@ def testStopTimer(self): 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)) + params = self.magnifier._getMagnifierParameters((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) + self.assertEqual(params.coordinates.x, expected_left) + self.assertEqual(params.coordinates.y, expected_top) + self.assertEqual(params.visibleWidth, expected_width) + self.assertEqual(params.visibleHeight, expected_height) # Test left clamping - left, top, width, height = self.magnifier._getMagnifierPosition((100, 540)) - self.assertGreaterEqual(left, 0) + params = self.magnifier._getMagnifierParameters((100, 540)) + self.assertGreaterEqual(params.coordinates.x, 0) # Test right clamping - left, top, width, height = self.magnifier._getMagnifierPosition((1800, 540)) - self.assertLessEqual(left + width, self.screenWidth) + params = self.magnifier._getMagnifierParameters((1800, 540)) + self.assertLessEqual(params.coordinates.x + params.visibleWidth, self.screenWidth) # Test different zoom level self.magnifier.zoomLevel = 4.0 - left, top, width, height = self.magnifier._getMagnifierPosition((960, 540)) + params = self.magnifier._getMagnifierParameters((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) + self.assertEqual(params.visibleWidth, expected_width) + self.assertEqual(params.visibleHeight, expected_height) def testGetNVDAPosition(self): """Getting NVDA position with different API responses.""" From 704a4a83426128f811b3af5f08310ac72ad137b2 Mon Sep 17 00:00:00 2001 From: Antoine HAFFREINGUE Date: Wed, 4 Feb 2026 14:53:17 +0100 Subject: [PATCH 05/21] fixed window creation, first draft --- source/_magnifier/fixedMagnifier.py | 22 ++- source/_magnifier/fullscreenMagnifier.py | 28 ++-- source/_magnifier/magnifier.py | 14 +- source/_magnifier/utils/types.py | 10 +- source/_magnifier/utils/windowCreator.py | 152 ++++++++++++++++++ .../test_fullscreenMagnifier.py | 8 +- tests/unit/test_magnifier/test_magnifier.py | 10 +- 7 files changed, 208 insertions(+), 36 deletions(-) create mode 100644 source/_magnifier/utils/windowCreator.py diff --git a/source/_magnifier/fixedMagnifier.py b/source/_magnifier/fixedMagnifier.py index 85936eca78a..4f66f163245 100644 --- a/source/_magnifier/fixedMagnifier.py +++ b/source/_magnifier/fixedMagnifier.py @@ -8,7 +8,10 @@ """ from .magnifier import Magnifier -from .utils.types import Coordinates, MagnifierType +from .utils.types import Coordinates, MagnifierType, MagnifierParameters +from .utils.windowCreator import WindowCreator + +import wx class FixedMagnifier(Magnifier): @@ -16,12 +19,27 @@ def __init__(self): super().__init__() self._magnifierType = MagnifierType.FIXED self._currentCoordinates = Coordinates(0, 0) + self._window: None | wx.Frame = None + self.params = MagnifierParameters( + magnifierWidth=300, + magnifierHeight=300, + coordinates=(0, 0), + styles=wx.STAY_ON_TOP | wx.FRAME_NO_TASKBAR, + ) def _startMagnifier(self) -> None: super()._startMagnifier() + self._window = WindowCreator.createMagnifierWindow( + parent=None, + title="Fixed Magnifier", + frameType="fixedMagnifier", + screenSize=self._displayOrientation, + magnifierParameters=self.params, + ) def _stopMagnifier(self) -> None: + self._window.Destroy() super()._stopMagnifier() def _doUpdate(self): - return super()._doUpdate() + self._window.Refresh() diff --git a/source/_magnifier/fullscreenMagnifier.py b/source/_magnifier/fullscreenMagnifier.py index 1c1fa5a0131..01e9304c947 100644 --- a/source/_magnifier/fullscreenMagnifier.py +++ b/source/_magnifier/fullscreenMagnifier.py @@ -169,8 +169,8 @@ def moveMouseToScreen(self) -> None: keep mouse in screen """ params = self._getMagnifierParameters(self._currentCoordinates) - centerX = int(params.coordinates.x + (params.visibleWidth / 2)) - centerY = int(params.coordinates.y + (params.visibleHeight / 2)) + centerX = int(params.coordinates.x + (params.magnifierWidth / 2)) + centerY = int(params.coordinates.y + (params.magnifierHeight / 2)) winUser.setCursorPos(centerX, centerY) def _borderPos( @@ -187,15 +187,15 @@ def _borderPos( """ focusX, focusY = coordinates params = self._getMagnifierParameters(self._lastScreenPosition) - visibleWidth = params.visibleWidth - visibleHeight = params.visibleHeight + magnifierWidth = params.magnifierWidth + magnifierHeight = params.magnifierHeight 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 @@ -233,21 +233,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 diff --git a/source/_magnifier/magnifier.py b/source/_magnifier/magnifier.py index 1295f624884..fd1a428edab 100644 --- a/source/_magnifier/magnifier.py +++ b/source/_magnifier/magnifier.py @@ -212,15 +212,15 @@ def _getMagnifierParameters(self, coordinates: Coordinates) -> MagnifierParamete """ 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 + 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 - (visibleWidth / 2)) - top = int(y - (visibleHeight / 2)) + left = int(x - (magnifierWidth / 2)) + top = int(y - (magnifierHeight / 2)) # Clamp to screen boundaries - left = max(0, min(left, int(self._displayOrientation.width - visibleWidth))) - top = max(0, min(top, int(self._displayOrientation.height - visibleHeight))) + left = max(0, min(left, int(self._displayOrientation.width - magnifierWidth))) + top = max(0, min(top, int(self._displayOrientation.height - magnifierHeight))) - return MagnifierParameters(int(visibleWidth), int(visibleHeight), Coordinates(left, top), None) + return MagnifierParameters(int(magnifierWidth), int(magnifierHeight), Coordinates(left, top), None) diff --git a/source/_magnifier/utils/types.py b/source/_magnifier/utils/types.py index f199fbd3e81..87ead080c9a 100644 --- a/source/_magnifier/utils/types.py +++ b/source/_magnifier/utils/types.py @@ -93,12 +93,14 @@ class FocusType(Enum): class MagnifierParameters(NamedTuple): - """Named tuple representing the position and size of the magnifier window""" + """ + Named tuple representing the position and size of the magnifier window + """ - visibleWidth: int - visibleHeight: int + magnifierWidth: int + magnifierHeight: int coordinates: Coordinates - styles: None | int + styles: int class ZoomHistory(NamedTuple): diff --git a/source/_magnifier/utils/windowCreator.py b/source/_magnifier/utils/windowCreator.py new file mode 100644 index 00000000000..4c244c442d6 --- /dev/null +++ b/source/_magnifier/utils/windowCreator.py @@ -0,0 +1,152 @@ +# A part of NonVisual Desktop Access (NVDA) +# Copyright (C) 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 + +from logHandler import log +import wx + +from .types import MagnifierParameters +from winAPI._displayTracking import OrientationState + + +class MagnifierPanel(wx.Panel): + """A simple panel for the magnifier.""" + + def __init__(self, parent: wx.Frame, panelType: str): + super().__init__(parent) + + self.panelType = panelType + self.SetName(panelType) + + self.contentImage = None + self.contentBitmap = None + + self.Bind(wx.EVT_PAINT, self.onPaint) + + def setContent(self, content): + """Set the content image to be displayed in the magnifier panel.""" + if content: + if isinstance(content, wx.Bitmap): + self.contentBitmap = content + self.contentImage = content.ConvertToImage() if content.IsOk() else None + elif isinstance(content, wx.Image): + self.contentImage = content + self.contentBitmap = wx.Bitmap(content) if content.IsOk() else None + else: + log.error(f"Unknown content type: {type(content)}") + return + + if self.contentImage: + log.info(f"Content details: {self.contentImage.GetWidth()}x{self.contentImage.GetHeight()}") + else: + self.contentBitmap = None + self.contentImage = None + + self.Refresh() + log.info(f"{self.panelType.capitalize()} panel refreshed") + + def onPaint(self, event): + """Handle the paint event to draw the magnified content.""" + dc = wx.PaintDC(self) + # Clear background + dc.Clear() + + # Draw content if available + if self.contentImage and self.contentImage.IsOk(): + # Draw the magnified content + dc.DrawBitmap(self.contentBitmap, 0, 0) + + +class MagnifierFrame(wx.Frame): + """A simple window frame for the magnifier.""" + + def __init__( + self, + parent=None, + title: str = "Magnifier Window", + frameType: str = "magnifier", + screenSize: OrientationState = None, + magnifierParameters: MagnifierParameters = None, + ): + self.frameType = frameType + self.screenSize = screenSize + self.magnifierParameters = magnifierParameters + super().__init__( + parent, + title=title, + size=(magnifierParameters.magnifierWidth, magnifierParameters.magnifierHeight), + ) + self.SetWindowStyle(self.magnifierParameters.styles) + self.SetPosition(self.magnifierParameters.coordinates) + self.panel = self.createPanel() + self.Show() + + def createPanel(self) -> MagnifierPanel: + """Create and return a magnifier panel.""" + return MagnifierPanel(self, self.frameType) + + def setupLayout(self) -> None: + """Set up the layout of the magnifier frame.""" + self.createPanel() + sizer = wx.BoxSizer(wx.VERTICAL) + sizer.Add(self.panel, 1, wx.EXPAND) + self.SetSizer(sizer) + self.Layout() + + def updateFrameContent(self, content) -> None: + """Update the content displayed in the magnifier frame.""" + if self.panel: + self.panel.setContent(content) + + +class WindowCreator: + """A factory class to create magnifier windows.""" + + @staticmethod + def createMagnifierWindow( + parent: wx.Frame, + title: str, + frameType: str, + screenSize: OrientationState, + magnifierParameters: MagnifierParameters, + ) -> MagnifierFrame: + """ + Create and return a magnifier window. + + :param parent: The parent wx.Frame + :param title: The title of the window + :param frameType: The type of the frame (e.g., "magnifier", "spotlight") + :param screenSize: The size of the screen + :param magnifierParameters: The parameters for the magnifier + + :return: An instance of MagnifierFrame + """ + window = MagnifierFrame( + parent=parent, + title=title, + frameType=frameType, + screenSize=screenSize, + magnifierParameters=magnifierParameters, + ) + window.setupLayout() + return window + + +def getContent(magnifierParameters: MagnifierParameters) -> wx.Image: + """ + Placeholder function to get the content for the magnifier. + In a real implementation, this would capture the screen area defined by magnifierParameters. + + :param magnifierParameters: The parameters defining the area to capture + + :return: A wx.Image representing the captured content + """ + # Placeholder implementation + width = magnifierParameters.magnifierWidth + height = magnifierParameters.magnifierHeight + x = magnifierParameters.coordinates.x + y = magnifierParameters.coordinates.y + image = wx.Image(width, height) + image.SetRGBRect(wx.Rect(x, y, width, height), 200, 200, 200) + return image diff --git a/tests/unit/test_magnifier/test_fullscreenMagnifier.py b/tests/unit/test_magnifier/test_fullscreenMagnifier.py index 7b8b6c886c7..a375534ce4c 100644 --- a/tests/unit/test_magnifier/test_fullscreenMagnifier.py +++ b/tests/unit/test_magnifier/test_fullscreenMagnifier.py @@ -113,15 +113,15 @@ def testMagnifierPositionCalculation(self): # Basic checks self.assertIsInstance(params.coordinates.x, int) self.assertIsInstance(params.coordinates.y, int) - self.assertIsInstance(params.visibleWidth, int) - self.assertIsInstance(params.visibleHeight, int) + self.assertIsInstance(params.magnifierWidth, int) + self.assertIsInstance(params.magnifierHeight, 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(params.visibleWidth, expectedWidth) - self.assertEqual(params.visibleHeight, expectedHeight) + self.assertEqual(params.magnifierWidth, expectedWidth) + self.assertEqual(params.magnifierHeight, expectedHeight) # Cleanup magnifier._stopMagnifier() diff --git a/tests/unit/test_magnifier/test_magnifier.py b/tests/unit/test_magnifier/test_magnifier.py index b83360d2f3e..99d43528665 100644 --- a/tests/unit/test_magnifier/test_magnifier.py +++ b/tests/unit/test_magnifier/test_magnifier.py @@ -219,8 +219,8 @@ def testMagnifierPosition(self): self.assertEqual(params.coordinates.x, expected_left) self.assertEqual(params.coordinates.y, expected_top) - self.assertEqual(params.visibleWidth, expected_width) - self.assertEqual(params.visibleHeight, expected_height) + self.assertEqual(params.magnifierWidth, expected_width) + self.assertEqual(params.magnifierHeight, expected_height) # Test left clamping params = self.magnifier._getMagnifierParameters((100, 540)) @@ -228,12 +228,12 @@ def testMagnifierPosition(self): # Test right clamping params = self.magnifier._getMagnifierParameters((1800, 540)) - self.assertLessEqual(params.coordinates.x + params.visibleWidth, self.screenWidth) + self.assertLessEqual(params.coordinates.x + params.magnifierWidth, self.screenWidth) # Test different zoom level self.magnifier.zoomLevel = 4.0 params = self.magnifier._getMagnifierParameters((960, 540)) expected_width = int(self.screenWidth / self.magnifier.zoomLevel) expected_height = int(self.screenHeight / self.magnifier.zoomLevel) - self.assertEqual(params.visibleWidth, expected_width) - self.assertEqual(params.visibleHeight, expected_height) + self.assertEqual(params.magnifierWidth, expected_width) + self.assertEqual(params.magnifierHeight, expected_height) From f695dc68a2ff099fecd7e285bb7f9f940a2690cd Mon Sep 17 00:00:00 2001 From: Antoine HAFFREINGUE Date: Wed, 4 Feb 2026 15:36:05 +0100 Subject: [PATCH 06/21] updated setting dialog for clearer options --- source/gui/settingsDialogs.py | 119 ++++++++++++++++++++++++++++++---- 1 file changed, 105 insertions(+), 14 deletions(-) diff --git a/source/gui/settingsDialogs.py b/source/gui/settingsDialogs.py index ca2ec5a3510..d3f1f06d71d 100644 --- a/source/gui/settingsDialogs.py +++ b/source/gui/settingsDialogs.py @@ -5909,6 +5909,9 @@ class MagnifierPanel(SettingsPanel): title = _("Magnifier") helpId = "MagnifierSettings" + # Translators: This is a label appearing on the magnifier settings panel. + panelDescription = _("The following options control the NVDA magnifier behavior.") + def makeSettings( self, settingsSizer: wx.BoxSizer, @@ -5918,6 +5921,17 @@ def makeSettings( sizer=settingsSizer, ) + sHelper.addItem(wx.StaticText(self, label=self.panelDescription)) + + # GENERAL GROUP + # Translators: This is the label for a group of general magnifier options in the + # magnifier settings panel + generalGroupText = _("General") + generalGroupSizer = wx.StaticBoxSizer(wx.VERTICAL, self, label=generalGroupText) + generalGroupBox = generalGroupSizer.GetStaticBox() + generalGroup = guiHelper.BoxSizerHelper(self, sizer=generalGroupSizer) + sHelper.addItem(generalGroup) + # ZOOM SETTINGS # Translators: The label for a setting in magnifier settings to select the default zoom level. defaultZoomLabelText = _("Default &zoom level:") @@ -5925,7 +5939,7 @@ def makeSettings( zoomValues = magnifierConfig.ZoomLevel.zoom_range() zoomChoices = magnifierConfig.ZoomLevel.zoom_strings() - self.defaultZoomList = sHelper.addLabeledControl( + self.defaultZoomList = generalGroup.addLabeledControl( defaultZoomLabelText, wx.Choice, choices=zoomChoices, @@ -5951,7 +5965,7 @@ def makeSettings( # Translators: The label for a setting in magnifier settings to select the default filter defaultFilterLabelText = _("Default &filter:") filterChoices = [f.displayString for f in Filter] - self.defaultFilterList = sHelper.addLabeledControl( + self.defaultFilterList = generalGroup.addLabeledControl( defaultFilterLabelText, wx.Choice, choices=filterChoices, @@ -5962,11 +5976,23 @@ def makeSettings( defaultFilter = magnifierConfig.getDefaultFilter() self.defaultFilterList.SetSelection(list(Filter).index(defaultFilter)) + # KEEP MOUSE CENTERED + # Translators: The label for a checkbox to keep the mouse pointer centered in the magnifier view + keepMouseCenteredText = _("Keep &mouse pointer centered in magnifier view") + self.keepMouseCenteredCheckBox = generalGroup.addItem( + wx.CheckBox(generalGroupBox, label=keepMouseCenteredText), + ) + self.bindHelpEvent( + "magnifierKeepMouseCentered", + self.keepMouseCenteredCheckBox, + ) + self.keepMouseCenteredCheckBox.SetValue(magnifierConfig.shouldKeepMouseCentered()) + # MAGNIFIER TYPE SETTINGS # Translators: The label for a setting in magnifier settings to select the default magnifier type magnifierTypeLabelText = _("Default &magnifier type:") magnifierTypeChoices = [mt.displayString for mt in MagnifierType] - self.magnifierTypeList = sHelper.addLabeledControl( + self.magnifierTypeList = generalGroup.addLabeledControl( magnifierTypeLabelText, wx.Choice, choices=magnifierTypeChoices, @@ -5980,11 +6006,23 @@ def makeSettings( defaultMagnifierType = magnifierConfig.getDefaultMagnifierType() self.magnifierTypeList.SetSelection(list(MagnifierType).index(defaultMagnifierType)) + # Bind event to update visibility when magnifier type changes + self.magnifierTypeList.Bind(wx.EVT_CHOICE, self._onMagnifierTypeChange) + + # FULLSCREEN GROUP + # Translators: This is the label for a group of fullscreen magnifier options in the + # magnifier settings panel + fullscreenGroupText = _("Fullscreen") + self.fullscreenGroupSizer = wx.StaticBoxSizer(wx.VERTICAL, self, label=fullscreenGroupText) + fullscreenGroupBox = self.fullscreenGroupSizer.GetStaticBox() + fullscreenGroup = guiHelper.BoxSizerHelper(self, sizer=self.fullscreenGroupSizer) + sHelper.addItem(fullscreenGroup) + # FULLSCREEN MODE SETTINGS # Translators: The label for a setting in magnifier settings to select the default full-screen mode - defaultFullscreenModeLabelText = _("Default &fullscreen mode:") + defaultFullscreenModeLabelText = _("Default fullscreen &mode:") fullscreenModeChoices = [mode.displayString for mode in FullScreenMode] if FullScreenMode else [] - self.defaultFullscreenModeList = sHelper.addLabeledControl( + self.defaultFullscreenModeList = fullscreenGroup.addLabeledControl( defaultFullscreenModeLabelText, wx.Choice, choices=fullscreenModeChoices, @@ -5998,15 +6036,68 @@ def makeSettings( defaultFullscreenMode = magnifierConfig.getDefaultFullscreenMode() self.defaultFullscreenModeList.SetSelection(list(FullScreenMode).index(defaultFullscreenMode)) - # KEEP MOUSE CENTERED - # Translators: The label for a checkbox to keep the mouse pointer centered in the magnifier view - keepMouseCenteredText = _("Keep &mouse pointer centered in magnifier view") - self.keepMouseCenteredCheckBox = sHelper.addItem(wx.CheckBox(self, label=keepMouseCenteredText)) - self.bindHelpEvent( - "magnifierKeepMouseCentered", - self.keepMouseCenteredCheckBox, - ) - self.keepMouseCenteredCheckBox.SetValue(magnifierConfig.shouldKeepMouseCentered()) + # FIXED MAGNIFIER GROUP + # Translators: This is the label for a group of fixed magnifier options in the + # magnifier settings panel + fixedGroupText = _("Fixed") + self.fixedGroupSizer = wx.StaticBoxSizer(wx.VERTICAL, self, label=fixedGroupText) + fixedGroupBox = self.fixedGroupSizer.GetStaticBox() + fixedGroup = guiHelper.BoxSizerHelper(self, sizer=self.fixedGroupSizer) + sHelper.addItem(fixedGroup) + + # TODO: Add fixed magnifier specific options here + # Translators: Placeholder text for fixed magnifier options + fixedPlaceholderText = _("Options for fixed magnifier will be added here.") + fixedGroup.addItem(wx.StaticText(fixedGroupBox, label=fixedPlaceholderText)) + + # DOCKED MAGNIFIER GROUP + # Translators: This is the label for a group of docked magnifier options in the + # magnifier settings panel + dockedGroupText = _("Docked") + self.dockedGroupSizer = wx.StaticBoxSizer(wx.VERTICAL, self, label=dockedGroupText) + dockedGroupBox = self.dockedGroupSizer.GetStaticBox() + dockedGroup = guiHelper.BoxSizerHelper(self, sizer=self.dockedGroupSizer) + sHelper.addItem(dockedGroup) + + # TODO: Add docked magnifier specific options here + # Translators: Placeholder text for docked magnifier options + dockedPlaceholderText = _("Options for docked magnifier will be added here.") + dockedGroup.addItem(wx.StaticText(dockedGroupBox, label=dockedPlaceholderText)) + + # LENS MAGNIFIER GROUP + # Translators: This is the label for a group of lens magnifier options in the + # magnifier settings panel + lensGroupText = _("Lens") + self.lensGroupSizer = wx.StaticBoxSizer(wx.VERTICAL, self, label=lensGroupText) + lensGroupBox = self.lensGroupSizer.GetStaticBox() + lensGroup = guiHelper.BoxSizerHelper(self, sizer=self.lensGroupSizer) + sHelper.addItem(lensGroup) + + # TODO: Add lens magnifier specific options here + # Translators: Placeholder text for lens magnifier options + lensPlaceholderText = _("Options for lens magnifier will be added here.") + lensGroup.addItem(wx.StaticText(lensGroupBox, label=lensPlaceholderText)) + + # Initialize enabled state based on current selection + self._updateMagnifierGroupsState() + + def _onMagnifierTypeChange(self, evt): + """Update enabled state of magnifier type-specific groups when selection changes.""" + self._updateMagnifierGroupsState() + + def _updateMagnifierGroupsState(self): + """Enable/disable magnifier type-specific groups based on selected type.""" + selectedIdx = self.magnifierTypeList.GetSelection() + if selectedIdx == wx.NOT_FOUND: + return + + selectedType = list(MagnifierType)[selectedIdx] + + # Enable only the group corresponding to the selected magnifier type + self.fullscreenGroupSizer.GetStaticBox().Enable(selectedType == MagnifierType.FULLSCREEN) + self.fixedGroupSizer.GetStaticBox().Enable(selectedType == MagnifierType.FIXED) + self.dockedGroupSizer.GetStaticBox().Enable(selectedType == MagnifierType.DOCKED) + self.lensGroupSizer.GetStaticBox().Enable(selectedType == MagnifierType.LENS) def onSave(self): """Save the current selections to config.""" From bb4dff12b4e095deaf6de731c76975e29ceffa5a Mon Sep 17 00:00:00 2001 From: Antoine HAFFREINGUE Date: Wed, 4 Feb 2026 16:26:31 +0100 Subject: [PATCH 07/21] changed typing, wip --- source/_magnifier/fullscreenMagnifier.py | 8 ++++---- source/_magnifier/magnifier.py | 5 +++-- source/_magnifier/utils/types.py | 26 ++++++++++++------------ source/gui/settingsDialogs.py | 1 - 4 files changed, 20 insertions(+), 20 deletions(-) diff --git a/source/_magnifier/fullscreenMagnifier.py b/source/_magnifier/fullscreenMagnifier.py index 01e9304c947..caa7931c3c9 100644 --- a/source/_magnifier/fullscreenMagnifier.py +++ b/source/_magnifier/fullscreenMagnifier.py @@ -169,8 +169,8 @@ def moveMouseToScreen(self) -> None: keep mouse in screen """ params = self._getMagnifierParameters(self._currentCoordinates) - centerX = int(params.coordinates.x + (params.magnifierWidth / 2)) - centerY = int(params.coordinates.y + (params.magnifierHeight / 2)) + centerX = int(params.coordinates.x + (params.magnifierSize.width / 2)) + centerY = int(params.coordinates.y + (params.magnifierSize.height / 2)) winUser.setCursorPos(centerX, centerY) def _borderPos( @@ -187,8 +187,8 @@ def _borderPos( """ focusX, focusY = coordinates params = self._getMagnifierParameters(self._lastScreenPosition) - magnifierWidth = params.magnifierWidth - magnifierHeight = params.magnifierHeight + magnifierWidth = params.magnifierSize.width + magnifierHeight = params.magnifierSize.height lastLeft = params.coordinates.x lastTop = params.coordinates.y diff --git a/source/_magnifier/magnifier.py b/source/_magnifier/magnifier.py index fd1a428edab..aaf14106d34 100644 --- a/source/_magnifier/magnifier.py +++ b/source/_magnifier/magnifier.py @@ -19,6 +19,7 @@ from .utils.types import ( MagnifierParameters, Coordinates, + Size, MagnifierType, Direction, Filter, @@ -208,7 +209,7 @@ def _getMagnifierParameters(self, coordinates: Coordinates) -> MagnifierParamete :param coordinates: The (x, y) coordinates to center the magnifier on - :return: The size, position and styles (if any) of the magnifier window + :return: The size and position of the magnifier window """ x, y = coordinates # Calculate the size of the capture area at the current zoom level @@ -223,4 +224,4 @@ def _getMagnifierParameters(self, coordinates: Coordinates) -> MagnifierParamete left = max(0, min(left, int(self._displayOrientation.width - magnifierWidth))) top = max(0, min(top, int(self._displayOrientation.height - magnifierHeight))) - return MagnifierParameters(int(magnifierWidth), int(magnifierHeight), Coordinates(left, top), None) + return MagnifierParameters(Size(int(magnifierWidth), int(magnifierHeight)), Coordinates(left, top)) diff --git a/source/_magnifier/utils/types.py b/source/_magnifier/utils/types.py index 87ead080c9a..cafa433e7d2 100644 --- a/source/_magnifier/utils/types.py +++ b/source/_magnifier/utils/types.py @@ -7,19 +7,12 @@ Types used in the magnifier module. """ +from curses import window from enum import Enum, auto from typing import NamedTuple from utils.displayString import DisplayStringStrEnum, DisplayStringEnum -class MagnifierParams(NamedTuple): - """Named tuple representing magnifier parameters for initialization""" - - zoomLevel: float - filter: str - fullscreenMode: str - - class Direction(Enum): """Direction for zoom operations""" @@ -33,6 +26,11 @@ class Coordinates(NamedTuple): x: int y: int +class Size(NamedTuple): + """Named tuple representing width and height""" + + width: int + height: int class MagnifierAction(DisplayStringEnum): """Actions that can be performed with the magnifier""" @@ -91,18 +89,20 @@ class FocusType(Enum): SYSTEM_FOCUS = auto() NAVIGATOR = auto() - class MagnifierParameters(NamedTuple): + """Named tuple representing the size and position of the magnifier""" + magnifierSize: Size + coordinates: Coordinates + + +class WindowMagnifierParameters(NamedTuple): """ Named tuple representing the position and size of the magnifier window """ - - magnifierWidth: int - magnifierHeight: int + windowSize: Size coordinates: Coordinates styles: int - class ZoomHistory(NamedTuple): """Named tuple representing zoom history entry with zoom level and coordinates""" diff --git a/source/gui/settingsDialogs.py b/source/gui/settingsDialogs.py index d3f1f06d71d..4729a61af33 100644 --- a/source/gui/settingsDialogs.py +++ b/source/gui/settingsDialogs.py @@ -6014,7 +6014,6 @@ def makeSettings( # magnifier settings panel fullscreenGroupText = _("Fullscreen") self.fullscreenGroupSizer = wx.StaticBoxSizer(wx.VERTICAL, self, label=fullscreenGroupText) - fullscreenGroupBox = self.fullscreenGroupSizer.GetStaticBox() fullscreenGroup = guiHelper.BoxSizerHelper(self, sizer=self.fullscreenGroupSizer) sHelper.addItem(fullscreenGroup) From 090a247c8bd0318ce15e1efa741d92af64e4d692 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 4 Feb 2026 15:27:34 +0000 Subject: [PATCH 08/21] Pre-commit auto-fix --- source/_magnifier/utils/types.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/source/_magnifier/utils/types.py b/source/_magnifier/utils/types.py index cafa433e7d2..c46e92453f3 100644 --- a/source/_magnifier/utils/types.py +++ b/source/_magnifier/utils/types.py @@ -7,7 +7,6 @@ Types used in the magnifier module. """ -from curses import window from enum import Enum, auto from typing import NamedTuple from utils.displayString import DisplayStringStrEnum, DisplayStringEnum @@ -26,12 +25,14 @@ class Coordinates(NamedTuple): x: int y: int + class Size(NamedTuple): """Named tuple representing width and height""" width: int height: int + class MagnifierAction(DisplayStringEnum): """Actions that can be performed with the magnifier""" @@ -89,8 +90,10 @@ class FocusType(Enum): SYSTEM_FOCUS = auto() NAVIGATOR = auto() + class MagnifierParameters(NamedTuple): """Named tuple representing the size and position of the magnifier""" + magnifierSize: Size coordinates: Coordinates @@ -99,10 +102,12 @@ class WindowMagnifierParameters(NamedTuple): """ Named tuple representing the position and size of the magnifier window """ + windowSize: Size coordinates: Coordinates styles: int + class ZoomHistory(NamedTuple): """Named tuple representing zoom history entry with zoom level and coordinates""" From 50e45b7f700830f9718a20b163b2bb2a5aecafbf Mon Sep 17 00:00:00 2001 From: Antoine HAFFREINGUE Date: Wed, 4 Feb 2026 16:50:30 +0100 Subject: [PATCH 09/21] fix tests --- source/_magnifier/utils/types.py | 1 - tests/unit/test_magnifier/test_fullscreenMagnifier.py | 8 ++++---- tests/unit/test_magnifier/test_magnifier.py | 10 +++++----- 3 files changed, 9 insertions(+), 10 deletions(-) diff --git a/source/_magnifier/utils/types.py b/source/_magnifier/utils/types.py index cafa433e7d2..9d260918966 100644 --- a/source/_magnifier/utils/types.py +++ b/source/_magnifier/utils/types.py @@ -7,7 +7,6 @@ Types used in the magnifier module. """ -from curses import window from enum import Enum, auto from typing import NamedTuple from utils.displayString import DisplayStringStrEnum, DisplayStringEnum diff --git a/tests/unit/test_magnifier/test_fullscreenMagnifier.py b/tests/unit/test_magnifier/test_fullscreenMagnifier.py index a375534ce4c..1ee16b534d1 100644 --- a/tests/unit/test_magnifier/test_fullscreenMagnifier.py +++ b/tests/unit/test_magnifier/test_fullscreenMagnifier.py @@ -113,15 +113,15 @@ def testMagnifierPositionCalculation(self): # Basic checks self.assertIsInstance(params.coordinates.x, int) self.assertIsInstance(params.coordinates.y, int) - self.assertIsInstance(params.magnifierWidth, int) - self.assertIsInstance(params.magnifierHeight, 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(params.magnifierWidth, expectedWidth) - self.assertEqual(params.magnifierHeight, expectedHeight) + self.assertEqual(params.magnifierSize.width, expectedWidth) + self.assertEqual(params.magnifierSize.height, expectedHeight) # Cleanup magnifier._stopMagnifier() diff --git a/tests/unit/test_magnifier/test_magnifier.py b/tests/unit/test_magnifier/test_magnifier.py index 99d43528665..c6eeac83dce 100644 --- a/tests/unit/test_magnifier/test_magnifier.py +++ b/tests/unit/test_magnifier/test_magnifier.py @@ -219,8 +219,8 @@ def testMagnifierPosition(self): self.assertEqual(params.coordinates.x, expected_left) self.assertEqual(params.coordinates.y, expected_top) - self.assertEqual(params.magnifierWidth, expected_width) - self.assertEqual(params.magnifierHeight, expected_height) + self.assertEqual(params.magnifierSize.width, expected_width) + self.assertEqual(params.magnifierSize.height, expected_height) # Test left clamping params = self.magnifier._getMagnifierParameters((100, 540)) @@ -228,12 +228,12 @@ def testMagnifierPosition(self): # Test right clamping params = self.magnifier._getMagnifierParameters((1800, 540)) - self.assertLessEqual(params.coordinates.x + params.magnifierWidth, self.screenWidth) + self.assertLessEqual(params.coordinates.x + params.magnifierSize.width, self.screenWidth) # Test different zoom level self.magnifier.zoomLevel = 4.0 params = self.magnifier._getMagnifierParameters((960, 540)) expected_width = int(self.screenWidth / self.magnifier.zoomLevel) expected_height = int(self.screenHeight / self.magnifier.zoomLevel) - self.assertEqual(params.magnifierWidth, expected_width) - self.assertEqual(params.magnifierHeight, expected_height) + self.assertEqual(params.magnifierSize.width, expected_width) + self.assertEqual(params.magnifierSize.height, expected_height) From 53d90f127c78ed8ad4ab0b4f1509ead3c91ee23f Mon Sep 17 00:00:00 2001 From: Antoine HAFFREINGUE Date: Tue, 10 Feb 2026 17:15:34 +0100 Subject: [PATCH 10/21] added filter and better window creation handling --- source/_magnifier/__init__.py | 12 +- source/_magnifier/dockedMagnifier.py | 2 +- source/_magnifier/fixedMagnifier.py | 69 +++++-- source/_magnifier/fullscreenMagnifier.py | 9 +- source/_magnifier/lensMagnifier.py | 2 +- source/_magnifier/magnifier.py | 15 +- source/_magnifier/utils/types.py | 38 ++-- source/_magnifier/utils/windowCreator.py | 180 +++++++++++++----- .../test_fullscreenMagnifier.py | 7 +- tests/unit/test_magnifier/test_magnifier.py | 22 ++- 10 files changed, 245 insertions(+), 111 deletions(-) diff --git a/source/_magnifier/__init__.py b/source/_magnifier/__init__.py index 7fb7049780f..ecfd85ec238 100644 --- a/source/_magnifier/__init__.py +++ b/source/_magnifier/__init__.py @@ -12,10 +12,6 @@ from .config import getDefaultMagnifierType from .utils.types import MagnifierType -from .fullscreenMagnifier import FullScreenMagnifier -from .fixedMagnifier import FixedMagnifier -from .dockedMagnifier import DockedMagnifier -from .lensMagnifier import LensMagnifier if TYPE_CHECKING: from .magnifier import Magnifier @@ -33,12 +29,20 @@ def createMagnifier(magnifierType: MagnifierType) -> "Magnifier": """ match magnifierType: case MagnifierType.FULLSCREEN: + from .fullscreenMagnifier import FullScreenMagnifier + return FullScreenMagnifier() case MagnifierType.FIXED: + from .fixedMagnifier import FixedMagnifier + return FixedMagnifier() case MagnifierType.DOCKED: + from .dockedMagnifier import DockedMagnifier + return DockedMagnifier() case MagnifierType.LENS: + from .lensMagnifier import LensMagnifier + return LensMagnifier() case _: raise ValueError(f"Unsupported magnifier type: {magnifierType}") diff --git a/source/_magnifier/dockedMagnifier.py b/source/_magnifier/dockedMagnifier.py index 98a5e56705a..6e680cad95f 100644 --- a/source/_magnifier/dockedMagnifier.py +++ b/source/_magnifier/dockedMagnifier.py @@ -24,4 +24,4 @@ def _stopMagnifier(self) -> None: super()._stopMagnifier() def _doUpdate(self): - return super()._doUpdate() + super()._doUpdate() diff --git a/source/_magnifier/fixedMagnifier.py b/source/_magnifier/fixedMagnifier.py index 4f66f163245..e6ae8830479 100644 --- a/source/_magnifier/fixedMagnifier.py +++ b/source/_magnifier/fixedMagnifier.py @@ -7,39 +7,66 @@ Fixed magnifier module. """ +from logHandler import log from .magnifier import Magnifier -from .utils.types import Coordinates, MagnifierType, MagnifierParameters -from .utils.windowCreator import WindowCreator +from .utils.types import ( + Coordinates, + Size, + MagnifierType, + WindowMagnifierParameters, + Filter, +) +from .utils.windowCreator import WindowedMagnifier import wx -class FixedMagnifier(Magnifier): +class FixedMagnifier(Magnifier, WindowedMagnifier): def __init__(self): - super().__init__() + windowParameters = WindowMagnifierParameters( + title="NVDA Fixed Magnifier", + windowSize=Size(300, 300), + windowPosition=Coordinates(0, 0), + styles=wx.FRAME_NO_TASKBAR | wx.STAY_ON_TOP, + ) + Magnifier.__init__(self) + WindowedMagnifier.__init__(self, windowParameters) self._magnifierType = MagnifierType.FIXED self._currentCoordinates = Coordinates(0, 0) - self._window: None | wx.Frame = None - self.params = MagnifierParameters( - magnifierWidth=300, - magnifierHeight=300, - coordinates=(0, 0), - styles=wx.STAY_ON_TOP | wx.FRAME_NO_TASKBAR, - ) + self._windowParameters = windowParameters + + @property + def filterType(self) -> Filter: + return self._filterType + + @filterType.setter + def filterType(self, value: Filter) -> None: + self._filterType = value + if self._isActive: + self._applyFilter() + + def event_gainFocus( + self, + obj, + nextHandler, + ): + log.debug("Full-screen Magnifier gain focus event") + nextHandler() def _startMagnifier(self) -> None: + """ + Start the Fixed magnifier by creating a window and starting the update timer. + """ super()._startMagnifier() - self._window = WindowCreator.createMagnifierWindow( - parent=None, - title="Fixed Magnifier", - frameType="fixedMagnifier", - screenSize=self._displayOrientation, - magnifierParameters=self.params, + self._startTimer(self._updateMagnifier) + log.debug( + f"Starting fixed magnifier position:{self._windowParameters.windowPosition} size:{self._windowParameters.windowSize}\n with zoom level {self.zoomLevel} and filter {self.filterType}", ) + def _doUpdate(self): + params = self._getMagnifierParameters(self._currentCoordinates, self._windowParameters.windowSize) + super()._setContent(params, self.zoomLevel) + def _stopMagnifier(self) -> None: - self._window.Destroy() + super()._destroyWindow() super()._stopMagnifier() - - def _doUpdate(self): - self._window.Refresh() diff --git a/source/_magnifier/fullscreenMagnifier.py b/source/_magnifier/fullscreenMagnifier.py index caa7931c3c9..f1f464e7cd9 100644 --- a/source/_magnifier/fullscreenMagnifier.py +++ b/source/_magnifier/fullscreenMagnifier.py @@ -14,7 +14,7 @@ from .magnifier import Magnifier from .utils.filterHandler import FilterMatrix from .utils.spotlightManager import SpotlightManager -from .utils.types import Filter, Coordinates, MagnifierType, FullScreenMode, FocusType +from .utils.types import Filter, Coordinates, MagnifierType, FullScreenMode, FocusType, Size from .config import getDefaultFullscreenMode, shouldKeepMouseCentered @@ -25,6 +25,7 @@ def __init__(self): self._fullscreenMode = getDefaultFullscreenMode() self._currentCoordinates = Coordinates(0, 0) self._spotlightManager = SpotlightManager(self) + self._displaySize = Size(self._displayOrientation.width, self._displayOrientation.height) @property def filterType(self) -> Filter: @@ -133,7 +134,7 @@ def _fullscreenMagnifier(self, coordinates: Coordinates) -> None: if not self._isActive: return - params = self._getMagnifierParameters(coordinates) + params = self._getMagnifierParameters(coordinates, self._displaySize) try: result = magnification.MagSetFullscreenTransform( self.zoomLevel, @@ -168,7 +169,7 @@ def moveMouseToScreen(self) -> None: """ keep mouse in screen """ - params = self._getMagnifierParameters(self._currentCoordinates) + params = self._getMagnifierParameters(self._currentCoordinates, self._displaySize) centerX = int(params.coordinates.x + (params.magnifierSize.width / 2)) centerY = int(params.coordinates.y + (params.magnifierSize.height / 2)) winUser.setCursorPos(centerX, centerY) @@ -186,7 +187,7 @@ def _borderPos( :return: The adjusted position (x, y) of the focus point """ focusX, focusY = coordinates - params = self._getMagnifierParameters(self._lastScreenPosition) + params = self._getMagnifierParameters(self._lastScreenPosition, self._displaySize) magnifierWidth = params.magnifierSize.width magnifierHeight = params.magnifierSize.height lastLeft = params.coordinates.x diff --git a/source/_magnifier/lensMagnifier.py b/source/_magnifier/lensMagnifier.py index 11aa1533b49..4ea529b6e44 100644 --- a/source/_magnifier/lensMagnifier.py +++ b/source/_magnifier/lensMagnifier.py @@ -24,4 +24,4 @@ def _stopMagnifier(self) -> None: super()._stopMagnifier() def _doUpdate(self): - return super()._doUpdate() + super()._doUpdate() diff --git a/source/_magnifier/magnifier.py b/source/_magnifier/magnifier.py index aaf14106d34..dd7bca50b99 100644 --- a/source/_magnifier/magnifier.py +++ b/source/_magnifier/magnifier.py @@ -203,25 +203,30 @@ def _stopTimer(self) -> None: else: log.debug("no timer to stop") - def _getMagnifierParameters(self, coordinates: Coordinates) -> MagnifierParameters: + def _getMagnifierParameters(self, coordinates: Coordinates, displaySize: Size) -> 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 + :param displaySize: The size of the display area (width, height) - used to calculate capture size :return: The size and position 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 + magnifierWidth = displaySize.width / self.zoomLevel + magnifierHeight = displaySize.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 + # Clamp to screen boundaries (always use actual screen size, not display area size) 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)) + return MagnifierParameters( + Size(int(magnifierWidth), int(magnifierHeight)), + Coordinates(left, top), + self._filterType, + ) diff --git a/source/_magnifier/utils/types.py b/source/_magnifier/utils/types.py index c46e92453f3..7b61831bed4 100644 --- a/source/_magnifier/utils/types.py +++ b/source/_magnifier/utils/types.py @@ -83,6 +83,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""" @@ -96,6 +113,7 @@ class MagnifierParameters(NamedTuple): magnifierSize: Size coordinates: Coordinates + filter: Filter class WindowMagnifierParameters(NamedTuple): @@ -103,8 +121,9 @@ class WindowMagnifierParameters(NamedTuple): Named tuple representing the position and size of the magnifier window """ + title: str windowSize: Size - coordinates: Coordinates + windowPosition: Coordinates styles: int @@ -130,20 +149,3 @@ def _displayStringLabels(self) -> dict["FullScreenMode", str]: # Translators: Magnifier focus mode - maintain relative position. self.RELATIVE: pgettext("magnifier", "Relative"), } - - -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"), - } diff --git a/source/_magnifier/utils/windowCreator.py b/source/_magnifier/utils/windowCreator.py index 4c244c442d6..c9d06f0c907 100644 --- a/source/_magnifier/utils/windowCreator.py +++ b/source/_magnifier/utils/windowCreator.py @@ -5,9 +5,9 @@ from logHandler import log import wx +import array -from .types import MagnifierParameters -from winAPI._displayTracking import OrientationState +from .types import MagnifierParameters, WindowMagnifierParameters, Size, Filter class MagnifierPanel(wx.Panel): @@ -66,19 +66,19 @@ def __init__( parent=None, title: str = "Magnifier Window", frameType: str = "magnifier", - screenSize: OrientationState = None, - magnifierParameters: MagnifierParameters = None, + screenSize: Size = None, + windowMagnifierParameters: WindowMagnifierParameters = None, ): self.frameType = frameType self.screenSize = screenSize - self.magnifierParameters = magnifierParameters + self.windowMagnifierParameters = windowMagnifierParameters super().__init__( parent, title=title, - size=(magnifierParameters.magnifierWidth, magnifierParameters.magnifierHeight), + size=(windowMagnifierParameters.windowSize.width, windowMagnifierParameters.windowSize.height), ) - self.SetWindowStyle(self.magnifierParameters.styles) - self.SetPosition(self.magnifierParameters.coordinates) + self.SetWindowStyle(self.windowMagnifierParameters.styles) + self.SetPosition(self.windowMagnifierParameters.windowPosition) self.panel = self.createPanel() self.Show() @@ -100,53 +100,133 @@ def updateFrameContent(self, content) -> None: self.panel.setContent(content) -class WindowCreator: - """A factory class to create magnifier windows.""" - - @staticmethod - def createMagnifierWindow( - parent: wx.Frame, - title: str, - frameType: str, - screenSize: OrientationState, - magnifierParameters: MagnifierParameters, - ) -> MagnifierFrame: - """ - Create and return a magnifier window. +class WindowedMagnifier: + """ + Base class for magnifiers that use a separate window to display magnified content. + Provides common functionality for creating and managing the magnifier window and panel. + """ - :param parent: The parent wx.Frame - :param title: The title of the window - :param frameType: The type of the frame (e.g., "magnifier", "spotlight") - :param screenSize: The size of the screen - :param magnifierParameters: The parameters for the magnifier + def __init__(self, windowMagnifierParameters: WindowMagnifierParameters): + self.windowMagnifierParameters = windowMagnifierParameters + self._frame: None | MagnifierFrame = None + self._panel: None | MagnifierPanel = None + self._setupWindow() - :return: An instance of MagnifierFrame + def _setupWindow(self): """ - window = MagnifierFrame( - parent=parent, - title=title, - frameType=frameType, - screenSize=screenSize, - magnifierParameters=magnifierParameters, + Create the magnifier window and panel based on the provided parameters. + """ + self._frame = MagnifierFrame( + title=self.windowMagnifierParameters.title, + frameType="magnifier", + screenSize=self.windowMagnifierParameters.windowSize, + windowMagnifierParameters=self.windowMagnifierParameters, ) - window.setupLayout() - return window + self._panel = self._frame.panel + def _applyColorFilter(self, image: wx.Image, filterType: Filter) -> wx.Image: + """ + Apply color filter with array optimization for better performance. -def getContent(magnifierParameters: MagnifierParameters) -> wx.Image: - """ - Placeholder function to get the content for the magnifier. - In a real implementation, this would capture the screen area defined by magnifierParameters. + :param image: The image to apply the filter to + :param filterType: The filter type to apply (NORMAL, GRAYSCALE, INVERTED) + :return: The filtered image + """ + if filterType == Filter.NORMAL or not image or not image.IsOk(): + return image + + try: + # Get raw image data as bytes + width, height = image.GetWidth(), image.GetHeight() + data = image.GetData() # Returns RGB data as bytes + + # Convert to array for faster manipulation + rgb_array = array.array("B", data) # 'B' = unsigned char (0-255) + + if filterType == Filter.GRAYSCALE: + # Process 3 bytes at a time (R, G, B) + for i in range(0, len(rgb_array), 3): + r, g, b = rgb_array[i], rgb_array[i + 1], rgb_array[i + 2] + # Standard grayscale formula + gray = int(0.299 * r + 0.587 * g + 0.114 * b) + rgb_array[i] = rgb_array[i + 1] = rgb_array[i + 2] = gray + + elif filterType == Filter.INVERTED: + # Invert all values + for i in range(len(rgb_array)): + rgb_array[i] = 255 - rgb_array[i] + + # Create new image with modified data + new_image = wx.Image(width, height) + new_image.SetData(rgb_array.tobytes()) + return new_image + + except Exception as e: + log.error(f"Error applying color filter: {e}") + return image + + def _setContent(self, magnifierParameters: MagnifierParameters, zoomLevel: float): + content = self._getContent(magnifierParameters, zoomLevel) + if self._panel: + self._panel.setContent(content) + else: + log.debug("No panel available to set content") + + def _destroyWindow(self): + if self._frame: + self._frame.Destroy() + self._frame = None + self._panel = None + + def _getContent(self, magnifierParameters: MagnifierParameters, zoomLevel: float) -> wx.Bitmap | None: + """ + Capture the screen area defined by magnifierParameters and return it as a scaled bitmap. - :param magnifierParameters: The parameters defining the area to capture + :param magnifierParameters: The parameters defining the area to capture + :param zoomLevel: The zoom level to apply to the captured content + :return: A wx.Bitmap scaled to fill the panel, or None if capture fails + """ + if not self._panel: + log.warning("No panel available for capture") + return None + + panelSize = self._panel.GetSize() + panelWidth, panelHeight = panelSize.width, panelSize.height + + # Calculate the size of the area to capture based on zoom level + captureWidth = panelWidth / zoomLevel + captureHeight = panelHeight / zoomLevel + captureLeft = int(magnifierParameters.coordinates.x) + captureTop = int(magnifierParameters.coordinates.y) + + # Capture screen + screen = wx.ScreenDC() + bitmap = wx.Bitmap(int(captureWidth), int(captureHeight)) + memoryDc = wx.MemoryDC() + memoryDc.SelectObject(bitmap) + success = memoryDc.Blit(0, 0, int(captureWidth), int(captureHeight), screen, captureLeft, captureTop) + memoryDc.SelectObject(wx.NullBitmap) + + log.info( + f"Capture at ({captureLeft}, {captureTop}) " + f"size {int(captureWidth)}x{int(captureHeight)} " + f"zoom {zoomLevel}x -> panel {panelWidth}x{panelHeight}", + ) - :return: A wx.Image representing the captured content - """ - # Placeholder implementation - width = magnifierParameters.magnifierWidth - height = magnifierParameters.magnifierHeight - x = magnifierParameters.coordinates.x - y = magnifierParameters.coordinates.y - image = wx.Image(width, height) - image.SetRGBRect(wx.Rect(x, y, width, height), 200, 200, 200) - return image + if success and bitmap.IsOk(): + # Convert to image + image = bitmap.ConvertToImage() + + # Apply color filter if we have access to filterType + if hasattr(self, "_filterType"): + image = self._applyColorFilter(image, magnifierParameters.filter) + + # Scale image to fill the entire panel (this applies the zoom magnification) + magnifiedImage = image.Scale(panelWidth, panelHeight, wx.IMAGE_QUALITY_BICUBIC) + magnifiedBitmap = wx.Bitmap(magnifiedImage) + return magnifiedBitmap + else: + log.error( + f"Screen capture failed at ({captureLeft}, {captureTop}) size {int(captureWidth)}x{int(captureHeight)}", + ) + return None diff --git a/tests/unit/test_magnifier/test_fullscreenMagnifier.py b/tests/unit/test_magnifier/test_fullscreenMagnifier.py index 1ee16b534d1..cfe381090e9 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 -from _magnifier.utils.types import Filter, FullScreenMode, MagnifierType, Direction +from _magnifier.utils.types import Filter, FullScreenMode, MagnifierType, Direction, Size from _magnifier.fullscreenMagnifier import FullScreenMagnifier from tests.unit.test_magnifier.test_magnifier import _TestMagnifier from _magnifier.magnifier import Magnifier @@ -108,7 +108,10 @@ def testMagnifierPositionCalculation(self): magnifier = FullScreenMagnifier() # Test position calculation - params = magnifier._getMagnifierParameters((500, 400)) + params = magnifier._getMagnifierParameters( + (500, 400), + Size(magnifier._displayOrientation.width, magnifier._displayOrientation.height), + ) # Basic checks self.assertIsInstance(params.coordinates.x, int) diff --git a/tests/unit/test_magnifier/test_magnifier.py b/tests/unit/test_magnifier/test_magnifier.py index c6eeac83dce..45f953701ac 100644 --- a/tests/unit/test_magnifier/test_magnifier.py +++ b/tests/unit/test_magnifier/test_magnifier.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 _magnifier.magnifier import Magnifier -from _magnifier.utils.types import Coordinates, Filter, Direction +from _magnifier.utils.types import Coordinates, Filter, Direction, Size import unittest from winAPI._displayTracking import getPrimaryDisplayOrientation @@ -210,7 +210,10 @@ def testStopTimer(self): def testMagnifierPosition(self): """Computing magnifier position and size.""" x, y = int(self.screenWidth / 2), int(self.screenHeight / 2) - params = self.magnifier._getMagnifierParameters((x, y)) + params = self.magnifier._getMagnifierParameters( + (x, y), + Size(self.magnifier._displayOrientation.width, self.magnifier._displayOrientation.height), + ) expected_width = int(self.screenWidth / self.magnifier.zoomLevel) expected_height = int(self.screenHeight / self.magnifier.zoomLevel) @@ -223,16 +226,25 @@ def testMagnifierPosition(self): self.assertEqual(params.magnifierSize.height, expected_height) # Test left clamping - params = self.magnifier._getMagnifierParameters((100, 540)) + params = self.magnifier._getMagnifierParameters( + (100, 540), + Size(self.magnifier._displayOrientation.width, self.magnifier._displayOrientation.height), + ) self.assertGreaterEqual(params.coordinates.x, 0) # Test right clamping - params = self.magnifier._getMagnifierParameters((1800, 540)) + params = self.magnifier._getMagnifierParameters( + (1800, 540), + Size(self.magnifier._displayOrientation.width, self.magnifier._displayOrientation.height), + ) self.assertLessEqual(params.coordinates.x + params.magnifierSize.width, self.screenWidth) # Test different zoom level self.magnifier.zoomLevel = 4.0 - params = self.magnifier._getMagnifierParameters((960, 540)) + params = self.magnifier._getMagnifierParameters( + (960, 540), + Size(self.magnifier._displayOrientation.width, self.magnifier._displayOrientation.height), + ) expected_width = int(self.screenWidth / self.magnifier.zoomLevel) expected_height = int(self.screenHeight / self.magnifier.zoomLevel) self.assertEqual(params.magnifierSize.width, expected_width) From 8e87f5d4131730e50a3bbf1f91793a2e1d5b985f Mon Sep 17 00:00:00 2001 From: Antoine HAFFREINGUE Date: Wed, 11 Feb 2026 14:10:21 +0100 Subject: [PATCH 11/21] adding settings --- source/_magnifier/config.py | 56 +++++++++++++++++++++++++- source/_magnifier/fixedMagnifier.py | 42 +++++++++++++++++--- source/_magnifier/utils/types.py | 22 +++++++++++ source/config/configSpec.py | 3 ++ source/gui/settingsDialogs.py | 61 ++++++++++++++++++++++++++--- 5 files changed, 171 insertions(+), 13 deletions(-) diff --git a/source/_magnifier/config.py b/source/_magnifier/config.py index d42e8445186..feed29743d8 100644 --- a/source/_magnifier/config.py +++ b/source/_magnifier/config.py @@ -9,7 +9,7 @@ """ import config -from .utils.types import Filter, FullScreenMode, MagnifierType +from .utils.types import Filter, FullScreenMode, MagnifierType, FixedWindowPosition class ZoomLevel: @@ -131,6 +131,60 @@ def setDefaultFullscreenMode(mode: FullScreenMode) -> None: config.conf["magnifier"]["defaultFullscreenMode"] = mode.value +def getDefaultFixedWindowWidth() -> int: + """ + Get default fixed magnifier window width from config. + + :return: The default fixed magnifier window width. + """ + return config.conf["magnifier"]["defaultFixedWindowWidth"] + + +def setDefaultFixedWindowWidth(width: int) -> None: + """ + Set default fixed magnifier window width from settings. + + :param width: The fixed magnifier window width to set. + """ + config.conf["magnifier"]["defaultFixedWindowWidth"] = width + + +def getDefaultFixedWindowHeight() -> int: + """ + Get default fixed magnifier window height from config. + + :return: The default fixed magnifier window height. + """ + return config.conf["magnifier"]["defaultFixedWindowHeight"] + + +def setDefaultFixedWindowHeight(height: int) -> None: + """ + Set default fixed magnifier window height from settings. + + :param height: The fixed magnifier window height to set. + """ + config.conf["magnifier"]["defaultFixedWindowHeight"] = height + + +def getDefaultFixedWindowPosition() -> FixedWindowPosition: + """ + Get default magnifier window position from config. + + :return: The default magnifier window position. + """ + return FixedWindowPosition(config.conf["magnifier"]["defaultFixedWindowPosition"]) + + +def setDefaultFixedWindowPosition(position: FixedWindowPosition) -> None: + """ + Set default magnifier window position from settings. + + :param position: The magnifier window position to set. + """ + config.conf["magnifier"]["defaultFixedWindowPosition"] = position.value + + def shouldKeepMouseCentered() -> bool: """ Check if mouse pointer should be kept centered in magnifier view. diff --git a/source/_magnifier/fixedMagnifier.py b/source/_magnifier/fixedMagnifier.py index e6ae8830479..500e526d371 100644 --- a/source/_magnifier/fixedMagnifier.py +++ b/source/_magnifier/fixedMagnifier.py @@ -15,21 +15,18 @@ MagnifierType, WindowMagnifierParameters, Filter, + FixedWindowPosition, ) from .utils.windowCreator import WindowedMagnifier +from .config import getDefaultFixedWindowWidth, getDefaultFixedWindowHeight, getDefaultFixedWindowPosition import wx class FixedMagnifier(Magnifier, WindowedMagnifier): def __init__(self): - windowParameters = WindowMagnifierParameters( - title="NVDA Fixed Magnifier", - windowSize=Size(300, 300), - windowPosition=Coordinates(0, 0), - styles=wx.FRAME_NO_TASKBAR | wx.STAY_ON_TOP, - ) Magnifier.__init__(self) + windowParameters = self._getWindowParameters() WindowedMagnifier.__init__(self, windowParameters) self._magnifierType = MagnifierType.FIXED self._currentCoordinates = Coordinates(0, 0) @@ -70,3 +67,36 @@ def _doUpdate(self): def _stopMagnifier(self) -> None: super()._destroyWindow() super()._stopMagnifier() + + def _getWindowParameters(self) -> WindowMagnifierParameters: + """ + Get the parameters for the magnifier window from configuration. + + :return: The parameters for the magnifier window + """ + case = getDefaultFixedWindowPosition() + windowSize = Size(getDefaultFixedWindowWidth(), getDefaultFixedWindowHeight()) + displaySize = Size(self._displayOrientation.width, self._displayOrientation.height) + log.info( + f"Getting window parameters for fixed magnifier with position {case}, window size {windowSize}", + ) + + match case: + case FixedWindowPosition.TOP_LEFT: + position = Coordinates(0, 0) + case FixedWindowPosition.TOP_RIGHT: + position = Coordinates(displaySize.width - windowSize.width, 0) + case FixedWindowPosition.BOTTOM_LEFT: + position = Coordinates(0, displaySize.height - windowSize.height) + case FixedWindowPosition.BOTTOM_RIGHT: + position = Coordinates( + displaySize.width - windowSize.width, + displaySize.height - windowSize.height, + ) + + return WindowMagnifierParameters( + title="NVDA Fixed Magnifier", + windowSize=windowSize, + windowPosition=position, + styles=wx.FRAME_NO_TASKBAR | wx.STAY_ON_TOP, + ) diff --git a/source/_magnifier/utils/types.py b/source/_magnifier/utils/types.py index 7b61831bed4..3eb57f535c8 100644 --- a/source/_magnifier/utils/types.py +++ b/source/_magnifier/utils/types.py @@ -149,3 +149,25 @@ def _displayStringLabels(self) -> dict["FullScreenMode", str]: # Translators: Magnifier focus mode - maintain relative position. self.RELATIVE: pgettext("magnifier", "Relative"), } + + +class FixedWindowPosition(DisplayStringStrEnum): + """Position of the magnifier window""" + + TOP_LEFT = "topLeft" + TOP_RIGHT = "topRight" + BOTTOM_LEFT = "bottomLeft" + BOTTOM_RIGHT = "bottomRight" + + @property + def _displayStringLabels(self) -> dict["FixedWindowPosition", str]: + return { + # Translators: Position of the magnifier window - top left corner of the screen. + self.TOP_LEFT: pgettext("magnifier window position", "Top Left"), + # Translators: Position of the magnifier window - top right corner of the screen. + self.TOP_RIGHT: pgettext("magnifier window position", "Top Right"), + # Translators: Position of the magnifier window - bottom left corner of the screen. + self.BOTTOM_LEFT: pgettext("magnifier window position", "Bottom Left"), + # Translators: Position of the magnifier window - bottom right corner of the screen. + self.BOTTOM_RIGHT: pgettext("magnifier window position", "Bottom Right"), + } diff --git a/source/config/configSpec.py b/source/config/configSpec.py index 6582b03d3f5..2666ea9fc35 100644 --- a/source/config/configSpec.py +++ b/source/config/configSpec.py @@ -117,6 +117,9 @@ defaultFilter = string(default="normal") defaultMagnifierType = string(default="fullscreen") defaultFullscreenMode = string(default="center") + defaultFixedWindowWidth = integer(default=200, min=50, max=1000) + defaultFixedWindowHeight = integer(default=200, min=50, max=1000) + defaultFixedWindowPosition = string(default="topLeft") keepMouseCentered = boolean(default=false) diff --git a/source/gui/settingsDialogs.py b/source/gui/settingsDialogs.py index 969c1286138..374e5e776b4 100644 --- a/source/gui/settingsDialogs.py +++ b/source/gui/settingsDialogs.py @@ -43,7 +43,7 @@ import languageHandler import logHandler import _magnifier.config as magnifierConfig -from _magnifier.utils.types import Filter, FullScreenMode, MagnifierType +from _magnifier.utils.types import Filter, FullScreenMode, MagnifierType, FixedWindowPosition import queueHandler import requests import speech @@ -6036,14 +6036,57 @@ def makeSettings( # magnifier settings panel fixedGroupText = _("Fixed") self.fixedGroupSizer = wx.StaticBoxSizer(wx.VERTICAL, self, label=fixedGroupText) - fixedGroupBox = self.fixedGroupSizer.GetStaticBox() fixedGroup = guiHelper.BoxSizerHelper(self, sizer=self.fixedGroupSizer) sHelper.addItem(fixedGroup) - # TODO: Add fixed magnifier specific options here - # Translators: Placeholder text for fixed magnifier options - fixedPlaceholderText = _("Options for fixed magnifier will be added here.") - fixedGroup.addItem(wx.StaticText(fixedGroupBox, label=fixedPlaceholderText)) + # Translators: The label for a setting in magnifier settings to select the default fixed magnifier window width in pixels. + # Window width settings + defaultFixedWindowWidthLabelText = _("Default fixed magnifier &window width (pixels):") + self.defaultFixedWindowWidthEdit = fixedGroup.addLabeledControl( + defaultFixedWindowWidthLabelText, + nvdaControls.SelectOnFocusSpinCtrl, + value=str(magnifierConfig.getDefaultFixedWindowWidth()), + min=50, + max=1000, + ) + self.bindHelpEvent( + "magnifierDefaultFixedWindowWidth", + self.defaultFixedWindowWidthEdit, + ) + + # Translators: The label for a setting in magnifier settings to select the default fixed magnifier window height in pixels. + # Window height settings + defaultFixedWindowHeightLabelText = _("Default fixed magnifier &window height (pixels):") + self.defaultFixedWindowHeightEdit = fixedGroup.addLabeledControl( + defaultFixedWindowHeightLabelText, + nvdaControls.SelectOnFocusSpinCtrl, + value=str(magnifierConfig.getDefaultFixedWindowHeight()), + min=50, + max=1000, + ) + self.bindHelpEvent( + "magnifierDefaultFixedWindowHeight", + self.defaultFixedWindowHeightEdit, + ) + + # Translators: The label for a setting in magnifier settings to select the default fixed magnifier window position. + # Window position settings + defaultFixedWindowPositionLabelText = _("Default fixed magnifier &window position:") + fixedWindowPositionChoices = [pos.displayString for pos in FixedWindowPosition] + self.defaultFixedWindowPositionList = fixedGroup.addLabeledControl( + defaultFixedWindowPositionLabelText, + wx.Choice, + choices=fixedWindowPositionChoices, + ) + self.bindHelpEvent( + "magnifierDefaultFixedWindowPosition", + self.defaultFixedWindowPositionList, + ) + + defaultFixedWindowPosition = magnifierConfig.getDefaultFixedWindowPosition() + self.defaultFixedWindowPositionList.SetSelection( + list(FixedWindowPosition).index(defaultFixedWindowPosition), + ) # DOCKED MAGNIFIER GROUP # Translators: This is the label for a group of docked magnifier options in the @@ -6108,6 +6151,12 @@ def onSave(self): selectedModeIdx = self.defaultFullscreenModeList.GetSelection() magnifierConfig.setDefaultFullscreenMode(list(FullScreenMode)[selectedModeIdx]) + magnifierConfig.setDefaultFixedWindowWidth(self.defaultFixedWindowWidthEdit.GetValue()) + magnifierConfig.setDefaultFixedWindowHeight(self.defaultFixedWindowHeightEdit.GetValue()) + + selectedPositionIdx = self.defaultFixedWindowPositionList.GetSelection() + magnifierConfig.setDefaultFixedWindowPosition(list(FixedWindowPosition)[selectedPositionIdx]) + config.conf["magnifier"]["keepMouseCentered"] = self.keepMouseCenteredCheckBox.GetValue() From a08a6db0b1803ae65a94075db394194e58b1debc Mon Sep 17 00:00:00 2001 From: Antoine HAFFREINGUE Date: Wed, 11 Feb 2026 14:29:57 +0100 Subject: [PATCH 12/21] added userguide for fixedWindow and updated fullscreen --- user_docs/en/userGuide.md | 39 +++++++++++++++++++++++++++++++++------ 1 file changed, 33 insertions(+), 6 deletions(-) diff --git a/user_docs/en/userGuide.md b/user_docs/en/userGuide.md index edfc2d2e723..078143c07d3 100644 --- a/user_docs/en/userGuide.md +++ b/user_docs/en/userGuide.md @@ -1564,9 +1564,17 @@ NVDA will announce the name of the currently selected filter. The default color filter when the magnifier is first enabled can be configured in the [Magnifier settings](#MagnifierSettings). -### Focus Tracking Modes {#MagnifierFocusModes} +### Magnifier Modes {#MagnifierModes} -The magnifier offers three different modes for tracking focus and determining which part of the screen to magnify: +The magnfier can be used in multiple modes, each designed to suit different user needs and preferences: + +* **Full-screen mode**: The entire screen is magnified, and the magnified view follows the system focus or mouse pointer. This mode provides multiple type of focus mode. + +* **Fixed window mode**: A separate window displays the magnified content, and the rest of the screen remains at normal size. This allows you to see both the magnified content and the surrounding context simultaneously. + +### Focus Fullscreen Focus Modes {#MagnifierFullscreenFocusModes} + +The Fullscreen magnifier offers three different modes for focus and determining which part of the screen to magnify: * **Center**: The magnified area is always centered on the current focus position. This mode keeps the focused element at the center of the screen at all times. @@ -1575,14 +1583,14 @@ This mode provides a more stable view, only adjusting when necessary. * **Relative**: The magnified area maintains the relative position of the focus within the screen. This mode mimics the behavior of the Windows Magnifier. -To cycle through the focus tracking modes, please assign a custom gesture using the [Input Gestures dialog](#InputGestures). +To cycle through the focus modes, please assign a custom gesture using the [Input Gestures dialog](#InputGestures). NVDA will announce the name of the currently selected mode. The default focus mode when the magnifier is first enabled can be configured in the [Magnifier settings](#MagnifierSettings). ### Spotlight Mode {#MagnifierSpotlight} -Spotlight mode is a special feature designed for presentations or focused reading tasks. +Spotlight mode is a special fullscreen magnifier feature designed for presentations or focused reading tasks. When activated, it temporarily zooms out the magnified view to show the full screen, then zooms back in to the current focus position after a brief period of mouse inactivity. This is useful when you want to: @@ -2820,9 +2828,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 {#MagnifierDefaultFocusMode} +##### Default fullscreen magnifier focus mode {#MagnifierDefaultFocusMode} -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 default focus tracking mode when the magnifier mode is fullscreen. To cycle through the focus tracking modes, please assign a custom gesture using the [Input Gestures dialog](#InputGestures). The available options are: @@ -2838,6 +2846,25 @@ The available options are: | Border | The magnified area only moves when the focus approaches the edge of the visible area. | | Relative | The magnified area maintains the relative position of the focus within the screen. | +##### Default fixed magnifier size {#MagnifierDefaultFixedSizeMode} + +These two entries allow to choose the default size of the magnifier window by width and height. + +| . {.hideHeaderRow} |.| +|---|---| +|Options |100 to 1000 pixels| +|Default |200 pixels| + +##### Default fixed magnifier position {#MagnifierDefaultFixedPosition} + +this combo box allows you to select the default position of the magnifier window when the magnifier mode is fixed. +The available options are: + +| . {.hideHeaderRow} |.| +|---|---| +|Options |TopLeft, TopRight, BottomLeft, BottomRight| +|Default |TopLeft| + ##### Keep mouse centered {#MagnifierKeepMouseCentered} This checkbox controls whether the mouse pointer should be automatically moved to the center of the magnified area when certain focus events occur. From d9a54c869e000bbd798b0dcb3d39e515d79d2cd7 Mon Sep 17 00:00:00 2001 From: Antoine HAFFREINGUE Date: Wed, 11 Feb 2026 15:54:41 +0100 Subject: [PATCH 13/21] simplifying WindowCreator --- source/_magnifier/utils/windowCreator.py | 165 +++++++++++++---------- 1 file changed, 91 insertions(+), 74 deletions(-) diff --git a/source/_magnifier/utils/windowCreator.py b/source/_magnifier/utils/windowCreator.py index c9d06f0c907..2f66632d758 100644 --- a/source/_magnifier/utils/windowCreator.py +++ b/source/_magnifier/utils/windowCreator.py @@ -5,7 +5,6 @@ from logHandler import log import wx -import array from .types import MagnifierParameters, WindowMagnifierParameters, Size, Filter @@ -14,47 +13,55 @@ class MagnifierPanel(wx.Panel): """A simple panel for the magnifier.""" def __init__(self, parent: wx.Frame, panelType: str): + """ + Initialize the magnifier panel. + + :param parent: The parent frame that will contain this panel + :param panelType: The type/name identifier for this panel + """ super().__init__(parent) self.panelType = panelType self.SetName(panelType) - self.contentImage = None self.contentBitmap = None self.Bind(wx.EVT_PAINT, self.onPaint) - def setContent(self, content): - """Set the content image to be displayed in the magnifier panel.""" - if content: - if isinstance(content, wx.Bitmap): + def setContent(self, content: wx.Bitmap | wx.Image | None) -> None: + """ + Set the content image to be displayed in the magnifier panel. + + :param content: The content to display - can be a wx.Bitmap, wx.Image, or None to clear + """ + if not content: + self.contentBitmap = None + return + + if isinstance(content, wx.Bitmap): + if content.IsOk(): self.contentBitmap = content - self.contentImage = content.ConvertToImage() if content.IsOk() else None - elif isinstance(content, wx.Image): - self.contentImage = content - self.contentBitmap = wx.Bitmap(content) if content.IsOk() else None + log.debug(f"Bitmap content set: {content.GetWidth()}x{content.GetHeight()}") else: - log.error(f"Unknown content type: {type(content)}") - return - - if self.contentImage: - log.info(f"Content details: {self.contentImage.GetWidth()}x{self.contentImage.GetHeight()}") + self.contentBitmap = None + log.debug("Invalid bitmap content") + elif isinstance(content, wx.Image): + if content.IsOk(): + self.contentBitmap = wx.Bitmap(content) + log.debug(f"Image content set: {content.GetWidth()}x{content.GetHeight()}") else: self.contentBitmap = None - self.contentImage = None + log.debug("Invalid image content") - self.Refresh() - log.info(f"{self.panelType.capitalize()} panel refreshed") + self.Refresh() + log.debug(f"{self.panelType.capitalize()} panel refreshed") - def onPaint(self, event): + def onPaint(self, event: wx.PaintEvent) -> None: """Handle the paint event to draw the magnified content.""" dc = wx.PaintDC(self) - # Clear background dc.Clear() - # Draw content if available - if self.contentImage and self.contentImage.IsOk(): - # Draw the magnified content + if self.contentBitmap and self.contentBitmap.IsOk(): dc.DrawBitmap(self.contentBitmap, 0, 0) @@ -69,6 +76,15 @@ def __init__( screenSize: Size = None, windowMagnifierParameters: WindowMagnifierParameters = None, ): + """ + Initialize the magnifier frame window. + + :param parent: Optional parent window + :param title: The window title + :param frameType: The type identifier for the frame + :param screenSize: The screen size (optional, for reference) + :param windowMagnifierParameters: Parameters defining window size, position, and styles + """ self.frameType = frameType self.screenSize = screenSize self.windowMagnifierParameters = windowMagnifierParameters @@ -80,21 +96,18 @@ def __init__( self.SetWindowStyle(self.windowMagnifierParameters.styles) self.SetPosition(self.windowMagnifierParameters.windowPosition) self.panel = self.createPanel() + # Setup sizer for proper layout + sizer = wx.BoxSizer(wx.VERTICAL) + sizer.Add(self.panel, 1, wx.EXPAND) + self.SetSizer(sizer) + self.Layout() self.Show() def createPanel(self) -> MagnifierPanel: """Create and return a magnifier panel.""" return MagnifierPanel(self, self.frameType) - def setupLayout(self) -> None: - """Set up the layout of the magnifier frame.""" - self.createPanel() - sizer = wx.BoxSizer(wx.VERTICAL) - sizer.Add(self.panel, 1, wx.EXPAND) - self.SetSizer(sizer) - self.Layout() - - def updateFrameContent(self, content) -> None: + def updateFrameContent(self, content: wx.Bitmap | wx.Image | None) -> None: """Update the content displayed in the magnifier frame.""" if self.panel: self.panel.setContent(content) @@ -107,6 +120,11 @@ class WindowedMagnifier: """ def __init__(self, windowMagnifierParameters: WindowMagnifierParameters): + """ + Initialize the windowed magnifier. + + :param windowMagnifierParameters: Parameters defining the magnifier window configuration + """ self.windowMagnifierParameters = windowMagnifierParameters self._frame: None | MagnifierFrame = None self._panel: None | MagnifierPanel = None @@ -126,7 +144,7 @@ def _setupWindow(self): def _applyColorFilter(self, image: wx.Image, filterType: Filter) -> wx.Image: """ - Apply color filter with array optimization for better performance. + Apply color filter directly on bytes for optimal performance. :param image: The image to apply the filter to :param filterType: The filter type to apply (NORMAL, GRAYSCALE, INVERTED) @@ -135,44 +153,45 @@ def _applyColorFilter(self, image: wx.Image, filterType: Filter) -> wx.Image: if filterType == Filter.NORMAL or not image or not image.IsOk(): return image - try: - # Get raw image data as bytes - width, height = image.GetWidth(), image.GetHeight() - data = image.GetData() # Returns RGB data as bytes - - # Convert to array for faster manipulation - rgb_array = array.array("B", data) # 'B' = unsigned char (0-255) - - if filterType == Filter.GRAYSCALE: - # Process 3 bytes at a time (R, G, B) - for i in range(0, len(rgb_array), 3): - r, g, b = rgb_array[i], rgb_array[i + 1], rgb_array[i + 2] - # Standard grayscale formula - gray = int(0.299 * r + 0.587 * g + 0.114 * b) - rgb_array[i] = rgb_array[i + 1] = rgb_array[i + 2] = gray - - elif filterType == Filter.INVERTED: - # Invert all values - for i in range(len(rgb_array)): - rgb_array[i] = 255 - rgb_array[i] - - # Create new image with modified data - new_image = wx.Image(width, height) - new_image.SetData(rgb_array.tobytes()) - return new_image - - except Exception as e: - log.error(f"Error applying color filter: {e}") - return image + width, height = image.GetWidth(), image.GetHeight() + data = image.GetData() # Returns RGB data as bytes + + # Use bytearray for direct manipulation (faster than array.array) + rgb_data = bytearray(data) + + if filterType == Filter.GRAYSCALE: + # Process 3 bytes at a time (R, G, B) + for i in range(0, len(rgb_data), 3): + r, g, b = rgb_data[i], rgb_data[i + 1], rgb_data[i + 2] + # Standard grayscale formula (ITU-R BT.601) + gray = int(0.299 * r + 0.587 * g + 0.114 * b) + rgb_data[i] = rgb_data[i + 1] = rgb_data[i + 2] = gray + + elif filterType == Filter.INVERTED: + # Invert all values in place + for i in range(len(rgb_data)): + rgb_data[i] = 255 - rgb_data[i] - def _setContent(self, magnifierParameters: MagnifierParameters, zoomLevel: float): + # Create new image with modified data + new_image = wx.Image(width, height) + new_image.SetData(bytes(rgb_data)) + return new_image + + def _setContent(self, magnifierParameters: MagnifierParameters, zoomLevel: float) -> None: + """ + Update the magnifier panel with captured and processed content. + + :param magnifierParameters: Parameters defining what and how to capture + :param zoomLevel: The zoom magnification level to apply + """ content = self._getContent(magnifierParameters, zoomLevel) if self._panel: self._panel.setContent(content) else: log.debug("No panel available to set content") - def _destroyWindow(self): + def _destroyWindow(self) -> None: + """Destroy the magnifier window and clean up resources.""" if self._frame: self._frame.Destroy() self._frame = None @@ -194,22 +213,22 @@ def _getContent(self, magnifierParameters: MagnifierParameters, zoomLevel: float panelWidth, panelHeight = panelSize.width, panelSize.height # Calculate the size of the area to capture based on zoom level - captureWidth = panelWidth / zoomLevel - captureHeight = panelHeight / zoomLevel + captureWidth = int(panelWidth / zoomLevel) + captureHeight = int(panelHeight / zoomLevel) captureLeft = int(magnifierParameters.coordinates.x) captureTop = int(magnifierParameters.coordinates.y) # Capture screen screen = wx.ScreenDC() - bitmap = wx.Bitmap(int(captureWidth), int(captureHeight)) + bitmap = wx.Bitmap(captureWidth, captureHeight) memoryDc = wx.MemoryDC() memoryDc.SelectObject(bitmap) - success = memoryDc.Blit(0, 0, int(captureWidth), int(captureHeight), screen, captureLeft, captureTop) + success = memoryDc.Blit(0, 0, captureWidth, captureHeight, screen, captureLeft, captureTop) memoryDc.SelectObject(wx.NullBitmap) - log.info( + log.debug( f"Capture at ({captureLeft}, {captureTop}) " - f"size {int(captureWidth)}x{int(captureHeight)} " + f"size {captureWidth}x{captureHeight} " f"zoom {zoomLevel}x -> panel {panelWidth}x{panelHeight}", ) @@ -217,9 +236,7 @@ def _getContent(self, magnifierParameters: MagnifierParameters, zoomLevel: float # Convert to image image = bitmap.ConvertToImage() - # Apply color filter if we have access to filterType - if hasattr(self, "_filterType"): - image = self._applyColorFilter(image, magnifierParameters.filter) + image = self._applyColorFilter(image, magnifierParameters.filter) # Scale image to fill the entire panel (this applies the zoom magnification) magnifiedImage = image.Scale(panelWidth, panelHeight, wx.IMAGE_QUALITY_BICUBIC) @@ -227,6 +244,6 @@ def _getContent(self, magnifierParameters: MagnifierParameters, zoomLevel: float return magnifiedBitmap else: log.error( - f"Screen capture failed at ({captureLeft}, {captureTop}) size {int(captureWidth)}x{int(captureHeight)}", + f"Screen capture failed at ({captureLeft}, {captureTop}) size {captureWidth}x{captureHeight}", ) return None From 516be8054edfd5557ad3f2faa6c2a03bd649220e Mon Sep 17 00:00:00 2001 From: Antoine HAFFREINGUE Date: Wed, 11 Feb 2026 16:29:21 +0100 Subject: [PATCH 14/21] tests for magnifierPanel & magnifierFrame --- .../test_fullscreenMagnifier.py | 4 +- .../unit/test_magnifier/test_windowCreator.py | 190 ++++++++++++++++++ 2 files changed, 192 insertions(+), 2 deletions(-) create mode 100644 tests/unit/test_magnifier/test_windowCreator.py diff --git a/tests/unit/test_magnifier/test_fullscreenMagnifier.py b/tests/unit/test_magnifier/test_fullscreenMagnifier.py index cfe381090e9..bc0dda4d35d 100644 --- a/tests/unit/test_magnifier/test_fullscreenMagnifier.py +++ b/tests/unit/test_magnifier/test_fullscreenMagnifier.py @@ -10,8 +10,8 @@ from _magnifier.magnifier import Magnifier -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.""" diff --git a/tests/unit/test_magnifier/test_windowCreator.py b/tests/unit/test_magnifier/test_windowCreator.py new file mode 100644 index 00000000000..71c7cd1cf3b --- /dev/null +++ b/tests/unit/test_magnifier/test_windowCreator.py @@ -0,0 +1,190 @@ +# A part of NonVisual Desktop Access (NVDA) +# Copyright (C) 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 + +import unittest +from unittest.mock import MagicMock, patch +from _magnifier.utils.types import Coordinates, Size, WindowMagnifierParameters +import wx +from _magnifier.utils.windowCreator import MagnifierPanel, MagnifierFrame + + +class TestMagnifierPanel(unittest.TestCase): + """Tests for the MagnifierPanel class.""" + + @classmethod + def setUpClass(cls): + """Setup that runs once for all tests.""" + if not wx.GetApp(): + cls.app = wx.App(False) + + def setUp(self): + """Set up test fixtures.""" + self.frame = wx.Frame(None) + self.panelType = "testPanel" + self.panel = MagnifierPanel(self.frame, self.panelType) + + def tearDown(self): + """Clean up after tests.""" + if self.frame: + self.frame.Destroy() + + def test_init(self): + """Test MagnifierPanel initialization.""" + self.assertEqual(self.panel.panelType, self.panelType) + self.assertEqual(self.panel.GetName(), self.panelType) + self.assertIsNone(self.panel.contentBitmap) + + def test_setContent_with_valid_bitmap(self): + """Test setContent with a valid wx.Bitmap.""" + bitmap = wx.Bitmap(100, 100) + self.panel.setContent(bitmap) + + self.assertIsNotNone(self.panel.contentBitmap) + self.assertEqual(self.panel.contentBitmap, bitmap) + + def test_setContent_with_valid_image(self): + """Test setContent with a valid wx.Image.""" + image = wx.Image(100, 100) + self.panel.setContent(image) + + self.assertIsNotNone(self.panel.contentBitmap) + self.assertIsInstance(self.panel.contentBitmap, wx.Bitmap) + + def test_setContent_with_none(self): + """Test setContent with None to clear content.""" + bitmap = wx.Bitmap(100, 100) + self.panel.setContent(bitmap) + self.assertIsNotNone(self.panel.contentBitmap) + self.panel.setContent(None) + self.assertIsNone(self.panel.contentBitmap) + + def test_setContent_with_invalid_bitmap(self): + """Test setContent with an invalid bitmap.""" + bitmap = wx.Bitmap() # Empty/invalid bitmap + self.panel.setContent(bitmap) + + self.assertIsNone(self.panel.contentBitmap) + + def test_setContent_with_invalid_image(self): + """Test setContent with an invalid image.""" + image = wx.Image() # Empty/invalid image + self.panel.setContent(image) + + self.assertIsNone(self.panel.contentBitmap) + + def test_onPaint_without_content(self): + """Test onPaint without content.""" + event = MagicMock() + mockDC = MagicMock() + + with patch("wx.PaintDC", return_value=mockDC): + self.panel.onPaint(event) + mockDC.Clear.assert_called_once() + mockDC.DrawBitmap.assert_not_called() + + def test_onPaint_with_content(self): + """Test onPaint with content.""" + bitmap = wx.Bitmap(100, 100) + self.panel.setContent(bitmap) + + event = MagicMock() + mockDC = MagicMock() + + with patch("wx.PaintDC", return_value=mockDC): + self.panel.onPaint(event) + mockDC.Clear.assert_called_once() + mockDC.DrawBitmap.assert_called_once_with(bitmap, 0, 0) + + +class TestMagnifierFrame(unittest.TestCase): + """Tests for the MagnifierFrame class.""" + + @classmethod + def setUpClass(cls): + """Setup that runs once for all tests.""" + if not wx.GetApp(): + cls.app = wx.App(False) + + def setUp(self): + """Set up test fixtures.""" + windowMagnifierParams = WindowMagnifierParameters( + title="Test Magnifier", + windowSize=Size(width=400, height=300), + windowPosition=Coordinates(x=100, y=100), + styles=wx.DEFAULT_FRAME_STYLE, + ) + self.frame = MagnifierFrame( + title="Test Frame", + frameType="testFrame", + screenSize=Size(width=1920, height=1080), + windowMagnifierParameters=windowMagnifierParams, + ) + # Hide the frame to prevent it from being displayed during tests + self.frame.Hide() + + def tearDown(self): + """Clean up after tests.""" + if self.frame: + self.frame.Destroy() + + def test_init(self): + """Test MagnifierFrame initialization.""" + self.assertEqual(self.frame.frameType, "testFrame") + self.assertIsNotNone(self.frame.screenSize) + self.assertEqual(self.frame.screenSize.width, 1920) + self.assertEqual(self.frame.screenSize.height, 1080) + self.assertIsNotNone(self.frame.windowMagnifierParameters) + self.assertIsNotNone(self.frame.panel) + self.assertIsInstance(self.frame.panel, MagnifierPanel) + self.assertEqual(self.frame.GetSize().GetWidth(), 400) + self.assertEqual(self.frame.GetSize().GetHeight(), 300) + self.assertEqual(self.frame.GetPosition().x, 100) + self.assertEqual(self.frame.GetPosition().y, 100) + + def test_createPanel(self): + """Test createPanel method.""" + panel = self.frame.createPanel() + self.assertIsNotNone(panel) + self.assertIsInstance(panel, MagnifierPanel) + self.assertEqual(panel.panelType, "testFrame") + + def test_updateFrameContent(self): + """Test updateFrameContent method.""" + bitmap = wx.Bitmap(200, 150) + self.frame.updateFrameContent(bitmap) + self.assertEqual(self.frame.panel.contentBitmap, bitmap) + + image = wx.Image(200, 150) + self.frame.updateFrameContent(image) + self.assertIsNotNone(self.frame.panel.contentBitmap) + self.assertIsInstance(self.frame.panel.contentBitmap, wx.Bitmap) + + self.frame.updateFrameContent(None) + self.assertIsNone(self.frame.panel.contentBitmap) + + +class TestWindowedMagnifier(unittest.TestCase): + """Tests for the WindowedMagnifier class.""" + + def setUp(self): + """Set up test fixtures.""" + + def test_init(self): + """Test WindowedMagnifier initialization.""" + + def test_setupWindow(self): + """Test _setupWindow method.""" + + def test_applyColorFilter(self): + """Test _applyColorFilter""" + + def test_setContent(self): + """Test _setContent method.""" + + def test_destroyWindow(self): + """Test _destroyWindow method.""" + + def test_getContent(self): + """Test _getContent method.""" From 34ccff9cb985b419af296ec1d0cafaa0f8f42168 Mon Sep 17 00:00:00 2001 From: Antoine HAFFREINGUE Date: Wed, 11 Feb 2026 16:59:20 +0100 Subject: [PATCH 15/21] added tests fpr WindowedMagnifier --- source/_magnifier/utils/windowCreator.py | 8 -- .../unit/test_magnifier/test_windowCreator.py | 132 +++++++++++++++++- 2 files changed, 126 insertions(+), 14 deletions(-) diff --git a/source/_magnifier/utils/windowCreator.py b/source/_magnifier/utils/windowCreator.py index 2f66632d758..d941f685cfb 100644 --- a/source/_magnifier/utils/windowCreator.py +++ b/source/_magnifier/utils/windowCreator.py @@ -126,14 +126,6 @@ def __init__(self, windowMagnifierParameters: WindowMagnifierParameters): :param windowMagnifierParameters: Parameters defining the magnifier window configuration """ self.windowMagnifierParameters = windowMagnifierParameters - self._frame: None | MagnifierFrame = None - self._panel: None | MagnifierPanel = None - self._setupWindow() - - def _setupWindow(self): - """ - Create the magnifier window and panel based on the provided parameters. - """ self._frame = MagnifierFrame( title=self.windowMagnifierParameters.title, frameType="magnifier", diff --git a/tests/unit/test_magnifier/test_windowCreator.py b/tests/unit/test_magnifier/test_windowCreator.py index 71c7cd1cf3b..313222686ed 100644 --- a/tests/unit/test_magnifier/test_windowCreator.py +++ b/tests/unit/test_magnifier/test_windowCreator.py @@ -5,9 +5,9 @@ import unittest from unittest.mock import MagicMock, patch -from _magnifier.utils.types import Coordinates, Size, WindowMagnifierParameters +from _magnifier.utils.types import Coordinates, Size, WindowMagnifierParameters, Filter, MagnifierParameters import wx -from _magnifier.utils.windowCreator import MagnifierPanel, MagnifierFrame +from _magnifier.utils.windowCreator import MagnifierPanel, MagnifierFrame, WindowedMagnifier class TestMagnifierPanel(unittest.TestCase): @@ -168,23 +168,143 @@ def test_updateFrameContent(self): class TestWindowedMagnifier(unittest.TestCase): """Tests for the WindowedMagnifier class.""" + @classmethod + def setUpClass(cls): + """Setup that runs once for all tests.""" + if not wx.GetApp(): + cls.app = wx.App(False) + def setUp(self): """Set up test fixtures.""" + windowMagnifierParams = WindowMagnifierParameters( + title="Test WindowedMagnifier", + windowSize=Size(400, 300), + windowPosition=Coordinates(100, 100), + styles=wx.DEFAULT_FRAME_STYLE, + ) + # Mock Show to prevent window from being displayed during tests + with patch.object(MagnifierFrame, "Show"): + self.magnifier = WindowedMagnifier(windowMagnifierParams) + + def tearDown(self): + """Clean up after tests.""" + if self.magnifier and self.magnifier._frame: + self.magnifier._frame.Destroy() def test_init(self): """Test WindowedMagnifier initialization.""" + self.assertIsNotNone(self.magnifier.windowMagnifierParameters) + self.assertIsNotNone(self.magnifier._frame) + self.assertIsNotNone(self.magnifier._panel) + self.assertIsInstance(self.magnifier._frame, MagnifierFrame) + self.assertIsInstance(self.magnifier._panel, MagnifierPanel) + self.assertEqual(self.magnifier._frame.frameType, "magnifier") + self.assertEqual(self.magnifier._panel, self.magnifier._frame.panel) + + def test_applyColorFilter_normal(self): + """Test _applyColorFilter with NORMAL filter.""" + image = wx.Image(100, 100) + image.SetRGB(wx.Rect(0, 0, 100, 100), 255, 0, 0) # Red image - def test_setupWindow(self): - """Test _setupWindow method.""" + result = self.magnifier._applyColorFilter(image, Filter.NORMAL) + self.assertEqual(result, image) # Should return same image - def test_applyColorFilter(self): - """Test _applyColorFilter""" + def test_applyColorFilter_grayscale(self): + """Test _applyColorFilter with GRAYSCALE filter.""" + image = wx.Image(100, 100) + image.SetRGB(wx.Rect(0, 0, 100, 100), 255, 0, 0) # Red image + + result = self.magnifier._applyColorFilter(image, Filter.GRAYSCALE) + self.assertIsNotNone(result) + self.assertTrue(result.IsOk()) + # Check that the first pixel is grayscale (R=G=B) + rgb = result.GetRed(0, 0), result.GetGreen(0, 0), result.GetBlue(0, 0) + self.assertEqual(rgb[0], rgb[1]) + self.assertEqual(rgb[1], rgb[2]) + + def test_applyColorFilter_inverted(self): + """Test _applyColorFilter with INVERTED filter.""" + image = wx.Image(100, 100) + image.SetRGB(wx.Rect(0, 0, 100, 100), 255, 0, 0) # Red image + + result = self.magnifier._applyColorFilter(image, Filter.INVERTED) + self.assertIsNotNone(result) + self.assertTrue(result.IsOk()) + # Check that colors are inverted (255-R, 255-G, 255-B) + rgb = result.GetRed(0, 0), result.GetGreen(0, 0), result.GetBlue(0, 0) + self.assertEqual(rgb[0], 0) # 255-255 = 0 + self.assertEqual(rgb[1], 255) # 255-0 = 255 + self.assertEqual(rgb[2], 255) # 255-0 = 255 + + def test_applyColorFilter_invalid_image(self): + """Test _applyColorFilter with invalid image.""" + image = wx.Image() # Invalid/empty image + result = self.magnifier._applyColorFilter(image, Filter.GRAYSCALE) + self.assertEqual(result, image) # Should return same invalid image def test_setContent(self): """Test _setContent method.""" + magnifierParams = MagnifierParameters( + magnifierSize=Size(200, 150), + coordinates=Coordinates(0, 0), + filter=Filter.NORMAL, + ) + + # Mock _getContent to return a bitmap + bitmap = wx.Bitmap(100, 100) + with patch.object(self.magnifier, "_getContent", return_value=bitmap): + self.magnifier._setContent(magnifierParams, 2.0) + self.assertEqual(self.magnifier._panel.contentBitmap, bitmap) + + def test_setContent_no_panel(self): + """Test _setContent when panel is None.""" + magnifierParams = MagnifierParameters( + magnifierSize=Size(200, 150), + coordinates=Coordinates(0, 0), + filter=Filter.NORMAL, + ) + + # Set panel to None + self.magnifier._panel = None + + # Should not raise error + self.magnifier._setContent(magnifierParams, 2.0) def test_destroyWindow(self): """Test _destroyWindow method.""" + self.assertIsNotNone(self.magnifier._frame) + self.assertIsNotNone(self.magnifier._panel) + + self.magnifier._destroyWindow() + + self.assertIsNone(self.magnifier._frame) + self.assertIsNone(self.magnifier._panel) def test_getContent(self): """Test _getContent method.""" + magnifierParams = MagnifierParameters( + magnifierSize=Size(200, 150), + coordinates=Coordinates(0, 0), + filter=Filter.NORMAL, + ) + + result = self.magnifier._getContent(magnifierParams, 2.0) + + # Should return a bitmap or None + if result is not None: + self.assertIsInstance(result, wx.Bitmap) + self.assertTrue(result.IsOk()) + + def test_getContent_no_panel(self): + """Test _getContent when panel is None.""" + magnifierParams = MagnifierParameters( + magnifierSize=Size(200, 150), + coordinates=Coordinates(0, 0), + filter=Filter.NORMAL, + ) + + # Set panel to None + self.magnifier._panel = None + + result = self.magnifier._getContent(magnifierParams, 2.0) + self.assertIsNone(result) From aa4e7d50705d51153ababafb450b151937f59371 Mon Sep 17 00:00:00 2001 From: Antoine HAFFREINGUE Date: Wed, 11 Feb 2026 17:49:23 +0100 Subject: [PATCH 16/21] unit test for fixed Magnifier --- source/_magnifier/fixedMagnifier.py | 4 +- .../test_magnifier/test_fixedMagnifier.py | 105 ++++++++++++++++++ 2 files changed, 106 insertions(+), 3 deletions(-) create mode 100644 tests/unit/test_magnifier/test_fixedMagnifier.py diff --git a/source/_magnifier/fixedMagnifier.py b/source/_magnifier/fixedMagnifier.py index 500e526d371..b1a74d6d8d3 100644 --- a/source/_magnifier/fixedMagnifier.py +++ b/source/_magnifier/fixedMagnifier.py @@ -39,15 +39,13 @@ def filterType(self) -> Filter: @filterType.setter def filterType(self, value: Filter) -> None: self._filterType = value - if self._isActive: - self._applyFilter() def event_gainFocus( self, obj, nextHandler, ): - log.debug("Full-screen Magnifier gain focus event") + log.debug("Fixed Magnifier gain focus event") nextHandler() def _startMagnifier(self) -> None: diff --git a/tests/unit/test_magnifier/test_fixedMagnifier.py b/tests/unit/test_magnifier/test_fixedMagnifier.py new file mode 100644 index 00000000000..cb1fa1c0249 --- /dev/null +++ b/tests/unit/test_magnifier/test_fixedMagnifier.py @@ -0,0 +1,105 @@ +# A part of NonVisual Desktop Access (NVDA) +# Copyright (C) 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 + +import unittest +from unittest.mock import MagicMock, patch +from _magnifier.utils.types import ( + Coordinates, + FixedWindowPosition, +) +from _magnifier.fixedMagnifier import FixedMagnifier +from _magnifier.utils.windowCreator import MagnifierFrame, WindowedMagnifier +import wx + + +class TestFixedMagnifier(unittest.TestCase): + """Tests for the FixedMagnifier class.""" + + @classmethod + def setUpClass(cls): + """Setup that runs once for all tests.""" + if not wx.GetApp(): + cls.app = wx.App(False) + + def setUp(self): + """Setup before each test.""" + # Mock config functions to avoid dependencies + with patch("_magnifier.fixedMagnifier.getDefaultFixedWindowWidth", return_value=400): + with patch("_magnifier.fixedMagnifier.getDefaultFixedWindowHeight", return_value=300): + with patch( + "_magnifier.fixedMagnifier.getDefaultFixedWindowPosition", + return_value=FixedWindowPosition.TOP_LEFT, + ): + # Mock Show to prevent window from being displayed during tests + with patch.object(MagnifierFrame, "Show"): + self.magnifier = FixedMagnifier() + + def tearDown(self): + """Cleanup after each test.""" + if hasattr(self, "magnifier") and self.magnifier._frame: + self.magnifier._frame.Destroy() + + def test_init(self): + """Test initialization of FixedMagnifier.""" + self.assertIsNotNone(self.magnifier._frame) + self.assertIsNotNone(self.magnifier._panel) + self.assertIsNotNone(self.magnifier._windowParameters) + self.assertEqual(self.magnifier._currentCoordinates.x, 0) + self.assertEqual(self.magnifier._currentCoordinates.y, 0) + + def test_startMagnifier(self): + """Test starting the FixedMagnifier.""" + with patch.object(self.magnifier, "_startTimer") as mock_timer: + self.magnifier._startMagnifier() + + self.assertTrue(self.magnifier._isActive) + mock_timer.assert_called_once() + + def test_doUpdate(self): + """Test the update magnifier functionality.""" + self.magnifier._currentCoordinates = Coordinates(100, 200) + + with patch.object(WindowedMagnifier, "_setContent") as mock_setContent: + with patch.object(self.magnifier, "_getMagnifierParameters") as mock_getParams: + mock_params = MagicMock() + mock_getParams.return_value = mock_params + + self.magnifier._doUpdate() + + mock_getParams.assert_called_once_with( + self.magnifier._currentCoordinates, + self.magnifier._windowParameters.windowSize, + ) + mock_setContent.assert_called_once_with(mock_params, self.magnifier.zoomLevel) + + def test_stopMagnifier(self): + """Test stopping the FixedMagnifier.""" + # Start magnifier first + with patch.object(self.magnifier, "_startTimer"): + self.magnifier._startMagnifier() + + self.assertTrue(self.magnifier._isActive) + + with patch.object(WindowedMagnifier, "_destroyWindow") as mock_destroy: + self.magnifier._stopMagnifier() + + mock_destroy.assert_called_once() + self.assertFalse(self.magnifier._isActive) + + def test_getWindowParameters(self): + """Test retrieving window parameters.""" + with patch("_magnifier.fixedMagnifier.getDefaultFixedWindowWidth", return_value=400): + with patch("_magnifier.fixedMagnifier.getDefaultFixedWindowHeight", return_value=300): + with patch( + "_magnifier.fixedMagnifier.getDefaultFixedWindowPosition", + return_value=FixedWindowPosition.TOP_LEFT, + ): + params = self.magnifier._getWindowParameters() + + self.assertEqual(params.windowSize.width, 400) + self.assertEqual(params.windowSize.height, 300) + self.assertEqual(params.windowPosition.x, 0) + self.assertEqual(params.windowPosition.y, 0) + self.assertEqual(params.title, "NVDA Fixed Magnifier") From 011d4c3cc7bbc92bf2d8c990fe46b461be9028f9 Mon Sep 17 00:00:00 2001 From: Antoine HAFFREINGUE Date: Wed, 25 Feb 2026 17:34:23 +0100 Subject: [PATCH 17/21] fixed true center, update gui --- source/_magnifier/fixedMagnifier.py | 37 ++++++++++++++++- source/_magnifier/fullscreenMagnifier.py | 41 +++++++++++++++++- source/_magnifier/magnifier.py | 23 +---------- source/gui/settingsDialogs.py | 25 +++++------ tests/unit/test_magnifier/test_magnifier.py | 46 --------------------- 5 files changed, 90 insertions(+), 82 deletions(-) diff --git a/source/_magnifier/fixedMagnifier.py b/source/_magnifier/fixedMagnifier.py index 5ede79d66d0..fd4721f15dd 100644 --- a/source/_magnifier/fixedMagnifier.py +++ b/source/_magnifier/fixedMagnifier.py @@ -14,11 +14,17 @@ Size, MagnifierType, WindowMagnifierParameters, + MagnifierParameters, Filter, FixedWindowPosition, ) from .utils.windowCreator import WindowedMagnifier -from .config import getDefaultFixedWindowWidth, getDefaultFixedWindowHeight, getDefaultFixedWindowPosition +from .config import ( + getDefaultFixedWindowWidth, + getDefaultFixedWindowHeight, + getDefaultFixedWindowPosition, + isTrueCentered, +) import wx @@ -98,3 +104,32 @@ def _getWindowParameters(self) -> WindowMagnifierParameters: windowPosition=position, styles=wx.FRAME_NO_TASKBAR | wx.STAY_ON_TOP, ) + + 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 + :param displaySize: The size of the display area (width, height) - used to calculate capture size + + :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._windowParameters.windowSize.width / self.zoomLevel + magnifierHeight = self._windowParameters.windowSize.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/fullscreenMagnifier.py b/source/_magnifier/fullscreenMagnifier.py index 87384e10176..67e4c62dd5c 100644 --- a/source/_magnifier/fullscreenMagnifier.py +++ b/source/_magnifier/fullscreenMagnifier.py @@ -14,8 +14,16 @@ from .magnifier import Magnifier from .utils.filterHandler import FilterMatrix from .utils.spotlightManager import SpotlightManager -from .utils.types import Filter, Coordinates, MagnifierType, FullScreenMode, FocusType, Size -from .config import getDefaultFullscreenMode, shouldKeepMouseCentered +from .utils.types import ( + Filter, + Coordinates, + MagnifierType, + FullScreenMode, + FocusType, + Size, + MagnifierParameters, +) +from .config import getDefaultFullscreenMode, shouldKeepMouseCentered, isTrueCentered class FullScreenMagnifier(Magnifier): @@ -268,3 +276,32 @@ 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 + :param displaySize: The size of the display area (width, height) - used to calculate capture size + + :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 80b1aeaa288..a28aee8c9ea 100644 --- a/source/_magnifier/magnifier.py +++ b/source/_magnifier/magnifier.py @@ -19,13 +19,12 @@ from .utils.types import ( MagnifierParameters, Coordinates, - Size, MagnifierType, Direction, Filter, ) from .utils.focusManager import FocusManager -from .config import getDefaultZoomLevel, getDefaultFilter, ZoomLevel, isTrueCentered +from .config import getDefaultZoomLevel, getDefaultFilter, ZoomLevel class Magnifier: @@ -212,22 +211,4 @@ def _getMagnifierParameters(self, coordinates: Coordinates) -> MagnifierParamete :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, - ) + raise NotImplementedError("Subclasses must implement this method") diff --git a/source/gui/settingsDialogs.py b/source/gui/settingsDialogs.py index fe84617eaeb..cdaf558b312 100644 --- a/source/gui/settingsDialogs.py +++ b/source/gui/settingsDialogs.py @@ -6038,18 +6038,6 @@ def makeSettings( ) self.trueCenterCheckBox.SetValue(magnifierConfig.isTrueCentered()) - # KEEP MOUSE CENTERED - # Translators: The label for a checkbox to keep the mouse pointer centered in the magnifier view - keepMouseCenteredText = _("Keep &mouse pointer centered in magnifier view") - self.keepMouseCenteredCheckBox = generalGroup.addItem( - wx.CheckBox(generalGroupBox, label=keepMouseCenteredText), - ) - self.bindHelpEvent( - "magnifierKeepMouseCentered", - self.keepMouseCenteredCheckBox, - ) - self.keepMouseCenteredCheckBox.SetValue(magnifierConfig.shouldKeepMouseCentered()) - # MAGNIFIER TYPE SETTINGS # Translators: The label for a setting in magnifier settings to select the default magnifier type magnifierTypeLabelText = _("Default &magnifier type:") @@ -6076,6 +6064,7 @@ def makeSettings( # magnifier settings panel fullscreenGroupText = _("Fullscreen") self.fullscreenGroupSizer = wx.StaticBoxSizer(wx.VERTICAL, self, label=fullscreenGroupText) + fullscreenGroupBox = self.fullscreenGroupSizer.GetStaticBox() fullscreenGroup = guiHelper.BoxSizerHelper(self, sizer=self.fullscreenGroupSizer) sHelper.addItem(fullscreenGroup) @@ -6097,6 +6086,18 @@ def makeSettings( defaultFullscreenMode = magnifierConfig.getDefaultFullscreenMode() self.defaultFullscreenModeList.SetSelection(list(FullScreenMode).index(defaultFullscreenMode)) + # KEEP MOUSE CENTERED + # Translators: The label for a checkbox to keep the mouse pointer centered in the magnifier view + keepMouseCenteredText = _("Keep &mouse pointer centered in magnifier view") + self.keepMouseCenteredCheckBox = fullscreenGroup.addItem( + wx.CheckBox(fullscreenGroupBox, label=keepMouseCenteredText), + ) + self.bindHelpEvent( + "magnifierKeepMouseCentered", + self.keepMouseCenteredCheckBox, + ) + self.keepMouseCenteredCheckBox.SetValue(magnifierConfig.shouldKeepMouseCentered()) + # FIXED MAGNIFIER GROUP # Translators: This is the label for a group of fixed magnifier options in the # magnifier settings panel diff --git a/tests/unit/test_magnifier/test_magnifier.py b/tests/unit/test_magnifier/test_magnifier.py index d0e605f71ac..143f160a059 100644 --- a/tests/unit/test_magnifier/test_magnifier.py +++ b/tests/unit/test_magnifier/test_magnifier.py @@ -206,49 +206,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) - params = self.magnifier._getMagnifierParameters((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(params.coordinates.x, expected_left) - self.assertEqual(params.coordinates.y, expected_top) - self.assertEqual(params.magnifierSize.width, expected_width) - self.assertEqual(params.magnifierSize.height, expected_height) - - # Test left clamping - params = self.magnifier._getMagnifierParameters((100, 540)) - self.assertGreaterEqual(params.coordinates.x, 0) - - # Test right clamping - params = self.magnifier._getMagnifierParameters((1800, 540)) - self.assertLessEqual(params.coordinates.x + params.magnifierSize.width, self.screenWidth) - - # Test different zoom level - self.magnifier.zoomLevel = 4.0 - params = self.magnifier._getMagnifierParameters((960, 540)) - expected_width = int(self.screenWidth / self.magnifier.zoomLevel) - expected_height = int(self.screenHeight / self.magnifier.zoomLevel) - self.assertEqual(params.magnifierSize.width, expected_width) - self.assertEqual(params.magnifierSize.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): - params = self.magnifier._getMagnifierParameters((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(params.coordinates.x, expected_left) - self.assertEqual(params.coordinates.y, expected_top) - self.assertEqual(params.magnifierSize.width, expected_width) - self.assertEqual(params.magnifierSize.height, expected_height) From b3bdfc8907e58badeaad31bfdcc3737c0feb04a6 Mon Sep 17 00:00:00 2001 From: Antoine HAFFREINGUE Date: Tue, 3 Mar 2026 10:50:02 +0100 Subject: [PATCH 18/21] changed to native win32 for window handling --- source/_magnifier/fixedMagnifier.py | 3 - source/_magnifier/utils/types.py | 7 +- source/_magnifier/utils/windowCreator.py | 639 ++++++++++----- .../test_magnifier/test_fixedMagnifier.py | 25 +- .../unit/test_magnifier/test_windowCreator.py | 757 +++++++++++------- 5 files changed, 936 insertions(+), 495 deletions(-) diff --git a/source/_magnifier/fixedMagnifier.py b/source/_magnifier/fixedMagnifier.py index fd4721f15dd..f6a2aafee1a 100644 --- a/source/_magnifier/fixedMagnifier.py +++ b/source/_magnifier/fixedMagnifier.py @@ -26,8 +26,6 @@ isTrueCentered, ) -import wx - class FixedMagnifier(Magnifier, WindowedMagnifier): def __init__(self): @@ -102,7 +100,6 @@ def _getWindowParameters(self) -> WindowMagnifierParameters: title="NVDA Fixed Magnifier", windowSize=windowSize, windowPosition=position, - styles=wx.FRAME_NO_TASKBAR | wx.STAY_ON_TOP, ) def _getMagnifierParameters(self, coordinates: Coordinates) -> MagnifierParameters: diff --git a/source/_magnifier/utils/types.py b/source/_magnifier/utils/types.py index bcd45885c06..14564b46452 100644 --- a/source/_magnifier/utils/types.py +++ b/source/_magnifier/utils/types.py @@ -142,13 +142,16 @@ class MagnifierParameters(NamedTuple): class WindowMagnifierParameters(NamedTuple): """ - Named tuple representing the position and size of the magnifier window + Named tuple representing the position and size of the magnifier window. + The styles field is no longer used since window styles are now determined + by the MagnifierOverlayWindow class for proper NVDA invisibility, + anti-capture and click-through behaviour. """ title: str windowSize: Size windowPosition: Coordinates - styles: int + styles: int = 0 class ZoomHistory(NamedTuple): diff --git a/source/_magnifier/utils/windowCreator.py b/source/_magnifier/utils/windowCreator.py index d941f685cfb..bce5421f7e9 100644 --- a/source/_magnifier/utils/windowCreator.py +++ b/source/_magnifier/utils/windowCreator.py @@ -3,239 +3,476 @@ # 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 logHandler import log -import wx +""" +Windowed magnifier overlay using Win32 native windows. -from .types import MagnifierParameters, WindowMagnifierParameters, Size, Filter +Provides a magnifier overlay window with three key properties: +1. Invisible to NVDA and accessibility APIs (WS_EX_TRANSPARENT + WS_EX_TOOLWINDOW) +2. Excluded from screen capture to prevent feedback loops (SetWindowDisplayAffinity) +3. Click-through so it doesn't interfere with user interaction (WS_EX_TRANSPARENT + WS_DISABLED) +""" +import ctypes +import ctypes.wintypes -class MagnifierPanel(wx.Panel): - """A simple panel for the magnifier.""" +from logHandler import log +import winUser +import winGDI +import winBindings.gdi32 as gdi32 +from winBindings import user32 +from windowUtils import CustomWindow + +from .types import MagnifierParameters, WindowMagnifierParameters, Filter + +#: Window Display Affinity: exclude from screen capture (Windows 10 2004+) +WDA_EXCLUDEFROMCAPTURE: int = 0x00000011 +#: WM_PAINT message +WM_PAINT: int = 0x000F +#: WM_DESTROY message +WM_DESTROY: int = 0x0002 +#: WM_ERASEBKGND message +WM_ERASEBKGND: int = 0x0014 +#: SetStretchBltMode: high-quality image stretching mode +HALFTONE: int = 4 +#: BitBlt raster-op: copy inverted source to destination +NOTSRCCOPY: int = 0x00330008 + +_user32_dll = ctypes.windll.user32 +_gdi32_dll = ctypes.windll.gdi32 + +_user32_dll.SetWindowDisplayAffinity.argtypes = [ctypes.wintypes.HWND, ctypes.wintypes.DWORD] +_user32_dll.SetWindowDisplayAffinity.restype = ctypes.wintypes.BOOL + +_gdi32_dll.SetStretchBltMode.argtypes = [ctypes.wintypes.HDC, ctypes.c_int] +_gdi32_dll.SetStretchBltMode.restype = ctypes.c_int + +_gdi32_dll.SetDIBits.argtypes = [ + ctypes.wintypes.HDC, + ctypes.wintypes.HBITMAP, + ctypes.c_uint, + ctypes.c_uint, + ctypes.c_void_p, + ctypes.c_void_p, + ctypes.c_uint, +] +_gdi32_dll.SetDIBits.restype = ctypes.c_int + +#: DrawIconEx flag: draw cursor with its normal mask and colour +DI_NORMAL: int = 0x0003 +#: CURSORINFO.flags value: the cursor is showing +CURSOR_SHOWING: int = 0x00000001 +#: GetSystemMetrics index: default cursor width +SM_CXCURSOR: int = 13 +#: GetSystemMetrics index: default cursor height +SM_CYCURSOR: int = 14 + + +class ICONINFO(ctypes.Structure): + _fields_ = [ + ("fIcon", ctypes.wintypes.BOOL), + ("xHotspot", ctypes.wintypes.DWORD), + ("yHotspot", ctypes.wintypes.DWORD), + ("hbmMask", ctypes.wintypes.HBITMAP), + ("hbmColor", ctypes.wintypes.HBITMAP), + ] + + +class CURSORINFO(ctypes.Structure): + _fields_ = [ + ("cbSize", ctypes.wintypes.DWORD), + ("flags", ctypes.wintypes.DWORD), + ("hCursor", ctypes.wintypes.HANDLE), + ("ptScreenPos", ctypes.wintypes.POINT), + ] + + +_user32_dll.GetCursorInfo.argtypes = [ctypes.POINTER(CURSORINFO)] +_user32_dll.GetCursorInfo.restype = ctypes.wintypes.BOOL + +_user32_dll.GetIconInfo.argtypes = [ctypes.wintypes.HANDLE, ctypes.POINTER(ICONINFO)] +_user32_dll.GetIconInfo.restype = ctypes.wintypes.BOOL + +_user32_dll.DrawIconEx.argtypes = [ + ctypes.wintypes.HDC, + ctypes.c_int, + ctypes.c_int, + ctypes.wintypes.HANDLE, + ctypes.c_int, + ctypes.c_int, + ctypes.c_uint, + ctypes.wintypes.HBRUSH, + ctypes.c_uint, +] +_user32_dll.DrawIconEx.restype = ctypes.wintypes.BOOL + +_user32_dll.GetSystemMetrics.argtypes = [ctypes.c_int] +_user32_dll.GetSystemMetrics.restype = ctypes.c_int + + +class MagnifierOverlayWindow(CustomWindow): + """Win32 native overlay window for displaying magnified screen content. + + This window is: + - **Invisible to NVDA**: ``WS_EX_TRANSPARENT`` and ``WS_EX_TOOLWINDOW`` + make the window ignored by accessibility APIs and absent from the taskbar. + - **Excluded from screen capture**: ``SetWindowDisplayAffinity(WDA_EXCLUDEFROMCAPTURE)`` + prevents screen-capture APIs (including the Windows Magnifier) from seeing + this window, avoiding infinite feedback loops. + - **Click-through**: the combination of ``WS_DISABLED`` and ``WS_EX_TRANSPARENT`` + lets all mouse events fall through to the window beneath. + """ - def __init__(self, parent: wx.Frame, panelType: str): - """ - Initialize the magnifier panel. + className = "NVDAMagnifierOverlay" - :param parent: The parent frame that will contain this panel - :param panelType: The type/name identifier for this panel - """ - super().__init__(parent) + @classmethod + def _get__wClass(cls): + wClass = super()._wClass + wClass.style = winUser.CS_HREDRAW | winUser.CS_VREDRAW + return wClass - self.panelType = panelType - self.SetName(panelType) + def __init__(self, windowParams: WindowMagnifierParameters): + """Create the overlay window and configure its special properties. - self.contentBitmap = None + :param windowParams: Title, size and position for the overlay. + """ + super().__init__( + windowName=windowParams.title, + windowStyle=winUser.WS_POPUP | winUser.WS_DISABLED, + extendedWindowStyle=( + winUser.WS_EX_TOPMOST + | winUser.WS_EX_LAYERED + | winUser.WS_EX_NOACTIVATE + | winUser.WS_EX_TRANSPARENT + | winUser.WS_EX_TOOLWINDOW + ), + ) - self.Bind(wx.EVT_PAINT, self.onPaint) + self._windowWidth: int = windowParams.windowSize.width + self._windowHeight: int = windowParams.windowSize.height + + # GDI resources for the captured screen region + self._captureDC = None + self._captureBitmap = None + self._oldCaptureBitmap = None + self._captureWidth: int = 0 + self._captureHeight: int = 0 + self._currentFilter: Filter = Filter.NORMAL + + # Cursor overlay state (updated at each capture frame) + self._cursorHandle = None + self._cursorWindowX: int = -1 + self._cursorWindowY: int = -1 + self._cursorHotspotX: int = 0 + self._cursorHotspotY: int = 0 + + # Position and size the window + x, y = windowParams.windowPosition + user32.SetWindowPos( + self.handle, + winUser.HWND_TOPMOST, + x, + y, + self._windowWidth, + self._windowHeight, + winUser.SWP_NOACTIVATE, + ) - def setContent(self, content: wx.Bitmap | wx.Image | None) -> None: - """ - Set the content image to be displayed in the magnifier panel. + # Make the window fully opaque via the layered-window mechanism + winUser.SetLayeredWindowAttributes(self.handle, 0, 255, winUser.LWA_ALPHA) + + # Exclude from screen capture to prevent feedback loops + if not _user32_dll.SetWindowDisplayAffinity(self.handle, WDA_EXCLUDEFROMCAPTURE): + log.warning( + "SetWindowDisplayAffinity failed – overlay window may cause screen-capture feedback", + ) - :param content: The content to display - can be a wx.Bitmap, wx.Image, or None to clear + # Show the window without activating it + user32.ShowWindow(self.handle, winUser.SW_SHOWNA) + user32.UpdateWindow(self.handle) + + def windowProc(self, hwnd: int, msg: int, wParam: int, lParam: int): + if msg == WM_PAINT: + self._paint() + return 0 + elif msg == WM_ERASEBKGND: + # Prevent background erasure to avoid flicker + return 1 + elif msg == WM_DESTROY: + self._cleanupGDI() + return 0 + return None + + def updateContent( + self, + captureX: int, + captureY: int, + captureW: int, + captureH: int, + filterType: Filter = Filter.NORMAL, + ) -> None: + """Capture a screen region, optionally apply a colour filter, then repaint. + + The captured region is stored in an off-screen memory DC at its native + resolution. Scaling to the window size happens during ``WM_PAINT`` via + ``StretchBlt``, keeping the capture step lightweight. + + :param captureX: Screen X of the region to capture. + :param captureY: Screen Y of the region to capture. + :param captureW: Width of the region to capture (pixels). + :param captureH: Height of the region to capture (pixels). + :param filterType: Colour filter to apply (NORMAL, GRAYSCALE, INVERTED). """ - if not content: - self.contentBitmap = None + if captureW <= 0 or captureH <= 0: return - if isinstance(content, wx.Bitmap): - if content.IsOk(): - self.contentBitmap = content - log.debug(f"Bitmap content set: {content.GetWidth()}x{content.GetHeight()}") - else: - self.contentBitmap = None - log.debug("Invalid bitmap content") - elif isinstance(content, wx.Image): - if content.IsOk(): - self.contentBitmap = wx.Bitmap(content) - log.debug(f"Image content set: {content.GetWidth()}x{content.GetHeight()}") - else: - self.contentBitmap = None - log.debug("Invalid image content") - - self.Refresh() - log.debug(f"{self.panelType.capitalize()} panel refreshed") - - def onPaint(self, event: wx.PaintEvent) -> None: - """Handle the paint event to draw the magnified content.""" - dc = wx.PaintDC(self) - dc.Clear() - - if self.contentBitmap and self.contentBitmap.IsOk(): - dc.DrawBitmap(self.contentBitmap, 0, 0) - - -class MagnifierFrame(wx.Frame): - """A simple window frame for the magnifier.""" - - def __init__( + screenDC = user32.GetDC(0) + try: + # (Re-)create the capture DC / bitmap when the capture size changes + if self._captureWidth != captureW or self._captureHeight != captureH: + self._cleanupGDI() + self._captureDC = gdi32.CreateCompatibleDC(screenDC) + self._captureBitmap = gdi32.CreateCompatibleBitmap(screenDC, captureW, captureH) + self._oldCaptureBitmap = gdi32.SelectObject(self._captureDC, self._captureBitmap) + self._captureWidth = captureW + self._captureHeight = captureH + + # Copy the screen region into the off-screen bitmap + gdi32.StretchBlt( + self._captureDC, + 0, + 0, + captureW, + captureH, + screenDC, + captureX, + captureY, + captureW, + captureH, + winGDI.SRCCOPY, + ) + finally: + user32.ReleaseDC(0, screenDC) + + # Grayscale requires per-pixel DIB manipulation. + # Inverted is handled at zero cost in _paint() via the NOTSRCCOPY raster-op. + if filterType == Filter.GRAYSCALE: + self._applyGrayscaleFilter() + + self._currentFilter = filterType + + # Snapshot cursor position relative to this capture frame + self._snapshotCursor(captureX, captureY, captureW, captureH) + + # Trigger a WM_PAINT + user32.InvalidateRect(self.handle, None, False) + + def _paint(self) -> None: + """StretchBlt the captured bitmap to the window's client area.""" + with winUser.paint(self.handle) as hdc: + if self._captureDC and self._captureWidth > 0 and self._captureHeight > 0: + _gdi32_dll.SetStretchBltMode(hdc, HALFTONE) + # NOTSRCCOPY inverts all pixels during the blit – free colour inversion + rop = NOTSRCCOPY if self._currentFilter == Filter.INVERTED else winGDI.SRCCOPY + gdi32.StretchBlt( + hdc, + 0, + 0, + self._windowWidth, + self._windowHeight, + self._captureDC, + 0, + 0, + self._captureWidth, + self._captureHeight, + rop, + ) + # Draw the cursor on top of the magnified content + self._paintCursor(hdc) + + def _applyGrayscaleFilter(self) -> None: + """Convert the captured bitmap to grayscale via direct DIB byte manipulation.""" + w, h = self._captureWidth, self._captureHeight + numPixels = w * h + + bmInfo = gdi32.BITMAPINFO() + bmInfo.bmiHeader.biSize = ctypes.sizeof(gdi32.BITMAPINFO) + bmInfo.bmiHeader.biWidth = w + bmInfo.bmiHeader.biHeight = -h # top-down + bmInfo.bmiHeader.biPlanes = 1 + bmInfo.bmiHeader.biBitCount = 32 + bmInfo.bmiHeader.biCompression = winGDI.BI_RGB + + bufferSize = numPixels * 4 + buffer = (ctypes.c_ubyte * bufferSize)() + gdi32.GetDIBits( + self._captureDC, + self._captureBitmap, + 0, + h, + buffer, + ctypes.byref(bmInfo), + winGDI.DIB_RGB_COLORS, + ) + + # Process BGRA pixels with fixed-point ITU-R BT.601 coefficients + data = bytearray(buffer) + for i in range(0, bufferSize, 4): + b, g, r = data[i], data[i + 1], data[i + 2] + gray = (77 * r + 150 * g + 29 * b) >> 8 + data[i] = data[i + 1] = data[i + 2] = gray + + ctypes.memmove(buffer, (ctypes.c_char * bufferSize).from_buffer(data), bufferSize) + _gdi32_dll.SetDIBits( + self._captureDC, + self._captureBitmap, + 0, + h, + buffer, + ctypes.byref(bmInfo), + winGDI.DIB_RGB_COLORS, + ) + + def _snapshotCursor( self, - parent=None, - title: str = "Magnifier Window", - frameType: str = "magnifier", - screenSize: Size = None, - windowMagnifierParameters: WindowMagnifierParameters = None, - ): + captureX: int, + captureY: int, + captureW: int, + captureH: int, + ) -> None: + """Record the current cursor position in window coordinates. + + Called once per frame inside :meth:`updateContent`. If the cursor is + outside the capture area, or invisible, ``_cursorHandle`` is set to + ``None`` so :meth:`_paintCursor` is a no-op. """ - Initialize the magnifier frame window. + ci = CURSORINFO() + ci.cbSize = ctypes.sizeof(CURSORINFO) + if not _user32_dll.GetCursorInfo(ctypes.byref(ci)) or not (ci.flags & CURSOR_SHOWING): + self._cursorHandle = None + return - :param parent: Optional parent window - :param title: The window title - :param frameType: The type identifier for the frame - :param screenSize: The screen size (optional, for reference) - :param windowMagnifierParameters: Parameters defining window size, position, and styles - """ - self.frameType = frameType - self.screenSize = screenSize - self.windowMagnifierParameters = windowMagnifierParameters - super().__init__( - parent, - title=title, - size=(windowMagnifierParameters.windowSize.width, windowMagnifierParameters.windowSize.height), - ) - self.SetWindowStyle(self.windowMagnifierParameters.styles) - self.SetPosition(self.windowMagnifierParameters.windowPosition) - self.panel = self.createPanel() - # Setup sizer for proper layout - sizer = wx.BoxSizer(wx.VERTICAL) - sizer.Add(self.panel, 1, wx.EXPAND) - self.SetSizer(sizer) - self.Layout() - self.Show() - - def createPanel(self) -> MagnifierPanel: - """Create and return a magnifier panel.""" - return MagnifierPanel(self, self.frameType) - - def updateFrameContent(self, content: wx.Bitmap | wx.Image | None) -> None: - """Update the content displayed in the magnifier frame.""" - if self.panel: - self.panel.setContent(content) + cx, cy = ci.ptScreenPos.x, ci.ptScreenPos.y + relX = cx - captureX + relY = cy - captureY + if relX < 0 or relY < 0 or relX >= captureW or relY >= captureH: + # Cursor is outside the captured area + self._cursorHandle = None + return -class WindowedMagnifier: - """ - Base class for magnifiers that use a separate window to display magnified content. - Provides common functionality for creating and managing the magnifier window and panel. - """ + # Map cursor position to window (scaled) coordinates + scaleX = self._windowWidth / captureW + scaleY = self._windowHeight / captureH + self._cursorWindowX = int(relX * scaleX) + self._cursorWindowY = int(relY * scaleY) + self._cursorHandle = ci.hCursor + + # Retrieve hotspot so we can draw cursor anchored at its click point + ii = ICONINFO() + if _user32_dll.GetIconInfo(ci.hCursor, ctypes.byref(ii)): + self._cursorHotspotX = int(ii.xHotspot * scaleX) + self._cursorHotspotY = int(ii.yHotspot * scaleY) + # GetIconInfo allocates bitmaps – always free them + if ii.hbmMask: + gdi32.DeleteObject(ii.hbmMask) + if ii.hbmColor: + gdi32.DeleteObject(ii.hbmColor) + else: + self._cursorHotspotX = 0 + self._cursorHotspotY = 0 - def __init__(self, windowMagnifierParameters: WindowMagnifierParameters): - """ - Initialize the windowed magnifier. + def _paintCursor(self, hdc) -> None: + """Draw the cursor glyph on *hdc* using the state from :meth:`_snapshotCursor`.""" + if not self._cursorHandle or self._cursorWindowX < 0: + return - :param windowMagnifierParameters: Parameters defining the magnifier window configuration - """ - self.windowMagnifierParameters = windowMagnifierParameters - self._frame = MagnifierFrame( - title=self.windowMagnifierParameters.title, - frameType="magnifier", - screenSize=self.windowMagnifierParameters.windowSize, - windowMagnifierParameters=self.windowMagnifierParameters, + if self._captureWidth <= 0: + return + + scaleFactor = self._windowWidth / self._captureWidth + sysCursorW = _user32_dll.GetSystemMetrics(SM_CXCURSOR) + sysCursorH = _user32_dll.GetSystemMetrics(SM_CYCURSOR) + scaledW = max(1, int(sysCursorW * scaleFactor)) + scaledH = max(1, int(sysCursorH * scaleFactor)) + + drawX = self._cursorWindowX - self._cursorHotspotX + drawY = self._cursorWindowY - self._cursorHotspotY + + _user32_dll.DrawIconEx( + hdc, + drawX, + drawY, + self._cursorHandle, + scaledW, + scaledH, + 0, + None, + DI_NORMAL, ) - self._panel = self._frame.panel - def _applyColorFilter(self, image: wx.Image, filterType: Filter) -> wx.Image: - """ - Apply color filter directly on bytes for optimal performance. + # ── GDI resource management ────────────────────────────────────────── - :param image: The image to apply the filter to - :param filterType: The filter type to apply (NORMAL, GRAYSCALE, INVERTED) - :return: The filtered image - """ - if filterType == Filter.NORMAL or not image or not image.IsOk(): - return image + def _cleanupGDI(self) -> None: + """Release the off-screen capture DC, bitmap and associated objects.""" + if self._oldCaptureBitmap and self._captureDC: + gdi32.SelectObject(self._captureDC, self._oldCaptureBitmap) + self._oldCaptureBitmap = None + if self._captureBitmap: + gdi32.DeleteObject(self._captureBitmap) + self._captureBitmap = None + if self._captureDC: + gdi32.DeleteDC(self._captureDC) + self._captureDC = None + self._captureWidth = 0 + self._captureHeight = 0 - width, height = image.GetWidth(), image.GetHeight() - data = image.GetData() # Returns RGB data as bytes + def destroy(self) -> None: + """Destroy the window and free all GDI resources.""" + self._cleanupGDI() + CustomWindow.destroy(self) - # Use bytearray for direct manipulation (faster than array.array) - rgb_data = bytearray(data) - if filterType == Filter.GRAYSCALE: - # Process 3 bytes at a time (R, G, B) - for i in range(0, len(rgb_data), 3): - r, g, b = rgb_data[i], rgb_data[i + 1], rgb_data[i + 2] - # Standard grayscale formula (ITU-R BT.601) - gray = int(0.299 * r + 0.587 * g + 0.114 * b) - rgb_data[i] = rgb_data[i + 1] = rgb_data[i + 2] = gray - - elif filterType == Filter.INVERTED: - # Invert all values in place - for i in range(len(rgb_data)): - rgb_data[i] = 255 - rgb_data[i] - - # Create new image with modified data - new_image = wx.Image(width, height) - new_image.SetData(bytes(rgb_data)) - return new_image +class WindowedMagnifier: + """Mixin for magnifiers that display content in a separate overlay window. - def _setContent(self, magnifierParameters: MagnifierParameters, zoomLevel: float) -> None: - """ - Update the magnifier panel with captured and processed content. + Uses a native Win32 overlay window (:class:`MagnifierOverlayWindow`) to + ensure the magnified view is: - :param magnifierParameters: Parameters defining what and how to capture - :param zoomLevel: The zoom magnification level to apply - """ - content = self._getContent(magnifierParameters, zoomLevel) - if self._panel: - self._panel.setContent(content) - else: - log.debug("No panel available to set content") - - def _destroyWindow(self) -> None: - """Destroy the magnifier window and clean up resources.""" - if self._frame: - self._frame.Destroy() - self._frame = None - self._panel = None + * invisible to NVDA and other accessibility tools, + * excluded from screen capture (no infinite feedback with the system magnifier), + * fully click-through. + """ - def _getContent(self, magnifierParameters: MagnifierParameters, zoomLevel: float) -> wx.Bitmap | None: - """ - Capture the screen area defined by magnifierParameters and return it as a scaled bitmap. + def __init__(self, windowMagnifierParameters: WindowMagnifierParameters): + """Create the overlay window. - :param magnifierParameters: The parameters defining the area to capture - :param zoomLevel: The zoom level to apply to the captured content - :return: A wx.Bitmap scaled to fill the panel, or None if capture fails + :param windowMagnifierParameters: Configuration for the overlay window. """ - if not self._panel: - log.warning("No panel available for capture") - return None - - panelSize = self._panel.GetSize() - panelWidth, panelHeight = panelSize.width, panelSize.height - - # Calculate the size of the area to capture based on zoom level - captureWidth = int(panelWidth / zoomLevel) - captureHeight = int(panelHeight / zoomLevel) - captureLeft = int(magnifierParameters.coordinates.x) - captureTop = int(magnifierParameters.coordinates.y) - - # Capture screen - screen = wx.ScreenDC() - bitmap = wx.Bitmap(captureWidth, captureHeight) - memoryDc = wx.MemoryDC() - memoryDc.SelectObject(bitmap) - success = memoryDc.Blit(0, 0, captureWidth, captureHeight, screen, captureLeft, captureTop) - memoryDc.SelectObject(wx.NullBitmap) - - log.debug( - f"Capture at ({captureLeft}, {captureTop}) " - f"size {captureWidth}x{captureHeight} " - f"zoom {zoomLevel}x -> panel {panelWidth}x{panelHeight}", + self.windowMagnifierParameters = windowMagnifierParameters + self._overlayWindow: MagnifierOverlayWindow | None = MagnifierOverlayWindow( + windowMagnifierParameters, ) - if success and bitmap.IsOk(): - # Convert to image - image = bitmap.ConvertToImage() + def _setContent(self, magnifierParameters: MagnifierParameters, zoomLevel: float) -> None: + """Capture screen content and display it in the overlay window. + + :param magnifierParameters: What area to capture and which filter to apply. + :param zoomLevel: Current zoom level (already factored into *magnifierParameters*). + """ + if not self._overlayWindow or not self._overlayWindow.handle: + log.debug("No overlay window available for content update") + return - image = self._applyColorFilter(image, magnifierParameters.filter) + self._overlayWindow.updateContent( + captureX=magnifierParameters.coordinates.x, + captureY=magnifierParameters.coordinates.y, + captureW=magnifierParameters.magnifierSize.width, + captureH=magnifierParameters.magnifierSize.height, + filterType=magnifierParameters.filter, + ) - # Scale image to fill the entire panel (this applies the zoom magnification) - magnifiedImage = image.Scale(panelWidth, panelHeight, wx.IMAGE_QUALITY_BICUBIC) - magnifiedBitmap = wx.Bitmap(magnifiedImage) - return magnifiedBitmap - else: - log.error( - f"Screen capture failed at ({captureLeft}, {captureTop}) size {captureWidth}x{captureHeight}", - ) - return None + def _destroyWindow(self) -> None: + """Destroy the overlay window and release all resources.""" + if self._overlayWindow: + self._overlayWindow.destroy() + self._overlayWindow = None diff --git a/tests/unit/test_magnifier/test_fixedMagnifier.py b/tests/unit/test_magnifier/test_fixedMagnifier.py index 06d900eb4a3..0f5d32474a3 100644 --- a/tests/unit/test_magnifier/test_fixedMagnifier.py +++ b/tests/unit/test_magnifier/test_fixedMagnifier.py @@ -10,19 +10,12 @@ FixedWindowPosition, ) from _magnifier.fixedMagnifier import FixedMagnifier -from _magnifier.utils.windowCreator import MagnifierFrame, WindowedMagnifier -import wx +from _magnifier.utils.windowCreator import WindowedMagnifier class TestFixedMagnifier(unittest.TestCase): """Tests for the FixedMagnifier class.""" - @classmethod - def setUpClass(cls): - """Setup that runs once for all tests.""" - if not wx.GetApp(): - cls.app = wx.App(False) - def setUp(self): """Setup before each test.""" # Mock config functions to avoid dependencies @@ -32,19 +25,23 @@ def setUp(self): "_magnifier.fixedMagnifier.getDefaultFixedWindowPosition", return_value=FixedWindowPosition.TOP_LEFT, ): - # Mock Show to prevent window from being displayed during tests - with patch.object(MagnifierFrame, "Show"): + # Mock MagnifierOverlayWindow to prevent real Win32 window creation + with patch( + "_magnifier.utils.windowCreator.MagnifierOverlayWindow", + ) as MockOverlay: + self.mockOverlayWindow = MagicMock() + self.mockOverlayWindow.handle = 12345 + MockOverlay.return_value = self.mockOverlayWindow self.magnifier = FixedMagnifier() def tearDown(self): """Cleanup after each test.""" - if hasattr(self, "magnifier") and self.magnifier._frame: - self.magnifier._frame.Destroy() + if hasattr(self, "magnifier") and self.magnifier._overlayWindow: + self.magnifier._overlayWindow = None def test_init(self): """Test initialization of FixedMagnifier.""" - self.assertIsNotNone(self.magnifier._frame) - self.assertIsNotNone(self.magnifier._panel) + self.assertIsNotNone(self.magnifier._overlayWindow) self.assertIsNotNone(self.magnifier._windowParameters) self.assertEqual(self.magnifier._currentCoordinates.x, 0) self.assertEqual(self.magnifier._currentCoordinates.y, 0) diff --git a/tests/unit/test_magnifier/test_windowCreator.py b/tests/unit/test_magnifier/test_windowCreator.py index 313222686ed..c57f7ec5eec 100644 --- a/tests/unit/test_magnifier/test_windowCreator.py +++ b/tests/unit/test_magnifier/test_windowCreator.py @@ -3,308 +3,515 @@ # 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 +import ctypes import unittest from unittest.mock import MagicMock, patch from _magnifier.utils.types import Coordinates, Size, WindowMagnifierParameters, Filter, MagnifierParameters -import wx -from _magnifier.utils.windowCreator import MagnifierPanel, MagnifierFrame, WindowedMagnifier - - -class TestMagnifierPanel(unittest.TestCase): - """Tests for the MagnifierPanel class.""" - - @classmethod - def setUpClass(cls): - """Setup that runs once for all tests.""" - if not wx.GetApp(): - cls.app = wx.App(False) - - def setUp(self): - """Set up test fixtures.""" - self.frame = wx.Frame(None) - self.panelType = "testPanel" - self.panel = MagnifierPanel(self.frame, self.panelType) - - def tearDown(self): - """Clean up after tests.""" - if self.frame: - self.frame.Destroy() - - def test_init(self): - """Test MagnifierPanel initialization.""" - self.assertEqual(self.panel.panelType, self.panelType) - self.assertEqual(self.panel.GetName(), self.panelType) - self.assertIsNone(self.panel.contentBitmap) - - def test_setContent_with_valid_bitmap(self): - """Test setContent with a valid wx.Bitmap.""" - bitmap = wx.Bitmap(100, 100) - self.panel.setContent(bitmap) - - self.assertIsNotNone(self.panel.contentBitmap) - self.assertEqual(self.panel.contentBitmap, bitmap) - - def test_setContent_with_valid_image(self): - """Test setContent with a valid wx.Image.""" - image = wx.Image(100, 100) - self.panel.setContent(image) - - self.assertIsNotNone(self.panel.contentBitmap) - self.assertIsInstance(self.panel.contentBitmap, wx.Bitmap) - - def test_setContent_with_none(self): - """Test setContent with None to clear content.""" - bitmap = wx.Bitmap(100, 100) - self.panel.setContent(bitmap) - self.assertIsNotNone(self.panel.contentBitmap) - self.panel.setContent(None) - self.assertIsNone(self.panel.contentBitmap) - - def test_setContent_with_invalid_bitmap(self): - """Test setContent with an invalid bitmap.""" - bitmap = wx.Bitmap() # Empty/invalid bitmap - self.panel.setContent(bitmap) - - self.assertIsNone(self.panel.contentBitmap) - - def test_setContent_with_invalid_image(self): - """Test setContent with an invalid image.""" - image = wx.Image() # Empty/invalid image - self.panel.setContent(image) - - self.assertIsNone(self.panel.contentBitmap) - - def test_onPaint_without_content(self): - """Test onPaint without content.""" - event = MagicMock() - mockDC = MagicMock() - - with patch("wx.PaintDC", return_value=mockDC): - self.panel.onPaint(event) - mockDC.Clear.assert_called_once() - mockDC.DrawBitmap.assert_not_called() - - def test_onPaint_with_content(self): - """Test onPaint with content.""" - bitmap = wx.Bitmap(100, 100) - self.panel.setContent(bitmap) +from _magnifier.utils.windowCreator import ( + MagnifierOverlayWindow, + WindowedMagnifier, + WM_ERASEBKGND, + CURSOR_SHOWING, + CURSORINFO, + ICONINFO, +) + + +def _makeWindowParams( + title="Test Magnifier", + width=400, + height=300, + x=100, + y=100, +): + return WindowMagnifierParameters( + title=title, + windowSize=Size(width, height), + windowPosition=Coordinates(x, y), + ) + + +def _patchOverlayCreation(): + """Return a stack of patches that prevent real Win32 window creation.""" + return [ + patch( + "_magnifier.utils.windowCreator.CustomWindow.__new__", + return_value=object.__new__(MagnifierOverlayWindow), + ), + patch("_magnifier.utils.windowCreator.CustomWindow.__init__"), + patch("_magnifier.utils.windowCreator.user32"), + patch("_magnifier.utils.windowCreator._user32_dll"), + patch("_magnifier.utils.windowCreator.gdi32"), + patch("_magnifier.utils.windowCreator._gdi32_dll"), + patch("_magnifier.utils.windowCreator.winUser"), + ] + + +class TestMagnifierOverlayWindow(unittest.TestCase): + """Tests for the MagnifierOverlayWindow class.""" + + def _createWindow(self, params=None): + """Helper to create a MagnifierOverlayWindow with all Win32 calls mocked.""" + if params is None: + params = _makeWindowParams() + patches = _patchOverlayCreation() + mocks = {} + for p in patches: + mock = p.start() + self.addCleanup(p.stop) + # Use the patch target name as key for easy access + name = p.attribute if hasattr(p, "attribute") else str(p) + mocks[name] = mock + + # Give the window a fake handle + window = MagnifierOverlayWindow(params) + window.handle = 12345 + # Re-patch user32/winUser on the window object for verification + return window, mocks + + def test_init_stores_dimensions(self): + """Window stores width and height from parameters.""" + params = _makeWindowParams(width=800, height=600) + window, _ = self._createWindow(params) + self.assertEqual(window._windowWidth, 800) + self.assertEqual(window._windowHeight, 600) + + def test_init_sets_display_affinity(self): + """SetWindowDisplayAffinity is called with WDA_EXCLUDEFROMCAPTURE.""" + params = _makeWindowParams() + window, _ = self._createWindow(params) + # The _user32_dll mock is called during __init__ + # Verify indirectly by checking the window was created without error + self.assertIsNotNone(window.handle) + + def test_init_gdi_resources_are_none(self): + """GDI capture resources start as None.""" + window, _ = self._createWindow() + self.assertIsNone(window._captureDC) + self.assertIsNone(window._captureBitmap) + self.assertIsNone(window._oldCaptureBitmap) + self.assertEqual(window._captureWidth, 0) + self.assertEqual(window._captureHeight, 0) + + def test_init_default_filter_is_normal(self): + """Default filter should be NORMAL.""" + window, _ = self._createWindow() + self.assertEqual(window._currentFilter, Filter.NORMAL) + + def test_windowProc_paint_returns_zero(self): + """WM_PAINT returns 0 after calling _paint.""" + window, _ = self._createWindow() + with patch.object(window, "_paint"): + # WM_PAINT = 0x000F + result = window.windowProc(window.handle, 0x000F, 0, 0) + self.assertEqual(result, 0) + window._paint.assert_called_once() + + def test_windowProc_erasebkgnd_returns_one(self): + """WM_ERASEBKGND returns 1 to prevent flicker.""" + window, _ = self._createWindow() + result = window.windowProc(window.handle, WM_ERASEBKGND, 0, 0) + self.assertEqual(result, 1) + + def test_windowProc_destroy_cleans_gdi(self): + """WM_DESTROY triggers GDI cleanup.""" + window, _ = self._createWindow() + with patch.object(window, "_cleanupGDI") as mockCleanup: + # WM_DESTROY = 2 + result = window.windowProc(window.handle, 2, 0, 0) + self.assertEqual(result, 0) + mockCleanup.assert_called_once() + + def test_windowProc_unknown_msg_returns_none(self): + """Unknown messages return None for DefWindowProc.""" + window, _ = self._createWindow() + result = window.windowProc(window.handle, 0x9999, 0, 0) + self.assertIsNone(result) - event = MagicMock() + def test_updateContent_skips_invalid_size(self): + """updateContent does nothing for zero or negative capture dimensions.""" + window, _ = self._createWindow() + with patch.object(window, "_cleanupGDI") as mockCleanup: + window.updateContent(0, 0, 0, 100) + window.updateContent(0, 0, 100, -1) + mockCleanup.assert_not_called() + + def test_updateContent_creates_capture_dc(self): + """First call to updateContent creates the capture DC and bitmap.""" + window, _ = self._createWindow() + mockGdi32 = MagicMock() + mockUser32 = MagicMock() + + with ( + patch("_magnifier.utils.windowCreator.gdi32", mockGdi32), + patch("_magnifier.utils.windowCreator.user32", mockUser32), + ): + window.updateContent(10, 20, 200, 150) + + # Screen DC obtained and released + mockUser32.GetDC.assert_called_once_with(0) + mockUser32.ReleaseDC.assert_called_once() + # Capture DC created + mockGdi32.CreateCompatibleDC.assert_called_once() + mockGdi32.CreateCompatibleBitmap.assert_called_once() + mockGdi32.SelectObject.assert_called_once() + # StretchBlt to capture + mockGdi32.StretchBlt.assert_called_once() + # Dimensions stored + self.assertEqual(window._captureWidth, 200) + self.assertEqual(window._captureHeight, 150) + + def test_updateContent_reuses_dc_on_same_size(self): + """Subsequent calls with the same size reuse the existing capture DC.""" + window, _ = self._createWindow() + mockGdi32 = MagicMock() + mockUser32 = MagicMock() + # Pre-set capture dimensions to match + window._captureWidth = 200 + window._captureHeight = 150 + window._captureDC = MagicMock() + window._captureBitmap = MagicMock() + + with ( + patch("_magnifier.utils.windowCreator.gdi32", mockGdi32), + patch("_magnifier.utils.windowCreator.user32", mockUser32), + ): + window.updateContent(10, 20, 200, 150) + + # Should NOT recreate DC + mockGdi32.CreateCompatibleDC.assert_not_called() + # But should still StretchBlt + mockGdi32.StretchBlt.assert_called_once() + + def test_updateContent_sets_filter(self): + """updateContent stores the requested filter type.""" + window, _ = self._createWindow() + with ( + patch("_magnifier.utils.windowCreator.gdi32"), + patch("_magnifier.utils.windowCreator.user32"), + ): + window.updateContent(0, 0, 100, 100, Filter.INVERTED) + self.assertEqual(window._currentFilter, Filter.INVERTED) + + def test_updateContent_grayscale_calls_filter(self): + """updateContent with GRAYSCALE calls _applyGrayscaleFilter.""" + window, _ = self._createWindow() + with ( + patch("_magnifier.utils.windowCreator.gdi32"), + patch("_magnifier.utils.windowCreator.user32"), + patch.object(window, "_applyGrayscaleFilter") as mockFilter, + ): + window.updateContent(0, 0, 100, 100, Filter.GRAYSCALE) + mockFilter.assert_called_once() + + def test_updateContent_inverted_does_not_call_dib(self): + """updateContent with INVERTED does NOT call _applyGrayscaleFilter.""" + window, _ = self._createWindow() + with ( + patch("_magnifier.utils.windowCreator.gdi32"), + patch("_magnifier.utils.windowCreator.user32"), + patch.object(window, "_applyGrayscaleFilter") as mockFilter, + ): + window.updateContent(0, 0, 100, 100, Filter.INVERTED) + mockFilter.assert_not_called() + + def test_cleanupGDI_releases_resources(self): + """_cleanupGDI properly releases DC and bitmap.""" + window, _ = self._createWindow() mockDC = MagicMock() - - with patch("wx.PaintDC", return_value=mockDC): - self.panel.onPaint(event) - mockDC.Clear.assert_called_once() - mockDC.DrawBitmap.assert_called_once_with(bitmap, 0, 0) - - -class TestMagnifierFrame(unittest.TestCase): - """Tests for the MagnifierFrame class.""" - - @classmethod - def setUpClass(cls): - """Setup that runs once for all tests.""" - if not wx.GetApp(): - cls.app = wx.App(False) - - def setUp(self): - """Set up test fixtures.""" - windowMagnifierParams = WindowMagnifierParameters( - title="Test Magnifier", - windowSize=Size(width=400, height=300), - windowPosition=Coordinates(x=100, y=100), - styles=wx.DEFAULT_FRAME_STYLE, - ) - self.frame = MagnifierFrame( - title="Test Frame", - frameType="testFrame", - screenSize=Size(width=1920, height=1080), - windowMagnifierParameters=windowMagnifierParams, - ) - # Hide the frame to prevent it from being displayed during tests - self.frame.Hide() - - def tearDown(self): - """Clean up after tests.""" - if self.frame: - self.frame.Destroy() - - def test_init(self): - """Test MagnifierFrame initialization.""" - self.assertEqual(self.frame.frameType, "testFrame") - self.assertIsNotNone(self.frame.screenSize) - self.assertEqual(self.frame.screenSize.width, 1920) - self.assertEqual(self.frame.screenSize.height, 1080) - self.assertIsNotNone(self.frame.windowMagnifierParameters) - self.assertIsNotNone(self.frame.panel) - self.assertIsInstance(self.frame.panel, MagnifierPanel) - self.assertEqual(self.frame.GetSize().GetWidth(), 400) - self.assertEqual(self.frame.GetSize().GetHeight(), 300) - self.assertEqual(self.frame.GetPosition().x, 100) - self.assertEqual(self.frame.GetPosition().y, 100) - - def test_createPanel(self): - """Test createPanel method.""" - panel = self.frame.createPanel() - self.assertIsNotNone(panel) - self.assertIsInstance(panel, MagnifierPanel) - self.assertEqual(panel.panelType, "testFrame") - - def test_updateFrameContent(self): - """Test updateFrameContent method.""" - bitmap = wx.Bitmap(200, 150) - self.frame.updateFrameContent(bitmap) - self.assertEqual(self.frame.panel.contentBitmap, bitmap) - - image = wx.Image(200, 150) - self.frame.updateFrameContent(image) - self.assertIsNotNone(self.frame.panel.contentBitmap) - self.assertIsInstance(self.frame.panel.contentBitmap, wx.Bitmap) - - self.frame.updateFrameContent(None) - self.assertIsNone(self.frame.panel.contentBitmap) + mockBitmap = MagicMock() + mockOldBitmap = MagicMock() + window._captureDC = mockDC + window._captureBitmap = mockBitmap + window._oldCaptureBitmap = mockOldBitmap + window._captureWidth = 100 + window._captureHeight = 100 + + with patch("_magnifier.utils.windowCreator.gdi32") as mockGdi32: + window._cleanupGDI() + + mockGdi32.SelectObject.assert_called_once_with(mockDC, mockOldBitmap) + mockGdi32.DeleteObject.assert_called_once_with(mockBitmap) + mockGdi32.DeleteDC.assert_called_once_with(mockDC) + self.assertIsNone(window._captureDC) + self.assertIsNone(window._captureBitmap) + self.assertIsNone(window._oldCaptureBitmap) + self.assertEqual(window._captureWidth, 0) + self.assertEqual(window._captureHeight, 0) + + def test_cleanupGDI_noop_when_empty(self): + """_cleanupGDI is safe to call with no GDI resources.""" + window, _ = self._createWindow() + with patch("_magnifier.utils.windowCreator.gdi32") as mockGdi32: + window._cleanupGDI() # Should not raise + mockGdi32.SelectObject.assert_not_called() + mockGdi32.DeleteObject.assert_not_called() + mockGdi32.DeleteDC.assert_not_called() + + def test_destroy_cleans_gdi_then_calls_super(self): + """destroy() cleans GDI before delegating to CustomWindow.destroy.""" + window, _ = self._createWindow() + callOrder = [] + with ( + patch.object(window, "_cleanupGDI", side_effect=lambda: callOrder.append("gdi")), + patch( + "_magnifier.utils.windowCreator.CustomWindow.destroy", + side_effect=lambda s: callOrder.append("super"), + ), + ): + window.destroy() + self.assertEqual(callOrder, ["gdi", "super"]) + + +class TestMagnifierOverlayWindowCursor(unittest.TestCase): + """Tests for the cursor snapshot and painting logic.""" + + def _createWindow(self): + params = _makeWindowParams(width=400, height=300) + patches = _patchOverlayCreation() + for p in patches: + p.start() + self.addCleanup(p.stop) + window = MagnifierOverlayWindow(params) + window.handle = 12345 + return window + + def test_snapshotCursor_invisible_cursor_sets_handle_none(self): + """Cursor not showing → handle is cleared.""" + window = self._createWindow() + ci = CURSORINFO() + ci.flags = 0 # CURSOR_SHOWING not set + with patch("_magnifier.utils.windowCreator._user32_dll") as mockU: + mockU.GetCursorInfo.side_effect = lambda p: ( + ctypes.memmove(p, ctypes.byref(ci), ctypes.sizeof(CURSORINFO)), + True, + )[1] + window._snapshotCursor(0, 0, 1920, 1080) + self.assertIsNone(window._cursorHandle) + + def test_snapshotCursor_cursor_outside_capture_area(self): + """Cursor outside the capture region → handle is cleared.""" + window = self._createWindow() + ci = CURSORINFO() + ci.flags = CURSOR_SHOWING + ci.ptScreenPos.x = 950 # outside captureX=100..600 + ci.ptScreenPos.y = 600 + + def fake_get_cursor_info(ptr): + ctypes.memmove(ptr, ctypes.byref(ci), ctypes.sizeof(CURSORINFO)) + return True + + with patch("_magnifier.utils.windowCreator._user32_dll") as mockU: + mockU.GetCursorInfo.side_effect = fake_get_cursor_info + window._snapshotCursor(captureX=100, captureY=100, captureW=500, captureH=400) + + self.assertIsNone(window._cursorHandle) + + def test_snapshotCursor_cursor_inside_capture_area(self): + """Cursor inside the capture region → window coordinates are computed.""" + window = self._createWindow() + # Window = 400×300, capture = 200×150 → scale = 2 + ci = CURSORINFO() + ci.flags = CURSOR_SHOWING + # captureX=0, captureY=0, captureW=200, captureH=150 + # cursor at (100, 75) → rel (100, 75) → window (200, 150) + ci.ptScreenPos.x = 100 + ci.ptScreenPos.y = 75 + ci.hCursor = 0xABCD + + def fake_get_cursor_info(ptr): + ctypes.memmove(ptr, ctypes.byref(ci), ctypes.sizeof(CURSORINFO)) + return True + + ii = ICONINFO() + ii.xHotspot = 5 # → scaled = 10 + ii.yHotspot = 2 # → scaled = 4 + + def fake_get_icon_info(hcursor, ptr): + ctypes.memmove(ptr, ctypes.byref(ii), ctypes.sizeof(ICONINFO)) + return True + + with ( + patch("_magnifier.utils.windowCreator._user32_dll") as mockU, + ): + mockU.GetCursorInfo.side_effect = fake_get_cursor_info + mockU.GetIconInfo.side_effect = fake_get_icon_info + window._snapshotCursor(captureX=0, captureY=0, captureW=200, captureH=150) + + self.assertEqual(window._cursorHandle, 0xABCD) + self.assertEqual(window._cursorWindowX, 200) + self.assertEqual(window._cursorWindowY, 150) + self.assertEqual(window._cursorHotspotX, 10) + self.assertEqual(window._cursorHotspotY, 4) + + def test_snapshotCursor_frees_icon_bitmaps(self): + """GetIconInfo bitmaps are freed after hotspot extraction.""" + window = self._createWindow() + ci = CURSORINFO() + ci.flags = CURSOR_SHOWING + ci.ptScreenPos.x = 50 + ci.ptScreenPos.y = 50 + ci.hCursor = 0x1234 + + def fake_get_cursor_info(ptr): + ctypes.memmove(ptr, ctypes.byref(ci), ctypes.sizeof(CURSORINFO)) + return True + + ii = ICONINFO() + ii.hbmMask = 0xAAAA + ii.hbmColor = 0xBBBB + + def fake_get_icon_info(hcursor, ptr): + ctypes.memmove(ptr, ctypes.byref(ii), ctypes.sizeof(ICONINFO)) + return True + + with ( + patch("_magnifier.utils.windowCreator._user32_dll") as mockU, + patch("_magnifier.utils.windowCreator.gdi32") as mockGdi, + ): + mockU.GetCursorInfo.side_effect = fake_get_cursor_info + mockU.GetIconInfo.side_effect = fake_get_icon_info + window._snapshotCursor(captureX=0, captureY=0, captureW=400, captureH=300) + # Both bitmaps from GetIconInfo must be deleted + deleteObjectCalls = [args[0] for args, _ in mockGdi.DeleteObject.call_args_list] + self.assertIn(ii.hbmMask, deleteObjectCalls) + self.assertIn(ii.hbmColor, deleteObjectCalls) + + def test_paintCursor_noop_when_no_handle(self): + """_paintCursor does nothing when _cursorHandle is None.""" + window = self._createWindow() + window._cursorHandle = None + with patch("_magnifier.utils.windowCreator._user32_dll") as mockU: + window._paintCursor(0xDEAD) + mockU.DrawIconEx.assert_not_called() + + def test_paintCursor_calls_draw_icon_ex(self): + """_paintCursor calls DrawIconEx with scaled cursor dimensions.""" + window = self._createWindow() + # Window 400×300, capture 200×150 → scale = 2 + window._captureWidth = 200 + window._captureHeight = 150 + window._cursorHandle = 0xBEEF + window._cursorWindowX = 100 + window._cursorWindowY = 80 + window._cursorHotspotX = 4 + window._cursorHotspotY = 2 + + sysCursorW, sysCursorH = 32, 32 # system default cursor size + + with patch("_magnifier.utils.windowCreator._user32_dll") as mockU: + mockU.GetSystemMetrics.side_effect = lambda idx: sysCursorW if idx == 13 else sysCursorH + window._paintCursor(0xCAFE) + + mockU.DrawIconEx.assert_called_once() + args = mockU.DrawIconEx.call_args[0] + hdc, drawX, drawY, hCursor, scaledW, scaledH = ( + args[0], + args[1], + args[2], + args[3], + args[4], + args[5], + ) + self.assertEqual(hdc, 0xCAFE) + self.assertEqual(hCursor, 0xBEEF) + # draw pos = window pos – hotspot + self.assertEqual(drawX, 100 - 4) + self.assertEqual(drawY, 80 - 2) + # scale = 400/200 = 2 → 32*2 = 64 + self.assertEqual(scaledW, 64) + self.assertEqual(scaledH, 64) + + def test_updateContent_calls_snapshot_cursor(self): + """updateContent triggers _snapshotCursor with the capture coordinates.""" + window = self._createWindow() + window._captureWidth = 100 # pre-set to skip DC recreation + window._captureHeight = 100 + window._captureDC = MagicMock() + window._captureBitmap = MagicMock() + + with ( + patch("_magnifier.utils.windowCreator.gdi32"), + patch("_magnifier.utils.windowCreator.user32"), + patch.object(window, "_snapshotCursor") as mockSnap, + ): + window.updateContent(10, 20, 100, 100, Filter.NORMAL) + mockSnap.assert_called_once_with(10, 20, 100, 100) class TestWindowedMagnifier(unittest.TestCase): - """Tests for the WindowedMagnifier class.""" - - @classmethod - def setUpClass(cls): - """Setup that runs once for all tests.""" - if not wx.GetApp(): - cls.app = wx.App(False) + """Tests for the WindowedMagnifier mixin.""" def setUp(self): - """Set up test fixtures.""" - windowMagnifierParams = WindowMagnifierParameters( - title="Test WindowedMagnifier", - windowSize=Size(400, 300), - windowPosition=Coordinates(100, 100), - styles=wx.DEFAULT_FRAME_STYLE, - ) - # Mock Show to prevent window from being displayed during tests - with patch.object(MagnifierFrame, "Show"): - self.magnifier = WindowedMagnifier(windowMagnifierParams) - - def tearDown(self): - """Clean up after tests.""" - if self.magnifier and self.magnifier._frame: - self.magnifier._frame.Destroy() - - def test_init(self): - """Test WindowedMagnifier initialization.""" - self.assertIsNotNone(self.magnifier.windowMagnifierParameters) - self.assertIsNotNone(self.magnifier._frame) - self.assertIsNotNone(self.magnifier._panel) - self.assertIsInstance(self.magnifier._frame, MagnifierFrame) - self.assertIsInstance(self.magnifier._panel, MagnifierPanel) - self.assertEqual(self.magnifier._frame.frameType, "magnifier") - self.assertEqual(self.magnifier._panel, self.magnifier._frame.panel) - - def test_applyColorFilter_normal(self): - """Test _applyColorFilter with NORMAL filter.""" - image = wx.Image(100, 100) - image.SetRGB(wx.Rect(0, 0, 100, 100), 255, 0, 0) # Red image - - result = self.magnifier._applyColorFilter(image, Filter.NORMAL) - self.assertEqual(result, image) # Should return same image - - def test_applyColorFilter_grayscale(self): - """Test _applyColorFilter with GRAYSCALE filter.""" - image = wx.Image(100, 100) - image.SetRGB(wx.Rect(0, 0, 100, 100), 255, 0, 0) # Red image - - result = self.magnifier._applyColorFilter(image, Filter.GRAYSCALE) - self.assertIsNotNone(result) - self.assertTrue(result.IsOk()) - # Check that the first pixel is grayscale (R=G=B) - rgb = result.GetRed(0, 0), result.GetGreen(0, 0), result.GetBlue(0, 0) - self.assertEqual(rgb[0], rgb[1]) - self.assertEqual(rgb[1], rgb[2]) - - def test_applyColorFilter_inverted(self): - """Test _applyColorFilter with INVERTED filter.""" - image = wx.Image(100, 100) - image.SetRGB(wx.Rect(0, 0, 100, 100), 255, 0, 0) # Red image - - result = self.magnifier._applyColorFilter(image, Filter.INVERTED) - self.assertIsNotNone(result) - self.assertTrue(result.IsOk()) - # Check that colors are inverted (255-R, 255-G, 255-B) - rgb = result.GetRed(0, 0), result.GetGreen(0, 0), result.GetBlue(0, 0) - self.assertEqual(rgb[0], 0) # 255-255 = 0 - self.assertEqual(rgb[1], 255) # 255-0 = 255 - self.assertEqual(rgb[2], 255) # 255-0 = 255 - - def test_applyColorFilter_invalid_image(self): - """Test _applyColorFilter with invalid image.""" - image = wx.Image() # Invalid/empty image - result = self.magnifier._applyColorFilter(image, Filter.GRAYSCALE) - self.assertEqual(result, image) # Should return same invalid image - - def test_setContent(self): - """Test _setContent method.""" + """Create a WindowedMagnifier with a mocked MagnifierOverlayWindow.""" + self.params = _makeWindowParams() + with patch( + "_magnifier.utils.windowCreator.MagnifierOverlayWindow", + ) as MockOverlay: + self.mockWindow = MagicMock() + self.mockWindow.handle = 12345 + MockOverlay.return_value = self.mockWindow + self.magnifier = WindowedMagnifier(self.params) + + def test_init_creates_overlay(self): + """WindowedMagnifier creates a MagnifierOverlayWindow.""" + self.assertIsNotNone(self.magnifier._overlayWindow) + self.assertEqual(self.magnifier._overlayWindow, self.mockWindow) + + def test_init_stores_params(self): + """WindowedMagnifier stores the window parameters.""" + self.assertEqual(self.magnifier.windowMagnifierParameters, self.params) + + def test_setContent_delegates_to_overlay(self): + """_setContent calls updateContent on the overlay window.""" magnifierParams = MagnifierParameters( magnifierSize=Size(200, 150), - coordinates=Coordinates(0, 0), - filter=Filter.NORMAL, + coordinates=Coordinates(10, 20), + filter=Filter.INVERTED, ) + self.magnifier._setContent(magnifierParams, 2.0) - # Mock _getContent to return a bitmap - bitmap = wx.Bitmap(100, 100) - with patch.object(self.magnifier, "_getContent", return_value=bitmap): - self.magnifier._setContent(magnifierParams, 2.0) - self.assertEqual(self.magnifier._panel.contentBitmap, bitmap) + self.mockWindow.updateContent.assert_called_once_with( + captureX=10, + captureY=20, + captureW=200, + captureH=150, + filterType=Filter.INVERTED, + ) - def test_setContent_no_panel(self): - """Test _setContent when panel is None.""" + def test_setContent_noop_when_no_window(self): + """_setContent does nothing if the overlay window is destroyed.""" + self.magnifier._overlayWindow = None magnifierParams = MagnifierParameters( magnifierSize=Size(200, 150), coordinates=Coordinates(0, 0), filter=Filter.NORMAL, ) - - # Set panel to None - self.magnifier._panel = None - - # Should not raise error + # Should not raise self.magnifier._setContent(magnifierParams, 2.0) - def test_destroyWindow(self): - """Test _destroyWindow method.""" - self.assertIsNotNone(self.magnifier._frame) - self.assertIsNotNone(self.magnifier._panel) - - self.magnifier._destroyWindow() - - self.assertIsNone(self.magnifier._frame) - self.assertIsNone(self.magnifier._panel) - - def test_getContent(self): - """Test _getContent method.""" + def test_setContent_noop_when_no_handle(self): + """_setContent does nothing if the overlay window handle is None.""" + self.mockWindow.handle = None magnifierParams = MagnifierParameters( magnifierSize=Size(200, 150), coordinates=Coordinates(0, 0), filter=Filter.NORMAL, ) + self.magnifier._setContent(magnifierParams, 2.0) + self.mockWindow.updateContent.assert_not_called() - result = self.magnifier._getContent(magnifierParams, 2.0) - - # Should return a bitmap or None - if result is not None: - self.assertIsInstance(result, wx.Bitmap) - self.assertTrue(result.IsOk()) - - def test_getContent_no_panel(self): - """Test _getContent when panel is None.""" - magnifierParams = MagnifierParameters( - magnifierSize=Size(200, 150), - coordinates=Coordinates(0, 0), - filter=Filter.NORMAL, - ) + def test_destroyWindow_calls_destroy(self): + """_destroyWindow calls destroy() on the overlay and sets it to None.""" + self.magnifier._destroyWindow() - # Set panel to None - self.magnifier._panel = None + self.mockWindow.destroy.assert_called_once() + self.assertIsNone(self.magnifier._overlayWindow) - result = self.magnifier._getContent(magnifierParams, 2.0) - self.assertIsNone(result) + def test_destroyWindow_noop_when_already_destroyed(self): + """_destroyWindow is safe to call twice.""" + self.magnifier._destroyWindow() + # Second call should not raise + self.magnifier._destroyWindow() + # destroy only called once + self.mockWindow.destroy.assert_called_once() From 5bcad4022d20bd653b3cabca03cd4cb31032f81b Mon Sep 17 00:00:00 2001 From: Antoine HAFFREINGUE Date: Tue, 10 Mar 2026 10:11:26 +0100 Subject: [PATCH 19/21] continuing changes --- source/_magnifier/utils/filterHandler.py | 140 ++++++++++++++++++ source/_magnifier/utils/windowCreator.py | 96 +++--------- .../unit/test_magnifier/test_windowCreator.py | 14 +- 3 files changed, 168 insertions(+), 82 deletions(-) diff --git a/source/_magnifier/utils/filterHandler.py b/source/_magnifier/utils/filterHandler.py index 0da53b68195..02be88bf9f1 100644 --- a/source/_magnifier/utils/filterHandler.py +++ b/source/_magnifier/utils/filterHandler.py @@ -3,9 +3,39 @@ # 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 +""" +Filter handler for the magnifier module. + +Provides: +- :class:`FilterMatrix` – colour-effect matrices for the fullscreen Magnification API. +- :func:`applyBitmapFilter` – per-pixel filter applied to a GDI bitmap (windowed magnifiers). +- :func:`getBlitRasterOp` – raster-operation code to use when blitting. +""" + +import ctypes +import ctypes.wintypes + from enum import Enum +from typing import Callable + +import winGDI +import winBindings.gdi32 as gdi32 from winBindings.magnification import MAGCOLOREFFECT +from .types import Filter + +_gdi32_dll = ctypes.windll.gdi32 +_gdi32_dll.SetDIBits.argtypes = [ + ctypes.wintypes.HDC, + ctypes.wintypes.HBITMAP, + ctypes.c_uint, + ctypes.c_uint, + ctypes.c_void_p, + ctypes.c_void_p, + ctypes.c_uint, +] +_gdi32_dll.SetDIBits.restype = ctypes.c_int + def _createColorEffect( matrix: tuple, @@ -108,3 +138,113 @@ class FilterMatrix(Enum): 1.0, ), ) + + +def applyBitmapFilter( + filterType: Filter, + captureDC, + captureBitmap, + width: int, + height: int, +) -> None: + """Apply a colour filter to a captured GDI bitmap in-place. + + Filters that require per-pixel manipulation (e.g. grayscale, inverted) + are applied here. + + :param filterType: The colour filter to apply. + :param captureDC: The device context that owns *captureBitmap*. + :param captureBitmap: The bitmap handle to modify. + :param width: Bitmap width in pixels. + :param height: Bitmap height in pixels. + """ + if filterType == Filter.GRAYSCALE: + _applyGrayscale(captureDC, captureBitmap, width, height) + elif filterType == Filter.INVERTED: + _applyInverted(captureDC, captureBitmap, width, height) + + +def getBlitRasterOp(filterType: Filter) -> int: + """Return the GDI raster-operation code to use when blitting for *filterType*. + + :param filterType: The active colour filter. + :return: ``SRCCOPY`` – all filters are now applied at bitmap level. + """ + return winGDI.SRCCOPY + + +def _applyDIBTransform( + captureDC, + captureBitmap, + width: int, + height: int, + transform: Callable[[bytearray, int], None], +) -> None: + """Read a GDI bitmap into a bytearray, apply *transform* to each BGRA pixel, then write it back. + + :param captureDC: Device context owning *captureBitmap*. + :param captureBitmap: Bitmap handle to modify in-place. + :param width: Bitmap width in pixels. + :param height: Bitmap height in pixels. + :param transform: Callable ``(data, i)`` that modifies ``data[i:i+3]`` (BGR channels) + for the pixel starting at byte offset *i*. Alpha (``data[i+3]``) is + left unchanged unless the callable explicitly modifies it. + """ + numPixels = width * height + bufferSize = numPixels * 4 + + bmInfo = gdi32.BITMAPINFO() + bmInfo.bmiHeader.biSize = ctypes.sizeof(gdi32.BITMAPINFO) + bmInfo.bmiHeader.biWidth = width + bmInfo.bmiHeader.biHeight = -height # top-down + bmInfo.bmiHeader.biPlanes = 1 + bmInfo.bmiHeader.biBitCount = 32 + bmInfo.bmiHeader.biCompression = winGDI.BI_RGB + + buffer = (ctypes.c_ubyte * bufferSize)() + gdi32.GetDIBits( + captureDC, + captureBitmap, + 0, + height, + buffer, + ctypes.byref(bmInfo), + winGDI.DIB_RGB_COLORS, + ) + + data = bytearray(buffer) + for i in range(0, bufferSize, 4): + transform(data, i) + + ctypes.memmove(buffer, (ctypes.c_char * bufferSize).from_buffer(data), bufferSize) + _gdi32_dll.SetDIBits( + captureDC, + captureBitmap, + 0, + height, + buffer, + ctypes.byref(bmInfo), + winGDI.DIB_RGB_COLORS, + ) + + +def _applyGrayscale(captureDC, captureBitmap, width: int, height: int) -> None: + """Convert a GDI bitmap to grayscale (ITU-R BT.601: 77R + 150G + 29B).""" + + def _transform(data: bytearray, i: int) -> None: + b, g, r = data[i], data[i + 1], data[i + 2] + gray = (77 * r + 150 * g + 29 * b) >> 8 + data[i] = data[i + 1] = data[i + 2] = gray + + _applyDIBTransform(captureDC, captureBitmap, width, height, _transform) + + +def _applyInverted(captureDC, captureBitmap, width: int, height: int) -> None: + """Invert the colour channels of a GDI bitmap (alpha preserved).""" + + def _transform(data: bytearray, i: int) -> None: + data[i] = 255 - data[i] + data[i + 1] = 255 - data[i + 1] + data[i + 2] = 255 - data[i + 2] + + _applyDIBTransform(captureDC, captureBitmap, width, height, _transform) diff --git a/source/_magnifier/utils/windowCreator.py b/source/_magnifier/utils/windowCreator.py index bce5421f7e9..f636b5d8362 100644 --- a/source/_magnifier/utils/windowCreator.py +++ b/source/_magnifier/utils/windowCreator.py @@ -23,19 +23,18 @@ from windowUtils import CustomWindow from .types import MagnifierParameters, WindowMagnifierParameters, Filter +from .filterHandler import applyBitmapFilter, getBlitRasterOp #: Window Display Affinity: exclude from screen capture (Windows 10 2004+) WDA_EXCLUDEFROMCAPTURE: int = 0x00000011 #: WM_PAINT message -WM_PAINT: int = 0x000F +WM_PAINT: int = winUser.WM_PAINT #: WM_DESTROY message -WM_DESTROY: int = 0x0002 +WM_DESTROY: int = winUser.WM_DESTROY #: WM_ERASEBKGND message WM_ERASEBKGND: int = 0x0014 #: SetStretchBltMode: high-quality image stretching mode HALFTONE: int = 4 -#: BitBlt raster-op: copy inverted source to destination -NOTSRCCOPY: int = 0x00330008 _user32_dll = ctypes.windll.user32 _gdi32_dll = ctypes.windll.gdi32 @@ -46,17 +45,6 @@ _gdi32_dll.SetStretchBltMode.argtypes = [ctypes.wintypes.HDC, ctypes.c_int] _gdi32_dll.SetStretchBltMode.restype = ctypes.c_int -_gdi32_dll.SetDIBits.argtypes = [ - ctypes.wintypes.HDC, - ctypes.wintypes.HBITMAP, - ctypes.c_uint, - ctypes.c_uint, - ctypes.c_void_p, - ctypes.c_void_p, - ctypes.c_uint, -] -_gdi32_dll.SetDIBits.restype = ctypes.c_int - #: DrawIconEx flag: draw cursor with its normal mask and colour DI_NORMAL: int = 0x0003 #: CURSORINFO.flags value: the cursor is showing @@ -253,10 +241,7 @@ def updateContent( finally: user32.ReleaseDC(0, screenDC) - # Grayscale requires per-pixel DIB manipulation. - # Inverted is handled at zero cost in _paint() via the NOTSRCCOPY raster-op. - if filterType == Filter.GRAYSCALE: - self._applyGrayscaleFilter() + applyBitmapFilter(filterType, self._captureDC, self._captureBitmap, captureW, captureH) self._currentFilter = filterType @@ -271,8 +256,7 @@ def _paint(self) -> None: with winUser.paint(self.handle) as hdc: if self._captureDC and self._captureWidth > 0 and self._captureHeight > 0: _gdi32_dll.SetStretchBltMode(hdc, HALFTONE) - # NOTSRCCOPY inverts all pixels during the blit – free colour inversion - rop = NOTSRCCOPY if self._currentFilter == Filter.INVERTED else winGDI.SRCCOPY + rop = getBlitRasterOp(self._currentFilter) gdi32.StretchBlt( hdc, 0, @@ -289,49 +273,6 @@ def _paint(self) -> None: # Draw the cursor on top of the magnified content self._paintCursor(hdc) - def _applyGrayscaleFilter(self) -> None: - """Convert the captured bitmap to grayscale via direct DIB byte manipulation.""" - w, h = self._captureWidth, self._captureHeight - numPixels = w * h - - bmInfo = gdi32.BITMAPINFO() - bmInfo.bmiHeader.biSize = ctypes.sizeof(gdi32.BITMAPINFO) - bmInfo.bmiHeader.biWidth = w - bmInfo.bmiHeader.biHeight = -h # top-down - bmInfo.bmiHeader.biPlanes = 1 - bmInfo.bmiHeader.biBitCount = 32 - bmInfo.bmiHeader.biCompression = winGDI.BI_RGB - - bufferSize = numPixels * 4 - buffer = (ctypes.c_ubyte * bufferSize)() - gdi32.GetDIBits( - self._captureDC, - self._captureBitmap, - 0, - h, - buffer, - ctypes.byref(bmInfo), - winGDI.DIB_RGB_COLORS, - ) - - # Process BGRA pixels with fixed-point ITU-R BT.601 coefficients - data = bytearray(buffer) - for i in range(0, bufferSize, 4): - b, g, r = data[i], data[i + 1], data[i + 2] - gray = (77 * r + 150 * g + 29 * b) >> 8 - data[i] = data[i + 1] = data[i + 2] = gray - - ctypes.memmove(buffer, (ctypes.c_char * bufferSize).from_buffer(data), bufferSize) - _gdi32_dll.SetDIBits( - self._captureDC, - self._captureBitmap, - 0, - h, - buffer, - ctypes.byref(bmInfo), - winGDI.DIB_RGB_COLORS, - ) - def _snapshotCursor( self, captureX: int, @@ -410,26 +351,29 @@ def _paintCursor(self, hdc) -> None: DI_NORMAL, ) - # ── GDI resource management ────────────────────────────────────────── - def _cleanupGDI(self) -> None: """Release the off-screen capture DC, bitmap and associated objects.""" - if self._oldCaptureBitmap and self._captureDC: - gdi32.SelectObject(self._captureDC, self._oldCaptureBitmap) - self._oldCaptureBitmap = None - if self._captureBitmap: - gdi32.DeleteObject(self._captureBitmap) - self._captureBitmap = None - if self._captureDC: - gdi32.DeleteDC(self._captureDC) - self._captureDC = None + try: + if self._oldCaptureBitmap and self._captureDC: + gdi32.SelectObject(self._captureDC, self._oldCaptureBitmap) + self._oldCaptureBitmap = None + if self._captureBitmap: + gdi32.DeleteObject(self._captureBitmap) + self._captureBitmap = None + if self._captureDC: + gdi32.DeleteDC(self._captureDC) + self._captureDC = None + except (ctypes.ArgumentError, OSError): + # Guard against invalid handles (e.g. mock objects during tests) + pass self._captureWidth = 0 self._captureHeight = 0 def destroy(self) -> None: """Destroy the window and free all GDI resources.""" self._cleanupGDI() - CustomWindow.destroy(self) + if hasattr(self, "_classAtom"): + CustomWindow.destroy(self) class WindowedMagnifier: diff --git a/tests/unit/test_magnifier/test_windowCreator.py b/tests/unit/test_magnifier/test_windowCreator.py index c57f7ec5eec..1aa83cdeda5 100644 --- a/tests/unit/test_magnifier/test_windowCreator.py +++ b/tests/unit/test_magnifier/test_windowCreator.py @@ -189,31 +189,32 @@ def test_updateContent_sets_filter(self): with ( patch("_magnifier.utils.windowCreator.gdi32"), patch("_magnifier.utils.windowCreator.user32"), + patch("_magnifier.utils.windowCreator.applyBitmapFilter"), ): window.updateContent(0, 0, 100, 100, Filter.INVERTED) self.assertEqual(window._currentFilter, Filter.INVERTED) def test_updateContent_grayscale_calls_filter(self): - """updateContent with GRAYSCALE calls _applyGrayscaleFilter.""" + """updateContent with GRAYSCALE calls applyBitmapFilter.""" window, _ = self._createWindow() with ( patch("_magnifier.utils.windowCreator.gdi32"), patch("_magnifier.utils.windowCreator.user32"), - patch.object(window, "_applyGrayscaleFilter") as mockFilter, + patch("_magnifier.utils.windowCreator.applyBitmapFilter") as mockFilter, ): window.updateContent(0, 0, 100, 100, Filter.GRAYSCALE) mockFilter.assert_called_once() - def test_updateContent_inverted_does_not_call_dib(self): - """updateContent with INVERTED does NOT call _applyGrayscaleFilter.""" + def test_updateContent_inverted_calls_filter(self): + """updateContent with INVERTED calls applyBitmapFilter for pixel inversion.""" window, _ = self._createWindow() with ( patch("_magnifier.utils.windowCreator.gdi32"), patch("_magnifier.utils.windowCreator.user32"), - patch.object(window, "_applyGrayscaleFilter") as mockFilter, + patch("_magnifier.utils.windowCreator.applyBitmapFilter") as mockFilter, ): window.updateContent(0, 0, 100, 100, Filter.INVERTED) - mockFilter.assert_not_called() + mockFilter.assert_called_once() def test_cleanupGDI_releases_resources(self): """_cleanupGDI properly releases DC and bitmap.""" @@ -251,6 +252,7 @@ def test_cleanupGDI_noop_when_empty(self): def test_destroy_cleans_gdi_then_calls_super(self): """destroy() cleans GDI before delegating to CustomWindow.destroy.""" window, _ = self._createWindow() + window._classAtom = 1 # Simulate a fully initialised CustomWindow callOrder = [] with ( patch.object(window, "_cleanupGDI", side_effect=lambda: callOrder.append("gdi")), From f046a8ceff7779a2bc6858fb24b288114891e2ac Mon Sep 17 00:00:00 2001 From: Antoine HAFFREINGUE Date: Tue, 10 Mar 2026 17:57:31 +0100 Subject: [PATCH 20/21] copilot review, without doc --- source/_magnifier/commands.py | 2 +- source/_magnifier/fixedMagnifier.py | 2 + source/_magnifier/utils/types.py | 4 +- source/_magnifier/utils/windowCreator.py | 7 ++- source/gui/settingsDialogs.py | 2 +- .../test_magnifier/test_fixedMagnifier.py | 24 ++++++++++ user_docs/en/userGuide.md | 44 +++++++++---------- 7 files changed, 57 insertions(+), 28 deletions(-) diff --git a/source/_magnifier/commands.py b/source/_magnifier/commands.py index ddd426aef16..e0f491d4fc3 100644 --- a/source/_magnifier/commands.py +++ b/source/_magnifier/commands.py @@ -169,7 +169,7 @@ def pan(action: MagnifierAction) -> None: def toggleMagnifierType() -> None: - """Cycle through magnifier types (full-screen, docked, lens)""" + """Cycle through magnifier types (full-screen, fixed, docked (to do), lens (to do))""" magnifier: Magnifier = getMagnifier() if magnifierIsActiveVerify( magnifier, diff --git a/source/_magnifier/fixedMagnifier.py b/source/_magnifier/fixedMagnifier.py index f6a2aafee1a..934d75c2d95 100644 --- a/source/_magnifier/fixedMagnifier.py +++ b/source/_magnifier/fixedMagnifier.py @@ -57,6 +57,8 @@ def _startMagnifier(self) -> None: Start the Fixed magnifier by creating a window and starting the update timer. """ super()._startMagnifier() + if not self._overlayWindow: + self._createWindow() self._startTimer(self._updateMagnifier) log.debug( f"Starting fixed magnifier position:{self._windowParameters.windowPosition} size:{self._windowParameters.windowSize}\n with zoom level {self.zoomLevel} and filter {self.filterType}", diff --git a/source/_magnifier/utils/types.py b/source/_magnifier/utils/types.py index 14564b46452..9c0c4f6b192 100644 --- a/source/_magnifier/utils/types.py +++ b/source/_magnifier/utils/types.py @@ -75,9 +75,9 @@ def _displayStringLabels(self) -> dict["MagnifierAction", str]: # Translators: Action description for panning to bottom edge. self.PAN_BOTTOM_EDGE: pgettext("magnifier action", "pan to bottom edge"), # Translators: Action description for toggling color filters. - self.TOGGLE_FILTER: pgettext("magnifier action", "trying to toggle filters"), + self.TOGGLE_FILTER: pgettext("magnifier action", "toggle filters"), # Translators: Action description for changing magnifier type. - self.CHANGE_MAGNIFIER_TYPE: pgettext("magnifier action", "trying to change magnifier type"), + self.CHANGE_MAGNIFIER_TYPE: pgettext("magnifier action", "change magnifier type"), # 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. diff --git a/source/_magnifier/utils/windowCreator.py b/source/_magnifier/utils/windowCreator.py index f636b5d8362..600b008d21a 100644 --- a/source/_magnifier/utils/windowCreator.py +++ b/source/_magnifier/utils/windowCreator.py @@ -372,7 +372,8 @@ def _cleanupGDI(self) -> None: def destroy(self) -> None: """Destroy the window and free all GDI resources.""" self._cleanupGDI() - if hasattr(self, "_classAtom"): + # Only destroy the underlying window once; _classAtom may exist but be None + if getattr(self, "_classAtom", None) and getattr(self, "handle", None): CustomWindow.destroy(self) @@ -415,6 +416,10 @@ def _setContent(self, magnifierParameters: MagnifierParameters, zoomLevel: float filterType=magnifierParameters.filter, ) + def _createWindow(self) -> None: + """Create the overlay window from the stored parameters.""" + self._overlayWindow = MagnifierOverlayWindow(self.windowMagnifierParameters) + def _destroyWindow(self) -> None: """Destroy the overlay window and release all resources.""" if self._overlayWindow: diff --git a/source/gui/settingsDialogs.py b/source/gui/settingsDialogs.py index 759b7445abe..bf85ef8bd38 100644 --- a/source/gui/settingsDialogs.py +++ b/source/gui/settingsDialogs.py @@ -6121,7 +6121,7 @@ def makeSettings( wx.CheckBox(fullscreenGroupBox, label=keepMouseCenteredText), ) self.bindHelpEvent( - "magnifierKeepMouseCentered", + "MagnifierKeepMouseCentered", self.keepMouseCenteredCheckBox, ) self.keepMouseCenteredCheckBox.SetValue(magnifierConfig.shouldKeepMouseCentered()) diff --git a/tests/unit/test_magnifier/test_fixedMagnifier.py b/tests/unit/test_magnifier/test_fixedMagnifier.py index 0f5d32474a3..f48180615e6 100644 --- a/tests/unit/test_magnifier/test_fixedMagnifier.py +++ b/tests/unit/test_magnifier/test_fixedMagnifier.py @@ -82,6 +82,30 @@ def test_stopMagnifier(self): mock_destroy.assert_called_once() self.assertFalse(self.magnifier._isActive) + def test_startMagnifier_recreates_window_after_stop(self): + """Stopping then starting the magnifier must recreate the destroyed overlay window.""" + with patch.object(self.magnifier, "_startTimer"): + self.magnifier._startMagnifier() + + # Simulate _destroyWindow (as called by _stopMagnifier) + self.magnifier._overlayWindow.destroy = MagicMock() + self.magnifier._stopMagnifier() + self.assertIsNone(self.magnifier._overlayWindow) + + # Restart: _startMagnifier must recreate the window + with patch( + "_magnifier.utils.windowCreator.MagnifierOverlayWindow", + ) as MockOverlay: + new_mock = MagicMock() + new_mock.handle = 99999 + MockOverlay.return_value = new_mock + + with patch.object(self.magnifier, "_startTimer"): + self.magnifier._startMagnifier() + + self.assertIsNotNone(self.magnifier._overlayWindow) + self.assertEqual(self.magnifier._overlayWindow.handle, 99999) + def test_getWindowParameters(self): """Test retrieving window parameters.""" with patch("_magnifier.fixedMagnifier.getDefaultFixedWindowWidth", return_value=400): diff --git a/user_docs/en/userGuide.md b/user_docs/en/userGuide.md index 8ee6f99a4b1..7a01f020e46 100644 --- a/user_docs/en/userGuide.md +++ b/user_docs/en/userGuide.md @@ -1516,6 +1516,11 @@ This feature is particularly useful for users with low vision who need to enlarg The NVDA Magnifier operates as a full-screen magnifier, meaning it enlarges the entire screen while following the system focus or mouse pointer. It provides several configuration options to customize the magnification experience according to your needs. +### Magnifier Settings {#MagnifierSettings} + +The magnifier can be configured in the "Magnifier" category of the NVDA Settings dialog (`NVDA+control+w`). +See the [Magnifier settings](#MagnifierSettingsCategory) section for details on available options. + ### Enabling and Disabling the Magnifier {#MagnifierToggle} To enable or disable the magnifier, press `NVDA+shift+w`. @@ -1563,9 +1568,9 @@ The default zoom level when the magnifier is first enabled can be configured in Color filters can help users with certain visual impairments or light sensitivity by modifying the colors displayed on the screen. The magnifier provides three color filter options: -* **Normal**: No color modification is applied. This is the default setting. -* **Grayscale**: Converts all colors to shades of gray, which can help reduce eye strain and improve contrast for some users. -* **Inverted**: Inverts all colors on the screen (black becomes white, white becomes black, etc.), which can be helpful for users who prefer light text on dark backgrounds or have photophobia. +* Normal: No color modification is applied. This is the default setting. +* Grayscale: Converts all colors to shades of gray, which can help reduce eye strain and improve contrast for some users. +* Inverted: Inverts all colors on the screen (black becomes white, white becomes black, etc.), which can be helpful for users who prefer light text on dark backgrounds or have photophobia. To cycle through the available filters press `NVDA+shift+i`. NVDA will announce the name of the currently selected filter. @@ -1574,23 +1579,21 @@ The default color filter when the magnifier is first enabled can be configured i ### Magnifier Modes {#MagnifierModes} -The magnfier can be used in multiple modes, each designed to suit different user needs and preferences: - -* **Full-screen mode**: The entire screen is magnified, and the magnified view follows the system focus or mouse pointer. This mode provides multiple type of focus mode. +The magnifier can be used in multiple modes, each designed to suit different user needs and preferences: -* **Fixed window mode**: A separate window displays the magnified content, and the rest of the screen remains at normal size. This allows you to see both the magnified content and the surrounding context simultaneously. +* Full-screen mode: The entire screen is magnified, and the magnified view follows the system focus or mouse pointer. This mode supports multiple focus modes. +* Fixed window mode: A separate window displays the magnified content, and the rest of the screen remains at normal size. This allows you to see both the magnified content and the surrounding context simultaneously. -### Focus Fullscreen Focus Modes {#MagnifierFullscreenFocusModes} +### Fullscreen Focus Modes {#MagnifierFullscreenFocusModes} -The Fullscreen magnifier offers three different modes for focus and determining which part of the screen to magnify: +The fullscreen magnifier offers three different focus modes that determine which part of the screen is magnified: -* **Center**: The magnified area is centered on the current focus position. +* Center: The magnified area is centered on the current focus position. This mode keeps the focused element at the center of the screen and clamps to the screen edge. To disable clamping, activate [true center mode in the Magnifier settings](#MagnifierUseTrueCenter). - -* **Border**: The magnified area only moves when the focus approaches the edge of the visible area. +* Border: The magnified area only moves when the focus approaches the edge of the visible area. This mode provides a more stable view, only adjusting when necessary. -* **Relative**: The magnified area maintains the relative position of the focus within the screen. +* Relative: The magnified area maintains the relative position of the focus within the screen. This mode mimics the behavior of the Windows Magnifier. To cycle through the focus modes, please assign a custom gesture using the [Input Gestures dialog](#InputGestures). @@ -1618,11 +1621,6 @@ Once activated, the magnifier will: Spotlight mode automatically deactivates after zooming back in. If you move the mouse before the zoom-back occurs, the timer resets, giving you more time to view the full screen. -### Magnifier Settings {#MagnifierSettings} - -The magnifier can be configured in the "Magnifier" category of the NVDA Settings dialog (`NVDA+control+w`). -See the [Magnifier settings](#MagnifierSettingsCategory) section for details on available options. - ## Content Recognition {#ContentRecognition} When authors don't provide sufficient information for a screen reader user to determine the content of something, various tools can be used to attempt to recognize the content from an image. @@ -2850,7 +2848,7 @@ This option is disabled by default. |Options |Disabled, Enabled| |Default |Disabled| -##### Default fullscreen magnifier focus mode {#MagnifierDefaultFocusMode} +##### Default fullscreen magnifier focus mode {#MagnifierDefaultFullscreenFocusMode} This combo box allows you to select the default focus tracking mode when the magnifier mode is fullscreen. To cycle through the focus tracking modes, please assign a custom gesture using the [Input Gestures dialog](#InputGestures). @@ -2892,18 +2890,18 @@ These two entries allow to choose the default size of the magnifier window by wi | . {.hideHeaderRow} |.| |---|---| -|Options |100 to 1000 pixels| +|Options |50 to 1000 pixels| |Default |200 pixels| ##### Default fixed magnifier position {#MagnifierDefaultFixedPosition} -this combo box allows you to select the default position of the magnifier window when the magnifier mode is fixed. +This combo box allows you to select the default position of the magnifier window when the magnifier mode is fixed. The available options are: | . {.hideHeaderRow} |.| |---|---| -|Options |TopLeft, TopRight, BottomLeft, BottomRight| -|Default |TopLeft| +|Options |Top Left, Top Right, Bottom Left, Bottom Right| +|Default |Top Left| ##### Keep mouse centered {#MagnifierKeepMouseCentered} From ab308304593cfae92d84c908676d6d4a8578de3f Mon Sep 17 00:00:00 2001 From: Antoine HAFFREINGUE Date: Tue, 17 Mar 2026 13:46:58 +0100 Subject: [PATCH 21/21] removed 'default' mentions --- source/_magnifier/__init__.py | 4 +- source/_magnifier/commands.py | 24 +-- source/_magnifier/config.py | 118 +++++++------- source/_magnifier/fixedMagnifier.py | 10 +- source/_magnifier/fullscreenMagnifier.py | 6 +- source/_magnifier/magnifier.py | 12 +- source/config/configSpec.py | 16 +- source/globalCommands.py | 18 +-- source/gui/settingsDialogs.py | 151 +++++++++--------- .../test_magnifier/test_fixedMagnifier.py | 12 +- user_docs/en/userGuide.md | 7 +- 11 files changed, 188 insertions(+), 190 deletions(-) diff --git a/source/_magnifier/__init__.py b/source/_magnifier/__init__.py index 7ac39aff9e7..43e509473e2 100644 --- a/source/_magnifier/__init__.py +++ b/source/_magnifier/__init__.py @@ -10,7 +10,7 @@ from typing import TYPE_CHECKING -from .config import getDefaultMagnifierType +from .config import getMagnifierType from .utils.types import MagnifierType if TYPE_CHECKING: @@ -68,7 +68,7 @@ def initialize() -> None: """ Initialize the magnifier module with the default magnifier type from config. """ - magnifierType = getDefaultMagnifierType() + magnifierType = getMagnifierType() _setMagnifierType(magnifierType) _magnifier._startMagnifier() diff --git a/source/_magnifier/commands.py b/source/_magnifier/commands.py index e0f491d4fc3..5ca92de387a 100644 --- a/source/_magnifier/commands.py +++ b/source/_magnifier/commands.py @@ -12,10 +12,10 @@ import ui from . import getMagnifier, initialize, changeMagnifierType, terminate from .config import ( - getDefaultZoomLevelString, - getDefaultFilter, - getDefaultMagnifierType, - getDefaultFullscreenMode, + getZoomLevelString, + getFilter, + getMagnifierType, + getFullscreenMode, ZoomLevel, ) from .magnifier import Magnifier @@ -101,10 +101,10 @@ def toggleMagnifier() -> None: else: initialize() - filter = getDefaultFilter() - magnifierType = getDefaultMagnifierType() + filter = getFilter() + magnifierType = getMagnifierType() if magnifierType == MagnifierType.FULLSCREEN: - fullscreenMode = getDefaultFullscreenMode() + fullscreenMode = getFullscreenMode() ui.message( pgettext( "magnifier", @@ -112,7 +112,7 @@ def toggleMagnifier() -> None: "Starting fullscreen magnifier with {zoomLevel} zoom level, {filter} filter, and {fullscreenMode} full-screen mode", ).format( magnifierType=magnifierType.displayString, - zoomLevel=getDefaultZoomLevelString(), + zoomLevel=getZoomLevelString(), filter=filter.displayString, fullscreenMode=fullscreenMode.displayString, ), @@ -125,7 +125,7 @@ def toggleMagnifier() -> None: "Starting {magnifierType} magnifier with {zoomLevel} zoom level and {filter} filter", ).format( magnifierType=magnifierType.displayString, - zoomLevel=getDefaultZoomLevelString(), + zoomLevel=getZoomLevelString(), filter=filter.displayString, ), ) @@ -168,7 +168,7 @@ def pan(action: MagnifierAction) -> None: ) -def toggleMagnifierType() -> None: +def cycleMagnifierType() -> None: """Cycle through magnifier types (full-screen, fixed, docked (to do), lens (to do))""" magnifier: Magnifier = getMagnifier() if magnifierIsActiveVerify( @@ -191,7 +191,7 @@ def toggleMagnifierType() -> None: ) -def toggleFilter() -> None: +def cycleFilter() -> None: """Cycle through color filters""" magnifier: Magnifier = getMagnifier() log.debug(f"Toggling filter for magnifier: {magnifier}") @@ -213,7 +213,7 @@ def toggleFilter() -> None: ) -def toggleFullscreenMode() -> None: +def cycleFullscreenMode() -> None: """Cycle through full-screen focus modes (center, border, relative)""" magnifier: Magnifier = getMagnifier() if magnifierIsActiveVerify( diff --git a/source/_magnifier/config.py b/source/_magnifier/config.py index e0ccc86e5ff..c30611f0b69 100644 --- a/source/_magnifier/config.py +++ b/source/_magnifier/config.py @@ -49,93 +49,93 @@ 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] -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. """ - config.conf["magnifier"]["defaultZoomLevel"] = zoomLevel + config.conf["magnifier"]["zoomLevel"] = zoomLevel -def getDefaultMagnifierType() -> MagnifierType: +def getMagnifierType() -> MagnifierType: """ - Get default magnifier type from config. + Get magnifier type from config. - :return: The default magnifier type. + :return: The magnifier type. """ - return MagnifierType(config.conf["magnifier"]["defaultMagnifierType"]) + return MagnifierType(config.conf["magnifier"]["magnifierType"]) -def setDefaultMagnifierType(magnifierType: MagnifierType) -> None: +def setMagnifierType(magnifierType: MagnifierType) -> None: """ - Set default magnifier type from settings. + Set magnifier type from settings. :param magnifierType: The magnifier type to set. """ - config.conf["magnifier"]["defaultMagnifierType"] = magnifierType.value + config.conf["magnifier"]["magnifierType"] = magnifierType.value -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 isTrueCentered() -> bool: @@ -156,73 +156,73 @@ def shouldKeepMouseCentered() -> bool: return config.conf["magnifier"]["keepMouseCentered"] -def getDefaultFullscreenMode() -> FullScreenMode: +def getFullscreenMode() -> FullScreenMode: """ - Get default full-screen mode from config. + Get full-screen mode from config. - :return: The default full-screen mode. + :return: The full-screen mode. """ - return FullScreenMode(config.conf["magnifier"]["defaultFullscreenMode"]) + return FullScreenMode(config.conf["magnifier"]["fullscreenMode"]) -def setDefaultFullscreenMode(mode: FullScreenMode) -> None: +def setFullscreenMode(mode: FullScreenMode) -> None: """ - Set default full-screen mode from settings. + Set full-screen mode from settings. :param mode: The full-screen mode to set. """ - config.conf["magnifier"]["defaultFullscreenMode"] = mode.value + config.conf["magnifier"]["fullscreenMode"] = mode.value -def getDefaultFixedWindowWidth() -> int: +def getFixedWindowWidth() -> int: """ - Get default fixed magnifier window width from config. + Get fixed magnifier window width from config. - :return: The default fixed magnifier window width. + :return: The fixed magnifier window width. """ - return config.conf["magnifier"]["defaultFixedWindowWidth"] + return config.conf["magnifier"]["fixedWindowWidth"] -def setDefaultFixedWindowWidth(width: int) -> None: +def setFixedWindowWidth(width: int) -> None: """ - Set default fixed magnifier window width from settings. + Set fixed magnifier window width from settings. :param width: The fixed magnifier window width to set. """ - config.conf["magnifier"]["defaultFixedWindowWidth"] = width + config.conf["magnifier"]["fixedWindowWidth"] = width -def getDefaultFixedWindowHeight() -> int: +def getFixedWindowHeight() -> int: """ - Get default fixed magnifier window height from config. + Get fixed magnifier window height from config. - :return: The default fixed magnifier window height. + :return: The fixed magnifier window height. """ - return config.conf["magnifier"]["defaultFixedWindowHeight"] + return config.conf["magnifier"]["fixedWindowHeight"] -def setDefaultFixedWindowHeight(height: int) -> None: +def setFixedWindowHeight(height: int) -> None: """ - Set default fixed magnifier window height from settings. + Set fixed magnifier window height from settings. :param height: The fixed magnifier window height to set. """ - config.conf["magnifier"]["defaultFixedWindowHeight"] = height + config.conf["magnifier"]["fixedWindowHeight"] = height -def getDefaultFixedWindowPosition() -> FixedWindowPosition: +def getFixedWindowPosition() -> FixedWindowPosition: """ - Get default magnifier window position from config. + Get magnifier window position from config. - :return: The default magnifier window position. + :return: The magnifier window position. """ - return FixedWindowPosition(config.conf["magnifier"]["defaultFixedWindowPosition"]) + return FixedWindowPosition(config.conf["magnifier"]["fixedWindowPosition"]) -def setDefaultFixedWindowPosition(position: FixedWindowPosition) -> None: +def setFixedWindowPosition(position: FixedWindowPosition) -> None: """ - Set default magnifier window position from settings. + Set magnifier window position from settings. :param position: The magnifier window position to set. """ - config.conf["magnifier"]["defaultFixedWindowPosition"] = position.value + config.conf["magnifier"]["fixedWindowPosition"] = position.value diff --git a/source/_magnifier/fixedMagnifier.py b/source/_magnifier/fixedMagnifier.py index 934d75c2d95..9fb21820f14 100644 --- a/source/_magnifier/fixedMagnifier.py +++ b/source/_magnifier/fixedMagnifier.py @@ -20,9 +20,9 @@ ) from .utils.windowCreator import WindowedMagnifier from .config import ( - getDefaultFixedWindowWidth, - getDefaultFixedWindowHeight, - getDefaultFixedWindowPosition, + getFixedWindowWidth, + getFixedWindowHeight, + getFixedWindowPosition, isTrueCentered, ) @@ -78,8 +78,8 @@ def _getWindowParameters(self) -> WindowMagnifierParameters: :return: The parameters for the magnifier window """ - case = getDefaultFixedWindowPosition() - windowSize = Size(getDefaultFixedWindowWidth(), getDefaultFixedWindowHeight()) + case = getFixedWindowPosition() + windowSize = Size(getFixedWindowWidth(), getFixedWindowHeight()) displaySize = Size(self._displayOrientation.width, self._displayOrientation.height) log.info( f"Getting window parameters for fixed magnifier with position {case}, window size {windowSize}", diff --git a/source/_magnifier/fullscreenMagnifier.py b/source/_magnifier/fullscreenMagnifier.py index ce67c6340b0..44cf146b763 100644 --- a/source/_magnifier/fullscreenMagnifier.py +++ b/source/_magnifier/fullscreenMagnifier.py @@ -23,14 +23,14 @@ Size, MagnifierParameters, ) -from .config import getDefaultFullscreenMode, shouldKeepMouseCentered, isTrueCentered +from .config import getFullscreenMode, shouldKeepMouseCentered, isTrueCentered class FullScreenMagnifier(Magnifier): def __init__(self): super().__init__() self._magnifierType = MagnifierType.FULLSCREEN - self._fullscreenMode = getDefaultFullscreenMode() + self._fullscreenMode = getFullscreenMode() self._currentCoordinates = Coordinates(0, 0) self._spotlightManager = SpotlightManager(self) self._displaySize = Size(self._displayOrientation.width, self._displayOrientation.height) @@ -187,7 +187,7 @@ def moveMouseToScreen(self) -> None: log.debug("Mouse button pressed, skipping cursor repositioning to avoid interfering with click") return - left, top, visibleWidth, visibleHeight = self._getMagnifierPosition( + left, top, visibleWidth, visibleHeight, _ = self._getMagnifierParameters( self._currentCoordinates, ) centerX = int(left + (visibleWidth / 2)) diff --git a/source/_magnifier/magnifier.py b/source/_magnifier/magnifier.py index 8966077d030..59143b8d7de 100644 --- a/source/_magnifier/magnifier.py +++ b/source/_magnifier/magnifier.py @@ -26,9 +26,9 @@ Filter, ) from .config import ( - getDefaultZoomLevel, - getDefaultPanStep, - getDefaultFilter, + getZoomLevel, + getPanStep, + getFilter, ZoomLevel, isTrueCentered, ) @@ -43,14 +43,14 @@ def __init__(self): self._displayOrientation = getPrimaryDisplayOrientation() 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) diff --git a/source/config/configSpec.py b/source/config/configSpec.py index a7d192baf8f..f1471947384 100644 --- a/source/config/configSpec.py +++ b/source/config/configSpec.py @@ -114,15 +114,15 @@ # Magnifier settings [magnifier] - defaultZoomLevel = float(min=1.0, max=10.0, default=2.0) - defaultPanStep = integer(min=1, max=100, default=10) + 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") - defaultMagnifierType = string(default="fullscreen") - defaultFullscreenMode = string(default="center") - defaultFixedWindowWidth = integer(default=200, min=50, max=1000) - defaultFixedWindowHeight = integer(default=200, min=50, max=1000) - defaultFixedWindowPosition = string(default="topLeft") + filter = string(default="normal") + magnifierType = string(default="fullscreen") + fullscreenMode = string(default="center") + fixedWindowWidth = integer(default=200, min=50, max=1000) + fixedWindowHeight = integer(default=200, min=50, max=1000) + fixedWindowPosition = string(default="topLeft") keepMouseCentered = boolean(default=false) diff --git a/source/globalCommands.py b/source/globalCommands.py index b1c708ae44d..234b283f1d2 100755 --- a/source/globalCommands.py +++ b/source/globalCommands.py @@ -5124,43 +5124,43 @@ def script_panToBottomEdge( @script( description=_( # Translators: Describes a command. - "Toggle Magnifier type", + "Cycle through Magnifier type", ), category=SCRCAT_VISION, gesture="kb:nvda+shift+t", ) - def script_toggleMagnifierType( + def script_cycleMagnifierType( self, gesture: inputCore.InputGesture, ) -> None: - _magnifier.commands.toggleMagnifierType() + _magnifier.commands.cycleMagnifierType() @script( description=_( # Translators: Describes a command. - "Toggle filter of the magnifier", + "Cycle through filter of the magnifier", ), category=SCRCAT_VISION, gesture="kb:NVDA+shift+i", ) - def script_toggleFilter( + def script_cycleFilter( self, gesture: inputCore.InputGesture, ) -> None: - _magnifier.commands.toggleFilter() + _magnifier.commands.cycleFilter() @script( description=_( # Translators: Describes a command. - "Toggle focus mode for the full-screen magnifier", + "Cycle through focus mode for the full-screen magnifier", ), category=SCRCAT_VISION, ) - def script_toggleFullscreenMode( + def script_cycleFullscreenMode( self, gesture: inputCore.InputGesture, ) -> None: - _magnifier.commands.toggleFullscreenMode() + _magnifier.commands.cycleFullscreenMode() @script( description=_( diff --git a/source/gui/settingsDialogs.py b/source/gui/settingsDialogs.py index ba9cf5794f7..b43c91121d1 100644 --- a/source/gui/settingsDialogs.py +++ b/source/gui/settingsDialogs.py @@ -5973,9 +5973,6 @@ class MagnifierPanel(SettingsPanel): title = _("Magnifier") helpId = "MagnifierSettings" - # Translators: This is a label appearing on the magnifier settings panel. - panelDescription = _("The following options control the NVDA magnifier behavior.") - def makeSettings( self, settingsSizer: wx.BoxSizer, @@ -5997,48 +5994,48 @@ def makeSettings( sHelper.addItem(generalGroup) # 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 = generalGroup.addLabeledControl( - defaultZoomLabelText, + self.zoomList = generalGroup.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) # FILTER SETTINGS - # Translators: The label for a setting in magnifier settings to select the default filter - defaultFilterLabelText = _("Default &filter:") + # Translators: The label for a setting in magnifier settings to select the filter + filterLabelText = _(" &filter:") filterChoices = [f.displayString for f in Filter] - self.defaultFilterList = generalGroup.addLabeledControl( - defaultFilterLabelText, + self.filterList = generalGroup.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)) # TRUE CENTER # Translators: The label for a setting in magnifier settings to select whether true center is used in full-screen mode @@ -6053,8 +6050,8 @@ def makeSettings( self.trueCenterCheckBox.SetValue(magnifierConfig.isTrueCentered()) # MAGNIFIER TYPE SETTINGS - # Translators: The label for a setting in magnifier settings to select the default magnifier type - magnifierTypeLabelText = _("Default &magnifier type:") + # Translators: The label for a setting in magnifier settings to select the magnifier type + magnifierTypeLabelText = _("&Magnifier type:") magnifierTypeChoices = [mt.displayString for mt in MagnifierType] self.magnifierTypeList = generalGroup.addLabeledControl( magnifierTypeLabelText, @@ -6062,13 +6059,13 @@ def makeSettings( choices=magnifierTypeChoices, ) self.bindHelpEvent( - "MagnifierDefaultMagnifierType", + "MagnifierMagnifierType", self.magnifierTypeList, ) - # Set default value from config - defaultMagnifierType = magnifierConfig.getDefaultMagnifierType() - self.magnifierTypeList.SetSelection(list(MagnifierType).index(defaultMagnifierType)) + # Set value from config + magnifierTypeValue = magnifierConfig.getMagnifierType() + self.magnifierTypeList.SetSelection(list(MagnifierType).index(magnifierTypeValue)) # Bind event to update visibility when magnifier type changes self.magnifierTypeList.Bind(wx.EVT_CHOICE, self._onMagnifierTypeChange) @@ -6077,7 +6074,7 @@ def makeSettings( # Translators: The label for a setting in magnifier settings to select the pan step size (in percentage). panStepSizeLabelText = _("&Panning step size (%):") - self.defaultPanSpinCtrl = generalGroup.addLabeledControl( + self.panSpinCtrl = generalGroup.addLabeledControl( panStepSizeLabelText, wx.SpinCtrl, min=1, @@ -6085,12 +6082,12 @@ 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) # FULLSCREEN GROUP # Translators: This is the label for a group of fullscreen magnifier options in the @@ -6102,22 +6099,22 @@ def makeSettings( sHelper.addItem(fullscreenGroup) # 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 = fullscreenGroup.addLabeledControl( - defaultFullscreenModeLabelText, + self.fullscreenModeList = fullscreenGroup.addLabeledControl( + fullscreenModeLabelText, wx.Choice, choices=fullscreenModeChoices, ) self.bindHelpEvent( - "MagnifierDefaultFullscreenFocusMode", - self.defaultFullscreenModeList, + "MagnifierFullscreenFocusMode", + self.fullscreenModeList, ) - # Set default value from config - defaultFullscreenMode = magnifierConfig.getDefaultFullscreenMode() - self.defaultFullscreenModeList.SetSelection(list(FullScreenMode).index(defaultFullscreenMode)) + # Set value from config + fullscreenModeValue = magnifierConfig.getFullscreenMode() + self.fullscreenModeList.SetSelection(list(FullScreenMode).index(fullscreenModeValue)) # KEEP MOUSE CENTERED # Translators: The label for a checkbox to keep the mouse pointer centered in the magnifier view @@ -6139,53 +6136,53 @@ def makeSettings( fixedGroup = guiHelper.BoxSizerHelper(self, sizer=self.fixedGroupSizer) sHelper.addItem(fixedGroup) - # Translators: The label for a setting in magnifier settings to select the default fixed magnifier window width in pixels. + # Translators: The label for a setting in magnifier settings to select the fixed magnifier window width in pixels. # Window width settings - defaultFixedWindowWidthLabelText = _("Default fixed magnifier &window width (pixels):") - self.defaultFixedWindowWidthEdit = fixedGroup.addLabeledControl( - defaultFixedWindowWidthLabelText, + fixedWindowWidthLabelText = _(" fixed magnifier &window width (pixels):") + self.fixedWindowWidthEdit = fixedGroup.addLabeledControl( + fixedWindowWidthLabelText, nvdaControls.SelectOnFocusSpinCtrl, - value=str(magnifierConfig.getDefaultFixedWindowWidth()), + value=str(magnifierConfig.getFixedWindowWidth()), min=50, max=1000, ) self.bindHelpEvent( - "MagnifierDefaultFixedWindowWidth", - self.defaultFixedWindowWidthEdit, + "MagnifierFixedWindowWidth", + self.fixedWindowWidthEdit, ) - # Translators: The label for a setting in magnifier settings to select the default fixed magnifier window height in pixels. + # Translators: The label for a setting in magnifier settings to select the fixed magnifier window height in pixels. # Window height settings - defaultFixedWindowHeightLabelText = _("Default fixed magnifier &window height (pixels):") - self.defaultFixedWindowHeightEdit = fixedGroup.addLabeledControl( - defaultFixedWindowHeightLabelText, + fixedWindowHeightLabelText = _(" fixed magnifier &window height (pixels):") + self.fixedWindowHeightEdit = fixedGroup.addLabeledControl( + fixedWindowHeightLabelText, nvdaControls.SelectOnFocusSpinCtrl, - value=str(magnifierConfig.getDefaultFixedWindowHeight()), + value=str(magnifierConfig.getFixedWindowHeight()), min=50, max=1000, ) self.bindHelpEvent( - "MagnifierDefaultFixedWindowHeight", - self.defaultFixedWindowHeightEdit, + "MagnifierFixedWindowHeight", + self.fixedWindowHeightEdit, ) - # Translators: The label for a setting in magnifier settings to select the default fixed magnifier window position. + # Translators: The label for a setting in magnifier settings to select the fixed magnifier window position. # Window position settings - defaultFixedWindowPositionLabelText = _("Default fixed magnifier &window position:") + fixedWindowPositionLabelText = _(" fixed magnifier &window position:") fixedWindowPositionChoices = [pos.displayString for pos in FixedWindowPosition] - self.defaultFixedWindowPositionList = fixedGroup.addLabeledControl( - defaultFixedWindowPositionLabelText, + self.fixedWindowPositionList = fixedGroup.addLabeledControl( + fixedWindowPositionLabelText, wx.Choice, choices=fixedWindowPositionChoices, ) self.bindHelpEvent( - "MagnifierDefaultFixedWindowPosition", - self.defaultFixedWindowPositionList, + "MagnifierFixedWindowPosition", + self.fixedWindowPositionList, ) - defaultFixedWindowPosition = magnifierConfig.getDefaultFixedWindowPosition() - self.defaultFixedWindowPositionList.SetSelection( - list(FixedWindowPosition).index(defaultFixedWindowPosition), + fixedWindowPosition = magnifierConfig.getFixedWindowPosition() + self.fixedWindowPositionList.SetSelection( + list(FixedWindowPosition).index(fixedWindowPosition), ) # DOCKED MAGNIFIER GROUP @@ -6239,25 +6236,25 @@ def _updateMagnifierGroupsState(self): 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]) selectedMagnifierTypeIdx = self.magnifierTypeList.GetSelection() - magnifierConfig.setDefaultMagnifierType(list(MagnifierType)[selectedMagnifierTypeIdx]) + magnifierConfig.setMagnifierType(list(MagnifierType)[selectedMagnifierTypeIdx]) - selectedModeIdx = self.defaultFullscreenModeList.GetSelection() - magnifierConfig.setDefaultFullscreenMode(list(FullScreenMode)[selectedModeIdx]) + selectedModeIdx = self.fullscreenModeList.GetSelection() + magnifierConfig.setFullscreenMode(list(FullScreenMode)[selectedModeIdx]) - magnifierConfig.setDefaultFixedWindowWidth(self.defaultFixedWindowWidthEdit.GetValue()) - magnifierConfig.setDefaultFixedWindowHeight(self.defaultFixedWindowHeightEdit.GetValue()) + magnifierConfig.setFixedWindowWidth(self.fixedWindowWidthEdit.GetValue()) + magnifierConfig.setFixedWindowHeight(self.fixedWindowHeightEdit.GetValue()) - selectedPositionIdx = self.defaultFixedWindowPositionList.GetSelection() - magnifierConfig.setDefaultFixedWindowPosition(list(FixedWindowPosition)[selectedPositionIdx]) + selectedPositionIdx = self.fixedWindowPositionList.GetSelection() + magnifierConfig.setFixedWindowPosition(list(FixedWindowPosition)[selectedPositionIdx]) config.conf["magnifier"]["isTrueCentered"] = self.trueCenterCheckBox.GetValue() config.conf["magnifier"]["keepMouseCentered"] = self.keepMouseCenteredCheckBox.GetValue() diff --git a/tests/unit/test_magnifier/test_fixedMagnifier.py b/tests/unit/test_magnifier/test_fixedMagnifier.py index f48180615e6..cb764e68e9e 100644 --- a/tests/unit/test_magnifier/test_fixedMagnifier.py +++ b/tests/unit/test_magnifier/test_fixedMagnifier.py @@ -19,10 +19,10 @@ class TestFixedMagnifier(unittest.TestCase): def setUp(self): """Setup before each test.""" # Mock config functions to avoid dependencies - with patch("_magnifier.fixedMagnifier.getDefaultFixedWindowWidth", return_value=400): - with patch("_magnifier.fixedMagnifier.getDefaultFixedWindowHeight", return_value=300): + with patch("_magnifier.fixedMagnifier.getFixedWindowWidth", return_value=400): + with patch("_magnifier.fixedMagnifier.getFixedWindowHeight", return_value=300): with patch( - "_magnifier.fixedMagnifier.getDefaultFixedWindowPosition", + "_magnifier.fixedMagnifier.getFixedWindowPosition", return_value=FixedWindowPosition.TOP_LEFT, ): # Mock MagnifierOverlayWindow to prevent real Win32 window creation @@ -108,10 +108,10 @@ def test_startMagnifier_recreates_window_after_stop(self): def test_getWindowParameters(self): """Test retrieving window parameters.""" - with patch("_magnifier.fixedMagnifier.getDefaultFixedWindowWidth", return_value=400): - with patch("_magnifier.fixedMagnifier.getDefaultFixedWindowHeight", return_value=300): + with patch("_magnifier.fixedMagnifier.getFixedWindowWidth", return_value=400): + with patch("_magnifier.fixedMagnifier.getFixedWindowHeight", return_value=300): with patch( - "_magnifier.fixedMagnifier.getDefaultFixedWindowPosition", + "_magnifier.fixedMagnifier.getFixedWindowPosition", return_value=FixedWindowPosition.TOP_LEFT, ): params = self.magnifier._getWindowParameters() diff --git a/user_docs/en/userGuide.md b/user_docs/en/userGuide.md index 5ac380fb75e..37a619cb42b 100644 --- a/user_docs/en/userGuide.md +++ b/user_docs/en/userGuide.md @@ -1598,8 +1598,7 @@ The magnifier can be used in multiple modes, each designed to suit different use ### Fullscreen Magnifier {#MagnifierFullscreen} -The fullscreen magnifier is the default mode of the NVDA Magnifier and provides a seamless magnification experience by enlarging the entire screen. -In this mode, the magnified view follows the system focus or mouse pointer, allowing you to easily navigate and interact with content while maintaining a clear view of the magnified area. +The fullscreen magnifier provides a seamless magnification experience with a magnified view covering the entire screen. ### Fullscreen Focus Modes {#MagnifierFullscreenFocusModes} @@ -1640,7 +1639,9 @@ If you move the mouse before the zoom-back occurs, the timer resets, giving you ### Fixed Magnifier {#MagnifierFixed} -The fixed magnifier mode provides a separate window that displays the magnified content, while the rest of the screen remains at normal size. This allows you to see both the magnified content and the surrounding context simultaneously. The fixed magnifier window can be moved to corners of your screen and resized independently of the main screen, giving you flexibility in how you view magnified content. +The fixed magnifier mode provides a separate window that displays the magnified content, while the rest of the screen remains at normal size. +This allows you to see both the magnified content and the surrounding context simultaneously. +The fixed magnifier window can be moved to corners of your screen and resized independently of the main screen, giving you flexibility in how you view magnified content. The modifications can be configured in the [Magnifier settings](#MagnifierSettings). ## Content Recognition {#ContentRecognition}