Skip to main content

makeover_layout/
facet.rs

1use crate::Nesting;
2
3// Names this module's prose links to, resolved for rustdoc.
4#[allow(unused_imports)]
5use crate::{Awaiting, FieldKind};
6
7/// A named dimension a set can be narrowed by.
8///
9/// One word for six things that were six mechanisms. MNW's discover page filters
10/// by free text, a flat any-of over item types, a tree of tags, a numeric range
11/// over price, a nested one-of over AI tier, and a browse position in the tag
12/// tree held separately from the tag selection, and the last two being separate
13/// is the whole reason a filter row there needs a tick box *and* a chevron. The
14/// panel is a mixed bag of hand-written controls because nothing named the thing
15/// they all are.
16///
17/// Deliberately wider than that one page. audiofiles' library browser and
18/// goingson's filters are the same shape, and a word that only fitted discover
19/// would be discover's markup with a neutral name on it.
20///
21/// # What it does not say
22///
23/// **What picking a value calls.** This crate names no address, so a facet is
24/// paired with routes the way a sortable column is paired with what
25/// reordering calls.
26///
27/// **How a tree is drawn.** Indented rows, a column of panes, a breadcrumb and a
28/// list: all four are honest renderings of the same described facet, and a
29/// terminal will not pick the same one a browser does. [`FacetValue::depth`] is
30/// what a renderer needs to draw any of them; the choice is not described.
31///
32/// **Which values to show.** A tag tree has thousands of nodes and a panel shows
33/// a handful. Deciding which handful is the app's, it is the same question as
34/// which rows go in a table, and no table member answers it either.
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
36#[non_exhaustive]
37pub struct Facet<'a> {
38    /// What the dimension is called, as the user reads it.
39    pub name: &'a str,
40    /// How many of its values may be in force, and in what shape.
41    pub mode: Selecting,
42    /// The values on offer, in the order they are drawn.
43    ///
44    /// A [`Selecting::Text`] facet has none: the value is whatever was typed,
45    /// and a description that listed the possible strings would be listing the
46    /// corpus. A [`Selecting::Range`] facet has none either, for the reason
47    /// [`FieldKind::Range`] takes bounds rather than options, the ends are the
48    /// question and the values between them are not enumerable.
49    pub values: &'a [FacetValue<'a>],
50}
51
52impl<'a> Facet<'a> {
53    /// A dimension with values to pick from.
54    #[must_use]
55    pub const fn new(name: &'a str, mode: Selecting, values: &'a [FacetValue<'a>]) -> Self {
56        Self { name, mode, values }
57    }
58
59    /// Whether the facet is narrowing the set right now.
60    ///
61    /// The question a renderer asks to decide whether to offer a way out of it,
62    /// and the reason it is derived rather than carried: a facet with nothing
63    /// standing is unengaged by construction, so a member saying so could
64    /// disagree with the values beside it. [`Standing::Inherited`] does not
65    /// count, something further up is what is doing the narrowing, and clearing
66    /// a child that was never picked clears nothing.
67    ///
68    /// Always false for [`Selecting::Text`] and [`Selecting::Range`], which
69    /// carry no values. A host that wants a clear affordance on those knows
70    /// whether its own box is empty; the description does not hold the typed
71    /// string.
72    #[must_use]
73    pub fn engaged(&self) -> bool {
74        self.values.iter().any(|value| value.standing.is_picked())
75    }
76
77    /// The deepest value in the facet, or zero when it is flat.
78    ///
79    /// What an indenting renderer needs to reserve a gutter before it draws the
80    /// first row, which is "First paint is final paint" applied to a tree: a
81    /// gutter widened as deeper values arrive is the reflow that rule forbids.
82    #[must_use]
83    pub fn reach(&self) -> u8 {
84        self.values
85            .iter()
86            .map(|value| value.depth.level)
87            .max()
88            .unwrap_or(0)
89    }
90}
91
92/// How many of a [`Facet`]'s values may be in force, and in what shape.
93///
94/// Five, and the fifth is what made this an enum rather than a bool. `one-of`,
95/// `any-of`, a range and free text are the four a form vocabulary already has in
96/// [`FieldKind`]; a tree's selection is none of them, and describing tags as
97/// any-of was what forced browsing to be a second mechanism beside filtering.
98#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
99#[non_exhaustive]
100pub enum Selecting {
101    /// Exactly one value, and picking another replaces it.
102    ///
103    /// MNW's AI tier, whose three options are nested ranges rather than
104    /// independent values, so two of them at once means nothing.
105    OneOf,
106    /// Any number of values, each independent of the others.
107    AnyOf,
108    /// A low end, a high end, or both.
109    ///
110    /// Carries no values for [`FieldKind::Range`]'s reason: the ends are the
111    /// question.
112    Range,
113    /// Whatever the user types.
114    Text,
115    /// A position in a tree, edited by taking branches in and pruning branches
116    /// out.
117    ///
118    /// The one mode that is not reducible to the others, and the one gesture
119    /// that replaced two. Picking a value narrows the set to it *and* reveals
120    /// its children, so browsing a tree and filtering by it stop being separate
121    /// mechanisms with separate state. What a selection then is: a set of
122    /// branches taken and a set pruned, resolved nearest-ancestor-first, so
123    /// `music` in and `music/synths` out is sayable and no flat mode can say it.
124    ///
125    /// Resolution happens in the app, and what reaches a renderer is the
126    /// [`Standing`] each drawn value ended up with. A renderer walking ancestors
127    /// itself would be a renderer that can disagree with the results beside it.
128    Subtree,
129}
130
131impl Selecting {
132    /// Whether the mode picks from values the description lists.
133    ///
134    /// False for [`Text`](Self::Text) and [`Range`](Self::Range), which are the
135    /// two whose answer is not one of a set. A renderer asks this before it
136    /// looks at [`Facet::values`], the way it asks
137    /// [`FieldKind::offers_options`] before it looks at a field's options.
138    #[must_use]
139    pub const fn offers_values(self) -> bool {
140        matches!(self, Self::OneOf | Self::AnyOf | Self::Subtree)
141    }
142
143    /// Whether a value can be pruned as well as picked.
144    ///
145    /// [`Subtree`](Self::Subtree) alone. Excluding a value from a flat facet is
146    /// the same fact as not picking it, so an exclude affordance there would be
147    /// a second control for a state the first one already holds.
148    #[must_use]
149    pub const fn prunes(self) -> bool {
150        matches!(self, Self::Subtree)
151    }
152
153    /// Whether picking a second value keeps the first.
154    #[must_use]
155    pub const fn accumulates(self) -> bool {
156        matches!(self, Self::AnyOf | Self::Subtree)
157    }
158}
159
160/// One value a [`Facet`] offers, as it currently stands.
161#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
162#[non_exhaustive]
163pub struct FacetValue<'a> {
164    /// What identifies it, and what a host keys its route on.
165    ///
166    /// An option's split between what submits and what reads, and a tree is
167    /// why it is not optional: two leaves under different parents are
168    /// legitimately both called "Ambient",
169    /// and the path is the only thing telling them apart. It is also what
170    /// nearest-ancestor-wins resolves over, so an app that carried only labels
171    /// could not compute the [`standing`](Self::standing) it hands back here.
172    pub value: &'a str,
173    /// What it is called, as the user reads it.
174    ///
175    /// The leaf's own name rather than its path: a facet drawn as an indented
176    /// tree repeats every ancestor on every line otherwise, and one drawn as a
177    /// breadcrumb has the ancestors already.
178    pub label: &'a str,
179    /// How many members of the set carry it.
180    ///
181    /// Optional, and settled that way rather than made mandatory: a count is a
182    /// measured fact the app may not have. Counting a tag subtree under an
183    /// active text search is a second query, and an app that will not pay for it
184    /// should be able to describe the facet anyway rather than write a zero that
185    /// reads as "none of them". That is [`Awaiting::amount`]'s rule in a second
186    /// place, state a number when it was measured, and nothing when it was not.
187    pub count: Option<u64>,
188    /// Whether it is narrowing the set, and how it came to be.
189    pub standing: Standing,
190    /// How far down the tree it sits, counting from zero at the root.
191    ///
192    /// Always zero for a flat facet, which is what makes an indenting renderer
193    /// one code path rather than two. A renderer that draws no tree at all still
194    /// reads this, since a value's depth is what distinguishes two same-named
195    /// leaves under different parents.
196    pub depth: Nesting,
197    /// Whether taking it reveals values under it.
198    ///
199    /// Distinct from having a nonzero [`depth`](Self::depth): a leaf deep in the
200    /// tree branches no further, and a root with children does. Both facts are
201    /// needed and neither implies the other, which is why the pair is two
202    /// members rather than one count.
203    pub branching: bool,
204    /// Whether pressing it narrows the set to it.
205    ///
206    /// True for every value of a flat facet. In a tree, a node that is not
207    /// pickable is a group: a heading whose press opens or closes it, and which
208    /// offers no exclude. A non-pickable node that does not branch is a link
209    /// row ("31 more", "More groups"), whose press is its whole meaning. Which
210    /// nodes are pickable is data, never a name: an app passes it in.
211    pub pickable: bool,
212    /// Whether its children are drawn, when that is view state of its own.
213    ///
214    /// `None` keeps the older reading, open while in force, because picking a
215    /// value used to be the only way to look inside it. `Some` separates the
216    /// two: opening a group is not picking it.
217    pub open: Option<bool>,
218    /// How the picks among its children combine, for a node whose children
219    /// are picked: any of them, or all of them. `None` where nothing under it
220    /// can be picked. A renderer offers the toggle on the open row.
221    pub join: Option<Join>,
222    /// Whether [`count`](Self::count) is a gain rather than a total: how many
223    /// more results picking it adds, under a parent combining any of its picks
224    /// that already holds one. A renderer writes it as `+n`.
225    pub gain: bool,
226    /// Where a group ranks among the others, for a group the app ranked.
227    pub rank: Option<GroupRank>,
228}
229
230/// How the picks under one parent combine.
231#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
232#[non_exhaustive]
233pub enum Join {
234    /// A member needs at least one of them.
235    #[default]
236    Any,
237    /// A member needs every one of them.
238    All,
239}
240
241/// Where a group ranks: how much a pick inside it would narrow the set.
242///
243/// A group is offered first when picking in it cuts the most, which the app
244/// measures from its results (tagtree's `rank_groups` is one way). Nothing
245/// here names a group, so the order moves as the set narrows.
246#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
247#[non_exhaustive]
248pub struct GroupRank {
249    /// A pick sits inside it already.
250    pub in_use: bool,
251    /// Some pick inside it would still leave results. A dead group says so
252    /// rather than offering choices that empty the set.
253    pub live: bool,
254    /// How hard a pick here cuts, from 0 to 100, against the best group on
255    /// offer: what a bar draws.
256    pub cut: u8,
257    /// The best group not yet in use: where a newcomer should start.
258    pub start: bool,
259}
260
261impl GroupRank {
262    /// A live group cutting `cut` (clamped to 100).
263    #[must_use]
264    pub const fn cutting(cut: u8) -> Self {
265        Self {
266            in_use: false,
267            live: true,
268            cut: if cut > 100 { 100 } else { cut },
269            start: false,
270        }
271    }
272
273    /// A group with a pick inside it.
274    #[must_use]
275    pub const fn in_use() -> Self {
276        Self {
277            in_use: true,
278            live: true,
279            cut: 0,
280            start: false,
281        }
282    }
283
284    /// A group no pick in which would leave anything.
285    #[must_use]
286    pub const fn dead() -> Self {
287        Self {
288            in_use: false,
289            live: false,
290            cut: 0,
291            start: false,
292        }
293    }
294
295    /// Marked as where to start.
296    #[must_use]
297    pub const fn starting(mut self) -> Self {
298        self.start = true;
299        self
300    }
301}
302
303impl<'a> FacetValue<'a> {
304    /// An unpicked value at the root of the facet.
305    #[must_use]
306    pub const fn new(value: &'a str, label: &'a str) -> Self {
307        Self {
308            value,
309            label,
310            count: None,
311            standing: Standing::Open,
312            depth: Nesting::top(),
313            branching: false,
314            pickable: true,
315            open: None,
316            join: None,
317            gain: false,
318            rank: None,
319        }
320    }
321
322    /// A value whose identifier is also what the user reads.
323    ///
324    /// A plain option's convenience, and it is the flat case: a type or a tier
325    /// is its own name, and only a tree needs a path that is not one.
326    #[must_use]
327    pub const fn of(value: &'a str) -> Self {
328        Self::new(value, value)
329    }
330
331    /// How many members carry it, when that was measured.
332    #[must_use]
333    pub const fn counted(mut self, count: u64) -> Self {
334        self.count = Some(count);
335        self
336    }
337
338    /// How it stands in the current selection.
339    #[must_use]
340    pub const fn standing(mut self, standing: Standing) -> Self {
341        self.standing = standing;
342        self
343    }
344
345    /// Where it sits in the tree, and whether anything hangs off it.
346    #[must_use]
347    pub const fn at(mut self, depth: Nesting, branching: bool) -> Self {
348        self.depth = depth;
349        self.branching = branching;
350        self
351    }
352
353    /// A group: a heading whose press opens it, never a pick.
354    #[must_use]
355    pub const fn heading(mut self) -> Self {
356        self.pickable = false;
357        self
358    }
359
360    /// Whether its children are drawn, as view state apart from its standing.
361    #[must_use]
362    pub const fn opened(mut self, open: bool) -> Self {
363        self.open = Some(open);
364        self
365    }
366
367    /// How the picks among its children combine.
368    #[must_use]
369    pub const fn joined(mut self, join: Join) -> Self {
370        self.join = Some(join);
371        self
372    }
373
374    /// Its count is a gain, `+n`, rather than a total.
375    #[must_use]
376    pub const fn gained(mut self) -> Self {
377        self.gain = true;
378        self
379    }
380
381    /// Where it ranks among the groups.
382    #[must_use]
383    pub const fn ranked(mut self, rank: GroupRank) -> Self {
384        self.rank = Some(rank);
385        self
386    }
387
388    /// Whether its children are drawn: its own open state when it has one,
389    /// else whether it is in force.
390    #[must_use]
391    pub const fn is_open(&self) -> bool {
392        match self.open {
393            Some(open) => open,
394            None => self.standing.in_force(),
395        }
396    }
397
398    /// A link row: neither pickable nor branching, so pressing it is its
399    /// whole meaning ("31 more").
400    #[must_use]
401    pub const fn is_link(&self) -> bool {
402        !self.pickable && !self.branching
403    }
404}
405
406/// Whether a [`FacetValue`] is narrowing the set, and how it came to be.
407///
408/// Five rather than a bool, and the extra members are what a tree costs. A
409/// pruned branch and an untaken one are not the same state (one was decided
410/// against and the other was never reached) and a child under a taken parent is
411/// in force without anybody having picked it. A renderer given a bool either
412/// marks every descendant of a taken branch, which reads as forty deliberate
413/// choices, or marks none of them, which reads as unfiltered.
414///
415/// The out side needs the same pair the in side has. A child under a pruned
416/// branch is out without anybody having pruned it: [`PrunedAbove`] to
417/// [`Pruned`]'s decision, as [`Inherited`] is to [`Taken`]'s.
418///
419/// [`PrunedAbove`]: Self::PrunedAbove
420/// [`Pruned`]: Self::Pruned
421/// [`Inherited`]: Self::Inherited
422/// [`Taken`]: Self::Taken
423#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
424#[non_exhaustive]
425pub enum Standing {
426    /// Not picked, and nothing above it is either.
427    #[default]
428    Open,
429    /// Picked here. The set is narrowed to it and whatever hangs off it.
430    Taken,
431    /// In force because something above it was taken.
432    Inherited,
433    /// Pruned out, though something above it was taken.
434    ///
435    /// The state that only [`Selecting::Subtree`] can reach, and the reason
436    /// exclusion is drawn as a visible affordance beside each label rather than
437    /// as a modifier on the ordinary one: a gesture a terminal cannot express is
438    /// a gesture half the renderers would have to leave out, and an affordance
439    /// nothing teaches is one users do not find.
440    Pruned,
441    /// Out because something above it was pruned.
442    ///
443    /// Not picked: clearing it clears nothing, because the decision is further
444    /// up, which is [`Inherited`](Self::Inherited)'s position on the other
445    /// side.
446    PrunedAbove,
447}
448
449impl Standing {
450    /// Whether the user decided this value, either way.
451    ///
452    /// True for [`Taken`](Self::Taken) and [`Pruned`](Self::Pruned), both are
453    /// choices, and both are things a "clear this" affordance has to clear.
454    /// [`Inherited`](Self::Inherited) is not: clearing it clears nothing,
455    /// because the decision is further up.
456    #[must_use]
457    pub const fn is_picked(self) -> bool {
458        matches!(self, Self::Taken | Self::Pruned)
459    }
460
461    /// Whether the value narrows the set in.
462    ///
463    /// [`Taken`](Self::Taken) and [`Inherited`](Self::Inherited): one was picked
464    /// and one came down from above, and to the set they mean the same thing.
465    /// The pair is named here so a renderer colouring in-force values does not
466    /// have to know which is which.
467    #[must_use]
468    pub const fn in_force(self) -> bool {
469        matches!(self, Self::Taken | Self::Inherited)
470    }
471
472    /// The content intent the value takes.
473    ///
474    /// [`Pruned`](Self::Pruned) reads back a step, which is the three-tone rule
475    /// above rather than a new decision: a pruned branch is still a live control
476    /// (pressing it takes the prune off) so it may not wear `content-muted`,
477    /// and it is not the thing itself either.
478    #[must_use]
479    pub const fn intent(self) -> &'static str {
480        match self {
481            Self::Taken | Self::Inherited | Self::Open => "content",
482            Self::Pruned | Self::PrunedAbove => "content-secondary",
483        }
484    }
485
486    /// Whether the value is out of the set by a prune, here or above.
487    ///
488    /// [`in_force`](Self::in_force)'s counterpart, so a renderer reading a
489    /// value back a step does not have to know which of the two it has.
490    #[must_use]
491    pub const fn pruned_out(self) -> bool {
492        matches!(self, Self::Pruned | Self::PrunedAbove)
493    }
494}