|
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 |
+ |
|
|
44 |
+ |
use makeover_layout::{Bevel, Depth, Fill, Intent};
|
|
45 |
+ |
use std::fmt::Write as _;
|
|
46 |
+ |
|
|
47 |
+ |
/// How the emitted CSS is shaped.
|
|
48 |
+ |
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
49 |
+ |
pub 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 |
+ |
|
|
59 |
+ |
impl 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]
|
|
70 |
+ |
pub 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]
|
|
83 |
+ |
pub 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]
|
|
97 |
+ |
pub 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]
|
|
113 |
+ |
pub 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]
|
|
128 |
+ |
pub 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]
|
|
146 |
+ |
pub 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]
|
|
178 |
+ |
pub 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)]
|
|
190 |
+ |
mod 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 |
+ |
}
|