Skip to main content

rgb_sequencer/
sequence.rs

1//! RGB color sequence definitions and evaluation.
2
3use crate::BLACK;
4use crate::time::TimeDuration;
5use crate::types::{LoopCount, SequenceError, SequenceStep, TransitionStyle};
6use heapless::Vec;
7use palette::{Mix, Srgb};
8
9/// Applies easing curve to linear progress value (0.0 to 1.0).
10///
11/// Uses quadratic easing for balance between visual smoothness and computational
12/// efficiency on embedded targets. More complex curves (cubic, sinusoidal) can be
13/// implemented via function-based sequences if needed.
14#[inline]
15fn apply_easing(t: f32, transition: TransitionStyle) -> f32 {
16    match transition {
17        TransitionStyle::Step => t,
18        TransitionStyle::Linear => t,
19        TransitionStyle::EaseIn => t * t,
20        TransitionStyle::EaseOut => t * (2.0 - t),
21        TransitionStyle::EaseInOut => {
22            if t < 0.5 {
23                2.0 * t * t
24            } else {
25                -1.0 + (4.0 - 2.0 * t) * t
26            }
27        }
28        TransitionStyle::EaseOutIn => {
29            if t < 0.5 {
30                // Fast start (EaseOut on first half)
31                let t2 = t * 2.0;
32                t2 * (2.0 - t2) * 0.5
33            } else {
34                // Fast end (EaseIn on second half)
35                let t2 = (t - 0.5) * 2.0;
36                0.5 + t2 * t2 * 0.5
37            }
38        }
39    }
40}
41
42/// Position within a sequence.
43#[derive(Debug, Clone, Copy)]
44pub struct StepPosition<D: TimeDuration> {
45    /// Current step index.
46    pub step_index: usize,
47    /// Elapsed time in current step.
48    pub time_in_step: D,
49    /// Time remaining until step ends.
50    pub time_until_step_end: D,
51    /// Whether sequence is complete.
52    pub is_complete: bool,
53    /// Current loop iteration.
54    pub current_loop: u32,
55}
56
57/// An RGB color sequence.
58#[derive(Debug, Clone)]
59pub struct RgbSequence<D: TimeDuration, const N: usize> {
60    steps: Vec<SequenceStep<D>, N>,
61    loop_count: LoopCount,
62    start_color: Option<Srgb>,
63    landing_color: Option<Srgb>,
64    loop_duration: D,
65
66    color_fn: Option<fn(Srgb, D) -> Srgb>,
67    timing_fn: Option<fn(D) -> Option<D>>,
68}
69
70impl<D: TimeDuration, const N: usize> RgbSequence<D, N> {
71    /// Creates a new sequence builder for step-based sequences.
72    pub fn builder() -> SequenceBuilder<D, N> {
73        SequenceBuilder::new()
74    }
75
76    /// Creates a function-based sequence for algorithmic animations.
77    ///
78    /// The `color_fn` receives base color and elapsed time, returning the current color.
79    /// The `timing_fn` returns next service delay (`Some(D::ZERO)` for continuous updates,
80    /// `Some(delay)` to wait, `None` when complete).
81    pub fn from_function(
82        base_color: Srgb,
83        color_fn: fn(Srgb, D) -> Srgb,
84        timing_fn: fn(D) -> Option<D>,
85    ) -> Self {
86        Self {
87            steps: Vec::new(),
88            loop_count: LoopCount::Finite(1),
89            landing_color: None,
90            loop_duration: D::ZERO,
91            start_color: Some(base_color),
92            color_fn: Some(color_fn),
93            timing_fn: Some(timing_fn),
94        }
95    }
96
97    /// Creates a simple solid color sequence with zero duration.
98    ///
99    /// Returns `SequenceError::CapacityExceeded` if `N < 1`.
100    pub fn solid(color: Srgb) -> Result<Self, SequenceError> {
101        Self::builder()
102            .step(color, D::ZERO, TransitionStyle::Step)?
103            .build()
104    }
105
106    /// Evaluates color and next service time at elapsed time.
107    ///
108    /// Returns `(color, timing)` where timing is `Some(D::ZERO)` for continuous animation,
109    /// `Some(delay)` for static hold, or `None` when sequence completes.
110    #[inline]
111    pub fn evaluate(&self, elapsed: D) -> (Srgb, Option<D>) {
112        // Use custom functions if present
113        if let (Some(color_fn), Some(timing_fn)) = (self.color_fn, self.timing_fn) {
114            let base = self.start_color.unwrap_or(BLACK);
115            return (color_fn(base, elapsed), timing_fn(elapsed));
116        }
117
118        // Step-based evaluation - calculate position once
119        if let Some(position) = self.find_step_position(elapsed) {
120            let color = self.color_at_position(&position);
121            let timing = self.next_service_time_from_position(&position);
122            (color, timing)
123        } else {
124            // Empty sequence fallback (shouldn't happen after validation)
125            (BLACK, None)
126        }
127    }
128
129    /// Returns true if step-based finite sequence has completed all loops.
130    #[inline]
131    fn is_complete_step_based(&self, elapsed: D) -> bool {
132        match self.loop_count {
133            LoopCount::Finite(count) => {
134                let loop_millis = self.loop_duration.as_millis();
135                if loop_millis == 0 {
136                    elapsed.as_millis() > 0
137                } else {
138                    let total_duration = loop_millis * (count as u64);
139                    elapsed.as_millis() >= total_duration
140                }
141            }
142            LoopCount::Infinite => false,
143        }
144    }
145
146    /// Creates position for zero-duration sequences.
147    #[inline]
148    fn handle_zero_duration_sequence(&self, elapsed: D) -> StepPosition<D> {
149        let is_complete = elapsed.as_millis() > 0;
150        let step_index = if is_complete { self.steps.len() - 1 } else { 0 };
151
152        StepPosition {
153            step_index,
154            time_in_step: D::ZERO,
155            time_until_step_end: D::ZERO,
156            is_complete,
157            current_loop: 0,
158        }
159    }
160
161    /// Creates position representing sequence completion.
162    #[inline]
163    fn create_complete_position(&self) -> StepPosition<D> {
164        let last_index = self.steps.len() - 1;
165        let loop_count = match self.loop_count {
166            LoopCount::Finite(count) => count,
167            LoopCount::Infinite => 0,
168        };
169
170        StepPosition {
171            step_index: last_index,
172            time_in_step: self.steps[last_index].duration,
173            time_until_step_end: D::ZERO,
174            is_complete: true,
175            current_loop: loop_count.saturating_sub(1),
176        }
177    }
178
179    /// Finds the step position at a specific time within a loop.
180    #[inline]
181    fn find_step_at_time(&self, time_in_loop: D, current_loop: u32) -> StepPosition<D> {
182        let mut accumulated_time = D::ZERO;
183
184        for (step_idx, step) in self.steps.iter().enumerate() {
185            let step_end_time =
186                D::from_millis(accumulated_time.as_millis() + step.duration.as_millis());
187
188            if time_in_loop.as_millis() < step_end_time.as_millis() {
189                let time_in_step =
190                    D::from_millis(time_in_loop.as_millis() - accumulated_time.as_millis());
191                let time_until_end = step_end_time.saturating_sub(time_in_loop);
192
193                return StepPosition {
194                    step_index: step_idx,
195                    time_in_step,
196                    time_until_step_end: time_until_end,
197                    is_complete: false,
198                    current_loop,
199                };
200            }
201
202            accumulated_time = step_end_time;
203        }
204
205        let last_index = self.steps.len() - 1;
206        StepPosition {
207            step_index: last_index,
208            time_in_step: self.steps[last_index].duration,
209            time_until_step_end: D::ZERO,
210            is_complete: false,
211            current_loop,
212        }
213    }
214
215    /// Interpolates color at current position with easing applied.
216    ///
217    /// Uses linear sRGB interpolation for computational efficiency (3 multiplies + 3 adds).
218    /// While not perceptually uniform (e.g., red→green may appear darker at midpoint), this
219    /// avoids expensive gamma correction or LAB color space conversions, making it suitable
220    /// for embedded targets with FPU.
221    #[inline]
222    fn interpolate_color(&self, position: &StepPosition<D>, step: &SequenceStep<D>) -> Srgb {
223        // Determine if this transition should use start_color for first step of first loop
224        let use_start_color = position.step_index == 0
225            && position.current_loop == 0
226            && self.start_color.is_some()
227            && matches!(
228                step.transition,
229                TransitionStyle::Linear
230                    | TransitionStyle::EaseIn
231                    | TransitionStyle::EaseOut
232                    | TransitionStyle::EaseInOut
233                    | TransitionStyle::EaseOutIn
234            );
235
236        let previous_color = if use_start_color {
237            self.start_color.unwrap()
238        } else if position.step_index == 0 {
239            self.steps.last().unwrap().color
240        } else {
241            self.steps[position.step_index - 1].color
242        };
243
244        let duration_millis = step.duration.as_millis();
245        if duration_millis == 0 {
246            return step.color;
247        }
248
249        let time_millis = position.time_in_step.as_millis();
250        let mut progress = (time_millis as f32) / (duration_millis as f32);
251        progress = progress.clamp(0.0, 1.0);
252
253        // Apply easing function
254        progress = apply_easing(progress, step.transition);
255
256        previous_color.mix(step.color, progress)
257    }
258
259    /// Returns the current position within the sequence at the given elapsed time.
260    ///
261    /// Includes step index, loop number, and timing information within the current step.
262    /// Returns `None` if the sequence is empty or function-based.
263    pub fn find_step_position(&self, elapsed: D) -> Option<StepPosition<D>> {
264        if self.steps.is_empty() {
265            return None;
266        }
267
268        let loop_millis = self.loop_duration.as_millis();
269
270        if loop_millis == 0 {
271            return Some(self.handle_zero_duration_sequence(elapsed));
272        }
273
274        if self.is_complete_step_based(elapsed) {
275            return Some(self.create_complete_position());
276        }
277
278        let elapsed_millis = elapsed.as_millis();
279        // Use modulo for O(1) loop position calculation without tracking iteration state
280        let current_loop = (elapsed_millis / loop_millis) as u32;
281        let time_in_loop = D::from_millis(elapsed_millis % loop_millis);
282
283        Some(self.find_step_at_time(time_in_loop, current_loop))
284    }
285
286    /// Returns the color at the given position.
287    #[inline]
288    fn color_at_position(&self, position: &StepPosition<D>) -> Srgb {
289        if position.is_complete {
290            return self
291                .landing_color
292                .unwrap_or(self.steps.last().unwrap().color);
293        }
294
295        let step = &self.steps[position.step_index];
296
297        match step.transition {
298            TransitionStyle::Step => step.color,
299            TransitionStyle::Linear
300            | TransitionStyle::EaseIn
301            | TransitionStyle::EaseOut
302            | TransitionStyle::EaseInOut
303            | TransitionStyle::EaseOutIn => self.interpolate_color(position, step),
304        }
305    }
306
307    /// Returns next service delay based on position and transition type.
308    #[inline]
309    fn next_service_time_from_position(&self, position: &StepPosition<D>) -> Option<D> {
310        if position.is_complete {
311            return None;
312        }
313
314        let step = &self.steps[position.step_index];
315        match step.transition {
316            // Interpolating transitions need continuous updates
317            TransitionStyle::Linear
318            | TransitionStyle::EaseIn
319            | TransitionStyle::EaseOut
320            | TransitionStyle::EaseInOut
321            | TransitionStyle::EaseOutIn => Some(D::ZERO),
322            // Step transition can wait until the end
323            TransitionStyle::Step => Some(position.time_until_step_end),
324        }
325    }
326
327    /// Returns true if sequence has completed.
328    #[inline]
329    pub fn has_completed(&self, elapsed: D) -> bool {
330        if let Some(timing_fn) = self.timing_fn {
331            timing_fn(elapsed).is_none()
332        } else {
333            self.is_complete_step_based(elapsed)
334        }
335    }
336
337    /// Returns loop duration.
338    #[inline]
339    pub fn loop_duration(&self) -> D {
340        self.loop_duration
341    }
342
343    /// Returns step count.
344    #[inline]
345    pub fn step_count(&self) -> usize {
346        self.steps.len()
347    }
348
349    /// Returns loop count.
350    #[inline]
351    pub fn loop_count(&self) -> LoopCount {
352        self.loop_count
353    }
354
355    /// Returns landing color.
356    #[inline]
357    pub fn landing_color(&self) -> Option<Srgb> {
358        self.landing_color
359    }
360
361    /// Returns start color.
362    #[inline]
363    pub fn start_color(&self) -> Option<Srgb> {
364        self.start_color
365    }
366
367    /// Returns step at index.
368    #[inline]
369    pub fn get_step(&self, index: usize) -> Option<&SequenceStep<D>> {
370        self.steps.get(index)
371    }
372
373    /// Returns true if function-based.
374    #[inline]
375    pub fn is_function_based(&self) -> bool {
376        self.color_fn.is_some()
377    }
378}
379
380/// Builder for RGB sequences.
381#[derive(Debug)]
382pub struct SequenceBuilder<D: TimeDuration, const N: usize> {
383    steps: Vec<SequenceStep<D>, N>,
384    loop_count: LoopCount,
385    landing_color: Option<Srgb>,
386    start_color: Option<Srgb>,
387}
388
389impl<D: TimeDuration, const N: usize> SequenceBuilder<D, N> {
390    /// Creates a new sequence builder.
391    pub fn new() -> Self {
392        Self {
393            steps: Vec::new(),
394            loop_count: LoopCount::default(),
395            landing_color: None,
396            start_color: None,
397        }
398    }
399
400    /// Adds a step to the sequence.
401    ///
402    /// Panics if capacity `N` is exceeded.
403    pub fn step(
404        mut self,
405        color: Srgb,
406        duration: D,
407        transition: TransitionStyle,
408    ) -> Result<Self, SequenceError> {
409        self.steps
410            .push(SequenceStep::new(color, duration, transition))
411            .map_err(|_| SequenceError::CapacityExceeded)?;
412        Ok(self)
413    }
414
415    /// Sets loop count (default: `Finite(1)`).
416    pub fn loop_count(mut self, count: LoopCount) -> Self {
417        self.loop_count = count;
418        self
419    }
420
421    /// Sets landing color shown after sequence completes (finite sequences only).
422    pub fn landing_color(mut self, color: Srgb) -> Self {
423        self.landing_color = Some(color);
424        self
425    }
426
427    /// Sets start color for smooth entry into first step (first loop only, Linear transitions only).
428    pub fn start_color(mut self, color: Srgb) -> Self {
429        self.start_color = Some(color);
430        self
431    }
432
433    /// Builds and validates sequence.
434    ///
435    /// Returns error if:
436    /// - Sequence is empty
437    /// - Has zero-duration steps with TransitionStyle != Step
438    /// - Has start_color and first step is Step transition
439    /// - Has landing_color with infinite loop
440    pub fn build(self) -> Result<RgbSequence<D, N>, SequenceError> {
441        if self.steps.is_empty() {
442            return Err(SequenceError::EmptySequence);
443        }
444
445        for step in &self.steps {
446            if step.duration.as_millis() == 0
447                && matches!(
448                    step.transition,
449                    TransitionStyle::Linear
450                        | TransitionStyle::EaseIn
451                        | TransitionStyle::EaseOut
452                        | TransitionStyle::EaseInOut
453                        | TransitionStyle::EaseOutIn
454                )
455            {
456                return Err(SequenceError::ZeroDurationWithInterpolation);
457            }
458        }
459
460        // Validate start_color is only set when first sequence step has TransitionStyle != Step
461        if self.start_color.is_some()
462            && let Some(first_step) = self.steps.first()
463            && matches!(first_step.transition, TransitionStyle::Step)
464        {
465            return Err(SequenceError::StartColorWithStepTransition);
466        }
467
468        // Validate landing_color is only set with finite loop count
469        if self.landing_color.is_some() && matches!(self.loop_count, LoopCount::Infinite) {
470            return Err(SequenceError::LandingColorWithInfiniteLoop);
471        }
472
473        // Calculate and cache loop duration here to avoid repeated calculation during operation
474        let total_millis: u64 = self.steps.iter().map(|s| s.duration.as_millis()).sum();
475        let loop_duration = D::from_millis(total_millis);
476
477        Ok(RgbSequence {
478            steps: self.steps,
479            loop_count: self.loop_count,
480            landing_color: self.landing_color,
481            loop_duration,
482            start_color: self.start_color,
483            color_fn: None,
484            timing_fn: None,
485        })
486    }
487}
488
489impl<D: TimeDuration, const N: usize> Default for SequenceBuilder<D, N> {
490    /// Returns a new default sequence builder.
491    fn default() -> Self {
492        Self::new()
493    }
494}