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