Skip to main content

max / makeover-tui

0.32.0: a number reads with its unit makeover-layout 0.33.0's `Field::unit`, drawn on the value rather than in the label. A terminal has one line the eye is on -- the number -- and the label is a line above it, so `0.05 s` is the reading and `Attack (s)` two rows up is not. A range shows it after the readout beside the bar; a typed number after the value. Which kinds carry one is `FieldKind::measurable`'s answer.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-22 00:27 UTC
Signed with PGP, not checked
Commit: 0a168576eca72a60f944fd7da102fda1f3a1a5d3
Parent: 59eb3b8
3 files changed, +89 insertions, -4 deletions
M Cargo.toml +2 -2
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "makeover-tui"
3 - version = "0.31.0"
3 + version = "0.32.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"
@@ -19,7 +19,7 @@
19 19 # compile against an API added in a later one -- which is what `Depth::Overlay`
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 - makeover-layout = "0.32.0"
22 + makeover-layout = "0.33.0"
23 23 makeover = { version = "3.0", optional = true }
24 24
25 25 [lints.rust]
M src/lib.rs +9
@@ -127,6 +127,15 @@
127 127 //! once, so an unanswered one is a list with no mark against any row rather
128 128 //! than an empty box with nothing in it.
129 129 //!
130 + //! # 0.32.0: a number reads with its unit
131 + //!
132 + //! `makeover-layout` 0.33.0's `Field::unit`, drawn on the value rather than in
133 + //! the label. A terminal has one line the eye is on -- the number -- and the
134 + //! label is a line above it, so `0.05 s` is the reading and `Attack (s)` two
135 + //! rows up is not. A range shows it after the readout beside the bar; a typed
136 + //! number after the value. Every other kind ignores it, and which those are is
137 + //! `FieldKind::measurable`'s answer rather than a `matches!` kept here.
138 + //!
130 139 //! # 0.31.0: the bar fills along the curve
131 140 //!
132 141 //! `makeover-layout` 0.32.0's [`Curve`](makeover_layout::Curve). Where a value
M src/piece.rs +78 -2
@@ -517,7 +517,7 @@
517 517 _ if held.text().is_empty() => {
518 518 empty_well(style, placeholder, well, focused, below(area, used), buf)
519 519 }
520 - _ => text::draw(held.text(), well, below(area, used), buf),
520 + _ => text::draw(&measured(field, held.text()), well, below(area, used), buf),
521 521 };
522 522
523 523 // The error wins over the hint, the same order a webview uses: a hint is
@@ -585,10 +585,32 @@
585 585 Span::styled(format!("{} ", field.min.unwrap_or_default()), style.muted),
586 586 Span::styled(bar, well),
587 587 Span::styled(format!(" {}", field.max.unwrap_or_default()), style.muted),
588 - Span::styled(format!(" {value}"), well),
588 + Span::styled(format!(" {}", measured(field, value)), well),
589 589 ])
590 590 }
591 591
592 + /// The unit to draw beside this field's value, if there is one to draw.
593 + ///
594 + /// Two conditions rather than one: the field has to carry a unit and its kind
595 + /// has to be one that means anything by it. `FieldKind::measurable` is the
596 + /// description answering the second, so this renderer keeps no list of its own
597 + /// of which kinds are quantities.
598 + fn unit_of<'a>(field: &Field<'a>) -> Option<&'a str> {
599 + field.unit.filter(|_| field.kind.measurable())
600 + }
601 +
602 + /// A value with what it is measured in, as one string.
603 + ///
604 + /// The unit rides on the value rather than on the label, which is
605 + /// `makeover-layout` 0.33.0's rule and is what a terminal wants anyway: the
606 + /// label is a line above and the number is the line the eye is on.
607 + fn measured(field: &Field<'_>, value: &str) -> String {
608 + match unit_of(field) {
609 + Some(unit) => format!("{value} {unit}"),
610 + None => value.to_owned(),
611 + }
612 + }
613 +
592 614 /// The label, marked where the field is compulsory.
593 615 fn label_of(style: &PieceStyle, field: &Field<'_>) -> String {
594 616 if field.required {
@@ -932,6 +954,60 @@
932 954 assert_eq!(field_height(&style, &field_, 40), 2);
933 955 }
934 956
957 + #[test]
958 + fn a_unit_rides_on_the_value_and_not_on_the_label() {
959 + // The label is a line above; the number is the line the eye is on.
960 + let style = style();
961 + let field_ = Field {
962 + unit: Some("s"),
963 + ..Field::range("attack", "Attack", "0", "5")
964 + };
965 + let mut buf = buffer(40, 3);
966 + field(
967 + &style,
968 + &field_,
969 + Held::Text("2.5"),
970 + false,
971 + buf.area,
972 + &mut buf,
973 + );
974 + assert_eq!(rows(&buf)[0].trim_end(), "Attack");
975 + assert_eq!(rows(&buf)[1].trim_end(), "0 #####----- 5 2.5 s");
976 + }
977 +
978 + #[test]
979 + fn a_typed_number_reads_with_its_unit_too() {
980 + let style = style();
981 + let field_ = Field {
982 + unit: Some("ms"),
983 + ..Field::new(FieldKind::Number, "fade", "Fade")
984 + };
985 + let mut buf = buffer(40, 3);
986 + field(&style, &field_, Held::Text("50"), false, buf.area, &mut buf);
987 + assert_eq!(rows(&buf)[1].trim_end(), "50 ms");
988 + }
989 +
990 + #[test]
991 + fn a_unit_on_a_kind_that_is_not_a_quantity_is_ignored() {
992 + // Which kinds are quantities is the description's answer, not a
993 + // `matches!` kept in this crate.
994 + let style = style();
995 + let field_ = Field {
996 + unit: Some("s"),
997 + ..Field::new(FieldKind::Text, "name", "Name")
998 + };
999 + let mut buf = buffer(40, 3);
1000 + field(
1001 + &style,
1002 + &field_,
1003 + Held::Text("kick"),
1004 + false,
1005 + buf.area,
1006 + &mut buf,
1007 + );
1008 + assert_eq!(rows(&buf)[1].trim_end(), "kick");
1009 + }
1010 +
935 1011 #[test]
936 1012 fn a_range_holding_something_unreadable_still_shows_it() {
937 1013 // The app put the value there. A terminal that quietly rounded it to a