feat: group-aware grid rendering and hide/show item
Builds out two half-finished view features: Group collapse: - AxisEntry enum distinguishes GroupHeader from DataItem on grid axes - expand_category() emits group headers and filters collapsed items - Grid renders inline group header rows with ▼/▶ indicator - `z` keybinding toggles collapse of nearest group above cursor Hide/show item: - Restore show_item() (was commented out alongside hide_item) - Add HideItem / ShowItem commands and dispatch - `H` keybinding hides the current row item - `:show-item <cat> <item>` command to restore hidden items - Restore silenced test assertions for hide/show round-trip Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@ -1,38 +1,42 @@
|
|||||||
|
|
||||||
use crate::model::Model;
|
|
||||||
use crate::model::cell::{CellKey, CellValue};
|
|
||||||
use crate::formula::parse_formula;
|
|
||||||
use crate::persistence;
|
|
||||||
use crate::import::analyzer::{analyze_records, extract_array_at_path, FieldKind};
|
|
||||||
use super::types::{CellValueArg, Command, CommandResult};
|
use super::types::{CellValueArg, Command, CommandResult};
|
||||||
|
use crate::formula::parse_formula;
|
||||||
|
use crate::import::analyzer::{analyze_records, extract_array_at_path, FieldKind};
|
||||||
|
use crate::model::cell::{CellKey, CellValue};
|
||||||
|
use crate::model::Model;
|
||||||
|
use crate::persistence;
|
||||||
|
|
||||||
/// Execute a command against the model, returning a result.
|
/// Execute a command against the model, returning a result.
|
||||||
/// This is the single authoritative mutation path used by both the TUI and headless modes.
|
/// This is the single authoritative mutation path used by both the TUI and headless modes.
|
||||||
pub fn dispatch(model: &mut Model, cmd: &Command) -> CommandResult {
|
pub fn dispatch(model: &mut Model, cmd: &Command) -> CommandResult {
|
||||||
match cmd {
|
match cmd {
|
||||||
Command::AddCategory { name } => {
|
Command::AddCategory { name } => match model.add_category(name) {
|
||||||
match model.add_category(name) {
|
Ok(_) => CommandResult::ok_msg(format!("Category '{name}' added")),
|
||||||
Ok(_) => CommandResult::ok_msg(format!("Category '{name}' added")),
|
Err(e) => CommandResult::err(e.to_string()),
|
||||||
Err(e) => CommandResult::err(e.to_string()),
|
},
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Command::AddItem { category, item } => {
|
Command::AddItem { category, item } => match model.category_mut(category) {
|
||||||
match model.category_mut(category) {
|
Some(cat) => {
|
||||||
Some(cat) => { cat.add_item(item); CommandResult::ok() }
|
cat.add_item(item);
|
||||||
None => CommandResult::err(format!("Category '{category}' not found")),
|
CommandResult::ok()
|
||||||
}
|
}
|
||||||
}
|
None => CommandResult::err(format!("Category '{category}' not found")),
|
||||||
|
},
|
||||||
|
|
||||||
Command::AddItemInGroup { category, item, group } => {
|
Command::AddItemInGroup {
|
||||||
match model.category_mut(category) {
|
category,
|
||||||
Some(cat) => { cat.add_item_in_group(item, group); CommandResult::ok() }
|
item,
|
||||||
None => CommandResult::err(format!("Category '{category}' not found")),
|
group,
|
||||||
|
} => match model.category_mut(category) {
|
||||||
|
Some(cat) => {
|
||||||
|
cat.add_item_in_group(item, group);
|
||||||
|
CommandResult::ok()
|
||||||
}
|
}
|
||||||
}
|
None => CommandResult::err(format!("Category '{category}' not found")),
|
||||||
|
},
|
||||||
|
|
||||||
Command::SetCell { coords, value } => {
|
Command::SetCell { coords, value } => {
|
||||||
let kv: Vec<(String, String)> = coords.iter()
|
let kv: Vec<(String, String)> = coords
|
||||||
|
.iter()
|
||||||
.map(|pair| (pair[0].clone(), pair[1].clone()))
|
.map(|pair| (pair[0].clone(), pair[1].clone()))
|
||||||
.collect();
|
.collect();
|
||||||
// Validate all categories exist before mutating anything
|
// Validate all categories exist before mutating anything
|
||||||
@ -55,7 +59,8 @@ pub fn dispatch(model: &mut Model, cmd: &Command) -> CommandResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Command::ClearCell { coords } => {
|
Command::ClearCell { coords } => {
|
||||||
let kv: Vec<(String, String)> = coords.iter()
|
let kv: Vec<(String, String)> = coords
|
||||||
|
.iter()
|
||||||
.map(|pair| (pair[0].clone(), pair[1].clone()))
|
.map(|pair| (pair[0].clone(), pair[1].clone()))
|
||||||
.collect();
|
.collect();
|
||||||
let key = CellKey::new(kv);
|
let key = CellKey::new(kv);
|
||||||
@ -63,7 +68,10 @@ pub fn dispatch(model: &mut Model, cmd: &Command) -> CommandResult {
|
|||||||
CommandResult::ok()
|
CommandResult::ok()
|
||||||
}
|
}
|
||||||
|
|
||||||
Command::AddFormula { raw, target_category } => {
|
Command::AddFormula {
|
||||||
|
raw,
|
||||||
|
target_category,
|
||||||
|
} => {
|
||||||
match parse_formula(raw, target_category) {
|
match parse_formula(raw, target_category) {
|
||||||
Ok(formula) => {
|
Ok(formula) => {
|
||||||
// Ensure the target item exists in the target category
|
// Ensure the target item exists in the target category
|
||||||
@ -79,7 +87,10 @@ pub fn dispatch(model: &mut Model, cmd: &Command) -> CommandResult {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Command::RemoveFormula { target, target_category } => {
|
Command::RemoveFormula {
|
||||||
|
target,
|
||||||
|
target_category,
|
||||||
|
} => {
|
||||||
model.remove_formula(target, target_category);
|
model.remove_formula(target, target_category);
|
||||||
CommandResult::ok()
|
CommandResult::ok()
|
||||||
}
|
}
|
||||||
@ -89,19 +100,15 @@ pub fn dispatch(model: &mut Model, cmd: &Command) -> CommandResult {
|
|||||||
CommandResult::ok()
|
CommandResult::ok()
|
||||||
}
|
}
|
||||||
|
|
||||||
Command::DeleteView { name } => {
|
Command::DeleteView { name } => match model.delete_view(name) {
|
||||||
match model.delete_view(name) {
|
Ok(_) => CommandResult::ok(),
|
||||||
Ok(_) => CommandResult::ok(),
|
Err(e) => CommandResult::err(e.to_string()),
|
||||||
Err(e) => CommandResult::err(e.to_string()),
|
},
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Command::SwitchView { name } => {
|
Command::SwitchView { name } => match model.switch_view(name) {
|
||||||
match model.switch_view(name) {
|
Ok(_) => CommandResult::ok(),
|
||||||
Ok(_) => CommandResult::ok(),
|
Err(e) => CommandResult::err(e.to_string()),
|
||||||
Err(e) => CommandResult::err(e.to_string()),
|
},
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Command::SetAxis { category, axis } => {
|
Command::SetAxis { category, axis } => {
|
||||||
model.active_view_mut().set_axis(category, *axis);
|
model.active_view_mut().set_axis(category, *axis);
|
||||||
@ -114,28 +121,36 @@ pub fn dispatch(model: &mut Model, cmd: &Command) -> CommandResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Command::ToggleGroup { category, group } => {
|
Command::ToggleGroup { category, group } => {
|
||||||
model.active_view_mut().toggle_group_collapse(category, group);
|
model
|
||||||
|
.active_view_mut()
|
||||||
|
.toggle_group_collapse(category, group);
|
||||||
CommandResult::ok()
|
CommandResult::ok()
|
||||||
}
|
}
|
||||||
|
|
||||||
Command::Save { path } => {
|
Command::HideItem { category, item } => {
|
||||||
match persistence::save(model, std::path::Path::new(path)) {
|
model.active_view_mut().hide_item(category, item);
|
||||||
Ok(_) => CommandResult::ok_msg(format!("Saved to {path}")),
|
CommandResult::ok()
|
||||||
Err(e) => CommandResult::err(e.to_string()),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Command::Load { path } => {
|
Command::ShowItem { category, item } => {
|
||||||
match persistence::load(std::path::Path::new(path)) {
|
model.active_view_mut().show_item(category, item);
|
||||||
Ok(mut loaded) => {
|
CommandResult::ok()
|
||||||
loaded.normalize_view_state();
|
|
||||||
*model = loaded;
|
|
||||||
CommandResult::ok_msg(format!("Loaded from {path}"))
|
|
||||||
}
|
|
||||||
Err(e) => CommandResult::err(e.to_string()),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Command::Save { path } => match persistence::save(model, std::path::Path::new(path)) {
|
||||||
|
Ok(_) => CommandResult::ok_msg(format!("Saved to {path}")),
|
||||||
|
Err(e) => CommandResult::err(e.to_string()),
|
||||||
|
},
|
||||||
|
|
||||||
|
Command::Load { path } => match persistence::load(std::path::Path::new(path)) {
|
||||||
|
Ok(mut loaded) => {
|
||||||
|
loaded.normalize_view_state();
|
||||||
|
*model = loaded;
|
||||||
|
CommandResult::ok_msg(format!("Loaded from {path}"))
|
||||||
|
}
|
||||||
|
Err(e) => CommandResult::err(e.to_string()),
|
||||||
|
},
|
||||||
|
|
||||||
Command::ExportCsv { path } => {
|
Command::ExportCsv { path } => {
|
||||||
let view_name = model.active_view.clone();
|
let view_name = model.active_view.clone();
|
||||||
match persistence::export_csv(model, &view_name, std::path::Path::new(path)) {
|
match persistence::export_csv(model, &view_name, std::path::Path::new(path)) {
|
||||||
@ -144,9 +159,11 @@ pub fn dispatch(model: &mut Model, cmd: &Command) -> CommandResult {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Command::ImportJson { path, model_name, array_path } => {
|
Command::ImportJson {
|
||||||
import_json_headless(model, path, model_name.as_deref(), array_path.as_deref())
|
path,
|
||||||
}
|
model_name,
|
||||||
|
array_path,
|
||||||
|
} => import_json_headless(model, path, model_name.as_deref(), array_path.as_deref()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -193,7 +210,13 @@ fn import_json_headless(
|
|||||||
array_paths: vec![],
|
array_paths: vec![],
|
||||||
selected_path: array_path.unwrap_or("").to_string(),
|
selected_path: array_path.unwrap_or("").to_string(),
|
||||||
records,
|
records,
|
||||||
proposals: proposals.into_iter().map(|mut p| { p.accepted = p.kind != FieldKind::Label; p }).collect(),
|
proposals: proposals
|
||||||
|
.into_iter()
|
||||||
|
.map(|mut p| {
|
||||||
|
p.accepted = p.kind != FieldKind::Label;
|
||||||
|
p
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
model_name: model_name.unwrap_or("Imported Model").to_string(),
|
model_name: model_name.unwrap_or("Imported Model").to_string(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use crate::view::Axis;
|
use crate::view::Axis;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
/// All commands that can mutate a Model.
|
/// All commands that can mutate a Model.
|
||||||
///
|
///
|
||||||
@ -15,7 +15,11 @@ pub enum Command {
|
|||||||
AddItem { category: String, item: String },
|
AddItem { category: String, item: String },
|
||||||
|
|
||||||
/// Add an item inside a named group.
|
/// Add an item inside a named group.
|
||||||
AddItemInGroup { category: String, item: String, group: String },
|
AddItemInGroup {
|
||||||
|
category: String,
|
||||||
|
item: String,
|
||||||
|
group: String,
|
||||||
|
},
|
||||||
|
|
||||||
/// Set a cell value. `coords` is a list of `[category, item]` pairs.
|
/// Set a cell value. `coords` is a list of `[category, item]` pairs.
|
||||||
SetCell {
|
SetCell {
|
||||||
@ -30,10 +34,16 @@ pub enum Command {
|
|||||||
/// Add or replace a formula.
|
/// Add or replace a formula.
|
||||||
/// `raw` is the full formula string, e.g. "Profit = Revenue - Cost".
|
/// `raw` is the full formula string, e.g. "Profit = Revenue - Cost".
|
||||||
/// `target_category` names the category that owns the formula target.
|
/// `target_category` names the category that owns the formula target.
|
||||||
AddFormula { raw: String, target_category: String },
|
AddFormula {
|
||||||
|
raw: String,
|
||||||
|
target_category: String,
|
||||||
|
},
|
||||||
|
|
||||||
/// Remove a formula by its target name and category.
|
/// Remove a formula by its target name and category.
|
||||||
RemoveFormula { target: String, target_category: String },
|
RemoveFormula {
|
||||||
|
target: String,
|
||||||
|
target_category: String,
|
||||||
|
},
|
||||||
|
|
||||||
/// Create a new view.
|
/// Create a new view.
|
||||||
CreateView { name: String },
|
CreateView { name: String },
|
||||||
@ -53,6 +63,12 @@ pub enum Command {
|
|||||||
/// Toggle collapse of a group in the active view.
|
/// Toggle collapse of a group in the active view.
|
||||||
ToggleGroup { category: String, group: String },
|
ToggleGroup { category: String, group: String },
|
||||||
|
|
||||||
|
/// Hide an item in the active view.
|
||||||
|
HideItem { category: String, item: String },
|
||||||
|
|
||||||
|
/// Show (un-hide) an item in the active view.
|
||||||
|
ShowItem { category: String, item: String },
|
||||||
|
|
||||||
/// Save the model to a file path.
|
/// Save the model to a file path.
|
||||||
Save { path: String },
|
Save { path: String },
|
||||||
|
|
||||||
@ -88,12 +104,21 @@ pub struct CommandResult {
|
|||||||
|
|
||||||
impl CommandResult {
|
impl CommandResult {
|
||||||
pub fn ok() -> Self {
|
pub fn ok() -> Self {
|
||||||
Self { ok: true, message: None }
|
Self {
|
||||||
|
ok: true,
|
||||||
|
message: None,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
pub fn ok_msg(msg: impl Into<String>) -> Self {
|
pub fn ok_msg(msg: impl Into<String>) -> Self {
|
||||||
Self { ok: true, message: Some(msg.into()) }
|
Self {
|
||||||
|
ok: true,
|
||||||
|
message: Some(msg.into()),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
pub fn err(msg: impl Into<String>) -> Self {
|
pub fn err(msg: impl Into<String>) -> Self {
|
||||||
Self { ok: false, message: Some(msg.into()) }
|
Self {
|
||||||
|
ok: false,
|
||||||
|
message: Some(msg.into()),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
891
src/ui/app.rs
891
src/ui/app.rs
File diff suppressed because it is too large
Load Diff
629
src/ui/grid.rs
629
src/ui/grid.rs
@ -6,13 +6,15 @@ use ratatui::{
|
|||||||
};
|
};
|
||||||
use unicode_width::UnicodeWidthStr;
|
use unicode_width::UnicodeWidthStr;
|
||||||
|
|
||||||
use crate::model::Model;
|
|
||||||
use crate::model::cell::CellValue;
|
use crate::model::cell::CellValue;
|
||||||
use crate::view::GridLayout;
|
use crate::model::Model;
|
||||||
use crate::ui::app::AppMode;
|
use crate::ui::app::AppMode;
|
||||||
|
use crate::view::{AxisEntry, GridLayout};
|
||||||
|
|
||||||
const ROW_HEADER_WIDTH: u16 = 16;
|
const ROW_HEADER_WIDTH: u16 = 16;
|
||||||
const COL_WIDTH: u16 = 10;
|
const COL_WIDTH: u16 = 10;
|
||||||
|
const GROUP_EXPANDED: &str = "▼";
|
||||||
|
const GROUP_COLLAPSED: &str = "▶";
|
||||||
|
|
||||||
pub struct GridWidget<'a> {
|
pub struct GridWidget<'a> {
|
||||||
pub model: &'a Model,
|
pub model: &'a Model,
|
||||||
@ -22,7 +24,11 @@ pub struct GridWidget<'a> {
|
|||||||
|
|
||||||
impl<'a> GridWidget<'a> {
|
impl<'a> GridWidget<'a> {
|
||||||
pub fn new(model: &'a Model, mode: &'a AppMode, search_query: &'a str) -> Self {
|
pub fn new(model: &'a Model, mode: &'a AppMode, search_query: &'a str) -> Self {
|
||||||
Self { model, mode, search_query }
|
Self {
|
||||||
|
model,
|
||||||
|
mode,
|
||||||
|
search_query,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_grid(&self, area: Rect, buf: &mut Buffer) {
|
fn render_grid(&self, area: Rect, buf: &mut Buffer) {
|
||||||
@ -39,150 +45,352 @@ impl<'a> GridWidget<'a> {
|
|||||||
|
|
||||||
// Sub-column widths for row header area
|
// Sub-column widths for row header area
|
||||||
let sub_col_w = ROW_HEADER_WIDTH / n_row_levels as u16;
|
let sub_col_w = ROW_HEADER_WIDTH / n_row_levels as u16;
|
||||||
let sub_widths: Vec<u16> = (0..n_row_levels).map(|d| {
|
let sub_widths: Vec<u16> = (0..n_row_levels)
|
||||||
if d < n_row_levels - 1 { sub_col_w }
|
.map(|d| {
|
||||||
else { ROW_HEADER_WIDTH.saturating_sub(sub_col_w * (n_row_levels as u16 - 1)) }
|
if d < n_row_levels - 1 {
|
||||||
}).collect();
|
sub_col_w
|
||||||
|
} else {
|
||||||
|
ROW_HEADER_WIDTH.saturating_sub(sub_col_w * (n_row_levels as u16 - 1))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Flat lists of data-only tuples for repeat-suppression in headers
|
||||||
|
let data_col_items: Vec<&Vec<String>> = layout
|
||||||
|
.col_items
|
||||||
|
.iter()
|
||||||
|
.filter_map(|e| {
|
||||||
|
if let AxisEntry::DataItem(v) = e {
|
||||||
|
Some(v)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let data_row_items: Vec<&Vec<String>> = layout
|
||||||
|
.row_items
|
||||||
|
.iter()
|
||||||
|
.filter_map(|e| {
|
||||||
|
if let AxisEntry::DataItem(v) = e {
|
||||||
|
Some(v)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Map each data-col index to its group name (None if ungrouped)
|
||||||
|
let col_groups: Vec<Option<String>> = {
|
||||||
|
let mut groups = Vec::new();
|
||||||
|
let mut current: Option<String> = None;
|
||||||
|
for entry in &layout.col_items {
|
||||||
|
match entry {
|
||||||
|
AxisEntry::GroupHeader { group_name, .. } => current = Some(group_name.clone()),
|
||||||
|
AxisEntry::DataItem(_) => groups.push(current.clone()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
groups
|
||||||
|
};
|
||||||
|
let has_col_groups = col_groups.iter().any(|g| g.is_some());
|
||||||
|
|
||||||
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_range = col_offset..(col_offset + available_cols.max(1)).min(layout.col_count());
|
let visible_col_range =
|
||||||
|
col_offset..(col_offset + available_cols.max(1)).min(layout.col_count());
|
||||||
|
|
||||||
let header_rows = n_col_levels as u16 + 1; // +1 for separator
|
let _header_rows = n_col_levels as u16 + 1 + if has_col_groups { 1 } else { 0 };
|
||||||
let available_rows = area.height.saturating_sub(header_rows) as usize;
|
|
||||||
let visible_row_range = row_offset..(row_offset + available_rows.max(1)).min(layout.row_count());
|
let visual_row_start = layout
|
||||||
|
.data_row_to_visual(row_offset)
|
||||||
|
.unwrap_or(layout.row_items.len());
|
||||||
|
|
||||||
let mut y = area.y;
|
let mut y = area.y;
|
||||||
|
|
||||||
// Column headers — one row per level, with repeat suppression
|
// Optional column group header row
|
||||||
let header_style = Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD);
|
if has_col_groups {
|
||||||
for d in 0..n_col_levels {
|
let group_style = Style::default()
|
||||||
buf.set_string(area.x, y,
|
.fg(Color::Green)
|
||||||
|
.add_modifier(Modifier::BOLD);
|
||||||
|
buf.set_string(
|
||||||
|
area.x,
|
||||||
|
y,
|
||||||
format!("{:<width$}", "", width = ROW_HEADER_WIDTH as usize),
|
format!("{:<width$}", "", width = ROW_HEADER_WIDTH as usize),
|
||||||
Style::default());
|
Style::default(),
|
||||||
|
);
|
||||||
|
let mut x = area.x + ROW_HEADER_WIDTH;
|
||||||
|
let mut prev_group: Option<&str> = None;
|
||||||
|
for ci in visible_col_range.clone() {
|
||||||
|
if x >= area.x + area.width {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let group = col_groups[ci].as_deref();
|
||||||
|
let label = if group != prev_group {
|
||||||
|
group.unwrap_or("")
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
};
|
||||||
|
prev_group = group;
|
||||||
|
buf.set_string(
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
format!(
|
||||||
|
"{:<width$}",
|
||||||
|
truncate(label, COL_WIDTH as usize),
|
||||||
|
width = COL_WIDTH as usize
|
||||||
|
),
|
||||||
|
group_style,
|
||||||
|
);
|
||||||
|
x += COL_WIDTH;
|
||||||
|
}
|
||||||
|
y += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Column headers — one row per level, with repeat suppression
|
||||||
|
let header_style = Style::default()
|
||||||
|
.fg(Color::Yellow)
|
||||||
|
.add_modifier(Modifier::BOLD);
|
||||||
|
for d in 0..n_col_levels {
|
||||||
|
buf.set_string(
|
||||||
|
area.x,
|
||||||
|
y,
|
||||||
|
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 in visible_col_range.clone() {
|
for ci in visible_col_range.clone() {
|
||||||
let label = if layout.col_cats.is_empty() {
|
let label = if layout.col_cats.is_empty() {
|
||||||
layout.col_label(ci)
|
layout.col_label(ci)
|
||||||
} else {
|
} else {
|
||||||
let show = ci == 0
|
let show = ci == 0 || data_col_items[ci][..=d] != data_col_items[ci - 1][..=d];
|
||||||
|| layout.col_items[ci][..=d] != layout.col_items[ci - 1][..=d];
|
if show {
|
||||||
if show { layout.col_items[ci][d].clone() } else { String::new() }
|
data_col_items[ci][d].clone()
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
}
|
||||||
};
|
};
|
||||||
let styled = if ci == sel_col {
|
let styled = if ci == sel_col {
|
||||||
header_style.add_modifier(Modifier::UNDERLINED)
|
header_style.add_modifier(Modifier::UNDERLINED)
|
||||||
} else {
|
} else {
|
||||||
header_style
|
header_style
|
||||||
};
|
};
|
||||||
buf.set_string(x, y,
|
buf.set_string(
|
||||||
format!("{:>width$}", truncate(&label, COL_WIDTH as usize), width = COL_WIDTH as usize),
|
x,
|
||||||
styled);
|
y,
|
||||||
|
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
|
||||||
buf.set_string(area.x, y,
|
buf.set_string(
|
||||||
|
area.x,
|
||||||
|
y,
|
||||||
"─".repeat(area.width as usize),
|
"─".repeat(area.width as usize),
|
||||||
Style::default().fg(Color::DarkGray));
|
Style::default().fg(Color::DarkGray),
|
||||||
|
);
|
||||||
y += 1;
|
y += 1;
|
||||||
|
|
||||||
// Data rows
|
// Data rows — iterate visual entries, rendering group headers inline
|
||||||
for ri in visible_row_range.clone() {
|
let group_header_style = Style::default()
|
||||||
if y >= area.y + area.height { break; }
|
.fg(Color::Green)
|
||||||
|
.add_modifier(Modifier::BOLD);
|
||||||
let row_style = if ri == sel_row {
|
let mut data_row_idx = row_offset;
|
||||||
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)
|
for entry in &layout.row_items[visual_row_start..] {
|
||||||
} else {
|
if y >= area.y + area.height {
|
||||||
Style::default()
|
break;
|
||||||
};
|
|
||||||
|
|
||||||
// Multi-level row header — one sub-column per row category
|
|
||||||
let mut hx = area.x;
|
|
||||||
for d in 0..n_row_levels {
|
|
||||||
let sw = sub_widths[d] as usize;
|
|
||||||
let label = if layout.row_cats.is_empty() {
|
|
||||||
layout.row_label(ri)
|
|
||||||
} else {
|
|
||||||
let show = ri == 0
|
|
||||||
|| layout.row_items[ri][..=d] != layout.row_items[ri - 1][..=d];
|
|
||||||
if show { layout.row_items[ri][d].clone() } else { String::new() }
|
|
||||||
};
|
|
||||||
buf.set_string(hx, y,
|
|
||||||
format!("{:<width$}", truncate(&label, sw), width = sw),
|
|
||||||
row_style);
|
|
||||||
hx += sub_widths[d];
|
|
||||||
}
|
}
|
||||||
|
match entry {
|
||||||
|
AxisEntry::GroupHeader {
|
||||||
|
cat_name,
|
||||||
|
group_name,
|
||||||
|
} => {
|
||||||
|
let indicator = if view.is_group_collapsed(cat_name, group_name) {
|
||||||
|
GROUP_COLLAPSED
|
||||||
|
} else {
|
||||||
|
GROUP_EXPANDED
|
||||||
|
};
|
||||||
|
let label = format!("{indicator} {group_name}");
|
||||||
|
buf.set_string(
|
||||||
|
area.x,
|
||||||
|
y,
|
||||||
|
format!(
|
||||||
|
"{:<width$}",
|
||||||
|
truncate(&label, ROW_HEADER_WIDTH as usize),
|
||||||
|
width = ROW_HEADER_WIDTH as usize
|
||||||
|
),
|
||||||
|
group_header_style,
|
||||||
|
);
|
||||||
|
let mut x = area.x + ROW_HEADER_WIDTH;
|
||||||
|
while x < area.x + area.width {
|
||||||
|
buf.set_string(
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
format!("{:─<width$}", "", width = COL_WIDTH as usize),
|
||||||
|
Style::default().fg(Color::DarkGray),
|
||||||
|
);
|
||||||
|
x += COL_WIDTH;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AxisEntry::DataItem(_) => {
|
||||||
|
let ri = data_row_idx;
|
||||||
|
data_row_idx += 1;
|
||||||
|
|
||||||
let mut x = area.x + ROW_HEADER_WIDTH;
|
let row_style = if ri == sel_row {
|
||||||
for ci in visible_col_range.clone() {
|
Style::default()
|
||||||
if x >= area.x + area.width { break; }
|
.fg(Color::Cyan)
|
||||||
|
.add_modifier(Modifier::BOLD)
|
||||||
|
} else {
|
||||||
|
Style::default()
|
||||||
|
};
|
||||||
|
|
||||||
let key = match layout.cell_key(ri, ci) {
|
// Multi-level row header — one sub-column per row category
|
||||||
Some(k) => k,
|
let mut hx = area.x;
|
||||||
None => { x += COL_WIDTH; continue; }
|
for d in 0..n_row_levels {
|
||||||
};
|
let sw = sub_widths[d] as usize;
|
||||||
let value = self.model.evaluate(&key);
|
let label = if layout.row_cats.is_empty() {
|
||||||
|
layout.row_label(ri)
|
||||||
|
} else {
|
||||||
|
let show =
|
||||||
|
ri == 0 || data_row_items[ri][..=d] != data_row_items[ri - 1][..=d];
|
||||||
|
if show {
|
||||||
|
data_row_items[ri][d].clone()
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
buf.set_string(
|
||||||
|
hx,
|
||||||
|
y,
|
||||||
|
format!("{:<width$}", truncate(&label, sw), width = sw),
|
||||||
|
row_style,
|
||||||
|
);
|
||||||
|
hx += sub_widths[d];
|
||||||
|
}
|
||||||
|
|
||||||
let cell_str = format_value(value.as_ref(), fmt_comma, fmt_decimals);
|
let mut x = area.x + ROW_HEADER_WIDTH;
|
||||||
let is_selected = ri == sel_row && ci == sel_col;
|
for ci in visible_col_range.clone() {
|
||||||
let is_search_match = !self.search_query.is_empty()
|
if x >= area.x + area.width {
|
||||||
&& cell_str.to_lowercase().contains(&self.search_query.to_lowercase());
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
let cell_style = if is_selected {
|
let key = match layout.cell_key(ri, ci) {
|
||||||
Style::default().fg(Color::Black).bg(Color::Cyan).add_modifier(Modifier::BOLD)
|
Some(k) => k,
|
||||||
} else if is_search_match {
|
None => {
|
||||||
Style::default().fg(Color::Black).bg(Color::Yellow)
|
x += COL_WIDTH;
|
||||||
} else if value.is_none() {
|
continue;
|
||||||
Style::default().fg(Color::DarkGray)
|
}
|
||||||
} else {
|
};
|
||||||
Style::default()
|
let value = self.model.evaluate(&key);
|
||||||
};
|
|
||||||
|
|
||||||
buf.set_string(x, y,
|
let cell_str = format_value(value.as_ref(), fmt_comma, fmt_decimals);
|
||||||
format!("{:>width$}", truncate(&cell_str, COL_WIDTH as usize), width = COL_WIDTH as usize),
|
let is_selected = ri == sel_row && ci == sel_col;
|
||||||
cell_style);
|
let is_search_match = !self.search_query.is_empty()
|
||||||
x += COL_WIDTH;
|
&& cell_str
|
||||||
}
|
.to_lowercase()
|
||||||
|
.contains(&self.search_query.to_lowercase());
|
||||||
|
|
||||||
// Edit indicator
|
let cell_style = if is_selected {
|
||||||
if matches!(self.mode, AppMode::Editing { .. }) && ri == sel_row {
|
Style::default()
|
||||||
if let AppMode::Editing { buffer } = self.mode {
|
.fg(Color::Black)
|
||||||
let edit_x = area.x + ROW_HEADER_WIDTH + (sel_col.saturating_sub(col_offset)) as u16 * COL_WIDTH;
|
.bg(Color::Cyan)
|
||||||
buf.set_string(edit_x, y,
|
.add_modifier(Modifier::BOLD)
|
||||||
truncate(&format!("{:<width$}", buffer, width = COL_WIDTH as usize), COL_WIDTH as usize),
|
} else if is_search_match {
|
||||||
Style::default().fg(Color::Green).add_modifier(Modifier::UNDERLINED));
|
Style::default().fg(Color::Black).bg(Color::Yellow)
|
||||||
|
} else if value.is_none() {
|
||||||
|
Style::default().fg(Color::DarkGray)
|
||||||
|
} else {
|
||||||
|
Style::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
buf.set_string(
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
format!(
|
||||||
|
"{:>width$}",
|
||||||
|
truncate(&cell_str, COL_WIDTH as usize),
|
||||||
|
width = COL_WIDTH as usize
|
||||||
|
),
|
||||||
|
cell_style,
|
||||||
|
);
|
||||||
|
x += COL_WIDTH;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Edit indicator
|
||||||
|
if matches!(self.mode, AppMode::Editing { .. }) && ri == sel_row {
|
||||||
|
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;
|
||||||
|
buf.set_string(
|
||||||
|
edit_x,
|
||||||
|
y,
|
||||||
|
truncate(
|
||||||
|
&format!("{:<width$}", buffer, width = COL_WIDTH as usize),
|
||||||
|
COL_WIDTH as usize,
|
||||||
|
),
|
||||||
|
Style::default()
|
||||||
|
.fg(Color::Green)
|
||||||
|
.add_modifier(Modifier::UNDERLINED),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
y += 1;
|
y += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Total row
|
// Total row
|
||||||
if layout.row_count() > 0 && layout.col_count() > 0 {
|
if layout.row_count() > 0 && layout.col_count() > 0 {
|
||||||
if y < area.y + area.height {
|
if y < area.y + area.height {
|
||||||
buf.set_string(area.x, y,
|
buf.set_string(
|
||||||
|
area.x,
|
||||||
|
y,
|
||||||
"─".repeat(area.width as usize),
|
"─".repeat(area.width as usize),
|
||||||
Style::default().fg(Color::DarkGray));
|
Style::default().fg(Color::DarkGray),
|
||||||
|
);
|
||||||
y += 1;
|
y += 1;
|
||||||
}
|
}
|
||||||
if y < area.y + area.height {
|
if y < area.y + area.height {
|
||||||
buf.set_string(area.x, y,
|
buf.set_string(
|
||||||
|
area.x,
|
||||||
|
y,
|
||||||
format!("{:<width$}", "Total", width = ROW_HEADER_WIDTH as usize),
|
format!("{:<width$}", "Total", width = ROW_HEADER_WIDTH as usize),
|
||||||
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 in visible_col_range {
|
for ci in visible_col_range {
|
||||||
if x >= area.x + area.width { break; }
|
if x >= area.x + area.width {
|
||||||
|
break;
|
||||||
|
}
|
||||||
let total: f64 = (0..layout.row_count())
|
let total: f64 = (0..layout.row_count())
|
||||||
.filter_map(|ri| layout.cell_key(ri, ci))
|
.filter_map(|ri| layout.cell_key(ri, ci))
|
||||||
.map(|key| self.model.evaluate_f64(&key))
|
.map(|key| self.model.evaluate_f64(&key))
|
||||||
.sum();
|
.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(
|
||||||
format!("{:>width$}", truncate(&total_str, COL_WIDTH as usize), width = COL_WIDTH as usize),
|
x,
|
||||||
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD));
|
y,
|
||||||
|
format!(
|
||||||
|
"{:>width$}",
|
||||||
|
truncate(&total_str, COL_WIDTH as usize),
|
||||||
|
width = COL_WIDTH as usize
|
||||||
|
),
|
||||||
|
Style::default()
|
||||||
|
.fg(Color::Yellow)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
);
|
||||||
x += COL_WIDTH;
|
x += COL_WIDTH;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -190,11 +398,9 @@ impl<'a> GridWidget<'a> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
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) {
|
||||||
let view_name = self.model.active_view
|
let view_name = self.model.active_view.clone();
|
||||||
.clone();
|
|
||||||
let block = Block::default()
|
let block = Block::default()
|
||||||
.borders(Borders::ALL)
|
.borders(Borders::ALL)
|
||||||
.title(format!(" View: {} ", view_name));
|
.title(format!(" View: {} ", view_name));
|
||||||
@ -204,13 +410,18 @@ impl<'a> Widget for GridWidget<'a> {
|
|||||||
// Page axis bar
|
// Page axis bar
|
||||||
let layout = GridLayout::new(self.model, self.model.active_view());
|
let layout = GridLayout::new(self.model, self.model.active_view());
|
||||||
if !layout.page_coords.is_empty() && inner.height > 0 {
|
if !layout.page_coords.is_empty() && inner.height > 0 {
|
||||||
let page_info: Vec<String> = layout.page_coords.iter()
|
let page_info: Vec<String> = layout
|
||||||
|
.page_coords
|
||||||
|
.iter()
|
||||||
.map(|(cat, sel)| format!("{cat} = {sel}"))
|
.map(|(cat, sel)| format!("{cat} = {sel}"))
|
||||||
.collect();
|
.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,
|
||||||
Style::default().fg(Color::Magenta));
|
Style::default().fg(Color::Magenta),
|
||||||
|
);
|
||||||
|
|
||||||
let grid_area = Rect {
|
let grid_area = Rect {
|
||||||
y: inner.y + 1,
|
y: inner.y + 1,
|
||||||
@ -234,7 +445,8 @@ fn format_value(v: Option<&CellValue>, comma: bool, decimals: u8) -> String {
|
|||||||
|
|
||||||
pub fn parse_number_format(fmt: &str) -> (bool, u8) {
|
pub fn parse_number_format(fmt: &str) -> (bool, u8) {
|
||||||
let comma = fmt.contains(',');
|
let comma = fmt.contains(',');
|
||||||
let decimals = fmt.rfind('.')
|
let decimals = fmt
|
||||||
|
.rfind('.')
|
||||||
.and_then(|i| fmt[i + 1..].parse::<u8>().ok())
|
.and_then(|i| fmt[i + 1..].parse::<u8>().ok())
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
(comma, decimals)
|
(comma, decimals)
|
||||||
@ -255,10 +467,14 @@ pub fn format_f64(n: f64, comma: bool, decimals: u8) -> String {
|
|||||||
let digits = if is_neg { &int_part[1..] } else { int_part };
|
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('-');
|
||||||
|
}
|
||||||
let mut out: String = result.chars().rev().collect();
|
let mut out: String = result.chars().rev().collect();
|
||||||
if let Some(dec) = dec_part {
|
if let Some(dec) = dec_part {
|
||||||
out.push_str(dec);
|
out.push_str(dec);
|
||||||
@ -275,7 +491,9 @@ fn truncate(s: &str, max_width: usize) -> String {
|
|||||||
let mut width = 0;
|
let mut width = 0;
|
||||||
for c in s.chars() {
|
for c in s.chars() {
|
||||||
let cw = unicode_width::UnicodeWidthChar::width(c).unwrap_or(1);
|
let cw = unicode_width::UnicodeWidthChar::width(c).unwrap_or(1);
|
||||||
if width + cw + 1 > max_width { break; }
|
if width + cw + 1 > max_width {
|
||||||
|
break;
|
||||||
|
}
|
||||||
result.push(c);
|
result.push(c);
|
||||||
width += cw;
|
width += cw;
|
||||||
}
|
}
|
||||||
@ -290,11 +508,11 @@ fn truncate(s: &str, max_width: usize) -> String {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use ratatui::{buffer::Buffer, layout::Rect, widgets::Widget};
|
use ratatui::{buffer::Buffer, layout::Rect, widgets::Widget};
|
||||||
|
|
||||||
use crate::model::Model;
|
|
||||||
use crate::model::cell::{CellKey, CellValue};
|
|
||||||
use crate::formula::parse_formula;
|
|
||||||
use crate::ui::app::AppMode;
|
|
||||||
use super::GridWidget;
|
use super::GridWidget;
|
||||||
|
use crate::formula::parse_formula;
|
||||||
|
use crate::model::cell::{CellKey, CellValue};
|
||||||
|
use crate::model::Model;
|
||||||
|
use crate::ui::app::AppMode;
|
||||||
|
|
||||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@ -311,20 +529,31 @@ mod tests {
|
|||||||
let w = buf.area().width as usize;
|
let w = buf.area().width as usize;
|
||||||
buf.content()
|
buf.content()
|
||||||
.chunks(w)
|
.chunks(w)
|
||||||
.map(|row| row.iter().map(|c| c.symbol()).collect::<String>().trim_end().to_string())
|
.map(|row| {
|
||||||
|
row.iter()
|
||||||
|
.map(|c| c.symbol())
|
||||||
|
.collect::<String>()
|
||||||
|
.trim_end()
|
||||||
|
.to_string()
|
||||||
|
})
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join("\n")
|
.join("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
fn coord(pairs: &[(&str, &str)]) -> CellKey {
|
fn coord(pairs: &[(&str, &str)]) -> CellKey {
|
||||||
CellKey::new(pairs.iter().map(|(c, i)| (c.to_string(), i.to_string())).collect())
|
CellKey::new(
|
||||||
|
pairs
|
||||||
|
.iter()
|
||||||
|
.map(|(c, i)| (c.to_string(), i.to_string()))
|
||||||
|
.collect(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Minimal model: Type on Row, Month on Column.
|
/// Minimal model: Type on Row, Month on Column.
|
||||||
fn two_cat_model() -> Model {
|
fn two_cat_model() -> Model {
|
||||||
let mut m = Model::new("Test");
|
let mut m = Model::new("Test");
|
||||||
m.add_category("Type").unwrap(); // → Row
|
m.add_category("Type").unwrap(); // → Row
|
||||||
m.add_category("Month").unwrap(); // → Column
|
m.add_category("Month").unwrap(); // → Column
|
||||||
if let Some(c) = m.category_mut("Type") {
|
if let Some(c) = m.category_mut("Type") {
|
||||||
c.add_item("Food");
|
c.add_item("Food");
|
||||||
c.add_item("Clothing");
|
c.add_item("Clothing");
|
||||||
@ -342,8 +571,8 @@ mod tests {
|
|||||||
fn column_headers_appear() {
|
fn column_headers_appear() {
|
||||||
let m = two_cat_model();
|
let m = two_cat_model();
|
||||||
let text = buf_text(&render(&m, 80, 24));
|
let text = buf_text(&render(&m, 80, 24));
|
||||||
assert!(text.contains("Jan"), "expected 'Jan' in:\n{text}");
|
assert!(text.contains("Jan"), "expected 'Jan' in:\n{text}");
|
||||||
assert!(text.contains("Feb"), "expected 'Feb' in:\n{text}");
|
assert!(text.contains("Feb"), "expected 'Feb' in:\n{text}");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Row headers ───────────────────────────────────────────────────────────
|
// ── Row headers ───────────────────────────────────────────────────────────
|
||||||
@ -352,7 +581,7 @@ mod tests {
|
|||||||
fn row_headers_appear() {
|
fn row_headers_appear() {
|
||||||
let m = two_cat_model();
|
let m = two_cat_model();
|
||||||
let text = buf_text(&render(&m, 80, 24));
|
let text = buf_text(&render(&m, 80, 24));
|
||||||
assert!(text.contains("Food"), "expected 'Food' in:\n{text}");
|
assert!(text.contains("Food"), "expected 'Food' in:\n{text}");
|
||||||
assert!(text.contains("Clothing"), "expected 'Clothing' in:\n{text}");
|
assert!(text.contains("Clothing"), "expected 'Clothing' in:\n{text}");
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -361,7 +590,10 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn cell_value_appears_in_correct_position() {
|
fn cell_value_appears_in_correct_position() {
|
||||||
let mut m = two_cat_model();
|
let mut m = two_cat_model();
|
||||||
m.set_cell(coord(&[("Type", "Food"), ("Month", "Jan")]), CellValue::Number(123.0));
|
m.set_cell(
|
||||||
|
coord(&[("Type", "Food"), ("Month", "Jan")]),
|
||||||
|
CellValue::Number(123.0),
|
||||||
|
);
|
||||||
let text = buf_text(&render(&m, 80, 24));
|
let text = buf_text(&render(&m, 80, 24));
|
||||||
assert!(text.contains("123"), "expected '123' in:\n{text}");
|
assert!(text.contains("123"), "expected '123' in:\n{text}");
|
||||||
}
|
}
|
||||||
@ -369,13 +601,22 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn multiple_cell_values_all_appear() {
|
fn multiple_cell_values_all_appear() {
|
||||||
let mut m = two_cat_model();
|
let mut m = two_cat_model();
|
||||||
m.set_cell(coord(&[("Type", "Food"), ("Month", "Jan")]), CellValue::Number(100.0));
|
m.set_cell(
|
||||||
m.set_cell(coord(&[("Type", "Food"), ("Month", "Feb")]), CellValue::Number(200.0));
|
coord(&[("Type", "Food"), ("Month", "Jan")]),
|
||||||
m.set_cell(coord(&[("Type", "Clothing"), ("Month", "Jan")]), CellValue::Number(50.0));
|
CellValue::Number(100.0),
|
||||||
|
);
|
||||||
|
m.set_cell(
|
||||||
|
coord(&[("Type", "Food"), ("Month", "Feb")]),
|
||||||
|
CellValue::Number(200.0),
|
||||||
|
);
|
||||||
|
m.set_cell(
|
||||||
|
coord(&[("Type", "Clothing"), ("Month", "Jan")]),
|
||||||
|
CellValue::Number(50.0),
|
||||||
|
);
|
||||||
let text = buf_text(&render(&m, 80, 24));
|
let text = buf_text(&render(&m, 80, 24));
|
||||||
assert!(text.contains("100"), "expected '100' in:\n{text}");
|
assert!(text.contains("100"), "expected '100' in:\n{text}");
|
||||||
assert!(text.contains("200"), "expected '200' in:\n{text}");
|
assert!(text.contains("200"), "expected '200' in:\n{text}");
|
||||||
assert!(text.contains("50"), "expected '50' in:\n{text}");
|
assert!(text.contains("50"), "expected '50' in:\n{text}");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -399,11 +640,20 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn total_row_sums_column_correctly() {
|
fn total_row_sums_column_correctly() {
|
||||||
let mut m = two_cat_model();
|
let mut m = two_cat_model();
|
||||||
m.set_cell(coord(&[("Type", "Food"), ("Month", "Jan")]), CellValue::Number(100.0));
|
m.set_cell(
|
||||||
m.set_cell(coord(&[("Type", "Clothing"), ("Month", "Jan")]), CellValue::Number(50.0));
|
coord(&[("Type", "Food"), ("Month", "Jan")]),
|
||||||
|
CellValue::Number(100.0),
|
||||||
|
);
|
||||||
|
m.set_cell(
|
||||||
|
coord(&[("Type", "Clothing"), ("Month", "Jan")]),
|
||||||
|
CellValue::Number(50.0),
|
||||||
|
);
|
||||||
let text = buf_text(&render(&m, 80, 24));
|
let text = buf_text(&render(&m, 80, 24));
|
||||||
// Food(100) + Clothing(50) = 150 for Jan
|
// Food(100) + Clothing(50) = 150 for Jan
|
||||||
assert!(text.contains("150"), "expected '150' (total for Jan) in:\n{text}");
|
assert!(
|
||||||
|
text.contains("150"),
|
||||||
|
"expected '150' (total for Jan) in:\n{text}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Page filter bar ───────────────────────────────────────────────────────
|
// ── Page filter bar ───────────────────────────────────────────────────────
|
||||||
@ -411,18 +661,25 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn page_filter_bar_shows_category_and_selection() {
|
fn page_filter_bar_shows_category_and_selection() {
|
||||||
let mut m = Model::new("Test");
|
let mut m = Model::new("Test");
|
||||||
m.add_category("Type").unwrap(); // → Row
|
m.add_category("Type").unwrap(); // → Row
|
||||||
m.add_category("Month").unwrap(); // → Column
|
m.add_category("Month").unwrap(); // → Column
|
||||||
m.add_category("Payer").unwrap(); // → Page
|
m.add_category("Payer").unwrap(); // → Page
|
||||||
if let Some(c) = m.category_mut("Type") { c.add_item("Food"); }
|
if let Some(c) = m.category_mut("Type") {
|
||||||
if let Some(c) = m.category_mut("Month") { c.add_item("Jan"); }
|
c.add_item("Food");
|
||||||
|
}
|
||||||
|
if let Some(c) = m.category_mut("Month") {
|
||||||
|
c.add_item("Jan");
|
||||||
|
}
|
||||||
if let Some(c) = m.category_mut("Payer") {
|
if let Some(c) = m.category_mut("Payer") {
|
||||||
c.add_item("Alice");
|
c.add_item("Alice");
|
||||||
c.add_item("Bob");
|
c.add_item("Bob");
|
||||||
}
|
}
|
||||||
m.active_view_mut().set_page_selection("Payer", "Bob");
|
m.active_view_mut().set_page_selection("Payer", "Bob");
|
||||||
let text = buf_text(&render(&m, 80, 24));
|
let text = buf_text(&render(&m, 80, 24));
|
||||||
assert!(text.contains("Payer = Bob"), "expected 'Payer = Bob' in:\n{text}");
|
assert!(
|
||||||
|
text.contains("Payer = Bob"),
|
||||||
|
"expected 'Payer = Bob' in:\n{text}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -431,15 +688,22 @@ mod tests {
|
|||||||
m.add_category("Type").unwrap();
|
m.add_category("Type").unwrap();
|
||||||
m.add_category("Month").unwrap();
|
m.add_category("Month").unwrap();
|
||||||
m.add_category("Payer").unwrap();
|
m.add_category("Payer").unwrap();
|
||||||
if let Some(c) = m.category_mut("Type") { c.add_item("Food"); }
|
if let Some(c) = m.category_mut("Type") {
|
||||||
if let Some(c) = m.category_mut("Month") { c.add_item("Jan"); }
|
c.add_item("Food");
|
||||||
|
}
|
||||||
|
if let Some(c) = m.category_mut("Month") {
|
||||||
|
c.add_item("Jan");
|
||||||
|
}
|
||||||
if let Some(c) = m.category_mut("Payer") {
|
if let Some(c) = m.category_mut("Payer") {
|
||||||
c.add_item("Alice");
|
c.add_item("Alice");
|
||||||
c.add_item("Bob");
|
c.add_item("Bob");
|
||||||
}
|
}
|
||||||
// No explicit selection — should default to first item
|
// No explicit selection — should default to first item
|
||||||
let text = buf_text(&render(&m, 80, 24));
|
let text = buf_text(&render(&m, 80, 24));
|
||||||
assert!(text.contains("Payer = Alice"), "expected 'Payer = Alice' in:\n{text}");
|
assert!(
|
||||||
|
text.contains("Payer = Alice"),
|
||||||
|
"expected 'Payer = Alice' in:\n{text}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Formula evaluation ────────────────────────────────────────────────────
|
// ── Formula evaluation ────────────────────────────────────────────────────
|
||||||
@ -447,16 +711,24 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn formula_cell_renders_computed_value() {
|
fn formula_cell_renders_computed_value() {
|
||||||
let mut m = Model::new("Test");
|
let mut m = Model::new("Test");
|
||||||
m.add_category("Measure").unwrap(); // → Row
|
m.add_category("Measure").unwrap(); // → Row
|
||||||
m.add_category("Region").unwrap(); // → Column
|
m.add_category("Region").unwrap(); // → Column
|
||||||
if let Some(c) = m.category_mut("Measure") {
|
if let Some(c) = m.category_mut("Measure") {
|
||||||
c.add_item("Revenue");
|
c.add_item("Revenue");
|
||||||
c.add_item("Cost");
|
c.add_item("Cost");
|
||||||
c.add_item("Profit");
|
c.add_item("Profit");
|
||||||
}
|
}
|
||||||
if let Some(c) = m.category_mut("Region") { c.add_item("East"); }
|
if let Some(c) = m.category_mut("Region") {
|
||||||
m.set_cell(coord(&[("Measure", "Revenue"), ("Region", "East")]), CellValue::Number(1000.0));
|
c.add_item("East");
|
||||||
m.set_cell(coord(&[("Measure", "Cost"), ("Region", "East")]), CellValue::Number(600.0));
|
}
|
||||||
|
m.set_cell(
|
||||||
|
coord(&[("Measure", "Revenue"), ("Region", "East")]),
|
||||||
|
CellValue::Number(1000.0),
|
||||||
|
);
|
||||||
|
m.set_cell(
|
||||||
|
coord(&[("Measure", "Cost"), ("Region", "East")]),
|
||||||
|
CellValue::Number(600.0),
|
||||||
|
);
|
||||||
m.add_formula(parse_formula("Profit = Revenue - Cost", "Measure").unwrap());
|
m.add_formula(parse_formula("Profit = Revenue - Cost", "Measure").unwrap());
|
||||||
|
|
||||||
let text = buf_text(&render(&m, 80, 24));
|
let text = buf_text(&render(&m, 80, 24));
|
||||||
@ -468,23 +740,38 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn two_row_categories_produce_cross_product_labels() {
|
fn two_row_categories_produce_cross_product_labels() {
|
||||||
let mut m = Model::new("Test");
|
let mut m = Model::new("Test");
|
||||||
m.add_category("Type").unwrap(); // → Row
|
m.add_category("Type").unwrap(); // → Row
|
||||||
m.add_category("Month").unwrap(); // → Column
|
m.add_category("Month").unwrap(); // → Column
|
||||||
m.add_category("Recipient").unwrap(); // → Page by default; move to Row
|
m.add_category("Recipient").unwrap(); // → Page by default; move to Row
|
||||||
if let Some(c) = m.category_mut("Type") { c.add_item("Food"); c.add_item("Clothing"); }
|
if let Some(c) = m.category_mut("Type") {
|
||||||
if let Some(c) = m.category_mut("Month") { c.add_item("Jan"); }
|
c.add_item("Food");
|
||||||
if let Some(c) = m.category_mut("Recipient") { c.add_item("Alice"); c.add_item("Bob"); }
|
c.add_item("Clothing");
|
||||||
m.active_view_mut().set_axis("Recipient", crate::view::Axis::Row);
|
}
|
||||||
|
if let Some(c) = m.category_mut("Month") {
|
||||||
|
c.add_item("Jan");
|
||||||
|
}
|
||||||
|
if let Some(c) = m.category_mut("Recipient") {
|
||||||
|
c.add_item("Alice");
|
||||||
|
c.add_item("Bob");
|
||||||
|
}
|
||||||
|
m.active_view_mut()
|
||||||
|
.set_axis("Recipient", crate::view::Axis::Row);
|
||||||
|
|
||||||
let text = buf_text(&render(&m, 80, 24));
|
let text = buf_text(&render(&m, 80, 24));
|
||||||
// Multi-level row headers: category values shown separately, not joined with /
|
// Multi-level row headers: category values shown separately, not joined with /
|
||||||
assert!(!text.contains("Food/Alice"), "slash-joined labels should be gone:\n{text}");
|
assert!(
|
||||||
assert!(!text.contains("Clothing/Bob"), "slash-joined labels should be gone:\n{text}");
|
!text.contains("Food/Alice"),
|
||||||
|
"slash-joined labels should be gone:\n{text}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!text.contains("Clothing/Bob"),
|
||||||
|
"slash-joined labels should be gone:\n{text}"
|
||||||
|
);
|
||||||
// Each item name appears on its own
|
// Each item name appears on its own
|
||||||
assert!(text.contains("Food"), "expected 'Food' in:\n{text}");
|
assert!(text.contains("Food"), "expected 'Food' in:\n{text}");
|
||||||
assert!(text.contains("Clothing"), "expected 'Clothing' in:\n{text}");
|
assert!(text.contains("Clothing"), "expected 'Clothing' in:\n{text}");
|
||||||
assert!(text.contains("Alice"), "expected 'Alice' in:\n{text}");
|
assert!(text.contains("Alice"), "expected 'Alice' in:\n{text}");
|
||||||
assert!(text.contains("Bob"), "expected 'Bob' in:\n{text}");
|
assert!(text.contains("Bob"), "expected 'Bob' in:\n{text}");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -493,10 +780,18 @@ mod tests {
|
|||||||
m.add_category("Type").unwrap();
|
m.add_category("Type").unwrap();
|
||||||
m.add_category("Month").unwrap();
|
m.add_category("Month").unwrap();
|
||||||
m.add_category("Recipient").unwrap();
|
m.add_category("Recipient").unwrap();
|
||||||
if let Some(c) = m.category_mut("Type") { c.add_item("Food"); }
|
if let Some(c) = m.category_mut("Type") {
|
||||||
if let Some(c) = m.category_mut("Month") { c.add_item("Jan"); }
|
c.add_item("Food");
|
||||||
if let Some(c) = m.category_mut("Recipient") { c.add_item("Alice"); c.add_item("Bob"); }
|
}
|
||||||
m.active_view_mut().set_axis("Recipient", crate::view::Axis::Row);
|
if let Some(c) = m.category_mut("Month") {
|
||||||
|
c.add_item("Jan");
|
||||||
|
}
|
||||||
|
if let Some(c) = m.category_mut("Recipient") {
|
||||||
|
c.add_item("Alice");
|
||||||
|
c.add_item("Bob");
|
||||||
|
}
|
||||||
|
m.active_view_mut()
|
||||||
|
.set_axis("Recipient", crate::view::Axis::Row);
|
||||||
// Set data at the full 3-coordinate key
|
// Set data at the full 3-coordinate key
|
||||||
m.set_cell(
|
m.set_cell(
|
||||||
coord(&[("Month", "Jan"), ("Recipient", "Alice"), ("Type", "Food")]),
|
coord(&[("Month", "Jan"), ("Recipient", "Alice"), ("Type", "Food")]),
|
||||||
@ -509,19 +804,33 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn two_column_categories_produce_cross_product_headers() {
|
fn two_column_categories_produce_cross_product_headers() {
|
||||||
let mut m = Model::new("Test");
|
let mut m = Model::new("Test");
|
||||||
m.add_category("Type").unwrap(); // → Row
|
m.add_category("Type").unwrap(); // → Row
|
||||||
m.add_category("Month").unwrap(); // → Column
|
m.add_category("Month").unwrap(); // → Column
|
||||||
m.add_category("Year").unwrap(); // → Page by default; move to Column
|
m.add_category("Year").unwrap(); // → Page by default; move to Column
|
||||||
if let Some(c) = m.category_mut("Type") { c.add_item("Food"); }
|
if let Some(c) = m.category_mut("Type") {
|
||||||
if let Some(c) = m.category_mut("Month") { c.add_item("Jan"); }
|
c.add_item("Food");
|
||||||
if let Some(c) = m.category_mut("Year") { c.add_item("2024"); c.add_item("2025"); }
|
}
|
||||||
m.active_view_mut().set_axis("Year", crate::view::Axis::Column);
|
if let Some(c) = m.category_mut("Month") {
|
||||||
|
c.add_item("Jan");
|
||||||
|
}
|
||||||
|
if let Some(c) = m.category_mut("Year") {
|
||||||
|
c.add_item("2024");
|
||||||
|
c.add_item("2025");
|
||||||
|
}
|
||||||
|
m.active_view_mut()
|
||||||
|
.set_axis("Year", crate::view::Axis::Column);
|
||||||
|
|
||||||
let text = buf_text(&render(&m, 80, 24));
|
let text = buf_text(&render(&m, 80, 24));
|
||||||
// Multi-level column headers: category values shown separately, not joined with /
|
// Multi-level column headers: category values shown separately, not joined with /
|
||||||
assert!(!text.contains("Jan/2024"), "slash-joined headers should be gone:\n{text}");
|
assert!(
|
||||||
assert!(!text.contains("Jan/2025"), "slash-joined headers should be gone:\n{text}");
|
!text.contains("Jan/2024"),
|
||||||
assert!(text.contains("Jan"), "expected 'Jan' in:\n{text}");
|
"slash-joined headers should be gone:\n{text}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!text.contains("Jan/2025"),
|
||||||
|
"slash-joined headers should be gone:\n{text}"
|
||||||
|
);
|
||||||
|
assert!(text.contains("Jan"), "expected 'Jan' in:\n{text}");
|
||||||
assert!(text.contains("2024"), "expected '2024' in:\n{text}");
|
assert!(text.contains("2024"), "expected '2024' in:\n{text}");
|
||||||
assert!(text.contains("2025"), "expected '2025' in:\n{text}");
|
assert!(text.contains("2025"), "expected '2025' in:\n{text}");
|
||||||
}
|
}
|
||||||
|
|||||||
@ -23,9 +23,11 @@ impl Widget for HelpWidget {
|
|||||||
let inner = block.inner(popup_area);
|
let inner = block.inner(popup_area);
|
||||||
block.render(popup_area, buf);
|
block.render(popup_area, buf);
|
||||||
|
|
||||||
let head = Style::default().fg(Color::Blue).add_modifier(Modifier::BOLD);
|
let head = Style::default()
|
||||||
let key = Style::default().fg(Color::Cyan);
|
.fg(Color::Blue)
|
||||||
let dim = Style::default().fg(Color::DarkGray);
|
.add_modifier(Modifier::BOLD);
|
||||||
|
let key = Style::default().fg(Color::Cyan);
|
||||||
|
let dim = Style::default().fg(Color::DarkGray);
|
||||||
let norm = Style::default();
|
let norm = Style::default();
|
||||||
|
|
||||||
// (key_col, desc_col, style)
|
// (key_col, desc_col, style)
|
||||||
@ -51,25 +53,51 @@ impl Widget for HelpWidget {
|
|||||||
("", "", norm),
|
("", "", norm),
|
||||||
("Panels", "", head),
|
("Panels", "", head),
|
||||||
(" F", "Toggle Formula panel (n:new d:del)", key),
|
(" F", "Toggle Formula panel (n:new d:del)", key),
|
||||||
(" C", "Toggle Category panel (n:new-cat a:add-items)", key),
|
(
|
||||||
|
" C",
|
||||||
|
"Toggle Category panel (n:new-cat a:add-items)",
|
||||||
|
key,
|
||||||
|
),
|
||||||
(" N", "New category quick-add (from anywhere)", key),
|
(" N", "New category quick-add (from anywhere)", key),
|
||||||
(" V", "Toggle View panel (n:new d:del Enter:switch)", key),
|
(
|
||||||
|
" V",
|
||||||
|
"Toggle View panel (n:new d:del Enter:switch)",
|
||||||
|
key,
|
||||||
|
),
|
||||||
(" Tab", "Focus next open panel", key),
|
(" Tab", "Focus next open panel", key),
|
||||||
("", "", norm),
|
("", "", norm),
|
||||||
("Pivot / Tiles", "", head),
|
("Pivot / Tiles / Groups", "", head),
|
||||||
|
(" z", "Toggle collapse nearest group above cursor", key),
|
||||||
|
(
|
||||||
|
" H",
|
||||||
|
"Hide current row item (:show-item cat item to restore)",
|
||||||
|
key,
|
||||||
|
),
|
||||||
(" T", "Tile-select mode", key),
|
(" T", "Tile-select mode", key),
|
||||||
(" ← h / → l", "Select previous/next tile", dim),
|
(" ← h / → l", "Select previous/next tile", dim),
|
||||||
(" Space / Enter", "Cycle axis (Row→Col→Page)", dim),
|
(" Space / Enter", "Cycle axis (Row→Col→Page)", dim),
|
||||||
(" r / c / p", "Set axis to Row / Col / Page", dim),
|
(" r / c / p", "Set axis to Row / Col / Page", dim),
|
||||||
("", "", norm),
|
("", "", norm),
|
||||||
("Command line ( : )", "", head),
|
("Command line ( : )", "", head),
|
||||||
(" :q :q! :wq ZZ", "Quit / force-quit / save+quit", key),
|
(
|
||||||
|
" :q :q! :wq ZZ",
|
||||||
|
"Quit / force-quit / save+quit",
|
||||||
|
key,
|
||||||
|
),
|
||||||
(" :w [path]", "Save (path optional)", key),
|
(" :w [path]", "Save (path optional)", key),
|
||||||
(" :import <path.json>", "Open JSON import wizard", key),
|
(" :import <path.json>", "Open JSON import wizard", key),
|
||||||
(" :export [path.csv]", "Export active view to CSV", key),
|
(" :export [path.csv]", "Export active view to CSV", key),
|
||||||
(" :add-cat <name>", "Add a category", key),
|
(" :add-cat <name>", "Add a category", key),
|
||||||
(" :add-item <cat> <item>", "Add one item to a category", key),
|
(
|
||||||
(" :add-items <cat> a b c…", "Add multiple items at once", key),
|
" :add-item <cat> <item>",
|
||||||
|
"Add one item to a category",
|
||||||
|
key,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
" :add-items <cat> a b c…",
|
||||||
|
"Add multiple items at once",
|
||||||
|
key,
|
||||||
|
),
|
||||||
(" :formula <cat> <Name=expr>", "Add a formula", key),
|
(" :formula <cat> <Name=expr>", "Add a formula", key),
|
||||||
(" :add-view [name]", "Create a new view", key),
|
(" :add-view [name]", "Create a new view", key),
|
||||||
("", "", norm),
|
("", "", norm),
|
||||||
@ -79,7 +107,9 @@ impl Widget for HelpWidget {
|
|||||||
|
|
||||||
let key_col_w = 32usize;
|
let key_col_w = 32usize;
|
||||||
for (i, (k, d, style)) in rows.iter().enumerate() {
|
for (i, (k, d, style)) in rows.iter().enumerate() {
|
||||||
if i >= inner.height as usize { break; }
|
if i >= inner.height as usize {
|
||||||
|
break;
|
||||||
|
}
|
||||||
let y = inner.y + i as u16;
|
let y = inner.y + i as u16;
|
||||||
if d.is_empty() {
|
if d.is_empty() {
|
||||||
buf.set_string(inner.x, y, k, *style);
|
buf.set_string(inner.x, y, k, *style);
|
||||||
|
|||||||
@ -1,62 +1,156 @@
|
|||||||
use crate::model::Model;
|
|
||||||
use crate::model::cell::CellKey;
|
use crate::model::cell::CellKey;
|
||||||
|
use crate::model::Model;
|
||||||
use crate::view::{Axis, View};
|
use crate::view::{Axis, View};
|
||||||
|
|
||||||
|
/// One entry on a grid axis: either a visual group header or a data-item tuple.
|
||||||
|
///
|
||||||
|
/// `GroupHeader` entries are always visible so the user can see the group label
|
||||||
|
/// and toggle its collapse state. `DataItem` entries are absent when their
|
||||||
|
/// group is collapsed.
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub enum AxisEntry {
|
||||||
|
GroupHeader {
|
||||||
|
cat_name: String,
|
||||||
|
group_name: String,
|
||||||
|
},
|
||||||
|
DataItem(Vec<String>),
|
||||||
|
}
|
||||||
|
|
||||||
/// The resolved 2-D layout of a view: which item tuples appear on each axis,
|
/// 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.
|
/// what page filter is active, and how to map (row, col) → CellKey.
|
||||||
///
|
///
|
||||||
/// This is the single authoritative place that converts the multi-dimensional
|
/// 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.
|
/// model into the flat grid consumed by both the terminal renderer and CSV exporter.
|
||||||
pub struct GridLayout {
|
pub struct GridLayout {
|
||||||
pub row_cats: Vec<String>,
|
pub row_cats: Vec<String>,
|
||||||
pub col_cats: Vec<String>,
|
pub col_cats: Vec<String>,
|
||||||
pub page_coords: Vec<(String, String)>,
|
pub page_coords: Vec<(String, String)>,
|
||||||
pub row_items: Vec<Vec<String>>,
|
pub row_items: Vec<AxisEntry>,
|
||||||
pub col_items: Vec<Vec<String>>,
|
pub col_items: Vec<AxisEntry>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl GridLayout {
|
impl GridLayout {
|
||||||
pub fn new(model: &Model, view: &View) -> Self {
|
pub fn new(model: &Model, view: &View) -> Self {
|
||||||
let row_cats: Vec<String> = view.categories_on(Axis::Row)
|
let row_cats: Vec<String> = view
|
||||||
.into_iter().map(String::from).collect();
|
.categories_on(Axis::Row)
|
||||||
let col_cats: Vec<String> = view.categories_on(Axis::Column)
|
.into_iter()
|
||||||
.into_iter().map(String::from).collect();
|
.map(String::from)
|
||||||
let page_cats: Vec<String> = view.categories_on(Axis::Page)
|
.collect();
|
||||||
.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 page_coords = page_cats
|
||||||
let items: Vec<String> = model.category(cat)
|
.iter()
|
||||||
.map(|c| c.ordered_item_names().into_iter().map(String::from).collect())
|
.map(|cat| {
|
||||||
.unwrap_or_default();
|
let items: Vec<String> = model
|
||||||
let sel = view.page_selection(cat)
|
.category(cat)
|
||||||
.map(String::from)
|
.map(|c| {
|
||||||
.or_else(|| items.first().cloned())
|
c.ordered_item_names()
|
||||||
.unwrap_or_default();
|
.into_iter()
|
||||||
(cat.clone(), sel)
|
.map(String::from)
|
||||||
}).collect();
|
.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 row_items = cross_product(model, view, &row_cats);
|
||||||
let col_items = cross_product(model, view, &col_cats);
|
let col_items = cross_product(model, view, &col_cats);
|
||||||
|
|
||||||
Self { row_cats, col_cats, page_coords, row_items, col_items }
|
Self {
|
||||||
|
row_cats,
|
||||||
|
col_cats,
|
||||||
|
page_coords,
|
||||||
|
row_items,
|
||||||
|
col_items,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn row_count(&self) -> usize { self.row_items.len() }
|
/// Number of data rows (group headers excluded).
|
||||||
pub fn col_count(&self) -> usize { self.col_items.len() }
|
pub fn row_count(&self) -> usize {
|
||||||
|
self.row_items
|
||||||
|
.iter()
|
||||||
|
.filter(|e| matches!(e, AxisEntry::DataItem(_)))
|
||||||
|
.count()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Number of data columns (group headers excluded).
|
||||||
|
pub fn col_count(&self) -> usize {
|
||||||
|
self.col_items
|
||||||
|
.iter()
|
||||||
|
.filter(|e| matches!(e, AxisEntry::DataItem(_)))
|
||||||
|
.count()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn row_label(&self, row: usize) -> String {
|
pub fn row_label(&self, row: usize) -> String {
|
||||||
self.row_items.get(row).map(|r| r.join("/")).unwrap_or_default()
|
self.row_items
|
||||||
|
.iter()
|
||||||
|
.filter_map(|e| {
|
||||||
|
if let AxisEntry::DataItem(v) = e {
|
||||||
|
Some(v)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.nth(row)
|
||||||
|
.map(|r| r.join("/"))
|
||||||
|
.unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn col_label(&self, col: usize) -> String {
|
pub fn col_label(&self, col: usize) -> String {
|
||||||
self.col_items.get(col).map(|c| c.join("/")).unwrap_or_default()
|
self.col_items
|
||||||
|
.iter()
|
||||||
|
.filter_map(|e| {
|
||||||
|
if let AxisEntry::DataItem(v) = e {
|
||||||
|
Some(v)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.nth(col)
|
||||||
|
.map(|c| c.join("/"))
|
||||||
|
.unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build the CellKey for the data cell at (row, col), including the active
|
/// 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.
|
/// page-axis filter. Returns None if row or col is out of bounds.
|
||||||
pub fn cell_key(&self, row: usize, col: usize) -> Option<CellKey> {
|
pub fn cell_key(&self, row: usize, col: usize) -> Option<CellKey> {
|
||||||
let row_item = self.row_items.get(row)?;
|
let row_item = self
|
||||||
let col_item = self.col_items.get(col)?;
|
.row_items
|
||||||
|
.iter()
|
||||||
|
.filter_map(|e| {
|
||||||
|
if let AxisEntry::DataItem(v) = e {
|
||||||
|
Some(v)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.nth(row)?;
|
||||||
|
let col_item = self
|
||||||
|
.col_items
|
||||||
|
.iter()
|
||||||
|
.filter_map(|e| {
|
||||||
|
if let AxisEntry::DataItem(v) = e {
|
||||||
|
Some(v)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.nth(col)?;
|
||||||
let mut coords = self.page_coords.clone();
|
let mut coords = self.page_coords.clone();
|
||||||
for (cat, item) in self.row_cats.iter().zip(row_item.iter()) {
|
for (cat, item) in self.row_cats.iter().zip(row_item.iter()) {
|
||||||
coords.push((cat.clone(), item.clone()));
|
coords.push((cat.clone(), item.clone()));
|
||||||
@ -66,48 +160,126 @@ impl GridLayout {
|
|||||||
}
|
}
|
||||||
Some(CellKey::new(coords))
|
Some(CellKey::new(coords))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Visual index of the nth data row (skipping group headers).
|
||||||
|
pub fn data_row_to_visual(&self, data_row: usize) -> Option<usize> {
|
||||||
|
let mut count = 0;
|
||||||
|
for (vi, entry) in self.row_items.iter().enumerate() {
|
||||||
|
if let AxisEntry::DataItem(_) = entry {
|
||||||
|
if count == data_row {
|
||||||
|
return Some(vi);
|
||||||
|
}
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Visual index of the nth data column (skipping group headers).
|
||||||
|
pub fn data_col_to_visual(&self, data_col: usize) -> Option<usize> {
|
||||||
|
let mut count = 0;
|
||||||
|
for (vi, entry) in self.col_items.iter().enumerate() {
|
||||||
|
if let AxisEntry::DataItem(_) = entry {
|
||||||
|
if count == data_col {
|
||||||
|
return Some(vi);
|
||||||
|
}
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Expand a single category into `AxisEntry` values, given a coordinate prefix.
|
||||||
|
/// Emits a `GroupHeader` at each group boundary, then `DataItem` entries for
|
||||||
|
/// visible, non-collapsed items.
|
||||||
|
fn expand_category(
|
||||||
|
model: &Model,
|
||||||
|
view: &View,
|
||||||
|
cat_name: &str,
|
||||||
|
prefix: Vec<String>,
|
||||||
|
) -> Vec<AxisEntry> {
|
||||||
|
let Some(cat) = model.category(cat_name) else {
|
||||||
|
return vec![];
|
||||||
|
};
|
||||||
|
let mut result = Vec::new();
|
||||||
|
let mut last_group: Option<&str> = None;
|
||||||
|
|
||||||
|
for item_name in cat.ordered_item_names() {
|
||||||
|
if view.is_hidden(cat_name, item_name) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let item_group = cat.items.get(item_name).and_then(|i| i.group.as_deref());
|
||||||
|
|
||||||
|
// Emit a group header at each group boundary.
|
||||||
|
if item_group != last_group {
|
||||||
|
if let Some(g) = item_group {
|
||||||
|
result.push(AxisEntry::GroupHeader {
|
||||||
|
cat_name: cat_name.to_string(),
|
||||||
|
group_name: g.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
last_group = item_group;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip the data item if its group is collapsed.
|
||||||
|
if item_group.map_or(false, |g| view.is_group_collapsed(cat_name, g)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut row = prefix.clone();
|
||||||
|
row.push(item_name.to_string());
|
||||||
|
result.push(AxisEntry::DataItem(row));
|
||||||
|
}
|
||||||
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Cartesian product of visible items across `cats`, in category order.
|
/// Cartesian product of visible items across `cats`, in category order.
|
||||||
/// Hidden items are excluded. Returns `vec![vec![]]` when `cats` is empty.
|
/// Hidden items and items in collapsed groups are excluded from `DataItem`
|
||||||
fn cross_product(model: &Model, view: &View, cats: &[String]) -> Vec<Vec<String>> {
|
/// entries; group headers are always emitted.
|
||||||
|
/// Returns `vec![DataItem(vec![])]` when `cats` is empty.
|
||||||
|
fn cross_product(model: &Model, view: &View, cats: &[String]) -> Vec<AxisEntry> {
|
||||||
if cats.is_empty() {
|
if cats.is_empty() {
|
||||||
return vec![vec![]];
|
return vec![AxisEntry::DataItem(vec![])];
|
||||||
}
|
}
|
||||||
let mut result: Vec<Vec<String>> = vec![vec![]];
|
let mut result: Vec<AxisEntry> = vec![AxisEntry::DataItem(vec![])];
|
||||||
for cat_name in cats {
|
for cat_name in cats {
|
||||||
let items: Vec<String> = model.category(cat_name)
|
result = result
|
||||||
.map(|c| c.ordered_item_names().into_iter()
|
.into_iter()
|
||||||
.filter(|item| !view.is_hidden(cat_name, item))
|
.flat_map(|entry| match entry {
|
||||||
.map(String::from).collect())
|
AxisEntry::DataItem(prefix) => expand_category(model, view, cat_name, prefix),
|
||||||
.unwrap_or_default();
|
header @ AxisEntry::GroupHeader { .. } => vec![header],
|
||||||
result = result.into_iter().flat_map(|prefix| {
|
|
||||||
items.iter().map(move |item| {
|
|
||||||
let mut row = prefix.clone();
|
|
||||||
row.push(item.clone());
|
|
||||||
row
|
|
||||||
})
|
})
|
||||||
}).collect();
|
.collect();
|
||||||
}
|
}
|
||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::GridLayout;
|
use super::{AxisEntry, GridLayout};
|
||||||
use crate::model::Model;
|
|
||||||
use crate::model::cell::{CellKey, CellValue};
|
use crate::model::cell::{CellKey, CellValue};
|
||||||
|
use crate::model::Model;
|
||||||
|
|
||||||
fn coord(pairs: &[(&str, &str)]) -> CellKey {
|
fn coord(pairs: &[(&str, &str)]) -> CellKey {
|
||||||
CellKey::new(pairs.iter().map(|(c, i)| (c.to_string(), i.to_string())).collect())
|
CellKey::new(
|
||||||
|
pairs
|
||||||
|
.iter()
|
||||||
|
.map(|(c, i)| (c.to_string(), i.to_string()))
|
||||||
|
.collect(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn two_cat_model() -> Model {
|
fn two_cat_model() -> Model {
|
||||||
let mut m = Model::new("T");
|
let mut m = Model::new("T");
|
||||||
m.add_category("Type").unwrap();
|
m.add_category("Type").unwrap();
|
||||||
m.add_category("Month").unwrap();
|
m.add_category("Month").unwrap();
|
||||||
for item in ["Food", "Clothing"] { m.category_mut("Type").unwrap().add_item(item); }
|
for item in ["Food", "Clothing"] {
|
||||||
for item in ["Jan", "Feb"] { m.category_mut("Month").unwrap().add_item(item); }
|
m.category_mut("Type").unwrap().add_item(item);
|
||||||
|
}
|
||||||
|
for item in ["Jan", "Feb"] {
|
||||||
|
m.category_mut("Month").unwrap().add_item(item);
|
||||||
|
}
|
||||||
m
|
m
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -174,8 +346,141 @@ mod tests {
|
|||||||
m.category_mut("Type").unwrap().add_item("Food");
|
m.category_mut("Type").unwrap().add_item("Food");
|
||||||
m.category_mut("Month").unwrap().add_item("Jan");
|
m.category_mut("Month").unwrap().add_item("Jan");
|
||||||
m.category_mut("Year").unwrap().add_item("2025");
|
m.category_mut("Year").unwrap().add_item("2025");
|
||||||
m.active_view_mut().set_axis("Year", crate::view::Axis::Column);
|
m.active_view_mut()
|
||||||
|
.set_axis("Year", crate::view::Axis::Column);
|
||||||
let layout = GridLayout::new(&m, m.active_view());
|
let layout = GridLayout::new(&m, m.active_view());
|
||||||
assert_eq!(layout.col_label(0), "Jan/2025");
|
assert_eq!(layout.col_label(0), "Jan/2025");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn row_count_excludes_group_headers() {
|
||||||
|
let mut m = Model::new("T");
|
||||||
|
m.add_category("Month").unwrap();
|
||||||
|
m.add_category("Type").unwrap();
|
||||||
|
m.category_mut("Month")
|
||||||
|
.unwrap()
|
||||||
|
.add_item_in_group("Jan", "Q1");
|
||||||
|
m.category_mut("Month")
|
||||||
|
.unwrap()
|
||||||
|
.add_item_in_group("Feb", "Q1");
|
||||||
|
m.category_mut("Month")
|
||||||
|
.unwrap()
|
||||||
|
.add_item_in_group("Apr", "Q2");
|
||||||
|
m.category_mut("Type").unwrap().add_item("Food");
|
||||||
|
let layout = GridLayout::new(&m, m.active_view());
|
||||||
|
assert_eq!(layout.row_count(), 3); // Jan, Feb, Apr — headers don't count
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn group_header_emitted_at_group_boundary() {
|
||||||
|
let mut m = Model::new("T");
|
||||||
|
m.add_category("Month").unwrap();
|
||||||
|
m.add_category("Type").unwrap();
|
||||||
|
m.category_mut("Month")
|
||||||
|
.unwrap()
|
||||||
|
.add_item_in_group("Jan", "Q1");
|
||||||
|
m.category_mut("Month")
|
||||||
|
.unwrap()
|
||||||
|
.add_item_in_group("Apr", "Q2");
|
||||||
|
m.category_mut("Type").unwrap().add_item("Food");
|
||||||
|
let layout = GridLayout::new(&m, m.active_view());
|
||||||
|
let headers: Vec<_> = layout
|
||||||
|
.row_items
|
||||||
|
.iter()
|
||||||
|
.filter(|e| matches!(e, AxisEntry::GroupHeader { .. }))
|
||||||
|
.collect();
|
||||||
|
assert_eq!(headers.len(), 2);
|
||||||
|
assert!(
|
||||||
|
matches!(&headers[0], AxisEntry::GroupHeader { group_name, .. } if group_name == "Q1")
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
matches!(&headers[1], AxisEntry::GroupHeader { group_name, .. } if group_name == "Q2")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn collapsed_group_has_header_but_no_data_items() {
|
||||||
|
let mut m = Model::new("T");
|
||||||
|
m.add_category("Month").unwrap();
|
||||||
|
m.add_category("Type").unwrap();
|
||||||
|
m.category_mut("Month")
|
||||||
|
.unwrap()
|
||||||
|
.add_item_in_group("Jan", "Q1");
|
||||||
|
m.category_mut("Month")
|
||||||
|
.unwrap()
|
||||||
|
.add_item_in_group("Feb", "Q1");
|
||||||
|
m.category_mut("Month")
|
||||||
|
.unwrap()
|
||||||
|
.add_item_in_group("Apr", "Q2");
|
||||||
|
m.category_mut("Type").unwrap().add_item("Food");
|
||||||
|
m.active_view_mut().toggle_group_collapse("Month", "Q1");
|
||||||
|
let layout = GridLayout::new(&m, m.active_view());
|
||||||
|
// Q1 collapsed: header present, Jan and Feb absent; Q2 intact
|
||||||
|
assert_eq!(layout.row_count(), 1); // only Apr
|
||||||
|
let q1_header = layout
|
||||||
|
.row_items
|
||||||
|
.iter()
|
||||||
|
.find(|e| matches!(e, AxisEntry::GroupHeader { group_name, .. } if group_name == "Q1"));
|
||||||
|
assert!(q1_header.is_some());
|
||||||
|
let jan = layout
|
||||||
|
.row_items
|
||||||
|
.iter()
|
||||||
|
.find(|e| matches!(e, AxisEntry::DataItem(v) if v.contains(&"Jan".to_string())));
|
||||||
|
assert!(jan.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ungrouped_items_produce_no_headers() {
|
||||||
|
let m = two_cat_model();
|
||||||
|
let layout = GridLayout::new(&m, m.active_view());
|
||||||
|
assert!(!layout
|
||||||
|
.row_items
|
||||||
|
.iter()
|
||||||
|
.any(|e| matches!(e, AxisEntry::GroupHeader { .. })));
|
||||||
|
assert!(!layout
|
||||||
|
.col_items
|
||||||
|
.iter()
|
||||||
|
.any(|e| matches!(e, AxisEntry::GroupHeader { .. })));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cell_key_correct_with_grouped_items() {
|
||||||
|
let mut m = Model::new("T");
|
||||||
|
m.add_category("Month").unwrap();
|
||||||
|
m.add_category("Type").unwrap();
|
||||||
|
m.category_mut("Month")
|
||||||
|
.unwrap()
|
||||||
|
.add_item_in_group("Jan", "Q1");
|
||||||
|
m.category_mut("Month")
|
||||||
|
.unwrap()
|
||||||
|
.add_item_in_group("Apr", "Q2");
|
||||||
|
m.category_mut("Type").unwrap().add_item("Food");
|
||||||
|
m.set_cell(
|
||||||
|
coord(&[("Month", "Apr"), ("Type", "Food")]),
|
||||||
|
CellValue::Number(99.0),
|
||||||
|
);
|
||||||
|
let layout = GridLayout::new(&m, m.active_view());
|
||||||
|
// data row 0 = Jan, data row 1 = Apr
|
||||||
|
let key = layout.cell_key(1, 0).unwrap();
|
||||||
|
assert_eq!(m.evaluate(&key), Some(CellValue::Number(99.0)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn data_row_to_visual_skips_headers() {
|
||||||
|
let mut m = Model::new("T");
|
||||||
|
m.add_category("Month").unwrap();
|
||||||
|
m.add_category("Type").unwrap();
|
||||||
|
m.category_mut("Month")
|
||||||
|
.unwrap()
|
||||||
|
.add_item_in_group("Jan", "Q1");
|
||||||
|
m.category_mut("Month")
|
||||||
|
.unwrap()
|
||||||
|
.add_item_in_group("Apr", "Q2");
|
||||||
|
m.category_mut("Type").unwrap().add_item("Food");
|
||||||
|
let layout = GridLayout::new(&m, m.active_view());
|
||||||
|
// visual: [GroupHeader(Q1), DataItem(Jan), GroupHeader(Q2), DataItem(Apr)]
|
||||||
|
assert_eq!(layout.data_row_to_visual(0), Some(1)); // Jan is at visual index 1
|
||||||
|
assert_eq!(layout.data_row_to_visual(1), Some(3)); // Apr is at visual index 3
|
||||||
|
assert_eq!(layout.data_row_to_visual(2), None);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
pub mod view;
|
|
||||||
pub mod axis;
|
pub mod axis;
|
||||||
pub mod layout;
|
pub mod layout;
|
||||||
|
pub mod view;
|
||||||
|
|
||||||
pub use view::View;
|
|
||||||
pub use axis::Axis;
|
pub use axis::Axis;
|
||||||
pub use layout::GridLayout;
|
pub use layout::{AxisEntry, GridLayout};
|
||||||
|
pub use view::View;
|
||||||
|
|||||||
135
src/view/view.rs
135
src/view/view.rs
@ -1,6 +1,6 @@
|
|||||||
use std::collections::{HashMap, HashSet};
|
|
||||||
use indexmap::IndexMap;
|
use indexmap::IndexMap;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
|
|
||||||
use super::axis::Axis;
|
use super::axis::Axis;
|
||||||
|
|
||||||
@ -62,20 +62,23 @@ impl View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn axis_of(&self, cat_name: &str) -> Axis {
|
pub fn axis_of(&self, cat_name: &str) -> Axis {
|
||||||
*self.category_axes.get(cat_name)
|
*self
|
||||||
|
.category_axes
|
||||||
|
.get(cat_name)
|
||||||
.expect("axis_of called for category not registered with this view")
|
.expect("axis_of called for category not registered with this view")
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn categories_on(&self, axis: Axis) -> Vec<&str> {
|
pub fn categories_on(&self, axis: Axis) -> Vec<&str> {
|
||||||
self.category_axes.iter()
|
self.category_axes
|
||||||
|
.iter()
|
||||||
.filter(|(_, &a)| a == axis)
|
.filter(|(_, &a)| a == axis)
|
||||||
.map(|(n, _)| n.as_str())
|
.map(|(n, _)| n.as_str())
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
pub fn set_page_selection(&mut self, cat_name: &str, item: &str) {
|
pub fn set_page_selection(&mut self, cat_name: &str, item: &str) {
|
||||||
self.page_selections.insert(cat_name.to_string(), item.to_string());
|
self.page_selections
|
||||||
|
.insert(cat_name.to_string(), item.to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn page_selection(&self, cat_name: &str) -> Option<&str> {
|
pub fn page_selection(&self, cat_name: &str) -> Option<&str> {
|
||||||
@ -83,7 +86,10 @@ impl View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn toggle_group_collapse(&mut self, cat_name: &str, group_name: &str) {
|
pub fn toggle_group_collapse(&mut self, cat_name: &str, group_name: &str) {
|
||||||
let set = self.collapsed_groups.entry(cat_name.to_string()).or_default();
|
let set = self
|
||||||
|
.collapsed_groups
|
||||||
|
.entry(cat_name.to_string())
|
||||||
|
.or_default();
|
||||||
if set.contains(group_name) {
|
if set.contains(group_name) {
|
||||||
set.remove(group_name);
|
set.remove(group_name);
|
||||||
} else {
|
} else {
|
||||||
@ -91,34 +97,52 @@ impl View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// pub fn is_group_collapsed(&self, cat_name: &str, group_name: &str) -> bool {
|
pub fn is_group_collapsed(&self, cat_name: &str, group_name: &str) -> bool {
|
||||||
// self.collapsed_groups
|
self.collapsed_groups
|
||||||
// .get(cat_name)
|
.get(cat_name)
|
||||||
// .map(|s| s.contains(group_name))
|
.map(|s| s.contains(group_name))
|
||||||
// .unwrap_or(false)
|
.unwrap_or(false)
|
||||||
// }
|
|
||||||
|
|
||||||
pub fn hide_item(&mut self, cat_name: &str, item_name: &str) {
|
|
||||||
self.hidden_items.entry(cat_name.to_string()).or_default().insert(item_name.to_string());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// pub fn show_item(&mut self, cat_name: &str, item_name: &str) {
|
pub fn hide_item(&mut self, cat_name: &str, item_name: &str) {
|
||||||
// if let Some(set) = self.hidden_items.get_mut(cat_name) {
|
self.hidden_items
|
||||||
// set.remove(item_name);
|
.entry(cat_name.to_string())
|
||||||
// }
|
.or_default()
|
||||||
// }
|
.insert(item_name.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn show_item(&mut self, cat_name: &str, item_name: &str) {
|
||||||
|
if let Some(set) = self.hidden_items.get_mut(cat_name) {
|
||||||
|
set.remove(item_name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn is_hidden(&self, cat_name: &str, item_name: &str) -> bool {
|
pub fn is_hidden(&self, cat_name: &str, item_name: &str) -> bool {
|
||||||
self.hidden_items.get(cat_name).map(|s| s.contains(item_name)).unwrap_or(false)
|
self.hidden_items
|
||||||
|
.get(cat_name)
|
||||||
|
.map(|s| s.contains(item_name))
|
||||||
|
.unwrap_or(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Swap all Row categories to Column and all Column categories to Row.
|
/// Swap all Row categories to Column and all Column categories to Row.
|
||||||
/// Page categories are unaffected.
|
/// Page categories are unaffected.
|
||||||
pub fn transpose_axes(&mut self) {
|
pub fn transpose_axes(&mut self) {
|
||||||
let rows: Vec<String> = self.categories_on(Axis::Row).iter().map(|s| s.to_string()).collect();
|
let rows: Vec<String> = self
|
||||||
let cols: Vec<String> = self.categories_on(Axis::Column).iter().map(|s| s.to_string()).collect();
|
.categories_on(Axis::Row)
|
||||||
for cat in &rows { self.set_axis(cat, Axis::Column); }
|
.iter()
|
||||||
for cat in &cols { self.set_axis(cat, Axis::Row); }
|
.map(|s| s.to_string())
|
||||||
|
.collect();
|
||||||
|
let cols: Vec<String> = self
|
||||||
|
.categories_on(Axis::Column)
|
||||||
|
.iter()
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.collect();
|
||||||
|
for cat in &rows {
|
||||||
|
self.set_axis(cat, Axis::Column);
|
||||||
|
}
|
||||||
|
for cat in &cols {
|
||||||
|
self.set_axis(cat, Axis::Row);
|
||||||
|
}
|
||||||
self.selected = (0, 0);
|
self.selected = (0, 0);
|
||||||
self.row_offset = 0;
|
self.row_offset = 0;
|
||||||
self.col_offset = 0;
|
self.col_offset = 0;
|
||||||
@ -127,9 +151,9 @@ impl View {
|
|||||||
/// Cycle axis for a category: Row → Column → Page → Row
|
/// Cycle axis for a category: Row → Column → Page → Row
|
||||||
pub fn cycle_axis(&mut self, cat_name: &str) {
|
pub fn cycle_axis(&mut self, cat_name: &str) {
|
||||||
let next = match self.axis_of(cat_name) {
|
let next = match self.axis_of(cat_name) {
|
||||||
Axis::Row => Axis::Column,
|
Axis::Row => Axis::Column,
|
||||||
Axis::Column => Axis::Page,
|
Axis::Column => Axis::Page,
|
||||||
Axis::Page => Axis::Row,
|
Axis::Page => Axis::Row,
|
||||||
};
|
};
|
||||||
self.set_axis(cat_name, next);
|
self.set_axis(cat_name, next);
|
||||||
self.selected = (0, 0);
|
self.selected = (0, 0);
|
||||||
@ -145,7 +169,9 @@ mod tests {
|
|||||||
|
|
||||||
fn view_with_cats(cats: &[&str]) -> View {
|
fn view_with_cats(cats: &[&str]) -> View {
|
||||||
let mut v = View::new("Test");
|
let mut v = View::new("Test");
|
||||||
for &c in cats { v.on_category_added(c); }
|
for &c in cats {
|
||||||
|
v.on_category_added(c);
|
||||||
|
}
|
||||||
v
|
v
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -164,7 +190,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn third_and_later_categories_assigned_to_page() {
|
fn third_and_later_categories_assigned_to_page() {
|
||||||
let v = view_with_cats(&["Region", "Product", "Time", "Scenario"]);
|
let v = view_with_cats(&["Region", "Product", "Time", "Scenario"]);
|
||||||
assert_eq!(v.axis_of("Time"), Axis::Page);
|
assert_eq!(v.axis_of("Time"), Axis::Page);
|
||||||
assert_eq!(v.axis_of("Scenario"), Axis::Page);
|
assert_eq!(v.axis_of("Scenario"), Axis::Page);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -178,19 +204,19 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn categories_on_returns_correct_list() {
|
fn categories_on_returns_correct_list() {
|
||||||
let v = view_with_cats(&["Region", "Product", "Time"]);
|
let v = view_with_cats(&["Region", "Product", "Time"]);
|
||||||
assert_eq!(v.categories_on(Axis::Row), vec!["Region"]);
|
assert_eq!(v.categories_on(Axis::Row), vec!["Region"]);
|
||||||
assert_eq!(v.categories_on(Axis::Column), vec!["Product"]);
|
assert_eq!(v.categories_on(Axis::Column), vec!["Product"]);
|
||||||
assert_eq!(v.categories_on(Axis::Page), vec!["Time"]);
|
assert_eq!(v.categories_on(Axis::Page), vec!["Time"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn transpose_axes_swaps_row_and_column() {
|
fn transpose_axes_swaps_row_and_column() {
|
||||||
let mut v = view_with_cats(&["Region", "Product"]);
|
let mut v = view_with_cats(&["Region", "Product"]);
|
||||||
// Default: Region=Row, Product=Column
|
// Default: Region=Row, Product=Column
|
||||||
assert_eq!(v.axis_of("Region"), Axis::Row);
|
assert_eq!(v.axis_of("Region"), Axis::Row);
|
||||||
assert_eq!(v.axis_of("Product"), Axis::Column);
|
assert_eq!(v.axis_of("Product"), Axis::Column);
|
||||||
v.transpose_axes();
|
v.transpose_axes();
|
||||||
assert_eq!(v.axis_of("Region"), Axis::Column);
|
assert_eq!(v.axis_of("Region"), Axis::Column);
|
||||||
assert_eq!(v.axis_of("Product"), Axis::Row);
|
assert_eq!(v.axis_of("Product"), Axis::Row);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -207,7 +233,7 @@ mod tests {
|
|||||||
let mut v = view_with_cats(&["Region", "Product"]);
|
let mut v = view_with_cats(&["Region", "Product"]);
|
||||||
v.transpose_axes();
|
v.transpose_axes();
|
||||||
v.transpose_axes();
|
v.transpose_axes();
|
||||||
assert_eq!(v.axis_of("Region"), Axis::Row);
|
assert_eq!(v.axis_of("Region"), Axis::Row);
|
||||||
assert_eq!(v.axis_of("Product"), Axis::Column);
|
assert_eq!(v.axis_of("Product"), Axis::Column);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -222,21 +248,32 @@ mod tests {
|
|||||||
fn page_selection_set_and_get() {
|
fn page_selection_set_and_get() {
|
||||||
let mut v = view_with_cats(&["Region", "Product", "Time"]);
|
let mut v = view_with_cats(&["Region", "Product", "Time"]);
|
||||||
v.set_page_selection("Time", "Q1");
|
v.set_page_selection("Time", "Q1");
|
||||||
assert_eq!(v.page_selection("Time"), Some("Q1"));
|
assert_eq!(v.page_selection("Time"), Some("Q1"));
|
||||||
assert_eq!(v.page_selection("Region"), None);
|
assert_eq!(v.page_selection("Region"), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn toggle_group_collapse_toggles_twice() {
|
fn toggle_group_collapse_toggles_twice() {
|
||||||
let collapsed = |v: &View, cat: &str, group: &str| {
|
|
||||||
v.collapsed_groups.get(cat).map(|s| s.contains(group)).unwrap_or(false)
|
|
||||||
};
|
|
||||||
let mut v = View::new("Test");
|
let mut v = View::new("Test");
|
||||||
assert!(!collapsed(&v, "Time", "Q1"));
|
assert!(!v.is_group_collapsed("Time", "Q1"));
|
||||||
v.toggle_group_collapse("Time", "Q1");
|
v.toggle_group_collapse("Time", "Q1");
|
||||||
assert!(collapsed(&v, "Time", "Q1"));
|
assert!(v.is_group_collapsed("Time", "Q1"));
|
||||||
v.toggle_group_collapse("Time", "Q1");
|
v.toggle_group_collapse("Time", "Q1");
|
||||||
assert!(!collapsed(&v, "Time", "Q1"));
|
assert!(!v.is_group_collapsed("Time", "Q1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn is_group_collapsed_isolated_across_categories() {
|
||||||
|
let mut v = View::new("Test");
|
||||||
|
v.toggle_group_collapse("Cat1", "G1");
|
||||||
|
assert!(!v.is_group_collapsed("Cat2", "G1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn is_group_collapsed_isolated_across_groups() {
|
||||||
|
let mut v = View::new("Test");
|
||||||
|
v.toggle_group_collapse("Cat1", "G1");
|
||||||
|
assert!(!v.is_group_collapsed("Cat1", "G2"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -245,8 +282,8 @@ mod tests {
|
|||||||
assert!(!v.is_hidden("Region", "East"));
|
assert!(!v.is_hidden("Region", "East"));
|
||||||
v.hide_item("Region", "East");
|
v.hide_item("Region", "East");
|
||||||
assert!(v.is_hidden("Region", "East"));
|
assert!(v.is_hidden("Region", "East"));
|
||||||
// v.show_item("Region", "East");
|
v.show_item("Region", "East");
|
||||||
// assert!(!v.is_hidden("Region", "East"));
|
assert!(!v.is_hidden("Region", "East"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -280,7 +317,7 @@ mod tests {
|
|||||||
v.cycle_axis("Region");
|
v.cycle_axis("Region");
|
||||||
assert_eq!(v.row_offset, 0);
|
assert_eq!(v.row_offset, 0);
|
||||||
assert_eq!(v.col_offset, 0);
|
assert_eq!(v.col_offset, 0);
|
||||||
assert_eq!(v.selected, (0, 0));
|
assert_eq!(v.selected, (0, 0));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -290,7 +327,6 @@ mod prop_tests {
|
|||||||
use crate::view::Axis;
|
use crate::view::Axis;
|
||||||
use proptest::prelude::*;
|
use proptest::prelude::*;
|
||||||
|
|
||||||
|
|
||||||
fn unique_cat_names() -> impl Strategy<Value = Vec<String>> {
|
fn unique_cat_names() -> impl Strategy<Value = Vec<String>> {
|
||||||
prop::collection::hash_set("[A-Za-z][a-z]{1,7}", 1usize..=8)
|
prop::collection::hash_set("[A-Za-z][a-z]{1,7}", 1usize..=8)
|
||||||
.prop_map(|s| s.into_iter().collect::<Vec<_>>())
|
.prop_map(|s| s.into_iter().collect::<Vec<_>>())
|
||||||
@ -406,8 +442,8 @@ mod prop_tests {
|
|||||||
let mut v = View::new("T");
|
let mut v = View::new("T");
|
||||||
v.hide_item(&cat, &item);
|
v.hide_item(&cat, &item);
|
||||||
prop_assert!(v.is_hidden(&cat, &item));
|
prop_assert!(v.is_hidden(&cat, &item));
|
||||||
// v.show_item(&cat, &item);
|
v.show_item(&cat, &item);
|
||||||
// prop_assert!(!v.is_hidden(&cat, &item));
|
prop_assert!(!v.is_hidden(&cat, &item));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// toggle_group_collapse is its own inverse
|
/// toggle_group_collapse is its own inverse
|
||||||
@ -417,11 +453,10 @@ mod prop_tests {
|
|||||||
group in "[A-Za-z][a-z]{1,7}",
|
group in "[A-Za-z][a-z]{1,7}",
|
||||||
) {
|
) {
|
||||||
let mut v = View::new("T");
|
let mut v = View::new("T");
|
||||||
let collapsed = |v: &View| v.collapsed_groups.get(&cat).map(|s| s.contains(&group as &str)).unwrap_or(false);
|
let initial = v.is_group_collapsed(&cat, &group);
|
||||||
let initial = collapsed(&v);
|
|
||||||
v.toggle_group_collapse(&cat, &group);
|
v.toggle_group_collapse(&cat, &group);
|
||||||
v.toggle_group_collapse(&cat, &group);
|
v.toggle_group_collapse(&cat, &group);
|
||||||
prop_assert_eq!(collapsed(&v), initial);
|
prop_assert_eq!(v.is_group_collapsed(&cat, &group), initial);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user