denise-ui 0.10.1

Scene graph, widgets and compositor for Denise.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
//! Pictures shown one at a time, sliding between them.

use alloc::vec::Vec;

use denise::{ElementState, InputEvent, KeyCode, Point, Rect, Role, Size};
use denise_render::Canvas;

use crate::widget::{Animation, Event, EventCtx, Handled, PaintCtx, VisualState, Widget};
use crate::widgets::image::{Fit, Image};
use crate::widgets::style::{focus_ring, interactive_pair};

/// How long a slide takes. A quarter second reads as motion without making a
/// person wait for it.
const SLIDE_MS: u64 = 250;

/// Frame interval while sliding: 20 fps, [`Spinner`](super::Spinner)'s number
/// and reasoning — the cost on a Pi-class device is the wakes, not the draws.
const FRAME_MS: u64 = 50;

/// Dragging past this fraction of the width commits to the next page.
const COMMIT_DIVISOR: i32 = 4;

/// One whole page width, in the fixed-point fraction slides are measured in.
///
/// A slide's displacement is stored as a fraction of the width rather than in
/// pixels, because [`Widget::animate`] has no geometry: the advance clock
/// starts a slide without ever having seen the widget's rectangle, and a
/// fraction lets paint — which has the rectangle — do the multiply.
const WHOLE: i32 = 1024;

/// The indicator dots' radius, and the spacing between their centres.
const DOT: i32 = 4;
const DOT_GAP: i32 = 14;

/// What the carousel is doing between input events.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Motion {
    /// Showing the current page, waiting for input or the advance clock.
    Still,
    /// A finger holds the pages displaced by a fraction of the width,
    /// [`WHOLE`] being one page.
    Dragging { fraction: i32 },
    /// Sliding from `fraction` back to rest on the (already updated) current
    /// page, having started at `from_ms`.
    Sliding { fraction: i32, from_ms: u64 },
}

/// Pictures shown one at a time in one rectangle, sliding between them.
///
/// ```ignore
/// enum Message { Page(usize) }
/// Carousel::new(Message::Page)
///     .with_picture(sunset, Size::new(640, 480))
///     .with_picture(harbour, Size::new(640, 480))
///     .auto_advance(8_000)
/// ```
///
/// The signage rotator: swipe or drag changes the page, arrow keys change it
/// from the keyboard, and [`auto_advance`](Carousel::auto_advance) turns it
/// into the idle-screen photo loop. Pages are *pictures* — the premultiplied
/// buffers [`Image`] takes, each with its own [`Fit`] — because a carousel of
/// arbitrary widgets would need this widget to own nodes, and `EventCtx`
/// deliberately cannot. A carousel of mixed content is composed the other way
/// round: [`Tabs`](super::Tabs) without visible tabs, node visibility swapped
/// on the message.
///
/// # It wraps
///
/// A rotator is a cycle by definition — the advance clock has to come round —
/// so the keyboard wraps too: [`RadioGroup`](super::RadioGroup)'s convention,
/// not [`List`](super::List)'s.
///
/// # What it costs
///
/// Idle with no advance clock: nothing. Holding on a page with one: **one
/// wake per interval**, the toast arrangement — [`animate`](Widget::animate)
/// answers the deadline and nothing repaints until it fires. Frames are spent
/// only during the quarter-second slide. Like [`Spinner`](super::Spinner) it
/// does not start itself: the advance clock runs once the application calls
/// [`Ui::request_animation`](crate::Ui::request_animation), so nothing
/// rotates merely because a screen exists.
///
/// # The settle message
///
/// `fn(usize) -> M` is emitted when a *person* lands the carousel on a page —
/// a committed swipe, an arrow key — and once per arrival, not per frame. A
/// drag that springs back emits nothing, and neither does the advance clock:
/// the clock is the machine talking to itself, and a message reports what a
/// person did — the rule every silent setter in this crate follows. An
/// application that needs the shown page reads [`current`](Carousel::current).
#[derive(Clone, Debug)]
pub struct Carousel<M> {
    pages: Vec<Image>,
    current: usize,
    motion: Motion,
    /// Where a drag started, and the width it is measured against.
    grip: Option<(Point, i32)>,
    /// The advance interval, if the application asked for one.
    advance_ms: Option<u64>,
    /// When the current hold began, against `tick`'s clock.
    held_since: u64,
    message: Option<fn(usize) -> M>,
    role: Role,
}

impl<M> Carousel<M> {
    /// An empty carousel, reporting arrivals through `message`.
    pub fn new(message: fn(usize) -> M) -> Self {
        Self {
            pages: Vec::new(),
            current: 0,
            motion: Motion::Still,
            grip: None,
            advance_ms: None,
            held_since: 0,
            message: Some(message),
            role: Role::Primary,
        }
    }

    /// A carousel that emits nothing — the pure signage case, where nobody is
    /// listening and the pictures simply rotate.
    pub fn inert() -> Self {
        Self {
            pages: Vec::new(),
            current: 0,
            motion: Motion::Still,
            grip: None,
            advance_ms: None,
            held_since: 0,
            message: None,
            role: Role::Primary,
        }
    }

    /// Adds a page: premultiplied `0xAARRGGBB` pixels, [`Image`]'s contract,
    /// shown with [`Fit::Cover`].
    pub fn with_picture(mut self, pixels: Vec<u32>, size: Size) -> Self {
        self.pages
            .push(Image::new(pixels, size).with_fit(Fit::Cover));
        self
    }

    /// Adds a page with its own [`Fit`].
    pub fn with_picture_fit(mut self, pixels: Vec<u32>, size: Size, fit: Fit) -> Self {
        self.pages.push(Image::new(pixels, size).with_fit(fit));
        self
    }

    /// Advances to the next page every `interval_ms` — once the application
    /// starts the clock with
    /// [`Ui::request_animation`](crate::Ui::request_animation).
    ///
    /// Floored at twice the slide, because an interval the slide cannot keep
    /// up with is a carousel that never rests.
    pub fn auto_advance(mut self, interval_ms: u64) -> Self {
        self.advance_ms = Some(interval_ms.max(SLIDE_MS * 2));
        self
    }

    /// Sets the colour role of the current page's dot and the focus ring.
    pub fn with_role(mut self, role: Role) -> Self {
        self.role = role;
        self
    }

    /// The page currently shown, or arriving.
    #[inline]
    pub const fn current(&self) -> usize {
        self.current
    }

    /// How many pages there are.
    #[inline]
    pub fn page_count(&self) -> usize {
        self.pages.len()
    }

    /// Shows a page immediately, without sliding and without emitting — the
    /// application writing state, like every setter here. Out of range does
    /// nothing.
    pub fn set_current(&mut self, index: usize) {
        if index < self.pages.len() {
            self.current = index;
            self.motion = Motion::Still;
        }
    }

    /// Appends a page after construction, reporting its index.
    pub fn push_picture(&mut self, pixels: Vec<u32>, size: Size) -> usize {
        self.pages
            .push(Image::new(pixels, size).with_fit(Fit::Cover));
        self.pages.len() - 1
    }

    /// The page `steps` away, wrapping — a rotator is a cycle.
    fn neighbour(&self, steps: i32) -> usize {
        let count = self.pages.len().max(1) as i32;
        (self.current as i32 + steps).rem_euclid(count) as usize
    }

    /// The current displacement as a fraction of the width, [`WHOLE`] being
    /// one page.
    fn fraction_at(&self, now_ms: u64) -> i32 {
        match self.motion {
            Motion::Still => 0,
            Motion::Dragging { fraction } => fraction,
            Motion::Sliding { fraction, from_ms } => {
                slide_fraction(fraction, now_ms.saturating_sub(from_ms))
            }
        }
    }

    /// Lands on `target` and slides in from `fraction`, emitting the arrival.
    ///
    /// The current page is updated *now* and the slide runs from a
    /// displacement back to rest — which is what makes an interrupted slide
    /// land somewhere honest rather than between pages.
    /// The advance clock is *not* reset here, and deliberately: every slide
    /// ends in [`Widget::animate`]'s landing, which restarts the hold from the
    /// moment of arrival. An earlier version reset it here too, and a mutation
    /// removing that changed nothing observable — the landing was already
    /// doing the work — so it came out, the `label_box` rule.
    fn arrive(&mut self, target: usize, fraction: i32, ctx: &mut EventCtx<'_, M>) {
        self.current = target;
        self.motion = Motion::Sliding {
            fraction,
            from_ms: ctx.now_ms,
        };
        if let Some(message) = self.message {
            ctx.emit(message(target));
        }
        ctx.request_animation();
    }
}

/// Where a slide that started displaced by `fraction` has got to, `elapsed`
/// in. Linear: on the panels this ships to, a quarter-second linear slide is
/// indistinguishable from an eased one and costs no curve table.
fn slide_fraction(fraction: i32, elapsed: u64) -> i32 {
    if elapsed >= SLIDE_MS {
        return 0;
    }
    let remaining = (SLIDE_MS - elapsed) as i64;
    (i64::from(fraction) * remaining / SLIDE_MS as i64) as i32
}

impl<M: 'static> Widget<M> for Carousel<M> {
    fn paint(&self, ctx: &mut PaintCtx<'_>, canvas: &mut Canvas<'_>) {
        let bounds = ctx.bounds;
        if bounds.is_empty() {
            return;
        }
        // The backdrop: letterboxing, and the ground mid-slide when neither
        // page covers everything.
        let (backdrop, _) = interactive_pair(ctx.theme, Role::Base200, ctx.state);
        canvas.fill_rect(bounds, backdrop);
        if self.pages.is_empty() {
            return;
        }

        let fraction = self.fraction_at(ctx.now_ms);
        let offset = (i64::from(fraction) * i64::from(bounds.width) / i64::from(WHOLE)) as i32;

        // The current page at its offset, and whichever neighbour the gap
        // exposes — clipped to the bounds, so pages slide behind the
        // rectangle rather than across the panel.
        {
            let mut c = canvas.with_clip(bounds);
            let page_at = |c: &mut Canvas<'_>, index: usize, dx: i32| {
                if let Some(page) = self.pages.get(index) {
                    let shifted = Rect::new(bounds.x + dx, bounds.y, bounds.width, bounds.height);
                    page.paint_at(shifted, 0, c);
                }
            };
            page_at(&mut c, self.current, offset);
            if offset > 0 {
                // The current page sits right of rest, so its predecessor
                // shows through on the left.
                page_at(&mut c, self.neighbour(-1), offset - bounds.width);
            } else if offset < 0 {
                page_at(&mut c, self.neighbour(1), offset + bounds.width);
            }
        }

        // The dots: current filled in the role's colour, the others hollow.
        // Display only — a dot is too small a touch target to be honest about.
        if self.pages.len() > 1 {
            let count = self.pages.len() as i32;
            let span = (count - 1) * DOT_GAP;
            let mut x = bounds.x + (bounds.width - span) / 2;
            let y = bounds.bottom() - DOT * 3;
            let (accent, _) = interactive_pair(ctx.theme, self.role, ctx.state);
            let rim = ctx.theme.color(Role::Base100);
            for index in 0..count as usize {
                let centre = Point::new(x, y);
                // A rim under every dot, so they read against any photograph.
                canvas.fill_circle(centre, DOT + 1, rim);
                if index == self.current {
                    canvas.fill_circle(centre, DOT, accent);
                } else {
                    canvas.stroke_circle(centre, DOT, 1, ctx.theme.color(Role::Base300));
                }
                x += DOT_GAP;
            }
        }

        if ctx.state.contains(VisualState::FOCUSED) {
            focus_ring(
                ctx.theme,
                bounds,
                ctx.theme.radius(denise::Radius::Field),
                canvas,
            );
        }
    }

    fn on_event(&mut self, event: &Event<'_>, ctx: &mut EventCtx<'_, M>) -> Handled {
        if self.pages.len() < 2 {
            return Handled::No;
        }
        let width = ctx.bounds.width.max(1);

        match event {
            Event::Input(InputEvent::PointerButton {
                state: ElementState::Down,
                position,
                ..
            })
            | Event::Input(InputEvent::TouchDown { position, .. }) => {
                if !ctx.bounds.contains(*position) {
                    return Handled::No;
                }
                self.grip = Some((*position, width));
                // A touch catches a slide where it is; the drag takes over
                // from the slide's current displacement.
                self.motion = Motion::Dragging {
                    fraction: self.fraction_at(ctx.now_ms),
                };
                self.held_since = ctx.now_ms;
                Handled::Yes
            }

            Event::Input(InputEvent::PointerMoved { position })
            | Event::Input(InputEvent::TouchMoved { position, .. }) => {
                let Some((grip, width)) = self.grip else {
                    return Handled::No;
                };
                // Clamped to one page: dragging further than the neighbour
                // exposes nothing past it.
                let fraction = ((i64::from(position.x - grip.x) * i64::from(WHOLE))
                    / i64::from(width.max(1))) as i32;
                let fraction = fraction.clamp(-WHOLE, WHOLE);
                if self.motion == (Motion::Dragging { fraction }) {
                    return Handled::No;
                }
                self.motion = Motion::Dragging { fraction };
                Handled::Yes
            }

            Event::Input(InputEvent::PointerButton {
                state: ElementState::Up,
                ..
            })
            | Event::Input(InputEvent::TouchUp { .. }) => {
                if self.grip.take().is_none() {
                    return Handled::No;
                }
                let fraction = match self.motion {
                    Motion::Dragging { fraction } => fraction,
                    _ => 0,
                };
                let commit = WHOLE / COMMIT_DIVISOR;
                if fraction <= -commit {
                    // Dragged left: the next page was pulled in from the right
                    // and now sits a page short of rest.
                    self.arrive(self.neighbour(1), fraction + WHOLE, ctx);
                } else if fraction >= commit {
                    self.arrive(self.neighbour(-1), fraction - WHOLE, ctx);
                } else if fraction != 0 {
                    // Not far enough: spring back. No message — the page did
                    // not change. The hold restarts when the spring lands.
                    self.motion = Motion::Sliding {
                        fraction,
                        from_ms: ctx.now_ms,
                    };
                    ctx.request_animation();
                } else {
                    self.motion = Motion::Still;
                }
                Handled::Yes
            }

            Event::Input(InputEvent::Key {
                code,
                state: ElementState::Down,
                ..
            }) if ctx.state.contains(VisualState::FOCUSED) => match code {
                KeyCode::ArrowRight | KeyCode::ArrowDown => {
                    self.arrive(self.neighbour(1), WHOLE, ctx);
                    Handled::Yes
                }
                KeyCode::ArrowLeft | KeyCode::ArrowUp => {
                    self.arrive(self.neighbour(-1), -WHOLE, ctx);
                    Handled::Yes
                }
                KeyCode::Home if self.current != 0 => {
                    self.arrive(0, -WHOLE, ctx);
                    Handled::Yes
                }
                KeyCode::End if self.current != self.pages.len() - 1 => {
                    self.arrive(self.pages.len() - 1, WHOLE, ctx);
                    Handled::Yes
                }
                KeyCode::Home | KeyCode::End => Handled::Yes,
                _ => Handled::No,
            },
            _ => Handled::No,
        }
    }

    fn animate(&mut self, now_ms: u64) -> Animation {
        match self.motion {
            Motion::Sliding { from_ms, .. } => {
                if now_ms.saturating_sub(from_ms) >= SLIDE_MS {
                    // Arrived. The hold starts now; the advance clock decides
                    // whether there is anything left to wake for.
                    self.motion = Motion::Still;
                    self.held_since = now_ms;
                    Animation {
                        repaint: true,
                        next_ms: self.advance_ms.map(|interval| now_ms + interval),
                    }
                } else {
                    Animation {
                        repaint: true,
                        next_ms: Some(now_ms + FRAME_MS),
                    }
                }
            }
            Motion::Dragging { .. } => Animation {
                // A finger holds the pages: nothing moves by itself, and the
                // advance clock waits for it to lift. One distant check keeps
                // the animation alive without costing frames.
                repaint: false,
                next_ms: self.advance_ms.map(|interval| now_ms + interval),
            },
            Motion::Still => match self.advance_ms {
                None => Animation::NONE,
                Some(interval) if self.pages.len() < 2 => Animation {
                    repaint: false,
                    next_ms: Some(now_ms + interval),
                },
                Some(interval) => {
                    let due = self.held_since.saturating_add(interval);
                    if now_ms < due {
                        // Holding: one wake at the deadline, the toast
                        // arrangement — no repaint until it fires.
                        Animation {
                            repaint: false,
                            next_ms: Some(due),
                        }
                    } else {
                        // Due: slide to the next page. No message — see the
                        // note on the type — the clock is the machine talking
                        // to itself.
                        self.current = self.neighbour(1);
                        self.motion = Motion::Sliding {
                            fraction: WHOLE,
                            from_ms: now_ms,
                        };
                        self.held_since = now_ms;
                        Animation {
                            repaint: true,
                            next_ms: Some(now_ms + FRAME_MS),
                        }
                    }
                }
            },
        }
    }

    fn accepts_pointer(&self) -> bool {
        true
    }

    /// One page is nothing to navigate, and a carousel nobody listens to is
    /// display: neither is a tab stop.
    fn focusable(&self) -> bool {
        self.message.is_some() && self.pages.len() > 1
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn picture(word: u32) -> (Vec<u32>, Size) {
        (alloc::vec![word; 16], Size::new(4, 4))
    }

    fn carousel(pages: usize) -> Carousel<usize> {
        let mut c = Carousel::new(|index| index);
        for i in 0..pages {
            let (px, size) = picture(0xFF00_0000 | i as u32);
            c.pages.push(Image::new(px, size).with_fit(Fit::Cover));
        }
        c
    }

    /// A rotator is a cycle: one past the end is the start, in both directions.
    #[test]
    fn the_neighbour_wraps_in_both_directions() {
        let mut c = carousel(3);
        assert_eq!(c.neighbour(1), 1);
        c.set_current(2);
        assert_eq!(c.neighbour(1), 0, "forward past the end wraps");
        c.set_current(0);
        assert_eq!(c.neighbour(-1), 2, "backward past the start wraps");
    }

    /// The slide runs from its displacement to zero, linearly, and is exactly
    /// zero at the end — a slide that lands at 1 leaves a one-pixel seam.
    #[test]
    fn a_slide_runs_to_exactly_rest() {
        assert_eq!(slide_fraction(WHOLE, 0), WHOLE);
        assert_eq!(slide_fraction(WHOLE, SLIDE_MS), 0);
        assert_eq!(slide_fraction(WHOLE, SLIDE_MS * 10), 0, "and stays there");
        assert_eq!(slide_fraction(WHOLE, SLIDE_MS / 2), WHOLE / 2);
        assert_eq!(slide_fraction(-WHOLE, SLIDE_MS / 2), -WHOLE / 2);
        // Monotonic: a slide never moves backwards.
        let mut previous = WHOLE;
        for at in 0..=SLIDE_MS {
            let now = slide_fraction(WHOLE, at);
            assert!(now <= previous, "the slide went backwards at {at}");
            previous = now;
        }
    }

    /// While holding on a page, the animation asks for exactly one wake — the
    /// deadline — and no repaint. This is the cost claim.
    #[test]
    fn holding_asks_for_one_wake_at_the_deadline() {
        let mut c = carousel(3).auto_advance(8_000);
        c.held_since = 1_000;
        let hold = Widget::<usize>::animate(&mut c, 2_000);
        assert!(!hold.repaint, "a hold must not repaint");
        assert_eq!(hold.next_ms, Some(9_000), "one wake, at the deadline");
        // Asked again before the deadline — the tree wakes for the most
        // impatient animation and asks everybody — same answer.
        let again = Widget::<usize>::animate(&mut c, 5_000);
        assert_eq!(again.next_ms, Some(9_000));
        assert_eq!(c.current(), 0, "and the page has not moved");
    }

    /// At the deadline the clock slides to the next page, then rests for a
    /// full interval — and never emits, because no person did anything.
    #[test]
    fn the_advance_clock_slides_and_then_rests() {
        let mut c = carousel(3).auto_advance(8_000);
        c.held_since = 0;
        let due = Widget::<usize>::animate(&mut c, 8_000);
        assert!(due.repaint);
        assert_eq!(due.next_ms, Some(8_000 + FRAME_MS), "sliding at frame rate");
        assert_eq!(c.current(), 1);

        // The slide finishes; the next wake is the next deadline.
        let settled = Widget::<usize>::animate(&mut c, 8_000 + SLIDE_MS);
        assert!(settled.repaint, "the landing frame paints");
        assert_eq!(settled.next_ms, Some(8_000 + SLIDE_MS + 8_000));
        assert_eq!(c.motion, Motion::Still);

        // Asked again mid-hold — the tree wakes for the most impatient
        // animation and asks everybody, so a spinner elsewhere on the screen
        // asks this carousel every frame. The hold must hold: same deadline,
        // no advance. This is what the landing's clock restart is *for*; the
        // landing's own `next_ms` return covers the quiet case by itself.
        let mid_hold = Widget::<usize>::animate(&mut c, 8_000 + SLIDE_MS + 1_000);
        assert!(!mid_hold.repaint);
        assert_eq!(mid_hold.next_ms, Some(8_000 + SLIDE_MS + 8_000));
        assert_eq!(c.current(), 1, "an early ask must not advance the page");
    }

    /// Without an advance clock, a still carousel asks for nothing at all —
    /// the idle-cost floor every widget here is held to.
    #[test]
    fn a_still_carousel_without_a_clock_costs_nothing() {
        let mut c = carousel(3);
        assert_eq!(Widget::<usize>::animate(&mut c, 5_000), Animation::NONE);
    }

    /// A single picture cannot rotate: the clock keeps its distant check but
    /// never slides, and the widget is not a tab stop.
    #[test]
    fn one_page_neither_rotates_nor_takes_focus() {
        let mut c = carousel(1).auto_advance(1_000);
        c.held_since = 0;
        let asked = Widget::<usize>::animate(&mut c, 10_000);
        assert!(!asked.repaint);
        assert_eq!(c.current(), 0, "nowhere to go");
        assert!(!Widget::<usize>::focusable(&c));
        assert!(Widget::<usize>::focusable(&carousel(2)));
        assert!(
            !Widget::<usize>::focusable(&Carousel::<usize>::inert()),
            "a carousel nobody listens to is display"
        );
    }

    /// `set_current` is a silent setter, out of range does nothing, and it
    /// cancels any motion — the application wrote state, the state is shown.
    #[test]
    fn set_current_is_silent_and_clamped() {
        let mut c = carousel(3);
        c.motion = Motion::Sliding {
            fraction: WHOLE,
            from_ms: 0,
        };
        c.set_current(2);
        assert_eq!(c.current(), 2);
        assert_eq!(c.motion, Motion::Still);
        c.set_current(99);
        assert_eq!(c.current(), 2, "out of range does nothing");
    }
}