//! 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 of this first cut //! //! Depth only: the bevel and the surfaces it shapes. That much is settled, //! and 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. //! //! Deliberately absent, because each is a naming decision rather than a //! transcription: badge versus chip, toast versus banner, the list row's //! parts, heading levels, segmented controls, and whether a description names //! loading state at all. Those are tracked as subtasks of the extraction task //! and land as they are settled. Guessing at them now is how a description //! becomes a framework. #![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. #[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. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] 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, } // 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", } } } /// 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. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] 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, } impl Depth { /// The edge this depth is drawn with, if it has one. #[must_use] pub const fn bevel(self) -> Option { match self { Self::Flat => 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), } } /// 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, } } } #[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 flat_has_neither_edge_nor_fill() { assert_eq!(Depth::Flat.bevel(), None); assert_eq!(Depth::Flat.fill(), None); } #[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()] { assert!(!t.starts_with('#'), "{t} looks like a value"); } } }