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
13#include <PhosphorAnimation/phosphoranimation_export.h>
14
15#include <QColor>
16#include <QLoggingCategory>
17#include <QPointF>
18#include <QRectF>
19#include <QSizeF>
20#include <QTransform>
21#include <QtGlobal>
22
23#include <algorithm>
24#include <chrono>
25#include <cmath>
26#include <memory>
27#include <optional>
28#include <tuple>
29#include <utility>
30
32
33// Export needed because the category is referenced from consumer TUs
34// but defined in phosphor-animation's animatedvalue.cpp.
35Q_DECLARE_EXPORTED_LOGGING_CATEGORY(lcAnimatedValue, PHOSPHORANIMATION_EXPORT)
36
37PHOSPHORANIMATION_EXPORT std::shared_ptr<const Curve> defaultFallbackCurve();
38
42template<typename T, ColorSpace Space = ColorSpace::Linear>
44{
45public:
46 AnimatedValue() = default;
47 ~AnimatedValue() = default;
48
49 AnimatedValue(const AnimatedValue&) = delete;
51 AnimatedValue(AnimatedValue&&) noexcept = default;
52 AnimatedValue& operator=(AnimatedValue&&) noexcept = default;
53
54 // Sibling Space instantiations are friends so seedFrom can copy
55 // private idle state across a space boundary.
56 template<typename, ColorSpace>
57 friend class AnimatedValue;
58
60 bool start(T from, T to, MotionSpec<T> spec)
61 {
62 if (!spec.clock) {
63 qCWarning(lcAnimatedValue) << "start() rejected: null clock";
64 return false;
65 }
66 // NaN/Inf gate — corrupt endpoints would poison every downstream paint.
68 qCWarning(lcAnimatedValue) << "start() rejected: non-finite from/to";
69 return false;
70 }
71
72 m_from = std::move(from);
73 m_to = std::move(to);
74 m_spec = std::move(spec);
75 m_current = m_from;
76 m_cachedCurve = m_spec.profile.curve ? m_spec.profile.curve : defaultFallbackCurve();
77 m_state = CurveState{};
78 m_state.startValue = 0.0;
79 m_state.duration = 1.0;
80 m_startTime.reset();
81 m_lastTickTime.reset();
82 m_loggedStatelessDegrade = false;
83 m_loggedNegativeDt = false;
84 m_loggedTransformDegrade = false;
85 m_loggedEpochMismatch = false;
86
87 if (qFuzzyIsNull(Interpolate<T>::distance(m_from, m_to))) {
88 m_current = m_to;
89 m_state.value = 1.0;
90 m_isAnimating = false;
91 m_isComplete = true;
92 return false;
93 }
94
95 m_isAnimating = true;
96 m_isComplete = false;
97 m_spec.clock->requestFrame();
98 return true;
99 }
100
103 bool retarget(T newTo, RetargetPolicy policy)
104 {
105 if (!m_spec.clock) {
106 qCWarning(lcAnimatedValue) << "retarget() rejected: no stored spec (never started)";
107 return false;
108 }
109 if (!Interpolate<T>::isFinite(newTo)) {
110 qCWarning(lcAnimatedValue) << "retarget() rejected: non-finite newTo";
111 return false;
112 }
113
114 const T newFrom = m_current;
115 const qreal oldDistance = Interpolate<T>::distance(m_from, m_to);
116 const qreal newDistance = Interpolate<T>::distance(newFrom, newTo);
117
118 const auto curve = effectiveCurve();
119 const bool stateful = curve && curve->isStateful();
120
121 qreal newVelocity = 0.0;
122 switch (policy) {
124 if constexpr (std::same_as<T, QTransform>) {
125 // Frobenius metric mixes units for non-translate transforms —
126 // velocity rescale is only meaningful for pure-translate segments.
127 const bool pureTranslate = detail::isPureTranslate(m_from) && detail::isPureTranslate(m_to)
129 if (!pureTranslate) {
130 if (!m_loggedTransformDegrade) {
131 qCDebug(lcAnimatedValue) << "QTransform PreserveVelocity degrading to PreservePosition: "
132 << "non-translate components present (Frobenius metric mixes units)";
133 m_loggedTransformDegrade = true;
134 }
135 newVelocity = 0.0;
136 break;
137 }
138 }
139 if (stateful && newDistance > Interpolate<T>::retargetEpsilon
140 && oldDistance > Interpolate<T>::retargetEpsilon) {
141 // Map normalised velocity through world-space:
142 // worldRate = state.velocity * oldDistance
143 // newNormalisedVelocity = worldRate / newDistance
144 // Per-type epsilon gates BOTH distances to prevent
145 // velocity explosion on sub-threshold retargets.
146 newVelocity = (m_state.velocity * oldDistance) / newDistance;
147 } else if (!stateful && !m_loggedStatelessDegrade) {
148 qCDebug(lcAnimatedValue) << "PreserveVelocity degrading to PreservePosition on stateless curve"
149 << (curve ? curve->typeId() : QStringLiteral("null"));
150 m_loggedStatelessDegrade = true;
151 }
152 break;
154 newVelocity = 0.0;
155 break;
157 newVelocity = 0.0;
158 break;
159 }
160
161 m_from = newFrom;
162 m_to = std::move(newTo);
163 m_current = m_from;
164 m_state.value = 0.0;
165 m_state.velocity = newVelocity;
166 m_state.time = 0.0;
167 m_state.startValue = 0.0;
168 m_startTime.reset();
169 m_lastTickTime.reset();
170
171 if (qFuzzyIsNull(newDistance)) {
172 m_current = m_to;
173 m_state.value = 1.0;
174 m_state.velocity = 0.0;
175 m_isAnimating = false;
176 m_isComplete = true;
177 return false;
178 }
179
180 m_isAnimating = true;
181 m_isComplete = false;
182 m_spec.clock->requestFrame();
183 return true;
184 }
185
187 bool retarget(T newTo)
188 {
189 return retarget(std::move(newTo), m_spec.retargetPolicy);
190 }
191
193 void rebindClock(IMotionClock* newClock);
194
198 void cancel()
199 {
200 m_isAnimating = false;
201 m_isComplete = false;
202 }
203
205 template<ColorSpace OtherSpace>
206 void seedFrom(const AnimatedValue<T, OtherSpace>& other);
207
209 template<ColorSpace OtherSpace>
210 void seedSpecFrom(const AnimatedValue<T, OtherSpace>& other);
211
213 void finish()
214 {
215 if (!m_isAnimating) {
216 return;
217 }
218 m_current = m_to;
219 m_state.value = 1.0;
220 m_state.velocity = 0.0;
221 m_isAnimating = false;
222 m_isComplete = true;
223 if (m_spec.onValueChanged) {
224 m_spec.onValueChanged(m_current);
225 }
226 // Re-entrancy guard: onValueChanged may have restarted this instance.
227 if (m_isComplete && m_spec.onComplete) {
228 m_spec.onComplete();
229 }
230 }
231
234 void advance()
235 {
236 if (!m_isAnimating || !m_spec.clock) {
237 return;
238 }
239
240 const auto now = m_spec.clock->now();
241
242 if (!m_startTime) {
243 m_startTime = now;
244 m_lastTickTime = now;
245 m_current = m_from;
246 if (m_spec.onValueChanged) {
247 m_spec.onValueChanged(m_current);
248 }
249 if (m_spec.clock) {
250 m_spec.clock->requestFrame();
251 }
252 return;
253 }
254
255 const auto elapsed = now - *m_startTime;
256 const qreal dtSeconds = std::chrono::duration<qreal>(now - *m_lastTickTime).count();
257 m_lastTickTime = now;
258
259 if (dtSeconds < 0.0) {
260 if (!m_loggedNegativeDt) {
261 qCWarning(lcAnimatedValue) << "negative dt from clock (" << dtSeconds
262 << "s) — treating as zero-step. Monotonicity contract violated.";
263 m_loggedNegativeDt = true;
264 }
265 m_spec.clock->requestFrame();
266 return;
267 }
268
269 const auto curve = effectiveCurve();
270
271 bool complete = false;
272
273 if (curve->isStateful()) {
274 curve->step(dtSeconds, m_state, 1.0);
275
276 if (m_state.value >= 1.0 && qAbs(m_state.velocity) <= 1.0e-6) {
277 complete = true;
278 } else if (elapsed > kSafetyCap) {
279 qCWarning(lcAnimatedValue) << "stateful curve exceeded safety cap; forcing completion";
280 complete = true;
281 }
282 } else {
283 const qreal durationMs = m_spec.profile.effectiveDuration();
284 const qreal elapsedMs = std::chrono::duration<qreal, std::milli>(elapsed).count();
285 if (!std::isfinite(durationMs) || durationMs <= 0.0 || elapsedMs >= durationMs) {
286 m_state.value = 1.0;
287 complete = true;
288 } else {
289 const qreal t = elapsedMs / durationMs;
290 m_state.value = curve->evaluate(t);
291 }
292 }
293
294 if (complete) {
295 m_current = m_to;
296 m_state.value = 1.0;
297 m_state.velocity = 0.0;
298 m_isAnimating = false;
299 m_isComplete = true;
300 if (m_spec.onValueChanged) {
301 m_spec.onValueChanged(m_current);
302 }
303 // Re-entrancy guard: onValueChanged may have restarted via start().
304 if (m_isComplete && m_spec.onComplete) {
305 m_spec.onComplete();
306 }
307 } else {
308 m_current = lerpStateValue();
309 if (m_spec.onValueChanged) {
310 m_spec.onValueChanged(m_current);
311 }
312 if (m_spec.clock) {
313 m_spec.clock->requestFrame();
314 }
315 }
316 }
317
318 T value() const
319 {
320 return m_current;
321 }
322 qreal velocity() const
323 {
324 return m_state.velocity;
325 }
326 const CurveState& state() const
327 {
328 return m_state;
329 }
330 bool isAnimating() const
331 {
332 return m_isAnimating;
333 }
334 bool isComplete() const
335 {
336 return m_isComplete;
337 }
338 const MotionSpec<T>& spec() const
339 {
340 return m_spec;
341 }
342 T from() const
343 {
344 return m_from;
345 }
346 T to() const
347 {
348 return m_to;
349 }
350
351 // Geometric bounds & swept-range queries — definitions in
352 // AnimatedValue_geometric.h, included at the bottom of this file.
353
354 QRectF bounds() const
355 requires detail::PositionalGeometric<T>;
356
357 QRectF boundsAt(QPointF anchor) const
358 requires detail::SizeGeometric<T>;
359
360 std::pair<QSizeF, QSizeF> sweptSize() const
361 requires detail::SizeGeometric<T>;
362
363 bool hasSizeChange(qreal epsilonPx = kRectSizeEpsilonPx) const
364 requires std::same_as<T, QRectF>;
365
366 std::pair<T, T> sweptRange() const
367 requires detail::ScalarValue<T>;
368
369 static constexpr std::chrono::seconds safetyCap() noexcept
370 {
371 return kSafetyCap;
372 }
373
374private:
375 static constexpr std::chrono::seconds kSafetyCap{60};
376 static constexpr int kOvershootSamples = 50;
377
378 T lerpStateValue() const
379 {
380 if constexpr (std::same_as<T, QColor> && Space == ColorSpace::OkLab) {
381 return detail::lerpColorOkLab(m_from, m_to, m_state.value);
382 } else {
383 return Interpolate<T>::lerp(m_from, m_to, m_state.value);
384 }
385 }
386
387 std::shared_ptr<const Curve> effectiveCurve() const
388 {
389 if (m_cachedCurve) {
390 return m_cachedCurve;
391 }
392 return defaultFallbackCurve();
393 }
394
395 QRectF boundsImpl() const
396 requires detail::PositionalGeometric<T>;
397
398 std::pair<QSizeF, QSizeF> sweptSizeImpl() const
399 requires detail::SizeGeometric<T>;
400
401 template<typename Sampler>
402 void sampleOvershoots(qreal& minX, qreal& minY, qreal& maxX, qreal& maxY, const Sampler& sampleAt) const;
403
404 std::pair<T, T> sweptRangeImpl() const;
405
406 T m_from{};
407 T m_to{};
408 T m_current{};
409 CurveState m_state;
410 MotionSpec<T> m_spec;
411 std::shared_ptr<const Curve> m_cachedCurve;
412 std::optional<std::chrono::nanoseconds> m_startTime;
413 std::optional<std::chrono::nanoseconds> m_lastTickTime;
414 bool m_isAnimating = false;
415 bool m_isComplete = false;
416 bool m_loggedStatelessDegrade = false; // rate-limit: stateless PreserveVelocity degrade
417 bool m_loggedNegativeDt = false; // rate-limit: non-monotonic clock
418 bool m_loggedTransformDegrade = false; // rate-limit: QTransform velocity degrade
419 bool m_loggedEpochMismatch = false; // rate-limit: epoch mismatch on rebindClock
420};
421
422} // namespace PhosphorAnimation
423
Unified motion primitive: one value of type T transitioning from start to target over time,...
Definition AnimatedValue.h:44
const CurveState & state() const
Definition AnimatedValue.h:326
AnimatedValue(const AnimatedValue &)=delete
bool retarget(T newTo, RetargetPolicy policy)
Redirect in-flight animation to a new target.
Definition AnimatedValue.h:103
T value() const
Definition AnimatedValue.h:318
T to() const
Definition AnimatedValue.h:346
void cancel()
Stop animating; leave value() at its current position.
Definition AnimatedValue.h:198
AnimatedValue & operator=(const AnimatedValue &)=delete
bool isAnimating() const
Definition AnimatedValue.h:330
bool isComplete() const
Definition AnimatedValue.h:334
T from() const
Definition AnimatedValue.h:342
qreal velocity() const
Definition AnimatedValue.h:322
void finish()
Snap to target immediately, fire onValueChanged + onComplete.
Definition AnimatedValue.h:213
bool retarget(T newTo)
Convenience overload using m_spec.retargetPolicy.
Definition AnimatedValue.h:187
AnimatedValue(AnimatedValue &&) noexcept=default
void advance()
Advance animation by one paint tick.
Definition AnimatedValue.h:234
const MotionSpec< T > & spec() const
Definition AnimatedValue.h:338
Polymorphic base for all animation curves.
Definition Curve.h:44
Abstract clock interface for the motion runtime.
Definition IMotionClock.h:18
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:31
constexpr qreal kRectSizeEpsilonPx
Sub-pixel epsilon for rect size-change detection.
Definition Interpolate.h:26
PHOSPHORANIMATION_EXPORT std::shared_ptr< const Curve > defaultFallbackCurve()
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:19
qreal startValue
Start of current segment — stateless step() lerps from here to target.
Definition Curve.h:26
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:24