-
-
Notifications
You must be signed in to change notification settings - Fork 801
Feature/smoother magnifier #20226
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
Boumtchack
wants to merge
6
commits into
nvaccess:master
from
France-Travail:feature/smootherMagnifier
Closed
Feature/smoother magnifier #20226
Changes from 3 commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
04b6431
adding animation manager
Boumtchack 61db7ed
adding animation to magnifier
Boumtchack ed2ca71
simplified tests
Boumtchack 60e3fb9
invalid value fix
Boumtchack 930eca1
enforce start before set target
Boumtchack 044c7c9
last copilot
Boumtchack File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
| 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 | ||
|
Boumtchack marked this conversation as resolved.
|
||
| if totalSteps is not None: | ||
| self._totalSteps = totalSteps | ||
|
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) | ||
|
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 | ||
|
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 | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.