Skip to main content

makeover_webview/
meter.rs

1//! A proportion, rendered as a bar.
2//!
3//! The third phase-B emitter, beside [`form`](crate::form) and
4//! [`list`](crate::list). It is much the smallest, and it is here rather than in
5//! the app because the trough it fills has been in phase A since before anything
6//! could describe one: `progress_rules` emitted `.progress` and
7//! `.progress-fill[data-tone]` for every tone while the only way to say "3 of 7"
8//! was to concatenate it into a heading.
9//!
10//! # What the pair buys, at the last layer
11//!
12//! `makeover_layout::Meter` carries `done` and `total` rather than a percentage,
13//! and the reason shows up here. A bar that is full because it landed exactly
14//! and a bar that is full because it ran over are the same width and are not the
15//! same fact, so the width is not allowed to be the only thing emitted.
16//!
17//! # Neither number is divided here
18//!
19//! `--meter-done` and `--meter-total` go out as themselves and `progress_rules`
20//! does the arithmetic. That is the property the residual seam needs, and
21//! [`chart`](crate::chart) states the reasoning in full: a residual is derived
22//! once from stand-in values, so a number this crate WORKS OUT from two of them
23//! is not a stand-in any filler can find, and one request's percentage bakes
24//! into the template. A number handed over whole stays a hole.
25//!
26//! It also retires `data-over`. That flag existed because a percentage was the
27//! only thing emitted, so the over-run had nowhere else to live; with both
28//! integers in the markup the comparison is there to be read, and a stylesheet
29//! that wants to react can make it where it uses it. Dropping it also clears a
30//! collision: quasi-webview writes `data-over="<selection>"` on a control, and
31//! goingson's `quasi-selection.js` selects `[data-over]` blind.
32//!
33//! What an over-run should look like stays app taste, goingson says it with
34//! `Tone::Danger`, and a renderer that picked a stripe for everyone would be
35//! decorating rather than describing.
36
37use crate::form::escape_into;
38use crate::{Emit, class, depth_rule, push_class};
39use makeover_layout::{Depth, Intent, Meter, Tone};
40use std::fmt::Write as _;
41
42/// Every class this module can put in markup.
43///
44/// [`crate::facet::FACET_CLASSES`]' obligation. Both carry rules, so the
45/// scraped vocabulary already holds them; the list is what keeps that true if
46/// a rule goes away.
47pub const METER_CLASSES: &[&str] = &["progress", "progress-fill"];
48
49/// The accessible name for a meter: the two numbers, and the noun if it has one.
50///
51/// The description carries the noun alone, so the sentence is built here. That
52/// is the whole reason `Meter::label` is not the assembled string: a tooltip
53/// wants "3 of 7 subtasks" and a terminal at one line wants "3/7", and a
54/// description that shipped either one would have chosen for both.
55///
56/// The true `done` is used, not the clamped one. This is the text that says an
57/// over-run happened.
58#[must_use]
59pub fn meter_text(meter: &Meter<'_>) -> String {
60    match meter.label {
61        Some(label) => format!("{} of {} {label}", meter.done, meter.total),
62        None => format!("{} of {}", meter.done, meter.total),
63    }
64}
65
66/// A meter as a filled trough.
67///
68/// ```
69/// use makeover_layout::{Meter, Tone};
70/// use makeover_webview::{Emit, meter::meter_html};
71///
72/// let meter = Meter::new(3, 7).tone(Tone::Success).label("subtasks");
73/// let html = meter_html(&meter, &Emit::default());
74///
75/// assert!(html.contains(r#"aria-label="3 of 7 subtasks""#));
76/// assert!(html.contains(r#"data-tone="success""#));
77/// // The two counts, not the 42% they come to: `progress_rules` divides.
78/// assert!(html.contains("--meter-done: 3; --meter-total: 7"));
79/// assert!(!html.contains("--meter-fill"));
80/// ```
81///
82/// `aria-valuenow` is `done` as it stands, so an over-run reports above
83/// `aria-valuemax`. A clamp here would be a derivation, which a residual cannot
84/// carry; see [`meter_html_into`] for the whole argument.
85#[must_use]
86pub fn meter_html(meter: &Meter<'_>, opts: &Emit) -> String {
87    let mut html = String::new();
88    meter_html_into(meter, opts, &mut html);
89    html
90}
91
92/// A meter, written into a buffer the caller already has.
93///
94/// [`meter_html`]'s streaming form, byte-identical to it. The accessible name is
95/// written a piece at a time rather than built and then escaped: the numbers
96/// carry nothing an escaper would encode, so only the noun goes through one.
97pub fn meter_html_into(meter: &Meter<'_>, opts: &Emit, out: &mut String) {
98    emit_meter(meter, opts, out, None);
99}
100
101/// A meter, saying where its label landed.
102///
103/// Byte-identical to [`meter_html_into`], and it sets `placed` to the offsets
104/// in `out` between which the escaped label was written, inside the accessible
105/// name. `None` stays `None` for a meter with no label. The label is the one
106/// caller-supplied string a meter writes, and the one a template compiler
107/// cannot find by searching; see [`crate::list::cells_html_placed`].
108pub fn meter_html_placed(
109    meter: &Meter<'_>,
110    opts: &Emit,
111    out: &mut String,
112    placed: &mut Option<core::ops::Range<usize>>,
113) {
114    emit_meter(meter, opts, out, Some(placed));
115}
116
117fn emit_meter(
118    meter: &Meter<'_>,
119    opts: &Emit,
120    out: &mut String,
121    placed: Option<&mut Option<core::ops::Range<usize>>>,
122) {
123    out.push_str("<div class=\"");
124    push_class(out, "progress", opts);
125    // `aria-valuenow` is `done` as it stands, not `done.min(total)`. The clamp
126    // was a derivation, so it baked one request's value into a residual where
127    // the raw number is a hole, and a stylesheet cannot write an attribute,
128    // so moving the width into CSS would not have reached it.
129    //
130    // An over-run therefore reports a value above `aria-valuemax`, which ARIA
131    // calls out of range. That is the honest reading: a progressbar whose value
132    // exceeds its maximum is what an over-run IS, and clamping is the renderer
133    // deciding a screen-reader user should not be told what the sighted reader
134    // can see. The accessible name below carries both true numbers either way.
135    let _ = write!(
136        out,
137        "\" role=\"progressbar\" aria-valuenow=\"{}\" \
138         aria-valuemin=\"0\" aria-valuemax=\"{}\" aria-label=\"{} of {}",
139        meter.done, meter.total, meter.done, meter.total
140    );
141    if let Some(label) = meter.label {
142        out.push(' ');
143        let at = out.len();
144        escape_into(label, out);
145        if let Some(placed) = placed {
146            *placed = Some(at..out.len());
147        }
148    }
149    out.push_str("\">");
150
151    out.push_str("<div class=\"");
152    push_class(out, "progress-fill", opts);
153    out.push('"');
154    // Neutral is the untoned bar, and `progress_rules` gives it `--action`
155    // rather than a tone attribute. Emitting `data-tone="content-muted"` would
156    // match a rule that does not exist and read as disabled if it did.
157    if meter.tone != Tone::Neutral {
158        let _ = write!(out, " data-tone=\"{}\"", meter.tone.token());
159    }
160    // The two counts as custom properties the trough's rule divides, in
161    // `data-vars` rather than `style`: see [`crate::VARS_ATTR`]. Not the fill:
162    // see the module header for why nothing is divided here.
163    let _ = write!(
164        out,
165        " {}=\"--meter-done: {}; --meter-total: {}\"></div></div>",
166        crate::VARS_ATTR,
167        meter.done,
168        meter.total
169    );
170}
171
172/// The progress trough these rules fill.
173///
174/// [`meter::meter_html`](crate::meter::meter_html) is what fills these.
175///
176/// The rules stay a superset of what a description can ask for. An app drawing
177/// its own bar keeps these classes, which is what the four goingson grew
178/// independently were adopted onto.
179///
180/// The trough is a [`Depth::Well`], the same reading a text field gets:
181/// something with its content down inside it.
182pub(crate) fn progress_rules(opts: &Emit) -> String {
183    let progress = class("progress", opts);
184    // `progress-fill` rather than a bare `fill`: an unprefixed build claims
185    // these names in the app's own stylesheet, and `.fill` is grabby enough to
186    // catch things that have nothing to do with progress. goingson already
187    // calls it `.progress-fill`, so this is also the name that deletes.
188    let fill = class("progress-fill", opts);
189    let mut css = depth_rule(&progress, Depth::Well);
190
191    // The trough's block size, and the fill filling it. The fill is an empty
192    // block, so a trough nothing sized had no height and the meter drew nothing:
193    // goingson carried the size in its own sheet, and MNW, which did not, drew
194    // its Cloud Sync storage meter as a blank gap. Width stays the app's, below;
195    // a height is not taste, it is whether the bar exists.
196    let _ = writeln!(
197        css,
198        ".{progress} {{\n    block-size: var(--meter-block, 0.625rem);\n    overflow: hidden;\n}}"
199    );
200
201    // The untoned bar is `--action`, not [`Tone::Neutral`]. That is the one
202    // place this differs from the badge rules, and deliberately: a badge with
203    // no status is a muted label, while a bar with no status is still
204    // reporting progress, and `content-muted` would read as disabled.
205    //
206    // The width is the two counts the meter emitter hands over in `data-vars`,
207    // divided here. A finished percentage is a number worked out from two
208    // others and therefore not a stand-in a residual filler can find: one
209    // request's width baked into the template. This division is the half of the contract the markup cannot
210    // state on its own, exactly as `chart_rules` says of a bar's height.
211    //
212    // `min(done, total)` is the clamp, here for the same reason: an over-run draws a full bar rather than one wider than its
213    // trough. `max(var(--meter-total), 1)` rather than a guard, for
214    // `chart_rules`' reason, dividing by zero makes the whole declaration
215    // invalid at computed-value time, which drops the width to `auto`, and an
216    // auto-width block is the whole trough, reading as done.
217    //
218    // Both fallbacks are 0, so an element with neither property draws an empty
219    // bar rather than a full one, which is the reading the old `0%` default had.
220    //
221    // On the fill alone, not under `.progress >`: a rule names every class in
222    // its selector to a consumer's drift check, and the trough's width is the
223    // app's to set. goingson sizes `.progress` and failed its build on a width
224    // this rule never gave the trough.
225    let _ = writeln!(
226        css,
227        ".{fill} {{\n    width: calc(\n        min(var(--meter-done, 0), var(--meter-total, 0))\n        \
228         * 100% / max(var(--meter-total, 1), 1)\n    );\n    block-size: 100%;\n}}"
229    );
230    let _ = writeln!(
231        css,
232        ".{progress} > .{fill} {{\n    background: var(--action);\n}}"
233    );
234
235    // A bar can be saying something, same as a badge: goingson colours subtask
236    // progress as success and an over-estimate as danger, which is real
237    // information rather than decoration. Emitting the tones is what lets that
238    // survive adoption instead of staying hand-written.
239    for tone in [Tone::Info, Tone::Success, Tone::Warning, Tone::Danger] {
240        let _ = writeln!(
241            css,
242            ".{progress} > .{fill}[data-tone=\"{0}\"] {{\n    background: var(--{0});\n}}",
243            tone.token()
244        );
245    }
246    css
247}
248
249#[cfg(test)]
250mod tests {
251    use super::*;
252    use crate::form::escape;
253
254    /// The accessible name is built by [`meter_text`] in one form and written a
255    /// piece at a time in the other, and the over-run case is the one where the
256    /// numbers differ from what the bar draws.
257    #[test]
258    fn a_streamed_meter_is_the_meter_the_other_form_returns() {
259        let opts = Emit {
260            class_prefix: "mk-",
261            ..Emit::default()
262        };
263        for meter in [
264            Meter::new(0, 0),
265            Meter::new(3, 7).label("sub & tasks"),
266            Meter::new(9, 7).tone(Tone::Danger).label("<tasks>"),
267        ] {
268            let mut streamed = String::new();
269            meter_html_into(&meter, &opts, &mut streamed);
270            assert_eq!(streamed, meter_html(&meter, &opts));
271            assert!(
272                streamed.contains(&format!("aria-label=\"{}\"", escape(&meter_text(&meter)))),
273                "{streamed}"
274            );
275        }
276    }
277
278    #[test]
279    fn a_full_bar_says_whether_it_ran_over() {
280        // The two facts a percentage could not tell apart, and the reason the
281        // description carries a pair. Both draw a full bar, the rule clamps
282        // with `min`, and the markup still tells them apart, now because both
283        // counts are in it rather than because a flag was bolted on.
284        let exact = meter_html(&Meter::new(30, 30), &Emit::default());
285        let over = meter_html(&Meter::new(45, 30), &Emit::default());
286
287        assert!(
288            exact.contains("--meter-done: 30; --meter-total: 30"),
289            "{exact}"
290        );
291        assert!(
292            over.contains("--meter-done: 45; --meter-total: 30"),
293            "{over}"
294        );
295        assert!(!exact.contains("style="));
296
297        // No percentage is emitted at all: that is what lets a meter cross a
298        // residual. If this ever comes back, the seam quietly closes again.
299        assert!(!exact.contains("--meter-fill"), "{exact}");
300        assert!(!over.contains("--meter-fill"), "{over}");
301
302        // `data-over` is retired, on both the exact and the over-run bar. The
303        // spelling belongs to quasi-webview's `Act::over` now, and goingson's
304        // selection script selects it blind.
305        assert!(!exact.contains("data-over"), "{exact}");
306        assert!(!over.contains("data-over"), "{over}");
307    }
308
309    #[test]
310    fn the_accessible_name_keeps_the_number_the_bar_cannot_show() {
311        // The bar is clamped and the name is not. Losing this is how an
312        // over-run becomes invisible to anyone not looking at the colour.
313        let over = Meter::new(45, 30).label("minutes");
314        assert_eq!(meter_text(&over), "45 of 30 minutes");
315        assert!(meter_html(&over, &Emit::default()).contains(r#"aria-label="45 of 30 minutes""#));
316    }
317
318    #[test]
319    fn aria_valuenow_reports_the_over_run_rather_than_clamping_it() {
320        // This test used to assert the opposite, and the reversal is deliberate
321        // rather than a relaxation, so the reason is written down here.
322        //
323        // `aria-valuenow` was `done.min(total)`. That is a number worked out
324        // from two others, so it could not be a residual stand-in: one
325        // request's clamped value baked into the template. Moving the width
326        // into the stylesheet does not reach it, because a stylesheet cannot
327        // write an attribute, this was the last derivation in the emitter.
328        //
329        // The cost is real and accepted: on an over-run the value sits above
330        // the maximum, which ARIA calls out of range. The alternative is the
331        // renderer deciding a screen-reader user should not be told what the
332        // sighted reader can see, and `aria-label` carries both true numbers
333        // either way (pinned by the test above).
334        let html = meter_html(&Meter::new(45, 30), &Emit::default());
335        assert!(html.contains(r#"aria-valuenow="45""#), "{html}");
336        assert!(html.contains(r#"aria-valuemax="30""#), "{html}");
337
338        // A bar that did not run over reports exactly what it always did, so
339        // the change is confined to the case that was being misreported.
340        let under = meter_html(&Meter::new(3, 7), &Emit::default());
341        assert!(under.contains(r#"aria-valuenow="3""#), "{under}");
342        assert!(under.contains(r#"aria-valuemax="7""#), "{under}");
343    }
344
345    #[test]
346    fn an_untoned_bar_emits_no_tone_attribute() {
347        // `progress_rules` styles the untoned bar with `--action` on the bare
348        // class. A `data-tone="content-muted"` here would match no rule.
349        let plain = meter_html(&Meter::new(1, 2), &Emit::default());
350        assert!(!plain.contains("data-tone"));
351
352        let toned = meter_html(&Meter::new(1, 2).tone(Tone::Danger), &Emit::default());
353        assert!(toned.contains(r#"data-tone="danger""#));
354    }
355
356    #[test]
357    fn an_empty_set_renders_an_empty_trough() {
358        // Sayable, so it has to be emittable. The emitter no longer divides at
359        // all, so the zero case cannot fault here; what it has to get right is
360        // that the RULE survives it, which `the_progress_trough_has_a_height_
361        // the_fill_fills` pins by reading the emitted `max(..., 1)` divisor.
362        let html = meter_html(&Meter::new(0, 0), &Emit::default());
363        assert!(html.contains("--meter-done: 0; --meter-total: 0"), "{html}");
364        assert!(html.contains(r#"aria-valuemax="0""#));
365    }
366
367    #[test]
368    fn the_label_is_escaped_like_every_other_string() {
369        // It arrives from the app the same as a field label does.
370        let html = meter_html(&Meter::new(1, 2).label("a & b"), &Emit::default());
371        assert!(html.contains("a &amp; b"));
372        assert!(!html.contains("a & b"));
373    }
374
375    #[test]
376    fn the_prefix_reaches_both_classes() {
377        // A prefixed build claims its own names, and the fill is a descendant
378        // selector in the emitted CSS: miss one and the rule stops matching.
379        let opts = Emit {
380            class_prefix: "mo-",
381            ..Emit::default()
382        };
383        let html = meter_html(&Meter::new(1, 2), &opts);
384        assert!(html.contains(r#"class="mo-progress""#));
385        assert!(html.contains(r#"class="mo-progress-fill""#));
386    }
387}