Skip to content
Closed
Show file tree
Hide file tree
Changes from 3 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
11 changes: 7 additions & 4 deletions source/_magnifier/fullscreenMagnifier.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
Size,
MagnifierParameters,
Coordinates,
MagnifierFollowFocusType,
)
from .config import getFullscreenMode, isTrueCentered
from .utils.errorHandling import trackNativeMagnifierErrors
Expand Down Expand Up @@ -81,6 +82,8 @@ def _startMagnifier(self) -> None:

if self._isActive:
self._applyFilter()
initialCoords = self._getCoordinatesForMode(self.currentCoordinates)
self._initPositionAnimator(initialCoords)
self._startTimer(self._updateMagnifier)

def _initializeNativeMagnification(self) -> None:
Expand Down Expand Up @@ -111,11 +114,10 @@ def _doUpdate(self):
"""
Perform the actual update of the magnifier
"""
# Calculate new position based on focus mode
coordinates = self._getCoordinatesForMode(self.currentCoordinates)
# Always save screen position for mode continuity
targetCoords = self._getCoordinatesForMode(self.currentCoordinates)
isMouseTracking = self._focusManager.getLastFocusType() == MagnifierFollowFocusType.MOUSE
coordinates = self._advanceAnimation(targetCoords, animate=not isMouseTracking)
self._lastScreenPosition = coordinates

self._fullscreenMagnifier(coordinates)

@override
Expand Down Expand Up @@ -375,6 +377,7 @@ def _stopSpotlight(self) -> None:
Stop and destroy Spotlight from Full-screen class
"""
self._spotlightManager._spotlightIsActive = False
self._resetPositionAnimator()
self._startTimer(self._updateMagnifier)

@override
Expand Down
50 changes: 50 additions & 0 deletions source/_magnifier/magnifier.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
Direction,
Filter,
Coordinates,
AnimationFrame,
)
from .config import (
getZoomLevel,
Expand All @@ -35,13 +36,16 @@
shouldKeepMouseCentered,
)
from .utils.focusManager import FocusManager
from .utils.animationManager import AnimationManager


class Magnifier:
_TIMER_INTERVAL_MS: int = 12
_MARGIN_BORDER: int = 50
_MAX_CONSECUTIVE_ERRORS: int = 3
_MAGNIFIED_VIEW: MagnifiedView
_ANIMATION_SPEED_PX_PER_TICK: float = 12.0 # pixels per 12 ms tick ≈ 1000 px/s
_ANIMATION_MAX_STEPS: int = 25 # cap at 300 ms for large jumps

def __init__(self):
self._displayOrientation = getPrimaryDisplayOrientation()
Expand All @@ -57,6 +61,8 @@ def __init__(self):
self._isManualPanning: bool = False
self._consecutiveErrors: int = 0
self._recoveryAttempts: int = 0
self._positionAnimator: AnimationManager | None = None
self._targetScreenPosition: Coordinates | None = None
# Register for display changes
_displayTracking.displayChanged.register(self._onDisplayChanged)
self._screenCurtainIsActive: bool = False
Expand Down Expand Up @@ -164,6 +170,50 @@ def _onDisplayChanged(self, orientationState: OrientationState) -> None:
log.debug("Display configuration changed, updating screen dimensions")
self.orientationState = orientationState

def _initPositionAnimator(self, initialCoords: Coordinates) -> None:
"""
Create and start the position animator at a known screen position.
Call this in _startMagnifier once the initial screen coordinates are known.
"""
self._positionAnimator = AnimationManager(
speedPxPerTick=self._ANIMATION_SPEED_PX_PER_TICK,
maxSteps=self._ANIMATION_MAX_STEPS,
)
self._positionAnimator.start(AnimationFrame(self.zoomLevel, initialCoords))
self._targetScreenPosition = initialCoords

def _resetPositionAnimator(self) -> None:
"""
Discard the current animation state so the animator reinitialises on the next
_advanceAnimation call. Call this whenever an external event (e.g. spotlight end)
puts the view in an unknown position.
"""
if self._positionAnimator is not None:
self._positionAnimator.reset()
self._targetScreenPosition = None

def _advanceAnimation(self, targetCoords: Coordinates, animate: bool = True) -> Coordinates:
"""
Tick the position animator toward targetCoords and return the interpolated position.
If no animator has been initialised, returns targetCoords unchanged (no animation).

When animate is False the view snaps directly to targetCoords and the animator is
synchronised to that position so there is no visual jump when animation resumes.
"""
if self._positionAnimator is None:
return targetCoords
if not animate:
self._positionAnimator.start(AnimationFrame(self.zoomLevel, targetCoords))
self._targetScreenPosition = targetCoords
return targetCoords
if self._positionAnimator.currentFrame is None:
self._positionAnimator.start(AnimationFrame(self.zoomLevel, targetCoords))
self._targetScreenPosition = targetCoords
if targetCoords != self._targetScreenPosition:
self._targetScreenPosition = targetCoords
self._positionAnimator.setTarget(AnimationFrame(self.zoomLevel, targetCoords))
return self._positionAnimator.tick().coordinates

def _startMagnifier(self) -> None:
"""
Start the magnifier
Expand Down
148 changes: 148 additions & 0 deletions source/_magnifier/utils/animationManager.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
# 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

"""
Linear animation manager for smooth magnifier transitions.
Does not own a timer — callers drive animation by invoking tick() at their own cadence.
"""

import math
from typing import Callable
from .types import AnimationFrame, Coordinates


class AnimationManager:
"""
Drives fixed-step linear interpolation between two AnimationFrames.

Each tick() call advances the animation by exactly 1/totalSteps of the total distance.
The animation completes after exactly totalSteps ticks, with no asymptotic tail.

Callers are responsible for the timer: call tick() each interval until isComplete is True.
A new setTarget() call mid-animation redirects from the current frame in a new totalSteps sequence.

When speedPxPerTick is provided, setTarget() computes totalSteps from the Euclidean distance
between the current and target coordinates, capped at maxSteps. An explicit totalSteps
argument to setTarget() always takes precedence over the auto-computed value.
"""

def __init__(
self,
totalSteps: int = 40,
speedPxPerTick: float | None = None,
maxSteps: int | None = None,
) -> None:
"""
:param totalSteps: Default number of ticks per animation segment.
Used when speedPxPerTick is None, or as the fallback when distance is zero.
At 12 ms/tick, 40 steps = 480 ms (matches the original spotlight animation).
:param speedPxPerTick: Pixels to travel per tick. When set, setTarget() computes
the number of steps from distance ÷ speed so every segment animates at the same
visual velocity regardless of distance.
:param maxSteps: Upper bound on the auto-computed step count. Has no effect when
speedPxPerTick is None.
"""
self._totalSteps = totalSteps
self._speedPxPerTick = speedPxPerTick
self._maxSteps = maxSteps
Comment thread
Boumtchack marked this conversation as resolved.
self._step: int = 0
self._start: AnimationFrame | None = None
self._current: AnimationFrame | None = None
self._target: AnimationFrame | None = None
self._onComplete: Callable[[], None] | None = None
self._isComplete: bool = True

def start(self, initial: AnimationFrame) -> None:
"""Initialise the animator at a known frame. Must be called before the first tick()."""
self._start = initial
self._current = initial
self._target = initial
self._isComplete = True
self._onComplete = None
self._step = 0

def reset(self) -> None:
"""
Return to uninitialised state without discarding speed/step configuration.
After reset(), start() must be called before tick() can be used again.
"""
self._step = 0
self._start = None
self._current = None
self._target = None
self._onComplete = None
self._isComplete = True

def setTarget(
self,
target: AnimationFrame,
onComplete: Callable[[], None] | None = None,
totalSteps: int | None = None,
) -> None:
"""
Set a new destination frame, starting a fresh linear sequence from the current position.
Redirects any in-progress animation from the current frame without jumping.

Step count resolution (highest priority first):
1. The explicit totalSteps argument, if provided.
2. Auto-computed from distance when speedPxPerTick was set at construction.
3. The totalSteps value from the constructor (unchanged).
"""
self._start = self._current
self._target = target
self._onComplete = onComplete
self._isComplete = False
self._step = 0
Comment thread
Boumtchack marked this conversation as resolved.
if totalSteps is not None:
self._totalSteps = totalSteps
Comment thread
Boumtchack marked this conversation as resolved.
elif self._speedPxPerTick is not None and self._start is not None:
dx = target.coordinates.x - self._start.coordinates.x
dy = target.coordinates.y - self._start.coordinates.y
dist = math.hypot(dx, dy)
steps = round(dist / self._speedPxPerTick)
Comment thread
Boumtchack marked this conversation as resolved.
if self._maxSteps is not None:
steps = min(steps, self._maxSteps)
self._totalSteps = max(1, steps)

def tick(self) -> AnimationFrame:
"""
Advance the animation by one linear step and return the resulting frame.
Calls onComplete once when totalSteps is reached.
Raises RuntimeError if start() has not been called yet.
"""
if self._current is None:
raise RuntimeError("AnimationManager not initialised — call start() first")
if self._isComplete or self._target is None:
return self._current

self._step += 1
t = self._step / self._totalSteps
Comment thread
Boumtchack marked this conversation as resolved.

zoom = self._start.zoomLevel + (self._target.zoomLevel - self._start.zoomLevel) * t
x = self._start.coordinates.x + (self._target.coordinates.x - self._start.coordinates.x) * t
y = self._start.coordinates.y + (self._target.coordinates.y - self._start.coordinates.y) * t

if self._step >= self._totalSteps:
self._current = self._target
self._isComplete = True
if self._onComplete:
cb = self._onComplete
self._onComplete = None
cb()
else:
self._current = AnimationFrame(
round(zoom, 2),
Coordinates(round(x), round(y)),
)

return self._current

@property
def isComplete(self) -> bool:
return self._isComplete

@property
def currentFrame(self) -> AnimationFrame | None:
return self._current
Loading
Loading