makeover-webview 0.91.0

The webview renderer for makeover-layout. Emits CSS, and is the one renderer that needs no palette: var() is the late binding, so resolution stays with the browser.
Documentation
//! A proportion, rendered as a bar.
//!
//! The third phase-B emitter, beside [`form`](crate::form) and
//! [`list`](crate::list). It is much the smallest, and it is here rather than in
//! the app because the trough it fills has been in phase A since before anything
//! could describe one: `progress_rules` emitted `.progress` and
//! `.progress-fill[data-tone]` for every tone while the only way to say "3 of 7"
//! was to concatenate it into a heading.
//!
//! # What the pair buys, at the last layer
//!
//! `makeover_layout::Meter` carries `done` and `total` rather than a percentage,
//! and the reason shows up here. A bar that is full because it landed exactly
//! and a bar that is full because it ran over are the same width and are not the
//! same fact, so the width is not allowed to be the only thing emitted.
//!
//! # Neither number is divided here
//!
//! `--meter-done` and `--meter-total` go out as themselves and `progress_rules`
//! does the arithmetic. That is the property the residual seam needs, and
//! [`chart`](crate::chart) states the reasoning in full: a residual is derived
//! once from stand-in values, so a number this crate WORKS OUT from two of them
//! is not a stand-in any filler can find, and one request's percentage bakes
//! into the template. A number handed over whole stays a hole.
//!
//! It also retires `data-over`. That flag existed because a percentage was the
//! only thing emitted, so the over-run had nowhere else to live; with both
//! integers in the markup the comparison is there to be read, and a stylesheet
//! that wants to react can make it where it uses it. Dropping it also clears a
//! collision: quasi-webview writes `data-over="<selection>"` on a control, and
//! goingson's `quasi-selection.js` selects `[data-over]` blind.
//!
//! What an over-run should look like stays app taste -- goingson says it with
//! `Tone::Danger` -- and a renderer that picked a stripe for everyone would be
//! decorating rather than describing.

use crate::form::escape_into;
use crate::{Emit, push_class};
use makeover_layout::{Intent, Meter, Tone};
use std::fmt::Write as _;

/// Every class this module can put in markup.
///
/// [`crate::facet::FACET_CLASSES`]' obligation. Both carry rules, so the
/// scraped vocabulary already holds them; the list is what keeps that true if
/// a rule goes away.
pub const METER_CLASSES: &[&str] = &["progress", "progress-fill"];

/// The accessible name for a meter: the two numbers, and the noun if it has one.
///
/// The description carries the noun alone, so the sentence is built here. That
/// is the whole reason `Meter::label` is not the assembled string: a tooltip
/// wants "3 of 7 subtasks" and a terminal at one line wants "3/7", and a
/// description that shipped either one would have chosen for both.
///
/// The true `done` is used, not the clamped one. This is the text that says an
/// over-run happened.
#[must_use]
pub fn meter_text(meter: &Meter<'_>) -> String {
    match meter.label {
        Some(label) => format!("{} of {} {label}", meter.done, meter.total),
        None => format!("{} of {}", meter.done, meter.total),
    }
}

/// A meter as a filled trough.
///
/// ```
/// use makeover_layout::{Meter, Tone};
/// use makeover_webview::{Emit, meter::meter_html};
///
/// let meter = Meter::new(3, 7).tone(Tone::Success).label("subtasks");
/// let html = meter_html(&meter, &Emit::default());
///
/// assert!(html.contains(r#"aria-label="3 of 7 subtasks""#));
/// assert!(html.contains(r#"data-tone="success""#));
/// // The two counts, not the 42% they come to: `progress_rules` divides.
/// assert!(html.contains("--meter-done: 3; --meter-total: 7"));
/// assert!(!html.contains("--meter-fill"));
/// ```
///
/// `aria-valuenow` is `done` as it stands, so an over-run reports above
/// `aria-valuemax`. The clamp that used to sit here was a derivation and could
/// not survive a residual; see [`meter_html_into`] for the whole argument.
#[must_use]
pub fn meter_html(meter: &Meter<'_>, opts: &Emit) -> String {
    let mut html = String::new();
    meter_html_into(meter, opts, &mut html);
    html
}

/// A meter, written into a buffer the caller already has.
///
/// [`meter_html`]'s streaming form, byte-identical to it. The accessible name is
/// written a piece at a time rather than built and then escaped: the numbers
/// carry nothing an escaper would encode, so only the noun goes through one.
pub fn meter_html_into(meter: &Meter<'_>, opts: &Emit, out: &mut String) {
    out.push_str("<div class=\"");
    push_class(out, "progress", opts);
    // `aria-valuenow` is `done` as it stands, not `done.min(total)`. The clamp
    // was a derivation, so it baked one request's value into a residual where
    // the raw number is a hole -- and a stylesheet cannot write an attribute,
    // so moving the width into CSS would not have reached it.
    //
    // An over-run therefore reports a value above `aria-valuemax`, which ARIA
    // calls out of range. That is the honest reading: a progressbar whose value
    // exceeds its maximum is what an over-run IS, and clamping is the renderer
    // deciding a screen-reader user should not be told what the sighted reader
    // can see. The accessible name below carries both true numbers either way.
    let _ = write!(
        out,
        "\" role=\"progressbar\" aria-valuenow=\"{}\" \
         aria-valuemin=\"0\" aria-valuemax=\"{}\" aria-label=\"{} of {}",
        meter.done, meter.total, meter.done, meter.total
    );
    if let Some(label) = meter.label {
        out.push(' ');
        escape_into(label, out);
    }
    out.push_str("\">");

    out.push_str("<div class=\"");
    push_class(out, "progress-fill", opts);
    out.push('"');
    // Neutral is the untoned bar, and `progress_rules` gives it `--action`
    // rather than a tone attribute. Emitting `data-tone="content-muted"` would
    // match a rule that does not exist and read as disabled if it did.
    if meter.tone != Tone::Neutral {
        let _ = write!(out, " data-tone=\"{}\"", meter.tone.token());
    }
    // The two counts as custom properties the trough's rule divides, in
    // `data-vars` rather than `style`: see [`crate::VARS_ATTR`]. Not the fill:
    // see the module header for why nothing is divided here.
    let _ = write!(
        out,
        " {}=\"--meter-done: {}; --meter-total: {}\"></div></div>",
        crate::VARS_ATTR,
        meter.done,
        meter.total
    );
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::form::escape;

    /// The accessible name is built by [`meter_text`] in one form and written a
    /// piece at a time in the other, and the over-run case is the one where the
    /// numbers differ from what the bar draws.
    #[test]
    fn a_streamed_meter_is_the_meter_the_other_form_returns() {
        let opts = Emit {
            class_prefix: "mk-",
            ..Emit::default()
        };
        for meter in [
            Meter::new(0, 0),
            Meter::new(3, 7).label("sub & tasks"),
            Meter::new(9, 7).tone(Tone::Danger).label("<tasks>"),
        ] {
            let mut streamed = String::new();
            meter_html_into(&meter, &opts, &mut streamed);
            assert_eq!(streamed, meter_html(&meter, &opts));
            assert!(
                streamed.contains(&format!("aria-label=\"{}\"", escape(&meter_text(&meter)))),
                "{streamed}"
            );
        }
    }

    #[test]
    fn a_full_bar_says_whether_it_ran_over() {
        // The two facts a percentage could not tell apart, and the reason the
        // description carries a pair. Both draw a full bar -- the rule clamps
        // with `min` -- and the markup still tells them apart, now because both
        // counts are in it rather than because a flag was bolted on.
        let exact = meter_html(&Meter::new(30, 30), &Emit::default());
        let over = meter_html(&Meter::new(45, 30), &Emit::default());

        assert!(
            exact.contains("--meter-done: 30; --meter-total: 30"),
            "{exact}"
        );
        assert!(
            over.contains("--meter-done: 45; --meter-total: 30"),
            "{over}"
        );
        assert!(!exact.contains("style="));

        // No percentage is emitted at all: that is what lets a meter cross a
        // residual. If this ever comes back, the seam quietly closes again.
        assert!(!exact.contains("--meter-fill"), "{exact}");
        assert!(!over.contains("--meter-fill"), "{over}");

        // `data-over` is retired, on both the exact and the over-run bar. The
        // spelling belongs to quasi-webview's `Act::over` now, and goingson's
        // selection script selects it blind.
        assert!(!exact.contains("data-over"), "{exact}");
        assert!(!over.contains("data-over"), "{over}");
    }

    #[test]
    fn the_accessible_name_keeps_the_number_the_bar_cannot_show() {
        // The bar is clamped and the name is not. Losing this is how an
        // over-run becomes invisible to anyone not looking at the colour.
        let over = Meter::new(45, 30).label("minutes");
        assert_eq!(meter_text(&over), "45 of 30 minutes");
        assert!(meter_html(&over, &Emit::default()).contains(r#"aria-label="45 of 30 minutes""#));
    }

    #[test]
    fn aria_valuenow_reports_the_over_run_rather_than_clamping_it() {
        // This test used to assert the opposite, and the reversal is deliberate
        // rather than a relaxation, so the reason is written down here.
        //
        // `aria-valuenow` was `done.min(total)`. That is a number worked out
        // from two others, so it could not be a residual stand-in: one
        // request's clamped value baked into the template. Moving the width
        // into the stylesheet does not reach it, because a stylesheet cannot
        // write an attribute -- this was the last derivation in the emitter.
        //
        // The cost is real and accepted: on an over-run the value sits above
        // the maximum, which ARIA calls out of range. The alternative is the
        // renderer deciding a screen-reader user should not be told what the
        // sighted reader can see, and `aria-label` carries both true numbers
        // either way (pinned by the test above).
        let html = meter_html(&Meter::new(45, 30), &Emit::default());
        assert!(html.contains(r#"aria-valuenow="45""#), "{html}");
        assert!(html.contains(r#"aria-valuemax="30""#), "{html}");

        // A bar that did not run over reports exactly what it always did, so
        // the change is confined to the case that was being misreported.
        let under = meter_html(&Meter::new(3, 7), &Emit::default());
        assert!(under.contains(r#"aria-valuenow="3""#), "{under}");
        assert!(under.contains(r#"aria-valuemax="7""#), "{under}");
    }

    #[test]
    fn an_untoned_bar_emits_no_tone_attribute() {
        // `progress_rules` styles the untoned bar with `--action` on the bare
        // class. A `data-tone="content-muted"` here would match no rule.
        let plain = meter_html(&Meter::new(1, 2), &Emit::default());
        assert!(!plain.contains("data-tone"));

        let toned = meter_html(&Meter::new(1, 2).tone(Tone::Danger), &Emit::default());
        assert!(toned.contains(r#"data-tone="danger""#));
    }

    #[test]
    fn an_empty_set_renders_an_empty_trough() {
        // Sayable, so it has to be emittable. The emitter no longer divides at
        // all, so the zero case cannot fault here; what it has to get right is
        // that the RULE survives it, which `the_progress_trough_has_a_height_
        // the_fill_fills` pins by reading the emitted `max(..., 1)` divisor.
        let html = meter_html(&Meter::new(0, 0), &Emit::default());
        assert!(html.contains("--meter-done: 0; --meter-total: 0"), "{html}");
        assert!(html.contains(r#"aria-valuemax="0""#));
    }

    #[test]
    fn the_label_is_escaped_like_every_other_string() {
        // It arrives from the app the same as a field label does.
        let html = meter_html(&Meter::new(1, 2).label("a & b"), &Emit::default());
        assert!(html.contains("a &amp; b"));
        assert!(!html.contains("a & b"));
    }

    #[test]
    fn the_prefix_reaches_both_classes() {
        // A prefixed build claims its own names, and the fill is a descendant
        // selector in the emitted CSS: miss one and the rule stops matching.
        let opts = Emit {
            class_prefix: "mo-",
            ..Emit::default()
        };
        let html = meter_html(&Meter::new(1, 2), &opts);
        assert!(html.contains(r#"class="mo-progress""#));
        assert!(html.contains(r#"class="mo-progress-fill""#));
    }
}