Skip to main content

max / makeover-immediate

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