Files
improvise/src/command/cmd/grid.rs
Edward Langley a22478eb87 Merge branch 'main' into worktree-improvise-ewi-formula-crate
# Conflicts:
#	src/ui/app.rs
#	src/ui/effect.rs
#	src/view/layout.rs
2026-04-15 21:39:00 -07:00

484 lines
16 KiB
Rust

use crate::model::cell::CellValue;
use crate::ui::app::AppMode;
use crate::ui::effect::{self, Effect};
use crate::view::AxisEntry;
use super::core::{Cmd, CmdContext};
#[cfg(test)]
mod tests {
use super::*;
use crate::command::cmd::test_helpers::*;
#[test]
fn toggle_group_under_cursor_returns_empty_without_groups() {
let m = two_cat_model();
let layout = make_layout(&m);
let reg = make_registry();
let ctx = make_ctx(&m, &layout, &reg);
let cmd = ToggleGroupAtCursor { is_row: true };
let effects = cmd.execute(&ctx);
assert!(effects.is_empty());
}
#[test]
fn law_toggle_group_involution() {
let m = two_cat_model();
let layout = make_layout(&m);
let reg = make_registry();
let ctx = make_ctx(&m, &layout, &reg);
let cmd = ToggleGroupAtCursor { is_row: true };
let first = effects_debug(&cmd.execute(&ctx));
let second = effects_debug(&cmd.execute(&ctx));
assert_eq!(first, second, "Toggle should be structurally consistent");
}
#[test]
fn view_forward_with_empty_stack_shows_status() {
let m = two_cat_model();
let layout = make_layout(&m);
let reg = make_registry();
let ctx = make_ctx(&m, &layout, &reg);
let cmd = ViewNavigate { forward: true };
let effects = cmd.execute(&ctx);
let dbg = effects_debug(&effects);
assert!(
dbg.contains("No forward view"),
"Expected status message, got: {dbg}"
);
}
#[test]
fn view_back_with_empty_stack_shows_status() {
let m = two_cat_model();
let layout = make_layout(&m);
let reg = make_registry();
let ctx = make_ctx(&m, &layout, &reg);
let cmd = ViewNavigate { forward: false };
let effects = cmd.execute(&ctx);
let dbg = effects_debug(&effects);
assert!(
dbg.contains("No previous view"),
"Expected status message, got: {dbg}"
);
}
#[test]
fn view_forward_with_stack_produces_effect() {
let m = two_cat_model();
let layout = make_layout(&m);
let reg = make_registry();
let fwd_stack = vec![crate::ui::app::ViewFrame {
view_name: "View 2".to_string(),
mode: crate::ui::app::AppMode::Normal,
}];
let mut ctx = make_ctx(&m, &layout, &reg);
ctx.view_forward_stack = &fwd_stack;
let cmd = ViewNavigate { forward: true };
let effects = cmd.execute(&ctx);
let dbg = effects_debug(&effects);
assert!(
dbg.contains("ViewForward"),
"Expected ViewForward, got: {dbg}"
);
}
#[test]
fn view_back_with_stack_produces_apply_and_back() {
let m = two_cat_model();
let layout = make_layout(&m);
let reg = make_registry();
let back_stack = vec![crate::ui::app::ViewFrame {
view_name: "Default".to_string(),
mode: crate::ui::app::AppMode::Normal,
}];
let mut ctx = make_ctx(&m, &layout, &reg);
ctx.view_back_stack = &back_stack;
let cmd = ViewNavigate { forward: false };
let effects = cmd.execute(&ctx);
assert_eq!(effects.len(), 2);
let dbg = effects_debug(&effects);
assert!(
dbg.contains("ApplyAndClearDrill"),
"Expected ApplyAndClearDrill, got: {dbg}"
);
assert!(dbg.contains("ViewBack"), "Expected ViewBack, got: {dbg}");
}
#[test]
fn toggle_prune_empty_produces_toggle_and_dirty() {
let m = two_cat_model();
let layout = make_layout(&m);
let reg = make_registry();
let ctx = make_ctx(&m, &layout, &reg);
let effects = TogglePruneEmpty.execute(&ctx);
let dbg = effects_debug(&effects);
assert!(
dbg.contains("TogglePruneEmpty"),
"Expected TogglePruneEmpty, got: {dbg}"
);
}
/// Drilling into a formula cell (e.g. Profit = Revenue - Cost) should
/// return the underlying data records, not an empty result set. The
/// formula target coordinate is stripped from the drill key so that
/// matching_cells finds the raw data backing the formula.
#[test]
fn drill_into_formula_cell_returns_data_records() {
use crate::formula::parse_formula;
use crate::model::cell::{CellKey, CellValue};
use crate::workbook::Workbook;
let mut m = Workbook::new("Test");
m.add_category("Region").unwrap();
m.model.category_mut("Region").unwrap().add_item("East");
m.model.category_mut("_Measure").unwrap().add_item("Revenue");
m.model.category_mut("_Measure").unwrap().add_item("Cost");
m.model.set_cell(
CellKey::new(vec![
("_Measure".into(), "Revenue".into()),
("Region".into(), "East".into()),
]),
CellValue::Number(1000.0),
);
m.model.set_cell(
CellKey::new(vec![
("_Measure".into(), "Cost".into()),
("Region".into(), "East".into()),
]),
CellValue::Number(600.0),
);
m.model.add_formula(parse_formula("Profit = Revenue - Cost", "_Measure").unwrap());
let layout = make_layout(&m);
let reg = make_registry();
let ctx = make_ctx(&m, &layout, &reg);
// Drill into the Profit/East cell — a formula-derived cell
let key = CellKey::new(vec![
("_Measure".into(), "Profit".into()),
("Region".into(), "East".into()),
]);
let cmd = DrillIntoCell { key };
let effects = cmd.execute(&ctx);
let dbg = effects_debug(&effects);
// Should find underlying data records, not "0 rows"
assert!(
!dbg.contains("0 rows"),
"Drill into formula cell should find data records, got: {dbg}"
);
}
}
// ── Grid operations ─────────────────────────────────────────────────────
/// Toggle the row or column group collapse under the cursor.
#[derive(Debug)]
pub struct ToggleGroupAtCursor {
pub is_row: bool,
}
impl Cmd for ToggleGroupAtCursor {
fn name(&self) -> &'static str {
if self.is_row {
"toggle-group-under-cursor"
} else {
"toggle-col-group-under-cursor"
}
}
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
let lookup = if self.is_row {
ctx.layout.row_group_for(ctx.selected.0)
} else {
ctx.layout.col_group_for(ctx.selected.1)
};
let Some((cat, group)) = lookup else {
return vec![];
};
vec![
Box::new(effect::ToggleGroup {
category: cat,
group,
}),
effect::mark_dirty(),
]
}
}
/// Hide the row item at the cursor.
#[derive(Debug)]
pub struct HideSelectedRowItem;
impl Cmd for HideSelectedRowItem {
fn name(&self) -> &'static str {
"hide-selected-row-item"
}
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
let Some(cat_name) = ctx.layout.row_cats.first().cloned() else {
return vec![];
};
let sel_row = ctx.selected.0;
let Some(items) = ctx
.layout
.row_items
.iter()
.filter_map(|e| {
if let AxisEntry::DataItem(v) = e {
Some(v)
} else {
None
}
})
.nth(sel_row)
else {
return vec![];
};
let item_name = items[0].clone();
vec![
Box::new(effect::HideItem {
category: cat_name,
item: item_name,
}),
effect::mark_dirty(),
]
}
}
/// Navigate back or forward in view history.
#[derive(Debug)]
pub struct ViewNavigate {
pub forward: bool,
}
impl Cmd for ViewNavigate {
fn name(&self) -> &'static str {
if self.forward {
"view-forward"
} else {
"view-back"
}
}
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
if self.forward {
if ctx.view_forward_stack.is_empty() {
vec![effect::set_status("No forward view")]
} else {
vec![Box::new(effect::ViewForward)]
}
} else {
if ctx.view_back_stack.is_empty() {
vec![effect::set_status("No previous view")]
} else {
vec![
Box::new(effect::ApplyAndClearDrill),
Box::new(effect::ViewBack),
]
}
}
}
}
/// Drill down into an aggregated cell: create a _Drill view with _Index on
/// Row and _Dim on Column (records/long-format view). Fixed coordinates
/// from the drilled cell become page filters.
#[derive(Debug)]
pub struct DrillIntoCell {
pub key: crate::model::cell::CellKey,
}
impl Cmd for DrillIntoCell {
fn name(&self) -> &'static str {
"drill-into-cell"
}
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
let drill_name = "_Drill".to_string();
let mut effects: Vec<Box<dyn Effect>> = Vec::new();
// If drilling into a formula cell, strip the formula target from the
// key so matching_cells finds the underlying raw data records instead
// of returning nothing.
let drill_key = if let Some(measure_val) = self.key.get("_Measure") {
let is_formula_target = ctx
.model
.formulas()
.iter()
.any(|f| f.target_category == "_Measure" && f.target == measure_val);
if is_formula_target {
self.key.without("_Measure")
} else {
self.key.clone()
}
} else {
self.key.clone()
};
// Capture the records snapshot NOW (before we switch views).
let records: Vec<(crate::model::cell::CellKey, crate::model::cell::CellValue)> =
if drill_key.0.is_empty() {
ctx.model
.data
.iter_cells()
.map(|(k, v)| (k, v.clone()))
.collect()
} else {
ctx.model
.data
.matching_cells(&drill_key.0)
.into_iter()
.map(|(k, v)| (k, v.clone()))
.collect()
};
let n = records.len();
// Freeze the snapshot in the drill state
effects.push(Box::new(effect::StartDrill(records)));
// Create (or replace) the drill view
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 {
category: "_Index".to_string(),
axis: crate::view::Axis::Row,
}));
effects.push(Box::new(effect::SetAxis {
category: "_Dim".to_string(),
axis: crate::view::Axis::Column,
}));
// Fixed coords (from drilled cell) -> Page with that value as filter
let fixed_cats: std::collections::HashSet<String> =
self.key.0.iter().map(|(c, _)| c.clone()).collect();
for (cat, item) in &self.key.0 {
effects.push(Box::new(effect::SetAxis {
category: cat.clone(),
axis: crate::view::Axis::Page,
}));
effects.push(Box::new(effect::SetPageSelection {
category: cat.clone(),
item: item.clone(),
}));
}
// Previously-aggregated categories (none_cats) stay on Axis::None so
// they don't filter records; they'll appear as columns in records mode.
// Skip virtual categories — we already set _Index/_Dim above.
for cat in ctx.none_cats() {
if fixed_cats.contains(cat) || cat.starts_with('_') {
continue;
}
effects.push(Box::new(effect::SetAxis {
category: cat.clone(),
axis: crate::view::Axis::None,
}));
}
effects.push(effect::set_status(format!("Drilled into cell: {n} rows")));
effects
}
}
/// Toggle pruning of empty rows/columns in the current view.
#[derive(Debug)]
pub struct TogglePruneEmpty;
impl Cmd for TogglePruneEmpty {
fn name(&self) -> &'static str {
"toggle-prune-empty"
}
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
let currently_on = ctx.view.prune_empty;
vec![
Box::new(effect::TogglePruneEmpty),
effect::set_status(if currently_on {
"Showing all rows/columns"
} else {
"Hiding empty rows/columns"
}),
]
}
}
/// Toggle between records mode and pivot mode using the view stack.
/// Entering records mode creates a `_Records` view and switches to it.
/// Leaving records mode navigates back to the previous view.
#[derive(Debug)]
pub struct ToggleRecordsMode;
impl Cmd for ToggleRecordsMode {
fn name(&self) -> &'static str {
"toggle-records-mode"
}
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
let is_records = ctx.layout.is_records_mode();
if is_records {
// Navigate back to the previous view (restores original axes)
return vec![Box::new(effect::ViewBack), effect::set_status("Pivot mode")];
}
let mut effects: Vec<Box<dyn Effect>> = Vec::new();
let records_name = "_Records".to_string();
effects.push(Box::new(effect::SortData));
// Create (or replace) a _Records view and switch to it
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 {
category: "_Index".to_string(),
axis: crate::view::Axis::Row,
}));
effects.push(Box::new(effect::SetAxis {
category: "_Dim".to_string(),
axis: crate::view::Axis::Column,
}));
for name in ctx.model.categories.keys() {
if name != "_Index" && name != "_Dim" {
effects.push(Box::new(effect::SetAxis {
category: name.clone(),
axis: crate::view::Axis::None,
}));
}
}
effects.push(effect::change_mode(AppMode::RecordsNormal));
effects.push(effect::set_status("Records mode"));
effects
}
}
/// In records mode, add a new row with an empty value. The new cell gets
/// coords from the current page filters. In pivot mode, this is a no-op.
#[derive(Debug)]
pub struct AddRecordRow;
impl Cmd for AddRecordRow {
fn name(&self) -> &'static str {
"add-record-row"
}
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
if !ctx.is_records_mode() {
return vec![effect::set_status(
"add-record-row only works in records mode",
)];
}
// Build a CellKey from the current page filters
let view = ctx.view;
let page_cats: Vec<String> = view
.categories_on(crate::view::Axis::Page)
.into_iter()
.map(String::from)
.collect();
let coords: Vec<(String, String)> = page_cats
.iter()
.map(|cat| {
let sel = view.page_selection(cat).unwrap_or("").to_string();
(cat.clone(), sel)
})
.filter(|(_, v)| !v.is_empty())
.collect();
let key = crate::model::cell::CellKey::new(coords);
vec![
Box::new(effect::SetCell(key, CellValue::Number(0.0))),
effect::mark_dirty(),
effect::set_status("Added new record row"),
]
}
}