Skip to main content

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}