makeover_webview/lib.rs
1//! The webview renderer for [`makeover_layout`].
2//!
3//! <!-- wiki: makeover-webview -->
4//!
5//! # The renderer that needs no palette
6//!
7//! `makeover-immediate` and `makeover-tui` both take a `Palette`, because egui
8//! and a terminal need an actual colour before they can put anything on
9//! screen. A webview does not: `var(--surface-raised)` *is* the late binding,
10//! and the browser resolves it against whatever `themes.js` last wrote onto
11//! `:root`.
12//!
13//! So this crate emits text naming intents, and never learns a colour. It is
14//! the deferral rule with no adapter in the way, and it is why the webview was
15//! always the wrong renderer to derive a vocabulary from: it can express
16//! anything, so it never pushes back.
17//!
18//! # Phase A: the stylesheet only
19//!
20//! This emits component CSS and no markup, deliberately. GoingsOn has 145
21//! `innerHTML` sites and Balanced Breakfast 175 `createElement` sites; moving
22//! markup is a migration, while adopting a generated stylesheet is a deletion.
23//! The apps keep every line of their markup and gain the classes.
24//!
25//! The output is byte-identical to the bevel composition both apps already
26//! hand-write, which is asserted below, so adoption removes duplicated lines
27//! rather than changing a pixel.
28//!
29//! # Substitution, three ways
30//!
31//! `Fill::Well` has no colour on makeover before 2.3.0, and each renderer
32//! answers that differently, which is the evidence that dropping
33//! `Fill::fallback` from the description was right:
34//!
35//! - `makeover-immediate` substitutes the page in Rust.
36//! - `makeover-tui` refuses to substitute and draws an edge instead, because a
37//! terminal would quantise the two together.
38//! - here, CSS already has the mechanism: `var(--surface-well,
39//! var(--surface-page))` falls back in the browser, and nothing in Rust
40//! decides anything.
41
42#![forbid(unsafe_code)]
43
44use makeover_layout::{Bevel, Depth, Fill, Intent};
45use std::fmt::Write as _;
46
47/// How the emitted CSS is shaped.
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub struct Emit {
50 /// Bevel thickness, as a CSS length.
51 ///
52 /// A value, so it arrives from the caller: border widths belong to
53 /// `makeover-geometry` and will come from there once it carries them.
54 pub border_width: &'static str,
55 /// Prefix for emitted class names, without the leading dot.
56 pub class_prefix: &'static str,
57}
58
59impl Default for Emit {
60 fn default() -> Self {
61 Self {
62 border_width: "1px",
63 class_prefix: "",
64 }
65 }
66}
67
68/// The CSS custom property holding a bevel's composition.
69#[must_use]
70pub fn bevel_var(bevel: Bevel) -> &'static str {
71 match bevel {
72 Bevel::Raised => "--bevel-raised",
73 Bevel::Inset => "--bevel-inset",
74 }
75}
76
77/// A `var()` reference to a fill intent, with the browser's own fallback where
78/// the intent may be absent.
79///
80/// The fallback is CSS syntax, not a decision made here. That is the whole
81/// difference between this renderer and the other two.
82#[must_use]
83pub fn fill_var(fill: Fill) -> String {
84 match fill {
85 Fill::Well => format!("var(--{}, var(--{}))", fill.token(), Fill::Page.token()),
86 other => format!("var(--{})", other.token()),
87 }
88}
89
90/// The two-tone edge as a `box-shadow` value.
91///
92/// Two inset shadows, one per corner pair: the light one offset down and
93/// right so it lands on the top and left edges, the dark one the other way.
94/// The same assignment `makeover-immediate` draws with polylines and
95/// `makeover-tui` draws with box-drawing characters.
96#[must_use]
97pub fn bevel_shadow(bevel: Bevel, opts: &Emit) -> String {
98 let (top_left, bottom_right) = bevel.edges();
99 let w = opts.border_width;
100 format!(
101 "inset {w} {w} 0 var(--{}), inset -{w} -{w} 0 var(--{})",
102 top_left.token(),
103 bottom_right.token()
104 )
105}
106
107/// The custom properties both bevels resolve through.
108///
109/// Emitted as properties rather than inlined into every rule because that is
110/// what the apps already do, and because a consumer that wants the edge
111/// without the fill reads the property directly.
112#[must_use]
113pub fn bevel_properties(opts: &Emit) -> String {
114 let mut css = String::new();
115 for bevel in [Bevel::Raised, Bevel::Inset] {
116 let _ = writeln!(
117 css,
118 " {}: {};",
119 bevel_var(bevel),
120 bevel_shadow(bevel, opts)
121 );
122 }
123 css
124}
125
126/// The class name for a depth.
127#[must_use]
128pub fn depth_class(depth: Depth, opts: &Emit) -> Option<String> {
129 let name = match depth {
130 Depth::Flat => return None,
131 Depth::Raised => "raised",
132 Depth::Well => "well",
133 };
134 Some(format!("{}{name}", opts.class_prefix))
135}
136
137/// One rule per depth: its fill and its edge, together.
138///
139/// `Depth::Flat` emits nothing. A class that sets no properties is a class
140/// that means "I thought about this", which is what comments are for.
141///
142/// A pressed rule rides along with the raised one, because the cascade can
143/// carry a state that an immediate-mode renderer has to resolve per call site.
144/// That is the one thing this renderer gets for free and the others do not.
145#[must_use]
146pub fn depth_rules(opts: &Emit) -> String {
147 let mut css = String::new();
148 for depth in [Depth::Raised, Depth::Well] {
149 let Some(class) = depth_class(depth, opts) else {
150 continue;
151 };
152 let (Some(fill), Some(bevel)) = (depth.fill(), depth.bevel()) else {
153 continue;
154 };
155 let _ = writeln!(
156 css,
157 ".{class} {{\n background: {};\n box-shadow: var({});\n}}",
158 fill_var(fill),
159 bevel_var(bevel)
160 );
161 }
162 if let (Some(raised), Some(pressed)) = (
163 depth_class(Depth::Raised, opts),
164 Depth::Raised.pressed().bevel(),
165 ) {
166 let _ = writeln!(
167 css,
168 ".{raised}:active {{\n box-shadow: var({});\n}}",
169 bevel_var(pressed)
170 );
171 }
172 css
173}
174
175/// The whole phase-A stylesheet: properties and rules, with a generated-file
176/// banner.
177#[must_use]
178pub fn stylesheet(opts: &Emit) -> String {
179 format!(
180 "/* Generated by makeover-webview from makeover-layout. Do not edit.\n \
181 Depth is a fill and an edge together; naming them apart is what let\n \
182 them disagree. See the crate's README and wiki note makeover-layout. */\n\
183 :root {{\n{}}}\n\n{}",
184 bevel_properties(opts),
185 depth_rules(opts)
186 )
187}
188
189#[cfg(test)]
190mod tests {
191 use super::*;
192 use makeover_layout::Edge;
193
194 #[test]
195 fn the_emitted_bevel_matches_what_the_apps_already_hand_write() {
196 // Balanced Breakfast's styles.css, verbatim. Adoption has to be a
197 // deletion, not a redesign, or nobody will take it.
198 let opts = Emit::default();
199 assert_eq!(
200 bevel_shadow(Bevel::Raised, &opts),
201 "inset 1px 1px 0 var(--bevel-light), inset -1px -1px 0 var(--bevel-dark)"
202 );
203 assert_eq!(
204 bevel_shadow(Bevel::Inset, &opts),
205 "inset 1px 1px 0 var(--bevel-dark), inset -1px -1px 0 var(--bevel-light)"
206 );
207 }
208
209 #[test]
210 fn no_colour_ever_reaches_the_output() {
211 let css = stylesheet(&Emit::default());
212 assert!(!css.contains('#'), "a hex literal escaped into the CSS");
213 assert!(
214 !css.contains("rgb"),
215 "a colour function escaped into the CSS"
216 );
217 // Every colour is named, never resolved.
218 assert!(css.contains("var(--surface-raised)"));
219 assert!(css.contains("var(--bevel-light)"));
220 }
221
222 #[test]
223 fn a_well_falls_back_through_css_rather_than_through_rust() {
224 assert_eq!(
225 fill_var(Fill::Well),
226 "var(--surface-well, var(--surface-page))"
227 );
228 // Nothing else needs one.
229 assert_eq!(fill_var(Fill::Raised), "var(--surface-raised)");
230 assert_eq!(fill_var(Fill::Page), "var(--surface-page)");
231 }
232
233 #[test]
234 fn raised_and_well_do_not_collapse_onto_each_other() {
235 let css = depth_rules(&Emit::default());
236 assert!(css.contains(".raised {"));
237 assert!(css.contains(".well {"));
238 assert!(css.contains("var(--bevel-raised)"));
239 assert!(css.contains("var(--bevel-inset)"));
240 }
241
242 #[test]
243 fn the_cascade_carries_the_pressed_state() {
244 let css = depth_rules(&Emit::default());
245 // The one thing this renderer gets free that the other two resolve by
246 // hand, eighteen call sites deep in audiofiles' case.
247 assert!(css.contains(".raised:active {"));
248 }
249
250 #[test]
251 fn flat_emits_nothing_at_all() {
252 assert_eq!(depth_class(Depth::Flat, &Emit::default()), None);
253 assert!(!depth_rules(&Emit::default()).contains("flat"));
254 }
255
256 #[test]
257 fn a_prefix_namespaces_every_class() {
258 let opts = Emit {
259 class_prefix: "mo-",
260 ..Emit::default()
261 };
262 let css = depth_rules(&opts);
263 assert!(css.contains(".mo-raised {"));
264 assert!(css.contains(".mo-well {"));
265 assert!(!css.contains(".raised {"));
266 }
267
268 #[test]
269 fn the_border_width_is_the_callers() {
270 let opts = Emit {
271 border_width: "2px",
272 ..Emit::default()
273 };
274 assert!(bevel_shadow(Bevel::Raised, &opts).contains("inset 2px 2px 0"));
275 }
276
277 #[test]
278 fn edges_agree_with_the_description() {
279 // Not a tautology: it is the guard that a CSS-shaped convenience never
280 // quietly reverses which side is lit.
281 let (tl, br) = Bevel::Raised.edges();
282 assert_eq!(tl.token(), Edge::Light.token());
283 assert_eq!(br.token(), Edge::Dark.token());
284 }
285}