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