Skip to main content

max / makeover-immediate

27.4 KB · 728 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 ui.add_enabled(
254 !disabled,
255 egui::Button::new(RichText::new(label).color(colour)),
256 )
257 }
258
259 /// A figure: the value, then what it counts under it.
260 ///
261 /// The tone lands on the value and its change rather than on the caption, which
262 /// is what [`Figure::tone`] means: the figure is an ordinary fact and it is the
263 /// movement that reads as good or bad. `makeover-tui` says the same thing with a
264 /// bold span; here it is a larger one, because egui can size text and a terminal
265 /// cannot.
266 pub fn figure(
267 ui: &mut Ui,
268 figure: &Figure<'_>,
269 palette: &Palette,
270 style: &WidgetStyle,
271 ) -> Response {
272 ui.with_layout(Layout::top_down(Align::Min), |ui| {
273 let value = match figure.change {
274 Some(change) => format!("{} {change}", figure.value),
275 None => figure.value.to_owned(),
276 };
277 let size = egui::TextStyle::Body.resolve(ui.style()).size * style.figure_scale;
278 let shown = ui.label(
279 RichText::new(value)
280 .color(palette.tone(figure.tone))
281 .size(size)
282 .strong(),
283 );
284 ui.add_space(style.figure_gap);
285 ui.label(RichText::new(figure.caption).color(palette.content_muted));
286 shown
287 })
288 .inner
289 }
290
291 /// What a host can see about a wait that is running.
292 ///
293 /// Both halves are optional because both are the host's to observe and neither
294 /// is derivable from the description. `makeover_layout::Awaiting` says how big
295 /// the payload is; nothing in a description can say how much of it has landed,
296 /// because that is a fact about a transfer in flight.
297 #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
298 pub struct Progress {
299 /// How much has arrived, in whatever unit the description counted.
300 ///
301 /// `None` means nothing is watching the transfer, which is the common case
302 /// and is what keeps the bar from being drawn out of a total alone.
303 pub delivered: Option<u64>,
304 /// How long the wait has lasted so far.
305 ///
306 /// The one time value a wait is allowed to show. Never a remaining time and
307 /// never a rate: see [`awaiting`].
308 pub elapsed: Option<Duration>,
309 }
310
311 /// The activity mark: one small square, blinking.
312 ///
313 /// Rule 2 of wiki `loading-and-progress-standard`, and the thing that replaced
314 /// `Ui::spinner` here. A spinner turns at a rate it invented and reads as
315 /// progress; this claims nothing beyond "something is happening", which is the
316 /// whole of what an unmeasured wait knows.
317 ///
318 /// **`reduced` stills the mark rather than removing it.** egui has no
319 /// `prefers-reduced-motion`, so the preference arrives as a bool from whatever
320 /// the host asked its own platform, exactly as `makeover_timing::activity_blink`
321 /// is shaped for. A still mark still says something is happening; hiding it
322 /// would answer a request nobody made.
323 ///
324 /// The cadence is `makeover_timing::Cadence::Activity` and is not a number this
325 /// crate chooses, so a browser, a terminal and an egui window blink together.
326 ///
327 /// Repaint is asked for at the next flip rather than every frame: a blinking
328 /// mark should not turn a window that is otherwise idle into one that renders
329 /// continuously.
330 pub fn activity(ui: &mut Ui, reduced: bool, palette: &Palette, style: &WidgetStyle) -> Response {
331 let (rect, response) = ui.allocate_exact_size(Vec2::splat(style.mark_size), Sense::hover());
332 let lit = match activity_blink(reduced) {
333 // Still, and lit. The state the mark holds when nothing may move.
334 None => true,
335 Some(half) => {
336 let half = half.as_secs_f64();
337 // A cadence of zero would divide by nothing and blink infinitely
338 // fast, which is the one value the token cannot mean.
339 if half <= 0.0 {
340 true
341 } else {
342 let phase = ui.input(|input| input.time).rem_euclid(half * 2.0);
343 let lit = phase < half;
344 let next = if lit { half } else { half * 2.0 } - phase;
345 ui.ctx()
346 .request_repaint_after(Duration::from_secs_f64(next.max(0.0)));
347 lit
348 }
349 }
350 };
351 // Lit is the accent, dark is the trough it sits in. Not "drawn and not
352 // drawn": a mark that vanishes half the time is a hole in the layout, and
353 // the reader loses where to look between blinks.
354 let colour = if lit { palette.action } else { palette.sunken };
355 ui.painter().rect_filled(rect, style.radius, colour);
356 response
357 }
358
359 /// A wait, drawn from what is actually known about it.
360 ///
361 /// The branch is `Awaiting::is_determinate` and one more question the
362 /// description cannot answer: whether anything is watching the transfer. A bar
363 /// needs both a total and a numerator, so a described amount with no
364 /// [`Progress::delivered`] beside it draws the mark, not an empty trough that
365 /// implies someone is counting.
366 ///
367 /// **What the bar may not do**, from rule 1 of wiki
368 /// `loading-and-progress-standard` and from `Awaiting`'s own docs: what is done
369 /// over what there is, plus the time it has taken. Never a remaining time, an
370 /// arrival time, or a rate extrapolated forward. A prediction is wrong the
371 /// moment the transfer stalls, and being confidently wrong is worse than being
372 /// honestly indeterminate.
373 ///
374 /// The reading is the two raw numbers, as [`meter`] does it. The unit is the
375 /// app's — bytes for an upload, rows for an import — and a renderer that
376 /// guessed at one would be formatting a quantity it was deliberately not told
377 /// about.
378 pub fn awaiting(
379 ui: &mut Ui,
380 awaiting: Awaiting,
381 progress: Progress,
382 reduced: bool,
383 palette: &Palette,
384 style: &WidgetStyle,
385 ) -> Response {
386 let (Some(total), Some(done)) = (awaiting.amount, progress.delivered) else {
387 return activity(ui, reduced, palette, style);
388 };
389 let width = style
390 .meter_width
391 .unwrap_or_else(|| ui.available_width().max(1.0));
392 ui.horizontal(|ui| {
393 let (rect, response) =
394 ui.allocate_exact_size(Vec2::new(width, style.meter_height), Sense::hover());
395 ui.painter().rect_filled(rect, style.radius, palette.sunken);
396 // A total of zero is no payload rather than a finished one, which is
397 // `meter`'s reading of the same case. Over-delivery clamps for the same
398 // reason it does there: a rect cannot be longer than itself.
399 let share = if total == 0 {
400 0.0
401 } else {
402 #[expect(
403 clippy::cast_precision_loss,
404 reason = "a byte count past 2^53 is not a wait anyone is watching a bar for"
405 )]
406 let share = (done as f64 / total as f64).min(1.0);
407 share
408 };
409 #[expect(
410 clippy::cast_possible_truncation,
411 reason = "a share is 0..=1 and the product is a width in points"
412 )]
413 let filled = (f64::from(rect.width()) * share) as f32;
414 if filled > 0.0 {
415 let mut fill = rect;
416 fill.set_width(filled);
417 ui.painter().rect_filled(fill, style.radius, palette.action);
418 }
419 let reading = match progress.elapsed {
420 Some(elapsed) => format!("{done}/{total} {}s", elapsed.as_secs()),
421 None => format!("{done}/{total}"),
422 };
423 ui.label(RichText::new(reading).color(palette.content_muted));
424 response
425 })
426 .inner
427 }
428
429 #[cfg(test)]
430 mod tests {
431 use super::*;
432
433 /// What the accessibility tree says a widget drew.
434 fn announced(
435 draw: impl FnMut(&mut Ui),
436 ) -> Vec<(
437 egui::accesskit::Role,
438 String,
439 Option<egui::accesskit::Toggled>,
440 )> {
441 let ctx = egui::Context::default();
442 ctx.enable_accesskit();
443 let mut draw = draw;
444 let input = || egui::RawInput {
445 screen_rect: Some(egui::Rect::from_min_size(
446 egui::Pos2::ZERO,
447 egui::vec2(600.0, 400.0),
448 )),
449 ..Default::default()
450 };
451 let _ = ctx.run_ui(input(), &mut draw);
452 let out = ctx.run_ui(input(), &mut draw);
453 out.platform_output
454 .accesskit_update
455 .expect("accesskit is on")
456 .nodes
457 .iter()
458 .map(|(_, node)| {
459 (
460 node.role(),
461 node.label()
462 .or_else(|| node.value())
463 .unwrap_or_default()
464 .to_owned(),
465 node.toggled(),
466 )
467 })
468 .collect()
469 }
470
471 #[test]
472 fn a_chip_is_announced_as_a_control_and_says_whether_it_is_held() {
473 // A token paints its own text onto its own rect, so before 2026-08-22
474 // it reached the tree as nothing: pressable by a mouse and invisible to
475 // everything else.
476 let p = palette();
477 let style = WidgetStyle::default();
478 let drawn = announced(|ui| {
479 token(
480 ui,
481 "C#",
482 Token::Chip { removable: false },
483 Tone::Neutral,
484 true,
485 &p,
486 &style,
487 );
488 });
489
490 let chip = drawn
491 .iter()
492 .find(|(role, name, _)| *role == egui::accesskit::Role::Button && name == "C#")
493 .unwrap_or_else(|| panic!("the chip is not in the tree: {drawn:?}"));
494 assert_eq!(
495 chip.2,
496 Some(egui::accesskit::Toggled::True),
497 "a latched chip is held down and says so: {drawn:?}"
498 );
499 }
500
501 #[test]
502 fn a_badge_is_announced_as_the_text_it_is() {
503 // Not a control, and not nothing either: a badge is a word on the
504 // screen and painting it is not the same as saying it.
505 let p = palette();
506 let style = WidgetStyle::default();
507 let drawn = announced(|ui| {
508 token(ui, "wav", Token::Badge, Tone::Neutral, false, &p, &style);
509 });
510
511 assert!(
512 drawn
513 .iter()
514 .any(|(role, name, _)| *role == egui::accesskit::Role::Label && name == "wav"),
515 "{drawn:?}"
516 );
517 assert!(
518 !drawn
519 .iter()
520 .any(|(role, _, _)| *role == egui::accesskit::Role::Button),
521 "a badge answers nothing and must not claim to: {drawn:?}"
522 );
523 }
524
525 fn palette() -> Palette {
526 use egui::Color32;
527 Palette {
528 page: Color32::from_rgb(1, 1, 1),
529 raised: Color32::from_rgb(2, 2, 2),
530 overlay: Color32::from_rgb(3, 3, 3),
531 well: Color32::from_rgb(4, 4, 4),
532 sunken: Color32::from_rgb(5, 5, 5),
533 bevel_light: Color32::from_rgb(6, 6, 6),
534 bevel_dark: Color32::from_rgb(7, 7, 7),
535 elevation: Color32::from_black_alpha(40),
536 content: Color32::from_rgb(20, 20, 20),
537 content_secondary: Color32::from_rgb(120, 120, 120),
538 content_muted: Color32::from_rgb(21, 21, 21),
539 action: Color32::from_rgb(22, 22, 22),
540 danger: Color32::from_rgb(23, 23, 23),
541 success: Color32::from_rgb(24, 24, 24),
542 warning: Color32::from_rgb(25, 25, 25),
543 info: Color32::from_rgb(26, 26, 26),
544 }
545 }
546
547 #[test]
548 fn every_tone_resolves_and_no_two_share_a_colour() {
549 // The reason the three status intents arrived together: a resolver
550 // missing one has to invent a colour for it.
551 let p = palette();
552 let all = [
553 p.tone(Tone::Neutral),
554 p.tone(Tone::Info),
555 p.tone(Tone::Success),
556 p.tone(Tone::Warning),
557 p.tone(Tone::Danger),
558 ];
559 for (i, a) in all.iter().enumerate() {
560 for b in &all[i + 1..] {
561 assert_ne!(a, b, "two tones resolved to one colour");
562 }
563 }
564 assert_eq!(p.tone(Tone::Neutral), p.content, "neutral is ordinary text");
565 }
566
567 #[test]
568 fn a_meter_draws_and_an_overrun_does_not_panic() {
569 // `done` may exceed `total`, which is the case Meter's own docs call
570 // the one worth drawing. The fill clamps; the reading does not.
571 let p = palette();
572 let style = WidgetStyle::default();
573 egui::__run_test_ui(|ui| {
574 meter(ui, &Meter::new(3, 6), &p, &style);
575 meter(ui, &Meter::new(9, 6), &p, &style);
576 // No set, rather than a complete one.
577 meter(ui, &Meter::new(0, 0), &p, &style);
578 // The overflow `makeover-layout` pins on its own side.
579 meter(ui, &Meter::new(u32::MAX, u32::MAX), &p, &style);
580 });
581 }
582
583 #[test]
584 fn a_wait_draws_a_bar_only_when_something_is_counting_it() {
585 // The described total is half of what a bar needs. Without a numerator
586 // the honest drawing is the mark, not an empty trough implying that
587 // someone is watching bytes land.
588 let p = palette();
589 let style = WidgetStyle::default();
590 egui::__run_test_ui(|ui| {
591 awaiting(
592 ui,
593 Awaiting::unmeasured(),
594 Progress::default(),
595 false,
596 &p,
597 &style,
598 );
599 awaiting(
600 ui,
601 Awaiting::of(41_943_040),
602 Progress::default(),
603 false,
604 &p,
605 &style,
606 );
607 awaiting(
608 ui,
609 Awaiting::of(41_943_040),
610 Progress {
611 delivered: Some(10_485_760),
612 elapsed: Some(Duration::from_secs(3)),
613 },
614 false,
615 &p,
616 &style,
617 );
618 // A zero payload is no payload, and over-delivery clamps.
619 awaiting(
620 ui,
621 Awaiting::of(0),
622 Progress {
623 delivered: Some(9),
624 elapsed: None,
625 },
626 false,
627 &p,
628 &style,
629 );
630 awaiting(
631 ui,
632 Awaiting::of(4),
633 Progress {
634 delivered: Some(9),
635 elapsed: None,
636 },
637 false,
638 &p,
639 &style,
640 );
641 });
642 }
643
644 #[test]
645 fn reduced_motion_stills_the_mark_and_does_not_remove_it() {
646 // `activity_blink(true)` is None, which means lit and still. A renderer
647 // that drew nothing would have answered a request nobody made.
648 let p = palette();
649 let style = WidgetStyle::default();
650 egui::__run_test_ui(|ui| {
651 let still = activity(ui, true, &p, &style);
652 let blinking = activity(ui, false, &p, &style);
653 assert_eq!(
654 still.rect.size(),
655 blinking.rect.size(),
656 "the mark occupies the same space either way"
657 );
658 });
659 }
660
661 #[test]
662 fn a_chip_answers_a_click_and_a_badge_does_not() {
663 // `Token::interactive` is the whole difference between the members, and
664 // the sense is what makes egui agree with it.
665 let p = palette();
666 let style = WidgetStyle::default();
667 egui::__run_test_ui(|ui| {
668 let badge = token(ui, "beta", Token::Badge, Tone::Info, false, &p, &style);
669 assert!(!badge.sense.senses_click(), "a badge answers no click");
670
671 let chip = token(
672 ui,
673 "drums",
674 Token::Chip { removable: false },
675 Tone::Neutral,
676 false,
677 &p,
678 &style,
679 );
680 assert!(chip.sense.senses_click(), "a chip answers a click");
681 });
682 }
683
684 #[test]
685 fn a_disabled_control_is_drawn_and_does_not_answer() {
686 // Present, visible, and not answering. Through
687 // `State::suppresses_interaction` rather than a second reading here.
688 let p = palette();
689 let style = WidgetStyle::default();
690 egui::__run_test_ui(|ui| {
691 let live = act(ui, &Act::new("Save"), &p, &style);
692 assert!(live.enabled());
693
694 let gone = act(ui, &Act::new("Save").state(State::Disabled), &p, &style);
695 assert!(!gone.enabled(), "a disabled control still answers");
696 });
697 }
698
699 #[test]
700 fn a_control_shows_the_key_the_description_named() {
701 // `Act::key` was written for a terminal before there was one. A desktop
702 // app has keys too, so this is its second reader.
703 let p = palette();
704 let style = WidgetStyle::default();
705 egui::__run_test_ui(|ui| {
706 act(ui, &Act::new("New").key("n"), &p, &style);
707 act(ui, &Act::new("New"), &p, &style);
708 });
709 }
710
711 #[test]
712 fn a_figure_draws_its_movement_beside_its_value() {
713 let p = palette();
714 let style = WidgetStyle::default();
715 egui::__run_test_ui(|ui| {
716 figure(ui, &Figure::new("17", "Current streak"), &p, &style);
717 figure(
718 ui,
719 &Figure::new("17", "Current streak")
720 .change("+3")
721 .tone(Tone::Success),
722 &p,
723 &style,
724 );
725 });
726 }
727 }
728