refactor: extract GridLayout as single source for view→grid mapping
Three copies of cross_product existed (grid.rs, app.rs, persistence/mod.rs) with slightly different signatures. Extracted into GridLayout in src/view/layout.rs, which is now the single canonical mapping from a View to a 2-D grid: row/col counts, labels, and cell_key(row, col) → CellKey. All consumers updated to use GridLayout::new(model, view): - grid.rs: render_grid, total-row computation, page bar - persistence/mod.rs: export_csv - app.rs: move_selection, jump_to_last_row/col, scroll_rows, search_navigate, selected_cell_key Also includes two app.rs UI bug fixes that were discovered while refactoring: - Ctrl+Arrow tile movement was unreachable (shadowed by plain arrow arms); moved before plain arrow handlers - RemoveFormula dispatch now passes target_category (required by the formula management fix in the previous commit) GridLayout has 6 unit tests covering counts, label formatting, cell_key correctness, out-of-bounds, page coord inclusion, and evaluate round-trip. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@ -6,7 +6,7 @@ use flate2::write::GzEncoder;
|
|||||||
use flate2::Compression;
|
use flate2::Compression;
|
||||||
|
|
||||||
use crate::model::Model;
|
use crate::model::Model;
|
||||||
use crate::view::View;
|
use crate::view::GridLayout;
|
||||||
|
|
||||||
const MAGIC: &str = ".improv";
|
const MAGIC: &str = ".improv";
|
||||||
const COMPRESSED_EXT: &str = ".improv.gz";
|
const COMPRESSED_EXT: &str = ".improv.gz";
|
||||||
@ -57,36 +57,17 @@ pub fn autosave_path(path: &Path) -> std::path::PathBuf {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn export_csv(model: &Model, view_name: &str, path: &Path) -> Result<()> {
|
pub fn export_csv(model: &Model, view_name: &str, path: &Path) -> Result<()> {
|
||||||
use crate::view::Axis;
|
|
||||||
|
|
||||||
let view = model.views.get(view_name)
|
let view = model.views.get(view_name)
|
||||||
.ok_or_else(|| anyhow::anyhow!("View '{view_name}' not found"))?;
|
.ok_or_else(|| anyhow::anyhow!("View '{view_name}' not found"))?;
|
||||||
|
|
||||||
let row_cats: Vec<String> = view.categories_on(Axis::Row).into_iter().map(String::from).collect();
|
let layout = GridLayout::new(model, view);
|
||||||
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();
|
|
||||||
|
|
||||||
// Build page-axis coords from current selections (or first item)
|
|
||||||
let page_coords: Vec<(String, String)> = page_cats.iter().map(|cat_name| {
|
|
||||||
let items: Vec<String> = model.category(cat_name)
|
|
||||||
.map(|c| c.ordered_item_names().into_iter().map(String::from).collect())
|
|
||||||
.unwrap_or_default();
|
|
||||||
let sel = view.page_selection(cat_name)
|
|
||||||
.map(String::from)
|
|
||||||
.or_else(|| items.first().cloned())
|
|
||||||
.unwrap_or_default();
|
|
||||||
(cat_name.clone(), sel)
|
|
||||||
}).collect();
|
|
||||||
|
|
||||||
// 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();
|
let mut out = String::new();
|
||||||
|
|
||||||
// Header row: row-label columns then col-item labels
|
// Header row
|
||||||
let row_header = if row_cats.is_empty() { String::new() } else { row_cats.join("/") };
|
let row_header = layout.row_cats.join("/");
|
||||||
let page_label: Vec<String> = page_coords.iter().map(|(c, v)| format!("{c}={v}")).collect();
|
let page_label: Vec<String> = layout.page_coords.iter()
|
||||||
|
.map(|(c, v)| format!("{c}={v}")).collect();
|
||||||
let header_prefix = if page_label.is_empty() { row_header } else {
|
let header_prefix = if page_label.is_empty() { row_header } else {
|
||||||
format!("{} ({})", row_header, page_label.join(", "))
|
format!("{} ({})", row_header, page_label.join(", "))
|
||||||
};
|
};
|
||||||
@ -94,38 +75,35 @@ pub fn export_csv(model: &Model, view_name: &str, path: &Path) -> Result<()> {
|
|||||||
out.push_str(&header_prefix);
|
out.push_str(&header_prefix);
|
||||||
out.push(',');
|
out.push(',');
|
||||||
}
|
}
|
||||||
let col_labels: Vec<String> = col_items.iter().map(|ci| ci.join("/")).collect();
|
let col_labels: Vec<String> = (0..layout.col_count()).map(|ci| layout.col_label(ci)).collect();
|
||||||
out.push_str(&col_labels.join(","));
|
out.push_str(&col_labels.join(","));
|
||||||
out.push('\n');
|
out.push('\n');
|
||||||
|
|
||||||
// Data rows
|
// Data rows — treat zero-item axes as a single empty placeholder row/col
|
||||||
let effective_row_items: Vec<Vec<String>> = if row_items.iter().all(|r| r.is_empty()) {
|
let row_range: Box<dyn Iterator<Item = Option<usize>>> = if layout.row_count() == 0 {
|
||||||
vec![vec![]]
|
Box::new(std::iter::once(None))
|
||||||
} else {
|
} else {
|
||||||
row_items
|
Box::new((0..layout.row_count()).map(Some))
|
||||||
};
|
};
|
||||||
let effective_col_items: Vec<Vec<String>> = if col_items.iter().all(|c| c.is_empty()) {
|
let col_indices: Vec<Option<usize>> = if layout.col_count() == 0 {
|
||||||
vec![vec![]]
|
vec![None]
|
||||||
} else {
|
} else {
|
||||||
col_items
|
(0..layout.col_count()).map(Some).collect()
|
||||||
};
|
};
|
||||||
|
|
||||||
for row_item in &effective_row_items {
|
for row_opt in row_range {
|
||||||
let row_label = row_item.join("/");
|
let row_label = row_opt.map(|ri| layout.row_label(ri)).unwrap_or_default();
|
||||||
if !row_label.is_empty() {
|
if !row_label.is_empty() {
|
||||||
out.push_str(&row_label);
|
out.push_str(&row_label);
|
||||||
out.push(',');
|
out.push(',');
|
||||||
}
|
}
|
||||||
let row_values: Vec<String> = effective_col_items.iter().map(|col_item| {
|
let row_values: Vec<String> = col_indices.iter().map(|&col_opt| {
|
||||||
let mut coords = page_coords.clone();
|
match (row_opt, col_opt) {
|
||||||
for (cat, item) in row_cats.iter().zip(row_item.iter()) {
|
(Some(ri), Some(ci)) => layout.cell_key(ri, ci)
|
||||||
coords.push((cat.clone(), item.clone()));
|
.map(|key| model.evaluate(&key).to_string())
|
||||||
|
.unwrap_or_default(),
|
||||||
|
_ => String::new(),
|
||||||
}
|
}
|
||||||
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()
|
|
||||||
}).collect();
|
}).collect();
|
||||||
out.push_str(&row_values.join(","));
|
out.push_str(&row_values.join(","));
|
||||||
out.push('\n');
|
out.push('\n');
|
||||||
@ -134,27 +112,3 @@ 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
|
|
||||||
}
|
|
||||||
|
|||||||
138
src/ui/app.rs
138
src/ui/app.rs
@ -7,7 +7,7 @@ use crate::model::Model;
|
|||||||
use crate::model::cell::{CellKey, CellValue};
|
use crate::model::cell::{CellKey, CellValue};
|
||||||
use crate::import::wizard::{ImportWizard, WizardStep};
|
use crate::import::wizard::{ImportWizard, WizardStep};
|
||||||
use crate::persistence;
|
use crate::persistence;
|
||||||
use crate::view::Axis;
|
use crate::view::{Axis, GridLayout};
|
||||||
use crate::command::{self, Command};
|
use crate::command::{self, Command};
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
@ -176,6 +176,17 @@ impl App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Tile movement (Ctrl+Arrow) — must come before plain arrows ──
|
||||||
|
(KeyCode::Left, KeyModifiers::CONTROL)
|
||||||
|
| (KeyCode::Right, KeyModifiers::CONTROL)
|
||||||
|
| (KeyCode::Up, KeyModifiers::CONTROL)
|
||||||
|
| (KeyCode::Down, KeyModifiers::CONTROL) => {
|
||||||
|
let count = self.model.category_names().len();
|
||||||
|
if count > 0 {
|
||||||
|
self.mode = AppMode::TileSelect { cat_idx: 0 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Navigation ─────────────────────────────────────────────────
|
// ── Navigation ─────────────────────────────────────────────────
|
||||||
(KeyCode::Up, _) | (KeyCode::Char('k'), KeyModifiers::NONE) => {
|
(KeyCode::Up, _) | (KeyCode::Char('k'), KeyModifiers::NONE) => {
|
||||||
self.move_selection(-1, 0);
|
self.move_selection(-1, 0);
|
||||||
@ -279,17 +290,6 @@ impl App {
|
|||||||
self.mode = AppMode::TileSelect { cat_idx: 0 };
|
self.mode = AppMode::TileSelect { cat_idx: 0 };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Legacy Ctrl+Arrow still works
|
|
||||||
(KeyCode::Left, KeyModifiers::CONTROL)
|
|
||||||
| (KeyCode::Right, KeyModifiers::CONTROL)
|
|
||||||
| (KeyCode::Up, KeyModifiers::CONTROL)
|
|
||||||
| (KeyCode::Down, KeyModifiers::CONTROL) => {
|
|
||||||
let count = self.model.category_names().len();
|
|
||||||
if count > 0 {
|
|
||||||
self.mode = AppMode::TileSelect { cat_idx: 0 };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Page axis ──────────────────────────────────────────────────
|
// ── Page axis ──────────────────────────────────────────────────
|
||||||
(KeyCode::Char('['), _) => { self.page_prev(); }
|
(KeyCode::Char('['), _) => { self.page_prev(); }
|
||||||
(KeyCode::Char(']'), _) => { self.page_next(); }
|
(KeyCode::Char(']'), _) => { self.page_next(); }
|
||||||
@ -606,8 +606,10 @@ impl App {
|
|||||||
}
|
}
|
||||||
KeyCode::Char('d') | KeyCode::Delete => {
|
KeyCode::Char('d') | KeyCode::Delete => {
|
||||||
if self.formula_cursor < self.model.formulas.len() {
|
if self.formula_cursor < self.model.formulas.len() {
|
||||||
let target = self.model.formulas[self.formula_cursor].target.clone();
|
let f = &self.model.formulas[self.formula_cursor];
|
||||||
command::dispatch(&mut self.model, &Command::RemoveFormula { target });
|
let target = f.target.clone();
|
||||||
|
let target_category = f.target_category.clone();
|
||||||
|
command::dispatch(&mut self.model, &Command::RemoveFormula { target, target_category });
|
||||||
if self.formula_cursor > 0 { self.formula_cursor -= 1; }
|
if self.formula_cursor > 0 { self.formula_cursor -= 1; }
|
||||||
self.dirty = true;
|
self.dirty = true;
|
||||||
}
|
}
|
||||||
@ -915,14 +917,10 @@ impl App {
|
|||||||
// ── Motion helpers ───────────────────────────────────────────────────────
|
// ── Motion helpers ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
fn move_selection(&mut self, dr: i32, dc: i32) {
|
fn move_selection(&mut self, dr: i32, dc: i32) {
|
||||||
// Use cross-product counts so multi-category axes navigate correctly.
|
|
||||||
let (row_max, col_max) = {
|
let (row_max, col_max) = {
|
||||||
let view = match self.model.active_view() { Some(v) => v, None => 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 layout = GridLayout::new(&self.model, view);
|
||||||
let col_cats: Vec<String> = view.categories_on(Axis::Column).into_iter().map(String::from).collect();
|
(layout.row_count().saturating_sub(1), layout.col_count().saturating_sub(1))
|
||||||
let rm = cross_product_strs(&row_cats, &self.model, view).len().saturating_sub(1);
|
|
||||||
let cm = cross_product_strs(&col_cats, &self.model, view).len().saturating_sub(1);
|
|
||||||
(rm, cm)
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Some(view) = self.model.active_view_mut() {
|
if let Some(view) = self.model.active_view_mut() {
|
||||||
@ -941,8 +939,7 @@ impl App {
|
|||||||
fn jump_to_last_row(&mut self) {
|
fn jump_to_last_row(&mut self) {
|
||||||
let count = {
|
let count = {
|
||||||
let view = match self.model.active_view() { Some(v) => v, None => 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();
|
GridLayout::new(&self.model, view).row_count().saturating_sub(1)
|
||||||
cross_product_strs(&row_cats, &self.model, view).len().saturating_sub(1)
|
|
||||||
};
|
};
|
||||||
if let Some(view) = self.model.active_view_mut() {
|
if let Some(view) = self.model.active_view_mut() {
|
||||||
view.selected.0 = count;
|
view.selected.0 = count;
|
||||||
@ -953,8 +950,7 @@ impl App {
|
|||||||
fn jump_to_last_col(&mut self) {
|
fn jump_to_last_col(&mut self) {
|
||||||
let count = {
|
let count = {
|
||||||
let view = match self.model.active_view() { Some(v) => v, None => return };
|
let view = match self.model.active_view() { Some(v) => v, None => return };
|
||||||
let col_cats: Vec<String> = view.categories_on(Axis::Column).into_iter().map(String::from).collect();
|
GridLayout::new(&self.model, view).col_count().saturating_sub(1)
|
||||||
cross_product_strs(&col_cats, &self.model, view).len().saturating_sub(1)
|
|
||||||
};
|
};
|
||||||
if let Some(view) = self.model.active_view_mut() {
|
if let Some(view) = self.model.active_view_mut() {
|
||||||
view.selected.1 = count;
|
view.selected.1 = count;
|
||||||
@ -965,8 +961,7 @@ impl App {
|
|||||||
fn scroll_rows(&mut self, delta: i32) {
|
fn scroll_rows(&mut self, delta: i32) {
|
||||||
let row_max = {
|
let row_max = {
|
||||||
let view = match self.model.active_view() { Some(v) => v, None => 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();
|
GridLayout::new(&self.model, view).row_count().saturating_sub(1)
|
||||||
cross_product_strs(&row_cats, &self.model, view).len().saturating_sub(1)
|
|
||||||
};
|
};
|
||||||
if let Some(view) = self.model.active_view_mut() {
|
if let Some(view) = self.model.active_view_mut() {
|
||||||
let nr = (view.selected.0 as i32 + delta).clamp(0, row_max as i32) as usize;
|
let nr = (view.selected.0 as i32 + delta).clamp(0, row_max as i32) as usize;
|
||||||
@ -986,45 +981,21 @@ impl App {
|
|||||||
Some(v) => v,
|
Some(v) => v,
|
||||||
None => return,
|
None => return,
|
||||||
};
|
};
|
||||||
let row_cats: Vec<String> = view.categories_on(Axis::Row).into_iter().map(String::from).collect();
|
let layout = GridLayout::new(&self.model, view);
|
||||||
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;
|
let (cur_row, cur_col) = view.selected;
|
||||||
|
|
||||||
// Build cross-product for rows and cols (inline, avoids circular dep with grid.rs)
|
let total_rows = layout.row_count().max(1);
|
||||||
let row_items: Vec<Vec<String>> = cross_product_strs(&row_cats, &self.model, view);
|
let total_cols = layout.col_count().max(1);
|
||||||
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 total = total_rows * total_cols;
|
||||||
let cur_flat = cur_row * total_cols + cur_col;
|
let cur_flat = cur_row * total_cols + cur_col;
|
||||||
|
|
||||||
let matches: Vec<usize> = (0..total).filter(|&flat| {
|
let matches: Vec<usize> = (0..total).filter(|&flat| {
|
||||||
let ri = flat / total_cols;
|
let ri = flat / total_cols;
|
||||||
let ci = flat % total_cols;
|
let ci = flat % total_cols;
|
||||||
let row_item = row_items.get(ri).cloned().unwrap_or_default();
|
let key = match layout.cell_key(ri, ci) {
|
||||||
let col_item = col_items.get(ci).cloned().unwrap_or_default();
|
Some(k) => k,
|
||||||
let mut coords = page_coords.clone();
|
None => return false,
|
||||||
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 val = self.model.evaluate(&key);
|
||||||
let s = match &val {
|
let s = match &val {
|
||||||
CellValue::Number(n) => format!("{n}"),
|
CellValue::Number(n) => format!("{n}"),
|
||||||
@ -1130,36 +1101,8 @@ impl App {
|
|||||||
|
|
||||||
pub fn selected_cell_key(&self) -> Option<CellKey> {
|
pub fn selected_cell_key(&self) -> Option<CellKey> {
|
||||||
let view = self.model.active_view()?;
|
let view = self.model.active_view()?;
|
||||||
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 (sel_row, sel_col) = view.selected;
|
let (sel_row, sel_col) = view.selected;
|
||||||
let mut coords = vec![];
|
GridLayout::new(&self.model, view).cell_key(sel_row, sel_col)
|
||||||
|
|
||||||
for cat_name in &page_cats {
|
|
||||||
let items = self.model.category(cat_name)
|
|
||||||
.map(|c| c.ordered_item_names().into_iter().map(String::from).collect::<Vec<_>>())
|
|
||||||
.unwrap_or_default();
|
|
||||||
let sel = view.page_selection(cat_name)
|
|
||||||
.map(String::from)
|
|
||||||
.or_else(|| items.first().cloned())?;
|
|
||||||
coords.push((cat_name.clone(), sel));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use cross-product indexing so multi-category axes resolve correctly.
|
|
||||||
let row_items = cross_product_strs(&row_cats, &self.model, view);
|
|
||||||
let row_item = row_items.get(sel_row)?;
|
|
||||||
for (cat, item) in row_cats.iter().zip(row_item.iter()) {
|
|
||||||
coords.push((cat.clone(), item.clone()));
|
|
||||||
}
|
|
||||||
|
|
||||||
let col_items = cross_product_strs(&col_cats, &self.model, view);
|
|
||||||
let col_item = col_items.get(sel_col)?;
|
|
||||||
for (cat, item) in col_cats.iter().zip(col_item.iter()) {
|
|
||||||
coords.push((cat.clone(), item.clone()));
|
|
||||||
}
|
|
||||||
|
|
||||||
Some(CellKey::new(coords))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Persistence ──────────────────────────────────────────────────────────
|
// ── Persistence ──────────────────────────────────────────────────────────
|
||||||
@ -1209,26 +1152,3 @@ 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
|
|
||||||
}
|
|
||||||
|
|||||||
180
src/ui/grid.rs
180
src/ui/grid.rs
@ -7,8 +7,8 @@ use ratatui::{
|
|||||||
use unicode_width::UnicodeWidthStr;
|
use unicode_width::UnicodeWidthStr;
|
||||||
|
|
||||||
use crate::model::Model;
|
use crate::model::Model;
|
||||||
use crate::model::cell::{CellKey, CellValue};
|
use crate::model::cell::CellValue;
|
||||||
use crate::view::Axis;
|
use crate::view::GridLayout;
|
||||||
use crate::ui::app::AppMode;
|
use crate::ui::app::AppMode;
|
||||||
|
|
||||||
const ROW_HEADER_WIDTH: u16 = 16;
|
const ROW_HEADER_WIDTH: u16 = 16;
|
||||||
@ -32,110 +32,73 @@ impl<'a> GridWidget<'a> {
|
|||||||
None => return,
|
None => return,
|
||||||
};
|
};
|
||||||
|
|
||||||
let row_cats: Vec<&str> = view.categories_on(Axis::Row);
|
let layout = GridLayout::new(self.model, view);
|
||||||
let col_cats: Vec<&str> = view.categories_on(Axis::Column);
|
|
||||||
let page_cats: Vec<&str> = view.categories_on(Axis::Page);
|
|
||||||
|
|
||||||
// Gather row items — cross-product of all row-axis categories
|
|
||||||
let row_items: Vec<Vec<String>> = cross_product_items(
|
|
||||||
&row_cats, self.model, view,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Gather col items — cross-product of all col-axis categories
|
|
||||||
let col_items: Vec<Vec<String>> = cross_product_items(
|
|
||||||
&col_cats, self.model, view,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Page filter coords
|
|
||||||
let page_coords: Vec<(String, String)> = page_cats.iter().map(|cat_name| {
|
|
||||||
let items: Vec<String> = self.model.category(cat_name)
|
|
||||||
.map(|c| c.ordered_item_names().into_iter().map(String::from).collect())
|
|
||||||
.unwrap_or_default();
|
|
||||||
let sel = view.page_selection(cat_name)
|
|
||||||
.map(String::from)
|
|
||||||
.or_else(|| items.first().cloned())
|
|
||||||
.unwrap_or_default();
|
|
||||||
(cat_name.to_string(), sel)
|
|
||||||
}).collect();
|
|
||||||
|
|
||||||
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);
|
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;
|
let available_cols = ((area.width.saturating_sub(ROW_HEADER_WIDTH)) / COL_WIDTH) as usize;
|
||||||
let visible_col_items: Vec<_> = col_items.iter()
|
let visible_col_range = col_offset..(col_offset + available_cols.max(1)).min(layout.col_count());
|
||||||
.skip(col_offset)
|
|
||||||
.take(available_cols.max(1))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let available_rows = area.height.saturating_sub(2) as usize; // header + border
|
let available_rows = area.height.saturating_sub(2) as usize;
|
||||||
let visible_row_items: Vec<_> = row_items.iter()
|
let visible_row_range = row_offset..(row_offset + available_rows.max(1)).min(layout.row_count());
|
||||||
.skip(row_offset)
|
|
||||||
.take(available_rows.max(1))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let mut y = area.y;
|
let mut y = area.y;
|
||||||
|
|
||||||
// Column headers
|
// Column headers
|
||||||
let header_style = Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD);
|
let header_style = Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD);
|
||||||
let row_header_col = format!("{:<width$}", "", width = ROW_HEADER_WIDTH as usize);
|
buf.set_string(area.x, y,
|
||||||
buf.set_string(area.x, y, &row_header_col, Style::default());
|
format!("{:<width$}", "", width = ROW_HEADER_WIDTH as usize),
|
||||||
|
Style::default());
|
||||||
let mut x = area.x + ROW_HEADER_WIDTH;
|
let mut x = area.x + ROW_HEADER_WIDTH;
|
||||||
for (ci, col_item) in visible_col_items.iter().enumerate() {
|
for ci in visible_col_range.clone() {
|
||||||
let abs_ci = ci + col_offset;
|
let label = layout.col_label(ci);
|
||||||
let label = col_item.join("/");
|
let styled = if ci == sel_col {
|
||||||
let styled = if abs_ci == sel_col {
|
|
||||||
header_style.add_modifier(Modifier::UNDERLINED)
|
header_style.add_modifier(Modifier::UNDERLINED)
|
||||||
} else {
|
} else {
|
||||||
header_style
|
header_style
|
||||||
};
|
};
|
||||||
let truncated = truncate(&label, COL_WIDTH as usize);
|
buf.set_string(x, y,
|
||||||
buf.set_string(x, y, format!("{:>width$}", truncated, width = COL_WIDTH as usize), styled);
|
format!("{:>width$}", truncate(&label, COL_WIDTH as usize), width = COL_WIDTH as usize),
|
||||||
|
styled);
|
||||||
x += COL_WIDTH;
|
x += COL_WIDTH;
|
||||||
if x >= area.x + area.width { break; }
|
if x >= area.x + area.width { break; }
|
||||||
}
|
}
|
||||||
y += 1;
|
y += 1;
|
||||||
|
|
||||||
// Separator
|
// Separator
|
||||||
let sep = "─".repeat(area.width as usize);
|
buf.set_string(area.x, y,
|
||||||
buf.set_string(area.x, y, &sep, Style::default().fg(Color::DarkGray));
|
"─".repeat(area.width as usize),
|
||||||
|
Style::default().fg(Color::DarkGray));
|
||||||
y += 1;
|
y += 1;
|
||||||
|
|
||||||
// Data rows
|
// Data rows
|
||||||
for (ri, row_item) in visible_row_items.iter().enumerate() {
|
for ri in visible_row_range.clone() {
|
||||||
let abs_ri = ri + row_offset;
|
|
||||||
if y >= area.y + area.height { break; }
|
if y >= area.y + area.height { break; }
|
||||||
|
|
||||||
let row_label = row_item.join("/");
|
let row_label = layout.row_label(ri);
|
||||||
let row_style = if abs_ri == sel_row {
|
let row_style = if ri == sel_row {
|
||||||
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)
|
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)
|
||||||
} else {
|
} else {
|
||||||
Style::default()
|
Style::default()
|
||||||
};
|
};
|
||||||
let row_header_str = truncate(&row_label, ROW_HEADER_WIDTH as usize - 1);
|
|
||||||
buf.set_string(area.x, y,
|
buf.set_string(area.x, y,
|
||||||
format!("{:<width$}", row_header_str, width = ROW_HEADER_WIDTH as usize),
|
format!("{:<width$}", truncate(&row_label, ROW_HEADER_WIDTH as usize - 1), width = ROW_HEADER_WIDTH as usize),
|
||||||
row_style);
|
row_style);
|
||||||
|
|
||||||
let mut x = area.x + ROW_HEADER_WIDTH;
|
let mut x = area.x + ROW_HEADER_WIDTH;
|
||||||
for (ci, col_item) in visible_col_items.iter().enumerate() {
|
for ci in visible_col_range.clone() {
|
||||||
let abs_ci = ci + col_offset;
|
|
||||||
if x >= area.x + area.width { break; }
|
if x >= area.x + area.width { break; }
|
||||||
|
|
||||||
let mut coords = page_coords.clone();
|
let key = match layout.cell_key(ri, ci) {
|
||||||
for (cat, item) in row_cats.iter().zip(row_item.iter()) {
|
Some(k) => k,
|
||||||
coords.push((cat.to_string(), item.clone()));
|
None => { x += COL_WIDTH; continue; }
|
||||||
}
|
};
|
||||||
for (cat, item) in col_cats.iter().zip(col_item.iter()) {
|
|
||||||
coords.push((cat.to_string(), item.clone()));
|
|
||||||
}
|
|
||||||
let key = CellKey::new(coords);
|
|
||||||
let value = self.model.evaluate(&key);
|
let value = self.model.evaluate(&key);
|
||||||
|
|
||||||
let cell_str = format_value(&value, fmt_comma, fmt_decimals);
|
let cell_str = format_value(&value, fmt_comma, fmt_decimals);
|
||||||
let is_selected = abs_ri == sel_row && abs_ci == sel_col;
|
let is_selected = ri == sel_row && 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());
|
||||||
|
|
||||||
@ -149,18 +112,18 @@ impl<'a> GridWidget<'a> {
|
|||||||
Style::default()
|
Style::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
let formatted = format!("{:>width$}", truncate(&cell_str, COL_WIDTH as usize), width = COL_WIDTH as usize);
|
buf.set_string(x, y,
|
||||||
buf.set_string(x, y, formatted, cell_style);
|
format!("{:>width$}", truncate(&cell_str, COL_WIDTH as usize), width = COL_WIDTH as usize),
|
||||||
|
cell_style);
|
||||||
x += COL_WIDTH;
|
x += COL_WIDTH;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Edit indicator
|
// Edit indicator
|
||||||
if matches!(self.mode, AppMode::Editing { .. }) && abs_ri == sel_row {
|
if matches!(self.mode, AppMode::Editing { .. }) && ri == sel_row {
|
||||||
if let AppMode::Editing { buffer } = self.mode {
|
if let AppMode::Editing { buffer } = self.mode {
|
||||||
let edit_x = area.x + ROW_HEADER_WIDTH + (sel_col.saturating_sub(col_offset)) as u16 * COL_WIDTH;
|
let edit_x = area.x + ROW_HEADER_WIDTH + (sel_col.saturating_sub(col_offset)) as u16 * COL_WIDTH;
|
||||||
let edit_str = format!("{:<width$}", buffer, width = COL_WIDTH as usize);
|
|
||||||
buf.set_string(edit_x, y,
|
buf.set_string(edit_x, y,
|
||||||
truncate(&edit_str, COL_WIDTH as usize),
|
truncate(&format!("{:<width$}", buffer, width = COL_WIDTH as usize), COL_WIDTH as usize),
|
||||||
Style::default().fg(Color::Green).add_modifier(Modifier::UNDERLINED));
|
Style::default().fg(Color::Green).add_modifier(Modifier::UNDERLINED));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -169,10 +132,11 @@ impl<'a> GridWidget<'a> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Total row
|
// Total row
|
||||||
if !col_items.is_empty() && !row_items.is_empty() {
|
if layout.row_count() > 0 && layout.col_count() > 0 {
|
||||||
if y < area.y + area.height {
|
if y < area.y + area.height {
|
||||||
let sep = "─".repeat(area.width as usize);
|
buf.set_string(area.x, y,
|
||||||
buf.set_string(area.x, y, &sep, Style::default().fg(Color::DarkGray));
|
"─".repeat(area.width as usize),
|
||||||
|
Style::default().fg(Color::DarkGray));
|
||||||
y += 1;
|
y += 1;
|
||||||
}
|
}
|
||||||
if y < area.y + area.height {
|
if y < area.y + area.height {
|
||||||
@ -181,21 +145,12 @@ impl<'a> GridWidget<'a> {
|
|||||||
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD));
|
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD));
|
||||||
|
|
||||||
let mut x = area.x + ROW_HEADER_WIDTH;
|
let mut x = area.x + ROW_HEADER_WIDTH;
|
||||||
for (_ci, col_item) in visible_col_items.iter().enumerate() {
|
for ci in visible_col_range {
|
||||||
if x >= area.x + area.width { break; }
|
if x >= area.x + area.width { break; }
|
||||||
let mut coords = page_coords.clone();
|
let total: f64 = (0..layout.row_count())
|
||||||
for (cat, item) in col_cats.iter().zip(col_item.iter()) {
|
.filter_map(|ri| layout.cell_key(ri, ci))
|
||||||
coords.push((cat.to_string(), item.clone()));
|
.map(|key| self.model.evaluate(&key).as_f64().unwrap_or(0.0))
|
||||||
}
|
.sum();
|
||||||
let total: f64 = row_items.iter().map(|ri| {
|
|
||||||
let mut c = coords.clone();
|
|
||||||
for (cat, item) in row_cats.iter().zip(ri.iter()) {
|
|
||||||
c.push((cat.to_string(), item.clone()));
|
|
||||||
}
|
|
||||||
let key = CellKey::new(c);
|
|
||||||
self.model.evaluate(&key).as_f64().unwrap_or(0.0)
|
|
||||||
}).sum();
|
|
||||||
|
|
||||||
let total_str = format_f64(total, fmt_comma, fmt_decimals);
|
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),
|
||||||
@ -207,37 +162,6 @@ impl<'a> GridWidget<'a> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the cross-product of items across `cats`, filtered by hidden state.
|
|
||||||
/// Each element is a Vec of item names, one per category in `cats` order.
|
|
||||||
fn cross_product_items(cats: &[&str], 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
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<'a> Widget for GridWidget<'a> {
|
impl<'a> Widget for GridWidget<'a> {
|
||||||
fn render(self, area: Rect, buf: &mut Buffer) {
|
fn render(self, area: Rect, buf: &mut Buffer) {
|
||||||
@ -250,20 +174,12 @@ impl<'a> Widget for GridWidget<'a> {
|
|||||||
block.render(area, buf);
|
block.render(area, buf);
|
||||||
|
|
||||||
// Page axis bar
|
// Page axis bar
|
||||||
let view = self.model.active_view();
|
if let Some(view) = self.model.active_view() {
|
||||||
if let Some(view) = view {
|
let layout = GridLayout::new(self.model, view);
|
||||||
let page_cats: Vec<&str> = view.categories_on(Axis::Page);
|
if !layout.page_coords.is_empty() && inner.height > 0 {
|
||||||
if !page_cats.is_empty() && inner.height > 0 {
|
let page_info: Vec<String> = layout.page_coords.iter()
|
||||||
let page_info: Vec<String> = page_cats.iter().map(|cat_name| {
|
.map(|(cat, sel)| format!("{cat} = {sel}"))
|
||||||
let items: Vec<String> = self.model.category(cat_name)
|
.collect();
|
||||||
.map(|c| c.ordered_item_names().into_iter().map(String::from).collect())
|
|
||||||
.unwrap_or_default();
|
|
||||||
let sel = view.page_selection(cat_name)
|
|
||||||
.map(String::from)
|
|
||||||
.or_else(|| items.first().cloned())
|
|
||||||
.unwrap_or_else(|| "(none)".to_string());
|
|
||||||
format!("{cat_name} = {sel}")
|
|
||||||
}).collect();
|
|
||||||
let page_str = format!(" [{}] ", page_info.join(" | "));
|
let page_str = format!(" [{}] ", page_info.join(" | "));
|
||||||
buf.set_string(inner.x, inner.y,
|
buf.set_string(inner.x, inner.y,
|
||||||
&page_str,
|
&page_str,
|
||||||
|
|||||||
181
src/view/layout.rs
Normal file
181
src/view/layout.rs
Normal file
@ -0,0 +1,181 @@
|
|||||||
|
use crate::model::Model;
|
||||||
|
use crate::model::cell::CellKey;
|
||||||
|
use crate::view::{Axis, View};
|
||||||
|
|
||||||
|
/// The resolved 2-D layout of a view: which item tuples appear on each axis,
|
||||||
|
/// what page filter is active, and how to map (row, col) → CellKey.
|
||||||
|
///
|
||||||
|
/// This is the single authoritative place that converts the multi-dimensional
|
||||||
|
/// model into the flat grid consumed by both the terminal renderer and CSV exporter.
|
||||||
|
pub struct GridLayout {
|
||||||
|
pub row_cats: Vec<String>,
|
||||||
|
pub col_cats: Vec<String>,
|
||||||
|
pub page_coords: Vec<(String, String)>,
|
||||||
|
pub row_items: Vec<Vec<String>>,
|
||||||
|
pub col_items: Vec<Vec<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GridLayout {
|
||||||
|
pub fn new(model: &Model, view: &View) -> Self {
|
||||||
|
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 page_coords = page_cats.iter().map(|cat| {
|
||||||
|
let items: Vec<String> = 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();
|
||||||
|
|
||||||
|
let row_items = cross_product(model, view, &row_cats);
|
||||||
|
let col_items = cross_product(model, view, &col_cats);
|
||||||
|
|
||||||
|
Self { row_cats, col_cats, page_coords, row_items, col_items }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn row_count(&self) -> usize { self.row_items.len() }
|
||||||
|
pub fn col_count(&self) -> usize { self.col_items.len() }
|
||||||
|
|
||||||
|
pub fn row_label(&self, row: usize) -> String {
|
||||||
|
self.row_items.get(row).map(|r| r.join("/")).unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn col_label(&self, col: usize) -> String {
|
||||||
|
self.col_items.get(col).map(|c| c.join("/")).unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the CellKey for the data cell at (row, col), including the active
|
||||||
|
/// page-axis filter. Returns None if row or col is out of bounds.
|
||||||
|
pub fn cell_key(&self, row: usize, col: usize) -> Option<CellKey> {
|
||||||
|
let row_item = self.row_items.get(row)?;
|
||||||
|
let col_item = self.col_items.get(col)?;
|
||||||
|
let mut coords = self.page_coords.clone();
|
||||||
|
for (cat, item) in self.row_cats.iter().zip(row_item.iter()) {
|
||||||
|
coords.push((cat.clone(), item.clone()));
|
||||||
|
}
|
||||||
|
for (cat, item) in self.col_cats.iter().zip(col_item.iter()) {
|
||||||
|
coords.push((cat.clone(), item.clone()));
|
||||||
|
}
|
||||||
|
Some(CellKey::new(coords))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cartesian product of visible items across `cats`, in category order.
|
||||||
|
/// Hidden items are excluded. Returns `vec![vec![]]` when `cats` is empty.
|
||||||
|
fn cross_product(model: &Model, view: &View, cats: &[String]) -> 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
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::GridLayout;
|
||||||
|
use crate::model::Model;
|
||||||
|
use crate::model::cell::{CellKey, CellValue};
|
||||||
|
|
||||||
|
fn coord(pairs: &[(&str, &str)]) -> CellKey {
|
||||||
|
CellKey::new(pairs.iter().map(|(c, i)| (c.to_string(), i.to_string())).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn two_cat_model() -> Model {
|
||||||
|
let mut m = Model::new("T");
|
||||||
|
m.add_category("Type").unwrap();
|
||||||
|
m.add_category("Month").unwrap();
|
||||||
|
for item in ["Food", "Clothing"] { m.category_mut("Type").unwrap().add_item(item); }
|
||||||
|
for item in ["Jan", "Feb"] { m.category_mut("Month").unwrap().add_item(item); }
|
||||||
|
m
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn row_and_col_counts_match_item_counts() {
|
||||||
|
let m = two_cat_model();
|
||||||
|
let layout = GridLayout::new(&m, m.active_view().unwrap());
|
||||||
|
assert_eq!(layout.row_count(), 2); // Food, Clothing
|
||||||
|
assert_eq!(layout.col_count(), 2); // Jan, Feb
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cell_key_encodes_correct_coordinates() {
|
||||||
|
let m = two_cat_model();
|
||||||
|
let layout = GridLayout::new(&m, m.active_view().unwrap());
|
||||||
|
// row 0 = Food, col 1 = Feb
|
||||||
|
let key = layout.cell_key(0, 1).unwrap();
|
||||||
|
assert_eq!(key, coord(&[("Month", "Feb"), ("Type", "Food")]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cell_key_out_of_bounds_returns_none() {
|
||||||
|
let m = two_cat_model();
|
||||||
|
let layout = GridLayout::new(&m, m.active_view().unwrap());
|
||||||
|
assert!(layout.cell_key(99, 0).is_none());
|
||||||
|
assert!(layout.cell_key(0, 99).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cell_key_includes_page_coords() {
|
||||||
|
let mut m = Model::new("T");
|
||||||
|
m.add_category("Type").unwrap();
|
||||||
|
m.add_category("Month").unwrap();
|
||||||
|
m.add_category("Region").unwrap();
|
||||||
|
m.category_mut("Type").unwrap().add_item("Food");
|
||||||
|
m.category_mut("Month").unwrap().add_item("Jan");
|
||||||
|
m.category_mut("Region").unwrap().add_item("East");
|
||||||
|
m.category_mut("Region").unwrap().add_item("West");
|
||||||
|
m.active_view_mut().unwrap().set_page_selection("Region", "West");
|
||||||
|
let layout = GridLayout::new(&m, m.active_view().unwrap());
|
||||||
|
let key = layout.cell_key(0, 0).unwrap();
|
||||||
|
assert_eq!(key.get("Region"), Some("West"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cell_key_round_trips_through_model_evaluate() {
|
||||||
|
let mut m = two_cat_model();
|
||||||
|
m.set_cell(
|
||||||
|
coord(&[("Month", "Feb"), ("Type", "Clothing")]),
|
||||||
|
CellValue::Number(42.0),
|
||||||
|
);
|
||||||
|
let layout = GridLayout::new(&m, m.active_view().unwrap());
|
||||||
|
// Clothing = row 1, Feb = col 1
|
||||||
|
let key = layout.cell_key(1, 1).unwrap();
|
||||||
|
assert_eq!(m.evaluate(&key), CellValue::Number(42.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn labels_join_with_slash_for_multi_cat_axis() {
|
||||||
|
let mut m = Model::new("T");
|
||||||
|
m.add_category("Type").unwrap();
|
||||||
|
m.add_category("Month").unwrap();
|
||||||
|
m.add_category("Year").unwrap();
|
||||||
|
m.category_mut("Type").unwrap().add_item("Food");
|
||||||
|
m.category_mut("Month").unwrap().add_item("Jan");
|
||||||
|
m.category_mut("Year").unwrap().add_item("2025");
|
||||||
|
m.active_view_mut().unwrap().set_axis("Year", crate::view::Axis::Column);
|
||||||
|
let layout = GridLayout::new(&m, m.active_view().unwrap());
|
||||||
|
assert_eq!(layout.col_label(0), "Jan/2025");
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,5 +1,7 @@
|
|||||||
pub mod view;
|
pub mod view;
|
||||||
pub mod axis;
|
pub mod axis;
|
||||||
|
pub mod layout;
|
||||||
|
|
||||||
pub use view::View;
|
pub use view::View;
|
||||||
pub use axis::Axis;
|
pub use axis::Axis;
|
||||||
|
pub use layout::GridLayout;
|
||||||
|
|||||||
Reference in New Issue
Block a user