Skip to main content

makeover_layout/
lib.rs

1//! The renderer-agnostic half of the make-family design system.
2//!
3//! <!-- wiki: makeover-layout -->
4//!
5//! `makeover` answers *what colour*, and varies by theme. `makeover-geometry`
6//! answers *how much space*, and varies by density and surface. This crate
7//! answers *what the thing is*, and varies by nothing.
8//!
9//! # The deferral rule
10//!
11//! A description names intents and relationships, never values. Say
12//! [`Fill::Raised`], never `#D9DDF4`. Say `Gap::Peer`, never `6px`. What is
13//! left once colour and spacing are deferred is **composition**: which edges
14//! are lit, what inverts on press, what nests in what.
15//!
16//! The constraint that shapes all of it: a renderer that can only paint
17//! rectangles has to be able to express the result. egui has no
18//! `box-shadow: inset` and one stroke per widget with no per-side control; a
19//! terminal has box-drawing characters and one cell of resolution, and cannot
20//! draw a two-tone lit edge at all. A description that assumes per-side edges
21//! is a CSS description wearing a neutral name. So this crate names the
22//! *intent* — this region is a well — and each renderer chooses an expression
23//! it can actually produce, including dropping half of one.
24//!
25//! # Scope of this first cut
26//!
27//! Depth only: the bevel and the surfaces it shapes. That much is settled,
28//! and settled the hard way — the vocabulary here was read off audiofiles'
29//! `ui::theme` and `ui::widgets`, which are the only implementation written
30//! by a consumer with no CSS, then checked against both webview apps. All
31//! three agreed once Balanced Breakfast's fills were corrected.
32//!
33//! Deliberately absent, because each is a naming decision rather than a
34//! transcription: badge versus chip, toast versus banner, the list row's
35//! parts, heading levels, segmented controls, and whether a description names
36//! loading state at all. Those are tracked as subtasks of the extraction task
37//! and land as they are settled. Guessing at them now is how a description
38//! becomes a framework.
39
40#![forbid(unsafe_code)]
41
42/// A colour intent this crate refers to but never resolves.
43///
44/// The string is the token name `makeover` publishes, so a renderer can look
45/// it up without this crate knowing what colour came back.
46pub trait Intent {
47    /// The `makeover` intent token this resolves against.
48    fn token(self) -> &'static str;
49}
50
51/// Which way the light falls across a two-tone edge.
52///
53/// The whole content of a bevel, once colour and thickness are deferred. The
54/// light is always assumed to come from the top left: every consumer measured
55/// agreed on that and none of them ever varied it, so it is an invariant here
56/// rather than a parameter.
57#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
58pub enum Bevel {
59    /// Lit from the top left: light on top and left, dark on bottom and right.
60    Raised,
61    /// The same edge inverted, which is also the pressed state of anything
62    /// that draws itself [`Bevel::Raised`].
63    Inset,
64}
65
66impl Bevel {
67    /// The edge intents, as `(top_left, bottom_right)`.
68    ///
69    /// Split out from any painting because the inversion *is* the idea, and
70    /// it is the one part every renderer implements identically.
71    #[must_use]
72    pub const fn edges(self) -> (Edge, Edge) {
73        match self {
74            Self::Raised => (Edge::Light, Edge::Dark),
75            Self::Inset => (Edge::Dark, Edge::Light),
76        }
77    }
78
79    /// Pressing inverts. A raised control reads as inset while held.
80    ///
81    /// Stated here rather than left to each consumer because a cascade can
82    /// carry a pressed state and an immediate-mode renderer cannot: audiofiles
83    /// resolves this per call site, eighteen times.
84    #[must_use]
85    pub const fn pressed(self) -> Self {
86        match self {
87            Self::Raised => Self::Inset,
88            Self::Inset => Self::Raised,
89        }
90    }
91}
92
93/// One side of a bevel, named by the intent it takes.
94#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
95pub enum Edge {
96    /// The lit side.
97    Light,
98    /// The shadowed side.
99    Dark,
100}
101
102impl Intent for Edge {
103    fn token(self) -> &'static str {
104        match self {
105            Self::Light => "bevel-light",
106            Self::Dark => "bevel-dark",
107        }
108    }
109}
110
111/// A surface intent a region is filled with.
112#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
113pub enum Fill {
114    /// The page behind everything.
115    Page,
116    /// A surface lifted off the page: cards, controls, menus, toasts.
117    Raised,
118    /// A surface floating above the page rather than resting on it.
119    Overlay,
120    /// The inside of a well.
121    Well,
122}
123
124// No `fallback` here, deliberately. An earlier cut had `Fill::Well` fall back
125// to `Fill::Page` so a consumer on makeover 2.2.0, which has no `surface-well`,
126// had something to paint. makeover-tui found that wrong within a day: page is
127// the surface a well is usually cut into, so on a terminal that substitution
128// produces exactly the invisibility it was meant to prevent, and the right
129// answer there is a drawn edge rather than a different colour.
130//
131// Substituting one intent for another is renderer policy. The description says
132// what the region is and stops.
133
134impl Intent for Fill {
135    fn token(self) -> &'static str {
136        match self {
137            Self::Page => "surface-page",
138            Self::Raised => "surface-raised",
139            Self::Overlay => "surface-overlay",
140            Self::Well => "surface-well",
141        }
142    }
143}
144
145/// How a region sits relative to the surface behind it.
146///
147/// Fill and bevel are named together because naming them apart is what let
148/// them disagree. Every consumer measured had at least one region carrying a
149/// raised bevel over a recessed fill: audiofiles fixed it in `raised_frame`
150/// and recorded the bug in its doc comment, and Balanced Breakfast still had
151/// twelve of them a year later. A single name for the pair makes that
152/// unrepresentable.
153#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
154pub enum Depth {
155    /// Level with its surroundings. No edge.
156    Flat,
157    /// A card laid on the panel it sits in.
158    Raised,
159    /// A hole in the panel, with content down inside it. For anything the
160    /// user looks *into*: a table body, a tag tree, a text field.
161    Well,
162}
163
164impl Depth {
165    /// The edge this depth is drawn with, if it has one.
166    #[must_use]
167    pub const fn bevel(self) -> Option<Bevel> {
168        match self {
169            Self::Flat => None,
170            Self::Raised => Some(Bevel::Raised),
171            Self::Well => Some(Bevel::Inset),
172        }
173    }
174
175    /// The surface this depth is filled with.
176    ///
177    /// [`Depth::Flat`] has no fill of its own: it inherits whatever it sits on,
178    /// which is the difference between level-with and painted-the-same-colour.
179    #[must_use]
180    pub const fn fill(self) -> Option<Fill> {
181        match self {
182            Self::Flat => None,
183            Self::Raised => Some(Fill::Raised),
184            Self::Well => Some(Fill::Well),
185        }
186    }
187
188    /// Pressing a raised region reads as a well, and nothing else moves.
189    #[must_use]
190    pub const fn pressed(self) -> Self {
191        match self {
192            Self::Raised => Self::Well,
193            other => other,
194        }
195    }
196}
197
198#[cfg(test)]
199mod tests {
200    use super::*;
201
202    #[test]
203    fn inset_is_raised_with_the_light_moved() {
204        let (rl, rd) = Bevel::Raised.edges();
205        let (il, id) = Bevel::Inset.edges();
206        assert_eq!((rl, rd), (Edge::Light, Edge::Dark));
207        assert_eq!((il, id), (rd, rl));
208    }
209
210    #[test]
211    fn pressing_twice_is_a_no_op() {
212        for b in [Bevel::Raised, Bevel::Inset] {
213            assert_eq!(b.pressed().pressed(), b);
214        }
215    }
216
217    #[test]
218    fn a_raised_region_is_never_filled_with_a_recessed_surface() {
219        // The bug this vocabulary exists to make unrepresentable.
220        assert_eq!(Depth::Raised.fill(), Some(Fill::Raised));
221        assert_eq!(Depth::Raised.bevel(), Some(Bevel::Raised));
222        assert_eq!(Depth::Well.bevel(), Some(Bevel::Inset));
223        assert_ne!(Depth::Well.fill(), Depth::Raised.fill());
224    }
225
226    #[test]
227    fn flat_has_neither_edge_nor_fill() {
228        assert_eq!(Depth::Flat.bevel(), None);
229        assert_eq!(Depth::Flat.fill(), None);
230    }
231
232    #[test]
233    fn pressing_a_card_makes_a_well() {
234        assert_eq!(Depth::Raised.pressed(), Depth::Well);
235        assert_eq!(
236            Depth::Raised.pressed().bevel(),
237            Depth::Raised.bevel().map(Bevel::pressed)
238        );
239        // Only raised regions respond to being pressed.
240        assert_eq!(Depth::Flat.pressed(), Depth::Flat);
241        assert_eq!(Depth::Well.pressed(), Depth::Well);
242    }
243
244    #[test]
245    fn intents_name_makeover_tokens_and_nothing_else() {
246        assert_eq!(Edge::Light.token(), "bevel-light");
247        assert_eq!(Edge::Dark.token(), "bevel-dark");
248        assert_eq!(Fill::Raised.token(), "surface-raised");
249        assert_eq!(Fill::Well.token(), "surface-well");
250        // No value ever leaves this crate.
251        for t in [Edge::Light.token(), Edge::Dark.token()] {
252            assert!(!t.starts_with('#'), "{t} looks like a value");
253        }
254    }
255}