Skip to main content

max / makeover-immediate

31.6 KB · 828 lines History Blame Raw
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
26 use egui::{Align, Layout, Response, RichText, Sense, Ui, Vec2};
27 use makeover_layout::{Act, Awaiting, Bar, Chart, Figure, Meter, State, Token, Tone};
28 use makeover_timing::activity_blink;
29 use std::time::Duration;
30
31 use 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)]
39 pub 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
75 impl 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.
107 pub 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.
164 pub 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.
250 pub 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.
285 pub 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)]
317 pub 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.
349 pub 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.
397 pub 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.
465 pub 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)]
530 mod 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 }
828