Skip to main content

max / makeover-immediate

0.40.0: an option carries what picking it means `Choice::detail` from makeover-layout 0.39.0. A radio group draws it under the option, inset past the control; a combo has no second line and no space beside the row, so both extra strings run into the row's text the way a `<select>`'s option does in the webview.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session
https://claude.ai/code/session_0136sbU8F6i9WrcvA3wn4Lgk
Author: Max Johnson <me@maxj.phd> · 2026-08-29 15:43 UTC
Signed with PGP, not checked
Commit: c1a8e9262f369eb32bc3a62bea252c982780dd27
Parent: cb281b6
2 files changed, +89 insertions, -6 deletions
M Cargo.toml +2 -2
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "makeover-immediate"
3 - version = "0.39.0"
3 + version = "0.40.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.38.0"
15 + makeover-layout = "0.39.0"
16 16 # The cadence the activity mark blinks at, and the motion-off seam beside it.
17 17 # Taken rather than chosen here: three renderers draw this mark and a number
18 18 # picked per renderer is three heartbeats for one wait.
M src/lib.rs +87 -4
@@ -228,6 +228,11 @@
228 228 //! in the list, inert, with its precondition beside it instead of behind a
229 229 //! hover — a greyed row with no reason reads as a dead end, which is the
230 230 //! whole finding.
231 + //! - **`Choice::detail` (makeover-layout 0.39.0) is drawn under the option in a
232 + //! radio group and inside the row in a combo.** A closed chooser hides its
233 + //! list, so everything an option carries has to travel with its row; a group
234 + //! has a line to spare and putting a sentence beside the control instead
235 + //! would push every option's radio out of line with its neighbours.
231 236 //!
232 237 //! The value still arrives as a `&mut String` and a slider is a number, so the
233 238 //! parse and the write-back are this renderer's, and the write happens only on
@@ -728,6 +733,33 @@
728 733 /// belong wherever that read happens rather than here. What the app gets is the
729 734 /// local value in `DATETIME_FORMAT`, which is what it got before the member
730 735 /// existed. No described site on this host asks for it today.
736 + /// One row of a closed chooser, as the single string it has room for.
737 + ///
738 + /// A combo hides its list, so everything an option carries has to travel with
739 + /// the row it belongs to: there is no second line to put a detail on and no
740 + /// space beside the row to put a reason in. That is the same constraint a
741 + /// `<select>`'s option has in the webview, and it takes the same answer.
742 + ///
743 + /// The order is what the option *is* before why it cannot be picked, which is
744 + /// the order the two read in and the order the radio group draws them in.
745 + fn combo_row(opt: &Choice<'_>) -> String {
746 + let mut text = opt.label.to_owned();
747 + for extra in [opt.detail, opt.unavailable].into_iter().flatten() {
748 + text.push_str(" ");
749 + text.push_str(extra);
750 + }
751 + text
752 + }
753 +
754 + /// How far an option's second line is inset, in points.
755 + ///
756 + /// makeover-layout 0.39.0. The width of egui's radio button plus the gap after
757 + /// it, so the line starts under the label rather than under the control. A
758 + /// magnitude, which is `makeover-geometry`'s subject and not this crate's --
759 + /// but this one is measured off a widget egui draws and sizes, so there is
760 + /// nothing for a spacing scale to say about it.
761 + const OPTION_DETAIL_INDENT: f32 = 22.0;
762 +
731 763 const fn control_shape(kind: FieldKind) -> Control {
732 764 match kind {
733 765 FieldKind::Select => Control::Chosen,
@@ -1141,6 +1173,24 @@
1141 1173 RichText::new(opt.label).color(option_color(value, opt.value, palette)),
1142 1174 )
1143 1175 };
1176 + // What picking it means, under the option rather than
1177 + // beside it. makeover-layout 0.39.0, and the placement is
1178 + // the difference from the reason above: a precondition is
1179 + // short enough to sit on the line, and a sentence saying
1180 + // what a tier is would push every option's control out of
1181 + // line with its neighbours.
1182 + //
1183 + // Indented past the radio, so it reads as belonging to the
1184 + // option above rather than as a label of its own. Muted,
1185 + // the same reading `.form-option-detail` takes in the
1186 + // webview: the line orients the label, it does not compete
1187 + // with it.
1188 + if let Some(detail) = opt.detail {
1189 + ui.horizontal(|ui| {
1190 + ui.add_space(OPTION_DETAIL_INDENT);
1191 + ui.label(RichText::new(detail).color(palette.content_muted));
1192 + });
1193 + }
1144 1194 answered = Some(match answered {
1145 1195 Some(prev) => prev.union(picked),
1146 1196 None => picked,
@@ -1167,13 +1217,18 @@
1167 1217 // Same rule as the radio group: shown, inert, and
1168 1218 // saying why. A closed control hides its list, so the
1169 1219 // reason has to travel with the row it belongs to.
1170 - if let Some(reason) = opt.unavailable {
1220 + // A closed control hides its list, so everything an
1221 + // option carries has to travel with the row it belongs
1222 + // to. That is why both extra strings run into the text
1223 + // here and neither does in the group above: a combo
1224 + // row is a row, the way a `<select>`'s option is.
1225 + let text = combo_row(opt);
1226 + if opt.unavailable.is_some() {
1171 1227 ui.add_enabled_ui(false, |ui| {
1172 1228 ui.selectable_value(
1173 1229 value,
1174 1230 opt.value.to_owned(),
1175 - RichText::new(format!("{} {reason}", opt.label))
1176 - .color(palette.content_muted),
1231 + RichText::new(text).color(palette.content_muted),
1177 1232 );
1178 1233 });
1179 1234 continue;
@@ -1181,7 +1236,7 @@
1181 1236 ui.selectable_value(
1182 1237 value,
1183 1238 opt.value.to_owned(),
1184 - RichText::new(opt.label).color(option_color(value, opt.value, palette)),
1239 + RichText::new(text).color(option_color(value, opt.value, palette)),
1185 1240 );
1186 1241 }
1187 1242 })
@@ -1708,6 +1763,34 @@
1708 1763 );
1709 1764 }
1710 1765
1766 + #[test]
1767 + fn a_closed_chooser_carries_both_of_an_options_extra_lines_in_its_row() {
1768 + // makeover-layout 0.39.0. The radio group has room for a second line
1769 + // and this control does not, so the two draw the same description
1770 + // differently and neither is a preference.
1771 + let plain = Choice::new("24", "Small Files");
1772 + assert_eq!(combo_row(&plain), "Small Files");
1773 +
1774 + let detailed = plain.detailing("$24/mo. Fits audio, plugins, binaries.");
1775 + assert_eq!(
1776 + combo_row(&detailed),
1777 + "Small Files $24/mo. Fits audio, plugins, binaries."
1778 + );
1779 +
1780 + // What it is, then why it cannot be picked. An option carrying both has
1781 + // said two things, and the order is the one the group draws.
1782 + let both = detailed.unless("Sold out.");
1783 + assert_eq!(
1784 + combo_row(&both),
1785 + "Small Files $24/mo. Fits audio, plugins, binaries. Sold out."
1786 + );
1787 + assert_eq!(
1788 + combo_row(&plain.unless("Sold out.")),
1789 + "Small Files Sold out.",
1790 + "the reason still reads the way it did before the detail existed"
1791 + );
1792 + }
1793 +
1711 1794 #[test]
1712 1795 fn only_a_required_field_is_marked() {
1713 1796 let style = FieldStyle::default();