Skip to main content

makeover_immediate/
widget.rs

1//! The described things that are not fields, tables or frames.
2//!
3//! A meter, a token, a control, a figure, and a wait. `makeover-tui` has had
4//! most of these since its
5//! own `widget` module and this crate has not, which is the gap that showed up
6//! the moment anything tried to draw a whole `quasi_router::Screen` in egui:
7//! the screen walk had a renderer for the containers and nothing for four of the
8//! nodes inside them, so the drawing would have landed in the consumer, one copy
9//! per app. That is the divergence this suite exists to end, so it lands here.
10//!
11//! # What "in egui" changes, and what it does not
12//!
13//! The semantics are `makeover-tui`'s, deliberately: a meter is a bar and a
14//! reading, a badge is round and a chip is square, a control names its key where
15//! the description gave one, and a figure puts the movement on the value rather
16//! than on the caption. Those are description-level readings and they do not get
17//! a second opinion per host.
18//!
19//! What differs is forced by the target rather than chosen. A terminal spends a
20//! whole cell on a character and returns a `Line` for the caller to place; egui
21//! paints an arbitrary rect and answers a [`Response`], so every function here
22//! draws into the `Ui` it is given and hands back what the user did to it. That
23//! is also why nothing here takes a `focused` flag the way `makeover-tui`'s
24//! `act` does: egui owns focus, which is the rule the crate header states.
25
26use egui::{Align, Layout, Response, RichText, Sense, Ui, Vec2};
27use makeover_layout::{Act, Awaiting, Figure, Meter, State, Token, Tone};
28use makeover_timing::activity_blink;
29use std::time::Duration;
30
31use crate::Palette;
32
33/// The sizes a widget cannot derive from the description.
34///
35/// Every number a caller might reasonably want different, in one place, on the
36/// footing [`FrameStyle`](crate::FrameStyle) and [`FieldStyle`](crate::FieldStyle)
37/// already establish: this crate owns no sizes.
38#[derive(Debug, Clone, Copy, PartialEq)]
39pub struct WidgetStyle {
40    /// How tall a meter's bar is drawn.
41    pub meter_height: f32,
42    /// How wide a meter's bar runs, or `None` to take the width on offer.
43    ///
44    /// `None` is the honest default in immediate mode: a bar in a side panel and
45    /// a bar in a wide pane are the same description, and the available width is
46    /// the only thing either of them knows.
47    pub meter_width: Option<f32>,
48    /// The corner radius on a meter's trough and on a token.
49    pub radius: u8,
50    /// Inside a token, around its label.
51    pub token_padding: Vec2,
52    /// Between a figure's value and its caption.
53    pub figure_gap: f32,
54    /// How much larger a figure's value is drawn than the body text.
55    ///
56    /// A multiplier rather than a size, so a figure scales with whatever text
57    /// style the app has set rather than pinning a point size this crate has no
58    /// business choosing.
59    pub figure_scale: f32,
60    /// The side of the activity mark, square.
61    ///
62    /// Small on purpose. The mark says one thing and a reader should have to
63    /// look at it to read it, which is the difference between an indicator and
64    /// an animation competing with the content it sits beside.
65    pub mark_size: f32,
66}
67
68impl Default for WidgetStyle {
69    /// Bars at 6pt taking the width on offer, a figure at double text size, and
70    /// the activity mark a square a little larger than a bar is tall.
71    fn default() -> Self {
72        Self {
73            meter_height: 6.0,
74            meter_width: None,
75            radius: 3,
76            token_padding: Vec2::new(6.0, 2.0),
77            figure_gap: 2.0,
78            figure_scale: 2.0,
79            mark_size: 8.0,
80        }
81    }
82}
83
84/// A proportion as a bar and a reading.
85///
86/// The reading is built here from the two numbers and the noun, for the reason
87/// `makeover-tui` states: [`Meter::label`] carries the noun alone, so each
88/// renderer picks its own sentence order rather than the description picking one
89/// for all of them.
90///
91/// **A bar that has run over is drawn full and reads over.** `done` may exceed
92/// `total` and that is the case worth drawing, per `Meter`'s own docs: the fill
93/// is clamped because a rect cannot be longer than itself, and the reading is
94/// not, because "9/6" is the fact the user needs. Clamping both would hide the
95/// overrun entirely, which is the bug goingson's `is_over_estimate` flag exists
96/// to recover from on the other side.
97///
98/// A zero `total` is no set rather than a complete one, so it draws empty.
99pub fn meter(ui: &mut Ui, meter: &Meter<'_>, palette: &Palette, style: &WidgetStyle) -> Response {
100    let width = style
101        .meter_width
102        .unwrap_or_else(|| ui.available_width().max(1.0));
103    ui.horizontal(|ui| {
104        let (rect, response) =
105            ui.allocate_exact_size(Vec2::new(width, style.meter_height), Sense::hover());
106        // The trough is the sunken surface rather than a tint of the tone: a
107        // bar is a thing set into the page with something in it, which is what
108        // `Fill::Sunken` means, and tinting the empty half would read as a
109        // second, paler proportion.
110        ui.painter().rect_filled(rect, style.radius, palette.sunken);
111        let share = if meter.total == 0 {
112            0.0
113        } else {
114            (f64::from(meter.done) / f64::from(meter.total)).min(1.0)
115        };
116        #[expect(
117            clippy::cast_possible_truncation,
118            reason = "a share is 0..=1 and the product is a width in points"
119        )]
120        let filled = (f64::from(rect.width()) * share) as f32;
121        if filled > 0.0 {
122            let mut fill = rect;
123            fill.set_width(filled);
124            ui.painter()
125                .rect_filled(fill, style.radius, palette.tone(meter.tone));
126        }
127        let reading = match meter.label {
128            Some(label) => format!("{}/{} {label}", meter.done, meter.total),
129            None => format!("{}/{}", meter.done, meter.total),
130        };
131        ui.label(RichText::new(reading).color(palette.content_muted));
132        response
133    })
134    .inner
135}
136
137/// A badge or a chip.
138///
139/// Round for a badge, square for a chip, which is `makeover-tui`'s reading and
140/// `makeover-webview`'s before it. The shape carries the difference because
141/// colour is already spent on the tone.
142///
143/// **A chip answers a click and a badge does not**, which is
144/// [`Token::interactive`] and is the whole difference between the members. The
145/// `Response` comes back either way, so a caller that presses a badge is
146/// pressing something this function said was not interactive; the sense is what
147/// makes egui agree.
148///
149/// `latched` is a chip that is switched on, and it fills rather than outlines. A
150/// terminal has to collide latched with focus because it has one spare axis for
151/// two facts; egui does not, so it does not.
152///
153/// A chip's removable half is not drawn, on `makeover-tui`'s reasoning: a second
154/// control inside a token is a question for whoever owns the interaction rather
155/// than for a drawing.
156pub fn token(
157    ui: &mut Ui,
158    label: &str,
159    kind: Token,
160    tone: Tone,
161    latched: bool,
162    palette: &Palette,
163    style: &WidgetStyle,
164) -> Response {
165    let painted = palette.tone(tone);
166    let radius = match kind {
167        // Round enough to read as a pill whatever the height turns out to be.
168        Token::Badge => u8::MAX,
169        Token::Chip { .. } => style.radius,
170    };
171    let sense = if kind.interactive() {
172        Sense::click()
173    } else {
174        Sense::hover()
175    };
176
177    // Laid out before the rect is allocated, because a token is exactly as wide
178    // as what it says plus its padding: there is no box to fit text into here,
179    // the way a table cell has one.
180    let ink = if latched { palette.page } else { painted };
181    let galley = ui.painter().layout_no_wrap(
182        label.to_owned(),
183        egui::TextStyle::Body.resolve(ui.style()),
184        ink,
185    );
186    let size = galley.size() + style.token_padding * 2.0;
187    let (rect, response) = ui.allocate_exact_size(size, sense);
188
189    if latched {
190        ui.painter().rect_filled(rect, radius, painted);
191    } else {
192        ui.painter().rect_stroke(
193            rect,
194            radius,
195            egui::Stroke::new(1.0, painted),
196            egui::StrokeKind::Inside,
197        );
198    }
199    ui.painter()
200        .galley(rect.center() - galley.size() / 2.0, galley, ink);
201
202    // Say what was drawn, because painting it says nothing.
203    //
204    // A token allocates its rect and paints the text straight onto it, so
205    // nothing reached the accessibility tree at all until 2026-08-22: an
206    // interactive chip was a control a mouse could press and a screen reader
207    // could not find, and a badge was text nobody could read out. The filter
208    // panel's twenty-four key pills were the site -- a whole way of filtering,
209    // absent.
210    //
211    // A chip that latches says so through `selected`, which is what a screen
212    // reader announces as pressed. That is `latched`'s whole meaning: the key
213    // is held down.
214    let role = if kind.interactive() {
215        egui::WidgetType::Button
216    } else {
217        egui::WidgetType::Label
218    };
219    response.widget_info(|| {
220        let mut info = egui::WidgetInfo::labeled(role, ui.is_enabled(), label);
221        if kind.interactive() {
222            info.selected = Some(latched);
223        }
224        info
225    });
226    response
227}
228
229/// A control.
230///
231/// The key the description named is drawn beside the label where there is one,
232/// which is [`Act::key`] finally being read by a second renderer: it was written
233/// for a terminal, and a desktop app has keys too.
234///
235/// **A disabled control is drawn and does not answer**, through
236/// [`State::suppresses_interaction`] rather than a second reading of what
237/// disabled means, and it takes [`Palette::content_muted`] because that is the
238/// intent `State::Disabled` resolves to. egui is told through `add_enabled`, so
239/// its own focus walk skips it: a control that is drawn and not reachable is
240/// exactly what `disabled` means on every host, and here the host already has
241/// the machinery.
242pub fn act(ui: &mut Ui, act: &Act<'_>, palette: &Palette, _style: &WidgetStyle) -> Response {
243    let disabled = act.state.is_some_and(State::suppresses_interaction);
244    let label = match act.key {
245        Some(key) => format!("{}  ({key})", act.label),
246        None => act.label.to_owned(),
247    };
248    let colour = if disabled {
249        palette.content_muted
250    } else {
251        palette.tone(act.tone)
252    };
253    let drawn = ui.add_enabled(
254        !disabled,
255        egui::Button::new(RichText::new(label).color(colour)),
256    );
257    // Standing help, as a hover, which is honest on this host in a way it is
258    // not on a terminal: egui has a pointer. `makeover_tui` says the same
259    // sentence as a muted row under the control.
260    //
261    // Drawn here rather than by the caller as of `Act::hint` (0.40.0). quasi's
262    // egui renderer was doing exactly this outside the widget because
263    // `layout::Act` carried no hint, so a host that was not quasi got nothing.
264    match act.hint {
265        Some(hint) => drawn.on_hover_text(hint),
266        None => drawn,
267    }
268}
269
270/// A figure: the value, then what it counts under it.
271///
272/// The tone lands on the value and its change rather than on the caption, which
273/// is what [`Figure::tone`] means: the figure is an ordinary fact and it is the
274/// movement that reads as good or bad. `makeover-tui` says the same thing with a
275/// bold span; here it is a larger one, because egui can size text and a terminal
276/// cannot.
277pub fn figure(
278    ui: &mut Ui,
279    figure: &Figure<'_>,
280    palette: &Palette,
281    style: &WidgetStyle,
282) -> Response {
283    ui.with_layout(Layout::top_down(Align::Min), |ui| {
284        let value = match figure.change {
285            Some(change) => format!("{} {change}", figure.value),
286            None => figure.value.to_owned(),
287        };
288        let size = egui::TextStyle::Body.resolve(ui.style()).size * style.figure_scale;
289        let shown = ui.label(
290            RichText::new(value)
291                .color(palette.tone(figure.tone))
292                .size(size)
293                .strong(),
294        );
295        ui.add_space(style.figure_gap);
296        ui.label(RichText::new(figure.caption).color(palette.content_muted));
297        shown
298    })
299    .inner
300}
301
302/// What a host can see about a wait that is running.
303///
304/// Both halves are optional because both are the host's to observe and neither
305/// is derivable from the description. `makeover_layout::Awaiting` says how big
306/// the payload is; nothing in a description can say how much of it has landed,
307/// because that is a fact about a transfer in flight.
308#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
309pub struct Progress {
310    /// How much has arrived, in whatever unit the description counted.
311    ///
312    /// `None` means nothing is watching the transfer, which is the common case
313    /// and is what keeps the bar from being drawn out of a total alone.
314    pub delivered: Option<u64>,
315    /// How long the wait has lasted so far.
316    ///
317    /// The one time value a wait is allowed to show. Never a remaining time and
318    /// never a rate: see [`awaiting`].
319    pub elapsed: Option<Duration>,
320}
321
322/// The activity mark: one small square, blinking.
323///
324/// Rule 2 of wiki `loading-and-progress-standard`, and the thing that replaced
325/// `Ui::spinner` here. A spinner turns at a rate it invented and reads as
326/// progress; this claims nothing beyond "something is happening", which is the
327/// whole of what an unmeasured wait knows.
328///
329/// **`reduced` stills the mark rather than removing it.** egui has no
330/// `prefers-reduced-motion`, so the preference arrives as a bool from whatever
331/// the host asked its own platform, exactly as `makeover_timing::activity_blink`
332/// is shaped for. A still mark still says something is happening; hiding it
333/// would answer a request nobody made.
334///
335/// The cadence is `makeover_timing::Cadence::Activity` and is not a number this
336/// crate chooses, so a browser, a terminal and an egui window blink together.
337///
338/// Repaint is asked for at the next flip rather than every frame: a blinking
339/// mark should not turn a window that is otherwise idle into one that renders
340/// continuously.
341pub fn activity(ui: &mut Ui, reduced: bool, palette: &Palette, style: &WidgetStyle) -> Response {
342    let (rect, response) = ui.allocate_exact_size(Vec2::splat(style.mark_size), Sense::hover());
343    let lit = match activity_blink(reduced) {
344        // Still, and lit. The state the mark holds when nothing may move.
345        None => true,
346        Some(half) => {
347            let half = half.as_secs_f64();
348            // A cadence of zero would divide by nothing and blink infinitely
349            // fast, which is the one value the token cannot mean.
350            if half <= 0.0 {
351                true
352            } else {
353                let phase = ui.input(|input| input.time).rem_euclid(half * 2.0);
354                let lit = phase < half;
355                let next = if lit { half } else { half * 2.0 } - phase;
356                ui.ctx()
357                    .request_repaint_after(Duration::from_secs_f64(next.max(0.0)));
358                lit
359            }
360        }
361    };
362    // Lit is the accent, dark is the trough it sits in. Not "drawn and not
363    // drawn": a mark that vanishes half the time is a hole in the layout, and
364    // the reader loses where to look between blinks.
365    let colour = if lit { palette.action } else { palette.sunken };
366    ui.painter().rect_filled(rect, style.radius, colour);
367    response
368}
369
370/// A wait, drawn from what is actually known about it.
371///
372/// The branch is `Awaiting::is_determinate` and one more question the
373/// description cannot answer: whether anything is watching the transfer. A bar
374/// needs both a total and a numerator, so a described amount with no
375/// [`Progress::delivered`] beside it draws the mark, not an empty trough that
376/// implies someone is counting.
377///
378/// **What the bar may not do**, from rule 1 of wiki
379/// `loading-and-progress-standard` and from `Awaiting`'s own docs: what is done
380/// over what there is, plus the time it has taken. Never a remaining time, an
381/// arrival time, or a rate extrapolated forward. A prediction is wrong the
382/// moment the transfer stalls, and being confidently wrong is worse than being
383/// honestly indeterminate.
384///
385/// The reading is the two raw numbers, as [`meter`] does it. The unit is the
386/// app's — bytes for an upload, rows for an import — and a renderer that
387/// guessed at one would be formatting a quantity it was deliberately not told
388/// about.
389pub fn awaiting(
390    ui: &mut Ui,
391    awaiting: Awaiting,
392    progress: Progress,
393    reduced: bool,
394    palette: &Palette,
395    style: &WidgetStyle,
396) -> Response {
397    let (Some(total), Some(done)) = (awaiting.amount, progress.delivered) else {
398        return activity(ui, reduced, palette, style);
399    };
400    let width = style
401        .meter_width
402        .unwrap_or_else(|| ui.available_width().max(1.0));
403    ui.horizontal(|ui| {
404        let (rect, response) =
405            ui.allocate_exact_size(Vec2::new(width, style.meter_height), Sense::hover());
406        ui.painter().rect_filled(rect, style.radius, palette.sunken);
407        // A total of zero is no payload rather than a finished one, which is
408        // `meter`'s reading of the same case. Over-delivery clamps for the same
409        // reason it does there: a rect cannot be longer than itself.
410        let share = if total == 0 {
411            0.0
412        } else {
413            #[expect(
414                clippy::cast_precision_loss,
415                reason = "a byte count past 2^53 is not a wait anyone is watching a bar for"
416            )]
417            let share = (done as f64 / total as f64).min(1.0);
418            share
419        };
420        #[expect(
421            clippy::cast_possible_truncation,
422            reason = "a share is 0..=1 and the product is a width in points"
423        )]
424        let filled = (f64::from(rect.width()) * share) as f32;
425        if filled > 0.0 {
426            let mut fill = rect;
427            fill.set_width(filled);
428            ui.painter().rect_filled(fill, style.radius, palette.action);
429        }
430        let reading = match progress.elapsed {
431            Some(elapsed) => format!("{done}/{total}  {}s", elapsed.as_secs()),
432            None => format!("{done}/{total}"),
433        };
434        ui.label(RichText::new(reading).color(palette.content_muted));
435        response
436    })
437    .inner
438}
439
440#[cfg(test)]
441mod tests {
442    use super::*;
443
444    /// What the accessibility tree says a widget drew.
445    fn announced(
446        draw: impl FnMut(&mut Ui),
447    ) -> Vec<(
448        egui::accesskit::Role,
449        String,
450        Option<egui::accesskit::Toggled>,
451    )> {
452        let ctx = egui::Context::default();
453        ctx.enable_accesskit();
454        let mut draw = draw;
455        let input = || egui::RawInput {
456            screen_rect: Some(egui::Rect::from_min_size(
457                egui::Pos2::ZERO,
458                egui::vec2(600.0, 400.0),
459            )),
460            ..Default::default()
461        };
462        let _ = ctx.run_ui(input(), &mut draw);
463        let out = ctx.run_ui(input(), &mut draw);
464        out.platform_output
465            .accesskit_update
466            .expect("accesskit is on")
467            .nodes
468            .iter()
469            .map(|(_, node)| {
470                (
471                    node.role(),
472                    node.label()
473                        .or_else(|| node.value())
474                        .unwrap_or_default()
475                        .to_owned(),
476                    node.toggled(),
477                )
478            })
479            .collect()
480    }
481
482    #[test]
483    fn a_chip_is_announced_as_a_control_and_says_whether_it_is_held() {
484        // A token paints its own text onto its own rect, so before 2026-08-22
485        // it reached the tree as nothing: pressable by a mouse and invisible to
486        // everything else.
487        let p = palette();
488        let style = WidgetStyle::default();
489        let drawn = announced(|ui| {
490            token(
491                ui,
492                "C#",
493                Token::Chip { removable: false },
494                Tone::Neutral,
495                true,
496                &p,
497                &style,
498            );
499        });
500
501        let chip = drawn
502            .iter()
503            .find(|(role, name, _)| *role == egui::accesskit::Role::Button && name == "C#")
504            .unwrap_or_else(|| panic!("the chip is not in the tree: {drawn:?}"));
505        assert_eq!(
506            chip.2,
507            Some(egui::accesskit::Toggled::True),
508            "a latched chip is held down and says so: {drawn:?}"
509        );
510    }
511
512    #[test]
513    fn a_badge_is_announced_as_the_text_it_is() {
514        // Not a control, and not nothing either: a badge is a word on the
515        // screen and painting it is not the same as saying it.
516        let p = palette();
517        let style = WidgetStyle::default();
518        let drawn = announced(|ui| {
519            token(ui, "wav", Token::Badge, Tone::Neutral, false, &p, &style);
520        });
521
522        assert!(
523            drawn
524                .iter()
525                .any(|(role, name, _)| *role == egui::accesskit::Role::Label && name == "wav"),
526            "{drawn:?}"
527        );
528        assert!(
529            !drawn
530                .iter()
531                .any(|(role, _, _)| *role == egui::accesskit::Role::Button),
532            "a badge answers nothing and must not claim to: {drawn:?}"
533        );
534    }
535
536    fn palette() -> Palette {
537        use egui::Color32;
538        Palette {
539            page: Color32::from_rgb(1, 1, 1),
540            raised: Color32::from_rgb(2, 2, 2),
541            overlay: Color32::from_rgb(3, 3, 3),
542            well: Color32::from_rgb(4, 4, 4),
543            sunken: Color32::from_rgb(5, 5, 5),
544            bevel_light: Color32::from_rgb(6, 6, 6),
545            bevel_dark: Color32::from_rgb(7, 7, 7),
546            elevation: Color32::from_black_alpha(40),
547            content: Color32::from_rgb(20, 20, 20),
548            content_secondary: Color32::from_rgb(120, 120, 120),
549            content_muted: Color32::from_rgb(21, 21, 21),
550            action: Color32::from_rgb(22, 22, 22),
551            danger: Color32::from_rgb(23, 23, 23),
552            success: Color32::from_rgb(24, 24, 24),
553            warning: Color32::from_rgb(25, 25, 25),
554            info: Color32::from_rgb(26, 26, 26),
555        }
556    }
557
558    #[test]
559    fn every_tone_resolves_and_no_two_share_a_colour() {
560        // The reason the three status intents arrived together: a resolver
561        // missing one has to invent a colour for it.
562        let p = palette();
563        let all = [
564            p.tone(Tone::Neutral),
565            p.tone(Tone::Info),
566            p.tone(Tone::Success),
567            p.tone(Tone::Warning),
568            p.tone(Tone::Danger),
569        ];
570        for (i, a) in all.iter().enumerate() {
571            for b in &all[i + 1..] {
572                assert_ne!(a, b, "two tones resolved to one colour");
573            }
574        }
575        assert_eq!(p.tone(Tone::Neutral), p.content, "neutral is ordinary text");
576    }
577
578    #[test]
579    fn a_meter_draws_and_an_overrun_does_not_panic() {
580        // `done` may exceed `total`, which is the case Meter's own docs call
581        // the one worth drawing. The fill clamps; the reading does not.
582        let p = palette();
583        let style = WidgetStyle::default();
584        egui::__run_test_ui(|ui| {
585            meter(ui, &Meter::new(3, 6), &p, &style);
586            meter(ui, &Meter::new(9, 6), &p, &style);
587            // No set, rather than a complete one.
588            meter(ui, &Meter::new(0, 0), &p, &style);
589            // The overflow `makeover-layout` pins on its own side.
590            meter(ui, &Meter::new(u32::MAX, u32::MAX), &p, &style);
591        });
592    }
593
594    #[test]
595    fn a_wait_draws_a_bar_only_when_something_is_counting_it() {
596        // The described total is half of what a bar needs. Without a numerator
597        // the honest drawing is the mark, not an empty trough implying that
598        // someone is watching bytes land.
599        let p = palette();
600        let style = WidgetStyle::default();
601        egui::__run_test_ui(|ui| {
602            awaiting(
603                ui,
604                Awaiting::unmeasured(),
605                Progress::default(),
606                false,
607                &p,
608                &style,
609            );
610            awaiting(
611                ui,
612                Awaiting::of(41_943_040),
613                Progress::default(),
614                false,
615                &p,
616                &style,
617            );
618            awaiting(
619                ui,
620                Awaiting::of(41_943_040),
621                Progress {
622                    delivered: Some(10_485_760),
623                    elapsed: Some(Duration::from_secs(3)),
624                },
625                false,
626                &p,
627                &style,
628            );
629            // A zero payload is no payload, and over-delivery clamps.
630            awaiting(
631                ui,
632                Awaiting::of(0),
633                Progress {
634                    delivered: Some(9),
635                    elapsed: None,
636                },
637                false,
638                &p,
639                &style,
640            );
641            awaiting(
642                ui,
643                Awaiting::of(4),
644                Progress {
645                    delivered: Some(9),
646                    elapsed: None,
647                },
648                false,
649                &p,
650                &style,
651            );
652        });
653    }
654
655    #[test]
656    fn reduced_motion_stills_the_mark_and_does_not_remove_it() {
657        // `activity_blink(true)` is None, which means lit and still. A renderer
658        // that drew nothing would have answered a request nobody made.
659        let p = palette();
660        let style = WidgetStyle::default();
661        egui::__run_test_ui(|ui| {
662            let still = activity(ui, true, &p, &style);
663            let blinking = activity(ui, false, &p, &style);
664            assert_eq!(
665                still.rect.size(),
666                blinking.rect.size(),
667                "the mark occupies the same space either way"
668            );
669        });
670    }
671
672    #[test]
673    fn a_chip_answers_a_click_and_a_badge_does_not() {
674        // `Token::interactive` is the whole difference between the members, and
675        // the sense is what makes egui agree with it.
676        let p = palette();
677        let style = WidgetStyle::default();
678        egui::__run_test_ui(|ui| {
679            let badge = token(ui, "beta", Token::Badge, Tone::Info, false, &p, &style);
680            assert!(!badge.sense.senses_click(), "a badge answers no click");
681
682            let chip = token(
683                ui,
684                "drums",
685                Token::Chip { removable: false },
686                Tone::Neutral,
687                false,
688                &p,
689                &style,
690            );
691            assert!(chip.sense.senses_click(), "a chip answers a click");
692        });
693    }
694
695    #[test]
696    fn a_disabled_control_is_drawn_and_does_not_answer() {
697        // Present, visible, and not answering. Through
698        // `State::suppresses_interaction` rather than a second reading here.
699        let p = palette();
700        let style = WidgetStyle::default();
701        egui::__run_test_ui(|ui| {
702            let live = act(ui, &Act::new("Save"), &p, &style);
703            assert!(live.enabled());
704
705            let gone = act(ui, &Act::new("Save").state(State::Disabled), &p, &style);
706            assert!(!gone.enabled(), "a disabled control still answers");
707        });
708    }
709
710    #[test]
711    fn a_control_shows_the_key_the_description_named() {
712        // `Act::key` was written for a terminal before there was one. A desktop
713        // app has keys too, so this is its second reader.
714        let p = palette();
715        let style = WidgetStyle::default();
716        egui::__run_test_ui(|ui| {
717            act(ui, &Act::new("New").key("n"), &p, &style);
718            act(ui, &Act::new("New"), &p, &style);
719        });
720    }
721
722    #[test]
723    fn a_figure_draws_its_movement_beside_its_value() {
724        let p = palette();
725        let style = WidgetStyle::default();
726        egui::__run_test_ui(|ui| {
727            figure(ui, &Figure::new("17", "Current streak"), &p, &style);
728            figure(
729                ui,
730                &Figure::new("17", "Current streak")
731                    .change("+3")
732                    .tone(Tone::Success),
733                &p,
734                &style,
735            );
736        });
737    }
738}