20 Commits

Author SHA1 Message Date
42d869e4c2 refactor(ui): integrate centralized layout and display logic
Update UI components and view layout to use the new centralized layout and
display logic.

- Update `CategoryPanel` to remove redundant title text.
- Update `ViewPanel` to remove redundant title text.
- Refactor `Effect` implementations to use `display_value` and `Rc` for
  records.
- Update `GridWidget` to use the centralized `layout` and `display_text` .
- Refactor `GridLayout` to support synthetic keys for records mode and
  unified display.
- Update `view` module to re-export `synthetic_record_info` .

Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/gemma-4-26B-A4B-it-GGUF:UD-Q5_K_XL)
2026-04-07 09:29:45 -07:00
d32a6140b8 refactor(core): centralize formatting logic
Move formatting logic to a new `format` module and update `main.rs` and
`persistence` to use it.

- Create `src/format.rs` with shared formatting functions.
- Update `src/main.rs` to include the `format` module.
- Refactor `src/persistence/mod.rs` to use the new `display_text` logic via
  `GridLayout` .

Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/gemma-4-26B-A4B-it-GGUF:UD-Q5_K_XL)
2026-04-07 09:29:45 -07:00
9251e37180 refactor(ui): optimize record sharing and centralize layout management
Refactor `App` and `DrillState` to use `Rc` for efficient sharing of frozen
records and integrate a persistent `layout` field.

- Update `DrillState` to use `Rc<Vec<(CellKey, CellValue)>>` for records.
- Add `layout` field to `App` .
- Implement `rebuild_layout()` in `App` to refresh the grid layout.
- Ensure `layout` is rebuilt after applying effects and handling key
  events.
- Update `App::new` and `App::cmd_context` to use the new layout
  management.

Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/gemma-4-26B-A4B-it-GGUF:UD-Q5_K_XL)
2026-04-07 09:29:45 -07:00
492d309277 feat(command): update keybindings for navigation and editing
Update keybindings to support new navigation commands and improve user
experience.

- Bind `Home` to `jump-first-col` and `End` to `jump-last-col` .
- Bind `PageUp` and `PageDown` to `page-scroll` .
- Update `o` keybinding from `add-record-row` to `open-record-row` .
- Bind `Tab` to `commit-and-advance-right` in editing mode.

Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/gemma-4-26B-A4B-it-GGUF:UD-Q5_K_XL)
2026-04-07 09:29:45 -07:00
85a459289d refactor(command): unify layout access and navigation commands
Refactor `CmdContext` to delegate layout-related information (row/column
counts, categories, cell keys) to a `GridLayout` object.

- Add `layout` field to `CmdContext` .
- Implement helper methods on `CmdContext` to access layout data.
- Consolidate multiple jump commands ( `JumpToFirstRow` , `JumpToLastRow` ,
  `JumpToFirstCol` , `JumpToLastCol` ) into a single `JumpToEdge` command.
- Introduce `ScrollRows` and `PageScroll` commands for improved navigation.
- Update `CursorState` instantiation to use the new context structure.
- Update command registry to use the new unified commands and macros.

Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/gemma-4-26B-A4B-it-GGUF:UD-Q5_K_XL)
2026-04-07 09:29:45 -07:00
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
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
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
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
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
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
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
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
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
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
32677141de chore(merge): remote-tracking branch 'origin/main' 2026-04-06 08:59:09 -07:00
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
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
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
9d88ad3205 feat: add fake bank data 2026-04-05 14:04:11 -07:00
18 changed files with 1763 additions and 650 deletions

File diff suppressed because it is too large Load Diff

View File

@ -266,10 +266,14 @@ impl KeymapSet {
normal.bind(KeyCode::Char('G'), none, "jump-last-row"); normal.bind(KeyCode::Char('G'), none, "jump-last-row");
normal.bind(KeyCode::Char('0'), none, "jump-first-col"); normal.bind(KeyCode::Char('0'), none, "jump-first-col");
normal.bind(KeyCode::Char('$'), none, "jump-last-col"); normal.bind(KeyCode::Char('$'), none, "jump-last-col");
normal.bind(KeyCode::Home, none, "jump-first-col");
normal.bind(KeyCode::End, none, "jump-last-col");
// Scroll // Scroll
normal.bind_args(KeyCode::Char('d'), ctrl, "scroll-rows", vec!["5".into()]); normal.bind_args(KeyCode::Char('d'), ctrl, "scroll-rows", vec!["5".into()]);
normal.bind_args(KeyCode::Char('u'), ctrl, "scroll-rows", vec!["-5".into()]); normal.bind_args(KeyCode::Char('u'), ctrl, "scroll-rows", vec!["-5".into()]);
normal.bind_args(KeyCode::PageDown, none, "page-scroll", vec!["1".into()]);
normal.bind_args(KeyCode::PageUp, none, "page-scroll", vec!["-1".into()]);
// Cell operations // Cell operations
normal.bind(KeyCode::Char('x'), none, "clear-cell"); normal.bind(KeyCode::Char('x'), none, "clear-cell");
@ -354,9 +358,14 @@ impl KeymapSet {
normal.bind(KeyCode::Char('z'), none, "toggle-group-under-cursor"); normal.bind(KeyCode::Char('z'), none, "toggle-group-under-cursor");
normal.bind(KeyCode::Char('H'), none, "hide-selected-row-item"); 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, "drill-into-cell");
normal.bind(KeyCode::Char('<'), none, "view-back"); normal.bind(KeyCode::Char('<'), none, "view-back");
normal.bind(KeyCode::Char('o'), none, "open-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 // Tile select
normal.bind(KeyCode::Char('T'), none, "enter-tile-select"); normal.bind(KeyCode::Char('T'), none, "enter-tile-select");
@ -417,6 +426,9 @@ impl KeymapSet {
fp.bind(KeyCode::Char('o'), none, "enter-formula-edit"); fp.bind(KeyCode::Char('o'), none, "enter-formula-edit");
fp.bind(KeyCode::Char('d'), none, "delete-formula-at-cursor"); fp.bind(KeyCode::Char('d'), none, "delete-formula-at-cursor");
fp.bind(KeyCode::Delete, 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)); set.insert(ModeKey::FormulaPanel, Arc::new(fp));
// ── Category panel ─────────────────────────────────────────────── // ── Category panel ───────────────────────────────────────────────
@ -439,7 +451,7 @@ impl KeymapSet {
vec!["category".into(), "1".into()], 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(KeyCode::Char(' '), none, "cycle-axis-at-cursor");
cp.bind_args( cp.bind_args(
KeyCode::Char('n'), KeyCode::Char('n'),
@ -449,6 +461,27 @@ impl KeymapSet {
); );
cp.bind(KeyCode::Char('a'), none, "open-item-add-at-cursor"); 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('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)); set.insert(ModeKey::CategoryPanel, Arc::new(cp));
// ── View panel ─────────────────────────────────────────────────── // ── View panel ───────────────────────────────────────────────────
@ -476,6 +509,9 @@ impl KeymapSet {
vp.bind(KeyCode::Char('o'), none, "create-and-switch-view"); vp.bind(KeyCode::Char('o'), none, "create-and-switch-view");
vp.bind(KeyCode::Char('d'), none, "delete-view-at-cursor"); vp.bind(KeyCode::Char('d'), none, "delete-view-at-cursor");
vp.bind(KeyCode::Delete, 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)); set.insert(ModeKey::ViewPanel, Arc::new(vp));
// ── Tile select ────────────────────────────────────────────────── // ── Tile select ──────────────────────────────────────────────────
@ -528,6 +564,7 @@ impl KeymapSet {
let mut ed = Keymap::new(); let mut ed = Keymap::new();
ed.bind_args(KeyCode::Esc, none, "enter-mode", vec!["normal".into()]); ed.bind_args(KeyCode::Esc, none, "enter-mode", vec!["normal".into()]);
ed.bind(KeyCode::Enter, none, "commit-cell-edit"); ed.bind(KeyCode::Enter, none, "commit-cell-edit");
ed.bind(KeyCode::Tab, none, "commit-and-advance-right");
ed.bind_args(KeyCode::Backspace, none, "pop-char", vec!["edit".into()]); ed.bind_args(KeyCode::Backspace, none, "pop-char", vec!["edit".into()]);
ed.bind_any_char("append-char", vec!["edit".into()]); ed.bind_any_char("append-char", vec!["edit".into()]);
set.insert(ModeKey::Editing, Arc::new(ed)); set.insert(ModeKey::Editing, Arc::new(ed));

View File

@ -65,9 +65,16 @@ pub fn run_tui(
tui_context.terminal.draw(|f| draw(f, &app))?; tui_context.terminal.draw(|f| draw(f, &app))?;
if event::poll(Duration::from_millis(100))? { if event::poll(Duration::from_millis(100))? {
if let Event::Key(key) = event::read()? { match event::read()? {
Event::Key(key) => {
app.handle_key(key)?; app.handle_key(key)?;
} }
Event::Resize(w, h) => {
app.term_width = w;
app.term_height = h;
}
_ => {}
}
} }
app.autosave_if_needed(); app.autosave_if_needed();
@ -161,9 +168,7 @@ fn draw(f: &mut Frame, app: &App) {
f.render_widget(ImportWizardWidget::new(wizard), size); f.render_widget(ImportWizardWidget::new(wizard), size);
} }
} }
if matches!(app.mode, AppMode::ExportPrompt { .. }) { // ExportPrompt now uses the minibuffer at the bottom bar.
draw_export_prompt(f, size, app);
}
if app.is_empty_model() && matches!(app.mode, AppMode::Normal | AppMode::CommandMode { .. }) { if app.is_empty_model() && matches!(app.mode, AppMode::Normal | AppMode::CommandMode { .. }) {
draw_welcome(f, main_chunks[1]); 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 { if app.category_panel_open {
let a = Rect::new(side.x, y, side.width, ph); let a = Rect::new(side.x, y, side.width, ph);
f.render_widget( 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, a,
); );
y += ph; 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) { 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 { .. } => { AppMode::CommandMode { .. } => {
let buf = app.buffers.get("command").map(|s| s.as_str()).unwrap_or(""); 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); 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) { fn draw_welcome(f: &mut Frame, area: Rect) {
let popup = centered_popup(area, 58, 20); let popup = centered_popup(area, 58, 20);

50
src/format.rs Normal file
View File

@ -0,0 +1,50 @@
use crate::model::cell::CellValue;
/// Format a CellValue for display with number formatting options.
pub fn format_value(v: Option<&CellValue>, comma: bool, decimals: u8) -> String {
match v {
Some(CellValue::Number(n)) => format_f64(*n, comma, decimals),
Some(CellValue::Text(s)) => s.clone(),
None => String::new(),
}
}
/// Parse a number format string like ",.0" into (use_commas, decimal_places).
pub fn parse_number_format(fmt: &str) -> (bool, u8) {
let comma = fmt.contains(',');
let decimals = fmt
.rfind('.')
.and_then(|i| fmt[i + 1..].parse::<u8>().ok())
.unwrap_or(0);
(comma, decimals)
}
/// Format an f64 with optional comma grouping and decimal places.
pub fn format_f64(n: f64, comma: bool, decimals: u8) -> String {
let formatted = format!("{:.prec$}", n, prec = decimals as usize);
if !comma {
return formatted;
}
let (int_part, dec_part) = if let Some(dot) = formatted.find('.') {
(&formatted[..dot], Some(&formatted[dot..]))
} else {
(&formatted[..], None)
};
let is_neg = int_part.starts_with('-');
let digits = if is_neg { &int_part[1..] } else { int_part };
let mut result = String::new();
for (idx, c) in digits.chars().rev().enumerate() {
if idx > 0 && idx % 3 == 0 {
result.push(',');
}
result.push(c);
}
if is_neg {
result.push('-');
}
let mut out: String = result.chars().rev().collect();
if let Some(dec) = dec_part {
out.push_str(dec);
}
out
}

View File

@ -1,5 +1,6 @@
mod command; mod command;
mod draw; mod draw;
mod format;
mod formula; mod formula;
mod import; mod import;
mod model; mod model;

View File

@ -117,6 +117,10 @@ impl Category {
id id
} }
pub fn remove_item(&mut self, name: &str) {
self.items.shift_remove(name);
}
pub fn add_item_in_group( pub fn add_item_in_group(
&mut self, &mut self,
name: impl Into<String>, name: impl Into<String>,

View File

@ -53,10 +53,14 @@ impl Model {
next_category_id: 2, next_category_id: 2,
measure_agg: HashMap::new(), 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() { for view in m.views.values_mut() {
view.on_category_added("_Index"); view.on_category_added("_Index");
view.on_category_added("_Dim"); view.on_category_added("_Dim");
view.set_axis("_Index", crate::view::Axis::Row);
view.set_axis("_Dim", crate::view::Axis::Column);
} }
m m
} }
@ -107,6 +111,47 @@ impl Model {
Ok(id) 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> { pub fn category_mut(&mut self, name: &str) -> Option<&mut Category> {
self.categories.get_mut(name) self.categories.get_mut(name)
} }
@ -527,6 +572,31 @@ mod model_tests {
assert_eq!(m.get_cell(&k4), Some(&CellValue::Number(40.0))); 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] #[test]
fn create_view_copies_category_structure() { fn create_view_copies_category_structure() {
let mut m = Model::new("Test"); let mut m = Model::new("Test");

View File

@ -458,17 +458,7 @@ pub fn export_csv(model: &Model, view_name: &str, path: &Path) -> Result<()> {
out.push(','); out.push(',');
} }
let row_values: Vec<String> = (0..layout.col_count()) let row_values: Vec<String> = (0..layout.col_count())
.map(|ci| { .map(|ci| layout.display_text(model, ri, ci, false, 0))
if layout.is_records_mode() {
layout.records_display(ri, ci).unwrap_or_default()
} else {
layout
.cell_key(ri, ci)
.and_then(|key| model.evaluate_aggregated(&key, &layout.none_cats))
.map(|v| v.to_string())
.unwrap_or_default()
}
})
.collect(); .collect();
out.push_str(&row_values.join(",")); out.push_str(&row_values.join(","));
out.push('\n'); out.push('\n');

View File

@ -5,23 +5,25 @@ use std::path::PathBuf;
use std::sync::Arc; use std::sync::Arc;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use std::rc::Rc;
use crate::command::cmd::CmdContext; use crate::command::cmd::CmdContext;
use crate::command::keymap::{Keymap, KeymapSet}; use crate::command::keymap::{Keymap, KeymapSet};
use crate::import::wizard::ImportWizard; use crate::import::wizard::ImportWizard;
use crate::model::cell::CellValue; use crate::model::cell::CellValue;
use crate::model::Model; use crate::model::Model;
use crate::persistence; use crate::persistence;
use crate::ui::grid::{
compute_col_widths, compute_row_header_width, compute_visible_cols, parse_number_format,
};
use crate::view::GridLayout; use crate::view::GridLayout;
/// Drill-down state: frozen record snapshot + pending edits that have not /// Drill-down state: frozen record snapshot + pending edits that have not
/// yet been applied to the model. /// yet been applied to the model.
#[derive(Debug, Clone, Default)] #[derive(Debug, Clone, Default)]
pub struct DrillState { pub struct DrillState {
/// Frozen snapshot of records shown in the drill view. /// Frozen snapshot of records shown in the drill view (Rc for cheap cloning).
pub records: Vec<( pub records: Rc<Vec<(crate::model::cell::CellKey, crate::model::cell::CellValue)>>,
crate::model::cell::CellKey,
crate::model::cell::CellValue,
)>,
/// Pending edits keyed by (record_idx, column_name) → new string value. /// Pending edits keyed by (record_idx, column_name) → new string value.
/// column_name is either "Value" or a category name. /// column_name is either "Value" or a category name.
pub pending_edits: std::collections::HashMap<(usize, String), String>, pub pending_edits: std::collections::HashMap<(usize, String), String>,
@ -91,15 +93,27 @@ pub struct App {
/// when filters would change. Pending edits are stored alongside and /// when filters would change. Pending edits are stored alongside and
/// applied to the model on commit/navigate-away. /// applied to the model on commit/navigate-away.
pub drill_state: Option<DrillState>, 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 /// Named text buffers for text-entry modes
pub buffers: HashMap<String, String>, pub buffers: HashMap<String, String>,
/// Transient keymap for Emacs-style prefix key sequences (g→gg, y→yy, etc.) /// Transient keymap for Emacs-style prefix key sequences (g→gg, y→yy, etc.)
pub transient_keymap: Option<Arc<Keymap>>, pub transient_keymap: Option<Arc<Keymap>>,
/// Current grid layout, derived from model + view + drill_state.
/// Rebuilt via `rebuild_layout()` after state changes.
pub layout: GridLayout,
keymap_set: KeymapSet, keymap_set: KeymapSet,
} }
impl App { impl App {
pub fn new(model: Model, file_path: Option<PathBuf>) -> Self { pub fn new(model: Model, file_path: Option<PathBuf>) -> Self {
let layout = {
let view = model.active_view();
GridLayout::with_frozen_records(&model, view, None)
};
Self { Self {
model, model,
file_path, file_path,
@ -121,19 +135,31 @@ impl App {
view_back_stack: Vec::new(), view_back_stack: Vec::new(),
view_forward_stack: Vec::new(), view_forward_stack: Vec::new(),
drill_state: None, 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(), buffers: HashMap::new(),
transient_keymap: None, transient_keymap: None,
layout,
keymap_set: KeymapSet::default_keymaps(), keymap_set: KeymapSet::default_keymaps(),
} }
} }
/// Rebuild the grid layout from current model, view, and drill state.
/// Note: `with_frozen_records` already handles pruning internally.
pub fn rebuild_layout(&mut self) {
let view = self.model.active_view();
let frozen = self.drill_state.as_ref().map(|s| Rc::clone(&s.records));
self.layout = GridLayout::with_frozen_records(&self.model, view, frozen);
}
pub fn cmd_context(&self, key: KeyCode, _mods: KeyModifiers) -> CmdContext<'_> { pub fn cmd_context(&self, key: KeyCode, _mods: KeyModifiers) -> CmdContext<'_> {
let view = self.model.active_view(); let view = self.model.active_view();
let frozen_records = self.drill_state.as_ref().map(|s| s.records.clone()); let layout = &self.layout;
let layout = GridLayout::with_frozen_records(&self.model, view, frozen_records);
let (sel_row, sel_col) = view.selected; let (sel_row, sel_col) = view.selected;
CmdContext { CmdContext {
model: &self.model, model: &self.model,
layout,
mode: &self.mode, mode: &self.mode,
selected: view.selected, selected: view.selected,
row_offset: view.row_offset, row_offset: view.row_offset,
@ -150,27 +176,40 @@ impl App {
cat_panel_cursor: self.cat_panel_cursor, cat_panel_cursor: self.cat_panel_cursor,
view_panel_cursor: self.view_panel_cursor, view_panel_cursor: self.view_panel_cursor,
tile_cat_idx: self.tile_cat_idx, tile_cat_idx: self.tile_cat_idx,
cell_key: layout.cell_key(sel_row, sel_col), view_back_stack: &self.view_back_stack,
row_count: layout.row_count(), view_forward_stack: &self.view_forward_stack,
col_count: layout.col_count(), display_value: {
none_cats: layout.none_cats.clone(), let key = layout.cell_key(sel_row, sel_col);
view_back_stack: self.view_back_stack.clone(), if let Some(k) = &key {
view_forward_stack: self.view_forward_stack.clone(), if let Some((idx, dim)) = crate::view::synthetic_record_info(k) {
records_col: if layout.is_records_mode() { self.drill_state
Some(layout.col_label(sel_col)) .as_ref()
.and_then(|s| s.pending_edits.get(&(idx, dim)).cloned())
.or_else(|| layout.resolve_display(k))
.unwrap_or_default()
} else { } else {
None self.model
}, .get_cell(k)
records_value: if layout.is_records_mode() { .map(|v| v.to_string())
// Check pending edits first, then fall back to original .unwrap_or_default()
let col_name = layout.col_label(sel_col); }
let pending = self.drill_state.as_ref().and_then(|s| {
s.pending_edits.get(&(sel_row, col_name.clone())).cloned()
});
pending.or_else(|| layout.records_display(sel_row, sel_col))
} else { } else {
None String::new()
}
}, },
visible_rows: (self.term_height as usize).saturating_sub(8),
visible_cols: {
let (fmt_comma, fmt_decimals) = parse_number_format(&view.number_format);
let col_widths = compute_col_widths(&self.model, layout, fmt_comma, fmt_decimals);
let row_header_width = compute_row_header_width(layout);
compute_visible_cols(
&col_widths,
row_header_width,
self.term_width,
view.col_offset,
)
},
expanded_cats: &self.expanded_cats,
key_code: key, key_code: key,
} }
} }
@ -179,6 +218,7 @@ impl App {
for effect in effects { for effect in effects {
effect.apply(self); effect.apply(self);
} }
self.rebuild_layout();
} }
/// True when the model has no categories yet (show welcome screen) /// True when the model has no categories yet (show welcome screen)
@ -187,6 +227,8 @@ impl App {
} }
pub fn handle_key(&mut self, key: KeyEvent) -> Result<()> { pub fn handle_key(&mut self, key: KeyEvent) -> Result<()> {
self.rebuild_layout();
// Transient keymap (prefix key sequence) takes priority // Transient keymap (prefix key sequence) takes priority
if let Some(transient) = self.transient_keymap.take() { if let Some(transient) = self.transient_keymap.take() {
let effects = { let effects = {
@ -230,11 +272,11 @@ impl App {
/// Hint text for the status bar (context-sensitive) /// Hint text for the status bar (context-sensitive)
pub fn hint_text(&self) -> &'static str { pub fn hint_text(&self) -> &'static str {
match &self.mode { match &self.mode {
AppMode::Normal => "hjkl:nav 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::Editing { .. } => "Enter:commit Tab:commit+right Esc:cancel",
AppMode::FormulaPanel => "n:new d:delete jk:nav Esc:back", AppMode::FormulaPanel => "n:new d:delete jk:nav Esc:back",
AppMode::FormulaEdit { .. } => "Enter:save Esc:cancel — type: Name = expression", AppMode::FormulaEdit { .. } => "Enter:save Esc:cancel — type: Name = expression",
AppMode::CategoryPanel => "jk:nav Space:cycle-axis n:new-cat a:add-items 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::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::ItemAdd { .. } => "Enter:add & continue Tab:same Esc:done — type an item name",
AppMode::ViewPanel => "jk:nav Enter:switch n:new d:delete Esc:back", AppMode::ViewPanel => "jk:nav Enter:switch n:new d:delete Esc:back",
@ -280,6 +322,8 @@ mod tests {
col_count: 2, col_count: 2,
row_offset: 0, row_offset: 0,
col_offset: 0, col_offset: 0,
visible_rows: 20,
visible_cols: 8,
}; };
crate::command::cmd::EnterAdvance { cursor } crate::command::cmd::EnterAdvance { cursor }
} }
@ -353,6 +397,187 @@ mod tests {
assert_eq!(app.buffers.get("command").map(|s| s.as_str()), Some("q")); assert_eq!(app.buffers.get("command").map(|s| s.as_str()), Some("q"));
} }
#[test]
fn col_offset_scrolls_when_cursor_moves_past_visible_columns() {
use crate::model::cell::{CellKey, CellValue};
// Create a model with 8 wide columns. Column item names are 30 chars
// each → column widths ~31 chars. With term_width=80, row header ~4,
// data area ~76 → only ~2 columns actually fit. But the rough estimate
// (8030)/12 = 4 over-counts, so viewport_effects never scrolls.
let mut m = Model::new("T");
m.add_category("Row").unwrap();
m.add_category("Col").unwrap();
m.category_mut("Row").unwrap().add_item("R1");
for i in 0..8 {
let name = format!("VeryLongColumnItemName_{i:03}");
m.category_mut("Col").unwrap().add_item(&name);
}
// Populate a value so the model isn't empty
let key = CellKey::new(vec![
("Row".to_string(), "R1".to_string()),
("Col".to_string(), "VeryLongColumnItemName_000".to_string()),
]);
m.set_cell(key, CellValue::Number(1.0));
let mut app = App::new(m, None);
app.term_width = 80;
// Press 'l' (right) 3 times to move cursor to column 3.
// Only ~2 columns fit in 76 chars of data area (each col ~26 chars wide),
// so column 3 is well off-screen. The buggy estimate (8030)/12 = 4
// thinks 4 columns fit, so it won't scroll until col 4.
for _ in 0..3 {
app.handle_key(KeyEvent::new(KeyCode::Char('l'), KeyModifiers::NONE))
.unwrap();
}
assert_eq!(
app.model.active_view().selected.1,
3,
"cursor should be at column 3"
);
assert!(
app.model.active_view().col_offset > 0,
"col_offset should scroll when cursor moves past visible area (only ~2 cols fit \
in 80-char terminal with 26-char-wide columns), but col_offset is {}",
app.model.active_view().col_offset
);
}
#[test]
fn home_jumps_to_first_col() {
let mut app = two_col_model();
app.model.active_view_mut().selected = (1, 1);
app.handle_key(KeyEvent::new(KeyCode::Home, KeyModifiers::NONE))
.unwrap();
assert_eq!(app.model.active_view().selected, (1, 0));
}
#[test]
fn end_jumps_to_last_col() {
let mut app = two_col_model();
app.model.active_view_mut().selected = (1, 0);
app.handle_key(KeyEvent::new(KeyCode::End, KeyModifiers::NONE))
.unwrap();
assert_eq!(app.model.active_view().selected, (1, 1));
}
#[test]
fn page_down_scrolls_by_three_quarters_visible() {
let mut app = two_col_model();
// Add enough rows
for i in 0..30 {
app.model
.category_mut("Row")
.unwrap()
.add_item(&format!("R{i}"));
}
app.term_height = 28; // ~20 visible rows → delta = 15
app.model.active_view_mut().selected = (0, 0);
app.handle_key(KeyEvent::new(KeyCode::PageDown, KeyModifiers::NONE))
.unwrap();
assert_eq!(app.model.active_view().selected.1, 0, "column preserved");
assert!(
app.model.active_view().selected.0 > 0,
"row should advance on PageDown"
);
// 3/4 of ~20 = 15
assert_eq!(app.model.active_view().selected.0, 15);
}
#[test]
fn page_up_scrolls_backward() {
let mut app = two_col_model();
for i in 0..30 {
app.model
.category_mut("Row")
.unwrap()
.add_item(&format!("R{i}"));
}
app.term_height = 28;
app.model.active_view_mut().selected = (20, 0);
app.handle_key(KeyEvent::new(KeyCode::PageUp, KeyModifiers::NONE))
.unwrap();
assert_eq!(app.model.active_view().selected.0, 5);
}
#[test]
fn jump_last_row_scrolls_with_small_terminal() {
let mut app = two_col_model();
// Total rows: A, B, C + R0..R9 = 13 rows. Last row = 12.
for i in 0..10 {
app.model.category_mut("Row").unwrap().add_item(&format!("R{i}"));
}
app.term_height = 13; // ~5 visible rows
app.model.active_view_mut().selected = (0, 0);
// G jumps to last row (row 12)
app.handle_key(KeyEvent::new(KeyCode::Char('G'), KeyModifiers::NONE))
.unwrap();
let last = app.model.active_view().selected.0;
assert_eq!(last, 12, "should be at last row");
// With only ~5 visible rows and 13 rows, offset should scroll.
// Bug: hardcoded 20 means `12 >= 0 + 20` is false → no scroll.
let offset = app.model.active_view().row_offset;
assert!(
offset > 0,
"row_offset should scroll when last row is beyond visible area, but is {offset}"
);
}
#[test]
fn ctrl_d_scrolls_viewport_with_small_terminal() {
let mut app = two_col_model();
for i in 0..30 {
app.model
.category_mut("Row")
.unwrap()
.add_item(&format!("R{i}"));
}
app.term_height = 13; // ~5 visible rows
app.model.active_view_mut().selected = (0, 0);
// Ctrl+d scrolls by 5 rows
app.handle_key(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL))
.unwrap();
assert_eq!(app.model.active_view().selected.0, 5);
// Press Ctrl+d again — now at row 10 with only 5 visible rows,
// row_offset should have scrolled (not stay at 0 due to hardcoded 20)
app.handle_key(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL))
.unwrap();
assert_eq!(app.model.active_view().selected.0, 10);
assert!(
app.model.active_view().row_offset > 0,
"row_offset should scroll with small terminal, but is {}",
app.model.active_view().row_offset
);
}
#[test]
fn tab_in_edit_mode_commits_and_moves_right() {
let mut app = two_col_model();
app.model.active_view_mut().selected = (0, 0);
// Enter edit mode
app.handle_key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE))
.unwrap();
assert!(matches!(app.mode, AppMode::Editing { .. }));
// Type a digit
app.handle_key(KeyEvent::new(KeyCode::Char('5'), KeyModifiers::NONE))
.unwrap();
// Press Tab — should commit, move right, re-enter edit mode
app.handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE))
.unwrap();
// Should be in edit mode on column 1
assert!(
matches!(app.mode, AppMode::Editing { .. }),
"should be in edit mode after Tab, but mode is {:?}",
app.mode
);
assert_eq!(
app.model.active_view().selected.1,
1,
"should have moved to column 1"
);
}
#[test] #[test]
fn command_mode_buffer_cleared_on_reentry() { fn command_mode_buffer_cleared_on_reentry() {
use crossterm::event::KeyEvent; use crossterm::event::KeyEvent;

51
src/ui/cat_tree.rs Normal file
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
}

View File

@ -7,6 +7,7 @@ use ratatui::{
use crate::model::Model; use crate::model::Model;
use crate::ui::app::AppMode; use crate::ui::app::AppMode;
use crate::ui::cat_tree::{build_cat_tree, CatTreeEntry};
use crate::view::Axis; use crate::view::Axis;
fn axis_display(axis: Axis) -> (&'static str, Color) { fn axis_display(axis: Axis) -> (&'static str, Color) {
@ -22,14 +23,21 @@ pub struct CategoryPanel<'a> {
pub model: &'a Model, pub model: &'a Model,
pub mode: &'a AppMode, pub mode: &'a AppMode,
pub cursor: usize, pub cursor: usize,
pub expanded: &'a std::collections::HashSet<String>,
} }
impl<'a> CategoryPanel<'a> { 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 { Self {
model, model,
mode, mode,
cursor, cursor,
expanded,
} }
} }
} }
@ -40,18 +48,8 @@ impl<'a> Widget for CategoryPanel<'a> {
let is_cat_add = matches!(self.mode, AppMode::CategoryAdd { .. }); let is_cat_add = matches!(self.mode, AppMode::CategoryAdd { .. });
let is_active = matches!(self.mode, AppMode::CategoryPanel) || is_item_add || is_cat_add; let is_active = matches!(self.mode, AppMode::CategoryPanel) || is_item_add || is_cat_add;
let (border_color, title) = if is_cat_add { let (border_color, title) = if is_active {
( (Color::Cyan, " Categories ")
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 ")
} else { } else {
(Color::DarkGray, " Categories ") (Color::DarkGray, " Categories ")
}; };
@ -64,9 +62,9 @@ impl<'a> Widget for CategoryPanel<'a> {
block.render(area, buf); block.render(area, buf);
let view = self.model.active_view(); 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 tree.is_empty() {
if cat_names.is_empty() {
buf.set_string( buf.set_string(
inner.x, inner.x,
inner.y, inner.y,
@ -76,36 +74,14 @@ impl<'a> Widget for CategoryPanel<'a> {
return; return;
} }
// How many rows for the list vs the prompt at bottom for (i, entry) in tree.iter().enumerate() {
let prompt_rows = if is_item_add { 2u16 } else { 0 }; if i as u16 >= inner.height {
let list_height = inner.height.saturating_sub(prompt_rows);
for (i, cat_name) in cat_names.iter().enumerate() {
if i as u16 >= list_height {
break; break;
} }
let y = inner.y + i as u16; 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 base_style = if is_selected {
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 {
Style::default() Style::default()
.fg(Color::Black) .fg(Color::Black)
.bg(Color::Cyan) .bg(Color::Cyan)
@ -114,12 +90,20 @@ impl<'a> Widget for CategoryPanel<'a> {
Style::default() Style::default()
}; };
if is_selected_cat { if is_selected {
let fill = " ".repeat(inner.width as usize); let fill = " ".repeat(inner.width as usize);
buf.set_string(inner.x, y, &fill, base_style); buf.set_string(inner.x, y, &fill, base_style);
} }
let name_part = format!(" {cat_name} ({item_count})"); 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}]"); let axis_part = format!(" [{axis_str}]");
buf.set_string(inner.x, y, &name_part, base_style); buf.set_string(inner.x, y, &name_part, base_style);
@ -128,7 +112,7 @@ impl<'a> Widget for CategoryPanel<'a> {
inner.x + name_part.len() as u16, inner.x + name_part.len() as u16,
y, y,
&axis_part, &axis_part,
if is_selected_cat { if is_selected {
base_style base_style
} else { } else {
Style::default().fg(axis_color) Style::default().fg(axis_color)
@ -136,29 +120,11 @@ impl<'a> Widget for CategoryPanel<'a> {
); );
} }
} }
CatTreeEntry::Item { item_name, .. } => {
// Inline prompt at the bottom for CategoryAdd or ItemAdd let label = format!(" · {item_name}");
let (prompt_color, prompt_text) = match self.mode { buf.set_string(inner.x, y, &label, base_style);
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),
);
} }
} }
} }

View File

@ -90,6 +90,60 @@ 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 = ctx.display_value.clone();
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 ─────────────────────────────────────────────────────────── // ── View mutations ───────────────────────────────────────────────────────────
#[derive(Debug)] #[derive(Debug)]
@ -344,7 +398,7 @@ pub struct StartDrill(pub Vec<(CellKey, CellValue)>);
impl Effect for StartDrill { impl Effect for StartDrill {
fn apply(&self, app: &mut App) { fn apply(&self, app: &mut App) {
app.drill_state = Some(super::app::DrillState { app.drill_state = Some(super::app::DrillState {
records: self.0.clone(), records: std::rc::Rc::new(self.0.clone()),
pending_edits: std::collections::HashMap::new(), pending_edits: std::collections::HashMap::new(),
}); });
} }
@ -358,6 +412,9 @@ impl Effect for ApplyAndClearDrill {
let Some(drill) = app.drill_state.take() else { let Some(drill) = app.drill_state.take() else {
return; return;
}; };
if drill.pending_edits.is_empty() {
return;
}
// For each pending edit, update the cell // For each pending edit, update the cell
for ((record_idx, col_name), new_value) in &drill.pending_edits { for ((record_idx, col_name), new_value) in &drill.pending_edits {
let Some((orig_key, _)) = drill.records.get(*record_idx) else { let Some((orig_key, _)) = drill.records.get(*record_idx) else {
@ -773,6 +830,16 @@ pub enum Panel {
View, View,
} }
impl Panel {
pub fn mode(self) -> AppMode {
match self {
Panel::Formula => AppMode::FormulaPanel,
Panel::Category => AppMode::CategoryPanel,
Panel::View => AppMode::ViewPanel,
}
}
}
impl Effect for SetPanelOpen { impl Effect for SetPanelOpen {
fn apply(&self, app: &mut App) { fn apply(&self, app: &mut App) {
match self.panel { match self.panel {

View File

@ -6,15 +6,15 @@ use ratatui::{
}; };
use unicode_width::UnicodeWidthStr; use unicode_width::UnicodeWidthStr;
use crate::model::cell::CellValue;
use crate::model::Model; use crate::model::Model;
use crate::ui::app::AppMode; use crate::ui::app::AppMode;
use crate::view::{AxisEntry, GridLayout}; use crate::view::{AxisEntry, GridLayout};
const ROW_HEADER_WIDTH: u16 = 16; /// Minimum column width — enough for short numbers/labels + 1 char gap.
const COL_WIDTH: u16 = 10; const MIN_COL_WIDTH: u16 = 5;
const MIN_COL_WIDTH: u16 = 6;
const MAX_COL_WIDTH: u16 = 32; 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. /// Subtle dark-gray background used to highlight the row containing the cursor.
const ROW_HIGHLIGHT_BG: Color = Color::Indexed(237); const ROW_HIGHLIGHT_BG: Color = Color::Indexed(237);
const GROUP_EXPANDED: &str = ""; const GROUP_EXPANDED: &str = "";
@ -22,6 +22,7 @@ const GROUP_COLLAPSED: &str = "▶";
pub struct GridWidget<'a> { pub struct GridWidget<'a> {
pub model: &'a Model, pub model: &'a Model,
pub layout: &'a GridLayout,
pub mode: &'a AppMode, pub mode: &'a AppMode,
pub search_query: &'a str, pub search_query: &'a str,
pub buffers: &'a std::collections::HashMap<String, String>, pub buffers: &'a std::collections::HashMap<String, String>,
@ -31,6 +32,7 @@ pub struct GridWidget<'a> {
impl<'a> GridWidget<'a> { impl<'a> GridWidget<'a> {
pub fn new( pub fn new(
model: &'a Model, model: &'a Model,
layout: &'a GridLayout,
mode: &'a AppMode, mode: &'a AppMode,
search_query: &'a str, search_query: &'a str,
buffers: &'a std::collections::HashMap<String, String>, buffers: &'a std::collections::HashMap<String, String>,
@ -38,6 +40,7 @@ impl<'a> GridWidget<'a> {
) -> Self { ) -> Self {
Self { Self {
model, model,
layout,
mode, mode,
search_query, search_query,
buffers, buffers,
@ -45,23 +48,9 @@ impl<'a> GridWidget<'a> {
} }
} }
/// In records mode, get the display text for (row, col): pending edit if
/// staged, otherwise the underlying record's value for that column.
fn records_cell_text(&self, layout: &GridLayout, row: usize, col: usize) -> String {
let col_name = layout.col_label(col);
let pending = self
.drill_state
.and_then(|s| s.pending_edits.get(&(row, col_name.clone())).cloned());
pending
.or_else(|| layout.records_display(row, col))
.unwrap_or_default()
}
fn render_grid(&self, area: Rect, buf: &mut Buffer) { fn render_grid(&self, area: Rect, buf: &mut Buffer) {
let view = self.model.active_view(); let view = self.model.active_view();
let layout = self.layout;
let frozen = self.drill_state.map(|s| s.records.clone());
let layout = GridLayout::with_frozen_records(self.model, view, frozen);
let (sel_row, sel_col) = view.selected; let (sel_row, sel_col) = view.selected;
let row_offset = view.row_offset; let row_offset = view.row_offset;
let col_offset = view.col_offset; let col_offset = view.col_offset;
@ -70,52 +59,11 @@ impl<'a> GridWidget<'a> {
let n_col_levels = layout.col_cats.len().max(1); let n_col_levels = layout.col_cats.len().max(1);
let n_row_levels = layout.row_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 let col_widths = compute_col_widths(self.model, &layout, fmt_comma, fmt_decimals);
// 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() {
let n = layout.col_count();
let mut widths = vec![MIN_COL_WIDTH; n];
for ci in 0..n {
let header = layout.col_label(ci);
let w = header.width() as u16;
if w > widths[ci] {
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;
}
}
}
// Add 2 cells of right-padding; cap at MAX_COL_WIDTH.
widths
.into_iter()
.map(|w| (w + 2).min(MAX_COL_WIDTH))
.collect()
} else {
vec![COL_WIDTH; layout.col_count()]
};
// Sub-column widths for row header area // ── Adaptive row header widths ───────────────────────────────
let sub_col_w = ROW_HEADER_WIDTH / n_row_levels as u16; let data_row_items: Vec<&Vec<String>> = layout
let sub_widths: Vec<u16> = (0..n_row_levels) .row_items
.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
.iter() .iter()
.filter_map(|e| { .filter_map(|e| {
if let AxisEntry::DataItem(v) = e { if let AxisEntry::DataItem(v) = e {
@ -125,8 +73,23 @@ impl<'a> GridWidget<'a> {
} }
}) })
.collect(); .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() .iter()
.filter_map(|e| { .filter_map(|e| {
if let AxisEntry::DataItem(v) = e { if let AxisEntry::DataItem(v) = e {
@ -143,11 +106,11 @@ impl<'a> GridWidget<'a> {
.any(|e| matches!(e, AxisEntry::GroupHeader { .. })); .any(|e| matches!(e, AxisEntry::GroupHeader { .. }));
// Compute how many columns fit starting from col_offset. // 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 acc = 0u16;
let mut last = col_offset; let mut last = col_offset;
for ci in col_offset..layout.col_count() { 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 { if acc + w > data_area_width {
break; break;
} }
@ -160,16 +123,16 @@ impl<'a> GridWidget<'a> {
let col_x: Vec<u16> = { let col_x: Vec<u16> = {
let mut v = vec![0u16; layout.col_count() + 1]; let mut v = vec![0u16; layout.col_count() + 1];
for ci in 0..layout.col_count() { 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 v
}; };
let col_x_at = |ci: usize| -> u16 { let col_x_at = |ci: usize| -> u16 {
area.x area.x
+ ROW_HEADER_WIDTH + row_header_width
+ col_x[ci].saturating_sub(col_x[col_offset]) + 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 }; let _header_rows = n_col_levels as u16 + 1 + if has_col_groups { 1 } else { 0 };
@ -187,7 +150,7 @@ impl<'a> GridWidget<'a> {
buf.set_string( buf.set_string(
area.x, area.x,
y, y,
format!("{:<width$}", "", width = ROW_HEADER_WIDTH as usize), format!("{:<width$}", "", width = row_header_width as usize),
Style::default(), Style::default(),
); );
let mut prev_group: Option<String> = None; let mut prev_group: Option<String> = None;
@ -233,7 +196,7 @@ impl<'a> GridWidget<'a> {
buf.set_string( buf.set_string(
area.x, area.x,
y, y,
format!("{:<width$}", "", width = ROW_HEADER_WIDTH as usize), format!("{:<width$}", "", width = row_header_width as usize),
Style::default(), Style::default(),
); );
for ci in visible_col_range.clone() { for ci in visible_col_range.clone() {
@ -252,7 +215,17 @@ impl<'a> GridWidget<'a> {
String::new() 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) header_style.add_modifier(Modifier::UNDERLINED)
} else { } else {
header_style header_style
@ -301,8 +274,8 @@ impl<'a> GridWidget<'a> {
y, y,
format!( format!(
"{:<width$}", "{:<width$}",
truncate(&label, ROW_HEADER_WIDTH as usize), truncate(&label, row_header_width as usize),
width = ROW_HEADER_WIDTH as usize width = row_header_width as usize
), ),
group_header_style, group_header_style,
); );
@ -340,9 +313,9 @@ impl<'a> GridWidget<'a> {
if is_sel_row { if is_sel_row {
let row_w = (area.x + area.width).saturating_sub(area.x); let row_w = (area.x + area.width).saturating_sub(area.x);
buf.set_string( buf.set_string(
area.x + ROW_HEADER_WIDTH, area.x + row_header_width,
y, 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), Style::default().bg(ROW_HIGHLIGHT_BG),
); );
} }
@ -378,23 +351,15 @@ impl<'a> GridWidget<'a> {
} }
let cw = col_w_at(ci) as usize; let cw = col_w_at(ci) as usize;
let (cell_str, value) = if layout.is_records_mode() { // Check pending drill edits first, then use display_text
let s = self.records_cell_text(&layout, ri, ci); let cell_str = if let Some(ds) = self.drill_state {
// In records mode the value is a string, not aggregated let col_name = layout.col_label(ci);
let v = if !s.is_empty() { ds.pending_edits
Some(crate::model::cell::CellValue::Text(s.clone())) .get(&(ri, col_name))
.cloned()
.unwrap_or_else(|| layout.display_text(self.model, ri, ci, fmt_comma, fmt_decimals))
} else { } else {
None layout.display_text(self.model, ri, ci, fmt_comma, fmt_decimals)
};
(s, v)
} else {
let key = match layout.cell_key(ri, ci) {
Some(k) => k,
None => continue,
};
let value = self.model.evaluate_aggregated(&key, &layout.none_cats);
let s = format_value(value.as_ref(), fmt_comma, fmt_decimals);
(s, value)
}; };
let is_selected = ri == sel_row && ci == sel_col; let is_selected = ri == sel_row && ci == sel_col;
let is_search_match = !self.search_query.is_empty() let is_search_match = !self.search_query.is_empty()
@ -407,7 +372,12 @@ impl<'a> GridWidget<'a> {
// "drill to edit". Records mode cells are always // "drill to edit". Records mode cells are always
// directly editable, as are plain pivot cells. // directly editable, as are plain pivot cells.
let is_aggregated = !layout.is_records_mode() 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 { let mut cell_style = if is_selected {
Style::default() Style::default()
.fg(Color::Black) .fg(Color::Black)
@ -416,13 +386,13 @@ impl<'a> GridWidget<'a> {
} else if is_search_match { } else if is_search_match {
Style::default().fg(Color::Black).bg(Color::Yellow) Style::default().fg(Color::Black).bg(Color::Yellow)
} else if is_sel_row { } else if is_sel_row {
let fg = if value.is_none() { let fg = if cell_str.is_empty() {
Color::DarkGray Color::DarkGray
} else { } else {
Color::White Color::White
}; };
Style::default().fg(fg).bg(ROW_HIGHLIGHT_BG) Style::default().fg(fg).bg(ROW_HIGHLIGHT_BG)
} else if value.is_none() { } else if cell_str.is_empty() {
Style::default().fg(Color::DarkGray) Style::default().fg(Color::DarkGray)
} else { } else {
Style::default() Style::default()
@ -479,7 +449,7 @@ impl<'a> GridWidget<'a> {
buf.set_string( buf.set_string(
area.x, area.x,
y, y,
format!("{:<width$}", "Total", width = ROW_HEADER_WIDTH as usize), format!("{:<width$}", "Total", width = row_header_width as usize),
Style::default() Style::default()
.fg(Color::Yellow) .fg(Color::Yellow)
.add_modifier(Modifier::BOLD), .add_modifier(Modifier::BOLD),
@ -551,53 +521,111 @@ impl<'a> Widget for GridWidget<'a> {
} }
} }
fn format_value(v: Option<&CellValue>, comma: bool, decimals: u8) -> String { /// Compute adaptive column widths for pivot mode (header labels + cell values).
match v { /// Header widths use the widest *individual* level label (not the joined
Some(CellValue::Number(n)) => format_f64(*n, comma, decimals), /// multi-level string), matching how the grid renderer draws each level on
Some(CellValue::Text(s)) => s.clone(), /// its own row with repeat-suppression.
None => String::new(), pub fn compute_col_widths(model: &Model, layout: &GridLayout, fmt_comma: bool, fmt_decimals: u8) -> Vec<u16> {
} let n = layout.col_count();
} let mut widths = vec![0u16; n];
// Measure individual header level labels
pub fn parse_number_format(fmt: &str) -> (bool, u8) { let data_col_items: Vec<&Vec<String>> = layout
let comma = fmt.contains(','); .col_items
let decimals = fmt .iter()
.rfind('.') .filter_map(|e| {
.and_then(|i| fmt[i + 1..].parse::<u8>().ok()) if let AxisEntry::DataItem(v) = e {
.unwrap_or(0); Some(v)
(comma, decimals)
}
pub fn format_f64(n: f64, comma: bool, decimals: u8) -> String {
let formatted = format!("{:.prec$}", n, prec = decimals as usize);
if !comma {
return formatted;
}
// Split integer and decimal parts
let (int_part, dec_part) = if let Some(dot) = formatted.find('.') {
(&formatted[..dot], Some(&formatted[dot..]))
} else { } else {
(&formatted[..], None) None
};
let is_neg = int_part.starts_with('-');
let digits = if is_neg { &int_part[1..] } else { int_part };
let mut result = String::new();
for (idx, c) in digits.chars().rev().enumerate() {
if idx > 0 && idx % 3 == 0 {
result.push(',');
} }
result.push(c); })
.collect();
for (ci, wref) in widths.iter_mut().enumerate().take(n) {
if let Some(levels) = data_col_items.get(ci) {
let max_level_w = levels.iter().map(|s| s.width() as u16).max().unwrap_or(0);
if max_level_w > *wref {
*wref = max_level_w;
} }
if is_neg {
result.push('-');
} }
let mut out: String = result.chars().rev().collect();
if let Some(dec) = dec_part {
out.push_str(dec);
} }
out // Measure cell content widths (works for both pivot and records modes)
for ri in 0..layout.row_count() {
for (ci, wref) in widths.iter_mut().enumerate().take(n) {
let s = layout.display_text(model, ri, ci, fmt_comma, fmt_decimals);
let w = s.width() as u16;
if w > *wref {
*wref = w;
}
}
}
// Measure total row (column sums) — pivot mode only
if !layout.is_records_mode() && layout.row_count() > 0 {
for (ci, wref) in widths.iter_mut().enumerate().take(n) {
let total: f64 = (0..layout.row_count())
.filter_map(|ri| layout.cell_key(ri, ci))
.map(|key| model.evaluate_aggregated_f64(&key, &layout.none_cats))
.sum();
let s = format_f64(total, fmt_comma, fmt_decimals);
let w = s.width() as u16;
if w > *wref {
*wref = w;
}
}
}
widths
.into_iter()
.map(|w| (w + 1).max(MIN_COL_WIDTH).min(MAX_COL_WIDTH))
.collect()
} }
/// Compute the total row header width from the layout's row items.
pub fn compute_row_header_width(layout: &GridLayout) -> u16 {
let n_row_levels = layout.row_cats.len().max(1);
let data_row_items: Vec<&Vec<String>> = layout
.row_items
.iter()
.filter_map(|e| {
if let AxisEntry::DataItem(v) = e {
Some(v)
} else {
None
}
})
.collect();
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();
sub_widths.iter().sum()
}
/// Count how many columns fit starting from `col_offset` given the available width.
pub fn compute_visible_cols(col_widths: &[u16], row_header_width: u16, term_width: u16, col_offset: usize) -> usize {
// Account for grid border (2 chars)
let data_area_width = term_width.saturating_sub(2).saturating_sub(row_header_width);
let mut acc = 0u16;
let mut count = 0usize;
for ci in col_offset..col_widths.len() {
let w = col_widths[ci];
if acc + w > data_area_width {
break;
}
acc += w;
count += 1;
}
count.max(1)
}
// Re-export shared formatting functions
pub use crate::format::{format_f64, parse_number_format};
fn truncate(s: &str, max_width: usize) -> String { fn truncate(s: &str, max_width: usize) -> String {
let w = s.width(); let w = s.width();
if w <= max_width { if w <= max_width {
@ -637,7 +665,8 @@ mod tests {
let area = Rect::new(0, 0, width, height); let area = Rect::new(0, 0, width, height);
let mut buf = Buffer::empty(area); let mut buf = Buffer::empty(area);
let bufs = std::collections::HashMap::new(); let bufs = std::collections::HashMap::new();
GridWidget::new(model, &AppMode::Normal, "", &bufs, None).render(area, &mut buf); let layout = GridLayout::new(model, model.active_view());
GridWidget::new(model, &layout, &AppMode::Normal, "", &bufs, None).render(area, &mut buf);
buf buf
} }
@ -667,6 +696,7 @@ mod tests {
} }
/// Minimal model: Type on Row, Month on Column. /// Minimal model: Type on Row, Month on Column.
/// Every cell has a value so rows/cols survive pruning.
fn two_cat_model() -> Model { fn two_cat_model() -> Model {
let mut m = Model::new("Test"); let mut m = Model::new("Test");
m.add_category("Type").unwrap(); // → Row m.add_category("Type").unwrap(); // → Row
@ -679,6 +709,15 @@ mod tests {
c.add_item("Jan"); c.add_item("Jan");
c.add_item("Feb"); 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 m
} }
@ -738,10 +777,19 @@ mod tests {
#[test] #[test]
fn unset_cells_show_no_value() { 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)); let text = buf_text(&render(&m, 80, 24));
// No digits should appear in the data area if nothing is set // Should not contain large numbers that weren't set
// (Total row shows "0" — exclude that from this check by looking for non-zero)
assert!(!text.contains("100"), "unexpected '100' in:\n{text}"); assert!(!text.contains("100"), "unexpected '100' in:\n{text}");
} }
@ -873,6 +921,15 @@ mod tests {
} }
m.active_view_mut() m.active_view_mut()
.set_axis("Recipient", crate::view::Axis::Row); .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)); let text = buf_text(&render(&m, 80, 24));
// Multi-level row headers: category values shown separately, not joined with / // Multi-level row headers: category values shown separately, not joined with /
@ -936,6 +993,13 @@ mod tests {
} }
m.active_view_mut() m.active_view_mut()
.set_axis("Year", crate::view::Axis::Column); .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)); let text = buf_text(&render(&m, 80, 24));
// Multi-level column headers: category values shown separately, not joined with / // Multi-level column headers: category values shown separately, not joined with /

View File

@ -1,4 +1,5 @@
pub mod app; pub mod app;
pub mod cat_tree;
pub mod category_panel; pub mod category_panel;
pub mod effect; pub mod effect;
pub mod formula_panel; pub mod formula_panel;

View File

@ -36,7 +36,7 @@ impl<'a> Widget for ViewPanel<'a> {
let block = Block::default() let block = Block::default()
.borders(Borders::ALL) .borders(Borders::ALL)
.border_style(border_style) .border_style(border_style)
.title(" Views [Enter] switch [n]ew [d]elete "); .title(" Views ");
let inner = block.inner(area); let inner = block.inner(area);
block.render(area, buf); block.render(area, buf);

View File

@ -1,7 +1,17 @@
use std::rc::Rc;
use crate::model::cell::{CellKey, CellValue}; use crate::model::cell::{CellKey, CellValue};
use crate::model::Model; use crate::model::Model;
use crate::view::{Axis, View}; use crate::view::{Axis, View};
/// Extract (record_index, dim_name) from a synthetic records-mode CellKey.
/// Returns None for normal pivot-mode keys.
pub fn synthetic_record_info(key: &CellKey) -> Option<(usize, String)> {
let idx: usize = key.get("_Index")?.parse().ok()?;
let dim = key.get("_Dim")?.to_string();
Some((idx, dim))
}
/// One entry on a grid axis: either a visual group header or a data-item tuple. /// One entry on a grid axis: either a visual group header or a data-item tuple.
/// ///
/// `GroupHeader` entries are always visible so the user can see the group label /// `GroupHeader` entries are always visible so the user can see the group label
@ -30,8 +40,8 @@ pub struct GridLayout {
/// Categories on `Axis::None` — hidden, implicitly aggregated. /// Categories on `Axis::None` — hidden, implicitly aggregated.
pub none_cats: Vec<String>, pub none_cats: Vec<String>,
/// In records mode: the filtered cell list, one per row. /// In records mode: the filtered cell list, one per row.
/// None for normal pivot views. /// None for normal pivot views. Rc for cheap sharing.
pub records: Option<Vec<(CellKey, CellValue)>>, pub records: Option<Rc<Vec<(CellKey, CellValue)>>>,
} }
impl GridLayout { impl GridLayout {
@ -40,12 +50,11 @@ impl GridLayout {
pub fn with_frozen_records( pub fn with_frozen_records(
model: &Model, model: &Model,
view: &View, view: &View,
frozen_records: Option<Vec<(CellKey, CellValue)>>, frozen_records: Option<Rc<Vec<(CellKey, CellValue)>>>,
) -> Self { ) -> Self {
let mut layout = Self::new(model, view); let mut layout = Self::new(model, view);
if layout.is_records_mode() { if layout.is_records_mode() {
if let Some(records) = frozen_records { if let Some(records) = frozen_records {
// Re-build with the frozen records instead
let row_items: Vec<AxisEntry> = (0..records.len()) let row_items: Vec<AxisEntry> = (0..records.len())
.map(|i| AxisEntry::DataItem(vec![i.to_string()])) .map(|i| AxisEntry::DataItem(vec![i.to_string()]))
.collect(); .collect();
@ -53,6 +62,9 @@ impl GridLayout {
layout.records = Some(records); layout.records = Some(records);
} }
} }
if view.prune_empty {
layout.prune_empty(model);
}
layout layout
} }
@ -152,10 +164,11 @@ impl GridLayout {
.map(|i| AxisEntry::DataItem(vec![i.to_string()])) .map(|i| AxisEntry::DataItem(vec![i.to_string()]))
.collect(); .collect();
// Synthesize col items: one per category + "Value" // Synthesize col items: one per non-virtual category + "Value"
let cat_names: Vec<String> = model let cat_names: Vec<String> = model
.category_names() .category_names()
.into_iter() .into_iter()
.filter(|c| !c.starts_with('_'))
.map(String::from) .map(String::from)
.collect(); .collect();
let mut col_items: Vec<AxisEntry> = cat_names let mut col_items: Vec<AxisEntry> = cat_names
@ -171,7 +184,7 @@ impl GridLayout {
row_items, row_items,
col_items, col_items,
none_cats, none_cats,
records: Some(records), records: Some(Rc::new(records)),
} }
} }
@ -195,6 +208,104 @@ 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] = 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(Rc::new(new_records));
}
}
/// Whether this layout is in records mode. /// Whether this layout is in records mode.
pub fn is_records_mode(&self) -> bool { pub fn is_records_mode(&self) -> bool {
self.records.is_some() self.records.is_some()
@ -246,18 +357,57 @@ impl GridLayout {
.unwrap_or_default() .unwrap_or_default()
} }
/// Resolve the display string for a synthetic records-mode CellKey.
/// Returns None for non-synthetic (pivot) keys.
pub fn resolve_display(&self, key: &CellKey) -> Option<String> {
let (idx, dim) = synthetic_record_info(key)?;
let records = self.records.as_ref()?;
let (orig_key, value) = records.get(idx)?;
if dim == "Value" {
Some(value.to_string())
} else {
Some(orig_key.get(&dim).unwrap_or("").to_string())
}
}
/// Unified display text for a cell at (row, col). Handles both pivot and
/// records modes. In pivot mode, evaluates and formats the cell value.
/// In records mode, resolves via the frozen records snapshot.
pub fn display_text(
&self,
model: &Model,
row: usize,
col: usize,
fmt_comma: bool,
fmt_decimals: u8,
) -> String {
if self.is_records_mode() {
self.records_display(row, col).unwrap_or_default()
} else {
self.cell_key(row, col)
.and_then(|key| model.evaluate_aggregated(&key, &self.none_cats))
.map(|v| crate::format::format_value(Some(&v), fmt_comma, fmt_decimals))
.unwrap_or_default()
}
}
/// Build the CellKey for the data cell at (row, col), including the active /// Build the CellKey for the data cell at (row, col), including the active
/// page-axis filter. Returns None if row or col is out of bounds. /// page-axis filter. Returns None if row or col is out of bounds.
/// In records mode: returns the real underlying CellKey when the column /// In records mode: returns a synthetic `(_Index, _Dim)` key for every column.
/// is "Value" (editable); returns None for coord columns (read-only).
pub fn cell_key(&self, row: usize, col: usize) -> Option<CellKey> { pub fn cell_key(&self, row: usize, col: usize) -> Option<CellKey> {
if let Some(records) = &self.records { if self.records.is_some() {
// Records mode: only the Value column maps to a real, editable cell. let records = self.records.as_ref().unwrap();
if self.col_label(col) == "Value" { if row >= records.len() {
return records.get(row).map(|(k, _)| k.clone());
} else {
return None; return None;
} }
let col_label = self.col_label(col);
if col_label.is_empty() {
return None;
}
return Some(CellKey::new(vec![
("_Index".to_string(), row.to_string()),
("_Dim".to_string(), col_label),
]));
} }
let row_item = self let row_item = self
.row_items .row_items
@ -421,7 +571,7 @@ fn cross_product(model: &Model, view: &View, cats: &[String]) -> Vec<AxisEntry>
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{AxisEntry, GridLayout}; use super::{synthetic_record_info, AxisEntry, GridLayout};
use crate::model::cell::{CellKey, CellValue}; use crate::model::cell::{CellKey, CellValue};
use crate::model::Model; use crate::model::Model;
use crate::view::Axis; use crate::view::Axis;
@ -450,6 +600,30 @@ mod tests {
m 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] #[test]
fn records_mode_activated_when_index_and_dim_on_axes() { fn records_mode_activated_when_index_and_dim_on_axes() {
let mut m = records_model(); let mut m = records_model();
@ -462,40 +636,66 @@ mod tests {
} }
#[test] #[test]
fn records_mode_cell_key_editable_for_value_column() { fn records_mode_cell_key_returns_synthetic_for_all_columns() {
let mut m = records_model(); let mut m = records_model();
let v = m.active_view_mut(); let v = m.active_view_mut();
v.set_axis("_Index", Axis::Row); v.set_axis("_Index", Axis::Row);
v.set_axis("_Dim", Axis::Column); v.set_axis("_Dim", Axis::Column);
let layout = GridLayout::new(&m, m.active_view()); let layout = GridLayout::new(&m, m.active_view());
assert!(layout.is_records_mode()); assert!(layout.is_records_mode());
// Find the "Value" column index
let cols: Vec<String> = (0..layout.col_count()).map(|i| layout.col_label(i)).collect(); let cols: Vec<String> = (0..layout.col_count()).map(|i| layout.col_label(i)).collect();
// All columns return synthetic keys
let value_col = cols.iter().position(|c| c == "Value").unwrap(); let value_col = cols.iter().position(|c| c == "Value").unwrap();
// cell_key should be Some for Value column let key = layout.cell_key(0, value_col).unwrap();
let key = layout.cell_key(0, value_col); assert_eq!(key.get("_Index"), Some("0"));
assert!(key.is_some(), "Value column should be editable"); assert_eq!(key.get("_Dim"), Some("Value"));
// cell_key should be None for coord columns
let region_col = cols.iter().position(|c| c == "Region").unwrap(); let region_col = cols.iter().position(|c| c == "Region").unwrap();
assert!( let key = layout.cell_key(0, region_col).unwrap();
layout.cell_key(0, region_col).is_none(), assert_eq!(key.get("_Index"), Some("0"));
"Region column should not be editable" assert_eq!(key.get("_Dim"), Some("Region"));
);
} }
#[test] #[test]
fn records_mode_cell_key_maps_to_real_cell() { fn records_mode_resolve_display_returns_values() {
let mut m = records_model(); let mut m = records_model();
let v = m.active_view_mut(); let v = m.active_view_mut();
v.set_axis("_Index", Axis::Row); v.set_axis("_Index", Axis::Row);
v.set_axis("_Dim", Axis::Column); v.set_axis("_Dim", Axis::Column);
let layout = GridLayout::new(&m, m.active_view()); let layout = GridLayout::new(&m, m.active_view());
let cols: Vec<String> = (0..layout.col_count()).map(|i| layout.col_label(i)).collect(); let cols: Vec<String> = (0..layout.col_count()).map(|i| layout.col_label(i)).collect();
// Value column resolves to the cell value
let value_col = cols.iter().position(|c| c == "Value").unwrap(); let value_col = cols.iter().position(|c| c == "Value").unwrap();
// The CellKey at (0, Value) should look up a real cell value
let key = layout.cell_key(0, value_col).unwrap(); let key = layout.cell_key(0, value_col).unwrap();
let val = m.evaluate(&key); let display = layout.resolve_display(&key);
assert!(val.is_some(), "cell_key should resolve to a real cell"); assert!(display.is_some(), "Value column should resolve");
// Category column resolves to the coordinate value
let region_col = cols.iter().position(|c| c == "Region").unwrap();
let key = layout.cell_key(0, region_col).unwrap();
let display = layout.resolve_display(&key).unwrap();
assert!(!display.is_empty(), "Region column should resolve to a value");
}
#[test]
fn synthetic_record_info_returns_none_for_pivot_keys() {
let key = CellKey::new(vec![
("Region".to_string(), "East".to_string()),
("Product".to_string(), "Shoes".to_string()),
]);
assert!(synthetic_record_info(&key).is_none());
}
#[test]
fn synthetic_record_info_extracts_index_and_dim() {
let key = CellKey::new(vec![
("_Index".to_string(), "3".to_string()),
("_Dim".to_string(), "Region".to_string()),
]);
let (idx, dim) = synthetic_record_info(&key).unwrap();
assert_eq!(idx, 3);
assert_eq!(dim, "Region");
} }
fn coord(pairs: &[(&str, &str)]) -> CellKey { fn coord(pairs: &[(&str, &str)]) -> CellKey {

View File

@ -3,5 +3,5 @@ pub mod layout;
pub mod types; pub mod types;
pub use axis::Axis; pub use axis::Axis;
pub use layout::{AxisEntry, GridLayout}; pub use layout::{synthetic_record_info, AxisEntry, GridLayout};
pub use types::View; pub use types::View;

View File

@ -4,6 +4,10 @@ use std::collections::{HashMap, HashSet};
use super::axis::Axis; use super::axis::Axis;
fn default_prune() -> bool {
true
}
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct View { pub struct View {
pub name: String, pub name: String,
@ -17,6 +21,9 @@ pub struct View {
pub collapsed_groups: HashMap<String, HashSet<String>>, pub collapsed_groups: HashMap<String, HashSet<String>>,
/// Number format string (e.g. ",.0f" for comma-separated integer) /// Number format string (e.g. ",.0f" for comma-separated integer)
pub number_format: String, 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 /// Scroll offset for grid
pub row_offset: usize, pub row_offset: usize,
pub col_offset: usize, pub col_offset: usize,
@ -33,6 +40,7 @@ impl View {
hidden_items: HashMap::new(), hidden_items: HashMap::new(),
collapsed_groups: HashMap::new(), collapsed_groups: HashMap::new(),
number_format: ",.0".to_string(), number_format: ",.0".to_string(),
prune_empty: false,
row_offset: 0, row_offset: 0,
col_offset: 0, col_offset: 0,
selected: (0, 0), selected: (0, 0),
@ -41,16 +49,47 @@ impl View {
pub fn on_category_added(&mut self, cat_name: &str) { pub fn on_category_added(&mut self, cat_name: &str) {
if !self.category_axes.contains_key(cat_name) { 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. // 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('_') { let axis = if cat_name.starts_with('_') {
Axis::None Axis::None
} else { } else {
let rows = self.categories_on(Axis::Row).len(); let regular_rows: Vec<String> = self
let cols = self.categories_on(Axis::Column).len(); .categories_on(Axis::Row)
if rows == 0 { .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 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 Axis::Column
} else { } else {
Axis::Page 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) { pub fn set_axis(&mut self, cat_name: &str, axis: Axis) {
if let Some(a) = self.category_axes.get_mut(cat_name) { if let Some(a) = self.category_axes.get_mut(cat_name) {
*a = axis; *a = axis;