Skip to main content

denise_ui/widgets/
toggle.rs

1//! The same boolean as a checkbox, with a different affordance.
2
3use alloc::string::String;
4
5use denise::Pen;
6use denise::{ElementState, InputEvent, KeyCode, Rect, Role, Theme};
7use denise_text::{TextEngine, TextStyle};
8
9use crate::motion::Wake;
10use crate::widget::{
11    Animation, Event, EventCtx, Handled, MeasureCtx, Measured, Offer, PaintCtx, VisualState, Widget,
12};
13use crate::widgets::describe::{
14    Describe, DynDescribe, Group, Mismatch, Payload, Property, PropertyKind, ROLES, Value,
15};
16use crate::widgets::style::{Align, draw_aligned, focus_ring, interactive_pair};
17
18/// How long the knob takes to cross, in milliseconds.
19///
20/// Short enough that nobody waits for it and long enough to read as movement
21/// rather than as a dropped frame.
22///
23/// A **duration**, not a frame rate: [`Motion`](crate::Motion) decides how many
24/// positions the knob is drawn in on the way, and never how long it takes.
25const TRAVEL_MS: u64 = 120;
26
27/// Knob positions are fixed-point over this range, so the whole widget is
28/// integer arithmetic — `denise-ui` is `no_std` and the rasteriser is integer
29/// throughout.
30const SCALE: i32 = 1000;
31
32/// A switch: a track, a knob, and a boolean.
33///
34/// Reads better than a [`Checkbox`](super::Checkbox) at arm's length, which is
35/// what a wall panel is. Identical semantics otherwise, including the message
36/// being a function of the **new** value:
37///
38/// ```
39/// # use denise_ui::Toggle;
40/// enum Message { Muted(bool) }
41/// Toggle::new("Mute", Message::Muted);
42/// ```
43///
44/// # The knob slides
45///
46/// Flipping the switch requests animation for the 120 ms crossing and then the
47/// widget stops asking — the bounded-transition contract described on
48/// [`Widget::animate`]. Focus is not involved: the knob finishes its crossing
49/// whether or not focus moves away mid-slide, which is what retired the
50/// `FocusLost`-snaps-the-knob workaround this widget shipped with.
51///
52/// The animation is still a courtesy, not a mechanism: nothing about the value
53/// depends on a frame arriving, and a `Toggle` on a tree whose application
54/// never calls [`Ui::tick`](crate::Ui::tick) is merely a switch that jumps.
55#[derive(Clone, Debug)]
56pub struct Toggle<M> {
57    label: String,
58    checked: bool,
59    /// Knob position when the current transition started, `0..=SCALE`.
60    from: i32,
61    /// When the current transition started, in the [`crate::Ui::tick`] clock.
62    started_ms: u64,
63    message: Option<fn(bool) -> M>,
64    role: Role,
65    style: TextStyle,
66}
67
68impl<M> Toggle<M> {
69    /// An off switch whose message is built from the value it changes to.
70    pub fn new(label: impl Into<String>, message: fn(bool) -> M) -> Self {
71        Self {
72            label: label.into(),
73            checked: false,
74            from: 0,
75            started_ms: 0,
76            message: Some(message),
77            role: Role::Primary,
78            style: TextStyle::built_in(16),
79        }
80    }
81
82    /// A switch that emits nothing, for a value the application reads rather
83    /// than reacts to.
84    pub fn inert(label: impl Into<String>) -> Self {
85        Self {
86            label: label.into(),
87            checked: false,
88            from: 0,
89            started_ms: 0,
90            message: None,
91            role: Role::Primary,
92            style: TextStyle::built_in(16),
93        }
94    }
95
96    /// Sets the initial value. The knob starts there rather than sliding into it.
97    pub fn with_checked(mut self, checked: bool) -> Self {
98        self.checked = checked;
99        self.from = Self::target(checked);
100        self
101    }
102
103    /// Sets the colour role of the track when on.
104    pub fn with_role(mut self, role: Role) -> Self {
105        self.role = role;
106        self
107    }
108
109    /// Sets the label's font and size.
110    pub fn with_style(mut self, style: TextStyle) -> Self {
111        self.style = style;
112        self
113    }
114
115    /// Sets the label's size, keeping the font.
116    pub fn with_size(mut self, size_px: u16) -> Self {
117        self.style.size_px = size_px;
118        self
119    }
120
121    /// Whether the switch is on.
122    #[inline]
123    pub const fn checked(&self) -> bool {
124        self.checked
125    }
126
127    /// Sets the value **without emitting anything, and without animating**.
128    ///
129    /// Silent for the same reason [`Checkbox::set_checked`] is: the message
130    /// reports what a person did, and an application that assigned here and got
131    /// its own message back would either loop or have to guard against itself.
132    ///
133    /// It does not animate because a setter has no way to request frames — the
134    /// tree grants them through an event or [`crate::Ui::request_animation`],
135    /// and a widget cannot reach the tree from here. An application that wants
136    /// the slide can call `request_animation` itself; the default is the honest
137    /// jump.
138    ///
139    /// [`Checkbox::set_checked`]: super::Checkbox::set_checked
140    pub fn set_checked(&mut self, checked: bool) {
141        self.checked = checked;
142        self.from = Self::target(checked);
143        self.started_ms = 0;
144    }
145
146    /// The current label.
147    #[inline]
148    pub fn label(&self) -> &str {
149        &self.label
150    }
151
152    /// Replaces the label.
153    pub fn set_label(&mut self, label: impl Into<String>) {
154        self.label = label.into();
155    }
156
157    /// Replaces the colour role.
158    pub fn set_role(&mut self, role: Role) {
159        self.role = role;
160    }
161
162    /// Replaces the label's font and size.
163    pub fn set_style(&mut self, style: TextStyle) {
164        self.style = style;
165    }
166
167    /// Width this toggle needs for its track, its gap and its label.
168    pub fn preferred_width(&self, theme: &Theme, engine: &mut TextEngine) -> i32 {
169        let height = theme.metrics.size_selector;
170        let track = track_width(height);
171        if self.label.is_empty() {
172            track
173        } else {
174            track + gap(height) + engine.measure_line(self.style, &self.label)
175        }
176    }
177
178    #[inline]
179    const fn target(checked: bool) -> i32 {
180        if checked { SCALE } else { 0 }
181    }
182
183    /// Where the knob is at `now_ms`, `0..=SCALE`.
184    ///
185    /// Interpolated from where it was when the transition started rather than
186    /// accumulated per frame, so a toggle flipped back mid-slide reverses from
187    /// where it actually is and a dropped frame costs nothing.
188    fn position(&self, now_ms: u64) -> i32 {
189        let target = Self::target(self.checked);
190        if self.from == target {
191            return target;
192        }
193        let elapsed = now_ms.saturating_sub(self.started_ms);
194        if elapsed >= TRAVEL_MS {
195            return target;
196        }
197        let progress = (elapsed as i32) * SCALE / (TRAVEL_MS as i32);
198        self.from + (target - self.from) * progress / SCALE
199    }
200
201    /// Whether the knob is still moving at `now_ms`.
202    #[inline]
203    fn moving(&self, now_ms: u64) -> bool {
204        self.from != Self::target(self.checked)
205            && now_ms.saturating_sub(self.started_ms) < TRAVEL_MS
206    }
207
208    /// Ends any transition immediately, wherever it had got to.
209    ///
210    /// The arrival itself. [`Widget::snap`] is the tree asking for it to happen
211    /// now.
212    fn settle(&mut self) {
213        self.from = Self::target(self.checked);
214    }
215}
216
217/// Space between the track and its label.
218#[inline]
219const fn gap(height: i32) -> i32 {
220    if height < 2 { 1 } else { height / 2 }
221}
222
223/// A switch is wider than it is tall, or it is a circle with a knob in it.
224#[inline]
225const fn track_width(height: i32) -> i32 {
226    let width = height * 9 / 5;
227    if width < height + 2 {
228        height + 2
229    } else {
230        width
231    }
232}
233
234/// The track: a stadium at the leading edge, centred vertically.
235fn track_rect(bounds: Rect, theme: &Theme) -> Rect {
236    let mut height = theme.metrics.size_selector.min(bounds.height);
237    if height < 1 {
238        height = 1;
239    }
240    let mut width = track_width(height);
241    if width > bounds.width && bounds.width >= 1 {
242        width = bounds.width;
243    }
244    Rect::new(
245        bounds.x,
246        bounds.y + (bounds.height - height) / 2,
247        width,
248        height,
249    )
250}
251
252/// The knob, for a track and a position in `0..=SCALE`.
253fn knob_rect(track: Rect, position: i32) -> Rect {
254    // Inset so the knob sits *in* the track rather than filling it. An eighth of
255    // the height keeps the proportion at every selector metric.
256    let inset = (track.height / 8).max(1);
257    let size = (track.height - inset * 2).max(1);
258    let travel = (track.width - inset * 2 - size).max(0);
259    let x = track.x + inset + travel * position.clamp(0, SCALE) / SCALE;
260    Rect::new(x, track.y + inset, size, size)
261}
262
263impl<M: 'static> Widget<M> for Toggle<M> {
264    fn describe(&self) -> Option<&dyn DynDescribe> {
265        Some(self)
266    }
267
268    fn describe_mut(&mut self) -> Option<&mut dyn DynDescribe> {
269        Some(self)
270    }
271    fn measure(&self, ctx: &mut MeasureCtx<'_>, _offered: Offer) -> Measured {
272        Measured::both(
273            self.preferred_width(ctx.theme, ctx.text),
274            ctx.theme.metrics.size_selector.max(1),
275        )
276    }
277
278    fn paint(&self, ctx: &mut PaintCtx<'_>, canvas: &mut Pen<'_>) {
279        let track = track_rect(ctx.bounds, ctx.theme);
280        // A stadium: the radius is half the height, whatever the theme's selector
281        // radius says. A switch with square-ish corners reads as a progress bar.
282        let radius = track.height / 2;
283
284        // The label stays plain text on the panel rather than taking the track's
285        // role colour; reading it through the pairing is what mutes it when disabled.
286        let on_surface = interactive_pair(ctx.theme, Role::Base100, ctx.state).1;
287
288        // Track and knob come out of the same pairing, which is what makes the
289        // knob readable rather than hopeful: `content_of` is defined as "the
290        // colour to draw on top of this role", and a knob is a thing drawn on top
291        // of the track. A fixed knob colour cannot work — `Base100` on `Base300`
292        // is 1.38:1 on the light theme, two light surfaces on top of each other,
293        // and `the_knob_is_visible_against_both_track_colours_in_every_theme` is
294        // what caught it.
295        //
296        // Both follow the *value* rather than the knob's position, so a toggle
297        // mid-slide already shows where it is going.
298        let (track_color, knob_color) = if self.checked {
299            interactive_pair(ctx.theme, self.role, ctx.state)
300        } else {
301            interactive_pair(ctx.theme, Role::Base300, ctx.state)
302        };
303        canvas.fill_rounded_rect(track, radius, track_color);
304
305        let knob = knob_rect(track, self.position(ctx.now_ms));
306        canvas.fill_rounded_rect(knob, knob.height / 2, knob_color);
307
308        if ctx.state.contains(VisualState::FOCUSED) {
309            focus_ring(
310                ctx.theme,
311                ctx.bounds,
312                ctx.theme.radius(denise::Radius::Field),
313                canvas,
314            );
315        }
316
317        if self.label.is_empty() {
318            return;
319        }
320        let text = Rect::from_edges(
321            track.right() + gap(track.height),
322            ctx.bounds.y,
323            ctx.bounds.right(),
324            ctx.bounds.bottom(),
325        );
326        if !text.is_empty() {
327            draw_aligned(
328                canvas,
329                ctx.text,
330                self.style,
331                text,
332                (Align::Start, Align::Center),
333                &self.label,
334                on_surface,
335            );
336        }
337    }
338
339    fn on_event(&mut self, event: &Event<'_>, ctx: &mut EventCtx<'_, M>) -> Handled {
340        let toggled = match event {
341            Event::Input(InputEvent::PointerButton {
342                state: ElementState::Up,
343                position,
344                ..
345            }) => ctx.bounds.contains(*position),
346            Event::Input(InputEvent::TouchUp {
347                position,
348                cancelled: false,
349                ..
350            }) => ctx.bounds.contains(*position),
351            // Space, not Enter — Enter belongs to the form's default action. Same
352            // rule as `Checkbox`, and for the same reason.
353            Event::Input(InputEvent::Key {
354                code: KeyCode::Space,
355                state: ElementState::Down,
356                repeat: false,
357                ..
358            }) => ctx.state.contains(VisualState::FOCUSED),
359            _ => return Handled::No,
360        };
361        if !toggled {
362            return Handled::No;
363        }
364
365        // From wherever the knob actually is, so flipping it back mid-slide
366        // reverses smoothly instead of jumping to the far end first.
367        self.from = self.position(ctx.now_ms);
368        self.started_ms = ctx.now_ms;
369        self.checked = !self.checked;
370        // Frames for the crossing, and only the crossing: `animate` answers
371        // `None` the moment the knob arrives.
372        ctx.request_animation();
373        if let Some(message) = self.message {
374            ctx.emit(message(self.checked));
375        }
376        Handled::Yes
377    }
378
379    fn animate(&mut self, now_ms: u64) -> Animation {
380        if !self.moving(now_ms) {
381            // Arrived. Collapsing `from` onto the target is what makes the next
382            // `moving` cheap and, more importantly, stops this widget asking for
383            // another frame — a toggle that kept requesting wakes would hold a
384            // kiosk's CPU awake for the life of the device.
385            //
386            // **The arrival is a frame, and it used to be dropped.** `moving`
387            // goes false the moment `elapsed` reaches `TRAVEL_MS`, and that is
388            // the first tick on which `position` answers `target` — so the last
389            // frame anyone painted had the knob a step short of the end. On a
390            // double-buffered display that does not merely look imprecise: one
391            // buffer holds the knob short and the other holds it home, and a
392            // panel that keeps presenting alternates between them for as long
393            // as the toggle is on screen. It was reported from a Pi as flicker
394            // on the control under the pointer.
395            //
396            // So arriving repaints, and only settling again is free. Which is
397            // what `snap` next door had right all along: `repaint: moving`.
398            let arriving = self.from != Self::target(self.checked);
399            self.settle();
400            return Animation {
401                repaint: arriving,
402                next: Wake::Never,
403            };
404        }
405        // Moving, at whatever rate the tree animates at. The crossing still
406        // takes `TRAVEL_MS` either way: a coarser rate draws it in fewer
407        // positions, it does not draw it more slowly.
408        Animation::MOVING
409    }
410
411    /// The knob arrives at once. A switch is the clearest case for reduced
412    /// motion: the slide was always a courtesy, and the value never depended on
413    /// it.
414    fn snap(&mut self, now_ms: u64) -> Animation {
415        let moving = self.moving(now_ms);
416        self.settle();
417        Animation {
418            repaint: moving,
419            next: Wake::Never,
420        }
421    }
422
423    fn accepts_pointer(&self) -> bool {
424        true
425    }
426
427    fn focusable(&self) -> bool {
428        true
429    }
430}
431
432impl<M> Describe for Toggle<M> {
433    const KIND: &'static str = "toggle";
434    const DOC: &'static str = "The same on-or-off as a checkbox, shaped like a switch.";
435    const GROUP: Group = Group::Input;
436    const ICON: &'static denise::icon::Icon = &super::icons::TOGGLE;
437
438    const PROPERTIES: &'static [Property] = &[
439        Property::new("text", PropertyKind::Text, "The label beside the track."),
440        Property::new("checked", PropertyKind::Bool, "Whether the switch is on."),
441        Property::new(
442            "on-change",
443            PropertyKind::Message(Payload::Bool),
444            "The message built from the value the switch changes to. Omitted, the toggle is inert.",
445        ),
446        Property::new(
447            "role",
448            PropertyKind::Enum(ROLES),
449            "Colour role of the track when on. The knob comes from the theme's pairing, so it stays visible against it.",
450        ),
451        Property::new(
452            "size",
453            PropertyKind::Int { min: 6, max: 96 },
454            "Label text size in logical pixels. The track itself is a theme metric.",
455        )
456        .in_pixels(),
457    ];
458
459    fn get(&self, name: &str) -> Option<Value> {
460        Some(match name {
461            "text" => Value::text(self.label.as_str()),
462            "checked" => Value::Bool(self.checked),
463            // A `fn(bool) -> M` cannot be reported as a `Value`. See the
464            // `describe` module docs.
465            "on-change" => return None,
466            "role" => Value::role(self.role),
467            "size" => Value::Int(i32::from(self.style.size_px)),
468            _ => return None,
469        })
470    }
471
472    fn apply(&mut self, name: &str, value: Value) -> Result<(), Mismatch> {
473        match name {
474            "text" => self.label = value.as_text()?,
475            // The setter, which lands the knob where the value says rather than
476            // sliding it there. Assigning `checked` alone would leave `from` at
477            // the other end, and a form that opened with three switches gliding
478            // into place would be animating a state nobody changed.
479            "checked" => self.set_checked(value.as_bool()?),
480            "on-change" => return Err(Mismatch::Supplied),
481            "role" => self.role = value.as_role()?,
482            "size" => self.style.size_px = value.as_size()?,
483            _ => return Err(Mismatch::Unknown),
484        }
485        Ok(())
486    }
487}
488
489#[cfg(test)]
490mod tests {
491    use super::*;
492    use denise::theme;
493
494    /// A switch has to be wider than it is tall, or it is a circle with a knob
495    /// rattling around inside it.
496    #[test]
497    fn the_track_is_a_stadium_at_the_leading_edge() {
498        let bounds = Rect::new(10, 20, 300, 40);
499        let track = track_rect(bounds, &theme::DARK);
500
501        assert_eq!(track.height, theme::DARK.metrics.size_selector);
502        assert!(track.width > track.height, "a switch is not a circle");
503        assert_eq!(track.x, bounds.x);
504        assert_eq!(
505            track.y + track.height / 2,
506            bounds.y + bounds.height / 2,
507            "centred against the label beside it"
508        );
509    }
510
511    /// Degenerate bounds still have to produce something drawable. A knob with
512    /// zero or negative size reaches `fill_rounded_rect` as an inverted rectangle.
513    #[test]
514    fn degenerate_bounds_still_give_a_drawable_track_and_knob() {
515        for bounds in [
516            Rect::new(0, 0, 0, 0),
517            Rect::new(0, 0, 1, 40),
518            Rect::new(0, 0, 300, 1),
519            Rect::new(0, 0, 4, 4),
520        ] {
521            let track = track_rect(bounds, &theme::DARK);
522            assert!(track.height >= 1, "{bounds:?} gave no track");
523            for position in [0, SCALE / 2, SCALE] {
524                let knob = knob_rect(track, position);
525                assert!(
526                    knob.width >= 1 && knob.height >= 1,
527                    "{bounds:?} @ {position}"
528                );
529            }
530        }
531    }
532
533    /// The knob stays inside the track at both ends, which is the whole of what
534    /// the travel arithmetic has to get right.
535    #[test]
536    fn the_knob_stays_inside_the_track_across_its_whole_travel() {
537        for metrics in [theme::Metrics::DEFAULT, theme::Metrics::TOUCH] {
538            let theme = theme::DARK.with_metrics(metrics);
539            let track = track_rect(Rect::new(0, 0, 300, 60), &theme);
540            let (mut previous, mut moved) = (i32::MIN, false);
541
542            for position in 0..=SCALE {
543                let knob = knob_rect(track, position);
544                assert!(
545                    track.contains_rect(&knob),
546                    "{metrics:?} @ {position}: {knob:?} escaped {track:?}"
547                );
548                if knob.x > previous && previous != i32::MIN {
549                    moved = true;
550                }
551                assert!(knob.x >= previous, "the knob went backwards");
552                previous = knob.x;
553            }
554            assert!(moved, "the knob never moved at all");
555        }
556    }
557
558    /// Out-of-range positions are clamped rather than producing a knob outside
559    /// the track. Nothing should pass one, which is exactly why it is worth
560    /// pinning: the clamp is invisible until it is missing.
561    #[test]
562    fn a_position_outside_the_range_is_clamped() {
563        let track = track_rect(Rect::new(0, 0, 300, 40), &theme::DARK);
564        assert_eq!(knob_rect(track, -5000), knob_rect(track, 0));
565        assert_eq!(knob_rect(track, SCALE * 3), knob_rect(track, SCALE));
566    }
567
568    /// The interpolation: starts where it was, ends where it is going, and is
569    /// somewhere in between along the way.
570    #[test]
571    fn the_knob_crosses_over_the_travel_time_and_then_stops() {
572        let mut toggle: Toggle<bool> = Toggle::new("Mute", |on| on);
573        toggle.checked = true;
574        toggle.from = 0;
575        toggle.started_ms = 1_000;
576
577        assert_eq!(toggle.position(1_000), 0);
578        let middle = toggle.position(1_000 + TRAVEL_MS / 2);
579        assert!(
580            middle > 0 && middle < SCALE,
581            "{middle} is not between the ends"
582        );
583        assert_eq!(toggle.position(1_000 + TRAVEL_MS), SCALE);
584        assert_eq!(
585            toggle.position(9_999_999),
586            SCALE,
587            "and stays there rather than overshooting"
588        );
589        assert!(!toggle.moving(1_000 + TRAVEL_MS));
590    }
591
592    /// **The frame that lands the crossing must be a frame that repaints.**
593    ///
594    /// The regression this is here for. `moving` goes false the instant
595    /// `elapsed` reaches `TRAVEL_MS`, and that same instant is the first at
596    /// which `position` answers `SCALE` — so the arrival was a frame nobody
597    /// painted, and every frame before it had the knob short of the end. One
598    /// buffer of a double-buffered panel therefore kept the knob short while
599    /// the other had it home, and a display that keeps presenting alternates
600    /// between the two. It was reported from a Pi as flicker on the control
601    /// under the pointer, and it is the same defect the toast stack had.
602    #[test]
603    fn the_frame_that_lands_the_crossing_repaints() {
604        let mut toggle: Toggle<bool> = Toggle::new("Mute", |on| on);
605        toggle.checked = true;
606        toggle.from = 0;
607        toggle.started_ms = 1_000;
608
609        let last_moving = toggle.animate(1_000 + TRAVEL_MS - 1);
610        assert!(last_moving.repaint, "still crossing");
611        assert!(
612            toggle.position(1_000 + TRAVEL_MS - 1) < SCALE,
613            "and short of the end, which is what makes the next frame matter"
614        );
615
616        let landing = toggle.animate(1_000 + TRAVEL_MS);
617        assert!(
618            landing.repaint,
619            "the knob reached the end on this frame; somebody has to draw it there"
620        );
621        assert_eq!(landing.next, Wake::Never, "and then stop asking");
622    }
623
624    /// The other half: a toggle sitting still is free. Asking for a repaint
625    /// every tick would hold a kiosk's CPU awake for the life of the device,
626    /// which is the cost the arrival must not be paid for with.
627    #[test]
628    fn a_settled_toggle_asks_for_nothing() {
629        let mut toggle: Toggle<bool> = Toggle::new("Mute", |on| on);
630        toggle.checked = true;
631        toggle.from = 0;
632        toggle.started_ms = 1_000;
633        toggle.animate(1_000 + TRAVEL_MS);
634
635        for now in [1_000 + TRAVEL_MS, 2_000, 9_999_999] {
636            let idle = toggle.animate(now);
637            assert!(!idle.repaint, "nothing changed at {now} ms");
638            assert_eq!(idle.next, Wake::Never);
639        }
640    }
641
642    /// A clock that goes backwards — a host that resets its epoch, or a `tick`
643    /// arriving out of order — must not produce a negative elapsed time.
644    #[test]
645    fn a_clock_that_goes_backwards_does_not_underflow() {
646        let mut toggle: Toggle<bool> = Toggle::new("Mute", |on| on);
647        toggle.checked = true;
648        toggle.from = 0;
649        toggle.started_ms = 5_000;
650        assert_eq!(toggle.position(1), 0, "before the start is the start");
651    }
652
653    /// Flipping back mid-slide reverses from where the knob actually is, rather
654    /// than snapping to the far end and starting from there.
655    #[test]
656    fn flipping_back_halfway_reverses_from_where_it_got_to() {
657        let mut toggle: Toggle<bool> = Toggle::new("Mute", |on| on);
658        toggle.checked = true;
659        toggle.from = 0;
660        toggle.started_ms = 0;
661
662        let halfway = toggle.position(TRAVEL_MS / 2);
663        // What `on_event` does when it is toggled again.
664        toggle.from = halfway;
665        toggle.started_ms = TRAVEL_MS / 2;
666        toggle.checked = false;
667
668        assert_eq!(toggle.position(TRAVEL_MS / 2), halfway, "no jump");
669        assert_eq!(toggle.position(TRAVEL_MS / 2 + TRAVEL_MS), 0);
670    }
671
672    /// Assigning is silent *and* immediate: a setter cannot request frames, so
673    /// the honest picture is the one that needs none.
674    #[test]
675    fn setting_the_value_programmatically_is_silent_and_lands_immediately() {
676        let mut toggle: Toggle<bool> = Toggle::new("Mute", |on| on);
677        toggle.set_checked(true);
678        assert!(toggle.checked());
679        assert_eq!(toggle.position(0), SCALE);
680        assert!(!toggle.moving(0));
681    }
682
683    /// A knob that vanishes into its track is a switch with no visible state at
684    /// all, and it vanishes in exactly one theme rather than all of them — which
685    /// is the kind of thing that ships.
686    ///
687    /// This is what rejected the obvious rule of a fixed `Base100` knob: on the
688    /// light theme that is 1.38:1 against a `Base300` track, two light surfaces
689    /// stacked. Pairing the knob with the track through `interactive_pair` makes
690    /// the guarantee structural instead.
691    #[test]
692    fn the_knob_is_visible_against_its_track_in_every_theme_and_state() {
693        use denise::theme::{AA_LARGE, contrast_x100};
694
695        for theme in Theme::BUILT_IN {
696            for state in [
697                VisualState::NONE,
698                VisualState::HOVERED,
699                VisualState::PRESSED,
700                VisualState::DISABLED,
701                VisualState::FOCUSED,
702            ] {
703                for (name, role) in [("off", Role::Base300), ("on", Role::Primary)] {
704                    let (track, knob) = interactive_pair(&theme, role, state);
705                    let ratio = contrast_x100(track, knob);
706                    assert!(
707                        ratio >= AA_LARGE,
708                        "{} {name} {state:?}: knob against track is {ratio}, \
709                         floor is {AA_LARGE}",
710                        theme.name
711                    );
712                }
713            }
714        }
715    }
716}