Skip to main content

max / makeover-layout

9.0 KB · 256 lines History Blame Raw
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.
46 pub 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)]
58 pub 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
66 impl 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)]
95 pub enum Edge {
96 /// The lit side.
97 Light,
98 /// The shadowed side.
99 Dark,
100 }
101
102 impl 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)]
113 pub 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
134 impl 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)]
154 pub 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
164 impl 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)]
199 mod 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 }
256