diff --git a/source/_magnifier/commands.py b/source/_magnifier/commands.py index 7c75e2ec42c..6f564c5f11f 100644 --- a/source/_magnifier/commands.py +++ b/source/_magnifier/commands.py @@ -16,6 +16,9 @@ getDefaultFilter, getDefaultFullscreenMode, ZoomLevel, + getFollowState, + setFollowState, + toggleAllFollowStates, ) from .magnifier import Magnifier from .fullscreenMagnifier import FullScreenMagnifier @@ -25,6 +28,7 @@ MagnifierType, FullScreenMode, MagnifierAction, + MagnifierFollowFocusType, ) from logHandler import log @@ -175,6 +179,68 @@ def toggleFilter() -> None: ) +def toggleFollow(focusType: MagnifierFollowFocusType) -> None: + """ + Toggle the specified follow mode setting and update focus immediately. + + :param focusType: The follow mode to toggle (mouse, system focus, review cursor, navigator object) + """ + magnifier: Magnifier = getMagnifier() + if magnifierIsActiveVerify( + magnifier, + MagnifierAction.TOGGLE_FOLLOW_SETTINGS, + ): + state = not getFollowState(focusType) + setFollowState(focusType, state) + + magnifier._focusManager.updateFollowedFocus() + + ui.message( + pgettext( + "magnifier", + # Translators: Message announced when toggling a follow setting with {setting} being the name of the setting and {state} being either "enabled" or "disabled". + "{setting} {state}", + ).format( + setting=focusType.displayString, + state=pgettext( + "magnifier", + # Translators: State of the follow setting being toggled enabled. + "enabled", + ) + if state + else pgettext( + "magnifier", + # Translators: State of the follow setting being toggled disabled. + "disabled", + ), + ), + ) + + +def toggleAllFollow() -> None: + """Toggle all follow settings at once and update focus immediately""" + magnifier: Magnifier = getMagnifier() + if magnifierIsActiveVerify( + magnifier, + MagnifierAction.TOGGLE_FOLLOW_SETTINGS, + ): + isActiveNow = toggleAllFollowStates() + magnifier._focusManager.updateFollowedFocus() + if not isActiveNow: + stateMessage = pgettext( + "magnifier", + # Translators: State of all follow settings being toggled disabled. + "All follow settings disabled", + ) + else: + stateMessage = pgettext( + "magnifier", + # Translators: State of all follow settings being toggled enabled. + "All follow settings enabled", + ) + ui.message(stateMessage) + + def toggleFullscreenMode() -> None: """Cycle through full-screen focus modes (center, border, relative)""" magnifier: Magnifier = getMagnifier() diff --git a/source/_magnifier/config.py b/source/_magnifier/config.py index 7fc38ba6ab5..16d7eb03ac0 100644 --- a/source/_magnifier/config.py +++ b/source/_magnifier/config.py @@ -9,7 +9,8 @@ """ import config -from .utils.types import Filter, FullScreenMode +from dataclasses import dataclass, field +from .utils.types import Filter, FullScreenMode, MagnifierFollowFocusType class ZoomLevel: @@ -78,9 +79,6 @@ def setDefaultZoomLevel(zoomLevel: float) -> None: :param zoomLevel: The zoom level to set. """ - - if "magnifier" not in config.conf: - config.conf["magnifier"] = {} config.conf["magnifier"]["defaultZoomLevel"] = zoomLevel @@ -99,9 +97,6 @@ def setDefaultPanStep(panStep: int) -> None: :param panStep: The pan value to set. """ - - if "magnifier" not in config.conf: - config.conf["magnifier"] = {} config.conf["magnifier"]["defaultPanStep"] = panStep @@ -123,6 +118,77 @@ def setDefaultFilter(filter: Filter) -> None: config.conf["magnifier"]["defaultFilter"] = filter.value +_FOLLOW_CONFIG_KEYS: dict[MagnifierFollowFocusType, str] = { + MagnifierFollowFocusType.MOUSE: "followMouse", + MagnifierFollowFocusType.SYSTEM_FOCUS: "followSystemFocus", + MagnifierFollowFocusType.REVIEW: "followReviewCursor", + MagnifierFollowFocusType.NAVIGATOR_OBJECT: "followNavigatorObject", +} + + +@dataclass +class _FollowStateOverride: + savedStates: dict[MagnifierFollowFocusType, bool] = field(default_factory=dict) + isActive: bool = False + + +_followStateOverride = _FollowStateOverride() + + +def _ensureSavedStatesInitialized() -> None: + """ + Populate _followStateOverride.savedStates from current config if not yet done. + Called lazily to avoid reading config.conf at module import time. + """ + if not _followStateOverride.savedStates: + saveFollowStates() + + +def getFollowState(focusType: MagnifierFollowFocusType) -> bool: + """ + Get the current follow state for a given focus type. + + :param focusType: The focus type to query. + :return: True if the magnifier follows the given focus type, False otherwise. + """ + return config.conf["magnifier"][_FOLLOW_CONFIG_KEYS[focusType]] + + +def setFollowState(focusType: MagnifierFollowFocusType, state: bool) -> None: + """ + Set the follow state for a given focus type. + + :param focusType: The focus type to update. + :param state: True to enable following, False to disable. + """ + config.conf["magnifier"][_FOLLOW_CONFIG_KEYS[focusType]] = state + + +def saveFollowStates() -> None: + """Save current follow states so they can be restored later.""" + for focusType in _FOLLOW_CONFIG_KEYS: + _followStateOverride.savedStates[focusType] = getFollowState(focusType) + + +def toggleAllFollowStates() -> bool: + """ + Toggle all follow states between forced-active and previously saved states. + + :return: True when all follow states are forced active after the call, False when restored. + """ + _ensureSavedStatesInitialized() + if _followStateOverride.isActive: + for focusType, state in _followStateOverride.savedStates.items(): + setFollowState(focusType, state) + _followStateOverride.isActive = False + else: + saveFollowStates() + for focusType in _FOLLOW_CONFIG_KEYS: + setFollowState(focusType, True) + _followStateOverride.isActive = True + return _followStateOverride.isActive + + def getDefaultFullscreenMode() -> FullScreenMode: """ Get default full-screen mode from config. diff --git a/source/_magnifier/utils/focusManager.py b/source/_magnifier/utils/focusManager.py index 20c04b00f6f..db9b8187669 100644 --- a/source/_magnifier/utils/focusManager.py +++ b/source/_magnifier/utils/focusManager.py @@ -11,7 +11,8 @@ import api import winUser import mouseHandler -from .types import Coordinates, FocusType +from .types import Coordinates, MagnifierFollowFocusType +from ..config import getFollowState class FocusManager: @@ -22,82 +23,111 @@ class FocusManager: def __init__(self): """Initialize the focus manager.""" - self._lastFocusedObject: FocusType | None = None + self._lastFocusedObject: MagnifierFollowFocusType | None = None self._lastMousePosition = Coordinates(0, 0) self._lastSystemFocusPosition = Coordinates(0, 0) + self._lastReviewPosition: Coordinates | None = None self._lastNavigatorObjectPosition = Coordinates(0, 0) self._lastValidSystemFocusPosition = Coordinates(0, 0) + self._lastValidReviewPosition = Coordinates(0, 0) self._lastValidNavigatorObjectPosition = Coordinates(0, 0) def getCurrentFocusCoordinates(self) -> Coordinates: """ Get the current focus coordinates based on priority. - Priority: Mouse > Navigator Object > System Focus + Priority: Mouse (drag) > Mouse > System Focus > Review > Navigator Object. + Special case: when both the system focus and navigator object change simultaneously + but the review cursor does not (e.g. table cell navigation via numpad), the navigator + object takes priority over system focus. + + Each source is only considered when its corresponding setting is enabled. :return: The (x, y) coordinates of the current focus """ - # Get all three positions - systemFocusPosition = self._getSystemFocusPosition() - navigatorObjectPosition = self._getNavigatorObjectPosition() mousePosition = self._getMousePosition() - - # Check if left mouse button is pressed + systemFocusPosition = self._getSystemFocusPosition() + reviewPosition = self._getReviewPosition() + navigatorPosition = self._getNavigatorObjectPosition() isClickPressed = mouseHandler.isLeftMouseButtonLocked() - # Track which positions have changed - systemFocusChanged = self._lastSystemFocusPosition != systemFocusPosition - navigatorObjectChanged = self._lastNavigatorObjectPosition != navigatorObjectPosition + # Cache settings once — each call reads from config.conf + isFollowMouse = getFollowState(MagnifierFollowFocusType.MOUSE) + isFollowSystemFocus = getFollowState(MagnifierFollowFocusType.SYSTEM_FOCUS) + isFollowReviewCursor = getFollowState(MagnifierFollowFocusType.REVIEW) + isFollowNavigatorObject = getFollowState(MagnifierFollowFocusType.NAVIGATOR_OBJECT) + mouseChanged = self._lastMousePosition != mousePosition + systemFocusChanged = self._lastSystemFocusPosition != systemFocusPosition + reviewChanged = reviewPosition is not None and self._lastReviewPosition != reviewPosition + navigatorChanged = self._lastNavigatorObjectPosition != navigatorPosition - # Update last positions - if systemFocusChanged: - self._lastSystemFocusPosition = systemFocusPosition - if navigatorObjectChanged: - self._lastNavigatorObjectPosition = navigatorObjectPosition + # Update tracked positions if mouseChanged: self._lastMousePosition = mousePosition - - # Priority 1: Mouse during drag & drop - if isClickPressed: - self._lastFocusedObject = FocusType.MOUSE - return mousePosition - - # Priority 2: Mouse movement (when not dragging) - if mouseChanged: - self._lastFocusedObject = FocusType.MOUSE + if systemFocusChanged: + self._lastSystemFocusPosition = systemFocusPosition + if reviewChanged: + self._lastReviewPosition = reviewPosition + if navigatorChanged: + self._lastNavigatorObjectPosition = navigatorPosition + + # Priority 1: Mouse — drag (fires even when stationary) or movement + if (isClickPressed or mouseChanged) and isFollowMouse: + self._lastFocusedObject = MagnifierFollowFocusType.MOUSE return mousePosition - # Priority 3: Navigator object – but only when it represents a genuinely independent movement. - if navigatorObjectChanged: - # If both navigator and system focus changed but ended up at the same - # coordinates, treat this as ordinary system-focus navigation. - # This avoids marking normal focus/caret movement as NAVIGATOR when - # the review cursor is merely following focus/caret. - if systemFocusChanged and navigatorObjectPosition == systemFocusPosition: - # Navigator followed focus/caret – behave as a system-focus event. - self._lastFocusedObject = FocusType.SYSTEM_FOCUS - return systemFocusPosition - self._lastFocusedObject = FocusType.NAVIGATOR - return navigatorObjectPosition - - # Priority 4: System focus (Tab, plain focus changes, browse-mode caret). - # Reached when only the system-focus position changed without a corresponding - # navigator change - if systemFocusChanged: - self._lastFocusedObject = FocusType.SYSTEM_FOCUS + # Special case: table cell navigation (numpad). + # When both the system focus and the navigator object change simultaneously but the + # review cursor does not, the navigator object reflects the user's explicit navigation + # intent and therefore takes priority over the system focus. + if navigatorChanged and systemFocusChanged and not reviewChanged and isFollowNavigatorObject: + self._lastFocusedObject = MagnifierFollowFocusType.NAVIGATOR_OBJECT + return navigatorPosition + + # Priority 2: System focus (focus object + browse mode cursor) + if systemFocusChanged and isFollowSystemFocus: + self._lastFocusedObject = MagnifierFollowFocusType.SYSTEM_FOCUS return systemFocusPosition - # No changes detected - return last focused position - match self._lastFocusedObject: - case FocusType.MOUSE: - return mousePosition - case FocusType.SYSTEM_FOCUS: - return systemFocusPosition - case FocusType.NAVIGATOR: - return navigatorObjectPosition - case _: - # Default to mouse if no previous focus - return mousePosition + # Priority 3: Review cursor + if reviewChanged and isFollowReviewCursor and reviewPosition is not None: + self._lastFocusedObject = MagnifierFollowFocusType.REVIEW + return reviewPosition + + # Priority 4: Navigator object (NumPad navigation) + if navigatorChanged and isFollowNavigatorObject: + self._lastFocusedObject = MagnifierFollowFocusType.NAVIGATOR_OBJECT + return navigatorPosition + + # Resolve the effective review position once (fallback to last valid when None) + reviewEffectivePosition = ( + reviewPosition if reviewPosition is not None else self._lastValidReviewPosition + ) + + # All sources in priority order + _sources = ( + (MagnifierFollowFocusType.MOUSE, isFollowMouse, mousePosition), + (MagnifierFollowFocusType.SYSTEM_FOCUS, isFollowSystemFocus, systemFocusPosition), + (MagnifierFollowFocusType.REVIEW, isFollowReviewCursor, reviewEffectivePosition), + (MagnifierFollowFocusType.NAVIGATOR_OBJECT, isFollowNavigatorObject, navigatorPosition), + ) + + # Keep current source if still enabled; otherwise mark it as NONE so we switch + for focusType, isEnabled, position in _sources: + if self._lastFocusedObject == focusType: + if isEnabled: + return position + self._lastFocusedObject = None + break + + # No active source – switch to the highest-priority enabled source + for focusType, isEnabled, position in _sources: + if isEnabled: + self._lastFocusedObject = focusType + return position + + # All sources disabled – return mouse position without changing focus state + return mousePosition def _getMousePosition(self) -> Coordinates: """ @@ -144,17 +174,19 @@ def _getSystemFocusPosition(self) -> Coordinates: def _getReviewPosition(self) -> Coordinates | None: """ - Get the current review position (review cursor). + Get the current review cursor position. - :return: The (x, y) coordinates of the review position, or None if not available + :return: The (x, y) coordinates of the review cursor, or ``None`` if not available. """ reviewPosition = api.getReviewPosition() if reviewPosition: try: point = reviewPosition.pointAtStart - return Coordinates(point.x, point.y) + coords = Coordinates(point.x, point.y) + if coords != Coordinates(0, 0): + self._lastValidReviewPosition = coords + return coords except (NotImplementedError, LookupError, AttributeError): - # Review position may not support pointAtStart pass return None @@ -179,29 +211,30 @@ def _getNavigatorObjectLocation(self) -> Coordinates | None: def _getNavigatorObjectPosition(self) -> Coordinates: """ Get the navigator object position (NumPad navigation). - Tries review position first, then navigator object location. - :return: The (x, y) coordinates of the navigator object - """ - # Try review position first - position = self._getReviewPosition() - if position and position != Coordinates(0, 0): - self._lastValidNavigatorObjectPosition = position - return position + Updates :attr:`_lastValidNavigatorObjectPosition` when a valid position is obtained. - # Fallback: use navigator object location + :return: The (x, y) coordinates of the navigator object center, + or the last valid position as fallback. + """ position = self._getNavigatorObjectLocation() if position and position != Coordinates(0, 0): self._lastValidNavigatorObjectPosition = position return position - - # Return last valid navigator object position instead of (0, 0) return self._lastValidNavigatorObjectPosition - def getLastFocusType(self) -> FocusType | None: + def getLastFocusType(self) -> MagnifierFollowFocusType | None: """ Get the type of the last focused object. - :return: The type of the last focused object + :return: The type of the last focused object, or None when no focus source is active. """ return self._lastFocusedObject + + def updateFollowedFocus(self) -> None: + """ + Force an update of the magnifier focus based on current settings. + Called after toggling follow settings to immediately apply changes. + """ + self._lastFocusedObject = None # Reset to force re-evaluation of focus + self.getCurrentFocusCoordinates() diff --git a/source/_magnifier/utils/types.py b/source/_magnifier/utils/types.py index cc18b9f70b6..2d5fcf7c7b2 100644 --- a/source/_magnifier/utils/types.py +++ b/source/_magnifier/utils/types.py @@ -41,6 +41,7 @@ class MagnifierAction(DisplayStringEnum): PAN_TOP_EDGE = auto() PAN_BOTTOM_EDGE = auto() TOGGLE_FILTER = auto() + TOGGLE_FOLLOW_SETTINGS = auto() CHANGE_FULLSCREEN_MODE = auto() START_SPOTLIGHT = auto() @@ -67,6 +68,8 @@ def _displayStringLabels(self) -> dict["MagnifierAction", str]: self.PAN_TOP_EDGE: pgettext("magnifier action", "pan to top edge"), # Translators: Action description for panning to bottom edge. self.PAN_BOTTOM_EDGE: pgettext("magnifier action", "pan to bottom edge"), + # Translators: Action description for toggling settings. + self.TOGGLE_FOLLOW_SETTINGS: pgettext("magnifier action", "toggle follow settings"), # Translators: Action description for toggling color filters. self.TOGGLE_FILTER: pgettext("magnifier action", "toggle filters"), # Translators: Action description for changing full-screen mode. @@ -76,6 +79,28 @@ def _displayStringLabels(self) -> dict["MagnifierAction", str]: } +class MagnifierFollowFocusType(DisplayStringEnum): + """Type of focus the magnifier should follow based on user settings""" + + MOUSE = auto() + SYSTEM_FOCUS = auto() + REVIEW = auto() + NAVIGATOR_OBJECT = auto() + + @property + def _displayStringLabels(self) -> dict["MagnifierFollowFocusType", str]: + return { + # Translators: Focus type for magnifier to follow - mouse cursor. + self.MOUSE: pgettext("magnifier follow focus type", "Mouse"), + # Translators: Focus type for magnifier to follow - system focus (active element). + self.SYSTEM_FOCUS: pgettext("magnifier follow focus type", "System focus"), + # Translators: Focus type for magnifier to follow - review cursor position. + self.REVIEW: pgettext("magnifier follow focus type", "Review cursor"), + # Translators: Focus type for magnifier to follow - navigator object position. + self.NAVIGATOR_OBJECT: pgettext("magnifier follow focus type", "Navigator object"), + } + + class MagnifierType(DisplayStringStrEnum): """Type of magnifier""" @@ -95,14 +120,6 @@ def _displayStringLabels(self) -> dict["MagnifierType", str]: } -class FocusType(Enum): - """Type of focus being tracked by the magnifier""" - - MOUSE = auto() - SYSTEM_FOCUS = auto() - NAVIGATOR = auto() - - class MagnifierPosition(NamedTuple): """Named tuple representing the position and size of the magnifier window""" diff --git a/source/config/configSpec.py b/source/config/configSpec.py index 378ad16bdd8..292f7c5b50c 100644 --- a/source/config/configSpec.py +++ b/source/config/configSpec.py @@ -117,10 +117,14 @@ # Magnifier settings [magnifier] defaultZoomLevel = float(min=1.0, max=10.0, default=2.0) - defaultPanStep = integer(min=1, max=100, default=10) - defaultFullscreenMode = string(default="center") isTrueCentered = boolean(default=False) defaultFilter = string(default="normal") + followMouse = boolean(default=True) + followSystemFocus = boolean(default=True) + followReviewCursor = boolean(default=True) + followNavigatorObject = boolean(default=True) + defaultPanStep = integer(min=1, max=100, default=10) + defaultFullscreenMode = string(default="center") keepMouseCentered = boolean(default=false) saveShortcutChanges = boolean(default=false) diff --git a/source/globalCommands.py b/source/globalCommands.py index 048ec2d3512..57490c715cb 100755 --- a/source/globalCommands.py +++ b/source/globalCommands.py @@ -26,6 +26,7 @@ import review import _magnifier import _magnifier.commands +from _magnifier.utils.types import MagnifierFollowFocusType import controlTypes import api import textInfos @@ -5204,6 +5205,71 @@ def script_toggleFilter( ) -> None: _magnifier.commands.toggleFilter() + @script( + description=_( + # Translators: Describes a command. + "Toggle follow mouse for the magnifier", + ), + category=SCRCAT_VISION, + ) + def script_toggleFollowMouse( + self, + gesture: inputCore.InputGesture, + ) -> None: + _magnifier.commands.toggleFollow(MagnifierFollowFocusType.MOUSE) + + @script( + description=_( + # Translators: Describes a command. + "Toggle follow system focus for the magnifier", + ), + category=SCRCAT_VISION, + ) + def script_toggleFollowSystemFocus( + self, + gesture: inputCore.InputGesture, + ) -> None: + _magnifier.commands.toggleFollow(MagnifierFollowFocusType.SYSTEM_FOCUS) + + @script( + description=_( + # Translators: Describes a command. + "Toggle follow review cursor for the magnifier", + ), + category=SCRCAT_VISION, + ) + def script_toggleFollowReview( + self, + gesture: inputCore.InputGesture, + ) -> None: + _magnifier.commands.toggleFollow(MagnifierFollowFocusType.REVIEW) + + @script( + description=_( + # Translators: Describes a command. + "Toggle follow navigator object for the magnifier", + ), + category=SCRCAT_VISION, + ) + def script_toggleFollowNavigatorObject( + self, + gesture: inputCore.InputGesture, + ) -> None: + _magnifier.commands.toggleFollow(MagnifierFollowFocusType.NAVIGATOR_OBJECT) + + @script( + description=_( + # Translators: Describes a command. + "Toggle all follow modes for the magnifier", + ), + category=SCRCAT_VISION, + ) + def script_toggleAllFollow( + self, + gesture: inputCore.InputGesture, + ) -> None: + _magnifier.commands.toggleAllFollow() + @script( description=_( # Translators: Describes a command. diff --git a/source/gui/settingsDialogs.py b/source/gui/settingsDialogs.py index 482d784d8f2..6f17f10f551 100644 --- a/source/gui/settingsDialogs.py +++ b/source/gui/settingsDialogs.py @@ -41,7 +41,7 @@ import languageHandler import logHandler import _magnifier.config as magnifierConfig -from _magnifier.utils.types import Filter, FullScreenMode +from _magnifier.utils.types import Filter, FullScreenMode, MagnifierFollowFocusType import queueHandler import requests import speech @@ -6125,6 +6125,34 @@ def makeSettings( defaultFullscreenMode = magnifierConfig.getDefaultFullscreenMode() self.defaultFullscreenModeList.SetSelection(list(FullScreenMode).index(defaultFullscreenMode)) + # FOCUS GROUP + # Translators: This is the label for a group of focus options in the magnifier settings panel + focusGroupText = _("Focus") + focusGroupSizer = wx.StaticBoxSizer(wx.VERTICAL, self, label=focusGroupText) + focusGroupBox = focusGroupSizer.GetStaticBox() + focusGroup = guiHelper.BoxSizerHelper(self, sizer=focusGroupSizer) + sHelper.addItem(focusGroup) + + _followFocusLabels: dict[MagnifierFollowFocusType, tuple[str, str]] = { + # Translators: The label for a setting in magnifier settings to select whether the magnifier view should follow the mouse + MagnifierFollowFocusType.MOUSE: (_("Follow &mouse"), "MagnifierFollowMouse"), + # Translators: The label for a setting in magnifier settings to select whether the magnifier view should follow the system focus + MagnifierFollowFocusType.SYSTEM_FOCUS: (_("Follow &system focus"), "MagnifierFollowSystemFocus"), + # Translators: The label for a setting in magnifier settings to select whether the magnifier view should follow the review cursor + MagnifierFollowFocusType.REVIEW: (_("Follow &review cursor"), "MagnifierFollowReviewCursor"), + MagnifierFollowFocusType.NAVIGATOR_OBJECT: ( + # Translators: The label for a setting in magnifier settings to select whether the magnifier view should follow the navigator object + _("Follow &navigator object"), + "MagnifierFollowNavigatorObject", + ), + } + self._followFocusCheckBoxes: dict[MagnifierFollowFocusType, wx.CheckBox] = {} + for focusType, (label, helpId) in _followFocusLabels.items(): + checkBox = focusGroup.addItem(wx.CheckBox(focusGroupBox, label=label)) + self.bindHelpEvent(helpId, checkBox) + checkBox.SetValue(magnifierConfig.getFollowState(focusType)) + self._followFocusCheckBoxes[focusType] = checkBox + # 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") @@ -6149,6 +6177,8 @@ def onSave(self): magnifierConfig.setDefaultFullscreenMode(list(FullScreenMode)[selectedModeIdx]) config.conf["magnifier"]["isTrueCentered"] = self.trueCenterCheckBox.GetValue() + for focusType, checkBox in self._followFocusCheckBoxes.items(): + magnifierConfig.setFollowState(focusType, checkBox.GetValue()) config.conf["magnifier"]["keepMouseCentered"] = self.keepMouseCenteredCheckBox.GetValue() diff --git a/tests/unit/test_magnifier/test_focusManager.py b/tests/unit/test_magnifier/test_focusManager.py index 24ca10e77d9..45d5f8e04b6 100644 --- a/tests/unit/test_magnifier/test_focusManager.py +++ b/tests/unit/test_magnifier/test_focusManager.py @@ -5,13 +5,29 @@ from dataclasses import dataclass from _magnifier.utils.focusManager import FocusManager -from _magnifier.utils.types import Coordinates, FocusType +from _magnifier.utils.types import Coordinates, MagnifierFollowFocusType import unittest from unittest.mock import MagicMock, Mock, patch import mouseHandler import winUser +def _makeFollowStateSideEffect( + followMouse: bool = True, + followSystemFocus: bool = True, + followReview: bool = True, + followNavigatorObject: bool = True, +): + """Return a side_effect function for patching getFollowState.""" + states = { + MagnifierFollowFocusType.MOUSE: followMouse, + MagnifierFollowFocusType.SYSTEM_FOCUS: followSystemFocus, + MagnifierFollowFocusType.REVIEW: followReview, + MagnifierFollowFocusType.NAVIGATOR_OBJECT: followNavigatorObject, + } + return states.__getitem__ + + @dataclass(frozen=True) class FocusTestParam: """Parameters for focus coordinate testing.""" @@ -21,9 +37,14 @@ class FocusTestParam: mousePos: tuple leftPressed: bool expectedCoords: Coordinates - expectedFocus: FocusType + expectedFocus: MagnifierFollowFocusType | None description: str = "" - lastFocusedObject: FocusType | None = None + lastFocusedObject: MagnifierFollowFocusType | None = None + reviewPos: Coordinates | None = None + followMouse: bool = True + followSystemFocus: bool = True + followReview: bool = True + followNavigatorObject: bool = True class TestFocusManager(unittest.TestCase): @@ -36,39 +57,66 @@ def setUp(self): def testFocusManagerCreation(self): """Can we create a FocusManager with initialized values?""" self.assertIsNone(self.focusManager._lastFocusedObject) + self.assertIsNone(self.focusManager._lastReviewPosition) + self.assertEqual(self.focusManager._lastMousePosition, Coordinates(0, 0)) self.assertEqual(self.focusManager._lastSystemFocusPosition, Coordinates(0, 0)) self.assertEqual(self.focusManager._lastNavigatorObjectPosition, Coordinates(0, 0)) - self.assertEqual(self.focusManager._lastMousePosition, Coordinates(0, 0)) + self.assertEqual(self.focusManager._lastValidSystemFocusPosition, Coordinates(0, 0)) + self.assertEqual(self.focusManager._lastValidReviewPosition, Coordinates(0, 0)) + self.assertEqual(self.focusManager._lastValidNavigatorObjectPosition, Coordinates(0, 0)) def testGetNavigatorObjectPosition(self): """Getting navigator object position with different API responses.""" - # Case 1: Review position successful + # Case 1: Navigator object location available + with patch("_magnifier.utils.focusManager.api.getNavigatorObject") as mock_navigator: + mock_navigator.return_value.location = (100, 150, 200, 300) + + coords = self.focusManager._getNavigatorObjectPosition() + # Center: (100 + 200//2, 150 + 300//2) = (200, 300) + self.assertEqual(coords, Coordinates(200, 300)) + + # Case 2: Navigator object fails - should return last valid position from Case 1 + with patch("_magnifier.utils.focusManager.api.getNavigatorObject") as mock_navigator: + mock_navigator.return_value.location = Mock(side_effect=Exception()) + + coords = self.focusManager._getNavigatorObjectPosition() + # Should return last valid position (200, 300) + self.assertEqual(coords, Coordinates(200, 300)) + + # Case 3: Navigator object is None - should return last valid position + with patch("_magnifier.utils.focusManager.api.getNavigatorObject", return_value=None): + coords = self.focusManager._getNavigatorObjectPosition() + self.assertEqual(coords, Coordinates(200, 300)) + + def testGetReviewPosition(self): + """Getting review cursor position with different API responses.""" + # Case 1: Review position available with patch("_magnifier.utils.focusManager.api.getReviewPosition") as mock_review: mock_point = Mock() mock_point.x = 300 mock_point.y = 400 mock_review.return_value.pointAtStart = mock_point - coords = self.focusManager._getNavigatorObjectPosition() + coords = self.focusManager._getReviewPosition() self.assertEqual(coords, Coordinates(300, 400)) + # _lastValidReviewPosition must be updated + self.assertEqual(self.focusManager._lastValidReviewPosition, Coordinates(300, 400)) - # Case 2: Review position fails, navigator object works - with patch("_magnifier.utils.focusManager.api.getReviewPosition", return_value=None): - with patch("_magnifier.utils.focusManager.api.getNavigatorObject") as mock_navigator: - mock_navigator.return_value.location = (100, 150, 200, 300) + # Case 2: pointAtStart raises NotImplementedError → returns None + with patch("_magnifier.utils.focusManager.api.getReviewPosition") as mock_review: + type(mock_review.return_value).pointAtStart = property( + fget=Mock(side_effect=NotImplementedError), + ) - coords = self.focusManager._getNavigatorObjectPosition() - # Center: (100 + 200//2, 150 + 300//2) = (200, 300) - self.assertEqual(coords, Coordinates(200, 300)) + coords = self.focusManager._getReviewPosition() + self.assertIsNone(coords) + # _lastValidReviewPosition must NOT change + self.assertEqual(self.focusManager._lastValidReviewPosition, Coordinates(300, 400)) - # Case 3: Everything fails - should return last valid position from Case 2 + # Case 3: getReviewPosition returns None → returns None with patch("_magnifier.utils.focusManager.api.getReviewPosition", return_value=None): - with patch("_magnifier.utils.focusManager.api.getNavigatorObject") as mock_navigator: - mock_navigator.return_value.location = Mock(side_effect=Exception()) - - coords = self.focusManager._getNavigatorObjectPosition() - # Should return last valid position (200, 300) - self.assertEqual(coords, Coordinates(200, 300)) + coords = self.focusManager._getReviewPosition() + self.assertIsNone(coords) def testGetSystemFocusPosition(self): """Getting system focus position with different API responses.""" @@ -113,7 +161,7 @@ def testGetCurrentFocusCoordinates(self): mousePos=(0, 0), leftPressed=True, expectedCoords=Coordinates(0, 0), - expectedFocus=FocusType.MOUSE, + expectedFocus=MagnifierFollowFocusType.MOUSE, description="Left click is pressed should return mouse position", ), FocusTestParam( @@ -122,7 +170,7 @@ def testGetCurrentFocusCoordinates(self): mousePos=(10, 10), leftPressed=False, expectedCoords=Coordinates(10, 10), - expectedFocus=FocusType.MOUSE, + expectedFocus=MagnifierFollowFocusType.MOUSE, description="Mouse moving (not dragging)", ), FocusTestParam( @@ -131,7 +179,7 @@ def testGetCurrentFocusCoordinates(self): mousePos=(0, 0), leftPressed=False, expectedCoords=Coordinates(15, 15), - expectedFocus=FocusType.SYSTEM_FOCUS, + expectedFocus=MagnifierFollowFocusType.SYSTEM_FOCUS, description="System focus changed alone (navigator did not move)", ), FocusTestParam( @@ -140,7 +188,7 @@ def testGetCurrentFocusCoordinates(self): mousePos=(0, 0), leftPressed=False, expectedCoords=Coordinates(20, 20), - expectedFocus=FocusType.NAVIGATOR, + expectedFocus=MagnifierFollowFocusType.NAVIGATOR_OBJECT, description="Navigator object changed (NumPad navigation)", ), FocusTestParam( @@ -149,18 +197,49 @@ def testGetCurrentFocusCoordinates(self): mousePos=(0, 0), leftPressed=False, expectedCoords=Coordinates(30, 30), - expectedFocus=FocusType.NAVIGATOR, + expectedFocus=MagnifierFollowFocusType.NAVIGATOR_OBJECT, description="Both system focus and navigator changed (table cell navigation): navigator wins", ), + FocusTestParam( + navigatorObjectPos=Coordinates(0, 0), + systemFocusPos=Coordinates(0, 0), + mousePos=(0, 0), + leftPressed=False, + reviewPos=Coordinates(30, 30), + expectedCoords=Coordinates(30, 30), + expectedFocus=MagnifierFollowFocusType.REVIEW, + description="Review cursor changed with followReview enabled", + ), + FocusTestParam( + navigatorObjectPos=Coordinates(20, 20), + systemFocusPos=Coordinates(0, 0), + mousePos=(0, 0), + leftPressed=False, + reviewPos=Coordinates(30, 30), + expectedCoords=Coordinates(30, 30), + expectedFocus=MagnifierFollowFocusType.REVIEW, + description="Review has higher priority than navigator", + ), + FocusTestParam( + navigatorObjectPos=Coordinates(20, 20), + systemFocusPos=Coordinates(0, 0), + mousePos=(0, 0), + leftPressed=False, + reviewPos=Coordinates(30, 30), + followReview=False, + expectedCoords=Coordinates(20, 20), + expectedFocus=MagnifierFollowFocusType.NAVIGATOR_OBJECT, + description="Review cursor ignored when followReview=False", + ), FocusTestParam( navigatorObjectPos=Coordinates(0, 0), systemFocusPos=Coordinates(0, 0), mousePos=(0, 0), leftPressed=False, expectedCoords=Coordinates(0, 0), - expectedFocus=FocusType.MOUSE, + expectedFocus=MagnifierFollowFocusType.MOUSE, description="Nothing changed, last was Mouse", - lastFocusedObject=FocusType.MOUSE, + lastFocusedObject=MagnifierFollowFocusType.MOUSE, ), FocusTestParam( navigatorObjectPos=Coordinates(0, 0), @@ -168,9 +247,20 @@ def testGetCurrentFocusCoordinates(self): mousePos=(0, 0), leftPressed=False, expectedCoords=Coordinates(0, 0), - expectedFocus=FocusType.NAVIGATOR, + expectedFocus=MagnifierFollowFocusType.NAVIGATOR_OBJECT, description="Nothing changed, last was NAVIGATOR", - lastFocusedObject=FocusType.NAVIGATOR, + lastFocusedObject=MagnifierFollowFocusType.NAVIGATOR_OBJECT, + ), + FocusTestParam( + navigatorObjectPos=Coordinates(0, 0), + systemFocusPos=Coordinates(0, 0), + mousePos=(0, 0), + leftPressed=False, + reviewPos=Coordinates(30, 30), + expectedCoords=Coordinates(30, 30), + expectedFocus=MagnifierFollowFocusType.REVIEW, + description="Nothing changed, last was REVIEW - returns current review position", + lastFocusedObject=MagnifierFollowFocusType.REVIEW, ), FocusTestParam( navigatorObjectPos=Coordinates(10, 10), @@ -178,7 +268,7 @@ def testGetCurrentFocusCoordinates(self): mousePos=(20, 20), leftPressed=False, expectedCoords=Coordinates(20, 20), - expectedFocus=FocusType.MOUSE, + expectedFocus=MagnifierFollowFocusType.MOUSE, description="Both mouse and navigator object moved (mouse has priority)", ), FocusTestParam( @@ -187,7 +277,7 @@ def testGetCurrentFocusCoordinates(self): mousePos=(20, 20), leftPressed=True, expectedCoords=Coordinates(20, 20), - expectedFocus=FocusType.MOUSE, + expectedFocus=MagnifierFollowFocusType.MOUSE, description="All three moved while dragging (mouse drag has highest priority)", ), ] @@ -198,21 +288,31 @@ def testGetCurrentFocusCoordinates(self): self.focusManager._lastNavigatorObjectPosition = Coordinates(0, 0) self.focusManager._lastSystemFocusPosition = Coordinates(0, 0) self.focusManager._lastMousePosition = Coordinates(0, 0) + self.focusManager._lastReviewPosition = None + self.focusManager._lastFocusedObject = param.lastFocusedObject - # Set lastFocusedObject if specified - if param.lastFocusedObject is not None: - self.focusManager._lastFocusedObject = param.lastFocusedObject - - # Mock methods + # Mock instance methods self.focusManager._getNavigatorObjectPosition = MagicMock( return_value=param.navigatorObjectPos, ) self.focusManager._getSystemFocusPosition = MagicMock(return_value=param.systemFocusPos) + self.focusManager._getReviewPosition = MagicMock(return_value=param.reviewPos) mouseHandler.isLeftMouseButtonLocked = MagicMock(return_value=param.leftPressed) winUser.getCursorPos = MagicMock(return_value=param.mousePos) - # Execute - focusCoordinates = self.focusManager.getCurrentFocusCoordinates() + followStateSideEffect = _makeFollowStateSideEffect( + followMouse=param.followMouse, + followSystemFocus=param.followSystemFocus, + followReview=param.followReview, + followNavigatorObject=param.followNavigatorObject, + ) + + with patch( + "_magnifier.utils.focusManager.getFollowState", + side_effect=followStateSideEffect, + ): + # Execute + focusCoordinates = self.focusManager.getCurrentFocusCoordinates() # Assert self.assertEqual(focusCoordinates, param.expectedCoords) @@ -222,11 +322,197 @@ def testGetLastFocusType(self): """Test getting the last focus type.""" self.assertIsNone(self.focusManager.getLastFocusType()) - self.focusManager._lastFocusedObject = FocusType.MOUSE - self.assertEqual(self.focusManager.getLastFocusType(), FocusType.MOUSE) + for focusType in MagnifierFollowFocusType: + self.focusManager._lastFocusedObject = focusType + self.assertEqual(self.focusManager.getLastFocusType(), focusType) + + +class TestFollowSettings(unittest.TestCase): + """Verify that each follow* setting actually gates its source.""" + + def setUp(self): + self.focusManager = FocusManager() + self.focusManager._lastMousePosition = Coordinates(0, 0) + self.focusManager._lastSystemFocusPosition = Coordinates(0, 0) + self.focusManager._lastReviewPosition = None + self.focusManager._lastNavigatorObjectPosition = Coordinates(0, 0) + + def _run(self, *, followMouse, followSystemFocus, followReview, followNavigatorObject): + """Run getCurrentFocusCoordinates with all sources moved and the given settings.""" + self.focusManager._getMousePosition = MagicMock(return_value=Coordinates(10, 10)) + self.focusManager._getSystemFocusPosition = MagicMock(return_value=Coordinates(20, 20)) + self.focusManager._getReviewPosition = MagicMock(return_value=Coordinates(30, 30)) + self.focusManager._getNavigatorObjectPosition = MagicMock(return_value=Coordinates(40, 40)) + mouseHandler.isLeftMouseButtonLocked = MagicMock(return_value=False) + + followStateSideEffect = _makeFollowStateSideEffect( + followMouse=followMouse, + followSystemFocus=followSystemFocus, + followReview=followReview, + followNavigatorObject=followNavigatorObject, + ) + + with patch( + "_magnifier.utils.focusManager.getFollowState", + side_effect=followStateSideEffect, + ): + return self.focusManager.getCurrentFocusCoordinates() + + def testFollowMouseDisabled(self): + """When followMouse=False, mouse changes are ignored and system focus wins.""" + coords = self._run( + followMouse=False, + followSystemFocus=True, + followReview=True, + followNavigatorObject=True, + ) + self.assertEqual(coords, Coordinates(20, 20)) + self.assertEqual(self.focusManager.getLastFocusType(), MagnifierFollowFocusType.SYSTEM_FOCUS) + + def testFollowSystemFocusDisabled(self): + """When followSystemFocus=False, system focus changes are ignored and review wins.""" + coords = self._run( + followMouse=False, + followSystemFocus=False, + followReview=True, + followNavigatorObject=True, + ) + self.assertEqual(coords, Coordinates(30, 30)) + self.assertEqual(self.focusManager.getLastFocusType(), MagnifierFollowFocusType.REVIEW) + + def testFollowReviewDisabled(self): + """When followReview=False, review changes are ignored and navigator wins.""" + coords = self._run( + followMouse=False, + followSystemFocus=False, + followReview=False, + followNavigatorObject=True, + ) + self.assertEqual(coords, Coordinates(40, 40)) + self.assertEqual(self.focusManager.getLastFocusType(), MagnifierFollowFocusType.NAVIGATOR_OBJECT) + + def testAllFollowDisabled(self): + """When all settings are False, no source fires and we fall back to default mouse.""" + coords = self._run( + followMouse=False, + followSystemFocus=False, + followReview=False, + followNavigatorObject=False, + ) + # No previous focus → default branch returns current mouse position + self.assertEqual(coords, Coordinates(10, 10)) + + def testFollowMouseDragIgnoresSettings(self): + """Mouse drag (left click held) with followMouse=True always wins regardless of others.""" + self.focusManager._getMousePosition = MagicMock(return_value=Coordinates(10, 10)) + self.focusManager._getSystemFocusPosition = MagicMock(return_value=Coordinates(20, 20)) + self.focusManager._getReviewPosition = MagicMock(return_value=Coordinates(30, 30)) + self.focusManager._getNavigatorObjectPosition = MagicMock(return_value=Coordinates(40, 40)) + mouseHandler.isLeftMouseButtonLocked = MagicMock(return_value=True) + + followStateSideEffect = _makeFollowStateSideEffect( + followMouse=True, + followSystemFocus=True, + followReview=True, + followNavigatorObject=True, + ) + + with patch( + "_magnifier.utils.focusManager.getFollowState", + side_effect=followStateSideEffect, + ): + coords = self.focusManager.getCurrentFocusCoordinates() + + self.assertEqual(coords, Coordinates(10, 10)) + self.assertEqual(self.focusManager.getLastFocusType(), MagnifierFollowFocusType.MOUSE) + + def testDisableFollowMouseFallsToSystemFocus(self): + """When followMouse is disabled after mouse was the last focus, should fall back to system focus.""" + # Simulate: mouse was previously the active focus source + self.focusManager._lastFocusedObject = MagnifierFollowFocusType.MOUSE + # Positions haven't changed from last recorded values (no "change" detected) + self.focusManager._lastMousePosition = Coordinates(10, 10) + self.focusManager._lastSystemFocusPosition = Coordinates(20, 20) + self.focusManager._lastNavigatorObjectPosition = Coordinates(40, 40) + + self.focusManager._getMousePosition = MagicMock(return_value=Coordinates(10, 10)) + self.focusManager._getSystemFocusPosition = MagicMock(return_value=Coordinates(20, 20)) + self.focusManager._getReviewPosition = MagicMock(return_value=None) + self.focusManager._getNavigatorObjectPosition = MagicMock(return_value=Coordinates(40, 40)) + mouseHandler.isLeftMouseButtonLocked = MagicMock(return_value=False) + + followStateSideEffect = _makeFollowStateSideEffect( + followMouse=False, + followSystemFocus=True, + followReview=True, + followNavigatorObject=True, + ) + + with patch( + "_magnifier.utils.focusManager.getFollowState", + side_effect=followStateSideEffect, + ): + coords = self.focusManager.getCurrentFocusCoordinates() + + self.assertEqual(coords, Coordinates(20, 20)) + self.assertEqual(self.focusManager.getLastFocusType(), MagnifierFollowFocusType.SYSTEM_FOCUS) + + def testDisableFollowMouseWhileMouseMovingFallsToSystemFocus(self): + """When followMouse is disabled and mouse is still moving, should not follow mouse.""" + self.focusManager._lastFocusedObject = MagnifierFollowFocusType.MOUSE + self.focusManager._lastMousePosition = Coordinates(10, 10) + self.focusManager._lastSystemFocusPosition = Coordinates(20, 20) + self.focusManager._lastNavigatorObjectPosition = Coordinates(40, 40) + + # Mouse has moved but followMouse is False + self.focusManager._getMousePosition = MagicMock(return_value=Coordinates(15, 15)) + self.focusManager._getSystemFocusPosition = MagicMock(return_value=Coordinates(20, 20)) + self.focusManager._getReviewPosition = MagicMock(return_value=None) + self.focusManager._getNavigatorObjectPosition = MagicMock(return_value=Coordinates(40, 40)) + mouseHandler.isLeftMouseButtonLocked = MagicMock(return_value=False) + + followStateSideEffect = _makeFollowStateSideEffect( + followMouse=False, + followSystemFocus=True, + followReview=True, + followNavigatorObject=True, + ) + + with patch( + "_magnifier.utils.focusManager.getFollowState", + side_effect=followStateSideEffect, + ): + coords = self.focusManager.getCurrentFocusCoordinates() + + self.assertEqual(coords, Coordinates(20, 20)) + self.assertEqual(self.focusManager.getLastFocusType(), MagnifierFollowFocusType.SYSTEM_FOCUS) + + def testDisableFollowSystemFocusFallsToReview(self): + """When last focus was system focus and its setting is disabled, should fall to review.""" + self.focusManager._lastFocusedObject = MagnifierFollowFocusType.SYSTEM_FOCUS + self.focusManager._lastMousePosition = Coordinates(10, 10) + self.focusManager._lastSystemFocusPosition = Coordinates(20, 20) + self.focusManager._lastReviewPosition = Coordinates(30, 30) + self.focusManager._lastNavigatorObjectPosition = Coordinates(40, 40) + + self.focusManager._getMousePosition = MagicMock(return_value=Coordinates(10, 10)) + self.focusManager._getSystemFocusPosition = MagicMock(return_value=Coordinates(20, 20)) + self.focusManager._getReviewPosition = MagicMock(return_value=Coordinates(30, 30)) + self.focusManager._getNavigatorObjectPosition = MagicMock(return_value=Coordinates(40, 40)) + mouseHandler.isLeftMouseButtonLocked = MagicMock(return_value=False) + + followStateSideEffect = _makeFollowStateSideEffect( + followMouse=False, + followSystemFocus=False, + followReview=True, + followNavigatorObject=True, + ) - self.focusManager._lastFocusedObject = FocusType.NAVIGATOR - self.assertEqual(self.focusManager.getLastFocusType(), FocusType.NAVIGATOR) + with patch( + "_magnifier.utils.focusManager.getFollowState", + side_effect=followStateSideEffect, + ): + coords = self.focusManager.getCurrentFocusCoordinates() - self.focusManager._lastFocusedObject = FocusType.SYSTEM_FOCUS - self.assertEqual(self.focusManager.getLastFocusType(), FocusType.SYSTEM_FOCUS) + self.assertEqual(coords, Coordinates(30, 30)) + self.assertEqual(self.focusManager.getLastFocusType(), MagnifierFollowFocusType.REVIEW) diff --git a/user_docs/en/userGuide.md b/user_docs/en/userGuide.md index 5c8580c9c5c..43ba209edc9 100644 --- a/user_docs/en/userGuide.md +++ b/user_docs/en/userGuide.md @@ -2947,6 +2947,54 @@ This option is disabled by default. |Options |Disabled, Enabled| |Default |Disabled| +#### Follow mouse {#MagnifierFollowMouse} + +This checkbox controls whether the magnifier should follow the mouse pointer. +When enabled, the magnified area will automatically move to follow the mouse pointer, which can be helpful for users who navigate primarily using the mouse rather than the keyboard. + +This option is enabled by default. + +| . {.hideHeaderRow} |.| +|---|---| +|Options |Disabled, Enabled| +|Default |Enabled| + +#### Follow system focus {#MagnifierFollowSystemFocus} + +This checkbox controls whether the magnifier should follow the system focus. +When enabled, the magnified area will automatically move to follow the system focus, which can be helpful for users who navigate primarily using the keyboard and want the magnifier to track their navigation. + +This option is enabled by default. + +| . {.hideHeaderRow} |.| +|---|---| +|Options |Disabled, Enabled| +|Default |Enabled| + +#### Follow review cursor {#MagnifierFollowReviewCursor} + +This checkbox controls whether the magnifier should follow the review cursor. +When enabled, the magnified area will automatically move to follow the review cursor, which can be helpful for users who use the review cursor to navigate through content and want the magnifier to track their navigation. + +This option is enabled by default. + +| . {.hideHeaderRow} |.| +|---|---| +|Options |Disabled, Enabled| +|Default |Enabled| + +#### Follow navigator object {#MagnifierFollowNavigatorObject} + +This checkbox controls whether the magnifier should follow the navigator object. +When enabled, the magnified area will automatically move to follow the navigator object, which can be helpful for users who use object navigation to navigate through content and want the magnifier to track their navigation. + +This option is enabled by default. + +| . {.hideHeaderRow} |.| +|---|---| +|Options |Disabled, Enabled| +|Default |Enabled| + ##### Keep mouse centered {#MagnifierKeepMouseCentered} This checkbox controls whether the mouse pointer should be automatically moved to the center of the magnified area when certain focus events occur.