diff --git a/source/_magnifier/fullscreenMagnifier.py b/source/_magnifier/fullscreenMagnifier.py index 94caad051cc..e1000c20aa6 100644 --- a/source/_magnifier/fullscreenMagnifier.py +++ b/source/_magnifier/fullscreenMagnifier.py @@ -24,6 +24,7 @@ Size, MagnifierParameters, Coordinates, + MagnifierFollowFocusType, ) from .config import getFullscreenMode, isTrueCentered from .utils.errorHandling import trackNativeMagnifierErrors @@ -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: @@ -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 @@ -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 diff --git a/source/_magnifier/magnifier.py b/source/_magnifier/magnifier.py index a44e024e3af..a4a347fd47e 100644 --- a/source/_magnifier/magnifier.py +++ b/source/_magnifier/magnifier.py @@ -25,6 +25,7 @@ Direction, Filter, Coordinates, + AnimationFrame, ) from .config import ( getZoomLevel, @@ -35,6 +36,7 @@ shouldKeepMouseCentered, ) from .utils.focusManager import FocusManager +from .utils.animationManager import AnimationManager class Magnifier: @@ -42,6 +44,8 @@ class Magnifier: _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() @@ -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 @@ -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 diff --git a/source/_magnifier/utils/animationManager.py b/source/_magnifier/utils/animationManager.py new file mode 100644 index 00000000000..d935756a0b4 --- /dev/null +++ b/source/_magnifier/utils/animationManager.py @@ -0,0 +1,156 @@ +# 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. + """ + if totalSteps < 1: + raise ValueError(f"totalSteps must be >= 1, got {totalSteps}") + if speedPxPerTick is not None and speedPxPerTick <= 0: + raise ValueError(f"speedPxPerTick must be > 0, got {speedPxPerTick}") + self._totalSteps = totalSteps + self._speedPxPerTick = speedPxPerTick + self._maxSteps = maxSteps + 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). + """ + if self._current is None: + raise RuntimeError("AnimationManager not initialised — call start() first") + self._start = self._current + self._target = target + self._onComplete = onComplete + self._isComplete = False + self._step = 0 + if totalSteps is not None: + if totalSteps < 1: + raise ValueError(f"totalSteps must be >= 1, got {totalSteps}") + self._totalSteps = totalSteps + 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) + 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 + + 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 diff --git a/source/_magnifier/utils/spotlightManager.py b/source/_magnifier/utils/spotlightManager.py index 6d62467a4dd..630c68ecd7e 100644 --- a/source/_magnifier/utils/spotlightManager.py +++ b/source/_magnifier/utils/spotlightManager.py @@ -10,7 +10,9 @@ from typing import TYPE_CHECKING, Callable import ui -from .types import Coordinates, ZoomHistory, FullScreenMode +from .types import Coordinates, AnimationFrame, FullScreenMode +from .animationManager import AnimationManager +from ..config import ZoomLevel import wx from logHandler import log @@ -27,12 +29,13 @@ def __init__( self._spotlightIsActive: bool = False self._lastMousePosition = Coordinates(0, 0) self._timer: wx.CallLater | None = None - self._animationSteps: int = 40 self._animationStepDelay: int = 12 + self._animator: AnimationManager = AnimationManager(totalSteps=40) self._currentCoordinates: Coordinates = fullscreenMagnifier._focusManager.getCurrentFocusCoordinates() self._originalZoomLevel: int = 0 self._currentZoomLevel: float = 0.0 self._originalMode: FullScreenMode | None = None + self._animationStep: int = 0 def _startSpotlight(self) -> None: """ @@ -41,10 +44,6 @@ def _startSpotlight(self) -> None: self._originalZoomLevel = self._fullscreenMagnifier.zoomLevel self._currentZoomLevel = self._fullscreenMagnifier.zoomLevel - log.debug("start spotlight") - - self._spotlightIsActive = True - startCoords = self._fullscreenMagnifier._focusManager.getCurrentFocusCoordinates() startCoords = self._fullscreenMagnifier._getCoordinatesForMode(startCoords) centerScreen = Coordinates( @@ -55,13 +54,21 @@ def _startSpotlight(self) -> None: # Save the current mode for zoom back self._originalMode = self._fullscreenMagnifier._fullscreenMode self._currentCoordinates = startCoords - self._animateZoom(ZoomHistory(1.0, centerScreen), self._startMouseMonitoring) + + log.debug( + f"[spotlight] start — zoom={self._originalZoomLevel}, " + f"startCoords={startCoords}, centerScreen={centerScreen}, mode={self._originalMode}", + ) + + self._spotlightIsActive = True + self._animator.start(AnimationFrame(self._currentZoomLevel, self._currentCoordinates)) + self._animateZoom(AnimationFrame(float(ZoomLevel.MIN_ZOOM), centerScreen), self._startMouseMonitoring) def _stopSpotlight(self) -> None: """ Stop the spotlight """ - log.debug("stop spotlight") + log.debug("[spotlight] _stopSpotlight called — stopping timer and notifying user") ui.message( pgettext( "magnifier", @@ -78,65 +85,75 @@ def _stopSpotlight(self) -> None: def _animateZoom( self, - target: ZoomHistory, + target: AnimationFrame, callback: Callable[[], None], ) -> None: """ - Animate the zoom level change + Animate the zoom level change towards target, then call callback. - :param target: The target zoom history (zoom level and coordinates) + :param target: The target animation frame (zoom level and coordinates) :param callback: The function to call after animation completes """ + self._animationStep = 0 log.debug( - f"animate zoom with original zoom level {self._originalZoomLevel} and current zoom level {self._currentZoomLevel}", + f"[spotlight] _animateZoom — from zoom={self._currentZoomLevel:.2f} " + f"to zoom={target.zoomLevel:.2f}, coords={target.coordinates}, callback={getattr(callback, '__name__', type(callback).__name__)}", ) + self._animator.setTarget(target, onComplete=callback) + self._driveAnimation() - self._animationStepsList = self._computeAnimationSteps( - round(self._currentZoomLevel), - round(target.zoomLevel), - self._currentCoordinates, - target.coordinates, - ) - - self._executeStep(0, callback) - - def _executeStep( - self, - stepIndex: int, - callback: Callable[[], None], - ) -> None: + def _driveAnimation(self) -> None: """ - Execute one animation step. - Also queues the next animation step. - - :param stepIndex: The index of the current animation step - :param callback: The function to call after animation completes + Advance the animator by one step, apply the resulting frame to the magnifier, + and schedule the next step if the animation is not yet complete. """ - log.debug( - f"execute step with original zoom level {self._originalZoomLevel} and current zoom level {self._currentZoomLevel}", - ) + if not self._fullscreenMagnifier._isActive: + log.debug( + f"[spotlight] _driveAnimation aborted at step {self._animationStep} — magnifier no longer active", + ) + if self._timer: + self._timer.Stop() + self._timer = None + self._spotlightIsActive = False + return + + self._animationStep += 1 + frame = self._animator.tick() + + if self._animationStep == 1 or self._animationStep % 10 == 0: + log.debug( + f"[spotlight] step {self._animationStep} — zoom={frame.zoomLevel:.2f}, coords={frame.coordinates}", + ) + + try: + self._fullscreenMagnifier._setZoomRawValue(frame.zoomLevel) + self._fullscreenMagnifier._fullscreenMagnifier(frame.coordinates) + except Exception: + log.error( + f"[spotlight] error at step {self._animationStep} — aborting spotlight", + exc_info=True, + ) + self._stopSpotlight() + return + + self._currentZoomLevel = frame.zoomLevel + self._currentCoordinates = frame.coordinates - if stepIndex < len(self._animationStepsList): - zoomLevel, coords = self._animationStepsList[stepIndex] - try: - self._fullscreenMagnifier._setZoomRawValue(zoomLevel) - self._fullscreenMagnifier._fullscreenMagnifier(coords) - except Exception: - log.error("Error during spotlight animation step, aborting spotlight", exc_info=True) - self._stopSpotlight() - return - self._currentZoomLevel = zoomLevel - self._currentCoordinates = coords - wx.CallLater(self._animationStepDelay, lambda: self._executeStep(stepIndex + 1, callback)) + if self._animator.isComplete: + self._timer = None + log.debug( + f"[spotlight] animation complete at step {self._animationStep} " + f"— final zoom={frame.zoomLevel:.2f}, coords={frame.coordinates}", + ) else: - if callback: - callback() + self._timer = wx.CallLater(self._animationStepDelay, self._driveAnimation) def _startMouseMonitoring(self) -> None: """ Start monitoring the mouse position to detect idleness """ self._lastMousePosition = Coordinates(*wx.GetMousePosition()) + log.debug(f"[spotlight] _startMouseMonitoring — mouse at {self._lastMousePosition}, waiting 2000 ms") self._timer = wx.CallLater(2000, self._checkMouseIdle) def _checkMouseIdle(self) -> None: @@ -144,6 +161,10 @@ def _checkMouseIdle(self) -> None: Check if the mouse has been idle """ currentMousePosition = Coordinates(*wx.GetMousePosition()) + log.debug( + f"[spotlight] _checkMouseIdle — current={currentMousePosition}, last={self._lastMousePosition}, " + f"idle={currentMousePosition == self._lastMousePosition}", + ) if currentMousePosition == self._lastMousePosition: self.zoomBack() else: @@ -156,10 +177,6 @@ def zoomBack(self) -> None: """ Zoom back to mouse position """ - log.debug( - f"zoom back with original zoom level {self._originalZoomLevel} and current zoom level {self._currentZoomLevel}", - ) - focus = self._fullscreenMagnifier._focusManager.getCurrentFocusCoordinates() if self._originalMode == FullScreenMode.RELATIVE: @@ -171,47 +188,8 @@ def zoomBack(self) -> None: endCoordinates = focus self._fullscreenMagnifier._lastScreenPosition = endCoordinates - self._animateZoom(ZoomHistory(self._originalZoomLevel, endCoordinates), self._stopSpotlight) - - def _computeAnimationSteps( - self, - zoomStart: int, - zoomEnd: int, - coordinateStart: Coordinates, - coordinateEnd: Coordinates, - ) -> list[ZoomHistory]: - """ - Compute all intermediate animation steps with zoom levels and Coordinates - - :param zoomStart: Starting zoom level - :param zoomEnd: Ending zoom level - :param coordinateStart: Starting Coordinates (x, y) - :param coordinateEnd: Ending Coordinates (x, y) - - :return: List of animation steps as ZoomHistory for each animation step - """ log.debug( - f"compute animation steps with original zoom level {self._originalZoomLevel} and current zoom level {self._currentZoomLevel}", + f"[spotlight] zoomBack — originalZoom={self._originalZoomLevel}, " + f"currentZoom={self._currentZoomLevel:.2f}, focus={focus}, endCoords={endCoordinates}", ) - - startX, startY = coordinateStart - endX, endY = coordinateEnd - animationSteps: list[ZoomHistory] = [] - - zoomDelta = (zoomEnd - zoomStart) / self._animationSteps - coordDeltaX = (endX - startX) / self._animationSteps - coordDeltaY = (endY - startY) / self._animationSteps - - for step in range(1, self._animationSteps + 1): - currentZoom = zoomStart + zoomDelta * step - - currentX = startX + coordDeltaX * step - currentY = startY + coordDeltaY * step - - animationSteps.append( - ZoomHistory( - round(currentZoom, 2), - Coordinates(round(currentX), round(currentY)), - ), - ) - return animationSteps + self._animateZoom(AnimationFrame(self._originalZoomLevel, endCoordinates), self._stopSpotlight) diff --git a/source/_magnifier/utils/types.py b/source/_magnifier/utils/types.py index c1a47364603..00ca4c11603 100644 --- a/source/_magnifier/utils/types.py +++ b/source/_magnifier/utils/types.py @@ -140,8 +140,8 @@ class Coordinates(NamedTuple): y: int -class ZoomHistory(NamedTuple): - """Named tuple representing zoom history entry with zoom level and coordinates""" +class AnimationFrame(NamedTuple): + """Named tuple representing a magnifier animation frame with zoom level and coordinates""" zoomLevel: float coordinates: Coordinates diff --git a/tests/unit/test_magnifier/test_animationManager.py b/tests/unit/test_magnifier/test_animationManager.py new file mode 100644 index 00000000000..c4a498270c5 --- /dev/null +++ b/tests/unit/test_magnifier/test_animationManager.py @@ -0,0 +1,280 @@ +# 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 + +import unittest +from _magnifier.utils.animationManager import AnimationManager +from _magnifier.utils.types import AnimationFrame, Coordinates + + +def _frame(zoom: float, x: int, y: int) -> AnimationFrame: + return AnimationFrame(zoom, Coordinates(x, y)) + + +class TestAnimationManager(unittest.TestCase): + """Test suite for AnimationManager linear interpolation.""" + + def testDefaultTotalSteps(self): + """Default number of steps should be 40.""" + manager = AnimationManager() + self.assertEqual(manager._totalSteps, 40) + + def testCustomTotalSteps(self): + """totalSteps parameter should be stored.""" + manager = AnimationManager(totalSteps=20) + self.assertEqual(manager._totalSteps, 20) + + def testInitiallyComplete(self): + """A freshly created manager should report isComplete=True (no animation pending).""" + manager = AnimationManager() + self.assertTrue(manager.isComplete) + + def testCurrentFrameIsNoneBeforeStart(self): + """currentFrame should be None before start() is called.""" + manager = AnimationManager() + self.assertIsNone(manager.currentFrame) + + def testStartSetsCurrentFrame(self): + """start() should initialise currentFrame to the given frame.""" + manager = AnimationManager() + initial = _frame(200.0, 500, 400) + manager.start(initial) + self.assertEqual(manager.currentFrame, initial) + + def testStartKeepsComplete(self): + """start() should leave isComplete=True (no animation running).""" + manager = AnimationManager() + manager.start(_frame(200.0, 500, 400)) + self.assertTrue(manager.isComplete) + + def testTickBeforeStartRaisesRuntimeError(self): + """tick() without a prior start() should raise RuntimeError.""" + manager = AnimationManager() + with self.assertRaises(RuntimeError): + manager.tick() + + def testSetTargetStartsAnimation(self): + """setTarget() should mark animation as in progress.""" + manager = AnimationManager(totalSteps=10) + manager.start(_frame(200.0, 0, 0)) + manager.setTarget(_frame(100.0, 100, 0)) + self.assertFalse(manager.isComplete) + + def testSetTargetStoresTarget(self): + """setTarget() should store the target frame.""" + manager = AnimationManager(totalSteps=10) + manager.start(_frame(200.0, 0, 0)) + target = _frame(100.0, 100, 0) + manager.setTarget(target) + self.assertEqual(manager._target, target) + + def testCompletesInExactlyTotalSteps(self): + """Animation must complete after exactly totalSteps ticks, no more, no less.""" + steps = 10 + manager = AnimationManager(totalSteps=steps) + manager.start(_frame(200.0, 0, 0)) + manager.setTarget(_frame(100.0, 100, 0)) + + for i in range(steps - 1): + manager.tick() + self.assertFalse(manager.isComplete, f"Should not be complete at step {i + 1}") + + manager.tick() # final step + self.assertTrue(manager.isComplete) + + def testFinalFrameIsExactlyTarget(self): + """The frame returned on the last tick must equal the target exactly.""" + manager = AnimationManager(totalSteps=10) + target = _frame(100.0, 960, 540) + manager.start(_frame(200.0, 0, 0)) + manager.setTarget(target) + + for _ in range(10): + frame = manager.tick() + + self.assertEqual(frame, target) + + def testInterpolationIsLinear(self): + """Each step should cover an equal fraction of the total distance.""" + steps = 10 + manager = AnimationManager(totalSteps=steps) + manager.start(_frame(200.0, 0, 0)) + manager.setTarget(_frame(100.0, 100, 0)) + + frames = [manager.tick() for _ in range(steps - 1)] # exclude snap step + + for i, frame in enumerate(frames): + expectedZoom = round(200.0 + (100.0 - 200.0) * (i + 1) / steps, 2) + expectedX = round(0 + (100 - 0) * (i + 1) / steps) + self.assertAlmostEqual(frame.zoomLevel, expectedZoom, places=1) + self.assertEqual(frame.coordinates.x, expectedX) + + def testFirstStepIsNotAtTarget(self): + """The first tick should produce an intermediate value, not the target.""" + manager = AnimationManager(totalSteps=10) + manager.start(_frame(200.0, 0, 0)) + manager.setTarget(_frame(100.0, 100, 0)) + + frame = manager.tick() + self.assertNotEqual(frame.zoomLevel, 100.0) + + def testOnCompleteCalledOnFinalStep(self): + """onComplete callback should be called exactly once when the animation finishes.""" + manager = AnimationManager(totalSteps=5) + manager.start(_frame(200.0, 0, 0)) + + calls = [] + manager.setTarget(_frame(100.0, 100, 0), onComplete=lambda: calls.append(1)) + + for _ in range(5): + manager.tick() + + self.assertEqual(len(calls), 1) + + def testOnCompleteNotCalledBeforeFinalStep(self): + """onComplete must not fire before the last step.""" + manager = AnimationManager(totalSteps=5) + manager.start(_frame(200.0, 0, 0)) + + calls = [] + manager.setTarget(_frame(100.0, 100, 0), onComplete=lambda: calls.append(1)) + + for _ in range(4): + manager.tick() + + self.assertEqual(len(calls), 0) + + def testOnCompleteNotCalledAgainOnExtraTicks(self): + """Extra tick() calls after completion must not invoke onComplete again.""" + manager = AnimationManager(totalSteps=5) + manager.start(_frame(200.0, 0, 0)) + + calls = [] + manager.setTarget(_frame(100.0, 100, 0), onComplete=lambda: calls.append(1)) + + for _ in range(5): + manager.tick() + + manager.tick() + manager.tick() + + self.assertEqual(len(calls), 1) + + def testTickAfterCompleteReturnsTargetFrame(self): + """Extra ticks after completion should keep returning the target frame.""" + manager = AnimationManager(totalSteps=5) + target = _frame(100.0, 100, 0) + manager.start(_frame(200.0, 0, 0)) + manager.setTarget(target) + + for _ in range(5): + manager.tick() + + self.assertEqual(manager.tick(), target) + + def testRedirectMidAnimationEndsAtNewTarget(self): + """Calling setTarget() mid-animation should redirect and end at the new target.""" + manager = AnimationManager(totalSteps=10) + manager.start(_frame(200.0, 0, 0)) + manager.setTarget(_frame(100.0, 100, 0)) + + # Advance halfway + for _ in range(5): + manager.tick() + + # Redirect + new_target = _frame(150.0, 50, 0) + manager.setTarget(new_target) + self.assertFalse(manager.isComplete) + + for _ in range(10): + frame = manager.tick() + + self.assertEqual(frame, new_target) + self.assertTrue(manager.isComplete) + + def testRedirectStartsFromCurrentPosition(self): + """After a redirect, the first step should depart from the current animated position.""" + manager = AnimationManager(totalSteps=10) + manager.start(_frame(200.0, 0, 0)) + manager.setTarget(_frame(100.0, 100, 0)) + + for _ in range(5): + mid = manager.tick() + + mid_zoom = mid.zoomLevel + + manager.setTarget(_frame(200.0, 0, 0)) + first = manager.tick() + + # First redirected step must be between mid and the new target, not jump + self.assertGreater(first.zoomLevel, mid_zoom) + self.assertLess(first.zoomLevel, 200.0) + + def testSpeedBasedStepsScaleWithDistance(self): + """With speedPxPerTick set, totalSteps must equal round(distance / speed).""" + speed = 10.0 + manager = AnimationManager(speedPxPerTick=speed) + manager.start(_frame(100.0, 0, 0)) + + manager.setTarget(_frame(100.0, 50, 0)) # 50 px → 5 steps + self.assertEqual(manager._totalSteps, 5) + + manager.start(_frame(100.0, 0, 0)) + manager.setTarget(_frame(100.0, 100, 0)) # 100 px → 10 steps + self.assertEqual(manager._totalSteps, 10) + + def testSpeedBasedStepsRespectMaxSteps(self): + """maxSteps must cap the auto-computed step count.""" + manager = AnimationManager(speedPxPerTick=10.0, maxSteps=5) + manager.start(_frame(100.0, 0, 0)) + manager.setTarget(_frame(100.0, 1000, 0)) # would be 100 steps without cap + + self.assertEqual(manager._totalSteps, 5) + + def testExplicitTotalStepsOverridesSpeed(self): + """An explicit totalSteps argument to setTarget takes precedence over speedPxPerTick.""" + manager = AnimationManager(speedPxPerTick=10.0) + manager.start(_frame(100.0, 0, 0)) + manager.setTarget(_frame(100.0, 100, 0), totalSteps=3) # distance would give 10 + + self.assertEqual(manager._totalSteps, 3) + + def testReset(self): + """reset() must clear animated state while preserving speed configuration.""" + manager = AnimationManager(speedPxPerTick=10.0, maxSteps=20) + manager.start(_frame(200.0, 0, 0)) + manager.setTarget(_frame(100.0, 50, 0)) + manager.tick() + + manager.reset() + + self.assertIsNone(manager.currentFrame) + self.assertTrue(manager.isComplete) + # Speed config must be preserved + self.assertEqual(manager._speedPxPerTick, 10.0) + self.assertEqual(manager._maxSteps, 20) + + def testSetTargetBeforeStartRaisesRuntimeError(self): + """setTarget() before start() must raise RuntimeError.""" + manager = AnimationManager() + with self.assertRaises(RuntimeError): + manager.setTarget(_frame(100.0, 100, 0)) + + def testInvalidTotalStepsRaisesValueError(self): + """totalSteps=0 in constructor must raise ValueError.""" + with self.assertRaises(ValueError): + AnimationManager(totalSteps=0) + + def testInvalidSpeedPxPerTickRaisesValueError(self): + """speedPxPerTick=0 in constructor must raise ValueError.""" + with self.assertRaises(ValueError): + AnimationManager(speedPxPerTick=0.0) + + def testInvalidTotalStepsOverrideInSetTargetRaisesValueError(self): + """An explicit totalSteps=0 in setTarget must raise ValueError.""" + manager = AnimationManager(totalSteps=10) + manager.start(_frame(200.0, 0, 0)) + with self.assertRaises(ValueError): + manager.setTarget(_frame(100.0, 100, 0), totalSteps=0) diff --git a/tests/unit/test_magnifier/test_fullscreenMagnifier.py b/tests/unit/test_magnifier/test_fullscreenMagnifier.py index 493344e7287..398f89516ca 100644 --- a/tests/unit/test_magnifier/test_fullscreenMagnifier.py +++ b/tests/unit/test_magnifier/test_fullscreenMagnifier.py @@ -4,9 +4,13 @@ # For full terms and any additional permissions, see the NVDA license file: https://github.com/nvaccess/nvda/blob/master/copying.txt from unittest.mock import MagicMock, patch -from _magnifier.config import ZoomLevel from _magnifier.magnifier import Magnifier -from _magnifier.utils.types import Filter, FullScreenMode, MagnifiedView, Direction, Coordinates +from _magnifier.utils.types import ( + FullScreenMode, + MagnifiedView, + Coordinates, + MagnifierFollowFocusType, +) from _magnifier.fullscreenMagnifier import FullScreenMagnifier from tests.unit.test_magnifier.test_magnifier import _TestMagnifier from winAPI._displayTracking import getPrimaryDisplayOrientation @@ -16,214 +20,40 @@ class TestFullscreenMagnifierEndToEnd(_TestMagnifier): """End-to-end test suite for fullscreen magnifier functionality.""" def testMagnifierCreation(self): - """Test creating a magnifier.""" + """FullScreenMagnifier starts with FULLSCREEN view and CENTER mode.""" magnifier = FullScreenMagnifier() magnifier._startMagnifier() - - self.assertEqual(magnifier.zoomLevel, 200) - self.assertEqual(magnifier.filterType, Filter.NORMAL) self.assertEqual(magnifier._fullscreenMode, FullScreenMode.CENTER) self.assertEqual(magnifier._MAGNIFIED_VIEW, MagnifiedView.FULLSCREEN) self.assertTrue(magnifier._isActive) - - magnifier._stopMagnifier() - - def testMagnifierZoom(self): - """Test zoom functionality.""" - magnifier = FullScreenMagnifier() - magnifier._startMagnifier() - - # Set initial zoom to 100 for predictable testing - magnifier.zoomLevel = 100 - - # Test zoom in - magnifier._zoom(Direction.IN) - self.assertEqual(magnifier.zoomLevel, 150) - - # Test zoom out - magnifier._zoom(Direction.OUT) - self.assertEqual(magnifier.zoomLevel, 100) - self.assertEqual(magnifier.zoomLevel, 100) - - # Cleanup - magnifier._stopMagnifier() - - def testMagnifierCoordinates(self): - """Test coordinate handling.""" - magnifier = FullScreenMagnifier() - magnifier._startMagnifier() - - # Test setting coordinates - magnifier._currentCoordinates = (100, 200) - self.assertEqual(magnifier._currentCoordinates, (100, 200)) - - # Test negative coordinates - magnifier._currentCoordinates = (-50, -100) - self.assertEqual(magnifier._currentCoordinates, (-50, -100)) - - # Cleanup magnifier._stopMagnifier() def testMagnifierUpdate(self): - """Test magnifier update cycle.""" + """_doUpdate calls getCoordinatesForMode, advances animation and calls _fullscreenMagnifier.""" magnifier = FullScreenMagnifier() magnifier._startMagnifier() - - # Mock the update methods magnifier._getCoordinatesForMode = MagicMock(return_value=(150, 250)) magnifier._fullscreenMagnifier = MagicMock() - - # Set initial coordinates + magnifier._advanceAnimation = MagicMock(side_effect=lambda coords, **_: coords) magnifier._currentCoordinates = (100, 200) - # Test update magnifier._doUpdate() - # Verify update was called correctly magnifier._getCoordinatesForMode.assert_called_once_with((100, 200)) self.assertEqual(magnifier._lastScreenPosition, (150, 250)) magnifier._fullscreenMagnifier.assert_called_once_with((150, 250)) - - # Cleanup - magnifier._stopMagnifier() - - def testMagnifierStop(self): - """Test stopping the magnifier.""" - magnifier = FullScreenMagnifier() - magnifier._startMagnifier() - - # Mock the timer - magnifier._stopTimer = MagicMock() - - # Verify it's active first - self.assertTrue(magnifier._isActive) - - # Stop the magnifier magnifier._stopMagnifier() - # Verify it's stopped - self.assertFalse(magnifier._isActive) - magnifier._stopTimer.assert_called_once() - def testMagnifierPositionCalculation(self): - """Test position calculation.""" + """_getMagnifierParameters returns a capture area scaled to the current zoom.""" magnifier = FullScreenMagnifier() magnifier._startMagnifier() - - # Test position calculation params = magnifier._getMagnifierParameters((500, 400)) - - # Basic checks - self.assertIsInstance(params.coordinates.x, int) - self.assertIsInstance(params.coordinates.y, int) - self.assertIsInstance(params.magnifierSize.width, int) - self.assertIsInstance(params.magnifierSize.height, int) - - # Width and height should be screen size divided by zoom expectedWidth = int(magnifier._displayOrientation.width / 2.0) expectedHeight = int(magnifier._displayOrientation.height / 2.0) - self.assertEqual(params.magnifierSize.width, expectedWidth) self.assertEqual(params.magnifierSize.height, expectedHeight) - - # Cleanup - magnifier._stopMagnifier() - - def testMagnifierZoomBoundaries(self): - """Test zoom boundaries.""" - magnifier = FullScreenMagnifier() - magnifier._startMagnifier() - magnifier.zoomLevel = ZoomLevel.MIN_ZOOM - - # Test minimum boundary - magnifier._zoom(Direction.OUT) # Try to zoom out below minimum - self.assertEqual(magnifier.zoomLevel, ZoomLevel.MIN_ZOOM) - - # Test maximum boundary - magnifier.zoomLevel = ZoomLevel.MAX_ZOOM - magnifier._zoom(Direction.IN) # Try to zoom in above maximum - self.assertEqual(magnifier.zoomLevel, ZoomLevel.MAX_ZOOM) - - # Cleanup - magnifier._stopMagnifier() - - def testMagnifiedViewProperty(self): - """Test magnifiedView property for FullScreenMagnifier.""" - magnifier = FullScreenMagnifier() - magnifier._startMagnifier() - - # Should default to FULLSCREEN - self.assertEqual(magnifier._MAGNIFIED_VIEW, MagnifiedView.FULLSCREEN) - - # Test that we can read it (inherited property from Magnifier) - self.assertIsNotNone(magnifier._MAGNIFIED_VIEW) - - # Cleanup - magnifier._stopMagnifier() - - def testMagnifierInheritance(self): - """Test inheritance structure.""" - magnifier = FullScreenMagnifier() - magnifier._startMagnifier() - - self.assertIsInstance(magnifier, Magnifier) - - # Test basic properties exist - self.assertTrue(hasattr(magnifier, "zoomLevel")) - self.assertTrue(hasattr(magnifier, "filterType")) - self.assertTrue(hasattr(magnifier, "_MAGNIFIED_VIEW")) - self.assertTrue(hasattr(magnifier, "_fullscreenMode")) - self.assertTrue(hasattr(magnifier, "_isActive")) - self.assertTrue(hasattr(magnifier, "_currentCoordinates")) - - # Cleanup - magnifier._stopMagnifier() - - def testMagnifierApiHandling(self): - """Test API error handling.""" - magnifier = FullScreenMagnifier() - magnifier._startMagnifier() - - # Mock magnification API to fail - magnifier._stopTimer = MagicMock() - - # Should not raise exception when API fails - try: - magnifier._stopMagnifier() - testPassed = True - except Exception: - testPassed = False - - self.assertTrue(testPassed) - self.assertFalse(magnifier._isActive) - - def testMagnifierSimpleLifecycle(self): - """Test simple magnifier lifecycle.""" - # Create magnifier - magnifier = FullScreenMagnifier() - magnifier._startMagnifier() - self.assertTrue(magnifier._isActive) - self.assertEqual(magnifier.zoomLevel, 200) - - # Zoom a bit - magnifier._zoom(Direction.IN) - self.assertEqual(magnifier.zoomLevel, 250) - - # Set some coordinates - magnifier._currentCoordinates = (200, 300) - self.assertEqual(magnifier._currentCoordinates, (200, 300)) - - # Change mode - magnifier._fullscreenMode = FullScreenMode.RELATIVE - self.assertEqual(magnifier._fullscreenMode, FullScreenMode.RELATIVE) - - # Change filter - magnifier.filterType = Filter.INVERTED - self.assertEqual(magnifier.filterType, Filter.INVERTED) - - # Stop magnifier magnifier._stopMagnifier() - self.assertFalse(magnifier._isActive) def testAttemptRecoverySuccess(self): """FullScreenMagnifier._attemptRecovery reinitialises API and restarts timer on success.""" @@ -287,6 +117,62 @@ def testUpdateLoopSurvivesSingleDoUpdateError(self): magnifier._stopMagnifier() +class TestFullScreenMagnifierPositionAnimation(_TestMagnifier): + """Tests for smooth position animation in FullScreenMagnifier._doUpdate.""" + + def setUp(self): + super().setUp() + self.magnifier = FullScreenMagnifier() + self.magnifier._startMagnifier() + self.magnifier._fullscreenMagnifier = MagicMock() + self.magnifier._focusManager.getLastFocusType = MagicMock(return_value=None) + + def tearDown(self): + self.magnifier._stopMagnifier() + super().tearDown() + + def testAnimatorInitialisedOnStart(self): + """Position animator must be ready after _startMagnifier.""" + self.assertIsNotNone(self.magnifier._positionAnimator) + + def testDoUpdateNoAnimationForMouseTracking(self): + """When tracking the mouse, _doUpdate must snap to target without interpolation.""" + dist = int(Magnifier._ANIMATION_SPEED_PX_PER_TICK * 10) + self.magnifier._initPositionAnimator(Coordinates(0, 0)) + self.magnifier._focusManager.getLastFocusType = MagicMock( + return_value=MagnifierFollowFocusType.MOUSE, + ) + self.magnifier._getCoordinatesForMode = MagicMock(return_value=Coordinates(dist, 0)) + + self.magnifier._doUpdate() + + self.assertEqual(self.magnifier._lastScreenPosition, Coordinates(dist, 0)) + + def testTransitionFromMouseToKeyboardAnimatesFromMousePosition(self): + """After mouse tracking ends, animation must depart from the last mouse position.""" + mousePos = Coordinates(200, 0) + keyboardPos = Coordinates(0, 0) + + self.magnifier._initPositionAnimator(Coordinates(0, 0)) + + # Snap to mouse position + self.magnifier._focusManager.getLastFocusType = MagicMock( + return_value=MagnifierFollowFocusType.MOUSE, + ) + self.magnifier._getCoordinatesForMode = MagicMock(return_value=mousePos) + self.magnifier._doUpdate() + self.assertEqual(self.magnifier._lastScreenPosition, mousePos) + + # Switch to keyboard focus — first animated step must depart from mousePos + self.magnifier._focusManager.getLastFocusType = MagicMock(return_value=None) + self.magnifier._getCoordinatesForMode = MagicMock(return_value=keyboardPos) + self.magnifier._doUpdate() + + firstPos = self.magnifier._lastScreenPosition + self.assertGreater(firstPos.x, keyboardPos.x) + self.assertLess(firstPos.x, mousePos.x) + + class TestFullScreenMagnifierApiConflict(_TestMagnifier): """Tests for Windows Magnification API conflict detection at startup and during recovery.""" diff --git a/tests/unit/test_magnifier/test_spotlightManager.py b/tests/unit/test_magnifier/test_spotlightManager.py index e62fe60f205..215734fca93 100644 --- a/tests/unit/test_magnifier/test_spotlightManager.py +++ b/tests/unit/test_magnifier/test_spotlightManager.py @@ -4,7 +4,7 @@ # For full terms and any additional permissions, see the NVDA license file: https://github.com/nvaccess/nvda/blob/master/copying.txt from unittest.mock import MagicMock, patch -from _magnifier.utils.types import FullScreenMode, Coordinates +from _magnifier.utils.types import FullScreenMode, Coordinates, AnimationFrame from _magnifier.fullscreenMagnifier import FullScreenMagnifier from tests.unit.test_magnifier.test_magnifier import _TestMagnifier @@ -19,7 +19,8 @@ def testSpotlightManagerCreation(self): self.assertIsNotNone(spotlightManager) self.assertFalse(spotlightManager._spotlightIsActive) - self.assertEqual(spotlightManager._animationSteps, 40) + self.assertIsNotNone(spotlightManager._animator) + self.assertEqual(spotlightManager._animator._totalSteps, 40) self.assertEqual(spotlightManager._originalZoomLevel, 0) self.assertEqual(spotlightManager._currentZoomLevel, 0.0) @@ -67,39 +68,55 @@ def testSpotlightDeactivation(self): magnifier._stopMagnifier() - def testComputeAnimationSteps(self): - """Test animation steps calculation.""" + def testAnimateZoomSetsAnimatorTarget(self): + """Test that _animateZoom sets the correct target on the animator.""" magnifier = FullScreenMagnifier() spotlightManager = magnifier._spotlightManager - # Test animation from zoom 2.0 to 1.0, coordinates (500, 400) to (960, 540) - steps = spotlightManager._computeAnimationSteps( - 200, - 100, - (500, 400), - (960, 540), - ) - - # Should have 40 steps - self.assertEqual(len(steps), 40) - - # First step should be closer to start - firstZoom, firstCoords = steps[0] - self.assertLess(abs(firstZoom - 200), abs(firstZoom - 100)) - - # Last step should be at target - lastZoom, lastCoords = steps[-1] - self.assertEqual(lastZoom, 100) - self.assertEqual(lastCoords, (960, 540)) - - # Steps should progress linearly (decreasing from 200 to 100) - for i in range(len(steps) - 1): - currentZoom, _ = steps[i] - nextZoom, _ = steps[i + 1] - self.assertGreater( - currentZoom, - nextZoom, - ) # Zoom should decrease from 200 to 100 + spotlightManager._currentZoomLevel = 200.0 + spotlightManager._currentCoordinates = Coordinates(500, 400) + spotlightManager._animator.start(AnimationFrame(200.0, Coordinates(500, 400))) + magnifier._isActive = True + magnifier._setZoomRawValue = MagicMock() + magnifier._fullscreenMagnifier = MagicMock() + + target = AnimationFrame(100.0, Coordinates(960, 540)) + callback = MagicMock() + with patch( + "_magnifier.utils.spotlightManager.wx.CallLater", + side_effect=lambda delay, func, *args, **kwargs: MagicMock(), + ): + spotlightManager._animateZoom(target, callback) + + self.assertFalse(spotlightManager._animator.isComplete) + self.assertEqual(spotlightManager._animator._target, target) + + magnifier._stopMagnifier() + + def testAnimationCompletesAfterTotalSteps(self): + """Test that the animation driven by _driveAnimation completes in exactly totalSteps ticks.""" + magnifier = FullScreenMagnifier() + spotlightManager = magnifier._spotlightManager + + magnifier._isActive = True + magnifier._setZoomRawValue = MagicMock() + magnifier._fullscreenMagnifier = MagicMock() + + initial = AnimationFrame(200.0, Coordinates(500, 400)) + target = AnimationFrame(100.0, Coordinates(960, 540)) + callback = MagicMock() + + spotlightManager._currentZoomLevel = 200.0 + spotlightManager._currentCoordinates = Coordinates(500, 400) + spotlightManager._animator.start(initial) + spotlightManager._animator.setTarget(target, onComplete=callback) + + steps = spotlightManager._animator._totalSteps + for _ in range(steps): + spotlightManager._animator.tick() + + self.assertTrue(spotlightManager._animator.isComplete) + callback.assert_called_once() magnifier._stopMagnifier()