Skip to main content

open_gpui_motion/
controller.rs

1//! Renderer-neutral motion controller contracts.
2
3use crate::spring::{MotionModel, MotionScalarSample};
4use crate::value::MotionValue;
5use crate::{
6    MotionPolicyInput, MotionPolicyReport, MotionRunState, MotionSpec, validate_motion_policy,
7};
8use std::time::{Duration, Instant};
9
10/// Renderer-neutral frame demand returned by motion controllers.
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum MotionFrameDemand {
13    /// No more animation frames are required.
14    Idle,
15    /// At least one track is active and the adapter should request another frame for the reason.
16    NeedsFrame(MotionFrameReason),
17}
18
19/// Minimal reason vocabulary for adapter-owned frame requests.
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum MotionFrameReason {
22    /// The adapter should sample motion and render the updated presentation state.
23    UpdateRender,
24}
25
26impl MotionFrameDemand {
27    /// Returns whether another frame should be requested by the adapter.
28    pub const fn needs_frame(self) -> bool {
29        matches!(self, Self::NeedsFrame(_))
30    }
31
32    /// Returns the reason another frame is needed.
33    pub const fn reason(self) -> Option<MotionFrameReason> {
34        match self {
35            Self::Idle => None,
36            Self::NeedsFrame(reason) => Some(reason),
37        }
38    }
39
40    fn from_active(active: bool) -> Self {
41        if active {
42            Self::NeedsFrame(MotionFrameReason::UpdateRender)
43        } else {
44            Self::Idle
45        }
46    }
47
48    /// Combines two frame demands into one adapter-owned frame request.
49    pub const fn combine(self, other: Self) -> Self {
50        match (self, other) {
51            (Self::NeedsFrame(reason), _) | (_, Self::NeedsFrame(reason)) => {
52                Self::NeedsFrame(reason)
53            }
54            (Self::Idle, Self::Idle) => Self::Idle,
55        }
56    }
57
58    /// Combines many frame demands into one adapter-owned frame request.
59    pub fn combine_all(demands: impl IntoIterator<Item = Self>) -> Self {
60        demands.into_iter().fold(Self::Idle, Self::combine)
61    }
62}
63
64/// Adapter clock sample mapped into deterministic controller elapsed time.
65#[derive(Debug, Clone, Copy, PartialEq, Eq)]
66pub struct MotionClockSample {
67    elapsed: Duration,
68    delta: Duration,
69    clamped: bool,
70}
71
72impl MotionClockSample {
73    /// A zero elapsed-time clock sample.
74    pub const ZERO: Self = Self {
75        elapsed: Duration::ZERO,
76        delta: Duration::ZERO,
77        clamped: false,
78    };
79
80    /// Creates a clock sample from previous and requested elapsed times.
81    ///
82    /// Non-monotonic elapsed time is clamped to the previous elapsed time. This keeps controller
83    /// sampling deterministic and avoids negative deltas for adapters whose frame clock moves
84    /// backwards or is restored from stale state.
85    pub fn from_elapsed(previous_elapsed: Duration, requested_elapsed: Duration) -> Self {
86        if requested_elapsed < previous_elapsed {
87            Self {
88                elapsed: previous_elapsed,
89                delta: Duration::ZERO,
90                clamped: true,
91            }
92        } else {
93            Self {
94                elapsed: requested_elapsed,
95                delta: requested_elapsed - previous_elapsed,
96                clamped: false,
97            }
98        }
99    }
100
101    /// Creates a clock sample from a start instant and current adapter instant.
102    pub fn from_instant(started_at: Instant, now: Instant) -> Self {
103        Self::from_elapsed(Duration::ZERO, now.saturating_duration_since(started_at))
104    }
105
106    /// Creates a clock sample from adapter instants and clamps non-monotonic elapsed time.
107    pub fn from_instants(started_at: Instant, previous_now: Instant, now: Instant) -> Self {
108        Self::from_elapsed(
109            previous_now.saturating_duration_since(started_at),
110            now.saturating_duration_since(started_at),
111        )
112    }
113
114    /// Returns clamped controller elapsed time.
115    pub const fn elapsed(self) -> Duration {
116        self.elapsed
117    }
118
119    /// Returns elapsed-time delta since the previous sample.
120    pub const fn delta(self) -> Duration {
121        self.delta
122    }
123
124    /// Returns whether requested elapsed time was clamped.
125    pub const fn clamped(self) -> bool {
126        self.clamped
127    }
128}
129
130/// Policy-resolved execution state for a motion run before adapter sampling begins.
131#[derive(Debug, Clone, Copy, PartialEq, Eq)]
132pub enum MotionExecutionState {
133    /// The run should publish its final semantic state without requesting frames.
134    Immediate,
135    /// The run may sample over time and request adapter-owned frames.
136    Scheduled,
137}
138
139impl MotionExecutionState {
140    /// Returns whether the run completes immediately.
141    pub const fn is_immediate(self) -> bool {
142        matches!(self, Self::Immediate)
143    }
144
145    /// Returns whether the run should be sampled over time.
146    pub const fn is_scheduled(self) -> bool {
147        matches!(self, Self::Scheduled)
148    }
149}
150
151/// Renderer-neutral policy result used to start motion from a single owner.
152#[derive(Debug, Clone, PartialEq)]
153pub struct MotionExecutionPlan {
154    model: MotionModel,
155    policy_report: MotionPolicyReport,
156    state: MotionExecutionState,
157}
158
159impl MotionExecutionPlan {
160    /// Resolves a requested model through motion policy, falling back to immediate motion on
161    /// policy failure.
162    pub fn resolve(input: MotionPolicyInput) -> Self {
163        let requested_model = input.model();
164        let policy_report = validate_motion_policy(input);
165        let model = if policy_report.is_ok() {
166            requested_model
167        } else {
168            MotionModel::timeline(MotionSpec::immediate())
169        };
170        let state = if model.is_immediate() {
171            MotionExecutionState::Immediate
172        } else {
173            MotionExecutionState::Scheduled
174        };
175        Self {
176            model,
177            policy_report,
178            state,
179        }
180    }
181
182    /// Returns the model that should execute after policy resolution.
183    pub const fn model(&self) -> MotionModel {
184        self.model
185    }
186
187    /// Returns the policy report produced for the requested model.
188    pub const fn policy_report(&self) -> &MotionPolicyReport {
189        &self.policy_report
190    }
191
192    /// Returns the policy-resolved execution state.
193    pub const fn state(&self) -> MotionExecutionState {
194        self.state
195    }
196
197    /// Returns whether the run should complete immediately.
198    pub const fn is_immediate(&self) -> bool {
199        self.state.is_immediate()
200    }
201
202    /// Consumes the plan and returns its parts.
203    pub fn into_parts(self) -> (MotionModel, MotionPolicyReport, MotionExecutionState) {
204        (self.model, self.policy_report, self.state)
205    }
206}
207
208/// One scalar motion track sampled by deterministic elapsed time.
209#[derive(Debug, Clone, PartialEq)]
210pub struct MotionScalarTrack {
211    model: MotionModel,
212    value: MotionValue,
213    target: f32,
214    initial_velocity: f32,
215    started_at: Duration,
216    cancelled_at: Option<Duration>,
217    finished_at: Option<Duration>,
218}
219
220impl MotionScalarTrack {
221    /// Starts a scalar track at the provided controller time.
222    pub fn start(
223        model: MotionModel,
224        from: f32,
225        target: f32,
226        initial_velocity: f32,
227        started_at: Duration,
228    ) -> Self {
229        Self {
230            model,
231            value: MotionValue::new(from),
232            target,
233            initial_velocity,
234            started_at,
235            cancelled_at: None,
236            finished_at: None,
237        }
238    }
239
240    /// Creates an immediate scalar track at a fixed value.
241    pub fn immediate(value: f32, started_at: Duration) -> Self {
242        Self::start(
243            MotionModel::timeline(MotionSpec::immediate()),
244            value,
245            value,
246            0.0,
247            started_at,
248        )
249    }
250
251    /// Returns the motion model.
252    pub const fn model(&self) -> MotionModel {
253        self.model
254    }
255
256    /// Returns the source value.
257    pub const fn from(&self) -> f32 {
258        self.value.current()
259    }
260
261    /// Returns the target value.
262    pub const fn target(&self) -> f32 {
263        self.target
264    }
265
266    /// Returns the initial velocity.
267    pub const fn initial_velocity(&self) -> f32 {
268        self.initial_velocity
269    }
270
271    /// Returns the controller time at which the track started.
272    pub const fn started_at(&self) -> Duration {
273        self.started_at
274    }
275
276    /// Returns the controller time at which the track was cancelled.
277    pub const fn cancelled_at(&self) -> Option<Duration> {
278        self.cancelled_at
279    }
280
281    /// Returns the controller time at which the track was explicitly finished.
282    pub const fn finished_at(&self) -> Option<Duration> {
283        self.finished_at
284    }
285
286    /// Cancels the track at the provided controller time.
287    pub fn cancel_at(&mut self, cancelled_at: Duration) {
288        if self.finished_at.is_none() {
289            self.cancelled_at = Some(cancelled_at);
290        }
291    }
292
293    /// Finishes the track at the provided controller time and publishes the target value.
294    pub fn finish_at(&mut self, finished_at: Duration) {
295        self.finished_at = Some(finished_at);
296        self.cancelled_at = None;
297    }
298
299    /// Retargets the track from its sampled value and velocity.
300    pub fn retarget(&self, model: MotionModel, target: f32, now: Duration) -> Self {
301        let sample = self.sample_at(now);
302        Self::start(model, sample.value(), target, sample.velocity(), now)
303    }
304
305    /// Samples the track at the provided controller time.
306    pub fn sample_at(&self, now: Duration) -> MotionScalarSample {
307        if let Some(finished_at) = self.finished_at {
308            return MotionScalarSample::new(
309                MotionRunState::Completed,
310                finished_at.saturating_sub(self.started_at),
311                self.target,
312                0.0,
313                self.target,
314            );
315        }
316
317        let effective_now = self.cancelled_at.unwrap_or(now);
318        let elapsed = effective_now.saturating_sub(self.started_at);
319        let mut sample = self.model.sample_scalar_elapsed(
320            self.value.current(),
321            self.target,
322            self.initial_velocity,
323            elapsed,
324        );
325        if self.cancelled_at.is_some() && sample.state().is_active() {
326            sample = MotionScalarSample::new(
327                MotionRunState::Cancelled,
328                sample.elapsed(),
329                sample.value(),
330                sample.velocity(),
331                sample.target(),
332            );
333        }
334        sample
335    }
336
337    /// Returns whether this track needs another adapter-owned frame.
338    pub fn frame_demand_at(&self, now: Duration) -> MotionFrameDemand {
339        MotionFrameDemand::from_active(self.sample_at(now).is_active())
340    }
341}
342
343/// Sample from a policy-resolved scalar execution.
344#[derive(Debug, Clone, Copy, PartialEq)]
345pub struct MotionScalarExecutionSample {
346    sample: MotionScalarSample,
347    complete: bool,
348    frame_demand: MotionFrameDemand,
349}
350
351impl MotionScalarExecutionSample {
352    const fn new(
353        sample: MotionScalarSample,
354        complete: bool,
355        frame_demand: MotionFrameDemand,
356    ) -> Self {
357        Self {
358            sample,
359            complete,
360            frame_demand,
361        }
362    }
363
364    /// Returns the underlying scalar sample.
365    pub const fn scalar_sample(self) -> MotionScalarSample {
366        self.sample
367    }
368
369    /// Returns the sampled scalar value.
370    pub const fn value(self) -> f32 {
371        self.sample.value()
372    }
373
374    /// Returns whether the run has reached the semantic completion state.
375    pub const fn complete(self) -> bool {
376        self.complete
377    }
378
379    /// Returns whether the adapter should request another frame.
380    pub const fn frame_demand(self) -> MotionFrameDemand {
381        self.frame_demand
382    }
383}
384
385/// Sample from a normalized 0..1 progress execution.
386#[derive(Debug, Clone, Copy, PartialEq)]
387pub struct MotionProgressSample {
388    sample: MotionScalarExecutionSample,
389}
390
391impl MotionProgressSample {
392    const fn new(sample: MotionScalarExecutionSample) -> Self {
393        Self { sample }
394    }
395
396    /// Returns the underlying scalar execution sample.
397    pub const fn scalar_sample(self) -> MotionScalarExecutionSample {
398        self.sample
399    }
400
401    /// Returns the clamped normalized progress.
402    pub fn progress(self) -> f32 {
403        self.sample.value().clamp(0.0, 1.0)
404    }
405
406    /// Returns whether the progress run reached its semantic completion state.
407    pub const fn complete(self) -> bool {
408        self.sample.complete()
409    }
410
411    /// Returns whether the adapter should request another frame.
412    pub const fn frame_demand(self) -> MotionFrameDemand {
413        self.sample.frame_demand()
414    }
415}
416
417/// A single scalar track plus its policy-resolved execution metadata.
418#[derive(Debug, Clone, PartialEq)]
419pub struct MotionScalarExecution {
420    plan: MotionExecutionPlan,
421    track: MotionScalarTrack,
422}
423
424impl MotionScalarExecution {
425    /// Starts a scalar execution from an already resolved motion plan.
426    pub fn start(
427        plan: MotionExecutionPlan,
428        from: f32,
429        target: f32,
430        initial_velocity: f32,
431        started_at: Duration,
432    ) -> Self {
433        let track =
434            MotionScalarTrack::start(plan.model(), from, target, initial_velocity, started_at);
435        Self { plan, track }
436    }
437
438    /// Resolves policy input and starts a scalar execution.
439    pub fn start_resolved(
440        input: MotionPolicyInput,
441        from: f32,
442        target: f32,
443        initial_velocity: f32,
444        started_at: Duration,
445    ) -> Self {
446        Self::start(
447            MotionExecutionPlan::resolve(input),
448            from,
449            target,
450            initial_velocity,
451            started_at,
452        )
453    }
454
455    /// Returns the resolved execution plan.
456    pub const fn plan(&self) -> &MotionExecutionPlan {
457        &self.plan
458    }
459
460    /// Returns the underlying scalar track.
461    pub const fn track(&self) -> &MotionScalarTrack {
462        &self.track
463    }
464
465    /// Returns the model that should execute after policy resolution.
466    pub const fn model(&self) -> MotionModel {
467        self.plan.model()
468    }
469
470    /// Returns the policy report produced for the requested model.
471    pub const fn policy_report(&self) -> &MotionPolicyReport {
472        self.plan.policy_report()
473    }
474
475    /// Returns the policy-resolved execution state.
476    pub const fn state(&self) -> MotionExecutionState {
477        self.plan.state()
478    }
479
480    /// Samples the execution at deterministic elapsed time.
481    pub fn sample_at(&self, now: Duration) -> MotionScalarExecutionSample {
482        let sample = self.track.sample_at(now);
483        let complete = self.plan.is_immediate() || sample.reached_final_state();
484        let frame_demand = if complete {
485            MotionFrameDemand::Idle
486        } else {
487            MotionFrameDemand::from_active(sample.is_active())
488        };
489        MotionScalarExecutionSample::new(sample, complete, frame_demand)
490    }
491
492    /// Samples the execution at a deterministic adapter clock sample.
493    pub fn sample_clock(&self, clock: MotionClockSample) -> MotionScalarExecutionSample {
494        self.sample_at(clock.elapsed())
495    }
496
497    /// Samples the execution from adapter instants while keeping deterministic elapsed-time
498    /// semantics in the controller layer.
499    pub fn sample_since(&self, started_at: Instant, now: Instant) -> MotionScalarExecutionSample {
500        self.sample_at(now.saturating_duration_since(started_at))
501    }
502}
503
504/// A policy-resolved normalized 0..1 progress run.
505///
506/// This is the renderer-neutral lifecycle primitive for adapters that need one progress value to
507/// drive their own layout, geometry, or paint projection. It deliberately does not schedule frames
508/// itself; callers translate the returned [`MotionFrameDemand`] through their adapter.
509#[derive(Debug, Clone, PartialEq)]
510pub struct MotionProgressExecution {
511    execution: MotionScalarExecution,
512    started_at: Instant,
513}
514
515impl MotionProgressExecution {
516    /// Starts a normalized progress run from an already resolved motion plan.
517    pub fn start(plan: MotionExecutionPlan, started_at: Instant) -> Self {
518        Self {
519            execution: MotionScalarExecution::start(plan, 0.0, 1.0, 0.0, Duration::ZERO),
520            started_at,
521        }
522    }
523
524    /// Resolves policy input and starts a normalized progress run.
525    pub fn start_resolved(input: MotionPolicyInput, started_at: Instant) -> Self {
526        Self::start(MotionExecutionPlan::resolve(input), started_at)
527    }
528
529    /// Returns the underlying scalar execution.
530    pub const fn scalar_execution(&self) -> &MotionScalarExecution {
531        &self.execution
532    }
533
534    /// Returns the resolved execution plan.
535    pub const fn plan(&self) -> &MotionExecutionPlan {
536        self.execution.plan()
537    }
538
539    /// Returns the model that should execute after policy resolution.
540    pub const fn model(&self) -> MotionModel {
541        self.execution.model()
542    }
543
544    /// Returns the policy report produced for the requested model.
545    pub const fn policy_report(&self) -> &MotionPolicyReport {
546        self.execution.policy_report()
547    }
548
549    /// Returns the policy-resolved execution state.
550    pub const fn state(&self) -> MotionExecutionState {
551        self.execution.state()
552    }
553
554    /// Returns the adapter instant at which this progress run started.
555    pub const fn started_at(&self) -> Instant {
556        self.started_at
557    }
558
559    /// Samples the progress run at deterministic elapsed time.
560    pub fn sample_at(&self, now: Duration) -> MotionProgressSample {
561        MotionProgressSample::new(self.execution.sample_at(now))
562    }
563
564    /// Samples the progress run at a deterministic adapter clock sample.
565    pub fn sample_clock(&self, clock: MotionClockSample) -> MotionProgressSample {
566        MotionProgressSample::new(self.execution.sample_clock(clock))
567    }
568
569    /// Samples the progress run from adapter instants while keeping deterministic elapsed-time
570    /// semantics in the controller layer.
571    pub fn sample_since(&self, now: Instant) -> MotionProgressSample {
572        MotionProgressSample::new(self.execution.sample_since(self.started_at, now))
573    }
574}
575
576/// A sampled keyed scalar motion track.
577#[derive(Debug, Clone, PartialEq)]
578pub struct MotionScalarTrackSample<K> {
579    key: K,
580    sample: MotionScalarSample,
581}
582
583impl<K> MotionScalarTrackSample<K> {
584    /// Creates a keyed track sample.
585    pub const fn new(key: K, sample: MotionScalarSample) -> Self {
586        Self { key, sample }
587    }
588
589    /// Returns the track key.
590    pub const fn key(&self) -> &K {
591        &self.key
592    }
593
594    /// Returns the scalar motion sample.
595    pub const fn sample(&self) -> MotionScalarSample {
596        self.sample
597    }
598}
599
600/// A grouped controller sample.
601#[derive(Debug, Clone, PartialEq)]
602pub struct MotionScalarControllerSample<K> {
603    tracks: Vec<MotionScalarTrackSample<K>>,
604    frame_demand: MotionFrameDemand,
605}
606
607impl<K> MotionScalarControllerSample<K> {
608    /// Creates a grouped sample.
609    pub fn new(tracks: Vec<MotionScalarTrackSample<K>>, frame_demand: MotionFrameDemand) -> Self {
610        Self {
611            tracks,
612            frame_demand,
613        }
614    }
615
616    /// Returns sampled tracks in controller order.
617    pub fn tracks(&self) -> &[MotionScalarTrackSample<K>] {
618        &self.tracks
619    }
620
621    /// Returns the grouped frame demand.
622    pub const fn frame_demand(&self) -> MotionFrameDemand {
623        self.frame_demand
624    }
625
626    /// Returns whether all grouped tracks are terminal for adapter frame scheduling.
627    pub const fn complete(&self) -> bool {
628        !self.frame_demand.needs_frame()
629    }
630}
631
632impl<K: PartialEq> MotionScalarControllerSample<K> {
633    /// Returns the sample for a key.
634    pub fn track(&self, key: &K) -> Option<&MotionScalarTrackSample<K>> {
635        self.tracks.iter().find(|track| track.key() == key)
636    }
637}
638
639/// A small keyed scalar motion controller.
640#[derive(Debug, Clone, PartialEq)]
641pub struct MotionScalarController<K> {
642    tracks: Vec<(K, MotionScalarTrack)>,
643}
644
645impl<K> Default for MotionScalarController<K> {
646    fn default() -> Self {
647        Self::new()
648    }
649}
650
651impl<K> MotionScalarController<K> {
652    /// Creates an empty scalar motion controller.
653    pub const fn new() -> Self {
654        Self { tracks: Vec::new() }
655    }
656
657    /// Returns all registered tracks in insertion order.
658    pub fn tracks(&self) -> &[(K, MotionScalarTrack)] {
659        &self.tracks
660    }
661}
662
663impl<K: PartialEq> MotionScalarController<K> {
664    /// Starts or replaces a keyed scalar track.
665    pub fn start(
666        &mut self,
667        key: K,
668        model: MotionModel,
669        from: f32,
670        target: f32,
671        initial_velocity: f32,
672        started_at: Duration,
673    ) {
674        let track = MotionScalarTrack::start(model, from, target, initial_velocity, started_at);
675        if let Some((_, existing)) = self
676            .tracks
677            .iter_mut()
678            .find(|(track_key, _)| track_key == &key)
679        {
680            *existing = track;
681        } else {
682            self.tracks.push((key, track));
683        }
684    }
685
686    /// Sets or replaces a keyed scalar track with an immediate fixed value.
687    pub fn set_immediate(&mut self, key: K, value: f32, now: Duration) {
688        let track = MotionScalarTrack::immediate(value, now);
689        if let Some((_, existing)) = self
690            .tracks
691            .iter_mut()
692            .find(|(track_key, _)| track_key == &key)
693        {
694            *existing = track;
695        } else {
696            self.tracks.push((key, track));
697        }
698    }
699
700    /// Retargets an existing keyed track from its sampled value and velocity.
701    pub fn retarget(&mut self, key: K, model: MotionModel, target: f32, now: Duration) {
702        if let Some((_, existing)) = self
703            .tracks
704            .iter_mut()
705            .find(|(track_key, _)| track_key == &key)
706        {
707            *existing = existing.retarget(model, target, now);
708        } else {
709            self.start(key, model, target, target, 0.0, now);
710        }
711    }
712
713    /// Cancels an existing keyed track at the provided controller time.
714    pub fn cancel(&mut self, key: &K, now: Duration) {
715        if let Some((_, existing)) = self
716            .tracks
717            .iter_mut()
718            .find(|(track_key, _)| track_key == key)
719        {
720            existing.cancel_at(now);
721        }
722    }
723
724    /// Finishes an existing keyed track at the provided controller time.
725    pub fn finish(&mut self, key: &K, now: Duration) {
726        if let Some((_, existing)) = self
727            .tracks
728            .iter_mut()
729            .find(|(track_key, _)| track_key == key)
730        {
731            existing.finish_at(now);
732        }
733    }
734
735    /// Returns the frame demand at the provided controller time.
736    pub fn frame_demand_at(&self, now: Duration) -> MotionFrameDemand {
737        MotionFrameDemand::combine_all(
738            self.tracks
739                .iter()
740                .map(|(_, track)| track.frame_demand_at(now)),
741        )
742    }
743}
744
745impl<K> MotionScalarController<K> {
746    /// Removes terminal tracks and returns the number of pruned entries.
747    pub fn prune_terminal_at(&mut self, now: Duration) -> usize {
748        let before = self.tracks.len();
749        self.tracks
750            .retain(|(_, track)| track.frame_demand_at(now).needs_frame());
751        before - self.tracks.len()
752    }
753}
754
755impl<K: Clone> MotionScalarController<K> {
756    /// Samples all tracks at the provided controller time.
757    pub fn sample_at(&self, now: Duration) -> MotionScalarControllerSample<K> {
758        let tracks = self
759            .tracks
760            .iter()
761            .map(|(key, track)| MotionScalarTrackSample::new(key.clone(), track.sample_at(now)))
762            .collect::<Vec<_>>();
763        let frame_demand = MotionFrameDemand::combine_all(
764            tracks
765                .iter()
766                .map(|track| MotionFrameDemand::from_active(track.sample().is_active())),
767        );
768        MotionScalarControllerSample::new(tracks, frame_demand)
769    }
770
771    /// Samples all tracks at a deterministic adapter clock sample.
772    pub fn sample_clock(&self, clock: MotionClockSample) -> MotionScalarControllerSample<K> {
773        self.sample_at(clock.elapsed())
774    }
775
776    /// Samples all tracks from adapter instants while keeping deterministic elapsed-time
777    /// semantics in the controller layer.
778    pub fn sample_since(
779        &self,
780        started_at: Instant,
781        now: Instant,
782    ) -> MotionScalarControllerSample<K> {
783        self.sample_at(now.saturating_duration_since(started_at))
784    }
785}
786
787#[cfg(test)]
788mod tests {
789    use super::*;
790    use crate::{
791        MotionDuration, MotionEasing, MotionModel, MotionPolicyContext, MotionPreference,
792        MotionRunState, MotionSpec, MotionSpringSpec,
793    };
794    use std::time::Duration;
795
796    #[test]
797    fn grouped_tracks_report_frame_demand_until_every_track_completes() {
798        let mut controller = MotionScalarController::new();
799        let model = MotionModel::timeline(MotionSpec::new(
800            MotionPreference::Animated,
801            MotionDuration::Custom(Duration::from_millis(100)),
802            MotionEasing::Linear,
803        ));
804
805        controller.start("left", model, 0.0, 1.0, 0.0, Duration::ZERO);
806        controller.start("right", model, 1.0, 0.0, 0.0, Duration::ZERO);
807
808        let active = controller.sample_at(Duration::from_millis(50));
809        assert!(active.frame_demand().needs_frame());
810        assert_eq!(
811            active.frame_demand().reason(),
812            Some(MotionFrameReason::UpdateRender)
813        );
814        assert_eq!(active.tracks().len(), 2);
815        assert!(
816            active
817                .tracks()
818                .iter()
819                .all(|track| track.sample().is_active())
820        );
821
822        let complete = controller.sample_at(Duration::from_millis(120));
823        assert!(!complete.frame_demand().needs_frame());
824        assert!(
825            complete
826                .tracks()
827                .iter()
828                .all(|track| track.sample().reached_final_state())
829        );
830        assert!(complete.complete());
831    }
832
833    #[test]
834    fn frame_demand_combines_idle_and_active_with_stable_reason() {
835        let active = MotionFrameDemand::NeedsFrame(MotionFrameReason::UpdateRender);
836
837        assert_eq!(MotionFrameDemand::Idle.combine(active), active);
838        assert_eq!(active.combine(MotionFrameDemand::Idle), active);
839        assert_eq!(
840            MotionFrameDemand::combine_all([
841                MotionFrameDemand::Idle,
842                active,
843                MotionFrameDemand::Idle,
844            ]),
845            active
846        );
847    }
848
849    #[test]
850    fn clock_sample_clamps_non_monotonic_elapsed_time() {
851        let sample =
852            MotionClockSample::from_elapsed(Duration::from_millis(40), Duration::from_millis(15));
853
854        assert_eq!(sample.elapsed(), Duration::from_millis(40));
855        assert_eq!(sample.delta(), Duration::ZERO);
856        assert!(sample.clamped());
857    }
858
859    #[test]
860    fn finishing_track_jumps_to_target_and_stops_frame_demand() {
861        let mut track = MotionScalarTrack::start(
862            MotionModel::timeline(MotionSpec::new(
863                MotionPreference::Animated,
864                MotionDuration::Custom(Duration::from_millis(200)),
865                MotionEasing::Linear,
866            )),
867            0.0,
868            1.0,
869            0.0,
870            Duration::ZERO,
871        );
872
873        track.finish_at(Duration::from_millis(40));
874        let sample = track.sample_at(Duration::from_millis(50));
875
876        assert_eq!(sample.state(), MotionRunState::Completed);
877        assert_eq!(sample.value(), 1.0);
878        assert!(sample.reached_final_state());
879        assert!(
880            !track
881                .frame_demand_at(Duration::from_millis(50))
882                .needs_frame()
883        );
884    }
885
886    #[test]
887    fn controller_prunes_terminal_tracks_after_cancel_and_finish() {
888        let mut controller = MotionScalarController::new();
889        let model = MotionModel::timeline(MotionSpec::new(
890            MotionPreference::Animated,
891            MotionDuration::Custom(Duration::from_millis(200)),
892            MotionEasing::Linear,
893        ));
894
895        controller.start("cancelled", model, 0.0, 1.0, 0.0, Duration::ZERO);
896        controller.start("finished", model, 0.0, 1.0, 0.0, Duration::ZERO);
897        controller.start("active", model, 0.0, 1.0, 0.0, Duration::ZERO);
898        controller.cancel(&"cancelled", Duration::from_millis(40));
899        controller.finish(&"finished", Duration::from_millis(40));
900
901        assert_eq!(controller.prune_terminal_at(Duration::from_millis(50)), 2);
902        assert_eq!(controller.tracks().len(), 1);
903        assert_eq!(controller.tracks()[0].0, "active");
904        assert!(
905            controller
906                .frame_demand_at(Duration::from_millis(50))
907                .needs_frame()
908        );
909    }
910
911    #[test]
912    fn retarget_preserves_sampled_value_and_velocity_for_one_track() {
913        let mut controller = MotionScalarController::new();
914        let model = MotionModel::spring(MotionSpringSpec::layout(MotionPreference::Animated));
915
916        controller.start("pane", model, 0.0, 1.0, 0.0, Duration::ZERO);
917        let before = controller.sample_at(Duration::from_millis(80));
918        let sampled = before.track(&"pane").expect("pane track").sample();
919
920        controller.retarget("pane", model, 2.0, Duration::from_millis(80));
921        let after = controller.sample_at(Duration::from_millis(80));
922        let retargeted = after.track(&"pane").expect("pane track").sample();
923
924        assert_eq!(retargeted.value(), sampled.value());
925        assert_eq!(retargeted.velocity(), sampled.velocity());
926        assert_eq!(retargeted.target(), 2.0);
927        assert!(after.frame_demand().needs_frame());
928        assert_eq!(
929            after.frame_demand().reason(),
930            Some(MotionFrameReason::UpdateRender)
931        );
932    }
933
934    #[test]
935    fn cancelling_track_is_terminal_without_reaching_final_state() {
936        let mut track = MotionScalarTrack::start(
937            MotionModel::timeline(MotionSpec::new(
938                MotionPreference::Animated,
939                MotionDuration::Custom(Duration::from_millis(200)),
940                MotionEasing::Linear,
941            )),
942            0.0,
943            1.0,
944            0.0,
945            Duration::ZERO,
946        );
947
948        track.cancel_at(Duration::from_millis(40));
949        let sample = track.sample_at(Duration::from_millis(100));
950
951        assert_eq!(sample.state(), MotionRunState::Cancelled);
952        assert!(!sample.reached_final_state());
953        assert!(
954            !track
955                .frame_demand_at(Duration::from_millis(100))
956                .needs_frame()
957        );
958    }
959
960    #[test]
961    fn immediate_track_never_requests_a_frame() {
962        let track = MotionScalarTrack::immediate(0.75, Duration::from_millis(10));
963
964        let sample = track.sample_at(Duration::from_millis(10));
965        assert_eq!(sample.state(), MotionRunState::Immediate);
966        assert_eq!(sample.value(), 0.75);
967        assert!(
968            !track
969                .frame_demand_at(Duration::from_millis(10))
970                .needs_frame()
971        );
972    }
973
974    #[test]
975    fn execution_plan_downgrades_policy_failures_to_immediate_motion() {
976        let requested_model = MotionModel::timeline(MotionSpec::new(
977            MotionPreference::Animated,
978            MotionDuration::Custom(Duration::from_millis(900)),
979            MotionEasing::Linear,
980        ));
981        let plan = MotionExecutionPlan::resolve(
982            MotionPolicyInput::new(MotionPolicyContext::CommittedLayout, requested_model)
983                .with_spatial_motion(true)
984                .with_reduced_motion_final_state(true),
985        );
986
987        assert!(!plan.policy_report().is_ok());
988        assert!(plan.is_immediate());
989        assert!(plan.model().is_immediate());
990    }
991
992    #[test]
993    fn scalar_execution_reports_completion_and_frame_demand_from_one_sample() {
994        let execution = MotionScalarExecution::start_resolved(
995            MotionPolicyInput::new(
996                MotionPolicyContext::CommittedLayout,
997                MotionModel::timeline(MotionSpec::new(
998                    MotionPreference::Animated,
999                    MotionDuration::Custom(Duration::from_millis(100)),
1000                    MotionEasing::Linear,
1001                )),
1002            )
1003            .with_spatial_motion(true)
1004            .with_reduced_motion_final_state(true),
1005            0.0,
1006            1.0,
1007            0.0,
1008            Duration::ZERO,
1009        );
1010
1011        let midpoint = execution.sample_at(Duration::from_millis(50));
1012        assert_eq!(midpoint.value(), 0.5);
1013        assert!(!midpoint.complete());
1014        assert!(midpoint.frame_demand().needs_frame());
1015
1016        let complete = execution.sample_at(Duration::from_millis(120));
1017        assert_eq!(complete.value(), 1.0);
1018        assert!(complete.complete());
1019        assert!(!complete.frame_demand().needs_frame());
1020    }
1021
1022    #[test]
1023    fn reduced_motion_execution_publishes_final_value_without_frame_demand() {
1024        let execution = MotionScalarExecution::start_resolved(
1025            MotionPolicyInput::new(
1026                MotionPolicyContext::CommittedLayout,
1027                MotionModel::timeline(MotionSpec::committed_layout(MotionPreference::Reduced)),
1028            )
1029            .with_reduced_motion_final_state(true),
1030            0.0,
1031            1.0,
1032            0.0,
1033            Duration::ZERO,
1034        );
1035
1036        let sample = execution.sample_at(Duration::ZERO);
1037
1038        assert_eq!(sample.scalar_sample().state(), MotionRunState::Immediate);
1039        assert_eq!(sample.value(), 1.0);
1040        assert!(sample.complete());
1041        assert!(!sample.frame_demand().needs_frame());
1042    }
1043
1044    #[test]
1045    fn progress_execution_samples_normalized_lifecycle() {
1046        let started_at = Instant::now();
1047        let progress = MotionProgressExecution::start_resolved(
1048            MotionPolicyInput::new(
1049                MotionPolicyContext::CommittedLayout,
1050                MotionModel::timeline(MotionSpec::new(
1051                    MotionPreference::Animated,
1052                    MotionDuration::Custom(Duration::from_millis(100)),
1053                    MotionEasing::Linear,
1054                )),
1055            )
1056            .with_spatial_motion(true)
1057            .with_reduced_motion_final_state(true),
1058            started_at,
1059        );
1060
1061        let midpoint = progress.sample_since(started_at + Duration::from_millis(50));
1062        assert_eq!(midpoint.progress(), 0.5);
1063        assert!(midpoint.frame_demand().needs_frame());
1064
1065        let complete = progress.sample_at(Duration::from_millis(120));
1066        assert_eq!(complete.progress(), 1.0);
1067        assert!(complete.complete());
1068        assert!(!complete.frame_demand().needs_frame());
1069    }
1070}