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:
@ -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));
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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<String>,
|
||||
|
||||
@ -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<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> {
|
||||
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");
|
||||
|
||||
@ -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)]
|
||||
|
||||
Reference in New Issue
Block a user