Skip to main content

max / makeover-layout

0.8.1: describe a choice whose options are all on screen FieldKind::Radio, from audiofiles' Add Library form, where the storage style (copy samples in, or reference them where they lie) is irreversible and was being held out of a Select by hand. Not a presentation of Select. What differs is a property of the question: a consequential choice has to be readable without opening anything, because a closed control shows one option and hides the rest. It is also the one HTML input type the enum was missing -- everything else here is an <input type=...>, a <select> or a <textarea>, and the hole was radio. Field::radio joins Field::select, both now delegating to one private constructor, and FieldKind::offers_options names the pair that reads Field::options so the two renderers and the docs stop restating it. Additive on a non_exhaustive enum, so consumers pick it up without a lockstep bump, which is what non_exhaustive was put there for.
Author: Max Johnson <me@maxj.phd> · 2026-08-05 19:58 UTC
Signed with PGP, not checked
Commit: 1c8087e0044960823610d384fb711af324529f4d
Parent: f3cb7de
2 files changed, +122 insertions, -16 deletions
M Cargo.toml +1 -1
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "makeover-layout"
3 - version = "0.8.0"
3 + version = "0.8.1"
4 4 edition = "2024"
5 5 description = "The renderer-agnostic half of the make-family design system: what a thing IS, named as intents and relationships and never as values. Colour defers to makeover, spacing to makeover-geometry; what is left is composition."
6 6 license = "MIT"
M src/lib.rs +121 -15
@@ -843,8 +843,26 @@
843 843 Tel,
844 844 /// Several lines of text.
845 845 Textarea,
846 - /// One of a fixed set.
846 + /// One of a fixed set, offered behind a control that shows one at a time.
847 847 Select,
848 + /// One of a fixed set, with every option on screen at once.
849 + ///
850 + /// Not a presentation of [`Select`](Self::Select), which is the reading to
851 + /// resist: what differs is a property of the *question*. A choice that is
852 + /// consequential or irreversible has to be readable without opening
853 + /// anything, because a closed control shows one option and hides the rest,
854 + /// and the one it shows is whichever was current before the user had read
855 + /// the alternatives. audiofiles asks whether a library copies samples into
856 + /// its store or references them where they lie — which cannot be changed
857 + /// afterwards — and had already promoted that out of a checkbox by hand,
858 + /// with a comment giving this reason, before the description could say it.
859 + ///
860 + /// It is also the one HTML input type this enum was missing. Everything
861 + /// else here is an `<input type=...>`, a `<select>` or a `<textarea>`, and
862 + /// the hole was `radio`.
863 + ///
864 + /// Added 0.8.1, from audiofiles' Add Library form.
865 + Radio,
848 866 /// On or off.
849 867 Checkbox,
850 868 /// Carried through the form and never shown.
@@ -869,13 +887,28 @@
869 887 /// A checkbox labels itself on the right of the box; everything else takes
870 888 /// a label above. Both webview apps already do this and both special-case
871 889 /// it inline, which is the tell that it belongs in the description.
890 + ///
891 + /// A [`Radio`](Self::Radio) is not one of them, and the near-miss is worth
892 + /// naming: its *options* each label themselves, but the field still asks a
893 + /// question above them, so the group takes a label like everything else.
872 894 #[must_use]
873 895 pub const fn labels_itself(self) -> bool {
874 896 matches!(self, Self::Checkbox)
875 897 }
898 +
899 + /// Whether the kind reads [`Field::options`].
900 + ///
901 + /// Two kinds do, so the pair is named once here rather than spelled out at
902 + /// each renderer and again in [`Field::options`]' own doc, where "every
903 + /// kind but `Select`" was true for exactly one release. A third
904 + /// option-taking kind should land here and nowhere else.
905 + #[must_use]
906 + pub const fn offers_options(self) -> bool {
907 + matches!(self, Self::Select | Self::Radio)
908 + }
876 909 }
877 910
878 - /// One option a [`FieldKind::Select`] offers.
911 + /// One option offered by a field [`FieldKind::offers_options`] accepts.
879 912 ///
880 913 /// Two strings, because the submitted value and the read label are different
881 914 /// facts and every renderer that has tried to collapse them has had to
@@ -953,10 +986,10 @@
953 986 pub placeholder: Option<&'a str>,
954 987 /// The options offered, in the order they are offered.
955 988 ///
956 - /// Empty for every kind but [`FieldKind::Select`]. A select described with
957 - /// no options is sayable on purpose: it is what an app with an
958 - /// unfinished-loading option list actually has, and a renderer showing an
959 - /// empty select says so on screen rather than in a log.
989 + /// Empty for every kind [`FieldKind::offers_options`] rejects. A field
990 + /// described with no options is sayable on purpose: it is what an app with
991 + /// an unfinished-loading option list actually has, and a renderer showing
992 + /// an empty control says so on screen rather than in a log.
960 993 ///
961 994 /// Which option is *current* is not here. That is the value, and the value
962 995 /// is renderer state.
@@ -986,21 +1019,38 @@
986 1019
987 1020 /// A select offering the given options.
988 1021 ///
989 - /// The one kind that is under-described by [`Field::new`], so it gets a
1022 + /// One of the two kinds under-described by [`Field::new`], so it gets a
990 1023 /// constructor rather than leaving every call site to remember that a
991 1024 /// select with an empty `options` renders as an empty select.
992 1025 #[must_use]
993 1026 pub const fn select(name: &'a str, label: &'a str, options: &'a [Choice<'a>]) -> Self {
1027 + Self::offering(FieldKind::Select, name, label, options)
1028 + }
1029 +
1030 + /// A radio group offering the given options.
1031 + ///
1032 + /// The other. Same hazard as [`select`](Self::select) and a worse one: a
1033 + /// radio group with no options draws nothing at all, so a call site that
1034 + /// forgot them has an empty rectangle rather than a visibly empty control.
1035 + #[must_use]
1036 + pub const fn radio(name: &'a str, label: &'a str, options: &'a [Choice<'a>]) -> Self {
1037 + Self::offering(FieldKind::Radio, name, label, options)
1038 + }
1039 +
1040 + /// The shared body of the two constructors that take options.
1041 + ///
1042 + /// Private, and keyed on the kind rather than exposed, because the two
1043 + /// public names are the point: a call site says which question it is
1044 + /// asking, not which flag it is setting.
1045 + const fn offering(
1046 + kind: FieldKind,
1047 + name: &'a str,
1048 + label: &'a str,
1049 + options: &'a [Choice<'a>],
1050 + ) -> Self {
994 1051 Self {
995 - kind: FieldKind::Select,
996 - name,
997 - label,
998 - hint: None,
999 - error: None,
1000 - placeholder: None,
1001 1052 options,
1002 - required: false,
1003 - extended: false,
1053 + ..Self::new(kind, name, label)
1004 1054 }
1005 1055 }
1006 1056
@@ -1413,6 +1463,62 @@
1413 1463 assert_ne!(spelled.value, spelled.label);
1414 1464 }
1415 1465
1466 + #[test]
1467 + fn a_radio_asks_the_same_question_as_a_select_and_is_not_the_same_kind() {
1468 + // Both offer a fixed set and both read `options`, so the two
1469 + // constructors differ in exactly one thing. That one thing is the
1470 + // point: a renderer decides whether the alternatives are readable
1471 + // without opening anything, and it can only decide that if the
1472 + // description said which question was asked.
1473 + let styles = [
1474 + Choice {
1475 + value: "copy",
1476 + label: "Copy samples in",
1477 + },
1478 + Choice {
1479 + value: "reference",
1480 + label: "Reference in place",
1481 + },
1482 + ];
1483 + let radio = Field::radio("storage", "Storage style", &styles);
1484 + let select = Field::select("storage", "Storage style", &styles);
1485 +
1486 + assert_eq!(radio.kind, FieldKind::Radio);
1487 + assert_ne!(radio.kind, select.kind);
1488 + assert_eq!(radio.options, select.options);
1489 + assert_eq!(Field { kind: select.kind, ..radio }, select);
1490 + }
1491 +
1492 + #[test]
1493 + fn exactly_the_option_taking_kinds_say_so() {
1494 + // The renderers branch on this rather than on a list of their own, so
1495 + // a kind added without a decision here renders its options nowhere.
1496 + assert!(FieldKind::Select.offers_options());
1497 + assert!(FieldKind::Radio.offers_options());
1498 + for kind in [
1499 + FieldKind::Text,
1500 + FieldKind::Secret,
1501 + FieldKind::Number,
1502 + FieldKind::Email,
1503 + FieldKind::Url,
1504 + FieldKind::Tel,
1505 + FieldKind::Textarea,
1506 + FieldKind::Checkbox,
1507 + FieldKind::Hidden,
1508 + ] {
1509 + assert!(!kind.offers_options(), "{kind:?} does not offer options");
1510 + }
1511 + }
1512 +
1513 + #[test]
1514 + fn a_radio_group_takes_a_label_even_though_its_options_carry_their_own() {
1515 + // The near-miss: each option is labelled beside its own button, so a
1516 + // renderer could plausibly read the group as self-labelling and drop
1517 + // the question. Checkbox is the only kind that does that.
1518 + assert!(!FieldKind::Radio.labels_itself());
1519 + assert!(FieldKind::Checkbox.labels_itself());
1520 + }
1521 +
1416 1522 #[test]
1417 1523 fn a_select_with_no_options_is_sayable() {
1418 1524 // An app whose option list has not loaded has exactly this. Making it