chore: format
This commit is contained in:
@ -36,31 +36,61 @@ fn load_grammar() -> HashMap<String, (RuleType, Expr)> {
|
|||||||
// ── Word pools for realistic output ─────────────────────────────────────────
|
// ── Word pools for realistic output ─────────────────────────────────────────
|
||||||
|
|
||||||
const BARE_WORDS: &[&str] = &[
|
const BARE_WORDS: &[&str] = &[
|
||||||
"Region", "Product", "Customer", "Channel", "Date",
|
"Region",
|
||||||
"North", "South", "East", "West",
|
"Product",
|
||||||
"Revenue", "Cost", "Profit", "Margin",
|
"Customer",
|
||||||
"Widgets", "Gadgets", "Sprockets",
|
"Channel",
|
||||||
"Q1", "Q2", "Q3", "Q4",
|
"Date",
|
||||||
"Jan", "Feb", "Mar", "Apr",
|
"North",
|
||||||
"Acme", "Globex", "Initech", "Umbrella",
|
"South",
|
||||||
|
"East",
|
||||||
|
"West",
|
||||||
|
"Revenue",
|
||||||
|
"Cost",
|
||||||
|
"Profit",
|
||||||
|
"Margin",
|
||||||
|
"Widgets",
|
||||||
|
"Gadgets",
|
||||||
|
"Sprockets",
|
||||||
|
"Q1",
|
||||||
|
"Q2",
|
||||||
|
"Q3",
|
||||||
|
"Q4",
|
||||||
|
"Jan",
|
||||||
|
"Feb",
|
||||||
|
"Mar",
|
||||||
|
"Apr",
|
||||||
|
"Acme",
|
||||||
|
"Globex",
|
||||||
|
"Initech",
|
||||||
|
"Umbrella",
|
||||||
];
|
];
|
||||||
|
|
||||||
const QUOTED_WORDS: &[&str] = &[
|
const QUOTED_WORDS: &[&str] = &[
|
||||||
"Total Revenue", "Net Income", "Gross Margin",
|
"Total Revenue",
|
||||||
"2025-01", "2025-02", "2025-03",
|
"Net Income",
|
||||||
"East Coast", "West Coast",
|
"Gross Margin",
|
||||||
"Acme Corp", "Globex Inc",
|
"2025-01",
|
||||||
"Cost of Goods", "Operating Expense",
|
"2025-02",
|
||||||
|
"2025-03",
|
||||||
|
"East Coast",
|
||||||
|
"West Coast",
|
||||||
|
"Acme Corp",
|
||||||
|
"Globex Inc",
|
||||||
|
"Cost of Goods",
|
||||||
|
"Operating Expense",
|
||||||
];
|
];
|
||||||
|
|
||||||
const MODEL_NAMES: &[&str] = &[
|
const MODEL_NAMES: &[&str] = &[
|
||||||
"Sales Report", "Budget 2025", "Quarterly Review",
|
"Sales Report",
|
||||||
"Inventory Model", "Revenue Analysis", "Demo Model",
|
"Budget 2025",
|
||||||
|
"Quarterly Review",
|
||||||
|
"Inventory Model",
|
||||||
|
"Revenue Analysis",
|
||||||
|
"Demo Model",
|
||||||
];
|
];
|
||||||
|
|
||||||
const VIEW_NAMES: &[&str] = &[
|
const VIEW_NAMES: &[&str] = &["Default", "Summary", "Detail", "By Region", "Monthly"];
|
||||||
"Default", "Summary", "Detail", "By Region", "Monthly",
|
|
||||||
];
|
|
||||||
|
|
||||||
const FORMULA_EXPRS: &[&str] = &[
|
const FORMULA_EXPRS: &[&str] = &[
|
||||||
"Profit = Revenue - Cost",
|
"Profit = Revenue - Cost",
|
||||||
@ -70,9 +100,7 @@ const FORMULA_EXPRS: &[&str] = &[
|
|||||||
"Net = Revenue - Cost - Tax",
|
"Net = Revenue - Cost - Tax",
|
||||||
];
|
];
|
||||||
|
|
||||||
const FORMAT_STRINGS: &[&str] = &[
|
const FORMAT_STRINGS: &[&str] = &[",.0", ",.2f", ",.1f", ".0%"];
|
||||||
",.0", ",.2f", ",.1f", ".0%",
|
|
||||||
];
|
|
||||||
|
|
||||||
// ── PRNG ────────────────────────────────────────────────────────────────────
|
// ── PRNG ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@ -3,7 +3,7 @@ use crate::ui::app::AppMode;
|
|||||||
use crate::ui::effect::{self, Effect};
|
use crate::ui::effect::{self, Effect};
|
||||||
|
|
||||||
use super::core::{Cmd, CmdContext};
|
use super::core::{Cmd, CmdContext};
|
||||||
use super::navigation::{viewport_effects, CursorState, EnterAdvance};
|
use super::navigation::{CursorState, EnterAdvance, viewport_effects};
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
|||||||
@ -3,8 +3,8 @@ use std::fmt::Debug;
|
|||||||
|
|
||||||
use crossterm::event::KeyCode;
|
use crossterm::event::KeyCode;
|
||||||
|
|
||||||
use crate::model::cell::{CellKey, CellValue};
|
|
||||||
use crate::model::Model;
|
use crate::model::Model;
|
||||||
|
use crate::model::cell::{CellKey, CellValue};
|
||||||
use crate::ui::app::AppMode;
|
use crate::ui::app::AppMode;
|
||||||
use crate::ui::effect::{Effect, Panel};
|
use crate::ui::effect::{Effect, Panel};
|
||||||
use crate::view::{Axis, GridLayout};
|
use crate::view::{Axis, GridLayout};
|
||||||
|
|||||||
@ -2,7 +2,7 @@ use crate::model::cell::CellValue;
|
|||||||
use crate::ui::effect::{self, Effect};
|
use crate::ui::effect::{self, Effect};
|
||||||
use crate::view::Axis;
|
use crate::view::Axis;
|
||||||
|
|
||||||
use super::core::{require_args, Cmd, CmdContext};
|
use super::core::{Cmd, CmdContext, require_args};
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
@ -110,7 +110,6 @@ macro_rules! effect_cmd {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
effect_cmd!(
|
effect_cmd!(
|
||||||
AddCategoryCmd,
|
AddCategoryCmd,
|
||||||
"add-category",
|
"add-category",
|
||||||
@ -202,7 +201,10 @@ effect_cmd!(
|
|||||||
"add-formula",
|
"add-formula",
|
||||||
|args: &[String]| {
|
|args: &[String]| {
|
||||||
if args.is_empty() || args.len() > 2 {
|
if args.is_empty() || args.len() > 2 {
|
||||||
return Err(format!("add-formula requires 1-2 argument(s), got {}", args.len()));
|
return Err(format!(
|
||||||
|
"add-formula requires 1-2 argument(s), got {}",
|
||||||
|
args.len()
|
||||||
|
));
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
},
|
},
|
||||||
@ -397,7 +399,10 @@ effect_cmd!(
|
|||||||
"help",
|
"help",
|
||||||
|_args: &[String]| -> Result<(), String> { Ok(()) },
|
|_args: &[String]| -> Result<(), String> { Ok(()) },
|
||||||
|_args: &Vec<String>, _ctx: &CmdContext| -> Vec<Box<dyn Effect>> {
|
|_args: &Vec<String>, _ctx: &CmdContext| -> Vec<Box<dyn Effect>> {
|
||||||
vec![effect::help_page_set(0), effect::change_mode(crate::ui::app::AppMode::Help)]
|
vec![
|
||||||
|
effect::help_page_set(0),
|
||||||
|
effect::change_mode(crate::ui::app::AppMode::Help),
|
||||||
|
]
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -1,15 +1,15 @@
|
|||||||
pub mod core;
|
|
||||||
pub mod navigation;
|
|
||||||
pub mod mode;
|
|
||||||
pub mod cell;
|
pub mod cell;
|
||||||
pub mod search;
|
|
||||||
pub mod panel;
|
|
||||||
pub mod grid;
|
|
||||||
pub mod tile;
|
|
||||||
pub mod text_buffer;
|
|
||||||
pub mod commit;
|
pub mod commit;
|
||||||
|
pub mod core;
|
||||||
pub mod effect_cmds;
|
pub mod effect_cmds;
|
||||||
|
pub mod grid;
|
||||||
|
pub mod mode;
|
||||||
|
pub mod navigation;
|
||||||
|
pub mod panel;
|
||||||
pub mod registry;
|
pub mod registry;
|
||||||
|
pub mod search;
|
||||||
|
pub mod text_buffer;
|
||||||
|
pub mod tile;
|
||||||
|
|
||||||
// Re-export items used by external code
|
// Re-export items used by external code
|
||||||
pub use self::core::{Cmd, CmdContext, CmdRegistry};
|
pub use self::core::{Cmd, CmdContext, CmdRegistry};
|
||||||
|
|||||||
@ -3,7 +3,7 @@ use crossterm::event::KeyCode;
|
|||||||
use crate::ui::app::AppMode;
|
use crate::ui::app::AppMode;
|
||||||
use crate::ui::effect::{self, Effect};
|
use crate::ui::effect::{self, Effect};
|
||||||
|
|
||||||
use super::core::{read_buffer, Cmd, CmdContext};
|
use super::core::{Cmd, CmdContext, read_buffer};
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
|||||||
@ -235,9 +235,8 @@ impl Keymap {
|
|||||||
|
|
||||||
/// Look up the binding for a key, falling through to parent keymaps.
|
/// Look up the binding for a key, falling through to parent keymaps.
|
||||||
pub fn lookup(&self, key: KeyCode, mods: KeyModifiers) -> Option<&Binding> {
|
pub fn lookup(&self, key: KeyCode, mods: KeyModifiers) -> Option<&Binding> {
|
||||||
self.lookup_local(key, mods).or_else(|| {
|
self.lookup_local(key, mods)
|
||||||
self.parent.as_ref().and_then(|p| p.lookup(key, mods))
|
.or_else(|| self.parent.as_ref().and_then(|p| p.lookup(key, mods)))
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Dispatch a key: look up binding, resolve through registry, return effects.
|
/// Dispatch a key: look up binding, resolve through registry, return effects.
|
||||||
@ -729,50 +728,82 @@ impl KeymapSet {
|
|||||||
|
|
||||||
// ── Editing mode ─────────────────────────────────────────────────
|
// ── Editing mode ─────────────────────────────────────────────────
|
||||||
let mut ed = Keymap::new();
|
let mut ed = Keymap::new();
|
||||||
ed.bind_seq(KeyCode::Esc, none, vec![
|
ed.bind_seq(
|
||||||
("clear-buffer", vec!["edit".into()]),
|
KeyCode::Esc,
|
||||||
("enter-mode", vec!["normal".into()]),
|
none,
|
||||||
]);
|
vec![
|
||||||
ed.bind_seq(KeyCode::Enter, none, vec![
|
("clear-buffer", vec!["edit".into()]),
|
||||||
("commit-cell-edit", vec![]),
|
("enter-mode", vec!["normal".into()]),
|
||||||
("clear-buffer", vec!["edit".into()]),
|
],
|
||||||
]);
|
);
|
||||||
ed.bind_seq(KeyCode::Tab, none, vec![
|
ed.bind_seq(
|
||||||
("commit-and-advance-right", vec![]),
|
KeyCode::Enter,
|
||||||
("clear-buffer", vec!["edit".into()]),
|
none,
|
||||||
]);
|
vec![
|
||||||
|
("commit-cell-edit", vec![]),
|
||||||
|
("clear-buffer", vec!["edit".into()]),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
ed.bind_seq(
|
||||||
|
KeyCode::Tab,
|
||||||
|
none,
|
||||||
|
vec![
|
||||||
|
("commit-and-advance-right", vec![]),
|
||||||
|
("clear-buffer", vec!["edit".into()]),
|
||||||
|
],
|
||||||
|
);
|
||||||
ed.bind_args(KeyCode::Backspace, none, "pop-char", vec!["edit".into()]);
|
ed.bind_args(KeyCode::Backspace, none, "pop-char", vec!["edit".into()]);
|
||||||
ed.bind_any_char("append-char", vec!["edit".into()]);
|
ed.bind_any_char("append-char", vec!["edit".into()]);
|
||||||
set.insert(ModeKey::Editing, Arc::new(ed));
|
set.insert(ModeKey::Editing, Arc::new(ed));
|
||||||
|
|
||||||
// ── Formula edit ─────────────────────────────────────────────────
|
// ── Formula edit ─────────────────────────────────────────────────
|
||||||
let mut fe = Keymap::new();
|
let mut fe = Keymap::new();
|
||||||
fe.bind_seq(KeyCode::Esc, none, vec![
|
fe.bind_seq(
|
||||||
("clear-buffer", vec!["formula".into()]),
|
KeyCode::Esc,
|
||||||
("enter-mode", vec!["formula-panel".into()]),
|
none,
|
||||||
]);
|
vec![
|
||||||
fe.bind_seq(KeyCode::Enter, none, vec![
|
("clear-buffer", vec!["formula".into()]),
|
||||||
("commit-formula", vec![]),
|
("enter-mode", vec!["formula-panel".into()]),
|
||||||
("clear-buffer", vec!["formula".into()]),
|
],
|
||||||
]);
|
);
|
||||||
|
fe.bind_seq(
|
||||||
|
KeyCode::Enter,
|
||||||
|
none,
|
||||||
|
vec![
|
||||||
|
("commit-formula", vec![]),
|
||||||
|
("clear-buffer", vec!["formula".into()]),
|
||||||
|
],
|
||||||
|
);
|
||||||
fe.bind_args(KeyCode::Backspace, none, "pop-char", vec!["formula".into()]);
|
fe.bind_args(KeyCode::Backspace, none, "pop-char", vec!["formula".into()]);
|
||||||
fe.bind_any_char("append-char", vec!["formula".into()]);
|
fe.bind_any_char("append-char", vec!["formula".into()]);
|
||||||
set.insert(ModeKey::FormulaEdit, Arc::new(fe));
|
set.insert(ModeKey::FormulaEdit, Arc::new(fe));
|
||||||
|
|
||||||
// ── Category add ─────────────────────────────────────────────────
|
// ── Category add ─────────────────────────────────────────────────
|
||||||
let mut ca = Keymap::new();
|
let mut ca = Keymap::new();
|
||||||
ca.bind_seq(KeyCode::Esc, none, vec![
|
ca.bind_seq(
|
||||||
("clear-buffer", vec!["category".into()]),
|
KeyCode::Esc,
|
||||||
("enter-mode", vec!["category-panel".into()]),
|
none,
|
||||||
]);
|
vec![
|
||||||
ca.bind_seq(KeyCode::Enter, none, vec![
|
("clear-buffer", vec!["category".into()]),
|
||||||
("commit-category-add", vec![]),
|
("enter-mode", vec!["category-panel".into()]),
|
||||||
("clear-buffer", vec!["category".into()]),
|
],
|
||||||
]);
|
);
|
||||||
ca.bind_seq(KeyCode::Tab, none, vec![
|
ca.bind_seq(
|
||||||
("commit-category-add", vec![]),
|
KeyCode::Enter,
|
||||||
("clear-buffer", vec!["category".into()]),
|
none,
|
||||||
]);
|
vec![
|
||||||
|
("commit-category-add", vec![]),
|
||||||
|
("clear-buffer", vec!["category".into()]),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
ca.bind_seq(
|
||||||
|
KeyCode::Tab,
|
||||||
|
none,
|
||||||
|
vec![
|
||||||
|
("commit-category-add", vec![]),
|
||||||
|
("clear-buffer", vec!["category".into()]),
|
||||||
|
],
|
||||||
|
);
|
||||||
ca.bind_args(
|
ca.bind_args(
|
||||||
KeyCode::Backspace,
|
KeyCode::Backspace,
|
||||||
none,
|
none,
|
||||||
@ -784,46 +815,74 @@ impl KeymapSet {
|
|||||||
|
|
||||||
// ── Item add ─────────────────────────────────────────────────────
|
// ── Item add ─────────────────────────────────────────────────────
|
||||||
let mut ia = Keymap::new();
|
let mut ia = Keymap::new();
|
||||||
ia.bind_seq(KeyCode::Esc, none, vec![
|
ia.bind_seq(
|
||||||
("clear-buffer", vec!["item".into()]),
|
KeyCode::Esc,
|
||||||
("enter-mode", vec!["category-panel".into()]),
|
none,
|
||||||
]);
|
vec![
|
||||||
ia.bind_seq(KeyCode::Enter, none, vec![
|
("clear-buffer", vec!["item".into()]),
|
||||||
("commit-item-add", vec![]),
|
("enter-mode", vec!["category-panel".into()]),
|
||||||
("clear-buffer", vec!["item".into()]),
|
],
|
||||||
]);
|
);
|
||||||
ia.bind_seq(KeyCode::Tab, none, vec![
|
ia.bind_seq(
|
||||||
("commit-item-add", vec![]),
|
KeyCode::Enter,
|
||||||
("clear-buffer", vec!["item".into()]),
|
none,
|
||||||
]);
|
vec![
|
||||||
|
("commit-item-add", vec![]),
|
||||||
|
("clear-buffer", vec!["item".into()]),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
ia.bind_seq(
|
||||||
|
KeyCode::Tab,
|
||||||
|
none,
|
||||||
|
vec![
|
||||||
|
("commit-item-add", vec![]),
|
||||||
|
("clear-buffer", vec!["item".into()]),
|
||||||
|
],
|
||||||
|
);
|
||||||
ia.bind_args(KeyCode::Backspace, none, "pop-char", vec!["item".into()]);
|
ia.bind_args(KeyCode::Backspace, none, "pop-char", vec!["item".into()]);
|
||||||
ia.bind_any_char("append-char", vec!["item".into()]);
|
ia.bind_any_char("append-char", vec!["item".into()]);
|
||||||
set.insert(ModeKey::ItemAdd, Arc::new(ia));
|
set.insert(ModeKey::ItemAdd, Arc::new(ia));
|
||||||
|
|
||||||
// ── Export prompt ────────────────────────────────────────────────
|
// ── Export prompt ────────────────────────────────────────────────
|
||||||
let mut ep = Keymap::new();
|
let mut ep = Keymap::new();
|
||||||
ep.bind_seq(KeyCode::Esc, none, vec![
|
ep.bind_seq(
|
||||||
("clear-buffer", vec!["export".into()]),
|
KeyCode::Esc,
|
||||||
("enter-mode", vec!["normal".into()]),
|
none,
|
||||||
]);
|
vec![
|
||||||
ep.bind_seq(KeyCode::Enter, none, vec![
|
("clear-buffer", vec!["export".into()]),
|
||||||
("commit-export", vec![]),
|
("enter-mode", vec!["normal".into()]),
|
||||||
("clear-buffer", vec!["export".into()]),
|
],
|
||||||
]);
|
);
|
||||||
|
ep.bind_seq(
|
||||||
|
KeyCode::Enter,
|
||||||
|
none,
|
||||||
|
vec![
|
||||||
|
("commit-export", vec![]),
|
||||||
|
("clear-buffer", vec!["export".into()]),
|
||||||
|
],
|
||||||
|
);
|
||||||
ep.bind_args(KeyCode::Backspace, none, "pop-char", vec!["export".into()]);
|
ep.bind_args(KeyCode::Backspace, none, "pop-char", vec!["export".into()]);
|
||||||
ep.bind_any_char("append-char", vec!["export".into()]);
|
ep.bind_any_char("append-char", vec!["export".into()]);
|
||||||
set.insert(ModeKey::ExportPrompt, Arc::new(ep));
|
set.insert(ModeKey::ExportPrompt, Arc::new(ep));
|
||||||
|
|
||||||
// ── Command mode ─────────────────────────────────────────────────
|
// ── Command mode ─────────────────────────────────────────────────
|
||||||
let mut cm = Keymap::new();
|
let mut cm = Keymap::new();
|
||||||
cm.bind_seq(KeyCode::Esc, none, vec![
|
cm.bind_seq(
|
||||||
("clear-buffer", vec!["command".into()]),
|
KeyCode::Esc,
|
||||||
("enter-mode", vec!["normal".into()]),
|
none,
|
||||||
]);
|
vec![
|
||||||
cm.bind_seq(KeyCode::Enter, none, vec![
|
("clear-buffer", vec!["command".into()]),
|
||||||
("execute-command", vec![]),
|
("enter-mode", vec!["normal".into()]),
|
||||||
("clear-buffer", vec!["command".into()]),
|
],
|
||||||
]);
|
);
|
||||||
|
cm.bind_seq(
|
||||||
|
KeyCode::Enter,
|
||||||
|
none,
|
||||||
|
vec![
|
||||||
|
("execute-command", vec![]),
|
||||||
|
("clear-buffer", vec!["command".into()]),
|
||||||
|
],
|
||||||
|
);
|
||||||
cm.bind(KeyCode::Backspace, none, "command-mode-backspace");
|
cm.bind(KeyCode::Backspace, none, "command-mode-backspace");
|
||||||
cm.bind_any_char("append-char", vec!["command".into()]);
|
cm.bind_any_char("append-char", vec!["command".into()]);
|
||||||
set.insert(ModeKey::CommandMode, Arc::new(cm));
|
set.insert(ModeKey::CommandMode, Arc::new(cm));
|
||||||
@ -1086,9 +1145,11 @@ mod tests {
|
|||||||
let ks = KeymapSet::default_keymaps();
|
let ks = KeymapSet::default_keymaps();
|
||||||
let editing = ks.mode_maps.get(&ModeKey::Editing).unwrap();
|
let editing = ks.mode_maps.get(&ModeKey::Editing).unwrap();
|
||||||
// Should have AnyChar for text input
|
// Should have AnyChar for text input
|
||||||
assert!(editing
|
assert!(
|
||||||
.lookup(KeyCode::Char('z'), KeyModifiers::NONE)
|
editing
|
||||||
.is_some());
|
.lookup(KeyCode::Char('z'), KeyModifiers::NONE)
|
||||||
|
.is_some()
|
||||||
|
);
|
||||||
// Should have Esc to exit
|
// Should have Esc to exit
|
||||||
assert!(editing.lookup(KeyCode::Esc, KeyModifiers::NONE).is_some());
|
assert!(editing.lookup(KeyCode::Esc, KeyModifiers::NONE).is_some());
|
||||||
}
|
}
|
||||||
@ -1097,9 +1158,11 @@ mod tests {
|
|||||||
fn search_mode_has_any_char_and_esc() {
|
fn search_mode_has_any_char_and_esc() {
|
||||||
let ks = KeymapSet::default_keymaps();
|
let ks = KeymapSet::default_keymaps();
|
||||||
let search = ks.mode_maps.get(&ModeKey::SearchMode).unwrap();
|
let search = ks.mode_maps.get(&ModeKey::SearchMode).unwrap();
|
||||||
assert!(search
|
assert!(
|
||||||
.lookup(KeyCode::Char('a'), KeyModifiers::NONE)
|
search
|
||||||
.is_some());
|
.lookup(KeyCode::Char('a'), KeyModifiers::NONE)
|
||||||
|
.is_some()
|
||||||
|
);
|
||||||
assert!(search.lookup(KeyCode::Esc, KeyModifiers::NONE).is_some());
|
assert!(search.lookup(KeyCode::Esc, KeyModifiers::NONE).is_some());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -5,7 +5,7 @@
|
|||||||
//! Coordinate pairs use `/`: `Category/Item`
|
//! Coordinate pairs use `/`: `Category/Item`
|
||||||
//! Quoted strings supported: `"Profit = Revenue - Cost"`
|
//! Quoted strings supported: `"Profit = Revenue - Cost"`
|
||||||
|
|
||||||
use super::cmd::{default_registry, Cmd, CmdRegistry};
|
use super::cmd::{Cmd, CmdRegistry, default_registry};
|
||||||
|
|
||||||
/// Parse a line into commands using the default registry.
|
/// Parse a line into commands using the default registry.
|
||||||
pub fn parse_line(line: &str) -> Result<Vec<Box<dyn Cmd>>, String> {
|
pub fn parse_line(line: &str) -> Result<Vec<Box<dyn Cmd>>, String> {
|
||||||
|
|||||||
@ -6,14 +6,14 @@ use anyhow::Result;
|
|||||||
use crossterm::{
|
use crossterm::{
|
||||||
event::{self, Event},
|
event::{self, Event},
|
||||||
execute,
|
execute,
|
||||||
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
|
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
|
||||||
};
|
};
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
|
Frame, Terminal,
|
||||||
backend::CrosstermBackend,
|
backend::CrosstermBackend,
|
||||||
layout::{Constraint, Direction, Layout, Rect},
|
layout::{Constraint, Direction, Layout, Rect},
|
||||||
style::{Color, Modifier, Style},
|
style::{Color, Modifier, Style},
|
||||||
widgets::{Block, Borders, Clear, Paragraph},
|
widgets::{Block, Borders, Clear, Paragraph},
|
||||||
Frame, Terminal,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::model::Model;
|
use crate::model::Model;
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
use anyhow::{anyhow, Result};
|
use anyhow::{Result, anyhow};
|
||||||
|
|
||||||
use super::ast::{AggFunc, BinOp, Expr, Filter, Formula};
|
use super::ast::{AggFunc, BinOp, Expr, Filter, Formula};
|
||||||
|
|
||||||
@ -364,7 +364,7 @@ fn parse_primary(tokens: &[Token], pos: &mut usize) -> Result<Expr> {
|
|||||||
t => {
|
t => {
|
||||||
return Err(anyhow!(
|
return Err(anyhow!(
|
||||||
"Expected category name, got {t:?}"
|
"Expected category name, got {t:?}"
|
||||||
))
|
));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
// expect =
|
// expect =
|
||||||
@ -537,11 +537,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_sum_with_top_level_where_works() {
|
fn parse_sum_with_top_level_where_works() {
|
||||||
let f = parse_formula(
|
let f = parse_formula("EastTotal = SUM(Revenue) WHERE Region = \"East\"", "Foo").unwrap();
|
||||||
"EastTotal = SUM(Revenue) WHERE Region = \"East\"",
|
|
||||||
"Foo",
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
assert!(matches!(f.expr, Expr::Agg(AggFunc::Sum, _, _)));
|
assert!(matches!(f.expr, Expr::Agg(AggFunc::Sum, _, _)));
|
||||||
let filter = f.filter.as_ref().unwrap();
|
let filter = f.filter.as_ref().unwrap();
|
||||||
assert_eq!(filter.category, "Region");
|
assert_eq!(filter.category, "Region");
|
||||||
@ -552,11 +548,7 @@ mod tests {
|
|||||||
/// The tokenizer must not merge "Revenue WHERE" into a single identifier.
|
/// The tokenizer must not merge "Revenue WHERE" into a single identifier.
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_sum_with_inline_where_filter() {
|
fn parse_sum_with_inline_where_filter() {
|
||||||
let f = parse_formula(
|
let f = parse_formula("EastTotal = SUM(Revenue WHERE Region = \"East\")", "Foo").unwrap();
|
||||||
"EastTotal = SUM(Revenue WHERE Region = \"East\")",
|
|
||||||
"Foo",
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
if let Expr::Agg(AggFunc::Sum, inner, Some(filter)) = &f.expr {
|
if let Expr::Agg(AggFunc::Sum, inner, Some(filter)) = &f.expr {
|
||||||
assert!(matches!(**inner, Expr::Ref(_)));
|
assert!(matches!(**inner, Expr::Ref(_)));
|
||||||
assert_eq!(filter.category, "Region");
|
assert_eq!(filter.category, "Region");
|
||||||
@ -772,11 +764,8 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn pipe_quoted_in_inline_where() {
|
fn pipe_quoted_in_inline_where() {
|
||||||
let f = parse_formula(
|
let f =
|
||||||
"X = SUM(Revenue WHERE |Region Name| = |East Coast|)",
|
parse_formula("X = SUM(Revenue WHERE |Region Name| = |East Coast|)", "Foo").unwrap();
|
||||||
"Foo",
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
if let Expr::Agg(AggFunc::Sum, _, Some(filter)) = &f.expr {
|
if let Expr::Agg(AggFunc::Sum, _, Some(filter)) = &f.expr {
|
||||||
assert_eq!(filter.category, "Region Name");
|
assert_eq!(filter.category, "Region Name");
|
||||||
assert_eq!(filter.item, "East Coast");
|
assert_eq!(filter.item, "East Coast");
|
||||||
|
|||||||
@ -1,13 +1,13 @@
|
|||||||
use anyhow::{anyhow, Result};
|
use anyhow::{Result, anyhow};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
use super::analyzer::{
|
use super::analyzer::{
|
||||||
analyze_records, extract_array_at_path, extract_date_component, find_array_paths,
|
DateComponent, FieldKind, FieldProposal, analyze_records, extract_array_at_path,
|
||||||
DateComponent, FieldKind, FieldProposal,
|
extract_date_component, find_array_paths,
|
||||||
};
|
};
|
||||||
use crate::formula::parse_formula;
|
use crate::formula::parse_formula;
|
||||||
use crate::model::cell::{CellKey, CellValue};
|
|
||||||
use crate::model::Model;
|
use crate::model::Model;
|
||||||
|
use crate::model::cell::{CellKey, CellValue};
|
||||||
|
|
||||||
// ── Pipeline (no UI state) ────────────────────────────────────────────────────
|
// ── Pipeline (no UI state) ────────────────────────────────────────────────────
|
||||||
|
|
||||||
@ -850,7 +850,7 @@ mod tests {
|
|||||||
let mut w = ImportWizard::new(raw);
|
let mut w = ImportWizard::new(raw);
|
||||||
assert_eq!(w.step, WizardStep::SelectArrayPath);
|
assert_eq!(w.step, WizardStep::SelectArrayPath);
|
||||||
w.confirm_path(); // selects first path
|
w.confirm_path(); // selects first path
|
||||||
// Should advance past SelectArrayPath
|
// Should advance past SelectArrayPath
|
||||||
assert_ne!(w.step, WizardStep::SelectArrayPath);
|
assert_ne!(w.step, WizardStep::SelectArrayPath);
|
||||||
assert!(!w.pipeline.records.is_empty());
|
assert!(!w.pipeline.records.is_empty());
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use anyhow::{anyhow, Result};
|
use anyhow::{Result, anyhow};
|
||||||
use indexmap::IndexMap;
|
use indexmap::IndexMap;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
@ -274,7 +274,12 @@ impl Model {
|
|||||||
pub fn measure_item_names(&self) -> Vec<String> {
|
pub fn measure_item_names(&self) -> Vec<String> {
|
||||||
let mut names: Vec<String> = self
|
let mut names: Vec<String> = self
|
||||||
.category("_Measure")
|
.category("_Measure")
|
||||||
.map(|c| c.ordered_item_names().iter().map(|s| s.to_string()).collect())
|
.map(|c| {
|
||||||
|
c.ordered_item_names()
|
||||||
|
.iter()
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
for f in &self.formulas {
|
for f in &self.formulas {
|
||||||
if f.target_category == "_Measure" && !names.iter().any(|n| n == &f.target) {
|
if f.target_category == "_Measure" && !names.iter().any(|n| n == &f.target) {
|
||||||
@ -292,7 +297,12 @@ impl Model {
|
|||||||
self.measure_item_names()
|
self.measure_item_names()
|
||||||
} else {
|
} else {
|
||||||
self.category(cat_name)
|
self.category(cat_name)
|
||||||
.map(|c| c.ordered_item_names().iter().map(|s| s.to_string()).collect())
|
.map(|c| {
|
||||||
|
c.ordered_item_names()
|
||||||
|
.iter()
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -504,8 +514,8 @@ impl Model {
|
|||||||
match expr {
|
match expr {
|
||||||
Expr::Number(n) => Ok(*n),
|
Expr::Number(n) => Ok(*n),
|
||||||
Expr::Ref(name) => {
|
Expr::Ref(name) => {
|
||||||
let cat = find_item_category(model, name)
|
let cat =
|
||||||
.ok_or_else(|| format!("ref:{name}"))?;
|
find_item_category(model, name).ok_or_else(|| format!("ref:{name}"))?;
|
||||||
let ref_key = context.clone().with(cat, name);
|
let ref_key = context.clone().with(cat, name);
|
||||||
// Check formula cache first, then aggregate raw data
|
// Check formula cache first, then aggregate raw data
|
||||||
if let Some(cached) = model.formula_cache.get(&ref_key) {
|
if let Some(cached) = model.formula_cache.get(&ref_key) {
|
||||||
@ -529,12 +539,26 @@ impl Model {
|
|||||||
BinOp::Add => Ok(lv + rv),
|
BinOp::Add => Ok(lv + rv),
|
||||||
BinOp::Sub => Ok(lv - rv),
|
BinOp::Sub => Ok(lv - rv),
|
||||||
BinOp::Mul => Ok(lv * rv),
|
BinOp::Mul => Ok(lv * rv),
|
||||||
BinOp::Div => if rv == 0.0 { Err("div/0".into()) } else { Ok(lv / rv) },
|
BinOp::Div => {
|
||||||
|
if rv == 0.0 {
|
||||||
|
Err("div/0".into())
|
||||||
|
} else {
|
||||||
|
Ok(lv / rv)
|
||||||
|
}
|
||||||
|
}
|
||||||
BinOp::Pow => Ok(lv.powf(rv)),
|
BinOp::Pow => Ok(lv.powf(rv)),
|
||||||
BinOp::Eq | BinOp::Ne | BinOp::Lt | BinOp::Gt | BinOp::Le | BinOp::Ge => Err("type".into()),
|
BinOp::Eq | BinOp::Ne | BinOp::Lt | BinOp::Gt | BinOp::Le | BinOp::Ge => {
|
||||||
|
Err("type".into())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Expr::UnaryMinus(e) => Ok(-eval_expr_cached(e, context, model, target_category, none_cats)?),
|
Expr::UnaryMinus(e) => Ok(-eval_expr_cached(
|
||||||
|
e,
|
||||||
|
context,
|
||||||
|
model,
|
||||||
|
target_category,
|
||||||
|
none_cats,
|
||||||
|
)?),
|
||||||
Expr::Agg(func, inner, agg_filter) => {
|
Expr::Agg(func, inner, agg_filter) => {
|
||||||
use crate::formula::AggFunc;
|
use crate::formula::AggFunc;
|
||||||
let mut partial = context.without(target_category);
|
let mut partial = context.without(target_category);
|
||||||
@ -554,9 +578,23 @@ impl Model {
|
|||||||
.collect();
|
.collect();
|
||||||
match func {
|
match func {
|
||||||
AggFunc::Sum => Ok(values.iter().sum()),
|
AggFunc::Sum => Ok(values.iter().sum()),
|
||||||
AggFunc::Avg => if values.is_empty() { Err("empty".into()) } else { Ok(values.iter().sum::<f64>() / values.len() as f64) },
|
AggFunc::Avg => {
|
||||||
AggFunc::Min => values.iter().cloned().reduce(f64::min).ok_or_else(|| "empty".into()),
|
if values.is_empty() {
|
||||||
AggFunc::Max => values.iter().cloned().reduce(f64::max).ok_or_else(|| "empty".into()),
|
Err("empty".into())
|
||||||
|
} else {
|
||||||
|
Ok(values.iter().sum::<f64>() / values.len() as f64)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AggFunc::Min => values
|
||||||
|
.iter()
|
||||||
|
.cloned()
|
||||||
|
.reduce(f64::min)
|
||||||
|
.ok_or_else(|| "empty".into()),
|
||||||
|
AggFunc::Max => values
|
||||||
|
.iter()
|
||||||
|
.cloned()
|
||||||
|
.reduce(f64::max)
|
||||||
|
.ok_or_else(|| "empty".into()),
|
||||||
AggFunc::Count => Ok(values.len() as f64),
|
AggFunc::Count => Ok(values.len() as f64),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -597,7 +635,13 @@ impl Model {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
match eval_expr_cached(&formula.expr, context, self, &formula.target_category, none_cats) {
|
match eval_expr_cached(
|
||||||
|
&formula.expr,
|
||||||
|
context,
|
||||||
|
self,
|
||||||
|
&formula.target_category,
|
||||||
|
none_cats,
|
||||||
|
) {
|
||||||
Ok(n) => Some(CellValue::Number(n)),
|
Ok(n) => Some(CellValue::Number(n)),
|
||||||
Err(e) => Some(CellValue::Error(e)),
|
Err(e) => Some(CellValue::Error(e)),
|
||||||
}
|
}
|
||||||
@ -679,8 +723,8 @@ impl Model {
|
|||||||
match expr {
|
match expr {
|
||||||
Expr::Number(n) => Ok(*n),
|
Expr::Number(n) => Ok(*n),
|
||||||
Expr::Ref(name) => {
|
Expr::Ref(name) => {
|
||||||
let cat = find_item_category(model, name)
|
let cat =
|
||||||
.ok_or_else(|| format!("ref:{name}"))?;
|
find_item_category(model, name).ok_or_else(|| format!("ref:{name}"))?;
|
||||||
let new_key = context.clone().with(cat, name);
|
let new_key = context.clone().with(cat, name);
|
||||||
match model.evaluate_depth(&new_key, depth) {
|
match model.evaluate_depth(&new_key, depth) {
|
||||||
Some(CellValue::Number(n)) => Ok(n),
|
Some(CellValue::Number(n)) => Ok(n),
|
||||||
@ -735,8 +779,16 @@ impl Model {
|
|||||||
Ok(values.iter().sum::<f64>() / values.len() as f64)
|
Ok(values.iter().sum::<f64>() / values.len() as f64)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
AggFunc::Min => values.iter().cloned().reduce(f64::min).ok_or_else(|| "empty".into()),
|
AggFunc::Min => values
|
||||||
AggFunc::Max => values.iter().cloned().reduce(f64::max).ok_or_else(|| "empty".into()),
|
.iter()
|
||||||
|
.cloned()
|
||||||
|
.reduce(f64::min)
|
||||||
|
.ok_or_else(|| "empty".into()),
|
||||||
|
AggFunc::Max => values
|
||||||
|
.iter()
|
||||||
|
.cloned()
|
||||||
|
.reduce(f64::max)
|
||||||
|
.ok_or_else(|| "empty".into()),
|
||||||
AggFunc::Count => Ok(values.len() as f64),
|
AggFunc::Count => Ok(values.len() as f64),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -779,7 +831,13 @@ impl Model {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
match eval_expr(&formula.expr, context, self, &formula.target_category, depth) {
|
match eval_expr(
|
||||||
|
&formula.expr,
|
||||||
|
context,
|
||||||
|
self,
|
||||||
|
&formula.target_category,
|
||||||
|
depth,
|
||||||
|
) {
|
||||||
Ok(n) => Some(CellValue::Number(n)),
|
Ok(n) => Some(CellValue::Number(n)),
|
||||||
Err(e) => Some(CellValue::Error(e)),
|
Err(e) => Some(CellValue::Error(e)),
|
||||||
}
|
}
|
||||||
@ -1011,11 +1069,19 @@ mod model_tests {
|
|||||||
m.category_mut("_Measure").unwrap().add_item("Amount");
|
m.category_mut("_Measure").unwrap().add_item("Amount");
|
||||||
|
|
||||||
m.set_cell(
|
m.set_cell(
|
||||||
coord(&[("Payee", "Acme"), ("Date", "Jan-01"), ("_Measure", "Amount")]),
|
coord(&[
|
||||||
|
("Payee", "Acme"),
|
||||||
|
("Date", "Jan-01"),
|
||||||
|
("_Measure", "Amount"),
|
||||||
|
]),
|
||||||
CellValue::Number(100.0),
|
CellValue::Number(100.0),
|
||||||
);
|
);
|
||||||
m.set_cell(
|
m.set_cell(
|
||||||
coord(&[("Payee", "Acme"), ("Date", "Jan-02"), ("_Measure", "Amount")]),
|
coord(&[
|
||||||
|
("Payee", "Acme"),
|
||||||
|
("Date", "Jan-02"),
|
||||||
|
("_Measure", "Amount"),
|
||||||
|
]),
|
||||||
CellValue::Number(50.0),
|
CellValue::Number(50.0),
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -1780,8 +1846,10 @@ mod five_category {
|
|||||||
.evaluate(&coord(region, product, channel, time, "Margin"))
|
.evaluate(&coord(region, product, channel, time, "Margin"))
|
||||||
.and_then(|v| v.as_f64())
|
.and_then(|v| v.as_f64())
|
||||||
.unwrap_or_else(|| panic!("Margin empty at {region}/{product}/{channel}/{time}"));
|
.unwrap_or_else(|| panic!("Margin empty at {region}/{product}/{channel}/{time}"));
|
||||||
assert!(approx(actual, expected),
|
assert!(
|
||||||
"Margin at {region}/{product}/{channel}/{time}: expected {expected:.4}, got {actual:.4}");
|
approx(actual, expected),
|
||||||
|
"Margin at {region}/{product}/{channel}/{time}: expected {expected:.4}, got {actual:.4}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,16 +1,16 @@
|
|||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
|
use flate2::Compression;
|
||||||
use flate2::read::GzDecoder;
|
use flate2::read::GzDecoder;
|
||||||
use flate2::write::GzEncoder;
|
use flate2::write::GzEncoder;
|
||||||
use flate2::Compression;
|
|
||||||
use pest::Parser;
|
use pest::Parser;
|
||||||
use pest_derive::Parser;
|
use pest_derive::Parser;
|
||||||
use std::io::{BufReader, BufWriter, Read, Write};
|
use std::io::{BufReader, BufWriter, Read, Write};
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
use crate::formula::parse_formula;
|
use crate::formula::parse_formula;
|
||||||
|
use crate::model::Model;
|
||||||
use crate::model::category::Group;
|
use crate::model::category::Group;
|
||||||
use crate::model::cell::{CellKey, CellValue};
|
use crate::model::cell::{CellKey, CellValue};
|
||||||
use crate::model::Model;
|
|
||||||
use crate::view::{Axis, GridLayout};
|
use crate::view::{Axis, GridLayout};
|
||||||
|
|
||||||
#[derive(Parser)]
|
#[derive(Parser)]
|
||||||
@ -152,7 +152,6 @@ pub fn autosave_path(path: &Path) -> std::path::PathBuf {
|
|||||||
p
|
p
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/// Serialize a model to the markdown `.improv` format.
|
/// Serialize a model to the markdown `.improv` format.
|
||||||
pub fn format_md(model: &Model) -> String {
|
pub fn format_md(model: &Model) -> String {
|
||||||
// writeln! to a String is infallible; this macro avoids .unwrap() noise.
|
// writeln! to a String is infallible; this macro avoids .unwrap() noise.
|
||||||
@ -188,11 +187,15 @@ pub fn format_md(model: &Model) -> String {
|
|||||||
if !view.number_format.is_empty() {
|
if !view.number_format.is_empty() {
|
||||||
w!(out, "format: {}", view.number_format);
|
w!(out, "format: {}", view.number_format);
|
||||||
}
|
}
|
||||||
for (prefix, map) in [("hidden", &view.hidden_items), ("collapsed", &view.collapsed_groups)]
|
for (prefix, map) in [
|
||||||
{
|
("hidden", &view.hidden_items),
|
||||||
|
("collapsed", &view.collapsed_groups),
|
||||||
|
] {
|
||||||
let mut pairs: Vec<_> = map
|
let mut pairs: Vec<_> = map
|
||||||
.iter()
|
.iter()
|
||||||
.flat_map(|(cat, items)| items.iter().map(move |item| (cat.as_str(), item.as_str())))
|
.flat_map(|(cat, items)| {
|
||||||
|
items.iter().map(move |item| (cat.as_str(), item.as_str()))
|
||||||
|
})
|
||||||
.collect();
|
.collect();
|
||||||
pairs.sort();
|
pairs.sort();
|
||||||
for (cat, item) in pairs {
|
for (cat, item) in pairs {
|
||||||
@ -225,7 +228,10 @@ pub fn format_md(model: &Model) -> String {
|
|||||||
for cat in model.categories.values() {
|
for cat in model.categories.values() {
|
||||||
use crate::model::category::CategoryKind;
|
use crate::model::category::CategoryKind;
|
||||||
// Skip _Index and _Dim — they are fully virtual, never persisted
|
// Skip _Index and _Dim — they are fully virtual, never persisted
|
||||||
if matches!(cat.kind, CategoryKind::VirtualIndex | CategoryKind::VirtualDim) {
|
if matches!(
|
||||||
|
cat.kind,
|
||||||
|
CategoryKind::VirtualIndex | CategoryKind::VirtualDim
|
||||||
|
) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
w!(out, "\n## Category: {}", cat.name);
|
w!(out, "\n## Category: {}", cat.name);
|
||||||
@ -234,7 +240,9 @@ pub fn format_md(model: &Model) -> String {
|
|||||||
for item in cat.items.values() {
|
for item in cat.items.values() {
|
||||||
// For _Measure, skip items that are formula targets
|
// For _Measure, skip items that are formula targets
|
||||||
// (they'll be recreated from the ## Formulas section)
|
// (they'll be recreated from the ## Formulas section)
|
||||||
if cat.kind == CategoryKind::VirtualMeasure && formula_targets.contains(item.name.as_str()) {
|
if cat.kind == CategoryKind::VirtualMeasure
|
||||||
|
&& formula_targets.contains(item.name.as_str())
|
||||||
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
match &item.group {
|
match &item.group {
|
||||||
@ -272,7 +280,6 @@ pub fn format_md(model: &Model) -> String {
|
|||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/// Parse the `.improv` format into a Model using the pest grammar.
|
/// Parse the `.improv` format into a Model using the pest grammar.
|
||||||
///
|
///
|
||||||
/// Sections may appear in any order; a two-pass approach registers categories
|
/// Sections may appear in any order; a two-pass approach registers categories
|
||||||
@ -359,7 +366,10 @@ pub fn parse_md(text: &str) -> Result<Model> {
|
|||||||
}
|
}
|
||||||
Rule::category_section => {
|
Rule::category_section => {
|
||||||
let mut inner = pair.into_inner();
|
let mut inner = pair.into_inner();
|
||||||
let cname = next(&mut inner, "category_section")?.as_str().trim().to_string();
|
let cname = next(&mut inner, "category_section")?
|
||||||
|
.as_str()
|
||||||
|
.trim()
|
||||||
|
.to_string();
|
||||||
let mut pc = PCategory {
|
let mut pc = PCategory {
|
||||||
name: cname,
|
name: cname,
|
||||||
items: Vec::new(),
|
items: Vec::new(),
|
||||||
@ -440,7 +450,10 @@ pub fn parse_md(text: &str) -> Result<Model> {
|
|||||||
}
|
}
|
||||||
Rule::view_section => {
|
Rule::view_section => {
|
||||||
let mut inner = pair.into_inner();
|
let mut inner = pair.into_inner();
|
||||||
let vname = next(&mut inner, "view_section")?.as_str().trim().to_string();
|
let vname = next(&mut inner, "view_section")?
|
||||||
|
.as_str()
|
||||||
|
.trim()
|
||||||
|
.to_string();
|
||||||
let mut pv = PView {
|
let mut pv = PView {
|
||||||
name: vname,
|
name: vname,
|
||||||
axes: Vec::new(),
|
axes: Vec::new(),
|
||||||
@ -615,9 +628,9 @@ pub fn export_csv(model: &Model, view_name: &str, path: &Path) -> Result<()> {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::{format_md, parse_md};
|
use super::{format_md, parse_md};
|
||||||
use crate::formula::parse_formula;
|
use crate::formula::parse_formula;
|
||||||
|
use crate::model::Model;
|
||||||
use crate::model::category::Group;
|
use crate::model::category::Group;
|
||||||
use crate::model::cell::{CellKey, CellValue};
|
use crate::model::cell::{CellKey, CellValue};
|
||||||
use crate::model::Model;
|
|
||||||
use crate::view::Axis;
|
use crate::view::Axis;
|
||||||
|
|
||||||
fn coord(pairs: &[(&str, &str)]) -> CellKey {
|
fn coord(pairs: &[(&str, &str)]) -> CellKey {
|
||||||
@ -656,7 +669,10 @@ mod tests {
|
|||||||
let text = format_md(&m);
|
let text = format_md(&m);
|
||||||
assert!(text.contains("## Category: Type"));
|
assert!(text.contains("## Category: Type"));
|
||||||
// Bare items are now comma-separated on one line
|
// Bare items are now comma-separated on one line
|
||||||
assert!(text.contains("- Food, Gas"), "expected comma-separated items:\n{text}");
|
assert!(
|
||||||
|
text.contains("- Food, Gas"),
|
||||||
|
"expected comma-separated items:\n{text}"
|
||||||
|
);
|
||||||
assert!(text.contains("## Category: Month"));
|
assert!(text.contains("## Category: Month"));
|
||||||
assert!(text.contains("Jan"));
|
assert!(text.contains("Jan"));
|
||||||
}
|
}
|
||||||
@ -706,7 +722,10 @@ mod tests {
|
|||||||
"expected sorted order Feb < Jan:\n{text}"
|
"expected sorted order Feb < Jan:\n{text}"
|
||||||
);
|
);
|
||||||
assert!(text.contains("= 200"), "number not quoted:\n{text}");
|
assert!(text.contains("= 200"), "number not quoted:\n{text}");
|
||||||
assert!(text.contains("= |N/A|"), "text should be pipe-quoted:\n{text}");
|
assert!(
|
||||||
|
text.contains("= |N/A|"),
|
||||||
|
"text should be pipe-quoted:\n{text}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -755,14 +774,16 @@ mod tests {
|
|||||||
fn parse_md_round_trips_categories_and_items() {
|
fn parse_md_round_trips_categories_and_items() {
|
||||||
let m = two_cat_model();
|
let m = two_cat_model();
|
||||||
let m2 = parse_md(&format_md(&m)).unwrap();
|
let m2 = parse_md(&format_md(&m)).unwrap();
|
||||||
assert!(m2
|
assert!(
|
||||||
.category("Type")
|
m2.category("Type")
|
||||||
.and_then(|c| c.items.get("Food"))
|
.and_then(|c| c.items.get("Food"))
|
||||||
.is_some());
|
.is_some()
|
||||||
assert!(m2
|
);
|
||||||
.category("Month")
|
assert!(
|
||||||
.and_then(|c| c.items.get("Feb"))
|
m2.category("Month")
|
||||||
.is_some());
|
.and_then(|c| c.items.get("Feb"))
|
||||||
|
.is_some()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -1450,8 +1471,8 @@ Type=Food = 42
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod parser_prop_tests {
|
mod parser_prop_tests {
|
||||||
use super::{format_md, parse_md};
|
use super::{format_md, parse_md};
|
||||||
use crate::model::cell::{CellKey, CellValue};
|
|
||||||
use crate::model::Model;
|
use crate::model::Model;
|
||||||
|
use crate::model::cell::{CellKey, CellValue};
|
||||||
use proptest::prelude::*;
|
use proptest::prelude::*;
|
||||||
|
|
||||||
fn coord(pairs: &[(&str, &str)]) -> CellKey {
|
fn coord(pairs: &[(&str, &str)]) -> CellKey {
|
||||||
@ -1501,31 +1522,29 @@ mod parser_prop_tests {
|
|||||||
let items2 = prop::collection::hash_set(safe_ident(), 1..=4);
|
let items2 = prop::collection::hash_set(safe_ident(), 1..=4);
|
||||||
let values = prop::collection::vec(cell_value(), 1..=8);
|
let values = prop::collection::vec(cell_value(), 1..=8);
|
||||||
|
|
||||||
(safe_ident(), items1, items2, values).prop_map(
|
(safe_ident(), items1, items2, values).prop_map(|(name, items1, items2, values)| {
|
||||||
|(name, items1, items2, values)| {
|
let mut m = Model::new(&name);
|
||||||
let mut m = Model::new(&name);
|
m.add_category("CatA").unwrap();
|
||||||
m.add_category("CatA").unwrap();
|
m.add_category("CatB").unwrap();
|
||||||
m.add_category("CatB").unwrap();
|
|
||||||
|
|
||||||
let items1: Vec<_> = items1.into_iter().collect();
|
let items1: Vec<_> = items1.into_iter().collect();
|
||||||
let items2: Vec<_> = items2.into_iter().collect();
|
let items2: Vec<_> = items2.into_iter().collect();
|
||||||
|
|
||||||
for item in &items1 {
|
for item in &items1 {
|
||||||
m.category_mut("CatA").unwrap().add_item(item);
|
m.category_mut("CatA").unwrap().add_item(item);
|
||||||
}
|
}
|
||||||
for item in &items2 {
|
for item in &items2 {
|
||||||
m.category_mut("CatB").unwrap().add_item(item);
|
m.category_mut("CatB").unwrap().add_item(item);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (i, value) in values.into_iter().enumerate() {
|
for (i, value) in values.into_iter().enumerate() {
|
||||||
let a = &items1[i % items1.len()];
|
let a = &items1[i % items1.len()];
|
||||||
let b = &items2[i % items2.len()];
|
let b = &items2[i % items2.len()];
|
||||||
m.set_cell(coord(&[("CatA", a), ("CatB", b)]), value);
|
m.set_cell(coord(&[("CatA", a), ("CatB", b)]), value);
|
||||||
}
|
}
|
||||||
|
|
||||||
m
|
m
|
||||||
},
|
})
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
proptest! {
|
proptest! {
|
||||||
@ -1720,9 +1739,9 @@ mod parser_prop_tests {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod parser_edge_cases {
|
mod parser_edge_cases {
|
||||||
use super::{format_md, parse_md};
|
use super::{format_md, parse_md};
|
||||||
|
use crate::model::Model;
|
||||||
use crate::model::category::Group;
|
use crate::model::category::Group;
|
||||||
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(
|
CellKey::new(
|
||||||
@ -1865,14 +1884,14 @@ mod parser_edge_cases {
|
|||||||
let text = format_md(&m);
|
let text = format_md(&m);
|
||||||
let loaded = parse_md(&text).unwrap();
|
let loaded = parse_md(&text).unwrap();
|
||||||
let cat = loaded.category("Type").unwrap();
|
let cat = loaded.category("Type").unwrap();
|
||||||
let item = cat
|
let item = cat.items.values().find(|i| i.name == "Data [raw]");
|
||||||
.items
|
|
||||||
.values()
|
|
||||||
.find(|i| i.name == "Data [raw]");
|
|
||||||
assert!(
|
assert!(
|
||||||
item.is_some(),
|
item.is_some(),
|
||||||
"Item 'Data [raw]' with group not found.\nItems: {:?}\n{text}",
|
"Item 'Data [raw]' with group not found.\nItems: {:?}\n{text}",
|
||||||
cat.items.values().map(|i| (&i.name, &i.group)).collect::<Vec<_>>()
|
cat.items
|
||||||
|
.values()
|
||||||
|
.map(|i| (&i.name, &i.group))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
);
|
);
|
||||||
assert_eq!(item.unwrap().group.as_deref(), Some("Input"));
|
assert_eq!(item.unwrap().group.as_deref(), Some("Input"));
|
||||||
}
|
}
|
||||||
@ -1882,8 +1901,10 @@ mod parser_edge_cases {
|
|||||||
#[test]
|
#[test]
|
||||||
fn parse_empty_string() {
|
fn parse_empty_string() {
|
||||||
let result = parse_md("");
|
let result = parse_md("");
|
||||||
assert!(result.is_err() || result.unwrap().name.is_empty(),
|
assert!(
|
||||||
"Empty input should either error or produce empty model");
|
result.is_err() || result.unwrap().name.is_empty(),
|
||||||
|
"Empty input should either error or produce empty model"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@ -12,8 +12,8 @@ use ratatui::style::Color;
|
|||||||
use crate::command::cmd::CmdContext;
|
use crate::command::cmd::CmdContext;
|
||||||
use crate::command::keymap::{Keymap, KeymapSet};
|
use crate::command::keymap::{Keymap, KeymapSet};
|
||||||
use crate::import::wizard::ImportWizard;
|
use crate::import::wizard::ImportWizard;
|
||||||
use crate::model::cell::CellValue;
|
|
||||||
use crate::model::Model;
|
use crate::model::Model;
|
||||||
|
use crate::model::cell::CellValue;
|
||||||
use crate::persistence;
|
use crate::persistence;
|
||||||
use crate::ui::grid::{
|
use crate::ui::grid::{
|
||||||
compute_col_widths, compute_row_header_width, compute_visible_cols, parse_number_format,
|
compute_col_widths, compute_row_header_width, compute_visible_cols, parse_number_format,
|
||||||
@ -338,7 +338,9 @@ impl App {
|
|||||||
self.model.categories.values().all(|c| {
|
self.model.categories.values().all(|c| {
|
||||||
matches!(
|
matches!(
|
||||||
c.kind,
|
c.kind,
|
||||||
CategoryKind::VirtualIndex | CategoryKind::VirtualDim | CategoryKind::VirtualMeasure
|
CategoryKind::VirtualIndex
|
||||||
|
| CategoryKind::VirtualDim
|
||||||
|
| CategoryKind::VirtualMeasure
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@ -389,16 +391,26 @@ impl App {
|
|||||||
/// Hint text for the status bar (context-sensitive)
|
/// Hint text for the status bar (context-sensitive)
|
||||||
pub fn hint_text(&self) -> &'static str {
|
pub fn hint_text(&self) -> &'static str {
|
||||||
match &self.mode {
|
match &self.mode {
|
||||||
AppMode::Normal => "hjkl:nav i:edit R:records P:prune F/C/V:panels T:tiles [:]:page >:drill ::cmd",
|
AppMode::Normal => {
|
||||||
|
"hjkl:nav i:edit R:records P:prune F/C/V:panels T:tiles [:]:page >:drill ::cmd"
|
||||||
|
}
|
||||||
AppMode::Editing { .. } => "Enter:commit Tab:commit+right Esc:cancel",
|
AppMode::Editing { .. } => "Enter:commit Tab:commit+right Esc:cancel",
|
||||||
AppMode::FormulaPanel => "n:new d:delete jk:nav Esc:back",
|
AppMode::FormulaPanel => "n:new d:delete jk:nav Esc:back",
|
||||||
AppMode::FormulaEdit { .. } => "Enter:save Esc:cancel — type: Name = expression",
|
AppMode::FormulaEdit { .. } => "Enter:save Esc:cancel — type: Name = expression",
|
||||||
AppMode::CategoryPanel => "jk:nav Space:cycle-axis n:new-cat a:add-items d:delete Esc:back",
|
AppMode::CategoryPanel => {
|
||||||
AppMode::CategoryAdd { .. } => "Enter:add & continue Tab:same Esc:done — type a category name",
|
"jk:nav Space:cycle-axis n:new-cat a:add-items d:delete Esc:back"
|
||||||
AppMode::ItemAdd { .. } => "Enter:add & continue Tab:same Esc:done — type an item name",
|
}
|
||||||
|
AppMode::CategoryAdd { .. } => {
|
||||||
|
"Enter:add & continue Tab:same Esc:done — type a category name"
|
||||||
|
}
|
||||||
|
AppMode::ItemAdd { .. } => {
|
||||||
|
"Enter:add & continue Tab:same Esc:done — type an item name"
|
||||||
|
}
|
||||||
AppMode::ViewPanel => "jk:nav Enter:switch n:new d:delete Esc:back",
|
AppMode::ViewPanel => "jk:nav Enter:switch n:new d:delete Esc:back",
|
||||||
AppMode::TileSelect => "hl:select Enter:cycle r/c/p/n:set-axis Esc:back",
|
AppMode::TileSelect => "hl:select Enter:cycle r/c/p/n:set-axis Esc:back",
|
||||||
AppMode::CommandMode { .. } => ":q quit :w save :import :add-cat :formula :show-item :help",
|
AppMode::CommandMode { .. } => {
|
||||||
|
":q quit :w save :import :add-cat :formula :show-item :help"
|
||||||
|
}
|
||||||
AppMode::ImportWizard => "Space:toggle c:cycle Enter:next Esc:cancel",
|
AppMode::ImportWizard => "Space:toggle c:cycle Enter:next Esc:cancel",
|
||||||
AppMode::Help => "h/l:pages q/Esc:close",
|
AppMode::Help => "h/l:pages q/Esc:close",
|
||||||
_ => "",
|
_ => "",
|
||||||
|
|||||||
@ -6,7 +6,7 @@ use ratatui::{
|
|||||||
|
|
||||||
use crate::model::Model;
|
use crate::model::Model;
|
||||||
use crate::ui::app::AppMode;
|
use crate::ui::app::AppMode;
|
||||||
use crate::ui::cat_tree::{build_cat_tree, CatTreeEntry};
|
use crate::ui::cat_tree::{CatTreeEntry, build_cat_tree};
|
||||||
use crate::ui::panel::PanelContent;
|
use crate::ui::panel::PanelContent;
|
||||||
use crate::view::Axis;
|
use crate::view::Axis;
|
||||||
|
|
||||||
|
|||||||
@ -743,7 +743,7 @@ pub struct ImportJsonHeadless {
|
|||||||
impl Effect for ImportJsonHeadless {
|
impl Effect for ImportJsonHeadless {
|
||||||
fn apply(&self, app: &mut App) {
|
fn apply(&self, app: &mut App) {
|
||||||
use crate::import::analyzer::{
|
use crate::import::analyzer::{
|
||||||
analyze_records, extract_array_at_path, find_array_paths, FieldKind,
|
FieldKind, analyze_records, extract_array_at_path, find_array_paths,
|
||||||
};
|
};
|
||||||
use crate::import::wizard::ImportPipeline;
|
use crate::import::wizard::ImportPipeline;
|
||||||
|
|
||||||
@ -952,8 +952,8 @@ pub fn help_page_set(page: usize) -> Box<dyn Effect> {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::model::cell::{CellKey, CellValue};
|
|
||||||
use crate::model::Model;
|
use crate::model::Model;
|
||||||
|
use crate::model::cell::{CellKey, CellValue};
|
||||||
|
|
||||||
fn test_app() -> App {
|
fn test_app() -> App {
|
||||||
let mut m = Model::new("Test");
|
let mut m = Model::new("Test");
|
||||||
@ -1036,12 +1036,13 @@ mod tests {
|
|||||||
fn add_formula_adds_target_item_to_category() {
|
fn add_formula_adds_target_item_to_category() {
|
||||||
let mut app = test_app();
|
let mut app = test_app();
|
||||||
// "Margin" does not exist as an item in "Type" before adding the formula
|
// "Margin" does not exist as an item in "Type" before adding the formula
|
||||||
assert!(!app
|
assert!(
|
||||||
.model
|
!app.model
|
||||||
.category("Type")
|
.category("Type")
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.ordered_item_names()
|
.ordered_item_names()
|
||||||
.contains(&"Margin"));
|
.contains(&"Margin")
|
||||||
|
);
|
||||||
AddFormula {
|
AddFormula {
|
||||||
raw: "Margin = Food * 2".to_string(),
|
raw: "Margin = Food * 2".to_string(),
|
||||||
target_category: "Type".to_string(),
|
target_category: "Type".to_string(),
|
||||||
@ -1257,16 +1258,14 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn set_buffer_empty_clears() {
|
fn set_buffer_empty_clears() {
|
||||||
let mut app = test_app();
|
let mut app = test_app();
|
||||||
app.buffers.insert("formula".to_string(), "old text".to_string());
|
app.buffers
|
||||||
|
.insert("formula".to_string(), "old text".to_string());
|
||||||
SetBuffer {
|
SetBuffer {
|
||||||
name: "formula".to_string(),
|
name: "formula".to_string(),
|
||||||
value: String::new(),
|
value: String::new(),
|
||||||
}
|
}
|
||||||
.apply(&mut app);
|
.apply(&mut app);
|
||||||
assert_eq!(
|
assert_eq!(app.buffers.get("formula").map(|s| s.as_str()), Some(""),);
|
||||||
app.buffers.get("formula").map(|s| s.as_str()),
|
|
||||||
Some(""),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -1597,19 +1596,21 @@ mod tests {
|
|||||||
group: "MyGroup".to_string(),
|
group: "MyGroup".to_string(),
|
||||||
}
|
}
|
||||||
.apply(&mut app);
|
.apply(&mut app);
|
||||||
assert!(app
|
assert!(
|
||||||
.model
|
app.model
|
||||||
.active_view()
|
.active_view()
|
||||||
.is_group_collapsed("Type", "MyGroup"));
|
.is_group_collapsed("Type", "MyGroup")
|
||||||
|
);
|
||||||
ToggleGroup {
|
ToggleGroup {
|
||||||
category: "Type".to_string(),
|
category: "Type".to_string(),
|
||||||
group: "MyGroup".to_string(),
|
group: "MyGroup".to_string(),
|
||||||
}
|
}
|
||||||
.apply(&mut app);
|
.apply(&mut app);
|
||||||
assert!(!app
|
assert!(
|
||||||
.model
|
!app.model
|
||||||
.active_view()
|
.active_view()
|
||||||
.is_group_collapsed("Type", "MyGroup"));
|
.is_group_collapsed("Type", "MyGroup")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Cycle axis ──────────────────────────────────────────────────────
|
// ── Cycle axis ──────────────────────────────────────────────────────
|
||||||
|
|||||||
@ -674,8 +674,8 @@ mod tests {
|
|||||||
|
|
||||||
use super::GridWidget;
|
use super::GridWidget;
|
||||||
use crate::formula::parse_formula;
|
use crate::formula::parse_formula;
|
||||||
use crate::model::cell::{CellKey, CellValue};
|
|
||||||
use crate::model::Model;
|
use crate::model::Model;
|
||||||
|
use crate::model::cell::{CellKey, CellValue};
|
||||||
use crate::ui::app::AppMode;
|
use crate::ui::app::AppMode;
|
||||||
use crate::view::GridLayout;
|
use crate::view::GridLayout;
|
||||||
|
|
||||||
@ -914,8 +914,10 @@ mod tests {
|
|||||||
CellValue::Number(600.0),
|
CellValue::Number(600.0),
|
||||||
);
|
);
|
||||||
m.add_formula(parse_formula("Profit = Revenue - Cost", "_Measure").unwrap());
|
m.add_formula(parse_formula("Profit = Revenue - Cost", "_Measure").unwrap());
|
||||||
m.active_view_mut().set_axis("_Measure", crate::view::Axis::Row);
|
m.active_view_mut()
|
||||||
m.active_view_mut().set_axis("Region", crate::view::Axis::Column);
|
.set_axis("_Measure", crate::view::Axis::Row);
|
||||||
|
m.active_view_mut()
|
||||||
|
.set_axis("Region", crate::view::Axis::Column);
|
||||||
|
|
||||||
let text = buf_text(&render(&m, 80, 24));
|
let text = buf_text(&render(&m, 80, 24));
|
||||||
assert!(text.contains("400"), "expected '400' (Profit) in:\n{text}");
|
assert!(text.contains("400"), "expected '400' (Profit) in:\n{text}");
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
|
|
||||||
use crate::model::cell::{CellKey, CellValue};
|
|
||||||
use crate::model::Model;
|
use crate::model::Model;
|
||||||
|
use crate::model::cell::{CellKey, CellValue};
|
||||||
use crate::view::{Axis, View};
|
use crate::view::{Axis, View};
|
||||||
|
|
||||||
/// Extract (record_index, dim_name) from a synthetic records-mode CellKey.
|
/// Extract (record_index, dim_name) from a synthetic records-mode CellKey.
|
||||||
@ -149,7 +149,7 @@ impl GridLayout {
|
|||||||
.collect()
|
.collect()
|
||||||
};
|
};
|
||||||
// Sort for deterministic ordering
|
// Sort for deterministic ordering
|
||||||
records.sort_by(|a, b| a.0 .0.cmp(&b.0 .0));
|
records.sort_by(|a, b| a.0.0.cmp(&b.0.0));
|
||||||
|
|
||||||
// Synthesize row items: one per record, labeled with its index
|
// Synthesize row items: one per record, labeled with its index
|
||||||
let row_items: Vec<AxisEntry> = (0..records.len())
|
let row_items: Vec<AxisEntry> = (0..records.len())
|
||||||
@ -192,7 +192,7 @@ impl GridLayout {
|
|||||||
// col_item is a category name
|
// col_item is a category name
|
||||||
let found = record
|
let found = record
|
||||||
.0
|
.0
|
||||||
.0
|
.0
|
||||||
.iter()
|
.iter()
|
||||||
.find(|(c, _)| c == &col_item)
|
.find(|(c, _)| c == &col_item)
|
||||||
.map(|(_, v)| v.clone());
|
.map(|(_, v)| v.clone());
|
||||||
@ -516,7 +516,10 @@ fn expand_category(
|
|||||||
if view.is_hidden(cat_name, item_name) {
|
if view.is_hidden(cat_name, item_name) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let item_group = cat.items.get(item_name.as_str()).and_then(|i| i.group.as_deref());
|
let item_group = cat
|
||||||
|
.items
|
||||||
|
.get(item_name.as_str())
|
||||||
|
.and_then(|i| i.group.as_deref());
|
||||||
|
|
||||||
// Emit a group header at each group boundary.
|
// Emit a group header at each group boundary.
|
||||||
if item_group != last_group {
|
if item_group != last_group {
|
||||||
@ -564,9 +567,9 @@ fn cross_product(model: &Model, view: &View, cats: &[String]) -> Vec<AxisEntry>
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{synthetic_record_info, AxisEntry, GridLayout};
|
use super::{AxisEntry, GridLayout, synthetic_record_info};
|
||||||
use crate::model::cell::{CellKey, CellValue};
|
|
||||||
use crate::model::Model;
|
use crate::model::Model;
|
||||||
|
use crate::model::cell::{CellKey, CellValue};
|
||||||
use crate::view::Axis;
|
use crate::view::Axis;
|
||||||
|
|
||||||
fn records_model() -> Model {
|
fn records_model() -> Model {
|
||||||
@ -867,14 +870,18 @@ mod tests {
|
|||||||
fn ungrouped_items_produce_no_headers() {
|
fn ungrouped_items_produce_no_headers() {
|
||||||
let m = two_cat_model();
|
let m = two_cat_model();
|
||||||
let layout = GridLayout::new(&m, m.active_view());
|
let layout = GridLayout::new(&m, m.active_view());
|
||||||
assert!(!layout
|
assert!(
|
||||||
.row_items
|
!layout
|
||||||
.iter()
|
.row_items
|
||||||
.any(|e| matches!(e, AxisEntry::GroupHeader { .. })));
|
.iter()
|
||||||
assert!(!layout
|
.any(|e| matches!(e, AxisEntry::GroupHeader { .. }))
|
||||||
.col_items
|
);
|
||||||
.iter()
|
assert!(
|
||||||
.any(|e| matches!(e, AxisEntry::GroupHeader { .. })));
|
!layout
|
||||||
|
.col_items
|
||||||
|
.iter()
|
||||||
|
.any(|e| matches!(e, AxisEntry::GroupHeader { .. }))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@ -3,5 +3,5 @@ pub mod layout;
|
|||||||
pub mod types;
|
pub mod types;
|
||||||
|
|
||||||
pub use axis::Axis;
|
pub use axis::Axis;
|
||||||
pub use layout::{synthetic_record_info, AxisEntry, GridLayout};
|
pub use layout::{AxisEntry, GridLayout, synthetic_record_info};
|
||||||
pub use types::View;
|
pub use types::View;
|
||||||
|
|||||||
Reference in New Issue
Block a user