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:
142
src/ui/app.rs
142
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 <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; }
|
||||
"" => {} // 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<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) {
|
||||
let page_cats: Vec<String> = 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<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