| 154 |
154 |
|
//! looks for it, and because the silent version reads as "egui does not need
|
| 155 |
155 |
|
//! a palette" rather than "nobody has built the renderer yet".
|
| 156 |
156 |
|
|
|
157 |
+ |
//! # 0.28.0: the slider, the unanswered chooser, and the option that is not
|
|
158 |
+ |
//! offered yet
|
|
159 |
+ |
//!
|
|
160 |
+ |
//! Three things `makeover-layout` 0.28.0 lets a description say, all three
|
|
161 |
+ |
//! found by audiofiles' forms port hitting a wall it could not describe its way
|
|
162 |
+ |
//! past.
|
|
163 |
+ |
//!
|
|
164 |
+ |
//! - **[`FieldKind::Range`] is a fifth control shape**, `Control::Slid`, and
|
|
165 |
+ |
//! the first one added since 0.5.0. egui has `Slider` and this crate had no
|
|
166 |
+ |
//! way to be asked for one, so four sliders in the only consuming app stayed
|
|
167 |
+ |
//! hand-rolled against a vocabulary that could not name them. A range missing
|
|
168 |
+ |
//! an end falls back to a well rather than to invented bounds, which is what
|
|
169 |
+ |
//! `makeover_layout::Field::bounded` is for.
|
|
170 |
+ |
//! - **`Field::placeholder` finally reads on a chooser.** It was sayable and
|
|
171 |
+ |
//! this renderer ignored it, so a select with nothing chosen showed an empty
|
|
172 |
+ |
//! box. Nothing new is described; the renderer caught up.
|
|
173 |
+ |
//! - **`Choice::unavailable` is drawn rather than dropped.** The option stays
|
|
174 |
+ |
//! in the list, inert, with its precondition beside it instead of behind a
|
|
175 |
+ |
//! hover — a greyed row with no reason reads as a dead end, which is the
|
|
176 |
+ |
//! whole finding.
|
|
177 |
+ |
//!
|
|
178 |
+ |
//! The value still arrives as a `&mut String` and a slider is a number, so the
|
|
179 |
+ |
//! parse and the write-back are this renderer's, and the write happens only on
|
|
180 |
+ |
//! a real drag: a value the app put there that this host cannot read survives
|
|
181 |
+ |
//! being looked at.
|
|
182 |
+ |
|
| 157 |
183 |
|
#![forbid(unsafe_code)]
|
| 158 |
184 |
|
|
| 159 |
185 |
|
use egui::{
|
| 160 |
|
- |
Color32, ComboBox, CornerRadius, Margin, Painter, Rect, Response, RichText, Shape, Stroke,
|
| 161 |
|
- |
TextEdit, Ui,
|
|
186 |
+ |
Color32, ComboBox, CornerRadius, Margin, Painter, Rect, Response, RichText, Shape, Slider,
|
|
187 |
+ |
Stroke, TextEdit, Ui,
|
| 162 |
188 |
|
};
|
| 163 |
189 |
|
use makeover_layout::{Bevel, Choice, Depth, Edge, Field, FieldKind, Fill, State, Tone};
|
|
190 |
+ |
use std::ops::RangeInclusive;
|
| 164 |
191 |
|
|
| 165 |
192 |
|
/// Columns, narrowing, cell parts and the sort caret, over `egui_extras`.
|
| 166 |
193 |
|
pub mod table;
|
| 563 |
590 |
|
Listed,
|
| 564 |
591 |
|
/// Held on or off.
|
| 565 |
592 |
|
Toggled,
|
|
593 |
+ |
/// Dragged across an extent that is on screen the whole time.
|
|
594 |
+ |
///
|
|
595 |
+ |
/// Apart from [`Typed`](Self::Typed) for the reason
|
|
596 |
+ |
/// [`FieldKind::Range`] is apart from `Number`: the two ends are what the
|
|
597 |
+ |
/// question means, so a well with a figure in it is not a quieter version
|
|
598 |
+ |
/// of this control, it is a different one.
|
|
599 |
+ |
Slid,
|
| 566 |
600 |
|
}
|
| 567 |
601 |
|
|
| 568 |
602 |
|
/// Which shape a kind takes.
|
| 593 |
627 |
|
FieldKind::Select => Control::Chosen,
|
| 594 |
628 |
|
FieldKind::Radio => Control::Listed,
|
| 595 |
629 |
|
FieldKind::Checkbox => Control::Toggled,
|
|
630 |
+ |
FieldKind::Range => Control::Slid,
|
| 596 |
631 |
|
_ => Control::Typed,
|
| 597 |
632 |
|
}
|
| 598 |
633 |
|
}
|
| 599 |
634 |
|
|
|
635 |
+ |
/// The shape the field actually gets, which is the kind's unless the field is
|
|
636 |
+ |
/// missing what that shape needs.
|
|
637 |
+ |
///
|
|
638 |
+ |
/// One case, and `makeover-layout` names it: a [`FieldKind::Range`] carries its
|
|
639 |
+ |
/// extent in [`Field::min`] and [`Field::max`], and a range missing an end has
|
|
640 |
+ |
/// nothing to slide across. egui's `Slider` demands a `RangeInclusive`, so
|
|
641 |
+ |
/// inventing one would be this renderer picking bounds the app never stated and
|
|
642 |
+ |
/// the user then dragging against them.
|
|
643 |
+ |
///
|
|
644 |
+ |
/// It falls back to [`Control::Typed`], which is where every kind this renderer
|
|
645 |
+ |
/// cannot draw natively already lands: a number in a well is a true report of
|
|
646 |
+ |
/// the value and takes any answer the slider would.
|
|
647 |
+ |
fn shape_of(field: &Field<'_>) -> Control {
|
|
648 |
+ |
match control_shape(field.kind) {
|
|
649 |
+ |
Control::Slid if !field.bounded() => Control::Typed,
|
|
650 |
+ |
shape => shape,
|
|
651 |
+ |
}
|
|
652 |
+ |
}
|
|
653 |
+ |
|
|
654 |
+ |
/// The two ends of a range, as egui wants them.
|
|
655 |
+ |
///
|
|
656 |
+ |
/// `None` when either end is missing or is not a number this host can read.
|
|
657 |
+ |
/// The description carries the bounds as text on purpose — the bound of a date
|
|
658 |
+ |
/// is a date — so parsing them is the renderer's job and failing to is a real
|
|
659 |
+ |
/// outcome rather than an assertion.
|
|
660 |
+ |
fn extent(field: &Field<'_>) -> Option<RangeInclusive<f64>> {
|
|
661 |
+ |
let min = field.min?.parse::<f64>().ok()?;
|
|
662 |
+ |
let max = field.max?.parse::<f64>().ok()?;
|
|
663 |
+ |
Some(min..=max)
|
|
664 |
+ |
}
|
|
665 |
+ |
|
|
666 |
+ |
/// How many decimals to write a dragged value back with.
|
|
667 |
+ |
///
|
|
668 |
+ |
/// Read off [`Field::step`], which is the only thing that says what
|
|
669 |
+ |
/// granularity the question has: a step of `0.01` is a two-decimal question and
|
|
670 |
+ |
/// a step of `1` is a whole-number one. Without a step the host's own
|
|
671 |
+ |
/// granularity stands, and egui's is continuous, so the value is written back
|
|
672 |
+ |
/// at whatever precision it round-trips at.
|
|
673 |
+ |
fn decimals(step: Option<&str>) -> Option<usize> {
|
|
674 |
+ |
let step = step?;
|
|
675 |
+ |
Some(match step.split_once('.') {
|
|
676 |
+ |
Some((_, fraction)) => fraction.trim_end_matches('0').len(),
|
|
677 |
+ |
None => 0,
|
|
678 |
+ |
})
|
|
679 |
+ |
}
|
|
680 |
+ |
|
| 600 |
681 |
|
/// What a select shows for the value it currently holds.
|
| 601 |
682 |
|
///
|
| 602 |
683 |
|
/// A value no option carries stays on screen as itself rather than reading as
|
| 604 |
685 |
|
/// 10 against a 1/3/7/14/0 list and the browser silently showed it as 1, so the
|
| 605 |
686 |
|
/// next save wrote a value nobody chose; `makeover-webview` grew the fix as a
|
| 606 |
687 |
|
/// stray `<option>` and this is the same fix in the shape egui allows.
|
|
688 |
+ |
///
|
|
689 |
+ |
/// The empty value is the one case that reads as unanswered rather than as an
|
|
690 |
+ |
/// answer, and [`chosen_text`] is what puts the field's ghost text there.
|
| 607 |
691 |
|
fn shown_label<'a>(options: &'a [Choice<'a>], value: &'a str) -> &'a str {
|
| 608 |
692 |
|
options
|
| 609 |
693 |
|
.iter()
|
| 611 |
695 |
|
.map_or(value, |opt| opt.label)
|
| 612 |
696 |
|
}
|
| 613 |
697 |
|
|
|
698 |
+ |
/// What a select's closed control reads, and in which tone.
|
|
699 |
+ |
///
|
|
700 |
+ |
/// A chooser with nothing chosen showed an empty box: `shown_label` falls back
|
|
701 |
+ |
/// to the value, and the unanswered value is the empty string. So an app with
|
|
702 |
+ |
/// an instruction to give — audiofiles' "Select device..." — had nowhere to put
|
|
703 |
+ |
/// it but a disabled button elsewhere on the screen, which is the affordance
|
|
704 |
+ |
/// this vocabulary keeps moving messages *off*.
|
|
705 |
+ |
///
|
|
706 |
+ |
/// [`Field::placeholder`] is already the description's word for "what the field
|
|
707 |
+ |
/// reads while it is empty" and was honoured by the typed kinds alone, so
|
|
708 |
+ |
/// nothing new is said here; the renderer is what had not caught up. Muted
|
|
709 |
+ |
/// because it is not an answer, the same tone the typed kinds' ghost text takes
|
|
710 |
+ |
/// three lines up.
|
|
711 |
+ |
///
|
|
712 |
+ |
/// A value no option carries but that is *not* empty stays as itself, in
|
|
713 |
+ |
/// `content`: that is the goingson retention bug and it is a wrong answer
|
|
714 |
+ |
/// rather than an absent one.
|
|
715 |
+ |
///
|
|
716 |
+ |
/// Returns the words and the tone rather than a built [`RichText`], because
|
|
717 |
+ |
/// what it decides is both of them and only one of them is readable back off a
|
|
718 |
+ |
/// `RichText`.
|
|
719 |
+ |
///
|
|
720 |
+ |
/// [`Field::placeholder`]: makeover_layout::Field::placeholder
|
|
721 |
+ |
fn chosen_text<'a>(field: &'a Field<'a>, value: &'a str, palette: &Palette) -> (&'a str, Color32) {
|
|
722 |
+ |
match field.placeholder {
|
|
723 |
+ |
Some(ghost) if value.is_empty() => (ghost, palette.content_muted),
|
|
724 |
+ |
_ => (shown_label(field.options, value), palette.content),
|
|
725 |
+ |
}
|
|
726 |
+ |
}
|
|
727 |
+ |
|
| 614 |
728 |
|
/// What one option in a choice field is drawn in.
|
| 615 |
729 |
|
///
|
| 616 |
730 |
|
/// The chosen one is the emphasised thing and takes `content`; the rest take
|
| 642 |
756 |
|
let mut discard = String::new();
|
| 643 |
757 |
|
let mut off = false;
|
| 644 |
758 |
|
|
| 645 |
|
- |
match control_shape(field.kind) {
|
|
759 |
+ |
match shape_of(field) {
|
|
760 |
+ |
Control::Slid => {
|
|
761 |
+ |
let value = match filling {
|
|
762 |
+ |
Filling::Text(text) => text,
|
|
763 |
+ |
_ => &mut discard,
|
|
764 |
+ |
};
|
|
765 |
+ |
// `shape_of` has already refused an unbounded range, so the extent
|
|
766 |
+ |
// is only missing here if a bound is not a number — a date range,
|
|
767 |
+ |
// say, which this control cannot draw either.
|
|
768 |
+ |
let Some(extent) = extent(field) else {
|
|
769 |
+ |
return ui.label(RichText::new(value.as_str()).color(palette.content));
|
|
770 |
+ |
};
|
|
771 |
+ |
|
|
772 |
+ |
// A value the host cannot read starts at the low end rather than at
|
|
773 |
+ |
// zero, which may be outside the extent entirely. Nothing is
|
|
774 |
+ |
// written back until the user drags, so an unreadable value the app
|
|
775 |
+ |
// put there survives being looked at.
|
|
776 |
+ |
let mut number = value.parse::<f64>().unwrap_or(*extent.start());
|
|
777 |
+ |
let mut slider = Slider::new(&mut number, extent).text("");
|
|
778 |
+ |
if let Some(places) = decimals(field.step) {
|
|
779 |
+ |
slider = slider.max_decimals(places);
|
|
780 |
+ |
}
|
|
781 |
+ |
if let Some(step) = field.step.and_then(|s| s.parse::<f64>().ok()) {
|
|
782 |
+ |
slider = slider.step_by(step);
|
|
783 |
+ |
}
|
|
784 |
+ |
let response = ui.add(slider);
|
|
785 |
+ |
if response.changed() {
|
|
786 |
+ |
*value = match decimals(field.step) {
|
|
787 |
+ |
Some(places) => format!("{number:.places$}"),
|
|
788 |
+ |
None => number.to_string(),
|
|
789 |
+ |
};
|
|
790 |
+ |
}
|
|
791 |
+ |
response
|
|
792 |
+ |
}
|
| 646 |
793 |
|
Control::Typed => {
|
| 647 |
794 |
|
let text = match filling {
|
| 648 |
795 |
|
Filling::Text(text) => text,
|
| 684 |
831 |
|
let group = ui.vertical(|ui| {
|
| 685 |
832 |
|
let mut answered: Option<Response> = None;
|
| 686 |
833 |
|
for opt in field.options {
|
| 687 |
|
- |
let picked = ui.radio_value(
|
| 688 |
|
- |
value,
|
| 689 |
|
- |
opt.value.to_owned(),
|
| 690 |
|
- |
RichText::new(opt.label).color(option_color(value, opt.value, palette)),
|
| 691 |
|
- |
);
|
|
834 |
+ |
// An option that cannot be picked yet is drawn and does not
|
|
835 |
+ |
// answer, with the precondition beside it rather than
|
|
836 |
+ |
// behind a hover: a greyed row with no reason reads as a
|
|
837 |
+ |
// dead end, which is the state `Choice::unavailable` exists
|
|
838 |
+ |
// to stop being sayable.
|
|
839 |
+ |
let picked = if let Some(reason) = opt.unavailable {
|
|
840 |
+ |
ui.horizontal(|ui| {
|
|
841 |
+ |
let picked = ui
|
|
842 |
+ |
.add_enabled_ui(false, |ui| {
|
|
843 |
+ |
ui.radio_value(
|
|
844 |
+ |
value,
|
|
845 |
+ |
opt.value.to_owned(),
|
|
846 |
+ |
RichText::new(opt.label).color(palette.content_muted),
|
|
847 |
+ |
)
|
|
848 |
+ |
})
|
|
849 |
+ |
.inner;
|
|
850 |
+ |
ui.label(RichText::new(reason).color(palette.content_muted));
|
|
851 |
+ |
picked
|
|
852 |
+ |
})
|
|
853 |
+ |
.inner
|
|
854 |
+ |
} else {
|
|
855 |
+ |
ui.radio_value(
|
|
856 |
+ |
value,
|
|
857 |
+ |
opt.value.to_owned(),
|
|
858 |
+ |
RichText::new(opt.label).color(option_color(value, opt.value, palette)),
|
|
859 |
+ |
)
|
|
860 |
+ |
};
|
| 692 |
861 |
|
answered = Some(match answered {
|
| 693 |
862 |
|
Some(prev) => prev.union(picked),
|
| 694 |
863 |
|
None => picked,
|
| 707 |
876 |
|
Filling::Text(text) => text,
|
| 708 |
877 |
|
_ => &mut discard,
|
| 709 |
878 |
|
};
|
| 710 |
|
- |
let shown = shown_label(field.options, value);
|
|
879 |
+ |
let (shown, tone) = chosen_text(field, value, palette);
|
| 711 |
880 |
|
ComboBox::from_id_salt(field.name)
|
| 712 |
|
- |
.selected_text(RichText::new(shown).color(palette.content))
|
|
881 |
+ |
.selected_text(RichText::new(shown).color(tone))
|
| 713 |
882 |
|
.show_ui(ui, |ui| {
|
| 714 |
883 |
|
for opt in field.options {
|
|
884 |
+ |
// Same rule as the radio group: shown, inert, and
|
|
885 |
+ |
// saying why. A closed control hides its list, so the
|
|
886 |
+ |
// reason has to travel with the row it belongs to.
|
|
887 |
+ |
if let Some(reason) = opt.unavailable {
|
|
888 |
+ |
ui.add_enabled_ui(false, |ui| {
|
|
889 |
+ |
ui.selectable_value(
|
|
890 |
+ |
value,
|
|
891 |
+ |
opt.value.to_owned(),
|
|
892 |
+ |
RichText::new(format!("{} {reason}", opt.label))
|
|
893 |
+ |
.color(palette.content_muted),
|
|
894 |
+ |
);
|
|
895 |
+ |
});
|
|
896 |
+ |
continue;
|
|
897 |
+ |
}
|
| 715 |
898 |
|
ui.selectable_value(
|
| 716 |
899 |
|
value,
|
| 717 |
900 |
|
opt.value.to_owned(),
|
| 941 |
1124 |
|
];
|
| 942 |
1125 |
|
assert_eq!(shown_label(&options, "10"), "10");
|
| 943 |
1126 |
|
// And a value that does match reads as its label, not as itself.
|
| 944 |
|
- |
let spelled = [Choice {
|
| 945 |
|
- |
value: "7",
|
| 946 |
|
- |
label: "One week",
|
| 947 |
|
- |
}];
|
|
1127 |
+ |
let spelled = [Choice::new("7", "One week")];
|
| 948 |
1128 |
|
assert_eq!(shown_label(&spelled, "7"), "One week");
|
| 949 |
1129 |
|
}
|
| 950 |
1130 |
|
|
|
1131 |
+ |
#[test]
|
|
1132 |
+ |
fn an_unanswered_chooser_reads_its_ghost_text_and_reads_it_muted() {
|
|
1133 |
+ |
let p = palette(Color32::from_rgb(9, 9, 9));
|
|
1134 |
+ |
let options = [Choice::new("sp404", "SP-404")];
|
|
1135 |
+ |
let field = Field {
|
|
1136 |
+ |
placeholder: Some("Select device..."),
|
|
1137 |
+ |
..Field::select("device", "Conform for device", &options)
|
|
1138 |
+ |
};
|
|
1139 |
+ |
|
|
1140 |
+ |
assert_eq!(
|
|
1141 |
+ |
chosen_text(&field, "", &p),
|
|
1142 |
+ |
("Select device...", p.content_muted),
|
|
1143 |
+ |
"ghost text is not an answer, so it takes the tone the typed kinds' ghost text does"
|
|
1144 |
+ |
);
|
|
1145 |
+ |
|
|
1146 |
+ |
// Answered, and it is the label that reads rather than the value.
|
|
1147 |
+ |
assert_eq!(chosen_text(&field, "sp404", &p), ("SP-404", p.content));
|
|
1148 |
+ |
}
|
|
1149 |
+ |
|
|
1150 |
+ |
#[test]
|
|
1151 |
+ |
fn a_wrong_answer_is_not_an_absent_one() {
|
|
1152 |
+ |
// The retention-10 bug and the ghost text meet here: a value no option
|
|
1153 |
+ |
// carries still reads as itself, because the field IS answered and the
|
|
1154 |
+ |
// answer is wrong. Only the empty value is unanswered.
|
|
1155 |
+ |
let p = palette(Color32::from_rgb(9, 9, 9));
|
|
1156 |
+ |
let options = [Choice::plain("1"), Choice::plain("7")];
|
|
1157 |
+ |
let field = Field {
|
|
1158 |
+ |
placeholder: Some("Pick one"),
|
|
1159 |
+ |
..Field::select("retention", "Keep backups for", &options)
|
|
1160 |
+ |
};
|
|
1161 |
+ |
|
|
1162 |
+ |
assert_eq!(chosen_text(&field, "10", &p), ("10", p.content));
|
|
1163 |
+ |
}
|
|
1164 |
+ |
|
|
1165 |
+ |
#[test]
|
|
1166 |
+ |
fn a_chooser_with_no_ghost_text_is_unchanged() {
|
|
1167 |
+ |
// The whole change is opt-in from the description. A field that says
|
|
1168 |
+ |
// nothing about its empty state still shows an empty box.
|
|
1169 |
+ |
let p = palette(Color32::from_rgb(9, 9, 9));
|
|
1170 |
+ |
let options = [Choice::plain("1")];
|
|
1171 |
+ |
let field = Field::select("retention", "Keep backups for", &options);
|
|
1172 |
+ |
assert_eq!(chosen_text(&field, "", &p), ("", p.content));
|
|
1173 |
+ |
}
|
|
1174 |
+ |
|
|
1175 |
+ |
#[test]
|
|
1176 |
+ |
fn a_range_is_slid_and_a_number_is_typed_into() {
|
|
1177 |
+ |
// The distinction the kind was added for, at the renderer that has to
|
|
1178 |
+ |
// act on it. A well with a figure in it is not a quiet slider.
|
|
1179 |
+ |
assert_eq!(control_shape(FieldKind::Range), Control::Slid);
|
|
1180 |
+ |
assert_eq!(control_shape(FieldKind::Number), Control::Typed);
|
|
1181 |
+ |
}
|
|
1182 |
+ |
|
|
1183 |
+ |
#[test]
|
|
1184 |
+ |
fn a_range_missing_an_end_falls_back_to_a_well() {
|
|
1185 |
+ |
// egui's `Slider` demands both ends, so inventing one would be this
|
|
1186 |
+ |
// renderer picking bounds the app never stated and the user then
|
|
1187 |
+ |
// dragging against them. A typed number takes every answer the slider
|
|
1188 |
+ |
// would.
|
|
1189 |
+ |
let whole = Field::range("review", "Review above", "0", "1");
|
|
1190 |
+ |
assert_eq!(shape_of(&whole), Control::Slid);
|
|
1191 |
+ |
|
|
1192 |
+ |
let half = Field {
|
|
1193 |
+ |
max: Some("1"),
|
|
1194 |
+ |
..Field::new(FieldKind::Range, "review", "Review above")
|
|
1195 |
+ |
};
|
|
1196 |
+ |
assert_eq!(shape_of(&half), Control::Typed);
|
|
1197 |
+ |
assert_eq!(extent(&half), None);
|
|
1198 |
+ |
|
|
1199 |
+ |
// A bound this host cannot read is the same outcome by a different
|
|
1200 |
+ |
// route: the description carries bounds as text because the bound of a
|
|
1201 |
+ |
// date is a date.
|
|
1202 |
+ |
let dated = Field::range("when", "When", "2026-08-01", "2026-08-31");
|
|
1203 |
+ |
assert_eq!(extent(&dated), None);
|
|
1204 |
+ |
}
|
|
1205 |
+ |
|
|
1206 |
+ |
#[test]
|
|
1207 |
+ |
fn the_step_decides_how_a_dragged_value_is_written_back() {
|
|
1208 |
+ |
// Without it a 0-to-1 threshold writes back whatever float the drag
|
|
1209 |
+ |
// landed on, which is the host's granularity and is what the
|
|
1210 |
+ |
// description says an absent step means.
|
|
1211 |
+ |
assert_eq!(decimals(None), None);
|
|
1212 |
+ |
assert_eq!(decimals(Some("1")), Some(0));
|
|
1213 |
+ |
assert_eq!(decimals(Some("0.01")), Some(2));
|
|
1214 |
+ |
// Trailing zeros are not precision: 0.10 is a one-decimal question.
|
|
1215 |
+ |
assert_eq!(decimals(Some("0.10")), Some(1));
|
|
1216 |
+ |
}
|
|
1217 |
+ |
|
|
1218 |
+ |
#[test]
|
|
1219 |
+ |
fn an_unavailable_option_is_drawn_muted_rather_than_dropped() {
|
|
1220 |
+ |
// The tone rule, at the one place it is a claim rather than a
|
|
1221 |
+ |
// preference: this option genuinely will not answer, so muted is the
|
|
1222 |
+ |
// truth. The available ones beside it keep the secondary intent.
|
|
1223 |
+ |
let p = palette(Color32::from_rgb(9, 9, 9));
|
|
1224 |
+ |
let options = [
|
|
1225 |
+ |
Choice::new("chromatic", "Chromatic"),
|
|
1226 |
+ |
Choice::new("multi", "Multi-sample").unless("Drop a second sample."),
|
|
1227 |
+ |
];
|
|
1228 |
+ |
assert!(options[0].available());
|
|
1229 |
+ |
assert!(!options[1].available());
|
|
1230 |
+ |
assert_eq!(
|
|
1231 |
+ |
option_color("chromatic", options[0].value, &p),
|
|
1232 |
+ |
p.content,
|
|
1233 |
+ |
"the chosen option is the emphasised thing"
|
|
1234 |
+ |
);
|
|
1235 |
+ |
assert_eq!(
|
|
1236 |
+ |
option_color("chromatic", options[1].value, &p),
|
|
1237 |
+ |
p.content_secondary,
|
|
1238 |
+ |
"and `option_color` never mutes: the unavailable path is what does"
|
|
1239 |
+ |
);
|
|
1240 |
+ |
}
|
|
1241 |
+ |
|
| 951 |
1242 |
|
#[test]
|
| 952 |
1243 |
|
fn only_a_required_field_is_marked() {
|
| 953 |
1244 |
|
let style = FieldStyle::default();
|