//! 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::reset::Reset;
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("
", facet.reach());
out.push_str("
");
escape_into(facet.name, out);
out.push_str("
");
out.push_str("
");
if facet.mode.offers_values() {
for value in facet.values {
value_html_into(facet, value, opts, out);
}
}
out.push_str("
", value.depth);
out.push_str("");
if facet.mode.prunes() {
out.push_str("");
}
out.push_str("
");
}
/// 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 gives back what a `
` brought, the same ask `row_rules`
// makes: a described set of tags is not a bulleted list and rendered as one
// because nothing said otherwise.
css.push_str(&Reset::BULLETS.rule(&format!(".{values}")));
// 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.
//
// Flat states nothing, no fill and no bevel, so `depth_rule` emitted an
// empty string and saying it was the whole of what this arm did. Where an
// app hands makeover the cascade with `revert-layer`, an empty layer rolls
// the handoff past makeover to the app's own bare `button` rule and the
// value renders raised, with `[aria-pressed="true"]` its only true state.
// Flat here has to be said out loud, which is what the reset is for.
css.push_str(&Reset::FLAT_BUTTON.rule(&format!(".{take}")));
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}}");
// Same withdrawal as the take, for the same reason.
css.push_str(&Reset::FLAT_BUTTON.rule(&format!(".{prune}")));
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("
0"));
}
#[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", Selecting::AnyOf, &values),
&Emit::default(),
);
assert!(html.contains("A & B"));
assert!(html.contains(r#"data-facet-value="a&b""#));
assert!(html.contains("T<ag>"));
assert!(!html.contains(""));
}
#[test]
fn a_value_reads_flat_before_it_is_touched() {
// The reason the arm exists: `Depth::Flat` declares nothing, so an app
// handing makeover the cascade with `revert-layer` rolled the handoff
// past an empty layer onto its own bare `button` rule and the value
// came out raised. Both controls say flat out loud now, and the states
// below it are what a press is allowed to change.
let css = facet_rules(&Emit::default());
for name in ["facet-take", "facet-prune"] {
assert!(
css.contains(&format!(
".{name} {{\n background: none;\n border: none;\n box-shadow: none;\n}}"
)),
"{name} is not withdrawn: {css}"
);
}
}
#[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");
}
}
}