1a359b4ae6
A collapsed group is written to CSV as its own total row, because an export writes what the grid draws (improvise-iot6). core.md §14 carries the argument; the evidence entries record each rival and where it failed. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
746 lines
26 KiB
Rust
746 lines
26 KiB
Rust
use crate::model::cell::CellValue;
|
|
use crate::ui::app::AppMode;
|
|
use crate::ui::effect::{self, Effect, ViewTarget};
|
|
use crate::view::{AxisEntry, GridLayout};
|
|
|
|
use super::core::{Cmd, CmdContext};
|
|
use super::navigation::viewport_effects;
|
|
|
|
#[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());
|
|
}
|
|
|
|
fn two_groups_on_the_row_axis() -> crate::workbook::Workbook {
|
|
let mut wb = crate::workbook::Workbook::new("T");
|
|
wb.add_category("Month").unwrap();
|
|
wb.add_category("Type").unwrap();
|
|
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.add_item_in_group("Month", "May", "Q2").unwrap();
|
|
wb.add_item("Type", "Food").unwrap();
|
|
wb
|
|
}
|
|
|
|
/// The real path: the App's own context, the command, then `apply_effects`,
|
|
/// so a placement the toggle's reframe throws away is seen.
|
|
fn toggle_group_at_cursor(app: &mut crate::ui::app::App) -> String {
|
|
use crate::command::key::{KeyCode, KeyModifiers};
|
|
let effects = {
|
|
let ctx = app.cmd_context(KeyCode::Char('z'), KeyModifiers::NONE);
|
|
ToggleGroupAtCursor { is_row: true }.execute(&ctx)
|
|
};
|
|
let dbg = effects_debug(&effects);
|
|
app.apply_effects(effects);
|
|
dbg
|
|
}
|
|
|
|
fn row_labels(app: &crate::ui::app::App) -> Vec<String> {
|
|
(0..app.layout.row_count())
|
|
.map(|r| app.layout.row_label(r))
|
|
.collect()
|
|
}
|
|
|
|
/// Evidence: `context/test-evidence/command.md`.
|
|
#[test]
|
|
fn toggling_at_the_cursor_names_the_group_the_cursor_sits_in_and_collapses_it() {
|
|
use crate::ui::app::App;
|
|
|
|
let mut app = App::new(two_groups_on_the_row_axis(), None);
|
|
app.rebuild_layout();
|
|
// Off row 0, or a toggle that ignores the cursor names Q1 and passes.
|
|
app.cursor_mut().selected = (2, 0);
|
|
assert!(
|
|
!app.model_state
|
|
.workbook
|
|
.active_view()
|
|
.is_group_collapsed("Month", "Q2"),
|
|
"the fixture starts expanded, or the assertion below proves nothing"
|
|
);
|
|
|
|
let dbg = toggle_group_at_cursor(&mut app);
|
|
|
|
assert!(
|
|
dbg.contains(r#"ToggleGroup { view: Active, category: "Month", group: "Q2" }"#),
|
|
"the cursor sits on Apr, which is in Q2, got: {dbg}"
|
|
);
|
|
let view = app.model_state.workbook.active_view();
|
|
assert!(
|
|
view.is_group_collapsed("Month", "Q2"),
|
|
"the effect was returned but the view did not move"
|
|
);
|
|
assert!(
|
|
!view.is_group_collapsed("Month", "Q1"),
|
|
"the neighbour was left alone"
|
|
);
|
|
}
|
|
|
|
/// Evidence: `context/test-evidence/command.md`.
|
|
#[test]
|
|
fn toggling_a_group_collapses_that_group_and_the_cursor_follows_it() {
|
|
use crate::ui::app::App;
|
|
|
|
let mut app = App::new(two_groups_on_the_row_axis(), None);
|
|
app.rebuild_layout();
|
|
app.cursor_mut().selected = (2, 0);
|
|
assert_eq!(
|
|
app.layout.row_label(2),
|
|
"Apr",
|
|
"the cursor starts inside Q2"
|
|
);
|
|
|
|
let dbg = toggle_group_at_cursor(&mut app);
|
|
|
|
assert!(dbg.contains(r#"group: "Q2""#), "Apr is in Q2, got: {dbg}");
|
|
assert_eq!(row_labels(&app), ["Jan", "Feb", "Q2"]);
|
|
assert_eq!(app.cursor().selected, (2, 0), "the cursor is on the Q2 row");
|
|
|
|
let dbg = toggle_group_at_cursor(&mut app);
|
|
|
|
assert!(
|
|
dbg.contains(r#"group: "Q2""#),
|
|
"z again names Q2, got: {dbg}"
|
|
);
|
|
assert_eq!(
|
|
row_labels(&app),
|
|
["Jan", "Feb", "Apr", "May"],
|
|
"z z is a no-op"
|
|
);
|
|
assert_eq!(app.cursor().selected, (2, 0), "back on Q2's first item");
|
|
}
|
|
|
|
/// Evidence: `context/test-evidence/command.md`.
|
|
#[test]
|
|
fn a_collapse_from_the_tail_of_a_run_lands_on_the_runs_first_row() {
|
|
use crate::ui::app::App;
|
|
|
|
let mut app = App::new(two_groups_on_the_row_axis(), None);
|
|
app.rebuild_layout();
|
|
app.cursor_mut().selected = (3, 0);
|
|
assert_eq!(app.layout.row_label(3), "May");
|
|
|
|
toggle_group_at_cursor(&mut app);
|
|
|
|
assert_eq!(row_labels(&app), ["Jan", "Feb", "Q2"]);
|
|
assert_eq!(app.cursor().selected, (2, 0), "May folded into the Q2 row");
|
|
}
|
|
|
|
/// Evidence: `context/test-evidence/command.md`.
|
|
#[test]
|
|
fn collapsing_an_inner_group_lands_where_every_earlier_run_shrank() {
|
|
use crate::ui::app::App;
|
|
use crate::view::Axis;
|
|
|
|
let mut wb = crate::workbook::Workbook::new("T");
|
|
wb.add_category("Region").unwrap();
|
|
wb.add_category("Month").unwrap();
|
|
wb.add_category("Type").unwrap();
|
|
wb.add_item("Region", "North").unwrap();
|
|
wb.add_item("Region", "South").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("Type", "Food").unwrap();
|
|
wb.active_view_mut().set_axis("Region", Axis::Row);
|
|
wb.active_view_mut().set_axis("Month", Axis::Row);
|
|
wb.active_view_mut().set_axis("Type", Axis::Column);
|
|
let mut app = App::new(wb, None);
|
|
app.rebuild_layout();
|
|
assert_eq!(
|
|
app.layout.row_cats,
|
|
["Region", "Month"],
|
|
"Month is the INNER level"
|
|
);
|
|
app.cursor_mut().selected = (6, 0);
|
|
assert_eq!(
|
|
app.layout.row_tuple(6),
|
|
Some(&["South".into(), "Apr".into()][..])
|
|
);
|
|
|
|
toggle_group_at_cursor(&mut app);
|
|
|
|
assert_eq!(
|
|
row_labels(&app),
|
|
[
|
|
"North/Jan",
|
|
"North/Feb",
|
|
"North/Q2",
|
|
"South/Jan",
|
|
"South/Feb",
|
|
"South/Q2"
|
|
]
|
|
);
|
|
assert_eq!(
|
|
app.cursor().selected,
|
|
(5, 0),
|
|
"South's Q2, not the old index 6"
|
|
);
|
|
}
|
|
|
|
/// Evidence: `context/test-evidence/command.md`.
|
|
#[test]
|
|
fn a_collapsed_group_can_be_re_expanded_from_the_grid() {
|
|
use crate::ui::app::App;
|
|
|
|
let mut app = App::new(two_groups_on_the_row_axis(), None);
|
|
app.rebuild_layout();
|
|
app.cursor_mut().selected = (2, 0);
|
|
toggle_group_at_cursor(&mut app);
|
|
app.cursor_mut().selected = (1, 0);
|
|
toggle_group_at_cursor(&mut app);
|
|
assert_eq!(row_labels(&app), ["Q1", "Q2"], "both groups collapsed");
|
|
app.cursor_mut().selected = (1, 0);
|
|
|
|
let dbg = toggle_group_at_cursor(&mut app);
|
|
|
|
assert!(
|
|
dbg.contains(r#"group: "Q2""#),
|
|
"the Q2 row names Q2, got: {dbg}"
|
|
);
|
|
assert_eq!(row_labels(&app), ["Q1", "Apr", "May"]);
|
|
assert_eq!(app.cursor().selected, (1, 0), "on Q2's first item");
|
|
}
|
|
|
|
#[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 must return the underlying data records: the
|
|
/// target coordinate is stripped so `matching_cells` finds them (gotcha 10).
|
|
#[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.add_item("Region", "East").unwrap();
|
|
m.add_item("_Measure", "Revenue").unwrap();
|
|
m.add_item("_Measure", "Cost").unwrap();
|
|
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}"
|
|
);
|
|
}
|
|
|
|
/// improvise-9mo9 made `SetAxis` reset the cursor, and a drill issues several.
|
|
/// The view drilled FROM must keep its position — see `context/code/command.md`.
|
|
#[test]
|
|
fn drilling_resets_only_the_drill_views_cursor() {
|
|
use crate::ui::app::App;
|
|
|
|
let wb = two_cat_model();
|
|
let mut app = App::new(wb, None);
|
|
app.rebuild_layout();
|
|
let origin_view = app.view_state.active_view.clone();
|
|
app.cursor_mut().selected = (1, 1);
|
|
app.cursor_mut().row_offset = 1;
|
|
|
|
let effects = {
|
|
let reg = make_registry();
|
|
let mut ctx = make_ctx(&app.model_state.workbook, &app.layout, ®);
|
|
ctx.selected = (1, 1);
|
|
let key = ctx.cell_key().expect("a cell under the cursor");
|
|
DrillIntoCell { key }.execute(&ctx)
|
|
};
|
|
app.apply_effects(effects);
|
|
|
|
assert_ne!(
|
|
app.view_state.active_view, origin_view,
|
|
"the drill did not switch views, so this proves nothing about per-view cursors"
|
|
);
|
|
assert_eq!(
|
|
app.cursor().selected,
|
|
(0, 0),
|
|
"the drill view opened somewhere other than its origin"
|
|
);
|
|
assert_eq!(
|
|
app.cursor_in(&origin_view).selected,
|
|
(1, 1),
|
|
"the drill threw away the cursor in the view it was launched from"
|
|
);
|
|
assert_eq!(app.cursor_in(&origin_view).row_offset, 1);
|
|
}
|
|
|
|
/// improvise-dz8: a drilled coordinate is a constraint, not a dimension to page
|
|
/// through, so it lands on `Filter`. It used to land on `Page`.
|
|
#[test]
|
|
fn drilling_fixes_its_coordinates_on_filter_not_page() {
|
|
let m = two_cat_model();
|
|
let layout = make_layout(&m);
|
|
let reg = make_registry();
|
|
let ctx = make_ctx(&m, &layout, ®);
|
|
|
|
let key = crate::model::cell::CellKey::new(vec![("Region".into(), "East".into())]);
|
|
let effects = DrillIntoCell { key }.execute(&ctx);
|
|
let dbg = effects_debug(&effects);
|
|
|
|
assert!(
|
|
dbg.contains(r#"SetAxis { view: Active, category: "Region", axis: Filter }"#),
|
|
"the drilled coordinate should be fixed on Filter, got: {dbg}"
|
|
);
|
|
assert!(
|
|
!dbg.contains(r#"SetAxis { view: Active, category: "Region", axis: Page }"#),
|
|
"and must not also be offered as a page dimension, 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![];
|
|
};
|
|
let landing = self.landing(ctx, &cat, &group);
|
|
// The toggle reframes and so resets the cursor; the placement must come
|
|
// AFTER it or it is thrown away (gotcha 24, core.md §14).
|
|
let mut effects: Vec<Box<dyn Effect>> = vec![Box::new(effect::ToggleGroup {
|
|
view: ViewTarget::Active,
|
|
category: cat,
|
|
group,
|
|
})];
|
|
effects.extend(
|
|
landing
|
|
.map(|at| self.place_cursor(ctx, at))
|
|
.unwrap_or_default(),
|
|
);
|
|
effects
|
|
}
|
|
}
|
|
|
|
impl ToggleGroupAtCursor {
|
|
/// Where the toggled group sits once toggled, asked of the layout the toggle
|
|
/// will produce — collapsing an inner level shifts every earlier run (§14).
|
|
fn landing(&self, ctx: &CmdContext, cat: &str, group: &str) -> Option<usize> {
|
|
let (cats, tuple) = match self.is_row {
|
|
true => (&ctx.layout.row_cats, ctx.layout.row_tuple(ctx.selected.0)?),
|
|
false => (&ctx.layout.col_cats, ctx.layout.col_tuple(ctx.selected.1)?),
|
|
};
|
|
let prefix = tuple.get(..cats.iter().position(|c| c == cat)?)?;
|
|
let mut view = ctx.view.clone();
|
|
view.toggle_group_collapse(cat, group);
|
|
let after = GridLayout::with_frozen_records(ctx.model, &view, None);
|
|
match self.is_row {
|
|
true => after.row_run_start(cat, group, prefix),
|
|
false => after.col_run_start(cat, group, prefix),
|
|
}
|
|
}
|
|
|
|
/// The cursor on `at`, the scroll restored where the toggle zeroed it and
|
|
/// moved only as far as `at` needs.
|
|
fn place_cursor(&self, ctx: &CmdContext, at: usize) -> Vec<Box<dyn Effect>> {
|
|
let (row, col) = match self.is_row {
|
|
true => (at, ctx.selected.1),
|
|
false => (ctx.selected.0, at),
|
|
};
|
|
let mut effects: Vec<Box<dyn Effect>> = vec![
|
|
Box::new(effect::SetRowOffset(ctx.row_offset)),
|
|
Box::new(effect::SetColOffset(ctx.col_offset)),
|
|
];
|
|
effects.extend(viewport_effects(
|
|
row,
|
|
col,
|
|
ctx.row_offset,
|
|
ctx.col_offset,
|
|
ctx.visible_rows,
|
|
ctx.visible_cols,
|
|
));
|
|
effects
|
|
}
|
|
}
|
|
|
|
/// 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 { tuple, .. } = e {
|
|
Some(tuple)
|
|
} else {
|
|
None
|
|
}
|
|
})
|
|
.nth(sel_row)
|
|
else {
|
|
return vec![];
|
|
};
|
|
let item_name = items[0].clone();
|
|
vec![Box::new(effect::HideItem {
|
|
view: ViewTarget::Active,
|
|
category: cat_name,
|
|
item: item_name,
|
|
})]
|
|
}
|
|
}
|
|
|
|
/// 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),
|
|
]
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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<crate::view::ViewStep>) -> Vec<Box<dyn Effect>> {
|
|
steps
|
|
.into_iter()
|
|
.map(|step| -> Box<dyn Effect> {
|
|
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)]
|
|
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 = crate::view::DRILL_VIEW.to_string();
|
|
let mut effects: Vec<Box<dyn Effect>> = Vec::new();
|
|
|
|
// 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();
|
|
|
|
// 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)));
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
/// 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(ViewTarget::Active)),
|
|
effect::set_status(if currently_on {
|
|
"Showing all rows/columns"
|
|
} else {
|
|
"Hiding empty rows/columns"
|
|
}),
|
|
]
|
|
}
|
|
}
|
|
|
|
/// Toggle records mode through the view stack: entering creates a `_Records` view
|
|
/// and switches to it, leaving navigates back.
|
|
#[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 {
|
|
// Leaving cleans up the empty-`CellKey` records `AddRecordRow` makes with
|
|
// no page filters — the inverse of the entry `SortData`.
|
|
return vec![
|
|
Box::new(effect::CleanEmptyRecords),
|
|
Box::new(effect::ViewBack),
|
|
effect::set_status("Pivot mode"),
|
|
];
|
|
}
|
|
|
|
let mut effects: Vec<Box<dyn Effect>> = Vec::new();
|
|
let records_name = crate::view::RECORDS_VIEW.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)));
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
/// 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
|
|
.filtering_cats()
|
|
.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);
|
|
// The row this lands on is named, not inherited — improvise-o9uq, and
|
|
// the ORDER is the fix: `context/code/command.md`.
|
|
let existing = (0..ctx.layout.row_count()).find(|&r| {
|
|
ctx.layout
|
|
.record_row(r)
|
|
.is_some_and(|rec| rec.coords == key)
|
|
});
|
|
let mut effects: Vec<Box<dyn Effect>> = Vec::new();
|
|
match existing {
|
|
None => effects.push(Box::new(effect::SetCell(key, CellValue::Number(0.0)))),
|
|
Some(_) => effects.push(effect::set_status(
|
|
"That record row is already here — a row is its coordinates, so \
|
|
fill this one in first",
|
|
)),
|
|
}
|
|
let target_row = existing.unwrap_or_else(|| ctx.layout.row_count());
|
|
effects.extend(super::navigation::viewport_effects(
|
|
target_row,
|
|
0,
|
|
ctx.row_offset,
|
|
ctx.col_offset,
|
|
ctx.visible_rows,
|
|
ctx.visible_cols,
|
|
));
|
|
if existing.is_none() {
|
|
effects.push(effect::set_status("Added new record row"));
|
|
}
|
|
effects
|
|
}
|
|
}
|