//! The renderer-agnostic half of the make-family design system. //! //! //! //! `makeover` answers *what colour*, and varies by theme. `makeover-geometry` //! answers *how much space*, and varies by density and surface. This crate //! answers *what the thing is*, and varies by nothing. //! //! # The deferral rule //! //! A description names intents and relationships, never values. Say //! [`Fill::Raised`], never `#D9DDF4`. Say `Gap::Peer`, never `6px`. What is //! left once colour and spacing are deferred is **composition**: which edges //! are lit, what inverts on press, what nests in what. //! //! The constraint that shapes all of it: a renderer that can only paint //! rectangles has to be able to express the result. egui has no //! `box-shadow: inset` and one stroke per widget with no per-side control; a //! terminal has box-drawing characters and one cell of resolution, and cannot //! draw a two-tone lit edge at all. A description that assumes per-side edges //! is a CSS description wearing a neutral name. So this crate names the //! *intent* — this region is a well — and each renderer chooses an expression //! it can actually produce, including dropping half of one. //! //! # Scope //! //! Depth came first: the bevel and the surfaces it shapes. That much was //! settled the hard way — the vocabulary here was read off audiofiles' //! `ui::theme` and `ui::widgets`, which are the only implementation written //! by a consumer with no CSS, then checked against both webview apps. All //! three agreed once Balanced Breakfast's fills were corrected. //! //! 0.2.0 adds the rest of the description, each member drawn the same way, //! from what the three apps already hand-write rather than from a taxonomy: //! //! - Components. [`Token`] (badge against chip), [`Notice`] (toast against //! banner), [`RowPart`], [`Heading`], [`Selector`], [`Readiness`], and //! [`Tone`], which is the one intent family they share. //! - Schemas. [`Field`] for forms and [`Column`] for lists and tables. //! - Structure. [`Region`] for the parts of a screen, [`Arrangement`] for how //! a screen is put together. //! //! **Validation** is absent on purpose rather than pending: neither app has a //! shared story, and a schema describing fields but not constraints acquires a //! constraint layer per app, which is how the divergence this crate exists to //! end got started. //! //! 0.3.0 closes a gap the first real adoption found, which is what adopting //! against goingson first was for. [`Selector`] described only the *chosen* //! option, so an unchosen one fell through to [`Depth::Flat`] and no renderer //! drew it; goingson's tab strip recesses its unchosen tabs by hand and could //! not delete the line, because being recessed is *why* the chosen tab reads as //! coming forward. So [`Selector::unchosen`] joins `chosen`, and saying it //! needed [`Fill::Sunken`] and [`Depth::Sunken`]: a surface set back by colour //! with no edge, which is neither a well nor level-with. //! //! 0.7.0 adds [`State`], the interaction axis, closing the gap that adopting //! against three apps rather than one made visible. The description named //! rest and, through [`Depth::pressed`], pressed. It named neither focus nor //! disabled, so `makeover-webview` emitted a hover rule and stopped, and each //! consumer completed the primitive from outside by out-specifying a rule it //! did not own: 19 such rules in goingson, 21 in the MNW server, a further set //! in Balanced Breakfast, and three focus rings that do not match. The axis is //! deliberately two members wide, because hover and pressed belong where they //! already are. [`State`]'s own docs carry that argument. //! //! 0.8.0 finishes [`Field`], which described a field well enough to label it and //! not well enough to draw it. Writing `makeover-webview`'s form emitter found //! three things missing and the renderer supplied all three from outside: the //! current value, a select's options, and the placeholder. Two of those move //! here and one does not. //! //! - [`Field::placeholder`] is user-facing text sitting beside `label` and //! `hint`. There was never a reading on which it was renderer state; it was //! outside only because adding a field to a published struct is breaking. //! - [`Field::options`] moves because every renderer needs them and each was //! going to invent its own shape. [`Choice`] is the shape `makeover-webview` //! already arrived at, taken as-is rather than redesigned. //! - The current value stays renderer-side and is not coming here. It is the //! one of the three that is genuinely state: a webview reads it out of the //! DOM, an immediate-mode renderer holds a `&mut` to the app's own field, and //! a description that carried it would be a form model. //! //! # Where the description stops //! //! The bespoke widgets, a day-plan timeline and a kanban board and a calendar, //! are not describable here and will not become describable. A description //! expressive enough to produce a timeline is a widget library wearing a //! description's name. Generate the boring 80% so the bespoke 20% gets the //! attention. //! //! [`Region::Bespoke`] is how that limit is stated rather than hidden. The //! description names the *place* and the app owns the contents, so a screen //! containing a timeline is still a whole screen and still routable. Without //! it, the four goingson screens that make the app worth using would need a //! second, undescribed path beside the router, and two paths is how a //! vocabulary starts drifting from its app again. #![forbid(unsafe_code)] /// A colour intent this crate refers to but never resolves. /// /// The string is the token name `makeover` publishes, so a renderer can look /// it up without this crate knowing what colour came back. pub trait Intent { /// The `makeover` intent token this resolves against. fn token(self) -> &'static str; } /// Which way the light falls across a two-tone edge. /// /// The whole content of a bevel, once colour and thickness are deferred. The /// light is always assumed to come from the top left: every consumer measured /// agreed on that and none of them ever varied it, so it is an invariant here /// rather than a parameter. /// /// # The two corners that belong to both edges /// /// Top-right and bottom-left are where the lit run meets the shaded one, and /// the description's claim is that they belong to *both*. How a renderer says /// that is its own business, because the answer is bounded by resolution and /// not by taste: /// /// - A terminal cell is roughly 8x17 device pixels, so giving the whole corner /// to one tone thickens that edge by a cell and reads as one run overrunning /// the other. A half-cell glyph divides the cell already, so `makeover-tui` /// splits it and recovers real information. Its box-drawing fallback cannot: /// a single stroke has no half to give, so there both corners go to dark. /// - A pixel bevel is a one-point stroke by default, which makes the corner a /// one-point square. There is nothing to divide — a diagonal seam across one /// point is sub-pixel, and antialiasing renders it as the blend a mitred join /// already produces. So `makeover-immediate` mitres and is *not* diverging; /// it is the same rule at a resolution where the split degenerates. /// /// Stated here so the difference reads as a decision rather than as drift. A /// renderer with room to divide the corner should; one without should mitre or /// pick the shaded tone, and neither is a bug. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum Bevel { /// Lit from the top left: light on top and left, dark on bottom and right. Raised, /// The same edge inverted, which is also the pressed state of anything /// that draws itself [`Bevel::Raised`]. Inset, } impl Bevel { /// The edge intents, as `(top_left, bottom_right)`. /// /// Split out from any painting because the inversion *is* the idea, and /// it is the one part every renderer implements identically. #[must_use] pub const fn edges(self) -> (Edge, Edge) { match self { Self::Raised => (Edge::Light, Edge::Dark), Self::Inset => (Edge::Dark, Edge::Light), } } /// Pressing inverts. A raised control reads as inset while held. /// /// Stated here rather than left to each consumer because a cascade can /// carry a pressed state and an immediate-mode renderer cannot: audiofiles /// resolves this per call site, eighteen times. #[must_use] pub const fn pressed(self) -> Self { match self { Self::Raised => Self::Inset, Self::Inset => Self::Raised, } } } /// One side of a bevel, named by the intent it takes. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum Edge { /// The lit side. Light, /// The shadowed side. Dark, } impl Intent for Edge { fn token(self) -> &'static str { match self { Self::Light => "bevel-light", Self::Dark => "bevel-dark", } } } /// A surface intent a region is filled with. /// /// `#[non_exhaustive]`, so a renderer must carry a wildcard arm and a new /// member is additive rather than breaking. Added 0.4.0, after [`Sunken`] /// (an additive member, 0.3.0) hard-broke `makeover-tui` and /// `makeover-immediate` at compile time and left neither able to move until /// both published. The vocabulary exists to grow and the renderers exist to /// disagree about how much of it they answer, so growth must not be a /// lockstep event. The renderer's wildcard is not a hole: [`Fill`] is /// resolved through a fallible lookup, and a missing intent is answered with /// structure rather than with a substituted colour. /// /// [`Sunken`]: Fill::Sunken #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] #[non_exhaustive] pub enum Fill { /// The page behind everything. Page, /// A surface lifted off the page: cards, controls, menus, toasts. Raised, /// A surface floating above the page rather than resting on it. Overlay, /// The inside of a well. Well, /// A surface set back from the one it sits on, by colour and nothing else. /// /// Not a well. A well is a hole with an edge, and the two are authored in /// opposite directions: `makeover` derives `surface-well` by inverting /// against the theme's own content colour, while `surface-sunken` is /// authored and free to sit darker than raised (goingson's does). Naming /// only the well left the recessed-with-no-edge surface unsayable, which is /// what an unchosen tab is: it recedes so the chosen one can come forward, /// and it carries no bevel of its own. /// /// Added 0.3.0, from goingson's tab strip, which hand-writes exactly this /// and could not delete the line because no member described it. Sunken, } // No `fallback` here, deliberately. An earlier cut had `Fill::Well` fall back // to `Fill::Page` so a consumer on makeover 2.2.0, which has no `surface-well`, // had something to paint. makeover-tui found that wrong within a day: page is // the surface a well is usually cut into, so on a terminal that substitution // produces exactly the invisibility it was meant to prevent, and the right // answer there is a drawn edge rather than a different colour. // // Substituting one intent for another is renderer policy. The description says // what the region is and stops. impl Intent for Fill { fn token(self) -> &'static str { match self { Self::Page => "surface-page", Self::Raised => "surface-raised", Self::Overlay => "surface-overlay", Self::Well => "surface-well", Self::Sunken => "surface-sunken", } } } /// How a region sits relative to the surface behind it. /// /// Fill and bevel are named together because naming them apart is what let /// them disagree. Every consumer measured had at least one region carrying a /// raised bevel over a recessed fill: audiofiles fixed it in `raised_frame` /// and recorded the bug in its doc comment, and Balanced Breakfast still had /// twelve of them a year later. A single name for the pair makes that /// unrepresentable. /// `#[non_exhaustive]` for the same reason as [`Fill`], and in the same /// release: a depth this renderer has no drawing for should cost it a /// wildcard arm, not a compile error and a wait on someone else's publish. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] #[non_exhaustive] pub enum Depth { /// Level with its surroundings. No edge. Flat, /// A card laid on the panel it sits in. Raised, /// A hole in the panel, with content down inside it. For anything the /// user looks *into*: a table body, a tag tree, a text field. Well, /// Set back from what it sits on, by colour alone. No edge. /// /// The one member carrying a fill without a bevel, so a renderer cannot /// assume the two arrive together. That is deliberate and it is still the /// pairing rule: both halves come off the same `Depth`, so they cannot /// disagree, and here one half is legitimately absent. /// /// Distinct from [`Depth::Flat`], which has no fill either and inherits. /// Recessed and level-with are different claims, and only one of them /// needs a colour. Sunken, } impl Depth { /// The edge this depth is drawn with, if it has one. #[must_use] pub const fn bevel(self) -> Option { match self { // Sunken joins Flat here, for the opposite reason: Flat has no edge // because nothing separates it from its surroundings, and Sunken has // none because its colour is already doing the separating. Self::Flat | Self::Sunken => None, Self::Raised => Some(Bevel::Raised), Self::Well => Some(Bevel::Inset), } } /// The surface this depth is filled with. /// /// [`Depth::Flat`] has no fill of its own: it inherits whatever it sits on, /// which is the difference between level-with and painted-the-same-colour. #[must_use] pub const fn fill(self) -> Option { match self { Self::Flat => None, Self::Raised => Some(Fill::Raised), Self::Well => Some(Fill::Well), Self::Sunken => Some(Fill::Sunken), } } /// Pressing a raised region reads as a well, and nothing else moves. #[must_use] pub const fn pressed(self) -> Self { match self { Self::Raised => Self::Well, other => other, } } } /// An interaction state a region can be in, beside whatever [`Depth`] it is. /// /// Orthogonal to depth on purpose. A disabled button is still [`Depth::Raised`] /// and a disabled field is still a [`Depth::Well`], so folding either member /// into `Depth` would make [`Depth::bevel`] and [`Depth::fill`] answer for /// something that is not a depth, and would leave disabled-button and /// disabled-field sharing one variant that cannot tell them apart. /// /// # Why hover and pressed are not members /// /// The line is whether every renderer has the state to express, not whether CSS /// does. Hover is renderer policy and `makeover-webview` says so in its own /// header: a terminal and an immediate-mode painter have no pointer hovering /// over anything, and pressed already arrives through [`Bevel::pressed`] and /// [`Depth::pressed`], where it belongs, because pressing is a depth inversion /// rather than a separate condition. /// /// Focus and disabled are different in kind. A TUI has a focused widget and a /// greyed-out one; so does egui. Both were unsayable here, so all three webview /// consumers supplied them from outside the primitive by out-specifying rules /// they did not own: goingson alone carries 19 of them, and the MNW server /// another 21. That is the divergence this crate exists to end, arriving one /// layer down. /// /// # The principle this encodes /// /// A primitive owns every state it implies. A renderer that emits a hover rule /// for a thing owes disabled, focus and the capability answer for that same /// thing, because anything less exports the completion work to N consumers who /// will each do it differently. /// /// `#[non_exhaustive]` for the reason [`Fill`] and [`Depth`] carry it: growth /// must not be a lockstep event across the three renderers. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] #[non_exhaustive] pub enum State { /// Keyboard focus, as distinct from the pointer having landed on something. /// /// One ring, not one per primitive. Where the ring sits is [`Depth`]'s /// question and not a per-component choice: a well takes it inside its own /// edge and a raised surface takes it outside. That is one decision with /// two renderings rather than one decision per component, which is how the /// three apps ended up with three rings. Focus, /// Present, visible, and not answering. /// /// Not the same as absent, and deliberately not a [`Fill`]: a disabled /// control keeps the surface it always had and stops responding, so what /// changes is its content and its interactivity rather than what it is. Disabled, } impl State { /// Whether a region in this state stops answering the pointer. /// /// Stated in the description rather than left to each renderer, on the same /// reasoning as [`Bevel::pressed`]: a cascade carries it for free and an /// immediate-mode renderer resolves it per call site, so leaving it unsaid /// means resolving it once per consumer and disagreeing. #[must_use] pub const fn suppresses_interaction(self) -> bool { match self { Self::Disabled => true, Self::Focus => false, } } } impl Intent for State { fn token(self) -> &'static str { match self { // Already derived by `makeover` from `action.primary`, and unused // until now for the same reason `hover-surface` was: nothing // emitted the rule that would consume it. Self::Focus => "focus-ring", // Reusing the muted content intent rather than minting a // `disabled` colour. Disabled is a reduction and not a status, and // `makeover-webview`'s progress rules already record the reading // that `content-muted` is what disabled looks like. Self::Disabled => "content-muted", } } } /// 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. Reads as ordinary de-emphasised content. 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. It takes the muted // content intent, which is what both webview apps already spell as // `data-color="muted"`. Self::Neutral => "content-muted", 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. /// /// Four, taken from Balanced Breakfast, which is the only consumer that had all /// of them (`row-primary`, `row-secondary`, `row-meta`, `row-actions`). /// audiofiles has two and no slot structure at all, so it gains meta and /// actions as real work rather than a rename; goingson moves off /// `task-row` / `task-cell`. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] 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, } impl RowPart { /// Whether the part stays hidden until the row is hovered or focused. /// /// Behaviour of the part, not app policy: Balanced Breakfast and goingson /// grew the same hover-reveal on their actions independently and neither /// applies it to anything else. /// /// A renderer with no hover shows it always. That is a renderer decision /// and this returning `true` does not forbid it. #[must_use] pub const fn revealed_on_hover(self) -> bool { matches!(self, Self::Actions) } /// 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", } } } /// 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. /// /// Added 0.3.0. 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) } } /// Whether the content of a region has arrived. /// /// The state, not the shimmer. Whether pending paints a skeleton, a spinner or /// nothing at all is renderer policy, the same class of decision that got /// `Fill::fallback` deleted from this crate. goingson and Balanced Breakfast /// each grew a skeleton with differently-named parts; both keep them, as the /// webview renderer's expression of [`Readiness::Pending`]. audiofiles has none /// and needs none, because an immediate-mode renderer simply repaints. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum Readiness { /// The content is here. Ready, /// The content is on its way. Pending, } /// A named part of a screen. /// /// The thing `makeover-geometry` deliberately does not name: it names the space /// *between* things by relationship, and nothing named the things. Six named /// members, taken from what the two webview apps actually use, plus /// [`Region::Bespoke`] for the parts no description should reach. Both apps' /// `layout.css` currently names exactly two things, `.raised` and `.well`, so /// this layer is absent rather than divergent, which makes it the cheapest of /// the schemas to add and the easiest to over-build. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum Region<'a> { /// A full-width strip with a title slot and an actions cluster, either of /// which may be empty. goingson's `.page-header`, Balanced Breakfast's /// `.header` and `.detail-header` are all this, differing only in which /// slots they fill. Band, /// A persistent column beside the content, holding navigation. Sidebar, /// A region of content with its own scroll. Pane, /// Two panes side by side, where the left chooses what the right shows. Split, /// A set of panes, one visible at a time, with a [`Selector::Tabs`] above. TabGroup, /// Content over a scrim, taking input until dismissed. Modal, /// A region this crate names the *place* of and nothing else. The app owns /// what goes in it. /// /// The escape hatch, and the thing that keeps the description honest about /// its own limits. A day-plan timeline, a kanban board, a calendar and the /// paint interaction over the timeline are not describable here and are not /// going to become describable: a description expressive enough to produce /// a timeline is a widget library wearing a description's name. /// /// But a screen containing one still has to be a screen. Without this /// member the description covers only the boring screens, and the four that /// make goingson worth using would need a second, undescribed path beside /// the router. Two paths is how the vocabulary starts drifting from the app /// again, which is the exact failure this crate exists to end. /// /// So the description says "a thing called `day-plan` goes here" and stops. /// The name is opaque: this crate never interprets it, and no renderer is /// expected to know what it means beyond handing the space over. Bespoke { /// What the app calls it. Never interpreted here. name: &'a str, }, } impl Region<'_> { /// How the region sits on what is behind it. #[must_use] pub const fn depth(self) -> Depth { match self { Self::Band | Self::Sidebar | Self::Split | Self::TabGroup => Depth::Flat, // A pane is looked into, the same as a table body or a tag tree. Self::Pane => Depth::Well, Self::Modal => Depth::Raised, // Flat because it inherits: a bespoke region takes the depth of // whatever frames it. An app that wants its timeline in a well puts // it in a `Pane`, which composes rather than adding a knob here. Self::Bespoke { .. } => Depth::Flat, } } /// Whether this crate can say anything about the region's contents. /// /// A renderer walks the description and hands every region it understands /// to the right drawing code. This is how it tells the two apart, and the /// reason it is a method rather than a `matches!` at each renderer: there /// is exactly one opaque member and there should stay exactly one. #[must_use] pub const fn described(self) -> bool { !matches!(self, Self::Bespoke { .. }) } } /// How a screen is laid out. /// /// Two, and the second is not a variant of the first. goingson is list-detail, /// Balanced Breakfast is sidebar plus content, and neither app has a third. /// The tab group is a modifier rather than a member, because goingson uses it /// *inside* the same content region rather than instead of one. /// /// This exists at all because the router has to be able to express a screen /// rather than only a control. Discovering the arrangement layer missing after /// the renderers exist is a redesign; naming two now is a morning. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum Arrangement { /// A list that chooses what the detail beside it shows. ListDetail { /// Whether the detail side is a [`Region::TabGroup`]. tabbed: bool, }, /// Navigation down the side, content filling the rest. SidebarContent, } /// What kind of value a form field takes. /// /// The union of the two vocabularies that diverged, which is what triggered /// this crate. They have since converged on their own: both apps now have a /// `renderFormField` emitting the same anatomy, and what is left differing is /// the kind set, the error shape, and whether the return is a string or a node. /// /// Validation is deliberately absent. Neither app has a shared story (goingson /// validates after collecting the form data, with per-field transform hooks; /// Balanced Breakfast has `required` and nothing else), and a schema that /// describes fields but not constraints acquires a constraint layer per app, /// which is exactly how the current divergence started. Naming it absent is a /// decision; leaving it unmentioned would not be. /// `#[non_exhaustive]` for the reason [`Fill`] is: renderers match on this and /// the set keeps growing, so growth must not be a lockstep event. Email, Url /// and Tel arriving in 0.5.0 is the second growth in two releases. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] #[non_exhaustive] pub enum FieldKind { /// A single line of text. Text, /// A single line of text that must never be echoed, logged or round-tripped /// through anything that might persist it. Secret, /// A number. Number, /// An email address. /// /// Distinct from [`Text`](Self::Text) because the distinction is not /// decoration: a webview renderer emits `type="email"`, which on a touch /// device changes the keyboard that appears and turns on the platform's own /// validation. goingson ships to iOS, so collapsing this into text costs a /// keyboard with no `@` on it. /// /// Added 0.5.0, from goingson's contact form. Email, /// A URL. Same reasoning as [`Email`](Self::Email). /// /// Added 0.5.0, from goingson's contact-social and contact-feed forms. Url, /// A telephone number. Same reasoning as [`Email`](Self::Email), and the /// clearest case of it: the keyboard is a numeric pad rather than letters. /// /// Added 0.5.0, from goingson's contact-phone form. Tel, /// Several lines of text. Textarea, /// One of a fixed set. Select, /// On or off. Checkbox, /// Carried through the form and never shown. Hidden, } impl FieldKind { /// Whether the field is drawn at all. #[must_use] pub const fn visible(self) -> bool { !matches!(self, Self::Hidden) } /// Whether the value must be kept out of logs and diagnostics. #[must_use] pub const fn confidential(self) -> bool { matches!(self, Self::Secret) } /// Where the field's own label sits. /// /// A checkbox labels itself on the right of the box; everything else takes /// a label above. Both webview apps already do this and both special-case /// it inline, which is the tell that it belongs in the description. #[must_use] pub const fn labels_itself(self) -> bool { matches!(self, Self::Checkbox) } } /// One option a [`FieldKind::Select`] offers. /// /// Two strings, because the submitted value and the read label are different /// facts and every renderer that has tried to collapse them has had to /// un-collapse them later. `makeover-webview` invented this shape writing its /// form emitter and it is taken here unchanged; moving it down rather than /// re-deriving it is the point, since the second and third renderers were each /// going to arrive at a near-miss of it. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct Choice<'a> { /// What is submitted. pub value: &'a str, /// What is read. pub label: &'a str, } impl<'a> Choice<'a> { /// An option whose submitted value is also its label. #[must_use] pub const fn plain(value: &'a str) -> Self { Self { value, label: value, } } } /// One field of a form. /// /// Borrowed rather than owned: a description is built, read once by a renderer, /// and dropped. Nothing here outlives the screen it describes. /// /// # What it carries, and what it does not /// /// Stated here so the next renderer does not re-ask, which is what the first /// two both did. It carries everything a renderer needs to *draw* the field: /// its kind, what it is called, what it is asked for, its standing help, what /// is wrong with it now, whether it is compulsory, whether it hides behind a /// disclosure, its ghost text, and the options it offers. /// /// It does not carry the **current value**, and it is not going to. That is the /// one thing here that is genuinely renderer state: a webview reads it back out /// of the DOM, an immediate-mode renderer holds a `&mut` to the app's own field /// and writes through it, and a terminal keeps an edit buffer. A description /// that carried the value would have to carry a way to write it back, at which /// point it is a form model and no longer a description. /// /// **Validation** is absent for the reason [`FieldKind`] records: [`error`] is /// the *result* of validating, supplied by whoever validated. Nothing here /// decides that a value is wrong. /// /// [`error`]: Field::error #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct Field<'a> { /// What kind of value it takes. pub kind: FieldKind, /// The name the value is submitted under. pub name: &'a str, /// What the user is asked for. pub label: &'a str, /// Standing help, shown whether or not anything is wrong. pub hint: Option<&'a str>, /// What is currently wrong with the value. pub error: Option<&'a str>, /// Ghost text shown while the field is empty. /// /// User-facing text, and it sits with `label` and `hint` rather than with /// the value because it is a property of the *question* and not of the /// answer. It lived renderer-side in `makeover-webview` until 0.8.0 for one /// reason and it was not a reading on where it belonged: adding a field to /// a published struct is a breaking change. /// /// Not a substitute for a label. A field labelled only by its placeholder /// loses its label the moment anything is typed, and no renderer here can /// make that not happen, so the description keeps both. pub placeholder: Option<&'a str>, /// The options offered, in the order they are offered. /// /// Empty for every kind but [`FieldKind::Select`]. A select described with /// no options is sayable on purpose: it is what an app with an /// unfinished-loading option list actually has, and a renderer showing an /// empty select says so on screen rather than in a log. /// /// Which option is *current* is not here. That is the value, and the value /// is renderer state. pub options: &'a [Choice<'a>], /// Whether the form refuses to submit without it. pub required: bool, /// Whether the field lives behind a "more options" disclosure. pub extended: bool, } impl<'a> Field<'a> { /// A plain required-nothing field of the given kind. #[must_use] pub const fn new(kind: FieldKind, name: &'a str, label: &'a str) -> Self { Self { kind, name, label, hint: None, error: None, placeholder: None, options: &[], required: false, extended: false, } } /// A select offering the given options. /// /// The one kind that is under-described by [`Field::new`], so it gets a /// constructor rather than leaving every call site to remember that a /// select with an empty `options` renders as an empty select. #[must_use] pub const fn select(name: &'a str, label: &'a str, options: &'a [Choice<'a>]) -> Self { Self { kind: FieldKind::Select, name, label, hint: None, error: None, placeholder: None, options, required: false, extended: false, } } /// Whether the field is currently reporting a problem. /// /// Read this rather than testing `error.is_some()` at each renderer: the /// error state has to mark the field's whole group and not only the /// message, because a renderer with no descendant selectors (egui, a /// terminal) cannot find the group from the message. goingson already marks /// the group and Balanced Breakfast does not, so goingson's shape is the /// one taken here. #[must_use] pub const fn invalid(&self) -> bool { self.error.is_some() } } /// How much room a column asks for. /// /// An intent, so the actual floor stays with `makeover-geometry`. goingson's /// task table spells these as `minmax(200px, 1fr)`, `140px` and content-sized; /// only the first three words of that survive deferral. /// `#[non_exhaustive]`, for the reason [`Fill`] and [`FieldKind`] are: a /// renderer matches on this and a vocabulary that grows must not break every /// renderer when it does. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] #[non_exhaustive] pub enum Width { /// Takes what it needs and no more. Content, /// A fixed share, the same at every width. Fixed, /// Absorbs whatever is left over. Fill, } /// What a column is worth when there is not room for all of them. /// /// Ordered: [`Priority::Optional`] drops first, [`Priority::Essential`] never /// drops. This replaces addressing columns by position, which is what both /// webview apps do today and is a live bug rather than only verbosity. goingson /// hides mobile columns with `nth-child(n+5)` against a seven-column table, so /// inserting a column silently hides the wrong one. /// `#[non_exhaustive]`, same reasoning as [`Width`]. Note the ordering is the /// whole point of the type, so a new tier has to be declared in its place in /// the sequence rather than appended. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] #[non_exhaustive] pub enum Priority { /// Dropped first. Optional, /// Dropped once the optional columns are gone. Secondary, /// Never dropped. Without it the row does not identify itself. Essential, } /// One column of a table. /// /// Described once. The grid track, the cell order and the drop behaviour are /// all derived from this, rather than being three hand-written encodings that /// must agree and are never checked against each other. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct Column<'a> { /// The heading, and the name the cell is addressed by. pub name: &'a str, /// How much room it asks for. pub width: Width, /// What it is worth when room runs out. pub priority: Priority, } impl<'a> Column<'a> { /// A column that absorbs slack and drops after the optional ones. #[must_use] pub const fn new(name: &'a str) -> Self { Self { name, width: Width::Fill, priority: Priority::Secondary, } } /// Whether this column survives at the given cutoff. /// /// A renderer narrows by raising the cutoff, and never by counting /// positions. #[must_use] pub const fn kept_at(&self, cutoff: Priority) -> bool { (self.priority as u8) >= (cutoff as u8) } } #[cfg(test)] mod tests { use super::*; #[test] fn inset_is_raised_with_the_light_moved() { let (rl, rd) = Bevel::Raised.edges(); let (il, id) = Bevel::Inset.edges(); assert_eq!((rl, rd), (Edge::Light, Edge::Dark)); assert_eq!((il, id), (rd, rl)); } #[test] fn pressing_twice_is_a_no_op() { for b in [Bevel::Raised, Bevel::Inset] { assert_eq!(b.pressed().pressed(), b); } } #[test] fn a_raised_region_is_never_filled_with_a_recessed_surface() { // The bug this vocabulary exists to make unrepresentable. assert_eq!(Depth::Raised.fill(), Some(Fill::Raised)); assert_eq!(Depth::Raised.bevel(), Some(Bevel::Raised)); assert_eq!(Depth::Well.bevel(), Some(Bevel::Inset)); assert_ne!(Depth::Well.fill(), Depth::Raised.fill()); } #[test] fn state_is_orthogonal_to_depth() { // The reason State is its own axis and not a Depth member: a disabled // button and a disabled field are both disabled and are not the same // shape, which one shared variant could not have said. assert_eq!(Depth::Raised.fill(), Some(Fill::Raised)); assert_eq!(Depth::Well.fill(), Some(Fill::Well)); assert!(State::Disabled.suppresses_interaction()); } #[test] fn only_disabled_stops_answering() { // Focus is a thing you can still click. Getting this backwards is how // a focus ring ends up on something inert. assert!(!State::Focus.suppresses_interaction()); assert!(State::Disabled.suppresses_interaction()); } #[test] fn both_states_resolve_against_intents_makeover_already_derives() { // Neither needs a new token, so this costs no `makeover` release. assert_eq!(State::Focus.token(), "focus-ring"); assert_eq!(State::Disabled.token(), "content-muted"); } #[test] fn flat_has_neither_edge_nor_fill() { assert_eq!(Depth::Flat.bevel(), None); assert_eq!(Depth::Flat.fill(), None); } #[test] fn sunken_is_recessed_by_colour_with_no_edge() { // The one member carrying a fill without a bevel. A renderer that // assumes the two arrive together drops the fill silently, which is // exactly what makeover-webview did before 0.3.0. assert_eq!(Depth::Sunken.fill(), Some(Fill::Sunken)); assert_eq!(Depth::Sunken.bevel(), None); } #[test] fn sunken_and_flat_are_different_claims() { // Both edgeless, and only one of them needs a colour. Collapsing them // is what left an unchosen tab unsayable. assert_eq!(Depth::Flat.bevel(), Depth::Sunken.bevel()); assert_ne!(Depth::Flat.fill(), Depth::Sunken.fill()); } #[test] fn a_sunken_surface_is_not_a_well() { // Authored in opposite directions: makeover derives surface-well by // inverting against the theme's content colour, while surface-sunken is // authored and may sit darker than raised. assert_ne!(Fill::Sunken, Fill::Well); assert_eq!(Fill::Sunken.token(), "surface-sunken"); assert_eq!(Fill::Well.token(), "surface-well"); } #[test] fn every_selector_describes_both_of_its_states() { // The gap 0.3.0 closed. Before it, only `chosen` existed and the // unchosen option fell through to Flat at every renderer. for s in [Selector::Tabs, Selector::Segmented, Selector::Toggle] { assert_ne!( s.chosen(), s.unchosen(), "{s:?} cannot tell picked from unpicked" ); } } #[test] fn only_a_tab_inverts_the_other_way() { // Tabs recede so the chosen one comes forward; a segment and a toggle // stand up so the chosen one is held in. That inversion is the whole // content of "picked" once colour is deferred, and it is why the three // are not one member with a flag. assert_eq!(Selector::Tabs.unchosen(), Depth::Sunken); assert_eq!(Selector::Tabs.chosen(), Depth::Raised); for s in [Selector::Segmented, Selector::Toggle] { assert_eq!(s.unchosen(), Depth::Raised); assert_eq!(s.chosen(), Depth::Well); // Held in is what pressing produces: one appearance, two reasons. assert_eq!(s.unchosen().pressed(), s.chosen()); } } #[test] fn pressing_a_card_makes_a_well() { assert_eq!(Depth::Raised.pressed(), Depth::Well); assert_eq!( Depth::Raised.pressed().bevel(), Depth::Raised.bevel().map(Bevel::pressed) ); // Only raised regions respond to being pressed. assert_eq!(Depth::Flat.pressed(), Depth::Flat); assert_eq!(Depth::Well.pressed(), Depth::Well); } #[test] fn intents_name_makeover_tokens_and_nothing_else() { assert_eq!(Edge::Light.token(), "bevel-light"); assert_eq!(Edge::Dark.token(), "bevel-dark"); assert_eq!(Fill::Raised.token(), "surface-raised"); assert_eq!(Fill::Well.token(), "surface-well"); // No value ever leaves this crate. for t in [ Edge::Light.token(), Edge::Dark.token(), Tone::Danger.token(), Tone::Neutral.token(), State::Focus.token(), State::Disabled.token(), ] { assert!(!t.starts_with('#'), "{t} looks like a value"); assert!( !t.chars().next().unwrap().is_ascii_digit(), "{t} is a value" ); } } #[test] fn a_badge_cannot_be_pressed_and_a_chip_latches() { // The one line that runs through all three apps' taxonomies. assert!(!Token::Badge.interactive()); assert!(Token::Chip { removable: false }.interactive()); assert!(Token::Chip { removable: true }.interactive()); // A badge is a label, so giving it an edge would lie about it. assert_eq!(Token::Badge.depth(false), Depth::Flat); assert_eq!(Token::Badge.depth(true), Depth::Flat); // A latched chip wears the same shape a pressed one does. let chip = Token::Chip { removable: false }; assert_eq!(chip.depth(false), Depth::Raised); assert_eq!(chip.depth(true), Depth::Raised.pressed()); } #[test] fn a_toast_and_a_banner_differ_in_more_than_placement() { assert!(Notice::Toast.transient()); assert!(!Notice::Banner.transient()); // A toast floats above the page; a banner rests in the flow. assert_eq!(Notice::Toast.fill(), Fill::Overlay); assert_eq!(Notice::Banner.fill(), Fill::Raised); } #[test] fn only_the_actions_part_hides_until_hovered() { for p in [RowPart::Primary, RowPart::Secondary, RowPart::Meta] { assert!(!p.revealed_on_hover(), "{p:?} should always be visible"); } assert!(RowPart::Actions.revealed_on_hover()); // Emphasis falls off down the row, and never rises again. assert_eq!(RowPart::Primary.intent(), "content"); assert_eq!(RowPart::Secondary.intent(), "content-secondary"); assert_eq!(RowPart::Meta.intent(), "content-muted"); } #[test] fn a_separator_is_what_tells_a_section_from_a_subsection() { assert!(Heading::Section.separated()); assert!(!Heading::Subsection.separated()); assert!(!Heading::Page.separated()); } #[test] fn a_chosen_segment_is_held_in_and_a_chosen_tab_comes_forward() { assert_eq!(Selector::Segmented.chosen(), Depth::Well); assert_eq!(Selector::Toggle.chosen(), Depth::Well); // The exception, and the whole folder semantic: the open tab joins its // pane rather than sinking away from it. assert_eq!(Selector::Tabs.chosen(), Depth::Raised); // A held-in segment is indistinguishable from a pressed raised one, // which is the economy the light model buys over a colour swap. assert_eq!(Selector::Segmented.chosen(), Depth::Raised.pressed()); // A toggle stands alone; the other two are built out of parts that // touch. assert!(Selector::Segmented.abutting()); assert!(Selector::Tabs.abutting()); assert!(!Selector::Toggle.abutting()); } #[test] fn a_pane_is_looked_into_and_a_band_is_not() { assert_eq!(Region::Pane.depth(), Depth::Well); assert_eq!(Region::Modal.depth(), Depth::Raised); for r in [ Region::Band, Region::Sidebar, Region::Split, Region::TabGroup, ] { assert_eq!(r.depth(), Depth::Flat, "{r:?} should carry no edge"); } } #[test] fn exactly_one_region_is_opaque() { // The escape hatch is one member and stays one member. If a second // undescribed region ever appears, the description has started // conceding rather than deferring. for r in [ Region::Band, Region::Sidebar, Region::Pane, Region::Split, Region::TabGroup, Region::Modal, ] { assert!(r.described(), "{r:?} should be describable"); } assert!(!Region::Bespoke { name: "day-plan" }.described()); } #[test] fn a_bespoke_region_inherits_its_depth_rather_than_choosing_one() { // The app owns the contents, not the placement. An app that wants its // timeline in a well frames it in a Pane. assert_eq!(Region::Bespoke { name: "day-plan" }.depth(), Depth::Flat); assert_eq!(Region::Bespoke { name: "kanban" }.depth(), Depth::Flat); } #[test] fn a_screen_with_a_bespoke_region_is_still_a_whole_screen() { // The argument the member exists for: goingson's day-plan has to be // routable, or the description covers only the boring screens and the // interesting four need a second path beside the router. let day_plan = [ Region::Band, Region::Bespoke { name: "day-plan" }, Region::Sidebar, ]; assert_eq!(day_plan.iter().filter(|r| r.described()).count(), 2); assert_eq!(day_plan.iter().filter(|r| !r.described()).count(), 1); } #[test] fn a_secret_field_is_marked_as_one_and_a_hidden_field_is_not_drawn() { let secret = Field::new(FieldKind::Secret, "password", "Password"); assert!(secret.kind.confidential()); assert!(secret.kind.visible()); assert!(!FieldKind::Hidden.visible()); // Nothing else is confidential, or the marker means nothing. for k in [ FieldKind::Text, FieldKind::Number, FieldKind::Textarea, FieldKind::Select, FieldKind::Checkbox, FieldKind::Hidden, ] { assert!(!k.confidential(), "{k:?} should not be confidential"); } // Only a checkbox carries its own label. assert!(FieldKind::Checkbox.labels_itself()); assert!(!FieldKind::Text.labels_itself()); } #[test] fn a_plain_field_offers_nothing_and_a_select_offers_its_options() { let text = Field::new(FieldKind::Text, "title", "Title"); assert!(text.options.is_empty()); assert_eq!(text.placeholder, None); let sizes = [Choice::plain("small"), Choice::plain("large")]; let select = Field::select("size", "Size", &sizes); assert_eq!(select.kind, FieldKind::Select); assert_eq!(select.options.len(), 2); } #[test] fn a_choice_says_what_submits_and_what_is_read_apart() { // The whole reason it is two strings. `plain` is the case where they // coincide, and it is a shorthand rather than the general shape. let plain = Choice::plain("7"); assert_eq!((plain.value, plain.label), ("7", "7")); let spelled = Choice { value: "7", label: "One week", }; assert_ne!(spelled.value, spelled.label); } #[test] fn a_select_with_no_options_is_sayable() { // An app whose option list has not loaded has exactly this. Making it // unrepresentable would push the state somewhere less visible, and a // renderer drawing an empty select reports it on screen. let loading = Field::select("project", "Project", &[]); assert!(loading.options.is_empty()); } #[test] fn the_description_carries_the_question_and_never_the_answer() { // The line 0.8.0 drew. Placeholder and options are properties of what // is being asked; the current value is what came back, and no field // here holds one. let f = Field { placeholder: Some("yyyy-mm-dd"), ..Field::new(FieldKind::Text, "due", "Due") }; assert_eq!(f.placeholder, Some("yyyy-mm-dd")); // A placeholder is not a label, and having one does not excuse the // field from carrying the other. assert_eq!(f.label, "Due"); } #[test] fn a_field_reports_its_own_error_state() { let mut f = Field::new(FieldKind::Text, "title", "Title"); assert!(!f.invalid()); f.error = Some("Required"); assert!(f.invalid()); } #[test] fn columns_drop_by_priority_and_never_by_position() { let cols = [ Column { name: "Title", width: Width::Fill, priority: Priority::Essential, }, Column { name: "Due", width: Width::Fixed, priority: Priority::Secondary, }, Column { name: "Estimate", width: Width::Fixed, priority: Priority::Optional, }, ]; // Widest: everything survives. assert_eq!( cols.iter() .filter(|c| c.kept_at(Priority::Optional)) .count(), 3 ); // Narrower: the optional column goes first. let kept: Vec<_> = cols .iter() .filter(|c| c.kept_at(Priority::Secondary)) .map(|c| c.name) .collect(); assert_eq!(kept, ["Title", "Due"]); // Narrowest: only what identifies the row. let kept: Vec<_> = cols .iter() .filter(|c| c.kept_at(Priority::Essential)) .map(|c| c.name) .collect(); assert_eq!(kept, ["Title"]); } #[test] fn inserting_a_column_does_not_move_what_gets_dropped() { // The bug the ordinal form has and this form cannot: goingson hides // `nth-child(n+5)` against a seven-column table, so a column inserted // anywhere to the left silently hides a different one. let before = [ Column::new("Title"), Column { name: "Estimate", width: Width::Fixed, priority: Priority::Optional, }, ]; let after = [ Column::new("Title"), Column::new("Project"), // inserted Column { name: "Estimate", width: Width::Fixed, priority: Priority::Optional, }, ]; fn dropped<'a>(cols: &[Column<'a>]) -> Vec<&'a str> { cols.iter() .filter(|c| !c.kept_at(Priority::Secondary)) .map(|c| c.name) .collect() } assert_eq!(dropped(&before), ["Estimate"]); assert_eq!(dropped(&after), ["Estimate"]); } #[test] fn an_arrangement_carries_the_tab_group_as_a_modifier() { // goingson uses the tab group inside the content region rather than // instead of one, so it is not a third arrangement. let go = Arrangement::ListDetail { tabbed: true }; let plain = Arrangement::ListDetail { tabbed: false }; assert_ne!(go, plain); assert_ne!(go, Arrangement::SidebarContent); } #[test] fn readiness_names_the_state_and_not_the_shimmer() { // Two members and no third. If a skeleton ever appears in this enum, // the deferral rule has been broken. assert_ne!(Readiness::Ready, Readiness::Pending); } }