15 Commits

Author SHA1 Message Date
Edward Langley 334597d825 refactor: update TogglePanelAndFocus to use open/focused flags
Update TogglePanelAndFocus and related components to use open/focused flags.

Changed TogglePanelAndFocus from currently_open to open+focused flags.
Parser accepts optional [open] [focused] arguments.

Interactive mode toggles between open+focus and closed/unfocused.

Keymap updates: F/C/V in panel modes close panels when focused.

Model initialization: Virtual categories _Index/_Dim default to None
axis, regular categories auto-assign Row/Column/Page.

App test context updated with visible_rows/visible_cols.

Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/Qwen3.5-35B-A3B-GGUF:Q5_K_M)
2026-04-06 15:09:58 -07:00
Edward Langley 9329f04082 refactor: update viewport effects to use dynamic visible dimensions
Update viewport effects to use dynamic visible dimensions.

viewport_effects() now takes visible_rows and visible_cols parameters
instead of hardcoded 20/8 values.

Scrolling logic uses these parameters:
- row_offset updates when nr >= row_offset + visible_rows
- col_offset updates when nc >= col_offset + visible_cols

Default registry initialized with visible_rows=20, visible_cols=8
for MoveSelection, MovePanelCursor, and other commands.

Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/Qwen3.5-35B-A3B-GGUF:Q5_K_M)
2026-04-06 15:09:58 -07:00
Edward Langley 631067b011 refactor: update grid widget with adaptive widths and pruning support
Update grid widget with adaptive column/row widths and pruning support.

Replaced fixed ROW_HEADER_WIDTH (16) and COL_WIDTH (10) with adaptive
widths based on content. MIN_COL_WIDTH=5, MAX_COL_WIDTH=32. MIN_ROW_HEADER_W=4,
MAX_ROW_HEADER_W=24.

Column widths measured from header labels and cell content (pivot mode
measures formatted values, records mode measures raw values).

Row header widths measured from widest label at each level.

Added underlining for columns sharing ancestor groups with selected
column. Updated is_aggregated check to filter virtual categories.

Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/Qwen3.5-35B-A3B-GGUF:Q5_K_M)
2026-04-06 15:09:58 -07:00
Edward Langley bd5dcfe1f7 refactor: update command tests for new API
Update command tests for new API.

Added EMPTY_EXPANDED static for expanded_cats in test context.

Renamed tests:
- toggle_panel_and_focus_opens_and_enters_mode → toggle_panel_open_and_focus
- toggle_panel_and_focus_closes_when_open → toggle_panel_close_and_unfocus

Updated test assertions to use open/focused flags instead of
currently_open. Tests now verify 2 effects (SetPanelOpen + ChangeMode)
for both open and close operations.

Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/Qwen3.5-35B-A3B-GGUF:Q5_K_M)
2026-04-06 15:09:58 -07:00
Edward Langley 0249afe33d chore: update app hints and mode descriptions
Update status bar hints for new features.

Normal mode hint: Added R:records P:prune, removed F/C/V:panels
CategoryPanel hint: Added d:delete

These hints reflect the new keybindings for records mode toggle,
prune empty toggle, and category deletion.

Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/Qwen3.5-35B-A3B-GGUF:Q5_K_M)
2026-04-06 15:09:58 -07:00
Edward Langley 132f017c79 feat: add effect to re-enter edit mode after commit+advance
Add EnterEditAtCursor effect to re-enter edit mode after commit.

Used by CommitCellEdit to continue data entry after advancing
cursor. Reads the cell value at the new cursor position and
starts editing mode with that value pre-filled.

Also adds TogglePruneEmpty, ToggleCatExpand, RemoveItem, and
RemoveCategory effects to effect.rs for the new commands.

Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/Qwen3.5-35B-A3B-GGUF:Q5_K_M)
2026-04-06 15:09:58 -07:00
Edward Langley f1a777670f refactor: update commit commands to continue editing after advance
Update commit commands to continue editing after advancing cursor.

CommitCellEdit now advances cursor (typewriter-style) and re-enters
edit mode at the new cell, allowing continuous data entry.

CommitCategoryAdd and CommitItemAdd now exit to CategoryPanel when
the buffer is empty, instead of just clearing the buffer.

Empty buffer behavior:
- CommitCategoryAdd: empty → exit to CategoryPanel
- CommitItemAdd: empty → exit to CategoryPanel
- Non-empty: add item/category, clear buffer, stay in add mode

Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/Qwen3.5-35B-A3B-GGUF:Q5_K_M)
2026-04-06 15:09:57 -07:00
Edward Langley 92f351bce3 feat: add prune empty feature for pivot views
Add prune_empty feature to hide empty rows/columns in pivot mode.

View gains prune_empty boolean (default false for backward compat).
GridLayout::prune_empty() removes data rows where all columns are
empty and data columns where all rows are empty.

Group headers are preserved if at least one data item survives.
In records mode, pruning is skipped (user drilled in to see all data).

EditOrDrill command updated to check for regular (non-virtual)
categories when determining if a cell is aggregated.

Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/Qwen3.5-35B-A3B-GGUF:Q5_K_M)
2026-04-06 15:09:57 -07:00
Edward Langley 6f4bc5e798 feat: add new commands for records mode and category management
Add new commands for enhanced data entry and category management.

AddRecordRow: Adds a new record row in records mode with empty value.
TogglePruneEmpty: Toggles pruning of empty rows/columns in pivot mode.
ToggleRecordsMode: Switches between records and pivot layout.
DeleteCategoryAtCursor: Removes a category and all its cells.
ToggleCatExpand: Expands/collapses a category in the tree.
FilterToItem: Filters to show only items matching cursor position.

Model gains remove_category() and remove_item() to delete categories
and items along with all referencing cells and formulas.

Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/Qwen3.5-35B-A3B-GGUF:Q5_K_M)
2026-04-06 15:09:57 -07:00
Edward Langley 8e0c06d888 feat: add category tree with expand/collapse in category panel
Add a tree-based category panel that supports expand/collapse of categories.

Introduces CatTreeEntry and build_cat_tree to render categories as
a collapsible tree. The category panel now displays categories with
expand indicators (▶/▼) and shows items under expanded categories.

CmdContext gains cat_tree_entry(), cat_at_cursor(), and cat_tree_len()
methods to work with the tree. App tracks expanded_cats in a HashSet.

Keymap updates: Enter in category panel now triggers filter-to-item.

Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/Qwen3.5-35B-A3B-GGUF:Q5_K_M)
2026-04-06 15:09:57 -07:00
Edward Langley 32677141de chore(merge): remote-tracking branch 'origin/main' 2026-04-06 08:59:09 -07:00
Edward Langley ecc2987963 fix(ui): prevent grid column overflow with proper truncation
Fix column header and cell text truncation to prevent overflow
when text width equals column width. Changed truncate() calls to
use cw.saturating_sub(1) instead of cw, ensuring at least one
character of padding remains.

Affected areas:
- Column header labels (left-aligned)
- Column header labels (right-aligned)
- Cell values
- Total/summary rows

Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/Qwen3.5-35B-A3B-GGUF:Q5_K_M)
2026-04-05 14:05:33 -07:00
Edward Langley 6d5138d904 test(import): add tests for label field import behavior
Add two new tests for label field functionality:
- label_fields_imported_as_label_category_coords: Verifies that
  high-cardinality fields (>20 distinct values) are classified as
  Label kind, default to accepted, and are stored as category coords
- label_category_defaults_to_none_axis: Verifies that label categories
  default to Axis::None in the active view

Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/Qwen3.5-35B-A3B-GGUF:Q5_K_M)
2026-04-05 14:05:33 -07:00
Edward Langley def3902eb9 feat(import): add Label field support for high-cardinality per-row data
Add support for Label-kind categories to handle high-cardinality
per-row fields like descriptions, IDs, and notes. These fields are
stored alongside regular categories but default to Axis::None and
are excluded from pivot category limits.

Changes:
- analyzer.rs: Label fields now default to accepted=true
- wizard.rs: Collect and process label fields during model building,
  attaching label values as coordinates for each cell
- category.rs: Add Label variant to CategoryKind enum
- types.rs: Add add_label_category() method and update category
  counting to only include Regular-kind categories

Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/Qwen3.5-35B-A3B-GGUF:Q5_K_M)
2026-04-05 14:05:33 -07:00
Edward Langley 9d88ad3205 feat: add fake bank data 2026-04-05 14:04:11 -07:00
13 changed files with 1019 additions and 230 deletions
+356 -57
View File
@@ -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
View File
@@ -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
View File
@@ -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);
+4
View File
@@ -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
View File
@@ -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
View File
@@ -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 }
}
+51
View File
@@ -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
View File
@@ -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),
);
}
}
}
+65
View File
@@ -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
View File
@@ -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
View File
@@ -1,4 +1,5 @@
pub mod app;
pub mod cat_tree;
pub mod category_panel;
pub mod effect;
pub mod formula_panel;
+131 -1
View File
@@ -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
View File
@@ -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;