screen_app/recp/
crossfade.rs1use std::time::Duration;
8
9pub const CROSSFADE_DURATION: Duration = Duration::from_millis(150);
11
12#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
14pub enum CrossfadeState {
15 #[default]
17 Steady,
18 InProgress {
21 progress: u8,
23 },
24 Settling,
26}
27
28impl CrossfadeState {
29 #[must_use]
33 pub fn begin(self) -> Self {
34 Self::InProgress { progress: 0 }
35 }
36
37 #[must_use]
40 pub fn tick(self, elapsed: Duration) -> Self {
41 match self {
42 Self::InProgress { progress } => {
43 let frac = elapsed.as_secs_f64() / CROSSFADE_DURATION.as_secs_f64();
44 #[allow(
45 clippy::cast_possible_truncation,
46 clippy::cast_sign_loss,
47 reason = "progress is bounded [0, 255]; cast can't overflow after clamp"
48 )]
49 let delta = (frac * 255.0).round() as i32;
50 let next = i32::from(progress).saturating_add(delta);
51 if next >= 255 {
52 Self::Settling
53 } else {
54 #[allow(
55 clippy::cast_possible_truncation,
56 clippy::cast_sign_loss,
57 reason = "next is bounded < 255 from the branch above"
58 )]
59 Self::InProgress {
60 progress: next as u8,
61 }
62 }
63 }
64 other => other,
66 }
67 }
68
69 #[must_use]
71 pub fn settled(self) -> Self {
72 match self {
73 Self::Settling => Self::Steady,
74 other => other,
75 }
76 }
77
78 #[must_use]
80 pub fn is_settling(self) -> bool {
81 matches!(self, Self::Settling)
82 }
83
84 #[must_use]
88 pub fn incoming_alpha(self) -> u8 {
89 match self {
90 Self::Steady => 0,
91 Self::InProgress { progress } => progress,
92 Self::Settling => 255,
93 }
94 }
95}
96
97#[cfg(test)]
98mod tests {
99 use super::*;
100
101 #[test]
102 fn default_is_steady() {
103 assert_eq!(CrossfadeState::default(), CrossfadeState::Steady);
104 }
105
106 #[test]
107 fn begin_starts_at_zero() {
108 let s = CrossfadeState::default().begin();
109 assert_eq!(s, CrossfadeState::InProgress { progress: 0 });
110 assert_eq!(s.incoming_alpha(), 0);
111 }
112
113 #[test]
114 fn tick_advances_proportionally() {
115 let s = CrossfadeState::default().begin();
116 let s = s.tick(CROSSFADE_DURATION / 2);
118 if let CrossfadeState::InProgress { progress } = s {
119 assert!((120..=135).contains(&progress), "progress = {progress}");
120 } else {
121 panic!("expected InProgress, got {s:?}");
122 }
123 }
124
125 #[test]
126 fn full_duration_reaches_settling() {
127 let s = CrossfadeState::default().begin();
128 let s = s.tick(CROSSFADE_DURATION);
129 assert!(s.is_settling());
130 assert_eq!(s.incoming_alpha(), 255);
131 }
132
133 #[test]
134 fn settled_returns_to_steady() {
135 let s = CrossfadeState::Settling.settled();
136 assert_eq!(s, CrossfadeState::Steady);
137 }
138
139 #[test]
140 fn third_camera_click_mid_crossfade_resets_progress() {
141 let s = CrossfadeState::InProgress { progress: 128 }.begin();
142 assert_eq!(s, CrossfadeState::InProgress { progress: 0 });
145 }
146}