Skip to main content

max / makeover-webview

15.0 KB · 391 lines History Blame Raw
1 //! A run of magnitudes against one axis, rendered as bars.
2 //!
3 //! [`meter`](crate::meter)'s neighbour and its opposite in one respect: a meter
4 //! draws one proportion and computes the width from the pair it is handed, and
5 //! this draws a series and computes nothing. Both numbers reach the markup as
6 //! they were given, and the division happens in CSS.
7 //!
8 //! # Why the arithmetic is in the stylesheet
9 //!
10 //! Not taste, and not an optimisation. `quasi-declare` derives a compiled
11 //! template by rendering a screen with stand-in values and keeping the bytes
12 //! that no request reaches; a number the description HANDS a renderer is found
13 //! in that render and becomes a hole, and a number the renderer WORKS OUT from
14 //! two of them is printed as its arithmetic, leaves no stand-in to find, and is
15 //! baked into the template as a constant. `quasi_router::stage::number_at` says
16 //! so in as many words.
17 //!
18 //! So a chart drawn from a width this crate computed could be described and
19 //! could not be compiled, which for MNW's revenue chart is the difference
20 //! between a screen on the seam and the one screen left off it.
21 //! `--value` and `--most` are printed with `{}` and reach the markup as
22 //! themselves, and `chart_rules` divides them where a browser can.
23 //!
24 //! It costs nothing and reads better: the DOM carries the two real numbers
25 //! rather than a percentage with nothing behind it, which is
26 //! [`makeover_layout::Chart`]'s own argument for carrying the pair.
27
28 use crate::form::escape_into;
29 use crate::{Depth, Emit, class, depth_rule, gated, hover_condition, push_class};
30 use makeover_layout::{Bar, Chart, Intent, Tone};
31 use std::fmt::Write as _;
32
33 /// Every class this module can put in markup.
34 ///
35 /// [`crate::facet::FACET_CLASSES`]' obligation, and the list is what keeps the
36 /// scraped vocabulary true if a rule goes away.
37 pub const CHART_CLASSES: &[&str] = &[
38 "chart",
39 "chart-bars",
40 "chart-bar-col",
41 "chart-bar",
42 "chart-bar-label",
43 ];
44
45 /// What a bar says when a pointer rests on it, or nothing.
46 ///
47 /// The reading and the note, in that order, joined the way the description did
48 /// not: [`Bar::reading`] and [`Bar::note`] arrive worded separately so a
49 /// terminal at one line and a tooltip can want different sentences, which is
50 /// [`crate::meter::meter_text`]'s split exactly.
51 ///
52 /// The place on the axis is deliberately not in here. It is drawn under the bar
53 /// as its own label, so repeating it in the tooltip is the readout arguing with
54 /// itself.
55 #[must_use]
56 pub fn bar_text(bar: &Bar<'_>) -> Option<String> {
57 match (bar.reading, bar.note) {
58 (Some(reading), Some(note)) => Some(format!("{reading} / {note}")),
59 (Some(only), None) | (None, Some(only)) => Some(only.to_string()),
60 (None, None) => None,
61 }
62 }
63
64 /// A chart as a run of bars, written into a buffer the caller already has.
65 ///
66 /// The bars arrive as an iterator rather than a slice so a caller holding owned
67 /// bars can map them through without building a second `Vec`, which is how
68 /// `quasi-webview` holds a `Vec<screen::Bar>` and this wants
69 /// [`makeover_layout::Bar`].
70 ///
71 /// An empty axis draws its container and no bars. A chart over nothing is
72 /// sayable on purpose -- see [`Chart::is_empty`] -- and drawing the frame says
73 /// so on screen, where dividing by the axis would put `NaN` in a length.
74 pub fn chart_html_into<'a>(
75 chart: &Chart<'_>,
76 bars: impl IntoIterator<Item = Bar<'a>>,
77 opts: &Emit,
78 out: &mut String,
79 ) {
80 emit_chart(chart, bars, opts, out, None);
81 }
82
83 /// A chart, saying where each bar landed.
84 ///
85 /// Byte-identical to [`chart_html_into`], and it appends one entry to `placed`
86 /// per bar, in order: the offsets in `out` between which that bar's whole
87 /// column was written. See [`crate::list::cells_html_placed`], which exists for
88 /// the same reason and says it at length: a caller compiling this markup into a
89 /// template has to know which bytes one bar produced, and the writer is the
90 /// only source for that which cannot be wrong.
91 pub fn chart_html_placed<'a>(
92 chart: &Chart<'_>,
93 bars: impl IntoIterator<Item = Bar<'a>>,
94 opts: &Emit,
95 out: &mut String,
96 placed: &mut Vec<core::ops::Range<usize>>,
97 ) {
98 emit_chart(chart, bars, opts, out, Some(placed));
99 }
100
101 fn emit_chart<'a>(
102 chart: &Chart<'_>,
103 bars: impl IntoIterator<Item = Bar<'a>>,
104 opts: &Emit,
105 out: &mut String,
106 mut placed: Option<&mut Vec<core::ops::Range<usize>>>,
107 ) {
108 out.push_str("<div class=\"");
109 push_class(out, "chart", opts);
110 out.push('"');
111 // The axis, once, on the container the bars read it from. Stated here and
112 // not per bar because it is one fact about the chart, and a fact repeated
113 // per bar is one the copies can disagree about.
114 let _ = write!(out, " style=\"--most: {}\"", chart.most);
115 if chart.tone != Tone::Neutral {
116 let _ = write!(out, " data-tone=\"{}\"", chart.tone.token());
117 }
118 // `role="img"` only where there is a name for it. The role tells a screen
119 // reader to announce this as one thing instead of reading the bars, and an
120 // unnamed one announces nothing at all -- worse than the group of labelled
121 // readouts the markup already is. So the role and the name arrive together
122 // or neither does, and a description that wants the chart announced says
123 // what the magnitudes are.
124 if let Some(label) = chart.label {
125 out.push_str(" role=\"img\" aria-label=\"");
126 escape_into(label, out);
127 out.push('"');
128 }
129 out.push('>');
130
131 out.push_str("<div class=\"");
132 push_class(out, "chart-bars", opts);
133 out.push_str("\">");
134
135 for bar in bars {
136 let at = out.len();
137 bar_html_into(&bar, opts, out);
138 if let Some(placed) = placed.as_deref_mut() {
139 placed.push(at..out.len());
140 }
141 }
142
143 out.push_str("</div></div>");
144 }
145
146 /// One bar and its label.
147 ///
148 /// Split out because the loop over bars is the loop a compiled template holds,
149 /// so what one pass emits is worth being able to read on its own.
150 fn bar_html_into(bar: &Bar<'_>, opts: &Emit, out: &mut String) {
151 out.push_str("<div class=\"");
152 push_class(out, "chart-bar-col", opts);
153 out.push('"');
154 if let Some(text) = bar_text(bar) {
155 out.push_str(" data-tooltip=\"");
156 escape_into(&text, out);
157 out.push('"');
158 }
159 out.push('>');
160
161 out.push_str("<div class=\"");
162 push_class(out, "chart-bar", opts);
163 // The magnitude as it was handed over. See the module header for why this
164 // is not a width.
165 let _ = write!(out, "\" style=\"--value: {}\"></div>", bar.value);
166
167 out.push_str("<div class=\"");
168 push_class(out, "chart-bar-label", opts);
169 out.push_str("\">");
170 escape_into(bar.at, out);
171 out.push_str("</div></div>");
172 }
173
174 /// A chart as a returned string.
175 #[must_use]
176 pub fn chart_html<'a>(
177 chart: &Chart<'_>,
178 bars: impl IntoIterator<Item = Bar<'a>>,
179 opts: &Emit,
180 ) -> String {
181 let mut html = String::new();
182 chart_html_into(chart, bars, opts, &mut html);
183 html
184 }
185
186 /// What a chart looks like.
187 ///
188 /// # What is emitted and what is deferred
189 ///
190 /// `progress_rules`' rule, applied: the tones are emitted and the sizes are
191 /// not. This crate names no magnitude -- that is `makeover-geometry`'s -- so
192 /// every length here is a custom property with a default an adopter overrides
193 /// once, exactly as `--awaiting-bar` is. How tall a chart stands is the app's:
194 /// MNW's revenue chart is 200px and a sparkline beside a figure is 24px.
195 ///
196 /// The height of a BAR is the one length that has to be here, and it is not a
197 /// magnitude: it is the two numbers the markup carries, divided. That division
198 /// is the half of the contract the markup cannot state on its own.
199 ///
200 /// `max(var(--most), 1)` rather than a guard: an axis of zero is sayable, and
201 /// dividing by it makes the whole declaration invalid at computed-value time,
202 /// which drops the height to `auto` -- in a flex column, a bar of full height.
203 /// Clamping the divisor draws every bar at nothing, which is what an empty axis
204 /// means.
205 fn chart_rules(opts: &Emit) -> String {
206 let chart = class("chart", opts);
207 let bars = class("chart-bars", opts);
208 let col = class("chart-bar-col", opts);
209 let bar = class("chart-bar", opts);
210 let label = class("chart-bar-label", opts);
211
212 let mut css = depth_rule(&chart, Depth::Well);
213
214 let _ = writeln!(
215 css,
216 ".{bars} {{\n display: flex;\n align-items: flex-end;\n \
217 gap: var(--chart-gap, 2px);\n height: var(--chart-height, 200px);\n}}"
218 );
219 let _ = writeln!(
220 css,
221 ".{col} {{\n flex: 1;\n display: flex;\n flex-direction: column;\n \
222 align-items: center;\n min-width: 0;\n position: relative;\n}}"
223 );
224 let _ = writeln!(
225 css,
226 ".{bar} {{\n width: 100%;\n background: var(--action);\n \
227 min-height: var(--chart-bar-least, 2px);\n \
228 height: calc(var(--value, 0) * 100% / max(var(--most, 1), 1));\n}}"
229 );
230 // A chart can be saying something, the same way a bar can. `progress_rules`
231 // emits the tones for that reason and this follows it.
232 for tone in [Tone::Info, Tone::Success, Tone::Warning, Tone::Danger] {
233 let _ = writeln!(
234 css,
235 ".{chart}[data-tone=\"{0}\"] .{bar} {{\n background: var(--{0});\n}}",
236 tone.token()
237 );
238 }
239 let _ = writeln!(
240 css,
241 ".{label} {{\n color: var(--content-muted);\n max-width: 100%;\n \
242 white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n}}"
243 );
244
245 // The readout, revealed from the attribute the markup already carries.
246 //
247 // Gated, because it is a hover state and this crate asks
248 // `makeover-touch` whether a hover state exists rather than assuming one.
249 // Keyed on the attribute rather than on a class, so a bar with nothing to
250 // say reveals no empty bubble.
251 //
252 // Centred with `inset-inline: 0` and an auto margin rather than with a
253 // half-width translate: the translate is the idiom and it names a
254 // magnitude, and this does the same job with three keywords.
255 css.push_str(&gated(
256 hover_condition(),
257 &format!(
258 ".{col}[data-tooltip]:hover::before {{\n content: attr(data-tooltip);\n \
259 position: absolute;\n bottom: 100%;\n inset-inline: 0;\n \
260 margin-inline: auto;\n width: max-content;\n \
261 background: var(--surface-raised);\n color: var(--content);\n \
262 border: var(--border);\n box-shadow: var(--elevation-overlay);\n \
263 padding: var(--chart-readout-padding, 0.25em 0.5em);\n \
264 white-space: nowrap;\n pointer-events: none;\n}}\n"
265 ),
266 ));
267 css
268 }
269
270 /// The rules, for the stylesheet builder.
271 #[must_use]
272 pub fn rules(opts: &Emit) -> String {
273 chart_rules(opts)
274 }
275
276 #[cfg(test)]
277 mod tests {
278 use super::*;
279
280 fn axis() -> Chart<'static> {
281 Chart::new(6740).label("revenue")
282 }
283
284 /// The two numbers reach the markup as themselves. This is the whole reason
285 /// the member is shaped the way it is, so it is asserted rather than
286 /// assumed: a width computed here would compile into a template as a
287 /// constant and serve one request's chart to everybody.
288 #[test]
289 fn both_numbers_are_printed_and_neither_is_divided() {
290 let html = chart_html(
291 &axis(),
292 [Bar::at("Mar 3").of(4210).reading("$42.10").note("3 sales")],
293 &Emit::default(),
294 );
295 assert!(html.contains("--most: 6740"), "{html}");
296 assert!(html.contains("--value: 4210"), "{html}");
297 assert!(
298 !html.contains('%'),
299 "a percentage reached the markup: {html}"
300 );
301 }
302
303 /// The role and the name arrive together or neither does. An unnamed
304 /// `role="img"` announces nothing, which is worse than the labelled
305 /// readouts the markup already is.
306 #[test]
307 fn an_unlabelled_chart_claims_no_role() {
308 let named = chart_html(&axis(), [Bar::at("Mar 3").of(1)], &Emit::default());
309 assert!(
310 named.contains(r#"role="img" aria-label="revenue""#),
311 "{named}"
312 );
313
314 let bare = chart_html(&Chart::new(10), [Bar::at("Mar 3").of(1)], &Emit::default());
315 assert!(!bare.contains("role="), "{bare}");
316 assert!(!bare.contains("aria-label"), "{bare}");
317 }
318
319 /// A bar says both facts or the one it has, and a bar with neither draws no
320 /// tooltip rather than an empty one.
321 #[test]
322 fn a_readout_is_what_the_bar_was_given() {
323 assert_eq!(
324 bar_text(&Bar::at("a").of(1).reading("$1").note("2 sales")),
325 Some("$1 / 2 sales".to_string())
326 );
327 assert_eq!(
328 bar_text(&Bar::at("a").of(1).reading("$1")),
329 Some("$1".to_string())
330 );
331 assert_eq!(
332 bar_text(&Bar::at("a").of(1).note("2 sales")),
333 Some("2 sales".to_string())
334 );
335 assert_eq!(bar_text(&Bar::at("a").of(1)), None);
336
337 let bare = chart_html(&axis(), [Bar::at("Mar 3").of(1)], &Emit::default());
338 assert!(!bare.contains("data-tooltip"), "{bare}");
339 }
340
341 /// Everything a request brings goes through the escaper, in the text and in
342 /// the attribute. A label reaching a chart from a database is why.
343 #[test]
344 fn a_label_and_a_readout_are_escaped() {
345 let html = chart_html(
346 &Chart::new(10).label("a & b"),
347 [Bar::at("<script>").of(5).reading("\"x\"")],
348 &Emit::default(),
349 );
350 assert!(!html.contains("<script>"), "{html}");
351 assert!(html.contains("&lt;script&gt;"), "{html}");
352 assert!(html.contains("a &amp; b"), "{html}");
353 assert!(html.contains("&quot;x&quot;"), "{html}");
354 }
355
356 /// An axis of zero draws its frame and its bars, and the stylesheet is what
357 /// keeps them at nothing. Drawing no frame would be a screen that says
358 /// nothing where it has nothing, which is the empty state's job and not
359 /// this one's.
360 #[test]
361 fn an_empty_axis_draws_rather_than_dividing() {
362 let html = chart_html(&Chart::new(0), [Bar::at("Mar 3").of(0)], &Emit::default());
363 assert!(html.contains("--most: 0"), "{html}");
364 assert!(html.contains("--value: 0"), "{html}");
365 }
366
367 /// The streamed form and the returned one are the same bytes, which is the
368 /// obligation every other emitter here carries.
369 #[test]
370 fn the_streamed_form_is_the_returned_one() {
371 let opts = Emit {
372 class_prefix: "mk-",
373 ..Emit::default()
374 };
375 let bars = [Bar::at("Mar 3").of(4210).reading("$42.10")];
376 let mut streamed = String::new();
377 chart_html_into(&axis(), bars, &opts, &mut streamed);
378 assert_eq!(streamed, chart_html(&axis(), bars, &opts));
379 }
380
381 /// Every class the emitter can write carries a rule, which is what
382 /// `CHART_CLASSES` exists to keep true.
383 #[test]
384 fn every_class_this_module_writes_has_a_rule() {
385 let css = chart_rules(&Emit::default());
386 for name in CHART_CLASSES {
387 assert!(css.contains(&format!(".{name}")), "{name} has no rule");
388 }
389 }
390 }
391