Skip to main content

max / makeover-layout

48.9 KB · 1032 lines History Blame Raw
1 use crate::{Choice, Curve, ThemeChoice, Tone};
2
3 // Names this module's prose links to, resolved for rustdoc.
4 #[allow(unused_imports)]
5 use crate::{Awaiting, Contrast, Fill, ThemeVariant};
6
7 /// What kind of value a form field takes.
8 ///
9 /// The union of the two vocabularies that diverged, which is what triggered
10 /// this crate. They have since converged on their own: both apps now have a
11 /// `renderFormField` emitting the same anatomy, and what is left differing is
12 /// the kind set, the error shape, and whether the return is a string or a node.
13 ///
14 /// Validation is deliberately absent. Neither app has a shared story (goingson
15 /// validates after collecting the form data, with per-field transform hooks;
16 /// Balanced Breakfast has `required` and nothing else), and a schema that
17 /// describes fields but not constraints acquires a constraint layer per app,
18 /// which is exactly how the current divergence started. Naming it absent is a
19 /// decision; leaving it unmentioned would not be.
20 /// `#[non_exhaustive]` for the reason [`Fill`] is: renderers match on this and
21 /// the set keeps growing, so growth must not be a lockstep event.
22 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
23 #[non_exhaustive]
24 pub enum FieldKind {
25 /// A single line of text.
26 Text,
27 /// A single line of text that must never be echoed, logged or round-tripped
28 /// through anything that might persist it.
29 Secret,
30 /// A number.
31 Number,
32 /// A number inside bounds the user drags across, where the range being
33 /// visible is the point.
34 ///
35 /// Not [`Number`](Self::Number) with [`min`](Field::min) and
36 /// [`max`](Field::max), which is the reading to resist and is the same
37 /// resistance [`Radio`](Self::Radio) needed against `Select`. A bounded
38 /// number and a validated number are different *questions*. A validated
39 /// number is typed and can be wrong: the bounds are a rule the answer is
40 /// checked against, and being told "must be at least 1" afterwards is the
41 /// normal course of it. A range cannot be out of range at all, because the
42 /// bounds are the control's extent rather than a rule, and the two ends are
43 /// what the question means — audiofiles asks for a classifier threshold
44 /// between 0 and 1, where 0 is never and 1 is only-on-certainty, and a typed
45 /// 0.72 says nothing without both ends on screen beside it.
46 ///
47 /// A renderer cannot infer which one is meant from `min`/`max` alone, which
48 /// is why this is a kind and not an inference: goingson's `min="1"` duration
49 /// is a validated number and would become a slider.
50 ///
51 /// The membership test passes without stretching: a webview emits
52 /// `<input type="range">`, egui has `Slider`, a terminal draws a bar and
53 /// takes arrow keys, a CLI takes a bounded argument.
54 ///
55 /// # It owes its bounds
56 ///
57 /// [`min`](Field::min) and [`max`](Field::max) are `Option` for every other
58 /// kind and are **required** here, in the sense the description can require
59 /// anything: [`Field::bounded`] is the check, and a range missing one has no
60 /// extent for a renderer to draw. What a renderer does with an unbounded
61 /// range is its own call and both answers are honest — fall back to a typed
62 /// number, or pick a host default — so this is stated rather than enforced,
63 /// the way every other constraint here is.
64 ///
65 /// [`Field::step`] is the third fact and is genuinely optional: absent, the
66 /// host's own granularity stands.
67 Range,
68 /// One question with two ends: a lower value and an upper one, submitted
69 /// under two names.
70 ///
71 /// "Show me samples between 90 and 130 BPM" has a single answer with two
72 /// ends, and the ends constrain each other: a minimum above the maximum is
73 /// not a wrong value, it is an empty result nobody asked for. Described as
74 /// two [`Number`](Self::Number) fields that is unsayable — nothing says they
75 /// are one question, so a renderer draws two controls with two labels and no
76 /// relationship, and [`Field::error`] can only be attached to one side of a
77 /// fault that belongs to both.
78 ///
79 /// Not [`Range`](Self::Range), which was the reading to resist and the
80 /// resistance is the same one `Range` itself needed against `Number`. A
81 /// range describes *one* value inside an extent; this describes two, and the
82 /// extent is a bound on each rather than the question's meaning. The two
83 /// come apart in the answer: a range has a value, an interval has a pair,
84 /// and either end may be absent while the other stands.
85 ///
86 /// # It states both names
87 ///
88 /// [`Field::name`] is the lower end and [`Field::upper_name`] is the upper
89 /// one, stated rather than derived. One member instead of a naming
90 /// convention this crate would then own forever.
91 ///
92 /// Direction is carried by which member the name sits in, so nothing
93 /// separate says which end is which.
94 ///
95 /// # What it does not enforce
96 ///
97 /// The crossing rule. A lower end above the upper one is describable here
98 /// and always was, exactly as an out-of-[`min`](Field::min) number is: this
99 /// crate carries constraints and never checks them, and deciding a value is
100 /// wrong stays with whoever validated. What the description buys is that the
101 /// fault now has one place to be reported rather than two.
102 ///
103 /// # Both ends take the same facts
104 ///
105 /// [`min`](Field::min), [`max`](Field::max), [`step`](Field::step) and
106 /// [`unit`](Field::unit) describe the axis rather than one end of it, so
107 /// they are read once and applied to both. Six of audiofiles' filter axes
108 /// are exactly this: one extent, one unit, one granularity, two ends.
109 ///
110 /// The bounds are optional here, unlike `Range`. They are a rule the answer
111 /// is checked against rather than the control's extent, which is
112 /// [`Number`](Self::Number)'s arrangement and not a slider's.
113 Interval,
114 /// An email address.
115 ///
116 /// Distinct from [`Text`](Self::Text) because the distinction is not
117 /// decoration: a webview renderer emits `type="email"`, which on a touch
118 /// device changes the keyboard that appears and turns on the platform's own
119 /// validation. goingson ships to iOS, so collapsing this into text costs a
120 /// keyboard with no `@` on it.
121 Email,
122 /// A URL. Same reasoning as [`Email`](Self::Email).
123 Url,
124 /// A telephone number. Same reasoning as [`Email`](Self::Email), and the
125 /// clearest case of it: the keyboard is a numeric pad rather than letters.
126 Tel,
127 /// A calendar day, with no time of day in it.
128 ///
129 /// [`Email`](Self::Email)'s argument, and it carries further: a webview
130 /// emits `type="date"`, which is a native picker, the platform's own
131 /// validation, and on a touch device the date keyboard. Described as
132 /// [`Text`](Self::Text) with a hint reading "YYYY-MM-DD", all three are
133 /// lost and the hint is doing the platform's job in prose.
134 ///
135 /// The membership test passes on every host without stretching: a webview
136 /// and a Tauri app emit the input, egui has a date picker, a terminal
137 /// prompts for a day and can validate it, a CLI takes an argument.
138 ///
139 /// # The value is ISO 8601, `YYYY-MM-DD`
140 ///
141 /// Named here rather than left to each host, because a host that picks
142 /// differently sends a server something it parses differently, and the
143 /// failure is silent and per-host. It is `<input type="date">`'s own wire
144 /// format, so the webview renderer owes nothing to honour it and the other
145 /// hosts have one spelling to meet. [`DATE_FORMAT`] is the constant, and a
146 /// test asserts this doc and that constant agree.
147 Date,
148 /// A calendar day and a time of day together.
149 ///
150 /// Apart from [`Date`](Self::Date) because the question is different rather
151 /// than more precise: "which day does this expire" and "at what moment does
152 /// this publish" are asked by different screens and answered by different
153 /// controls. A webview emits `type="datetime-local"` for one and
154 /// `type="date"` for the other, and a host that collapsed them would ask
155 /// half the tree for a precision it does not want.
156 ///
157 /// Both arrived together on measurement rather than on symmetry: 13 sites
158 /// of each across the MNW server and goingson, and **zero** of `time`,
159 /// `month` or `week`, which is why those are not here. A member added for a
160 /// case nobody has is a member designed against nothing, which is
161 /// [`File`](Self::File)'s reasoning about `accept` applied to a whole
162 /// member.
163 ///
164 /// # The value is `YYYY-MM-DDTHH:MM`, local, with no zone
165 ///
166 /// `<input type="datetime-local">`'s own format, and the "local" is the
167 /// load-bearing half: the value carries no offset and no `Z`, so the moment
168 /// it names is only fixed once something supplies a zone. That is the app's
169 /// business and not the description's. Seconds are absent, which is the
170 /// browser's own default and is left as the rule rather than restated as a
171 /// constraint. [`DATETIME_FORMAT`] is the constant.
172 ///
173 /// [`Field::min`] and [`Field::max`] already take "the host's own spelling
174 /// of a bound", so a floor of *not in the past* needs nothing new here: it
175 /// is a string in this same format.
176 DateTime,
177 /// Several lines of text.
178 Textarea,
179 /// Several lines of text the user writes markdown in.
180 ///
181 /// The editing counterpart of prose a description carries as markdown
182 /// source, and the reason it can exist at all is the same one that lets the
183 /// source be carried: editing markdown is editing text, so a terminal, an
184 /// immediate-mode host and a webview all have an honest answer, and none of
185 /// them has to refuse. A kind that meant "rich text" in the WYSIWYG sense
186 /// would have been a document model, and two of the three hosts would have
187 /// had to draw something they cannot.
188 ///
189 /// What the mark buys over [`Textarea`](Self::Textarea) is that a renderer
190 /// may offer the affordances markdown has and plain text does not — a
191 /// preview, a syntax pass, a monospaced face for the source — and that a
192 /// host reading the value back knows what it is holding. A renderer with
193 /// none of that draws a textarea, which is why this is additive rather than
194 /// a second control.
195 ///
196 /// It says nothing about **when** the value is saved. Autosave is a clock,
197 /// clocks are not described here, and the four MNW editors this was measured
198 /// against each keep their own.
199 ///
200 /// Sanitising stays where it already is for markdown that is only displayed:
201 /// with the renderer, at the point markup is produced. Being described is
202 /// not a safety property, and a host with its own sanitiser and its own
203 /// content-security posture still owns both.
204 Rich,
205 /// One of a fixed set, offered behind a control that shows one at a time.
206 Select,
207 /// One of a fixed set, with every option on screen at once.
208 ///
209 /// Not a presentation of [`Select`](Self::Select), which is the reading to
210 /// resist: what differs is a property of the *question*. A choice that is
211 /// consequential or irreversible has to be readable without opening
212 /// anything, because a closed control shows one option and hides the rest,
213 /// and the one it shows is whichever was current before the user had read
214 /// the alternatives. audiofiles asks whether a library copies samples into
215 /// its store or references them where they lie — which cannot be changed
216 /// afterwards — and had already promoted that out of a checkbox by hand,
217 /// with a comment giving this reason, before the description could say it.
218 ///
219 /// Everything here is an `<input type=...>`, a `<select>` or a
220 /// `<textarea>`, and the way this enum grows is by a site being measured
221 /// rather than by a list being completed. No member is ever "the last one".
222 Radio,
223 /// On or off.
224 Checkbox,
225 /// A file the user picks from wherever the host keeps files.
226 ///
227 /// It was filed as a router finding — a control whose destination is a
228 /// host capability rather than an address — and splitting it is what made
229 /// it two answers instead of one member satisfying neither. *Opening* a
230 /// file is a one-way handoff and needs no new API. *Picking* one returns a
231 /// value into a write, which is a form concern, which is this.
232 ///
233 /// The membership test passes on every host and not by a stretch: a Tauri
234 /// app opens a native picker, a server renders `<input type="file">`, a
235 /// terminal prompts for a path, a CLI takes an argument. That is closer to
236 /// [`Email`](Self::Email), which exists because it changes the keyboard,
237 /// than to anything bespoke.
238 ///
239 /// # The four things an upload says, and where each of them lives
240 ///
241 /// | axis | where |
242 /// |---|---|
243 /// | what it accepts | [`Field::accept`] |
244 /// | one file or several | [`Field::multiple`] |
245 /// | where the bytes go | the router's action, not here |
246 /// | how far along it is | [`Awaiting`] on that action |
247 ///
248 /// Only the first two are this crate's, and that split is the answer to
249 /// "describe an upload in full" rather than a gap in it. A destination is an
250 /// address and this crate holds no addresses; progress is a live number and
251 /// a description is built once, so the number is the renderer's to observe
252 /// against the size [`Awaiting::amount`] carried before the transfer began.
253 ///
254 /// # How the file is handed over is the host's
255 ///
256 /// A drop area, a button opening a native picker, a path typed at a prompt:
257 /// all three are the same field, and every measured site has the first. It
258 /// is not described for the reason no gesture is — this crate owns no
259 /// coordinates and no pointer, and a terminal that cannot be dropped on
260 /// would be refusing a description it can otherwise honour completely.
261 ///
262 /// [`Field::accept`] and [`Field::multiple`] are measured rather than
263 /// deferred. A member designed against nothing is the rule to keep: count
264 /// the sites before adding one.
265 File,
266 /// Which theme the app wears.
267 ///
268 /// The one member here that names a *subject* rather than a shape of
269 /// answer, and it is worth saying why that is not the door it looks like.
270 /// Every other kind is a question a screen might ask about anything; this
271 /// one is a specific question every app in the family asks, once, on its
272 /// settings screen, and three of them wrote the same control by hand.
273 ///
274 /// # It is furniture, and the measurement is what says so
275 ///
276 /// The reading to resist is that this is [`Select`](Self::Select) with a
277 /// grouped option list. Max rejected that: `optgroup` appears at one live
278 /// site in the tree and the non-theme grouping count is zero, so the thing
279 /// that recurs is this picker rather than option lists that group.
280 ///
281 /// # What it carries that a select cannot
282 ///
283 /// [`Field::themes`] rather than [`Field::options`], because a theme is
284 /// four facts and an option is two. The two extra facts are the ones no
285 /// app can supply without redoing work the theme layer has already done:
286 /// which [`ThemeVariant`] group a theme is in, and how legible its muted
287 /// text measured. `Choice::new(id, format!("{name} ({variant})"))` is what
288 /// the three apps had, and it flattens the group into prose and loses the
289 /// tier entirely.
290 ///
291 /// [`Field::follows`] carries the entry that is not a theme.
292 ///
293 /// # The cost, stated rather than discovered later
294 ///
295 /// This puts one screen's shape into a vocabulary that otherwise holds
296 /// none, which was the objection raised against it and accepted going in.
297 /// The mitigation is narrowness: this describes a theme picker, not a
298 /// general "list the host resolved" mechanism. A second host-resolved list
299 /// is when that generalisation gets measured, and not before.
300 ///
301 /// A renderer that has not heard of it draws a select over
302 /// [`Field::themes`]' names and loses the grouping, which is the state
303 /// every app was in before this member. Degrading to the status quo ante
304 /// is the floor the member is designed against.
305 Theme,
306 /// Carried through the form and never shown.
307 Hidden,
308 }
309
310 /// The wire format a [`FieldKind::Date`] value takes: ISO 8601, `YYYY-MM-DD`.
311 ///
312 /// A constant rather than a sentence in a doc comment, because the reason to
313 /// name the format at all is that a host picking its own would fail silently
314 /// against a server parsing another. A host that cannot emit the native control
315 /// still has one spelling to meet, and can say which one it meant.
316 pub const DATE_FORMAT: &str = "%Y-%m-%d";
317
318 /// The wire format a [`FieldKind::DateTime`] value takes: `YYYY-MM-DDTHH:MM`,
319 /// local, carrying no zone and no seconds.
320 ///
321 /// [`DATE_FORMAT`]'s sibling and there for its reason. The absent zone is a
322 /// property of the value rather than an omission: the moment is not fixed until
323 /// something outside the description supplies one.
324 pub const DATETIME_FORMAT: &str = "%Y-%m-%dT%H:%M";
325
326 impl FieldKind {
327 /// Whether the value the kind takes is a moment rather than a string.
328 ///
329 /// Named once here for the reason [`offers_options`](Self::offers_options)
330 /// is: two kinds answer yes, and a host that has to parse or format a value
331 /// needs to ask without spelling the pair out at each renderer. A third
332 /// temporal kind should land here and nowhere else.
333 ///
334 /// The format each one takes is [`DATE_FORMAT`] and [`DATETIME_FORMAT`].
335 #[must_use]
336 pub const fn temporal(self) -> bool {
337 matches!(self, Self::Date | Self::DateTime)
338 }
339
340 /// Whether the field is drawn at all.
341 #[must_use]
342 pub const fn visible(self) -> bool {
343 !matches!(self, Self::Hidden)
344 }
345
346 /// Whether the value must be kept out of logs and diagnostics.
347 #[must_use]
348 pub const fn confidential(self) -> bool {
349 matches!(self, Self::Secret)
350 }
351
352 /// Where the field's own label sits.
353 ///
354 /// A checkbox labels itself on the right of the box; everything else takes
355 /// a label above. Both webview apps already do this and both special-case
356 /// it inline, which is the tell that it belongs in the description.
357 ///
358 /// A [`Radio`](Self::Radio) is not one of them, and the near-miss is worth
359 /// naming: its *options* each label themselves, but the field still asks a
360 /// question above them, so the group takes a label like everything else.
361 #[must_use]
362 pub const fn labels_itself(self) -> bool {
363 matches!(self, Self::Checkbox)
364 }
365
366 /// Whether the kind reads [`Field::options`].
367 ///
368 /// Two kinds do, so the pair is named once here rather than spelled out at
369 /// each renderer and again in [`Field::options`]' own doc, where "every
370 /// kind but `Select`" was true for exactly one release. A third
371 /// option-taking kind should land here and nowhere else.
372 #[must_use]
373 pub const fn offers_options(self) -> bool {
374 matches!(self, Self::Select | Self::Radio)
375 }
376
377 /// Whether the kind reads [`Field::themes`] and [`Field::follows`].
378 ///
379 /// One member answers yes, and it gets a name for
380 /// [`takes_files`](Self::takes_files)'s reason rather than in spite of
381 /// being alone: four renderers ask it before they read either member, and
382 /// a `matches!` per renderer is where the next one goes missing.
383 ///
384 /// Deliberately not folded into
385 /// [`offers_options`](Self::offers_options). A theme picker offers no
386 /// [`Choice`]es at all, so a renderer walking `options` for it walks an
387 /// empty slice and draws an empty control.
388 #[must_use]
389 pub const fn offers_themes(self) -> bool {
390 matches!(self, Self::Theme)
391 }
392
393 /// Whether the value runs to more than one line.
394 ///
395 /// Named once here for [`temporal`](Self::temporal)'s reason: two kinds
396 /// answer yes, every renderer has to ask it before it can size anything,
397 /// and a `matches!` per renderer is the pair drifting apart one member at a
398 /// time. What a host does with the markdown, if anything, it reads from the
399 /// kind itself; this is only whether one line is enough.
400 #[must_use]
401 pub const fn multiline(self) -> bool {
402 matches!(self, Self::Textarea | Self::Rich)
403 }
404
405 /// Whether the value is a file the host picks rather than a string typed
406 /// into a box.
407 ///
408 /// One member answers yes, which is [`visible`](Self::visible)'s and
409 /// [`confidential`](Self::confidential)'s footing rather than a departure
410 /// from it: the question gets a name because three renderers ask it before
411 /// they can read [`Field::accept`] or [`Field::multiple`], and a `matches!`
412 /// per renderer is where a second file-taking kind would go missing.
413 #[must_use]
414 pub const fn takes_files(self) -> bool {
415 matches!(self, Self::File)
416 }
417
418 /// Whether the value is a quantity, so [`Field::unit`] means something.
419 ///
420 /// The numeric kinds and nothing else. A date is a quantity in the sense
421 /// that it is ordered, and it is not one in the sense that matters here:
422 /// its unit is fixed by the kind, so `Date` carrying `days` would be the
423 /// description restating what [`kind`](Field::kind) already said.
424 ///
425 /// [`takes_files`](Self::takes_files)'s footing, and for its reason: the
426 /// renderers ask this before they decide where a unit goes, and a
427 /// `matches!` per renderer is where the next measurable kind goes missing.
428 ///
429 /// [`Interval`](Self::Interval) is measurable too: an axis is measured in
430 /// something and both its ends are in it.
431 #[must_use]
432 pub const fn measurable(self) -> bool {
433 matches!(self, Self::Number | Self::Range | Self::Interval)
434 }
435 }
436
437 /// A family of media a file can belong to.
438 ///
439 /// Three members, because three is what a media type's own first segment offers
440 /// that a renderer can do anything with. `text` and `application` are families
441 /// too and neither buys a disclosure — there is no preview of an
442 /// `application/octet-stream` — so naming them would be a member added for a
443 /// case nobody has.
444 ///
445 /// It is the answer to "which disclosure", not a validation rule.
446 /// [`Field::accept`] is what a host filters on.
447 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
448 #[non_exhaustive]
449 pub enum Family {
450 /// A still picture.
451 Image,
452 /// Sound.
453 Audio,
454 /// Moving pictures, with or without sound.
455 Video,
456 }
457
458 impl Family {
459 /// The wildcard media type that means the whole family.
460 ///
461 /// `image/*` and its two siblings, which is what the measured sites write
462 /// and what a webview puts in an `accept` attribute. Named here so the three
463 /// renderers do not each spell the star.
464 #[must_use]
465 pub const fn wildcard(self) -> &'static str {
466 match self {
467 Self::Image => "image/*",
468 Self::Audio => "audio/*",
469 Self::Video => "video/*",
470 }
471 }
472
473 /// The family a media type's first segment names, if it is one of these.
474 ///
475 /// Case-insensitive on the segment, because a media type is
476 /// case-insensitive and half the tree writes them lowercase by habit rather
477 /// than by rule.
478 #[must_use]
479 pub fn of_type(media_type: &str) -> Option<Self> {
480 let (top, _) = media_type.split_once('/')?;
481 if top.eq_ignore_ascii_case("image") {
482 Some(Self::Image)
483 } else if top.eq_ignore_ascii_case("audio") {
484 Some(Self::Audio)
485 } else if top.eq_ignore_ascii_case("video") {
486 Some(Self::Video)
487 } else {
488 None
489 }
490 }
491 }
492
493 /// One entry in a file field's accept list.
494 ///
495 /// Three shapes rather than a string, and all three are in the measured sites:
496 /// the MNW server writes `image/*`, `image/jpeg,image/png,image/webp`,
497 /// `.zip,.dmg,.exe,.appimage,.deb,.tar.gz,.clap,.vst3` and, in one place,
498 /// `.csv,text/csv`. A single string would carry all of them and answer nothing
499 /// about any of them.
500 ///
501 /// # Why the list is not just a filter
502 ///
503 /// It is read twice. Once to decide what the picker offers, which any of the
504 /// three shapes serves, and once to decide **which disclosure** the field gets:
505 /// a preview for a picture, a duration or a waveform for a sound. There is one
506 /// upload shape and a media upload is that shape with more of it shown, so the
507 /// accept list is what says which more. [`family`](Self::family) is that
508 /// question answered once here instead of a media-type parser in each renderer.
509 ///
510 /// # A suffix names no family, on purpose
511 ///
512 /// `.mp3` is audio in fact, and nothing here says so. A suffix-to-family table
513 /// in a published crate is a mapping that goes stale, disagrees with the host's
514 /// own idea of what a file is, and is wrong the first time somebody hands it a
515 /// container. A call site that wants a picture's preview writes
516 /// [`Family::Image`] or `image/jpeg`; a call site listing installer suffixes
517 /// wants no disclosure anyway, which is the measured case.
518 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
519 #[non_exhaustive]
520 pub enum Accepted<'a> {
521 /// Every file of a family: `image/*` and its siblings.
522 Family(Family),
523 /// One media type, written the way a media type is written:
524 /// `image/jpeg`, `text/csv`.
525 Type(&'a str),
526 /// One file-name suffix, written with its leading dot: `.zip`, `.tar.gz`.
527 ///
528 /// A suffix and not an extension, because `.tar.gz` is a measured site and
529 /// is two dots.
530 Suffix(&'a str),
531 }
532
533 impl<'a> Accepted<'a> {
534 /// The family this entry belongs to, when it names one.
535 ///
536 /// [`None`] for a [`Suffix`](Self::Suffix) and for any media type outside
537 /// the three families, which is the honest answer rather than a missing
538 /// one: the description did not say.
539 #[must_use]
540 pub fn family(self) -> Option<Family> {
541 match self {
542 Self::Family(family) => Some(family),
543 Self::Type(media_type) => Family::of_type(media_type),
544 Self::Suffix(_) => None,
545 }
546 }
547
548 /// How a host that wants one string writes this entry.
549 ///
550 /// A webview's `accept` attribute takes exactly these spellings, and a
551 /// terminal listing what it will take reads the same words.
552 #[must_use]
553 pub const fn as_str(self) -> &'a str {
554 match self {
555 Self::Family(family) => family.wildcard(),
556 Self::Type(text) | Self::Suffix(text) => text,
557 }
558 }
559 }
560
561 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
562 pub struct Field<'a> {
563 /// What kind of value it takes.
564 pub kind: FieldKind,
565 /// The name the value is submitted under.
566 ///
567 /// The *lower* end's name for a [`FieldKind::Interval`], whose upper end is
568 /// [`upper_name`](Self::upper_name). Every other kind submits one value and
569 /// this is the whole of it.
570 pub name: &'a str,
571 /// The name a [`FieldKind::Interval`]'s upper end is submitted under.
572 ///
573 /// [`None`] for every other kind, and sayable-and-ignored there the way
574 /// [`options`](Self::options) is on a kind that offers none.
575 ///
576 /// Stated rather than derived from [`name`](Self::name), and
577 /// [`FieldKind::Interval`] carries the measurement that decided it: the two
578 /// sites in this tree disagree about affix order, so a derived rule would
579 /// rename one of them. Which member a name sits in is also what says which
580 /// end it is, so nothing separate carries the direction.
581 ///
582 /// An interval missing it is an interval with one end that can be submitted,
583 /// which is a description a renderer may draw honestly and no better than
584 /// that. [`Field::interval`] is what makes forgetting it unsayable, on the
585 /// same footing as [`Field::range`] and its bounds.
586 pub upper_name: Option<&'a str>,
587 /// What the user is asked for.
588 pub label: &'a str,
589 /// Standing help, shown whether or not anything is wrong.
590 pub hint: Option<&'a str>,
591 /// What is currently wrong with the value.
592 pub error: Option<&'a str>,
593 /// A consequence of the answer the user has given, carrying its own tone.
594 ///
595 /// The third message channel, between [`hint`](Self::hint) and
596 /// [`error`](Self::error) and overlapping neither. A hint is standing help
597 /// that does not depend on the value; an error says the value is not
598 /// acceptable. A note is the case in the middle: the value is perfectly
599 /// acceptable and choosing it costs something the user should know about.
600 ///
601 /// The first consumer is audiofiles' export Format field, where choosing
602 /// WAV or AIFF over Original re-encodes and silently drops embedded BWF,
603 /// iXML, loop points, cue markers and ID3. That is not a validation
604 /// failure and it is not standing help — it is true of one answer to one
605 /// question — and it was hand-drawn in the app's own draw callback for
606 /// want of anywhere to say it.
607 ///
608 /// The tone is carried rather than fixed at [`Tone::Warning`] because the
609 /// channel is not only for warnings: the same slot says "this is the
610 /// recommended one" ([`Tone::Success`]) and "this is what that setting
611 /// implies" ([`Tone::Info`]). A renderer gets the announcement behaviour
612 /// off the tone for free — makeover-webview emits `data-tone` and treats
613 /// Warning and Danger as assertive for `aria-live`.
614 ///
615 /// It does **not** make the field invalid. [`invalid`](Self::invalid) stays
616 /// `error.is_some()`, so a note never marks the group as a problem.
617 ///
618 /// # Precedence, for a renderer with room for one
619 ///
620 /// Error, then note, then hint. A renderer that shows every message shows
621 /// them in that order too. makeover-tui is the one with room for exactly
622 /// one line, and it is why the order is decided here rather than three
623 /// times: what is wrong outranks what it costs, which outranks how it
624 /// works.
625 pub note: Option<(Tone, &'a str)>,
626 /// Ghost text shown while the field is empty.
627 ///
628 /// User-facing text, and it sits with `label` and `hint` rather than with
629 /// the value because it is a property of the *question* and not of the
630 /// answer.
631 ///
632 /// Not a substitute for a label. A field labelled only by its placeholder
633 /// loses its label the moment anything is typed, and no renderer here can
634 /// make that not happen, so the description keeps both.
635 pub placeholder: Option<&'a str>,
636 /// The options offered, in the order they are offered.
637 ///
638 /// Empty for every kind [`FieldKind::offers_options`] rejects. A field
639 /// described with no options is sayable on purpose: it is what an app with
640 /// an unfinished-loading option list actually has, and a renderer showing
641 /// an empty control says so on screen rather than in a log.
642 ///
643 /// Which option is *current* is not here. That is the value, and the value
644 /// is renderer state.
645 pub options: &'a [Choice<'a>],
646 /// The themes offered, in the order they are offered.
647 ///
648 /// Empty for every kind [`FieldKind::offers_themes`] rejects, and sayable
649 /// as empty for the one that accepts it: an app whose theme directories
650 /// hold nothing has a picker offering only [`follows`](Self::follows),
651 /// which is a true description of that machine.
652 ///
653 /// **The order is the grouping.** Entries arrive sorted by
654 /// [`ThemeVariant`] and then by [`Contrast`] within each variant, so a
655 /// renderer that draws headings walks the run of one variant and a renderer
656 /// that cannot still gets the useful order. Handing back groups would force
657 /// the second renderer to flatten what the first wanted.
658 ///
659 /// Nothing here sorts. The description carries the order it was given, and
660 /// the sort belongs with whoever measured the tiers — `makeover::theme_options`
661 /// is what produces it, and re-sorting here would be this crate deciding a
662 /// question it cannot see the inputs to.
663 ///
664 /// Which theme is *current* is not here. That is the value, and the value
665 /// is renderer state, exactly as it is for [`options`](Self::options).
666 pub themes: &'a [ThemeChoice<'a>],
667 /// The entry that follows the ambient mode instead of naming a theme.
668 ///
669 /// [`None`] for a picker that does not offer one, which is a real answer:
670 /// an app whose host has no ambient mode to follow should not offer a row
671 /// that does nothing.
672 ///
673 /// A [`Choice`] rather than a bare label, because the *value* is the app's.
674 /// Every store in the family spells it `system` today and none of them is
675 /// obliged to; a description that hardcoded the spelling would be this
676 /// crate holding a fact about somebody else's config table.
677 ///
678 /// It is not a [`ThemeChoice`] with an absent variant. Following is a
679 /// standing instruction that resolves differently as the desktop flips, and
680 /// a theme id is an answer that does not — which is the distinction
681 /// `makeover::ThemeSelection` exists to hold, carried here rather than
682 /// blurred.
683 pub follows: Option<Choice<'a>>,
684 /// What a file field takes, in the order a host offering the list shows it.
685 ///
686 /// Empty for every kind [`FieldKind::takes_files`] rejects, and empty is
687 /// also a real answer for one that accepts it: a field that takes any file
688 /// says so by listing nothing, which is what an `<input type="file">` with
689 /// no `accept` does and what most of the measured sites are.
690 ///
691 /// It is a filter and it is the disclosure cue, and [`Accepted`]'s doc
692 /// carries which reading is which. Nothing here validates: a host may hand
693 /// back a file the list does not cover, exactly as a browser does when the
694 /// user switches the picker to "All Files", and deciding a value is wrong
695 /// stays with whoever validated.
696 pub accept: &'a [Accepted<'a>],
697 /// Whether more than one file may be picked at once.
698 ///
699 /// Only [`FieldKind::takes_files`] reads it. A multi-valued answer to any
700 /// other question is a different shape — a set of options, a repeated
701 /// group — and neither is this flag with a different kind beside it.
702 ///
703 /// False is the common case: 4 of the MNW server's 16 file inputs carry it.
704 pub multiple: bool,
705 /// Whether the form refuses to submit without it.
706 pub required: bool,
707 /// The longest the value may be, in characters.
708 pub max_length: Option<u32>,
709 /// The lowest value accepted, as the host would write it.
710 ///
711 /// Text rather than a number, because the bound is only a number for some
712 /// of the kinds that take one. goingson's own sites are `min="1"` on a
713 /// duration and `min="2026-08-09T14:30"` on a datetime, and a numeric member
714 /// could say the first and not the second. The [`kind`](Self::kind) already
715 /// says how to read it, the same way it does for the value.
716 pub min: Option<&'a str>,
717 /// The highest value accepted, as the host would write it. See
718 /// [`min`](Self::min).
719 pub max: Option<&'a str>,
720 /// The granularity the value moves in, as the host would write it.
721 ///
722 /// Text for [`min`](Self::min)'s reason, and it earns it twice over: the
723 /// step of a date is a day and the step of a threshold is 0.01, and a
724 /// numeric member could say one of them.
725 ///
726 /// Absent means the host's own granularity, which is the honest default
727 /// rather than a missing value: a webview's `<input>` steps by 1 unless told
728 /// otherwise, and that is the browser's rule and not this crate's to
729 /// restate.
730 ///
731 /// # It is the granularity of a *typed* value
732 ///
733 /// [`FieldKind::Range`] reads its own from [`curve`](Self::curve) and
734 /// ignores this. On a slider the granularity and the mapping are one
735 /// decision, and on a typed number there is no mapping to decide with. See
736 /// [`Curve`], "Why the step is here".
737 pub step: Option<&'a str>,
738 /// How a slider's position becomes its value, and how finely it moves.
739 ///
740 /// [`FieldKind::Range`]'s, and nothing else reads it: a typed number has a
741 /// granularity but no mapping, and takes [`step`](Self::step) instead.
742 ///
743 /// Defaults to [`Curve::Linear`] with no step, which is what an
744 /// undescribed range means.
745 pub curve: Curve<'a>,
746 /// What the number is measured in: `s`, `ms`, `dB`, `GiB`.
747 ///
748 /// A fact about the value, not part of the question's name, and that
749 /// distinction is the whole reason it is a member. The two readings come
750 /// apart the moment anything reads a field back rather than drawing it: a
751 /// [`max`](Self::max) of `-96` and a bound of `-96 dBFS` are the same number
752 /// and not the same answer, and under the convention this replaces the unit
753 /// could only be recovered by parsing it back out of a label.
754 ///
755 /// # Where a renderer draws it
756 ///
757 /// Beside the value, wherever that host puts a value. Not in the label: a
758 /// label is the sentence above the control, so unit-in-label reads the same
759 /// on every host and is wrong on any host with somewhere better. egui puts
760 /// it inside
761 /// the slider where the readout already is, a terminal appends it to the
762 /// value in the edit line, a webview sets it adjacent to the input.
763 ///
764 /// # Which kinds read it
765 ///
766 /// [`FieldKind::measurable`] answers, and it is
767 /// [`takes_files`](FieldKind::takes_files)'s footing: three renderers ask
768 /// before they can decide whether to draw this, and a `matches!` per
769 /// renderer is where the next measurable kind goes missing. A unit on a kind
770 /// that rejects it is sayable and ignored, the same way
771 /// [`options`](Self::options) is on a kind that offers none.
772 ///
773 /// # Why a string
774 ///
775 /// The measured sites are `GiB`, `dBFS`, `s` and `ms`. An enum would have to
776 /// grow a member for every unit any consumer ever wants, and this crate does
777 /// not know them; it knows that a number has one.
778 ///
779 /// Written as the symbol alone, with no brackets and no leading space. The
780 /// spacing is the renderer's, because a slider's readout and a sentence want
781 /// different answers.
782 pub unit: Option<&'a str>,
783 /// Whether the field lives behind a "more options" disclosure.
784 pub extended: bool,
785 /// Whether this local wall-clock value is submitted as an absolute instant.
786 ///
787 /// [`FieldKind::DateTime`] asks for a time the way a person says one --
788 /// "the 14th at half past two" -- and that names a different moment in
789 /// Denver than it does in Berlin. A route that stores an instant needs the
790 /// moment, so somebody has to convert. This member says the description
791 /// wants that conversion; it does not say how.
792 ///
793 /// # The conversion belongs to the renderer
794 ///
795 /// Because the renderer is the only party that knows what "your computer's
796 /// time zone" means for its host. A browser has one and the user is sitting
797 /// in it; a TUI reads the host clock; an egui app reads the same clock a
798 /// different way. Nothing above the renderer can answer it, and the
799 /// alternatives all try: a hidden IANA-zone field needs a host capability
800 /// for reading the zone that three hosts answer differently, plus a kind
801 /// that does not exist, plus a wire-contract change; a timezone on the
802 /// user's profile is a product decision wearing a bug's clothes. Say it
803 /// here, and the next reader does not propose them again.
804 ///
805 /// # What a renderer does
806 ///
807 /// Draws the same control it always did -- the flag changes what is
808 /// *submitted*, not what is shown -- and converts the local value to an
809 /// absolute instant on the way out. A renderer that cannot convert submits
810 /// the local value unchanged, which is what every renderer did before this
811 /// existed.
812 ///
813 /// No wire contract moves when a site adopts it: the route was already
814 /// receiving an instant. What changes is who computed it.
815 ///
816 /// # Which kinds read it
817 ///
818 /// [`FieldKind::DateTime`]'s. `Date` and `Time` are each half a moment and
819 /// cannot name one on their own, so the flag is sayable and ignored there,
820 /// the way [`options`](Self::options) is on a kind that offers none.
821 pub as_instant: bool,
822 }
823
824 impl<'a> Field<'a> {
825 /// A plain required-nothing field of the given kind.
826 #[must_use]
827 pub const fn new(kind: FieldKind, name: &'a str, label: &'a str) -> Self {
828 Self {
829 kind,
830 name,
831 upper_name: None,
832 label,
833 hint: None,
834 error: None,
835 note: None,
836 placeholder: None,
837 options: &[],
838 themes: &[],
839 follows: None,
840 accept: &[],
841 multiple: false,
842 required: false,
843 max_length: None,
844 min: None,
845 max: None,
846 step: None,
847 curve: Curve::Linear { step: None },
848 unit: None,
849 extended: false,
850 as_instant: false,
851 }
852 }
853
854 /// A bounded number the user drags across its whole extent.
855 ///
856 /// The third under-described kind, and it gets a constructor for
857 /// [`select`](Self::select)'s reason: a range is the one kind whose bounds
858 /// are not a rule but the control itself, so a call site that forgot them
859 /// has a slider with nothing to slide across. Taking them as arguments is
860 /// what makes that unsayable.
861 ///
862 /// The granularity stays a field rather than a fourth argument, and it is
863 /// [`curve`](Self::curve)'s: it is genuinely optional, since the host's own
864 /// is a real answer, and the two bounds are not.
865 #[must_use]
866 pub const fn range(name: &'a str, label: &'a str, min: &'a str, max: &'a str) -> Self {
867 Self {
868 min: Some(min),
869 max: Some(max),
870 ..Self::new(FieldKind::Range, name, label)
871 }
872 }
873
874 /// One question with two ends, taking the name each end submits under.
875 ///
876 /// A constructor for [`range`](Self::range)'s reason inverted: a range's
877 /// bounds are what a call site cannot forget, and an interval's second name
878 /// is. An interval built through [`new`](Self::new) has an upper end with
879 /// nowhere to be submitted, and nothing downstream can invent one, so taking
880 /// it as an argument is what makes that unsayable.
881 ///
882 /// The extent, the granularity and the unit stay members. They describe the
883 /// axis rather than either end and they are genuinely optional, which is
884 /// [`FieldKind::Number`]'s arrangement and the one an interval takes.
885 #[must_use]
886 pub const fn interval(name: &'a str, upper_name: &'a str, label: &'a str) -> Self {
887 Self {
888 upper_name: Some(upper_name),
889 ..Self::new(FieldKind::Interval, name, label)
890 }
891 }
892
893 /// A file field, taking the given accept list.
894 ///
895 /// The fourth under-described kind and it gets a constructor for
896 /// [`range`](Self::range)'s reason rather than [`select`](Self::select)'s:
897 /// a file field with no accept list is not broken, it is a field that takes
898 /// anything, and the hazard is the opposite one. A call site that meant to
899 /// restrict and forgot has a picker offering every file on the machine and
900 /// a server refusing the upload afterwards, which is the failure the list
901 /// exists to move forward. Taking it as an argument is what makes an
902 /// accidental omission a deliberate `&[]`.
903 ///
904 /// [`multiple`](Self::multiple) stays a field. One file is the common case
905 /// and the honest default; several is the thing worth saying.
906 #[must_use]
907 pub const fn upload(name: &'a str, label: &'a str, accept: &'a [Accepted<'a>]) -> Self {
908 Self {
909 accept,
910 ..Self::new(FieldKind::File, name, label)
911 }
912 }
913
914 /// A select offering the given options.
915 ///
916 /// One of the two kinds under-described by [`Field::new`], so it gets a
917 /// constructor rather than leaving every call site to remember that a
918 /// select with an empty `options` renders as an empty select.
919 #[must_use]
920 pub const fn select(name: &'a str, label: &'a str, options: &'a [Choice<'a>]) -> Self {
921 Self::offering(FieldKind::Select, name, label, options)
922 }
923
924 /// A radio group offering the given options.
925 ///
926 /// The other. Same hazard as [`select`](Self::select) and a worse one: a
927 /// radio group with no options draws nothing at all, so a call site that
928 /// forgot them has an empty rectangle rather than a visibly empty control.
929 #[must_use]
930 pub const fn radio(name: &'a str, label: &'a str, options: &'a [Choice<'a>]) -> Self {
931 Self::offering(FieldKind::Radio, name, label, options)
932 }
933
934 /// A theme picker over the themes the host resolved.
935 ///
936 /// A constructor for [`select`](Self::select)'s reason and one of its own.
937 /// The shared reason: a theme picker built through [`new`](Self::new) has
938 /// an empty [`themes`](Self::themes) list and draws an empty control. Its
939 /// own: the list is the *only* thing this kind takes that a call site
940 /// cannot get wrong by omission and can get wrong by substitution, since
941 /// [`options`](Self::options) is right there and reads as if it would work.
942 ///
943 /// [`following`](Self::following) is the builder rather than a fourth
944 /// argument, because a picker with no follow-the-system row is a real
945 /// picker and every renderer draws it honestly.
946 #[must_use]
947 pub const fn theme(name: &'a str, label: &'a str, themes: &'a [ThemeChoice<'a>]) -> Self {
948 Self {
949 themes,
950 ..Self::new(FieldKind::Theme, name, label)
951 }
952 }
953
954 /// The same picker, offering a row that tracks the ambient mode.
955 ///
956 /// The [`Choice`] carries the value the app's own store spells it with.
957 #[must_use]
958 pub const fn following(mut self, follow: Choice<'a>) -> Self {
959 self.follows = Some(follow);
960 self
961 }
962
963 /// The shared body of the two constructors that take options.
964 ///
965 /// Private, and keyed on the kind rather than exposed, because the two
966 /// public names are the point: a call site says which question it is
967 /// asking, not which flag it is setting.
968 const fn offering(
969 kind: FieldKind,
970 name: &'a str,
971 label: &'a str,
972 options: &'a [Choice<'a>],
973 ) -> Self {
974 Self {
975 options,
976 ..Self::new(kind, name, label)
977 }
978 }
979
980 /// Whether the field is currently reporting a problem.
981 ///
982 /// Read this rather than testing `error.is_some()` at each renderer: the
983 /// error state has to mark the field's whole group and not only the
984 /// message, because a renderer with no descendant selectors (egui, a
985 /// terminal) cannot find the group from the message. goingson already marks
986 /// the group and Balanced Breakfast does not, so goingson's shape is the
987 /// one taken here.
988 ///
989 /// [`note`](Self::note) is deliberately not consulted. A note says the
990 /// answer costs something, not that it is unacceptable, and a field the
991 /// user may submit as it stands is not invalid.
992 #[must_use]
993 pub const fn invalid(&self) -> bool {
994 self.error.is_some()
995 }
996
997 /// Whether the field carries both ends of its extent.
998 ///
999 /// Only [`FieldKind::Range`] owes them, and it owes them absolutely: a
1000 /// slider with one end missing has no extent to draw. Named here rather
1001 /// than left to each renderer to test `min.is_some() && max.is_some()`,
1002 /// which is three renderers arriving at the same condition and one of them
1003 /// getting it wrong, and named as a question about the *field* rather than
1004 /// about the kind because the kind cannot see the bounds.
1005 ///
1006 /// It is a check and not a guarantee. Nothing here refuses to build an
1007 /// unbounded range — [`Field::range`] is what makes the bounded one easy —
1008 /// so a renderer asks this and falls back to whatever its host does
1009 /// honestly with a number.
1010 #[must_use]
1011 pub const fn bounded(&self) -> bool {
1012 self.min.is_some() && self.max.is_some()
1013 }
1014
1015 /// Whether anything in [`accept`](Self::accept) names a media family.
1016 ///
1017 /// The question a renderer asks before it decides to keep room for a
1018 /// preview, and it is deliberately the *whole list* rather than one entry:
1019 /// the media dropzone this was measured against takes `image/*,video/*`, so
1020 /// there is no single family to return and there is still a disclosure to
1021 /// offer. Which one it turns out to be is known once a file is picked, which
1022 /// is renderer-side and after the description is gone.
1023 ///
1024 /// False for an empty list, for a list of suffixes, and for `text/csv`. A
1025 /// renderer that wants the family of a particular entry reads
1026 /// [`Accepted::family`].
1027 #[must_use]
1028 pub fn accepts_media(&self) -> bool {
1029 self.accept.iter().any(|one| one.family().is_some())
1030 }
1031 }
1032