Skip to main content

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}