Skip to main content

max / makeover-immediate

Draw a slider, a chooser's ghost text, and an option that is not available yet makeover-layout 0.28.0's three form findings, at the renderer that found them. Control::Slid is the fifth control shape and the first since 0.5.0; an unbounded range falls back to a well rather than to invented bounds. An unanswered select reads Field::placeholder muted instead of showing an empty box. An unavailable option is drawn inert with its precondition beside it rather than dropped from the list.
Author: Max Johnson <me@maxj.phd> · 2026-08-17 18:44 UTC
Commit: f5fb9176d3f1e469e0557542125626b025ba3bb6
Parent: f83068d
2 files changed, +307 insertions, -16 deletions
M Cargo.toml +2 -2
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "makeover-immediate"
3 - version = "0.27.0"
3 + version = "0.28.0"
4 4 edition = "2024"
5 5 description = "The immediate-mode renderer for makeover-layout. Immediate mode is the constraint that matters, not the library: no cascade, no retained tree, one stroke per widget. Backed by egui."
6 6 license = "MIT"
@@ -12,7 +12,7 @@
12 12 # Exact patch rather than the minor, as the rest of the suite pins: a
13 13 # minor-only requirement is satisfied by a consumer lock holding an earlier
14 14 # patch, which then fails to compile against an API added in a later one.
15 - makeover-layout = "0.27.5"
15 + makeover-layout = "0.28.0"
16 16
17 17 [lints.rust]
18 18 unused = "warn"
M src/lib.rs +305 -14
@@ -154,13 +154,40 @@
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,6 +590,13 @@
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,10 +627,57 @@
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,6 +685,9 @@
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,6 +695,36 @@
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,7 +756,40 @@
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,11 +831,33 @@
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,11 +876,25 @@
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,13 +1124,121 @@
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();