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}