diff --git a/source/_magnifier/commands.py b/source/_magnifier/commands.py index 4ab40da2594..e884c356af9 100644 --- a/source/_magnifier/commands.py +++ b/source/_magnifier/commands.py @@ -176,7 +176,7 @@ def pan(action: MagnifierAction) -> None: ) -def toggleFilter() -> None: +def cycleFilter() -> None: """Cycle through color filters""" magnifier: Magnifier = getMagnifier() log.debug(f"Toggling filter for magnifier: {magnifier}") diff --git a/source/_magnifier/config.py b/source/_magnifier/config.py index 822b86c9b2e..0dc28906ece 100644 --- a/source/_magnifier/config.py +++ b/source/_magnifier/config.py @@ -10,7 +10,7 @@ import config from dataclasses import dataclass, field -from .utils.types import Filter, FullScreenMode, MagnifierFollowFocusType, MagnifiedView +from .utils.types import Filter, FullScreenMode, MagnifierFollowFocusType, MagnifiedView, FixedWindowPosition class ZoomLevel: @@ -114,7 +114,7 @@ def getFilter() -> Filter: def setFilter(filter: Filter) -> None: """ - Set filter from settings. + Set filter from settings. :param filter: The filter to set. """ @@ -262,3 +262,57 @@ def setFullscreenMode(mode: FullScreenMode) -> None: :param mode: The full-screen mode to set. """ config.conf["magnifier"]["fullscreenMode"] = mode.value + + +def getFixedWindowWidth() -> int: + """ + Get fixed magnifier window width from config. + + :return: The fixed magnifier window width. + """ + return config.conf["magnifier"]["fixedWindowWidth"] + + +def setFixedWindowWidth(width: int) -> None: + """ + Set fixed magnifier window width from settings. + + :param width: The fixed magnifier window width to set. + """ + config.conf["magnifier"]["fixedWindowWidth"] = width + + +def getFixedWindowHeight() -> int: + """ + Get fixed magnifier window height from config. + + :return: The fixed magnifier window height. + """ + return config.conf["magnifier"]["fixedWindowHeight"] + + +def setFixedWindowHeight(height: int) -> None: + """ + Set fixed magnifier window height from settings. + + :param height: The fixed magnifier window height to set. + """ + config.conf["magnifier"]["fixedWindowHeight"] = height + + +def getFixedWindowPosition() -> FixedWindowPosition: + """ + Get magnifier window position from config. + + :return: The magnifier window position. + """ + return FixedWindowPosition(config.conf["magnifier"]["fixedWindowPosition"]) + + +def setFixedWindowPosition(position: FixedWindowPosition) -> None: + """ + Set magnifier window position from settings. + + :param position: The magnifier window position to set. + """ + config.conf["magnifier"]["fixedWindowPosition"] = position.value diff --git a/source/_magnifier/fixedMagnifier.py b/source/_magnifier/fixedMagnifier.py index 4c29bd2f235..7fe9652cedd 100644 --- a/source/_magnifier/fixedMagnifier.py +++ b/source/_magnifier/fixedMagnifier.py @@ -7,23 +7,129 @@ Fixed magnifier module. """ +from logHandler import log from .magnifier import Magnifier -from .utils.types import MagnifiedView +from .utils.types import ( + Coordinates, + Size, + MagnifiedView, + WindowMagnifierParameters, + MagnifierParameters, + Filter, + FixedWindowPosition, +) +from .utils.windowCreator import WindowedMagnifier +from .config import ( + getFixedWindowWidth, + getFixedWindowHeight, + getFixedWindowPosition, + isTrueCentered, +) -class FixedMagnifier(Magnifier): - """Displays a floating magnified panel that can be pinned anywhere on the screen.""" - +class FixedMagnifier(Magnifier, WindowedMagnifier): _MAGNIFIED_VIEW = MagnifiedView.FIXED def __init__(self): - super().__init__() + Magnifier.__init__(self) + windowParameters = self._getWindowParameters() + WindowedMagnifier.__init__(self, windowParameters) + self._currentCoordinates = Coordinates(0, 0) + self._windowParameters = windowParameters + + @property + def filterType(self) -> Filter: + return self._filterType + + @filterType.setter + def filterType(self, value: Filter) -> None: + self._filterType = value + + def event_gainFocus( + self, + obj, + nextHandler, + ): + log.debug("Fixed Magnifier gain focus event") + nextHandler() 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}", + ) + + def _doUpdate(self): + params = self._getMagnifierParameters(self._currentCoordinates) + super()._setContent(params, self.zoomLevel) def _stopMagnifier(self) -> None: + super()._destroyWindow() super()._stopMagnifier() - def _doUpdate(self): - pass + def _getWindowParameters(self) -> WindowMagnifierParameters: + """ + Get the parameters for the magnifier window from configuration. + + :return: The parameters for the magnifier window + """ + 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}", + ) + + 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, + ) + + 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/magnifier.py b/source/_magnifier/magnifier.py index 4a757c88a70..1d02aa538b3 100644 --- a/source/_magnifier/magnifier.py +++ b/source/_magnifier/magnifier.py @@ -357,6 +357,7 @@ def _getMagnifierParameters(self, coordinates: Coordinates) -> MagnifierParamete 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 """ 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/types.py b/source/_magnifier/utils/types.py index c1a47364603..a486104ef88 100644 --- a/source/_magnifier/utils/types.py +++ b/source/_magnifier/utils/types.py @@ -179,3 +179,39 @@ def _displayStringLabels(self) -> dict["Filter", str]: # Translators: Magnifier color filter - inverted colors. self.INVERTED: pgettext("magnifier", "Inverted"), } + + +class WindowMagnifierParameters(NamedTuple): + """ + 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 = 0 + + +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/_magnifier/utils/windowCreator.py b/source/_magnifier/utils/windowCreator.py new file mode 100644 index 00000000000..600b008d21a --- /dev/null +++ b/source/_magnifier/utils/windowCreator.py @@ -0,0 +1,427 @@ +# 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 + +""" +Windowed magnifier overlay using Win32 native windows. + +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 + +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 +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 = winUser.WM_PAINT +#: WM_DESTROY message +WM_DESTROY: int = winUser.WM_DESTROY +#: WM_ERASEBKGND message +WM_ERASEBKGND: int = 0x0014 +#: SetStretchBltMode: high-quality image stretching mode +HALFTONE: int = 4 + +_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 + +#: 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. + """ + + className = "NVDAMagnifierOverlay" + + @classmethod + def _get__wClass(cls): + wClass = super()._wClass + wClass.style = winUser.CS_HREDRAW | winUser.CS_VREDRAW + return wClass + + def __init__(self, windowParams: WindowMagnifierParameters): + """Create the overlay window and configure its special properties. + + :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._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, + ) + + # 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", + ) + + # 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 captureW <= 0 or captureH <= 0: + return + + 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) + + applyBitmapFilter(filterType, self._captureDC, self._captureBitmap, captureW, captureH) + + 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) + rop = getBlitRasterOp(self._currentFilter) + 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 _snapshotCursor( + self, + 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. + """ + 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 + + 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 + + # 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 _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 + + 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, + ) + + def _cleanupGDI(self) -> None: + """Release the off-screen capture DC, bitmap and associated objects.""" + 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() + # 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) + + +class WindowedMagnifier: + """Mixin for magnifiers that display content in a separate overlay window. + + Uses a native Win32 overlay window (:class:`MagnifierOverlayWindow`) to + ensure the magnified view is: + + * invisible to NVDA and other accessibility tools, + * excluded from screen capture (no infinite feedback with the system magnifier), + * fully click-through. + """ + + def __init__(self, windowMagnifierParameters: WindowMagnifierParameters): + """Create the overlay window. + + :param windowMagnifierParameters: Configuration for the overlay window. + """ + self.windowMagnifierParameters = windowMagnifierParameters + self._overlayWindow: MagnifierOverlayWindow | None = MagnifierOverlayWindow( + windowMagnifierParameters, + ) + + 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 + + self._overlayWindow.updateContent( + captureX=magnifierParameters.coordinates.x, + captureY=magnifierParameters.coordinates.y, + captureW=magnifierParameters.magnifierSize.width, + captureH=magnifierParameters.magnifierSize.height, + 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: + self._overlayWindow.destroy() + self._overlayWindow = None diff --git a/source/config/configSpec.py b/source/config/configSpec.py index faaf99f71cc..66570719c7b 100644 --- a/source/config/configSpec.py +++ b/source/config/configSpec.py @@ -127,6 +127,9 @@ panStep = integer(min=1, max=100, default=10) fullscreenMode = string(default="center") keepMouseCentered = boolean(default=false) + fixedWindowWidth = integer(default=200, min=50, max=1000) + fixedWindowHeight = integer(default=200, min=50, max=1000) + fixedWindowPosition = string(default="topLeft") # Presentation settings [presentation] diff --git a/source/globalCommands.py b/source/globalCommands.py index 57490c715cb..7b20d20396c 100755 --- a/source/globalCommands.py +++ b/source/globalCommands.py @@ -5194,16 +5194,16 @@ def script_panToBottomEdge( @script( description=_( # Translators: Describes a command. - "Toggle filter of the magnifier", + "Cycle through Magnifier type", ), category=SCRCAT_VISION, - gesture="kb:NVDA+shift+i", + gesture="kb:nvda+shift+t", ) - def script_toggleFilter( + def script_cycleMagnifierType( self, gesture: inputCore.InputGesture, ) -> None: - _magnifier.commands.toggleFilter() + _magnifier.commands.cycleMagnifiedView() @script( description=_( @@ -5276,6 +5276,20 @@ def script_toggleAllFollow( "Toggle focus mode for the full-screen magnifier", ), category=SCRCAT_VISION, + gesture="kb:NVDA+shift+i", + ) + def script_cycleFilter( + self, + gesture: inputCore.InputGesture, + ) -> None: + _magnifier.commands.cycleFilter() + + @script( + description=_( + # Translators: Describes a command. + "Cycle through focus mode for the full-screen magnifier", + ), + category=SCRCAT_VISION, ) def script_toggleFullscreenMode( self, diff --git a/source/gui/settingsDialogs.py b/source/gui/settingsDialogs.py index eba08d3441c..bdcc5e2c664 100644 --- a/source/gui/settingsDialogs.py +++ b/source/gui/settingsDialogs.py @@ -41,7 +41,13 @@ import languageHandler import logHandler import _magnifier.config as magnifierConfig -from _magnifier.utils.types import Filter, FullScreenMode, MagnifierFollowFocusType +from _magnifier.utils.types import ( + Filter, + FullScreenMode, + MagnifierFollowFocusType, + FixedWindowPosition, + MagnifiedView, +) import queueHandler import requests import speech @@ -6049,6 +6055,15 @@ def makeSettings( sizer=settingsSizer, ) + # 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 zoom level. zoomLabelText = _("&Zoom level:") @@ -6056,7 +6071,7 @@ def makeSettings( zoomValues = magnifierConfig.ZoomLevel.zoom_range() zoomChoices = magnifierConfig.ZoomLevel.zoom_strings() - self.zoomList = sHelper.addLabeledControl( + self.zoomList = generalGroup.addLabeledControl( zoomLabelText, wx.Choice, choices=zoomChoices, @@ -6082,7 +6097,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.panSpinCtrl = sHelper.addLabeledControl( + self.panSpinCtrl = generalGroup.addLabeledControl( panStepSizeLabelText, wx.SpinCtrl, min=1, @@ -6101,7 +6116,7 @@ def makeSettings( # Translators: The label for a setting in magnifier settings to select the default filter filterLabelText = _("&filter:") filterChoices = [f.displayString for f in Filter] - self.filterList = sHelper.addLabeledControl( + self.filterList = generalGroup.addLabeledControl( filterLabelText, wx.Choice, choices=filterChoices, @@ -6112,33 +6127,38 @@ def makeSettings( filterValue = magnifierConfig.getFilter() self.filterList.SetSelection(list(Filter).index(filterValue)) - # FULLSCREEN MODE SETTINGS - # Translators: The label for a setting in magnifier settings to select the full-screen mode - fullscreenModeLabelText = _("&fullscreen mode:") - fullscreenModeChoices = [mode.displayString for mode in FullScreenMode] if FullScreenMode else [] - self.fullscreenModeList = sHelper.addLabeledControl( - fullscreenModeLabelText, - wx.Choice, - choices=fullscreenModeChoices, - ) - self.bindHelpEvent( - "MagnifierFullscreenFocusMode", - self.fullscreenModeList, - ) - # TRUE CENTER # Translators: The label for a setting in magnifier settings to select whether true center is used in full-screen mode - trueCenterText = _("Use &true center in fullscreen mode") - self.trueCenterCheckBox = sHelper.addItem(wx.CheckBox(self, label=trueCenterText)) + trueCenterText = _("Use &true center") + self.trueCenterCheckBox = generalGroup.addItem( + wx.CheckBox(generalGroupBox, label=trueCenterText), + ) self.bindHelpEvent( "MagnifierUseTrueCenter", self.trueCenterCheckBox, ) self.trueCenterCheckBox.SetValue(magnifierConfig.isTrueCentered()) - # Set default value from config - defaultFullscreenMode = magnifierConfig.getFullscreenMode() - self.fullscreenModeList.SetSelection(list(FullScreenMode).index(defaultFullscreenMode)) + # MAGNIFIED VIEW SETTINGS + # Translators: The label for a setting in magnifier settings to select the magnified view + magnifiedViewLabelText = _("&Magnified view:") + magnifiedViewChoices = [mt.displayString for mt in MagnifiedView] + self.magnifiedViewList = generalGroup.addLabeledControl( + magnifiedViewLabelText, + wx.Choice, + choices=magnifiedViewChoices, + ) + self.bindHelpEvent( + "MagnifierMagnifiedView", + self.magnifiedViewList, + ) + + # Set value from config + magnifiedViewValue = magnifierConfig.getMagnifiedView() + self.magnifiedViewList.SetSelection(list(MagnifiedView).index(magnifiedViewValue)) + + # Bind event to update visibility when magnifier type changes + self.magnifiedViewList.Bind(wx.EVT_CHOICE, self._onMagnifiedViewChange) # FOCUS GROUP # Translators: This is the label for a group of focus options in the magnifier settings panel @@ -6168,16 +6188,151 @@ def makeSettings( checkBox.SetValue(magnifierConfig.getFollowState(focusType)) self._followFocusCheckBoxes[focusType] = checkBox + # 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 full-screen mode + fullscreenModeLabelText = _("&fullscreen mode:") + fullscreenModeChoices = [mode.displayString for mode in FullScreenMode] if FullScreenMode else [] + self.fullscreenModeList = fullscreenGroup.addLabeledControl( + fullscreenModeLabelText, + wx.Choice, + choices=fullscreenModeChoices, + ) + self.bindHelpEvent( + "MagnifierFullscreenFocusMode", + self.fullscreenModeList, + ) + + # Set default value from config + defaultFullscreenMode = magnifierConfig.getFullscreenMode() + self.fullscreenModeList.SetSelection(list(FullScreenMode).index(defaultFullscreenMode)) + # KEEP MOUSE CENTERED # Translators: The label for a checkbox to keep the mouse pointer centered in the magnifier view keepMouseCenteredText = _("Keep &mouse pointer centered in magnifier view") - self.keepMouseCenteredCheckBox = sHelper.addItem(wx.CheckBox(self, label=keepMouseCenteredText)) + 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 + fixedGroupText = _("Fixed") + self.fixedGroupSizer = wx.StaticBoxSizer(wx.VERTICAL, self, label=fixedGroupText) + fixedGroup = guiHelper.BoxSizerHelper(self, sizer=self.fixedGroupSizer) + sHelper.addItem(fixedGroup) + + # Translators: The label for a setting in magnifier settings to select the fixed magnifier window width in pixels. + # Window width settings + fixedWindowWidthLabelText = _(" fixed magnifier &window width (pixels):") + self.fixedWindowWidthEdit = fixedGroup.addLabeledControl( + fixedWindowWidthLabelText, + nvdaControls.SelectOnFocusSpinCtrl, + value=str(magnifierConfig.getFixedWindowWidth()), + min=50, + max=1000, + ) + self.bindHelpEvent( + "MagnifierFixedWindowWidth", + self.fixedWindowWidthEdit, + ) + + # Translators: The label for a setting in magnifier settings to select the fixed magnifier window height in pixels. + # Window height settings + fixedWindowHeightLabelText = _(" fixed magnifier &window height (pixels):") + self.fixedWindowHeightEdit = fixedGroup.addLabeledControl( + fixedWindowHeightLabelText, + nvdaControls.SelectOnFocusSpinCtrl, + value=str(magnifierConfig.getFixedWindowHeight()), + min=50, + max=1000, + ) + self.bindHelpEvent( + "MagnifierFixedWindowHeight", + self.fixedWindowHeightEdit, + ) + + # Translators: The label for a setting in magnifier settings to select the fixed magnifier window position. + # Window position settings + fixedWindowPositionLabelText = _(" fixed magnifier &window position:") + fixedWindowPositionChoices = [pos.displayString for pos in FixedWindowPosition] + self.fixedWindowPositionList = fixedGroup.addLabeledControl( + fixedWindowPositionLabelText, + wx.Choice, + choices=fixedWindowPositionChoices, + ) + self.bindHelpEvent( + "MagnifierFixedWindowPosition", + self.fixedWindowPositionList, + ) + + fixedWindowPosition = magnifierConfig.getFixedWindowPosition() + self.fixedWindowPositionList.SetSelection( + list(FixedWindowPosition).index(fixedWindowPosition), + ) + + # 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 _onMagnifiedViewChange(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.magnifiedViewList.GetSelection() + if selectedIdx == wx.NOT_FOUND: + return + + selectedType = list(MagnifiedView)[selectedIdx] + + # Enable only the group corresponding to the selected magnifier type + self.fullscreenGroupSizer.GetStaticBox().Enable(selectedType == MagnifiedView.FULLSCREEN) + self.fixedGroupSizer.GetStaticBox().Enable(selectedType == MagnifiedView.FIXED) + self.dockedGroupSizer.GetStaticBox().Enable(selectedType == MagnifiedView.DOCKED) + self.lensGroupSizer.GetStaticBox().Enable(selectedType == MagnifiedView.LENS) + def onSave(self): """Save the current selections to config.""" selectedZoom = self.zoomList.GetSelection() @@ -6188,9 +6343,18 @@ def onSave(self): selectedFilterIdx = self.filterList.GetSelection() magnifierConfig.setFilter(list(Filter)[selectedFilterIdx]) + selectedMagnifiedViewIdx = self.magnifiedViewList.GetSelection() + magnifierConfig.setMagnifiedView(list(MagnifiedView)[selectedMagnifiedViewIdx]) + selectedModeIdx = self.fullscreenModeList.GetSelection() magnifierConfig.setFullscreenMode(list(FullScreenMode)[selectedModeIdx]) + magnifierConfig.setFixedWindowWidth(self.fixedWindowWidthEdit.GetValue()) + magnifierConfig.setFixedWindowHeight(self.fixedWindowHeightEdit.GetValue()) + + selectedPositionIdx = self.fixedWindowPositionList.GetSelection() + magnifierConfig.setFixedWindowPosition(list(FixedWindowPosition)[selectedPositionIdx]) + config.conf["magnifier"]["isTrueCentered"] = self.trueCenterCheckBox.GetValue() for focusType, checkBox in self._followFocusCheckBoxes.items(): magnifierConfig.setFollowState(focusType, checkBox.GetValue()) diff --git a/tests/unit/test_magnifier/test_fixedMagnifier.py b/tests/unit/test_magnifier/test_fixedMagnifier.py new file mode 100644 index 00000000000..cb764e68e9e --- /dev/null +++ b/tests/unit/test_magnifier/test_fixedMagnifier.py @@ -0,0 +1,123 @@ +# 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 WindowedMagnifier + + +class TestFixedMagnifier(unittest.TestCase): + """Tests for the FixedMagnifier class.""" + + def setUp(self): + """Setup before each test.""" + # Mock config functions to avoid dependencies + with patch("_magnifier.fixedMagnifier.getFixedWindowWidth", return_value=400): + with patch("_magnifier.fixedMagnifier.getFixedWindowHeight", return_value=300): + with patch( + "_magnifier.fixedMagnifier.getFixedWindowPosition", + return_value=FixedWindowPosition.TOP_LEFT, + ): + # 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._overlayWindow: + self.magnifier._overlayWindow = None + + def test_init(self): + """Test initialization of FixedMagnifier.""" + self.assertIsNotNone(self.magnifier._overlayWindow) + 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) + 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_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.getFixedWindowWidth", return_value=400): + with patch("_magnifier.fixedMagnifier.getFixedWindowHeight", return_value=300): + with patch( + "_magnifier.fixedMagnifier.getFixedWindowPosition", + 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") diff --git a/tests/unit/test_magnifier/test_windowCreator.py b/tests/unit/test_magnifier/test_windowCreator.py new file mode 100644 index 00000000000..1aa83cdeda5 --- /dev/null +++ b/tests/unit/test_magnifier/test_windowCreator.py @@ -0,0 +1,519 @@ +# 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 ctypes +import unittest +from unittest.mock import MagicMock, patch +from _magnifier.utils.types import Coordinates, Size, WindowMagnifierParameters, Filter, MagnifierParameters +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) + + 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"), + 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 applyBitmapFilter.""" + window, _ = self._createWindow() + with ( + patch("_magnifier.utils.windowCreator.gdi32"), + patch("_magnifier.utils.windowCreator.user32"), + patch("_magnifier.utils.windowCreator.applyBitmapFilter") as mockFilter, + ): + window.updateContent(0, 0, 100, 100, Filter.GRAYSCALE) + mockFilter.assert_called_once() + + 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("_magnifier.utils.windowCreator.applyBitmapFilter") as mockFilter, + ): + window.updateContent(0, 0, 100, 100, Filter.INVERTED) + mockFilter.assert_called_once() + + def test_cleanupGDI_releases_resources(self): + """_cleanupGDI properly releases DC and bitmap.""" + window, _ = self._createWindow() + mockDC = MagicMock() + 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() + window._classAtom = 1 # Simulate a fully initialised CustomWindow + 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 mixin.""" + + def setUp(self): + """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(10, 20), + filter=Filter.INVERTED, + ) + self.magnifier._setContent(magnifierParams, 2.0) + + self.mockWindow.updateContent.assert_called_once_with( + captureX=10, + captureY=20, + captureW=200, + captureH=150, + filterType=Filter.INVERTED, + ) + + 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, + ) + # Should not raise + self.magnifier._setContent(magnifierParams, 2.0) + + 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() + + def test_destroyWindow_calls_destroy(self): + """_destroyWindow calls destroy() on the overlay and sets it to None.""" + self.magnifier._destroyWindow() + + self.mockWindow.destroy.assert_called_once() + self.assertIsNone(self.magnifier._overlayWindow) + + 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() diff --git a/user_docs/en/userGuide.md b/user_docs/en/userGuide.md index 98be0703093..f16a8f649cc 100644 --- a/user_docs/en/userGuide.md +++ b/user_docs/en/userGuide.md @@ -1561,6 +1561,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`. @@ -1618,9 +1623,20 @@ 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 {#MagnifierFullscreenFocusModes} +### Magnifier Modes {#MagnifierDefaultMagnifierType} + +The magnifier can be used in multiple modes, each designed to suit different user needs and preferences: -The magnifier offers three different modes for tracking focus and determining which part of the screen to magnify: +* 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. + +### Fullscreen Magnifier {#MagnifierFullscreen} + +The fullscreen magnifier provides a seamless magnification experience with a magnified view covering the entire screen. + +### Fullscreen Focus Modes {#MagnifierFullscreenFocusModes} + +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. This mode keeps the focused element at the center of the screen and clamps to the screen edge. @@ -1630,14 +1646,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: @@ -1655,10 +1671,12 @@ 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} +### Fixed Magnifier {#MagnifierFixed} -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. +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} @@ -3010,6 +3028,52 @@ This option is disabled by default. |Options |Disabled, Enabled| |Default |Disabled| +##### 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). +The available options are: + +| . {.hideHeaderRow} |.| +|---|---| +|Options |Center, Border, Relative| +|Default |Center| +|Toggle command |None | + +| Option | Description | +|---|---| +| Center | The magnified area is always centered on the current focus position. | +| 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. | + +##### Fixed magnifier width {#MagnifierDefaultFixedWindowWidth} + +This entry allows you to choose the default width of the magnifier window when the magnifier mode is fixed. + +| . {.hideHeaderRow} |.| +|---|---| +|Options |50 to 1000 pixels| +|Default |200 pixels| + +##### Fixed magnifier height {#MagnifierDefaultFixedWindowHeight} + +This entry allows you to choose the default height of the magnifier window when the magnifier mode is fixed. + +| . {.hideHeaderRow} |.| +|---|---| +|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. +The available options are: + +| . {.hideHeaderRow} |.| +|---|---| +|Options |Top Left, Top Right, Bottom Left, Bottom Right| +|Default |Top Left| + #### Keyboard {#KeyboardSettings}