feat: group-aware grid rendering and hide/show item

Builds out two half-finished view features:

Group collapse:
- AxisEntry enum distinguishes GroupHeader from DataItem on grid axes
- expand_category() emits group headers and filters collapsed items
- Grid renders inline group header rows with ▼/▶ indicator
- `z` keybinding toggles collapse of nearest group above cursor

Hide/show item:
- Restore show_item() (was commented out alongside hide_item)
- Add HideItem / ShowItem commands and dispatch
- `H` keybinding hides the current row item
- `:show-item <cat> <item>` command to restore hidden items
- Restore silenced test assertions for hide/show round-trip

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Edward Langley
2026-03-31 00:07:11 -07:00
parent 3cf64b40a3
commit 37584670eb
8 changed files with 1735 additions and 555 deletions

View File

@ -1,38 +1,42 @@
use crate::model::Model;
use crate::model::cell::{CellKey, CellValue};
use crate::formula::parse_formula;
use crate::persistence;
use crate::import::analyzer::{analyze_records, extract_array_at_path, FieldKind};
use super::types::{CellValueArg, Command, CommandResult};
use crate::formula::parse_formula;
use crate::import::analyzer::{analyze_records, extract_array_at_path, FieldKind};
use crate::model::cell::{CellKey, CellValue};
use crate::model::Model;
use crate::persistence;
/// Execute a command against the model, returning a result.
/// This is the single authoritative mutation path used by both the TUI and headless modes.
pub fn dispatch(model: &mut Model, cmd: &Command) -> CommandResult {
match cmd {
Command::AddCategory { name } => {
match model.add_category(name) {
Ok(_) => CommandResult::ok_msg(format!("Category '{name}' added")),
Err(e) => CommandResult::err(e.to_string()),
}
}
Command::AddCategory { name } => match model.add_category(name) {
Ok(_) => CommandResult::ok_msg(format!("Category '{name}' added")),
Err(e) => CommandResult::err(e.to_string()),
},
Command::AddItem { category, item } => {
match model.category_mut(category) {
Some(cat) => { cat.add_item(item); CommandResult::ok() }
None => CommandResult::err(format!("Category '{category}' not found")),
Command::AddItem { category, item } => match model.category_mut(category) {
Some(cat) => {
cat.add_item(item);
CommandResult::ok()
}
}
None => CommandResult::err(format!("Category '{category}' not found")),
},
Command::AddItemInGroup { category, item, group } => {
match model.category_mut(category) {
Some(cat) => { cat.add_item_in_group(item, group); CommandResult::ok() }
None => CommandResult::err(format!("Category '{category}' not found")),
Command::AddItemInGroup {
category,
item,
group,
} => match model.category_mut(category) {
Some(cat) => {
cat.add_item_in_group(item, group);
CommandResult::ok()
}
}
None => CommandResult::err(format!("Category '{category}' not found")),
},
Command::SetCell { coords, value } => {
let kv: Vec<(String, String)> = coords.iter()
let kv: Vec<(String, String)> = coords
.iter()
.map(|pair| (pair[0].clone(), pair[1].clone()))
.collect();
// Validate all categories exist before mutating anything
@ -55,7 +59,8 @@ pub fn dispatch(model: &mut Model, cmd: &Command) -> CommandResult {
}
Command::ClearCell { coords } => {
let kv: Vec<(String, String)> = coords.iter()
let kv: Vec<(String, String)> = coords
.iter()
.map(|pair| (pair[0].clone(), pair[1].clone()))
.collect();
let key = CellKey::new(kv);
@ -63,7 +68,10 @@ pub fn dispatch(model: &mut Model, cmd: &Command) -> CommandResult {
CommandResult::ok()
}
Command::AddFormula { raw, target_category } => {
Command::AddFormula {
raw,
target_category,
} => {
match parse_formula(raw, target_category) {
Ok(formula) => {
// Ensure the target item exists in the target category
@ -79,7 +87,10 @@ pub fn dispatch(model: &mut Model, cmd: &Command) -> CommandResult {
}
}
Command::RemoveFormula { target, target_category } => {
Command::RemoveFormula {
target,
target_category,
} => {
model.remove_formula(target, target_category);
CommandResult::ok()
}
@ -89,19 +100,15 @@ pub fn dispatch(model: &mut Model, cmd: &Command) -> CommandResult {
CommandResult::ok()
}
Command::DeleteView { name } => {
match model.delete_view(name) {
Ok(_) => CommandResult::ok(),
Err(e) => CommandResult::err(e.to_string()),
}
}
Command::DeleteView { name } => match model.delete_view(name) {
Ok(_) => CommandResult::ok(),
Err(e) => CommandResult::err(e.to_string()),
},
Command::SwitchView { name } => {
match model.switch_view(name) {
Ok(_) => CommandResult::ok(),
Err(e) => CommandResult::err(e.to_string()),
}
}
Command::SwitchView { name } => match model.switch_view(name) {
Ok(_) => CommandResult::ok(),
Err(e) => CommandResult::err(e.to_string()),
},
Command::SetAxis { category, axis } => {
model.active_view_mut().set_axis(category, *axis);
@ -114,28 +121,36 @@ pub fn dispatch(model: &mut Model, cmd: &Command) -> CommandResult {
}
Command::ToggleGroup { category, group } => {
model.active_view_mut().toggle_group_collapse(category, group);
model
.active_view_mut()
.toggle_group_collapse(category, group);
CommandResult::ok()
}
Command::Save { path } => {
match persistence::save(model, std::path::Path::new(path)) {
Ok(_) => CommandResult::ok_msg(format!("Saved to {path}")),
Err(e) => CommandResult::err(e.to_string()),
}
Command::HideItem { category, item } => {
model.active_view_mut().hide_item(category, item);
CommandResult::ok()
}
Command::Load { path } => {
match persistence::load(std::path::Path::new(path)) {
Ok(mut loaded) => {
loaded.normalize_view_state();
*model = loaded;
CommandResult::ok_msg(format!("Loaded from {path}"))
}
Err(e) => CommandResult::err(e.to_string()),
}
Command::ShowItem { category, item } => {
model.active_view_mut().show_item(category, item);
CommandResult::ok()
}
Command::Save { path } => match persistence::save(model, std::path::Path::new(path)) {
Ok(_) => CommandResult::ok_msg(format!("Saved to {path}")),
Err(e) => CommandResult::err(e.to_string()),
},
Command::Load { path } => match persistence::load(std::path::Path::new(path)) {
Ok(mut loaded) => {
loaded.normalize_view_state();
*model = loaded;
CommandResult::ok_msg(format!("Loaded from {path}"))
}
Err(e) => CommandResult::err(e.to_string()),
},
Command::ExportCsv { path } => {
let view_name = model.active_view.clone();
match persistence::export_csv(model, &view_name, std::path::Path::new(path)) {
@ -144,9 +159,11 @@ pub fn dispatch(model: &mut Model, cmd: &Command) -> CommandResult {
}
}
Command::ImportJson { path, model_name, array_path } => {
import_json_headless(model, path, model_name.as_deref(), array_path.as_deref())
}
Command::ImportJson {
path,
model_name,
array_path,
} => import_json_headless(model, path, model_name.as_deref(), array_path.as_deref()),
}
}
@ -193,7 +210,13 @@ fn import_json_headless(
array_paths: vec![],
selected_path: array_path.unwrap_or("").to_string(),
records,
proposals: proposals.into_iter().map(|mut p| { p.accepted = p.kind != FieldKind::Label; p }).collect(),
proposals: proposals
.into_iter()
.map(|mut p| {
p.accepted = p.kind != FieldKind::Label;
p
})
.collect(),
model_name: model_name.unwrap_or("Imported Model").to_string(),
};