| 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 |
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 |
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 |
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 |
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 |
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
|