diff --git a/src/command/cmd.rs b/src/command/cmd.rs index 49986e0..7c99998 100644 --- a/src/command/cmd.rs +++ b/src/command/cmd.rs @@ -696,6 +696,45 @@ impl Cmd for EditOrDrill { } } +/// 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> { + if ctx.records_col.is_none() { + return vec![effect::set_status("add-record-row only works in records mode")]; + } + // Build a CellKey from the current page filters + let view = ctx.model.active_view(); + let page_cats: Vec = 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"), + ] + } +} + /// Typewriter-style advance: move down, wrap to top of next column at bottom. #[derive(Debug)] pub struct EnterAdvance { @@ -1301,6 +1340,187 @@ impl Cmd for OpenItemAddAtCursor { } } +/// Toggle expand/collapse of the category at the tree cursor. +#[derive(Debug)] +pub struct ToggleCatExpand; +impl Cmd for ToggleCatExpand { + fn name(&self) -> &'static str { + "toggle-cat-expand" + } + fn execute(&self, ctx: &CmdContext) -> Vec> { + if let Some(cat_name) = ctx.cat_at_cursor() { + vec![Box::new(effect::ToggleCatExpand(cat_name))] + } else { + vec![] + } + } +} + +/// Filter to item: when on an item row, set the category to Page with the +/// item as the filter value. +#[derive(Debug)] +pub struct FilterToItem; +impl Cmd for FilterToItem { + fn name(&self) -> &'static str { + "filter-to-item" + } + fn execute(&self, ctx: &CmdContext) -> Vec> { + use crate::ui::cat_tree::CatTreeEntry; + match ctx.cat_tree_entry() { + Some(CatTreeEntry::Item { + cat_name, + item_name, + }) => { + vec![ + Box::new(effect::SetAxis { + category: cat_name.clone(), + axis: crate::view::Axis::Page, + }), + Box::new(effect::SetPageSelection { + category: cat_name.clone(), + item: item_name.clone(), + }), + effect::set_status(format!("Filter: {cat_name} = {item_name}")), + ] + } + Some(CatTreeEntry::Category { .. }) => { + // On a category header — toggle expand instead + ToggleCatExpand.execute(ctx) + } + None => vec![], + } + } +} + +/// 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> { + let currently_on = ctx.model.active_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 (_Index on Row, _Dim on Column) and +/// pivot mode (auto-assigned axes). In records mode every cell is shown +/// as a flat row; in pivot mode the view is a cross-tab. +#[derive(Debug)] +pub struct ToggleRecordsMode; +impl Cmd for ToggleRecordsMode { + fn name(&self) -> &'static str { + "toggle-records-mode" + } + fn execute(&self, ctx: &CmdContext) -> Vec> { + use crate::view::Axis; + let view = ctx.model.active_view(); + + // Detect current state + let is_records = view + .category_axes + .get("_Index") + .copied() + == Some(Axis::Row) + && view.category_axes.get("_Dim").copied() == Some(Axis::Column); + + let mut effects: Vec> = Vec::new(); + + if is_records { + // Switch back to pivot: auto-assign axes + // First regular category → Row, second → Column, rest → Page, + // virtuals/labels → None. + let mut row_done = false; + let mut col_done = false; + for (name, cat) in &ctx.model.categories { + let axis = if !cat.kind.is_regular() { + Axis::None + } else if !row_done { + row_done = true; + Axis::Row + } else if !col_done { + col_done = true; + Axis::Column + } else { + Axis::Page + }; + effects.push(Box::new(effect::SetAxis { + category: name.clone(), + axis, + })); + } + effects.push(effect::set_status("Pivot mode")); + } else { + // Switch to records mode + effects.push(Box::new(effect::SetAxis { + category: "_Index".to_string(), + axis: Axis::Row, + })); + effects.push(Box::new(effect::SetAxis { + category: "_Dim".to_string(), + axis: Axis::Column, + })); + // Everything else → None + for name in ctx.model.categories.keys() { + if name != "_Index" && name != "_Dim" { + effects.push(Box::new(effect::SetAxis { + category: name.clone(), + axis: Axis::None, + })); + } + } + effects.push(effect::set_status("Records mode")); + } + effects + } +} + +/// Delete the category or item at the panel cursor. +/// On a category header → delete the whole category. +/// On an item row → delete just that item. +#[derive(Debug)] +pub struct DeleteCategoryAtCursor; +impl Cmd for DeleteCategoryAtCursor { + fn name(&self) -> &'static str { + "delete-category-at-cursor" + } + fn execute(&self, ctx: &CmdContext) -> Vec> { + use crate::ui::cat_tree::CatTreeEntry; + match ctx.cat_tree_entry() { + Some(CatTreeEntry::Category { name, .. }) => { + vec![ + Box::new(effect::RemoveCategory(name.clone())), + effect::mark_dirty(), + effect::set_status(format!("Deleted category '{name}'")), + ] + } + Some(CatTreeEntry::Item { + cat_name, + item_name, + }) => { + vec![ + Box::new(effect::RemoveItem { + category: cat_name.clone(), + item: item_name.clone(), + }), + effect::mark_dirty(), + effect::set_status(format!("Deleted item '{item_name}' from '{cat_name}'")), + ] + } + None => vec![effect::set_status("No category to delete")], + } + } +} + // ── View panel commands ───────────────────────────────────────────────────── /// Switch to the view at the panel cursor and return to Normal mode. @@ -2468,26 +2688,42 @@ pub fn default_registry() -> CmdRegistry { // ── Panel operations ───────────────────────────────────────────────── r.register( - &TogglePanelAndFocus { panel: Panel::Formula, currently_open: false }, + &TogglePanelAndFocus { panel: Panel::Formula, open: true, focused: true }, |args| { + // Parse: toggle-panel-and-focus [open] [focused] require_args("toggle-panel-and-focus", args, 1)?; let panel = parse_panel(&args[0])?; + let open = args.get(1).map(|s| s == "true").unwrap_or(true); + let focused = args.get(2).map(|s| s == "true").unwrap_or(open); Ok(Box::new(TogglePanelAndFocus { panel, - currently_open: false, + open, + focused, })) }, |args, ctx| { require_args("toggle-panel-and-focus", args, 1)?; let panel = parse_panel(&args[0])?; + // Default interactive: if already open+focused → close, else open+focus let currently_open = match panel { Panel::Formula => ctx.formula_panel_open, Panel::Category => ctx.category_panel_open, Panel::View => ctx.view_panel_open, }; + let currently_focused = match panel { + Panel::Formula => matches!(ctx.mode, AppMode::FormulaPanel | AppMode::FormulaEdit { .. }), + Panel::Category => matches!(ctx.mode, AppMode::CategoryPanel | AppMode::CategoryAdd { .. } | AppMode::ItemAdd { .. }), + Panel::View => matches!(ctx.mode, AppMode::ViewPanel), + }; + let (open, focused) = if currently_open && currently_focused { + (false, false) // close + } else { + (true, true) // open + focus + }; Ok(Box::new(TogglePanelAndFocus { panel, - currently_open, + open, + focused, })) }, ); @@ -2565,8 +2801,14 @@ pub fn default_registry() -> CmdRegistry { r.register_nullary(|| { Box::new(DeleteFormulaAtCursor) }); + r.register_nullary(|| Box::new(AddRecordRow)); + r.register_nullary(|| Box::new(TogglePruneEmpty)); + r.register_nullary(|| Box::new(ToggleRecordsMode)); r.register_nullary(|| Box::new(CycleAxisAtCursor)); r.register_nullary(|| Box::new(OpenItemAddAtCursor)); + r.register_nullary(|| Box::new(DeleteCategoryAtCursor)); + r.register_nullary(|| Box::new(ToggleCatExpand)); + r.register_nullary(|| Box::new(FilterToItem)); r.register_nullary(|| Box::new(SwitchViewAtCursor)); r.register_nullary(|| Box::new(CreateAndSwitchView)); r.register_nullary(|| Box::new(DeleteViewAtCursor)); diff --git a/src/command/keymap.rs b/src/command/keymap.rs index ec2e7e7..ec2a7f0 100644 --- a/src/command/keymap.rs +++ b/src/command/keymap.rs @@ -354,9 +354,14 @@ impl KeymapSet { normal.bind(KeyCode::Char('z'), none, "toggle-group-under-cursor"); normal.bind(KeyCode::Char('H'), none, "hide-selected-row-item"); - // Drill into aggregated cell / view history + // Drill into aggregated cell / view history / add row normal.bind(KeyCode::Char('>'), none, "drill-into-cell"); normal.bind(KeyCode::Char('<'), none, "view-back"); + normal.bind(KeyCode::Char('o'), none, "add-record-row"); + + // Records mode toggle and prune toggle + normal.bind(KeyCode::Char('R'), none, "toggle-records-mode"); + normal.bind(KeyCode::Char('P'), none, "toggle-prune-empty"); // Tile select normal.bind(KeyCode::Char('T'), none, "enter-tile-select"); diff --git a/src/model/category.rs b/src/model/category.rs index 2c4f224..bac3aed 100644 --- a/src/model/category.rs +++ b/src/model/category.rs @@ -117,6 +117,10 @@ impl Category { id } + pub fn remove_item(&mut self, name: &str) { + self.items.shift_remove(name); + } + pub fn add_item_in_group( &mut self, name: impl Into, diff --git a/src/model/types.rs b/src/model/types.rs index 5a0ea59..b1ffd3b 100644 --- a/src/model/types.rs +++ b/src/model/types.rs @@ -107,6 +107,47 @@ impl Model { Ok(id) } + /// Remove a category and all cells that reference it. + pub fn remove_category(&mut self, name: &str) { + if !self.categories.contains_key(name) { + return; + } + self.categories.shift_remove(name); + // Remove from all views + for view in self.views.values_mut() { + view.on_category_removed(name); + } + // Remove cells that have a coord in this category + let to_remove: Vec = self + .data + .iter_cells() + .filter(|(k, _)| k.get(name).is_some()) + .map(|(k, _)| k) + .collect(); + for k in to_remove { + self.data.remove(&k); + } + // Remove formulas targeting this category + self.formulas + .retain(|f| f.target_category != name); + } + + /// Remove an item from a category and all cells that reference it. + pub fn remove_item(&mut self, cat_name: &str, item_name: &str) { + if let Some(cat) = self.categories.get_mut(cat_name) { + cat.remove_item(item_name); + } + let to_remove: Vec = self + .data + .iter_cells() + .filter(|(k, _)| k.get(cat_name) == Some(item_name)) + .map(|(k, _)| k) + .collect(); + for k in to_remove { + self.data.remove(&k); + } + } + pub fn category_mut(&mut self, name: &str) -> Option<&mut Category> { self.categories.get_mut(name) } @@ -527,6 +568,31 @@ mod model_tests { assert_eq!(m.get_cell(&k4), Some(&CellValue::Number(40.0))); } + #[test] + fn remove_category_deletes_category_and_cells() { + let mut m = Model::new("Test"); + m.add_category("Region").unwrap(); + m.add_category("Product").unwrap(); + m.category_mut("Region").unwrap().add_item("East"); + m.category_mut("Product").unwrap().add_item("Shirts"); + m.set_cell( + coord(&[("Region", "East"), ("Product", "Shirts")]), + CellValue::Number(42.0), + ); + m.remove_category("Region"); + assert!(m.category("Region").is_none()); + // Cells referencing Region should be gone + assert_eq!( + m.data.iter_cells().count(), + 0, + "all cells with Region coord should be removed" + ); + // Views should no longer know about Region + // (axis_of would panic for unknown category, so check categories_on) + let v = m.active_view(); + assert!(v.categories_on(crate::view::Axis::Row).is_empty()); + } + #[test] fn create_view_copies_category_structure() { let mut m = Model::new("Test"); diff --git a/src/ui/effect.rs b/src/ui/effect.rs index 0945091..83b7fd1 100644 --- a/src/ui/effect.rs +++ b/src/ui/effect.rs @@ -90,6 +90,68 @@ impl Effect for RemoveFormula { } } +/// Re-enter edit mode by reading the cell value at the current cursor. +/// Used after commit+advance to continue data entry. +#[derive(Debug)] +pub struct EnterEditAtCursor; +impl Effect for EnterEditAtCursor { + fn apply(&self, app: &mut App) { + let ctx = app.cmd_context(crossterm::event::KeyCode::Null, crossterm::event::KeyModifiers::NONE); + let value = if let Some(v) = &ctx.records_value { + v.clone() + } else { + ctx.cell_key + .as_ref() + .and_then(|k| ctx.model.get_cell(k).cloned()) + .map(|v| v.to_string()) + .unwrap_or_default() + }; + drop(ctx); + app.buffers.insert("edit".to_string(), value); + app.mode = AppMode::Editing { + buffer: String::new(), + }; + } +} + +#[derive(Debug)] +pub struct TogglePruneEmpty; +impl Effect for TogglePruneEmpty { + fn apply(&self, app: &mut App) { + let v = app.model.active_view_mut(); + v.prune_empty = !v.prune_empty; + } +} + +#[derive(Debug)] +pub struct ToggleCatExpand(pub String); +impl Effect for ToggleCatExpand { + fn apply(&self, app: &mut App) { + if !app.expanded_cats.remove(&self.0) { + app.expanded_cats.insert(self.0.clone()); + } + } +} + +#[derive(Debug)] +pub struct RemoveItem { + pub category: String, + pub item: String, +} +impl Effect for RemoveItem { + fn apply(&self, app: &mut App) { + app.model.remove_item(&self.category, &self.item); + } +} + +#[derive(Debug)] +pub struct RemoveCategory(pub String); +impl Effect for RemoveCategory { + fn apply(&self, app: &mut App) { + app.model.remove_category(&self.0); + } +} + // ── View mutations ─────────────────────────────────────────────────────────── #[derive(Debug)]