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:
@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user