diff --git a/src/command/dispatch.rs b/src/command/dispatch.rs index 012e41b..1495811 100644 --- a/src/command/dispatch.rs +++ b/src/command/dispatch.rs @@ -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(), }; diff --git a/src/command/types.rs b/src/command/types.rs index ca7c6c0..8b64f46 100644 --- a/src/command/types.rs +++ b/src/command/types.rs @@ -1,5 +1,5 @@ -use serde::{Deserialize, Serialize}; use crate::view::Axis; +use serde::{Deserialize, Serialize}; /// All commands that can mutate a Model. /// @@ -15,7 +15,11 @@ pub enum Command { AddItem { category: String, item: String }, /// Add an item inside a named group. - AddItemInGroup { category: String, item: String, group: String }, + AddItemInGroup { + category: String, + item: String, + group: String, + }, /// Set a cell value. `coords` is a list of `[category, item]` pairs. SetCell { @@ -30,10 +34,16 @@ pub enum Command { /// Add or replace a formula. /// `raw` is the full formula string, e.g. "Profit = Revenue - Cost". /// `target_category` names the category that owns the formula target. - AddFormula { raw: String, target_category: String }, + AddFormula { + raw: String, + target_category: String, + }, /// Remove a formula by its target name and category. - RemoveFormula { target: String, target_category: String }, + RemoveFormula { + target: String, + target_category: String, + }, /// Create a new view. CreateView { name: String }, @@ -53,6 +63,12 @@ pub enum Command { /// Toggle collapse of a group in the active view. ToggleGroup { category: String, group: String }, + /// Hide an item in the active view. + HideItem { category: String, item: String }, + + /// Show (un-hide) an item in the active view. + ShowItem { category: String, item: String }, + /// Save the model to a file path. Save { path: String }, @@ -88,12 +104,21 @@ pub struct CommandResult { impl CommandResult { pub fn ok() -> Self { - Self { ok: true, message: None } + Self { + ok: true, + message: None, + } } pub fn ok_msg(msg: impl Into) -> Self { - Self { ok: true, message: Some(msg.into()) } + Self { + ok: true, + message: Some(msg.into()), + } } pub fn err(msg: impl Into) -> Self { - Self { ok: false, message: Some(msg.into()) } + Self { + ok: false, + message: Some(msg.into()), + } } } diff --git a/src/ui/app.rs b/src/ui/app.rs index e30fd17..124556e 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -1,32 +1,47 @@ -use std::path::{Path, PathBuf}; -use std::time::{Duration, Instant}; use anyhow::Result; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; -use crate::model::Model; -use crate::model::cell::{CellKey, CellValue}; -use crate::import::wizard::{ImportWizard, WizardStep}; -use crate::persistence; -use crate::view::{Axis, GridLayout}; use crate::command::{self, Command}; +use crate::import::wizard::{ImportWizard, WizardStep}; +use crate::model::cell::{CellKey, CellValue}; +use crate::model::Model; +use crate::persistence; +use crate::view::{Axis, AxisEntry, GridLayout}; #[derive(Debug, Clone, PartialEq)] pub enum AppMode { Normal, - Editing { buffer: String }, - FormulaEdit { buffer: String }, + Editing { + buffer: String, + }, + FormulaEdit { + buffer: String, + }, FormulaPanel, CategoryPanel, /// Quick-add a new category: Enter adds and stays open, Esc closes. - CategoryAdd { buffer: String }, + CategoryAdd { + buffer: String, + }, /// Quick-add items to `category`: Enter adds and stays open, Esc closes. - ItemAdd { category: String, buffer: String }, + ItemAdd { + category: String, + buffer: String, + }, ViewPanel, - TileSelect { cat_idx: usize }, + TileSelect { + cat_idx: usize, + }, ImportWizard, - ExportPrompt { buffer: String }, + ExportPrompt { + buffer: String, + }, /// Vim-style `:` command line - CommandMode { buffer: String }, + CommandMode { + buffer: String, + }, Help, Quit, } @@ -84,19 +99,45 @@ impl App { pub fn handle_key(&mut self, key: KeyEvent) -> Result<()> { match &self.mode.clone() { AppMode::Quit => {} - AppMode::Help => { self.mode = AppMode::Normal; } - AppMode::ImportWizard => { self.handle_wizard_key(key)?; } - AppMode::Editing { .. } => { self.handle_edit_key(key)?; } - AppMode::FormulaEdit { .. } => { self.handle_formula_edit_key(key)?; } - AppMode::FormulaPanel => { self.handle_formula_panel_key(key)?; } - AppMode::CategoryPanel => { self.handle_category_panel_key(key)?; } - AppMode::CategoryAdd { .. } => { self.handle_category_add_key(key)?; } - AppMode::ItemAdd { .. } => { self.handle_item_add_key(key)?; } - AppMode::ViewPanel => { self.handle_view_panel_key(key)?; } - AppMode::TileSelect { .. } => { self.handle_tile_select_key(key)?; } - AppMode::ExportPrompt { .. } => { self.handle_export_key(key)?; } - AppMode::CommandMode { .. } => { self.handle_command_mode_key(key)?; } - AppMode::Normal => { self.handle_normal_key(key)?; } + AppMode::Help => { + self.mode = AppMode::Normal; + } + AppMode::ImportWizard => { + self.handle_wizard_key(key)?; + } + AppMode::Editing { .. } => { + self.handle_edit_key(key)?; + } + AppMode::FormulaEdit { .. } => { + self.handle_formula_edit_key(key)?; + } + AppMode::FormulaPanel => { + self.handle_formula_panel_key(key)?; + } + AppMode::CategoryPanel => { + self.handle_category_panel_key(key)?; + } + AppMode::CategoryAdd { .. } => { + self.handle_category_add_key(key)?; + } + AppMode::ItemAdd { .. } => { + self.handle_item_add_key(key)?; + } + AppMode::ViewPanel => { + self.handle_view_panel_key(key)?; + } + AppMode::TileSelect { .. } => { + self.handle_tile_select_key(key)?; + } + AppMode::ExportPrompt { .. } => { + self.handle_export_key(key)?; + } + AppMode::CommandMode { .. } => { + self.handle_command_mode_key(key)?; + } + AppMode::Normal => { + self.handle_normal_key(key)?; + } } Ok(()) } @@ -125,11 +166,15 @@ impl App { } // ── File ops ─────────────────────────────────────────────────── - (KeyCode::Char('s'), KeyModifiers::CONTROL) => { self.save()?; } + (KeyCode::Char('s'), KeyModifiers::CONTROL) => { + self.save()?; + } // ── Command line ─────────────────────────────────────────────── (KeyCode::Char(':'), _) => { - self.mode = AppMode::CommandMode { buffer: String::new() }; + self.mode = AppMode::CommandMode { + buffer: String::new(), + }; } // ── Panel toggles (uppercase letter = no modifier needed) ────── @@ -162,7 +207,9 @@ impl App { self.view_panel_open = !self.view_panel_open; } (KeyCode::Char('e'), KeyModifiers::CONTROL) => { - self.mode = AppMode::ExportPrompt { buffer: String::new() }; + self.mode = AppMode::ExportPrompt { + buffer: String::new(), + }; } // ── Tab cycles open panels ───────────────────────────────────── @@ -202,7 +249,9 @@ impl App { } // G = last row, gg = first row (g sets pending) - (KeyCode::Char('G'), _) => { self.jump_to_last_row(); } + (KeyCode::Char('G'), _) => { + self.jump_to_last_row(); + } (KeyCode::Char('g'), KeyModifiers::NONE) => { self.pending_key = Some('g'); } @@ -213,19 +262,27 @@ impl App { view.selected.1 = 0; view.col_offset = 0; } - (KeyCode::Char('$'), _) => { self.jump_to_last_col(); } + (KeyCode::Char('$'), _) => { + self.jump_to_last_col(); + } // Ctrl+D / Ctrl+U = half-page scroll - (KeyCode::Char('d'), KeyModifiers::CONTROL) => { self.scroll_rows(5); } - (KeyCode::Char('u'), KeyModifiers::CONTROL) => { self.scroll_rows(-5); } + (KeyCode::Char('d'), KeyModifiers::CONTROL) => { + self.scroll_rows(5); + } + (KeyCode::Char('u'), KeyModifiers::CONTROL) => { + self.scroll_rows(-5); + } // Enter = advance (down, wrapping to top of next column) - (KeyCode::Enter, _) => { self.enter_advance(); } + (KeyCode::Enter, _) => { + self.enter_advance(); + } // ── Editing ──────────────────────────────────────────────────── - (KeyCode::Char('i'), KeyModifiers::NONE) - | (KeyCode::Char('a'), KeyModifiers::NONE) => { - let current = self.selected_cell_key() + (KeyCode::Char('i'), KeyModifiers::NONE) | (KeyCode::Char('a'), KeyModifiers::NONE) => { + let current = self + .selected_cell_key() .and_then(|k| self.model.get_cell(&k).cloned()) .map(|v| v.to_string()) .unwrap_or_default(); @@ -254,8 +311,16 @@ impl App { if let Some(key) = self.selected_cell_key() { let coords = key.0.iter().map(|(c, v)| [c.clone(), v.clone()]).collect(); let cmd = match &value { - CellValue::Number(n) => Command::SetCell { coords, value: crate::command::types::CellValueArg::Number { number: *n } }, - CellValue::Text(t) => Command::SetCell { coords, value: crate::command::types::CellValueArg::Text { text: t.clone() } }, + CellValue::Number(n) => Command::SetCell { + coords, + value: crate::command::types::CellValueArg::Number { number: *n }, + }, + CellValue::Text(t) => Command::SetCell { + coords, + value: crate::command::types::CellValueArg::Text { + text: t.clone(), + }, + }, }; command::dispatch(&mut self.model, &cmd); self.dirty = true; @@ -279,7 +344,9 @@ impl App { } else { // N with no active search = quick-add a new category self.category_panel_open = true; - self.mode = AppMode::CategoryAdd { buffer: String::new() }; + self.mode = AppMode::CategoryAdd { + buffer: String::new(), + }; } } @@ -297,8 +364,22 @@ impl App { } } // ── Page axis ────────────────────────────────────────────────── - (KeyCode::Char('['), _) => { self.page_prev(); } - (KeyCode::Char(']'), _) => { self.page_next(); } + (KeyCode::Char('['), _) => { + self.page_prev(); + } + (KeyCode::Char(']'), _) => { + self.page_next(); + } + + // ── Group collapse toggle ─────────────────────────────────────── + (KeyCode::Char('z'), KeyModifiers::NONE) => { + self.toggle_group_under_cursor(); + } + + // ── Hide row item ─────────────────────────────────────────────── + (KeyCode::Char('H'), _) => { + self.hide_selected_row_item(); + } _ => {} } @@ -310,11 +391,9 @@ impl App { match (first, key.code) { // gg = first row ('g', KeyCode::Char('g')) => { - { - let view = self.model.active_view_mut(); - view.selected = (0, view.selected.1); - view.row_offset = 0; - } + let view = self.model.active_view_mut(); + view.selected = (0, view.selected.1); + view.row_offset = 0; } // yy = yank current cell ('y', KeyCode::Char('y')) => { @@ -338,9 +417,15 @@ impl App { fn handle_search_key(&mut self, key: KeyEvent) -> Result<()> { match key.code { - KeyCode::Esc | KeyCode::Enter => { self.search_mode = false; } - KeyCode::Char(c) => { self.search_query.push(c); } - KeyCode::Backspace => { self.search_query.pop(); } + KeyCode::Esc | KeyCode::Enter => { + self.search_mode = false; + } + KeyCode::Char(c) => { + self.search_query.push(c); + } + KeyCode::Backspace => { + self.search_query.pop(); + } _ => {} } Ok(()) @@ -356,7 +441,9 @@ impl App { KeyCode::Enter => { let buf = if let AppMode::CommandMode { buffer } = &self.mode { buffer.clone() - } else { return Ok(()); }; + } else { + return Ok(()); + }; self.execute_command(&buf)?; } KeyCode::Char(c) => { @@ -380,7 +467,8 @@ impl App { fn execute_command(&mut self, raw: &str) -> Result<()> { let raw = raw.trim(); - let (cmd_name, rest) = raw.split_once(char::is_whitespace) + let (cmd_name, rest) = raw + .split_once(char::is_whitespace) .map(|(c, r)| (c, r.trim())) .unwrap_or((raw, "")); @@ -390,12 +478,15 @@ impl App { match cmd_name { "q" | "quit" => { if self.dirty { - self.status_msg = "Unsaved changes. Use :q! to force quit or :wq to save+quit.".to_string(); + self.status_msg = + "Unsaved changes. Use :q! to force quit or :wq to save+quit.".to_string(); } else { self.mode = AppMode::Quit; } } - "q!" => { self.mode = AppMode::Quit; } + "q!" => { + self.mode = AppMode::Quit; + } "w" | "write" => { if rest.is_empty() { self.save()?; @@ -421,9 +512,13 @@ impl App { self.wizard = Some(ImportWizard::new(json)); self.mode = AppMode::ImportWizard; } - Err(e) => { self.status_msg = format!("JSON parse error: {e}"); } + Err(e) => { + self.status_msg = format!("JSON parse error: {e}"); + } + }, + Err(e) => { + self.status_msg = format!("Cannot read file: {e}"); } - Err(e) => { self.status_msg = format!("Cannot read file: {e}"); } } } } @@ -431,15 +526,24 @@ impl App { let path = if rest.is_empty() { "export.csv" } else { rest }; let view_name = self.model.active_view.clone(); match persistence::export_csv(&self.model, &view_name, Path::new(path)) { - Ok(_) => { self.status_msg = format!("Exported to {path}"); } - Err(e) => { self.status_msg = format!("Export error: {e}"); } + Ok(_) => { + self.status_msg = format!("Exported to {path}"); + } + Err(e) => { + self.status_msg = format!("Export error: {e}"); + } } } "add-cat" | "add-category" | "cat" => { if rest.is_empty() { self.status_msg = "Usage: :add-cat ".to_string(); } else { - let result = command::dispatch(&mut self.model, &Command::AddCategory { name: rest.to_string() }); + let result = command::dispatch( + &mut self.model, + &Command::AddCategory { + name: rest.to_string(), + }, + ); self.status_msg = result.message.unwrap_or_default(); self.dirty = true; } @@ -452,10 +556,13 @@ impl App { if cat.is_empty() || item.is_empty() { self.status_msg = "Usage: :add-item ".to_string(); } else { - let result = command::dispatch(&mut self.model, &Command::AddItem { - category: cat.to_string(), - item: item.to_string(), - }); + let result = command::dispatch( + &mut self.model, + &Command::AddItem { + category: cat.to_string(), + item: item.to_string(), + }, + ); self.status_msg = result.message.unwrap_or_else(|| "Item added".to_string()); self.dirty = true; } @@ -471,10 +578,13 @@ impl App { let items: Vec<&str> = items_str.split_whitespace().collect(); let count = items.len(); for item in &items { - command::dispatch(&mut self.model, &Command::AddItem { - category: cat.clone(), - item: item.to_string(), - }); + command::dispatch( + &mut self.model, + &Command::AddItem { + category: cat.clone(), + item: item.to_string(), + }, + ); } self.status_msg = format!("Added {count} items to \"{cat}\"."); self.dirty = true; @@ -492,11 +602,16 @@ impl App { if cat.is_empty() || formula.is_empty() { self.status_msg = "Usage: :formula ".to_string(); } else { - let result = command::dispatch(&mut self.model, &Command::AddFormula { - raw: formula.to_string(), - target_category: cat.to_string(), - }); - self.status_msg = result.message.unwrap_or_else(|| "Formula added".to_string()); + let result = command::dispatch( + &mut self.model, + &Command::AddFormula { + raw: formula.to_string(), + target_category: cat.to_string(), + }, + ); + self.status_msg = result + .message + .unwrap_or_else(|| "Formula added".to_string()); self.dirty = true; } } @@ -522,7 +637,30 @@ impl App { self.dirty = true; } } - "help" | "h" => { self.mode = AppMode::Help; } + "show-item" | "show" => { + // :show-item + let mut parts = rest.splitn(2, char::is_whitespace); + let cat = parts.next().unwrap_or("").trim(); + let item = parts.next().unwrap_or("").trim(); + if cat.is_empty() || item.is_empty() { + self.status_msg = "Usage: :show-item ".to_string(); + } else { + let result = command::dispatch( + &mut self.model, + &Command::ShowItem { + category: cat.to_string(), + item: item.to_string(), + }, + ); + self.status_msg = result + .message + .unwrap_or_else(|| format!("Showed \"{item}\" in \"{cat}\"")); + self.dirty = true; + } + } + "help" | "h" => { + self.mode = AppMode::Help; + } "" => {} // just pressed Enter with empty buffer other => { self.status_msg = format!("Unknown command: :{other} (try :help)"); @@ -535,17 +673,29 @@ impl App { fn handle_edit_key(&mut self, key: KeyEvent) -> Result<()> { match key.code { - KeyCode::Esc => { self.mode = AppMode::Normal; } + KeyCode::Esc => { + self.mode = AppMode::Normal; + } KeyCode::Enter => { - let buf = if let AppMode::Editing { buffer } = &self.mode { buffer.clone() } else { return Ok(()); }; + let buf = if let AppMode::Editing { buffer } = &self.mode { + buffer.clone() + } else { + return Ok(()); + }; if let Some(key) = self.selected_cell_key() { let coords = key.0.iter().map(|(c, v)| [c.clone(), v.clone()]).collect(); let cmd = if buf.is_empty() { Command::ClearCell { coords } } else if let Ok(n) = buf.parse::() { - Command::SetCell { coords, value: crate::command::types::CellValueArg::Number { number: n } } + Command::SetCell { + coords, + value: crate::command::types::CellValueArg::Number { number: n }, + } } else { - Command::SetCell { coords, value: crate::command::types::CellValueArg::Text { text: buf.clone() } } + Command::SetCell { + coords, + value: crate::command::types::CellValueArg::Text { text: buf.clone() }, + } }; command::dispatch(&mut self.model, &cmd); self.dirty = true; @@ -554,10 +704,14 @@ impl App { self.move_selection(1, 0); } KeyCode::Char(c) => { - if let AppMode::Editing { buffer } = &mut self.mode { buffer.push(c); } + if let AppMode::Editing { buffer } = &mut self.mode { + buffer.push(c); + } } KeyCode::Backspace => { - if let AppMode::Editing { buffer } = &mut self.mode { buffer.pop(); } + if let AppMode::Editing { buffer } = &mut self.mode { + buffer.pop(); + } } _ => {} } @@ -568,16 +722,32 @@ impl App { fn handle_formula_edit_key(&mut self, key: KeyEvent) -> Result<()> { match key.code { - KeyCode::Esc => { self.mode = AppMode::FormulaPanel; } + KeyCode::Esc => { + self.mode = AppMode::FormulaPanel; + } KeyCode::Enter => { - let buf = if let AppMode::FormulaEdit { buffer } = &self.mode { buffer.clone() } else { return Ok(()); }; - let first_cat = self.model.category_names().into_iter().next().map(String::from); + let buf = if let AppMode::FormulaEdit { buffer } = &self.mode { + buffer.clone() + } else { + return Ok(()); + }; + let first_cat = self + .model + .category_names() + .into_iter() + .next() + .map(String::from); if let Some(cat) = first_cat { - let result = command::dispatch(&mut self.model, &Command::AddFormula { - raw: buf, - target_category: cat, - }); - self.status_msg = result.message.unwrap_or_else(|| "Formula added".to_string()); + let result = command::dispatch( + &mut self.model, + &Command::AddFormula { + raw: buf, + target_category: cat, + }, + ); + self.status_msg = result + .message + .unwrap_or_else(|| "Formula added".to_string()); self.dirty = true; } else { self.status_msg = "Add at least one category first.".to_string(); @@ -585,10 +755,14 @@ impl App { self.mode = AppMode::FormulaPanel; } KeyCode::Char(c) => { - if let AppMode::FormulaEdit { buffer } = &mut self.mode { buffer.push(c); } + if let AppMode::FormulaEdit { buffer } = &mut self.mode { + buffer.push(c); + } } KeyCode::Backspace => { - if let AppMode::FormulaEdit { buffer } = &mut self.mode { buffer.pop(); } + if let AppMode::FormulaEdit { buffer } = &mut self.mode { + buffer.pop(); + } } _ => {} } @@ -600,29 +774,48 @@ impl App { fn handle_formula_panel_key(&mut self, key: KeyEvent) -> Result<()> { // Clamp cursor in case the formula list shrank since it was last set. let flen = self.model.formulas().len(); - if flen == 0 { self.formula_cursor = 0; } - else { self.formula_cursor = self.formula_cursor.min(flen - 1); } + if flen == 0 { + self.formula_cursor = 0; + } else { + self.formula_cursor = self.formula_cursor.min(flen - 1); + } match key.code { - KeyCode::Esc | KeyCode::Tab => { self.mode = AppMode::Normal; } + KeyCode::Esc | KeyCode::Tab => { + self.mode = AppMode::Normal; + } KeyCode::Char('a') | KeyCode::Char('n') | KeyCode::Char('o') => { - self.mode = AppMode::FormulaEdit { buffer: String::new() }; + self.mode = AppMode::FormulaEdit { + buffer: String::new(), + }; } KeyCode::Char('d') | KeyCode::Delete => { if self.formula_cursor < self.model.formulas().len() { let f = &self.model.formulas()[self.formula_cursor]; let target = f.target.clone(); let target_category = f.target_category.clone(); - command::dispatch(&mut self.model, &Command::RemoveFormula { target, target_category }); - if self.formula_cursor > 0 { self.formula_cursor -= 1; } + command::dispatch( + &mut self.model, + &Command::RemoveFormula { + target, + target_category, + }, + ); + if self.formula_cursor > 0 { + self.formula_cursor -= 1; + } self.dirty = true; } } KeyCode::Up | KeyCode::Char('k') => { - if self.formula_cursor > 0 { self.formula_cursor -= 1; } + if self.formula_cursor > 0 { + self.formula_cursor -= 1; + } } KeyCode::Down | KeyCode::Char('j') => { - if self.formula_cursor + 1 < self.model.formulas().len() { self.formula_cursor += 1; } + if self.formula_cursor + 1 < self.model.formulas().len() { + self.formula_cursor += 1; + } } _ => {} } @@ -630,14 +823,25 @@ impl App { } fn handle_category_panel_key(&mut self, key: KeyEvent) -> Result<()> { - let cat_names: Vec = self.model.category_names().into_iter().map(String::from).collect(); + let cat_names: Vec = self + .model + .category_names() + .into_iter() + .map(String::from) + .collect(); match key.code { - KeyCode::Esc | KeyCode::Tab => { self.mode = AppMode::Normal; } + KeyCode::Esc | KeyCode::Tab => { + self.mode = AppMode::Normal; + } KeyCode::Up | KeyCode::Char('k') => { - if self.cat_panel_cursor > 0 { self.cat_panel_cursor -= 1; } + if self.cat_panel_cursor > 0 { + self.cat_panel_cursor -= 1; + } } KeyCode::Down | KeyCode::Char('j') => { - if self.cat_panel_cursor + 1 < cat_names.len() { self.cat_panel_cursor += 1; } + if self.cat_panel_cursor + 1 < cat_names.len() { + self.cat_panel_cursor += 1; + } } KeyCode::Enter | KeyCode::Char(' ') => { if let Some(cat_name) = cat_names.get(self.cat_panel_cursor) { @@ -646,7 +850,9 @@ impl App { } // n — add a new category KeyCode::Char('n') => { - self.mode = AppMode::CategoryAdd { buffer: String::new() }; + self.mode = AppMode::CategoryAdd { + buffer: String::new(), + }; } // a / o — open quick-add items mode for the selected category KeyCode::Char('a') | KeyCode::Char('o') => { @@ -656,7 +862,8 @@ impl App { buffer: String::new(), }; } else { - self.status_msg = "No category selected. Press n to add a category first.".to_string(); + self.status_msg = + "No category selected. Press n to add a category first.".to_string(); } } _ => {} @@ -673,10 +880,15 @@ impl App { KeyCode::Enter | KeyCode::Tab => { let buf = if let AppMode::CategoryAdd { buffer } = &self.mode { buffer.trim().to_string() - } else { return Ok(()); }; + } else { + return Ok(()); + }; if !buf.is_empty() { - let result = command::dispatch(&mut self.model, &Command::AddCategory { name: buf.clone() }); + let result = command::dispatch( + &mut self.model, + &Command::AddCategory { name: buf.clone() }, + ); if result.ok { // Move cursor to the new category self.cat_panel_cursor = self.model.categories.len().saturating_sub(1); @@ -693,10 +905,14 @@ impl App { } } KeyCode::Char(c) => { - if let AppMode::CategoryAdd { ref mut buffer } = self.mode { buffer.push(c); } + if let AppMode::CategoryAdd { ref mut buffer } = self.mode { + buffer.push(c); + } } KeyCode::Backspace => { - if let AppMode::CategoryAdd { ref mut buffer } = self.mode { buffer.pop(); } + if let AppMode::CategoryAdd { ref mut buffer } = self.mode { + buffer.pop(); + } } _ => {} } @@ -713,16 +929,27 @@ impl App { KeyCode::Enter => { let (cat, buf) = if let AppMode::ItemAdd { category, buffer } = &self.mode { (category.clone(), buffer.trim().to_string()) - } else { return Ok(()); }; + } else { + return Ok(()); + }; if !buf.is_empty() { - let result = command::dispatch(&mut self.model, &Command::AddItem { - category: cat.clone(), - item: buf.clone(), - }); + let result = command::dispatch( + &mut self.model, + &Command::AddItem { + category: cat.clone(), + item: buf.clone(), + }, + ); if result.ok { - let count = self.model.category(&cat).map(|c| c.items.len()).unwrap_or(0); - self.status_msg = format!("Added \"{buf}\" — {count} items. Enter to add more, Esc to finish."); + let count = self + .model + .category(&cat) + .map(|c| c.items.len()) + .unwrap_or(0); + self.status_msg = format!( + "Added \"{buf}\" — {count} items. Enter to add more, Esc to finish." + ); self.dirty = true; } else { self.status_msg = result.message.unwrap_or_default(); @@ -758,12 +985,18 @@ impl App { fn handle_view_panel_key(&mut self, key: KeyEvent) -> Result<()> { let view_names: Vec = self.model.views.keys().cloned().collect(); match key.code { - KeyCode::Esc | KeyCode::Tab => { self.mode = AppMode::Normal; } + KeyCode::Esc | KeyCode::Tab => { + self.mode = AppMode::Normal; + } KeyCode::Up | KeyCode::Char('k') => { - if self.view_panel_cursor > 0 { self.view_panel_cursor -= 1; } + if self.view_panel_cursor > 0 { + self.view_panel_cursor -= 1; + } } KeyCode::Down | KeyCode::Char('j') => { - if self.view_panel_cursor + 1 < view_names.len() { self.view_panel_cursor += 1; } + if self.view_panel_cursor + 1 < view_names.len() { + self.view_panel_cursor += 1; + } } KeyCode::Enter => { if let Some(name) = view_names.get(self.view_panel_cursor) { @@ -773,7 +1006,12 @@ impl App { } KeyCode::Char('n') | KeyCode::Char('o') => { let new_name = format!("View {}", self.model.views.len() + 1); - command::dispatch(&mut self.model, &Command::CreateView { name: new_name.clone() }); + command::dispatch( + &mut self.model, + &Command::CreateView { + name: new_name.clone(), + }, + ); command::dispatch(&mut self.model, &Command::SwitchView { name: new_name }); self.dirty = true; self.mode = AppMode::Normal; @@ -781,7 +1019,9 @@ impl App { KeyCode::Delete | KeyCode::Char('d') => { if let Some(name) = view_names.get(self.view_panel_cursor) { command::dispatch(&mut self.model, &Command::DeleteView { name: name.clone() }); - if self.view_panel_cursor > 0 { self.view_panel_cursor -= 1; } + if self.view_panel_cursor > 0 { + self.view_panel_cursor -= 1; + } self.dirty = true; } } @@ -791,19 +1031,34 @@ impl App { } fn handle_tile_select_key(&mut self, key: KeyEvent) -> Result<()> { - let cat_names: Vec = self.model.category_names().into_iter().map(String::from).collect(); - let cat_idx = if let AppMode::TileSelect { cat_idx } = self.mode { cat_idx } else { 0 }; + let cat_names: Vec = self + .model + .category_names() + .into_iter() + .map(String::from) + .collect(); + let cat_idx = if let AppMode::TileSelect { cat_idx } = self.mode { + cat_idx + } else { + 0 + }; match key.code { - KeyCode::Esc | KeyCode::Tab => { self.mode = AppMode::Normal; } + KeyCode::Esc | KeyCode::Tab => { + self.mode = AppMode::Normal; + } KeyCode::Left | KeyCode::Char('h') => { if let AppMode::TileSelect { ref mut cat_idx } = self.mode { - if *cat_idx > 0 { *cat_idx -= 1; } + if *cat_idx > 0 { + *cat_idx -= 1; + } } } KeyCode::Right | KeyCode::Char('l') => { if let AppMode::TileSelect { ref mut cat_idx } = self.mode { - if *cat_idx + 1 < cat_names.len() { *cat_idx += 1; } + if *cat_idx + 1 < cat_names.len() { + *cat_idx += 1; + } } } KeyCode::Enter | KeyCode::Char(' ') => { @@ -815,21 +1070,39 @@ impl App { } KeyCode::Char('r') => { if let Some(name) = cat_names.get(cat_idx) { - command::dispatch(&mut self.model, &Command::SetAxis { category: name.clone(), axis: Axis::Row }); + command::dispatch( + &mut self.model, + &Command::SetAxis { + category: name.clone(), + axis: Axis::Row, + }, + ); self.dirty = true; } self.mode = AppMode::Normal; } KeyCode::Char('c') => { if let Some(name) = cat_names.get(cat_idx) { - command::dispatch(&mut self.model, &Command::SetAxis { category: name.clone(), axis: Axis::Column }); + command::dispatch( + &mut self.model, + &Command::SetAxis { + category: name.clone(), + axis: Axis::Column, + }, + ); self.dirty = true; } self.mode = AppMode::Normal; } KeyCode::Char('p') => { if let Some(name) = cat_names.get(cat_idx) { - command::dispatch(&mut self.model, &Command::SetAxis { category: name.clone(), axis: Axis::Page }); + command::dispatch( + &mut self.model, + &Command::SetAxis { + category: name.clone(), + axis: Axis::Page, + }, + ); self.dirty = true; } self.mode = AppMode::Normal; @@ -841,21 +1114,35 @@ impl App { fn handle_export_key(&mut self, key: KeyEvent) -> Result<()> { match key.code { - KeyCode::Esc => { self.mode = AppMode::Normal; } + KeyCode::Esc => { + self.mode = AppMode::Normal; + } KeyCode::Enter => { - let buf = if let AppMode::ExportPrompt { buffer } = &self.mode { buffer.clone() } else { return Ok(()); }; + let buf = if let AppMode::ExportPrompt { buffer } = &self.mode { + buffer.clone() + } else { + return Ok(()); + }; let view_name = self.model.active_view.clone(); match persistence::export_csv(&self.model, &view_name, Path::new(&buf)) { - Ok(_) => { self.status_msg = format!("Exported to {buf}"); } - Err(e) => { self.status_msg = format!("Export error: {e}"); } + Ok(_) => { + self.status_msg = format!("Exported to {buf}"); + } + Err(e) => { + self.status_msg = format!("Export error: {e}"); + } } self.mode = AppMode::Normal; } KeyCode::Char(c) => { - if let AppMode::ExportPrompt { buffer } = &mut self.mode { buffer.push(c); } + if let AppMode::ExportPrompt { buffer } = &mut self.mode { + buffer.push(c); + } } KeyCode::Backspace => { - if let AppMode::ExportPrompt { buffer } = &mut self.mode { buffer.pop(); } + if let AppMode::ExportPrompt { buffer } = &mut self.mode { + buffer.pop(); + } } _ => {} } @@ -867,14 +1154,20 @@ impl App { match &wizard.step.clone() { WizardStep::Preview => match key.code { KeyCode::Enter | KeyCode::Char(' ') => wizard.advance(), - KeyCode::Esc => { self.mode = AppMode::Normal; self.wizard = None; } + KeyCode::Esc => { + self.mode = AppMode::Normal; + self.wizard = None; + } _ => {} }, WizardStep::SelectArrayPath => match key.code { KeyCode::Up | KeyCode::Char('k') => wizard.move_cursor(-1), KeyCode::Down | KeyCode::Char('j') => wizard.move_cursor(1), KeyCode::Enter => wizard.confirm_path(), - KeyCode::Esc => { self.mode = AppMode::Normal; self.wizard = None; } + KeyCode::Esc => { + self.mode = AppMode::Normal; + self.wizard = None; + } _ => {} }, WizardStep::ReviewProposals => match key.code { @@ -883,45 +1176,122 @@ impl App { KeyCode::Char(' ') => wizard.toggle_proposal(), KeyCode::Char('c') => wizard.cycle_proposal_kind(), KeyCode::Enter => wizard.advance(), - KeyCode::Esc => { self.mode = AppMode::Normal; self.wizard = None; } + KeyCode::Esc => { + self.mode = AppMode::Normal; + self.wizard = None; + } _ => {} }, WizardStep::NameModel => match key.code { KeyCode::Char(c) => wizard.push_name_char(c), KeyCode::Backspace => wizard.pop_name_char(), - KeyCode::Enter => { - match wizard.build_model() { - Ok(mut model) => { - model.normalize_view_state(); - self.model = model; - self.formula_cursor = 0; - self.dirty = true; - self.status_msg = "Import successful! Press :w to save.".to_string(); - self.mode = AppMode::Normal; - self.wizard = None; - } - Err(e) => { - if let Some(w) = &mut self.wizard { - w.message = Some(format!("Error: {e}")); - } + KeyCode::Enter => match wizard.build_model() { + Ok(mut model) => { + model.normalize_view_state(); + self.model = model; + self.formula_cursor = 0; + self.dirty = true; + self.status_msg = + "Import successful! Press :w to save.".to_string(); + self.mode = AppMode::Normal; + self.wizard = None; + } + Err(e) => { + if let Some(w) = &mut self.wizard { + w.message = Some(format!("Error: {e}")); } } + }, + KeyCode::Esc => { + self.mode = AppMode::Normal; + self.wizard = None; } - KeyCode::Esc => { self.mode = AppMode::Normal; self.wizard = None; } _ => {} }, - WizardStep::Done => { self.mode = AppMode::Normal; self.wizard = None; } + WizardStep::Done => { + self.mode = AppMode::Normal; + self.wizard = None; + } } } Ok(()) } + // ── Group collapse ─────────────────────────────────────────────────────── + + fn toggle_group_under_cursor(&mut self) { + let layout = GridLayout::new(&self.model, self.model.active_view()); + let sel_row = self.model.active_view().selected.0; + let Some(vi) = layout.data_row_to_visual(sel_row) else { + return; + }; + let group = layout.row_items[..vi].iter().rev().find_map(|e| { + if let AxisEntry::GroupHeader { + cat_name, + group_name, + } = e + { + Some((cat_name.clone(), group_name.clone())) + } else { + None + } + }); + if let Some((cat, group)) = group { + let cmd = Command::ToggleGroup { + category: cat, + group, + }; + command::dispatch(&mut self.model, &cmd); + self.dirty = true; + } + } + + fn hide_selected_row_item(&mut self) { + let layout = GridLayout::new(&self.model, self.model.active_view()); + let Some(cat_name) = layout.row_cats.first().cloned() else { + return; + }; + let sel_row = self.model.active_view().selected.0; + let Some(items) = layout + .row_items + .iter() + .filter_map(|e| { + if let AxisEntry::DataItem(v) = e { + Some(v) + } else { + None + } + }) + .nth(sel_row) + else { + return; + }; + let item_name = items[0].clone(); + command::dispatch( + &mut self.model, + &Command::HideItem { + category: cat_name, + item: item_name, + }, + ); + // Clamp selection in case it's now out of bounds + let row_max = GridLayout::new(&self.model, self.model.active_view()) + .row_count() + .saturating_sub(1); + let sel = self.model.active_view().selected; + self.model.active_view_mut().selected = (sel.0.min(row_max), sel.1); + self.dirty = true; + } + // ── Motion helpers ─────────────────────────────────────────────────────── fn move_selection(&mut self, dr: i32, dc: i32) { let (row_max, col_max) = { let layout = GridLayout::new(&self.model, self.model.active_view()); - (layout.row_count().saturating_sub(1), layout.col_count().saturating_sub(1)) + ( + layout.row_count().saturating_sub(1), + layout.col_count().saturating_sub(1), + ) }; let view = self.model.active_view_mut(); let (r, c) = view.selected; @@ -929,40 +1299,64 @@ impl App { let nc = (c as i32 + dc).clamp(0, col_max as i32) as usize; view.selected = (nr, nc); // Keep cursor in visible area (approximate viewport: 20 rows, 8 cols) - if nr < view.row_offset { view.row_offset = nr; } - if nr >= view.row_offset + 20 { view.row_offset = nr.saturating_sub(19); } - if nc < view.col_offset { view.col_offset = nc; } - if nc >= view.col_offset + 8 { view.col_offset = nc.saturating_sub(7); } + if nr < view.row_offset { + view.row_offset = nr; + } + if nr >= view.row_offset + 20 { + view.row_offset = nr.saturating_sub(19); + } + if nc < view.col_offset { + view.col_offset = nc; + } + if nc >= view.col_offset + 8 { + view.col_offset = nc.saturating_sub(7); + } } fn jump_to_last_row(&mut self) { - let count = GridLayout::new(&self.model, self.model.active_view()).row_count().saturating_sub(1); + let count = GridLayout::new(&self.model, self.model.active_view()) + .row_count() + .saturating_sub(1); let view = self.model.active_view_mut(); view.selected.0 = count; - if count >= view.row_offset + 20 { view.row_offset = count.saturating_sub(19); } + if count >= view.row_offset + 20 { + view.row_offset = count.saturating_sub(19); + } } fn jump_to_last_col(&mut self) { - let count = GridLayout::new(&self.model, self.model.active_view()).col_count().saturating_sub(1); + let count = GridLayout::new(&self.model, self.model.active_view()) + .col_count() + .saturating_sub(1); let view = self.model.active_view_mut(); view.selected.1 = count; - if count >= view.col_offset + 8 { view.col_offset = count.saturating_sub(7); } + if count >= view.col_offset + 8 { + view.col_offset = count.saturating_sub(7); + } } fn scroll_rows(&mut self, delta: i32) { - let row_max = GridLayout::new(&self.model, self.model.active_view()).row_count().saturating_sub(1); + let row_max = GridLayout::new(&self.model, self.model.active_view()) + .row_count() + .saturating_sub(1); let view = self.model.active_view_mut(); let nr = (view.selected.0 as i32 + delta).clamp(0, row_max as i32) as usize; view.selected.0 = nr; - if nr < view.row_offset { view.row_offset = nr; } - if nr >= view.row_offset + 20 { view.row_offset = nr.saturating_sub(19); } + if nr < view.row_offset { + view.row_offset = nr; + } + if nr >= view.row_offset + 20 { + view.row_offset = nr.saturating_sub(19); + } } /// Navigate to the next (`forward=true`) or previous (`forward=false`) cell /// whose display value contains `self.search_query` (case-insensitive). fn search_navigate(&mut self, forward: bool) { let query = self.search_query.to_lowercase(); - if query.is_empty() { return; } + if query.is_empty() { + return; + } let view = self.model.active_view(); let layout = GridLayout::new(&self.model, view); @@ -973,20 +1367,22 @@ impl App { let total = total_rows * total_cols; let cur_flat = cur_row * total_cols + cur_col; - let matches: Vec = (0..total).filter(|&flat| { - let ri = flat / total_cols; - let ci = flat % total_cols; - let key = match layout.cell_key(ri, ci) { - Some(k) => k, - None => return false, - }; - let s = match self.model.evaluate(&key) { - Some(CellValue::Number(n)) => format!("{n}"), - Some(CellValue::Text(t)) => t, - None => String::new(), - }; - s.to_lowercase().contains(&query) - }).collect(); + let matches: Vec = (0..total) + .filter(|&flat| { + let ri = flat / total_cols; + let ci = flat % total_cols; + let key = match layout.cell_key(ri, ci) { + Some(k) => k, + None => return false, + }; + let s = match self.model.evaluate(&key) { + Some(CellValue::Number(n)) => format!("{n}"), + Some(CellValue::Text(t)) => t, + None => String::new(), + }; + s.to_lowercase().contains(&query) + }) + .collect(); if matches.is_empty() { self.status_msg = format!("No matches for '{}'", self.search_query); @@ -995,11 +1391,16 @@ impl App { // Find next/prev match relative to current position let target_flat = if forward { - matches.iter().find(|&&f| f > cur_flat) + matches + .iter() + .find(|&&f| f > cur_flat) .or_else(|| matches.first()) .copied() } else { - matches.iter().rev().find(|&&f| f < cur_flat) + matches + .iter() + .rev() + .find(|&&f| f < cur_flat) .or_else(|| matches.last()) .copied() }; @@ -1011,45 +1412,78 @@ impl App { let view = self.model.active_view_mut(); view.selected = (ri, ci); // Adjust scroll offsets to keep cursor visible - if ri < view.row_offset { view.row_offset = ri; } - if ci < view.col_offset { view.col_offset = ci; } + if ri < view.row_offset { + view.row_offset = ri; + } + if ci < view.col_offset { + view.col_offset = ci; + } } - self.status_msg = format!("Match {}/{} for '{}'", + self.status_msg = format!( + "Match {}/{} for '{}'", matches.iter().position(|&f| f == flat).unwrap_or(0) + 1, matches.len(), - self.search_query); + self.search_query + ); } } /// Gather (cat_name, items, current_idx) for all non-empty page categories. fn page_cat_data(&self) -> Vec<(String, Vec, usize)> { - let page_cats: Vec = self.model.active_view() - .categories_on(Axis::Page).into_iter().map(String::from).collect(); - page_cats.into_iter().filter_map(|cat| { - let items: Vec = self.model.category(&cat) - .map(|c| c.ordered_item_names().into_iter().map(String::from).collect()) - .unwrap_or_default(); - if items.is_empty() { return None; } - let current = self.model.active_view() - .page_selection(&cat) - .map(String::from) - .or_else(|| items.first().cloned()) - .unwrap_or_default(); - let idx = items.iter().position(|i| *i == current).unwrap_or(0); - Some((cat, items, idx)) - }).collect() + let page_cats: Vec = self + .model + .active_view() + .categories_on(Axis::Page) + .into_iter() + .map(String::from) + .collect(); + page_cats + .into_iter() + .filter_map(|cat| { + let items: Vec = self + .model + .category(&cat) + .map(|c| { + c.ordered_item_names() + .into_iter() + .map(String::from) + .collect() + }) + .unwrap_or_default(); + if items.is_empty() { + return None; + } + let current = self + .model + .active_view() + .page_selection(&cat) + .map(String::from) + .or_else(|| items.first().cloned()) + .unwrap_or_default(); + let idx = items.iter().position(|i| *i == current).unwrap_or(0); + Some((cat, items, idx)) + }) + .collect() } fn page_next(&mut self) { let data = self.page_cat_data(); - if data.is_empty() { return; } + if data.is_empty() { + return; + } // Odometer: advance from last category, carry propagates backward. let mut indices: Vec = data.iter().map(|(_, _, i)| *i).collect(); let mut carry = true; for i in (0..data.len()).rev() { - if !carry { break; } + if !carry { + break; + } indices[i] += 1; - if indices[i] >= data[i].1.len() { indices[i] = 0; } else { carry = false; } + if indices[i] >= data[i].1.len() { + indices[i] = 0; + } else { + carry = false; + } } let view = self.model.active_view_mut(); for (i, (cat, items, _)) in data.iter().enumerate() { @@ -1059,12 +1493,16 @@ impl App { fn page_prev(&mut self) { let data = self.page_cat_data(); - if data.is_empty() { return; } + if data.is_empty() { + return; + } // Odometer: decrement from last category, borrow propagates backward. let mut indices: Vec = data.iter().map(|(_, _, i)| *i).collect(); let mut borrow = true; for i in (0..data.len()).rev() { - if !borrow { break; } + if !borrow { + break; + } if indices[i] == 0 { indices[i] = data[i].1.len().saturating_sub(1); } else { @@ -1119,7 +1557,10 @@ impl App { pub fn enter_advance(&mut self) { let (row_max, col_max) = { let layout = GridLayout::new(&self.model, self.model.active_view()); - (layout.row_count().saturating_sub(1), layout.col_count().saturating_sub(1)) + ( + layout.row_count().saturating_sub(1), + layout.col_count().saturating_sub(1), + ) }; let view = self.model.active_view_mut(); let (r, c) = view.selected; @@ -1131,10 +1572,18 @@ impl App { (r, c) // already at bottom-right; stay }; view.selected = (nr, nc); - if nr < view.row_offset { view.row_offset = nr; } - if nr >= view.row_offset + 20 { view.row_offset = nr.saturating_sub(19); } - if nc < view.col_offset { view.col_offset = nc; } - if nc >= view.col_offset + 8 { view.col_offset = nc.saturating_sub(7); } + if nr < view.row_offset { + view.row_offset = nr; + } + if nr >= view.row_offset + 20 { + view.row_offset = nr.saturating_sub(19); + } + if nc < view.col_offset { + view.col_offset = nc; + } + if nc >= view.col_offset + 8 { + view.col_offset = nc.saturating_sub(7); + } } /// Hint text for the status bar (context-sensitive) @@ -1149,7 +1598,7 @@ impl App { AppMode::ItemAdd { .. } => "Enter:add & continue Tab:same Esc:done — type an item name", AppMode::ViewPanel => "jk:nav Enter:switch n:new d:delete Esc:back", AppMode::TileSelect { .. } => "hl:select Enter:cycle r/c/p:set-axis Esc:back", - AppMode::CommandMode { .. } => ":q quit :w save :import :add-cat :formula :help", + AppMode::CommandMode { .. } => ":q quit :w save :import :add-cat :formula :show-item :help", AppMode::ImportWizard => "Space:toggle c:cycle Enter:next Esc:cancel", _ => "", } @@ -1163,8 +1612,8 @@ mod tests { fn two_col_model() -> App { let mut m = Model::new("T"); - m.add_category("Row").unwrap(); // → Row axis - m.add_category("Col").unwrap(); // → Column axis + m.add_category("Row").unwrap(); // → Row axis + m.add_category("Col").unwrap(); // → Column axis m.category_mut("Row").unwrap().add_item("A"); m.category_mut("Row").unwrap().add_item("B"); m.category_mut("Row").unwrap().add_item("C"); @@ -1205,8 +1654,10 @@ mod tests { let mut app = two_col_model(); let json: serde_json::Value = serde_json::json!([{"cat": "A", "val": 1}]); app.start_import_wizard(json); - assert!(matches!(app.mode, AppMode::ImportWizard), - "mode should be ImportWizard after start_import_wizard"); + assert!( + matches!(app.mode, AppMode::ImportWizard), + "mode should be ImportWizard after start_import_wizard" + ); } #[test] @@ -1215,7 +1666,9 @@ mod tests { // Inject JSON via start_import_wizard to simulate what :import does app.start_import_wizard(serde_json::json!([{"x": 1}])); // After the command the mode must NOT be reset to Normal - assert!(!matches!(app.mode, AppMode::Normal), - "mode must not be Normal after import wizard is opened"); + assert!( + !matches!(app.mode, AppMode::Normal), + "mode must not be Normal after import wizard is opened" + ); } } diff --git a/src/ui/grid.rs b/src/ui/grid.rs index a8c9c78..6277f50 100644 --- a/src/ui/grid.rs +++ b/src/ui/grid.rs @@ -6,13 +6,15 @@ use ratatui::{ }; use unicode_width::UnicodeWidthStr; -use crate::model::Model; use crate::model::cell::CellValue; -use crate::view::GridLayout; +use crate::model::Model; use crate::ui::app::AppMode; +use crate::view::{AxisEntry, GridLayout}; const ROW_HEADER_WIDTH: u16 = 16; const COL_WIDTH: u16 = 10; +const GROUP_EXPANDED: &str = "▼"; +const GROUP_COLLAPSED: &str = "▶"; pub struct GridWidget<'a> { pub model: &'a Model, @@ -22,7 +24,11 @@ pub struct GridWidget<'a> { impl<'a> GridWidget<'a> { pub fn new(model: &'a Model, mode: &'a AppMode, search_query: &'a str) -> Self { - Self { model, mode, search_query } + Self { + model, + mode, + search_query, + } } fn render_grid(&self, area: Rect, buf: &mut Buffer) { @@ -39,150 +45,352 @@ impl<'a> GridWidget<'a> { // Sub-column widths for row header area let sub_col_w = ROW_HEADER_WIDTH / n_row_levels as u16; - let sub_widths: Vec = (0..n_row_levels).map(|d| { - if d < n_row_levels - 1 { sub_col_w } - else { ROW_HEADER_WIDTH.saturating_sub(sub_col_w * (n_row_levels as u16 - 1)) } - }).collect(); + let sub_widths: Vec = (0..n_row_levels) + .map(|d| { + if d < n_row_levels - 1 { + sub_col_w + } else { + ROW_HEADER_WIDTH.saturating_sub(sub_col_w * (n_row_levels as u16 - 1)) + } + }) + .collect(); + + // Flat lists of data-only tuples for repeat-suppression in headers + let data_col_items: Vec<&Vec> = layout + .col_items + .iter() + .filter_map(|e| { + if let AxisEntry::DataItem(v) = e { + Some(v) + } else { + None + } + }) + .collect(); + let data_row_items: Vec<&Vec> = layout + .row_items + .iter() + .filter_map(|e| { + if let AxisEntry::DataItem(v) = e { + Some(v) + } else { + None + } + }) + .collect(); + + // Map each data-col index to its group name (None if ungrouped) + let col_groups: Vec> = { + let mut groups = Vec::new(); + let mut current: Option = None; + for entry in &layout.col_items { + match entry { + AxisEntry::GroupHeader { group_name, .. } => current = Some(group_name.clone()), + AxisEntry::DataItem(_) => groups.push(current.clone()), + } + } + groups + }; + let has_col_groups = col_groups.iter().any(|g| g.is_some()); let available_cols = ((area.width.saturating_sub(ROW_HEADER_WIDTH)) / COL_WIDTH) as usize; - let visible_col_range = col_offset..(col_offset + available_cols.max(1)).min(layout.col_count()); + let visible_col_range = + col_offset..(col_offset + available_cols.max(1)).min(layout.col_count()); - let header_rows = n_col_levels as u16 + 1; // +1 for separator - let available_rows = area.height.saturating_sub(header_rows) as usize; - let visible_row_range = row_offset..(row_offset + available_rows.max(1)).min(layout.row_count()); + let _header_rows = n_col_levels as u16 + 1 + if has_col_groups { 1 } else { 0 }; + + let visual_row_start = layout + .data_row_to_visual(row_offset) + .unwrap_or(layout.row_items.len()); let mut y = area.y; - // Column headers — one row per level, with repeat suppression - let header_style = Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD); - for d in 0..n_col_levels { - buf.set_string(area.x, y, + // Optional column group header row + if has_col_groups { + let group_style = Style::default() + .fg(Color::Green) + .add_modifier(Modifier::BOLD); + buf.set_string( + area.x, + y, format!("{: = None; + for ci in visible_col_range.clone() { + if x >= area.x + area.width { + break; + } + let group = col_groups[ci].as_deref(); + let label = if group != prev_group { + group.unwrap_or("") + } else { + "" + }; + prev_group = group; + buf.set_string( + x, + y, + format!( + "{:width$}", truncate(&label, COL_WIDTH as usize), width = COL_WIDTH as usize), - styled); + buf.set_string( + x, + y, + format!( + "{:>width$}", + truncate(&label, COL_WIDTH as usize), + width = COL_WIDTH as usize + ), + styled, + ); x += COL_WIDTH; - if x >= area.x + area.width { break; } + if x >= area.x + area.width { + break; + } } y += 1; } // Separator - buf.set_string(area.x, y, + buf.set_string( + area.x, + y, "─".repeat(area.width as usize), - Style::default().fg(Color::DarkGray)); + Style::default().fg(Color::DarkGray), + ); y += 1; - // Data rows - for ri in visible_row_range.clone() { - if y >= area.y + area.height { break; } - - let row_style = if ri == sel_row { - Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD) - } else { - Style::default() - }; - - // Multi-level row header — one sub-column per row category - let mut hx = area.x; - for d in 0..n_row_levels { - let sw = sub_widths[d] as usize; - let label = if layout.row_cats.is_empty() { - layout.row_label(ri) - } else { - let show = ri == 0 - || layout.row_items[ri][..=d] != layout.row_items[ri - 1][..=d]; - if show { layout.row_items[ri][d].clone() } else { String::new() } - }; - buf.set_string(hx, y, - format!("{:= area.y + area.height { + break; } + match entry { + AxisEntry::GroupHeader { + cat_name, + group_name, + } => { + let indicator = if view.is_group_collapsed(cat_name, group_name) { + GROUP_COLLAPSED + } else { + GROUP_EXPANDED + }; + let label = format!("{indicator} {group_name}"); + buf.set_string( + area.x, + y, + format!( + "{: { + let ri = data_row_idx; + data_row_idx += 1; - let mut x = area.x + ROW_HEADER_WIDTH; - for ci in visible_col_range.clone() { - if x >= area.x + area.width { break; } + let row_style = if ri == sel_row { + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD) + } else { + Style::default() + }; - let key = match layout.cell_key(ri, ci) { - Some(k) => k, - None => { x += COL_WIDTH; continue; } - }; - let value = self.model.evaluate(&key); + // Multi-level row header — one sub-column per row category + let mut hx = area.x; + for d in 0..n_row_levels { + let sw = sub_widths[d] as usize; + let label = if layout.row_cats.is_empty() { + layout.row_label(ri) + } else { + let show = + ri == 0 || data_row_items[ri][..=d] != data_row_items[ri - 1][..=d]; + if show { + data_row_items[ri][d].clone() + } else { + String::new() + } + }; + buf.set_string( + hx, + y, + format!("{:= area.x + area.width { + break; + } - let cell_style = if is_selected { - Style::default().fg(Color::Black).bg(Color::Cyan).add_modifier(Modifier::BOLD) - } else if is_search_match { - Style::default().fg(Color::Black).bg(Color::Yellow) - } else if value.is_none() { - Style::default().fg(Color::DarkGray) - } else { - Style::default() - }; + let key = match layout.cell_key(ri, ci) { + Some(k) => k, + None => { + x += COL_WIDTH; + continue; + } + }; + let value = self.model.evaluate(&key); - buf.set_string(x, y, - format!("{:>width$}", truncate(&cell_str, COL_WIDTH as usize), width = COL_WIDTH as usize), - cell_style); - x += COL_WIDTH; - } + let cell_str = format_value(value.as_ref(), fmt_comma, fmt_decimals); + let is_selected = ri == sel_row && ci == sel_col; + let is_search_match = !self.search_query.is_empty() + && cell_str + .to_lowercase() + .contains(&self.search_query.to_lowercase()); - // Edit indicator - if matches!(self.mode, AppMode::Editing { .. }) && ri == sel_row { - if let AppMode::Editing { buffer } = self.mode { - let edit_x = area.x + ROW_HEADER_WIDTH + (sel_col.saturating_sub(col_offset)) as u16 * COL_WIDTH; - buf.set_string(edit_x, y, - truncate(&format!("{:width$}", + truncate(&cell_str, COL_WIDTH as usize), + width = COL_WIDTH as usize + ), + cell_style, + ); + x += COL_WIDTH; + } + + // Edit indicator + if matches!(self.mode, AppMode::Editing { .. }) && ri == sel_row { + if let AppMode::Editing { buffer } = self.mode { + let edit_x = area.x + + ROW_HEADER_WIDTH + + (sel_col.saturating_sub(col_offset)) as u16 * COL_WIDTH; + buf.set_string( + edit_x, + y, + truncate( + &format!("{: 0 && layout.col_count() > 0 { if y < area.y + area.height { - buf.set_string(area.x, y, + buf.set_string( + area.x, + y, "─".repeat(area.width as usize), - Style::default().fg(Color::DarkGray)); + Style::default().fg(Color::DarkGray), + ); y += 1; } if y < area.y + area.height { - buf.set_string(area.x, y, + buf.set_string( + area.x, + y, format!("{:= area.x + area.width { break; } + if x >= area.x + area.width { + break; + } let total: f64 = (0..layout.row_count()) .filter_map(|ri| layout.cell_key(ri, ci)) .map(|key| self.model.evaluate_f64(&key)) .sum(); let total_str = format_f64(total, fmt_comma, fmt_decimals); - buf.set_string(x, y, - format!("{:>width$}", truncate(&total_str, COL_WIDTH as usize), width = COL_WIDTH as usize), - Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)); + buf.set_string( + x, + y, + format!( + "{:>width$}", + truncate(&total_str, COL_WIDTH as usize), + width = COL_WIDTH as usize + ), + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ); x += COL_WIDTH; } } @@ -190,11 +398,9 @@ impl<'a> GridWidget<'a> { } } - impl<'a> Widget for GridWidget<'a> { fn render(self, area: Rect, buf: &mut Buffer) { - let view_name = self.model.active_view - .clone(); + let view_name = self.model.active_view.clone(); let block = Block::default() .borders(Borders::ALL) .title(format!(" View: {} ", view_name)); @@ -204,13 +410,18 @@ impl<'a> Widget for GridWidget<'a> { // Page axis bar let layout = GridLayout::new(self.model, self.model.active_view()); if !layout.page_coords.is_empty() && inner.height > 0 { - let page_info: Vec = layout.page_coords.iter() + let page_info: Vec = layout + .page_coords + .iter() .map(|(cat, sel)| format!("{cat} = {sel}")) .collect(); let page_str = format!(" [{}] ", page_info.join(" | ")); - buf.set_string(inner.x, inner.y, + buf.set_string( + inner.x, + inner.y, &page_str, - Style::default().fg(Color::Magenta)); + Style::default().fg(Color::Magenta), + ); let grid_area = Rect { y: inner.y + 1, @@ -234,7 +445,8 @@ fn format_value(v: Option<&CellValue>, comma: bool, decimals: u8) -> String { pub fn parse_number_format(fmt: &str) -> (bool, u8) { let comma = fmt.contains(','); - let decimals = fmt.rfind('.') + let decimals = fmt + .rfind('.') .and_then(|i| fmt[i + 1..].parse::().ok()) .unwrap_or(0); (comma, decimals) @@ -255,10 +467,14 @@ pub fn format_f64(n: f64, comma: bool, decimals: u8) -> String { let digits = if is_neg { &int_part[1..] } else { int_part }; let mut result = String::new(); for (idx, c) in digits.chars().rev().enumerate() { - if idx > 0 && idx % 3 == 0 { result.push(','); } + if idx > 0 && idx % 3 == 0 { + result.push(','); + } result.push(c); } - if is_neg { result.push('-'); } + if is_neg { + result.push('-'); + } let mut out: String = result.chars().rev().collect(); if let Some(dec) = dec_part { out.push_str(dec); @@ -275,7 +491,9 @@ fn truncate(s: &str, max_width: usize) -> String { let mut width = 0; for c in s.chars() { let cw = unicode_width::UnicodeWidthChar::width(c).unwrap_or(1); - if width + cw + 1 > max_width { break; } + if width + cw + 1 > max_width { + break; + } result.push(c); width += cw; } @@ -290,11 +508,11 @@ fn truncate(s: &str, max_width: usize) -> String { mod tests { use ratatui::{buffer::Buffer, layout::Rect, widgets::Widget}; - use crate::model::Model; - use crate::model::cell::{CellKey, CellValue}; - use crate::formula::parse_formula; - use crate::ui::app::AppMode; use super::GridWidget; + use crate::formula::parse_formula; + use crate::model::cell::{CellKey, CellValue}; + use crate::model::Model; + use crate::ui::app::AppMode; // ── Helpers ─────────────────────────────────────────────────────────────── @@ -311,20 +529,31 @@ mod tests { let w = buf.area().width as usize; buf.content() .chunks(w) - .map(|row| row.iter().map(|c| c.symbol()).collect::().trim_end().to_string()) + .map(|row| { + row.iter() + .map(|c| c.symbol()) + .collect::() + .trim_end() + .to_string() + }) .collect::>() .join("\n") } fn coord(pairs: &[(&str, &str)]) -> CellKey { - CellKey::new(pairs.iter().map(|(c, i)| (c.to_string(), i.to_string())).collect()) + CellKey::new( + pairs + .iter() + .map(|(c, i)| (c.to_string(), i.to_string())) + .collect(), + ) } /// Minimal model: Type on Row, Month on Column. fn two_cat_model() -> Model { let mut m = Model::new("Test"); - m.add_category("Type").unwrap(); // → Row - m.add_category("Month").unwrap(); // → Column + m.add_category("Type").unwrap(); // → Row + m.add_category("Month").unwrap(); // → Column if let Some(c) = m.category_mut("Type") { c.add_item("Food"); c.add_item("Clothing"); @@ -342,8 +571,8 @@ mod tests { fn column_headers_appear() { let m = two_cat_model(); let text = buf_text(&render(&m, 80, 24)); - assert!(text.contains("Jan"), "expected 'Jan' in:\n{text}"); - assert!(text.contains("Feb"), "expected 'Feb' in:\n{text}"); + assert!(text.contains("Jan"), "expected 'Jan' in:\n{text}"); + assert!(text.contains("Feb"), "expected 'Feb' in:\n{text}"); } // ── Row headers ─────────────────────────────────────────────────────────── @@ -352,7 +581,7 @@ mod tests { fn row_headers_appear() { let m = two_cat_model(); let text = buf_text(&render(&m, 80, 24)); - assert!(text.contains("Food"), "expected 'Food' in:\n{text}"); + assert!(text.contains("Food"), "expected 'Food' in:\n{text}"); assert!(text.contains("Clothing"), "expected 'Clothing' in:\n{text}"); } @@ -361,7 +590,10 @@ mod tests { #[test] fn cell_value_appears_in_correct_position() { let mut m = two_cat_model(); - m.set_cell(coord(&[("Type", "Food"), ("Month", "Jan")]), CellValue::Number(123.0)); + m.set_cell( + coord(&[("Type", "Food"), ("Month", "Jan")]), + CellValue::Number(123.0), + ); let text = buf_text(&render(&m, 80, 24)); assert!(text.contains("123"), "expected '123' in:\n{text}"); } @@ -369,13 +601,22 @@ mod tests { #[test] fn multiple_cell_values_all_appear() { let mut m = two_cat_model(); - m.set_cell(coord(&[("Type", "Food"), ("Month", "Jan")]), CellValue::Number(100.0)); - m.set_cell(coord(&[("Type", "Food"), ("Month", "Feb")]), CellValue::Number(200.0)); - m.set_cell(coord(&[("Type", "Clothing"), ("Month", "Jan")]), CellValue::Number(50.0)); + m.set_cell( + coord(&[("Type", "Food"), ("Month", "Jan")]), + CellValue::Number(100.0), + ); + m.set_cell( + coord(&[("Type", "Food"), ("Month", "Feb")]), + CellValue::Number(200.0), + ); + m.set_cell( + coord(&[("Type", "Clothing"), ("Month", "Jan")]), + CellValue::Number(50.0), + ); let text = buf_text(&render(&m, 80, 24)); assert!(text.contains("100"), "expected '100' in:\n{text}"); assert!(text.contains("200"), "expected '200' in:\n{text}"); - assert!(text.contains("50"), "expected '50' in:\n{text}"); + assert!(text.contains("50"), "expected '50' in:\n{text}"); } #[test] @@ -399,11 +640,20 @@ mod tests { #[test] fn total_row_sums_column_correctly() { let mut m = two_cat_model(); - m.set_cell(coord(&[("Type", "Food"), ("Month", "Jan")]), CellValue::Number(100.0)); - m.set_cell(coord(&[("Type", "Clothing"), ("Month", "Jan")]), CellValue::Number(50.0)); + m.set_cell( + coord(&[("Type", "Food"), ("Month", "Jan")]), + CellValue::Number(100.0), + ); + m.set_cell( + coord(&[("Type", "Clothing"), ("Month", "Jan")]), + CellValue::Number(50.0), + ); let text = buf_text(&render(&m, 80, 24)); // Food(100) + Clothing(50) = 150 for Jan - assert!(text.contains("150"), "expected '150' (total for Jan) in:\n{text}"); + assert!( + text.contains("150"), + "expected '150' (total for Jan) in:\n{text}" + ); } // ── Page filter bar ─────────────────────────────────────────────────────── @@ -411,18 +661,25 @@ mod tests { #[test] fn page_filter_bar_shows_category_and_selection() { let mut m = Model::new("Test"); - m.add_category("Type").unwrap(); // → Row - m.add_category("Month").unwrap(); // → Column - m.add_category("Payer").unwrap(); // → Page - if let Some(c) = m.category_mut("Type") { c.add_item("Food"); } - if let Some(c) = m.category_mut("Month") { c.add_item("Jan"); } + m.add_category("Type").unwrap(); // → Row + m.add_category("Month").unwrap(); // → Column + m.add_category("Payer").unwrap(); // → Page + if let Some(c) = m.category_mut("Type") { + c.add_item("Food"); + } + if let Some(c) = m.category_mut("Month") { + c.add_item("Jan"); + } if let Some(c) = m.category_mut("Payer") { c.add_item("Alice"); c.add_item("Bob"); } m.active_view_mut().set_page_selection("Payer", "Bob"); let text = buf_text(&render(&m, 80, 24)); - assert!(text.contains("Payer = Bob"), "expected 'Payer = Bob' in:\n{text}"); + assert!( + text.contains("Payer = Bob"), + "expected 'Payer = Bob' in:\n{text}" + ); } #[test] @@ -431,15 +688,22 @@ mod tests { m.add_category("Type").unwrap(); m.add_category("Month").unwrap(); m.add_category("Payer").unwrap(); - if let Some(c) = m.category_mut("Type") { c.add_item("Food"); } - if let Some(c) = m.category_mut("Month") { c.add_item("Jan"); } + if let Some(c) = m.category_mut("Type") { + c.add_item("Food"); + } + if let Some(c) = m.category_mut("Month") { + c.add_item("Jan"); + } if let Some(c) = m.category_mut("Payer") { c.add_item("Alice"); c.add_item("Bob"); } // No explicit selection — should default to first item let text = buf_text(&render(&m, 80, 24)); - assert!(text.contains("Payer = Alice"), "expected 'Payer = Alice' in:\n{text}"); + assert!( + text.contains("Payer = Alice"), + "expected 'Payer = Alice' in:\n{text}" + ); } // ── Formula evaluation ──────────────────────────────────────────────────── @@ -447,16 +711,24 @@ mod tests { #[test] fn formula_cell_renders_computed_value() { let mut m = Model::new("Test"); - m.add_category("Measure").unwrap(); // → Row - m.add_category("Region").unwrap(); // → Column + m.add_category("Measure").unwrap(); // → Row + m.add_category("Region").unwrap(); // → Column if let Some(c) = m.category_mut("Measure") { c.add_item("Revenue"); c.add_item("Cost"); c.add_item("Profit"); } - if let Some(c) = m.category_mut("Region") { c.add_item("East"); } - m.set_cell(coord(&[("Measure", "Revenue"), ("Region", "East")]), CellValue::Number(1000.0)); - m.set_cell(coord(&[("Measure", "Cost"), ("Region", "East")]), CellValue::Number(600.0)); + if let Some(c) = m.category_mut("Region") { + c.add_item("East"); + } + m.set_cell( + coord(&[("Measure", "Revenue"), ("Region", "East")]), + CellValue::Number(1000.0), + ); + m.set_cell( + coord(&[("Measure", "Cost"), ("Region", "East")]), + CellValue::Number(600.0), + ); m.add_formula(parse_formula("Profit = Revenue - Cost", "Measure").unwrap()); let text = buf_text(&render(&m, 80, 24)); @@ -468,23 +740,38 @@ mod tests { #[test] fn two_row_categories_produce_cross_product_labels() { let mut m = Model::new("Test"); - m.add_category("Type").unwrap(); // → Row - m.add_category("Month").unwrap(); // → Column + m.add_category("Type").unwrap(); // → Row + m.add_category("Month").unwrap(); // → Column m.add_category("Recipient").unwrap(); // → Page by default; move to Row - if let Some(c) = m.category_mut("Type") { c.add_item("Food"); c.add_item("Clothing"); } - if let Some(c) = m.category_mut("Month") { c.add_item("Jan"); } - if let Some(c) = m.category_mut("Recipient") { c.add_item("Alice"); c.add_item("Bob"); } - m.active_view_mut().set_axis("Recipient", crate::view::Axis::Row); + if let Some(c) = m.category_mut("Type") { + c.add_item("Food"); + c.add_item("Clothing"); + } + if let Some(c) = m.category_mut("Month") { + c.add_item("Jan"); + } + if let Some(c) = m.category_mut("Recipient") { + c.add_item("Alice"); + c.add_item("Bob"); + } + m.active_view_mut() + .set_axis("Recipient", crate::view::Axis::Row); let text = buf_text(&render(&m, 80, 24)); // Multi-level row headers: category values shown separately, not joined with / - assert!(!text.contains("Food/Alice"), "slash-joined labels should be gone:\n{text}"); - assert!(!text.contains("Clothing/Bob"), "slash-joined labels should be gone:\n{text}"); + assert!( + !text.contains("Food/Alice"), + "slash-joined labels should be gone:\n{text}" + ); + assert!( + !text.contains("Clothing/Bob"), + "slash-joined labels should be gone:\n{text}" + ); // Each item name appears on its own - assert!(text.contains("Food"), "expected 'Food' in:\n{text}"); + assert!(text.contains("Food"), "expected 'Food' in:\n{text}"); assert!(text.contains("Clothing"), "expected 'Clothing' in:\n{text}"); - assert!(text.contains("Alice"), "expected 'Alice' in:\n{text}"); - assert!(text.contains("Bob"), "expected 'Bob' in:\n{text}"); + assert!(text.contains("Alice"), "expected 'Alice' in:\n{text}"); + assert!(text.contains("Bob"), "expected 'Bob' in:\n{text}"); } #[test] @@ -493,10 +780,18 @@ mod tests { m.add_category("Type").unwrap(); m.add_category("Month").unwrap(); m.add_category("Recipient").unwrap(); - if let Some(c) = m.category_mut("Type") { c.add_item("Food"); } - if let Some(c) = m.category_mut("Month") { c.add_item("Jan"); } - if let Some(c) = m.category_mut("Recipient") { c.add_item("Alice"); c.add_item("Bob"); } - m.active_view_mut().set_axis("Recipient", crate::view::Axis::Row); + if let Some(c) = m.category_mut("Type") { + c.add_item("Food"); + } + if let Some(c) = m.category_mut("Month") { + c.add_item("Jan"); + } + if let Some(c) = m.category_mut("Recipient") { + c.add_item("Alice"); + c.add_item("Bob"); + } + m.active_view_mut() + .set_axis("Recipient", crate::view::Axis::Row); // Set data at the full 3-coordinate key m.set_cell( coord(&[("Month", "Jan"), ("Recipient", "Alice"), ("Type", "Food")]), @@ -509,19 +804,33 @@ mod tests { #[test] fn two_column_categories_produce_cross_product_headers() { let mut m = Model::new("Test"); - m.add_category("Type").unwrap(); // → Row - m.add_category("Month").unwrap(); // → Column - m.add_category("Year").unwrap(); // → Page by default; move to Column - if let Some(c) = m.category_mut("Type") { c.add_item("Food"); } - if let Some(c) = m.category_mut("Month") { c.add_item("Jan"); } - if let Some(c) = m.category_mut("Year") { c.add_item("2024"); c.add_item("2025"); } - m.active_view_mut().set_axis("Year", crate::view::Axis::Column); + m.add_category("Type").unwrap(); // → Row + m.add_category("Month").unwrap(); // → Column + m.add_category("Year").unwrap(); // → Page by default; move to Column + if let Some(c) = m.category_mut("Type") { + c.add_item("Food"); + } + if let Some(c) = m.category_mut("Month") { + c.add_item("Jan"); + } + if let Some(c) = m.category_mut("Year") { + c.add_item("2024"); + c.add_item("2025"); + } + m.active_view_mut() + .set_axis("Year", crate::view::Axis::Column); let text = buf_text(&render(&m, 80, 24)); // Multi-level column headers: category values shown separately, not joined with / - assert!(!text.contains("Jan/2024"), "slash-joined headers should be gone:\n{text}"); - assert!(!text.contains("Jan/2025"), "slash-joined headers should be gone:\n{text}"); - assert!(text.contains("Jan"), "expected 'Jan' in:\n{text}"); + assert!( + !text.contains("Jan/2024"), + "slash-joined headers should be gone:\n{text}" + ); + assert!( + !text.contains("Jan/2025"), + "slash-joined headers should be gone:\n{text}" + ); + assert!(text.contains("Jan"), "expected 'Jan' in:\n{text}"); assert!(text.contains("2024"), "expected '2024' in:\n{text}"); assert!(text.contains("2025"), "expected '2025' in:\n{text}"); } diff --git a/src/ui/help.rs b/src/ui/help.rs index 8625f98..05ed98a 100644 --- a/src/ui/help.rs +++ b/src/ui/help.rs @@ -23,9 +23,11 @@ impl Widget for HelpWidget { let inner = block.inner(popup_area); block.render(popup_area, buf); - let head = Style::default().fg(Color::Blue).add_modifier(Modifier::BOLD); - let key = Style::default().fg(Color::Cyan); - let dim = Style::default().fg(Color::DarkGray); + let head = Style::default() + .fg(Color::Blue) + .add_modifier(Modifier::BOLD); + let key = Style::default().fg(Color::Cyan); + let dim = Style::default().fg(Color::DarkGray); let norm = Style::default(); // (key_col, desc_col, style) @@ -51,25 +53,51 @@ impl Widget for HelpWidget { ("", "", norm), ("Panels", "", head), (" F", "Toggle Formula panel (n:new d:del)", key), - (" C", "Toggle Category panel (n:new-cat a:add-items)", key), + ( + " C", + "Toggle Category panel (n:new-cat a:add-items)", + key, + ), (" N", "New category quick-add (from anywhere)", key), - (" V", "Toggle View panel (n:new d:del Enter:switch)", key), + ( + " V", + "Toggle View panel (n:new d:del Enter:switch)", + key, + ), (" Tab", "Focus next open panel", key), ("", "", norm), - ("Pivot / Tiles", "", head), + ("Pivot / Tiles / Groups", "", head), + (" z", "Toggle collapse nearest group above cursor", key), + ( + " H", + "Hide current row item (:show-item cat item to restore)", + key, + ), (" T", "Tile-select mode", key), (" ← h / → l", "Select previous/next tile", dim), (" Space / Enter", "Cycle axis (Row→Col→Page)", dim), (" r / c / p", "Set axis to Row / Col / Page", dim), ("", "", norm), ("Command line ( : )", "", head), - (" :q :q! :wq ZZ", "Quit / force-quit / save+quit", key), + ( + " :q :q! :wq ZZ", + "Quit / force-quit / save+quit", + key, + ), (" :w [path]", "Save (path optional)", key), (" :import ", "Open JSON import wizard", key), (" :export [path.csv]", "Export active view to CSV", key), (" :add-cat ", "Add a category", key), - (" :add-item ", "Add one item to a category", key), - (" :add-items a b c…", "Add multiple items at once", key), + ( + " :add-item ", + "Add one item to a category", + key, + ), + ( + " :add-items a b c…", + "Add multiple items at once", + key, + ), (" :formula ", "Add a formula", key), (" :add-view [name]", "Create a new view", key), ("", "", norm), @@ -79,7 +107,9 @@ impl Widget for HelpWidget { let key_col_w = 32usize; for (i, (k, d, style)) in rows.iter().enumerate() { - if i >= inner.height as usize { break; } + if i >= inner.height as usize { + break; + } let y = inner.y + i as u16; if d.is_empty() { buf.set_string(inner.x, y, k, *style); diff --git a/src/view/layout.rs b/src/view/layout.rs index 33e7ff0..42e4887 100644 --- a/src/view/layout.rs +++ b/src/view/layout.rs @@ -1,62 +1,156 @@ -use crate::model::Model; use crate::model::cell::CellKey; +use crate::model::Model; use crate::view::{Axis, View}; +/// One entry on a grid axis: either a visual group header or a data-item tuple. +/// +/// `GroupHeader` entries are always visible so the user can see the group label +/// and toggle its collapse state. `DataItem` entries are absent when their +/// group is collapsed. +#[derive(Debug, Clone, PartialEq)] +pub enum AxisEntry { + GroupHeader { + cat_name: String, + group_name: String, + }, + DataItem(Vec), +} + /// The resolved 2-D layout of a view: which item tuples appear on each axis, /// what page filter is active, and how to map (row, col) → CellKey. /// /// This is the single authoritative place that converts the multi-dimensional /// model into the flat grid consumed by both the terminal renderer and CSV exporter. pub struct GridLayout { - pub row_cats: Vec, - pub col_cats: Vec, + pub row_cats: Vec, + pub col_cats: Vec, pub page_coords: Vec<(String, String)>, - pub row_items: Vec>, - pub col_items: Vec>, + pub row_items: Vec, + pub col_items: Vec, } impl GridLayout { pub fn new(model: &Model, view: &View) -> Self { - let row_cats: Vec = view.categories_on(Axis::Row) - .into_iter().map(String::from).collect(); - let col_cats: Vec = view.categories_on(Axis::Column) - .into_iter().map(String::from).collect(); - let page_cats: Vec = view.categories_on(Axis::Page) - .into_iter().map(String::from).collect(); + let row_cats: Vec = view + .categories_on(Axis::Row) + .into_iter() + .map(String::from) + .collect(); + let col_cats: Vec = view + .categories_on(Axis::Column) + .into_iter() + .map(String::from) + .collect(); + let page_cats: Vec = view + .categories_on(Axis::Page) + .into_iter() + .map(String::from) + .collect(); - let page_coords = page_cats.iter().map(|cat| { - let items: Vec = model.category(cat) - .map(|c| c.ordered_item_names().into_iter().map(String::from).collect()) - .unwrap_or_default(); - let sel = view.page_selection(cat) - .map(String::from) - .or_else(|| items.first().cloned()) - .unwrap_or_default(); - (cat.clone(), sel) - }).collect(); + let page_coords = page_cats + .iter() + .map(|cat| { + let items: Vec = model + .category(cat) + .map(|c| { + c.ordered_item_names() + .into_iter() + .map(String::from) + .collect() + }) + .unwrap_or_default(); + let sel = view + .page_selection(cat) + .map(String::from) + .or_else(|| items.first().cloned()) + .unwrap_or_default(); + (cat.clone(), sel) + }) + .collect(); let row_items = cross_product(model, view, &row_cats); let col_items = cross_product(model, view, &col_cats); - Self { row_cats, col_cats, page_coords, row_items, col_items } + Self { + row_cats, + col_cats, + page_coords, + row_items, + col_items, + } } - pub fn row_count(&self) -> usize { self.row_items.len() } - pub fn col_count(&self) -> usize { self.col_items.len() } + /// Number of data rows (group headers excluded). + pub fn row_count(&self) -> usize { + self.row_items + .iter() + .filter(|e| matches!(e, AxisEntry::DataItem(_))) + .count() + } + + /// Number of data columns (group headers excluded). + pub fn col_count(&self) -> usize { + self.col_items + .iter() + .filter(|e| matches!(e, AxisEntry::DataItem(_))) + .count() + } pub fn row_label(&self, row: usize) -> String { - self.row_items.get(row).map(|r| r.join("/")).unwrap_or_default() + self.row_items + .iter() + .filter_map(|e| { + if let AxisEntry::DataItem(v) = e { + Some(v) + } else { + None + } + }) + .nth(row) + .map(|r| r.join("/")) + .unwrap_or_default() } pub fn col_label(&self, col: usize) -> String { - self.col_items.get(col).map(|c| c.join("/")).unwrap_or_default() + self.col_items + .iter() + .filter_map(|e| { + if let AxisEntry::DataItem(v) = e { + Some(v) + } else { + None + } + }) + .nth(col) + .map(|c| c.join("/")) + .unwrap_or_default() } /// Build the CellKey for the data cell at (row, col), including the active /// page-axis filter. Returns None if row or col is out of bounds. pub fn cell_key(&self, row: usize, col: usize) -> Option { - let row_item = self.row_items.get(row)?; - let col_item = self.col_items.get(col)?; + let row_item = self + .row_items + .iter() + .filter_map(|e| { + if let AxisEntry::DataItem(v) = e { + Some(v) + } else { + None + } + }) + .nth(row)?; + let col_item = self + .col_items + .iter() + .filter_map(|e| { + if let AxisEntry::DataItem(v) = e { + Some(v) + } else { + None + } + }) + .nth(col)?; let mut coords = self.page_coords.clone(); for (cat, item) in self.row_cats.iter().zip(row_item.iter()) { coords.push((cat.clone(), item.clone())); @@ -66,48 +160,126 @@ impl GridLayout { } Some(CellKey::new(coords)) } + + /// Visual index of the nth data row (skipping group headers). + pub fn data_row_to_visual(&self, data_row: usize) -> Option { + let mut count = 0; + for (vi, entry) in self.row_items.iter().enumerate() { + if let AxisEntry::DataItem(_) = entry { + if count == data_row { + return Some(vi); + } + count += 1; + } + } + None + } + + /// Visual index of the nth data column (skipping group headers). + pub fn data_col_to_visual(&self, data_col: usize) -> Option { + let mut count = 0; + for (vi, entry) in self.col_items.iter().enumerate() { + if let AxisEntry::DataItem(_) = entry { + if count == data_col { + return Some(vi); + } + count += 1; + } + } + None + } +} + +/// Expand a single category into `AxisEntry` values, given a coordinate prefix. +/// Emits a `GroupHeader` at each group boundary, then `DataItem` entries for +/// visible, non-collapsed items. +fn expand_category( + model: &Model, + view: &View, + cat_name: &str, + prefix: Vec, +) -> Vec { + let Some(cat) = model.category(cat_name) else { + return vec![]; + }; + let mut result = Vec::new(); + let mut last_group: Option<&str> = None; + + for item_name in cat.ordered_item_names() { + if view.is_hidden(cat_name, item_name) { + continue; + } + let item_group = cat.items.get(item_name).and_then(|i| i.group.as_deref()); + + // Emit a group header at each group boundary. + if item_group != last_group { + if let Some(g) = item_group { + result.push(AxisEntry::GroupHeader { + cat_name: cat_name.to_string(), + group_name: g.to_string(), + }); + } + last_group = item_group; + } + + // Skip the data item if its group is collapsed. + if item_group.map_or(false, |g| view.is_group_collapsed(cat_name, g)) { + continue; + } + + let mut row = prefix.clone(); + row.push(item_name.to_string()); + result.push(AxisEntry::DataItem(row)); + } + result } /// Cartesian product of visible items across `cats`, in category order. -/// Hidden items are excluded. Returns `vec![vec![]]` when `cats` is empty. -fn cross_product(model: &Model, view: &View, cats: &[String]) -> Vec> { +/// Hidden items and items in collapsed groups are excluded from `DataItem` +/// entries; group headers are always emitted. +/// Returns `vec![DataItem(vec![])]` when `cats` is empty. +fn cross_product(model: &Model, view: &View, cats: &[String]) -> Vec { if cats.is_empty() { - return vec![vec![]]; + return vec![AxisEntry::DataItem(vec![])]; } - let mut result: Vec> = vec![vec![]]; + let mut result: Vec = vec![AxisEntry::DataItem(vec![])]; for cat_name in cats { - let items: Vec = model.category(cat_name) - .map(|c| c.ordered_item_names().into_iter() - .filter(|item| !view.is_hidden(cat_name, item)) - .map(String::from).collect()) - .unwrap_or_default(); - result = result.into_iter().flat_map(|prefix| { - items.iter().map(move |item| { - let mut row = prefix.clone(); - row.push(item.clone()); - row + result = result + .into_iter() + .flat_map(|entry| match entry { + AxisEntry::DataItem(prefix) => expand_category(model, view, cat_name, prefix), + header @ AxisEntry::GroupHeader { .. } => vec![header], }) - }).collect(); + .collect(); } result } #[cfg(test)] mod tests { - use super::GridLayout; - use crate::model::Model; + use super::{AxisEntry, GridLayout}; use crate::model::cell::{CellKey, CellValue}; + use crate::model::Model; fn coord(pairs: &[(&str, &str)]) -> CellKey { - CellKey::new(pairs.iter().map(|(c, i)| (c.to_string(), i.to_string())).collect()) + CellKey::new( + pairs + .iter() + .map(|(c, i)| (c.to_string(), i.to_string())) + .collect(), + ) } fn two_cat_model() -> Model { let mut m = Model::new("T"); m.add_category("Type").unwrap(); m.add_category("Month").unwrap(); - for item in ["Food", "Clothing"] { m.category_mut("Type").unwrap().add_item(item); } - for item in ["Jan", "Feb"] { m.category_mut("Month").unwrap().add_item(item); } + for item in ["Food", "Clothing"] { + m.category_mut("Type").unwrap().add_item(item); + } + for item in ["Jan", "Feb"] { + m.category_mut("Month").unwrap().add_item(item); + } m } @@ -174,8 +346,141 @@ mod tests { m.category_mut("Type").unwrap().add_item("Food"); m.category_mut("Month").unwrap().add_item("Jan"); m.category_mut("Year").unwrap().add_item("2025"); - m.active_view_mut().set_axis("Year", crate::view::Axis::Column); + m.active_view_mut() + .set_axis("Year", crate::view::Axis::Column); let layout = GridLayout::new(&m, m.active_view()); assert_eq!(layout.col_label(0), "Jan/2025"); } + + #[test] + fn row_count_excludes_group_headers() { + let mut m = Model::new("T"); + m.add_category("Month").unwrap(); + m.add_category("Type").unwrap(); + m.category_mut("Month") + .unwrap() + .add_item_in_group("Jan", "Q1"); + m.category_mut("Month") + .unwrap() + .add_item_in_group("Feb", "Q1"); + m.category_mut("Month") + .unwrap() + .add_item_in_group("Apr", "Q2"); + m.category_mut("Type").unwrap().add_item("Food"); + let layout = GridLayout::new(&m, m.active_view()); + assert_eq!(layout.row_count(), 3); // Jan, Feb, Apr — headers don't count + } + + #[test] + fn group_header_emitted_at_group_boundary() { + let mut m = Model::new("T"); + m.add_category("Month").unwrap(); + m.add_category("Type").unwrap(); + m.category_mut("Month") + .unwrap() + .add_item_in_group("Jan", "Q1"); + m.category_mut("Month") + .unwrap() + .add_item_in_group("Apr", "Q2"); + m.category_mut("Type").unwrap().add_item("Food"); + let layout = GridLayout::new(&m, m.active_view()); + let headers: Vec<_> = layout + .row_items + .iter() + .filter(|e| matches!(e, AxisEntry::GroupHeader { .. })) + .collect(); + assert_eq!(headers.len(), 2); + assert!( + matches!(&headers[0], AxisEntry::GroupHeader { group_name, .. } if group_name == "Q1") + ); + assert!( + matches!(&headers[1], AxisEntry::GroupHeader { group_name, .. } if group_name == "Q2") + ); + } + + #[test] + fn collapsed_group_has_header_but_no_data_items() { + let mut m = Model::new("T"); + m.add_category("Month").unwrap(); + m.add_category("Type").unwrap(); + m.category_mut("Month") + .unwrap() + .add_item_in_group("Jan", "Q1"); + m.category_mut("Month") + .unwrap() + .add_item_in_group("Feb", "Q1"); + m.category_mut("Month") + .unwrap() + .add_item_in_group("Apr", "Q2"); + m.category_mut("Type").unwrap().add_item("Food"); + m.active_view_mut().toggle_group_collapse("Month", "Q1"); + let layout = GridLayout::new(&m, m.active_view()); + // Q1 collapsed: header present, Jan and Feb absent; Q2 intact + assert_eq!(layout.row_count(), 1); // only Apr + let q1_header = layout + .row_items + .iter() + .find(|e| matches!(e, AxisEntry::GroupHeader { group_name, .. } if group_name == "Q1")); + assert!(q1_header.is_some()); + let jan = layout + .row_items + .iter() + .find(|e| matches!(e, AxisEntry::DataItem(v) if v.contains(&"Jan".to_string()))); + assert!(jan.is_none()); + } + + #[test] + fn ungrouped_items_produce_no_headers() { + let m = two_cat_model(); + let layout = GridLayout::new(&m, m.active_view()); + assert!(!layout + .row_items + .iter() + .any(|e| matches!(e, AxisEntry::GroupHeader { .. }))); + assert!(!layout + .col_items + .iter() + .any(|e| matches!(e, AxisEntry::GroupHeader { .. }))); + } + + #[test] + fn cell_key_correct_with_grouped_items() { + let mut m = Model::new("T"); + m.add_category("Month").unwrap(); + m.add_category("Type").unwrap(); + m.category_mut("Month") + .unwrap() + .add_item_in_group("Jan", "Q1"); + m.category_mut("Month") + .unwrap() + .add_item_in_group("Apr", "Q2"); + m.category_mut("Type").unwrap().add_item("Food"); + m.set_cell( + coord(&[("Month", "Apr"), ("Type", "Food")]), + CellValue::Number(99.0), + ); + let layout = GridLayout::new(&m, m.active_view()); + // data row 0 = Jan, data row 1 = Apr + let key = layout.cell_key(1, 0).unwrap(); + assert_eq!(m.evaluate(&key), Some(CellValue::Number(99.0))); + } + + #[test] + fn data_row_to_visual_skips_headers() { + let mut m = Model::new("T"); + m.add_category("Month").unwrap(); + m.add_category("Type").unwrap(); + m.category_mut("Month") + .unwrap() + .add_item_in_group("Jan", "Q1"); + m.category_mut("Month") + .unwrap() + .add_item_in_group("Apr", "Q2"); + m.category_mut("Type").unwrap().add_item("Food"); + let layout = GridLayout::new(&m, m.active_view()); + // visual: [GroupHeader(Q1), DataItem(Jan), GroupHeader(Q2), DataItem(Apr)] + assert_eq!(layout.data_row_to_visual(0), Some(1)); // Jan is at visual index 1 + assert_eq!(layout.data_row_to_visual(1), Some(3)); // Apr is at visual index 3 + assert_eq!(layout.data_row_to_visual(2), None); + } } diff --git a/src/view/mod.rs b/src/view/mod.rs index c241190..b2882d4 100644 --- a/src/view/mod.rs +++ b/src/view/mod.rs @@ -1,7 +1,7 @@ -pub mod view; pub mod axis; pub mod layout; +pub mod view; -pub use view::View; pub use axis::Axis; -pub use layout::GridLayout; +pub use layout::{AxisEntry, GridLayout}; +pub use view::View; diff --git a/src/view/view.rs b/src/view/view.rs index 8582616..fc4fe7a 100644 --- a/src/view/view.rs +++ b/src/view/view.rs @@ -1,6 +1,6 @@ -use std::collections::{HashMap, HashSet}; use indexmap::IndexMap; use serde::{Deserialize, Serialize}; +use std::collections::{HashMap, HashSet}; use super::axis::Axis; @@ -62,20 +62,23 @@ impl View { } pub fn axis_of(&self, cat_name: &str) -> Axis { - *self.category_axes.get(cat_name) + *self + .category_axes + .get(cat_name) .expect("axis_of called for category not registered with this view") } pub fn categories_on(&self, axis: Axis) -> Vec<&str> { - self.category_axes.iter() + self.category_axes + .iter() .filter(|(_, &a)| a == axis) .map(|(n, _)| n.as_str()) .collect() } - pub fn set_page_selection(&mut self, cat_name: &str, item: &str) { - self.page_selections.insert(cat_name.to_string(), item.to_string()); + self.page_selections + .insert(cat_name.to_string(), item.to_string()); } pub fn page_selection(&self, cat_name: &str) -> Option<&str> { @@ -83,7 +86,10 @@ impl View { } pub fn toggle_group_collapse(&mut self, cat_name: &str, group_name: &str) { - let set = self.collapsed_groups.entry(cat_name.to_string()).or_default(); + let set = self + .collapsed_groups + .entry(cat_name.to_string()) + .or_default(); if set.contains(group_name) { set.remove(group_name); } else { @@ -91,34 +97,52 @@ impl View { } } - // pub fn is_group_collapsed(&self, cat_name: &str, group_name: &str) -> bool { - // self.collapsed_groups - // .get(cat_name) - // .map(|s| s.contains(group_name)) - // .unwrap_or(false) - // } - - pub fn hide_item(&mut self, cat_name: &str, item_name: &str) { - self.hidden_items.entry(cat_name.to_string()).or_default().insert(item_name.to_string()); + pub fn is_group_collapsed(&self, cat_name: &str, group_name: &str) -> bool { + self.collapsed_groups + .get(cat_name) + .map(|s| s.contains(group_name)) + .unwrap_or(false) } - // pub fn show_item(&mut self, cat_name: &str, item_name: &str) { - // if let Some(set) = self.hidden_items.get_mut(cat_name) { - // set.remove(item_name); - // } - // } + pub fn hide_item(&mut self, cat_name: &str, item_name: &str) { + self.hidden_items + .entry(cat_name.to_string()) + .or_default() + .insert(item_name.to_string()); + } + + pub fn show_item(&mut self, cat_name: &str, item_name: &str) { + if let Some(set) = self.hidden_items.get_mut(cat_name) { + set.remove(item_name); + } + } pub fn is_hidden(&self, cat_name: &str, item_name: &str) -> bool { - self.hidden_items.get(cat_name).map(|s| s.contains(item_name)).unwrap_or(false) + self.hidden_items + .get(cat_name) + .map(|s| s.contains(item_name)) + .unwrap_or(false) } /// Swap all Row categories to Column and all Column categories to Row. /// Page categories are unaffected. pub fn transpose_axes(&mut self) { - let rows: Vec = self.categories_on(Axis::Row).iter().map(|s| s.to_string()).collect(); - let cols: Vec = self.categories_on(Axis::Column).iter().map(|s| s.to_string()).collect(); - for cat in &rows { self.set_axis(cat, Axis::Column); } - for cat in &cols { self.set_axis(cat, Axis::Row); } + let rows: Vec = self + .categories_on(Axis::Row) + .iter() + .map(|s| s.to_string()) + .collect(); + let cols: Vec = self + .categories_on(Axis::Column) + .iter() + .map(|s| s.to_string()) + .collect(); + for cat in &rows { + self.set_axis(cat, Axis::Column); + } + for cat in &cols { + self.set_axis(cat, Axis::Row); + } self.selected = (0, 0); self.row_offset = 0; self.col_offset = 0; @@ -127,9 +151,9 @@ impl View { /// Cycle axis for a category: Row → Column → Page → Row pub fn cycle_axis(&mut self, cat_name: &str) { let next = match self.axis_of(cat_name) { - Axis::Row => Axis::Column, + Axis::Row => Axis::Column, Axis::Column => Axis::Page, - Axis::Page => Axis::Row, + Axis::Page => Axis::Row, }; self.set_axis(cat_name, next); self.selected = (0, 0); @@ -145,7 +169,9 @@ mod tests { fn view_with_cats(cats: &[&str]) -> View { let mut v = View::new("Test"); - for &c in cats { v.on_category_added(c); } + for &c in cats { + v.on_category_added(c); + } v } @@ -164,7 +190,7 @@ mod tests { #[test] fn third_and_later_categories_assigned_to_page() { let v = view_with_cats(&["Region", "Product", "Time", "Scenario"]); - assert_eq!(v.axis_of("Time"), Axis::Page); + assert_eq!(v.axis_of("Time"), Axis::Page); assert_eq!(v.axis_of("Scenario"), Axis::Page); } @@ -178,19 +204,19 @@ mod tests { #[test] fn categories_on_returns_correct_list() { let v = view_with_cats(&["Region", "Product", "Time"]); - assert_eq!(v.categories_on(Axis::Row), vec!["Region"]); + assert_eq!(v.categories_on(Axis::Row), vec!["Region"]); assert_eq!(v.categories_on(Axis::Column), vec!["Product"]); - assert_eq!(v.categories_on(Axis::Page), vec!["Time"]); + assert_eq!(v.categories_on(Axis::Page), vec!["Time"]); } #[test] fn transpose_axes_swaps_row_and_column() { let mut v = view_with_cats(&["Region", "Product"]); // Default: Region=Row, Product=Column - assert_eq!(v.axis_of("Region"), Axis::Row); + assert_eq!(v.axis_of("Region"), Axis::Row); assert_eq!(v.axis_of("Product"), Axis::Column); v.transpose_axes(); - assert_eq!(v.axis_of("Region"), Axis::Column); + assert_eq!(v.axis_of("Region"), Axis::Column); assert_eq!(v.axis_of("Product"), Axis::Row); } @@ -207,7 +233,7 @@ mod tests { let mut v = view_with_cats(&["Region", "Product"]); v.transpose_axes(); v.transpose_axes(); - assert_eq!(v.axis_of("Region"), Axis::Row); + assert_eq!(v.axis_of("Region"), Axis::Row); assert_eq!(v.axis_of("Product"), Axis::Column); } @@ -222,21 +248,32 @@ mod tests { fn page_selection_set_and_get() { let mut v = view_with_cats(&["Region", "Product", "Time"]); v.set_page_selection("Time", "Q1"); - assert_eq!(v.page_selection("Time"), Some("Q1")); + assert_eq!(v.page_selection("Time"), Some("Q1")); assert_eq!(v.page_selection("Region"), None); } #[test] fn toggle_group_collapse_toggles_twice() { - let collapsed = |v: &View, cat: &str, group: &str| { - v.collapsed_groups.get(cat).map(|s| s.contains(group)).unwrap_or(false) - }; let mut v = View::new("Test"); - assert!(!collapsed(&v, "Time", "Q1")); + assert!(!v.is_group_collapsed("Time", "Q1")); v.toggle_group_collapse("Time", "Q1"); - assert!(collapsed(&v, "Time", "Q1")); + assert!(v.is_group_collapsed("Time", "Q1")); v.toggle_group_collapse("Time", "Q1"); - assert!(!collapsed(&v, "Time", "Q1")); + assert!(!v.is_group_collapsed("Time", "Q1")); + } + + #[test] + fn is_group_collapsed_isolated_across_categories() { + let mut v = View::new("Test"); + v.toggle_group_collapse("Cat1", "G1"); + assert!(!v.is_group_collapsed("Cat2", "G1")); + } + + #[test] + fn is_group_collapsed_isolated_across_groups() { + let mut v = View::new("Test"); + v.toggle_group_collapse("Cat1", "G1"); + assert!(!v.is_group_collapsed("Cat1", "G2")); } #[test] @@ -245,8 +282,8 @@ mod tests { assert!(!v.is_hidden("Region", "East")); v.hide_item("Region", "East"); assert!(v.is_hidden("Region", "East")); - // v.show_item("Region", "East"); - // assert!(!v.is_hidden("Region", "East")); + v.show_item("Region", "East"); + assert!(!v.is_hidden("Region", "East")); } #[test] @@ -280,7 +317,7 @@ mod tests { v.cycle_axis("Region"); assert_eq!(v.row_offset, 0); assert_eq!(v.col_offset, 0); - assert_eq!(v.selected, (0, 0)); + assert_eq!(v.selected, (0, 0)); } } @@ -290,7 +327,6 @@ mod prop_tests { use crate::view::Axis; use proptest::prelude::*; - fn unique_cat_names() -> impl Strategy> { prop::collection::hash_set("[A-Za-z][a-z]{1,7}", 1usize..=8) .prop_map(|s| s.into_iter().collect::>()) @@ -406,8 +442,8 @@ mod prop_tests { let mut v = View::new("T"); v.hide_item(&cat, &item); prop_assert!(v.is_hidden(&cat, &item)); - // v.show_item(&cat, &item); - // prop_assert!(!v.is_hidden(&cat, &item)); + v.show_item(&cat, &item); + prop_assert!(!v.is_hidden(&cat, &item)); } /// toggle_group_collapse is its own inverse @@ -417,11 +453,10 @@ mod prop_tests { group in "[A-Za-z][a-z]{1,7}", ) { let mut v = View::new("T"); - let collapsed = |v: &View| v.collapsed_groups.get(&cat).map(|s| s.contains(&group as &str)).unwrap_or(false); - let initial = collapsed(&v); + let initial = v.is_group_collapsed(&cat, &group); v.toggle_group_collapse(&cat, &group); v.toggle_group_collapse(&cat, &group); - prop_assert_eq!(collapsed(&v), initial); + prop_assert_eq!(v.is_group_collapsed(&cat, &group), initial); } } }