Skip to main content

max / makeover-layout

20.2 KB · 440 lines History Blame Raw
1 // Names this module's prose links to, resolved for rustdoc.
2 #[allow(unused_imports)]
3 use 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]
18 pub 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 }
101
102 impl<'a> Choice<'a> {
103 /// An option whose submitted value is also its label.
104 #[must_use]
105 pub const fn plain(value: &'a str) -> Self {
106 Self::new(value, value)
107 }
108
109 /// An option that submits one string and reads as another.
110 ///
111 /// A constructor rather than a literal, which is what `#[non_exhaustive]`
112 /// costs and buys: outside this crate the struct cannot be built by naming
113 /// its members, so every call site goes through here and the next member
114 /// added breaks none of them.
115 #[must_use]
116 pub const fn new(value: &'a str, label: &'a str) -> Self {
117 Self {
118 value,
119 label,
120 unavailable: None,
121 detail: None,
122 }
123 }
124
125 /// The same option, not pickable yet, and why.
126 ///
127 /// Builder-shaped because the reason is the rare case: 39 of the 40 option
128 /// sites measured across the tree do not have one.
129 #[must_use]
130 pub const fn unless(mut self, reason: &'a str) -> Self {
131 self.unavailable = Some(reason);
132 self
133 }
134
135 /// The same option, with the line that says what picking it means.
136 ///
137 /// Builder-shaped for [`unless`](Self::unless)'s reason, and it is the
138 /// commoner of the two: six measured sites want this and one wants a
139 /// precondition. See [`detail`](Self::detail).
140 #[must_use]
141 pub const fn detailing(mut self, detail: &'a str) -> Self {
142 self.detail = Some(detail);
143 self
144 }
145
146 /// Whether the option can be picked right now.
147 ///
148 /// The predicate a renderer branches on, so that "unavailable" is read as
149 /// one condition in one place rather than as `unavailable.is_some()` at
150 /// three renderers, one of which will invert it.
151 #[must_use]
152 pub const fn available(&self) -> bool {
153 self.unavailable.is_none()
154 }
155 }
156
157 /// One entry in a field's suggestion list.
158 ///
159 /// A suggestion-only type rather than a fourth member on [`Choice`], ruled by
160 /// Max. The two are near-identical and that is the accepted drift risk, so the
161 /// mitigation is written here: **an
162 /// option and a candidate are submitted the same way and read differently.**
163 /// An option is a thing you pick from a known set, and the set is the whole of
164 /// what there is. A candidate is a thing you are being *oriented* toward out of
165 /// a set nobody can see, which is why it carries [`detail`](Self::detail) and
166 /// an option does not.
167 ///
168 /// This reverses a position quasi-router stated in its own doc, that a
169 /// candidate is [`Choice`] "because a candidate is submitted under one string
170 /// and read under another, which is what an option is". True and not
171 /// sufficient: how a thing is submitted was never the half that differed.
172 ///
173 /// # Why the second string is not folded into the label
174 ///
175 /// Because every renderer wants it separately, and the two measured sites both
176 /// draw it by hand today. The MNW server's tag box computes its context as the
177 /// parent path -- "the parent path orients an otherwise ambiguous leaf:
178 /// 'Format' appears under audio, software, writing, and video" -- and a list of
179 /// four identical rows reading "Format" is not a usable list. In a webview the
180 /// second string is styled differently, in a terminal it wants the remaining
181 /// columns rather than a dash, and in neither is it part of what the typed
182 /// value matches against. `Choice::new(slug, format!("{label} - {context}"))`
183 /// loses all three of those facts, which is the condition this type exists to
184 /// end.
185 ///
186 /// # No `unavailable`
187 ///
188 /// [`Choice::unavailable`] has no counterpart here, and the omission is the
189 /// implementer's call recorded rather than an oversight. A suggestion that
190 /// cannot be picked is arguably not a suggestion: an option list is a fixed set
191 /// a user is owed an explanation about, and a candidate list is whatever a
192 /// route decided to offer, so a route with nothing to say simply does not offer
193 /// the row. Add it if a measured site ever wants it.
194 ///
195 /// # What it does not carry, and where that lives
196 ///
197 /// What *happens* when a candidate is picked. Picking is local by default -- it
198 /// writes [`value`](Self::value) into the field that owns the list -- and a
199 /// candidate that does something else says so with an action. An action is not
200 /// a word this crate has, exactly as [`Field`] here has no `suggests` member,
201 /// so both live on the router's owned mirror of this type.
202 ///
203 /// `#[non_exhaustive]` from birth. Non-negotiable: adding it later means a
204 /// breaking change at every literal site in the tree.
205 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
206 #[non_exhaustive]
207 pub struct Candidate<'a> {
208 /// What is submitted, and what picking writes into the field.
209 pub value: &'a str,
210 /// What is read.
211 pub label: &'a str,
212 /// The second line: what orients this candidate among rows that read alike.
213 ///
214 /// Optional because a candidate list whose labels are already distinct
215 /// wants nothing here, and a renderer given [`None`] draws one line rather
216 /// than an empty second one.
217 pub detail: Option<&'a str>,
218 }
219
220 impl<'a> Candidate<'a> {
221 /// A candidate whose submitted value is also its label.
222 #[must_use]
223 pub const fn plain(value: &'a str) -> Self {
224 Self::new(value, value)
225 }
226
227 /// A candidate that submits one string and reads as another.
228 ///
229 /// A constructor rather than a literal, which is what `#[non_exhaustive]`
230 /// costs and buys: outside this crate the struct cannot be built by naming
231 /// its members, so every call site goes through here and the next member
232 /// added breaks none of them.
233 #[must_use]
234 pub const fn new(value: &'a str, label: &'a str) -> Self {
235 Self {
236 value,
237 label,
238 detail: None,
239 }
240 }
241
242 /// The same candidate, with the line that tells it from its neighbours.
243 #[must_use]
244 pub const fn detailed(mut self, detail: &'a str) -> Self {
245 self.detail = Some(detail);
246 self
247 }
248 }
249
250 /// One field of a form.
251 ///
252 /// Borrowed rather than owned: a description is built, read once by a renderer,
253 /// and dropped. Nothing here outlives the screen it describes.
254 ///
255 /// # What it carries, and what it does not
256 ///
257 /// Stated here so the next renderer does not re-ask, which is what the first
258 /// two both did. It carries everything a renderer needs to *draw* the field:
259 /// its kind, what it is called, what it is asked for, its standing help, what
260 /// is wrong with it now, whether it is compulsory, whether it hides behind a
261 /// disclosure, its ghost text, and the options it offers.
262 ///
263 /// It does not carry the **current value**, and it is not going to. That is the
264 /// one thing here that is genuinely renderer state: a webview reads it back out
265 /// of the DOM, an immediate-mode renderer holds a `&mut` to the app's own field
266 /// and writes through it, and a terminal keeps an edit buffer. A description
267 /// that carried the value would have to carry a way to write it back, at which
268 /// point it is a form model and no longer a description.
269 ///
270 /// **Constraints** are here and enforcement is not, which is one line rather
271 /// than two. [`required`], [`max_length`], [`min`] and [`max`] are facts about
272 /// the *question*, so a renderer can emit its host's idiom for each — an HTML
273 /// attribute, a marked label, a clamped spinner — and the platform helps the
274 /// user before anything is submitted. Deciding that a value is wrong stays with
275 /// whoever validated, and [`error`] is that decision arriving back.
276 ///
277 /// The set stops before `pattern`, and stops there on both tests at once. A
278 /// regex has an honest answer in a webview and none anywhere else: egui would
279 /// have to run it per keystroke and decide what a half-typed value means,
280 /// which is enforcement wearing description's clothes. And it is one site in
281 /// goingson and none in Balanced Breakfast, against 8 and 1 for `maxlength`.
282 ///
283 /// [`error`]: Field::error
284 /// [`required`]: Field::required
285 /// [`max_length`]: Field::max_length
286 /// [`min`]: Field::min
287 /// [`max`]: Field::max
288 /// How a slider's position becomes its value, and how finely it moves.
289 ///
290 /// **The data of a slider is a fraction and a function taking numbers to
291 /// numbers.** Stated by Max, and it is what [`min`](Field::min) and
292 /// [`max`](Field::max) are not: they were never the control's extent.
293 /// A slider's extent is always 0 to 1 — a thumb at 40% of a track — and the
294 /// bounds are `f(0)` and `f(1)`. Linear is the constant-slope case, which is
295 /// exactly why nobody noticed the function was there: when `f` is
296 /// `min + t * (max - min)` the extent and the bounds coincide numerically and
297 /// the mapping is invisible.
298 ///
299 /// So this is not a scale flag bolted onto a range. Every range described
300 /// before it had a mapping, and four renderers each hard-coded the same one.
301 ///
302 /// # Why a closed family and not a function
303 ///
304 /// `fn(f64) -> f64` is the literal reading and it does not survive the
305 /// description boundary. A fn pointer cannot be emitted into a browser, and it
306 /// cannot be compared or hashed in a way that means anything, which this struct
307 /// needs. A named family is the same semantics with arbitrary closures given
308 /// up, and nothing measured wants one: the tree has a single non-linear shape
309 /// across five controls and no second shape at all.
310 ///
311 /// # Why the step is here
312 ///
313 /// Max, in the same breath: if the family is prescriptive anyway, the step
314 /// spacing belongs in it. On a slider the granularity and the mapping are one
315 /// decision — a curve chosen without saying how finely it moves is half an
316 /// answer — and holding them apart is what let a 0-to-1 threshold ship as a
317 /// two-position control, since the host default of 1 was applied to a mapping
318 /// nobody had named. It also un-overloads [`Field::step`], which stays as it
319 /// was for a *typed* value, where there is no mapping and the granularity is a
320 /// plain fact about the number.
321 ///
322 /// A future curve carrying a fact of its own — an exponent, an inflection —
323 /// puts it in its own variant rather than on the struct, which is the second
324 /// reason this shape is right.
325 ///
326 /// **The step is in the value's own units under every curve.** What a curve
327 /// changes is the mapping, not the units the granularity is measured in: a step
328 /// of `0.001` on an envelope time is three decimals whether the track is
329 /// logarithmic or not, and a renderer that reads the step for display precision
330 /// keeps reading it the same way.
331 #[non_exhaustive]
332 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
333 pub enum Curve<'a> {
334 /// Constant slope: `f(t) = min + t * (max - min)`.
335 ///
336 /// What every described range meant before this enum existed, and the
337 /// default, so a site that says nothing is correct unchanged.
338 Linear {
339 /// The granularity, in the value's own units. `None` is the host's own.
340 step: Option<&'a str>,
341 },
342 /// Constant ratio: `f(t) = min * (max / min).powf(t)`.
343 ///
344 /// The mapping for a question whose extent spans orders of magnitude and
345 /// whose interesting half is the small end. audiofiles' envelope times run
346 /// 0.001 to 5 seconds, where a 5 ms attack and a 50 ms attack are audibly
347 /// different instruments and a linear track puts both inside its first one
348 /// percent.
349 ///
350 /// # It needs positive bounds
351 ///
352 /// A constant ratio is undefined across zero, so this asks for `min > 0`.
353 /// A range that does not have that is mapped [`Linear`](Self::Linear)ly
354 /// instead — see [`value_at`](Self::value_at). Stated rather than enforced,
355 /// the way every other constraint in this crate is, and it is not a
356 /// hypothetical: an envelope's sustain is a 0-to-1 level and is linear for
357 /// this reason rather than by oversight.
358 Logarithmic {
359 /// The granularity, in the value's own units. `None` is the host's own.
360 step: Option<&'a str>,
361 },
362 }
363
364 impl Default for Curve<'_> {
365 fn default() -> Self {
366 Self::Linear { step: None }
367 }
368 }
369
370 impl<'a> Curve<'a> {
371 /// The granularity this curve moves in, whichever curve it is.
372 ///
373 /// Every variant carries one, so reading it does not need a match at each
374 /// of the four renderers.
375 #[must_use]
376 pub const fn step(self) -> Option<&'a str> {
377 // No wildcard: `#[non_exhaustive]` binds downstream, not here, so a
378 // curve added later has to answer this rather than fall through to a
379 // granularity nobody chose.
380 match self {
381 Self::Linear { step } | Self::Logarithmic { step } => step,
382 }
383 }
384
385 /// Whether this curve maps as a constant ratio *given these bounds*.
386 ///
387 /// The bounds are the argument because [`Logarithmic`](Self::Logarithmic)
388 /// is a request rather than a guarantee: it needs `0 < min < max`, and a
389 /// range that does not have that is drawn linearly. A renderer asks this
390 /// instead of matching on the variant, so the fallback is decided in one
391 /// place rather than four.
392 #[must_use]
393 pub fn is_ratio(self, min: f64, max: f64) -> bool {
394 matches!(self, Self::Logarithmic { .. }) && min > 0.0 && max > min
395 }
396
397 /// The value at a position along the track, where `position` is 0 to 1.
398 ///
399 /// `f`. The whole point of the type, and it lives here rather than in each
400 /// renderer so that a terminal's bar, an egui slider and a browser's input
401 /// cannot disagree about where a value sits.
402 ///
403 /// A position outside 0 to 1 is clamped, and bounds that are equal or
404 /// inverted give `min` back: a track with no extent has one value on it.
405 #[must_use]
406 pub fn value_at(self, position: f64, min: f64, max: f64) -> f64 {
407 let position = position.clamp(0.0, 1.0);
408 // NaN named rather than fallen through: `max <= min` is false for a NaN
409 // bound, so without it a track with no numbers on it would be mapped as
410 // if it had two.
411 if max <= min || min.is_nan() || max.is_nan() {
412 return min;
413 }
414 if self.is_ratio(min, max) {
415 min * (max / min).powf(position)
416 } else {
417 position.mul_add(max - min, min)
418 }
419 }
420
421 /// The position a value sits at, where the answer is 0 to 1.
422 ///
423 /// `f` inverted, which is what a renderer needs to *draw* a value it was
424 /// handed. Same clamping and the same degenerate answer as
425 /// [`value_at`](Self::value_at).
426 #[must_use]
427 pub fn position_of(self, value: f64, min: f64, max: f64) -> f64 {
428 if max <= min || min.is_nan() || max.is_nan() {
429 return 0.0;
430 }
431 let value = value.clamp(min, max);
432 let position = if self.is_ratio(min, max) {
433 (value / min).ln() / (max / min).ln()
434 } else {
435 (value - min) / (max - min)
436 };
437 position.clamp(0.0, 1.0)
438 }
439 }
440