diff --git a/source/_magnifier/magnifier.py b/source/_magnifier/magnifier.py index 66e96c359a6..c2ac3695ac8 100644 --- a/source/_magnifier/magnifier.py +++ b/source/_magnifier/magnifier.py @@ -12,10 +12,14 @@ from comtypes import COMError from logHandler import log import wx +import gui import ui import speech import screenCurtain +import touchHandler from winAPI import _displayTracking +from winAPI.winUser.constants import SystemMetrics +from winBindings import user32 from winAPI._displayTracking import OrientationState, getPrimaryDisplayOrientation from .utils.types import ( MagnifierParameters, @@ -184,6 +188,39 @@ def _startMagnifier(self) -> None: self._isActive = True self.currentCoordinates = self._focusManager.getCurrentFocusCoordinates() + if touchHandler.handler is not None: + # Touch events are already intercepted by NVDA; block gesture execution + # to prevent incorrect coordinates from the magnified view reaching the system. + touchHandler.blockTouchInput = True + elif user32.GetSystemMetrics(SystemMetrics.MAXIMUM_TOUCHES) > 0: + # The user has a touchscreen but NVDA cannot intercept inputs; warn them. + if touchHandler.touchSupported(): + reason = pgettext( + "magnifier", + # Translators: Warning when magnifier is started on a device with a touch screen but NVDA's touch support is disabled. + "Touch screen input cannot be used because NVDA touch support is disabled. ", + ) + else: + reason = pgettext( + "magnifier", + # Translators: Warning when the magnifier starts on a portable/source copy with a touchscreen. + "Touch screen input cannot be used because NVDA is not installed. ", + ) + wx.CallAfter( + gui.messageBox, + reason + + pgettext( + "magnifier", + # Translators: Suffix appended to touch warning when touch inputs cannot be blocked by the magnifier. + "Touch inputs may not behave as expected while the magnifier is running.", + ), + pgettext( + "magnifier", + # Translators: Title of the warning dialog shown when touch cannot be intercepted while the magnifier is running. + "Touch Screen Warning", + ), + wx.OK | wx.ICON_WARNING, + ) def _updateMagnifier(self) -> None: """ @@ -254,6 +291,8 @@ def _stopMagnifier(self) -> None: return self._stopTimer() self._isActive = False + if touchHandler.handler is not None: + touchHandler.blockTouchInput = False # Unregister from display changes _displayTracking.displayChanged.unregister(self._onDisplayChanged) diff --git a/source/touchHandler.py b/source/touchHandler.py index b0b177a3410..410a0b814df 100644 --- a/source/touchHandler.py +++ b/source/touchHandler.py @@ -9,6 +9,7 @@ """ import threading +import winsound from functools import cached_property from typing import ( TYPE_CHECKING, @@ -189,6 +190,7 @@ class POINTER_TOUCH_INFO(Structure): touchWindow = None touchThread = None +blockTouchInput: bool = False class TouchInputGesture(inputCore.InputGesture): @@ -391,6 +393,9 @@ def setMode(self, mode): def pump(self): for preheldTracker, tracker in self.trackerManager.emitTrackers(): + if blockTouchInput: + winsound.MessageBeep(winsound.MB_ICONASTERISK) + continue gesture = TouchInputGesture(preheldTracker, tracker, self._curTouchMode.value) try: inputCore.manager.executeGesture(gesture) diff --git a/tests/unit/test_magnifier/test_magnifierTouchscreen.py b/tests/unit/test_magnifier/test_magnifierTouchscreen.py new file mode 100644 index 00000000000..4b2c783c4f2 --- /dev/null +++ b/tests/unit/test_magnifier/test_magnifierTouchscreen.py @@ -0,0 +1,86 @@ +# A part of NonVisual Desktop Access (NVDA) +# Copyright (C) 2025-2026 NV Access Limited, Antoine Haffreingue +# This file may be used under the terms of the GNU General Public License, version 2 or later, as modified by the NVDA license. +# For full terms and any additional permissions, see the NVDA license file: https://github.com/nvaccess/nvda/blob/master/copying.txt + +from _magnifier.magnifier import Magnifier +from _magnifier.utils.types import Coordinates +from tests.unit.test_magnifier.test_magnifier import _TestMagnifier +import touchHandler +from winAPI._displayTracking import getPrimaryDisplayOrientation +from unittest.mock import MagicMock, patch + + +class TestMagnifierTouchscreen(_TestMagnifier): + """Tests for touchscreen blocking/warning behaviour when the magnifier starts and stops.""" + + def setUp(self): + super().setUp() + self.magnifier = Magnifier() + screenDimensions = getPrimaryDisplayOrientation() + self.focusCoords = Coordinates(screenDimensions.width // 2, screenDimensions.height // 2) + self.magnifier._focusManager.getCurrentFocusCoordinates = MagicMock(return_value=self.focusCoords) + + def tearDown(self): + if hasattr(self.magnifier, "_timer") and self.magnifier._timer: + self.magnifier._timer.Stop() + self.magnifier._timer = None + if hasattr(self.magnifier, "_isActive") and self.magnifier._isActive: + self.magnifier._isActive = False + super().tearDown() + + def testStartMagnifierBlocksTouchWhenHandlerActive(self): + """blockTouchInput is set when the magnifier starts with the touch handler running.""" + with ( + patch("touchHandler.handler", new=MagicMock()), + patch("touchHandler.blockTouchInput", False), + ): + self.magnifier._startMagnifier() + self.assertTrue(touchHandler.blockTouchInput) + + def testStartMagnifierWarnsWhenTouchSupportDisabled(self): + """Dialog shown when device has a touchscreen but NVDA touch support is disabled.""" + with ( + patch("touchHandler.handler", new=None), + patch("winBindings.user32.GetSystemMetrics", return_value=5), + patch("touchHandler.touchSupported", return_value=True), + patch("_magnifier.magnifier.wx.CallAfter") as mock_call_after, + ): + self.magnifier._startMagnifier() + + mock_call_after.assert_called_once() + + def testStartMagnifierWarnsOnPortableOrNoUIAccess(self): + """Dialog shown when device has a touchscreen but NVDA cannot intercept (portable/no UI access).""" + with ( + patch("touchHandler.handler", new=None), + patch("winBindings.user32.GetSystemMetrics", return_value=5), + patch("touchHandler.touchSupported", return_value=False), + patch("_magnifier.magnifier.wx.CallAfter") as mock_call_after, + ): + self.magnifier._startMagnifier() + + mock_call_after.assert_called_once() + + def testStartMagnifierNoActionWithoutTouchscreen(self): + """No dialog and no blocking when the device has no touchscreen.""" + with ( + patch("touchHandler.handler", new=None), + patch("winBindings.user32.GetSystemMetrics", return_value=0), + patch("_magnifier.magnifier.wx.CallAfter") as mock_call_after, + ): + self.magnifier._startMagnifier() + + mock_call_after.assert_not_called() + + def testStopMagnifierUnblocksTouchInput(self): + """blockTouchInput is reset to False when the magnifier stops with the touch handler active.""" + self.magnifier._stopTimer = MagicMock() + self.magnifier._isActive = True + touchHandler.blockTouchInput = True + self.addCleanup(setattr, touchHandler, "blockTouchInput", False) + + with patch("touchHandler.handler", new=MagicMock()): + self.magnifier._stopMagnifier() + + self.assertFalse(touchHandler.blockTouchInput)