From 8dad4b74762ad1b928e0e54656dd3945bb8b4e49 Mon Sep 17 00:00:00 2001 From: "Claude (agent-hier)" Date: Thu, 24 Sep 2026 21:22:16 -0700 Subject: [PATCH] feat(core): groups nest within a category and start collapsed (improvise-felo.6) Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- crates/improvise-core/src/model/category.rs | 114 +++- crates/improvise-core/src/view/layout.rs | 580 ++++++++++++++++---- crates/improvise-core/src/view/mod.rs | 3 +- crates/improvise-core/src/view/types.rs | 202 +++++-- 4 files changed, 746 insertions(+), 153 deletions(-) diff --git a/crates/improvise-core/src/model/category.rs b/crates/improvise-core/src/model/category.rs index 31d881c6..940aafed 100644 --- a/crates/improvise-core/src/model/category.rs +++ b/crates/improvise-core/src/model/category.rs @@ -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> { + 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(); diff --git a/crates/improvise-core/src/view/layout.rs b/crates/improvise-core/src/view/layout.rs index b139d4db..b39f6705 100644 --- a/crates/improvise-core/src/view/layout.rs +++ b/crates/improvise-core/src/view/layout.rs @@ -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, collapsed: bool }, @@ -301,28 +304,38 @@ fn data_index_to_visual(items: &[AxisEntry], data_idx: usize) -> Option { .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>; +/// 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, keep: impl Fn(usize) -> bool) { let mut kept: Vec = 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> = Vec::new(); + let mut output: Vec = 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, 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>, kept: &mut Vec) { - 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, kept: &mut Vec) { + 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( items: &[AxisEntry], - mut visit: impl FnMut(&[Option]) -> ControlFlow, + mut visit: impl FnMut(&[OpenRun]) -> ControlFlow, ) -> Option { - let mut open: Vec> = Vec::new(); + let mut open: Vec = 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( 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, +} + +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 = (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 = (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 = (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>> { + fn open_runs(items: &[AxisEntry]) -> Vec> { 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 = 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 { 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], level: usize) -> Option { - runs.get(level).copied().flatten() + fn at(runs: &[super::OpenRun], key: super::RunKey) -> Option { + 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 = + 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>], a: usize, b: usize| { + let same = |runs: &[Vec], 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 { + (0..l.row_count()).map(|r| l.row_label(r)).collect() + } + + fn value(wb: &Workbook, l: &GridLayout, label: &str) -> Option { + 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)); + } } diff --git a/crates/improvise-core/src/view/mod.rs b/crates/improvise-core/src/view/mod.rs index fe12c4aa..8583123d 100644 --- a/crates/improvise-core/src/view/mod.rs +++ b/crates/improvise-core/src/view/mod.rs @@ -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, }; diff --git a/crates/improvise-core/src/view/types.rs b/crates/improvise-core/src/view/types.rs index e9b91961..f1b5a434 100644 --- a/crates/improvise-core/src/view/types.rs +++ b/crates/improvise-core/src/view/types.rs @@ -27,6 +27,15 @@ pub fn unused_view_name<'a>(existing: impl IntoIterator) -> 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, /// Hidden items per category pub hidden_items: HashMap>, - /// Collapsed groups per category - pub collapsed_groups: HashMap>, + /// 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, + /// Per-group answers that override the depth default, in either direction. + pub group_folds: HashMap>, /// 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 { + 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); } } }