umsh_ux_display_tracker/
attention.rs

1//! Display-attention policy: when the device should stop assuming the
2//! user is looking at it.
3//!
4//! Both panel technologies lapse after a period of inactivity; what
5//! lapsing *does* differs, because their costs differ:
6//!
7//! - [`DisplayKind::Emissive`] (OLED) burns current for as long as it is
8//!   lit, so lapsing turns the panel off — falling smoothly into a dimmed
9//!   warning state first, so it reads as going to sleep rather than dying.
10//! - [`DisplayKind::Persistent`] (e-paper) costs nothing to keep
11//!   readable, so it stays visible. Lapsing instead collapses the menu
12//!   back to its home page.
13//!
14//! The shared part is the one that matters to the user: after a while
15//! away, the device forgets what you were in the middle of, and the next
16//! press starts from a page whose meaning is visible. Both kinds
17//! therefore send the menu home on [`Transition::Lapsed`] (see
18//! [`crate::menu::UiModel::go_home`]); only emissive panels also cut
19//! power.
20//!
21//! # Driving it
22//!
23//! 1. Call [`Attention::wake`] on every event that means "the user is
24//!    here or wants to be": a button press (on the press edge, not the
25//!    release), a BLE connection-state change, an opening pairing
26//!    window, an alert, a low-battery notice.
27//! 2. Call [`Attention::set_hold`] for conditions that must stay visible
28//!    for as long as they last, such as a pairing window showing a PIN.
29//! 3. Call [`Attention::poll`] when [`Attention::next_deadline`]
30//!    elapses, and act on any [`Transition`] it returns.
31//!
32//! Content changes that are *not* the user's doing — a battery sample, a
33//! bond count — must not call `wake`. Redraw them only while
34//! [`Attention::accepts_redraw`] is true, or a board that samples its
35//! battery on a timer will never let its panel sleep.
36
37use core::time::Duration;
38
39/// How often the fall into the dim state is stepped.
40///
41/// Twenty steps to a second, which on every panel in this class is one
42/// three-byte contrast write apiece and no framebuffer traffic at all.
43const RAMP_STEP_MS: u64 = 50;
44const RAMP_STEP: Duration = Duration::from_millis(RAMP_STEP_MS);
45
46/// How the board's panel behaves when it is not being looked at.
47#[derive(Clone, Copy, Debug, PartialEq, Eq)]
48pub enum DisplayKind {
49    /// Lit panel (OLED). Costs power while visible; can be dimmed and
50    /// switched off.
51    Emissive,
52    /// Bistable panel (e-paper). Readable at zero power; never switched
53    /// off while the device is awake.
54    Persistent,
55}
56
57/// Timing policy. Held by value so a board can adjust it at runtime —
58/// the plumbing a future `PROP_DISPLAY_TIMEOUT` needs.
59#[derive(Clone, Copy, Debug, PartialEq, Eq)]
60pub struct AttentionConfig {
61    /// Inactivity before attention lapses.
62    pub timeout: Duration,
63    /// How long before `timeout` an emissive panel dims as a warning.
64    /// Zero (or anything at least as large as `timeout`) disables the
65    /// dim state. Ignored for persistent panels.
66    pub dim_margin: Duration,
67    /// How long the fall from full brightness to the dim floor is drawn
68    /// out over. Zero drops in a single step, which is what a persistent
69    /// panel — and any board whose panel cannot be dimmed gradually —
70    /// wants.
71    pub dim_ramp: Duration,
72}
73
74impl AttentionConfig {
75    /// OLED default: off after 30 s, dimmed for the last 10 s of that,
76    /// falling into the dim over the first second of those ten.
77    ///
78    /// The numbers are what a panel a person is actually reading from
79    /// needs: twenty seconds is long enough to walk to a menu entry,
80    /// read it, and think about it, and ten dimmed seconds afterwards is
81    /// long enough that the warning is a warning rather than the last
82    /// moment before the screen is gone.
83    pub const EMISSIVE: Self = Self {
84        timeout: Duration::from_secs(30),
85        dim_margin: Duration::from_secs(10),
86        dim_ramp: Duration::from_secs(1),
87    };
88
89    /// E-paper default: menu returns home after 30 s. Longer than the
90    /// emissive timeout because nothing is being spent to keep the
91    /// screen readable — only stale menu context is at stake — and
92    /// because each partial refresh is visible enough that a twitchy
93    /// fallback would be an annoyance of its own.
94    pub const PERSISTENT: Self = Self {
95        timeout: Duration::from_secs(30),
96        dim_margin: Duration::ZERO,
97        dim_ramp: Duration::ZERO,
98    };
99
100    /// The instant, relative to the last activity, at which the panel
101    /// should dim. `None` when this config has no dim state.
102    fn dim_after(&self, kind: DisplayKind) -> Option<Duration> {
103        if kind != DisplayKind::Emissive
104            || self.dim_margin.is_zero()
105            || self.dim_margin >= self.timeout
106        {
107            return None;
108        }
109        Some(self.timeout - self.dim_margin)
110    }
111
112    /// How many [`RAMP_STEP`]s the fall into the dim state takes.
113    ///
114    /// Always at least one: a zero ramp is a single step change, which is
115    /// the whole of what a panel that cannot fade gradually needs.
116    fn ramp_steps(&self) -> u16 {
117        let steps = self.dim_ramp.as_millis().div_ceil(u128::from(RAMP_STEP_MS));
118        steps.clamp(1, u128::from(u16::MAX)) as u16
119    }
120}
121
122/// A condition that pins the display awake for as long as it holds.
123#[derive(Clone, Copy, Debug, PartialEq, Eq)]
124pub enum HoldReason {
125    /// A pairing window is open. Its PIN has to stay readable for the
126    /// whole window.
127    Pairing,
128    /// A locate alert is running — the display is part of being found.
129    Alert,
130    /// A guided maintenance or update flow is on screen.
131    Maintenance,
132    /// A shutdown or power-off confirmation is counting down.
133    Shutdown,
134}
135
136impl HoldReason {
137    const fn bit(self) -> u8 {
138        1 << self as u8
139    }
140}
141
142/// What the panel is currently doing.
143#[derive(Clone, Copy, Debug, PartialEq, Eq)]
144pub enum DisplayState {
145    /// Being looked at: lit at full brightness, menu navigable.
146    Active,
147    /// Emissive only: still lit, falling to or resting at the dim floor,
148    /// about to lapse. [`Attention::brightness_permille`] says which.
149    Dim,
150    /// Attention has lapsed. Emissive panels are off; persistent panels
151    /// are showing their home page.
152    Lapsed,
153}
154
155/// A state change the owning task has to act on.
156#[derive(Clone, Copy, Debug, PartialEq, Eq)]
157pub enum Transition {
158    /// Attention regained. Emissive: draw the fresh frame *first*, then
159    /// power the panel on (and restore full contrast if it was dimmed),
160    /// so the user never catches a stale frame. Persistent: just redraw.
161    ///
162    /// A wake snaps straight back to full brightness. Only the fall is
163    /// gradual — the user is waiting on the rise.
164    Woke,
165    /// Emissive only: apply [`Attention::brightness_permille`].
166    ///
167    /// One of these arrives per [`RAMP_STEP`] of the fall into the dim
168    /// state, not one per lapse, so a caller that treats it as a single
169    /// edge to a fixed contrast will draw a staircase of one step.
170    Dimming,
171    /// Attention lapsed. Send the menu home, then — emissive only —
172    /// power the panel off.
173    Lapsed,
174}
175
176/// Display-attention state machine.
177///
178/// Pure logic driven by a monotonic millisecond clock: no timers, no
179/// hardware. Every method that can change state takes `now_ms` so the
180/// whole thing is testable with synthetic time.
181#[derive(Clone, Copy, Debug)]
182pub struct Attention {
183    kind: DisplayKind,
184    config: AttentionConfig,
185    state: DisplayState,
186    holds: u8,
187    /// Timestamp the current inactivity window is measured from.
188    since_ms: u64,
189    /// How many steps of the fall into the dim state have been reported.
190    /// Zero outside [`DisplayState::Dim`].
191    ramp_step: u16,
192}
193
194impl Attention {
195    /// Start in [`DisplayState::Active`] — boot is itself a wake event.
196    pub fn new(kind: DisplayKind, config: AttentionConfig, now_ms: u64) -> Self {
197        Self {
198            kind,
199            config,
200            state: DisplayState::Active,
201            holds: 0,
202            since_ms: now_ms,
203            ramp_step: 0,
204        }
205    }
206
207    pub fn kind(&self) -> DisplayKind {
208        self.kind
209    }
210
211    pub fn state(&self) -> DisplayState {
212        self.state
213    }
214
215    pub fn config(&self) -> AttentionConfig {
216        self.config
217    }
218
219    /// Replace the timing policy. The new timeout is measured from the
220    /// existing activity mark, so shortening it below the time already
221    /// elapsed lapses at the next [`poll`](Self::poll) rather than
222    /// retroactively.
223    pub fn set_config(&mut self, config: AttentionConfig) {
224        self.config = config;
225    }
226
227    /// True once attention has lapsed.
228    pub fn is_lapsed(&self) -> bool {
229        matches!(self.state, DisplayState::Lapsed)
230    }
231
232    /// Whether the panel can show a redraw right now.
233    ///
234    /// False only for an emissive panel that has been powered off:
235    /// pushing pixels at a dark panel wastes bus traffic and, on a
236    /// board that samples its battery on a timer, would otherwise run
237    /// forever. Persistent panels always accept a redraw — that is how
238    /// their lapse is rendered.
239    pub fn accepts_redraw(&self) -> bool {
240        self.kind == DisplayKind::Persistent || !self.is_lapsed()
241    }
242
243    /// Whether any hold is currently pinning the display awake.
244    pub fn held(&self) -> bool {
245        self.holds != 0
246    }
247
248    /// How far the panel sits between its dim floor and full brightness,
249    /// in permille: 1000 while active, falling across
250    /// [`AttentionConfig::dim_ramp`] once the dim state begins, and 0
251    /// once it has settled there.
252    ///
253    /// Deliberately *not* an absolute brightness. Which two levels a
254    /// panel's floor and full are is the board's business — an SH1106's
255    /// dim contrast is an eighth of its normal one and an SSD1306's is
256    /// half — and a policy that named either would be wrong on the other.
257    /// All this says is where between them to sit.
258    pub fn brightness_permille(&self) -> u16 {
259        match self.state {
260            DisplayState::Active => 1_000,
261            DisplayState::Dim => {
262                let steps = self.config.ramp_steps();
263                let done = u32::from(self.ramp_step.min(steps));
264                (1_000 - 1_000 * done / u32::from(steps)) as u16
265            }
266            DisplayState::Lapsed => 0,
267        }
268    }
269
270    /// Register user-driven activity.
271    ///
272    /// Returns [`Transition::Woke`] when this actually brought the panel
273    /// back, so the caller can order its redraw and power-on correctly;
274    /// returns `None` when the display was already active and only the
275    /// inactivity timer moved.
276    pub fn wake(&mut self, now_ms: u64) -> Option<Transition> {
277        self.since_ms = now_ms;
278        if matches!(self.state, DisplayState::Active) {
279            return None;
280        }
281        self.state = DisplayState::Active;
282        self.ramp_step = 0;
283        Some(Transition::Woke)
284    }
285
286    /// Assert or release a hold.
287    ///
288    /// Asserting one also counts as activity, so an event like a pairing
289    /// window opening both wakes the panel and pins it. Releasing the
290    /// last hold restarts the inactivity window from that moment, so the
291    /// user gets a full timeout to read whatever the hold was showing.
292    pub fn set_hold(
293        &mut self,
294        reason: HoldReason,
295        active: bool,
296        now_ms: u64,
297    ) -> Option<Transition> {
298        let before = self.holds;
299        if active {
300            self.holds |= reason.bit();
301        } else {
302            self.holds &= !reason.bit();
303        }
304        if self.holds == before {
305            return None;
306        }
307        if active {
308            return self.wake(now_ms);
309        }
310        if self.holds == 0 {
311            self.since_ms = now_ms;
312        }
313        None
314    }
315
316    /// Advance time. Returns a transition when one becomes due.
317    pub fn poll(&mut self, now_ms: u64) -> Option<Transition> {
318        if self.held() || self.is_lapsed() {
319            return None;
320        }
321        let idle = Duration::from_millis(now_ms.saturating_sub(self.since_ms));
322        if idle >= self.config.timeout {
323            self.state = DisplayState::Lapsed;
324            return Some(Transition::Lapsed);
325        }
326        let dim_after = self.config.dim_after(self.kind)?;
327        if idle < dim_after {
328            return None;
329        }
330        // Which step the fall has reached is derived from the clock rather
331        // than counted, so a poll that arrives late — or several steps
332        // late — lands on the brightness the elapsed time asks for instead
333        // of walking there one call at a time.
334        let steps = self.config.ramp_steps();
335        let elapsed = (idle - dim_after).as_millis() as u64;
336        let due = ((elapsed / RAMP_STEP_MS) + 1).min(u64::from(steps)) as u16;
337        if matches!(self.state, DisplayState::Active) {
338            self.state = DisplayState::Dim;
339        } else if due <= self.ramp_step {
340            return None;
341        }
342        self.ramp_step = due;
343        Some(Transition::Dimming)
344    }
345
346    /// Absolute monotonic-millisecond deadline for the next
347    /// [`poll`](Self::poll), if any is pending.
348    pub fn next_deadline(&self) -> Option<u64> {
349        if self.held() || self.is_lapsed() {
350            return None;
351        }
352        let lapse_at = self
353            .since_ms
354            .saturating_add(self.config.timeout.as_millis() as u64);
355        let after = match self.state {
356            DisplayState::Active => self
357                .config
358                .dim_after(self.kind)
359                .unwrap_or(self.config.timeout),
360            // Still falling: the next step of the ramp. Once it has
361            // settled on the floor there is nothing left to do but lapse.
362            DisplayState::Dim => match self.config.dim_after(self.kind) {
363                Some(dim_after) if self.ramp_step < self.config.ramp_steps() => {
364                    dim_after + RAMP_STEP * u32::from(self.ramp_step)
365                }
366                _ => return Some(lapse_at),
367            },
368            DisplayState::Lapsed => return None,
369        };
370        Some(
371            self.since_ms
372                .saturating_add(after.as_millis() as u64)
373                .min(lapse_at),
374        )
375    }
376}
377
378#[cfg(test)]
379mod tests {
380    use super::*;
381
382    fn oled() -> Attention {
383        Attention::new(DisplayKind::Emissive, AttentionConfig::EMISSIVE, 0)
384    }
385
386    fn epaper() -> Attention {
387        Attention::new(DisplayKind::Persistent, AttentionConfig::PERSISTENT, 0)
388    }
389
390    /// Drive `a` the way a display task does: poll at each deadline up to
391    /// `until_ms`. Returns how many steps of the fall came back and the
392    /// first and last instant one did, asserting on the way through that
393    /// nothing but a ramp step arrived.
394    fn ramp(a: &mut Attention, until_ms: u64) -> (usize, u64, u64) {
395        let (mut count, mut first, mut last) = (0, 0, 0);
396        while let Some(deadline) = a.next_deadline() {
397            if deadline > until_ms {
398                break;
399            }
400            if let Some(transition) = a.poll(deadline) {
401                assert_eq!(transition, Transition::Dimming, "at {deadline}");
402                if count == 0 {
403                    first = deadline;
404                }
405                last = deadline;
406                count += 1;
407            }
408        }
409        (count, first, last)
410    }
411
412    #[test]
413    fn emissive_dims_then_lapses() {
414        let mut a = oled();
415        assert_eq!(a.next_deadline(), Some(20_000));
416        assert_eq!(a.poll(19_999), None);
417        assert_eq!(a.poll(20_000), Some(Transition::Dimming));
418        assert_eq!(a.state(), DisplayState::Dim);
419
420        // The ramp owns the next second; the lapse is the deadline after it.
421        ramp(&mut a, 29_999);
422        assert_eq!(a.brightness_permille(), 0);
423        assert_eq!(a.next_deadline(), Some(30_000));
424        assert_eq!(a.poll(29_999), None);
425        assert_eq!(a.poll(30_000), Some(Transition::Lapsed));
426        assert_eq!(a.state(), DisplayState::Lapsed);
427        assert_eq!(a.next_deadline(), None);
428    }
429
430    /// The whole point of the ramp: a fall the eye reads as a fade rather
431    /// than as the panel dropping a level.
432    #[test]
433    fn the_fall_into_the_dim_state_is_stepped() {
434        let mut a = oled();
435        let (count, first, last) = ramp(&mut a, 29_999);
436        assert_eq!(count, 20, "one second of 50 ms steps");
437        // First step at the dim instant, last one 950 ms later.
438        assert_eq!(first, 20_000);
439        assert_eq!(last, 20_950);
440    }
441
442    #[test]
443    fn brightness_falls_monotonically_to_the_floor() {
444        let mut a = oled();
445        assert_eq!(a.brightness_permille(), 1_000);
446        let mut previous = 1_000;
447        while let Some(deadline) = a.next_deadline() {
448            if deadline >= 30_000 {
449                break;
450            }
451            a.poll(deadline);
452            let now = a.brightness_permille();
453            assert!(now < previous, "{now} is not below {previous}");
454            previous = now;
455        }
456        assert_eq!(previous, 0);
457        a.poll(30_000);
458        assert_eq!(a.brightness_permille(), 0);
459    }
460
461    /// A task that misses several steps — a busy radio, a long flush —
462    /// lands on the brightness the clock asks for rather than walking
463    /// there one poll at a time.
464    #[test]
465    fn a_late_poll_catches_up_in_one_step() {
466        let mut a = oled();
467        assert_eq!(a.poll(20_500), Some(Transition::Dimming));
468        assert_eq!(a.brightness_permille(), 450);
469        assert_eq!(a.poll(20_950), Some(Transition::Dimming));
470        assert_eq!(a.brightness_permille(), 0);
471    }
472
473    #[test]
474    fn the_ramp_stops_at_the_floor() {
475        let mut a = oled();
476        ramp(&mut a, 29_999);
477        assert_eq!(a.poll(25_000), None);
478        assert_eq!(a.brightness_permille(), 0);
479    }
480
481    #[test]
482    fn a_wake_mid_ramp_returns_to_full_brightness() {
483        let mut a = oled();
484        a.poll(20_200);
485        assert_eq!(a.state(), DisplayState::Dim);
486        assert!(a.brightness_permille() < 1_000);
487        assert_eq!(a.wake(20_300), Some(Transition::Woke));
488        assert_eq!(a.brightness_permille(), 1_000);
489        assert_eq!(a.next_deadline(), Some(40_300));
490    }
491
492    #[test]
493    fn a_panel_that_cannot_fade_drops_in_one_step() {
494        let config = AttentionConfig {
495            dim_ramp: Duration::ZERO,
496            ..AttentionConfig::EMISSIVE
497        };
498        let mut a = Attention::new(DisplayKind::Emissive, config, 0);
499        assert_eq!(a.poll(20_000), Some(Transition::Dimming));
500        assert_eq!(a.brightness_permille(), 0);
501        assert_eq!(a.next_deadline(), Some(30_000));
502        assert_eq!(a.poll(25_000), None);
503    }
504
505    #[test]
506    fn no_deadline_ever_overshoots_the_lapse() {
507        // A ramp longer than the margin it has to fit inside.
508        let config = AttentionConfig {
509            timeout: Duration::from_secs(10),
510            dim_margin: Duration::from_secs(1),
511            dim_ramp: Duration::from_secs(5),
512        };
513        let mut a = Attention::new(DisplayKind::Emissive, config, 0);
514        while let Some(deadline) = a.next_deadline() {
515            assert!(deadline <= 10_000, "{deadline} is past the lapse");
516            if a.poll(deadline) == Some(Transition::Lapsed) {
517                break;
518            }
519        }
520        assert!(a.is_lapsed());
521    }
522
523    #[test]
524    fn lapse_fires_once() {
525        let mut a = oled();
526        a.poll(20_000);
527        assert_eq!(a.poll(30_000), Some(Transition::Lapsed));
528        assert_eq!(a.poll(40_000), None);
529    }
530
531    #[test]
532    fn persistent_lapses_without_dimming() {
533        let mut a = epaper();
534        assert_eq!(a.next_deadline(), Some(30_000));
535        assert_eq!(a.poll(29_999), None);
536        assert_eq!(a.poll(30_000), Some(Transition::Lapsed));
537        assert_eq!(a.state(), DisplayState::Lapsed);
538    }
539
540    #[test]
541    fn persistent_always_accepts_redraw() {
542        let mut a = epaper();
543        assert!(a.accepts_redraw());
544        a.poll(30_000);
545        assert!(a.is_lapsed());
546        assert!(a.accepts_redraw());
547    }
548
549    #[test]
550    fn dark_emissive_panel_refuses_redraw() {
551        let mut a = oled();
552        assert!(a.accepts_redraw());
553        a.poll(20_000);
554        // Dimmed is still visible.
555        assert!(a.accepts_redraw());
556        a.poll(30_000);
557        assert!(!a.accepts_redraw());
558    }
559
560    #[test]
561    fn wake_from_lapsed_reports_the_transition() {
562        let mut a = oled();
563        a.poll(30_000);
564        assert_eq!(a.wake(32_000), Some(Transition::Woke));
565        assert_eq!(a.state(), DisplayState::Active);
566        // Full timeout again, measured from the wake.
567        assert_eq!(a.next_deadline(), Some(52_000));
568    }
569
570    #[test]
571    fn wake_from_dim_restores_full_brightness() {
572        let mut a = oled();
573        a.poll(20_000);
574        assert_eq!(a.state(), DisplayState::Dim);
575        assert_eq!(a.wake(21_000), Some(Transition::Woke));
576        assert_eq!(a.state(), DisplayState::Active);
577        assert_eq!(a.brightness_permille(), 1_000);
578    }
579
580    #[test]
581    fn wake_while_active_only_defers_the_deadline() {
582        let mut a = oled();
583        assert_eq!(a.wake(5_000), None);
584        assert_eq!(a.next_deadline(), Some(25_000));
585        assert_eq!(a.poll(20_000), None);
586    }
587
588    #[test]
589    fn a_hold_pins_the_display_awake() {
590        let mut a = oled();
591        a.set_hold(HoldReason::Pairing, true, 1_000);
592        assert_eq!(a.next_deadline(), None);
593        assert_eq!(a.poll(60_000), None);
594        assert_eq!(a.state(), DisplayState::Active);
595    }
596
597    #[test]
598    fn asserting_a_hold_wakes_a_lapsed_panel() {
599        let mut a = oled();
600        a.poll(30_000);
601        assert!(a.is_lapsed());
602        assert_eq!(
603            a.set_hold(HoldReason::Alert, true, 31_000),
604            Some(Transition::Woke)
605        );
606        assert_eq!(a.state(), DisplayState::Active);
607    }
608
609    #[test]
610    fn releasing_the_last_hold_restarts_the_full_timeout() {
611        let mut a = oled();
612        a.set_hold(HoldReason::Pairing, true, 1_000);
613        assert_eq!(a.set_hold(HoldReason::Pairing, false, 60_000), None);
614        assert_eq!(a.next_deadline(), Some(80_000));
615        assert_eq!(a.poll(79_000), None);
616        assert_eq!(a.poll(80_000), Some(Transition::Dimming));
617    }
618
619    #[test]
620    fn overlapping_holds_release_independently() {
621        let mut a = oled();
622        a.set_hold(HoldReason::Pairing, true, 1_000);
623        a.set_hold(HoldReason::Alert, true, 2_000);
624        a.set_hold(HoldReason::Pairing, false, 3_000);
625        assert!(a.held());
626        assert_eq!(a.poll(60_000), None);
627        a.set_hold(HoldReason::Alert, false, 4_000);
628        assert!(!a.held());
629        assert_eq!(a.next_deadline(), Some(24_000));
630    }
631
632    #[test]
633    fn redundant_hold_changes_do_not_move_the_clock() {
634        let mut a = oled();
635        a.set_hold(HoldReason::Pairing, true, 1_000);
636        a.set_hold(HoldReason::Pairing, true, 5_000);
637        a.set_hold(HoldReason::Pairing, false, 6_000);
638        // Timer restarts from the real release, not the duplicate assert.
639        assert_eq!(a.next_deadline(), Some(26_000));
640    }
641
642    #[test]
643    fn releasing_a_hold_that_was_never_held_is_inert() {
644        let mut a = oled();
645        assert_eq!(a.set_hold(HoldReason::Maintenance, false, 5_000), None);
646        assert_eq!(a.next_deadline(), Some(20_000));
647    }
648
649    #[test]
650    fn zero_dim_margin_lapses_without_a_dim_state() {
651        let config = AttentionConfig {
652            timeout: Duration::from_secs(10),
653            dim_margin: Duration::ZERO,
654            ..AttentionConfig::EMISSIVE
655        };
656        let mut a = Attention::new(DisplayKind::Emissive, config, 0);
657        assert_eq!(a.next_deadline(), Some(10_000));
658        assert_eq!(a.poll(9_999), None);
659        assert_eq!(a.poll(10_000), Some(Transition::Lapsed));
660    }
661
662    #[test]
663    fn dim_margin_at_or_over_the_timeout_disables_dimming() {
664        let config = AttentionConfig {
665            timeout: Duration::from_secs(10),
666            dim_margin: Duration::from_secs(10),
667            ..AttentionConfig::EMISSIVE
668        };
669        let mut a = Attention::new(DisplayKind::Emissive, config, 0);
670        assert_eq!(a.next_deadline(), Some(10_000));
671        assert_eq!(a.poll(10_000), Some(Transition::Lapsed));
672    }
673
674    #[test]
675    fn a_shorter_timeout_applies_from_the_existing_activity_mark() {
676        let mut a = oled();
677        a.set_config(AttentionConfig {
678            timeout: Duration::from_secs(5),
679            dim_margin: Duration::ZERO,
680            ..AttentionConfig::EMISSIVE
681        });
682        assert_eq!(a.next_deadline(), Some(5_000));
683        assert_eq!(a.poll(5_000), Some(Transition::Lapsed));
684    }
685
686    #[test]
687    fn a_timeout_already_exceeded_lapses_at_the_next_poll() {
688        let mut a = oled();
689        a.wake(100_000);
690        a.set_config(AttentionConfig {
691            timeout: Duration::from_secs(1),
692            dim_margin: Duration::ZERO,
693            ..AttentionConfig::EMISSIVE
694        });
695        assert_eq!(a.poll(101_500), Some(Transition::Lapsed));
696    }
697}