use crate::{Depth, Fill, Intent, Priority}; // Names this module's prose links to, resolved for rustdoc. #[allow(unused_imports)] use crate::{Awaiting, Meter, Region}; /// What a region is saying, when it is saying something. /// /// The one intent family shared by badges, notices and nothing else. Kept /// separate from [`Fill`] because a surface is where a thing sits and a tone is /// what it means, and the three apps agree on the four statuses: /// `info_banner` / `warning_banner` in audiofiles, `.toast-info` / /// `.toast-success` / `.toast-error` in goingson, `.toast.success` / /// `.toast.error` in Balanced Breakfast. /// /// The per-tag palette (`category-one` through `category-six`) is deliberately /// not here. Which colour a *particular* tag takes is app domain, and both /// webview apps already carry it as a `data-color` attribute. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum Tone { /// No status. /// /// Ordinary content, at full weight. It does not also mean muted: a /// badge reads quiet because [`Token::Badge`] answers no click, which is /// the renderer's knowledge and not this axis's. A renderer wanting a /// muted badge reaches for [`Token::interactive`] itself rather than /// expecting `Neutral` to have muted it. Neutral, /// Something worth knowing and nothing to do about it. Info, /// Something finished and it worked. Success, /// Something the user should look at before continuing. Warning, /// Something broken, or something about to be destroyed. Danger, } impl Intent for Tone { fn token(self) -> &'static str { match self { // Neutral has no status token of its own, so it takes the plain // content intent. It used to answer `content-muted`, which read // "no status" as "de-emphasised" and muted every figure value in // the webview. Muting is a renderer's call about a particular // token, not something the status axis knows. Self::Neutral => "content", Self::Info => "info", Self::Success => "success", Self::Warning => "warning", Self::Danger => "danger", } } } /// A small labelled thing that sits inside something else. /// /// Two members, because the three apps drew three taxonomies and only one line /// runs through all of them: does it answer a click. audiofiles has /// `classification_badge` (a label) against `tag_chip`, `tag_chip_removable` /// and `selectable_tag` (all of which do). Balanced Breakfast has `.tag` and /// `.badge` against `.tag-chip`. goingson is the one that has to move: its /// `.tag` and `.badge` are a single CSS rule, so every call site has to be read /// to decide which of the two it always was. /// /// The evidence that a chip is a real concept rather than a badge with a /// cursor: audiofiles inverts its bevel on press and Balanced Breakfast latches /// `.tag-chip.active` with the inset bevel. Two independent arrivals at "a chip /// holds itself down", which is exactly what [`Depth::pressed`] already says. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum Token { /// Non-interactive status or count. Answers no click. Badge, /// An interactive or removable token. Answers a click, and latches if it /// stands for a filter that is either on or off. Chip { /// Whether it carries its own remove affordance. removable: bool, }, } impl Token { /// Whether this answers a click. /// /// The whole difference between the two members, and the reason a renderer /// with no hover (a touch surface, a terminal) can still tell them apart. #[must_use] pub const fn interactive(self) -> bool { matches!(self, Self::Chip { .. }) } /// How it sits, given whether it is currently latched down. /// /// A badge is flat: it is a label, and giving it an edge would say it can /// be pressed. A chip is raised, and inset while latched. #[must_use] pub const fn depth(self, latched: bool) -> Depth { match self { Self::Badge => Depth::Flat, Self::Chip { .. } if latched => Depth::Well, Self::Chip { .. } => Depth::Raised, } } } /// Something the app is telling the user, unprompted. /// /// Two concepts, not one with a placement. They differ in more than where they /// sit: a toast is transient, stacked and self-dismissing, and a banner is /// persistent, in flow, one per region, and dismissed by fixing the condition /// it reports. Folding them into one member with a placement parameter would /// make lifetime, stacking and dismissal all placement-dependent, which is the /// description leaking renderer policy. /// /// All three apps have banners: `info_banner` and `warning_banner` in /// audiofiles, five of them in goingson (sync, sync-result, vacation-day, /// timer-active, past-review), `.update-banner` in Balanced Breakfast. The two /// webview apps also have toasts. So neither member is speculative, and no app /// gains a concept it lacks except audiofiles, whose renderer may legitimately /// decline to draw a toast at all. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum Notice { /// Transient, stacked, dismisses itself. Toast, /// Persistent, in flow, one per region, dismissed by fixing the cause. Banner, } impl Notice { /// Whether it goes away on its own. #[must_use] pub const fn transient(self) -> bool { matches!(self, Self::Toast) } /// How it sits. /// /// A toast floats above the page rather than resting on it, which is /// [`Fill::Overlay`]'s whole reason to exist. A banner is a card in the /// flow. Both are raised, and they are raised off different things. #[must_use] pub const fn fill(self) -> Fill { match self { Self::Toast => Fill::Overlay, Self::Banner => Fill::Raised, } } } /// The parts of a list row. /// /// `#[non_exhaustive]`: a renderer carries a wildcard arm, so a new part is not /// a lockstep event across three renderers. /// /// # Meta against Tokens /// /// The line is whether the thing has its own standing. `Meta` is one short /// trailing fact about the row, written as text: a count, a size, a date. /// `Tokens` is a set of small labelled things, each of which can be toned and /// can answer a click. "3 files" is meta. A status badge that is amber, and a /// tag you can click to filter by, are tokens. /// /// Keeping them apart is what a single widened slot would have foreclosed. A /// renderer can right-align one string and cannot usefully do the same to a /// strip of chips, and a fact that is not clickable should not be drawn as /// though it were. /// How much vertical room a part's text may take. /// /// A row is an inline run and every part in it is a leaf, so a part's text has /// always been drawn on one line and no description could say otherwise. Two /// apps say otherwise in their own stylesheets, both to the same number and /// both with a comment explaining it: Balanced Breakfast clamps a feed row's /// title to two lines (`.row--article .row-primary`, whose comment reads /// "overrides .row-primary's single flex line"), and goingson clamps a /// problem's body to two ("two lines is enough to recognize one, and the full /// text is in the task once promoted"). /// /// Two named tiers rather than a line count, and the count is what the measured /// demand argues against. Both sites want exactly one tier past the default, /// and a number invites a row whose primary is a paragraph, which is a block /// and has no business in a run. A third tier is a decision, made here, rather /// than something a call site can reach for. /// /// What a renderer owes it: `Tight` is what a run already does and needs no /// answer. `Relaxed` is at most two lines and then truncation, however that /// renderer truncates -- a webview clamps, a terminal wraps into two rows of /// cells, an immediate-mode renderer caps the galley. A renderer that cannot /// give two lines may draw one; what it may not do is grow without bound, /// because the run is a line and the row's neighbours are relying on that. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] #[non_exhaustive] pub enum Flow { /// One line. What every part did before this type existed. #[default] Tight, /// Up to two lines, then truncated. Relaxed, } impl Flow { /// How many lines the part may take. /// /// A number here rather than in the enum, because a renderer needs one and /// a call site does not. That asymmetry is the whole argument for the /// tiers: the description says how much room the thing deserves and this /// says what that costs, so a third tier changes one line rather than every /// consumer's arithmetic. #[must_use] pub const fn lines(self) -> u8 { match self { Self::Relaxed => 2, // Including any tier added later: one line is the safe reading of // an unknown flow, since it is what the run guaranteed before flows // existed. _ => 1, } } } /// How deep a row sits inside a set: a tree, an outline, a threaded list. /// /// [`RowPart`] below already names what is *in* a row; nothing named where a /// row sits relative to its siblings, so every consumer that had a hierarchy /// carried a bare number and every renderer decided for itself what one was /// worth. /// /// # Not `Depth`, and the collision is the reason /// /// [`Depth`] is taken and means something else entirely: surface bevel -- /// `Flat`, `Raised`, `Well`, `Sunken`, `Overlay` -- a fact about a surface /// rather than a position in a hierarchy. Two meanings under one word in one /// crate is the collision that costs a reader an hour, and the word this /// concept wants is the one that would cause it. /// /// # The magnitude is the renderer's, and that is the precedent /// /// [`Awaiting`] is the shape: this crate names the fact and declines to name /// what it is worth. makeover-webview writes the rule as a custom property with /// a fallback -- the way it writes `margin-inline-start: var(--awaiting-gap, /// 0.5ch)` -- so a level has one answer per renderer and an app can override /// it. A terminal spends columns, a browser spends inline space, and neither /// number belongs in a description. /// /// # Zero is a real answer /// /// [`top`](Self::top) is the default and is what a flat list says: every row is /// at the top level, which is true and is the reading a renderer needs. An /// `Option` here would make "not nested" and "nested at zero" two spellings of /// one thing, the same argument `Discovery`'s `indexable` makes about defaults /// that are meaningful. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, PartialOrd, Ord)] #[non_exhaustive] pub struct Nesting { /// How many levels in, counting from zero. /// /// `u8` because a hierarchy a reader can follow is not 256 deep, and a /// renderer indenting by a level has to multiply it by something -- a wider /// integer here is a wider integer in every renderer's arithmetic for a /// range nothing will use. pub level: u8, } impl Nesting { /// The top level: not nested. The default, and what a flat list says. #[must_use] pub const fn top() -> Self { Self { level: 0 } } /// A row this many levels in. #[must_use] pub const fn at(level: u8) -> Self { Self { level } } /// Whether this row sits under another. /// /// The question every renderer asks before it spends anything on indenting, /// answered once here rather than by a `> 0` in each -- which is /// [`Awaiting::is_determinate`]'s reason too. #[must_use] pub const fn is_nested(self) -> bool { self.level > 0 } } #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] #[non_exhaustive] pub enum RowPart { /// The thing itself. What the row is called. Primary, /// Supporting text under the primary. Secondary, /// A short trailing fact: a count, a size, a date. Meta, /// Controls that act on this row. Actions, /// Small labelled things belonging to the row: badges, chips, tags. /// /// Each carries its own [`Token`] kind and [`Tone`], so a renderer with no /// colour still has the kind to work with, and one with no chips still has /// the label. That is the constrained-consumer test this vocabulary exists /// to pass, and it is why the tone lives on the token rather than on the /// part. Tokens, /// How much of a set the row's thing has done: a [`Meter`] in the row. /// /// A row holds no nodes, by the rule that a row part may not carry an /// arbitrary node, which is the door through which a description becomes a /// templating language. So the part carries the *description of a bar* rather than a node, exactly as /// `Tokens` carries tags rather than nodes. /// /// Without it a row flattens the proportion into [`Meta`](Self::Meta) as /// "3/7 subtasks", which keeps both numbers and loses the reading, the same /// way a toned status badge read as prose before `Tokens`. Proportion, } impl RowPart { /// What the part is worth when the run does not fit. /// /// The default only. A part may say otherwise, and a renderer reads the /// part rather than the role; this is what a description that has never /// heard of [`Priority`] means, which is every description written before /// the field existed. /// /// Deriving it from the role is the thing this vocabulary has otherwise /// been moving away from, and it is right here for one reason: the roles /// already encode this ranking and every consumer already assumes it. /// [`Primary`](Self::Primary) is what the row is called, and /// [`Priority::Essential`]'s own doc was written about exactly that -- /// "without it the row does not identify itself". /// /// [`Actions`](Self::Actions) is `Essential` and it is the interesting one. /// A control is not a fact, so dropping it does not cost the reader a /// detail; it costs them the only way to act on the row, and in a terminal /// it silently removes something focus had already been claimed for. A /// renderer that needs room takes it from what the row *says*, never from /// what it *offers*. /// /// An unknown member reads as [`Priority::Secondary`]: droppable, but not /// first, since guessing `Optional` for something this crate has not been /// taught would make a new member the first thing to vanish. #[must_use] pub const fn priority(self) -> Priority { match self { Self::Primary | Self::Actions => Priority::Essential, Self::Meta | Self::Proportion => Priority::Optional, _ => Priority::Secondary, } } /// The content intent the part takes. #[must_use] pub const fn intent(self) -> &'static str { match self { Self::Primary => "content", Self::Secondary => "content-secondary", Self::Meta => "content-muted", // Actions carry controls rather than text, so they inherit. Self::Actions => "content", // So do tokens: each one carries its own tone, and a part-level // intent underneath it would fight the token that sits on it. Self::Tokens => "content", // And so does a proportion, for the same reason: the meter carries // the tone, and it is about the ratio rather than about the row. Self::Proportion => "content", } } } /// How far down the heading tree a title sits. /// /// Three, and only the three that are actually headings. The bands those used /// to be filed with (goingson's `.page-header`, Balanced Breakfast's `.header` /// and `.detail-header`) are arrangement, not type, and live at /// [`Region::Band`]. One of them contains no text at all. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum Heading { /// Names the whole screen. One per screen. Page, /// Names a block within the screen. Section, /// Names a sub-block inside an already-named section. Subsection, } impl Heading { /// Whether a rule follows the heading. /// /// audiofiles' `section_header` draws a separator and its /// `subsection_label` deliberately does not, which is the only thing /// distinguishing the two once weight and colour are deferred. #[must_use] pub const fn separated(self) -> bool { matches!(self, Self::Section) } } /// A control that picks between things. /// /// Three, because three distinct behaviours are in play and collapsing any two /// loses something. A segmented control picks a value; a tab picks a pane; a /// toggle picks nothing and simply holds itself on or off. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum Selector { /// Exactly one of N, and the options abut. Segmented, /// Independent on or off, on its own. Toggle, /// Navigation between panes. The folder semantic. Tabs, } impl Selector { /// How the chosen option sits. /// /// Held in for a segmented control and a toggle, which is the same shape /// pressing produces and the whole economy of the idiom: one appearance, /// two reasons to wear it. A tab is the exception, because the selected /// folder tab comes *forward* to join the pane it opens. #[must_use] pub const fn chosen(self) -> Depth { match self { Self::Segmented | Self::Toggle => Depth::Well, Self::Tabs => Depth::Raised, } } /// How the options that were *not* picked sit. /// /// Describing only [`Selector::chosen`] left the unchosen option falling /// through to [`Depth::Flat`], which says it is level with the strip it /// sits in, and no renderer emitted anything for it. That is wrong in both /// directions and goingson proved it: its unchosen tabs are recessed by /// hand, and being recessed is *why* the chosen one reads as coming /// forward. Against a flat strip, a raised chosen tab is a bevel drawn on /// the strip's own colour, which is a much weaker folder effect than the /// contrast the idiom is named after. /// /// Each member is the inverse of its chosen state, which is the whole /// content of "picked" once colour is deferred: /// /// - Tabs recede, so the chosen one comes forward. /// - A segment and a toggle stand up, so the chosen one is held in. #[must_use] pub const fn unchosen(self) -> Depth { match self { Self::Tabs => Depth::Sunken, Self::Segmented | Self::Toggle => Depth::Raised, } } /// Whether the options touch. /// /// The gap is the entire difference between a segmented control and a row /// of buttons that happen to sit near each other, which is what audiofiles' /// `segmented_control` says in its own comment and why it zeroes the /// spacing by hand. #[must_use] pub const fn abutting(self) -> bool { matches!(self, Self::Segmented | Self::Tabs) } }