makeover_layout/choice.rs
1// Names this module's prose links to, resolved for rustdoc.
2#[allow(unused_imports)]
3use crate::{Field, FieldKind, Unit};
4
5/// One option offered by a field [`FieldKind::offers_options`] accepts.
6///
7/// Two strings, because the submitted value and the read label are different
8/// facts and every renderer that has tried to collapse them has had to
9/// un-collapse them later. `makeover-webview` invented this shape writing its
10/// form emitter and it is taken here unchanged; moving it down rather than
11/// re-deriving it is the point, since the second and third renderers were each
12/// going to arrive at a near-miss of it.
13/// `#[non_exhaustive]`, which every type here that a renderer matches or builds
14/// carries. Without it a new member is a breaking change at every literal site
15/// in the tree.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
17#[non_exhaustive]
18pub struct Choice<'a> {
19 /// What is submitted.
20 pub value: &'a str,
21 /// What is read.
22 pub label: &'a str,
23 /// Why it cannot be picked right now, when it cannot.
24 ///
25 /// One member rather than an `available: bool` beside a reason, and the
26 /// conflation is the point: an option greyed out with no explanation is a
27 /// dead end the user cannot act on, and it is exactly the state the app
28 /// that found this gap had to patch by hand with a line of prose under the
29 /// control. Making the reason mandatory means the description cannot say
30 /// the useless half.
31 ///
32 /// The option stays in the list. Dropping it is what an app does today, and
33 /// it costs the user the knowledge that the thing exists at all —
34 /// audiofiles' multi-sample mode appears on its own once a second sample is
35 /// dropped, so a user who never sees it never learns what to drop.
36 ///
37 /// **Not [`Field::error`], and not [`Field::hint`].** An error is about the
38 /// answer and a hint is standing help for the whole question; this is about
39 /// one option among several, which is the level neither of those reaches.
40 ///
41 /// **Not disabled-the-state.** `State::Disabled` is about a whole field
42 /// refusing to answer. This says the field is live and one of its answers
43 /// is not available yet, which is a different sentence and the reason the
44 /// tone rule matters here: the *other* options are still usable.
45 pub unavailable: Option<&'a str>,
46 /// The line under the label that says what picking this means.
47 ///
48 /// A choice between three plans is a choice nobody can make from three
49 /// names, and until this existed the description had nowhere to put the
50 /// sentence that made it makeable. What the corpus did instead is the
51 /// tell: four of the six measured sites fold it into the label —
52 /// `<strong>Public</strong>: Anyone can see this repository` in MNW's git
53 /// settings, the same shape in its project-basics AI tier and its cart's
54 /// currency conversion, and `Mislabeled (wrong AI tier or category)` in
55 /// its report modal. The described screens do it too, in miniature: `Every
56 /// 15 minutes (recommended)`, `Reference samples in place (loose-files
57 /// mode)`. One fact, six spellings, no member.
58 ///
59 /// # Where it goes is the host's, and the rule already exists
60 ///
61 /// This is [`unavailable`](Self::unavailable)'s question met a third time
62 /// and it takes the same answer, which is the strongest evidence one member
63 /// is right rather than two. A radio group has room and gives the line its
64 /// own element beside the label. A `<select>`'s option takes no elements,
65 /// no second line and no title a keyboard reaches, so the line runs into
66 /// the option's own text — exactly as a precondition does, and as a theme's
67 /// contrast badge does in brackets. A terminal has rows and puts it on one
68 /// under the option.
69 ///
70 /// # Not a price, and that is a measurement rather than a preference
71 ///
72 /// The site that asked for this is MNW's fee calculator, whose tier cards
73 /// carry a name, a price *and* a description, so a second member for the
74 /// price was on the table. It loses on the count: the tree's other three
75 /// priced tier lists — `project.html`, `project_paywall.html`,
76 /// `index.html` — are not option lists at all. Each card carries its own
77 /// submit, which makes it a region with a heading, a fact and an act, and
78 /// it is sayable already. So a price member would have exactly one
79 /// consumer, and it would mean this crate growing a money type it does not
80 /// have: [`Unit`] is a time axis, and every amount in the described tree is
81 /// text.
82 ///
83 /// The price therefore leads the line: `$24/mo. 2GB/file, 100GB total.
84 /// Fits audio, plugins, binaries.` What would reopen it is a **second**
85 /// priced option list, not a judgement about how that reads.
86 ///
87 /// # What it is not
88 ///
89 /// Not [`unavailable`](Self::unavailable), which says the option cannot be
90 /// picked. This says what it means to pick it, and the two are drawn
91 /// together on an option that carries both: the description that says a
92 /// tier is out of stock *and* what the tier is has said two things.
93 ///
94 /// Not [`Field::hint`], which is standing help for the whole question, and
95 /// not markup. One line of plain text, for [`Candidate::detail`]'s reason:
96 /// an option list is a place a renderer lays out, and a description that
97 /// put a block in one would be handing every host a layout problem for the
98 /// benefit of one.
99 pub detail: Option<&'a str>,
100 /// Whether this is the option currently chosen.
101 ///
102 /// The alternative, and what every renderer here did before this member
103 /// existed, is to compare the field's current value against each option's
104 /// own. That reads the same and is not the same: it states which option is
105 /// marked ONCE, at the field, and leaves each option to work out whether
106 /// the sentence is about it. A description whose data already knows per row
107 /// -- a theme list where each theme carries `selected` -- then has to
108 /// collapse that to one string for the renderer to re-derive, which is one
109 /// fact stated twice.
110 ///
111 /// # Never both
112 ///
113 /// An option list either marks itself here or is matched against the
114 /// field's value, and a description that does both has said one thing two
115 /// ways, which is how the two drift. quasi-declare refuses the pair at
116 /// compile time. This crate cannot: it is handed a list and a value with no
117 /// record of which spelling built them, so a renderer marks an option whose
118 /// `chosen` is set OR whose value matches, and a caller that sets both gets
119 /// both marked.
120 ///
121 /// # What it buys, beyond saying it once
122 ///
123 /// It is the only form a compiled template can carry. A residual holds one
124 /// body per loop, so "exactly one row differs" cannot be a property of the
125 /// row body when the difference is decided by a comparison the body does
126 /// not make. Said here it is a branch inside the row, which is a shape a
127 /// residual has.
128 pub chosen: bool,
129}
130
131impl<'a> Choice<'a> {
132 /// An option whose submitted value is also its label.
133 #[must_use]
134 pub const fn plain(value: &'a str) -> Self {
135 Self::new(value, value)
136 }
137
138 /// An option that submits one string and reads as another.
139 ///
140 /// A constructor rather than a literal, which is what `#[non_exhaustive]`
141 /// costs and buys: outside this crate the struct cannot be built by naming
142 /// its members, so every call site goes through here and the next member
143 /// added breaks none of them.
144 #[must_use]
145 pub const fn new(value: &'a str, label: &'a str) -> Self {
146 Self {
147 value,
148 label,
149 unavailable: None,
150 detail: None,
151 chosen: false,
152 }
153 }
154
155 /// The same option, not pickable yet, and why.
156 ///
157 /// Builder-shaped because the reason is the rare case: 39 of the 40 option
158 /// sites measured across the tree do not have one.
159 #[must_use]
160 pub const fn unless(mut self, reason: &'a str) -> Self {
161 self.unavailable = Some(reason);
162 self
163 }
164
165 /// The same option, with the line that says what picking it means.
166 ///
167 /// Builder-shaped for [`unless`](Self::unless)'s reason, and it is the
168 /// commoner of the two: six measured sites want this and one wants a
169 /// precondition. See [`detail`](Self::detail).
170 #[must_use]
171 pub const fn detailing(mut self, detail: &'a str) -> Self {
172 self.detail = Some(detail);
173 self
174 }
175
176 /// The same option, marked as the one currently chosen.
177 ///
178 /// See [`chosen`](Self::chosen). Builder-shaped like the other two, and for
179 /// the same reason: the marked option is one row of a list where every
180 /// other row is not.
181 #[must_use]
182 pub const fn chosen(mut self) -> Self {
183 self.chosen = true;
184 self
185 }
186
187 /// Whether the option can be picked right now.
188 ///
189 /// The predicate a renderer branches on, so that "unavailable" is read as
190 /// one condition in one place rather than as `unavailable.is_some()` at
191 /// three renderers, one of which will invert it.
192 #[must_use]
193 pub const fn available(&self) -> bool {
194 self.unavailable.is_none()
195 }
196}
197
198/// One entry in a field's suggestion list.
199///
200/// A suggestion-only type rather than a fourth member on [`Choice`], ruled by
201/// Max. The two are near-identical and that is the accepted drift risk, so the
202/// mitigation is written here: **an
203/// option and a candidate are submitted the same way and read differently.**
204/// An option is a thing you pick from a known set, and the set is the whole of
205/// what there is. A candidate is a thing you are being *oriented* toward out of
206/// a set nobody can see, which is why it carries [`detail`](Self::detail) and
207/// an option does not.
208///
209/// This reverses a position quasi-router stated in its own doc, that a
210/// candidate is [`Choice`] "because a candidate is submitted under one string
211/// and read under another, which is what an option is". True and not
212/// sufficient: how a thing is submitted was never the half that differed.
213///
214/// # Why the second string is not folded into the label
215///
216/// Because every renderer wants it separately, and the two measured sites both
217/// draw it by hand today. The MNW server's tag box computes its context as the
218/// parent path -- "the parent path orients an otherwise ambiguous leaf:
219/// 'Format' appears under audio, software, writing, and video" -- and a list of
220/// four identical rows reading "Format" is not a usable list. In a webview the
221/// second string is styled differently, in a terminal it wants the remaining
222/// columns rather than a dash, and in neither is it part of what the typed
223/// value matches against. `Choice::new(slug, format!("{label} - {context}"))`
224/// loses all three of those facts, which is the condition this type exists to
225/// end.
226///
227/// # No `unavailable`
228///
229/// [`Choice::unavailable`] has no counterpart here, and the omission is the
230/// implementer's call recorded rather than an oversight. A suggestion that
231/// cannot be picked is arguably not a suggestion: an option list is a fixed set
232/// a user is owed an explanation about, and a candidate list is whatever a
233/// route decided to offer, so a route with nothing to say simply does not offer
234/// the row. Add it if a measured site ever wants it.
235///
236/// # What it does not carry, and where that lives
237///
238/// What *happens* when a candidate is picked. Picking is local by default -- it
239/// writes [`value`](Self::value) into the field that owns the list -- and a
240/// candidate that does something else says so with an action. An action is not
241/// a word this crate has, exactly as [`Field`] here has no `suggests` member,
242/// so both live on the router's owned mirror of this type.
243///
244/// `#[non_exhaustive]` from birth. Non-negotiable: adding it later means a
245/// breaking change at every literal site in the tree.
246#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
247#[non_exhaustive]
248pub struct Candidate<'a> {
249 /// What is submitted, and what picking writes into the field.
250 pub value: &'a str,
251 /// What is read.
252 pub label: &'a str,
253 /// The second line: what orients this candidate among rows that read alike.
254 ///
255 /// Optional because a candidate list whose labels are already distinct
256 /// wants nothing here, and a renderer given [`None`] draws one line rather
257 /// than an empty second one.
258 pub detail: Option<&'a str>,
259}
260
261impl<'a> Candidate<'a> {
262 /// A candidate whose submitted value is also its label.
263 #[must_use]
264 pub const fn plain(value: &'a str) -> Self {
265 Self::new(value, value)
266 }
267
268 /// A candidate that submits one string and reads as another.
269 ///
270 /// A constructor rather than a literal, which is what `#[non_exhaustive]`
271 /// costs and buys: outside this crate the struct cannot be built by naming
272 /// its members, so every call site goes through here and the next member
273 /// added breaks none of them.
274 #[must_use]
275 pub const fn new(value: &'a str, label: &'a str) -> Self {
276 Self {
277 value,
278 label,
279 detail: None,
280 }
281 }
282
283 /// The same candidate, with the line that tells it from its neighbours.
284 #[must_use]
285 pub const fn detailed(mut self, detail: &'a str) -> Self {
286 self.detail = Some(detail);
287 self
288 }
289}
290
291/// One field of a form.
292///
293/// Borrowed rather than owned: a description is built, read once by a renderer,
294/// and dropped. Nothing here outlives the screen it describes.
295///
296/// # What it carries, and what it does not
297///
298/// Stated here so the next renderer does not re-ask, which is what the first
299/// two both did. It carries everything a renderer needs to *draw* the field:
300/// its kind, what it is called, what it is asked for, its standing help, what
301/// is wrong with it now, whether it is compulsory, whether it hides behind a
302/// disclosure, its ghost text, and the options it offers.
303///
304/// It does not carry the **current value**, and it is not going to. That is the
305/// one thing here that is genuinely renderer state: a webview reads it back out
306/// of the DOM, an immediate-mode renderer holds a `&mut` to the app's own field
307/// and writes through it, and a terminal keeps an edit buffer. A description
308/// that carried the value would have to carry a way to write it back, at which
309/// point it is a form model and no longer a description.
310///
311/// **Constraints** are here and enforcement is not, which is one line rather
312/// than two. [`required`], [`max_length`], [`min`] and [`max`] are facts about
313/// the *question*, so a renderer can emit its host's idiom for each — an HTML
314/// attribute, a marked label, a clamped spinner — and the platform helps the
315/// user before anything is submitted. Deciding that a value is wrong stays with
316/// whoever validated, and [`error`] is that decision arriving back.
317///
318/// The set stops before `pattern`, and stops there on both tests at once. A
319/// regex has an honest answer in a webview and none anywhere else: egui would
320/// have to run it per keystroke and decide what a half-typed value means,
321/// which is enforcement wearing description's clothes. And it is one site in
322/// goingson and none in Balanced Breakfast, against 8 and 1 for `maxlength`.
323///
324/// [`error`]: Field::error
325/// [`required`]: Field::required
326/// [`max_length`]: Field::max_length
327/// [`min`]: Field::min
328/// [`max`]: Field::max
329/// How a slider's position becomes its value, and how finely it moves.
330///
331/// **The data of a slider is a fraction and a function taking numbers to
332/// numbers.** Stated by Max, and it is what [`min`](Field::min) and
333/// [`max`](Field::max) are not: they were never the control's extent.
334/// A slider's extent is always 0 to 1 — a thumb at 40% of a track — and the
335/// bounds are `f(0)` and `f(1)`. Linear is the constant-slope case, which is
336/// exactly why nobody noticed the function was there: when `f` is
337/// `min + t * (max - min)` the extent and the bounds coincide numerically and
338/// the mapping is invisible.
339///
340/// So this is not a scale flag bolted onto a range. Every range described
341/// before it had a mapping, and four renderers each hard-coded the same one.
342///
343/// # Why a closed family and not a function
344///
345/// `fn(f64) -> f64` is the literal reading and it does not survive the
346/// description boundary. A fn pointer cannot be emitted into a browser, and it
347/// cannot be compared or hashed in a way that means anything, which this struct
348/// needs. A named family is the same semantics with arbitrary closures given
349/// up, and nothing measured wants one: the tree has a single non-linear shape
350/// across five controls and no second shape at all.
351///
352/// # Why the step is here
353///
354/// Max, in the same breath: if the family is prescriptive anyway, the step
355/// spacing belongs in it. On a slider the granularity and the mapping are one
356/// decision — a curve chosen without saying how finely it moves is half an
357/// answer — and holding them apart is what let a 0-to-1 threshold ship as a
358/// two-position control, since the host default of 1 was applied to a mapping
359/// nobody had named. It also un-overloads [`Field::step`], which stays as it
360/// was for a *typed* value, where there is no mapping and the granularity is a
361/// plain fact about the number.
362///
363/// A future curve carrying a fact of its own — an exponent, an inflection —
364/// puts it in its own variant rather than on the struct, which is the second
365/// reason this shape is right.
366///
367/// **The step is in the value's own units under every curve.** What a curve
368/// changes is the mapping, not the units the granularity is measured in: a step
369/// of `0.001` on an envelope time is three decimals whether the track is
370/// logarithmic or not, and a renderer that reads the step for display precision
371/// keeps reading it the same way.
372#[non_exhaustive]
373#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
374pub enum Curve<'a> {
375 /// Constant slope: `f(t) = min + t * (max - min)`.
376 ///
377 /// What every described range meant before this enum existed, and the
378 /// default, so a site that says nothing is correct unchanged.
379 Linear {
380 /// The granularity, in the value's own units. `None` is the host's own.
381 step: Option<&'a str>,
382 },
383 /// Constant ratio: `f(t) = min * (max / min).powf(t)`.
384 ///
385 /// The mapping for a question whose extent spans orders of magnitude and
386 /// whose interesting half is the small end. audiofiles' envelope times run
387 /// 0.001 to 5 seconds, where a 5 ms attack and a 50 ms attack are audibly
388 /// different instruments and a linear track puts both inside its first one
389 /// percent.
390 ///
391 /// # It needs positive bounds
392 ///
393 /// A constant ratio is undefined across zero, so this asks for `min > 0`.
394 /// A range that does not have that is mapped [`Linear`](Self::Linear)ly
395 /// instead — see [`value_at`](Self::value_at). Stated rather than enforced,
396 /// the way every other constraint in this crate is, and it is not a
397 /// hypothetical: an envelope's sustain is a 0-to-1 level and is linear for
398 /// this reason rather than by oversight.
399 Logarithmic {
400 /// The granularity, in the value's own units. `None` is the host's own.
401 step: Option<&'a str>,
402 },
403}
404
405impl Default for Curve<'_> {
406 fn default() -> Self {
407 Self::Linear { step: None }
408 }
409}
410
411impl<'a> Curve<'a> {
412 /// The granularity this curve moves in, whichever curve it is.
413 ///
414 /// Every variant carries one, so reading it does not need a match at each
415 /// of the four renderers.
416 #[must_use]
417 pub const fn step(self) -> Option<&'a str> {
418 // No wildcard: `#[non_exhaustive]` binds downstream, not here, so a
419 // curve added later has to answer this rather than fall through to a
420 // granularity nobody chose.
421 match self {
422 Self::Linear { step } | Self::Logarithmic { step } => step,
423 }
424 }
425
426 /// Whether this curve maps as a constant ratio *given these bounds*.
427 ///
428 /// The bounds are the argument because [`Logarithmic`](Self::Logarithmic)
429 /// is a request rather than a guarantee: it needs `0 < min < max`, and a
430 /// range that does not have that is drawn linearly. A renderer asks this
431 /// instead of matching on the variant, so the fallback is decided in one
432 /// place rather than four.
433 #[must_use]
434 pub fn is_ratio(self, min: f64, max: f64) -> bool {
435 matches!(self, Self::Logarithmic { .. }) && min > 0.0 && max > min
436 }
437
438 /// The value at a position along the track, where `position` is 0 to 1.
439 ///
440 /// `f`. The whole point of the type, and it lives here rather than in each
441 /// renderer so that a terminal's bar, an egui slider and a browser's input
442 /// cannot disagree about where a value sits.
443 ///
444 /// A position outside 0 to 1 is clamped, and bounds that are equal or
445 /// inverted give `min` back: a track with no extent has one value on it.
446 #[must_use]
447 pub fn value_at(self, position: f64, min: f64, max: f64) -> f64 {
448 let position = position.clamp(0.0, 1.0);
449 // NaN named rather than fallen through: `max <= min` is false for a NaN
450 // bound, so without it a track with no numbers on it would be mapped as
451 // if it had two.
452 if max <= min || min.is_nan() || max.is_nan() {
453 return min;
454 }
455 if self.is_ratio(min, max) {
456 min * (max / min).powf(position)
457 } else {
458 position.mul_add(max - min, min)
459 }
460 }
461
462 /// The position a value sits at, where the answer is 0 to 1.
463 ///
464 /// `f` inverted, which is what a renderer needs to *draw* a value it was
465 /// handed. Same clamping and the same degenerate answer as
466 /// [`value_at`](Self::value_at).
467 #[must_use]
468 pub fn position_of(self, value: f64, min: f64, max: f64) -> f64 {
469 if max <= min || min.is_nan() || max.is_nan() {
470 return 0.0;
471 }
472 let value = value.clamp(min, max);
473 let position = if self.is_ratio(min, max) {
474 (value / min).ln() / (max / min).ln()
475 } else {
476 (value - min) / (max - min)
477 };
478 position.clamp(0.0, 1.0)
479 }
480}