denise-ui 0.30.0

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
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
//! The closed half of a dropdown, and the four lines that open it.

use alloc::string::String;
use alloc::vec::Vec;

use denise::Pen;
use denise::{ElementState, InputEvent, KeyCode, Point, Radius, Rect, Role};
use denise_text::TextStyle;

use crate::widget::{
    Event, EventCtx, Handled, MeasureCtx, Measured, Offer, PaintCtx, VisualState, Widget,
};
use crate::widgets::describe::{
    Describe, DynDescribe, Group, Mismatch, Payload, Property, PropertyKind, ROLES, Value,
};
use crate::widgets::style::{Align, draw_aligned, focus_ring, interactive_pair, muted};

/// A control showing one chosen option, which asks to be opened.
///
/// ```
/// # use denise_ui::Select;
/// enum Message { Open, Chose(usize) }
/// Select::new(["Auto", "Manuell", "Av"], Message::Open).with_placeholder("Velg modus");
/// ```
///
/// # It does not open its own list, and nothing here can
///
/// To open a list a widget would have to create nodes from inside `on_event`.
/// [`EventCtx`] can emit a message, ask for focus, ask for frames and ask to be
/// revealed — it cannot add widgets, and giving it that power would let any
/// widget restructure the tree from an event handler.
///
/// That is the same line already drawn three times: [`Tabs`](super::Tabs) owns
/// the selected index and not the pages, [`List`](super::List) owns the
/// selection and not the viewport, and
/// [`Ui::push_popup`](crate::Ui::push_popup) places a container the caller
/// fills. A select that owned its list would be the first widget to own nodes,
/// and the exception would be permanent.
///
/// So this widget emits an *open* message and the application opens the list —
/// which [`open_select`] does in one call:
///
/// ```
/// # use denise::{Size, theme};
/// # use denise_ui::{Select, Ui, widgets};
/// # #[derive(Clone, Debug)] enum Message { Open, Chose(usize) }
/// # fn demo(message: Message, select: denise_ui::NodeId) {
/// # let mut ui: Ui<Message> = Ui::new(Size::new(1920, 1080), theme::DARK);
/// match message {
///     Message::Open => { widgets::open_select(&mut ui, select, Message::Chose); }
///     Message::Chose(index) => {
///         ui.close_popup();
///         ui.widget_mut::<Select<Message>>(select).unwrap().set_selected(Some(index));
///     }
/// }
/// # }
/// ```
///
/// Everything the open list needs is already there: the popup flips near a
/// screen edge, closes on Escape or a press outside — swallowing that press —
/// and returns focus here when it goes.
///
/// # Keyboard
///
/// `Enter`, `Space` and `ArrowDown` open it. Left and Right deliberately do
/// **not** cycle the value: a select whose value changes as somebody tabs past
/// it is the classic accidental-edit bug, and a closed control that quietly
/// edits itself is worse than one that needs a second keystroke.
#[derive(Clone, Debug)]
pub struct Select<M> {
    options: Vec<String>,
    selected: Option<usize>,
    placeholder: String,
    message: Option<M>,
    role: Role,
    style: TextStyle,
}

impl<M> Select<M> {
    /// A select with nothing chosen, emitting `message` when it wants opening.
    pub fn new(options: impl IntoIterator<Item = impl Into<String>>, message: M) -> Self {
        Self {
            options: options.into_iter().map(Into::into).collect(),
            selected: None,
            placeholder: String::from(""),
            message: Some(message),
            role: Role::Base100,
            style: TextStyle::built_in(16),
        }
    }

    /// A dropdown that emits nothing, for a chosen value the application reads
    /// rather than one it is told about.
    ///
    /// The message a `Select` carries is its request to be **opened** — the
    /// popup is a scene the application pushes, since a widget cannot own other
    /// nodes — so one without a message is a closed control showing
    /// [`selected`](Select::selected) and nothing else. That is the honest
    /// meaning of an inert select, and it is what a form file wants for a value
    /// it displays.
    ///
    /// A form that wants the list to open names `on-change`, and the engine
    /// wires it; see `docs/forms.md`.
    pub fn inert(options: impl IntoIterator<Item = impl Into<String>>) -> Self {
        Self {
            options: options.into_iter().map(Into::into).collect(),
            selected: None,
            placeholder: String::from(""),
            message: None,
            role: Role::Base100,
            style: TextStyle::built_in(16),
        }
    }

    /// Sets the text shown when nothing is chosen.
    pub fn with_placeholder(mut self, placeholder: impl Into<String>) -> Self {
        self.placeholder = placeholder.into();
        self
    }

    /// Sets the initially chosen option. Out of range chooses nothing.
    pub fn with_selected(mut self, index: Option<usize>) -> Self {
        self.set_selected(index);
        self
    }

    /// Sets the control's surface role.
    pub fn with_role(mut self, role: Role) -> Self {
        self.role = role;
        self
    }

    /// Sets the font and size.
    pub fn with_style(mut self, style: TextStyle) -> Self {
        self.style = style;
        self
    }

    /// The chosen index, if any.
    #[inline]
    pub const fn selected(&self) -> Option<usize> {
        self.selected
    }

    /// The chosen option's text, or `None` when nothing is chosen.
    #[inline]
    pub fn selected_option(&self) -> Option<&str> {
        self.options.get(self.selected?).map(String::as_str)
    }

    /// Chooses an option **without emitting anything**. Out of range chooses
    /// nothing, as [`List::set_selected`](super::List::set_selected) does and
    /// for the same reason: nothing chosen is a state this control can show.
    pub fn set_selected(&mut self, index: Option<usize>) {
        self.selected = index.filter(|index| *index < self.options.len());
    }

    /// The options, in order.
    #[inline]
    pub fn options(&self) -> &[String] {
        &self.options
    }

    /// Replaces the options, dropping a selection that no longer exists.
    pub fn set_options(&mut self, options: impl IntoIterator<Item = impl Into<String>>) {
        self.options = options.into_iter().map(Into::into).collect();
        self.set_selected(self.selected);
    }

    /// Replaces the font and size.
    pub fn set_style(&mut self, style: TextStyle) {
        self.style = style;
    }

    /// The font and size the text draws in.
    #[inline]
    pub const fn style(&self) -> TextStyle {
        self.style
    }

    /// What the control currently reads.
    fn shown(&self) -> &str {
        self.selected_option().unwrap_or(&self.placeholder)
    }
}

/// Space between the text and the control's edge.
#[inline]
const fn padding(size_px: u16) -> i32 {
    let half = size_px as i32 / 2;
    if half < 4 { 4 } else { half }
}

/// The chevron's box: a square at the trailing edge, inset by the padding.
fn chevron_box(bounds: Rect, pad: i32) -> Rect {
    let side = (bounds.height / 3).clamp(1, bounds.width.max(1));
    Rect::new(
        bounds.right() - pad - side,
        bounds.y + (bounds.height - side / 2) / 2,
        side,
        side / 2,
    )
}

/// Draws a downward chevron inside `box_of`, as two strokes.
///
/// Two lines rather than a glyph: the built-in font has no arrow, and a control
/// whose affordance depended on which font was loaded would lose it on the
/// panel that ships with none.
fn draw_chevron(canvas: &mut Pen<'_>, box_of: Rect, thickness: i32, color: denise::Color) {
    if box_of.is_empty() {
        return;
    }
    let tip = Point::new(box_of.x + box_of.width / 2, box_of.bottom());
    let left = Point::new(box_of.x, box_of.y);
    let right = Point::new(box_of.right(), box_of.y);
    for offset in 0..thickness.max(1) {
        let dy = offset;
        canvas.draw_line(
            Point::new(left.x, left.y + dy),
            Point::new(tip.x, tip.y + dy),
            color,
        );
        canvas.draw_line(
            Point::new(tip.x, tip.y + dy),
            Point::new(right.x, right.y + dy),
            color,
        );
    }
}

impl<M: Clone + 'static> Widget<M> for Select<M> {
    fn describe(&self) -> Option<&dyn DynDescribe> {
        Some(self)
    }

    fn describe_mut(&mut self) -> Option<&mut dyn DynDescribe> {
        Some(self)
    }
    fn measure(&self, ctx: &mut MeasureCtx<'_>, _offered: Offer) -> Measured {
        // As wide as its longest option, so a dropdown does not clip the thing
        // it exists to show. The chevron and both paddings go on top.
        let pad = padding(self.style.size_px);
        // A plain loop rather than an iterator chain: `measure_line` takes the
        // engine mutably, so a closure over it would hold it for the whole walk.
        let mut widest = ctx.text.measure_line(self.style, &self.placeholder);
        for option in self.options() {
            widest = widest.max(ctx.text.measure_line(self.style, option));
        }
        Measured::both(widest + pad * 4, ctx.theme.metrics.size_field.max(1))
    }

    fn paint(&self, ctx: &mut PaintCtx<'_>, canvas: &mut Pen<'_>) {
        let bounds = ctx.bounds;
        if bounds.is_empty() {
            return;
        }
        let radius = ctx.theme.radius(Radius::Field);
        let (surface, content) = interactive_pair(ctx.theme, self.role, ctx.state);
        canvas.fill_rounded_rect(bounds, radius, surface);
        canvas.stroke_rounded_rect(
            bounds,
            radius,
            ctx.theme.metrics.border,
            ctx.theme.color(Role::Base300),
        );
        if ctx.state.contains(VisualState::FOCUSED) {
            focus_ring(ctx.theme, bounds, radius, canvas);
        }

        let pad = padding(self.style.size_px);
        let chevron = chevron_box(bounds, pad);
        draw_chevron(canvas, chevron, ctx.theme.metrics.border, content);

        // A placeholder is de-emphasised, a chosen value is not — the same
        // `muted` every de-emphasised label here goes through, so a pair with
        // no contrast to spare is returned unchanged rather than made
        // unreadable.
        let colour = if self.selected.is_some() {
            content
        } else {
            muted(surface, content)
        };
        let text = Rect::from_edges(
            bounds.x + pad,
            bounds.y,
            (chevron.x - pad).max(bounds.x + pad),
            bounds.bottom(),
        );
        if !text.is_empty() {
            draw_aligned(
                canvas,
                ctx.text,
                self.style,
                text,
                (Align::Start, Align::Center),
                self.shown(),
                colour,
            );
        }
    }

    fn on_event(&mut self, event: &Event<'_>, ctx: &mut EventCtx<'_, M>) -> Handled {
        let opened = match event {
            Event::Input(InputEvent::PointerButton {
                state: ElementState::Up,
                position,
                ..
            })
            | Event::Input(InputEvent::TouchUp {
                position,
                cancelled: false,
                ..
            }) => ctx.bounds.contains(*position),
            // Enter, Space and Down open. Left and Right are deliberately not
            // handled: a closed select that edited its own value as somebody
            // tabbed past it is the classic accidental-edit bug.
            Event::Input(InputEvent::Key {
                code: KeyCode::Enter | KeyCode::NumpadEnter | KeyCode::Space | KeyCode::ArrowDown,
                state: ElementState::Down,
                repeat: false,
                ..
            }) => ctx.state.contains(VisualState::FOCUSED),
            _ => return Handled::No,
        };
        if !opened || self.options.is_empty() {
            return Handled::No;
        }
        if let Some(message) = self.message.clone() {
            ctx.emit(message);
        }
        Handled::Yes
    }

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

    /// A select with no options is not a tab stop: there is nothing to open.
    fn focusable(&self) -> bool {
        !self.options.is_empty()
    }
}

impl<M> Describe for Select<M> {
    const KIND: &'static str = "select";
    const DOC: &'static str = "One choice out of many, picked from a dropdown list.";
    const GROUP: Group = Group::Input;
    const ICON: &'static denise::icon::Icon = &super::icons::SELECT;

    const PROPERTIES: &'static [Property] = &[
        Property::new(
            "option",
            PropertyKind::List,
            "The choices, as `option` child nodes. A dropdown's are usually the real ones.",
        ),
        Property::new(
            "selected",
            PropertyKind::Int {
                min: 0,
                max: i32::MAX,
            },
            "The chosen option. Without one, nothing is chosen and the placeholder shows.",
        ),
        Property::new(
            "placeholder",
            PropertyKind::Text,
            "Shown while nothing is chosen.",
        ),
        Property::new(
            "on-change",
            // The exception to the payload table: a `Select` holds one message
            // and the application reads `selected()` when it arrives, because a
            // dropdown's choice outlives the event that made it.
            PropertyKind::Message(Payload::None),
            "Emitted when a choice is made; the application reads `selected` afterwards.",
        ),
        Property::new(
            "role",
            PropertyKind::Enum(ROLES),
            "Colour role of the control's own surface.",
        ),
        Property::new(
            "size",
            PropertyKind::Int { min: 6, max: 96 },
            "Text size in logical pixels.",
        )
        .in_pixels(),
    ];

    fn get(&self, name: &str) -> Option<Value> {
        Some(match name {
            // Nothing chosen reports nothing, which is the state the format
            // spells by leaving `selected` out.
            "selected" => Value::Int(i32::try_from(self.selected?).unwrap_or(i32::MAX)),
            "placeholder" => Value::text(self.placeholder.as_str()),
            "role" => Value::role(self.role),
            "size" => Value::Int(i32::from(self.style.size_px)),
            _ => return None,
        })
    }

    fn apply(&mut self, name: &str, value: Value) -> Result<(), Mismatch> {
        match name {
            // Through the setter, so an option that is not there chooses
            // nothing rather than leaving a dangling index behind.
            "selected" => self.set_selected(Some(value.as_index()?)),
            "placeholder" => self.placeholder = value.as_text()?,
            // The engine builds these from the child nodes, and an
            // inspector edits them where they live. See
            // `PropertyKind::List`.
            "on-change" | "option" => return Err(Mismatch::Supplied),
            "role" => self.role = value.as_role()?,
            "size" => self.style.size_px = value.as_size()?,
            _ => return Err(Mismatch::Unknown),
        }
        Ok(())
    }
}

/// Opens a [`Select`]'s option list as a popup below it.
///
/// The four lines an application would otherwise write, and the ones several
/// panels would each get subtly wrong: sizing the popup to the widest option,
/// matching its width to the control so the open list lines up with the closed
/// one, and seeding the list's selection from the select's.
///
/// The popup is an ordinary one — [`Ui::push_popup`](crate::Ui::push_popup) —
/// so it flips near a screen edge, closes on Escape or a press outside
/// (swallowing that press), and returns focus to the select. Its contents are
/// ordinary nodes: a caller who wants a different open list writes those four
/// lines instead of calling this.
///
/// message` is emitted when a row is **chosen** — by `Enter` or by a tap — and
/// not while the arrow keys move the highlight through the list. That is the
/// distinction [`List`](super::List) draws between selecting and activating,
/// and a dropdown wants only the second: a list that reported every row the
/// keyboard passed over would have an application applying three values on the
/// way to the fourth.
///
/// The application closes the popup and applies the choice; this does not,
/// because choosing is the application's business and a helper that closed the
/// popup would be deciding when a multi-select was finished.
///
/// Returns the popup's container, or `None` if `select` is not a live node
/// holding a `Select`.
pub fn open_select<M: Clone + 'static>(
    ui: &mut crate::Ui<M>,
    select: crate::NodeId,
    message: fn(usize) -> M,
) -> Option<crate::NodeId> {
    let widget = ui.widget::<Select<M>>(select)?;
    let options: Vec<String> = widget.options().to_vec();
    if options.is_empty() {
        return None;
    }
    let style = widget.style();
    let chosen = widget.selected();

    let anchor = ui.bounds(select)?;
    let row = ui.theme().metrics.size_field;
    let widest = options
        .iter()
        .map(|option| ui.text_mut().measure_line(style, option))
        .max()
        .unwrap_or(0);

    // As wide as the control, or as wide as the options need — whichever is
    // more. A list narrower than the thing it drops out of looks detached.
    let pad = padding(style.size_px);
    let width = anchor.width.max(widest + pad * 2);
    let content = row * options.len() as i32;
    // No taller than the room on the roomier side of the control, in whole
    // rows. A list of a hundred keyboard layouts sized to its content runs off
    // the surface, where no wheel or arrow key can reach the rows it hides.
    let surface = ui.bounds(ui.root())?;
    let room = (surface.bottom() - anchor.bottom()).max(anchor.y - surface.y) - POPUP_MARGIN;
    let height = content.min((room / row).max(1) * row);

    let container = ui.push_popup(
        select,
        denise::Size::new(width as u32, height as u32),
        crate::Side::Below,
    )?;
    // The panel is the viewport and the list inside it is as tall as its rows:
    // the tree scrolls it for the wheel and the page keys, and the list's arrow
    // keys pull it along.
    let viewport = ui.add(
        container,
        super::Panel::default(),
        Rect::new(0, 0, width, height),
    )?;
    ui.set_scrollable(viewport, true);
    // Inert for selection, wired for activation: the arrows move the highlight
    // silently and only Enter or a tap reports a choice. `activate_on_click`
    // makes one tap do both, which is what a dropdown row is — a command, not
    // an option to be pondered.
    let list = super::List::inert(options)
        .on_activate(message)
        .with_row_height(row)
        .with_style(style)
        .activate_on_click()
        .with_selected(chosen);
    let list = ui.add(viewport, list, Rect::new(0, 0, width, content))?;
    // So the keyboard works the moment it opens, and Escape has somewhere to
    // return focus from.
    ui.focus(Some(list));
    // Open on the current choice, in the middle where there is room, rather
    // than on the first rows with the choice somewhere out of sight below.
    if let Some(chosen) = chosen {
        let y = row * chosen as i32 - (height - row) / 2;
        ui.set_scroll(viewport, Point::new(0, y));
    }
    Some(container)
}

/// Space kept between an open list and the surface's edge, beyond the room
/// the popup leaves between itself and its control.
const POPUP_MARGIN: i32 = 8;

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

    fn select() -> Select<u8> {
        Select::new(["Auto", "Manuell", "Av"], 1u8)
    }

    /// An inert select is a closed control showing what is chosen.
    ///
    /// Its message is the request to be *opened*, so one without a message
    /// cannot be — which is the honest meaning of inert here, and different from
    /// a checkbox's, which still changes its own value. Everything else about it
    /// works: the options are there, the selection is readable and settable, and
    /// it still takes focus, because a focused one is still readable.
    #[test]
    fn an_inert_select_shows_a_choice_and_cannot_be_opened() {
        let mut inert: Select<u8> = Select::inert(["Auto", "Manuell", "Av"]);
        assert_eq!(inert.options().len(), 3);
        assert_eq!(inert.selected(), None);

        inert.set_selected(Some(2));
        assert_eq!(inert.selected(), Some(2));
        assert!(inert.focusable(), "an inert select is still readable");

        // The one built with a message wants opening; this one has nothing to
        // ask with.
        assert!(select().message.is_some());
        assert!(inert.message.is_none());
    }

    /// Nothing chosen shows the placeholder; a choice shows the option.
    #[test]
    fn it_shows_the_placeholder_until_something_is_chosen() {
        let mut select = select().with_placeholder("Velg modus");
        assert_eq!(select.shown(), "Velg modus");
        assert_eq!(select.selected(), None);

        select.set_selected(Some(1));
        assert_eq!(select.shown(), "Manuell");
        assert_eq!(select.selected_option(), Some("Manuell"));
    }

    /// Out of range chooses nothing rather than the nearest option — nothing
    /// chosen is a state this control can show, so it is the honest answer.
    #[test]
    fn an_index_that_does_not_exist_chooses_nothing() {
        let mut select = select();
        select.set_selected(Some(9));
        assert_eq!(select.selected(), None);

        select.set_selected(Some(2));
        select.set_options(["Bare én"]);
        assert_eq!(select.selected(), None, "a shorter list drops it");
    }

    /// A select with no options is not a tab stop: there is nothing to open.
    #[test]
    fn an_empty_select_is_not_a_tab_stop() {
        let empty: Select<u8> = Select::new(Vec::<String>::new(), 1u8);
        assert!(!Widget::<u8>::focusable(&empty));
        assert!(Widget::<u8>::focusable(&select()));
    }

    /// The chevron stays inside the control at every size, and never inverts.
    #[test]
    fn the_chevron_stays_inside_the_control() {
        for bounds in [
            Rect::new(0, 0, 200, 36),
            Rect::new(10, 10, 40, 20),
            Rect::new(0, 0, 8, 8),
            Rect::new(0, 0, 1, 1),
        ] {
            let box_of = chevron_box(bounds, 8);
            assert!(box_of.width >= 0 && box_of.height >= 0, "{bounds:?}");
            assert!(
                box_of.right() <= bounds.right(),
                "{bounds:?}: chevron {box_of:?} escaped right"
            );
            assert!(
                box_of.y >= bounds.y && box_of.bottom() <= bounds.bottom(),
                "{bounds:?}: chevron {box_of:?} escaped vertically"
            );
        }
    }

    /// The text column stops before the chevron, so a long option is clipped
    /// rather than drawn through the affordance.
    #[test]
    fn the_text_column_stops_before_the_chevron() {
        let bounds = Rect::new(0, 0, 200, 36);
        let pad = padding(16);
        let chevron = chevron_box(bounds, pad);
        let text = Rect::from_edges(
            bounds.x + pad,
            bounds.y,
            (chevron.x - pad).max(bounds.x + pad),
            bounds.bottom(),
        );
        assert!(text.width > 0);
        assert!(
            text.right() <= chevron.x,
            "the text runs into the chevron: {text:?} {chevron:?}"
        );
    }

    /// The placeholder is de-emphasised and a value is not — and both stay
    /// readable in every theme, through the shared `muted`.
    #[test]
    fn the_placeholder_is_muted_but_still_readable() {
        use denise::Theme;
        use denise::theme::{AA_LARGE, contrast_x100};

        for theme in Theme::BUILT_IN {
            for state in [VisualState::NONE, VisualState::DISABLED] {
                let (surface, content) = interactive_pair(&theme, Role::Base100, state);
                let placeholder = muted(surface, content);
                let ratio = contrast_x100(surface, placeholder);
                assert!(
                    ratio >= AA_LARGE,
                    "{} {state:?}: placeholder is {ratio}, floor is {AA_LARGE}",
                    theme.name
                );
            }
            // Enabled, it is visibly quieter than a chosen value.
            let (surface, content) = interactive_pair(&theme, Role::Base100, VisualState::NONE);
            assert_ne!(muted(surface, content), content, "{}", theme.name);
        }
    }
}