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.
#[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<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 ─────────────────────────────────────────────────────
/// 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 <panel> [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));