Skip to main content

max / makeover-layout

13.1 KB · 313 lines History Blame Raw
1 use crate::Intent;
2
3 /// Which way the light falls across a two-tone edge.
4 ///
5 /// The whole content of a bevel, once colour and thickness are deferred. The
6 /// light is always assumed to come from the top left: every consumer measured
7 /// agreed on that and none of them ever varied it, so it is an invariant here
8 /// rather than a parameter.
9 ///
10 /// # The two corners that belong to both edges
11 ///
12 /// Top-right and bottom-left are where the lit run meets the shaded one, and
13 /// the description's claim is that they belong to *both*. How a renderer says
14 /// that is its own business, because the answer is bounded by resolution and
15 /// not by taste:
16 ///
17 /// - A terminal cell is roughly 8x17 device pixels, so giving the whole corner
18 /// to one tone thickens that edge by a cell and reads as one run overrunning
19 /// the other. A half-cell glyph divides the cell already, so `makeover-tui`
20 /// splits it and recovers real information. Its box-drawing fallback cannot:
21 /// a single stroke has no half to give, so there both corners go to dark.
22 /// - A pixel bevel is a one-point stroke by default, which makes the corner a
23 /// one-point square. There is nothing to divide — a diagonal seam across one
24 /// point is sub-pixel, and antialiasing renders it as the blend a mitred join
25 /// already produces. So `makeover-immediate` mitres and is *not* diverging;
26 /// it is the same rule at a resolution where the split degenerates.
27 ///
28 /// Stated here so the difference reads as a decision rather than as drift. A
29 /// renderer with room to divide the corner should; one without should mitre or
30 /// pick the shaded tone, and neither is a bug.
31 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
32 pub enum Bevel {
33 /// Lit from the top left: light on top and left, dark on bottom and right.
34 Raised,
35 /// The same edge inverted, which is also the pressed state of anything
36 /// that draws itself [`Bevel::Raised`].
37 Inset,
38 }
39
40 impl Bevel {
41 /// The edge intents, as `(top_left, bottom_right)`.
42 ///
43 /// Split out from any painting because the inversion *is* the idea, and
44 /// it is the one part every renderer implements identically.
45 #[must_use]
46 pub const fn edges(self) -> (Edge, Edge) {
47 match self {
48 Self::Raised => (Edge::Light, Edge::Dark),
49 Self::Inset => (Edge::Dark, Edge::Light),
50 }
51 }
52
53 /// Pressing inverts. A raised control reads as inset while held.
54 ///
55 /// Stated here rather than left to each consumer because a cascade can
56 /// carry a pressed state and an immediate-mode renderer cannot: audiofiles
57 /// resolves this per call site, eighteen times.
58 #[must_use]
59 pub const fn pressed(self) -> Self {
60 match self {
61 Self::Raised => Self::Inset,
62 Self::Inset => Self::Raised,
63 }
64 }
65 }
66
67 /// One side of a bevel, named by the intent it takes.
68 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
69 pub enum Edge {
70 /// The lit side.
71 Light,
72 /// The shadowed side.
73 Dark,
74 }
75
76 impl Intent for Edge {
77 fn token(self) -> &'static str {
78 match self {
79 Self::Light => "bevel-light",
80 Self::Dark => "bevel-dark",
81 }
82 }
83 }
84
85 /// A surface intent a region is filled with.
86 ///
87 /// `#[non_exhaustive]`, so a renderer must carry a wildcard arm and a new
88 /// member is additive rather than breaking. The vocabulary exists to grow and
89 /// the renderers exist to disagree about how much of it they answer, so growth
90 /// must not be a lockstep event. The renderer's wildcard is not a hole:
91 /// [`Fill`] is resolved through a fallible lookup, and a missing intent is
92 /// answered with structure rather than with a substituted colour.
93 ///
94 /// [`Sunken`]: Fill::Sunken
95 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
96 #[non_exhaustive]
97 pub enum Fill {
98 /// The page behind everything.
99 Page,
100 /// A surface lifted off the page: cards, controls, menus, toasts.
101 Raised,
102 /// A surface floating above the page rather than resting on it.
103 Overlay,
104 /// The inside of a well.
105 Well,
106 /// A surface set back from the one it sits on, by colour and nothing else.
107 ///
108 /// Not a well. A well is a hole with an edge, and the two are authored in
109 /// opposite directions: `makeover` derives `surface-well` by inverting
110 /// against the theme's own content colour, while `surface-sunken` is
111 /// authored and free to sit darker than raised (goingson's does). Naming
112 /// only the well left the recessed-with-no-edge surface unsayable, which is
113 /// what an unchosen tab is: it recedes so the chosen one can come forward,
114 /// and it carries no bevel of its own.
115 Sunken,
116 }
117
118 // No `fallback` here, deliberately. An earlier cut had `Fill::Well` fall back
119 // to `Fill::Page` so a consumer on makeover 2.2.0, which has no `surface-well`,
120 // had something to paint. makeover-tui found that wrong within a day: page is
121 // the surface a well is usually cut into, so on a terminal that substitution
122 // produces exactly the invisibility it was meant to prevent, and the right
123 // answer there is a drawn edge rather than a different colour.
124 //
125 // Substituting one intent for another is renderer policy. The description says
126 // what the region is and stops.
127
128 impl Intent for Fill {
129 fn token(self) -> &'static str {
130 match self {
131 Self::Page => "surface-page",
132 Self::Raised => "surface-raised",
133 Self::Overlay => "surface-overlay",
134 Self::Well => "surface-well",
135 Self::Sunken => "surface-sunken",
136 }
137 }
138 }
139
140 /// How a region sits relative to the surface behind it.
141 ///
142 /// Fill and bevel are named together because naming them apart is what let
143 /// them disagree. Every consumer measured had at least one region carrying a
144 /// raised bevel over a recessed fill: audiofiles fixed it in `raised_frame`
145 /// and recorded the bug in its doc comment, and Balanced Breakfast still had
146 /// twelve of them a year later. A single name for the pair makes that
147 /// unrepresentable.
148 /// `#[non_exhaustive]` for the same reason as [`Fill`], and in the same
149 /// release: a depth this renderer has no drawing for should cost it a
150 /// wildcard arm, not a compile error and a wait on someone else's publish.
151 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
152 #[non_exhaustive]
153 pub enum Depth {
154 /// Level with its surroundings. No edge.
155 Flat,
156 /// A card laid on the panel it sits in.
157 Raised,
158 /// A hole in the panel, with content down inside it. For anything the
159 /// user looks *into*: a table body, a tag tree, a text field.
160 Well,
161 /// Set back from what it sits on, by colour alone. No edge.
162 ///
163 /// The one member carrying a fill without a bevel, so a renderer cannot
164 /// assume the two arrive together. That is deliberate and it is still the
165 /// pairing rule: both halves come off the same `Depth`, so they cannot
166 /// disagree, and here one half is legitimately absent.
167 ///
168 /// Distinct from [`Depth::Flat`], which has no fill either and inherits.
169 /// Recessed and level-with are different claims, and only one of them
170 /// needs a colour.
171 Sunken,
172 /// A surface sitting *over* the page rather than in it. A modal, a popover,
173 /// a menu.
174 ///
175 /// Takes elevation and no bevel: a surface overlaying the page is lifted
176 /// off it, and a surface in the page is cut into it. That is the same
177 /// pairing rule the rest of the enum holds, applied to the one case where
178 /// the separation is not an edge at all — the lift and the scrim behind it
179 /// are already saying where the surface is.
180 ///
181 /// Every renderer already has the surface: `makeover-tui` carries
182 /// `Palette::overlay`, `makeover-immediate` `Palette::elevation`, and
183 /// `makeover-webview` emits `--elevation-overlay`. This variant is the
184 /// route from a description to any of them, which is why it is one variant
185 /// rather than a feature.
186 Overlay,
187 }
188
189 impl Depth {
190 /// The edge this depth is drawn with, if it has one.
191 #[must_use]
192 pub const fn bevel(self) -> Option<Bevel> {
193 match self {
194 // Sunken joins Flat here, for the opposite reason: Flat has no edge
195 // because nothing separates it from its surroundings, and Sunken has
196 // none because its colour is already doing the separating.
197 Self::Flat | Self::Sunken => None,
198 // A third reason to have no edge, which is why it gets its own arm
199 // rather than joining the two above: an overlay is separated by the
200 // lift and by the scrim behind it, so an edge would be a second
201 // answer to a question already answered.
202 Self::Overlay => None,
203 Self::Raised => Some(Bevel::Raised),
204 Self::Well => Some(Bevel::Inset),
205 }
206 }
207
208 /// The surface this depth is filled with.
209 ///
210 /// [`Depth::Flat`] has no fill of its own: it inherits whatever it sits on,
211 /// which is the difference between level-with and painted-the-same-colour.
212 #[must_use]
213 pub const fn fill(self) -> Option<Fill> {
214 match self {
215 Self::Flat => None,
216 Self::Raised => Some(Fill::Raised),
217 Self::Well => Some(Fill::Well),
218 Self::Sunken => Some(Fill::Sunken),
219 Self::Overlay => Some(Fill::Overlay),
220 }
221 }
222
223 /// Pressing a raised region reads as a well, and nothing else moves.
224 ///
225 /// [`Depth::Overlay`] is untouched along with the rest: an overlay is a
226 /// surface, not a control, so there is nothing there to press.
227 #[must_use]
228 pub const fn pressed(self) -> Self {
229 match self {
230 Self::Raised => Self::Well,
231 other => other,
232 }
233 }
234 }
235
236 /// An interaction state a region can be in, beside whatever [`Depth`] it is.
237 ///
238 /// Orthogonal to depth on purpose. A disabled button is still [`Depth::Raised`]
239 /// and a disabled field is still a [`Depth::Well`], so folding either member
240 /// into `Depth` would make [`Depth::bevel`] and [`Depth::fill`] answer for
241 /// something that is not a depth, and would leave disabled-button and
242 /// disabled-field sharing one variant that cannot tell them apart.
243 ///
244 /// # Why hover and pressed are not members
245 ///
246 /// The line is whether every renderer has the state to express, not whether CSS
247 /// does. Hover is renderer policy and `makeover-webview` says so in its own
248 /// header: a terminal and an immediate-mode painter have no pointer hovering
249 /// over anything, and pressed already arrives through [`Bevel::pressed`] and
250 /// [`Depth::pressed`], where it belongs, because pressing is a depth inversion
251 /// rather than a separate condition.
252 ///
253 /// Focus and disabled are different in kind. A TUI has a focused widget and a
254 /// greyed-out one; so does egui. Both were unsayable here, so all three webview
255 /// consumers supplied them from outside the primitive by out-specifying rules
256 /// they did not own: goingson alone carries 19 of them, and the MNW server
257 /// another 21. That is the divergence this crate exists to end, arriving one
258 /// layer down.
259 ///
260 /// # The principle this encodes
261 ///
262 /// A primitive owns every state it implies. A renderer that emits a hover rule
263 /// for a thing owes disabled and the capability answer for that same thing,
264 /// because anything less exports the completion work to N consumers who will
265 /// each do it differently.
266 ///
267 /// Focus is not on that list and is not on this axis. It is the renderer's,
268 /// decided after the description; see the crate header, "Reach,
269 /// focus and the focus ring", for the three terms and who owns each.
270 ///
271 /// `#[non_exhaustive]` for the reason [`Fill`] and [`Depth`] carry it: growth
272 /// must not be a lockstep event across the three renderers.
273 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
274 #[non_exhaustive]
275 pub enum State {
276 /// Present, visible, and not answering.
277 ///
278 /// Not the same as absent, and deliberately not a [`Fill`]: a disabled
279 /// control keeps the surface it always had and stops responding, so what
280 /// changes is its content and its interactivity rather than what it is.
281 Disabled,
282 }
283
284 impl State {
285 /// Whether a region in this state stops answering the pointer.
286 ///
287 /// Stated in the description rather than left to each renderer, on the same
288 /// reasoning as [`Bevel::pressed`]: a cascade carries it for free and an
289 /// immediate-mode renderer resolves it per call site, so leaving it unsaid
290 /// means resolving it once per consumer and disagreeing.
291 #[must_use]
292 pub const fn suppresses_interaction(self) -> bool {
293 // A match rather than a bare `true`, so a member added to this
294 // `#[non_exhaustive]` axis has to answer the question rather than
295 // inheriting an answer.
296 match self {
297 Self::Disabled => true,
298 }
299 }
300 }
301
302 impl Intent for State {
303 fn token(self) -> &'static str {
304 match self {
305 // Reusing the muted content intent rather than minting a
306 // `disabled` colour. Disabled is a reduction and not a status, and
307 // `makeover-webview`'s progress rules already record the reading
308 // that `content-muted` is what disabled looks like.
309 Self::Disabled => "content-muted",
310 }
311 }
312 }
313