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 <noreply@anthropic.com>
This commit is contained in:
Ed L
2026-03-21 23:47:52 -07:00
parent cc072e192d
commit 2b5e3b3576
3 changed files with 220 additions and 60 deletions

View File

@ -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<String> = 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<String> = 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<Vec<String>> = cross_product_items(&row_cats, model, view);
let col_items: Vec<Vec<String>> = 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<String> = 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<String> = col_items.iter().map(|ci| ci.join("/")).collect();
out.push_str(&col_labels.join(","));
out.push('\n');
// Data rows
let effective_row_items: Vec<String> = if row_items.is_empty() { vec!["".to_string()] } else { row_items };
let effective_col_items: Vec<String> = if col_items.is_empty() { vec!["".to_string()] } else { col_items };
let effective_row_items: Vec<Vec<String>> = if row_items.iter().all(|r| r.is_empty()) {
vec![vec![]]
} else {
row_items
};
let effective_col_items: Vec<Vec<String>> = 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<String> = effective_col_items.iter().map(|ci| {
let row_values: Vec<String> = 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<Vec<String>> {
if cats.is_empty() {
return vec![vec![]];
}
let mut result: Vec<Vec<String>> = vec![vec![]];
for cat_name in cats {
let items: Vec<String> = 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
}