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