Phosphor
Qt6 / Wayland library suite for window-management tools
 
Loading...
Searching...
No Matches
AnimatedValue.h
Go to the documentation of this file.
1// SPDX-FileCopyrightText: 2026 fuddlesworth
2// SPDX-License-Identifier: LGPL-2.1-or-later
3
4#pragma once
5
14#include <PhosphorAnimation/phosphoranimation_export.h>
15
16#include <QColor>
17#include <QLoggingCategory>
18#include <QPointF>
19#include <QRectF>
20#include <QSizeF>
21#include <QTransform>
22#include <QtGlobal>
23
24#include <algorithm>
25#include <chrono>
26#include <cmath>
27#include <memory>
28#include <optional>
29#include <tuple>
30#include <utility>
31
33
34// Export needed because the category is referenced from consumer TUs
35// but defined in phosphor-animation's animatedvalue.cpp.
36Q_DECLARE_EXPORTED_LOGGING_CATEGORY(lcAnimatedValue, PHOSPHORANIMATION_EXPORT)
37
38PHOSPHORANIMATION_EXPORT std::shared_ptr<const Curve> defaultFallbackCurve();
39
43template<typename T, ColorSpace Space = ColorSpace::Linear>
45{
46public:
47 AnimatedValue() = default;
48 ~AnimatedValue() = default;
49
50 AnimatedValue(const AnimatedValue&) = delete;
52 AnimatedValue(AnimatedValue&&) noexcept = default;
53 AnimatedValue& operator=(AnimatedValue&&) noexcept = default;
54
55 // Sibling Space instantiations are friends so seedFrom can copy
56 // private idle state across a space boundary.
57 template<typename, ColorSpace>
58 friend class AnimatedValue;
59
61 bool start(T from, T to, MotionSpec<T> spec)
62 {
63 if (!spec.clock) {
64 qCWarning(lcAnimatedValue) << "start() rejected: null clock";
65 return false;
66 }
67 // NaN/Inf gate — corrupt endpoints would poison every downstream paint.
69 qCWarning(lcAnimatedValue) << "start() rejected: non-finite from/to";
70 return false;
71 }
72
73 m_from = std::move(from);
74 m_to = std::move(to);
75 m_spec = std::move(spec);
76 m_current = m_from;
77 m_cachedCurve = m_spec.profile.curve ? m_spec.profile.curve : defaultFallbackCurve();
78 m_state = CurveState{};
79 m_state.startValue = 0.0;
80 m_state.duration = 1.0;
81 m_startTime.reset();
82 m_lastTickTime.reset();
83 m_loggedStatelessDegrade = false;
84 m_loggedNegativeDt = false;
85 m_loggedTransformDegrade = false;
86 m_loggedEpochMismatch = false;
87
88 if (qFuzzyIsNull(Interpolate<T>::distance(m_from, m_to))) {
89 m_current = m_to;
90 m_state.value = 1.0;
91 m_isAnimating = false;
92 m_isComplete = true;
93 return false;
94 }
95
96 m_isAnimating = true;
97 m_isComplete = false;
98 m_spec.clock->requestFrame();
99 return true;
100 }
101
104 bool retarget(T newTo, RetargetPolicy policy)
105 {
106 if (!m_spec.clock) {
107 qCWarning(lcAnimatedValue) << "retarget() rejected: no stored spec (never started)";
108 return false;
109 }
110 if (!Interpolate<T>::isFinite(newTo)) {
111 qCWarning(lcAnimatedValue) << "retarget() rejected: non-finite newTo";
112 return false;
113 }
114
115 const T newFrom = m_current;
116 // Check the FROM endpoint too, not just newTo. `m_current` is the live
117 // interpolated value, so a retarget landing while the state is garbage (a
118 // diverged integrator, a poisoned lerp) would LATCH that garbage as the new
119 // animation's origin — and every subsequent bounds() / damage rect is then
120 // computed from it. Finish rather than propagate: the value is already wrong,
121 // and completing puts the target on screen.
122 if (!Interpolate<T>::isFinite(newFrom)) {
123 qCWarning(lcAnimatedValue) << "retarget() rejected: non-finite current value; finishing at target";
124 finish();
125 return false;
126 }
127 const qreal oldDistance = Interpolate<T>::distance(m_from, m_to);
128 const qreal newDistance = Interpolate<T>::distance(newFrom, newTo);
129
130 const auto curve = effectiveCurve();
131 const bool stateful = curve && curve->isStateful();
132
133 qreal newVelocity = 0.0;
134 switch (policy) {
136 if constexpr (std::same_as<T, QTransform>) {
137 // Frobenius metric mixes units for non-translate transforms —
138 // velocity rescale is only meaningful for pure-translate segments.
139 const bool pureTranslate = detail::isPureTranslate(m_from) && detail::isPureTranslate(m_to)
141 if (!pureTranslate) {
142 if (!m_loggedTransformDegrade) {
143 qCDebug(lcAnimatedValue) << "QTransform PreserveVelocity degrading to PreservePosition: "
144 << "non-translate components present (Frobenius metric mixes units)";
145 m_loggedTransformDegrade = true;
146 }
147 newVelocity = 0.0;
148 break;
149 }
150 }
151 if (stateful && newDistance > Interpolate<T>::retargetEpsilon
152 && oldDistance > Interpolate<T>::retargetEpsilon) {
153 // Map normalised velocity through world-space:
154 // worldRate = state.velocity * oldDistance
155 // newNormalisedVelocity = worldRate / newDistance
156 // Per-type epsilon gates BOTH distances to prevent
157 // velocity explosion on sub-threshold retargets.
158 newVelocity = (m_state.velocity * oldDistance) / newDistance;
159 } else if (!stateful && !m_loggedStatelessDegrade) {
160 qCDebug(lcAnimatedValue) << "PreserveVelocity degrading to PreservePosition on stateless curve"
161 << (curve ? curve->typeId() : QStringLiteral("null"));
162 m_loggedStatelessDegrade = true;
163 }
164 break;
166 newVelocity = 0.0;
167 break;
169 newVelocity = 0.0;
170 break;
171 }
172
173 m_from = newFrom;
174 m_to = std::move(newTo);
175 m_current = m_from;
176 m_state.value = 0.0;
177 m_state.velocity = newVelocity;
178 m_state.time = 0.0;
179 m_state.startValue = 0.0;
180 m_startTime.reset();
181 m_lastTickTime.reset();
182
183 if (qFuzzyIsNull(newDistance)) {
184 m_current = m_to;
185 m_state.value = 1.0;
186 m_state.velocity = 0.0;
187 m_isAnimating = false;
188 m_isComplete = true;
189 return false;
190 }
191
192 m_isAnimating = true;
193 m_isComplete = false;
194 m_spec.clock->requestFrame();
195 return true;
196 }
197
199 bool retarget(T newTo)
200 {
201 return retarget(std::move(newTo), m_spec.retargetPolicy);
202 }
203
205 void rebindClock(IMotionClock* newClock);
206
210 void cancel()
211 {
212 m_isAnimating = false;
213 m_isComplete = false;
214 }
215
217 template<ColorSpace OtherSpace>
218 void seedFrom(const AnimatedValue<T, OtherSpace>& other);
219
221 template<ColorSpace OtherSpace>
222 void seedSpecFrom(const AnimatedValue<T, OtherSpace>& other);
223
225 void finish()
226 {
227 if (!m_isAnimating) {
228 return;
229 }
230 m_current = m_to;
231 m_state.value = 1.0;
232 m_state.velocity = 0.0;
233 m_isAnimating = false;
234 m_isComplete = true;
235 if (m_spec.onValueChanged) {
236 m_spec.onValueChanged(m_current);
237 }
238 // Re-entrancy guard: onValueChanged may have restarted this instance.
239 if (m_isComplete && m_spec.onComplete) {
240 m_spec.onComplete();
241 }
242 }
243
246 void advance()
247 {
248 if (!m_isAnimating || !m_spec.clock) {
249 return;
250 }
251
252 const auto now = m_spec.clock->now();
253
254 if (!m_startTime) {
255 m_startTime = now;
256 m_lastTickTime = now;
257 m_current = m_from;
258 if (m_spec.onValueChanged) {
259 m_spec.onValueChanged(m_current);
260 }
261 if (m_spec.clock) {
262 m_spec.clock->requestFrame();
263 }
264 return;
265 }
266
267 const auto elapsed = now - *m_startTime;
268 const qreal dtSeconds = std::chrono::duration<qreal>(now - *m_lastTickTime).count();
269 m_lastTickTime = now;
270
271 if (dtSeconds < 0.0) {
272 if (!m_loggedNegativeDt) {
273 qCWarning(lcAnimatedValue) << "negative dt from clock (" << dtSeconds
274 << "s) — treating as zero-step. Monotonicity contract violated.";
275 m_loggedNegativeDt = true;
276 }
277 m_spec.clock->requestFrame();
278 return;
279 }
280
281 const auto curve = effectiveCurve();
282
283 bool complete = false;
284
285 if (curve->isStateful()) {
286 // Cap the integrator step at Limits::MaxShaderTimeDeltaSeconds.
287 //
288 // NOT for stability: Spring::step is an EXACT exponential integrator
289 // (the closed form of the ODE, not a numerical scheme), so it is
290 // unconditionally stable at any dt, omega and zeta. The cap bounds how
291 // far a stall JUMPS — a suspend/resume or scheduler hitch would
292 // otherwise advance the spring by multiple seconds in one tick, which is
293 // correct physics but reads as a teleport. At the cap a single tick is
294 // 6 frames of motion at 60 Hz; beyond that the animation "skips" rather
295 // than blurring through motion the user never sees.
296 //
297 // Only this branch integrates dt — the parametric branch derives its
298 // value from elapsed/duration, so a stall there merely lands further
299 // along the curve, which is correct. A clamped spring advances less
300 // than wall-clock across a stall (it "skips" less), and kSafetyCap
301 // still bounds its lifetime.
302 curve->step(qMin(dtSeconds, static_cast<qreal>(Limits::MaxShaderTimeDeltaSeconds)), m_state, 1.0);
303
304 // A stateful curve's lifetime is its own analytical settle time. The
305 // convergence test below is necessary but NOT sufficient: it wants
306 // |velocity| <= 1e-6, which an UNDAMPED spring (zeta = 0, inside
307 // Spring's own qBound(0, zeta, 10) and reachable from the wire string
308 // "spring:12,0") never satisfies — it oscillates forever, so without a
309 // bound it would run to kSafetyCap, pinning per-frame repaints for a
310 // full minute. Even a merely soft spring overruns badly: "spring:1,0.2"
311 // converges only after ~46 s.
312 //
313 // settleTime() alone is a PHYSICS bound (itself capped at 30 s inside
314 // Spring), which is right for consumers deliberately outside the
315 // compositor's duration envelope — the daemon's SurfaceAnimator. It is
316 // NOT sufficient for the compositor: its shader leg cuts the same curve
317 // at MaxAnimationDurationMs (2 s), so a slider-reachable soft spring
318 // (zeta*omega < 2.649) would leave the geometry animation requesting
319 // frames for seconds after its shader was torn down. A consumer that
320 // has already resolved a lifetime for this curve passes it as
321 // `maxLifetimeMs`, and the two legs then provably agree.
322 //
323 // settleMs must be positive as well as finite: Curve is polymorphic and
324 // a third-party curve returning 0 or a negative settle time would
325 // otherwise complete on the second tick and snap the animation. Such a
326 // curve falls through to kSafetyCap instead.
327 const qreal elapsedMs = std::chrono::duration<qreal, std::milli>(elapsed).count();
328 qreal lifetimeMs = curve->settleTime() * 1000.0;
329 if (m_spec.maxLifetimeMs) {
330 lifetimeMs = qMin(lifetimeMs, static_cast<qreal>(*m_spec.maxLifetimeMs));
331 }
332 // AT the target, not merely past it: `value >= 1.0` alone is also true
333 // at an overshoot CREST (displaced past the target with the velocity
334 // instantaneously ~0), and completing there would snap the window back
335 // mid-bounce. The real completion path is step()'s convergence lock,
336 // which snaps value to EXACTLY the target once |error| < 1e-4 — so
337 // requiring the value to be at the target costs nothing on the settle
338 // path and closes the crest hole.
339 if (qAbs(m_state.value - 1.0) <= 1.0e-6 && qAbs(m_state.velocity) <= 1.0e-6) {
340 complete = true;
341 } else if (lifetimeMs > 0.0 && std::isfinite(lifetimeMs) && elapsedMs >= lifetimeMs) {
342 complete = true;
343 } else if (elapsed > kSafetyCap) {
344 qCWarning(lcAnimatedValue) << "stateful curve exceeded safety cap; forcing completion";
345 complete = true;
346 }
347 } else {
348 const qreal durationMs = m_spec.profile.effectiveDuration();
349 const qreal elapsedMs = std::chrono::duration<qreal, std::milli>(elapsed).count();
350 if (!std::isfinite(durationMs) || durationMs <= 0.0 || elapsedMs >= durationMs) {
351 m_state.value = 1.0;
352 complete = true;
353 } else {
354 const qreal t = elapsedMs / durationMs;
355 m_state.value = curve->evaluate(t);
356 }
357 }
358
359 if (complete) {
360 m_current = m_to;
361 m_state.value = 1.0;
362 m_state.velocity = 0.0;
363 m_isAnimating = false;
364 m_isComplete = true;
365 if (m_spec.onValueChanged) {
366 m_spec.onValueChanged(m_current);
367 }
368 // Re-entrancy guard: onValueChanged may have restarted via start().
369 if (m_isComplete && m_spec.onComplete) {
370 m_spec.onComplete();
371 }
372 } else {
373 m_current = lerpStateValue();
374 if (m_spec.onValueChanged) {
375 m_spec.onValueChanged(m_current);
376 }
377 if (m_spec.clock) {
378 m_spec.clock->requestFrame();
379 }
380 }
381 }
382
383 T value() const
384 {
385 return m_current;
386 }
387 qreal velocity() const
388 {
389 return m_state.velocity;
390 }
391 const CurveState& state() const
392 {
393 return m_state;
394 }
395 bool isAnimating() const
396 {
397 return m_isAnimating;
398 }
399 bool isComplete() const
400 {
401 return m_isComplete;
402 }
403 const MotionSpec<T>& spec() const
404 {
405 return m_spec;
406 }
407 T from() const
408 {
409 return m_from;
410 }
411 T to() const
412 {
413 return m_to;
414 }
415
416 // Geometric bounds & swept-range queries — definitions in
417 // AnimatedValue_geometric.h, included at the bottom of this file.
418
419 QRectF bounds() const
420 requires detail::PositionalGeometric<T>;
421
422 QRectF boundsAt(QPointF anchor) const
423 requires detail::SizeGeometric<T>;
424
425 std::pair<QSizeF, QSizeF> sweptSize() const
426 requires detail::SizeGeometric<T>;
427
428 bool hasSizeChange(qreal epsilonPx = kRectSizeEpsilonPx) const
429 requires std::same_as<T, QRectF>;
430
431 std::pair<T, T> sweptRange() const
432 requires detail::ScalarValue<T>;
433
434 static constexpr std::chrono::seconds safetyCap() noexcept
435 {
436 return kSafetyCap;
437 }
438
439private:
440 static constexpr std::chrono::seconds kSafetyCap{60};
441 static constexpr int kOvershootSamples = 50;
442
443 T lerpStateValue() const
444 {
445 // Bound the overshoot envelope HERE, at the one point where a curve's
446 // progress becomes a value, rather than at either producer. Both the
447 // stateless branch (`evaluate()`) and the stateful one (`step()`) reach
448 // the lerp through this, and `m_state.value` must be left alone: for a
449 // stateful curve that field IS the integrator state and is fed back into
450 // the next step(), so clamping it in place would corrupt the physics
451 // instead of bounding the output. Matches the bound the shader applies to
452 // iTime (`ShaderInternal::clampProgressForCurve`), so the pixels and the
453 // window frame overshoot by the same amount. See AnimationLimits.h.
454 const qreal progress = boundCurveProgress(m_state.value);
455 if constexpr (std::same_as<T, QColor> && Space == ColorSpace::OkLab) {
456 return detail::lerpColorOkLab(m_from, m_to, progress);
457 } else {
458 return Interpolate<T>::lerp(m_from, m_to, progress);
459 }
460 }
461
462 std::shared_ptr<const Curve> effectiveCurve() const
463 {
464 if (m_cachedCurve) {
465 return m_cachedCurve;
466 }
467 return defaultFallbackCurve();
468 }
469
470 QRectF boundsImpl() const
471 requires detail::PositionalGeometric<T>;
472
473 std::pair<QSizeF, QSizeF> sweptSizeImpl() const
474 requires detail::SizeGeometric<T>;
475
476 template<typename Sampler>
477 void sampleOvershoots(qreal& minX, qreal& minY, qreal& maxX, qreal& maxY, const Sampler& sampleAt) const;
478
479 std::pair<T, T> sweptRangeImpl() const;
480
481 T m_from{};
482 T m_to{};
483 T m_current{};
484 CurveState m_state;
485 MotionSpec<T> m_spec;
486 std::shared_ptr<const Curve> m_cachedCurve;
487 std::optional<std::chrono::nanoseconds> m_startTime;
488 std::optional<std::chrono::nanoseconds> m_lastTickTime;
489 bool m_isAnimating = false;
490 bool m_isComplete = false;
491 bool m_loggedStatelessDegrade = false; // rate-limit: stateless PreserveVelocity degrade
492 bool m_loggedNegativeDt = false; // rate-limit: non-monotonic clock
493 bool m_loggedTransformDegrade = false; // rate-limit: QTransform velocity degrade
494 bool m_loggedEpochMismatch = false; // rate-limit: epoch mismatch on rebindClock
495};
496
497} // namespace PhosphorAnimation
498
Animation-wide UI bounds (duration, stagger interval).
Unified motion primitive: one value of type T transitioning from start to target over time,...
Definition AnimatedValue.h:45
const CurveState & state() const
Definition AnimatedValue.h:391
AnimatedValue(const AnimatedValue &)=delete
bool retarget(T newTo, RetargetPolicy policy)
Redirect in-flight animation to a new target.
Definition AnimatedValue.h:104
T value() const
Definition AnimatedValue.h:383
T to() const
Definition AnimatedValue.h:411
void cancel()
Stop animating; leave value() at its current position.
Definition AnimatedValue.h:210
AnimatedValue & operator=(const AnimatedValue &)=delete
bool isAnimating() const
Definition AnimatedValue.h:395
bool isComplete() const
Definition AnimatedValue.h:399
T from() const
Definition AnimatedValue.h:407
qreal velocity() const
Definition AnimatedValue.h:387
void finish()
Snap to target immediately, fire onValueChanged + onComplete.
Definition AnimatedValue.h:225
bool retarget(T newTo)
Convenience overload using m_spec.retargetPolicy.
Definition AnimatedValue.h:199
AnimatedValue(AnimatedValue &&) noexcept=default
void advance()
Advance animation by one paint tick.
Definition AnimatedValue.h:246
const MotionSpec< T > & spec() const
Definition AnimatedValue.h:403
Polymorphic base for all animation curves.
Definition Curve.h:45
Abstract clock interface for the motion runtime.
Definition IMotionClock.h:18
constexpr float MaxShaderTimeDeltaSeconds
Hard ceiling on a per-frame time delta handed to a shader or to a physics integrator,...
Definition AnimationLimits.h:160
QColor lerpColorOkLab(const QColor &from, const QColor &to, qreal t)
Definition Interpolate.h:191
bool isPureTranslate(const QTransform &t)
True if the 2x2 linear part is identity (only translation present).
Definition Interpolate.h:306
Definition AnimatedValue.h:32
constexpr qreal kRectSizeEpsilonPx
Sub-pixel epsilon for rect size-change detection.
Definition Interpolate.h:26
PHOSPHORANIMATION_EXPORT std::shared_ptr< const Curve > defaultFallbackCurve()
qreal boundCurveProgress(qreal progress)
Bound a curve's output to the overshoot envelope [Limits::MinCurveProgress, Limits::MaxCurveProgress]...
Definition Curve.h:118
RetargetPolicy
How an in-flight AnimatedValue<T> reshapes on retarget().
Definition RetargetPolicy.h:9
@ ResetVelocity
Zero velocity on retarget; motion restarts from rest toward the new target.
@ PreserveVelocity
Carry velocity across the segment boundary, re-scaled to the new distance. Default.
@ PreservePosition
Position-continuous only; velocity treatment delegated to the curve's natural behaviour.
ColorSpace
Interpolation space selector for AnimatedValue<QColor, ...>.
Definition Interpolate.h:29
@ Linear
sRGB -> linear lerp -> sRGB (radiometrically correct)
@ OkLab
sRGB -> OkLab lerp -> sRGB (perceptually uniform)
Mutable state for stateful curve progression (springs carry position+velocity across frames; stateles...
Definition Curve.h:20
qreal startValue
Start of current segment — stateless step() lerps from here to target.
Definition Curve.h:27
Type-specific linear interpolation and path-distance for AnimatedValue<T>.
Definition Interpolate.h:36
Runtime call-site bundle for starting an AnimatedValue<T>.
Definition MotionSpec.h:25