Skip to main content

max / makeover-tui

Draw a bounded number as a bar, and an option that is not available yet makeover-layout 0.28.0. A range takes meter's cells with its extent read out either side, since the two ends are the question; an unbounded one falls back to the text path rather than to invented bounds. An unavailable option reads muted with its precondition on the row, which is the one case where muted is the truth. The unanswered chooser needed nothing here: every option is already on screen.
Author: Max Johnson <me@maxj.phd> · 2026-08-17 18:49 UTC
Commit: 463387a7d6dfb2f9dd5998387bfa5b488c341906
Parent: 7fb04d3
3 files changed, +184 insertions, -6 deletions
M Cargo.toml +2 -2
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "makeover-tui"
3 - version = "0.26.0"
3 + version = "0.27.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.27.5"
22 + makeover-layout = "0.28.0"
23 23 makeover = { version = "2.5", optional = true }
24 24
25 25 [lints.rust]
M src/lib.rs +18
@@ -109,6 +109,24 @@
109 109 //! layout kind, and nothing outside this tree ever needed a word for a drawing
110 110 //! routine. `WidgetStyle` is `PieceStyle`.
111 111 //!
112 + //! # 0.27.0: a bar for a bounded number, and an option that is not offered yet
113 + //!
114 + //! `makeover-layout` 0.28.0's form findings, at the renderer that has the bar
115 + //! already. A [`makeover_layout::FieldKind::Range`] is drawn as [`piece::meter`]'s
116 + //! cells with its two ends read out either side, because the ends are what the
117 + //! question means and a terminal is where it would be easiest to quietly show a
118 + //! figure instead. An unbounded range falls back to the text path rather than
119 + //! to bounds this crate invented.
120 + //!
121 + //! `Choice::unavailable` is the one place the three-tone convention's muted is
122 + //! the truth rather than the lie it warns about: that option will not answer,
123 + //! and the reason it will not now sits on its row.
124 + //!
125 + //! `Field::placeholder` on a chooser, the third finding, is already answered
126 + //! here and needed nothing: this renderer draws every option of a select at
127 + //! once, so an unanswered one is a list with no mark against any row rather
128 + //! than an empty box with nothing in it.
129 + //!
112 130 //! # The correction this renderer forced
113 131 //!
114 132 //! [`makeover_layout::Fill`] briefly carried a `fallback` method, returning
M src/piece.rs +164 -4
@@ -406,6 +406,9 @@
406 406 return 0;
407 407 }
408 408 let label = text::height(&label_of(style, field), width);
409 + // A range is one row like every other single control: the bar, its two ends
410 + // and the reading are one line by construction, and a bar that wrapped
411 + // would stop being a bar.
409 412 let body = match field.kind {
410 413 FieldKind::Textarea => 3,
411 414 kind if kind.offers_options() => u16::try_from(field.options.len()).unwrap_or(u16::MAX),
@@ -448,19 +451,39 @@
448 451 below(area, used),
449 452 buf,
450 453 ),
454 + // A range's two ends are what the question means, so they are drawn
455 + // rather than left to a hint. A terminal has the bar already: this is
456 + // `meter`'s cells with the extent read out at either side of them.
457 + //
458 + // An unbounded range has no extent to draw and falls through to the
459 + // text path, which is `makeover-immediate`'s answer as well and for the
460 + // same reason: bounds this crate invented are bounds the user would
461 + // then drag against.
462 + FieldKind::Range if field.bounded() => {
463 + let line = range_line(style, field, held.text(), well);
464 + text::draw_line(&line, below(area, used), buf)
465 + }
451 466 kind if kind.offers_options() => {
452 467 let mut rows = 0;
453 468 for choice in field.options {
454 469 let chosen = held.text() == choice.value;
455 - let mark = if chosen { "(*)" } else { "( )" };
456 - rows += text::draw(
457 - &format!("{mark} {}", choice.label),
470 + // An option that cannot be picked yet reads as inert, which is
471 + // the one place muted is the truth rather than the lie below:
472 + // it will not answer, and the reason it will not is on the row
473 + // beside it rather than nowhere.
474 + let (mark, painted, suffix) = match choice.unavailable {
475 + Some(reason) => ("( )", style.muted, format!(": {reason}")),
476 + None if chosen => ("(*)", well, String::new()),
458 477 // An option that is not chosen is still an option: pressing
459 478 // it chooses it. So it takes the secondary content intent
460 479 // and not the muted one, which is what disabled looks like
461 480 // (`State::Disabled` resolves to it). Muted here read as a
462 481 // list of five where four were greyed out.
463 - if chosen { well } else { style.secondary },
482 + None => ("( )", style.secondary, String::new()),
483 + };
484 + rows += text::draw(
485 + &format!("{mark} {}{suffix}", choice.label),
486 + painted,
464 487 below(area, used + rows),
465 488 buf,
466 489 );
@@ -500,6 +523,54 @@
500 523 }
501 524 }
502 525
526 + /// A bounded number as one line: the low end, the bar, the high end, then what
527 + /// it currently reads.
528 + ///
529 + /// The two ends are drawn because they are the question. A threshold of 0.72
530 + /// says nothing without them, which is the whole argument for
531 + /// [`FieldKind::Range`] being a kind rather than a number with bounds, and a
532 + /// terminal is where it would be easiest to quietly drop them and show a figure.
533 + ///
534 + /// The bar is [`meter`]'s cells, so a range and a proportion read as the same
535 + /// object in the same app. What differs is the reading beside it: a meter counts
536 + /// something and a range holds a value.
537 + ///
538 + /// A value the host cannot read as a number empties the bar and is still shown
539 + /// as itself. That is [`empty_well`]'s position on an unreadable value: the app
540 + /// put it there, and a terminal that silently rounded it to a bound would be
541 + /// reporting a value nobody set.
542 + fn range_line(style: &PieceStyle, field: &Field<'_>, value: &str, well: Style) -> Line<'static> {
543 + let cells = usize::from(style.meter_cells);
544 + let ends = field
545 + .min
546 + .zip(field.max)
547 + .and_then(|(min, max)| Some((min.parse::<f64>().ok()?, max.parse::<f64>().ok()?)));
548 + let filled = match (ends, value.parse::<f64>()) {
549 + (Some((min, max)), Ok(number)) if max > min => {
550 + #[expect(
551 + clippy::cast_possible_truncation,
552 + clippy::cast_sign_loss,
553 + reason = "the proportion is clamped to 0..=1 before it is scaled by a cell count \
554 + that came from a u16"
555 + )]
556 + let reached = (((number - min) / (max - min)).clamp(0.0, 1.0) * cells as f64) as usize;
557 + reached
558 + }
559 + _ => 0,
560 + };
561 + let bar = format!(
562 + "{}{}",
563 + style.meter_full.to_string().repeat(filled),
564 + style.meter_empty.to_string().repeat(cells - filled)
565 + );
566 + Line::from(vec![
567 + Span::styled(format!("{} ", field.min.unwrap_or_default()), style.muted),
568 + Span::styled(bar, well),
569 + Span::styled(format!(" {}", field.max.unwrap_or_default()), style.muted),
570 + Span::styled(format!(" {value}"), well),
571 + ])
572 + }
573 +
503 574 /// The label, marked where the field is compulsory.
504 575 fn label_of(style: &PieceStyle, field: &Field<'_>) -> String {
505 576 if field.required {
@@ -824,6 +895,95 @@
824 895 assert_eq!(field_height(&style, &field_, 20), 3);
825 896 }
826 897
898 + #[test]
899 + fn a_range_draws_its_two_ends_and_where_the_value_sits_between_them() {
900 + let style = style();
901 + let field_ = Field::range("review", "Review above", "0", "1");
902 + let mut buf = buffer(40, 3);
903 + field(
904 + &style,
905 + &field_,
906 + Held::Text("0.5"),
907 + false,
908 + buf.area,
909 + &mut buf,
910 + );
911 + // Ten cells by default, half of them filled, with the extent read out
912 + // at either side: 0.5 means nothing without the 0 and the 1.
913 + assert_eq!(rows(&buf)[1].trim_end(), "0 #####----- 1 0.5");
914 + assert_eq!(field_height(&style, &field_, 40), 2);
915 + }
916 +
917 + #[test]
918 + fn a_range_holding_something_unreadable_still_shows_it() {
919 + // The app put the value there. A terminal that quietly rounded it to a
920 + // bound would be reporting a value nobody set, which is `empty_well`'s
921 + // position on the same problem.
922 + let style = style();
923 + let field_ = Field::range("review", "Review above", "0", "1");
924 + let mut buf = buffer(40, 3);
925 + field(
926 + &style,
927 + &field_,
928 + Held::Text("unset"),
929 + false,
930 + buf.area,
931 + &mut buf,
932 + );
933 + assert_eq!(rows(&buf)[1].trim_end(), "0 ---------- 1 unset");
934 + }
935 +
936 + #[test]
937 + fn an_unbounded_range_is_typed_into_rather_than_dragged() {
938 + // Bounds this crate invented are bounds the user would then drag
939 + // against. The text path takes every answer the bar would.
940 + let style = style();
941 + let field_ = Field {
942 + max: Some("1"),
943 + ..Field::new(FieldKind::Range, "review", "Review above")
944 + };
945 + let mut buf = buffer(40, 3);
946 + field(
947 + &style,
948 + &field_,
949 + Held::Text("0.5"),
950 + false,
951 + buf.area,
952 + &mut buf,
953 + );
954 + assert_eq!(rows(&buf)[1].trim_end(), "0.5");
955 + }
956 +
957 + #[test]
958 + fn an_unavailable_option_reads_as_inert_and_says_why() {
959 + // The one place muted is the truth rather than the lie the convention
960 + // warns about: this option will not answer, and the reason is on the
961 + // row rather than nowhere.
962 + let style = style();
963 + let options = [
964 + Choice::new("chromatic", "Chromatic"),
965 + Choice::new("multi", "Multi-sample").unless("Drop a second sample."),
966 + ];
967 + let mut field_ = Field::new(FieldKind::Radio, "mode", "Mode");
968 + field_.options = &options;
969 + let mut buf = buffer(46, 4);
970 + field(
971 + &style,
972 + &field_,
973 + Held::Text("chromatic"),
974 + false,
975 + buf.area,
976 + &mut buf,
977 + );
978 + assert_eq!(rows(&buf)[1].trim_end(), "(*) Chromatic");
979 + assert_eq!(
980 + rows(&buf)[2].trim_end(),
981 + "( ) Multi-sample: Drop a second sample."
982 + );
983 + let muted = buf.cell((0, 2)).expect("the unavailable row").style();
984 + assert!(muted.add_modifier.contains(Modifier::DIM));
985 + }
986 +
827 987 #[test]
828 988 fn an_unchosen_option_does_not_read_as_disabled() {
829 989 // The three-tone convention: muted is inert, and every option in this