feat(core): groups nest within a category and start collapsed (improvise-felo.6)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
This commit is contained in:
Claude (agent-hier)
2026-09-24 21:22:16 -07:00
committed by Edward Langley
parent b639266b0d
commit 8dad4b7476
4 changed files with 746 additions and 153 deletions
+111 -3
View File
@@ -199,15 +199,66 @@ impl Category {
self.items.keys().map(|s| s.as_str()).collect()
}
/// The items whose own `group` names `group`, in insertion order. Read off the
/// items, never `groups`: `add_item_in_group` registers no `Group` (core.md §14).
/// The items `group` covers — its own and every child group's, through any
/// depth — in insertion order. Read off the items, not `groups` (core.md §14).
pub fn items_in_group(&self, group: &str) -> Vec<&str> {
self.items
.values()
.filter(|i| i.group.as_deref() == Some(group))
.filter(|i| self.item_chain(&i.name).contains(&group))
.map(|i| i.name.as_str())
.collect()
}
/// The parent a `>` line gave `group`; `None` for a root.
pub fn group_parent(&self, group: &str) -> Option<&str> {
self.groups
.iter()
.find(|g| g.name == group)
.and_then(|g| g.parent.as_deref())
}
/// `group` and its ancestors, ROOT FIRST. A cycle built in code stops the walk
/// where it would repeat; a file cannot carry one (`hierarchy_cycle`).
pub fn group_chain<'a>(&'a self, group: &'a str) -> Vec<&'a str> {
let mut chain = vec![group];
while let Some(parent) = chain.last().and_then(|g| self.group_parent(g)) {
if chain.contains(&parent) {
break;
}
chain.push(parent);
}
chain.reverse();
chain
}
/// How many ancestors `group` has: 0 for a root.
pub fn group_depth(&self, group: &str) -> usize {
self.group_chain(group).len() - 1
}
/// The groups an item sits in, root first; empty for an ungrouped item.
pub fn item_chain(&self, item: &str) -> Vec<&str> {
self.items
.get(item)
.and_then(|i| i.group.as_deref())
.map(|g| self.group_chain(g))
.unwrap_or_default()
}
/// A cycle among the declared parents, its members in walk order: the one
/// shape a hierarchy can take that is not a forest (core.md §19).
pub fn hierarchy_cycle(&self) -> Option<Vec<&str>> {
self.groups.iter().find_map(|start| {
let mut path: Vec<&str> = vec![start.name.as_str()];
while let Some(parent) = path.last().and_then(|g| self.group_parent(g)) {
if let Some(at) = path.iter().position(|g| *g == parent) {
return Some(path[at..].to_vec());
}
path.push(parent);
}
None
})
}
}
#[cfg(test)]
@@ -297,6 +348,63 @@ mod tests {
assert_eq!(c.groups.len(), 1);
}
/// Year > Quarter > Month, with `2024-03` added LAST so Q1-2024 and 2024 are
/// non-contiguous in insertion order.
fn dates() -> Category {
let mut c = Category::new(0, "Date");
c.add_item_in_group("2024-01", "Q1-2024");
c.add_item_in_group("2024-04", "Q2-2024");
c.add_item_in_group("2025-01", "Q1-2025");
c.add_item("Undated");
c.add_item_in_group("2024-03", "Q1-2024");
c.add_group(Group::new("Q1-2024").with_parent("2024"));
c.add_group(Group::new("Q2-2024").with_parent("2024"));
c.add_group(Group::new("Q1-2025").with_parent("2025"));
c
}
#[test]
fn a_parent_group_covers_every_leaf_beneath_its_child_groups() {
let c = dates();
assert_eq!(
c.items_in_group("2024"),
vec!["2024-01", "2024-04", "2024-03"]
);
assert_eq!(c.items_in_group("Q1-2024"), vec!["2024-01", "2024-03"]);
assert_eq!(c.items_in_group("2025"), vec!["2025-01"]);
assert!(c.items_in_group("Undated").is_empty());
}
#[test]
fn an_items_chain_runs_root_first_and_depth_counts_ancestors() {
let c = dates();
assert_eq!(c.item_chain("2024-03"), vec!["2024", "Q1-2024"]);
assert!(c.item_chain("Undated").is_empty());
assert_eq!(c.group_depth("2024"), 0);
assert_eq!(c.group_depth("Q2-2024"), 1);
}
#[test]
fn a_cycle_among_parents_is_found_and_named() {
let mut c = dates();
assert_eq!(c.hierarchy_cycle(), None);
c.add_group(Group::new("2024").with_parent("Q2-2024"));
let cycle = c.hierarchy_cycle().expect("2024 -> Q2-2024 -> 2024");
assert!(
cycle.contains(&"2024") && cycle.contains(&"Q2-2024"),
"{cycle:?}"
);
assert!(!cycle.contains(&"Q1-2025"), "{cycle:?}");
assert!(c.group_chain("Q1-2024").len() <= 3, "a walk through a cycle stops");
}
#[test]
fn a_self_parent_is_a_cycle_of_one() {
let mut c = cat();
c.add_group(Group::new("G").with_parent("G"));
assert_eq!(c.hierarchy_cycle(), Some(vec!["G"]));
}
#[test]
fn item_index_reflects_insertion_order() {
let mut c = cat();
+469 -111
View File
@@ -205,16 +205,19 @@ pub enum RecordCol {
/// and collapse state show; a collapsed group is ONE `DataItem` (core.md §14).
#[derive(Debug, Clone, PartialEq)]
pub enum AxisEntry {
/// Opens a run at `level` (the category's place on the axis) and closes
/// every run inside it. Membership is per level: core.md §14, improvise-e265.
/// Opens a run at `(level, depth)` — the category's place on the axis, then the
/// group's depth in its hierarchy — and closes every run inside it (§6, §19).
GroupHeader {
level: usize,
depth: usize,
cat_name: String,
group_name: String,
/// The view's answer for this group, so no reader re-derives it.
collapsed: bool,
},
/// Ends the run at `level` and every run inside it (improvise-gd83, e265).
/// Nothing is drawn for it; it exists so membership is not inherited.
GroupEnd { level: usize },
/// Ends the run at `(level, depth)` and every run inside it (improvise-gd83,
/// e265). Nothing is drawn for it; it exists so membership is not inherited.
GroupEnd { level: usize, depth: usize },
/// `collapsed` is not derivable from `tuple`: a group and an item may share a
/// name. Set for a folded group and for everything expanded beneath one.
DataItem { tuple: Vec<String>, collapsed: bool },
@@ -301,28 +304,38 @@ fn data_index_to_visual(items: &[AxisEntry], data_idx: usize) -> Option<usize> {
.nth(data_idx)
}
/// Each open run, by level, as `(position of its header, the header)`. A header
/// at L replaces L and closes deeper runs; a `GroupEnd` at L closes L and deeper.
type OpenRuns = Vec<Option<(usize, AxisEntry)>>;
/// Where a run sits: `(level, depth)`, ordered, so "inside" is "greater than".
/// Across categories the level decides; within one, the depth (§19).
pub type RunKey = (usize, usize);
fn open_at(runs: &mut OpenRuns, level: usize, run: Option<(usize, AxisEntry)>) {
runs.truncate(level);
runs.resize(level, None);
runs.extend(run.map(Some));
/// One open run: its key and the position of the header that opened it.
pub type OpenRun = (RunKey, usize);
/// Each open run, innermost last, with its header. A header at K replaces K
/// and closes every deeper run; a `GroupEnd` at K closes K and deeper.
type OpenRuns = Vec<(RunKey, usize, AxisEntry)>;
/// Close every run at or inside `key`, then open `run` there if one is given.
fn open_at(runs: &mut OpenRuns, key: RunKey, run: Option<(usize, AxisEntry)>) {
let outside = runs.partition_point(|(k, _, _)| *k < key);
runs.truncate(outside);
runs.extend(run.map(|(at, header)| (key, at, header)));
}
/// Drop the data items `keep` rejects, then re-mark the survivors so each keeps
/// exactly the runs it was in. Why re-marked, not filtered: core.md §6, e265.
fn prune_axis_items(items: &mut Vec<AxisEntry>, keep: impl Fn(usize) -> bool) {
let mut kept: Vec<AxisEntry> = Vec::new();
// What the input has open at each level, and what `kept` has open so far.
// What the input has open, and what `kept` has open so far.
let mut input: OpenRuns = Vec::new();
let mut output: Vec<Option<usize>> = Vec::new();
let mut output: Vec<OpenRun> = Vec::new();
let mut data_idx = 0usize;
for (at, entry) in items.drain(..).enumerate() {
match entry {
AxisEntry::GroupHeader { level, .. } => open_at(&mut input, level, Some((at, entry))),
AxisEntry::GroupEnd { level } => open_at(&mut input, level, None),
AxisEntry::GroupHeader { level, depth, .. } => {
open_at(&mut input, (level, depth), Some((at, entry)))
}
AxisEntry::GroupEnd { level, depth } => open_at(&mut input, (level, depth), None),
AxisEntry::DataItem { .. } => {
if keep(data_idx) {
remark(&input, &mut output, &mut kept);
@@ -335,48 +348,58 @@ fn prune_axis_items(items: &mut Vec<AxisEntry>, keep: impl Fn(usize) -> bool) {
*items = kept;
}
/// Emit the markers that make `kept`'s open runs equal the input's, outermost
/// first: a header where a different run is open, a `GroupEnd` where none is.
fn remark(input: &OpenRuns, output: &mut Vec<Option<usize>>, kept: &mut Vec<AxisEntry>) {
for level in 0..input.len().max(output.len()) {
let want = input.get(level).cloned().flatten();
let have = output.get(level).copied().flatten();
if want.as_ref().map(|(at, _)| *at) == have {
continue;
}
// Deeper runs end first, innermost out, as `expand_category` ends them.
for deeper in (level + 1..output.len()).rev() {
if output[deeper].is_some() {
kept.push(AxisEntry::GroupEnd { level: deeper });
}
}
output.truncate(level);
output.resize(level, None);
match want {
Some((at, header)) => {
kept.push(header);
output.push(Some(at));
}
None => kept.push(AxisEntry::GroupEnd { level }),
}
fn group_end((level, depth): RunKey) -> AxisEntry {
AxisEntry::GroupEnd { level, depth }
}
/// Emit the markers that make `kept`'s open runs equal the input's: past the
/// runs they share, end the deeper ones innermost out, then open what differs.
fn remark(input: &OpenRuns, output: &mut Vec<OpenRun>, kept: &mut Vec<AxisEntry>) {
let shared = input
.iter()
.zip(output.iter())
.take_while(|((_, want, _), (_, have))| want == have)
.count();
let want = input.get(shared);
let have = output.get(shared).copied();
let Some(slot) = [want.map(|w| w.0), have.map(|h| h.0)]
.into_iter()
.flatten()
.min()
else {
return;
};
// Deeper runs end first, innermost out, as `expand_category` ends them.
for (key, _) in output[shared..].iter().rev().filter(|(k, _)| *k > slot) {
kept.push(group_end(*key));
}
output.truncate(shared);
let replaces = want.is_some_and(|w| w.0 == slot);
if !replaces && have.is_some_and(|h| h.0 == slot) {
kept.push(group_end(slot));
}
for (key, at, header) in &input[shared..] {
kept.push(header.clone());
output.push((*key, *at));
}
}
/// The one reading of the markers: each data item's open runs, by level, as
/// header positions, lent to `visit` — one stack, never copied (0r01).
/// The one reading of the markers: each data item's open runs, innermost last,
/// lent to `visit` — one stack, never copied (0r01).
fn walk_open_runs<T>(
items: &[AxisEntry],
mut visit: impl FnMut(&[Option<usize>]) -> ControlFlow<T>,
mut visit: impl FnMut(&[OpenRun]) -> ControlFlow<T>,
) -> Option<T> {
let mut open: Vec<Option<usize>> = Vec::new();
let mut open: Vec<OpenRun> = Vec::new();
for (at, entry) in items.iter().enumerate() {
match entry {
AxisEntry::GroupHeader { level, .. } => {
open.truncate(*level);
open.resize(*level, None);
open.push(Some(at));
AxisEntry::GroupHeader { level, depth, .. } => {
open.truncate(open.partition_point(|(k, _)| *k < (*level, *depth)));
open.push(((*level, *depth), at));
}
AxisEntry::GroupEnd { level, depth } => {
open.truncate(open.partition_point(|(k, _)| *k < (*level, *depth)))
}
AxisEntry::GroupEnd { level } => open.truncate(*level),
AxisEntry::DataItem { .. } => {
if let ControlFlow::Break(found) = visit(&open) {
return Some(found);
@@ -392,7 +415,7 @@ fn walk_open_runs<T>(
fn innermost_group(items: &[AxisEntry], idx: usize) -> Option<(String, String)> {
let mut data_idx = 0usize;
let at = walk_open_runs(items, |open| match data_idx == idx {
true => ControlFlow::Break(open.iter().rev().find_map(|run| *run)),
true => ControlFlow::Break(open.last().map(|(_, at)| *at)),
false => {
data_idx += 1;
ControlFlow::Continue(())
@@ -872,9 +895,13 @@ fn expand_category(
return vec![];
};
// The prefix holds one item per outer category, so its length is this level.
let level = prefix.len();
let mut result = Vec::new();
let mut last_group: Option<&str> = None;
let mut runs = CategoryRuns {
level: prefix.len(),
cat_name,
view,
open: Vec::new(),
out: Vec::new(),
};
// A group's items need not be contiguous, so a collapsed one folds ONCE.
let mut folded: Vec<&str> = Vec::new();
@@ -884,50 +911,77 @@ fn expand_category(
if view.is_hidden(cat_name, item_name) {
continue;
}
let item_group = cat
.items
.get(item_name.as_str())
.and_then(|i| i.group.as_deref());
let collapsed = item_group.filter(|g| view.is_group_collapsed(cat_name, g));
if collapsed.is_some_and(|g| folded.contains(&g)) {
let chain = cat.item_chain(item_name);
// The OUTERMOST collapsed group folds every leaf beneath it (§19).
let fold_depth =
(0..chain.len()).find(|&depth| view.is_group_collapsed(cat_name, &chain[..=depth]));
let fold = fold_depth.map(|depth| chain[depth]);
if fold.is_some_and(|g| folded.contains(&g)) {
continue;
}
// A header on entering a group; a `GroupEnd` only on leaving one for
// NO group — another group's header already ends the run.
if item_group != last_group {
if last_group.is_some() && item_group.is_none() {
result.push(AxisEntry::GroupEnd { level });
}
if let Some(g) = item_group {
result.push(AxisEntry::GroupHeader {
level,
cat_name: cat_name.to_string(),
group_name: g.to_string(),
});
}
last_group = item_group;
}
runs.enter(&chain[..fold_depth.map_or(chain.len(), |d| d + 1)]);
let mut tuple = prefix.clone();
match collapsed {
match fold {
Some(g) => {
folded.push(g);
tuple.push(g.to_string());
}
None => tuple.push(item_name.to_string()),
}
result.push(AxisEntry::DataItem {
runs.out.push(AxisEntry::DataItem {
tuple,
collapsed: prefix_collapsed || collapsed.is_some(),
collapsed: prefix_collapsed || fold.is_some(),
});
}
// The expansion is the end of the run too: the next prefix's items follow
// immediately and must not inherit it (improvise-gd83).
if last_group.is_some() {
result.push(AxisEntry::GroupEnd { level });
runs.enter(&[]);
runs.out
}
/// One category's group runs as `expand_category` emits them: the chain of groups
/// open, root first, and the markers that move it from item to item.
struct CategoryRuns<'a> {
level: usize,
cat_name: &'a str,
view: &'a View,
open: Vec<&'a str>,
out: Vec<AxisEntry>,
}
impl<'a> CategoryRuns<'a> {
/// Move the open chain to `chain` in `remark`'s own convention, so an unpruned
/// axis survives pruning marker for marker (core.md §6).
fn enter(&mut self, chain: &[&'a str]) {
let shared = self
.open
.iter()
.zip(chain)
.take_while(|(open, want)| open == want)
.count();
let level = self.level;
for depth in (shared + 1..self.open.len()).rev() {
self.out.push(AxisEntry::GroupEnd { level, depth });
}
if chain.len() == shared && self.open.len() > shared {
self.out.push(AxisEntry::GroupEnd {
level,
depth: shared,
});
}
self.open.truncate(shared);
for (depth, group) in chain.iter().enumerate().skip(shared) {
self.out.push(AxisEntry::GroupHeader {
level,
depth,
cat_name: self.cat_name.to_string(),
group_name: group.to_string(),
collapsed: self.view.is_group_collapsed(self.cat_name, &chain[..=depth]),
});
self.open.push(group);
}
}
result
}
/// Cartesian product of visible items across `cats`, headers always emitted.
@@ -1026,8 +1080,10 @@ mod tests {
AxisEntry::item(vec!["A".into()]),
AxisEntry::GroupHeader {
level: 0,
depth: 0,
cat_name: "C".into(),
group_name: "G".into(),
collapsed: false,
},
AxisEntry::item(vec!["B".into()]),
AxisEntry::item(vec!["C".into()]),
@@ -1051,8 +1107,10 @@ mod tests {
let mut items = vec![
AxisEntry::GroupHeader {
level: 0,
depth: 0,
cat_name: "C".into(),
group_name: "G".into(),
collapsed: false,
},
AxisEntry::item(vec!["A".into()]),
AxisEntry::item(vec!["B".into()]),
@@ -1418,6 +1476,7 @@ mod tests {
wb.add_item_in_group("Month", "Jan", "Q1").unwrap();
wb.add_item_in_group("Month", "Feb", "Q1").unwrap();
wb.add_item_in_group("Month", "Apr", "Q2").unwrap();
wb.active_view_mut().set_expand_depth("Month", 1);
wb.add_item("Type", "Food").unwrap();
let layout = GridLayout::new(&wb.model, wb.active_view());
assert_eq!(layout.row_count(), 3); // Jan, Feb, Apr — headers don't count
@@ -1430,6 +1489,7 @@ mod tests {
wb.add_category("Type").unwrap();
wb.add_item_in_group("Month", "Jan", "Q1").unwrap();
wb.add_item_in_group("Month", "Apr", "Q2").unwrap();
wb.active_view_mut().set_expand_depth("Month", 1);
wb.add_item("Type", "Food").unwrap();
let layout = GridLayout::new(&wb.model, wb.active_view());
let headers: Vec<_> = layout
@@ -1457,8 +1517,9 @@ mod tests {
wb.add_item_in_group("Month", "Feb", "Q1").unwrap();
wb.add_item_in_group("Month", "Apr", "Q2").unwrap();
wb.add_item_in_group("Month", "May", "Q2").unwrap();
wb.active_view_mut().set_expand_depth("Month", 1);
wb.add_item("Type", "Food").unwrap();
wb.active_view_mut().toggle_group_collapse("Month", "Q1");
wb.active_view_mut().toggle_group_collapse("Month", &["Q1"]);
let layout = GridLayout::new(&wb.model, wb.active_view());
let labels: Vec<String> = (0..layout.row_count())
.map(|r| layout.row_label(r))
@@ -1489,8 +1550,9 @@ mod tests {
wb.add_item_in_group("Month", "A", "G1").unwrap();
wb.add_item_in_group("Month", "B", "G2").unwrap();
wb.add_item_in_group("Month", "C", "G1").unwrap();
wb.active_view_mut().set_expand_depth("Month", 1);
wb.add_item("Type", "Food").unwrap();
wb.active_view_mut().toggle_group_collapse("Month", "G1");
wb.active_view_mut().toggle_group_collapse("Month", &["G1"]);
let layout = GridLayout::new(&wb.model, wb.active_view());
let labels: Vec<String> = (0..layout.row_count())
.map(|r| layout.row_label(r))
@@ -1518,10 +1580,11 @@ mod tests {
wb.add_category("Type").unwrap();
wb.add_item_in_group("Month", "Jan", "Q1").unwrap();
wb.add_item_in_group("Month", "Apr", "Q2").unwrap();
wb.active_view_mut().set_expand_depth("Month", 1);
wb.add_item("Region", "North").unwrap();
wb.add_item("Type", "Food").unwrap();
wb.active_view_mut().set_axis("Region", Axis::Row);
wb.active_view_mut().toggle_group_collapse("Month", "Q1");
wb.active_view_mut().toggle_group_collapse("Month", &["Q1"]);
let layout = GridLayout::new(&wb.model, wb.active_view());
assert_eq!(
layout.row_tuple(0),
@@ -1543,6 +1606,7 @@ mod tests {
wb.add_category("Type").unwrap();
wb.add_item_in_group("Month", "Jan", "Q1").unwrap();
wb.add_item_in_group("Month", "Feb", "Q1").unwrap();
wb.active_view_mut().set_expand_depth("Month", 1);
wb.add_item("Month", "Sep").unwrap();
wb.add_item("Type", "Food").unwrap();
let layout = GridLayout::new(&wb.model, wb.active_view());
@@ -1572,6 +1636,7 @@ mod tests {
wb.add_item_in_group("Recipient", "Alice", "Family")
.unwrap();
wb.add_item_in_group("Recipient", "Bob", "Family").unwrap();
wb.active_view_mut().set_expand_depth("Recipient", 1);
wb.active_view_mut().set_axis("Recipient", Axis::Row);
let layout = GridLayout::new(&wb.model, wb.active_view());
@@ -1612,6 +1677,7 @@ mod tests {
wb.add_category("Type").unwrap();
wb.add_item_in_group("Month", "Jan", "Q1").unwrap();
wb.add_item_in_group("Month", "Apr", "Q2").unwrap();
wb.active_view_mut().set_expand_depth("Month", 1);
wb.add_item("Type", "Food").unwrap();
wb.model.set_cell(
coord(&[("Month", "Apr"), ("Type", "Food")]),
@@ -1630,6 +1696,7 @@ mod tests {
wb.add_category("Type").unwrap();
wb.add_item_in_group("Month", "Jan", "Q1").unwrap();
wb.add_item_in_group("Month", "Apr", "Q2").unwrap();
wb.active_view_mut().set_expand_depth("Month", 1);
wb.add_item("Type", "Food").unwrap();
let layout = GridLayout::new(&wb.model, wb.active_view());
// visual: [GroupHeader(Q1), DataItem(Jan), GroupHeader(Q2), DataItem(Apr)]
@@ -1646,6 +1713,7 @@ mod tests {
wb.add_item("Type", "Food").unwrap();
wb.add_item_in_group("Month", "Jan", "Q1").unwrap();
wb.add_item_in_group("Month", "Apr", "Q2").unwrap();
wb.active_view_mut().set_expand_depth("Month", 1);
let layout = GridLayout::new(&wb.model, wb.active_view());
// col_items: [GroupHeader(Q1), DataItem(Jan), GroupHeader(Q2), DataItem(Apr)]
assert_eq!(layout.data_col_to_visual(0), Some(1));
@@ -1660,6 +1728,7 @@ mod tests {
wb.add_category("Type").unwrap();
wb.add_item_in_group("Month", "Jan", "Q1").unwrap();
wb.add_item_in_group("Month", "Apr", "Q2").unwrap();
wb.active_view_mut().set_expand_depth("Month", 1);
wb.add_item("Type", "Food").unwrap();
let layout = GridLayout::new(&wb.model, wb.active_view());
assert_eq!(
@@ -1691,6 +1760,7 @@ mod tests {
wb.add_item("Type", "Food").unwrap();
wb.add_item_in_group("Month", "Jan", "Q1").unwrap();
wb.add_item_in_group("Month", "Apr", "Q2").unwrap();
wb.active_view_mut().set_expand_depth("Month", 1);
let layout = GridLayout::new(&wb.model, wb.active_view());
assert_eq!(
layout.col_group_for(0),
@@ -1725,6 +1795,7 @@ mod tests {
None => wb.add_item("Month", *name).unwrap(),
};
}
wb.active_view_mut().set_expand_depth("Month", 1);
wb.add_item("Type", "Food").unwrap();
for m in funded {
wb.model.set_cell(
@@ -1755,7 +1826,7 @@ mod tests {
&[("Jan", Some("Q1")), ("Mar", None), ("Sep", None)],
&["Jan", "Sep"],
);
wb.active_view_mut().toggle_group_collapse("Month", "Q1");
wb.active_view_mut().toggle_group_collapse("Month", &["Q1"]);
let layout = GridLayout::with_frozen_records(&wb.model, wb.active_view(), None);
// The whole sequence, not `any(is Q1)`: that cannot see a header
// pushed in the wrong place, nor a stray `GroupEnd`.
@@ -1764,14 +1835,16 @@ mod tests {
vec![
AxisEntry::GroupHeader {
level: 0,
depth: 0,
cat_name: "Month".into(),
group_name: "Q1".into()
group_name: "Q1".into(),
collapsed: true,
},
AxisEntry::DataItem {
tuple: vec!["Q1".into()],
collapsed: true
},
AxisEntry::GroupEnd { level: 0 },
AxisEntry::GroupEnd { level: 0, depth: 0 },
AxisEntry::item(vec!["Sep".into()]),
],
"Q1 folds to one funded row under its header, and Mar pruned"
@@ -1814,6 +1887,8 @@ mod tests {
wb.add_item_in_group("Region", "South", "Lower").unwrap();
wb.add_item_in_group("Month", "Jan", "Q1").unwrap();
wb.add_item_in_group("Month", "Feb", "Q1").unwrap();
wb.active_view_mut().set_expand_depth("Region", 1);
wb.active_view_mut().set_expand_depth("Month", 1);
wb.add_item("Type", "Food").unwrap();
for month in ["Jan", "Feb"] {
wb.model.set_cell(
@@ -1895,6 +1970,7 @@ mod tests {
wb.add_item_in_group("Month", "Apr", "Q2").unwrap();
wb.add_item_in_group("Month", "Jan", "Q1").unwrap();
wb.add_item_in_group("Month", "Feb", "Q1").unwrap();
wb.active_view_mut().set_expand_depth("Month", 1);
wb.add_item("_Measure", "Revenue").unwrap();
wb.add_item("_Measure", "Cost").unwrap();
for (month, revenue, cost) in [("Jan", 10.0, 5.0), ("Feb", 30.0, 3.0)] {
@@ -1937,7 +2013,7 @@ mod tests {
.collect();
let chain: Vec<&str> = chain.iter().map(String::as_str).collect();
let mut wb = margin_book(&chain);
wb.active_view_mut().toggle_group_collapse("Month", "Q1");
wb.active_view_mut().toggle_group_collapse("Month", &["Q1"]);
wb.model.recompute_formulas(&wb.active_view().none_cats());
let unpruned = GridLayout::new(&wb.model, wb.active_view());
let kept_by_fresh_evaluation: Vec<String> = (0..unpruned.row_count())
@@ -1976,7 +2052,7 @@ mod tests {
.collect();
let chain: Vec<&str> = chain.iter().map(String::as_str).collect();
let mut wb = margin_book(&chain);
wb.active_view_mut().toggle_group_collapse("Month", "Q1");
wb.active_view_mut().toggle_group_collapse("Month", &["Q1"]);
wb.model.recompute_formulas(&wb.active_view().none_cats());
let layout = GridLayout::new(&wb.model, wb.active_view());
let q1 = (0..layout.row_count())
@@ -2055,7 +2131,7 @@ mod tests {
#[test]
fn a_grid_evaluates_under_its_own_covers_not_another_grids() {
let mut wb = margin_book(&["R = Revenue / Cost"]);
wb.active_view_mut().toggle_group_collapse("Month", "Q1");
wb.active_view_mut().toggle_group_collapse("Month", &["Q1"]);
let all = GridLayout::new(&wb.model, wb.active_view());
let mut no_feb_view = wb.active_view().clone();
no_feb_view.hide_item("Month", "Feb");
@@ -2093,7 +2169,7 @@ mod tests {
let mut folded = margin_book(formulas);
folded
.active_view_mut()
.toggle_group_collapse("Month", "Q1");
.toggle_group_collapse("Month", &["Q1"]);
let fold = grid_value(&mut folded, "Q1", "Margin").expect("a Q1 fold");
let mut none = margin_book(formulas);
@@ -2120,7 +2196,7 @@ mod tests {
let mut folded = margin_book(&formulas);
folded
.active_view_mut()
.toggle_group_collapse("Month", "Q1");
.toggle_group_collapse("Month", &["Q1"]);
let fold = grid_value(&mut folded, "Q1", measure);
let mut none = margin_book(&formulas);
none.active_view_mut().set_axis("Month", Axis::None);
@@ -2133,7 +2209,7 @@ mod tests {
let mut folded = margin_book(&formulas);
folded
.active_view_mut()
.toggle_group_collapse("Month", "Q1");
.toggle_group_collapse("Month", &["Q1"]);
assert_eq!(
grid_value(&mut folded, "Q1", "Flag"),
Some(0.0),
@@ -2146,7 +2222,7 @@ mod tests {
#[test]
fn a_folds_aggregate_formula_pools_its_members_records() {
let mut wb = margin_book(&["Top = MAX(Revenue)", "Total = SUM(Revenue)"]);
wb.active_view_mut().toggle_group_collapse("Month", "Q1");
wb.active_view_mut().toggle_group_collapse("Month", &["Q1"]);
assert_eq!(grid_value(&mut wb, "Q1", "Total"), Some(40.0));
assert_eq!(grid_value(&mut wb, "Q1", "Top"), Some(30.0));
}
@@ -2159,7 +2235,7 @@ mod tests {
let mut folded = margin_book(&formula);
folded
.active_view_mut()
.toggle_group_collapse("Month", "Q1");
.toggle_group_collapse("Month", &["Q1"]);
let fold = grid_value(&mut folded, "Q1", "Jans");
let mut none = margin_book(&formula);
@@ -2190,6 +2266,7 @@ mod tests {
wb.add_item("Type", "Food").unwrap();
for (month, quarter, n) in [("Jan", "Q1", 3.0), ("Feb", "Q1", 4.0), ("Apr", "Q2", 100.0)] {
wb.add_item_in_group("Month", month, quarter).unwrap();
wb.active_view_mut().set_expand_depth("Month", 1);
wb.model.set_cell(
CellKey::new(vec![
("Month".into(), month.into()),
@@ -2203,7 +2280,7 @@ mod tests {
wb.active_view_mut().hide_item("Month", "Feb");
assert_eq!(column_total(&wb), 103.0, "expanded: Jan and Apr are drawn");
wb.active_view_mut().toggle_group_collapse("Month", "Q1");
wb.active_view_mut().toggle_group_collapse("Month", &["Q1"]);
assert_eq!(column_total(&wb), 103.0, "z moved the total");
assert_eq!(
grid_value(&mut wb, "Q1", "Food"),
@@ -2290,7 +2367,7 @@ mod group_membership {
use std::ops::ControlFlow;
/// Every data item's open runs, collected through the one reader.
fn open_runs(items: &[AxisEntry]) -> Vec<Vec<Option<usize>>> {
fn open_runs(items: &[AxisEntry]) -> Vec<Vec<super::OpenRun>> {
let mut all = Vec::new();
walk_open_runs(items, |open| {
all.push(open.to_vec());
@@ -2300,7 +2377,13 @@ mod group_membership {
}
type Level<'a> = (&'a str, &'a [(&'a str, Option<&'a str>)]);
type Fixture<'a> = (&'a [Level<'a>], &'a [(&'a str, &'a str)]);
type Fixture<'a> = (
&'a [Level<'a>],
&'a [(&'a str, &'a str)],
&'a [Parent<'a>],
);
/// `(category, group, parent)`: a `>` line.
type Parent<'a> = (&'a str, &'a str, &'a str);
const TWO: &[Level] = &[
("Region", &[("North", Some("Upper")), ("South", None)]),
@@ -2354,19 +2437,74 @@ mod group_membership {
(
TWO,
&[("Region", "Upper"), ("Month", "Q1"), ("Month", "Q2")],
&[],
),
(
THREE,
&[("Region", "Upper"), ("Month", "Q1"), ("Kind", "Goods")],
&[],
),
(NONCONTIGUOUS, &[("Region", "Upper"), ("Month", "Q1")]),
(HOLE, &[("Region", "Upper"), ("Month", "Q1")]),
(NONCONTIGUOUS, &[("Region", "Upper"), ("Month", "Q1")], &[]),
(HOLE, &[("Region", "Upper"), ("Month", "Q1")], &[]),
(
ADJACENT,
&[("Region", "Upper"), ("Region", "Lower"), ("Month", "Q1")],
&[],
),
(
NESTED,
&[
("Region", "Upper"),
("Month", "H1"),
("Month", "Q1"),
("Month", "H2"),
],
NESTED_PARENTS,
),
];
/// improvise-felo.6: a hierarchy INSIDE one category, under an outer group.
/// Q1 is non-contiguous, and Jun sits directly in H1 after its child Q2.
const NESTED: &[Level] = &[
("Region", &[("North", Some("Upper")), ("South", None)]),
(
"Month",
&[
("Jan", Some("Q1")),
("Apr", Some("Q2")),
("Feb", Some("Q1")),
("Jun", Some("H1")),
("Jul", Some("Q3")),
],
),
];
const NESTED_PARENTS: &[Parent] = &[
("Month", "Q1", "H1"),
("Month", "Q2", "H1"),
("Month", "Q3", "H2"),
];
/// [`book`] with `parents` declared and every group OPEN before `collapsed`
/// folds its own, so a subset of groups is exactly what is collapsed.
fn nested_book(levels: &[Level], parents: &[Parent], collapsed: &[(&str, &str)]) -> Workbook {
let mut wb = book(levels, &[]);
for (cat, group, parent) in parents {
let c = wb.model.category_mut(cat).unwrap();
c.add_group(crate::model::category::Group::new(*group).with_parent(*parent));
}
for (cat, _) in levels {
wb.active_view_mut().set_expand_depth(cat, 8);
}
for (cat, group) in collapsed {
let chain: Vec<String> = wb.model.category(cat).unwrap().group_chain(group)
.into_iter().map(String::from).collect();
let chain: Vec<&str> = chain.iter().map(String::as_str).collect();
wb.active_view_mut().toggle_group_collapse(cat, &chain);
}
wb
}
/// `levels` on Row, one column, the groups `collapsed` names folded. No cell
/// is set: the unpruned layout has every row, and pruning is driven directly.
fn book(levels: &[Level], collapsed: &[(&str, &str)]) -> Workbook {
@@ -2379,13 +2517,14 @@ mod group_membership {
None => wb.add_item(cat, *item).unwrap(),
};
}
wb.active_view_mut().set_expand_depth(cat, 1);
wb.active_view_mut().set_axis(cat, Axis::Row);
}
wb.add_category("Col").unwrap();
wb.add_item("Col", "X").unwrap();
wb.active_view_mut().set_axis("Col", Axis::Column);
for (cat, group) in collapsed {
wb.active_view_mut().toggle_group_collapse(cat, group);
wb.active_view_mut().toggle_group_collapse(cat, &[group]);
}
wb
}
@@ -2406,13 +2545,13 @@ mod group_membership {
/// non-empty set of kept rows. The layout is the cost, so it is built once.
fn cases() -> Vec<Case> {
let mut out = Vec::new();
for (fixture, (levels, groups)) in FIXTURES.iter().enumerate() {
for (fixture, (levels, groups, parents)) in FIXTURES.iter().enumerate() {
for folds in 0..(1u32 << groups.len()) {
let collapsed: Vec<(&str, &str)> = (0..groups.len())
.filter(|g| folds & (1 << g) != 0)
.map(|g| groups[g])
.collect();
let full = layout(&book(levels, &collapsed)).row_items;
let full = layout(&nested_book(levels, parents, &collapsed)).row_items;
let rows = data_items(&full).count();
for mask in 1..(1u32 << rows) {
let mut pruned = full.clone();
@@ -2441,8 +2580,8 @@ mod group_membership {
}
}
fn at(runs: &[Option<usize>], level: usize) -> Option<usize> {
runs.get(level).copied().flatten()
fn at(runs: &[super::OpenRun], key: super::RunKey) -> Option<usize> {
runs.iter().find(|(k, _)| *k == key).map(|(_, at)| *at)
}
#[test]
@@ -2463,24 +2602,25 @@ mod group_membership {
if kept.len() == full_runs.len() {
assert_eq!(pruned, full, "{name}: nothing pruned");
}
let levels = full_runs.iter().map(Vec::len).max().unwrap_or(0);
let levels: std::collections::BTreeSet<super::RunKey> =
full_runs.iter().flatten().map(|(k, _)| *k).collect();
for (r, &u) in kept.iter().enumerate() {
for level in 0..levels {
for &level in &levels {
assert_eq!(
named(pruned, at(&pruned_runs[r], level)),
named(full, at(&full_runs[u], level)),
"{name}: row {r}, level {level}\n pruned {pruned:?}\n full {full:?}"
"{name}: row {r}, level {level:?}\n pruned {pruned:?}\n full {full:?}"
);
if r == 0 {
continue;
}
let same = |runs: &[Vec<Option<usize>>], a: usize, b: usize| {
let same = |runs: &[Vec<super::OpenRun>], a: usize, b: usize| {
at(&runs[a], level).is_some() && at(&runs[a], level) == at(&runs[b], level)
};
assert_eq!(
same(&pruned_runs, r - 1, r),
same(&full_runs, kept[r - 1], u),
"{name}: run identity at level {level}, row {r}\n pruned {pruned:?}\n full {full:?}"
"{name}: run identity at level {level:?}, row {r}\n pruned {pruned:?}\n full {full:?}"
);
}
}
@@ -2538,14 +2678,18 @@ mod group_membership {
let items = vec![
AxisEntry::GroupHeader {
level: 0,
depth: 0,
cat_name: "Region".into(),
group_name: "Upper".into(),
collapsed: false,
},
AxisEntry::item(vec!["North".into()]),
AxisEntry::GroupHeader {
level: usize::MAX,
depth: 0,
cat_name: "Nowhere".into(),
group_name: "Unreachable".into(),
collapsed: false,
},
AxisEntry::item(vec!["South".into()]),
];
@@ -2561,14 +2705,16 @@ mod group_membership {
fn a_late_lookup_answers_for_its_own_row() {
let header = |group: &str| AxisEntry::GroupHeader {
level: 0,
depth: 0,
cat_name: "Month".into(),
group_name: group.into(),
collapsed: false,
};
let items = vec![
header("Q1"),
AxisEntry::item(vec!["Jan".into()]),
AxisEntry::item(vec!["Feb".into()]),
AxisEntry::GroupEnd { level: 0 },
AxisEntry::GroupEnd { level: 0, depth: 0 },
AxisEntry::item(vec!["Mar".into()]),
header("Q2"),
AxisEntry::item(vec!["Apr".into()]),
@@ -2601,4 +2747,216 @@ mod group_membership {
);
assert_eq!(l.row_group_for(at(["South", "Mar"])), None);
}
/// Precondition for the enumeration above: NESTED must really produce runs
/// below depth 0, or it enumerates nothing new.
#[test]
fn the_nested_fixture_draws_runs_inside_one_category() {
let wb = nested_book(NESTED, NESTED_PARENTS, &[]);
let deep = layout(&wb)
.row_items
.iter()
.filter(|e| matches!(e, AxisEntry::GroupHeader { depth: 1, .. }))
.count();
assert!(deep >= 4, "Q1, Q2, Q1 again and Q3 under each region: {deep}");
}
}
/// improvise-felo.6: a hierarchy inside one category — Year > Quarter > Month.
#[cfg(test)]
mod hierarchy {
use super::{AxisEntry, GridLayout};
use crate::model::category::Group;
use crate::model::cell::{CellKey, CellValue};
use crate::view::Axis;
use crate::workbook::Workbook;
/// `2024-02` is added AFTER `2025-01` and `Undated`, so Q1-2024 and 2024 are
/// non-contiguous. Values are powers of ten, so every fold is its own sum.
fn dates() -> Workbook {
let mut wb = Workbook::new("T");
wb.add_category("Date").unwrap();
for (month, quarter) in [
("2024-01", Some("Q1-2024")),
("2024-04", Some("Q2-2024")),
("2025-01", Some("Q1-2025")),
("Undated", None),
("2024-02", Some("Q1-2024")),
] {
match quarter {
Some(q) => wb.add_item_in_group("Date", month, q).unwrap(),
None => wb.add_item("Date", month).unwrap(),
};
}
let cat = wb.model.category_mut("Date").unwrap();
for (quarter, year) in [
("Q1-2024", "2024"),
("Q2-2024", "2024"),
("Q1-2025", "2025"),
] {
cat.add_group(Group::new(quarter).with_parent(year));
}
wb.add_item("_Measure", "Revenue").unwrap();
wb.active_view_mut().set_axis("Date", Axis::Row);
wb.active_view_mut().set_axis("_Measure", Axis::Column);
for (month, v) in [
("2024-01", 1.0),
("2024-02", 10.0),
("2024-04", 100.0),
("2025-01", 1000.0),
("Undated", 5.0),
] {
wb.model.set_cell(key(month), CellValue::Number(v));
}
wb
}
fn key(month: &str) -> CellKey {
CellKey::new(vec![
("Date".into(), month.into()),
("_Measure".into(), "Revenue".into()),
])
}
fn layout(wb: &Workbook) -> GridLayout {
GridLayout::with_frozen_records(&wb.model, wb.active_view(), None)
}
fn rows(l: &GridLayout) -> Vec<String> {
(0..l.row_count()).map(|r| l.row_label(r)).collect()
}
fn value(wb: &Workbook, l: &GridLayout, label: &str) -> Option<f64> {
let r = rows(l).iter().position(|x| x == label)?;
l.evaluate(&wb.model, &l.cell_key(r, 0)?)?.as_f64()
}
fn header(depth: usize, group: &str, collapsed: bool) -> AxisEntry {
AxisEntry::GroupHeader {
level: 0,
depth,
cat_name: "Date".into(),
group_name: group.into(),
collapsed,
}
}
fn end(depth: usize) -> AxisEntry {
AxisEntry::GroupEnd { level: 0, depth }
}
fn item(name: &str) -> AxisEntry {
AxisEntry::item(vec![name.into()])
}
/// Opened to depth 2, the whole sequence: runs nest by depth, a new year's
/// header replaces the old, and a non-contiguous quarter reopens both.
#[test]
fn a_year_quarter_month_hierarchy_nests_its_runs() {
let mut wb = dates();
wb.active_view_mut().set_expand_depth("Date", 2);
assert_eq!(
layout(&wb).row_items,
vec![
header(0, "2024", false),
header(1, "Q1-2024", false),
item("2024-01"),
header(1, "Q2-2024", false),
item("2024-04"),
end(1),
header(0, "2025", false),
header(1, "Q1-2025", false),
item("2025-01"),
end(1),
end(0),
item("Undated"),
header(0, "2024", false),
header(1, "Q1-2024", false),
item("2024-02"),
]
);
}
/// The user's ruling: a hierarchy starts closed, one row per root.
#[test]
fn a_hierarchy_opens_closed_as_one_fold_per_root() {
let wb = dates();
let l = layout(&wb);
assert_eq!(rows(&l), vec!["2024", "2025", "Undated"]);
assert_eq!(value(&wb, &l, "2024"), Some(111.0), "every month of 2024");
assert_eq!(value(&wb, &l, "2025"), Some(1000.0));
assert!(l.row_folds_a_group(0) && !l.row_folds_a_group(2));
}
/// Open the years only: each quarter folds its months, ONCE, even when its
/// months are not contiguous.
#[test]
fn a_collapsed_group_at_an_inner_level_folds_its_leaves_once() {
let mut wb = dates();
wb.active_view_mut().set_expand_depth("Date", 1);
let l = layout(&wb);
assert_eq!(rows(&l), vec!["Q1-2024", "Q2-2024", "Q1-2025", "Undated"]);
assert_eq!(value(&wb, &l, "Q1-2024"), Some(11.0), "Jan and the late Feb");
assert_eq!(value(&wb, &l, "Q2-2024"), Some(100.0));
}
/// Collapsing an outer group folds everything under it, whatever its
/// children's own state.
#[test]
fn collapsing_a_parent_folds_its_children_whatever_their_state() {
let mut wb = dates();
wb.active_view_mut().set_expand_depth("Date", 2);
wb.active_view_mut().toggle_group_collapse("Date", &["2024"]);
let l = layout(&wb);
assert_eq!(rows(&l), vec!["2024", "2025-01", "Undated"]);
assert_eq!(value(&wb, &l, "2024"), Some(111.0));
}
/// `z` names the innermost open group: a month's quarter, a quarter fold's
/// own quarter, a year fold's year — at a nonzero row each time.
#[test]
fn the_group_under_the_cursor_is_the_innermost_at_every_depth() {
let mut wb = dates();
wb.active_view_mut().set_expand_depth("Date", 1);
wb.active_view_mut()
.toggle_group_collapse("Date", &["2024", "Q2-2024"]);
let l = layout(&wb);
let at = |label: &str| rows(&l).iter().position(|x| x == label).unwrap();
let g = |name: &str| Some(("Date".to_string(), name.to_string()));
assert_eq!(l.row_group_for(at("2024-04")), g("Q2-2024"));
assert_eq!(l.row_group_for(at("Q1-2025")), g("Q1-2025"));
assert_eq!(l.row_group_for(at("Undated")), None);
wb.active_view_mut().set_expand_depth("Date", 0);
let l = layout(&wb);
assert_eq!(l.row_group_for(1), g("2025"));
}
/// A hidden month leaves every fold above it; a quarter whose every month
/// is hidden has no row and no header.
#[test]
fn hidden_items_leave_every_fold_above_them() {
let mut wb = dates();
wb.active_view_mut().hide_item("Date", "2024-04");
let l = layout(&wb);
assert_eq!(value(&wb, &l, "2024"), Some(11.0), "2024-04 hidden");
wb.active_view_mut().set_expand_depth("Date", 2);
let l = layout(&wb);
let q2 = |e: &AxisEntry| {
matches!(e, AxisEntry::GroupHeader { group_name, .. } if group_name == "Q2-2024")
};
assert!(!l.row_items.iter().any(q2), "{:?}", l.row_items);
}
/// `prune_empty` at depth: an empty quarter goes, header and all, and the
/// year above it stays for the quarter that has data.
#[test]
fn pruning_drops_an_empty_inner_group_and_keeps_its_parent() {
let mut wb = dates();
wb.model.clear_cell(&key("2024-04"));
wb.active_view_mut().set_expand_depth("Date", 1);
wb.active_view_mut().prune_empty = true;
let l = layout(&wb);
assert_eq!(rows(&l), vec!["Q1-2024", "Q1-2025", "Undated"]);
assert_eq!(l.row_items[0], header(0, "2024", false));
}
}
+2 -1
View File
@@ -9,5 +9,6 @@ pub use layout::{
records_steps, synthetic_record_info,
};
pub use types::{
DEFAULT_VIEW, DRILL_VIEW, RECORDS_VIEW, VIRTUAL_VIEW_NAMES, View, unused_view_name,
DEFAULT_VIEW, DRILL_VIEW, GroupFold, RECORDS_VIEW, VIRTUAL_VIEW_NAMES, View,
unused_view_name,
};
+164 -38
View File
@@ -27,6 +27,15 @@ pub fn unused_view_name<'a>(existing: impl IntoIterator<Item = &'a str>) -> Stri
.expect("a name is free below usize::MAX, there being fewer views")
}
/// One group's own fold, overriding what it inherits (core.md §19).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum GroupFold {
/// `collapsed: Cat/Group` — closed, whatever reaches it from above.
Collapsed,
/// `expanded(N): Cat/Group` — open, with N-1 levels beneath it; N >= 1.
Expanded(usize),
}
#[derive(Clone)]
pub struct View {
pub name: String,
@@ -36,8 +45,11 @@ pub struct View {
pub page_selections: HashMap<String, String>,
/// Hidden items per category
pub hidden_items: HashMap<String, HashSet<String>>,
/// Collapsed groups per category
pub collapsed_groups: HashMap<String, HashSet<String>>,
/// How deep each category's hierarchy opens: groups shallower than this start
/// open, the rest closed. Absent is 0 — everything closed (core.md §19).
pub expand_depth: HashMap<String, usize>,
/// Per-group answers that override the depth default, in either direction.
pub group_folds: HashMap<String, HashMap<String, GroupFold>>,
/// Number format string (e.g. ",.0f" for comma-separated integer)
pub number_format: String,
/// When true, empty rows/columns are pruned from the display.
@@ -54,7 +66,8 @@ impl std::fmt::Debug for View {
category_axes,
page_selections,
hidden_items,
collapsed_groups,
expand_depth,
group_folds,
number_format,
prune_empty,
} = self;
@@ -64,12 +77,18 @@ impl std::fmt::Debug for View {
.collect()
};
let pages: BTreeMap<_, _> = page_selections.iter().collect();
let depths: BTreeMap<_, _> = expand_depth.iter().collect();
let folds: BTreeMap<_, BTreeMap<_, _>> = group_folds
.iter()
.map(|(k, v)| (k, v.iter().collect()))
.collect();
f.debug_struct("View")
.field("name", name)
.field("category_axes", category_axes)
.field("page_selections", &pages)
.field("hidden_items", &sorted(hidden_items))
.field("collapsed_groups", &sorted(collapsed_groups))
.field("expand_depth", &depths)
.field("group_folds", &folds)
.field("number_format", number_format)
.field("prune_empty", prune_empty)
.finish()
@@ -88,7 +107,8 @@ impl View {
category_axes: IndexMap::new(),
page_selections: HashMap::new(),
hidden_items: HashMap::new(),
collapsed_groups: HashMap::new(),
expand_depth: HashMap::new(),
group_folds: HashMap::new(),
// Decimals by default: a ratio is the case a format cannot be
// guessed for, and `.0` draws 2/3 as `1` (improvise-e0ex).
number_format: ",.2".to_string(),
@@ -135,7 +155,8 @@ impl View {
self.category_axes.shift_remove(cat_name);
self.page_selections.remove(cat_name);
self.hidden_items.remove(cat_name);
self.collapsed_groups.remove(cat_name);
self.expand_depth.remove(cat_name);
self.group_folds.remove(cat_name);
}
/// Forget an item this view names. A hidden or paged-to item that outlives
@@ -222,23 +243,78 @@ impl View {
self.page_selections.get(cat_name).map(|s| s.as_str())
}
pub fn toggle_group_collapse(&mut self, cat_name: &str, group_name: &str) {
let set = self
.collapsed_groups
.entry(cat_name.to_string())
.or_default();
if set.contains(group_name) {
set.remove(group_name);
} else {
set.insert(group_name.to_string());
/// How many of `cat_name`'s top levels open (`expanded(N): Cat`); 0 — all
/// closed — if unset.
pub fn expand_depth(&self, cat_name: &str) -> usize {
self.expand_depth.get(cat_name).copied().unwrap_or(0)
}
/// Open `cat_name`'s top `depth` levels; 0 forgets the setting.
pub fn set_expand_depth(&mut self, cat_name: &str, depth: usize) {
match depth {
0 => self.expand_depth.remove(cat_name),
_ => self.expand_depth.insert(cat_name.to_string(), depth),
};
}
/// Whether the group ending `chain` (root first, from `Category::group_chain`)
/// is collapsed: its own fold wins, else what it inherits (core.md §19).
pub fn is_group_collapsed(&self, cat_name: &str, chain: &[&str]) -> bool {
match chain.last().and_then(|g| self.group_fold(cat_name, g)) {
Some(GroupFold::Collapsed) => true,
Some(GroupFold::Expanded(_)) => false,
None => self.inherits_collapsed(cat_name, chain),
}
}
pub fn is_group_collapsed(&self, cat_name: &str, group_name: &str) -> bool {
self.collapsed_groups
.get(cat_name)
.map(|s| s.contains(group_name))
.unwrap_or(false)
/// The answer without the group's own fold: open when the category's depth or
/// an ancestor's `expanded(N)` reaches it, closed otherwise.
fn inherits_collapsed(&self, cat_name: &str, chain: &[&str]) -> bool {
let depth = chain.len().saturating_sub(1);
let by_ancestor = chain[..depth].iter().enumerate().any(|(at, ancestor)| {
matches!(self.group_fold(cat_name, ancestor),
Some(GroupFold::Expanded(n)) if depth - at < n)
});
!by_ancestor && depth >= self.expand_depth(cat_name)
}
/// The fold this view holds for one group, if any.
pub fn group_fold(&self, cat_name: &str, group_name: &str) -> Option<GroupFold> {
self.group_folds.get(cat_name)?.get(group_name).copied()
}
/// Record a fold as given — what a file's `collapsed:`/`expanded:` says.
pub fn set_group_fold(&mut self, cat_name: &str, group_name: &str, fold: GroupFold) {
self.group_folds
.entry(cat_name.to_string())
.or_default()
.insert(group_name.to_string(), fold);
}
/// Flip the group ending `chain`: `z`. It writes `Collapsed` or `Expanded(1)`
/// — or nothing, where the flip lands on what the group inherits (§19).
pub fn toggle_group_collapse(&mut self, cat_name: &str, chain: &[&str]) {
let Some(group) = chain.last() else {
return;
};
let collapse = !self.is_group_collapsed(cat_name, chain);
match collapse == self.inherits_collapsed(cat_name, chain) {
true => {
if let Some(folds) = self.group_folds.get_mut(cat_name) {
folds.remove(*group);
if folds.is_empty() {
self.group_folds.remove(cat_name);
}
}
}
false => {
let fold = match collapse {
true => GroupFold::Collapsed,
false => GroupFold::Expanded(1),
};
self.set_group_fold(cat_name, group, fold);
}
}
}
pub fn hide_item(&mut self, cat_name: &str, item_name: &str) {
@@ -292,7 +368,7 @@ impl View {
#[cfg(test)]
mod tests {
use super::{View, unused_view_name};
use super::{GroupFold, View, unused_view_name};
use crate::model::category::CategoryKind;
use crate::view::Axis;
@@ -563,28 +639,77 @@ mod tests {
assert_eq!(v.page_selection("Region"), None);
}
/// The user's ruling on improvise-felo.6: every group starts closed.
#[test]
fn toggle_group_collapse_toggles_twice() {
fn a_group_starts_collapsed_at_every_depth() {
let v = View::new("Test");
assert!(v.is_group_collapsed("Time", &["2024"]));
assert!(v.is_group_collapsed("Time", &["2024", "Q1-2024"]));
}
/// `expanded(1): Time` opens the roots and nothing below them.
#[test]
fn a_category_depth_opens_its_top_levels() {
let mut v = View::new("Test");
assert!(!v.is_group_collapsed("Time", "Q1"));
v.toggle_group_collapse("Time", "Q1");
assert!(v.is_group_collapsed("Time", "Q1"));
v.toggle_group_collapse("Time", "Q1");
assert!(!v.is_group_collapsed("Time", "Q1"));
v.set_expand_depth("Time", 1);
assert!(!v.is_group_collapsed("Time", &["2024"]));
assert!(v.is_group_collapsed("Time", &["2024", "Q1-2024"]));
assert!(v.is_group_collapsed("Other", &["G"]), "per category");
v.set_expand_depth("Time", 0);
assert!(v.expand_depth.is_empty(), "0 is the absence, not a setting");
}
/// `expanded(2): Time/2024` opens 2024 and one level beneath it, no deeper,
/// and nothing of the sibling year.
#[test]
fn a_groups_depth_opens_it_and_n_minus_one_levels_beneath() {
let mut v = View::new("Test");
v.set_group_fold("Time", "2024", GroupFold::Expanded(2));
assert!(!v.is_group_collapsed("Time", &["2024"]));
assert!(!v.is_group_collapsed("Time", &["2024", "Q1"]));
assert!(v.is_group_collapsed("Time", &["2024", "Q1", "Jan"]));
assert!(v.is_group_collapsed("Time", &["2025", "Q1"]));
}
/// A group's own `collapsed:` wins over an `expanded(N)` that reaches it.
#[test]
fn a_collapsed_group_inside_an_expanded_region_stays_collapsed() {
let mut v = View::new("Test");
v.set_expand_depth("Time", 3);
v.set_group_fold("Time", "Q1", GroupFold::Collapsed);
assert!(v.is_group_collapsed("Time", &["2024", "Q1"]));
assert!(!v.is_group_collapsed("Time", &["2024", "Q2"]));
}
/// `z` writes `Expanded(1)` or `Collapsed`, in BOTH directions, and
/// toggling back leaves no fold behind.
#[test]
fn a_toggle_overrides_what_a_group_inherits_either_way_and_toggling_back_clears_it() {
let mut v = View::new("Test");
v.set_expand_depth("Time", 1);
v.toggle_group_collapse("Time", &["2024", "Q1-2024"]);
assert_eq!(v.group_fold("Time", "Q1-2024"), Some(GroupFold::Expanded(1)));
v.toggle_group_collapse("Time", &["2024"]);
assert_eq!(v.group_fold("Time", "2024"), Some(GroupFold::Collapsed));
assert!(!v.is_group_collapsed("Time", &["2025"]), "a sibling is untouched");
v.toggle_group_collapse("Time", &["2024", "Q1-2024"]);
v.toggle_group_collapse("Time", &["2024"]);
assert!(v.group_folds.is_empty(), "{:?}", v.group_folds);
}
#[test]
fn is_group_collapsed_isolated_across_categories() {
let mut v = View::new("Test");
v.toggle_group_collapse("Cat1", "G1");
assert!(!v.is_group_collapsed("Cat2", "G1"));
v.toggle_group_collapse("Cat1", &["G1"]);
assert!(!v.is_group_collapsed("Cat1", &["G1"]));
assert!(v.is_group_collapsed("Cat2", &["G1"]));
}
#[test]
fn is_group_collapsed_isolated_across_groups() {
let mut v = View::new("Test");
v.toggle_group_collapse("Cat1", "G1");
assert!(!v.is_group_collapsed("Cat1", "G2"));
v.toggle_group_collapse("Cat1", &["G1"]);
assert!(v.is_group_collapsed("Cat1", &["G2"]));
}
/// Arm 2 of improvise-qx1z: the other three lines of `on_category_removed`
@@ -593,12 +718,13 @@ mod tests {
fn on_category_removed_forgets_a_collapsed_group() {
let mut v = View::new("V");
v.on_category_added("Month", CategoryKind::of_name("Month"));
v.toggle_group_collapse("Month", "Q1");
v.set_expand_depth("Month", 1);
v.toggle_group_collapse("Month", &["Q1"]);
v.on_category_removed("Month");
v.on_category_added("Month", CategoryKind::of_name("Month"));
assert!(
!v.is_group_collapsed("Month", "Q1"),
"a re-added category came back with its group collapsed"
v.group_folds.is_empty() && v.expand_depth.is_empty(),
"a re-added category came back with its old fold state"
);
}
@@ -808,10 +934,10 @@ mod prop_tests {
group in "[A-Za-z][a-z]{1,7}",
) {
let mut v = View::new("T");
let initial = v.is_group_collapsed(&cat, &group);
v.toggle_group_collapse(&cat, &group);
v.toggle_group_collapse(&cat, &group);
prop_assert_eq!(v.is_group_collapsed(&cat, &group), initial);
let initial = v.is_group_collapsed(&cat, &[group.as_str()]);
v.toggle_group_collapse(&cat, &[group.as_str()]);
v.toggle_group_collapse(&cat, &[group.as_str()]);
prop_assert_eq!(v.is_group_collapsed(&cat, &[group.as_str()]), initial);
}
}
}