Skip to main content

max / makeover-webview

19.4 KB · 499 lines History Blame Raw
1 //! A dimension a set is narrowed by, rendered as a list of values.
2 //!
3 //! The fourth phase-B emitter, beside [`form`](crate::form),
4 //! [`list`](crate::list) and [`meter`](crate::meter). Same split as those: this
5 //! owns the structure of the panel and the app owns the routes. A value's
6 //! identifier leaves in `data-facet-value`, which is the hook an app wires its
7 //! own request onto, exactly as [`list`](crate::list) writes `data-column` and
8 //! lets the app decide what pressing a heading calls.
9 //!
10 //! # Why this is markup and not only CSS
11 //!
12 //! Phase A's rule is that an app keeps its markup and gains the classes, and
13 //! that rule works because the markup already existed. Here it mostly does not:
14 //! a facet panel is the shape MNW's discover page reached by writing a tick box
15 //! and a chevron per row because filtering and browsing were two mechanisms, and
16 //! the whole point of [`makeover_layout::Selecting::Subtree`] is that they stop
17 //! being two. There is nothing to keep.
18 //!
19 //! # The one thing drawn that no flat control has
20 //!
21 //! An exclude affordance beside each value, in a subtree facet only. It is a
22 //! visible control rather than a modifier or a long press, and that was ruled
23 //! rather than chosen here: a gesture a terminal cannot express is a gesture
24 //! half the renderers leave out, and an affordance nothing teaches is one users
25 //! do not find. The glyph is this renderer's pick, and it takes the standing
26 //! preference for the heavier, simpler mark.
27 //!
28 //! # What the depth does and does not do
29 //!
30 //! `--facet-depth` carries the tree level as a number, and the indent rule
31 //! multiplies it by one geometry step. That keeps the whole tree one flat list
32 //! in the DOM rather than nested lists, which is what lets a renderer draw the
33 //! same description as a breadcrumb or a column of panes without the markup
34 //! disagreeing. It is not a size: the number is the level, and the step is
35 //! `makeover-geometry`'s.
36
37 use crate::form::escape_into;
38 use crate::reset::Reset;
39 use crate::{Emit, class, push_class};
40 use makeover_layout::{Depth, Facet, FacetValue, Selecting, Standing};
41 use std::fmt::Write as _;
42
43 /// The classes this module can put in markup.
44 ///
45 /// [`crate::list::ROW_PART_CLASSES`]' obligation, and it exists for the same
46 /// reason: every class here is also ruled by [`facet_rules`], so the vocabulary
47 /// seal picks them up from the generated sheet, and this list is what a test
48 /// checks that against.
49 pub const FACET_CLASSES: &[&str] = &[
50 "facet",
51 "facet-name",
52 "facet-values",
53 "facet-value",
54 "facet-take",
55 "facet-count",
56 "facet-prune",
57 ];
58
59 /// The name a selection mode goes by in `data-selecting`.
60 ///
61 /// An attribute rather than a class, for `data-selector`'s reason on a selector
62 /// group: the mode changes what the panel *means*, not how one value is
63 /// painted, and a class there would read as the styling hook the value's class
64 /// actually is.
65 #[must_use]
66 pub const fn selecting_name(mode: Selecting) -> &'static str {
67 match mode {
68 Selecting::OneOf => "one-of",
69 Selecting::AnyOf => "any-of",
70 Selecting::Range => "range",
71 Selecting::Text => "text",
72 Selecting::Subtree => "subtree",
73 // A mode added to the description since this renderer was built.
74 // `Selecting` is `#[non_exhaustive]`, and an unknown mode reads as the
75 // one that offers no values and prunes nothing: drawing a value list
76 // for a mode whose values mean something else is the worse mistake.
77 _ => "unknown",
78 }
79 }
80
81 /// The name a standing goes by in `data-standing`.
82 #[must_use]
83 pub const fn standing_name(standing: Standing) -> &'static str {
84 match standing {
85 Standing::Open => "open",
86 Standing::Taken => "taken",
87 Standing::Inherited => "inherited",
88 Standing::Pruned => "pruned",
89 // Unknown reads as open, which is the state that claims nothing about
90 // the set.
91 _ => "open",
92 }
93 }
94
95 /// A facet as a labelled list of values.
96 ///
97 /// ```
98 /// use makeover_layout::{Facet, FacetValue, Selecting, Standing};
99 /// use makeover_webview::{Emit, facet::facet_html};
100 ///
101 /// let values = [
102 /// FacetValue::new("music", "Music")
103 /// .standing(Standing::Taken)
104 /// .counted(128)
105 /// .at(0, true),
106 /// FacetValue::new("music/synths", "Synths").at(1, false),
107 /// ];
108 /// let facet = Facet::new("Tag", Selecting::Subtree, &values);
109 /// let html = facet_html(&facet, &Emit::default());
110 ///
111 /// assert!(html.contains(r#"data-selecting="subtree""#));
112 /// assert!(html.contains(r#"data-facet-value="music/synths""#));
113 /// // A subtree is the one mode that offers a way to prune a branch out.
114 /// assert!(html.contains("facet-prune"));
115 /// ```
116 ///
117 /// A [`Selecting::Text`] or [`Selecting::Range`] facet lists nothing, so what
118 /// comes back is the panel and its name with an empty list inside it. That is
119 /// deliberate rather than an empty string: the app puts its own box in the
120 /// panel, and the panel is what gives the box the group label and the shared
121 /// geometry.
122 #[must_use]
123 pub fn facet_html(facet: &Facet<'_>, opts: &Emit) -> String {
124 let mut html = String::new();
125 facet_html_into(facet, opts, &mut html);
126 html
127 }
128
129 /// A facet, written into a buffer the caller already has.
130 ///
131 /// [`facet_html`]'s streaming form, byte-identical to it.
132 pub fn facet_html_into(facet: &Facet<'_>, opts: &Emit, out: &mut String) {
133 out.push_str("<div class=\"");
134 push_class(out, "facet", opts);
135 out.push_str("\" role=\"group\" data-selecting=\"");
136 out.push_str(selecting_name(facet.mode));
137 // The gutter an indenting renderer reserves before it draws anything, so
138 // the panel does not widen as deeper values arrive. "First paint is final
139 // paint" applied to a tree.
140 let _ = write!(out, "\" style=\"--facet-reach: {}\">", facet.reach());
141
142 out.push_str("<p class=\"");
143 push_class(out, "facet-name", opts);
144 out.push_str("\">");
145 escape_into(facet.name, out);
146 out.push_str("</p>");
147
148 out.push_str("<ul class=\"");
149 push_class(out, "facet-values", opts);
150 out.push_str("\">");
151 if facet.mode.offers_values() {
152 for value in facet.values {
153 value_html_into(facet, value, opts, out);
154 }
155 }
156 out.push_str("</ul></div>");
157 }
158
159 fn value_html_into(facet: &Facet<'_>, value: &FacetValue<'_>, opts: &Emit, out: &mut String) {
160 out.push_str("<li class=\"");
161 push_class(out, "facet-value", opts);
162 out.push_str("\" data-standing=\"");
163 out.push_str(standing_name(value.standing));
164 let _ = write!(out, "\" style=\"--facet-depth: {}\">", value.depth);
165
166 out.push_str("<button type=\"button\" class=\"");
167 push_class(out, "facet-take", opts);
168 out.push_str("\" data-facet-value=\"");
169 escape_into(value.value, out);
170 // `aria-pressed` and not `aria-selected`: the values are toggles over a set
171 // rather than options in a listbox, and an inherited value is pressed in
172 // fact even though nobody pressed it. That is `Standing::in_force`, which
173 // exists so a renderer does not have to know which of the two it has.
174 out.push_str("\" aria-pressed=\"");
175 out.push_str(if value.standing.in_force() {
176 "true\""
177 } else {
178 "false\""
179 });
180 // A branch that opens says so, so a reader is told there is more before
181 // pressing rather than after.
182 if value.branching {
183 out.push_str(" aria-expanded=\"");
184 out.push_str(if value.standing.in_force() {
185 "true\""
186 } else {
187 "false\""
188 });
189 }
190 out.push('>');
191 escape_into(value.label, out);
192
193 // Absent rather than zero when it was not measured, which is the
194 // description's own position: a written zero reads as "none of them".
195 if let Some(count) = value.count {
196 out.push_str("<span class=\"");
197 push_class(out, "facet-count", opts);
198 let _ = write!(out, "\">{count}</span>");
199 }
200 out.push_str("</button>");
201
202 if facet.mode.prunes() {
203 out.push_str("<button type=\"button\" class=\"");
204 push_class(out, "facet-prune", opts);
205 out.push_str("\" data-facet-value=\"");
206 escape_into(value.value, out);
207 out.push_str("\" aria-pressed=\"");
208 out.push_str(if value.standing == Standing::Pruned {
209 "true\""
210 } else {
211 "false\""
212 });
213 // The accessible name is built here rather than described, for
214 // `meter_text`'s reason: a tooltip wants a sentence and a terminal
215 // wants a glyph, and a description shipping either would choose for
216 // both.
217 out.push_str(" aria-label=\"Exclude ");
218 escape_into(value.label, out);
219 // The heavier, simpler mark. It is the glyph and not a class, because a
220 // renderer that swaps it is not changing what the control means.
221 out.push_str("\">\u{2715}</button>");
222 }
223
224 out.push_str("</li>");
225 }
226
227 /// The rules for a facet panel.
228 ///
229 /// Depth comes from the description: a value at rest sits as
230 /// [`Depth::Flat`] and a taken one is held in, which is
231 /// [`makeover_layout::Selector::chosen`]'s shape for a segment and is the same
232 /// sentence — this one is picked, so it is pressed. Nothing here states a
233 /// colour or a size; the indent is a count multiplied by a geometry step, and
234 /// the step is the one variable this crate is allowed to read.
235 pub(crate) fn facet_rules(opts: &Emit) -> String {
236 let mut css = String::new();
237 let panel = class("facet", opts);
238 let name = class("facet-name", opts);
239 let values = class("facet-values", opts);
240 let value = class("facet-value", opts);
241 let take = class("facet-take", opts);
242 let count = class("facet-count", opts);
243 let prune = class("facet-prune", opts);
244
245 // The name of the dimension. A caption, and captions are legitimately
246 // muted: it was never going to answer a press.
247 let _ = writeln!(css, ".{name} {{\n color: var(--content-muted);\n}}");
248
249 // The list gives back what a `<ul>` brought, the same ask `row_rules`
250 // makes: a described set of tags is not a bulleted list and rendered as one
251 // because nothing said otherwise.
252 css.push_str(&Reset::BULLETS.rule(&format!(".{values}")));
253
254 // The indent is the level times one step. `--facet-depth` is written per
255 // value and `--facet-reach` per panel; the panel one reserves the gutter so
256 // nothing moves as deeper values arrive.
257 let _ = writeln!(
258 css,
259 ".{value} {{\n padding-inline-start: calc(var(--facet-depth, 0) * var(--space-tight, 0.5rem));\n}}"
260 );
261
262 let _ = writeln!(
263 css,
264 ".{panel} {{\n min-inline-size: calc(var(--facet-reach, 0) * var(--space-tight, 0.5rem));\n}}"
265 );
266
267 // The value's own control. Flat at rest, held in when it is in force, and
268 // that is the segmented control's sentence rather than a new one.
269 //
270 // Flat states nothing, no fill and no bevel, so `depth_rule` emitted an
271 // empty string and saying it was the whole of what this arm did. Where an
272 // app hands makeover the cascade with `revert-layer`, an empty layer rolls
273 // the handoff past makeover to the app's own bare `button` rule and the
274 // value renders raised, with `[aria-pressed="true"]` its only true state.
275 // Flat here has to be said out loud, which is what the reset is for.
276 css.push_str(&Reset::FLAT_BUTTON.rule(&format!(".{take}")));
277 css.push_str(&crate::interactive_rules(&take, Depth::Flat, opts));
278 css.push_str(&crate::depth_rule(
279 &format!("{take}[aria-pressed=\"true\"]"),
280 Depth::Well,
281 ));
282
283 // A pruned branch reads one step back and stays live: pressing it takes the
284 // prune off, so it may not wear `content-muted`. `Standing::intent` is
285 // where that is decided.
286 let _ = writeln!(
287 css,
288 ".{value}[data-standing=\"pruned\"] .{take} {{\n color: var(--{});\n}}",
289 Standing::Pruned.intent()
290 );
291
292 // Inherited is in force and was not chosen. It reads as the thing itself,
293 // like a taken value, and the difference is carried by the attribute for
294 // whoever wants it rather than by a colour claiming something.
295 let _ = writeln!(css, ".{count} {{\n color: var(--content-muted);\n}}");
296
297 // Same withdrawal as the take, for the same reason.
298 css.push_str(&Reset::FLAT_BUTTON.rule(&format!(".{prune}")));
299 css.push_str(&crate::interactive_rules(&prune, Depth::Flat, opts));
300 css.push_str(&crate::depth_rule(
301 &format!("{prune}[aria-pressed=\"true\"]"),
302 Depth::Well,
303 ));
304
305 css
306 }
307
308 #[cfg(test)]
309 mod tests {
310 use super::*;
311
312 fn tag_values() -> [FacetValue<'static>; 3] {
313 [
314 FacetValue::new("music", "Music")
315 .standing(Standing::Taken)
316 .counted(128)
317 .at(0, true),
318 FacetValue::new("music/synths", "Synths")
319 .standing(Standing::Inherited)
320 .at(1, false),
321 FacetValue::new("music/drums", "Drums")
322 .standing(Standing::Pruned)
323 .at(1, false),
324 ]
325 }
326
327 #[test]
328 fn a_streamed_facet_is_the_facet_the_other_form_returns() {
329 let opts = Emit {
330 class_prefix: "mk-",
331 ..Emit::default()
332 };
333 let values = tag_values();
334 for facet in [
335 Facet::new("Tag", Selecting::Subtree, &values),
336 Facet::new("Type", Selecting::AnyOf, &values),
337 Facet::new("Search", Selecting::Text, &[]),
338 ] {
339 let mut streamed = String::new();
340 facet_html_into(&facet, &opts, &mut streamed);
341 assert_eq!(streamed, facet_html(&facet, &opts));
342 }
343 }
344
345 #[test]
346 fn only_a_subtree_draws_an_exclude_affordance() {
347 // The one control a flat facet has no use for: excluding a value there
348 // is the same fact as not picking it.
349 let values = tag_values();
350 let subtree = facet_html(
351 &Facet::new("Tag", Selecting::Subtree, &values),
352 &Emit::default(),
353 );
354 assert!(subtree.contains("facet-prune"));
355 assert!(subtree.contains(r#"aria-label="Exclude Drums""#));
356
357 let flat = facet_html(
358 &Facet::new("Type", Selecting::AnyOf, &values),
359 &Emit::default(),
360 );
361 assert!(!flat.contains("facet-prune"));
362 }
363
364 #[test]
365 fn an_inherited_value_reads_as_pressed_without_having_been_pressed() {
366 // The distinction `Standing` has four members for. A renderer given a
367 // bool marks every descendant of a taken branch or marks none, and both
368 // are wrong on screen.
369 let values = tag_values();
370 let html = facet_html(
371 &Facet::new("Tag", Selecting::Subtree, &values),
372 &Emit::default(),
373 );
374 let synths = html
375 .split("<li")
376 .find(|chunk| chunk.contains("music/synths"))
377 .expect("the inherited value");
378 assert!(synths.contains(r#"data-standing="inherited""#));
379 assert!(synths.contains(r#"aria-pressed="true""#));
380
381 let drums = html
382 .split("<li")
383 .find(|chunk| chunk.contains("music/drums"))
384 .expect("the pruned value");
385 // Pruned is a decision and it is not in force, so the take control is
386 // not pressed and the prune control is.
387 assert!(drums.contains(r#"data-standing="pruned""#));
388 assert!(drums.contains(r#"aria-pressed="false""#));
389 assert!(drums.contains(r#"aria-label="Exclude Drums""#));
390 }
391
392 #[test]
393 fn a_mode_that_lists_nothing_still_renders_its_panel() {
394 // The app puts its own box in; the panel is what gives it the group
395 // label and the shared geometry.
396 let html = facet_html(
397 &Facet::new("Search", Selecting::Text, &[]),
398 &Emit::default(),
399 );
400 assert!(html.contains("facet-name"));
401 assert!(html.contains(r#"data-selecting="text""#));
402 assert!(!html.contains("facet-take"));
403 }
404
405 #[test]
406 fn an_unmeasured_count_emits_no_number_at_all() {
407 // A written zero reads as "none of them", which is a different claim
408 // from "not counted".
409 let values = [FacetValue::of("Ambient")];
410 let html = facet_html(
411 &Facet::new("Tag", Selecting::AnyOf, &values),
412 &Emit::default(),
413 );
414 assert!(!html.contains("facet-count"));
415
416 let counted = [FacetValue::of("Ambient").counted(0)];
417 let html = facet_html(
418 &Facet::new("Tag", Selecting::AnyOf, &counted),
419 &Emit::default(),
420 );
421 assert!(html.contains(">0</span>"));
422 }
423
424 #[test]
425 fn the_gutter_is_reserved_from_the_deepest_value_before_anything_is_drawn() {
426 // "First paint is final paint" applied to a tree: a gutter widened as
427 // deeper values arrive is the reflow that rule forbids.
428 let values = tag_values();
429 let html = facet_html(
430 &Facet::new("Tag", Selecting::Subtree, &values),
431 &Emit::default(),
432 );
433 assert!(html.contains("--facet-reach: 1"));
434 assert!(html.contains("--facet-depth: 0"));
435 assert!(html.contains("--facet-depth: 1"));
436 }
437
438 #[test]
439 fn labels_and_identifiers_are_escaped_like_every_other_string() {
440 // Both arrive from the app, and a tag path is user-supplied on a system
441 // where a user names their own tags.
442 let values = [FacetValue::new("a&b", "A & B")];
443 let html = facet_html(
444 &Facet::new("T<ag>", Selecting::AnyOf, &values),
445 &Emit::default(),
446 );
447 assert!(html.contains("A &amp; B"));
448 assert!(html.contains(r#"data-facet-value="a&amp;b""#));
449 assert!(html.contains("T&lt;ag&gt;"));
450 assert!(!html.contains("<ag>"));
451 }
452
453 #[test]
454 fn a_value_reads_flat_before_it_is_touched() {
455 // The reason the arm exists: `Depth::Flat` declares nothing, so an app
456 // handing makeover the cascade with `revert-layer` rolled the handoff
457 // past an empty layer onto its own bare `button` rule and the value
458 // came out raised. Both controls say flat out loud now, and the states
459 // below it are what a press is allowed to change.
460 let css = facet_rules(&Emit::default());
461 for name in ["facet-take", "facet-prune"] {
462 assert!(
463 css.contains(&format!(
464 ".{name} {{\n background: none;\n border: none;\n box-shadow: none;\n}}"
465 )),
466 "{name} is not withdrawn: {css}"
467 );
468 }
469 }
470
471 #[test]
472 fn every_class_this_module_emits_is_one_the_stylesheet_rules() {
473 // `ROW_PART_CLASSES`' obligation: a class this crate writes and the
474 // sheet does not rule is invisible to the dead-vocabulary seal.
475 let names = crate::vocabulary::names(&Emit::default());
476 for name in FACET_CLASSES {
477 assert!(
478 names.contains(&crate::class(name, &Emit::default())),
479 "{name} is not in the vocabulary"
480 );
481 }
482 }
483
484 #[test]
485 fn the_prefix_reaches_every_class_in_the_markup() {
486 // A prefixed build claims its own names, and the emitted CSS selects
487 // descendants: miss one and the rule stops matching.
488 let opts = Emit {
489 class_prefix: "mo-",
490 ..Emit::default()
491 };
492 let values = tag_values();
493 let html = facet_html(&Facet::new("Tag", Selecting::Subtree, &values), &opts);
494 for name in FACET_CLASSES {
495 assert!(html.contains(&format!("mo-{name}")), "{name} is unprefixed");
496 }
497 }
498 }
499