Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
77b7e19
placeholder and option for magnifier types
Boumtchack Jan 19, 2026
9c1a665
pre-commit
Boumtchack Jan 19, 2026
8523c02
Merge branch 'master' of https://github.com/France-Travail/nvda into …
Boumtchack Jan 28, 2026
b4ccd7c
pre-commit
Boumtchack Jan 28, 2026
a1d9f4b
changing MagnifierPosition to MagnifierParameters prepare for window …
Boumtchack Feb 3, 2026
04c53e7
Merge branch 'master' of https://github.com/France-Travail/nvda into …
Boumtchack Feb 3, 2026
e47e926
Merge branch 'nvaccess:master' into fixedMagWindow
Boumtchack Feb 4, 2026
704a4a8
fixed window creation, first draft
Boumtchack Feb 4, 2026
85f4e39
Merge branch 'fixedMagWindow' of https://github.com/France-Travail/nv…
Boumtchack Feb 4, 2026
f695dc6
updated setting dialog for clearer options
Boumtchack Feb 4, 2026
bb4dff1
changed typing, wip
Boumtchack Feb 4, 2026
090a247
Pre-commit auto-fix
pre-commit-ci[bot] Feb 4, 2026
50e45b7
fix tests
Boumtchack Feb 4, 2026
d9a737e
Merge pre-commit autofix changes
Boumtchack Feb 4, 2026
80124fe
Merge branch 'nvaccess:master' into fixedMagWindow
Boumtchack Feb 10, 2026
53d90f1
added filter and better window creation handling
Boumtchack Feb 10, 2026
8e87f5d
adding settings
Boumtchack Feb 11, 2026
a08a6db
added userguide for fixedWindow and updated fullscreen
Boumtchack Feb 11, 2026
d9a54c8
simplifying WindowCreator
Boumtchack Feb 11, 2026
516be80
tests for magnifierPanel & magnifierFrame
Boumtchack Feb 11, 2026
34ccff9
added tests fpr WindowedMagnifier
Boumtchack Feb 11, 2026
aa4e7d5
unit test for fixed Magnifier
Boumtchack Feb 11, 2026
1bd5868
Merge branch 'nvaccess:master' into fixedMagWindow
Boumtchack Feb 18, 2026
c2fba2c
merge conflict resolve
Boumtchack Feb 25, 2026
011d4c3
fixed true center, update gui
Boumtchack Feb 25, 2026
939c3ff
Merge branch 'master' of https://github.com/France-Travail/nvda into …
Boumtchack Mar 2, 2026
b3bdfc8
changed to native win32 for window handling
Boumtchack Mar 3, 2026
5bcad40
continuing changes
Boumtchack Mar 10, 2026
1785883
Merge branch 'nvaccess:master' into fixedMagWindow
Boumtchack Mar 10, 2026
e70fc26
Merge branch 'nvaccess:master' into FixedMagWindow
Boumtchack Mar 10, 2026
5280b10
Merge pull request #6 from France-Travail/FixedMagWindow
Boumtchack Mar 10, 2026
f046a8c
copilot review, without doc
Boumtchack Mar 10, 2026
9a192e4
master merge + doc
Boumtchack Mar 11, 2026
ab30830
removed 'default' mentions
Boumtchack Mar 17, 2026
a5784a6
Merge branch 'master' into fixedMagWindow
Boumtchack May 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion source/_magnifier/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}")
Expand Down
58 changes: 56 additions & 2 deletions source/_magnifier/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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.
"""
Expand Down Expand Up @@ -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
120 changes: 113 additions & 7 deletions source/_magnifier/fixedMagnifier.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Comment thread
Boumtchack marked this conversation as resolved.

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,
)
1 change: 1 addition & 0 deletions source/_magnifier/magnifier.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
"""
Expand Down
140 changes: 140 additions & 0 deletions source/_magnifier/utils/filterHandler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Loading
Loading