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