1use core::time::Duration;
38
39#[derive(Clone, Copy, Debug, PartialEq, Eq)]
41pub enum DisplayKind {
42 Emissive,
45 Persistent,
48}
49
50#[derive(Clone, Copy, Debug, PartialEq, Eq)]
53pub struct AttentionConfig {
54 pub timeout: Duration,
56 pub dim_margin: Duration,
60}
61
62impl AttentionConfig {
63 pub const EMISSIVE: Self = Self {
65 timeout: Duration::from_secs(10),
66 dim_margin: Duration::from_secs(3),
67 };
68
69 pub const PERSISTENT: Self = Self {
75 timeout: Duration::from_secs(30),
76 dim_margin: Duration::ZERO,
77 };
78
79 fn dim_after(&self, kind: DisplayKind) -> Option<Duration> {
82 if kind != DisplayKind::Emissive
83 || self.dim_margin.is_zero()
84 || self.dim_margin >= self.timeout
85 {
86 return None;
87 }
88 Some(self.timeout - self.dim_margin)
89 }
90}
91
92#[derive(Clone, Copy, Debug, PartialEq, Eq)]
94pub enum HoldReason {
95 Pairing,
98 Alert,
100 Maintenance,
102 Shutdown,
104}
105
106impl HoldReason {
107 const fn bit(self) -> u8 {
108 1 << self as u8
109 }
110}
111
112#[derive(Clone, Copy, Debug, PartialEq, Eq)]
114pub enum DisplayState {
115 Active,
117 Dim,
119 Lapsed,
122}
123
124#[derive(Clone, Copy, Debug, PartialEq, Eq)]
126pub enum Transition {
127 Woke,
131 Dimmed,
133 Lapsed,
136}
137
138#[derive(Clone, Copy, Debug)]
144pub struct Attention {
145 kind: DisplayKind,
146 config: AttentionConfig,
147 state: DisplayState,
148 holds: u8,
149 since_ms: u64,
151}
152
153impl Attention {
154 pub fn new(kind: DisplayKind, config: AttentionConfig, now_ms: u64) -> Self {
156 Self {
157 kind,
158 config,
159 state: DisplayState::Active,
160 holds: 0,
161 since_ms: now_ms,
162 }
163 }
164
165 pub fn kind(&self) -> DisplayKind {
166 self.kind
167 }
168
169 pub fn state(&self) -> DisplayState {
170 self.state
171 }
172
173 pub fn config(&self) -> AttentionConfig {
174 self.config
175 }
176
177 pub fn set_config(&mut self, config: AttentionConfig) {
182 self.config = config;
183 }
184
185 pub fn is_lapsed(&self) -> bool {
187 matches!(self.state, DisplayState::Lapsed)
188 }
189
190 pub fn accepts_redraw(&self) -> bool {
198 self.kind == DisplayKind::Persistent || !self.is_lapsed()
199 }
200
201 pub fn held(&self) -> bool {
203 self.holds != 0
204 }
205
206 pub fn wake(&mut self, now_ms: u64) -> Option<Transition> {
213 self.since_ms = now_ms;
214 if matches!(self.state, DisplayState::Active) {
215 return None;
216 }
217 self.state = DisplayState::Active;
218 Some(Transition::Woke)
219 }
220
221 pub fn set_hold(
228 &mut self,
229 reason: HoldReason,
230 active: bool,
231 now_ms: u64,
232 ) -> Option<Transition> {
233 let before = self.holds;
234 if active {
235 self.holds |= reason.bit();
236 } else {
237 self.holds &= !reason.bit();
238 }
239 if self.holds == before {
240 return None;
241 }
242 if active {
243 return self.wake(now_ms);
244 }
245 if self.holds == 0 {
246 self.since_ms = now_ms;
247 }
248 None
249 }
250
251 pub fn poll(&mut self, now_ms: u64) -> Option<Transition> {
253 if self.held() || self.is_lapsed() {
254 return None;
255 }
256 let idle = Duration::from_millis(now_ms.saturating_sub(self.since_ms));
257 if idle >= self.config.timeout {
258 self.state = DisplayState::Lapsed;
259 return Some(Transition::Lapsed);
260 }
261 if matches!(self.state, DisplayState::Active)
262 && let Some(dim_after) = self.config.dim_after(self.kind)
263 && idle >= dim_after
264 {
265 self.state = DisplayState::Dim;
266 return Some(Transition::Dimmed);
267 }
268 None
269 }
270
271 pub fn next_deadline(&self) -> Option<u64> {
274 if self.held() || self.is_lapsed() {
275 return None;
276 }
277 let after = match self.state {
278 DisplayState::Active => self
279 .config
280 .dim_after(self.kind)
281 .unwrap_or(self.config.timeout),
282 DisplayState::Dim => self.config.timeout,
283 DisplayState::Lapsed => return None,
284 };
285 Some(self.since_ms.saturating_add(after.as_millis() as u64))
286 }
287}
288
289#[cfg(test)]
290mod tests {
291 use super::*;
292
293 fn oled() -> Attention {
294 Attention::new(DisplayKind::Emissive, AttentionConfig::EMISSIVE, 0)
295 }
296
297 fn epaper() -> Attention {
298 Attention::new(DisplayKind::Persistent, AttentionConfig::PERSISTENT, 0)
299 }
300
301 #[test]
302 fn emissive_dims_then_lapses() {
303 let mut a = oled();
304 assert_eq!(a.next_deadline(), Some(7_000));
305 assert_eq!(a.poll(6_999), None);
306 assert_eq!(a.poll(7_000), Some(Transition::Dimmed));
307 assert_eq!(a.state(), DisplayState::Dim);
308 assert_eq!(a.next_deadline(), Some(10_000));
309 assert_eq!(a.poll(9_999), None);
310 assert_eq!(a.poll(10_000), Some(Transition::Lapsed));
311 assert_eq!(a.state(), DisplayState::Lapsed);
312 assert_eq!(a.next_deadline(), None);
313 }
314
315 #[test]
316 fn lapse_fires_once() {
317 let mut a = oled();
318 a.poll(7_000);
319 assert_eq!(a.poll(10_000), Some(Transition::Lapsed));
320 assert_eq!(a.poll(20_000), None);
321 }
322
323 #[test]
324 fn persistent_lapses_without_dimming() {
325 let mut a = epaper();
326 assert_eq!(a.next_deadline(), Some(30_000));
327 assert_eq!(a.poll(29_999), None);
328 assert_eq!(a.poll(30_000), Some(Transition::Lapsed));
329 assert_eq!(a.state(), DisplayState::Lapsed);
330 }
331
332 #[test]
333 fn persistent_always_accepts_redraw() {
334 let mut a = epaper();
335 assert!(a.accepts_redraw());
336 a.poll(30_000);
337 assert!(a.is_lapsed());
338 assert!(a.accepts_redraw());
339 }
340
341 #[test]
342 fn dark_emissive_panel_refuses_redraw() {
343 let mut a = oled();
344 assert!(a.accepts_redraw());
345 a.poll(7_000);
346 assert!(a.accepts_redraw());
348 a.poll(10_000);
349 assert!(!a.accepts_redraw());
350 }
351
352 #[test]
353 fn wake_from_lapsed_reports_the_transition() {
354 let mut a = oled();
355 a.poll(10_000);
356 assert_eq!(a.wake(12_000), Some(Transition::Woke));
357 assert_eq!(a.state(), DisplayState::Active);
358 assert_eq!(a.next_deadline(), Some(19_000));
360 }
361
362 #[test]
363 fn wake_from_dim_restores_full_brightness() {
364 let mut a = oled();
365 a.poll(7_000);
366 assert_eq!(a.state(), DisplayState::Dim);
367 assert_eq!(a.wake(8_000), Some(Transition::Woke));
368 assert_eq!(a.state(), DisplayState::Active);
369 }
370
371 #[test]
372 fn wake_while_active_only_defers_the_deadline() {
373 let mut a = oled();
374 assert_eq!(a.wake(5_000), None);
375 assert_eq!(a.next_deadline(), Some(12_000));
376 assert_eq!(a.poll(10_000), None);
377 }
378
379 #[test]
380 fn a_hold_pins_the_display_awake() {
381 let mut a = oled();
382 a.set_hold(HoldReason::Pairing, true, 1_000);
383 assert_eq!(a.next_deadline(), None);
384 assert_eq!(a.poll(60_000), None);
385 assert_eq!(a.state(), DisplayState::Active);
386 }
387
388 #[test]
389 fn asserting_a_hold_wakes_a_lapsed_panel() {
390 let mut a = oled();
391 a.poll(10_000);
392 assert!(a.is_lapsed());
393 assert_eq!(
394 a.set_hold(HoldReason::Alert, true, 11_000),
395 Some(Transition::Woke)
396 );
397 assert_eq!(a.state(), DisplayState::Active);
398 }
399
400 #[test]
401 fn releasing_the_last_hold_restarts_the_full_timeout() {
402 let mut a = oled();
403 a.set_hold(HoldReason::Pairing, true, 1_000);
404 assert_eq!(a.set_hold(HoldReason::Pairing, false, 60_000), None);
405 assert_eq!(a.next_deadline(), Some(67_000));
406 assert_eq!(a.poll(66_000), None);
407 assert_eq!(a.poll(67_000), Some(Transition::Dimmed));
408 }
409
410 #[test]
411 fn overlapping_holds_release_independently() {
412 let mut a = oled();
413 a.set_hold(HoldReason::Pairing, true, 1_000);
414 a.set_hold(HoldReason::Alert, true, 2_000);
415 a.set_hold(HoldReason::Pairing, false, 3_000);
416 assert!(a.held());
417 assert_eq!(a.poll(60_000), None);
418 a.set_hold(HoldReason::Alert, false, 4_000);
419 assert!(!a.held());
420 assert_eq!(a.next_deadline(), Some(11_000));
421 }
422
423 #[test]
424 fn redundant_hold_changes_do_not_move_the_clock() {
425 let mut a = oled();
426 a.set_hold(HoldReason::Pairing, true, 1_000);
427 a.set_hold(HoldReason::Pairing, true, 5_000);
428 a.set_hold(HoldReason::Pairing, false, 6_000);
429 assert_eq!(a.next_deadline(), Some(13_000));
431 }
432
433 #[test]
434 fn releasing_a_hold_that_was_never_held_is_inert() {
435 let mut a = oled();
436 assert_eq!(a.set_hold(HoldReason::Maintenance, false, 5_000), None);
437 assert_eq!(a.next_deadline(), Some(7_000));
438 }
439
440 #[test]
441 fn zero_dim_margin_lapses_without_a_dim_state() {
442 let config = AttentionConfig {
443 timeout: Duration::from_secs(10),
444 dim_margin: Duration::ZERO,
445 };
446 let mut a = Attention::new(DisplayKind::Emissive, config, 0);
447 assert_eq!(a.next_deadline(), Some(10_000));
448 assert_eq!(a.poll(9_999), None);
449 assert_eq!(a.poll(10_000), Some(Transition::Lapsed));
450 }
451
452 #[test]
453 fn dim_margin_at_or_over_the_timeout_disables_dimming() {
454 let config = AttentionConfig {
455 timeout: Duration::from_secs(10),
456 dim_margin: Duration::from_secs(10),
457 };
458 let mut a = Attention::new(DisplayKind::Emissive, config, 0);
459 assert_eq!(a.next_deadline(), Some(10_000));
460 assert_eq!(a.poll(10_000), Some(Transition::Lapsed));
461 }
462
463 #[test]
464 fn a_shorter_timeout_applies_from_the_existing_activity_mark() {
465 let mut a = oled();
466 a.set_config(AttentionConfig {
467 timeout: Duration::from_secs(5),
468 dim_margin: Duration::ZERO,
469 });
470 assert_eq!(a.next_deadline(), Some(5_000));
471 assert_eq!(a.poll(5_000), Some(Transition::Lapsed));
472 }
473
474 #[test]
475 fn a_timeout_already_exceeded_lapses_at_the_next_poll() {
476 let mut a = oled();
477 a.wake(100_000);
478 a.set_config(AttentionConfig {
479 timeout: Duration::from_secs(1),
480 dim_margin: Duration::ZERO,
481 });
482 assert_eq!(a.poll(101_500), Some(Transition::Lapsed));
483 }
484}