Compare commits
15 Commits
main
...
334597d825
| Author | SHA1 | Date | |
|---|---|---|---|
| 334597d825 | |||
| 9329f04082 | |||
| 631067b011 | |||
| bd5dcfe1f7 | |||
| 0249afe33d | |||
| 132f017c79 | |||
| f1a777670f | |||
| 92f351bce3 | |||
| 6f4bc5e798 | |||
| 8e0c06d888 | |||
| 32677141de | |||
| ecc2987963 | |||
| 6d5138d904 | |||
| def3902eb9 | |||
| 9d88ad3205 |
+356
-57
@@ -47,10 +47,35 @@ pub struct CmdContext<'a> {
|
||||
/// The display value at the cursor in records mode (including any
|
||||
/// pending edit override). None for normal pivot views.
|
||||
pub records_value: Option<String>,
|
||||
/// How many data rows/cols fit on screen (for viewport scrolling).
|
||||
/// Defaults to generous fallbacks when unknown.
|
||||
pub visible_rows: usize,
|
||||
pub visible_cols: usize,
|
||||
/// Expanded categories in the tree panel
|
||||
pub expanded_cats: &'a std::collections::HashSet<String>,
|
||||
/// The key that triggered this command
|
||||
pub key_code: KeyCode,
|
||||
}
|
||||
|
||||
impl<'a> CmdContext<'a> {
|
||||
/// Resolve the category panel tree entry at the current cursor.
|
||||
pub fn cat_tree_entry(&self) -> Option<crate::ui::cat_tree::CatTreeEntry> {
|
||||
let tree = crate::ui::cat_tree::build_cat_tree(self.model, self.expanded_cats);
|
||||
tree.into_iter().nth(self.cat_panel_cursor)
|
||||
}
|
||||
|
||||
/// The category name at the current tree cursor (whether on a
|
||||
/// category header or an item).
|
||||
pub fn cat_at_cursor(&self) -> Option<String> {
|
||||
self.cat_tree_entry().map(|e| e.cat_name().to_string())
|
||||
}
|
||||
|
||||
/// Total number of entries in the category tree.
|
||||
pub fn cat_tree_len(&self) -> usize {
|
||||
crate::ui::cat_tree::build_cat_tree(self.model, self.expanded_cats).len()
|
||||
}
|
||||
}
|
||||
|
||||
/// A command that reads state and produces effects.
|
||||
pub trait Cmd: Debug + Send + Sync {
|
||||
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>>;
|
||||
@@ -217,6 +242,8 @@ pub struct CursorState {
|
||||
pub col_count: usize,
|
||||
pub row_offset: usize,
|
||||
pub col_offset: usize,
|
||||
pub visible_rows: usize,
|
||||
pub visible_cols: usize,
|
||||
}
|
||||
|
||||
impl CursorState {
|
||||
@@ -228,6 +255,8 @@ impl CursorState {
|
||||
col_count: ctx.col_count,
|
||||
row_offset: ctx.row_offset,
|
||||
col_offset: ctx.col_offset,
|
||||
visible_rows: ctx.visible_rows,
|
||||
visible_cols: ctx.visible_cols,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -238,21 +267,25 @@ fn viewport_effects(
|
||||
nc: usize,
|
||||
old_row_offset: usize,
|
||||
old_col_offset: usize,
|
||||
visible_rows: usize,
|
||||
visible_cols: usize,
|
||||
) -> Vec<Box<dyn Effect>> {
|
||||
let mut effects: Vec<Box<dyn Effect>> = vec![effect::set_selected(nr, nc)];
|
||||
let mut row_offset = old_row_offset;
|
||||
let mut col_offset = old_col_offset;
|
||||
let vr = visible_rows.max(1);
|
||||
let vc = visible_cols.max(1);
|
||||
if nr < row_offset {
|
||||
row_offset = nr;
|
||||
}
|
||||
if nr >= row_offset + 20 {
|
||||
row_offset = nr.saturating_sub(19);
|
||||
if nr >= row_offset + vr {
|
||||
row_offset = nr.saturating_sub(vr - 1);
|
||||
}
|
||||
if nc < col_offset {
|
||||
col_offset = nc;
|
||||
}
|
||||
if nc >= col_offset + 8 {
|
||||
col_offset = nc.saturating_sub(7);
|
||||
if nc >= col_offset + vc {
|
||||
col_offset = nc.saturating_sub(vc - 1);
|
||||
}
|
||||
if row_offset != old_row_offset {
|
||||
effects.push(Box::new(effect::SetRowOffset(row_offset)));
|
||||
@@ -280,7 +313,7 @@ impl Cmd for MoveSelection {
|
||||
let col_max = self.cursor.col_count.saturating_sub(1);
|
||||
let nr = (self.cursor.row as i32 + self.dr).clamp(0, row_max as i32) as usize;
|
||||
let nc = (self.cursor.col as i32 + self.dc).clamp(0, col_max as i32) as usize;
|
||||
viewport_effects(nr, nc, self.cursor.row_offset, self.cursor.col_offset)
|
||||
viewport_effects(nr, nc, self.cursor.row_offset, self.cursor.col_offset, self.cursor.visible_rows, self.cursor.visible_cols)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -543,25 +576,28 @@ impl Cmd for EnterSearchMode {
|
||||
#[derive(Debug)]
|
||||
pub struct TogglePanelAndFocus {
|
||||
pub panel: Panel,
|
||||
pub currently_open: bool,
|
||||
pub open: bool,
|
||||
pub focused: bool,
|
||||
}
|
||||
impl Cmd for TogglePanelAndFocus {
|
||||
fn name(&self) -> &'static str {
|
||||
"toggle-panel-and-focus"
|
||||
}
|
||||
fn execute(&self, _ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
||||
let new_open = !self.currently_open;
|
||||
let mut effects: Vec<Box<dyn Effect>> = vec![Box::new(effect::SetPanelOpen {
|
||||
let mut effects: Vec<Box<dyn Effect>> = Vec::new();
|
||||
effects.push(Box::new(effect::SetPanelOpen {
|
||||
panel: self.panel,
|
||||
open: new_open,
|
||||
})];
|
||||
if new_open {
|
||||
open: self.open,
|
||||
}));
|
||||
if self.focused {
|
||||
let mode = match self.panel {
|
||||
Panel::Formula => AppMode::FormulaPanel,
|
||||
Panel::Category => AppMode::CategoryPanel,
|
||||
Panel::View => AppMode::ViewPanel,
|
||||
};
|
||||
effects.push(effect::change_mode(mode));
|
||||
} else {
|
||||
effects.push(effect::change_mode(AppMode::Normal));
|
||||
}
|
||||
effects
|
||||
}
|
||||
@@ -643,7 +679,16 @@ impl Cmd for EditOrDrill {
|
||||
"edit-or-drill"
|
||||
}
|
||||
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
||||
let is_aggregated = ctx.records_col.is_none() && !ctx.none_cats.is_empty();
|
||||
// Only consider regular (non-virtual, non-label) categories on None
|
||||
// as true aggregation. Virtuals like _Index/_Dim are always None in
|
||||
// pivot mode and don't imply aggregation.
|
||||
let regular_none = ctx.none_cats.iter().any(|c| {
|
||||
ctx.model
|
||||
.category(c)
|
||||
.map(|cat| cat.kind.is_regular())
|
||||
.unwrap_or(false)
|
||||
});
|
||||
let is_aggregated = ctx.records_col.is_none() && regular_none;
|
||||
if is_aggregated {
|
||||
let Some(key) = ctx.cell_key.clone() else {
|
||||
return vec![effect::set_status(
|
||||
@@ -667,6 +712,45 @@ impl Cmd for EditOrDrill {
|
||||
}
|
||||
}
|
||||
|
||||
/// In records mode, add a new row with an empty value. The new cell gets
|
||||
/// coords from the current page filters. In pivot mode, this is a no-op.
|
||||
#[derive(Debug)]
|
||||
pub struct AddRecordRow;
|
||||
impl Cmd for AddRecordRow {
|
||||
fn name(&self) -> &'static str {
|
||||
"add-record-row"
|
||||
}
|
||||
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
||||
if ctx.records_col.is_none() {
|
||||
return vec![effect::set_status("add-record-row only works in records mode")];
|
||||
}
|
||||
// Build a CellKey from the current page filters
|
||||
let view = ctx.model.active_view();
|
||||
let page_cats: Vec<String> = view
|
||||
.categories_on(crate::view::Axis::Page)
|
||||
.into_iter()
|
||||
.map(String::from)
|
||||
.collect();
|
||||
let coords: Vec<(String, String)> = page_cats
|
||||
.iter()
|
||||
.map(|cat| {
|
||||
let sel = view
|
||||
.page_selection(cat)
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
(cat.clone(), sel)
|
||||
})
|
||||
.filter(|(_, v)| !v.is_empty())
|
||||
.collect();
|
||||
let key = crate::model::cell::CellKey::new(coords);
|
||||
vec![
|
||||
Box::new(effect::SetCell(key, CellValue::Number(0.0))),
|
||||
effect::mark_dirty(),
|
||||
effect::set_status("Added new record row"),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
/// Typewriter-style advance: move down, wrap to top of next column at bottom.
|
||||
#[derive(Debug)]
|
||||
pub struct EnterAdvance {
|
||||
@@ -687,7 +771,7 @@ impl Cmd for EnterAdvance {
|
||||
} else {
|
||||
(r, c) // already at bottom-right; stay
|
||||
};
|
||||
viewport_effects(nr, nc, self.cursor.row_offset, self.cursor.col_offset)
|
||||
viewport_effects(nr, nc, self.cursor.row_offset, self.cursor.col_offset, self.cursor.visible_rows, self.cursor.visible_cols)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1241,9 +1325,8 @@ impl Cmd for CycleAxisAtCursor {
|
||||
"cycle-axis-at-cursor"
|
||||
}
|
||||
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
||||
let cat_names = ctx.model.category_names();
|
||||
if let Some(cat_name) = cat_names.get(ctx.cat_panel_cursor) {
|
||||
vec![Box::new(effect::CycleAxis(cat_name.to_string()))]
|
||||
if let Some(cat_name) = ctx.cat_at_cursor() {
|
||||
vec![Box::new(effect::CycleAxis(cat_name))]
|
||||
} else {
|
||||
vec![]
|
||||
}
|
||||
@@ -1258,10 +1341,9 @@ impl Cmd for OpenItemAddAtCursor {
|
||||
"open-item-add-at-cursor"
|
||||
}
|
||||
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
||||
let cat_names = ctx.model.category_names();
|
||||
if let Some(cat_name) = cat_names.get(ctx.cat_panel_cursor) {
|
||||
if let Some(cat_name) = ctx.cat_at_cursor() {
|
||||
vec![effect::change_mode(AppMode::ItemAdd {
|
||||
category: cat_name.to_string(),
|
||||
category: cat_name,
|
||||
buffer: String::new(),
|
||||
})]
|
||||
} else {
|
||||
@@ -1272,6 +1354,187 @@ impl Cmd for OpenItemAddAtCursor {
|
||||
}
|
||||
}
|
||||
|
||||
/// Toggle expand/collapse of the category at the tree cursor.
|
||||
#[derive(Debug)]
|
||||
pub struct ToggleCatExpand;
|
||||
impl Cmd for ToggleCatExpand {
|
||||
fn name(&self) -> &'static str {
|
||||
"toggle-cat-expand"
|
||||
}
|
||||
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
||||
if let Some(cat_name) = ctx.cat_at_cursor() {
|
||||
vec![Box::new(effect::ToggleCatExpand(cat_name))]
|
||||
} else {
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Filter to item: when on an item row, set the category to Page with the
|
||||
/// item as the filter value.
|
||||
#[derive(Debug)]
|
||||
pub struct FilterToItem;
|
||||
impl Cmd for FilterToItem {
|
||||
fn name(&self) -> &'static str {
|
||||
"filter-to-item"
|
||||
}
|
||||
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
||||
use crate::ui::cat_tree::CatTreeEntry;
|
||||
match ctx.cat_tree_entry() {
|
||||
Some(CatTreeEntry::Item {
|
||||
cat_name,
|
||||
item_name,
|
||||
}) => {
|
||||
vec![
|
||||
Box::new(effect::SetAxis {
|
||||
category: cat_name.clone(),
|
||||
axis: crate::view::Axis::Page,
|
||||
}),
|
||||
Box::new(effect::SetPageSelection {
|
||||
category: cat_name.clone(),
|
||||
item: item_name.clone(),
|
||||
}),
|
||||
effect::set_status(format!("Filter: {cat_name} = {item_name}")),
|
||||
]
|
||||
}
|
||||
Some(CatTreeEntry::Category { .. }) => {
|
||||
// On a category header — toggle expand instead
|
||||
ToggleCatExpand.execute(ctx)
|
||||
}
|
||||
None => vec![],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Toggle pruning of empty rows/columns in the current view.
|
||||
#[derive(Debug)]
|
||||
pub struct TogglePruneEmpty;
|
||||
impl Cmd for TogglePruneEmpty {
|
||||
fn name(&self) -> &'static str {
|
||||
"toggle-prune-empty"
|
||||
}
|
||||
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
||||
let currently_on = ctx.model.active_view().prune_empty;
|
||||
vec![
|
||||
Box::new(effect::TogglePruneEmpty),
|
||||
effect::set_status(if currently_on {
|
||||
"Showing all rows/columns"
|
||||
} else {
|
||||
"Hiding empty rows/columns"
|
||||
}),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
/// Toggle between records mode (_Index on Row, _Dim on Column) and
|
||||
/// pivot mode (auto-assigned axes). In records mode every cell is shown
|
||||
/// as a flat row; in pivot mode the view is a cross-tab.
|
||||
#[derive(Debug)]
|
||||
pub struct ToggleRecordsMode;
|
||||
impl Cmd for ToggleRecordsMode {
|
||||
fn name(&self) -> &'static str {
|
||||
"toggle-records-mode"
|
||||
}
|
||||
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
||||
use crate::view::Axis;
|
||||
let view = ctx.model.active_view();
|
||||
|
||||
// Detect current state
|
||||
let is_records = view
|
||||
.category_axes
|
||||
.get("_Index")
|
||||
.copied()
|
||||
== Some(Axis::Row)
|
||||
&& view.category_axes.get("_Dim").copied() == Some(Axis::Column);
|
||||
|
||||
let mut effects: Vec<Box<dyn Effect>> = Vec::new();
|
||||
|
||||
if is_records {
|
||||
// Switch back to pivot: auto-assign axes
|
||||
// First regular category → Row, second → Column, rest → Page,
|
||||
// virtuals/labels → None.
|
||||
let mut row_done = false;
|
||||
let mut col_done = false;
|
||||
for (name, cat) in &ctx.model.categories {
|
||||
let axis = if !cat.kind.is_regular() {
|
||||
Axis::None
|
||||
} else if !row_done {
|
||||
row_done = true;
|
||||
Axis::Row
|
||||
} else if !col_done {
|
||||
col_done = true;
|
||||
Axis::Column
|
||||
} else {
|
||||
Axis::Page
|
||||
};
|
||||
effects.push(Box::new(effect::SetAxis {
|
||||
category: name.clone(),
|
||||
axis,
|
||||
}));
|
||||
}
|
||||
effects.push(effect::set_status("Pivot mode"));
|
||||
} else {
|
||||
// Switch to records mode
|
||||
effects.push(Box::new(effect::SetAxis {
|
||||
category: "_Index".to_string(),
|
||||
axis: Axis::Row,
|
||||
}));
|
||||
effects.push(Box::new(effect::SetAxis {
|
||||
category: "_Dim".to_string(),
|
||||
axis: Axis::Column,
|
||||
}));
|
||||
// Everything else → None
|
||||
for name in ctx.model.categories.keys() {
|
||||
if name != "_Index" && name != "_Dim" {
|
||||
effects.push(Box::new(effect::SetAxis {
|
||||
category: name.clone(),
|
||||
axis: Axis::None,
|
||||
}));
|
||||
}
|
||||
}
|
||||
effects.push(effect::set_status("Records mode"));
|
||||
}
|
||||
effects
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete the category or item at the panel cursor.
|
||||
/// On a category header → delete the whole category.
|
||||
/// On an item row → delete just that item.
|
||||
#[derive(Debug)]
|
||||
pub struct DeleteCategoryAtCursor;
|
||||
impl Cmd for DeleteCategoryAtCursor {
|
||||
fn name(&self) -> &'static str {
|
||||
"delete-category-at-cursor"
|
||||
}
|
||||
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
||||
use crate::ui::cat_tree::CatTreeEntry;
|
||||
match ctx.cat_tree_entry() {
|
||||
Some(CatTreeEntry::Category { name, .. }) => {
|
||||
vec![
|
||||
Box::new(effect::RemoveCategory(name.clone())),
|
||||
effect::mark_dirty(),
|
||||
effect::set_status(format!("Deleted category '{name}'")),
|
||||
]
|
||||
}
|
||||
Some(CatTreeEntry::Item {
|
||||
cat_name,
|
||||
item_name,
|
||||
}) => {
|
||||
vec![
|
||||
Box::new(effect::RemoveItem {
|
||||
category: cat_name.clone(),
|
||||
item: item_name.clone(),
|
||||
}),
|
||||
effect::mark_dirty(),
|
||||
effect::set_status(format!("Deleted item '{item_name}' from '{cat_name}'")),
|
||||
]
|
||||
}
|
||||
None => vec![effect::set_status("No category to delete")],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── View panel commands ─────────────────────────────────────────────────────
|
||||
|
||||
/// Switch to the view at the panel cursor and return to Normal mode.
|
||||
@@ -1704,12 +1967,13 @@ impl Cmd for CommitCellEdit {
|
||||
)));
|
||||
effects.push(effect::mark_dirty());
|
||||
}
|
||||
effects.push(effect::change_mode(AppMode::Normal));
|
||||
// Advance cursor down (typewriter-style)
|
||||
// Advance cursor down (typewriter-style) and re-enter edit mode
|
||||
// at the new cell so the user can continue data entry.
|
||||
let adv = EnterAdvance {
|
||||
cursor: CursorState::from_ctx(ctx),
|
||||
};
|
||||
effects.extend(adv.execute(ctx));
|
||||
effects.push(Box::new(effect::EnterEditAtCursor));
|
||||
effects
|
||||
}
|
||||
}
|
||||
@@ -1755,18 +2019,19 @@ impl Cmd for CommitCategoryAdd {
|
||||
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
||||
let buf = ctx.buffers.get("category").cloned().unwrap_or_default();
|
||||
let trimmed = buf.trim().to_string();
|
||||
let mut effects: Vec<Box<dyn Effect>> = Vec::new();
|
||||
if !trimmed.is_empty() {
|
||||
effects.push(Box::new(effect::AddCategory(trimmed.clone())));
|
||||
effects.push(effect::mark_dirty());
|
||||
effects.push(effect::set_status(format!("Added category \"{trimmed}\"")));
|
||||
if trimmed.is_empty() {
|
||||
// Empty → exit category-add mode
|
||||
return vec![effect::change_mode(AppMode::CategoryPanel)];
|
||||
}
|
||||
// Clear buffer for next entry
|
||||
effects.push(Box::new(effect::SetBuffer {
|
||||
name: "category".to_string(),
|
||||
value: String::new(),
|
||||
}));
|
||||
effects
|
||||
vec![
|
||||
Box::new(effect::AddCategory(trimmed.clone())),
|
||||
effect::mark_dirty(),
|
||||
effect::set_status(format!("Added category \"{trimmed}\"")),
|
||||
Box::new(effect::SetBuffer {
|
||||
name: "category".to_string(),
|
||||
value: String::new(),
|
||||
}),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1780,27 +2045,27 @@ impl Cmd for CommitItemAdd {
|
||||
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
||||
let buf = ctx.buffers.get("item").cloned().unwrap_or_default();
|
||||
let trimmed = buf.trim().to_string();
|
||||
// Get the category from the mode
|
||||
if trimmed.is_empty() {
|
||||
// Empty → exit item-add mode
|
||||
return vec![effect::change_mode(AppMode::CategoryPanel)];
|
||||
}
|
||||
let category = if let AppMode::ItemAdd { category, .. } = ctx.mode {
|
||||
category.clone()
|
||||
} else {
|
||||
return vec![];
|
||||
};
|
||||
let mut effects: Vec<Box<dyn Effect>> = Vec::new();
|
||||
if !trimmed.is_empty() {
|
||||
effects.push(Box::new(effect::AddItem {
|
||||
vec![
|
||||
Box::new(effect::AddItem {
|
||||
category,
|
||||
item: trimmed.clone(),
|
||||
}));
|
||||
effects.push(effect::mark_dirty());
|
||||
effects.push(effect::set_status(format!("Added \"{trimmed}\"")));
|
||||
}
|
||||
// Clear buffer for next entry
|
||||
effects.push(Box::new(effect::SetBuffer {
|
||||
name: "item".to_string(),
|
||||
value: String::new(),
|
||||
}));
|
||||
effects
|
||||
}),
|
||||
effect::mark_dirty(),
|
||||
effect::set_status(format!("Added \"{trimmed}\"")),
|
||||
Box::new(effect::SetBuffer {
|
||||
name: "item".to_string(),
|
||||
value: String::new(),
|
||||
}),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2201,6 +2466,8 @@ pub fn default_registry() -> CmdRegistry {
|
||||
col_count: 0,
|
||||
row_offset: 0,
|
||||
col_offset: 0,
|
||||
visible_rows: 20,
|
||||
visible_cols: 8,
|
||||
},
|
||||
}))
|
||||
},
|
||||
@@ -2281,6 +2548,8 @@ pub fn default_registry() -> CmdRegistry {
|
||||
col_count: 0,
|
||||
row_offset: 0,
|
||||
col_offset: 0,
|
||||
visible_rows: 20,
|
||||
visible_cols: 8,
|
||||
},
|
||||
}))
|
||||
},
|
||||
@@ -2304,6 +2573,8 @@ pub fn default_registry() -> CmdRegistry {
|
||||
col_count: 0,
|
||||
row_offset: 0,
|
||||
col_offset: 0,
|
||||
visible_rows: 20,
|
||||
visible_cols: 8,
|
||||
},
|
||||
}))
|
||||
},
|
||||
@@ -2439,26 +2710,42 @@ pub fn default_registry() -> CmdRegistry {
|
||||
|
||||
// ── Panel operations ─────────────────────────────────────────────────
|
||||
r.register(
|
||||
&TogglePanelAndFocus { panel: Panel::Formula, currently_open: false },
|
||||
&TogglePanelAndFocus { panel: Panel::Formula, open: true, focused: true },
|
||||
|args| {
|
||||
// Parse: toggle-panel-and-focus <panel> [open] [focused]
|
||||
require_args("toggle-panel-and-focus", args, 1)?;
|
||||
let panel = parse_panel(&args[0])?;
|
||||
let open = args.get(1).map(|s| s == "true").unwrap_or(true);
|
||||
let focused = args.get(2).map(|s| s == "true").unwrap_or(open);
|
||||
Ok(Box::new(TogglePanelAndFocus {
|
||||
panel,
|
||||
currently_open: false,
|
||||
open,
|
||||
focused,
|
||||
}))
|
||||
},
|
||||
|args, ctx| {
|
||||
require_args("toggle-panel-and-focus", args, 1)?;
|
||||
let panel = parse_panel(&args[0])?;
|
||||
// Default interactive: if already open+focused → close, else open+focus
|
||||
let currently_open = match panel {
|
||||
Panel::Formula => ctx.formula_panel_open,
|
||||
Panel::Category => ctx.category_panel_open,
|
||||
Panel::View => ctx.view_panel_open,
|
||||
};
|
||||
let currently_focused = match panel {
|
||||
Panel::Formula => matches!(ctx.mode, AppMode::FormulaPanel | AppMode::FormulaEdit { .. }),
|
||||
Panel::Category => matches!(ctx.mode, AppMode::CategoryPanel | AppMode::CategoryAdd { .. } | AppMode::ItemAdd { .. }),
|
||||
Panel::View => matches!(ctx.mode, AppMode::ViewPanel),
|
||||
};
|
||||
let (open, focused) = if currently_open && currently_focused {
|
||||
(false, false) // close
|
||||
} else {
|
||||
(true, true) // open + focus
|
||||
};
|
||||
Ok(Box::new(TogglePanelAndFocus {
|
||||
panel,
|
||||
currently_open,
|
||||
open,
|
||||
focused,
|
||||
}))
|
||||
},
|
||||
);
|
||||
@@ -2522,7 +2809,7 @@ pub fn default_registry() -> CmdRegistry {
|
||||
let delta = args[1].parse::<i32>().map_err(|e| e.to_string())?;
|
||||
let (current, max) = match panel {
|
||||
Panel::Formula => (ctx.formula_cursor, ctx.model.formulas().len()),
|
||||
Panel::Category => (ctx.cat_panel_cursor, ctx.model.category_names().len()),
|
||||
Panel::Category => (ctx.cat_panel_cursor, ctx.cat_tree_len()),
|
||||
Panel::View => (ctx.view_panel_cursor, ctx.model.views.len()),
|
||||
};
|
||||
Ok(Box::new(MovePanelCursor {
|
||||
@@ -2536,8 +2823,14 @@ pub fn default_registry() -> CmdRegistry {
|
||||
r.register_nullary(|| {
|
||||
Box::new(DeleteFormulaAtCursor)
|
||||
});
|
||||
r.register_nullary(|| Box::new(AddRecordRow));
|
||||
r.register_nullary(|| Box::new(TogglePruneEmpty));
|
||||
r.register_nullary(|| Box::new(ToggleRecordsMode));
|
||||
r.register_nullary(|| Box::new(CycleAxisAtCursor));
|
||||
r.register_nullary(|| Box::new(OpenItemAddAtCursor));
|
||||
r.register_nullary(|| Box::new(DeleteCategoryAtCursor));
|
||||
r.register_nullary(|| Box::new(ToggleCatExpand));
|
||||
r.register_nullary(|| Box::new(FilterToItem));
|
||||
r.register_nullary(|| Box::new(SwitchViewAtCursor));
|
||||
r.register_nullary(|| Box::new(CreateAndSwitchView));
|
||||
r.register_nullary(|| Box::new(DeleteViewAtCursor));
|
||||
@@ -2655,6 +2948,8 @@ mod tests {
|
||||
|
||||
static EMPTY_BUFFERS: std::sync::LazyLock<HashMap<String, String>> =
|
||||
std::sync::LazyLock::new(HashMap::new);
|
||||
static EMPTY_EXPANDED: std::sync::LazyLock<std::collections::HashSet<String>> =
|
||||
std::sync::LazyLock::new(std::collections::HashSet::new);
|
||||
|
||||
fn make_ctx(model: &Model) -> CmdContext<'_> {
|
||||
let view = model.active_view();
|
||||
@@ -2686,6 +2981,9 @@ mod tests {
|
||||
cell_key: layout.cell_key(sr, sc),
|
||||
row_count: layout.row_count(),
|
||||
col_count: layout.col_count(),
|
||||
visible_rows: 20,
|
||||
visible_cols: 8,
|
||||
expanded_cats: &EMPTY_EXPANDED,
|
||||
key_code: KeyCode::Null,
|
||||
}
|
||||
}
|
||||
@@ -2792,12 +3090,13 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toggle_panel_and_focus_opens_and_enters_mode() {
|
||||
fn toggle_panel_open_and_focus() {
|
||||
let m = two_cat_model();
|
||||
let ctx = make_ctx(&m);
|
||||
let cmd = TogglePanelAndFocus {
|
||||
panel: effect::Panel::Formula,
|
||||
currently_open: false,
|
||||
open: true,
|
||||
focused: true,
|
||||
};
|
||||
let effects = cmd.execute(&ctx);
|
||||
assert_eq!(effects.len(), 2); // SetPanelOpen + ChangeMode
|
||||
@@ -2809,16 +3108,16 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toggle_panel_and_focus_closes_when_open() {
|
||||
fn toggle_panel_close_and_unfocus() {
|
||||
let m = two_cat_model();
|
||||
let mut ctx = make_ctx(&m);
|
||||
ctx.formula_panel_open = true;
|
||||
let ctx = make_ctx(&m);
|
||||
let cmd = TogglePanelAndFocus {
|
||||
panel: effect::Panel::Formula,
|
||||
currently_open: true,
|
||||
open: false,
|
||||
focused: false,
|
||||
};
|
||||
let effects = cmd.execute(&ctx);
|
||||
assert_eq!(effects.len(), 1); // SetPanelOpen only, no mode change
|
||||
assert_eq!(effects.len(), 2); // SetPanelOpen(false) + ChangeMode(Normal)
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+34
-2
@@ -354,9 +354,14 @@ impl KeymapSet {
|
||||
normal.bind(KeyCode::Char('z'), none, "toggle-group-under-cursor");
|
||||
normal.bind(KeyCode::Char('H'), none, "hide-selected-row-item");
|
||||
|
||||
// Drill into aggregated cell / view history
|
||||
// Drill into aggregated cell / view history / add row
|
||||
normal.bind(KeyCode::Char('>'), none, "drill-into-cell");
|
||||
normal.bind(KeyCode::Char('<'), none, "view-back");
|
||||
normal.bind(KeyCode::Char('o'), none, "add-record-row");
|
||||
|
||||
// Records mode toggle and prune toggle
|
||||
normal.bind(KeyCode::Char('R'), none, "toggle-records-mode");
|
||||
normal.bind(KeyCode::Char('P'), none, "toggle-prune-empty");
|
||||
|
||||
// Tile select
|
||||
normal.bind(KeyCode::Char('T'), none, "enter-tile-select");
|
||||
@@ -417,6 +422,9 @@ impl KeymapSet {
|
||||
fp.bind(KeyCode::Char('o'), none, "enter-formula-edit");
|
||||
fp.bind(KeyCode::Char('d'), none, "delete-formula-at-cursor");
|
||||
fp.bind(KeyCode::Delete, none, "delete-formula-at-cursor");
|
||||
fp.bind_args(KeyCode::Char('F'), none, "toggle-panel-and-focus", vec!["formula".into()]);
|
||||
fp.bind_args(KeyCode::Char('C'), none, "toggle-panel-and-focus", vec!["category".into()]);
|
||||
fp.bind_args(KeyCode::Char('V'), none, "toggle-panel-and-focus", vec!["view".into()]);
|
||||
set.insert(ModeKey::FormulaPanel, Arc::new(fp));
|
||||
|
||||
// ── Category panel ───────────────────────────────────────────────
|
||||
@@ -439,7 +447,7 @@ impl KeymapSet {
|
||||
vec!["category".into(), "1".into()],
|
||||
);
|
||||
}
|
||||
cp.bind(KeyCode::Enter, none, "cycle-axis-at-cursor");
|
||||
cp.bind(KeyCode::Enter, none, "filter-to-item");
|
||||
cp.bind(KeyCode::Char(' '), none, "cycle-axis-at-cursor");
|
||||
cp.bind_args(
|
||||
KeyCode::Char('n'),
|
||||
@@ -449,6 +457,27 @@ impl KeymapSet {
|
||||
);
|
||||
cp.bind(KeyCode::Char('a'), none, "open-item-add-at-cursor");
|
||||
cp.bind(KeyCode::Char('o'), none, "open-item-add-at-cursor");
|
||||
cp.bind(KeyCode::Char('d'), none, "delete-category-at-cursor");
|
||||
cp.bind(KeyCode::Delete, none, "delete-category-at-cursor");
|
||||
// C/F/V in panel modes: close panel (toggle-panel-and-focus sees focused=true)
|
||||
cp.bind_args(
|
||||
KeyCode::Char('C'),
|
||||
none,
|
||||
"toggle-panel-and-focus",
|
||||
vec!["category".into()],
|
||||
);
|
||||
cp.bind_args(
|
||||
KeyCode::Char('F'),
|
||||
none,
|
||||
"toggle-panel-and-focus",
|
||||
vec!["formula".into()],
|
||||
);
|
||||
cp.bind_args(
|
||||
KeyCode::Char('V'),
|
||||
none,
|
||||
"toggle-panel-and-focus",
|
||||
vec!["view".into()],
|
||||
);
|
||||
set.insert(ModeKey::CategoryPanel, Arc::new(cp));
|
||||
|
||||
// ── View panel ───────────────────────────────────────────────────
|
||||
@@ -476,6 +505,9 @@ impl KeymapSet {
|
||||
vp.bind(KeyCode::Char('o'), none, "create-and-switch-view");
|
||||
vp.bind(KeyCode::Char('d'), none, "delete-view-at-cursor");
|
||||
vp.bind(KeyCode::Delete, none, "delete-view-at-cursor");
|
||||
vp.bind_args(KeyCode::Char('V'), none, "toggle-panel-and-focus", vec!["view".into()]);
|
||||
vp.bind_args(KeyCode::Char('C'), none, "toggle-panel-and-focus", vec!["category".into()]);
|
||||
vp.bind_args(KeyCode::Char('F'), none, "toggle-panel-and-focus", vec!["formula".into()]);
|
||||
set.insert(ModeKey::ViewPanel, Arc::new(vp));
|
||||
|
||||
// ── Tile select ──────────────────────────────────────────────────
|
||||
|
||||
+66
-30
@@ -65,8 +65,15 @@ pub fn run_tui(
|
||||
tui_context.terminal.draw(|f| draw(f, &app))?;
|
||||
|
||||
if event::poll(Duration::from_millis(100))? {
|
||||
if let Event::Key(key) = event::read()? {
|
||||
app.handle_key(key)?;
|
||||
match event::read()? {
|
||||
Event::Key(key) => {
|
||||
app.handle_key(key)?;
|
||||
}
|
||||
Event::Resize(w, h) => {
|
||||
app.term_width = w;
|
||||
app.term_height = h;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,9 +168,7 @@ fn draw(f: &mut Frame, app: &App) {
|
||||
f.render_widget(ImportWizardWidget::new(wizard), size);
|
||||
}
|
||||
}
|
||||
if matches!(app.mode, AppMode::ExportPrompt { .. }) {
|
||||
draw_export_prompt(f, size, app);
|
||||
}
|
||||
// ExportPrompt now uses the minibuffer at the bottom bar.
|
||||
if app.is_empty_model() && matches!(app.mode, AppMode::Normal | AppMode::CommandMode { .. }) {
|
||||
draw_welcome(f, main_chunks[1]);
|
||||
}
|
||||
@@ -228,7 +233,12 @@ fn draw_content(f: &mut Frame, area: Rect, app: &App) {
|
||||
if app.category_panel_open {
|
||||
let a = Rect::new(side.x, y, side.width, ph);
|
||||
f.render_widget(
|
||||
CategoryPanel::new(&app.model, &app.mode, app.cat_panel_cursor),
|
||||
CategoryPanel::new(
|
||||
&app.model,
|
||||
&app.mode,
|
||||
app.cat_panel_cursor,
|
||||
&app.expanded_cats,
|
||||
),
|
||||
a,
|
||||
);
|
||||
y += ph;
|
||||
@@ -261,12 +271,59 @@ fn draw_tile_bar(f: &mut Frame, area: Rect, app: &App) {
|
||||
}
|
||||
|
||||
fn draw_bottom_bar(f: &mut Frame, area: Rect, app: &App) {
|
||||
match app.mode {
|
||||
// All text-entry modes use the bottom bar as a minibuffer.
|
||||
let minibuf = match &app.mode {
|
||||
AppMode::CommandMode { .. } => {
|
||||
let buf = app.buffers.get("command").map(|s| s.as_str()).unwrap_or("");
|
||||
draw_command_bar(f, area, buf);
|
||||
Some((format!(":{buf}▌"), Color::Yellow))
|
||||
}
|
||||
_ => draw_status(f, area, app),
|
||||
AppMode::Editing { .. } => {
|
||||
let buf = app.buffers.get("edit").map(|s| s.as_str()).unwrap_or("");
|
||||
Some((format!("edit: {buf}▌"), Color::Green))
|
||||
}
|
||||
AppMode::FormulaEdit { .. } => {
|
||||
let buf = app
|
||||
.buffers
|
||||
.get("formula")
|
||||
.map(|s| s.as_str())
|
||||
.unwrap_or("");
|
||||
Some((format!("formula: {buf}▌"), Color::Cyan))
|
||||
}
|
||||
AppMode::CategoryAdd { .. } => {
|
||||
let buf = app
|
||||
.buffers
|
||||
.get("category")
|
||||
.map(|s| s.as_str())
|
||||
.unwrap_or("");
|
||||
Some((format!("new category: {buf}▌"), Color::Yellow))
|
||||
}
|
||||
AppMode::ItemAdd { category, .. } => {
|
||||
let buf = app.buffers.get("item").map(|s| s.as_str()).unwrap_or("");
|
||||
Some((format!("add item to {category}: {buf}▌"), Color::Green))
|
||||
}
|
||||
AppMode::ExportPrompt { .. } => {
|
||||
let buf = app
|
||||
.buffers
|
||||
.get("export")
|
||||
.map(|s| s.as_str())
|
||||
.unwrap_or("");
|
||||
Some((format!("export path: {buf}▌"), Color::Yellow))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
|
||||
if let Some((text, color)) = minibuf {
|
||||
f.render_widget(
|
||||
Paragraph::new(text).style(
|
||||
Style::default()
|
||||
.fg(color)
|
||||
.bg(Color::Indexed(235))
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
area,
|
||||
);
|
||||
} else {
|
||||
draw_status(f, area, app);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -292,27 +349,6 @@ fn draw_status(f: &mut Frame, area: Rect, app: &App) {
|
||||
f.render_widget(Paragraph::new(line).style(mode_style(&app.mode)), area);
|
||||
}
|
||||
|
||||
fn draw_command_bar(f: &mut Frame, area: Rect, buffer: &str) {
|
||||
f.render_widget(
|
||||
Paragraph::new(format!(":{buffer}▌"))
|
||||
.style(Style::default().fg(Color::White).bg(Color::Black)),
|
||||
area,
|
||||
);
|
||||
}
|
||||
|
||||
fn draw_export_prompt(f: &mut Frame, area: Rect, app: &App) {
|
||||
let buf = if let AppMode::ExportPrompt { buffer } = &app.mode {
|
||||
buffer.as_str()
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let popup = centered_popup(area, 64, 3);
|
||||
let inner = draw_popup_frame(f, popup, " Export CSV — path (Esc cancel) ", Color::Yellow);
|
||||
f.render_widget(
|
||||
Paragraph::new(format!("{buf}▌")).style(Style::default().fg(Color::Green)),
|
||||
inner,
|
||||
);
|
||||
}
|
||||
|
||||
fn draw_welcome(f: &mut Frame, area: Rect) {
|
||||
let popup = centered_popup(area, 58, 20);
|
||||
|
||||
@@ -117,6 +117,10 @@ impl Category {
|
||||
id
|
||||
}
|
||||
|
||||
pub fn remove_item(&mut self, name: &str) {
|
||||
self.items.shift_remove(name);
|
||||
}
|
||||
|
||||
pub fn add_item_in_group(
|
||||
&mut self,
|
||||
name: impl Into<String>,
|
||||
|
||||
+71
-1
@@ -53,10 +53,14 @@ impl Model {
|
||||
next_category_id: 2,
|
||||
measure_agg: HashMap::new(),
|
||||
};
|
||||
// Add virtuals to existing views (default view)
|
||||
// Add virtuals to existing views (default view).
|
||||
// Start in records mode; on_category_added will reclaim Row/Column
|
||||
// for the first two regular categories.
|
||||
for view in m.views.values_mut() {
|
||||
view.on_category_added("_Index");
|
||||
view.on_category_added("_Dim");
|
||||
view.set_axis("_Index", crate::view::Axis::Row);
|
||||
view.set_axis("_Dim", crate::view::Axis::Column);
|
||||
}
|
||||
m
|
||||
}
|
||||
@@ -107,6 +111,47 @@ impl Model {
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// Remove a category and all cells that reference it.
|
||||
pub fn remove_category(&mut self, name: &str) {
|
||||
if !self.categories.contains_key(name) {
|
||||
return;
|
||||
}
|
||||
self.categories.shift_remove(name);
|
||||
// Remove from all views
|
||||
for view in self.views.values_mut() {
|
||||
view.on_category_removed(name);
|
||||
}
|
||||
// Remove cells that have a coord in this category
|
||||
let to_remove: Vec<CellKey> = self
|
||||
.data
|
||||
.iter_cells()
|
||||
.filter(|(k, _)| k.get(name).is_some())
|
||||
.map(|(k, _)| k)
|
||||
.collect();
|
||||
for k in to_remove {
|
||||
self.data.remove(&k);
|
||||
}
|
||||
// Remove formulas targeting this category
|
||||
self.formulas
|
||||
.retain(|f| f.target_category != name);
|
||||
}
|
||||
|
||||
/// Remove an item from a category and all cells that reference it.
|
||||
pub fn remove_item(&mut self, cat_name: &str, item_name: &str) {
|
||||
if let Some(cat) = self.categories.get_mut(cat_name) {
|
||||
cat.remove_item(item_name);
|
||||
}
|
||||
let to_remove: Vec<CellKey> = self
|
||||
.data
|
||||
.iter_cells()
|
||||
.filter(|(k, _)| k.get(cat_name) == Some(item_name))
|
||||
.map(|(k, _)| k)
|
||||
.collect();
|
||||
for k in to_remove {
|
||||
self.data.remove(&k);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn category_mut(&mut self, name: &str) -> Option<&mut Category> {
|
||||
self.categories.get_mut(name)
|
||||
}
|
||||
@@ -527,6 +572,31 @@ mod model_tests {
|
||||
assert_eq!(m.get_cell(&k4), Some(&CellValue::Number(40.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_category_deletes_category_and_cells() {
|
||||
let mut m = Model::new("Test");
|
||||
m.add_category("Region").unwrap();
|
||||
m.add_category("Product").unwrap();
|
||||
m.category_mut("Region").unwrap().add_item("East");
|
||||
m.category_mut("Product").unwrap().add_item("Shirts");
|
||||
m.set_cell(
|
||||
coord(&[("Region", "East"), ("Product", "Shirts")]),
|
||||
CellValue::Number(42.0),
|
||||
);
|
||||
m.remove_category("Region");
|
||||
assert!(m.category("Region").is_none());
|
||||
// Cells referencing Region should be gone
|
||||
assert_eq!(
|
||||
m.data.iter_cells().count(),
|
||||
0,
|
||||
"all cells with Region coord should be removed"
|
||||
);
|
||||
// Views should no longer know about Region
|
||||
// (axis_of would panic for unknown category, so check categories_on)
|
||||
let v = m.active_view();
|
||||
assert!(v.categories_on(crate::view::Axis::Row).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_view_copies_category_structure() {
|
||||
let mut m = Model::new("Test");
|
||||
|
||||
+20
-2
@@ -91,6 +91,11 @@ pub struct App {
|
||||
/// when filters would change. Pending edits are stored alongside and
|
||||
/// applied to the model on commit/navigate-away.
|
||||
pub drill_state: Option<DrillState>,
|
||||
/// Terminal dimensions (updated on resize and at startup).
|
||||
pub term_width: u16,
|
||||
pub term_height: u16,
|
||||
/// Categories expanded in the category panel tree view.
|
||||
pub expanded_cats: std::collections::HashSet<String>,
|
||||
/// Named text buffers for text-entry modes
|
||||
pub buffers: HashMap<String, String>,
|
||||
/// Transient keymap for Emacs-style prefix key sequences (g→gg, y→yy, etc.)
|
||||
@@ -121,6 +126,9 @@ impl App {
|
||||
view_back_stack: Vec::new(),
|
||||
view_forward_stack: Vec::new(),
|
||||
drill_state: None,
|
||||
term_width: crossterm::terminal::size().map(|(w, _)| w).unwrap_or(80),
|
||||
term_height: crossterm::terminal::size().map(|(_, h)| h).unwrap_or(24),
|
||||
expanded_cats: std::collections::HashSet::new(),
|
||||
buffers: HashMap::new(),
|
||||
transient_keymap: None,
|
||||
keymap_set: KeymapSet::default_keymaps(),
|
||||
@@ -171,6 +179,14 @@ impl App {
|
||||
} else {
|
||||
None
|
||||
},
|
||||
// Approximate visible rows/cols from terminal size.
|
||||
// Chrome: title(1) + border(2) + col_headers(n_col_levels) + separator(1)
|
||||
// + tile_bar(1) + status_bar(1) = ~8 rows of chrome.
|
||||
visible_rows: (self.term_height as usize).saturating_sub(8),
|
||||
// Visible cols depends on column widths — use a rough estimate.
|
||||
// The grid renderer does the precise calculation.
|
||||
visible_cols: ((self.term_width as usize).saturating_sub(30) / 12).max(1),
|
||||
expanded_cats: &self.expanded_cats,
|
||||
key_code: key,
|
||||
}
|
||||
}
|
||||
@@ -230,11 +246,11 @@ impl App {
|
||||
/// Hint text for the status bar (context-sensitive)
|
||||
pub fn hint_text(&self) -> &'static str {
|
||||
match &self.mode {
|
||||
AppMode::Normal => "hjkl:nav Enter:advance i:edit x:clear t:transpose /:search 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 Esc:cancel",
|
||||
AppMode::FormulaPanel => "n:new d:delete jk:nav Esc:back",
|
||||
AppMode::FormulaEdit { .. } => "Enter:save Esc:cancel — type: Name = expression",
|
||||
AppMode::CategoryPanel => "jk:nav Space:cycle-axis n:new-cat a:add-items Esc:back",
|
||||
AppMode::CategoryPanel => "jk:nav Space:cycle-axis n:new-cat a:add-items d:delete Esc:back",
|
||||
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",
|
||||
@@ -280,6 +296,8 @@ mod tests {
|
||||
col_count: 2,
|
||||
row_offset: 0,
|
||||
col_offset: 0,
|
||||
visible_rows: 20,
|
||||
visible_cols: 8,
|
||||
};
|
||||
crate::command::cmd::EnterAdvance { cursor }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
use crate::model::Model;
|
||||
use std::collections::HashSet;
|
||||
|
||||
/// A flattened entry in the category panel tree.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum CatTreeEntry {
|
||||
/// Category header row: name, item count, expanded?
|
||||
Category {
|
||||
name: String,
|
||||
item_count: usize,
|
||||
expanded: bool,
|
||||
},
|
||||
/// Item row under a category
|
||||
Item { cat_name: String, item_name: String },
|
||||
}
|
||||
|
||||
impl CatTreeEntry {
|
||||
/// The category this entry belongs to.
|
||||
pub fn cat_name(&self) -> &str {
|
||||
match self {
|
||||
CatTreeEntry::Category { name, .. } => name,
|
||||
CatTreeEntry::Item { cat_name, .. } => cat_name,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the flattened tree of categories and their items.
|
||||
pub fn build_cat_tree(model: &Model, expanded: &HashSet<String>) -> Vec<CatTreeEntry> {
|
||||
let mut entries = Vec::new();
|
||||
for cat_name in model.category_names() {
|
||||
let cat = model.category(cat_name);
|
||||
let item_count = cat.map(|c| c.items.len()).unwrap_or(0);
|
||||
let is_expanded = expanded.contains(cat_name);
|
||||
entries.push(CatTreeEntry::Category {
|
||||
name: cat_name.to_string(),
|
||||
item_count,
|
||||
expanded: is_expanded,
|
||||
});
|
||||
if is_expanded {
|
||||
if let Some(cat) = cat {
|
||||
for item_name in cat.ordered_item_names() {
|
||||
entries.push(CatTreeEntry::Item {
|
||||
cat_name: cat_name.to_string(),
|
||||
item_name: item_name.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
entries
|
||||
}
|
||||
+46
-80
@@ -7,6 +7,7 @@ use ratatui::{
|
||||
|
||||
use crate::model::Model;
|
||||
use crate::ui::app::AppMode;
|
||||
use crate::ui::cat_tree::{build_cat_tree, CatTreeEntry};
|
||||
use crate::view::Axis;
|
||||
|
||||
fn axis_display(axis: Axis) -> (&'static str, Color) {
|
||||
@@ -22,14 +23,21 @@ pub struct CategoryPanel<'a> {
|
||||
pub model: &'a Model,
|
||||
pub mode: &'a AppMode,
|
||||
pub cursor: usize,
|
||||
pub expanded: &'a std::collections::HashSet<String>,
|
||||
}
|
||||
|
||||
impl<'a> CategoryPanel<'a> {
|
||||
pub fn new(model: &'a Model, mode: &'a AppMode, cursor: usize) -> Self {
|
||||
pub fn new(
|
||||
model: &'a Model,
|
||||
mode: &'a AppMode,
|
||||
cursor: usize,
|
||||
expanded: &'a std::collections::HashSet<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
model,
|
||||
mode,
|
||||
cursor,
|
||||
expanded,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -40,18 +48,8 @@ impl<'a> Widget for CategoryPanel<'a> {
|
||||
let is_cat_add = matches!(self.mode, AppMode::CategoryAdd { .. });
|
||||
let is_active = matches!(self.mode, AppMode::CategoryPanel) || is_item_add || is_cat_add;
|
||||
|
||||
let (border_color, title) = if is_cat_add {
|
||||
(
|
||||
Color::Yellow,
|
||||
" Categories — New category (Enter:add Esc:done) ",
|
||||
)
|
||||
} else if is_item_add {
|
||||
(
|
||||
Color::Green,
|
||||
" Categories — Adding items (Enter:add Esc:done) ",
|
||||
)
|
||||
} else if is_active {
|
||||
(Color::Cyan, " Categories n:new a:add-items Space:axis ")
|
||||
let (border_color, title) = if is_active {
|
||||
(Color::Cyan, " Categories n:new d:del Space:axis ")
|
||||
} else {
|
||||
(Color::DarkGray, " Categories ")
|
||||
};
|
||||
@@ -64,9 +62,9 @@ impl<'a> Widget for CategoryPanel<'a> {
|
||||
block.render(area, buf);
|
||||
|
||||
let view = self.model.active_view();
|
||||
let tree = build_cat_tree(self.model, self.expanded);
|
||||
|
||||
let cat_names: Vec<&str> = self.model.category_names();
|
||||
if cat_names.is_empty() {
|
||||
if tree.is_empty() {
|
||||
buf.set_string(
|
||||
inner.x,
|
||||
inner.y,
|
||||
@@ -76,36 +74,14 @@ impl<'a> Widget for CategoryPanel<'a> {
|
||||
return;
|
||||
}
|
||||
|
||||
// How many rows for the list vs the prompt at bottom
|
||||
let prompt_rows = if is_item_add { 2u16 } else { 0 };
|
||||
let list_height = inner.height.saturating_sub(prompt_rows);
|
||||
|
||||
for (i, cat_name) in cat_names.iter().enumerate() {
|
||||
if i as u16 >= list_height {
|
||||
for (i, entry) in tree.iter().enumerate() {
|
||||
if i as u16 >= inner.height {
|
||||
break;
|
||||
}
|
||||
let y = inner.y + i as u16;
|
||||
let is_selected = i == self.cursor && is_active;
|
||||
|
||||
let (axis_str, axis_color) = axis_display(view.axis_of(cat_name));
|
||||
|
||||
let item_count = self
|
||||
.model
|
||||
.category(cat_name)
|
||||
.map(|c| c.items.len())
|
||||
.unwrap_or(0);
|
||||
|
||||
// Highlight the selected category both in CategoryPanel and ItemAdd modes
|
||||
let is_selected_cat = if is_item_add {
|
||||
if let AppMode::ItemAdd { category, .. } = self.mode {
|
||||
*cat_name == category.as_str()
|
||||
} else {
|
||||
false
|
||||
}
|
||||
} else {
|
||||
i == self.cursor && is_active
|
||||
};
|
||||
|
||||
let base_style = if is_selected_cat {
|
||||
let base_style = if is_selected {
|
||||
Style::default()
|
||||
.fg(Color::Black)
|
||||
.bg(Color::Cyan)
|
||||
@@ -114,51 +90,41 @@ impl<'a> Widget for CategoryPanel<'a> {
|
||||
Style::default()
|
||||
};
|
||||
|
||||
if is_selected_cat {
|
||||
if is_selected {
|
||||
let fill = " ".repeat(inner.width as usize);
|
||||
buf.set_string(inner.x, y, &fill, base_style);
|
||||
}
|
||||
|
||||
let name_part = format!(" {cat_name} ({item_count})");
|
||||
let axis_part = format!(" [{axis_str}]");
|
||||
match entry {
|
||||
CatTreeEntry::Category {
|
||||
name,
|
||||
item_count,
|
||||
expanded,
|
||||
} => {
|
||||
let indicator = if *expanded { "▼" } else { "▶" };
|
||||
let (axis_str, axis_color) = axis_display(view.axis_of(name));
|
||||
let name_part = format!("{indicator} {name} ({item_count})");
|
||||
let axis_part = format!(" [{axis_str}]");
|
||||
|
||||
buf.set_string(inner.x, y, &name_part, base_style);
|
||||
if name_part.len() + axis_part.len() < inner.width as usize {
|
||||
buf.set_string(
|
||||
inner.x + name_part.len() as u16,
|
||||
y,
|
||||
&axis_part,
|
||||
if is_selected_cat {
|
||||
base_style
|
||||
} else {
|
||||
Style::default().fg(axis_color)
|
||||
},
|
||||
);
|
||||
buf.set_string(inner.x, y, &name_part, base_style);
|
||||
if name_part.len() + axis_part.len() < inner.width as usize {
|
||||
buf.set_string(
|
||||
inner.x + name_part.len() as u16,
|
||||
y,
|
||||
&axis_part,
|
||||
if is_selected {
|
||||
base_style
|
||||
} else {
|
||||
Style::default().fg(axis_color)
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
CatTreeEntry::Item { item_name, .. } => {
|
||||
let label = format!(" · {item_name}");
|
||||
buf.set_string(inner.x, y, &label, base_style);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Inline prompt at the bottom for CategoryAdd or ItemAdd
|
||||
let (prompt_color, prompt_text) = match self.mode {
|
||||
AppMode::CategoryAdd { buffer } => (Color::Yellow, format!(" + category: {buffer}▌")),
|
||||
AppMode::ItemAdd { buffer, .. } => (Color::Green, format!(" + item: {buffer}▌")),
|
||||
_ => return,
|
||||
};
|
||||
|
||||
let sep_y = inner.y + list_height;
|
||||
let prompt_y = sep_y + 1;
|
||||
if sep_y < inner.y + inner.height {
|
||||
let sep = "─".repeat(inner.width as usize);
|
||||
buf.set_string(inner.x, sep_y, &sep, Style::default().fg(prompt_color));
|
||||
}
|
||||
if prompt_y < inner.y + inner.height {
|
||||
buf.set_string(
|
||||
inner.x,
|
||||
prompt_y,
|
||||
&prompt_text,
|
||||
Style::default()
|
||||
.fg(prompt_color)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,6 +90,68 @@ impl Effect for RemoveFormula {
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-enter edit mode by reading the cell value at the current cursor.
|
||||
/// Used after commit+advance to continue data entry.
|
||||
#[derive(Debug)]
|
||||
pub struct EnterEditAtCursor;
|
||||
impl Effect for EnterEditAtCursor {
|
||||
fn apply(&self, app: &mut App) {
|
||||
let ctx = app.cmd_context(crossterm::event::KeyCode::Null, crossterm::event::KeyModifiers::NONE);
|
||||
let value = if let Some(v) = &ctx.records_value {
|
||||
v.clone()
|
||||
} else {
|
||||
ctx.cell_key
|
||||
.as_ref()
|
||||
.and_then(|k| ctx.model.get_cell(k).cloned())
|
||||
.map(|v| v.to_string())
|
||||
.unwrap_or_default()
|
||||
};
|
||||
drop(ctx);
|
||||
app.buffers.insert("edit".to_string(), value);
|
||||
app.mode = AppMode::Editing {
|
||||
buffer: String::new(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct TogglePruneEmpty;
|
||||
impl Effect for TogglePruneEmpty {
|
||||
fn apply(&self, app: &mut App) {
|
||||
let v = app.model.active_view_mut();
|
||||
v.prune_empty = !v.prune_empty;
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ToggleCatExpand(pub String);
|
||||
impl Effect for ToggleCatExpand {
|
||||
fn apply(&self, app: &mut App) {
|
||||
if !app.expanded_cats.remove(&self.0) {
|
||||
app.expanded_cats.insert(self.0.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct RemoveItem {
|
||||
pub category: String,
|
||||
pub item: String,
|
||||
}
|
||||
impl Effect for RemoveItem {
|
||||
fn apply(&self, app: &mut App) {
|
||||
app.model.remove_item(&self.category, &self.item);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct RemoveCategory(pub String);
|
||||
impl Effect for RemoveCategory {
|
||||
fn apply(&self, app: &mut App) {
|
||||
app.model.remove_category(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
// ── View mutations ───────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -358,6 +420,9 @@ impl Effect for ApplyAndClearDrill {
|
||||
let Some(drill) = app.drill_state.take() else {
|
||||
return;
|
||||
};
|
||||
if drill.pending_edits.is_empty() {
|
||||
return;
|
||||
}
|
||||
// For each pending edit, update the cell
|
||||
for ((record_idx, col_name), new_value) in &drill.pending_edits {
|
||||
let Some((orig_key, _)) = drill.records.get(*record_idx) else {
|
||||
|
||||
+124
-52
@@ -11,10 +11,11 @@ use crate::model::Model;
|
||||
use crate::ui::app::AppMode;
|
||||
use crate::view::{AxisEntry, GridLayout};
|
||||
|
||||
const ROW_HEADER_WIDTH: u16 = 16;
|
||||
const COL_WIDTH: u16 = 10;
|
||||
const MIN_COL_WIDTH: u16 = 6;
|
||||
/// Minimum column width — enough for short numbers/labels + 1 char gap.
|
||||
const MIN_COL_WIDTH: u16 = 5;
|
||||
const MAX_COL_WIDTH: u16 = 32;
|
||||
const MIN_ROW_HEADER_W: u16 = 4;
|
||||
const MAX_ROW_HEADER_W: u16 = 24;
|
||||
/// Subtle dark-gray background used to highlight the row containing the cursor.
|
||||
const ROW_HIGHLIGHT_BG: Color = Color::Indexed(237);
|
||||
const GROUP_EXPANDED: &str = "▼";
|
||||
@@ -70,12 +71,13 @@ impl<'a> GridWidget<'a> {
|
||||
let n_col_levels = layout.col_cats.len().max(1);
|
||||
let n_row_levels = layout.row_cats.len().max(1);
|
||||
|
||||
// Per-column widths. In records mode, size each column to its widest
|
||||
// content (pending edit → record value → header label). Otherwise use
|
||||
// the fixed COL_WIDTH. Always at least MIN_COL_WIDTH, capped at MAX.
|
||||
let col_widths: Vec<u16> = if layout.is_records_mode() {
|
||||
// ── Adaptive column widths ────────────────────────────────────
|
||||
// Size each column to fit its widest content (header + cell values)
|
||||
// plus 1 char gap. Minimum MIN_COL_WIDTH, capped at MAX_COL_WIDTH.
|
||||
let col_widths: Vec<u16> = {
|
||||
let n = layout.col_count();
|
||||
let mut widths = vec![MIN_COL_WIDTH; n];
|
||||
let mut widths = vec![0u16; n];
|
||||
// Measure column header labels
|
||||
for ci in 0..n {
|
||||
let header = layout.col_label(ci);
|
||||
let w = header.width() as u16;
|
||||
@@ -83,39 +85,44 @@ impl<'a> GridWidget<'a> {
|
||||
widths[ci] = w;
|
||||
}
|
||||
}
|
||||
for ri in 0..layout.row_count() {
|
||||
for (ci, wref) in widths.iter_mut().enumerate().take(n) {
|
||||
let s = self.records_cell_text(&layout, ri, ci);
|
||||
let w = s.width() as u16;
|
||||
if w > *wref {
|
||||
*wref = w;
|
||||
// Measure cell content
|
||||
if layout.is_records_mode() {
|
||||
for ri in 0..layout.row_count() {
|
||||
for (ci, wref) in widths.iter_mut().enumerate().take(n) {
|
||||
let s = self.records_cell_text(&layout, ri, ci);
|
||||
let w = s.width() as u16;
|
||||
if w > *wref {
|
||||
*wref = w;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Pivot mode: measure formatted cell values
|
||||
for ri in 0..layout.row_count() {
|
||||
for (ci, wref) in widths.iter_mut().enumerate().take(n) {
|
||||
if let Some(key) = layout.cell_key(ri, ci) {
|
||||
let value =
|
||||
self.model.evaluate_aggregated(&key, &layout.none_cats);
|
||||
let s = format_value(value.as_ref(), fmt_comma, fmt_decimals);
|
||||
let w = s.width() as u16;
|
||||
if w > *wref {
|
||||
*wref = w;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Add 2 cells of right-padding; cap at MAX_COL_WIDTH.
|
||||
// +1 for gap between columns
|
||||
widths
|
||||
.into_iter()
|
||||
.map(|w| (w + 2).min(MAX_COL_WIDTH))
|
||||
.map(|w| (w + 1).max(MIN_COL_WIDTH).min(MAX_COL_WIDTH))
|
||||
.collect()
|
||||
} else {
|
||||
vec![COL_WIDTH; layout.col_count()]
|
||||
};
|
||||
|
||||
// Sub-column widths for row header area
|
||||
let sub_col_w = ROW_HEADER_WIDTH / n_row_levels as u16;
|
||||
let sub_widths: Vec<u16> = (0..n_row_levels)
|
||||
.map(|d| {
|
||||
if d < n_row_levels - 1 {
|
||||
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
|
||||
// ── Adaptive row header widths ───────────────────────────────
|
||||
// Measure the widest label at each row-header level.
|
||||
let data_row_items: Vec<&Vec<String>> = layout
|
||||
.row_items
|
||||
.iter()
|
||||
.filter_map(|e| {
|
||||
if let AxisEntry::DataItem(v) = e {
|
||||
@@ -125,8 +132,23 @@ impl<'a> GridWidget<'a> {
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let data_row_items: Vec<&Vec<String>> = layout
|
||||
.row_items
|
||||
|
||||
let sub_widths: Vec<u16> = (0..n_row_levels)
|
||||
.map(|d| {
|
||||
let max_label = data_row_items
|
||||
.iter()
|
||||
.filter_map(|v| v.get(d))
|
||||
.map(|s| s.width() as u16)
|
||||
.max()
|
||||
.unwrap_or(0);
|
||||
(max_label + 1).max(MIN_ROW_HEADER_W).min(MAX_ROW_HEADER_W)
|
||||
})
|
||||
.collect();
|
||||
let row_header_width: u16 = sub_widths.iter().sum();
|
||||
|
||||
// Flat list of data-only column 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 {
|
||||
@@ -143,11 +165,11 @@ impl<'a> GridWidget<'a> {
|
||||
.any(|e| matches!(e, AxisEntry::GroupHeader { .. }));
|
||||
|
||||
// Compute how many columns fit starting from col_offset.
|
||||
let data_area_width = area.width.saturating_sub(ROW_HEADER_WIDTH);
|
||||
let data_area_width = area.width.saturating_sub(row_header_width);
|
||||
let mut acc = 0u16;
|
||||
let mut last = col_offset;
|
||||
for ci in col_offset..layout.col_count() {
|
||||
let w = *col_widths.get(ci).unwrap_or(&COL_WIDTH);
|
||||
let w = *col_widths.get(ci).unwrap_or(&MIN_COL_WIDTH);
|
||||
if acc + w > data_area_width {
|
||||
break;
|
||||
}
|
||||
@@ -160,16 +182,16 @@ impl<'a> GridWidget<'a> {
|
||||
let col_x: Vec<u16> = {
|
||||
let mut v = vec![0u16; layout.col_count() + 1];
|
||||
for ci in 0..layout.col_count() {
|
||||
v[ci + 1] = v[ci] + *col_widths.get(ci).unwrap_or(&COL_WIDTH);
|
||||
v[ci + 1] = v[ci] + *col_widths.get(ci).unwrap_or(&MIN_COL_WIDTH);
|
||||
}
|
||||
v
|
||||
};
|
||||
let col_x_at = |ci: usize| -> u16 {
|
||||
area.x
|
||||
+ ROW_HEADER_WIDTH
|
||||
+ row_header_width
|
||||
+ col_x[ci].saturating_sub(col_x[col_offset])
|
||||
};
|
||||
let col_w_at = |ci: usize| -> u16 { *col_widths.get(ci).unwrap_or(&COL_WIDTH) };
|
||||
let col_w_at = |ci: usize| -> u16 { *col_widths.get(ci).unwrap_or(&MIN_COL_WIDTH) };
|
||||
|
||||
let _header_rows = n_col_levels as u16 + 1 + if has_col_groups { 1 } else { 0 };
|
||||
|
||||
@@ -187,7 +209,7 @@ impl<'a> GridWidget<'a> {
|
||||
buf.set_string(
|
||||
area.x,
|
||||
y,
|
||||
format!("{:<width$}", "", width = ROW_HEADER_WIDTH as usize),
|
||||
format!("{:<width$}", "", width = row_header_width as usize),
|
||||
Style::default(),
|
||||
);
|
||||
let mut prev_group: Option<String> = None;
|
||||
@@ -233,7 +255,7 @@ impl<'a> GridWidget<'a> {
|
||||
buf.set_string(
|
||||
area.x,
|
||||
y,
|
||||
format!("{:<width$}", "", width = ROW_HEADER_WIDTH as usize),
|
||||
format!("{:<width$}", "", width = row_header_width as usize),
|
||||
Style::default(),
|
||||
);
|
||||
for ci in visible_col_range.clone() {
|
||||
@@ -252,7 +274,17 @@ impl<'a> GridWidget<'a> {
|
||||
String::new()
|
||||
}
|
||||
};
|
||||
let styled = if ci == sel_col {
|
||||
// Underline columns that share the same ancestor group as
|
||||
// sel_col through level d. At the bottom level this matches
|
||||
// only sel_col; at higher levels it spans all sub-columns.
|
||||
let in_sel_group = if layout.col_cats.is_empty() {
|
||||
ci == sel_col
|
||||
} else if sel_col < data_col_items.len() && ci < data_col_items.len() {
|
||||
data_col_items[ci][..=d] == data_col_items[sel_col][..=d]
|
||||
} else {
|
||||
false
|
||||
};
|
||||
let styled = if in_sel_group {
|
||||
header_style.add_modifier(Modifier::UNDERLINED)
|
||||
} else {
|
||||
header_style
|
||||
@@ -301,8 +333,8 @@ impl<'a> GridWidget<'a> {
|
||||
y,
|
||||
format!(
|
||||
"{:<width$}",
|
||||
truncate(&label, ROW_HEADER_WIDTH as usize),
|
||||
width = ROW_HEADER_WIDTH as usize
|
||||
truncate(&label, row_header_width as usize),
|
||||
width = row_header_width as usize
|
||||
),
|
||||
group_header_style,
|
||||
);
|
||||
@@ -340,9 +372,9 @@ impl<'a> GridWidget<'a> {
|
||||
if is_sel_row {
|
||||
let row_w = (area.x + area.width).saturating_sub(area.x);
|
||||
buf.set_string(
|
||||
area.x + ROW_HEADER_WIDTH,
|
||||
area.x + row_header_width,
|
||||
y,
|
||||
" ".repeat(row_w.saturating_sub(ROW_HEADER_WIDTH) as usize),
|
||||
" ".repeat(row_w.saturating_sub(row_header_width) as usize),
|
||||
Style::default().bg(ROW_HIGHLIGHT_BG),
|
||||
);
|
||||
}
|
||||
@@ -407,7 +439,12 @@ impl<'a> GridWidget<'a> {
|
||||
// "drill to edit". Records mode cells are always
|
||||
// directly editable, as are plain pivot cells.
|
||||
let is_aggregated = !layout.is_records_mode()
|
||||
&& !layout.none_cats.is_empty();
|
||||
&& layout.none_cats.iter().any(|c| {
|
||||
self.model
|
||||
.category(c)
|
||||
.map(|cat| cat.kind.is_regular())
|
||||
.unwrap_or(false)
|
||||
});
|
||||
let mut cell_style = if is_selected {
|
||||
Style::default()
|
||||
.fg(Color::Black)
|
||||
@@ -479,7 +516,7 @@ impl<'a> GridWidget<'a> {
|
||||
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),
|
||||
@@ -667,6 +704,7 @@ mod tests {
|
||||
}
|
||||
|
||||
/// Minimal model: Type on Row, Month on Column.
|
||||
/// Every cell has a value so rows/cols survive pruning.
|
||||
fn two_cat_model() -> Model {
|
||||
let mut m = Model::new("Test");
|
||||
m.add_category("Type").unwrap(); // → Row
|
||||
@@ -679,6 +717,15 @@ mod tests {
|
||||
c.add_item("Jan");
|
||||
c.add_item("Feb");
|
||||
}
|
||||
// Fill every cell so nothing is pruned as empty.
|
||||
for t in ["Food", "Clothing"] {
|
||||
for mo in ["Jan", "Feb"] {
|
||||
m.set_cell(
|
||||
coord(&[("Type", t), ("Month", mo)]),
|
||||
CellValue::Number(1.0),
|
||||
);
|
||||
}
|
||||
}
|
||||
m
|
||||
}
|
||||
|
||||
@@ -738,10 +785,19 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn unset_cells_show_no_value() {
|
||||
let m = two_cat_model();
|
||||
// Build a model without the two_cat_model helper (which fills every cell).
|
||||
let mut m = Model::new("Test");
|
||||
m.add_category("Type").unwrap();
|
||||
m.add_category("Month").unwrap();
|
||||
m.category_mut("Type").unwrap().add_item("Food");
|
||||
m.category_mut("Month").unwrap().add_item("Jan");
|
||||
// Set one cell so the row/col isn't pruned
|
||||
m.set_cell(
|
||||
coord(&[("Type", "Food"), ("Month", "Jan")]),
|
||||
CellValue::Number(1.0),
|
||||
);
|
||||
let text = buf_text(&render(&m, 80, 24));
|
||||
// No digits should appear in the data area if nothing is set
|
||||
// (Total row shows "0" — exclude that from this check by looking for non-zero)
|
||||
// Should not contain large numbers that weren't set
|
||||
assert!(!text.contains("100"), "unexpected '100' in:\n{text}");
|
||||
}
|
||||
|
||||
@@ -873,6 +929,15 @@ mod tests {
|
||||
}
|
||||
m.active_view_mut()
|
||||
.set_axis("Recipient", crate::view::Axis::Row);
|
||||
// Populate cells so rows/cols survive pruning
|
||||
for t in ["Food", "Clothing"] {
|
||||
for r in ["Alice", "Bob"] {
|
||||
m.set_cell(
|
||||
coord(&[("Type", t), ("Month", "Jan"), ("Recipient", r)]),
|
||||
CellValue::Number(1.0),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let text = buf_text(&render(&m, 80, 24));
|
||||
// Multi-level row headers: category values shown separately, not joined with /
|
||||
@@ -936,6 +1001,13 @@ mod tests {
|
||||
}
|
||||
m.active_view_mut()
|
||||
.set_axis("Year", crate::view::Axis::Column);
|
||||
// Populate cells so cols survive pruning
|
||||
for y in ["2024", "2025"] {
|
||||
m.set_cell(
|
||||
coord(&[("Type", "Food"), ("Month", "Jan"), ("Year", y)]),
|
||||
CellValue::Number(1.0),
|
||||
);
|
||||
}
|
||||
|
||||
let text = buf_text(&render(&m, 80, 24));
|
||||
// Multi-level column headers: category values shown separately, not joined with /
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod app;
|
||||
pub mod cat_tree;
|
||||
pub mod category_panel;
|
||||
pub mod effect;
|
||||
pub mod formula_panel;
|
||||
|
||||
+131
-1
@@ -53,6 +53,9 @@ impl GridLayout {
|
||||
layout.records = Some(records);
|
||||
}
|
||||
}
|
||||
if view.prune_empty {
|
||||
layout.prune_empty(model);
|
||||
}
|
||||
layout
|
||||
}
|
||||
|
||||
@@ -152,10 +155,11 @@ impl GridLayout {
|
||||
.map(|i| AxisEntry::DataItem(vec![i.to_string()]))
|
||||
.collect();
|
||||
|
||||
// Synthesize col items: one per category + "Value"
|
||||
// Synthesize col items: one per non-virtual category + "Value"
|
||||
let cat_names: Vec<String> = model
|
||||
.category_names()
|
||||
.into_iter()
|
||||
.filter(|c| !c.starts_with('_'))
|
||||
.map(String::from)
|
||||
.collect();
|
||||
let mut col_items: Vec<AxisEntry> = cat_names
|
||||
@@ -195,6 +199,108 @@ impl GridLayout {
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove data rows where every column is empty and data columns
|
||||
/// where every row is empty. Group headers are kept if at least one
|
||||
/// of their data items survives.
|
||||
///
|
||||
/// In records mode every column is shown (the user drilled in to see
|
||||
/// all the raw data). In pivot mode, rows and columns where every
|
||||
/// cell is empty are hidden to reduce clutter.
|
||||
pub fn prune_empty(&mut self, model: &Model) {
|
||||
if self.is_records_mode() {
|
||||
return;
|
||||
}
|
||||
let rc = self.row_count();
|
||||
let cc = self.col_count();
|
||||
if rc == 0 || cc == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
// Build a row×col grid of "has content?"
|
||||
let mut has_value = vec![vec![false; cc]; rc];
|
||||
for ri in 0..rc {
|
||||
for ci in 0..cc {
|
||||
has_value[ri][ci] = if self.is_records_mode() {
|
||||
let s = self.records_display(ri, ci).unwrap_or_default();
|
||||
!s.is_empty()
|
||||
} else {
|
||||
self.cell_key(ri, ci)
|
||||
.and_then(|k| model.evaluate_aggregated(&k, &self.none_cats))
|
||||
.is_some()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Which data-row indices are non-empty?
|
||||
let keep_row: Vec<bool> = (0..rc)
|
||||
.map(|ri| (0..cc).any(|ci| has_value[ri][ci]))
|
||||
.collect();
|
||||
// Which data-col indices are non-empty?
|
||||
let keep_col: Vec<bool> = (0..cc)
|
||||
.map(|ci| (0..rc).any(|ri| has_value[ri][ci]))
|
||||
.collect();
|
||||
|
||||
// Filter row_items, preserving group headers when at least one
|
||||
// subsequent data item survives.
|
||||
let mut new_rows = Vec::new();
|
||||
let mut pending_header: Option<AxisEntry> = None;
|
||||
let mut data_idx = 0usize;
|
||||
for entry in self.row_items.drain(..) {
|
||||
match &entry {
|
||||
AxisEntry::GroupHeader { .. } => {
|
||||
pending_header = Some(entry);
|
||||
}
|
||||
AxisEntry::DataItem(_) => {
|
||||
if data_idx < rc && keep_row[data_idx] {
|
||||
if let Some(h) = pending_header.take() {
|
||||
new_rows.push(h);
|
||||
}
|
||||
new_rows.push(entry);
|
||||
}
|
||||
data_idx += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
self.row_items = new_rows;
|
||||
|
||||
// Filter col_items (same logic)
|
||||
let mut new_cols = Vec::new();
|
||||
let mut pending_header: Option<AxisEntry> = None;
|
||||
let mut data_idx = 0usize;
|
||||
for entry in self.col_items.drain(..) {
|
||||
match &entry {
|
||||
AxisEntry::GroupHeader { .. } => {
|
||||
pending_header = Some(entry);
|
||||
}
|
||||
AxisEntry::DataItem(_) => {
|
||||
if data_idx < cc && keep_col[data_idx] {
|
||||
if let Some(h) = pending_header.take() {
|
||||
new_cols.push(h);
|
||||
}
|
||||
new_cols.push(entry);
|
||||
}
|
||||
data_idx += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
self.col_items = new_cols;
|
||||
|
||||
// If records mode, also prune the records vec and re-index row_items
|
||||
if let Some(records) = &self.records {
|
||||
let new_records: Vec<_> = keep_row
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, keep)| **keep)
|
||||
.map(|(i, _)| records[i].clone())
|
||||
.collect();
|
||||
let new_row_items: Vec<AxisEntry> = (0..new_records.len())
|
||||
.map(|i| AxisEntry::DataItem(vec![i.to_string()]))
|
||||
.collect();
|
||||
self.row_items = new_row_items;
|
||||
self.records = Some(new_records);
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this layout is in records mode.
|
||||
pub fn is_records_mode(&self) -> bool {
|
||||
self.records.is_some()
|
||||
@@ -450,6 +556,30 @@ mod tests {
|
||||
m
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prune_empty_removes_all_empty_columns_in_pivot_mode() {
|
||||
let mut m = Model::new("T");
|
||||
m.add_category("Row").unwrap();
|
||||
m.add_category("Col").unwrap();
|
||||
m.category_mut("Row").unwrap().add_item("A");
|
||||
m.category_mut("Col").unwrap().add_item("X");
|
||||
m.category_mut("Col").unwrap().add_item("Y");
|
||||
// Only X has data; Y is entirely empty
|
||||
m.set_cell(
|
||||
CellKey::new(vec![
|
||||
("Row".into(), "A".into()),
|
||||
("Col".into(), "X".into()),
|
||||
]),
|
||||
CellValue::Number(1.0),
|
||||
);
|
||||
|
||||
let mut layout = GridLayout::new(&m, m.active_view());
|
||||
assert_eq!(layout.col_count(), 2); // X and Y before pruning
|
||||
layout.prune_empty(&m);
|
||||
assert_eq!(layout.col_count(), 1); // only X after pruning
|
||||
assert_eq!(layout.col_label(0), "X");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn records_mode_activated_when_index_and_dim_on_axes() {
|
||||
let mut m = records_model();
|
||||
|
||||
+50
-5
@@ -4,6 +4,10 @@ use std::collections::{HashMap, HashSet};
|
||||
|
||||
use super::axis::Axis;
|
||||
|
||||
fn default_prune() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct View {
|
||||
pub name: String,
|
||||
@@ -17,6 +21,9 @@ pub struct View {
|
||||
pub collapsed_groups: HashMap<String, HashSet<String>>,
|
||||
/// Number format string (e.g. ",.0f" for comma-separated integer)
|
||||
pub number_format: String,
|
||||
/// When true, empty rows/columns are pruned from the display.
|
||||
#[serde(default = "default_prune")]
|
||||
pub prune_empty: bool,
|
||||
/// Scroll offset for grid
|
||||
pub row_offset: usize,
|
||||
pub col_offset: usize,
|
||||
@@ -33,6 +40,7 @@ impl View {
|
||||
hidden_items: HashMap::new(),
|
||||
collapsed_groups: HashMap::new(),
|
||||
number_format: ",.0".to_string(),
|
||||
prune_empty: false,
|
||||
row_offset: 0,
|
||||
col_offset: 0,
|
||||
selected: (0, 0),
|
||||
@@ -41,16 +49,47 @@ impl View {
|
||||
|
||||
pub fn on_category_added(&mut self, cat_name: &str) {
|
||||
if !self.category_axes.contains_key(cat_name) {
|
||||
// Virtual categories (names starting with `_`) default to Axis::None.
|
||||
// Virtual/underscore categories default to Axis::None.
|
||||
// Regular categories auto-assign: first → Row, second → Column, rest → Page.
|
||||
// If a virtual currently holds Row or Column and a regular category needs
|
||||
// the slot, bump the virtual to None.
|
||||
let axis = if cat_name.starts_with('_') {
|
||||
Axis::None
|
||||
} else {
|
||||
let rows = self.categories_on(Axis::Row).len();
|
||||
let cols = self.categories_on(Axis::Column).len();
|
||||
if rows == 0 {
|
||||
let regular_rows: Vec<String> = self
|
||||
.categories_on(Axis::Row)
|
||||
.into_iter()
|
||||
.filter(|c| !c.starts_with('_'))
|
||||
.map(String::from)
|
||||
.collect();
|
||||
let regular_cols: Vec<String> = self
|
||||
.categories_on(Axis::Column)
|
||||
.into_iter()
|
||||
.filter(|c| !c.starts_with('_'))
|
||||
.map(String::from)
|
||||
.collect();
|
||||
if regular_rows.is_empty() {
|
||||
// Bump any virtual on Row to None
|
||||
let bump: Vec<String> = self
|
||||
.categories_on(Axis::Row)
|
||||
.into_iter()
|
||||
.filter(|c| c.starts_with('_'))
|
||||
.map(String::from)
|
||||
.collect();
|
||||
for c in bump {
|
||||
self.category_axes.insert(c, Axis::None);
|
||||
}
|
||||
Axis::Row
|
||||
} else if cols == 0 {
|
||||
} else if regular_cols.is_empty() {
|
||||
let bump: Vec<String> = self
|
||||
.categories_on(Axis::Column)
|
||||
.into_iter()
|
||||
.filter(|c| c.starts_with('_'))
|
||||
.map(String::from)
|
||||
.collect();
|
||||
for c in bump {
|
||||
self.category_axes.insert(c, Axis::None);
|
||||
}
|
||||
Axis::Column
|
||||
} else {
|
||||
Axis::Page
|
||||
@@ -60,6 +99,12 @@ impl View {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn on_category_removed(&mut self, cat_name: &str) {
|
||||
self.category_axes.shift_remove(cat_name);
|
||||
self.page_selections.remove(cat_name);
|
||||
self.hidden_items.remove(cat_name);
|
||||
}
|
||||
|
||||
pub fn set_axis(&mut self, cat_name: &str, axis: Axis) {
|
||||
if let Some(a) = self.category_axes.get_mut(cat_name) {
|
||||
*a = axis;
|
||||
|
||||
Reference in New Issue
Block a user