Skip to main content

makeover_webview/
facet.rs

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
37use crate::form::escape_into;
38use crate::reset::Reset;
39use crate::{Emit, class, push_class};
40use makeover_layout::{Depth, Facet, FacetValue, Selecting, Standing};
41use 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.
49pub 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]
66pub 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]
83pub 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, Nesting, 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(Nesting::at(0), true),
106///     FacetValue::new("music/synths", "Synths").at(Nesting::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]
123pub 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.
132pub 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!(
141        out,
142        "\" {}=\"--facet-reach: {}\">",
143        crate::VARS_ATTR,
144        facet.reach()
145    );
146
147    out.push_str("<p class=\"");
148    push_class(out, "facet-name", opts);
149    out.push_str("\">");
150    escape_into(facet.name, out);
151    out.push_str("</p>");
152
153    out.push_str("<ul class=\"");
154    push_class(out, "facet-values", opts);
155    out.push_str("\">");
156    if facet.mode.offers_values() {
157        for value in facet.values {
158            value_html_into(facet, value, opts, out);
159        }
160    }
161    out.push_str("</ul></div>");
162}
163
164fn value_html_into(facet: &Facet<'_>, value: &FacetValue<'_>, opts: &Emit, out: &mut String) {
165    out.push_str("<li class=\"");
166    push_class(out, "facet-value", opts);
167    out.push_str("\" data-standing=\"");
168    out.push_str(standing_name(value.standing));
169    let _ = write!(
170        out,
171        "\" {}=\"--facet-depth: {}\">",
172        crate::VARS_ATTR,
173        value.depth.level
174    );
175
176    out.push_str("<button type=\"button\" class=\"");
177    push_class(out, "facet-take", opts);
178    out.push_str("\" data-facet-value=\"");
179    escape_into(value.value, out);
180    // `aria-pressed` and not `aria-selected`: the values are toggles over a set
181    // rather than options in a listbox, and an inherited value is pressed in
182    // fact even though nobody pressed it. That is `Standing::in_force`, which
183    // exists so a renderer does not have to know which of the two it has.
184    out.push_str("\" aria-pressed=\"");
185    out.push_str(if value.standing.in_force() {
186        "true\""
187    } else {
188        "false\""
189    });
190    // A branch that opens says so, so a reader is told there is more before
191    // pressing rather than after.
192    if value.branching {
193        out.push_str(" aria-expanded=\"");
194        out.push_str(if value.standing.in_force() {
195            "true\""
196        } else {
197            "false\""
198        });
199    }
200    out.push('>');
201    escape_into(value.label, out);
202
203    // Absent rather than zero when it was not measured, which is the
204    // description's own position: a written zero reads as "none of them".
205    if let Some(count) = value.count {
206        out.push_str("<span class=\"");
207        push_class(out, "facet-count", opts);
208        let _ = write!(out, "\">{count}</span>");
209    }
210    out.push_str("</button>");
211
212    if facet.mode.prunes() {
213        out.push_str("<button type=\"button\" class=\"");
214        push_class(out, "facet-prune", opts);
215        out.push_str("\" data-facet-value=\"");
216        escape_into(value.value, out);
217        out.push_str("\" aria-pressed=\"");
218        out.push_str(if value.standing == Standing::Pruned {
219            "true\""
220        } else {
221            "false\""
222        });
223        // The accessible name is built here rather than described, for
224        // `meter_text`'s reason: a tooltip wants a sentence and a terminal
225        // wants a glyph, and a description shipping either would choose for
226        // both.
227        out.push_str(" aria-label=\"Exclude ");
228        escape_into(value.label, out);
229        // The heavier, simpler mark. It is the glyph and not a class, because a
230        // renderer that swaps it is not changing what the control means.
231        out.push_str("\">\u{2715}</button>");
232    }
233
234    out.push_str("</li>");
235}
236
237/// The rules for a facet panel.
238///
239/// Depth comes from the description: a value at rest sits as
240/// [`Depth::Flat`] and a taken one is held in, which is
241/// [`makeover_layout::Selector::chosen`]'s shape for a segment and is the same
242/// sentence — this one is picked, so it is pressed. Nothing here states a
243/// colour or a size; the indent is a count multiplied by a geometry step, and
244/// the step is the one variable this crate is allowed to read.
245pub(crate) fn facet_rules(opts: &Emit) -> String {
246    let mut css = String::new();
247    let panel = class("facet", opts);
248    let name = class("facet-name", opts);
249    let values = class("facet-values", opts);
250    let value = class("facet-value", opts);
251    let take = class("facet-take", opts);
252    let count = class("facet-count", opts);
253    let prune = class("facet-prune", opts);
254
255    // The name of the dimension. A caption, and captions are legitimately
256    // muted: it was never going to answer a press.
257    let _ = writeln!(css, ".{name} {{\n    color: var(--content-muted);\n}}");
258
259    // The list gives back what a `<ul>` brought, the same ask `row_rules`
260    // makes: a described set of tags is not a bulleted list and rendered as one
261    // because nothing said otherwise.
262    css.push_str(&Reset::BULLETS.rule(&format!(".{values}")));
263
264    // The indent is the level times one step. `--facet-depth` is written per
265    // value and `--facet-reach` per panel; the panel one reserves the gutter so
266    // nothing moves as deeper values arrive.
267    let _ = writeln!(
268        css,
269        ".{value} {{\n    padding-inline-start: calc(var(--facet-depth, 0) * var(--space-tight, 0.5rem));\n}}"
270    );
271
272    let _ = writeln!(
273        css,
274        ".{panel} {{\n    min-inline-size: calc(var(--facet-reach, 0) * var(--space-tight, 0.5rem));\n}}"
275    );
276
277    // The value's own control. Flat at rest, held in when it is in force, and
278    // that is the segmented control's sentence rather than a new one.
279    //
280    // Flat states nothing, no fill and no bevel, so `depth_rule` emitted an
281    // empty string and saying it was the whole of what this arm did. Where an
282    // app hands makeover the cascade with `revert-layer`, an empty layer rolls
283    // the handoff past makeover to the app's own bare `button` rule and the
284    // value renders raised, with `[aria-pressed="true"]` its only true state.
285    // Flat here has to be said out loud, which is what the reset is for.
286    css.push_str(&Reset::FLAT_BUTTON.rule(&format!(".{take}")));
287    css.push_str(&crate::interactive_rules(&take, Depth::Flat, opts));
288    css.push_str(&crate::depth_rule(
289        &format!("{take}[aria-pressed=\"true\"]"),
290        Depth::Well,
291    ));
292
293    // A pruned branch reads one step back and stays live: pressing it takes the
294    // prune off, so it may not wear `content-muted`. `Standing::intent` is
295    // where that is decided.
296    let _ = writeln!(
297        css,
298        ".{value}[data-standing=\"pruned\"] .{take} {{\n    color: var(--{});\n}}",
299        Standing::Pruned.intent()
300    );
301
302    // Inherited is in force and was not chosen. It reads as the thing itself,
303    // like a taken value, and the difference is carried by the attribute for
304    // whoever wants it rather than by a colour claiming something.
305    let _ = writeln!(css, ".{count} {{\n    color: var(--content-muted);\n}}");
306
307    // Same withdrawal as the take, for the same reason.
308    css.push_str(&Reset::FLAT_BUTTON.rule(&format!(".{prune}")));
309    css.push_str(&crate::interactive_rules(&prune, Depth::Flat, opts));
310    css.push_str(&crate::depth_rule(
311        &format!("{prune}[aria-pressed=\"true\"]"),
312        Depth::Well,
313    ));
314
315    css
316}
317
318#[cfg(test)]
319mod tests {
320    use super::*;
321    use makeover_layout::Nesting;
322
323    fn tag_values() -> [FacetValue<'static>; 3] {
324        [
325            FacetValue::new("music", "Music")
326                .standing(Standing::Taken)
327                .counted(128)
328                .at(Nesting::at(0), true),
329            FacetValue::new("music/synths", "Synths")
330                .standing(Standing::Inherited)
331                .at(Nesting::at(1), false),
332            FacetValue::new("music/drums", "Drums")
333                .standing(Standing::Pruned)
334                .at(Nesting::at(1), false),
335        ]
336    }
337
338    #[test]
339    fn a_streamed_facet_is_the_facet_the_other_form_returns() {
340        let opts = Emit {
341            class_prefix: "mk-",
342            ..Emit::default()
343        };
344        let values = tag_values();
345        for facet in [
346            Facet::new("Tag", Selecting::Subtree, &values),
347            Facet::new("Type", Selecting::AnyOf, &values),
348            Facet::new("Search", Selecting::Text, &[]),
349        ] {
350            let mut streamed = String::new();
351            facet_html_into(&facet, &opts, &mut streamed);
352            assert_eq!(streamed, facet_html(&facet, &opts));
353        }
354    }
355
356    #[test]
357    fn only_a_subtree_draws_an_exclude_affordance() {
358        // The one control a flat facet has no use for: excluding a value there
359        // is the same fact as not picking it.
360        let values = tag_values();
361        let subtree = facet_html(
362            &Facet::new("Tag", Selecting::Subtree, &values),
363            &Emit::default(),
364        );
365        assert!(subtree.contains("facet-prune"));
366        assert!(subtree.contains(r#"aria-label="Exclude Drums""#));
367
368        let flat = facet_html(
369            &Facet::new("Type", Selecting::AnyOf, &values),
370            &Emit::default(),
371        );
372        assert!(!flat.contains("facet-prune"));
373    }
374
375    #[test]
376    fn an_inherited_value_reads_as_pressed_without_having_been_pressed() {
377        // The distinction `Standing` has four members for. A renderer given a
378        // bool marks every descendant of a taken branch or marks none, and both
379        // are wrong on screen.
380        let values = tag_values();
381        let html = facet_html(
382            &Facet::new("Tag", Selecting::Subtree, &values),
383            &Emit::default(),
384        );
385        let synths = html
386            .split("<li")
387            .find(|chunk| chunk.contains("music/synths"))
388            .expect("the inherited value");
389        assert!(synths.contains(r#"data-standing="inherited""#));
390        assert!(synths.contains(r#"aria-pressed="true""#));
391
392        let drums = html
393            .split("<li")
394            .find(|chunk| chunk.contains("music/drums"))
395            .expect("the pruned value");
396        // Pruned is a decision and it is not in force, so the take control is
397        // not pressed and the prune control is.
398        assert!(drums.contains(r#"data-standing="pruned""#));
399        assert!(drums.contains(r#"aria-pressed="false""#));
400        assert!(drums.contains(r#"aria-label="Exclude Drums""#));
401    }
402
403    #[test]
404    fn a_mode_that_lists_nothing_still_renders_its_panel() {
405        // The app puts its own box in; the panel is what gives it the group
406        // label and the shared geometry.
407        let html = facet_html(
408            &Facet::new("Search", Selecting::Text, &[]),
409            &Emit::default(),
410        );
411        assert!(html.contains("facet-name"));
412        assert!(html.contains(r#"data-selecting="text""#));
413        assert!(!html.contains("facet-take"));
414    }
415
416    #[test]
417    fn an_unmeasured_count_emits_no_number_at_all() {
418        // A written zero reads as "none of them", which is a different claim
419        // from "not counted".
420        let values = [FacetValue::of("Ambient")];
421        let html = facet_html(
422            &Facet::new("Tag", Selecting::AnyOf, &values),
423            &Emit::default(),
424        );
425        assert!(!html.contains("facet-count"));
426
427        let counted = [FacetValue::of("Ambient").counted(0)];
428        let html = facet_html(
429            &Facet::new("Tag", Selecting::AnyOf, &counted),
430            &Emit::default(),
431        );
432        assert!(html.contains(">0</span>"));
433    }
434
435    #[test]
436    fn the_gutter_is_reserved_from_the_deepest_value_before_anything_is_drawn() {
437        // "First paint is final paint" applied to a tree: a gutter widened as
438        // deeper values arrive is the reflow that rule forbids.
439        let values = tag_values();
440        let html = facet_html(
441            &Facet::new("Tag", Selecting::Subtree, &values),
442            &Emit::default(),
443        );
444        assert!(html.contains("--facet-reach: 1"));
445        assert!(html.contains("--facet-depth: 0"));
446        assert!(html.contains("--facet-depth: 1"));
447    }
448
449    #[test]
450    fn labels_and_identifiers_are_escaped_like_every_other_string() {
451        // Both arrive from the app, and a tag path is user-supplied on a system
452        // where a user names their own tags.
453        let values = [FacetValue::new("a&b", "A & B")];
454        let html = facet_html(
455            &Facet::new("T<ag>", Selecting::AnyOf, &values),
456            &Emit::default(),
457        );
458        assert!(html.contains("A &amp; B"));
459        assert!(html.contains(r#"data-facet-value="a&amp;b""#));
460        assert!(html.contains("T&lt;ag&gt;"));
461        assert!(!html.contains("<ag>"));
462    }
463
464    #[test]
465    fn a_value_reads_flat_before_it_is_touched() {
466        // The reason the arm exists: `Depth::Flat` declares nothing, so an app
467        // handing makeover the cascade with `revert-layer` rolled the handoff
468        // past an empty layer onto its own bare `button` rule and the value
469        // came out raised. Both controls say flat out loud now, and the states
470        // below it are what a press is allowed to change.
471        let css = facet_rules(&Emit::default());
472        for name in ["facet-take", "facet-prune"] {
473            assert!(
474                css.contains(&format!(
475                    ".{name} {{\n    background: none;\n    border: none;\n    box-shadow: none;\n}}"
476                )),
477                "{name} is not withdrawn: {css}"
478            );
479        }
480    }
481
482    #[test]
483    fn every_class_this_module_emits_is_one_the_stylesheet_rules() {
484        // `ROW_PART_CLASSES`' obligation: a class this crate writes and the
485        // sheet does not rule is invisible to the dead-vocabulary seal.
486        let names = crate::vocabulary::names(&Emit::default());
487        for name in FACET_CLASSES {
488            assert!(
489                names.contains(&crate::class(name, &Emit::default())),
490                "{name} is not in the vocabulary"
491            );
492        }
493    }
494
495    #[test]
496    fn the_prefix_reaches_every_class_in_the_markup() {
497        // A prefixed build claims its own names, and the emitted CSS selects
498        // descendants: miss one and the rule stops matching.
499        let opts = Emit {
500            class_prefix: "mo-",
501            ..Emit::default()
502        };
503        let values = tag_values();
504        let html = facet_html(&Facet::new("Tag", Selecting::Subtree, &values), &opts);
505        for name in FACET_CLASSES {
506            assert!(html.contains(&format!("mo-{name}")), "{name} is unprefixed");
507        }
508    }
509}