feat: add new commands for records mode and category management

Add new commands for enhanced data entry and category management.

AddRecordRow: Adds a new record row in records mode with empty value.
TogglePruneEmpty: Toggles pruning of empty rows/columns in pivot mode.
ToggleRecordsMode: Switches between records and pivot layout.
DeleteCategoryAtCursor: Removes a category and all its cells.
ToggleCatExpand: Expands/collapses a category in the tree.
FilterToItem: Filters to show only items matching cursor position.

Model gains remove_category() and remove_item() to delete categories
and items along with all referencing cells and formulas.

Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/Qwen3.5-35B-A3B-GGUF:Q5_K_M)
This commit is contained in:
Edward Langley
2026-04-06 15:09:57 -07:00
parent 5fe553b57a
commit 55cad99ae1
5 changed files with 383 additions and 4 deletions

View File

@ -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<Box<dyn Effect>> {
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<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"),
]
}
}
/// Typewriter-style advance: move down, wrap to top of next column at bottom. /// Typewriter-style advance: move down, wrap to top of next column at bottom.
#[derive(Debug)] #[derive(Debug)]
pub struct EnterAdvance { 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<Box<dyn Effect>> {
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<Box<dyn Effect>> {
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<Box<dyn Effect>> {
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<Box<dyn Effect>> {
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<Box<dyn Effect>> = 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<Box<dyn Effect>> {
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 ───────────────────────────────────────────────────── // ── View panel commands ─────────────────────────────────────────────────────
/// Switch to the view at the panel cursor and return to Normal mode. /// Switch to the view at the panel cursor and return to Normal mode.
@ -2468,26 +2688,42 @@ pub fn default_registry() -> CmdRegistry {
// ── Panel operations ───────────────────────────────────────────────── // ── Panel operations ─────────────────────────────────────────────────
r.register( r.register(
&TogglePanelAndFocus { panel: Panel::Formula, currently_open: false }, &TogglePanelAndFocus { panel: Panel::Formula, open: true, focused: true },
|args| { |args| {
// Parse: toggle-panel-and-focus <panel> [open] [focused]
require_args("toggle-panel-and-focus", args, 1)?; require_args("toggle-panel-and-focus", args, 1)?;
let panel = parse_panel(&args[0])?; 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 { Ok(Box::new(TogglePanelAndFocus {
panel, panel,
currently_open: false, open,
focused,
})) }))
}, },
|args, ctx| { |args, ctx| {
require_args("toggle-panel-and-focus", args, 1)?; require_args("toggle-panel-and-focus", args, 1)?;
let panel = parse_panel(&args[0])?; let panel = parse_panel(&args[0])?;
// Default interactive: if already open+focused → close, else open+focus
let currently_open = match panel { let currently_open = match panel {
Panel::Formula => ctx.formula_panel_open, Panel::Formula => ctx.formula_panel_open,
Panel::Category => ctx.category_panel_open, Panel::Category => ctx.category_panel_open,
Panel::View => ctx.view_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 { Ok(Box::new(TogglePanelAndFocus {
panel, panel,
currently_open, open,
focused,
})) }))
}, },
); );
@ -2565,8 +2801,14 @@ pub fn default_registry() -> CmdRegistry {
r.register_nullary(|| { r.register_nullary(|| {
Box::new(DeleteFormulaAtCursor) 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(CycleAxisAtCursor));
r.register_nullary(|| Box::new(OpenItemAddAtCursor)); 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(SwitchViewAtCursor));
r.register_nullary(|| Box::new(CreateAndSwitchView)); r.register_nullary(|| Box::new(CreateAndSwitchView));
r.register_nullary(|| Box::new(DeleteViewAtCursor)); r.register_nullary(|| Box::new(DeleteViewAtCursor));

View File

@ -354,9 +354,14 @@ impl KeymapSet {
normal.bind(KeyCode::Char('z'), none, "toggle-group-under-cursor"); normal.bind(KeyCode::Char('z'), none, "toggle-group-under-cursor");
normal.bind(KeyCode::Char('H'), none, "hide-selected-row-item"); 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, "drill-into-cell");
normal.bind(KeyCode::Char('<'), none, "view-back"); 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 // Tile select
normal.bind(KeyCode::Char('T'), none, "enter-tile-select"); normal.bind(KeyCode::Char('T'), none, "enter-tile-select");

View File

@ -117,6 +117,10 @@ impl Category {
id id
} }
pub fn remove_item(&mut self, name: &str) {
self.items.shift_remove(name);
}
pub fn add_item_in_group( pub fn add_item_in_group(
&mut self, &mut self,
name: impl Into<String>, name: impl Into<String>,

View File

@ -107,6 +107,47 @@ impl Model {
Ok(id) 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<CellKey> = 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<CellKey> = 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> { pub fn category_mut(&mut self, name: &str) -> Option<&mut Category> {
self.categories.get_mut(name) self.categories.get_mut(name)
} }
@ -527,6 +568,31 @@ mod model_tests {
assert_eq!(m.get_cell(&k4), Some(&CellValue::Number(40.0))); 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] #[test]
fn create_view_copies_category_structure() { fn create_view_copies_category_structure() {
let mut m = Model::new("Test"); let mut m = Model::new("Test");

View File

@ -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 ─────────────────────────────────────────────────────────── // ── View mutations ───────────────────────────────────────────────────────────
#[derive(Debug)] #[derive(Debug)]