makeover-layout 0.56.0

The renderer-agnostic half of the make-family design system: what a thing IS, named as intents and relationships and never as values. Colour defers to makeover, spacing to makeover-geometry; what is left is composition, plus which affordances exist at a given input class and window size.
Documentation
//! A tag tree as a [`Selecting::Subtree`] facet, from tagtree's three answers.
//!
//! [`Selecting::Subtree`] leaves resolution to the app, and tagtree (published
//! as `mnw-tagtree`) is where the resolution lives: an outline for the rows in
//! draw order, a selection for how each row stands, and a rollup for how many
//! items sit under each. This is the mapping from those to [`FacetValue`]s, in
//! one place rather than once per app. Behind the `tagtree` feature, so a
//! consumer that draws no tag tree takes no dependency for it.
//!
//! Which rows to show stays the app's, as [`Facet`] says, and
//! [`tagtree::Selection::reveals`] is the usual answer: roots, the children of
//! what was picked, and the path down to every decision.
//!
//! [`grouped`](crate::tag_facet::grouped) is the same mapping for a [`tagtree::TagQuery`], where picks
//! combine per parent and groups are headings: opening one is view state, not
//! a pick, and each group carries how the picks under it combine and where it
//! ranks.

use crate::{FacetValue, GroupRank, Join, Nesting, Standing};

impl From<tagtree::Standing> for Standing {
    fn from(standing: tagtree::Standing) -> Self {
        match standing {
            tagtree::Standing::Open => Self::Open,
            tagtree::Standing::Taken => Self::Taken,
            tagtree::Standing::Inherited => Self::Inherited,
            tagtree::Standing::Pruned => Self::Pruned,
            tagtree::Standing::PrunedAbove => Self::PrunedAbove,
        }
    }
}

/// One facet value per outline row, standing from `selection` and counted from
/// `counts` when the app measured them.
///
/// `rows` is whichever part of [`tagtree::TagIndex::outline`] the app chose to
/// show, in the order it came: the outline is pre-order, which is draw order.
/// A row's [`depth`](FacetValue::depth) counts from zero at a root, where
/// tagtree counts segments from one. `branching` is the outline's own answer,
/// whether anything hangs below the node in the index, rather than whether its
/// children happen to be shown, so a reader is told a branch opens before
/// pressing it.
///
/// ```
/// use makeover_layout::{Standing, tag_facet::values};
///
/// let index = tagtree::TagIndex::new(vec!["audio.genre.ambient".into(), "video.essay".into()]);
/// let mut selection = tagtree::Selection::new();
/// selection.pick("audio");
/// let outline = index.outline();
/// let shown: Vec<_> = outline.into_iter().filter(|n| selection.reveals(n.path)).collect();
/// let rollup = tagtree::Rollup::from_sets([(vec!["audio.genre.ambient"], 3)]);
///
/// let facet = values(&shown, &selection, Some(&rollup));
/// let audio = &facet[0];
/// assert_eq!((audio.value, audio.standing, audio.count), ("audio", Standing::Taken, Some(3)));
/// assert_eq!(facet[1].value, "audio.genre");
/// assert_eq!(facet[1].standing, Standing::Inherited);
/// assert_eq!(facet[1].depth.level, 1);
/// ```
#[must_use]
pub fn values<'a>(
    rows: &[tagtree::OutlineNode<'a>],
    selection: &tagtree::Selection,
    counts: Option<&tagtree::Rollup>,
) -> Vec<FacetValue<'a>> {
    rows.iter()
        .map(|row| {
            let value = value_of(row).standing(selection.standing(row.path).into());
            match counts {
                Some(rollup) => value.counted(rollup.total(row.path)),
                None => value,
            }
        })
        .collect()
}

/// A row's value at its depth, before any standing or count is said.
fn value_of<'a>(row: &tagtree::OutlineNode<'a>) -> FacetValue<'a> {
    FacetValue::new(row.path, row.label).at(
        Nesting::at(u8::try_from(row.depth.saturating_sub(1)).unwrap_or(u8::MAX)),
        row.branching,
    )
}

impl From<tagtree::Join> for Join {
    fn from(join: tagtree::Join) -> Self {
        match join {
            tagtree::Join::Any => Self::Any,
            tagtree::Join::All => Self::All,
        }
    }
}

/// How `path` stands in `query`: picked or excluded itself, else in or out
/// because of the nearest pick or exclusion above it, else open.
#[must_use]
pub fn query_standing(query: &tagtree::TagQuery, path: &str) -> Standing {
    if query.is_picked(path) {
        return Standing::Taken;
    }
    if query.is_excluded(path) {
        return Standing::Pruned;
    }
    for above in tagtree::ancestors(path).into_iter().rev() {
        if query.is_excluded(above) {
            return Standing::PrunedAbove;
        }
        if query.is_picked(above) {
            return Standing::Inherited;
        }
    }
    Standing::Open
}

/// One facet value per outline row, for a query whose picks combine per parent.
///
/// - `pickable` says which nodes may be picked (leaves, plus inner nodes the
///   app flags); every other node is a [heading](FacetValue::heading).
/// - `open` is the app's view state for a branching node: opened by the
///   visitor, or by the ranking. Opening is never picking.
/// - `ranks` is [`tagtree::rank_groups`]'s answer for the groups on offer, in
///   rank order. Each ranked group gets a [`GroupRank`] whose cut is its score
///   against the best live group's, and the first live group not in use is
///   marked as where to start. Pass the top-level ranking.
/// - `count` answers, per pickable value, how many results picking it gives
///   and whether that number is a gain (`+n`) rather than a total.
///
/// ```
/// use makeover_layout::{Join, Standing, tag_facet::grouped};
///
/// let index = tagtree::TagIndex::new(vec!["genre.ambient".into(), "genre.drone".into()]);
/// let mut query = tagtree::TagQuery::new();
/// query.pick("genre.ambient");
/// let outline = index.outline();
/// let leaf = |p: &str| p.contains('.');
/// let facet = grouped(&outline, &query, leaf, |_| true, &[], |_| Some((2, true)));
/// assert!(!facet[0].pickable && facet[0].is_open());
/// assert_eq!(facet[0].join, Some(Join::Any));
/// assert_eq!(facet[1].standing, Standing::Taken);
/// assert_eq!((facet[2].count, facet[2].gain), (Some(2), true));
/// ```
#[must_use]
pub fn grouped<'a>(
    rows: &[tagtree::OutlineNode<'a>],
    query: &tagtree::TagQuery,
    pickable: impl Fn(&str) -> bool,
    open: impl Fn(&str) -> bool,
    ranks: &[tagtree::GroupRank],
    count: impl Fn(&str) -> Option<(u64, bool)>,
) -> Vec<FacetValue<'a>> {
    let best = ranks
        .iter()
        .filter(|r| r.live && !r.in_use)
        .map(|r| r.score)
        .fold(0.0_f64, f64::max);
    let start = ranks
        .iter()
        .find(|r| r.live && !r.in_use)
        .map(|r| r.path.as_str());
    rows.iter()
        .map(|row| {
            let path = row.path;
            let mut value = value_of(row).standing(query_standing(query, path));
            if row.branching {
                value = value.opened(open(path)).joined(query.join(path).into());
            }
            if pickable(path) {
                if let Some((n, gain)) = count(path) {
                    value = value.counted(n);
                    if gain {
                        value = value.gained();
                    }
                }
            } else {
                value = value.heading();
            }
            if let Some(r) = ranks.iter().find(|r| r.path == path) {
                let rank = if r.in_use {
                    GroupRank::in_use()
                } else if !r.live {
                    GroupRank::dead()
                } else {
                    let cut = if best > 0.0 {
                        r.score / best * 100.0
                    } else {
                        0.0
                    };
                    // A live group always shows some bar: zero would read as dead.
                    GroupRank::cutting((cut.round() as u8).max(4))
                };
                value = value.ranked(if start == Some(path) {
                    rank.starting()
                } else {
                    rank
                });
            }
            value
        })
        .collect()
}

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

    #[test]
    fn every_tagtree_standing_maps_to_its_namesake() {
        assert_eq!(Standing::from(tagtree::Standing::Open), Standing::Open);
        assert_eq!(Standing::from(tagtree::Standing::Taken), Standing::Taken);
        assert_eq!(
            Standing::from(tagtree::Standing::Inherited),
            Standing::Inherited
        );
        assert_eq!(Standing::from(tagtree::Standing::Pruned), Standing::Pruned);
        assert_eq!(
            Standing::from(tagtree::Standing::PrunedAbove),
            Standing::PrunedAbove
        );
    }

    #[test]
    fn an_unmeasured_facet_carries_no_counts() {
        let index = tagtree::TagIndex::new(vec!["a.b".into()]);
        let outline = index.outline();
        let facet = values(&outline, &tagtree::Selection::new(), None);
        assert!(facet.iter().all(|v| v.count.is_none()));
        assert!(facet[0].branching && !facet[1].branching);
        assert_eq!(facet[0].label, "a");
        assert_eq!(facet[1].label, "b");
    }

    #[test]
    fn a_prune_reads_out_below_it() {
        let index = tagtree::TagIndex::new(vec!["a.b.c".into()]);
        let outline = index.outline();
        let mut selection = tagtree::Selection::taking(["a"]);
        selection.prune("a.b");
        let facet = values(&outline, &selection, None);
        let standings: Vec<Standing> = facet.iter().map(|v| v.standing).collect();
        assert_eq!(
            standings,
            [Standing::Taken, Standing::Pruned, Standing::PrunedAbove]
        );
    }

    #[test]
    fn a_query_reads_nearest_decision_first() {
        let mut query = tagtree::TagQuery::new();
        query.pick("a.b");
        query.exclude("a.b.c");
        assert_eq!(query_standing(&query, "a.b"), Standing::Taken);
        assert_eq!(query_standing(&query, "a.b.x"), Standing::Inherited);
        assert_eq!(query_standing(&query, "a.b.c.d"), Standing::PrunedAbove);
        assert_eq!(query_standing(&query, "a"), Standing::Open);
    }

    #[test]
    fn ranks_scale_to_the_best_group_and_mark_where_to_start() {
        let index =
            tagtree::TagIndex::new(vec!["m.a".into(), "g.x".into(), "p.l".into(), "f.y".into()]);
        let outline = index.outline();
        let rank = |path: &str, in_use, live, score| tagtree::GroupRank {
            path: path.into(),
            in_use,
            live,
            score,
            cut: 0.0,
            coverage: 0.0,
            choices: 1,
        };
        let ranks = [
            rank("m", true, true, 1.0),
            rank("g", false, true, 0.2),
            rank("f", false, true, 0.1),
            rank("p", false, false, 0.0),
        ];
        let mut query = tagtree::TagQuery::new();
        query.pick("m.a");
        let facet = grouped(
            &outline,
            &query,
            |p| p.contains('.'),
            |_| false,
            &ranks,
            |_| None,
        );
        let by = |p: &str| {
            facet
                .iter()
                .find(|v| v.value == p)
                .and_then(|v| v.rank)
                .unwrap()
        };
        assert!(by("m").in_use);
        assert_eq!((by("g").cut, by("g").start), (100, true));
        assert_eq!((by("f").cut, by("f").start), (50, false));
        assert!(!by("p").live);
    }
}