Skip to main content

max / makeover-immediate

0.36.0: a wait is a mark that blinks, not an arc that spins `Ui::spinner` was the only true rotating animation in the tree, and `quasi-immediate` drew it for `Readiness::Pending` unconditionally: a region with a measured payload behind it and one with nothing known about it got the same turning arc. A spinner turns at a rate it invented and reads as progress, which is the claim an unmeasured wait has not earned. `activity` is rule 2 of wiki `loading-and-progress-standard`: one small square, lit or dark, saying that something is happening and nothing more. `awaiting` is the branch above it, and it asks one question the description cannot answer -- whether anything is watching the transfer. `Awaiting` carries the payload size; how much of it has landed is a fact about bytes in flight and only the host knows it. So a described amount with no `Progress::delivered` beside it draws the mark rather than an empty trough implying somebody is counting. The bar shows what is done over what there is, plus elapsed, and cannot express a remaining time, an arrival time or a rate. Rule 1, and `Awaiting`'s own docs before it. Takes `makeover-timing` for the cadence rather than picking one. Three renderers draw this mark; a number chosen per renderer is three heartbeats for one wait. egui has no `prefers-reduced-motion` to read, so the preference arrives as the bool `activity_blink` is shaped for, and it stills the mark rather than removing it. Breaking on `WidgetStyle`, which gained `mark_size`.
Author: Max Johnson <me@maxj.phd> · 2026-08-26 19:41 UTC
Signed with PGP, not checked
Commit: 4d90466b5d0adbc49bd71afcbfad48d26b607028
Parent: 5cc8136
2 files changed, +235 insertions, -4 deletions
M Cargo.toml +5 -1
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "makeover-immediate"
3 - version = "0.35.0"
3 + version = "0.36.0"
4 4 edition = "2024"
5 5 description = "The immediate-mode renderer for makeover-layout. Immediate mode is the constraint that matters, not the library: no cascade, no retained tree, one stroke per widget. Backed by egui."
6 6 license = "MIT"
@@ -13,6 +13,10 @@
13 13 # minor-only requirement is satisfied by a consumer lock holding an earlier
14 14 # patch, which then fails to compile against an API added in a later one.
15 15 makeover-layout = "0.35.0"
16 + # The cadence the activity mark blinks at, and the motion-off seam beside it.
17 + # Taken rather than chosen here: three renderers draw this mark and a number
18 + # picked per renderer is three heartbeats for one wait.
19 + makeover-timing = "0.1.1"
16 20
17 21 [lints.rust]
18 22 unused = "warn"
M src/widget.rs +230 -3
@@ -1,6 +1,7 @@
1 1 //! The described things that are not fields, tables or frames.
2 2 //!
3 - //! A meter, a token, a control, a figure. `makeover-tui` has had these since its
3 + //! A meter, a token, a control, a figure, and a wait. `makeover-tui` has had
4 + //! most of these since its
4 5 //! own `widget` module and this crate has not, which is the gap that showed up
5 6 //! the moment anything tried to draw a whole `quasi_router::Screen` in egui:
6 7 //! the screen walk had a renderer for the containers and nothing for four of the
@@ -23,7 +24,9 @@
23 24 //! `act` does: egui owns focus, which is the rule the crate header states.
24 25
25 26 use egui::{Align, Layout, Response, RichText, Sense, Ui, Vec2};
26 - use makeover_layout::{Act, Figure, Meter, State, Token, Tone};
27 + use makeover_layout::{Act, Awaiting, Figure, Meter, State, Token, Tone};
28 + use makeover_timing::activity_blink;
29 + use std::time::Duration;
27 30
28 31 use crate::Palette;
29 32
@@ -54,10 +57,17 @@
54 57 /// style the app has set rather than pinning a point size this crate has no
55 58 /// business choosing.
56 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,
57 66 }
58 67
59 68 impl Default for WidgetStyle {
60 - /// Bars at 6pt taking the width on offer, and a figure at double text size.
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.
61 71 fn default() -> Self {
62 72 Self {
63 73 meter_height: 6.0,
@@ -66,6 +76,7 @@
66 76 token_padding: Vec2::new(6.0, 2.0),
67 77 figure_gap: 2.0,
68 78 figure_scale: 2.0,
79 + mark_size: 8.0,
69 80 }
70 81 }
71 82 }
@@ -277,6 +288,144 @@
277 288 .inner
278 289 }
279 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 +
280 429 #[cfg(test)]
281 430 mod tests {
282 431 use super::*;
@@ -431,6 +580,84 @@
431 580 });
432 581 }
433 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 +
434 661 #[test]
435 662 fn a_chip_answers_a_click_and_a_badge_does_not() {
436 663 // `Token::interactive` is the whole difference between the members, and