diff --git a/context/code/core.md b/context/code/core.md index 7eb5b389..a608ff4a 100644 --- a/context/code/core.md +++ b/context/code/core.md @@ -580,6 +580,36 @@ builds on demand. Both put the virtual `_Index`/`_Dim` categories on axes, and those categories are never persisted — so these views are rebuilt from scratch each time rather than saved (improvise-60z). +### What the view must be is model-side; how a front end gets there is not + +`view::layout` answers **what** a virtual view is, as data: `records_spine()` +(`_Index` on Row, `_Dim` on Column — the pair `GridLayout` auto-detects records +mode by), `records_steps(model)` and `drill_steps(key, none_cats)`, each a +`Vec` of `Axis` and `PageSelection` assignments. Nothing in them +names an `Effect`, an `App` or a front end. + +The command layer answers **how**: `cmd/grid.rs::steps_into_active_view` maps +each step to the `effect::SetAxis`/`SetPageSelection` a TUI keypress applies to +`ViewTarget::Active`. That is the only thing the two rebuilders still have in +common, and it is three lines of translation. + +The split exists because a second front end has to reach the same view without +the effect layer — a browser composes `ModelCommand`s naming `_Drill`, not +effects targeting an active view it does not own. Design-principles §5 is the +rule: a derivation done twice is the defect even when both copies are right +today, and before this split the drill's filter placement and the records +spine were spelled out twice in `cmd/grid.rs` with nothing keeping them in +agreement. `drill_constraint` and `drill_snapshot` were already model-side for +the same reason and are untouched; §8 says why their **order** is the thing +that cannot be derived a second time. + +What deliberately did **not** move is `DrillState`. It holds an `Rc` and its +`pending_edits` are uncommitted writes belonging to whoever staged them — the +session-local residue of gotcha 30 — so it stays on `App`, which is where that +tag puts it. Separating the view configuration does not make the staging map +portable, and a decomposition that claimed otherwise would be the interesting +half of this ticket answered by assertion. + ### `create_view` replaces, and `unused_view_name` is why it may keep doing so `Workbook::create_view` inserts over whatever holds the name. That is not an diff --git a/crates/improvise-command/src/command/cmd/grid.rs b/crates/improvise-command/src/command/cmd/grid.rs index 2c3acbd3..cc68b51d 100644 --- a/crates/improvise-command/src/command/cmd/grid.rs +++ b/crates/improvise-command/src/command/cmd/grid.rs @@ -1,4 +1,3 @@ -use crate::model::category::{CategoryKind, DIM_CATEGORY, INDEX_CATEGORY}; use crate::model::cell::CellValue; use crate::ui::app::AppMode; use crate::ui::effect::{self, Effect, ViewTarget}; @@ -334,6 +333,30 @@ impl Cmd for ViewNavigate { } } +/// The UI half of a view configuration: how *this* front end reaches what +/// `improvise-core` says the view must be. The browser's answer is its own. +fn steps_into_active_view(steps: Vec) -> Vec> { + steps + .into_iter() + .map(|step| -> Box { + match step { + crate::view::ViewStep::Axis { category, axis } => Box::new(effect::SetAxis { + view: ViewTarget::Active, + category, + axis, + }), + crate::view::ViewStep::PageSelection { category, item } => { + Box::new(effect::SetPageSelection { + view: ViewTarget::Active, + category, + item, + }) + } + } + }) + .collect() +} + /// Drill into an aggregated cell: a `_Drill` view with `_Index` on Row and `_Dim` on /// Column. The drilled cell's fixed coordinates become filters (gotcha 27). #[derive(Debug)] @@ -348,8 +371,8 @@ impl Cmd for DrillIntoCell { let drill_name = crate::view::DRILL_VIEW.to_string(); let mut effects: Vec> = Vec::new(); - // Both halves have ONE home, shared with the addressed drill a session - // asks for: `context/code/core.md` §8 (gotchas 10 and 18). + // Every model-side half has ONE home in `improvise-core`, shared with + // whatever front end asks: `context/code/core.md` §8 (gotchas 10, 18). let drill_key = crate::view::drill_constraint(ctx.model, &self.key); let records = crate::view::drill_snapshot(ctx.model, &drill_key); let n = records.len(); @@ -361,46 +384,10 @@ impl Cmd for DrillIntoCell { effects.push(Box::new(effect::CreateView(drill_name.clone()))); effects.push(Box::new(effect::SwitchView(drill_name))); - // Records mode: _Index on Row, _Dim on Column - effects.push(Box::new(effect::SetAxis { - view: ViewTarget::Active, - category: INDEX_CATEGORY.to_string(), - axis: crate::view::Axis::Row, - })); - effects.push(Box::new(effect::SetAxis { - view: ViewTarget::Active, - category: DIM_CATEGORY.to_string(), - axis: crate::view::Axis::Column, - })); - - // Fixed coords go to Filter: an immutable constraint of this drill rather - // than a dimension to page through (improvise-dz8). - let fixed_cats: std::collections::HashSet = - self.key.0.iter().map(|(c, _)| c.clone()).collect(); - for (cat, item) in &self.key.0 { - effects.push(Box::new(effect::SetAxis { - view: ViewTarget::Active, - category: cat.clone(), - axis: crate::view::Axis::Filter, - })); - effects.push(Box::new(effect::SetPageSelection { - view: ViewTarget::Active, - category: cat.clone(), - item: item.clone(), - })); - } - // Previously-aggregated categories stay on `Axis::None` so they do not filter - // records; they appear as columns. `_Index`/`_Dim` are already set. - for cat in ctx.none_cats() { - if fixed_cats.contains(cat) || cat.starts_with('_') { - continue; - } - effects.push(Box::new(effect::SetAxis { - view: ViewTarget::Active, - category: cat.clone(), - axis: crate::view::Axis::None, - })); - } + effects.extend(steps_into_active_view(crate::view::drill_steps( + &self.key, + ctx.none_cats(), + ))); effects.push(effect::set_status(format!("Drilled into cell: {n} rows"))); effects @@ -457,26 +444,9 @@ impl Cmd for ToggleRecordsMode { effects.push(Box::new(effect::CreateView(records_name.clone()))); effects.push(Box::new(effect::SwitchView(records_name))); - // _Index on Row, _Dim on Column, everything else -> None - effects.push(Box::new(effect::SetAxis { - view: ViewTarget::Active, - category: INDEX_CATEGORY.to_string(), - axis: crate::view::Axis::Row, - })); - effects.push(Box::new(effect::SetAxis { - view: ViewTarget::Active, - category: DIM_CATEGORY.to_string(), - axis: crate::view::Axis::Column, - })); - for name in ctx.model.categories.keys() { - if !CategoryKind::of_name(name).is_index_or_dim() { - effects.push(Box::new(effect::SetAxis { - view: ViewTarget::Active, - category: name.clone(), - axis: crate::view::Axis::None, - })); - } - } + effects.extend(steps_into_active_view(crate::view::records_steps( + ctx.model, + ))); effects.push(effect::change_mode(AppMode::RecordsNormal)); effects.push(effect::set_status("Records mode")); effects diff --git a/crates/improvise-core/src/view/layout.rs b/crates/improvise-core/src/view/layout.rs index 9cafca1c..e2b937ff 100644 --- a/crates/improvise-core/src/view/layout.rs +++ b/crates/improvise-core/src/view/layout.rs @@ -75,7 +75,7 @@ impl RecordRow { pub fn drill_constraint(model: &Model, key: &CellKey) -> CellKey { let names_a_formula = key.get(MEASURE_CATEGORY).is_some_and(|target| { model.formulas().iter().any(|f| { - crate::model::category::CategoryKind::of_name(&f.target_category).is_measure() + crate::model::category::CategoryKind::of_name(&f.target_category).is_index_or_dim() && f.target == target }) }); @@ -104,6 +104,73 @@ pub fn drill_snapshot(model: &Model, constraint: &CellKey) -> Vec { RecordRow::group(cells) } +/// One step of a view configuration: **what** a view must be, with no answer +/// to how a front end gets there. The model half of drill — core.md §8. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ViewStep { + Axis { category: String, axis: Axis }, + PageSelection { category: String, item: String }, +} + +/// The records-mode spine: `_Index` on Row and `_Dim` on Column, which is +/// what `GridLayout` auto-detects records mode by (§6). +pub fn records_spine() -> Vec { + vec![ + ViewStep::Axis { + category: INDEX_CATEGORY.to_string(), + axis: Axis::Row, + }, + ViewStep::Axis { + category: DIM_CATEGORY.to_string(), + axis: Axis::Column, + }, + ] +} + +/// The `_Records` view over `model`: the spine, and every other category +/// aggregated away onto `Axis::None`. +pub fn records_steps(model: &Model) -> Vec { + let mut steps = records_spine(); + let rest = model + .categories + .keys() + .filter(|name| !crate::model::category::CategoryKind::of_name(name).is_measure()); + steps.extend(rest.map(|name| ViewStep::Axis { + category: name.clone(), + axis: Axis::None, + })); + steps +} + +/// The `_Drill` view a drill into `key` needs, given the categories the grid +/// it was launched from was aggregating over. Coordinates fix on **Filter**. +pub fn drill_steps(key: &CellKey, none_cats: &[String]) -> Vec { + let mut steps = records_spine(); + // A drilled coordinate is an immutable constraint of this drill, not a + // dimension to page through (improvise-dz8, gotcha 27). + for (cat, item) in &key.0 { + steps.push(ViewStep::Axis { + category: cat.clone(), + axis: Axis::Filter, + }); + steps.push(ViewStep::PageSelection { + category: cat.clone(), + item: item.clone(), + }); + } + // Previously-aggregated categories stay on `Axis::None` so they do not + // filter records; they appear as columns. + let fixed: std::collections::HashSet<&str> = key.0.iter().map(|(c, _)| c.as_str()).collect(); + let free = none_cats + .iter() + .filter(|cat| !fixed.contains(cat.as_str()) && !cat.starts_with('_')); + steps.extend(free.map(|cat| ViewStep::Axis { + category: cat.clone(), + axis: Axis::None, + })); + steps +} + /// What a records-mode column holds, resolved from the column index so a /// category and a measure sharing a name stay distinguishable. #[derive(Debug, Clone, PartialEq, Eq, Hash)] diff --git a/crates/improvise-core/src/view/mod.rs b/crates/improvise-core/src/view/mod.rs index 488dc073..e567d3c6 100644 --- a/crates/improvise-core/src/view/mod.rs +++ b/crates/improvise-core/src/view/mod.rs @@ -4,8 +4,9 @@ pub mod types; pub use axis::{Axis, AxisColor}; pub use layout::{ - AxisEntry, GridLayout, NEW_CATEGORY_COL, NEW_MEASURE_COL, RecordCol, RecordRow, - drill_constraint, drill_snapshot, synthetic_record_info, + AxisEntry, GridLayout, NEW_CATEGORY_COL, NEW_MEASURE_COL, RecordCol, RecordRow, ViewStep, + drill_constraint, drill_snapshot, drill_steps, records_spine, records_steps, + synthetic_record_info, }; pub use types::{ DEFAULT_VIEW, DRILL_VIEW, RECORDS_VIEW, VIRTUAL_VIEW_NAMES, View, unused_view_name,