makeover-webview 0.51.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
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
//! A dimension a set is narrowed by, rendered as a list of values.
//!
//! The fourth phase-B emitter, beside [`form`](crate::form),
//! [`list`](crate::list) and [`meter`](crate::meter). Same split as those: this
//! owns the structure of the panel and the app owns the routes. A value's
//! identifier leaves in `data-facet-value`, which is the hook an app wires its
//! own request onto, exactly as [`list`](crate::list) writes `data-column` and
//! lets the app decide what pressing a heading calls.
//!
//! # Why this is markup and not only CSS
//!
//! Phase A's rule is that an app keeps its markup and gains the classes, and
//! that rule works because the markup already existed. Here it mostly does not:
//! a facet panel is the shape MNW's discover page reached by writing a tick box
//! and a chevron per row because filtering and browsing were two mechanisms, and
//! the whole point of [`makeover_layout::Selecting::Subtree`] is that they stop
//! being two. There is nothing to keep.
//!
//! # The one thing drawn that no flat control has
//!
//! An exclude affordance beside each value, in a subtree facet only. It is a
//! visible control rather than a modifier or a long press, and that was ruled
//! rather than chosen here: a gesture a terminal cannot express is a gesture
//! half the renderers leave out, and an affordance nothing teaches is one users
//! do not find. The glyph is this renderer's pick, and it takes the standing
//! preference for the heavier, simpler mark.
//!
//! # What the depth does and does not do
//!
//! `--facet-depth` carries the tree level as a number, and the indent rule
//! multiplies it by one geometry step. That keeps the whole tree one flat list
//! in the DOM rather than nested lists, which is what lets a renderer draw the
//! same description as a breadcrumb or a column of panes without the markup
//! disagreeing. It is not a size: the number is the level, and the step is
//! `makeover-geometry`'s.

use crate::form::escape_into;
use crate::{Emit, class, push_class};
use makeover_layout::{Depth, Facet, FacetValue, Selecting, Standing};
use std::fmt::Write as _;

/// The classes this module can put in markup.
///
/// [`crate::list::ROW_PART_CLASSES`]' obligation, and it exists for the same
/// reason: every class here is also ruled by [`facet_rules`], so the vocabulary
/// seal picks them up from the generated sheet, and this list is what a test
/// checks that against.
pub const FACET_CLASSES: &[&str] = &[
    "facet",
    "facet-name",
    "facet-values",
    "facet-value",
    "facet-take",
    "facet-count",
    "facet-prune",
];

/// The name a selection mode goes by in `data-selecting`.
///
/// An attribute rather than a class, for `data-selector`'s reason on a selector
/// group: the mode changes what the panel *means*, not how one value is
/// painted, and a class there would read as the styling hook the value's class
/// actually is.
#[must_use]
pub const fn selecting_name(mode: Selecting) -> &'static str {
    match mode {
        Selecting::OneOf => "one-of",
        Selecting::AnyOf => "any-of",
        Selecting::Range => "range",
        Selecting::Text => "text",
        Selecting::Subtree => "subtree",
        // A mode added to the description since this renderer was built.
        // `Selecting` is `#[non_exhaustive]`, and an unknown mode reads as the
        // one that offers no values and prunes nothing: drawing a value list
        // for a mode whose values mean something else is the worse mistake.
        _ => "unknown",
    }
}

/// The name a standing goes by in `data-standing`.
#[must_use]
pub const fn standing_name(standing: Standing) -> &'static str {
    match standing {
        Standing::Open => "open",
        Standing::Taken => "taken",
        Standing::Inherited => "inherited",
        Standing::Pruned => "pruned",
        // Unknown reads as open, which is the state that claims nothing about
        // the set.
        _ => "open",
    }
}

/// A facet as a labelled list of values.
///
/// ```
/// use makeover_layout::{Facet, FacetValue, Selecting, Standing};
/// use makeover_webview::{Emit, facet::facet_html};
///
/// let values = [
///     FacetValue::new("music", "Music")
///         .standing(Standing::Taken)
///         .counted(128)
///         .at(0, true),
///     FacetValue::new("music/synths", "Synths").at(1, false),
/// ];
/// let facet = Facet::new("Tag", Selecting::Subtree, &values);
/// let html = facet_html(&facet, &Emit::default());
///
/// assert!(html.contains(r#"data-selecting="subtree""#));
/// assert!(html.contains(r#"data-facet-value="music/synths""#));
/// // A subtree is the one mode that offers a way to prune a branch out.
/// assert!(html.contains("facet-prune"));
/// ```
///
/// A [`Selecting::Text`] or [`Selecting::Range`] facet lists nothing, so what
/// comes back is the panel and its name with an empty list inside it. That is
/// deliberate rather than an empty string: the app puts its own box in the
/// panel, and the panel is what gives the box the group label and the shared
/// geometry.
#[must_use]
pub fn facet_html(facet: &Facet<'_>, opts: &Emit) -> String {
    let mut html = String::new();
    facet_html_into(facet, opts, &mut html);
    html
}

/// A facet, written into a buffer the caller already has.
///
/// [`facet_html`]'s streaming form, byte-identical to it.
pub fn facet_html_into(facet: &Facet<'_>, opts: &Emit, out: &mut String) {
    out.push_str("<div class=\"");
    push_class(out, "facet", opts);
    out.push_str("\" role=\"group\" data-selecting=\"");
    out.push_str(selecting_name(facet.mode));
    // The gutter an indenting renderer reserves before it draws anything, so
    // the panel does not widen as deeper values arrive. "First paint is final
    // paint" applied to a tree.
    let _ = write!(out, "\" style=\"--facet-reach: {}\">", facet.reach());

    out.push_str("<p class=\"");
    push_class(out, "facet-name", opts);
    out.push_str("\">");
    escape_into(facet.name, out);
    out.push_str("</p>");

    out.push_str("<ul class=\"");
    push_class(out, "facet-values", opts);
    out.push_str("\">");
    if facet.mode.offers_values() {
        for value in facet.values {
            value_html_into(facet, value, opts, out);
        }
    }
    out.push_str("</ul></div>");
}

fn value_html_into(facet: &Facet<'_>, value: &FacetValue<'_>, opts: &Emit, out: &mut String) {
    out.push_str("<li class=\"");
    push_class(out, "facet-value", opts);
    out.push_str("\" data-standing=\"");
    out.push_str(standing_name(value.standing));
    let _ = write!(out, "\" style=\"--facet-depth: {}\">", value.depth);

    out.push_str("<button type=\"button\" class=\"");
    push_class(out, "facet-take", opts);
    out.push_str("\" data-facet-value=\"");
    escape_into(value.value, out);
    // `aria-pressed` and not `aria-selected`: the values are toggles over a set
    // rather than options in a listbox, and an inherited value is pressed in
    // fact even though nobody pressed it. That is `Standing::in_force`, which
    // exists so a renderer does not have to know which of the two it has.
    out.push_str("\" aria-pressed=\"");
    out.push_str(if value.standing.in_force() {
        "true\""
    } else {
        "false\""
    });
    // A branch that opens says so, so a reader is told there is more before
    // pressing rather than after.
    if value.branching {
        out.push_str(" aria-expanded=\"");
        out.push_str(if value.standing.in_force() {
            "true\""
        } else {
            "false\""
        });
    }
    out.push('>');
    escape_into(value.label, out);

    // Absent rather than zero when it was not measured, which is the
    // description's own position: a written zero reads as "none of them".
    if let Some(count) = value.count {
        out.push_str("<span class=\"");
        push_class(out, "facet-count", opts);
        let _ = write!(out, "\">{count}</span>");
    }
    out.push_str("</button>");

    if facet.mode.prunes() {
        out.push_str("<button type=\"button\" class=\"");
        push_class(out, "facet-prune", opts);
        out.push_str("\" data-facet-value=\"");
        escape_into(value.value, out);
        out.push_str("\" aria-pressed=\"");
        out.push_str(if value.standing == Standing::Pruned {
            "true\""
        } else {
            "false\""
        });
        // The accessible name is built here rather than described, for
        // `meter_text`'s reason: a tooltip wants a sentence and a terminal
        // wants a glyph, and a description shipping either would choose for
        // both.
        out.push_str(" aria-label=\"Exclude ");
        escape_into(value.label, out);
        // The heavier, simpler mark. It is the glyph and not a class, because a
        // renderer that swaps it is not changing what the control means.
        out.push_str("\">\u{2715}</button>");
    }

    out.push_str("</li>");
}

/// The rules for a facet panel.
///
/// Depth comes from the description: a value at rest sits as
/// [`Depth::Flat`] and a taken one is held in, which is
/// [`makeover_layout::Selector::chosen`]'s shape for a segment and is the same
/// sentence — this one is picked, so it is pressed. Nothing here states a
/// colour or a size; the indent is a count multiplied by a geometry step, and
/// the step is the one variable this crate is allowed to read.
pub(crate) fn facet_rules(opts: &Emit) -> String {
    let mut css = String::new();
    let panel = class("facet", opts);
    let name = class("facet-name", opts);
    let values = class("facet-values", opts);
    let value = class("facet-value", opts);
    let take = class("facet-take", opts);
    let count = class("facet-count", opts);
    let prune = class("facet-prune", opts);

    // The name of the dimension. A caption, and captions are legitimately
    // muted: it was never going to answer a press.
    let _ = writeln!(css, ".{name} {{\n    color: var(--content-muted);\n}}");

    // The list undoes the bullet a browser adds, for `row_rules`' reason: a
    // described set of tags is not a bulleted list and rendered as one because
    // nothing said otherwise.
    let _ = writeln!(
        css,
        ".{values} {{\n    list-style: none;\n    margin: 0;\n    padding: 0;\n}}"
    );

    // The indent is the level times one step. `--facet-depth` is written per
    // value and `--facet-reach` per panel; the panel one reserves the gutter so
    // nothing moves as deeper values arrive.
    let _ = writeln!(
        css,
        ".{value} {{\n    padding-inline-start: calc(var(--facet-depth, 0) * var(--space-tight, 0.5rem));\n}}"
    );

    let _ = writeln!(
        css,
        ".{panel} {{\n    min-inline-size: calc(var(--facet-reach, 0) * var(--space-tight, 0.5rem));\n}}"
    );

    // The value's own control. Flat at rest, held in when it is in force, and
    // that is the segmented control's sentence rather than a new one.
    css.push_str(&crate::depth_rule(&take, Depth::Flat));
    css.push_str(&crate::interactive_rules(&take, Depth::Flat, opts));
    css.push_str(&crate::depth_rule(
        &format!("{take}[aria-pressed=\"true\"]"),
        Depth::Well,
    ));

    // A pruned branch reads one step back and stays live: pressing it takes the
    // prune off, so it may not wear `content-muted`. `Standing::intent` is
    // where that is decided.
    let _ = writeln!(
        css,
        ".{value}[data-standing=\"pruned\"] .{take} {{\n    color: var(--{});\n}}",
        Standing::Pruned.intent()
    );

    // Inherited is in force and was not chosen. It reads as the thing itself,
    // like a taken value, and the difference is carried by the attribute for
    // whoever wants it rather than by a colour claiming something.
    let _ = writeln!(css, ".{count} {{\n    color: var(--content-muted);\n}}");

    css.push_str(&crate::depth_rule(&prune, Depth::Flat));
    css.push_str(&crate::interactive_rules(&prune, Depth::Flat, opts));
    css.push_str(&crate::depth_rule(
        &format!("{prune}[aria-pressed=\"true\"]"),
        Depth::Well,
    ));

    css
}

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

    fn tag_values() -> [FacetValue<'static>; 3] {
        [
            FacetValue::new("music", "Music")
                .standing(Standing::Taken)
                .counted(128)
                .at(0, true),
            FacetValue::new("music/synths", "Synths")
                .standing(Standing::Inherited)
                .at(1, false),
            FacetValue::new("music/drums", "Drums")
                .standing(Standing::Pruned)
                .at(1, false),
        ]
    }

    #[test]
    fn a_streamed_facet_is_the_facet_the_other_form_returns() {
        let opts = Emit {
            class_prefix: "mk-",
            ..Emit::default()
        };
        let values = tag_values();
        for facet in [
            Facet::new("Tag", Selecting::Subtree, &values),
            Facet::new("Type", Selecting::AnyOf, &values),
            Facet::new("Search", Selecting::Text, &[]),
        ] {
            let mut streamed = String::new();
            facet_html_into(&facet, &opts, &mut streamed);
            assert_eq!(streamed, facet_html(&facet, &opts));
        }
    }

    #[test]
    fn only_a_subtree_draws_an_exclude_affordance() {
        // The one control a flat facet has no use for: excluding a value there
        // is the same fact as not picking it.
        let values = tag_values();
        let subtree = facet_html(
            &Facet::new("Tag", Selecting::Subtree, &values),
            &Emit::default(),
        );
        assert!(subtree.contains("facet-prune"));
        assert!(subtree.contains(r#"aria-label="Exclude Drums""#));

        let flat = facet_html(
            &Facet::new("Type", Selecting::AnyOf, &values),
            &Emit::default(),
        );
        assert!(!flat.contains("facet-prune"));
    }

    #[test]
    fn an_inherited_value_reads_as_pressed_without_having_been_pressed() {
        // The distinction `Standing` has four members for. A renderer given a
        // bool marks every descendant of a taken branch or marks none, and both
        // are wrong on screen.
        let values = tag_values();
        let html = facet_html(
            &Facet::new("Tag", Selecting::Subtree, &values),
            &Emit::default(),
        );
        let synths = html
            .split("<li")
            .find(|chunk| chunk.contains("music/synths"))
            .expect("the inherited value");
        assert!(synths.contains(r#"data-standing="inherited""#));
        assert!(synths.contains(r#"aria-pressed="true""#));

        let drums = html
            .split("<li")
            .find(|chunk| chunk.contains("music/drums"))
            .expect("the pruned value");
        // Pruned is a decision and it is not in force, so the take control is
        // not pressed and the prune control is.
        assert!(drums.contains(r#"data-standing="pruned""#));
        assert!(drums.contains(r#"aria-pressed="false""#));
        assert!(drums.contains(r#"aria-label="Exclude Drums""#));
    }

    #[test]
    fn a_mode_that_lists_nothing_still_renders_its_panel() {
        // The app puts its own box in; the panel is what gives it the group
        // label and the shared geometry.
        let html = facet_html(
            &Facet::new("Search", Selecting::Text, &[]),
            &Emit::default(),
        );
        assert!(html.contains("facet-name"));
        assert!(html.contains(r#"data-selecting="text""#));
        assert!(!html.contains("facet-take"));
    }

    #[test]
    fn an_unmeasured_count_emits_no_number_at_all() {
        // A written zero reads as "none of them", which is a different claim
        // from "not counted".
        let values = [FacetValue::of("Ambient")];
        let html = facet_html(
            &Facet::new("Tag", Selecting::AnyOf, &values),
            &Emit::default(),
        );
        assert!(!html.contains("facet-count"));

        let counted = [FacetValue::of("Ambient").counted(0)];
        let html = facet_html(
            &Facet::new("Tag", Selecting::AnyOf, &counted),
            &Emit::default(),
        );
        assert!(html.contains(">0</span>"));
    }

    #[test]
    fn the_gutter_is_reserved_from_the_deepest_value_before_anything_is_drawn() {
        // "First paint is final paint" applied to a tree: a gutter widened as
        // deeper values arrive is the reflow that rule forbids.
        let values = tag_values();
        let html = facet_html(
            &Facet::new("Tag", Selecting::Subtree, &values),
            &Emit::default(),
        );
        assert!(html.contains("--facet-reach: 1"));
        assert!(html.contains("--facet-depth: 0"));
        assert!(html.contains("--facet-depth: 1"));
    }

    #[test]
    fn labels_and_identifiers_are_escaped_like_every_other_string() {
        // Both arrive from the app, and a tag path is user-supplied on a system
        // where a user names their own tags.
        let values = [FacetValue::new("a&b", "A & B")];
        let html = facet_html(
            &Facet::new("T<ag>", Selecting::AnyOf, &values),
            &Emit::default(),
        );
        assert!(html.contains("A &amp; B"));
        assert!(html.contains(r#"data-facet-value="a&amp;b""#));
        assert!(html.contains("T&lt;ag&gt;"));
        assert!(!html.contains("<ag>"));
    }

    #[test]
    fn every_class_this_module_emits_is_one_the_stylesheet_rules() {
        // `ROW_PART_CLASSES`' obligation: a class this crate writes and the
        // sheet does not rule is invisible to the dead-vocabulary seal.
        let names = crate::vocabulary::names(&Emit::default());
        for name in FACET_CLASSES {
            assert!(
                names.contains(&crate::class(name, &Emit::default())),
                "{name} is not in the vocabulary"
            );
        }
    }

    #[test]
    fn the_prefix_reaches_every_class_in_the_markup() {
        // A prefixed build claims its own names, and the emitted CSS selects
        // descendants: miss one and the rule stops matching.
        let opts = Emit {
            class_prefix: "mo-",
            ..Emit::default()
        };
        let values = tag_values();
        let html = facet_html(&Facet::new("Tag", Selecting::Subtree, &values), &opts);
        for name in FACET_CLASSES {
            assert!(html.contains(&format!("mo-{name}")), "{name} is unprefixed");
        }
    }
}