Skip to content
Open
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:
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we debug log this error?

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 on lines +196 to +197
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it would be cleaner to just indent this into the except block. It will make reading the logs when it's raised more obvious too


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 on lines +204 to +208
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
try:
return textInfo._getPointFromOffset(prevOffset)
except (NotImplementedError, LookupError, AttributeError):
pass
raise originalExc
log.debug(...)
try:
return textInfo._getPointFromOffset(prevOffset)
except (NotImplementedError, LookupError, AttributeError) as e:
log.debug(...)
raise e

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I kept both fallbacks for better precision and added debug logging to each. The final raise re-raises the original pointAtStart error, which is the most useful for debugging.


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