Skip to main content

max / makeover-webview

10.3 KB · 269 lines History Blame Raw
1 //! What an HTML element brings uninvited, and how a primitive gives it back.
2 //!
3 //! The renderer picks an element from the description (a link that writes is a
4 //! `<button>`, a described set of values is a `<ul>`) and the element arrives
5 //! carrying a user-agent look nobody asked for. Withdrawing that look is a
6 //! recurring ask rather than an edge case, and it was written by hand three
7 //! times before this module existed: twice byte-identically for a list, once
8 //! for a link, with no arm aware of the others.
9 //!
10 //! # Why this is a withdrawal and not a depth
11 //!
12 //! [`makeover_layout::Depth`] was the obvious home and is the wrong one. A
13 //! depth states what a region *is*, a fill and a bevel, and every variant
14 //! answers `None` for a stroke, so an added border axis would have covered one
15 //! of the seven properties in play and left `.link` untouched. What these arms
16 //! share is not a shape. It is the absence of one the browser supplied.
17 //!
18 //! # Renderer-local by construction
19 //!
20 //! A terminal has no element chrome to withdraw and an immediate-mode painter
21 //! draws from nothing, so this concept cannot rise into the description layer.
22 //! Nothing in `makeover-layout` knows the word, and there is no cascade.
23
24 use std::fmt::Write as _;
25
26 /// One thing an element brings that a description never asked for.
27 ///
28 /// Atoms rather than bundles, because the bundles disagree at the edges: a
29 /// link-as-button gives back its padding and its font so it can read as text,
30 /// and a facet button keeps both so it stays worth aiming at. The named sets
31 /// below are the bundles, spelled once each.
32 #[derive(Clone, Copy, PartialEq, Eq, Debug)]
33 #[non_exhaustive]
34 pub enum Chrome {
35 /// The bullet on a list item. `list-style: none`.
36 Bullet,
37 /// The gutter around a list, which existed to make room for the bullet.
38 /// `margin: 0`.
39 Gutter,
40 /// A control's surface. `background: none`.
41 Fill,
42 /// A control's stroke. `border: none`.
43 Edge,
44 /// A control's raised look, where an app's own `button` rule supplies one.
45 /// `box-shadow: none`.
46 Shadow,
47 /// The room a control keeps around its label. `padding: 0`.
48 Padding,
49 /// The face a control is set in, which is not the face around it.
50 /// `font: inherit`.
51 Type,
52 /// The one addition rather than a withdrawal: a `<button>` points with the
53 /// default arrow where an `<a>` points with a hand. `cursor: pointer`.
54 Pointing,
55 }
56
57 /// The order every reset emits in, outside the box and inward: how it sits in
58 /// flow, then its surface, then what it does with its contents. Fixed here so
59 /// that two primitives withdrawing the same pair can never spell it in two
60 /// orders and read as two rules.
61 const ORDER: [(Chrome, &str); 8] = [
62 (Chrome::Bullet, "list-style: none"),
63 (Chrome::Gutter, "margin: 0"),
64 (Chrome::Fill, "background: none"),
65 (Chrome::Edge, "border: none"),
66 (Chrome::Shadow, "box-shadow: none"),
67 (Chrome::Padding, "padding: 0"),
68 (Chrome::Type, "font: inherit"),
69 (Chrome::Pointing, "cursor: pointer"),
70 ];
71
72 const fn bit(chrome: Chrome) -> u8 {
73 match chrome {
74 Chrome::Bullet => 1 << 0,
75 Chrome::Gutter => 1 << 1,
76 Chrome::Fill => 1 << 2,
77 Chrome::Edge => 1 << 3,
78 Chrome::Shadow => 1 << 4,
79 Chrome::Padding => 1 << 5,
80 Chrome::Type => 1 << 6,
81 Chrome::Pointing => 1 << 7,
82 }
83 }
84
85 /// A set of [`Chrome`] a primitive opts into giving back.
86 #[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
87 pub struct Reset(u8);
88
89 impl Reset {
90 /// Withdraw nothing. The starting point for [`Reset::and`], and what a
91 /// primitive that is happy with its element gets by saying nothing.
92 pub const NOTHING: Self = Self(0);
93
94 /// The triple a `<ul>` or `<ol>` brings: the bullet, the gutter that made
95 /// room for it, and the indent. A described list of SSH keys is not a
96 /// bulleted list, and it rendered as one because nothing said otherwise.
97 pub const BULLETS: Self = Self::NOTHING
98 .and(Chrome::Bullet)
99 .and(Chrome::Gutter)
100 .and(Chrome::Padding);
101
102 /// A `<button>`'s raised look and nothing else: fill, stroke, shadow. What
103 /// stays is the hit area and the type, so the control is still worth
104 /// aiming at and still reads as a control.
105 ///
106 /// This is the set that matters where an app hands makeover the cascade
107 /// with `revert-layer`: with an empty layer the handoff rolls past
108 /// makeover to a bare `button` rule, which supplies all three, and a
109 /// described flat control renders raised.
110 pub const FLAT_BUTTON: Self = Self::NOTHING
111 .and(Chrome::Fill)
112 .and(Chrome::Edge)
113 .and(Chrome::Shadow);
114
115 /// A `<button>` that has to stop looking like one, because the description
116 /// said link and only the method said button. Everything a control brings,
117 /// plus the pointing hand a link has and a button does not.
118 ///
119 /// [`Reset::FLAT_BUTTON`] plus the type and metrics, which is the split
120 /// worth reading off the two: a facet keeps its hit area because it is
121 /// still a control, and a link gives it back because it is a word in a
122 /// sentence.
123 ///
124 /// The shadow was absent until 0.68.0, and the hole was invisible for the
125 /// worst reason: the MNW server's own handoff hands `box-shadow` back on
126 /// `button.link`, and with nothing in the layer to hand back
127 /// `revert-layer` rolled past to the UA default, which happens to be
128 /// `none`. So the app was compensating for a gap here and the right answer
129 /// arrived by luck. Measured across the three webview consumers before
130 /// closing it: the MNW server is the only one with a bare `button` rule
131 /// setting any chrome at all, and its computed value does not move.
132 pub const TEXT_BUTTON: Self = Self::NOTHING
133 .and(Chrome::Fill)
134 .and(Chrome::Edge)
135 .and(Chrome::Shadow)
136 .and(Chrome::Padding)
137 .and(Chrome::Type)
138 .and(Chrome::Pointing);
139
140 /// Add one thing to the set. Const, so a named set above is a constant and
141 /// not a function call at every emit.
142 #[must_use]
143 pub const fn and(self, chrome: Chrome) -> Self {
144 Self(self.0 | bit(chrome))
145 }
146
147 /// Whether the set carries this one.
148 #[must_use]
149 pub const fn carries(self, chrome: Chrome) -> bool {
150 self.0 & bit(chrome) != 0
151 }
152
153 /// Whether the set withdraws nothing, in which case a caller emits no rule
154 /// at all rather than an empty one. Same contract as
155 /// [`depth_declarations`](crate::depth_declarations) and
156 /// [`depth_rule`](crate::depth_rule).
157 #[must_use]
158 pub const fn is_empty(self) -> bool {
159 self.0 == 0
160 }
161
162 /// The declarations, indented and terminated, ready for a rule body.
163 #[must_use]
164 pub fn declarations(self) -> String {
165 let mut css = String::new();
166 for (chrome, declaration) in ORDER {
167 if self.carries(chrome) {
168 let _ = writeln!(css, " {declaration};");
169 }
170 }
171 css
172 }
173
174 /// One rule, or nothing when the set withdraws nothing.
175 ///
176 /// The selector is written in full and taken verbatim, which is where this
177 /// parts company with [`depth_rule`](crate::depth_rule): a reset exists
178 /// because of the element underneath, so its selector is routinely
179 /// element-qualified: `button.link` and not `.link`.
180 #[must_use]
181 pub fn rule(self, selector: &str) -> String {
182 if self.is_empty() {
183 return String::new();
184 }
185 format!("{selector} {{\n{}}}\n", self.declarations())
186 }
187 }
188
189 #[cfg(test)]
190 mod tests {
191 use super::*;
192
193 #[test]
194 fn nothing_emits_nothing() {
195 assert!(Reset::NOTHING.is_empty());
196 assert_eq!(Reset::NOTHING.rule(".x"), "");
197 }
198
199 #[test]
200 fn the_selector_is_verbatim() {
201 assert!(
202 Reset::TEXT_BUTTON
203 .rule("button.link")
204 .starts_with("button.link {")
205 );
206 }
207
208 /// The three sets, spelled out. Two of them are the bytes hand-written arms
209 /// emitted before the reset was named; the third, `TEXT_BUTTON`, is those
210 /// bytes plus the `box-shadow` 0.68.0 added, which is the one place the
211 /// reset deliberately says more than what it replaced.
212 #[test]
213 fn the_named_sets_emit_what_they_replaced() {
214 assert_eq!(
215 Reset::BULLETS.rule(".list"),
216 ".list {\n list-style: none;\n margin: 0;\n padding: 0;\n}\n"
217 );
218 assert_eq!(
219 Reset::TEXT_BUTTON.rule("button.link"),
220 "button.link {\n background: none;\n border: none;\n \
221 box-shadow: none;\n padding: 0;\n font: inherit;\n \
222 cursor: pointer;\n}\n"
223 );
224 assert_eq!(
225 Reset::FLAT_BUTTON.rule(".facet-take"),
226 ".facet-take {\n background: none;\n border: none;\n \
227 box-shadow: none;\n}\n"
228 );
229 }
230
231 /// Order is a property of the emitter and not of the order a caller asked
232 /// in, which is what stops two primitives withdrawing the same pair from
233 /// emitting two different rules.
234 /// The relationship the two button sets are meant to have, so a later edit
235 /// to one cannot quietly make a link keep chrome a facet gives back.
236 #[test]
237 fn a_text_button_withdraws_everything_a_flat_one_does() {
238 for (chrome, _) in ORDER {
239 if Reset::FLAT_BUTTON.carries(chrome) {
240 assert!(Reset::TEXT_BUTTON.carries(chrome), "{chrome:?}");
241 }
242 }
243 }
244
245 #[test]
246 fn order_is_the_emitters() {
247 let forwards = Reset::NOTHING.and(Chrome::Fill).and(Chrome::Bullet);
248 let backwards = Reset::NOTHING.and(Chrome::Bullet).and(Chrome::Fill);
249 assert_eq!(forwards, backwards);
250 assert_eq!(
251 forwards.declarations(),
252 " list-style: none;\n background: none;\n"
253 );
254 }
255
256 #[test]
257 fn every_member_has_a_declaration() {
258 for (chrome, _) in ORDER {
259 assert!(Reset::NOTHING.and(chrome).carries(chrome), "{chrome:?}");
260 }
261 // One bit each, and no member left out of the order.
262 let all = ORDER
263 .iter()
264 .fold(Reset::NOTHING, |set, (chrome, _)| set.and(*chrome));
265 assert_eq!(all.0, u8::MAX);
266 assert_eq!(all.declarations().lines().count(), ORDER.len());
267 }
268 }
269