Skip to main content

makeover_layout/
component.rs

1use crate::{Depth, Fill, Intent, Priority};
2
3// Names this module's prose links to, resolved for rustdoc.
4#[allow(unused_imports)]
5use crate::{Awaiting, Region};
6
7/// What a region is saying, when it is saying something.
8///
9/// The one intent family shared by badges, notices and nothing else. Kept
10/// separate from [`Fill`] because a surface is where a thing sits and a tone is
11/// what it means, and the three apps agree on the four statuses:
12/// `info_banner` / `warning_banner` in audiofiles, `.toast-info` /
13/// `.toast-success` / `.toast-error` in goingson, `.toast.success` /
14/// `.toast.error` in Balanced Breakfast.
15///
16/// The per-tag palette (`category-one` through `category-six`) is deliberately
17/// not here. Which colour a *particular* tag takes is app domain, and both
18/// webview apps already carry it as a `data-color` attribute.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
20#[non_exhaustive]
21pub enum Tone {
22    /// No status.
23    ///
24    /// Ordinary content, at full weight. It does not also mean muted: a
25    /// badge reads quiet because [`Token::Badge`] answers no click, which is
26    /// the renderer's knowledge and not this axis's. A renderer wanting a
27    /// muted badge reaches for [`Token::interactive`] itself rather than
28    /// expecting `Neutral` to have muted it.
29    Neutral,
30    /// Something worth knowing and nothing to do about it.
31    Info,
32    /// Something finished and it worked.
33    Success,
34    /// Something the user should look at before continuing.
35    Warning,
36    /// Something broken, or something about to be destroyed.
37    Danger,
38}
39
40impl Intent for Tone {
41    fn token(self) -> &'static str {
42        match self {
43            // Neutral has no status token of its own, so it takes the plain
44            // content intent. It used to answer `content-muted`, which read
45            // "no status" as "de-emphasised" and muted every figure value in
46            // the webview. Muting is a renderer's call about a particular
47            // token, not something the status axis knows.
48            Self::Neutral => "content",
49            Self::Info => "info",
50            Self::Success => "success",
51            Self::Warning => "warning",
52            Self::Danger => "danger",
53        }
54    }
55}
56
57/// A small labelled thing that sits inside something else.
58///
59/// Two members, because the three apps drew three taxonomies and only one line
60/// runs through all of them: does it answer a click. audiofiles has
61/// `classification_badge` (a label) against `tag_chip`, `tag_chip_removable`
62/// and `selectable_tag` (all of which do). Balanced Breakfast has `.tag` and
63/// `.badge` against `.tag-chip`. goingson is the one that has to move: its
64/// `.tag` and `.badge` are a single CSS rule, so every call site has to be read
65/// to decide which of the two it always was.
66///
67/// The evidence that a chip is a real concept rather than a badge with a
68/// cursor: audiofiles inverts its bevel on press and Balanced Breakfast latches
69/// `.tag-chip.active` with the inset bevel. Two independent arrivals at "a chip
70/// holds itself down", which is exactly what [`Depth::pressed`] already says.
71#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
72#[non_exhaustive]
73pub enum Token {
74    /// Non-interactive status or count. Answers no click.
75    Badge,
76    /// An interactive or removable token. Answers a click, and latches if it
77    /// stands for a filter that is either on or off.
78    Chip {
79        /// Whether it carries its own remove affordance.
80        removable: bool,
81    },
82}
83
84impl Token {
85    /// Whether this answers a click.
86    ///
87    /// The whole difference between the two members, and the reason a renderer
88    /// with no hover (a touch surface, a terminal) can still tell them apart.
89    #[must_use]
90    pub const fn interactive(self) -> bool {
91        matches!(self, Self::Chip { .. })
92    }
93
94    /// How it sits, given whether it is currently latched down.
95    ///
96    /// A badge is flat: it is a label, and giving it an edge would say it can
97    /// be pressed. A chip is raised, and inset while latched.
98    #[must_use]
99    pub const fn depth(self, latched: bool) -> Depth {
100        match self {
101            Self::Badge => Depth::Flat,
102            Self::Chip { .. } if latched => Depth::Well,
103            Self::Chip { .. } => Depth::Raised,
104        }
105    }
106}
107
108/// Something the app is telling the user, unprompted.
109///
110/// Two concepts, not one with a placement. They differ in more than where they
111/// sit: a toast is transient, stacked and self-dismissing, and a banner is
112/// persistent, in flow, one per region, and dismissed by fixing the condition
113/// it reports. Folding them into one member with a placement parameter would
114/// make lifetime, stacking and dismissal all placement-dependent, which is the
115/// description leaking renderer policy.
116///
117/// All three apps have banners: `info_banner` and `warning_banner` in
118/// audiofiles, five of them in goingson (sync, sync-result, vacation-day,
119/// timer-active, past-review), `.update-banner` in Balanced Breakfast. The two
120/// webview apps also have toasts. So neither member is speculative, and no app
121/// gains a concept it lacks except audiofiles, whose renderer may legitimately
122/// decline to draw a toast at all.
123#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
124#[non_exhaustive]
125pub enum Notice {
126    /// Transient, stacked, dismisses itself.
127    Toast,
128    /// Persistent, in flow, one per region, dismissed by fixing the cause.
129    Banner,
130}
131
132impl Notice {
133    /// Whether it goes away on its own.
134    #[must_use]
135    pub const fn transient(self) -> bool {
136        matches!(self, Self::Toast)
137    }
138
139    /// How it sits.
140    ///
141    /// A toast floats above the page rather than resting on it, which is
142    /// [`Fill::Overlay`]'s whole reason to exist. A banner is a card in the
143    /// flow. Both are raised, and they are raised off different things.
144    #[must_use]
145    pub const fn fill(self) -> Fill {
146        match self {
147            Self::Toast => Fill::Overlay,
148            Self::Banner => Fill::Raised,
149        }
150    }
151}
152
153/// The parts of a list row.
154///
155/// `#[non_exhaustive]`: a renderer carries a wildcard arm, so a new part is not
156/// a lockstep event across three renderers.
157///
158/// # Meta against Tokens
159///
160/// The line is whether the thing has its own standing. `Meta` is one short
161/// trailing fact about the row, written as text: a count, a size, a date.
162/// `Tokens` is a set of small labelled things, each of which can be toned and
163/// can answer a click. "3 files" is meta. A status badge that is amber, and a
164/// tag you can click to filter by, are tokens.
165///
166/// Keeping them apart is what a single widened slot would have foreclosed. A
167/// renderer can right-align one string and cannot usefully do the same to a
168/// strip of chips, and a fact that is not clickable should not be drawn as
169/// though it were.
170/// How much vertical room a part's text may take.
171///
172/// A row is an inline run and every part in it is a leaf, so a part's text has
173/// always been drawn on one line and no description could say otherwise. Two
174/// apps say otherwise in their own stylesheets, both to the same number and
175/// both with a comment explaining it: Balanced Breakfast clamps a feed row's
176/// title to two lines (`.row--article .row-primary`, whose comment reads
177/// "overrides .row-primary's single flex line"), and goingson clamps a
178/// problem's body to two ("two lines is enough to recognize one, and the full
179/// text is in the task once promoted").
180///
181/// Two named tiers rather than a line count, and the count is what the measured
182/// demand argues against. Both sites want exactly one tier past the default,
183/// and a number invites a row whose primary is a paragraph, which is a block
184/// and has no business in a run. A third tier is a decision, made here, rather
185/// than something a call site can reach for.
186///
187/// What a renderer owes it: `Tight` is what a run already does and needs no
188/// answer. `Relaxed` is at most two lines and then truncation, however that
189/// renderer truncates, a webview clamps, a terminal wraps into two rows of
190/// cells, an immediate-mode renderer caps the galley. A renderer that cannot
191/// give two lines may draw one; what it may not do is grow without bound,
192/// because the run is a line and the row's neighbours are relying on that.
193#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
194#[non_exhaustive]
195pub enum Flow {
196    /// One line. What every part did before this type existed.
197    #[default]
198    Tight,
199    /// Up to two lines, then truncated.
200    Relaxed,
201}
202
203impl Flow {
204    /// How many lines the part may take.
205    ///
206    /// A number here rather than in the enum, because a renderer needs one and
207    /// a call site does not. That asymmetry is the whole argument for the
208    /// tiers: the description says how much room the thing deserves and this
209    /// says what that costs, so a third tier changes one line rather than every
210    /// consumer's arithmetic.
211    #[must_use]
212    pub const fn lines(self) -> u8 {
213        match self {
214            Self::Relaxed => 2,
215            // Including any tier added later: one line is the safe reading of
216            // an unknown flow, since it is what the run guaranteed before flows
217            // existed.
218            _ => 1,
219        }
220    }
221}
222
223/// How deep a row sits inside a set: a tree, an outline, a threaded list.
224///
225/// [`RowPart`] below already names what is *in* a row; nothing named where a
226/// row sits relative to its siblings, so every consumer that had a hierarchy
227/// carried a bare number and every renderer decided for itself what one was
228/// worth.
229///
230/// # Not `Depth`, and the collision is the reason
231///
232/// [`Depth`] is taken and means something else entirely: surface bevel --
233/// `Flat`, `Raised`, `Well`, `Sunken`, `Overlay`, a fact about a surface
234/// rather than a position in a hierarchy. Two meanings under one word in one
235/// crate is the collision that costs a reader an hour, and the word this
236/// concept wants is the one that would cause it.
237///
238/// # The magnitude is the renderer's, and that is the precedent
239///
240/// [`Awaiting`] is the shape: this crate names the fact and declines to name
241/// what it is worth. makeover-webview writes the rule as a custom property with
242/// a fallback, the way it writes `margin-inline-start: var(--awaiting-gap,
243/// 0.5ch)`, so a level has one answer per renderer and an app can override
244/// it. A terminal spends columns, a browser spends inline space, and neither
245/// number belongs in a description.
246///
247/// # Zero is a real answer
248///
249/// [`top`](Self::top) is the default and is what a flat list says: every row is
250/// at the top level, which is true and is the reading a renderer needs. An
251/// `Option` here would make "not nested" and "nested at zero" two spellings of
252/// one thing, the same argument `Discovery`'s `indexable` makes about defaults
253/// that are meaningful.
254#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, PartialOrd, Ord)]
255#[non_exhaustive]
256pub struct Nesting {
257    /// How many levels in, counting from zero.
258    ///
259    /// `u8` because a hierarchy a reader can follow is not 256 deep, and a
260    /// renderer indenting by a level has to multiply it by something, a wider
261    /// integer here is a wider integer in every renderer's arithmetic for a
262    /// range nothing will use.
263    pub level: u8,
264}
265
266impl Nesting {
267    /// The top level: not nested. The default, and what a flat list says.
268    #[must_use]
269    pub const fn top() -> Self {
270        Self { level: 0 }
271    }
272
273    /// A row this many levels in.
274    #[must_use]
275    pub const fn at(level: u8) -> Self {
276        Self { level }
277    }
278
279    /// Whether this row sits under another.
280    ///
281    /// The question every renderer asks before it spends anything on indenting,
282    /// answered once here rather than by a `> 0` in each, which is
283    /// [`Awaiting::is_determinate`]'s reason too.
284    #[must_use]
285    pub const fn is_nested(self) -> bool {
286        self.level > 0
287    }
288}
289
290#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
291#[non_exhaustive]
292pub enum RowPart {
293    /// The thing itself. What the row is called.
294    Primary,
295    /// Supporting text under the primary.
296    Secondary,
297    /// A short trailing fact: a count, a size, a date.
298    Meta,
299    /// Controls that act on this row.
300    Actions,
301    /// Small labelled things belonging to the row: badges, chips, tags.
302    ///
303    /// Each carries its own [`Token`] kind and [`Tone`], so a renderer with no
304    /// colour still has the kind to work with, and one with no chips still has
305    /// the label. That is the constrained-consumer test this vocabulary exists
306    /// to pass, and it is why the tone lives on the token rather than on the
307    /// part.
308    Tokens,
309    /// How much of a set the row's thing has done: a meter in the row.
310    ///
311    /// A row holds no nodes, by the rule that a row part may not carry an
312    /// arbitrary node, which is the door through which a description becomes a
313    /// templating language. So the part carries the *description of a bar* rather than a node, exactly as
314    /// `Tokens` carries tags rather than nodes.
315    ///
316    /// Without it a row flattens the proportion into [`Meta`](Self::Meta) as
317    /// "3/7 subtasks", which keeps both numbers and loses the reading, the same
318    /// way a toned status badge read as prose before `Tokens`.
319    Proportion,
320}
321
322impl RowPart {
323    /// What the part is worth when the run does not fit.
324    ///
325    /// The default only. A part may say otherwise, and a renderer reads the
326    /// part rather than the role; this is what a description that has never
327    /// heard of [`Priority`] means, which is every description written before
328    /// the field existed.
329    ///
330    /// Deriving it from the role is the thing this vocabulary has otherwise
331    /// been moving away from, and it is right here for one reason: the roles
332    /// already encode this ranking and every consumer already assumes it.
333    /// [`Primary`](Self::Primary) is what the row is called, and
334    /// [`Priority::Essential`]'s own doc was written about exactly that --
335    /// "without it the row does not identify itself".
336    ///
337    /// [`Actions`](Self::Actions) is `Essential` and it is the interesting one.
338    /// A control is not a fact, so dropping it does not cost the reader a
339    /// detail; it costs them the only way to act on the row, and in a terminal
340    /// it silently removes something focus had already been claimed for. A
341    /// renderer that needs room takes it from what the row *says*, never from
342    /// what it *offers*.
343    ///
344    /// An unknown member reads as [`Priority::Secondary`]: droppable, but not
345    /// first, since guessing `Optional` for something this crate has not been
346    /// taught would make a new member the first thing to vanish.
347    #[must_use]
348    pub const fn priority(self) -> Priority {
349        match self {
350            Self::Primary | Self::Actions => Priority::Essential,
351            Self::Meta | Self::Proportion => Priority::Optional,
352            _ => Priority::Secondary,
353        }
354    }
355
356    /// The content intent the part takes.
357    #[must_use]
358    pub const fn intent(self) -> &'static str {
359        match self {
360            Self::Primary => "content",
361            Self::Secondary => "content-secondary",
362            Self::Meta => "content-muted",
363            // Actions carry controls rather than text, so they inherit.
364            Self::Actions => "content",
365            // So do tokens: each one carries its own tone, and a part-level
366            // intent underneath it would fight the token that sits on it.
367            Self::Tokens => "content",
368            // And so does a proportion, for the same reason: the meter carries
369            // the tone, and it is about the ratio rather than about the row.
370            Self::Proportion => "content",
371        }
372    }
373}
374
375/// How far down the heading tree a title sits.
376///
377/// Three, and only the three that are actually headings. The bands those used
378/// to be filed with (goingson's `.page-header`, Balanced Breakfast's `.header`
379/// and `.detail-header`) are arrangement, not type, and live at
380/// [`Region::Band`]. One of them contains no text at all.
381#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
382#[non_exhaustive]
383pub enum Heading {
384    /// Names the whole screen. One per screen.
385    Page,
386    /// Names a block within the screen.
387    Section,
388    /// Names a sub-block inside an already-named section.
389    Subsection,
390}
391
392/// A control that picks between things.
393///
394/// Three, because three distinct behaviours are in play and collapsing any two
395/// loses something. A segmented control picks a value; a tab picks a pane; a
396/// toggle picks nothing and simply holds itself on or off.
397#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
398#[non_exhaustive]
399pub enum Selector {
400    /// Exactly one of N, and the options abut.
401    Segmented,
402    /// Independent on or off, on its own.
403    Toggle,
404    /// Navigation between panes. The folder semantic.
405    Tabs,
406}
407
408impl Selector {
409    /// How the chosen option sits.
410    ///
411    /// Held in for a segmented control and a toggle, which is the same shape
412    /// pressing produces and the whole economy of the idiom: one appearance,
413    /// two reasons to wear it. A tab is the exception, because the selected
414    /// folder tab comes *forward* to join the pane it opens.
415    #[must_use]
416    pub const fn chosen(self) -> Depth {
417        match self {
418            Self::Segmented | Self::Toggle => Depth::Well,
419            Self::Tabs => Depth::Raised,
420        }
421    }
422
423    /// How the options that were *not* picked sit.
424    ///
425    /// Describing only [`Selector::chosen`] left the unchosen option falling
426    /// through to [`Depth::Flat`], which says it is level with the strip it
427    /// sits in, and no renderer emitted anything for it. That is wrong in both
428    /// directions and goingson proved it: its unchosen tabs are recessed by
429    /// hand, and being recessed is *why* the chosen one reads as coming
430    /// forward. Against a flat strip, a raised chosen tab is a bevel drawn on
431    /// the strip's own colour, which is a much weaker folder effect than the
432    /// contrast the idiom is named after.
433    ///
434    /// Each member is the inverse of its chosen state, which is the whole
435    /// content of "picked" once colour is deferred:
436    ///
437    /// - Tabs recede, so the chosen one comes forward.
438    /// - A segment and a toggle stand up, so the chosen one is held in.
439    #[must_use]
440    pub const fn unchosen(self) -> Depth {
441        match self {
442            Self::Tabs => Depth::Sunken,
443            Self::Segmented | Self::Toggle => Depth::Raised,
444        }
445    }
446
447    /// Whether the options touch.
448    ///
449    /// The gap is the entire difference between a segmented control and a row
450    /// of buttons that happen to sit near each other, which is what audiofiles'
451    /// `segmented_control` says in its own comment and why it zeroes the
452    /// spacing by hand.
453    #[must_use]
454    pub const fn abutting(self) -> bool {
455        matches!(self, Self::Segmented | Self::Tabs)
456    }
457
458    /// Whether the chosen option joins the surface it opens.
459    ///
460    /// True for [`Tabs`](Self::Tabs) alone, and it is the half of the folder
461    /// semantic [`chosen`](Self::chosen) cannot carry. A depth says how a
462    /// thing sits relative to what is behind it; this says that the chosen
463    /// option and its pane are one surface, so the run between them is not
464    /// drawn at all.
465    ///
466    /// Kept as its own question rather than folded into the depth because the
467    /// pairing rule holds: fill and bevel travel together off one [`Depth`],
468    /// and a renderer composes that depth's fill with
469    /// [`Bevel::RaisedOpen`](crate::Bevel::RaisedOpen) when this is true. A
470    /// segment or a toggle picks a value and opens nothing, so there is
471    /// nothing for either of them to join.
472    #[must_use]
473    pub const fn joins_its_pane(self) -> bool {
474        matches!(self, Self::Tabs)
475    }
476}