diff --git a/source/visionEnhancementProviders/magnifier.py b/source/visionEnhancementProviders/magnifier.py new file mode 100644 index 0000000000..10fef67d15 --- /dev/null +++ b/source/visionEnhancementProviders/magnifier.py @@ -0,0 +1,122 @@ +from ctypes import WinError +from ctypes.wintypes import RECT + +from locationHelper import RectLTRB, RectLTWH +from logHandler import log +from vision import ( + _isDebug, +) +from .screenCurtain import MAGTRANSFORM, Magnification +from winAPI import _displayTracking +from windowUtils import CustomWindow +import winUser + +WindowClassName = "MagnifierWindow" +WindowTitle = "Screen Magnifier Sample" +WC_MAGNIFIER = "Magnifier" +RESTOREDWINDOWSTYLES = ( + winUser.WS_SIZEBOX + | winUser.WS_SYSMENU + | winUser.WS_CLIPCHILDREN + | winUser.WS_CAPTION + | winUser.WS_MAXIMIZEBOX +) + + +class HostWindow(CustomWindow): + className = WindowClassName + windowName = WindowTitle + windowsStyle = RESTOREDWINDOWSTYLES + extendedWindowStyle = ( + # Ensure that the window is on top of all other windows + winUser.WS_EX_TOPMOST + # A layered window ensures that L{transparentColor} will be considered transparent, when painted + | winUser.WS_EX_LAYERED + ) + + def __init__(self, magnificationFactor: int = 2): + super().__init__( + windowName=self.windowName, + windowStyle=self.windowsStyle, + extendedWindowStyle=self.extendedWindowStyle, + parent=None, + ) + winUser.SetLayeredWindowAttributes( + self.handle, + 0x00, + 0xFF, + winUser.LWA_ALPHA, + ) + if not winUser.user32.SetWindowPos( + self.handle, + winUser.HWND_TOPMOST, + self.targetRect.left, + self.targetRect.top, + self.targetRect.width, + int(self.targetRect.height), + winUser.SWP_NOACTIVATE | winUser.SWP_NOMOVE | winUser.SWP_NOSIZE, + ): + raise WinError() + if not winUser.user32.UpdateWindow(self.handle): + raise WinError() + self.magnifierWindow = MagnifierWindow(self, magnificationFactor) + + @property + def targetRect(self) -> RectLTRB: + # Top quarter of screen + return RectLTRB( + 0, + 0, + _displayTracking._orientationState.width, + _displayTracking._orientationState.height / 4, + ) + + def windowProc(self, hwnd: int, msg: int, wParam: int, lParam: int): + log.debug(f"received window proc message: {msg}") + + +class MagnifierWindow(CustomWindow): + className = WC_MAGNIFIER + windowName = "MagnifierWindow" + windowStyle = winUser.WS_CHILD | winUser.MS_SHOWMAGNIFIEDCURSOR | winUser.WS_VISIBLE + + def __init__(self, hostWindow: HostWindow, magnificationFactor: int = 2): + self.hostWindow = hostWindow + self.magnificationFactor = magnificationFactor + if _isDebug(): + log.debug("initializing NVDA Magnifier window") + super().__init__( + windowName=self.windowName, + windowStyle=self.windowStyle, + parent=hostWindow.handle, + ) + + magWindowRect = self.magWindowRect + if not winUser.user32.SetWindowPos( + self.handle, + winUser.HWND_TOPMOST, + magWindowRect.left, + magWindowRect.top, + magWindowRect.width, + magWindowRect.height, + winUser.SWP_NOACTIVATE | winUser.SWP_NOMOVE | winUser.SWP_NOSIZE, + ): + raise WinError() + if not winUser.user32.UpdateWindow(self.handle): + raise WinError() + + Magnification.MagSetWindowSource(self.handle, RECT(200, 200, 700, 700)) + Magnification.MagSetWindowTransform(self.handle, MAGTRANSFORM(self.magnificationFactor)) + + @property + def magWindowRect(self) -> RectLTWH: + r = winUser.getClientRect(self.hostWindow.handle) + return RectLTRB( + r.left, + r.top, + r.right, + r.bottom, + ).toLTWH() + + def windowProc(self, hwnd: int, msg: int, wParam: int, lParam: int): + log.debug(f"received window proc message: {msg}") diff --git a/source/visionEnhancementProviders/screenCurtain.py b/source/visionEnhancementProviders/screenCurtain.py index f1f4b8174e..c051e41c26 100644 --- a/source/visionEnhancementProviders/screenCurtain.py +++ b/source/visionEnhancementProviders/screenCurtain.py @@ -1,7 +1,7 @@ # A part of NonVisual Desktop Access (NVDA) # This file is covered by the GNU General Public License. # See the file COPYING for more details. -# Copyright (C) 2018-2023 NV Access Limited, Babbage B.V., Leonard de Ruijter +# Copyright (C) 2018-2024 NV Access Limited, Babbage B.V., Leonard de Ruijter """Screen curtain implementation based on the windows magnification API. The Magnification API has been marked by MS as unsupported for WOW64 applications such as NVDA. (#12491) @@ -10,7 +10,7 @@ import os from vision import providerBase from ctypes import Structure, windll, c_float, POINTER, WINFUNCTYPE, WinError -from ctypes.wintypes import BOOL +from ctypes.wintypes import BOOL, FLOAT, HWND, RECT, INT from autoSettingsUtils.driverSetting import BooleanDriverSetting from autoSettingsUtils.autoSettings import SupportedSettingType import wx @@ -30,6 +30,24 @@ class MAGCOLOREFFECT(Structure): _fields_ = (("transform", c_float * 5 * 5),) +class MAGTRANSFORM(Structure): + _fields_ = (("v", c_float * 3 * 3),) + + def __init__(self, magnificationFactor: float = 1.0): + """ + https://learn.microsoft.com/en-us/windows/win32/api/magnification/ns-magnification-magtransform + + :param magnificationFactor: defaults to 1.0. + The minimum value of this parameter is 1.0, and the maximum value is 4096.0. + If this value is 1.0, the screen content is not magnified and no offsets are applied. + """ + super().__init__() + assert 1.0 <= magnificationFactor <= 4096.0 + self.v[0][0] = magnificationFactor + self.v[1][1] = magnificationFactor + self.v[2][2] = 1.0 + + # homogeneous matrix for a 4-space transformation (red, green, blue, opacity). # https://docs.microsoft.com/en-gb/windows/win32/gdiplus/-gdiplus-using-a-color-matrix-to-transform-a-single-color-use TRANSFORM_BLACK = MAGCOLOREFFECT() # empty transformation @@ -85,6 +103,73 @@ class Magnification: MagUninitialize = _MagUninitializeFuncType(("MagUninitialize", _magnification)) MagUninitialize.errcheck = _errCheck + _MagSetWindowSourceFuncType = WINFUNCTYPE(BOOL, HWND, POINTER(RECT)) + _MagSetWindowSourceArgTypes = ((1, "hwnd"), (1, "rect")) + MagSetWindowSource = _MagSetWindowSourceFuncType( + ("MagSetWindowSource", _magnification), + _MagSetWindowSourceArgTypes, + ) + MagSetWindowSource.errcheck = _errCheck + + _MagGetWindowSourceFuncType = WINFUNCTYPE(BOOL, HWND, POINTER(RECT)) + _MagGetWindowSourceArgTypes = ((1, "hwnd"), (2, "rect")) + MagGetWindowSource = _MagGetWindowSourceFuncType( + ("MagGetWindowSource", _magnification), + _MagGetWindowSourceArgTypes, + ) + MagGetWindowSource.errcheck = _errCheck + + _MagSetWindowTransformFuncType = WINFUNCTYPE(BOOL, HWND, POINTER(MAGTRANSFORM)) + _MagSetWindowTransformArgTypes = ((1, "hwnd"), (1, "transform")) + MagSetWindowTransform = _MagSetWindowTransformFuncType( + ("MagSetWindowTransform", _magnification), + _MagSetWindowTransformArgTypes, + ) + MagSetWindowTransform.errcheck = _errCheck + + # Create transformation window + _MagGetWindowTransformFuncType = WINFUNCTYPE(BOOL, HWND, POINTER(MAGTRANSFORM)) + _MagGetWindowTransformArgTypes = ((1, "hwnd"), (2, "transform")) + MagGetWindowTransform = _MagGetWindowTransformFuncType( + ("MagGetWindowTransform", _magnification), + _MagGetWindowTransformArgTypes, + ) + MagGetWindowTransform.errcheck = _errCheck + + _MagSetFullscreenTransformFuncType = WINFUNCTYPE(BOOL, POINTER(FLOAT), POINTER(INT), POINTER(INT)) + _MagSetFullscreenTransformArgTypes = ((1, "magLevel"), (1, "offsetX"), (1, "offsetY")) + MagSetFullscreenTransform = _MagSetFullscreenTransformFuncType( + ("MagSetFullscreenTransform", _magnification), + _MagSetFullscreenTransformArgTypes, + ) + MagSetFullscreenTransform.errcheck = _errCheck + + _MagGetFullscreenTransformFuncType = WINFUNCTYPE(BOOL, POINTER(FLOAT), POINTER(INT), POINTER(INT)) + _MagGetFullscreenTransformArgTypes = ((2, "magLevel"), (2, "offsetX"), (2, "offsetY")) + MagGetFullscreenTransform = _MagGetFullscreenTransformFuncType( + ("MagGetFullscreenTransform", _magnification), + _MagGetFullscreenTransformArgTypes, + ) + MagGetFullscreenTransform.errcheck = _errCheck + + # # Create transformation window + # _MagGetInputTransformFuncType = WINFUNCTYPE(BOOL, POINTER(BOOL), POINTER(RECT), POINTER(RECT)) + # _MagGetInputTransformArgTypes = ((2, "enabled"), (2, "src"), (2, "dest")) + # MagGetInputTransform = _MagGetInputTransformFuncType( + # ("MagGetInputTransform", _magnification), + # _MagGetInputTransformArgTypes, + # ) + # MagGetInputTransform.errcheck = _errCheck + + # # Create transformation window + # _MagSetInputTransformFuncType = WINFUNCTYPE(BOOL, POINTER(BOOL), POINTER(RECT), POINTER(RECT)) + # _MagSetInputTransformArgTypes = ((1, "enabled"), (1, "src"), (1, "dest")) + # MagSetInputTransform = _MagGetInputTransformFuncType( + # ("MagSetInputTransform", _magnification), + # _MagSetInputTransformArgTypes, + # ) + # MagSetInputTransform.errcheck = _errCheck + # Translators: Name for a vision enhancement provider that disables output to the screen, # making it black. diff --git a/source/winUser.py b/source/winUser.py index 6d00a0e2fb..a82431c021 100644 --- a/source/winUser.py +++ b/source/winUser.py @@ -142,6 +142,9 @@ class GUITHREADINFO(Structure): WS_VSCROLL = 0x200000 WS_CAPTION = 0xC00000 WS_CLIPCHILDREN = 0x02000000 +WS_MAXIMIZEBOX = 0x00010000 +WS_CHILD = 0x40000000 +MS_SHOWMAGNIFIEDCURSOR = 0x0001 WS_EX_TOPMOST = 0x00000008 WS_EX_LAYERED = 0x80000 WS_EX_TOOLWINDOW = 0x00000080 @@ -533,7 +536,7 @@ def getControlID(hwnd): return user32.GetWindowLongW(hwnd, GWL_ID) -def getClientRect(hwnd): +def getClientRect(hwnd: HWND) -> RECT: r = RECT() if not user32.GetClientRect(hwnd, byref(r)): raise WinError() diff --git a/tests/unit/test_visionEnhancementProviders/test_magnificationAPI.py b/tests/unit/test_visionEnhancementProviders/test_magnificationAPI.py index 819baaebb0..7facf4ce52 100644 --- a/tests/unit/test_visionEnhancementProviders/test_magnificationAPI.py +++ b/tests/unit/test_visionEnhancementProviders/test_magnificationAPI.py @@ -7,7 +7,9 @@ import unittest -from visionEnhancementProviders.screenCurtain import Magnification, TRANSFORM_BLACK +from winAPI import _displayTracking +from visionEnhancementProviders.magnifier import HostWindow +from visionEnhancementProviders.screenCurtain import MAGTRANSFORM, Magnification, TRANSFORM_BLACK class _Test_MagnificationAPI(unittest.TestCase): @@ -53,3 +55,49 @@ def test_MagShowSystemCursor(self): def test_MagHideSystemCursor(self): result = Magnification.MagShowSystemCursor(False) self.assertTrue(result) + + +class Test_Magnification(unittest.TestCase): + def setUp(self): + self.hostWindow: HostWindow | None = None + self._prevOrientationState = _displayTracking._orientationState + self.assertIsNone(self._prevOrientationState) + _displayTracking.initialize() + self.assertTrue(Magnification.MagInitialize()) + + def tearDown(self): + self.assertTrue(Magnification.MagUninitialize()) + _displayTracking._orientationState = self._prevOrientationState + if self.hostWindow: + self.hostWindow.destroy() + + def _initializeMagWindow(self, magnificationFactor: int = 1): + self.hostWindow = HostWindow(magnificationFactor) + self.assertTrue(self.hostWindow.handle) + self.assertTrue(self.hostWindow.magnifierWindow.handle) + + def test_setAndConfirmMagLevel(self): + expectedTransform = MAGTRANSFORM(2) + self._initializeMagWindow(2) + resultTransform = Magnification.MagGetWindowTransform(self.hostWindow.magnifierWindow.handle) + for i in range(3): + for j in range(3): + with self.subTest(i=i, j=j): + self.assertEqual( + expectedTransform.v[i][j], + resultTransform.v[i][j], + msg=f"i={i}, j={j}, resultTransform={resultTransform}", + ) + + def test_getDefaultIdentityMagLevel(self): + self._initializeMagWindow() + resultTransform = Magnification.MagGetWindowTransform(self.hostWindow.magnifierWindow.handle) + for i in range(3): + for j in range(3): + with self.subTest(i=i, j=j): + self.assertEqual( + # The transform matrix should be the identity matrix + int(i == j), + resultTransform.v[i][j], + msg=f"i={i}, j={j}, resultTransform={resultTransform}", + )