Skip to main content

max / makeover-immediate

0.5.1: render FieldKind::Radio Control gains Listed, apart from Chosen because the description holds the two apart and that separation is the whole content of the kind: same question, and an answer readable without opening anything. Every option is drawn with egui's own radio_value and their responses are unioned, so a caller's .changed() covers the group rather than one button. A group with no options answers as its own empty area instead of not at all, which keeps that chain working while an option list is still loading. No shown_label counterpart is needed. A value no option carries leaves every button unfilled, which is already the honest report; the select needs the fix because it has one slot and must put something in it.
Author: Max Johnson <me@maxj.phd> · 2026-08-05 20:00 UTC
Signed with PGP, not checked
Commit: 234ebaff7a7faed7def98039710a2a90d14d02ac
Parent: 96f28ea
2 files changed, +59 insertions, -5 deletions
M Cargo.toml +2 -2
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "makeover-immediate"
3 - version = "0.5.0"
3 + version = "0.5.1"
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"
@@ -8,7 +8,7 @@
8 8
9 9 [dependencies]
10 10 egui = { version = "0.35", default-features = false }
11 - makeover-layout = "0.8.0"
11 + makeover-layout = "0.8.1"
12 12
13 13 [lints.rust]
14 14 unused = "warn"
M src/lib.rs +57 -3
@@ -331,7 +331,7 @@
331 331 }
332 332 }
333 333
334 - /// The three shapes a control comes in here, which is fewer than there are
334 + /// The four shapes a control comes in here, which is fewer than there are
335 335 /// kinds.
336 336 ///
337 337 /// [`FieldKind`] is `#[non_exhaustive]` and grows; this does not, because the
@@ -342,9 +342,16 @@
342 342 enum Control {
343 343 /// Typed into, so it is drawn as a well: the user looks into it.
344 344 Typed,
345 - /// Picked from. Pressed rather than looked into, so egui's own control
346 - /// painting stands.
345 + /// Picked from a control that shows one option at a time. Pressed rather
346 + /// than looked into, so egui's own control painting stands.
347 347 Chosen,
348 + /// Picked from options that are all on screen at once.
349 + ///
350 + /// Apart from [`Chosen`](Self::Chosen) because the description holds them
351 + /// apart, and holding them apart is the whole content of
352 + /// [`FieldKind::Radio`]: same question, and an answer the user can read
353 + /// without opening anything.
354 + Listed,
348 355 /// Held on or off.
349 356 Toggled,
350 357 }
@@ -357,6 +364,7 @@
357 364 const fn control_shape(kind: FieldKind) -> Control {
358 365 match kind {
359 366 FieldKind::Select => Control::Chosen,
367 + FieldKind::Radio => Control::Listed,
360 368 FieldKind::Checkbox => Control::Toggled,
361 369 _ => Control::Typed,
362 370 }
@@ -421,6 +429,36 @@
421 429 };
422 430 ui.checkbox(on, RichText::new(field.label).color(palette.content))
423 431 }
432 + Control::Listed => {
433 + let value = match filling {
434 + Filling::Text(text) => text,
435 + _ => &mut discard,
436 + };
437 + // No `shown_label` counterpart, and none is needed: a value no
438 + // option carries leaves every button unfilled, which is already
439 + // the honest report on screen. The select needs the fix because it
440 + // has one slot and must put *something* in it.
441 + let group = ui.vertical(|ui| {
442 + let mut answered: Option<Response> = None;
443 + for opt in field.options {
444 + let picked = ui.radio_value(
445 + value,
446 + opt.value.to_owned(),
447 + RichText::new(opt.label).color(palette.content),
448 + );
449 + answered = Some(match answered {
450 + Some(prev) => prev.union(picked),
451 + None => picked,
452 + });
453 + }
454 + answered
455 + });
456 + // A group described with no options answers as its own empty area
457 + // rather than as no response at all, which keeps the caller's
458 + // `.changed()` chain working on a field whose option list has not
459 + // loaded yet.
460 + group.inner.unwrap_or(group.response)
461 + }
424 462 Control::Chosen => {
425 463 let value = match filling {
426 464 Filling::Text(text) => text,
@@ -659,6 +697,7 @@
659 697 // What decides whether the control gets a well. A well is for what the
660 698 // user looks into, and only one of these is.
661 699 assert_eq!(control_shape(FieldKind::Select), Control::Chosen);
700 + assert_eq!(control_shape(FieldKind::Radio), Control::Listed);
662 701 assert_eq!(control_shape(FieldKind::Checkbox), Control::Toggled);
663 702 for k in [
664 703 FieldKind::Text,
@@ -673,6 +712,21 @@
673 712 }
674 713 }
675 714
715 + #[test]
716 + fn the_two_option_taking_kinds_are_drawn_differently_on_purpose() {
717 + // The description holds Select and Radio apart, and a renderer that
718 + // collapsed them would silently answer a question the app did not ask:
719 + // audiofiles' storage style is irreversible and its alternatives have
720 + // to be readable without opening anything. Asserting the two shapes
721 + // differ is asserting that distinction survives the trip.
722 + assert!(FieldKind::Select.offers_options());
723 + assert!(FieldKind::Radio.offers_options());
724 + assert_ne!(
725 + control_shape(FieldKind::Select),
726 + control_shape(FieldKind::Radio)
727 + );
728 + }
729 +
676 730 #[test]
677 731 fn a_hidden_field_draws_nothing_and_answers_nothing() {
678 732 // Where the two renderers legitimately part: a webview still emits an