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 flate2::Compression;
|
||||||
|
|
||||||
use crate::model::Model;
|
use crate::model::Model;
|
||||||
|
use crate::view::View;
|
||||||
|
|
||||||
const MAGIC: &str = ".improv";
|
const MAGIC: &str = ".improv";
|
||||||
const COMPRESSED_EXT: &str = ".improv.gz";
|
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)
|
(cat_name.clone(), sel)
|
||||||
}).collect();
|
}).collect();
|
||||||
|
|
||||||
let row_items: Vec<String> = if row_cats.is_empty() {
|
// Cross-product of all row-axis categories (same logic as grid renderer)
|
||||||
vec![]
|
let row_items: Vec<Vec<String>> = cross_product_items(&row_cats, model, view);
|
||||||
} else {
|
let col_items: Vec<Vec<String>> = cross_product_items(&col_cats, model, view);
|
||||||
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()
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut out = String::new();
|
let mut out = String::new();
|
||||||
|
|
||||||
// Header row
|
// Header row: row-label columns then col-item labels
|
||||||
let row_label = if row_cats.is_empty() { String::new() } else { row_cats.join("/") };
|
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 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 {
|
let header_prefix = if page_label.is_empty() { row_header } else {
|
||||||
format!("{} ({})", row_label, page_label.join(", "))
|
format!("{} ({})", row_header, page_label.join(", "))
|
||||||
};
|
};
|
||||||
if !header_prefix.is_empty() {
|
if !header_prefix.is_empty() {
|
||||||
out.push_str(&header_prefix);
|
out.push_str(&header_prefix);
|
||||||
out.push(',');
|
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');
|
out.push('\n');
|
||||||
|
|
||||||
// Data rows
|
// Data rows
|
||||||
let effective_row_items: Vec<String> = if row_items.is_empty() { vec!["".to_string()] } else { row_items };
|
let effective_row_items: Vec<Vec<String>> = if row_items.iter().all(|r| r.is_empty()) {
|
||||||
let effective_col_items: Vec<String> = if col_items.is_empty() { vec!["".to_string()] } else { col_items };
|
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 {
|
for row_item in &effective_row_items {
|
||||||
if !ri.is_empty() {
|
let row_label = row_item.join("/");
|
||||||
out.push_str(ri);
|
if !row_label.is_empty() {
|
||||||
|
out.push_str(&row_label);
|
||||||
out.push(',');
|
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();
|
let mut coords = page_coords.clone();
|
||||||
if !row_cats.is_empty() && !ri.is_empty() {
|
for (cat, item) in row_cats.iter().zip(row_item.iter()) {
|
||||||
coords.push((row_cats[0].clone(), ri.clone()));
|
coords.push((cat.clone(), item.clone()));
|
||||||
}
|
}
|
||||||
if !col_cats.is_empty() && !ci.is_empty() {
|
for (cat, item) in col_cats.iter().zip(col_item.iter()) {
|
||||||
coords.push((col_cats[0].clone(), ci.clone()));
|
coords.push((cat.clone(), item.clone()));
|
||||||
}
|
}
|
||||||
let key = crate::model::CellKey::new(coords);
|
let key = crate::model::CellKey::new(coords);
|
||||||
model.evaluate(&key).to_string()
|
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)?;
|
std::fs::write(path, out)?;
|
||||||
Ok(())
|
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
|
||||||
|
}
|
||||||
|
|||||||
140
src/ui/app.rs
140
src/ui/app.rs
@ -257,22 +257,19 @@ impl App {
|
|||||||
self.search_query.clear();
|
self.search_query.clear();
|
||||||
}
|
}
|
||||||
(KeyCode::Char('n'), KeyModifiers::NONE) => {
|
(KeyCode::Char('n'), KeyModifiers::NONE) => {
|
||||||
// next search match — for now just a status hint
|
|
||||||
if !self.search_query.is_empty() {
|
if !self.search_query.is_empty() {
|
||||||
self.status_msg = format!("Searching: {}", self.search_query);
|
self.search_navigate(true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
(KeyCode::Char('N'), _) => {
|
(KeyCode::Char('N'), _) => {
|
||||||
if !self.search_query.is_empty() {
|
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
|
||||||
|
|
||||||
// N = quick-add a new category (opens Category panel in add mode)
|
|
||||||
(KeyCode::Char('N'), _) => {
|
|
||||||
self.category_panel_open = true;
|
self.category_panel_open = true;
|
||||||
self.mode = AppMode::CategoryAdd { buffer: String::new() };
|
self.mode = AppMode::CategoryAdd { buffer: String::new() };
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Tile movement ──────────────────────────────────────────────
|
// ── Tile movement ──────────────────────────────────────────────
|
||||||
// T = enter tile select mode (single key, no Ctrl needed)
|
// T = enter tile select mode (single key, no Ctrl needed)
|
||||||
@ -508,6 +505,19 @@ impl App {
|
|||||||
let _ = command::dispatch(&mut self.model, &Command::SwitchView { name });
|
let _ = command::dispatch(&mut self.model, &Command::SwitchView { name });
|
||||||
self.dirty = true;
|
self.dirty = true;
|
||||||
}
|
}
|
||||||
|
"set-format" | "fmt" => {
|
||||||
|
// :set-format <format> e.g. ",.2" ",.0" ".2"
|
||||||
|
// "," = comma separators; ".N" = N decimal places
|
||||||
|
if rest.is_empty() {
|
||||||
|
self.status_msg = "Usage: :set-format <fmt> 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; }
|
"help" | "h" => { self.mode = AppMode::Help; }
|
||||||
"" => {} // just pressed Enter with empty buffer
|
"" => {} // just pressed Enter with empty buffer
|
||||||
other => {
|
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<String> = view.categories_on(Axis::Row).into_iter().map(String::from).collect();
|
||||||
|
let col_cats: Vec<String> = view.categories_on(Axis::Column).into_iter().map(String::from).collect();
|
||||||
|
let page_cats: Vec<String> = 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<Vec<String>> = cross_product_strs(&row_cats, &self.model, view);
|
||||||
|
let col_items: Vec<Vec<String>> = cross_product_strs(&col_cats, &self.model, view);
|
||||||
|
|
||||||
|
let page_coords: Vec<(String, String)> = page_cats.iter().map(|cat| {
|
||||||
|
let items: Vec<String> = 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<usize> = (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) {
|
fn page_next(&mut self) {
|
||||||
let page_cats: Vec<String> = self.model.active_view()
|
let page_cats: Vec<String> = self.model.active_view()
|
||||||
.map(|v| v.categories_on(Axis::Page).into_iter().map(String::from).collect())
|
.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<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
|
||||||
|
}
|
||||||
|
|||||||
@ -61,6 +61,7 @@ impl<'a> GridWidget<'a> {
|
|||||||
let (sel_row, sel_col) = view.selected;
|
let (sel_row, sel_col) = view.selected;
|
||||||
let row_offset = view.row_offset;
|
let row_offset = view.row_offset;
|
||||||
let col_offset = view.col_offset;
|
let col_offset = view.col_offset;
|
||||||
|
let (fmt_comma, fmt_decimals) = parse_number_format(&view.number_format);
|
||||||
|
|
||||||
// Available cols
|
// Available cols
|
||||||
let available_cols = ((area.width.saturating_sub(ROW_HEADER_WIDTH)) / COL_WIDTH) as usize;
|
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 key = CellKey::new(coords);
|
||||||
let value = self.model.evaluate(&key);
|
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_selected = abs_ri == sel_row && abs_ci == sel_col;
|
||||||
let is_search_match = !self.search_query.is_empty()
|
let is_search_match = !self.search_query.is_empty()
|
||||||
&& cell_str.to_lowercase().contains(&self.search_query.to_lowercase());
|
&& 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)
|
self.model.evaluate(&key).as_f64().unwrap_or(0.0)
|
||||||
}).sum();
|
}).sum();
|
||||||
|
|
||||||
let total_str = format_f64(total);
|
let total_str = format_f64(total, fmt_comma, fmt_decimals);
|
||||||
buf.set_string(x, y,
|
buf.set_string(x, y,
|
||||||
format!("{:>width$}", truncate(&total_str, COL_WIDTH as usize), width = COL_WIDTH as usize),
|
format!("{:>width$}", truncate(&total_str, COL_WIDTH as usize), width = COL_WIDTH as usize),
|
||||||
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD));
|
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 {
|
match v {
|
||||||
CellValue::Number(n) => format_f64(*n),
|
CellValue::Number(n) => format_f64(*n, comma, decimals),
|
||||||
CellValue::Text(s) => s.clone(),
|
CellValue::Text(s) => s.clone(),
|
||||||
CellValue::Empty => String::new(),
|
CellValue::Empty => String::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn format_f64(n: f64) -> String {
|
pub fn parse_number_format(fmt: &str) -> (bool, u8) {
|
||||||
if n == 0.0 {
|
let comma = fmt.contains(',');
|
||||||
return "0".to_string();
|
let decimals = fmt.rfind('.')
|
||||||
|
.and_then(|i| fmt[i + 1..].parse::<u8>().ok())
|
||||||
|
.unwrap_or(0);
|
||||||
|
(comma, decimals)
|
||||||
}
|
}
|
||||||
if n.fract() == 0.0 && n.abs() < 1e12 {
|
|
||||||
// Integer with comma formatting
|
pub fn format_f64(n: f64, comma: bool, decimals: u8) -> String {
|
||||||
let i = n as i64;
|
let formatted = format!("{:.prec$}", n, prec = decimals as usize);
|
||||||
let s = i.to_string();
|
if !comma {
|
||||||
let is_neg = s.starts_with('-');
|
return formatted;
|
||||||
let digits = if is_neg { &s[1..] } else { &s[..] };
|
}
|
||||||
|
// Split integer and decimal parts
|
||||||
|
let (int_part, dec_part) = if let Some(dot) = formatted.find('.') {
|
||||||
|
(&formatted[..dot], Some(&formatted[dot..]))
|
||||||
|
} else {
|
||||||
|
(&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();
|
let mut result = String::new();
|
||||||
for (idx, c) in digits.chars().rev().enumerate() {
|
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);
|
result.push(c);
|
||||||
}
|
}
|
||||||
if is_neg { result.push('-'); }
|
if is_neg { result.push('-'); }
|
||||||
result.chars().rev().collect()
|
let mut out: String = result.chars().rev().collect();
|
||||||
} else {
|
if let Some(dec) = dec_part {
|
||||||
format!("{n:.2}")
|
out.push_str(dec);
|
||||||
}
|
}
|
||||||
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
fn truncate(s: &str, max_width: usize) -> String {
|
fn truncate(s: &str, max_width: usize) -> String {
|
||||||
|
|||||||
Reference in New Issue
Block a user