Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
53 changes: 51 additions & 2 deletions source/_magnifier/utils/focusManager.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,14 @@
Handles all focus tracking logic and coordinate calculations.
"""

from logHandler import log
import api
import winUser
import mouseHandler
import time
import locationHelper
import textInfos
from textInfos.offsets import OffsetsTextInfo
from .types import Coordinates, FocusType


Expand All @@ -20,6 +25,8 @@ class FocusManager:
Tracks mouse, system focus, and navigator object positions.
"""

_SYSTEM_FOCUS_STICKINESS_SECONDS: float = 0.12

def __init__(self):
"""Initialize the focus manager."""
self._lastFocusedObject: FocusType | None = None
Expand All @@ -28,6 +35,7 @@ def __init__(self):
self._lastNavigatorObjectPosition = Coordinates(0, 0)
self._lastValidSystemFocusPosition = Coordinates(0, 0)
self._lastValidNavigatorObjectPosition = Coordinates(0, 0)
self._lastSystemFocusChangeTime: float = 0.0

def getCurrentFocusCoordinates(self) -> Coordinates:
"""
Expand All @@ -36,6 +44,8 @@ def getCurrentFocusCoordinates(self) -> Coordinates:
:return: The (x, y) coordinates of the current focus
"""
now = time.monotonic()

# Get all three positions
systemFocusPosition = self._getSystemFocusPosition()
navigatorObjectPosition = self._getNavigatorObjectPosition()
Expand All @@ -52,6 +62,7 @@ def getCurrentFocusCoordinates(self) -> Coordinates:
# Update last positions
if systemFocusChanged:
self._lastSystemFocusPosition = systemFocusPosition
self._lastSystemFocusChangeTime = now
if navigatorObjectChanged:
self._lastNavigatorObjectPosition = navigatorObjectPosition
if mouseChanged:
Expand All @@ -69,6 +80,14 @@ def getCurrentFocusCoordinates(self) -> Coordinates:

# Priority 3: Navigator object – but only when it represents a genuinely independent movement.
if navigatorObjectChanged:
# If system focus just changed and we were already tracking it, keep system focus
# briefly to avoid visible oscillation while editing text.
if (
self._lastFocusedObject == FocusType.SYSTEM_FOCUS
and now - self._lastSystemFocusChangeTime <= self._SYSTEM_FOCUS_STICKINESS_SECONDS
):
return systemFocusPosition

# 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
Expand Down Expand Up @@ -118,7 +137,7 @@ def _getSystemFocusPosition(self) -> Coordinates:
try:
# Get caret position (works for both browse mode and regular focus)
caretPosition = api.getCaretPosition()
point = caretPosition.pointAtStart
point = self._getPointAtStart(caretPosition)
coords = Coordinates(point.x, point.y)
# Store as last valid position if not (0, 0)
if coords != Coordinates(0, 0):
Expand Down Expand Up @@ -151,13 +170,43 @@ def _getReviewPosition(self) -> Coordinates | None:
reviewPosition = api.getReviewPosition()
if reviewPosition:
try:
point = reviewPosition.pointAtStart
point = self._getPointAtStart(reviewPosition)
return Coordinates(point.x, point.y)
except (NotImplementedError, LookupError, AttributeError):
# Review position may not support pointAtStart
pass
return None

def _getPointAtStart(self, textInfo: textInfos.TextInfo) -> locationHelper.Point:
"""
Get a point for the start of a text range with a local end-of-text fallback.
When a collapsed TextInfo is positioned at an exclusive end offset, use the
right edge of the previous character if available. This keeps the workaround
local to the magnifier instead of changing TextInfo behavior globally.
"""
try:
return textInfo.pointAtStart
except (NotImplementedError, LookupError, AttributeError) as e:
Comment thread
seanbudd marked this conversation as resolved.
log.debug(f"pointAtStart failed for {textInfo!r}: {e}", exc_info=True)
originalExc = e

# Only apply the fallback for TextInfos exposing the offset-based internals
# we need. Otherwise, preserve the original failure.
if not (isinstance(textInfo, OffsetsTextInfo) and textInfo.isCollapsed and textInfo._startOffset > 0):
raise originalExc
Comment thread
seanbudd marked this conversation as resolved.

prevOffset = textInfo._startOffset - 1
try:
return textInfo._getBoundingRectFromOffset(prevOffset).topRight
except (NotImplementedError, LookupError, AttributeError) as e:
log.debug(f"_getBoundingRectFromOffset failed: {e}", exc_info=True)
try:
return textInfo._getPointFromOffset(prevOffset)
except (NotImplementedError, LookupError, AttributeError) as e:
log.debug(f"_getPointFromOffset failed: {e}", exc_info=True)
raise originalExc
Comment thread
seanbudd marked this conversation as resolved.

def _getNavigatorObjectLocation(self) -> Coordinates | None:
"""
Get the navigator object location from its bounding rectangle.
Expand Down
2 changes: 2 additions & 0 deletions tests/unit/test_magnifier/test_focusManager.py
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,8 @@ def testGetCurrentFocusCoordinates(self):
self.focusManager._lastNavigatorObjectPosition = Coordinates(0, 0)
self.focusManager._lastSystemFocusPosition = Coordinates(0, 0)
self.focusManager._lastMousePosition = Coordinates(0, 0)
self.focusManager._lastFocusedObject = None
self.focusManager._lastSystemFocusChangeTime = 0.0

# Set lastFocusedObject if specified
if param.lastFocusedObject is not None:
Expand Down
Loading