| 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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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,
|