Merge pull request #317 from fiddlerwoaroof/feat/felo6-group-parents

feat: groups nest inside a category and start collapsed; views persist expanded(N) (improvise-felo.6)
This commit is contained in:
Ed Langley
2026-09-24 23:39:28 -07:00
committed by GitHub
37 changed files with 1927 additions and 296 deletions
-6
View File
@@ -84,9 +84,6 @@ fn chain_app(regions: usize, products: usize) -> App {
v.set_axis("Region", Axis::Row); v.set_axis("Region", Axis::Row);
v.set_axis("Product", Axis::Row); v.set_axis("Product", Axis::Row);
v.set_axis("_Measure", Axis::Column); v.set_axis("_Measure", Axis::Column);
for g in 0..regions.div_ceil(10) {
v.toggle_group_collapse("Region", &format!("G{g}"));
}
} }
let mut n = 0.0f64; let mut n = 0.0f64;
for r in 0..regions { for r in 0..regions {
@@ -127,9 +124,6 @@ fn grouped_app(days: usize, per_group: usize, products: usize, measures: usize)
v.set_axis("Day", Axis::Row); v.set_axis("Day", Axis::Row);
v.set_axis("Product", Axis::Row); v.set_axis("Product", Axis::Row);
v.set_axis("_Measure", Axis::Column); v.set_axis("_Measure", Axis::Column);
for g in 0..days.div_ceil(per_group) {
v.toggle_group_collapse("Day", &format!("G{g}"));
}
} }
let mut n = 0.0f64; let mut n = 0.0f64;
for d in 0..days { for d in 0..days {
+130 -3
View File
@@ -258,9 +258,11 @@ user drilled in to see all the raw data.
On an axis with two grouped categories the runs **nest**: `North` in `Upper` On an axis with two grouped categories the runs **nest**: `North` in `Upper`
holds `Jan` in `Q1`, and after `Q1` ends `North/Mar` is still in `Upper`. So a holds `Jan` in `Q1`, and after `Q1` ends `North/Mar` is still in `Upper`. So a
marker names its level. `GroupHeader { level, .. }` opens a run at `level` and marker names its level. `GroupHeader { level, depth, .. }` opens a run at
closes every deeper one; `GroupEnd { level }` closes `level` and every deeper `(level, depth)` and closes every deeper one; `GroupEnd { level, depth }` closes
one. `expand_category` reads the level off its prefix, which holds one item per that run and every deeper one. "Deeper" is the ordering of the pair: the level
first, then the depth inside one category's hierarchy (§19, improvise-felo.6).
`expand_category` reads the level off its prefix, which holds one item per
outer category, so it is derived rather than passed. A row's group, for outer category, so it is derived rather than passed. A row's group, for
`row_group_for`/`col_group_for` and so for `z`, is the **innermost** run open `row_group_for`/`col_group_for` and so for `z`, is the **innermost** run open
at it. That is the user's ruling on the ticket: membership is per level, and at it. That is the user's ruling on the ticket: membership is per level, and
@@ -1556,3 +1558,128 @@ editor stays open over the user's text (gotcha 21). `was` is not redundant
with a stable id: a concurrent *replace* keeps the id and changes the text, with a stable id: a concurrent *replace* keeps the id and changes the text,
which only `was` can see. which only `was` can see.
## 19. Group hierarchies: parents nest runs, and every group starts closed (improvise-felo.6)
`Group::parent` was parsed (`> Q1[2024]`), written, round-tripped and hashed,
and nothing read it: `Q1` was never drawn inside `2024`. It is now the
hierarchy.
### The user's rulings
- "I think hierarchies should start closed unless we somehow opt into a
specific one opened to a certain depth (view config maybe?)" — and the
reason: "in most cases, the closed by default start is preferable because it
makes the grid more dense."
- "Everything opens closed, including old files." There is no version gate: a
file written before this change opens with its groups closed, and its
`collapsed:` lines simply agree with the default.
- "I think we'll need a depth for expanded, something like expanded(2): ...".
- "expanded+collapsed in the same group is an error".
### The model
- **A group's chain** is the group and its ancestors, root first
(`Category::group_chain`). An item's chain is its group's
(`Category::item_chain`); an ungrouped item has none. A group's depth is its
number of ancestors.
- **A group covers every leaf beneath it.** `items_in_group` reads each item's
chain, so `2024` covers the months of `Q1-2024` and `Q2-2024`. That is the
whole change to evaluation: `evaluate_covering` already expanded a group
coordinate through `items_in_group` (§14), so a year's fold is the fold over
its months with no second path.
- **An item may sit directly in a parent group, beside its child groups.**
Chosen because nothing is simpler: the chain handles it (`Jun[H1]` after a
run of `Q2` inside `H1` ends the `Q2` run and stays in `H1`), and refusing it
would be a rule with nothing to protect. A date import never produces one.
- **A cycle is refused on load, naming its members** (`Refusal::HierarchyCycle`,
`persistence/parse.rs`). A cycle built in code cannot hang anything either:
`group_chain` stops where it would repeat.
- **There is no such thing as a dangling parent in this format.** A root group
is declared *only* by being named as a parent: the writer emits `>` lines
only for groups that have a parent, and nothing else can name `2024`. So
"a parent that names no group" and "a root" are the same line, and refusing
one refuses the other.
### Which groups are open
State lives on the `View`, in two places that cannot disagree about one group:
- `expand_depth: cat → N` — `expanded(N): Cat`, the category's top N levels
open.
- `group_folds: cat → group → GroupFold` — ONE map, so a group has at most one
fold. `GroupFold::Collapsed` is `collapsed: Cat/Group`;
`GroupFold::Expanded(N)` is `expanded(N): Cat/Group`, the group open and
N-1 levels beneath it. Two sets would have made "both" representable, which
is the state the user ruled an error
(`doctrine(an_invariant_is_made_unrepresentable_not_validated)`).
`View::is_group_collapsed(cat, chain)` answers, in order:
1. the group's own fold, if it has one — so a `collapsed:` group inside an
`expanded(N)` region stays collapsed, and an `expanded:` group below the
category's depth opens;
2. open, if an ancestor's `Expanded(n)` reaches it (the ancestor `k` levels up
reaches it when `k < n`), or the category's depth does (`depth < N`);
3. closed.
**`z` writes one fold, or none.** `toggle_group_collapse` flips the answer and
records the flip as `Collapsed` or `Expanded(1)` — unless the flip lands on
what the group would inherit from rule 2, in which case it removes the group's
fold. So `z z` leaves the view as it was, and no sequence of toggles writes a
file that names one group twice
(`no_sequence_of_toggles_writes_a_conflicting_file`). Closing a group that
carried `Expanded(3)` drops the 3: reopening it with `z` opens one level.
### The layout
`expand_category` walks each drawn item's chain. The **outermost** collapsed
group in the chain folds everything beneath it: the row's tuple is that group's
name, it folds once however non-contiguous its members are (§14), and the
headers above it are drawn. The run markers move from item to item exactly as
`remark` moves them after pruning — past the shared prefix of the two chains,
end the deeper runs innermost-out, then open what differs — so an unpruned axis
survives `prune_axis_items` marker for marker, and the enumeration in
`group_membership` (now with a `NESTED` fixture: a non-contiguous quarter, an
item directly in a half-year, an outer grouped category) asserts it.
A header carries its own `collapsed`, computed once in `expand_category`, so
the frame projection and the frame hash read it rather than asking the view
again with a chain they would have to rebuild.
### Persistence
`expanded(N): Cat`, `expanded(N): Cat/Group`, bare `expanded:` for N = 1, and
the existing `collapsed: Cat/Group` (`improv.pest`, `expanded_line`). N is at
least 1 by the grammar (`'1'..'9' ~ ASCII_DIGIT*`), so `expanded(0)` is a parse
error rather than a value to explain. Two further decisions:
- **The writer keeps what the view holds.** An old file's `collapsed:` lines
load as `Collapsed` folds and are written back verbatim, though they agree
with the default. They are the user's explicit closes, and they survive a
later `expanded(N): Cat` that would otherwise open those groups; dropping
them on save would change what that later line means. A `z` round trip
removes such a line, because `z` normalises (above).
- **A category depth is never written bare.** `axis_line` is tried before
`expanded_line`, so a category literally named `expanded` keeps its
`expanded: row`. The price is that `expanded: row` meaning "category `row`,
depth 1" would read as that axis line, so the writer always spells the
category form `expanded(N): Cat`. The group form has a `/` and is never
ambiguous.
A view naming one group with two different folds, or one category with two
depths, is refused on load, naming the view and the group
(`Refusal::FoldConflict`). The same line twice is not a conflict.
The grammar-walking generator emits cycles and conflicts with ordinary
probability (`> ||[||]` is a self-parent), so `generated_file_parses` accepts a
`Refusal` — a typed error, so it cannot accept a parse failure by accident.
### Known limits
- The TUI draws nested headers exactly as it draws any other: one line per
header, not indented by depth, and the column band shows each column's
innermost group only. Both are pre-existing presentation choices; the frame
carries `depth` for the day a front end wants to indent.
- Two `>` lines giving one group two different parents: the last wins, as it
did before this change. That makes the hierarchy a function of line order.
+26
View File
@@ -218,6 +218,32 @@ times. The probe was removed rather than kept: its rate depends on proptest's
internal RNG, so as a permanent assertion it would fail on a dependency bump internal RNG, so as a permanent assertion it would fail on a dependency bump
for a reason unrelated to the format. for a reason unrelated to the format.
### Folds are view lines: `collapsed:` and `expanded(N):` (improvise-felo.6)
Groups start collapsed, so a view persists what it opens. `expanded(N): Cat`
opens a category's top N levels, `expanded(N): Cat/Group` opens that group and
N-1 levels beneath it, a bare `expanded:` is N = 1, and `collapsed: Cat/Group`
keeps its old meaning. The semantics, the precedence and the refusals are
`context/code/core.md` §19; what is particular to the format:
- **No version bump.** The line is additive, and the user ruled that old files
open closed like new ones, so `v2025-04-09` still reads every file it did.
- **N >= 1 is grammar, not validation**: `depth_number = @{ '1'..'9' ~
ASCII_DIGIT* }`. Spelled with a `Range` and `ASCII_DIGIT` rather than
`ASCII_NONZERO_DIGIT`, because the generator knows the first two and would
emit nothing for the third — `expanded(): ` — a file its own grammar refuses.
- **`axis_line` comes before `expanded_line`** in `view_entry`, so a category
named `expanded` keeps its axis line, and the writer never spells a category
depth bare (`expanded: row` would be that axis line).
- **Two refusals the grammar cannot express** — a cycle among `>` lines and one
group given two folds — are a typed `Refusal`, which `generated_file_parses`
accepts and nothing else does. The generator produces both at ordinary rates.
- **The generator reaches the new rule**, asserted by
`the_generator_writes_expanded_lines_with_and_without_a_depth` over 512
deterministic files. Unlike the kind-line probe above, it asserts that each
spelling occurs at all, not a rate, so a proptest bump fails it only if the
walk stopped reaching the rule.
### The tree walkers are infallible by construction ### The tree walkers are infallible by construction
Every `build_*` in `parser.rs` operates on a `Pair` pest has already Every `build_*` in `parser.rs` operates on a `Pair` pest has already
+25 -4
View File
@@ -1474,6 +1474,22 @@ sequence. The header's level also gives a client the group's category,
the dead field improvise-7xyx removed: `level` is read by `groups()`, and the the dead field improvise-7xyx removed: `level` is read by `groups()`, and the
category name is not carried twice. category name is not carried twice.
**Within one category runs nest by `depth` (improvise-felo.6).** A group's
parent (`> Q1[2024]`) puts the quarter's run inside the year's on the SAME
level, so both markers also carry `depth`, the group's number of ancestors.
A run's key is `(level, depth)`, compared in that order: a header at a key
closes every run with a greater key, and so does a `GroupEnd`. With `Date`
alone on an axis, `2024-03` after `Q1-2024` ends sits in `2024`, and after
`2024` ends in nothing. Depth rides beside `level` rather than being folded
into one flat index, because a flat index would need every category's tree
height to be known before the first marker, and `level` would stop naming
`cats[level]`. Both front ends draw a nested header exactly as they draw any
other: one inline row per `Group` entry, the innermost group in the column
band. `the_two_front_ends_draw_the_same_nested_hierarchy_on_rows` and
`..._on_columns` in `tests/grid_frame_agreement.rs` hold them together, and
`the_frame_agrees_with_the_layout_inside_one_categorys_hierarchy` does the same for the
windowed frame against the layout.
`group_runs` is unchanged. It coalesces whatever `groups()` answers, by header `group_runs` is unchanged. It coalesces whatever `groups()` answers, by header
identity (improvise-h0iv), so the band still paints one group per position, now identity (improvise-h0iv), so the band still paints one group per position, now
the innermost. One band per level would be a separate feature. the innermost. One band per level would be a separate feature.
@@ -1554,14 +1570,19 @@ runs. Reversing it is one comparison, and the test is renamed
## §3b. A frame's levels are checked as it arrives ## §3b. A frame's levels are checked as it arrives
A `FrameGroup.level` and a `GroupEnd { level }` name a category: they index A `FrameGroup.level` and a `GroupEnd`'s `level` name a category: they index
`cats`. `AxisFrame::groups` keeps one open run per level and sizes that stack `cats`. `AxisFrame::groups` used to keep one open run per level and size that
with `open.resize(g.level, None)`. So a level arriving over the wire decides how stack with `open.resize(g.level, None)`, so a level arriving over the wire
much it allocates. A frame whose level is `4294967295` would ask for a decided how much it allocated. A frame whose level is `4294967295` would ask for a
multi-gigabyte vector, and the client would be taken down instead of the frame multi-gigabyte vector, and the client would be taken down instead of the frame
being declined. ChatGPT Codex found this on #250, the PR that gave levels to the being declined. ChatGPT Codex found this on #250, the PR that gave levels to the
wire (improvise-e265). wire (improvise-e265).
Since improvise-felo.6 `groups()` keeps only the headers it has received,
ordered by `(level, depth)`, so no number on the wire sizes anything and the
new `depth` field needs no bound. The level check stays for its other reason:
`cats[level]` names the group's category, which a browser `z` reads.
The bound is enforced at deserialization, by `#[serde(try_from = The bound is enforced at deserialization, by `#[serde(try_from =
"UncheckedAxisFrame")]`. That is the one place a frame enters from outside. A "UncheckedAxisFrame")]`. That is the one place a frame enters from outside. A
level that is not below `cats.len()` fails to parse, with an error that names the level that is not below `cats.len()` fails to parse, with an error that names the
+13
View File
@@ -7214,3 +7214,16 @@ cleared the cell cannot pass.
`the_shipped_demo_seeds_a_worker_whose_raw_cells_are_settable` wrote with `the_shipped_demo_seeds_a_worker_whose_raw_cells_are_settable` wrote with
`view: "Default"` against `web/demo.improv`, whose view is `Plan`. It passed `view: "Default"` against `web/demo.improv`, whose view is `Plan`. It passed
only because a stored record ignored the view name. It now names `Plan`. only because a stored record ignored the view name. It now names `Plan`.
### the_frame_agrees_with_the_layout_inside_one_categorys_hierarchy
improvise-felo.6. The windowed frame carries the runs open above the window;
those are now keyed by `(level, depth)`. Rival: `axis_frame` closing carried
runs by level alone. Only this test fails; the existing agreement test has one
group per level, where the two keyings coincide.
### z_opens_a_closed_hierarchy_one_level_at_a_time_and_closes_it_again
`z` through a closed Year > Quarter > Month hierarchy, cursor at row 1. Rival:
`toggle_group_collapse` never removing a fold; the final assertion (no fold left
behind once the quarter is closed again) fails.
+52 -1
View File
@@ -1865,5 +1865,56 @@ were run against the whole `improvise-core` suite:
**What it cannot see is the allocation itself.** A visitor that clones the **What it cannot see is the allocation itself.** A visitor that clones the
stack at every row returns the same answers, so no assertion here can tell the stack at every row returns the same answers, so no assertion here can tell the
two apart without a counting allocator. The copy-free property is held by the two apart without a counting allocator. The copy-free property is held by the
shape: the visitor receives a borrowed `&[Option<usize>]`, and no owned stack shape: the visitor receives a borrowed slice of open runs (`&[OpenRun]` since
improvise-felo.6), and no owned stack
leaves the walk. It is not held by a test. leaves the walk. It is not held by a test.
### a_parent_group_covers_every_leaf_beneath_its_child_groups
improvise-felo.6. `items_in_group` read only the items whose own `group` named
the group, so a year covered nothing: its months sit in quarters. Rival run
against `improvise-core`: the direct-only filter restored. Four tests fail —
this one, `a_hierarchy_opens_closed_as_one_fold_per_root` (the 2024 fold reads
empty), `collapsing_a_parent_folds_its_children_whatever_their_state` and
`hidden_items_leave_every_fold_above_them`. The fixture adds `2024-03` last, so
the year and its first quarter are non-contiguous in insertion order.
### a_year_quarter_month_hierarchy_nests_its_runs
improvise-felo.6: the whole marker sequence of a Year > Quarter > Month axis
opened to depth 2, compared exactly. Rival: every header inside a category
emitted at depth 0 (a flat hierarchy). This test fails, and so do
`pruning_keeps_every_surviving_rows_runs_at_every_level` (the `NESTED` fixture),
`the_nested_fixture_draws_runs_inside_one_category` and
`pruning_drops_an_empty_inner_group_and_keeps_its_parent`.
### a_hierarchy_opens_closed_as_one_fold_per_root
The user's ruling on improvise-felo.6: groups start closed. Two rivals fail it.
The old open default (`inherits_collapsed` always false) fails twelve tests
across `improvise-core` and `improvise-io`, this one among them. The INNERMOST
collapsed group folding instead of the outermost (`.rev()` on the fold search)
fails this one, `hidden_items_leave_every_fold_above_them` and
`the_group_under_the_cursor_is_the_innermost_at_every_depth`: under it a closed
year shows its quarters as rows.
### a_groups_depth_opens_it_and_n_minus_one_levels_beneath
`expanded(2): Time/2024` opens 2024 and its quarters, not its months. Rival: an
ancestor reaching `k <= n` levels instead of `k < n`. Only this test fails, which
is why it exists: the boundary is the whole semantics of N.
### a_toggle_overrides_what_a_group_inherits_either_way_and_toggling_back_clears_it
`z` writes `Collapsed` or `Expanded(1)`, or removes the fold where the flip
lands on what the group inherits. Rival: never remove. This test and
`command::cmd::grid::tests::z_opens_a_closed_hierarchy_one_level_at_a_time_and_closes_it_again`
fail; every layout test passes, since a redundant fold draws the same grid. What
the rival breaks is the file (a line per toggle, forever) and `z z` being the
identity on the view.
### pruning_drops_an_empty_inner_group_and_keeps_its_parent
`prune_empty` at depth: an empty quarter goes, header and all, and its year's
header stays for the sibling quarter that has data. Rival: `prune_axis_items`
keying open runs by level alone. This test and the `NESTED` enumeration fail.
+42
View File
@@ -1644,6 +1644,48 @@ the reviewer's rival, **date-derived names dropped from the taken names**,
which fails it. That fault would have let `add_category` reuse the existing which fails it. That fault would have let `add_category` reuse the existing
category, and every cell key would then name `when_Year` twice. category, and every cell key would then name `when_Year` twice.
### a_group_both_expanded_and_collapsed_is_refused_naming_view_and_group
The user's ruling on improvise-felo.6. Three spellings of one group given two
folds, each refused as `Refusal::FoldConflict` naming the view and the group,
plus a category given two depths. Rival: a second fold for a group silently
wins. Only this test fails. Its control is
`a_collapsed_group_inside_an_expanded_region_loads`: a DIFFERENT group
collapsed inside an expanded region, and the same line twice, both load.
### a_cycle_among_group_parents_is_refused_naming_it
`> Q1-2025[|2025|]` and `> |2025|[Q1-2025]`. Rival: the cycle check disabled;
only this test fails. The fixture's first version spelled `2024-01` bare, which
the grammar refuses (a bare name starts with a letter), so every test on it
passed or failed for the parse error, `a_malformed_depth_is_a_parse_error`
vacuously. It now carries a control that parses.
### an_axis_line_for_a_category_named_expanded_is_still_an_axis_line
`axis_line` is tried before `expanded_line`, so the writer must never spell a
category depth bare. Rival: the writer writing `expanded: Cat` for depth 1. This
test fails: the category `row` at depth 1 reads back as an axis line for a
category named `expanded`.
### an_old_file_with_only_collapsed_lines_loads_closed_and_keeps_its_lines
"Everything opens closed, including old files." Every group of the file opens
closed, the one its `collapsed:` line names and the three it does not, and the
line is written back. Rival: the old open default; this test fails with the
other eleven in that rival's list.
### every_fold_the_api_can_build_reads_back_from_the_file_it_writes
ChatGPT Codex, P2 on #317 at `e09ec752`: `GroupFold::Expanded(usize)` and
`View::set_group_fold` are public, so `Expanded(0)` could be built, written as
`expanded(0): …` and then refused by the grammar that wrote it. Confirmed by
reading: the writer formatted any `usize`, and `depth_number` starts at `1`.
The fix makes the state unrepresentable (`Expanded(NonZeroUsize)`), so the
regression guard is the type, and the old failing input no longer compiles.
This test covers every fold the API CAN build (`Collapsed` and depths 1 to 3,
beside a category depth), each saved and read back.
### a_record_with_no_value_for_a_category_is_kept_without_that_coordinate ### a_record_with_no_value_for_a_category_is_kept_without_that_coordinate
improvise-8jk6.2, reported by the user from the wizard: "Skipped 6 of 342 improvise-8jk6.2, reported by the user from the wizard: "Skipped 6 of 342
+8
View File
@@ -1259,3 +1259,11 @@ the Enter commit.
- **Rival** (the commit naming `state.view.active_view`, the view at - **Rival** (the commit naming `state.view.active_view`, the view at
`Enter`), run across the whole workspace: fails **only** this test, with `Enter`), run across the whole workspace: fails **only** this test, with
`["Later"]` against `["Other"]`. `["Later"]` against `["Other"]`.
### a_quarters_end_leaves_its_year_open_on_the_same_level
improvise-felo.6: runs inside one category nest by `(level, depth)`. `Direct`
sits in `2024` after `Q1`'s `GroupEnd { level: 0, depth: 1 }`, and `Undated`
in nothing after `2024` ends. Rival: `groups()` closing by level alone, as it did
before depth existed. Only this test fails in `improvise-protocol`: every other
frame fixture puts one group per level.
+15
View File
@@ -1429,3 +1429,18 @@ to the list's header row, which no item can cover. This test fills three rows
with a three-line item and a second item after it. It asserts the first item with a three-line item and a second item after it. It asserts the first item
is drawn whole and the header reads `1–1 of 2`. The rival **the header claims is drawn whole and the header reads `1–1 of 2`. The rival **the header claims
every item shown** fails it. every item shown** fails it.
### the_two_front_ends_draw_the_same_nested_hierarchy_on_rows
improvise-felo.6: Year > Quarter > Month in one category, under a grouped outer
category, with a year open two levels, a quarter closed and a year folded. The
precondition pins the header sequence, so the agreement cannot pass on a frame
that drew no nested header. What makes the two ends agree is that neither
derives membership: both walk the same `FrameEntry` list. Rival: the browser's
`render.rs` skipping a `Group` entry whose `depth > 0`. This test fails on the
row count (the TUI draws the quarter line, the browser does not); the column
test does not, since the band shows only each column's innermost group, which
the rival leaves alone. Group names are short because the TUI
truncates a band label to its column's width, which the browser does not: the
first version named its years `Y2024` and the column test failed on `▶ Y2…`
against `▶ Y2025`, a truncation, not a disagreement.
@@ -177,6 +177,7 @@ mod tests {
wb.add_item_in_group("Month", "Feb", "Q1").unwrap(); wb.add_item_in_group("Month", "Feb", "Q1").unwrap();
wb.add_item_in_group("Month", "Apr", "Q2").unwrap(); wb.add_item_in_group("Month", "Apr", "Q2").unwrap();
wb.add_item_in_group("Month", "May", "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.add_item("Type", "Food").unwrap();
wb wb
} }
@@ -213,7 +214,7 @@ mod tests {
!app.model_state !app.model_state
.workbook .workbook
.active_view() .active_view()
.is_group_collapsed("Month", "Q2"), .is_group_collapsed("Month", &["Q2"]),
"the fixture starts expanded, or the assertion below proves nothing" "the fixture starts expanded, or the assertion below proves nothing"
); );
@@ -225,11 +226,11 @@ mod tests {
); );
let view = app.model_state.workbook.active_view(); let view = app.model_state.workbook.active_view();
assert!( assert!(
view.is_group_collapsed("Month", "Q2"), view.is_group_collapsed("Month", &["Q2"]),
"the effect was returned but the view did not move" "the effect was returned but the view did not move"
); );
assert!( assert!(
!view.is_group_collapsed("Month", "Q1"), !view.is_group_collapsed("Month", &["Q1"]),
"the neighbour was left alone" "the neighbour was left alone"
); );
} }
@@ -262,9 +263,9 @@ mod tests {
); );
app.apply_effects(effects); app.apply_effects(effects);
let view = app.model_state.workbook.active_view(); let view = app.model_state.workbook.active_view();
assert!(view.is_group_collapsed("Month", "Q2")); assert!(view.is_group_collapsed("Month", &["Q2"]));
assert!( assert!(
!view.is_group_collapsed("Month", "Q1"), !view.is_group_collapsed("Month", &["Q1"]),
"the neighbour stays" "the neighbour stays"
); );
} }
@@ -286,6 +287,7 @@ mod tests {
for (month, quarter) in [("Jan", "Q1"), ("Feb", "Q1"), ("Apr", "Q2"), ("May", "Q2")] { for (month, quarter) in [("Jan", "Q1"), ("Feb", "Q1"), ("Apr", "Q2"), ("May", "Q2")] {
wb.add_item_in_group("Month", month, quarter).unwrap(); wb.add_item_in_group("Month", month, quarter).unwrap();
} }
wb.active_view_mut().set_expand_depth("Month", 1);
wb.add_item("Type", "Food").unwrap(); wb.add_item("Type", "Food").unwrap();
wb.add_item("Type", "Tools").unwrap(); wb.add_item("Type", "Tools").unwrap();
wb.active_view_mut().set_axis("Region", Axis::Column); wb.active_view_mut().set_axis("Region", Axis::Column);
@@ -328,6 +330,66 @@ mod tests {
); );
} }
/// improvise-felo.6: `z` walks DOWN a closed hierarchy — year, then quarter —
/// and back up, the cursor on the group it acted on each time.
#[test]
fn z_opens_a_closed_hierarchy_one_level_at_a_time_and_closes_it_again() {
use crate::ui::app::App;
let mut wb = crate::workbook::Workbook::new("T");
wb.add_category("Date").unwrap();
wb.add_category("Type").unwrap();
for (month, quarter) in [
("Jan", "Q1"),
("Feb", "Q1"),
("Apr", "Q2"),
("Jan25", "Q1-25"),
] {
wb.add_item_in_group("Date", month, quarter).unwrap();
}
let date = wb.model.category_mut("Date").unwrap();
for (quarter, year) in [("Q1", "Y2024"), ("Q2", "Y2024"), ("Q1-25", "Y2025")] {
date.add_group(crate::model::category::Group::new(quarter).with_parent(year));
}
wb.add_item("Type", "Food").unwrap();
let mut app = App::new(wb, None);
app.rebuild_layout();
assert_eq!(row_labels(&app), ["Y2024", "Y2025"], "closed by default");
app.cursor_mut().selected = (1, 0);
toggle_group_at_cursor(&mut app);
assert_eq!(
row_labels(&app),
["Y2024", "Q1-25"],
"Y2025 opens to its quarter"
);
assert_eq!(app.cursor().selected, (1, 0));
toggle_group_at_cursor(&mut app);
assert_eq!(
row_labels(&app),
["Y2024", "Jan25"],
"then the quarter to its month"
);
toggle_group_at_cursor(&mut app);
assert_eq!(
row_labels(&app),
["Y2024", "Q1-25"],
"z on the month closes its quarter"
);
app.cursor_mut().selected = (0, 0);
toggle_group_at_cursor(&mut app);
assert_eq!(row_labels(&app), ["Q1", "Q2", "Q1-25"]);
assert_eq!(
app.model_state
.workbook
.active_view()
.group_fold("Date", "Q1-25"),
None,
"closing back to the default leaves no fold behind"
);
}
/// Evidence: `context/test-evidence/command.md`. /// Evidence: `context/test-evidence/command.md`.
#[test] #[test]
fn toggling_a_group_collapses_that_group_and_the_cursor_follows_it() { fn toggling_a_group_collapses_that_group_and_the_cursor_follows_it() {
@@ -393,6 +455,7 @@ mod tests {
for (month, quarter) in [("Jan", "Q1"), ("Feb", "Q1"), ("Apr", "Q2"), ("May", "Q2")] { for (month, quarter) in [("Jan", "Q1"), ("Feb", "Q1"), ("Apr", "Q2"), ("May", "Q2")] {
wb.add_item_in_group("Month", month, quarter).unwrap(); wb.add_item_in_group("Month", month, quarter).unwrap();
} }
wb.active_view_mut().set_expand_depth("Month", 1);
wb.add_item("Type", "Food").unwrap(); wb.add_item("Type", "Food").unwrap();
wb.active_view_mut().set_axis("Region", Axis::Row); wb.active_view_mut().set_axis("Region", Axis::Row);
wb.active_view_mut().set_axis("Month", Axis::Row); wb.active_view_mut().set_axis("Month", Axis::Row);
@@ -704,7 +767,9 @@ impl ToggleGroupAtCursor {
}; };
let prefix = tuple.get(..cats.iter().position(|c| c == cat)?)?; let prefix = tuple.get(..cats.iter().position(|c| c == cat)?)?;
let mut view = ctx.view.clone(); let mut view = ctx.view.clone();
view.toggle_group_collapse(cat, group); let chain = ctx.model.group_chain(cat, group);
let chain: Vec<&str> = chain.iter().map(String::as_str).collect();
view.toggle_group_collapse(cat, &chain);
let after = GridLayout::with_frozen_records(ctx.model, &view, None); let after = GridLayout::with_frozen_records(ctx.model, &view, None);
match self.is_row { match self.is_row {
true => after.row_run_start(cat, group, prefix), true => after.row_run_start(cat, group, prefix),
@@ -155,6 +155,7 @@ pub(super) mod test_helpers {
for (month, quarter) in [("Jan", "Q1"), ("Feb", "Q1"), ("Apr", "Q2"), ("May", "Q2")] { for (month, quarter) in [("Jan", "Q1"), ("Feb", "Q1"), ("Apr", "Q2"), ("May", "Q2")] {
wb.add_item_in_group("Month", month, quarter).unwrap(); wb.add_item_in_group("Month", month, quarter).unwrap();
} }
wb.active_view_mut().set_expand_depth("Month", 1);
wb.add_item("_Measure", "Revenue").unwrap(); wb.add_item("_Measure", "Revenue").unwrap();
wb.add_item("_Measure", "Cost").unwrap(); wb.add_item("_Measure", "Cost").unwrap();
for (i, month) in ["Jan", "Feb", "Apr", "May"].into_iter().enumerate() { for (i, month) in ["Jan", "Feb", "Apr", "May"].into_iter().enumerate() {
@@ -168,7 +169,7 @@ pub(super) mod test_helpers {
} }
wb.active_view_mut().set_axis("Month", Axis::Row); wb.active_view_mut().set_axis("Month", Axis::Row);
wb.active_view_mut().set_axis("_Measure", Axis::Column); wb.active_view_mut().set_axis("_Measure", Axis::Column);
wb.active_view_mut().toggle_group_collapse("Month", "Q2"); wb.active_view_mut().toggle_group_collapse("Month", &["Q2"]);
let mut app = crate::ui::app::App::new(wb, None); let mut app = crate::ui::app::App::new(wb, None);
app.rebuild_layout(); app.rebuild_layout();
app.cursor_mut().selected = (2, 1); app.cursor_mut().selected = (2, 1);
@@ -197,8 +197,9 @@ mod tests {
for (month, quarter) in [("Jan", "Q1"), ("Feb", "Q1"), ("Apr", "Q2"), ("May", "Q2")] { for (month, quarter) in [("Jan", "Q1"), ("Feb", "Q1"), ("Apr", "Q2"), ("May", "Q2")] {
wb.add_item_in_group("Month", month, quarter).unwrap(); wb.add_item_in_group("Month", month, quarter).unwrap();
} }
wb.active_view_mut().set_expand_depth("Month", 1);
wb.add_item("Type", "Food").unwrap(); wb.add_item("Type", "Food").unwrap();
wb.active_view_mut().toggle_group_collapse("Month", "Q2"); wb.active_view_mut().toggle_group_collapse("Month", &["Q2"]);
let layout = make_layout(&wb); let layout = make_layout(&wb);
let reg = make_registry(); let reg = make_registry();
let mut ctx = make_ctx(&wb, &layout, &reg); let mut ctx = make_ctx(&wb, &layout, &reg);
+31 -18
View File
@@ -50,17 +50,17 @@ fn view_shape(model: &crate::model::types::Model, view: &View) -> u64 {
.filter(|item| !hidden.is_some_and(|h| h.contains(item))) .filter(|item| !hidden.is_some_and(|h| h.contains(item)))
.collect(); .collect();
drawn.hash(&mut h); drawn.hash(&mut h);
// Off the drawn items, not `Category::groups`: `add_item_in_group` does not // Off the drawn items' chains, not `Category::groups`: a group with nothing
// populate that, and a group with nothing drawn under it is not shape. // drawn under it is not shape, and a fold at any depth is.
let mut collapsed: Vec<&str> = model let mut collapsed: Vec<&str> = model
.category(cat) .category(cat)
.into_iter() .into_iter()
.flat_map(|c| drawn.iter().filter_map(|item| c.items.get(item))) .flat_map(|c| drawn.iter().map(move |item| c.item_chain(item)))
.filter_map(|item| item.group.as_deref()) .flat_map(|chain| {
.filter(|group| { (0..chain.len())
view.collapsed_groups .filter(|&d| view.is_group_collapsed(cat, &chain[..=d]))
.get(cat) .map(|d| chain[d])
.is_some_and(|c| c.contains(*group)) .collect::<Vec<_>>()
}) })
.collect(); .collect();
collapsed.sort_unstable(); collapsed.sort_unstable();
@@ -77,15 +77,14 @@ fn view_shape(model: &crate::model::types::Model, view: &View) -> u64 {
fn view_frame(model: &crate::model::types::Model, view: &View) -> u64 { fn view_frame(model: &crate::model::types::Model, view: &View) -> u64 {
// `with_frozen_records`, not `new`: `new` does NOT apply `prune_empty`, and a // `with_frozen_records`, not `new`: `new` does NOT apply `prune_empty`, and a
// pruned row is exactly the kind of reframing this observation exists for. // pruned row is exactly the kind of reframing this observation exists for.
frame_of( frame_of(&crate::view::layout::GridLayout::with_frozen_records(
&crate::view::layout::GridLayout::with_frozen_records(model, view, None), model, view, None,
view, ))
)
} }
/// The same hash off a layout already built. The batch's opening basis is free /// The same hash off a layout already built. The batch's opening basis is free
/// this way — `context/code/command.md` (improvise-mcq4). /// this way — `context/code/command.md` (improvise-mcq4).
fn frame_of(layout: &crate::view::layout::GridLayout, view: &View) -> u64 { fn frame_of(layout: &crate::view::layout::GridLayout) -> u64 {
use std::hash::{Hash, Hasher}; use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new(); let mut h = std::collections::hash_map::DefaultHasher::new();
layout.row_cats.hash(&mut h); layout.row_cats.hash(&mut h);
@@ -95,14 +94,17 @@ fn frame_of(layout: &crate::view::layout::GridLayout, view: &View) -> u64 {
for entry in entries { for entry in entries {
match entry { match entry {
crate::view::layout::AxisEntry::GroupHeader { crate::view::layout::AxisEntry::GroupHeader {
depth,
cat_name, cat_name,
group_name, group_name,
collapsed,
.. ..
} => { } => {
0u8.hash(&mut h); 0u8.hash(&mut h);
depth.hash(&mut h);
cat_name.hash(&mut h); cat_name.hash(&mut h);
group_name.hash(&mut h); group_name.hash(&mut h);
view.is_group_collapsed(cat_name, group_name).hash(&mut h); collapsed.hash(&mut h);
} }
crate::view::layout::AxisEntry::DataItem { tuple, collapsed } => { crate::view::layout::AxisEntry::DataItem { tuple, collapsed } => {
1u8.hash(&mut h); 1u8.hash(&mut h);
@@ -111,9 +113,10 @@ fn frame_of(layout: &crate::view::layout::GridLayout, view: &View) -> u64 {
} }
// Drawn as nothing, but it ends a group's run (improvise-gd83), // Drawn as nothing, but it ends a group's run (improvise-gd83),
// so where it sits is what a row's group attribution depends on. // so where it sits is what a row's group attribution depends on.
crate::view::layout::AxisEntry::GroupEnd { level } => { crate::view::layout::AxisEntry::GroupEnd { level, depth } => {
2u8.hash(&mut h); 2u8.hash(&mut h);
level.hash(&mut h); level.hash(&mut h);
depth.hash(&mut h);
} }
} }
} }
@@ -204,7 +207,16 @@ pub(crate) fn document_config(workbook: &Workbook) -> u64 {
axis.hash(&mut h); axis.hash(&mut h);
} }
sorted_pairs(&view.hidden_items).hash(&mut h); sorted_pairs(&view.hidden_items).hash(&mut h);
sorted_pairs(&view.collapsed_groups).hash(&mut h); let mut depths: Vec<_> = view.expand_depth.iter().collect();
depths.sort_unstable();
depths.hash(&mut h);
let mut folds: Vec<_> = view
.group_folds
.iter()
.flat_map(|(cat, groups)| groups.iter().map(move |(g, fold)| (cat, g, fold)))
.collect();
folds.sort_unstable();
folds.hash(&mut h);
let mut pages: Vec<(&String, &String)> = view.page_selections.iter().collect(); let mut pages: Vec<(&String, &String)> = view.page_selections.iter().collect();
pages.sort_unstable(); pages.sort_unstable();
pages.hash(&mut h); pages.hash(&mut h);
@@ -862,7 +874,7 @@ impl App {
fn opening_view_frame(&self) -> u64 { fn opening_view_frame(&self) -> u64 {
let (_, view) = self.resolve_view(self.view_state.active_view.as_str()); let (_, view) = self.resolve_view(self.view_state.active_view.as_str());
match self.drill_state.is_none() { match self.drill_state.is_none() {
true => frame_of(&self.layout, view), true => frame_of(&self.layout),
// A drill froze the record list, so the held layout is not what // A drill froze the record list, so the held layout is not what
// `view_frame` would build and the two must not be compared. // `view_frame` would build and the two must not be compared.
false => view_frame(&self.model_state.workbook.model, view), false => view_frame(&self.model_state.workbook.model, view),
@@ -3430,8 +3442,9 @@ mod tests {
for (month, quarter) in [("Jan", "Q1"), ("Feb", "Q1"), ("Apr", "Q2"), ("May", "Q2")] { for (month, quarter) in [("Jan", "Q1"), ("Feb", "Q1"), ("Apr", "Q2"), ("May", "Q2")] {
wb.add_item_in_group("Month", month, quarter).unwrap(); wb.add_item_in_group("Month", month, quarter).unwrap();
} }
wb.active_view_mut().set_expand_depth("Month", 1);
wb.add_item("Type", "Food").unwrap(); wb.add_item("Type", "Food").unwrap();
wb.active_view_mut().toggle_group_collapse("Month", "Q2"); wb.active_view_mut().toggle_group_collapse("Month", &["Q2"]);
let mut app = App::new(wb, None); let mut app = App::new(wb, None);
app.rebuild_layout(); app.rebuild_layout();
app app
+12 -5
View File
@@ -550,8 +550,14 @@ pub struct ToggleGroup {
} }
impl Effect for ToggleGroup { impl Effect for ToggleGroup {
fn apply(&self, app: &mut App) { fn apply(&self, app: &mut App) {
let chain = app
.model_state
.workbook
.model
.group_chain(&self.category, &self.group);
let chain: Vec<&str> = chain.iter().map(String::as_str).collect();
on_view(app, &self.view, |v| { on_view(app, &self.view, |v| {
v.toggle_group_collapse(&self.category, &self.group); v.toggle_group_collapse(&self.category, &chain);
}); });
} }
fn kind(&self) -> EffectKind { fn kind(&self) -> EffectKind {
@@ -3876,10 +3882,11 @@ mod tests {
} }
.apply(&mut app); .apply(&mut app);
assert!( assert!(
app.model_state !app.model_state
.workbook .workbook
.active_view() .active_view()
.is_group_collapsed("Type", "MyGroup") .is_group_collapsed("Type", &["MyGroup"]),
"a group starts collapsed, so the first toggle opens it"
); );
ToggleGroup { ToggleGroup {
view: ViewTarget::Active, view: ViewTarget::Active,
@@ -3888,10 +3895,10 @@ mod tests {
} }
.apply(&mut app); .apply(&mut app);
assert!( assert!(
!app.model_state app.model_state
.workbook .workbook
.active_view() .active_view()
.is_group_collapsed("Type", "MyGroup") .is_group_collapsed("Type", &["MyGroup"])
); );
} }
+7 -3
View File
@@ -1547,11 +1547,12 @@ mod tests {
wb.model.set_cell(month_cell(month, measure), value); wb.model.set_cell(month_cell(month, measure), value);
} }
} }
wb.active_view_mut().set_expand_depth("Month", 1);
let profit = crate::formula::parse_formula("Profit = Revenue - Cost", "_Measure"); let profit = crate::formula::parse_formula("Profit = Revenue - Cost", "_Measure");
wb.model.add_formula(profit.unwrap()); wb.model.add_formula(profit.unwrap());
wb.active_view_mut().set_axis("Month", Axis::Row); wb.active_view_mut().set_axis("Month", Axis::Row);
wb.active_view_mut().set_axis("_Measure", Axis::Column); wb.active_view_mut().set_axis("_Measure", Axis::Column);
wb.active_view_mut().toggle_group_collapse("Month", "Q2"); wb.active_view_mut().toggle_group_collapse("Month", &["Q2"]);
let mut app = App::new(wb, None); let mut app = App::new(wb, None);
app.rebuild_layout(); app.rebuild_layout();
assert!( assert!(
@@ -1733,13 +1734,14 @@ mod tests {
for measure in ["Revenue", "Cost"] { for measure in ["Revenue", "Cost"] {
wb.add_item_in_group("_Measure", measure, "Money").unwrap(); wb.add_item_in_group("_Measure", measure, "Money").unwrap();
} }
wb.active_view_mut().set_expand_depth("_Measure", 1);
wb.add_item("_Measure", "Units").unwrap(); wb.add_item("_Measure", "Units").unwrap();
let jan = month_cell("Jan", "Revenue"); let jan = month_cell("Jan", "Revenue");
wb.model.set_cell(jan, CellValue::Number(10.0)); wb.model.set_cell(jan, CellValue::Number(10.0));
wb.active_view_mut().set_axis("Month", Axis::Row); wb.active_view_mut().set_axis("Month", Axis::Row);
wb.active_view_mut().set_axis("_Measure", Axis::Column); wb.active_view_mut().set_axis("_Measure", Axis::Column);
wb.active_view_mut() wb.active_view_mut()
.toggle_group_collapse("_Measure", "Money"); .toggle_group_collapse("_Measure", &["Money"]);
let mut app = App::new(wb, None); let mut app = App::new(wb, None);
app.rebuild_layout(); app.rebuild_layout();
let target = month_cell("Feb", "Units"); let target = month_cell("Feb", "Units");
@@ -1779,12 +1781,13 @@ mod tests {
wb.model.set_cell(key, CellValue::Number(10.0)); wb.model.set_cell(key, CellValue::Number(10.0));
} }
} }
wb.active_view_mut().set_expand_depth("_Measure", 1);
wb.add_item("_Measure", "Units").unwrap(); wb.add_item("_Measure", "Units").unwrap();
wb.active_view_mut().set_axis("Month", Axis::Row); wb.active_view_mut().set_axis("Month", Axis::Row);
wb.active_view_mut().set_axis("_Measure", Axis::Column); wb.active_view_mut().set_axis("_Measure", Axis::Column);
if collapsed { if collapsed {
wb.active_view_mut() wb.active_view_mut()
.toggle_group_collapse("_Measure", "Money"); .toggle_group_collapse("_Measure", &["Money"]);
} }
let mut app = App::new(wb, None); let mut app = App::new(wb, None);
app.rebuild_layout(); app.rebuild_layout();
@@ -1808,6 +1811,7 @@ mod tests {
for measure in ["Revenue", "Cost"] { for measure in ["Revenue", "Cost"] {
wb.add_item_in_group("_Measure", measure, "Money").unwrap(); wb.add_item_in_group("_Measure", measure, "Money").unwrap();
} }
wb.active_view_mut().set_expand_depth("_Measure", 1);
let measures = wb.model.category_mut("_Measure").unwrap(); let measures = wb.model.category_mut("_Measure").unwrap();
measures.add_group(crate::model::category::Group::new("Money").with_parent("Totals")); measures.add_group(crate::model::category::Group::new("Money").with_parent("Totals"));
wb.model wb.model
+86 -27
View File
@@ -147,8 +147,8 @@ pub fn grid_in(app: &App, view_name: &str, viewport: Viewport) -> GridCache {
let body = grid_body(model, layout, &plan, rows.clone(), cols.clone()); let body = grid_body(model, layout, &plan, rows.clone(), cols.clone());
GridCache { GridCache {
row_frame: axis_frame(&layout.row_cats, &layout.row_items, view, rows.clone()), row_frame: axis_frame(&layout.row_cats, &layout.row_items, rows.clone()),
col_frame: axis_frame(&layout.col_cats, &layout.col_items, view, cols.clone()), col_frame: axis_frame(&layout.col_cats, &layout.col_items, cols.clone()),
col_widths: cols col_widths: cols
.clone() .clone()
.map(|c| body.col_widths.get(c).copied().unwrap_or(0)) .map(|c| body.col_widths.get(c).copied().unwrap_or(0))
@@ -212,12 +212,7 @@ pub(crate) fn rederived_for(
/// One axis's frame, windowed to the data indices `window` names. Three edge rules, /// One axis's frame, windowed to the data indices `window` names. Three edge rules,
/// all about group headers — see `context/code/command.md`. /// all about group headers — see `context/code/command.md`.
fn axis_frame( fn axis_frame(cats: &[String], items: &[AxisEntry], window: std::ops::Range<usize>) -> AxisFrame {
cats: &[String],
items: &[AxisEntry],
view: &View,
window: std::ops::Range<usize>,
) -> AxisFrame {
let total = items let total = items
.iter() .iter()
.filter(|e| matches!(e, AxisEntry::DataItem { .. })) .filter(|e| matches!(e, AxisEntry::DataItem { .. }))
@@ -225,40 +220,48 @@ fn axis_frame(
let mut data_idx = 0usize; let mut data_idx = 0usize;
let mut entries: Vec<FrameEntry> = Vec::new(); let mut entries: Vec<FrameEntry> = Vec::new();
let mut pending: Vec<FrameEntry> = Vec::new(); let mut pending: Vec<FrameEntry> = Vec::new();
// Before the window opens, only the runs still open matter, one per level: // Before the window opens, only the runs still open matter, keyed `(level,
// O(levels), however many groups a deep scroll passed (improvise-e265). // depth)`: O(depth), however many groups a deep scroll passed (e265, felo.6).
let mut carried: Vec<Option<FrameEntry>> = Vec::new(); let mut carried: Vec<((usize, usize), FrameEntry)> = Vec::new();
let close = |carried: &mut Vec<((usize, usize), FrameEntry)>, key: (usize, usize)| {
carried.truncate(carried.partition_point(|(k, _)| *k < key))
};
for entry in items { for entry in items {
match entry { match entry {
AxisEntry::GroupHeader { AxisEntry::GroupHeader {
level, level,
cat_name, depth,
group_name, group_name,
collapsed,
..
} => { } => {
let group = FrameEntry::Group(FrameGroup { let group = FrameEntry::Group(FrameGroup {
level: *level, level: *level,
depth: *depth,
name: group_name.clone(), name: group_name.clone(),
collapsed: view.is_group_collapsed(cat_name, group_name), collapsed: *collapsed,
}); });
match entries.is_empty() { match entries.is_empty() {
true => { true => {
carried.truncate(*level); close(&mut carried, (*level, *depth));
carried.resize(*level, None); carried.push(((*level, *depth), group));
carried.push(Some(group));
} }
false => pending.push(group), false => pending.push(group),
} }
} }
AxisEntry::GroupEnd { level } => match entries.is_empty() { AxisEntry::GroupEnd { level, depth } => match entries.is_empty() {
true => carried.truncate(*level), true => close(&mut carried, (*level, *depth)),
false => pending.push(FrameEntry::GroupEnd { level: *level }), false => pending.push(FrameEntry::GroupEnd {
level: *level,
depth: *depth,
}),
}, },
AxisEntry::DataItem { tuple, collapsed } => { AxisEntry::DataItem { tuple, collapsed } => {
let idx = data_idx; let idx = data_idx;
data_idx += 1; data_idx += 1;
if window.contains(&idx) { if window.contains(&idx) {
if entries.is_empty() { if entries.is_empty() {
entries.extend(carried.drain(..).flatten()); entries.extend(carried.drain(..).map(|(_, group)| group));
} }
entries.append(&mut pending); entries.append(&mut pending);
entries.push(FrameEntry::Item { entries.push(FrameEntry::Item {
@@ -271,7 +274,7 @@ fn axis_frame(
} }
if window.end >= total { if window.end >= total {
if entries.is_empty() { if entries.is_empty() {
entries.extend(carried.drain(..).flatten()); entries.extend(carried.drain(..).map(|(_, group)| group));
} }
entries.append(&mut pending); entries.append(&mut pending);
} }
@@ -1458,6 +1461,7 @@ mod tests {
wb.add_item("Region", "North").unwrap(); wb.add_item("Region", "North").unwrap();
wb.add_item("Region", "South").unwrap(); wb.add_item("Region", "South").unwrap();
wb.add_item_in_group("Region", "East", "Late").unwrap(); wb.add_item_in_group("Region", "East", "Late").unwrap();
wb.active_view_mut().set_expand_depth("Region", 1);
wb.add_item("_Measure", "Revenue").unwrap(); wb.add_item("_Measure", "Revenue").unwrap();
wb.active_view_mut().set_axis("Region", Axis::Row); wb.active_view_mut().set_axis("Region", Axis::Row);
wb.active_view_mut().set_axis("_Measure", Axis::Column); wb.active_view_mut().set_axis("_Measure", Axis::Column);
@@ -1469,7 +1473,8 @@ mod tests {
wb.model.set_cell(key, CellValue::Number(1.0)); wb.model.set_cell(key, CellValue::Number(1.0));
} }
if collapsed { if collapsed {
wb.active_view_mut().toggle_group_collapse("Region", "Late"); wb.active_view_mut()
.toggle_group_collapse("Region", &["Late"]);
} }
let mut app = App::new(wb, None); let mut app = App::new(wb, None);
app.rebuild_layout(); app.rebuild_layout();
@@ -1573,6 +1578,7 @@ mod tests {
wb.add_item("Region", "North").unwrap(); wb.add_item("Region", "North").unwrap();
wb.add_item_in_group("Region", "Inner", "Hidden").unwrap(); wb.add_item_in_group("Region", "Inner", "Hidden").unwrap();
wb.add_item_in_group("Region", "East", "Late").unwrap(); wb.add_item_in_group("Region", "East", "Late").unwrap();
wb.active_view_mut().set_expand_depth("Region", 1);
wb.add_item("_Measure", "Revenue").unwrap(); wb.add_item("_Measure", "Revenue").unwrap();
wb.active_view_mut().set_axis("Region", Axis::Row); wb.active_view_mut().set_axis("Region", Axis::Row);
wb.active_view_mut().set_axis("_Measure", Axis::Column); wb.active_view_mut().set_axis("_Measure", Axis::Column);
@@ -1584,7 +1590,7 @@ mod tests {
wb.model.set_cell(key, CellValue::Number(1.0)); wb.model.set_cell(key, CellValue::Number(1.0));
} }
wb.active_view_mut() wb.active_view_mut()
.toggle_group_collapse("Region", "Hidden"); .toggle_group_collapse("Region", &["Hidden"]);
let mut app = App::new(wb, None); let mut app = App::new(wb, None);
app.rebuild_layout(); app.rebuild_layout();
app app
@@ -1626,9 +1632,11 @@ mod tests {
wb.add_category("Region").unwrap(); wb.add_category("Region").unwrap();
wb.add_category("Month").unwrap(); wb.add_category("Month").unwrap();
wb.add_item_in_group("Region", "North", "Upper").unwrap(); wb.add_item_in_group("Region", "North", "Upper").unwrap();
wb.active_view_mut().set_expand_depth("Region", 1);
wb.add_item("Region", "South").unwrap(); wb.add_item("Region", "South").unwrap();
wb.add_item_in_group("Month", "Jan", "Q1").unwrap(); wb.add_item_in_group("Month", "Jan", "Q1").unwrap();
wb.add_item_in_group("Month", "Feb", "Q1").unwrap(); wb.add_item_in_group("Month", "Feb", "Q1").unwrap();
wb.active_view_mut().set_expand_depth("Month", 1);
wb.add_item("Month", "Mar").unwrap(); wb.add_item("Month", "Mar").unwrap();
wb.add_item("_Measure", "Revenue").unwrap(); wb.add_item("_Measure", "Revenue").unwrap();
wb.active_view_mut().set_axis("Region", Axis::Row); wb.active_view_mut().set_axis("Region", Axis::Row);
@@ -1674,6 +1682,52 @@ mod tests {
); );
} }
/// improvise-felo.6: the same agreement for runs nested INSIDE one category,
/// at every window — the carried stack is keyed by depth as well as level.
#[test]
fn the_frame_agrees_with_the_layout_inside_one_categorys_hierarchy() {
let mut wb = Workbook::new("T");
wb.add_category("Region").unwrap();
wb.add_category("Date").unwrap();
wb.add_item_in_group("Region", "North", "Upper").unwrap();
wb.add_item("Region", "South").unwrap();
for (month, group) in [("Jan", "Q1"), ("Feb", "Q1"), ("Jun", "H1"), ("Jul", "Q3")] {
wb.add_item_in_group("Date", month, group).unwrap();
}
wb.add_item("Date", "Undated").unwrap();
let date = wb.model.category_mut("Date").unwrap();
date.add_group(crate::model::category::Group::new("Q1").with_parent("H1"));
date.add_group(crate::model::category::Group::new("Q3").with_parent("H2"));
wb.add_item("_Measure", "Revenue").unwrap();
let view = wb.active_view_mut();
view.set_axis("Region", Axis::Row);
view.set_axis("Date", Axis::Row);
view.set_axis("_Measure", Axis::Column);
view.set_expand_depth("Region", 1);
view.set_expand_depth("Date", 2);
let mut app = App::new(wb, None);
app.rebuild_layout();
let rows = app.layout.row_count();
assert_eq!(rows, 10, "two regions by five dates");
for offset in 0..rows {
let frame = grid_of(&app, rows_from(offset)).row_frame;
let from_frame: Vec<Option<(String, String)>> = frame
.groups()
.into_iter()
.map(|g| g.map(|g| (frame.cats[g.level].clone(), g.name.clone())))
.collect();
let from_layout: Vec<Option<(String, String)>> = (offset..rows)
.map(|r| app.layout.row_group_for(r))
.collect();
assert_eq!(from_frame, from_layout, "window from row {offset}");
}
assert_eq!(
app.layout.row_group_for(2),
Some(("Date".into(), "H1".into())),
"precondition: North/Jun sits in H1 once Q1 ends, not in Q1 nor Upper"
);
}
/// A window opening inside `North/Q1` carries the open run at EVERY level, or /// A window opening inside `North/Q1` carries the open run at EVERY level, or
/// `North/Mar` loses `Upper` once `Q1` ends. context/test-evidence/command.md. /// `North/Mar` loses `Upper` once `Q1` ends. context/test-evidence/command.md.
#[test] #[test]
@@ -1693,7 +1747,7 @@ mod tests {
fn z_on_a_row_past_an_inner_run_folds_the_outer_group() { fn z_on_a_row_past_an_inner_run_folds_the_outer_group() {
use crate::command::key::{KeyCode, KeyEvent, KeyModifiers}; use crate::command::key::{KeyCode, KeyEvent, KeyModifiers};
let z = || KeyEvent::new(KeyCode::Char('z'), KeyModifiers::NONE); let z = || KeyEvent::new(KeyCode::Char('z'), KeyModifiers::NONE);
let collapsed = |app: &App, cat, group| app.view().is_group_collapsed(cat, group); let collapsed = |app: &App, cat, group| app.view().is_group_collapsed(cat, &[group]);
let mut app = app_with_nested_groups(); let mut app = app_with_nested_groups();
app.cursor_mut().selected = (0, 0); app.cursor_mut().selected = (0, 0);
@@ -2895,6 +2949,7 @@ mod tests {
.unwrap(); .unwrap();
wb.add_item_in_group("Product", "Gadget", "Hardware") wb.add_item_in_group("Product", "Gadget", "Hardware")
.unwrap(); .unwrap();
wb.active_view_mut().set_expand_depth("Product", 1);
wb.add_item("Product", "Service").unwrap(); wb.add_item("Product", "Service").unwrap();
wb.add_item("_Measure", "Revenue").unwrap(); wb.add_item("_Measure", "Revenue").unwrap();
wb.add_item("_Measure", "Cost").unwrap(); wb.add_item("_Measure", "Cost").unwrap();
@@ -4658,6 +4713,7 @@ mod cell_identity {
wb.add_item("Region", "North").unwrap(); wb.add_item("Region", "North").unwrap();
wb.add_item_in_group("Region", "East", "Coastal").unwrap(); wb.add_item_in_group("Region", "East", "Coastal").unwrap();
wb.add_item_in_group("Region", "West", "Coastal").unwrap(); wb.add_item_in_group("Region", "West", "Coastal").unwrap();
wb.active_view_mut().set_expand_depth("Region", 1);
wb.add_item("_Measure", "Revenue").unwrap(); wb.add_item("_Measure", "Revenue").unwrap();
wb.active_view_mut().set_axis("Region", Axis::Row); wb.active_view_mut().set_axis("Region", Axis::Row);
wb.active_view_mut().set_axis("_Measure", Axis::Column); wb.active_view_mut().set_axis("_Measure", Axis::Column);
@@ -4671,7 +4727,7 @@ mod cell_identity {
); );
} }
wb.active_view_mut() wb.active_view_mut()
.toggle_group_collapse("Region", "Coastal"); .toggle_group_collapse("Region", &["Coastal"]);
let mut app = App::new(wb, None); let mut app = App::new(wb, None);
app.rebuild_layout(); app.rebuild_layout();
assert_eq!(app.layout.row_label(1), "Coastal"); assert_eq!(app.layout.row_label(1), "Coastal");
@@ -5642,9 +5698,11 @@ mod cell_identity {
let mut wb = Workbook::new("T"); let mut wb = Workbook::new("T");
wb.add_category("Month").unwrap(); wb.add_category("Month").unwrap();
wb.add_item_in_group("Month", "Apr", "Q2").unwrap(); wb.add_item_in_group("Month", "Apr", "Q2").unwrap();
wb.active_view_mut().set_expand_depth("Month", 1);
for month in ["Jan", "Feb", "Mar"] { for month in ["Jan", "Feb", "Mar"] {
wb.add_item_in_group("Month", month, "Q1").unwrap(); wb.add_item_in_group("Month", month, "Q1").unwrap();
} }
wb.active_view_mut().set_expand_depth("Month", 1);
for (month, revenue, cost) in [("Jan", 10.0, 5.0), ("Feb", 30.0, 3.0), ("Mar", 50.0, 50.0)] for (month, revenue, cost) in [("Jan", 10.0, 5.0), ("Feb", 30.0, 3.0), ("Mar", 50.0, 50.0)]
{ {
for (measure, n) in [("Revenue", revenue), ("Cost", cost)] { for (measure, n) in [("Revenue", revenue), ("Cost", cost)] {
@@ -5663,7 +5721,7 @@ mod cell_identity {
wb.active_view_mut().set_axis("Month", Axis::Row); wb.active_view_mut().set_axis("Month", Axis::Row);
wb.active_view_mut().set_axis("_Measure", Axis::Column); wb.active_view_mut().set_axis("_Measure", Axis::Column);
wb.active_view_mut().hide_item("Month", "Mar"); wb.active_view_mut().hide_item("Month", "Mar");
wb.active_view_mut().toggle_group_collapse("Month", "Q1"); wb.active_view_mut().toggle_group_collapse("Month", &["Q1"]);
let app = App::new(wb, None); let app = App::new(wb, None);
let grid = grid_of(&app, WHOLE); let grid = grid_of(&app, WHOLE);
@@ -6334,13 +6392,14 @@ mod column_totals {
wb.model.set_cell(at, CellValue::Number(v)); wb.model.set_cell(at, CellValue::Number(v));
} }
} }
wb.active_view_mut().set_expand_depth("Month", 1);
let max = crate::formula::AggFunc::Max; let max = crate::formula::AggFunc::Max;
wb.model.measure_agg.insert("Revenue".into(), max); wb.model.measure_agg.insert("Revenue".into(), max);
wb.active_view_mut().set_axis("Month", Axis::Row); wb.active_view_mut().set_axis("Month", Axis::Row);
wb.active_view_mut().set_axis("_Measure", Axis::Column); wb.active_view_mut().set_axis("_Measure", Axis::Column);
wb.active_view_mut().set_axis("Region", Axis::None); wb.active_view_mut().set_axis("Region", Axis::None);
if collapsed { if collapsed {
wb.active_view_mut().toggle_group_collapse("Month", "Q1"); wb.active_view_mut().toggle_group_collapse("Month", &["Q1"]);
} }
wb wb
}; };
+3 -3
View File
@@ -1052,9 +1052,9 @@ pub(crate) mod tests {
view.hide_item("Region", "West"); view.hide_item("Region", "West");
view.hide_item("Region", "South"); view.hide_item("Region", "South");
view.hide_item("Month", "Mar"); view.hide_item("Month", "Mar");
view.toggle_group_collapse("Region", "Coastal"); view.toggle_group_collapse("Region", &["Coastal"]);
view.toggle_group_collapse("Region", "Inland"); view.toggle_group_collapse("Region", &["Inland"]);
view.toggle_group_collapse("Month", "Q1"); view.toggle_group_collapse("Month", &["Q1"]);
app.rebuild_layout(); app.rebuild_layout();
app app
} }
+2 -1
View File
@@ -214,6 +214,7 @@ mod tests {
wb.add_item_in_group("Month", "Apr", "Q2").unwrap(); wb.add_item_in_group("Month", "Apr", "Q2").unwrap();
wb.add_item_in_group("Month", "Jan", "Q1").unwrap(); wb.add_item_in_group("Month", "Jan", "Q1").unwrap();
wb.add_item_in_group("Month", "Feb", "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", "Revenue").unwrap();
for (month, revenue) in [("Jan", 10.0), ("Feb", 30.0)] { for (month, revenue) in [("Jan", 10.0), ("Feb", 30.0)] {
let key = CellKey::new(vec![ let key = CellKey::new(vec![
@@ -231,7 +232,7 @@ mod tests {
} }
wb.active_view_mut().set_axis("Month", Axis::Row); wb.active_view_mut().set_axis("Month", Axis::Row);
wb.active_view_mut().set_axis("_Measure", Axis::Column); wb.active_view_mut().set_axis("_Measure", Axis::Column);
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()); wb.model.recompute_formulas(&wb.active_view().none_cats());
wb wb
} }
+109 -3
View File
@@ -199,15 +199,61 @@ impl Category {
self.items.keys().map(|s| s.as_str()).collect() self.items.keys().map(|s| s.as_str()).collect()
} }
/// The items whose own `group` names `group`, in insertion order. Read off the /// The items `group` covers — its own and every child group's, through any
/// items, never `groups`: `add_item_in_group` registers no `Group` (core.md §14). /// depth — in insertion order. Read off the items, not `groups` (core.md §14).
pub fn items_in_group(&self, group: &str) -> Vec<&str> { pub fn items_in_group(&self, group: &str) -> Vec<&str> {
self.items self.items
.values() .values()
.filter(|i| i.group.as_deref() == Some(group)) .filter(|i| self.item_chain(&i.name).contains(&group))
.map(|i| i.name.as_str()) .map(|i| i.name.as_str())
.collect() .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
}
/// 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)] #[cfg(test)]
@@ -297,6 +343,66 @@ mod tests {
assert_eq!(c.groups.len(), 1); 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() {
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_chain("2024"), vec!["2024"]);
assert_eq!(c.group_chain("Q2-2024"), vec!["2024", "Q2-2024"]);
}
#[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] #[test]
fn item_index_reflects_insertion_order() { fn item_index_reflects_insertion_order() {
let mut c = cat(); let mut c = cat();
+9
View File
@@ -525,6 +525,15 @@ impl Model {
self.categories.get(name) self.categories.get(name)
} }
/// `group` and its ancestors in `cat`, root first and owned: the chain a
/// `View` folds by (core.md §19). Just `[group]` where `cat` is unknown.
pub fn group_chain(&self, cat: &str, group: &str) -> Vec<String> {
match self.category(cat) {
Some(c) => c.group_chain(group).into_iter().map(String::from).collect(),
None => vec![group.to_string()],
}
}
pub fn set_cell(&mut self, key: CellKey, value: CellValue) { pub fn set_cell(&mut self, key: CellKey, value: CellValue) {
self.data.set(key, value); self.data.set(key, value);
} }
+479 -111
View File
@@ -205,16 +205,19 @@ pub enum RecordCol {
/// and collapse state show; a collapsed group is ONE `DataItem` (core.md §14). /// and collapse state show; a collapsed group is ONE `DataItem` (core.md §14).
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
pub enum AxisEntry { pub enum AxisEntry {
/// Opens a run at `level` (the category's place on the axis) and closes /// Opens a run at `(level, depth)` — the category's place on the axis, then the
/// every run inside it. Membership is per level: core.md §14, improvise-e265. /// group's depth in its hierarchy — and closes every run inside it (§6, §19).
GroupHeader { GroupHeader {
level: usize, level: usize,
depth: usize,
cat_name: String, cat_name: String,
group_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). /// Ends the run at `(level, depth)` and every run inside it (improvise-gd83,
/// Nothing is drawn for it; it exists so membership is not inherited. /// e265). Nothing is drawn for it; it exists so membership is not inherited.
GroupEnd { level: usize }, GroupEnd { level: usize, depth: usize },
/// `collapsed` is not derivable from `tuple`: a group and an item may share a /// `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. /// name. Set for a folded group and for everything expanded beneath one.
DataItem { tuple: Vec<String>, collapsed: bool }, 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) .nth(data_idx)
} }
/// Each open run, by level, as `(position of its header, the header)`. A header /// Where a run sits: `(level, depth)`, ordered, so "inside" is "greater than".
/// at L replaces L and closes deeper runs; a `GroupEnd` at L closes L and deeper. /// Across categories the level decides; within one, the depth (§19).
type OpenRuns = Vec<Option<(usize, AxisEntry)>>; pub type RunKey = (usize, usize);
fn open_at(runs: &mut OpenRuns, level: usize, run: Option<(usize, AxisEntry)>) { /// One open run: its key and the position of the header that opened it.
runs.truncate(level); pub type OpenRun = (RunKey, usize);
runs.resize(level, None);
runs.extend(run.map(Some)); /// 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 /// 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. /// 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) { fn prune_axis_items(items: &mut Vec<AxisEntry>, keep: impl Fn(usize) -> bool) {
let mut kept: Vec<AxisEntry> = Vec::new(); 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 input: OpenRuns = Vec::new();
let mut output: Vec<Option<usize>> = Vec::new(); let mut output: Vec<OpenRun> = Vec::new();
let mut data_idx = 0usize; let mut data_idx = 0usize;
for (at, entry) in items.drain(..).enumerate() { for (at, entry) in items.drain(..).enumerate() {
match entry { match entry {
AxisEntry::GroupHeader { level, .. } => open_at(&mut input, level, Some((at, entry))), AxisEntry::GroupHeader { level, depth, .. } => {
AxisEntry::GroupEnd { level } => open_at(&mut input, level, None), open_at(&mut input, (level, depth), Some((at, entry)))
}
AxisEntry::GroupEnd { level, depth } => open_at(&mut input, (level, depth), None),
AxisEntry::DataItem { .. } => { AxisEntry::DataItem { .. } => {
if keep(data_idx) { if keep(data_idx) {
remark(&input, &mut output, &mut kept); 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; *items = kept;
} }
/// Emit the markers that make `kept`'s open runs equal the input's, outermost fn group_end((level, depth): RunKey) -> AxisEntry {
/// first: a header where a different run is open, a `GroupEnd` where none is. AxisEntry::GroupEnd { level, depth }
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(); /// Emit the markers that make `kept`'s open runs equal the input's: past the
let have = output.get(level).copied().flatten(); /// runs they share, end the deeper ones innermost out, then open what differs.
if want.as_ref().map(|(at, _)| *at) == have { fn remark(input: &OpenRuns, output: &mut Vec<OpenRun>, kept: &mut Vec<AxisEntry>) {
continue; let shared = input
} .iter()
// Deeper runs end first, innermost out, as `expand_category` ends them. .zip(output.iter())
for deeper in (level + 1..output.len()).rev() { .take_while(|((_, want, _), (_, have))| want == have)
if output[deeper].is_some() { .count();
kept.push(AxisEntry::GroupEnd { level: deeper }); let want = input.get(shared);
} let have = output.get(shared).copied();
} let Some(slot) = [want.map(|w| w.0), have.map(|h| h.0)]
output.truncate(level); .into_iter()
output.resize(level, None); .flatten()
match want { .min()
Some((at, header)) => { else {
kept.push(header); return;
output.push(Some(at)); };
} // Deeper runs end first, innermost out, as `expand_category` ends them.
None => kept.push(AxisEntry::GroupEnd { level }), 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 /// The one reading of the markers: each data item's open runs, innermost last,
/// header positions, lent to `visit` — one stack, never copied (0r01). /// lent to `visit` — one stack, never copied (0r01).
fn walk_open_runs<T>( fn walk_open_runs<T>(
items: &[AxisEntry], items: &[AxisEntry],
mut visit: impl FnMut(&[Option<usize>]) -> ControlFlow<T>, mut visit: impl FnMut(&[OpenRun]) -> ControlFlow<T>,
) -> Option<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() { for (at, entry) in items.iter().enumerate() {
match entry { match entry {
AxisEntry::GroupHeader { level, .. } => { AxisEntry::GroupHeader { level, depth, .. } => {
open.truncate(*level); open.truncate(open.partition_point(|(k, _)| *k < (*level, *depth)));
open.resize(*level, None); open.push(((*level, *depth), at));
open.push(Some(at)); }
AxisEntry::GroupEnd { level, depth } => {
open.truncate(open.partition_point(|(k, _)| *k < (*level, *depth)))
} }
AxisEntry::GroupEnd { level } => open.truncate(*level),
AxisEntry::DataItem { .. } => { AxisEntry::DataItem { .. } => {
if let ControlFlow::Break(found) = visit(&open) { if let ControlFlow::Break(found) = visit(&open) {
return Some(found); return Some(found);
@@ -392,7 +415,7 @@ fn walk_open_runs<T>(
fn innermost_group(items: &[AxisEntry], idx: usize) -> Option<(String, String)> { fn innermost_group(items: &[AxisEntry], idx: usize) -> Option<(String, String)> {
let mut data_idx = 0usize; let mut data_idx = 0usize;
let at = walk_open_runs(items, |open| match data_idx == idx { 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 => { false => {
data_idx += 1; data_idx += 1;
ControlFlow::Continue(()) ControlFlow::Continue(())
@@ -872,9 +895,13 @@ fn expand_category(
return vec![]; return vec![];
}; };
// The prefix holds one item per outer category, so its length is this level. // The prefix holds one item per outer category, so its length is this level.
let level = prefix.len(); let mut runs = CategoryRuns {
let mut result = Vec::new(); level: prefix.len(),
let mut last_group: Option<&str> = None; cat_name,
view,
open: Vec::new(),
out: Vec::new(),
};
// A group's items need not be contiguous, so a collapsed one folds ONCE. // A group's items need not be contiguous, so a collapsed one folds ONCE.
let mut folded: Vec<&str> = Vec::new(); let mut folded: Vec<&str> = Vec::new();
@@ -884,50 +911,79 @@ fn expand_category(
if view.is_hidden(cat_name, item_name) { if view.is_hidden(cat_name, item_name) {
continue; continue;
} }
let item_group = cat let chain = cat.item_chain(item_name);
.items // The OUTERMOST collapsed group folds every leaf beneath it (§19).
.get(item_name.as_str()) let fold_depth =
.and_then(|i| i.group.as_deref()); (0..chain.len()).find(|&depth| view.is_group_collapsed(cat_name, &chain[..=depth]));
let collapsed = item_group.filter(|g| view.is_group_collapsed(cat_name, g)); let fold = fold_depth.map(|depth| chain[depth]);
if collapsed.is_some_and(|g| folded.contains(&g)) { if fold.is_some_and(|g| folded.contains(&g)) {
continue; continue;
} }
runs.enter(&chain[..fold_depth.map_or(chain.len(), |d| d + 1)]);
// 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;
}
let mut tuple = prefix.clone(); let mut tuple = prefix.clone();
match collapsed { match fold {
Some(g) => { Some(g) => {
folded.push(g); folded.push(g);
tuple.push(g.to_string()); tuple.push(g.to_string());
} }
None => tuple.push(item_name.to_string()), None => tuple.push(item_name.to_string()),
} }
result.push(AxisEntry::DataItem { runs.out.push(AxisEntry::DataItem {
tuple, 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 // The expansion is the end of the run too: the next prefix's items follow
// immediately and must not inherit it (improvise-gd83). // immediately and must not inherit it (improvise-gd83).
if last_group.is_some() { runs.enter(&[]);
result.push(AxisEntry::GroupEnd { level }); 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. /// Cartesian product of visible items across `cats`, headers always emitted.
@@ -1026,8 +1082,10 @@ mod tests {
AxisEntry::item(vec!["A".into()]), AxisEntry::item(vec!["A".into()]),
AxisEntry::GroupHeader { AxisEntry::GroupHeader {
level: 0, level: 0,
depth: 0,
cat_name: "C".into(), cat_name: "C".into(),
group_name: "G".into(), group_name: "G".into(),
collapsed: false,
}, },
AxisEntry::item(vec!["B".into()]), AxisEntry::item(vec!["B".into()]),
AxisEntry::item(vec!["C".into()]), AxisEntry::item(vec!["C".into()]),
@@ -1051,8 +1109,10 @@ mod tests {
let mut items = vec![ let mut items = vec![
AxisEntry::GroupHeader { AxisEntry::GroupHeader {
level: 0, level: 0,
depth: 0,
cat_name: "C".into(), cat_name: "C".into(),
group_name: "G".into(), group_name: "G".into(),
collapsed: false,
}, },
AxisEntry::item(vec!["A".into()]), AxisEntry::item(vec!["A".into()]),
AxisEntry::item(vec!["B".into()]), AxisEntry::item(vec!["B".into()]),
@@ -1418,6 +1478,7 @@ mod tests {
wb.add_item_in_group("Month", "Jan", "Q1").unwrap(); wb.add_item_in_group("Month", "Jan", "Q1").unwrap();
wb.add_item_in_group("Month", "Feb", "Q1").unwrap(); wb.add_item_in_group("Month", "Feb", "Q1").unwrap();
wb.add_item_in_group("Month", "Apr", "Q2").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.add_item("Type", "Food").unwrap();
let layout = GridLayout::new(&wb.model, wb.active_view()); let layout = GridLayout::new(&wb.model, wb.active_view());
assert_eq!(layout.row_count(), 3); // Jan, Feb, Apr — headers don't count assert_eq!(layout.row_count(), 3); // Jan, Feb, Apr — headers don't count
@@ -1430,6 +1491,7 @@ mod tests {
wb.add_category("Type").unwrap(); wb.add_category("Type").unwrap();
wb.add_item_in_group("Month", "Jan", "Q1").unwrap(); wb.add_item_in_group("Month", "Jan", "Q1").unwrap();
wb.add_item_in_group("Month", "Apr", "Q2").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.add_item("Type", "Food").unwrap();
let layout = GridLayout::new(&wb.model, wb.active_view()); let layout = GridLayout::new(&wb.model, wb.active_view());
let headers: Vec<_> = layout let headers: Vec<_> = layout
@@ -1457,8 +1519,9 @@ mod tests {
wb.add_item_in_group("Month", "Feb", "Q1").unwrap(); wb.add_item_in_group("Month", "Feb", "Q1").unwrap();
wb.add_item_in_group("Month", "Apr", "Q2").unwrap(); wb.add_item_in_group("Month", "Apr", "Q2").unwrap();
wb.add_item_in_group("Month", "May", "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.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 layout = GridLayout::new(&wb.model, wb.active_view());
let labels: Vec<String> = (0..layout.row_count()) let labels: Vec<String> = (0..layout.row_count())
.map(|r| layout.row_label(r)) .map(|r| layout.row_label(r))
@@ -1489,8 +1552,9 @@ mod tests {
wb.add_item_in_group("Month", "A", "G1").unwrap(); wb.add_item_in_group("Month", "A", "G1").unwrap();
wb.add_item_in_group("Month", "B", "G2").unwrap(); wb.add_item_in_group("Month", "B", "G2").unwrap();
wb.add_item_in_group("Month", "C", "G1").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.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 layout = GridLayout::new(&wb.model, wb.active_view());
let labels: Vec<String> = (0..layout.row_count()) let labels: Vec<String> = (0..layout.row_count())
.map(|r| layout.row_label(r)) .map(|r| layout.row_label(r))
@@ -1518,10 +1582,11 @@ mod tests {
wb.add_category("Type").unwrap(); wb.add_category("Type").unwrap();
wb.add_item_in_group("Month", "Jan", "Q1").unwrap(); wb.add_item_in_group("Month", "Jan", "Q1").unwrap();
wb.add_item_in_group("Month", "Apr", "Q2").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("Region", "North").unwrap();
wb.add_item("Type", "Food").unwrap(); wb.add_item("Type", "Food").unwrap();
wb.active_view_mut().set_axis("Region", Axis::Row); 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()); let layout = GridLayout::new(&wb.model, wb.active_view());
assert_eq!( assert_eq!(
layout.row_tuple(0), layout.row_tuple(0),
@@ -1543,6 +1608,7 @@ mod tests {
wb.add_category("Type").unwrap(); wb.add_category("Type").unwrap();
wb.add_item_in_group("Month", "Jan", "Q1").unwrap(); wb.add_item_in_group("Month", "Jan", "Q1").unwrap();
wb.add_item_in_group("Month", "Feb", "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("Month", "Sep").unwrap();
wb.add_item("Type", "Food").unwrap(); wb.add_item("Type", "Food").unwrap();
let layout = GridLayout::new(&wb.model, wb.active_view()); let layout = GridLayout::new(&wb.model, wb.active_view());
@@ -1572,6 +1638,7 @@ mod tests {
wb.add_item_in_group("Recipient", "Alice", "Family") wb.add_item_in_group("Recipient", "Alice", "Family")
.unwrap(); .unwrap();
wb.add_item_in_group("Recipient", "Bob", "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); wb.active_view_mut().set_axis("Recipient", Axis::Row);
let layout = GridLayout::new(&wb.model, wb.active_view()); let layout = GridLayout::new(&wb.model, wb.active_view());
@@ -1612,6 +1679,7 @@ mod tests {
wb.add_category("Type").unwrap(); wb.add_category("Type").unwrap();
wb.add_item_in_group("Month", "Jan", "Q1").unwrap(); wb.add_item_in_group("Month", "Jan", "Q1").unwrap();
wb.add_item_in_group("Month", "Apr", "Q2").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.add_item("Type", "Food").unwrap();
wb.model.set_cell( wb.model.set_cell(
coord(&[("Month", "Apr"), ("Type", "Food")]), coord(&[("Month", "Apr"), ("Type", "Food")]),
@@ -1630,6 +1698,7 @@ mod tests {
wb.add_category("Type").unwrap(); wb.add_category("Type").unwrap();
wb.add_item_in_group("Month", "Jan", "Q1").unwrap(); wb.add_item_in_group("Month", "Jan", "Q1").unwrap();
wb.add_item_in_group("Month", "Apr", "Q2").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.add_item("Type", "Food").unwrap();
let layout = GridLayout::new(&wb.model, wb.active_view()); let layout = GridLayout::new(&wb.model, wb.active_view());
// visual: [GroupHeader(Q1), DataItem(Jan), GroupHeader(Q2), DataItem(Apr)] // visual: [GroupHeader(Q1), DataItem(Jan), GroupHeader(Q2), DataItem(Apr)]
@@ -1646,6 +1715,7 @@ mod tests {
wb.add_item("Type", "Food").unwrap(); wb.add_item("Type", "Food").unwrap();
wb.add_item_in_group("Month", "Jan", "Q1").unwrap(); wb.add_item_in_group("Month", "Jan", "Q1").unwrap();
wb.add_item_in_group("Month", "Apr", "Q2").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()); let layout = GridLayout::new(&wb.model, wb.active_view());
// col_items: [GroupHeader(Q1), DataItem(Jan), GroupHeader(Q2), DataItem(Apr)] // col_items: [GroupHeader(Q1), DataItem(Jan), GroupHeader(Q2), DataItem(Apr)]
assert_eq!(layout.data_col_to_visual(0), Some(1)); assert_eq!(layout.data_col_to_visual(0), Some(1));
@@ -1660,6 +1730,7 @@ mod tests {
wb.add_category("Type").unwrap(); wb.add_category("Type").unwrap();
wb.add_item_in_group("Month", "Jan", "Q1").unwrap(); wb.add_item_in_group("Month", "Jan", "Q1").unwrap();
wb.add_item_in_group("Month", "Apr", "Q2").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.add_item("Type", "Food").unwrap();
let layout = GridLayout::new(&wb.model, wb.active_view()); let layout = GridLayout::new(&wb.model, wb.active_view());
assert_eq!( assert_eq!(
@@ -1691,6 +1762,7 @@ mod tests {
wb.add_item("Type", "Food").unwrap(); wb.add_item("Type", "Food").unwrap();
wb.add_item_in_group("Month", "Jan", "Q1").unwrap(); wb.add_item_in_group("Month", "Jan", "Q1").unwrap();
wb.add_item_in_group("Month", "Apr", "Q2").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()); let layout = GridLayout::new(&wb.model, wb.active_view());
assert_eq!( assert_eq!(
layout.col_group_for(0), layout.col_group_for(0),
@@ -1725,6 +1797,7 @@ mod tests {
None => wb.add_item("Month", *name).unwrap(), None => wb.add_item("Month", *name).unwrap(),
}; };
} }
wb.active_view_mut().set_expand_depth("Month", 1);
wb.add_item("Type", "Food").unwrap(); wb.add_item("Type", "Food").unwrap();
for m in funded { for m in funded {
wb.model.set_cell( wb.model.set_cell(
@@ -1755,7 +1828,7 @@ mod tests {
&[("Jan", Some("Q1")), ("Mar", None), ("Sep", None)], &[("Jan", Some("Q1")), ("Mar", None), ("Sep", None)],
&["Jan", "Sep"], &["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); let layout = GridLayout::with_frozen_records(&wb.model, wb.active_view(), None);
// The whole sequence, not `any(is Q1)`: that cannot see a header // The whole sequence, not `any(is Q1)`: that cannot see a header
// pushed in the wrong place, nor a stray `GroupEnd`. // pushed in the wrong place, nor a stray `GroupEnd`.
@@ -1764,14 +1837,16 @@ mod tests {
vec![ vec![
AxisEntry::GroupHeader { AxisEntry::GroupHeader {
level: 0, level: 0,
depth: 0,
cat_name: "Month".into(), cat_name: "Month".into(),
group_name: "Q1".into() group_name: "Q1".into(),
collapsed: true,
}, },
AxisEntry::DataItem { AxisEntry::DataItem {
tuple: vec!["Q1".into()], tuple: vec!["Q1".into()],
collapsed: true collapsed: true
}, },
AxisEntry::GroupEnd { level: 0 }, AxisEntry::GroupEnd { level: 0, depth: 0 },
AxisEntry::item(vec!["Sep".into()]), AxisEntry::item(vec!["Sep".into()]),
], ],
"Q1 folds to one funded row under its header, and Mar pruned" "Q1 folds to one funded row under its header, and Mar pruned"
@@ -1814,6 +1889,8 @@ mod tests {
wb.add_item_in_group("Region", "South", "Lower").unwrap(); wb.add_item_in_group("Region", "South", "Lower").unwrap();
wb.add_item_in_group("Month", "Jan", "Q1").unwrap(); wb.add_item_in_group("Month", "Jan", "Q1").unwrap();
wb.add_item_in_group("Month", "Feb", "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(); wb.add_item("Type", "Food").unwrap();
for month in ["Jan", "Feb"] { for month in ["Jan", "Feb"] {
wb.model.set_cell( wb.model.set_cell(
@@ -1895,6 +1972,7 @@ mod tests {
wb.add_item_in_group("Month", "Apr", "Q2").unwrap(); wb.add_item_in_group("Month", "Apr", "Q2").unwrap();
wb.add_item_in_group("Month", "Jan", "Q1").unwrap(); wb.add_item_in_group("Month", "Jan", "Q1").unwrap();
wb.add_item_in_group("Month", "Feb", "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", "Revenue").unwrap();
wb.add_item("_Measure", "Cost").unwrap(); wb.add_item("_Measure", "Cost").unwrap();
for (month, revenue, cost) in [("Jan", 10.0, 5.0), ("Feb", 30.0, 3.0)] { for (month, revenue, cost) in [("Jan", 10.0, 5.0), ("Feb", 30.0, 3.0)] {
@@ -1937,7 +2015,7 @@ mod tests {
.collect(); .collect();
let chain: Vec<&str> = chain.iter().map(String::as_str).collect(); let chain: Vec<&str> = chain.iter().map(String::as_str).collect();
let mut wb = margin_book(&chain); 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()); wb.model.recompute_formulas(&wb.active_view().none_cats());
let unpruned = GridLayout::new(&wb.model, wb.active_view()); let unpruned = GridLayout::new(&wb.model, wb.active_view());
let kept_by_fresh_evaluation: Vec<String> = (0..unpruned.row_count()) let kept_by_fresh_evaluation: Vec<String> = (0..unpruned.row_count())
@@ -1976,7 +2054,7 @@ mod tests {
.collect(); .collect();
let chain: Vec<&str> = chain.iter().map(String::as_str).collect(); let chain: Vec<&str> = chain.iter().map(String::as_str).collect();
let mut wb = margin_book(&chain); 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()); wb.model.recompute_formulas(&wb.active_view().none_cats());
let layout = GridLayout::new(&wb.model, wb.active_view()); let layout = GridLayout::new(&wb.model, wb.active_view());
let q1 = (0..layout.row_count()) let q1 = (0..layout.row_count())
@@ -2055,7 +2133,7 @@ mod tests {
#[test] #[test]
fn a_grid_evaluates_under_its_own_covers_not_another_grids() { fn a_grid_evaluates_under_its_own_covers_not_another_grids() {
let mut wb = margin_book(&["R = Revenue / Cost"]); 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 all = GridLayout::new(&wb.model, wb.active_view());
let mut no_feb_view = wb.active_view().clone(); let mut no_feb_view = wb.active_view().clone();
no_feb_view.hide_item("Month", "Feb"); no_feb_view.hide_item("Month", "Feb");
@@ -2093,7 +2171,7 @@ mod tests {
let mut folded = margin_book(formulas); let mut folded = margin_book(formulas);
folded folded
.active_view_mut() .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 fold = grid_value(&mut folded, "Q1", "Margin").expect("a Q1 fold");
let mut none = margin_book(formulas); let mut none = margin_book(formulas);
@@ -2120,7 +2198,7 @@ mod tests {
let mut folded = margin_book(&formulas); let mut folded = margin_book(&formulas);
folded folded
.active_view_mut() .active_view_mut()
.toggle_group_collapse("Month", "Q1"); .toggle_group_collapse("Month", &["Q1"]);
let fold = grid_value(&mut folded, "Q1", measure); let fold = grid_value(&mut folded, "Q1", measure);
let mut none = margin_book(&formulas); let mut none = margin_book(&formulas);
none.active_view_mut().set_axis("Month", Axis::None); none.active_view_mut().set_axis("Month", Axis::None);
@@ -2133,7 +2211,7 @@ mod tests {
let mut folded = margin_book(&formulas); let mut folded = margin_book(&formulas);
folded folded
.active_view_mut() .active_view_mut()
.toggle_group_collapse("Month", "Q1"); .toggle_group_collapse("Month", &["Q1"]);
assert_eq!( assert_eq!(
grid_value(&mut folded, "Q1", "Flag"), grid_value(&mut folded, "Q1", "Flag"),
Some(0.0), Some(0.0),
@@ -2146,7 +2224,7 @@ mod tests {
#[test] #[test]
fn a_folds_aggregate_formula_pools_its_members_records() { fn a_folds_aggregate_formula_pools_its_members_records() {
let mut wb = margin_book(&["Top = MAX(Revenue)", "Total = SUM(Revenue)"]); 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", "Total"), Some(40.0));
assert_eq!(grid_value(&mut wb, "Q1", "Top"), Some(30.0)); assert_eq!(grid_value(&mut wb, "Q1", "Top"), Some(30.0));
} }
@@ -2159,7 +2237,7 @@ mod tests {
let mut folded = margin_book(&formula); let mut folded = margin_book(&formula);
folded folded
.active_view_mut() .active_view_mut()
.toggle_group_collapse("Month", "Q1"); .toggle_group_collapse("Month", &["Q1"]);
let fold = grid_value(&mut folded, "Q1", "Jans"); let fold = grid_value(&mut folded, "Q1", "Jans");
let mut none = margin_book(&formula); let mut none = margin_book(&formula);
@@ -2198,12 +2276,13 @@ mod tests {
CellValue::Number(n), CellValue::Number(n),
); );
} }
wb.active_view_mut().set_expand_depth("Month", 1);
wb.active_view_mut().set_axis("Month", Axis::Row); wb.active_view_mut().set_axis("Month", Axis::Row);
wb.active_view_mut().set_axis("Type", Axis::Column); wb.active_view_mut().set_axis("Type", Axis::Column);
wb.active_view_mut().hide_item("Month", "Feb"); wb.active_view_mut().hide_item("Month", "Feb");
assert_eq!(column_total(&wb), 103.0, "expanded: Jan and Apr are drawn"); 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!(column_total(&wb), 103.0, "z moved the total");
assert_eq!( assert_eq!(
grid_value(&mut wb, "Q1", "Food"), grid_value(&mut wb, "Q1", "Food"),
@@ -2290,7 +2369,7 @@ mod group_membership {
use std::ops::ControlFlow; use std::ops::ControlFlow;
/// Every data item's open runs, collected through the one reader. /// 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(); let mut all = Vec::new();
walk_open_runs(items, |open| { walk_open_runs(items, |open| {
all.push(open.to_vec()); all.push(open.to_vec());
@@ -2300,7 +2379,9 @@ mod group_membership {
} }
type Level<'a> = (&'a str, &'a [(&'a str, Option<&'a str>)]); 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] = &[ const TWO: &[Level] = &[
("Region", &[("North", Some("Upper")), ("South", None)]), ("Region", &[("North", Some("Upper")), ("South", None)]),
@@ -2354,19 +2435,80 @@ mod group_membership {
( (
TWO, TWO,
&[("Region", "Upper"), ("Month", "Q1"), ("Month", "Q2")], &[("Region", "Upper"), ("Month", "Q1"), ("Month", "Q2")],
&[],
), ),
( (
THREE, THREE,
&[("Region", "Upper"), ("Month", "Q1"), ("Kind", "Goods")], &[("Region", "Upper"), ("Month", "Q1"), ("Kind", "Goods")],
&[],
), ),
(NONCONTIGUOUS, &[("Region", "Upper"), ("Month", "Q1")]), (NONCONTIGUOUS, &[("Region", "Upper"), ("Month", "Q1")], &[]),
(HOLE, &[("Region", "Upper"), ("Month", "Q1")]), (HOLE, &[("Region", "Upper"), ("Month", "Q1")], &[]),
( (
ADJACENT, ADJACENT,
&[("Region", "Upper"), ("Region", "Lower"), ("Month", "Q1")], &[("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 /// `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. /// is set: the unpruned layout has every row, and pruning is driven directly.
fn book(levels: &[Level], collapsed: &[(&str, &str)]) -> Workbook { fn book(levels: &[Level], collapsed: &[(&str, &str)]) -> Workbook {
@@ -2380,12 +2522,13 @@ mod group_membership {
}; };
} }
wb.active_view_mut().set_axis(cat, Axis::Row); wb.active_view_mut().set_axis(cat, Axis::Row);
wb.active_view_mut().set_expand_depth(cat, 1);
} }
wb.add_category("Col").unwrap(); wb.add_category("Col").unwrap();
wb.add_item("Col", "X").unwrap(); wb.add_item("Col", "X").unwrap();
wb.active_view_mut().set_axis("Col", Axis::Column); wb.active_view_mut().set_axis("Col", Axis::Column);
for (cat, group) in collapsed { for (cat, group) in collapsed {
wb.active_view_mut().toggle_group_collapse(cat, group); wb.active_view_mut().toggle_group_collapse(cat, &[group]);
} }
wb wb
} }
@@ -2406,13 +2549,13 @@ mod group_membership {
/// non-empty set of kept rows. The layout is the cost, so it is built once. /// non-empty set of kept rows. The layout is the cost, so it is built once.
fn cases() -> Vec<Case> { fn cases() -> Vec<Case> {
let mut out = Vec::new(); 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()) { for folds in 0..(1u32 << groups.len()) {
let collapsed: Vec<(&str, &str)> = (0..groups.len()) let collapsed: Vec<(&str, &str)> = (0..groups.len())
.filter(|g| folds & (1 << g) != 0) .filter(|g| folds & (1 << g) != 0)
.map(|g| groups[g]) .map(|g| groups[g])
.collect(); .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(); let rows = data_items(&full).count();
for mask in 1..(1u32 << rows) { for mask in 1..(1u32 << rows) {
let mut pruned = full.clone(); let mut pruned = full.clone();
@@ -2441,8 +2584,8 @@ mod group_membership {
} }
} }
fn at(runs: &[Option<usize>], level: usize) -> Option<usize> { fn at(runs: &[super::OpenRun], key: super::RunKey) -> Option<usize> {
runs.get(level).copied().flatten() runs.iter().find(|(k, _)| *k == key).map(|(_, at)| *at)
} }
#[test] #[test]
@@ -2463,24 +2606,25 @@ mod group_membership {
if kept.len() == full_runs.len() { if kept.len() == full_runs.len() {
assert_eq!(pruned, full, "{name}: nothing pruned"); 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 (r, &u) in kept.iter().enumerate() {
for level in 0..levels { for &level in &levels {
assert_eq!( assert_eq!(
named(pruned, at(&pruned_runs[r], level)), named(pruned, at(&pruned_runs[r], level)),
named(full, at(&full_runs[u], 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 { if r == 0 {
continue; 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) at(&runs[a], level).is_some() && at(&runs[a], level) == at(&runs[b], level)
}; };
assert_eq!( assert_eq!(
same(&pruned_runs, r - 1, r), same(&pruned_runs, r - 1, r),
same(&full_runs, kept[r - 1], u), 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 +2682,18 @@ mod group_membership {
let items = vec![ let items = vec![
AxisEntry::GroupHeader { AxisEntry::GroupHeader {
level: 0, level: 0,
depth: 0,
cat_name: "Region".into(), cat_name: "Region".into(),
group_name: "Upper".into(), group_name: "Upper".into(),
collapsed: false,
}, },
AxisEntry::item(vec!["North".into()]), AxisEntry::item(vec!["North".into()]),
AxisEntry::GroupHeader { AxisEntry::GroupHeader {
level: usize::MAX, level: usize::MAX,
depth: 0,
cat_name: "Nowhere".into(), cat_name: "Nowhere".into(),
group_name: "Unreachable".into(), group_name: "Unreachable".into(),
collapsed: false,
}, },
AxisEntry::item(vec!["South".into()]), AxisEntry::item(vec!["South".into()]),
]; ];
@@ -2561,14 +2709,16 @@ mod group_membership {
fn a_late_lookup_answers_for_its_own_row() { fn a_late_lookup_answers_for_its_own_row() {
let header = |group: &str| AxisEntry::GroupHeader { let header = |group: &str| AxisEntry::GroupHeader {
level: 0, level: 0,
depth: 0,
cat_name: "Month".into(), cat_name: "Month".into(),
group_name: group.into(), group_name: group.into(),
collapsed: false,
}; };
let items = vec![ let items = vec![
header("Q1"), header("Q1"),
AxisEntry::item(vec!["Jan".into()]), AxisEntry::item(vec!["Jan".into()]),
AxisEntry::item(vec!["Feb".into()]), AxisEntry::item(vec!["Feb".into()]),
AxisEntry::GroupEnd { level: 0 }, AxisEntry::GroupEnd { level: 0, depth: 0 },
AxisEntry::item(vec!["Mar".into()]), AxisEntry::item(vec!["Mar".into()]),
header("Q2"), header("Q2"),
AxisEntry::item(vec!["Apr".into()]), AxisEntry::item(vec!["Apr".into()]),
@@ -2601,4 +2751,222 @@ mod group_membership {
); );
assert_eq!(l.row_group_for(at(["South", "Mar"])), None); 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));
}
} }
+1 -1
View File
@@ -9,5 +9,5 @@ pub use layout::{
records_steps, synthetic_record_info, records_steps, synthetic_record_info,
}; };
pub use types::{ 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,
}; };
+177 -38
View File
@@ -1,5 +1,6 @@
use indexmap::IndexMap; use indexmap::IndexMap;
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
use std::num::NonZeroUsize;
use super::axis::Axis; use super::axis::Axis;
use crate::model::category::CategoryKind; use crate::model::category::CategoryKind;
@@ -27,6 +28,16 @@ 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") .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. Non-zero by
/// type, since `expanded(0)` is no spelling the grammar reads back.
Expanded(NonZeroUsize),
}
#[derive(Clone)] #[derive(Clone)]
pub struct View { pub struct View {
pub name: String, pub name: String,
@@ -36,8 +47,11 @@ pub struct View {
pub page_selections: HashMap<String, String>, pub page_selections: HashMap<String, String>,
/// Hidden items per category /// Hidden items per category
pub hidden_items: HashMap<String, HashSet<String>>, pub hidden_items: HashMap<String, HashSet<String>>,
/// Collapsed groups per category /// How deep each category's hierarchy opens: groups shallower than this start
pub collapsed_groups: HashMap<String, HashSet<String>>, /// 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) /// Number format string (e.g. ",.0f" for comma-separated integer)
pub number_format: String, pub number_format: String,
/// When true, empty rows/columns are pruned from the display. /// When true, empty rows/columns are pruned from the display.
@@ -54,7 +68,8 @@ impl std::fmt::Debug for View {
category_axes, category_axes,
page_selections, page_selections,
hidden_items, hidden_items,
collapsed_groups, expand_depth,
group_folds,
number_format, number_format,
prune_empty, prune_empty,
} = self; } = self;
@@ -64,12 +79,18 @@ impl std::fmt::Debug for View {
.collect() .collect()
}; };
let pages: BTreeMap<_, _> = page_selections.iter().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") f.debug_struct("View")
.field("name", name) .field("name", name)
.field("category_axes", category_axes) .field("category_axes", category_axes)
.field("page_selections", &pages) .field("page_selections", &pages)
.field("hidden_items", &sorted(hidden_items)) .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("number_format", number_format)
.field("prune_empty", prune_empty) .field("prune_empty", prune_empty)
.finish() .finish()
@@ -88,7 +109,8 @@ impl View {
category_axes: IndexMap::new(), category_axes: IndexMap::new(),
page_selections: HashMap::new(), page_selections: HashMap::new(),
hidden_items: 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 // Decimals by default: a ratio is the case a format cannot be
// guessed for, and `.0` draws 2/3 as `1` (improvise-e0ex). // guessed for, and `.0` draws 2/3 as `1` (improvise-e0ex).
number_format: ",.2".to_string(), number_format: ",.2".to_string(),
@@ -135,7 +157,8 @@ impl View {
self.category_axes.shift_remove(cat_name); self.category_axes.shift_remove(cat_name);
self.page_selections.remove(cat_name); self.page_selections.remove(cat_name);
self.hidden_items.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 /// Forget an item this view names. A hidden or paged-to item that outlives
@@ -222,23 +245,78 @@ impl View {
self.page_selections.get(cat_name).map(|s| s.as_str()) self.page_selections.get(cat_name).map(|s| s.as_str())
} }
pub fn toggle_group_collapse(&mut self, cat_name: &str, group_name: &str) { /// How many of `cat_name`'s top levels open (`expanded(N): Cat`); 0 — all
let set = self /// closed — if unset.
.collapsed_groups pub fn expand_depth(&self, cat_name: &str) -> usize {
.entry(cat_name.to_string()) self.expand_depth.get(cat_name).copied().unwrap_or(0)
.or_default(); }
if set.contains(group_name) {
set.remove(group_name); /// Open `cat_name`'s top `depth` levels; 0 forgets the setting.
} else { pub fn set_expand_depth(&mut self, cat_name: &str, depth: usize) {
set.insert(group_name.to_string()); 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 { /// The answer without the group's own fold: open when the category's depth or
self.collapsed_groups /// an ancestor's `expanded(N)` reaches it, closed otherwise.
.get(cat_name) fn inherits_collapsed(&self, cat_name: &str, chain: &[&str]) -> bool {
.map(|s| s.contains(group_name)) let depth = chain.len().saturating_sub(1);
.unwrap_or(false) 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.get())
});
!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(NonZeroUsize::MIN),
};
self.set_group_fold(cat_name, group, fold);
}
}
} }
pub fn hide_item(&mut self, cat_name: &str, item_name: &str) { pub fn hide_item(&mut self, cat_name: &str, item_name: &str) {
@@ -292,9 +370,10 @@ impl View {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{View, unused_view_name}; use super::{GroupFold, View, unused_view_name};
use crate::model::category::CategoryKind; use crate::model::category::CategoryKind;
use crate::view::Axis; use crate::view::Axis;
use std::num::NonZeroUsize;
/// improvise-vw47: the count is the starting guess, never the answer. /// improvise-vw47: the count is the starting guess, never the answer.
/// What a taken name costs: `context/test-evidence/core.md`. /// What a taken name costs: `context/test-evidence/core.md`.
@@ -563,28 +642,87 @@ mod tests {
assert_eq!(v.page_selection("Region"), None); assert_eq!(v.page_selection("Region"), None);
} }
/// The user's ruling on improvise-felo.6: every group starts closed.
#[test] #[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"); let mut v = View::new("Test");
assert!(!v.is_group_collapsed("Time", "Q1")); v.set_expand_depth("Time", 1);
v.toggle_group_collapse("Time", "Q1"); assert!(!v.is_group_collapsed("Time", &["2024"]));
assert!(v.is_group_collapsed("Time", "Q1")); assert!(v.is_group_collapsed("Time", &["2024", "Q1-2024"]));
v.toggle_group_collapse("Time", "Q1"); assert!(v.is_group_collapsed("Other", &["G"]), "per category");
assert!(!v.is_group_collapsed("Time", "Q1")); 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(NonZeroUsize::new(2).unwrap()),
);
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(NonZeroUsize::MIN))
);
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] #[test]
fn is_group_collapsed_isolated_across_categories() { fn is_group_collapsed_isolated_across_categories() {
let mut v = View::new("Test"); let mut v = View::new("Test");
v.toggle_group_collapse("Cat1", "G1"); v.toggle_group_collapse("Cat1", &["G1"]);
assert!(!v.is_group_collapsed("Cat2", "G1")); assert!(!v.is_group_collapsed("Cat1", &["G1"]));
assert!(v.is_group_collapsed("Cat2", &["G1"]));
} }
#[test] #[test]
fn is_group_collapsed_isolated_across_groups() { fn is_group_collapsed_isolated_across_groups() {
let mut v = View::new("Test"); let mut v = View::new("Test");
v.toggle_group_collapse("Cat1", "G1"); v.toggle_group_collapse("Cat1", &["G1"]);
assert!(!v.is_group_collapsed("Cat1", "G2")); assert!(v.is_group_collapsed("Cat1", &["G2"]));
} }
/// Arm 2 of improvise-qx1z: the other three lines of `on_category_removed` /// Arm 2 of improvise-qx1z: the other three lines of `on_category_removed`
@@ -593,12 +731,13 @@ mod tests {
fn on_category_removed_forgets_a_collapsed_group() { fn on_category_removed_forgets_a_collapsed_group() {
let mut v = View::new("V"); let mut v = View::new("V");
v.on_category_added("Month", CategoryKind::of_name("Month")); 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_removed("Month");
v.on_category_added("Month", CategoryKind::of_name("Month")); v.on_category_added("Month", CategoryKind::of_name("Month"));
assert!( assert!(
!v.is_group_collapsed("Month", "Q1"), v.group_folds.is_empty() && v.expand_depth.is_empty(),
"a re-added category came back with its group collapsed" "a re-added category came back with its old fold state"
); );
} }
@@ -808,10 +947,10 @@ mod prop_tests {
group in "[A-Za-z][a-z]{1,7}", group in "[A-Za-z][a-z]{1,7}",
) { ) {
let mut v = View::new("T"); let mut v = View::new("T");
let initial = v.is_group_collapsed(&cat, &group); let initial = v.is_group_collapsed(&cat, &[group.as_str()]);
v.toggle_group_collapse(&cat, &group); v.toggle_group_collapse(&cat, &[group.as_str()]);
v.toggle_group_collapse(&cat, &group); v.toggle_group_collapse(&cat, &[group.as_str()]);
prop_assert_eq!(v.is_group_collapsed(&cat, &group), initial); prop_assert_eq!(v.is_group_collapsed(&cat, &[group.as_str()]), initial);
} }
} }
} }
@@ -214,6 +214,7 @@ mod tests {
wb.add_category("Type").unwrap(); wb.add_category("Type").unwrap();
wb.add_item_in_group("Month", "Jan", "Q1").unwrap(); wb.add_item_in_group("Month", "Jan", "Q1").unwrap();
wb.add_item_in_group("Month", "Feb", "Q1").unwrap(); wb.add_item_in_group("Month", "Feb", "Q1").unwrap();
wb.active_view_mut().set_expand_depth("Month", 1);
wb.add_item("Type", "Food").unwrap(); wb.add_item("Type", "Food").unwrap();
for (month, value) in [("Jan", 3.0), ("Feb", 4.0)] { for (month, value) in [("Jan", 3.0), ("Feb", 4.0)] {
wb.model.set_cell( wb.model.set_cell(
@@ -226,7 +227,7 @@ mod tests {
v.set_axis("Month", Axis::Row); v.set_axis("Month", Axis::Row);
v.set_axis("Type", Axis::Column); v.set_axis("Type", Axis::Column);
v.prune_empty = true; v.prune_empty = true;
v.toggle_group_collapse("Month", "Q1"); v.toggle_group_collapse("Month", &["Q1"]);
} }
let csv = String::from_utf8(super::render_csv(&wb, "Default").unwrap()).unwrap(); let csv = String::from_utf8(super::render_csv(&wb, "Default").unwrap()).unwrap();
let rows: Vec<&str> = csv.lines().skip(1).collect(); let rows: Vec<&str> = csv.lines().skip(1).collect();
+34 -15
View File
@@ -2,7 +2,7 @@
use crate::model::category::CategoryKind; use crate::model::category::CategoryKind;
use crate::model::cell::{CellKey, CellValue}; use crate::model::cell::{CellKey, CellValue};
use crate::view::Axis; use crate::view::{Axis, GroupFold};
use crate::workbook::Workbook; use crate::workbook::Workbook;
// The one home of the pipe-quoting rule (improvise-i2m5); a name written here // The one home of the pipe-quoting rule (improvise-i2m5); a name written here
@@ -119,19 +119,37 @@ pub fn format_md(workbook: &Workbook) -> String {
if view.prune_empty { if view.prune_empty {
w!(out, "prune: true"); w!(out, "prune: true");
} }
for (prefix, map) in [ let mut hidden: Vec<_> = view
("hidden", &view.hidden_items), .hidden_items
("collapsed", &view.collapsed_groups), .iter()
] { .flat_map(|(cat, items)| items.iter().map(move |item| (cat.as_str(), item.as_str())))
let mut pairs: Vec<_> = map .collect();
.iter() hidden.sort();
.flat_map(|(cat, items)| { for (cat, item) in hidden {
items.iter().map(move |item| (cat.as_str(), item.as_str())) w!(out, "hidden: {}/{}", quote_name(cat), quote_name(item));
}) }
.collect(); let mut depths: Vec<_> = view.expand_depth.iter().collect();
pairs.sort(); depths.sort();
for (cat, item) in pairs { // Never bare: `expanded: row` would read back as an axis line.
w!(out, "{prefix}: {}/{}", quote_name(cat), quote_name(item)); for (cat, depth) in depths {
w!(out, "expanded({depth}): {}", quote_name(cat));
}
let mut folds: Vec<_> = view
.group_folds
.iter()
.flat_map(|(cat, groups)| {
groups
.iter()
.map(move |(g, fold)| (cat.as_str(), g.as_str(), *fold))
})
.collect();
folds.sort();
for (cat, group, fold) in folds {
let target = format!("{}/{}", quote_name(cat), quote_name(group));
match fold {
GroupFold::Collapsed => w!(out, "collapsed: {target}"),
GroupFold::Expanded(n) if n.get() == 1 => w!(out, "expanded: {target}"),
GroupFold::Expanded(n) => w!(out, "expanded({n}): {target}"),
} }
} }
} }
@@ -370,7 +388,8 @@ mod tests {
#[test] #[test]
fn format_md_collapsed_group() { fn format_md_collapsed_group() {
let mut m = two_cat_model(); let mut m = two_cat_model();
m.active_view_mut().toggle_group_collapse("Type", "MyGroup"); m.active_view_mut()
.set_group_fold("Type", "MyGroup", crate::view::GroupFold::Collapsed);
let text = format_md(&m); let text = format_md(&m);
assert!(text.contains("collapsed: Type/MyGroup")); assert!(text.contains("collapsed: Type/MyGroup"));
} }
@@ -120,7 +120,9 @@ view_section = {
view_entry* view_entry*
} }
view_entry = _{ format_line | prune_line | hidden_line | collapsed_line | axis_line } // `axis_line` is tried before `expanded_line`, so a category named `expanded`
// keeps its `expanded: row`; the writer never spells a category depth bare.
view_entry = _{ format_line | prune_line | hidden_line | collapsed_line | axis_line | expanded_line }
axis_line = { axis_line = {
name ~ ": " ~ axis_kind ~ (", " ~ name)? ~ NEWLINE ~ blank_lines name ~ ": " ~ axis_kind ~ (", " ~ name)? ~ NEWLINE ~ blank_lines
@@ -135,6 +137,12 @@ prune_line = { "prune: " ~ bool_kw ~ NEWLINE ~ blank_lines }
bool_kw = @{ "true" | "false" } bool_kw = @{ "true" | "false" }
hidden_line = { "hidden: " ~ name ~ "/" ~ name ~ NEWLINE ~ blank_lines } hidden_line = { "hidden: " ~ name ~ "/" ~ name ~ NEWLINE ~ blank_lines }
collapsed_line = { "collapsed: " ~ name ~ "/" ~ name ~ NEWLINE ~ blank_lines } collapsed_line = { "collapsed: " ~ name ~ "/" ~ name ~ NEWLINE ~ blank_lines }
// Groups start collapsed. `expanded(N): Cat` opens the category's top N levels;
// `expanded(N): Cat/Group` opens the group and N-1 levels beneath it. A bare
// `expanded:` is N = 1, and N is at least 1. context/code/formula-and-io.md.
expanded_line = { "expanded" ~ fold_depth? ~ ": " ~ name ~ ("/" ~ name)? ~ NEWLINE ~ blank_lines }
fold_depth = { "(" ~ depth_number ~ ")" }
depth_number = @{ '1'..'9' ~ ASCII_DIGIT* }
// ── Names ──────────────────────────────────────────────────────────────────── // ── Names ────────────────────────────────────────────────────────────────────
// //
+161 -13
View File
@@ -9,10 +9,10 @@ use pest_derive::Parser;
use crate::formula::{AggFunc, parse_formula}; use crate::formula::{AggFunc, parse_formula};
use crate::model::category::{CategoryKind, Group}; use crate::model::category::{CategoryKind, Group};
use crate::model::cell::{CellKey, CellValue}; use crate::model::cell::{CellKey, CellValue};
use crate::view::Axis; use crate::view::{Axis, GroupFold};
use crate::workbook::Workbook; use crate::workbook::Workbook;
use crate::formula::quote::unescape_pipe_body; use crate::formula::quote::{quote_name, unescape_pipe_body};
#[derive(Parser)] #[derive(Parser)]
#[grammar = "persistence/improv.pest"] #[grammar = "persistence/improv.pest"]
@@ -40,9 +40,74 @@ struct PView {
format: String, format: String,
prune_empty: bool, prune_empty: bool,
hidden: Vec<(String, String)>, hidden: Vec<(String, String)>,
collapsed: Vec<(String, String)>, folds: Vec<PFold>,
} }
/// One `collapsed:` or `expanded(N):` line. `group` is `None` for the category
/// form, which only `expanded` has.
struct PFold {
category: String,
group: Option<String>,
fold: GroupFold,
}
/// A file the grammar accepts and a model cannot hold (improvise-felo.6).
/// Typed, so the generator's property can tell it from a parse failure.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Refusal {
/// `> A[B]` and `> B[A]`: a hierarchy must be a forest.
HierarchyCycle {
category: String,
groups: Vec<String>,
},
/// One view gives one group (or one category's depth) two different folds.
FoldConflict {
view: String,
category: String,
group: Option<String>,
},
}
impl std::fmt::Display for Refusal {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Refusal::HierarchyCycle { category, groups } => write!(
f,
"category {}: the group parents form a cycle through {}",
quote_name(category),
groups
.iter()
.map(|g| quote_name(g))
.collect::<Vec<_>>()
.join(" > ")
),
Refusal::FoldConflict {
view,
category,
group: Some(group),
} => write!(
f,
"view {}: group {}/{} is both expanded and collapsed, or expanded to two depths",
quote_name(view),
quote_name(category),
quote_name(group)
),
Refusal::FoldConflict {
view,
category,
group: None,
} => write!(
f,
"view {}: category {} is expanded to two depths",
quote_name(view),
quote_name(category)
),
}
}
}
impl std::error::Error for Refusal {}
/// Everything pass 1 collects, before any of it is resolved against the model. /// Everything pass 1 collects, before any of it is resolved against the model.
#[derive(Default)] #[derive(Default)]
struct ParsedFile { struct ParsedFile {
@@ -332,7 +397,31 @@ fn walk_axis_line(entry: Pair<'_, Rule>, pv: &mut PView) -> Result<()> {
Ok(()) Ok(())
} }
/// `## View: Name` plus its axis, format, hidden and collapsed lines. /// `expanded(N): Cat` or `expanded(N): Cat/Group`; a bare `expanded:` is N = 1.
fn walk_expanded_line(entry: Pair<'_, Rule>) -> Result<PFold> {
let mut depth = std::num::NonZeroUsize::MIN;
let mut names = Vec::new();
for part in entry.into_inner() {
match part.as_rule() {
Rule::fold_depth => {
let digits = first_str(part)?;
depth = digits
.parse()
.with_context(|| format!("expanded({digits}): depth out of range"))?;
}
_ => names.push(extract_name(part)?),
}
}
let mut names = names.into_iter();
let category = names.next().context("expanded_line: no category")?;
Ok(PFold {
category,
group: names.next(),
fold: GroupFold::Expanded(depth),
})
}
/// `## View: Name` plus its axis, format, hidden, collapsed and expanded lines.
fn walk_view_section(pair: Pair<'_, Rule>) -> Result<PView> { fn walk_view_section(pair: Pair<'_, Rule>) -> Result<PView> {
let mut inner = pair.into_inner(); let mut inner = pair.into_inner();
let mut pv = PView { let mut pv = PView {
@@ -342,7 +431,7 @@ fn walk_view_section(pair: Pair<'_, Rule>) -> Result<PView> {
format: String::new(), format: String::new(),
prune_empty: false, prune_empty: false,
hidden: Vec::new(), hidden: Vec::new(),
collapsed: Vec::new(), folds: Vec::new(),
}; };
for entry in inner { for entry in inner {
match entry.as_rule() { match entry.as_rule() {
@@ -350,7 +439,15 @@ fn walk_view_section(pair: Pair<'_, Rule>) -> Result<PView> {
Rule::format_line => pv.format = first_str(entry)?, Rule::format_line => pv.format = first_str(entry)?,
Rule::prune_line => pv.prune_empty = first_str(entry)? == "true", Rule::prune_line => pv.prune_empty = first_str(entry)? == "true",
Rule::hidden_line => pv.hidden.push(extract_name_pair(entry)?), Rule::hidden_line => pv.hidden.push(extract_name_pair(entry)?),
Rule::collapsed_line => pv.collapsed.push(extract_name_pair(entry)?), Rule::collapsed_line => {
let (category, group) = extract_name_pair(entry)?;
pv.folds.push(PFold {
category,
group: Some(group),
fold: GroupFold::Collapsed,
});
}
Rule::expanded_line => pv.folds.push(walk_expanded_line(entry)?),
other => bail!("unhandled rule in view_section: {other:?}"), other => bail!("unhandled rule in view_section: {other:?}"),
} }
} }
@@ -411,6 +508,13 @@ fn build_category(wb: &mut Workbook, pc: &PCategory) -> Result<()> {
None => cat.add_group(Group::new(group_name).with_parent(parent)), None => cat.add_group(Group::new(group_name).with_parent(parent)),
} }
} }
if let Some(cycle) = cat.hierarchy_cycle() {
return Err(Refusal::HierarchyCycle {
category: pc.name.clone(),
groups: cycle.into_iter().map(String::from).collect(),
}
.into());
}
Ok(()) Ok(())
} }
@@ -436,8 +540,29 @@ fn build_view(wb: &mut Workbook, pv: &PView) -> Result<()> {
for (cat, item) in &pv.hidden { for (cat, item) in &pv.hidden {
view.hide_item(cat, item); view.hide_item(cat, item);
} }
for (cat, grp) in &pv.collapsed { for PFold {
view.toggle_group_collapse(cat, grp); category,
group,
fold,
} in &pv.folds
{
let conflict = || Refusal::FoldConflict {
view: pv.name.clone(),
category: category.clone(),
group: group.clone(),
};
match (group, fold) {
(Some(g), _) => match view.group_fold(category, g) {
Some(held) if held != *fold => return Err(conflict().into()),
_ => view.set_group_fold(category, g, *fold),
},
(None, GroupFold::Expanded(n)) => match view.expand_depth(category) {
0 => view.set_expand_depth(category, n.get()),
held if held != n.get() => return Err(conflict().into()),
_ => {}
},
(None, GroupFold::Collapsed) => bail!("a category-wide collapse has no spelling"),
}
} }
Ok(()) Ok(())
} }
@@ -534,6 +659,7 @@ mod grammar_coverage {
Rule::axis_line | Rule::axis_kind => "walk_axis_line", Rule::axis_line | Rule::axis_kind => "walk_axis_line",
Rule::format_line | Rule::prune_line | Rule::bool_kw => "walk_view_section", Rule::format_line | Rule::prune_line | Rule::bool_kw => "walk_view_section",
Rule::hidden_line | Rule::collapsed_line => "walk_view_section", Rule::hidden_line | Rule::collapsed_line => "walk_view_section",
Rule::expanded_line | Rule::fold_depth | Rule::depth_number => "walk_expanded_line",
Rule::view_entry => "silent: hoisted into view_section", Rule::view_entry => "silent: hoisted into view_section",
Rule::name => "silent: hoisted into its parent", Rule::name => "silent: hoisted into its parent",
Rule::pipe_quoted | Rule::pipe_inner | Rule::bare_name => "extract_name", Rule::pipe_quoted | Rule::pipe_inner | Rule::bare_name => "extract_name",
@@ -1059,12 +1185,14 @@ mod parser_edge_cases {
fn collapsed_group_with_slash_in_name() { fn collapsed_group_with_slash_in_name() {
let mut m = Workbook::new("Test"); let mut m = Workbook::new("Test");
m.add_category("Type").unwrap(); m.add_category("Type").unwrap();
m.active_view_mut().toggle_group_collapse("Type", "Q1/Q2"); m.active_view_mut()
.set_group_fold("Type", "Q1/Q2", crate::view::GroupFold::Collapsed);
let text = format_md(&m); let text = format_md(&m);
let loaded = parse_md(&text).unwrap(); let loaded = parse_md(&text).unwrap();
assert!( assert!(
loaded.active_view().is_group_collapsed("Type", "Q1/Q2"), loaded.active_view().group_fold("Type", "Q1/Q2")
== Some(crate::view::GroupFold::Collapsed),
"Collapsed group with '/' in name was corrupted.\n{text}" "Collapsed group with '/' in name was corrupted.\n{text}"
); );
} }
@@ -1604,6 +1732,24 @@ mod generator {
}) })
} }
/// improvise-felo.6: the round-trip properties see `expanded(N):` lines only
/// if the walk emits them, depths included.
#[test]
fn the_generator_writes_expanded_lines_with_and_without_a_depth() {
use proptest::strategy::ValueTree;
use proptest::test_runner::TestRunner;
let mut runner = TestRunner::deterministic();
let strategy = improv_file();
let files: Vec<String> = (0..512)
.map(|_| strategy.new_tree(&mut runner).unwrap().current())
.collect();
let lines = || files.iter().flat_map(|f| f.lines());
assert!(lines().any(|l| l.starts_with("expanded(")), "a depth");
assert!(lines().any(|l| l.starts_with("expanded: ")), "a bare line");
assert!(lines().any(|l| l.starts_with("collapsed: ")), "a collapse");
}
/// improvise-8shu. Evidence: `context/test-evidence/formula-and-io.md`. /// improvise-8shu. Evidence: `context/test-evidence/formula-and-io.md`.
#[test] #[test]
fn the_grammar_is_parsed_once_however_many_files_the_strategy_yields() { fn the_grammar_is_parsed_once_however_many_files_the_strategy_yields() {
@@ -1625,7 +1771,7 @@ mod generator {
#[cfg(test)] #[cfg(test)]
mod grammar_prop_tests { mod grammar_prop_tests {
use super::{generator, parse_md}; use super::{Refusal, generator, parse_md};
use crate::persistence::format_md; use crate::persistence::format_md;
use proptest::prelude::*; use proptest::prelude::*;
@@ -1633,11 +1779,13 @@ mod grammar_prop_tests {
proptest! { proptest! {
#![proptest_config(ProptestConfig::with_cases(256))] #![proptest_config(ProptestConfig::with_cases(256))]
/// parse(generate()) — every generated file parses without error. /// parse(generate()) — every generated file parses, or is refused for a
/// named reason the grammar cannot express (a `Refusal`, improvise-felo.6).
#[test] #[test]
fn generated_file_parses(file in generator::improv_file()) { fn generated_file_parses(file in generator::improv_file()) {
let result = parse_md(&file); let result = parse_md(&file);
prop_assert!(result.is_ok(), let refused = result.as_ref().err().is_some_and(|e| e.downcast_ref::<Refusal>().is_some());
prop_assert!(result.is_ok() || refused,
"Generated file failed to parse:\n{}\nError: {}", "Generated file failed to parse:\n{}\nError: {}",
file, result.unwrap_err()); file, result.unwrap_err());
} }
@@ -267,10 +267,198 @@ fn parse_md_round_trips_text_cell_values() {
#[test] #[test]
fn parse_md_round_trips_collapsed_group() { fn parse_md_round_trips_collapsed_group() {
let mut m = two_cat_model(); let mut m = two_cat_model();
m.active_view_mut().toggle_group_collapse("Type", "MyGroup"); m.active_view_mut()
.set_group_fold("Type", "MyGroup", crate::view::GroupFold::Collapsed);
let text = format_md(&m); let text = format_md(&m);
let loaded = parse_md(&text).unwrap(); let loaded = parse_md(&text).unwrap();
assert!(loaded.active_view().is_group_collapsed("Type", "MyGroup")); assert!(
loaded.active_view().group_fold("Type", "MyGroup")
== Some(crate::view::GroupFold::Collapsed)
);
}
// ── Group hierarchies and folds (improvise-felo.6) ─────────────────────
/// Year > Quarter > Month as a file spells it: `> Quarter[Year]` lines.
const DATES: &str = "v2025-04-09\n# T\nInitial View: Default\n\n## View: Default\nDate: row\n\
_Measure: column\n@FOLDS@\n## Category: Date\n\
- |2024-01|[Q1-2024]\n- |2024-04|[Q2-2024]\n- |2025-01|[Q1-2025]\n\
> Q1-2024[|2024|]\n> Q2-2024[|2024|]\n> Q1-2025[|2025|]\n\n\
## Category: _Measure\n- Revenue\n";
fn dates_with(folds: &str) -> String {
DATES.replace("@FOLDS@", folds)
}
/// Every spelling of a fold, together in one view, survives save and load —
/// and the writer's own text is stable.
#[test]
fn expanded_and_collapsed_lines_round_trip_together() {
use crate::view::GroupFold;
let text = dates_with(
"expanded(2): Date\ncollapsed: Date/Q1-2024\nexpanded: Date/Q2-2024\n\
expanded(3): Date/|2025|",
);
let loaded = parse_md(&text).unwrap();
let v = loaded.active_view();
assert_eq!(v.expand_depth("Date"), 2);
assert_eq!(v.group_fold("Date", "Q1-2024"), Some(GroupFold::Collapsed));
assert_eq!(
v.group_fold("Date", "Q2-2024"),
Some(GroupFold::Expanded(std::num::NonZeroUsize::MIN))
);
assert_eq!(
v.group_fold("Date", "2025"),
Some(GroupFold::Expanded(std::num::NonZeroUsize::new(3).unwrap()))
);
let written = format_md(&loaded);
for line in [
"expanded(2): Date",
"collapsed: Date/Q1-2024",
"expanded: Date/Q2-2024",
"expanded(3): Date/|2025|",
] {
assert!(written.lines().any(|l| l == line), "{line} in:\n{written}");
}
assert_eq!(format_md(&parse_md(&written).unwrap()), written);
}
/// A file from before improvise-felo.6 carries only `collapsed:` lines. Every
/// group opens closed, the ones it names AND the ones it does not.
#[test]
fn an_old_file_with_only_collapsed_lines_loads_closed_and_keeps_its_lines() {
let text = dates_with("collapsed: Date/Q1-2024");
let loaded = parse_md(&text).unwrap();
let v = loaded.active_view();
let cat = loaded.model.category("Date").unwrap();
for group in ["2024", "Q1-2024", "Q2-2024", "2025"] {
assert!(
v.is_group_collapsed("Date", &cat.group_chain(group)),
"{group} opens closed"
);
}
assert!(
format_md(&loaded)
.lines()
.any(|l| l == "collapsed: Date/Q1-2024"),
"the writer keeps the line the file had, though it agrees with the default"
);
}
/// The user's ruling: expanded and collapsed on ONE group is an error, named.
#[test]
fn a_group_both_expanded_and_collapsed_is_refused_naming_view_and_group() {
use super::parse::Refusal;
for folds in [
"collapsed: Date/Q1-2024\nexpanded: Date/Q1-2024",
"expanded(2): Date/Q1-2024\ncollapsed: Date/Q1-2024",
"expanded: Date/Q1-2024\nexpanded(2): Date/Q1-2024",
] {
let err = parse_md(&dates_with(folds)).unwrap_err();
assert_eq!(
err.downcast_ref::<Refusal>(),
Some(&Refusal::FoldConflict {
view: "Default".into(),
category: "Date".into(),
group: Some("Q1-2024".into()),
}),
"{folds}: {err}"
);
assert!(err.to_string().contains("Default"), "{err}");
assert!(err.to_string().contains("Date/Q1-2024"), "{err}");
}
let err = parse_md(&dates_with("expanded(2): Date\nexpanded(3): Date")).unwrap_err();
assert!(err.downcast_ref::<Refusal>().is_some(), "{err}");
}
/// Not the error: a DIFFERENT group collapsed inside an expanded region, and
/// the same line twice.
#[test]
fn a_collapsed_group_inside_an_expanded_region_loads() {
let text = dates_with(
"expanded(2): Date\nexpanded(2): Date/|2024|\ncollapsed: Date/Q1-2024\n\
collapsed: Date/Q1-2024",
);
let loaded = parse_md(&text).unwrap();
let v = loaded.active_view();
assert!(v.is_group_collapsed("Date", &["2024", "Q1-2024"]));
assert!(!v.is_group_collapsed("Date", &["2024", "Q2-2024"]));
}
/// A cycle among `>` lines is refused on load, naming its members.
#[test]
fn a_cycle_among_group_parents_is_refused_naming_it() {
use super::parse::Refusal;
let text = DATES
.replace("@FOLDS@", "")
.replace("> Q1-2025[|2025|]", "> Q1-2025[|2025|]\n> |2025|[Q1-2025]");
let err = parse_md(&text).unwrap_err();
match err.downcast_ref::<Refusal>() {
Some(Refusal::HierarchyCycle { category, groups }) => {
assert_eq!(category, "Date");
let mut groups = groups.clone();
groups.sort();
assert_eq!(groups, vec!["2025", "Q1-2025"]);
}
other => panic!("not a cycle refusal: {other:?} ({err})"),
}
}
/// `expanded(0)` opens nothing and is not a spelling; `(x)` is not a depth.
#[test]
fn a_malformed_depth_is_a_parse_error() {
assert!(
parse_md(&dates_with("expanded(10): Date")).is_ok(),
"the control"
);
for bad in ["expanded(0): Date", "expanded(x): Date", "expanded(): Date"] {
assert!(parse_md(&dates_with(bad)).is_err(), "{bad}");
}
}
/// A category named `expanded` keeps its axis line, and a category whose NAME is
/// an axis keyword keeps its depth: the writer never spells a depth bare.
#[test]
fn an_axis_line_for_a_category_named_expanded_is_still_an_axis_line() {
let mut wb = Workbook::new("T");
wb.add_category("expanded").unwrap();
wb.add_category("row").unwrap();
wb.active_view_mut().set_axis("expanded", Axis::Column);
wb.active_view_mut().set_expand_depth("row", 1);
let loaded = parse_md(&format_md(&wb)).unwrap();
assert_eq!(loaded.active_view().axis_of("expanded"), Axis::Column);
assert_eq!(loaded.active_view().expand_depth("row"), 1);
}
/// `z` writes through ONE map, so no sequence of toggles can save a file that
/// names a group both ways. Every toggle order over three levels, twice over.
#[test]
fn no_sequence_of_toggles_writes_a_conflicting_file() {
let base = parse_md(&dates_with("expanded(1): Date")).unwrap();
let chains: [&[&str]; 4] = [
&["2024"],
&["2024", "Q1-2024"],
&["2024", "Q2-2024"],
&["2025", "Q1-2025"],
];
for order in 0..(4usize.pow(4)) {
let mut wb = base.clone();
let mut code = order;
for _ in 0..4 {
let chain = chains[code % 4];
code /= 4;
wb.active_view_mut().toggle_group_collapse("Date", chain);
let text = format_md(&wb);
let back = parse_md(&text).unwrap_or_else(|e| panic!("{e}\n{text}"));
for chain in chains {
assert_eq!(
back.active_view().is_group_collapsed("Date", chain),
wb.active_view().is_group_collapsed("Date", chain),
"{chain:?} after order {order}:\n{text}"
);
}
}
}
} }
// ── parse_md: number format roundtrip ─────────────────────────────── // ── parse_md: number format roundtrip ───────────────────────────────
@@ -336,7 +524,8 @@ fn full_roundtrip_preserves_all_features() {
m.active_view_mut().set_axis("Month", Axis::Page); m.active_view_mut().set_axis("Month", Axis::Page);
m.active_view_mut().set_page_selection("Month", "Jan"); m.active_view_mut().set_page_selection("Month", "Jan");
m.active_view_mut().hide_item("Type", "Gas"); m.active_view_mut().hide_item("Type", "Gas");
m.active_view_mut().toggle_group_collapse("Type", "G1"); m.active_view_mut()
.set_group_fold("Type", "G1", crate::view::GroupFold::Collapsed);
m.active_view_mut().number_format = ",.0".to_string(); m.active_view_mut().number_format = ",.0".to_string();
let text = format_md(&m); let text = format_md(&m);
@@ -361,7 +550,7 @@ fn full_roundtrip_preserves_all_features() {
assert_eq!(v.axis_of("Month"), Axis::Page); assert_eq!(v.axis_of("Month"), Axis::Page);
assert_eq!(v.page_selection("Month"), Some("Jan")); assert_eq!(v.page_selection("Month"), Some("Jan"));
assert!(v.is_hidden("Type", "Gas")); assert!(v.is_hidden("Type", "Gas"));
assert!(v.is_group_collapsed("Type", "G1")); assert!(v.group_fold("Type", "G1") == Some(crate::view::GroupFold::Collapsed));
assert_eq!(v.number_format, ",.0"); assert_eq!(v.number_format, ",.0");
} }
@@ -1237,3 +1426,28 @@ fn a_loaded_label_category_is_not_on_a_pivot_axis() {
); );
} }
} }
/// ChatGPT Codex on #317: a fold built through the public API must survive its
/// own save. `Expanded(0)` is now unconstructible; every depth that is survives.
#[test]
fn every_fold_the_api_can_build_reads_back_from_the_file_it_writes() {
use crate::view::GroupFold;
use std::num::NonZeroUsize;
let base = parse_md(&dates_with("")).unwrap();
for n in 1..=3 {
let depth = NonZeroUsize::new(n).unwrap();
for fold in [GroupFold::Collapsed, GroupFold::Expanded(depth)] {
let mut wb = base.clone();
wb.active_view_mut().set_group_fold("Date", "2024", fold);
wb.active_view_mut().set_expand_depth("Date", n);
let text = format_md(&wb);
let back = parse_md(&text).unwrap_or_else(|e| panic!("{e}\n{text}"));
assert_eq!(
back.active_view().group_fold("Date", "2024"),
Some(fold),
"{text}"
);
assert_eq!(back.active_view().expand_depth("Date"), n, "{text}");
}
}
}
+70 -17
View File
@@ -214,6 +214,9 @@ pub struct FrameGroup {
/// Which header level opened it — an index into [`AxisFrame::cats`]. Runs /// Which header level opened it — an index into [`AxisFrame::cats`]. Runs
/// nest, so membership is per level (improvise-e265). /// nest, so membership is per level (improvise-e265).
pub level: usize, pub level: usize,
/// The group's depth in its category's hierarchy, 0 for a root. Runs nest
/// by `(level, depth)`: `context/code/protocol.md` §3a (improvise-felo.6).
pub depth: usize,
pub name: String, pub name: String,
pub collapsed: bool, pub collapsed: bool,
} }
@@ -223,10 +226,11 @@ pub struct FrameGroup {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum FrameEntry { pub enum FrameEntry {
Group(FrameGroup), Group(FrameGroup),
/// The run at `level`, and every run inside it, ends here — `AxisEntry::GroupEnd` /// The run at `(level, depth)`, and every run inside it, ends here —
/// on the wire (improvise-gd83, e265). Nothing is drawn for it. /// `AxisEntry::GroupEnd` on the wire (improvise-gd83, e265). Nothing is drawn.
GroupEnd { GroupEnd {
level: usize, level: usize,
depth: usize,
}, },
/// One label per header level, outermost first. Data indices — the /// One label per header level, outermost first. Data indices — the
/// `row`/`col` of every other field on [`GridCache`] — count only these. /// `row`/`col` of every other field on [`GridCache`] — count only these.
@@ -279,7 +283,7 @@ impl TryFrom<UncheckedAxisFrame> for AxisFrame {
.iter() .iter()
.filter_map(|e| match e { .filter_map(|e| match e {
FrameEntry::Group(g) => Some(g.level), FrameEntry::Group(g) => Some(g.level),
FrameEntry::GroupEnd { level } => Some(*level), FrameEntry::GroupEnd { level, .. } => Some(*level),
FrameEntry::Item { .. } => None, FrameEntry::Item { .. } => None,
}) })
.find(|level| *level >= raw.cats.len()); .find(|level| *level >= raw.cats.len());
@@ -418,20 +422,22 @@ impl AxisFrame {
.collect() .collect()
} }
/// The innermost group each data item sits in, in order — one pass. A /// The innermost group each data item sits in, in order — one pass. A group
/// group at L closes deeper runs; so does a `GroupEnd` at L (improvise-e265). /// at `(level, depth)` closes deeper runs; so does a `GroupEnd` there (§3a).
pub fn groups(&self) -> Vec<Option<&FrameGroup>> { pub fn groups(&self) -> Vec<Option<&FrameGroup>> {
let mut open: Vec<Option<&FrameGroup>> = Vec::new(); let mut open: Vec<&FrameGroup> = Vec::new();
let close = |open: &mut Vec<&FrameGroup>, key: (usize, usize)| {
open.truncate(open.partition_point(|g| (g.level, g.depth) < key))
};
let mut out = Vec::new(); let mut out = Vec::new();
for entry in &self.entries { for entry in &self.entries {
match entry { match entry {
FrameEntry::Group(g) => { FrameEntry::Group(g) => {
open.truncate(g.level); close(&mut open, (g.level, g.depth));
open.resize(g.level, None); open.push(g);
open.push(Some(g));
} }
FrameEntry::GroupEnd { level } => open.truncate(*level), FrameEntry::GroupEnd { level, depth } => close(&mut open, (*level, *depth)),
FrameEntry::Item { .. } => out.push(open.iter().rev().find_map(|g| *g)), FrameEntry::Item { .. } => out.push(open.last().copied()),
} }
} }
out out
@@ -1349,6 +1355,7 @@ mod tests {
FrameEntry::item(vec!["A".into(), "y".into()]), FrameEntry::item(vec!["A".into(), "y".into()]),
FrameEntry::Group(FrameGroup { FrameEntry::Group(FrameGroup {
level: 0, level: 0,
depth: 0,
name: "Late".into(), name: "Late".into(),
collapsed: true, collapsed: true,
}), }),
@@ -1362,6 +1369,7 @@ mod tests {
fn repeated_group_frame() -> AxisFrame { fn repeated_group_frame() -> AxisFrame {
let q1 = || FrameGroup { let q1 = || FrameGroup {
level: 1, level: 1,
depth: 0,
name: "Q1".into(), name: "Q1".into(),
collapsed: false, collapsed: false,
}; };
@@ -1388,12 +1396,13 @@ mod tests {
entries: vec![ entries: vec![
FrameEntry::Group(FrameGroup { FrameEntry::Group(FrameGroup {
level: 0, level: 0,
depth: 0,
name: "Q1".into(), name: "Q1".into(),
collapsed: false, collapsed: false,
}), }),
FrameEntry::item(vec!["Jan".into()]), FrameEntry::item(vec!["Jan".into()]),
FrameEntry::item(vec!["Feb".into()]), FrameEntry::item(vec!["Feb".into()]),
FrameEntry::GroupEnd { level: 0 }, FrameEntry::GroupEnd { level: 0, depth: 0 },
FrameEntry::item(vec!["Sep".into()]), FrameEntry::item(vec!["Sep".into()]),
], ],
} }
@@ -1406,6 +1415,7 @@ mod tests {
let group = |level: usize, name: &str| { let group = |level: usize, name: &str| {
FrameEntry::Group(FrameGroup { FrameEntry::Group(FrameGroup {
level, level,
depth: 0,
name: name.into(), name: name.into(),
collapsed: true, collapsed: true,
}) })
@@ -1416,7 +1426,7 @@ mod tests {
entries: vec![ entries: vec![
group(0, "Upper"), group(0, "Upper"),
group(1, "Q1"), group(1, "Q1"),
FrameEntry::GroupEnd { level: 1 }, FrameEntry::GroupEnd { level: 1, depth: 0 },
], ],
}; };
let json = serde_json::to_string(&frame).unwrap(); let json = serde_json::to_string(&frame).unwrap();
@@ -1424,10 +1434,13 @@ mod tests {
assert_eq!(back, frame); assert_eq!(back, frame);
for (from, to) in [ for (from, to) in [
( (
r#""level":1,"name":"Q1""#, r#""level":1,"depth":0,"name":"Q1""#,
r#""level":4294967295,"name":"Q1""#, r#""level":4294967295,"depth":0,"name":"Q1""#,
),
(
r#"{"GroupEnd":{"level":1,"depth":0}}"#,
r#"{"GroupEnd":{"level":2,"depth":0}}"#,
), ),
(r#"{"GroupEnd":{"level":1}}"#, r#"{"GroupEnd":{"level":2}}"#),
] { ] {
let bad = json.replacen(from, to, 1); let bad = json.replacen(from, to, 1);
assert_ne!(bad, json, "the fault {to} was injected"); assert_ne!(bad, json, "the fault {to} was injected");
@@ -1440,6 +1453,45 @@ mod tests {
} }
} }
/// improvise-felo.6: runs nest by `(level, depth)` inside one category, so
/// the end of a quarter leaves its year open. See the evidence file.
#[test]
fn a_quarters_end_leaves_its_year_open_on_the_same_level() {
let group = |depth: usize, name: &str| {
FrameEntry::Group(FrameGroup {
level: 0,
depth,
name: name.into(),
collapsed: false,
})
};
let frame = AxisFrame {
cats: vec!["Date".into()],
first: 0,
entries: vec![
group(0, "2024"),
group(1, "Q1"),
FrameEntry::item(vec!["Jan".into()]),
FrameEntry::GroupEnd { level: 0, depth: 1 },
FrameEntry::item(vec!["Direct".into()]),
group(1, "Q2"),
FrameEntry::item(vec!["Apr".into()]),
FrameEntry::GroupEnd { level: 0, depth: 0 },
FrameEntry::item(vec!["Undated".into()]),
],
};
let names: Vec<Option<&str>> = frame
.groups()
.into_iter()
.map(|g| g.map(|g| g.name.as_str()))
.collect();
assert_eq!(
names,
vec![Some("Q1"), Some("2024"), Some("Q2"), None],
"Direct sits in 2024 after Q1 ends; Undated in nothing"
);
}
/// improvise-gd83's property, asserted in the crate that owns the type. /// improvise-gd83's property, asserted in the crate that owns the type.
/// See `context/test-evidence/protocol.md`. /// See `context/test-evidence/protocol.md`.
#[test] #[test]
@@ -1467,10 +1519,11 @@ mod tests {
entries: vec![ entries: vec![
FrameEntry::Group(FrameGroup { FrameEntry::Group(FrameGroup {
level: 0, level: 0,
depth: 0,
name: "Q1".into(), name: "Q1".into(),
collapsed: true, collapsed: true,
}), }),
FrameEntry::GroupEnd { level: 0 }, FrameEntry::GroupEnd { level: 0, depth: 0 },
FrameEntry::item(vec!["Sep".into()]), FrameEntry::item(vec!["Sep".into()]),
], ],
}; };
@@ -591,6 +591,7 @@ mod tests {
fn group(name: &str, collapsed: bool) -> FrameEntry { fn group(name: &str, collapsed: bool) -> FrameEntry {
FrameEntry::Group(improvise_protocol::FrameGroup { FrameEntry::Group(improvise_protocol::FrameGroup {
level: 0, level: 0,
depth: 0,
name: name.into(), name: name.into(),
collapsed, collapsed,
}) })
@@ -189,6 +189,7 @@ fn a_grouped_row_axis_does_not_shift_the_write() {
// two rows — a leading header is dropped by `AxisFrame::body_from`. // two rows — a leading header is dropped by `AxisFrame::body_from`.
wb.add_item("Region", "West").unwrap(); wb.add_item("Region", "West").unwrap();
wb.add_item_in_group("Region", "East", "Coastal").unwrap(); wb.add_item_in_group("Region", "East", "Coastal").unwrap();
wb.active_view_mut().set_expand_depth("Region", 1);
wb.add_item("_Measure", "Revenue").unwrap(); wb.add_item("_Measure", "Revenue").unwrap();
wb.active_view_mut().set_axis("Region", Axis::Row); wb.active_view_mut().set_axis("Region", Axis::Row);
wb.active_view_mut().set_axis("_Measure", Axis::Column); wb.active_view_mut().set_axis("_Measure", Axis::Column);
@@ -2258,6 +2258,7 @@ fn fold_host() -> App {
for (month, quarter) in [("Jan", "Q1"), ("Feb", "Q1"), ("Apr", "Q2"), ("May", "Q2")] { for (month, quarter) in [("Jan", "Q1"), ("Feb", "Q1"), ("Apr", "Q2"), ("May", "Q2")] {
wb.add_item_in_group("Month", month, quarter).unwrap(); wb.add_item_in_group("Month", month, quarter).unwrap();
} }
wb.active_view_mut().set_expand_depth("Month", 1);
wb.add_item("_Measure", "Revenue").unwrap(); wb.add_item("_Measure", "Revenue").unwrap();
wb.add_item("_Measure", "Cost").unwrap(); wb.add_item("_Measure", "Cost").unwrap();
for month in ["Jan", "Feb", "Apr", "May"] { for month in ["Jan", "Feb", "Apr", "May"] {
@@ -2272,7 +2273,7 @@ fn fold_host() -> App {
} }
wb.active_view_mut().set_axis("Month", Axis::Row); wb.active_view_mut().set_axis("Month", Axis::Row);
wb.active_view_mut().set_axis("_Measure", Axis::Column); wb.active_view_mut().set_axis("_Measure", Axis::Column);
wb.active_view_mut().toggle_group_collapse("Month", "Q2"); wb.active_view_mut().toggle_group_collapse("Month", &["Q2"]);
let mut app = App::new(wb, None); let mut app = App::new(wb, None);
app.rebuild_layout(); app.rebuild_layout();
assert_eq!(app.layout.row_label(2), "Q2", "setup: the fold is row 2"); assert_eq!(app.layout.row_label(2), "Q2", "setup: the fold is row 2");
@@ -2469,7 +2470,7 @@ fn advancing_the_editor_past_the_window_settles_where_the_tui_does() {
let expanded = || { let expanded = || {
let mut app = fold_host(); let mut app = fold_host();
let view = app.model_state.workbook.active_view_mut(); let view = app.model_state.workbook.active_view_mut();
view.toggle_group_collapse("Month", "Q2"); view.toggle_group_collapse("Month", &["Q2"]);
app.rebuild_layout(); app.rebuild_layout();
app app
}; };
@@ -3543,20 +3544,22 @@ fn nested_groups_host(coast_collapsed: bool, kit_collapsed: bool) -> App {
for item in ["East", "West"] { for item in ["East", "West"] {
wb.add_item_in_group("Region", item, "Coast").unwrap(); wb.add_item_in_group("Region", item, "Coast").unwrap();
} }
wb.active_view_mut().set_expand_depth("Region", 1);
wb.add_item("Product", "Widget").unwrap(); wb.add_item("Product", "Widget").unwrap();
for item in ["Gadget", "Gizmo"] { for item in ["Gadget", "Gizmo"] {
wb.add_item_in_group("Product", item, "Kit").unwrap(); wb.add_item_in_group("Product", item, "Kit").unwrap();
} }
wb.active_view_mut().set_expand_depth("Product", 1);
wb.add_item("_Measure", "Revenue").unwrap(); wb.add_item("_Measure", "Revenue").unwrap();
let view = wb.active_view_mut(); let view = wb.active_view_mut();
view.set_axis("Region", Axis::Row); view.set_axis("Region", Axis::Row);
view.set_axis("Product", Axis::Row); view.set_axis("Product", Axis::Row);
view.set_axis("_Measure", Axis::Column); view.set_axis("_Measure", Axis::Column);
if coast_collapsed { if coast_collapsed {
view.toggle_group_collapse("Region", "Coast"); view.toggle_group_collapse("Region", &["Coast"]);
} }
if kit_collapsed { if kit_collapsed {
view.toggle_group_collapse("Product", "Kit"); view.toggle_group_collapse("Product", &["Kit"]);
} }
let mut app = App::new(wb, None); let mut app = App::new(wb, None);
app.term_height = 12 + 8; app.term_height = 12 + 8;
@@ -3614,11 +3617,12 @@ fn folded_host() -> App {
for item in ["East", "West"] { for item in ["East", "West"] {
wb.add_item_in_group("Region", item, "Coast").unwrap(); wb.add_item_in_group("Region", item, "Coast").unwrap();
} }
wb.active_view_mut().set_expand_depth("Region", 1);
wb.add_item("_Measure", "Revenue").unwrap(); wb.add_item("_Measure", "Revenue").unwrap();
wb.active_view_mut().set_axis("Region", Axis::Row); wb.active_view_mut().set_axis("Region", Axis::Row);
wb.active_view_mut().set_axis("_Measure", Axis::Column); wb.active_view_mut().set_axis("_Measure", Axis::Column);
wb.active_view_mut() wb.active_view_mut()
.toggle_group_collapse("Region", "Coast"); .toggle_group_collapse("Region", &["Coast"]);
let mut app = App::new(wb, None); let mut app = App::new(wb, None);
app.term_height = 4 + 8; app.term_height = 4 + 8;
app.rebuild_layout(); app.rebuild_layout();
+2
View File
@@ -11,6 +11,8 @@ _Measure: page, Revenue
_Index: none _Index: none
_Dim: none _Dim: none
collapsed: Region/Upland collapsed: Region/Upland
expanded(1): Month
expanded(1): Region
format: ,.0 format: ,.0
## Category: Region ## Category: Region
+2
View File
@@ -8,6 +8,7 @@ _Dim: none
_Measure: page _Measure: page
Region: row Region: row
format: ,.0 format: ,.0
expanded(1): Region
## View: Tall ## View: Tall
_Index: none _Index: none
@@ -15,6 +16,7 @@ _Dim: none
_Measure: column _Measure: column
Region: row Region: row
format: ,.0 format: ,.0
expanded(1): Region
## Category: _Measure ## Category: _Measure
- Revenue, Cost - Revenue, Cost
+2
View File
@@ -860,6 +860,8 @@ mod tests {
m.add_item_in_group("Region", "West", "Inland").unwrap(); m.add_item_in_group("Region", "West", "Inland").unwrap();
m.add_item_in_group("Month", "Jan", "Q1").unwrap(); m.add_item_in_group("Month", "Jan", "Q1").unwrap();
m.add_item_in_group("Month", "Apr", "Q2").unwrap(); m.add_item_in_group("Month", "Apr", "Q2").unwrap();
m.active_view_mut().set_expand_depth("Region", 1);
m.active_view_mut().set_expand_depth("Month", 1);
m.add_item("Payer", "Alice").unwrap(); m.add_item("Payer", "Alice").unwrap();
// Pinned so the byte-exact frames below characterize the LAYOUT and not // Pinned so the byte-exact frames below characterize the LAYOUT and not
// the view default, which grew two decimals with improvise-e0ex. // the view default, which grew two decimals with improvise-e0ex.
+4 -2
View File
@@ -12,6 +12,7 @@ fn grouped_item_order_survives_save() {
let mut w = Workbook::new("test"); let mut w = Workbook::new("test");
w.add_category("C").unwrap(); w.add_category("C").unwrap();
w.add_item_in_group("C", "first", "G").unwrap(); w.add_item_in_group("C", "first", "G").unwrap();
w.active_view_mut().set_expand_depth("C", 1);
w.add_item("C", "second").unwrap(); w.add_item("C", "second").unwrap();
let r = parse_md(&format_md(&w)).unwrap(); let r = parse_md(&format_md(&w)).unwrap();
assert_eq!( assert_eq!(
@@ -429,8 +430,9 @@ fn slash_in_hidden_name_survives_roundtrip() {
w.add_category("C").unwrap(); w.add_category("C").unwrap();
w.add_item("C", "A/B").unwrap(); w.add_item("C", "A/B").unwrap();
w.add_item_in_group("C", "x", "G/H").unwrap(); w.add_item_in_group("C", "x", "G/H").unwrap();
w.active_view_mut().set_expand_depth("C", 1);
w.active_view_mut().hide_item("C", "A/B"); w.active_view_mut().hide_item("C", "A/B");
w.active_view_mut().toggle_group_collapse("C", "G/H"); w.active_view_mut().toggle_group_collapse("C", &["G/H"]);
let text = format_md(&w); let text = format_md(&w);
let r = parse_md(&text).unwrap_or_else(|e| panic!("unparseable: {e}\n{text}")); let r = parse_md(&text).unwrap_or_else(|e| panic!("unparseable: {e}\n{text}"));
assert!( assert!(
@@ -438,7 +440,7 @@ fn slash_in_hidden_name_survives_roundtrip() {
"hidden lost:\n{text}" "hidden lost:\n{text}"
); );
assert!( assert!(
r.active_view().is_group_collapsed("C", "G/H"), r.active_view().is_group_collapsed("C", &["G/H"]),
"collapsed lost:\n{text}" "collapsed lost:\n{text}"
); );
} }
+110 -6
View File
@@ -4,7 +4,7 @@
use improvise::model::cell::{CellKey, CellValue}; use improvise::model::cell::{CellKey, CellValue};
use improvise::ui::app::{App, AppMode, ViewCursor}; use improvise::ui::app::{App, AppMode, ViewCursor};
use improvise::ui::grid::GridWidget; use improvise::ui::grid::GridWidget;
use improvise::view::{Axis, GridLayout}; use improvise::view::{Axis, GridLayout, GroupFold};
use improvise::workbook::Workbook; use improvise::workbook::Workbook;
use improvise_command::ui::project; use improvise_command::ui::project;
use improvise_protocol::{FrameEntry, GridCache, Snapshot, ViewState}; use improvise_protocol::{FrameEntry, GridCache, Snapshot, ViewState};
@@ -31,10 +31,12 @@ fn two_level_rows() -> Workbook {
m.add_item("Type", "Clothing").unwrap(); m.add_item("Type", "Clothing").unwrap();
m.add_item_in_group("Month", "Jan", "Q1").unwrap(); m.add_item_in_group("Month", "Jan", "Q1").unwrap();
m.add_item_in_group("Month", "Feb", "Q1").unwrap(); m.add_item_in_group("Month", "Feb", "Q1").unwrap();
m.active_view_mut().set_expand_depth("Month", 1);
m.add_item("Month", "Sep").unwrap(); m.add_item("Month", "Sep").unwrap();
m.add_item("Recipient", "Carol").unwrap(); m.add_item("Recipient", "Carol").unwrap();
m.add_item_in_group("Recipient", "Alice", "Family").unwrap(); m.add_item_in_group("Recipient", "Alice", "Family").unwrap();
m.add_item_in_group("Recipient", "Bob", "Family").unwrap(); m.add_item_in_group("Recipient", "Bob", "Family").unwrap();
m.active_view_mut().set_expand_depth("Recipient", 1);
m.active_view_mut().set_axis("Recipient", Axis::Row); m.active_view_mut().set_axis("Recipient", Axis::Row);
m.add_item("_Measure", "Revenue").unwrap(); m.add_item("_Measure", "Revenue").unwrap();
m.add_item("_Measure", "Cost").unwrap(); m.add_item("_Measure", "Cost").unwrap();
@@ -188,10 +190,111 @@ fn browser_model(cache: &GridCache) -> RenderModel {
#[test] #[test]
fn the_two_front_ends_draw_the_same_row_header() { fn the_two_front_ends_draw_the_same_row_header() {
let mut wb = two_level_rows(); assert_row_headers_agree(&mut two_level_rows());
let (_app, cache) = whole_grid(&mut wb); }
let tui = tui_row_headers(&wb, &cache); /// improvise-felo.6: Year > Quarter > Month in ONE category under a grouped one;
/// Y24 open two levels, Q2 and Y25 closed. Short names fit a TUI band cell.
fn a_date_hierarchy() -> Workbook {
let mut m = Workbook::new("Test");
m.add_category("Recipient").unwrap();
m.add_category("Date").unwrap();
m.add_item_in_group("Recipient", "Alice", "Fam").unwrap();
m.add_item("Recipient", "Carol").unwrap();
for (month, quarter) in [("Jan", "Q1"), ("Feb", "Q1"), ("Apr", "Q2"), ("Jan25", "Q9")] {
m.add_item_in_group("Date", month, quarter).unwrap();
}
m.add_item("Date", "Undated").unwrap();
let date = m.model.category_mut("Date").unwrap();
for (quarter, year) in [("Q1", "Y24"), ("Q2", "Y24"), ("Q9", "Y25")] {
date.add_group(improvise::model::category::Group::new(quarter).with_parent(year));
}
m.add_item("_Measure", "Revenue").unwrap();
let view = m.active_view_mut();
view.set_axis("Recipient", Axis::Row);
view.set_axis("Date", Axis::Row);
view.set_axis("_Measure", Axis::Column);
view.set_expand_depth("Recipient", 1);
view.set_group_fold(
"Date",
"Y24",
GroupFold::Expanded(std::num::NonZeroUsize::new(2).unwrap()),
);
view.set_group_fold("Date", "Q2", GroupFold::Collapsed);
for r in ["Alice", "Carol"] {
for d in ["Jan", "Feb", "Apr", "Jan25", "Undated"] {
m.model.set_cell(
coord(&[("Recipient", r), ("Date", d), ("_Measure", "Revenue")]),
CellValue::Number(1.0),
);
}
}
m
}
/// See `context/test-evidence/tui.md`.
#[test]
fn the_two_front_ends_draw_the_same_nested_hierarchy_on_rows() {
let mut wb = a_date_hierarchy();
let (_app, cache) = whole_grid(&mut wb);
let groups: Vec<String> = browser_rows(&cache)
.into_iter()
.filter_map(|r| match r {
RenderRow::Group { label, .. } => Some(label),
RenderRow::Data { .. } => None,
})
.collect();
assert_eq!(
groups,
["Fam", "Y24", "Q1", "Q2", "Y25", "Y24", "Q1", "Q2", "Y25"],
"precondition: a year AND its quarters head their runs, under each recipient"
);
assert_row_headers_agree(&mut wb);
}
/// The column side of the same hierarchy: the band paints each column's
/// innermost group, coalesced by header, at both ends.
#[test]
fn the_two_front_ends_draw_the_same_nested_hierarchy_on_columns() {
let mut wb = a_date_hierarchy();
wb.active_view_mut().transpose_axes();
let (_app, cache) = whole_grid(&mut wb);
let model = browser_model(&cache);
let lines = tui_lines(&wb, &cache);
let rule = header_rule(&lines);
let levels = model.col_levels.len();
let named: Vec<(Option<&str>, usize)> = model
.col_groups
.iter()
.map(|g| (g.label.as_deref(), g.span))
.collect();
assert!(
named.iter().any(|(l, _)| *l == Some("Q1")) && named.iter().any(|(l, _)| *l == Some("Y25")),
"precondition: a quarter run and a folded year run: {named:?}"
);
let expected: Vec<String> = model
.col_groups
.iter()
.flat_map(|g| {
let head = match &g.label {
Some(name) => format!("{} {name}", indicator(g.collapsed)),
None => String::new(),
};
std::iter::once(head).chain(std::iter::repeat_n(String::new(), g.span - 1))
})
.collect();
assert_eq!(
&tui_header_cells(&lines[rule - levels - 1], &cache),
&expected,
"the TUI's group line and the browser's runs disagree"
);
}
/// The row-header comparison, row by row: both ends, one cache.
fn assert_row_headers_agree(wb: &mut Workbook) {
let (_app, cache) = whole_grid(wb);
let tui = tui_row_headers(wb, &cache);
let browser = browser_rows(&cache); let browser = browser_rows(&cache);
assert_eq!( assert_eq!(
@@ -376,6 +479,7 @@ fn leading_group_rows() -> Workbook {
m.add_category("Recipient").unwrap(); m.add_category("Recipient").unwrap();
m.add_item_in_group("Recipient", "Alice", "Fam").unwrap(); m.add_item_in_group("Recipient", "Alice", "Fam").unwrap();
m.add_item_in_group("Recipient", "Bob", "Fam").unwrap(); m.add_item_in_group("Recipient", "Bob", "Fam").unwrap();
m.active_view_mut().set_expand_depth("Recipient", 1);
m.add_item("Recipient", "Carol").unwrap(); m.add_item("Recipient", "Carol").unwrap();
m.add_item("_Measure", "Revenue").unwrap(); m.add_item("_Measure", "Revenue").unwrap();
m.active_view_mut().set_axis("Recipient", Axis::Row); m.active_view_mut().set_axis("Recipient", Axis::Row);
@@ -397,7 +501,7 @@ fn a_group_header_first_on_its_axis_reaches_both_front_ends() {
let mut wb = leading_group_rows(); let mut wb = leading_group_rows();
if collapse { if collapse {
wb.active_view_mut() wb.active_view_mut()
.toggle_group_collapse("Recipient", "Fam"); .toggle_group_collapse("Recipient", &["Fam"]);
} }
let (_app, cache) = whole_grid(&mut wb); let (_app, cache) = whole_grid(&mut wb);
@@ -455,7 +559,7 @@ fn the_collapse_state_crosses_as_a_fact_not_a_glyph() {
let mut wb = two_level_rows(); let mut wb = two_level_rows();
if collapse { if collapse {
wb.active_view_mut() wb.active_view_mut()
.toggle_group_collapse("Recipient", "Family"); .toggle_group_collapse("Recipient", &["Family"]);
} }
let (_app, cache) = whole_grid(&mut wb); let (_app, cache) = whole_grid(&mut wb);