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