From 2b5e3b3576673da131a677b99b3eb0b162e8935f Mon Sep 17 00:00:00 2001 From: Ed L Date: Sat, 21 Mar 2026 23:47:52 -0700 Subject: [PATCH] feat: number formatting, real search navigation, CSV cross-product fix - grid.rs: honour view.number_format (",.0" default, ",.2", ".4", etc.) via parse_number_format/format_f64(n,comma,decimals); format_f64 now pub so callers can reuse the same formatting logic. - app.rs: n/N actually navigate to next/prev search match (cross-product aware); fix dead unreachable N arm; add :set-format / :fmt command to change the active view's number_format at runtime. - persistence/mod.rs: CSV export now uses full cross-product of all row/col-axis categories, matching grid rendering behaviour. Co-Authored-By: Claude Sonnet 4.6 --- src/persistence/mod.rs | 83 +++++++++++++++--------- src/ui/app.rs | 142 ++++++++++++++++++++++++++++++++++++++--- src/ui/grid.rs | 55 ++++++++++------ 3 files changed, 220 insertions(+), 60 deletions(-) diff --git a/src/persistence/mod.rs b/src/persistence/mod.rs index c0a5a89..472d9d5 100644 --- a/src/persistence/mod.rs +++ b/src/persistence/mod.rs @@ -6,6 +6,7 @@ use flate2::write::GzEncoder; use flate2::Compression; use crate::model::Model; +use crate::view::View; const MAGIC: &str = ".improv"; const COMPRESSED_EXT: &str = ".improv.gz"; @@ -77,53 +78,51 @@ pub fn export_csv(model: &Model, view_name: &str, path: &Path) -> Result<()> { (cat_name.clone(), sel) }).collect(); - let row_items: Vec = if row_cats.is_empty() { - vec![] - } else { - model.category(&row_cats[0]) - .map(|c| c.ordered_item_names().into_iter().map(String::from).collect()) - .unwrap_or_default() - }; - - let col_items: Vec = if col_cats.is_empty() { - vec![] - } else { - model.category(&col_cats[0]) - .map(|c| c.ordered_item_names().into_iter().map(String::from).collect()) - .unwrap_or_default() - }; + // Cross-product of all row-axis categories (same logic as grid renderer) + let row_items: Vec> = cross_product_items(&row_cats, model, view); + let col_items: Vec> = cross_product_items(&col_cats, model, view); let mut out = String::new(); - // Header row - let row_label = if row_cats.is_empty() { String::new() } else { row_cats.join("/") }; + // Header row: row-label columns then col-item labels + let row_header = if row_cats.is_empty() { String::new() } else { row_cats.join("/") }; let page_label: Vec = page_coords.iter().map(|(c, v)| format!("{c}={v}")).collect(); - let header_prefix = if page_label.is_empty() { row_label } else { - format!("{} ({})", row_label, page_label.join(", ")) + let header_prefix = if page_label.is_empty() { row_header } else { + format!("{} ({})", row_header, page_label.join(", ")) }; if !header_prefix.is_empty() { out.push_str(&header_prefix); out.push(','); } - out.push_str(&col_items.join(",")); + let col_labels: Vec = col_items.iter().map(|ci| ci.join("/")).collect(); + out.push_str(&col_labels.join(",")); out.push('\n'); // Data rows - let effective_row_items: Vec = if row_items.is_empty() { vec!["".to_string()] } else { row_items }; - let effective_col_items: Vec = if col_items.is_empty() { vec!["".to_string()] } else { col_items }; + let effective_row_items: Vec> = if row_items.iter().all(|r| r.is_empty()) { + vec![vec![]] + } else { + row_items + }; + let effective_col_items: Vec> = if col_items.iter().all(|c| c.is_empty()) { + vec![vec![]] + } else { + col_items + }; - for ri in &effective_row_items { - if !ri.is_empty() { - out.push_str(ri); + for row_item in &effective_row_items { + let row_label = row_item.join("/"); + if !row_label.is_empty() { + out.push_str(&row_label); out.push(','); } - let row_values: Vec = effective_col_items.iter().map(|ci| { + let row_values: Vec = effective_col_items.iter().map(|col_item| { let mut coords = page_coords.clone(); - if !row_cats.is_empty() && !ri.is_empty() { - coords.push((row_cats[0].clone(), ri.clone())); + for (cat, item) in row_cats.iter().zip(row_item.iter()) { + coords.push((cat.clone(), item.clone())); } - if !col_cats.is_empty() && !ci.is_empty() { - coords.push((col_cats[0].clone(), ci.clone())); + for (cat, item) in col_cats.iter().zip(col_item.iter()) { + coords.push((cat.clone(), item.clone())); } let key = crate::model::CellKey::new(coords); model.evaluate(&key).to_string() @@ -135,3 +134,27 @@ pub fn export_csv(model: &Model, view_name: &str, path: &Path) -> Result<()> { std::fs::write(path, out)?; Ok(()) } + +/// Compute the Cartesian product of items from `cats` filtered by hidden state in the view. +/// Returns `vec![vec![]]` when `cats` is empty. +fn cross_product_items(cats: &[String], model: &Model, view: &View) -> Vec> { + if cats.is_empty() { + return vec![vec![]]; + } + let mut result: Vec> = vec![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 + }) + }).collect(); + } + result +} diff --git a/src/ui/app.rs b/src/ui/app.rs index 9e29610..94186a2 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -257,23 +257,20 @@ impl App { self.search_query.clear(); } (KeyCode::Char('n'), KeyModifiers::NONE) => { - // next search match — for now just a status hint if !self.search_query.is_empty() { - self.status_msg = format!("Searching: {}", self.search_query); + self.search_navigate(true); } } (KeyCode::Char('N'), _) => { if !self.search_query.is_empty() { - self.status_msg = format!("Search prev: {}", self.search_query); + self.search_navigate(false); + } else { + // N with no active search = quick-add a new category + self.category_panel_open = true; + self.mode = AppMode::CategoryAdd { buffer: String::new() }; } } - // N = quick-add a new category (opens Category panel in add mode) - (KeyCode::Char('N'), _) => { - self.category_panel_open = true; - self.mode = AppMode::CategoryAdd { buffer: String::new() }; - } - // ── Tile movement ────────────────────────────────────────────── // T = enter tile select mode (single key, no Ctrl needed) (KeyCode::Char('T'), _) => { @@ -508,6 +505,19 @@ impl App { let _ = command::dispatch(&mut self.model, &Command::SwitchView { name }); self.dirty = true; } + "set-format" | "fmt" => { + // :set-format e.g. ",.2" ",.0" ".2" + // "," = comma separators; ".N" = N decimal places + if rest.is_empty() { + self.status_msg = "Usage: :set-format e.g. ,.0 ,.2 .4".to_string(); + } else { + if let Some(view) = self.model.active_view_mut() { + view.number_format = rest.to_string(); + } + self.status_msg = format!("Number format set to '{rest}'"); + self.dirty = true; + } + } "help" | "h" => { self.mode = AppMode::Help; } "" => {} // just pressed Enter with empty buffer other => { @@ -964,6 +974,96 @@ impl App { } } + /// 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; } + + let view = match self.model.active_view() { + Some(v) => v, + None => return, + }; + 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 (cur_row, cur_col) = view.selected; + + // Build cross-product for rows and cols (inline, avoids circular dep with grid.rs) + let row_items: Vec> = cross_product_strs(&row_cats, &self.model, view); + let col_items: Vec> = cross_product_strs(&col_cats, &self.model, view); + + let page_coords: Vec<(String, String)> = page_cats.iter().map(|cat| { + let items: Vec = self.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(); + + // Enumerate all (row_idx, col_idx) grid positions + let total_rows = row_items.len().max(1); + let total_cols = col_items.len().max(1); + 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 row_item = row_items.get(ri).cloned().unwrap_or_default(); + let col_item = col_items.get(ci).cloned().unwrap_or_default(); + let mut coords = page_coords.clone(); + for (cat, item) in row_cats.iter().zip(row_item.iter()) { + coords.push((cat.clone(), item.clone())); + } + for (cat, item) in col_cats.iter().zip(col_item.iter()) { + coords.push((cat.clone(), item.clone())); + } + let key = CellKey::new(coords); + let val = self.model.evaluate(&key); + let s = match &val { + CellValue::Number(n) => format!("{n}"), + CellValue::Text(t) => t.clone(), + CellValue::Empty => String::new(), + }; + s.to_lowercase().contains(&query) + }).collect(); + + if matches.is_empty() { + self.status_msg = format!("No matches for '{}'", self.search_query); + return; + } + + // Find next/prev match relative to current position + let target_flat = if forward { + matches.iter().find(|&&f| f > cur_flat) + .or_else(|| matches.first()) + .copied() + } else { + matches.iter().rev().find(|&&f| f < cur_flat) + .or_else(|| matches.last()) + .copied() + }; + + if let Some(flat) = target_flat { + let ri = flat / total_cols; + let ci = flat % total_cols; + if let Some(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; } + } + self.status_msg = format!("Match {}/{} for '{}'", + matches.iter().position(|&f| f == flat).unwrap_or(0) + 1, + matches.len(), + self.search_query); + } + } + fn page_next(&mut self) { let page_cats: Vec = self.model.active_view() .map(|v| v.categories_on(Axis::Page).into_iter().map(String::from).collect()) @@ -1095,3 +1195,27 @@ impl App { } } } + +/// Compute the Cartesian product of items from `cats` in the active view, +/// filtering hidden items. Returns `vec![vec![]]` when `cats` is empty. +fn cross_product_strs(cats: &[String], model: &crate::model::Model, view: &crate::view::View) -> Vec> { + if cats.is_empty() { + return vec![vec![]]; + } + let mut result: Vec> = vec![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 + }) + }).collect(); + } + result +} diff --git a/src/ui/grid.rs b/src/ui/grid.rs index 107b8ba..50d6dba 100644 --- a/src/ui/grid.rs +++ b/src/ui/grid.rs @@ -61,6 +61,7 @@ impl<'a> GridWidget<'a> { let (sel_row, sel_col) = view.selected; let row_offset = view.row_offset; let col_offset = view.col_offset; + let (fmt_comma, fmt_decimals) = parse_number_format(&view.number_format); // Available cols let available_cols = ((area.width.saturating_sub(ROW_HEADER_WIDTH)) / COL_WIDTH) as usize; @@ -133,7 +134,7 @@ impl<'a> GridWidget<'a> { let key = CellKey::new(coords); let value = self.model.evaluate(&key); - let cell_str = format_value(&value); + let cell_str = format_value(&value, fmt_comma, fmt_decimals); let is_selected = abs_ri == sel_row && abs_ci == sel_col; let is_search_match = !self.search_query.is_empty() && cell_str.to_lowercase().contains(&self.search_query.to_lowercase()); @@ -195,7 +196,7 @@ impl<'a> GridWidget<'a> { self.model.evaluate(&key).as_f64().unwrap_or(0.0) }).sum(); - let total_str = format_f64(total); + 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)); @@ -281,34 +282,46 @@ impl<'a> Widget for GridWidget<'a> { } } -fn format_value(v: &CellValue) -> String { +fn format_value(v: &CellValue, comma: bool, decimals: u8) -> String { match v { - CellValue::Number(n) => format_f64(*n), + CellValue::Number(n) => format_f64(*n, comma, decimals), CellValue::Text(s) => s.clone(), CellValue::Empty => String::new(), } } -fn format_f64(n: f64) -> String { - if n == 0.0 { - return "0".to_string(); +pub fn parse_number_format(fmt: &str) -> (bool, u8) { + let comma = fmt.contains(','); + let decimals = fmt.rfind('.') + .and_then(|i| fmt[i + 1..].parse::().ok()) + .unwrap_or(0); + (comma, decimals) +} + +pub fn format_f64(n: f64, comma: bool, decimals: u8) -> String { + let formatted = format!("{:.prec$}", n, prec = decimals as usize); + if !comma { + return formatted; } - if n.fract() == 0.0 && n.abs() < 1e12 { - // Integer with comma formatting - let i = n as i64; - let s = i.to_string(); - let is_neg = s.starts_with('-'); - let digits = if is_neg { &s[1..] } else { &s[..] }; - let mut result = String::new(); - for (idx, c) in digits.chars().rev().enumerate() { - if idx > 0 && idx % 3 == 0 { result.push(','); } - result.push(c); - } - if is_neg { result.push('-'); } - result.chars().rev().collect() + // Split integer and decimal parts + let (int_part, dec_part) = if let Some(dot) = formatted.find('.') { + (&formatted[..dot], Some(&formatted[dot..])) } else { - format!("{n:.2}") + (&formatted[..], None) + }; + let is_neg = int_part.starts_with('-'); + 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(','); } + result.push(c); } + if is_neg { result.push('-'); } + let mut out: String = result.chars().rev().collect(); + if let Some(dec) = dec_part { + out.push_str(dec); + } + out } fn truncate(s: &str, max_width: usize) -> String {