484 lines
16 KiB
Rust
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, ®);
|
|
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, ®);
|
|
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, ®);
|
|
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, ®);
|
|
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, ®);
|
|
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, ®);
|
|
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, ®);
|
|
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, ®);
|
|
|
|
// 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"),
|
|
]
|
|
}
|
|
}
|