Merge commit '62066e1e1c972218b099a36f3f10440eb93d430e' into adr007/plan

# Conflicts:
#	context/code/core.md
#	context/test-evidence/core.md
This commit is contained in:
Claude (agent-adr007)
2026-09-25 10:05:02 -07:00
committed by Edward Langley
62 changed files with 4515 additions and 811 deletions
+2 -1
View File
@@ -59,7 +59,8 @@ they mean, rather than testing for the variant
**Layering.** `forbidden/2` in `kb.pl` is the authority:
`improvise-core` no UI/IO, `improvise-io` no UI/commands, `improvise-command`
no ratatui/crossterm. A ratatui type in core is the usual violation — the fix
is a semantic enum in core that the render layer maps (see `AxisColor`).
is a semantic value the render layer maps; for appearance that is a `Face` in
`improvise-protocol`, resolved by `draw.rs::face_style` (ADR 004).
**`Option`/`Result`/`Box` at the boundaries.** Wrap at parse/lookup/IO, unwrap
early, keep core logic on concrete values. Heap-container plumbing should be
+378 -247
View File
File diff suppressed because one or more lines are too long
+27 -1
View File
@@ -146,13 +146,39 @@ jobs:
# dependency crate each run. The NixOS runners substitute from titan's
# harmonia first, and titan builds gh/main's `#improvise` hourly, so a PR that
# leaves Cargo.lock alone rebuilds only the workspace crates it changed.
#
# The repo-stats pipeline runs here because it needs this job's binary:
# `.#repo-stats-tools` is that build plus code-maat and its JRE, which stay
# out of the ci shell every other job enters.
# Its suite gates; the report on a push to main never fails the build, and
# its step fails only when improvise refuses the pipeline's input
# (context/code/tooling.md section 7). Full history is for the report.
nix-build:
name: Nix build (crate2nix)
name: Nix build (crate2nix) and the repo-stats pipeline
runs-on: [self-hosted, nixos, microvm]
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- name: nix build .#improvise
run: nix build --print-build-logs .#improvise
- name: The repo-stats pipeline computes what it claims to
run: nix shell .#repo-stats-tools --command ./scripts/checkers.sh --ci nix-build
- name: Churn, coupling and doc-growth report (report only)
if: github.event_name == 'push'
continue-on-error: true
run: |
rc=0
nix shell .#repo-stats-tools --command ./scripts/repo-stats --out repo-stats > repo-stats.txt || rc=$?
{ echo '```'; cat repo-stats.txt; echo '```'; } >> "$GITHUB_STEP_SUMMARY"
exit "$rc"
- name: Upload the repo-stats model and its CSVs
if: github.event_name == 'push'
uses: actions/upload-artifact@v7
with:
name: repo-stats
path: repo-stats
retention-days: 30
wasm:
name: wasm32 check (browser bundle crates)
Generated
+30 -29
View File
@@ -854,7 +854,7 @@ dependencies = [
"serde",
"serde_json",
"tempfile",
"thiserror 2.0.20",
"thiserror 2.0.21",
"unicode-width",
]
@@ -1045,9 +1045,9 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "js-sys"
version = "0.3.105"
version = "0.3.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e"
checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5"
dependencies = [
"cfg-if",
"futures-util",
@@ -1062,7 +1062,7 @@ checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899"
dependencies = [
"hashbrown 0.16.1",
"portable-atomic",
"thiserror 2.0.20",
"thiserror 2.0.21",
]
[[package]]
@@ -1606,7 +1606,7 @@ dependencies = [
"palette",
"serde",
"strum",
"thiserror 2.0.20",
"thiserror 2.0.21",
"unicode-segmentation",
"unicode-truncate",
"unicode-width",
@@ -1893,9 +1893,9 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]]
name = "smallvec"
version = "1.16.1"
version = "1.16.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891"
checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e"
[[package]]
name = "socket2"
@@ -2073,11 +2073,11 @@ dependencies = [
[[package]]
name = "thiserror"
version = "2.0.20"
version = "2.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"
checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"
dependencies = [
"thiserror-impl 2.0.20",
"thiserror-impl 2.0.21",
]
[[package]]
@@ -2093,9 +2093,9 @@ dependencies = [
[[package]]
name = "thiserror-impl"
version = "2.0.20"
version = "2.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"
dependencies = [
"proc-macro2",
"quote",
@@ -2174,7 +2174,7 @@ dependencies = [
"log",
"rand 0.10.3",
"sha1",
"thiserror 2.0.20",
"thiserror 2.0.21",
]
[[package]]
@@ -2289,9 +2289,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen"
version = "0.2.128"
version = "0.2.129"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf"
checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409"
dependencies = [
"cfg-if",
"once_cell",
@@ -2302,19 +2302,20 @@ dependencies = [
[[package]]
name = "wasm-bindgen-futures"
version = "0.4.78"
version = "0.4.79"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ef4c5d3d2cdf5c54f4231181768f5510842e350db025faf1f7163b1030ed928"
checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e"
dependencies = [
"js-sys",
"tokio",
"wasm-bindgen",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.128"
version = "0.2.129"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed"
checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
@@ -2322,9 +2323,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.128"
version = "0.2.129"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a"
checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7"
dependencies = [
"bumpalo",
"proc-macro2",
@@ -2335,18 +2336,18 @@ dependencies = [
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.128"
version = "0.2.129"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e"
checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6"
dependencies = [
"unicode-ident",
]
[[package]]
name = "web-sys"
version = "0.3.105"
version = "0.3.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9fbddc4a036f00ec4f18c83445bd3115cb306a91da554919a099d9222fe4a7f8"
checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4"
dependencies = [
"js-sys",
"wasm-bindgen",
@@ -2532,18 +2533,18 @@ checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
[[package]]
name = "zerocopy"
version = "0.8.57"
version = "0.8.58"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d35102a9f36d089ccae9e4c6802bc118be4487b80aaffc0ab4e0cf5ce92d2873"
checksum = "c17e8fafad82b542ff3717217ecdc736231b59e387768c9630123b4ce4d2db44"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.57"
version = "0.8.58"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "146c01f5ab44258da43cf276c74a2763db2ff3969c9c652c3f2de07041d0b2bc"
checksum = "595f56e044df4f46a0c9a626f65c3d99eb8488f7e8a8baa12dd76326d9710bf2"
dependencies = [
"proc-macro2",
"quote",
+38 -37
View File
@@ -2657,7 +2657,7 @@ rec {
}
{
name = "thiserror";
packageId = "thiserror 2.0.20";
packageId = "thiserror 2.0.21";
}
{
name = "unicode-width";
@@ -3310,9 +3310,9 @@ rec {
};
"js-sys" = rec {
crateName = "js-sys";
version = "0.3.105";
version = "0.3.106";
edition = "2021";
sha256 = "17pr58p55pxbflkjl2wz6pyz854j80nlgavjq4mcwr583q6x4myf";
sha256 = "1icwmpjw54lb7vg5926k5y4y5jbiih0zxhwgjwnzn475v90xk0vq";
libName = "js_sys";
authors = [
"The wasm-bindgen Developers"
@@ -3364,7 +3364,7 @@ rec {
}
{
name = "thiserror";
packageId = "thiserror 2.0.20";
packageId = "thiserror 2.0.21";
usesDefaultFeatures = false;
}
];
@@ -4913,7 +4913,7 @@ rec {
}
{
name = "thiserror";
packageId = "thiserror 2.0.20";
packageId = "thiserror 2.0.21";
usesDefaultFeatures = false;
}
{
@@ -5894,9 +5894,9 @@ rec {
};
"smallvec" = rec {
crateName = "smallvec";
version = "1.16.1";
version = "1.16.2";
edition = "2018";
sha256 = "14gqvsqdli51r1bii3hfqv5vx1b9r0gic4br0x9fsixmy5b70ims";
sha256 = "13iai5hhwyp8z0pbn8r11q4j5956jaxhcbvvf2drm17f1q7myfgr";
authors = [
"The Servo Project Developers"
];
@@ -6430,18 +6430,18 @@ rec {
];
};
"thiserror 2.0.20" = rec {
"thiserror 2.0.21" = rec {
crateName = "thiserror";
version = "2.0.20";
version = "2.0.21";
edition = "2021";
sha256 = "0kxs6p295jffxhzaxpxv1dwaaf5iqlm6sx8h0djp6ancbxgj71pc";
sha256 = "17hq1lh5dyr3bkc7zzjrbrp4qgkvhc48kgq1n5fdxkindaw2rr89";
authors = [
"David Tolnay <dtolnay@gmail.com>"
];
dependencies = [
{
name = "thiserror-impl";
packageId = "thiserror-impl 2.0.20";
packageId = "thiserror-impl 2.0.21";
}
];
features = {
@@ -6475,11 +6475,11 @@ rec {
];
};
"thiserror-impl 2.0.20" = rec {
"thiserror-impl 2.0.21" = rec {
crateName = "thiserror-impl";
version = "2.0.20";
version = "2.0.21";
edition = "2021";
sha256 = "1bwjc94gi0xn5jz26h1a8bjj1wdkvvr6jifamyc4mp9n28zcs15w";
sha256 = "0945n8agp7kg6n6b35yyjb4g5xv2q22vrw15h6jj1nw76a99flgy";
procMacro = true;
libName = "thiserror_impl";
authors = [
@@ -6829,7 +6829,7 @@ rec {
}
{
name = "thiserror";
packageId = "thiserror 2.0.20";
packageId = "thiserror 2.0.21";
}
];
devDependencies = [
@@ -7142,9 +7142,9 @@ rec {
};
"wasm-bindgen" = rec {
crateName = "wasm-bindgen";
version = "0.2.128";
version = "0.2.129";
edition = "2021";
sha256 = "1gsi4ggm03dqr3j9bcc1sbxzmkw6ykm3cqx4g8xmw31v7niqgjxf";
sha256 = "02flhqld01jqb6vbfyx1gb8s78g1pnq2164daxad93y6mhrlzdcv";
libName = "wasm_bindgen";
authors = [
"The wasm-bindgen Developers"
@@ -7175,12 +7175,6 @@ rec {
rename = "rustversion-compat";
}
];
devDependencies = [
{
name = "once_cell";
packageId = "once_cell";
}
];
features = {
"default" = [ "std" ];
"enable-interning" = [ "std" ];
@@ -7193,9 +7187,9 @@ rec {
};
"wasm-bindgen-futures" = rec {
crateName = "wasm-bindgen-futures";
version = "0.4.78";
version = "0.4.79";
edition = "2021";
sha256 = "0a6r1q1v2qvi3ypmy0nva3il422iyml1f61i897wbxfdsb9wbx3f";
sha256 = "03k1wcg5j3wqk6vgy6gzcq868vsa9k81dlhqissfk0nrw96v7fiw";
libName = "wasm_bindgen_futures";
authors = [
"The wasm-bindgen Developers"
@@ -7206,6 +7200,13 @@ rec {
packageId = "js-sys";
usesDefaultFeatures = false;
}
{
name = "tokio";
packageId = "tokio";
usesDefaultFeatures = false;
target = { target, features }: (("emscripten" == target."os" or null) && (target."wasm_bindgen_unstable_tokio" or false));
features = [ "rt" "time" ];
}
{
name = "wasm-bindgen";
packageId = "wasm-bindgen";
@@ -7221,9 +7222,9 @@ rec {
};
"wasm-bindgen-macro" = rec {
crateName = "wasm-bindgen-macro";
version = "0.2.128";
version = "0.2.129";
edition = "2021";
sha256 = "1vdrjrb7yqh8p0r0yr93rlaq9iq05hy1wl9kbsjv7a61wc8xb456";
sha256 = "0dc5xq09sy1v9ns1fhb0cnyqz5p1wcjjvkdmxskj9qhnbg1x0a9f";
procMacro = true;
libName = "wasm_bindgen_macro";
authors = [
@@ -7245,9 +7246,9 @@ rec {
};
"wasm-bindgen-macro-support" = rec {
crateName = "wasm-bindgen-macro-support";
version = "0.2.128";
version = "0.2.129";
edition = "2021";
sha256 = "12p4jm9dr88h1j5sglycizvvxvr05pgmz7aa2v9g47h7y23lh7j1";
sha256 = "1dx6w90f14avmhri7ss9lyz03060g6475r4axy3bm7biqf5ill3g";
libName = "wasm_bindgen_macro_support";
authors = [
"The wasm-bindgen Developers"
@@ -7281,10 +7282,10 @@ rec {
};
"wasm-bindgen-shared" = rec {
crateName = "wasm-bindgen-shared";
version = "0.2.128";
version = "0.2.129";
edition = "2021";
links = "wasm_bindgen";
sha256 = "0ghh8hqx038h9c9zj1rlmzlv2k2shh917q756dn054hcipbir541";
sha256 = "1ilmp5d3sl8lrq9djhcs90gk66yvk8mgwk4sfrvpvkd75b2wkw13";
libName = "wasm_bindgen_shared";
authors = [
"The wasm-bindgen Developers"
@@ -7299,9 +7300,9 @@ rec {
};
"web-sys" = rec {
crateName = "web-sys";
version = "0.3.105";
version = "0.3.106";
edition = "2021";
sha256 = "1y57whpj5ncrl0cljmfsj5m31jqm66ylad68317yq03g0d5drgcz";
sha256 = "195bd1m14w3hsw5fg3sr938mh7y4cb260d0sq6a6brffxjfin9l8";
libName = "web_sys";
authors = [
"The wasm-bindgen Developers"
@@ -8468,9 +8469,9 @@ rec {
};
"zerocopy" = rec {
crateName = "zerocopy";
version = "0.8.57";
version = "0.8.58";
edition = "2021";
sha256 = "0wr85plmrkz0nh5grbqap23l9ghqq4mq1ip4x759q23dyflh4lfk";
sha256 = "0i6vsbj4qfqj62b8qxl7wdcin8rnqz6pw88p6zzl5dc2mnpqyzn1";
dependencies = [
{
name = "zerocopy-derive";
@@ -8500,9 +8501,9 @@ rec {
};
"zerocopy-derive" = rec {
crateName = "zerocopy-derive";
version = "0.8.57";
version = "0.8.58";
edition = "2021";
sha256 = "1g5js10p1q1d7wn6b74wjvrjznv34x5cfxpj7jj8s9a4mgsh2v0l";
sha256 = "1whbf7cjcqyp5nhvma78yy489swr7mfgc9m6r6h4ckyz8kh5cpsr";
procMacro = true;
libName = "zerocopy_derive";
dependencies = [
-6
View File
@@ -84,9 +84,6 @@ fn chain_app(regions: usize, products: usize) -> App {
v.set_axis("Region", Axis::Row);
v.set_axis("Product", Axis::Row);
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;
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("Product", Axis::Row);
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;
for d in 0..days {
+7 -7
View File
@@ -1652,7 +1652,7 @@ through the server's own types; with one definition there is nothing left for
them to compare, which is what improvise-xks6 was for.
**`rejection_message` is spelled here rather than on `Rejection`** for the
reason `AxisColor` is not a ratatui colour: the protocol carries the *fact*, and
reason a `Face` is not a ratatui colour: the protocol carries the *fact*, and
each front end words it. The wording tracks ADR 001's force table, because "try
again" and "this cannot be forced" are the two different things a user must be
able to tell apart — a `RecordGone` has nothing to write to, and offering force
@@ -1877,8 +1877,8 @@ Four smaller decisions that would be re-derived wrongly:
`improvise_protocol::read_buffer` so the `"search"` special case keeps its one
home (gotcha 11); `None` is "no text-entry mode is open", so the page asks
nothing about the mode. The colour stays semantic — `MinibufColor` crosses and
the CSS class is the page's last translation, exactly as `draw.rs::minibuf_fg`
is the TUI's (gotcha 26). It is drawn as a surface of its own rather than *in
the CSS class is the page's last translation. (The TUI no longer reads it: it
draws a face per text-entry mode, and improvise-nn4s.4 moves the page there.) It is drawn as a surface of its own rather than *in
place of* `#status` as the TUI's bottom bar does, because `#status` also
carries the transport's failures and a repaint must not erase one (gotcha 48).
Since improvise-n6t8 it shares `#bottom-dock` with the toast, which stacks
@@ -1920,7 +1920,7 @@ home for it.
The rows themselves are `improvise_protocol::help::pages()`, the same ones
`src/ui/help.rs` draws. `RenderHelpRow` carries a `kind` string
(`heading`, `keys`, `accent`, …) which is a *semantic* name the page maps to a
CSS class — the `AxisColor → axis_fg` shape again, and the reason the page can
CSS class — the `Face → draw.rs::face_style` shape again, and the reason the page can
style a heading without knowing what makes a line a heading. Where the content
comes from and what is derived from the keymap is `context/code/protocol.md` §8.
@@ -2353,7 +2353,7 @@ truncation. A browser has no column to fit, so the abbreviation buys nothing and
reads to a user as a rendering defect — it was spotted in PR #120's screenshots
as "Quarter Pag". `render.rs` now asks `plain_label`, which is the other
vocabulary already on `Axis`: the decision stays in the shared layer and only
the *choice* of vocabulary is local, exactly the `AxisColor → ratatui::Color`
the *choice* of vocabulary is local, exactly the `Face → ratatui::Style`
shape §5 blesses. `short_label` is untouched, because the TUI's width is
load-bearing.
@@ -2439,8 +2439,8 @@ So it is now a raised bar: fixed, centred over the grid, larger, bordered in the
mode's own colour and shadowed. **Every part of that is paint.** The content is
`RenderMinibuffer`, built in `render.rs` from `state.view.mode.minibuffer()` —
the same semantic value the TUI reads — and the colour is `MinibufColor`
resolved to a concrete colour exactly as `draw.rs::minibuf_fg` resolves it for
the terminal. Nothing here asks which mode is open, which buffer is being typed
resolved to a concrete colour by `style.css`. (The TUI now draws a face per
text-entry mode instead; improvise-nn4s.4 moves the page onto the same faces.) Nothing here asks which mode is open, which buffer is being typed
into, or what the prompt says; a second answer to any of those is the disease
design-principles §5 is about. The caret is drawn as a CSS `::after` rather than
appended to the text for the same reason: the text is the host's, and the page
+140 -11
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`
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
closes every deeper one; `GroupEnd { level }` closes `level` and every deeper
one. `expand_category` reads the level off its prefix, which holds one item per
marker names its level. `GroupHeader { level, depth, .. }` opens a run at
`(level, depth)` and closes every deeper one; `GroupEnd { level, depth }` closes
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
`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
@@ -357,12 +359,14 @@ browser symptom and silently break drill.
`Axis` also owns four presentation vocabularies — `plain_label()`, `glyph()`
(which `Display` appends, giving "Row ↕"), `short_label()` (three columns for
the tile bar; `None` is "·", **not** a truncation of "None") and `initial()`
(one letter for the view panel; `None` is empty) — plus `color() -> AxisColor`.
`AxisColor` is a *semantic* palette because `improvise-core` must not name a
ratatui type; `draw.rs::axis_fg` does the one mapping, exactly as `minibuf_fg`
does for `MinibufColor` (improvise-rml). The tile bar and the category panel each
carried a copy of the colour table before, with nothing keeping them in
agreement.
(one letter for the view panel; `None` is empty). Its **colour is no longer
here**. It was `color() -> AxisColor` (improvise-rml), a semantic palette that
lived in core only because it was a method on a core type; ADR 004 clause 6
moved it to `improvise-protocol` as `Face::Axis(axis)`, because a face is UI and
this crate is the one that must have none. The TUI resolves it through
`draw.rs::face_style` (`context/code/tui.md`). The tile bar and the category
panel each carried a copy of the colour table before improvise-rml, with nothing
keeping them in agreement, and they still share one: the face.
### `on_category_added`: a virtual on an axis is a configuration, not a vacancy
@@ -1556,7 +1560,133 @@ 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,
which only `was` can see.
## 19. How an id is made — `model/id.rs` (ADR 007 clause 4, improvise-cq1q.2)
## 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.
## 20. How an id is made — `model/id.rs` (ADR 007 clause 4, improvise-cq1q.2)
`slugify` and `IdAllocator` are the first piece of ADR 007. They are pure
functions of the display and the allocator's own history. Nothing calls them
@@ -1609,4 +1739,3 @@ must name both lines of a duplicate; `ClaimError` carries only the id, so the
loader keeps the line numbers. The virtual views `_records` and `_drill` are
not yet in `VIRTUAL_IDS`. improvise-p003 item 1 asks for them, and they arrive
with the ADR text fix (improvise-cq1q.1) rather than ahead of it.
+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
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
Every `build_*` in `parser.rs` operates on a `Pair` pest has already
+110 -7
View File
@@ -1311,8 +1311,8 @@ re-derived from `kind`, so the two ends cannot disagree about what "Measure
rather than re-exported, because this crate must not depend on `improvise-io`
(the wizard is an io type, gotcha 30) and a client that draws the wizard must
still be able to name the step it is on. `WizardFieldKind` is the semantic
half of a colour decision — the terminal mapping lives in `draw.rs`, exactly
as `AxisColor`/`axis_fg` does (gotcha 26).
half of a colour decision: it indexes the `wizard.field.*` faces (§10), and the
terminal mapping is `draw.rs::face_style`.
### `Projection` carries no multi-widget variant
@@ -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
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
identity (improvise-h0iv), so the band still paints one group per position, now
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
A `FrameGroup.level` and a `GroupEnd { level }` name a category: they index
`cats`. `AxisFrame::groups` keeps one open run per level and sizes that stack
with `open.resize(g.level, None)`. So a level arriving over the wire decides how
much it allocates. A frame whose level is `4294967295` would ask for a
A `FrameGroup.level` and a `GroupEnd`'s `level` name a category: they index
`cats`. `AxisFrame::groups` used to keep one open run per level and size that
stack with `open.resize(g.level, None)`, so a level arriving over the wire
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
being declined. ChatGPT Codex found this on #250, the PR that gave levels to the
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 =
"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
@@ -2353,7 +2374,7 @@ deserializes now carries no English at all.
`Hint { keys, command, caption }` is the unit: `keys` come off
`Keymaps::default_keymaps()`, `command` is the semantic value a localized or
non-terminal front end keys off, and `caption` is the default English — exactly
the `AxisColor`/`draw.rs::axis_fg` shape, with the decision shared and only the
the shape faces now have (`Face` → `draw.rs::face_style`, §10), with the decision shared and only the
last translation local (`invariant(semantic_palette_at_the_core_boundary)`).
### What that bought, and what it cost
@@ -2441,3 +2462,85 @@ the mode, the same shape `col_groups` uses for "no groups". `web/app.js` paints
rows it does not author; `src/ui/help.rs` keeps its ratatui styles and lost
~310 lines of content that is now shared. Neither end restates what the keys
are.
## 10. `face.rs` — appearance named by role (ADR 004)
ADR 004 (`context/adr/004-faces-and-themes.md`) is the decision; this section
is where its first two migration steps landed and what they settled that the
ADR left open (improvise-nn4s.1, improvise-nn4s.2).
### What is here
- `Face`: one variant per role. Where a domain enum already exists the face is
**parameterised by it** — `Face::Axis(Axis)`, `Face::WizardField(WizardFieldKind)`,
`Face::ModePanel(Panel)`, `Face::ModeTextEntryOf(TextEntry)`,
`Face::MinibufferOf(TextEntry)` — so adding an arm to that enum adds a face by
construction (clause 2). `Face::name` spells each one as a theme file keys it
(`axis.row`, `mode.text-entry.command`, `minibuffer.category-add`), and those
names are a compatibility surface once a theme can be downloaded (clause 9).
- `Face::parent`: the inheritance, **fixed in the vocabulary rather than stated
by the theme**. A theme that omits a face must still get the face's parent's
values ("a theme written today keeps working when a face is added tomorrow",
clause 9), and that is only true if the chain does not depend on what the
theme says.
- `Colour` (`Default | Named(AnsiSlot) | Indexed(u8) | Rgb`), `Weight`, `Slant`,
and `FaceSpec`, whose six attributes are all `Option`: `None` inherits, and
`Some(Colour::Default)` states "the surface" and stops inheritance. The two
are different answers and the type keeps them apart.
- `Theme { faces }` and `Theme::resolve(Face) -> ResolvedFace`, which walks the
chain once and returns every attribute concrete. **No front end walks the
chain** (clause 3). `resolve` takes a face and nothing else.
- `Theme::shipped()`: today's terminal rendering, palette-relative throughout
(clause 4). `the_shipped_theme_states_no_absolute_colour` holds that line.
- `Face::of_mode(&AppMode)` and `Face::of_minibuffer(&AppMode)`, both
exhaustive over `AppMode`, so a new mode fails the build here as it used to
in `draw.rs::mode_style` (improvise-zx8t).
### The JSON shape
Face names are the keys and a colour is spelled for a person: `"default"`, a
slot name (`"green"`, `"bright-black"`), a bare number for an index (`235`),
`"#rrggbb"` for `Rgb`. A misspelt face or attribute is **refused**, not
ignored (`deny_unknown_fields`, and `Face`'s `Deserialize` rejects an unknown
name): a file the user edits by hand should say where it is wrong rather than
silently draw the default. The faces map is a `BTreeMap` keyed on `Face`'s
derived order, so a re-download after an edit is a reviewable diff (clause 9).
The protocol crate derives and the front ends encode, so `serde_json` stays a
dev-dependency here.
### Choices the ADR did not make, made here
- **`minibuffer.*` inherits `mode.text-entry` directly, as the ADR says, and
the shipped theme pays for it.** The mode indicator is black text on an
accent, and the minibuffer is accent text on `Indexed(235)`, bold. So under
this chain every `minibuffer.<entry>` in the shipped theme states all three of
its attributes and inherits none of them from its parent. A first version gave
them a `minibuffer` base face instead. ChatGPT Codex caught it on PR #319: a
published parent is compatibility behaviour, so a theme written to the
accepted ADR that restyles `mode.text-entry` would have silently missed every
prompt. `a_theme_that_restyles_text_entry_reaches_every_minibuffer` pins the
accepted chain. Changing it is the user's call and belongs in the ADR, not in
this file.
- **`mode.tile-select` hangs directly off `mode`.** The ADR's decomposition
covers the thirteen DarkGray modes and the text-entry chain, and does not
place `TileSelect`.
- **The mode faces still state `Named(Black)` ink.** Clause 5 wants the twelve
`fg(Black).bg(accent)` sites to become `reverse` over the accent, but that is
not a no-op on a `TestBackend` buffer, and clause 8 names only one exception.
So it waits for improvise-nn4s.6 and a ruling.
- **No slot tables yet.** `Theme` has no `slots`/`dark`/`default_scheme`: they
are the browser's substitute for a terminal palette (clause 4), and the first
consumer is improvise-nn4s.5. Adding them then is a field with a serde
default, not a format break.
- **Only the faces some call site draws with exist.** The ADR's full vocabulary
(`emphasis`, `error`, `selection`, `grid.*`, `panel.*`) arrives with the
call sites that use it, because a face with no caller is a published name
with no evidence behind it.
### The category-add exception
`minibuffer.category-add` resolves to green in the shipped theme, where the old
literal was yellow: the user ruled the split an accident (ADR 004 Q1), and it
is clause 8's one declared exception. `AppMode::category_add` now carries
`MinibufColor::Green` too, so the browser, which still reads the wire colour
until improvise-nn4s.4, agrees with the TUI rather than drifting from it.
+209
View File
@@ -1032,3 +1032,212 @@ changes. Two rivals were run:
- reading every failure as stale fails the offline case (exit 1, wanted 2);
- dropping `--locked` fails the stale case (exit 0, wanted 1). Without
`--locked`, cargo quietly rewrites the lock and reports success.
## 7. The repo-stats pipeline (improvise-1xfb.5.1)
`scripts/repo-stats` reports churn, change coupling and doc growth over a window
of history: by default the last 7 days of `gh/main`, falling back to `HEAD`,
without merges. It reports and never gates. The user asked for code-maat to do
the analyses it has, and for improvise to be the engine for the rest. So the
pipeline is also an end-to-end run of improvise's headless product: `import`,
`script` and `export`.
### The shape
1. `git log --numstat --no-renames --no-merges` in code-maat's `git2` format.
2. code-maat runs `coupling` (with `--min-revs`/`--min-shared-revs`), `abs-churn`,
`age` and `soc`. Each writes one CSV into the output directory, named after
the analysis. code-maat is pinned in `flake.nix` as a `fetchurl` of the
1.0.4 standalone jar, run by a JRE. It is in the dev shell and in the
`.#repo-stats-tools` package, but not in the `ci` shell: the JRE is about
0.6 GB on Linux, and every CI job enters that shell.
3. The script adds `churn.csv`, one row per (commit, file) with the date, the ISO
week, the path (`entity`, code-maat's own name for it), the area, the kind
(prose, code or other), and the lines added and deleted. It also adds two
flags to code-maat's `coupling.csv`: `crosses-crate` and `agent-rules`.
4. `improvise import <dir>` (improvise-lpzu) merges the directory's five CSVs
into **one** sparse model, `repo-stats.improv`. The merge's `File` category
names each source, and the import makes one view per file, named after its
stem and filtered to it. That is the user's ruling: "Records are already
sparse: the model can just leave null/empty values for columns not in a
given CSV file." A record keeps no coordinate in a category its file lacks.
5. `improvise script` runs the checked-in `scripts/repo-stats.improvise`. It
adds the prose-to-code formula, builds four views on top of the per-file
ones and `export`s each to `report/*.csv`. The four names (`By area`,
`By kind`, `Area by week`, `By day`) must never be a file's stem:
`create-view` of an existing name replaces that view silently (gap 17). The
suite's view count, 10, fails if one does. None of the report reads the
merged `Default` view, which can be empty for a typical code-maat pair
(improvise-8jk6.15).
6. The text report is those exports laid out as tables. The code-maat rankings,
doc sizes and the coupling flags are added to it. `--json` gives the same
data as JSON.
The `.improv` file is the artifact. It opens in the TUI, and CI uploads it with
the CSVs.
**A window in which no file changed builds no model.** Its commits are all
empty (`--allow-empty`), so there is no churn to import and nothing for
code-maat to analyse. The script writes `churn.csv` with its header alone, skips
code-maat and improvise, and the report says so. It exits 0. Running improvise
over an empty CSV was the other option. It was not taken, because it would test
only how improvise treats a CSV with no records, which is not what this pipeline
exists to exercise.
**`--out` must hold no CSV the script did not write.** `improvise import .`
merges every CSV in the directory, so a stale or foreign one would silently
join the model. The script refuses one with exit 2 and names it. It refuses
rather than deleting, because deleting files in a directory the caller named is
the wrong default. Its own five CSVs are overwritten on every run, so a rerun
into the same `--out` works.
**No collisions, by construction.** A `churn.csv` record is keyed by commit and
file, which `--no-renames` makes unique. code-maat's records are keyed by
entity, by entity pair or by date. `File` separates the sources. So the import
keeps its default, `--on-duplicate refuse`. A collision would be a bug in this
script, and the import refuses it.
**What improvise computes and what it does not.** Everything that is a sum or a
ratio of sums is a view:
- churn by area, with `DeletionRatio = deleted / added` as a `_Measure` formula;
- prose, code and other, with `ProseToCode = prose / code` as a formula on the
`kind` category;
- area by ISO week;
- lines added per day from `churn.csv` next to code-maat's `abs-churn`. The two
columns must agree, and the suite checks that they do.
Two kinds of output stay in Python. The ranked code-maat tables (coupling, soc
and age) are read from code-maat's own CSVs, because improvise cannot sort rows
(gap 11 below). Doc sizes are a property of files at one ref, not of the window.
### Definitions
- **Areas**: first match wins, in the order of `AREAS` in the script.
`generated` comes first, so `harness/opfs-contract/Cargo.lock` counts as
generated and not as harness. `.beads/` and `site/` are generated too.
`docs/*.md`, `README.md` and `RELEASING.md` are "other docs", and `.codex/`
is agent docs and skills. A Rust test is a `.rs` file under a `tests/` or
`benches/` directory, or one named `tests.rs` or `*_test(s).rs`. A
`#[cfg(test)] mod tests` inside a source file counts as Rust src, because no
path can tell the two apart.
- **Prose** is `context/code`, `context/test-evidence`, other `context/`, other
docs, `repo-map`, and the agent docs and skills. **Code** is Rust src, Rust tests,
scripts/CI, and web and harness. `kb.pl`, generated files and other files are
neither.
- **The deletion ratio** is deleted lines over added lines. It is 0 when nothing
was deleted, and blank when nothing was added.
- **A crate crossing**: both paths are in a crate (`crates/<name>/`, the root
crate's `src/`, `tests/`, `benches/`, `examples/` or `Cargo.toml`, or
`harness/opfs-contract/`), and the two crates differ. A pair with one side
outside every crate is not flagged.
- **Agent rules**: both paths are in the agent docs and skills area. Such a pair
means one rule stated in several places.
- **Gotchas** are the numbered items between `## Gotchas` and the next `---` or
`##`. The longest gotcha is measured in characters. Most gotchas are one long
line, so a line count would say little.
- **Test-evidence entries** are the `###` and `####` headings.
### What fails, and what never does
- **The numbers never fail anything.** No budget is checked. Budgets are a
separate decision under the compaction epic, improvise-1xfb.5.
- **improvise refusing the pipeline's input exits 3.** That covers a non-zero
exit from `import` or `script`, and any `warning:` on their stderr. The
warning rule is there because a skipped record is data loss that exits 0
(gaps 3 and 4). It is a product regression, not a statistic.
- **In CI**, the suite runs on every PR in `nix-build` and gates like any other
suite. The report runs only on a push to `main`. Its step has
`continue-on-error`, so an improvise failure there marks the step failed and
the job and the deploy go on. Whether that failure should block the deploy
is a question for the user on the PR. The report appends to
`$GITHUB_STEP_SUMMARY`, and the directory is uploaded as the `repo-stats`
artifact.
- The report runs in `nix-build` because that job already builds
`.#improvise`. Its steps run under `nix shell .#repo-stats-tools`, which joins
that same build with code-maat, so nothing is built twice. It is one package
rather than two installables because `check-ci-graph.py` reads a counted
step as `nix shell <one> --command`. The job's checkout fetches full history
(`fetch-depth: 0`) for the report.
### Gaps in the headless product, and the workarounds used
Each of these was found by running the pipeline, and each is a finding against
improvise's command and model layer. Where a workaround exists, the script
names it.
1. **A forced `--category` value is still parsed as a number.** The commit
`00565796` imported as item `565796`, and `220944e2` imported as
`22094400.0`. Workaround: `churn.csv` writes commits as `g<sha>`.
improvise-8jk6.4.
2. **`import --formula` drops a formula that fails to parse, and exits 0.**
`--formula 'X = ('` saves a model with no formula and says nothing.
`add-formula` in a script refuses it and exits 1, so the pipeline's own
category formula is added there. improvise-8jk6.5.
3. **A record that lacks any category's value is skipped.** A merge of CSVs with
different headers, which is the user's sparse model, imports **no records**.
Closed by improvise-lpzu, whose sparse import this pipeline now uses. Until
it landed, every CSV was padded with `-` for the categories it lacked. A
related hole is still open: a record with no measure value stores nothing
(improvise-8jk6.14).
4. **An import that skipped records exits 0**, even when it skipped all of
them. It saves an empty model and prints only a warning. improvise-8jk6.6.
5. **`set-axis` and `set-page` accept names that do not exist, and exit 0.** An
unknown category, an unknown axis name (`rwo`) and an unknown item are all
silent no-ops. improvise-31oe.2.
6. **The command language does not read `|pipe quoting|`.**
`create-view |By area|` made a view named `|By`, and the second word was
ignored. Commands that take a fixed number of arguments ignore extra ones
without saying so. Workaround: `"double quotes"`. improvise-31oe.3.
7. **`create-view` puts every category it is not told about on Page**, with its
first item selected. That silently filters the view. Every view has to set
every category's axis, one `set-axis` line each. improvise-31oe.4.
8. **A view cannot hide a category's column.** A per-file records view shows
every category and measure in the model. improvise-31oe.5.
9. **`export` of a records view writes the editor's `+cat` and `+` columns.**
improvise-8jk6.7.
10. **`export` writes display text.** It uses the view's number format, so the
import's default `,.2` gives `"17,512.00"`. Workaround: `set-format .0` on
every exported view, and `FORMAT .3` on the ratio formulas.
improvise-8jk6.8.
11. **Nothing sorts.** Items stay in first-seen order, so weeks come out
newest first. A top-N table cannot be produced, so code-maat's rankings are
read from its CSVs. improvise-31oe.6.
12. **`--extract` has no ISO week.** It has year, month and quarter. Workaround:
`churn.csv` carries a `week` column. improvise-8jk6.9.
13. **`import --formula` targets only `_Measure`.** A formula on another
category needs `add-formula <category> …` in a script. improvise-8jk6.10.
14. **The saved model opens on the import's `Default` view**, which puts `File`
on rows and `area` on columns, and not on the view the script left active.
That is improvise-azhb.
15. **No folder import.** Closed by improvise-lpzu (PR #314), which this
pipeline now uses. Until it landed, the script named the five files and
generated its own per-file views.
16. **There is no headless output but a file.** `export` writes a CSV, and the
tables are laid out in Python. improvise-31oe.7.
17. **`create-view` of an existing name replaces that view, and exits 0.**
`Workbook::create_view` inserts into an `IndexMap`, so it overwrites without
a word. Folder import makes a view per file stem, so a report view named
like a stem would replace that file's view.
improvise-31oe.8.
### The suite
`scripts/repo-stats-test.sh` builds a fixture repository whose every number is
known, runs the whole pipeline, code-maat and improvise included, and asserts
improvise's exported tables. It runs in `nix-build`, which puts improvise and
code-maat on `PATH`. Locally it uses improvise from `PATH`, or else
`target/debug/improvise`, and it fails with a message when there is neither.
The fallback does not check how fresh that binary is, so locally the suite can
test a `target/debug/improvise` older than the tree. Rebuild after changing
improvise.
It does not build improvise itself. `checkers.sh` scrubs the environment to an
allowlist, and on macOS that removes what the linker needs: a `cargo build`
inside the suite failed with `library not found for -liconv`. That is a bug in
`checkers.sh`: its allowlist drops the Nix linker variables, and the
orchestrator has filed it separately. Build first with
`cargo build --bin improvise`; `cargo test --workspace` also leaves the binary
there. The suite is the slowest in `checkers.sh`, at about 25 seconds, most of
it JVM start-up for code-maat's four analyses per run.
The evidence and the rivals are in `context/test-evidence/tooling.md`.
+38 -35
View File
@@ -104,46 +104,49 @@ appear. Three rules:
- `SHIFT`/`CONTROL`/`ALT` survive; every other modifier bit (`SUPER`, `HYPER`,
`META`) is dropped.
### Semantic-to-terminal colour, and the one that is wrong
### `face_style` — the one place a face becomes a terminal style
This file holds the mapping design-principles §5 names as the shape everything
else should imitate: **the decision lives in the shared layer as a semantic
enum, and only the last translation is local.**
ADR 004 replaced the four per-family maps this section used to describe
(`axis_fg`, `minibuf_fg`, `field_kind_fg`, `mode_style`) with one function:
`face_style(Face) -> Style` resolves the face against the shipped theme
(`Theme::resolve`, in `improvise-protocol`) and translates the result. That is
design-principles §5's shape with nothing left over: **the decision is shared,
and only the last translation is local** — `Colour` → `ratatui::Color` and the
six attributes → `Modifier`. The chain is never walked here (ADR 004 clause 3).
What the vocabulary is and why is `context/code/protocol.md` §10.
- `axis_fg(AxisColor) -> Color` — `Axis::color` is the single place each axis's
accent is decided; this is the single place axis colouring is *rendered*
(improvise-rml, gotcha 26). Before it, the tile bar and the category panel
each carried their own copy of the colour table with nothing keeping them in
agreement.
- `minibuf_fg(MinibufColor) -> Color` — the same shape for the minibuffer.
- `src/ui/import_wizard_ui.rs::field_kind_fg(WizardFieldKind) -> Color` — the
same shape again. `improvise-protocol` must not name a ratatui `Color`.
Three things to know.
`mode_style(&AppMode) -> Style` stays here for the same reason (`Style` and
`Color` are ratatui types and `improvise-command`, where `AppMode` lives,
carries no rendering-framework dependency), and since `improvise-zx8t` it is
**exhaustive**: the thirteen variants that take the default chrome are spelled
out rather than swept up by a `_ =>` wildcard, so adding an `AppMode` fails the
build here until someone chooses its colour. The arithmetic is the check a
prose count does not get: 13 + `Editing`/`RecordsEditing` + `CommandMode` +
`TileSelect` = 17, which is `AppMode`'s variant count. It read *fourteen* until
2026-09-22, and nothing could have caught that — a count written in prose is
checked by no one.
- **`Colour::Default` sets nothing.** It maps to "no `fg`/`bg` on the `Style`",
so the terminal's own colour shows through. That is also why a face with no
stated colour produces `Style::default()` and not a `Reset`.
- **The slot names follow ANSI, not ratatui.** ANSI slot 7 is `White`, which
ratatui calls `Gray`; slot 8 is `BrightBlack`, ratatui's `DarkGray`; slot 15
is `BrightWhite`, ratatui's `White`. `terminal_colour` is the one place that
translation is written.
- **The shipped theme is resolved once**, into a `OnceLock`, because there is no
theme picker yet (ADR 004 "Deferred"). When one arrives the theme becomes the
TUI's own state rather than a static, and a theme is still never on `App`'s
`ViewState` (clause 7).
It is still not quite the same shape as `axis_fg` and its two siblings, and the
difference is deliberate rather than pending. Those three map a *semantic enum*
the shared layer owns; `mode_style` matches `AppMode` itself, because there is
no `ModeColor`. `improvise-zx8t` offered making one, and it was declined here
for two reasons: the colour is the only thing at stake, and the ticket says to
decide it together with `improvise-k099`, which asks the same question of
`hint_text`/`label` and is not in this unit. Exhaustiveness is what the
repo map's "the compiler names every one you must fill in" promise needed; a
semantic enum is a separate, larger answer and is still open.
**The mode indicator is still exhaustive over `AppMode`**, now in
`Face::of_mode` rather than here: adding a variant fails the build there
(improvise-zx8t's guard, moved with the table). The thirteen DarkGray variants
are no longer one arm: they are the five faces ADR 004 derives
(`mode.resting`, `mode.text-entry`, `mode.panel.*`, `mode.overlay`,
`mode.transient`), which all still inherit the same colours from `mode`.
**The guard is the compiler, not a test.** Nothing asserts the colours, and a
test enumerating the variants would not fail when a fifteenth is added — only
the missing match arm does. Verified by deleting the `AppMode::Help` arm:
`error[E0004]: non-exhaustive patterns: `&AppMode::Help` not covered`.
**No-op evidence.** `face_tests::the_shipped_theme_reproduces_every_migrated_literal`
holds the old literals as a table and asserts the shipped theme draws each one,
which is clause 8's property. The one deliberate difference, the category-add
prompt turning green, is asserted by
`the_category_add_prompt_draws_as_the_item_add_prompt_does` rather than being
left out of the table silently.
**Still literal**: every other `Color::` in `src/` (the selection sites, the
panels' accents, the grid's headers, help, the welcome popup). They move as
their files are touched (improvise-nn4s.3, improvise-nn4s.6). Do not touch
`src/ui/grid.rs` for this while improvise-felo.6 is in flight.
### The minibuffer shares the bottom row with the status line
+2 -2
View File
@@ -269,8 +269,8 @@ why `doctrine(mirror_the_cmd_line_for_line)` is a rule about *resolvers* — the
narrow, unavoidable residue — and never a licence to restate a table.
**What legitimately stays per-framework.** Painting, event wiring, and the
mapping from a semantic value to a framework type: `AxisColor → ratatui::Color`
in `draw.rs::axis_fg`, `MinibufColor → minibuf_fg`. That mapping is the shape
mapping from a semantic value to a framework type: a `Face`, resolved by the
shared `Theme`, becomes a `ratatui::Style` in `draw.rs::face_style` (ADR 004). That mapping is the shape
to imitate — the *decision* is in the shared layer as a semantic enum, and only
the last translation is local. Anything larger than a lookup table is a
candidate for the store.
+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
`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`.
### 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.
+53 -7
View File
@@ -710,11 +710,6 @@ Applying `next` four times is the identity from every starting point and no
navigable axis is a fixed point, so no variant is stranded or skipped. Together
with the entry above this pins the rotation without enumerating it twice.
### every_axis_gets_its_own_color
Distinct axes must be distinguishable at a glance; `color()` is the only reason
`AxisColor` exists, so a collision defeats it.
### a_routing_formula_over_a_group_sums_the_members_answers
improvise-4zl7, and the hand-computed half of its evidence. Every number in
@@ -1865,15 +1860,66 @@ were run against the whole `improvise-core` suite:
**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
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.
### 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.
## `model/id.rs` (ADR 007 clause 4, improvise-cq1q.2)
These tests were written before `slugify` and `IdAllocator` had bodies. With
`todo!()` bodies all eleven failed. Each rival below was then injected into the
finished module, run against `cargo test -p improvise-core --lib model::id`, and
reverted. The reasoning is `context/code/core.md` §19.
reverted. The reasoning is `context/code/core.md` §20.
### model::id::tests::slugify_case_folds_rather_than_lowercases
+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
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
improvise-8jk6.2, reported by the user from the wizard: "Skipped 6 of 342
+48
View File
@@ -1259,3 +1259,51 @@ the Enter commit.
- **Rival** (the commit naming `state.view.active_view`, the view at
`Enter`), run across the whole workspace: fails **only** this test, with
`["Later"]` against `["Other"]`.
### resolution_takes_each_attribute_from_the_nearest_face_that_states_it
ADR 004 clause 3: inheritance resolves once, in shared code. The fixture states
a different attribute at each of three levels — `default` (foreground White,
italic), `mode` (foreground Black, background Blue), and
`mode.text-entry.save` (background `Colour::Default`, underline) — so the one
resolved face has to take its foreground from the middle, its slant from the
root and its background from itself. `Some(Colour::Default)` is the case that
matters: it is a stated value and must not fall through to `mode`'s Blue, which
the sibling `mode.text-entry.export` (stating nothing) does inherit.
**Injected fault**: fold the chain so the parent wins. This test fails, and so
does the TUI's `the_shipped_theme_reproduces_every_migrated_literal`, because
`mode.text-entry.edit` then draws its parent's DarkGray.
### every_axis_gets_its_own_accent_in_the_shipped_theme
Moved from `improvise-core`'s `every_axis_gets_its_own_color` when `AxisColor`
left core (ADR 004 clause 6). Distinct axes must be distinguishable at a
glance, and the axis faces exist for exactly that, so two axes resolving alike
defeats them. It now asks the shipped theme rather than an enum's arms, which
is the stronger question: two distinct `AxisColor` arms could always have been
mapped to one terminal colour.
### a_theme_that_restyles_text_entry_reaches_every_minibuffer
ADR 004 says `minibuffer.*` inherits `mode.text-entry` directly. PR #319's first
head gave the minibuffer faces a `minibuffer` base face instead, and ChatGPT
Codex flagged it (P2): face parents are compatibility behaviour once a theme
can be downloaded, so a theme that restyles `mode.text-entry` must reach every
prompt. The test overrides only `mode.text-entry` with italic, an attribute no
minibuffer face states in the shipped theme, and asserts every
`minibuffer.<entry>` resolves italic. A test that sets a colour instead would
be vacuous, because each minibuffer face states its own colours.
**Failed first**, against the `minibuffer` base parent: `Edit` resolved to
`Normal`. It passes under the accepted chain. The no-op oracle
`the_shipped_theme_reproduces_every_migrated_literal` passed both before and
after the change, which shows the change moved no pixel.
### 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.
+104
View File
@@ -1811,3 +1811,107 @@ that failed would leave the owner-authored base at HEAD and fail the case too.
`GIT_CONFIG_GLOBAL=/dev/null` and `GIT_CONFIG_NOSYSTEM=1`, and every `GIT_*`
identity variable is unset first. So a caller's own identity cannot make the
author or committer assertions agree by accident.
### The repo-stats pipeline computes what it claims to — `scripts/repo-stats-test.sh` (improvise-1xfb.5.1)
**What it pins.** The fixture repository has one commit before the window, a
side branch merged with `--no-ff`, a shrink in the next ISO week, and one file
in every area. So each number the report prints is known by construction. The
suite runs the real pipeline, code-maat and improvise included, and asserts
improvise's exported tables: each area's added, deleted and deletion ratio;
prose, code and the `ProseToCode` row; the week split; and the per-day totals.
It also checks the doc sizes and the coupling flags, and that an improvise that
refuses or warns makes the pipeline exit 3.
**Two fixture choices are load-bearing.**
- The repo map's `4.` item sits after the `---` that closes the Gotchas
section, with no `##` heading between them. In an earlier fixture a `##`
heading followed the `---`, so the `---` rule could be deleted and the suite
stayed green.
- `churn.csv` must equal code-maat's `abs-churn` on every day. That compares
two independent derivations of one number, one of them improvise's
aggregation. The fixture has a binary-free, rename-free history, so the two
agree exactly.
**Rivals**, each injected into the script or into `repo-stats.improvise` and run
through `checkers.sh repo-stats`:
- Rust src matched before Rust tests: the rust src and rust tests rows fail.
- `DeletionRatio = deleted / (added + deleted)`: six ratio rows fail.
- `ProseToCode = code / prose`: the `ProseToCode` row fails.
- `--no-merges` dropped: the commit count fails (6, wanted 5).
- The `---` end of the Gotchas section ignored: the gotcha count fails (4, wanted 3).
- A crate crossing as "the crates differ", so a crate against no crate counts:
the crate-crossing case fails.
- `kb.pl` counted as prose: the prose row and the ratio fail.
- The category padding removed: the import skipped every record, and both
pipeline runs failed with exit 3. That rival is retired: folder import
(improvise-lpzu) keeps sparse records, and the padding is gone. The suite
passes 41 of 41 without it.
- Months instead of ISO weeks: both week cases fail.
- A `warning:` from improvise ignored: the warning-stub case fails.
### A window of empty commits, and four faults the first fixture missed — `scripts/repo-stats-test.sh` (improvise-1xfb.5.1)
**What broke.** ChatGPT Codex found it on PR #313, and the adversarial review
reproduced it. When every non-merge commit in the window is `--allow-empty`,
the log has commit headers and no files. `build_dir` took `churn.csv`'s header
from `rows[0]`, raised `IndexError` and exited 1. That exit is outside the
pipeline's failure split: it is neither a number nor improvise's exit 3.
**Fix.** `churn.csv` has a fixed header. When no file changed, the script
skips code-maat and improvise, writes the header alone, and exits 0.
**Red first.** The new case is a second fixture repository whose only
in-window commit is empty. Restoring the `rows[0]` header fails its run case
with the traceback.
**The review's four surviving faults, each now killed:**
- **`--no-renames` dropped.** The fixture now renames `scripts/foo.sh` to
`scripts/bar.sh`. Without the flag git reports one `{foo.sh => bar.sh}` entity
with no lines changed, not a delete and an add. Four cases fail: the scripts/CI
row, the code row, the ratio row and the coupling count.
- **`crate_of` forgetting the root crate.** `src/main.rs` now co-changes with
both crates. The crossing list loses three pairs.
- **A failing `script` call ignored.** A third stub exits 0 for `import` and
refuses `script`. The pipeline then reads exports that were never written and
exits 1, not 3.
- **`.beads/` and `site/` left out of generated.** Both are in the fixture now.
The generated row and the other row both fail.
**Also counted now:** `docs/guide.md` as other docs and `.codex/config.toml` as
agent docs. The coupling count is 16, not 15: `web/app.js` and
`scripts/foo.sh` change together in two commits, the 06-04 one and the rename.
### The pipeline on folder import — `scripts/repo-stats-test.sh` (improvise-1xfb.5.1)
Once #314 merged, `improvise import <dir>` replaced the file list, the `-`
padding and the generated per-file views. The suite needed no change of
expectations and still passed 41 of 41. That shows only that the numbers
agree. It does **not** show that the model is right: the suite could not tell
sparse from padded. The delta review re-added `pad_categories` and the suite
still passed 41 of 41, while the model then had a `|-|` item in 13 places and
the `churn` view seated `agent-rules` on rows and paged everything else. The
view case counted 10 views either way.
**What separates them now:**
- **"the sparse model has no padding item"**: no line of the model names `|-|`.
- **"the churn view seats …"**: four cases, one per seat. `date` is on rows, and
`agent-rules`, `coupled` and `crosses-crate`, which are coupling-only, are on
`none`. The churn view puts `entity` on columns, not `area` as the review
guessed. What discriminates is `date` on rows and the coupling-only
categories on `none`, which a padded CSV cannot give, because it claims all
nine categories.
**Rivals:**
- **Padding re-added** (the review's own injection): 5 cases fail. They are
the `|-|` case, with 161 lines naming it in the fixture model, and all four
seat cases.
- **A report view named like a file stem**: `By day` renamed to `churn` leaves
9 views, and the view-count case fails.
- **The foreign-CSV guard removed**: "a foreign CSV in --out is refused with
exit 2" fails with exit 0. The guard is the answer to the review's
non-blocking finding that `import .` picks up any CSV in `--out`. It refuses
instead of clearing. Deleting files in a directory the caller named is the
wrong default, and the script's own five CSVs are overwritten on every run,
so a rerun into the same `--out` is still accepted. The same fixture run
writes JSON and then text into one directory, which shows that.
+48
View File
@@ -1429,3 +1429,51 @@ 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
is drawn whole and the header reads `1–1 of 2`. The rival **the header claims
every item shown** fails it.
### the_category_add_prompt_draws_as_the_item_add_prompt_does
ADR 004 clause 8's one declared exception, decided by the user (Q1 on PR #122:
the green/yellow split between item-add and category-add is an accident). It
draws both prompts through the real `draw_bottom_bar` into a `TestBackend` and
compares the style of the prompt's first cell, after checking that cell really
holds the prompt's `n`.
**Failed first**, at `gh/main` `b639266b` before any face existed: `left:
...yellow().bg(Indexed(235)).bold()`, `right: ...green()...` — the category-add
prompt was yellow. **Injected fault** after the migration: set the shipped
theme's `minibuffer.category-add` back to `Named(Yellow)`; this test fails and
nothing else in the TUI does, which is the point of naming the exception in a
test of its own rather than leaving it out of the no-op table.
### the_shipped_theme_reproduces_every_migrated_literal
ADR 004 clause 8's property: migrating a call site onto a face is a no-op. The
oracle is the literal tables as they stood before the migration — axis
accents, wizard field kinds, `mode_style`'s exhaustive match (kept verbatim as
`mode_bg_before_faces`) and the minibuffer's accent-on-`Indexed(235)`-bold —
and every one is compared with `face_style` of its face. Category-add is left
out on purpose (the entry above). It was first written against the old
functions themselves and passed, then the functions were deleted and their
tables moved into the test.
**Injected faults**, each failing here by the case name in the message:
`axis.column` changed from Blue to Cyan (fails at `Column`); `face_style` made
to add `BOLD` for `Weight::Normal` instead of `Bold`; `Theme::resolve` folded so
that the parent wins over the child (fails at `Editing`, which draws DarkGray
instead of its own Green); and `Face::of_mode(TileSelect)` returning
`mode.resting` (also caught by `the_status_line_is_drawn_in_the_mode_face`).
### 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", "Apr", "Q2").unwrap();
wb.add_item_in_group("Month", "May", "Q2").unwrap();
wb.active_view_mut().set_expand_depth("Month", 1);
wb.add_item("Type", "Food").unwrap();
wb
}
@@ -213,7 +214,7 @@ mod tests {
!app.model_state
.workbook
.active_view()
.is_group_collapsed("Month", "Q2"),
.is_group_collapsed("Month", &["Q2"]),
"the fixture starts expanded, or the assertion below proves nothing"
);
@@ -225,11 +226,11 @@ mod tests {
);
let view = app.model_state.workbook.active_view();
assert!(
view.is_group_collapsed("Month", "Q2"),
view.is_group_collapsed("Month", &["Q2"]),
"the effect was returned but the view did not move"
);
assert!(
!view.is_group_collapsed("Month", "Q1"),
!view.is_group_collapsed("Month", &["Q1"]),
"the neighbour was left alone"
);
}
@@ -262,9 +263,9 @@ mod tests {
);
app.apply_effects(effects);
let view = app.model_state.workbook.active_view();
assert!(view.is_group_collapsed("Month", "Q2"));
assert!(view.is_group_collapsed("Month", &["Q2"]));
assert!(
!view.is_group_collapsed("Month", "Q1"),
!view.is_group_collapsed("Month", &["Q1"]),
"the neighbour stays"
);
}
@@ -286,6 +287,7 @@ mod tests {
for (month, quarter) in [("Jan", "Q1"), ("Feb", "Q1"), ("Apr", "Q2"), ("May", "Q2")] {
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", "Tools").unwrap();
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`.
#[test]
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")] {
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.active_view_mut().set_axis("Region", 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 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);
match self.is_row {
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")] {
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", "Cost").unwrap();
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("_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);
app.rebuild_layout();
app.cursor_mut().selected = (2, 1);
@@ -197,8 +197,9 @@ mod tests {
for (month, quarter) in [("Jan", "Q1"), ("Feb", "Q1"), ("Apr", "Q2"), ("May", "Q2")] {
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.active_view_mut().toggle_group_collapse("Month", "Q2");
wb.active_view_mut().toggle_group_collapse("Month", &["Q2"]);
let layout = make_layout(&wb);
let reg = make_registry();
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)))
.collect();
drawn.hash(&mut h);
// Off the drawn items, not `Category::groups`: `add_item_in_group` does not
// populate that, and a group with nothing drawn under it is not shape.
// Off the drawn items' chains, not `Category::groups`: a group with nothing
// drawn under it is not shape, and a fold at any depth is.
let mut collapsed: Vec<&str> = model
.category(cat)
.into_iter()
.flat_map(|c| drawn.iter().filter_map(|item| c.items.get(item)))
.filter_map(|item| item.group.as_deref())
.filter(|group| {
view.collapsed_groups
.get(cat)
.is_some_and(|c| c.contains(*group))
.flat_map(|c| drawn.iter().map(move |item| c.item_chain(item)))
.flat_map(|chain| {
(0..chain.len())
.filter(|&d| view.is_group_collapsed(cat, &chain[..=d]))
.map(|d| chain[d])
.collect::<Vec<_>>()
})
.collect();
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 {
// `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.
frame_of(
&crate::view::layout::GridLayout::with_frozen_records(model, view, None),
view,
)
frame_of(&crate::view::layout::GridLayout::with_frozen_records(
model, view, None,
))
}
/// The same hash off a layout already built. The batch's opening basis is free
/// 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};
let mut h = std::collections::hash_map::DefaultHasher::new();
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 {
match entry {
crate::view::layout::AxisEntry::GroupHeader {
depth,
cat_name,
group_name,
collapsed,
..
} => {
0u8.hash(&mut h);
depth.hash(&mut h);
cat_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 } => {
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),
// 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);
level.hash(&mut h);
depth.hash(&mut h);
}
}
}
@@ -204,7 +207,16 @@ pub(crate) fn document_config(workbook: &Workbook) -> u64 {
axis.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();
pages.sort_unstable();
pages.hash(&mut h);
@@ -862,7 +874,7 @@ impl App {
fn opening_view_frame(&self) -> u64 {
let (_, view) = self.resolve_view(self.view_state.active_view.as_str());
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
// `view_frame` would build and the two must not be compared.
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")] {
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.active_view_mut().toggle_group_collapse("Month", "Q2");
wb.active_view_mut().toggle_group_collapse("Month", &["Q2"]);
let mut app = App::new(wb, None);
app.rebuild_layout();
app
+12 -5
View File
@@ -550,8 +550,14 @@ pub struct ToggleGroup {
}
impl Effect for ToggleGroup {
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| {
v.toggle_group_collapse(&self.category, &self.group);
v.toggle_group_collapse(&self.category, &chain);
});
}
fn kind(&self) -> EffectKind {
@@ -3876,10 +3882,11 @@ mod tests {
}
.apply(&mut app);
assert!(
app.model_state
!app.model_state
.workbook
.active_view()
.is_group_collapsed("Type", "MyGroup")
.is_group_collapsed("Type", &["MyGroup"]),
"a group starts collapsed, so the first toggle opens it"
);
ToggleGroup {
view: ViewTarget::Active,
@@ -3888,10 +3895,10 @@ mod tests {
}
.apply(&mut app);
assert!(
!app.model_state
app.model_state
.workbook
.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.active_view_mut().set_expand_depth("Month", 1);
let profit = crate::formula::parse_formula("Profit = Revenue - Cost", "_Measure");
wb.model.add_formula(profit.unwrap());
wb.active_view_mut().set_axis("Month", Axis::Row);
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);
app.rebuild_layout();
assert!(
@@ -1733,13 +1734,14 @@ mod tests {
for measure in ["Revenue", "Cost"] {
wb.add_item_in_group("_Measure", measure, "Money").unwrap();
}
wb.active_view_mut().set_expand_depth("_Measure", 1);
wb.add_item("_Measure", "Units").unwrap();
let jan = month_cell("Jan", "Revenue");
wb.model.set_cell(jan, CellValue::Number(10.0));
wb.active_view_mut().set_axis("Month", Axis::Row);
wb.active_view_mut().set_axis("_Measure", Axis::Column);
wb.active_view_mut()
.toggle_group_collapse("_Measure", "Money");
.toggle_group_collapse("_Measure", &["Money"]);
let mut app = App::new(wb, None);
app.rebuild_layout();
let target = month_cell("Feb", "Units");
@@ -1779,12 +1781,13 @@ mod tests {
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.active_view_mut().set_axis("Month", Axis::Row);
wb.active_view_mut().set_axis("_Measure", Axis::Column);
if collapsed {
wb.active_view_mut()
.toggle_group_collapse("_Measure", "Money");
.toggle_group_collapse("_Measure", &["Money"]);
}
let mut app = App::new(wb, None);
app.rebuild_layout();
@@ -1808,6 +1811,7 @@ mod tests {
for measure in ["Revenue", "Cost"] {
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();
measures.add_group(crate::model::category::Group::new("Money").with_parent("Totals"));
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());
GridCache {
row_frame: axis_frame(&layout.row_cats, &layout.row_items, view, rows.clone()),
col_frame: axis_frame(&layout.col_cats, &layout.col_items, view, cols.clone()),
row_frame: axis_frame(&layout.row_cats, &layout.row_items, rows.clone()),
col_frame: axis_frame(&layout.col_cats, &layout.col_items, cols.clone()),
col_widths: cols
.clone()
.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,
/// all about group headers — see `context/code/command.md`.
fn axis_frame(
cats: &[String],
items: &[AxisEntry],
view: &View,
window: std::ops::Range<usize>,
) -> AxisFrame {
fn axis_frame(cats: &[String], items: &[AxisEntry], window: std::ops::Range<usize>) -> AxisFrame {
let total = items
.iter()
.filter(|e| matches!(e, AxisEntry::DataItem { .. }))
@@ -225,40 +220,48 @@ fn axis_frame(
let mut data_idx = 0usize;
let mut entries: Vec<FrameEntry> = Vec::new();
let mut pending: Vec<FrameEntry> = Vec::new();
// Before the window opens, only the runs still open matter, one per level:
// O(levels), however many groups a deep scroll passed (improvise-e265).
let mut carried: Vec<Option<FrameEntry>> = Vec::new();
// Before the window opens, only the runs still open matter, keyed `(level,
// depth)`: O(depth), however many groups a deep scroll passed (e265, felo.6).
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 {
match entry {
AxisEntry::GroupHeader {
level,
cat_name,
depth,
group_name,
collapsed,
..
} => {
let group = FrameEntry::Group(FrameGroup {
level: *level,
depth: *depth,
name: group_name.clone(),
collapsed: view.is_group_collapsed(cat_name, group_name),
collapsed: *collapsed,
});
match entries.is_empty() {
true => {
carried.truncate(*level);
carried.resize(*level, None);
carried.push(Some(group));
close(&mut carried, (*level, *depth));
carried.push(((*level, *depth), group));
}
false => pending.push(group),
}
}
AxisEntry::GroupEnd { level } => match entries.is_empty() {
true => carried.truncate(*level),
false => pending.push(FrameEntry::GroupEnd { level: *level }),
AxisEntry::GroupEnd { level, depth } => match entries.is_empty() {
true => close(&mut carried, (*level, *depth)),
false => pending.push(FrameEntry::GroupEnd {
level: *level,
depth: *depth,
}),
},
AxisEntry::DataItem { tuple, collapsed } => {
let idx = data_idx;
data_idx += 1;
if window.contains(&idx) {
if entries.is_empty() {
entries.extend(carried.drain(..).flatten());
entries.extend(carried.drain(..).map(|(_, group)| group));
}
entries.append(&mut pending);
entries.push(FrameEntry::Item {
@@ -271,7 +274,7 @@ fn axis_frame(
}
if window.end >= total {
if entries.is_empty() {
entries.extend(carried.drain(..).flatten());
entries.extend(carried.drain(..).map(|(_, group)| group));
}
entries.append(&mut pending);
}
@@ -1458,6 +1461,7 @@ mod tests {
wb.add_item("Region", "North").unwrap();
wb.add_item("Region", "South").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.active_view_mut().set_axis("Region", Axis::Row);
wb.active_view_mut().set_axis("_Measure", Axis::Column);
@@ -1469,7 +1473,8 @@ mod tests {
wb.model.set_cell(key, CellValue::Number(1.0));
}
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);
app.rebuild_layout();
@@ -1573,6 +1578,7 @@ mod tests {
wb.add_item("Region", "North").unwrap();
wb.add_item_in_group("Region", "Inner", "Hidden").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.active_view_mut().set_axis("Region", Axis::Row);
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.active_view_mut()
.toggle_group_collapse("Region", "Hidden");
.toggle_group_collapse("Region", &["Hidden"]);
let mut app = App::new(wb, None);
app.rebuild_layout();
app
@@ -1626,9 +1632,11 @@ mod tests {
wb.add_category("Region").unwrap();
wb.add_category("Month").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_in_group("Month", "Jan", "Q1").unwrap();
wb.add_item_in_group("Month", "Feb", "Q1").unwrap();
wb.active_view_mut().set_expand_depth("Month", 1);
wb.add_item("Month", "Mar").unwrap();
wb.add_item("_Measure", "Revenue").unwrap();
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
/// `North/Mar` loses `Upper` once `Q1` ends. context/test-evidence/command.md.
#[test]
@@ -1693,7 +1747,7 @@ mod tests {
fn z_on_a_row_past_an_inner_run_folds_the_outer_group() {
use crate::command::key::{KeyCode, KeyEvent, KeyModifiers};
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();
app.cursor_mut().selected = (0, 0);
@@ -2895,6 +2949,7 @@ mod tests {
.unwrap();
wb.add_item_in_group("Product", "Gadget", "Hardware")
.unwrap();
wb.active_view_mut().set_expand_depth("Product", 1);
wb.add_item("Product", "Service").unwrap();
wb.add_item("_Measure", "Revenue").unwrap();
wb.add_item("_Measure", "Cost").unwrap();
@@ -4658,6 +4713,7 @@ mod cell_identity {
wb.add_item("Region", "North").unwrap();
wb.add_item_in_group("Region", "East", "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.active_view_mut().set_axis("Region", Axis::Row);
wb.active_view_mut().set_axis("_Measure", Axis::Column);
@@ -4671,7 +4727,7 @@ mod cell_identity {
);
}
wb.active_view_mut()
.toggle_group_collapse("Region", "Coastal");
.toggle_group_collapse("Region", &["Coastal"]);
let mut app = App::new(wb, None);
app.rebuild_layout();
assert_eq!(app.layout.row_label(1), "Coastal");
@@ -5642,9 +5698,11 @@ mod cell_identity {
let mut wb = Workbook::new("T");
wb.add_category("Month").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"] {
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 (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("_Measure", Axis::Column);
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 grid = grid_of(&app, WHOLE);
@@ -6334,13 +6392,14 @@ mod column_totals {
wb.model.set_cell(at, CellValue::Number(v));
}
}
wb.active_view_mut().set_expand_depth("Month", 1);
let max = crate::formula::AggFunc::Max;
wb.model.measure_agg.insert("Revenue".into(), max);
wb.active_view_mut().set_axis("Month", Axis::Row);
wb.active_view_mut().set_axis("_Measure", Axis::Column);
wb.active_view_mut().set_axis("Region", Axis::None);
if collapsed {
wb.active_view_mut().toggle_group_collapse("Month", "Q1");
wb.active_view_mut().toggle_group_collapse("Month", &["Q1"]);
}
wb
};
+3 -3
View File
@@ -1052,9 +1052,9 @@ pub(crate) mod tests {
view.hide_item("Region", "West");
view.hide_item("Region", "South");
view.hide_item("Month", "Mar");
view.toggle_group_collapse("Region", "Coastal");
view.toggle_group_collapse("Region", "Inland");
view.toggle_group_collapse("Month", "Q1");
view.toggle_group_collapse("Region", &["Coastal"]);
view.toggle_group_collapse("Region", &["Inland"]);
view.toggle_group_collapse("Month", &["Q1"]);
app.rebuild_layout();
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", "Jan", "Q1").unwrap();
wb.add_item_in_group("Month", "Feb", "Q1").unwrap();
wb.active_view_mut().set_expand_depth("Month", 1);
wb.add_item("_Measure", "Revenue").unwrap();
for (month, revenue) in [("Jan", 10.0), ("Feb", 30.0)] {
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("_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
}
+109 -3
View File
@@ -199,15 +199,61 @@ impl Category {
self.items.keys().map(|s| s.as_str()).collect()
}
/// The items whose own `group` names `group`, in insertion order. Read off the
/// items, never `groups`: `add_item_in_group` registers no `Group` (core.md §14).
/// The items `group` covers — its own and every child group's, through any
/// depth — in insertion order. Read off the items, not `groups` (core.md §14).
pub fn items_in_group(&self, group: &str) -> Vec<&str> {
self.items
.values()
.filter(|i| i.group.as_deref() == Some(group))
.filter(|i| self.item_chain(&i.name).contains(&group))
.map(|i| i.name.as_str())
.collect()
}
/// The parent a `>` line gave `group`; `None` for a root.
pub fn group_parent(&self, group: &str) -> Option<&str> {
self.groups
.iter()
.find(|g| g.name == group)
.and_then(|g| g.parent.as_deref())
}
/// `group` and its ancestors, ROOT FIRST. A cycle built in code stops the walk
/// where it would repeat; a file cannot carry one (`hierarchy_cycle`).
pub fn group_chain<'a>(&'a self, group: &'a str) -> Vec<&'a str> {
let mut chain = vec![group];
while let Some(parent) = chain.last().and_then(|g| self.group_parent(g)) {
if chain.contains(&parent) {
break;
}
chain.push(parent);
}
chain.reverse();
chain
}
/// The groups an item sits in, root first; empty for an ungrouped item.
pub fn item_chain(&self, item: &str) -> Vec<&str> {
self.items
.get(item)
.and_then(|i| i.group.as_deref())
.map(|g| self.group_chain(g))
.unwrap_or_default()
}
/// A cycle among the declared parents, its members in walk order: the one
/// shape a hierarchy can take that is not a forest (core.md §19).
pub fn hierarchy_cycle(&self) -> Option<Vec<&str>> {
self.groups.iter().find_map(|start| {
let mut path: Vec<&str> = vec![start.name.as_str()];
while let Some(parent) = path.last().and_then(|g| self.group_parent(g)) {
if let Some(at) = path.iter().position(|g| *g == parent) {
return Some(path[at..].to_vec());
}
path.push(parent);
}
None
})
}
}
#[cfg(test)]
@@ -297,6 +343,66 @@ mod tests {
assert_eq!(c.groups.len(), 1);
}
/// Year > Quarter > Month, with `2024-03` added LAST so Q1-2024 and 2024 are
/// non-contiguous in insertion order.
fn dates() -> Category {
let mut c = Category::new(0, "Date");
c.add_item_in_group("2024-01", "Q1-2024");
c.add_item_in_group("2024-04", "Q2-2024");
c.add_item_in_group("2025-01", "Q1-2025");
c.add_item("Undated");
c.add_item_in_group("2024-03", "Q1-2024");
c.add_group(Group::new("Q1-2024").with_parent("2024"));
c.add_group(Group::new("Q2-2024").with_parent("2024"));
c.add_group(Group::new("Q1-2025").with_parent("2025"));
c
}
#[test]
fn a_parent_group_covers_every_leaf_beneath_its_child_groups() {
let c = dates();
assert_eq!(
c.items_in_group("2024"),
vec!["2024-01", "2024-04", "2024-03"]
);
assert_eq!(c.items_in_group("Q1-2024"), vec!["2024-01", "2024-03"]);
assert_eq!(c.items_in_group("2025"), vec!["2025-01"]);
assert!(c.items_in_group("Undated").is_empty());
}
#[test]
fn an_items_chain_runs_root_first() {
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]
fn item_index_reflects_insertion_order() {
let mut c = cat();
+1 -1
View File
@@ -1,5 +1,5 @@
//! How an id is made: `slugify`, and the document's `IdAllocator` (ADR 007 clause 4).
//! Reasoning: `context/code/core.md` §19.
//! Reasoning: `context/code/core.md` §20.
use std::collections::HashSet;
/// What a generated id names; spells the `<kind>` of an empty slug's `<kind>-<n>`.
+9
View File
@@ -525,6 +525,15 @@ impl Model {
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) {
self.data.set(key, value);
}
+1 -35
View File
@@ -1,6 +1,6 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Axis {
Row,
@@ -12,17 +12,6 @@ pub enum Axis {
None,
}
/// Accent color for an axis. Semantic palette only; `draw.rs::axis_fg` maps
/// each variant to a terminal color, so `Axis` names no ratatui type.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AxisColor {
Row,
Column,
Page,
Filter,
Inactive,
}
impl Axis {
/// The next axis in the Row → Column → Page → None → Row rotation.
/// `Filter` is outside it and cycling one is a no-op (improvise-dz8).
@@ -48,18 +37,6 @@ impl Axis {
!matches!(self, Axis::Filter)
}
/// Accent color. The single source for axis coloring: the tile bar and the
/// category panel each carried a copy before (improvise-rml).
pub fn color(self) -> AxisColor {
match self {
Axis::Row => AxisColor::Row,
Axis::Column => AxisColor::Column,
Axis::Page => AxisColor::Page,
Axis::Filter => AxisColor::Filter,
Axis::None => AxisColor::Inactive,
}
}
/// Name with no decoration: "Row", "Col", "Page", "None".
pub fn plain_label(self) -> &'static str {
match self {
@@ -176,15 +153,4 @@ mod tests {
assert!(!Axis::Column.filters_data());
assert!(!Axis::None.filters_data());
}
/// A collision defeats the only reason `AxisColor` exists.
#[test]
fn every_axis_gets_its_own_color() {
let colors = ALL.map(Axis::color);
for (i, a) in colors.iter().enumerate() {
for b in &colors[i + 1..] {
assert_ne!(a, b, "two axes share an accent color");
}
}
}
}
+479 -111
View File
@@ -205,16 +205,19 @@ pub enum RecordCol {
/// and collapse state show; a collapsed group is ONE `DataItem` (core.md §14).
#[derive(Debug, Clone, PartialEq)]
pub enum AxisEntry {
/// Opens a run at `level` (the category's place on the axis) and closes
/// every run inside it. Membership is per level: core.md §14, improvise-e265.
/// Opens a run at `(level, depth)` — the category's place on the axis, then the
/// group's depth in its hierarchy — and closes every run inside it (§6, §19).
GroupHeader {
level: usize,
depth: usize,
cat_name: String,
group_name: String,
/// The view's answer for this group, so no reader re-derives it.
collapsed: bool,
},
/// Ends the run at `level` and every run inside it (improvise-gd83, e265).
/// Nothing is drawn for it; it exists so membership is not inherited.
GroupEnd { level: usize },
/// Ends the run at `(level, depth)` and every run inside it (improvise-gd83,
/// e265). Nothing is drawn for it; it exists so membership is not inherited.
GroupEnd { level: usize, depth: usize },
/// `collapsed` is not derivable from `tuple`: a group and an item may share a
/// name. Set for a folded group and for everything expanded beneath one.
DataItem { tuple: Vec<String>, collapsed: bool },
@@ -301,28 +304,38 @@ fn data_index_to_visual(items: &[AxisEntry], data_idx: usize) -> Option<usize> {
.nth(data_idx)
}
/// Each open run, by level, as `(position of its header, the header)`. A header
/// at L replaces L and closes deeper runs; a `GroupEnd` at L closes L and deeper.
type OpenRuns = Vec<Option<(usize, AxisEntry)>>;
/// Where a run sits: `(level, depth)`, ordered, so "inside" is "greater than".
/// Across categories the level decides; within one, the depth (§19).
pub type RunKey = (usize, usize);
fn open_at(runs: &mut OpenRuns, level: usize, run: Option<(usize, AxisEntry)>) {
runs.truncate(level);
runs.resize(level, None);
runs.extend(run.map(Some));
/// One open run: its key and the position of the header that opened it.
pub type OpenRun = (RunKey, usize);
/// Each open run, innermost last, with its header. A header at K replaces K
/// and closes every deeper run; a `GroupEnd` at K closes K and deeper.
type OpenRuns = Vec<(RunKey, usize, AxisEntry)>;
/// Close every run at or inside `key`, then open `run` there if one is given.
fn open_at(runs: &mut OpenRuns, key: RunKey, run: Option<(usize, AxisEntry)>) {
let outside = runs.partition_point(|(k, _, _)| *k < key);
runs.truncate(outside);
runs.extend(run.map(|(at, header)| (key, at, header)));
}
/// Drop the data items `keep` rejects, then re-mark the survivors so each keeps
/// exactly the runs it was in. Why re-marked, not filtered: core.md §6, e265.
fn prune_axis_items(items: &mut Vec<AxisEntry>, keep: impl Fn(usize) -> bool) {
let mut kept: Vec<AxisEntry> = Vec::new();
// What the input has open at each level, and what `kept` has open so far.
// What the input has open, and what `kept` has open so far.
let mut input: OpenRuns = Vec::new();
let mut output: Vec<Option<usize>> = Vec::new();
let mut output: Vec<OpenRun> = Vec::new();
let mut data_idx = 0usize;
for (at, entry) in items.drain(..).enumerate() {
match entry {
AxisEntry::GroupHeader { level, .. } => open_at(&mut input, level, Some((at, entry))),
AxisEntry::GroupEnd { level } => open_at(&mut input, level, None),
AxisEntry::GroupHeader { level, depth, .. } => {
open_at(&mut input, (level, depth), Some((at, entry)))
}
AxisEntry::GroupEnd { level, depth } => open_at(&mut input, (level, depth), None),
AxisEntry::DataItem { .. } => {
if keep(data_idx) {
remark(&input, &mut output, &mut kept);
@@ -335,48 +348,58 @@ fn prune_axis_items(items: &mut Vec<AxisEntry>, keep: impl Fn(usize) -> bool) {
*items = kept;
}
/// Emit the markers that make `kept`'s open runs equal the input's, outermost
/// first: a header where a different run is open, a `GroupEnd` where none is.
fn remark(input: &OpenRuns, output: &mut Vec<Option<usize>>, kept: &mut Vec<AxisEntry>) {
for level in 0..input.len().max(output.len()) {
let want = input.get(level).cloned().flatten();
let have = output.get(level).copied().flatten();
if want.as_ref().map(|(at, _)| *at) == have {
continue;
}
// Deeper runs end first, innermost out, as `expand_category` ends them.
for deeper in (level + 1..output.len()).rev() {
if output[deeper].is_some() {
kept.push(AxisEntry::GroupEnd { level: deeper });
}
}
output.truncate(level);
output.resize(level, None);
match want {
Some((at, header)) => {
kept.push(header);
output.push(Some(at));
}
None => kept.push(AxisEntry::GroupEnd { level }),
}
fn group_end((level, depth): RunKey) -> AxisEntry {
AxisEntry::GroupEnd { level, depth }
}
/// Emit the markers that make `kept`'s open runs equal the input's: past the
/// runs they share, end the deeper ones innermost out, then open what differs.
fn remark(input: &OpenRuns, output: &mut Vec<OpenRun>, kept: &mut Vec<AxisEntry>) {
let shared = input
.iter()
.zip(output.iter())
.take_while(|((_, want, _), (_, have))| want == have)
.count();
let want = input.get(shared);
let have = output.get(shared).copied();
let Some(slot) = [want.map(|w| w.0), have.map(|h| h.0)]
.into_iter()
.flatten()
.min()
else {
return;
};
// Deeper runs end first, innermost out, as `expand_category` ends them.
for (key, _) in output[shared..].iter().rev().filter(|(k, _)| *k > slot) {
kept.push(group_end(*key));
}
output.truncate(shared);
let replaces = want.is_some_and(|w| w.0 == slot);
if !replaces && have.is_some_and(|h| h.0 == slot) {
kept.push(group_end(slot));
}
for (key, at, header) in &input[shared..] {
kept.push(header.clone());
output.push((*key, *at));
}
}
/// The one reading of the markers: each data item's open runs, by level, as
/// header positions, lent to `visit` — one stack, never copied (0r01).
/// The one reading of the markers: each data item's open runs, innermost last,
/// lent to `visit` — one stack, never copied (0r01).
fn walk_open_runs<T>(
items: &[AxisEntry],
mut visit: impl FnMut(&[Option<usize>]) -> ControlFlow<T>,
mut visit: impl FnMut(&[OpenRun]) -> ControlFlow<T>,
) -> Option<T> {
let mut open: Vec<Option<usize>> = Vec::new();
let mut open: Vec<OpenRun> = Vec::new();
for (at, entry) in items.iter().enumerate() {
match entry {
AxisEntry::GroupHeader { level, .. } => {
open.truncate(*level);
open.resize(*level, None);
open.push(Some(at));
AxisEntry::GroupHeader { level, depth, .. } => {
open.truncate(open.partition_point(|(k, _)| *k < (*level, *depth)));
open.push(((*level, *depth), at));
}
AxisEntry::GroupEnd { level, depth } => {
open.truncate(open.partition_point(|(k, _)| *k < (*level, *depth)))
}
AxisEntry::GroupEnd { level } => open.truncate(*level),
AxisEntry::DataItem { .. } => {
if let ControlFlow::Break(found) = visit(&open) {
return Some(found);
@@ -392,7 +415,7 @@ fn walk_open_runs<T>(
fn innermost_group(items: &[AxisEntry], idx: usize) -> Option<(String, String)> {
let mut data_idx = 0usize;
let at = walk_open_runs(items, |open| match data_idx == idx {
true => ControlFlow::Break(open.iter().rev().find_map(|run| *run)),
true => ControlFlow::Break(open.last().map(|(_, at)| *at)),
false => {
data_idx += 1;
ControlFlow::Continue(())
@@ -872,9 +895,13 @@ fn expand_category(
return vec![];
};
// The prefix holds one item per outer category, so its length is this level.
let level = prefix.len();
let mut result = Vec::new();
let mut last_group: Option<&str> = None;
let mut runs = CategoryRuns {
level: prefix.len(),
cat_name,
view,
open: Vec::new(),
out: Vec::new(),
};
// A group's items need not be contiguous, so a collapsed one folds ONCE.
let mut folded: Vec<&str> = Vec::new();
@@ -884,50 +911,79 @@ fn expand_category(
if view.is_hidden(cat_name, item_name) {
continue;
}
let item_group = cat
.items
.get(item_name.as_str())
.and_then(|i| i.group.as_deref());
let collapsed = item_group.filter(|g| view.is_group_collapsed(cat_name, g));
if collapsed.is_some_and(|g| folded.contains(&g)) {
let chain = cat.item_chain(item_name);
// The OUTERMOST collapsed group folds every leaf beneath it (§19).
let fold_depth =
(0..chain.len()).find(|&depth| view.is_group_collapsed(cat_name, &chain[..=depth]));
let fold = fold_depth.map(|depth| chain[depth]);
if fold.is_some_and(|g| folded.contains(&g)) {
continue;
}
// A header on entering a group; a `GroupEnd` only on leaving one for
// NO group — another group's header already ends the run.
if item_group != last_group {
if last_group.is_some() && item_group.is_none() {
result.push(AxisEntry::GroupEnd { level });
}
if let Some(g) = item_group {
result.push(AxisEntry::GroupHeader {
level,
cat_name: cat_name.to_string(),
group_name: g.to_string(),
});
}
last_group = item_group;
}
runs.enter(&chain[..fold_depth.map_or(chain.len(), |d| d + 1)]);
let mut tuple = prefix.clone();
match collapsed {
match fold {
Some(g) => {
folded.push(g);
tuple.push(g.to_string());
}
None => tuple.push(item_name.to_string()),
}
result.push(AxisEntry::DataItem {
runs.out.push(AxisEntry::DataItem {
tuple,
collapsed: prefix_collapsed || collapsed.is_some(),
collapsed: prefix_collapsed || fold.is_some(),
});
}
// The expansion is the end of the run too: the next prefix's items follow
// immediately and must not inherit it (improvise-gd83).
if last_group.is_some() {
result.push(AxisEntry::GroupEnd { level });
runs.enter(&[]);
runs.out
}
/// One category's group runs as `expand_category` emits them: the chain of groups
/// open, root first, and the markers that move it from item to item.
struct CategoryRuns<'a> {
level: usize,
cat_name: &'a str,
view: &'a View,
open: Vec<&'a str>,
out: Vec<AxisEntry>,
}
impl<'a> CategoryRuns<'a> {
/// Move the open chain to `chain` in `remark`'s own convention, so an unpruned
/// axis survives pruning marker for marker (core.md §6).
fn enter(&mut self, chain: &[&'a str]) {
let shared = self
.open
.iter()
.zip(chain)
.take_while(|(open, want)| open == want)
.count();
let level = self.level;
for depth in (shared + 1..self.open.len()).rev() {
self.out.push(AxisEntry::GroupEnd { level, depth });
}
if chain.len() == shared && self.open.len() > shared {
self.out.push(AxisEntry::GroupEnd {
level,
depth: shared,
});
}
self.open.truncate(shared);
for (depth, group) in chain.iter().enumerate().skip(shared) {
self.out.push(AxisEntry::GroupHeader {
level,
depth,
cat_name: self.cat_name.to_string(),
group_name: group.to_string(),
collapsed: self
.view
.is_group_collapsed(self.cat_name, &chain[..=depth]),
});
self.open.push(group);
}
}
result
}
/// Cartesian product of visible items across `cats`, headers always emitted.
@@ -1026,8 +1082,10 @@ mod tests {
AxisEntry::item(vec!["A".into()]),
AxisEntry::GroupHeader {
level: 0,
depth: 0,
cat_name: "C".into(),
group_name: "G".into(),
collapsed: false,
},
AxisEntry::item(vec!["B".into()]),
AxisEntry::item(vec!["C".into()]),
@@ -1051,8 +1109,10 @@ mod tests {
let mut items = vec![
AxisEntry::GroupHeader {
level: 0,
depth: 0,
cat_name: "C".into(),
group_name: "G".into(),
collapsed: false,
},
AxisEntry::item(vec!["A".into()]),
AxisEntry::item(vec!["B".into()]),
@@ -1418,6 +1478,7 @@ mod tests {
wb.add_item_in_group("Month", "Jan", "Q1").unwrap();
wb.add_item_in_group("Month", "Feb", "Q1").unwrap();
wb.add_item_in_group("Month", "Apr", "Q2").unwrap();
wb.active_view_mut().set_expand_depth("Month", 1);
wb.add_item("Type", "Food").unwrap();
let layout = GridLayout::new(&wb.model, wb.active_view());
assert_eq!(layout.row_count(), 3); // Jan, Feb, Apr — headers don't count
@@ -1430,6 +1491,7 @@ mod tests {
wb.add_category("Type").unwrap();
wb.add_item_in_group("Month", "Jan", "Q1").unwrap();
wb.add_item_in_group("Month", "Apr", "Q2").unwrap();
wb.active_view_mut().set_expand_depth("Month", 1);
wb.add_item("Type", "Food").unwrap();
let layout = GridLayout::new(&wb.model, wb.active_view());
let headers: Vec<_> = layout
@@ -1457,8 +1519,9 @@ mod tests {
wb.add_item_in_group("Month", "Feb", "Q1").unwrap();
wb.add_item_in_group("Month", "Apr", "Q2").unwrap();
wb.add_item_in_group("Month", "May", "Q2").unwrap();
wb.active_view_mut().set_expand_depth("Month", 1);
wb.add_item("Type", "Food").unwrap();
wb.active_view_mut().toggle_group_collapse("Month", "Q1");
wb.active_view_mut().toggle_group_collapse("Month", &["Q1"]);
let layout = GridLayout::new(&wb.model, wb.active_view());
let labels: Vec<String> = (0..layout.row_count())
.map(|r| layout.row_label(r))
@@ -1489,8 +1552,9 @@ mod tests {
wb.add_item_in_group("Month", "A", "G1").unwrap();
wb.add_item_in_group("Month", "B", "G2").unwrap();
wb.add_item_in_group("Month", "C", "G1").unwrap();
wb.active_view_mut().set_expand_depth("Month", 1);
wb.add_item("Type", "Food").unwrap();
wb.active_view_mut().toggle_group_collapse("Month", "G1");
wb.active_view_mut().toggle_group_collapse("Month", &["G1"]);
let layout = GridLayout::new(&wb.model, wb.active_view());
let labels: Vec<String> = (0..layout.row_count())
.map(|r| layout.row_label(r))
@@ -1518,10 +1582,11 @@ mod tests {
wb.add_category("Type").unwrap();
wb.add_item_in_group("Month", "Jan", "Q1").unwrap();
wb.add_item_in_group("Month", "Apr", "Q2").unwrap();
wb.active_view_mut().set_expand_depth("Month", 1);
wb.add_item("Region", "North").unwrap();
wb.add_item("Type", "Food").unwrap();
wb.active_view_mut().set_axis("Region", Axis::Row);
wb.active_view_mut().toggle_group_collapse("Month", "Q1");
wb.active_view_mut().toggle_group_collapse("Month", &["Q1"]);
let layout = GridLayout::new(&wb.model, wb.active_view());
assert_eq!(
layout.row_tuple(0),
@@ -1543,6 +1608,7 @@ mod tests {
wb.add_category("Type").unwrap();
wb.add_item_in_group("Month", "Jan", "Q1").unwrap();
wb.add_item_in_group("Month", "Feb", "Q1").unwrap();
wb.active_view_mut().set_expand_depth("Month", 1);
wb.add_item("Month", "Sep").unwrap();
wb.add_item("Type", "Food").unwrap();
let layout = GridLayout::new(&wb.model, wb.active_view());
@@ -1572,6 +1638,7 @@ mod tests {
wb.add_item_in_group("Recipient", "Alice", "Family")
.unwrap();
wb.add_item_in_group("Recipient", "Bob", "Family").unwrap();
wb.active_view_mut().set_expand_depth("Recipient", 1);
wb.active_view_mut().set_axis("Recipient", Axis::Row);
let layout = GridLayout::new(&wb.model, wb.active_view());
@@ -1612,6 +1679,7 @@ mod tests {
wb.add_category("Type").unwrap();
wb.add_item_in_group("Month", "Jan", "Q1").unwrap();
wb.add_item_in_group("Month", "Apr", "Q2").unwrap();
wb.active_view_mut().set_expand_depth("Month", 1);
wb.add_item("Type", "Food").unwrap();
wb.model.set_cell(
coord(&[("Month", "Apr"), ("Type", "Food")]),
@@ -1630,6 +1698,7 @@ mod tests {
wb.add_category("Type").unwrap();
wb.add_item_in_group("Month", "Jan", "Q1").unwrap();
wb.add_item_in_group("Month", "Apr", "Q2").unwrap();
wb.active_view_mut().set_expand_depth("Month", 1);
wb.add_item("Type", "Food").unwrap();
let layout = GridLayout::new(&wb.model, wb.active_view());
// visual: [GroupHeader(Q1), DataItem(Jan), GroupHeader(Q2), DataItem(Apr)]
@@ -1646,6 +1715,7 @@ mod tests {
wb.add_item("Type", "Food").unwrap();
wb.add_item_in_group("Month", "Jan", "Q1").unwrap();
wb.add_item_in_group("Month", "Apr", "Q2").unwrap();
wb.active_view_mut().set_expand_depth("Month", 1);
let layout = GridLayout::new(&wb.model, wb.active_view());
// col_items: [GroupHeader(Q1), DataItem(Jan), GroupHeader(Q2), DataItem(Apr)]
assert_eq!(layout.data_col_to_visual(0), Some(1));
@@ -1660,6 +1730,7 @@ mod tests {
wb.add_category("Type").unwrap();
wb.add_item_in_group("Month", "Jan", "Q1").unwrap();
wb.add_item_in_group("Month", "Apr", "Q2").unwrap();
wb.active_view_mut().set_expand_depth("Month", 1);
wb.add_item("Type", "Food").unwrap();
let layout = GridLayout::new(&wb.model, wb.active_view());
assert_eq!(
@@ -1691,6 +1762,7 @@ mod tests {
wb.add_item("Type", "Food").unwrap();
wb.add_item_in_group("Month", "Jan", "Q1").unwrap();
wb.add_item_in_group("Month", "Apr", "Q2").unwrap();
wb.active_view_mut().set_expand_depth("Month", 1);
let layout = GridLayout::new(&wb.model, wb.active_view());
assert_eq!(
layout.col_group_for(0),
@@ -1725,6 +1797,7 @@ mod tests {
None => wb.add_item("Month", *name).unwrap(),
};
}
wb.active_view_mut().set_expand_depth("Month", 1);
wb.add_item("Type", "Food").unwrap();
for m in funded {
wb.model.set_cell(
@@ -1755,7 +1828,7 @@ mod tests {
&[("Jan", Some("Q1")), ("Mar", None), ("Sep", None)],
&["Jan", "Sep"],
);
wb.active_view_mut().toggle_group_collapse("Month", "Q1");
wb.active_view_mut().toggle_group_collapse("Month", &["Q1"]);
let layout = GridLayout::with_frozen_records(&wb.model, wb.active_view(), None);
// The whole sequence, not `any(is Q1)`: that cannot see a header
// pushed in the wrong place, nor a stray `GroupEnd`.
@@ -1764,14 +1837,16 @@ mod tests {
vec![
AxisEntry::GroupHeader {
level: 0,
depth: 0,
cat_name: "Month".into(),
group_name: "Q1".into()
group_name: "Q1".into(),
collapsed: true,
},
AxisEntry::DataItem {
tuple: vec!["Q1".into()],
collapsed: true
},
AxisEntry::GroupEnd { level: 0 },
AxisEntry::GroupEnd { level: 0, depth: 0 },
AxisEntry::item(vec!["Sep".into()]),
],
"Q1 folds to one funded row under its header, and Mar pruned"
@@ -1814,6 +1889,8 @@ mod tests {
wb.add_item_in_group("Region", "South", "Lower").unwrap();
wb.add_item_in_group("Month", "Jan", "Q1").unwrap();
wb.add_item_in_group("Month", "Feb", "Q1").unwrap();
wb.active_view_mut().set_expand_depth("Region", 1);
wb.active_view_mut().set_expand_depth("Month", 1);
wb.add_item("Type", "Food").unwrap();
for month in ["Jan", "Feb"] {
wb.model.set_cell(
@@ -1895,6 +1972,7 @@ mod tests {
wb.add_item_in_group("Month", "Apr", "Q2").unwrap();
wb.add_item_in_group("Month", "Jan", "Q1").unwrap();
wb.add_item_in_group("Month", "Feb", "Q1").unwrap();
wb.active_view_mut().set_expand_depth("Month", 1);
wb.add_item("_Measure", "Revenue").unwrap();
wb.add_item("_Measure", "Cost").unwrap();
for (month, revenue, cost) in [("Jan", 10.0, 5.0), ("Feb", 30.0, 3.0)] {
@@ -1937,7 +2015,7 @@ mod tests {
.collect();
let chain: Vec<&str> = chain.iter().map(String::as_str).collect();
let mut wb = margin_book(&chain);
wb.active_view_mut().toggle_group_collapse("Month", "Q1");
wb.active_view_mut().toggle_group_collapse("Month", &["Q1"]);
wb.model.recompute_formulas(&wb.active_view().none_cats());
let unpruned = GridLayout::new(&wb.model, wb.active_view());
let kept_by_fresh_evaluation: Vec<String> = (0..unpruned.row_count())
@@ -1976,7 +2054,7 @@ mod tests {
.collect();
let chain: Vec<&str> = chain.iter().map(String::as_str).collect();
let mut wb = margin_book(&chain);
wb.active_view_mut().toggle_group_collapse("Month", "Q1");
wb.active_view_mut().toggle_group_collapse("Month", &["Q1"]);
wb.model.recompute_formulas(&wb.active_view().none_cats());
let layout = GridLayout::new(&wb.model, wb.active_view());
let q1 = (0..layout.row_count())
@@ -2055,7 +2133,7 @@ mod tests {
#[test]
fn a_grid_evaluates_under_its_own_covers_not_another_grids() {
let mut wb = margin_book(&["R = Revenue / Cost"]);
wb.active_view_mut().toggle_group_collapse("Month", "Q1");
wb.active_view_mut().toggle_group_collapse("Month", &["Q1"]);
let all = GridLayout::new(&wb.model, wb.active_view());
let mut no_feb_view = wb.active_view().clone();
no_feb_view.hide_item("Month", "Feb");
@@ -2093,7 +2171,7 @@ mod tests {
let mut folded = margin_book(formulas);
folded
.active_view_mut()
.toggle_group_collapse("Month", "Q1");
.toggle_group_collapse("Month", &["Q1"]);
let fold = grid_value(&mut folded, "Q1", "Margin").expect("a Q1 fold");
let mut none = margin_book(formulas);
@@ -2120,7 +2198,7 @@ mod tests {
let mut folded = margin_book(&formulas);
folded
.active_view_mut()
.toggle_group_collapse("Month", "Q1");
.toggle_group_collapse("Month", &["Q1"]);
let fold = grid_value(&mut folded, "Q1", measure);
let mut none = margin_book(&formulas);
none.active_view_mut().set_axis("Month", Axis::None);
@@ -2133,7 +2211,7 @@ mod tests {
let mut folded = margin_book(&formulas);
folded
.active_view_mut()
.toggle_group_collapse("Month", "Q1");
.toggle_group_collapse("Month", &["Q1"]);
assert_eq!(
grid_value(&mut folded, "Q1", "Flag"),
Some(0.0),
@@ -2146,7 +2224,7 @@ mod tests {
#[test]
fn a_folds_aggregate_formula_pools_its_members_records() {
let mut wb = margin_book(&["Top = MAX(Revenue)", "Total = SUM(Revenue)"]);
wb.active_view_mut().toggle_group_collapse("Month", "Q1");
wb.active_view_mut().toggle_group_collapse("Month", &["Q1"]);
assert_eq!(grid_value(&mut wb, "Q1", "Total"), Some(40.0));
assert_eq!(grid_value(&mut wb, "Q1", "Top"), Some(30.0));
}
@@ -2159,7 +2237,7 @@ mod tests {
let mut folded = margin_book(&formula);
folded
.active_view_mut()
.toggle_group_collapse("Month", "Q1");
.toggle_group_collapse("Month", &["Q1"]);
let fold = grid_value(&mut folded, "Q1", "Jans");
let mut none = margin_book(&formula);
@@ -2198,12 +2276,13 @@ mod tests {
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("Type", Axis::Column);
wb.active_view_mut().hide_item("Month", "Feb");
assert_eq!(column_total(&wb), 103.0, "expanded: Jan and Apr are drawn");
wb.active_view_mut().toggle_group_collapse("Month", "Q1");
wb.active_view_mut().toggle_group_collapse("Month", &["Q1"]);
assert_eq!(column_total(&wb), 103.0, "z moved the total");
assert_eq!(
grid_value(&mut wb, "Q1", "Food"),
@@ -2290,7 +2369,7 @@ mod group_membership {
use std::ops::ControlFlow;
/// Every data item's open runs, collected through the one reader.
fn open_runs(items: &[AxisEntry]) -> Vec<Vec<Option<usize>>> {
fn open_runs(items: &[AxisEntry]) -> Vec<Vec<super::OpenRun>> {
let mut all = Vec::new();
walk_open_runs(items, |open| {
all.push(open.to_vec());
@@ -2300,7 +2379,9 @@ mod group_membership {
}
type Level<'a> = (&'a str, &'a [(&'a str, Option<&'a str>)]);
type Fixture<'a> = (&'a [Level<'a>], &'a [(&'a str, &'a str)]);
type Fixture<'a> = (&'a [Level<'a>], &'a [(&'a str, &'a str)], &'a [Parent<'a>]);
/// `(category, group, parent)`: a `>` line.
type Parent<'a> = (&'a str, &'a str, &'a str);
const TWO: &[Level] = &[
("Region", &[("North", Some("Upper")), ("South", None)]),
@@ -2354,19 +2435,80 @@ mod group_membership {
(
TWO,
&[("Region", "Upper"), ("Month", "Q1"), ("Month", "Q2")],
&[],
),
(
THREE,
&[("Region", "Upper"), ("Month", "Q1"), ("Kind", "Goods")],
&[],
),
(NONCONTIGUOUS, &[("Region", "Upper"), ("Month", "Q1")]),
(HOLE, &[("Region", "Upper"), ("Month", "Q1")]),
(NONCONTIGUOUS, &[("Region", "Upper"), ("Month", "Q1")], &[]),
(HOLE, &[("Region", "Upper"), ("Month", "Q1")], &[]),
(
ADJACENT,
&[("Region", "Upper"), ("Region", "Lower"), ("Month", "Q1")],
&[],
),
(
NESTED,
&[
("Region", "Upper"),
("Month", "H1"),
("Month", "Q1"),
("Month", "H2"),
],
NESTED_PARENTS,
),
];
/// improvise-felo.6: a hierarchy INSIDE one category, under an outer group.
/// Q1 is non-contiguous, and Jun sits directly in H1 after its child Q2.
const NESTED: &[Level] = &[
("Region", &[("North", Some("Upper")), ("South", None)]),
(
"Month",
&[
("Jan", Some("Q1")),
("Apr", Some("Q2")),
("Feb", Some("Q1")),
("Jun", Some("H1")),
("Jul", Some("Q3")),
],
),
];
const NESTED_PARENTS: &[Parent] = &[
("Month", "Q1", "H1"),
("Month", "Q2", "H1"),
("Month", "Q3", "H2"),
];
/// [`book`] with `parents` declared and every group OPEN before `collapsed`
/// folds its own, so a subset of groups is exactly what is collapsed.
fn nested_book(levels: &[Level], parents: &[Parent], collapsed: &[(&str, &str)]) -> Workbook {
let mut wb = book(levels, &[]);
for (cat, group, parent) in parents {
let c = wb.model.category_mut(cat).unwrap();
c.add_group(crate::model::category::Group::new(*group).with_parent(*parent));
}
for (cat, _) in levels {
wb.active_view_mut().set_expand_depth(cat, 8);
}
for (cat, group) in collapsed {
let chain: Vec<String> = wb
.model
.category(cat)
.unwrap()
.group_chain(group)
.into_iter()
.map(String::from)
.collect();
let chain: Vec<&str> = chain.iter().map(String::as_str).collect();
wb.active_view_mut().toggle_group_collapse(cat, &chain);
}
wb
}
/// `levels` on Row, one column, the groups `collapsed` names folded. No cell
/// is set: the unpruned layout has every row, and pruning is driven directly.
fn book(levels: &[Level], collapsed: &[(&str, &str)]) -> Workbook {
@@ -2380,12 +2522,13 @@ mod group_membership {
};
}
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_item("Col", "X").unwrap();
wb.active_view_mut().set_axis("Col", Axis::Column);
for (cat, group) in collapsed {
wb.active_view_mut().toggle_group_collapse(cat, group);
wb.active_view_mut().toggle_group_collapse(cat, &[group]);
}
wb
}
@@ -2406,13 +2549,13 @@ mod group_membership {
/// non-empty set of kept rows. The layout is the cost, so it is built once.
fn cases() -> Vec<Case> {
let mut out = Vec::new();
for (fixture, (levels, groups)) in FIXTURES.iter().enumerate() {
for (fixture, (levels, groups, parents)) in FIXTURES.iter().enumerate() {
for folds in 0..(1u32 << groups.len()) {
let collapsed: Vec<(&str, &str)> = (0..groups.len())
.filter(|g| folds & (1 << g) != 0)
.map(|g| groups[g])
.collect();
let full = layout(&book(levels, &collapsed)).row_items;
let full = layout(&nested_book(levels, parents, &collapsed)).row_items;
let rows = data_items(&full).count();
for mask in 1..(1u32 << rows) {
let mut pruned = full.clone();
@@ -2441,8 +2584,8 @@ mod group_membership {
}
}
fn at(runs: &[Option<usize>], level: usize) -> Option<usize> {
runs.get(level).copied().flatten()
fn at(runs: &[super::OpenRun], key: super::RunKey) -> Option<usize> {
runs.iter().find(|(k, _)| *k == key).map(|(_, at)| *at)
}
#[test]
@@ -2463,24 +2606,25 @@ mod group_membership {
if kept.len() == full_runs.len() {
assert_eq!(pruned, full, "{name}: nothing pruned");
}
let levels = full_runs.iter().map(Vec::len).max().unwrap_or(0);
let levels: std::collections::BTreeSet<super::RunKey> =
full_runs.iter().flatten().map(|(k, _)| *k).collect();
for (r, &u) in kept.iter().enumerate() {
for level in 0..levels {
for &level in &levels {
assert_eq!(
named(pruned, at(&pruned_runs[r], level)),
named(full, at(&full_runs[u], level)),
"{name}: row {r}, level {level}\n pruned {pruned:?}\n full {full:?}"
"{name}: row {r}, level {level:?}\n pruned {pruned:?}\n full {full:?}"
);
if r == 0 {
continue;
}
let same = |runs: &[Vec<Option<usize>>], a: usize, b: usize| {
let same = |runs: &[Vec<super::OpenRun>], a: usize, b: usize| {
at(&runs[a], level).is_some() && at(&runs[a], level) == at(&runs[b], level)
};
assert_eq!(
same(&pruned_runs, r - 1, r),
same(&full_runs, kept[r - 1], u),
"{name}: run identity at level {level}, row {r}\n pruned {pruned:?}\n full {full:?}"
"{name}: run identity at level {level:?}, row {r}\n pruned {pruned:?}\n full {full:?}"
);
}
}
@@ -2538,14 +2682,18 @@ mod group_membership {
let items = vec![
AxisEntry::GroupHeader {
level: 0,
depth: 0,
cat_name: "Region".into(),
group_name: "Upper".into(),
collapsed: false,
},
AxisEntry::item(vec!["North".into()]),
AxisEntry::GroupHeader {
level: usize::MAX,
depth: 0,
cat_name: "Nowhere".into(),
group_name: "Unreachable".into(),
collapsed: false,
},
AxisEntry::item(vec!["South".into()]),
];
@@ -2561,14 +2709,16 @@ mod group_membership {
fn a_late_lookup_answers_for_its_own_row() {
let header = |group: &str| AxisEntry::GroupHeader {
level: 0,
depth: 0,
cat_name: "Month".into(),
group_name: group.into(),
collapsed: false,
};
let items = vec![
header("Q1"),
AxisEntry::item(vec!["Jan".into()]),
AxisEntry::item(vec!["Feb".into()]),
AxisEntry::GroupEnd { level: 0 },
AxisEntry::GroupEnd { level: 0, depth: 0 },
AxisEntry::item(vec!["Mar".into()]),
header("Q2"),
AxisEntry::item(vec!["Apr".into()]),
@@ -2601,4 +2751,222 @@ mod group_membership {
);
assert_eq!(l.row_group_for(at(["South", "Mar"])), None);
}
/// Precondition for the enumeration above: NESTED must really produce runs
/// below depth 0, or it enumerates nothing new.
#[test]
fn the_nested_fixture_draws_runs_inside_one_category() {
let wb = nested_book(NESTED, NESTED_PARENTS, &[]);
let deep = layout(&wb)
.row_items
.iter()
.filter(|e| matches!(e, AxisEntry::GroupHeader { depth: 1, .. }))
.count();
assert!(
deep >= 4,
"Q1, Q2, Q1 again and Q3 under each region: {deep}"
);
}
}
/// improvise-felo.6: a hierarchy inside one category — Year > Quarter > Month.
#[cfg(test)]
mod hierarchy {
use super::{AxisEntry, GridLayout};
use crate::model::category::Group;
use crate::model::cell::{CellKey, CellValue};
use crate::view::Axis;
use crate::workbook::Workbook;
/// `2024-02` is added AFTER `2025-01` and `Undated`, so Q1-2024 and 2024 are
/// non-contiguous. Values are powers of ten, so every fold is its own sum.
fn dates() -> Workbook {
let mut wb = Workbook::new("T");
wb.add_category("Date").unwrap();
for (month, quarter) in [
("2024-01", Some("Q1-2024")),
("2024-04", Some("Q2-2024")),
("2025-01", Some("Q1-2025")),
("Undated", None),
("2024-02", Some("Q1-2024")),
] {
match quarter {
Some(q) => wb.add_item_in_group("Date", month, q).unwrap(),
None => wb.add_item("Date", month).unwrap(),
};
}
let cat = wb.model.category_mut("Date").unwrap();
for (quarter, year) in [
("Q1-2024", "2024"),
("Q2-2024", "2024"),
("Q1-2025", "2025"),
] {
cat.add_group(Group::new(quarter).with_parent(year));
}
wb.add_item("_Measure", "Revenue").unwrap();
wb.active_view_mut().set_axis("Date", Axis::Row);
wb.active_view_mut().set_axis("_Measure", Axis::Column);
for (month, v) in [
("2024-01", 1.0),
("2024-02", 10.0),
("2024-04", 100.0),
("2025-01", 1000.0),
("Undated", 5.0),
] {
wb.model.set_cell(key(month), CellValue::Number(v));
}
wb
}
fn key(month: &str) -> CellKey {
CellKey::new(vec![
("Date".into(), month.into()),
("_Measure".into(), "Revenue".into()),
])
}
fn layout(wb: &Workbook) -> GridLayout {
GridLayout::with_frozen_records(&wb.model, wb.active_view(), None)
}
fn rows(l: &GridLayout) -> Vec<String> {
(0..l.row_count()).map(|r| l.row_label(r)).collect()
}
fn value(wb: &Workbook, l: &GridLayout, label: &str) -> Option<f64> {
let r = rows(l).iter().position(|x| x == label)?;
l.evaluate(&wb.model, &l.cell_key(r, 0)?)?.as_f64()
}
fn header(depth: usize, group: &str, collapsed: bool) -> AxisEntry {
AxisEntry::GroupHeader {
level: 0,
depth,
cat_name: "Date".into(),
group_name: group.into(),
collapsed,
}
}
fn end(depth: usize) -> AxisEntry {
AxisEntry::GroupEnd { level: 0, depth }
}
fn item(name: &str) -> AxisEntry {
AxisEntry::item(vec![name.into()])
}
/// Opened to depth 2, the whole sequence: runs nest by depth, a new year's
/// header replaces the old, and a non-contiguous quarter reopens both.
#[test]
fn a_year_quarter_month_hierarchy_nests_its_runs() {
let mut wb = dates();
wb.active_view_mut().set_expand_depth("Date", 2);
assert_eq!(
layout(&wb).row_items,
vec![
header(0, "2024", false),
header(1, "Q1-2024", false),
item("2024-01"),
header(1, "Q2-2024", false),
item("2024-04"),
end(1),
header(0, "2025", false),
header(1, "Q1-2025", false),
item("2025-01"),
end(1),
end(0),
item("Undated"),
header(0, "2024", false),
header(1, "Q1-2024", false),
item("2024-02"),
]
);
}
/// The user's ruling: a hierarchy starts closed, one row per root.
#[test]
fn a_hierarchy_opens_closed_as_one_fold_per_root() {
let wb = dates();
let l = layout(&wb);
assert_eq!(rows(&l), vec!["2024", "2025", "Undated"]);
assert_eq!(value(&wb, &l, "2024"), Some(111.0), "every month of 2024");
assert_eq!(value(&wb, &l, "2025"), Some(1000.0));
assert!(l.row_folds_a_group(0) && !l.row_folds_a_group(2));
}
/// Open the years only: each quarter folds its months, ONCE, even when its
/// months are not contiguous.
#[test]
fn a_collapsed_group_at_an_inner_level_folds_its_leaves_once() {
let mut wb = dates();
wb.active_view_mut().set_expand_depth("Date", 1);
let l = layout(&wb);
assert_eq!(rows(&l), vec!["Q1-2024", "Q2-2024", "Q1-2025", "Undated"]);
assert_eq!(
value(&wb, &l, "Q1-2024"),
Some(11.0),
"Jan and the late Feb"
);
assert_eq!(value(&wb, &l, "Q2-2024"), Some(100.0));
}
/// Collapsing an outer group folds everything under it, whatever its
/// children's own state.
#[test]
fn collapsing_a_parent_folds_its_children_whatever_their_state() {
let mut wb = dates();
wb.active_view_mut().set_expand_depth("Date", 2);
wb.active_view_mut()
.toggle_group_collapse("Date", &["2024"]);
let l = layout(&wb);
assert_eq!(rows(&l), vec!["2024", "2025-01", "Undated"]);
assert_eq!(value(&wb, &l, "2024"), Some(111.0));
}
/// `z` names the innermost open group: a month's quarter, a quarter fold's
/// own quarter, a year fold's year — at a nonzero row each time.
#[test]
fn the_group_under_the_cursor_is_the_innermost_at_every_depth() {
let mut wb = dates();
wb.active_view_mut().set_expand_depth("Date", 1);
wb.active_view_mut()
.toggle_group_collapse("Date", &["2024", "Q2-2024"]);
let l = layout(&wb);
let at = |label: &str| rows(&l).iter().position(|x| x == label).unwrap();
let g = |name: &str| Some(("Date".to_string(), name.to_string()));
assert_eq!(l.row_group_for(at("2024-04")), g("Q2-2024"));
assert_eq!(l.row_group_for(at("Q1-2025")), g("Q1-2025"));
assert_eq!(l.row_group_for(at("Undated")), None);
wb.active_view_mut().set_expand_depth("Date", 0);
let l = layout(&wb);
assert_eq!(l.row_group_for(1), g("2025"));
}
/// A hidden month leaves every fold above it; a quarter whose every month
/// is hidden has no row and no header.
#[test]
fn hidden_items_leave_every_fold_above_them() {
let mut wb = dates();
wb.active_view_mut().hide_item("Date", "2024-04");
let l = layout(&wb);
assert_eq!(value(&wb, &l, "2024"), Some(11.0), "2024-04 hidden");
wb.active_view_mut().set_expand_depth("Date", 2);
let l = layout(&wb);
let q2 = |e: &AxisEntry| matches!(e, AxisEntry::GroupHeader { group_name, .. } if group_name == "Q2-2024");
assert!(!l.row_items.iter().any(q2), "{:?}", l.row_items);
}
/// `prune_empty` at depth: an empty quarter goes, header and all, and the
/// year above it stays for the quarter that has data.
#[test]
fn pruning_drops_an_empty_inner_group_and_keeps_its_parent() {
let mut wb = dates();
wb.model.clear_cell(&key("2024-04"));
wb.active_view_mut().set_expand_depth("Date", 1);
wb.active_view_mut().prune_empty = true;
let l = layout(&wb);
assert_eq!(rows(&l), vec!["Q1-2024", "Q1-2025", "Undated"]);
assert_eq!(l.row_items[0], header(0, "2024", false));
}
}
+2 -2
View File
@@ -2,12 +2,12 @@ pub mod axis;
pub mod layout;
pub mod types;
pub use axis::{Axis, AxisColor};
pub use axis::Axis;
pub use layout::{
AxisEntry, GridEval, GridLayout, NEW_CATEGORY_COL, NEW_MEASURE_COL, RecordCol, RecordRow,
ViewStep, drill_constraint, drill_snapshot, drill_steps, record_row, records_spine,
records_steps, synthetic_record_info,
};
pub use types::{
DEFAULT_VIEW, DRILL_VIEW, RECORDS_VIEW, VIRTUAL_VIEW_NAMES, View, unused_view_name,
DEFAULT_VIEW, DRILL_VIEW, GroupFold, RECORDS_VIEW, VIRTUAL_VIEW_NAMES, View, unused_view_name,
};
+177 -38
View File
@@ -1,5 +1,6 @@
use indexmap::IndexMap;
use std::collections::{HashMap, HashSet};
use std::num::NonZeroUsize;
use super::axis::Axis;
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")
}
/// 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)]
pub struct View {
pub name: String,
@@ -36,8 +47,11 @@ pub struct View {
pub page_selections: HashMap<String, String>,
/// Hidden items per category
pub hidden_items: HashMap<String, HashSet<String>>,
/// Collapsed groups per category
pub collapsed_groups: HashMap<String, HashSet<String>>,
/// How deep each category's hierarchy opens: groups shallower than this start
/// open, the rest closed. Absent is 0 — everything closed (core.md §19).
pub expand_depth: HashMap<String, usize>,
/// Per-group answers that override the depth default, in either direction.
pub group_folds: HashMap<String, HashMap<String, GroupFold>>,
/// Number format string (e.g. ",.0f" for comma-separated integer)
pub number_format: String,
/// When true, empty rows/columns are pruned from the display.
@@ -54,7 +68,8 @@ impl std::fmt::Debug for View {
category_axes,
page_selections,
hidden_items,
collapsed_groups,
expand_depth,
group_folds,
number_format,
prune_empty,
} = self;
@@ -64,12 +79,18 @@ impl std::fmt::Debug for View {
.collect()
};
let pages: BTreeMap<_, _> = page_selections.iter().collect();
let depths: BTreeMap<_, _> = expand_depth.iter().collect();
let folds: BTreeMap<_, BTreeMap<_, _>> = group_folds
.iter()
.map(|(k, v)| (k, v.iter().collect()))
.collect();
f.debug_struct("View")
.field("name", name)
.field("category_axes", category_axes)
.field("page_selections", &pages)
.field("hidden_items", &sorted(hidden_items))
.field("collapsed_groups", &sorted(collapsed_groups))
.field("expand_depth", &depths)
.field("group_folds", &folds)
.field("number_format", number_format)
.field("prune_empty", prune_empty)
.finish()
@@ -88,7 +109,8 @@ impl View {
category_axes: IndexMap::new(),
page_selections: HashMap::new(),
hidden_items: HashMap::new(),
collapsed_groups: HashMap::new(),
expand_depth: HashMap::new(),
group_folds: HashMap::new(),
// Decimals by default: a ratio is the case a format cannot be
// guessed for, and `.0` draws 2/3 as `1` (improvise-e0ex).
number_format: ",.2".to_string(),
@@ -135,7 +157,8 @@ impl View {
self.category_axes.shift_remove(cat_name);
self.page_selections.remove(cat_name);
self.hidden_items.remove(cat_name);
self.collapsed_groups.remove(cat_name);
self.expand_depth.remove(cat_name);
self.group_folds.remove(cat_name);
}
/// Forget an item this view names. A hidden or paged-to item that outlives
@@ -222,23 +245,78 @@ impl View {
self.page_selections.get(cat_name).map(|s| s.as_str())
}
pub fn toggle_group_collapse(&mut self, cat_name: &str, group_name: &str) {
let set = self
.collapsed_groups
.entry(cat_name.to_string())
.or_default();
if set.contains(group_name) {
set.remove(group_name);
} else {
set.insert(group_name.to_string());
/// How many of `cat_name`'s top levels open (`expanded(N): Cat`); 0 — all
/// closed — if unset.
pub fn expand_depth(&self, cat_name: &str) -> usize {
self.expand_depth.get(cat_name).copied().unwrap_or(0)
}
/// Open `cat_name`'s top `depth` levels; 0 forgets the setting.
pub fn set_expand_depth(&mut self, cat_name: &str, depth: usize) {
match depth {
0 => self.expand_depth.remove(cat_name),
_ => self.expand_depth.insert(cat_name.to_string(), depth),
};
}
/// Whether the group ending `chain` (root first, from `Category::group_chain`)
/// is collapsed: its own fold wins, else what it inherits (core.md §19).
pub fn is_group_collapsed(&self, cat_name: &str, chain: &[&str]) -> bool {
match chain.last().and_then(|g| self.group_fold(cat_name, g)) {
Some(GroupFold::Collapsed) => true,
Some(GroupFold::Expanded(_)) => false,
None => self.inherits_collapsed(cat_name, chain),
}
}
pub fn is_group_collapsed(&self, cat_name: &str, group_name: &str) -> bool {
self.collapsed_groups
.get(cat_name)
.map(|s| s.contains(group_name))
.unwrap_or(false)
/// The answer without the group's own fold: open when the category's depth or
/// an ancestor's `expanded(N)` reaches it, closed otherwise.
fn inherits_collapsed(&self, cat_name: &str, chain: &[&str]) -> bool {
let depth = chain.len().saturating_sub(1);
let by_ancestor = chain[..depth].iter().enumerate().any(|(at, ancestor)| {
matches!(self.group_fold(cat_name, ancestor),
Some(GroupFold::Expanded(n)) if depth - at < n.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) {
@@ -292,9 +370,10 @@ impl View {
#[cfg(test)]
mod tests {
use super::{View, unused_view_name};
use super::{GroupFold, View, unused_view_name};
use crate::model::category::CategoryKind;
use crate::view::Axis;
use std::num::NonZeroUsize;
/// improvise-vw47: the count is the starting guess, never the answer.
/// What a taken name costs: `context/test-evidence/core.md`.
@@ -563,28 +642,87 @@ mod tests {
assert_eq!(v.page_selection("Region"), None);
}
/// The user's ruling on improvise-felo.6: every group starts closed.
#[test]
fn toggle_group_collapse_toggles_twice() {
fn a_group_starts_collapsed_at_every_depth() {
let v = View::new("Test");
assert!(v.is_group_collapsed("Time", &["2024"]));
assert!(v.is_group_collapsed("Time", &["2024", "Q1-2024"]));
}
/// `expanded(1): Time` opens the roots and nothing below them.
#[test]
fn a_category_depth_opens_its_top_levels() {
let mut v = View::new("Test");
assert!(!v.is_group_collapsed("Time", "Q1"));
v.toggle_group_collapse("Time", "Q1");
assert!(v.is_group_collapsed("Time", "Q1"));
v.toggle_group_collapse("Time", "Q1");
assert!(!v.is_group_collapsed("Time", "Q1"));
v.set_expand_depth("Time", 1);
assert!(!v.is_group_collapsed("Time", &["2024"]));
assert!(v.is_group_collapsed("Time", &["2024", "Q1-2024"]));
assert!(v.is_group_collapsed("Other", &["G"]), "per category");
v.set_expand_depth("Time", 0);
assert!(v.expand_depth.is_empty(), "0 is the absence, not a setting");
}
/// `expanded(2): Time/2024` opens 2024 and one level beneath it, no deeper,
/// and nothing of the sibling year.
#[test]
fn a_groups_depth_opens_it_and_n_minus_one_levels_beneath() {
let mut v = View::new("Test");
v.set_group_fold(
"Time",
"2024",
GroupFold::Expanded(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]
fn is_group_collapsed_isolated_across_categories() {
let mut v = View::new("Test");
v.toggle_group_collapse("Cat1", "G1");
assert!(!v.is_group_collapsed("Cat2", "G1"));
v.toggle_group_collapse("Cat1", &["G1"]);
assert!(!v.is_group_collapsed("Cat1", &["G1"]));
assert!(v.is_group_collapsed("Cat2", &["G1"]));
}
#[test]
fn is_group_collapsed_isolated_across_groups() {
let mut v = View::new("Test");
v.toggle_group_collapse("Cat1", "G1");
assert!(!v.is_group_collapsed("Cat1", "G2"));
v.toggle_group_collapse("Cat1", &["G1"]);
assert!(v.is_group_collapsed("Cat1", &["G2"]));
}
/// Arm 2 of improvise-qx1z: the other three lines of `on_category_removed`
@@ -593,12 +731,13 @@ mod tests {
fn on_category_removed_forgets_a_collapsed_group() {
let mut v = View::new("V");
v.on_category_added("Month", CategoryKind::of_name("Month"));
v.toggle_group_collapse("Month", "Q1");
v.set_expand_depth("Month", 1);
v.toggle_group_collapse("Month", &["Q1"]);
v.on_category_removed("Month");
v.on_category_added("Month", CategoryKind::of_name("Month"));
assert!(
!v.is_group_collapsed("Month", "Q1"),
"a re-added category came back with its group collapsed"
v.group_folds.is_empty() && v.expand_depth.is_empty(),
"a re-added category came back with its old fold state"
);
}
@@ -808,10 +947,10 @@ mod prop_tests {
group in "[A-Za-z][a-z]{1,7}",
) {
let mut v = View::new("T");
let initial = v.is_group_collapsed(&cat, &group);
v.toggle_group_collapse(&cat, &group);
v.toggle_group_collapse(&cat, &group);
prop_assert_eq!(v.is_group_collapsed(&cat, &group), initial);
let initial = v.is_group_collapsed(&cat, &[group.as_str()]);
v.toggle_group_collapse(&cat, &[group.as_str()]);
v.toggle_group_collapse(&cat, &[group.as_str()]);
prop_assert_eq!(v.is_group_collapsed(&cat, &[group.as_str()]), initial);
}
}
}
@@ -214,6 +214,7 @@ mod tests {
wb.add_category("Type").unwrap();
wb.add_item_in_group("Month", "Jan", "Q1").unwrap();
wb.add_item_in_group("Month", "Feb", "Q1").unwrap();
wb.active_view_mut().set_expand_depth("Month", 1);
wb.add_item("Type", "Food").unwrap();
for (month, value) in [("Jan", 3.0), ("Feb", 4.0)] {
wb.model.set_cell(
@@ -226,7 +227,7 @@ mod tests {
v.set_axis("Month", Axis::Row);
v.set_axis("Type", Axis::Column);
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 rows: Vec<&str> = csv.lines().skip(1).collect();
+34 -15
View File
@@ -2,7 +2,7 @@
use crate::model::category::CategoryKind;
use crate::model::cell::{CellKey, CellValue};
use crate::view::Axis;
use crate::view::{Axis, GroupFold};
use crate::workbook::Workbook;
// 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 {
w!(out, "prune: true");
}
for (prefix, map) in [
("hidden", &view.hidden_items),
("collapsed", &view.collapsed_groups),
] {
let mut pairs: Vec<_> = map
.iter()
.flat_map(|(cat, items)| {
items.iter().map(move |item| (cat.as_str(), item.as_str()))
})
.collect();
pairs.sort();
for (cat, item) in pairs {
w!(out, "{prefix}: {}/{}", quote_name(cat), quote_name(item));
let mut hidden: Vec<_> = view
.hidden_items
.iter()
.flat_map(|(cat, items)| items.iter().map(move |item| (cat.as_str(), item.as_str())))
.collect();
hidden.sort();
for (cat, item) in hidden {
w!(out, "hidden: {}/{}", quote_name(cat), quote_name(item));
}
let mut depths: Vec<_> = view.expand_depth.iter().collect();
depths.sort();
// Never bare: `expanded: row` would read back as an axis line.
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]
fn format_md_collapsed_group() {
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);
assert!(text.contains("collapsed: Type/MyGroup"));
}
@@ -120,7 +120,9 @@ view_section = {
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 = {
name ~ ": " ~ axis_kind ~ (", " ~ name)? ~ NEWLINE ~ blank_lines
@@ -135,6 +137,12 @@ prune_line = { "prune: " ~ bool_kw ~ NEWLINE ~ blank_lines }
bool_kw = @{ "true" | "false" }
hidden_line = { "hidden: " ~ 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 ────────────────────────────────────────────────────────────────────
//
+161 -13
View File
@@ -9,10 +9,10 @@ use pest_derive::Parser;
use crate::formula::{AggFunc, parse_formula};
use crate::model::category::{CategoryKind, Group};
use crate::model::cell::{CellKey, CellValue};
use crate::view::Axis;
use crate::view::{Axis, GroupFold};
use crate::workbook::Workbook;
use crate::formula::quote::unescape_pipe_body;
use crate::formula::quote::{quote_name, unescape_pipe_body};
#[derive(Parser)]
#[grammar = "persistence/improv.pest"]
@@ -40,9 +40,74 @@ struct PView {
format: String,
prune_empty: bool,
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.
#[derive(Default)]
struct ParsedFile {
@@ -332,7 +397,31 @@ fn walk_axis_line(entry: Pair<'_, Rule>, pv: &mut PView) -> Result<()> {
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> {
let mut inner = pair.into_inner();
let mut pv = PView {
@@ -342,7 +431,7 @@ fn walk_view_section(pair: Pair<'_, Rule>) -> Result<PView> {
format: String::new(),
prune_empty: false,
hidden: Vec::new(),
collapsed: Vec::new(),
folds: Vec::new(),
};
for entry in inner {
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::prune_line => pv.prune_empty = first_str(entry)? == "true",
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:?}"),
}
}
@@ -411,6 +508,13 @@ fn build_category(wb: &mut Workbook, pc: &PCategory) -> Result<()> {
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(())
}
@@ -436,8 +540,29 @@ fn build_view(wb: &mut Workbook, pv: &PView) -> Result<()> {
for (cat, item) in &pv.hidden {
view.hide_item(cat, item);
}
for (cat, grp) in &pv.collapsed {
view.toggle_group_collapse(cat, grp);
for PFold {
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(())
}
@@ -534,6 +659,7 @@ mod grammar_coverage {
Rule::axis_line | Rule::axis_kind => "walk_axis_line",
Rule::format_line | Rule::prune_line | Rule::bool_kw => "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::name => "silent: hoisted into its parent",
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() {
let mut m = Workbook::new("Test");
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 loaded = parse_md(&text).unwrap();
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}"
);
}
@@ -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`.
#[test]
fn the_grammar_is_parsed_once_however_many_files_the_strategy_yields() {
@@ -1625,7 +1771,7 @@ mod generator {
#[cfg(test)]
mod grammar_prop_tests {
use super::{generator, parse_md};
use super::{Refusal, generator, parse_md};
use crate::persistence::format_md;
use proptest::prelude::*;
@@ -1633,11 +1779,13 @@ mod grammar_prop_tests {
proptest! {
#![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]
fn generated_file_parses(file in generator::improv_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: {}",
file, result.unwrap_err());
}
@@ -267,10 +267,198 @@ fn parse_md_round_trips_text_cell_values() {
#[test]
fn parse_md_round_trips_collapsed_group() {
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 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 ───────────────────────────────
@@ -336,7 +524,8 @@ fn full_roundtrip_preserves_all_features() {
m.active_view_mut().set_axis("Month", Axis::Page);
m.active_view_mut().set_page_selection("Month", "Jan");
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();
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.page_selection("Month"), Some("Jan"));
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");
}
@@ -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}");
}
}
}
+682
View File
@@ -0,0 +1,682 @@
//! Faces: appearance named by role, and a theme that binds roles to values
//! (ADR 004). The reasoning is `context/code/protocol.md` §10.
use std::collections::BTreeMap;
use std::fmt;
use serde::de::{self, Deserializer, Visitor};
use serde::{Deserialize, Serialize, Serializer};
use crate::render::WizardFieldKind;
use crate::{AppMode, Panel};
use improvise_core::view::Axis;
/// One of the sixteen palette slots: "whatever THIS display calls that slot".
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum AnsiSlot {
Black,
Red,
Green,
Yellow,
Blue,
Magenta,
Cyan,
White,
BrightBlack,
BrightRed,
BrightGreen,
BrightYellow,
BrightBlue,
BrightMagenta,
BrightCyan,
BrightWhite,
}
impl AnsiSlot {
pub const ALL: [AnsiSlot; 16] = [
AnsiSlot::Black,
AnsiSlot::Red,
AnsiSlot::Green,
AnsiSlot::Yellow,
AnsiSlot::Blue,
AnsiSlot::Magenta,
AnsiSlot::Cyan,
AnsiSlot::White,
AnsiSlot::BrightBlack,
AnsiSlot::BrightRed,
AnsiSlot::BrightGreen,
AnsiSlot::BrightYellow,
AnsiSlot::BrightBlue,
AnsiSlot::BrightMagenta,
AnsiSlot::BrightCyan,
AnsiSlot::BrightWhite,
];
/// The slot's number in the 256-colour palette, 0–15.
pub fn index(self) -> u8 {
self as u8
}
/// The spelling a theme file uses.
pub fn name(self) -> &'static str {
match self {
AnsiSlot::Black => "black",
AnsiSlot::Red => "red",
AnsiSlot::Green => "green",
AnsiSlot::Yellow => "yellow",
AnsiSlot::Blue => "blue",
AnsiSlot::Magenta => "magenta",
AnsiSlot::Cyan => "cyan",
AnsiSlot::White => "white",
AnsiSlot::BrightBlack => "bright-black",
AnsiSlot::BrightRed => "bright-red",
AnsiSlot::BrightGreen => "bright-green",
AnsiSlot::BrightYellow => "bright-yellow",
AnsiSlot::BrightBlue => "bright-blue",
AnsiSlot::BrightMagenta => "bright-magenta",
AnsiSlot::BrightCyan => "bright-cyan",
AnsiSlot::BrightWhite => "bright-white",
}
}
pub fn from_name(name: &str) -> Option<Self> {
Self::ALL.into_iter().find(|slot| slot.name() == name)
}
}
/// A colour attribute. `Default`, `Named` and `Indexed` are palette-relative;
/// `Rgb` is absolute, and the shipped theme never uses it (ADR 004 clause 4).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Colour {
Default,
Named(AnsiSlot),
Indexed(u8),
Rgb(u8, u8, u8),
}
impl Colour {
/// True for the one arm that opts a viewer out of their own palette.
pub fn is_absolute(self) -> bool {
match self {
Colour::Rgb(..) => true,
Colour::Default | Colour::Named(_) | Colour::Indexed(_) => false,
}
}
}
impl Serialize for Colour {
fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
match *self {
Colour::Default => s.serialize_str("default"),
Colour::Named(slot) => s.serialize_str(slot.name()),
Colour::Indexed(n) => s.serialize_u8(n),
Colour::Rgb(r, g, b) => s.serialize_str(&format!("#{r:02x}{g:02x}{b:02x}")),
}
}
}
struct ColourVisitor;
impl Visitor<'_> for ColourVisitor {
type Value = Colour;
fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
f.write_str("\"default\", a slot name such as \"green\", 0-255, or \"#rrggbb\"")
}
fn visit_u64<E: de::Error>(self, n: u64) -> Result<Colour, E> {
u8::try_from(n)
.map(Colour::Indexed)
.map_err(|_| E::custom(format!("colour index {n} is outside 0-255")))
}
fn visit_str<E: de::Error>(self, s: &str) -> Result<Colour, E> {
parse_colour(s).ok_or_else(|| E::invalid_value(de::Unexpected::Str(s), &self))
}
}
fn parse_colour(s: &str) -> Option<Colour> {
match s.strip_prefix('#') {
Some(hex) if hex.len() == 6 && hex.bytes().all(|b| b.is_ascii_hexdigit()) => {
let byte = |i: usize| u8::from_str_radix(&hex[i..i + 2], 16).ok();
Some(Colour::Rgb(byte(0)?, byte(2)?, byte(4)?))
}
Some(_) => None,
None if s == "default" => Some(Colour::Default),
None => AnsiSlot::from_name(s).map(Colour::Named),
}
}
impl<'de> Deserialize<'de> for Colour {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
d.deserialize_any(ColourVisitor)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Weight {
Normal,
Bold,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Slant {
Normal,
Italic,
}
/// The text-entry modes, one per `MinibufferConfig`-carrying `AppMode` arm
/// except that cell editing is one role over two layouts.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum TextEntry {
Edit,
Formula,
Command,
CategoryAdd,
ItemAdd,
Export,
Save,
}
impl TextEntry {
pub const ALL: [TextEntry; 7] = [
TextEntry::Edit,
TextEntry::Formula,
TextEntry::Command,
TextEntry::CategoryAdd,
TextEntry::ItemAdd,
TextEntry::Export,
TextEntry::Save,
];
/// The text-entry role of `mode`, or `None` for a mode that takes no text.
pub fn of(mode: &AppMode) -> Option<Self> {
match mode {
AppMode::Editing { .. } | AppMode::RecordsEditing { .. } => Some(TextEntry::Edit),
AppMode::FormulaEdit { .. } => Some(TextEntry::Formula),
AppMode::CommandMode { .. } => Some(TextEntry::Command),
AppMode::CategoryAdd { .. } => Some(TextEntry::CategoryAdd),
AppMode::ItemAdd { .. } => Some(TextEntry::ItemAdd),
AppMode::ExportPrompt { .. } => Some(TextEntry::Export),
AppMode::SavePrompt { .. } => Some(TextEntry::Save),
AppMode::Normal
| AppMode::RecordsNormal
| AppMode::FormulaPanel
| AppMode::CategoryPanel
| AppMode::ViewPanel
| AppMode::TileSelect
| AppMode::ImportWizard
| AppMode::Help
| AppMode::Quit => None,
}
}
fn name(self) -> &'static str {
match self {
TextEntry::Edit => "edit",
TextEntry::Formula => "formula",
TextEntry::Command => "command",
TextEntry::CategoryAdd => "category-add",
TextEntry::ItemAdd => "item-add",
TextEntry::Export => "export",
TextEntry::Save => "save",
}
}
}
const AXES: [Axis; 5] = [
Axis::Row,
Axis::Column,
Axis::Page,
Axis::Filter,
Axis::None,
];
const FIELD_KINDS: [WizardFieldKind; 4] = [
WizardFieldKind::Category,
WizardFieldKind::Measure,
WizardFieldKind::TimeCategory,
WizardFieldKind::Label,
];
fn axis_name(axis: Axis) -> &'static str {
match axis {
Axis::Row => "row",
Axis::Column => "column",
Axis::Page => "page",
Axis::Filter => "filter",
Axis::None => "none",
}
}
fn field_kind_name(kind: WizardFieldKind) -> &'static str {
match kind {
WizardFieldKind::Category => "category",
WizardFieldKind::Measure => "measure",
WizardFieldKind::TimeCategory => "time",
WizardFieldKind::Label => "label",
}
}
/// A role something is drawn in. Its `name` is a compatibility surface once a
/// theme file carries it (ADR 004 clause 9), so never rename one.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum Face {
Default,
Dim,
Axis(Axis),
WizardField(WizardFieldKind),
Mode,
ModeResting,
ModeTextEntry,
ModeTextEntryOf(TextEntry),
ModePanel(Panel),
ModeOverlay,
ModeTransient,
ModeTileSelect,
MinibufferOf(TextEntry),
}
impl Face {
/// Every face, parents before children.
pub fn all() -> Vec<Face> {
let mut out = vec![Face::Default, Face::Dim];
out.extend(AXES.map(Face::Axis));
out.extend(FIELD_KINDS.map(Face::WizardField));
out.extend([Face::Mode, Face::ModeResting, Face::ModeTextEntry]);
out.extend(TextEntry::ALL.map(Face::ModeTextEntryOf));
out.extend(Panel::ALL.map(Face::ModePanel));
out.extend([Face::ModeOverlay, Face::ModeTransient, Face::ModeTileSelect]);
out.extend(TextEntry::ALL.map(Face::MinibufferOf));
out
}
/// The face this one inherits every attribute it does not state from.
pub fn parent(self) -> Option<Face> {
match self {
Face::Default => None,
Face::Dim | Face::Mode => Some(Face::Default),
Face::Axis(Axis::None) | Face::WizardField(WizardFieldKind::Label) => Some(Face::Dim),
Face::Axis(_) | Face::WizardField(_) => Some(Face::Default),
Face::ModeResting
| Face::ModeTextEntry
| Face::ModePanel(_)
| Face::ModeOverlay
| Face::ModeTransient
| Face::ModeTileSelect => Some(Face::Mode),
Face::ModeTextEntryOf(_) | Face::MinibufferOf(_) => Some(Face::ModeTextEntry),
}
}
/// The spelling a theme file keys this face by.
pub fn name(self) -> String {
match self {
Face::Default => "default".into(),
Face::Dim => "dim".into(),
Face::Axis(axis) => format!("axis.{}", axis_name(axis)),
Face::WizardField(kind) => format!("wizard.field.{}", field_kind_name(kind)),
Face::Mode => "mode".into(),
Face::ModeResting => "mode.resting".into(),
Face::ModeTextEntry => "mode.text-entry".into(),
Face::ModeTextEntryOf(entry) => format!("mode.text-entry.{}", entry.name()),
Face::ModePanel(panel) => format!("mode.panel.{}", panel.name()),
Face::ModeOverlay => "mode.overlay".into(),
Face::ModeTransient => "mode.transient".into(),
Face::ModeTileSelect => "mode.tile-select".into(),
Face::MinibufferOf(entry) => format!("minibuffer.{}", entry.name()),
}
}
pub fn from_name(name: &str) -> Option<Face> {
Face::all().into_iter().find(|face| face.name() == name)
}
/// The face the mode indicator is drawn in.
pub fn of_mode(mode: &AppMode) -> Face {
match mode {
AppMode::Normal | AppMode::RecordsNormal => Face::ModeResting,
AppMode::FormulaPanel => Face::ModePanel(Panel::Formula),
AppMode::CategoryPanel => Face::ModePanel(Panel::Category),
AppMode::ViewPanel => Face::ModePanel(Panel::View),
AppMode::ImportWizard | AppMode::Help => Face::ModeOverlay,
AppMode::Quit => Face::ModeTransient,
AppMode::TileSelect => Face::ModeTileSelect,
AppMode::Editing { .. }
| AppMode::RecordsEditing { .. }
| AppMode::FormulaEdit { .. }
| AppMode::CommandMode { .. }
| AppMode::CategoryAdd { .. }
| AppMode::ItemAdd { .. }
| AppMode::ExportPrompt { .. }
| AppMode::SavePrompt { .. } => TextEntry::of(mode)
.map(Face::ModeTextEntryOf)
.unwrap_or(Face::ModeTextEntry),
}
}
/// The face a text-entry mode's minibuffer is drawn in.
pub fn of_minibuffer(mode: &AppMode) -> Option<Face> {
TextEntry::of(mode).map(Face::MinibufferOf)
}
}
impl Serialize for Face {
fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(&self.name())
}
}
impl<'de> Deserialize<'de> for Face {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let name = String::deserialize(d)?;
Face::from_name(&name).ok_or_else(|| de::Error::custom(format!("unknown face {name:?}")))
}
}
/// What a theme states about one face. `None` inherits from the parent;
/// `Some(Colour::Default)` states "the surface" and does not inherit.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct FaceSpec {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub foreground: Option<Colour>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub background: Option<Colour>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub weight: Option<Weight>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub slant: Option<Slant>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub underline: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reverse: Option<bool>,
}
impl FaceSpec {
fn or(self, parent: FaceSpec) -> FaceSpec {
FaceSpec {
foreground: self.foreground.or(parent.foreground),
background: self.background.or(parent.background),
weight: self.weight.or(parent.weight),
slant: self.slant.or(parent.slant),
underline: self.underline.or(parent.underline),
reverse: self.reverse.or(parent.reverse),
}
}
fn fg(colour: Colour) -> FaceSpec {
FaceSpec {
foreground: Some(colour),
..FaceSpec::default()
}
}
fn bg(colour: Colour) -> FaceSpec {
FaceSpec {
background: Some(colour),
..FaceSpec::default()
}
}
}
/// A face with every attribute concrete: what a front end translates.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ResolvedFace {
pub foreground: Colour,
pub background: Colour,
pub weight: Weight,
pub slant: Slant,
pub underline: bool,
pub reverse: bool,
}
/// Faces bound to values. Client-local: never a `Command`, never in
/// `ViewState`, never projected, never in `.improv` (ADR 004 clause 7).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Theme {
pub faces: BTreeMap<Face, FaceSpec>,
}
impl Theme {
/// The theme improvise ships: today's terminal rendering, palette-relative
/// throughout, so the user's terminal theme still applies.
pub fn shipped() -> Theme {
use AnsiSlot::*;
let named = Colour::Named;
let minibuffer = |accent| FaceSpec {
foreground: Some(named(accent)),
background: Some(Colour::Indexed(235)),
weight: Some(Weight::Bold),
..FaceSpec::default()
};
let faces = [
(Face::Dim, FaceSpec::fg(named(BrightBlack))),
(Face::Axis(Axis::Row), FaceSpec::fg(named(Green))),
(Face::Axis(Axis::Column), FaceSpec::fg(named(Blue))),
(Face::Axis(Axis::Page), FaceSpec::fg(named(Magenta))),
(Face::Axis(Axis::Filter), FaceSpec::fg(named(Yellow))),
(
Face::WizardField(WizardFieldKind::Category),
FaceSpec::fg(named(Green)),
),
(
Face::WizardField(WizardFieldKind::Measure),
FaceSpec::fg(named(Cyan)),
),
(
Face::WizardField(WizardFieldKind::TimeCategory),
FaceSpec::fg(named(Magenta)),
),
(
Face::Mode,
FaceSpec {
foreground: Some(named(Black)),
background: Some(named(BrightBlack)),
..FaceSpec::default()
},
),
(
Face::ModeTextEntryOf(TextEntry::Edit),
FaceSpec::bg(named(Green)),
),
(
Face::ModeTextEntryOf(TextEntry::Command),
FaceSpec::bg(named(Yellow)),
),
(Face::ModeTileSelect, FaceSpec::bg(named(Magenta))),
(Face::MinibufferOf(TextEntry::Edit), minibuffer(Green)),
(Face::MinibufferOf(TextEntry::ItemAdd), minibuffer(Green)),
(
Face::MinibufferOf(TextEntry::CategoryAdd),
minibuffer(Green),
),
(Face::MinibufferOf(TextEntry::Formula), minibuffer(Cyan)),
(Face::MinibufferOf(TextEntry::Command), minibuffer(Yellow)),
(Face::MinibufferOf(TextEntry::Export), minibuffer(Yellow)),
(Face::MinibufferOf(TextEntry::Save), minibuffer(Yellow)),
];
Theme {
faces: faces.into_iter().collect(),
}
}
/// Walk `face`'s inheritance chain once, so no front end ever does.
pub fn resolve(&self, face: Face) -> ResolvedFace {
let stated = std::iter::successors(Some(face), |f| f.parent())
.filter_map(|f| self.faces.get(&f).copied())
.fold(FaceSpec::default(), FaceSpec::or);
ResolvedFace {
foreground: stated.foreground.unwrap_or(Colour::Default),
background: stated.background.unwrap_or(Colour::Default),
weight: stated.weight.unwrap_or(Weight::Normal),
slant: stated.slant.unwrap_or(Slant::Normal),
underline: stated.underline.unwrap_or(false),
reverse: stated.reverse.unwrap_or(false),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_face_name_is_distinct_and_reads_back_as_that_face() {
let all = Face::all();
for face in &all {
assert_eq!(Face::from_name(&face.name()), Some(*face), "{face:?}");
}
let mut names: Vec<String> = all.iter().map(|f| f.name()).collect();
names.sort();
names.dedup();
assert_eq!(names.len(), all.len(), "two faces share a name");
}
#[test]
fn every_parent_is_a_face_listed_before_its_child() {
let all = Face::all();
for (i, face) in all.iter().enumerate() {
if let Some(parent) = face.parent() {
let at = all.iter().position(|f| *f == parent);
assert!(at.is_some_and(|p| p < i), "{face:?}'s parent {parent:?}");
}
}
}
/// ADR 004 clause 4: the shipped theme keeps the user's terminal palette.
#[test]
fn the_shipped_theme_states_no_absolute_colour() {
let theme = Theme::shipped();
for face in Face::all() {
let resolved = theme.resolve(face);
assert!(!resolved.foreground.is_absolute(), "{}", face.name());
assert!(!resolved.background.is_absolute(), "{}", face.name());
}
}
/// ADR 004: `minibuffer.*` inherits `mode.text-entry` directly, so a theme
/// that restyles text entry reaches every prompt. `context/test-evidence/protocol.md`.
#[test]
fn a_theme_that_restyles_text_entry_reaches_every_minibuffer() {
let mut theme = Theme::shipped();
let italic = FaceSpec {
slant: Some(Slant::Italic),
..FaceSpec::default()
};
theme.faces.insert(Face::ModeTextEntry, italic);
for entry in TextEntry::ALL {
let got = theme.resolve(Face::MinibufferOf(entry));
assert_eq!(got.slant, Slant::Italic, "{entry:?}");
}
}
/// A collision defeats the reason the axis faces exist (improvise-rml).
#[test]
fn every_axis_gets_its_own_accent_in_the_shipped_theme() {
let theme = Theme::shipped();
let accents = AXES.map(|axis| theme.resolve(Face::Axis(axis)));
for (i, a) in accents.iter().enumerate() {
for b in &accents[i + 1..] {
assert_ne!(a, b, "two axes share an accent");
}
}
}
/// A child overrides what it states and inherits what it does not, from
/// the nearest ancestor that states it — `context/test-evidence/protocol.md`.
#[test]
fn resolution_takes_each_attribute_from_the_nearest_face_that_states_it() {
let named = Colour::Named;
let theme = Theme {
faces: [
(
Face::Default,
FaceSpec {
foreground: Some(named(AnsiSlot::White)),
slant: Some(Slant::Italic),
..FaceSpec::default()
},
),
(
Face::Mode,
FaceSpec {
foreground: Some(named(AnsiSlot::Black)),
background: Some(named(AnsiSlot::Blue)),
..FaceSpec::default()
},
),
(
Face::ModeTextEntryOf(TextEntry::Save),
FaceSpec {
background: Some(Colour::Default),
underline: Some(true),
..FaceSpec::default()
},
),
]
.into_iter()
.collect(),
};
let got = theme.resolve(Face::ModeTextEntryOf(TextEntry::Save));
assert_eq!(
got,
ResolvedFace {
foreground: named(AnsiSlot::Black),
background: Colour::Default,
weight: Weight::Normal,
slant: Slant::Italic,
underline: true,
reverse: false,
}
);
let sibling = theme.resolve(Face::ModeTextEntryOf(TextEntry::Export));
assert_eq!(sibling.background, named(AnsiSlot::Blue));
}
#[test]
fn a_theme_survives_its_own_json() {
let theme = Theme::shipped();
let text = serde_json::to_string_pretty(&theme).unwrap();
assert!(text.contains("\"minibuffer.category-add\""), "{text}");
assert_eq!(serde_json::from_str::<Theme>(&text).unwrap(), theme);
}
#[test]
fn a_colour_is_spelled_for_a_person_editing_the_file() {
let cases = [
(Colour::Default, "\"default\""),
(Colour::Named(AnsiSlot::BrightBlack), "\"bright-black\""),
(Colour::Indexed(235), "235"),
(Colour::Rgb(0x3f, 0x7a, 0x4d), "\"#3f7a4d\""),
];
for (colour, json) in cases {
assert_eq!(serde_json::to_string(&colour).unwrap(), json);
assert_eq!(serde_json::from_str::<Colour>(json).unwrap(), colour);
}
for bad in ["\"grene\"", "\"#3f7a4\"", "256", "\"#gggggg\""] {
assert!(serde_json::from_str::<Colour>(bad).is_err(), "{bad}");
}
}
#[test]
fn a_misspelt_face_or_attribute_is_refused_rather_than_ignored() {
let face = r#"{"faces":{"axis.rwo":{"foreground":"green"}}}"#;
assert!(serde_json::from_str::<Theme>(face).is_err());
let attr = r#"{"faces":{"axis.row":{"forground":"green"}}}"#;
assert!(serde_json::from_str::<Theme>(attr).is_err());
}
#[test]
fn every_text_entry_mode_has_a_text_entry_role_and_no_other_mode_does() {
for mode in AppMode::all() {
assert_eq!(
TextEntry::of(&mode).is_some(),
mode.minibuffer().is_some(),
"{mode:?}"
);
}
}
}
+4 -2
View File
@@ -6,6 +6,7 @@ use std::collections::HashMap;
pub mod basis;
pub mod client;
pub mod face;
pub mod frame;
pub mod help;
pub mod key;
@@ -23,6 +24,7 @@ mod vocabulary_is_composed;
pub use basis::{Basis, BasisCache, Rejection};
pub use client::{ClientState, Sent};
pub use face::{Face, Theme};
pub use frame::{ClientFrame, ServerFrame};
pub use help::{HelpLine, Hint, hint_line, hints, mode_label};
pub use key::{KeyCode, KeyEvent, KeyModifiers};
@@ -103,7 +105,7 @@ pub struct MinibufferConfig {
/// One of the three side panels. Paired with [`AppMode::panel`], which names
/// the panel a mode is focused in.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
pub enum Panel {
Formula,
Category,
@@ -394,7 +396,7 @@ impl AppMode {
minibuf: MinibufferConfig {
buffer_key: "category".into(),
prompt: "new category: ".into(),
color: MinibufColor::Yellow,
color: MinibufColor::Green,
},
}
}
+71 -18
View File
@@ -214,6 +214,9 @@ pub struct FrameGroup {
/// Which header level opened it — an index into [`AxisFrame::cats`]. Runs
/// nest, so membership is per level (improvise-e265).
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 collapsed: bool,
}
@@ -223,10 +226,11 @@ pub struct FrameGroup {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum FrameEntry {
Group(FrameGroup),
/// The run at `level`, and every run inside it, ends here — `AxisEntry::GroupEnd`
/// on the wire (improvise-gd83, e265). Nothing is drawn for it.
/// The run at `(level, depth)`, and every run inside it, ends here —
/// `AxisEntry::GroupEnd` on the wire (improvise-gd83, e265). Nothing is drawn.
GroupEnd {
level: usize,
depth: usize,
},
/// One label per header level, outermost first. Data indices — the
/// `row`/`col` of every other field on [`GridCache`] — count only these.
@@ -279,7 +283,7 @@ impl TryFrom<UncheckedAxisFrame> for AxisFrame {
.iter()
.filter_map(|e| match e {
FrameEntry::Group(g) => Some(g.level),
FrameEntry::GroupEnd { level } => Some(*level),
FrameEntry::GroupEnd { level, .. } => Some(*level),
FrameEntry::Item { .. } => None,
})
.find(|level| *level >= raw.cats.len());
@@ -418,20 +422,22 @@ impl AxisFrame {
.collect()
}
/// The innermost group each data item sits in, in order — one pass. A
/// group at L closes deeper runs; so does a `GroupEnd` at L (improvise-e265).
/// The innermost group each data item sits in, in order — one pass. A group
/// at `(level, depth)` closes deeper runs; so does a `GroupEnd` there (§3a).
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();
for entry in &self.entries {
match entry {
FrameEntry::Group(g) => {
open.truncate(g.level);
open.resize(g.level, None);
open.push(Some(g));
close(&mut open, (g.level, g.depth));
open.push(g);
}
FrameEntry::GroupEnd { level } => open.truncate(*level),
FrameEntry::Item { .. } => out.push(open.iter().rev().find_map(|g| *g)),
FrameEntry::GroupEnd { level, depth } => close(&mut open, (*level, *depth)),
FrameEntry::Item { .. } => out.push(open.last().copied()),
}
}
out
@@ -927,7 +933,7 @@ pub enum WizardStepKind {
/// What kind of thing a proposed field is. The semantic half of a colour
/// decision; the terminal mapping lives in `draw.rs` (gotcha 26).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
pub enum WizardFieldKind {
Category,
Measure,
@@ -1349,6 +1355,7 @@ mod tests {
FrameEntry::item(vec!["A".into(), "y".into()]),
FrameEntry::Group(FrameGroup {
level: 0,
depth: 0,
name: "Late".into(),
collapsed: true,
}),
@@ -1362,6 +1369,7 @@ mod tests {
fn repeated_group_frame() -> AxisFrame {
let q1 = || FrameGroup {
level: 1,
depth: 0,
name: "Q1".into(),
collapsed: false,
};
@@ -1388,12 +1396,13 @@ mod tests {
entries: vec![
FrameEntry::Group(FrameGroup {
level: 0,
depth: 0,
name: "Q1".into(),
collapsed: false,
}),
FrameEntry::item(vec!["Jan".into()]),
FrameEntry::item(vec!["Feb".into()]),
FrameEntry::GroupEnd { level: 0 },
FrameEntry::GroupEnd { level: 0, depth: 0 },
FrameEntry::item(vec!["Sep".into()]),
],
}
@@ -1406,6 +1415,7 @@ mod tests {
let group = |level: usize, name: &str| {
FrameEntry::Group(FrameGroup {
level,
depth: 0,
name: name.into(),
collapsed: true,
})
@@ -1416,7 +1426,7 @@ mod tests {
entries: vec![
group(0, "Upper"),
group(1, "Q1"),
FrameEntry::GroupEnd { level: 1 },
FrameEntry::GroupEnd { level: 1, depth: 0 },
],
};
let json = serde_json::to_string(&frame).unwrap();
@@ -1424,10 +1434,13 @@ mod tests {
assert_eq!(back, frame);
for (from, to) in [
(
r#""level":1,"name":"Q1""#,
r#""level":4294967295,"name":"Q1""#,
r#""level":1,"depth":0,"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);
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.
/// See `context/test-evidence/protocol.md`.
#[test]
@@ -1467,10 +1519,11 @@ mod tests {
entries: vec![
FrameEntry::Group(FrameGroup {
level: 0,
depth: 0,
name: "Q1".into(),
collapsed: true,
}),
FrameEntry::GroupEnd { level: 0 },
FrameEntry::GroupEnd { level: 0, depth: 0 },
FrameEntry::item(vec!["Sep".into()]),
],
};
+3 -2
View File
@@ -75,8 +75,8 @@ pub struct RenderModel {
pub struct RenderMinibuffer {
pub prompt: String,
pub value: String,
/// Semantic, as `MinibufColor` is: the last translation to a CSS class is
/// the page's, exactly as `draw.rs::minibuf_fg` is the TUI's (gotcha 26).
/// A colour, not a role: the TUI now draws a face per text-entry mode, and
/// improvise-nn4s.4 moves the page onto the same faces (ADR 004).
pub color: improvise_protocol::MinibufColor,
}
@@ -591,6 +591,7 @@ mod tests {
fn group(name: &str, collapsed: bool) -> FrameEntry {
FrameEntry::Group(improvise_protocol::FrameGroup {
level: 0,
depth: 0,
name: name.into(),
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`.
wb.add_item("Region", "West").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.active_view_mut().set_axis("Region", Axis::Row);
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")] {
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", "Cost").unwrap();
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("_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);
app.rebuild_layout();
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 mut app = fold_host();
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
};
@@ -3543,20 +3544,22 @@ fn nested_groups_host(coast_collapsed: bool, kit_collapsed: bool) -> App {
for item in ["East", "West"] {
wb.add_item_in_group("Region", item, "Coast").unwrap();
}
wb.active_view_mut().set_expand_depth("Region", 1);
wb.add_item("Product", "Widget").unwrap();
for item in ["Gadget", "Gizmo"] {
wb.add_item_in_group("Product", item, "Kit").unwrap();
}
wb.active_view_mut().set_expand_depth("Product", 1);
wb.add_item("_Measure", "Revenue").unwrap();
let view = wb.active_view_mut();
view.set_axis("Region", Axis::Row);
view.set_axis("Product", Axis::Row);
view.set_axis("_Measure", Axis::Column);
if coast_collapsed {
view.toggle_group_collapse("Region", "Coast");
view.toggle_group_collapse("Region", &["Coast"]);
}
if kit_collapsed {
view.toggle_group_collapse("Product", "Kit");
view.toggle_group_collapse("Product", &["Kit"]);
}
let mut app = App::new(wb, None);
app.term_height = 12 + 8;
@@ -3614,11 +3617,12 @@ fn folded_host() -> App {
for item in ["East", "West"] {
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.active_view_mut().set_axis("Region", Axis::Row);
wb.active_view_mut().set_axis("_Measure", Axis::Column);
wb.active_view_mut()
.toggle_group_collapse("Region", "Coast");
.toggle_group_collapse("Region", &["Coast"]);
let mut app = App::new(wb, None);
app.term_height = 4 + 8;
app.rebuild_layout();
Generated
+6 -6
View File
@@ -297,11 +297,11 @@
},
"nixpkgs_4": {
"locked": {
"lastModified": 1790046670,
"narHash": "sha256-MYiI+CzL0tuWgRPjGsKCDHqYs2T3OzMlMQWOYWG0qso=",
"lastModified": 1790185690,
"narHash": "sha256-xJ+X4hBtOcAFGBOe5nAMyMUeF9foJBmIOu3NjBqBycU=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "6774f7bc253789b113a4f39285dc0fa100abeacc",
"rev": "4975466d324710c576dc11ad614684e6bd8cad8e",
"type": "github"
},
"original": {
@@ -368,11 +368,11 @@
"nixpkgs": "nixpkgs_5"
},
"locked": {
"lastModified": 1790062494,
"narHash": "sha256-Mds1vuoczq1fRm1rf+Iar8IKDBHNjOMo2JqqPWJUlng=",
"lastModified": 1790234952,
"narHash": "sha256-tjQa1vDoHJir4lasZTakz/xgmsfB6SRjeQqaIbefFOk=",
"owner": "oxalica",
"repo": "rust-overlay",
"rev": "b88764907c1b2de6c0e206d8d9d9634d91c81334",
"rev": "ed34466c59b766481248959a21a6e46cf0c60468",
"type": "github"
},
"original": {
+22 -3
View File
@@ -47,13 +47,13 @@
wasmBindgenCli = pkgs.buildWasmBindgenCli rec {
src = pkgs.fetchCrate {
pname = "wasm-bindgen-cli";
version = "0.2.128";
hash = "sha256-a7lcXJnnZkYReja+iUO7NqqrWyv3toxnUgQb8s4IS5s=";
version = "0.2.129";
hash = "sha256-pcecKQd7E8Opw6bkFoE569epUi7gh5qpQF1e5PJY6V8=";
};
cargoDeps = pkgs.rustPlatform.fetchCargoVendor {
inherit src;
inherit (src) pname version;
hash = "sha256-R1Tas33Ursy8kqsxguAkG0ZhNed2n5uFTAhw1l2qlLY=";
hash = "sha256-vmUrWVU7kPJJxO5qIVeAkwQyWDELO1Z4Z5gitz2kco8=";
};
};
@@ -85,6 +85,17 @@
pkgs.jq
];
# scripts/repo-stats runs code-maat (improvise-1xfb.5.1). The JRE is about
# 0.6 GB, so it is kept out of ciInputs; only `nix-build` takes it.
codeMaatJar = pkgs.fetchurl {
url = "https://github.com/adamtornhill/code-maat/releases/download/v1.0.4/code-maat-1.0.4-standalone.jar";
hash = "sha256-QoeuIDSQGERFD3aVR7xEl6DaGm0cf6b63IWHBeZ0O18=";
};
codeMaat = pkgs.writeShellScriptBin "code-maat" ''
exec ${pkgs.jre_headless}/bin/java -jar ${codeMaatJar} "$@"
'';
# Playwright's downloaded browsers are generic-Linux builds that cannot
# start on NixOS, so nixpkgs' build is used. harness/package.json pins
# @playwright/test to this playwright-driver's version: they must match.
@@ -114,6 +125,7 @@
pkgs.global
pkgs.universal-ctags
beads.packages.${system}.default
codeMaat
# Demo recording and release tooling
pkgs.asciinema
@@ -129,6 +141,13 @@
// {
improvise = cargoNix.workspaceMembers.improvise.build;
default = self.packages.${system}.improvise;
code-maat = codeMaat;
# What the repo-stats pipeline runs besides the ci shell: one
# installable, because check-ci-graph reads `nix shell <one> --command`.
repo-stats-tools = pkgs.symlinkJoin {
name = "repo-stats-tools";
paths = [self.packages.${system}.improvise codeMaat];
};
};
# `nix flake check` builds every CI build: crane's checks plus the
+2
View File
@@ -11,6 +11,8 @@ _Measure: page, Revenue
_Index: none
_Dim: none
collapsed: Region/Upland
expanded(1): Month
expanded(1): Region
format: ,.0
## Category: Region
+2
View File
@@ -8,6 +8,7 @@ _Dim: none
_Measure: page
Region: row
format: ,.0
expanded(1): Region
## View: Tall
_Index: none
@@ -15,6 +16,7 @@ _Dim: none
_Measure: column
Region: row
format: ,.0
expanded(1): Region
## Category: _Measure
- Revenue, Cost
+24 -14
View File
@@ -239,9 +239,9 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "js-sys"
version = "0.3.105"
version = "0.3.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e"
checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5"
dependencies = [
"cfg-if",
"futures-util",
@@ -472,6 +472,15 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "tokio"
version = "1.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"
dependencies = [
"pin-project-lite",
]
[[package]]
name = "ucd-trie"
version = "0.1.7"
@@ -492,9 +501,9 @@ checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
[[package]]
name = "wasm-bindgen"
version = "0.2.128"
version = "0.2.129"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf"
checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409"
dependencies = [
"cfg-if",
"once_cell",
@@ -505,19 +514,20 @@ dependencies = [
[[package]]
name = "wasm-bindgen-futures"
version = "0.4.78"
version = "0.4.79"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ef4c5d3d2cdf5c54f4231181768f5510842e350db025faf1f7163b1030ed928"
checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e"
dependencies = [
"js-sys",
"tokio",
"wasm-bindgen",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.128"
version = "0.2.129"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed"
checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
@@ -525,9 +535,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.128"
version = "0.2.129"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a"
checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7"
dependencies = [
"bumpalo",
"proc-macro2",
@@ -538,18 +548,18 @@ dependencies = [
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.128"
version = "0.2.129"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e"
checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6"
dependencies = [
"unicode-ident",
]
[[package]]
name = "web-sys"
version = "0.3.105"
version = "0.3.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9fbddc4a036f00ec4f18c83445bd3115cb306a91da554919a099d9222fe4a7f8"
checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4"
dependencies = [
"js-sys",
"wasm-bindgen",
+2
View File
@@ -101,6 +101,8 @@ ci_suites() {
case "$1" in
build) echo assemble-stamp ;;
js) echo node-test ;;
# Needs improvise and code-maat, which only this job has (tooling.md section 7).
nix-build) echo repo-stats ;;
# check-harness-lock must never read "could not resolve" as "stale" (improvise-zs2r).
kb) echo kb checkers pr-watch release-preflight pr-reviews fmt-workspaces bd-audit-ids check-harness-lock agent-git ;;
*) return 1 ;;
+321
View File
@@ -0,0 +1,321 @@
#!/usr/bin/env python3
"""Churn, coupling and doc-growth report over a window of history.
code-maat's analyses and this script's per-file churn go into one directory of
CSVs; improvise imports them into one model and computes the report's tables.
See context/code/tooling.md section 7.
"""
import argparse
import csv
import datetime
import json
import os
import re
import subprocess
import sys
import tempfile
# First match wins, so the order is part of the definition.
AREAS = [
("generated", re.compile(r"^(\.beads/|site/|Cargo\.nix$|roadmap\.org$)"
r"|\.(lock|cast)$|(^|/)package-lock\.json$")),
("repo-map", re.compile(r"^context/repo-map\.md$")),
("kb.pl", re.compile(r"^prolog-sessions/kb\.pl$")),
("context/code", re.compile(r"^context/code/")),
("context/test-evidence", re.compile(r"^context/test-evidence/")),
("other context", re.compile(r"^context/")),
("other docs", re.compile(r"^(docs/.*\.md|README\.md|RELEASING\.md)$")),
("agent docs and skills", re.compile(r"^(AGENTS\.md|CLAUDE\.md|\.agents/|\.claude/|\.codex/)")),
("scripts/CI", re.compile(r"^(scripts/|\.github/|nix/|flake\.nix$)")),
("web and harness", re.compile(r"^(web|harness)/")),
("rust tests", re.compile(r"(^|/)(tests|benches)/.*\.rs$|(^|/|_)tests?\.rs$")),
("rust src", re.compile(r"\.(rs|pest)$")),
]
OTHER = "other"
PROSE = {"context/code", "context/test-evidence", "other context", "other docs", "repo-map",
"agent docs and skills"}
CODE = {"rust src", "rust tests", "scripts/CI", "web and harness"}
AGENT_RULES = "agent docs and skills"
# One CSV per code-maat analysis, plus churn.csv. Each stem becomes an item of
# the model's File category, which the .improvise script pages on.
ANALYSES = ["coupling", "abs-churn", "age", "soc"]
CATEGORIES = ["commit", "date", "week", "entity", "area", "kind", "coupled",
"crosses-crate", "agent-rules"]
CHURN_HEADER = ["commit", "date", "week", "entity", "area", "kind", "added", "deleted"]
MEASURES = ["added", "deleted", "commits", "degree", "average-revs", "age-months", "soc"]
TABLES = ["by-area", "by-kind", "area-by-week", "by-day"]
HERE = os.path.dirname(os.path.abspath(__file__))
class ImproviseFailed(Exception):
"""improvise refused the pipeline's input: a product regression, not a statistic."""
def area_of(path):
for name, rx in AREAS:
if rx.search(path):
return name
return OTHER
def kind_of(area):
return "prose" if area in PROSE else "code" if area in CODE else "other"
def crate_of(path):
"""The crate a path belongs to, or None for a path in no crate."""
m = re.match(r"^crates/([^/]+)/", path)
if m:
return m.group(1)
if path.startswith("harness/opfs-contract/"):
return "opfs-contract"
if re.match(r"^(src|tests|benches|examples)/", path) or path == "Cargo.toml":
return "improvise"
return None
def git(repo, *args):
return subprocess.run(["git", "-C", repo, *args], capture_output=True, text=True,
check=True).stdout
def resolve_ref(repo, ref):
"""The ref asked for; the default gh/main falls back to HEAD where it is absent."""
for r in ([ref] if ref else ["gh/main", "HEAD"]):
out = subprocess.run(["git", "-C", repo, "rev-parse", "--verify", "--quiet",
r + "^{commit}"], capture_output=True, text=True)
if out.returncode == 0:
return r, out.stdout.strip()
sys.exit(f"repo-stats: cannot resolve {ref or 'gh/main or HEAD'}")
def git2_log(repo, sha, since):
"""The log in code-maat's git2 format: the ref's history, without merges."""
return git(repo, "log", sha, "--no-merges", f"--since={since}", "--numstat",
"--date=short", "--pretty=format:--%h--%ad--%aN", "--no-renames")
def is_header(line):
return line.startswith("--") and line.count("--") >= 3
def churn_rows(log):
"""One row per (commit, file). A commit is written `g<sha>`, because improvise
reads an all-digit or exponent-shaped sha as a number (tooling.md section 7)."""
rows, commit, date = [], None, None
for line in log.split("\n"):
if is_header(line):
_, commit, date, _ = line.split("--", 3)
elif "\t" in line:
a, d, path = line.split("\t", 2)
year, week, _ = datetime.date.fromisoformat(date).isocalendar()
area = area_of(path)
rows.append({"commit": "g" + commit, "date": date,
"week": f"{year}-W{week:02d}", "entity": path, "area": area,
"kind": kind_of(area), "added": 0 if a == "-" else int(a),
"deleted": 0 if d == "-" else int(d)})
return rows
def write_csv(path, header, rows):
with open(path, "w", newline="") as f:
w = csv.DictWriter(f, fieldnames=header)
w.writeheader()
w.writerows(rows)
def read_csv(path):
with open(path, newline="") as f:
return list(csv.DictReader(f))
def code_maat(logfile, analysis, out, *extra):
subprocess.run(["code-maat", "-l", logfile, "-c", "git2", "-a", analysis,
"-o", out, *extra], capture_output=True, text=True, check=True)
def flag_coupling(path):
"""code-maat's coupling.csv, plus the two flags code-maat has no notion of."""
rows = read_csv(path)
for r in rows:
a, b = crate_of(r["entity"]), crate_of(r["coupled"])
r["crosses-crate"] = "yes" if a and b and a != b else "no"
both = area_of(r["entity"]) == area_of(r["coupled"]) == AGENT_RULES
r["agent-rules"] = "yes" if both else "no"
write_csv(path, ["entity", "coupled", "degree", "average-revs", "crosses-crate",
"agent-rules"], rows)
def build_dir(out, log, rows, min_revs, today):
"""The directory improvise imports: churn.csv and one CSV per analysis."""
with tempfile.NamedTemporaryFile("w", suffix=".log", delete=False) as f:
f.write(log + "\n")
try:
revs = ["--min-revs", str(min_revs), "--min-shared-revs", str(min_revs)]
extra = {"coupling": revs, "age": ["--age-time-now", today]}
for a in ANALYSES:
code_maat(f.name, a, os.path.join(out, f"{a}.csv"), *extra.get(a, []))
finally:
os.unlink(f.name)
flag_coupling(os.path.join(out, "coupling.csv"))
def improvise(out, *args):
exe = os.environ.get("IMPROVISE", "improvise")
p = subprocess.run([exe, *args], cwd=out, capture_output=True, text=True)
if p.returncode != 0 or "warning:" in p.stderr:
raise ImproviseFailed(f"improvise {' '.join(args)} exited {p.returncode}:\n"
f"{p.stderr}")
def run_improvise(out):
"""Import the directory into one model, a view per file included, then build
the report's views and export them."""
flags = [f"--category={c}" for c in CATEGORIES] + [f"--measure={m}" for m in MEASURES]
improvise(out, "import", ".", "--no-wizard", *flags, "--name=Repo stats",
"--formula=DeletionRatio = deleted / added FORMAT .3",
"-o", "repo-stats.improv")
os.makedirs(os.path.join(out, "report"), exist_ok=True)
improvise(out, "script", os.path.join(HERE, "repo-stats.improvise"),
"-f", "repo-stats.improv")
return {t: read_rows(os.path.join(out, "report", f"{t}.csv")) for t in TABLES}
def read_rows(path):
with open(path, newline="") as f:
return list(csv.reader(f))
def gotchas(text):
"""(number, lines, chars) for each numbered item of repo-map's Gotchas section."""
out, cur, inside = [], None, False
for line in text.split("\n"):
if line.startswith("## "):
inside = line.startswith("## Gotchas")
continue
if inside and line.startswith("---"):
inside = False
if not inside:
continue
m = re.match(r"^(\d+)\. ", line)
if m:
cur = [int(m.group(1)), 0, 0]
out.append(cur)
if cur is not None and line.strip():
cur[1] += 1
cur[2] += len(line)
return [tuple(g) for g in out]
def doc_sizes(repo, sha):
files = [p for p in git(repo, "ls-tree", "-r", "--name-only", sha, "--",
"context").split("\n") if p.endswith(".md")]
show = lambda p: git(repo, "show", f"{sha}:{p}")
lines = {p: len(show(p).splitlines()) for p in sorted(files)}
gs = gotchas(show("context/repo-map.md")) if "context/repo-map.md" in files else []
longest = max(gs, key=lambda g: g[2], default=None)
evidence = {p: sum(1 for l in show(p).split("\n") if re.match(r"^#{3,4} ", l))
for p in sorted(files) if p.startswith("context/test-evidence/")}
return {
"context_lines": lines,
"repo_map": {"gotchas": len(gs), "longest": longest and
{"number": longest[0], "lines": longest[1], "chars": longest[2]}},
"test_evidence": {"entries": sum(evidence.values()), "per_file": evidence},
}
def report(repo, ref, since, min_revs, out):
name, sha = resolve_ref(repo, ref)
log = git2_log(repo, sha, since)
commits = sum(1 for line in log.split("\n") if is_header(line))
today = git(repo, "show", "-s", "--date=short", "--format=%cd", sha).strip()
r = {"ref": name, "sha": sha, "since": since, "commits": commits, "out": out,
"docs": doc_sizes(repo, sha), "tables": {}, "coupling": [], "soc": [], "age": []}
rows = churn_rows(log)
write_csv(os.path.join(out, "churn.csv"), CHURN_HEADER, rows)
# Empty commits alone change no file: nothing to analyse, so no model is built.
if rows:
build_dir(out, log, rows, min_revs, today)
r["tables"] = run_improvise(out)
for a in ("coupling", "soc", "age"):
r[a] = read_csv(os.path.join(out, f"{a}.csv"))
return r
def table(rows):
"""A CSV improvise exported, as aligned text; the first column left-aligned."""
n = max(len(r) for r in rows)
widths = [max(len(r[i]) for r in rows if i < len(r)) for i in range(n)]
return [(" " + " ".join((c.ljust if i == 0 else c.rjust)(widths[i])
for i, c in enumerate(r))).rstrip() for r in rows]
TITLES = {
"by-area": "Churn by area (improvise)",
"by-kind": "Prose, code and other; ProseToCode = prose / code (improvise)",
"area-by-week": "Lines added by area and ISO week (improvise)",
"by-day": "Lines added per day, churn.csv beside code-maat's abs-churn (improvise)",
}
def text(r, top):
out = [f"repo-stats: {r['ref']} ({r['sha'][:10]}), since {r['since']}, "
f"{r['commits']} non-merge commits"]
if not r["tables"]:
out += ["", "No file changed in the window, so no model was built."]
for t in TABLES:
if r["tables"].get(t):
out += ["", TITLES[t]] + table(r["tables"][t])
out += ["", "Doc sizes (lines)"]
out += [f" {n:>6} {path}" for path, n in r["docs"]["context_lines"].items()]
rm, te = r["docs"]["repo_map"], r["docs"]["test_evidence"]
lg = rm["longest"]
out.append(f" repo-map gotchas: {rm['gotchas']}" + (
f"; longest is {lg['number']} ({lg['lines']} lines, {lg['chars']} chars)"
if lg else ""))
out.append(f" test-evidence entries: {te['entries']}")
out += ["", f"Change coupling (code-maat; top {top})"]
for c in r["coupling"][:top]:
flags = [f for f, k in (("CROSSES-CRATE", "crosses-crate"),
("AGENT-RULES", "agent-rules")) if c[k] == "yes"]
out.append(f" {c['degree']:>4}% {c['average-revs']:>4} revs {c['entity']}"
f" <-> {c['coupled']}" + (f" [{', '.join(flags)}]" if flags else ""))
out += ["", f"Sum of coupling (code-maat; top {top})"]
out += [f" {s['soc']:>6} {s['entity']}" for s in r["soc"][:top]]
out += ["", f"Code age in months (code-maat; first {top})"]
out += [f" {a['age-months']:>4} {a['entity']}" for a in r["age"][:top]]
return "\n".join(out)
def main():
ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
ap.add_argument("--ref", help="the ref to report on (default gh/main, else HEAD)")
ap.add_argument("--since", default="7 days ago", help="git --since (default 7 days ago)")
ap.add_argument("--min-revs", type=int, default=5,
help="code-maat --min-revs and --min-shared-revs (default 5)")
ap.add_argument("--top", type=int, default=15, help="rows per ranked table")
ap.add_argument("--out", default="repo-stats",
help="the directory for the CSVs, the model and its exports")
ap.add_argument("--json", action="store_true", help="emit JSON instead of text")
ap.add_argument("-C", dest="repo", default=".", help="the repository (default .)")
a = ap.parse_args()
os.makedirs(a.out, exist_ok=True)
ours = {f"{n}.csv" for n in ["churn"] + ANALYSES}
foreign = sorted(f for f in os.listdir(a.out) if f.lower().endswith(".csv") and f not in ours)
if foreign:
print(f"repo-stats: {a.out} holds CSVs this script did not write, and "
f"`improvise import` would merge them: {', '.join(foreign)}", file=sys.stderr)
return 2
try:
r = report(a.repo, a.ref, a.since, a.min_revs, os.path.abspath(a.out))
except ImproviseFailed as e:
print(f"repo-stats: {e}", file=sys.stderr)
return 3
print(json.dumps(r, indent=2) if a.json else text(r, a.top))
return 0
if __name__ == "__main__":
sys.exit(main())
+233
View File
@@ -0,0 +1,233 @@
#!/usr/bin/env bash
# check_the_checker for scripts/repo-stats (improvise-1xfb.5.1): the whole pipeline,
# code-maat and improvise included, over a fixture repo whose every number is known.
# See context/test-evidence/tooling.md.
set -uo pipefail
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
STATS="$here/repo-stats"
work="$(mktemp -d)"
trap 'rm -rf "$work"' EXIT
pass=0; fail=0
ok() { printf ' ok %s\n' "$1"; pass=$((pass+1)); }
bad() { printf ' FAIL %s (%s)\n' "$1" "$2"; fail=$((fail+1)); }
# The improvise under test: on PATH in CI, else this tree's debug build. No build
# here: checkers.sh scrubs the linker's environment (context/code/tooling.md §7).
if command -v improvise >/dev/null; then
IMPROVISE="$(command -v improvise)"
elif [ -x "$here/../target/debug/improvise" ]; then
IMPROVISE="$here/../target/debug/improvise"
else
echo "repo-stats-test: no improvise; run \`cargo build --bin improvise\` first" >&2
exit 1
fi
export IMPROVISE
# $1 name, $2 jq expression over $work/out.json, $3 the value it must print
is() {
local got
got="$(jq -c "$2" "$work/out.json" 2>&1)"
[ "$got" = "$3" ] && ok "$1" || bad "$1" "got $got, wanted $3"
}
# $1 name, $2 table, $3 row label, $4 the row's cells after the label, as JSON
row() {
is "$1" ".tables[\"$2\"][]|select(.[0]==\"$3\")|.[1:]" "$4"
}
# $1 name, $2 fixed-string the text report must contain
says() {
grep -Fq -- "$2" "$work/out.txt" && ok "$1" || bad "$1" "no '$2' in the text report"
}
export GIT_CONFIG_NOSYSTEM=1 GIT_CONFIG_GLOBAL=/dev/null
export GIT_AUTHOR_NAME=fixture GIT_AUTHOR_EMAIL=f@x GIT_COMMITTER_NAME=fixture GIT_COMMITTER_EMAIL=f@x
repo="$work/repo"
g() { git -C "$repo" "$@" >/dev/null; }
# $1 path, $2 line count, $3 tag. Rewriting with a new tag replaces every line.
put() { mkdir -p "$(dirname "$repo/$1")"; seq -f "$3 %g" 1 "$2" > "$repo/$1"; }
# $1 date; commits everything staged or not.
commit() {
g add -A
GIT_AUTHOR_DATE="$1T12:00:00Z" GIT_COMMITTER_DATE="$1T12:00:00Z" g commit -q -m "at $1"
}
git init -q -b main "$repo"
# Before the window: must count nowhere.
put README.md 50 old; commit 2026-01-01
# Two commits co-change six files, so each pair couples at 100% over 2 revs.
# src/ is the root crate's, so it crosses a boundary with both crates/.
for f in crates/a/src/lib.rs crates/b/src/lib.rs; do put "$f" 10 x1; done
put src/main.rs 5 x1; put crates/a/tests/t.rs 4 x1
put AGENTS.md 3 x1; put .agents/skills/wave/agent-rules.md 3 x1
commit 2026-06-02
for f in crates/a/src/lib.rs crates/b/src/lib.rs; do put "$f" 6 x2; done
put src/main.rs 5 x2; put crates/a/tests/t.rs 4 x2
put AGENTS.md 3 x2; put .agents/skills/wave/agent-rules.md 3 x2
commit 2026-06-03
# One file in each remaining area. The repo map is 13 lines.
put context/code/x.md 8 x3
mkdir -p "$repo/context/test-evidence"
printf '### t_one\ntext\n#### t_two\n## not an entry\n### t_three\n' \
> "$repo/context/test-evidence/y.md"
cat > "$repo/context/repo-map.md" <<'EOF'
# Map
## Gotchas (read before editing)
1. **One.** short.
2. **Two.** a much longer gotcha, with plenty of words in it to be the longest one
and a second line.
3. **Three.** middling length.
---
4. not a gotcha, because the section has ended
EOF
put context/plan.md 2 x3
put prolog-sessions/kb.pl 5 x3
put scripts/foo.sh 7 x3; put .github/workflows/ci.yml 1 x3
put web/app.js 9 x3; put harness/x.js 2 x3
put Cargo.lock 11 x3
put crates/a/src/grammar.pest 3 x3
put .beads/issues.jsonl 2 x3; put site/index.html 1 x3
put docs/guide.md 2 x3; put .codex/config.toml 1 x3
commit 2026-06-04
g tag before-shrink
# A side branch merged with a merge commit: the side commit counts, the merge does not.
g checkout -q -b side
put notes.txt 4 x5; commit 2026-06-05
g checkout -q main
GIT_AUTHOR_DATE=2026-06-06T12:00:00Z GIT_COMMITTER_DATE=2026-06-06T12:00:00Z \
g merge -q --no-ff -m merge side
# A shrink in the next ISO week, so one area deletes and one row spans two weeks,
# and a rename, which --no-renames counts as a delete and an add.
put web/app.js 3 x4
g mv scripts/foo.sh scripts/bar.sh
commit 2026-06-09
# $1 output dir, $2 "text" to also write the text report, rest: extra args.
run() {
local out="$1" text="$2"; shift 2
"$STATS" -C "$repo" --since 2026-06-01 --min-revs 2 --out "$out" "$@" --json \
> "$work/out.json" 2> "$work/err" \
|| { bad "repo-stats $* ran" "$(cat "$work/err")"; return 1; }
[ "$text" = text ] || return 0
"$STATS" -C "$repo" --since 2026-06-01 --min-revs 2 --out "$out" "$@" \
> "$work/out.txt" 2>&1 \
|| { bad "repo-stats $* ran (text)" "$(cat "$work/out.txt")"; return 1; }
}
if run "$work/stats" text; then
is "no gh/main in the fixture, so the ref is HEAD" '.ref' '"HEAD"'
is "the merge commit is not counted, the side commit is" '.commits' '5'
row "rust src is .rs and .pest outside tests" by-area "rust src" '["45","25","0.556"]'
row "rust tests are the tests/ files" by-area "rust tests" '["8","4","0.500"]'
row "agent docs and skills take .codex" by-area "agent docs and skills" '["13","6","0.462"]'
row "docs/*.md is other docs" by-area "other docs" '["2","0","0.000"]'
row "context/code" by-area "context/code" '["8","0","0.000"]'
row "context/test-evidence" by-area "context/test-evidence" '["5","0","0.000"]'
row "repo-map is its own area" by-area "repo-map" '["13","0","0.000"]'
row "other context" by-area "other context" '["2","0","0.000"]'
row "kb.pl" by-area "kb.pl" '["5","0","0.000"]'
row "scripts/CI takes .github, and a rename is a delete and an add" \
by-area "scripts/CI" '["15","7","0.467"]'
row "web and harness, and its deletion ratio" by-area "web and harness" '["14","9","0.643"]'
row "a lock file, .beads/ and site/ are generated" by-area "generated" '["14","0","0.000"]'
row "the pre-window README counts nowhere" by-area "other" '["4","0","0.000"]'
row "prose is the context areas, other docs and agent docs" by-kind prose '["43","6","0.140"]'
row "code is rust, scripts/CI and web" by-kind code '["82","45","0.549"]'
row "prose to code is prose over code" by-kind ProseToCode '["0.524","0.133",""]'
is "weeks are columns, first-seen first" '.tables["area-by-week"][0][1:]' '["2026-W24","2026-W23"]'
row "an area is split by ISO week" area-by-week "web and harness" '["3","11"]'
is "every day's churn.csv total equals code-maat's abs-churn" \
'[.tables["by-day"][1:][]|.[1]==.[2]]|all' 'true'
row "a day's total is every file changed that day" by-day 2026-06-04 '["72","72"]'
is "gotchas end with their section" '.docs.repo_map.gotchas' '3'
is "the longest gotcha is measured over its lines" \
'.docs.repo_map.longest|[.number,.lines]' '[2,2]'
is "test-evidence entries are ### and #### headings" '.docs.test_evidence.entries' '3'
is "coupling finds the 15 pairs of six files, and web/app.js with scripts/foo.sh" \
'.coupling|length' '16'
is "files of two crates, the root crate's included, cross a boundary" \
'[.coupling[]|select(.["crosses-crate"]=="yes")|[.entity,.coupled]|sort]|sort' \
'[["crates/a/src/lib.rs","crates/b/src/lib.rs"],["crates/a/src/lib.rs","src/main.rs"],["crates/a/tests/t.rs","crates/b/src/lib.rs"],["crates/a/tests/t.rs","src/main.rs"],["crates/b/src/lib.rs","src/main.rs"]]'
is "two agent-rule docs are flagged" \
'[.coupling[]|select(.["agent-rules"]=="yes")|[.entity,.coupled]|sort]' \
'[[".agents/skills/wave/agent-rules.md","AGENTS.md"]]'
says "the text report flags a crate crossing" "[CROSSES-CRATE]"
says "the text report flags agent-rule docs" "[AGENT-RULES]"
says "the text report prints improvise's ratio row" "ProseToCode"
views="$(grep -c '^## View: ' "$work/stats/repo-stats.improv")"
[ "$views" = 10 ] && ok "the model keeps a view per file and the report's four" \
|| bad "the model keeps a view per file and the report's four" "$views views"
# Sparse, not padded: a padded merge has the same numbers and views, but a
# `-` item in every category and per-file views seated on the padding.
dashes="$(grep -c '|-|' "$work/stats/repo-stats.improv")"
[ "$dashes" = 0 ] && ok "the sparse model has no padding item" \
|| bad "the sparse model has no padding item" "$dashes lines name |-|"
churn_view="$(awk '/^## View: churn$/,/^$/' "$work/stats/repo-stats.improv")"
for seat in "date: row" "agent-rules: none" "coupled: none" "crosses-crate: none"; do
grep -qx -- "$seat" <<< "$churn_view" && ok "the churn view seats $seat" \
|| bad "the churn view seats $seat" "$(tr '\n' ' ' <<< "$churn_view")"
done
fi
# An --out holding a CSV this script did not write is refused, since the folder
# import would merge it. The script's own CSVs, from a rerun, are not foreign.
mkdir -p "$work/foreign"; echo "x,y" > "$work/foreign/other.csv"
"$STATS" -C "$repo" --since 2026-06-01 --out "$work/foreign" > /dev/null 2>&1
got=$?
[ "$got" = 2 ] && ok "a foreign CSV in --out is refused with exit 2" \
|| bad "a foreign CSV in --out is refused with exit 2" "exit $got"
# The window is scoped to the ref: at the tag, the shrink has not happened.
if run "$work/at-tag" json --ref before-shrink; then
row "--ref scopes the log" by-area "web and harness" '["11","0","0.000"]'
is "--ref names the ref it read" '.ref' '"before-shrink"'
fi
# A window whose only commits are empty changes no file: exit 0, and no model.
empty="$work/empty"
git init -q -b main "$empty"
repo="$empty"; put README.md 5 old; commit 2026-01-01
GIT_AUTHOR_DATE=2026-06-02T12:00:00Z GIT_COMMITTER_DATE=2026-06-02T12:00:00Z \
g commit -q --allow-empty -m empty
if run "$work/empty-out" text; then
is "an empty commit is counted" '.commits' '1'
is "and builds no tables" '.tables' '{}'
lines="$(wc -l < "$work/empty-out/churn.csv" | tr -d ' ')"
[ "$lines" = 1 ] && ok "churn.csv is written with its header alone" \
|| bad "churn.csv is written with its header alone" "$lines lines"
says "the text report says why there are no tables" "No file changed in the window"
fi
repo="$work/repo"
# improvise refusing the pipeline is exit 3, whatever the numbers were. The
# third stub imports cleanly and refuses only the scripts.
mkdir -p "$work/bin"
printf '#!/usr/bin/env bash\necho "Parse error: no" >&2\nexit 1\n' > "$work/bin/refuses"
printf '#!/usr/bin/env bash\necho "warning: Skipped 3 of 3 records" >&2\nexit 0\n' \
> "$work/bin/warns"
printf '#!/usr/bin/env bash\n[ "$1" = script ] || exit 0\necho "Parse error: no" >&2\nexit 1\n' \
> "$work/bin/refuses-a-script"
chmod +x "$work/bin/refuses" "$work/bin/warns" "$work/bin/refuses-a-script"
for stub in refuses warns refuses-a-script; do
IMPROVISE="$work/bin/$stub" "$STATS" -C "$repo" --since 2026-06-01 --out "$work/$stub" \
> /dev/null 2>&1
got=$?
[ "$got" = 3 ] && ok "an improvise that $stub fails the pipeline with exit 3" \
|| bad "an improvise that $stub fails the pipeline with exit 3" "exit $got"
done
printf '\nrepo-stats: %d passed, %d failed\n' "$pass" "$fail"
[ "$fail" = 0 ]
+87
View File
@@ -0,0 +1,87 @@
# The views scripts/repo-stats builds over the model it imported, run by
# `improvise script` with the output directory as the working directory.
# Every `export` here is a table the text report prints. See
# context/code/tooling.md section 7.
# The prose-to-code ratio is a formula over the kind category's own items.
add-formula kind "ProseToCode = prose / code FORMAT .3"
# Churn by area, with the deletion ratio (a _Measure formula from the import).
create-view "By area"
switch-view "By area"
set-axis File page
set-page File churn
set-axis area row
set-axis _Measure column
set-axis kind none
set-axis week none
set-axis date none
set-axis commit none
set-axis entity none
set-axis coupled none
set-axis crosses-crate none
set-axis agent-rules none
toggle-prune-empty
set-format .0
export report/by-area.csv
# Prose, code and other, and the ratio of the first two.
create-view "By kind"
switch-view "By kind"
set-axis File page
set-page File churn
set-axis kind row
set-axis _Measure column
set-axis area none
set-axis week none
set-axis date none
set-axis commit none
set-axis entity none
set-axis coupled none
set-axis crosses-crate none
set-axis agent-rules none
toggle-prune-empty
set-format .0
export report/by-kind.csv
# Lines added per area per ISO week.
create-view "Area by week"
switch-view "Area by week"
set-axis File page
set-page File churn
set-axis area row
set-axis week column
set-axis _Measure page
set-page _Measure added
set-axis kind none
set-axis date none
set-axis commit none
set-axis entity none
set-axis coupled none
set-axis crosses-crate none
set-axis agent-rules none
toggle-prune-empty
set-format .0
export report/area-by-week.csv
# Lines added per day, from both sources side by side: this script's churn
# rows and code-maat's abs-churn. The two columns must agree.
create-view "By day"
switch-view "By day"
set-axis date row
set-axis File column
set-axis _Measure page
set-page _Measure added
set-axis area none
set-axis kind none
set-axis week none
set-axis commit none
set-axis entity none
set-axis coupled none
set-axis crosses-crate none
set-axis agent-rules none
toggle-prune-empty
set-format .0
export report/by-day.csv
switch-view "By area"
+180 -48
View File
@@ -1,5 +1,6 @@
use std::io::{self, Stdout};
use std::path::PathBuf;
use std::sync::OnceLock;
use std::time::Duration;
use anyhow::Result;
@@ -18,7 +19,8 @@ use ratatui::{
use crate::command::key;
use crate::protocol::EditorSession;
use crate::ui::app::{App, AppMode, MinibufColor};
use crate::protocol::face::{AnsiSlot, Colour, Face, Slant, Theme, Weight};
use crate::ui::app::{App, AppMode};
use crate::ui::category_panel::CategoryContent;
use crate::ui::formula_panel::FormulaContent;
use crate::ui::grid::GridWidget;
@@ -29,7 +31,6 @@ use crate::ui::text::truncate;
use crate::ui::tile_bar::TileBar;
use crate::ui::view_panel::ViewContent;
use crate::ui::which_key::WhichKeyWidget;
use crate::view::AxisColor;
use improvise_command::ui::project;
use unicode_width::UnicodeWidthStr;
@@ -165,24 +166,54 @@ fn convert_modifiers(mods: event::KeyModifiers) -> key::KeyModifiers {
// ── Drawing ──────────────────────────────────────────────────────────────────
/// The one place `AxisColor` becomes a terminal color (improvise-rml); the
/// accent itself is decided by `Axis::color`. See `context/code/tui.md`.
pub(crate) fn axis_fg(color: AxisColor) -> Color {
match color {
AxisColor::Row => Color::Green,
AxisColor::Column => Color::Blue,
AxisColor::Page => Color::Magenta,
AxisColor::Filter => Color::Yellow,
AxisColor::Inactive => Color::DarkGray,
/// The one place a face becomes a terminal style (ADR 004): the theme resolves
/// it, and only this last translation is the TUI's. `context/code/tui.md`.
pub(crate) fn face_style(face: Face) -> Style {
static SHIPPED: OnceLock<Theme> = OnceLock::new();
let face = SHIPPED.get_or_init(Theme::shipped).resolve(face);
let mut style = Style::default();
if let Some(fg) = terminal_colour(face.foreground) {
style = style.fg(fg);
}
if let Some(bg) = terminal_colour(face.background) {
style = style.bg(bg);
}
let modifiers = [
(face.weight == Weight::Bold, Modifier::BOLD),
(face.slant == Slant::Italic, Modifier::ITALIC),
(face.underline, Modifier::UNDERLINED),
(face.reverse, Modifier::REVERSED),
];
modifiers
.into_iter()
.filter(|(on, _)| *on)
.fold(style, |style, (_, m)| style.add_modifier(m))
}
/// Map the semantic minibuffer accent to a concrete terminal color.
fn minibuf_fg(color: MinibufColor) -> Color {
match color {
MinibufColor::Green => Color::Green,
MinibufColor::Cyan => Color::Cyan,
MinibufColor::Yellow => Color::Yellow,
/// `None` is "state nothing", so the terminal's own colour shows through.
fn terminal_colour(colour: Colour) -> Option<Color> {
match colour {
Colour::Default => None,
Colour::Named(slot) => Some(match slot {
AnsiSlot::Black => Color::Black,
AnsiSlot::Red => Color::Red,
AnsiSlot::Green => Color::Green,
AnsiSlot::Yellow => Color::Yellow,
AnsiSlot::Blue => Color::Blue,
AnsiSlot::Magenta => Color::Magenta,
AnsiSlot::Cyan => Color::Cyan,
AnsiSlot::White => Color::Gray,
AnsiSlot::BrightBlack => Color::DarkGray,
AnsiSlot::BrightRed => Color::LightRed,
AnsiSlot::BrightGreen => Color::LightGreen,
AnsiSlot::BrightYellow => Color::LightYellow,
AnsiSlot::BrightBlue => Color::LightBlue,
AnsiSlot::BrightMagenta => Color::LightMagenta,
AnsiSlot::BrightCyan => Color::LightCyan,
AnsiSlot::BrightWhite => Color::White,
}),
Colour::Indexed(n) => Some(Color::Indexed(n)),
Colour::Rgb(r, g, b) => Some(Color::Rgb(r, g, b)),
}
}
@@ -231,30 +262,6 @@ fn draw_popup_frame(f: &mut Frame, popup: Rect, title: &str, border_color: Color
inner
}
/// Terminal styling for the mode indicator. Exhaustive on purpose, so a new
/// `AppMode` fails the build here (improvise-zx8t) — `context/code/tui.md`.
fn mode_style(mode: &AppMode) -> Style {
let bg = match mode {
AppMode::Editing { .. } | AppMode::RecordsEditing { .. } => Color::Green,
AppMode::CommandMode { .. } => Color::Yellow,
AppMode::TileSelect => Color::Magenta,
AppMode::Normal
| AppMode::RecordsNormal
| AppMode::FormulaEdit { .. }
| AppMode::FormulaPanel
| AppMode::CategoryPanel
| AppMode::CategoryAdd { .. }
| AppMode::ItemAdd { .. }
| AppMode::ViewPanel
| AppMode::ImportWizard
| AppMode::ExportPrompt { .. }
| AppMode::SavePrompt { .. }
| AppMode::Help
| AppMode::Quit => Color::DarkGray,
};
Style::default().fg(Color::Black).bg(bg)
}
fn draw(f: &mut Frame, app: &App) {
let size = f.area();
@@ -442,12 +449,9 @@ fn draw_bottom_bar(f: &mut Frame, area: Rect, app: &App) {
.unwrap_or("");
let text = minibuffer_line(&mb.prompt, buf, &app.view_state.status_msg, area.width);
f.render_widget(
Paragraph::new(text).style(
Style::default()
.fg(minibuf_fg(mb.color))
.bg(Color::Indexed(235))
.add_modifier(Modifier::BOLD),
),
Paragraph::new(text).style(face_style(
Face::of_minibuffer(&app.view_state.mode).unwrap_or(Face::ModeTextEntry),
)),
area,
);
} else {
@@ -484,7 +488,7 @@ fn draw_status(f: &mut Frame, area: Rect, app: &App) {
let line = fill_line(left, &view_badge, area.width, Keeps::Left);
f.render_widget(
Paragraph::new(line).style(mode_style(&app.view_state.mode)),
Paragraph::new(line).style(face_style(Face::of_mode(&app.view_state.mode))),
area,
);
}
@@ -639,6 +643,43 @@ mod bottom_bar_tests {
);
}
/// The first cell of the bottom row, where the prompt's first glyph lands.
fn prompt_cell(app: &App) -> ratatui::buffer::Cell {
let mut terminal = Terminal::new(TestBackend::new(40, 1)).expect("test backend");
terminal
.draw(|f| draw_bottom_bar(f, Rect::new(0, 0, 40, 1), app))
.expect("draw");
terminal.backend().buffer().content()[0].clone()
}
/// ADR 004 clause 8's one declared exception (the user, Q1): the
/// category-add prompt draws as item-add does. `context/test-evidence/tui.md`.
#[test]
fn the_category_add_prompt_draws_as_the_item_add_prompt_does() {
let mut app = App::new(Workbook::new("T"), None);
app.view_state.mode = AppMode::item_add("Region".into());
let item = prompt_cell(&app);
app.view_state.mode = AppMode::category_add();
let category = prompt_cell(&app);
assert_eq!(
category.symbol(),
"n",
"the prompt is not where it was looked for"
);
assert_eq!(item.fg, ratatui::style::Color::Green);
assert_eq!(category.style(), item.style());
}
/// The status line wears the mode's face, not the minibuffer's.
#[test]
fn the_status_line_is_drawn_in_the_mode_face() {
let mut app = App::new(Workbook::new("T"), None);
app.view_state.mode = AppMode::TileSelect;
let cell = prompt_cell(&app);
assert_eq!(cell.bg, ratatui::style::Color::Magenta);
assert_eq!(cell.fg, ratatui::style::Color::Black);
}
/// A refusal that keeps the user in a minibuffer stands while they fix it, and
/// goes when they leave (improvise-su4u). `context/test-evidence/tui.md`.
#[test]
@@ -1187,3 +1228,94 @@ mod content_tests {
}
}
}
#[cfg(test)]
mod face_tests {
use super::*;
use crate::protocol::face::TextEntry;
use crate::protocol::render::WizardFieldKind;
use crate::view::Axis;
/// `mode_style`'s table as it stood before faces (improvise-zx8t).
fn mode_bg_before_faces(mode: &AppMode) -> Color {
match mode {
AppMode::Editing { .. } | AppMode::RecordsEditing { .. } => Color::Green,
AppMode::CommandMode { .. } => Color::Yellow,
AppMode::TileSelect => Color::Magenta,
AppMode::Normal
| AppMode::RecordsNormal
| AppMode::FormulaEdit { .. }
| AppMode::FormulaPanel
| AppMode::CategoryPanel
| AppMode::CategoryAdd { .. }
| AppMode::ItemAdd { .. }
| AppMode::ViewPanel
| AppMode::ImportWizard
| AppMode::ExportPrompt { .. }
| AppMode::SavePrompt { .. }
| AppMode::Help
| AppMode::Quit => Color::DarkGray,
}
}
/// ADR 004 clause 8: the shipped theme draws every migrated site exactly as
/// its literal did. `context/test-evidence/tui.md`.
#[test]
fn the_shipped_theme_reproduces_every_migrated_literal() {
let fg = |c| Style::default().fg(c);
let axes = [
(Axis::Row, Color::Green),
(Axis::Column, Color::Blue),
(Axis::Page, Color::Magenta),
(Axis::Filter, Color::Yellow),
(Axis::None, Color::DarkGray),
];
for (axis, color) in axes {
assert_eq!(face_style(Face::Axis(axis)), fg(color), "{axis:?}");
}
let kinds = [
(WizardFieldKind::Category, Color::Green),
(WizardFieldKind::Measure, Color::Cyan),
(WizardFieldKind::TimeCategory, Color::Magenta),
(WizardFieldKind::Label, Color::DarkGray),
];
for (kind, color) in kinds {
assert_eq!(face_style(Face::WizardField(kind)), fg(color), "{kind:?}");
}
for mode in AppMode::all() {
let before = fg(Color::Black).bg(mode_bg_before_faces(&mode));
assert_eq!(face_style(Face::of_mode(&mode)), before, "{mode:?}");
}
let prompts = [
(TextEntry::Edit, Color::Green),
(TextEntry::ItemAdd, Color::Green),
(TextEntry::Formula, Color::Cyan),
(TextEntry::Command, Color::Yellow),
(TextEntry::Export, Color::Yellow),
(TextEntry::Save, Color::Yellow),
];
for (entry, color) in prompts {
let before = fg(color)
.bg(Color::Indexed(235))
.add_modifier(Modifier::BOLD);
assert_eq!(face_style(Face::MinibufferOf(entry)), before, "{entry:?}");
}
}
#[test]
fn every_face_reaches_the_terminal_with_every_attribute_it_states() {
let resolved = |face| Theme::shipped().resolve(face);
for face in Face::all() {
let r = resolved(face);
let style = face_style(face);
assert_eq!(style.fg, terminal_colour(r.foreground), "{}", face.name());
assert_eq!(style.bg, terminal_colour(r.background), "{}", face.name());
assert_eq!(
style.add_modifier.contains(Modifier::BOLD),
r.weight == Weight::Bold,
"{}",
face.name()
);
}
}
}
+5 -8
View File
@@ -6,13 +6,14 @@ use ratatui::{
use unicode_width::UnicodeWidthStr;
use crate::protocol::face::Face;
use crate::protocol::{CatTreeEntry, CategoryTreeCache, flatten_cat_tree};
use crate::ui::app::Panel as PanelKind;
use crate::ui::panel::PanelContent;
use crate::view::Axis;
fn axis_display(axis: Axis) -> (String, Color) {
(axis.to_string(), crate::draw::axis_fg(axis.color()))
fn axis_display(axis: Axis) -> (String, Style) {
(axis.to_string(), crate::draw::face_style(Face::Axis(axis)))
}
/// The category panel draws one flattened tree and nothing else: the axis
@@ -78,7 +79,7 @@ impl PanelContent for CategoryContent {
axis,
} => {
let indicator = if *expanded { "▼" } else { "▶" };
let (axis_str, axis_color) = axis_display(*axis);
let (axis_str, axis_style) = axis_display(*axis);
let name_part = format!("{indicator} {name} ({item_count})");
let axis_part = format!(" [{axis_str}]");
@@ -88,11 +89,7 @@ impl PanelContent for CategoryContent {
inner.x + name_part.width() as u16,
y,
&axis_part,
if is_selected {
base_style
} else {
Style::default().fg(axis_color)
},
if is_selected { base_style } else { axis_style },
);
}
}
+2
View File
@@ -860,6 +860,8 @@ mod tests {
m.add_item_in_group("Region", "West", "Inland").unwrap();
m.add_item_in_group("Month", "Jan", "Q1").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();
// Pinned so the byte-exact frames below characterize the LAYOUT and not
// the view default, which grew two decimals with improvise-e0ex.
+4 -14
View File
@@ -7,7 +7,8 @@ use ratatui::{
use unicode_width::UnicodeWidthStr;
use crate::protocol::{WizardCache, WizardFieldKind, WizardStepKind};
use crate::protocol::face::Face;
use crate::protocol::{WizardCache, WizardStepKind};
use crate::ui::text::wrap;
/// The import wizard, drawn from a [`WizardCache`] (improvise-764) and from
@@ -22,17 +23,6 @@ impl<'a> ImportWizardWidget<'a> {
}
}
/// Map a proposed field's kind to its accent, as `axis_fg` does for
/// `AxisColor` (gotcha 26). The decision is the protocol's; the color is not.
fn field_kind_fg(kind: WizardFieldKind) -> Color {
match kind {
WizardFieldKind::Category => Color::Green,
WizardFieldKind::Measure => Color::Cyan,
WizardFieldKind::TimeCategory => Color::Magenta,
WizardFieldKind::Label => Color::DarkGray,
}
}
impl<'a> Widget for ImportWizardWidget<'a> {
fn render(self, area: Rect, buf: &mut Buffer) {
let popup_w = area.width.min(80);
@@ -139,7 +129,7 @@ impl<'a> Widget for ImportWizardWidget<'a> {
}
let is_sel = i == self.wizard.cursor;
let kind_color = field_kind_fg(proposal.kind);
let kind_style = crate::draw::face_style(Face::WizardField(proposal.kind));
let accept_str = if proposal.accepted {
"[\u{2713}]"
@@ -159,7 +149,7 @@ impl<'a> Widget for ImportWizardWidget<'a> {
.bg(Color::Cyan)
.add_modifier(Modifier::BOLD)
} else if proposal.accepted {
Style::default().fg(kind_color)
kind_style
} else {
Style::default().fg(Color::DarkGray)
};
+8 -4
View File
@@ -7,6 +7,7 @@ use ratatui::{
use unicode_width::UnicodeWidthStr;
use crate::protocol::TileBarCache;
use crate::protocol::face::Face;
use crate::ui::app::AppMode;
use crate::view::Axis;
@@ -44,8 +45,11 @@ impl<'a> TileBar<'a> {
tile_cat_idx,
}
}
fn axis_display(axis: Axis) -> (&'static str, Color) {
(axis.short_label(), crate::draw::axis_fg(axis.color()))
fn axis_display(axis: Axis) -> (&'static str, Style) {
(
axis.short_label(),
crate::draw::face_style(Face::Axis(axis)),
)
}
}
@@ -101,7 +105,7 @@ impl<'a> Widget for TileBar<'a> {
break;
}
let (_, axis_color) = TileBar::axis_display(tiles[i].axis);
let (_, axis_style) = TileBar::axis_display(tiles[i].axis);
let is_selected = selected_cat_idx == Some(i);
let style = if is_selected {
Style::default()
@@ -109,7 +113,7 @@ impl<'a> Widget for TileBar<'a> {
.bg(Color::Cyan)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(axis_color)
axis_style
};
buf.set_string(x, area.y, &labels[i], style);
+4 -2
View File
@@ -12,6 +12,7 @@ fn grouped_item_order_survives_save() {
let mut w = Workbook::new("test");
w.add_category("C").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();
let r = parse_md(&format_md(&w)).unwrap();
assert_eq!(
@@ -429,8 +430,9 @@ fn slash_in_hidden_name_survives_roundtrip() {
w.add_category("C").unwrap();
w.add_item("C", "A/B").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().toggle_group_collapse("C", "G/H");
w.active_view_mut().toggle_group_collapse("C", &["G/H"]);
let text = format_md(&w);
let r = parse_md(&text).unwrap_or_else(|e| panic!("unparseable: {e}\n{text}"));
assert!(
@@ -438,7 +440,7 @@ fn slash_in_hidden_name_survives_roundtrip() {
"hidden lost:\n{text}"
);
assert!(
r.active_view().is_group_collapsed("C", "G/H"),
r.active_view().is_group_collapsed("C", &["G/H"]),
"collapsed lost:\n{text}"
);
}
+110 -6
View File
@@ -4,7 +4,7 @@
use improvise::model::cell::{CellKey, CellValue};
use improvise::ui::app::{App, AppMode, ViewCursor};
use improvise::ui::grid::GridWidget;
use improvise::view::{Axis, GridLayout};
use improvise::view::{Axis, GridLayout, GroupFold};
use improvise::workbook::Workbook;
use improvise_command::ui::project;
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_in_group("Month", "Jan", "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("Recipient", "Carol").unwrap();
m.add_item_in_group("Recipient", "Alice", "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.add_item("_Measure", "Revenue").unwrap();
m.add_item("_Measure", "Cost").unwrap();
@@ -188,10 +190,111 @@ fn browser_model(cache: &GridCache) -> RenderModel {
#[test]
fn the_two_front_ends_draw_the_same_row_header() {
let mut wb = two_level_rows();
let (_app, cache) = whole_grid(&mut wb);
assert_row_headers_agree(&mut two_level_rows());
}
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);
assert_eq!(
@@ -376,6 +479,7 @@ fn leading_group_rows() -> Workbook {
m.add_category("Recipient").unwrap();
m.add_item_in_group("Recipient", "Alice", "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("_Measure", "Revenue").unwrap();
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();
if collapse {
wb.active_view_mut()
.toggle_group_collapse("Recipient", "Fam");
.toggle_group_collapse("Recipient", &["Fam"]);
}
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();
if collapse {
wb.active_view_mut()
.toggle_group_collapse("Recipient", "Family");
.toggle_group_collapse("Recipient", &["Family"]);
}
let (_app, cache) = whole_grid(&mut wb);