|
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 |
+ |
impl Intent for Fill {
|
|
125 |
+ |
fn token(self) -> &'static str {
|
|
126 |
+ |
match self {
|
|
127 |
+ |
Self::Page => "surface-page",
|
|
128 |
+ |
Self::Raised => "surface-raised",
|
|
129 |
+ |
Self::Overlay => "surface-overlay",
|
|
130 |
+ |
Self::Well => "surface-well",
|
|
131 |
+ |
}
|
|
132 |
+ |
}
|
|
133 |
+ |
}
|
|
134 |
+ |
|
|
135 |
+ |
impl Fill {
|
|
136 |
+ |
/// What to use where [`Fill::token`] is not available yet.
|
|
137 |
+ |
///
|
|
138 |
+ |
/// `surface-well` is derived by makeover 2.3.0, which is bumped in-tree
|
|
139 |
+ |
/// and not published, so consumers on 2.2.0 have no such token. A well
|
|
140 |
+ |
/// filled `surface-page` is what audiofiles ships today and is close
|
|
141 |
+ |
/// enough to read as recessed; it is wrong only in that it does not track
|
|
142 |
+ |
/// the raised surface it was cut from.
|
|
143 |
+ |
///
|
|
144 |
+ |
/// Delete this once 2.3.0 is published and the three consumers adopt it.
|
|
145 |
+ |
#[must_use]
|
|
146 |
+ |
pub const fn fallback(self) -> Option<Self> {
|
|
147 |
+ |
match self {
|
|
148 |
+ |
Self::Well => Some(Self::Page),
|
|
149 |
+ |
_ => None,
|
|
150 |
+ |
}
|
|
151 |
+ |
}
|
|
152 |
+ |
}
|
|
153 |
+ |
|
|
154 |
+ |
/// How a region sits relative to the surface behind it.
|
|
155 |
+ |
///
|
|
156 |
+ |
/// Fill and bevel are named together because naming them apart is what let
|
|
157 |
+ |
/// them disagree. Every consumer measured had at least one region carrying a
|
|
158 |
+ |
/// raised bevel over a recessed fill: audiofiles fixed it in `raised_frame`
|
|
159 |
+ |
/// and recorded the bug in its doc comment, and Balanced Breakfast still had
|
|
160 |
+ |
/// twelve of them a year later. A single name for the pair makes that
|
|
161 |
+ |
/// unrepresentable.
|
|
162 |
+ |
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
|
163 |
+ |
pub enum Depth {
|
|
164 |
+ |
/// Level with its surroundings. No edge.
|
|
165 |
+ |
Flat,
|
|
166 |
+ |
/// A card laid on the panel it sits in.
|
|
167 |
+ |
Raised,
|
|
168 |
+ |
/// A hole in the panel, with content down inside it. For anything the
|
|
169 |
+ |
/// user looks *into*: a table body, a tag tree, a text field.
|
|
170 |
+ |
Well,
|
|
171 |
+ |
}
|
|
172 |
+ |
|
|
173 |
+ |
impl Depth {
|
|
174 |
+ |
/// The edge this depth is drawn with, if it has one.
|
|
175 |
+ |
#[must_use]
|
|
176 |
+ |
pub const fn bevel(self) -> Option<Bevel> {
|
|
177 |
+ |
match self {
|
|
178 |
+ |
Self::Flat => None,
|
|
179 |
+ |
Self::Raised => Some(Bevel::Raised),
|
|
180 |
+ |
Self::Well => Some(Bevel::Inset),
|
|
181 |
+ |
}
|
|
182 |
+ |
}
|
|
183 |
+ |
|
|
184 |
+ |
/// The surface this depth is filled with.
|
|
185 |
+ |
///
|
|
186 |
+ |
/// [`Depth::Flat`] has no fill of its own: it inherits whatever it sits on,
|
|
187 |
+ |
/// which is the difference between level-with and painted-the-same-colour.
|
|
188 |
+ |
#[must_use]
|
|
189 |
+ |
pub const fn fill(self) -> Option<Fill> {
|
|
190 |
+ |
match self {
|
|
191 |
+ |
Self::Flat => None,
|
|
192 |
+ |
Self::Raised => Some(Fill::Raised),
|
|
193 |
+ |
Self::Well => Some(Fill::Well),
|
|
194 |
+ |
}
|
|
195 |
+ |
}
|
|
196 |
+ |
|
|
197 |
+ |
/// Pressing a raised region reads as a well, and nothing else moves.
|
|
198 |
+ |
#[must_use]
|
|
199 |
+ |
pub const fn pressed(self) -> Self {
|
|
200 |
+ |
match self {
|
|
201 |
+ |
Self::Raised => Self::Well,
|
|
202 |
+ |
other => other,
|
|
203 |
+ |
}
|
|
204 |
+ |
}
|
|
205 |
+ |
}
|
|
206 |
+ |
|
|
207 |
+ |
#[cfg(test)]
|
|
208 |
+ |
mod tests {
|
|
209 |
+ |
use super::*;
|
|
210 |
+ |
|
|
211 |
+ |
#[test]
|
|
212 |
+ |
fn inset_is_raised_with_the_light_moved() {
|
|
213 |
+ |
let (rl, rd) = Bevel::Raised.edges();
|
|
214 |
+ |
let (il, id) = Bevel::Inset.edges();
|
|
215 |
+ |
assert_eq!((rl, rd), (Edge::Light, Edge::Dark));
|
|
216 |
+ |
assert_eq!((il, id), (rd, rl));
|
|
217 |
+ |
}
|
|
218 |
+ |
|
|
219 |
+ |
#[test]
|
|
220 |
+ |
fn pressing_twice_is_a_no_op() {
|
|
221 |
+ |
for b in [Bevel::Raised, Bevel::Inset] {
|
|
222 |
+ |
assert_eq!(b.pressed().pressed(), b);
|
|
223 |
+ |
}
|
|
224 |
+ |
}
|
|
225 |
+ |
|
|
226 |
+ |
#[test]
|
|
227 |
+ |
fn a_raised_region_is_never_filled_with_a_recessed_surface() {
|
|
228 |
+ |
// The bug this vocabulary exists to make unrepresentable.
|
|
229 |
+ |
assert_eq!(Depth::Raised.fill(), Some(Fill::Raised));
|
|
230 |
+ |
assert_eq!(Depth::Raised.bevel(), Some(Bevel::Raised));
|
|
231 |
+ |
assert_eq!(Depth::Well.bevel(), Some(Bevel::Inset));
|
|
232 |
+ |
assert_ne!(Depth::Well.fill(), Depth::Raised.fill());
|
|
233 |
+ |
}
|
|
234 |
+ |
|
|
235 |
+ |
#[test]
|
|
236 |
+ |
fn flat_has_neither_edge_nor_fill() {
|
|
237 |
+ |
assert_eq!(Depth::Flat.bevel(), None);
|
|
238 |
+ |
assert_eq!(Depth::Flat.fill(), None);
|
|
239 |
+ |
}
|
|
240 |
+ |
|
|
241 |
+ |
#[test]
|
|
242 |
+ |
fn pressing_a_card_makes_a_well() {
|
|
243 |
+ |
assert_eq!(Depth::Raised.pressed(), Depth::Well);
|
|
244 |
+ |
assert_eq!(
|
|
245 |
+ |
Depth::Raised.pressed().bevel(),
|
|
246 |
+ |
Depth::Raised.bevel().map(Bevel::pressed)
|
|
247 |
+ |
);
|
|
248 |
+ |
// Only raised regions respond to being pressed.
|
|
249 |
+ |
assert_eq!(Depth::Flat.pressed(), Depth::Flat);
|
|
250 |
+ |
assert_eq!(Depth::Well.pressed(), Depth::Well);
|
|
251 |
+ |
}
|
|
252 |
+ |
|
|
253 |
+ |
#[test]
|
|
254 |
+ |
fn intents_name_makeover_tokens_and_nothing_else() {
|
|
255 |
+ |
assert_eq!(Edge::Light.token(), "bevel-light");
|
|
256 |
+ |
assert_eq!(Edge::Dark.token(), "bevel-dark");
|
|
257 |
+ |
assert_eq!(Fill::Raised.token(), "surface-raised");
|
|
258 |
+ |
assert_eq!(Fill::Well.token(), "surface-well");
|
|
259 |
+ |
// No value ever leaves this crate.
|
|
260 |
+ |
for t in [Edge::Light.token(), Edge::Dark.token()] {
|
|
261 |
+ |
assert!(!t.starts_with('#'), "{t} looks like a value");
|
|
262 |
+ |
}
|
|
263 |
+ |
}
|
|
264 |
+ |
|
|
265 |
+ |
#[test]
|
|
266 |
+ |
fn only_the_well_needs_a_fallback() {
|
|
267 |
+ |
assert_eq!(Fill::Well.fallback(), Some(Fill::Page));
|
|
268 |
+ |
for f in [Fill::Page, Fill::Raised, Fill::Overlay] {
|
|
269 |
+ |
assert_eq!(f.fallback(), None);
|
|
270 |
+ |
}
|
|
271 |
+ |
}
|
|
272 |
+ |
}
|