Skip to main content

max / makeover-tui

0.35.0: a measured wait and an unmeasured one stop being the same line `quasi-tui`'s `busy_line` set `State::Disabled` on the control and returned, whatever the wait carried, so `Awaiting::of(41_943_040)` and `Awaiting::unmeasured()` produced identical output. The amount was described, carried through the runtime, and dropped at the draw. `piece::awaiting` is three drawings for the three states that actually exist: a blinking cell for an unmeasured wait, the cell and the size for a measured one nothing is watching, and a bar with both numbers when a host is counting delivery. The middle state is the one worth having -- a bar that read full because nobody was counting is the confidently-wrong drawing rule 1 of wiki `loading-and-progress-standard` exists to forbid. The mark reuses `meter_full` and `meter_empty` rather than a third pair of glyphs. A bar's filled cell and a lit mark are the same statement, and a terminal rendering two vocabularies of "on" would be saying there are two kinds of on. Dark rather than absent, so the line does not reflow around a mark that is drawn half the time. `activity_lit` is the one place the phase is worked out, so `piece` stays a pure drawing and this crate still holds no clock. The cadence is `makeover-timing`'s, not one chosen here.
Author: Max Johnson <me@maxj.phd> · 2026-08-26 19:41 UTC
Signed with PGP, not checked
Commit: 3213b55d86c5ec1f8bc43def0af4edd7aca02d71
Parent: 22d5139
3 files changed, +240 insertions, -2 deletions
M Cargo.toml +5 -1
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "makeover-tui"
3 - version = "0.34.0"
3 + version = "0.35.0"
4 4 edition = "2024"
5 5 description = "The terminal renderer for makeover-layout, on ratatui. Colour stops being the constraint above 256 entries; geometry never does, because an edge occupies a whole cell on every side."
6 6 license = "MIT"
@@ -20,6 +20,10 @@
20 20 # would do here. The rest of the suite has pinned this way since
21 21 # makeover-webview found it the hard way.
22 22 makeover-layout = "0.35.0"
23 + # The cadence the activity mark blinks at, and the motion-off seam beside it.
24 + # Taken rather than chosen here: `activity_lit` is this crate's only use of it,
25 + # and the whole point is that the number is not this crate's to pick.
26 + makeover-timing = "0.1.1"
23 27 makeover = { version = "3.0", optional = true }
24 28
25 29 [lints.rust]
M src/lib.rs +41
@@ -488,6 +488,47 @@
488 488 paint_bevel_with(buf, area, bevel, palette, set_for(palette, None));
489 489 }
490 490
491 + /// Whether the activity mark is lit, this far into a wait.
492 + ///
493 + /// The one place the blink's phase is worked out, so that `piece::activity` can
494 + /// stay a pure drawing and this crate can still hold no clock: the caller says
495 + /// how long the wait has run and gets back which of the two cells to draw.
496 + ///
497 + /// The cadence is `makeover_timing::Cadence::Activity`, a half-period, and is
498 + /// deliberately not a number chosen here. Three renderers draw this mark and
499 + /// one of them is a browser running it off a CSS custom property; a terminal
500 + /// that picked its own would be a second heartbeat for one wait.
501 + ///
502 + /// **`reduced` returns a lit mark, always.** A reader asking for less motion has
503 + /// asked for the movement to stop, not for the information to go away, which is
504 + /// the whole argument on `makeover_timing::activity_blink`. A terminal has no
505 + /// `prefers-reduced-motion` to read, so the preference arrives as a bool from
506 + /// whatever the host asked its own platform.
507 + ///
508 + /// ```
509 + /// use makeover_tui::activity_lit;
510 + /// use std::time::Duration;
511 + ///
512 + /// assert!(activity_lit(Duration::from_millis(0), false));
513 + /// assert!(!activity_lit(Duration::from_millis(600), false));
514 + /// assert!(activity_lit(Duration::from_millis(1100), false));
515 + /// // Motion off: lit, and it stays lit.
516 + /// assert!(activity_lit(Duration::from_millis(600), true));
517 + /// ```
518 + #[must_use]
519 + pub fn activity_lit(elapsed: std::time::Duration, reduced: bool) -> bool {
520 + let Some(half) = makeover_timing::activity_blink(reduced) else {
521 + return true;
522 + };
523 + let half = half.as_millis();
524 + // A cadence of zero would divide by nothing, which is the one value the
525 + // token cannot mean. Lit and still is the same answer reduced motion gets.
526 + if half == 0 {
527 + return true;
528 + }
529 + (elapsed.as_millis() / half).is_multiple_of(2)
530 + }
531 +
491 532 /// Which glyphs to draw with, given what the terminal can show.
492 533 ///
493 534 /// Above sixteen colours the two tones are available and [`BEVEL`] renders
M src/piece.rs +194 -1
@@ -22,6 +22,12 @@
22 22 //! works at, and every one of them had been hand-rolled at least twice in this
23 23 //! tree before it was lifted.
24 24 //!
25 + //! [`activity`] and [`awaiting`] joined them in 0.35.0, out of wiki
26 + //! `loading-and-progress-standard`. They are the one pair here that arrived
27 + //! before their second consumer rather than after it: nothing in the tree drew
28 + //! a wait at all, on any surface, which is why the crate that had the vocabulary
29 + //! for one had never been asked for the drawing.
30 + //!
25 31 //! # What these take, and what they leave alone
26 32 //!
27 33 //! Each takes a `makeover-layout` description, a [`PieceStyle`], and whatever
@@ -44,13 +50,14 @@
44 50 //! nothing here places anything relative to anything else, because the moment
45 51 //! it did it would be a layout engine with one consumer's flow baked into it.
46 52
47 - use makeover_layout::{Act, Field, FieldKind, Figure, Heading, Meter, Token, Tone};
53 + use makeover_layout::{Act, Awaiting, Field, FieldKind, Figure, Heading, Meter, Token, Tone};
48 54 use ratatui::buffer::Buffer;
49 55 use ratatui::layout::Rect;
50 56 use ratatui::style::{Modifier, Style};
51 57 use ratatui::text::{Line, Span};
52 58
53 59 use crate::text;
60 + use std::time::Duration;
54 61
55 62 /// The colours and marks the drawings below use.
56 63 ///
@@ -290,6 +297,127 @@
290 297 }
291 298 }
292 299
300 + /// What a host can see about a wait that is running.
301 + ///
302 + /// Neither half is derivable from a description, which is why both are here and
303 + /// not on [`Awaiting`]. That type says how big the payload is; how much of it
304 + /// has landed is a fact about a transfer in flight, and only whoever is running
305 + /// the transfer knows it.
306 + ///
307 + /// The same shape `makeover-immediate` carries, deliberately: a wait is one
308 + /// reading on every surface and the two renderers should not disagree about
309 + /// what a host owes them.
310 + #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
311 + pub struct Progress {
312 + /// How much has arrived, in whatever unit the description counted.
313 + pub delivered: Option<u64>,
314 + /// How long the wait has lasted so far.
315 + ///
316 + /// The one time value a wait may show. See [`awaiting`] for the three it
317 + /// may not.
318 + pub elapsed: Option<Duration>,
319 + }
320 +
321 + /// The activity mark: one cell, lit or dark.
322 + ///
323 + /// Rule 2 of wiki `loading-and-progress-standard`, and the surface the metaphor
324 + /// came from. A hard-disk light is one cell that blinks, and a terminal draws
325 + /// that with no metaphor in the way — where a webview needs a keyframe and egui
326 + /// needs a repaint schedule, this is a character.
327 + ///
328 + /// The two glyphs are [`PieceStyle::meter_full`] and
329 + /// [`PieceStyle::meter_empty`], not a third pair. A bar's filled cell and a lit
330 + /// mark are the same statement in the same alphabet, and a terminal that had to
331 + /// render two vocabularies of "on" would be saying there are two kinds of on.
332 + ///
333 + /// **Dark, not absent.** A mark that is drawn half the time is a hole in the
334 + /// line, and the line reflows around it or the reader loses where to look. It
335 + /// occupies its cell either way.
336 + ///
337 + /// `lit` is the caller's: this module holds no clock. [`crate::activity_lit`]
338 + /// is the one place the phase is worked out from the cadence, so a caller
339 + /// should reach for that rather than dividing by 500 itself.
340 + #[must_use]
341 + pub fn activity(style: &PieceStyle, lit: bool) -> Span<'static> {
342 + if lit {
343 + Span::styled(style.meter_full.to_string(), style.action)
344 + } else {
345 + Span::styled(style.meter_empty.to_string(), style.muted)
346 + }
347 + }
348 +
349 + /// A wait as one line, drawn from what is actually known about it.
350 + ///
351 + /// [`Awaiting::is_determinate`] is the first branch and there is a second the
352 + /// description cannot answer: whether anything is watching the transfer. A bar
353 + /// wants a total and a numerator both, so a described amount with no
354 + /// [`Progress::delivered`] beside it draws the mark and the size it is waiting
355 + /// on, rather than an empty trough implying somebody is counting.
356 + ///
357 + /// So three drawings for three states, which is the point:
358 + ///
359 + /// ```text
360 + /// unmeasured # a blinking cell
361 + /// measured, nothing watching # 41943040 the cell, and how much there is
362 + /// measured and observed ####------ 17825792/41943040 4s
363 + /// ```
364 + ///
365 + /// **What the bar may not do**, from rule 1 of the standard and from
366 + /// [`Awaiting`]'s own docs: what is done over what there is, plus the time it
367 + /// has taken. Never a remaining time, an arrival time, or a rate extrapolated
368 + /// forward. A prediction is wrong the moment the transfer stalls, and being
369 + /// confidently wrong is worse than being honestly indeterminate.
370 + ///
371 + /// The numbers are raw. The unit is the app's — bytes for an upload, rows for
372 + /// an import — and a renderer that formatted one as a file size would be
373 + /// dressing up a quantity it was deliberately not told about.
374 + #[must_use]
375 + pub fn awaiting(
376 + style: &PieceStyle,
377 + awaiting: Awaiting,
378 + progress: Progress,
379 + lit: bool,
380 + ) -> Line<'static> {
381 + let Some(total) = awaiting.amount else {
382 + return Line::from(vec![activity(style, lit)]);
383 + };
384 + let Some(done) = progress.delivered else {
385 + return Line::from(vec![
386 + activity(style, lit),
387 + Span::styled(format!(" {total}"), style.muted),
388 + ]);
389 + };
390 + let cells = u32::from(style.meter_cells);
391 + // In cells rather than in floating point, the way `meter` does it: a
392 + // terminal's bar has ten states and rounding through an f64 to reach one of
393 + // ten is arithmetic nobody needs. Saturating rather than wrapping, because
394 + // a transfer that over-delivers is a real case and a panicking bar is not
395 + // the way to report it.
396 + let filled = u32::try_from(
397 + done.saturating_mul(u64::from(cells))
398 + .checked_div(total)
399 + .unwrap_or(0),
400 + )
401 + .unwrap_or(cells)
402 + .min(cells);
403 + let bar = format!(
404 + "{}{}",
405 + style.meter_full.to_string().repeat(filled as usize),
406 + style
407 + .meter_empty
408 + .to_string()
409 + .repeat((cells - filled) as usize)
410 + );
411 + let reading = match progress.elapsed {
412 + Some(elapsed) => format!(" {done}/{total} {}s", elapsed.as_secs()),
413 + None => format!(" {done}/{total}"),
414 + };
415 + Line::from(vec![
416 + Span::styled(bar, style.action),
417 + Span::styled(reading, style.muted),
418 + ])
419 + }
420 +
293 421 /// A proportion as one line: the bar, then the reading beside it.
294 422 ///
295 423 /// The reading is built here from the two numbers and the noun rather than
@@ -1299,4 +1427,69 @@
1299 1427 assert_eq!(painted.bg, None);
1300 1428 }
1301 1429 }
1430 +
1431 + #[test]
1432 + fn the_three_states_of_a_wait_are_three_drawings() {
1433 + // The whole done condition of `5db1e0ed`: a measured wait and an
1434 + // unmeasured one stopped being the same line.
1435 + let style = PieceStyle::default();
1436 + let bare = awaiting(&style, Awaiting::unmeasured(), Progress::default(), true);
1437 + let sized = awaiting(&style, Awaiting::of(41_943_040), Progress::default(), true);
1438 + let watched = awaiting(
1439 + &style,
1440 + Awaiting::of(40),
1441 + Progress {
1442 + delivered: Some(20),
1443 + elapsed: Some(Duration::from_secs(4)),
1444 + },
1445 + true,
1446 + );
1447 + let read = |line: &Line<'_>| {
1448 + line.spans
1449 + .iter()
1450 + .map(|s| s.content.to_string())
1451 + .collect::<String>()
1452 + };
1453 + assert_eq!(read(&bare), "#");
1454 + assert_eq!(read(&sized), "# 41943040");
1455 + assert_eq!(read(&watched), "#####----- 20/40 4s");
1456 + }
1457 +
1458 + #[test]
1459 + fn a_dark_mark_still_occupies_its_cell() {
1460 + // Not absent. A line that reflowed every half second would move the
1461 + // content beside it, and the reader would lose where to look.
1462 + let style = PieceStyle::default();
1463 + assert_eq!(activity(&style, true).content.chars().count(), 1);
1464 + assert_eq!(activity(&style, false).content.chars().count(), 1);
1465 + }
1466 +
1467 + #[test]
1468 + fn an_over_delivered_wait_clamps_and_does_not_panic() {
1469 + // A transfer can hand over more than the size it announced, and the
1470 + // bar has ten cells whatever happens.
1471 + let style = PieceStyle::default();
1472 + let over = awaiting(
1473 + &style,
1474 + Awaiting::of(4),
1475 + Progress {
1476 + delivered: Some(9),
1477 + elapsed: None,
1478 + },
1479 + true,
1480 + );
1481 + assert!(over.spans[0].content.chars().all(|c| c == '#'));
1482 + assert_eq!(over.spans[0].content.chars().count(), 10);
1483 + // A zero payload is no payload rather than a finished one.
1484 + let empty = awaiting(
1485 + &style,
1486 + Awaiting::of(0),
1487 + Progress {
1488 + delivered: Some(9),
1489 + elapsed: None,
1490 + },
1491 + true,
1492 + );
1493 + assert!(empty.spans[0].content.starts_with('-'));
1494 + }
1302 1495 }