Merge pull request #197 from fiddlerwoaroof/collapse-fu/gw18

improvise-gw18/yk2s/lx7w/54g1: the cursor's cell has three doors
This commit is contained in:
Ed Langley
2026-09-23 08:18:10 -07:00
committed by GitHub
14 changed files with 912 additions and 39 deletions
+71 -3
View File
@@ -3340,9 +3340,77 @@ so `paste Region=West _Measure=Revenue Product=Gadget` from a script still
writes where the current view happens to aggregate `Product` away: a key that
names every coordinate is a record whatever the view shows. For the cursor's
key the two questions agree, because a cursor key never carries a None-axis
category. The **collapsed-group fold** half of improvise-l4mp is not covered:
its key carries the group's name as an ordinary coordinate, so no question about
the key alone can see it — it needs `GridLayout::folds_a_group`.
category. The **collapsed-group fold** half of improvise-l4mp cannot be seen by
any question about the key alone, because the key carries the group's name as an
ordinary coordinate. It is answered at the cursor instead, by the next section.
**The first version of that section wrote a false sentence here**: that the
checks in `paste_into` were reached "only by a key a script spells out". The
cursor path handed `PasteCell` the resolved record key, and `paste_into` ran
`aggregates` over it **again**. That is a second derivation beside `cell_id`, and
it reads a record lacking a later category (every record, once `+cat` adds one)
as an aggregate, so `p` refused where gh/main had pasted and the browser still
wrote (improvise-q9eg). The distinction is now a type: `PasteAt::Resolved`
carries a key the door answered and is written as it stands, and
`PasteAt::Spelled` carries a key a script typed, which only its shape can vet.
Nothing is asked twice.
## The cursor's cell has three doors (improvise-gw18, lx7w, 54g1, yk2s)
**The lesson of improvise-gw18 is that guarding one command did not close the
path.** PR #176 taught `EditOrDrill` to refuse a collapsed group's fold, and `>`
still drilled into it. The drill was empty, and `o` inside it wrote
`Month=Q1, _Measure=Revenue`, a coordinate that is not an item, into `## Data`.
The same shape turned up three more times, each in a command nobody had
listed. `p` wrote onto the fold (the fold half of improvise-l4mp). `x` in records
mode cleared the synthetic `(_Index, _Dim)` key, a silent no-op, while the
browser cleared the real record (improvise-lx7w). `p` onto a formula's cell
stored a value under the formula that survived in `## Data`, while the browser
declined (improvise-54g1). Each fix was one more guard in one more command. So
the answer is the **accessor**, not the commands: a command gets the cursor's
cell through a door that matches what it does with it.
Every command that takes the cursor's cell, and its door:
| command | door | on a fold | on a formula's cell | in records mode |
|---|---|---|---|---|
| `yank` | `cell_key` (reads) | yanks the fold's value | yanks the result | `YankCell` resolves the synthetic key |
| `paste`, `clear-cell` | `value_cell_key` | refused | refused | resolved to the real record; a coordinate or creating column and a drill are refused |
| `commit-cell-edit`, `commit-and-advance-right` | `committable_cell_key` | refused | refused | the synthetic key, which `commit_cell_value` resolves itself (gotcha 19) |
| `drill-into-cell` | `unfolded_cell_key` | refused | drills | unchanged |
| `edit-or-drill` | its own check, `folds_a_group` then `owning_formula` | refused | refused | unchanged |
| `hide-selected-row-item` (`H`) | `GridLayout::row_folds_a_group` | refused | n/a (hides a row item) | n/a |
| `add-record-row` (`o`) | none, it takes no cell | n/a | n/a | closed by the drill door: no drill of a fold exists to add a row in |
**`value_cell_key` asks the browser's own question.** It calls
`project::cell_id`, the function that fills `CellDisplay::id` for the browser
(ADR 002), and writes only where the answer is `CellId::Record`. The browser's
`record_cell_at_cursor` is that same match on the projected id. The two ends
therefore agree because they read one answer, not because two derivations
happen to match. `tests/lockstep.rs` holds them together for all three tickets.
`cell_id` took `&App` only to reach the model, so it now takes `&Model`. That is
what let `CmdContext` ask it.
**Commit has a door of its own**, and not `value_cell_key`, because commit is
the one writer that must see the synthetic key: renaming a coordinate column,
creating a column and staging a drill edit all resolve the records position
inside `commit_cell_value`. Its door refuses the two arms `i` refuses, an
aggregate (a fold included) and a formula's result. It needed one because
`Enter` and `Tab` advance the editor, and `EnterEditAtCursor` re-opens it at the
new cursor with no guard. `i` declines to edit `Profit`, yet `Tab` from `Cost`
landed the editor on it, and the next commit stored a value under the formula.
That is a fourth door nobody had listed. **The entry half is still open**
(improvise-enz6): the editor still opens on a fold or a formula's cell. Only the
write is refused, out loud, at commit. Closing the entry belongs in `ui/effect.rs`.
**Drill keeps the narrowest door** because drilling into an aggregate is its
purpose. Only a fold is refused, since no record carries a group's name. The
ticket's alternative, expanding the fold into its members' records, would be the
more useful drill and is not built.
**A text-path key is not covered.** `paste Month=Q1 _Measure=Revenue` typed as
coordinates still reaches `PasteCell` with a group name. The doors are about the
**cursor**, and a key a user spells out is a claim about the document that no
layout checked. The same is true of `set-cell`.
## A command is a function of its context — the determinism law (improvise-hzjo)
+5 -1
View File
@@ -900,7 +900,11 @@ regular on None projected the fold as `CellId::Record`, offered it for editing,
and a `SetCell` would have written the coordinate `Month=Q1`, which is not an
item. `render.rs::editable` is untouched (ADR 002: the variant decides).
`EditOrDrill` asks the same predicate and declines out loud: drilling would filter
on `Month=Q1`, which no record carries, and show an empty list.
on `Month=Q1`, which no record carries, and show an empty list. Guarding that one
command left `>`, `p`, `H` and an `Enter` advanced onto the fold all still writing
or drilling there (improvise-gw18, yk2s). Every command that takes the cursor's
cell now goes through a door that asks this predicate, or asks `cell_id`, which
does: `context/code/command.md`, "The cursor's cell has three doors".
### The cursor, and why the order is the fix
+32
View File
@@ -4558,6 +4558,27 @@ registers to agree; a TUI-only blank filter (`Text("")` to `None`) fails it on
`+`, which is why that filter was removed rather than kept. The evidence entries
for the host-side tests are in `command.md`.
### x_in_records_mode_clears_the_record_the_browser_clears
improvise-lx7w, both ends. The browser composes `ClearCell` of the projected
`CellId::Record`. Before the fix the host's `x` cleared the synthetic key and
the documents diverged in `both_ends`. South carries **two** measures. With one,
clearing it removes South's whole records row, the host resets its cursor on
the reframe, and `both_ends` reports a cursor divergence that belongs to the
fixture and not to `x`. The first version of the test did exactly that. The test
also asserts that South/Revenue is gone and North is untouched, because two ends
that both do nothing also agree.
### p_onto_a_formula_owned_cell_declines_on_both_ends
improvise-54g1, both ends, on `yank_host`'s `Profit` column (row 1, column 3).
The client must not compose a `Command::Model`, and the host's document must not
move and must say why. The control, Cost at column 2, must compose a write, so
a client that never writes cannot pass. The fold case,
`a_write_onto_a_fold_declines_on_both_ends`, runs the same helper for `p` and `x`
at row 2. Rival for all three: the host's paste/clear door restored to
`ctx.cell_key()`, which was `gh/main`'s code. All three fail.
### the worker is not called up until its first frame has arrived
improvise-i0v9. The worker transport wrote `in-page worker · <doc>` when it
@@ -4750,6 +4771,17 @@ disabled, the host held `Some(Number(42.0))`, so A's commit had re-created the
cell B cleared. Under that rival, the 0j0f test above failed as well. Both
files were restored.
### p_onto_a_sparse_record_writes_what_the_browser_writes
improvise-q9eg, both ends, on a **sparse** record: `Season` is added after
entering records mode, so no record carries it. `yy` on North/Revenue, then `p`
on South/Revenue (row 1), through `both_ends`, which compares the host's
document with the one the client's composed commands build. The host refused
while the browser composed `SetCell`, so the documents differed. The final
assertion that South holds 1200 is the half that keeps the test from passing
when both ends decline. Rival: `PasteAt::Resolved` back through `paste_into`.
This test fails.
### a_locally_resolved_page_scroll_lands_where_the_host_would_put_it
**What was missing (improvise-5xbq).** `tests/lockstep.rs` compared only `j`
+105
View File
@@ -5269,6 +5269,97 @@ the first message does not mention drilling. At `0ed2e41` it failed with the
old text. The rival is restoring that text. Real drill paste, which means
staging a typed `CellValue`, is still open as improvise-48jd.
The drilled paste now declines at the cursor door (`value_cell_key`) before
`PasteCell` runs, so the status carries the registry's `paste:` prefix and a
wording shared with `clear-cell`. The assertion was re-aimed from equality with
`PASTE_INSIDE_DRILL` to `ends_with(INSIDE_A_DRILL_REFUSAL)` (improvise-gw18's
doors). The advice half is unchanged.
### drilling_into_a_fold_is_refused_and_o_after_it_writes_no_group_coordinate
improvise-gw18. On `gh/main` at `800348a`, `>` on the Q2 fold (row 2, column 1)
opened a `_Drill` with no rows, and `o` in it stored `Month=Q2` in `## Data`. The
test presses both keys through `App::handle_key`, then saves and reloads, and
asserts that no coordinate names a non-item (`non_item_coordinates`, which asks
the category, not the spelling). It failed on the first assertion: the drill
opened. Rival: the registry's cursor fallback on the reading accessor
(`cell_key`) in place of `unfolded_cell_key`. This test and
`no_normal_mode_key_pressed_on_a_fold_stores_a_group_coordinate` fail. The
control, `drilling_into_the_row_above_a_fold_still_opens_a_drill`, holds the
refusal to the fold alone.
### no_normal_mode_key_pressed_on_a_fold_stores_a_group_coordinate
improvise-gw18's enumeration, made executable. Every key bound in Normal mode,
prefix sub-keys included, is pressed on a fresh app with the cursor on a fold
and a value yanked, and no stored coordinate may name a non-item. The sweep is
**enumerated** rather than hand-picked, which is how it found `p` (the fold half
of improvise-l4mp) without being told. The first run failed on
`[(Char('p'), NONE)] on a fold stored [("Month", "Q2")]`. It asserts that the
enumeration reaches `p`, so an empty sweep cannot pass. What it cannot see is a
write two keys deep (`>` then `o`). That is why the drill test above exists. A
two-key sweep was **estimated**, not measured, at several seconds and not built.
Widened after PR #197's review, which showed that `H` without its fold check
passed the sweep: a ghost `hidden:` entry is not a stored coordinate. Every
stroke must now also leave the `## Data` section and the view's hidden set
exactly as they were. With the `H` guard disabled, this test fails as well as
the yk2s one. What no sweep can see is `clear-cell` back on the reading
accessor, because clearing a key nothing is stored at changes nothing. The
lx7w tests and the fold lockstep case catch that.
### hiding_a_folds_row_is_refused_and_persists_no_hidden_group
improvise-yk2s. `H` on the fold emitted `HideItem { Month, "Q2" }`. `View`
accepts an unknown name, so a ghost `hidden:` line was saved, with no visible
change and no message. The test asserts no hidden entry, a message, and no
`hidden:` line in the saved text. The control hides `Feb` one row up. Rival: the
`row_folds_a_group` guard disabled (`if false && …`). Only this test fails. The
guard asks the row half of `folds_a_group`, so an inner-level fold
(`North/Q1`) is refused too, although `North` is a real item. That is a
deliberate over-refusal, taken to keep one predicate.
### enter_advancing_onto_a_fold_commits_nothing_there
The write half of improvise-enz6, closed by the commit door. `i` on Feb, then
`Enter`, which commits and advances onto the Q2 fold and re-opens the editor
there. That entry is still enz6's open half. Then `7` and `Enter`. At the
previous commit door (`cell_key`) the second commit stored `Month=Q2`. Rival:
`commit_at_cursor` on `cell_key` again. This test and
`tab_advancing_onto_a_formula_cell_commits_nothing_under_it` fail.
### x_in_records_mode_clears_the_real_record_under_the_cursor
improvise-lx7w. In records mode `x` built `ClearCell` from the synthetic
`(_Index, _Dim)` key, which names nothing stored, so it was a silent no-op. The
cursor is on West × Cost (row 1, column 3). The test asserts that West/Cost is
gone, that East/Cost is untouched (so a clear of the wrong row cannot pass), and
that nothing synthetic reaches the reloaded file. Rival: `clear-cell`'s cursor
fallback on the old door. This test and the coordinate-column one fail.
### x_on_a_records_coordinate_column_changes_nothing_and_says_so
improvise-lx7w's decline arm, the answer #184 gave paste: a coordinate column
stores no value, so `x` there changes nothing and says so. Before the fix it
changed nothing *silently*, and it fails on the message.
### p_onto_a_formula_owned_cell_writes_nothing_and_names_the_formula
improvise-54g1. `p` on West × Profit (`Profit = Revenue - Cost`, row 1,
column 2) stored `_Measure=Profit` under the formula. The recompute hid it, but
it survived in `## Data`. The test asserts that the saved text has no Profit
line, that the reload has no such cell, and that the status names the formula.
The control, one column left, must write, so a door that refused everything
cannot pass. Rival: the old door. It fails on the saved text.
### tab_advancing_onto_a_formula_cell_commits_nothing_under_it
A door the tickets did not list, found by working the enumeration. `i` declines
to edit a formula's cell, but `Tab` from West/Cost commits and advances onto
Profit, `EnterEditAtCursor` re-opens the editor there, and `Enter` stored
`7` under the formula. Found by this test failing at `90b93d4`, before
`committable_cell_key` existed. The rival is the one above.
### ui::host::tests::every_failing_model_command_refuses_its_whole_frame
improvise-9fpl, found by PR #181's review. Eight of bok4's refusal sites had no
@@ -5621,6 +5712,20 @@ cannot see the feature removed from `improvise-command` alone, because
`improvise-io`, a normal dependency, enables it in the same build. That case is
held by `every_crate_that_can_parse_json_enables_float_roundtrip` (`tooling.md`).
### p_onto_a_sparse_record_writes_it
improvise-q9eg, a regression introduced by PR #197 and found in its review. In
records mode a category is added (as `+cat` does), so every existing record
lacks it and it sits on `Axis::None`. `yy` on East/Cost, then `p` on West/Cost
(row 1): gh/main pasted 700, and #197 refused with the aggregate message.
`value_cell_key` handed `PasteCell` the real record key, and `paste_into` then
re-ran `aggregates(ctx, key)`. That key-shape check is a second derivation
beside `cell_id`, and it reads a record missing a None-axis category as an
aggregate. Every records fixture before this one was fully populated, which is
why nothing saw it. The fix is `PasteAt::{Resolved, Spelled}`: a resolved key is
written as it stands. Rival: routing `Resolved` back through `paste_into`. This
test and `p_onto_a_sparse_record_writes_what_the_browser_writes` fail.
## `ui/effect.rs` — `RecordRewrite`'s untested branches (improvise-phg1)
### renaming_a_drill_row_held_by_a_lone_measureless_cell_moves_that_cell
+190 -19
View File
@@ -3,7 +3,7 @@ use crate::model::cell::{CellKey, CellValue};
use crate::ui::effect::{self, Effect, ViewTarget};
use crate::view::synthetic_record_info;
use super::core::{Cmd, CmdContext};
use super::core::{AGGREGATE_REFUSAL, Cmd, CmdContext, INSIDE_A_DRILL_REFUSAL, NO_VALUE_REFUSAL};
#[cfg(test)]
mod tests {
@@ -66,7 +66,9 @@ mod tests {
("Type".into(), "Clothing".into()),
("Month".into(), "Feb".into()),
]);
let cmd = PasteCell { key: key.clone() };
let cmd = PasteCell {
at: PasteAt::Spelled(key.clone()),
};
let effects = cmd.execute(&ctx);
assert_eq!(effects.len(), 1);
let mut app = crate::ui::app::App::new(two_cat_model(), None);
@@ -88,7 +90,9 @@ mod tests {
("Type".into(), "Food".into()),
("Month".into(), "Jan".into()),
]);
let cmd = PasteCell { key };
let cmd = PasteCell {
at: PasteAt::Spelled(key),
};
let effects = cmd.execute(&ctx);
assert!(effects.is_empty());
}
@@ -332,9 +336,9 @@ mod tests {
app.cursor_mut().selected = (0, col_of(&app, "Revenue"));
press(&mut app, 'p');
let in_drill = app.view_state.status_msg.clone();
assert_eq!(
in_drill, PASTE_INSIDE_DRILL,
"setup: the drilled paste declines"
assert!(
in_drill.ends_with(INSIDE_A_DRILL_REFUSAL),
"setup: the drilled paste declines, got {in_drill:?}"
);
assert!(
!advice.to_lowercase().contains("drill"),
@@ -353,7 +357,10 @@ mod tests {
ctx.yanked = &yanked;
assert!(!ctx.none_cats().is_empty(), "setup: the view aggregates");
let key = record("West", "Revenue").with("Product", "Gadget");
let effects = PasteCell { key: key.clone() }.execute(&ctx);
let effects = PasteCell {
at: PasteAt::Spelled(key.clone()),
}
.execute(&ctx);
let mut app = aggregated_app(crate::view::Axis::None);
app.apply_effects(effects);
assert_eq!(
@@ -389,6 +396,163 @@ mod tests {
let dbg = effects_debug(&effects);
assert!(dbg.contains("Save"), "Expected Save, got: {dbg}");
}
/// improvise-q9eg: a record that lacks a later category is still a record —
/// see context/test-evidence/command.md.
#[test]
fn p_onto_a_sparse_record_writes_it() {
let mut app = records_app();
app.model_state.workbook.add_category("Season").unwrap();
app.rebuild_layout();
assert!(
app.layout.none_cats.iter().any(|c| c == "Season"),
"setup: the new category is aggregated away, so every record lacks it"
);
app.cursor_mut().selected = (0, col_of(&app, "Cost"));
press(&mut app, 'y');
press(&mut app, 'y');
app.cursor_mut().selected = (1, col_of(&app, "Cost"));
press(&mut app, 'p');
assert_eq!(
app.model_state
.workbook
.model
.get_cell(&record("West", "Cost")),
Some(&CellValue::Number(700.0)),
"status: {:?}",
app.view_state.status_msg
);
}
/// improvise-lx7w — see context/test-evidence/command.md.
#[test]
fn x_in_records_mode_clears_the_real_record_under_the_cursor() {
let mut app = records_app();
app.cursor_mut().selected = (1, col_of(&app, "Cost"));
press(&mut app, 'x');
let wb = &app.model_state.workbook;
assert_eq!(
wb.model.get_cell(&record("West", "Cost")),
None,
"West/Cost"
);
assert_eq!(
wb.model.get_cell(&record("East", "Cost")),
Some(&CellValue::Number(700.0)),
"the neighbouring record is untouched"
);
let (text, back) = saved_and_reloaded(wb);
assert!(!text.contains("West, _Measure=Cost"), "saved:\n{text}");
assert_eq!(back.model.get_cell(&record("West", "Cost")), None);
assert_eq!(synthetic_cells(&back), Vec::<CellKey>::new());
}
/// improvise-lx7w: a coordinate column stores no value, so `x` declines there.
#[test]
fn x_on_a_records_coordinate_column_changes_nothing_and_says_so() {
let mut app = records_app();
let before = crate::persistence::format_md(&app.model_state.workbook);
app.cursor_mut().selected = (1, col_of(&app, "Region"));
press(&mut app, 'x');
assert_eq!(
crate::persistence::format_md(&app.model_state.workbook),
before
);
assert!(
app.view_state.status_msg.ends_with(NO_VALUE_REFUSAL),
"declined out loud, got {:?}",
app.view_state.status_msg
);
}
/// Two regions by two raw measures and `Profit = Revenue - Cost`, the formula
/// in the third column, so the cursor on it is off row 0 and column 0.
fn formula_app() -> App {
let mut wb = Workbook::new("T");
wb.add_category("Region").unwrap();
wb.add_item("Region", "East").unwrap();
wb.add_item("Region", "West").unwrap();
wb.add_item("_Measure", "Revenue").unwrap();
wb.add_item("_Measure", "Cost").unwrap();
wb.model.add_formula(
crate::formula::parse_formula("Profit = Revenue - Cost", "_Measure").unwrap(),
);
wb.active_view_mut()
.set_axis("Region", crate::view::Axis::Row);
wb.active_view_mut()
.set_axis("_Measure", crate::view::Axis::Column);
for (region, measure, n) in [
("East", "Revenue", 10.0),
("East", "Cost", 4.0),
("West", "Revenue", 9.0),
("West", "Cost", 2.0),
] {
wb.model
.set_cell(record(region, measure), CellValue::Number(n));
}
let mut app = App::new(wb, None);
app.view_state.yanked = Some(CellValue::Number(99.0));
app
}
/// improvise-54g1 — see context/test-evidence/command.md.
#[test]
fn p_onto_a_formula_owned_cell_writes_nothing_and_names_the_formula() {
let mut app = formula_app();
app.cursor_mut().selected = (1, 2);
assert_eq!(app.layout.col_label(2), "Profit", "setup: on the formula");
press(&mut app, 'p');
let (text, back) = saved_and_reloaded(&app.model_state.workbook);
assert!(
!text.contains("_Measure=Profit"),
"saved under the formula:\n{text}"
);
assert_eq!(back.model.get_cell(&record("West", "Profit")), None);
assert!(
app.view_state
.status_msg
.contains("Profit is computed by a formula"),
"declined out loud, got {:?}",
app.view_state.status_msg
);
// The control: one column left is a stored cell, and p writes it.
app.cursor_mut().selected = (1, 1);
press(&mut app, 'p');
let wb = &app.model_state.workbook;
assert_eq!(
wb.model.get_cell(&record("West", "Cost")),
Some(&CellValue::Number(99.0))
);
}
/// The second door onto a formula cell: Tab advances the editor onto it, where
/// `i` would have declined. See context/test-evidence/command.md.
#[test]
fn tab_advancing_onto_a_formula_cell_commits_nothing_under_it() {
let mut app = formula_app();
app.cursor_mut().selected = (1, 1);
press(&mut app, 'i');
assert!(app.view_state.mode.is_editing(), "setup: editing West/Cost");
app.handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE))
.unwrap();
assert_eq!(
app.cursor().selected,
(1, 2),
"setup: Tab moved onto Profit"
);
for c in "7".chars() {
press(&mut app, c);
}
app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
.unwrap();
let (text, back) = saved_and_reloaded(&app.model_state.workbook);
assert!(
!text.contains("_Measure=Profit"),
"saved under the formula:\n{text}"
);
assert_eq!(back.model.get_cell(&record("West", "Profit")), None);
}
}
// ── Cell operations ──────────────────────────────────────────────────────────
@@ -432,20 +596,31 @@ impl Cmd for YankCell {
}
}
/// Where a paste lands: a key the cursor's door already resolved, or one a
/// script spelled out, which only the key's own shape can vet (improvise-q9eg).
#[derive(Debug)]
pub enum PasteAt {
Resolved(CellKey),
Spelled(CellKey),
}
/// Paste the yanked value into a cell.
#[derive(Debug)]
pub struct PasteCell {
pub key: crate::model::cell::CellKey,
pub at: PasteAt,
}
impl Cmd for PasteCell {
fn name(&self) -> &'static str {
"paste"
}
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
match ctx.yanked.clone() {
Some(value) => vec![paste_into(ctx, &self.key, value)],
None => vec![],
}
let Some(value) = ctx.yanked.clone() else {
return vec![];
};
vec![match &self.at {
PasteAt::Resolved(key) => Box::new(effect::SetCell(key.clone(), value)),
PasteAt::Spelled(key) => paste_into(ctx, key, value),
}]
}
}
@@ -454,7 +629,7 @@ impl Cmd for PasteCell {
fn paste_into(ctx: &CmdContext, key: &CellKey, value: CellValue) -> Box<dyn Effect> {
match synthetic_record_info(key) {
Some((row, col)) => paste_into_record(ctx, row, col, value),
None if aggregates(ctx, key) => effect::set_status(PASTE_ONTO_AGGREGATE),
None if aggregates(ctx, key) => effect::set_status(AGGREGATE_REFUSAL),
None => Box::new(effect::SetCell(key.clone(), value)),
}
}
@@ -466,9 +641,9 @@ fn paste_into_record(
value: CellValue,
) -> Box<dyn Effect> {
match (ctx.layout.record_cell_key(row, col), ctx.has_drill_state) {
(Some(_), true) => effect::set_status(PASTE_INSIDE_DRILL),
(Some(_), true) => effect::set_status(INSIDE_A_DRILL_REFUSAL),
(Some(real), false) => Box::new(effect::SetCell(real, value)),
(None, _) => effect::set_status(PASTE_ONTO_NO_VALUE),
(None, _) => effect::set_status(NO_VALUE_REFUSAL),
}
}
@@ -479,10 +654,6 @@ fn aggregates(ctx: &CmdContext, key: &CellKey) -> bool {
})
}
const PASTE_ONTO_AGGREGATE: &str = "Cannot paste onto an aggregate — it combines several records, so there is no one cell to write";
const PASTE_ONTO_NO_VALUE: &str = "Paste lands only on a measure column";
const PASTE_INSIDE_DRILL: &str = "Paste is not staged inside a drill — edit the cell instead";
// ── View commands ────────────────────────────────────────────────────────────
#[derive(Debug)]
@@ -11,6 +11,21 @@ use crate::ui::app::AppMode;
use crate::ui::effect::{Effect, Panel};
use crate::view::{Axis, GridLayout, View};
use crate::workbook::Workbook;
use improvise_protocol::CellId;
/// What a command says when the cursor sits on a collapsed group's row (§14).
pub const FOLD_REFUSAL: &str = "Collapsed group — press z to expand it first";
const NO_CELL: &str = "no cell at cursor";
pub const AGGREGATE_REFUSAL: &str =
"Cannot write to an aggregate — it combines several records, so there is no one cell to write";
pub const NO_VALUE_REFUSAL: &str = "Only a measure column holds a value";
pub const INSIDE_A_DRILL_REFUSAL: &str =
"A value is not staged inside a drill — edit the cell instead";
/// What a command says when the cell it would write is a formula's result.
pub fn derived_refusal(target: &str) -> String {
format!("{target} is computed by a formula — edit the formula, not the cell")
}
/// Read-only context available to commands for decision-making: a `&Model`, the
/// active `&View`, and the `&Workbook` for the rare command that enumerates views.
@@ -64,10 +79,75 @@ impl<'a> CmdContext<'a> {
self.layout.is_records_mode()
}
/// The cell under the cursor, for a command that only READS it. The writers'
/// doors: `context/code/command.md`, "The cursor's cell has three doors".
pub fn cell_key(&self) -> Option<CellKey> {
self.layout.cell_key(self.selected.0, self.selected.1)
}
/// The cursor's key for a command that resolves it itself (commit, drill),
/// refusing a fold: its key names a group, which no record carries (§14).
pub fn unfolded_cell_key(&self) -> Result<CellKey, String> {
match self.layout.folds_a_group(self.selected.0, self.selected.1) {
true => Err(FOLD_REFUSAL.to_string()),
false => self.cell_key().ok_or_else(|| NO_CELL.to_string()),
}
}
/// The cursor's key for a commit, which resolves a records position itself
/// (gotcha 19). An aggregate or a formula's result is refused, as `i` refuses it.
pub fn committable_cell_key(&self) -> Result<CellKey, String> {
match self.cursor_cell_id() {
Some(CellId::Aggregate(_)) => Err(self.aggregate_refusal()),
Some(CellId::Derived { target }) => Err(derived_refusal(&target)),
Some(CellId::Record(_) | CellId::Create(_) | CellId::Coordinate) | None => {
self.cell_key().ok_or_else(|| NO_CELL.to_string())
}
}
}
/// The stored cell a VALUE written at the cursor lands on, answered by the
/// `CellId` the browser is given: `Record` is the one settable arm (ADR 002).
pub fn value_cell_key(&self) -> Result<CellKey, String> {
match self.cursor_cell_id() {
Some(CellId::Record(_)) => self.stored_cell_key(),
Some(CellId::Aggregate(_)) => Err(self.aggregate_refusal()),
Some(CellId::Derived { target }) => Err(derived_refusal(&target)),
Some(CellId::Create(_) | CellId::Coordinate) => Err(NO_VALUE_REFUSAL.to_string()),
None => Err(match self.cell_key() {
Some(_) => NO_VALUE_REFUSAL,
None => NO_CELL,
}
.to_string()),
}
}
/// What the cursor's cell is, asked exactly as the projection asks it.
fn cursor_cell_id(&self) -> Option<CellId> {
crate::ui::project::cell_id(self.model, self.layout, self.selected.0, self.selected.1)
}
/// An aggregate's refusal, naming the fold where the aggregate is one (§14).
fn aggregate_refusal(&self) -> String {
match self.layout.folds_a_group(self.selected.0, self.selected.1) {
true => FOLD_REFUSAL,
false => AGGREGATE_REFUSAL,
}
.to_string()
}
/// A `Record`'s real key: a records position resolves through its row, and a
/// drill's records are staged, never written directly (gotcha 1, 19).
fn stored_cell_key(&self) -> Result<CellKey, String> {
let (row, col) = self.selected;
let stored = match (self.is_records_mode(), self.has_drill_state) {
(true, true) => return Err(INSIDE_A_DRILL_REFUSAL.to_string()),
(true, false) => self.layout.record_cell_key(row, col),
(false, _) => self.cell_key(),
};
stored.ok_or_else(|| NO_CELL.to_string())
}
/// The formula that owns the cell under the cursor, if one does. In records
/// mode the synthetic key names no measure, so the record's key is resolved.
pub fn owning_formula(&self) -> Option<&'a crate::formula::Formula> {
@@ -11,6 +11,96 @@ mod tests {
use super::*;
use crate::command::cmd::test_helpers::*;
fn press(app: &mut crate::ui::app::App, c: char) {
use crate::command::key::{KeyCode, KeyEvent, KeyModifiers};
app.handle_key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE))
.unwrap();
}
/// improvise-gw18 — see context/test-evidence/command.md.
#[test]
fn drilling_into_a_fold_is_refused_and_o_after_it_writes_no_group_coordinate() {
let mut app = app_on_a_fold();
let origin = app.view_state.active_view.clone();
press(&mut app, '>');
assert_eq!(app.view_state.active_view, origin, "no drill was opened");
assert!(
app.view_state.status_msg.contains("ollapsed group"),
"the refusal is out loud, got: {:?}",
app.view_state.status_msg
);
press(&mut app, 'o');
let (text, back) = saved_and_reloaded(&app.model_state.workbook);
let data = text.split("## Data").nth(1).expect("a data section");
assert!(
!data.contains("Q2"),
"a group coordinate was saved:\n{text}"
);
assert_eq!(non_item_coordinates(&back), Vec::<(String, String)>::new());
}
/// The commit door onto a fold: Enter advances the editor onto it, and the next
/// commit is what must refuse. See context/test-evidence/command.md.
#[test]
fn enter_advancing_onto_a_fold_commits_nothing_there() {
use crate::command::key::{KeyCode, KeyEvent, KeyModifiers};
let enter = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
let mut app = app_on_a_fold();
app.cursor_mut().selected = (1, 1);
press(&mut app, 'i');
app.handle_key(enter).unwrap();
assert_eq!(
app.cursor().selected,
(2, 1),
"setup: Enter moved onto the fold"
);
press(&mut app, '7');
app.handle_key(enter).unwrap();
let (text, back) = saved_and_reloaded(&app.model_state.workbook);
let data = text.split("## Data").nth(1).expect("a data section");
assert!(
!data.contains("Q2"),
"a group coordinate was saved:\n{text}"
);
assert_eq!(non_item_coordinates(&back), Vec::<(String, String)>::new());
}
/// The control for the refusal above: the same key one row up drills.
#[test]
fn drilling_into_the_row_above_a_fold_still_opens_a_drill() {
let mut app = app_on_a_fold();
app.cursor_mut().selected = (1, 1);
let origin = app.view_state.active_view.clone();
press(&mut app, '>');
assert_ne!(app.view_state.active_view, origin, "Feb × Cost drills");
}
/// improvise-yk2s — see context/test-evidence/command.md.
#[test]
fn hiding_a_folds_row_is_refused_and_persists_no_hidden_group() {
let mut app = app_on_a_fold();
press(&mut app, 'H');
let view = app.model_state.workbook.active_view();
assert!(!view.is_hidden("Month", "Q2"), "no ghost hidden entry");
assert!(
app.view_state.status_msg.contains("ollapsed group"),
"the refusal is out loud, got: {:?}",
app.view_state.status_msg
);
let (text, _) = saved_and_reloaded(&app.model_state.workbook);
assert!(
!text.contains("hidden:"),
"a hidden entry was saved:\n{text}"
);
// The control: the row above is an item, and H hides it.
app.cursor_mut().selected = (1, 1);
press(&mut app, 'H');
let view = app.model_state.workbook.active_view();
assert!(view.is_hidden("Month", "Feb"), "H on Feb hides Feb");
}
#[test]
fn toggle_group_under_cursor_returns_empty_without_groups() {
let m = two_cat_model();
@@ -505,6 +595,10 @@ impl Cmd for HideSelectedRowItem {
let Some(cat_name) = ctx.layout.row_cats.first().cloned() else {
return vec![];
};
// A fold's row names a group, not an item, so there is nothing to hide (§14).
if ctx.layout.row_folds_a_group(ctx.selected.0) {
return vec![effect::refuse(super::core::FOLD_REFUSAL)];
}
let sel_row = ctx.selected.0;
let Some(items) = ctx
.layout
@@ -143,4 +143,62 @@ pub(super) mod test_helpers {
pub fn make_registry() -> CmdRegistry {
default_registry()
}
/// Two quarters of months on Row, two measures on Column, every cell funded,
/// Q2 collapsed: rows `[Jan, Feb, Q2]`, and the cursor on the fold at `(2, 1)`.
pub fn app_on_a_fold() -> crate::ui::app::App {
use crate::model::cell::{CellKey, CellValue};
use crate::view::Axis;
let mut wb = Workbook::new("T");
wb.add_category("Month").unwrap();
for (month, quarter) in [("Jan", "Q1"), ("Feb", "Q1"), ("Apr", "Q2"), ("May", "Q2")] {
wb.add_item_in_group("Month", month, quarter).unwrap();
}
wb.add_item("_Measure", "Revenue").unwrap();
wb.add_item("_Measure", "Cost").unwrap();
for (i, month) in ["Jan", "Feb", "Apr", "May"].into_iter().enumerate() {
for measure in ["Revenue", "Cost"] {
let key = CellKey::new(vec![
("Month".into(), month.into()),
("_Measure".into(), measure.into()),
]);
wb.model.set_cell(key, CellValue::Number(10.0 + i as f64));
}
}
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");
let mut app = crate::ui::app::App::new(wb, None);
app.rebuild_layout();
app.cursor_mut().selected = (2, 1);
assert!(
app.layout.folds_a_group(2, 1),
"setup: the cursor is on the fold"
);
assert_eq!(app.layout.row_label(2), "Q2", "setup: the fold is Q2's row");
app
}
/// Every stored coordinate naming no item of its category — what a write to a
/// fold leaves behind (core.md §14).
pub fn non_item_coordinates(wb: &Workbook) -> Vec<(String, String)> {
let model = &wb.model;
model
.data
.iter_cells()
.flat_map(|(key, _)| key.0.clone())
.filter(|(cat, item)| {
!model
.category(cat)
.is_some_and(|c| c.items.contains_key(item.as_str()))
})
.collect()
}
/// The document saved and read back: what reaches the file, not the store.
pub fn saved_and_reloaded(wb: &Workbook) -> (String, Workbook) {
let text = crate::persistence::format_md(wb);
let back = crate::persistence::parse_md(&text).expect("the saved text reloads");
(text, back)
}
}
@@ -380,9 +380,8 @@ impl Cmd for EditOrDrill {
// The same question `cell_id` asks, off the same predicate. In records mode
// the synthetic key names no measure, so the record's key is resolved.
if let Some(formula) = ctx.owning_formula() {
return vec![effect::refuse(format!(
"{} is computed by a formula — edit the formula, not the cell",
formula.target
return vec![effect::refuse(super::core::derived_refusal(
&formula.target,
))];
}
vec![
@@ -22,9 +22,20 @@ use super::text_buffer::*;
use super::tile::*;
use super::wizard::*;
/// The cell under the cursor: what a coordinate command falls back to when given none.
/// The cell under the cursor for `drill-into-cell`, which opens aggregates but
/// not a fold. Which command takes which door: `context/code/command.md`.
fn cursor_key(ctx: &CmdContext) -> Result<CellKey, String> {
ctx.cell_key().clone().ok_or("no cell at cursor".into())
ctx.unfolded_cell_key()
}
/// The stored cell a value lands on, for `clear-cell` and `paste`.
fn cursor_value_key(ctx: &CmdContext) -> Result<CellKey, String> {
ctx.value_cell_key()
}
/// [`cursor_key`] for the one command that only READS the cell: `yank`.
fn cursor_key_to_read(ctx: &CmdContext) -> Result<CellKey, String> {
ctx.cell_key().ok_or("no cell at cursor".into())
}
impl AtCursor for Move {
@@ -57,7 +68,7 @@ fn commit_at_cursor(
Ok(CommitAndAdvance {
edit_mode: parse_mode_name(mode)?,
value: read_buffer(ctx, "edit"),
key: cursor_key(ctx)?,
key: ctx.committable_cell_key()?,
advance,
cursor: CursorState::from_ctx(ctx),
})
@@ -89,7 +100,7 @@ pub fn default_registry() -> CmdRegistry {
},
Some(|_, ctx| {
Ok(ClearCellCommand {
key: cursor_key(ctx)?,
key: cursor_value_key(ctx)?,
})
}),
);
@@ -237,25 +248,25 @@ pub fn default_registry() -> CmdRegistry {
},
Some(|_, ctx| {
Ok(YankCell {
key: cursor_key(ctx)?,
key: cursor_key_to_read(ctx)?,
})
}),
);
r.register(
&PasteCell {
key: CellKey::new(vec![]),
at: PasteAt::Spelled(CellKey::new(vec![])),
},
|args| {
if args.is_empty() {
return Err("paste requires at least one Cat/Item coordinate".into());
}
Ok(PasteCell {
key: parse_cell_key_from_args(args),
at: PasteAt::Spelled(parse_cell_key_from_args(args)),
})
},
Some(|_, ctx| {
Ok(PasteCell {
key: cursor_key(ctx)?,
at: PasteAt::Resolved(cursor_value_key(ctx)?),
})
}),
);
@@ -477,6 +477,69 @@ mod tests {
out
}
/// improvise-gw18: every Normal key, prefixes walked, pressed on a fold with a
/// value yanked — see context/test-evidence/command.md.
#[test]
fn no_normal_mode_key_pressed_on_a_fold_stores_a_group_coordinate() {
use improvise_protocol::key::KeyEvent;
let ks = KeymapSet::default_keymaps();
let normal = ks
.maps()
.get(&ModeKey::Normal)
.expect("a Normal keymap")
.clone();
let mods = [
KeyModifiers::NONE,
KeyModifiers::SHIFT,
KeyModifiers::CONTROL,
KeyModifiers::ALT,
];
let mut strokes: Vec<Vec<(KeyCode, KeyModifiers)>> = Vec::new();
for key in probe_keys() {
for m in mods {
match normal.lookup(key, m) {
Some(Binding::Prefix(sub)) => strokes.extend(
probe_keys()
.into_iter()
.flat_map(|k2| mods.map(|m2| (k2, m2)))
.filter(|&(k2, m2)| sub.lookup(k2, m2).is_some())
.map(|second| vec![(key, m), second]),
),
Some(_) => strokes.push(vec![(key, m)]),
None => {}
}
}
}
assert!(
strokes
.iter()
.any(|s| s == &[(KeyCode::Char('p'), KeyModifiers::NONE)]),
"the sweep reaches paste"
);
// No key on a fold may touch the stored data or the hidden set at all.
let touched = |app: &crate::ui::app::App| {
let wb = &app.model_state.workbook;
let text = crate::persistence::format_md(wb);
let data = text.split("## Data").nth(1).unwrap_or_default().to_string();
(data, wb.active_view().hidden_items.clone())
};
let before = touched(&test_helpers::app_on_a_fold());
for stroke in &strokes {
let mut app = test_helpers::app_on_a_fold();
app.view_state.yanked = Some(crate::model::cell::CellValue::Number(5.0));
for &(key, m) in stroke {
let _ = app.handle_key(KeyEvent::new(key, m));
}
let stray = test_helpers::non_item_coordinates(&app.model_state.workbook);
assert!(stray.is_empty(), "{stroke:?} on a fold stored {stray:?}");
assert_eq!(
touched(&app),
before,
"{stroke:?} on a fold changed the document"
);
}
}
/// A representative `AppMode` for each keymap, so a test can put a
/// `CmdContext` in the mode whose bindings it is walking.
fn app_mode_for(mode: &ModeKey) -> AppMode {
+8 -4
View File
@@ -280,7 +280,7 @@ fn cell_display(
text,
numeric: matches!(value, Some(CellValue::Number(_))),
error: matches!(value, Some(CellValue::Error(_))),
id: cell_id(app, layout, row, col),
id: cell_id(&app.model_state.workbook.model, layout, row, col),
value: value.clone(),
};
}
@@ -289,7 +289,7 @@ fn cell_display(
text: plan.at(key.as_ref()).value(value.as_ref()),
numeric: matches!(value, Some(CellValue::Number(_))),
error: matches!(value, Some(CellValue::Error(_))),
id: cell_id(app, layout, row, col),
id: cell_id(&app.model_state.workbook.model, layout, row, col),
// The *unformatted* value the TUI seeds its edit buffer from. Projecting it
// beats a client un-formatting `text` (gotcha 47).
edit_text: key.map(|key| app.display_value_in(layout, &key)),
@@ -299,8 +299,12 @@ fn cell_display(
/// What the cell at `(row, col)` *is*, as the surrogate the client echoes back. The
/// fork is `EditOrDrill`'s, asked the same way — see `context/code/command.md`.
fn cell_id(app: &App, layout: &GridLayout, row: usize, col: usize) -> Option<CellId> {
let model = &app.model_state.workbook.model;
pub(crate) fn cell_id(
model: &Model,
layout: &GridLayout,
row: usize,
col: usize,
) -> Option<CellId> {
if layout.is_records_mode() {
// A measure can be BOTH an explicit `_Measure` item and a formula target,
// so the document is asked about the record's real key (improvise-ir7l).
+6 -1
View File
@@ -736,7 +736,12 @@ impl GridLayout {
/// Whether the cell at `(row, col)` folds a collapsed group: an aggregate
/// with no record under it. The one home of that answer (§14).
pub fn folds_a_group(&self, row: usize, col: usize) -> bool {
folds(&self.row_items, row) || folds(&self.col_items, col)
self.row_folds_a_group(row) || folds(&self.col_items, col)
}
/// The row half of [`Self::folds_a_group`], for a command about the row alone.
pub fn row_folds_a_group(&self, row: usize) -> bool {
folds(&self.row_items, row)
}
/// The first data row of `cat`/`group`'s run under `prefix` — where a toggle
@@ -1895,6 +1895,185 @@ fn yy_over_an_aggregate_copies_the_sum_the_host_copies() {
);
}
// The cursor's cell as a write target (improvise-lx7w, improvise-54g1, improvise-gw18).
/// improvise-lx7w: `x` on a records measure clears the real record on both ends.
/// Evidence: `context/test-evidence/clients.md`.
#[test]
fn x_in_records_mode_clears_the_record_the_browser_clears() {
let r = KeyEvent::new(KeyCode::Char('R'), KeyModifiers::NONE);
let two_records = || {
let mut app = furnished_host();
let wb = &mut app.model_state.workbook;
wb.add_item("_Measure", "Cost").unwrap();
// Two measures, so clearing one leaves South's row where the cursor is.
for (measure, n) in [("Revenue", 900.0), ("Cost", 40.0)] {
let key = north_revenue()
.with("Region", "South")
.with("_Measure", measure);
wb.model
.set_cell(key, improvise_protocol::CellValue::Number(n));
}
app.rebuild_layout();
reduce_key(&mut app, r);
app
};
let (mut app, mut twin) = (two_records(), two_records());
let revenue = (0..app.layout.col_count())
.find(|&c| app.layout.col_label(c) == "Revenue")
.expect("a Revenue column");
assert_eq!(
app.layout.record_row(1).unwrap().coords.get("Region"),
Some("South"),
"setup: row 1 is South"
);
for host in [&mut app, &mut twin] {
host.cursor_mut().selected = (1, revenue);
}
let mut client = records_client(&app);
client.view.cursor_mut().selected = (1, revenue);
both_ends(&mut app, &mut twin, &mut client, "x");
let model = &app.model_state.workbook.model;
assert_eq!(
model.get_cell(&north_revenue().with("Region", "South")),
None
);
assert!(
model.get_cell(&north_revenue()).is_some(),
"North is untouched"
);
}
/// improvise-q9eg: `yy` then `p` onto a record that lacks a later category,
/// on both ends. Evidence: `context/test-evidence/clients.md`.
#[test]
fn p_onto_a_sparse_record_writes_what_the_browser_writes() {
let r = KeyEvent::new(KeyCode::Char('R'), KeyModifiers::NONE);
let sparse = || {
let mut app = furnished_host();
let south = north_revenue().with("Region", "South");
app.model_state
.workbook
.model
.set_cell(south, improvise_protocol::CellValue::Number(900.0));
app.rebuild_layout();
reduce_key(&mut app, r);
// As `+cat` does: every existing record now lacks `Season`.
app.model_state.workbook.add_category("Season").unwrap();
app.rebuild_layout();
app
};
let (mut app, mut twin) = (sparse(), sparse());
assert!(
app.layout.none_cats.iter().any(|c| c == "Season"),
"setup: the new category is aggregated away"
);
let revenue = (0..app.layout.col_count())
.find(|&c| app.layout.col_label(c) == "Revenue")
.expect("a Revenue column");
for host in [&mut app, &mut twin] {
host.cursor_mut().selected = (0, revenue);
}
let mut client = records_client(&app);
client.view.cursor_mut().selected = (0, revenue);
both_ends(&mut app, &mut twin, &mut client, "y");
both_ends(&mut app, &mut twin, &mut client, "y");
for host in [&mut app, &mut twin] {
host.cursor_mut().selected = (1, revenue);
}
client.view.cursor_mut().selected = (1, revenue);
both_ends(&mut app, &mut twin, &mut client, "p");
assert_eq!(
app.model_state
.workbook
.model
.get_cell(&north_revenue().with("Region", "South")),
Some(&improvise_protocol::CellValue::Number(1200.0)),
"the paste must land, or both ends agreeing is both declining"
);
}
/// `key` at `at` on a host and on a client seeded from it: neither writes.
fn declines_on_both_ends(mut app: App, at: (usize, usize), key: char) {
let event = KeyEvent::new(KeyCode::Char(key), KeyModifiers::NONE);
app.view_state.yanked = Some(improvise_protocol::CellValue::Number(99.0));
app.cursor_mut().selected = at;
let client = client_ready_to_edit(&app, (6, 6));
assert!(
!composes_a_write(&client, event),
"the browser composed a write"
);
let before = document_text(&app);
reduce_key(&mut app, event);
assert_eq!(
document_text(&app),
before,
"the TUI wrote where the browser declines"
);
assert!(
!app.view_state.status_msg.is_empty(),
"the TUI declined silently"
);
}
/// improvise-54g1: `p` on a formula-owned cell. The control is the stored cell
/// beside it. Evidence: `context/test-evidence/clients.md`.
#[test]
fn p_onto_a_formula_owned_cell_declines_on_both_ends() {
let app = yank_host();
assert_eq!(
app.layout.col_label(3),
"Profit",
"setup: column 3 is the formula"
);
declines_on_both_ends(app, (1, 3), 'p');
let mut app = yank_host();
app.view_state.yanked = Some(improvise_protocol::CellValue::Number(99.0));
app.cursor_mut().selected = (1, 2);
let client = client_ready_to_edit(&app, (6, 6));
let p = KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE);
assert!(
composes_a_write(&client, p),
"the control: Cost is settable"
);
}
/// improvise-gw18/l4mp: `p` and `x` on a collapsed group's fold, off the origin.
#[test]
fn a_write_onto_a_fold_declines_on_both_ends() {
let fold_host = || {
let mut wb = Workbook::new("Test");
wb.add_category("Month").unwrap();
for (month, quarter) in [("Jan", "Q1"), ("Feb", "Q1"), ("Apr", "Q2"), ("May", "Q2")] {
wb.add_item_in_group("Month", month, quarter).unwrap();
}
wb.add_item("_Measure", "Revenue").unwrap();
wb.add_item("_Measure", "Cost").unwrap();
for month in ["Jan", "Feb", "Apr", "May"] {
for measure in ["Revenue", "Cost"] {
let key = improvise_command::model::cell::CellKey::new(vec![
("Month".to_string(), month.to_string()),
("_Measure".to_string(), measure.to_string()),
]);
wb.model
.set_cell(key, improvise_protocol::CellValue::Number(3.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("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");
app
};
declines_on_both_ends(fold_host(), (2, 1), 'p');
declines_on_both_ends(fold_host(), (2, 1), 'x');
}
/// Two regions by two measures, `value` at (South, Revenue): row 1, column 1,
/// so "the cell under the cursor" is not "the first cell".
fn precise_host(value: improvise_protocol::CellValue) -> App {