//! The webview renderer for [`makeover_layout`]. //! //! //! //! # The renderer that needs no palette //! //! `makeover-immediate` and `makeover-tui` both take a `Palette`, because egui //! and a terminal need an actual colour before they can put anything on //! screen. A webview does not: `var(--surface-raised)` *is* the late binding, //! and the browser resolves it against whatever `themes.js` last wrote onto //! `:root`. //! //! So this crate emits text naming intents, and never learns a colour. It is //! the deferral rule with no adapter in the way, and it is why the webview was //! always the wrong renderer to derive a vocabulary from: it can express //! anything, so it never pushes back. //! //! # Phase A: the stylesheet only //! //! This emits component CSS and no markup, deliberately. GoingsOn has 145 //! `innerHTML` sites and Balanced Breakfast 175 `createElement` sites; moving //! markup is a migration, while adopting a generated stylesheet is a deletion. //! The apps keep every line of their markup and gain the classes. //! //! The output is byte-identical to the bevel composition both apps already //! hand-write, which is asserted below, so adoption removes duplicated lines //! rather than changing a pixel. //! //! # Substitution, three ways //! //! `Fill::Well` has no colour on makeover before 2.3.0, and each renderer //! answers that differently, which is the evidence that dropping //! `Fill::fallback` from the description was right: //! //! - `makeover-immediate` substitutes the page in Rust. //! - `makeover-tui` refuses to substitute and draws an edge instead, because a //! terminal would quantise the two together. //! - here, CSS already has the mechanism: `var(--surface-well, //! var(--surface-page))` falls back in the browser, and nothing in Rust //! decides anything. #![forbid(unsafe_code)] use makeover_layout::{Bevel, Depth, Fill, Intent}; use std::fmt::Write as _; /// How the emitted CSS is shaped. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct Emit { /// Bevel thickness, as a CSS length. /// /// A value, so it arrives from the caller: border widths belong to /// `makeover-geometry` and will come from there once it carries them. pub border_width: &'static str, /// Prefix for emitted class names, without the leading dot. pub class_prefix: &'static str, } impl Default for Emit { fn default() -> Self { Self { border_width: "1px", class_prefix: "", } } } /// The CSS custom property holding a bevel's composition. #[must_use] pub fn bevel_var(bevel: Bevel) -> &'static str { match bevel { Bevel::Raised => "--bevel-raised", Bevel::Inset => "--bevel-inset", } } /// A `var()` reference to a fill intent, with the browser's own fallback where /// the intent may be absent. /// /// The fallback is CSS syntax, not a decision made here. That is the whole /// difference between this renderer and the other two. #[must_use] pub fn fill_var(fill: Fill) -> String { match fill { Fill::Well => format!("var(--{}, var(--{}))", fill.token(), Fill::Page.token()), other => format!("var(--{})", other.token()), } } /// The two-tone edge as a `box-shadow` value. /// /// Two inset shadows, one per corner pair: the light one offset down and /// right so it lands on the top and left edges, the dark one the other way. /// The same assignment `makeover-immediate` draws with polylines and /// `makeover-tui` draws with box-drawing characters. #[must_use] pub fn bevel_shadow(bevel: Bevel, opts: &Emit) -> String { let (top_left, bottom_right) = bevel.edges(); let w = opts.border_width; format!( "inset {w} {w} 0 var(--{}), inset -{w} -{w} 0 var(--{})", top_left.token(), bottom_right.token() ) } /// The custom properties both bevels resolve through. /// /// Emitted as properties rather than inlined into every rule because that is /// what the apps already do, and because a consumer that wants the edge /// without the fill reads the property directly. #[must_use] pub fn bevel_properties(opts: &Emit) -> String { let mut css = String::new(); for bevel in [Bevel::Raised, Bevel::Inset] { let _ = writeln!( css, " {}: {};", bevel_var(bevel), bevel_shadow(bevel, opts) ); } css } /// The class name for a depth. #[must_use] pub fn depth_class(depth: Depth, opts: &Emit) -> Option { let name = match depth { Depth::Flat => return None, Depth::Raised => "raised", Depth::Well => "well", }; Some(format!("{}{name}", opts.class_prefix)) } /// One rule per depth: its fill and its edge, together. /// /// `Depth::Flat` emits nothing. A class that sets no properties is a class /// that means "I thought about this", which is what comments are for. /// /// A pressed rule rides along with the raised one, because the cascade can /// carry a state that an immediate-mode renderer has to resolve per call site. /// That is the one thing this renderer gets for free and the others do not. #[must_use] pub fn depth_rules(opts: &Emit) -> String { let mut css = String::new(); for depth in [Depth::Raised, Depth::Well] { let Some(class) = depth_class(depth, opts) else { continue; }; let (Some(fill), Some(bevel)) = (depth.fill(), depth.bevel()) else { continue; }; let _ = writeln!( css, ".{class} {{\n background: {};\n box-shadow: var({});\n}}", fill_var(fill), bevel_var(bevel) ); } if let (Some(raised), Some(pressed)) = ( depth_class(Depth::Raised, opts), Depth::Raised.pressed().bevel(), ) { let _ = writeln!( css, ".{raised}:active {{\n box-shadow: var({});\n}}", bevel_var(pressed) ); } css } /// The whole phase-A stylesheet: properties and rules, with a generated-file /// banner. #[must_use] pub fn stylesheet(opts: &Emit) -> String { format!( "/* Generated by makeover-webview from makeover-layout. Do not edit.\n \ Depth is a fill and an edge together; naming them apart is what let\n \ them disagree. See the crate's README and wiki note makeover-layout. */\n\ :root {{\n{}}}\n\n{}", bevel_properties(opts), depth_rules(opts) ) } #[cfg(test)] mod tests { use super::*; use makeover_layout::Edge; #[test] fn the_emitted_bevel_matches_what_the_apps_already_hand_write() { // Balanced Breakfast's styles.css, verbatim. Adoption has to be a // deletion, not a redesign, or nobody will take it. let opts = Emit::default(); assert_eq!( bevel_shadow(Bevel::Raised, &opts), "inset 1px 1px 0 var(--bevel-light), inset -1px -1px 0 var(--bevel-dark)" ); assert_eq!( bevel_shadow(Bevel::Inset, &opts), "inset 1px 1px 0 var(--bevel-dark), inset -1px -1px 0 var(--bevel-light)" ); } #[test] fn no_colour_ever_reaches_the_output() { let css = stylesheet(&Emit::default()); assert!(!css.contains('#'), "a hex literal escaped into the CSS"); assert!( !css.contains("rgb"), "a colour function escaped into the CSS" ); // Every colour is named, never resolved. assert!(css.contains("var(--surface-raised)")); assert!(css.contains("var(--bevel-light)")); } #[test] fn a_well_falls_back_through_css_rather_than_through_rust() { assert_eq!( fill_var(Fill::Well), "var(--surface-well, var(--surface-page))" ); // Nothing else needs one. assert_eq!(fill_var(Fill::Raised), "var(--surface-raised)"); assert_eq!(fill_var(Fill::Page), "var(--surface-page)"); } #[test] fn raised_and_well_do_not_collapse_onto_each_other() { let css = depth_rules(&Emit::default()); assert!(css.contains(".raised {")); assert!(css.contains(".well {")); assert!(css.contains("var(--bevel-raised)")); assert!(css.contains("var(--bevel-inset)")); } #[test] fn the_cascade_carries_the_pressed_state() { let css = depth_rules(&Emit::default()); // The one thing this renderer gets free that the other two resolve by // hand, eighteen call sites deep in audiofiles' case. assert!(css.contains(".raised:active {")); } #[test] fn flat_emits_nothing_at_all() { assert_eq!(depth_class(Depth::Flat, &Emit::default()), None); assert!(!depth_rules(&Emit::default()).contains("flat")); } #[test] fn a_prefix_namespaces_every_class() { let opts = Emit { class_prefix: "mo-", ..Emit::default() }; let css = depth_rules(&opts); assert!(css.contains(".mo-raised {")); assert!(css.contains(".mo-well {")); assert!(!css.contains(".raised {")); } #[test] fn the_border_width_is_the_callers() { let opts = Emit { border_width: "2px", ..Emit::default() }; assert!(bevel_shadow(Bevel::Raised, &opts).contains("inset 2px 2px 0")); } #[test] fn edges_agree_with_the_description() { // Not a tautology: it is the guard that a CSS-shaped convenience never // quietly reverses which side is lit. let (tl, br) = Bevel::Raised.edges(); assert_eq!(tl.token(), Edge::Light.token()); assert_eq!(br.token(), Edge::Dark.token()); } }