Skip to main content

max / makeover-layout

Describe a bounded number, and an option that is not available yet Three findings from audiofiles' forms port, all about what a question is rather than what a control looks like. FieldKind::Range plus Field::step: a validated number can be out of range and a slider cannot, so the bounds stop being a rule and become the control's extent. Field::bounded is the check, Field::range the constructor that makes the bounded one easy. Choice::unavailable, with #[non_exhaustive] on Choice in the same release so the 40 literal sites in six repos are paid once. One member rather than a flag and a reason, so an option greyed out with no explanation is unsayable.
Author: Max Johnson <me@maxj.phd> · 2026-08-17 18:42 UTC
Commit: 6a9c27f8c72b533d344ea5709c4a8aee04ee54cb
Parent: 669ee74
2 files changed, +247 insertions, -14 deletions
M Cargo.toml +1 -1
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "makeover-layout"
3 - version = "0.27.5"
3 + version = "0.28.0"
4 4 edition = "2024"
5 5 # One copy of this vocabulary per dependency graph, enforced by cargo rather
6 6 # than by remembering. Two versions of a description layer in one build means
M src/lib.rs +246 -13
@@ -299,6 +299,30 @@
299 299 //! say, and a value here would be a second source for something the description
300 300 //! already states by containing them.
301 301 //!
302 + //! 0.28.0 is what audiofiles' forms port found it could not say, three findings
303 + //! filed against a working conversion rather than guessed at in advance. All
304 + //! three are about a *question* rather than about a control, which is the line
305 + //! this crate keeps having to redraw.
306 + //!
307 + //! - [`FieldKind::Range`] and [`Field::step`]. A bounded number the user drags
308 + //! across, where both ends being on screen is what the question means. The
309 + //! reading to resist is that this is [`FieldKind::Number`] with bounds, and it
310 + //! is [`FieldKind::Radio`]'s argument again: a validated number can be out of
311 + //! range and a slider cannot, so the bounds stop being a rule and become the
312 + //! control's extent. [`Field::bounded`] is the check a renderer asks, since a
313 + //! range missing an end has nothing to draw.
314 + //! - [`Choice::unavailable`]. An option that is real, worth showing, and cannot
315 + //! be picked yet. Without it an app either drops the option — and the user
316 + //! never learns it is there — or hand-rolls the control outside the
317 + //! description, which is what audiofiles' instrument panel did: a permanently
318 + //! disabled radio plus a hand-written line saying what would enable it.
319 + //! `#[non_exhaustive]` arrives on [`Choice`] in the same release, so this is
320 + //! the last breaking addition to it.
321 + //! - Not a member at all: [`Field::placeholder`] on a chooser. It was sayable
322 + //! already and no renderer read it, so a select with nothing chosen showed an
323 + //! empty box and the instruction lived on a disabled button elsewhere. The
324 + //! renderers moved, not the description.
325 + //!
302 326 //! # Reach, focus and the focus ring
303 327 //!
304 328 //! Three terms, and no others, for what 0.19.0 moved out of the description.
@@ -2855,6 +2879,46 @@
2855 2879 Secret,
2856 2880 /// A number.
2857 2881 Number,
2882 + /// A number inside bounds the user drags across, where the range being
2883 + /// visible is the point.
2884 + ///
2885 + /// Not [`Number`](Self::Number) with [`min`](Field::min) and
2886 + /// [`max`](Field::max), which is the reading to resist and is the same
2887 + /// resistance [`Radio`](Self::Radio) needed against `Select`. A bounded
2888 + /// number and a validated number are different *questions*. A validated
2889 + /// number is typed and can be wrong: the bounds are a rule the answer is
2890 + /// checked against, and being told "must be at least 1" afterwards is the
2891 + /// normal course of it. A range cannot be out of range at all, because the
2892 + /// bounds are the control's extent rather than a rule, and the two ends are
2893 + /// what the question means — audiofiles asks for a classifier threshold
2894 + /// between 0 and 1, where 0 is never and 1 is only-on-certainty, and a typed
2895 + /// 0.72 says nothing without both ends on screen beside it.
2896 + ///
2897 + /// A renderer cannot infer which one is meant from `min`/`max` alone, which
2898 + /// is why this is a kind and not an inference: goingson's `min="1"` duration
2899 + /// is a validated number and would become a slider.
2900 + ///
2901 + /// The membership test passes without stretching: a webview emits
2902 + /// `<input type="range">`, egui has `Slider`, a terminal draws a bar and
2903 + /// takes arrow keys, a CLI takes a bounded argument.
2904 + ///
2905 + /// # It owes its bounds
2906 + ///
2907 + /// [`min`](Field::min) and [`max`](Field::max) are `Option` for every other
2908 + /// kind and are **required** here, in the sense the description can require
2909 + /// anything: [`Field::bounded`] is the check, and a range missing one has no
2910 + /// extent for a renderer to draw. What a renderer does with an unbounded
2911 + /// range is its own call and both answers are honest — fall back to a typed
2912 + /// number, or pick a host default — so this is stated rather than enforced,
2913 + /// the way every other constraint here is.
2914 + ///
2915 + /// [`Field::step`] is the third fact and is genuinely optional: absent, the
2916 + /// host's own granularity stands.
2917 + ///
2918 + /// Added 0.28.0, from audiofiles' classifier thresholds and storage cap
2919 + /// picker (`fb93426b`), where four sliders were hand-rolled against a
2920 + /// vocabulary that could not say what they were.
2921 + Range,
2858 2922 /// An email address.
2859 2923 ///
2860 2924 /// Distinct from [`Text`](Self::Text) because the distinction is not
@@ -3058,23 +3122,86 @@
3058 3122 /// form emitter and it is taken here unchanged; moving it down rather than
3059 3123 /// re-deriving it is the point, since the second and third renderers were each
3060 3124 /// going to arrive at a near-miss of it.
3125 + /// `#[non_exhaustive]` as of 0.28.0, which every other type here that a
3126 + /// renderer matches or builds has carried for releases. It was the omission
3127 + /// that made [`unavailable`](Self::unavailable) a breaking change across 40
3128 + /// literal sites in six repos, and it arrives with that member so the price is
3129 + /// paid once and never again.
3061 3130 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
3131 + #[non_exhaustive]
3062 3132 pub struct Choice<'a> {
3063 3133 /// What is submitted.
3064 3134 pub value: &'a str,
3065 3135 /// What is read.
3066 3136 pub label: &'a str,
3137 + /// Why it cannot be picked right now, when it cannot.
3138 + ///
3139 + /// One member rather than an `available: bool` beside a reason, and the
3140 + /// conflation is the point: an option greyed out with no explanation is a
3141 + /// dead end the user cannot act on, and it is exactly the state the app
3142 + /// that found this gap had to patch by hand with a line of prose under the
3143 + /// control. Making the reason mandatory means the description cannot say
3144 + /// the useless half.
3145 + ///
3146 + /// The option stays in the list. Dropping it is what an app does today, and
3147 + /// it costs the user the knowledge that the thing exists at all —
3148 + /// audiofiles' multi-sample mode appears on its own once a second sample is
3149 + /// dropped, so a user who never sees it never learns what to drop.
3150 + ///
3151 + /// **Not [`Field::error`], and not [`Field::hint`].** An error is about the
3152 + /// answer and a hint is standing help for the whole question; this is about
3153 + /// one option among several, which is the level neither of those reaches.
3154 + ///
3155 + /// **Not disabled-the-state.** `State::Disabled` is about a whole field
3156 + /// refusing to answer. This says the field is live and one of its answers
3157 + /// is not available yet, which is a different sentence and the reason the
3158 + /// tone rule matters here: the *other* options are still usable.
3159 + ///
3160 + /// Added 0.28.0, from audiofiles' instrument mode selector (`e761833e`).
3161 + pub unavailable: Option<&'a str>,
3067 3162 }
3068 3163
3069 3164 impl<'a> Choice<'a> {
3070 3165 /// An option whose submitted value is also its label.
3071 3166 #[must_use]
3072 3167 pub const fn plain(value: &'a str) -> Self {
3168 + Self::new(value, value)
3169 + }
3170 +
3171 + /// An option that submits one string and reads as another.
3172 + ///
3173 + /// A constructor rather than a literal, which is what `#[non_exhaustive]`
3174 + /// costs and buys: outside this crate the struct cannot be built by naming
3175 + /// its members, so every call site goes through here and the next member
3176 + /// added breaks none of them.
3177 + #[must_use]
3178 + pub const fn new(value: &'a str, label: &'a str) -> Self {
3073 3179 Self {
3074 3180 value,
3075 - label: value,
3181 + label,
3182 + unavailable: None,
3076 3183 }
3077 3184 }
3185 +
3186 + /// The same option, not pickable yet, and why.
3187 + ///
3188 + /// Builder-shaped because the reason is the rare case: 39 of the 40 option
3189 + /// sites measured across the tree do not have one.
3190 + #[must_use]
3191 + pub const fn unless(mut self, reason: &'a str) -> Self {
3192 + self.unavailable = Some(reason);
3193 + self
3194 + }
3195 +
3196 + /// Whether the option can be picked right now.
3197 + ///
3198 + /// The predicate a renderer branches on, so that "unavailable" is read as
3199 + /// one condition in one place rather than as `unavailable.is_some()` at
3200 + /// three renderers, one of which will invert it.
3201 + #[must_use]
3202 + pub const fn available(&self) -> bool {
3203 + self.unavailable.is_none()
3204 + }
3078 3205 }
3079 3206
3080 3207 /// One field of a form.
@@ -3169,6 +3296,22 @@
3169 3296 /// The highest value accepted, as the host would write it. See
3170 3297 /// [`min`](Self::min).
3171 3298 pub max: Option<&'a str>,
3299 + /// The granularity the value moves in, as the host would write it.
3300 + ///
3301 + /// Text for [`min`](Self::min)'s reason, and it earns it twice over: the
3302 + /// step of a date is a day and the step of a threshold is 0.01, and a
3303 + /// numeric member could say one of them.
3304 + ///
3305 + /// Absent means the host's own granularity, which is the honest default
3306 + /// rather than a missing value: a webview's `<input>` steps by 1 unless told
3307 + /// otherwise, and that is the browser's rule and not this crate's to
3308 + /// restate. It matters most to [`FieldKind::Range`], where the host default
3309 + /// turns a 0-to-1 threshold into a two-position control, and it is not
3310 + /// exclusive to it: a stepped [`Number`](FieldKind::Number) is the same fact
3311 + /// about a typed value.
3312 + ///
3313 + /// Added 0.28.0 with [`FieldKind::Range`].
3314 + pub step: Option<&'a str>,
3172 3315 /// Whether the field lives behind a "more options" disclosure.
3173 3316 pub extended: bool,
3174 3317 }
@@ -3189,10 +3332,31 @@
3189 3332 max_length: None,
3190 3333 min: None,
3191 3334 max: None,
3335 + step: None,
3192 3336 extended: false,
3193 3337 }
3194 3338 }
3195 3339
3340 + /// A bounded number the user drags across its whole extent.
3341 + ///
3342 + /// The third under-described kind, and it gets a constructor for
3343 + /// [`select`](Self::select)'s reason: a range is the one kind whose bounds
3344 + /// are not a rule but the control itself, so a call site that forgot them
3345 + /// has a slider with nothing to slide across. Taking them as arguments is
3346 + /// what makes that unsayable.
3347 + ///
3348 + /// [`step`](Self::step) stays a field rather than a fourth argument. It is
3349 + /// genuinely optional — the host's granularity is a real answer — and the
3350 + /// two bounds are not.
3351 + #[must_use]
3352 + pub const fn range(name: &'a str, label: &'a str, min: &'a str, max: &'a str) -> Self {
3353 + Self {
3354 + min: Some(min),
3355 + max: Some(max),
3356 + ..Self::new(FieldKind::Range, name, label)
3357 + }
3358 + }
3359 +
3196 3360 /// A select offering the given options.
3197 3361 ///
3198 3362 /// One of the two kinds under-described by [`Field::new`], so it gets a
@@ -3242,6 +3406,24 @@
3242 3406 pub const fn invalid(&self) -> bool {
3243 3407 self.error.is_some()
3244 3408 }
3409 +
3410 + /// Whether the field carries both ends of its extent.
3411 + ///
3412 + /// Only [`FieldKind::Range`] owes them, and it owes them absolutely: a
3413 + /// slider with one end missing has no extent to draw. Named here rather
3414 + /// than left to each renderer to test `min.is_some() && max.is_some()`,
3415 + /// which is three renderers arriving at the same condition and one of them
3416 + /// getting it wrong, and named as a question about the *field* rather than
3417 + /// about the kind because the kind cannot see the bounds.
3418 + ///
3419 + /// It is a check and not a guarantee. Nothing here refuses to build an
3420 + /// unbounded range — [`Field::range`] is what makes the bounded one easy —
3421 + /// so a renderer asks this and falls back to whatever its host does
3422 + /// honestly with a number.
3423 + #[must_use]
3424 + pub const fn bounded(&self) -> bool {
3425 + self.min.is_some() && self.max.is_some()
3426 + }
3245 3427 }
3246 3428
3247 3429 /// How much room a placement asks for.
@@ -3948,6 +4130,7 @@
3948 4130 FieldKind::Email,
3949 4131 FieldKind::Url,
3950 4132 FieldKind::Tel,
4133 + FieldKind::Range,
3951 4134 FieldKind::Textarea,
3952 4135 FieldKind::Select,
3953 4136 FieldKind::Radio,
@@ -4411,11 +4594,12 @@
4411 4594 let plain = Choice::plain("7");
4412 4595 assert_eq!((plain.value, plain.label), ("7", "7"));
4413 4596
4414 - let spelled = Choice {
4415 - value: "7",
4416 - label: "One week",
4417 - };
4597 + let spelled = Choice::new("7", "One week");
4418 4598 assert_ne!(spelled.value, spelled.label);
4599 + assert!(
4600 + spelled.available(),
4601 + "an option is pickable until it says not"
4602 + );
4419 4603 }
4420 4604
4421 4605 #[test]
@@ -4426,14 +4610,8 @@
4426 4610 // without opening anything, and it can only decide that if the
4427 4611 // description said which question was asked.
4428 4612 let styles = [
4429 - Choice {
4430 - value: "copy",
4431 - label: "Copy samples in",
4432 - },
4433 - Choice {
4434 - value: "reference",
4435 - label: "Reference in place",
4436 - },
4613 + Choice::new("copy", "Copy samples in"),
4614 + Choice::new("reference", "Reference in place"),
4437 4615 ];
4438 4616 let radio = Field::radio("storage", "Storage style", &styles);
4439 4617 let select = Field::select("storage", "Storage style", &styles);
@@ -4450,6 +4628,60 @@
4450 4628 );
4451 4629 }
4452 4630
4631 + #[test]
4632 + fn an_unavailable_option_cannot_be_silent_about_it() {
4633 + // The whole content of the one-member shape: saying an option is not
4634 + // pickable and saying why are the same act, so the greyed-out-with-no-
4635 + // reason state is unsayable rather than merely discouraged.
4636 + let multi =
4637 + Choice::new("multi", "Multi-sample").unless("Drop a second sample onto the keyboard.");
4638 + assert!(!multi.available());
4639 + assert_eq!(
4640 + multi.unavailable,
4641 + Some("Drop a second sample onto the keyboard.")
4642 + );
4643 +
4644 + // And the option is still in the list, carrying what it submits, so a
4645 + // renderer draws it rather than the app dropping it.
4646 + assert_eq!(multi.value, "multi");
4647 + assert_eq!(multi.label, "Multi-sample");
4648 + }
4649 +
4650 + #[test]
4651 + fn a_range_carries_both_ends_and_a_validated_number_need_not() {
4652 + // The distinction the kind exists for, asserted rather than only
4653 + // written down: bounds are a rule for one and the control itself for
4654 + // the other.
4655 + let threshold = Field::range("review", "Review above", "0", "1");
4656 + assert_eq!(threshold.kind, FieldKind::Range);
4657 + assert!(threshold.bounded());
4658 + assert_eq!(threshold.min, Some("0"));
4659 + assert_eq!(threshold.max, Some("1"));
4660 + // Granularity is the host's until an app says otherwise.
4661 + assert_eq!(threshold.step, None);
4662 +
4663 + // goingson's duration: a typed number with a floor, and it must not
4664 + // read as a slider.
4665 + let minutes = Field {
4666 + min: Some("1"),
4667 + ..Field::new(FieldKind::Number, "minutes", "Minutes")
4668 + };
4669 + assert_ne!(minutes.kind, FieldKind::Range);
4670 + assert!(!minutes.bounded(), "one end is a rule, not an extent");
4671 + }
4672 +
4673 + #[test]
4674 + fn a_range_described_with_one_end_says_so_rather_than_being_refused() {
4675 + // Nothing here enforces the pair, for the reason nothing here enforces
4676 + // `required`: the description states the constraint and the renderer
4677 + // asks. What it must not do is look bounded.
4678 + let half = Field {
4679 + max: Some("1"),
4680 + ..Field::new(FieldKind::Range, "review", "Review above")
4681 + };
4682 + assert!(!half.bounded());
4683 + }
4684 +
4453 4685 #[test]
4454 4686 fn exactly_the_option_taking_kinds_say_so() {
4455 4687 // The renderers branch on this rather than on a list of their own, so
@@ -4463,6 +4695,7 @@
4463 4695 FieldKind::Email,
4464 4696 FieldKind::Url,
4465 4697 FieldKind::Tel,
4698 + FieldKind::Range,
4466 4699 FieldKind::Textarea,
4467 4700 FieldKind::Checkbox,
4468 4701 FieldKind::Hidden,